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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user