diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua index 509f4889..401d4da2 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -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 @@ -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 diff --git a/pvp_api/referee.lua b/pvp_api/referee.lua index 5744c3ae..880397ab 100644 --- a/pvp_api/referee.lua +++ b/pvp_api/referee.lua @@ -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 @@ -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 { @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/tests/test_referee_match_over.lua b/tests/test_referee_match_over.lua new file mode 100644 index 00000000..359ff750 --- /dev/null +++ b/tests/test_referee_match_over.lua @@ -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")