Skip to content

fix(core,server): resolve critical security, stability, and logic issues#3796

Closed
berkelmali wants to merge 5 commits intoopenfrontio:mainfrom
berkelmali:main
Closed

fix(core,server): resolve critical security, stability, and logic issues#3796
berkelmali wants to merge 5 commits intoopenfrontio:mainfrom
berkelmali:main

Conversation

@berkelmali
Copy link
Copy Markdown
Contributor

@berkelmali berkelmali commented Apr 29, 2026

Description:

This PR addresses critical security vulnerabilities, stability issues, and logic bugs identified during a comprehensive code audit:

  • SEC-02: Added authentication to the start_game API endpoint to prevent unauthorized game starts
  • SEC-01: Rate limiter now uses a sliding window for total bytes to prevent burst abuse
  • BUG-02: Added readyState check to endTurn WebSocket broadcast to prevent crashes on closed connections
  • BUG-06: Guarded against division by zero in WinCheckExecution when all land tiles have fallout; replaced floating-point division with integer cross-multiplication for determinism
  • BUG-01: Fixed attack retreat stats recording sequence - retreated() is now checked before delete() to preserve attack state
  • BUG-03: Fixed typo in websocket error log
  • SUG-08: Removed duplicate express.json() middleware
  • SUG-06: Improved disconnectedTimeout readability with named constant
  • BUG-07: Clarified bfs() DFS behavior via JSDoc
  • CodeRabbit fix: Changed error log level from info to error for caught exceptions in GameServer websocket handler

Please complete the following:

  • I have added screenshots for all UI updates
  • I process any text displayed to the user through translateText() and I've added it to the en.json file
  • I have added relevant tests to the test directory
  • I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced

Please put your Discord username so you can be contacted if a bug or regression is found:

barfires

@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Apr 29, 2026

CLA assistant check
All committers have signed the CLA.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 29, 2026

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Capture retreat state before attack deletion; add division-by-zero guards in win checks; clarify GameMap.bfs JSDoc and traversal; implement 60s rolling byte window in client rate limiter; tighten websocket sends and host-cheats update; remove duplicate JSON parser and add authenticated POST /api/start_game/:id; add tests for retreat stats and win-check fallout edge cases.

Changes

Attack / Win-check / Map BFS & Tests

Layer / File(s) Summary
Retreat capture
src/core/execution/AttackExecution.ts
Capture wasRetreated before this.attack.delete() and use it when recording attackCancel stats instead of calling attack.retreated() after deletion.
Win-check guards
src/core/execution/WinCheckExecution.ts
Avoid division-by-zero by requiring numTilesWithoutFallout > 0 and using cross-multiplication checks for FFA and Team modes; skip awarding win to ColoredTeams.Bot in team path.
BFS doc & loop clarity
src/core/game/GameMap.ts
Expanded JSDoc for bfs; use DFS-style Array.pop() traversal and assert non-null with q.pop()!, removing redundant optional handling.
Attack retreat tests
tests/AttackRetreatStats.test.ts
Add tests asserting attackCancel is called when attack retreats and not called when an attack completes normally.
Win-check fallout tests
tests/core/executions/WinCheckExecution.test.ts
Add FFA and Team tests ensuring no winner is declared when all land tiles have fallout.

Server: Rate Limiter, GameServer, Worker API & Middleware

