Compare commits

..

4 Commits

Author SHA1 Message Date
funman300 65b423c77d Hold the hover tooltip to the screen against the real font
test / test (push) Successful in 15s
The pixel-measured wrap passed the headless suite (which stubs text_width) but
failed three assertions under the real engine in the smoke harness:

- "Looking for:"/"Offers:" hints and the two-tone combo rows render at scale
  0.3 but the width budget -- and every other row -- is measured at 0.32, so a
  row that fit at 0.3 slipped over the budget once measured at 0.32 and clipped.
  Render those rows small as before, but bound their wrap at the 0.32 reference:
  a 0.3 row is narrower still, so it can only wrap early, never clip.
- wrap_measured word-split multi-word joker names, so a fusion's result read as
  "Fusion ready: Diamond" / "Bard ($12)" -- two jokers, not one. atomic() fuses
  a name or multi-word label into one unbreakable wrap token (spaces -> a \1
  sentinel, restored for measurement and output); applied to every joker name
  and tag label that flows through a wrapped row.

Smoke: 20/20 against the real Card:generate_UIBox_ability_table pipeline and the
real Fusion Jokers recipe table. Headless: 947 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 13:09:40 -07:00
funman300 a66faadc21 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>
2026-07-27 12:48:38 -07:00
funman300 124e8da8ed Record the smoke-validated numbers behind MAIN_BOX_UNITS
test / test (push) Successful in 17s
The smoke run confirmed the width model against the real engine: room width
20 units, ~0.137 units/char at scale 0.32, and the geometry path clamps
correctly. That pins the no-clip margin -- subtracting 4.6 for a ~3.1-3.3
unit desc box keeps the budget under true capacity at every card position,
including one right at the 40% flip. Note it in the comment so it reads as
validated, not a guess. No behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 10:06:34 -07:00
funman300 075d5da4d1 Stop the hover tooltip from clipping off the left screen edge
The Combo Advisor info popup is right-anchored to the hovered card and
grows leftward with no screen clamp (vanilla align_h_popup leaves lr_clamp
off), so its width is set by its single widest row -- and several rows had
no cap: the fusion "Fuses into X - still needs Y" line, chained copy lines,
and the "name: reason" combo / "Clashes - name: blurb" rows, which run well
past a single row once a long joker name meets a full 34-char blurb. On a
left-of-centre shop card the box ran off the screen and clipped.

Enforce one width invariant on every rendered line via a wrap_text helper
(word-wrap, never splits a name, keeps continuation indents) and a seg_row
for the two-segment lines. The prefixed wrap_names blocks (Looking for:,
Offers:, also:, Feeds:, Fusing drops) now budget for their prefix instead
of adding it on top.

Size the budget to the card's actual gutter rather than a fixed number:
the room width is a constant in game units whatever the window size, so the
real variable is where the card sits and which side vanilla opens the popup
on. row_budget measures the gutter with the game's own text metric
(exact, resolution-independent) -- roomy for a far-side card, tight near the
40% flip point so it never clips, and it tracks an ultrawide/room-widening
mod for free. Missing geometry falls back to the fixed budget.

The one estimate, MAIN_BOX_UNITS (the card's own desc box between us and the
card), is sized ~1 unit generous to bias toward wrapping early over
clipping; dbg() logs the inputs for later calibration.

