Skip to content

Conversation

@nodeGarden
Copy link

@nodeGarden nodeGarden commented Dec 27, 2025

Summary

Fixes: #2685

  • Add local development convenience features: configurable ports, dev feature flags, and dev:stop script
  • Refactor User Settings modal with grouped/collapsible sections, tabbed navigation, and light theme support
  • Add Display settings tab with theme mode selector, territory color palette editor, and cosmetic placeholders
  • Add server-side improvements: PORT validation, ENABLE_PUBLIC_GAMES env var

Screenshots

Settings Tab Keybinds Tab
user-settings--settings user-settings--keybind
Display Tab Display Tab (Colors)
user-settings--display user-settings--display-2

Changes

Local Development

  • Configurable ports via OPENFRONT_CLIENT_PORT and OPENFRONT_SERVER_PORT environment variables
  • Dev feature flags system (DevConfig.ts) for toggling features during local development
  • pnpm dev:stop script to kill processes on dev ports (Windows + Unix support)
  • config.example.json template for local configuration

Settings UI Refactor

  • Tabbed interface (Settings, Keybinds, Display)
  • Collapsible grouped settings with 2-column masonry layout
  • Light/dark theme support with synced dark mode button
  • Territory color palette editor
  • Territory skins placeholder (cosmetics)

Server

  • PORT validation in Master.ts (0-65535 range check with clear error messages)
  • ENABLE_PUBLIC_GAMES env var to disable public game scheduling in dev
  • Removed dead code (modal.css, unused pastel theme files)

Test plan

  • pnpm lint passes
  • pnpm test passes (47 suites, 252 tests)
  • Manual testing of settings modal tabs and collapsible groups
  • Verify dev server starts with custom ports
  • Verify pnpm dev:stop kills processes correctly

@nodeGarden nodeGarden requested a review from a team as a code owner December 27, 2025 16:06
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 27, 2025

Walkthrough

Adds a dev-config system with runtime feature flags and conditional client script loading; implements theme modes and a new Display settings UI with multiple setting components; adds dev tooling (dev-stop script, dotenv/port wiring), server feature gating for public games, SAM radius rendering rewrite, AI behavior refactors, localization updates, and assorted styling/component changes.

Changes

Cohort / File(s) Summary
Dev config & runtime gating
config.example.json, src/client/DevConfig.ts, src/client/index.html
New example config and DevConfig loader (sync seed + async fetch). Client loads /config.json into window.__devConfig and conditionally inserts Turnstile/ads/analytics scripts based on feature flags; exposes wait/get/isDevFeatureEnabled.
Build, scripts & env
scripts/dev-stop.js, package.json, webpack.config.js, eslint.config.js, tsconfig.json
Added dev:stop script and scripts/dev-stop.js; webpack uses dotenv and OPENFRONT_* ports (dynamic devServer/proxy targets); added skipLibCheck/types; ESLint allowDefaultProject expanded.
Theme & user settings core
src/core/game/UserSettings.ts, src/client/DarkModeButton.ts, src/client/Main.ts
New ThemeMode type and APIs (themeMode, setThemeMode, applyTheme, isDarkModeActive); Main awaits dev config early and gates turnstile token; DarkModeButton reads isDarkModeActive().
Settings UI & components
src/client/UserSettingModal.ts, src/client/components/baseComponents/setting/*
Adds Display tab and reorganized grouped settings; new components: SettingGroup, SettingColorPalette, SettingTerritorySkins, SettingThemeMode with localStorage persistence and change events.
Modal styling & behavior
src/client/components/baseComponents/Modal.ts, src/client/styles/components/modal.css, src/client/styles.css
Modal made theme-aware with MutationObserver and CSS variables; modal.css file removed and styles consolidated into component/other CSS; styles.css import adjusted.
Settings styling
src/client/styles/components/setting.css
New grouped settings styles, sticky tabs, CSS variables, responsive wide modal layout and cosmetics placeholders.
Public lobbies feature gating
src/client/PublicLobby.ts
Public lobby fetch/render gated behind dev-config publicLobbies flag and waitForDevConfig.
Input behavior
src/client/InputHandler.ts
Alternate-view keybind changed from Space → Backslash; pointer-drag pan disabled while Shift is held (Shift+drag reserved for selection).
SAM rendering & renderer wiring
src/client/graphics/layers/SAMRadiusLayer.ts, src/client/graphics/GameRenderer.ts
SAMRadiusLayer rewritten to use off-screen canvas, merged-circle outlines, needs redraw tracking, and now requires a TransformHandler; GameRenderer updated to pass it.
AI / nation behavior
src/core/execution/* (NationExecution, AiAttackBehavior, NationMIRVBehavior, SAMLauncherExecution, etc.)
Multiple AI refactors: attack ordering and early-retaliation prioritization, MIRV cooldown/hesitation and caching, SAM hit/miss chance path, sendBoatRandomly visibility change, API visibility adjustments and other logic simplifications.
Server config & master scheduler
src/core/configuration/Config.ts, src/core/configuration/DefaultConfig.ts, src/server/Master.ts, tests/util/TestServerConfig.ts
Added enablePublicGames() and SAM chance accessors; Master gates public-game scheduling and validates OPENFRONT_SERVER_PORT; tests updated with new stubs.
Map generator & packaging
map-generator/* (README.md, go.mod, main.go, map_generator.go)
Docs simplified, module path changed, main orchestration removed, added combined binary packaging helpers (header, encode/decode) and low-level helpers.
Localization & small text changes
resources/lang/en.json
Difficulty keys capitalized and values adjusted; expanded user_setting labels for Display/groups; removed emoji prefixes in several labels.
Removed & replaced UI widget
src/client/components/FluentSlider.ts, related tests, HostLobbyModal.ts, SinglePlayerModal.ts
Removed FluentSlider and its tests; HostLobbyModal/SinglePlayerModal switched to native range inputs and debounced update logic.
Misc files & repo config
.gitignore, jest.config.ts, tests/*, package.json
.gitignore reorganized and added config.json entries; jest transformIgnorePatterns simplified (lit.* no longer excluded); other small test/tsconfig updates.

Sequence Diagram(s)

sequenceDiagram
    participant Browser
    participant ClientMain as Client Main
    participant DevConfig as DevConfig loader
    participant Fetch as /config.json
    participant UserSettings as UserSettings

    Browser->>ClientMain: page load
    ClientMain->>DevConfig: seed sync + waitForDevConfig()
    DevConfig->>Fetch: GET /config.json
    Fetch-->>DevConfig: config or failure
    DevConfig-->>ClientMain: resolved config
    ClientMain->>ClientMain: isDevFeatureEnabled('cloudflare')?
    alt enabled
        ClientMain->>Browser: insert Turnstile script
    else
        ClientMain->>ClientMain: skip Turnstile (token gating)
    end
    ClientMain->>UserSettings: applyTheme()
    UserSettings->>Browser: add/remove "dark" class
Loading
sequenceDiagram
    participant User
    participant SettingsModal as UserSettingModal
    participant ThemeComp as SettingThemeMode
    participant UserSettings as UserSettings

    User->>SettingsModal: open Display tab
    SettingsModal->>ThemeComp: mount selector
    User->>ThemeComp: choose "Dark"
    ThemeComp->>SettingsModal: dispatch change {mode: "dark"}
    SettingsModal->>UserSettings: setThemeMode("dark")
    UserSettings->>UserSettings: persist & applyTheme()
    UserSettings->>SettingsModal: emit dark-mode-changed
    SettingsModal->>SettingsModal: re-render with dark UI
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested labels

DevOps - CICD, UI/UX, Feature - Frontend, Refactor

Suggested reviewers

  • evanpelle
  • scottanderson

Poem

⚙️ Flags unfurl, the dev config sings,
Themes flip softly on tiny wings.
Modals breathe in light and shade,
SAM circles blend where lines were made,
Ports cleared, sliders gone — new inputs bring.

Pre-merge checks

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.22% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Add local development setup with dev feature flags and improved settings UI' accurately summarizes the main changes: development convenience features, a dev feature flag system, and a refactored settings modal UI.
Description check ✅ Passed The description is detailed and relevant, covering local development features, UI refactoring, server improvements, and test results. It clearly relates to the changeset with screenshots and structured sections.

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.

@nodeGarden nodeGarden force-pushed the feature/local-dev-setup branch from bcddf9f to e5c817b Compare December 27, 2025 16:10
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: 5

Caution

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

⚠️ Outside diff range comments (1)
src/client/UserSettingModal.ts (1)

87-112: Remove unused toggleDarkMode method.

This method in UserSettingModal.ts is dead code. It was replaced by handleThemeModeChange (line 280), which uses the new ThemeMode typed union system ("light" | "dark" | "system") instead of the old boolean settings.darkMode. The method is never called from anywhere in the codebase.

🧹 Nitpick comments (12)
webpack.config.js (1)

18-19: Inconsistent parsing: clientPort stays string, serverPort is parsed to int.

clientPort is used directly as a string, while serverPort is parsed with parseInt. This works because devServer.port accepts both, but the arithmetic on serverPort (e.g., serverPort + 1) requires an int.

For clarity, consider parsing both or keeping both as strings with explicit conversion where needed:

Option: parse both to int
-const clientPort = parseInt(process.env.OPENFRONT_CLIENT_PORT ?? "9000", 10);
-const serverPort = parseInt(process.env.OPENFRONT_SERVER_PORT ?? "3000", 10);
+const clientPort = parseInt(process.env.OPENFRONT_CLIENT_PORT ?? "9000", 10);
+const serverPort = parseInt(process.env.OPENFRONT_SERVER_PORT ?? "3000", 10);

(Both already parsed—looks like this is fine as-is, clientPort just doesn't need arithmetic. No change needed.)

scripts/dev-stop.js (1)

6-7: Same inconsistency as webpack: clientPort is string, serverPort is int.

Works fine due to String(port) on line 23, but matching types would be cleaner.

Parse both consistently
-const clientPort = process.env.OPENFRONT_CLIENT_PORT ?? "9000";
-const serverPort = parseInt(process.env.OPENFRONT_SERVER_PORT ?? "3000", 10);
+const clientPort = parseInt(process.env.OPENFRONT_CLIENT_PORT ?? "9000", 10);
+const serverPort = parseInt(process.env.OPENFRONT_SERVER_PORT ?? "3000", 10);
src/client/components/baseComponents/setting/SettingTerritorySkins.ts (2)

11-81: Premium flag has no enforcement.

The premium flag on skins is decorative only—users can select and save premium skins without restriction (see selectSkin at line 310). If this is intentional for the placeholder/mockup phase, consider adding a comment. Otherwise, gate premium skins behind a purchase check.


296-308: Remove stale custom URL from localStorage.

When switching from a custom skin to a preset, the old settings.territorySkinCustom value remains in storage. On reload, this could cause confusion.

🔎 Proposed fix
 private saveSkin() {
   localStorage.setItem("settings.territorySkin", this.selectedSkinId);
   if (this.customSkinUrl) {
     localStorage.setItem("settings.territorySkinCustom", this.customSkinUrl);
+  } else {
+    localStorage.removeItem("settings.territorySkinCustom");
   }
   this.dispatchEvent(
     new CustomEvent("change", {
       detail: { skinId: this.selectedSkinId, customUrl: this.customSkinUrl },
       bubbles: true,
       composed: true,
     }),
   );
 }
src/client/components/baseComponents/setting/SettingColorPalette.ts (1)

432-447: Consider user feedback for invalid input.

Invalid hex values are silently ignored. Users might not realize why their input didn't apply. A brief visual cue (e.g., red border flash or tooltip) would improve UX.

src/client/components/baseComponents/setting/SettingThemeMode.ts (1)

140-162: Hardcoded theme labels should use translation keys.

The labels "Light", "Dark", and "System" are hardcoded. Other components in this PR use translateText() for user-facing strings.

🔎 Proposed fix to use translations

You would need to import translateText and update the labels:

+import { translateText } from "../../../Utils";
...
-            <span class="theme-label">Light</span>
+            <span class="theme-label">${translateText("user_setting.theme_light")}</span>
...
-            <span class="theme-label">Dark</span>
+            <span class="theme-label">${translateText("user_setting.theme_dark")}</span>
...
-            <span class="theme-label">System</span>
+            <span class="theme-label">${translateText("user_setting.theme_system")}</span>
src/core/game/UserSettings.ts (1)

87-104: System mode does not react to OS preference changes after initial apply.

When mode === "system", the theme is applied based on current OS preference at call time. If the user changes their OS theme while the app is open, the UI will not update until applyTheme() is called again.

Consider adding a matchMedia listener when using system mode to auto-update.

🔎 Example approach for reactive system theme
// In applyTheme or a dedicated method:
private systemThemeQuery?: MediaQueryList;

applyTheme(): void {
  const mode = this.themeMode();
  
  // Clean up previous listener
  this.systemThemeQuery?.removeEventListener("change", this.handleSystemThemeChange);
  
  if (mode === "system") {
    this.systemThemeQuery = window.matchMedia("(prefers-color-scheme: dark)");
    this.systemThemeQuery.addEventListener("change", this.handleSystemThemeChange);
    // Apply current value
    document.documentElement.classList.toggle("dark", this.systemThemeQuery.matches);
  } else {
    document.documentElement.classList.toggle("dark", mode === "dark");
  }
}
src/client/styles/components/setting.css (1)

399-411: Sticky tabs use fragile negative margin calculation.

The margin -1.4rem and width calc(100% + 2.8rem) depend on the parent's exact padding. If modal padding changes, this breaks.

Consider using a different approach like margin-inline: calc(-1 * var(--modal-padding, 1.4rem)) if you introduce a padding variable.

src/client/components/baseComponents/setting/SettingGroup.ts (2)

96-107: Empty catch blocks hide localStorage errors.

While it's fine to ignore parse errors for robustness, logging in dev mode helps debugging.

🔎 Proposed fix to log errors in dev
     } catch {
-      // Ignore parse errors
+      // Ignore parse errors in production
+      if (import.meta.env?.DEV) {
+        console.warn("Failed to parse collapsed state from localStorage");
+      }
     }

72-80: Duplicate !important rules for collapsed state.

Lines 68-70 already define .setting-group__content.collapsed { display: none !important; }, and lines 78-80 repeat this for .columns.collapsed. The first rule already applies to elements with both classes.

🔎 Remove duplicate rule
     .setting-group__content.columns {
       display: grid !important;
       grid-template-columns: repeat(2, 1fr) !important;
       gap: 12px;
     }
-
-    .setting-group__content.columns.collapsed {
-      display: none !important;
-    }
src/client/DevConfig.ts (2)

106-122: Consider consolidating the helper functions with a generic.

The three helper functions follow the same pattern and could be unified to reduce repetition.

🔎 Proposed refactor using a generic helper
-  const applyBool = (key: string, value: boolean | undefined) => {
-    if (value !== undefined && localStorage.getItem(key) === null) {
-      localStorage.setItem(key, value.toString());
-    }
-  };
-
-  const applyFloat = (key: string, value: number | undefined) => {
-    if (value !== undefined && localStorage.getItem(key) === null) {
-      localStorage.setItem(key, value.toString());
-    }
-  };
-
-  const applyString = (key: string, value: string | undefined) => {
-    if (value !== undefined && localStorage.getItem(key) === null) {
-      localStorage.setItem(key, value);
-    }
-  };
+  const applySetting = <T>(key: string, value: T | undefined) => {
+    if (value !== undefined && localStorage.getItem(key) === null) {
+      localStorage.setItem(key, String(value));
+    }
+  };

Then replace all calls to applyBool, applyFloat, and applyString with applySetting.


105-183: Consider adding value validation before writing to localStorage.

The function writes config values directly to localStorage without validating they are in expected ranges or formats. For example, backgroundMusicVolume could be negative or greater than 1, and themeMode could be an invalid string.

🔎 Example validation for constrained values

Add validation helpers before the apply functions:

const validateThemeMode = (value: string | undefined): "light" | "dark" | "system" | undefined => {
  if (value === "light" || value === "dark" || value === "system") {
    return value;
  }
  return undefined;
};

const clampVolume = (value: number | undefined): number | undefined => {
  if (typeof value === "number") {
    return Math.max(0, Math.min(1, value));
  }
  return undefined;
};

Then use them when applying settings:

applyString("settings.themeMode", validateThemeMode(settings.display.themeMode));
applyFloat("settings.backgroundMusicVolume", clampVolume(settings.audio.backgroundMusicVolume));
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c9d9a32 and bcddf9f.

⛔ Files ignored due to path filters (6)
  • docs/user-settings--display-2.jpg is excluded by !**/*.jpg
  • docs/user-settings--display.jpg is excluded by !**/*.jpg
  • docs/user-settings--keybind.jpg is excluded by !**/*.jpg
  • docs/user-settings--settings.jpg is excluded by !**/*.jpg
  • package-lock.json is excluded by !**/package-lock.json
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (29)
  • .gitignore
  • config.example.json
  • eslint.config.js
  • package.json
  • resources/lang/en.json
  • scripts/dev-stop.js
  • src/client/DarkModeButton.ts
  • src/client/DevConfig.ts
  • src/client/InputHandler.ts
  • src/client/Main.ts
  • src/client/PublicLobby.ts
  • src/client/UserSettingModal.ts
  • src/client/components/baseComponents/Modal.ts
  • src/client/components/baseComponents/setting/SettingColorPalette.ts
  • src/client/components/baseComponents/setting/SettingGroup.ts
  • src/client/components/baseComponents/setting/SettingTerritorySkins.ts
  • src/client/components/baseComponents/setting/SettingThemeMode.ts
  • src/client/graphics/layers/PlayerInfoOverlay.ts
  • src/client/index.html
  • src/client/styles.css
  • src/client/styles/components/modal.css
  • src/client/styles/components/setting.css
  • src/core/configuration/Config.ts
  • src/core/configuration/DefaultConfig.ts
  • src/core/game/UserSettings.ts
  • src/server/Master.ts
  • tests/util/TestServerConfig.ts
  • tsconfig.json
  • webpack.config.js
