mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 10:43:12 +00:00
Merge webgl2 — full WebGL2 renderer migration
relates to #893 Replaces the canvas2D + Pixi.js map renderer with a pure WebGL2 pipeline. Map-space visuals (terrain, names, structures, units, FX, selection boxes, build ghosts, status icons, nuke trajectories, defense zones, spawn glow, water-nuke terrain deltas) all render through dedicated passes in src/client/render/gl/passes/. Controllers in src/client/controllers/ push state directly to the WebGL view; no relay events. Assets unified under resources/ + assetUrl(). Mode toggle wired to the existing darkMode UserSetting (no more day/night cycle). One input system (InputHandler + EventBus + TransformHandler). Known regressions to address in follow-up work: - [ ] webgl: highlight structures when hover on build menu - [ ] webgl: custom flags, flag atlas - [ ] webgl: territory patterns - [ ] webgl: defense post outline - [ ] webgl: territory expanse smoothing
This commit is contained in:
@@ -6,7 +6,7 @@ import {
|
||||
WarshipSelectionBoxCompleteEvent,
|
||||
WarshipSelectionBoxUpdateEvent,
|
||||
} from "../src/client/InputHandler";
|
||||
import { UIState } from "../src/client/graphics/UIState";
|
||||
import { UIState } from "../src/client/UIState";
|
||||
import { EventBus } from "../src/core/EventBus";
|
||||
import { UnitType } from "../src/core/game/Game";
|
||||
import { GameView, PlayerView } from "../src/core/game/GameView";
|
||||
@@ -928,13 +928,10 @@ describe("Warship box selection (Shift+drag)", () => {
|
||||
|
||||
test("Shift keydown discards active ghostStructure", () => {
|
||||
uiState.ghostStructure = UnitType.Warship;
|
||||
const emitSpy = vi.spyOn(eventBus, "emit");
|
||||
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { code: "ShiftLeft" }));
|
||||
|
||||
expect(uiState.ghostStructure).toBeNull();
|
||||
const types = emitSpy.mock.calls.map((c) => c[0].constructor.name);
|
||||
expect(types).toContain("GhostStructureChangedEvent");
|
||||
});
|
||||
|
||||
test("Shift+drag emits WarshipSelectionBoxUpdateEvent", () => {
|
||||
|
||||
+3
-9
@@ -1,14 +1,8 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { shouldPreserveGhostAfterBuild } from "../../../../src/client/graphics/layers/StructureIconsLayer";
|
||||
import { UnitType } from "../../../../src/core/game/Game";
|
||||
import { shouldPreserveGhostAfterBuild } from "../../../src/client/controllers/BuildPreviewController";
|
||||
import { UnitType } from "../../../src/core/game/Game";
|
||||
|
||||
/**
|
||||
* Tests for StructureIconsLayer edge cases mentioned in comments:
|
||||
* - Locked nuke / AtomBomb / HydrogenBomb: when confirming placement (Enter or key),
|
||||
* the ghost is preserved so the user can place multiple nukes or keep the nuke
|
||||
* selected. Other structure types clear the ghost after placement.
|
||||
*/
|
||||
describe("StructureIconsLayer ghost preservation (locked nuke / Enter confirm)", () => {
|
||||
describe("BuildPreviewController ghost preservation (locked nuke / Enter confirm)", () => {
|
||||
describe("shouldPreserveGhostAfterBuild", () => {
|
||||
test("returns true for AtomBomb so ghost is not cleared after placement", () => {
|
||||
expect(shouldPreserveGhostAfterBuild(UnitType.AtomBomb)).toBe(true);
|
||||
@@ -0,0 +1,96 @@
|
||||
import { WarshipSelectionController } from "../../../src/client/controllers/WarshipSelectionController";
|
||||
import { UnitSelectionEvent } from "../../../src/client/InputHandler";
|
||||
|
||||
describe("WarshipSelectionController", () => {
|
||||
let game: any;
|
||||
let eventBus: any;
|
||||
let transformHandler: any;
|
||||
let view: any;
|
||||
|
||||
beforeEach(() => {
|
||||
game = {
|
||||
width: () => 100,
|
||||
height: () => 100,
|
||||
config: () => ({
|
||||
theme: () => ({
|
||||
territoryColor: () => ({
|
||||
lighten: () => ({ alpha: () => ({ toRgbString: () => "#fff" }) }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
x: () => 10,
|
||||
y: () => 10,
|
||||
unitInfo: () => ({ maxHealth: 10, constructionDuration: 5 }),
|
||||
myPlayer: () => ({ id: () => 1 }),
|
||||
ticks: () => 1,
|
||||
updatesSinceLastTick: () => undefined,
|
||||
};
|
||||
eventBus = { on: vi.fn() };
|
||||
transformHandler = {};
|
||||
view = { setSelectedUnits: vi.fn() };
|
||||
});
|
||||
|
||||
it("tracks the selected unit on single-unit selection (rendering is WebGL)", () => {
|
||||
const ui = new WarshipSelectionController(
|
||||
game,
|
||||
eventBus,
|
||||
transformHandler,
|
||||
view,
|
||||
);
|
||||
const unit = {
|
||||
id: () => 1,
|
||||
type: () => "Warship",
|
||||
isActive: () => true,
|
||||
tile: () => ({}),
|
||||
owner: () => ({}),
|
||||
};
|
||||
const event = { isSelected: true, unit };
|
||||
ui["onUnitSelection"](event as UnitSelectionEvent);
|
||||
// selectedUnit is held for game-logic callers (the click handlers). The
|
||||
// visual selection box is drawn by WebGL SelectionBoxPass — wired from
|
||||
// ClientGameRunner via view.setSelectedUnits([unit.id()]).
|
||||
expect(ui["selectedUnit"]).toBe(unit);
|
||||
});
|
||||
|
||||
it("clears selection on deselect", () => {
|
||||
const ui = new WarshipSelectionController(
|
||||
game,
|
||||
eventBus,
|
||||
transformHandler,
|
||||
view,
|
||||
);
|
||||
const unit = {
|
||||
id: () => 1,
|
||||
type: () => "Warship",
|
||||
isActive: () => true,
|
||||
tile: () => ({}),
|
||||
owner: () => ({}),
|
||||
};
|
||||
ui["onUnitSelection"]({ isSelected: true, unit } as UnitSelectionEvent);
|
||||
ui["onUnitSelection"]({
|
||||
isSelected: false,
|
||||
unit: null,
|
||||
} as unknown as UnitSelectionEvent);
|
||||
expect(ui["selectedUnit"]).toBeNull();
|
||||
});
|
||||
|
||||
it("tracks multi-selection list", () => {
|
||||
const ui = new WarshipSelectionController(
|
||||
game,
|
||||
eventBus,
|
||||
transformHandler,
|
||||
view,
|
||||
);
|
||||
const units = [
|
||||
{ id: () => 1, isActive: () => true },
|
||||
{ id: () => 2, isActive: () => true },
|
||||
];
|
||||
ui["onUnitSelection"]({
|
||||
isSelected: true,
|
||||
unit: null,
|
||||
units,
|
||||
} as unknown as UnitSelectionEvent);
|
||||
expect(ui["multiSelectedWarships"]).toEqual(units);
|
||||
expect(ui["selectedUnit"]).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import { ProgressBar } from "../../../src/client/graphics/ProgressBar";
|
||||
|
||||
describe("ProgressBar", () => {
|
||||
let ctx: CanvasRenderingContext2D;
|
||||
let canvas: HTMLCanvasElement;
|
||||
|
||||
beforeEach(() => {
|
||||
canvas = document.createElement("canvas");
|
||||
canvas.width = 100;
|
||||
canvas.height = 20;
|
||||
ctx = canvas.getContext("2d")!;
|
||||
});
|
||||
|
||||
it("should initialize and draw the background", () => {
|
||||
const spyClearRect = vi.spyOn(ctx, "clearRect");
|
||||
const spyFillRect = vi.spyOn(ctx, "fillRect");
|
||||
const spyFillStyle = vi.spyOn(ctx, "fillStyle", "set");
|
||||
const bar = new ProgressBar(["#ff0000", "#00ff00"], ctx, 2, 2, 80, 10, 0.5);
|
||||
expect(spyClearRect).toHaveBeenCalledWith(0, 0, 82, 12);
|
||||
expect(spyFillRect).toHaveBeenCalledWith(1, 1, 80, 10);
|
||||
expect(spyFillStyle).toHaveBeenCalledWith("#00ff00");
|
||||
expect(bar.getX()).toBe(2);
|
||||
expect(bar.getY()).toBe(2);
|
||||
});
|
||||
|
||||
it("should set progress and draw the progress bar", () => {
|
||||
const bar = new ProgressBar(["#ff0000", "#00ff00"], ctx, 2, 2, 80, 10);
|
||||
const spyFillRect = vi.spyOn(ctx, "fillRect");
|
||||
bar.setProgress(0.5);
|
||||
expect(bar.getProgress()).toBe(0.5);
|
||||
expect(spyFillRect).toHaveBeenCalledWith(
|
||||
2,
|
||||
2,
|
||||
Math.floor(0.5 * (80 - 2)),
|
||||
8,
|
||||
);
|
||||
expect(ctx.fillStyle).toBe("#00ff00");
|
||||
|
||||
bar.setProgress(0.1);
|
||||
expect(ctx.fillStyle).toBe("#ff0000");
|
||||
});
|
||||
|
||||
it("should clamp progress between 0 and 1 on init", () => {
|
||||
const bar = new ProgressBar(["#ff0000", "#00ff00"], ctx, 2, 2, 80, 10, -1);
|
||||
expect(bar.getProgress()).toBe(0);
|
||||
const bar2 = new ProgressBar(["#ff0000", "#00ff00"], ctx, 2, 2, 80, 10, 2);
|
||||
expect(bar2.getProgress()).toBe(1);
|
||||
});
|
||||
|
||||
it("should handle empty colors array gracefully", () => {
|
||||
const bar = new ProgressBar([], ctx, 2, 2, 80, 10, 0.5);
|
||||
expect(() => bar.setProgress(0.5)).not.toThrow();
|
||||
expect(ctx.fillStyle).toBe("#808080");
|
||||
});
|
||||
});
|
||||
@@ -1,158 +0,0 @@
|
||||
import { UILayer } from "../../../src/client/graphics/layers/UILayer";
|
||||
import { UnitSelectionEvent } from "../../../src/client/InputHandler";
|
||||
import { UnitView } from "../../../src/core/game/GameView";
|
||||
|
||||
describe("UILayer", () => {
|
||||
let game: any;
|
||||
let eventBus: any;
|
||||
let transformHandler: any;
|
||||
|
||||
beforeEach(() => {
|
||||
game = {
|
||||
width: () => 100,
|
||||
height: () => 100,
|
||||
config: () => ({
|
||||
theme: () => ({
|
||||
territoryColor: () => ({
|
||||
lighten: () => ({ alpha: () => ({ toRgbString: () => "#fff" }) }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
x: () => 10,
|
||||
y: () => 10,
|
||||
unitInfo: () => ({ maxHealth: 10, constructionDuration: 5 }),
|
||||
myPlayer: () => ({ id: () => 1 }),
|
||||
ticks: () => 1,
|
||||
updatesSinceLastTick: () => undefined,
|
||||
};
|
||||
eventBus = { on: vi.fn() };
|
||||
transformHandler = {};
|
||||
});
|
||||
|
||||
it("should initialize and redraw canvas", () => {
|
||||
const ui = new UILayer(game, eventBus, transformHandler);
|
||||
ui.redraw();
|
||||
expect(ui["canvas"].width).toBe(100);
|
||||
expect(ui["canvas"].height).toBe(100);
|
||||
expect(ui["context"]).not.toBeNull();
|
||||
});
|
||||
|
||||
it("should handle unit selection event", () => {
|
||||
const ui = new UILayer(game, eventBus, transformHandler);
|
||||
ui.redraw();
|
||||
const unit = {
|
||||
type: () => "Warship",
|
||||
isActive: () => true,
|
||||
tile: () => ({}),
|
||||
owner: () => ({}),
|
||||
};
|
||||
const event = { isSelected: true, unit };
|
||||
ui.drawSelectionBox = vi.fn();
|
||||
ui["onUnitSelection"](event as UnitSelectionEvent);
|
||||
expect(ui.drawSelectionBox).toHaveBeenCalledWith(unit);
|
||||
});
|
||||
|
||||
it("should add and clear health bars", () => {
|
||||
const ui = new UILayer(game, eventBus, transformHandler);
|
||||
ui.redraw();
|
||||
const unit = {
|
||||
id: () => 1,
|
||||
type: () => "Warship",
|
||||
health: () => 5,
|
||||
tile: () => ({}),
|
||||
owner: () => ({}),
|
||||
isActive: () => true,
|
||||
createdAt: () => 1,
|
||||
} as unknown as UnitView;
|
||||
ui.drawHealthBar(unit);
|
||||
expect(ui["allHealthBars"].has(1)).toBe(true);
|
||||
|
||||
// a full hp unit doesn't have a health bar
|
||||
unit.health = () => 10;
|
||||
ui.drawHealthBar(unit);
|
||||
expect(ui["allHealthBars"].has(1)).toBe(false);
|
||||
|
||||
// a dead unit doesn't have a health bar
|
||||
unit.health = () => 5;
|
||||
ui.drawHealthBar(unit);
|
||||
expect(ui["allHealthBars"].has(1)).toBe(true);
|
||||
unit.health = () => 0;
|
||||
ui.drawHealthBar(unit);
|
||||
expect(ui["allHealthBars"].has(1)).toBe(false);
|
||||
});
|
||||
|
||||
it("should remove health bars for inactive units", () => {
|
||||
const ui = new UILayer(game, eventBus, transformHandler);
|
||||
ui.redraw();
|
||||
const unit = {
|
||||
id: () => 1,
|
||||
type: () => "Warship",
|
||||
health: () => 5,
|
||||
tile: () => ({}),
|
||||
owner: () => ({}),
|
||||
isActive: () => true,
|
||||
} as unknown as UnitView;
|
||||
ui.drawHealthBar(unit);
|
||||
expect(ui["allHealthBars"].has(1)).toBe(true);
|
||||
|
||||
// an inactive unit doesn't have a health bar
|
||||
unit.isActive = () => false;
|
||||
ui.drawHealthBar(unit);
|
||||
expect(ui["allHealthBars"].has(1)).toBe(false);
|
||||
});
|
||||
|
||||
it("should add loading bar for unit", () => {
|
||||
const ui = new UILayer(game, eventBus, transformHandler);
|
||||
ui.redraw();
|
||||
const unit = {
|
||||
id: () => 2,
|
||||
tile: () => ({}),
|
||||
isActive: () => true,
|
||||
} as unknown as UnitView;
|
||||
ui.createLoadingBar(unit);
|
||||
expect(ui["allProgressBars"].has(2)).toBe(true);
|
||||
});
|
||||
|
||||
it("should remove loading bar for inactive unit", () => {
|
||||
const ui = new UILayer(game, eventBus, transformHandler);
|
||||
ui.redraw();
|
||||
const unit = {
|
||||
id: () => 2,
|
||||
type: () => "City",
|
||||
isUnderConstruction: () => true,
|
||||
owner: () => ({ id: () => 1 }),
|
||||
tile: () => ({}),
|
||||
isActive: () => true,
|
||||
} as unknown as UnitView;
|
||||
ui.onUnitEvent(unit);
|
||||
expect(ui["allProgressBars"].has(2)).toBe(true);
|
||||
|
||||
// an inactive unit should not have a loading bar
|
||||
unit.isActive = () => false;
|
||||
ui.tick();
|
||||
expect(ui["allProgressBars"].has(2)).toBe(false);
|
||||
});
|
||||
|
||||
it("should remove loading bar for a finished progress bar", () => {
|
||||
const ui = new UILayer(game, eventBus, transformHandler);
|
||||
ui.redraw();
|
||||
const unit = {
|
||||
id: () => 2,
|
||||
type: () => "City",
|
||||
isUnderConstruction: () => true,
|
||||
owner: () => ({ id: () => 1 }),
|
||||
tile: () => ({}),
|
||||
isActive: () => true,
|
||||
createdAt: () => 1,
|
||||
markedForDeletion: () => false,
|
||||
} as unknown as UnitView;
|
||||
ui.onUnitEvent(unit);
|
||||
expect(ui["allProgressBars"].has(2)).toBe(true);
|
||||
|
||||
game.ticks = () => 6; // simulate enough ticks for completion
|
||||
// simulate construction finished
|
||||
(unit as any).isUnderConstruction = () => false;
|
||||
ui.tick();
|
||||
expect(ui["allProgressBars"].has(2)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* computePlayerStatus has two modes:
|
||||
*
|
||||
* - Replay mode (no localPlayerID): only crown / traitor / disconnected /
|
||||
* nukeActive flags are populated. All relative flags are false.
|
||||
* - Live mode (localPlayerID set): also fills alliance / target / embargo,
|
||||
* and nukeTargetsMe if a tileState buffer is supplied.
|
||||
*
|
||||
* The function only emits an entry per player when at least one flag is true
|
||||
* (the NamePass treats missing entries as "all flags off"). Tests assert
|
||||
* both presence and absence of entries.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computePlayerStatus } from "../../../../../src/client/render/frame/derive/PlayerStatus";
|
||||
import type {
|
||||
PlayerState,
|
||||
UnitState,
|
||||
} from "../../../../../src/client/render/types";
|
||||
import {
|
||||
UT_ATOM_BOMB,
|
||||
UT_WARSHIP,
|
||||
} from "../../../../../src/client/render/types";
|
||||
|
||||
function ps(overrides: Partial<PlayerState> = {}): PlayerState {
|
||||
return {
|
||||
smallID: 1,
|
||||
isAlive: true,
|
||||
isDisconnected: false,
|
||||
tilesOwned: 0,
|
||||
gold: 0,
|
||||
troops: 0,
|
||||
isTraitor: false,
|
||||
traitorRemainingTicks: 0,
|
||||
betrayals: 0,
|
||||
hasSpawned: true,
|
||||
lastDeleteUnitTick: 0,
|
||||
allies: [],
|
||||
embargoes: [],
|
||||
targets: [],
|
||||
outgoingAttacks: [],
|
||||
incomingAttacks: [],
|
||||
outgoingAllianceRequests: [],
|
||||
alliances: [],
|
||||
outgoingEmojis: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function unit(overrides: Partial<UnitState> = {}): UnitState {
|
||||
return {
|
||||
id: 1,
|
||||
unitType: UT_WARSHIP,
|
||||
ownerID: 1,
|
||||
lastOwnerID: null,
|
||||
pos: 0,
|
||||
lastPos: 0,
|
||||
isActive: true,
|
||||
reachedTarget: false,
|
||||
retreating: false,
|
||||
targetable: true,
|
||||
markedForDeletion: false,
|
||||
health: null,
|
||||
underConstruction: false,
|
||||
targetUnitId: null,
|
||||
targetTile: null,
|
||||
troops: 0,
|
||||
missileTimerQueue: [],
|
||||
level: 1,
|
||||
hasTrainStation: false,
|
||||
trainType: null,
|
||||
loaded: null,
|
||||
constructionStartTick: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function playersMap(...players: PlayerState[]): Map<number, PlayerState> {
|
||||
return new Map(players.map((p) => [p.smallID, p]));
|
||||
}
|
||||
|
||||
function unitsMap(...us: UnitState[]): Map<number, UnitState> {
|
||||
return new Map(us.map((u) => [u.id, u]));
|
||||
}
|
||||
|
||||
describe("computePlayerStatus — replay mode (no localPlayerID)", () => {
|
||||
it("returns empty map when no flags are set", () => {
|
||||
const players = playersMap(ps({ smallID: 1 }));
|
||||
const status = computePlayerStatus(players, unitsMap());
|
||||
expect(status.size).toBe(0);
|
||||
});
|
||||
|
||||
it("crown goes to the alive player with most tiles owned", () => {
|
||||
const players = playersMap(
|
||||
ps({ smallID: 1, tilesOwned: 100 }),
|
||||
ps({ smallID: 2, tilesOwned: 500 }), // king
|
||||
ps({ smallID: 3, tilesOwned: 250 }),
|
||||
);
|
||||
const status = computePlayerStatus(players, unitsMap());
|
||||
expect(status.get(2)?.crown).toBe(true);
|
||||
// Players 1 and 3 don't have crown and no other flags → no entry emitted.
|
||||
expect(status.has(1)).toBe(false);
|
||||
expect(status.has(3)).toBe(false);
|
||||
});
|
||||
|
||||
it("dead players don't get the crown even if they had the most tiles", () => {
|
||||
const players = playersMap(
|
||||
ps({ smallID: 1, tilesOwned: 1000, isAlive: false }),
|
||||
ps({ smallID: 2, tilesOwned: 100 }),
|
||||
);
|
||||
const status = computePlayerStatus(players, unitsMap());
|
||||
expect(status.get(2)?.crown).toBe(true);
|
||||
expect(status.has(1)).toBe(false);
|
||||
});
|
||||
|
||||
it("traitor + traitorRemainingTicks flow through", () => {
|
||||
const players = playersMap(
|
||||
ps({ smallID: 1, isTraitor: true, traitorRemainingTicks: 42 }),
|
||||
);
|
||||
const status = computePlayerStatus(players, unitsMap());
|
||||
expect(status.get(1)?.traitor).toBe(true);
|
||||
expect(status.get(1)?.traitorRemainingTicks).toBe(42);
|
||||
});
|
||||
|
||||
it("disconnected flag flows through", () => {
|
||||
const players = playersMap(ps({ smallID: 1, isDisconnected: true }));
|
||||
const status = computePlayerStatus(players, unitsMap());
|
||||
expect(status.get(1)?.disconnected).toBe(true);
|
||||
});
|
||||
|
||||
it("nukeActive: any in-flight nuke marks its owner", () => {
|
||||
const players = playersMap(ps({ smallID: 1 }), ps({ smallID: 2 }));
|
||||
const units = unitsMap(
|
||||
unit({ id: 10, ownerID: 2, unitType: UT_ATOM_BOMB, isActive: true }),
|
||||
);
|
||||
const status = computePlayerStatus(players, units);
|
||||
expect(status.get(2)?.nukeActive).toBe(true);
|
||||
expect(status.has(1)).toBe(false);
|
||||
});
|
||||
|
||||
it("inactive nukes don't trigger nukeActive", () => {
|
||||
const players = playersMap(ps({ smallID: 1 }));
|
||||
const units = unitsMap(
|
||||
unit({ id: 10, ownerID: 1, unitType: UT_ATOM_BOMB, isActive: false }),
|
||||
);
|
||||
const status = computePlayerStatus(players, units);
|
||||
expect(status.has(1)).toBe(false);
|
||||
});
|
||||
|
||||
it("relative flags (alliance/target/embargo/nukeTargetsMe) are always false in replay mode", () => {
|
||||
const players = playersMap(
|
||||
ps({ smallID: 1, allies: [2], targets: [2], embargoes: [2] }),
|
||||
ps({ smallID: 2, tilesOwned: 1 }), // crown so an entry exists
|
||||
);
|
||||
const status = computePlayerStatus(players, unitsMap());
|
||||
expect(status.get(2)?.alliance).toBe(false);
|
||||
expect(status.get(2)?.target).toBe(false);
|
||||
expect(status.get(2)?.embargo).toBe(false);
|
||||
expect(status.get(2)?.nukeTargetsMe).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computePlayerStatus — live mode (localPlayerID set)", () => {
|
||||
it("alliance: local has them as ally → alliance true", () => {
|
||||
const players = playersMap(
|
||||
ps({ smallID: 1, allies: [2] }), // me
|
||||
ps({ smallID: 2 }),
|
||||
);
|
||||
const status = computePlayerStatus(players, unitsMap(), {
|
||||
localPlayerID: 1,
|
||||
});
|
||||
expect(status.get(2)?.alliance).toBe(true);
|
||||
});
|
||||
|
||||
it("target: local has them in targets → target true", () => {
|
||||
const players = playersMap(
|
||||
ps({ smallID: 1, targets: [2] }), // me
|
||||
ps({ smallID: 2 }),
|
||||
);
|
||||
const status = computePlayerStatus(players, unitsMap(), {
|
||||
localPlayerID: 1,
|
||||
});
|
||||
expect(status.get(2)?.target).toBe(true);
|
||||
});
|
||||
|
||||
it("embargo is bilateral: true if I embargo them OR they embargo me", () => {
|
||||
// I embargo them.
|
||||
let status = computePlayerStatus(
|
||||
playersMap(ps({ smallID: 1, embargoes: [2] }), ps({ smallID: 2 })),
|
||||
unitsMap(),
|
||||
{ localPlayerID: 1 },
|
||||
);
|
||||
expect(status.get(2)?.embargo).toBe(true);
|
||||
|
||||
// They embargo me.
|
||||
status = computePlayerStatus(
|
||||
playersMap(ps({ smallID: 1 }), ps({ smallID: 2, embargoes: [1] })),
|
||||
unitsMap(),
|
||||
{ localPlayerID: 1 },
|
||||
);
|
||||
expect(status.get(2)?.embargo).toBe(true);
|
||||
|
||||
// Neither.
|
||||
status = computePlayerStatus(
|
||||
playersMap(ps({ smallID: 1 }), ps({ smallID: 2, tilesOwned: 1 })),
|
||||
unitsMap(),
|
||||
{ localPlayerID: 1 },
|
||||
);
|
||||
// Player 2 only has crown — embargo should be false.
|
||||
expect(status.get(2)?.embargo).toBe(false);
|
||||
});
|
||||
|
||||
it("relative flags are NOT set for the local player itself (no self-relationships)", () => {
|
||||
const players = playersMap(
|
||||
ps({
|
||||
smallID: 1,
|
||||
tilesOwned: 100,
|
||||
allies: [1],
|
||||
targets: [1],
|
||||
embargoes: [1],
|
||||
}),
|
||||
ps({ smallID: 2 }),
|
||||
);
|
||||
const status = computePlayerStatus(players, unitsMap(), {
|
||||
localPlayerID: 1,
|
||||
});
|
||||
// Player 1 (local) gets crown but no relative flags vs. self.
|
||||
expect(status.get(1)?.crown).toBe(true);
|
||||
expect(status.get(1)?.alliance).toBe(false);
|
||||
expect(status.get(1)?.target).toBe(false);
|
||||
expect(status.get(1)?.embargo).toBe(false);
|
||||
});
|
||||
|
||||
it("nukeTargetsMe: requires tileState — without it, stays false", () => {
|
||||
const players = playersMap(ps({ smallID: 1 }), ps({ smallID: 2 }));
|
||||
const units = unitsMap(
|
||||
unit({
|
||||
id: 10,
|
||||
ownerID: 2,
|
||||
unitType: UT_ATOM_BOMB,
|
||||
isActive: true,
|
||||
targetTile: 5,
|
||||
}),
|
||||
);
|
||||
const status = computePlayerStatus(players, units, { localPlayerID: 1 });
|
||||
expect(status.get(2)?.nukeActive).toBe(true);
|
||||
expect(status.get(2)?.nukeTargetsMe).toBe(false);
|
||||
});
|
||||
|
||||
it("nukeTargetsMe: true when nuke targets a tile owned by local player", () => {
|
||||
const players = playersMap(ps({ smallID: 1 }), ps({ smallID: 2 }));
|
||||
const units = unitsMap(
|
||||
unit({
|
||||
id: 10,
|
||||
ownerID: 2,
|
||||
unitType: UT_ATOM_BOMB,
|
||||
isActive: true,
|
||||
targetTile: 5,
|
||||
}),
|
||||
);
|
||||
// tileState[5] low 12 bits = 1 (local player's smallID).
|
||||
const tileState = new Uint16Array(16);
|
||||
tileState[5] = 1;
|
||||
|
||||
const status = computePlayerStatus(players, units, {
|
||||
localPlayerID: 1,
|
||||
tileState,
|
||||
});
|
||||
expect(status.get(2)?.nukeTargetsMe).toBe(true);
|
||||
});
|
||||
|
||||
it("nukeTargetsMe: false when nuke targets a tile owned by someone else", () => {
|
||||
const players = playersMap(
|
||||
ps({ smallID: 1 }),
|
||||
ps({ smallID: 2 }),
|
||||
ps({ smallID: 3 }),
|
||||
);
|
||||
const units = unitsMap(
|
||||
unit({
|
||||
id: 10,
|
||||
ownerID: 2,
|
||||
unitType: UT_ATOM_BOMB,
|
||||
isActive: true,
|
||||
targetTile: 5,
|
||||
}),
|
||||
);
|
||||
// tileState[5] = player 3, not me.
|
||||
const tileState = new Uint16Array(16);
|
||||
tileState[5] = 3;
|
||||
|
||||
const status = computePlayerStatus(players, units, {
|
||||
localPlayerID: 1,
|
||||
tileState,
|
||||
});
|
||||
expect(status.get(2)?.nukeTargetsMe).toBe(false);
|
||||
});
|
||||
|
||||
it("entry is emitted when only a relative flag is true (even with no base flags)", () => {
|
||||
const players = playersMap(
|
||||
ps({ smallID: 1, allies: [2] }), // me
|
||||
ps({ smallID: 2 }), // no other flags
|
||||
);
|
||||
const status = computePlayerStatus(players, unitsMap(), {
|
||||
localPlayerID: 1,
|
||||
});
|
||||
// Without local-mode, player 2 wouldn't get an entry — alliance is the
|
||||
// only reason it shows up here.
|
||||
expect(status.get(2)).toBeDefined();
|
||||
expect(status.get(2)?.alliance).toBe(true);
|
||||
});
|
||||
|
||||
it("localPlayerID = 0 (no local player) behaves like replay mode", () => {
|
||||
const players = playersMap(
|
||||
ps({ smallID: 1, allies: [2] }),
|
||||
ps({ smallID: 2, tilesOwned: 1 }),
|
||||
);
|
||||
const status = computePlayerStatus(players, unitsMap(), {
|
||||
localPlayerID: 0,
|
||||
});
|
||||
expect(status.get(2)?.alliance).toBe(false);
|
||||
});
|
||||
|
||||
it("allianceReq and allianceFraction are not computed (deferred)", () => {
|
||||
const players = playersMap(
|
||||
ps({ smallID: 1, allies: [2] }),
|
||||
ps({ smallID: 2 }),
|
||||
);
|
||||
const status = computePlayerStatus(players, unitsMap(), {
|
||||
localPlayerID: 1,
|
||||
});
|
||||
expect(status.get(2)?.allianceReq).toBe(false);
|
||||
expect(status.get(2)?.allianceFraction).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -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?: number[];
|
||||
bEmbargoSmallIDs?: number[];
|
||||
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