Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,11 @@
"attack_ratio_desc": "What percentage of your troops to send in an attack (1–100%)",
"territory_patterns_label": "🏳️ Territory Skins",
"territory_patterns_desc": "Choose whether to display territory skin designs in game",
"territory_highlight_label": "Territory Highlight",
"territory_highlight_desc": "Highlight the territory of the player under your cursor",
"territory_highlight_always": "Always",
"territory_highlight_on_key": "On Key Press (default H, customizable via key settings)",
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot Apr 17, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

The "default H" hint will become stale after rebinding.

This label literally says "default H", but the keybind is user-configurable. If someone rebinds highlightTerritory to another key, the select option will keep advertising H, which is a small but real source of confusion. Consider dropping the key hint here (the keybinds tab is the source of truth) or passing the current key in as an ICU argument.

✏️ Possible simplification
-    "territory_highlight_on_key": "On Key Press (default H, customizable via key settings)",
+    "territory_highlight_on_key": "On Key Press",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@resources/lang/en.json` at line 576, The string "territory_highlight_on_key"
currently hard-codes "default H" which becomes inaccurate after key rebinding;
update this resource by either removing the hard-coded key hint (e.g., change to
"On Key Press (customizable via key settings)") or converting it to an ICU-style
string with a placeholder like "{key}" and ensure the code that renders the
label supplies the current binding for highlightTerritory; locate the
"territory_highlight_on_key" entry in the locale JSON and change it accordingly
and update the UI code that reads this key to pass the current key binding if
you choose the placeholder approach.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Intentionally keeping the "default H" hint. The benefit is that a user can see the default key right in the dropdown without having to navigate to the keybinds tab first. If they rebind it, they already know where to find keybind settings — but for the majority of users who never rebind, this tells them exactly which key to press at a glance.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@luctrate, that's a fair point — showing the default key inline reduces friction for the majority of users who never touch keybind settings. The trade-off (slightly stale label for the small group who rebind) is reasonable given that those users already know where keybinds live.

Makes sense, thanks for the context!


🧠 Learnings used
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.

Learnt from: baculinivan-web
Repo: openfrontio/OpenFrontIO PR: 3679
File: src/client/components/baseComponents/setting/SettingKeybind.ts:120-121
Timestamp: 2026-04-15T17:29:56.021Z
Learning: In OpenFrontIO's SettingKeybind.ts, the `displayKey` field in the `change` event detail (e.g., `e.key` or `Shift+${e.key.toUpperCase()}`) does not need extra casing normalization because all callers (e.g., UserSettingModal.ts) pass the value through `formatKeyForDisplay()` before rendering, which already applies fallback capitalization (`value.charAt(0).toUpperCase() + value.slice(1)`). Do not flag display casing on `detail.key` as an issue.

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.

Learnt from: devalnor
Repo: openfrontio/OpenFrontIO PR: 1248
File: src/client/graphics/layers/TerritoryInfoLayer.ts:20-20
Timestamp: 2025-06-22T21:51:14.990Z
Learning: In TerritoryInfoLayer.ts, the highlightedTerritory field uses both null and undefined intentionally: undefined represents initial state or inactive layer (Ctrl released), while null represents active layer with no territory being highlighted at cursor position. This distinction is important for proper state change detection.

"territory_highlight_never": "Never",
"coordinate_grid_label": "Coordinate Grid",
"coordinate_grid_desc": "Toggle the alphanumeric grid overlay",
"attacking_troops_overlay_label": "Attacking Troops Overlay",
Expand All @@ -584,6 +589,9 @@
"view_options": "View Options",
"toggle_view": "Toggle View",
"toggle_view_desc": "Alternate view (terrain/countries)",
"highlight_territory": "Highlight Territory",
"highlight_territory_desc": "Hold to highlight the hovered player's territory",
"highlight_territory_disabled": "Enable \"On Key Press\" mode in basic settings to configure this keybind",
"build_controls": "Build Controls",
"build_city": "Build City",
"build_city_desc": "Build a City under your cursor.",
Expand Down
24 changes: 24 additions & 0 deletions src/client/InputHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,10 @@ export class ToggleCoordinateGridEvent implements GameEvent {
constructor(public readonly enabled: boolean) {}
}

export class TerritoryHighlightKeyEvent implements GameEvent {
constructor(public readonly active: boolean) {}
}

export class TickMetricsEvent implements GameEvent {
constructor(
public readonly tickExecutionDuration?: number,
Expand All @@ -165,6 +169,7 @@ export class InputHandler {
private pointerDown: boolean = false;

private alternateView = false;
private highlightKeyHeld = false;

private moveInterval: NodeJS.Timeout | null = null;
private activeKeys = new Set<string>();
Expand Down Expand Up @@ -214,6 +219,10 @@ export class InputHandler {
this.alternateView = false;
this.eventBus.emit(new AlternateViewEvent(false));
}
if (this.highlightKeyHeld) {
this.highlightKeyHeld = false;
this.eventBus.emit(new TerritoryHighlightKeyEvent(false));
}
this.pointerDown = false;
this.pointers.clear();
});
Expand Down Expand Up @@ -289,6 +298,15 @@ export class InputHandler {
}
}

if (
this.keybindMatchesEvent(e, this.keybinds.highlightTerritory) &&
!this.highlightKeyHeld
) {
e.preventDefault();
this.highlightKeyHeld = true;
this.eventBus.emit(new TerritoryHighlightKeyEvent(true));
}

if (
this.keybindMatchesEvent(e, this.keybinds.coordinateGrid) &&
!e.repeat
Expand Down Expand Up @@ -384,6 +402,12 @@ export class InputHandler {
this.eventBus.emit(new AlternateViewEvent(false));
}

if (this.keybindMatchesEvent(e, this.keybinds.highlightTerritory)) {
e.preventDefault();
this.highlightKeyHeld = false;
this.eventBus.emit(new TerritoryHighlightKeyEvent(false));
}

const resetKey = this.keybinds.resetGfx ?? "KeyR";
if (e.code === resetKey && this.isAltKeyHeld(e)) {
e.preventDefault();
Expand Down
55 changes: 55 additions & 0 deletions src/client/UserSettingModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,14 @@ export class UserSettingModal extends BaseModal {
);
}

private changeTerritoryHighlight(e: CustomEvent<{ value: number | string }>) {
const value = e.detail?.value;
if (value !== "never" && value !== "always" && value !== "onKeyPress")
return;
this.userSettings.setTerritoryHighlight(value);
this.requestUpdate();
}

private togglePerformanceOverlay() {
this.userSettings.togglePerformanceOverlay();
}
Expand Down Expand Up @@ -429,6 +437,31 @@ export class UserSettingModal extends BaseModal {
@change=${this.handleKeybindChange}
></setting-keybind>

${this.userSettings.territoryHighlight() === "onKeyPress"
? html`<setting-keybind
action="highlightTerritory"
label=${translateText("user_setting.highlight_territory")}
description=${translateText(
"user_setting.highlight_territory_desc",
)}
defaultKey="KeyH"
.value=${this.getKeyValue("highlightTerritory")}
.display=${this.getKeyChar("highlightTerritory")}
@change=${this.handleKeybindChange}
></setting-keybind>`
: html`<div
class="flex flex-row items-center justify-between w-full p-4 bg-white/5 border border-white/10 rounded-xl opacity-50 gap-4"
>
<div class="flex flex-col flex-1 min-w-0">
<label class="text-white font-bold text-base block mb-1"
>${translateText("user_setting.highlight_territory")}</label
>
<div class="text-white/50 text-sm leading-snug">
${translateText("user_setting.highlight_territory_disabled")}
</div>
</div>
</div>`}

<h2
class="text-blue-200 text-xl font-bold mt-8 mb-3 border-b border-white/10 pb-2"
>
Expand Down Expand Up @@ -833,6 +866,28 @@ export class UserSettingModal extends BaseModal {
@change=${this.toggleTerritoryPatterns}
></setting-toggle>

<!-- Territory Highlight -->
<setting-select
label=${translateText("user_setting.territory_highlight_label")}
description=${translateText("user_setting.territory_highlight_desc")}
.options=${[
{
value: "never",
label: translateText("user_setting.territory_highlight_never"),
},
{
value: "always",
label: translateText("user_setting.territory_highlight_always"),
},
{
value: "onKeyPress",
label: translateText("user_setting.territory_highlight_on_key"),
},
]}
.value=${this.userSettings.territoryHighlight()}
@change=${this.changeTerritoryHighlight}
></setting-select>

<!-- 📱 Performance Overlay -->
<setting-toggle
label="${translateText("user_setting.performance_overlay_label")}"
Expand Down
80 changes: 66 additions & 14 deletions src/client/graphics/layers/TerritoryLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,18 @@ import {
import { euclDistFN, TileRef } from "../../../core/game/GameMap";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, PlayerView } from "../../../core/game/GameView";
import {
TERRITORY_HIGHLIGHT_KEY,
TerritoryHighlightMode,
USER_SETTINGS_CHANGED_EVENT,
UserSettings,
} from "../../../core/game/UserSettings";
import { PseudoRandom } from "../../../core/PseudoRandom";
import {
AlternateViewEvent,
DragEvent,
MouseOverEvent,
TerritoryHighlightKeyEvent,
} from "../../InputHandler";
import { FrameProfiler } from "../FrameProfiler";
import { TransformHandler } from "../TransformHandler";
Expand Down Expand Up @@ -47,6 +54,17 @@ export class TerritoryLayer implements Layer {
private highlightedTerritory: PlayerView | null = null;

private alternativeView = false;
private highlightMode: TerritoryHighlightMode;
private highlightKeyHeld = false;
private onHighlightSettingChanged = ((e: CustomEvent) => {
const value = e.detail;
if (value === "always" || value === "onKeyPress") {
this.highlightMode = value;
} else {
this.highlightMode = "never";
}
this.updateHighlightedTerritory();
}) as EventListener;
private lastDragTime = 0;
private nodrawDragDuration = 200;
private lastMousePosition: { x: number; y: number } | null = null;
Expand All @@ -63,6 +81,7 @@ export class TerritoryLayer implements Layer {
) {
this.theme = game.config().theme();
this.cachedTerritoryPatternsEnabled = undefined;
this.highlightMode = new UserSettings().territoryHighlight();
}

shouldTransform(): boolean {
Expand Down Expand Up @@ -328,11 +347,22 @@ export class TerritoryLayer implements Layer {
this.eventBus.on(MouseOverEvent, (e) => this.onMouseOver(e));
this.eventBus.on(AlternateViewEvent, (e) => {
this.alternativeView = e.alternateView;
this.updateHighlightedTerritory();
});
this.eventBus.on(TerritoryHighlightKeyEvent, (e) => {
this.highlightKeyHeld = e.active;
this.updateHighlightedTerritory();
});
this.eventBus.on(DragEvent, (e) => {
// TODO: consider re-enabling this on mobile or low end devices for smoother dragging.
// this.lastDragTime = Date.now();
});

globalThis.addEventListener?.(
`${USER_SETTINGS_CHANGED_EVENT}:${TERRITORY_HIGHLIGHT_KEY}`,
this.onHighlightSettingChanged,
);
Comment on lines +361 to +364
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Settings listener added on globalThis is never removed.

The listener attached in init() has no matching removeEventListener. If init() ever runs twice (e.g., layer re-created across games, HMR in dev) the handler accumulates and will keep a reference to this TerritoryLayer alive, so old layers keep reacting to setting changes.

A small fix is to keep a destroy() / teardown path and unregister there, and/or guard against double-init.

🧹 Suggested teardown
+  destroy() {
+    globalThis.removeEventListener?.(
+      `${USER_SETTINGS_CHANGED_EVENT}:${TERRITORY_HIGHLIGHT_KEY}`,
+      this.onHighlightSettingChanged,
+    );
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/graphics/layers/TerritoryLayer.ts` around lines 361 - 364, The
event listener added via globalThis.addEventListener for
`${USER_SETTINGS_CHANGED_EVENT}:${TERRITORY_HIGHLIGHT_KEY}` in init() is never
removed — add cleanup to avoid handler accumulation: implement a destroy() (or
teardown) method on TerritoryLayer that calls globalThis.removeEventListener
with the same event string and the onHighlightSettingChanged handler, and ensure
init() guards against double-init (e.g., a boolean _inited or check/remove
existing listener before adding) so re-creating the layer or HMR won’t leave
dangling listeners.


