Skip to content

Uichanges#3149

Closed
ryanbarlow97 wants to merge 3 commits intomainfrom
uichanges
Closed

Uichanges#3149
ryanbarlow97 wants to merge 3 commits intomainfrom
uichanges

Conversation

@ryanbarlow97
Copy link
Contributor

@ryanbarlow97 ryanbarlow97 commented Feb 7, 2026

Description:

Simplifies UI - only tailwind changes / div->button fixes

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:

w.o.n

@ryanbarlow97 ryanbarlow97 requested a review from a team as a code owner February 7, 2026 20:20
Copilot AI review requested due to automatic review settings February 7, 2026 20:20
@ryanbarlow97 ryanbarlow97 changed the base branch from main to rejoingame February 7, 2026 20:21
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 7, 2026

Walkthrough

A comprehensive refactoring consolidates game configuration state and UI into a new GameConfigForm component. HostLobbyModal and SinglePlayerModal are refactored to eliminate duplicated per-property state, replacing it with centralized form management. Supporting utilities are reorganized: two modules are removed and replaced with a unified ConfigCards utility. Related components are updated to align with new rendering patterns.

Changes

Cohort / File(s) Summary
Game Configuration Component
src/client/components/GameConfigForm.ts
New LitElement component providing centralized game configuration state, UI rendering, and public API (getConfig, resolveSelectedMap, reset, hasOptionsChanged). Includes host-variant options, default configs, and event dispatching for config changes.
Modal Refactoring
src/client/HostLobbyModal.ts, src/client/SinglePlayerModal.ts
Replaced extensive per-property state with GameConfigForm integration. HostLobbyModal adds eventBus subscription for lobby updates and simplified lifecycle. SinglePlayerModal removes local option fields and refactors startGame to derive config from form. createLobby signature simplified to accept only gameID.
UI Utilities Reorganization
src/client/utilities/ConfigCards.ts, src/client/utilities/RenderToggleInputCard.ts, src/client/utilities/RenderUnitTypeOptions.ts
New ConfigCards module consolidates reusable styling helpers and render functions (cardStateClasses, renderCardLabel, renderToggleCard, renderUnitTypeOptions). Deleted RenderToggleInputCard and RenderUnitTypeOptions modules, moving functionality into ConfigCards.
Component Updates
src/client/components/map/MapDisplay.ts, src/client/components/map/MapPicker.ts, src/client/components/FluentSlider.ts, src/client/UserSettingModal.ts
Updated to use new ConfigCards utilities and replace div-based interactions with button elements for accessibility. MapDisplay and MapPicker adopt cardStateClasses and renderCardLabel helpers. FluentSlider and UserSettingModal change trigger elements from span/div to button.
Test Updates
tests/client/components/FluentSlider.test.ts
Updated test assertion to verify button element instead of span with role="button" semantics.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Suggested labels

Feature

Poem

🎨 Config forms consolidate the sprawl,
One component to rule them all,
Modals dance with shared delight,
State flows clean, the logic tight,
From chaos, unity takes flight! ✨

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Uichanges' is too vague and generic; it does not clearly convey the main changes (refactoring to GameConfigForm, replacing divs with buttons, config centralization). Use a more descriptive title such as 'Refactor game config UI with GameConfigForm component and replace divs with buttons' to clearly summarize the primary changes.
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The description mentions Tailwind changes and div-to-button replacements, which are present; however, it significantly understates the scope of the changes (major refactoring with GameConfigForm, HostLobbyModal updates, API changes).

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


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.

This comment was marked as outdated.

coderabbitai[bot]
coderabbitai bot previously approved these changes Feb 8, 2026
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: 4

Caution

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

⚠️ Outside diff range comments (3)
src/server/GameServer.ts (2)

710-717: ⚠️ Potential issue | 🟠 Major

endTurn sends to all active clients without checking WebSocket readyState.

Line 716 calls c.ws.send(msg) without confirming c.ws.readyState === WebSocket.OPEN. If a WebSocket is in CLOSING state between the addListeners race guard and the next endTurn, this could throw. Compare with broadcastLobbyInfo (line 605) which does check readyState.

Suggested fix
     this.activeClients.forEach((c) => {
-      c.ws.send(msg);
+      if (c.ws.readyState === WebSocket.OPEN) {
+        c.ws.send(msg);
+      }
     });

926-933: ⚠️ Potential issue | 🟡 Minor

markClientDisconnected creates a TurnIntent where clientID means "target", not "sender".

This ties to the MarkDisconnectedIntentSchema issue noted in Schemas.ts. The clientID here is the player being marked disconnected, but TurnIntent.clientID is normally the sender. In ExecutionManager.createExec, intent.clientID is used to find the player — which coincidentally works because the target IS the player to operate on. But this dual meaning could bite if someone refactors the intent handling to distinguish sender from target.

src/client/HostLobbyModal.ts (1)

216-237: ⚠️ Potential issue | 🟠 Major

Missing .catch() on createLobby promise chain — failed lobby creation silently swallowed.

If createLobby rejects (e.g., 401 from invalid token, network error), the unhandled rejection leaves the user stuck on the host modal with no feedback. Add a .catch() to show an error message and close the modal.

Proposed fix
     createLobby(this.lobbyId)
       .then(async (lobby) => {
         this.lobbyId = lobby.gameID;
         if (!isValidGameID(this.lobbyId)) {
           throw new Error(`Invalid lobby ID format: ${this.lobbyId}`);
         }
         crazyGamesSDK.showInviteButton(this.lobbyId);
         const url = await this.constructUrl();
         this.updateHistory(url);
       })
       .then(() => {
         this.dispatchEvent(
           new CustomEvent("join-lobby", {
             detail: {
               gameID: this.lobbyId,
               source: "host",
             } as JoinLobbyEvent,
             bubbles: true,
             composed: true,
           }),
         );
+      })
+      .catch((error) => {
+        console.error("Failed to create lobby:", error);
+        window.dispatchEvent(
+          new CustomEvent("show-message", {
+            detail: {
+              message: translateText("host_modal.create_failed"),
+              color: "red",
+              duration: 3000,
+            },
+          }),
+        );
+        this.close();
       });
🤖 Fix all issues with AI agents
In `@src/client/components/GameConfigForm.ts`:
- Around line 788-792: The max-timer keydown handler handleMaxTimerValueKeyDown
currently prevents "-", "+", and "e" but misses uppercase "E", allowing
scientific notation like "1E2"; update handleMaxTimerValueKeyDown to block ["-",
"+", "e", "E"] (matching handleNumberKeyDown) so uppercase E is prevented and
the input can't be entered in scientific notation.
- Around line 830-837: The handler handleSpawnImmunityDurationInput currently
returns early on invalid input and leaves this.spawnImmunityDurationMinutes
unchanged, causing a UI/state desync; update the function so that when input is
NaN or outside 0–120 you set this.spawnImmunityDurationMinutes = undefined
(matching handleMaxTimerValueChanges behavior) and then call
this.fireConfigChanged(), otherwise parse and assign the valid value as now.

In `@src/core/Schemas.ts`:
- Around line 399-403: Rename the ambiguous clientID in
MarkDisconnectedIntentSchema to targetClientID to distinguish the disconnected
player from the intent sender: update the schema export
(MarkDisconnectedIntentSchema) to use targetClientID: ID, then update all code
that constructs or reads this intent (e.g., GameServer.markClientDisconnected
where the intent is created/queued and any ExecutionManager.createExec or
consumers that currently access intent.clientID) to read/write
intent.targetClientID instead; also adjust any tests/types that rely on
MarkDisconnectedIntentSchema to use the new field name so the semantics no
longer conflict with TurnIntentSchema's clientID.
- Around line 447-449: TurnIntentSchema currently applies clientID via
IntentSchema.and(...), which creates a ZodIntersection and can block object-only
methods and cause TypeScript inference issues; instead, for each intent variant
(e.g., AttackIntentSchema, CancelAttackIntentSchema, MoveIntentSchema, etc.)
apply .and(z.object({ clientID: ID })) to produce per-variant schemas, then
build TurnIntentSchema with z.discriminatedUnion("type", [A, B, ...]) using
those augmented variants; update the TurnIntentSchema declaration to use that
discriminated union pattern and keep the TurnIntent type as Intent & { clientID:
ClientID } if needed.
🧹 Nitpick comments (10)
src/client/components/GameConfigForm.ts (2)

