Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
adaca7e
feat: Nuke Trajectory predicts with alliance breakage
bibizu Jan 15, 2026
581b43b
fix: manage alliances properly
bibizu Jan 15, 2026
244d7dc
fix: bad return
bibizu Jan 15, 2026
7118660
style: SAM radii should use theme colors
bibizu Jan 15, 2026
2ff97c0
feat: Stressed allied SAMs show new colors
bibizu Jan 15, 2026
6295742
style: move all trajectory calculation into nuke util layer
bibizu Jan 15, 2026
2b638b0
feat: Highlight SAMs from "Stressed" and "Intercepting" players
bibizu Jan 16, 2026
184cca0
Merge branch 'openfrontio:main' into sam-nuke-colors
bibizu Jan 17, 2026
dc1e759
style: SAM radii should use theme colors
bibizu Jan 15, 2026
2c6955d
feat: Stressed allied SAMs show new colors
bibizu Jan 15, 2026
a79dced
style: move all trajectory calculation into nuke util layer
bibizu Jan 15, 2026
9372cda
feat: Highlight SAMs from "Stressed" and "Intercepting" players
bibizu Jan 16, 2026
2302e7b
Merge branch 'sam-nuke-colors' of https://github.com/bibizu/openfront…
bibizu Jan 20, 2026
1517e3d
fix: reset trajectory properly when not needed
bibizu Jan 20, 2026
e7e6445
Merge branch 'openfrontio:main' into sam-nuke-colors
bibizu Jan 20, 2026
0554ceb
enhance: show enemy as opaque all the time
bibizu Jan 20, 2026
07bf639
enhance: alt mode now reverts SAM transparency and coloring
bibizu Jan 20, 2026
1fe990c
Merge branch 'main' into sam-nuke-colors
evanpelle Jan 22, 2026
650bab7
fix: only highlight radii from intercepting SAMs, not players
bibizu Jan 25, 2026
663892a
Merge branch 'openfrontio:main' into sam-nuke-colors
bibizu Jan 25, 2026
bcb3841
Merge branch 'sam-nuke-colors' of https://github.com/bibizu/openfront…
bibizu Jan 25, 2026
5805cc6
enhance: add structure tracking to nuke util layer
bibizu Jan 26, 2026
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
28 changes: 21 additions & 7 deletions src/client/graphics/GameRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { Leaderboard } from "./layers/Leaderboard";
import { MainRadialMenu } from "./layers/MainRadialMenu";
import { MultiTabModal } from "./layers/MultiTabModal";
import { NameLayer } from "./layers/NameLayer";
import { NukeRenderUtilLayer } from "./layers/NukeRenderUtilLayer";
import { NukeTrajectoryPreviewLayer } from "./layers/NukeTrajectoryPreviewLayer";
import { PerformanceOverlay } from "./layers/PerformanceOverlay";
import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay";
Expand Down Expand Up @@ -211,9 +212,6 @@ export function createRenderer(
}
headsUpMessage.game = game;

const structureLayer = new StructureLayer(game, eventBus, transformHandler);
const samRadiusLayer = new SAMRadiusLayer(game, eventBus, uiState);

const performanceOverlay = document.querySelector(
"performance-overlay",
) as PerformanceOverlay;
Expand Down Expand Up @@ -244,20 +242,36 @@ export function createRenderer(
}
immunityTimer.game = game;

// Layer for all nuke trajectory and alliance prediction calculations.
// Passed onto several other layers down the line for property access.
const nukeRenderUtilLayer = new NukeRenderUtilLayer(
game,
eventBus,
uiState,
transformHandler,
);

