From 48ba82706a67adc6381aa20f9eec9dca3354c8ce Mon Sep 17 00:00:00 2001 From: ChronoFinale Date: Sun, 12 Jul 2026 19:19:39 -0500 Subject: [PATCH] fix(pvp): play opponent skip SFX at skip time, not first hand MPAPI.calculate_blind (framework api/gamemode/hooks.lua) resolves the blind to dispatch display-sync context through by reading G.GAME.blind (whichever blind is CURRENTLY ACTIVE), but at the moment a skip happens the active blind is the vanilla small/big blind being skipped -- bl_mp_nemesis only becomes active once the boss blind starts. That resolution silently drops the skip's display-sync dispatch (and its SFX) whenever the active blind isn't the nemesis, so enemy.skips stays stale until the opponent's first playHand of the boss blind, whose payload still carries the cumulative skip count and fires the SFX there instead -- one hand late. Dispatch the nemesis blind's display-sync calculate directly by its stable registered key (G.P_BLINDS["bl_mp_nemesis"]) from pvp_api/net.lua's playHand/skip routes, instead of through MPAPI.calculate_blind's active-blind lookup, so the sync always fires at the real skip moment regardless of which blind is active. This also makes a later playHand echo of the same cumulative skip count a no-op reconcile (the delta is already zero), so the SFX plays exactly once per skip. --- objects/blinds/nemesis.lua | 8 +- pvp_api/net.lua | 23 ++++- tests/test_skip_sync.lua | 198 +++++++++++++++++++++++++++++++++++++ 3 files changed, 222 insertions(+), 7 deletions(-) create mode 100644 tests/test_skip_sync.lua diff --git a/objects/blinds/nemesis.lua b/objects/blinds/nemesis.lua index 30033e70..45790e20 100644 --- a/objects/blinds/nemesis.lua +++ b/objects/blinds/nemesis.lua @@ -28,9 +28,11 @@ MPAPI.Blind({ return false end, - -- Decides whether/what to tell the opponent on a hand played or discard, dispatched via - -- MPAPI.calculate_blind from pvp_api/net.lua's playHand/skip routes -- the blind's own - -- decision now, not an external hardcoded sync call. + -- Decides whether/what to tell the opponent on a hand played or discard, dispatched by + -- key from pvp_api/net.lua's playHand/skip routes (sync_pvp_blind) rather than through + -- MPAPI.calculate_blind's "currently active blind" lookup -- a skip fires while the + -- vanilla small/big blind is still active, so that lookup would miss this blind's + -- calculate entirely at the moment a skip happens. The blind's own decision, either way. calculate = function(self, context) if context.hand_played or context.discarded then return { diff --git a/pvp_api/net.lua b/pvp_api/net.lua index 084818a3..5bf53474 100644 --- a/pvp_api/net.lua +++ b/pvp_api/net.lua @@ -43,6 +43,21 @@ local function gen_seed() return MP.generate_seed() end +-- Dispatch the hand_played/discarded display-sync context straight to the nemesis blind +-- by its stable registered key, instead of MPAPI.calculate_blind's "whatever blind is +-- currently active" resolution (G.GAME.blind.config.blind in the framework's +-- api/gamemode/hooks.lua). A skip fires while the ACTIVE blind is still the vanilla +-- small/big blind being skipped -- bl_mp_nemesis only becomes active once the boss +-- blind starts -- so gating this on the active blind silently drops the skip's display +-- sync (and its SFX) until the opponent's first hand of the boss blind, whose payload +-- still carries the cumulative skip count and fires the SFX there instead, one hand +-- late (see tests/test_skip_sync.lua). The nemesis blind is registered once at load and +-- is the only consumer of this context shape, so resolving it directly here is safe. +local function sync_pvp_blind(context) + local nemesis = G.P_BLINDS and G.P_BLINDS["bl_mp_nemesis"] + if nemesis and nemesis.calculate then nemesis:calculate(context) end +end + -- Map each legacy action to its peer broadcast. Anything not listed (lobby/auth/ -- version/replay-stream actions the API now owns) is silently dropped. local ROUTES = { @@ -75,8 +90,8 @@ local ROUTES = { end, playHand = function(msg) broadcast("pvp_play_hand", { score = msg.score, handsLeft = msg.handsLeft, skips = my_skips(), lives = my_lives() }) -- referee (host-authoritative) - -- Display sync is the active blind's own decision now (see objects/blinds/nemesis.lua). - MPAPI.calculate_blind({ hand_played = true, score = msg.score, hands_left = msg.handsLeft, skips = my_skips(), lives = my_lives() }) + -- Display sync via the nemesis blind's own calculate/send (see sync_pvp_blind above). + sync_pvp_blind({ hand_played = true, score = msg.score, hands_left = msg.handsLeft, skips = my_skips(), lives = my_lives() }) -- Score-bearing RLOG event (Phase 6): this is the only point in the -- codebase where "my own score after playing" is already computed for -- the legacy broadcast, so it doubles as the source for a server-side @@ -95,8 +110,8 @@ local ROUTES = { end, skip = function(msg) broadcast("pvp_skip", { skips = msg.skips, score = my_score_str(), handsLeft = my_hands(), lives = my_lives() }) -- referee (host-authoritative) - -- Display sync is the active blind's own decision now (see objects/blinds/nemesis.lua). - MPAPI.calculate_blind({ discarded = true, skips = msg.skips, score = my_score_str(), hands_left = my_hands(), lives = my_lives() }) + -- Display sync via the nemesis blind's own calculate/send (see sync_pvp_blind above). + sync_pvp_blind({ discarded = true, skips = msg.skips, score = my_score_str(), hands_left = my_hands(), lives = my_lives() }) -- See playHand's hand_result comment above -- same rationale, for discards. if MP.RLOG then MP.RLOG.record("hand_result", { my_score_str(), my_hands() }) end end, diff --git a/tests/test_skip_sync.lua b/tests/test_skip_sync.lua new file mode 100644 index 00000000..263dbe73 --- /dev/null +++ b/tests/test_skip_sync.lua @@ -0,0 +1,198 @@ +--[[ + Opponent-skip sync test (pvp_api/net.lua : sync_pvp_blind, objects/blinds/nemesis.lua). + + Bug: the opponent's "skip" SFX (negative + gong) fired on their FIRST HAND of + the PvP (nemesis) blind instead of at the moment they actually skipped. + + Root cause: the display-sync dispatch resolved the blind to sync through via + "whatever blind is CURRENTLY ACTIVE" (originally MP.sync_pvp_blind reading + G.GAME.blind.config.blind directly; after the MPAPI sync/receive rework, + MPAPI.calculate_blind's identical G.GAME.blind.config.blind lookup in the + framework's api/gamemode/hooks.lua reproduces the exact same bug). At the + moment a skip happens, the active blind is the vanilla small/big blind being + skipped -- bl_mp_nemesis only becomes active once the boss blind starts -- so + that lookup resolves to a blind with no display-sync calculate, and the sync + (and its SFX) is silently dropped. G.GAME.enemy.skips then stays stale until + the opponent's first playHand of the boss blind, whose payload still carries + the cumulative skip count; receive's stale delta (0 -> N) fires the SFX there + instead -- one hand late. + + Fix: pvp_api/net.lua's playHand/skip routes dispatch display sync straight to + the nemesis blind by its stable registered key (G.P_BLINDS["bl_mp_nemesis"]) + instead of through MPAPI.calculate_blind's active-blind resolution, so the + sync fires at the real skip moment regardless of which blind is active. This + also makes a LATER playHand echo of the same cumulative skips count a no-op + reconcile (the delta is already 0), so the SFX plays exactly once per skip. + + Run from the repo root: + luajit tests/test_skip_sync.lua +]] + +-- ─── Stub game/framework globals ─────────────────────────────────────────── + +local play_sound_calls = {} +function play_sound(name) + play_sound_calls[#play_sound_calls + 1] = name +end +function sendDebugMessage() end +function Event(t) + return t +end + +SMODS = { Atlas = function() end } + +G = { + C = { MULTIPLAYER = {} }, + P_BLINDS = {}, + E_MANAGER = { add_event = function() end }, -- score easing is cosmetic; not under test + GAME = {}, +} + +-- Minimal stand-in for MPAPI.Blind / the MPAPI sync mixin (api/synced/objects.lua + +-- api/synced/core.lua in the BalatroMultiplayerAPI framework): registers the object +-- under its full key and gives it a :calculate(context) method that wraps the +-- consumer's own calculate (captured here as _user_calculate, same as the real +-- mixin) and, if it returns a `send` table, delivers it to the object's own +-- `receive`. In production that delivery is a network broadcast whose self-echo is +-- suppressed, so `receive` only ever runs on the RECEIVING (non-sender) client; +-- calling it directly here is exactly what that receiving peer's client does. +MPAPI = { + Blind = function(def) + local key = "bl_mp_" .. def.key + local obj = { key = key, _user_calculate = def.calculate, receive = def.receive } + function obj:calculate(context) + local ret = self._user_calculate and self:_user_calculate(context) + if type(ret) == "table" and ret.send ~= nil and self.receive then + self:receive({ from = "opponent", data = ret.send }) + end + return ret + end + G.P_BLINDS[key] = obj + return obj + end, + get_current_lobby = function() + return nil -- no real lobby; the referee broadcast in net.lua's routes no-ops + end, + ActionTypes = {}, +} + +MP = { + GAME = { + enemy = { + skips = 0, + highest_score = { v = 0 }, + spent_in_shop = {}, + lives = 4, + hands = 4, + info_received = false, + }, + score = { v = 0 }, + lives = 4, + timer_started = false, + nemesis_timer_started = false, + timer_consumed = false, + }, + LOBBY = { config = { timer = false, timer_increment_seconds = 0 } }, + UI = { + restore_timer = function() end, + juice_up_pvp_hud = function() end, + }, + INSANE_INT = { + empty = function() + return { v = 0 } + end, + from_string = function(s) + return { v = tonumber(s) or 0 } + end, + to_string = function(v) + return tostring(v.v) + end, + greater_than = function(a, b) + return a.v > b.v + end, + equal = function(a, b) + return a.v == b.v + end, + }, + is_any_layer_active = function() + return false + end, + is_layer_active = function() + return false + end, + -- Target-candidate gating (pvp_api/lobby_bridge.lua) is orthogonal to the skip-sync + -- fix under test; no-op it so `receive` always accepts the stubbed sender. + note_target_candidate = function() end, + current_target_id = function() + return nil + end, +} + +dofile("objects/blinds/nemesis.lua") +assert(G.P_BLINDS["bl_mp_nemesis"], "nemesis blind not registered under 'bl_mp_nemesis'") + +dofile("pvp_api/net.lua") +assert(MP.net_route, "MP.net_route not defined after load") + +local failures = 0 +local function check(name, cond) + if cond then + print("ok - " .. name) + else + failures = failures + 1 + print("FAIL - " .. name) + end +end + +local function count_sound(name) + local n = 0 + for _, s in ipairs(play_sound_calls) do + if s == name then n = n + 1 end + end + return n +end + +-- ─── Scenario: opponent skips a VANILLA small/big blind (not the nemesis) ─── +-- This is the exact bug condition: at skip time the currently active blind is +-- NOT bl_mp_nemesis (it only becomes active once the boss blind starts). +G.GAME.blind = { config = { blind = { key = "bl_small" } } } -- vanilla blind, no display-sync calculate +G.GAME.skips = 1 + +MP.net_route({ action = "skip", skips = 1 }) + +check("enemy.skips updated at skip time (not deferred to first hand)", MP.GAME.enemy.skips == 1) +check("skip SFX 'negative' played exactly once", count_sound("negative") == 1) +check("skip SFX 'gong' played exactly once", count_sound("gong") == 1) + +local sfx_count_after_first_skip = #play_sound_calls + +-- A second skip (still on a vanilla blind, e.g. the big blind) must reconcile +-- and play again -- one real skip, one sound, every time. +G.GAME.blind = { config = { blind = { key = "bl_big" } } } +G.GAME.skips = 2 + +MP.net_route({ action = "skip", skips = 2 }) + +check("second skip updates enemy.skips to 2", MP.GAME.enemy.skips == 2) +check("second skip plays its own SFX (2 more sounds)", #play_sound_calls == sfx_count_after_first_skip + 2) + +local sfx_count_after_second_skip = #play_sound_calls + +-- ─── Scenario: opponent reaches the boss blind and plays their first hand ─── +-- playHand's payload still carries the same CUMULATIVE skips count (my_skips(), +-- read from G.GAME.skips, unchanged at 2). Since enemy.skips was already +-- reconciled at skip time, this must be a silent, idempotent reconcile -- no +-- repeat SFX (the double-SFX regression this fix must avoid). Point G.GAME.blind +-- at the REAL registered nemesis object (as production does -- Balatro/SMODS +-- reuse one center object per blind key) so this also faithfully models the +-- active-blind-is-genuinely-nemesis case. +G.GAME.blind = { config = { blind = G.P_BLINDS["bl_mp_nemesis"] } } +G.GAME.current_round = { hands_left = 3 } + +MP.net_route({ action = "playHand", score = "300", handsLeft = 3 }) + +check("enemy.skips unchanged by the playHand echo", MP.GAME.enemy.skips == 2) +check("playHand's stale skips delta plays NO additional SFX", #play_sound_calls == sfx_count_after_second_skip) + +if failures > 0 then error(failures .. " check(s) failed") end +print("\nAll skip-sync checks passed.")