Repository navigation
Record whether the archived stats were agreed; replay lone-voter wins - #5769
Conversation
Appends an optional `statsAgreed` boolean at the end of GameEndInfoSchema and a matching trailing parameter to createPartialGameRecord. The end info is only sent as JSON over HTTP (it is not part of any zbin wire message); GameStartInfo, which is on the wire, is unchanged. Game servers will set it from the end-of-game vote. Singleplayer records (LocalServer) never set it: no vote backs their stats. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The archived record took allPlayersStats from the first client that voted for the winning result, so a modified client that voted for the true winner first wrote every player's stats in the record. Among the voters for the decided winner, each distinct stats version (statsDigest, from #5717) is now a candidate of its own, counted in unique IPs against the same electorate and by the same strict-majority rule as the winner vote at the moment it is decided (all votes against the voting electorate, or only active IPs' votes after the electorate shrinks). When exactly one version holds that majority, the record carries it and info.statsAgreed is true. Otherwise the record carries the most-backed version (the first vote's on a tie, as before) and statsAgreed is false; so does a game that ends with no decided winner. The winner decision is untouched: same key, same thresholds, decided and archived at the same vote. VoteRound gains read-only standings() and a shared isStrictMajority() that result()/resultAmong() now call. The "winner stats agreement" log line stays, now with `agreed`, and warns whenever the record is not agreed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The winner is decided at the first strict majority, so a forged vote among that majority always left the honest stats version one vote short: a single modified client could deny statsAgreed to the whole lobby. When no stats version has a majority at the deciding vote, the record now waits up to STATS_VOTE_WINDOW_MS (5s) for the voters still to come. Late votes count only toward the stats, and only if they back the decided winner; the winner, its timing and everything players see are unchanged. During the window the stats are counted as the shrink re-tally counts: only still-connected players' votes, against the still-connected electorate. The record is archived as soon as one version has a majority, every player still connected who may vote has voted, the window runs out, or the game ends. The majority rule itself is unchanged. Games whose stats already agree at the deciding vote (every honest game) are archived at that vote, as before. archiveGame() now runs at most once (an `archived` flag, which also rejects votes after archiving) and cancels the window timer. end() settles and archives an open record first thing, while the roster is intact, so GameManager, which drops a game right after end(), cannot lose it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
WalkthroughThe server tallies stats separately from winner votes. After a winner decision, stats votes can remain open for up to five seconds. The server archives the selected stats and records whether they reached a strict majority. ChangesWinner Stats Agreement
Priority: ➖ Normal Estimated code review effort: 4 (Complex) | ~45 minutes Change: Feature Suggested reviewers: Merge Risk: ⚪ Minimal · up to No actionable merge-blocking issue was established; the change is ready for normal merge checks. Security Architecture Review
🚥 Pre-merge checks | ✅ 3 | ❌ 2
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Votes gather; stats take shape Comment |
Main moved src/core into packages/ (#5805). Consensus.ts keeps the stats vote's isStrictMajority/Standing imports alongside main's createHash, on the new @openfront/shared/WireSchemas path, and the stats-agreed record test imports from @openfront/shared and @openfront/engine-api. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s window Main (#5807) replays the game when the winner vote is disputed; this branch holds the record briefly for late stats votes. Combined: an undisputed vote still waits for its stats to settle, a disputed one goes straight to the replay, whose stats are the server's own and so count as agreed. If the replay fails, the vote's stats go out, settled as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Players who leave after the winner is decided stay in the stats electorate, so a departure can end the window early but never make a version agreed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Celant
left a comment
There was a problem hiding this comment.
Coordinated review
Part of a batch for @Celant, reviewed alongside the backend's XP ingest. CI review was stale by 5 days and has been re-run.
The client cannot assert statsAgreed itself, which I checked specifically because the backend treats that flag as the thing that decides whether a game earns XP at all. It's written only by the game server in archiveGame (GameServer.ts:2064), the archive POST is API-key authenticated, and the client's singleplayer path never passes the argument — consistent with the backend deliberately stripping it on /archive_singleplayer_game.
Vote edge cases all hold up. Tie: no version reaches a strict majority, the window opens, and the forced settle takes the most-backed version (earliest on a tie) with agreed: false. Zero voters: archiveGame(null, null) → statsAgreed: false, per-player stats omitted, record still archived from end(). Winner not voting is irrelevant by design. Disconnects are handled — checkWinnerAfterElectorateShrink re-evaluates open stats, settleStatsAmong counts only still-connected players' IPs, and everyVoterHasVoted() force-settles when the last unvoted player leaves. Double-archive is guarded, the timer is cleared in settleWinner, and end() force-settles before GameManager drops the game.
Test coverage across Consensus.test.ts, WinnerStatsAgreement.test.ts, WinnerReplaySettle.test.ts and StatsAgreedRecord.test.ts is unusually thorough, including the shared-IP double-majority branch, key-order canonicalisation and the archive JSON round trip.
Three low findings below. I have one further observation about what statsAgreed certifies that I'm raising with @Celant directly first rather than here.
One note worth recording: nothing in this repo reads the backend's new requireStatsAgreed, so a game whose voters disagree currently scores nothing with no player-facing explanation. Probably a follow-up rather than a change here.
3 finding(s): 3 low
Findings whose anchor falls outside the diff:
[low] handleWinner isn't gated on the game having started (pre-existing, but this PR rebuilds the path)
src/server/GameServer.ts
Unlike handleReport, handleWinner doesn't check this.stage === "started". A winner frame accepted during the lobby can decide the vote and run straight through settleStatsOrArchive → settleWinner → archiveGame, which dereferences this.gameStartInfo (:248, non-null asserted) before start() has assigned it. SocketIngress only applies decode, rate-limit and spectator checks; it doesn't wrap onMessage.
Failure scenario: an early vote from a lobby throws inside the socket message path, and because archived has already been set the game then never archives its real record either.
Behaviour is identical on origin/main (the decided flag suppressed the later archive there too), so this is not a regression — but the PR rebuilds this path and the guard is one line.
Suggested fix: add this.stage !== "started" to the handleWinner early return, matching handleReport.
|
Agreed on scope, and reworked on top of #5852 (d57d2e2, 6ee04a2):
The description is rewritten to match. The diff against 🤖 Addressed by Claude Code |
|
Re-reviewed after the rework. The reduction is exactly right, and it closes the thing I was most worried about. The shrink from +1139/-101 to +343/-21 is real, not cosmetic: The flag itself is correct. I traced all three
Singleplayer cannot set it: And it closes the degenerate-electorate case. A 1-of-1 decided vote now takes the replay branch, so such a record only gets Test coverage on the new branches is thorough: lone-voter overturn, lone-voter replay failure, NAT-shared IP, departed backer counted, no-majority replay failure, singleplayer absent, JSON round trip, non-boolean rejected. [high] The lone-voter branch is a behaviour and capacity change, not a recording changeWhere: Requiring It also catches any multiplayer game where one client is the last connected at the win tick (common at the end of a long FFA), single-human public/private lobbies, and — per this PR's own test "counts IPs, not clients: two clients on one IP are a lone voter" — a genuine 1v1 between two players behind one NAT. A replay is a whole game's worth of simulation in a forked subprocess at Failure scenario: replays arrive faster than they drain. Suggested fix: decide this explicitly and say so in the PR description, because it is well outside "record a flag". Either archive the lone voter's result immediately with [medium]
|
… throws - handleWinner ignores votes unless the game has started and not ended, like handleReport. - A replay that throws falls back to the vote's result, as a replay that answers null does (counted as failed), instead of only logging. - The replay's reason gains "no majority" (one candidate, no decision); end()'s log line no longer claims a pending record was archived. - Drop a key-order assertion on GameEndInfoSchema; records travel as JSON. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks. All four addressed in 8210cbc:
🤖 Addressed by Claude Code |
A vote decided by one IP is common (the loser leaves before voting), so replaying every one can pile up the worker's serial replay queue. When 3 replays already wait or run, a lone-voter game is archived straight away with the vote's result, unconfirmed (statsAgreed false), and counted in the new winner_replay outcome "skipped". Disputed and no-majority votes still always queue. The pending count reaches settleWinner through a new replayPending dep so tests can set it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
On the [high] (lone-voter replays and queue capacity), decided explicitly and in the description now (b5aa21b):
The cap keeps the common ending out of a long serial queue at the cost of no XP for that game when a worker is busy. If the 🤖 Addressed by Claude Code |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
🤖 Claude Code ReviewVerdict: No issues found. Checked for bugs and CLAUDE.md compliance. (0 high / 0 medium / 0 low) Reviewed 🤖 Generated with Claude Code |
Celant
left a comment
There was a problem hiding this comment.
Approving: CI Claude review clean at 63348821, all required checks green, findings from every earlier round closed and verified in code.
Upstream brought: - Nation AI overhaul (openfrontio#5899, openfrontio#5788, openfrontio#5783): many more cities, ports and factories; building income while saving for nukes; bulk upgrades; SAM upgrades; defense posts behind the front and (Hard/Impossible) on hostile borders; reactions to landed boat invasions; tribes walled in and annexed; strikes on a neighbour of similar strength; nuking back whoever has a bomb in the air; teammate backup in teams; rival MIRVs on Hard/Impossible; hydrogen-only nations removed. Hitting back no longer angers the attacked (except on Easy). - Severed land annexation (openfrontio#5751), attacks with nothing to conquer refused (openfrontio#5821), malformed troop counts dropped (openfrontio#5883), train split reconcile (openfrontio#5719). - Server-side winner replays for disputed or one-IP votes (openfrontio#5807, openfrontio#5769). - XP, levels, prestige, reward track, Legend ceremony, profile sharing, privacy settings; clan browser/boosts/wars; ranked FFA and clan wars. - Quick chat on R with a redesigned phrase panel (openfrontio#5841), emoji key with favourites, a target key, hover-panel shortcut buttons, HUD opacity. - Lazy-loaded modals and client, the engine sealed from fetching its own map files (MapFiles passed in), AssetPaths moved to shared, vitest projects (only tests/client under jsdom), rolldown chunk groups. - Halloween homepage theme, Schema.org data, new maps (Ommatrypta, Brain Rot), translated nuke/naval alerts, lobby player limit, sound fixes. Conflicts (29 files), settled mechanically, keeping every DerpyFront feature: Config troopCap (Capital bonus in both branches), AttackExecution (upstream rule with our relation penalty and reason), Nation MIRV/nuke/ structure behaviours (AI personalities, Domes, Capital re-applied around upstream's new logic), en.json (both sides' keys), Cosmetics (Derp Store catalog), GameModeSelector, lobby modals, InputHandler/UserSettings/ UserSettingModal (Enter chat kept; upstream's target key moved from N, our Dome key, to J), Main (lazy modals with our fonts, store page, ads off), WorkerClient, nav bars, ChatDisplay, GameRightSidebar, PlayerInfoOverlay, PlayerPanel, WinModal (awards, Derp Coins, fanfare), GameServer (Derpy archive with awards), server Worker, package.json, two tests. What is turned off follows in separate commits: XP and levels, quick chat shortcuts, server winner replays, the Halloween look and the search-engine data. Build and test fixes for the merge are in the next commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CBUcrnBX9WVyHWuWpshPGS
Resolves #5768
Description:
Reworked after #5852. #5852 (now on
main) keys the winner vote on the winner and the per-player stats, so a decided vote already means a majority agreed on those exact stats, and any disagreement goes to the server replay. This PR used to add its own second stats vote and a short window for late votes; that is all dropped in favour of #5852. What's left is whatmainis missing: the archived record says whether its stats are trustworthy, and a game decided by a single voter is checked by the replay instead of being trusted.statsAgreedon the archived recordGameEndInfogains an optionalstatsAgreedboolean (packages/shared/src/WireSchemas.ts, passed throughcreatePartialGameRecord). The API only awards XP for a game whose record hasstatsAgreed: true(when progression is fully on), so this flag is what lets multiplayer games earn XP.A lone deciding voter is replayed
A strict majority of one voting IP still decides, for example a 1v1 where the loser leaves without voting, or two clients on one IP. #5852 notes those stats are unchecked. Now
settleWinnerarchives a vote directly only when its key has at least 2 backers (WinnerVote.backers(): unique IPs that sent the decided key, including IPs that have since left). With 1 backer it runs the existing replay; if the replay fails, the lone vote's result is archived withstatsAgreed: false.Keeping the replay queue bounded
One-voter games are replayed so their stats can be confirmed. They are common, though, and each worker runs one replay at a time. To keep the queue bounded, when a worker already has 3 replays waiting or running (
LONE_VOTER_REPLAY_CAP), a one-voter game is recorded straight away with the vote's result as unconfirmed (statsAgreed: false, so no XP) and counted in a newskippedreplay outcome. Disputed and no-majority games are unaffected: they always queue a replay.Logging
"winner vote decided by one IP, replaying game", alongside"winner vote disputed, replaying game"; both carrybackers."winner replay result"gainsreason: "disputed" | "lone voter"."winner replay failed, archiving the vote"withreason,backersandstatsAgreed."archiving game"gainsstatsAgreed."winner replay queue full, archiving the lone vote unconfirmed"(warn) withpendingandcap.openfront.winner_replay.outcomesgainsskipped.Rollout
The API only scores games with
statsAgreed: trueoncePROGRESSION_ENABLED="1". This must be deployed to the game servers before production switches to"1"; until then production stays on"unverified".Screenshots
No UI change.
Testing
tests/server/WinnerReplaySettle.test.ts: every existing case now also checksstatsAgreed. New cases: a lone voter whose replay succeeds (true), a lone voter whose replay fails (falls back, false), two clients on one IP (replayed as a lone voter), a departed IP that sent the decided key still counting as a backer (true, no replay), a no-majority game whose replay fails (winnerless, false). Queue cap: a lone voter at the cap is archived at once (false, no replay,skippedcounted), one below the cap is still replayed, and a disputed vote at the cap is still replayed.Consensus.test.tsandVoteTally.test.ts:backers().tests/StatsAgreedRecord.test.ts: the flag on the record and its schema.npm run typecheck, eslint and prettier clean;tests/server,tests/core, the schema and wire tests pass (1660 passed).Please complete the following:
Please put your Discord username so you can be contacted if a bug or regression is found:
Iamlewis
🤖 Generated with Claude Code