Skip to content

Conversation

@evanpelle
Copy link
Collaborator

If this PR fixes an issue, link it below. If not, delete these two lines.
Resolves #(issue number)

Description:

Describe the PR.

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:

DISCORD_USERNAME

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 24, 2025

Walkthrough

A comprehensive refactoring moves type definitions (UnitType, PlayerType, MessageType) from the Game module to a new GameUpdates module, introduces a Protocol Buffers schema for game state serialization, and restructures update payloads to use nested objects instead of flat fields throughout the codebase.

Changes

Cohort / File(s) Summary
Build Configuration
package.json
Added npm script gen-proto and devDependencies (@bufbuild/protobuf, ts-proto) to support TypeScript protobuf code generation.
Protocol Buffers Schema
src/core/game/GameUpdates.proto
New schema file defining enums (PlayerType, UnitType, TrainType, GameUpdateType, MessageType, MessageCategory, RailType) and message types for all game update payloads (GameUpdate, GameUpdates, GameUpdateViewData, UnitUpdate, PlayerUpdate, AllianceRequestUpdate, etc.) with nested structure and oneof polymorphism.
Type Export Reorganization
src/core/game/Game.ts, src/core/GameRunner.ts, src/core/Schemas.ts, src/core/StatsSchemas.ts
Removed exports of UnitType, PlayerType, MessageType, TrainType, NameViewData from Game module; moved to GameUpdates module. Updated executeNextTick return type from GameUpdates to GameUpdateViewData.
Core Game Model Updates
src/core/game/GameImpl.ts, src/core/game/GameView.ts, src/core/game/PlayerImpl.ts, src/core/game/UnitImpl.ts
Restructured GameUpdateViewData with nested updates arrays; renamed ID fields (ownerID → ownerId, clientID → clientId, smallID → smallId); changed update payloads to nest under objects (e.g., hash: { tick, hash }, allianceRequestReply: { request, accepted }); updated hashing logic and gold as BigInt.
Execution Layer Import Refactoring
src/core/execution/*.ts, src/core/execution/utils/*.ts, src/core/execution/alliance/*.ts
Moved imports of UnitType, PlayerType, MessageType, TrainType from Game to GameUpdates across 30+ execution files; no logic changes beyond import sources.
Railroad & Rail Type Updates
src/core/execution/RailroadExecution.ts, src/core/game/Railroad.ts, src/core/game/TrainStation.ts
Renamed RailType enum members (VERTICAL → vertical, HORIZONTAL → horizontal, etc.); updated RailroadEvent payload structure to nest under railroad object (railroad: { isActive, railTiles }).
Alliance Request Type Updates
src/core/game/AllianceRequestImpl.ts
Changed toUpdate() return type from AllianceRequestUpdate to GameUpdate; updated payload to nest fields under allianceRequest object with camelCase IDs (requestorId, recipientId).
Other Core Game Files
src/core/game/{RailNetworkImpl, StatsImpl, TeamAssignment, TransportShipUtils, UnitGrid}.ts
Relocated UnitType/PlayerType imports from Game to GameUpdates; no behavioral changes.
Configuration & Utility Updates
src/core/{Util, configuration/*.ts}
Moved UnitType, PlayerType imports to GameUpdates; updated createRandomName to accept PlayerType instead of string.
Client Game Runner
src/client/ClientGameRunner.ts
Updated to access Hash/Win updates via nested updates arrays (gu.updates[GameUpdateType.Hash].updates); parse winner and stats as JSON; pass Winner and AllPlayersStats objects to createPartialGameRecord.
Client Graphics Layer Updates
src/client/graphics/layers/{AlertFrame, ChatDisplay, EventsDisplay, FxLayer, GameRightSidebar, NukeTrajectoryPreviewLayer, PlayerInfoOverlay, ...}.ts
Widespread updates: moved UnitType/MessageType/PlayerType imports; refactored update access patterns to use nested .updates arrays; renamed ID fields to camelCase; adjusted event filtering and handling logic for new payload structures.
Client Graphics Field Name Updates
src/client/graphics/layers/{RailroadSprites, RailroadLayer, StructureLayer, TerritoryLayer, UILayer, UnitLayer}.ts
Updated enum key usage (RailType.VERTICAL → RailType.vertical); changed update extraction to handle nested unit/railroad objects (unit.unit!.id, railroad object); adjusted map key types from string to UnitType.
SAM & Emoji Updates
src/client/graphics/layers/{SAMRadiusLayer, PlayerIcons}.ts
Consolidated GameUpdateType/UnitType imports; refactored unit update iteration to use nested updates; updated emoji logic to use allPlayers indicator and emoji field instead of message.
Win & Game Modal Updates
src/client/graphics/layers/WinModal.ts, src/client/InputHandler.ts, src/client/Transport.ts, src/client/Utils.ts
Updated WinModal to access win updates via nested structure and JSON-parse winner/stats; refactored update access patterns across input/transport/utilities.
Client UI Components
src/client/{HostLobbyModal, SinglePlayerModal, components/LobbyTeamView}.ts
Relocated UnitType/PlayerType imports from Game to GameUpdates; no logic changes.
Client Graphics Components & Calculators
src/client/graphics/{NameBoxCalculator, PlayerIcons, SpriteLoader, UIState}.ts, src/client/utilities/RenderUnitTypeOptions.ts
Import source changes for NameViewData, UnitType, PlayerType to GameUpdates; minor type assertion adjustment in test.
Conquest & Effect Updates
src/client/graphics/fx/ConquestFx.ts
Added PlayerView import; changed player lookup to use game.playerBySmallID with explicit cast instead of game.player.
Test Suite Import Updates
tests/*.ts, tests/client/**/*.ts, tests/core/**/*.ts, tests/economy/**/*.ts, tests/nukes/**/*.ts, tests/util/**/*.ts
Updated 35+ test files to import PlayerType and UnitType from GameUpdates instead of Game; one test updated to use safe optional chaining for Win updates.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes


Possibly related PRs

  • #1159: Modifies game update data model (GameUpdate/GameUpdateType entries, nested GameUpdates, RailroadEvent/BonusEvent) and related consumers (GameImpl, FxLayer, RailroadLayer execution code).
  • #2358: Updates AlertFrame.ts attack detection and outgoing-attack tracking logic (retaliation window, pruning seen attacks).
  • #1838: Modifies SAMLauncherExecution/SAMTargetingSystem constructor and ownership checks affecting the same SAM targeting logic.

Suggested labels

Backend Feature - New, Refactor


Suggested reviewers

  • scottanderson

Poem

📋 From flat fields to nested flows,
Protobuf brings order where chaos grows;
A hundred imports dance in place,
Types find their rightful space.
Camel-cased IDs lead the way,
Refactored code will surely stay. 🎯

Pre-merge checks

❌ Failed checks (2 warnings, 1 inconclusive)
Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description is a placeholder template with incomplete information and no actual description of the changes implemented in this pull request. Replace the template with a clear description of the changes: explain the introduction of GameUpdates.proto, the reorganization of enums/types, the structural changes to update payloads, and the migration path for all affected files.
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Title check ❓ Inconclusive The title 'proto' is extremely vague and does not clearly describe the main changes in this substantial refactoring of game update structures. Use a more descriptive title that captures the essence of the changes, such as 'Refactor: Move game update types to GameUpdates proto schema' or 'Reorganize: Export enums and types from new GameUpdates module'.

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
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: 6

Caution

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

⚠️ Outside diff range comments (7)
src/client/graphics/layers/WinModal.ts (1)

320-345: Inconsistent handling of wu.winner — treated as both string and array.

The code calls JSON.parse(wu.winner) on lines 327 and 345, which means wu.winner is a JSON string. However, the code also accesses wu.winner[0] (line 325) and wu.winner[1] (lines 329, 334, 340) as if it were already an array.

If wu.winner is a JSON string like '["team", "red"]':

  • wu.winner[0] returns '[' (first character), not "team"
  • The condition wu.winner[0] === "team" will always be false
  • wu.winner[1] returns '"', not the team name

You need to parse the winner first, then use the parsed result for comparisons.