💤 Files with no reviewable changes (2)
  • src/client/styles.css
  • src/client/styles/components/modal.css
🧰 Additional context used
🧠 Learnings (8)
📚 Learning: 2025-08-12T00:31:50.144Z
Learnt from: scottanderson
Repo: openfrontio/OpenFrontIO PR: 1752
File: src/core/game/Game.ts:750-752
Timestamp: 2025-08-12T00:31:50.144Z
Learning: In the OpenFrontIO codebase, changes to the PlayerInteraction interface (like adding canDonateGold and canDonateTroops flags) do not require corresponding updates to src/core/Schemas.ts or server serialization code.

Applied to files:

  • src/client/graphics/layers/PlayerInfoOverlay.ts
  • src/server/Master.ts
  • src/core/configuration/Config.ts
📚 Learning: 2025-06-09T02:20:43.637Z
Learnt from: VariableVince
Repo: openfrontio/OpenFrontIO PR: 1110
File: src/client/Main.ts:293-295
Timestamp: 2025-06-09T02:20:43.637Z
Learning: In src/client/Main.ts, during game start in the handleJoinLobby callback, UI elements are hidden using direct DOM manipulation with classList.add("hidden") for consistency. This includes modals, buttons, and error divs. The codebase follows this pattern rather than using component APIs for hiding elements during game transitions.

Applied to files:

  • src/client/PublicLobby.ts
  • src/client/Main.ts
  • src/client/UserSettingModal.ts
📚 Learning: 2025-06-07T19:47:34.514Z
Learnt from: its-sii
Repo: openfrontio/OpenFrontIO PR: 1089
File: src/client/styles/components/setting.css:9-22
Timestamp: 2025-06-07T19:47:34.514Z
Learning: In src/client/styles/components/setting.css, the padding difference between .setting-item and .setting-item-group is intentional - .setting-item-group doesn't need padding for the specific grouped setting styling being implemented.

Applied to files:

  • src/client/styles/components/setting.css
  • src/client/components/baseComponents/setting/SettingGroup.ts
📚 Learning: 2025-10-20T20:15:28.858Z
Learnt from: sambokai
Repo: openfrontio/OpenFrontIO PR: 2225
File: src/core/execution/FakeHumanExecution.ts:51-51
Timestamp: 2025-10-20T20:15:28.858Z
Learning: In src/core/execution/FakeHumanExecution.ts, game balance constants like MIRV_COOLDOWN_TICKS, MIRV_HESITATION_ODDS, VICTORY_DENIAL_TEAM_THRESHOLD, VICTORY_DENIAL_INDIVIDUAL_THRESHOLD, and STEAMROLL_CITY_GAP_MULTIPLIER are experimental tuning parameters subject to frequent change during balance testing. Do not flag changes to these values as issues or compare them against previous values.

Applied to files:

  • src/core/configuration/Config.ts
📚 Learning: 2025-11-12T23:11:34.445Z
Learnt from: MaxHT0x
Repo: openfrontio/OpenFrontIO PR: 2262
File: src/core/configuration/DefaultConfig.ts:806-832
Timestamp: 2025-11-12T23:11:34.445Z
Learning: In src/core/configuration/DefaultConfig.ts, JSDoc documentation for configuration methods should not be added inline, as it was requested that documentation be placed elsewhere in the codebase.

Applied to files:

  • src/client/DevConfig.ts
📚 Learning: 2025-07-26T05:35:03.686Z
Learnt from: aaa4xu
Repo: openfrontio/OpenFrontIO PR: 1581
File: webpack.config.js:139-141
Timestamp: 2025-07-26T05:35:03.686Z
Learning: In OpenFrontIO's webpack configuration, binary-loader processes files from resources/maps at build-time and inlines the binary payload directly into the compiled JS bundle. This means the original .bin assets are no longer needed at runtime and can be safely excluded from the final Docker image using webpack's globOptions.ignore pattern to reduce image size.

Applied to files:

  • webpack.config.js
📚 Learning: 2025-06-20T20:11:00.965Z
Learnt from: devalnor
Repo: openfrontio/OpenFrontIO PR: 1195
File: src/client/graphics/layers/AlertFrame.ts:18-18
Timestamp: 2025-06-20T20:11:00.965Z
Learning: In the OpenFrontIO codebase, UserSettings instances are created directly with `new UserSettings()` in each component that needs them. This pattern is used consistently across at least 12+ files including OptionsMenu, EventsDisplay, DarkModeButton, Main, UserSettingModal, UsernameInput, NameLayer, AlertFrame, UILayer, InputHandler, ClientGameRunner, and GameView. This is the established architectural pattern and should be followed for consistency.

Applied to files:

  • src/core/game/UserSettings.ts
  • src/client/UserSettingModal.ts
📚 Learning: 2025-08-28T15:14:40.664Z
Learnt from: mokizzz
Repo: openfrontio/OpenFrontIO PR: 1956
File: src/client/InputHandler.ts:430-449
Timestamp: 2025-08-28T15:14:40.664Z
Learning: On trackpads and touchscreens, users don't expect purely vertical two-finger swipes to trigger zoom. Pan detection using deltaX !== 0 is appropriate since any horizontal movement indicates pan intent, and zoom should be handled via pinch gestures instead.

Applied to files:

  • src/client/InputHandler.ts
🧬 Code graph analysis (4)
src/server/Master.ts (3)
src/core/game/GameImpl.ts (1)
  • config (336-338)
src/core/game/GameView.ts (1)
  • config (665-667)
src/client/PublicLobby.ts (1)
  • fetchLobbies (90-111)
src/client/PublicLobby.ts (1)
src/client/DevConfig.ts (2)
  • waitForDevConfig (191-193)
  • isDevFeatureEnabled (205-209)
src/client/components/baseComponents/setting/SettingThemeMode.ts (1)
src/core/game/UserSettings.ts (2)
  • ThemeMode (6-6)
  • darkMode (66-68)
src/client/Main.ts (1)
src/client/DevConfig.ts (2)
  • loadDevConfig (214-214)
  • isDevFeatureEnabled (205-209)
🔇 Additional comments (52)
src/client/graphics/layers/PlayerInfoOverlay.ts (1)

3-3: Import path is correct for Lit v3.

The current import lit/directives/ref.js is the correct modern path. The project uses Lit v3.3.1, which supports this structure. No old lit-html imports remain in the codebase. The usage at line 321 is correct and idiomatic.

src/client/InputHandler.ts (2)

191-191: LGTM: Keybind change to Backslash.

The change from Space to Backslash for the toggleView keybind is reasonable and reduces potential conflicts with text input and browser shortcuts.


555-560: LGTM: Shift+drag pan prevention is well-implemented.

The logic correctly prevents panning when Shift is held, reserving Shift+drag for bounding box selection. This implementation is consistent with the existing keyboard panning behavior at lines 243-248, which also skips panning when Shift keys are active.

tsconfig.json (1)

24-34: Good config updates for the dev workflow.

skipLibCheck: true speeds up builds and avoids type conflicts in third-party packages. Adding "jest" to types keeps global type pollution minimal. The "scripts/**/*" include path matches the new scripts/dev-stop.js file added in this PR.

.gitignore (1)

13-24: Well-organized ignore rules.

Good additions:

  • config.json files stay local (users copy from config.example.json)
  • package-lock.json ignored since the project uses pnpm
  • Clear section comments improve readability
config.example.json (1)

1-36: Clear example config for local development.

Good structure with feature flags disabled by default (analytics, publicLobbies, cloudflare, ads all false). Settings are grouped by concern (display, interface, graphics, controls, privacy, audio), making it easy for developers to customize their local setup.

webpack.config.js (1)

189-266: Proxy targets correctly use dynamic ports.

WebSocket and HTTP proxies for workers use serverPort + 1, +2, +3, matching the workerPortByIndex pattern in DefaultConfig. API endpoints route to serverPort. All entries are consistent.

package.json (1)

12-12: Handy cleanup script.

dev:stop fits the naming pattern (dev:staging, dev:prod) and provides a quick way to kill dev processes on configured ports.

eslint.config.js (1)

31-31: Correct ESLint config for the new script.

Adding scripts/dev-stop.js to allowDefaultProject lets ESLint process it without requiring a full tsconfig reference. Placement is alphabetically correct.

src/core/configuration/DefaultConfig.ts (1)

234-236: Clean opt-out toggle for public games.

The method returns true by default, requiring explicit ENABLE_PUBLIC_GAMES=false to disable. This keeps production behavior on while letting developers skip public game scheduling locally. The method is properly declared in the ServerConfig interface and used in game scheduling logic.

src/client/components/baseComponents/setting/SettingTerritorySkins.ts (2)

87-93: LGTM!

Default skin "cosmos" exists in AVAILABLE_SKINS and state initialization is clean.


310-315: LGTM!

The disabled guard and state updates are correct.

src/client/components/baseComponents/setting/SettingColorPalette.ts (3)

280-302: LGTM!

Robust error handling with a safe fallback for corrupted localStorage.


328-356: LGTM!

Standard HSL conversion with correct edge case handling.


358-396: LGTM!

Clean HSL-to-hex conversion with proper padding.

resources/lang/en.json (1)

367-407: LGTM!

Localization updates align with the new Display tab and settings groups. Clean text improvements with emoji cleanup.

tests/util/TestServerConfig.ts (1)

16-18: LGTM!

Test stub follows the established pattern for unimplemented methods.

src/client/DarkModeButton.ts (1)

8-8: LGTM!

Method rename to isDarkModeActive() is clearer and consistent.

Also applies to: 31-31

src/core/configuration/Config.ts (1)

68-68: LGTM!

Clean interface addition for the public games feature toggle.

src/server/Master.ts (2)

96-114: LGTM!

Clean feature toggle with informative logging when disabled.


142-149: Restrict port range to 1–65535 to prevent unintended dynamic port assignment.

Port validation currently allows 0, which tells the operating system to automatically pick a random available port. This is unusual for a production server and has no documented purpose in the code. Change the validation from PORT < 0 to PORT < 1.

src/client/PublicLobby.ts (2)

34-46: LGTM!

Clean async feature flag gating that waits for config to load before initializing.


121-124: LGTM!

Consistent feature flag gating in render method.

src/client/Main.ts (5)

17-17: LGTM! Clean import for dev feature flag utilities.

Imports align with the DevConfig module pattern for conditional feature loading.


112-115: LGTM! Type update correctly reflects nullable token.

The union with null matches the new getTurnstileToken behavior when cloudflare feature is disabled.


120-121: LGTM! Config loaded early before dependent logic runs.

Awaiting loadDevConfig() at the start of initialize() ensures feature flags are ready for all downstream checks.


348-349: LGTM! Theme application simplified to single call.

Replacing manual DOM class manipulation with applyTheme() centralizes theme logic in UserSettings.


716-720: LGTM! Early exit prevents unnecessary Turnstile wait loop.

When cloudflare feature is disabled, returning null immediately avoids the 10-second polling loop for window.turnstile.

src/client/index.html (5)

133-147: Synchronous XHR blocks page render until config loads.

The synchronous request ensures window.__devConfig is set before any conditional script checks run. This is fine for local dev where /config.json exists. In production (missing file), the catch block handles the 404 silently.

One note: if the server is slow, this blocks the entire page. This is acceptable for dev environments but worth documenting.


149-161: LGTM! Turnstile conditionally loaded based on cloudflare flag.

The pattern !window.__devConfig || ...cloudflare !== false ensures scripts load in production (no config) and only skip in dev when explicitly disabled.


163-176: LGTM! Ads script conditionally loaded.

Same safe pattern applied for Publift/Fuse ads.


178-204: LGTM! Analytics scripts conditionally loaded.

Google Ads, Analytics, and gtag setup are gated behind the analytics feature flag.


523-535: LGTM! Cloudflare Analytics beacon conditionally loaded.

Consistent pattern with other conditional script blocks.

src/client/components/baseComponents/setting/SettingThemeMode.ts (2)

91-100: LGTM! Clean lifecycle management for event listener.

The dark-mode-changed listener is properly added on connect and removed on disconnect. Arrow function binding is correct.


102-111: LGTM! Legacy migration for darkMode setting.

Fallback from settings.darkMode to "dark" or "system" handles upgrades from the old boolean setting.

src/core/game/UserSettings.ts (3)

6-6: LGTM! Clean typed union for theme modes.

Using a union type instead of enum or class hierarchy is the right approach.


70-80: LGTM! Theme mode getter with validation and legacy support.

Validates stored value before returning and falls back to legacy darkMode migration.


176-188: LGTM! Toggle now uses theme mode system with event dispatch.

The toggle cycles between light and dark (skipping system), which is a reasonable UX for a quick-toggle button. Event dispatch keeps other components in sync.

src/client/styles/components/setting.css (2)

9-27: LGTM! Grouped settings layout with responsive handling.

Fixed 720px width on desktop provides consistent layout, with full-width fallback on mobile.


33-50: LGTM! CSS variables enable theme switching.

Fallback values ensure dark theme works even outside Modal context.

src/client/components/baseComponents/Modal.ts (3)

13-29: LGTM! MutationObserver properly manages theme detection lifecycle.

Observer watches only class attribute changes on documentElement and disconnects on component removal. This is efficient.


118-138: LGTM! Light theme CSS variables provide clean theming.

Defining theme tokens in the wrapper class allows child components to inherit correct values without additional logic.


182-189: LGTM! Class list construction is clean and readable.

Using array with filter(Boolean).join(" ") handles conditional classes well without template string clutter.

src/client/components/baseComponents/setting/SettingGroup.ts (1)

128-152: LGTM! Clean render with composition via slot.

The group component wraps slotted content, enabling flexible use across different setting types.

src/client/UserSettingModal.ts (5)

4-12: LGTM! Clean imports for new setting components.