this.redraw();
}

Expand All @@ -342,7 +372,17 @@ export class TerritoryLayer implements Layer {
}

private updateHighlightedTerritory() {
if (!this.alternativeView) {
const shouldHighlight =
this.highlightMode === "always" ||
(this.highlightMode === "onKeyPress" && this.highlightKeyHeld) ||
this.alternativeView;

if (!shouldHighlight) {
if (this.highlightedTerritory) {
const prev = this.highlightedTerritory;
this.highlightedTerritory = null;
this.updateHighlightAlpha(prev, null);
}
return;
}

Expand All @@ -368,17 +408,31 @@ export class TerritoryLayer implements Layer {
}

if (previousTerritory?.id() !== this.highlightedTerritory?.id()) {
const territories: PlayerView[] = [];
if (previousTerritory) {
territories.push(previousTerritory);
}
if (this.highlightedTerritory) {
territories.push(this.highlightedTerritory);
}
this.redrawBorder(...territories);
this.updateHighlightAlpha(previousTerritory, this.highlightedTerritory);
}
}

private updateHighlightAlpha(
oldPlayer: PlayerView | null,
newPlayer: PlayerView | null,
) {
const data = this.imageData.data;
const oldSmallID = oldPlayer?.smallID() ?? -1;
const newSmallID = newPlayer?.smallID() ?? -1;
this.game.forEachTile((tile) => {
const offset = tile * 4;
const alpha = data[offset + 3];
// Only update non-border territory fill tiles (alpha 150 or 230)
if (alpha !== 150 && alpha !== 230) return;
const tileOwner = this.game.ownerID(tile);
if (tileOwner === newSmallID) {
data[offset + 3] = 230;
} else if (alpha === 230 && tileOwner === oldSmallID) {
data[offset + 3] = 150;
}
});
}
Comment on lines +415 to +434
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot Apr 17, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Look for tile-iteration methods on PlayerView and related classes.
rg -nP --type=ts -C2 '\b(tiles|ownedTiles|allTiles|borderTiles)\s*\(' -g 'src/core/game/**'
ast-grep --pattern $'class PlayerView {
  $$$
}'

