Merge branch 'main' into team-names

This commit is contained in:
Mattia Migliorini
2026-03-01 12:37:25 +01:00
committed by GitHub
48 changed files with 2000 additions and 128 deletions
-9
View File
@@ -385,15 +385,6 @@ export class ClientGameRunner {
}
});
const worker = this.worker;
const keepWorkerAlive = () => {
if (this.isActive) {
worker.sendHeartbeat();
requestAnimationFrame(keepWorkerAlive);
}
};
requestAnimationFrame(keepWorkerAlive);
const onconnect = () => {
console.log("Connected to game server!");
this.transport.rejoinGame(this.turnsSeen);
+9
View File
@@ -42,6 +42,7 @@ export class HelpModal extends BaseModal {
const isMac = /Mac/.test(navigator.userAgent);
return {
toggleView: "Space",
coordinateGrid: "KeyM",
centerCamera: "KeyC",
moveUp: "KeyW",
moveDown: "KeyS",
@@ -265,6 +266,14 @@ export class HelpModal extends BaseModal {
${translateText("help_modal.action_alt_view")}
</td>
</tr>
<tr class="hover:bg-white/5 transition-colors">
<td class="py-3 pl-4 border-b border-white/5">
${this.renderKey(keybinds.coordinateGrid)}
</td>
<td class="py-3 border-b border-white/5 text-white/70">
${translateText("help_modal.action_coordinate_grid")}
</td>
</tr>
<tr class="hover:bg-white/5 transition-colors">
<td class="py-3 pl-4 border-b border-white/5">
${this.renderKey(keybinds.swapDirection)}
+14
View File
@@ -129,6 +129,10 @@ export class AutoUpgradeEvent implements GameEvent {
) {}
}
export class ToggleCoordinateGridEvent implements GameEvent {
constructor(public readonly enabled: boolean) {}
}
export class TickMetricsEvent implements GameEvent {
constructor(
public readonly tickExecutionDuration?: number,
@@ -154,6 +158,7 @@ export class InputHandler {
private moveInterval: NodeJS.Timeout | null = null;
private activeKeys = new Set<string>();
private keybinds: Record<string, string> = {};
private coordinateGridEnabled = false;
private readonly PAN_SPEED = 5;
private readonly ZOOM_SPEED = 10;
@@ -201,6 +206,7 @@ export class InputHandler {
this.keybinds = {
toggleView: "Space",
coordinateGrid: "KeyM",
centerCamera: "KeyC",
moveUp: "KeyW",
moveDown: "KeyS",
@@ -316,6 +322,14 @@ export class InputHandler {
}
}
if (e.code === this.keybinds.coordinateGrid && !e.repeat) {
e.preventDefault();
this.coordinateGridEnabled = !this.coordinateGridEnabled;
this.eventBus.emit(
new ToggleCoordinateGridEvent(this.coordinateGridEnabled),
);
}
if (e.code === "Escape") {
e.preventDefault();
this.eventBus.emit(new CloseViewEvent());
+11
View File
@@ -22,6 +22,7 @@ const isMac =
const DefaultKeybinds: Record<string, string> = {
toggleView: "Space",
coordinateGrid: "KeyM",
buildCity: "Digit1",
buildFactory: "Digit2",
buildPort: "Digit3",
@@ -491,6 +492,16 @@ export class UserSettingModal extends BaseModal {
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="coordinateGrid"
label=${translateText("user_setting.coordinate_grid_label")}
description=${translateText("user_setting.coordinate_grid_desc")}
defaultKey=${DefaultKeybinds.coordinateGrid}
.value=${this.getKeyValue("coordinateGrid")}
.display=${this.getKeyChar("coordinateGrid")}
@change=${this.handleKeybindChange}
></setting-keybind>
<h2
class="text-blue-200 text-xl font-bold mt-8 mb-3 border-b border-white/10 pb-2"
>
+34 -19
View File
@@ -318,50 +318,65 @@ export function formatDebugTranslation(
return `${key}::${serializedParams}`;
}
const EMPTY_TRANSLATION_PARAMS: Record<string, string | number> = {};
function getCachedLangSelector(): LangSelector | null {
const self = translateText as any;
const cached = self.langSelector as LangSelector | null | undefined;
if (cached && cached.isConnected) return cached;
const found = document.querySelector("lang-selector") as LangSelector | null;
self.langSelector = found ?? null;
return found;
}
export const translateText = (
key: string,
params: Record<string, string | number> = {},
params?: Record<string, string | number>,
): string => {
const self = translateText as any;
self.formatterCache ??= new Map();
self.lastLang ??= null;
const langSelector = document.querySelector("lang-selector") as LangSelector;
const langSelector = getCachedLangSelector();
if (!langSelector) {
console.warn("LangSelector not found in DOM");
return key;
}
const resolvedParams = params ?? EMPTY_TRANSLATION_PARAMS;
if (langSelector.currentLang === "debug") {
return formatDebugTranslation(key, params);
return formatDebugTranslation(key, resolvedParams);
}
if (
!langSelector.translations ||
Object.keys(langSelector.translations).length === 0
) {
return key;
}
const translations = langSelector.translations;
const defaultTranslations = langSelector.defaultTranslations;
if (!translations && !defaultTranslations) return key;
if (self.lastLang !== langSelector.currentLang) {
self.formatterCache.clear();
self.lastLang = langSelector.currentLang;
}
let message = langSelector.translations[key];
let message = translations?.[key];
const hasPrimaryTranslation = message !== undefined;
if (!message && langSelector.defaultTranslations) {
const defaultTranslations = langSelector.defaultTranslations;
if (defaultTranslations && defaultTranslations[key]) {
message = defaultTranslations[key];
}
message ??= defaultTranslations?.[key];
if (message === undefined) return key;
// Fast path: no params and no ICU placeholders.
if (
resolvedParams === EMPTY_TRANSLATION_PARAMS &&
message.indexOf("{") === -1
) {
return message;
}
if (!message) return key;
try {
const locale =
!langSelector.translations[key] && langSelector.currentLang !== "en"
!hasPrimaryTranslation && langSelector.currentLang !== "en"
? "en"
: langSelector.currentLang;
const cacheKey = `${key}:${locale}:${message}`;
@@ -372,7 +387,7 @@ export const translateText = (
self.formatterCache.set(cacheKey, formatter);
}
return formatter.format(params) as string;
return formatter.format(resolvedParams) as string;
} catch (e) {
console.warn("ICU format error", e);
return message;
+2
View File
@@ -12,6 +12,7 @@ import { BuildMenu } from "./layers/BuildMenu";
import { ChatDisplay } from "./layers/ChatDisplay";
import { ChatModal } from "./layers/ChatModal";
import { ControlPanel } from "./layers/ControlPanel";
import { CoordinateGridLayer } from "./layers/CoordinateGridLayer";
import { DynamicUILayer } from "./layers/DynamicUILayer";
import { EmojiTable } from "./layers/EmojiTable";
import { EventsDisplay } from "./layers/EventsDisplay";
@@ -282,6 +283,7 @@ export function createRenderer(
new TerrainLayer(game, transformHandler),
new TerritoryLayer(game, eventBus, transformHandler, userSettings),
new RailroadLayer(game, eventBus, transformHandler, uiState),
new CoordinateGridLayer(game, eventBus, transformHandler),
structureLayer,
samRadiusLayer,
new UnitLayer(game, eventBus, transformHandler),
@@ -0,0 +1,319 @@
import { EventBus } from "../../../core/EventBus";
import { Cell } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import {
AlternateViewEvent,
ToggleCoordinateGridEvent,
} from "../../InputHandler";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
const BASE_CELL_COUNT = 10;
const MAX_COLUMNS = 50;
const MIN_ROWS = 2;
const LABEL_PADDING = 8;
const toAlphaLabel = (index: number): string => {
let value = index;
let label = "";
do {
label = String.fromCharCode(65 + (value % 26)) + label;
value = Math.floor(value / 26) - 1;
} while (value >= 0);
return label;
};
const computeGrid = (width: number, height: number) => {
// Initial square-ish estimate
let cellSize = Math.min(width, height) / BASE_CELL_COUNT;
let rows = Math.max(1, Math.round(height / cellSize));
let cols = Math.max(1, Math.round(width / cellSize));
// Cap columns and adjust rows accordingly
if (cols > MAX_COLUMNS) {
const maxRowsForCols = Math.floor((MAX_COLUMNS * height) / width);
rows = Math.max(MIN_ROWS, Math.min(rows, maxRowsForCols));
cols = MAX_COLUMNS;
}
cellSize = Math.min(width / cols, height / rows);
const fullCols = Math.max(1, Math.floor(width / cellSize));
const fullRows = Math.max(1, Math.floor(height / cellSize));
const remainderX = Math.max(0, width - fullCols * cellSize);
const remainderY = Math.max(0, height - fullRows * cellSize);
const hasExtraCol = remainderX > 0.001;
const hasExtraRow = remainderY > 0.001;
const totalCols = fullCols + (hasExtraCol ? 1 : 0);
const totalRows = fullRows + (hasExtraRow ? 1 : 0);
const lastColWidth = hasExtraCol ? remainderX : cellSize;
const lastRowHeight = hasExtraRow ? remainderY : cellSize;
return {
cellSize,
rows: totalRows,
cols: totalCols,
fullCols,
fullRows,
lastColWidth,
lastRowHeight,
hasExtraCol,
hasExtraRow,
gridWidth: width,
gridHeight: height,
};
};
export class CoordinateGridLayer implements Layer {
private isVisible = false;
private alternateView = false;
private cachedGridCanvas: HTMLCanvasElement | null = null;
private cachedGridContext: CanvasRenderingContext2D | null = null;
private cachedGridKey = "";
constructor(
private game: GameView,
private eventBus: EventBus,
private transformHandler: TransformHandler,
) {}
init() {
this.eventBus.on(ToggleCoordinateGridEvent, (event) => {
this.isVisible = event.enabled;
});
this.eventBus.on(AlternateViewEvent, (event) => {
this.alternateView = event.alternateView;
});
}
shouldTransform(): boolean {
return false;
}
renderLayer(context: CanvasRenderingContext2D) {
if (!this.isVisible && !this.alternateView) return;
const width = this.game.width();
const height = this.game.height();
if (width <= 0 || height <= 0) return;
const canvasWidth = context.canvas.width;
const canvasHeight = context.canvas.height;
const cacheKey = this.buildCacheKey(
width,
height,
canvasWidth,
canvasHeight,
);
const cacheContext = this.ensureCacheContext(canvasWidth, canvasHeight);
if (cacheContext === null || this.cachedGridCanvas === null) return;
if (this.cachedGridKey !== cacheKey) {
cacheContext.clearRect(0, 0, canvasWidth, canvasHeight);
this.drawGrid(cacheContext, width, height);
this.cachedGridKey = cacheKey;
}
context.drawImage(this.cachedGridCanvas, 0, 0);
}
private ensureCacheContext(
canvasWidth: number,
canvasHeight: number,
): CanvasRenderingContext2D | null {
this.cachedGridCanvas ??= document.createElement("canvas");
if (
this.cachedGridCanvas.width !== canvasWidth ||
this.cachedGridCanvas.height !== canvasHeight
) {
this.cachedGridCanvas.width = canvasWidth;
this.cachedGridCanvas.height = canvasHeight;
this.cachedGridContext = null;
this.cachedGridKey = "";
}
this.cachedGridContext ??= this.cachedGridCanvas.getContext("2d");
return this.cachedGridContext;
}
private buildCacheKey(
width: number,
height: number,
canvasWidth: number,
canvasHeight: number,
): string {
const topLeft = this.transformHandler.worldToScreenCoordinates(
new Cell(0, 0),
);
const bottomRight = this.transformHandler.worldToScreenCoordinates(
new Cell(width, height),
);
return [
width,
height,
canvasWidth,
canvasHeight,
this.transformHandler.scale.toFixed(4),
topLeft.x.toFixed(2),
topLeft.y.toFixed(2),
bottomRight.x.toFixed(2),
bottomRight.y.toFixed(2),
].join("|");
}
private drawGrid(
context: CanvasRenderingContext2D,
width: number,
height: number,
) {
const {
cellSize,
rows,
cols,
fullCols,
fullRows,
lastColWidth,
lastRowHeight,
hasExtraCol,
hasExtraRow,
gridWidth,
gridHeight,
} = computeGrid(width, height);
const cellWidth = cellSize;
const cellHeight = cellSize;
const canvasWidth = context.canvas.width;
const canvasHeight = context.canvas.height;
const mapTopScreenRaw = this.transformHandler.worldToScreenCoordinates(
new Cell(0, 0),
).y;
const mapBottomScreenRaw = this.transformHandler.worldToScreenCoordinates(
new Cell(0, height),
).y;
const mapLeftScreenRaw = this.transformHandler.worldToScreenCoordinates(
new Cell(0, 0),
).x;
const mapRightScreenRaw = this.transformHandler.worldToScreenCoordinates(
new Cell(width, 0),
).x;
const mapTopScreen = Math.min(mapTopScreenRaw, mapBottomScreenRaw);
const mapLeftScreen = Math.min(mapLeftScreenRaw, mapRightScreenRaw);
const mapTopWorld = 0;
const mapLeftWorld = 0;
context.save();
context.strokeStyle = "rgba(255, 255, 255, 0.35)";
context.lineWidth = 1.25;
context.beginPath();
for (let col = 0; col <= fullCols; col++) {
const worldX = col * cellWidth + mapLeftWorld;
const screenX = this.transformHandler.worldToScreenCoordinates(
new Cell(worldX, mapTopWorld),
).x;
if (screenX < -1 || screenX > canvasWidth + 1) continue;
const screenBottom = this.transformHandler.worldToScreenCoordinates(
new Cell(worldX, gridHeight),
).y;
context.moveTo(screenX, mapTopScreen);
context.lineTo(screenX, screenBottom);
}
// Final vertical line at map right edge only if grid fits perfectly
if (!hasExtraCol) {
const mapRightLine = this.transformHandler.worldToScreenCoordinates(
new Cell(gridWidth, mapTopWorld),
).x;
context.moveTo(mapRightLine, mapTopScreen);
context.lineTo(
mapRightLine,
this.transformHandler.worldToScreenCoordinates(
new Cell(gridWidth, gridHeight),
).y,
);
}
for (let row = 0; row <= fullRows; row++) {
const worldY = row * cellHeight + mapTopWorld;
const screenY = this.transformHandler.worldToScreenCoordinates(
new Cell(mapLeftWorld, worldY),
).y;
if (screenY < -1 || screenY > canvasHeight + 1) continue;
const screenRight = this.transformHandler.worldToScreenCoordinates(
new Cell(gridWidth, worldY),
).x;
context.moveTo(mapLeftScreen, screenY);
context.lineTo(screenRight, screenY);
}
// Final horizontal line at map bottom edge only if grid fits perfectly
if (!hasExtraRow) {
const mapBottomLine = this.transformHandler.worldToScreenCoordinates(
new Cell(mapLeftWorld, gridHeight),
).y;
context.moveTo(mapLeftScreen, mapBottomLine);
context.lineTo(
this.transformHandler.worldToScreenCoordinates(
new Cell(gridWidth, gridHeight),
).x,
mapBottomLine,
);
}
context.stroke();
context.font = "12px monospace";
const drawLabel = (text: string, x: number, y: number) => {
context.textAlign = "left";
context.textBaseline = "top";
context.fillStyle = "rgba(20, 20, 20, 0.9)";
context.fillText(text, x, y);
};
// Render per-cell labels (like A1, B1, etc.) at cell top-left
const fontSize = Math.min(
16,
Math.max(9, 10 + (this.transformHandler.scale - 1) * 1.2),
);
context.font = `${fontSize}px monospace`;
for (let row = 0; row < rows; row++) {
const rowLabel = toAlphaLabel(row);
const startY = row * cellHeight;
const rowHeight = row < fullRows ? cellHeight : lastRowHeight;
const centerY = startY + rowHeight / 2;
const screenY = this.transformHandler.worldToScreenCoordinates(
new Cell(0, centerY),
).y;
if (screenY < -LABEL_PADDING || screenY > canvasHeight + LABEL_PADDING)
continue;
for (let col = 0; col < cols; col++) {
const startX = col * cellWidth;
const colWidth = col < fullCols ? cellWidth : lastColWidth;
const centerX = startX + colWidth / 2;
const screenX = this.transformHandler.worldToScreenCoordinates(
new Cell(centerX, centerY),
).x;
if (screenX < -LABEL_PADDING || screenX > canvasWidth + LABEL_PADDING)
continue;
// Position at cell top-left in screen space
const cellTopLeft = this.transformHandler.worldToScreenCoordinates(
new Cell(startX, startY),
);
drawLabel(
`${rowLabel}${col + 1}`,
cellTopLeft.x + LABEL_PADDING,
cellTopLeft.y + LABEL_PADDING,
);
}
}
context.restore();
}
}
+20 -4
View File
@@ -65,11 +65,27 @@ export class UnitLayer implements Layer {
}
tick() {
const unitIds = this.game
.updatesSinceLastTick()
?.[GameUpdateType.Unit]?.map((unit) => unit.id);
const updatedUnitIds =
this.game
.updatesSinceLastTick()
?.[GameUpdateType.Unit]?.map((unit) => unit.id) ?? [];
this.updateUnitsSprites(unitIds ?? []);
const motionPlanUnitIds = this.game.motionPlannedUnitIds();
if (updatedUnitIds.length === 0) {
this.updateUnitsSprites(motionPlanUnitIds);
return;
}
if (motionPlanUnitIds.length === 0) {
this.updateUnitsSprites(updatedUnitIds);
return;
}
const unitIds = new Set<number>(updatedUnitIds);
for (const id of motionPlanUnitIds) {
unitIds.add(id);
}
this.updateUnitsSprites(Array.from(unitIds));
}
init() {
+2
View File
@@ -170,10 +170,12 @@ export class GameRunner {
}
const packedTileUpdates = this.game.drainPackedTileUpdates();
const packedMotionPlans = this.game.drainPackedMotionPlans();
this.callBack({
tick: this.game.ticks(),
packedTileUpdates,
...(packedMotionPlans ? { packedMotionPlans } : {}),
updates: updates,
playerNameViewData: this.playerViewData,
tickExecutionDuration: tickExecutionDuration,
+26 -7
View File
@@ -19,6 +19,8 @@ export class TradeShipExecution implements Execution {
private wasCaptured = false;
private pathFinder: SteppingPathFinder<TileRef>;
private tilesTraveled = 0;
private motionPlanId = 1;
private motionPlanDst: TileRef | null = null;
constructor(
private origOwner: Player,
@@ -93,6 +95,8 @@ export class TradeShipExecution implements Execution {
} else {
this._dstPort = ports[0];
this.tradeShip.setTargetUnit(this._dstPort);
// Plan-driven units don't emit per-tick unit updates, so force a sync for the new target.
this.tradeShip.touch();
}
}
@@ -102,14 +106,29 @@ export class TradeShipExecution implements Execution {
return;
}
const result = this.pathFinder.next(curTile, this._dstPort.tile());
const dst = this._dstPort.tile();
const result = this.pathFinder.next(curTile, dst);
switch (result.status) {
case PathStatus.PENDING:
// Fire unit event to rerender.
this.tradeShip.move(curTile);
break;
case PathStatus.NEXT:
if (dst !== this.motionPlanDst) {
this.motionPlanId++;
const from = result.node;
const path = this.pathFinder.findPath(from, dst) ?? [from];
if (path.length === 0 || path[0] !== from) {
path.unshift(from);
}
this.mg.recordMotionPlan({
kind: "grid",
unitId: this.tradeShip.id(),
planId: this.motionPlanId,
startTick: ticks + 1,
ticksPerStep: 1,
path,
});
this.motionPlanDst = dst;
}
// Update safeFromPirates status
if (this.mg.isWater(result.node) && this.mg.isShoreline(result.node)) {
this.tradeShip.setSafeFromPirates();
@@ -119,14 +138,14 @@ export class TradeShipExecution implements Execution {
break;
case PathStatus.COMPLETE:
this.complete();
break;
return;
case PathStatus.NOT_FOUND:
console.warn("captured trade ship cannot find route");
if (this.tradeShip.isActive()) {
this.tradeShip.delete(false);
}
this.active = false;
break;
return;
}
}
+31
View File
@@ -7,6 +7,7 @@ import {
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { MotionPlanRecord } from "../game/MotionPlans";
import { RailNetwork } from "../game/RailNetwork";
import { getOrientedRailroad, OrientedRailroad } from "../game/Railroad";
import { TrainStation } from "../game/TrainStation";
@@ -63,6 +64,36 @@ export class TrainExecution implements Execution {
return;
}
this.train = this.createTrainUnits(spawn);
const carUnitIds = this.cars.map((c) => c.id());
const pathTiles: TileRef[] = [];
for (let i = 0; i + 1 < this.stations.length; i++) {
const segment = getOrientedRailroad(
this.stations[i],
this.stations[i + 1],
);
if (!segment) {
this.active = false;
return;
}
pathTiles.push(...segment.getTiles());
}
const startTile = this.train.tile();
if (pathTiles.length === 0 || pathTiles[0] !== startTile) {
pathTiles.unshift(startTile);
}
const plan: MotionPlanRecord = {
kind: "train",
engineUnitId: this.train.id(),
carUnitIds,
planId: 1,
startTick: ticks + 1,
speed: this.speed,
spacing: this.spacing,
path: pathTiles,
};
this.mg.recordMotionPlan(plan);
}
tick(ticks: number): void {
+39 -2
View File
@@ -9,6 +9,7 @@ import {
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { MotionPlanRecord } from "../game/MotionPlans";
import { targetTransportTile } from "../game/TransportShipUtils";
import { PathFinding } from "../pathfinding/PathFinder";
import { PathStatus, SteppingPathFinder } from "../pathfinding/types";
@@ -31,6 +32,8 @@ export class TransportShipExecution implements Execution {
private src: TileRef | null;
private retreatDst: TileRef | false | null = null;
private boat: Unit;
private motionPlanId = 1;
private motionPlanDst: TileRef | null = null;
private originalOwner: Player;
@@ -110,6 +113,22 @@ export class TransportShipExecution implements Execution {
targetTile: this.dst,
});
const fullPath = this.pathFinder.findPath(this.src, this.dst) ?? [this.src];
if (fullPath.length === 0 || fullPath[0] !== this.src) {
fullPath.unshift(this.src);
}
const motionPlan: MotionPlanRecord = {
kind: "grid",
unitId: this.boat.id(),
planId: this.motionPlanId,
startTick: ticks + this.ticksPerMove,
ticksPerStep: this.ticksPerMove,
path: fullPath,
};
this.mg.recordMotionPlan(motionPlan);
this.motionPlanDst = this.dst;
// Notify the target player about the incoming naval invasion
if (this.target.id() !== mg.terraNullius().id()) {
mg.displayIncomingUnit(
@@ -229,8 +248,6 @@ export class TransportShipExecution implements Execution {
case PathStatus.NEXT:
this.boat.move(result.node);
break;
case PathStatus.PENDING:
break;
case PathStatus.NOT_FOUND: {
// TODO: add to poisoned port list
const map = this.mg.map();
@@ -244,6 +261,26 @@ export class TransportShipExecution implements Execution {
return;
}
}
if (this.dst !== null && this.dst !== this.motionPlanDst) {
this.motionPlanId++;
const fullPath = this.pathFinder.findPath(this.boat.tile(), this.dst) ?? [
this.boat.tile(),
];
if (fullPath.length === 0 || fullPath[0] !== this.boat.tile()) {
fullPath.unshift(this.boat.tile());
}
this.mg.recordMotionPlan({
kind: "grid",
unitId: this.boat.id(),
planId: this.motionPlanId,
startTick: ticks + this.ticksPerMove,
ticksPerStep: this.ticksPerMove,
path: fullPath,
});
this.motionPlanDst = this.dst;
}
}
owner(): Player {
-6
View File
@@ -190,9 +190,6 @@ export class WarshipExecution implements Execution {
case PathStatus.NEXT:
this.warship.move(result.node);
break;
case PathStatus.PENDING:
this.warship.touch();
break;
case PathStatus.NOT_FOUND: {
console.log(`path not found to target`);
break;
@@ -221,9 +218,6 @@ export class WarshipExecution implements Execution {
case PathStatus.NEXT:
this.warship.move(result.node);
break;
case PathStatus.PENDING:
this.warship.touch();
return;
case PathStatus.NOT_FOUND: {
console.log(`path not found to target`);
break;
@@ -76,6 +76,11 @@ export class NationAllianceBehavior {
otherPlayer: Player,
isResponse: boolean,
): boolean {
// Reject alliance requests during the spawn phase
if (this.game.inSpawnPhase()) {
return false;
}
// Easy (dumb) nations sometimes get confused and accept/reject randomly (Just like dumb humans do)
if (this.isConfused()) {
return this.random.chance(2);
+7
View File
@@ -10,6 +10,7 @@ import {
PlayerUpdate,
UnitUpdate,
} from "./GameUpdates";
import { MotionPlanRecord } from "./MotionPlans";
import { RailNetwork } from "./RailNetwork";
import { Stats } from "./Stats";
import { UnitPredicate } from "./UnitGrid";
@@ -127,6 +128,8 @@ export enum GameMapType {
Didier = "Didier",
DidierFrance = "Didier France",
AmazonRiver = "Amazon River",
BosphorusStraits = "Bosphorus Straits",
BeringStrait = "Bering Strait",
Yenisei = "Yenisei",
TradersDream = "Traders Dream",
Hawaii = "Hawaii",
@@ -178,6 +181,8 @@ export const mapCategories: Record<string, GameMapType[]> = {
GameMapType.TwoLakes,
GameMapType.StraitOfHormuz,
GameMapType.AmazonRiver,
GameMapType.BosphorusStraits,
GameMapType.BeringStrait,
GameMapType.Yenisei,
GameMapType.Hawaii,
GameMapType.Alps,
@@ -777,6 +782,8 @@ export interface Game extends GameMap {
inSpawnPhase(): boolean;
executeNextTick(): GameUpdates;
drainPackedTileUpdates(): Uint32Array;
recordMotionPlan(record: MotionPlanRecord): void;
drainPackedMotionPlans(): Uint32Array | null;
setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void;
getWinner(): Player | Team | null;
config(): Config;
+44
View File
@@ -43,6 +43,7 @@ import {
} from "./Game";
import { GameMap, TileRef } from "./GameMap";
import { GameUpdate, GameUpdateType } from "./GameUpdates";
import { MotionPlanRecord, packMotionPlans } from "./MotionPlans";
import { PlayerImpl } from "./PlayerImpl";
import { RailNetwork } from "./RailNetwork";
import { createRailNetwork } from "./RailNetworkImpl";
@@ -95,6 +96,8 @@ export class GameImpl implements Game {
private updates: GameUpdates = createGameUpdatesMap();
private tileUpdatePairs: number[] = [];
private motionPlanRecords: MotionPlanRecord[] = [];
private planDrivenUnitIds = new Set<number>();
private unitGrid: UnitGrid;
private playerTeams: Team[] = [];
@@ -500,6 +503,46 @@ export class GameImpl implements Game {
return packed;
}
recordMotionPlan(record: MotionPlanRecord): void {
switch (record.kind) {
case "grid":
this.planDrivenUnitIds.add(record.unitId);
break;
case "train":
this.planDrivenUnitIds.add(record.engineUnitId);
for (const unitId of record.carUnitIds) {
this.planDrivenUnitIds.add(unitId);
}
break;
}
this.motionPlanRecords.push(record);
}
private isUnitPlanDriven(unitId: number): boolean {
return this.planDrivenUnitIds.has(unitId);
}
maybeAddUnitUpdate(unit: Unit): void {
if (!this.isUnitPlanDriven(unit.id())) {
this.addUpdate(unit.toUpdate());
}
}
onUnitMoved(unit: Unit): void {
this.updateUnitTile(unit);
this.maybeAddUnitUpdate(unit);
}
drainPackedMotionPlans(): Uint32Array | null {
const records = this.motionPlanRecords;
if (records.length === 0) {
return null;
}
const packed = packMotionPlans(records);
records.length = 0;
return packed;
}
private hash(): number {
let hash = 1;
this._players.forEach((p) => {
@@ -943,6 +986,7 @@ export class GameImpl implements Game {
}
removeUnit(u: Unit) {
this.unitGrid.removeUnit(u);
this.planDrivenUnitIds.delete(u.id());
if (u.hasTrainStation()) {
this._railNetwork.removeStation(u);
}
+7
View File
@@ -24,6 +24,13 @@ export interface GameUpdateViewData {
* state (`uint16`) stored in a `uint32` lane.
*/
packedTileUpdates: Uint32Array;
/**
* Optional packed motion plan records.
*
* When present, this buffer is expected to be transferred worker -> main
* (similar to `packedTileUpdates`) to avoid structured-clone copies.
*/
packedMotionPlans?: Uint32Array;
playerNameViewData: Record<string, NameViewData>;
tickExecutionDuration?: number;
pendingTurns?: number;
+323
View File
@@ -34,6 +34,7 @@ import {
PlayerUpdate,
UnitUpdate,
} from "./GameUpdates";
import { MotionPlanRecord, unpackMotionPlans } from "./MotionPlans";
import { TerrainMapData } from "./TerrainMapLoader";
import { TerraNulliusImpl } from "./TerraNulliusImpl";
import { UnitGrid, UnitPredicate } from "./UnitGrid";
@@ -83,6 +84,17 @@ export class UnitView {
this.data = data;
}
applyDerivedPosition(pos: TileRef) {
const prev = this.data.pos;
this.lastPos.push(pos);
this._wasUpdated = true;
this.data = {
...this.data,
lastPos: prev,
pos,
};
}
id(): number {
return this.data.id;
}
@@ -582,6 +594,20 @@ export class PlayerView {
}
}
type TrainPlanState = {
planId: number;
startTick: number;
speed: number;
spacing: number;
carUnitIds: Uint32Array;
path: Uint32Array;
cursor: number;
usedTilesBuf: Uint32Array;
usedHead: number;
usedLen: number;
lastAdvancedTick: Tick;
};
export class GameView implements GameMap {
private lastUpdate: GameUpdateViewData | null;
private smallIDToID = new Map<number, PlayerID>();
@@ -592,6 +618,17 @@ export class GameView implements GameMap {
private _myPlayer: PlayerView | null = null;
private unitGrid: UnitGrid;
private unitMotionPlans = new Map<
number,
{
planId: number;
startTick: number;
ticksPerStep: number;
path: Uint32Array;
}
>();
private trainMotionPlans = new Map<number, TrainPlanState>();
private trainUnitToEngine = new Map<number, number>();
private toDelete = new Set<number>();
@@ -637,6 +674,51 @@ export class GameView implements GameMap {
return this.lastUpdate?.updates ?? null;
}
public motionPlans(): ReadonlyMap<
number,
{
planId: number;
startTick: number;
ticksPerStep: number;
path: Uint32Array;
}
> {
return this.unitMotionPlans;
}
private motionPlannedUnitIdsCache: number[] = [];
private motionPlannedUnitIdsDirty = true;
private markMotionPlannedUnitIdsDirty(): void {
this.motionPlannedUnitIdsDirty = true;
}
private rebuildMotionPlannedUnitIdsCacheIfDirty(): void {
if (!this.motionPlannedUnitIdsDirty) {
return;
}
this.motionPlannedUnitIdsDirty = false;
const out = this.motionPlannedUnitIdsCache;
out.length = 0;
for (const unitId of this.unitMotionPlans.keys()) {
out.push(unitId);
}
for (const [engineId, plan] of this.trainMotionPlans) {
out.push(engineId);
for (let i = 0; i < plan.carUnitIds.length; i++) {
const id = plan.carUnitIds[i] >>> 0;
if (id !== 0) out.push(id);
}
}
}
public motionPlannedUnitIds(): number[] {
this.rebuildMotionPlannedUnitIdsCacheIfDirty();
return this.motionPlannedUnitIdsCache;
}
public isCatchingUp(): boolean {
return (this.lastUpdate?.pendingTurns ?? 0) > 1;
}
@@ -656,6 +738,11 @@ export class GameView implements GameMap {
this.updatedTiles.push(tile);
}
if (gu.packedMotionPlans) {
const records = unpackMotionPlans(gu.packedMotionPlans);
this.applyMotionPlanRecords(records);
}
if (gu.updates === null) {
throw new Error("lastUpdate.updates not initialized");
}
@@ -704,8 +791,244 @@ export class GameView implements GameMap {
if (!unit.isActive()) {
// Wait until next tick to delete the unit.
this.toDelete.add(unit.id());
if (this.unitMotionPlans.delete(unit.id())) {
this.markMotionPlannedUnitIdsDirty();
}
this.clearTrainPlanForUnit(unit.id());
}
});
this.advanceMotionPlannedUnits(gu.tick);
this.rebuildMotionPlannedUnitIdsCacheIfDirty();
}
private advanceMotionPlannedUnits(currentTick: Tick): void {
for (const [unitId, plan] of this.unitMotionPlans) {
const unit = this._units.get(unitId);
if (!unit || !unit.isActive()) {
if (this.unitMotionPlans.delete(unitId)) {
this.markMotionPlannedUnitIdsDirty();
}
continue;
}
const oldTile = unit.tile();
const dt = currentTick - plan.startTick;
const stepIndex =
dt <= 0 ? 0 : Math.floor(dt / Math.max(1, plan.ticksPerStep));
const lastIndex = plan.path.length - 1;
const idx = Math.max(0, Math.min(lastIndex, stepIndex));
const newTile = plan.path[idx] as TileRef;
if (newTile !== oldTile) {
unit.applyDerivedPosition(newTile);
this.unitGrid.updateUnitCell(unit);
continue;
}
// Once a plan is past its final step, `newTile` remains clamped to the last path tile.
// Drop finished plans to avoid repeatedly marking static units as updated each tick.
if (dt > 0 && stepIndex >= lastIndex) {
if (this.unitMotionPlans.delete(unitId)) {
this.markMotionPlannedUnitIdsDirty();
}
}
}
this.advanceTrainMotionPlannedUnits(currentTick);
}
private clearTrainPlanForUnit(unitId: number): void {
const engineId =
this.trainUnitToEngine.get(unitId) ??
(this.trainMotionPlans.has(unitId) ? unitId : null);
if (engineId === null) {
return;
}
const plan = this.trainMotionPlans.get(engineId);
if (!plan) {
this.trainUnitToEngine.delete(unitId);
return;
}
if (this.trainMotionPlans.delete(engineId)) {
this.markMotionPlannedUnitIdsDirty();
}
this.trainUnitToEngine.delete(engineId);
for (let i = 0; i < plan.carUnitIds.length; i++) {
const id = plan.carUnitIds[i] >>> 0;
if (id !== 0) this.trainUnitToEngine.delete(id);
}
}
private advanceTrainMotionPlannedUnits(currentTick: Tick): void {
const staleEngineIds: number[] = [];
for (const [engineId, plan] of this.trainMotionPlans) {
const engine = this._units.get(engineId);
if (!engine || !engine.isActive()) {
staleEngineIds.push(engineId);
continue;
}
const steps = currentTick - plan.lastAdvancedTick;
if (steps <= 0) {
continue;
}
const path = plan.path;
const lastIndex = path.length - 1;
const cap = plan.usedTilesBuf.length;
const pushUsed = (tile: TileRef) => {
if (cap === 0) return;
if (plan.usedLen < cap) {
const idx = (plan.usedHead + plan.usedLen) % cap;
plan.usedTilesBuf[idx] = tile >>> 0;
plan.usedLen++;
} else {
plan.usedTilesBuf[plan.usedHead] = tile >>> 0;
plan.usedHead = (plan.usedHead + 1) % cap;
plan.usedLen = cap;
}
};
const usedGet = (index: number): TileRef | null => {
if (index < 0 || index >= plan.usedLen || cap === 0) return null;
const idx = (plan.usedHead + index) % cap;
return plan.usedTilesBuf[idx] as TileRef;
};
let didMove = false;
for (let step = 0; step < steps; step++) {
const cursor = plan.cursor;
if (cursor >= lastIndex) {
break;
}
for (let i = 0; i < plan.speed && cursor + i < path.length; i++) {
pushUsed(path[cursor + i] as TileRef);
}
plan.cursor = Math.min(lastIndex, cursor + plan.speed);
for (let i = plan.carUnitIds.length - 1; i >= 0; --i) {
const carId = plan.carUnitIds[i] >>> 0;
if (carId === 0) continue;
const car = this._units.get(carId);
if (!car || !car.isActive()) {
continue;
}
const carTileIndex = (i + 1) * plan.spacing + 2;
const tile = usedGet(carTileIndex);
if (tile !== null) {
const oldTile = car.tile();
if (tile !== oldTile) {
car.applyDerivedPosition(tile);
this.unitGrid.updateUnitCell(car);
didMove = true;
}
}
}
const newEngineTile = path[plan.cursor] as TileRef;
const oldEngineTile = engine.tile();
if (newEngineTile !== oldEngineTile) {
engine.applyDerivedPosition(newEngineTile);
this.unitGrid.updateUnitCell(engine);
didMove = true;
}
}
plan.lastAdvancedTick = currentTick;
// Preserve the final-step redraw (plan remains for the tick where motion ends),
// then clear once the train has settled and no longer moves.
// Note: trains are currently deleted at the end of TrainExecution, and the ensuing
// `Unit` update (isActive=false) also clears any associated motion plan records.
// This expiry is defensive to avoid keeping stale plans around if that behavior changes.
if (!didMove && plan.cursor >= lastIndex) {
staleEngineIds.push(engineId);
}
}
for (const engineId of staleEngineIds) {
this.clearTrainPlanForUnit(engineId);
}
}
private applyMotionPlanRecords(records: readonly MotionPlanRecord[]): void {
for (const record of records) {
switch (record.kind) {
case "grid": {
if (record.ticksPerStep < 1 || record.path.length < 1) {
break;
}
const existing = this.unitMotionPlans.get(record.unitId);
if (existing && record.planId <= existing.planId) {
break;
}
const path =
record.path instanceof Uint32Array
? record.path
: Uint32Array.from(record.path);
this.unitMotionPlans.set(record.unitId, {
planId: record.planId,
startTick: record.startTick,
ticksPerStep: record.ticksPerStep,
path,
});
this.markMotionPlannedUnitIdsDirty();
break;
}
case "train": {
if (record.speed < 1 || record.path.length < 1) {
break;
}
const existing = this.trainMotionPlans.get(record.engineUnitId);
if (existing && record.planId <= existing.planId) {
break;
}
if (existing) {
this.clearTrainPlanForUnit(record.engineUnitId);
}
const carUnitIds =
record.carUnitIds instanceof Uint32Array
? record.carUnitIds
: Uint32Array.from(record.carUnitIds);
const path =
record.path instanceof Uint32Array
? record.path
: Uint32Array.from(record.path);
const usedCap = carUnitIds.length * record.spacing + 3;
const usedTilesBuf = new Uint32Array(Math.max(0, usedCap));
this.trainMotionPlans.set(record.engineUnitId, {
planId: record.planId,
startTick: record.startTick,
speed: record.speed,
spacing: record.spacing,
carUnitIds,
path,
cursor: 0,
usedTilesBuf,
usedHead: 0,
usedLen: 0,
lastAdvancedTick: record.startTick,
});
this.markMotionPlannedUnitIdsDirty();
this.trainUnitToEngine.set(record.engineUnitId, record.engineUnitId);
for (let i = 0; i < carUnitIds.length; i++) {
const carId = carUnitIds[i] >>> 0;
if (carId !== 0)
this.trainUnitToEngine.set(carId, record.engineUnitId);
}
break;
}
}
}
}
recentlyUpdatedTiles(): TileRef[] {
+212
View File
@@ -0,0 +1,212 @@
import { TileRef } from "./GameMap";
export enum PackedMotionPlanKind {
GridPathSet = 1,
TrainRailPathSet = 2,
}
export interface GridPathPlan {
kind: "grid";
unitId: number;
planId: number;
startTick: number;
ticksPerStep: number;
/**
* TileRef path where `path[0]` is the unit tile at `startTick`.
*/
path: readonly TileRef[] | Uint32Array;
}
export interface TrainRailPathPlan {
kind: "train";
engineUnitId: number;
/**
* TrainExecution `cars[]` order (tail engine + carriages).
*/
carUnitIds: readonly number[] | Uint32Array;
planId: number;
startTick: number;
speed: number;
spacing: number;
/**
* Concatenated rail tile path across all segments, without de-duplicating at stations.
*/
path: readonly TileRef[] | Uint32Array;
}
export type MotionPlanRecord = GridPathPlan | TrainRailPathPlan;
export function packMotionPlans(
records: readonly MotionPlanRecord[],
): Uint32Array {
let totalWords = 1;
for (const record of records) {
switch (record.kind) {
case "grid": {
const pathLen = (record.path.length >>> 0) as number;
totalWords += 2 + 5 + pathLen;
break;
}
case "train": {
const carCount = (record.carUnitIds.length >>> 0) as number;
const pathLen = (record.path.length >>> 0) as number;
totalWords += 2 + 7 + carCount + pathLen;
break;
}
}
}
const out = new Uint32Array(totalWords);
out[0] = records.length >>> 0;
let offset = 1;
for (const record of records) {
switch (record.kind) {
case "grid": {
const path = record.path as ArrayLike<number>;
const pathLen = path.length >>> 0;
const wordCount = 2 + 5 + pathLen;
out[offset++] = PackedMotionPlanKind.GridPathSet;
out[offset++] = wordCount >>> 0;
out[offset++] = record.unitId >>> 0;
out[offset++] = record.planId >>> 0;
out[offset++] = record.startTick >>> 0;
out[offset++] = record.ticksPerStep >>> 0;
out[offset++] = pathLen >>> 0;
for (let i = 0; i < pathLen; i++) {
out[offset++] = path[i] >>> 0;
}
break;
}
case "train": {
const carUnitIds = record.carUnitIds as ArrayLike<number>;
const carCount = carUnitIds.length >>> 0;
const path = record.path as ArrayLike<number>;
const pathLen = path.length >>> 0;
const wordCount = 2 + 7 + carCount + pathLen;
out[offset++] = PackedMotionPlanKind.TrainRailPathSet;
out[offset++] = wordCount >>> 0;
out[offset++] = record.engineUnitId >>> 0;
out[offset++] = record.planId >>> 0;
out[offset++] = record.startTick >>> 0;
out[offset++] = record.speed >>> 0;
out[offset++] = record.spacing >>> 0;
out[offset++] = carCount >>> 0;
out[offset++] = pathLen >>> 0;
for (let i = 0; i < carCount; i++) {
out[offset++] = carUnitIds[i] >>> 0;
}
for (let i = 0; i < pathLen; i++) {
out[offset++] = path[i] >>> 0;
}
break;
}
}
}
if (offset !== out.length) {
throw new Error(
`packMotionPlans size mismatch: wrote ${offset}, expected ${out.length}`,
);
}
return out;
}
export function unpackMotionPlans(packed: Uint32Array): MotionPlanRecord[] {
if (packed.length < 1) {
return [];
}
const recordCount = packed[0] >>> 0;
const records: MotionPlanRecord[] = [];
let offset = 1;
for (let i = 0; i < recordCount && offset + 1 < packed.length; i++) {
const kind = packed[offset] >>> 0;
const wordCount = packed[offset + 1] >>> 0;
if (wordCount < 2 || offset + wordCount > packed.length) {
break;
}
switch (kind) {
case PackedMotionPlanKind.GridPathSet: {
if (wordCount < 2 + 5) {
break;
}
const unitId = packed[offset + 2] >>> 0;
const planId = packed[offset + 3] >>> 0;
const startTick = packed[offset + 4] >>> 0;
const ticksPerStep = packed[offset + 5] >>> 0;
const pathLen = packed[offset + 6] >>> 0;
const expectedWordCount = 2 + 5 + pathLen;
if (expectedWordCount !== wordCount) {
break;
}
const pathStart = offset + 7;
const pathEnd = pathStart + pathLen;
const path = packed.slice(pathStart, pathEnd);
records.push({
kind: "grid",
unitId,
planId,
startTick,
ticksPerStep,
path,
});
break;
}
case PackedMotionPlanKind.TrainRailPathSet: {
if (wordCount < 2 + 7) {
break;
}
const engineUnitId = packed[offset + 2] >>> 0;
const planId = packed[offset + 3] >>> 0;
const startTick = packed[offset + 4] >>> 0;
const speed = packed[offset + 5] >>> 0;
const spacing = packed[offset + 6] >>> 0;
const carCount = packed[offset + 7] >>> 0;
const pathLen = packed[offset + 8] >>> 0;
const expectedWordCount = 2 + 7 + carCount + pathLen;
if (expectedWordCount !== wordCount) {
break;
}
const carStart = offset + 9;
const carEnd = carStart + carCount;
const pathStart = carEnd;
const pathEnd = pathStart + pathLen;
const carUnitIds = packed.slice(carStart, carEnd);
const path = packed.slice(pathStart, pathEnd);
records.push({
kind: "train",
engineUnitId,
carUnitIds,
planId,
startTick,
speed,
spacing,
path,
});
break;
}
default:
// Unknown kind: skip.
break;
}
offset += wordCount;
}
return records;
}
+5 -3
View File
@@ -159,8 +159,7 @@ export class UnitImpl implements Unit {
}
this._lastTile = this._tile;
this._tile = tile;
this.mg.updateUnitTile(this);
this.mg.addUpdate(this.toUpdate());
this.mg.onUnitMoved(this);
}
setTroops(troops: number): void {
@@ -336,7 +335,10 @@ export class UnitImpl implements Unit {
if (this.type() !== UnitType.TransportShip) {
throw new Error(`Cannot retreat ${this.type()}`);
}
this._retreating = true;
if (!this._retreating) {
this._retreating = true;
this.mg.addUpdate(this.toUpdate());
}
}
isUnderConstruction(): boolean {
+3 -5
View File
@@ -4,14 +4,12 @@
*/
export enum PathStatus {
NEXT,
PENDING,
COMPLETE,
NOT_FOUND,
NEXT = 0,
COMPLETE = 2,
NOT_FOUND = 3,
}
export type PathResult<T> =
| { status: PathStatus.PENDING }
| { status: PathStatus.NEXT; node: T }
| { status: PathStatus.COMPLETE; node: T }
| { status: PathStatus.NOT_FOUND };
+100 -29
View File
@@ -16,26 +16,110 @@ import {
const ctx: Worker = self as any;
let gameRunner: Promise<GameRunner> | null = null;
const mapLoader = new FetchGameMapLoader(`/maps`, version);
const MAX_TICKS_PER_HEARTBEAT = 4;
// Yield threshold; not a backlog cap. Used to avoid monopolizing the worker task
// and flooding the main thread with messages during catch-up.
const MAX_TICKS_BEFORE_YIELD = 4;
function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) {
// skip if ErrorUpdate
if (!("updates" in gu)) {
let drainScheduled = false;
let draining = false;
let drainRequested = false;
function scheduleDrain(): void {
drainRequested = true;
if (drainScheduled || draining) {
return;
}
sendMessage({
type: "game_update",
gameUpdate: gu,
});
drainScheduled = true;
setTimeout(() => {
void drain().catch((e) => {
console.error("Worker drain failed:", e);
});
}, 0);
}
async function drain(): Promise<void> {
drainScheduled = false;
if (draining) {
return;
}
if (!gameRunner) {
return;
}
draining = true;
drainRequested = false;
let shouldContinue = false;
try {
const gr = await gameRunner;
if (!gr) {
return;
}
const batch: GameUpdateViewData[] = [];
const onTickUpdate = (gu: GameUpdateViewData | ErrorUpdate) => {
if (!("updates" in gu)) {
return;
}
batch.push(gu);
};
// Temporarily route tick callbacks into this drain's batch.
tickUpdateSink = onTickUpdate;
let ticksRun = 0;
while (ticksRun < MAX_TICKS_BEFORE_YIELD && gr.pendingTurns() > 0) {
const ok = gr.executeNextTick(gr.pendingTurns());
if (!ok) {
break;
}
ticksRun++;
}
tickUpdateSink = null;
sendGameUpdateBatch(batch);
shouldContinue = gr.pendingTurns() > 0;
} finally {
tickUpdateSink = null;
draining = false;
}
if (shouldContinue || drainRequested) {
scheduleDrain();
}
}
let tickUpdateSink: ((gu: GameUpdateViewData | ErrorUpdate) => void) | null =
null;
function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) {
tickUpdateSink?.(gu);
}
function sendGameUpdateBatch(gameUpdates: GameUpdateViewData[]): void {
if (gameUpdates.length === 0) {
return;
}
const transfers: Transferable[] = [];
for (const gu of gameUpdates) {
transfers.push(gu.packedTileUpdates.buffer);
if (gu.packedMotionPlans) {
transfers.push(gu.packedMotionPlans.buffer);
}
}
ctx.postMessage(
{
type: "game_update_batch",
gameUpdates,
} as WorkerMessage,
transfers,
);
}
function sendMessage(message: WorkerMessage) {
if (message.type === "game_update") {
// Transfer the packed tile updates buffer to avoid structured-clone copies and
// reduce worker-side memory churn during long runs / catch-up.
ctx.postMessage(message, [message.gameUpdate.packedTileUpdates.buffer]);
return;
}
ctx.postMessage(message);
}
@@ -43,20 +127,6 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
const message = e.data;
switch (message.type) {
case "heartbeat": {
const gr = await gameRunner;
if (!gr) {
break;
}
const pendingTurns = gr.pendingTurns();
const ticksToRun = Math.min(pendingTurns, MAX_TICKS_PER_HEARTBEAT);
for (let i = 0; i < ticksToRun; i++) {
if (!gr.executeNextTick(gr.pendingTurns())) {
break;
}
}
break;
}
case "init":
try {
gameRunner = createGameRunner(
@@ -84,7 +154,8 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
try {
const gr = await gameRunner;
await gr.addTurn(message.turn);
gr.addTurn(message.turn);
scheduleDrain();
} catch (error) {
console.error("Failed to process turn:", error);
throw error;
+7 -6
View File
@@ -45,6 +45,13 @@ export class WorkerClient {
this.gameUpdateCallback(message.gameUpdate);
}
break;
case "game_update_batch":
if (this.gameUpdateCallback && message.gameUpdates) {
for (const gu of message.gameUpdates) {
this.gameUpdateCallback(gu);
}
}
break;
case "initialized":
default:
@@ -103,12 +110,6 @@ export class WorkerClient {
});
}
sendHeartbeat() {
this.worker.postMessage({
type: "heartbeat",
});
}
playerProfile(playerID: number): Promise<PlayerProfile> {
return new Promise((resolve, reject) => {
if (!this.isInitialized) {
+7 -6
View File
@@ -10,11 +10,11 @@ import { GameUpdateViewData } from "../game/GameUpdates";
import { ClientID, GameStartInfo, Turn } from "../Schemas";
export type WorkerMessageType =
| "heartbeat"
| "init"
| "initialized"
| "turn"
| "game_update"
| "game_update_batch"
| "player_actions"
| "player_actions_result"
| "player_profile"
@@ -32,10 +32,6 @@ interface BaseWorkerMessage {
id?: string;
}
export interface HeartbeatMessage extends BaseWorkerMessage {
type: "heartbeat";
}
// Messages from main thread to worker
export interface InitMessage extends BaseWorkerMessage {
type: "init";
@@ -58,6 +54,11 @@ export interface GameUpdateMessage extends BaseWorkerMessage {
gameUpdate: GameUpdateViewData;
}
export interface GameUpdateBatchMessage extends BaseWorkerMessage {
type: "game_update_batch";
gameUpdates: GameUpdateViewData[];
}
export interface PlayerActionsMessage extends BaseWorkerMessage {
type: "player_actions";
playerID: PlayerID;
@@ -116,7 +117,6 @@ export interface TransportShipSpawnResultMessage extends BaseWorkerMessage {
// Union types for type safety
export type MainThreadMessage =
| HeartbeatMessage
| InitMessage
| TurnMessage
| PlayerActionsMessage
@@ -129,6 +129,7 @@ export type MainThreadMessage =
export type WorkerMessage =
| InitializedMessage
| GameUpdateMessage
| GameUpdateBatchMessage
| PlayerActionsResultMessage
| PlayerProfileResultMessage
| PlayerBorderTilesResultMessage
+1 -1
View File
@@ -17,7 +17,7 @@ export class Client {
public readonly roles: string[] | undefined,
public readonly flares: string[] | undefined,
public readonly ip: string,
public readonly username: string,
public username: string,
public readonly uncensoredUsername: string,
public ws: WebSocket,
public readonly cosmetics: PlayerCosmetics | undefined,
+2 -1
View File
@@ -46,10 +46,11 @@ export class GameManager {
persistentID: string,
gameID: GameID,
lastTurn: number = 0,
newUsername?: string,
): boolean {
const game = this.games.get(gameID);
if (!game) return false;
return game.rejoinClient(ws, persistentID, lastTurn);
return game.rejoinClient(ws, persistentID, lastTurn, newUsername);
}
createGame(
+8
View File
@@ -257,10 +257,13 @@ export class GameServer {
// Attempt to reconnect a client by persistentID. Returns true if successful.
// Only the WebSocket is updated — username, cosmetics, etc. are preserved
// from the original join to maintain consistency throughout the game session.
// Exception: in the pre-game lobby, the username is updated so players can
// rename between leaving and rejoining.
public rejoinClient(
ws: WebSocket,
persistentID: string,
lastTurn: number = 0,
newUsername?: string,
): boolean {
const clientID = this.getClientIdForPersistentId(persistentID);
if (!clientID) return false;
@@ -283,6 +286,11 @@ export class GameServer {
client.lastPing = Date.now();
this.markClientDisconnected(client.clientID, false);
// Allow username updates in the pre-game lobby
if (!this._hasStarted && newUsername !== undefined) {
client.username = newUsername;
}
client.ws = ws;
this.addListeners(client);
this.startLobbyInfoBroadcast();
+123 -23
View File
@@ -11,6 +11,7 @@ import {
Quads,
RankedType,
Trios,
mapCategories,
} from "../core/game/Game";
import { PseudoRandom } from "../core/PseudoRandom";
import { GameConfig, PublicGameType, TeamCountConfig } from "../core/Schemas";
@@ -18,6 +19,7 @@ import { logger } from "./Logger";
import { getMapLandTiles } from "./MapLandTiles";
const log = logger.child({});
const ARCADE_MAPS = new Set(mapCategories.arcade);
// How many times each map should appear in the playlist.
// Note: The Partial should eventually be removed for better type safety.
@@ -64,6 +66,8 @@ const frequency: Partial<Record<GameMapName, number>> = {
DidierFrance: 1,
Didier: 1,
AmazonRiver: 3,
BosphorusStraits: 3,
BeringStrait: 4,
Sierpinski: 10,
TheBox: 3,
Yenisei: 6,
@@ -85,6 +89,16 @@ const TEAM_WEIGHTS: { config: TeamCountConfig; weight: number }[] = [
{ config: HumansVsNations, weight: 20 },
];
type ModifierKey = "isRandomSpawn" | "isCompact" | "isCrowded" | "startingGold";
// Each entry represents one "ticket" in the pool. More tickets = higher chance of selection.
const SPECIAL_MODIFIER_POOL: ModifierKey[] = [
...Array<ModifierKey>(4).fill("isRandomSpawn"),
...Array<ModifierKey>(7).fill("isCompact"),
...Array<ModifierKey>(1).fill("isCrowded"),
...Array<ModifierKey>(6).fill("startingGold"),
];
export class MapPlaylist {
private playlists: Record<PublicGameType, GameMapType[]> = {
ffa: [],
@@ -92,8 +106,6 @@ export class MapPlaylist {
team: [],
};
constructor() {}
public async gameConfig(type: PublicGameType): Promise<GameConfig> {
if (type === "special") {
return this.getSpecialConfig();
@@ -173,27 +185,76 @@ export class MapPlaylist {
} satisfies GameConfig;
}
private getSpecialConfig(): GameConfig {
// TODO: create better special configs.
private async getSpecialConfig(): Promise<GameConfig> {
const mode = Math.random() < 0.5 ? GameMode.FFA : GameMode.Team;
const map = this.getNextMap("special");
const playerTeams =
mode === GameMode.Team ? this.getTeamCount() : undefined;
const supportsCompact =
mode !== GameMode.Team || (await this.supportsCompactMapForTeams(map));
const excludedModifiers: ModifierKey[] = [];
if (!supportsCompact) {
excludedModifiers.push("isCompact");
}
if (
playerTeams === Duos ||
playerTeams === Trios ||
playerTeams === Quads
) {
excludedModifiers.push("isRandomSpawn");
}
let { isCrowded, startingGold, isCompact, isRandomSpawn } =
this.getRandomSpecialGameModifiers(excludedModifiers);
let crowdedMaxPlayers: number | undefined;
if (isCrowded) {
crowdedMaxPlayers = await this.getCrowdedMaxPlayers(map, isCompact);
if (crowdedMaxPlayers !== undefined) {
crowdedMaxPlayers = this.adjustForTeams(crowdedMaxPlayers, playerTeams);
} else {
// Map doesn't support crowded. Drop it and pick one replacement only
// if it was the sole modifier, so the lobby always has at least one.
isCrowded = false;
if (!isRandomSpawn && !isCompact && startingGold === undefined) {
excludedModifiers.push("isCrowded");
({ isRandomSpawn, isCompact, startingGold } =
this.getRandomSpecialGameModifiers(excludedModifiers, 1));
}
}
}
const maxPlayers = Math.max(
2,
crowdedMaxPlayers ??
(await this.lobbyMaxPlayers(map, mode, playerTeams, isCompact)),
);
return {
donateGold: true,
donateTroops: true,
donateGold: mode === GameMode.Team,
donateTroops: mode === GameMode.Team,
gameMap: map,
maxPlayers: 2,
maxPlayers,
gameType: GameType.Public,
gameMapSize: GameMapSize.Normal,
difficulty: Difficulty.Easy,
rankedType: RankedType.OneVOne,
gameMapSize: isCompact ? GameMapSize.Compact : GameMapSize.Normal,
publicGameModifiers: {
isCompact,
isRandomSpawn,
isCrowded,
startingGold,
},
startingGold,
difficulty: Difficulty.Medium,
infiniteGold: false,
infiniteTroops: false,
maxTimerValue: undefined,
instantBuild: false,
randomSpawn: false,
disableNations: true,
gameMode: GameMode.Team,
playerTeams: HumansVsNations,
bots: 100,
spawnImmunityDuration: 5 * 10,
randomSpawn: isRandomSpawn,
disableNations: mode === GameMode.Team && playerTeams !== HumansVsNations,
gameMode: mode,
playerTeams,
bots: isCompact ? 100 : 400,
spawnImmunityDuration: startingGold ? 30 * 10 : 5 * 10,
disabledUnits: [],
} satisfies GameConfig;
}
@@ -232,21 +293,21 @@ export class MapPlaylist {
private getNextMap(type: PublicGameType): GameMapType {
const playlist = this.playlists[type];
if (playlist.length === 0) {
playlist.push(...this.generateNewPlaylist());
playlist.push(...this.generateNewPlaylist(type));
}
return playlist.shift()!;
}
private generateNewPlaylist(): GameMapType[] {
const maps = this.buildMapsList();
private generateNewPlaylist(type: PublicGameType): GameMapType[] {
const maps = this.buildMapsList(type);
const rand = new PseudoRandom(Date.now());
const shuffledSource = rand.shuffleArray([...maps]);
const playlist: GameMapType[] = [];
const numAttempts = 10000;
for (let attempt = 0; attempt < numAttempts; attempt++) {
playlist.length = 0;
const source = [...shuffledSource];
// Re-shuffle every attempt so retries can explore different orderings.
const source = rand.shuffleArray([...maps]);
let success = true;
while (source.length > 0) {
@@ -286,11 +347,15 @@ export class MapPlaylist {
return false;
}
private buildMapsList(): GameMapType[] {
private buildMapsList(type: PublicGameType): GameMapType[] {
const maps: GameMapType[] = [];
(Object.keys(GameMapType) as GameMapName[]).forEach((key) => {
const map = GameMapType[key];
if (type !== "special" && ARCADE_MAPS.has(map)) {
return;
}
for (let i = 0; i < (frequency[key] ?? 0); i++) {
maps.push(GameMapType[key]);
maps.push(map);
}
});
return maps;
@@ -319,6 +384,41 @@ export class MapPlaylist {
};
}
private getRandomSpecialGameModifiers(
excludedModifiers: ModifierKey[] = [],
count?: number,
): PublicGameModifiers {
// Roll how many modifiers to pick: 30% → 1, 40% → 2, 20% → 3, 10% → 4
const modifierCountRoll = Math.floor(Math.random() * 10) + 1;
const k =
count ??
(modifierCountRoll <= 3
? 1
: modifierCountRoll <= 7
? 2
: modifierCountRoll <= 9
? 3
: 4);
// Shuffle the pool, then pick the first k unique modifier keys.
const pool = SPECIAL_MODIFIER_POOL.filter(
(key) => !excludedModifiers.includes(key),
).sort(() => Math.random() - 0.5);
const selected = new Set<ModifierKey>();
for (const key of pool) {
if (selected.size >= k) break;
selected.add(key);
}
return {
isRandomSpawn: selected.has("isRandomSpawn"),
isCompact: selected.has("isCompact"),
isCrowded: selected.has("isCrowded"),
startingGold: selected.has("startingGold") ? 5_000_000 : undefined,
};
}
// Maps with smallest player count (third number of calculateMapPlayerCounts) < 50 don't support compact map in team games
// (not enough players after 75% player reduction for compact maps)
private async supportsCompactMapForTeams(map: GameMapType): Promise<boolean> {
+14 -7
View File
@@ -356,8 +356,20 @@ export async function startWorker() {
}
// Try to reconnect an existing client (e.g., page refresh)
// If successful, skip all authorization
if (gm.rejoinClient(ws, persistentId, clientMsg.gameID)) {
// If successful, skip all authorization (but pass updated username
// so players can rename in the pre-game lobby)
const censoredUsername = privilegeRefresher
.get()
.censorUsername(clientMsg.username);
if (
gm.rejoinClient(
ws,
persistentId,
clientMsg.gameID,
0,
censoredUsername,
)
) {
return;
}
@@ -439,11 +451,6 @@ export async function startWorker() {
}
}
// Censor profane usernames server-side (don't reject, just rename)
const censoredUsername = privilegeRefresher
.get()
.censorUsername(clientMsg.username);
// Create client and add to game
const client = new Client(
generateID(),