All new components are properly imported with side-effect registrations.


19-19: LGTM! Settings mode extended with display tab.

Union type extended cleanly without breaking existing tab logic.


280-298: LGTM! Theme mode change handler with proper event dispatch.

Dispatches both theme-mode-changed (for new consumers) and dark-mode-changed (for DarkModeButton backward compatibility).


300-330: LGTM! Display settings grouped logically.

Theme mode, territory skins, and color palette are in separate collapsible groups. The disabled attribute on placeholders indicates future functionality.


726-729: LGTM! requestAnimationFrame fixes column layout timing.

Forcing a reflow after modal opens ensures the grouped/column layout renders correctly on first open.

src/client/DevConfig.ts (3)

4-57: Interface design is clean and well-structured.

The nested (preferred) and flat (legacy) format coexistence is well-documented and appropriate for backwards compatibility. Type definitions use clear, simple types that are easy to understand.


186-186: Auto-loading design is appropriate for this use case.

The immediate config load on module import is a reasonable trade-off. The API provides both waitForDevConfig() for async contexts and getDevConfig() for sync contexts (with defaults fallback). This pattern ensures config is available as early as possible without blocking module imports.


191-214: Public API is well-designed and clearly documented.

The mix of async (waitForDevConfig) and sync (getDevConfig) accessors provides flexibility for different usage contexts. The deprecation notice for loadDevConfig is properly marked. The convenience function isDevFeatureEnabled simplifies the common use case.

Comment on lines +285 to +294
private loadSkin() {
const saved = localStorage.getItem("settings.territorySkin");
if (saved) {
this.selectedSkinId = saved;
}
const customUrl = localStorage.getItem("settings.territorySkinCustom");
if (customUrl) {
this.customSkinUrl = customUrl;
}
}
Copy link
Contributor

@coderabbitai coderabbitai bot Dec 27, 2025

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Validate loaded skin ID and custom URL.

Loading from localStorage without validation risks broken state:

  • If settings.territorySkin contains an invalid ID, the component won't find a matching skin
  • settings.territorySkinCustom could contain a malicious URL (e.g., javascript: scheme)
🔎 Suggested validation
 private loadSkin() {
   const saved = localStorage.getItem("settings.territorySkin");
   if (saved) {
-    this.selectedSkinId = saved;
+    // Validate the saved skin exists
+    const skinExists = AVAILABLE_SKINS.some(s => s.id === saved) || saved === "custom";
+    if (skinExists) {
+      this.selectedSkinId = saved;
+    }
   }
   const customUrl = localStorage.getItem("settings.territorySkinCustom");
   if (customUrl) {
-    this.customSkinUrl = customUrl;
+    // Basic validation: must be http(s) or data URL
+    if (customUrl.startsWith("http://") || 
+        customUrl.startsWith("https://") || 
+        customUrl.startsWith("data:image/")) {
+      this.customSkinUrl = customUrl;
+    }
   }
 }
🤖 Prompt for AI Agents
In src/client/components/baseComponents/setting/SettingTerritorySkins.ts around
lines 285-294, the loadSkin method reads settings.territorySkin and
settings.territorySkinCustom from localStorage without validation; update it to
validate the saved skin ID against the current list of available skins (only
assign this.selectedSkinId if it matches an existing skin, otherwise leave or
set a safe default), and validate/sanitize the custom URL by allowing only safe
schemes (e.g., http and https), rejecting or clearing anything with javascript:
or other disallowed schemes, and handle non-string or malformed values
gracefully; ensure any invalid values fall back to safe defaults and do not get
assigned to component state.

Copy link
Author

Choose a reason for hiding this comment

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

Ignore. Placeholder UX

Copy link
Contributor

Choose a reason for hiding this comment

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

@nodeGarden, understood! I'll skip this validation since it's placeholder UX. Feel free to revisit when the final implementation is ready. 👍

Comment on lines +325 to +344
private handleUploadClick() {
if (this.disabled) return;
// Create hidden file input
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = () => {
this.customSkinUrl = reader.result as string;
this.selectedSkinId = "custom";
this.saveSkin();
};
reader.readAsDataURL(file);
}
};
input.click();
}
Copy link
Contributor

@coderabbitai coderabbitai bot Dec 27, 2025

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add file size validation and error handling.

Reading large images without limits risks:

  • localStorage quota exceeded (typically 5-10MB per origin)
  • Browser memory issues
  • Poor UX with no feedback on failure
🔎 Recommended safeguards
 private handleUploadClick() {
   if (this.disabled) return;
-  // Create hidden file input
   const input = document.createElement("input");
   input.type = "file";
   input.accept = "image/*";
   input.onchange = (e) => {
     const file = (e.target as HTMLInputElement).files?.[0];
     if (file) {
+      // Limit to 2MB to avoid localStorage quota issues
+      const MAX_FILE_SIZE = 2 * 1024 * 1024;
+      if (file.size > MAX_FILE_SIZE) {
+        console.error("File too large. Maximum size is 2MB.");
+        // TODO: Show user-facing error message
+        return;
+      }
       const reader = new FileReader();
       reader.onload = () => {
         this.customSkinUrl = reader.result as string;
         this.selectedSkinId = "custom";
         this.saveSkin();
       };
+      reader.onerror = () => {
+        console.error("Failed to read file.");
+        // TODO: Show user-facing error message
+      };
       reader.readAsDataURL(file);
     }
   };
   input.click();
 }
📝 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 handleUploadClick() {
if (this.disabled) return;
// Create hidden file input
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = () => {
this.customSkinUrl = reader.result as string;
this.selectedSkinId = "custom";
this.saveSkin();
};
reader.readAsDataURL(file);
}
};
input.click();
}
private handleUploadClick() {
if (this.disabled) return;
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
// Limit to 2MB to avoid localStorage quota issues
const MAX_FILE_SIZE = 2 * 1024 * 1024;
if (file.size > MAX_FILE_SIZE) {
console.error("File too large. Maximum size is 2MB.");
// TODO: Show user-facing error message
return;
}
const reader = new FileReader();
reader.onload = () => {
this.customSkinUrl = reader.result as string;
this.selectedSkinId = "custom";
this.saveSkin();
};
reader.onerror = () => {
console.error("Failed to read file.");
// TODO: Show user-facing error message
};
reader.readAsDataURL(file);
}
};
input.click();
}

Copy link
Author

Choose a reason for hiding this comment

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

Ignore. Placeholder UX

Copy link
Contributor

Choose a reason for hiding this comment

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

@nodeGarden, understood. I'll skip this for the placeholder implementation. 👍

…ngs UI

Local Development:
- Configurable ports via OPENFRONT_CLIENT_PORT and OPENFRONT_SERVER_PORT
- Dev feature flags system (DevConfig.ts) for toggling features locally
- pnpm dev:stop script to kill processes on dev ports (Windows + Unix)
- config.example.json template for local configuration
- ENABLE_PUBLIC_GAMES env var to disable public game scheduling

Settings UI Refactor:
- Tabbed interface (Settings, Keybinds, Display)
- Collapsible grouped settings with 2-column masonry layout
- Light/dark theme support with synced dark mode button
- Territory color palette editor
- Territory skins placeholder (cosmetics)

Server Improvements:
- PORT validation in Master.ts (0-65535 range check)
- Removed dead code (modal.css, unused pastel theme files)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@nodeGarden nodeGarden force-pushed the feature/local-dev-setup branch from e5c817b to 379a665 Compare December 27, 2025 16:13
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: 2

Caution

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

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

439-442: Remove debug logging statements.

These console.log calls appear to be debug artifacts and should be removed before merging to production.

🔎 Proposed fix
-      // Shift-D to toggle performance overlay
-      console.log(e.code, e.shiftKey, e.ctrlKey, e.altKey, e.metaKey);
       if (e.code === "KeyD" && e.shiftKey) {
         e.preventDefault();
-        console.log("TogglePerformanceOverlayEvent");
         this.eventBus.emit(new TogglePerformanceOverlayEvent());
       }
webpack.config.js (1)

136-145: Inconsistent WEBSOCKET_URL still hardcoded to port 3000.

The WEBSOCKET_URL uses a fixed localhost:3000 while dev server proxies now use dynamic serverPort. This could cause connection failures when using custom ports.