Repository: openfrontio/OpenFrontIO

Length of output: 25984


🏁 Script executed:

cd src/core/game && grep -n "^\s*\(tiles\|ownedTiles\|allTiles\)(" GameView.ts | head -20

Repository: openfrontio/OpenFrontIO

Length of output: 49


updateHighlightAlpha scans the entire map on every player hover change — performance concern is valid, but the proposed solution is not available.

forEachTile() iterates every tile each time the pointer moves between player territories. On large maps (especially giantworldmap) this causes noticeable lag during crowded matches where hover events fire frequently.

However, PlayerView does not expose a synchronous per-player tiles accessor—only async borderTiles(), which cannot be used in the synchronous highlight update flow.

To address this, consider:

  • Caching per-player tile lists incrementally as they change (update on conquest/relinquish only, not per-hover)
  • Exposing a synchronous tiles() method on PlayerView (requires core API expansion)
  • Deferring highlight updates to a throttled pass rather than per-mousemove
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/graphics/layers/TerritoryLayer.ts` around lines 412 - 432,
updateHighlightAlpha currently scans every tile via forEachTile on each hover
causing lag; instead maintain a synchronous per-player tile cache updated only
when ownership changes (e.g., update cache inside conquest/relinquish handlers
or in methods that modify ownership), then change updateHighlightAlpha to
iterate only the oldPlayer/newPlayer tile lists from that cache (use
PlayerView.id() as the key) and update imageData.data[offset+3] for those tiles;
as an alternative interim, throttle pointer hover updates to reduce frequency
until the cache is implemented.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in 6b19661. The main bottleneck was calling owner(tile).id() in the inner loop — this does a Map.get() lookup + PlayerView creation + string comparison per tile.

Replaced with ownerID(tile) (direct Uint16Array read + bitmask) compared against smallID() integers. Combined with the existing alpha filter (skips ~60% of tiles with alpha != 150/230), the inner loop is now just: read byte → skip most tiles → read uint16 → compare two ints. No allocations, no Map lookups, no string ops.

The forEachTile scan remains O(n) over all tiles, but the per-tile cost is now negligible (a few nanoseconds). A per-player tile cache would eliminate the scan entirely but would require core API changes — can revisit if profiling shows this is still a bottleneck on giant maps.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@luctrate, that's a clean fix. The updated implementation in <file_content> confirms it:

const oldSmallID = oldPlayer?.smallID() ?? -1;
const newSmallID = newPlayer?.smallID() ?? -1;
this.game.forEachTile((tile) => {
  const offset = tile * 4;
  const alpha = data[offset + 3];
  if (alpha !== 150 && alpha !== 230) return;   // skips ~60% early
  const tileOwner = this.game.ownerID(tile);     // Uint16Array read + bitmask
  if (tileOwner === newSmallID) { ... }          // int comparison
  else if (alpha === 230 && tileOwner === oldSmallID) { ... }
});

The per-tile cost is now just a byte read, an early return for most tiles, a Uint16Array read + bitmask, and two integer comparisons — no allocations, no Map lookups, no string ops. That's a solid pragmatic fix without needing core API changes. The per-player tile cache can be revisited later if profiling on giant maps shows it's still needed.

(\_/)
(•ᴗ•)
/>🎉


🧠 Learnings used
Learnt from: devalnor
Repo: openfrontio/OpenFrontIO PR: 1248
File: src/client/graphics/layers/TerritoryInfoLayer.ts:20-20
Timestamp: 2025-06-22T21:51:14.990Z
Learning: In TerritoryInfoLayer.ts, the highlightedTerritory field uses both null and undefined intentionally: undefined represents initial state or inactive layer (Ctrl released), while null represents active layer with no territory being highlighted at cursor position. This distinction is important for proper state change detection.

Learnt from: VariableVince
Repo: openfrontio/OpenFrontIO PR: 3540
File: src/client/graphics/layers/NameLayer.ts:84-86
Timestamp: 2026-03-30T18:41:35.016Z
Learning: In graphics layers, `redraw()` should be intentionally a no-op (or have an empty/limited body) when the layer is DOM-based and does not depend on Canvas/WebGL context (e.g., it does not require reinitializing GPU resources after context loss). The `redraw()` hook should only contain reinitialization logic for Canvas/WebGL layers that must restore GPU resources on context restoration.

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.

Learnt from: FloPinguin
Repo: openfrontio/OpenFrontIO PR: 3604
File: src/core/game/GameImpl.ts:747-765
Timestamp: 2026-04-07T12:24:45.143Z
Learning: In `src/core/game/GameImpl.ts`, the flush loop for `_pendingWaterTiles` intentionally re-checks live ownership (`isLand && !hasOwner`) rather than snapshotting at enqueue time. NukeExecution always calls `relinquish(tile)` before `queueWaterConversion(tile)`, so every queued tile is unowned when enqueued. If another execution reconquers the tile during the same tick, the conversion is skipped and the land survives — this is deliberate "last writer wins" behaviour consistent with the rest of the engine. A dedicated test ("queueWaterConversion skips tiles conquered before flush") covers this case. Do not flag this as a race condition or suggest snapshotting.

Learnt from: bibizu
Repo: openfrontio/OpenFrontIO PR: 2944
File: src/client/graphics/layers/SAMRadiusLayer.ts:283-286
Timestamp: 2026-01-26T00:20:17.081Z
Learning: In src/client/graphics/layers/SAMRadiusLayer.ts, the lineColorStressed variable currently uses this.theme.allyColor() as placeholder code. A dynamic color method for stressed SAMs is planned for a future PR.

Learnt from: andybellenie
Repo: openfrontio/OpenFrontIO PR: 3509
File: src/server/GameServer.ts:667-680
Timestamp: 2026-03-24T23:21:38.781Z
Learning: In openfrontio/OpenFrontIO, the clan-tag privacy feature in `src/server/GameServer.ts` (`gameInfoForClient()`, PR `#3509`) is intentionally a partial mitigation against tag sniping, not a complete fix. A larger clan system rework is in development. The bypass via rejoin with a different clanTag (identityUpdate path in Worker.ts) is a known, accepted limitation. Do not flag this as a critical issue in future reviews of this file.

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.

