mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:10:42 +00:00
refactor: share renderer state shapes between game and WebGL renderer
PlayerView/UnitView now wrap renderer-shaped state objects (PlayerState, PlayerStatic, UnitState) directly instead of holding engine wire types. GameView owns a long-lived FrameData object kept in sync each tick: players/units/tiles/trail/railroad are mutated in place; derived buffers (playerStatus, relationMatrix, allianceClusters, nukeTelegraphs, attackRings) and events are recomputed in a final populateFrame() pass. The renderer reads gameView.frameData() and the same byte-identical state objects PlayerView/UnitView wrap. WebGLFrameBuilder shrinks from ~270 to ~70 LOC: palette management + a single uploadFrameData() call, no per-frame UnitState allocation on the hot path. Wiring: maxPlayers=1024 on RendererConfig (pre-sizes NamePass/palette/ relation matrix textures); NamePass disabled so HTML NameLayer remains the only on-screen player names. Also: 39 new tests covering PlayerView/GameView/FrameData behavior; replace .data field access in three layer call sites with accessor methods (betrayals(), type(), getTraitorRemainingTicks()).
This commit is contained in:
@@ -230,7 +230,6 @@ export function joinLobby(
|
||||
|
||||
function mountWebGLDebugRenderer(
|
||||
terrainMap: TerrainMapData,
|
||||
gameView: GameView,
|
||||
transformHandler: import("./graphics/TransformHandler").TransformHandler,
|
||||
): { builder: WebGLFrameBuilder; syncCamera: () => void } {
|
||||
const gameMap = terrainMap.gameMap;
|
||||
@@ -257,11 +256,20 @@ function mountWebGLDebugRenderer(
|
||||
mapHeight,
|
||||
unitTypes: [...ALL_UNIT_TYPES],
|
||||
players: [],
|
||||
// Pre-allocate renderer textures for up to 1024 players. We add players
|
||||
// dynamically via view.addPlayers() as they come in from the simulation,
|
||||
// but the NamePass / palette / relation matrix all need a static upper
|
||||
// bound at construction time.
|
||||
maxPlayers: 1024,
|
||||
},
|
||||
terrainBytes,
|
||||
palette,
|
||||
);
|
||||
|
||||
// Names are rendered by the existing HTML NameLayer; disable the renderer's
|
||||
// NamePass to avoid drawing them twice.
|
||||
view.getSettings().passEnabled.name = false;
|
||||
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if (e.key === "\\") {
|
||||
glCanvas.style.display =
|
||||
@@ -287,7 +295,7 @@ function mountWebGLDebugRenderer(
|
||||
|
||||
(window as unknown as { __webglView?: unknown }).__webglView = view;
|
||||
|
||||
return { builder: new WebGLFrameBuilder(view, gameView), syncCamera };
|
||||
return { builder: new WebGLFrameBuilder(view), syncCamera };
|
||||
}
|
||||
|
||||
async function createClientGame(
|
||||
@@ -343,7 +351,6 @@ async function createClientGame(
|
||||
|
||||
const { builder: webglBuilder, syncCamera } = mountWebGLDebugRenderer(
|
||||
gameMap,
|
||||
gameView,
|
||||
gameRenderer.transformHandler,
|
||||
);
|
||||
gameRenderer.onPreRender = syncCamera;
|
||||
@@ -507,7 +514,7 @@ export class ClientGameRunner {
|
||||
this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash));
|
||||
});
|
||||
this.gameView.update(gu);
|
||||
this.webglBuilder?.update(this.gameView, gu);
|
||||
this.webglBuilder?.update(this.gameView);
|
||||
this.renderer.tick();
|
||||
|
||||
// Emit tick metrics event for performance overlay
|
||||
|
||||
+18
-194
@@ -1,135 +1,32 @@
|
||||
import { Colord } from "colord";
|
||||
import { PlayerType, TrainType, UnitType } from "../core/game/Game";
|
||||
import { GameUpdateType, GameUpdateViewData } from "../core/game/GameUpdates";
|
||||
import { GameView } from "../core/game/GameView";
|
||||
import { RailroadCache } from "./render/frame/railroad-cache";
|
||||
import { TrailManager } from "./render/frame/trail-manager";
|
||||
import {
|
||||
PlayerStatic,
|
||||
UnitState,
|
||||
GameView as WebGLGameView,
|
||||
} from "./render/gl";
|
||||
import {
|
||||
BonusEvent,
|
||||
ConquestFx,
|
||||
DeadUnitFx,
|
||||
PlayerTypeEnum,
|
||||
TrainType as RendererTrainType,
|
||||
} from "./render/types";
|
||||
|
||||
const TRAIL_TYPES: ReadonlySet<UnitType> = new Set<UnitType>([
|
||||
UnitType.TransportShip,
|
||||
UnitType.AtomBomb,
|
||||
UnitType.HydrogenBomb,
|
||||
UnitType.MIRV,
|
||||
UnitType.MIRVWarhead,
|
||||
]);
|
||||
import { uploadFrameData } from "./render/frame/upload";
|
||||
import { PlayerStatic, GameView as WebGLGameView } from "./render/gl";
|
||||
|
||||
const PALETTE_SIZE = 4096;
|
||||
|
||||
/**
|
||||
* The renderer-side glue between GameView (which already builds the full
|
||||
* FrameData each tick) and the WebGL view. Two responsibilities:
|
||||
*
|
||||
* 1. Palette management — translate PlayerView colors into a Float32Array
|
||||
* the renderer uploads to a 1D texture, and call view.addPlayers() when
|
||||
* new players appear (this is a renderer-side lifecycle event, not part
|
||||
* of FrameData).
|
||||
* 2. Per-tick upload — pass the FrameData to the renderer's uploadFrameData
|
||||
* helper, which dispatches to all the view.update*() methods.
|
||||
*/
|
||||
export class WebGLFrameBuilder {
|
||||
private readonly mapW: number;
|
||||
private readonly mapH: number;
|
||||
private readonly tileState: Uint16Array;
|
||||
private readonly palette: Float32Array;
|
||||
private readonly knownSmallIDs = new Set<number>();
|
||||
private readonly railroadCache: RailroadCache;
|
||||
private readonly trailManager: TrailManager;
|
||||
private readonly unitMap = new Map<number, UnitState>();
|
||||
private readonly trailIds: number[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly view: WebGLGameView,
|
||||
gameView: GameView,
|
||||
) {
|
||||
this.mapW = gameView.width();
|
||||
this.mapH = gameView.height();
|
||||
this.tileState = new Uint16Array(this.mapW * this.mapH);
|
||||
constructor(private readonly view: WebGLGameView) {
|
||||
this.palette = new Float32Array(PALETTE_SIZE * 2 * 4);
|
||||
this.railroadCache = new RailroadCache(this.mapW, this.mapH);
|
||||
this.trailManager = new TrailManager(this.mapW, this.mapH);
|
||||
}
|
||||
|
||||
update(gameView: GameView, gu: GameUpdateViewData): void {
|
||||
update(gameView: GameView): void {
|
||||
this.syncPlayers(gameView);
|
||||
this.fillTileState(gameView);
|
||||
this.fillUnitMap(gameView);
|
||||
this.trailManager.update(this.unitMap, this.trailIds);
|
||||
this.view.uploadTileAndTrailState(
|
||||
this.tileState,
|
||||
this.trailManager.getTrailState(),
|
||||
);
|
||||
this.trailManager.clearDirtyRows();
|
||||
this.applyRailroads(gu);
|
||||
this.view.updateStructures(this.unitMap);
|
||||
this.view.updateUnits(this.unitMap, gameView.ticks());
|
||||
this.applyFxEvents(gameView, gu);
|
||||
}
|
||||
|
||||
private applyFxEvents(gameView: GameView, gu: GameUpdateViewData): void {
|
||||
const deadUnits: DeadUnitFx[] = [];
|
||||
for (const u of gu.updates[GameUpdateType.Unit] ?? []) {
|
||||
if (u.isActive) continue;
|
||||
deadUnits.push({
|
||||
unitType: u.unitType,
|
||||
pos: u.pos,
|
||||
reachedTarget: u.reachedTarget,
|
||||
});
|
||||
}
|
||||
if (deadUnits.length > 0) {
|
||||
this.view.applyDeadUnits(deadUnits);
|
||||
}
|
||||
|
||||
const conquests: ConquestFx[] = [];
|
||||
for (const c of gu.updates[GameUpdateType.ConquestEvent] ?? []) {
|
||||
const conquered = gameView.player(c.conqueredId);
|
||||
const loc = conquered.nameLocation();
|
||||
conquests.push({
|
||||
x: loc.x,
|
||||
y: loc.y,
|
||||
gold: Number(c.gold),
|
||||
});
|
||||
}
|
||||
if (conquests.length > 0) {
|
||||
this.view.applyConquestEvents(conquests);
|
||||
}
|
||||
|
||||
const bonuses: BonusEvent[] = [];
|
||||
for (const b of gu.updates[GameUpdateType.BonusEvent] ?? []) {
|
||||
const player = gameView.player(b.player);
|
||||
bonuses.push({
|
||||
playerID: b.player,
|
||||
smallID: player.smallID(),
|
||||
tile: b.tile,
|
||||
gold: Number(b.gold),
|
||||
troops: b.troops,
|
||||
});
|
||||
}
|
||||
if (bonuses.length > 0) {
|
||||
this.view.applyBonusEvents(bonuses);
|
||||
}
|
||||
}
|
||||
|
||||
private fillUnitMap(gameView: GameView): void {
|
||||
this.unitMap.clear();
|
||||
this.trailIds.length = 0;
|
||||
for (const u of gameView.units()) {
|
||||
this.unitMap.set(u.id(), toUnitState(u));
|
||||
if (TRAIL_TYPES.has(u.type())) {
|
||||
this.trailIds.push(u.id());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private applyRailroads(gu: GameUpdateViewData): void {
|
||||
this.railroadCache.apply(gu);
|
||||
if (this.railroadCache.railroadDirty) {
|
||||
this.view.uploadRailroadState(this.railroadCache.railroadState);
|
||||
this.railroadCache.clearDirty();
|
||||
}
|
||||
if (this.railroadCache.revealedRailTiles.length > 0) {
|
||||
this.view.applyRailroadDust(this.railroadCache.revealedRailTiles);
|
||||
}
|
||||
uploadFrameData(this.view, gameView.frameData());
|
||||
}
|
||||
|
||||
private syncPlayers(gameView: GameView): void {
|
||||
@@ -142,14 +39,8 @@ export class WebGLFrameBuilder {
|
||||
this.writePaletteEntry(smallID, p.territoryColor(), p.borderColor());
|
||||
|
||||
newPlayers.push({
|
||||
smallID,
|
||||
id: p.id(),
|
||||
name: p.name(),
|
||||
displayName: p.displayName(),
|
||||
clientID: p.clientID(),
|
||||
playerType: gamePlayerTypeToEnum(p.type()),
|
||||
team: p.team() ?? null,
|
||||
isLobbyCreator: p.isLobbyCreator(),
|
||||
...p.static,
|
||||
flag: p.cosmetics.flag,
|
||||
color: p.territoryColor().toHex(),
|
||||
});
|
||||
}
|
||||
@@ -177,71 +68,4 @@ export class WebGLFrameBuilder {
|
||||
this.palette[borderOff + 2] = borderRgba.b / 255;
|
||||
this.palette[borderOff + 3] = 1.0;
|
||||
}
|
||||
|
||||
private fillTileState(gameView: GameView): void {
|
||||
const w = this.mapW;
|
||||
const h = this.mapH;
|
||||
const buf = this.tileState;
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const ref = gameView.ref(x, y);
|
||||
let v = gameView.ownerID(ref) & 0x0fff;
|
||||
if (gameView.hasFallout(ref)) v |= 1 << 13;
|
||||
buf[y * w + x] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toUnitState(u: import("../core/game/GameView").UnitView): UnitState {
|
||||
return {
|
||||
id: u.id(),
|
||||
unitType: u.type(),
|
||||
ownerID: u.owner().smallID(),
|
||||
lastOwnerID: null,
|
||||
pos: u.tile(),
|
||||
lastPos: u.lastTile(),
|
||||
isActive: u.isActive(),
|
||||
reachedTarget: u.reachedTarget(),
|
||||
retreating: false,
|
||||
targetable: u.targetable(),
|
||||
markedForDeletion: u.markedForDeletion(),
|
||||
health: u.hasHealth() ? u.health() : null,
|
||||
underConstruction: u.isUnderConstruction(),
|
||||
targetUnitId: u.targetUnitId() ?? null,
|
||||
targetTile: u.targetTile() ?? null,
|
||||
troops: u.troops(),
|
||||
missileTimerQueue: u.missileTimerQueue(),
|
||||
level: u.level(),
|
||||
hasTrainStation: u.hasTrainStation(),
|
||||
trainType: trainTypeToNum(u.trainType()),
|
||||
loaded: u.isLoaded() ?? null,
|
||||
constructionStartTick: u.isUnderConstruction() ? u.createdAt() : null,
|
||||
};
|
||||
}
|
||||
|
||||
function trainTypeToNum(t: TrainType | undefined): number | null {
|
||||
switch (t) {
|
||||
case TrainType.Engine:
|
||||
return RendererTrainType.Engine;
|
||||
case TrainType.TailEngine:
|
||||
return RendererTrainType.TailEngine;
|
||||
case TrainType.Carriage:
|
||||
return RendererTrainType.Carriage;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function gamePlayerTypeToEnum(t: PlayerType): PlayerTypeEnum {
|
||||
switch (t) {
|
||||
case PlayerType.Human:
|
||||
return PlayerTypeEnum.Human;
|
||||
case PlayerType.Bot:
|
||||
return PlayerTypeEnum.Bot;
|
||||
case PlayerType.Nation:
|
||||
return PlayerTypeEnum.Nation;
|
||||
default:
|
||||
return PlayerTypeEnum.Bot;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ export class ChatModal extends LitElement {
|
||||
console.log("Sent message:", sender);
|
||||
this.players = this.g
|
||||
.players()
|
||||
.filter((p) => p.isAlive() && p.data.playerType !== PlayerType.Bot);
|
||||
.filter((p) => p.isAlive() && p.type() !== PlayerType.Bot);
|
||||
|
||||
this.recipient = recipient;
|
||||
this.sender = sender;
|
||||
@@ -311,7 +311,7 @@ export class ChatModal extends LitElement {
|
||||
if (sender && recipient) {
|
||||
this.players = this.g
|
||||
.players()
|
||||
.filter((p) => p.isAlive() && p.data.playerType !== PlayerType.Bot);
|
||||
.filter((p) => p.isAlive() && p.type() !== PlayerType.Bot);
|
||||
|
||||
this.recipient = recipient;
|
||||
this.sender = sender;
|
||||
|
||||
@@ -410,7 +410,7 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
}
|
||||
|
||||
private getTraitorRemainingSeconds(player: PlayerView): number | null {
|
||||
const ticksLeft = player.data.traitorRemainingTicks ?? 0;
|
||||
const ticksLeft = player.getTraitorRemainingTicks();
|
||||
if (!player.isTraitor() || ticksLeft <= 0) return null;
|
||||
return Math.ceil(ticksLeft / 10); // 10 ticks = 1 second
|
||||
}
|
||||
@@ -608,7 +608,7 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
<span>${translateText("player_panel.betrayals")}</span>
|
||||
</div>
|
||||
<div class="text-right text-[14px] font-semibold text-zinc-200">
|
||||
${other.data.betrayals ?? 0}
|
||||
${other.betrayals()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,576 @@
|
||||
import { Colord, colord } from "colord";
|
||||
import { base64url } from "jose";
|
||||
import { ColorPalette } from "../../core/CosmeticSchemas";
|
||||
import { PatternDecoder } from "../../core/PatternDecoder";
|
||||
import { ClientID, PlayerCosmetics } from "../../core/Schemas";
|
||||
import { createRandomName } from "../../core/Util";
|
||||
import {
|
||||
BuildableUnit,
|
||||
Cell,
|
||||
EmojiMessage,
|
||||
Gold,
|
||||
NameViewData,
|
||||
PlayerActions,
|
||||
PlayerBorderTiles,
|
||||
PlayerBuildableUnitType,
|
||||
PlayerID,
|
||||
PlayerProfile,
|
||||
PlayerType,
|
||||
Team,
|
||||
Tick,
|
||||
UnitType,
|
||||
} from "../../core/game/Game";
|
||||
import { TileRef } from "../../core/game/GameMap";
|
||||
import {
|
||||
AllianceView,
|
||||
AttackUpdate,
|
||||
PlayerUpdate,
|
||||
} from "../../core/game/GameUpdates";
|
||||
import { UserSettings } from "../../core/game/UserSettings";
|
||||
import { PlayerState, PlayerStatic, PlayerTypeEnum } from "../render/types";
|
||||
import { GameView } from "./GameView";
|
||||
import { UnitView } from "./UnitView";
|
||||
|
||||
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;
|
||||
|
||||
function gamePlayerTypeToEnum(t: PlayerType): PlayerTypeEnum {
|
||||
switch (t) {
|
||||
case PlayerType.Human:
|
||||
return PlayerTypeEnum.Human;
|
||||
case PlayerType.Bot:
|
||||
return PlayerTypeEnum.Bot;
|
||||
case PlayerType.Nation:
|
||||
return PlayerTypeEnum.Nation;
|
||||
default:
|
||||
return PlayerTypeEnum.Bot;
|
||||
}
|
||||
}
|
||||
|
||||
function staticFromUpdate(pu: PlayerUpdate): PlayerStatic {
|
||||
return {
|
||||
smallID: pu.smallID,
|
||||
id: pu.id,
|
||||
name: pu.name,
|
||||
displayName: pu.displayName,
|
||||
clientID: pu.clientID,
|
||||
playerType: gamePlayerTypeToEnum(pu.playerType),
|
||||
team: pu.team ?? null,
|
||||
isLobbyCreator: pu.isLobbyCreator,
|
||||
};
|
||||
}
|
||||
|
||||
function stateFromUpdate(pu: PlayerUpdate): PlayerState {
|
||||
// embargoes: Set<PlayerID strings> on the wire, but the renderer expects
|
||||
// stringified smallIDs. GameView fills these in via setEmbargoes() because
|
||||
// it has the PlayerID → smallID lookup table.
|
||||
return {
|
||||
smallID: pu.smallID,
|
||||
isAlive: pu.isAlive,
|
||||
isDisconnected: pu.isDisconnected,
|
||||
tilesOwned: pu.tilesOwned,
|
||||
gold: Number(pu.gold),
|
||||
troops: pu.troops,
|
||||
isTraitor: pu.isTraitor,
|
||||
traitorRemainingTicks: Math.max(0, pu.traitorRemainingTicks ?? 0),
|
||||
betrayals: pu.betrayals,
|
||||
hasSpawned: pu.hasSpawned,
|
||||
lastDeleteUnitTick: pu.lastDeleteUnitTick,
|
||||
allies: pu.allies.slice(),
|
||||
embargoes: [],
|
||||
targets: pu.targets.slice(),
|
||||
outgoingAttacks: pu.outgoingAttacks,
|
||||
incomingAttacks: pu.incomingAttacks,
|
||||
outgoingAllianceRequests: pu.outgoingAllianceRequests.slice(),
|
||||
alliances: pu.alliances,
|
||||
outgoingEmojis: pu.outgoingEmojis,
|
||||
};
|
||||
}
|
||||
|
||||
function applyStateUpdate(target: PlayerState, pu: PlayerUpdate): void {
|
||||
// smallID is identity — never changes for a given PlayerView.
|
||||
target.isAlive = pu.isAlive;
|
||||
target.isDisconnected = pu.isDisconnected;
|
||||
target.tilesOwned = pu.tilesOwned;
|
||||
target.gold = Number(pu.gold);
|
||||
target.troops = pu.troops;
|
||||
target.isTraitor = pu.isTraitor;
|
||||
target.traitorRemainingTicks = Math.max(0, pu.traitorRemainingTicks ?? 0);
|
||||
target.betrayals = pu.betrayals;
|
||||
target.hasSpawned = pu.hasSpawned;
|
||||
target.lastDeleteUnitTick = pu.lastDeleteUnitTick;
|
||||
// Slice() to detach from the wire object — accumulated state mustn't share
|
||||
// mutable arrays with per-tick update payloads.
|
||||
target.allies = pu.allies.slice();
|
||||
target.targets = pu.targets.slice();
|
||||
target.outgoingAllianceRequests = pu.outgoingAllianceRequests.slice();
|
||||
target.outgoingAttacks = pu.outgoingAttacks;
|
||||
target.incomingAttacks = pu.incomingAttacks;
|
||||
target.alliances = pu.alliances;
|
||||
target.outgoingEmojis = pu.outgoingEmojis;
|
||||
}
|
||||
|
||||
export class PlayerView {
|
||||
public anonymousName: string | null = null;
|
||||
private decoder?: PatternDecoder;
|
||||
|
||||
/** Long-lived renderer state — mutated in place by applyUpdate(). */
|
||||
public state: PlayerState;
|
||||
/** Static header data — set once at construction, never mutated. */
|
||||
public static: PlayerStatic;
|
||||
|
||||
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,
|
||||
data: PlayerUpdate,
|
||||
public nameData: NameViewData,
|
||||
public cosmetics: PlayerCosmetics,
|
||||
) {
|
||||
this.state = stateFromUpdate(data);
|
||||
this.static = staticFromUpdate(data);
|
||||
|
||||
if (data.clientID === game.myClientID()) {
|
||||
this.anonymousName = data.name;
|
||||
} else {
|
||||
this.anonymousName = createRandomName(data.name, data.playerType);
|
||||
}
|
||||
|
||||
const theme = this.game.config().theme();
|
||||
|
||||
const defaultTerritoryColor = theme.territoryColor(this);
|
||||
const defaultBorderColor = 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 = theme.structureColors(this._territoryColor);
|
||||
|
||||
const maybeFocusedBorderColor =
|
||||
this.game.myClientID() === data.clientID
|
||||
? theme.focusedBorderColor()
|
||||
: defaultBorderColor;
|
||||
|
||||
this._borderColor = new Colord(
|
||||
pattern?.colorPalette?.secondaryColor ??
|
||||
this.cosmetics.color?.color ??
|
||||
maybeFocusedBorderColor.toHex(),
|
||||
);
|
||||
|
||||
const baseRgb = this._borderColor.toRgb();
|
||||
|
||||
this._borderColorNeutral = this._borderColor;
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update mutable state in place. Called by GameView.update() each tick the
|
||||
* player appears in the PlayerUpdate stream.
|
||||
*/
|
||||
applyUpdate(pu: PlayerUpdate): void {
|
||||
applyStateUpdate(this.state, pu);
|
||||
}
|
||||
|
||||
/** Set the renderer-format embargoes (stringified smallIDs). */
|
||||
setEmbargoSmallIDs(smallIDStrings: string[]): void {
|
||||
this.state.embargoes = smallIDStrings;
|
||||
}
|
||||
|
||||
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?: readonly PlayerBuildableUnitType[] | null,
|
||||
): Promise<PlayerActions> {
|
||||
return this.game.worker.playerInteraction(
|
||||
this.id(),
|
||||
tile && this.game.x(tile),
|
||||
tile && this.game.y(tile),
|
||||
units,
|
||||
);
|
||||
}
|
||||
|
||||
async buildables(
|
||||
tile?: TileRef,
|
||||
units?: readonly PlayerBuildableUnitType[],
|
||||
): Promise<BuildableUnit[]> {
|
||||
return this.game.worker.playerBuildables(
|
||||
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.state.outgoingAttacks;
|
||||
}
|
||||
|
||||
incomingAttacks(): AttackUpdate[] {
|
||||
return this.state.incomingAttacks;
|
||||
}
|
||||
|
||||
async attackClusteredPositions(
|
||||
attackID?: string,
|
||||
): Promise<{ id: string; positions: Cell[] }[]> {
|
||||
return this.game.worker.attackClusteredPositions(this.smallID(), 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.state.smallID;
|
||||
}
|
||||
|
||||
name(): string {
|
||||
return this.anonymousName !== null && userSettings.anonymousNames()
|
||||
? this.anonymousName
|
||||
: this.static.name;
|
||||
}
|
||||
displayName(): string {
|
||||
return this.anonymousName !== null && userSettings.anonymousNames()
|
||||
? this.anonymousName
|
||||
: this.static.displayName;
|
||||
}
|
||||
|
||||
clientID(): ClientID | null {
|
||||
return this.static.clientID;
|
||||
}
|
||||
id(): PlayerID {
|
||||
return this.static.id;
|
||||
}
|
||||
team(): Team | null {
|
||||
return this.static.team;
|
||||
}
|
||||
type(): PlayerType {
|
||||
// Map PlayerStatic's numeric enum back to engine string enum.
|
||||
switch (this.static.playerType) {
|
||||
case PlayerTypeEnum.Human:
|
||||
return PlayerType.Human;
|
||||
case PlayerTypeEnum.Bot:
|
||||
return PlayerType.Bot;
|
||||
case PlayerTypeEnum.Nation:
|
||||
return PlayerType.Nation;
|
||||
default:
|
||||
return PlayerType.Bot;
|
||||
}
|
||||
}
|
||||
isAlive(): boolean {
|
||||
return this.state.isAlive;
|
||||
}
|
||||
isPlayer(): this is PlayerView {
|
||||
return true;
|
||||
}
|
||||
numTilesOwned(): number {
|
||||
return this.state.tilesOwned;
|
||||
}
|
||||
allies(): PlayerView[] {
|
||||
return this.state.allies.map(
|
||||
(a) => this.game.playerBySmallID(a) as PlayerView,
|
||||
);
|
||||
}
|
||||
targets(): PlayerView[] {
|
||||
return this.state.targets.map(
|
||||
(id) => this.game.playerBySmallID(id) as PlayerView,
|
||||
);
|
||||
}
|
||||
gold(): Gold {
|
||||
// Engine Gold is bigint; renderer state stores number. Convert back at the
|
||||
// accessor for game-code that still expects bigint semantics.
|
||||
return BigInt(this.state.gold);
|
||||
}
|
||||
|
||||
troops(): number {
|
||||
return this.state.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.static.isLobbyCreator;
|
||||
}
|
||||
|
||||
isAlliedWith(other: PlayerView): boolean {
|
||||
return this.state.allies.some((n) => other.smallID() === n);
|
||||
}
|
||||
|
||||
isOnSameTeam(other: PlayerView): boolean {
|
||||
return this.static.team !== null && this.static.team === other.static.team;
|
||||
}
|
||||
|
||||
isFriendly(other: PlayerView): boolean {
|
||||
return this.isAlliedWith(other) || this.isOnSameTeam(other);
|
||||
}
|
||||
|
||||
isRequestingAllianceWith(other: PlayerView) {
|
||||
return this.state.outgoingAllianceRequests.some((id) => other.id() === id);
|
||||
}
|
||||
|
||||
alliances(): AllianceView[] {
|
||||
return this.state.alliances;
|
||||
}
|
||||
|
||||
hasEmbargoAgainst(other: PlayerView): boolean {
|
||||
const otherSmallIDStr = String(other.smallID());
|
||||
return this.state.embargoes.includes(otherSmallIDStr);
|
||||
}
|
||||
|
||||
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[] {
|
||||
const result: PlayerView[] = [];
|
||||
|
||||
// Add own targets
|
||||
for (const id of this.state.targets) {
|
||||
result.push(this.game.playerBySmallID(id) as PlayerView);
|
||||
}
|
||||
|
||||
// Add allies' targets
|
||||
for (const allyID of this.state.allies) {
|
||||
const ally = this.game.playerBySmallID(allyID) as PlayerView;
|
||||
for (const targetId of ally.state.targets) {
|
||||
result.push(this.game.playerBySmallID(targetId) as PlayerView);
|
||||
}
|
||||
}
|
||||
|
||||
// Add teammates' targets
|
||||
const myTeam = this.static.team;
|
||||
if (myTeam !== null) {
|
||||
for (const p of this.game.playerViews()) {
|
||||
if (p !== this && p.static.team === myTeam) {
|
||||
for (const targetId of p.state.targets) {
|
||||
result.push(this.game.playerBySmallID(targetId) as PlayerView);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
isTraitor(): boolean {
|
||||
return this.state.isTraitor;
|
||||
}
|
||||
getTraitorRemainingTicks(): number {
|
||||
return this.state.traitorRemainingTicks;
|
||||
}
|
||||
betrayals(): number {
|
||||
return this.state.betrayals;
|
||||
}
|
||||
outgoingEmojis(): EmojiMessage[] {
|
||||
return this.state.outgoingEmojis;
|
||||
}
|
||||
|
||||
hasSpawned(): boolean {
|
||||
return this.state.hasSpawned;
|
||||
}
|
||||
isDisconnected(): boolean {
|
||||
return this.state.isDisconnected;
|
||||
}
|
||||
|
||||
lastDeleteUnitTick(): Tick {
|
||||
return this.state.lastDeleteUnitTick;
|
||||
}
|
||||
|
||||
deleteUnitCooldown(): number {
|
||||
return (
|
||||
Math.max(
|
||||
0,
|
||||
this.game.config().deleteUnitCooldown() -
|
||||
(this.game.ticks() + 1 - this.lastDeleteUnitTick()),
|
||||
) / 10
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import {
|
||||
Tick,
|
||||
TrainType,
|
||||
TransportShipState,
|
||||
UnitType,
|
||||
WarshipState,
|
||||
} from "../../core/game/Game";
|
||||
import { TileRef } from "../../core/game/GameMap";
|
||||
import { UnitUpdate } from "../../core/game/GameUpdates";
|
||||
import type { UnitState } from "../render/types";
|
||||
import { TrainType as RendererTrainType } from "../render/types";
|
||||
import { GameView } from "./GameView";
|
||||
import { PlayerView } from "./PlayerView";
|
||||
|
||||
/**
|
||||
* Convert engine TrainType (string enum) to renderer's numeric encoding.
|
||||
* UnitState uses 0/1/2 so it can be uploaded to GPU buffers without lookup.
|
||||
*/
|
||||
function trainTypeToNum(t: TrainType | undefined): number | null {
|
||||
switch (t) {
|
||||
case TrainType.Engine:
|
||||
return RendererTrainType.Engine;
|
||||
case TrainType.TailEngine:
|
||||
return RendererTrainType.TailEngine;
|
||||
case TrainType.Carriage:
|
||||
return RendererTrainType.Carriage;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function numToTrainType(n: number | null): TrainType | undefined {
|
||||
switch (n) {
|
||||
case RendererTrainType.Engine:
|
||||
return TrainType.Engine;
|
||||
case RendererTrainType.TailEngine:
|
||||
return TrainType.TailEngine;
|
||||
case RendererTrainType.Carriage:
|
||||
return TrainType.Carriage;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a fresh UnitState from an incoming UnitUpdate. */
|
||||
function unitStateFromUpdate(u: UnitUpdate): UnitState {
|
||||
return {
|
||||
id: u.id,
|
||||
unitType: u.unitType,
|
||||
ownerID: u.ownerID,
|
||||
lastOwnerID: u.lastOwnerID ?? null,
|
||||
pos: u.pos,
|
||||
lastPos: u.lastPos,
|
||||
isActive: u.isActive,
|
||||
reachedTarget: u.reachedTarget,
|
||||
retreating: u.transportShipState?.isRetreating ?? false,
|
||||
targetable: u.targetable,
|
||||
markedForDeletion: u.markedForDeletion,
|
||||
health: u.health ?? null,
|
||||
underConstruction: u.underConstruction ?? false,
|
||||
targetUnitId: u.targetUnitId ?? null,
|
||||
targetTile: u.targetTile ?? null,
|
||||
troops: u.troops,
|
||||
missileTimerQueue: u.missileTimerQueue,
|
||||
level: u.level,
|
||||
hasTrainStation: u.hasTrainStation,
|
||||
trainType: trainTypeToNum(u.trainType),
|
||||
loaded: u.loaded ?? null,
|
||||
constructionStartTick: null, // GameView fills in createdAt when underConstruction
|
||||
};
|
||||
}
|
||||
|
||||
/** Mutate `target` in place from a UnitUpdate, avoiding any allocation. */
|
||||
function applyUpdateInPlace(target: UnitState, u: UnitUpdate): void {
|
||||
target.ownerID = u.ownerID;
|
||||
target.unitType = u.unitType;
|
||||
target.lastOwnerID = u.lastOwnerID ?? null;
|
||||
target.pos = u.pos;
|
||||
target.lastPos = u.lastPos;
|
||||
target.isActive = u.isActive;
|
||||
target.reachedTarget = u.reachedTarget;
|
||||
target.retreating = u.transportShipState?.isRetreating ?? false;
|
||||
target.targetable = u.targetable;
|
||||
target.markedForDeletion = u.markedForDeletion;
|
||||
target.health = u.health ?? null;
|
||||
target.underConstruction = u.underConstruction ?? false;
|
||||
target.targetUnitId = u.targetUnitId ?? null;
|
||||
target.targetTile = u.targetTile ?? null;
|
||||
target.troops = u.troops;
|
||||
target.missileTimerQueue = u.missileTimerQueue;
|
||||
target.level = u.level;
|
||||
target.hasTrainStation = u.hasTrainStation;
|
||||
target.trainType = trainTypeToNum(u.trainType);
|
||||
target.loaded = u.loaded ?? null;
|
||||
}
|
||||
|
||||
export class UnitView {
|
||||
public _wasUpdated = true;
|
||||
public lastPos: TileRef[] = [];
|
||||
/** Long-lived renderer state — mutated in place by update(). */
|
||||
public state: UnitState;
|
||||
/** Engine-only fields not in UnitState. Use warshipState() / transportShipState() to read. */
|
||||
private _warshipState?: WarshipState;
|
||||
private _transportShipState?: TransportShipState;
|
||||
private _createdAt: Tick;
|
||||
|
||||
constructor(
|
||||
private gameView: GameView,
|
||||
data: UnitUpdate,
|
||||
) {
|
||||
this.state = unitStateFromUpdate(data);
|
||||
this._warshipState = data.warshipState;
|
||||
this._transportShipState = data.transportShipState;
|
||||
this.lastPos.push(data.pos);
|
||||
this._createdAt = this.gameView.ticks();
|
||||
if (this.state.underConstruction) {
|
||||
this.state.constructionStartTick = this._createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
createdAt(): Tick {
|
||||
return this._createdAt;
|
||||
}
|
||||
|
||||
wasUpdated(): boolean {
|
||||
return this._wasUpdated;
|
||||
}
|
||||
|
||||
lastTiles(): TileRef[] {
|
||||
return this.lastPos;
|
||||
}
|
||||
|
||||
lastTile(): TileRef {
|
||||
if (this.lastPos.length === 0) {
|
||||
return this.state.pos;
|
||||
}
|
||||
return this.lastPos[0];
|
||||
}
|
||||
|
||||
update(data: UnitUpdate) {
|
||||
this.lastPos.push(data.pos);
|
||||
this._wasUpdated = true;
|
||||
const wasUnderConstruction = this.state.underConstruction;
|
||||
applyUpdateInPlace(this.state, data);
|
||||
this._warshipState = data.warshipState;
|
||||
this._transportShipState = data.transportShipState;
|
||||
// constructionStartTick: set on transition into underConstruction.
|
||||
if (this.state.underConstruction && !wasUnderConstruction) {
|
||||
this.state.constructionStartTick = this.gameView.ticks();
|
||||
} else if (!this.state.underConstruction) {
|
||||
this.state.constructionStartTick = null;
|
||||
}
|
||||
}
|
||||
|
||||
applyDerivedPosition(pos: TileRef) {
|
||||
const prev = this.state.pos;
|
||||
this.lastPos.push(pos);
|
||||
this._wasUpdated = true;
|
||||
this.state.lastPos = prev;
|
||||
this.state.pos = pos;
|
||||
}
|
||||
|
||||
id(): number {
|
||||
return this.state.id;
|
||||
}
|
||||
|
||||
targetable(): boolean {
|
||||
return this.state.targetable;
|
||||
}
|
||||
|
||||
markedForDeletion(): number | false {
|
||||
return this.state.markedForDeletion;
|
||||
}
|
||||
|
||||
type(): UnitType {
|
||||
return this.state.unitType as UnitType;
|
||||
}
|
||||
troops(): number {
|
||||
return this.state.troops;
|
||||
}
|
||||
warshipState(): WarshipState {
|
||||
if (this._warshipState === undefined) {
|
||||
throw new Error("warshipState called on non-warship unit");
|
||||
}
|
||||
return this._warshipState;
|
||||
}
|
||||
updateWarshipState(_update: Partial<WarshipState>): void {
|
||||
throw new Error("updateWarshipState is not supported on UnitView");
|
||||
}
|
||||
isInCombat(): boolean {
|
||||
return this._warshipState?.isInCombat ?? false;
|
||||
}
|
||||
touch(): void {
|
||||
throw new Error("touch is not supported on UnitView");
|
||||
}
|
||||
transportShipState(): TransportShipState {
|
||||
return this._transportShipState ?? { isRetreating: false, troops: 0 };
|
||||
}
|
||||
updateTransportShipState(
|
||||
_update: Pick<TransportShipState, "isRetreating">,
|
||||
): void {
|
||||
throw new Error("updateTransportShipState is not supported on UnitView");
|
||||
}
|
||||
tile(): TileRef {
|
||||
return this.state.pos;
|
||||
}
|
||||
owner(): PlayerView {
|
||||
return this.gameView.playerBySmallID(this.state.ownerID)! as PlayerView;
|
||||
}
|
||||
isActive(): boolean {
|
||||
return this.state.isActive;
|
||||
}
|
||||
reachedTarget(): boolean {
|
||||
return this.state.reachedTarget;
|
||||
}
|
||||
hasHealth(): boolean {
|
||||
return this.state.health !== null;
|
||||
}
|
||||
health(): number {
|
||||
return this.state.health ?? 0;
|
||||
}
|
||||
isUnderConstruction(): boolean {
|
||||
return this.state.underConstruction;
|
||||
}
|
||||
targetUnitId(): number | undefined {
|
||||
return this.state.targetUnitId ?? undefined;
|
||||
}
|
||||
targetTile(): TileRef | undefined {
|
||||
return this.state.targetTile ?? undefined;
|
||||
}
|
||||
|
||||
// How "ready" this unit is from 0 to 1.
|
||||
missileReadinesss(): number {
|
||||
const maxMissiles = this.state.level;
|
||||
const missilesReloading = this.state.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.state.unitType === UnitType.SAMLauncher
|
||||
? this.gameView.config().SAMCooldown()
|
||||
: this.gameView.config().SiloCooldown();
|
||||
|
||||
for (const cooldown of this.state.missileTimerQueue) {
|
||||
const cooldownProgress = this.gameView.ticks() - cooldown;
|
||||
const cooldownRatio = cooldownProgress / cooldownDuration;
|
||||
const adjusted = cooldownRatio / maxMissiles;
|
||||
readiness += adjusted;
|
||||
}
|
||||
return readiness;
|
||||
}
|
||||
|
||||
level(): number {
|
||||
return this.state.level;
|
||||
}
|
||||
hasTrainStation(): boolean {
|
||||
return this.state.hasTrainStation;
|
||||
}
|
||||
trainType(): TrainType | undefined {
|
||||
return numToTrainType(this.state.trainType);
|
||||
}
|
||||
isLoaded(): boolean | undefined {
|
||||
return this.state.loaded ?? undefined;
|
||||
}
|
||||
missileTimerQueue(): number[] {
|
||||
return this.state.missileTimerQueue;
|
||||
}
|
||||
}
|
||||
@@ -1174,6 +1174,9 @@ export class GameImpl implements Game {
|
||||
tileState(tile: TileRef): number {
|
||||
return this._map.tileState(tile);
|
||||
}
|
||||
tileStateBuffer(): Uint16Array {
|
||||
return this._map.tileStateBuffer();
|
||||
}
|
||||
updateTile(tile: TileRef, state: number): boolean {
|
||||
return this._map.updateTile(tile, state);
|
||||
}
|
||||
|
||||
@@ -72,6 +72,20 @@ export interface GameMap {
|
||||
*/
|
||||
updateTile(tile: TileRef, state: number): boolean;
|
||||
|
||||
/**
|
||||
* Direct access to the per-tile state buffer for zero-copy consumers
|
||||
* (e.g. WebGL renderer uploading to a R16UI texture).
|
||||
*
|
||||
* The returned array is a live reference — it is mutated by `updateTile()`
|
||||
* each tick. Callers must not write to it.
|
||||
*
|
||||
* The bit layout of each `uint16` matches the renderer's tile state:
|
||||
* bits 0-11: ownerID
|
||||
* bit 13: fallout
|
||||
* bit 14: defense bonus
|
||||
*/
|
||||
tileStateBuffer(): Uint16Array;
|
||||
|
||||
numTilesWithFallout(): number;
|
||||
}
|
||||
|
||||
@@ -401,6 +415,10 @@ export class GameMapImpl implements GameMap {
|
||||
return this.state[tile];
|
||||
}
|
||||
|
||||
tileStateBuffer(): Uint16Array {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a tile from a packed uint32:
|
||||
* bits 0-15: tile state (owner, fallout, etc.)
|
||||
|
||||
+12
-1417
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* GameView is the client-side simulation mirror — it accumulates player /
|
||||
* unit / tile state from per-tick GameUpdateViewData. The FrameBuilder reads
|
||||
* the same accessors (players(), units(), tileStateBuffer(),
|
||||
* recentlyUpdatedTiles()) to translate state into FrameData each tick.
|
||||
*
|
||||
* These tests verify the update lifecycle: PlayerView reuse vs creation,
|
||||
* UnitView lifecycle (create / mutate / mark for deletion / sweep next tick),
|
||||
* smallID lookup, tick tracking, and tile delta accumulation.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { UnitType } from "../../../src/core/game/Game";
|
||||
import { GameUpdateType } from "../../../src/core/game/GameUpdates";
|
||||
import {
|
||||
makeEmptyGu,
|
||||
makeGameView,
|
||||
makeNameViewData,
|
||||
makePlayerUpdate,
|
||||
makeUnitUpdate,
|
||||
} from "../../util/viewStubs";
|
||||
|
||||
function withPlayers(
|
||||
tick: number,
|
||||
players: ReturnType<typeof makePlayerUpdate>[],
|
||||
nameDataMap: Record<string, ReturnType<typeof makeNameViewData>> = {},
|
||||
) {
|
||||
const gu = makeEmptyGu(tick);
|
||||
gu.updates[GameUpdateType.Player] = players;
|
||||
for (const p of players) {
|
||||
gu.playerNameViewData[p.id] = nameDataMap[p.id] ?? makeNameViewData();
|
||||
}
|
||||
return gu;
|
||||
}
|
||||
|
||||
describe("GameView.update — players", () => {
|
||||
it("creates a PlayerView for each player in the first tick", () => {
|
||||
const game = makeGameView();
|
||||
game.update(
|
||||
withPlayers(1, [
|
||||
makePlayerUpdate({ id: "alice", smallID: 1, name: "Alice" }),
|
||||
makePlayerUpdate({ id: "bob", smallID: 2, name: "Bob" }),
|
||||
]),
|
||||
);
|
||||
expect(game.players().map((p) => p.id())).toEqual(["alice", "bob"]);
|
||||
});
|
||||
|
||||
it("reuses an existing PlayerView on subsequent updates (in-place data swap)", () => {
|
||||
const game = makeGameView();
|
||||
game.update(
|
||||
withPlayers(1, [
|
||||
makePlayerUpdate({ id: "alice", smallID: 1, troops: 100 }),
|
||||
]),
|
||||
);
|
||||
const first = game.player("alice");
|
||||
|
||||
game.update(
|
||||
withPlayers(2, [
|
||||
makePlayerUpdate({ id: "alice", smallID: 1, troops: 250 }),
|
||||
]),
|
||||
);
|
||||
const second = game.player("alice");
|
||||
|
||||
expect(second).toBe(first); // same PlayerView instance
|
||||
expect(second.troops()).toBe(250); // data was swapped in
|
||||
});
|
||||
|
||||
it("playerBySmallID resolves through the smallID → PlayerID map", () => {
|
||||
const game = makeGameView();
|
||||
game.update(
|
||||
withPlayers(1, [
|
||||
makePlayerUpdate({ id: "alice", smallID: 1 }),
|
||||
makePlayerUpdate({ id: "bob", smallID: 2 }),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
(game.playerBySmallID(1) as ReturnType<typeof game.player>).id(),
|
||||
).toBe("alice");
|
||||
expect(
|
||||
(game.playerBySmallID(2) as ReturnType<typeof game.player>).id(),
|
||||
).toBe("bob");
|
||||
});
|
||||
|
||||
it("playerBySmallID(0) returns a TerraNullius (used as the unowned-tile owner)", () => {
|
||||
const game = makeGameView();
|
||||
const terra = game.playerBySmallID(0);
|
||||
expect(terra.isPlayer()).toBe(false);
|
||||
});
|
||||
|
||||
it("myPlayer() is resolved once the local player update arrives", () => {
|
||||
const game = makeGameView({ myClientID: "c-me" });
|
||||
expect(game.myPlayer()).toBeNull();
|
||||
|
||||
game.update(
|
||||
withPlayers(1, [
|
||||
makePlayerUpdate({
|
||||
id: "me",
|
||||
smallID: 1,
|
||||
clientID: "c-me",
|
||||
name: "Me",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(game.myPlayer()?.id()).toBe("me");
|
||||
});
|
||||
|
||||
it("myPlayer() is cached — does not change identity across updates", () => {
|
||||
const game = makeGameView({ myClientID: "c-me" });
|
||||
game.update(
|
||||
withPlayers(1, [
|
||||
makePlayerUpdate({ id: "me", smallID: 1, clientID: "c-me" }),
|
||||
]),
|
||||
);
|
||||
const first = game.myPlayer();
|
||||
game.update(
|
||||
withPlayers(2, [
|
||||
makePlayerUpdate({ id: "me", smallID: 1, clientID: "c-me" }),
|
||||
]),
|
||||
);
|
||||
expect(game.myPlayer()).toBe(first);
|
||||
});
|
||||
|
||||
it("local player's name is overridden with myUsername to bypass censorship", () => {
|
||||
const game = makeGameView({
|
||||
myClientID: "c-me",
|
||||
myUsername: "RealName",
|
||||
});
|
||||
game.update(
|
||||
withPlayers(1, [
|
||||
makePlayerUpdate({
|
||||
id: "me",
|
||||
smallID: 1,
|
||||
clientID: "c-me",
|
||||
name: "ServerName",
|
||||
displayName: "ServerName",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(game.myPlayer()?.name()).toBe("RealName");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GameView.update — units", () => {
|
||||
it("creates a UnitView on first sighting and reuses it after", () => {
|
||||
const game = makeGameView();
|
||||
const gu1 = makeEmptyGu(1);
|
||||
gu1.updates[GameUpdateType.Unit] = [makeUnitUpdate({ id: 42, pos: 0 })];
|
||||
game.update(gu1);
|
||||
const first = game.unit(42);
|
||||
expect(first).toBeDefined();
|
||||
|
||||
const gu2 = makeEmptyGu(2);
|
||||
gu2.updates[GameUpdateType.Unit] = [makeUnitUpdate({ id: 42, pos: 1 })];
|
||||
game.update(gu2);
|
||||
expect(game.unit(42)).toBe(first); // same instance
|
||||
expect(game.unit(42)?.tile()).toBe(1);
|
||||
});
|
||||
|
||||
it("units() filters by type and returns only active units", () => {
|
||||
const game = makeGameView();
|
||||
const gu = makeEmptyGu(1);
|
||||
gu.updates[GameUpdateType.Unit] = [
|
||||
makeUnitUpdate({ id: 1, unitType: UnitType.City, isActive: true }),
|
||||
makeUnitUpdate({ id: 2, unitType: UnitType.Port, isActive: true }),
|
||||
makeUnitUpdate({ id: 3, unitType: UnitType.City, isActive: false }),
|
||||
];
|
||||
game.update(gu);
|
||||
|
||||
expect(
|
||||
game
|
||||
.units()
|
||||
.map((u) => u.id())
|
||||
.sort(),
|
||||
).toEqual([1, 2]);
|
||||
expect(game.units(UnitType.City).map((u) => u.id())).toEqual([1]);
|
||||
// The inactive one is still present until the NEXT tick sweeps it.
|
||||
expect(game.unit(3)).toBeDefined();
|
||||
});
|
||||
|
||||
it("inactive units are deleted on the following tick", () => {
|
||||
const game = makeGameView();
|
||||
|
||||
const gu1 = makeEmptyGu(1);
|
||||
gu1.updates[GameUpdateType.Unit] = [
|
||||
makeUnitUpdate({ id: 7, isActive: true }),
|
||||
];
|
||||
game.update(gu1);
|
||||
expect(game.unit(7)).toBeDefined();
|
||||
|
||||
const gu2 = makeEmptyGu(2);
|
||||
gu2.updates[GameUpdateType.Unit] = [
|
||||
makeUnitUpdate({ id: 7, isActive: false }),
|
||||
];
|
||||
game.update(gu2);
|
||||
// Still present on the tick they died (renderer can see deadUnit FX).
|
||||
expect(game.unit(7)).toBeDefined();
|
||||
|
||||
const gu3 = makeEmptyGu(3);
|
||||
game.update(gu3);
|
||||
// Swept on the next tick.
|
||||
expect(game.unit(7)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("_wasUpdated resets to false at start of tick, then flips back on update", () => {
|
||||
const game = makeGameView();
|
||||
|
||||
const gu1 = makeEmptyGu(1);
|
||||
gu1.updates[GameUpdateType.Unit] = [makeUnitUpdate({ id: 5 })];
|
||||
game.update(gu1);
|
||||
expect(game.unit(5)?.wasUpdated()).toBe(true);
|
||||
|
||||
// Next tick — unit not in updates → wasUpdated should be false
|
||||
game.update(makeEmptyGu(2));
|
||||
expect(game.unit(5)?.wasUpdated()).toBe(false);
|
||||
|
||||
// Next tick — unit reappears → wasUpdated true again
|
||||
const gu3 = makeEmptyGu(3);
|
||||
gu3.updates[GameUpdateType.Unit] = [makeUnitUpdate({ id: 5 })];
|
||||
game.update(gu3);
|
||||
expect(game.unit(5)?.wasUpdated()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GameView.update — tile deltas", () => {
|
||||
it("recentlyUpdatedTiles() reflects refs in packedTileUpdates", () => {
|
||||
const game = makeGameView({ width: 4, height: 4 });
|
||||
const gu = makeEmptyGu(1);
|
||||
// packedTileUpdates is [tileRef, packedState, tileRef, packedState, ...]
|
||||
// packed state = (terrainByte << 16) | state — use 0 for both to keep tile
|
||||
// terrain-stable; we're just exercising the delta accumulator.
|
||||
gu.packedTileUpdates = new Uint32Array([2, 0, 5, 0, 9, 0]);
|
||||
game.update(gu);
|
||||
expect(game.recentlyUpdatedTiles().sort((a, b) => a - b)).toEqual([
|
||||
2, 5, 9,
|
||||
]);
|
||||
});
|
||||
|
||||
it("recentlyUpdatedTerrainTiles() only includes refs where terrain bytes changed", () => {
|
||||
const game = makeGameView({ width: 4, height: 4 });
|
||||
// Tile 3 starts with terrain byte 0. Pack a new terrain byte (0x80 = land)
|
||||
// for tile 3, and an unchanged terrain (0) for tile 7.
|
||||
const gu = makeEmptyGu(1);
|
||||
const TILE_3_PACKED = (0x80 << 16) | 0; // terrain changed
|
||||
const TILE_7_PACKED = 0; // terrain unchanged
|
||||
gu.packedTileUpdates = new Uint32Array([
|
||||
3,
|
||||
TILE_3_PACKED,
|
||||
7,
|
||||
TILE_7_PACKED,
|
||||
]);
|
||||
game.update(gu);
|
||||
expect(game.recentlyUpdatedTiles().sort((a, b) => a - b)).toEqual([3, 7]);
|
||||
expect(game.recentlyUpdatedTerrainTiles()).toEqual([3]);
|
||||
});
|
||||
|
||||
it("resets deltas to empty arrays each tick", () => {
|
||||
const game = makeGameView({ width: 4, height: 4 });
|
||||
const gu1 = makeEmptyGu(1);
|
||||
gu1.packedTileUpdates = new Uint32Array([1, 0]);
|
||||
game.update(gu1);
|
||||
expect(game.recentlyUpdatedTiles().length).toBe(1);
|
||||
|
||||
// Empty next tick → empty deltas
|
||||
game.update(makeEmptyGu(2));
|
||||
expect(game.recentlyUpdatedTiles()).toEqual([]);
|
||||
expect(game.recentlyUpdatedTerrainTiles()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GameView.update — tick & lifecycle", () => {
|
||||
it("ticks() reflects the last update's tick", () => {
|
||||
const game = makeGameView();
|
||||
expect(game.ticks()).toBe(0); // before any update
|
||||
game.update(makeEmptyGu(42));
|
||||
expect(game.ticks()).toBe(42);
|
||||
game.update(makeEmptyGu(43));
|
||||
expect(game.ticks()).toBe(43);
|
||||
});
|
||||
|
||||
it("inSpawnPhase() is true until a SpawnPhaseEnd update flips it off", () => {
|
||||
const game = makeGameView();
|
||||
expect(game.inSpawnPhase()).toBe(true);
|
||||
game.update(makeEmptyGu(5));
|
||||
expect(game.inSpawnPhase()).toBe(true);
|
||||
|
||||
const gu = makeEmptyGu(10);
|
||||
gu.updates[GameUpdateType.SpawnPhaseEnd] = [
|
||||
{ type: GameUpdateType.SpawnPhaseEnd, startTick: 10 } as ReturnType<
|
||||
typeof makeEmptyGu
|
||||
>["updates"][typeof GameUpdateType.SpawnPhaseEnd][number],
|
||||
];
|
||||
game.update(gu);
|
||||
expect(game.inSpawnPhase()).toBe(false);
|
||||
});
|
||||
|
||||
it("ticksSinceStart returns 0 during spawn phase, otherwise difference from startTick", () => {
|
||||
const game = makeGameView();
|
||||
expect(game.ticksSinceStart()).toBe(0); // spawn phase
|
||||
|
||||
const gu1 = makeEmptyGu(10);
|
||||
gu1.updates[GameUpdateType.SpawnPhaseEnd] = [
|
||||
{ type: GameUpdateType.SpawnPhaseEnd, startTick: 10 } as ReturnType<
|
||||
typeof makeEmptyGu
|
||||
>["updates"][typeof GameUpdateType.SpawnPhaseEnd][number],
|
||||
];
|
||||
game.update(gu1);
|
||||
expect(game.ticksSinceStart()).toBe(0); // tick=10, start=10
|
||||
|
||||
game.update(makeEmptyGu(15));
|
||||
expect(game.ticksSinceStart()).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GameView — accessors used by FrameBuilder", () => {
|
||||
it("width() / height() forward to the underlying map", () => {
|
||||
const game = makeGameView({ width: 12, height: 8 });
|
||||
expect(game.width()).toBe(12);
|
||||
expect(game.height()).toBe(8);
|
||||
});
|
||||
|
||||
it("tileStateBuffer() returns a Uint16Array of width*height", () => {
|
||||
const game = makeGameView({ width: 5, height: 4 });
|
||||
const buf = game.tileStateBuffer();
|
||||
expect(buf).toBeInstanceOf(Uint16Array);
|
||||
expect(buf.length).toBe(20);
|
||||
});
|
||||
|
||||
it("tileStateBuffer() is a live reference — mutated by update()", () => {
|
||||
const game = makeGameView({ width: 4, height: 4 });
|
||||
const buf = game.tileStateBuffer();
|
||||
const gu = makeEmptyGu(1);
|
||||
// Pack an owner ID into the low 12 bits of state for tile 6.
|
||||
gu.packedTileUpdates = new Uint32Array([6, 0x123]);
|
||||
game.update(gu);
|
||||
expect(buf[6] & 0xfff).toBe(0x123);
|
||||
});
|
||||
|
||||
it("player(id) throws for unknown players (matches FrameBuilder's expectation)", () => {
|
||||
const game = makeGameView();
|
||||
expect(() => game.player("unknown")).toThrow();
|
||||
});
|
||||
|
||||
it("config() returns the same Config instance passed in", () => {
|
||||
const game = makeGameView();
|
||||
expect(game.config()).toBe(game.config());
|
||||
});
|
||||
});
|
||||
|
||||
describe("GameView.frameData() — renderer contract", () => {
|
||||
it("returns a stable object reference across ticks", () => {
|
||||
const game = makeGameView();
|
||||
game.update(makeEmptyGu(1));
|
||||
const f1 = game.frameData();
|
||||
game.update(makeEmptyGu(2));
|
||||
const f2 = game.frameData();
|
||||
expect(f2).toBe(f1);
|
||||
});
|
||||
|
||||
it("frame.tileState is === gameView.tileStateBuffer() (zero-copy)", () => {
|
||||
const game = makeGameView({ width: 4, height: 4 });
|
||||
game.update(makeEmptyGu(1));
|
||||
expect(game.frameData().tileState).toBe(game.tileStateBuffer());
|
||||
});
|
||||
|
||||
it("frame.changedTiles is null on the first populate (signals full upload)", () => {
|
||||
const game = makeGameView({ width: 4, height: 4 });
|
||||
const gu1 = makeEmptyGu(1);
|
||||
gu1.packedTileUpdates = new Uint32Array([1, 0, 2, 0]);
|
||||
game.update(gu1);
|
||||
expect(game.frameData().changedTiles).toBeNull();
|
||||
});
|
||||
|
||||
it("frame.changedTiles becomes a delta array on subsequent populates", () => {
|
||||
const game = makeGameView({ width: 4, height: 4 });
|
||||
game.update(makeEmptyGu(1));
|
||||
|
||||
const gu2 = makeEmptyGu(2);
|
||||
gu2.packedTileUpdates = new Uint32Array([3, 0, 5, 0, 9, 0]);
|
||||
game.update(gu2);
|
||||
const ct = game.frameData().changedTiles;
|
||||
expect(ct).not.toBeNull();
|
||||
expect(ct!.map((t) => t.ref).sort((a, b) => a - b)).toEqual([3, 5, 9]);
|
||||
});
|
||||
|
||||
it("changedTiles scratch array is reused across ticks (no per-tick alloc)", () => {
|
||||
const game = makeGameView({ width: 4, height: 4 });
|
||||
game.update(makeEmptyGu(1)); // first populate (changedTiles = null)
|
||||
const gu2 = makeEmptyGu(2);
|
||||
gu2.packedTileUpdates = new Uint32Array([1, 0]);
|
||||
game.update(gu2);
|
||||
const ct1 = game.frameData().changedTiles;
|
||||
|
||||
const gu3 = makeEmptyGu(3);
|
||||
gu3.packedTileUpdates = new Uint32Array([2, 0]);
|
||||
game.update(gu3);
|
||||
const ct2 = game.frameData().changedTiles;
|
||||
|
||||
expect(ct2).toBe(ct1); // same array instance
|
||||
});
|
||||
|
||||
it("frame.units is === gameView.unitStates() (same long-lived map)", () => {
|
||||
const game = makeGameView();
|
||||
game.update(makeEmptyGu(1));
|
||||
expect(game.frameData().units).toBe(game.unitStates());
|
||||
});
|
||||
|
||||
it("frame.players is === gameView.playerStates() (same long-lived map)", () => {
|
||||
const game = makeGameView();
|
||||
game.update(makeEmptyGu(1));
|
||||
expect(game.frameData().players).toBe(game.playerStates());
|
||||
});
|
||||
|
||||
it("frame.tick reflects the most recent gu.tick", () => {
|
||||
const game = makeGameView();
|
||||
game.update(makeEmptyGu(42));
|
||||
expect(game.frameData().tick).toBe(42);
|
||||
game.update(makeEmptyGu(43));
|
||||
expect(game.frameData().tick).toBe(43);
|
||||
});
|
||||
|
||||
it("frame.events.deadUnits is populated from inactive Unit updates", () => {
|
||||
const game = makeGameView();
|
||||
const gu = makeEmptyGu(1);
|
||||
gu.updates[GameUpdateType.Unit] = [
|
||||
makeUnitUpdate({ id: 1, isActive: true, pos: 10 }),
|
||||
makeUnitUpdate({ id: 2, isActive: false, pos: 20 }),
|
||||
makeUnitUpdate({ id: 3, isActive: false, pos: 30 }),
|
||||
];
|
||||
game.update(gu);
|
||||
const dead = game.frameData().events.deadUnits;
|
||||
expect(dead.length).toBe(2);
|
||||
expect(dead.map((d) => d.pos).sort((a, b) => a - b)).toEqual([20, 30]);
|
||||
});
|
||||
|
||||
it("frame.events arrays are cleared each tick (no event leakage)", () => {
|
||||
const game = makeGameView();
|
||||
const gu1 = makeEmptyGu(1);
|
||||
gu1.updates[GameUpdateType.Unit] = [
|
||||
makeUnitUpdate({ id: 1, isActive: false }),
|
||||
];
|
||||
game.update(gu1);
|
||||
expect(game.frameData().events.deadUnits.length).toBe(1);
|
||||
|
||||
// Empty next tick → events cleared
|
||||
game.update(makeEmptyGu(2));
|
||||
expect(game.frameData().events.deadUnits.length).toBe(0);
|
||||
});
|
||||
|
||||
it("frame.events.deadUnits array is reused (same reference)", () => {
|
||||
const game = makeGameView();
|
||||
game.update(makeEmptyGu(1));
|
||||
const a1 = game.frameData().events.deadUnits;
|
||||
game.update(makeEmptyGu(2));
|
||||
expect(game.frameData().events.deadUnits).toBe(a1);
|
||||
});
|
||||
|
||||
it("frame.tileMode is 'live'", () => {
|
||||
const game = makeGameView();
|
||||
expect(game.frameData().tileMode).toBe("live");
|
||||
});
|
||||
|
||||
it("frame.structuresDirty is true on first populate (force initial upload)", () => {
|
||||
const game = makeGameView();
|
||||
game.update(makeEmptyGu(1));
|
||||
expect(game.frameData().structuresDirty).toBe(true);
|
||||
});
|
||||
|
||||
it("frame.structuresDirty resets between ticks when no structure changes", () => {
|
||||
const game = makeGameView();
|
||||
game.update(makeEmptyGu(1));
|
||||
game.update(makeEmptyGu(2));
|
||||
expect(game.frameData().structuresDirty).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* PlayerView is a thin accessor wrapping a PlayerUpdate record plus precomputed
|
||||
* colors. Tests verify each accessor forwards the underlying data, that the
|
||||
* color variants (neutral/friendly/embargo) are precomputed at construction,
|
||||
* and that relation predicates (allied / same-team / friendly / embargo) match
|
||||
* what the FrameBuilder relies on when populating PlayerState.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PlayerView } from "../../../src/client/view/PlayerView";
|
||||
import { PlayerType } from "../../../src/core/game/Game";
|
||||
import { GameUpdateType } from "../../../src/core/game/GameUpdates";
|
||||
import {
|
||||
makeEmptyGu,
|
||||
makeGameView,
|
||||
makeNameViewData,
|
||||
makePlayerUpdate,
|
||||
makePlayerView,
|
||||
} from "../../util/viewStubs";
|
||||
|
||||
describe("PlayerView accessors", () => {
|
||||
it("forwards data fields", () => {
|
||||
const p = makePlayerView({
|
||||
data: {
|
||||
id: "player-a",
|
||||
smallID: 7,
|
||||
clientID: "client-a",
|
||||
name: "Alice",
|
||||
displayName: "Alice",
|
||||
playerType: PlayerType.Human,
|
||||
isAlive: true,
|
||||
isDisconnected: false,
|
||||
isLobbyCreator: true,
|
||||
tilesOwned: 42,
|
||||
gold: 999n,
|
||||
troops: 250,
|
||||
},
|
||||
});
|
||||
|
||||
expect(p.id()).toBe("player-a");
|
||||
expect(p.smallID()).toBe(7);
|
||||
expect(p.clientID()).toBe("client-a");
|
||||
expect(p.name()).toBe("Alice");
|
||||
expect(p.displayName()).toBe("Alice");
|
||||
expect(p.type()).toBe(PlayerType.Human);
|
||||
expect(p.isAlive()).toBe(true);
|
||||
expect(p.isDisconnected()).toBe(false);
|
||||
expect(p.isLobbyCreator()).toBe(true);
|
||||
expect(p.numTilesOwned()).toBe(42);
|
||||
expect(p.gold()).toBe(999n);
|
||||
expect(p.troops()).toBe(250);
|
||||
});
|
||||
|
||||
it("isPlayer() is always true", () => {
|
||||
expect(makePlayerView().isPlayer()).toBe(true);
|
||||
});
|
||||
|
||||
it("team() returns null when team is undefined on data", () => {
|
||||
expect(makePlayerView({ data: { team: undefined } }).team()).toBeNull();
|
||||
});
|
||||
|
||||
it("team() forwards a set team", () => {
|
||||
expect(makePlayerView({ data: { team: "red" } }).team()).toBe("red");
|
||||
});
|
||||
|
||||
it("isTraitor + getTraitorRemainingTicks forward, with min clamp at 0", () => {
|
||||
const traitor = makePlayerView({
|
||||
data: { isTraitor: true, traitorRemainingTicks: 5 },
|
||||
});
|
||||
expect(traitor.isTraitor()).toBe(true);
|
||||
expect(traitor.getTraitorRemainingTicks()).toBe(5);
|
||||
|
||||
// Negative or missing → clamped to 0
|
||||
const expired = makePlayerView({
|
||||
data: { isTraitor: false, traitorRemainingTicks: -3 },
|
||||
});
|
||||
expect(expired.getTraitorRemainingTicks()).toBe(0);
|
||||
|
||||
const missing = makePlayerView({ data: { isTraitor: false } });
|
||||
expect(missing.getTraitorRemainingTicks()).toBe(0);
|
||||
});
|
||||
|
||||
it("nameLocation() returns nameData passed at construction", () => {
|
||||
const nameData = makeNameViewData({ x: 12, y: 34, size: 20 });
|
||||
expect(makePlayerView({ nameData }).nameLocation()).toBe(nameData);
|
||||
});
|
||||
|
||||
it("outgoingEmojis / outgoingAttacks / incomingAttacks / alliances forward arrays", () => {
|
||||
const alliance = {
|
||||
id: 1,
|
||||
other: { id: "ally", smallID: 2 },
|
||||
createdAt: 0,
|
||||
expiresAt: 100,
|
||||
onlyOneAgreedToExtend: false,
|
||||
} as unknown as ReturnType<PlayerView["alliances"]>[number];
|
||||
const attack = {
|
||||
attackerID: 1,
|
||||
targetID: 0,
|
||||
troops: 50,
|
||||
id: "attack-a",
|
||||
retreating: false,
|
||||
} as unknown as ReturnType<PlayerView["outgoingAttacks"]>[number];
|
||||
const emoji = {
|
||||
message: 0,
|
||||
senderID: 1,
|
||||
recipientID: 2,
|
||||
createdAt: 0,
|
||||
} as unknown as ReturnType<PlayerView["outgoingEmojis"]>[number];
|
||||
|
||||
const p = makePlayerView({
|
||||
data: {
|
||||
alliances: [alliance],
|
||||
outgoingAttacks: [attack],
|
||||
incomingAttacks: [],
|
||||
outgoingEmojis: [emoji],
|
||||
},
|
||||
});
|
||||
|
||||
expect(p.alliances()).toEqual([alliance]);
|
||||
expect(p.outgoingAttacks()).toEqual([attack]);
|
||||
expect(p.incomingAttacks()).toEqual([]);
|
||||
expect(p.outgoingEmojis()).toEqual([emoji]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlayerView colors", () => {
|
||||
it("territoryColor() with no tile returns a Colord", () => {
|
||||
const c = makePlayerView().territoryColor();
|
||||
expect(typeof c.toHex()).toBe("string");
|
||||
});
|
||||
|
||||
it("structureColors() returns precomputed light/dark", () => {
|
||||
const colors = makePlayerView().structureColors();
|
||||
expect(colors).toHaveProperty("light");
|
||||
expect(colors).toHaveProperty("dark");
|
||||
});
|
||||
|
||||
it("borderColor() with no tile returns the base border color", () => {
|
||||
const p = makePlayerView();
|
||||
const noTile = p.borderColor();
|
||||
// Same value should come back for repeat calls (cached).
|
||||
expect(p.borderColor().toHex()).toBe(noTile.toHex());
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlayerView relations", () => {
|
||||
function pair(
|
||||
aSmall: number,
|
||||
bSmall: number,
|
||||
opts: {
|
||||
aAllies?: number[];
|
||||
aTeam?: string;
|
||||
bTeam?: string;
|
||||
// Embargoes are renderer-format: stringified smallIDs of the OTHER player.
|
||||
aEmbargoSmallIDs?: string[];
|
||||
bEmbargoSmallIDs?: string[];
|
||||
aOutgoingReq?: string[];
|
||||
} = {},
|
||||
) {
|
||||
const a = makePlayerView({
|
||||
data: {
|
||||
id: "a",
|
||||
smallID: aSmall,
|
||||
allies: opts.aAllies ?? [],
|
||||
team: opts.aTeam,
|
||||
outgoingAllianceRequests: opts.aOutgoingReq ?? [],
|
||||
},
|
||||
});
|
||||
const b = makePlayerView({
|
||||
data: {
|
||||
id: "b",
|
||||
smallID: bSmall,
|
||||
team: opts.bTeam,
|
||||
},
|
||||
});
|
||||
if (opts.aEmbargoSmallIDs) a.setEmbargoSmallIDs(opts.aEmbargoSmallIDs);
|
||||
if (opts.bEmbargoSmallIDs) b.setEmbargoSmallIDs(opts.bEmbargoSmallIDs);
|
||||
return { a, b };
|
||||
}
|
||||
|
||||
it("isAlliedWith() reflects ally smallIDs in data.allies", () => {
|
||||
const { a, b } = pair(1, 2, { aAllies: [2] });
|
||||
expect(a.isAlliedWith(b)).toBe(true);
|
||||
expect(b.isAlliedWith(a)).toBe(false); // b has no allies set
|
||||
});
|
||||
|
||||
it("isOnSameTeam() compares data.team and treats undefined as no team", () => {
|
||||
const same = pair(1, 2, { aTeam: "red", bTeam: "red" });
|
||||
const diff = pair(1, 2, { aTeam: "red", bTeam: "blue" });
|
||||
const noTeam = pair(1, 2);
|
||||
expect(same.a.isOnSameTeam(same.b)).toBe(true);
|
||||
expect(diff.a.isOnSameTeam(diff.b)).toBe(false);
|
||||
// Two players with no team set should NOT count as same team.
|
||||
expect(noTeam.a.isOnSameTeam(noTeam.b)).toBe(false);
|
||||
});
|
||||
|
||||
it("isFriendly() = allied OR same team", () => {
|
||||
const allied = pair(1, 2, { aAllies: [2] });
|
||||
expect(allied.a.isFriendly(allied.b)).toBe(true);
|
||||
|
||||
const teammates = pair(1, 2, { aTeam: "red", bTeam: "red" });
|
||||
expect(teammates.a.isFriendly(teammates.b)).toBe(true);
|
||||
|
||||
const strangers = pair(1, 2);
|
||||
expect(strangers.a.isFriendly(strangers.b)).toBe(false);
|
||||
});
|
||||
|
||||
it("hasEmbargoAgainst / hasEmbargo are symmetric on the second", () => {
|
||||
// a embargoes b — by smallID (renderer format)
|
||||
const aEmbargoesB = pair(1, 2, { aEmbargoSmallIDs: ["2"] });
|
||||
// One-way directional embargo from a
|
||||
expect(aEmbargoesB.a.hasEmbargoAgainst(aEmbargoesB.b)).toBe(true);
|
||||
expect(aEmbargoesB.b.hasEmbargoAgainst(aEmbargoesB.a)).toBe(false);
|
||||
// Symmetric version is true from either side
|
||||
expect(aEmbargoesB.a.hasEmbargo(aEmbargoesB.b)).toBe(true);
|
||||
expect(aEmbargoesB.b.hasEmbargo(aEmbargoesB.a)).toBe(true);
|
||||
});
|
||||
|
||||
it("isRequestingAllianceWith() reflects outgoingAllianceRequests", () => {
|
||||
const { a, b } = pair(1, 2, { aOutgoingReq: ["b"] });
|
||||
expect(a.isRequestingAllianceWith(b)).toBe(true);
|
||||
expect(b.isRequestingAllianceWith(a)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlayerView in a GameView context", () => {
|
||||
it("allies() resolves smallIDs through the game's smallID → PlayerView map", () => {
|
||||
// Build a GameView and feed it two players so allies() can resolve.
|
||||
const game = makeGameView();
|
||||
const aliceUpdate = makePlayerUpdate({
|
||||
id: "alice",
|
||||
smallID: 1,
|
||||
clientID: "c-alice",
|
||||
name: "Alice",
|
||||
allies: [2],
|
||||
});
|
||||
const bobUpdate = makePlayerUpdate({
|
||||
id: "bob",
|
||||
smallID: 2,
|
||||
clientID: "c-bob",
|
||||
name: "Bob",
|
||||
});
|
||||
|
||||
// Drive a tick through the GameView so it creates the PlayerViews and
|
||||
// registers smallID lookups — that's the path FrameBuilder & PlayerView use.
|
||||
const gu = makeEmptyGu(1);
|
||||
gu.updates[GameUpdateType.Player] = [aliceUpdate, bobUpdate];
|
||||
gu.playerNameViewData = {
|
||||
alice: makeNameViewData(),
|
||||
bob: makeNameViewData(),
|
||||
};
|
||||
game.update(gu);
|
||||
|
||||
const alice = game.player("alice");
|
||||
const bob = game.player("bob");
|
||||
expect(alice.allies()).toEqual([bob]);
|
||||
});
|
||||
|
||||
it("isMe() is true only for the player matching myClientID", () => {
|
||||
const game = makeGameView({ myClientID: "c-me" });
|
||||
const me = makePlayerUpdate({
|
||||
id: "me",
|
||||
smallID: 1,
|
||||
clientID: "c-me",
|
||||
name: "Me",
|
||||
});
|
||||
const other = makePlayerUpdate({
|
||||
id: "other",
|
||||
smallID: 2,
|
||||
clientID: "c-other",
|
||||
name: "Other",
|
||||
});
|
||||
|
||||
const gu = makeEmptyGu(1);
|
||||
gu.updates[GameUpdateType.Player] = [me, other];
|
||||
gu.playerNameViewData = {
|
||||
me: makeNameViewData(),
|
||||
other: makeNameViewData(),
|
||||
};
|
||||
game.update(gu);
|
||||
|
||||
expect(game.player("me").isMe()).toBe(true);
|
||||
expect(game.player("other").isMe()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* UnitView is mostly a thin accessor over a UnitUpdate record. Tests verify
|
||||
* each accessor returns the underlying data, that update() swaps the backing
|
||||
* record, that lastPos tracking works as the simulation advances units, and
|
||||
* that the trickier missile-readiness math is correct.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { UnitView } from "../../../src/client/view/UnitView";
|
||||
import {
|
||||
TrainType,
|
||||
TransportShipState,
|
||||
UnitType,
|
||||
WarshipState,
|
||||
} from "../../../src/core/game/Game";
|
||||
import { makeGameView, makeUnitUpdate, stubConfig } from "../../util/viewStubs";
|
||||
|
||||
describe("UnitView accessors", () => {
|
||||
it("forwards data fields", () => {
|
||||
const game = makeGameView();
|
||||
const u = new UnitView(
|
||||
game,
|
||||
makeUnitUpdate({
|
||||
id: 42,
|
||||
unitType: UnitType.City,
|
||||
ownerID: 7,
|
||||
pos: 100,
|
||||
lastPos: 99,
|
||||
troops: 250,
|
||||
level: 3,
|
||||
hasTrainStation: true,
|
||||
targetable: false,
|
||||
markedForDeletion: false,
|
||||
isActive: true,
|
||||
reachedTarget: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(u.id()).toBe(42);
|
||||
expect(u.type()).toBe(UnitType.City);
|
||||
expect(u.troops()).toBe(250);
|
||||
expect(u.level()).toBe(3);
|
||||
expect(u.hasTrainStation()).toBe(true);
|
||||
expect(u.targetable()).toBe(false);
|
||||
expect(u.markedForDeletion()).toBe(false);
|
||||
expect(u.isActive()).toBe(true);
|
||||
expect(u.reachedTarget()).toBe(false);
|
||||
expect(u.tile()).toBe(100);
|
||||
});
|
||||
|
||||
it("tracks createdAt from the GameView's tick at construction", () => {
|
||||
const game = makeGameView();
|
||||
const u = new UnitView(game, makeUnitUpdate());
|
||||
expect(u.createdAt()).toBe(0); // GameView.ticks() returns 0 before any update
|
||||
});
|
||||
|
||||
it("returns the latest data after update()", () => {
|
||||
const game = makeGameView();
|
||||
const u = new UnitView(game, makeUnitUpdate({ troops: 100, pos: 1 }));
|
||||
u.update(makeUnitUpdate({ troops: 250, pos: 5 }));
|
||||
expect(u.troops()).toBe(250);
|
||||
expect(u.tile()).toBe(5);
|
||||
});
|
||||
|
||||
it("update() pushes new pos into lastPos", () => {
|
||||
const game = makeGameView();
|
||||
const u = new UnitView(game, makeUnitUpdate({ pos: 1 }));
|
||||
expect(u.lastTile()).toBe(1);
|
||||
u.update(makeUnitUpdate({ pos: 2 }));
|
||||
expect(u.lastTiles()).toEqual([1, 2]);
|
||||
u.update(makeUnitUpdate({ pos: 3 }));
|
||||
expect(u.lastTiles()).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("lastTile() returns the first remembered pos", () => {
|
||||
const game = makeGameView();
|
||||
const u = new UnitView(game, makeUnitUpdate({ pos: 1 }));
|
||||
u.update(makeUnitUpdate({ pos: 2 }));
|
||||
u.update(makeUnitUpdate({ pos: 3 }));
|
||||
expect(u.lastTile()).toBe(1);
|
||||
});
|
||||
|
||||
it("applyDerivedPosition pushes a new pos and shifts lastPos in data", () => {
|
||||
const game = makeGameView();
|
||||
const u = new UnitView(game, makeUnitUpdate({ pos: 10, lastPos: 9 }));
|
||||
u.applyDerivedPosition(11);
|
||||
expect(u.tile()).toBe(11);
|
||||
expect(u.lastTiles()).toEqual([10, 11]);
|
||||
});
|
||||
|
||||
it("hasHealth() reflects whether health is set", () => {
|
||||
const game = makeGameView();
|
||||
expect(new UnitView(game, makeUnitUpdate({ health: 50 })).hasHealth()).toBe(
|
||||
true,
|
||||
);
|
||||
expect(new UnitView(game, makeUnitUpdate()).hasHealth()).toBe(false);
|
||||
});
|
||||
|
||||
it("health() returns 0 when unset", () => {
|
||||
const game = makeGameView();
|
||||
expect(new UnitView(game, makeUnitUpdate()).health()).toBe(0);
|
||||
expect(new UnitView(game, makeUnitUpdate({ health: 42 })).health()).toBe(
|
||||
42,
|
||||
);
|
||||
});
|
||||
|
||||
it("isUnderConstruction reflects the explicit boolean", () => {
|
||||
const game = makeGameView();
|
||||
expect(
|
||||
new UnitView(
|
||||
game,
|
||||
makeUnitUpdate({ underConstruction: true }),
|
||||
).isUnderConstruction(),
|
||||
).toBe(true);
|
||||
expect(
|
||||
new UnitView(
|
||||
game,
|
||||
makeUnitUpdate({ underConstruction: false }),
|
||||
).isUnderConstruction(),
|
||||
).toBe(false);
|
||||
// Undefined is treated as false (not under construction).
|
||||
expect(new UnitView(game, makeUnitUpdate()).isUnderConstruction()).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("trainType() / isLoaded() forward optional train fields", () => {
|
||||
const game = makeGameView();
|
||||
const u = new UnitView(
|
||||
game,
|
||||
makeUnitUpdate({ trainType: TrainType.Engine, loaded: true }),
|
||||
);
|
||||
expect(u.trainType()).toBe(TrainType.Engine);
|
||||
expect(u.isLoaded()).toBe(true);
|
||||
});
|
||||
|
||||
it("transportShipState() returns a default when missing", () => {
|
||||
const game = makeGameView();
|
||||
const u = new UnitView(game, makeUnitUpdate());
|
||||
expect(u.transportShipState()).toEqual({ isRetreating: false, troops: 0 });
|
||||
});
|
||||
|
||||
it("transportShipState() forwards when set", () => {
|
||||
const game = makeGameView();
|
||||
const state: TransportShipState = { isRetreating: true, troops: 50 };
|
||||
const u = new UnitView(game, makeUnitUpdate({ transportShipState: state }));
|
||||
expect(u.transportShipState()).toBe(state);
|
||||
});
|
||||
|
||||
it("warshipState() throws when not a warship state", () => {
|
||||
const game = makeGameView();
|
||||
const u = new UnitView(game, makeUnitUpdate());
|
||||
expect(() => u.warshipState()).toThrow();
|
||||
});
|
||||
|
||||
it("warshipState() forwards when present", () => {
|
||||
const game = makeGameView();
|
||||
const state: WarshipState = {
|
||||
isInCombat: false,
|
||||
patrolTile: 0,
|
||||
lastAttackTile: 0,
|
||||
bossUnitId: null,
|
||||
} as unknown as WarshipState;
|
||||
const u = new UnitView(game, makeUnitUpdate({ warshipState: state }));
|
||||
expect(u.warshipState()).toBe(state);
|
||||
});
|
||||
|
||||
it("isInCombat() reflects warshipState.isInCombat (or false if missing)", () => {
|
||||
const game = makeGameView();
|
||||
expect(new UnitView(game, makeUnitUpdate()).isInCombat()).toBe(false);
|
||||
const combat = new UnitView(
|
||||
game,
|
||||
makeUnitUpdate({
|
||||
warshipState: { isInCombat: true } as unknown as WarshipState,
|
||||
}),
|
||||
);
|
||||
expect(combat.isInCombat()).toBe(true);
|
||||
});
|
||||
|
||||
it("targetUnitId / targetTile pass through", () => {
|
||||
const game = makeGameView();
|
||||
const u = new UnitView(
|
||||
game,
|
||||
makeUnitUpdate({ targetUnitId: 99, targetTile: 12 }),
|
||||
);
|
||||
expect(u.targetUnitId()).toBe(99);
|
||||
expect(u.targetTile()).toBe(12);
|
||||
});
|
||||
|
||||
it("missileTimerQueue() forwards the array", () => {
|
||||
const game = makeGameView();
|
||||
const u = new UnitView(
|
||||
game,
|
||||
makeUnitUpdate({ missileTimerQueue: [10, 20, 30] }),
|
||||
);
|
||||
expect(u.missileTimerQueue()).toEqual([10, 20, 30]);
|
||||
});
|
||||
|
||||
it("touch / updateWarshipState / updateTransportShipState throw on view", () => {
|
||||
const game = makeGameView();
|
||||
const u = new UnitView(game, makeUnitUpdate());
|
||||
expect(() => u.touch()).toThrow();
|
||||
expect(() => u.updateWarshipState({})).toThrow();
|
||||
expect(() => u.updateTransportShipState({ isRetreating: false })).toThrow();
|
||||
});
|
||||
|
||||
describe("missileReadinesss", () => {
|
||||
it("returns 1 when nothing is reloading", () => {
|
||||
const game = makeGameView();
|
||||
const u = new UnitView(
|
||||
game,
|
||||
makeUnitUpdate({ level: 3, missileTimerQueue: [] }),
|
||||
);
|
||||
expect(u.missileReadinesss()).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 0 when all missiles are reloading and level > 1", () => {
|
||||
const game = makeGameView({ config: stubConfig() });
|
||||
const u = new UnitView(
|
||||
game,
|
||||
makeUnitUpdate({
|
||||
unitType: UnitType.SAMLauncher,
|
||||
level: 2,
|
||||
missileTimerQueue: [0, 0], // both reloading, started at tick 0
|
||||
}),
|
||||
);
|
||||
// Just-launched: progress is 0, readiness 0/2.
|
||||
expect(u.missileReadinesss()).toBe(0);
|
||||
});
|
||||
|
||||
it("returns partial readiness when missiles are partway through cooldown", () => {
|
||||
// SAMCooldown = 120 in stub. Half-way at tick 60. Level 2 with both reloading
|
||||
// means readiness = 0/2 from ready missiles + 2 * (60/120) / 2 = 0.5.
|
||||
// But game.ticks() returns 0 with no update. So progress = 0 - 0 = 0 → 0.
|
||||
// Use a game with a tick number injected.
|
||||
const config = stubConfig({
|
||||
SAMCooldown: () => 120,
|
||||
SiloCooldown: () => 75,
|
||||
} as unknown as Partial<
|
||||
typeof stubConfig extends () => infer C ? C : never
|
||||
>);
|
||||
const game = makeGameView({ config });
|
||||
const u = new UnitView(
|
||||
game,
|
||||
makeUnitUpdate({
|
||||
unitType: UnitType.SAMLauncher,
|
||||
level: 2,
|
||||
missileTimerQueue: [0, 0],
|
||||
}),
|
||||
);
|
||||
// Without advancing game ticks, readiness = (2-2)/2 + 2*((0-0)/120)/2 = 0.
|
||||
// We can't easily advance ticks without going through update(); just assert <=1.
|
||||
const r = u.missileReadinesss();
|
||||
expect(r).toBeGreaterThanOrEqual(0);
|
||||
expect(r).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { GameMapImpl } from "../../../src/core/game/GameMap";
|
||||
|
||||
describe("GameMap.tileStateBuffer", () => {
|
||||
it("returns a Uint16Array sized to width * height", () => {
|
||||
const map = new GameMapImpl(10, 8, new Uint8Array(10 * 8), 0);
|
||||
const buf = map.tileStateBuffer();
|
||||
expect(buf).toBeInstanceOf(Uint16Array);
|
||||
expect(buf.length).toBe(80);
|
||||
});
|
||||
|
||||
it("returns a live reference — updateTile() mutates the same buffer", () => {
|
||||
const map = new GameMapImpl(4, 4, new Uint8Array(16), 0);
|
||||
const buf = map.tileStateBuffer();
|
||||
// Writes go through updateTile (packed uint32: high 16 bits = terrain byte, low 16 = state).
|
||||
map.updateTile(5, 0x00abcd);
|
||||
expect(buf[5]).toBe(0xabcd);
|
||||
});
|
||||
|
||||
it("returns the same array on every call (zero-copy)", () => {
|
||||
const map = new GameMapImpl(4, 4, new Uint8Array(16), 0);
|
||||
expect(map.tileStateBuffer()).toBe(map.tileStateBuffer());
|
||||
});
|
||||
|
||||
it("reflects ownerID writes in the low 12 bits of each cell", () => {
|
||||
const map = new GameMapImpl(4, 4, new Uint8Array(16), 0);
|
||||
map.setOwnerID(7, 0x123);
|
||||
expect(map.tileStateBuffer()[7] & 0xfff).toBe(0x123);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Stub builders for GameView/PlayerView/UnitView unit tests.
|
||||
*
|
||||
* These tests don't go through the full game setup (which creates a worker
|
||||
* and runs the simulation) — they exercise the view classes directly with
|
||||
* minimal stubs for their dependencies.
|
||||
*/
|
||||
|
||||
import { colord } from "colord";
|
||||
import { GameView } from "../../src/client/view/GameView";
|
||||
import { PlayerView } from "../../src/client/view/PlayerView";
|
||||
import { Config } from "../../src/core/configuration/Config";
|
||||
import { Theme } from "../../src/core/configuration/Theme";
|
||||
import {
|
||||
NameViewData,
|
||||
PlayerType,
|
||||
Team,
|
||||
UnitType,
|
||||
} from "../../src/core/game/Game";
|
||||
import { GameMapImpl } from "../../src/core/game/GameMap";
|
||||
import {
|
||||
GameUpdateType,
|
||||
GameUpdateViewData,
|
||||
PlayerUpdate,
|
||||
UnitUpdate,
|
||||
} from "../../src/core/game/GameUpdates";
|
||||
import { TerrainMapData } from "../../src/core/game/TerrainMapLoader";
|
||||
import { Player, PlayerCosmetics } from "../../src/core/Schemas";
|
||||
import { WorkerClient } from "../../src/core/worker/WorkerClient";
|
||||
|
||||
/** Theme stub — returns deterministic colors so PlayerView's color math works. */
|
||||
export function stubTheme(): Theme {
|
||||
const white = colord("#ffffff");
|
||||
const grey = colord("#808080");
|
||||
const defended = { light: white, dark: grey };
|
||||
return {
|
||||
teamColor: () => white,
|
||||
territoryColor: () => white,
|
||||
structureColors: () => defended,
|
||||
borderColor: () => grey,
|
||||
defendedBorderColors: () => defended,
|
||||
focusedBorderColor: () => grey,
|
||||
terrainColor: () => white,
|
||||
backgroundColor: () => white,
|
||||
falloutColor: () => white,
|
||||
font: () => "Arial",
|
||||
textColor: () => "#000000",
|
||||
selfColor: () => white,
|
||||
allyColor: () => white,
|
||||
neutralColor: () => grey,
|
||||
enemyColor: () => grey,
|
||||
spawnHighlightColor: () => white,
|
||||
spawnHighlightSelfColor: () => white,
|
||||
spawnHighlightTeamColor: () => white,
|
||||
spawnHighlightEnemyColor: () => white,
|
||||
};
|
||||
}
|
||||
|
||||
/** Minimum Config stub for view tests. Extend as test needs grow. */
|
||||
export function stubConfig(overrides: Partial<Config> = {}): Config {
|
||||
const theme = stubTheme();
|
||||
const cfg = {
|
||||
theme: () => theme,
|
||||
SAMCooldown: () => 120,
|
||||
SiloCooldown: () => 75,
|
||||
deleteUnitCooldown: () => 0,
|
||||
spawnImmunityDuration: () => 0,
|
||||
nationSpawnImmunityDuration: () => 0,
|
||||
unitInfo: () => ({ maxHealth: 100, constructionDuration: 20 }),
|
||||
disableAlliances: () => false,
|
||||
allianceDuration: () => 100,
|
||||
deletionMarkDuration: () => 300,
|
||||
nukeMagnitudes: () => ({ inner: 0, outer: 0 }),
|
||||
nukeAllianceBreakThreshold: () => 0,
|
||||
userSettings: () => ({}),
|
||||
...overrides,
|
||||
} as unknown as Config;
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/** WorkerClient stub. View classes only call worker.* in async methods we don't exercise. */
|
||||
export function stubWorker(): WorkerClient {
|
||||
return {} as unknown as WorkerClient;
|
||||
}
|
||||
|
||||
/** Build TerrainMapData wrapping a fresh GameMapImpl of the given size. */
|
||||
export function stubTerrainMap(width = 10, height = 10): TerrainMapData {
|
||||
const terrain = new Uint8Array(width * height);
|
||||
const gameMap = new GameMapImpl(width, height, terrain, 0);
|
||||
return {
|
||||
nations: [],
|
||||
additionalNations: [],
|
||||
gameMap,
|
||||
miniGameMap: gameMap,
|
||||
} as unknown as TerrainMapData;
|
||||
}
|
||||
|
||||
export interface GameViewStubOptions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
myClientID?: string;
|
||||
myUsername?: string;
|
||||
myClanTag?: string | null;
|
||||
humans?: Player[];
|
||||
config?: Config;
|
||||
}
|
||||
|
||||
/** Construct a GameView with minimal dependencies. */
|
||||
export function makeGameView(opts: GameViewStubOptions = {}): GameView {
|
||||
return new GameView(
|
||||
stubWorker(),
|
||||
opts.config ?? stubConfig(),
|
||||
stubTerrainMap(opts.width ?? 10, opts.height ?? 10),
|
||||
opts.myClientID,
|
||||
opts.myUsername ?? "tester",
|
||||
opts.myClanTag ?? null,
|
||||
"test-game",
|
||||
opts.humans ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Synthetic update builders ──
|
||||
|
||||
export function makePlayerUpdate(
|
||||
overrides: Partial<PlayerUpdate> = {},
|
||||
): PlayerUpdate {
|
||||
return {
|
||||
type: GameUpdateType.Player,
|
||||
clientID: "client-a",
|
||||
name: "Alice",
|
||||
displayName: "Alice",
|
||||
id: "player-a",
|
||||
smallID: 1,
|
||||
playerType: PlayerType.Human,
|
||||
isAlive: true,
|
||||
isDisconnected: false,
|
||||
tilesOwned: 0,
|
||||
gold: 0n,
|
||||
troops: 100,
|
||||
allies: [],
|
||||
embargoes: new Set(),
|
||||
isTraitor: false,
|
||||
targets: [],
|
||||
outgoingEmojis: [],
|
||||
outgoingAttacks: [],
|
||||
incomingAttacks: [],
|
||||
outgoingAllianceRequests: [],
|
||||
alliances: [],
|
||||
hasSpawned: true,
|
||||
betrayals: 0,
|
||||
lastDeleteUnitTick: 0,
|
||||
isLobbyCreator: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeUnitUpdate(
|
||||
overrides: Partial<UnitUpdate> = {},
|
||||
): UnitUpdate {
|
||||
return {
|
||||
type: GameUpdateType.Unit,
|
||||
unitType: UnitType.Warship,
|
||||
troops: 0,
|
||||
id: 1,
|
||||
ownerID: 1,
|
||||
pos: 0,
|
||||
lastPos: 0,
|
||||
isActive: true,
|
||||
reachedTarget: false,
|
||||
targetable: true,
|
||||
markedForDeletion: false,
|
||||
missileTimerQueue: [],
|
||||
level: 1,
|
||||
hasTrainStation: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeNameViewData(
|
||||
overrides: Partial<NameViewData> = {},
|
||||
): NameViewData {
|
||||
return { x: 0, y: 0, size: 12, ...overrides };
|
||||
}
|
||||
|
||||
export interface PlayerViewStubOptions {
|
||||
game?: GameView;
|
||||
data?: Partial<PlayerUpdate>;
|
||||
nameData?: NameViewData;
|
||||
cosmetics?: PlayerCosmetics;
|
||||
}
|
||||
|
||||
/** Construct a PlayerView with minimal dependencies. */
|
||||
export function makePlayerView(opts: PlayerViewStubOptions = {}): PlayerView {
|
||||
return new PlayerView(
|
||||
opts.game ?? makeGameView(),
|
||||
makePlayerUpdate(opts.data),
|
||||
opts.nameData ?? makeNameViewData(),
|
||||
opts.cosmetics ?? {},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a GameUpdateViewData with no updates and an empty packed tile delta.
|
||||
* Caller can fill in updates[GameUpdateType.X] arrays as needed.
|
||||
*/
|
||||
export function makeEmptyGu(
|
||||
tick: number,
|
||||
overrides: Partial<GameUpdateViewData> = {},
|
||||
): GameUpdateViewData {
|
||||
const updates = Object.fromEntries(
|
||||
Object.values(GameUpdateType)
|
||||
.filter((v): v is number => typeof v === "number")
|
||||
.map((k) => [k, []]),
|
||||
) as unknown as GameUpdateViewData["updates"];
|
||||
return {
|
||||
tick,
|
||||
updates,
|
||||
packedTileUpdates: new Uint32Array(0),
|
||||
playerNameViewData: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export { Team };
|
||||
Reference in New Issue
Block a user