🔎 Proposed fix
 new webpack.DefinePlugin({
   "process.env.WEBSOCKET_URL": JSON.stringify(
-    isProduction ? "" : "localhost:3000",
+    isProduction ? "" : `localhost:${serverPort}`,
   ),
♻️ Duplicate comments (5)
scripts/dev-stop.js (2)

53-59: Unix branch always reports "stopped" even when no process exists.

The || true ensures the command never fails, so stoppedPorts.push(portNum) runs unconditionally. This was flagged in a past review.


71-72: Logs all ports instead of stopped ports.

Line 72 logs ports.join(", ") rather than stopped.join(", "). This was flagged in a past review.

src/client/components/baseComponents/setting/SettingTerritorySkins.ts (2)

285-294: Validate loaded skin ID and custom URL.

This was flagged in a past review. Loading from localStorage without validation risks broken state or malicious URLs.


325-344: Add file size validation and error handling.

This was flagged in a past review. Large files could exceed localStorage quota or cause memory issues.

src/client/DevConfig.ts (1)

74-98: Validation for config structure was flagged in a previous review.

A past review suggested adding basic structure validation after parsing the JSON. This remains unaddressed.

🧹 Nitpick comments (8)
src/client/components/baseComponents/setting/SettingGroup.ts (1)

96-107: Consider adding a type check for the parsed states object.

If localStorage gets corrupted (e.g., "settings.collapsedGroups" becomes a non-object), the access states[this.groupId] could fail unexpectedly.

Optional safeguard
 private loadCollapsedState() {
   if (!this.groupId) return;
   try {
     const saved = localStorage.getItem(COLLAPSED_STATE_KEY);
     if (saved) {
       const states = JSON.parse(saved) as Record<string, boolean>;
-      this.collapsed = states[this.groupId] ?? false;
+      if (states && typeof states === "object" && !Array.isArray(states)) {
+        this.collapsed = states[this.groupId] ?? false;
+      }
     }
   } catch {
     // Ignore parse errors
   }
 }
scripts/dev-stop.js (1)

6-7: Type inconsistency between clientPort and serverPort.

clientPort is a string, serverPort is parsed to number. When building the ports array, you mix string and number. This works because line 23 coerces to string, but it is confusing.

Consistent types
-const clientPort = process.env.OPENFRONT_CLIENT_PORT ?? "9000";
-const serverPort = parseInt(process.env.OPENFRONT_SERVER_PORT ?? "3000", 10);
+const clientPort = parseInt(process.env.OPENFRONT_CLIENT_PORT ?? "9000", 10);
+const serverPort = parseInt(process.env.OPENFRONT_SERVER_PORT ?? "3000", 10);
src/client/index.html (1)

134-147: Synchronous XHR blocks page rendering.

The synchronous XMLHttpRequest blocks the main thread until /config.json loads or fails. Consider using a small inline default and loading config asynchronously to avoid blocking initial paint.

That said, I understand the need to conditionally load scripts before they execute, so this may be an acceptable trade-off for dev environments.

src/client/components/baseComponents/setting/SettingColorPalette.ts (2)

328-356: hexToHsl assumes valid 7-character hex string.

If hex is malformed (e.g., "#FFF" shorthand or invalid chars), parseInt may return NaN and cause incorrect calculations. Consider adding validation or a fallback.

🔎 Proposed defensive check
 private hexToHsl(hex: string): { h: number; s: number; l: number } {
+  // Normalize shorthand hex and validate
+  if (!/^#[0-9A-Fa-f]{6}$/.test(hex)) {
+    return { h: 0, s: 0, l: 50 }; // Safe fallback
+  }
   const r = parseInt(hex.slice(1, 3), 16) / 255;
   const g = parseInt(hex.slice(3, 5), 16) / 255;
   const b = parseInt(hex.slice(5, 7), 16) / 255;

402-424: Click-only color picking may frustrate users.

The color preview and hue slider respond only to clicks, not drag. Users often expect to drag to fine-tune colors. Consider adding mousedown + mousemove listeners.

src/client/DevConfig.ts (2)

92-94: Silent catch swallows all fetch errors.

The empty catch block hides network errors, CORS issues, and JSON parse failures. Consider logging in dev mode or distinguishing 404 from other errors.

🔎 Proposed improvement
-  } catch {
-    // config.json not found, use defaults
+  } catch (error) {
+    // Log non-404 errors for debugging
+    if (error instanceof SyntaxError) {
+      console.warn("DevConfig: Invalid JSON in config.json", error);
+    }
   }

105-122: Helper functions are repetitive but clear.

The applyBool, applyFloat, and applyString helpers share the same pattern. Consider a generic helper, though the current approach is readable and type-safe.

🔎 Optional consolidation
const applyValue = <T extends string | number | boolean>(
  key: string,
  value: T | undefined,
) => {
  if (value !== undefined && localStorage.getItem(key) === null) {
    localStorage.setItem(key, String(value));
  }
};
src/client/UserSettingModal.ts (1)

111-112: Debug console.log statements remain in toggle handlers.

Multiple toggle handlers still log state changes (e.g., "🌙 Dark Mode: ON"). Consider removing these before merge or gating behind a debug flag.

Also applies to: 120-121, 129-130, 138-139, 147-148, 156-157, 165-166, 173-174, 181-182

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bcddf9f and e5c817b.

⛔ Files ignored due to path filters (6)
  • docs/user-settings--display-2.jpg is excluded by !**/*.jpg
  • docs/user-settings--display.jpg is excluded by !**/*.jpg
  • docs/user-settings--keybind.jpg is excluded by !**/*.jpg
  • docs/user-settings--settings.jpg is excluded by !**/*.jpg
  • package-lock.json is excluded by !**/package-lock.json
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (29)
  • .gitignore
  • config.example.json
  • eslint.config.js
  • package.json
  • resources/lang/en.json
  • scripts/dev-stop.js
  • src/client/DarkModeButton.ts
  • src/client/DevConfig.ts
  • src/client/InputHandler.ts
  • src/client/Main.ts
  • src/client/PublicLobby.ts
  • src/client/UserSettingModal.ts
  • src/client/components/baseComponents/Modal.ts
  • src/client/components/baseComponents/setting/SettingColorPalette.ts
  • src/client/components/baseComponents/setting/SettingGroup.ts
  • src/client/components/baseComponents/setting/SettingTerritorySkins.ts
  • src/client/components/baseComponents/setting/SettingThemeMode.ts
  • src/client/graphics/layers/PlayerInfoOverlay.ts
  • src/client/index.html
  • src/client/styles.css
  • src/client/styles/components/modal.css
  • src/client/styles/components/setting.css
  • src/core/configuration/Config.ts
  • src/core/configuration/DefaultConfig.ts
  • src/core/game/UserSettings.ts
  • src/server/Master.ts
  • tests/util/TestServerConfig.ts
  • tsconfig.json
  • webpack.config.js
💤 Files with no reviewable changes (2)
  • src/client/styles.css
  • src/client/styles/components/modal.css
🚧 Files skipped from review as they are similar to previous changes (13)
  • src/core/game/UserSettings.ts
  • package.json
  • src/client/styles/components/setting.css
  • tsconfig.json
  • eslint.config.js
  • config.example.json
  • tests/util/TestServerConfig.ts
  • src/client/graphics/layers/PlayerInfoOverlay.ts
  • src/core/configuration/Config.ts
  • src/client/components/baseComponents/setting/SettingThemeMode.ts
  • src/core/configuration/DefaultConfig.ts
  • src/client/PublicLobby.ts
  • .gitignore
🧰 Additional context used
🧠 Learnings (8)
📚 Learning: 2025-08-28T15:14:40.664Z
Learnt from: mokizzz
Repo: openfrontio/OpenFrontIO PR: 1956
File: src/client/InputHandler.ts:430-449
Timestamp: 2025-08-28T15:14:40.664Z
Learning: On trackpads and touchscreens, users don't expect purely vertical two-finger swipes to trigger zoom. Pan detection using deltaX !== 0 is appropriate since any horizontal movement indicates pan intent, and zoom should be handled via pinch gestures instead.

Applied to files:

  • src/client/InputHandler.ts
📚 Learning: 2025-06-07T19:47:34.514Z
Learnt from: its-sii
Repo: openfrontio/OpenFrontIO PR: 1089
File: src/client/styles/components/setting.css:9-22
Timestamp: 2025-06-07T19:47:34.514Z
Learning: In src/client/styles/components/setting.css, the padding difference between .setting-item and .setting-item-group is intentional - .setting-item-group doesn't need padding for the specific grouped setting styling being implemented.

Applied to files:

  • src/client/components/baseComponents/setting/SettingGroup.ts
📚 Learning: 2025-11-12T23:11:34.445Z
Learnt from: MaxHT0x
Repo: openfrontio/OpenFrontIO PR: 2262
File: src/core/configuration/DefaultConfig.ts:806-832
Timestamp: 2025-11-12T23:11:34.445Z
Learning: In src/core/configuration/DefaultConfig.ts, JSDoc documentation for configuration methods should not be added inline, as it was requested that documentation be placed elsewhere in the codebase.

Applied to files:

  • src/client/DevConfig.ts
📚 Learning: 2025-06-20T20:11:00.965Z
Learnt from: devalnor
Repo: openfrontio/OpenFrontIO PR: 1195
File: src/client/graphics/layers/AlertFrame.ts:18-18
Timestamp: 2025-06-20T20:11:00.965Z
Learning: In the OpenFrontIO codebase, UserSettings instances are created directly with `new UserSettings()` in each component that needs them. This pattern is used consistently across at least 12+ files including OptionsMenu, EventsDisplay, DarkModeButton, Main, UserSettingModal, UsernameInput, NameLayer, AlertFrame, UILayer, InputHandler, ClientGameRunner, and GameView. This is the established architectural pattern and should be followed for consistency.

Applied to files:

  • src/client/UserSettingModal.ts
📚 Learning: 2025-06-09T02:20:43.637Z
Learnt from: VariableVince
Repo: openfrontio/OpenFrontIO PR: 1110
File: src/client/Main.ts:293-295
Timestamp: 2025-06-09T02:20:43.637Z
Learning: In src/client/Main.ts, during game start in the handleJoinLobby callback, UI elements are hidden using direct DOM manipulation with classList.add("hidden") for consistency. This includes modals, buttons, and error divs. The codebase follows this pattern rather than using component APIs for hiding elements during game transitions.

Applied to files:

  • src/client/UserSettingModal.ts
  • src/client/Main.ts
  • src/client/components/baseComponents/Modal.ts
📚 Learning: 2025-07-26T05:35:03.686Z
Learnt from: aaa4xu
Repo: openfrontio/OpenFrontIO PR: 1581
File: webpack.config.js:139-141
Timestamp: 2025-07-26T05:35:03.686Z
Learning: In OpenFrontIO's webpack configuration, binary-loader processes files from resources/maps at build-time and inlines the binary payload directly into the compiled JS bundle. This means the original .bin assets are no longer needed at runtime and can be safely excluded from the final Docker image using webpack's globOptions.ignore pattern to reduce image size.

Applied to files:

  • webpack.config.js
📚 Learning: 2025-08-12T00:31:50.144Z
Learnt from: scottanderson
Repo: openfrontio/OpenFrontIO PR: 1752
File: src/core/game/Game.ts:750-752
Timestamp: 2025-08-12T00:31:50.144Z
Learning: In the OpenFrontIO codebase, changes to the PlayerInteraction interface (like adding canDonateGold and canDonateTroops flags) do not require corresponding updates to src/core/Schemas.ts or server serialization code.

Applied to files:

  • src/server/Master.ts
📚 Learning: 2025-06-02T14:27:37.609Z
Learnt from: andrewNiziolek
Repo: openfrontio/OpenFrontIO PR: 1007
File: resources/lang/de.json:115-115
Timestamp: 2025-06-02T14:27:37.609Z
Learning: For OpenFrontIO project: When localization keys are renamed in language JSON files, the maintainers separate technical changes from translation content updates. They wait for community translators to update the actual translation values rather than attempting to translate in the same PR. This allows technical changes to proceed while ensuring accurate translations from native speakers.

Applied to files:

  • resources/lang/en.json
🧬 Code graph analysis (4)
scripts/dev-stop.js (1)
webpack.config.js (2)
  • clientPort (18-18)
  • serverPort (19-19)
src/client/components/baseComponents/setting/SettingColorPalette.ts (1)
src/client/components/baseComponents/Modal.ts (1)
  • customElement (5-214)
webpack.config.js (3)
src/core/configuration/DefaultConfig.ts (1)
  • gitCommit (153-155)
src/core/configuration/DevConfig.ts (1)
  • gitCommit (45-47)
scripts/dev-stop.js (1)
  • clientPort (6-6)
src/server/Master.ts (3)
src/core/game/GameView.ts (1)
  • config (665-667)
src/core/game/GameImpl.ts (1)
  • config (336-338)
src/client/PublicLobby.ts (1)
  • fetchLobbies (90-111)
🔇 Additional comments (29)
src/client/InputHandler.ts (2)

555-560: Clean implementation of Shift+drag reservation.

The logic correctly prevents panning when Shift is held, reserving it for bounding box selection. This is consistent with the existing keyboard pan skip at lines 243-248.


191-191: Note: Default keybind changed from Space to Backslash.

The toggleView keybind default changed from "Space" to "Backslash". Existing users with saved preferences won't be affected, but new users will experience the new default. Backslash may be less discoverable than Space but avoids potential conflicts with other Space-key interactions.

src/client/DarkModeButton.ts (1)

8-8: Clean update to the new theme API.

The shift to isDarkModeActive() aligns well with the centralized theme management in UserSettings. Both the initial state and the post-toggle state now use the same accessor, which keeps behavior consistent.

Also applies to: 31-31

src/server/Master.ts (2)

96-114: Well-structured feature flag gating.

The conditional scheduling based on config.enablePublicGames() is clean. Logging when disabled helps with debugging.


142-149: Good PORT validation with clear error message.

The validation covers all edge cases (NaN, negative, above 65535) and exits gracefully with a helpful error.

src/client/components/baseComponents/Modal.ts (3)

13-33: Solid theme observation pattern.

The MutationObserver correctly watches for class changes on documentElement and cleans up on disconnect. This keeps the modal in sync with global theme state without tight coupling.


118-169: CSS variables for theming are well organized.

Defining theme tokens (--modal-bg, --modal-text, etc.) in both light and dark variants allows child components to inherit styling without duplicating rules. Clean approach.


183-189: Readable class composition.

The array-filter-join pattern for building class strings is cleaner than template string concatenation with conditionals.

src/client/components/baseComponents/setting/SettingGroup.ts (1)

1-153: Clean component structure overall.

The collapsible group with localStorage persistence is well-implemented. CSS variables integrate nicely with the parent modal theming, and the toggle animation is smooth.

resources/lang/en.json (1)

365-384: Localization keys align with the new settings structure.

The new group labels (group_theme, group_skins, etc.) and display-related strings are well-organized. The keys follow existing naming conventions.

src/client/Main.ts (3)

120-121: Good: DevConfig loads before other initialization.

Awaiting loadDevConfig() early ensures feature flags are available before Turnstile prefetch and other feature-gated logic runs.


348-349: Centralized theme application is cleaner.

Delegating to userSettings.applyTheme() instead of manual DOM manipulation keeps the theme logic in one place.


716-720: Clean feature guard for Turnstile.

Returning null early when cloudflare is disabled prevents unnecessary script loading and token retrieval in dev mode.

src/client/components/baseComponents/setting/SettingTerritorySkins.ts (1)

4-9: Clean component structure with typed Skin interface.

Using a simple interface for Skin instead of a class hierarchy is the right call. The @state() decorators for selectedSkinId and customSkinUrl keep the component reactive.

Also applies to: 87-93

webpack.config.js (3)

3-19: Good setup for configurable dev ports.

The dotenv integration and port parsing look correct. Using parseInt with radix 10 and sensible defaults (9000/3000) is the right approach.


26-50: Content hashing for assets improves caching.

Adding [contenthash] to asset filenames is a good practice for cache busting. The consistent pattern across binary, text, images, and fonts helps with long-term caching.


188-266: Dynamic proxy targets are correctly calculated.

The worker proxies correctly use serverPort + 1, serverPort + 2, and serverPort + 3 for the three workers. Both WebSocket and HTTP proxies are aligned.

src/client/index.html (1)

149-204: Feature flag conditional loading pattern is correct.

The checks follow a safe pattern: load scripts when no dev config exists (production) OR when the feature is not explicitly set to false. This preserves production behavior while allowing dev overrides.

Also applies to: 523-535

src/client/components/baseComponents/setting/SettingColorPalette.ts (3)

4-60: Clean interface and swatch definitions.

The ColorSwatch interface is simple and typed. The DEFAULT_SWATCHES array provides good semantic defaults for territory colors.


432-447: Hex input validation is robust.

The regex /^#[0-9A-Fa-f]{6}$/ correctly validates full hex colors before applying. Good handling of the # prefix normalization.


275-313: localStorage persistence and event dispatch are correct.

The component correctly loads from settings.colorPalette, falls back to defaults on parse error, and dispatches a bubbling change event on save.

src/client/DevConfig.ts (2)

124-165: Nested settings applied before flat settings.

If both settings.display.themeMode and settings.themeMode exist in config.json, the flat value wins because it runs second. This is fine for backwards compatibility, but consider documenting this precedence.


185-214: Clean public API with async and sync accessors.

The pattern of auto-loading at module init with both waitForDevConfig() (async) and getDevConfig() (sync with fallback) is practical. The deprecation alias for loadDevConfig helps migration.

src/client/UserSettingModal.ts (6)

4-13: New imports for display settings components.

The imports for SettingColorPalette, SettingGroup, SettingTerritorySkins, and SettingThemeMode are correctly added. The ThemeMode type import from UserSettings is appropriate.


280-298: Theme mode change handler dispatches two events.

The handler correctly updates settings and dispatches both theme-mode-changed and dark-mode-changed events. Dispatching dark-mode-changed on window keeps DarkModeButton in sync.


300-330: Display tab renders theme, skins, and color palette.

The three setting groups provide clear organization. The disabled attribute on skins and color palette indicates these are placeholders for future cosmetics.


723-730: requestAnimationFrame reflow hack indicates timing issue.

The double requestUpdate suggests a CSS layout race condition with the columns. This works but is fragile. Consider investigating root cause in the modal/group CSS if time permits.


332-491: Basic settings reorganized into logical groups.

The grouping into Interface, Graphics, Controls, and Privacy sections improves discoverability. Using setting-group with columns attribute provides the 2-column masonry layout.


493-720: Keybind settings well-organized by function.

The four groups (View Options, Build Controls, Attack Controls, Camera Controls) make keybinds easier to find. Each keybind has clear labels and descriptions.

Comment on lines +317 to +323
private getPreviewBackground(): string {
if (this.customSkinUrl) {
return `url(${this.customSkinUrl})`;
}
const skin = AVAILABLE_SKINS.find((s) => s.id === this.selectedSkinId);
return skin?.thumbnail ?? DEFAULT_PREVIEW;
}
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

Potential CSS injection via customSkinUrl.

Directly interpolating customSkinUrl into the url() CSS function without sanitization is risky. A crafted URL containing ) could break out of the url() and inject arbitrary CSS.

Suggested fix: sanitize or validate the URL
 private getPreviewBackground(): string {
   if (this.customSkinUrl) {
+    // Only allow data URLs or safe http(s) URLs
+    if (
+      this.customSkinUrl.startsWith("data:image/") ||
+      this.customSkinUrl.startsWith("https://") ||
+      this.customSkinUrl.startsWith("http://")
+    ) {
+      // Escape any characters that could break CSS url()
+      const safeUrl = this.customSkinUrl.replace(/[()'"\\]/g, "");
+      return `url(${safeUrl})`;
+    }
+    return DEFAULT_PREVIEW;
-    return `url(${this.customSkinUrl})`;
   }
   const skin = AVAILABLE_SKINS.find((s) => s.id === this.selectedSkinId);
   return skin?.thumbnail ?? DEFAULT_PREVIEW;
 }

Committable suggestion skipped: line range outside the PR's diff.

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

Caution

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

⚠️ Outside diff range comments (2)
src/core/execution/utils/AiAttackBehavior.ts (1)

190-235: Keep these methods private since they are not used outside the class.

These methods (attackBots(), getBotAttackMaxParallelism(), maybeBetrayAndAttack(), isBorderingNukedTerritory(), findNearestIslandEnemy()) are only called internally within AiAttackBehavior. Making them public without external usage creates an unintended public API surface.

src/core/execution/NationExecution.ts (1)

405-436: Document or revert public visibility of sendBoatRandomly.

This method is called only internally within NationExecution.ts (lines 387, 391). No external callers exist in the codebase. If this visibility change is intentional for future use, add a comment explaining why. Otherwise, return it to private.

♻️ Duplicate comments (5)
src/client/DevConfig.ts (1)

74-98: Add basic validation for loaded config structure.

As noted in a previous review, the loaded JSON is used without validation. If /config.json has an unexpected shape, runtime errors could occur when accessing config.features.

🔎 Proposed validation
    if (response.ok) {
      const config = await response.json();
+     // Basic structure check
+     if (
+       !config ||
+       typeof config !== "object" ||
+       (config.features && typeof config.features !== "object")
+     ) {
+       console.warn("Invalid config.json structure, using defaults");
+       cachedConfig = defaultConfig;
+       return defaultConfig;
+     }
      const mergedConfig: DevFeatureConfig = {
        features: { ...defaultConfig.features, ...config.features },
        settings: config.settings,
      };
src/client/index.html (1)

177-180: Trailing space in page_url value persists.

Line 179 still has a trailing space in "http://openfront.io ". A past review comment indicated this was addressed, but it remains.

🔎 Proposed fix
       googletag.cmd.push(function () {
-        googletag.pubads().set("page_url", "http://openfront.io ");
+        googletag.pubads().set("page_url", "http://openfront.io");
       });
src/client/components/baseComponents/setting/SettingTerritorySkins.ts (3)

285-294: Validate saved skin ID and custom URL from localStorage.

Previous review flagged this: loading from localStorage without validation risks broken state if settings.territorySkin contains an invalid ID, or if settings.territorySkinCustom contains a malicious URL (e.g., javascript: scheme). The suggested fix was to validate the skin exists in AVAILABLE_SKINS and only allow safe URL schemes.


317-323: Sanitize customSkinUrl before CSS interpolation.

Previous review flagged potential CSS injection: directly interpolating customSkinUrl into url() without sanitization allows a crafted URL containing ) to break out and inject arbitrary CSS.


325-344: Add file size validation for uploads.

Previous review flagged missing file size limits: reading large images without bounds risks localStorage quota exceeded (typically 5-10MB per origin) and poor UX with no error feedback. Suggested limiting to 2MB with user-facing error message.

🧹 Nitpick comments (14)
src/core/execution/nation/NationMIRVBehavior.ts (3)

63-79: Potential unthrottled retry when maybeSendMIRV fails.

If maybeSendMIRV() cannot fire (e.g., canBuild returns false), no cooldown is triggered, but considerMIRV() still returns true. This means the target selection logic runs again next tick without any throttling. If the failure condition persists, this creates repeated per-tick overhead.

Consider triggering cooldown in maybeSendMIRV even on failed builds, or returning whether a MIRV was actually sent.

🔎 Option: trigger cooldown on failed build
  private maybeSendMIRV(enemy: Player): void {
    if (this.player === null) throw new Error("not initialized");

    this.emojiBehavior.maybeSendAttackEmoji(enemy);

    const centerTile = this.calculateTerritoryCenter(enemy);
    if (centerTile && this.player.canBuild(UnitType.MIRV, centerTile)) {
      this.sendMIRV(centerTile);
      this.emojiBehavior.sendEmoji(AllPlayers, EMOJI_NUKE);
      return;
+   } else {
+     // Trigger cooldown even on failed build to prevent per-tick retry overhead
+     this.triggerMIRVCooldown();
    }
  }

178-178: Consider extracting cache TTL to a static constant.

The local constant MIRV_TARGETS_CACHE_TICKS is defined inline. For consistency with other static constants in this class, consider moving it to the class level. The comment // 2 seconds helps, but a named constant like MIRV_TARGETS_CACHE_TICKS = 20 at class level would be clearer.


241-243: Avoid creating full array just to get first element.

Array.from(this.player.tiles())[0] builds the entire tiles array just to grab the first tile. If the player has many tiles, this is wasteful. You can use the iterator directly:

-    const cooldownTile =
-      tile ?? Array.from(this.player.tiles())[0] ?? this.game.ref(0, 0);
+    const cooldownTile =
+      tile ?? this.player.tiles().next().value ?? this.game.ref(0, 0);

Note: This assumes tiles() returns an iterator. If it returns an iterable, use this.player.tiles()[Symbol.iterator]().next().value.

webpack.config.js (2)

28-28: Consider removing inline commit-artifact comments.

Comments like // Added content hash and // Changed from raw-loader are artifacts from the development process. They don't add value to code readers since the configuration itself is self-documenting.

🔎 Proposed cleanup
     output: {
       publicPath: "/",
-      filename: "js/[name].[contenthash].js", // Added content hash
+      filename: "js/[name].[contenthash].js",
       path: path.resolve(__dirname, "static"),
       clean: isProduction,
     },
     module: {
       rules: [
         {
           test: /\.bin$/,
-          type: "asset/resource", // Changed from raw-loader
+          type: "asset/resource",
           generator: {
-            filename: "binary/[name].[contenthash][ext]", // Added content hash
+            filename: "binary/[name].[contenthash][ext]",
           },
         },
         {
           test: /\.md$/,
-          type: "asset/resource", // Changed from raw-loader
+          type: "asset/resource",
           generator: {
-            filename: "text/[name].[contenthash][ext]", // Added content hash
+            filename: "text/[name].[contenthash][ext]",
           },
         },
         {
           test: /\.(webp|png|jpe?g|gif)$/i,
           type: "asset/resource",
           generator: {
-            filename: "images/[name].[contenthash][ext]", // Added content hash
+            filename: "images/[name].[contenthash][ext]",
           },
         },
         {
           test: /\.svg$/,
-          type: "asset/resource", // Changed from asset/inline for caching
+          type: "asset/resource",
           generator: {
-            filename: "images/[name].[contenthash][ext]", // Added content hash
+            filename: "images/[name].[contenthash][ext]",
           },
         },
         {
           test: /\.(woff|woff2|eot|ttf|otf|xml)$/,
-          type: "asset/resource", // Changed from file-loader
+          type: "asset/resource",
           generator: {
-            filename: "fonts/[name].[contenthash][ext]", // Added content hash and fixed path
+            filename: "fonts/[name].[contenthash][ext]",
           },
         },

Also applies to: 36-39, 47-50, 81-81, 92-92, 99-99


28-28: Good caching improvements with contenthash, but remove commit artifact comments.

The contenthash additions are sound for cache busting. The change from raw-loader to asset/resource for .bin files is safe—code that requires inlining (like BinaryLoaderGameMapLoader.ts) uses explicit !!binary-loader! inline loader syntax, which overrides the webpack config rule. The config rule serves as a flexible default for other cases.

Remove inline comments like "// Added content hash" and "// Changed from raw-loader"—these are commit artifacts that clutter the code. Keep the config clean by removing these explanatory comments.

src/client/graphics/layers/SAMRadiusLayer.ts (3)

253-298: O(n²) complexity in circle overlap detection.

The nested loop checks every circle against every other circle, resulting in O(n²) complexity. With many active SAM launchers, this could impact performance during redraws.

Consider spatial indexing (e.g., quadtree or grid-based bucketing) to reduce the number of overlap checks, especially if games can have 50+ SAM launchers simultaneously.

Since this only runs when needsRedraw is true (not every frame), the current approach may be acceptable depending on typical SAM counts in production.


186-192: Extract hard-coded styling values.

Colors, line widths, and dash patterns are hard-coded. Given the PR adds theme mode support (light/dark/system), these values should integrate with the theme system or at minimum be named constants.

private readonly STYLE_CONFIG = {
  outlineColor: "rgba(0, 0, 0, 1)",
  lineColorSelf: "rgba(0, 255, 0, 1)",
  lineColorEnemy: "rgba(255, 0, 0, 1)",
  lineColorFriend: "rgba(255, 255, 0, 1)",
  extraOutlineWidth: 1,
  lineWidth: 2,
  lineDash: [12, 6] as const,
};

Or better, integrate with this.game.config().theme() if SAM radius colors should respect the theme.


213-251: Complex geometric logic could be extracted for testing.

The interval merging (lines 213-251) and arc overlap calculations (lines 253-350) involve intricate geometric reasoning that's difficult to verify by inspection alone.

Consider extracting to a utility module:

// src/client/graphics/utils/CircleGeometry.ts
export function mergeAngularIntervals(
  intervals: Array<[number, number]>
): Array<[number, number]> {
  // ... current logic
}

export function computeCircleOverlap(
  a: Circle,
  b: Circle
): { fullyCovered: boolean; arcSpan?: [number, number] } {
  // ... current logic
}

This would enable:

  • Unit tests for edge cases (coincident circles, tangent circles, fully contained, etc.)
  • Reuse in other rendering contexts
  • Easier verification of correctness

Also applies to: 253-350

map-generator/README.md (1)

7-7: Use markdown link format for the URL.

Static analysis flags this as a bare URL. Consider using proper markdown link format for consistency.

🔎 Proposed fix
-1. Install go https://go.dev/doc/install
+1. Install go from the [official site](https://go.dev/doc/install)
src/core/execution/utils/AiAttackBehavior.ts (1)

106-118: Redundant condition check on line 112.

The condition borderingEnemies.length === 0 at line 112 is always true when reached, since lines 106-109 already return when borderingEnemies.length > 0. You can simplify by removing this check.

🔎 Proposed fix
     // Attack the weakest player
     if (borderingEnemies.length > 0) {
       this.sendAttack(borderingEnemies[0]);
       return;
     }

     // If we don't have bordering enemies, attack someone on an island next to us
-    if (borderingEnemies.length === 0) {
-      const nearestIslandEnemy = this.findNearestIslandEnemy();
-      if (nearestIslandEnemy) {
-        this.sendAttack(nearestIslandEnemy);
-        return;
-      }
+    const nearestIslandEnemy = this.findNearestIslandEnemy();
+    if (nearestIslandEnemy) {
+      this.sendAttack(nearestIslandEnemy);
     }
   }
src/client/components/baseComponents/setting/SettingThemeMode.ts (1)

102-111: Duplicated theme mode loading logic.

The loadThemeMode() method duplicates logic from UserSettings.themeMode(). Consider using a UserSettings instance to read the value instead.

🔎 Proposed approach
+import { UserSettings, ThemeMode } from "../../../../core/game/UserSettings";
+
 export class SettingThemeMode extends LitElement {
+  private userSettings = new UserSettings();
+
   private loadThemeMode() {
-    const stored = localStorage.getItem("settings.themeMode");
-    if (stored === "light" || stored === "dark" || stored === "system") {
-      this.selectedMode = stored;
-    } else {
-      // Check legacy darkMode
-      const darkMode = localStorage.getItem("settings.darkMode");
-      this.selectedMode = darkMode === "true" ? "dark" : "system";
-    }
+    this.selectedMode = this.userSettings.themeMode();
   }
src/client/styles/components/setting.css (1)

399-411: Sticky tabs rely on magic negative margins.

The margin: -1.4rem -1.4rem 0.75rem -1.4rem and width: calc(100% + 2.8rem) depend on the parent's padding being exactly 1.4rem. If the modal content padding changes, this breaks. Consider documenting this coupling or using CSS custom properties for the padding value.

src/client/components/baseComponents/setting/SettingTerritorySkins.ts (1)

296-308: Consider clearing custom URL when selecting a preset skin.

When selectSkin(id) is called, customSkinUrl is set to null, but saveSkin() only saves the custom URL if it's truthy. This means the old custom URL may persist in localStorage even after switching to a preset skin. Consider explicitly removing the key:

Suggested fix
 private saveSkin() {
   localStorage.setItem("settings.territorySkin", this.selectedSkinId);
   if (this.customSkinUrl) {
     localStorage.setItem("settings.territorySkinCustom", this.customSkinUrl);
+  } else {
+    localStorage.removeItem("settings.territorySkinCustom");
   }
src/client/UserSettingModal.ts (1)

723-730: Double requestUpdate may be unnecessary.

The open() method calls requestUpdate(), then schedules another requestUpdate() via requestAnimationFrame. The second call might be needed for the column layout fix mentioned in the comment, but consider if this is a symptom of a deeper issue with Lit's update lifecycle.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e5c817b and 379a665.

⛔ Files ignored due to path filters (6)
  • docs/user-settings--display-2.jpg is excluded by !**/*.jpg
  • docs/user-settings--display.jpg is excluded by !**/*.jpg
  • docs/user-settings--keybind.jpg is excluded by !**/*.jpg
  • docs/user-settings--settings.jpg is excluded by !**/*.jpg
  • package-lock.json is excluded by !**/package-lock.json
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (53)
  • .gitignore
  • config.example.json
  • eslint.config.js
  • jest.config.ts
  • map-generator/README.md
  • map-generator/go.mod
  • map-generator/main.go
  • map-generator/map_generator.go
  • package.json
  • resources/lang/en.json
  • scripts/dev-stop.js
  • src/client/DarkModeButton.ts
  • src/client/DevConfig.ts
  • src/client/HelpModal.ts
  • src/client/HostLobbyModal.ts
  • src/client/InputHandler.ts
  • src/client/Main.ts
  • src/client/PublicLobby.ts
  • src/client/SinglePlayerModal.ts
  • src/client/UserSettingModal.ts
  • src/client/components/FluentSlider.ts
  • src/client/components/baseComponents/Modal.ts
  • src/client/components/baseComponents/setting/SettingColorPalette.ts
  • src/client/components/baseComponents/setting/SettingGroup.ts
  • src/client/components/baseComponents/setting/SettingTerritorySkins.ts
  • src/client/components/baseComponents/setting/SettingThemeMode.ts
  • src/client/graphics/GameRenderer.ts
  • src/client/graphics/layers/PlayerInfoOverlay.ts
  • src/client/graphics/layers/SAMRadiusLayer.ts
  • src/client/index.html
  • src/client/styles.css
  • src/client/styles/components/modal.css
  • src/client/styles/components/setting.css
  • src/core/configuration/Config.ts
  • src/core/configuration/DefaultConfig.ts
  • src/core/configuration/DevConfig.ts
  • src/core/configuration/PastelTheme.ts
  • src/core/configuration/PastelThemeDark.ts
  • src/core/execution/NationExecution.ts
  • src/core/execution/SAMLauncherExecution.ts
  • src/core/execution/nation/NationAllianceBehavior.ts
  • src/core/execution/nation/NationMIRVBehavior.ts
  • src/core/execution/utils/AiAttackBehavior.ts
  • src/core/game/GameMap.ts
  • src/core/game/GameView.ts
  • src/core/game/UserSettings.ts
  • src/server/MapPlaylist.ts
  • src/server/Master.ts
  • tests/client/components/FluentSlider.test.ts
  • tests/util/TestConfig.ts
  • tests/util/TestServerConfig.ts
  • tsconfig.json
  • webpack.config.js
💤 Files with no reviewable changes (9)
  • src/core/game/GameMap.ts
  • src/client/styles.css
  • src/core/configuration/PastelTheme.ts
  • src/server/MapPlaylist.ts
  • src/core/configuration/PastelThemeDark.ts
  • src/client/components/FluentSlider.ts
  • tests/client/components/FluentSlider.test.ts
  • src/client/styles/components/modal.css
  • map-generator/main.go
🚧 Files skipped from review as they are similar to previous changes (13)
  • src/client/DarkModeButton.ts
  • src/client/InputHandler.ts
  • src/client/graphics/layers/PlayerInfoOverlay.ts
  • src/core/configuration/Config.ts
  • .gitignore
  • tests/util/TestServerConfig.ts
  • eslint.config.js
  • src/core/configuration/DefaultConfig.ts
  • config.example.json
  • scripts/dev-stop.js
  • src/client/Main.ts
  • src/server/Master.ts
  • src/client/PublicLobby.ts
🧰 Additional context used
🧠 Learnings (40)
📚 Learning: 2025-10-20T20:15:28.858Z
Learnt from: sambokai
Repo: openfrontio/OpenFrontIO PR: 2225
File: src/core/execution/FakeHumanExecution.ts:51-51
Timestamp: 2025-10-20T20:15:28.858Z
Learning: In src/core/execution/FakeHumanExecution.ts, game balance constants like MIRV_COOLDOWN_TICKS, MIRV_HESITATION_ODDS, VICTORY_DENIAL_TEAM_THRESHOLD, VICTORY_DENIAL_INDIVIDUAL_THRESHOLD, and STEAMROLL_CITY_GAP_MULTIPLIER are experimental tuning parameters subject to frequent change during balance testing. Do not flag changes to these values as issues or compare them against previous values.

Applied to files:

  • src/core/configuration/DevConfig.ts
  • src/core/execution/nation/NationAllianceBehavior.ts
  • tests/util/TestConfig.ts
  • src/core/execution/SAMLauncherExecution.ts
  • src/core/execution/nation/NationMIRVBehavior.ts
  • src/core/execution/NationExecution.ts
  • src/core/execution/utils/AiAttackBehavior.ts
📚 Learning: 2025-11-12T23:11:34.445Z
Learnt from: MaxHT0x
Repo: openfrontio/OpenFrontIO PR: 2262
File: src/core/configuration/DefaultConfig.ts:806-832
Timestamp: 2025-11-12T23:11:34.445Z
Learning: In src/core/configuration/DefaultConfig.ts, JSDoc documentation for configuration methods should not be added inline, as it was requested that documentation be placed elsewhere in the codebase.

Applied to files:

  • src/core/configuration/DevConfig.ts
📚 Learning: 2025-07-31T06:39:29.144Z
Learnt from: VariableVince
Repo: openfrontio/OpenFrontIO PR: 1536
File: src/core/execution/utils/BotBehavior.ts:41-54
Timestamp: 2025-07-31T06:39:29.144Z
Learning: In the OpenFrontIO codebase, the `PseudoRandom.chance(odds: number)` method takes inverse odds as a parameter, not a percentage. The probability is calculated as `1.0 / odds`. So `chance(1.5)` gives 66.67% probability, `chance(2)` gives 50% probability, etc. The implementation returns `Math.floor(this._nextFloat() * odds) === 0`.

Applied to files:

  • src/core/configuration/DevConfig.ts
  • tests/util/TestConfig.ts
📚 Learning: 2025-10-20T11:02:16.969Z
Learnt from: sambokai
Repo: openfrontio/OpenFrontIO PR: 2225
File: src/core/execution/FakeHumanExecution.ts:57-57
Timestamp: 2025-10-20T11:02:16.969Z
Learning: In src/core/execution/FakeHumanExecution.ts, the correct MIRV victory denial thresholds are VICTORY_DENIAL_TEAM_THRESHOLD = 0.8 (80% for team games) and VICTORY_DENIAL_INDIVIDUAL_THRESHOLD = 0.65 (65% for individual players), not 0.85 and 0.7 as might be mentioned in some documentation.

Applied to files:

  • src/core/configuration/DevConfig.ts
  • src/core/execution/nation/NationAllianceBehavior.ts
  • src/core/execution/SAMLauncherExecution.ts
  • src/core/execution/nation/NationMIRVBehavior.ts
  • src/core/execution/NationExecution.ts
  • src/core/execution/utils/AiAttackBehavior.ts
📚 Learning: 2025-06-07T19:47:34.514Z
Learnt from: its-sii
Repo: openfrontio/OpenFrontIO PR: 1089
File: src/client/styles/components/setting.css:9-22
Timestamp: 2025-06-07T19:47:34.514Z
Learning: In src/client/styles/components/setting.css, the padding difference between .setting-item and .setting-item-group is intentional - .setting-item-group doesn't need padding for the specific grouped setting styling being implemented.

Applied to files:

  • src/client/styles/components/setting.css
  • src/client/components/baseComponents/setting/SettingGroup.ts
📚 Learning: 2025-10-21T20:06:04.823Z
Learnt from: Saphereye
Repo: openfrontio/OpenFrontIO PR: 2233
File: src/client/HostLobbyModal.ts:891-891
Timestamp: 2025-10-21T20:06:04.823Z
Learning: For the HumansVsNations game mode in `src/client/HostLobbyModal.ts` and related files, the implementation strategy is to generate all nations and adjust their strength for balancing, rather than limiting lobby size based on the number of available nations on the map.

Applied to files:

  • map-generator/README.md
  • src/core/execution/nation/NationAllianceBehavior.ts
  • src/client/HostLobbyModal.ts
  • src/core/execution/nation/NationMIRVBehavior.ts
  • src/core/execution/NationExecution.ts
📚 Learning: 2025-08-10T14:37:35.777Z
Learnt from: TheGiraffe3
Repo: openfrontio/OpenFrontIO PR: 1770
File: resources/maps/world/manifest.json:279-355
Timestamp: 2025-08-10T14:37:35.777Z
Learning: The OpenFrontIO World Map includes both real-world nations and fictional/creative entities like "Polar Bears" as playable nations. These special nations may use non-two-letter flag codes (e.g., "polar_bears" instead of a two-letter code), and this is intentional design. The corresponding flag assets are stored in resources/flags/ with matching filenames.

Applied to files:

  • map-generator/README.md
📚 Learning: 2025-05-30T18:11:56.014Z
Learnt from: VariableVince
Repo: openfrontio/OpenFrontIO PR: 959
File: resources/maps/GatewayToTheAtlantic.json:124-124
Timestamp: 2025-05-30T18:11:56.014Z
Learning: In OpenFrontIO, flag names in map JSON files can use spaces (like "Amazigh flag") and don't need to follow slug format. Flag assets are stored with matching filenames including spaces (e.g., "Amazigh flag.svg"). Nation names and flag names are not translated/localized in the translation files.

Applied to files:

  • map-generator/README.md
📚 Learning: 2025-05-30T16:16:27.118Z
Learnt from: VariableVince
Repo: openfrontio/OpenFrontIO PR: 957
File: resources/maps/GatewayToTheAtlantic.json:175-179
Timestamp: 2025-05-30T16:16:27.118Z
Learning: In OpenFrontIO project, flag values in map JSON files (like GatewayToTheAtlantic.json) should match the actual flag file names in the resources directory (e.g., "French foreign legion" corresponds to "French foreign legion.svg"). The naming convention follows the file naming system rather than code naming conventions.

Applied to files:

  • map-generator/README.md
📚 Learning: 2025-08-19T11:00:55.422Z
Learnt from: TheGiraffe3
Repo: openfrontio/OpenFrontIO PR: 1864
File: resources/maps/arabianpeninsula/manifest.json:13-170
Timestamp: 2025-08-19T11:00:55.422Z
Learning: In OpenFrontIO, nation names in map manifests are displayed directly in the UI without translation. They do not need to be added to resources/lang/en.json or processed through translateText(). This is the established pattern across all existing maps including Europe, World, Asia, Africa, and others.

Applied to files:

  • map-generator/README.md
📚 Learning: 2025-08-09T05:14:19.147Z
Learnt from: scottanderson
Repo: openfrontio/OpenFrontIO PR: 1761
File: src/client/LocalPersistantStats.ts:24-31
Timestamp: 2025-08-09T05:14:19.147Z
Learning: Files in the src/client directory contain browser-only code in the OpenFrontIO project, so browser APIs like localStorage are guaranteed to be available and don't need undefined guards.

Applied to files:

  • src/client/index.html
📚 Learning: 2025-07-26T05:35:03.686Z
Learnt from: aaa4xu
Repo: openfrontio/OpenFrontIO PR: 1581
File: webpack.config.js:139-141
Timestamp: 2025-07-26T05:35:03.686Z
Learning: In OpenFrontIO's webpack configuration, binary-loader processes files from resources/maps at build-time and inlines the binary payload directly into the compiled JS bundle. This means the original .bin assets are no longer needed at runtime and can be safely excluded from the final Docker image using webpack's globOptions.ignore pattern to reduce image size.

Applied to files:

  • webpack.config.js
📚 Learning: 2025-08-24T11:13:08.495Z
Learnt from: DevelopingTom
Repo: openfrontio/OpenFrontIO PR: 1900
File: src/core/execution/SAMLauncherExecution.ts:103-111
Timestamp: 2025-08-24T11:13:08.495Z
Learning: In SAMLauncherExecution.ts, the cached target bug can only occur if: 1) SAM is not on cooldown when nuke is in range, 2) SAM goes on cooldown right after computing trajectory, 3) SAM becomes available again before nuke explodes. This is not possible with current cooldown values but the fix is still valuable for robustness.

Applied to files:

  • tests/util/TestConfig.ts
  • src/client/graphics/layers/SAMRadiusLayer.ts
  • src/core/execution/SAMLauncherExecution.ts
  • src/core/execution/nation/NationMIRVBehavior.ts
  • src/core/execution/NationExecution.ts
📚 Learning: 2025-10-18T11:00:57.142Z
Learnt from: NewYearNewPhil
Repo: openfrontio/OpenFrontIO PR: 2230
File: src/client/graphics/GameRenderer.ts:269-277
Timestamp: 2025-10-18T11:00:57.142Z
Learning: In src/client/graphics/GameRenderer.ts, the GameRecapCapture implementation does not use setCaptureRenderEnabled on layers. Instead, it uses RecapCaptureSurface.capture() to render capture layers (TerrainLayer, TerritoryLayer, RailroadLayer, StructureIconsLayer, UnitLayer) directly to an off-screen canvas without requiring layer-level capture mode methods.

Applied to files:

  • src/client/graphics/layers/SAMRadiusLayer.ts
  • src/client/graphics/GameRenderer.ts
📚 Learning: 2025-06-09T02:20:43.637Z
Learnt from: VariableVince
Repo: openfrontio/OpenFrontIO PR: 1110
File: src/client/Main.ts:293-295
Timestamp: 2025-06-09T02:20:43.637Z
Learning: In src/client/Main.ts, during game start in the handleJoinLobby callback, UI elements are hidden using direct DOM manipulation with classList.add("hidden") for consistency. This includes modals, buttons, and error divs. The codebase follows this pattern rather than using component APIs for hiding elements during game transitions.

Applied to files:

  • src/client/SinglePlayerModal.ts
  • src/client/UserSettingModal.ts
  • src/client/HostLobbyModal.ts
📚 Learning: 2025-08-12T00:31:50.144Z
Learnt from: scottanderson
Repo: openfrontio/OpenFrontIO PR: 1752
File: src/core/game/Game.ts:750-752
Timestamp: 2025-08-12T00:31:50.144Z
Learning: In the OpenFrontIO codebase, changes to the PlayerInteraction interface (like adding canDonateGold and canDonateTroops flags) do not require corresponding updates to src/core/Schemas.ts or server serialization code.

Applied to files:

  • src/client/SinglePlayerModal.ts
  • src/core/game/GameView.ts
📚 Learning: 2025-10-18T17:54:01.311Z
Learnt from: sambokai
Repo: openfrontio/OpenFrontIO PR: 2225
File: src/core/execution/FakeHumanExecution.ts:172-173
Timestamp: 2025-10-18T17:54:01.311Z
Learning: In src/core/execution/FakeHumanExecution.ts, MIRVs and ground attacks should not be mutually exclusive. The considerMIRV() method should not short-circuit maybeAttack() - both actions can occur in the same tick.

Applied to files:

  • src/core/execution/SAMLauncherExecution.ts
  • src/core/execution/nation/NationMIRVBehavior.ts
  • src/core/execution/NationExecution.ts
  • src/core/execution/utils/AiAttackBehavior.ts
📚 Learning: 2025-10-27T09:47:26.395Z
Learnt from: sambokai
Repo: openfrontio/OpenFrontIO PR: 2225
File: src/core/execution/FakeHumanExecution.ts:770-795
Timestamp: 2025-10-27T09:47:26.395Z
Learning: In src/core/execution/FakeHumanExecution.ts, the selectSteamrollStopTarget() method intentionally allows MIRV targeting when secondHighest city count is 0 (e.g., nuclear endgame scenarios where structures are destroyed). This is valid game design—"if you can afford it, you're good to go"—and should not be flagged as requiring a guard condition.

Applied to files:

  • src/core/execution/SAMLauncherExecution.ts
  • src/core/execution/nation/NationMIRVBehavior.ts
  • src/core/execution/NationExecution.ts
  • src/core/execution/utils/AiAttackBehavior.ts
📚 Learning: 2025-05-18T23:36:12.847Z
Learnt from: scottanderson
Repo: openfrontio/OpenFrontIO PR: 784
File: src/core/game/StatsImpl.ts:143-159
Timestamp: 2025-05-18T23:36:12.847Z
Learning: In this codebase, NukeType is a union type derived from specific UnitType enum values (AtomBomb, HydrogenBomb, MIRV, MIRVWarhead). This means that comparisons in switch statements between a NukeType parameter and UnitType enum values are valid and will work correctly at runtime.

Applied to files:

  • src/core/execution/SAMLauncherExecution.ts
📚 Learning: 2025-05-18T23:36:12.847Z
Learnt from: scottanderson
Repo: openfrontio/OpenFrontIO PR: 784
File: src/core/game/StatsImpl.ts:143-159
Timestamp: 2025-05-18T23:36:12.847Z
Learning: In this codebase, NukeType is a union type derived from UnitType values (specifically bomb-related values like AtomBomb, HydrogenBomb, MIRV, and MIRVWarhead) rather than a separate enum. This means comparing NukeType values against UnitType values in switch statements is valid and intentional.

Applied to files:

  • src/core/execution/SAMLauncherExecution.ts
📚 Learning: 2025-06-20T20:11:00.965Z
Learnt from: devalnor
Repo: openfrontio/OpenFrontIO PR: 1195
File: src/client/graphics/layers/AlertFrame.ts:18-18
Timestamp: 2025-06-20T20:11:00.965Z
Learning: In the OpenFrontIO codebase, UserSettings instances are created directly with `new UserSettings()` in each component that needs them. This pattern is used consistently across at least 12+ files including OptionsMenu, EventsDisplay, DarkModeButton, Main, UserSettingModal, UsernameInput, NameLayer, AlertFrame, UILayer, InputHandler, ClientGameRunner, and GameView. This is the established architectural pattern and should be followed for consistency.

Applied to files:

  • src/core/game/UserSettings.ts
  • src/client/UserSettingModal.ts
📚 Learning: 2025-10-17T19:16:57.699Z
Learnt from: sambokai
Repo: openfrontio/OpenFrontIO PR: 2225
File: src/core/execution/FakeHumanExecution.ts:711-723
Timestamp: 2025-10-17T19:16:57.699Z
Learning: In the OpenFrontIO game, MIRVs (Multiple Independently targetable Reentry Vehicles) have an area of effect with a radius of 1500 tiles around the aim point. To maximize efficiency, bots should aim at the center of the target player's territory rather than selecting random tiles. This differs from the initial understanding that MIRVs covered an entire player's territory regardless of aim point.

Applied to files:

  • src/core/execution/nation/NationMIRVBehavior.ts
📚 Learning: 2025-12-26T22:21:15.466Z
Learnt from: FloPinguin
Repo: openfrontio/OpenFrontIO PR: 2689
File: src/client/PublicLobby.ts:245-245
Timestamp: 2025-12-26T22:21:15.466Z
Learning: In public lobbies with HumansVsNations mode in src/client/PublicLobby.ts, maxPlayers represents only human player slots (already halved in DefaultConfig.ts). The nation NPCs are added automatically server-side and don't count toward maxPlayers. Therefore, getTeamSize correctly returns maxPlayers directly for HumansVsNations to display the proper team size (e.g., maxPlayers=5 yields "5 Humans vs 5 Nations").

Applied to files:

  • src/core/execution/nation/NationMIRVBehavior.ts
📚 Learning: 2025-10-26T15:37:07.732Z
Learnt from: GlacialDrift
Repo: openfrontio/OpenFrontIO PR: 2298
File: src/client/graphics/layers/TerritoryLayer.ts:200-210
Timestamp: 2025-10-26T15:37:07.732Z
Learning: In GameImpl.ts lines 124-139, team assignment logic varies by number of teams: when numPlayerTeams < 8, teams are assigned ColoredTeams values (Red, Blue, Yellow, Green, Purple, Orange, Teal); when numPlayerTeams >= 8, teams are assigned generic string identifiers like "Team 1", "Team 2", etc., which are not members of ColoredTeams.

Applied to files:

  • src/core/execution/nation/NationMIRVBehavior.ts
  • src/core/game/GameView.ts
📚 Learning: 2025-12-13T14:58:29.645Z
Learnt from: scamiv
Repo: openfrontio/OpenFrontIO PR: 2607
File: src/core/execution/PlayerExecution.ts:271-295
Timestamp: 2025-12-13T14:58:29.645Z
Learning: In src/core/execution/PlayerExecution.ts surroundedBySamePlayer(), the `as Player` cast on `mg.playerBySmallID(scan.enemyId)` is intentional. Since scan.enemyId comes from ownerID() on an owned tile and playerBySmallID() only returns Player or undefined, the cast expresses a known invariant. The maintainers prefer loud failures (runtime errors) over silent masking (early returns with guards) for corrupted game state scenarios at trusted call sites.

Applied to files:

  • src/core/execution/nation/NationMIRVBehavior.ts
  • src/core/execution/NationExecution.ts
  • src/core/game/GameView.ts
  • src/core/execution/utils/AiAttackBehavior.ts
📚 Learning: 2025-05-19T06:00:38.007Z
Learnt from: scottanderson
Repo: openfrontio/OpenFrontIO PR: 784
File: src/core/game/StatsImpl.ts:125-134
Timestamp: 2025-05-19T06:00:38.007Z
Learning: In StatsImpl.ts, unused parameters in boat/stats-related methods are intentionally kept for future use and shouldn't be removed.

Applied to files:

  • src/core/execution/NationExecution.ts
📚 Learning: 2025-08-23T07:48:19.060Z
Learnt from: ElMelchizedek
Repo: openfrontio/OpenFrontIO PR: 1876
File: src/core/execution/FakeHumanExecution.ts:470-473
Timestamp: 2025-08-23T07:48:19.060Z
Learning: In FakeHumanExecution.ts DefensePost placement logic, returning -Infinity from structureSpawnTileValue when no sampled border tiles neighbor enemies is intentional. The logic samples up to 50 border tiles as a heuristic - if none are adjacent to enemies, it assumes DefensePost placement is unnecessary and aborts the entire placement attempt rather than continuing to evaluate individual tiles.

Applied to files:

  • src/core/execution/NationExecution.ts
  • src/core/execution/utils/AiAttackBehavior.ts
📚 Learning: 2025-06-10T09:56:44.473Z
Learnt from: Ble4Ch
Repo: openfrontio/OpenFrontIO PR: 1063
File: src/core/configuration/PastelThemeDark.ts:53-53
Timestamp: 2025-06-10T09:56:44.473Z
Learning: In ColorAllocator class in src/core/configuration/Colors.ts, the correct method names are assignColor(id: string): Colord for general color assignment and assignTeamColor(team: Team): Colord for team-specific colors. There are no assignPlayerColor() or assignBotColor() methods.

Applied to files:

  • src/core/game/GameView.ts
📚 Learning: 2025-06-06T15:36:31.739Z
Learnt from: Ble4Ch
Repo: openfrontio/OpenFrontIO PR: 1063
File: src/core/configuration/Colors.ts:114-330
Timestamp: 2025-06-06T15:36:31.739Z
Learning: In the OpenFrontIO project, color improvements are implemented incrementally. The current focus for player colors is ensuring sufficient unique color availability rather than optimizing for visual distinction or accessibility features.

Applied to files:

  • src/core/game/GameView.ts
📚 Learning: 2025-11-26T20:49:29.140Z
Learnt from: scamiv
Repo: openfrontio/OpenFrontIO PR: 2519
File: src/core/game/GameView.ts:516-525
Timestamp: 2025-11-26T20:49:29.140Z
Learning: In GameView.ts, when usesSharedTileState is true (SAB mode), packedTileUpdates contains unpacked tile references as BigInt(tileRef) only, because all tile state lives in the shared Uint16Array. In non-SAB mode, packedTileUpdates contains packed TileUpdate bigints in the format (tileRef << 16n | state), which must be decoded via updateTile(tu). Therefore, Number(tu) is correct in SAB mode and shifting right by 16 bits would be wrong.

Applied to files:

  • src/core/game/GameView.ts
📚 Learning: 2025-06-06T15:42:21.903Z
Learnt from: Ble4Ch
Repo: openfrontio/OpenFrontIO PR: 1063
File: src/core/configuration/PastelThemeDark.ts:20-21
Timestamp: 2025-06-06T15:42:21.903Z
Learning: In ColorAllocator class, all methods (assignBotColor, assignPlayerColor, assignTeamColor) use the constructor colors from this.availableColors. Only the predefined team cases in assignTeamColor use hardcoded global colors, while the default case uses constructor colors.

Applied to files:

  • src/core/game/GameView.ts
📚 Learning: 2025-10-08T17:14:49.369Z
Learnt from: Foorack
Repo: openfrontio/OpenFrontIO PR: 2141
File: src/client/ClientGameRunner.ts:228-234
Timestamp: 2025-10-08T17:14:49.369Z
Learning: In `ClientGameRunner.ts`, the `myPlayer` field is always set when `shouldPreventWindowClose()` is called, so the null check in that method is sufficient without needing to fetch it again from `gameView.playerByClientID()`.

Applied to files:

  • src/core/execution/utils/AiAttackBehavior.ts
📚 Learning: 2025-11-01T00:24:33.860Z
Learnt from: FloPinguin
Repo: openfrontio/OpenFrontIO PR: 2161
File: src/core/execution/FakeHumanExecution.ts:670-678
Timestamp: 2025-11-01T00:24:33.860Z
Learning: In OpenFrontIO, PlayerType.Bot entities cannot be in teams and do not have friendliness relationships. Only PlayerType.Human and PlayerType.FakeHuman can participate in teams and alliances. Therefore, when targeting bot-owned tiles, friendliness checks like `owner.isFriendly(this.player)` are unnecessary and meaningless for bots.

Applied to files:

  • src/core/execution/utils/AiAttackBehavior.ts
📚 Learning: 2025-05-31T18:15:03.445Z
Learnt from: 1brucben
Repo: openfrontio/OpenFrontIO PR: 977
File: src/core/execution/AttackExecution.ts:123-125
Timestamp: 2025-05-31T18:15:03.445Z
Learning: The removeTroops function in PlayerImpl.ts already prevents negative troop counts by using minInt(this._troops, toInt(troops)) to ensure it never removes more troops than available.

Applied to files:

  • src/core/execution/utils/AiAttackBehavior.ts
📚 Learning: 2025-10-08T17:14:49.369Z
Learnt from: Foorack
Repo: openfrontio/OpenFrontIO PR: 2141
File: src/client/ClientGameRunner.ts:228-234
Timestamp: 2025-10-08T17:14:49.369Z
Learning: For the window close confirmation feature in `ClientGameRunner.ts`, the troop count requirement (>10,000 troops) from issue #2137 was intentionally removed because it was arbitrary and troop count can be reported as low despite having significant land. The confirmation now triggers for any alive player regardless of troop count.

Applied to files:

  • src/core/execution/utils/AiAttackBehavior.ts
📚 Learning: 2025-06-02T14:27:37.609Z
Learnt from: andrewNiziolek
Repo: openfrontio/OpenFrontIO PR: 1007
File: resources/lang/de.json:115-115
Timestamp: 2025-06-02T14:27:37.609Z
Learning: For OpenFrontIO project: When localization keys are renamed in language JSON files, the maintainers separate technical changes from translation content updates. They wait for community translators to update the actual translation values rather than attempting to translate in the same PR. This allows technical changes to proceed while ensuring accurate translations from native speakers.

Applied to files:

  • resources/lang/en.json
📚 Learning: 2025-07-12T08:41:35.101Z
Learnt from: Aotumuri
Repo: openfrontio/OpenFrontIO PR: 1357
File: resources/lang/de.json:523-540
Timestamp: 2025-07-12T08:41:35.101Z
Learning: In OpenFrontIO project localization files, always check the en.json source file before flagging potential spelling errors in other language files, as some keys may intentionally use non-standard spellings that need to be consistent across all translations.

Applied to files:

  • resources/lang/en.json
📚 Learning: 2025-08-27T08:12:19.610Z
Learnt from: mokizzz
Repo: openfrontio/OpenFrontIO PR: 1940
File: resources/lang/en.json:763-766
Timestamp: 2025-08-27T08:12:19.610Z
Learning: In OpenFrontIO, some country entries in src/client/data/countries.json may have similar names but different codes (e.g., "Empire of Japan" vs "Empire of Japan1"). Each unique code requires its own translation key in resources/lang/en.json after normalization. Always verify against countries.json before suggesting removal of translation keys.

Applied to files:

  • resources/lang/en.json
📚 Learning: 2025-08-16T10:52:08.292Z
Learnt from: TheGiraffe3
Repo: openfrontio/OpenFrontIO PR: 884
File: resources/lang/en.json:456-461
Timestamp: 2025-08-16T10:52:08.292Z
Learning: In OpenFrontIO, translation files in resources/lang/*.json (except en.json) should not be updated in regular PRs. Only dedicated translation PRs titled "mls" and made by Aotumori should update non-English locale files. Regular PRs should only update en.json when adding or modifying translation keys.

Applied to files:

  • resources/lang/en.json
📚 Learning: 2025-07-12T08:42:02.109Z
Learnt from: Aotumuri
Repo: openfrontio/OpenFrontIO PR: 1357
File: resources/lang/zh_cn.json:527-539
Timestamp: 2025-07-12T08:42:02.109Z
Learning: In OpenFrontIO project, the territory pattern key "embelem" is the correct spelling used in all language files, not "emblem". This is the technical identifier that appears in en.json and must be consistent across all localization files.

Applied to files:

  • resources/lang/en.json
🧬 Code graph analysis (11)
src/core/execution/nation/NationAllianceBehavior.ts (2)
src/core/Util.ts (1)
  • assertNever (257-259)
src/core/game/Game.ts (1)
  • Player (534-672)
src/client/components/baseComponents/setting/SettingThemeMode.ts (1)
src/core/game/UserSettings.ts (2)
  • ThemeMode (6-6)
  • darkMode (66-68)
src/client/graphics/GameRenderer.ts (1)
src/client/graphics/layers/SAMRadiusLayer.ts (1)
  • SAMRadiusLayer (13-351)
src/client/components/baseComponents/setting/SettingColorPalette.ts (2)
src/client/UserSettingModal.ts (1)
  • customElement (15-735)
src/client/components/baseComponents/setting/SettingGroup.ts (1)
  • customElement (6-153)
src/client/SinglePlayerModal.ts (2)
src/client/LangSelector.ts (1)
  • translateText (258-278)
src/client/Utils.ts (1)
  • translateText (92-147)
src/client/HelpModal.ts (2)
src/client/LangSelector.ts (1)
  • translateText (258-278)
src/client/Utils.ts (3)
  • translateText (92-147)
  • getModifierKey (204-211)
  • getAltKey (213-220)
src/core/execution/SAMLauncherExecution.ts (3)
src/core/game/GameView.ts (2)
  • type (94-96)
  • type (353-355)
src/core/game/UnitImpl.ts (1)
  • type (148-150)
src/core/game/PlayerImpl.ts (1)
  • type (205-207)
src/client/HostLobbyModal.ts (2)
src/client/LangSelector.ts (1)
  • translateText (258-278)
src/client/Utils.ts (1)
  • translateText (92-147)
src/core/execution/NationExecution.ts (1)
src/core/execution/SpawnExecution.ts (2)
  • SpawnExecution (10-123)
  • randTile (117-122)
src/core/game/GameView.ts (4)
src/core/game/UnitImpl.ts (1)
  • tile (178-180)
src/core/game/TrainStation.ts (1)
  • tile (141-143)
src/core/game/GameMap.ts (2)
  • x (127-129)
  • y (131-133)
src/core/game/GameImpl.ts (2)
  • x (842-844)
  • y (845-847)
src/core/execution/utils/AiAttackBehavior.ts (2)
src/core/game/Game.ts (2)
  • Player (534-672)
  • TerraNullius (521-526)
src/core/configuration/DefaultConfig.ts (1)
  • maxTroops (812-843)
🪛 Gitleaks (8.30.0)
src/client/index.html

[high] 540-540: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)

🪛 markdownlint-cli2 (0.18.1)
map-generator/README.md

7-7: Bare URL used

(MD034, no-bare-urls)

private samRanges: SAMRadius[] = [];
private showStroke: boolean = false;
private dashOffset = 0;
private rotationSpeed = 14; // px per second
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

Misleading comment and delayed dashOffset wrapping.

  1. Line 23: The comment "px per second" is incorrect. SAM radii are in game units, not pixels. The rotation speed affects dash offset, not physical pixels.

  2. Lines 124-126: dashOffset accumulates until it exceeds 1e6 before wrapping. This is arbitrary and could cause precision issues with very large numbers.

🔎 Proposed fixes
- private rotationSpeed = 14; // px per second
+ private rotationSpeed = 14; // units per second

  if (this.showStroke) {
    this.dashOffset += (this.rotationSpeed * dt) / 1000;
-   if (this.dashOffset > 1e6) this.dashOffset = this.dashOffset % 1000000;
+   // Wrap at a smaller value to avoid precision issues
+   if (this.dashOffset > 10000) this.dashOffset = this.dashOffset % 10000;
    this.needsRedraw = true;
  }

Also applies to: 124-126

🤖 Prompt for AI Agents
In src/client/graphics/layers/SAMRadiusLayer.ts around line 23 and lines
124-126, update the misleading comment on rotationSpeed to indicate it is in
"game units per second" (or "SAM units per second") rather than "px per second",
and optionally rename the variable to reflect units if desired; for dashOffset
wrapping replace the arbitrary "if > 1e6 then reset" logic with a proper
modulo-based wrap using the dash pattern cycle (e.g., dashOffset = dashOffset %
dashPatternLength or % 1 if using normalized offsets) so the offset never grows
unbounded and stays within the pattern period to avoid precision issues.

Comment on lines +37 to +46
) {
this.canvas = document.createElement("canvas");
const ctx = this.canvas.getContext("2d");
if (!ctx) {
throw new Error("2d context not supported");
}
this.context = ctx;
this.canvas.width = this.game.width();
this.canvas.height = this.game.height();
}
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

Canvas resize handling is missing.

The off-screen canvas dimensions are set once in the constructor using game.width() and game.height(). If the game resizes (e.g., window resize or canvas size change), this canvas won't update, leading to mismatched rendering.

🔎 Suggested fix: Add resize handling

Consider adding a method to handle canvas resizing:

private resizeCanvas() {
  const width = this.game.width();
  const height = this.game.height();
  if (this.canvas.width !== width || this.canvas.height !== height) {
    this.canvas.width = width;
    this.canvas.height = height;
    this.needsRedraw = true;
  }
}

Then call it in tick() or add a resize event listener in init().

🤖 Prompt for AI Agents
In src/client/graphics/layers/SAMRadiusLayer.ts around lines 37 to 46, the
off-screen canvas is sized only in the constructor so it will become out-of-sync
when the game resizes; add a private resizeCanvas() method that reads
game.width() and game.height(), compares to this.canvas.width/height, and if
different updates the canvas dimensions and sets this.needsRedraw = true; then
call resizeCanvas() at the start of tick() (or register a resize listener in
init()) so the off-screen canvas always matches the current game size.

Comment on lines 156 to 159
this.samLaunchers.clear();
samLaunchers.forEach((sam) =>
this.samLaunchers.set(sam.id(), {
ownerId: sam.owner().smallID(),
level: sam.level(),
}),
this.samLaunchers.set(sam.id(), sam.owner().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

Inconsistent data tracking between tick() and redraw().

The tick() method carefully maintains incremental updates to samLaunchers (lines 76-108), but redraw() clears and rebuilds the entire map from scratch. This makes the incremental update logic redundant.

Either:

  1. Remove the incremental update logic in tick() and always rebuild in redraw(), or
  2. Don't clear samLaunchers in redraw() and rely on the incremental updates

The current approach wastes computation tracking changes that get discarded.

🤖 Prompt for AI Agents
In src/client/graphics/layers/SAMRadiusLayer.ts around lines 156-159, redraw()
currently clears and rebuilds this.samLaunchers which discards the incremental
updates performed in tick(); to fix, stop clearing this.samLaunchers in redraw()
and instead iterate over the existing map when painting so redraw uses the
incrementally-maintained state from tick(); ensure tick() continues to remove
entries for destroyed/removed launchers (lines ~76-108) so redraw renders the
up-to-date set and avoid rebuilding the map inside redraw().

Comment on lines +195 to +201
ctx.save();
ctx.beginPath();
for (const c of circles) {
ctx.moveTo(c.x + c.r, c.y);
ctx.arc(c.x, c.y, c.r, 0, Math.PI * 2);
}
ctx.restore();
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

Fill operation missing - path created but never filled.

The code creates a path for the union of circles and the comment says "Fill union," but ctx.fill() is never called. The path is built but not rendered.

🔎 Proposed fix
   ctx.save();
   ctx.beginPath();
   for (const c of circles) {
     ctx.moveTo(c.x + c.r, c.y);
     ctx.arc(c.x, c.y, c.r, 0, Math.PI * 2);
   }
+  ctx.fillStyle = "rgba(0, 0, 0, 0.1)"; // or appropriate fill color
+  ctx.fill();
   ctx.restore();

If filling was intentionally removed, delete the path-building code and update the comment.

📝 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
ctx.save();
ctx.beginPath();
for (const c of circles) {
ctx.moveTo(c.x + c.r, c.y);
ctx.arc(c.x, c.y, c.r, 0, Math.PI * 2);
}
ctx.restore();
ctx.save();
ctx.beginPath();
for (const c of circles) {
ctx.moveTo(c.x + c.r, c.y);
ctx.arc(c.x, c.y, c.r, 0, Math.PI * 2);
}
ctx.fillStyle = "rgba(0, 0, 0, 0.1)"; // or appropriate fill color
ctx.fill();
ctx.restore();
🤖 Prompt for AI Agents
In src/client/graphics/layers/SAMRadiusLayer.ts around lines 195 to 201 the code
builds a path for the union of circles but never fills it: add a ctx.fill() (and
optionally set ctx.fillStyle before) after the path construction and before
ctx.restore() so the union is rendered; if the fill was intentionally removed,
instead remove the path-building loop and update the comment to reflect that no
fill is performed.

for (let i = 1; i < flat.length; i++) {
const it = flat[i];
if (it[0] <= cur[1] + EPS) {
if (it[0] <= cur[1] + 1e-9) {
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Numerical tolerance values need review.

Multiple epsilon comparisons use 1e-9 (lines 242, 270, 275, 279, 311, 316) and 1e-3 (line 322) without clear rationale. If game coordinates are large integers (e.g., on 10000×10000 maps), these tolerances might be inappropriate.

  1. Define named constants for tolerances:
private readonly EPSILON = 1e-6; // or appropriate value based on game scale
private readonly MIN_ARC_LENGTH = 0.01; // radians
  1. Base epsilon on game coordinate scale:
private readonly EPSILON = Math.max(1e-9, this.game.width() * 1e-9);
  1. Document why different tolerances are used in different contexts.

Also applies to: 270-286, 311-322

🤖 Prompt for AI Agents
In src/client/graphics/layers/SAMRadiusLayer.ts around lines 242-322, multiple
raw numeric epsilons (1e-9, 1e-3) are used for floating-point comparisons;
replace these literals with clear, named constants (e.g., EPSILON,
MIN_ARC_LENGTH) declared on the class, initialize EPSILON based on game scale
(for example Math.max(1e-9, this.game.width() * 1e-9) or another
scale-appropriate formula) and set MIN_ARC_LENGTH to an explicit radian
threshold (e.g., 0.01), then use these constants in all comparisons at the
listed lines and add short inline comments explaining the rationale for each
tolerance choice (distance-scale epsilon vs. angular minimum).

Comment on lines +68 to +70
if (this.random.chance(10)) {
// this.isTraitor = true
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Remove empty conditional block.

This if block with a commented-out assignment is dead code. Either implement the traitor logic or remove the block entirely.

Suggested fix
-    if (this.random.chance(10)) {
-      // this.isTraitor = true
-    }
📝 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 (this.random.chance(10)) {
// this.isTraitor = true
}
🤖 Prompt for AI Agents
In src/core/execution/NationExecution.ts around lines 68-70, there is an empty
conditional with a commented-out assignment (if (this.random.chance(10)) { //
this.isTraitor = true }) which is dead code; either remove the entire if block
or implement the intended traitor behavior by uncommenting and wiring up
this.isTraitor assignment and any necessary side-effects (state initialization,
persistence, and tests) so the condition has functional effect—pick one: delete
the block to clean up or restore and fully implement the traitor logic and its
downstream handling.

Comment on lines +18 to +19
const clientPort = parseInt(process.env.OPENFRONT_CLIENT_PORT ?? "9000", 10);
const serverPort = parseInt(process.env.OPENFRONT_SERVER_PORT ?? "3000", 10);
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

Validate parsed ports to prevent NaN and invalid values.

If OPENFRONT_CLIENT_PORT or OPENFRONT_SERVER_PORT contain non-numeric values, parseInt returns NaN. This creates invalid URLs like http://localhost:NaN that fail silently or produce cryptic errors. Additionally, ports should be validated for:

  • Valid range (0–65535)
  • No conflicts (clientPort !== serverPort)
🔎 Proposed validation helper
+function parsePort(envVar: string | undefined, defaultPort: string, name: string): number {
+  const port = parseInt(envVar ?? defaultPort, 10);
+  if (isNaN(port)) {
+    throw new Error(`${name} must be a valid number, got: ${envVar}`);
+  }
+  if (port < 0 || port > 65535) {
+    throw new Error(`${name} must be between 0 and 65535, got: ${port}`);
+  }
+  return port;
+}
+
-const clientPort = parseInt(process.env.OPENFRONT_CLIENT_PORT ?? "9000", 10);
-const serverPort = parseInt(process.env.OPENFRONT_SERVER_PORT ?? "3000", 10);
+const clientPort = parsePort(process.env.OPENFRONT_CLIENT_PORT, "9000", "OPENFRONT_CLIENT_PORT");
+const serverPort = parsePort(process.env.OPENFRONT_SERVER_PORT, "3000", "OPENFRONT_SERVER_PORT");
+
+if (clientPort === serverPort) {
+  throw new Error(`OPENFRONT_CLIENT_PORT and OPENFRONT_SERVER_PORT cannot be the same (both are ${clientPort})`);
+}
🤖 Prompt for AI Agents
In webpack.config.js around lines 18-19 the parsed clientPort and serverPort may
be NaN or out of range if OPENFRONT_* env vars are non-numeric; validate the
results of parseInt, ensure they are finite integers in the 0–65535 range, and
ensure clientPort !== serverPort; if validation fails either fall back to safe
defaults (9000/3000) and log a clear error or throw/exit during startup so
invalid ports don't produce URLs like http://localhost:NaN — implement a small
validator that checks Number.isInteger(port) && port >= 0 && port <= 65535,
compares the two ports for conflict, and handles failures deterministically (log
+ default or throw).

- Fix dev-stop.js Unix branch to only record stopped ports when processes
  actually exist (check lsof output before killing)
- Fix dev-stop.js log to show only actually stopped ports, not all
  configured ports
- Add config structure validation in DevConfig.ts loadConfig()

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@nodeGarden nodeGarden marked this pull request as draft December 27, 2025 16:37
The project CI uses npm, not pnpm. Reverting the lock file changes
to maintain CI compatibility.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
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: 0

🧹 Nitpick comments (4)
src/client/DevConfig.ts (2)

104-106: Silent catch hides JSON parse errors.

The empty catch handles "file not found" gracefully, but also silently swallows JSON parse errors if config.json exists but contains invalid JSON. A dev might spend time debugging why their config is ignored.

🔎 Proposed fix to log parse errors
-  } catch {
-    // config.json not found, use defaults
+  } catch (error) {
+    // Log only if it's likely a parse error (not a 404)
+    if (error instanceof SyntaxError) {
+      console.warn("Failed to parse config.json:", error.message);
+    }
   }

118-134: Helper functions are nearly identical.

applyBool and applyFloat do the same thing (call .toString()). You could use one function for both, or keep them separate for clarity — your call.

🔎 Proposed consolidation
-  const applyBool = (key: string, value: boolean | undefined) => {
-    if (value !== undefined && localStorage.getItem(key) === null) {
-      localStorage.setItem(key, value.toString());
-    }
-  };
-
-  const applyFloat = (key: string, value: number | undefined) => {
-    if (value !== undefined && localStorage.getItem(key) === null) {
-      localStorage.setItem(key, value.toString());
-    }
-  };
-
-  const applyString = (key: string, value: string | undefined) => {
-    if (value !== undefined && localStorage.getItem(key) === null) {
-      localStorage.setItem(key, value);
-    }
-  };
+  const applySetting = (key: string, value: string | number | boolean | undefined) => {
+    if (value !== undefined && localStorage.getItem(key) === null) {
+      localStorage.setItem(key, String(value));
+    }
+  };
src/core/game/UserSettings.ts (1)

87-104: Consider listening for system theme changes.

The applyTheme() method correctly applies the theme class based on mode. However, when mode === "system", the app checks prefers-color-scheme once but doesn't listen for changes. If a user switches their OS theme while the app is open, the app theme won't update automatically.

💡 Optional: Add listener for system preference changes

You could add a media query listener to react to OS theme changes:

  applyTheme(): void {
    const mode = this.themeMode();
    if (mode === "dark") {
      document.documentElement.classList.add("dark");
    } else if (mode === "light") {
      document.documentElement.classList.remove("dark");
    } else {
      // System mode - check prefers-color-scheme
-     const prefersDark = window.matchMedia(
-       "(prefers-color-scheme: dark)",
-     ).matches;
-     if (prefersDark) {
-       document.documentElement.classList.add("dark");
-     } else {
-       document.documentElement.classList.remove("dark");
-     }
+     const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
+     const applySystemTheme = () => {
+       if (mediaQuery.matches) {
+         document.documentElement.classList.add("dark");
+       } else {
+         document.documentElement.classList.remove("dark");
+       }
+     };
+     applySystemTheme();
+     mediaQuery.addEventListener("change", applySystemTheme);
    }
  }

Note: You'd need to track and remove the listener when mode changes to avoid memory leaks.

src/client/UserSettingModal.ts (1)

704-711: Layout workaround is acceptable but could be improved.

The requestAnimationFrame call fixes column layout issues after the modal opens, which is a valid workaround. However, this typically indicates either a CSS issue (columns layout calculating before content is rendered) or a Lit lifecycle timing concern.

If you encounter flickering or layout jumps, consider investigating:

  • Whether the modal content is fully rendered before open() completes
  • If CSS grid/flexbox needs explicit dimensions before the modal is shown
  • If the column layout calculation needs the modal's actual dimensions

For now, this workaround is fine, but keep an eye on UX for any visible reflow.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 379a665 and 26ebfe4.

📒 Files selected for processing (6)
  • .gitignore
  • scripts/dev-stop.js
  • src/client/DevConfig.ts
  • src/client/Main.ts
  • src/client/UserSettingModal.ts
  • src/core/game/UserSettings.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/client/Main.ts
  • scripts/dev-stop.js
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2025-11-12T23:11:34.445Z
Learnt from: MaxHT0x
Repo: openfrontio/OpenFrontIO PR: 2262
File: src/core/configuration/DefaultConfig.ts:806-832
Timestamp: 2025-11-12T23:11:34.445Z
Learning: In src/core/configuration/DefaultConfig.ts, JSDoc documentation for configuration methods should not be added inline, as it was requested that documentation be placed elsewhere in the codebase.

Applied to files:

  • src/client/DevConfig.ts
📚 Learning: 2025-06-20T20:11:00.965Z
Learnt from: devalnor
Repo: openfrontio/OpenFrontIO PR: 1195
File: src/client/graphics/layers/AlertFrame.ts:18-18
Timestamp: 2025-06-20T20:11:00.965Z
Learning: In the OpenFrontIO codebase, UserSettings instances are created directly with `new UserSettings()` in each component that needs them. This pattern is used consistently across at least 12+ files including OptionsMenu, EventsDisplay, DarkModeButton, Main, UserSettingModal, UsernameInput, NameLayer, AlertFrame, UILayer, InputHandler, ClientGameRunner, and GameView. This is the established architectural pattern and should be followed for consistency.

Applied to files:

  • src/client/UserSettingModal.ts
  • src/core/game/UserSettings.ts
📚 Learning: 2025-06-09T02:20:43.637Z
Learnt from: VariableVince
Repo: openfrontio/OpenFrontIO PR: 1110
File: src/client/Main.ts:293-295
Timestamp: 2025-06-09T02:20:43.637Z
Learning: In src/client/Main.ts, during game start in the handleJoinLobby callback, UI elements are hidden using direct DOM manipulation with classList.add("hidden") for consistency. This includes modals, buttons, and error divs. The codebase follows this pattern rather than using component APIs for hiding elements during game transitions.

Applied to files:

  • src/client/UserSettingModal.ts
🧬 Code graph analysis (1)
src/client/UserSettingModal.ts (2)
src/client/DarkModeButton.ts (1)
  • customElement (5-45)
src/core/game/UserSettings.ts (2)
  • UserSettings (8-260)
  • ThemeMode (6-6)
🔇 Additional comments (11)
src/client/DevConfig.ts (3)

4-57: Clean interface design with typed unions.

Good use of typed union for themeMode instead of a plain string. The nested structure with optional groups keeps things organized, and the flat legacy fields provide nice backwards compatibility.


136-195: Nested-first, flat-fallback order is correct.

The logic correctly applies nested settings first, so they take precedence over flat legacy settings when both are provided. Good backwards compatibility design.


207-221: Callers properly await config before using feature flags in most cases, but PublicLobby.ts render() method may access defaults.

Main.ts correctly await waitForDevConfig() at line 124 before any feature flag checks. However, PublicLobby.ts uses isDevFeatureEnabled() directly in the render() method (line 123) without guaranteed prior loading—it only awaits in connectedCallback() (line 40). Since render() can be called before connectedCallback() completes, the render method may return defaults if config hasn't loaded. If publicLobbies: false is the intended safe default, this is acceptable; otherwise, consider ensuring config loads before render can be called.

.gitignore (1)

13-21: LGTM! Clean organization for local dev configs.

Adding config.json and resources/config.json to .gitignore correctly prevents local configuration overrides from being committed while allowing the config.example.json template to remain in version control. The reorganization of the Claude Code section is also clean.

src/core/game/UserSettings.ts (3)

6-6: LGTM! Clean typed union for theme modes.

The ThemeMode type uses a string literal union (preferred over enums or class hierarchies), and the themeMode() method includes proper validation and legacy migration logic. Defaulting to "system" when no preference exists is a sensible choice.

Also applies to: 70-80


106-111: LGTM! Clear boolean helper.

The isDarkModeActive() method correctly evaluates the current theme state, including system preference resolution. Clean implementation.


176-188: LGTM! Toggle logic correctly bypasses system mode.

The updated toggleDarkMode() correctly switches between "light" and "dark" modes (bypassing "system"), which makes sense for a toggle button UX. Event dispatching synchronizes both the new theme-mode-changed and legacy dark-mode-changed events.

src/client/UserSettingModal.ts (4)

4-19: LGTM! Clean imports and state extension.

The new ThemeMode import and setting components are properly integrated, and the settingsMode state correctly extends to include "display" as a typed union member.


261-279: LGTM! Dual event dispatch correctly synchronizes theme state.

The handleThemeModeChange method correctly updates settings via setThemeMode() and dispatches events to both the component tree (this.dispatchEvent) and the window (window.dispatchEvent) to synchronize with different listeners like DarkModeButton.


281-311: LGTM! Display tab structure with placeholders as intended.

The renderDisplaySettings method introduces a clean grouped layout for the Display tab. The theme section is functional, while skins and colors are correctly marked as disabled placeholders (matching the PR's description of "territory skins placeholders").


217-259: LGTM! Clean tab integration with widened modal.

The Display tab is properly integrated into the tab navigation, and the user-setting-modal--wide class supports the expanded content layout. The conditional rendering logic correctly routes to the new renderDisplaySettings() method.

@VariableVince
Copy link
Contributor

VariableVince commented Dec 27, 2025

@nodeGarden It seems Claude has made changes to files you weren't attempting to change. Many files that you didn't touch in your original PR now have changes suddenly #2685. Like go.mod and pasteltheme.ts, where all kinds of comments were deleted for no reason. AI acting as Agent tends to do this kind of stuff and needs heavy guarding. It seems the AI did a "those changes shouldn't be here because they weren't here before, i'll convienently delete the changes"...

Also Evan closed the previous PR requesting it to be under 300 lines. This one is in the thousands. In your previous PR Description you said "Sorry these aren't separated, but I went too far down the path, and separating became messy. ". But i'm afraid there's some seperation needed after all. Maybe you could combine that work with the sadly needed manual cleanup (putting back deleted comments etc)?

@nodeGarden
Copy link
Author

@nodeGarden It seems Claude has made changes to files you weren't attempting to change. Many files that you didn't touch in your original PR now have changes suddenly #2685. Like go.mod and pasteltheme.ts, where all kinds of comments were deleted for no reason. AI acting as Agent tends to do this kind of stuff and needs heavy guarding. It seems the AI did a "those changes shouldn't be here because they weren't here before, i'll convienently delete the changes"...

Also Evan closed the previous PR requesting it to be under 300 lines. This one is in the thousands. In your previous PR Description you said "Sorry these aren't separated, but I went too far down the path, and separating became messy. ". But i'm afraid there's some seperation needed after all. Maybe you could combine that work with the sadly needed manual cleanup (putting back deleted comments etc)?

Ok. I'll make another attempt to break it up

@nodeGarden nodeGarden closed this Dec 27, 2025
@nodeGarden nodeGarden deleted the feature/local-dev-setup branch December 27, 2025 16:52
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