Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions api/matchmaking/handle.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions api/matchmaking/queue_guard.lua
Original file line number Diff line number Diff line change
@@ -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
246 changes: 246 additions & 0 deletions dev/test_queue_guard.lua
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions localization/en-us.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
Loading