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
11 changes: 11 additions & 0 deletions networking/action_handlers.lua
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,12 @@ local function action_player_info(p)
end

local function action_win_game()
-- Guard against re-entry: a stray duplicate winGame dispatch (network
-- retry, referee re-broadcast, etc.) must not replay the win jingle/screen
-- or double-record match stats.
if MP.GAME.won then
return
end
MP.end_game_jokers_payload = ""
MP.nemesis_deck_string = ""
MP.end_game_jokers_received = false
Expand All @@ -480,10 +486,15 @@ local function action_win_game()
end

local function action_lose_game()
-- Symmetric re-entry guard, see action_win_game.
if MP.GAME.lost then
return
end
MP.end_game_jokers_payload = ""
MP.nemesis_deck_string = ""
MP.end_game_jokers_received = false
MP.nemesis_deck_received = false
MP.GAME.lost = true
MP.STATS.record_match(false)
G.STATE_COMPLETE = false
G.STATE = G.STATES.GAME_OVER
Expand Down
25 changes: 22 additions & 3 deletions pvp_api/referee.lua
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ MP.REF = MP.REF
or {
players = {},
first_ready_at = nil,
match_over = false,
-- Nemesis-pairing (rotating no-repeat 1v1 duels, N>2): nemesis_of[id]=partner
-- id for the current ante (absent = bye this ante); used_pairs is the
-- no-repeat memory; last_bye_id lets bye assignment prefer rotating away
Expand All @@ -37,6 +38,19 @@ local function broadcast(key, params)
end
end

-- The match ends the first time a game winner is declared. Every subsequent
-- resolution attempt (extra play_hand loopbacks, timer fails, N>2 progress-nudge
-- re-checks, etc. that still observe <=1 alive) must NOT re-broadcast pvp_win, or
-- clients replay the win/lose jingle and screen once per stray event instead of
-- once per match.
local function declare_win(winner_id)
if MP.REF.match_over then
return
end
MP.REF.match_over = true
broadcast("pvp_win", { winner_id = winner_id })
end

local function ref_player(id)
MP.REF.players[id] = MP.REF.players[id]
or {
Expand Down Expand Up @@ -91,11 +105,12 @@ local function alive_ids()
end

-- Declares pvp_win once exactly one player remains alive (Royale's N-player analog
-- of the 2-player "opponent hit 0 lives" check). Returns true if it fired.
-- of the 2-player "opponent hit 0 lives" check). Returns true if it fired (or had
-- already fired -- see declare_win's match_over guard).
local function check_alive_win()
local alive = alive_ids()
if #alive <= 1 then
broadcast("pvp_win", { winner_id = alive[1] or "*draw*" })
declare_win(alive[1] or "*draw*")
return true
end
return false
Expand Down Expand Up @@ -224,6 +239,7 @@ function MP.referee_reset(starting_lives)
MP.REF.used_pairs = {}
MP.REF.nemesis_ante_computed_for = 0
MP.REF.last_bye_id = nil
MP.REF.match_over = false
MP._result_reported = false
local lives = starting_lives or MP.LOBBY.config.starting_lives or 4
for _, id in ipairs(both_players()) do
Expand Down Expand Up @@ -360,6 +376,9 @@ end
-- 2 alive -- at exactly 2 alive, floor(2/2)=1 degenerates to "the lower scorer of
-- the pair loses a life", so the ending plays out identically to a 1v1 anyway.
local function try_resolve_round()
if MP.REF.match_over then
return
end
local total = both_players()
if #total < 2 then
return
Expand All @@ -379,7 +398,7 @@ local function try_resolve_round()
local game_winner = (a.lives > b.lives) and a or b
winner.first_ready = false
loser.first_ready = false
broadcast("pvp_win", { winner_id = game_winner.id })
declare_win(game_winner.id)
return
end
end
Expand Down
129 changes: 129 additions & 0 deletions tests/test_referee_match_over.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
--[[
Referee match-over guard test.

Drives pvp_api/referee.lua with stubbed MPAPI/network seams through: a normal
round life loss (no match end), a second life loss that brings the loser to
0 lives (match end, pvp_win must broadcast), then 3+ further stray play_hand
events (loopback duplicates / play_hand(chips,0) / play_hand(0,0) style
re-triggers that happen in practice near match end) that must NOT re-broadcast
pvp_win. Also asserts a fresh MP.referee_reset() clears the match_over flag so
the next match can broadcast its own single pvp_win.

Run from the repo root:
luajit tests/test_referee_match_over.lua
]]

MP = {
LOBBY = { config = { starting_lives = 2 } },
}

-- Real (pure) InsaneInt module, plus its one MP.UTILS dependency.
MP.UTILS = {
string_split = function(inputstr, sep)
if sep == nil then
sep = "%s"
end
local t = {}
for str in string.gmatch(inputstr, "([^" .. sep .. "]+)") do
table.insert(t, str)
end
return t
end,
}
dofile("lib/insane_int.lua")

-- ─── Stub the MQTT lobby/action seam ────────────────────────────────────────

local broadcasts = {} -- { {key=..., params=...}, ... }

MPAPI = {
ActionTypes = setmetatable({}, {
__index = function(_, k)
return k
end,
}),
}

local fake_lobby = {
is_host = true,
players = { { id = "p1" }, { id = "p2" } },
}
function fake_lobby:get_players()
return self.players
end
function fake_lobby:action(action_type)
return {
broadcast = function(_, params)
broadcasts[#broadcasts + 1] = { key = action_type, params = params }
end,
}
end
function MPAPI.get_current_lobby()
return fake_lobby
end

dofile("pvp_api/referee.lua")

local function win_broadcasts()
local n = 0
for _, b in ipairs(broadcasts) do
if b.key == "pvp_win" then
n = n + 1
end
end
return n
end

-- ─── Drive a match ──────────────────────────────────────────────────────────

MP.referee_reset(2)
assert(MP.REF.match_over == false, "match_over should start false after reset")

-- Round 1: p1 falls behind and runs out of hands -> loses a life (2 -> 1),
-- match must NOT be over yet.
MP.referee_on_play_hand("p2", { score = "50", handsLeft = 3 })
MP.referee_on_play_hand("p1", { score = "10", handsLeft = 0 })
assert(MP.REF.players["p1"].lives == 1, "expected p1 lives 2 -> 1 after round 1")
assert(win_broadcasts() == 0, "no pvp_win expected after a non-terminal life loss")
assert(MP.REF.match_over == false, "match_over must still be false after round 1")

MP.referee_on_new_round("p1")

-- Round 2: p1 falls behind again -> lives 1 -> 0 -> match ends, pvp_win fires
-- exactly once.
MP.referee_on_play_hand("p2", { score = "60", handsLeft = 3 })
MP.referee_on_play_hand("p1", { score = "20", handsLeft = 0 })
assert(MP.REF.players["p1"].lives == 0, "expected p1 lives 1 -> 0 after round 2")
assert(win_broadcasts() == 1, "expected exactly one pvp_win broadcast at match end")
assert(MP.REF.match_over == true, "match_over must be true once pvp_win has fired")
assert(broadcasts[#broadcasts].params.winner_id == "p2", "winner should be p2")

-- Several more play_hand events land after the match is already over (the
-- reported bug: per-hand sends, play_hand(chips,0), play_hand(0,0) on deck-out
-- all loop back through referee_on_play_hand). None of these may re-broadcast
-- pvp_win.
MP.referee_on_play_hand("p1", { score = "20", handsLeft = 0 })
MP.referee_on_play_hand("p2", { score = "60", handsLeft = 0 })
MP.referee_on_play_hand("p1", { score = "0", handsLeft = 0 })
MP.referee_on_play_hand("p2", { score = "0", handsLeft = 0 })
assert(win_broadcasts() == 1, "pvp_win must still have broadcast exactly once after stray play_hand events")

-- ─── A fresh match resets the flag and can broadcast its own single win ────

MP.referee_reset(2)
assert(MP.REF.match_over == false, "match_over must reset to false for a new match")

MP.referee_on_play_hand("p2", { score = "50", handsLeft = 3 })
MP.referee_on_play_hand("p1", { score = "10", handsLeft = 0 })
MP.referee_on_new_round("p1")
MP.referee_on_play_hand("p2", { score = "60", handsLeft = 3 })
MP.referee_on_play_hand("p1", { score = "20", handsLeft = 0 })
assert(win_broadcasts() == 2, "expected a second, single pvp_win broadcast for the new match")

-- Stray events after the second match's end must not add a third.
MP.referee_on_play_hand("p1", { score = "20", handsLeft = 0 })
MP.referee_on_play_hand("p2", { score = "60", handsLeft = 0 })
MP.referee_on_play_hand("p1", { score = "0", handsLeft = 0 })
assert(win_broadcasts() == 2, "pvp_win must not fire again after the second match already ended")

print("test_referee_match_over: OK")