Layer / File(s) Summary
Rate window data shape
src/server/ClientMsgRateLimiter.ts
Replace single totalBytes counter with per-event byteEvents log and add BYTE_WINDOW_MS = 60_000 / TOTAL_BYTES constants for a 60s rolling window.
Rate limiter core
src/server/ClientMsgRateLimiter.ts
Implement eviction of events older than 60s, append current event, compute windowed byte sum, and enforce kick when sum ≥ TOTAL_BYTES; intent checks preserved.
GameServer safety & logs
src/server/GameServer.ts
Use numeric 30_000 for disconnect timeout, only apply gameConfig.hostCheats when provided, fix error log wording, and guard ws.send with WebSocket.OPEN.
Middleware cleanup
src/server/Worker.ts
Remove duplicate app.use(express.json()); keep single parser with limit.
Authenticated start endpoint
src/server/Worker.ts
Add POST /api/start_game/:id: validate game exists, reject public games, require Bearer token, verify token → persistentId, require requester equals lobbyCreatorClientID, call start, return success.
Worker wiring / responses
src/server/Worker.ts
Endpoint returns appropriate 4xx on validation/auth failure and 200 with { started: true } on success.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant Worker as Worker
    participant Auth as AuthService
    participant Store as GameStore
    participant Engine as GameEngine

    Client->>Worker: POST /api/start_game/:id (Bearer token)
    Worker->>Auth: Verify Bearer token
    Auth-->>Worker: persistentId or error
    Worker->>Store: Fetch game by id
    Store-->>Worker: Game data or not found
    alt exists && not public && persistentId == lobbyCreatorClientID
        Worker->>Engine: startGame(game)
        Engine-->>Worker: started
        Worker-->>Client: 200 { started: true }
    else unauthorized or invalid
        Worker-->>Client: 4xx error
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

✨ A retreat is noted before the trace is gone,
Percent checks skip zeros so wins don't spawn,
BFS pops in order, clear in the doc,
Bytes roll a minute while sockets wait on lock,
A start-game call wakes the lobby at dawn.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main objective of the changeset: resolving critical security, stability, and logic issues across multiple files.
Description check ✅ Passed The description is well-related to the changeset, detailing specific fixes for security vulnerabilities, stability issues, and logic bugs with clear references to bug IDs and implementation details.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/core/execution/WinCheckExecution.ts (1)

1-137: ⚠️ Potential issue | 🟡 Minor

Prettier failure is blocking CI for this file.

Please run formatting and commit the result for src/core/execution/WinCheckExecution.ts.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/execution/WinCheckExecution.ts` around lines 1 - 137, The file fails
Prettier formatting; run your project's formatter (e.g., prettier --write) on
src/core/execution/WinCheckExecution.ts and commit the reformatted file so CI
passes. Locate the WinCheckExecution class (and its methods init, tick,
checkWinnerFFA, checkWinnerTeam) and ensure spacing/line breaks conform to the
repo Prettier configuration, then stage and push the formatted changes.
🧹 Nitpick comments (2)
src/server/GameServer.ts (1)

582-582: Use error log level for caught exceptions in this path.

Line 582 handles an exception path but logs at info, which makes production triage harder.

♻️ Proposed adjustment
-        this.log.info(
-          `error handling websocket request in game server: ${error}`,
-          {
-            clientID: client.clientID,
-          },
-        );
+        this.log.error(`error handling websocket request in game server`, {
+          clientID: client.clientID,
+          error: String(error),
+        });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/GameServer.ts` at line 582, The caught-exception path currently
logs the template string `error handling websocket request in game server:
${error}` at info level; change that call to use error-level logging (e.g.,
logger.error or this.logger.error) and ensure the error object (or error.stack)
is passed so the message includes stack/details instead of only the string
interpolation; update the log invocation where the template is used so it emits
an error-level message with full error details.
src/core/game/GameMap.ts (1)

376-381: JSDoc clarification improves understanding.

The updated documentation accurately describes the stack-based (DFS) traversal and clarifies that results are order-invariant. This helps readers understand the performance trade-off and deterministic behavior.

💡 Optional: Consider renaming for clarity

The method name bfs (breadth-first search) doesn't match the DFS-style implementation. While the JSDoc now clarifies this, consider renaming to something like floodFill or connectedTiles in a future refactor to avoid confusion.

