From b4e4f8e23e374d9c8ff3af421759836bd12039f4 Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 22 Jul 2026 15:22:47 -0700 Subject: [PATCH] Read the perishable timer instead of assuming it, and fix a pcall idiom Two fixes from a review pass, plus the first tests to reach the post-run recap at all. The perishable caution said "gains die in 5 rounds" as a literal, but the count is a game variable (G.GAME.perishable_rounds, game.lua:1961) and vanilla's own sticker tooltip reads it (common_events.lua:3339). A challenge or another mod moving it made the advisor state a wrong number, in the one place this mod promises mechanical facts. It now reads perish_tally when the sticker is already ticking, falls back to the game value, then to 5, and says "1 round" rather than "1 rounds". The new tests fail against the old literal, so this one was real. The win screen's recap fallback tested only the second pcall return: local _, inserted = pcall(insert_recap, ui, 'from_game_won', 2, 1) if not inserted then ... win_cta ... end The first return is the ok flag, so on a throw `inserted` holds the error MESSAGE -- a truthy string -- and a failure reads as a success. That is a trap worth removing, but being honest about its blast radius: it is NOT currently reachable. Every way the first call can throw (recap_row, or find_trail on a malformed tree) throws identically for win_cta, and the table.insert cannot go out of bounds because spot.index indexes the very list being inserted into. The whole suite passes with the old idiom. This is a latent correctness fix, not a live bug -- it stops the trap biting if insert_recap ever grows an anchor-specific failure. The recap machinery (recap_row, find_trail, insert_recap) had no coverage. It does now, including the win_cta fallback and the empty-run case. 925 tests pass on 5.4 and LuaJIT, 18/18 in the real game. Co-Authored-By: Claude Opus 4.8 (1M context) --- main.lua | 18 ++++++++++++--- test.lua | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/main.lua b/main.lua index 98710f7..ca2d740 100644 --- a/main.lua +++ b/main.lua @@ -342,7 +342,14 @@ function JCA.sticker_cautions(card) local key = card.config and card.config.center and card.config.center.key local a = card.ability or {} if a.perishable and key and JCA.scalers[key] then - out[#out + 1] = 'perishable: gains die in 5 rounds' + -- Read the round count, never hardcode it: perishable_rounds is a game + -- variable (game.lua:1961 defaults it to 5, and vanilla's own sticker + -- tooltip reads it at common_events.lua:3339), so a challenge or + -- another mod can move it. perish_tally is what this card has left + -- (card.lua:680) and is the honest number when it is already ticking. + local n = a.perish_tally or (G.GAME and G.GAME.perishable_rounds) or 5 + out[#out + 1] = ('perishable: gains die in %d round%s') + :format(n, n == 1 and '' or 's') end if a.rental and key and JCA.db[key] and JCA.db[key].gives.money then out[#out + 1] = 'rental: rent may eat its payout' @@ -1282,12 +1289,17 @@ end -- Win: the buttons live inside the right-hand stats column; two levels up -- places the recap below the whole two-column stats row. 'win_cta' replaces -- 'from_game_won' when the CTA variant of the screen is shown. +-- +-- Keep BOTH pcall returns. The first is the ok flag; on a throw the second is +-- the error MESSAGE, which is a truthy string -- so testing it alone reads a +-- failure as a success and skips the win_cta fallback, which exists precisely +-- because the two win screens anchor at different ids. if create_UIBox_win then local orig_win = create_UIBox_win function create_UIBox_win(...) local ui = orig_win(...) - local _, inserted = pcall(insert_recap, ui, 'from_game_won', 2, 1) - if not inserted then pcall(insert_recap, ui, 'win_cta', 2, 1) end + local ok, inserted = pcall(insert_recap, ui, 'from_game_won', 2, 1) + if not (ok and inserted) then pcall(insert_recap, ui, 'win_cta', 2, 1) end return ui end end diff --git a/test.lua b/test.lua index 4c4cca6..5eabbf5 100644 --- a/test.lua +++ b/test.lua @@ -148,6 +148,24 @@ for _, f in ipairs(FusionJokers.fusions) do end end +-- Post-run recap screens. main.lua wraps these at load, so they have to exist +-- before it is required. Each returns a FRESH tree shaped like vanilla's +-- (root > column > row > button), because the recap is inserted by walking up +-- from the button id. RECAP_ANCHOR picks which id the win screen exposes, so +-- the win_cta fallback can be exercised. +RECAP_ANCHOR = 'from_game_won' +local function button_tree(id) + return {n = 'ROOT', nodes = { + {n = 'C', nodes = { + {n = 'R', nodes = { + {n = 'C', config = {id = id}}, + }}, + }}, + }} +end +create_UIBox_game_over = function() return button_tree('from_game_over') end +create_UIBox_win = function() return button_tree(RECAP_ANCHOR) end + local data = dofile('synergies.lua') dofile('main.lua') @@ -506,6 +524,42 @@ eq(G.GAME.jca_peak_synergy, 7, 'selling down does not lower the recorded peak') G.GAME = {} JCA.config.discovered, JCA.config.themes_found = saved_disc, saved_themes +-------------------------------------------------------------------------------- +section('Engine: the post-run recap finds its anchor on both screens') +-------------------------------------------------------------------------------- + +-- The recap is inserted relative to a vanilla button id by walking UP the +-- parent trail, and the two win screens anchor at different ids -- so the +-- fallback is the whole point of the hook, not decoration. +local function tree_texts(node, out) + out = out or {} + if type(node) ~= 'table' then return out end + if node.config and node.config.text then out[#out + 1] = node.config.text end + for _, child in ipairs(node.nodes or {}) do tree_texts(child, out) end + return out +end +local function has_recap(ui) + for _, t in ipairs(tree_texts(ui)) do + if t == 'Combos fielded this run' then return true end + end + return false +end + +G.GAME = {jca_run_combos = {['j_baron|j_mime'] = true}} +ok(has_recap(create_UIBox_game_over()), 'the game over screen gets the recap') +ok(has_recap(create_UIBox_win()), 'so does the win screen, at its own anchor') + +-- The CTA variant of the win screen replaces 'from_game_won' with 'win_cta'; +-- the first insert finds nothing and the fallback has to carry it. +RECAP_ANCHOR = 'win_cta' +ok(has_recap(create_UIBox_win()), 'and the win_cta variant falls back correctly') +RECAP_ANCHOR = 'from_game_won' + +-- Nothing fielded and no peak: no row at all, rather than an empty heading. +G.GAME = {} +ok(not has_recap(create_UIBox_win()), 'a run with no combos gets no recap row') +G.GAME = {} + -------------------------------------------------------------------------------- section('Engine: deck context counts base suits') -------------------------------------------------------------------------------- @@ -708,6 +762,20 @@ eq(#JCA.sticker_cautions(stickered('j_ride_the_bus', {})), 0, eq(#JCA.sticker_cautions(stickered('j_mod_unknown', {perishable = true, rental = true})), 0, 'unknown keys never warn and never error') +-- The round count is a GAME VARIABLE (G.GAME.perishable_rounds, game.lua:1961), +-- not the constant 5 -- a challenge or another mod can move it, and vanilla's +-- own sticker tooltip reads it. Hardcoding it would state a wrong number. +G.GAME = {perishable_rounds = 3} +eq(JCA.sticker_cautions(stickered('j_ride_the_bus', {perishable = true}))[1], + 'perishable: gains die in 3 rounds', 'the caution reads the game round count') +eq(JCA.sticker_cautions(stickered('j_ride_the_bus', + {perishable = true, perish_tally = 1}))[1], + 'perishable: gains die in 1 round', + 'a ticking sticker reports what is LEFT, and says "round" once') +G.GAME = {} +eq(JCA.sticker_cautions(stickered('j_ride_the_bus', {perishable = true}))[1], + 'perishable: gains die in 5 rounds', 'falling back to vanilla 5 outside a run') + -------------------------------------------------------------------------------- section('Data: themes are reachable') --------------------------------------------------------------------------------