🔎 Proposed fix
     winUpdates.forEach((update) => {
       const wu = update.win!;
+      const parsedWinner = wu.winner !== undefined ? JSON.parse(wu.winner) : undefined;
+      const parsedStats = JSON.parse(wu.stats);

       if (wu.winner === undefined) {
         // ...
-      } else if (wu.winner[0] === "team") {
-        this.eventBus.emit(
-          new SendWinnerEvent(JSON.parse(wu.winner), JSON.parse(wu.stats)),
-        );
-        if (wu.winner[1] === this.game.myPlayer()?.team()) {
+      } else if (parsedWinner[0] === "team") {
+        this.eventBus.emit(
+          new SendWinnerEvent(parsedWinner, parsedStats),
+        );
+        if (parsedWinner[1] === this.game.myPlayer()?.team()) {
           this._title = translateText("win_modal.your_team");
           this.isWin = true;
         } else {
           this._title = translateText("win_modal.other_team", {
-            team: wu.winner[1],
+            team: parsedWinner[1],
           });
           this.isWin = false;
         }
         this.show();
       } else {
-        const winner = this.game.playerByClientID(wu.winner[1]);
+        const winner = this.game.playerByClientID(parsedWinner[1]);
         if (!winner?.isPlayer()) return;
         const winnerClient = winner.clientID();
         if (winnerClient !== null) {
           this.eventBus.emit(
-            new SendWinnerEvent(["player", winnerClient], JSON.parse(wu.stats)),
+            new SendWinnerEvent(["player", winnerClient], parsedStats),
           );
         }
src/core/GameRunner.ts (1)

94-147: Wire playerNameViewData into updates and reset isExecuting on errors

Two issues here:

  1. this.playerViewData is updated but never copied into the tick payload. GameView.update expects gu.playerNameViewData[pu.id] to be populated for name placement, so names and territory overlays will be wrong or missing.

  2. In the catch block, this.isExecuting stays true, so any exception during a tick permanently blocks further ticks.

You can fix both with a small change:

Suggested patch
   public executeNextTick() {
@@
-    this.isExecuting = true;
+    this.isExecuting = true;
@@
-    try {
-      updates = this.game.executeNextTick();
-    } catch (error: unknown) {
+    try {
+      updates = this.game.executeNextTick();
+    } catch (error: unknown) {
       if (error instanceof Error) {
         console.error("Game tick error:", error.message);
         console.error("Stack trace:", error.stack);
         this.callBack({
           errMsg: error.message,
           stack: error.stack,
         } as ErrorUpdate);
       } else {
         console.error("Game tick error:", error);
       }
-      return;
+      this.isExecuting = false;
+      return;
     }
@@
-    if (this.game.ticks() < 3 || this.game.ticks() % 30 === 0) {
+    if (this.game.ticks() < 3 || this.game.ticks() % 30 === 0) {
       this.game.players().forEach((p) => {
         this.playerViewData[p.id()] = placeName(this.game, p);
       });
     }
@@
-    this.callBack(updates);
-    this.isExecuting = false;
+    // Attach computed name view data to the tick payload.
+    updates.playerNameViewData = this.playerViewData;
+
+    this.callBack(updates);
+    this.isExecuting = false;
   }

Also applies to: 153-173

src/client/graphics/layers/EventsDisplay.ts (2)

358-388: Add a finite number check before converting goldAmount to BigInt

The code checks if goldAmount !== undefined, but does not guard against non-finite values like NaN or Infinity. If these values reach the BigInt() call on line 369, the code will throw and break the message display layer.

Add a simple check to skip invalid values:

    if (event.goldAmount !== undefined) {
+     if (!Number.isFinite(event.goldAmount)) {
+       return;
+     }
      const hasChanged = this.latestGoldAmount !== BigInt(event.goldAmount);
      this.latestGoldAmount = BigInt(event.goldAmount);

This makes the UI robust even if a corrupt or unexpectedly large value is received.


172-188: Pass unwrapped GameUpdate payloads to event handlers

game.updatesSinceLastTick() now returns a map of GameUpdateType → GameUpdates, where GameUpdates.updates is an array of GameUpdate wrappers (each with nested fields like displayMessage, emoji, allianceRequest, etc.).

In tick() at lines 224–229, you currently pass these wrappers directly to handlers:

for (const [ut, fn] of this.updateMap) {
  updates[ut]?.updates.forEach(fn as (event: unknown) => void);
}

But handlers like onDisplayMessageEvent(event: DisplayMessageUpdate) expect the unwrapped payload. When you pass the wrapper, fields like event.playerId, update.recipientId, and event.goldAmount are undefined, so:

  • display message and chat events fail the player check and are silently dropped
  • alliance requests, target, emoji, and incoming unit events never show up
  • the as (event: unknown) cast hides this type mismatch

Unwrap each GameUpdate before calling the handler. Check other layers like ChatDisplay.ts and AlertFrame.ts for the correct pattern:

updates[GameUpdateType.DisplayEvent]?.updates.forEach((u) => {
  if (u.displayMessage) this.onDisplayMessageEvent(u.displayMessage);
});

Apply this to all nine entries in updateMap.

src/core/game/PlayerImpl.ts (1)

579-597: Fix emoji cooldown for direct recipients

In canSendEmoji, the filter using msg.allPlayers ?? msg.recipientId === recipientID is broken. Since allPlayers is always a boolean (never null/undefined), the ?? operator never evaluates the right side. When allPlayers is false for 1:1 emojis, the filter returns false and skips those messages entirely—meaning the cooldown check never runs for direct recipients.

The fix is to branch on the recipient type instead of using ??:

   const recipientID =
-      recipient === AllPlayers ? AllPlayers : recipient.smallID();
-    const prevMsgs = this.outgoingEmojis_.filter((msg) => {
-      return msg.allPlayers ?? msg.recipientId === recipientID;
-    });
+      recipient === AllPlayers ? AllPlayers : recipient.smallID();
+    const prevMsgs = this.outgoingEmojis_.filter((msg) =>
+      recipient === AllPlayers
+        ? msg.allPlayers
+        : msg.recipientId === recipientID,
+    );

This restores the intended per-recipient cooldown behavior.

src/core/game/GameImpl.ts (2)

740-761: Omit goldAmount from the update when it's undefined

The current code sends Number(undefined) which serializes to NaN for the gold_amount field:

displayMessage: {
  message: message,
  messageType: type,
  playerId: id ?? undefined,
  goldAmount: Number(goldAmount),
  params: params ?? {},
},

This causes the proto encoder to emit gold_amount with a nonsensical value even when there is no gold amount. The proto schema marks this field as optional int64 gold_amount, so it should be omitted entirely when not provided.

Update to only set goldAmount when present:

displayMessage: {
  message: message,
  messageType: type,
  playerId: id ?? undefined,
  goldAmount: goldAmount !== undefined ? Number(goldAmount) : undefined,
  params: params ?? {},
},

82-88: Set gameViewData.tick and convert helpers to function declarations

Three issues need fixing:

  1. tick always stays 0: gameViewData.tick defaults to 0 and is never updated before being returned in executeNextTick(). Since GameView reads lastUpdate.tick to determine the current tick, clients will always see tick 0, breaking spawn-phase logic and any tick-based UI timing.

  2. goldAmount converts undefined to NaN: When displayMessage() is called without a goldAmount parameter (8 calls across UnitImpl, PlayerImpl, and AttackExecution), the code does Number(goldAmount) which converts undefined to NaN. This sends invalid data to the client.

  3. TDZ warning: private gameViewData = createGameUpdateViewData(); at line 82 references createGameUpdateViewData, which is declared as a const at line 1023. This triggers the noInvalidUseBeforeDeclaration linter.

Fix all three by converting the helper functions to declarations (which are hoisted) and setting the tick explicitly before incrementing:

-  private gameViewData = createGameUpdateViewData();
+  private gameViewData = createGameUpdateViewData();

   executeNextTick(): GameUpdateViewData {
     this.gameViewData = createGameUpdateViewData();
     // ... executions ...
     if (this.ticks() % 10 === 0) {
       this.addUpdate({
         type: GameUpdateType.Hash,
         hash: {
           tick: this.ticks(),
           hash: this.hash(),
         },
       });
     }
+    // Export the tick number associated with these updates.
+    this.gameViewData.tick = this._ticks;
+
     this._ticks++;
     return this.gameViewData;
   }

-const createGameUpdatesMap = (): Record<GameUpdateType, GameUpdates> => {
-  const map = {} as Record<GameUpdateType, GameUpdates>;
-  Object.values(GameUpdateType)
-    .filter((key) => !isNaN(Number(key)))
-    .forEach((key) => {
-      map[key as GameUpdateType] = {
-        type: key as GameUpdateType,
-        updates: [],
-      };
-    });
-  return map;
-};
-
-const createGameUpdateViewData = (): GameUpdateViewData => {
-  return {
-    tick: 0,
-    updates: createGameUpdatesMap(),
-    tileUpdates: [],
-    playerNameViewData: {},
-    tickExecutionDuration: undefined,
-  };
-};
+function createGameUpdatesMap(): Record<GameUpdateType, GameUpdates> {
+  const map = {} as Record<GameUpdateType, GameUpdates>;
+  Object.values(GameUpdateType)
+    .filter((key) => !isNaN(Number(key)))
+    .forEach((key) => {
+      map[key as GameUpdateType] = {
+        type: key as GameUpdateType,
+        updates: [],
+      };
+    });
+  return map;
+}
+
+function createGameUpdateViewData(): GameUpdateViewData {
+  return {
+    tick: 0,
+    updates: createGameUpdatesMap(),
+    tileUpdates: [],
+    playerNameViewData: {},
+    tickExecutionDuration: undefined,
+  };
+}
🧹 Nitpick comments (3)
tests/client/graphics/UILayer.test.ts (1)

54-54: Consider creating the event object using the UnitSelectionEvent constructor instead of a plain object.

The mock at lines 46-52 is a plain object { isSelected: true, unit }, but UnitSelectionEvent is a class. The double cast as unknown as UnitSelectionEvent is needed because TypeScript treats class instances differently from plain objects. You can avoid this cast by instantiating the class: new UnitSelectionEvent(unit, true) instead of a plain object. This is clearer and removes the type safety bypass.

src/client/graphics/layers/StructureLayer.ts (1)

101-108: Minor: Redundant config lookup.

Line 105 looks up this.unitConfigs[unitType]! but config from line 103 already holds the same value.

Suggested simplification
   private loadIconData() {
     AllUnitTypes.forEach((unitType) => {
       const config = this.unitConfigs[unitType];
       if (config) {
-        this.loadIcon(unitType, this.unitConfigs[unitType]!);
+        this.loadIcon(unitType, config);
       }
     });
   }
src/core/game/Game.ts (1)

33-36: Restore type link between buildUnit’s type and params

Right now buildUnit accepts params: UnitParams<keyof UnitParamsMap>, which is a union of all param shapes. This gives up compile‑time safety that the params match the selected unit type.

You can keep runtime behavior the same but recover stronger typing like this:

Suggested type tightening
-export type UnitParams<T extends keyof UnitParamsMap> = UnitParamsMap[T];
+export type UnitParams<T extends keyof UnitParamsMap> = UnitParamsMap[T];
@@
-  buildUnit<T extends UnitType>(
-    type: T,
+  buildUnit<T extends keyof UnitParamsMap>(
+    type: T,
     spawnTile: TileRef,
-    params: UnitParams<keyof UnitParamsMap>,
+    params: UnitParams<T>,
   ): Unit;

This keeps the API simple but ensures, for example, that TransportShip builds cannot accidentally pass Warship params.

Also applies to: 268-272, 549-553

Comment on lines +333 to 335
if (gu.updates[GameUpdateType.Win]?.updates.length > 0) {
this.saveGame(gu.updates[GameUpdateType.Win][0]);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Bug: Missing .updates before array access.

Line 334 accesses gu.updates[GameUpdateType.Win][0] but should be gu.updates[GameUpdateType.Win].updates[0] based on the pattern used elsewhere (e.g., line 317).

Fix
       if (gu.updates[GameUpdateType.Win]?.updates.length > 0) {
-        this.saveGame(gu.updates[GameUpdateType.Win][0]);
+        this.saveGame(gu.updates[GameUpdateType.Win].updates[0]);
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (gu.updates[GameUpdateType.Win]?.updates.length > 0) {
this.saveGame(gu.updates[GameUpdateType.Win][0]);
}
if (gu.updates[GameUpdateType.Win]?.updates.length > 0) {
this.saveGame(gu.updates[GameUpdateType.Win].updates[0]);
}
🤖 Prompt for AI Agents
In src/client/ClientGameRunner.ts around lines 333 to 335, the code incorrectly
indexes gu.updates[GameUpdateType.Win][0] but the structure uses a nested
.updates array (as checked on line 333); change the access to
gu.updates[GameUpdateType.Win].updates[0] so you retrieve the first update
object correctly, keeping the existing guard that checks .updates.length before
calling saveGame.

Comment on lines +79 to 99
for (const update of updates?.[GameUpdateType.DisplayEvent]?.updates ??
[]) {
const msg = update.displayMessage!;
if (msg.messageType === MessageType.CHAT) {
const myPlayer = this.game.myPlayer();
if (
msg.playerId !== null &&
(!myPlayer || myPlayer.smallID() !== msg.playerId)
) {
continue;
}

this.chatEvents = [
...this.chatEvents,
{
description: msg.message,
unsafeDescription: true,
createdAt: this.game.ticks(),
},
];
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Non-null assertion on displayMessage could throw at runtime.

Line 81 uses update.displayMessage! but if the update object lacks this field, you get a crash. Consider a guard or optional chaining:

Suggested fix
     for (const update of updates?.[GameUpdateType.DisplayEvent]?.updates ??
       []) {
-      const msg = update.displayMessage!;
+      const msg = update.displayMessage;
+      if (!msg) continue;
       if (msg.messageType === MessageType.CHAT) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (const update of updates?.[GameUpdateType.DisplayEvent]?.updates ??
[]) {
const msg = update.displayMessage!;
if (msg.messageType === MessageType.CHAT) {
const myPlayer = this.game.myPlayer();
if (
msg.playerId !== null &&
(!myPlayer || myPlayer.smallID() !== msg.playerId)
) {
continue;
}
this.chatEvents = [
...this.chatEvents,
{
description: msg.message,
unsafeDescription: true,
createdAt: this.game.ticks(),
},
];
}
for (const update of updates?.[GameUpdateType.DisplayEvent]?.updates ??
[]) {
const msg = update.displayMessage;
if (!msg) continue;
if (msg.messageType === MessageType.CHAT) {
const myPlayer = this.game.myPlayer();
if (
msg.playerId !== null &&
(!myPlayer || myPlayer.smallID() !== msg.playerId)
) {
continue;
}
this.chatEvents = [
...this.chatEvents,
{
description: msg.message,
unsafeDescription: true,
createdAt: this.game.ticks(),
},
];
}
🤖 Prompt for AI Agents
In src/client/graphics/layers/ChatDisplay.ts around lines 79 to 99, the code
uses a non-null assertion update.displayMessage! which can throw if
displayMessage is missing; remove the non-null assertion and add a guard (e.g.,
if (!update.displayMessage) continue) or use optional chaining to skip updates
without displayMessage before accessing properties like messageType and
playerId, then proceed to push chatEvents only when displayMessage is present
and of type CHAT.

Comment on lines +318 to +319
const winUpdates =
updates !== null ? updates[GameUpdateType.Win].updates : [];
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Missing optional chaining causes potential runtime error.

If updates[GameUpdateType.Win] is undefined (likely for most ticks without a win), accessing .updates will throw TypeError: Cannot read property 'updates' of undefined.

🔎 Proposed fix
-    const winUpdates =
-      updates !== null ? updates[GameUpdateType.Win].updates : [];
+    const winUpdates =
+      updates !== null ? (updates[GameUpdateType.Win]?.updates ?? []) : [];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const winUpdates =
updates !== null ? updates[GameUpdateType.Win].updates : [];
const winUpdates =
updates !== null ? (updates[GameUpdateType.Win]?.updates ?? []) : [];
🤖 Prompt for AI Agents
In src/client/graphics/layers/WinModal.ts around lines 318 to 319, the code
accesses updates[GameUpdateType.Win].updates directly which will throw if
updates[GameUpdateType.Win] is undefined; change the expression to safely access
the nested property (e.g., use optional chaining or a fallback): compute
winUpdates from updates?.[GameUpdateType.Win]?.updates or from (updates &&
updates[GameUpdateType.Win] && updates[GameUpdateType.Win].updates) and default
to an empty array so winUpdates is always defined.

const winUpdates =
updates !== null ? updates[GameUpdateType.Win].updates : [];
winUpdates.forEach((update) => {
const wu = update.win!;
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Non-null assertion on update.win may cause runtime error.

The ! operator assumes update.win is always defined. If an update lacks the win property, this will throw. Consider adding a guard or using optional chaining with an early return.

🔎 Proposed fix
     winUpdates.forEach((update) => {
-      const wu = update.win!;
+      const wu = update.win;
+      if (wu === undefined) return;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const wu = update.win!;
winUpdates.forEach((update) => {
const wu = update.win;
if (wu === undefined) return;
🤖 Prompt for AI Agents
In src/client/graphics/layers/WinModal.ts around line 321, the code uses a
non-null assertion const wu = update.win! which can throw if update.win is
undefined; instead, add a guard or early return that checks for update.win (e.g.
if (!update.win) return; or handle the missing case) and then assign without the
bang (const wu = update.win), or use optional chaining where appropriate so the
code only accesses wu when it exists.

(emoji) =>
emoji.recipientID === AllPlayers ||
emoji.recipientID === myPlayer?.smallID(),
emoji.allPlayers ?? emoji.recipientId === myPlayer?.smallID(),
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Check operator precedence in recipient filter.

The nullish coalescing operator ?? has lower precedence than ===, so this parses as:

emoji.allPlayers ?? (emoji.recipientId === myPlayer?.smallID())

If emoji.allPlayers is explicitly false (not null or undefined), the recipientId comparison won't run. Consider:

🔎 Suggested fix
-          emoji.allPlayers ?? emoji.recipientId === myPlayer?.smallID(),
+          emoji.allPlayers || emoji.recipientId === myPlayer?.smallID(),

Or if allPlayers can be nullable:

-          emoji.allPlayers ?? emoji.recipientId === myPlayer?.smallID(),
+          (emoji.allPlayers ?? false) || emoji.recipientId === myPlayer?.smallID(),
🤖 Prompt for AI Agents
In src/client/graphics/PlayerIcons.ts around line 117, the expression uses ??
and === without parentheses so precedence makes it parse as emoji.allPlayers ??
(emoji.recipientId === myPlayer?.smallID()); disambiguate the intent by adding
parentheses: if you want to treat a defined false/true in allPlayers as
authoritative use (emoji.allPlayers ?? (emoji.recipientId ===
myPlayer?.smallID())); if you want allPlayers null/undefined to fall through to
the recipient check but treat false as a fallthrough value use (emoji.allPlayers
?? false) || emoji.recipientId === myPlayer?.smallID(); update the expression
accordingly to make the precedence explicit.

} from "../../../src/client/graphics/layers/RadialMenuElements";
import { UnitType } from "../../../src/core/game/Game";
import { TileRef } from "../../../src/core/game/GameMap";
import { UnitType } from "../../../src/core/game/GameUpdates";
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Import mismatch in mock setup.

Line 13 imports UnitType from GameUpdates, but line 22's mock still loads it from the Game module. If UnitType has been moved to GameUpdates, this mock will fail or use the wrong type.

🔎 Proposed fix
 jest.mock("../../../src/client/graphics/layers/BuildMenu", () => {
-  const { UnitType } = jest.requireActual("../../../src/core/game/Game");
+  const { UnitType } = jest.requireActual("../../../src/core/game/GameUpdates");
   return {

Also applies to: 22-22

🤖 Prompt for AI Agents
In tests/client/graphics/RadialMenuElements.test.ts around line 13 (and the mock
at line 22), the test imports UnitType from "../../../src/core/game/GameUpdates"
but the mock still references UnitType from the old Game module; update the mock
to import or reference UnitType from the same
"../../../src/core/game/GameUpdates" location so both the test and the mock use
the same source, and scan the file for any other imports/mocks referencing the
old Game path and change them to the GameUpdates path to keep types consistent.

@evanpelle evanpelle closed this Dec 25, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants