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
+5
View File
@@ -2,6 +2,11 @@
## Unreleased ## Unreleased
### Changed
- The synergy catalog now opens from the **pause menu** (Escape > Combos, under
Settings) instead of a button in the run HUD, which keeps the play screen
clear. Same overlay, same tabs; Mods > Combo Advisor is unchanged.
### Fusion Jokers integration ### Fusion Jokers integration
- When [Fusion Jokers](https://github.com/wingedcatgirl/Fusion-Jokers) is - When [Fusion Jokers](https://github.com/wingedcatgirl/Fusion-Jokers) is
installed, hovering any joker that is a fusion component names the fusion it installed, hovering any joker that is a fusion component names the fusion it
+14 -3
View File
@@ -169,9 +169,10 @@ Three-layer split; `JCA` is the single global namespace:
in with no animation on tab open and page switch. Only the first card of a in with no animation on tab open and page switch. Only the first card of a
page build plays the sound (vanilla's `i>1 or j>1` silence flag); tab defs page build plays the sound (vanilla's `i>1 or j>1` silence flag); tab defs
call `begin_page()` so the counter spans the page, not one row. The same call `begin_page()` so the counter spans the page, not one row. The same
tabs open in-run via a "Combos" HUD button (wrapped `create_UIBox_HUD`, tabs open in-run via a "Combos" row in the pause menu (wrapped
injected into the `button_area` node under Run Info/Options); the pagers `create_UIBox_options`, inserted before `restart_button` — an id vanilla
work there because vanilla `create_tabs` also names its body only builds during a run, so the row never reaches the main menu); the
pagers work there because vanilla `create_tabs` also names its body
`tab_contents`. `tab_contents`.
- Discovery: `CardArea:emplace` hook calls `JCA.check_discoveries` when a - Discovery: `CardArea:emplace` hook calls `JCA.check_discoveries` when a
joker lands in `G.jokers`; famous pairs fielded together persist in joker lands in `G.jokers`; famous pairs fielded together persist in
@@ -262,6 +263,16 @@ Three-layer split; `JCA` is the single global namespace:
"fix" them: `j_gluttenous_joker`, `j_selzer`, `j_ticket` (Golden Ticket), "fix" them: `j_gluttenous_joker`, `j_selzer`, `j_ticket` (Golden Ticket),
`j_trousers` (Spare Trousers), `j_ring_master` (Showman), `j_caino` (Canio), `j_trousers` (Spare Trousers), `j_ring_master` (Showman), `j_caino` (Canio),
`j_delayed_grat`, `j_todo_list`. `j_delayed_grat`, `j_todo_list`.
- **Never walk a vanilla node tree with `ipairs`** (or resize one with
`table.insert`/`#`). Vanilla builds node lists as table constructors with
conditional entries — the pause menu's seed row is
`G.GAME.seeded and current_seed or nil` (`UI_definitions.lua:2469`) — so an
unseeded run leaves a **hole** at index 2 with real nodes above it. The game
never notices because it iterates with `pairs` (`engine/ui.lua:273`); `ipairs`
stops dead at the hole. `find_trail`/`insert_node` in `main.lua` walk and
shift by explicit index, bounded by the highest numeric key, and any test or
smoke assertion that reads a menu back must do the same or it will report a
menu no player ever sees.
- Unknown joker keys (from other mods) must keep scoring 0 — never index - Unknown joker keys (from other mods) must keep scoring 0 — never index
`JCA.db[key]` without a nil guard. `JCA.db[key]` without a nil guard.
- Display names go through `localize{type='name_text', set='Joker', ...}` with a - Display names go through `localize{type='name_text', set='Joker', ...}` with a
+2 -2
View File
@@ -41,8 +41,8 @@ recommends joker combos while you play:
lineup — and, when your board is full, a "weakest combo piece" flag on lineup — and, when your board is full, a "weakest combo piece" flag on
the joker contributing least to the build, so you know the natural cut the joker contributing least to the build, so you know the natural cut
when something better shows up. when something better shows up.
- **Browse the synergy catalog** any time: a purple **Combos** button in the - **Browse the synergy catalog** any time: a **Combos** row in the pause menu
run HUD (under Run Info / Options) opens it in an overlay, and the same (Escape, under Settings) opens it in an overlay, and the same
tabs live under Mods > Combo Advisor. The *Combos* tab shows the famous tabs live under Mods > Combo Advisor. The *Combos* tab shows the famous
pairs as card duos with their explanation once fielded; *Engine* / pairs as card duos with their explanation once fielded; *Engine* /
*Economy* / *Hands* tabs page through each synergy theme with a one-line *Economy* / *Hands* tabs page through each synergy theme with a one-line
+179 -158
View File
@@ -561,6 +561,79 @@ end
-- Hover tooltip --------------------------------------------------------------- -- Hover tooltip ---------------------------------------------------------------
-- Tooltip width -----------------------------------------------------------
-- Advice rows are plain strings, but the hover box grows to fit the WIDEST of
-- them: vanilla's info_tip_from_rows sets no `maxw` on its rows
-- (UI_definitions.lua:1124), unlike desc_from_rows which accepts one. So a
-- single long row (a fusion recipe, a copy chain, a boss caution) pushes the
-- whole box off the edge of the screen, and counting characters cannot catch
-- it -- the same 60 characters are 40% wider in touch mode and wider again in
-- a CJK font.
--
-- So measure, with the same arithmetic the engine uses (engine/ui.lua:144):
-- units = FONT:getWidth(s) * squish * scale * FONTSCALE / TILESIZE
-- G.TILESCALE appears in both the numerator and the denominator there and
-- cancels, which is worth stating plainly: Balatro's layout is
-- resolution-INDEPENDENT. love.resize keeps G.ROOM.T.w pinned to the constant
-- G.TILE_W (main.lua:1331, globals.lua:282 = 20) and only changes how many
-- pixels a unit is worth. A bigger window does not buy the tooltip more room,
-- so the budget below is a fraction of the room, not of the screen.
local FALLBACK_UNITS_PER_CHAR = 0.41 -- m6x11plus, measured; headless only
local ROW_WIDTH_FRACTION = 0.24 -- ~4.8 of the room's 20 units
-- Width of `str` at `scale`, in game units.
function JCA.text_width(str, scale)
local f = G.LANG and G.LANG.font
if f and f.FONT and G.TILESIZE then
local ok, w = pcall(function()
return f.FONT:getWidth(str) * (f.squish or 1) * scale
* (f.FONTSCALE or 0.1) / G.TILESIZE
end)
if ok and type(w) == 'number' and w > 0 then return w end
end
-- Outside the game (tests) there is no font: fall back to a per-character
-- estimate so callers still get proportional, deterministic answers.
return #tostring(str) * scale * FALLBACK_UNITS_PER_CHAR
end
-- How wide one advice row may be, in game units. Read from the room rather
-- than hardcoded, so it still holds if a future Balatro changes G.TILE_W.
function JCA.row_width_budget()
local room = G.ROOM and G.ROOM.T and G.ROOM.T.w
return (type(room) == 'number' and room > 0 and room or 20) * ROW_WIDTH_FRACTION
end
-- Word-wrap `str` to the measured budget, returning finished lines with the
-- hanging `indent` already applied to continuations.
--
-- The indent is RESERVED while wrapping, not bolted on afterwards. Adding it
-- after the fact is exactly how a line that just fits becomes one that does
-- not, and it is a quiet two-space overflow that no amount of eyeballing
-- catches -- the suite caught it here.
--
-- A single word wider than the budget keeps its own over-long line rather than
-- being cut mid-word: a clipped joker name is worse than a slightly wide row.
function JCA.wrap_measured(str, scale, budget, indent)
budget = budget or JCA.row_width_budget()
indent = indent == nil and ' ' or indent
local pad = indent ~= '' and JCA.text_width(indent, scale) or 0
local lines, line = {}, nil
for word in tostring(str):gmatch('%S+') do
local nxt = line and (line .. ' ' .. word) or word
local limit = #lines == 0 and budget or budget - pad
if line and JCA.text_width(nxt, scale) > limit then
lines[#lines + 1] = line
line = word
else
line = nxt
end
end
if line then lines[#lines + 1] = line end
if #lines == 0 then return {''} end
for i = 2, #lines do lines[i] = indent .. lines[i] end
return lines
end
-- Wrap partner names into short tooltip lines. -- Wrap partner names into short tooltip lines.
local function wrap_names(partners, max_width, max_lines) local function wrap_names(partners, max_width, max_lines)
local lines, line = {}, nil local lines, line = {}, nil
@@ -581,91 +654,6 @@ local function wrap_names(partners, max_width, max_lines)
return lines return lines
end end
-- Every advice line has to fit the shop's left gutter. The hover info popup is
-- right-anchored to the card and grows LEFTWARD with no screen clamp -- vanilla
-- align_h_popup leaves lr_clamp off (UI_definitions.lua) -- so its width is set
-- by its single widest row, and a row wider than the gutter runs off the left
-- edge and clips (the reported bug: a long "Fuses into X - still needs Y" line).
-- Keep every rendered line at or under this many characters.
local ROW_BUDGET = 36
JCA._row_budget = ROW_BUDGET
-- Word-wrap a sentence to <= budget chars per line, breaking only on spaces so a
-- joker name is never split. An overlong single word is left whole (better one
-- wide line than a mangled name).
local function wrap_text(str, budget)
str = tostring(str)
-- Preserve a leading indent (callers pass " " to mark continuation lines);
-- gmatch would otherwise drop it. The indent counts against the budget.
local indent = str:match('^%s+') or ''
local body_budget = budget - #indent
local lines, line = {}, nil
for word in str:gmatch('%S+') do
local nxt = line and (line .. ' ' .. word) or word
if line and #nxt > body_budget then
lines[#lines + 1] = line
line = word
else
line = nxt
end
end
lines[#lines + 1] = line or ''
lines[1] = indent .. lines[1]
return lines
end
JCA._wrap_text = wrap_text
-- Dynamic width. The gutter a card leaves for its tooltip depends on where the
-- card sits -- vanilla opens the popup to the LEFT of a card past 40% of the
-- room and to the RIGHT of the rest (show_infotip, UI_definitions.lua) -- and on
-- the room width, which is a CONSTANT in game units whatever the window size
-- (love.resize only rescales pixels, main.lua:1304). So we size the wrap budget
-- to the real gutter: roomy for a far-side card, tight near the flip point, and
-- it tracks a room-widening/ultrawide mod for free. Anything missing (headless
-- tests) falls back to the fixed budget.
local ROW_BUDGET_MIN = 24
local ROW_BUDGET_MAX = 60
-- The card's OWN description popup sits between our tooltip and the card, eating
-- into the gutter. A joker's desc box is ~3.1-3.3 units wide; we subtract 4.6 so
-- the budget always UNDER-estimates the true capacity -- with the smoke-measured
-- metric (roomw 20, ~0.137 units/char at scale 0.32) that margin keeps every
-- card, even one right at the 40% flip, clear of the edge. dbg() logs the inputs.
local MAIN_BOX_UNITS = 4.6
-- Width of one average character in room units at a text scale, from the game's
-- own text-measuring formula (engine/ui.lua:143) -- exact and window-size
-- independent. nil when the font is not loaded (standalone tests).
local function unit_char_width(scale)
local f = G.LANG and G.LANG.font
local FONT = f and f.FONT
if not (FONT and FONT.getWidth and G.TILESIZE) then return nil end
local sample = 'abcdefghijklmnopqrstuvwxyz ,.-()ABCDEFGHIJKLMNOPQRSTUVWXYZ'
local ok, px = pcall(function()
return FONT:getWidth(sample) * (f.squish or 1) * scale * (f.FONTSCALE or 1)
end)
if not (ok and type(px) == 'number' and px > 0) then return nil end
return px / G.TILESIZE / #sample
end
-- Characters that fit one tooltip row for THIS card, clamped to a sane range.
local function row_budget(card)
local room = G.ROOM and G.ROOM.T
local T = card and card.T
if not (room and T and T.x and room.w) then return ROW_BUDGET end
-- Measure at our widest row scale (0.32) so the budget is safe for every row.
local cw = unit_char_width(0.32)
if not cw or cw <= 0 then return ROW_BUDGET end
local gutter = (T.x > room.w * 0.4) and T.x
or (room.w - (T.x + (T.w or 0)))
gutter = gutter - MAIN_BOX_UNITS
-- *1.08: a hair of slack so a line of wider-than-average glyphs still fits.
local budget = math.floor(gutter / (cw * 1.08))
dbg('row_budget x=', T.x, 'roomw=', room.w, 'cw=', cw,
'gutter=', gutter, '->', budget)
return math.max(ROW_BUDGET_MIN, math.min(ROW_BUDGET_MAX, budget))
end
JCA._row_budget_for = row_budget
-- Short theme nouns for the "Looking for:"/"Offers:" hints (engine roles -- Short theme nouns for the "Looking for:"/"Offers:" hints (engine roles
-- mult/chips/xmult are deliberately absent — they hint nothing specific). -- mult/chips/xmult are deliberately absent — they hint nothing specific).
local TAG_LABEL = { local TAG_LABEL = {
@@ -767,35 +755,43 @@ local function tooltip_rows(card)
local S = JCA.config.touch_mode and 1.4 or 1 local S = JCA.config.touch_mode and 1.4 or 1
local key = card.config.center.key local key = card.config.center.key
local partners, total = JCA.partners_for(card) local partners, total = JCA.partners_for(card)
local budget = row_budget(card)
local rows = {} local rows = {}
local function text_row(str, colour) -- Every row goes through here, so the width bound holds for all of them --
for _, line in ipairs(wrap_text(str, budget)) do -- including rows added later by someone who never read this comment.
-- Continuations are indented so a wrapped sentence reads as one thought.
local function text_row(str, colour, scale)
scale = scale or 0.32 * S
for _, line in ipairs(JCA.wrap_measured(str, scale)) do
rows[#rows + 1] = {{n = G.UIT.T, config = { rows[#rows + 1] = {{n = G.UIT.T, config = {
text = line, colour = colour or G.C.UI.TEXT_DARK, scale = 0.32 * S, text = line, colour = colour or G.C.UI.TEXT_DARK, scale = scale,
}}} }}}
end end
end end
-- A coloured label followed by a body sentence ("Baron: retriggers Kings").
-- Kept on one row when the pair fits; otherwise the label takes its own row -- Two-tone rows ("Mime: retriggers cards in hand"). Kept on one line while
-- and the body wraps beneath it, indented -- so neither a long joker name -- the pair fits; once it does not, the label takes its own line and the
-- nor a long blurb can push the row off the screen edge. -- body wraps beneath it, which keeps the colour split readable instead of
local function seg_row(label, label_colour, body) -- splitting a coloured phrase mid-word across rows.
if #label + #body <= budget then local function labelled_row(label, label_colour, body)
local scale = 0.3 * S
local budget = JCA.row_width_budget()
if JCA.text_width(label .. body, scale) <= budget then
rows[#rows + 1] = { rows[#rows + 1] = {
{n = G.UIT.T, config = {text = label, {n = G.UIT.T, config = {text = label, colour = label_colour, scale = scale}},
colour = label_colour, scale = 0.3 * S}},
{n = G.UIT.T, config = {text = body, {n = G.UIT.T, config = {text = body,
colour = G.C.UI.TEXT_DARK, scale = 0.3 * S}}, colour = G.C.UI.TEXT_DARK, scale = scale}},
} }
return return
end end
rows[#rows + 1] = {{n = G.UIT.T, config = { rows[#rows + 1] = {{n = G.UIT.T, config = {
text = label, colour = label_colour, scale = 0.3 * S}}} text = label, colour = label_colour, scale = scale}}}
for _, line in ipairs(wrap_text(body, budget - 2)) do -- The label owns the line above, so EVERY body line is indented here,
rows[#rows + 1] = {{n = G.UIT.T, config = { -- not just continuations -- reserve the indent for all of them.
text = ' ' .. line, colour = G.C.UI.TEXT_DARK, scale = 0.3 * S}}} local pad = JCA.text_width(' ', scale)
for _, line in ipairs(JCA.wrap_measured(body, scale, budget - pad, '')) do
rows[#rows + 1] = {{n = G.UIT.T, config = {text = ' ' .. line,
colour = G.C.UI.TEXT_DARK, scale = scale}}}
end end
end end
@@ -810,12 +806,11 @@ local function tooltip_rows(card)
labels, prefix = tag_labels(entry.gives), 'Offers: ' labels, prefix = tag_labels(entry.gives), 'Offers: '
end end
if #labels > 0 then if #labels > 0 then
for i, l in ipairs(wrap_names(labels, budget - #prefix, 2)) do -- One sentence, measured-wrapped, instead of a guessed
rows[#rows + 1] = {{n = G.UIT.T, config = { -- character count: text_row already bounds the width.
text = (i == 1 and prefix or ' ') .. l, local names = {}
colour = G.C.UI.TEXT_DARK, scale = 0.3 * S, for _, l in ipairs(labels) do names[#names + 1] = l.name end
}}} text_row(prefix .. table.concat(names, ', '), nil, 0.3 * S)
end
end end
end end
else else
@@ -828,14 +823,12 @@ local function tooltip_rows(card)
for i = 1, shown do for i = 1, shown do
local p = partners[i] local p = partners[i]
local reason = JCA.explain(key, p.key) or 'shared build theme' local reason = JCA.explain(key, p.key) or 'shared build theme'
seg_row(p.name .. ': ', G.C.RED, reason) labelled_row(p.name .. ': ', G.C.RED, reason)
end end
if #partners > shown then if #partners > shown then
local rest = {} local rest = {}
for i = shown + 1, #partners do rest[#rest + 1] = partners[i] end for i = shown + 1, #partners do rest[#rest + 1] = partners[i].name end
for i, l in ipairs(wrap_names(rest, budget - 6, 2)) do text_row('also: ' .. table.concat(rest, ', '), G.C.UI.TEXT_DARK)
text_row((i == 1 and 'also: ' or ' ') .. l, G.C.UI.TEXT_DARK)
end
end end
if in_buy_area(card) and total >= JCA.config.threshold if in_buy_area(card) and total >= JCA.config.threshold
and not JCA.config.learning_mode then and not JCA.config.learning_mode then
@@ -849,7 +842,7 @@ local function tooltip_rows(card)
if owned ~= card and owned.config.center.set == 'Joker' then if owned ~= card and owned.config.center.set == 'Joker' then
local warn = JCA.clash_blurb[pair_key(key, owned.config.center.key)] local warn = JCA.clash_blurb[pair_key(key, owned.config.center.key)]
if warn then if warn then
seg_row('Clashes - ' .. name_of(owned.config.center.key) .. ': ', labelled_row('Clashes - ' .. name_of(owned.config.center.key) .. ': ',
G.C.RED, warn) G.C.RED, warn)
end end
end end
@@ -931,11 +924,9 @@ local function tooltip_rows(card)
end end
if #p.loses > 0 then if #p.loses > 0 then
local names = {} local names = {}
for _, k in ipairs(p.loses) do names[#names + 1] = {name = name_of(k)} end for _, k in ipairs(p.loses) do names[#names + 1] = name_of(k) end
text_row('Fusing drops combos with:', G.C.RED) text_row('Fusing drops combos with: ' .. table.concat(names, ', '),
for _, l in ipairs(wrap_names(names, budget - 2, 2)) do G.C.RED)
text_row(' ' .. l, G.C.RED)
end
end end
end end
end end
@@ -1080,7 +1071,13 @@ local function tooltip_rows(card)
rows.name = 'Combo Advisor' rows.name = 'Combo Advisor'
return rows return rows
end end
JCA._tooltip_rows = tooltip_rows -- for the row-width test; not a public API
-- Test seam. This is the widest branch set in the mod and the only one the
-- headless suite could not reach, so it is deliberately exported: test.lua
-- uses it to assert that no advice row can ever exceed the width budget, on
-- any board, in either text scale. Not part of the public API -- other mods
-- should read partners_for/explain instead.
JCA._tooltip_rows = tooltip_rows
-- The consumable counterpart of tooltip_rows: names the owned jokers a -- The consumable counterpart of tooltip_rows: names the owned jokers a
-- Planet/Tarot/Spectral card or its pack feeds. Quiet unless an owned joker -- Planet/Tarot/Spectral card or its pack feeds. Quiet unless an owned joker
@@ -1089,19 +1086,21 @@ local function consumable_rows(card)
local S = JCA.config.touch_mode and 1.4 or 1 local S = JCA.config.touch_mode and 1.4 or 1
local fed, tag = JCA.consumable_partners(card) local fed, tag = JCA.consumable_partners(card)
if not tag then return nil end if not tag then return nil end
local budget = row_budget(card)
local rows = {} local rows = {}
-- Measured-wrapped like tooltip_rows: a long "Feeds:" list would push the
-- consumable's box off the screen exactly the same way.
local function text_row(str, colour) local function text_row(str, colour)
for _, line in ipairs(wrap_text(str, budget)) do local scale = 0.32 * S
for _, line in ipairs(JCA.wrap_measured(str, scale)) do
rows[#rows + 1] = {{n = G.UIT.T, config = { rows[#rows + 1] = {{n = G.UIT.T, config = {
text = line, colour = colour or G.C.UI.TEXT_DARK, scale = 0.32 * S, text = line, colour = colour or G.C.UI.TEXT_DARK, scale = scale,
}}} }}}
end end
end end
if #fed > 0 then if #fed > 0 then
for i, l in ipairs(wrap_names(fed, budget - 7, 2)) do local names = {}
text_row((i == 1 and 'Feeds: ' or ' ') .. l) for _, f in ipairs(fed) do names[#names + 1] = f.name end
end text_row('Feeds: ' .. table.concat(names, ', '))
end end
-- A Standard Pack permanently adds playing cards, and Erosion is paid per -- A Standard Pack permanently adds playing cards, and Erosion is paid per
-- card MISSING from the deck (card.lua:4318) -- the same verified mechanism -- card MISSING from the deck (card.lua:4318) -- the same verified mechanism
@@ -1353,11 +1352,28 @@ local function recap_row()
return {n = G.UIT.R, config = {align = 'cm', padding = 0.06}, nodes = rows} return {n = G.UIT.R, config = {align = 'cm', padding = 0.06}, nodes = rows}
end end
-- Vanilla node lists have HOLES in them: they are built as table constructors
-- with conditional entries (`G.GAME.seeded and current_seed or nil`,
-- UI_definitions.lua:2469), so index 2 can be empty with real nodes above it.
-- Vanilla never notices because it walks them with pairs (engine/ui.lua:273) --
-- but ipairs stops dead at the first hole, and table.insert/# are undefined on
-- one. So walk and insert by explicit index, bounded by the highest numeric key.
local function last_index(t)
local n = 0
for k in pairs(t) do
if type(k) == 'number' and k > n then n = k end
end
return n
end
-- Trail of {node, index} pairs from `node` down to the child carrying the id, -- Trail of {node, index} pairs from `node` down to the child carrying the id,
-- so callers can insert siblings at any ancestor level. -- so callers can insert siblings at any ancestor level.
local function find_trail(node, id, trail) local function find_trail(node, id, trail)
trail = trail or {} trail = trail or {}
for i, child in ipairs(node.nodes or {}) do local nodes = node.nodes or {}
for i = 1, last_index(nodes) do
local child = nodes[i]
if type(child) == 'table' then
trail[#trail + 1] = {node = node, index = i} trail[#trail + 1] = {node = node, index = i}
if child.config and child.config.id == id then return trail end if child.config and child.config.id == id then return trail end
local hit = find_trail(child, id, trail) local hit = find_trail(child, id, trail)
@@ -1365,6 +1381,16 @@ local function find_trail(node, id, trail)
trail[#trail] = nil trail[#trail] = nil
end end
end end
end
-- table.insert's shift, done by hand so a hole above `index` shifts as a hole
-- instead of truncating everything past it.
local function insert_node(nodes, index, row)
for i = last_index(nodes), index, -1 do
nodes[i + 1] = nodes[i]
end
nodes[index] = row
end
-- Insert the recap `up` ancestor levels above the anchor's parent, before -- Insert the recap `up` ancestor levels above the anchor's parent, before
-- (offset 0) or after (offset 1) that branch. Returns true on success. -- (offset 0) or after (offset 1) that branch. Returns true on success.
@@ -1374,7 +1400,7 @@ local function insert_recap(ui, id, up, offset)
local trail = find_trail(ui, id) local trail = find_trail(ui, id)
if not trail or #trail <= up then return end if not trail or #trail <= up then return end
local spot = trail[#trail - up] local spot = trail[#trail - up]
table.insert(spot.node.nodes, spot.index + offset, row) insert_node(spot.node.nodes, spot.index + offset, row)
return true return true
end end
@@ -1938,10 +1964,10 @@ if SMODS.current_mod then
SMODS.current_mod.extra_tabs = catalog_tabs SMODS.current_mod.extra_tabs = catalog_tabs
end end
-- In-run "Combos" HUD button --------------------------------------------------- -- In-run "Combos" menu entry ----------------------------------------------------
-- Adds a third button under Run Info / Options that opens the synergy catalog -- Adds a row to the pause menu (Escape), under Settings, that opens the synergy
-- in an overlay. The overlay reuses catalog_tabs(); the page cyclers work -- catalog in an overlay. The overlay reuses catalog_tabs(); the page cyclers
-- unchanged because vanilla create_tabs also names its body 'tab_contents'. -- work unchanged because vanilla create_tabs also names its body 'tab_contents'.
-- Shopping list shown at the top of the in-run overlay: the tags the board -- Shopping list shown at the top of the in-run overlay: the tags the board
-- wants but nothing else on it gives, and the hooks it gives that nothing -- wants but nothing else on it gives, and the hooks it gives that nothing
@@ -1983,32 +2009,27 @@ G.FUNCS.jca_open_catalog = function()
})} })}
end end
local function find_by_id(node, id) -- Anchored on 'restart_button' (Start New Run), which create_UIBox_options only
if node.config and node.config.id == id then return node end -- builds during a run (UI_definitions.lua:2439) -- so the entry never shows up on
for _, child in ipairs(node.nodes or {}) do -- the main menu, where there is no board to advise on. UIBox_button hangs the id
local hit = find_by_id(child, id) -- on the inner node, so the row to insert beside is one level up the trail, the
if hit then return hit end -- same walk the post-run recap does.
end if create_UIBox_options and UIBox_button then
end local orig_options = create_UIBox_options
function create_UIBox_options(...)
if create_UIBox_HUD then local ui = orig_options(...)
local orig_hud = create_UIBox_HUD pcall(function()
function create_UIBox_HUD(...) local trail = find_trail(ui, 'restart_button')
local hud = orig_hud(...) if not trail or #trail < 2 then return end
local ok, area = pcall(find_by_id, hud, 'button_area') local spot = trail[#trail - 1]
if ok and area and area.nodes then insert_node(spot.node.nodes, spot.index, UIBox_button{
local B = JCA.config.touch_mode and 1.5 or 1 id = 'jca_combos_button',
area.nodes[#area.nodes + 1] = label = {'Combos'},
{n = G.UIT.R, config = {align = 'cm', minh = 0.9 * B, minw = 1.5 * B, button = 'jca_open_catalog',
padding = 0.05, r = 0.1, hover = true, colour = G.C.PURPLE, minw = 5,
button = 'jca_open_catalog', shadow = true}, nodes = { })
{n = G.UIT.C, config = {align = 'cm', maxw = 1.4 * B}, nodes = { end)
{n = G.UIT.T, config = {text = 'Combos', scale = 0.38 * B, return ui
colour = G.C.UI.TEXT_LIGHT, shadow = true}},
}},
}}
end
return hud
end end
end end
+151 -72
View File
@@ -166,6 +166,35 @@ end
create_UIBox_game_over = function() return button_tree('from_game_over') end create_UIBox_game_over = function() return button_tree('from_game_over') end
create_UIBox_win = function() return button_tree(RECAP_ANCHOR) 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') local data = dofile('synergies.lua')
dofile('main.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') ok(not has_recap(create_UIBox_win()), 'a run with no combos gets no recap row')
G.GAME = {} 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') 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}} G.FUNCS.jca_page_Traps{cycle_config = {current_option = 999}}
end) 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)) print(('\n%d passed, %d failed'):format(pass, fail))
os.exit(fail == 0 and 0 or 1) os.exit(fail == 0 and 0 or 1)