Files
Bubberstation/code/datums/achievements/_awards.dm
Ghom e6253c7812 Adds a score for all species of fish that you've caught. (#86049)
## About The Pull Request
I'm adding a score that tracks which types of fish you've caught across
multiple rounds. To do so, I had to add a new score subtype that manages
the score value not being a number. Thankfully the achievement code is
fairly flexible so not a whole lot had to be done, although I've to add
a new column to the achievements table in the DB, because the 'value' is
for integers, while we need one for text strings ~~(the contents of the
list are converted to text with a delimiter before being saved cuz I'm
not sure if and how our DM slash SQL integration handles using lists
directly and I don't want to waste time finding it out)~~.

EDIT: It's mostly done beside the reviews that are going to point out
things that need to be changed. The UI changes are done. It's time for
reviews.

Here are screenshots of the UI with all fish still uncatched beside one
(I've since then the typo on its name and removed an extra zero from the
index number, as well as a nit with the spacing between cells):

![immagine](https://github.com/user-attachments/assets/a1dcfeb6-6d26-461e-aaa1-97c619f5cbfa)

![immagine](https://github.com/user-attachments/assets/768f6621-c992-4932-9bca-979dd1e43d6f)


## Why It's Good For The Game
We have about dozens over dozens of different fish in the game now, many
of which are just fluff anyway. It's getting to the point it's perhaps
doable to add a score or something to be a braggard about.

## Changelog
🆑
add: Added a new score that keeps track of all different fish that
you've caught between shifts.
server: Added a new schema table to store the aforementioned entries and
the ckeys associated to them, with an additional timestamp column.
/🆑
2024-11-04 13:48:25 -08:00

360 lines
14 KiB
Plaintext

/datum/award
///Name of the achievement, If null it won't show up in the achievement browser. (Handy for inheritance trees)
var/name
var/desc = "You did it."
///The dmi icon file that holds the award's icon state.
var/icon = ACHIEVEMENTS_SET
///The icon state for this award.
var/icon_state = "default"
var/category = "Normal"
///What ID do we use in db, limited to 32 characters
var/database_id
//Bump this up if you're changing outdated table identifier and/or achievement type
var/achievement_version = 2
///This proc loads the achievement data from the hub.
/datum/award/proc/load(datum/achievement_data/holder)
if(!SSdbcore.Connect())
return default_value()
if(!holder.owner_ckey || !database_id || !name)
return default_value()
var/value = parse_value(get_raw_value(holder.owner_ckey))
holder.original_cached_data[type] = holder.data[type] = value
return value
//Proc that returns a value upon db connection failure, in case we need different instances too.
/datum/award/proc/default_value()
return FALSE
/datum/award/proc/unlock(mob/user, datum/achievement_data/holder, value = 1)
return
/datum/award/proc/on_achievement_data_init(datum/achievement_data/holder, database_value)
holder.original_cached_data[type] = holder.data[type] = parse_value(database_value)
///This saves the changed data to the hub.
/datum/award/proc/get_changed_rows(datum/achievement_data/holder)
if(!database_id || !holder.owner_ckey || !name)
return
return list(
"ckey" = holder.owner_ckey,
"achievement_key" = database_id,
"value" = holder.data[type],
)
/datum/award/proc/get_metadata_row()
return list(
"achievement_key" = database_id,
"achievement_version" = achievement_version,
"achievement_type" = "award",
"achievement_name" = name,
"achievement_description" = desc,
)
///Get raw numerical achievement value from the database
/datum/award/proc/get_raw_value(key)
var/datum/db_query/Q = SSdbcore.NewQuery(
"SELECT value FROM [format_table_name("achievements")] WHERE ckey = :ckey AND achievement_key = :achievement_key",
list("ckey" = key, "achievement_key" = database_id)
)
if(!Q.Execute(async = TRUE))
qdel(Q)
return 0
var/result = 0
if(Q.NextRow())
result = text2num(Q.item[1])
qdel(Q)
return result
//Should return sanitized value for achievement cache
/datum/award/proc/parse_value(raw_value)
return default_value()
///Can be overridden for achievement specific events
/datum/award/proc/on_unlock(mob/user)
return
///returns additional ui data for the Check Achievements menu
/datum/award/proc/get_ui_data(list/award_data, datum/achievement_data/holder)
return list(
"score" = FALSE,
"achieve_info" = null,
"achieve_tooltip" = null,
)
///Achievements are one-off awards for usually doing cool things.
/datum/award/achievement
desc = "Achievement for epic people"
icon_state = "" // This should warn contributors that do not declare an icon when contributing new achievements.
///How many players have earned this achievement
var/times_achieved = 0
/datum/award/achievement/unlock(mob/user, datum/achievement_data/holder, value = 1)
if(holder.data[type]) //You already unlocked it so don't bother running the unlock proc
return
holder.data[type] = TRUE
on_unlock(user)
/datum/award/achievement/get_metadata_row()
. = ..()
.["achievement_type"] = "achievement"
/datum/award/achievement/get_ui_data(list/award_data, datum/achievement_data/holder)
. = ..()
.["achieve_info"] = "Unlocked by [times_achieved] players so far"
if(!SSachievements.most_unlocked_achievement)
.["achieve_tooltip"] = "No achievement has been unlocked yet. Be the first today!"
return
if(SSachievements.most_unlocked_achievement == src)
.["achieve_tooltip"] = "This is the most unlocked achievement"
return
var/percent = FLOOR(times_achieved / SSachievements.most_unlocked_achievement.times_achieved * 100, 0.01)
.["achieve_tooltip"] = "[(times_achieved && !percent) ? "Less than 0.01" : percent]% compared to the achievement unlocked by the most players: \"[SSachievements.most_unlocked_achievement.name])\""
/datum/award/achievement/parse_value(raw_value)
return raw_value > 0
/datum/award/achievement/on_unlock(mob/user)
. = ..()
to_chat(user, span_greenannounce("<B>Achievement unlocked: [name]!</B>"))
var/sound/sound_to_send = LAZYACCESS(GLOB.achievement_sounds, user.client.prefs.read_preference(/datum/preference/choiced/sound_achievement))
if(sound_to_send)
SEND_SOUND(user, sound_to_send)
times_achieved++
if(SSachievements.most_unlocked_achievement?.times_achieved < times_achieved)
SSachievements.most_unlocked_achievement = src
var/datum/achievement_report/new_report = new /datum/achievement_report()
new_report.winner = "[(user.real_name == user.name) ? user.real_name : "[user.real_name], as [user.name]"]"
new_report.cheevo = name
if(user.ckey)
new_report.winner_key = user.ckey
else
stack_trace("[name] achievement earned by [user], who did not have a ckey.")
new_report.award_location = "[get_area_name(user)]"
GLOB.achievements_unlocked += new_report
///Scores are for leaderboarded things, such as killcount of a specific boss
/datum/award/score
desc = "you did it sooo many times."
category = "Scores"
var/track_high_scores = TRUE
var/list/high_scores = list()
/datum/award/score/New()
. = ..()
if(track_high_scores)
LoadHighScores()
/datum/award/score/default_value()
return 0
/datum/award/score/get_metadata_row()
. = ..()
.["achievement_type"] = "score"
/datum/award/score/unlock(mob/user, datum/achievement_data/holder, value = 1)
holder.data[type] += value
/datum/award/score/get_ui_data(list/award_data, datum/achievement_data/holder)
. = ..()
.["score"] = TRUE
/datum/award/score/proc/LoadHighScores()
var/datum/db_query/Q = SSdbcore.NewQuery(
"SELECT ckey,value FROM [format_table_name("achievements")] WHERE achievement_key = :achievement_key ORDER BY value DESC LIMIT 50",
list("achievement_key" = database_id)
)
if(!Q.Execute(async = TRUE))
qdel(Q)
return
else
while(Q.NextRow())
var/key = Q.item[1]
var/score = text2num(Q.item[2])
high_scores += list(list("ckey" = key, "value" = score))
qdel(Q)
/datum/award/score/parse_value(raw_value)
return isnum(raw_value) ? raw_value : 0
///Defining this here 'cause it's the first score a player should see in the Scores category.
/datum/award/score/achievements_score
name = "Achievements Unlocked"
desc = "Don't worry, metagaming is all that matters."
icon_state = "elephant" //Obey the reference
database_id = ACHIEVEMENTS_SCORE
/datum/award/score/achievements_score/get_ui_data(list/award_data, datum/achievement_data/holder)
. = ..()
var/datum/db_query/get_unlocked_count = SSdbcore.NewQuery(
"SELECT COUNT(m.achievement_key) FROM [format_table_name("achievements")] AS a JOIN [format_table_name("achievement_metadata")] m ON a.achievement_key = m.achievement_key AND m.achievement_type = 'Achievement' WHERE a.ckey = :ckey",
list("ckey" = holder.owner_ckey)
)
if(!get_unlocked_count.Execute(async = TRUE))
qdel(get_unlocked_count)
.["value"] = 0
return .
if(get_unlocked_count.NextRow())
.["value"] = text2num(get_unlocked_count.item[1])
qdel(get_unlocked_count)
return .
/datum/award/score/achievements_score/LoadHighScores()
var/datum/db_query/get_unlocked_highscore = SSdbcore.NewQuery(
"SELECT ckey, COUNT(ckey) AS c FROM [format_table_name("achievements")] AS a JOIN [format_table_name("achievement_metadata")] m ON a.achievement_key = m.achievement_key AND m.achievement_type = 'Achievement' GROUP BY ckey ORDER BY c DESC LIMIT 50",
)
if(!get_unlocked_highscore.Execute(async = TRUE))
qdel(get_unlocked_highscore)
return
else
while(get_unlocked_highscore.NextRow())
var/key = get_unlocked_highscore.item[1]
var/score = text2num(get_unlocked_highscore.item[2])
high_scores += list(list("ckey" = key, "value" = score))
qdel(get_unlocked_highscore)
/datum/award/score/achievements_score/on_achievement_data_init(datum/achievement_data/holder, database_value)
var/datum/db_query/get_unlocked_load = SSdbcore.NewQuery(
"SELECT COUNT(m.achievement_key) FROM [format_table_name("achievements")] AS a JOIN [format_table_name("achievement_metadata")] m ON a.achievement_key = m.achievement_key AND m.achievement_type = 'Achievement' WHERE a.ckey = :ckey",
list("ckey" = holder.owner_ckey)
)
if(!get_unlocked_load.Execute(async = TRUE))
qdel(get_unlocked_load)
return
if(get_unlocked_load.NextRow())
holder.data[type] = text2num(get_unlocked_load.item[1]) || 0
holder.original_cached_data[type] = 0
qdel(get_unlocked_load)
/**
* A subtype of score linked to a schema table containing objects the player has caught, made, found or otherwise achieved.
* The value of the score should equal to the length of objects that have been achieved.
* It also has an unique tab in the UI that lets you review the progress.
*/
/datum/award/score/progress
/**
* When get_changed_rows is called, this list gets filled with the entries to be mass-inserted
* in the table associated with this progress score.
*/
VAR_FINAL/list/changed_entries = list()
/datum/award/score/progress/New()
. = ..()
if(!get_table())
CRASH("get_table() wasn't set for [type]!")
RegisterSignal(SSachievements, COMSIG_ACHIEVEMENTS_SAVED_TO_DB, PROC_REF(insert_entries))
/**
* Getter proc for the table used to save the entries - ckey association.
* so we an be extra-safe that data won't be ever inserted in the wrong table.
* Remember to set this
*/
/datum/award/score/progress/proc/get_table()
return
/datum/award/score/progress/vv_edit_var(var_name, var_value)
//These variable is associated for sql queries. We can't allow it to be edited.
if(var_name == NAMEOF(src, changed_entries))
return FALSE
return ..()
/datum/award/score/progress/unlock(mob/user, datum/achievement_data/holder, value)
var/list/entries = holder.data[type]
if(!value)
CRASH("empty value used as argument to progress this score award.")
if(value in entries)
return
// This ensures that the original list and the new won't be the same any longer.
// So that it'll pass the not-equal if statement and be saved in the db.
if(entries == holder.original_cached_data[type])
entries = entries?.Copy() || list()
holder.data[type] = entries
entries |= value
/datum/award/score/progress/load(datum/achievement_data/holder)
var/list/results = ..()
return validate_loaded_data(holder, results)
/datum/award/score/progress/on_achievement_data_init(datum/achievement_data/holder, database_value)
var/list/results = parse_value(get_raw_value(holder.owner_ckey))
validate_loaded_data(holder, results)
/datum/award/score/progress/proc/validate_loaded_data(datum/achievement_data/holder, list/results)
holder.original_cached_data[type] = holder.data[type] = results
if(!length(results))
return results
///This list will be populated on validate_entries()
var/list/validated_results = list()
if(!validate_entries(results, validated_results))
holder.data[type] = validated_results
return validated_results
///Along with the changed rows for the main table, this also populates changed_entries with the entries list
/datum/award/score/progress/get_changed_rows(datum/achievement_data/holder)
if(!database_id || !holder || !name || !get_table())
return
var/list/entries = holder.data[type]
for(var/entry in (entries - holder.original_cached_data[type]))
changed_entries += list(list("ckey" = holder.owner_ckey, "progress_entry" = entry))
return list(
"ckey" = holder.owner_ckey,
"achievement_key" = database_id,
"value" = length(entries),
)
/datum/award/score/progress/get_ui_data(list/award_data, datum/achievement_data/holder)
. = ..()
award_data["value"] = length(holder.data[type])
///We don't care much about the default value, which is only used for high scores. Instead, we get the entries string from the db.
/datum/award/score/progress/get_raw_value(key)
var/list/entries = list()
var/datum/db_query/get_entries_load = SSdbcore.NewQuery(
"SELECT progress_entry FROM [format_table_name(get_table())] WHERE ckey = :ckey",
list("ckey" = key)
)
if(!get_entries_load.Execute())
qdel(get_entries_load)
return entries
while(get_entries_load.NextRow())
entries |= get_entries_load.item[1]
qdel(get_entries_load)
return entries
/datum/award/score/progress/parse_value(raw_value)
return islist(raw_value) ? raw_value : list()
//Proc that returns a value upon db connection failure, in case we need to new instances too
/datum/award/score/progress/default_value()
return list()
/**
* Validates the list of entries after it's parsed.
* If TRUE is returned (entries list is valid), 'entries' will be stored in both
* original_cached_data[type] and data[type] of the achievements holder.
* Otherwise data[type] will be the new validated_entries list,
* ensuring that the original data and the new data aren't the same, allowing the new data will be saved.
*/
/datum/award/score/progress/proc/validate_entries(list/entries, list/validated_entries)
validated_entries = unique_list(entries)
return length(validated_entries) == length(entries)
////Returns a list of data that we can use to make an index of contents that progress this award/score.
/datum/award/score/progress/proc/get_progress(datum/achievement_data/holder)
CRASH("get_progress() undefined for [type]")
///Called once the achievements are saved in the DB, since we also have to insert the entries in the associated table.
/datum/award/score/progress/proc/insert_entries(datum/source)
SIGNAL_HANDLER
if(!length(changed_entries))
return
INVOKE_ASYNC(SSdbcore, TYPE_PROC_REF(/datum/controller/subsystem/dbcore, MassInsert), format_table_name(get_table()), changed_entries, duplicate_key = TRUE)