diff --git a/lib/joker_stats.lua b/lib/joker_stats.lua index b3a0f19d..18a63247 100644 --- a/lib/joker_stats.lua +++ b/lib/joker_stats.lua @@ -36,9 +36,96 @@ 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 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(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, + 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. +--- 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" +function MP.STATS.on_joker_removed(card, reason) + if not MP.LOBBY.code then return end + -- First try exact card reference match + for i = #MP.STATS.joker_lifecycle, 1, -1 do + local entry = MP.STATS.joker_lifecycle[i] + 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 + 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 + +--- 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, + 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 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", diff --git a/overrides/game.lua b/overrides/game.lua index e1ff308a..01434902 100644 --- a/overrides/game.lua +++ b/overrides/game.lua @@ -11,6 +11,10 @@ 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 + MP.STATS.on_joker_removed(self, "sold") + end end return sell_card_ref(self) end @@ -39,6 +43,12 @@ 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" + MP.STATS.on_joker_acquired(c1, key, edition, c1.cost, "shop") + end end return buy_from_shop_ref(e) end