Skip to content
Open
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
8 changes: 5 additions & 3 deletions objects/blinds/nemesis.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
23 changes: 19 additions & 4 deletions pvp_api/net.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
198 changes: 198 additions & 0 deletions tests/test_skip_sync.lua
Original file line number Diff line number Diff line change
@@ -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.")