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 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/dev/test_queue_guard.lua b/dev/test_queue_guard.lua new file mode 100644 index 0000000..77c8041 --- /dev/null +++ b/dev/test_queue_guard.lua @@ -0,0 +1,246 @@ +-- 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 GUARD_PATH = this_dir .. '../api/matchmaking/queue_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 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 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 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 +------------------------------------------------------------------- + +local function make_env() + local original_start_run_calls = 0 + local last_start_run_args = nil + 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 + last_start_run_args = _args + 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, + 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_guard(src, env) + load_chunk(src, 'queue_guard', { G = env.G, MPAPI = env.MPAPI }) +end + +------------------------ +-- guard_queued: the shared gate contract used by run-start and consumers +------------------------ + +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('-- 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') + +------------------------ +-- Run gate: not searching -> start_run proceeds untouched (zero overhead) +------------------------ + +print() +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') + +------------------------ +-- Run gate: searching -> start_run blocked, overlay shown instead +------------------------ + +print() +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') + +------------------------ +-- Run gate: "Leave Queue & Continue" -- blocked action stashed as a replay +-- closure, replaying it after leaving proceeds through the (now-open) gate +------------------------ + +print() +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' } +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. +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 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') + +------------------------ +-- handle:leave() fires the "left" event exactly once +------------------------ + +print() +print('-- 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()') + +------------------------ + +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..7f4eca2 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 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 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..fe064f0 --- /dev/null +++ b/ui/queue_guard_overlay.lua @@ -0,0 +1,122 @@ +-- 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 & 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. +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 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 action = mm.pending_action + mm.pending_action = nil + if action then + action() + end +end + +----------------------------- +-- GLOBAL UI ELEMENT +----------------------------- + +MPAPI.queue_guard_overlay = MPAPI.ui_element(create_UIBox_queue_guard_overlay)