From 2c20349648e2fc1dd51529828f6757dcbfc99053 Mon Sep 17 00:00:00 2001 From: ChronoFinale Date: Mon, 13 Jul 2026 10:28:35 -0500 Subject: [PATCH 1/3] feat(matchmaking): block singleplayer run start while queued Nothing stopped a queued player from clicking Play -> New Run (or Continue / a Challenge): the run would tear down the main menu while the queue stayed active server-side with no feedback. Wrap G.FUNCS.start_run -- the single vanilla chokepoint every run-entry flow funnels through -- and, while MPAPI.matchmaking.is_queued(), show a 'Matchmaking In Progress' dialog with three ways out: Leave Queue & Play (leaves every handle, then replays the blocked run-start back through the gate so it re-blocks if still searching), Leave Queue (return to menu), and Stay Queued (back button / Esc). No dismiss path reads search state, so it can't soft-lock if the search ends while open. Inert when not searching -- vanilla flow untouched. --- api/matchmaking/run_guard.lua | 31 +++++ dev/test_queue_guard.lua | 230 ++++++++++++++++++++++++++++++++++ localization/en-us.lua | 6 + ui/queue_guard_overlay.lua | 121 ++++++++++++++++++ 4 files changed, 388 insertions(+) create mode 100644 api/matchmaking/run_guard.lua create mode 100644 dev/test_queue_guard.lua create mode 100644 ui/queue_guard_overlay.lua diff --git a/api/matchmaking/run_guard.lua b/api/matchmaking/run_guard.lua new file mode 100644 index 0000000..ee34950 --- /dev/null +++ b/api/matchmaking/run_guard.lua @@ -0,0 +1,31 @@ +-- Blocks vanilla singleplayer run-start while a matchmaking search is active. +-- Bug: nothing stopped a queued player from clicking Play -> New Run (or +-- Continue / a Challenge); the run would tear down the main menu while the +-- queue stayed active server-side with no feedback. Maintainer verdict: you +-- should not be able to start a run at all while queued. +MPAPI.matchmaking = MPAPI.matchmaking or {} +MPAPI._internal.mm = MPAPI._internal.mm or {} +local mm = MPAPI._internal.mm + +-- G.FUNCS.start_run is the single vanilla chokepoint every "enter a run" +-- flow funnels through -- New Run and Continue (via start_setup_run), Challenges +-- (via start_challenge_run), the first-launch tutorial run, and the in-run +-- "Start New Run" restart button all call it directly. It is also the function +-- that actually tears down the menu (G.E_MANAGER:clear_queue(), wipe_on/off) +-- and calls Game:start_run -- gating here, before any of that runs, catches +-- every one of those entry points with a single wrap and leaves the menu +-- completely untouched when blocked. +local _start_run_ref = G.FUNCS.start_run +G.FUNCS.start_run = function(e, args) + if MPAPI.matchmaking.is_queued() then + -- Stash the blocked action so the overlay's "Leave Queue & Play" can + -- replay it after leaving. Replay goes back through this wrapper, so the + -- gate is re-checked -- if the leave somehow didn't take, it re-blocks + -- instead of starting a run while queued. + mm.pending_run = { e = e, args = args } + G.SETTINGS.paused = true + MPAPI.queue_guard_overlay:as_overlay() + return + end + return _start_run_ref(e, args) +end diff --git a/dev/test_queue_guard.lua b/dev/test_queue_guard.lua new file mode 100644 index 0000000..037b2b5 --- /dev/null +++ b/dev/test_queue_guard.lua @@ -0,0 +1,230 @@ +-- Standalone regression test for api/matchmaking/run_guard.lua's queue-blocks- +-- run-start gate. Not wired into a test framework (the repo has none) -- this +-- is a self-contained LuaJIT script that stubs just enough of the Balatro/ +-- MPAPI surface (G.FUNCS, G.SETTINGS, MPAPI.matchmaking.is_queued, +-- MPAPI.queue_guard_overlay) to load and drive the real module source. +-- +-- Run: luajit dev/test_queue_guard.lua + +local this_dir = debug.getinfo(1, 'S').source:match('@?(.*[/\\])') or './' +local SRC_PATH = this_dir .. '../api/matchmaking/run_guard.lua' + +local function read_file(path) + local f = assert(io.open(path, 'r')) + local content = f:read('*a') + f:close() + return content +end + +local fixed_src = read_file(SRC_PATH) + +-- The "broken" control variant reproduces the pre-fix behaviour (no gate at +-- all -- G.FUNCS.start_run falls straight through to the original), so the +-- regression test can prove it would actually have failed before the fix. +local GATE_BLOCK = [[ + if MPAPI.matchmaking.is_queued() then + -- Stash the blocked action so the overlay's "Leave Queue & Play" can + -- replay it after leaving. Replay goes back through this wrapper, so the + -- gate is re-checked -- if the leave somehow didn't take, it re-blocks + -- instead of starting a run while queued. + mm.pending_run = { e = e, args = args } + G.SETTINGS.paused = true + MPAPI.queue_guard_overlay:as_overlay() + return + end +]] + +local function strip_exact(src, needle) + local s, e = src:find(needle, 1, true) -- plain (non-pattern) find + assert(s, 'failed to construct pre-fix control variant (gate block not found verbatim)') + return src:sub(1, s - 1) .. src:sub(e + 1) +end + +local broken_src = strip_exact(fixed_src, GATE_BLOCK) +assert(broken_src ~= fixed_src, 'control variant did not change source') + +------------------------------------------------------------------- +-- Fake environment: G.FUNCS, G.SETTINGS, MPAPI +------------------------------------------------------------------- + +local function make_env() + local original_start_run_calls = 0 + local overlay_shown_count = 0 + local _searching = false + + local G = { + FUNCS = { + -- Stand-in for the vanilla G.FUNCS.start_run this module wraps. + start_run = function(_e, _args) + original_start_run_calls = original_start_run_calls + 1 + end, + }, + SETTINGS = { paused = false }, + } + + local MPAPI = { + _internal = {}, + matchmaking = { + is_queued = function() return _searching end, + }, + queue_guard_overlay = { + as_overlay = function() overlay_shown_count = overlay_shown_count + 1 end, + }, + } + + return { + G = G, + MPAPI = MPAPI, + set_searching = function(v) _searching = v end, + start_run_calls = function() return original_start_run_calls end, + overlay_shown_count = function() return overlay_shown_count end, + call_start_run = function() G.FUNCS.start_run(nil, {}) end, + } +end + +local function load_module(src, env) + local chunk_env = setmetatable({ G = env.G, MPAPI = env.MPAPI }, { __index = _G }) + local chunk = assert(loadstring(src, 'run_guard')) + setfenv(chunk, chunk_env) + chunk() +end + +------------------------ +-- Test harness +------------------------ + +local failures = 0 +local function check(cond, msg) + if cond then + print('PASS: ' .. msg) + else + failures = failures + 1 + print('FAIL: ' .. msg) + end +end + +------------------------ +-- Test 1 (fixed): not searching -> start_run proceeds untouched (zero overhead) +------------------------ + +print() +print('-- scenario: fixed run_guard.lua, not searching --') +local env2 = make_env() +load_module(fixed_src, env2) +env2.set_searching(false) +env2.call_start_run() +check(env2.start_run_calls() == 1, 'fixed: original start_run called when not searching') +check(env2.overlay_shown_count() == 0, 'fixed: guard overlay not shown when not searching') + +------------------------ +-- Test 3 (fixed): searching -> start_run blocked, overlay shown instead +------------------------ + +print() +print('-- scenario: fixed run_guard.lua, searching --') +local env3 = make_env() +load_module(fixed_src, env3) +env3.set_searching(true) +env3.call_start_run() +check(env3.start_run_calls() == 0, 'fixed: original start_run NOT called while searching') +check(env3.overlay_shown_count() == 1, 'fixed: guard overlay shown while searching') + +------------------------ +-- Test 4 (fixed): leave-queue-then-allow sequence +------------------------ + +print() +print('-- scenario: fixed run_guard.lua, leave queue then retry --') +local env4 = make_env() +load_module(fixed_src, env4) +env4.set_searching(true) +env4.call_start_run() +check(env4.start_run_calls() == 0, 'leave-then-allow: blocked while still searching') +check(env4.overlay_shown_count() == 1, 'leave-then-allow: overlay shown while still searching') + +-- Simulate "Leave Queue": the handle leaves, is_queued() flips false. +env4.set_searching(false) +env4.call_start_run() +check(env4.start_run_calls() == 1, 'leave-then-allow: start_run proceeds once no longer searching') + +------------------------ +-- Test 5 (fixed): "Leave Queue & Play" -- blocked action is stashed and can be +-- replayed through the gate once no longer searching +------------------------ + +print() +print('-- scenario: fixed run_guard.lua, leave queue and play (stash + replay) --') +local env5 = make_env() +load_module(fixed_src, env5) +env5.set_searching(true) +local marker_args = { tag = 'replay-me' } +env5.G.FUNCS.start_run(nil, marker_args) +local mm5 = env5.MPAPI._internal.mm +check(env5.start_run_calls() == 0, 'leave-and-play: blocked while searching') +check(mm5.pending_run ~= nil and mm5.pending_run.args == marker_args, 'leave-and-play: blocked action stashed with its original args') + +-- Simulate the overlay button: leave queue (is_queued flips false), replay. +env5.set_searching(false) +local pending = mm5.pending_run +mm5.pending_run = nil +env5.G.FUNCS.start_run(pending.e, pending.args) +check(env5.start_run_calls() == 1, 'leave-and-play: replayed action proceeds after leaving') + +-- Replay must re-check the gate: if still searching, it re-blocks. +local env5b = make_env() +load_module(fixed_src, env5b) +env5b.set_searching(true) +env5b.call_start_run() +local mm5b = env5b.MPAPI._internal.mm +local pending_b = mm5b.pending_run +env5b.G.FUNCS.start_run(pending_b.e, pending_b.args) -- still searching +check(env5b.start_run_calls() == 0, 'leave-and-play: replay while STILL searching re-blocks (no run starts queued)') + +------------------------ +-- Test 6: handle:leave() fires the "left" event exactly once +------------------------ + +print() +print('-- scenario: matchmaking handle fires left on leave() --') +local HANDLE_SRC = read_file(this_dir .. '../api/matchmaking/handle.lua') +local handle_MPAPI = { + matchmaking = {}, + _internal = { mm = { remove_handle = function() end } }, + get_connection = function() return nil end, + sendWarnMessage = function() end, +} +local handle_chunk = assert(loadstring(HANDLE_SRC, 'handle')) +setfenv(handle_chunk, setmetatable({ MPAPI = handle_MPAPI }, { __index = _G })) +handle_chunk() + +local h = handle_MPAPI.matchmaking._make_handle('TestMod', 'test_mode') +local left_fired = 0 +h:on('left', function() left_fired = left_fired + 1 end) +h:leave() +check(left_fired == 1, 'handle: left event fired on leave()') +h:leave() +check(left_fired == 1, 'handle: left event NOT re-fired on a duplicate leave()') + +------------------------ +-- Test 7 (RED control): pre-fix source has no gate -- run starts while searching +------------------------ + +print() +print('-- scenario: pre-fix run_guard.lua (control, proves the test is meaningful) --') +local broken_env = make_env() +load_module(broken_src, broken_env) +broken_env.set_searching(true) +broken_env.call_start_run() +check(broken_env.start_run_calls() == 1, 'broken: (control) start_run proceeds even while searching -- reproduces the bug') +check(broken_env.overlay_shown_count() == 0, 'broken: (control) no guard overlay ever shown') + +------------------------ + +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/localization/en-us.lua b/localization/en-us.lua index b520c5a..7333f66 100644 --- a/localization/en-us.lua +++ b/localization/en-us.lua @@ -66,6 +66,12 @@ return { k_chat_birth_privacy_1 = 'We do not store your date of birth anywhere.', k_chat_birth_privacy_2 = 'It is collected solely to enable chat on your account, as required by child safety regulations in many countries.', b_chat_submit_age = 'Confirm', + -- Queue guard overlay (blocks starting a run while queued) + k_queue_guard_title = 'Matchmaking In Progress', + k_queue_guard_desc = "You can't start a run while searching for a match.", + b_queue_guard_leave_play = 'Leave Queue & Play', + b_queue_guard_leave = 'Leave Queue', + b_queue_guard_stay = 'Stay Queued', -- In-game chat feedback k_chat_age_blocked = '[MultiplayerAPI] Chat is not available for your account.', k_chat_client_disabled = '[MultiplayerAPI] Chat is disabled. Re-enable it in the account overlay.', diff --git a/ui/queue_guard_overlay.lua b/ui/queue_guard_overlay.lua new file mode 100644 index 0000000..b08987b --- /dev/null +++ b/ui/queue_guard_overlay.lua @@ -0,0 +1,121 @@ +-- Overlay shown when the player tries to start any run (New Run, Continue, +-- Challenge, restart) while a matchmaking search is active. The gate itself +-- lives in api/matchmaking/run_guard.lua, which opens this in place of letting +-- G.FUNCS.start_run proceed. +-- +-- Three ways out, all always available (never soft-locks, even if the search +-- ends while this is open -- no dismiss branch reads search state): +-- Leave Queue & Play -- leaves every active handle, then replays the blocked +-- run-start (re-checked by the gate on the way through). +-- Leave Queue -- leaves every active handle, then dismisses back to +-- the menu. +-- Stay Queued -- the overlay's own back button (and Esc); dismisses. +local create_UIBox_queue_guard_overlay = function() + local contents = { + { + n = G.UIT.C, + config = { align = 'cm', minw = 6, padding = 0.25, r = 0.1, colour = G.C.CLEAR }, + nodes = { + { + n = G.UIT.R, + config = { align = 'cm', padding = 0.1 }, + nodes = { + { n = G.UIT.T, config = { text = localize('k_queue_guard_title'), scale = 0.5, colour = G.C.UI.TEXT_LIGHT, shadow = true } }, + }, + }, + { + n = G.UIT.R, + config = { align = 'cm', padding = 0.06 }, + nodes = { + { n = G.UIT.T, config = { text = localize('k_queue_guard_desc'), scale = 0.3, colour = G.C.UI.TEXT_LIGHT } }, + }, + }, + { n = G.UIT.R, config = { minh = 0.15 } }, + { + n = G.UIT.R, + config = { align = 'cm', padding = 0.05 }, + nodes = { + { + n = G.UIT.C, + config = { + align = 'cm', padding = 0.1, minw = 4, minh = 0.7, + r = 0.1, hover = true, colour = G.C.BLUE, shadow = true, + button = 'mpapi_queue_guard_leave_play', + }, + nodes = { + { n = G.UIT.T, config = { text = localize('b_queue_guard_leave_play'), scale = 0.38, colour = G.C.UI.TEXT_LIGHT, shadow = true } }, + }, + }, + }, + }, + { + n = G.UIT.R, + config = { align = 'cm', padding = 0.05 }, + nodes = { + { + n = G.UIT.C, + config = { + align = 'cm', padding = 0.1, minw = 4, minh = 0.7, + r = 0.1, hover = true, colour = G.C.RED, shadow = true, + button = 'mpapi_queue_guard_leave', + }, + nodes = { + { n = G.UIT.T, config = { text = localize('b_queue_guard_leave'), scale = 0.38, colour = G.C.UI.TEXT_LIGHT, shadow = true } }, + }, + }, + }, + }, + }, + }, + } + + return create_UIBox_generic_options({ + snap_back = true, + back_colour = G.C.GREEN, + back_label = localize('b_queue_guard_stay'), + contents = contents, + }) +end + +----------------------------- +-- LOGIC FUNCTIONS +----------------------------- + +-- Leave every active matchmaking handle (the same per-handle path a +-- consumer mod's own Cancel-Search button uses: handle:leave() marks it left, +-- removes it from mm.handles, fires its 'left' event, and tells the server). +-- Copy the list first -- handle:leave() mutates mm.handles in place via +-- mm.remove_handle, which would break live iteration. +local function leave_all_handles() + local mm = MPAPI._internal.mm + local handles = MPAPI.shallow_copy(mm.handles or {}) + for _, h in ipairs(handles) do + h:leave() + end +end + +G.FUNCS.mpapi_queue_guard_leave = function(e) + leave_all_handles() + G.FUNCS.exit_overlay_menu() +end + +-- Leave the queue and immediately replay the run-start that was blocked. +-- The replay goes through the wrapped G.FUNCS.start_run, so the gate is +-- re-checked -- if anything is still searching it re-blocks rather than +-- starting a run while queued. +G.FUNCS.mpapi_queue_guard_leave_play = function(e) + leave_all_handles() + G.FUNCS.exit_overlay_menu() + local mm = MPAPI._internal.mm + local pending = mm.pending_run + mm.pending_run = nil + if pending then + G.FUNCS.start_run(pending.e, pending.args) + end +end + +----------------------------- +-- GLOBAL UI ELEMENT +----------------------------- + +MPAPI.queue_guard_overlay = MPAPI.ui_element(create_UIBox_queue_guard_overlay) From bb6f5c789e84ce45b4640e9accd8793ec0edfdbd Mon Sep 17 00:00:00 2001 From: ChronoFinale Date: Mon, 13 Jul 2026 10:28:35 -0500 Subject: [PATCH 2/3] fix(matchmaking): fire the handle's left event from leave() handle:leave() marked the handle left and told the server, but never notified the handle's own 'left' subscribers -- so any leave initiated outside a consumer mod's own cancel path (e.g. the queue-guard overlay) left the mod's search UI stale, still showing Cancel Search. Fire the event right after the local state update, before the server round-trip, so UI resets even if the network call fails. --- api/matchmaking/handle.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/api/matchmaking/handle.lua b/api/matchmaking/handle.lua index aebeca0..cd7426f 100644 --- a/api/matchmaking/handle.lua +++ b/api/matchmaking/handle.lua @@ -40,6 +40,10 @@ MPAPI.matchmaking._make_handle = function(mod_id, game_mode) end self._left = true MPAPI._internal.mm.remove_handle(self) + -- Notify subscribers no matter which code path initiated the leave (a + -- consumer mod's own cancel button, the queue-guard overlay, ...), and + -- before the server round-trip so UI state resets even if it fails. + self:_fire('left') local conn = MPAPI.get_connection() if not conn then From 794790cefee877ea67428d2af55cd36884595cfe Mon Sep 17 00:00:00 2001 From: ChronoFinale Date: Mon, 13 Jul 2026 11:56:50 -0500 Subject: [PATCH 3/3] refactor(matchmaking): extract reusable guard_queued helper Generalize the run-start queue gate into a shared mechanism consumer mods can reuse for their own queue-conflicting actions (e.g. lobby buttons). - Rename run_guard.lua -> queue_guard.lua and add MPAPI.matchmaking.guard_queued(replay): if searching, stash the blocked call as a replay closure, show the leave-or-stay overlay, and return true so the caller aborts; else return false. The start_run wrap now goes through it. guard_queued documents that `replay` must re-enter the caller's OWN complete entry point (a consumer guarding a lobby button replays its own join/create function, not MPAPI.join_lobby -- replaying the API primitive would join server-side but skip the consumer's post-join setup and strand the player outside the lobby). - Generalize the overlay's stashed action from a start_run-specific { e, args } (pending_run) to a replay closure (pending_action), so the one overlay serves run-start and any consumer action. Button copy "Leave Queue & Play" -> "Leave Queue & Continue"; description mentions lobbies since consumers surface the same overlay for them. dev/test_queue_guard.lua covers the guard_queued contract (blocks/stashes/ shows overlay when searching, no-ops when not) and the run-start application (block/allow, stash-and-replay incl. re-block-while-searching), plus a pre-fix control that strips the gate to prove it reproduces the bug. --- api/matchmaking/queue_guard.lua | 51 +++++++ api/matchmaking/run_guard.lua | 31 ----- dev/test_queue_guard.lua | 238 +++++++++++++++++--------------- localization/en-us.lua | 4 +- ui/queue_guard_overlay.lua | 29 ++-- 5 files changed, 195 insertions(+), 158 deletions(-) create mode 100644 api/matchmaking/queue_guard.lua delete mode 100644 api/matchmaking/run_guard.lua diff --git a/api/matchmaking/queue_guard.lua b/api/matchmaking/queue_guard.lua new file mode 100644 index 0000000..4b8a38d --- /dev/null +++ b/api/matchmaking/queue_guard.lua @@ -0,0 +1,51 @@ +-- Blocks starting a singleplayer run while a matchmaking search is active, and +-- provides the shared queue-guard mechanism consumer mods reuse for their own +-- queue-conflicting actions (e.g. creating/joining a lobby). Bug: nothing +-- stopped a queued player from clicking Play -> New Run (or Continue / a +-- Challenge) -- the run tore down the main menu while the search stayed active +-- server-side with no feedback. Maintainer verdict: not possible while queued. +MPAPI.matchmaking = MPAPI.matchmaking or {} +MPAPI._internal.mm = MPAPI._internal.mm or {} +local mm = MPAPI._internal.mm + +-- Shared gate for any queue-conflicting entry point. If the local player is +-- searching, stash the blocked call as a replay closure and show the leave-or- +-- stay overlay instead of running it; returns true so the caller aborts. +-- Otherwise returns false and the caller proceeds normally. The overlay's +-- "Leave Queue & Continue" leaves every handle and invokes the stashed closure. +-- +-- IMPORTANT: `replay` must re-enter the caller's OWN complete entry point, not a +-- lower-level primitive. The start_run wrap below replays the wrapped +-- G.FUNCS.start_run (a complete flow). A consumer guarding its lobby buttons +-- must replay its own MP.pvp_join_lobby / create function -- NOT MPAPI.join_lobby +-- directly, which would join server-side but skip the consumer's post-join setup +-- (lobby mirror + UI transition), stranding the player outside the lobby. Since +-- the replay re-enters a guarded entry point, is_queued() is false by then so it +-- proceeds; if the leave somehow didn't take, it re-blocks. +function MPAPI.matchmaking.guard_queued(replay) + if not MPAPI.matchmaking.is_queued() then + return false + end + mm.pending_action = replay + G.SETTINGS.paused = true + MPAPI.queue_guard_overlay:as_overlay() + return true +end + +-- G.FUNCS.start_run is the single vanilla chokepoint every "enter a run" +-- flow funnels through -- New Run and Continue (via start_setup_run), Challenges +-- (via start_challenge_run), the first-launch tutorial run, and the in-run +-- "Start New Run" restart button all call it directly. It is also the function +-- that actually tears down the menu (G.E_MANAGER:clear_queue(), wipe_on/off) +-- and calls Game:start_run -- gating here, before any of that runs, catches +-- every one of those entry points with a single wrap and leaves the menu +-- completely untouched when blocked. +local _start_run_ref = G.FUNCS.start_run +G.FUNCS.start_run = function(e, args) + -- The replay closure re-enters the wrapper (not _start_run_ref) so the gate + -- re-checks on the way through, matching the lobby entry points. + if MPAPI.matchmaking.guard_queued(function() return G.FUNCS.start_run(e, args) end) then + return + end + return _start_run_ref(e, args) +end diff --git a/api/matchmaking/run_guard.lua b/api/matchmaking/run_guard.lua deleted file mode 100644 index ee34950..0000000 --- a/api/matchmaking/run_guard.lua +++ /dev/null @@ -1,31 +0,0 @@ --- Blocks vanilla singleplayer run-start while a matchmaking search is active. --- Bug: nothing stopped a queued player from clicking Play -> New Run (or --- Continue / a Challenge); the run would tear down the main menu while the --- queue stayed active server-side with no feedback. Maintainer verdict: you --- should not be able to start a run at all while queued. -MPAPI.matchmaking = MPAPI.matchmaking or {} -MPAPI._internal.mm = MPAPI._internal.mm or {} -local mm = MPAPI._internal.mm - --- G.FUNCS.start_run is the single vanilla chokepoint every "enter a run" --- flow funnels through -- New Run and Continue (via start_setup_run), Challenges --- (via start_challenge_run), the first-launch tutorial run, and the in-run --- "Start New Run" restart button all call it directly. It is also the function --- that actually tears down the menu (G.E_MANAGER:clear_queue(), wipe_on/off) --- and calls Game:start_run -- gating here, before any of that runs, catches --- every one of those entry points with a single wrap and leaves the menu --- completely untouched when blocked. -local _start_run_ref = G.FUNCS.start_run -G.FUNCS.start_run = function(e, args) - if MPAPI.matchmaking.is_queued() then - -- Stash the blocked action so the overlay's "Leave Queue & Play" can - -- replay it after leaving. Replay goes back through this wrapper, so the - -- gate is re-checked -- if the leave somehow didn't take, it re-blocks - -- instead of starting a run while queued. - mm.pending_run = { e = e, args = args } - G.SETTINGS.paused = true - MPAPI.queue_guard_overlay:as_overlay() - return - end - return _start_run_ref(e, args) -end diff --git a/dev/test_queue_guard.lua b/dev/test_queue_guard.lua index 037b2b5..77c8041 100644 --- a/dev/test_queue_guard.lua +++ b/dev/test_queue_guard.lua @@ -1,13 +1,18 @@ --- Standalone regression test for api/matchmaking/run_guard.lua's queue-blocks- --- run-start gate. Not wired into a test framework (the repo has none) -- this --- is a self-contained LuaJIT script that stubs just enough of the Balatro/ --- MPAPI surface (G.FUNCS, G.SETTINGS, MPAPI.matchmaking.is_queued, --- MPAPI.queue_guard_overlay) to load and drive the real module source. +-- Standalone regression test for the matchmaking queue guard +-- (api/matchmaking/queue_guard.lua): the shared guard_queued() gate and its +-- application to singleplayer run-start. Not wired into a test framework (the +-- repo has none) -- this is a self-contained LuaJIT script that stubs just +-- enough of the Balatro/MPAPI surface to load and drive the real module source. +-- +-- Consumer mods reuse guard_queued() for their own queue-conflicting actions +-- (e.g. lobby create/join); those live in the consumer repos and are tested +-- there -- each must replay its OWN complete entry point (see guard_queued's +-- note), which is why the gate is not applied to MPAPI.join_lobby here. -- -- Run: luajit dev/test_queue_guard.lua local this_dir = debug.getinfo(1, 'S').source:match('@?(.*[/\\])') or './' -local SRC_PATH = this_dir .. '../api/matchmaking/run_guard.lua' +local GUARD_PATH = this_dir .. '../api/matchmaking/queue_guard.lua' local function read_file(path) local f = assert(io.open(path, 'r')) @@ -16,32 +21,47 @@ local function read_file(path) return content end -local fixed_src = read_file(SRC_PATH) - --- The "broken" control variant reproduces the pre-fix behaviour (no gate at --- all -- G.FUNCS.start_run falls straight through to the original), so the --- regression test can prove it would actually have failed before the fix. -local GATE_BLOCK = [[ - if MPAPI.matchmaking.is_queued() then - -- Stash the blocked action so the overlay's "Leave Queue & Play" can - -- replay it after leaving. Replay goes back through this wrapper, so the - -- gate is re-checked -- if the leave somehow didn't take, it re-blocks - -- instead of starting a run while queued. - mm.pending_run = { e = e, args = args } - G.SETTINGS.paused = true - MPAPI.queue_guard_overlay:as_overlay() +local function strip_exact(src, needle) + local s, e = src:find(needle, 1, true) -- plain (non-pattern) find + assert(s, 'failed to construct pre-fix control variant (block not found verbatim)') + return src:sub(1, s - 1) .. src:sub(e + 1) +end + +local guard_src = read_file(GUARD_PATH) + +-- The "broken" control variant of the run gate reproduces the pre-fix behaviour +-- (no gate at all -- G.FUNCS.start_run falls straight through to the original), +-- so the regression test can prove it would actually have failed before the fix. +local RUN_GATE_BLOCK = [[ + -- The replay closure re-enters the wrapper (not _start_run_ref) so the gate + -- re-checks on the way through, matching the lobby entry points. + if MPAPI.matchmaking.guard_queued(function() return G.FUNCS.start_run(e, args) end) then return end ]] -local function strip_exact(src, needle) - local s, e = src:find(needle, 1, true) -- plain (non-pattern) find - assert(s, 'failed to construct pre-fix control variant (gate block not found verbatim)') - return src:sub(1, s - 1) .. src:sub(e + 1) +local broken_guard_src = strip_exact(guard_src, RUN_GATE_BLOCK) +assert(broken_guard_src ~= guard_src, 'run-gate control variant did not change source') + +------------------------ +-- Test harness +------------------------ + +local failures = 0 +local function check(cond, msg) + if cond then + print('PASS: ' .. msg) + else + failures = failures + 1 + print('FAIL: ' .. msg) + end end -local broken_src = strip_exact(fixed_src, GATE_BLOCK) -assert(broken_src ~= fixed_src, 'control variant did not change source') +local function load_chunk(src, name, env) + local chunk = assert(loadstring(src, name)) + setfenv(chunk, setmetatable(env, { __index = _G })) + chunk() +end ------------------------------------------------------------------- -- Fake environment: G.FUNCS, G.SETTINGS, MPAPI @@ -49,6 +69,7 @@ assert(broken_src ~= fixed_src, 'control variant did not change source') local function make_env() local original_start_run_calls = 0 + local last_start_run_args = nil local overlay_shown_count = 0 local _searching = false @@ -57,6 +78,7 @@ local function make_env() -- Stand-in for the vanilla G.FUNCS.start_run this module wraps. start_run = function(_e, _args) original_start_run_calls = original_start_run_calls + 1 + last_start_run_args = _args end, }, SETTINGS = { paused = false }, @@ -77,115 +99,122 @@ local function make_env() MPAPI = MPAPI, set_searching = function(v) _searching = v end, start_run_calls = function() return original_start_run_calls end, + last_start_run_args = function() return last_start_run_args end, overlay_shown_count = function() return overlay_shown_count end, call_start_run = function() G.FUNCS.start_run(nil, {}) end, } end -local function load_module(src, env) - local chunk_env = setmetatable({ G = env.G, MPAPI = env.MPAPI }, { __index = _G }) - local chunk = assert(loadstring(src, 'run_guard')) - setfenv(chunk, chunk_env) - chunk() +local function load_guard(src, env) + load_chunk(src, 'queue_guard', { G = env.G, MPAPI = env.MPAPI }) end ------------------------ --- Test harness +-- guard_queued: the shared gate contract used by run-start and consumers ------------------------ -local failures = 0 -local function check(cond, msg) - if cond then - print('PASS: ' .. msg) - else - failures = failures + 1 - print('FAIL: ' .. msg) - end -end - ------------------------- --- Test 1 (fixed): not searching -> start_run proceeds untouched (zero overhead) ------------------------- +print() +print('-- guard_queued: not searching -> proceeds, no side effects --') +local envg = make_env() +load_guard(guard_src, envg) +envg.set_searching(false) +local replays = 0 +local blocked = envg.MPAPI.matchmaking.guard_queued(function() replays = replays + 1 end) +check(blocked == false, 'guard_queued returns false when not searching (caller proceeds)') +check(envg.overlay_shown_count() == 0, 'guard_queued shows no overlay when not searching') +check(envg.MPAPI._internal.mm.pending_action == nil, 'guard_queued stashes nothing when not searching') print() -print('-- scenario: fixed run_guard.lua, not searching --') -local env2 = make_env() -load_module(fixed_src, env2) -env2.set_searching(false) -env2.call_start_run() -check(env2.start_run_calls() == 1, 'fixed: original start_run called when not searching') -check(env2.overlay_shown_count() == 0, 'fixed: guard overlay not shown when not searching') +print('-- guard_queued: searching -> blocks, stashes the replay, shows overlay --') +local envg2 = make_env() +load_guard(guard_src, envg2) +envg2.set_searching(true) +local sentinel = function() end +local blocked2 = envg2.MPAPI.matchmaking.guard_queued(sentinel) +check(blocked2 == true, 'guard_queued returns true when searching (caller aborts)') +check(envg2.overlay_shown_count() == 1, 'guard_queued shows the overlay when searching') +check(envg2.MPAPI._internal.mm.pending_action == sentinel, 'guard_queued stashes the caller-supplied replay closure verbatim') +check(envg2.G.SETTINGS.paused == true, 'guard_queued pauses while the overlay is up') ------------------------ --- Test 3 (fixed): searching -> start_run blocked, overlay shown instead +-- Run gate: not searching -> start_run proceeds untouched (zero overhead) ------------------------ print() -print('-- scenario: fixed run_guard.lua, searching --') -local env3 = make_env() -load_module(fixed_src, env3) -env3.set_searching(true) -env3.call_start_run() -check(env3.start_run_calls() == 0, 'fixed: original start_run NOT called while searching') -check(env3.overlay_shown_count() == 1, 'fixed: guard overlay shown while searching') +print('-- run gate: fixed, not searching --') +local env1 = make_env() +load_guard(guard_src, env1) +env1.set_searching(false) +env1.call_start_run() +check(env1.start_run_calls() == 1, 'fixed: original start_run called when not searching') +check(env1.overlay_shown_count() == 0, 'fixed: guard overlay not shown when not searching') ------------------------ --- Test 4 (fixed): leave-queue-then-allow sequence +-- Run gate: searching -> start_run blocked, overlay shown instead ------------------------ print() -print('-- scenario: fixed run_guard.lua, leave queue then retry --') -local env4 = make_env() -load_module(fixed_src, env4) -env4.set_searching(true) -env4.call_start_run() -check(env4.start_run_calls() == 0, 'leave-then-allow: blocked while still searching') -check(env4.overlay_shown_count() == 1, 'leave-then-allow: overlay shown while still searching') - --- Simulate "Leave Queue": the handle leaves, is_queued() flips false. -env4.set_searching(false) -env4.call_start_run() -check(env4.start_run_calls() == 1, 'leave-then-allow: start_run proceeds once no longer searching') +print('-- run gate: fixed, searching --') +local env2 = make_env() +load_guard(guard_src, env2) +env2.set_searching(true) +env2.call_start_run() +check(env2.start_run_calls() == 0, 'fixed: original start_run NOT called while searching') +check(env2.overlay_shown_count() == 1, 'fixed: guard overlay shown while searching') ------------------------ --- Test 5 (fixed): "Leave Queue & Play" -- blocked action is stashed and can be --- replayed through the gate once no longer searching +-- Run gate: "Leave Queue & Continue" -- blocked action stashed as a replay +-- closure, replaying it after leaving proceeds through the (now-open) gate ------------------------ print() -print('-- scenario: fixed run_guard.lua, leave queue and play (stash + replay) --') -local env5 = make_env() -load_module(fixed_src, env5) -env5.set_searching(true) +print('-- run gate: leave queue and continue (stash + replay) --') +local env3 = make_env() +load_guard(guard_src, env3) +env3.set_searching(true) local marker_args = { tag = 'replay-me' } -env5.G.FUNCS.start_run(nil, marker_args) -local mm5 = env5.MPAPI._internal.mm -check(env5.start_run_calls() == 0, 'leave-and-play: blocked while searching') -check(mm5.pending_run ~= nil and mm5.pending_run.args == marker_args, 'leave-and-play: blocked action stashed with its original args') +env3.G.FUNCS.start_run(nil, marker_args) +local mm3 = env3.MPAPI._internal.mm +check(env3.start_run_calls() == 0, 'leave-and-continue: blocked while searching') +check(type(mm3.pending_action) == 'function', 'leave-and-continue: blocked action stashed as a replay closure') -- Simulate the overlay button: leave queue (is_queued flips false), replay. -env5.set_searching(false) -local pending = mm5.pending_run -mm5.pending_run = nil -env5.G.FUNCS.start_run(pending.e, pending.args) -check(env5.start_run_calls() == 1, 'leave-and-play: replayed action proceeds after leaving') +env3.set_searching(false) +local action3 = mm3.pending_action +mm3.pending_action = nil +action3() +check(env3.start_run_calls() == 1, 'leave-and-continue: replayed action proceeds after leaving') +check(env3.last_start_run_args() == marker_args, 'leave-and-continue: replay carried the original args') -- Replay must re-check the gate: if still searching, it re-blocks. -local env5b = make_env() -load_module(fixed_src, env5b) -env5b.set_searching(true) -env5b.call_start_run() -local mm5b = env5b.MPAPI._internal.mm -local pending_b = mm5b.pending_run -env5b.G.FUNCS.start_run(pending_b.e, pending_b.args) -- still searching -check(env5b.start_run_calls() == 0, 'leave-and-play: replay while STILL searching re-blocks (no run starts queued)') +local env3b = make_env() +load_guard(guard_src, env3b) +env3b.set_searching(true) +env3b.call_start_run() +local mm3b = env3b.MPAPI._internal.mm +local action3b = mm3b.pending_action +action3b() -- still searching +check(env3b.start_run_calls() == 0, 'leave-and-continue: replay while STILL searching re-blocks (no run starts)') + +------------------------ +-- Run gate RED control: pre-fix source has no gate -- run starts while searching +------------------------ + +print() +print('-- run gate: pre-fix control (proves the test is meaningful) --') +local env_broken = make_env() +load_guard(broken_guard_src, env_broken) +env_broken.set_searching(true) +env_broken.call_start_run() +check(env_broken.start_run_calls() == 1, 'broken: (control) start_run proceeds even while searching -- reproduces the bug') +check(env_broken.overlay_shown_count() == 0, 'broken: (control) no guard overlay ever shown') ------------------------ --- Test 6: handle:leave() fires the "left" event exactly once +-- handle:leave() fires the "left" event exactly once ------------------------ print() -print('-- scenario: matchmaking handle fires left on leave() --') +print('-- matchmaking handle fires left on leave() --') local HANDLE_SRC = read_file(this_dir .. '../api/matchmaking/handle.lua') local handle_MPAPI = { matchmaking = {}, @@ -205,19 +234,6 @@ check(left_fired == 1, 'handle: left event fired on leave()') h:leave() check(left_fired == 1, 'handle: left event NOT re-fired on a duplicate leave()') ------------------------- --- Test 7 (RED control): pre-fix source has no gate -- run starts while searching ------------------------- - -print() -print('-- scenario: pre-fix run_guard.lua (control, proves the test is meaningful) --') -local broken_env = make_env() -load_module(broken_src, broken_env) -broken_env.set_searching(true) -broken_env.call_start_run() -check(broken_env.start_run_calls() == 1, 'broken: (control) start_run proceeds even while searching -- reproduces the bug') -check(broken_env.overlay_shown_count() == 0, 'broken: (control) no guard overlay ever shown') - ------------------------ print() diff --git a/localization/en-us.lua b/localization/en-us.lua index 7333f66..7f4eca2 100644 --- a/localization/en-us.lua +++ b/localization/en-us.lua @@ -68,8 +68,8 @@ return { b_chat_submit_age = 'Confirm', -- Queue guard overlay (blocks starting a run while queued) k_queue_guard_title = 'Matchmaking In Progress', - k_queue_guard_desc = "You can't start a run while searching for a match.", - b_queue_guard_leave_play = 'Leave Queue & Play', + k_queue_guard_desc = "You can't start a run or join a lobby while searching for a match.", + b_queue_guard_leave_play = 'Leave Queue & Continue', b_queue_guard_leave = 'Leave Queue', b_queue_guard_stay = 'Stay Queued', -- In-game chat feedback diff --git a/ui/queue_guard_overlay.lua b/ui/queue_guard_overlay.lua index b08987b..fe064f0 100644 --- a/ui/queue_guard_overlay.lua +++ b/ui/queue_guard_overlay.lua @@ -1,12 +1,13 @@ --- Overlay shown when the player tries to start any run (New Run, Continue, --- Challenge, restart) while a matchmaking search is active. The gate itself --- lives in api/matchmaking/run_guard.lua, which opens this in place of letting --- G.FUNCS.start_run proceed. +-- Overlay shown when the player tries to do something that conflicts with an +-- active matchmaking search -- start a run (New Run, Continue, Challenge, +-- restart) or create/join a lobby. The gate lives in +-- api/matchmaking/queue_guard.lua (guard_queued), which opens this in place of +-- letting the blocked action proceed. -- -- Three ways out, all always available (never soft-locks, even if the search -- ends while this is open -- no dismiss branch reads search state): --- Leave Queue & Play -- leaves every active handle, then replays the blocked --- run-start (re-checked by the gate on the way through). +-- Leave Queue & Continue -- leaves every active handle, then replays the +-- blocked action (re-checked by the gate on the way through). -- Leave Queue -- leaves every active handle, then dismisses back to -- the menu. -- Stay Queued -- the overlay's own back button (and Esc); dismisses. @@ -99,18 +100,18 @@ G.FUNCS.mpapi_queue_guard_leave = function(e) G.FUNCS.exit_overlay_menu() end --- Leave the queue and immediately replay the run-start that was blocked. --- The replay goes through the wrapped G.FUNCS.start_run, so the gate is --- re-checked -- if anything is still searching it re-blocks rather than --- starting a run while queued. +-- Leave the queue and immediately replay whatever was blocked (a run-start, a +-- lobby create, or a lobby join -- stashed as a closure by guard_queued). The +-- replay re-enters the same guarded entry point, so the gate is re-checked -- +-- if anything is still searching it re-blocks rather than proceeding. G.FUNCS.mpapi_queue_guard_leave_play = function(e) leave_all_handles() G.FUNCS.exit_overlay_menu() local mm = MPAPI._internal.mm - local pending = mm.pending_run - mm.pending_run = nil - if pending then - G.FUNCS.start_run(pending.e, pending.args) + local action = mm.pending_action + mm.pending_action = nil + if action then + action() end end