This is purely optional and out of scope for this PR.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/game/GameMap.ts` around lines 376 - 381, The method is documented as
using stack-based DFS but its name is still bfs, which is misleading; rename the
function symbol bfs to a clearer name such as floodFill or connectedTiles
(update all references/call sites and exported identifiers accordingly) and
adjust any tests/imports to use the new name so implementation, API, and JSDoc
are consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/core/execution/AttackExecution.ts`:
- Around line 208-217: Add a regression test that exercises the AttackExecution
flow to ensure mg.stats().attackCancel(owner, target, survivors) is called only
when attack.retreated() returns true; specifically, create two test cases that
instantiate an AttackExecution, set up one scenario where attack.retreated() is
true before delete() and one where it is false, run the code path that calls
this.attack.delete() and sets this.active = false, and assert that
stats().attackCancel(...) is called only in the true-retreated case and not in
the false case; target the AttackExecution class and the sequence around
attack.retreated(), this.attack.delete(), and mg.stats().attackCancel to prevent
regressions.

In `@src/core/execution/WinCheckExecution.ts`:
- Around line 72-75: The percent calculation uses floating-point division in
WinCheckExecution (the percentOwned computation and the similar check later)
which breaks determinism; replace those (percentOwned = (max.numTilesOwned() /
numTilesWithoutFallout) * 100 and the later `(owned/total)*100 > threshold`)
with integer cross-multiplication comparisons: use expressions like
`max.numTilesOwned() * 100` compared against `threshold *
numTilesWithoutFallout` (or `owned * 100 > threshold * total`) so you avoid any
floating-point arithmetic while preserving the same logic in the methods where
percentOwned and the later percentage check are computed.

In `@src/server/ClientMsgRateLimiter.ts`:
- Around line 30-37: The current fixed-window reset logic in
ClientMsgRateLimiter (uses bucket.totalBytes and bucket.byteWindowStart with
BYTE_WINDOW_MS) allows bursts around window edges; replace it with a
rolling-window byte accounting: record per-message (timestamp, bytes) entries on
the bucket (or maintain a deque/circular buffer) and, on each new message, evict
entries older than now - BYTE_WINDOW_MS and sum remaining bytes to check against
the 2MB limit, updating the buffer with the new sample; alternatively implement
a token-bucket/leaky-bucket tied to BYTE_WINDOW_MS so consumption decays
smoothly instead of resetting at byteWindowStart.

In `@src/server/Worker.ts`:
- Around line 259-260: The handler unconditionally returns success after calling
game.start(), which can be a no-op if the game is already started; update the
logic to detect whether start actually changed state (e.g., check a boolean
return from Game.start() or inspect game.started/isRunning property) and return
a 409 or a JSON { success: false, reason: "already_started" } when no-op occurs,
otherwise return { success: true } as before; modify the Game.start()
implementation if necessary to return a status flag and have the route use that
flag to decide the HTTP response.

---

Outside diff comments:
In `@src/core/execution/WinCheckExecution.ts`:
- Around line 1-137: The file fails Prettier formatting; run your project's
formatter (e.g., prettier --write) on src/core/execution/WinCheckExecution.ts
and commit the reformatted file so CI passes. Locate the WinCheckExecution class
(and its methods init, tick, checkWinnerFFA, checkWinnerTeam) and ensure
spacing/line breaks conform to the repo Prettier configuration, then stage and
push the formatted changes.

---

Nitpick comments:
In `@src/core/game/GameMap.ts`:
- Around line 376-381: The method is documented as using stack-based DFS but its
name is still bfs, which is misleading; rename the function symbol bfs to a
clearer name such as floodFill or connectedTiles (update all references/call
sites and exported identifiers accordingly) and adjust any tests/imports to use
the new name so implementation, API, and JSDoc are consistent.

In `@src/server/GameServer.ts`:
- Line 582: The caught-exception path currently logs the template string `error
handling websocket request in game server: ${error}` at info level; change that
call to use error-level logging (e.g., logger.error or this.logger.error) and
ensure the error object (or error.stack) is passed so the message includes
stack/details instead of only the string interpolation; update the log
invocation where the template is used so it emits an error-level message with
full error details.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e9ae74d7-8c0a-4385-934a-594e6fdfadd7