// When updating these layers please be mindful of the order.
// Try to group layers by the return value of shouldTransform.
// Not grouping the layers may cause excessive calls to context.save() and context.restore().
const layers: Layer[] = [
nukeRenderUtilLayer,
new TerrainLayer(game, transformHandler),
new TerritoryLayer(game, eventBus, transformHandler, userSettings),
new RailroadLayer(game, eventBus, transformHandler),
structureLayer,
samRadiusLayer,
new StructureLayer(game, eventBus, transformHandler),
new UnitLayer(game, eventBus, transformHandler),
new FxLayer(game),
new UILayer(game, eventBus, transformHandler),
new NukeTrajectoryPreviewLayer(game, eventBus, transformHandler, uiState),
new StructureIconsLayer(game, eventBus, uiState, transformHandler),
new StructureIconsLayer(
game,
eventBus,
uiState,
transformHandler,
nukeRenderUtilLayer,
),
new SAMRadiusLayer(game, eventBus, uiState, nukeRenderUtilLayer),
new NukeTrajectoryPreviewLayer(game, nukeRenderUtilLayer),
Comment on lines +273 to +274
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think it would be a bit cleaner to merge these two layers into one layer, and then that layer has a nukeRenderUtil layer that it calls into?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Structure Icon Layer was also running more or less the same logic, so there are three layers that all require the same calculation. This also makes it easy if I want to add more layers as well needing it as well. (such as I might wanna work on showing which structures in StructureLayer get destroyed by the nuke).

new DynamicUILayer(game, transformHandler, eventBus),
new NameLayer(game, transformHandler, eventBus),
eventsDisplay,
Expand Down
338 changes: 338 additions & 0 deletions src/client/graphics/layers/NukeRenderUtilLayer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,338 @@
import type { EventBus } from "../../../core/EventBus";
import { listAffectedByNuke } from "../../../core/execution/Util";
import { UnitType } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import type { GameView } from "../../../core/game/GameView";
import { UniversalPathFinding } from "../../../core/pathfinding/PathFinder";
import {
GhostStructureChangedEvent,
MouseMoveEvent,
SwapRocketDirectionEvent,
} from "../../InputHandler";
import { TransformHandler } from "../TransformHandler";
import { UIState } from "../UIState";
import { Layer } from "./Layer";

/**
* A layer that calculates nuke shared information
* for other layers to draw from.
* Does not draw anything itself!
*/
export class NukeRenderUtilLayer implements Layer {
private mousePos = { x: 0, y: 0 };
private currentGhostStructure: UnitType | null = null;
private nukeGhostActive = false;
private targetTile: TileRef | null = null;
private spawnTile: TileRef | null = null; // only updated on tick

// A list of every player that would have their alliance break if nuked.
// Includes players not currently allied.
private affectedPlayers = new Set<number>();
// A list of structures that will be destroyed by this nuke.
private affectedStructures = new Set<number>();

// for trajectory prediction
private trajectoryPoints: TileRef[] = [];
private untargetableSegmentBounds: [number, number] = [-1, -1];
private targetedIndex = -1;
private lastTrajectoryUpdate: number = 0;
private lastTargetTile: TileRef | null = null;

// A list of players currently stressed or intercepting the trajectory.
private interceptingPlayers = new Set<number>();
// A list of SAM units intercepting the trajectory.
private interceptingSAMs = new Set<number>();

constructor(
private readonly game: GameView,
private readonly eventBus: EventBus,
private readonly uiState: UIState,
private readonly transformHandler: TransformHandler,
) {}

init() {
this.eventBus.on(MouseMoveEvent, (e) => {
this.mousePos.x = e.x;
this.mousePos.y = e.y;
});
this.eventBus.on(GhostStructureChangedEvent, (e) => {
this.currentGhostStructure = e.ghostStructure;
this.nukeGhostActive =
e.ghostStructure === UnitType.AtomBomb ||
e.ghostStructure === UnitType.HydrogenBomb;
});
this.eventBus.on(SwapRocketDirectionEvent, (event) => {
this.uiState.rocketDirectionUp = event.rocketDirectionUp;
// Force trajectory recalculation
this.lastTargetTile = null;
});
}

/**
* Update trajectory preview - called from tick() to cache spawn tile via expensive player.actions() call
* This only runs when target tile changes, minimizing worker thread communication
*/
private trajectoryPreviewTick() {
// Clear trajectory if not a nuke type
if (!this.nukeGhostActive) {
this.spawnTile = null;
return;
}

// Throttle updates (similar to StructureIconsLayer.renderGhost)
const now = performance.now();
if (now - this.lastTrajectoryUpdate < 50) {
return;
}
this.lastTrajectoryUpdate = now;

const player = this.game.myPlayer();
if (!player) {
this.lastTargetTile = null;
this.spawnTile = null;
return;
}

if (this.findTargetTile() === null) {
this.spawnTile = null;
return;
}

// Only recalculate if target tile changed
if (this.lastTargetTile === this.targetTile) {
return;
}

this.lastTargetTile = this.targetTile;

// Get buildable units to find spawn tile (expensive call - only on tick when tile changes)
player
.actions(this.targetTile as number)
.then((actions) => {
// Ignore stale results if target changed
if (this.lastTargetTile !== this.targetTile) {
return;
}
const buildableUnit = actions.buildableUnits.find(
(bu) => bu.type === this.currentGhostStructure,
);
if (!buildableUnit || buildableUnit.canBuild === false) {
this.spawnTile = null;
return;
}
const spawnTile = buildableUnit.canBuild;
if (!spawnTile) {
this.spawnTile = null;
return;
}
// Cache the spawn tile for use in updateTrajectoryPath()
this.spawnTile = spawnTile;
})
.catch(() => {
// Handle errors silently
this.spawnTile = null;
});
}

/**
* Update trajectory path - called from renderLayer() each frame for smooth visual feedback
* Uses cached spawn tile to avoid expensive player.actions() calls
*/
private trajectoryPreviewFrame() {
// Target is already found for this frame when this is called in renderLayer().
// Safety check
if (!this.spawnTile || !this.targetTile) {
this.clearCurrentTrajectory();
return;
}
// Calculate trajectory using ParabolaUniversalPathFinder with cached spawn tile
const speed = this.game.config().defaultNukeSpeed();
const pathFinder = UniversalPathFinding.Parabola(this.game, {
increment: speed,
distanceBasedHeight: true, // AtomBomb/HydrogenBomb use distance-based height
directionUp: this.uiState.rocketDirectionUp,
});

this.trajectoryPoints =
pathFinder.findPath(this.spawnTile, this.targetTile) ?? [];

// NOTE: This is a lot to do in the rendering method, naive
// But trajectory is already calculated here and needed for prediction.
// From testing, does not seem to have much effect, so I keep it this way.

// Calculate points when bomb targetability switches
const targetRangeSquared =
this.game.config().defaultNukeTargetableRange() ** 2;

// Find two switch points where bomb transitions:
// [0]: leaves spawn range, enters untargetable zone
// [1]: enters target range, becomes targetable again
this.untargetableSegmentBounds = [-1, -1];
for (let i = 0; i < this.trajectoryPoints.length; i++) {
const tile = this.trajectoryPoints[i];
if (this.untargetableSegmentBounds[0] === -1) {
if (
this.game.euclideanDistSquared(tile, this.spawnTile) >
targetRangeSquared
) {
if (
this.game.euclideanDistSquared(tile, this.targetTile) <
targetRangeSquared
) {
// overlapping spawn & target range
break;
} else {
this.untargetableSegmentBounds[0] = i;
}
}
} else if (
this.game.euclideanDistSquared(tile, this.targetTile) <
targetRangeSquared
) {
this.untargetableSegmentBounds[1] = i;
break;
}
}
this.interceptingPlayers.clear();
this.interceptingSAMs.clear();
// Find the point where SAM can intercept
this.targetedIndex = this.trajectoryPoints.length;
// Check trajectory
for (let i = 0; i < this.trajectoryPoints.length; i++) {
const tile = this.trajectoryPoints[i];
for (const sam of this.game.nearbyUnits(
tile,
this.game.config().maxSamRange(),
UnitType.SAMLauncher,
)) {
if (
sam.unit.owner().isMe() ||
(this.game.myPlayer()?.isFriendly(sam.unit.owner()) &&
!this.affectedPlayers.has(sam.unit.owner().smallID()) &&
!this.interceptingPlayers.has(sam.unit.owner().smallID()))
) {
continue;
}
if (
sam.distSquared <=
this.game.config().samRange(sam.unit.level()) ** 2
) {
this.targetedIndex = i;
this.interceptingPlayers.add(sam.unit.owner().smallID());
this.interceptingSAMs.add(sam.unit.id());
}
}
if (this.targetedIndex !== this.trajectoryPoints.length) break;
// Jump over untargetable segment
if (i === this.untargetableSegmentBounds[0])
i = this.untargetableSegmentBounds[1] - 1;
}
}

// Attempts to find the tile the mouse points to.
// If valid, sets the targetTile property and returns it.
private findTargetTile(): TileRef | null {
this.targetTile = null;
if (!this.nukeGhostActive) {
return null;
}
// Convert mouse position to world coordinates
const rect = this.transformHandler.boundingRect();
if (!rect) {
return null;
}
const localX = this.mousePos.x - rect.left;
const localY = this.mousePos.y - rect.top;
const worldCoords = this.transformHandler.screenToWorldCoordinates(
localX,
localY,
);
if (!this.game.isValidCoord(worldCoords.x, worldCoords.y)) {
return null;
}
this.targetTile = this.game.ref(worldCoords.x, worldCoords.y);
return this.targetTile;
Comment on lines +239 to +254
Copy link
Contributor

@coderabbitai coderabbitai bot Jan 26, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Find and read the full findTargetTile method in NukeRenderUtilLayer.ts
fd NukeRenderUtilLayer.ts --type f

Repository: openfrontio/OpenFrontIO

Length of output: 116


🏁 Script executed:

# Find TransformHandler to understand screenToWorldCoordinates implementation
fd -e ts | xargs rg -l 'class TransformHandler|screenToWorldCoordinates' | head -5

Repository: openfrontio/OpenFrontIO

Length of output: 299


🏁 Script executed:

# Find StructureIconsLayer.ts and check the renderGhost method mentioned
fd StructureIconsLayer.ts --type f

Repository: openfrontio/OpenFrontIO

Length of output: 116


🏁 Script executed:

# Read the full findTargetTile method and surrounding context
rg -n 'findTargetTile' src/client/graphics/layers/NukeRenderUtilLayer.ts -B5 -A30

Repository: openfrontio/OpenFrontIO

Length of output: 3670


🏁 Script executed:

# Find where TransformHandler is defined
rg -n 'class TransformHandler|interface TransformHandler' --type=ts

Repository: openfrontio/OpenFrontIO

Length of output: 141


🏁 Script executed:

# Find the screenToWorldCoordinates method implementation
rg -n 'screenToWorldCoordinates' --type=ts -B2 -A15 | head -80

Repository: openfrontio/OpenFrontIO

Length of output: 6045


🏁 Script executed:

# Now check StructureIconsLayer.ts renderGhost method mentioned in the review
rg -n 'renderGhost' src/client/graphics/layers/StructureIconsLayer.ts -B5 -A20

Repository: openfrontio/OpenFrontIO

Length of output: 1662


🏁 Script executed:

# Also check all usages of screenToWorldCoordinates to see the pattern
rg -n 'screenToWorldCoordinates' --type=ts -B3 -A3 | grep -E "(\.x|\.y|-|screenToWorldCoordinates)"

Repository: openfrontio/OpenFrontIO

Length of output: 7540


🏁 Script executed:

# Verify the exact context - check if rect is the canvas bounding rect
rg -n 'boundingRect' src/client/graphics/TransformHandler.ts -B2 -A8

Repository: openfrontio/OpenFrontIO

Length of output: 4010


Remove the intermediate canvas-relative coordinate calculation—pass raw screen coordinates directly to screenToWorldCoordinates().

The method expects raw screen coordinates and internally subtracts the canvas bounding rect. Lines 244–245 already subtract rect.left/top, causing those offsets to be subtracted twice: once here, and again inside screenToWorldCoordinates() (TransformHandler.ts:102–103).

Compare with all other usages in the codebase (UnitLayer, TerritoryLayer, EmojiTable, BuildMenu, etc.)—they pass raw coordinates directly. Change to:

const worldCoords = this.transformHandler.screenToWorldCoordinates(
  this.mousePos.x,
  this.mousePos.y,
);

Then remove lines 244–245. This same bug exists in StructureIconsLayer.ts lines 255–259.

🤖 Prompt for AI Agents
In `@src/client/graphics/layers/NukeRenderUtilLayer.ts` around lines 239 - 254, In
NukeRenderUtilLayer, remove the intermediate canvas-relative coordinate
calculation (localX/localY) and call transformHandler.screenToWorldCoordinates
with the raw screen coordinates this.mousePos.x and this.mousePos.y, then use
the returned worldCoords with game.isValidCoord and game.ref to set
this.targetTile; also apply the same change in StructureIconsLayer by passing
raw this.mousePos values into screenToWorldCoordinates and deleting the manual
rect subtraction so the canvas offset isn’t subtracted twice.

Copy link
Contributor Author

@bibizu bibizu Jan 26, 2026

Choose a reason for hiding this comment

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

Nothing breaks in testing, and I suspect this is not an issue since we use mouse position directly, which needs the window position subtracted first, or something similar, to get it into "screen" coords. If there actually was a double subtraction, the trajectory should be way off.

Copy link
Contributor

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find MouseMoveEvent definition to see what coordinates it provides
rg -n 'class MouseMoveEvent|interface MouseMoveEvent' --type=ts -A10

Length of output: 597


🏁 Script executed:

#!/bin/bash
# Find where MouseMoveEvent is created/emitted to see what values are passed
rg -n 'new MouseMoveEvent|MouseMoveEvent\(' --type=ts -B2 -A2

Length of output: 330


🏁 Script executed:

#!/bin/bash
# Check other usages of MouseMoveEvent to see if they subtract rect
rg -n 'MouseMoveEvent.*=>|on\(MouseMoveEvent' --type=ts -B2 -A8

Length of output: 3517


🏁 Script executed:

#!/bin/bash
# Specifically check StructureIconsLayer's mouse handling
rg -n 'mousePos|MouseMoveEvent' src/client/graphics/layers/StructureIconsLayer.ts -B2 -A8

Length of output: 2490


🏁 Script executed:

#!/bin/bash
# Check if the canvas is typically fullscreen or positioned at (0,0)
rg -n 'canvas|position.*fixed|position.*absolute|fullscreen' src/client/graphics/GameRenderer.ts -B2 -A3 | head -50

Length of output: 1579


🏁 Script executed:

#!/bin/bash
# Check TransformHandler for canvas setup
rg -n 'canvas.*style|position|getBoundingClientRect' src/client/graphics/TransformHandler.ts -B2 -A5 | head -60

Length of output: 1172


🏁 Script executed:

#!/bin/bash
# Check for canvas CSS styling in the main application files
rg -n 'canvas.*{|#game-canvas|canvas.*style' src/client --type=ts -B2 -A5

Length of output: 127


🏁 Script executed:

#!/bin/bash
# Check the HTML file for canvas positioning
fd -e html | xargs cat | grep -A10 -B5 canvas

Length of output: 743


🏁 Script executed:

#!/bin/bash
# Check for any CSS files that might style the canvas
fd -e css | xargs cat

Length of output: 44050


@bibizu You're right that the canvas is likely at position (0, 0) during your testing, which masks the bug! When rect.left and rect.top are both 0, the double subtraction has no visible effect (0 - 0 - 0 = 0).

However, the double subtraction mathematically exists:

Current code flow:

  1. MouseMoveEvent provides clientX/clientY (raw viewport coordinates) — Line 246 in InputHandler.ts shows new MouseMoveEvent(e.clientX, e.clientY)
  2. NukeRenderUtilLayer subtracts rect.left/rect.top → creates localX/localY
  3. screenToWorldCoordinates(localX, localY) internally subtracts rect.left/rect.top again (TransformHandler.ts lines 102-103)

When the bug would surface:

  • Canvas embedded in a container with offset positioning
  • Mobile layouts with top navigation bars
  • Any scenario where the canvas bounding rect isn't at viewport (0, 0)

Evidence from codebase:
All other layers pass raw mousePos coordinates directly to screenToWorldCoordinates:

  • UnitLayer, TerritoryLayer, EmojiTable, BuildMenu (from earlier searches)
  • Even StructureIconsLayer's moveGhost method at line 417 correctly positions the ghost with raw coordinates, but then renderGhost at lines 255-259 has the same double-subtraction bug

Recommendation:
Fix it for robustness even though it works in your current test scenarios. The trajectory would be off by (rect.left, rect.top) pixels in non-fullscreen contexts.


🧠 Learnings used
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: 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: ryanbarlow97
Repo: openfrontio/OpenFrontIO PR: 2782
File: src/client/graphics/layers/StructureIconsLayer.ts:616-620
Timestamp: 2026-01-03T19:37:30.357Z
Learning: In `src/client/graphics/layers/StructureIconsLayer.ts`, the confirm button in `ensureGhostControls()` for locked ghost nukes (AtomBomb/HydrogenBomb) intentionally does NOT call `removeGhostStructure()` after emitting the build intent. This allows mobile players to rapidly place multiple nukes by "spamming" the confirm button without needing to reopen the radial menu.

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.

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

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.

Learnt from: BrewedCoffee
Repo: openfrontio/OpenFrontIO PR: 1957
File: src/core/execution/PlayerExecution.ts:40-52
Timestamp: 2025-08-29T16:16:11.309Z
Learning: In OpenFrontIO PlayerExecution.ts, when Defense Posts are captured due to tile ownership changes, the intended behavior is to first call u.decreaseLevel() to downgrade them, then still transfer them to the capturing player via captureUnit(). This is not a bug - Defense Posts should be both downgraded and captured, not destroyed outright.

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.

Learnt from: Aareksio
Repo: openfrontio/OpenFrontIO PR: 2815
File: tests/pathfinding/playground/server.ts:87-104
Timestamp: 2026-01-08T00:57:47.789Z
Learning: Playground code in tests/pathfinding/playground/ should not be subjected to detailed code review - working functionality is sufficient for these test/visualization utilities.

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

}

// Resets variables relating to trajectory prediction
private clearCurrentTrajectory() {
// check trajectory already cleared
if (this.targetedIndex === -1) {
return;
}
this.trajectoryPoints = [];
this.interceptingPlayers.clear();
this.targetedIndex = -1;
this.untargetableSegmentBounds = [-1, -1];
}

tick() {
this.trajectoryPreviewTick();
}

renderLayer(context: CanvasRenderingContext2D) {
if (this.findTargetTile() === null || !this.spawnTile) {
this.affectedPlayers.clear();
this.clearCurrentTrajectory();
return;
}
const player = this.game.myPlayer();
if (!player) {
return;
}

// Calculate which players & structures are affected by current nuke placement.
const affectedByNuke = listAffectedByNuke({
game: this.game,
targetTile: this.targetTile as number,
magnitude: this.game
.config()
.nukeMagnitudes(this.uiState.ghostStructure as UnitType),
playerID: player.smallID(),
allySmallIds: new Set(
this.game
.myPlayer()
?.allies()
.map((a) => a.smallID()),
),
threshold: this.game.config().nukeAllianceBreakThreshold(),
});
this.affectedPlayers = affectedByNuke.affectedPlayerIDs;
this.affectedStructures = affectedByNuke.affectedStructureIDs;

// Calculate possible trajectory
this.trajectoryPreviewFrame();
}

isNukeGhostActive() {
return this.nukeGhostActive;
}

// players who are targeted by nuke
getAffectedPlayers() {
return this.affectedPlayers;
}

// structures targeted by nuke
getAffectedStructures() {
return this.affectedStructures;
}

// players who will shoot the nuke down first are intercepting
getInterceptingPlayers() {
return this.interceptingPlayers;
}

// Specific SAM units intercepting the nuke
getInterceptingSAMs() {
return this.interceptingSAMs;
}

getTrajectoryInfo() {
return {
trajectoryPoints: this.trajectoryPoints,
untargetableSegmentBounds: this.untargetableSegmentBounds,
targetedIndex: this.targetedIndex,
};
}
}
Loading
Loading