mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-22 10:28:13 +00:00
Merge branch 'main' into team-names
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 525 KiB |
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "BeringStrait",
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [1297, 287],
|
||||
"name": "Alaska",
|
||||
"flag": "us"
|
||||
},
|
||||
{
|
||||
"coordinates": [186, 427],
|
||||
"name": "Russia",
|
||||
"flag": "ru"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 314 KiB |
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "Bosphorus Straits",
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [520, 300],
|
||||
"name": "Istanbul",
|
||||
"flag": "tr"
|
||||
},
|
||||
{
|
||||
"coordinates": [360, 280],
|
||||
"name": "Thrace",
|
||||
"flag": "tr"
|
||||
},
|
||||
{
|
||||
"coordinates": [220, 260],
|
||||
"name": "Edirne",
|
||||
"flag": "tr"
|
||||
},
|
||||
{
|
||||
"coordinates": [650, 360],
|
||||
"name": "Bursa",
|
||||
"flag": "tr"
|
||||
},
|
||||
{
|
||||
"coordinates": [690, 290],
|
||||
"name": "Izmit",
|
||||
"flag": "tr"
|
||||
},
|
||||
{
|
||||
"coordinates": [430, 430],
|
||||
"name": "Canakkale",
|
||||
"flag": "tr"
|
||||
},
|
||||
{
|
||||
"coordinates": [320, 330],
|
||||
"name": "Tekirdag",
|
||||
"flag": "tr"
|
||||
},
|
||||
{
|
||||
"coordinates": [610, 320],
|
||||
"name": "Yalova",
|
||||
"flag": "tr"
|
||||
},
|
||||
{
|
||||
"coordinates": [720, 260],
|
||||
"name": "Kocaeli",
|
||||
"flag": "tr"
|
||||
},
|
||||
{
|
||||
"coordinates": [160, 120],
|
||||
"name": "Varna",
|
||||
"flag": "bg"
|
||||
},
|
||||
{
|
||||
"coordinates": [220, 150],
|
||||
"name": "Burgas",
|
||||
"flag": "bg"
|
||||
},
|
||||
{
|
||||
"coordinates": [820, 470],
|
||||
"name": "Aegean Isles",
|
||||
"flag": "gr"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -29,7 +29,9 @@ var maps = []struct {
|
||||
{Name: "baikal"},
|
||||
{Name: "baikalnukewars"},
|
||||
{Name: "betweentwoseas"},
|
||||
{Name: "beringstrait"},
|
||||
{Name: "blacksea"},
|
||||
{Name: "bosphorusstraits"},
|
||||
{Name: "britannia"},
|
||||
{Name: "britanniaclassic"},
|
||||
{Name: "deglaciatedantarctica"},
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<rect x="3" y="3" width="18" height="18" fill="none" stroke="white" stroke-width="2" />
|
||||
<line x1="9" y1="3" x2="9" y2="21" stroke="white" stroke-width="2" />
|
||||
<line x1="15" y1="3" x2="15" y2="21" stroke="white" stroke-width="2" />
|
||||
<line x1="3" y1="9" x2="21" y2="9" stroke="white" stroke-width="2" />
|
||||
<line x1="3" y1="15" x2="21" y2="15" stroke="white" stroke-width="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 450 B |
@@ -89,6 +89,7 @@
|
||||
"table_key": "Key",
|
||||
"table_action": "Action",
|
||||
"action_alt_view": "Alternate view (terrain/countries)",
|
||||
"action_coordinate_grid": "Toggle coordinate grid overlay",
|
||||
"action_attack_altclick": "Attack (when left click is set to open menu)",
|
||||
"action_build": "Open build menu",
|
||||
"action_emote": "Open emote menu",
|
||||
@@ -327,6 +328,8 @@
|
||||
"didier": "Didier",
|
||||
"didierfrance": "Didier (France)",
|
||||
"amazonriver": "Amazon River",
|
||||
"bosphorusstraits": "Bosphorus Straits",
|
||||
"beringstrait": "Bering Strait",
|
||||
"tradersdream": "Traders Dream",
|
||||
"hawaii": "Hawaii",
|
||||
"alps": "Alps"
|
||||
@@ -512,6 +515,8 @@
|
||||
"attack_ratio_desc": "What percentage of your troops to send in an attack (1–100%)",
|
||||
"territory_patterns_label": "🏳️ Territory Skins",
|
||||
"territory_patterns_desc": "Choose whether to display territory skin designs in game",
|
||||
"coordinate_grid_label": "Coordinate Grid",
|
||||
"coordinate_grid_desc": "Toggle the alphanumeric grid overlay",
|
||||
"performance_overlay_label": "Performance Overlay",
|
||||
"performance_overlay_desc": "Toggle the performance overlay. When enabled, the performance overlay will be displayed. Press shift-D during game to toggle.",
|
||||
"easter_writing_speed_label": "Writing Speed Multiplier",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"map": {
|
||||
"height": 916,
|
||||
"num_land_tiles": 596318,
|
||||
"width": 1500
|
||||
},
|
||||
"map16x": {
|
||||
"height": 229,
|
||||
"num_land_tiles": 35567,
|
||||
"width": 375
|
||||
},
|
||||
"map4x": {
|
||||
"height": 458,
|
||||
"num_land_tiles": 146741,
|
||||
"width": 750
|
||||
},
|
||||
"name": "BeringStrait",
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [1297, 287],
|
||||
"flag": "us",
|
||||
"name": "Alaska"
|
||||
},
|
||||
{
|
||||
"coordinates": [186, 427],
|
||||
"flag": "ru",
|
||||
"name": "Russia"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"map": {
|
||||
"height": 612,
|
||||
"num_land_tiles": 387974,
|
||||
"width": 1000
|
||||
},
|
||||
"map16x": {
|
||||
"height": 153,
|
||||
"num_land_tiles": 22991,
|
||||
"width": 250
|
||||
},
|
||||
"map4x": {
|
||||
"height": 306,
|
||||
"num_land_tiles": 95321,
|
||||
"width": 500
|
||||
},
|
||||
"name": "Bosphorus Straits",
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [520, 300],
|
||||
"flag": "tr",
|
||||
"name": "Istanbul"
|
||||
},
|
||||
{
|
||||
"coordinates": [360, 280],
|
||||
"flag": "tr",
|
||||
"name": "Thrace"
|
||||
},
|
||||
{
|
||||
"coordinates": [220, 260],
|
||||
"flag": "tr",
|
||||
"name": "Edirne"
|
||||
},
|
||||
{
|
||||
"coordinates": [650, 360],
|
||||
"flag": "tr",
|
||||
"name": "Bursa"
|
||||
},
|
||||
{
|
||||
"coordinates": [690, 290],
|
||||
"flag": "tr",
|
||||
"name": "Izmit"
|
||||
},
|
||||
{
|
||||
"coordinates": [430, 430],
|
||||
"flag": "tr",
|
||||
"name": "Canakkale"
|
||||
},
|
||||
{
|
||||
"coordinates": [320, 330],
|
||||
"flag": "tr",
|
||||
"name": "Tekirdag"
|
||||
},
|
||||
{
|
||||
"coordinates": [610, 320],
|
||||
"flag": "tr",
|
||||
"name": "Yalova"
|
||||
},
|
||||
{
|
||||
"coordinates": [720, 260],
|
||||
"flag": "tr",
|
||||
"name": "Kocaeli"
|
||||
},
|
||||
{
|
||||
"coordinates": [160, 120],
|
||||
"flag": "bg",
|
||||
"name": "Varna"
|
||||
},
|
||||
{
|
||||
"coordinates": [220, 150],
|
||||
"flag": "bg",
|
||||
"name": "Burgas"
|
||||
},
|
||||
{
|
||||
"coordinates": [820, 470],
|
||||
"flag": "gr",
|
||||
"name": "Aegean Isles"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
@@ -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);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
|
||||
@@ -51,6 +51,10 @@ describe("AllianceBehavior.handleAllianceRequests", () => {
|
||||
player,
|
||||
new NationEmojiBehavior(random, game, player),
|
||||
);
|
||||
|
||||
while (game.inSpawnPhase()) {
|
||||
game.executeNextTick();
|
||||
}
|
||||
});
|
||||
|
||||
function setupAllianceRequest({
|
||||
@@ -92,6 +96,16 @@ describe("AllianceBehavior.handleAllianceRequests", () => {
|
||||
return mockRequest;
|
||||
}
|
||||
|
||||
test("should reject alliance during spawn phase", () => {
|
||||
vi.spyOn(game, "inSpawnPhase").mockReturnValue(true);
|
||||
const request = setupAllianceRequest({});
|
||||
|
||||
allianceBehavior.handleAllianceRequests();
|
||||
|
||||
expect(request.accept).not.toHaveBeenCalled();
|
||||
expect(request.reject).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should accept alliance when all conditions are met", () => {
|
||||
const request = setupAllianceRequest({});
|
||||
|
||||
|
||||
@@ -74,9 +74,11 @@ describe("TradeShipExecution", () => {
|
||||
tradeShip = {
|
||||
isActive: vi.fn(() => true),
|
||||
owner: vi.fn(() => origOwner),
|
||||
id: vi.fn(() => 123),
|
||||
move: vi.fn(),
|
||||
setTargetUnit: vi.fn(),
|
||||
setSafeFromPirates: vi.fn(),
|
||||
touch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
tile: vi.fn(() => 2001),
|
||||
} as any;
|
||||
@@ -85,6 +87,7 @@ describe("TradeShipExecution", () => {
|
||||
tradeShipExecution.init(game, 0);
|
||||
tradeShipExecution["pathFinder"] = {
|
||||
next: vi.fn(() => ({ status: PathStatus.NEXT, node: 2001 })),
|
||||
findPath: vi.fn((from: number) => [from]),
|
||||
} as any;
|
||||
tradeShipExecution["tradeShip"] = tradeShip;
|
||||
});
|
||||
@@ -116,6 +119,7 @@ describe("TradeShipExecution", () => {
|
||||
it("should complete trade and award gold", () => {
|
||||
tradeShipExecution["pathFinder"] = {
|
||||
next: vi.fn(() => ({ status: PathStatus.COMPLETE, node: 2001 })),
|
||||
findPath: vi.fn((from: number) => [from]),
|
||||
} as any;
|
||||
tradeShipExecution.tick(1);
|
||||
expect(tradeShip.delete).toHaveBeenCalledWith(false);
|
||||
|
||||
Reference in New Issue
Block a user