📥 Commits

Reviewing files that changed from the base of the PR and between 67a42c2 and c47ba82.

📒 Files selected for processing (6)
  • src/core/execution/AttackExecution.ts
  • src/core/execution/WinCheckExecution.ts
  • src/core/game/GameMap.ts
  • src/server/ClientMsgRateLimiter.ts
  • src/server/GameServer.ts
  • src/server/Worker.ts

Comment thread src/core/execution/AttackExecution.ts
Comment thread src/core/execution/WinCheckExecution.ts Outdated
Comment thread src/server/ClientMsgRateLimiter.ts Outdated
Comment thread src/server/Worker.ts Outdated
@github-project-automation github-project-automation Bot moved this from Triage to Development in OpenFront Release Management Apr 29, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/server/Worker.ts`:
- Around line 213-263: There are extra blank lines surrounding the new
app.post("/api/start_game/:id", ...) handler causing Prettier to fail; remove
the duplicated blank line(s) immediately before the handler and immediately
after its closing brace so the block sits flush with the surrounding code, then
re-run formatting (npx prettier --write) to ensure no trailing/leading blank
lines remain; locate the handler by searching for
app.post("/api/start_game/:id") and verify related symbols used inside
(verifyClientToken, ipAnonymize, gm.game, game.start) are unchanged.
- Around line 215-260: In the app.post("/api/start_game/:id") handler move the
Bearer token verification (use verifyClientToken and the Authorization header
parsing) to the top of the handler before any game lookup, logging, or calls to
gm.game(...) and ipAnonymize; after successful token verification, then call
gm.game(req.params.id), handle 404, then check game.isPublic() and only then
perform owner validation via game.getClientIdForPersistentId and compare to
game.gameInfo().lobbyCreatorClientID, and finally call game.start(); also remove
or relocate the initial log.info that reveals the lobby existence/visibility
before auth so unauthenticated callers cannot probe state.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4f0fb9c9-0a56-4d90-a107-f84155b092ad

📥 Commits

Reviewing files that changed from the base of the PR and between c47ba82 and cea2ec3.

📒 Files selected for processing (1)
  • src/server/Worker.ts

Comment thread src/server/Worker.ts Outdated
Comment thread src/server/Worker.ts Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/core/execution/WinCheckExecution.ts`:
- Around line 71-78: Add unit tests covering the edge case when
numTilesWithoutFallout === 0 so the percentage-based win check
(exceedsThreshold) is skipped and no winner is declared via the tile-percentage
path: create tests that exercise WinCheckExecution logic for both FFA and Team
modes ensuring max.numTilesOwned() * 100 >
this.mg.config().percentageTilesOwnedToWin() * numTilesWithoutFallout is not
evaluated when numTilesWithoutFallout is zero and that no winner is set by that
path; use the same setup helpers used by existing tests (simulate all tiles
having fallout, call the win-check invocation that uses exceedsThreshold) and
assert no winner for FFA and for Team (except keep the existing Bot team test
unchanged).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 63929242-6c12-4c16-8d74-c971bf66234f

📥 Commits

Reviewing files that changed from the base of the PR and between cea2ec3 and 2720d66.

📒 Files selected for processing (3)
  • src/core/execution/WinCheckExecution.ts
  • src/server/GameServer.ts
  • src/server/Worker.ts

Comment thread src/core/execution/WinCheckExecution.ts
@berkelmali
Copy link
Copy Markdown
Contributor Author

I have read the CLA Document and I hereby sign the CLA

@VariableVince
Copy link
Copy Markdown
Contributor

VariableVince commented May 6, 2026

Hi @berkelmali, if you'd want to join the Discord Dev server, there's an invite link in the readme here: https://github.com/openfrontio/OpenFrontIO#-contributing

