mirror of
https://github.com/Bubberstation/Bubberstation.git
synced 2026-07-17 11:05:16 +01:00
8c534a521e
## 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. /🆑
368 lines
14 KiB
Plaintext
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)
|