From 42c922bdc7374e00541890807a844417aeb80e6a Mon Sep 17 00:00:00 2001 From: ChronoFinale Date: Thu, 23 Jul 2026 09:16:04 -0500 Subject: [PATCH] feat(banpick): server-driven deck+stake draft UI + pool-fetch seam The deck+stake ban-pick draft engine plus the client seam that fetches a server-generated pool (pairs with the server-side draft-pool PR). Engine (api/ban_pick.lua): tuple identity is deck+stake (Red@White != Red@Gold); Random is a blind commit; two-column tile hover (deck + stake detail); cocktail composition badge; counter/buttons stay visible off-turn, greyed; per-draft stale-broadcast protection with atomic random commit + guest UI sync; draft tiles drag-locked; popups clamp to the screen on both axes. Composite-agnostic -- a pool item carries its own decks/name, no cocktail knowledge in the engine. Seam (api/matchmaking/draft.lua, networking/api_client/draft.lua): issue_draft_pool fetches the match's server pool, callback(nil) when none so the consumer chooses fallback vs abort. Server-authoritative -- no max_stake param, no client-side event posting. Visual scenarios in dev/shots.lua for the DevTools shot harness. --- api/ban_pick.lua | 989 +++++++++++++++++++++++++------ api/matchmaking/draft.lua | 32 + dev/shots.lua | 348 +++++++++++ dev/test_banpick_draft_guard.lua | 201 +++++++ dev/test_banpick_popup_clamp.lua | 79 +++ dev/test_banpick_selection.lua | 211 ++++++- localization/en-us.lua | 4 + networking/api_client.lua | 1 + networking/api_client/draft.lua | 15 + 9 files changed, 1676 insertions(+), 204 deletions(-) create mode 100644 api/matchmaking/draft.lua create mode 100644 dev/shots.lua create mode 100644 dev/test_banpick_draft_guard.lua create mode 100644 dev/test_banpick_popup_clamp.lua create mode 100644 networking/api_client/draft.lua diff --git a/api/ban_pick.lua b/api/ban_pick.lua index b64e6c5..b0f2b74 100644 --- a/api/ban_pick.lua +++ b/api/ban_pick.lua @@ -32,12 +32,26 @@ local _overlay = nil local _render = nil local _fired = false --- Select-and-confirm UI state: deck keys raised this turn, committed by the +-- Select-and-confirm UI state: item ids raised this turn, committed by the -- Confirm button. Lives outside the overlay so it survives rebuilds on state -- broadcasts (pruned against each new state instead). local _selected = {} -local _sel_ui = { count_text = '' } +local _sel_ui = { count_text = '', confirm_text = '', random_text = '' } local _areas = {} +-- Blind-random mode: arming Random commits you to unseen picks -- nothing is +-- marked or revealed, and the actual roll happens only when Confirm is pressed +-- (so there is nothing to peek at or reroll-fish for). +local _random_armed = false + +-- Per-draft identity guard: the host stamps each draft with a unique draft_id +-- carried ON the state. on_state drops a state from a dead (completed or +-- superseded) draft; a new draft_id supersedes the old one. No draft_id +-- (older host) = accept. State is a full snapshot, so a duplicate just +-- re-applies harmlessly -- no per-message sequencing (consistent with every +-- other synced state). +local _current_draft_id = nil +local _dead_drafts = {} +local _draft_counter = 0 ----------------------------- -- Helpers @@ -51,6 +65,20 @@ local function item_key(item) return item end +-- Unique identity of a pool item WITHIN its pool. Tuple pools may repeat a deck at +-- different stakes (up to 3), so identity must be key+stake, not key -- banning +-- Red@White must not also ban Red@Gold. Plain string items just use their key. +-- Ids travel opaquely through the ban wire format (item_key) and the selection UI. +local function item_id(item) + if type(item) == "table" then + if item.stake ~= nil then + return item.key .. "@" .. tostring(item.stake) + end + return item.key + end + return item +end + -- Default candidate pool: a random sample of deck Back center KEYS. local function default_build_pool(size) local keys = {} @@ -110,9 +138,9 @@ local function current_actor_id(state) return step and resolve_actor(state, step.actor) end -local function item_for_key(state, key) +local function item_for_id(state, id) for _, item in ipairs(state.pool) do - if item_key(item) == key then + if item_id(item) == id then return item end end @@ -123,7 +151,7 @@ end local function compute_survivors(state) local out = {} for _, item in ipairs(state.pool) do - if not state.banned[item_key(item)] then + if not state.banned[item_id(item)] then out[#out + 1] = item end end @@ -137,7 +165,7 @@ end local function survivors_left(state) local n = 0 for _, item in ipairs(state.pool) do - if not state.banned[item_key(item)] then + if not state.banned[item_id(item)] then n = n + 1 end end @@ -204,7 +232,7 @@ local function selection_prune(list, state) local out = {} local cap = selection_needed(state) for _, k in ipairs(list) do - if #out < cap and item_for_key(state, k) and not state.banned[k] then + if #out < cap and item_for_id(state, k) and not state.banned[k] then out[#out + 1] = k end end @@ -218,9 +246,9 @@ local function selection_randomize(state, rng) rng = rng or math.random local eligible = {} for _, item in ipairs(state.pool) do - local k = item_key(item) - if not state.banned[k] then - eligible[#eligible + 1] = k + local id = item_id(item) + if not state.banned[id] then + eligible[#eligible + 1] = id end end local out = {} @@ -231,17 +259,49 @@ local function selection_randomize(state, rng) return out end --- Exposed for the standalone test harness (dev/test_banpick_selection.lua). -BP._selection = { - needed = selection_needed, - contains = selection_contains, - toggle = selection_toggle, - prune = selection_prune, - randomize = selection_randomize, - list = function() - return _selected - end, -} +-- Test-only seam (dev/test_banpick_selection.lua). Attached only under +-- MPAPI._TEST so it is never present on shipped clients. +if MPAPI._TEST then + BP._selection = { + needed = selection_needed, + contains = selection_contains, + toggle = selection_toggle, + prune = selection_prune, + randomize = selection_randomize, + list = function() + return _selected + end, + -- test seams: is blind-random armed? / the live UI strings / inject fake + -- tile areas so sync_selection_ui is coverable headless + armed = function() + return _random_armed + end, + ui = function() + return _sel_ui + end, + set_areas = function(areas) + _areas = areas + end, + } +end + +-- Test-only seam for the draft-identity guard (dev/test_banpick_events.lua). +-- Attached only under MPAPI._TEST so it is never present on shipped clients. +if MPAPI._TEST then + BP._draft_guard = { + current_draft = function() + return _current_draft_id + end, + is_dead = function(id) + return _dead_drafts[id] == true + end, + reset = function() + _current_draft_id = nil + _dead_drafts = {} + _draft_counter = 0 + end, + } +end ----------------------------- -- UI @@ -291,25 +351,428 @@ local function set_card_selected(card, on, action) end end --- Re-derive every tile's raised/tagged state and the live counter from _selected. +-- Re-derive every tile's raised/tagged state and the live texts (counter + +-- button labels) from _selected. When blind-random is armed the counter reads +-- ?/N (the picks don't exist yet), Confirm reads "Confirm Random", and the +-- Random button flips to "Cancel Random". local function sync_selection_ui(state) - _sel_ui.count_text = tostring(#_selected) .. '/' .. tostring(selection_needed(state)) + local step = current_step(state) + local is_pick = step and step.action == "pick" + if _random_armed then + _sel_ui.count_text = '?/' .. tostring(selection_needed(state)) + _sel_ui.confirm_text = localize('k_banpick_confirm_random') + _sel_ui.random_text = localize('k_banpick_cancel_random') + else + _sel_ui.count_text = tostring(#_selected) .. '/' .. tostring(selection_needed(state)) + _sel_ui.confirm_text = localize(is_pick and 'k_banpick_confirm_pick' or 'k_banpick_confirm') + _sel_ui.random_text = localize('k_banpick_random') + end local step = current_step(state) local action = step and step.action or "ban" for _, area in ipairs(_areas) do for _, card in ipairs(area.cards or {}) do - if card.mp_deck_key then - set_card_selected(card, selection_contains(_selected, card.mp_deck_key), action) + if card.mp_item_id then + set_card_selected(card, selection_contains(_selected, card.mp_item_id), action) end end end end +-- Stake-column build is split in two (BP._stake_column): gather_stake_column runs +-- every fallible call (pool lookups, loc_vars, localize) into a caller-owned +-- collector; build_stake_column then assembles UI nodes from the completed gather. +-- The split matters because localize{type='descriptions'} CONSTRUCTS live +-- DynaText/UIBox objects the moment it runs (they self-register into G.I.MOVEABLE) -- +-- each nodes table is parked in gathered.line_sets BEFORE that call, so a mid-gather +-- failure still leaves every object reachable for release_stake_column, never +-- orphaned at the screen origin. +local function release_line_nodes(node) + if type(node) ~= "table" then + return + end + local obj = node.config and node.config.object + if obj and obj.remove then + obj:remove() + end + for _, child in ipairs(node.nodes or node) do + release_line_nodes(child) + end +end + +local function release_stake_column(gathered) + for _, lines in ipairs(gathered.line_sets or {}) do + for _, line in ipairs(lines) do + release_line_nodes(line) + end + end +end + +-- Mutates `gathered` ({ descs = {}, line_sets = {} }); sets gathered.ready +-- only when every fallible call completed. Caller owns the collector so a +-- thrown error still leaves the partial gather reachable for release. +local function gather_stake_column(item, gathered) + local stakes_pool = G.P_CENTER_POOLS and G.P_CENTER_POOLS.Stake + local top = stakes_pool and stakes_pool[item.stake] + if not top then + return + end + gathered.name = localize({ type = 'name_text', set = 'Stake', key = top.key }) + gathered.name_colour = get_stake_col(item.stake) + if item.stake > 2 then + gathered.also_applied = localize('k_also_applied') + end + local function gather_desc(i, drop_last) + local center = stakes_pool[i] + local res = {} + if center.loc_vars and type(center.loc_vars) == 'function' then + res = center:loc_vars() or {} + end + local lines = {} + gathered.line_sets[#gathered.line_sets + 1] = lines + localize({ + type = 'descriptions', + key = res.key or center.key, + set = res.set or center.set, + nodes = lines, + vars = res.vars or {}, + }) + -- Previous stakes drop their trailing "applies all previous Stakes" + -- boilerplate line, exactly like run-info -- released here, while any + -- objects localize built into it are still reachable. + if drop_last and #lines > 1 then + release_line_nodes(lines[#lines]) + lines[#lines] = nil + end + gathered.descs[#gathered.descs + 1] = { colour = get_stake_col(i), lines = lines } + end + gather_desc(item.stake, false) + for i = item.stake - 1, 2, -1 do + gather_desc(i, true) + end + gathered.ready = true +end + +-- Pure assembly: table constructors over fully-gathered data only. descs[1] is +-- the tile's own stake; the rest are the cumulative previous stakes (desc, then +-- the "Also applied:" label after the first when present). +local function build_stake_column(gathered) + local right = {} + local function chip_desc_row(colour, rows) + return { + n = G.UIT.R, + config = { align = "cm", padding = 0.03 }, + nodes = { + { + n = G.UIT.C, + config = { align = "cm" }, + nodes = { + { n = G.UIT.C, config = { align = "cm", colour = colour, r = 0.1, minh = 0.3, minw = 0.3, emboss = 0.05 }, nodes = {} }, + { n = G.UIT.B, config = { w = 0.08, h = 0.08 } }, + }, + }, + { n = G.UIT.C, config = { align = "cm", padding = 0.03, colour = G.C.WHITE, r = 0.1, minh = 0.5, minw = 3.2 }, nodes = rows }, + }, + } + end + right[#right + 1] = { + n = G.UIT.R, + config = { align = "cm", r = 0.1, minw = 2.5, maxw = 4.2, minh = 0.4 }, + nodes = { + { + n = G.UIT.T, + config = { + text = gathered.name, + scale = 0.38, + colour = gathered.name_colour, + shadow = true, + }, + }, + }, + } + for idx, d in ipairs(gathered.descs) do + local rows = {} + for _, line in ipairs(d.lines) do + rows[#rows + 1] = { n = G.UIT.R, config = { align = "cm" }, nodes = line } + end + right[#right + 1] = chip_desc_row(d.colour, rows) + if idx == 1 and gathered.also_applied then + right[#right + 1] = { + n = G.UIT.R, + config = { align = "cm", padding = 0.03 }, + nodes = { + { n = G.UIT.T, config = { text = gathered.also_applied, scale = 0.32, colour = G.C.UI.TEXT_LIGHT, shadow = true } }, + }, + } + end + end + return right +end + +-- Test-only seam (dev/test_banpick_selection.lua). Attached only under +-- MPAPI._TEST so it is never present on shipped clients. +if MPAPI._TEST then + BP._stake_column = { + gather = gather_stake_column, + build = build_stake_column, + release = release_stake_column, + } +end + +-- Pure vertical-clamp decision for the hover popup (see card:hover). The engine's +-- Moveable alignment flips a popup above/below its tile but only ever clamps +-- horizontally (lr_clamp), so a popup taller than its side's space runs off screen +-- (e.g. the composition popup from the bottom row). Returns the y keeping the popup +-- inside [edge, room_h - edge]; bottom is applied first so the top-edge rule wins for +-- popups taller than the room -- the content's top is what must stay readable. +local function popup_clamp_y(y, h, room_h, edge) + if y + h > room_h - edge then + y = room_h - edge - h + end + if y < edge then + y = edge + end + return y +end + +-- Test-only seam (dev/test_banpick_popup_clamp.lua). Attached only under +-- MPAPI._TEST so it is never present on shipped clients. +if MPAPI._TEST then + BP._popup = { + clamp_y = popup_clamp_y, + } +end + +-- Keep a hover popup on screen. Two mechanisms, since the engine treats still and +-- moving anchors differently (Moveable:move gates move_with_major on `not STATIONARY +-- or NEW_ALIGNMENT`): +-- 1. STATIC anchors (badge, unmoving tile): a one-shot offset mutation before the +-- popup's first move raises NEW_ALIGNMENT, so the content tree re-aligns to the +-- clamped position. Clamping T/VT directly would NOT work: a stationary popup's +-- content tree never re-follows its outer box. +-- 2. MOVING anchors (a raised tile carrying the popup): a per-frame clamp of the +-- outer transforms, since the content tree re-follows every frame anyway. +-- lr_clamp covers the horizontal axis for wide popups. +local function clamp_popup(popup, anchor) + if not popup or not popup.T or popup._mp_tb_clamp then + return + end + popup._mp_tb_clamp = true + -- NOTE: vanilla Card:move re-calls set_alignment(align_h_popup()) EVERY frame, + -- resetting alignment.lr_clamp and offset -- so for TILE anchors the one-shot + -- mutation above is overwritten before it acts, and the per-frame wrapper below + -- is the only mechanism that holds (it clamps both axes). UIElement anchors like + -- the badge have no such realignment, so the one-shot offset works for them. + local a = popup.alignment + if a then + a.lr_clamp = true + if a.offset and anchor and anchor.T then + -- Recreate the engine's alignment geometry for the popup top + -- (align_to_major: 't' -> above the anchor, 'b' -> below it). + local t = tostring(a.type or '') + local top + if t:find('t') then + top = anchor.T.y + a.offset.y - popup.T.h + elseif t:find('b') then + top = anchor.T.y + anchor.T.h + a.offset.y + end + if top then + a.offset.y = a.offset.y + (popup_clamp_y(top, popup.T.h, G.ROOM.T.h, 0.05) - top) + end + end + end + local base_move = popup.move + popup.move = function(p, dt) + base_move(p, dt) + p.T.y = popup_clamp_y(p.T.y, p.T.h, G.ROOM.T.h, 0.05) + p.VT.y = popup_clamp_y(p.VT.y, p.VT.h, G.ROOM.T.h, 0.05) + -- Same clamp horizontally (bounds [0, room_w], mirroring lr_clamp). + p.T.x = popup_clamp_y(p.T.x, p.T.w, G.ROOM.T.w, 0) + p.VT.x = popup_clamp_y(p.VT.x, p.VT.w, G.ROOM.T.w, 0) + end +end + +-- LAZY popup-row builders: DynaText/UIBox objects register themselves +-- globally the moment they are constructed, so anything built and then NOT +-- placed in a drawn popup would still be drawn -- unparented, at the screen +-- origin (the garbled text-over-the-status-panel bug). Only ever call these +-- from inside a hover that immediately places the result. +local function popup_name_row(text, name_scale) + return { + n = G.UIT.R, + config = { align = "cm", r = 0.1, minw = 3, maxw = 4, minh = 0.4 }, + nodes = { + { + n = G.UIT.O, + config = { + object = DynaText({ + string = text, + maxw = 4, + colours = { G.C.WHITE }, shadow = true, bump = true, scale = name_scale, pop_in = 0, silent = true, + }), + }, + }, + }, + } +end +local function popup_desc_row(center) + return { + n = G.UIT.R, + config = { + align = "cm", + colour = G.C.WHITE, minh = 0.5, maxh = 3, minw = 3, maxw = 4, r = 0.1, + }, + nodes = { + { + n = G.UIT.O, + config = { + object = UIBox({ + definition = Back(center):generate_UI(), + config = { offset = { x = 0, y = 0 } }, + }), + }, + }, + }, + } +end + +-- Title rows for a composite item's popup: its `name` (a display title the +-- consumer sets verbatim -- the engine is composite-agnostic and never adds +-- words like "Cocktail" itself) and optional `subtitle`. Both shared by the +-- tile hover and the badge detail. +local function composition_header(item) + local rows = {} + if item.name then + rows[#rows + 1] = popup_name_row(tostring(item.name), 0.5) + end + if item.subtitle then + rows[#rows + 1] = { + n = G.UIT.R, + config = { align = "cm", r = 0.1, minw = 3, maxw = 4, minh = 0.35 }, + nodes = { + { n = G.UIT.T, config = { text = tostring(item.subtitle), scale = 0.32, colour = G.C.UI.TEXT_LIGHT, shadow = true } }, + }, + } + end + return rows +end + +-- Compact composition rows for the TILE hover: header + contained deck +-- names only, no effect boxes -- the full breakdown lives in the badge's +-- detail popup. +local function composition_rows(item) + local rows = composition_header(item) + for _, ckey in ipairs(item.decks) do + local ccenter = G.P_CENTERS[ckey] + if ccenter then + rows[#rows + 1] = popup_name_row(Back(ccenter):get_name(), 0.38) + end + end + return rows +end + +-- The badge's full detail: header, then the contained decks laid out +-- SIDE BY SIDE -- one column per deck, name over effects. Horizontal on +-- purpose: three stacked descriptions are taller than any screen position +-- can guarantee, while three columns stay ~2 units tall and always fit. +local function composition_detail(item) + local cols = {} + for _, ckey in ipairs(item.decks) do + local ccenter = G.P_CENTERS[ckey] + if ccenter then + cols[#cols + 1] = { + n = G.UIT.C, + config = { align = "tm", padding = 0.05 }, + nodes = { + popup_name_row(Back(ccenter):get_name(), 0.38), + popup_desc_row(ccenter), + }, + } + end + end + local rows = composition_header(item) + rows[#rows + 1] = { n = G.UIT.R, config = { align = "cm" }, nodes = cols } + return rows +end + +-- Shared visual wrapper for both hover popups (tile + badge): the outlined +-- dark container the columns sit in. +local function popup_container(columns) + return { + n = G.UIT.C, + config = { align = "cm", padding = 0.1 }, + nodes = { + { + n = G.UIT.C, + config = { align = "cm", r = 0.1, colour = G.C.L_BLACK, padding = 0.1, outline = 1 }, + nodes = { + { n = G.UIT.R, config = { align = "tm" }, nodes = columns }, + }, + }, + }, + } +end + +-- Tile hover popup: mod badges (top of the panel) built via the vanilla +-- SMODS helper; mod_set is stripped the same as before (never shown here). +local function build_hover_mod_badges(center) + local badges = { n = G.UIT.C, config = { colour = G.C.CLEAR, align = "cm" }, nodes = {} } + SMODS.create_mod_badges(center, badges.nodes) + if badges.nodes.mod_set then + badges.nodes.mod_set = nil + end + return badges +end + +-- Left column of the tile hover: deck info (name+desc, or compact composition rows +-- for a composite item), then mod badges. COMPACT for composite items on purpose -- +-- full per-deck details live in the composition badge's popup instead, since a tile +-- tooltip tall enough for three descriptions would cover the row it points at. +local function build_hover_left_column(item, center, badges) + local left = {} + local has_composition = type(item) == "table" and type(item.decks) == "table" + if has_composition then + for _, row in ipairs(composition_rows(item)) do + left[#left + 1] = row + end + else + left[#left + 1] = popup_name_row(Back(center):get_name(), 0.5) + left[#left + 1] = popup_desc_row(center) + end + if badges.nodes[1] then + left[#left + 1] = { + n = G.UIT.R, + config = { align = "cm", r = 0.1, minw = 3, maxw = 4, minh = 0.4 }, + nodes = { badges }, + } + end + return left +end + +-- Right column of the tile hover: the stake column (gather_stake_column / +-- build_stake_column above), only for tuple-pool items with a numeric stake. +-- Two-phase gather -> build inside a pcall: a loc failure degrades to a logged +-- warning with every already-constructed object released -- never a dead hover. +local function build_hover_right_column(item) + local right = {} + if type(item) == "table" and type(item.stake) == "number" then + local gathered = { descs = {}, line_sets = {} } + local ok, err = pcall(gather_stake_column, item, gathered) + if ok and gathered.ready then + right = build_stake_column(gathered) + elseif not ok then + release_stake_column(gathered) + MPAPI.sendWarnMessage('[banpick] stake column failed: ' .. tostring(err)) + end + end + return right +end + -- One deck tile: a card showing the deck's Back center. `item` may carry metadata; the -- consumer's decorate_tile(card, item) is called after emplace (e.g. to stamp a stake -- sticker via card.sticker). BANNED tiles are debuffed. local function deck_tile(item, banned, area, decorate) local key = item_key(item) + local id = item_id(item) local center = G.P_CENTERS[key] local card = Card( area.T.x + area.T.w / 2, @@ -327,7 +790,9 @@ local function deck_tile(item, banned, area, decorate) if banned then card.debuff = true end - card.mp_deck_key = key + -- Identity is the item ID (key+stake for tuples): marking Red@White must not + -- raise or ban the Red@Gold tile sitting next to it. + card.mp_item_id = id -- Clicking toggles the mark; nothing commits here (that's the Confirm button). -- Off-turn and banned tiles don't react at all. @@ -337,9 +802,10 @@ local function deck_tile(item, banned, area, decorate) if banned or not state or not is_my_turn(lobby, state) then return end - if selection_toggle(_selected, key, selection_needed(state)) ~= "blocked" then - sync_selection_ui(state) - end + -- Touching a tile leaves blind-random mode: manual selection resumes. + _random_armed = false + selection_toggle(_selected, id, selection_needed(state)) + sync_selection_ui(state) end -- Selection drives the raise through set_card_selected; neuter the vanilla @@ -349,98 +815,104 @@ local function deck_tile(item, banned, area, decorate) end function card:hover() - local back = Back(self.config.center) - - local badges = { n = G.UIT.C, config = { colour = G.C.CLEAR, align = "cm" }, nodes = {} } - SMODS.create_mod_badges(self.config.center, badges.nodes) - if badges.nodes.mod_set then - badges.nodes.mod_set = nil + -- Two columns: deck info (left) and, for tuple pools, stake info (right). + local badges = build_hover_mod_badges(self.config.center) + local left = build_hover_left_column(item, self.config.center, badges) + local right = build_hover_right_column(item) + + local columns = { { n = G.UIT.C, config = { align = "tm", padding = 0.05 }, nodes = left } } + if right[1] then + columns[#columns + 1] = { n = G.UIT.C, config = { align = "tm", padding = 0.05 }, nodes = right } end self.config.h_popup = { n = G.UIT.C, config = { align = "cm", padding = 0.1 }, nodes = {} } - table.insert(self.config.h_popup.nodes, (self.T.x > G.ROOM.T.w * 0.4) and 2 or 1, { - n = G.UIT.C, - config = { align = "cm", padding = 0.1 }, - nodes = { - { - n = G.UIT.C, - config = { align = "cm", r = 0.1, colour = G.C.L_BLACK, padding = 0.1, outline = 1 }, - nodes = { - { - n = G.UIT.R, - config = { align = "cm", r = 0.1, minw = 3, maxw = 4, minh = 0.4 }, - nodes = { - { - n = G.UIT.O, - config = { - object = DynaText({ - string = back:get_name(), - maxw = 4, - colours = { G.C.WHITE }, shadow = true, bump = true, scale = 0.5, pop_in = 0, silent = true, - }), - }, - }, - }, - }, - { - n = G.UIT.R, - config = { - align = "cm", - colour = G.C.WHITE, minh = 0.5, maxh = 3, minw = 3, maxw = 4, r = 0.1, - }, - nodes = { - { - n = G.UIT.O, - config = { - object = UIBox({ - definition = back:generate_UI(), - config = { offset = { x = 0, y = 0 } }, - }), - }, - }, - }, - }, - badges.nodes[1] and { - n = G.UIT.R, - config = { align = "cm", r = 0.1, minw = 3, maxw = 4, minh = 0.4 }, - nodes = { badges }, - }, - }, - }, - }, - }) + -- The 2-or-1 position comes from the vanilla card_h_popup pattern where + -- nodes is pre-populated; here it is freshly EMPTY, so clamp the index or + -- inserting at 2 leaves nodes[1] = nil -- an array hole every ipairs + -- consumer stops at. + local popup_nodes = self.config.h_popup.nodes + table.insert(popup_nodes, math.min((self.T.x > G.ROOM.T.w * 0.4) and 2 or 1, #popup_nodes + 1), popup_container(columns)) self.config.h_popup_config = self:align_h_popup() Node.hover(self) + clamp_popup(self.children.h_popup, self) end end -local function build_banpick_contents() - local lobby = MPAPI.get_current_lobby() - local state = lobby and lobby._ban_pick - - if not state or not state.pool then - return { - { n = G.UIT.R, config = { align = 'cm', minh = 2 }, nodes = { - { n = G.UIT.T, config = { text = localize('k_banpick_waiting'), scale = 0.4, colour = G.C.UI.TEXT_LIGHT } }, - } }, - } +-- Per-frame init for the composition badge (config.func): installs a hover showing +-- the FULL composition (each deck's name + effects) as a popup growing DOWNWARD +-- from the badge (top-anchored on_demand_tooltip geometry). Sitting at the panel top +-- gives it the whole panel height to grow into, covering tiles only while read. +G.FUNCS.mpapi_composition_badge_init = function(e) + if e._mp_badge_init then + return end + e._mp_badge_init = true + e.states.collide.can = true + e.states.hover.can = true + e.hover = function(self) + local it = self.config.mp_comp_item + if not it then + return + end + self.config.h_popup = popup_container({ + { n = G.UIT.C, config = { align = "tm", padding = 0.05 }, nodes = composition_detail(it) }, + }) + self.config.h_popup_config = { align = 'bm', offset = { x = 0, y = 0.1 }, parent = self } + Node.hover(self) + clamp_popup(self.children.h_popup, self) + end + e.stop_hover = function(self) + Node.stop_hover(self) + self.config.h_popup = nil + end +end - local my_turn = is_my_turn(lobby, state) - local step = current_step(state) - local is_pick = step and step.action == "pick" - local left = survivors_left(state) - - local rows = {} +-- The always-visible composition badge row: ": Deck A + Deck B + +-- Deck C" at a glance, full details on hover (see the init func above). +-- Only built when the pool contains an item carrying a `decks` list. The +-- title is the item's `name` verbatim (the consumer owns the wording); an +-- item with no name falls back to just the deck list. +local function composition_badge_row(comp_item) + local names = {} + for _, ckey in ipairs(comp_item.decks) do + local ccenter = G.P_CENTERS[ckey] + if ccenter then + names[#names + 1] = Back(ccenter):get_name() + end + end + local decks_label = table.concat(names, ' + ') + local label = comp_item.name and (tostring(comp_item.name) .. ': ' .. decks_label) or decks_label + return { + n = G.UIT.R, + config = { align = 'cm', padding = 0.04 }, + nodes = { + { + n = G.UIT.C, + config = { + align = 'cm', padding = 0.08, r = 0.1, + colour = G.C.L_BLACK, outline = 1, outline_colour = G.C.UI.OUTLINE_LIGHT_TRANS, + func = 'mpapi_composition_badge_init', + mp_comp_item = comp_item, + }, + nodes = { + { n = G.UIT.T, config = { text = label, scale = 0.32, colour = G.C.UI.TEXT_LIGHT, shadow = true } }, + }, + }, + }, + } +end - -- Title. - rows[#rows + 1] = { n = G.UIT.R, config = { align = 'cm', padding = 0.05 }, nodes = { +-- Title. +local function build_title_row() + return { n = G.UIT.R, config = { align = 'cm', padding = 0.05 }, nodes = { { n = G.UIT.T, config = { text = localize('k_banpick_title'), scale = 0.6, colour = G.C.UI.TEXT_LIGHT, shadow = true } }, } } +end - -- Status: whose turn (ban vs pick) + how many actions/decks remain. +-- Status: whose turn (ban vs pick) + how many actions/decks remain. +local function build_status_rows(my_turn, is_pick, state, left) local status_text if not my_turn then status_text = localize('k_banpick_their_turn') @@ -450,23 +922,43 @@ local function build_banpick_contents() status_text = localize('k_banpick_your_turn') end local status_colour = my_turn and G.C.GREEN or G.C.UI.TEXT_INACTIVE - rows[#rows + 1] = { n = G.UIT.R, config = { align = 'cm', padding = 0.03 }, nodes = { + local status_row = { n = G.UIT.R, config = { align = 'cm', padding = 0.03 }, nodes = { { n = G.UIT.T, config = { text = status_text, scale = 0.42, colour = status_colour, shadow = true } }, } } local detail = is_pick and (localize('k_banpick_decks_left') .. ' ' .. tostring(left)) or (localize('k_banpick_bans_left') .. ' ' .. tostring(state.sched_remaining or 0) .. ' ' .. localize('k_banpick_decks_left') .. ' ' .. tostring(left)) - rows[#rows + 1] = { n = G.UIT.R, config = { align = 'cm', padding = 0.1 }, nodes = { + local detail_row = { n = G.UIT.R, config = { align = 'cm', padding = 0.1 }, nodes = { { n = G.UIT.T, config = { text = detail, scale = 0.32, colour = G.C.UI.TEXT_LIGHT } }, } } + return status_row, detail_row +end - local decorate = _config and _config.decorate_tile +-- Composition badge (top of the panel, above the tiles): at-a-glance +-- composition, full per-deck details on hover. Only the FIRST composite item +-- gets a badge (matches the original `break` after the first match). Returns +-- nil when the pool has no composite item. +local function build_composition_badge_section(state) + for _, item in ipairs(state.pool) do + if type(item) == "table" and type(item.decks) == "table" then + return composition_badge_row(item) + end + end + return nil +end + +-- The deck-tile grid: one CardArea per row of PER_ROW tiles, built left to +-- right through the pool. Returns the areas (for _areas / selection sync) and +-- the two rows (spacer + the grid itself) to append to the panel. +local function build_tile_grid(state, decorate) local areas = {} local areas_container = {} local cur_area = nil for i, item in ipairs(state.pool) do if (i - 1) % PER_ROW == 0 then - cur_area = CardArea(0, 0, G.CARD_W * ROW_SCALE * PER_ROW, G.CARD_H * ROW_SCALE, { + -- Width beyond the cards' own footprint becomes even spacing between + -- tiles (CardArea spreads its cards across the full width). + cur_area = CardArea(0, 0, G.CARD_W * ROW_SCALE * PER_ROW * 1.15, G.CARD_H * ROW_SCALE, { type = "joker", highlight_limit = PER_ROW, card_limit = PER_ROW, @@ -481,7 +973,7 @@ local function build_banpick_contents() }, } end - deck_tile(item, state.banned[item_key(item)], cur_area, decorate) + deck_tile(item, state.banned[item_id(item)], cur_area, decorate) end -- Tiles are buttons, not hand cards: never draggable (click-holding one -- would drag it around the panel and dismiss its hover popup mid-read; @@ -494,61 +986,106 @@ local function build_banpick_contents() card.states.drag.can = false end end - - rows[#rows + 1] = { n = G.UIT.R, config = { minh = 0.25 } } - rows[#rows + 1] = { - n = G.UIT.R, - config = { align = "cm", padding = 0.25, r = 0.25, colour = { 0, 0, 0, 0.1 } }, - nodes = areas_container, + local grid_rows = { + { n = G.UIT.R, config = { minh = 0.25 } }, + { + n = G.UIT.R, + config = { align = "cm", padding = 0.25, r = 0.25, colour = { 0, 0, 0, 0.1 } }, + nodes = areas_container, + }, } + return areas, grid_rows +end + +-- Selected counter row: "Selected: N/M", live via ref_table. +local function build_selected_counter_row() + return { n = G.UIT.R, config = { align = 'cm', padding = 0.03 }, nodes = { + { n = G.UIT.T, config = { text = localize('k_banpick_selected') .. ' ', scale = 0.35, colour = G.C.UI.TEXT_LIGHT } }, + { n = G.UIT.T, config = { ref_table = _sel_ui, ref_value = 'count_text', scale = 0.35, colour = G.C.UI.TEXT_LIGHT } }, + } } +end + +-- Confirm + Random buttons. ALWAYS rendered -- on the opponent's turn the check +-- funcs grey both out (inactive colour, config.button nulled) instead of the row +-- vanishing, keeping one stable layout. `button` must be present at definition time +-- (UIElement:set_values only arms click for nodes that HAVE it at UIBox build); the +-- per-frame check then nulls it while not ready (vanilla can_play pattern). Random +-- is deliberately NOT one_press: pressing it again re-rolls. +local function build_action_buttons_row() + return { n = G.UIT.R, config = { align = 'cm', padding = 0.06 }, nodes = { + { + n = G.UIT.C, + config = { + align = 'cm', minw = 3.2, minh = 0.7, r = 0.1, padding = 0.08, + shadow = true, hover = true, colour = G.C.UI.BACKGROUND_INACTIVE, + button = 'mpapi_ban_pick_confirm', one_press = true, + func = 'mpapi_ban_pick_confirm_check', + }, + nodes = { + { n = G.UIT.T, config = { ref_table = _sel_ui, ref_value = 'confirm_text', scale = 0.42, colour = G.C.UI.TEXT_LIGHT, shadow = true } }, + }, + }, + { n = G.UIT.C, config = { minw = 0.25 } }, + { + n = G.UIT.C, + config = { + -- Wide enough for its longest live label ("Cancel Random"). + align = 'cm', minw = 2.6, minh = 0.7, r = 0.1, padding = 0.08, + shadow = true, hover = true, colour = G.C.UI.BACKGROUND_INACTIVE, + button = 'mpapi_ban_pick_random', + func = 'mpapi_ban_pick_random_check', + }, + nodes = { + { n = G.UIT.T, config = { ref_table = _sel_ui, ref_value = 'random_text', scale = 0.42, colour = G.C.UI.TEXT_LIGHT, shadow = true } }, + }, + }, + } } +end + +local function build_banpick_contents() + local lobby = MPAPI.get_current_lobby() + local state = lobby and lobby._ban_pick + + if not state or not state.pool then + return { + { n = G.UIT.R, config = { align = 'cm', minh = 2 }, nodes = { + { n = G.UIT.T, config = { text = localize('k_banpick_waiting'), scale = 0.4, colour = G.C.UI.TEXT_LIGHT } }, + } }, + } + end + + local my_turn = is_my_turn(lobby, state) + local step = current_step(state) + local is_pick = step and step.action == "pick" + local left = survivors_left(state) + + local rows = {} + + rows[#rows + 1] = build_title_row() + + local status_row, detail_row = build_status_rows(my_turn, is_pick, state, left) + rows[#rows + 1] = status_row + rows[#rows + 1] = detail_row + + local badge_row = build_composition_badge_section(state) + if badge_row then + rows[#rows + 1] = badge_row + end + + local decorate = _config and _config.decorate_tile + local areas, grid_rows = build_tile_grid(state, decorate) + for _, r in ipairs(grid_rows) do + rows[#rows + 1] = r + end -- Re-apply any surviving selection to the freshly built tiles (the overlay is -- rebuilt on every state broadcast; _selected was pruned in on_state). _areas = areas sync_selection_ui(state) - -- Selected counter + Confirm + Random (reroll), only on our turn. The counter - -- text updates live via ref_table; both buttons enable themselves per frame - -- through their check funcs. - if my_turn then - rows[#rows + 1] = { n = G.UIT.R, config = { minh = 0.4 } } - rows[#rows + 1] = { n = G.UIT.R, config = { align = 'cm', padding = 0.03 }, nodes = { - { n = G.UIT.T, config = { text = localize('k_banpick_selected') .. ' ', scale = 0.35, colour = G.C.UI.TEXT_LIGHT } }, - { n = G.UIT.T, config = { ref_table = _sel_ui, ref_value = 'count_text', scale = 0.35, colour = G.C.UI.TEXT_LIGHT } }, - } } - -- `button` must be present at definition time: UIElement:set_values only arms - -- states.click.can for nodes that HAVE config.button when the UIBox is built. - -- The per-frame check then gates it by nulling config.button while not ready - -- (the vanilla can_play pattern). The Random button is deliberately NOT - -- one_press: pressing it again re-rolls. - rows[#rows + 1] = { n = G.UIT.R, config = { align = 'cm', padding = 0.06 }, nodes = { - { - n = G.UIT.C, - config = { - align = 'cm', minw = 3.2, minh = 0.7, r = 0.1, padding = 0.08, - shadow = true, hover = true, colour = G.C.UI.BACKGROUND_INACTIVE, - button = 'mpapi_ban_pick_confirm', one_press = true, - func = 'mpapi_ban_pick_confirm_check', - }, - nodes = { - { n = G.UIT.T, config = { text = localize(is_pick and 'k_banpick_confirm_pick' or 'k_banpick_confirm'), scale = 0.42, colour = G.C.UI.TEXT_LIGHT, shadow = true } }, - }, - }, - { n = G.UIT.C, config = { minw = 0.25 } }, - { - n = G.UIT.C, - config = { - align = 'cm', minw = 1.6, minh = 0.7, r = 0.1, padding = 0.08, - shadow = true, hover = true, colour = G.C.UI.BACKGROUND_INACTIVE, - button = 'mpapi_ban_pick_random', - func = 'mpapi_ban_pick_random_check', - }, - nodes = { - { n = G.UIT.T, config = { text = localize('k_banpick_random'), scale = 0.42, colour = G.C.UI.TEXT_LIGHT, shadow = true } }, - }, - }, - } } - end + rows[#rows + 1] = { n = G.UIT.R, config = { minh = 0.4 } } + rows[#rows + 1] = build_selected_counter_row() + rows[#rows + 1] = build_action_buttons_row() return rows end @@ -583,13 +1120,17 @@ function BP.broadcast_state(lobby) if not action_type then return end - lobby:action(action_type):broadcast({ state = lobby._ban_pick }) + local s = lobby._ban_pick + lobby:action(action_type):broadcast({ state = s }) end -- Host authority: apply `from_player_id`'s action (ban or pick, per the current schedule --- step) on `deck_key`. Returns true if it was legal and changed state (caller broadcasts). +-- step) on the given item id. Returns true if it was legal and changed state (caller broadcasts). -- Exported as apply_ban for backward compatibility with existing consumer ActionTypes. -local function apply_action(lobby, from_player_id, deck_key) +-- `id` is the pool item's identity (item_id): the plain key for string pools, +-- key@stake for tuple pools. It arrives opaquely through the consumers' ban +-- ActionType (their item_key parameter), so consumers need no changes. +local function apply_action(lobby, from_player_id, id) local s = lobby._ban_pick if not s or s.complete then return false @@ -597,24 +1138,25 @@ local function apply_action(lobby, from_player_id, deck_key) if current_actor_id(s) ~= from_player_id then return false end - if not item_for_key(s, deck_key) or s.banned[deck_key] then + if not item_for_id(s, id) or s.banned[id] then return false end local step = current_step(s) if step and step.action == "pick" then -- The picked item wins; everything else is discarded. - s.survivors = { item_for_key(s, deck_key) } + s.survivors = { item_for_id(s, id) } s.complete = true return true end - -- Ban. `ban_order` records the sequence bans happened in -- unused by the legacy - -- survivors-only consumers (GSS/WST), but lets a `keep=0` draft (nothing survives) use the - -- order itself as a result, e.g. SPDRN's All Deck mode drafting play order rather than - -- narrowing a pool (see BalatroMultiplayerSpeed/objects/gamemodes/all_deck.lua). - s.banned[deck_key] = true - s.ban_order[#s.ban_order + 1] = deck_key + -- Ban. `ban_order` records the sequence bans happened in -- unused by legacy + -- survivors-only consumers, but lets a `keep=0` draft use the order itself as the + -- result (e.g. SPDRN's All Deck mode drafting play order, see all_deck.lua). + -- ban_order stores the ITEM (same shape as survivors) so on_complete's two args + -- stay consistently keys-or-{key,meta}-tables regardless of pool kind. + s.banned[id] = true + s.ban_order[#s.ban_order + 1] = item_for_id(s, id) or id s.sched_remaining = (s.sched_remaining or 1) - 1 if s.sched_remaining <= 0 then s.sched_index = s.sched_index + 1 @@ -631,8 +1173,10 @@ end BP.apply_ban = apply_action --- Called by the action button (any client). Host applies directly; guest asks the host. -function BP.request_ban(deck_key) +-- Called by the Confirm flow (any client) with a pool item ID. Host applies +-- directly; guest asks the host (the wire parameter stays named item_key for +-- consumer ActionType compatibility -- it carries the item id opaquely). +function BP.request_ban(id) local lobby = MPAPI.get_current_lobby() if not lobby then return @@ -643,7 +1187,7 @@ function BP.request_ban(deck_key) end if lobby.is_host then - if apply_action(lobby, lobby.player_id, deck_key) then + if apply_action(lobby, lobby.player_id, id) then BP.broadcast_state(lobby) if _overlay then _overlay:update() @@ -655,7 +1199,7 @@ function BP.request_ban(deck_key) local action_type = MPAPI.ActionTypes[_config.ban_action] if action_type then -- order[1] is the host. - lobby:action(action_type):send(s.order[1], { item_key = deck_key }) + lobby:action(action_type):send(s.order[1], { item_key = id }) end end end @@ -664,10 +1208,26 @@ function BP.on_state(lobby, state) if not state then return end + -- Draft-identity guard, scoped by draft_id (see the module-local comment). + if state.draft_id then + if _dead_drafts[state.draft_id] then + return + end + if state.draft_id ~= _current_draft_id then + -- First sight of a new draft (possibly from a different host): + -- supersede the old draft so a late duplicate of it can't reappear. + if _current_draft_id then + _dead_drafts[_current_draft_id] = true + end + _current_draft_id = state.draft_id + end + end lobby._ban_pick = state - -- Drop marks the broadcast invalidated (opponent banned them, cap shrank). + -- Drop marks the broadcast invalidated (opponent banned them, cap shrank), + -- and disarm blind-random -- arming is cheap to redo and never stale. _selected = selection_prune(_selected, state) + _random_armed = false if _overlay then _overlay:update() @@ -677,6 +1237,10 @@ function BP.on_state(lobby, state) if state.complete and not _fired then _fired = true + -- The draft is over: any further broadcast bearing this id is a duplicate. + if state.draft_id then + _dead_drafts[state.draft_id] = true + end local cb = _on_complete _on_complete = nil if _overlay then @@ -711,11 +1275,30 @@ function BP.start(lobby, config, on_complete) _fired = false _selected = {} _areas = {} + _random_armed = false + + -- A new draft invalidates the previous one. Clearing state matters on GUESTS + -- (host reassigns below anyway): otherwise the old COMPLETE state renders as a + -- live board while the host's async pool fetch is still running. Dead-marking + -- the old draft_id stops a late duplicate from completing the NEW draft with + -- stale survivors. + lobby._ban_pick = nil + if _current_draft_id then + _dead_drafts[_current_draft_id] = true + end + _current_draft_id = nil if lobby.is_host then local pool = (config.build_pool and config.build_pool()) or default_build_pool(config.pool_size) local schedule = config.schedule or derive_schedule(config.pool_size or #pool, config.keep or 1) + -- Unique per draft: host id + wall clock + session counter. Guests scope + -- the draft-identity guard to this id, so it is never compared across + -- drafts or across hosts. + _draft_counter = _draft_counter + 1 + local draft_id = tostring(lobby.player_id) .. '#' .. tostring(os.time()) .. '#' .. tostring(_draft_counter) + _current_draft_id = draft_id lobby._ban_pick = { + draft_id = draft_id, pool = pool, banned = {}, ban_order = {}, @@ -757,9 +1340,11 @@ G.FUNCS.mpapi_ban_pick_confirm_check = function(e) local lobby = MPAPI.get_current_lobby() local s = lobby and lobby._ban_pick local needed = selection_needed(s) - if s and is_my_turn(lobby, s) and needed > 0 and #_selected == needed then - local step = current_step(s) - e.config.colour = (step and step.action == "pick") and G.C.GREEN or G.C.MULT + local ready = s and is_my_turn(lobby, s) and needed > 0 and (#_selected == needed or _random_armed) + if ready then + -- Green = "go", always: Confirm Ban / Confirm Pick / Confirm Random all + -- share the confirm signal colour (the label carries the meaning). + e.config.colour = G.C.GREEN e.config.button = "mpapi_ban_pick_confirm" else e.config.colour = G.C.UI.BACKGROUND_INACTIVE @@ -767,13 +1352,18 @@ G.FUNCS.mpapi_ban_pick_confirm_check = function(e) end end --- Random: replace the selection with a fresh random one (full reroll on every --- press). Committing still goes through Confirm, so a bad roll costs nothing. +-- Random: BLIND commit. Pressing Random arms random mode -- it clears any +-- manual marks, raises nothing, reveals nothing (counter reads ?/N, the button +-- goes green). Confirm then rolls the actual picks at commit time and sends +-- them through; there is nothing to peek at or reroll-fish for. Pressing +-- Random again (or clicking any tile) disarms back to manual selection. G.FUNCS.mpapi_ban_pick_random_check = function(e) local lobby = MPAPI.get_current_lobby() local s = lobby and lobby._ban_pick if s and is_my_turn(lobby, s) and selection_needed(s) > 0 then - e.config.colour = G.C.BLUE + -- Idle: blue "Random". Armed: red "Cancel Random" (red = back out; the + -- green go-signal lives on Confirm). + e.config.colour = _random_armed and G.C.RED or G.C.BLUE e.config.button = "mpapi_ban_pick_random" else e.config.colour = G.C.UI.BACKGROUND_INACTIVE @@ -787,11 +1377,10 @@ G.FUNCS.mpapi_ban_pick_random = function(_e) if not s or not is_my_turn(lobby, s) then return end - local out = selection_randomize(s) - if #out == 0 then - return + _random_armed = not _random_armed + if _random_armed then + _selected = {} end - _selected = out sync_selection_ui(s) end @@ -805,12 +1394,36 @@ G.FUNCS.mpapi_ban_pick_confirm = function(_e) return end local needed = selection_needed(s) - if needed == 0 or #_selected ~= needed then + if needed == 0 then return end - local keys = _selected + local ids + if _random_armed then + -- Blind random: the picks are rolled HERE, at commit time -- the player + -- confirmed "random", never a revealed selection. + _random_armed = false + ids = selection_randomize(s) + -- A short roll (eligible survivors < the step's remaining count) commits + -- NOTHING: a partial batch would exhaust the pool mid-step and wedge the + -- draft with no legal action left. Disarm, warn, leave the step intact. + if #ids < needed then + MPAPI.sendWarnMessage('[banpick] blind random rolled ' .. tostring(#ids) .. ' of ' .. tostring(needed) .. ' needed; nothing committed') + sync_selection_ui(s) + return + end + else + if #_selected ~= needed then + return + end + ids = _selected + end _selected = {} - for _, k in ipairs(keys) do - BP.request_ban(k) + for _, id in ipairs(ids) do + BP.request_ban(id) end + -- On the host every applied action re-renders (masking this); on a GUEST + -- request_ban only sends the wire message, so without an explicit sync the + -- tiles keep their raised state + Selected tags and the counter reads a full + -- N/N until the host's rebroadcast lands. + sync_selection_ui(s) end diff --git a/api/matchmaking/draft.lua b/api/matchmaking/draft.lua new file mode 100644 index 0000000..f892fe7 --- /dev/null +++ b/api/matchmaking/draft.lua @@ -0,0 +1,32 @@ +-- Server-generated draft support. The consumer's draft only ever runs inside +-- matchmaking, and every matchmaking queue has a server draft policy. +-- +-- The contract with consumers: +-- fetch_draft_pool(match_id, cb) -> cb(pool) with an array of { key, stake } +-- items ready for BanPick, or cb(nil) on ANY +-- failure (no connection, no match id, no +-- policy for the queue, transport error) -- +-- the caller must abort the draft on nil. + +MPAPI.matchmaking = MPAPI.matchmaking or {} + +function MPAPI.matchmaking.fetch_draft_pool(match_id, callback) + local conn = MPAPI.get_connection() + if not conn or not conn.api or not conn.jwt_token or not match_id then + callback(nil) + return + end + conn.api:issue_draft_pool(conn.jwt_token, match_id, function(err, data) + if err or not data or type(data.pool) ~= 'table' then + if err then + MPAPI.sendDebugMessage('[draft] pool fetch failed (caller must abort the draft): ' .. tostring(err.message or err)) + end + callback(nil) + return + end + -- The pool is a list of self-describing items: { key, stake }, and for a + -- composite deck additionally { decks = { key, ... }, name? } -- the + -- composition rides on the item, so there is no separate config fetch. + callback(data.pool) + end) +end diff --git a/dev/shots.lua b/dev/shots.lua new file mode 100644 index 0000000..417d859 --- /dev/null +++ b/dev/shots.lua @@ -0,0 +1,348 @@ +-- Visual scenarios for the draft UI, discovered and run by the +-- BalatroMultiplayerDevTools shot harness. This file is INERT on its own: +-- the API mod never loads it, nothing here executes at boot, and it only +-- runs when a developer with the DevTools mod installed explicitly starts a +-- shot run (BMP_SHOT_SUITE=1 / DEVTOOLS.run_shot_suite()). It lives here -- +-- next to dev/test_*.lua -- so the visual scenarios version WITH the code +-- they cover instead of drifting in the tools repo. +-- +-- Contract: return function(H) -> list of scenario tables +-- { name, expect, region?, skip?, setup(done), teardown? } +-- H is the harness: H.start_draft(pool, schedule, first), H.find_tile(id), +-- H.find_ui(node, pred). See the DevTools README for the full shape. + +return function(H) + local PLAIN_POOL = { 'b_red', 'b_blue', 'b_yellow', 'b_green', 'b_black', 'b_magic', 'b_nebula', 'b_ghost', 'b_abandoned' } + local TUPLE_POOL = { + { key = 'b_red', stake = 1 }, { key = 'b_red', stake = 5 }, { key = 'b_blue', stake = 3 }, + { key = 'b_green', stake = 4 }, { key = 'b_black', stake = 1 }, { key = 'b_magic', stake = 3 }, + { key = 'b_nebula', stake = 5 }, { key = 'b_ghost', stake = 1 }, { key = 'b_abandoned', stake = 4 }, + } + -- actor = 1 matters: resolve_actor maps a step's actor through state.first, + -- and a nil actor resolves as actor 2 -- without it every scene renders as + -- the OPPONENT's turn. + local BAN3 = { { actor = 1, action = 'ban', count = 3 } } + + -- The centered draft panel (no popups above it). + local PANEL_REGION = { x = 0.22, y = 0.38, w = 0.56, h = 0.60 } + -- Panel plus the airspace hover popups grow into. + local HOVER_REGION = { x = 0.16, y = 0.04, w = 0.68, h = 0.94 } + + local function cocktail_missing() + return not (G.P_CENTERS and G.P_CENTERS.b_mp_cocktail) + end + + local function cocktail_pool() + local pool = { unpack(TUPLE_POOL) } + pool[3] = { + key = 'b_mp_cocktail', stake = 3, + decks = { 'b_green', 'b_black', 'b_mp_orange' }, + name = 'Casjb Cocktail', -- consumer owns the wording; engine renders verbatim + subtitle = 'A rotating 3-deck mix', + } + return pool + end + + return { + { + name = '01-ban-turn-plain', + expect = "Draft overlay over the main menu: DECK BAN title, 'Your turn' status in green, 9 deck tiles in a row, 'Selected: 0/3' counter, greyed Confirm Ban, blue Random. No ERROR text anywhere.", + region = PANEL_REGION, + setup = function(done) + H.start_draft(PLAIN_POOL, BAN3, 1) + done() + end, + }, + { + name = '02-selected-2of3', + expect = "Two tiles (1st and 5th) raised with red 'Selected' tags; counter reads 'Selected: 2/3'; Confirm still greyed (needs exactly 3).", + region = PANEL_REGION, + setup = function(done) + H.start_draft(PLAIN_POOL, BAN3, 1) + local t1, t2 = H.find_tile('b_red'), H.find_tile('b_black') + if t1 then t1:click() end + if t2 then t2:click() end + done() + end, + }, + { + name = '03-random-armed', + expect = "No tiles raised; counter reads '?/3'; Random button is RED reading 'Cancel Random'; Confirm is GREEN reading 'Confirm Random'.", + region = PANEL_REGION, + setup = function(done) + H.start_draft(PLAIN_POOL, BAN3, 1) + G.FUNCS.mpapi_ban_pick_random() + done() + end, + }, + { + name = '04-offturn-greyed', + expect = "Status reads waiting/their-turn (not green); counter and BOTH buttons visible but greyed out; layout otherwise identical to scenario 01.", + region = PANEL_REGION, + setup = function(done) + H.start_draft(PLAIN_POOL, BAN3, 2) + done() + end, + }, + { + name = '05-banned-tiles', + expect = "Same board as 01 but the 2nd and 8th tiles are debuffed (darkened X overlay); they must not react to anything.", + region = PANEL_REGION, + setup = function(done) + local lobby = H.start_draft(PLAIN_POOL, BAN3, 1) + lobby._ban_pick.banned['b_blue'] = true + lobby._ban_pick.banned['b_ghost'] = true + MPAPI.BanPick.on_state(lobby, lobby._ban_pick) + done() + end, + }, + { + name = '05b-pick-phase', + expect = "PICK step between the last 2: seven tiles debuffed, two live; green 'Your turn: pick your deck'; the clicked survivor raised with a GREEN Selected tag; counter 'Selected: 1/1'; GREEN Confirm Pick button.", + region = PANEL_REGION, + setup = function(done) + -- Ranked-shaped 1-3-3 alternating bans, then the pick. Both + -- sides' bans applied through the host-authoritative + -- apply_ban (the exact path real remote bans take). + local lobby = H.start_draft(PLAIN_POOL, { + { actor = 2, action = 'ban', count = 1 }, + { actor = 1, action = 'ban', count = 3 }, + { actor = 2, action = 'ban', count = 3 }, + { actor = 1, action = 'pick', count = 1 }, + }, 1) + local order = lobby._ban_pick.order + MPAPI.BanPick.apply_ban(lobby, order[2], 'b_blue') + for _, k in ipairs({ 'b_yellow', 'b_green', 'b_black' }) do + MPAPI.BanPick.apply_ban(lobby, order[1], k) + end + for _, k in ipairs({ 'b_magic', 'b_nebula', 'b_ghost' }) do + MPAPI.BanPick.apply_ban(lobby, order[2], k) + end + MPAPI.BanPick.on_state(lobby, lobby._ban_pick) + local t = H.find_tile('b_red') + if t then t:click() end + done() + end, + }, + { + name = '06-tuple-hover-stake-column', + expect = "Hover popup over the 7th tile: deck name + effects on the left, stake column on the right (stake name in its colour, description, 'Also applied' list). Popup fully on screen.", + region = HOVER_REGION, + setup = function(done) + H.start_draft(TUPLE_POOL, BAN3, 1) + local tile = H.find_tile('b_nebula@5') + if tile then tile:hover() end + done() + end, + teardown = function() + local tile = H.find_tile('b_nebula@5') + if tile then tile:stop_hover() end + end, + }, + { + name = '07-cocktail-badge-hover', + expect = "Badge pill above the tiles reads 'Casjb Cocktail: Green Deck + Black Deck + Orange Deck'; its hover shows the three decks SIDE BY SIDE with full effects, growing downward, fully on screen.", + region = HOVER_REGION, + skip = cocktail_missing, + setup = function(done) + H.start_draft(cocktail_pool(), BAN3, 1) + local badge = H.find_ui(G.OVERLAY_MENU, function(n) + return n.config.mp_comp_item ~= nil + end) + if badge then + -- The rich hover is installed by the badge's per-frame init + -- func; run it explicitly (idempotent) before hovering. + G.FUNCS.mpapi_composition_badge_init(badge) + badge:hover() + end + done() + end, + }, + { + name = '08-cocktail-tile-hover-compact', + expect = "Cocktail tile hover is COMPACT: 'Casjb Cocktail' title, 'rotating 3-deck mix' line, three deck NAMES only (no effect boxes), plus the stake column. Same footprint as a normal deck's hover.", + region = HOVER_REGION, + skip = cocktail_missing, + setup = function(done) + H.start_draft(cocktail_pool(), BAN3, 1) + local tile = H.find_tile('b_mp_cocktail@3') + if tile then tile:hover() end + done() + end, + }, + { + name = '09-queue-guard-overlay', + expect = "Guard overlay: 'Matchmaking In Progress' title, description saying you can't start a run or join a lobby while searching, and three buttons -- 'Leave Queue & Continue', 'Leave Queue', 'Stay Queued'. No ERROR text.", + region = { x = 0.25, y = 0.2, w = 0.5, h = 0.6 }, + -- Only exists once the queue-guard feature is present (PR #7 line). + skip = function() + return not MPAPI.queue_guard_overlay + end, + setup = function(done) + H._guard_revert = H.fake_queue() + G.SETTINGS.paused = true + MPAPI.queue_guard_overlay:as_overlay() + done() + end, + teardown = function() + G.SETTINGS.paused = false + if H._guard_revert then + H._guard_revert() + H._guard_revert = nil + end + end, + }, + -- ── Queue-guard matrix: the guard fired from each REAL entry point, and + -- what each overlay button leads to. All fake the queued state via + -- H.fake_queue (no server needed); every trigger goes through the real + -- wrapped G.FUNCS path a player's click takes. + { + name = '10a-newrun-setup-while-queued', + expect = "The New Run setup screen open while a search runs: 'Queueing m:ss' visible in the connection status panel (left). This is the moment BEFORE clicking Play -- no guard yet.", + skip = function() + return not MPAPI.queue_guard_overlay + end, + setup = function(done) + H._guard_revert = H.fake_queue() + G.FUNCS.setup_run({ config = {} }) + done() + end, + teardown = function() + if H._guard_revert then + H._guard_revert() + H._guard_revert = nil + end + end, + }, + { + name = '10b-guard-replaces-setup', + expect = "After clicking Play from that setup screen: the guard REPLACES the setup overlay (as_overlay swaps, it does not stack) -- the run did NOT start, 'Queueing m:ss' still ticking in the status panel.", + skip = function() + return not MPAPI.queue_guard_overlay + end, + setup = function(done) + H._guard_revert = H.fake_queue() + G.FUNCS.setup_run({ config = {} }) + G.FUNCS.start_run(nil, nil) + done() + end, + teardown = function() + if H._guard_revert then + H._guard_revert() + H._guard_revert = nil + end + end, + }, + { + name = '11-guard-from-challenges-menu', + expect = "Guard overlay replacing the challenge list the same way -- starting a challenge while queued is blocked identically to a normal run.", + skip = function() + return not MPAPI.queue_guard_overlay + end, + setup = function(done) + G.FUNCS.challenge_list({ config = {} }) + local revert = H.fake_queue() + G.FUNCS.start_run(nil, nil) + H._guard_revert = revert + done() + end, + teardown = function() + if H._guard_revert then + H._guard_revert() + H._guard_revert = nil + end + end, + }, + { + name = '12-guard-then-stay-queued', + expect = "After pressing Stay Queued: overlay gone, back at the main menu, and 'Queueing m:ss' STILL ticking in the connection status -- the search survived.", + skip = function() + return not MPAPI.queue_guard_overlay + end, + setup = function(done) + local revert = H.fake_queue() + G.SETTINGS.paused = true + MPAPI.queue_guard_overlay:as_overlay() + G.FUNCS.exit_overlay_menu() + H._still_queued = MPAPI.matchmaking.is_queued() + H._guard_revert = revert + done() + end, + teardown = function() + if H._guard_revert then + H._guard_revert() + H._guard_revert = nil + end + end, + }, + { + name = '13-guard-then-leave-queue', + expect = "After pressing Leave Queue: overlay gone, menu unpaused, and the 'Queueing' status GONE from the connection panel -- the search ended. No run started.", + skip = function() + return not MPAPI.queue_guard_overlay + end, + setup = function(done) + local revert = H.fake_queue() + G.SETTINGS.paused = true + MPAPI.queue_guard_overlay:as_overlay() + G.FUNCS.mpapi_queue_guard_leave(nil) + H._guard_revert = revert + done() + end, + teardown = function() + if H._guard_revert then + H._guard_revert() + H._guard_revert = nil + end + end, + }, + -- LAST on purpose (names sort the run order): Leave Queue & Continue + -- actually starts the blocked run, which tears down the menu the other + -- scenarios need. The suite quits right after this capture. + { + name = '14-guard-then-leave-and-continue', + expect = "After pressing Leave Queue & Continue from the New Run guard: the queue is left AND the run actually starts -- captured at the blind-select screen of a fresh RED DECK run (deck forced for determinism; an interaction-on-start deck like Orange would land mid pack-picker instead).", + settle = 6.0, + skip = function() + return not MPAPI.queue_guard_overlay + end, + setup = function(done) + -- Force a passive deck: the run starts on the profile's + -- remembered deck, and e.g. Orange opens a mandatory pack + -- picker at run start -- the capture would land mid-pack + -- instead of at blind select. Restored in teardown. + local mem = G.PROFILES[G.SETTINGS.profile].MEMORY + H._mem_deck, H._mem_stake = mem.deck, mem.stake + mem.deck, mem.stake = 'Red Deck', 1 + G.FUNCS.setup_run({ config = {} }) + -- MEMORY only feeds the New Run tab; with a saved run present the + -- setup opens on Continue, which sets viewed_back from the SAVE. + -- Game:start_run gives viewed_back top precedence for a fresh + -- run (game.lua:2037), so force it directly. + G.GAME.viewed_back = Back(get_deck_from_name('Red Deck')) + G.viewed_stake = 1 + local revert = H.fake_queue() + G.FUNCS.start_run(nil, nil) + G.FUNCS.mpapi_queue_guard_leave_play(nil) + H._guard_revert = revert + done() + end, + teardown = function() + -- The run this scenario starts SAVES over the profile's current + -- run; delete that suite-created save so it cannot leak into + -- later runs (a leftover save flips the setup screen to the + -- Continue tab and changes which deck a scripted start uses). + pcall(function() + love.filesystem.remove(G.SETTINGS.profile .. '/save.jkr') + end) + local mem = G.PROFILES[G.SETTINGS.profile].MEMORY + mem.deck, mem.stake = H._mem_deck, H._mem_stake + H._mem_deck, H._mem_stake = nil, nil + if H._guard_revert then + H._guard_revert() + H._guard_revert = nil + end + end, + }, + } +end diff --git a/dev/test_banpick_draft_guard.lua b/dev/test_banpick_draft_guard.lua new file mode 100644 index 0000000..c129bde --- /dev/null +++ b/dev/test_banpick_draft_guard.lua @@ -0,0 +1,201 @@ +--[[ + Ban-pick draft_id guard test. + + The host stamps each DRAFT with a unique draft_id; on_state drops anything + from a dead (completed or superseded) draft_id and supersedes the old draft + on a new draft_id. State is a full snapshot, so a duplicate simply + re-applies harmlessly -- no per-message sequencing. + + Run from the repo root: + luajit dev/test_banpick_draft_guard.lua +]] + +-- ── Stubs ─────────────────────────────────────────────────────────────────── +MPAPI = { _TEST = true, sendWarnMessage = function() end } +G = { + FUNCS = {}, + C = { GREEN = 'green', MULT = 'mult', BLUE = 'blue', WHITE = 'white', BLACK = 'black', CLEAR = 'clear', UI = { BACKGROUND_INACTIVE = 'inactive', TEXT_LIGHT = 'light' } }, +} + +dofile('api/ban_pick.lua') +local BP = MPAPI.BanPick + +MPAPI.ActionTypes = {} +local LOBBY = { + is_host = true, + player_id = 'host', + get_players = function(_self) + return { { id = 'host' }, { id = 'guest' } } + end, +} +MPAPI.get_current_lobby = function() + return LOBBY +end + +-- Tuple pool: {key, stake} items, matching a server-issued pool's shape. +local POOL = { + { key = 'b_red', stake = 1 }, + { key = 'b_blue', stake = 4 }, + { key = 'b_yellow', stake = 8 }, + { key = 'b_green', stake = 1 }, +} + +local function start_draft() + BP.start(LOBBY, { + build_pool = function() + local copy = {} + for i, item in ipairs(POOL) do copy[i] = item end + return copy + end, + schedule = { + { actor = 1, action = 'ban', count = 2 }, + { actor = 2, action = 'ban', count = 1 }, + { actor = 1, action = 'pick', count = 1 }, + }, + state_action = 's', + ban_action = 'b', + on_refresh = function() end, + }, function() end) + LOBBY._ban_pick.first = 1 -- deterministic: actor 1 = host +end + +-- ── Harness ──────────────────────────────────────────────────────────────── +local failures = 0 +local function check(cond, msg) + if cond then print('PASS: ' .. msg) else failures = failures + 1; print('FAIL: ' .. msg) end +end + +-- ── REGRESSION: banning one stake of a deck must not ban its twin ──────────── +-- Found in MJ's in-game pass: tuple pools may repeat a deck at different stakes +-- (bot rules allow up to 3); identity keyed on the deck key alone removed BOTH +-- tiles when one was banned. +print() +print('-- regression: same deck at two stakes are independent items --') +BP.start(LOBBY, { + build_pool = function() + return { + { key = 'b_red', stake = 1 }, + { key = 'b_red', stake = 8 }, + { key = 'b_blue', stake = 4 }, + } + end, + schedule = { { actor = 1, action = 'ban', count = 1 }, { actor = 2, action = 'ban', count = 1 } }, + state_action = 's', + ban_action = 'b', + on_refresh = function() end, +}, function() end) +LOBBY._ban_pick.first = 1 +check(BP.apply_ban(LOBBY, 'host', 'b_red@1') == true, 'ban Red@White applies') +check(LOBBY._ban_pick.banned['b_red@1'] == true, 'Red@White is banned') +check(LOBBY._ban_pick.banned['b_red@8'] == nil, 'Red@Gold is NOT banned') +check(BP.apply_ban(LOBBY, 'guest', 'b_red@8') == true, 'Red@Gold can still be banned as its own item') +local survivors_after = 0 +for _, item in ipairs(LOBBY._ban_pick.pool) do + if not LOBBY._ban_pick.banned[(type(item) == 'table' and item.stake ~= nil) and (item.key .. '@' .. item.stake) or item.key or item] then + survivors_after = survivors_after + 1 + end +end +check(survivors_after == 1, 'exactly the untouched tuple survives') + +-- ── Draft-identity guard: stale/duplicate state broadcasts, scoped per draft ─ +print() +print('-- draft guard: host stamps a draft_id on the state --') +MPAPI.ActionTypes = { s = { key = 's' }, b = { key = 'b' } } +local broadcasts = {} +LOBBY.action = function(_self, _at) + return { + broadcast = function(_a, payload) broadcasts[#broadcasts + 1] = payload.state end, + send = function() end, + } +end +BP._draft_guard.reset() +start_draft() +check(type(LOBBY._ban_pick.draft_id) == 'string', 'host stamps a draft_id on the state') +check(BP.apply_ban(LOBBY, 'host', 'b_red@1') == true, 'host ban applies') +BP.broadcast_state(LOBBY) +check(#broadcasts == 2, 'both broadcasts went out') + +print() +print('-- draft guard: guest start clears stale state and blocks old-draft dups --') +local GUEST = { + is_host = false, + player_id = 'guest', + get_players = function(_self) + return { { id = 'host' }, { id = 'guest' } } + end, +} +MPAPI.get_current_lobby = function() + return GUEST +end +local function make_state(opts) + opts = opts or {} + return { + draft_id = opts.draft_id or 'hostA#1', + pool = { 'b_red', 'b_blue' }, + banned = opts.banned or {}, + order = { 'host', 'guest' }, + first = 1, + schedule = { { actor = 1, action = 'ban', count = 1 } }, + sched_index = 1, + sched_remaining = 1, + complete = opts.complete or false, + survivors = opts.survivors, + } +end +local guest_cfg = { + schedule = { { actor = 1, action = 'ban', count = 1 } }, + state_action = 's', + ban_action = 'b', + on_refresh = function() end, +} +BP._draft_guard.reset() +local completedA, completedB = nil, nil +BP.start(GUEST, guest_cfg, function(s) completedA = s end) +BP.on_state(GUEST, make_state()) +check(GUEST._ban_pick ~= nil and GUEST._ban_pick.draft_id == 'hostA#1', 'first broadcast of a draft is accepted') +local old_final = make_state({ complete = true, survivors = { 'b_red' } }) +BP.on_state(GUEST, old_final) +check(completedA ~= nil and completedA[1] == 'b_red', 'old draft completes normally') +check(GUEST._ban_pick == old_final, 'old final state attached to the lobby') + +BP.start(GUEST, guest_cfg, function(s) completedB = s end) +check(GUEST._ban_pick == nil, 'guest BP.start clears the previous draft state') +check(BP.is_active() == false, 'no live board while the host is still fetching the pool') +BP.on_state(GUEST, old_final) +check(GUEST._ban_pick == nil, 'late duplicate of the OLD final broadcast is ignored') +check(completedB == nil, 'old survivors do NOT complete the new draft') +BP.on_state(GUEST, make_state({ draft_id = 'hostA#2' })) +check(GUEST._ban_pick ~= nil and GUEST._ban_pick.draft_id == 'hostA#2', "the new draft's first broadcast (new draft_id) is accepted") +local legacy = { pool = { 'b_red' }, banned = {}, order = { 'host', 'guest' }, first = 1, schedule = { { actor = 1, action = 'ban', count = 1 } }, sched_index = 1, sched_remaining = 1, complete = false } +BP.on_state(GUEST, legacy) +check(GUEST._ban_pick == legacy, 'a state with NO draft_id (older host build) is accepted as before') + +-- ── REGRESSION: fresh host after a long-running previous host ─────────────── +-- The verifier's cross-host wedge: a guest who tracked host A through many +-- broadcasts must accept host B's brand-new draft. draft_id (dead-draft +-- marking), not sequencing, decides what is stale. +print() +print('-- regression: new host is never wedged by a dead prior-host draft --') +BP._draft_guard.reset() +local completedC = nil +BP.start(GUEST, guest_cfg, function(x) completedC = x end) +for i = 1, 8 do + BP.on_state(GUEST, make_state({ draft_id = 'hostA#7', complete = (i == 8), survivors = { 'b_red' } })) +end +check(completedC ~= nil, 'match 1 against host A completed') +BP.start(GUEST, guest_cfg, function() end) +BP.on_state(GUEST, make_state({ draft_id = 'hostB#1' })) +check(GUEST._ban_pick ~= nil and GUEST._ban_pick.draft_id == 'hostB#1', + "host B's first broadcast (fresh draft_id) is accepted (no cross-host floor)") +BP.on_state(GUEST, make_state({ draft_id = 'hostA#7', complete = true, survivors = { 'b_red' } })) +check(GUEST._ban_pick.draft_id == 'hostB#1', "a dup from dead host-A draft cannot displace host B's live draft") + +-- ── Summary ───────────────────────────────────────────────────────────────── +print() +if failures == 0 then + print('ALL TESTS PASSED') + os.exit(0) +else + print(failures .. ' TEST(S) FAILED') + os.exit(1) +end diff --git a/dev/test_banpick_popup_clamp.lua b/dev/test_banpick_popup_clamp.lua new file mode 100644 index 0000000..417370b --- /dev/null +++ b/dev/test_banpick_popup_clamp.lua @@ -0,0 +1,79 @@ +--[[ + Hover-popup vertical clamp test. + + The engine's Moveable alignment flips a hover popup above ('tm') or below + ('bm') its tile but only clamps horizontally, so a popup taller than the + space on its side of the tile runs off screen (the weekly cocktail's full + composition hovered from the bottom tile row). And because the popup is + position-bonded to its tile, which MOVES while the popup is open (selecting + a tile raises it), the clamp must hold frame-by-frame, not once at hover: + card:hover wraps the popup's move and re-clamps after every frame with + BP._popup.clamp_y -- the pure decision under test here. + + Contract: clamp_y(y, h, room_h, edge) returns the y keeping [y, y+h] inside + [edge, room_h - edge]; bottom edge applies first so the TOP edge wins for + popups taller than the room (the top of the content must stay readable). + + Run from the repo root: + luajit dev/test_banpick_popup_clamp.lua +]] + +-- ── Stubs to load the real module ─────────────────────────────────────────── +MPAPI = { + _TEST = true, + sendWarnMessage = function() end, +} +localize = function(k) return k end +G = { + FUNCS = {}, + C = { GREEN = 'green', RED = 'red', MULT = 'mult', BLUE = 'blue', WHITE = 'white', BLACK = 'black', CLEAR = 'clear', UI = { BACKGROUND_INACTIVE = 'inactive', TEXT_LIGHT = 'light' } }, +} + +dofile('api/ban_pick.lua') +local clamp_y = MPAPI.BanPick._popup.clamp_y + +local failures = 0 +local function check(cond, msg) + if not cond then + failures = failures + 1 + print('FAIL: ' .. msg) + end +end + +-- Quarter-unit fixtures throughout: exactly representable in binary floating +-- point, so the geometry identities hold under == with no tolerance. +local EDGE = 0.25 +local ROOM_H = 11.5 -- typical G.ROOM.T.h in game units + +-- A popup fully on screen is untouched. +check(clamp_y(3, 5, ROOM_H, EDGE) == 3, 'fitting popup is untouched') +check(clamp_y(EDGE, 5, ROOM_H, EDGE) == EDGE, 'popup exactly at the top edge is untouched') +check(clamp_y(ROOM_H - EDGE - 5, 5, ROOM_H, EDGE) == ROOM_H - EDGE - 5, 'popup exactly at the bottom edge is untouched') + +-- The bug scenario: tall popup pushed above the screen (tile raised while +-- hovered, or bottom-row tile with the cocktail composition) -> pulled down +-- to the top edge. +check(clamp_y(-2, 9, ROOM_H, EDGE) == EDGE, 'popup above the screen is pulled down to the top edge') + +-- Overflow past the bottom -> pushed up to the bottom edge. +check(clamp_y(8, 9, ROOM_H, EDGE) == ROOM_H - EDGE - 9, 'popup past the bottom is pushed up to the bottom edge') + +-- Taller than the whole room: cannot fit, so the TOP edge must win -- the +-- player reads content top-down. +check(clamp_y(-4, 20, ROOM_H, EDGE) == EDGE, 'taller-than-room popup pins to the top edge') +check(clamp_y(5, 20, ROOM_H, EDGE) == EDGE, 'taller-than-room popup pins to the top edge regardless of start y') + +-- Idempotence: clamping a clamped position changes nothing (the wrap runs +-- every frame, so a fixed point is required or the popup would creep). +for _, y in ipairs({ -2, 0, 3, 8, 15 }) do + for _, h in ipairs({ 2, 9, 20 }) do + local once = clamp_y(y, h, ROOM_H, EDGE) + check(clamp_y(once, h, ROOM_H, EDGE) == once, 'idempotent at y=' .. y .. ' h=' .. h) + end +end + +if failures == 0 then + print('OK: all popup clamp checks passed') +else + os.exit(1) +end diff --git a/dev/test_banpick_selection.lua b/dev/test_banpick_selection.lua index 6803249..6a3a37f 100644 --- a/dev/test_banpick_selection.lua +++ b/dev/test_banpick_selection.lua @@ -16,10 +16,15 @@ ]] -- ── Stubs to load the real module ─────────────────────────────────────────── -MPAPI = {} +local warns = {} +MPAPI = { + _TEST = true, + sendWarnMessage = function(msg) warns[#warns + 1] = msg end, +} +localize = function(k) return k end G = { FUNCS = {}, - C = { GREEN = 'green', MULT = 'mult', BLUE = 'blue', WHITE = 'white', BLACK = 'black', CLEAR = 'clear', UI = { BACKGROUND_INACTIVE = 'inactive', TEXT_LIGHT = 'light' } }, + C = { GREEN = 'green', RED = 'red', MULT = 'mult', BLUE = 'blue', WHITE = 'white', BLACK = 'black', CLEAR = 'clear', UI = { BACKGROUND_INACTIVE = 'inactive', TEXT_LIGHT = 'light' } }, } dofile('api/ban_pick.lua') @@ -134,7 +139,7 @@ G.FUNCS.mpapi_ban_pick_confirm_check(e) check(e.config.button == nil and e.config.colour == 'inactive', 'empty selection: button disabled') SEL.toggle(SEL.list(), 'b_red', 1) G.FUNCS.mpapi_ban_pick_confirm_check(e) -check(e.config.button == 'mpapi_ban_pick_confirm' and e.config.colour == 'mult', 'full selection: button live (ban colour)') +check(e.config.button == 'mpapi_ban_pick_confirm' and e.config.colour == 'green', 'full selection: button live and green (the confirm signal)') -- ── full draft with a pick step ends in on_complete ───────────────────────── print() @@ -165,24 +170,198 @@ check(r[1] ~= 'b_red' and r[2] ~= 'b_red', 'randomize never picks banned decks') check(r[1] ~= r[2], 'randomize picks distinct decks') check(r[1] == 'b_blue' and r[2] == 'b_yellow', 'randomize honours the injected rng') --- ── dice button: rerolls the module selection; confirm commits it ─────────── +-- ── random button: BLIND commit -- nothing revealed until confirmed ───────── print() -print('-- dice button: reroll then confirm commits the random selection --') +print('-- random: arms blind, reveals nothing, confirm rolls and commits --') start_draft({ { actor = 1, action = 'ban', count = 2 }, { actor = 2, action = 'ban', count = 1 } }) -G.FUNCS.mpapi_ban_pick_random() -check(#SEL.list() == 2, 'dice press fills the selection to the needed count') -local rolled = { SEL.list()[1], SEL.list()[2] } -G.FUNCS.mpapi_ban_pick_random() -check(#SEL.list() == 2, 'second dice press re-rolls (still a full selection)') local e3 = { config = {} } G.FUNCS.mpapi_ban_pick_random_check(e3) -check(e3.config.button == 'mpapi_ban_pick_random' and e3.config.colour == 'blue', 'dice button live on our turn') -rolled = { SEL.list()[1], SEL.list()[2] } -G.FUNCS.mpapi_ban_pick_confirm() -check(LOBBY._ban_pick.banned[rolled[1]] == true and LOBBY._ban_pick.banned[rolled[2]] == true, - 'confirm commits the rolled selection') +check(e3.config.button == 'mpapi_ban_pick_random' and e3.config.colour == 'blue', 'random button live on our turn') +G.FUNCS.mpapi_ban_pick_random() +check(SEL.armed() == true, 'random press arms blind-random') +check(#SEL.list() == 0, 'arming reveals NOTHING (no marks, no picks exist yet)') G.FUNCS.mpapi_ban_pick_random_check(e3) -check(e3.config.button == nil, "dice button disabled once it's not our turn") +check(e3.config.colour == 'red', 'armed random button goes red (Cancel Random)') +check(SEL.ui().random_text == 'k_banpick_cancel_random', 'random label flips to Cancel Random') +check(SEL.ui().confirm_text == 'k_banpick_confirm_random', 'confirm label reads Confirm Random') +check(SEL.ui().count_text == '?/2', 'counter hides the picks (?/N)') +local e4 = { config = {} } +G.FUNCS.mpapi_ban_pick_confirm_check(e4) +check(e4.config.button == 'mpapi_ban_pick_confirm', 'confirm goes live while armed (no marks needed)') +G.FUNCS.mpapi_ban_pick_random() +check(SEL.armed() == false, 'second random press disarms back to manual') +G.FUNCS.mpapi_ban_pick_random() +check(SEL.armed() == true, 're-armed') +G.FUNCS.mpapi_ban_pick_confirm() +check(SEL.armed() == false, 'confirm consumed the armed state') +local banned_count = 0 +for _ in pairs(LOBBY._ban_pick.banned) do banned_count = banned_count + 1 end +check(banned_count == 2, 'confirm rolled and committed exactly the needed count') +check(LOBBY._ban_pick.sched_index == 2, "turn advanced to the guest's step") + +print() +print('-- random: manual marks clear on arm; tile-click disarms --') +start_draft({ { actor = 1, action = 'ban', count = 2 }, { actor = 2, action = 'ban', count = 1 } }) +SEL.toggle(SEL.list(), 'b_red', 2) +G.FUNCS.mpapi_ban_pick_random() +check(SEL.armed() and #SEL.list() == 0, 'arming clears manual marks') + +-- ── random: a SHORT roll commits NOTHING ───────────────────────────────────── +-- Eligible survivors < the step's remaining count: committing the partial batch +-- would exhaust the pool mid-step and wedge the draft with no legal action left. +print() +print('-- random: short roll (eligible < needed) commits nothing --') +start_draft({ { actor = 1, action = 'ban', count = 3 }, { actor = 2, action = 'ban', count = 1 } }) +LOBBY._ban_pick.banned['b_red'] = true +LOBBY._ban_pick.banned['b_blue'] = true +warns = {} +G.FUNCS.mpapi_ban_pick_random() +check(SEL.armed() == true, 'armed over a too-small pool (arming itself is allowed)') +G.FUNCS.mpapi_ban_pick_confirm() +local short_banned = 0 +for _ in pairs(LOBBY._ban_pick.banned) do short_banned = short_banned + 1 end +check(short_banned == 2, 'confirm committed NOTHING beyond the pre-existing bans') +check(LOBBY._ban_pick.sched_index == 1 and LOBBY._ban_pick.sched_remaining == 3, 'the step is untouched, not wedged mid-way') +check(LOBBY._ban_pick.complete ~= true, 'draft not completed') +check(SEL.armed() == false, 'blind-random disarmed') +check(#warns == 1 and warns[1]:find('nothing committed', 1, true) ~= nil, 'short roll warns instead of committing') +check(SEL.ui().count_text == '0/3', 'counter re-synced after the refused roll') + +-- ── guest confirm: UI syncs immediately, before the host rebroadcast ───────── +-- On a guest, request_ban only sends the wire message: without the explicit +-- sync the tiles keep their Selected tags and the counter reads a full N/N +-- until the host's state broadcast lands. +print() +print('-- guest confirm: clears tags and counter immediately --') +local sent = {} +local GUEST = { + is_host = false, + player_id = 'guest', + get_players = function(_self) + return { { id = 'host' }, { id = 'guest' } } + end, + action = function(_self, _at) + return { + send = function(_a, to, payload) sent[#sent + 1] = { to = to, key = payload.item_key } end, + broadcast = function() end, + } + end, +} +MPAPI.get_current_lobby = function() + return GUEST +end +MPAPI.ActionTypes = { test_state = { key = 'test_state' }, test_ban = { key = 'test_ban' } } +BP._draft_guard.reset() +BP.start(GUEST, { + schedule = { { actor = 1, action = 'ban', count = 2 } }, + state_action = 'test_state', + ban_action = 'test_ban', + on_refresh = function() end, +}, function() end) +-- Host's broadcast: guest's turn (first = 2 makes actor 1 resolve to order[2]). +BP.on_state(GUEST, { + pool = { unpack(POOL) }, + banned = {}, + order = { 'host', 'guest' }, + first = 2, + schedule = { { actor = 1, action = 'ban', count = 2 } }, + sched_index = 1, + sched_remaining = 2, + complete = false, +}) +local function fake_card(id) + return { + mp_item_id = id, + highlighted = true, + children = { mp_sel_tag = { remove = function(self) self.removed = true end } }, + T = { w = 1 }, + } +end +local c1, c2 = fake_card('b_red'), fake_card('b_blue') +SEL.set_areas({ { cards = { c1, c2 } } }) +SEL.toggle(SEL.list(), 'b_red', 2) +SEL.toggle(SEL.list(), 'b_blue', 2) +G.FUNCS.mpapi_ban_pick_confirm() +check(#sent == 2 and sent[1].to == 'host' and sent[2].to == 'host', 'guest confirm sent both bans to the host') +check(sent[1].key == 'b_red' and sent[2].key == 'b_blue', 'wire carries the marked ids') +check(#SEL.list() == 0, 'selection cleared') +check(c1.highlighted == false and c2.highlighted == false, 'tiles lowered immediately') +check(c1.children.mp_sel_tag == nil and c2.children.mp_sel_tag == nil, 'Selected tags removed immediately') +check(SEL.ui().count_text == '0/2', 'counter reads 0/N, not a stale full N/N') +SEL.set_areas({}) +MPAPI.get_current_lobby = function() + return LOBBY +end +MPAPI.ActionTypes = {} + +-- ── stake column: two-phase gather/build is orphan-free on failure ────────── +-- localize{type='descriptions'} constructs live DynaText/UIBox objects the +-- moment it runs; a failed build must release every one of them (they would +-- otherwise draw unparented at the screen origin). +print() +print('-- stake column: gather/build happy path --') +local SC = BP._stake_column +G.UIT = { R = 'R', C = 'C', T = 'T', B = 'B', O = 'O' } +G.P_CENTER_POOLS = { Stake = { { key = 'stake1' }, { key = 'stake2' }, { key = 'stake3' } } } +get_stake_col = function(i) return 'col' .. i end +local made_objects = {} +local desc_calls = 0 +local desc_fail_on = nil +local plain_localize = localize +localize = function(arg) + if type(arg) ~= 'table' then + return arg + end + if arg.type == 'name_text' then + return 'NAME:' .. arg.key + end + -- descriptions: two lines, each carrying a live object (the {E:} analogue), + -- appended into the caller's nodes table exactly like the real localize. + desc_calls = desc_calls + 1 + if desc_calls == desc_fail_on then + error('loc boom') + end + for line = 1, 2 do + local obj = { remove = function(self) self.removed = true end } + made_objects[#made_objects + 1] = obj + arg.nodes[line] = { { n = 'O', config = { object = obj } } } + end +end +local gathered = { descs = {}, line_sets = {} } +SC.gather({ key = 'b_x', stake = 3 }, gathered) +check(gathered.ready == true, 'gather completes') +check(gathered.name == 'NAME:stake3' and gathered.name_colour == 'col3', 'name + colour gathered as plain data') +check(#gathered.descs == 2, 'own stake + one previous stake gathered') +check(#gathered.descs[2].lines == 1, "previous stake's boilerplate line dropped") +check(made_objects[4].removed == true, "the dropped line's live object was released at drop time") +local right = SC.build(gathered) +check(#right == 4, 'build: name row, own chip row, Also applied label, previous chip row') +check(right[1].nodes[1].config.text == 'NAME:stake3', 'name row first') +check(right[3].nodes[1].config.text == 'k_also_applied', 'label sits after the own-stake row') +check(right[2].nodes[1].nodes[1].config.colour == 'col3' and right[4].nodes[1].nodes[1].config.colour == 'col2', + 'chip swatches carry the gathered stake colours') + +print() +print('-- stake column: mid-gather failure releases every constructed object --') +made_objects = {} +desc_calls = 0 +desc_fail_on = 2 +local gathered2 = { descs = {}, line_sets = {} } +local ok2 = pcall(SC.gather, { key = 'b_x', stake = 3 }, gathered2) +check(ok2 == false, 'second descriptions call throws') +check(gathered2.ready ~= true, 'gather not marked ready') +check(#made_objects == 2, 'first call had constructed two live objects') +SC.release(gathered2) +check(made_objects[1].removed == true and made_objects[2].removed == true, + 'release removed every already-constructed object (orphan-free)') + +print() +print('-- stake column: unknown stake index gathers nothing, no error --') +desc_fail_on = nil +local gathered3 = { descs = {}, line_sets = {} } +local ok3 = pcall(SC.gather, { key = 'b_x', stake = 99 }, gathered3) +check(ok3 == true and gathered3.ready ~= true and #gathered3.descs == 0, 'missing stake center: silent empty column') +localize = plain_localize -- ── Summary ───────────────────────────────────────────────────────────────── print() diff --git a/localization/en-us.lua b/localization/en-us.lua index bf001e5..91615ee 100644 --- a/localization/en-us.lua +++ b/localization/en-us.lua @@ -116,7 +116,11 @@ return { k_banpick_selected_tag = 'Selected', k_banpick_confirm = 'Confirm Ban', k_banpick_confirm_pick = 'Confirm Pick', + k_banpick_confirm_random = 'Confirm Random', k_banpick_random = 'Random', + k_banpick_cancel_random = 'Cancel Random', + k_banpick_weekly_mix = 'A rotating 3-deck mix', + k_cocktail_suffix = 'Cocktail', }, }, descriptions = { diff --git a/networking/api_client.lua b/networking/api_client.lua index fe9ded0..a83b4bd 100644 --- a/networking/api_client.lua +++ b/networking/api_client.lua @@ -8,3 +8,4 @@ MPAPI.load_mpapi_file('networking/api_client/account.lua') MPAPI.load_mpapi_file('networking/api_client/lobby.lua') MPAPI.load_mpapi_file('networking/api_client/matchmaking.lua') MPAPI.load_mpapi_file('networking/api_client/replay.lua') +MPAPI.load_mpapi_file('networking/api_client/draft.lua') diff --git a/networking/api_client/draft.lua b/networking/api_client/draft.lua new file mode 100644 index 0000000..a693120 --- /dev/null +++ b/networking/api_client/draft.lua @@ -0,0 +1,15 @@ +local api_client = MPAPI.networking.api_client + +-- POST /api/matches/:id/draft-pool. Idempotent server-side: the first call rolls +-- and persists the pool, every retry/reconnect returns the identical pool. +-- 404 = the queue has no draft policy -- the caller's signal to abort the draft. +function api_client:issue_draft_pool(token, match_id, callback) + if not self:_transport_ready() then + callback(MPAPI.make_error(MPAPI.ErrorKind.NOT_CONNECTED, 'MQTT thread not running'), nil) + return + end + + self:_setup_json_callback(callback) + + self.mqtt:http_post_auth(self.base_url .. '/api/matches/' .. match_id .. '/draft-pool', '{}', token) +end