It could come in handy when discussing this PR for example.

CodeRabbit's feedback can be valid btw, it can help to fix things before a maintainer reviews the PR. I see in the Description you've applied one fix but the rest is still open. You can use it's ready-to-apply commits or AI prompts, or tell it why its feedback is wrong and it will learn from that. For unrelated things it can also create a seperate Issue if you ask it to.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
src/core/execution/AttackExecution.ts (1)

208-218: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Missing test for the retreat/cancel stats path.

This core logic change has no accompanying test. As per coding guidelines: "All changes to src/core/ must include tests".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/execution/AttackExecution.ts` around lines 208 - 218, Add a unit
test for the AttackExecution behavior that ensures when an attack is marked as
retreated the code records cancellation stats before the attack is deleted:
instantiate an AttackExecution (or use the factory used in existing tests), mark
its Attack instance so retreated() returns true, call the execution path that
triggers delete() and setting active=false (the method under test in
AttackExecution), and assert that mg.stats().attackCancel(...) was called with
the expected _owner, target and survivors; also add a negative test where
retreated() is false to verify attackCancel is not called. Use the same test
harness/mocks used by other core tests to mock this.attack.retreated(),
this.attack.delete(), and mg.stats() to verify call order and invocation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@src/core/execution/AttackExecution.ts`:
- Around line 208-218: Add a unit test for the AttackExecution behavior that
ensures when an attack is marked as retreated the code records cancellation
stats before the attack is deleted: instantiate an AttackExecution (or use the
factory used in existing tests), mark its Attack instance so retreated() returns
true, call the execution path that triggers delete() and setting active=false
(the method under test in AttackExecution), and assert that
mg.stats().attackCancel(...) was called with the expected _owner, target and
survivors; also add a negative test where retreated() is false to verify
attackCancel is not called. Use the same test harness/mocks used by other core
tests to mock this.attack.retreated(), this.attack.delete(), and mg.stats() to
verify call order and invocation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e4881886-1f27-4a9f-9c3a-0fba943eae10

📥 Commits

Reviewing files that changed from the base of the PR and between 2720d66 and 8c8c2e4.

📒 Files selected for processing (2)
  • src/core/execution/AttackExecution.ts
  • src/server/GameServer.ts

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (4)
src/server/Worker.ts (2)

247-256: 💤 Low value

callerPersistentId is always defined here — drop the optional chain.

result.type === "error" already returned at line 224, so result.persistentId is a string. The ?. on line 255 just hides that fact and weakens the type signal.

♻️ Tiny tidy-up
-    const callerPersistentId = result.persistentId;
+    const callerPersistentId: string = result.persistentId;
@@
       log.warn(
-        `Unauthorized start_game attempt by ${callerPersistentId?.substring(0, 8)}`,
+        `Unauthorized start_game attempt by ${callerPersistentId.substring(0, 8)}`,
       );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/Worker.ts` around lines 247 - 256, The optional chaining on
callerPersistentId in the log call is unnecessary because result.type ===
"error" was already handled and result.persistentId is a string; update the
log.warn invocation to call substring directly on callerPersistentId (remove the
`?.`) so the code reflects the non-nullable type; locate this in the start_game
handling around the variables callerPersistentId, existingClientId,
game.getClientIdForPersistentId and game.gameInfo().lobbyCreatorClientID, and
adjust the log.warn message accordingly.

243-263: ⚡ Quick win

Race condition exists in theory, but current implementation is already safe — consider defensive wrapping only if future changes risk idempotency.

A race exists between the hasStarted() check at line 243 and the game.start() call at line 262: GameManager.tick() (line 127) could call game.start() via timeout in that window. However, game.start() already guards against double-invocation (line 726 checks if (this._hasStarted || this._hasEnded) return;), so a second call is a safe no-op.

If you want defensive protection against future changes to start() logic, wrap the call in try-catch. Otherwise, the current code is safe.

Optional defensive guard (if desired)
+    try {
       game.start();
       res.status(200).json({ success: true });
+    } catch (error) {
+      log.error(`error starting game ${game.id} via API:`, error);
+      res.status(500).json({ error: "Failed to start game" });
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/Worker.ts` around lines 243 - 263, Race window between
game.hasStarted() and game.start() could let GameManager.tick() start the game
concurrently; add a defensive guard by re-checking state and catching errors
before calling start: after verifying caller identity (using
getClientIdForPersistentId and game.gameInfo().lobbyCreatorClientID), if
game.hasStarted() return 409, otherwise call game.start() inside a try-catch and
on catch log the error and respond with a 409/500 as appropriate; alternatively
perform an atomic re-check (if (!game.hasStarted()) try { game.start() } catch
(e) { ... }) to ensure idempotent, safe behavior even if start() changes later.
tests/AttackRetreatStats.test.ts (1)