Tests: real row-builder driven to its widest row and asserted within
budget; wrap_text budget/indent/word-integrity; row_budget fallback,
monotonicity and clamp. 936 pass on 5.4 and LuaJIT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 10:00:18 -07:00
5 changed files with 407 additions and 87 deletions
+11
View File
@@ -2,6 +2,17 @@
## 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.
### Fixed
- The Combo Advisor hover tooltip no longer runs off the edge of the screen:
every advice row is measured against the real font and wrapped to the card's
gutter, and a wrapped line never splits a joker name ("Diamond Bard" stays
whole rather than breaking across two rows).
### 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
+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
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
+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
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
+229 -82
View File
@@ -561,6 +561,92 @@ 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
-- A joker name or multi-word label must never wrap mid-phrase -- "Fusion ready:
-- Diamond" over one row and "Bard" over the next reads as two jokers. atomic()
-- fuses a phrase's spaces into a sentinel that %S+ tokenisation treats as one
-- word; wrap_measured restores them for both measurement and output. \1 never
-- occurs in localized joker text. ONLY apply to text that goes through
-- wrap_measured (text_row) -- a sentinel placed straight into a UIT node would
-- render as tofu (labelled_row's label node does NOT wrap, so it stays plain).
local NBSP = '\1'
local function atomic(s) return (tostring(s):gsub(' ', NBSP)) 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
-- Measure with sentinels restored to real spaces: an atomic() phrase is
-- one %S+ token but its true width is the width of the spaced text.
if line and JCA.text_width((nxt:gsub(NBSP, ' ')), 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
for i = 1, #lines do lines[i] = (lines[i]:gsub(NBSP, ' ')) end
return lines
end
-- Wrap partner names into short tooltip lines.
local function wrap_names(partners, max_width, max_lines)
local lines, line = {}, nil
@@ -684,10 +770,48 @@ local function tooltip_rows(card)
local partners, total = JCA.partners_for(card)
local rows = {}
local function text_row(str, colour)
-- 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 = scale,
}}}
end
end
-- 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)
-- Render small so "name: reason" fits one row more often, but bound the
-- width at the SAME 0.32*S reference the budget and every other row are
-- measured against -- a row drawn at 0.3 is narrower still, so this can
-- only ever wrap early, never clip. (Measuring the wrap at 0.3 let a
-- body line that fit at 0.3 slip over the budget once measured at 0.32.)
local scale = 0.3 * S
local ref = 0.32 * S
local budget = JCA.row_width_budget()
if JCA.text_width(label .. body, ref) <= budget then
rows[#rows + 1] = {
{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 = scale}},
}
return
end
rows[#rows + 1] = {{n = G.UIT.T, config = {
text = str, colour = colour or G.C.UI.TEXT_DARK, scale = 0.32 * 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(' ', ref)
for _, line in ipairs(JCA.wrap_measured(body, ref, budget - pad, '')) do
rows[#rows + 1] = {{n = G.UIT.T, config = {text = ' ' .. line,
colour = G.C.UI.TEXT_DARK, scale = scale}}}
end
end
if #partners == 0 then
@@ -701,12 +825,14 @@ 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, 36, 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. Rendered at
-- the default scale (like the "No synergy" line above it) so its
-- wrap holds under the same budget every other row is measured by;
-- multi-word labels stay whole so "card enhancing" never splits.
local names = {}
for _, l in ipairs(labels) do names[#names + 1] = atomic(l.name) end
text_row(prefix .. table.concat(names, ', '))
end
end
else
@@ -719,19 +845,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'
rows[#rows + 1] = {
{n = G.UIT.T, config = {text = p.name .. ': ',
colour = G.C.RED, scale = 0.3 * S}},
{n = G.UIT.T, config = {text = reason,
colour = G.C.UI.TEXT_DARK, scale = 0.3 * S}},
}
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, 34, 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] = atomic(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
@@ -745,13 +864,8 @@ 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
rows[#rows + 1] = {
{n = G.UIT.T, config = {
text = 'Clashes - ' .. name_of(owned.config.center.key) .. ': ',
colour = G.C.RED, scale = 0.3 * S}},
{n = G.UIT.T, config = {text = warn,
colour = G.C.UI.TEXT_DARK, scale = 0.3 * S}},
}
labelled_row('Clashes - ' .. name_of(owned.config.center.key) .. ': ',
G.C.RED, warn)
end
end
end
@@ -781,9 +895,9 @@ local function tooltip_rows(card)
local target, status, chain = JCA.copy_source_if_bought(key)
local via = ''
for _, link in ipairs(chain) do
via = via .. name_of(link.config.center.key) .. ' -> '
via = via .. atomic(name_of(link.config.center.key)) .. ' -> '
end
local tname = target and name_of(target.config.center.key)
local tname = target and atomic(name_of(target.config.center.key))
if status == 'ok' then
text_row('Will copy ' .. via .. tname .. ' as soon as it lands', G.C.GREEN)
elseif status == 'incompatible' then
@@ -809,12 +923,13 @@ local function tooltip_rows(card)
local ok, plans = pcall(JCA.fusion_plans, card)
for i = 1, (ok and math.min(2, #plans) or 0) do
local p = plans[i]
local rname = name_of(p.result)
local rname = atomic(name_of(p.result))
local price = p.cost and (' ($' .. p.cost .. ')') or ''
if not p.ready then
local want = {}
for _, m in ipairs(p.missing) do
want[#want + 1] = (m.n > 1 and (m.n .. 'x ') or '') .. name_of(m.key)
want[#want + 1] = atomic(
(m.n > 1 and (m.n .. 'x ') or '') .. name_of(m.key))
end
text_row(('Fuses into %s%s - still needs %s')
:format(rname, price, table.concat(want, ', ')))
@@ -832,11 +947,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
for li, l in ipairs(wrap_names(names, 30, 2)) do
text_row((li == 1 and 'Fusing drops combos with: ' or ' ') .. l,
G.C.RED)
end
for _, k in ipairs(p.loses) do names[#names + 1] = atomic(name_of(k)) end
text_row('Fusing drops combos with: ' .. table.concat(names, ', '),
G.C.RED)
end
end
end
@@ -928,9 +1041,9 @@ local function tooltip_rows(card)
-- Blueprint", which is the one answer that helps nobody.
local via = ''
for _, link in ipairs(chain) do
via = via .. name_of(link.config.center.key) .. ' -> '
via = via .. atomic(name_of(link.config.center.key)) .. ' -> '
end
local tname = target and name_of(target.config.center.key)
local tname = target and atomic(name_of(target.config.center.key))
if status == 'ok' then
text_row('Copying ' .. via .. tname, G.C.GREEN)
@@ -949,7 +1062,7 @@ local function tooltip_rows(card)
if status ~= 'ok' then
local best = JCA.best_copy_target(card)
if best then
local bname = name_of(best.config.center.key)
local bname = atomic(name_of(best.config.center.key))
text_row(key == 'j_brainstorm'
and ('Fix: make ' .. bname .. ' your leftmost joker')
or ('Fix: slot this just left of ' .. bname), G.C.GREEN)
@@ -961,7 +1074,7 @@ local function tooltip_rows(card)
-- next blind — worth shouting about while there is still time to reorder.
local victim, safe = JCA.dagger_victim(card)
if victim then
local vname = name_of(victim.config.center.key)
local vname = atomic(name_of(victim.config.center.key))
if safe then
text_row(vname .. ' is Eternal - the blade cannot eat it', G.C.GREEN)
else
@@ -982,6 +1095,13 @@ local function tooltip_rows(card)
return rows
end
-- 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
-- actually cares, so the tooltip adds nothing for everyone else.
@@ -990,15 +1110,20 @@ local function consumable_rows(card)
local fed, tag = JCA.consumable_partners(card)
if not tag then return nil end
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)
rows[#rows + 1] = {{n = G.UIT.T, config = {
text = str, colour = colour or G.C.UI.TEXT_DARK, scale = 0.32 * S,
}}}
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 = scale,
}}}
end
end
if #fed > 0 then
for i, l in ipairs(wrap_names(fed, 34, 2)) do
text_row((i == 1 and 'Feeds: ' or ' ') .. l)
end
local names = {}
for _, f in ipairs(fed) do names[#names + 1] = atomic(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
@@ -1250,19 +1375,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)
@@ -1271,7 +1423,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
@@ -1835,10 +1987,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
@@ -1880,32 +2032,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
+151
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')
--------------------------------------------------------------------------------