Files
OpenFrontIO/src/core/game/GameView.ts
T
scamiv c911bfb2d8 Packed unit updates / MotionPlans (#3292)
## Description:

Reduce per-step `Unit` update traffic by shipping packed motion plans
and letting the client advance plan-driven units locally.

Changes:
- Add packed motion plan records (`packedMotionPlans?: Uint32Array`) to
game updates and transfer the buffer worker -> main.
- Introduce `src/core/game/MotionPlans.ts` (schema + pack/unpack) for
grid + train motion plans.
- Extend `Game` with `recordMotionPlan(...)` and
`drainPackedMotionPlans()`, and implement buffering/packing in
`GameImpl`.
- Treat units with motion plans as “plan-driven”: suppress per-tile
`Unit` updates on `move()` and advance positions client-side.
- Emit motion plans from executions:
- `TradeShipExecution`: record/update grid motion plans and `touch()`
when changing target after capture.
- `TransportShipExecution`: record initial plan and update it when
destination changes.
  - `TrainExecution`: record a train plan on init (engine + cars).
- Client: apply motion plans in `GameView` and ensure `UnitLayer`
updates sprites for motion-planned units even when no `Unit` updates
arrived.

## Please complete the following:

- [ ] I have added screenshots for all UI updates
- [ ] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [ ] I have added relevant tests to the test directory
- [ ] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

DISCORD_USERNAME
2026-02-27 20:54:42 -08:00

1296 lines
34 KiB
TypeScript

import { Colord, colord } from "colord";
import { base64url } from "jose";
import { Config } from "../configuration/Config";
import { ColorPalette } from "../CosmeticSchemas";
import { PatternDecoder } from "../PatternDecoder";
import { ClientID, GameID, Player, PlayerCosmetics } from "../Schemas";
import { createRandomName } from "../Util";
import { WorkerClient } from "../worker/WorkerClient";
import {
Cell,
EmojiMessage,
GameUpdates,
Gold,
NameViewData,
PlayerActions,
PlayerBorderTiles,
PlayerID,
PlayerProfile,
PlayerType,
Team,
TerrainType,
TerraNullius,
Tick,
TrainType,
UnitInfo,
UnitType,
} from "./Game";
import { GameMap, TileRef } from "./GameMap";
import {
AllianceView,
AttackUpdate,
GameUpdateType,
GameUpdateViewData,
PlayerUpdate,
UnitUpdate,
} from "./GameUpdates";
import { MotionPlanRecord, unpackMotionPlans } from "./MotionPlans";
import { TerrainMapData } from "./TerrainMapLoader";
import { TerraNulliusImpl } from "./TerraNulliusImpl";
import { UnitGrid, UnitPredicate } from "./UnitGrid";
import { UserSettings } from "./UserSettings";
const userSettings: UserSettings = new UserSettings();
const FRIENDLY_TINT_TARGET = { r: 0, g: 255, b: 0, a: 1 };
const EMBARGO_TINT_TARGET = { r: 255, g: 0, b: 0, a: 1 };
const BORDER_TINT_RATIO = 0.35;
export class UnitView {
public _wasUpdated = true;
public lastPos: TileRef[] = [];
private _createdAt: Tick;
constructor(
private gameView: GameView,
private data: UnitUpdate,
) {
this.lastPos.push(data.pos);
this._createdAt = this.gameView.ticks();
}
createdAt(): Tick {
return this._createdAt;
}
wasUpdated(): boolean {
return this._wasUpdated;
}
lastTiles(): TileRef[] {
return this.lastPos;
}
lastTile(): TileRef {
if (this.lastPos.length === 0) {
return this.data.pos;
}
return this.lastPos[0];
}
update(data: UnitUpdate) {
this.lastPos.push(data.pos);
this._wasUpdated = true;
this.data = data;
}
applyDerivedPosition(pos: TileRef) {
const prev = this.data.pos;
this.lastPos.push(pos);
this._wasUpdated = true;
this.data = {
...this.data,
lastPos: prev,
pos,
};
}
id(): number {
return this.data.id;
}
targetable(): boolean {
return this.data.targetable;
}
markedForDeletion(): number | false {
return this.data.markedForDeletion;
}
type(): UnitType {
return this.data.unitType;
}
troops(): number {
return this.data.troops;
}
retreating(): boolean {
if (this.type() !== UnitType.TransportShip) {
throw Error("Must be a transport ship");
}
return this.data.retreating;
}
tile(): TileRef {
return this.data.pos;
}
owner(): PlayerView {
return this.gameView.playerBySmallID(this.data.ownerID)! as PlayerView;
}
isActive(): boolean {
return this.data.isActive;
}
reachedTarget(): boolean {
return this.data.reachedTarget;
}
hasHealth(): boolean {
return this.data.health !== undefined;
}
health(): number {
return this.data.health ?? 0;
}
isUnderConstruction(): boolean {
return this.data.underConstruction === true;
}
targetUnitId(): number | undefined {
return this.data.targetUnitId;
}
targetTile(): TileRef | undefined {
return this.data.targetTile;
}
// How "ready" this unit is from 0 to 1.
missileReadinesss(): number {
const maxMissiles = this.data.level;
const missilesReloading = this.data.missileTimerQueue.length;
if (missilesReloading === 0) {
return 1;
}
const missilesReady = maxMissiles - missilesReloading;
if (missilesReady === 0 && maxMissiles > 1) {
// Unless we have just one missile (level 1),
// show 0% readiness so user knows no missiles are ready.
return 0;
}
let readiness = missilesReady / maxMissiles;
const cooldownDuration =
this.data.unitType === UnitType.SAMLauncher
? this.gameView.config().SAMCooldown()
: this.gameView.config().SiloCooldown();
for (const cooldown of this.data.missileTimerQueue) {
const cooldownProgress = this.gameView.ticks() - cooldown;
const cooldownRatio = cooldownProgress / cooldownDuration;
const adjusted = cooldownRatio / maxMissiles;
readiness += adjusted;
}
return readiness;
}
level(): number {
return this.data.level;
}
hasTrainStation(): boolean {
return this.data.hasTrainStation;
}
trainType(): TrainType | undefined {
return this.data.trainType;
}
isLoaded(): boolean | undefined {
return this.data.loaded;
}
}
export class PlayerView {
public anonymousName: string | null = null;
private decoder?: PatternDecoder;
private _territoryColor: Colord;
private _borderColor: Colord;
// Update here to include structure light and dark colors
private _structureColors: { light: Colord; dark: Colord };
// Pre-computed border color variants
private _borderColorNeutral: Colord;
private _borderColorFriendly: Colord;
private _borderColorEmbargo: Colord;
private _borderColorDefendedNeutral: { light: Colord; dark: Colord };
private _borderColorDefendedFriendly: { light: Colord; dark: Colord };
private _borderColorDefendedEmbargo: { light: Colord; dark: Colord };
constructor(
private game: GameView,
public data: PlayerUpdate,
public nameData: NameViewData,
public cosmetics: PlayerCosmetics,
) {
if (data.clientID === game.myClientID()) {
this.anonymousName = this.data.name;
} else {
this.anonymousName = createRandomName(
this.data.name,
this.data.playerType,
);
}
const defaultTerritoryColor = this.game
.config()
.theme()
.territoryColor(this);
const defaultBorderColor = this.game
.config()
.theme()
.borderColor(defaultTerritoryColor);
const pattern = userSettings.territoryPatterns()
? this.cosmetics.pattern
: undefined;
if (pattern) {
pattern.colorPalette ??= {
name: "",
primaryColor: defaultTerritoryColor.toHex(),
secondaryColor: defaultBorderColor.toHex(),
} satisfies ColorPalette;
}
if (this.team() === null) {
this._territoryColor = colord(
this.cosmetics.color?.color ??
pattern?.colorPalette?.primaryColor ??
defaultTerritoryColor.toHex(),
);
} else {
this._territoryColor = defaultTerritoryColor;
}
this._structureColors = this.game
.config()
.theme()
.structureColors(this._territoryColor);
const maybeFocusedBorderColor =
this.game.myClientID() === this.data.clientID
? this.game.config().theme().focusedBorderColor()
: defaultBorderColor;
this._borderColor = new Colord(
pattern?.colorPalette?.secondaryColor ??
this.cosmetics.color?.color ??
maybeFocusedBorderColor.toHex(),
);
// Pre-compute all border color variants once
const theme = this.game.config().theme();
const baseRgb = this._borderColor.toRgb();
// Neutral is just the base color
this._borderColorNeutral = this._borderColor;
// Compute friendly tint
this._borderColorFriendly = colord({
r: Math.round(
baseRgb.r * (1 - BORDER_TINT_RATIO) +
FRIENDLY_TINT_TARGET.r * BORDER_TINT_RATIO,
),
g: Math.round(
baseRgb.g * (1 - BORDER_TINT_RATIO) +
FRIENDLY_TINT_TARGET.g * BORDER_TINT_RATIO,
),
b: Math.round(
baseRgb.b * (1 - BORDER_TINT_RATIO) +
FRIENDLY_TINT_TARGET.b * BORDER_TINT_RATIO,
),
a: baseRgb.a,
});
// Compute embargo tint
this._borderColorEmbargo = colord({
r: Math.round(
baseRgb.r * (1 - BORDER_TINT_RATIO) +
EMBARGO_TINT_TARGET.r * BORDER_TINT_RATIO,
),
g: Math.round(
baseRgb.g * (1 - BORDER_TINT_RATIO) +
EMBARGO_TINT_TARGET.g * BORDER_TINT_RATIO,
),
b: Math.round(
baseRgb.b * (1 - BORDER_TINT_RATIO) +
EMBARGO_TINT_TARGET.b * BORDER_TINT_RATIO,
),
a: baseRgb.a,
});
// Pre-compute defended variants
this._borderColorDefendedNeutral = theme.defendedBorderColors(
this._borderColorNeutral,
);
this._borderColorDefendedFriendly = theme.defendedBorderColors(
this._borderColorFriendly,
);
this._borderColorDefendedEmbargo = theme.defendedBorderColors(
this._borderColorEmbargo,
);
this.decoder =
pattern === undefined
? undefined
: new PatternDecoder(pattern, base64url.decode);
}
territoryColor(tile?: TileRef): Colord {
if (tile === undefined || this.decoder === undefined) {
return this._territoryColor;
}
const isPrimary = this.decoder.isPrimary(
this.game.x(tile),
this.game.y(tile),
);
return isPrimary ? this._territoryColor : this._borderColor;
}
structureColors(): { light: Colord; dark: Colord } {
return this._structureColors;
}
/**
* Border color for a tile:
* - Tints by neighbor relations (embargo → red, friendly → green, else neutral).
* - If defended, applies theme checkerboard to the tinted color.
*/
borderColor(tile?: TileRef, isDefended: boolean = false): Colord {
if (tile === undefined) {
return this._borderColor;
}
const { hasEmbargo, hasFriendly } = this.borderRelationFlags(tile);
let baseColor: Colord;
let defendedColors: { light: Colord; dark: Colord };
if (hasEmbargo) {
baseColor = this._borderColorEmbargo;
defendedColors = this._borderColorDefendedEmbargo;
} else if (hasFriendly) {
baseColor = this._borderColorFriendly;
defendedColors = this._borderColorDefendedFriendly;
} else {
baseColor = this._borderColorNeutral;
defendedColors = this._borderColorDefendedNeutral;
}
if (!isDefended) {
return baseColor;
}
const x = this.game.x(tile);
const y = this.game.y(tile);
const lightTile =
(x % 2 === 0 && y % 2 === 0) || (y % 2 === 1 && x % 2 === 1);
return lightTile ? defendedColors.light : defendedColors.dark;
}
/**
* Border relation flags for a tile, used by both CPU and WebGL renderers.
*/
borderRelationFlags(tile: TileRef): {
hasEmbargo: boolean;
hasFriendly: boolean;
} {
const mySmallID = this.smallID();
let hasEmbargo = false;
let hasFriendly = false;
for (const n of this.game.neighbors(tile)) {
if (!this.game.hasOwner(n)) {
continue;
}
const otherOwner = this.game.owner(n);
if (!otherOwner.isPlayer() || otherOwner.smallID() === mySmallID) {
continue;
}
if (this.hasEmbargo(otherOwner)) {
hasEmbargo = true;
break;
}
if (this.isFriendly(otherOwner) || otherOwner.isFriendly(this)) {
hasFriendly = true;
}
}
return { hasEmbargo, hasFriendly };
}
async actions(tile?: TileRef, units?: UnitType[]): Promise<PlayerActions> {
return this.game.worker.playerInteraction(
this.id(),
tile && this.game.x(tile),
tile && this.game.y(tile),
units,
);
}
async borderTiles(): Promise<PlayerBorderTiles> {
return this.game.worker.playerBorderTiles(this.id());
}
outgoingAttacks(): AttackUpdate[] {
return this.data.outgoingAttacks;
}
incomingAttacks(): AttackUpdate[] {
return this.data.incomingAttacks;
}
async attackAveragePosition(
playerID: number,
attackID: string,
): Promise<Cell | null> {
return this.game.worker.attackAveragePosition(playerID, attackID);
}
units(...types: UnitType[]): UnitView[] {
return this.game
.units(...types)
.filter((u) => u.owner().smallID() === this.smallID());
}
nameLocation(): NameViewData {
return this.nameData;
}
smallID(): number {
return this.data.smallID;
}
name(): string {
return this.anonymousName !== null && userSettings.anonymousNames()
? this.anonymousName
: this.data.name;
}
displayName(): string {
return this.anonymousName !== null && userSettings.anonymousNames()
? this.anonymousName
: this.data.name;
}
clientID(): ClientID | null {
return this.data.clientID;
}
id(): PlayerID {
return this.data.id;
}
team(): Team | null {
return this.data.team ?? null;
}
type(): PlayerType {
return this.data.playerType;
}
isAlive(): boolean {
return this.data.isAlive;
}
isPlayer(): this is PlayerView {
return true;
}
numTilesOwned(): number {
return this.data.tilesOwned;
}
allies(): PlayerView[] {
return this.data.allies.map(
(a) => this.game.playerBySmallID(a) as PlayerView,
);
}
targets(): PlayerView[] {
return this.data.targets.map(
(id) => this.game.playerBySmallID(id) as PlayerView,
);
}
gold(): Gold {
return this.data.gold;
}
troops(): number {
return this.data.troops;
}
totalUnitLevels(type: UnitType): number {
return this.units(type)
.filter((unit) => !unit.isUnderConstruction())
.map((unit) => unit.level())
.reduce((a, b) => a + b, 0);
}
isMe(): boolean {
return this.smallID() === this.game.myPlayer()?.smallID();
}
isLobbyCreator(): boolean {
return this.data.isLobbyCreator;
}
isAlliedWith(other: PlayerView): boolean {
return this.data.allies.some((n) => other.smallID() === n);
}
isOnSameTeam(other: PlayerView): boolean {
return this.data.team !== undefined && this.data.team === other.data.team;
}
isFriendly(other: PlayerView): boolean {
return this.isAlliedWith(other) || this.isOnSameTeam(other);
}
isRequestingAllianceWith(other: PlayerView) {
return this.data.outgoingAllianceRequests.some((id) => other.id() === id);
}
alliances(): AllianceView[] {
return this.data.alliances;
}
hasEmbargoAgainst(other: PlayerView): boolean {
return this.data.embargoes.has(other.id());
}
hasEmbargo(other: PlayerView): boolean {
return this.hasEmbargoAgainst(other) || other.hasEmbargoAgainst(this);
}
profile(): Promise<PlayerProfile> {
return this.game.worker.playerProfile(this.smallID());
}
bestTransportShipSpawn(targetTile: TileRef): Promise<TileRef | false> {
return this.game.worker.transportShipSpawn(this.id(), targetTile);
}
transitiveTargets(): PlayerView[] {
return [...this.targets(), ...this.allies().flatMap((p) => p.targets())];
}
isTraitor(): boolean {
return this.data.isTraitor;
}
getTraitorRemainingTicks(): number {
return Math.max(0, this.data.traitorRemainingTicks ?? 0);
}
outgoingEmojis(): EmojiMessage[] {
return this.data.outgoingEmojis;
}
hasSpawned(): boolean {
return this.data.hasSpawned;
}
isDisconnected(): boolean {
return this.data.isDisconnected;
}
lastDeleteUnitTick(): Tick {
return this.data.lastDeleteUnitTick;
}
deleteUnitCooldown(): number {
return (
Math.max(
0,
this.game.config().deleteUnitCooldown() -
(this.game.ticks() + 1 - this.lastDeleteUnitTick()),
) / 10
);
}
}
type TrainPlanState = {
planId: number;
startTick: number;
speed: number;
spacing: number;
carUnitIds: Uint32Array;
path: Uint32Array;
cursor: number;
usedTilesBuf: Uint32Array;
usedHead: number;
usedLen: number;
lastAdvancedTick: Tick;
};
export class GameView implements GameMap {
private lastUpdate: GameUpdateViewData | null;
private smallIDToID = new Map<number, PlayerID>();
private _players = new Map<PlayerID, PlayerView>();
private _units = new Map<number, UnitView>();
private updatedTiles: TileRef[] = [];
private _myPlayer: PlayerView | null = null;
private unitGrid: UnitGrid;
private unitMotionPlans = new Map<
number,
{
planId: number;
startTick: number;
ticksPerStep: number;
path: Uint32Array;
}
>();
private trainMotionPlans = new Map<number, TrainPlanState>();
private trainUnitToEngine = new Map<number, number>();
private toDelete = new Set<number>();
private _cosmetics: Map<string, PlayerCosmetics> = new Map();
private _map: GameMap;
constructor(
public worker: WorkerClient,
private _config: Config,
private _mapData: TerrainMapData,
private _myClientID: ClientID,
private _myUsername: string,
private _gameID: GameID,
private humans: Player[],
) {
this._map = this._mapData.gameMap;
this.lastUpdate = null;
this.unitGrid = new UnitGrid(this._map);
// Replace the local player's username with their own stored username.
// This way the user does not know they are being censored.
for (const h of this.humans) {
if (h.clientID === this._myClientID) {
h.username = this._myUsername;
}
}
this._cosmetics = new Map(
this.humans.map((h) => [h.clientID, h.cosmetics ?? {}]),
);
for (const nation of this._mapData.nations) {
// Nations don't have client ids, so we use their name as the key instead.
this._cosmetics.set(nation.name, {
flag: nation.flag,
} satisfies PlayerCosmetics);
}
}
isOnEdgeOfMap(ref: TileRef): boolean {
return this._map.isOnEdgeOfMap(ref);
}
public updatesSinceLastTick(): GameUpdates | null {
return this.lastUpdate?.updates ?? null;
}
public motionPlans(): ReadonlyMap<
number,
{
planId: number;
startTick: number;
ticksPerStep: number;
path: Uint32Array;
}
> {
return this.unitMotionPlans;
}
private motionPlannedUnitIdsCache: number[] = [];
private motionPlannedUnitIdsDirty = true;
private markMotionPlannedUnitIdsDirty(): void {
this.motionPlannedUnitIdsDirty = true;
}
private rebuildMotionPlannedUnitIdsCacheIfDirty(): void {
if (!this.motionPlannedUnitIdsDirty) {
return;
}
this.motionPlannedUnitIdsDirty = false;
const out = this.motionPlannedUnitIdsCache;
out.length = 0;
for (const unitId of this.unitMotionPlans.keys()) {
out.push(unitId);
}
for (const [engineId, plan] of this.trainMotionPlans) {
out.push(engineId);
for (let i = 0; i < plan.carUnitIds.length; i++) {
const id = plan.carUnitIds[i] >>> 0;
if (id !== 0) out.push(id);
}
}
}
public motionPlannedUnitIds(): number[] {
this.rebuildMotionPlannedUnitIdsCacheIfDirty();
return this.motionPlannedUnitIdsCache;
}
public isCatchingUp(): boolean {
return (this.lastUpdate?.pendingTurns ?? 0) > 1;
}
public update(gu: GameUpdateViewData) {
this.toDelete.forEach((id) => this._units.delete(id));
this.toDelete.clear();
this.lastUpdate = gu;
this.updatedTiles = [];
const packed = this.lastUpdate.packedTileUpdates;
for (let i = 0; i + 1 < packed.length; i += 2) {
const tile = packed[i];
const state = packed[i + 1];
this.updateTile(tile, state);
this.updatedTiles.push(tile);
}
if (gu.packedMotionPlans) {
const records = unpackMotionPlans(gu.packedMotionPlans);
this.applyMotionPlanRecords(records);
}
if (gu.updates === null) {
throw new Error("lastUpdate.updates not initialized");
}
gu.updates[GameUpdateType.Player].forEach((pu) => {
this.smallIDToID.set(pu.smallID, pu.id);
const player = this._players.get(pu.id);
if (player !== undefined) {
player.data = pu;
player.nameData = gu.playerNameViewData[pu.id];
} else {
this._players.set(
pu.id,
new PlayerView(
this,
pu,
gu.playerNameViewData[pu.id],
// First check human by clientID, then check nation by name.
this._cosmetics.get(pu.clientID ?? "") ??
this._cosmetics.get(pu.name) ??
{},
),
);
}
});
this._myPlayer ??= this.playerByClientID(this._myClientID);
for (const unit of this._units.values()) {
unit._wasUpdated = false;
unit.lastPos = unit.lastPos.slice(-1);
}
gu.updates[GameUpdateType.Unit].forEach((update) => {
let unit = this._units.get(update.id);
if (unit !== undefined) {
unit.update(update);
} else {
unit = new UnitView(this, update);
this._units.set(update.id, unit);
this.unitGrid.addUnit(unit);
}
if (!update.isActive) {
this.unitGrid.removeUnit(unit);
} else if (unit.tile() !== unit.lastTile()) {
this.unitGrid.updateUnitCell(unit);
}
if (!unit.isActive()) {
// Wait until next tick to delete the unit.
this.toDelete.add(unit.id());
if (this.unitMotionPlans.delete(unit.id())) {
this.markMotionPlannedUnitIdsDirty();
}
this.clearTrainPlanForUnit(unit.id());
}
});
this.advanceMotionPlannedUnits(gu.tick);
this.rebuildMotionPlannedUnitIdsCacheIfDirty();
}
private advanceMotionPlannedUnits(currentTick: Tick): void {
for (const [unitId, plan] of this.unitMotionPlans) {
const unit = this._units.get(unitId);
if (!unit || !unit.isActive()) {
if (this.unitMotionPlans.delete(unitId)) {
this.markMotionPlannedUnitIdsDirty();
}
continue;
}
const oldTile = unit.tile();
const dt = currentTick - plan.startTick;
const stepIndex =
dt <= 0 ? 0 : Math.floor(dt / Math.max(1, plan.ticksPerStep));
const lastIndex = plan.path.length - 1;
const idx = Math.max(0, Math.min(lastIndex, stepIndex));
const newTile = plan.path[idx] as TileRef;
if (newTile !== oldTile) {
unit.applyDerivedPosition(newTile);
this.unitGrid.updateUnitCell(unit);
continue;
}
// Once a plan is past its final step, `newTile` remains clamped to the last path tile.
// Drop finished plans to avoid repeatedly marking static units as updated each tick.
if (dt > 0 && stepIndex >= lastIndex) {
if (this.unitMotionPlans.delete(unitId)) {
this.markMotionPlannedUnitIdsDirty();
}
}
}
this.advanceTrainMotionPlannedUnits(currentTick);
}
private clearTrainPlanForUnit(unitId: number): void {
const engineId =
this.trainUnitToEngine.get(unitId) ??
(this.trainMotionPlans.has(unitId) ? unitId : null);
if (engineId === null) {
return;
}
const plan = this.trainMotionPlans.get(engineId);
if (!plan) {
this.trainUnitToEngine.delete(unitId);
return;
}
if (this.trainMotionPlans.delete(engineId)) {
this.markMotionPlannedUnitIdsDirty();
}
this.trainUnitToEngine.delete(engineId);
for (let i = 0; i < plan.carUnitIds.length; i++) {
const id = plan.carUnitIds[i] >>> 0;
if (id !== 0) this.trainUnitToEngine.delete(id);
}
}
private advanceTrainMotionPlannedUnits(currentTick: Tick): void {
const staleEngineIds: number[] = [];
for (const [engineId, plan] of this.trainMotionPlans) {
const engine = this._units.get(engineId);
if (!engine || !engine.isActive()) {
staleEngineIds.push(engineId);
continue;
}
const steps = currentTick - plan.lastAdvancedTick;
if (steps <= 0) {
continue;
}
const path = plan.path;
const lastIndex = path.length - 1;
const cap = plan.usedTilesBuf.length;
const pushUsed = (tile: TileRef) => {
if (cap === 0) return;
if (plan.usedLen < cap) {
const idx = (plan.usedHead + plan.usedLen) % cap;
plan.usedTilesBuf[idx] = tile >>> 0;
plan.usedLen++;
} else {
plan.usedTilesBuf[plan.usedHead] = tile >>> 0;
plan.usedHead = (plan.usedHead + 1) % cap;
plan.usedLen = cap;
}
};
const usedGet = (index: number): TileRef | null => {
if (index < 0 || index >= plan.usedLen || cap === 0) return null;
const idx = (plan.usedHead + index) % cap;
return plan.usedTilesBuf[idx] as TileRef;
};
let didMove = false;
for (let step = 0; step < steps; step++) {
const cursor = plan.cursor;
if (cursor >= lastIndex) {
break;
}
for (let i = 0; i < plan.speed && cursor + i < path.length; i++) {
pushUsed(path[cursor + i] as TileRef);
}
plan.cursor = Math.min(lastIndex, cursor + plan.speed);
for (let i = plan.carUnitIds.length - 1; i >= 0; --i) {
const carId = plan.carUnitIds[i] >>> 0;
if (carId === 0) continue;
const car = this._units.get(carId);
if (!car || !car.isActive()) {
continue;
}
const carTileIndex = (i + 1) * plan.spacing + 2;
const tile = usedGet(carTileIndex);
if (tile !== null) {
const oldTile = car.tile();
if (tile !== oldTile) {
car.applyDerivedPosition(tile);
this.unitGrid.updateUnitCell(car);
didMove = true;
}
}
}
const newEngineTile = path[plan.cursor] as TileRef;
const oldEngineTile = engine.tile();
if (newEngineTile !== oldEngineTile) {
engine.applyDerivedPosition(newEngineTile);
this.unitGrid.updateUnitCell(engine);
didMove = true;
}
}
plan.lastAdvancedTick = currentTick;
// Preserve the final-step redraw (plan remains for the tick where motion ends),
// then clear once the train has settled and no longer moves.
// Note: trains are currently deleted at the end of TrainExecution, and the ensuing
// `Unit` update (isActive=false) also clears any associated motion plan records.
// This expiry is defensive to avoid keeping stale plans around if that behavior changes.
if (!didMove && plan.cursor >= lastIndex) {
staleEngineIds.push(engineId);
}
}
for (const engineId of staleEngineIds) {
this.clearTrainPlanForUnit(engineId);
}
}
private applyMotionPlanRecords(records: readonly MotionPlanRecord[]): void {
for (const record of records) {
switch (record.kind) {
case "grid": {
if (record.ticksPerStep < 1 || record.path.length < 1) {
break;
}
const existing = this.unitMotionPlans.get(record.unitId);
if (existing && record.planId <= existing.planId) {
break;
}
const path =
record.path instanceof Uint32Array
? record.path
: Uint32Array.from(record.path);
this.unitMotionPlans.set(record.unitId, {
planId: record.planId,
startTick: record.startTick,
ticksPerStep: record.ticksPerStep,
path,
});
this.markMotionPlannedUnitIdsDirty();
break;
}
case "train": {
if (record.speed < 1 || record.path.length < 1) {
break;
}
const existing = this.trainMotionPlans.get(record.engineUnitId);
if (existing && record.planId <= existing.planId) {
break;
}
if (existing) {
this.clearTrainPlanForUnit(record.engineUnitId);
}
const carUnitIds =
record.carUnitIds instanceof Uint32Array
? record.carUnitIds
: Uint32Array.from(record.carUnitIds);
const path =
record.path instanceof Uint32Array
? record.path
: Uint32Array.from(record.path);
const usedCap = carUnitIds.length * record.spacing + 3;
const usedTilesBuf = new Uint32Array(Math.max(0, usedCap));
this.trainMotionPlans.set(record.engineUnitId, {
planId: record.planId,
startTick: record.startTick,
speed: record.speed,
spacing: record.spacing,
carUnitIds,
path,
cursor: 0,
usedTilesBuf,
usedHead: 0,
usedLen: 0,
lastAdvancedTick: record.startTick,
});
this.markMotionPlannedUnitIdsDirty();
this.trainUnitToEngine.set(record.engineUnitId, record.engineUnitId);
for (let i = 0; i < carUnitIds.length; i++) {
const carId = carUnitIds[i] >>> 0;
if (carId !== 0)
this.trainUnitToEngine.set(carId, record.engineUnitId);
}
break;
}
}
}
}
recentlyUpdatedTiles(): TileRef[] {
return this.updatedTiles;
}
nearbyUnits(
tile: TileRef,
searchRange: number,
types: UnitType | readonly UnitType[],
predicate?: UnitPredicate,
): Array<{ unit: UnitView; distSquared: number }> {
return this.unitGrid.nearbyUnits(
tile,
searchRange,
types,
predicate,
) as Array<{
unit: UnitView;
distSquared: number;
}>;
}
hasUnitNearby(
tile: TileRef,
searchRange: number,
type: UnitType,
playerId?: PlayerID,
includeUnderConstruction?: boolean,
) {
return this.unitGrid.hasUnitNearby(
tile,
searchRange,
type,
playerId,
includeUnderConstruction,
);
}
anyUnitNearby(
tile: TileRef,
searchRange: number,
types: readonly UnitType[],
predicate: (unit: UnitView) => boolean,
playerId?: PlayerID,
includeUnderConstruction?: boolean,
): boolean {
return this.unitGrid.anyUnitNearby(
tile,
searchRange,
types,
predicate,
playerId,
includeUnderConstruction,
);
}
myClientID(): ClientID {
return this._myClientID;
}
myPlayer(): PlayerView | null {
return this._myPlayer;
}
player(id: PlayerID): PlayerView {
const player = this._players.get(id);
if (player === undefined) {
throw Error(`player id ${id} not found`);
}
return player;
}
players(): PlayerView[] {
return Array.from(this._players.values());
}
playerBySmallID(id: number): PlayerView | TerraNullius {
if (id === 0) {
return new TerraNulliusImpl();
}
const playerId = this.smallIDToID.get(id);
if (playerId === undefined) {
throw new Error(`small id ${id} not found`);
}
return this.player(playerId);
}
playerByClientID(id: ClientID): PlayerView | null {
const player =
Array.from(this._players.values()).filter(
(p) => p.clientID() === id,
)[0] ?? null;
if (player === null) {
return null;
}
return player;
}
hasPlayer(id: PlayerID): boolean {
return false;
}
playerViews(): PlayerView[] {
return Array.from(this._players.values());
}
owner(tile: TileRef): PlayerView | TerraNullius {
return this.playerBySmallID(this.ownerID(tile));
}
ticks(): Tick {
if (this.lastUpdate === null) return 0;
return this.lastUpdate.tick;
}
inSpawnPhase(): boolean {
return this.ticks() <= this._config.numSpawnPhaseTurns();
}
isSpawnImmunityActive(): boolean {
return (
this._config.numSpawnPhaseTurns() + this._config.spawnImmunityDuration() >
this.ticks()
);
}
isNationSpawnImmunityActive(): boolean {
return (
this._config.numSpawnPhaseTurns() +
this._config.nationSpawnImmunityDuration() >
this.ticks()
);
}
config(): Config {
return this._config;
}
units(...types: UnitType[]): UnitView[] {
if (types.length === 0) {
return Array.from(this._units.values()).filter((u) => u.isActive());
}
return Array.from(this._units.values()).filter(
(u) => u.isActive() && types.includes(u.type()),
);
}
unit(id: number): UnitView | undefined {
return this._units.get(id);
}
unitInfo(type: UnitType): UnitInfo {
return this._config.unitInfo(type);
}
ref(x: number, y: number): TileRef {
return this._map.ref(x, y);
}
isValidRef(ref: TileRef): boolean {
return this._map.isValidRef(ref);
}
x(ref: TileRef): number {
return this._map.x(ref);
}
y(ref: TileRef): number {
return this._map.y(ref);
}
cell(ref: TileRef): Cell {
return this._map.cell(ref);
}
width(): number {
return this._map.width();
}
height(): number {
return this._map.height();
}
numLandTiles(): number {
return this._map.numLandTiles();
}
isValidCoord(x: number, y: number): boolean {
return this._map.isValidCoord(x, y);
}
isLand(ref: TileRef): boolean {
return this._map.isLand(ref);
}
isOceanShore(ref: TileRef): boolean {
return this._map.isOceanShore(ref);
}
isOcean(ref: TileRef): boolean {
return this._map.isOcean(ref);
}
isShoreline(ref: TileRef): boolean {
return this._map.isShoreline(ref);
}
magnitude(ref: TileRef): number {
return this._map.magnitude(ref);
}
ownerID(ref: TileRef): number {
return this._map.ownerID(ref);
}
hasOwner(ref: TileRef): boolean {
return this._map.hasOwner(ref);
}
setOwnerID(ref: TileRef, playerId: number): void {
return this._map.setOwnerID(ref, playerId);
}
hasFallout(ref: TileRef): boolean {
return this._map.hasFallout(ref);
}
setFallout(ref: TileRef, value: boolean): void {
return this._map.setFallout(ref, value);
}
isBorder(ref: TileRef): boolean {
return this._map.isBorder(ref);
}
neighbors(ref: TileRef): TileRef[] {
return this._map.neighbors(ref);
}
isWater(ref: TileRef): boolean {
return this._map.isWater(ref);
}
isLake(ref: TileRef): boolean {
return this._map.isLake(ref);
}
isShore(ref: TileRef): boolean {
return this._map.isShore(ref);
}
cost(ref: TileRef): number {
return this._map.cost(ref);
}
terrainType(ref: TileRef): TerrainType {
return this._map.terrainType(ref);
}
forEachTile(fn: (tile: TileRef) => void): void {
return this._map.forEachTile(fn);
}
manhattanDist(c1: TileRef, c2: TileRef): number {
return this._map.manhattanDist(c1, c2);
}
euclideanDistSquared(c1: TileRef, c2: TileRef): number {
return this._map.euclideanDistSquared(c1, c2);
}
circleSearch(
tile: TileRef,
radius: number,
filter?: (tile: TileRef, d2: number) => boolean,
): Set<TileRef> {
return this._map.circleSearch(tile, radius, filter);
}
bfs(
tile: TileRef,
filter: (gm: GameMap, tile: TileRef) => boolean,
): Set<TileRef> {
return this._map.bfs(tile, filter);
}
tileState(tile: TileRef): number {
return this._map.tileState(tile);
}
updateTile(tile: TileRef, state: number): void {
this._map.updateTile(tile, state);
}
numTilesWithFallout(): number {
return this._map.numTilesWithFallout();
}
gameID(): GameID {
return this._gameID;
}
focusedPlayer(): PlayerView | null {
return this.myPlayer();
}
}