77-86: ⚡ Quick win

Tiny gap: the second test passes even if the attack never finishes.

If outgoingAttacks().length never reaches 0 within 5000 ticks (e.g., due to a future regression that makes the attack stall), the loop simply exits because maxTicks hit 0, and attackCancel would correctly not have been called — so the test still passes. Adding a guard that the loop exited because the attack actually completed makes the negative case meaningful.

♻️ Suggested guard
     // Execute until attack completes
     let maxTicks = 5000;
     while (player1.outgoingAttacks().length > 0 && maxTicks > 0) {
       game.executeNextTick();
       maxTicks--;
     }

+    // Make sure the loop ended because the attack finished, not because we ran out of ticks.
+    expect(player1.outgoingAttacks().length).toBe(0);
+
     // Verify attackCancel was NOT called
     expect(attackCancelSpy).not.toHaveBeenCalled();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/AttackRetreatStats.test.ts` around lines 77 - 86, The test's loop can
end due to maxTicks expiring rather than the attack completing; after the while
loop that uses player1.outgoingAttacks(), maxTicks and game.executeNextTick(),
add an explicit guard that verifies the loop exited because the attack finished
(e.g., assert player1.outgoingAttacks().length === 0 or fail if maxTicks === 0)
before asserting attackCancelSpy.not.toHaveBeenCalled(); reference
outgoingAttacks(), maxTicks, attackCancelSpy, and game.executeNextTick() to
locate where to add this check.
src/server/ClientMsgRateLimiter.ts (1)

32-41: 💤 Low value

Optional: track a running total to skip the per-message reduce.

Each check() call walks the whole byteEvents array twice (eviction + reduce). With the current rate caps the array stays small, so this is purely cosmetic, but a running counter keeps things O(1) per message and reads a bit cleaner.

♻️ Optional running-total variant
 interface ClientBucket {
   perSecond: RateLimiter;
   perMinute: RateLimiter;
   byteEvents: Array<{ at: number; bytes: number }>;
+  totalBytes: number;
 }
@@
     const now = Date.now();
     const cutoff = now - BYTE_WINDOW_MS;
     while (bucket.byteEvents.length > 0 && bucket.byteEvents[0].at < cutoff) {
-      bucket.byteEvents.shift();
+      const evicted = bucket.byteEvents.shift()!;
+      bucket.totalBytes -= evicted.bytes;
     }

     bucket.byteEvents.push({ at: now, bytes });
+    bucket.totalBytes += bytes;

-    const totalBytes = bucket.byteEvents.reduce((sum, e) => sum + e.bytes, 0);
-    if (totalBytes >= TOTAL_BYTES) return "kick";
+    if (bucket.totalBytes >= TOTAL_BYTES) return "kick";
@@
       byteEvents: [],
+      totalBytes: 0,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/ClientMsgRateLimiter.ts` around lines 32 - 41, The current check()
implementation walks bucket.byteEvents twice (eviction loop + reduce) which is
unnecessary; add and maintain a running counter (e.g., bucket.totalBytes)
initialized when a bucket is created, decrement it when evicting old events in
the while loop (subtract the shifted event.bytes) and increment it when pushing
the new {at, bytes} event, then replace the reduce with a constant-time
comparison against TOTAL_BYTES; update any bucket-creation logic to set
totalBytes = 0 and ensure the eviction path correctly keeps totalBytes in sync
to avoid drift.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/server/ClientMsgRateLimiter.ts`:
- Around line 32-41: The current check() implementation walks bucket.byteEvents
twice (eviction loop + reduce) which is unnecessary; add and maintain a running
counter (e.g., bucket.totalBytes) initialized when a bucket is created,
decrement it when evicting old events in the while loop (subtract the shifted
event.bytes) and increment it when pushing the new {at, bytes} event, then
replace the reduce with a constant-time comparison against TOTAL_BYTES; update
any bucket-creation logic to set totalBytes = 0 and ensure the eviction path
correctly keeps totalBytes in sync to avoid drift.

In `@src/server/Worker.ts`:
- Around line 247-256: The optional chaining on callerPersistentId in the log
call is unnecessary because result.type === "error" was already handled and
result.persistentId is a string; update the log.warn invocation to call
substring directly on callerPersistentId (remove the `?.`) so the code reflects
the non-nullable type; locate this in the start_game handling around the
variables callerPersistentId, existingClientId, game.getClientIdForPersistentId
and game.gameInfo().lobbyCreatorClientID, and adjust the log.warn message
accordingly.
- Around line 243-263: Race window between game.hasStarted() and game.start()
could let GameManager.tick() start the game concurrently; add a defensive guard
by re-checking state and catching errors before calling start: after verifying
caller identity (using getClientIdForPersistentId and
game.gameInfo().lobbyCreatorClientID), if game.hasStarted() return 409,
otherwise call game.start() inside a try-catch and on catch log the error and
respond with a 409/500 as appropriate; alternatively perform an atomic re-check
(if (!game.hasStarted()) try { game.start() } catch (e) { ... }) to ensure
idempotent, safe behavior even if start() changes later.

In `@tests/AttackRetreatStats.test.ts`:
- Around line 77-86: The test's loop can end due to maxTicks expiring rather
than the attack completing; after the while loop that uses
player1.outgoingAttacks(), maxTicks and game.executeNextTick(), add an explicit
guard that verifies the loop exited because the attack finished (e.g., assert
player1.outgoingAttacks().length === 0 or fail if maxTicks === 0) before
asserting attackCancelSpy.not.toHaveBeenCalled(); reference outgoingAttacks(),
maxTicks, attackCancelSpy, and game.executeNextTick() to locate where to add
this check.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7013e87e-ddbc-41f4-9a42-24a1dfa3493c

📥 Commits

Reviewing files that changed from the base of the PR and between 8c8c2e4 and 5a1cd01.

📒 Files selected for processing (4)
  • src/server/ClientMsgRateLimiter.ts
  • src/server/Worker.ts
  • tests/AttackRetreatStats.test.ts
  • tests/core/executions/WinCheckExecution.test.ts

berkelmali added 3 commits May 6, 2026 19:08
This commit addresses the following:

- SEC-02: Added auth to start_game endpoint

- SEC-01: Rate limiter now uses sliding window for total bytes

- BUG-02: Added readyState check to endTurn WebSocket broadcast

- BUG-06: Guarded against division by zero in WinCheck execution

- BUG-01: Fixed attack retreat stats recording sequence

- BUG-03: Fixed typo in websocket error log

- SUG-08: Removed duplicate express.json() middleware

- SUG-06: Improved disconnectedTimeout readability

- BUG-07: Clarified bfs() DFS behavior via JSDoc
- Fix Prettier formatting for WinCheckExecution.ts and Worker.ts
- Replace floating-point division with integer cross-multiplication in WinCheckExecution for determinism
- Change error log level from info to error for caught exceptions in GameServer.ts
- Move auth check before game lookup in start_game handler (prevents state probing)
- Return 409 when game already started
- Replace fixed-window with rolling-window byte accounting in rate limiter
- Add regression tests for attack retreat/cancel stats path
- Add tests for zero-fallout edge case in WinCheckExecution (FFA + Team)
- Remove unnecessary optional chain on callerPersistentId
- Wrap game.start() in try-catch for race condition safety
- Use running totalBytes counter in rate limiter for O(1) check
- Add guard assertion in retreat test to verify attack completion
@berkelmali
Copy link
Copy Markdown
Contributor Author

Hey @VariableVince, thanks for the heads-up! I've now addressed all CodeRabbit feedback across all review rounds:

  • Auth reorder: Moved Bearer token verification before game lookup to prevent unauthenticated state probing
  • Race-safe start: Wrapped game.start() in try-catch with hasStarted() re-check for idempotent behavior
  • Rolling-window rate limiting: Replaced fixed-window with true sliding window + O(1) running-total counter
  • Tests added: Retreat/cancel stats regression test + zero-fallout edge case tests (FFA + Team)
  • Nitpicks resolved: Removed unnecessary optional chain, added guard assertion in test

Also rebased onto latest origin/main (cdb7c18a). The only remaining CI blocker is Has Milestone which requires a maintainer to assign. Could someone assign the v32 milestone? Thanks! 🙏

@babyboucher
Copy link
Copy Markdown
Contributor

berkelmali@e0cc50d

Why are we reverting this commit?

Accidental revert of commit e0cc50d during audit changes.
The if-guard must be removed so deselecting Host cheats
actually disables all active cheats.
@berkelmali
Copy link
Copy Markdown
Contributor Author

Sorry about that @babyboucher! The revert was unintentional - it happened during the initial audit changes when I modified GameServer.ts. I've restored your fix in commit 0d8f233. The if (gameConfig.hostCheats !== undefined) guard is now removed again, matching your original fix. Thanks for catching it! 🙏

@Zixer1
Copy link
Copy Markdown
Contributor

Zixer1 commented May 7, 2026

@berkelmali I suggest you create a branch and merge from there and not from your main, it will be cleaner when you work on multiple pr

My take on the different issues:
BUG-01 (retreat stats): Not a bug, _retreated survives delete(), the field is still there on the object.
SEC-02 (start_game endpoint): I am not sure what is being fixed here, you are adding a new endpoint that is not rlly being used from my understanding, looks like dead code.
BUG-06 (div-by-zero WinCheck): This is technically a real bug, if all tiles have fallout the division gives Infinity and Infinity > 80 is true in JS, so it'd falsely declare a winner. Extremely unlikely in practice though since someone would win way before that so might not be worth fluffing up the code for
BUG-02 (readyState check): This one is useful imo
SEC-01 (sliding window rate limiter): Actually useful, old counter never reset so long sessions could from what I am seeing false-kick players.
BUG-03 (typo fix): "handline" → "handling", sure seems like a typo indeed
SUG-08 (duplicate middleware): I would guess nobody noticed because the second one was a no-op either way..
SUG-06 (disconnectedTimeout): 1 * 30 * 1000 → 30_000 seems more like a style preference (could be easier to read ig).
BUG-07 (bfs JSDoc): not exactly sure what changes.

@evanpelle
Copy link
Copy Markdown
Collaborator

please create a PR for each bug

@evanpelle evanpelle closed this May 7, 2026
@github-project-automation github-project-automation Bot moved this from Development to Complete in OpenFront Release Management May 7, 2026
@berkelmali
Copy link
Copy Markdown
Contributor Author

Closing this PR as requested by maintainers. Changes have been split into individual PRs:

Thanks for the feedback @VariableVince!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Complete

Development

Successfully merging this pull request may close these issues.

6 participants