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 ## 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 ### 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
+220 -73
View File
@@ -561,6 +561,92 @@ 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
-- 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. -- 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
@@ -684,11 +770,49 @@ local function tooltip_rows(card)
local partners, total = JCA.partners_for(card) local partners, total = JCA.partners_for(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 --
-- 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 = str, 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
-- 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 = 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 if #partners == 0 then
text_row(in_buy_area(card) and 'No synergy with your jokers.' text_row(in_buy_area(card) and 'No synergy with your jokers.'
@@ -701,12 +825,14 @@ 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, 36, 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. Rendered at
text = (i == 1 and prefix or ' ') .. l, -- the default scale (like the "No synergy" line above it) so its
colour = G.C.UI.TEXT_DARK, scale = 0.3 * S, -- wrap holds under the same budget every other row is measured by;
}}} -- multi-word labels stay whole so "card enhancing" never splits.
end local names = {}
for _, l in ipairs(labels) do names[#names + 1] = atomic(l.name) end
text_row(prefix .. table.concat(names, ', '))
end end
end end
else else
@@ -719,19 +845,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'
rows[#rows + 1] = { labelled_row(p.name .. ': ', G.C.RED, reason)
{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}},
}
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] = atomic(partners[i].name) end
for i, l in ipairs(wrap_names(rest, 34, 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
@@ -745,13 +864,8 @@ 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
rows[#rows + 1] = { labelled_row('Clashes - ' .. name_of(owned.config.center.key) .. ': ',
{n = G.UIT.T, config = { G.C.RED, warn)
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}},
}
end end
end end
end end
@@ -781,9 +895,9 @@ local function tooltip_rows(card)
local target, status, chain = JCA.copy_source_if_bought(key) local target, status, chain = JCA.copy_source_if_bought(key)
local via = '' local via = ''
for _, link in ipairs(chain) do for _, link in ipairs(chain) do
via = via .. name_of(link.config.center.key) .. ' -> ' via = via .. atomic(name_of(link.config.center.key)) .. ' -> '
end 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 if status == 'ok' then
text_row('Will copy ' .. via .. tname .. ' as soon as it lands', G.C.GREEN) text_row('Will copy ' .. via .. tname .. ' as soon as it lands', G.C.GREEN)
elseif status == 'incompatible' then elseif status == 'incompatible' then
@@ -809,12 +923,13 @@ local function tooltip_rows(card)
local ok, plans = pcall(JCA.fusion_plans, card) local ok, plans = pcall(JCA.fusion_plans, card)
for i = 1, (ok and math.min(2, #plans) or 0) do for i = 1, (ok and math.min(2, #plans) or 0) do
local p = plans[i] 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 '' local price = p.cost and (' ($' .. p.cost .. ')') or ''
if not p.ready then if not p.ready then
local want = {} local want = {}
for _, m in ipairs(p.missing) do 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 end
text_row(('Fuses into %s%s - still needs %s') text_row(('Fuses into %s%s - still needs %s')
:format(rname, price, table.concat(want, ', '))) :format(rname, price, table.concat(want, ', ')))
@@ -832,14 +947,12 @@ 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] = atomic(name_of(k)) end
for li, l in ipairs(wrap_names(names, 30, 2)) do text_row('Fusing drops combos with: ' .. table.concat(names, ', '),
text_row((li == 1 and 'Fusing drops combos with: ' or ' ') .. l,
G.C.RED) G.C.RED)
end end
end end
end end
end
-- Run context: this deck, these hand levels. Facts that frame the tags, -- Run context: this deck, these hand levels. Facts that frame the tags,
-- never part of the score; capped at two lines so multi-suit jokers do -- never part of the score; capped at two lines so multi-suit jokers do
@@ -928,9 +1041,9 @@ local function tooltip_rows(card)
-- Blueprint", which is the one answer that helps nobody. -- Blueprint", which is the one answer that helps nobody.
local via = '' local via = ''
for _, link in ipairs(chain) do for _, link in ipairs(chain) do
via = via .. name_of(link.config.center.key) .. ' -> ' via = via .. atomic(name_of(link.config.center.key)) .. ' -> '
end 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 if status == 'ok' then
text_row('Copying ' .. via .. tname, G.C.GREEN) text_row('Copying ' .. via .. tname, G.C.GREEN)
@@ -949,7 +1062,7 @@ local function tooltip_rows(card)
if status ~= 'ok' then if status ~= 'ok' then
local best = JCA.best_copy_target(card) local best = JCA.best_copy_target(card)
if best then 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' text_row(key == 'j_brainstorm'
and ('Fix: make ' .. bname .. ' your leftmost joker') and ('Fix: make ' .. bname .. ' your leftmost joker')
or ('Fix: slot this just left of ' .. bname), G.C.GREEN) 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. -- next blind — worth shouting about while there is still time to reorder.
local victim, safe = JCA.dagger_victim(card) local victim, safe = JCA.dagger_victim(card)
if victim then if victim then
local vname = name_of(victim.config.center.key) local vname = atomic(name_of(victim.config.center.key))
if safe then if safe then
text_row(vname .. ' is Eternal - the blade cannot eat it', G.C.GREEN) text_row(vname .. ' is Eternal - the blade cannot eat it', G.C.GREEN)
else else
@@ -982,6 +1095,13 @@ local function tooltip_rows(card)
return rows return rows
end 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 -- 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
-- actually cares, so the tooltip adds nothing for everyone else. -- 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) local fed, tag = JCA.consumable_partners(card)
if not tag then return nil end if not tag then return nil end
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)
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 = str, 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, 34, 2)) do
text_row((i == 1 and 'Feeds: ' or ' ') .. l)
end end
if #fed > 0 then
local names = {}
for _, f in ipairs(fed) do names[#names + 1] = atomic(f.name) 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
@@ -1250,17 +1375,44 @@ 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)
if hit then return hit end if hit then return hit end
trail[#trail] = nil trail[#trail] = nil
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 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
@@ -1271,7 +1423,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
@@ -1835,10 +1987,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
@@ -1880,32 +2032,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
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')
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------