diff --git a/SQL/tgstation_schema.sql b/SQL/tgstation_schema.sql index ffebf71fa868..fb6d7cea7472 100644 --- a/SQL/tgstation_schema.sql +++ b/SQL/tgstation_schema.sql @@ -404,6 +404,9 @@ CREATE TABLE `player` ( `lastadminrank` varchar(32) NOT NULL DEFAULT 'Player', `accountjoindate` DATE DEFAULT NULL, `flags` smallint(5) unsigned DEFAULT '0' NOT NULL, + -- MASSMETA EDIT ADDITION START (metacoins) + `metacoins` int(10) unsigned NOT NULL DEFAULT '0', + -- MASSMETA EDIT ADDITION END (metacoins) PRIMARY KEY (`ckey`), KEY `idx_player_cid_ckey` (`computerid`,`ckey`), KEY `idx_player_ip_ckey` (`ip`,`ckey`) diff --git a/code/datums/achievements/_awards.dm b/code/datums/achievements/_awards.dm index 1cd856d86361..f0016f7e9431 100644 --- a/code/datums/achievements/_awards.dm +++ b/code/datums/achievements/_awards.dm @@ -91,7 +91,12 @@ if(holder.data[type]) //You already unlocked it so don't bother running the unlock proc return holder.data[type] = TRUE - + //MASSMETA EDIT ADDITION START (metacoins) + //Metacoins for achievementssss! + var/datum/metacoins_controller/metacoin_controller = get_metacoins_controller() + if(metacoin_controller) + metacoin_controller.award_achievement_metacoins(holder.owner_ckey, type, name) + //MASSMETA EDIT ADDITION END (metacoins) to_chat(user, span_greenannounce("Achievement unlocked: [name]!")) var/sound/sound_to_send = LAZYACCESS(GLOB.achievement_sounds, user.client.prefs.read_preference(/datum/preference/choiced/sound_achievement)) if(sound_to_send) diff --git a/code/modules/deathmatch/deathmatch_controller.dm b/code/modules/deathmatch/deathmatch_controller.dm index 3ae10ad51d20..275c7401b07f 100644 --- a/code/modules/deathmatch/deathmatch_controller.dm +++ b/code/modules/deathmatch/deathmatch_controller.dm @@ -21,9 +21,38 @@ loadouts = subtypesof(/datum/outfit/deathmatch_loadout) modifiers = sortTim(init_subtypes_w_path_keys(/datum/deathmatch_modifier), GLOBAL_PROC_REF(cmp_deathmatch_mods), associative = TRUE) +//MASSMETA EDIT CHANGE START (metacoins) +/* + ORIGINAL: /datum/deathmatch_controller/proc/create_new_lobby(mob/host) lobbies[host.ckey] = new /datum/deathmatch_lobby(host) deadchat_broadcast(" has opened a new deathmatch lobby. (Join)", "[host]") +*/ +/datum/deathmatch_controller/proc/create_new_lobby(mob/host, entry_fee = 0) + if(!host?.ckey) + return list("ok" = FALSE, "error" = "invalid_host") + + entry_fee = min(max(round(text2num("[entry_fee]") || 0), 0), 1000) + + if(entry_fee > 0) + var/datum/metacoin_shop_controller/shop = get_metacoin_shop_controller() + if(!shop) + return list("ok" = FALSE, "error" = "shop_unavailable") + + var/current_balance = shop.fetch_metacoin_balance(host.ckey) + if(isnull(current_balance)) + return list("ok" = FALSE, "error" = "db_unavailable") + if(current_balance < entry_fee) + return list("ok" = FALSE, "error" = "not_enough") + + var/datum/deathmatch_lobby/new_lobby = new /datum/deathmatch_lobby(host, entry_fee) + if(QDELETED(new_lobby) || !(host.ckey in new_lobby.players)) + return list("ok" = FALSE, "error" = "create_failed") + + lobbies[host.ckey] = new_lobby + deadchat_broadcast(" has opened a new deathmatch lobby. (Join)", "[host]") + return list("ok" = TRUE) +//MASSMETA EDIT CHANGE START (metacoins) /datum/deathmatch_controller/proc/remove_lobby(ckey) var/lobby = lobbies[ckey] @@ -61,7 +90,11 @@ players = lobby.players.len, max_players = initial(lobby.map.max_players), map = initial(lobby.map.name), - playing = lobby.playing + // MASSMETA EDIT ADDITION START (metacoins) + playing = lobby.playing, + entry_fee = lobby.entry_fee, + prize_pool = lobby.prize_pool, + // MASSMETA EDIT ADDITION END (metacoins) )) /datum/deathmatch_controller/proc/find_lobby_by_user(ckey) @@ -69,7 +102,7 @@ var/datum/deathmatch_lobby/lobby = lobbies[lobbykey] if(ckey in (lobby.players+lobby.observers)) return lobby - +// MASSMETA EDIT ADDITION START (metacoins) /datum/deathmatch_controller/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) . = ..() if(. || !isobserver(usr)) @@ -84,8 +117,21 @@ if(!SSticker.HasRoundStarted()) tgui_alert(usr, "The round hasn't started yet!") return + + var/entry_fee = min(max(round(text2num(params["entry_fee"]) || 0), 0), 1000) + var/list/create_result = create_new_lobby(usr, entry_fee) + if(!create_result["ok"]) + switch(create_result["error"]) + if("not_enough") + tgui_alert(usr, "Not enough metacoins for selected entry fee.") + if("shop_unavailable", "db_unavailable") + tgui_alert(usr, "Metacoin subsystem is unavailable right now.") + else + tgui_alert(usr, "Failed to create lobby.") + return + ui.close() - create_new_lobby(usr) +// MASSMETA EDIT ADDITION END (metacoins) if ("join") if(!(GLOB.ghost_role_flags & GHOSTROLE_MINIGAME)) tgui_alert(usr, "Deathmatch has been temporarily disabled by admins.") @@ -102,6 +148,7 @@ chosen_lobby.join(usr) chosen_lobby.ui_interact(usr) + if ("spectate") var/datum/deathmatch_lobby/playing_lobby = find_lobby_by_user(usr.ckey) if (!lobbies[params["id"]]) @@ -120,6 +167,7 @@ else chosen_lobby.spectate(usr) log_game("[usr.ckey] joined deathmatch lobby [params["id"]] as an observer.") + if ("admin") if (!check_rights(R_ADMIN)) message_admins("[usr.key] has attempted to use admin functions in the deathmatch panel!") diff --git a/code/modules/deathmatch/deathmatch_lobby.dm b/code/modules/deathmatch/deathmatch_lobby.dm index 3bb15bd81057..641896c05b19 100644 --- a/code/modules/deathmatch/deathmatch_lobby.dm +++ b/code/modules/deathmatch/deathmatch_lobby.dm @@ -23,20 +23,30 @@ var/mod_menu_open = FALSE /// artificial time padding when we start loading to give lighting a breather (admin starts will set this to 0) var/start_time = 8 SECONDS - -/datum/deathmatch_lobby/New(mob/player) + // MASSMETA EDIT ADDITION START (metacoins) + /// Metacoin entry fee for each player slot in this lobby. + var/entry_fee = 0 + /// Current metacoin bank for winner payout. + var/prize_pool = 0 + /// ckey => paid metacoins for this lobby. + var/list/fees_paid = list() + //MASSMETA EDIT ADDITION END (metacoins) + +/datum/deathmatch_lobby/New(mob/player, initial_fee = 0) . = ..() if (!player) stack_trace("Attempted to create a deathmatch lobby without a host.") return qdel(src) host = player.ckey + entry_fee = max(round(text2num("[initial_fee]") || 0), 0) map = GLOB.deathmatch_game.maps[pick(GLOB.deathmatch_game.maps)] log_game("[host] created a deathmatch lobby.") if (map.allowed_loadouts) loadouts = map.allowed_loadouts else loadouts = GLOB.deathmatch_game.loadouts - add_player(player, loadouts[1], TRUE) + if(!add_player(player, loadouts[1], TRUE)) + return qdel(src) ui_interact(player) addtimer(CALLBACK(src, PROC_REF(lobby_afk_probably)), 5 MINUTES) // being generous here @@ -55,6 +65,9 @@ location = null loadouts = null modifiers = null + // MASSMETA EDIT ADDITION START (metacoins) + fees_paid = null + // MASSMETA EDIT ADDITION END (metacoins) /datum/deathmatch_lobby/proc/start_game() if (playing) @@ -179,11 +192,19 @@ if (!location) CRASH("Reservation of deathmatch game [host] deleted during game.") var/mob/winner + // MASSMETA EDIT ADDITION START (metacoins) + var/winner_ckey + // MASSMETA EDIT ADDITION END (metacoins) if(players.len) - var/list/winner_info = players[pick(players)] + // MASSMETA EDIT CHANGE START (metacoins) + winner_ckey = pick(players) + var/list/winner_info = players[winner_ckey] if(!isnull(winner_info["mob"])) winner = winner_info["mob"] //only one should remain anyway but incase of a draw + if(prize_pool > 0) + pay_pool(winner_ckey, winner) + //MASSMETA EDIT CHANGE START (metacoins) announce(span_reallybig("THE GAME HAS ENDED.
THE WINNER IS: [winner ? winner.real_name : "no one"].")) for(var/ckey in players) @@ -245,14 +266,22 @@ /datum/deathmatch_lobby/proc/add_player(mob/mob, loadout, host = FALSE) if (observers[mob.ckey]) CRASH("Tried to add [mob.ckey] as a player while being an observer.") + //MASSMETA EDIT ADDITION START (metacoins) + if(!pay_fee(mob)) + return FALSE players[mob.ckey] = list("mob" = mob, "host" = host, "ready" = FALSE, "loadout" = loadout) - + return TRUE + //MASSMETA EDIT ADDITION END (metacoins) /datum/deathmatch_lobby/proc/remove_ckey_from_play(ckey) var/is_likely_player = (ckey in players) var/list/main_list = is_likely_player ? players : observers var/list/info = main_list[ckey] if(is_likely_player && islist(info)) ready_count -= info["ready"] + //MASSMETA EDIT ADDITION START (metacoins) + if(playing != DEATHMATCH_PLAYING) + refund_fee(ckey, "Left lobby before start") // you don't wanna pay a fee, then lose your hard-earned coins + // MASSMETA EDIT ADDITION END (metacoins) main_list -= ckey /datum/deathmatch_lobby/proc/announce(message) @@ -295,7 +324,13 @@ if (players.len >= map.max_players) add_observer(player) else - add_player(player, loadouts[1]) + // MASSMETA EDIT CHANGE START (metacoins) + /* ORIGINAL: add_player(player, loadouts[1]) + */ + if(!add_player(player, loadouts[1])) + ui_interact(player) + return + // MASSMETA EDIT CHANGE END (metacoins) ui_interact(player) /datum/deathmatch_lobby/proc/spectate(mob/player) @@ -390,6 +425,10 @@ data["modifiers"] = has_auth ? get_modifier_list(is_host, mod_menu_open) : list() data["observers"] = get_observer_list() data["players"] = get_player_list() + // //MASSMETA EDIT ADDITION START (metacoins) + data["entry_fee"] = entry_fee + data["prize_pool"] = prize_pool + // //MASSMETA EDIT ADDITION END (metacoins) data["playing"] = playing data["self"] = user.ckey @@ -454,7 +493,12 @@ return TRUE else if (observers[usr.ckey] && players.len < map.max_players) remove_ckey_from_play(usr.ckey) - add_player(usr, loadouts[1], host == usr.ckey) + // MASSMETA EDIT CHANGE START (metacoins) + // original: add_player(usr, loadouts[1], host == usr.ckey) + if(!add_player(usr, loadouts[1], host == usr.ckey)) + add_observer(usr, host == usr.ckey) + return FALSE + // MASSMETA EDIT CHANGE END (metacoins) return TRUE if ("ready") @@ -490,7 +534,12 @@ add_observer(umob, host == uckey) else if (observers[uckey] && players.len < map.max_players) remove_ckey_from_play(uckey) - add_player(umob, loadouts[1], host == uckey) + // MASSMETA EDIT CHANGE START (metacoins) + // original: add_player(umob, loadouts[1], host == uckey) + if(!add_player(umob, loadouts[1], host == uckey)) + add_observer(umob, host == uckey) + return FALSE + // MASSMETA EDIT CHANGE END (metacoins) return TRUE if ("change_map") if (!(params["map"] in GLOB.deathmatch_game.maps)) @@ -533,6 +582,92 @@ return FALSE +/// Tries to charge the player for current entry fee before adding them to players list. +/datum/deathmatch_lobby/proc/pay_fee(mob/player) + if(!player?.ckey) + return FALSE + + var/already_paid = text2num(fees_paid[player.ckey]) || 0 + if(entry_fee <= already_paid) + return TRUE + + var/to_pay = entry_fee - already_paid + var/datum/metacoin_shop_controller/shop = get_metacoin_shop_controller() + if(!shop) + to_chat(player, span_warning("Metacoin subsystem is unavailable.")) + return FALSE + + var/list/take_result = shop.take_metacoins(player.ckey, to_pay) + if(!take_result["ok"]) + switch(take_result["error"]) + if("not_enough") + to_chat(player, span_warning("Not enough metacoins for entry fee ([entry_fee]).")) + if("db_unavailable", "db_failed") + to_chat(player, span_warning("Metacoin database is unavailable.")) + else + to_chat(player, span_warning("Failed to pay lobby entry fee.")) + return FALSE + + fees_paid[player.ckey] = already_paid + to_pay + prize_pool += to_pay + to_chat(player, span_boldnicegreen("Entry fee paid: [to_pay] metacoins.")) + return TRUE +// MASSMETA EDIT ADDITION START (metacoins) +/// Returns paid fee to the player while lobby is not in active match state. +/datum/deathmatch_lobby/proc/refund_fee(target_ckey, reason) + if(!target_ckey) + return FALSE + + var/paid_amount = text2num(fees_paid[target_ckey]) || 0 + if(paid_amount <= 0) + fees_paid -= target_ckey + return TRUE + + var/datum/metacoin_shop_controller/shop = get_metacoin_shop_controller() + if(!shop || !shop.add_metacoins(target_ckey, paid_amount)) + log_game("Deathmatch lobby [host] failed to refund [paid_amount] metacoins to [target_ckey].") + return FALSE + + prize_pool = max(prize_pool - paid_amount, 0) + fees_paid -= target_ckey + + var/mob/player_mob = get_mob_by_ckey(target_ckey) + if(player_mob) + to_chat(player_mob, span_notice("Entry fee refunded: [paid_amount] metacoins. [reason]")) + return TRUE + +/// Pays prize pool to winner. If payout fails, tries to refund everyone. +/datum/deathmatch_lobby/proc/pay_pool(winner_ckey, mob/winner) + if(prize_pool <= 0) + return + + var/payout_amount = prize_pool + var/list/paid_snapshot = fees_paid?.Copy() || list() + var/datum/metacoin_shop_controller/shop = get_metacoin_shop_controller() + + if(winner_ckey && shop?.add_metacoins(winner_ckey, payout_amount)) + announce(span_boldnicegreen("[winner ? winner.real_name : winner_ckey] received [payout_amount] metacoins from the prize pool.")) + if(winner) + to_chat(winner, span_boldnicegreen("You won [payout_amount] metacoins from this deathmatch prize pool.")) + log_game("Deathmatch lobby [host] paid [payout_amount] metacoins to [winner_ckey].") + prize_pool = 0 + fees_paid = list() + return + + var/payout_target = winner_ckey || "no winner" + log_game("Deathmatch lobby [host] failed to pay prize pool [payout_amount] to [payout_target], trying refunds.") + if(shop) + for(var/paid_ckey in paid_snapshot) + var/paid_amount = text2num(paid_snapshot[paid_ckey]) || 0 + if(paid_amount <= 0) + continue + shop.add_metacoins(paid_ckey, paid_amount) + + announce(span_warning("Prize payout failed, entry fees were refunded when possible.")) + prize_pool = 0 + fees_paid = list() + // //MASSMETA EDIT ADDITION END (metacoins) + /// Selects the passed modifier. /datum/deathmatch_lobby/proc/select_modifier(datum/deathmatch_modifier/modifier) modifier.on_select(src) diff --git a/modular_meta/features/metacoins/code/metacoin.dm b/modular_meta/features/metacoins/code/metacoin.dm new file mode 100644 index 000000000000..8986a54405ed --- /dev/null +++ b/modular_meta/features/metacoins/code/metacoin.dm @@ -0,0 +1,408 @@ +#define METACOIN_REWARD_ROUNDSTART_READY 10 +#define METACOIN_REWARD_SURVIVE_EVAC 25 +#define METACOIN_REWARD_IMPORTANT_JOBS 50 +#define METACOIN_REWARD_ANTAG_GREENTEXT 50 +#define METACOIN_IMPORTANT_JOBS list(JOB_SHAFT_MINER, JOB_CAPTAIN, JOB_HEAD_OF_PERSONNEL, JOB_HEAD_OF_SECURITY, JOB_RESEARCH_DIRECTOR, JOB_SECURITY_OFFICER_SUPPLY, JOB_SECURITY_OFFICER_SCIENCE, JOB_SECURITY_OFFICER_ENGINEERING, JOB_WARDEN, JOB_SECURITY_OFFICER, JOB_CHIEF_MEDICAL_OFFICER, JOB_DETECTIVE, JOB_CHIEF_ENGINEER ) // THIS SHALL BE IN CONFIG, BUT I'M VERY LAZY, OKAY? +#define METACOIN_ICON_PATH "icons/obj/economy.dmi" +#define METACOIN_ICON_STATE "coin_tails" // someone get us a nice lil' carp_coin sprite, or "masscoin" +//metacoin awards, right now only used in achievements. +#define METACOIN_AWARD_SMALL 70 +#define METACOIN_AWARD_MED 150 +#define METACOIN_AWARD_BIG 250 +#define METACOIN_AWARD_HUGE 500 // economics here kinda suck actually + +//Custom rewards list, if you want to, let's say, award more metacoins for specific achievements. +GLOBAL_ALIST_INIT(metacoin_achievement_reward_overrides, alist( + /datum/award/achievement/misc/sisyphus = METACOIN_AWARD_HUGE, + /datum/award/achievement/misc = METACOIN_AWARD_SMALL + +)) + +GLOBAL_DATUM(metacoins_controller, /datum/metacoins_controller) + +/proc/get_metacoins_controller() + if(!GLOB.metacoins_controller) + GLOB.metacoins_controller = new /datum/metacoins_controller() + GLOB.metacoins_controller.register_round_callbacks() + return GLOB.metacoins_controller + +/datum/modpack/metacoins/initialize() + . = ..() + if(.) + return + get_metacoins_controller() + get_metacoin_shop_controller() + +/datum/metacoins_controller + var/list/roundstart_ready_ckeys = list() + var/list/round_award_log_by_ckey = list() + var/list/awarded_sources_by_ckey = list() + var/round_awards_applied = FALSE + var/callbacks_registered = FALSE + +/datum/metacoins_controller/proc/register_round_callbacks() + if(callbacks_registered) + return + + callbacks_registered = TRUE + SSticker.OnRoundstart(CALLBACK(src, PROC_REF(capture_roundstart_ready_snapshot))) + SSticker.OnRoundend(CALLBACK(src, PROC_REF(grant_round_end_rewards))) + +/datum/metacoins_controller/proc/capture_roundstart_ready_snapshot() + round_awards_applied = FALSE + round_award_log_by_ckey = list() + awarded_sources_by_ckey = list() + + var/list/ready_ckey_set = list() + for(var/ready_ckey in GLOB.joined_player_list) + if(!ready_ckey) + continue + ready_ckey_set[ready_ckey] = TRUE + + roundstart_ready_ckeys = ready_ckey_set + + var/roundstart_reward = get_reward_amount(METACOIN_REWARD_ROUNDSTART_READY) + if(roundstart_reward <= 0) + return + + for(var/player_ckey in roundstart_ready_ckeys) + award_metacoins(player_ckey, roundstart_reward, "roundstart_ready", "Roundstart Ready") + +/datum/metacoins_controller/proc/grant_round_end_rewards() + if(round_awards_applied) + return + + round_awards_applied = TRUE + + var/survive_reward = get_reward_amount(METACOIN_REWARD_SURVIVE_EVAC) + var/important_role_reward = get_reward_amount(METACOIN_REWARD_IMPORTANT_JOBS) + var/antag_greentext_reward = get_reward_amount(METACOIN_REWARD_ANTAG_GREENTEXT) + + var/list/processed_ckeys = list() + for(var/player_ckey in GLOB.joined_player_list) + if(!player_ckey || processed_ckeys[player_ckey]) + continue + + processed_ckeys[player_ckey] = TRUE + + if(survive_reward > 0 && is_evacuation_condition_met(player_ckey)) + award_metacoins(player_ckey, survive_reward, "survived_shift", "Survived Shift") + if(important_role_reward > 0 && is_important_role(player_ckey)) + award_metacoins(player_ckey, important_role_reward, "social_role", "Highly Important Role") + if(antag_greentext_reward > 0 && is_antag_greentext(player_ckey)) + award_metacoins(player_ckey, antag_greentext_reward, "antag_greentext", "Antagonist Greentext") + +/datum/metacoins_controller/proc/award_metacoins(target_ckey, amount, source, reason) + amount = get_reward_amount(amount) + if(!target_ckey || amount <= 0) + return FALSE + + var/sanitized_source = source || "unknown" + + var/list/source_awards = awarded_sources_by_ckey[target_ckey] + if(!islist(source_awards)) + source_awards = list() + awarded_sources_by_ckey[target_ckey] = source_awards + + if(source_awards[sanitized_source]) + return FALSE + + if(!SSdbcore.Connect()) + return FALSE + + if(!add_metacoins(target_ckey, amount)) + return FALSE + + source_awards[sanitized_source] = TRUE + + add_round_award_log_entry(target_ckey, amount, sanitized_source, reason) + + var/list/reward_entries = list(list( + "amount" = amount, + "source" = sanitized_source, + "reason" = reason || "Reward", + )) + notify_player_reward_awarded(target_ckey, amount, reward_entries) + + var/mob/player_mob = get_mob_by_ckey(target_ckey) + if(player_mob) + SStgui.update_user_uis(player_mob) + + return TRUE + +/datum/metacoins_controller/proc/get_achievement_reward(achievement_type) + if(!achievement_type) + return 0 + + var/list/reward_overrides = GLOB.metacoin_achievement_reward_overrides + var/custom_reward = reward_overrides?[achievement_type] + if(isnull(custom_reward)) + custom_reward = reward_overrides?["[achievement_type]"] + + if(isnull(custom_reward)) + return get_reward_amount(METACOIN_AWARD_SMALL) + + return get_reward_amount(custom_reward) + +/datum/metacoins_controller/proc/award_achievement_metacoins(target_ckey, achievement_type, achievement_name) + if(!target_ckey || !achievement_type) + return FALSE + + var/reward_amount = get_achievement_reward(achievement_type) + if(reward_amount <= 0) + return FALSE + + var/achievement_type_string = "[achievement_type]" + var/reward_source = "achievement:[achievement_type_string]" + var/reward_reason = "Achievement: [achievement_name || achievement_type_string]" + return award_metacoins(target_ckey, reward_amount, reward_source, reward_reason) + +/datum/metacoins_controller/proc/is_roundstart_ready(target_ckey) + if(!target_ckey) + return FALSE + return !!roundstart_ready_ckeys[target_ckey] + +/datum/metacoins_controller/proc/get_round_bonus(target_ckey) + if(!target_ckey) + return 0 + + var/list/award_log = round_award_log_by_ckey[target_ckey] + if(!islist(award_log)) + return 0 + + var/total_reward = 0 + for(var/list/award_entry in award_log) + total_reward += text2num(award_entry["amount"]) || 0 + + return total_reward + +/datum/metacoins_controller/proc/get_round_award_log(target_ckey) + if(!target_ckey) + return list() + + var/list/award_log = round_award_log_by_ckey[target_ckey] + if(!islist(award_log)) + return list() + + return award_log.Copy() + +/datum/metacoins_controller/proc/add_round_award_log_entry(target_ckey, amount, source, reason) + if(!target_ckey || amount <= 0) + return + + var/list/award_log = round_award_log_by_ckey[target_ckey] + if(!islist(award_log)) + award_log = list() + round_award_log_by_ckey[target_ckey] = award_log + + award_log += list(list( + "amount" = amount, + "source" = source || "unknown", + "reason" = reason || "No reason", + "time" = time2text(world.realtime, "YYYY-MM-DD hh:mm:ss"), + )) + +/datum/metacoins_controller/proc/get_round_mind(target_ckey) + if(!target_ckey) + return + + var/mob/player_mob = get_mob_by_ckey(target_ckey) + if(player_mob?.mind) + return player_mob.mind + + for(var/datum/mind/player_mind in SSticker.minds) + if(ckey(player_mind?.key) == target_ckey) + return player_mind + +/datum/metacoins_controller/proc/is_evacuation_condition_met(target_ckey) + var/datum/mind/player_mind = get_round_mind(target_ckey) + if(!player_mind) + return FALSE + + if(!considered_alive(player_mind, enforce_human = FALSE)) + return FALSE + + if(SSshuttle.emergency?.mode != SHUTTLE_ENDGAME) + return FALSE + + var/mob/player_mob = player_mind.current + var/area/player_area = get_area(player_mob) + if(!player_area || istype(player_area, /area/shuttle/escape/brig)) + return FALSE + + var/turf/player_turf = get_turf(player_mob) + if(!player_turf) + return FALSE + + if(player_turf.onCentCom()) + return TRUE + + return !!SSshuttle.emergency.shuttle_areas[player_area] + +/datum/metacoins_controller/proc/is_important_role(target_ckey) + var/datum/mind/player_mind = get_round_mind(target_ckey) + var/job_title = player_mind?.assigned_role?.title + if(!job_title) + return FALSE + + return (job_title in METACOIN_IMPORTANT_JOBS) + +/datum/metacoins_controller/proc/is_antag_greentext(target_ckey) + var/datum/mind/player_mind = get_round_mind(target_ckey) + if(!player_mind || !length(player_mind.antag_datums)) + return FALSE + + for(var/datum/antagonist/antag_datum as anything in player_mind.antag_datums) + if(istype(antag_datum, /datum/antagonist/greentext)) + return TRUE + if(antag_datum.antag_flags & ANTAG_FAKE) + continue + if(!is_antag_objectives_successful(antag_datum)) + continue + return TRUE + + return FALSE + +/datum/metacoins_controller/proc/is_antag_objectives_successful(datum/antagonist/antag_datum) + if(!antag_datum) + return FALSE + + if(!length(antag_datum.objectives)) + return TRUE + + for(var/datum/objective/objective as anything in antag_datum.objectives) + if(!objective.check_completion()) + return FALSE + + return TRUE + +/datum/metacoins_controller/proc/get_reward_amount(raw_reward) + if(isnum(raw_reward)) + return max(0, round(raw_reward)) + + var/parsed_reward = text2num("[raw_reward]") || 0 + return max(0, round(parsed_reward)) + +/datum/metacoins_controller/proc/notify_player_reward_awarded(target_ckey, total_reward, list/reward_entries) + if(total_reward <= 0) + return + + var/mob/player_mob = get_mob_by_ckey(target_ckey) + if(!player_mob) + return + + var/list/reason_parts = list() + for(var/list/reward_entry in reward_entries) + var/entry_amount = text2num(reward_entry["amount"]) || 0 + if(entry_amount <= 0) + continue + reason_parts += "+[entry_amount] [reward_entry["reason"] || "Reward"]" + + var/reasons_text = length(reason_parts) ? jointext(reason_parts, ", ") : "+[total_reward] Reward" + player_mob.playsound_local(player_mob, 'sound/effects/coin2.ogg', 40, TRUE, use_reverb = FALSE) + to_chat(player_mob, span_boldnicegreen("You received [total_reward] metacoins ([reasons_text]).")) + +/datum/metacoins_controller/proc/add_metacoins(target_ckey, amount) + if(!target_ckey || amount <= 0) + return FALSE + + var/table_player = format_table_name("player") + var/datum/db_query/update_query = SSdbcore.NewQuery( + "UPDATE [table_player] SET metacoins = metacoins + :amount WHERE ckey = :ckey", + list( + "amount" = amount, + "ckey" = target_ckey, + ), + ) + + var/success = update_query.warn_execute(async = FALSE) + qdel(update_query) + return success + +/datum/metacoins_panel + var/client/owner + +/datum/metacoins_panel/New(client/owner, mob/viewer) + src.owner = owner + ui_interact(viewer) + +/datum/metacoins_panel/ui_state() + return GLOB.always_state + +/datum/metacoins_panel/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "MetaCoins") + ui.open() + +/datum/metacoins_panel/ui_data(mob/user) + var/list/data = list() + var/datum/metacoins_controller/controller = get_metacoins_controller() + var/client_ckey = owner?.ckey + + data["coinIcon"] = METACOIN_ICON_PATH + data["coinIconState"] = METACOIN_ICON_STATE + data["roundAwardsApplied"] = controller.round_awards_applied + data["roundAwarded"] = client_ckey ? controller.get_round_bonus(client_ckey) : 0 + data["roundAwardLog"] = client_ckey ? controller.get_round_award_log(client_ckey) : list() + data["canOpenShop"] = TRUE + + var/balance = fetch_metacoin_balance(client_ckey) + data["dbConnected"] = !isnull(balance) + data["balance"] = isnull(balance) ? 0 : balance + + return data + +/datum/metacoins_panel/ui_act(action, params, datum/tgui/ui, datum/ui_state/state) + . = ..() + if(.) + return + + if(action == "open_shop") + new /datum/metacoin_shop_panel(owner, ui.user) + return TRUE + + return FALSE + +/datum/metacoins_panel/proc/fetch_metacoin_balance(target_ckey) + if(!target_ckey) + return 0 + + if(!SSdbcore.Connect()) + return null + + var/table_player = format_table_name("player") + var/datum/db_query/select_query = SSdbcore.NewQuery( + "SELECT metacoins FROM [table_player] WHERE ckey = :ckey", + list("ckey" = target_ckey), + ) + + if(!select_query.warn_execute(async = FALSE)) + qdel(select_query) + return null + + var/metacoin_balance = 0 + if(select_query.NextRow(async = FALSE)) + metacoin_balance = text2num(select_query.item[1]) || 0 + + qdel(select_query) + return metacoin_balance + +/client/verb/view_metacoins() + set name = "View Metacoins" + set category = "OOC" + set desc = "View your metacoin balance and this round award log." + + new /datum/metacoins_panel(src, usr) + +#undef METACOIN_REWARD_ROUNDSTART_READY +#undef METACOIN_REWARD_SURVIVE_EVAC +#undef METACOIN_REWARD_IMPORTANT_JOBS +#undef METACOIN_REWARD_ANTAG_GREENTEXT +#undef METACOIN_IMPORTANT_JOBS +#undef METACOIN_ICON_PATH +#undef METACOIN_ICON_STATE +#undef METACOIN_AWARD_SMALL +#undef METACOIN_AWARD_MED +#undef METACOIN_AWARD_BIG +#undef METACOIN_AWARD_HUGE diff --git a/modular_meta/features/metacoins/code/metacoin_gambling.dm b/modular_meta/features/metacoins/code/metacoin_gambling.dm new file mode 100644 index 000000000000..a21820e1da70 --- /dev/null +++ b/modular_meta/features/metacoins/code/metacoin_gambling.dm @@ -0,0 +1,414 @@ +#define METACOIN_SLOT_SPIN_COST 5 +#define METACOIN_SLOT_PAYOUT_LINE3 15 +#define METACOIN_SLOT_PAYOUT_LINE4 50 +#define METACOIN_SLOT_PAYOUT_LINE5 150 +#define METACOIN_SLOT_PAYOUT_JACKPOT 1000 +#define METACOIN_SLOT_COOLDOWN_DS 5 +#define METACOIN_SLOT_JACKPOT_ICON FA_ICON_7 + +/datum/metacoin_shop_controller + var/list/slot_spin_locks_by_ckey = list() + var/list/slot_next_spin_time_by_ckey = list() + +/datum/metacoin_shop_controller/proc/get_slot_icons_catalog() + var/static/list/slot_icons = list( + FA_ICON_LEMON = list("colour" = "yellow"), + FA_ICON_STAR = list("colour" = "yellow"), + FA_ICON_BOMB = list("colour" = "red"), + FA_ICON_BIOHAZARD = list("colour" = "green"), + FA_ICON_APPLE_WHOLE = list("colour" = "red"), + FA_ICON_7 = list("colour" = "yellow"), + FA_ICON_DOLLAR_SIGN = list("colour" = "green"), + ) + return slot_icons + +/datum/metacoin_shop_controller/proc/roll_slot_reels() + var/list/icons_catalog = get_slot_icons_catalog() + var/list/reels = list() + + for(var/reel_index in 1 to 5) + var/list/reel = list() + for(var/row_index in 1 to 3) + var/chosen_icon_name = pick(icons_catalog) + var/list/icon_data = icons_catalog[chosen_icon_name] + reel += list(list( + "icon_name" = chosen_icon_name, + "colour" = icon_data["colour"] || "white", + )) + + reels += list(reel) + + return reels + +/datum/metacoin_shop_controller/proc/get_slot_longest_line(list/reels) + if(!islist(reels) || length(reels) < 5) + return 0 + + var/best_line = 0 + + for(var/row_index in 1 to 3) + var/current_streak = 0 + var/last_icon_name + + for(var/reel_index in 1 to 5) + var/list/reel = reels[reel_index] + if(!islist(reel) || length(reel) < row_index) + current_streak = 0 + last_icon_name = null + continue + + var/list/symbol = reel[row_index] + var/icon_name = symbol?["icon_name"] + if(isnull(icon_name)) + current_streak = 0 + last_icon_name = null + continue + + if(icon_name == last_icon_name) + current_streak++ + else + current_streak = 1 + last_icon_name = icon_name + + best_line = max(best_line, current_streak) + + return best_line + +/datum/metacoin_shop_controller/proc/is_slot_jackpot(list/reels) + if(!islist(reels) || length(reels) < 5) + return FALSE + + var/jackpot_icon_name = "[METACOIN_SLOT_JACKPOT_ICON]" + for(var/reel_index in 1 to 5) + var/list/reel = reels[reel_index] + if(!islist(reel) || length(reel) < 2) + return FALSE + + var/list/symbol = reel[2] + if(symbol?["icon_name"] != jackpot_icon_name) + return FALSE + + return TRUE + +/datum/metacoin_shop_controller/proc/get_slot_payout(line_length, is_jackpot) + if(is_jackpot) + return METACOIN_SLOT_PAYOUT_JACKPOT + if(line_length >= 5) + return METACOIN_SLOT_PAYOUT_LINE5 + if(line_length >= 4) + return METACOIN_SLOT_PAYOUT_LINE4 + if(line_length >= 3) + return METACOIN_SLOT_PAYOUT_LINE3 + return 0 + +/datum/metacoin_shop_controller/proc/get_slot_cooldown_left_ds(target_ckey) + target_ckey = ckey(target_ckey) + if(!target_ckey) + return 0 + + var/next_spin_time = text2num(slot_next_spin_time_by_ckey[target_ckey]) || 0 + if(next_spin_time <= world.time) + return 0 + + return max(next_spin_time - world.time, 0) + +/datum/metacoin_shop_controller/proc/announce_slot_big_win(winner_name, payout_amount, jackpot_hit) + if(!winner_name || payout_amount <= 0) + return +//priority announce might be too much actually.. who cares though? + if(jackpot_hit) + priority_announce( + text = "[winner_name] hit the metacoin slot JACKPOT and won [payout_amount] metacoins!", + title = "Metacoin Slot Jackpot", + sound = 'sound/machines/roulette/roulettejackpot.ogg', + has_important_message = TRUE, + ) + return + + if(payout_amount >= METACOIN_SLOT_PAYOUT_LINE5) + priority_announce( + text = "[winner_name] won a huge metacoin slot payout: [payout_amount] metacoins!", + title = "Metacoin Slot Big Win", + sound = 'sound/effects/kaching.ogg', + ) + +/datum/metacoin_shop_controller/proc/try_slot_spin(target_ckey, mob/request_user) + target_ckey = ckey(target_ckey) + if(!target_ckey) + return list("ok" = FALSE, "error" = "invalid_request") + + if(!is_preround_purchase_open() && !isobserver(request_user)) + return list("ok" = FALSE, "error" = "shop_closed") + + if(slot_spin_locks_by_ckey[target_ckey]) + return list("ok" = FALSE, "error" = "busy") + + if(!SSdbcore.Connect()) + return list("ok" = FALSE, "error" = "db_unavailable") + + var/cooldown_left_ds = get_slot_cooldown_left_ds(target_ckey) + if(cooldown_left_ds > 0) + return list( + "ok" = FALSE, + "error" = "cooldown", + "cooldownLeftDs" = cooldown_left_ds, + ) + + slot_spin_locks_by_ckey[target_ckey] = TRUE + + var/list/result = list("ok" = FALSE, "error" = "unknown") + + var/current_balance = fetch_metacoin_balance(target_ckey) + if(isnull(current_balance)) + result["error"] = "db_unavailable" + slot_spin_locks_by_ckey -= target_ckey + return result + + if(current_balance < METACOIN_SLOT_SPIN_COST) + result["error"] = "not_enough" + slot_spin_locks_by_ckey -= target_ckey + return result + + var/table_player = format_table_name("player") + var/datum/db_query/debit_query = SSdbcore.NewQuery( + "UPDATE [table_player] SET metacoins = metacoins - :price WHERE ckey = :ckey AND metacoins >= :price", + list( + "price" = METACOIN_SLOT_SPIN_COST, + "ckey" = target_ckey, + ), + ) + + if(!debit_query.warn_execute(async = FALSE)) + qdel(debit_query) + result["error"] = "db_failed" + slot_spin_locks_by_ckey -= target_ckey + return result + qdel(debit_query) + + var/post_debit_balance = fetch_metacoin_balance(target_ckey) + if(isnull(post_debit_balance)) + result["error"] = "db_failed" + slot_spin_locks_by_ckey -= target_ckey + return result + + if(post_debit_balance > (current_balance - METACOIN_SLOT_SPIN_COST)) + result["error"] = "not_enough" + slot_spin_locks_by_ckey -= target_ckey + return result + + slot_next_spin_time_by_ckey[target_ckey] = world.time + METACOIN_SLOT_COOLDOWN_DS + + var/list/reels = roll_slot_reels() + var/line_length = get_slot_longest_line(reels) + var/is_jackpot = is_slot_jackpot(reels) + var/payout = get_slot_payout(line_length, is_jackpot) + + if(payout > 0) + var/datum/db_query/payout_query = SSdbcore.NewQuery( + "UPDATE [table_player] SET metacoins = metacoins + :amount WHERE ckey = :ckey", + list( + "amount" = payout, + "ckey" = target_ckey, + ), + ) + + if(!payout_query.warn_execute(async = FALSE)) + qdel(payout_query) + result["error"] = "db_failed" + slot_spin_locks_by_ckey -= target_ckey + return result + qdel(payout_query) + + var/final_balance = fetch_metacoin_balance(target_ckey) + if(isnull(final_balance)) + result["error"] = "db_failed" + slot_spin_locks_by_ckey -= target_ckey + return result + + result = list( + "ok" = TRUE, + "reels" = reels, + "lineLength" = line_length, + "isJackpot" = is_jackpot, + "payout" = payout, + "cost" = METACOIN_SLOT_SPIN_COST, + "balance" = final_balance, + "cooldownLeftDs" = get_slot_cooldown_left_ds(target_ckey), + ) + + slot_spin_locks_by_ckey -= target_ckey + return result + +/datum/metacoin_slot_panel + var/client/owner + var/list/current_reels = list() + var/working = FALSE + var/list/last_spin = list() + var/list/spin_history = list() + +/datum/metacoin_slot_panel/New(client/owner, mob/viewer) + src.owner = owner + current_reels = get_metacoin_shop_controller().roll_slot_reels() + last_spin = list( + "lineLength" = 0, + "payout" = 0, + "isJackpot" = FALSE, + "net" = 0, + "resultState" = "idle", + ) + spin_history = list() + ui_interact(viewer) + +/datum/metacoin_slot_panel/ui_state() + return GLOB.always_state + +/datum/metacoin_slot_panel/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "MetaCoinSlot") + ui.open() + +/datum/metacoin_slot_panel/ui_static_data(mob/user) + var/list/data = list() + + data["icons"] = list() + var/list/icons_catalog = get_metacoin_shop_controller().get_slot_icons_catalog() + for(var/icon_name in icons_catalog) + var/list/icon_info = icons_catalog[icon_name] + data["icons"] += list(list( + "icon_name" = icon_name, + "colour" = icon_info["colour"], + )) + + data["cost"] = METACOIN_SLOT_SPIN_COST + data["payoutLine3"] = METACOIN_SLOT_PAYOUT_LINE3 + data["payoutLine4"] = METACOIN_SLOT_PAYOUT_LINE4 + data["payoutLine5"] = METACOIN_SLOT_PAYOUT_LINE5 + data["payoutJackpot"] = METACOIN_SLOT_PAYOUT_JACKPOT + + return data + +/datum/metacoin_slot_panel/ui_data(mob/user) + var/list/data = list() + var/datum/metacoin_shop_controller/shop = get_metacoin_shop_controller() + var/client_ckey = owner?.ckey + var/balance = shop.fetch_metacoin_balance(client_ckey) + + data["isPregame"] = shop.is_preround_purchase_open() + data["isObserver"] = isobserver(user) + data["working"] = working + data["balance"] = isnull(balance) ? 0 : balance + data["state"] = current_reels + data["lastSpin"] = last_spin + data["history"] = spin_history.Copy() + data["cooldownLeftDs"] = shop.get_slot_cooldown_left_ds(client_ckey) + + return data + +/datum/metacoin_slot_panel/ui_act(action, params, datum/tgui/ui, datum/ui_state/state) + . = ..() + if(.) + return + + if(action == "spin") + if(working) + return FALSE + + working = TRUE + var/mob/user_mob = ui?.user + + var/list/result = get_metacoin_shop_controller().try_slot_spin(owner?.ckey, user_mob) + + if(!result["ok"]) + if(user_mob) + var/cooldown_seconds = (text2num(result["cooldownLeftDs"]) || 0) / 10 + switch(result["error"]) + if("shop_closed") + to_chat(user_mob, span_warning("Metacoin slot machine is available only before round start.")) + if("not_enough") + to_chat(user_mob, span_warning("Not enough metacoins for a spin.")) + if("cooldown") + to_chat(user_mob, span_warning("Spin cooldown active: [round(cooldown_seconds, 0.1)]s left.")) + if("busy") + to_chat(user_mob, span_warning("Spin is already being processed.")) + if("db_unavailable", "db_failed") + to_chat(user_mob, span_warning("Database error. Try again later.")) + else + to_chat(user_mob, span_warning("Spin failed.")) + + user_mob.playsound_local(user_mob, 'sound/machines/compiler/compiler-failure.ogg', 40, TRUE, use_reverb = FALSE) + + working = FALSE + return FALSE + + var/datum/weakref/user_ref = user_mob ? WEAKREF(user_mob) : null + addtimer(CALLBACK(src, PROC_REF(finish_spin), result, user_ref), 10) + SStgui.update_uis(src) + return TRUE + + return FALSE + +/datum/metacoin_slot_panel/proc/finish_spin(list/result, datum/weakref/user_ref) + if(!islist(result)) + working = FALSE + SStgui.update_uis(src) + return + + var/mob/user_mob = user_ref?.resolve() || owner?.mob + + current_reels = result["reels"] + var/payout_amount = text2num(result["payout"]) || 0 + var/spin_cost = text2num(result["cost"]) || METACOIN_SLOT_SPIN_COST + var/line_length = text2num(result["lineLength"]) || 0 + var/jackpot_hit = !!result["isJackpot"] + var/net_amount = payout_amount - spin_cost + var/result_state = payout_amount > 0 ? "win" : "loss" + if(jackpot_hit) + result_state = "jackpot" + + last_spin = list( + "lineLength" = line_length, + "payout" = payout_amount, + "isJackpot" = jackpot_hit, + "net" = net_amount, + "resultState" = result_state, + ) + + var/list/history_entry = list( + "time" = time2text(world.realtime, "hh:mm:ss"), + "lineLength" = line_length, + "payout" = payout_amount, + "net" = net_amount, + "isJackpot" = jackpot_hit, + ) + spin_history = list(history_entry) + spin_history + if(length(spin_history) > 5) + spin_history.Cut(6) + + if(payout_amount >= METACOIN_SLOT_PAYOUT_LINE5) + var/winner_name = user_mob?.real_name || owner?.ckey || "Unknown" + get_metacoin_shop_controller().announce_slot_big_win(winner_name, payout_amount, jackpot_hit) + + if(user_mob) + if(jackpot_hit) + to_chat(user_mob, span_boldnicegreen("JACKPOT! You won [payout_amount] metacoins.")) + user_mob.playsound_local(user_mob, 'sound/machines/roulette/roulettejackpot.ogg', 45, TRUE, use_reverb = FALSE) + else if(payout_amount > 0) + to_chat(user_mob, span_boldnicegreen("You won [payout_amount] metacoins.")) + user_mob.playsound_local(user_mob, 'sound/effects/kaching.ogg', 40, TRUE, use_reverb = FALSE) + else + to_chat(user_mob, span_warning("No luck this spin.")) + user_mob.playsound_local(user_mob, 'sound/machines/buzz/buzz-sigh.ogg', 10, TRUE, use_reverb = FALSE) + + working = FALSE + SStgui.update_uis(src) + if(user_mob) + SStgui.update_user_uis(user_mob) + +#undef METACOIN_SLOT_SPIN_COST +#undef METACOIN_SLOT_PAYOUT_LINE3 +#undef METACOIN_SLOT_PAYOUT_LINE4 +#undef METACOIN_SLOT_PAYOUT_LINE5 +#undef METACOIN_SLOT_PAYOUT_JACKPOT +#undef METACOIN_SLOT_COOLDOWN_DS +#undef METACOIN_SLOT_JACKPOT_ICON diff --git a/modular_meta/features/metacoins/code/metacoin_shop.dm b/modular_meta/features/metacoins/code/metacoin_shop.dm new file mode 100644 index 000000000000..38eb974f3e33 --- /dev/null +++ b/modular_meta/features/metacoins/code/metacoin_shop.dm @@ -0,0 +1,1009 @@ +GLOBAL_DATUM(metacoin_shop_controller, /datum/metacoin_shop_controller) + +/proc/get_metacoin_shop_controller() + if(!GLOB.metacoin_shop_controller) + GLOB.metacoin_shop_controller = new /datum/metacoin_shop_controller() + GLOB.metacoin_shop_controller.register_signals() + return GLOB.metacoin_shop_controller + +/datum/metacoin_shop_listing + var/id + var/name + var/desc + var/price + var/item_type + var/listing_kind = "item" + var/icon + var/icon_state + +/datum/metacoin_shop_listing/New(id, name, desc, price, item_type, listing_kind = "item", icon, icon_state) + src.id = id + src.name = name + src.desc = desc + src.price = price + src.item_type = item_type + src.listing_kind = listing_kind + src.icon = icon + src.icon_state = icon_state + +/datum/metacoin_shop_controller + var/list/preround_catalog = list() + var/list/preround_pending_by_ckey = list() + var/list/preround_delivered_by_ckey = list() + var/list/antag_token_pending_by_ckey = list() + var/antag_token_slots_left = 3 + var/default_listing_fallback_icon = "question-circle" + var/signals_registered = FALSE + +/datum/metacoin_shop_controller/New() + . = ..() + setup_catalog() + +//Add your items here!!!! ~`_-´ +//In the list preround_catalog + +/* EXAMPLE: + alist( + "listing_name" = "donut_box", + "listing_display_name" = "Donut Box", + "listing_display_desc" = "A box of donuts delivered on your first roundstart spawn.", + "listing_price" = 5, + "listing_typepath" = /obj/item/storage/fancy/donut_box, + ), +*/ + +/datum/metacoin_shop_controller/proc/setup_catalog() + var/list/raw_preround_catalog = list( + alist( + "listing_name" = "donut_box", + "listing_display_name" = "Donut Box", + "listing_display_desc" = "A box of donuts... what else do you expect?", + "listing_price" = 50, + "listing_typepath" = /obj/item/storage/fancy/donut_box, + ), + alist( + "listing_name" = "spray_libital", + "listing_display_name" = "Libital Spray", + "listing_display_desc" = "An medigel full of libital, mainly used to treat bruises", + "listing_price" = 75, + "listing_typepath" = /obj/item/reagent_containers/medigel/libital, + ), + alist( + "listing_name" = "spray_auri", + "listing_display_name" = "Aiuri Spray", + "listing_display_desc" = "An medigel full of aiuri, mainly used to treat burns", + "listing_price" = 75, + "listing_typepath" = /obj/item/reagent_containers/medigel/aiuri, + ), + alist( + "listing_name" = "antag_token", + "listing_display_name" = "Antag Token", + "listing_display_desc" = "Guarantees one chosen antagonist role at roundstart.", + "listing_price" = 500, + "listing_typepath" = /obj/item/coin/antagtoken, // to get the display icon of ours + "listing_kind" = "antag_token", + ), + ) + + preround_catalog = alist() + for(var/listing_data in raw_preround_catalog) + if(!listing_data) + continue + + var/listing_name = listing_data["listing_name"] + if(!listing_name) + continue + + var/listing_display_name = listing_data["listing_display_name"] + var/listing_display_desc = listing_data["listing_display_desc"] + var/listing_price = listing_data["listing_price"] + var/listing_typepath = listing_data["listing_typepath"] + var/listing_kind = listing_data["listing_kind"] + if(!listing_kind) + listing_kind = "item" + var/listing_icon = listing_data["listing_icon"] + var/listing_icon_state = listing_data["listing_icon_state"] + + if(listing_kind == "item" && !listing_typepath) + continue + + if(listing_typepath && !listing_icon) + var/obj/item/type_cast_item_path = listing_typepath + listing_icon = initial(type_cast_item_path.icon) + listing_icon_state = initial(type_cast_item_path.icon_state) + + preround_catalog[listing_name] = new /datum/metacoin_shop_listing( + listing_name, + listing_display_name, + listing_display_desc, + listing_price, + listing_typepath, + listing_kind, + listing_icon, + listing_icon_state, + ) + +/datum/metacoin_shop_controller/proc/register_signals() + if(signals_registered) + return + + signals_registered = TRUE + RegisterSignal(SSdcs, COMSIG_GLOB_JOB_AFTER_SPAWN, PROC_REF(on_job_after_spawn)) + SSticker.OnRoundstart(CALLBACK(src, PROC_REF(on_round_start))) + SSticker.OnRoundend(CALLBACK(src, PROC_REF(on_round_end))) + +/datum/metacoin_shop_controller/proc/on_round_start() + preround_delivered_by_ckey = list() + +/datum/metacoin_shop_controller/proc/on_round_end() + refund_all_pending_antag_tokens() + preround_pending_by_ckey = list() + preround_delivered_by_ckey = list() + antag_token_pending_by_ckey = list() + antag_token_slots_left = 3 + +/datum/metacoin_shop_controller/proc/is_preround_purchase_open() + if(!SSticker) + return FALSE + return SSticker.current_state == GAME_STATE_PREGAME + +/datum/metacoin_shop_controller/proc/get_antag_token_listing() + return preround_catalog["antag_token"] + +/datum/metacoin_shop_controller/proc/get_antag_token_slots_left() + return max(antag_token_slots_left, 0) + +/datum/metacoin_shop_controller/proc/get_antag_token_restricted_jobs() + var/static/list/antag_token_restricted_jobs = list( + JOB_CAPTAIN, + JOB_HEAD_OF_SECURITY, + JOB_WARDEN, + JOB_SECURITY_OFFICER, + JOB_SECURITY_OFFICER_MEDICAL, + JOB_SECURITY_OFFICER_ENGINEERING, + JOB_SECURITY_OFFICER_SCIENCE, + JOB_SECURITY_OFFICER_SUPPLY, + ) //i've spawned as a heretic captain, that's why it exists + + return antag_token_restricted_jobs + +/datum/metacoin_shop_controller/proc/is_antag_token_restricted_job(job_title) + if(!job_title) + return FALSE + + return job_title in get_antag_token_restricted_jobs() + +/datum/metacoin_shop_controller/proc/get_antag_token_restricted_job_preferences_for_client(client/target_client) + var/list/restricted_preferences = list() + var/list/job_preferences = target_client?.prefs?.job_preferences + if(!islist(job_preferences)) + return restricted_preferences + + for(var/job_title in get_antag_token_restricted_jobs()) + if(!isnull(job_preferences[job_title])) + restricted_preferences += job_title + + return restricted_preferences + +/datum/metacoin_shop_controller/proc/get_antag_token_restricted_job_preferences_warning_for_client(client/target_client) + var/list/restricted_preferences = get_antag_token_restricted_job_preferences_for_client(target_client) + if(!length(restricted_preferences)) + return null + + return "Warning: you have restricted jobs enabled in preferences ([english_list(restricted_preferences)]). If one of these jobs is assigned at roundstart, antag token will be refunded." + +/datum/metacoin_shop_controller/proc/get_antag_token_role_definitions() + var/static/list/role_definitions = list( + alist( + "id" = "traitor", + "name" = "Traitor", + "desc" = "An unpaid debt. A score to be settled. Maybe you were just in the wrong \ + place at the wrong time. Whatever the reasons, you were selected to \ + infiltrate Space Station 13.", + "ruleset_tag" = "Roundstart Traitor", + "jobban_flag" = ROLE_TRAITOR, + "antag_datum" = /datum/antagonist/traitor, + "default_min_pop" = 3, + ), + alist( + "id" = "changeling", + "name" = "Changeling", + "desc" = "A highly intelligent alien predator that is capable of altering their \ + shape to flawlessly resemble a human.", + "ruleset_tag" = "Roundstart Changeling", + "jobban_flag" = ROLE_CHANGELING, + "antag_datum" = /datum/antagonist/changeling, + "default_min_pop" = 15, + ), + alist( + "id" = "heretic", + "name" = "Heretic", + "desc" = " Forgotten, devoured, gutted. Humanity has forgotten the eldritch forces \ + of decay, but the mansus veil has weakened. We will make them taste fear \ + again...", + "ruleset_tag" = "Roundstart Heretics", + "jobban_flag" = ROLE_HERETIC, + "antag_datum" = /datum/antagonist/heretic, + "default_min_pop" = 30, + ), + ) + + return role_definitions + +/datum/metacoin_shop_controller/proc/get_antag_token_role_definition(role_id) + if(!role_id) + return null + + var/list/role_definitions = get_antag_token_role_definitions() + for(var/role_key in role_definitions) + var/list/role_definition = role_definitions[role_key] + if(!islist(role_definition) && islist(role_key)) + role_definition = role_key + if(!islist(role_definition)) + continue + if(role_definition["id"] == role_id) + return role_definition + + return null + +/datum/metacoin_shop_controller/proc/get_antag_token_role_display_name(role_id) + if(!role_id) + return null + + var/list/role_definition = get_antag_token_role_definition(role_id) + if(!role_definition) + return null + return role_definition["name"] + +/datum/metacoin_shop_controller/proc/dynamic_weight_has_positive_value(weight_setting) + if(isnull(weight_setting)) + return FALSE + + if(isnum(weight_setting)) + return text2num("[weight_setting]") > 0 + + if(islist(weight_setting)) + for(var/key in weight_setting) + if(text2num("[weight_setting[key]]") > 0) + return TRUE + + return FALSE + +/datum/metacoin_shop_controller/proc/dynamic_resolve_min_pop(min_pop_setting, fallback_value) + if(isnum(min_pop_setting)) + return max(text2num("[min_pop_setting]"), 0) + + if(islist(min_pop_setting)) + var/best_value + for(var/key in min_pop_setting) + var/current_value = max(text2num("[min_pop_setting[key]]"), 0) + if(isnull(best_value) || current_value < best_value) + best_value = current_value + + if(!isnull(best_value)) + return best_value + + return max(text2num("[fallback_value]"), 0) + +/datum/metacoin_shop_controller/proc/get_antag_token_role_block_info(target_ckey, role_id, datum/job/current_job = null) + var/list/role_definition = get_antag_token_role_definition(role_id) + if(!role_definition) + return list("code" = "unknown_role") + + var/role_ban_flag = role_definition["jobban_flag"] + if(target_ckey && is_banned_from(target_ckey, list(ROLE_SYNDICATE, role_ban_flag))) + return list("code" = "job_banned") + + if(current_job && is_antag_token_restricted_job(current_job.title)) + return list( + "code" = "restricted_job", + "job_title" = current_job.title, + ) + + var/default_min_pop = role_definition["default_min_pop"] + var/min_pop_setting = default_min_pop + + if(CONFIG_GET(flag/dynamic_config_enabled)) + var/ruleset_tag = role_definition["ruleset_tag"] + var/list/ruleset_config = SSdynamic.get_config()?[ruleset_tag] + + if(!isnull(ruleset_config?["weight"]) && !dynamic_weight_has_positive_value(ruleset_config["weight"])) + return list("code" = "disabled_by_config") + + if(!isnull(ruleset_config?["min_pop"])) + min_pop_setting = ruleset_config["min_pop"] + + var/min_pop = dynamic_resolve_min_pop(min_pop_setting, default_min_pop) + var/current_population = length(GLOB.new_player_list) + if(current_population < min_pop) + return list( + "code" = "min_pop", + "required_pop" = min_pop, + "current_pop" = current_population, + ) + + return null + +/datum/metacoin_shop_controller/proc/get_antag_token_role_block_text(list/block_info) + if(!islist(block_info)) + return null + + var/code = block_info["code"] + switch(code) + if("job_banned") + return "Role is blocked by jobban." + if("restricted_job") + var/job_title = block_info["job_title"] + if(job_title) + return "Role is blocked for your current job: [job_title]." + return "Role is blocked for your current job." + if("disabled_by_config") + return "Role is disabled by dynamic config." + if("min_pop") + var/current_pop = block_info["current_pop"] + var/required_pop = block_info["required_pop"] + return "Not enough population: [current_pop]/[required_pop]." + if("unknown_role") + return "Unknown role." + + return "Role is currently unavailable." + +/datum/metacoin_shop_controller/proc/get_antag_token_roles_ui_data(target_ckey) + var/list/roles_ui_data = list() + + var/list/role_definitions = get_antag_token_role_definitions() + for(var/role_key in role_definitions) + var/list/role_definition = role_definitions[role_key] + if(!islist(role_definition) && islist(role_key)) + role_definition = role_key + if(!islist(role_definition)) + continue + + var/role_id = role_definition["id"] + var/list/block_info = get_antag_token_role_block_info(target_ckey, role_id) + + roles_ui_data += list(list( + "id" = role_id, + "name" = role_definition["name"], + "desc" = role_definition["desc"], + "prefIconClass" = role_id, + "fallbackIcon" = default_listing_fallback_icon, + "available" = isnull(block_info), + "unavailableReason" = get_antag_token_role_block_text(block_info), + "unavailableCode" = block_info?["code"], + "minPopCurrent" = block_info?["current_pop"], + "minPopRequired" = block_info?["required_pop"], + )) + + return roles_ui_data + +/datum/metacoin_shop_controller/proc/refund_antag_token_purchase(target_ckey, failure_text, mob/notify_mob) + if(!target_ckey) + return FALSE + + if(!(target_ckey in antag_token_pending_by_ckey)) + log_game("[src] antag token refund skipped for [target_ckey]: no pending reservation.") + return FALSE + + var/datum/metacoin_shop_listing/antag_listing = get_antag_token_listing() + var/refund_amount = antag_listing?.price || 0 + + antag_token_pending_by_ckey -= target_ckey + antag_token_slots_left = min(antag_token_slots_left + 1, 3) + log_game("[src] antag token refund for [target_ckey], failure='[failure_text]', slots_left=[antag_token_slots_left].") + + if(refund_amount > 0) + add_metacoins(target_ckey, refund_amount) + + var/message = failure_text + if(!message) + message = "Antag token delivery failed." + if(refund_amount > 0) + message += " [refund_amount] metacoins were refunded." + + if(notify_mob?.client) + to_chat(notify_mob, span_warning(message)) + notify_mob.playsound_local(notify_mob, 'sound/machines/compiler/compiler-failure.ogg', 40, TRUE, use_reverb = FALSE) + else + log_game("[src] antag token refund notify deferred for [target_ckey]: no client on notify mob.") + addtimer(CALLBACK(src, PROC_REF(retry_notify_antag_token_result), target_ckey, message, 20), 1 SECONDS) + + return TRUE + +/datum/metacoin_shop_controller/proc/retry_notify_antag_token_result(target_ckey, message, attempts_left) + if(!target_ckey || !message) + return + + var/mob/target_mob = get_mob_by_ckey(target_ckey) + if(!target_mob?.client) + if(attempts_left > 0) + addtimer(CALLBACK(src, PROC_REF(retry_notify_antag_token_result), target_ckey, message, attempts_left - 1), 0.5 SECONDS) + return + + to_chat(target_mob, span_warning(message)) + target_mob.playsound_local(target_mob, 'sound/machines/compiler/compiler-failure.ogg', 40, TRUE, use_reverb = FALSE) + +/datum/metacoin_shop_controller/proc/refund_all_pending_antag_tokens() + if(!length(antag_token_pending_by_ckey)) + return + + var/list/ckeys_to_refund = antag_token_pending_by_ckey.Copy() + for(var/target_ckey in ckeys_to_refund) + refund_antag_token_purchase(target_ckey, null, null) + +/datum/metacoin_shop_controller/proc/get_catalog_ui_data(target_ckey) + var/list/catalog_data = list() + var/list/pending_items = get_pending_item_ids(target_ckey) + var/selected_antag_role = antag_token_pending_by_ckey[target_ckey] + var/balance = fetch_metacoin_balance(target_ckey) + + for(var/listing_id in preround_catalog) + var/datum/metacoin_shop_listing/listing = preround_catalog[listing_id] + if(!listing) + continue + + var/is_antag_token = listing.listing_kind == "antag_token" + var/is_owned = FALSE + if(is_antag_token) + is_owned = !isnull(selected_antag_role) + else + is_owned = (listing.id in pending_items) + + var/list/listing_payload = list( + "id" = listing.id, + "kind" = listing.listing_kind, + "name" = listing.name, + "desc" = listing.desc, + "price" = listing.price, + "icon" = listing.icon, + "iconState" = listing.icon_state, + "fallbackIcon" = default_listing_fallback_icon, + "owned" = is_owned, + "canAfford" = !isnull(balance) && (balance >= listing.price), + ) + + if(is_antag_token) + listing_payload["tokensLeft"] = get_antag_token_slots_left() + listing_payload["selectedRole"] = selected_antag_role + listing_payload["selectedRoleName"] = get_antag_token_role_display_name(selected_antag_role) + + catalog_data += list(listing_payload) + + return catalog_data + +/datum/metacoin_shop_controller/proc/get_pending_item_ids(target_ckey) + if(!target_ckey) + return list() + + var/list/pending_items = preround_pending_by_ckey[target_ckey] + if(!islist(pending_items)) + return list() + + return pending_items.Copy() + +/datum/metacoin_shop_controller/proc/fetch_metacoin_balance(target_ckey) + if(!target_ckey) + return 0 + + if(!SSdbcore.Connect()) + return null + + var/table_player = format_table_name("player") + var/datum/db_query/select_query = SSdbcore.NewQuery( + "SELECT metacoins FROM [table_player] WHERE ckey = :ckey", + list("ckey" = target_ckey), + ) + + if(!select_query.warn_execute(async = FALSE)) + qdel(select_query) + return null + + var/metacoin_balance = 0 + if(select_query.NextRow(async = FALSE)) + metacoin_balance = text2num(select_query.item[1]) || 0 + + qdel(select_query) + return metacoin_balance + +/datum/metacoin_shop_controller/proc/add_metacoins(target_ckey, delta_amount) + if(!target_ckey || !isnum(delta_amount) || delta_amount <= 0) + return FALSE + + if(!SSdbcore.Connect()) + return FALSE + + var/table_player = format_table_name("player") + var/datum/db_query/update_query = SSdbcore.NewQuery( + "UPDATE [table_player] SET metacoins = metacoins + :delta WHERE ckey = :ckey", + list( + "delta" = delta_amount, + "ckey" = target_ckey, + ), + ) + + if(!update_query.warn_execute(async = FALSE)) + qdel(update_query) + return FALSE + + qdel(update_query) + return TRUE + +///Takes coins in one atomic query +/datum/metacoin_shop_controller/proc/take_metacoins(target_ckey, delta_amount) + if(!target_ckey || !isnum(delta_amount) || delta_amount <= 0) + return list("ok" = FALSE, "error" = "invalid_request") + + if(!SSdbcore.Connect()) + return list("ok" = FALSE, "error" = "db_unavailable") + + var/current_balance = fetch_metacoin_balance(target_ckey) + if(isnull(current_balance)) + return list("ok" = FALSE, "error" = "db_unavailable") + + if(current_balance < delta_amount) + return list("ok" = FALSE, "error" = "not_enough") + + var/table_player = format_table_name("player") + var/datum/db_query/take_query = SSdbcore.NewQuery( + "UPDATE [table_player] SET metacoins = metacoins - :delta WHERE ckey = :ckey AND metacoins >= :delta", + list( + "delta" = delta_amount, + "ckey" = target_ckey, + ), + ) + + if(!take_query.warn_execute(async = FALSE)) + qdel(take_query) + return list("ok" = FALSE, "error" = "db_failed") + qdel(take_query) + + var/new_balance = fetch_metacoin_balance(target_ckey) + if(isnull(new_balance)) + return list("ok" = FALSE, "error" = "db_failed") + + if(new_balance > (current_balance - delta_amount)) + return list("ok" = FALSE, "error" = "not_enough") + + return list( + "ok" = TRUE, + "balance" = new_balance, + ) + +/datum/metacoin_shop_controller/proc/try_purchase_preround_item(target_ckey, item_id) + if(!target_ckey || !item_id) + return list("ok" = FALSE, "error" = "invalid_request") + + if(!is_preround_purchase_open()) + return list("ok" = FALSE, "error" = "shop_closed") + + var/datum/metacoin_shop_listing/listing = preround_catalog[item_id] + if(!listing) + return list("ok" = FALSE, "error" = "unknown_item") + + if(listing.listing_kind == "antag_token") + return list("ok" = FALSE, "error" = "open_antag_panel") + + var/list/pending_items = preround_pending_by_ckey[target_ckey] + if(!islist(pending_items)) + pending_items = list() + preround_pending_by_ckey[target_ckey] = pending_items + + if(item_id in pending_items) + return list("ok" = FALSE, "error" = "already_owned") + + if(!SSdbcore.Connect()) + return list("ok" = FALSE, "error" = "db_unavailable") + + var/current_balance = fetch_metacoin_balance(target_ckey) + if(isnull(current_balance)) + return list("ok" = FALSE, "error" = "db_unavailable") + + if(current_balance < listing.price) + return list("ok" = FALSE, "error" = "not_enough") + + var/table_player = format_table_name("player") + var/datum/db_query/buy_query = SSdbcore.NewQuery( + "UPDATE [table_player] SET metacoins = metacoins - :price WHERE ckey = :ckey AND metacoins >= :price", + list( + "price" = listing.price, + "ckey" = target_ckey, + ), + ) + + if(!buy_query.warn_execute(async = FALSE)) + qdel(buy_query) + return list("ok" = FALSE, "error" = "db_failed") + qdel(buy_query) + + var/new_balance = fetch_metacoin_balance(target_ckey) + if(isnull(new_balance)) + return list("ok" = FALSE, "error" = "db_failed") + + if(new_balance > (current_balance - listing.price)) + return list("ok" = FALSE, "error" = "not_enough") + + pending_items += item_id + + var/mob/player_mob = get_mob_by_ckey(target_ckey) + if(player_mob) + to_chat(player_mob, span_boldnicegreen("Purchased [listing.name] for [listing.price] metacoins. It will be delivered on first roundstart spawn.")) + player_mob.playsound_local(player_mob, 'sound/effects/kaching.ogg', 40, TRUE, use_reverb = FALSE) + SStgui.update_user_uis(player_mob) + + return list("ok" = TRUE) + +/datum/metacoin_shop_controller/proc/try_purchase_antag_token(target_ckey, role_id) + if(!target_ckey || !role_id) + return list("ok" = FALSE, "error" = "invalid_request") + + if(!is_preround_purchase_open()) + return list("ok" = FALSE, "error" = "shop_closed") + + if(antag_token_pending_by_ckey[target_ckey]) + return list("ok" = FALSE, "error" = "already_owned") + + if(get_antag_token_slots_left() <= 0) + return list("ok" = FALSE, "error" = "sold_out") + + var/list/block_info = get_antag_token_role_block_info(target_ckey, role_id) + if(block_info) + return list("ok" = FALSE, "error" = block_info["code"]) + + var/datum/metacoin_shop_listing/listing = get_antag_token_listing() + if(!listing) + return list("ok" = FALSE, "error" = "unknown_item") + + if(!SSdbcore.Connect()) + return list("ok" = FALSE, "error" = "db_unavailable") + + var/current_balance = fetch_metacoin_balance(target_ckey) + if(isnull(current_balance)) + return list("ok" = FALSE, "error" = "db_unavailable") + + if(current_balance < listing.price) + return list("ok" = FALSE, "error" = "not_enough") + + var/table_player = format_table_name("player") + var/datum/db_query/buy_query = SSdbcore.NewQuery( + "UPDATE [table_player] SET metacoins = metacoins - :price WHERE ckey = :ckey AND metacoins >= :price", + list( + "price" = listing.price, + "ckey" = target_ckey, + ), + ) + + if(!buy_query.warn_execute(async = FALSE)) + qdel(buy_query) + return list("ok" = FALSE, "error" = "db_failed") + qdel(buy_query) + + var/new_balance = fetch_metacoin_balance(target_ckey) + if(isnull(new_balance)) + return list("ok" = FALSE, "error" = "db_failed") + + if(new_balance > (current_balance - listing.price)) + return list("ok" = FALSE, "error" = "not_enough") + + antag_token_pending_by_ckey[target_ckey] = role_id + antag_token_slots_left = max(antag_token_slots_left - 1, 0) + + var/mob/player_mob = get_mob_by_ckey(target_ckey) + if(player_mob) + var/role_name = get_antag_token_role_display_name(role_id) + to_chat(player_mob, span_boldnicegreen("Purchased Antag Token ([role_name]) for [listing.price] metacoins. It will be applied at roundstart.")) + player_mob.playsound_local(player_mob, 'sound/effects/kaching.ogg', 40, TRUE, use_reverb = FALSE) + SStgui.update_user_uis(player_mob) + + return list("ok" = TRUE) + +/datum/metacoin_shop_controller/proc/try_grant_antag_token_after_spawn(target_ckey, mob/living/spawned, client/player_client) + if(!target_ckey) + return + + var/selected_role = antag_token_pending_by_ckey[target_ckey] + if(!selected_role) + return + + log_game("[src] antag token grant attempt: ckey=[target_ckey], role=[selected_role], state=[SSticker?.current_state], round_started=[SSticker?.HasRoundStarted()], job=[spawned?.mind?.assigned_role?.title], has_client=[!isnull(spawned?.client)].") + + var/mob/notify_mob = ismob(spawned) ? spawned : get_mob_by_ckey(target_ckey) + var/datum/job/current_job = spawned?.mind?.assigned_role + var/list/block_info = get_antag_token_role_block_info(target_ckey, selected_role, current_job) + if(block_info) + var/failure_text = "Antag token could not be applied: [get_antag_token_role_block_text(block_info)]" + log_game("[src] antag token grant blocked for [target_ckey]: code=[block_info["code"]], job=[current_job?.title].") + refund_antag_token_purchase(target_ckey, failure_text, notify_mob) + return + + if(!ishuman(spawned)) + log_game("[src] antag token grant failed for [target_ckey]: spawned mob is not human ([spawned?.type]).") + refund_antag_token_purchase(target_ckey, "Antag token requires a human roundstart spawn.", notify_mob) + return + + var/mob/living/carbon/human/human_spawned = spawned + if(!human_spawned.mind) + log_game("[src] antag token grant failed for [target_ckey]: human has no mind.") + refund_antag_token_purchase(target_ckey, "Antag token failed: no valid player mind found.", notify_mob) + return + + var/list/role_definition = get_antag_token_role_definition(selected_role) + if(!role_definition) + log_game("[src] antag token grant failed for [target_ckey]: invalid role definition '[selected_role]'.") + refund_antag_token_purchase(target_ckey, "Antag token failed: selected role is invalid.", notify_mob) + return + + var/antag_datum_path = role_definition["antag_datum"] + var/datum/antagonist/created_antag = new antag_datum_path() + created_antag.silent = TRUE + human_spawned.mind.add_antag_datum(created_antag) + + var/datum/antagonist/granted_antag = human_spawned.mind.has_antag_datum(antag_datum_path, TRUE) + if(!granted_antag) + log_game("[src] antag token grant failed for [target_ckey]: antag datum [antag_datum_path] not present after add.") + refund_antag_token_purchase(target_ckey, "Antag token failed to grant the selected role.", notify_mob) + return + + addtimer(CALLBACK(src, PROC_REF(retry_show_antag_token_intro), target_ckey, granted_antag, 20), 1 SECONDS) + + antag_token_pending_by_ckey -= target_ckey + log_game("[src] antag token grant success for [target_ckey]: role=[selected_role], slots_left=[antag_token_slots_left].") + + /*if(notify_mob) + unnecessary actually. why do you think we have stinger sounds? + var/role_name = role_definition["name"] + to_chat(notify_mob, span_boldnicegreen("Antag token applied successfully: [role_name].")) + notify_mob.playsound_local(notify_mob, 'sound/misc/server-ready.ogg', 25, TRUE, use_reverb = FALSE) + */ + SStgui.update_uis(src) + +/datum/metacoin_shop_controller/proc/retry_show_antag_token_intro(target_ckey, datum/antagonist/granted_antag, attempts_left) + if(!target_ckey || !granted_antag || QDELETED(granted_antag)) + return + + var/mob/player_mob = granted_antag.owner?.current + if(!player_mob || ckey(player_mob.ckey) != target_ckey) + player_mob = get_mob_by_ckey(target_ckey) + + if(!player_mob?.client) + if(attempts_left > 0) + addtimer(CALLBACK(src, PROC_REF(retry_show_antag_token_intro), target_ckey, granted_antag, attempts_left - 1), 0.5 SECONDS) + return + + var/datum/action/antag_info/info_button = granted_antag.info_button_ref?.resolve() + if(granted_antag.ui_name && !info_button) + if(attempts_left > 0) + addtimer(CALLBACK(src, PROC_REF(retry_show_antag_token_intro), target_ckey, granted_antag, attempts_left - 1), 0.5 SECONDS) + return + + granted_antag.silent = FALSE + granted_antag.greet() + + if(granted_antag.ui_name) + to_chat(player_mob, span_boldnotice("For more info, read the panel. You can always come back to it using the button in the top left.")) + info_button?.Trigger(player_mob) + + var/type_policy = get_policy("[granted_antag.type]") + if(type_policy) + to_chat(player_mob, type_policy) + +/datum/metacoin_shop_controller/proc/on_job_after_spawn(datum/source, datum/job/job, mob/living/spawned, client/player_client) + SIGNAL_HANDLER + + if(!player_client) + return + + var/target_ckey = ckey(player_client.ckey) + var/selected_role = antag_token_pending_by_ckey[target_ckey] + if(selected_role) + log_game("[src] on_job_after_spawn for token owner [target_ckey]: role=[selected_role], state=[SSticker?.current_state], round_started=[SSticker?.HasRoundStarted()], job=[job?.title], assigned=[spawned?.mind?.assigned_role?.title].") + + if(SSticker?.HasRoundStarted()) + if(selected_role) + log_game("[src] skipping antag token grant for [target_ckey]: round already started in on_job_after_spawn.") + return + if(!target_ckey) + return + + try_grant_antag_token_after_spawn(target_ckey, spawned, player_client) + + if(!ishuman(spawned)) + return + + if(preround_delivered_by_ckey[target_ckey]) + return + + var/list/pending_items = preround_pending_by_ckey[target_ckey] + if(!islist(pending_items) || !length(pending_items)) + return + + var/mob/living/carbon/human/human_spawned = spawned + + for(var/item_id in pending_items) + var/datum/metacoin_shop_listing/listing = preround_catalog[item_id] + if(listing?.listing_kind != "item" || !listing?.item_type) + continue + + var/obj/item/new_item = new listing.item_type(human_spawned) + if(human_spawned.back?.atom_storage?.attempt_insert(new_item, human_spawned, override = TRUE)) + continue + + if(!human_spawned.put_in_hands(new_item)) + new_item.forceMove(get_turf(human_spawned)) + + preround_pending_by_ckey -= target_ckey + + preround_delivered_by_ckey[target_ckey] = TRUE + + to_chat(human_spawned, span_boldnicegreen("Your preround purchases were delivered.")) + + human_spawned.playsound_local(human_spawned, 'sound/misc/server-ready.ogg', 25, TRUE, use_reverb = FALSE) + +/datum/metacoin_shop_panel + var/client/owner + +/datum/metacoin_shop_panel/New(client/owner, mob/viewer) + src.owner = owner + ui_interact(viewer) + +/datum/metacoin_shop_panel/ui_state() + return GLOB.always_state + +/datum/metacoin_shop_panel/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "MetaCoinShop") + ui.open() + + +/datum/metacoin_shop_panel/ui_data(mob/user) + var/list/data = list() + var/client_ckey = owner?.ckey + var/datum/metacoin_shop_controller/shop = get_metacoin_shop_controller() + var/balance = shop.fetch_metacoin_balance(client_ckey) + + data["isPregame"] = shop.is_preround_purchase_open() + data["balance"] = isnull(balance) ? 0 : balance + data["antagTokenSlotsLeft"] = shop.get_antag_token_slots_left() + data["preroundItems"] = shop.get_catalog_ui_data(client_ckey) + data["persistentItems"] = list() + + return data + +/datum/metacoin_shop_panel/ui_act(action, params, datum/tgui/ui, datum/ui_state/state) + . = ..() + if(.) + return + + if(action == "open_slots") + new /datum/metacoin_slot_panel(owner, ui.user) + return TRUE + + if(action == "open_antag_token") + new /datum/metacoin_antag_token_panel(owner, ui.user) + return TRUE + + if(action == "buy_preround") + var/target_item = params["itemId"] + if(!target_item) + return FALSE + + var/result = get_metacoin_shop_controller().try_purchase_preround_item(owner?.ckey, target_item) + if(!result["ok"]) + var/mob/user_mob = ui?.user + if(user_mob) + switch(result["error"]) + if("shop_closed") + to_chat(user_mob, span_warning("Preround shop is only available before round start.")) + user_mob.playsound_local(user_mob, 'sound/machines/compiler/compiler-failure.ogg', 40, TRUE, use_reverb = FALSE) + if("open_antag_panel") + to_chat(user_mob, span_warning("Use the Antag Token picker window for this purchase.")) + user_mob.playsound_local(user_mob, 'sound/machines/compiler/compiler-failure.ogg', 40, TRUE, use_reverb = FALSE) + if("already_owned") + to_chat(user_mob, span_warning("You already purchased this item for this round.")) + user_mob.playsound_local(user_mob, 'sound/machines/compiler/compiler-failure.ogg', 40, TRUE, use_reverb = FALSE) + if("not_enough") + to_chat(user_mob, span_warning("Not enough metacoins.")) + user_mob.playsound_local(user_mob, 'sound/machines/compiler/compiler-failure.ogg', 40, TRUE, use_reverb = FALSE) + if("db_unavailable", "db_failed") + to_chat(user_mob, span_warning("Database error. Try again later.")) + user_mob.playsound_local(user_mob, 'sound/machines/compiler/compiler-failure.ogg', 40, TRUE, use_reverb = FALSE) + else + to_chat(user_mob, span_warning("Purchase failed.")) + user_mob.playsound_local(user_mob, 'sound/machines/compiler/compiler-failure.ogg', 40, TRUE, use_reverb = FALSE) + return FALSE +//those exist justin cause ^^ + return TRUE + + return FALSE + +/datum/metacoin_antag_token_panel + var/client/owner + +/datum/metacoin_antag_token_panel/New(client/owner, mob/viewer) + src.owner = owner + ui_interact(viewer) + +/datum/metacoin_antag_token_panel/ui_state() + return GLOB.always_state + +/datum/metacoin_antag_token_panel/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "MetaCoinAntagToken") + ui.open() + +/datum/metacoin_antag_token_panel/ui_assets(mob/user) + return list(get_asset_datum(/datum/asset/spritesheet_batched/antagonists)) +/datum/metacoin_antag_token_panel/ui_data(mob/user) + var/list/data = list() + var/client_ckey = owner?.ckey + var/datum/metacoin_shop_controller/shop = get_metacoin_shop_controller() + var/balance = shop.fetch_metacoin_balance(client_ckey) + var/selected_role = shop.antag_token_pending_by_ckey[client_ckey] + var/datum/metacoin_shop_listing/antag_listing = shop.get_antag_token_listing() + + data["isPregame"] = shop.is_preround_purchase_open() + data["balance"] = isnull(balance) ? 0 : balance + data["price"] = antag_listing?.price || 40 + data["slotsLeft"] = shop.get_antag_token_slots_left() + data["alreadyPurchased"] = !isnull(selected_role) + data["selectedRole"] = selected_role + data["selectedRoleName"] = shop.get_antag_token_role_display_name(selected_role) + data["roles"] = shop.get_antag_token_roles_ui_data(client_ckey) + data["restrictedJobPreferences"] = shop.get_antag_token_restricted_job_preferences_for_client(owner) + data["restrictedJobWarning"] = shop.get_antag_token_restricted_job_preferences_warning_for_client(owner) + + return data + +/datum/metacoin_antag_token_panel/ui_act(action, params, datum/tgui/ui, datum/ui_state/state) + . = ..() + if(.) + return + + if(action == "buy_antag_token_role") + var/role_id = params["roleId"] + if(!role_id) + return FALSE + + var/result = get_metacoin_shop_controller().try_purchase_antag_token(owner?.ckey, role_id) + if(result["ok"]) + return TRUE + + var/mob/user_mob = ui?.user + if(user_mob) + switch(result["error"]) + if("shop_closed") + to_chat(user_mob, span_warning("Antag token purchases are only available before round start.")) + if("already_owned") + to_chat(user_mob, span_warning("You already purchased an antag token this round.")) + if("sold_out") + to_chat(user_mob, span_warning("No antag tokens are left for this round.")) + if("job_banned") + to_chat(user_mob, span_warning("You are jobbanned from this antagonist role.")) + if("disabled_by_config") + to_chat(user_mob, span_warning("This role is disabled by dynamic config.")) + if("min_pop") + to_chat(user_mob, span_warning("Current population is too low for this role.")) + if("not_enough") + to_chat(user_mob, span_warning("Not enough metacoins.")) + if("db_unavailable", "db_failed") + to_chat(user_mob, span_warning("Database error. Try again later.")) + if("unknown_role") + to_chat(user_mob, span_warning("Selected role is not valid.")) + else + to_chat(user_mob, span_warning("Antag token purchase failed.")) + + user_mob.playsound_local(user_mob, 'sound/machines/compiler/compiler-failure.ogg', 40, TRUE, use_reverb = FALSE) +// those kinda too ^^ + return FALSE + + return FALSE + +/client/verb/view_metacoin_shop() + set name = "View Metacoin Shop" + set category = "OOC" + set desc = "Open metacoin shop window." + + new /datum/metacoin_shop_panel(src, usr) + +//TODO: Admin logs, paid metacoin deathmath matches, prettier ui, background change, more sounds upon clicks, diff --git a/modular_meta/features/metacoins/includes.dm b/modular_meta/features/metacoins/includes.dm new file mode 100644 index 000000000000..4a7df4e3bf27 --- /dev/null +++ b/modular_meta/features/metacoins/includes.dm @@ -0,0 +1,11 @@ +#include "code\metacoin.dm" +#include "code\metacoin_shop.dm" +#include "code\metacoin_gambling.dm" + +/datum/modpack/metacoins + id = "metacoins" + name = "Metacoins" + group = "Features" + //icon = "" someone pls add + desc = "Мета-валюта и пре-раунд гемблинг!" + author = "Bruh24" diff --git a/modular_meta/features/metacoins/readme.md b/modular_meta/features/metacoins/readme.md new file mode 100644 index 000000000000..88b7b6f28d68 --- /dev/null +++ b/modular_meta/features/metacoins/readme.md @@ -0,0 +1,29 @@ +## Module ID: metacoins + +### Description: + +### TG Proc/File Changes: + +code\datums\achievements_awards.dm - 94 - 99 - every achievement gives you some coins! +tgui\packages\tgui\interfaces\DeathmatchPanel.tsx +tgui\packages\tgui\interfaces\DeathmatchLobby.tsx - those two are for paid matches :D +code\modules\deathmatch\deathmatch_controller.dm - + +### Modular Overrides: + +TODO: + +### Defines: + +- + +### TGUI Files: + +- tgui\packages\tgui\interfaces\MetaCoinAntagToken.tsx +- tgui\packages\tgui\interfaces\MetaCoins.tsx +- tgui\packages\tgui\interfaces\MetaCoinShop.tsx +- tgui\packages\tgui\interfaces\MetaCoinSlot.tsx + +### Credits: + +- diff --git a/modular_meta/main_modular_include.dm b/modular_meta/main_modular_include.dm index 0e4139b6544c..d40b627616c8 100644 --- a/modular_meta/main_modular_include.dm +++ b/modular_meta/main_modular_include.dm @@ -45,6 +45,7 @@ #include "features\novichok\includes.dm" #include "features\jukeboxes_to_bartender\includes.dm" #include "features\bot_topic\includes.dm" +#include "features\metacoins\includes.dm" #include "features\spaceman_races\includes.dm" /* --- Reverts --- */ diff --git a/tgui/packages/tgui/interfaces/DeathmatchLobby.tsx b/tgui/packages/tgui/interfaces/DeathmatchLobby.tsx index 33d622f75a62..f041bed90f49 100644 --- a/tgui/packages/tgui/interfaces/DeathmatchLobby.tsx +++ b/tgui/packages/tgui/interfaces/DeathmatchLobby.tsx @@ -46,6 +46,9 @@ type Map = { type Data = { active_mods: string; admin: BooleanLike; + // MASSMETA EDIT ADDITION START (metacoins) + entry_fee: number; + // MASSMETA EDIT ADDITION END (metacoins) host: BooleanLike; loadoutdesc: string; loadouts: string[]; @@ -56,6 +59,9 @@ type Data = { observers: Player[]; players: Player[]; playing: BooleanLike; + //MASSMETA EDIT ADDITION START (metacoins) + prize_pool: number; + // MASSMETA EDIT ADDITION END (metacoins) self: string; }; @@ -282,14 +288,32 @@ function PlayerColumn(props) { function HostControls(props) { const { act, data } = useBackend(); - const { active_mods = [], admin, host, loadoutdesc, playing } = data; - + /* MASSMETA EDIT ADDITION START (metacoins) */ + const { + active_mods = [], + admin, + entry_fee, + host, + loadoutdesc, + playing, + prize_pool, + } = data; + /* MASSMETA EDIT ADDITION END (metacoins) */ const fullAccess = !!host || !!admin; return (
+ + {/* MASSMETA EDIT ADDITION START (metacoins) */} + {entry_fee || 0} + + {/* MASSMETA EDIT ADDITION END (metacoins) */} + {prize_pool || 0} + + + {active_mods} diff --git a/tgui/packages/tgui/interfaces/DeathmatchPanel.tsx b/tgui/packages/tgui/interfaces/DeathmatchPanel.tsx index 25935614a767..e920da2435e8 100644 --- a/tgui/packages/tgui/interfaces/DeathmatchPanel.tsx +++ b/tgui/packages/tgui/interfaces/DeathmatchPanel.tsx @@ -1,8 +1,12 @@ +import { useState } from 'react'; + import { + Box, Button, Dropdown, Icon, NoticeBox, + NumberInput, Section, Stack, Table, @@ -19,6 +23,10 @@ type Lobby = { max_players: number; map: string; playing: BooleanLike; + /* MASSMETA EDIT ADDITION START (metacoins) */ + entry_fee: number; + prize_pool: number; + /* MASSMETA EDIT ADDITION START (metacoins) */ }; type Data = { @@ -31,9 +39,21 @@ type Data = { export function DeathmatchPanel(props) { const { act, data } = useBackend(); const { hosting } = data; + /* MASSMETA EDIT ADDITION START (metacoins) */ + const feeOptions = ['0', '30', '50', '60', '80', '100', 'Custom']; + const [selectedFee, setSelectedFee] = useState('0'); + const [customFee, setCustomFee] = useState(0); + const customMode = selectedFee === 'Custom'; + const resolvedFee = customMode + ? Math.min(1000, Math.max(0, Math.round(customFee))) + : Math.min(1000, Math.max(0, Math.round(Number(selectedFee) || 0))); + /* MASSMETA EDIT ADDITION START (metacoins) */ return ( - + // MASSMETA EDIT ADDITION START (metacoins) + //more width and height to fit the icons + + {/* MASSMETA EDIT ADDITION START (metacoins) */} @@ -46,15 +66,66 @@ export function DeathmatchPanel(props) { - + {/* MASSMETA EDIT CHANGE START (metacoins) + original: + + + */} + + + Entry fee: + + + setSelectedFee(String(value))} + /> + + {customMode && ( + + + setCustomFee( + Math.min(1000, Math.max(0, Math.round(value))), + ) + } + /> + + )} + + + + + {/*MASSMETA EDIT CHANGE END (metacoins) */} @@ -71,7 +142,19 @@ function LobbyPane(props) { Host + {/*MASSMETA EDIT ADDITION START (metacoins) */} Map + + + + + + + + + + + {/* MASSMETA EDIT ADDITION END (metacoins) */} @@ -84,7 +167,14 @@ function LobbyPane(props) { {lobbies.length === 0 && ( - + {/* MASSMETA EDIT ADDITION START (metacoins) */} + + {/* MASSMETA EDIT ADDITION END (metacoins) */} No lobbies found. Start one! @@ -128,6 +218,14 @@ function LobbyDisplay(props) { )} {lobby.map} + {/* MASSMETA EDIT ADDITION START (metacoins) */} + + {lobby.entry_fee || 0} + + + {lobby.prize_pool || 0} + + {/* MASSMETA EDIT ADDITION END (metacoins) */} {lobby.players}/{lobby.max_players} diff --git a/tgui/packages/tgui/interfaces/MetaCoinAntagToken.tsx b/tgui/packages/tgui/interfaces/MetaCoinAntagToken.tsx new file mode 100644 index 000000000000..988f7061b6a1 --- /dev/null +++ b/tgui/packages/tgui/interfaces/MetaCoinAntagToken.tsx @@ -0,0 +1,221 @@ +import { + Box, + Button, + Icon, + NoticeBox, + Section, + Stack, +} from 'tgui-core/components'; +import { classes } from 'tgui-core/react'; + +import { useBackend } from '../backend'; +import { Window } from '../layouts'; + +type RoleOption = { + id: string; + name: string; + desc: string; + prefIconClass?: string; + fallbackIcon?: string; + available: boolean; + unavailableReason?: string | null; + unavailableCode?: string | null; + minPopCurrent?: number | null; + minPopRequired?: number | null; +}; + +type Data = { + isPregame: boolean; + balance: number; + price: number; + slotsLeft: number; + alreadyPurchased: boolean | number; + selectedRoleName?: string | null; + roles?: RoleOption[] | Record | null; + restrictedJobPreferences?: string[] | null; + restrictedJobWarning?: string | null; +}; + +export const MetaCoinAntagToken = () => { + const { act, data } = useBackend(); + const { + isPregame, + balance, + price, + slotsLeft, + alreadyPurchased, + selectedRoleName, + roles: rawRoles = [], + restrictedJobPreferences: rawRestrictedJobPreferences = [], + restrictedJobWarning, + } = data; + + const roles = ( + Array.isArray(rawRoles) ? rawRoles : Object.values(rawRoles || {}) + ).filter( + (role): role is RoleOption => + !!role && typeof role === 'object' && 'id' in role, + ); + + const hasPurchasedToken = Boolean(alreadyPurchased); + + const restrictedJobPreferences = Array.isArray(rawRestrictedJobPreferences) + ? rawRestrictedJobPreferences.filter( + (job): job is string => !!job && typeof job === 'string', + ) + : []; + + const restrictedJobsWarningText = + restrictedJobWarning || + (restrictedJobPreferences.length + ? `Warning: restricted job preferences enabled (${restrictedJobPreferences.join(', ')}). If assigned at roundstart, token will be refunded.` + : null); + + const canBuyToken = + isPregame && !hasPurchasedToken && slotsLeft > 0 && balance >= price; + + return ( + + + {!isPregame && ( + + Antag token can be purchased only before round start. + + )} + + + Press Ready before round start to receive the selected + antagonist role. + + +
+ + Balance: {balance} + + + Token price: {price} + + 0 ? 'average' : 'bad'}> + Tokens left this round: {slotsLeft} + +
+ + {hasPurchasedToken && ( + + You already purchased an antag token for this round. + {selectedRoleName ? ` Selected role: ${selectedRoleName}.` : ''} + + )} + + {!!restrictedJobsWarningText && ( + {restrictedJobsWarningText} + )} + +
+ + {roles.map((role) => { + const fallbackName = role.fallbackIcon || 'question-circle'; + const fallbackNode = ; + const roleDisabled = !canBuyToken || !role.available; + const unavailableReasonText = + role.unavailableReason || 'Role is unavailable right now.'; + const iconBorderColor = roleDisabled + ? 'var(--color-red)' + : 'var(--color-green)'; + + return ( + +
+ act('buy_antag_token_role', { + roleId: role.id, + }) + } + > + Choose + + } + > + + {role.prefIconClass ? ( + + + + ) : ( + + {fallbackNode} + + )} + + + {role.desc} + + {!role.available && role.unavailableCode === 'min_pop' && ( + + {`Not enough population (${Number(role.minPopCurrent ?? 0)}/${Number(role.minPopRequired ?? 0)}).`} + + )} + + {!role.available && role.unavailableCode !== 'min_pop' && ( + + {unavailableReasonText} + + )} +
+
+ ); + })} +
+
+
+
+ ); +}; diff --git a/tgui/packages/tgui/interfaces/MetaCoinShop.tsx b/tgui/packages/tgui/interfaces/MetaCoinShop.tsx new file mode 100644 index 000000000000..89076704a8ee --- /dev/null +++ b/tgui/packages/tgui/interfaces/MetaCoinShop.tsx @@ -0,0 +1,227 @@ +import { + Box, + Button, + DmIcon, + Icon, + NoticeBox, + Section, + Stack, + Tabs, +} from 'tgui-core/components'; + +import { useBackend, useLocalState } from '../backend'; +import { Window } from '../layouts'; + +type ShopItem = { + id: string; + kind?: 'item' | 'antag_token'; + name: string; + desc: string; + price: number; + icon?: string; + iconState?: string; + fallbackIcon?: string; + owned: boolean; + canAfford: boolean; + tokensLeft?: number; + selectedRoleName?: string | null; +}; + +type Data = { + isPregame: boolean; + balance: number; + antagTokenSlotsLeft: number; + preroundItems: ShopItem[]; + persistentItems: ShopItem[]; +}; + +// MASSMETA EDIT ADDITION START (metacoins) +const renderListingIcon = (item: ShopItem) => { + const fallbackName = item.fallbackIcon || 'question-circle'; + const fallbackNode = ; + + if (item.icon && item.iconState) { + return ( + + ); + } + + return fallbackNode; +}; +// MASSMETA EDIT ADDITION END (metacoins) + +export const MetaCoinShop = () => { + const { act, data } = useBackend(); + const { isPregame, balance, preroundItems = [] } = data; + + const [activeTab, setActiveTab] = useLocalState<'preround' | 'persistent'>( + 'metacoinShopTab', + 'preround', + ); + + return ( + + +
act('open_slots')}> + Open Slot Machine + + } + > + + {balance} + +
+ +
+ + setActiveTab('preround')} + > + Preround shop + + setActiveTab('persistent')} + > + Persistent rewards + + +
+ + {activeTab === 'preround' && ( +
+ {isPregame + ? 'Purchases are open (pregame)' + : 'Purchases are closed'} + + } + > + {!preroundItems.length ? ( + No items available. + ) : ( + + {preroundItems.map((item) => ( + + {(() => { + const isAntagToken = item.kind === 'antag_token'; + const owned = Boolean(item.owned); + const canAfford = Boolean(item.canAfford); + const tokensLeft = Number(item.tokensLeft || 0); + const tokenSoldOut = isAntagToken && tokensLeft <= 0; + + const buttonDisabled = isAntagToken + ? !isPregame || owned || !canAfford || tokenSoldOut + : !isPregame || owned || !canAfford; + + const buttonText = isAntagToken + ? `Choose Role (${item.price})` + : `Buy (${item.price})`; + + const buttonAction = () => { + if (isAntagToken) { + act('open_antag_token'); + return; + } + + act('buy_preround', { + itemId: item.id, + }); + }; + + return ( +
+ {buttonText} + + } + > + + {renderListingIcon(item)} + + {item.desc} + + Price: {item.price} + + + {isAntagToken && ( + 0 ? 'average' : 'bad'} + > + Tokens left this round: {tokensLeft} + + )} + + {isAntagToken && + item.selectedRoleName && + owned && ( + + Reserved role: {item.selectedRoleName} + + )} + + {owned && !isAntagToken && ( + + Already purchased for this round. + + )} + + {owned && isAntagToken && ( + + Antag token already purchased for this round. + + )} + + {!canAfford && !owned && ( + + Not enough metacoins. + + )} + + {tokenSoldOut && !owned && ( + + No antag tokens left this round. + + )} + + +
+ ); + })()} +
+ ))} +
+ )} +
+ )} + + {activeTab === 'persistent' && ( +
+ Persistent rewards are not implemented yet. +
+ )} +
+
+ ); +}; diff --git a/tgui/packages/tgui/interfaces/MetaCoinSlot.tsx b/tgui/packages/tgui/interfaces/MetaCoinSlot.tsx new file mode 100644 index 000000000000..6671b1a6a611 --- /dev/null +++ b/tgui/packages/tgui/interfaces/MetaCoinSlot.tsx @@ -0,0 +1,388 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { + Box, + Button, + Icon, + NoticeBox, + Section, + Stack, + Table, +} from 'tgui-core/components'; + +import { useBackend } from '../backend'; +import { Window } from '../layouts'; + +type ReelSymbol = { + icon_name: string; + colour: string; +}; + +type LastSpin = { + lineLength: number; + payout: number; + isJackpot: boolean; + net: number; + resultState: 'idle' | 'loss' | 'win' | 'jackpot'; +}; + +type HistoryEntry = { + time: string; + lineLength: number; + payout: number; + net: number; + isJackpot: boolean; +}; + +type Data = { + isPregame: boolean; + isObserver: boolean; + working: boolean; + balance: number; + icons: ReelSymbol[]; + state: ReelSymbol[][]; + lastSpin: LastSpin; + history: HistoryEntry[]; + cooldownLeftDs: number; + cost: number; + payoutLine3: number; + payoutLine4: number; + payoutLine5: number; + payoutJackpot: number; +}; + +type ReelProps = { + reel: ReelSymbol[]; +}; + +type TileProps = { + symbol: ReelSymbol; +}; + +const Reel = (props: ReelProps) => { + const { reel } = props; + return ( + + {reel.map((symbol, index) => ( + + ))} + + ); +}; + +const Tile = (props: TileProps) => { + const { symbol } = props; + return ( + + + + ); +}; + +const getResultView = (lastSpin: LastSpin | undefined) => { + if (!lastSpin) { + return { + text: 'Ready to spin', + color: 'label' as const, + }; + } + + switch (lastSpin.resultState) { + case 'jackpot': + return { + text: `JACKPOT +${lastSpin.payout}`, + color: 'good' as const, + }; + case 'win': + return { + text: `WIN +${lastSpin.payout}`, + color: 'good' as const, + }; + case 'loss': + return { + text: `LOSE ${lastSpin.net}`, + color: 'bad' as const, + }; + default: + return { + text: 'Ready to spin', + color: 'label' as const, + }; + } +}; + +export const MetaCoinSlot = () => { + const { act, data } = useBackend(); + const { + isPregame, + isObserver, + working, + balance, + icons = [], + state = [], + lastSpin, + history: historyRaw = [], + cooldownLeftDs, + cost, + payoutLine3, + payoutLine4, + payoutLine5, + payoutJackpot, + } = data; + + const history = Array.isArray(historyRaw) ? historyRaw : []; + + const [displayState, setDisplayState] = useState(state); + const [localRolling, setLocalRolling] = useState(false); + const [rollMinEndsAtMs, setRollMinEndsAtMs] = useState(0); + const [cooldownUntilMs, setCooldownUntilMs] = useState(0); + const [clockMs, setClockMs] = useState(Date.now()); + const [frozenLastSpin, setFrozenLastSpin] = useState( + lastSpin, + ); + const [frozenHistory, setFrozenHistory] = useState(history); + + const iconPool = useMemo(() => { + if (icons.length) { + return icons; + } + return [ + { + icon_name: 'question', + colour: 'white', + }, + ]; + }, [icons]); + + useEffect(() => { + if (!localRolling) { + setDisplayState(state); + } + }, [state, localRolling]); + + useEffect(() => { + if (!cooldownLeftDs) { + return; + } + + setCooldownUntilMs(Date.now() + cooldownLeftDs * 100); + }, [cooldownLeftDs]); + + useEffect(() => { + const interval = window.setInterval(() => { + setClockMs(Date.now()); + }, 100); + + return () => window.clearInterval(interval); + }, []); + + useEffect(() => { + if (!localRolling) { + return; + } + + const interval = window.setInterval(() => { + if (Date.now() >= rollMinEndsAtMs && !working) { + setLocalRolling(false); + setDisplayState(state); + window.clearInterval(interval); + return; + } + + setDisplayState((previous) => + previous.map((reel) => + reel.map(() => iconPool[Math.floor(Math.random() * iconPool.length)]), + ), + ); + }, 70); + + return () => window.clearInterval(interval); + }, [iconPool, localRolling, rollMinEndsAtMs, state, working]); + + const cooldownMsLeft = Math.max(0, cooldownUntilMs - clockMs); + const cooldownActive = cooldownMsLeft > 0; + const cooldownSeconds = (cooldownMsLeft / 1000).toFixed(1); + + const displayedLastSpin = localRolling ? frozenLastSpin : lastSpin; + const displayedHistory = localRolling ? frozenHistory : history; + const resultView = getResultView(displayedLastSpin); + const spinLocked = + working || + localRolling || + (!isPregame && !isObserver) || + balance < cost || + cooldownActive; + + const handleSpin = () => { + if (spinLocked) { + return; + } + + setFrozenLastSpin(lastSpin); + setFrozenHistory(history); + setRollMinEndsAtMs(Date.now() + 500); + setLocalRolling(true); + act('spin'); + }; + + return ( + + + {!isPregame && !isObserver && ( + + Slot machine is available only before round start. + + )} + +
+ + + + Balance: {balance} + + + Spin cost: {cost} + + + {cooldownActive + ? `Cooldown: ${cooldownSeconds}s` + : 'Spin ready'} + + + + + + +
+ +
+ + {resultView.text} + + + + + Line length: {displayedLastSpin?.lineLength || 0} + + + + + Payout: {displayedLastSpin?.payout || 0} + + + + = 0 ? 'good' : 'bad'}> + Net: {displayedLastSpin?.net || 0} + + + +
+ +
+
+ + Condition + + Reward + + + + 3 in a row + {payoutLine3} + + + 4 in a row + {payoutLine4} + + + 5 in a row + {payoutLine5} + + + Jackpot (middle row 7 7 7 7 7) + {payoutJackpot} + +
+
+ +
+ + {displayState.map((reel, index) => ( + + ))} + +
+ +
+ {!displayedHistory.length ? ( + No spins yet. + ) : ( + + + + Time + + + Line + + + Payout + + + Net + + + {displayedHistory.map((entry, index) => ( + + {entry.time} + + {entry.lineLength} + + + {entry.payout} + + = 0 ? 'good' : 'bad'} + > + {entry.net} + + + ))} +
+ )} +
+ + + ); +}; diff --git a/tgui/packages/tgui/interfaces/MetaCoins.tsx b/tgui/packages/tgui/interfaces/MetaCoins.tsx new file mode 100644 index 000000000000..8503e6a2e877 --- /dev/null +++ b/tgui/packages/tgui/interfaces/MetaCoins.tsx @@ -0,0 +1,154 @@ +import { + Box, + Button, + DmIcon, + NoticeBox, + Section, + Stack, + Table, + Tabs, +} from 'tgui-core/components'; +import { useBackend, useLocalState } from '../backend'; +import { Window } from '../layouts'; + +type Data = { + dbConnected: boolean; + balance: number; + coinIcon: string; + coinIconState: string; + roundAwardsApplied: boolean; + roundAwarded: number; + roundAwardLog: RewardEntry[]; + canOpenShop: boolean; +}; + +type RewardEntry = { + amount: number; + source: string; + reason: string; + time: string; +}; + +export const MetaCoins = () => { + const { act, data } = useBackend(); + const { + dbConnected, + balance, + coinIcon, + coinIconState, + roundAwardsApplied, + roundAwarded, + roundAwardLog, + canOpenShop, + } = data; + const [activeTab, setActiveTab] = useLocalState<'overview' | 'log'>( + 'metacoinTab', + 'overview', + ); + + return ( + + + {!dbConnected && ( + + Database connection is unavailable right now. + + )} + +
+ + setActiveTab('overview')} + > + Overview + + setActiveTab('log')} + > + Round Reward Log + + +
+ + {activeTab === 'overview' && ( +
act('open_shop')}> + Open Shop + + ) : null + } + > + + + + + + + + + {balance} + + + +
+ )} + + {activeTab === 'log' && ( +
+ {roundAwardsApplied + ? `Round total: +${roundAwarded}` + : 'Round-end rewards are not processed yet'} + + } + > + {!roundAwardLog?.length ? ( + + No metacoin rewards were granted in this round yet. + + ) : ( + + + + Time + + Reason + + Amount + + + {roundAwardLog.map((entry, index) => ( + + {entry.time || 'N/A'} + + {entry.reason || entry.source || 'Unknown'} + + 0 ? 'good' : 'bad'} + > + {entry.amount > 0 ? `+${entry.amount}` : entry.amount} + + + ))} +
+ )} +
+ )} +
+
+ ); +};