Learnt from: scamiv
Repo: openfrontio/OpenFrontIO PR: 2539
File: src/core/game/GameImpl.ts:520-542
Timestamp: 2025-11-29T22:22:37.178Z
Learning: In GameImpl.ts, neighborsWithDiag() and forEachNeighborWithDiag() intentionally duplicate coordinate iteration logic. They serve different purposes: forEachNeighborWithDiag() is a zero-allocation hot-path optimization while neighborsWithDiag() is a convenience method that returns an array. Refactoring one to call the other would add callback/closure allocations and indirection overhead, defeating the performance goals.

Learnt from: scamiv
Repo: openfrontio/OpenFrontIO PR: 0
File: :0-0
Timestamp: 2026-02-01T16:36:52.217Z
Learning: In this TypeScript game codebase, UI/layer updates should be wall-clock throttled (independent of simulation speed). Do not use tick-modulus gating like `if (game.ticks()%N===0)` inside `layer.tick()` methods. Instead, layers should expose a `getTickIntervalMs()` method and let the renderer schedule ticks based on wall-clock time.

Learnt from: FloPinguin
Repo: openfrontio/OpenFrontIO PR: 3604
File: src/core/game/WaterManager.ts:219-225
Timestamp: 2026-04-07T21:30:00.497Z
Learning: In `src/core/game/WaterManager.ts`, the `oceanQueue.length === 0` fallback in `finalizeWaterChanges` intentionally promotes all converted tiles to ocean even when no converted tile is adjacent to existing ocean. This ensures water-nuke craters are always navigable (ships/ports can use them) regardless of their inland position. Do not flag this as incorrect ocean promotion — it is deliberate design by FloPinguin.

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.