30-54: DEFAULT_CONFIG with as const plus type casts is a workable pattern.

The undefined as number | undefined casts inside as const are needed to keep the types wide enough for mutable state. This works, but the interface GameConfigSnapshot already defines the canonical shape — consider whether as const adds value here since the object is only used for reset defaults, not as a type source.


239-245: resolveSelectedMap() mutates internal state as a side effect.

This public method both writes (this.selectedMap = this.getRandomMap()) and returns a value. Callers might not expect a "getter-like" method to change component state. Consider either:

  • Making it purely return a value without mutating, or
  • Renaming to something like finalizeSelectedMap() to signal the side effect.
src/client/utilities/ConfigCards.ts (2)

58-79: renderConfigCard correctly uses <button> with ?disabled binding.

The Lit ?disabled directive properly toggles the HTML disabled attribute. When disabled, the cursor-not-allowed class from cardStateClasses provides visual feedback. The active:scale-95 in BASE_CARD will still apply on disabled buttons in some browsers though — consider gating it.


165-191: UNIT_OPTIONS is a hardcoded list — new unit types require a manual update here.

This is fine for now, but if UnitType grows frequently, consider deriving the list from the enum to avoid drift. Low priority since the unit roster is likely stable.

src/client/components/map/MapDisplay.ts (1)

89-90: renderCardLabel always called with active: true — label is always bright white.

This means the label text does not visually change between selected and unselected states. If that is intentional for readability on map cards, this is fine. But if you want the label to dim alongside the card when unselected, pass this.selected instead:

-${renderCardLabel(this.translation ?? this.mapName ?? "", true)}
+${renderCardLabel(this.translation ?? this.mapName ?? "", this.selected)}
src/client/components/map/MapPicker.ts (1)

158-160: renderCardLabel always receives true — should it reflect selection state?

The second argument to renderCardLabel controls the text style (white vs white/60). Passing true always means the "Random" label looks active even when another map is selected. Consider passing this.useRandomMap for visual consistency with other cards.

Suggested fix
               <div class="p-3 border-t border-white/5">
-                ${renderCardLabel(translateText("map.random"), true)}
+                ${renderCardLabel(translateText("map.random"), this.useRandomMap)}
               </div>
src/core/execution/ExecutionManager.ts (1)

36-43: Remove unused clientID parameter from the Executor constructor.

The private clientID: ClientID parameter is never referenced in this class. Since createExec uses intent.clientID from the TurnIntent directly, storing clientID on the class is unnecessary and can be safely removed.

src/client/HostLobbyModal.ts (1)

365-377: HTTP polling for players still active alongside EventBus updates.

pollPlayers fetches clients via HTTP every second, while handleLobbyInfo also updates this.clients via EventBus. The dual-path update is not harmful (last-write-wins on this.clients), but it's unnecessary network traffic once the EventBus is connected. Consider removing the polling interval or keeping it only as a fallback with a longer interval.

src/client/ClientGameRunner.ts (2)

195-210: Multiple clientID! non-null assertions across the file rely on implicit ordering guarantees.

clientID can be undefined, yet it's used with ! at lines 227, 233, 247, 307, 308, 559, 594, 655, 674. Today the flow guarantees clientID is set before these paths run (the start message assigns it before createClientGame, and gameplay handlers run after start). But if the call order ever shifts, these become silent bugs.

A lightweight guard at the top of createClientGame would make the contract explicit:

Proposed guard
 async function createClientGame(
   lobbyConfig: LobbyConfig,
   clientID: ClientID | undefined,
   ...
 ): Promise<ClientGameRunner> {
   if (lobbyConfig.gameStartInfo === undefined) {
     throw new Error("missing gameStartInfo");
   }
   // For local games only, derive clientID from the first player in GameStartInfo
   if (!clientID && transport.isLocal) {
     clientID = lobbyConfig.gameStartInfo.players[0]?.clientID;
   }
+  if (!clientID) {
+    throw new Error("missing clientID: server did not assign one");
+  }
   const config = await getConfig(

This replaces many scattered ! assertions with a single, clear fail point.


767-795: showErrorModal now accepts clientID: ClientID | undefined — displays "undefined" when unset.

At line 790, client id: ${clientID} will render as "client id: undefined" if the client never received an ID. This is acceptable for error diagnostics (it tells support the ID was never assigned), but consider displaying a more user-friendly fallback like "unknown".

Minor readability tweak
-    `client id: ${clientID}`,
+    `client id: ${clientID ?? "unknown"}`,

Comment on lines +788 to +792
private handleMaxTimerValueKeyDown = (e: KeyboardEvent) => {
if (["-", "+", "e"].includes(e.key)) {
e.preventDefault();
}
};
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

Missing uppercase "E" in handleMaxTimerValueKeyDown.

handleNumberKeyDown (line 783) blocks ["-", "+", "e", "E"], but handleMaxTimerValueKeyDown only blocks ["-", "+", "e"]. This lets users type 1E2 (scientific notation) into the max timer input, which parseInt would parse as 1 (ignoring E2).

Proposed fix
  private handleMaxTimerValueKeyDown = (e: KeyboardEvent) => {
-   if (["-", "+", "e"].includes(e.key)) {
+   if (["-", "+", "e", "E"].includes(e.key)) {
      e.preventDefault();
    }
  };
📝 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
private handleMaxTimerValueKeyDown = (e: KeyboardEvent) => {
if (["-", "+", "e"].includes(e.key)) {
e.preventDefault();
}
};
private handleMaxTimerValueKeyDown = (e: KeyboardEvent) => {
if (["-", "+", "e", "E"].includes(e.key)) {
e.preventDefault();
}
};
🤖 Prompt for AI Agents
In `@src/client/components/GameConfigForm.ts` around lines 788 - 792, The
max-timer keydown handler handleMaxTimerValueKeyDown currently prevents "-",
"+", and "e" but misses uppercase "E", allowing scientific notation like "1E2";
update handleMaxTimerValueKeyDown to block ["-", "+", "e", "E"] (matching
handleNumberKeyDown) so uppercase E is prevented and the input can't be entered
in scientific notation.

Comment on lines +830 to +837
private handleSpawnImmunityDurationInput = (e: Event) => {
const input = e.target as HTMLInputElement;
input.value = input.value.replace(/[eE+-]/g, "");
const value = parseInt(input.value, 10);
if (Number.isNaN(value) || value < 0 || value > 120) return;
this.spawnImmunityDurationMinutes = value;
this.fireConfigChanged();
};
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

Inconsistent out-of-range handling in spawn immunity input.

Other handlers (e.g., handleMaxTimerValueChanges on line 798) set the value to undefined when input is invalid. This handler silently returns, leaving the old state value in place while the input field shows the invalid number — a visual desync.

Proposed fix — align with other handlers
  private handleSpawnImmunityDurationInput = (e: Event) => {
    const input = e.target as HTMLInputElement;
    input.value = input.value.replace(/[eE+-]/g, "");
    const value = parseInt(input.value, 10);
-   if (Number.isNaN(value) || value < 0 || value > 120) return;
-   this.spawnImmunityDurationMinutes = value;
+   if (Number.isNaN(value) || value < 0 || value > 120) {
+     this.spawnImmunityDurationMinutes = undefined;
+   } else {
+     this.spawnImmunityDurationMinutes = value;
+   }
    this.fireConfigChanged();
  };
📝 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
private handleSpawnImmunityDurationInput = (e: Event) => {
const input = e.target as HTMLInputElement;
input.value = input.value.replace(/[eE+-]/g, "");
const value = parseInt(input.value, 10);
if (Number.isNaN(value) || value < 0 || value > 120) return;
this.spawnImmunityDurationMinutes = value;
this.fireConfigChanged();
};
private handleSpawnImmunityDurationInput = (e: Event) => {
const input = e.target as HTMLInputElement;
input.value = input.value.replace(/[eE+-]/g, "");
const value = parseInt(input.value, 10);
if (Number.isNaN(value) || value < 0 || value > 120) {
this.spawnImmunityDurationMinutes = undefined;
} else {
this.spawnImmunityDurationMinutes = value;
}
this.fireConfigChanged();
};
🤖 Prompt for AI Agents
In `@src/client/components/GameConfigForm.ts` around lines 830 - 837, The handler
handleSpawnImmunityDurationInput currently returns early on invalid input and
leaves this.spawnImmunityDurationMinutes unchanged, causing a UI/state desync;
update the function so that when input is NaN or outside 0–120 you set
this.spawnImmunityDurationMinutes = undefined (matching
handleMaxTimerValueChanges behavior) and then call this.fireConfigChanged(),
otherwise parse and assign the valid value as now.

Comment on lines 399 to 403
export const MarkDisconnectedIntentSchema = z.object({
type: z.literal("mark_disconnected"),
clientID: ID,
isDisconnected: z.boolean(),
});
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

clientID in MarkDisconnectedIntentSchema overlaps with TurnIntentSchema's clientID — different meanings, same field name.

MarkDisconnectedIntentSchema has its own clientID: ID representing the player being disconnected. But TurnIntentSchema (line 448) also adds clientID: ID representing who issued the intent. Because both use the same field name, the intersection merges them silently.

It works today because the server creates mark_disconnected intents directly (in GameServer.markClientDisconnected) and sets clientID to the target player — which is also the player ExecutionManager.createExec looks up. But this conflation of "sender" and "target" in the same field is fragile.

Consider renaming the field in MarkDisconnectedIntentSchema to something like targetClientID to make the semantic difference clear and prevent future bugs if the intent ever needs to track both sender and target.

🤖 Prompt for AI Agents
In `@src/core/Schemas.ts` around lines 399 - 403, Rename the ambiguous clientID in
MarkDisconnectedIntentSchema to targetClientID to distinguish the disconnected
player from the intent sender: update the schema export
(MarkDisconnectedIntentSchema) to use targetClientID: ID, then update all code
that constructs or reads this intent (e.g., GameServer.markClientDisconnected
where the intent is created/queued and any ExecutionManager.createExec or
consumers that currently access intent.clientID) to read/write
intent.targetClientID instead; also adjust any tests/types that rely on
MarkDisconnectedIntentSchema to use the new field name so the semantics no
longer conflict with TurnIntentSchema's clientID.

Comment on lines 447 to 449
// TurnIntent = Intent with server-stamped clientID (used in turns and execution)
export const TurnIntentSchema = IntentSchema.and(z.object({ clientID: ID }));
export type TurnIntent = Intent & { clientID: ClientID };
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

🧩 Analysis chain

🏁 Script executed:

# Find the IntentSchema definition
rg "IntentSchema" --type ts -B 2 -A 2

Repository: openfrontio/OpenFrontIO

Length of output: 9972


🏁 Script executed:

# Look for the file structure around the review comment
head -n 460 src/core/Schemas.ts | tail -n 40

Repository: openfrontio/OpenFrontIO

Length of output: 1178


🏁 Script executed:

# Search for test files related to TurnIntentSchema
fd "test|spec" --type f -e ts | xargs rg "TurnIntentSchema" 2>/dev/null | head -20

Repository: openfrontio/OpenFrontIO

Length of output: 49


🌐 Web query:

Zod 4 discriminatedUnion .and() intersection method

💡 Result:

In Zod v4, .and() is just the intersection operator (z.intersection(a, b)), and it can be used with a z.discriminatedUnion(...). The result is a ZodIntersection, not a ZodObject. That means you generally can’t call object-only helpers like .extend() / .merge() on the result. [1][2]

Recommended pattern (add common fields to every variant)

Instead of intersecting the whole union, apply the common object to each option, then rebuild the discriminated union:

import { z } from "zod/v4";

const Common = z.object({ someData: z.string() });

const A = z.object({ type: z.literal("A"), a: z.string() }).and(Common);
const B = z.object({ type: z.literal("B"), b: z.number() }).and(Common);

const U = z.discriminatedUnion("type", [A, B]);
type T = z.infer<typeof U>;

Known TypeScript edge case

There’s a known TS inference failure where a discriminatedUnion(...).and(...) combined with recursion can cause the inferred type to become unknown (see issue #4714). [3]

Sources: Zod union/intersection docs summary (DeepWiki) [1], Zod v4 changelog note on intersections [2], Zod issue about .and + discriminated union + recursion inference (#4714) [3].


Consider using the recommended pattern: apply clientID to each intent variant before creating the discriminated union.

In Zod 4, .and() on a z.discriminatedUnion technically works and creates a ZodIntersection. However, this pattern has known limitations: .and() produces a type that can't use object-only methods like .extend() or .merge(), and there's a documented TypeScript inference edge case (issue #4714) where inference can fail to unknown in recursive scenarios.

The recommended pattern is to apply the common field to each variant first, then build the discriminated union:

const A = AttackIntentSchema.and(z.object({ clientID: ID }));
const B = CancelAttackIntentSchema.and(z.object({ clientID: ID }));
// ... apply to all variants ...
const TurnIntentSchema = z.discriminatedUnion("type", [A, B, /* ... */]);

The current implementation works for your use case (simple array usage without recursion), but switching to this pattern would be clearer and align with Zod 4 best practices.

🤖 Prompt for AI Agents
In `@src/core/Schemas.ts` around lines 447 - 449, TurnIntentSchema currently
applies clientID via IntentSchema.and(...), which creates a ZodIntersection and
can block object-only methods and cause TypeScript inference issues; instead,
for each intent variant (e.g., AttackIntentSchema, CancelAttackIntentSchema,
MoveIntentSchema, etc.) apply .and(z.object({ clientID: ID })) to produce
per-variant schemas, then build TurnIntentSchema with
z.discriminatedUnion("type", [A, B, ...]) using those augmented variants; update
the TurnIntentSchema declaration to use that discriminated union pattern and
keep the TurnIntent type as Intent & { clientID: ClientID } if needed.

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

Caution

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

⚠️ Outside diff range comments (2)
src/client/HostLobbyModal.ts (2)

209-245: ⚠️ Potential issue | 🟠 Major

No await on createLobby — errors in the second .then silently lost.

The promise chain from createLobby is not awaited or returned, so any rejection in the second .then (the join-lobby dispatch) is an unhandled promise rejection. Also, this.playersInterval starts immediately (line 243) before the lobby is confirmed created — if createLobby fails, you'll poll a nonexistent lobby.

Consider awaiting the chain or adding a .catch to handle failures and stop the polling interval:

Suggested approach
-    createLobby(this.lobbyId)
+    createLobby(this.lobbyId)
       .then(async (lobby) => {
         // ...existing logic...
       })
       .then(() => {
         // ...dispatch join-lobby...
       })
+      .catch((error) => {
+        console.error("Failed to create lobby:", error);
+        if (this.playersInterval) {
+          clearInterval(this.playersInterval);
+          this.playersInterval = null;
+        }
+      });

365-377: ⚠️ Potential issue | 🟡 Minor

Polling pollPlayers has no error handling for fetch failures.

If the fetch fails (network error, non-JSON response), the .then(r => r.json()) call will throw, and the interval keeps ticking with unhandled rejections each second. Add a .catch to gracefully handle failures.

Suggested fix
     fetch(`/${config.workerPath(this.lobbyId)}/api/game/${this.lobbyId}`, {
       method: "GET",
       headers: {
         "Content-Type": "application/json",
       },
     })
       .then((response) => response.json())
       .then((data: GameInfo) => {
         this.clients = data.clients ?? [];
-      });
+      })
+      .catch((err) => {
+        console.warn("Failed to poll players:", err);
+      });
🤖 Fix all issues with AI agents
In `@src/client/components/GameConfigForm.ts`:
- Around line 274-290: Update hasOptionsChanged() to include comparisons for
teamCount and useRandomMap against DEFAULT_CONFIG, and also check the
user-configurable numeric/value fields goldMultiplierValue, startingGoldValue,
and maxTimerValue; modify the boolean expression in the hasOptionsChanged method
(the one referencing DEFAULT_CONFIG and properties like disableNations, bots,
etc.) to OR in comparisons like this.teamCount !== DEFAULT_CONFIG.teamCount,
this.useRandomMap !== DEFAULT_CONFIG.useRandomMap, this.goldMultiplierValue !==
DEFAULT_CONFIG.goldMultiplier, this.startingGoldValue !==
DEFAULT_CONFIG.startingGold, and this.maxTimerValue !== DEFAULT_CONFIG.maxTimer
so any change to those settings correctly returns true.
🧹 Nitpick comments (8)
src/client/components/map/MapDisplay.ts (1)

89-89: Label is always rendered as "active" — intentional?

renderCardLabel(..., true) means the label text is always full white (text-white), even when the card is not selected. The selected/unselected distinction comes from cardStateClasses on the outer button, so this is visually consistent. Just flagging in case you intended the label to dim for unselected cards — if so, pass this.selected instead of true.

src/client/components/map/MapPicker.ts (2)

105-117: Tabs look clean, but consider completing the ARIA tab pattern.

The role="tab" + role="tablist" + aria-selected is a great start. For full WAI-ARIA compliance, the content panels (featured / all maps) would need role="tabpanel" with matching id / aria-labelledby / aria-controls attributes. Not a blocker, but worth a follow-up if accessibility is a priority.


144-161: Random map card layout differs slightly from <map-display> cards.

The random map <button> uses items-stretch with a separate <div class="p-3 border-t ..."> wrapper around the label, while <map-display> uses p-3 gap-3 items-center justify-between on the outer button with no label wrapper. This could produce visual differences (padding, alignment, separator line) between the random card and the regular map cards.

If the visual result looks good after testing, this is fine. Just flagging in case the cards should look identical.

src/client/components/GameConfigForm.ts (4)

30-54: as const does not deeply narrow here due to type assertions.

The as const on the object is overridden by the inline type assertions (undefined as number | undefined, [] as UnitType[], 2 as TeamCountConfig). These assertions widen the types back, so as const is effectively a no-op on those fields. This is not a bug (the values are still correct defaults), but it is misleading — a reader might think this object is deeply readonly.

Consider either removing as const (since it is not doing what it suggests) or removing the inline type assertions and letting as const narrow the types naturally. You can use satisfies to validate shape without widening:

Option: drop `as const`, add an explicit type
-const DEFAULT_CONFIG = {
+const DEFAULT_CONFIG: GameConfigSnapshot = {
   selectedMap: GameMapType.World,
   selectedDifficulty: Difficulty.Easy,
   disableNations: false,
   bots: 400,
   ...
-  disabledUnits: [] as UnitType[],
+  disabledUnits: [],
   ...
-} as const;
+};

239-245: resolveSelectedMap() mutates internal state as a side effect — surprising for a "resolve" method.

A method named resolve... reads like a pure query, but it silently writes this.selectedMap when useRandomMap is true. Callers in SinglePlayerModal and HostLobbyModal may not expect this side effect.

Consider splitting into a pure getter and a separate "commit" step, or at minimum documenting the mutation clearly in the method name (e.g., commitAndGetSelectedMap()).


446-461: Bot slider wrapper <div> is not a focusable/interactive element but has visual states.

The outer <div> uses cardStateClasses which applies hover styles, but the actual interaction is inside <fluent-slider>. This is fine visually, but for keyboard and screen reader users, the container div provides no semantic meaning. Not a blocker — just something to keep in mind for accessibility.


247-272: reset() manually resets every property — easy to forget a field.

If you add a new config property later, you must update reset(), getConfig(), hasOptionsChanged(), and DEFAULT_CONFIG in sync. Consider deriving reset from DEFAULT_CONFIG to reduce that surface:

Example
public reset(): void {
  Object.assign(this, { ...DEFAULT_CONFIG, disabledUnits: [...DEFAULT_CONFIG.disabledUnits] });
}

This reduces the chance of forgetting a field in a future change.

src/client/SinglePlayerModal.ts (1)

285-287: Redundant UnitType validation — disabledUnits is already UnitType[].

The .map(u => Object.values(UnitType).find(ut => ut === u)).filter(...) pipeline just validates that each item is a member of the UnitType enum, but the source (config.disabledUnits) is already typed as UnitType[] and populated from the form's toggleUnit handler which only adds valid UnitType values. This round-trip is a no-op.

You can simplify to just disabledUnits: [...config.disabledUnits] (or pass directly). Not urgent — it's defensive code, but it adds noise.

Comment on lines +274 to +290
/** Check if any options differ from defaults (used by SP for achievement warning). */
public hasOptionsChanged(): boolean {
return (
this.disableNations !== DEFAULT_CONFIG.disableNations ||
this.bots !== DEFAULT_CONFIG.bots ||
this.infiniteGold !== DEFAULT_CONFIG.infiniteGold ||
this.infiniteTroops !== DEFAULT_CONFIG.infiniteTroops ||
this.compactMap !== DEFAULT_CONFIG.compactMap ||
this.maxTimer !== DEFAULT_CONFIG.maxTimer ||
this.instantBuild !== DEFAULT_CONFIG.instantBuild ||
this.randomSpawn !== DEFAULT_CONFIG.randomSpawn ||
this.gameMode !== DEFAULT_CONFIG.gameMode ||
this.goldMultiplier !== DEFAULT_CONFIG.goldMultiplier ||
this.startingGold !== DEFAULT_CONFIG.startingGold ||
this.disabledUnits.length > 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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, locate GameConfigForm.ts and examine the full file
fd -e ts -n 'GameConfigForm' | head -20

Repository: openfrontio/OpenFrontIO

Length of output: 297


🏁 Script executed:

# Search for the hasOptionsChanged method usage to understand impact
rg -n 'hasOptionsChanged' --type=ts -C 3

Repository: openfrontio/OpenFrontIO

Length of output: 1117


🏁 Script executed:

# Find the DEFAULT_CONFIG definition
rg -n 'DEFAULT_CONFIG' --type=ts -B 2 -A 10 | head -100

Repository: openfrontio/OpenFrontIO

Length of output: 8867


🏁 Script executed:

# Get the complete DEFAULT_CONFIG definition
sed -n '30,50p' src/client/components/GameConfigForm.ts

Repository: openfrontio/OpenFrontIO

Length of output: 683


🏁 Script executed:

# Get the complete hasOptionsChanged method
sed -n '274,295p' src/client/components/GameConfigForm.ts

Repository: openfrontio/OpenFrontIO

Length of output: 970


🏁 Script executed:

# Check SinglePlayerModal to understand how optionsChanged affects achievements
rg -n 'optionsChanged' src/client/SinglePlayerModal.ts -B 5 -A 5

Repository: openfrontio/OpenFrontIO

Length of output: 1675


Add teamCount and useRandomMap checks to hasOptionsChanged().

The method currently skips these properties, both of which have defaults in DEFAULT_CONFIG. If a player changes team count from 2 or enables random map, hasOptionsChanged() returns false, bypassing the achievement warning. Additionally, goldMultiplierValue, startingGoldValue, and maxTimerValue are also unchecked despite being user-configurable.

🤖 Prompt for AI Agents
In `@src/client/components/GameConfigForm.ts` around lines 274 - 290, Update
hasOptionsChanged() to include comparisons for teamCount and useRandomMap
against DEFAULT_CONFIG, and also check the user-configurable numeric/value
fields goldMultiplierValue, startingGoldValue, and maxTimerValue; modify the
boolean expression in the hasOptionsChanged method (the one referencing
DEFAULT_CONFIG and properties like disableNations, bots, etc.) to OR in
comparisons like this.teamCount !== DEFAULT_CONFIG.teamCount, this.useRandomMap
!== DEFAULT_CONFIG.useRandomMap, this.goldMultiplierValue !==
DEFAULT_CONFIG.goldMultiplier, this.startingGoldValue !==
DEFAULT_CONFIG.startingGold, and this.maxTimerValue !== DEFAULT_CONFIG.maxTimer
so any change to those settings correctly returns true.

@github-project-automation github-project-automation bot moved this from Complete to Development in OpenFront Release Management Feb 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Development

Development

Successfully merging this pull request may close these issues.

1 participant