#define POPCOUNT_SURVIVORS "survivors" //Not dead at roundend
#define POPCOUNT_ESCAPEES "escapees" //Not dead and on centcom/shuttles marked as escaped
#define POPCOUNT_SHUTTLE_ESCAPEES "shuttle_escapees" //Emergency shuttle only.
#define PERSONAL_LAST_ROUND "personal last round"
#define SERVER_LAST_ROUND "server last round"
GLOBAL_LIST_INIT(achievements_unlocked, list())
/datum/controller/subsystem/ticker/proc/gather_roundend_feedback()
gather_antag_data()
record_nuke_disk_location()
var/json_file = file("[GLOB.log_directory]/round_end_data.json")
// All but npcs sublists and ghost category contain only mobs with minds
var/list/file_data = list("escapees" = list("humans" = list(), "silicons" = list(), "others" = list(), "npcs" = list()), "abandoned" = list("humans" = list(), "silicons" = list(), "others" = list(), "npcs" = list()), "ghosts" = list(), "additional data" = list())
var/num_survivors = 0 //Count of non-brain non-eye mobs with mind that are alive
var/num_escapees = 0 //Above and on centcom z
var/num_shuttle_escapees = 0 //Above and on escape shuttle
var/list/area/shuttle_areas
if(SSshuttle?.emergency)
shuttle_areas = SSshuttle.emergency.shuttle_areas
for(var/mob/M in GLOB.mob_list)
var/list/mob_data = list()
if(isnewplayer(M))
continue
var/escape_status = "abandoned" //default to abandoned
var/category = "npcs" //Default to simple count only bracket
var/count_only = TRUE //Count by name only or full info
mob_data["name"] = M.name
if(M.mind)
count_only = FALSE
mob_data["ckey"] = M.mind.key
if(M.stat != DEAD && !isbrain(M) && !iseyemob(M))
num_survivors++
if(EMERGENCY_ESCAPED_OR_ENDGAMED && (M.onCentCom() || M.onSyndieBase()))
num_escapees++
escape_status = "escapees"
if(shuttle_areas[get_area(M)])
num_shuttle_escapees++
if(isliving(M))
var/mob/living/L = M
mob_data["location"] = get_area(L)
mob_data["health"] = L.health
if(ishuman(L))
var/mob/living/carbon/human/H = L
category = "humans"
if(H.mind)
mob_data["job"] = H.mind.assigned_role.title
else
mob_data["job"] = "Unknown"
mob_data["species"] = H.dna.species.name
else if(issilicon(L))
category = "silicons"
if(isAI(L))
mob_data["module"] = "AI"
else if(ispAI(L))
mob_data["module"] = "pAI"
else if(iscyborg(L))
var/mob/living/silicon/robot/R = L
mob_data["module"] = (R.model ? R.model.name : "Null Model")
else
category = "others"
mob_data["typepath"] = M.type
//Ghosts don't care about minds, but we want to retain ckey data etc
if(isobserver(M))
count_only = FALSE
escape_status = "ghosts"
if(!M.mind)
mob_data["ckey"] = M.key
category = null //ghosts are one list deep
//All other mindless stuff just gets counts by name
if(count_only)
var/list/npc_nest = file_data["[escape_status]"]["npcs"]
var/name_to_use = initial(M.name)
if(ishuman(M))
name_to_use = "Unknown Human" //Monkeymen and other mindless corpses
if(npc_nest.Find(name_to_use))
file_data["[escape_status]"]["npcs"][name_to_use] += 1
else
file_data["[escape_status]"]["npcs"][name_to_use] = 1
else
//Mobs with minds and ghosts get detailed data
if(category)
var/pos = length(file_data["[escape_status]"]["[category]"]) + 1
file_data["[escape_status]"]["[category]"]["[pos]"] = mob_data
else
var/pos = length(file_data["[escape_status]"]) + 1
file_data["[escape_status]"]["[pos]"] = mob_data
var/datum/station_state/end_state = new /datum/station_state()
end_state.count()
var/station_integrity = min(PERCENT(GLOB.start_state.score(end_state)), 100)
file_data["additional data"]["station integrity"] = station_integrity
WRITE_FILE(json_file, json_encode(file_data))
SSblackbox.record_feedback("nested tally", "round_end_stats", num_survivors, list("survivors", "total"))
SSblackbox.record_feedback("nested tally", "round_end_stats", num_escapees, list("escapees", "total"))
SSblackbox.record_feedback("nested tally", "round_end_stats", GLOB.joined_player_list.len, list("players", "total"))
SSblackbox.record_feedback("nested tally", "round_end_stats", GLOB.joined_player_list.len - num_survivors, list("players", "dead"))
. = list()
.[POPCOUNT_SURVIVORS] = num_survivors
.[POPCOUNT_ESCAPEES] = num_escapees
.[POPCOUNT_SHUTTLE_ESCAPEES] = num_shuttle_escapees
.["station_integrity"] = station_integrity
/datum/controller/subsystem/ticker/proc/gather_antag_data()
var/team_gid = 1
var/list/team_ids = list()
for(var/datum/antagonist/A in GLOB.antagonists)
if(!A.owner)
continue
var/list/antag_info = list()
antag_info["key"] = A.owner.key
antag_info["name"] = A.owner.name
antag_info["antagonist_type"] = A.type
antag_info["antagonist_name"] = A.name //For auto and custom roles
antag_info["objectives"] = list()
antag_info["team"] = list()
var/datum/team/T = A.get_team()
if(T)
antag_info["team"]["type"] = T.type
antag_info["team"]["name"] = T.name
if(!team_ids[T])
team_ids[T] = team_gid++
antag_info["team"]["id"] = team_ids[T]
if(A.objectives.len)
for(var/datum/objective/O in A.objectives)
var/result = O.check_completion() ? "SUCCESS" : "FAIL"
antag_info["objectives"] += list(list("objective_type"=O.type,"text"=O.explanation_text,"result"=result))
SSblackbox.record_feedback("associative", "antagonists", 1, antag_info)
/datum/controller/subsystem/ticker/proc/record_nuke_disk_location()
var/disk_count = 1
for(var/obj/item/disk/nuclear/nuke_disk as anything in SSpoints_of_interest.real_nuclear_disks)
var/list/data = list()
var/turf/disk_turf = get_turf(nuke_disk)
if(disk_turf)
data["x"] = disk_turf.x
data["y"] = disk_turf.y
data["z"] = disk_turf.z
var/atom/outer = get_atom_on_turf(nuke_disk, /mob/living, TRUE)
if(outer != nuke_disk)
if(isliving(outer))
var/mob/living/disk_holder = outer
data["holder"] = disk_holder.real_name
else
data["holder"] = outer.name
SSblackbox.record_feedback("associative", "roundend_nukedisk", disk_count, data)
disk_count++
/datum/controller/subsystem/ticker/proc/gather_newscaster()
var/json_file = file("[GLOB.log_directory]/newscaster.json")
var/list/file_data = list()
var/pos = 1
for(var/datum/feed_channel/channel as anything in GLOB.news_network.network_channels)
if(!istype(channel))
stack_trace("Non-channel in newscaster channel list")
continue
file_data["[pos]"] = list("channel name" = "[channel.channel_name]", "author" = "[channel.author]", "censored" = channel.censored ? 1 : 0, "author censored" = channel.author_censor ? 1 : 0, "messages" = list())
for(var/M in channel.messages)
var/datum/feed_message/message = M
if(!istype(message))
stack_trace("Non-message in newscaster channel messages list")
continue
var/list/comment_data = list()
for(var/C in message.comments)
var/datum/feed_comment/comment = C
if(!istype(comment))
stack_trace("Non-message in newscaster message comments list")
continue
comment_data += list(list("author" = "[comment.author]", "time stamp" = "[comment.time_stamp]", "body" = "[comment.body]"))
file_data["[pos]"]["messages"] += list(list("author" = "[message.author]", "time stamp" = "[message.time_stamp]", "censored" = message.body_censor ? 1 : 0, "author censored" = message.author_censor ? 1 : 0, "photo file" = "[message.photo_file]", "photo caption" = "[message.caption]", "body" = "[message.body]", "comments" = comment_data))
pos++
if(GLOB.news_network.wanted_issue.active)
file_data["wanted"] = list("author" = "[GLOB.news_network.wanted_issue.scanned_user]", "criminal" = "[GLOB.news_network.wanted_issue.criminal]", "description" = "[GLOB.news_network.wanted_issue.body]", "photo file" = "[GLOB.news_network.wanted_issue.photo_file]")
WRITE_FILE(json_file, json_encode(file_data))
///Handles random hardcore point rewarding if it applies.
/datum/controller/subsystem/ticker/proc/HandleRandomHardcoreScore(client/player_client)
if(!ishuman(player_client?.mob))
return FALSE
var/mob/living/carbon/human/human_mob = player_client.mob
if(!human_mob.hardcore_survival_score) ///no score no glory
return FALSE
if(human_mob.mind && (length(human_mob.mind.antag_datums) > 0))
for(var/datum/antagonist/antag_datums as anything in human_mob.mind.antag_datums)
if(!antag_datums.hardcore_random_bonus) //don't give bonuses to dumb stuff like revs or hypnos
continue
if(initial(antag_datums.can_assign_self_objectives) && !antag_datums.can_assign_self_objectives)
continue // You don't get a prize if you picked your own objective, you can't fail those
var/greentexted = TRUE
for(var/datum/objective/objective_datum as anything in antag_datums.objectives)
if(!objective_datum.check_completion())
greentexted = FALSE
break
if(greentexted)
var/score = round(human_mob.hardcore_survival_score * 2)
player_client.give_award(/datum/award/score/hardcore_random, human_mob, score)
log_admin("[player_client] gained [score] hardcore random points, including greentext bonus!")
return
if(considered_escaped(human_mob.mind))
player_client.give_award(/datum/award/score/hardcore_random, human_mob, round(human_mob.hardcore_survival_score))
log_admin("[player_client] gained [round(human_mob.hardcore_survival_score)] hardcore random points.")
/datum/controller/subsystem/ticker/proc/declare_completion(was_forced = END_ROUND_AS_NORMAL)
set waitfor = FALSE
for(var/datum/callback/roundend_callbacks as anything in round_end_events)
roundend_callbacks.InvokeAsync()
LAZYCLEARLIST(round_end_events)
var/speed_round = (STATION_TIME_PASSED() <= 10 MINUTES)
for(var/client/C in GLOB.clients)
if(!C?.credits)
C?.RollCredits()
if(COOLDOWN_FINISHED(GLOB, web_sound_cooldown))
C?.playtitlemusic(volume_multiplier = 0.5)
if(speed_round && was_forced != ADMIN_FORCE_END_ROUND)
C?.give_award(/datum/award/achievement/misc/speed_round, C?.mob)
HandleRandomHardcoreScore(C)
var/popcount = gather_roundend_feedback()
display_report(popcount)
CHECK_TICK
// Add AntagHUD to everyone, see who was really evil the whole time!
for(var/datum/atom_hud/alternate_appearance/basic/antagonist_hud/antagonist_hud in GLOB.active_alternate_appearances)
for(var/mob/player as anything in GLOB.player_list)
antagonist_hud.show_to(player)
CHECK_TICK
//Set news report and mode result
SSdynamic.set_round_result()
to_chat(world, span_infoplain(span_big(span_bold("
The round has ended."))))
log_game("The round has ended.")
for(var/channel_tag in CONFIG_GET(str_list/channel_announce_end_game))
send2chat(new /datum/tgs_message_content("[GLOB.round_id ? "Round [GLOB.round_id]" : "The round has"] just ended."), channel_tag)
send2adminchat("Server", "Round just ended.")
if(length(CONFIG_GET(keyed_list/cross_server)))
send_news_report()
CHECK_TICK
handle_hearts()
set_observer_default_invisibility(0, span_warning("The round is over! You are now visible to the living."))
CHECK_TICK
//These need update to actually reflect the real antagonists
//Print a list of antagonists to the server log
var/list/total_antagonists = list()
//Look into all mobs in world, dead or alive
for(var/datum/antagonist/A in GLOB.antagonists)
if(!A.owner)
continue
if(!(A.name in total_antagonists))
total_antagonists[A.name] = list()
total_antagonists[A.name] += "[key_name(A.owner)]"
CHECK_TICK
//Now print them all into the log!
log_game("Antagonists at round end were...")
for(var/antag_name in total_antagonists)
var/list/L = total_antagonists[antag_name]
log_game("[antag_name]s :[L.Join(", ")].")
CHECK_TICK
SSdbcore.SetRoundEnd()
//Collects persistence features
SSpersistence.collect_data()
SSpersistent_paintings.save_paintings()
//stop collecting feedback during grifftime
SSblackbox.Seal()
TriggerRoundEndTgsEvent()
sleep(5 SECONDS)
ready_for_reboot = TRUE
standard_reboot()
/datum/controller/subsystem/ticker/proc/TriggerRoundEndTgsEvent()
world.TgsTriggerEvent("tg-Roundend", wait_for_completion = TRUE)
/datum/controller/subsystem/ticker/proc/standard_reboot()
if(ready_for_reboot)
if(GLOB.station_was_nuked)
Reboot("Station destroyed by Nuclear Device.", "nuke")
else
Reboot("Round ended.", "proper completion")
else
CRASH("Attempted standard reboot without ticker roundend completion")
//Common part of the report
/datum/controller/subsystem/ticker/proc/build_roundend_report()
var/list/parts = list()
//AI laws
parts += law_report()
CHECK_TICK
//Antagonists
parts += antag_report()
parts += hardcore_random_report()
CHECK_TICK
//Medals
parts += medal_report()
//Station Goals
parts += goal_report()
//Economy & Money
parts += market_report()
//Player Achievements
parts += cheevo_report()
list_clear_nulls(parts)
return parts.Join()
/datum/controller/subsystem/ticker/proc/survivor_report(popcount)
var/list/parts = list()
var/station_evacuated = EMERGENCY_ESCAPED_OR_ENDGAMED
if(GLOB.round_id)
var/statspage = CONFIG_GET(string/roundstatsurl)
var/info = statspage ? "[GLOB.round_id]" : GLOB.round_id
parts += "[FOURSPACES]Round ID: [info]"
parts += "[FOURSPACES]Map: [SSmapping.current_map?.return_map_name()]"
parts += "[FOURSPACES]Shift Duration: [DisplayTimeText(world.time - SSticker.round_start_time)]"
parts += "[FOURSPACES]Station Integrity: [GLOB.station_was_nuked ? span_redtext("Destroyed") : "[popcount["station_integrity"]]%"]"
var/total_players = GLOB.joined_player_list.len
if(total_players)
parts+= "[FOURSPACES]Total Population: [total_players]"
if(station_evacuated)
parts += "
[FOURSPACES]Evacuation Rate: [popcount[POPCOUNT_ESCAPEES]] ([PERCENT(popcount[POPCOUNT_ESCAPEES]/total_players)]%)"
parts += "[FOURSPACES](on emergency shuttle): [popcount[POPCOUNT_SHUTTLE_ESCAPEES]] ([PERCENT(popcount[POPCOUNT_SHUTTLE_ESCAPEES]/total_players)]%)"
parts += "[FOURSPACES]Survival Rate: [popcount[POPCOUNT_SURVIVORS]] ([PERCENT(popcount[POPCOUNT_SURVIVORS]/total_players)]%)"
if(SSblackbox.first_death)
var/list/ded = SSblackbox.first_death
if(ded.len)
parts += "[FOURSPACES]First Death: [ded["name"]], [ded["role"]], at [ded["area"]]. Damage taken: [ded["damage"]].[ded["last_words"] ? " Their last words were: \"[ded["last_words"]]\"" : ""]"
//ignore this comment, it fixes the broken sytax parsing caused by the " above
else
parts += "[FOURSPACES]Nobody died this shift!"
parts += "[FOURSPACES]Round: [SSdynamic.current_tier.name]"
for(var/datum/dynamic_ruleset/rule as anything in SSdynamic.executed_rulesets - SSdynamic.unreported_rulesets)
parts += "[FOURSPACES][FOURSPACES]- [rule.name] ([rule.config_tag])"
return parts.Join("
")
/client/proc/roundend_report_file()
return "data/roundend_reports/[ckey].html"
/**
* Log the round-end report as an HTML file
*
* Composits the roundend report, and saves it in two locations.
* The report is first saved along with the round's logs
* Then, the report is copied to a fixed directory specifically for
* housing the server's last roundend report. In this location,
* the file will be overwritten at the end of each shift.
*/
/datum/controller/subsystem/ticker/proc/log_roundend_report()
var/roundend_file = file("[GLOB.log_directory]/round_end_data.html")
var/list/parts = list()
parts += "