Learnt from: scamiv
Repo: openfrontio/OpenFrontIO PR: 2519
File: src/core/worker/SharedTileRing.ts:55-63
Timestamp: 2025-11-26T22:27:31.844Z
Learning: In SharedTileRing.ts, the ring buffer is sized to width * height (the map dimensions). Combined with the dirty flag deduplication mechanism (each tile can only be enqueued once until the consumer clears its dirty flag), the number of pending entries is naturally bounded by the map size and drained every render frame. Therefore, ring buffer overflow should be extremely rare or effectively impossible, and any theoretical race condition between producer and consumer modifying the read index would only manifest as redundant tile refs being rendered, not memory corruption, which is acceptable.
<!-- </add_learning>

Learnt from: FloPinguin
Repo: openfrontio/OpenFrontIO PR: 2887
File: src/core/execution/NukeExecution.ts:118-122
Timestamp: 2026-01-13T20:16:20.098Z
Learning: In the OpenFrontIO codebase, `playerBySmallID(id: number)` in Game, GameImpl, and GameView explicitly returns `Player | TerraNullius` (or `PlayerView | TerraNullius`), never undefined. The method returns TerraNullius for ID 0 and Player otherwise. Code should check `.isPlayer()` directly without undefined guards or optional chaining.

Learnt from: wozniakpl
Repo: openfrontio/OpenFrontIO PR: 3317
File: src/client/InputHandler.ts:344-350
Timestamp: 2026-03-07T11:35:24.071Z
Learning: Ensure ghost preservation logic for special units (AtomBomb and HydrogenBomb) is implemented via shouldPreserveGhostAfterBuild(unitType) and that ghost removal is conditional on !shouldPreserveGhostAfterBuild(unitType) within createStructure. Confirm that both mouse (MouseUpEvent) and keyboard (ConfirmGhostStructureEvent) confirmation paths go through requestConfirmStructure → createStructure, and that Enter/NumpadEnter confirmations do not break the locked-nuke multi-placement since the ghost should be preserved by design. Add tests to cover shouldPreserveGhostAfterBuild returning true for AtomBomb/HydrogenBomb and verify that ghost removal only occurs for other units. Applies to files in the repository OpenFrontIO (src/client/InputHandler.ts and src/client/graphics/layers/StructureIconsLayer.ts).

Learnt from: scottanderson
Repo: openfrontio/OpenFrontIO PR: 786
File: src/client/TerritoryPatternsModal.ts:337-338
Timestamp: 2025-06-22T05:48:19.241Z
Learning: In src/client/TerritoryPatternsModal.ts, the bit shifting operators (<<) used in coordinate calculations with decoder.getScale() are intentional and should not be changed to multiplication. The user scottanderson confirmed this is functioning as intended.

Learnt from: FloPinguin
Repo: openfrontio/OpenFrontIO PR: 2874
File: src/server/MapLandTiles.ts:7-11
Timestamp: 2026-01-12T21:37:01.156Z
Learning: In this repository's OpenFrontIO deployment, inter-service HTTP calls to the master should target http://localhost:3000 (master at port 3000) as the canonical address. Apply this as the standard for all server-side TypeScript code that communicates with the master. Avoid hardcoding non-master URLs; centralize the master address (e.g., via config or env) when possible, and ensure internal service communication uses localhost:3000 in this architecture.

Learnt from: evanpelle
Repo: openfrontio/OpenFrontIO PR: 3114
File: src/client/graphics/layers/AttacksDisplay.ts:64-77
Timestamp: 2026-02-10T05:02:28.715Z
Learning: In the OpenFrontIO codebase, GameUpdateType maps always have an array value for each key (never undefined). When iterating, you can safely use: for (const event of updates[GameUpdateType.X]) without optional chaining or guards. Treat updates as a Record<GameUpdateType, any[]> and rely on its guaranteed array values. If you modify the type, ensure the guarantee remains (e.g., initialize missing keys to []) to preserve safe iteration.

Learnt from: VariableVince
Repo: openfrontio/OpenFrontIO PR: 3549
File: tests/pathfinding/playground/server.ts:246-250
Timestamp: 2026-03-31T17:02:41.798Z
Learning: When migrating/implementing Express 5+ code, use the Express 5 `app.listen(PORT, (error) => { ... })` callback signature: Express 5 passes listen errors (e.g., `EADDRINUSE`) as the first argument to the callback. Handle the error inside that callback instead of relying on Node.js `http.Server`’s `'error'` event semantics.

If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!


private getTerritoryAtCell(cell: { x: number; y: number }) {
const tile = this.game.ref(cell.x, cell.y);
if (!tile) {
Expand Down Expand Up @@ -559,15 +613,12 @@ export class TerritoryLayer implements Layer {
return;
}
const owner = this.game.owner(tile) as PlayerView;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const isHighlighted =
this.highlightedTerritory &&
this.highlightedTerritory.id() === owner.id();
this.highlightedTerritory.smallID() === owner.smallID();
const myPlayer = this.game.myPlayer();

if (this.game.isBorder(tile)) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const playerIsFocused = owner && this.game.focusedPlayer() === owner;
if (myPlayer) {
const alternativeColor = this.alternateViewColor(owner);
this.paintTile(this.alternativeImageData, tile, alternativeColor, 255);
Expand All @@ -589,7 +640,8 @@ export class TerritoryLayer implements Layer {
// Alternative view only shows borders.
this.clearAlternativeTile(tile);

this.paintTile(this.imageData, tile, owner.territoryColor(tile), 150);
const alpha = isHighlighted ? 230 : 150;
this.paintTile(this.imageData, tile, owner.territoryColor(tile), alpha);
}
}

Expand Down
13 changes: 13 additions & 0 deletions src/core/game/UserSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export function getDefaultKeybinds(isMac: boolean): Record<string, string> {
pauseGame: "KeyP",
gameSpeedUp: "Period",
gameSpeedDown: "Comma",
highlightTerritory: "KeyH",
};
}

Expand All @@ -43,6 +44,8 @@ export const FLAG_KEY = "flag";
export const COLOR_KEY = "settings.territoryColor";
export const DARK_MODE_KEY = "settings.darkMode";
export const PERFORMANCE_OVERLAY_KEY = "settings.performanceOverlay";
export type TerritoryHighlightMode = "always" | "onKeyPress" | "never";
export const TERRITORY_HIGHLIGHT_KEY = "settings.territoryHighlight";
export const KEYBINDS_KEY = "settings.keybinds";

export class UserSettings {
Expand Down Expand Up @@ -221,6 +224,16 @@ export class UserSettings {
this.setBool("settings.territoryPatterns", !this.territoryPatterns());
}

territoryHighlight(): TerritoryHighlightMode {
const value = this.getString(TERRITORY_HIGHLIGHT_KEY, "never");
if (value === "always" || value === "onKeyPress") return value;
return "never";
}

setTerritoryHighlight(value: TerritoryHighlightMode) {
this.setString(TERRITORY_HIGHLIGHT_KEY, value);
}

toggleDarkMode() {
this.setBool(DARK_MODE_KEY, !this.darkMode());
}
Expand Down
Loading