Move the synergy catalog from the run HUD to the pause menu

The purple Combos button lived in the run HUD button_area; move it to a
"Combos" row in the pause menu (Escape > under Settings, before
restart_button) so the play screen stays clear. Same overlay, same tabs;
Mods > Combo Advisor is unchanged.

restart_button is an id vanilla only builds during a run, so the row never
reaches the main menu. Because the pause menu builds its node list as a table
constructor with conditional entries (an unseeded run leaves a hole at the
seed row's index), find_trail/insert_node walk and shift by explicit numeric
key rather than ipairs/table.insert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-27 12:44:37 -07:00
parent 124e8da8ed
commit a66faadc21
5 changed files with 356 additions and 240 deletions
+151 -72
View File
@@ -166,6 +166,35 @@ end
create_UIBox_game_over = function() return button_tree('from_game_over') end
create_UIBox_win = function() return button_tree(RECAP_ANCHOR) end
-- Pause menu. Same deal: main.lua wraps it at load and inserts the Combos row
-- beside 'restart_button', which vanilla only builds during a run -- so
-- OPTIONS_IN_RUN lets the suite check that the main-menu shape stays untouched.
-- UIBox_button hangs the id on an INNER node (UI_definitions.lua:6825), which is
-- why the insert walks a level up; a flatter stub would not catch that.
OPTIONS_IN_RUN = true
OPTIONS_SEEDED = false
UIBox_button = function(args)
return {n = 'R', config = {}, nodes = {
{n = 'C', config = {id = args.id, button = args.button},
nodes = {{n = 'T', config = {text = args.label[1]}}}},
}}
end
-- The HOLE at index 2 is the point. Vanilla writes the seed row as
-- `G.GAME.seeded and current_seed or nil` (UI_definitions.lua:2469), so an
-- unseeded run really does hand us {settings, nil, restart, ...} -- and ipairs
-- stops at that hole, one index short of the anchor.
create_UIBox_options = function()
local contents = {UIBox_button{label = {'Settings'}, button = 'settings'}}
contents[2] = OPTIONS_SEEDED
and UIBox_button{label = {'Copy Seed'}, button = 'copy_seed'} or nil
if OPTIONS_IN_RUN then
contents[3] = UIBox_button{id = 'restart_button',
label = {'New Run'}, button = 'setup_run'}
end
contents[4] = UIBox_button{label = {'Main Menu'}, button = 'go_to_menu'}
return {n = 'ROOT', nodes = {{n = 'C', nodes = {{n = 'R', nodes = contents}}}}}
end
local data = dofile('synergies.lua')
dofile('main.lua')
@@ -560,6 +589,128 @@ G.GAME = {}
ok(not has_recap(create_UIBox_win()), 'a run with no combos gets no recap row')
G.GAME = {}
--------------------------------------------------------------------------------
section('Engine: the catalog opens from the pause menu, in a run only')
--------------------------------------------------------------------------------
-- The Combos row replaced a HUD button, so the pause menu is now the only way
-- into the catalog mid-run. It is inserted beside 'restart_button', which only
-- exists during a run: on the main menu the walk must find nothing and leave
-- the vanilla menu alone rather than guessing at a position.
--
-- This reads the menu back the way the GAME does -- pairs, in index order
-- (engine/ui.lua:273) -- because an ipairs reader would stop at the seed hole
-- and cheerfully report a menu vanilla never shows.
local function menu_labels(node, out)
out = out or {}
if type(node) ~= 'table' then return out end
if node.config and node.config.button then
out[#out + 1] = node.config.button
end
local nodes, last = node.nodes or {}, 0
for k in pairs(nodes) do
if type(k) == 'number' and k > last then last = k end
end
for i = 1, last do menu_labels(nodes[i], out) end
return out
end
local in_run = menu_labels(create_UIBox_options())
eq(table.concat(in_run, ','), 'settings,jca_open_catalog,setup_run,go_to_menu',
'the Combos row sits under Settings, above Start New Run')
-- A seeded run fills the hole with the seed row; the anchor moves, and the
-- Combos row has to move with it rather than sit at a remembered index.
OPTIONS_SEEDED = true
eq(table.concat(menu_labels(create_UIBox_options()), ','),
'settings,copy_seed,jca_open_catalog,setup_run,go_to_menu',
'a seeded run keeps the row directly above Start New Run')
OPTIONS_SEEDED = false
OPTIONS_IN_RUN = false
eq(table.concat(menu_labels(create_UIBox_options()), ','), 'settings,go_to_menu',
'the main menu keeps its vanilla rows -- no anchor, no insert')
OPTIONS_IN_RUN = true
ok(type(G.FUNCS.jca_open_catalog) == 'function',
'and the button it fires is registered')
--------------------------------------------------------------------------------
section('UI: advice rows are measured, so the tooltip cannot run off-screen')
--------------------------------------------------------------------------------
-- info_tip_from_rows sets no maxw (UI_definitions.lua:1124), so the hover box
-- grows to its widest row. Counting characters cannot bound that: the same
-- string is 40% wider in touch mode. Everything below is in GAME UNITS, which
-- are resolution-independent (G.TILESCALE cancels in engine/ui.lua:144).
ok(JCA.text_width('mm', 0.3) > JCA.text_width('m', 0.3), 'width grows with length')
ok(JCA.text_width('m', 0.6) > JCA.text_width('m', 0.3), 'and with text scale')
eq(JCA.row_width_budget(), 20 * 0.24, 'the budget is a fraction of the room width')
-- A wider room means a wider budget: read, never hardcoded.
local saved_room = G.ROOM
G.ROOM = {T = {w = 40}}
eq(JCA.row_width_budget(), 40 * 0.24, 'a wider room raises the budget')
G.ROOM = saved_room
local function widest(lines, scale)
local w = 0
for _, l in ipairs(lines) do w = math.max(w, JCA.text_width(l, scale)) end
return w
end
local long = 'Fuses into Collectible Chaos Card ($9) - still needs Flash Card'
for _, scale in ipairs({0.3, 0.32, 0.42, 0.448}) do
ok(widest(JCA.wrap_measured(long, scale), scale) <= JCA.row_width_budget(),
('wrapping holds the budget at scale %.3f'):format(scale))
end
-- A word longer than the whole budget keeps its own line: a clipped joker name
-- is worse than one slightly wide row.
local huge = string.rep('W', 60)
eq(#JCA.wrap_measured(huge, 0.3), 1, 'an over-long word is never split mid-word')
eq(#JCA.wrap_measured('', 0.3), 1, 'an empty string still yields one line')
-- The real thing: render every advice row for a spread of boards and assert
-- none of them can push the box off the screen, in either text scale.
local function every_row_fits(card, label)
local budget = JCA.row_width_budget()
for _, row in ipairs(JCA._tooltip_rows(card)) do
local w, txt = 0, ''
for _, node in ipairs(row) do
if node.config and node.config.text then
w = w + JCA.text_width(node.config.text, node.config.scale)
txt = txt .. node.config.text
end
end
if w > budget + 0.001 then
return ok(false, label, ('%.2fu > %.2fu: %q'):format(w, budget, txt))
end
end
return ok(true, label)
end
local saved_touch = JCA.config.touch_mode
G.GAME = {dollars = 4}
for _, touch in ipairs({false, true}) do
JCA.config.touch_mode = touch
local tag = touch and 'touch mode' or 'normal'
-- A joker with three partners, a clash, and a long chain of explanations.
field('j_pareidolia', 'j_sock_and_buskin', 'j_photograph', 'j_smiley')
every_row_fits(G.jokers.cards[1], 'a crowded partner list fits (' .. tag .. ')')
-- The clash line: label plus warning is the widest two-tone row there is.
field('j_vampire', 'j_ticket')
every_row_fits(G.jokers.cards[2], 'a clash warning fits (' .. tag .. ')')
-- A fusion recipe naming a long result AND a long missing component.
field('j_greedy_joker')
every_row_fits(G.jokers.cards[1], 'a fusion recipe fits (' .. tag .. ')')
-- No partners: the "Looking for:" tag list can be long on its own.
field('j_bloodstone')
every_row_fits(G.jokers.cards[1], 'a long "Looking for" list fits (' .. tag .. ')')
end
JCA.config.touch_mode = saved_touch
G.GAME = {}
--------------------------------------------------------------------------------
section('Engine: deck context counts base suits')
--------------------------------------------------------------------------------
@@ -1108,78 +1259,6 @@ build('Traps last page', function()
G.FUNCS.jca_page_Traps{cycle_config = {current_option = 999}}
end)
--------------------------------------------------------------------------------
section('UI: advice rows fit the tooltip gutter')
--------------------------------------------------------------------------------
-- The hover info popup is right-anchored to the card and grows leftward with no
-- screen clamp, so a row wider than the shop's left gutter runs off-screen and
-- clips (the reported bug). Every rendered line -- summed across its coloured
-- segments -- must stay within the budget, however long the name or blurb.
local BUDGET = JCA._row_budget
-- wrap_text: never exceeds the budget, never splits a word, loses no content.
do
local long = 'Fuses into Obelisk ($12) - still needs Wrathful Joker and Blueprint'
local lines = JCA._wrap_text(long, BUDGET)
local wide = false
for _, l in ipairs(lines) do if #l > BUDGET then wide = true end end
ok(not wide, 'wrap_text keeps every line within budget')
eq(table.concat(lines, ' '), long, 'wrap_text loses no words')
eq(#JCA._wrap_text(string.rep('x', BUDGET + 10), BUDGET), 1,
'wrap_text leaves an unbreakable long word on its own line')
-- Continuation lines arrive pre-indented (" Baron, Mime"); the indent must
-- survive and still count against the budget.
local indented = JCA._wrap_text(' ' .. string.rep('a', BUDGET - 2), BUDGET)
eq(#indented, 1, 'an indented line at budget does not wrap')
ok(indented[1]:sub(1, 2) == ' ', 'wrap_text keeps the leading indent')
ok(#indented[1] <= BUDGET, 'indent counts against the budget')
end
-- row_budget sizes each row to the card's actual gutter, using the game's own
-- text metric. Window size never enters it (the room is a constant in units);
-- only the card's position does. With no geometry it must fall back to the fixed
-- budget so headless callers -- and this suite -- keep a stable width.
do
eq(JCA._row_budget_for({}), BUDGET,
'row_budget falls back to the fixed budget with no card geometry')
-- Stub the font metric: getWidth in "pixels", scaled to ~0.15 units/char.
G.TILESIZE = 0.32 / 0.15
G.LANG = {font = {squish = 1, FONTSCALE = 1,
FONT = {getWidth = function(_, s) return #s end}}}
local roomy = JCA._row_budget_for({T = {x = 17, w = 1}}) -- far right, wide gutter
local tight = JCA._row_budget_for({T = {x = 8.1, w = 1}}) -- just past the flip
ok(roomy > tight, 'a card with more room gets a wider budget',
('roomy=%d tight=%d'):format(roomy, tight))
ok(tight >= 20 and roomy <= 64, 'the budget stays within a sane clamp',
('roomy=%d tight=%d'):format(roomy, tight))
G.TILESIZE, G.LANG = nil, nil
end
-- The widest real row is a clash against a long-named joker carrying a full
-- 34-char blurb: "Clashes - <name>: <blurb>" is ~64 chars and must wrap.
G.shop_jokers = {}
G.jokers = {cards = {{config = {center = {key = 'j_gluttenous_joker', set = 'Joker'}}}}}
JCA.register_clash('j_widetest', 'j_gluttenous_joker', string.rep('x', BLURB_MAX))
local hovered = {
config = {center = {key = 'j_widetest', set = 'Joker'}},
ability = {}, area = G.shop_jokers,
}
local rows = JCA._tooltip_rows(hovered)
local widest, saw_clash = 0, false
for _, row in ipairs(rows) do
local w = 0
for _, seg in ipairs(row) do w = w + #((seg.config or {}).text or '') end
if w > widest then widest = w end
for _, seg in ipairs(row) do
if ((seg.config or {}).text or ''):find('Clashes') then saw_clash = true end
end
end
ok(saw_clash, 'the clash line is present to be measured')
ok(widest <= BUDGET, 'every advice row fits the budget',
('widest row is %d chars (budget %d)'):format(widest, BUDGET))
G.jokers, G.shop_jokers = nil, nil
--------------------------------------------------------------------------------
print(('\n%d passed, %d failed'):format(pass, fail))
os.exit(fail == 0 and 0 or 1)