Files
Ghom 8c534a521e Maintenance PDA themes are added to roundstart PDAs on future rounds as well once installed (#92983)
## About The Pull Request
I'm making the ordeal of finding a maint disk (or buying blackmarket
bootleg disk) with a theme in it a slightly more rewarding experience,
while sticking to the concept that it's something you've to find, unlike
default PDA themes.

This PR also proves to be an opportunity to put the progress tab that I
coded a year ago for the 'Fishdex' to good use.

TODO:
~~Refactor preferences to allow specific choices to be shown/hidden
depending on whether the player meets a defined criteria, otherwise
you'll have to do it manually every round, which is lame~~

- [x] Make some simple ui icons associated with each unlockable theme to
be shown in the cheevo progress tab

- [x] Code to validate deserialized DB values, in the remote case that
any theme is removed in the future, as well as unit test code for any
non-abstract theme without ID

- [x] Add sound cue and chat feedback when unlocking a theme (or when
fishing a new kind of fish for the first time, like, the code's similar)

- [x] Test all of this

## Why It's Good For The Game
These themes are basically an end in itself, and I understand the reason
behind their existence is to make for some cute, little maint loot, but
relegating it to chance of finding a disk somewhere in maintenance,
**every single round** really rots whatever little substance this purely
cosmetic feature already has.

## Changelog

🆑
add: Once installed, special PDA themes from maintenance disks will be
present on your roundstart PDA on future rounds (Sadly I couldn't figure
out a way to add those to the preferences UI yet). You can check which
PDA themes you've unlocked in the Progress tab of the achievements UI.
/🆑
2026-01-16 17:18:03 -05:00

368 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()
///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
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
/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
///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, progress_string)
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
if(!progress_string)
return
to_chat(user, get_progress_string(progress_string))
var/sound/sound_pref = LAZYACCESS(GLOB.achievement_sounds, user.client.prefs.read_preference(/datum/preference/choiced/sound_achievement))
if(!sound_pref)
return
//send a copy cheevo sound but with its volume halved, since you're making a small progress and not unlocking an entire achievement.
var/sound/sound_to_send = sound(sound_pref.file, volume = sound_pref.volume / 2)
if(sound_to_send)
SEND_SOUND(user, sound_to_send)
///Compose the string to send to the user's chat when the progress is made. This one here is generic, but you should override it really.
/datum/award/score/progress/proc/get_progress_string(progress_string)
return span_greenannounce("New progress made for [name]: <B>[progress_string]!</B>")
/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 checked on validate_entries() to see if the entries are valid.
var/list/validated_results = unique_list(results)
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)
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)