From 5be5ce59414e47e46bc2e97ddd2fb2a03599404e Mon Sep 17 00:00:00 2001 From: adilw3nomad Date: Sat, 28 Feb 2026 21:55:18 +0000 Subject: [PATCH 1/6] feat(stats): add joker lifecycle tracking to joker_stats.lua Add on_joker_acquired/on_joker_removed functions to track joker buy/sell events throughout a match. build_joker_report generates a telemetry payload at match end, and record_match now sends it via matchJokerReport action before resetting lifecycle state. --- lib/joker_stats.lua | 74 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/lib/joker_stats.lua b/lib/joker_stats.lua index b3a0f19d..6984ddaf 100644 --- a/lib/joker_stats.lua +++ b/lib/joker_stats.lua @@ -36,9 +36,83 @@ function MP.STATS.record_match(won) end SMODS.save_mod_config(SMODS.Mods["Multiplayer"]) + + -- Send telemetry to server + local joker_report = MP.STATS.build_joker_report(won) + if #joker_report > 0 then + MP.ACTIONS.match_joker_report(won, joker_report) + end + MP.STATS.reset_lifecycle() end function MP.STATS.get_joker_wins(joker_key) local config = SMODS.Mods["Multiplayer"].config return config.joker_stats and config.joker_stats[joker_key] or 0 end + +-- Joker lifecycle tracking (reset each match) +MP.STATS.joker_lifecycle = {} + +--- Call when a joker is added to the player's deck. +--- @param key string -- e.g. "j_pizza", "j_mp_speedrun" +--- @param edition string -- e.g. "polychrome", "foil", "none" +--- @param seal string -- e.g. "eternal", "perishable", "none" +--- @param cost number -- gold spent (0 if free) +--- @param source string -- "shop", "booster", "tag", "other" +function MP.STATS.on_joker_acquired(key, edition, seal, cost, source) + if not MP.LOBBY.code then return end -- only track in multiplayer + table.insert(MP.STATS.joker_lifecycle, { + key = key, + edition = edition or "none", + seal = seal or "none", + cost = cost or 0, + source = source or "other", + ante_acquired = G.GAME.round_resets and G.GAME.round_resets.ante or 1, + ante_removed = nil, + removal_reason = nil, + }) +end + +--- Call when a joker is removed from the player's deck. +--- Finds the most recent un-removed entry for this key. +--- @param key string +--- @param reason string -- "sold", "destroyed", "perishable", "traded" +function MP.STATS.on_joker_removed(key, reason) + if not MP.LOBBY.code then return end + -- Walk backwards to find the most recent un-removed entry + for i = #MP.STATS.joker_lifecycle, 1, -1 do + local entry = MP.STATS.joker_lifecycle[i] + if entry.key == key and entry.ante_removed == nil then + entry.ante_removed = G.GAME.round_resets and G.GAME.round_resets.ante or 1 + entry.removal_reason = reason + break + end + end +end + +--- Build the match joker report payload. +--- Called from record_match() after the existing local-save logic. +function MP.STATS.build_joker_report(won) + local report = {} + local final_ante = G.GAME.round_resets and G.GAME.round_resets.ante or 1 + for _, entry in ipairs(MP.STATS.joker_lifecycle) do + table.insert(report, { + key = entry.key, + edition = entry.edition, + seal = entry.seal, + cost = entry.cost, + source = entry.source, + ante_acquired = entry.ante_acquired, + ante_removed = entry.ante_removed, -- nil if held to end + removal_reason = entry.removal_reason, -- nil if held to end + held_at_end = entry.ante_removed == nil, + hold_duration_antes = (entry.ante_removed or final_ante) - entry.ante_acquired, + }) + end + return report +end + +--- Reset lifecycle tracking for a new match. +function MP.STATS.reset_lifecycle() + MP.STATS.joker_lifecycle = {} +end From 7bece0d7136e80944bd5cbefa01c3ca056951bc9 Mon Sep 17 00:00:00 2001 From: adilw3nomad Date: Sat, 28 Feb 2026 21:56:36 +0000 Subject: [PATCH 2/6] feat(telemetry): hook buy/sell/add_to_deck for joker tracking --- overrides/game.lua | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/overrides/game.lua b/overrides/game.lua index e1ff308a..7fb39f3e 100644 --- a/overrides/game.lua +++ b/overrides/game.lua @@ -11,6 +11,11 @@ function Card:sell_card() string.format("Client sent message: action:soldCard,card:%s", self.ability.name), "MULTIPLAYER" ) + -- Track joker removals for telemetry + if self.config and self.config.center and self.config.center.set == "Joker" then + local key = self.config.center.key + MP.STATS.on_joker_removed(key, "sold") + end end return sell_card_ref(self) end @@ -39,10 +44,42 @@ function G.FUNCS.buy_from_shop(e) string.format("Client sent message: action:boughtCardFromShop,card:%s,cost:%s", c1.ability.name, c1.cost), "MULTIPLAYER" ) + -- Track joker acquisitions for telemetry + if c1.config and c1.config.center and c1.config.center.set == "Joker" then + local key = c1.config.center.key + local edition = (c1.edition and c1.edition.type) or "none" + local seal = c1.seal or "none" + MP.STATS.on_joker_acquired(key, edition, seal, c1.cost, "shop") + end end return buy_from_shop_ref(e) end +-- Track joker acquisitions from non-shop sources (boosters, tags, etc.) +local add_to_deck_ref = Card.add_to_deck +function Card:add_to_deck(from_debuff) + if self.config and self.config.center and self.config.center.set == "Joker" then + if not (self.edition and self.edition.type == "mp_phantom") then + local key = self.config.center.key + -- Check if this joker was already tracked via shop purchase + local already_tracked = false + for i = #MP.STATS.joker_lifecycle, 1, -1 do + local entry = MP.STATS.joker_lifecycle[i] + if entry.key == key and entry.ante_removed == nil and entry.source == "shop" then + already_tracked = true + break + end + end + if not already_tracked then + local edition = (self.edition and self.edition.type) or "none" + local seal = self.seal or "none" + MP.STATS.on_joker_acquired(key, edition, seal, 0, "other") + end + end + end + return add_to_deck_ref(self, from_debuff) +end + local use_card_ref = G.FUNCS.use_card function G.FUNCS.use_card(e, mute, nosave) if e.config and e.config.ref_table and e.config.ref_table.ability and e.config.ref_table.ability.name then From 3bdb34c1337eb0646fe642f1ec32e6ab7d3edcf4 Mon Sep 17 00:00:00 2001 From: adilw3nomad Date: Sat, 28 Feb 2026 21:59:09 +0000 Subject: [PATCH 3/6] feat(telemetry): add matchJokerReport action and lifecycle reset on game start --- networking/action_handlers.lua | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua index fb24354d..3a1ac709 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -112,6 +112,7 @@ end ---@param stake_str string local function action_start_game(seed, stake_str) MP.reset_game_states() + MP.STATS.reset_lifecycle() local stake = tonumber(stake_str) MP.ACTIONS.set_ante(0) if not MP.LOBBY.config.different_seeds and MP.LOBBY.config.custom_seed ~= "random" then @@ -934,6 +935,19 @@ function MP.ACTIONS.send_game_stats() action_send_game_stats() end +function MP.ACTIONS.match_joker_report(won, joker_report) + Client.send({ + action = "matchJokerReport", + won = won, + stake = MP.LOBBY.config.stake or 1, + deck = MP.LOBBY.config.back or "Red Deck", + gamemode = MP.LOBBY.config.gamemode or "gamemode_mp_attrition", + ruleset = MP.LOBBY.config.ruleset or "ruleset_mp_blitz", + ante_reached = G.GAME.round_resets and G.GAME.round_resets.ante or 1, + jokers = joker_report, + }) +end + function MP.ACTIONS.request_nemesis_stats() Client.send({ action = "endGameStatsRequested", From 565b1590ab7ef8ed890f654905ef5fc324c90e0d Mon Sep 17 00:00:00 2001 From: adilw3nomad Date: Sat, 28 Feb 2026 22:01:22 +0000 Subject: [PATCH 4/6] fix(telemetry): use card object refs to handle duplicate joker keys --- lib/joker_stats.lua | 30 ++++++++++++++++++++++-------- overrides/game.lua | 13 ++++++------- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/lib/joker_stats.lua b/lib/joker_stats.lua index 6984ddaf..4a0f584a 100644 --- a/lib/joker_stats.lua +++ b/lib/joker_stats.lua @@ -54,14 +54,16 @@ end MP.STATS.joker_lifecycle = {} --- Call when a joker is added to the player's deck. +--- @param card table -- the Card object reference (used to match on removal) --- @param key string -- e.g. "j_pizza", "j_mp_speedrun" --- @param edition string -- e.g. "polychrome", "foil", "none" --- @param seal string -- e.g. "eternal", "perishable", "none" --- @param cost number -- gold spent (0 if free) --- @param source string -- "shop", "booster", "tag", "other" -function MP.STATS.on_joker_acquired(key, edition, seal, cost, source) +function MP.STATS.on_joker_acquired(card, key, edition, seal, cost, source) if not MP.LOBBY.code then return end -- only track in multiplayer table.insert(MP.STATS.joker_lifecycle, { + card_ref = card, key = key, edition = edition or "none", seal = seal or "none", @@ -74,18 +76,30 @@ function MP.STATS.on_joker_acquired(key, edition, seal, cost, source) end --- Call when a joker is removed from the player's deck. ---- Finds the most recent un-removed entry for this key. ---- @param key string ---- @param reason string -- "sold", "destroyed", "perishable", "traded" -function MP.STATS.on_joker_removed(key, reason) +--- Matches by card object reference first, falls back to key match. +--- @param card table -- the Card object being removed +--- @param reason string -- "sold", "destroyed", "perishable", "traded" +function MP.STATS.on_joker_removed(card, reason) if not MP.LOBBY.code then return end - -- Walk backwards to find the most recent un-removed entry + -- First try exact card reference match for i = #MP.STATS.joker_lifecycle, 1, -1 do local entry = MP.STATS.joker_lifecycle[i] - if entry.key == key and entry.ante_removed == nil then + if entry.card_ref == card and entry.ante_removed == nil then entry.ante_removed = G.GAME.round_resets and G.GAME.round_resets.ante or 1 entry.removal_reason = reason - break + return + end + end + -- Fallback: match by key (for cases where card ref isn't available) + local key = card.config and card.config.center and card.config.center.key + if key then + for i = #MP.STATS.joker_lifecycle, 1, -1 do + local entry = MP.STATS.joker_lifecycle[i] + if entry.key == key and entry.ante_removed == nil then + entry.ante_removed = G.GAME.round_resets and G.GAME.round_resets.ante or 1 + entry.removal_reason = reason + return + end end end end diff --git a/overrides/game.lua b/overrides/game.lua index 7fb39f3e..58017ac7 100644 --- a/overrides/game.lua +++ b/overrides/game.lua @@ -13,8 +13,7 @@ function Card:sell_card() ) -- Track joker removals for telemetry if self.config and self.config.center and self.config.center.set == "Joker" then - local key = self.config.center.key - MP.STATS.on_joker_removed(key, "sold") + MP.STATS.on_joker_removed(self, "sold") end end return sell_card_ref(self) @@ -49,7 +48,7 @@ function G.FUNCS.buy_from_shop(e) local key = c1.config.center.key local edition = (c1.edition and c1.edition.type) or "none" local seal = c1.seal or "none" - MP.STATS.on_joker_acquired(key, edition, seal, c1.cost, "shop") + MP.STATS.on_joker_acquired(c1, key, edition, seal, c1.cost, "shop") end end return buy_from_shop_ref(e) @@ -60,20 +59,20 @@ local add_to_deck_ref = Card.add_to_deck function Card:add_to_deck(from_debuff) if self.config and self.config.center and self.config.center.set == "Joker" then if not (self.edition and self.edition.type == "mp_phantom") then - local key = self.config.center.key - -- Check if this joker was already tracked via shop purchase + -- Check if this exact card was already tracked via shop purchase local already_tracked = false for i = #MP.STATS.joker_lifecycle, 1, -1 do local entry = MP.STATS.joker_lifecycle[i] - if entry.key == key and entry.ante_removed == nil and entry.source == "shop" then + if entry.card_ref == self and entry.ante_removed == nil then already_tracked = true break end end if not already_tracked then + local key = self.config.center.key local edition = (self.edition and self.edition.type) or "none" local seal = self.seal or "none" - MP.STATS.on_joker_acquired(key, edition, seal, 0, "other") + MP.STATS.on_joker_acquired(self, key, edition, seal, 0, "other") end end end From 9b178c6a7754400278da69d3250a4cd532c7d3ec Mon Sep 17 00:00:00 2001 From: adilw3nomad Date: Sat, 28 Feb 2026 22:33:02 +0000 Subject: [PATCH 5/6] remove unused code --- lib/joker_stats.lua | 2 +- overrides/game.lua | 25 ------------------------- 2 files changed, 1 insertion(+), 26 deletions(-) diff --git a/lib/joker_stats.lua b/lib/joker_stats.lua index 4a0f584a..199bdd7f 100644 --- a/lib/joker_stats.lua +++ b/lib/joker_stats.lua @@ -78,7 +78,7 @@ end --- Call when a joker is removed from the player's deck. --- Matches by card object reference first, falls back to key match. --- @param card table -- the Card object being removed ---- @param reason string -- "sold", "destroyed", "perishable", "traded" +--- @param reason string -- "sold", "destroyed", "perishable" function MP.STATS.on_joker_removed(card, reason) if not MP.LOBBY.code then return end -- First try exact card reference match diff --git a/overrides/game.lua b/overrides/game.lua index 58017ac7..9402191e 100644 --- a/overrides/game.lua +++ b/overrides/game.lua @@ -54,31 +54,6 @@ function G.FUNCS.buy_from_shop(e) return buy_from_shop_ref(e) end --- Track joker acquisitions from non-shop sources (boosters, tags, etc.) -local add_to_deck_ref = Card.add_to_deck -function Card:add_to_deck(from_debuff) - if self.config and self.config.center and self.config.center.set == "Joker" then - if not (self.edition and self.edition.type == "mp_phantom") then - -- Check if this exact card was already tracked via shop purchase - local already_tracked = false - for i = #MP.STATS.joker_lifecycle, 1, -1 do - local entry = MP.STATS.joker_lifecycle[i] - if entry.card_ref == self and entry.ante_removed == nil then - already_tracked = true - break - end - end - if not already_tracked then - local key = self.config.center.key - local edition = (self.edition and self.edition.type) or "none" - local seal = self.seal or "none" - MP.STATS.on_joker_acquired(self, key, edition, seal, 0, "other") - end - end - end - return add_to_deck_ref(self, from_debuff) -end - local use_card_ref = G.FUNCS.use_card function G.FUNCS.use_card(e, mute, nosave) if e.config and e.config.ref_table and e.config.ref_table.ability and e.config.ref_table.ability.name then From 5b3eaaa7bc955a82f47de1e460cdb5562a188193 Mon Sep 17 00:00:00 2001 From: adilw3nomad Date: Sun, 1 Mar 2026 09:41:20 +0000 Subject: [PATCH 6/6] remove seal joker acquired and match report as u can't get seals on jokers --- lib/joker_stats.lua | 3 +-- overrides/game.lua | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/joker_stats.lua b/lib/joker_stats.lua index 199bdd7f..18a63247 100644 --- a/lib/joker_stats.lua +++ b/lib/joker_stats.lua @@ -60,7 +60,7 @@ MP.STATS.joker_lifecycle = {} --- @param seal string -- e.g. "eternal", "perishable", "none" --- @param cost number -- gold spent (0 if free) --- @param source string -- "shop", "booster", "tag", "other" -function MP.STATS.on_joker_acquired(card, key, edition, seal, cost, source) +function MP.STATS.on_joker_acquired(card, key, edition, cost, source) if not MP.LOBBY.code then return end -- only track in multiplayer table.insert(MP.STATS.joker_lifecycle, { card_ref = card, @@ -113,7 +113,6 @@ function MP.STATS.build_joker_report(won) table.insert(report, { key = entry.key, edition = entry.edition, - seal = entry.seal, cost = entry.cost, source = entry.source, ante_acquired = entry.ante_acquired, diff --git a/overrides/game.lua b/overrides/game.lua index 9402191e..01434902 100644 --- a/overrides/game.lua +++ b/overrides/game.lua @@ -47,8 +47,7 @@ function G.FUNCS.buy_from_shop(e) if c1.config and c1.config.center and c1.config.center.set == "Joker" then local key = c1.config.center.key local edition = (c1.edition and c1.edition.type) or "none" - local seal = c1.seal or "none" - MP.STATS.on_joker_acquired(c1, key, edition, seal, c1.cost, "shop") + MP.STATS.on_joker_acquired(c1, key, edition, c1.cost, "shop") end end return buy_from_shop_ref(e)