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}
+
+
+ ))}
+
+ )}
+
+ )}
+
+
+ );
+};