diff --git a/CHANGELOG.md b/CHANGELOG.md index 1587712..aead5c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## 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 - When [Fusion Jokers](https://github.com/wingedcatgirl/Fusion-Jokers) is installed, hovering any joker that is a fusion component names the fusion it diff --git a/CLAUDE.md b/CLAUDE.md index 70633a2..4390185 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 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 - tabs open in-run via a "Combos" HUD button (wrapped `create_UIBox_HUD`, - injected into the `button_area` node under Run Info/Options); the pagers - work there because vanilla `create_tabs` also names its body + tabs open in-run via a "Combos" row in the pause menu (wrapped + `create_UIBox_options`, inserted before `restart_button` — an id vanilla + 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`. - Discovery: `CardArea:emplace` hook calls `JCA.check_discoveries` when a 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), `j_trousers` (Spare Trousers), `j_ring_master` (Showman), `j_caino` (Canio), `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 `JCA.db[key]` without a nil guard. - Display names go through `localize{type='name_text', set='Joker', ...}` with a diff --git a/README.md b/README.md index 7feb4f4..763db80 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,8 @@ recommends joker combos while you play: 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 when something better shows up. -- **Browse the synergy catalog** any time: a purple **Combos** button in the - run HUD (under Run Info / Options) opens it in an overlay, and the same +- **Browse the synergy catalog** any time: a **Combos** row in the pause menu + (Escape, under Settings) opens it in an overlay, and the same tabs live under Mods > Combo Advisor. The *Combos* tab shows the famous pairs as card duos with their explanation once fielded; *Engine* / *Economy* / *Hands* tabs page through each synergy theme with a one-line diff --git a/main.lua b/main.lua index 37eecf2..af11cb6 100644 --- a/main.lua +++ b/main.lua @@ -561,6 +561,79 @@ end -- 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. local function wrap_names(partners, max_width, max_lines) local lines, line = {}, nil @@ -581,91 +654,6 @@ local function wrap_names(partners, max_width, max_lines) return lines 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 -- mult/chips/xmult are deliberately absent — they hint nothing specific). local TAG_LABEL = { @@ -767,35 +755,43 @@ local function tooltip_rows(card) local S = JCA.config.touch_mode and 1.4 or 1 local key = card.config.center.key local partners, total = JCA.partners_for(card) - local budget = row_budget(card) local rows = {} - local function text_row(str, colour) - for _, line in ipairs(wrap_text(str, budget)) do + -- Every row goes through here, so the width bound holds for all of them -- + -- 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 = { - 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 - -- 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 - -- and the body wraps beneath it, indented -- so neither a long joker name - -- nor a long blurb can push the row off the screen edge. - local function seg_row(label, label_colour, body) - if #label + #body <= budget then + + -- Two-tone rows ("Mime: retriggers cards in hand"). Kept on one line while + -- the pair fits; once it does not, the label takes its own line and the + -- body wraps beneath it, which keeps the colour split readable instead of + -- splitting a coloured phrase mid-word across rows. + 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] = { - {n = G.UIT.T, config = {text = label, - colour = label_colour, scale = 0.3 * S}}, + {n = G.UIT.T, config = {text = label, colour = label_colour, scale = scale}}, {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 end rows[#rows + 1] = {{n = G.UIT.T, config = { - text = label, colour = label_colour, scale = 0.3 * S}}} - for _, line in ipairs(wrap_text(body, budget - 2)) do - rows[#rows + 1] = {{n = G.UIT.T, config = { - text = ' ' .. line, colour = G.C.UI.TEXT_DARK, scale = 0.3 * S}}} + text = label, colour = label_colour, scale = scale}}} + -- The label owns the line above, so EVERY body line is indented here, + -- not just continuations -- reserve the indent for all of them. + 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 @@ -810,12 +806,11 @@ local function tooltip_rows(card) labels, prefix = tag_labels(entry.gives), 'Offers: ' end if #labels > 0 then - for i, l in ipairs(wrap_names(labels, budget - #prefix, 2)) do - rows[#rows + 1] = {{n = G.UIT.T, config = { - text = (i == 1 and prefix or ' ') .. l, - colour = G.C.UI.TEXT_DARK, scale = 0.3 * S, - }}} - end + -- One sentence, measured-wrapped, instead of a guessed + -- character count: text_row already bounds the width. + local names = {} + for _, l in ipairs(labels) do names[#names + 1] = l.name end + text_row(prefix .. table.concat(names, ', '), nil, 0.3 * S) end end else @@ -828,14 +823,12 @@ local function tooltip_rows(card) for i = 1, shown do local p = partners[i] 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 if #partners > shown then local rest = {} - for i = shown + 1, #partners do rest[#rest + 1] = partners[i] end - for i, l in ipairs(wrap_names(rest, budget - 6, 2)) do - text_row((i == 1 and 'also: ' or ' ') .. l, G.C.UI.TEXT_DARK) - end + for i = shown + 1, #partners do rest[#rest + 1] = partners[i].name end + text_row('also: ' .. table.concat(rest, ', '), G.C.UI.TEXT_DARK) end if in_buy_area(card) and total >= JCA.config.threshold 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 local warn = JCA.clash_blurb[pair_key(key, owned.config.center.key)] 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) end end @@ -931,11 +924,9 @@ local function tooltip_rows(card) end if #p.loses > 0 then local names = {} - for _, k in ipairs(p.loses) do names[#names + 1] = {name = name_of(k)} end - text_row('Fusing drops combos with:', G.C.RED) - for _, l in ipairs(wrap_names(names, budget - 2, 2)) do - text_row(' ' .. l, G.C.RED) - end + for _, k in ipairs(p.loses) do names[#names + 1] = name_of(k) end + text_row('Fusing drops combos with: ' .. table.concat(names, ', '), + G.C.RED) end end end @@ -1080,7 +1071,13 @@ local function tooltip_rows(card) rows.name = 'Combo Advisor' return rows 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 -- 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 fed, tag = JCA.consumable_partners(card) if not tag then return nil end - local budget = row_budget(card) 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) - 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 = { - 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 if #fed > 0 then - for i, l in ipairs(wrap_names(fed, budget - 7, 2)) do - text_row((i == 1 and 'Feeds: ' or ' ') .. l) - end + local names = {} + for _, f in ipairs(fed) do names[#names + 1] = f.name end + text_row('Feeds: ' .. table.concat(names, ', ')) end -- A Standard Pack permanently adds playing cards, and Erosion is paid per -- card MISSING from the deck (card.lua:4318) -- the same verified mechanism @@ -1353,19 +1352,46 @@ local function recap_row() return {n = G.UIT.R, config = {align = 'cm', padding = 0.06}, nodes = rows} 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, -- so callers can insert siblings at any ancestor level. local function find_trail(node, id, trail) trail = trail or {} - for i, child in ipairs(node.nodes or {}) do - trail[#trail + 1] = {node = node, index = i} - if child.config and child.config.id == id then return trail end - local hit = find_trail(child, id, trail) - if hit then return hit end - trail[#trail] = nil + 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} + if child.config and child.config.id == id then return trail end + local hit = find_trail(child, id, trail) + if hit then return hit end + trail[#trail] = nil + 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 -- (offset 0) or after (offset 1) that branch. Returns true on success. local function insert_recap(ui, id, up, offset) @@ -1374,7 +1400,7 @@ local function insert_recap(ui, id, up, offset) local trail = find_trail(ui, id) if not trail or #trail <= up then return end 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 end @@ -1938,10 +1964,10 @@ if SMODS.current_mod then SMODS.current_mod.extra_tabs = catalog_tabs end --- In-run "Combos" HUD button --------------------------------------------------- --- Adds a third button under Run Info / Options that opens the synergy catalog --- in an overlay. The overlay reuses catalog_tabs(); the page cyclers work --- unchanged because vanilla create_tabs also names its body 'tab_contents'. +-- In-run "Combos" menu entry ---------------------------------------------------- +-- Adds a row to the pause menu (Escape), under Settings, that opens the synergy +-- catalog in an overlay. The overlay reuses catalog_tabs(); the page cyclers +-- 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 -- 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 -local function find_by_id(node, id) - if node.config and node.config.id == id then return node end - for _, child in ipairs(node.nodes or {}) do - local hit = find_by_id(child, id) - if hit then return hit end - end -end - -if create_UIBox_HUD then - local orig_hud = create_UIBox_HUD - function create_UIBox_HUD(...) - local hud = orig_hud(...) - local ok, area = pcall(find_by_id, hud, 'button_area') - if ok and area and area.nodes then - local B = JCA.config.touch_mode and 1.5 or 1 - area.nodes[#area.nodes + 1] = - {n = G.UIT.R, config = {align = 'cm', minh = 0.9 * B, minw = 1.5 * B, - padding = 0.05, r = 0.1, hover = true, colour = G.C.PURPLE, - button = 'jca_open_catalog', shadow = true}, nodes = { - {n = G.UIT.C, config = {align = 'cm', maxw = 1.4 * B}, nodes = { - {n = G.UIT.T, config = {text = 'Combos', scale = 0.38 * B, - colour = G.C.UI.TEXT_LIGHT, shadow = true}}, - }}, - }} - end - return hud +-- Anchored on 'restart_button' (Start New Run), which create_UIBox_options only +-- builds during a run (UI_definitions.lua:2439) -- so the entry never shows up on +-- the main menu, where there is no board to advise on. UIBox_button hangs the id +-- on the inner node, so the row to insert beside is one level up the trail, the +-- same walk the post-run recap does. +if create_UIBox_options and UIBox_button then + local orig_options = create_UIBox_options + function create_UIBox_options(...) + local ui = orig_options(...) + pcall(function() + local trail = find_trail(ui, 'restart_button') + if not trail or #trail < 2 then return end + local spot = trail[#trail - 1] + insert_node(spot.node.nodes, spot.index, UIBox_button{ + id = 'jca_combos_button', + label = {'Combos'}, + button = 'jca_open_catalog', + minw = 5, + }) + end) + return ui end end diff --git a/test.lua b/test.lua index 5b4ecb2..7e0f84c 100644 --- a/test.lua +++ b/test.lua @@ -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 - : " 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)