Read the perishable timer instead of assuming it, and fix a pcall idiom
test / test (push) Successful in 14s

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) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-22 15:22:47 -07:00
parent 319f79bc5d
commit b4e4f8e23e
2 changed files with 83 additions and 3 deletions
+15 -3
View File
@@ -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
+68
View File
@@ -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')
--------------------------------------------------------------------------------