From 67ef586ba1bcf1abc22312bcb48d575ede7dd2ea Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Wed, 29 Oct 2025 17:45:01 +0100 Subject: [PATCH 01/20] feat(game): add capital recalculation execution and update player model to track capital --- src/core/GameRunner.ts | 3 + .../CapitalRecalculationExecution.ts | 106 ++++++++++++++++++ src/core/game/Game.ts | 3 + src/core/game/GameUpdates.ts | 3 + src/core/game/PlayerImpl.ts | 16 +++ 5 files changed, 131 insertions(+) create mode 100644 src/core/execution/CapitalRecalculationExecution.ts diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index f68119c2d..c4219f58d 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -1,6 +1,7 @@ import { placeName } from "../client/graphics/NameBoxCalculator"; import { getConfig } from "./configuration/ConfigLoader"; import { AllianceExpireCheckExecution } from "./execution/alliance/AllianceExpireCheckExecution"; +import { CapitalRecalculationExecution } from "./execution/CapitalRecalculationExecution"; import { Executor } from "./execution/ExecutionManager"; import { WinCheckExecution } from "./execution/WinCheckExecution"; import { AllianceImpl } from "./game/AllianceImpl"; @@ -241,6 +242,8 @@ export class GameRunner { } this.game.addExecution(new WinCheckExecution()); this.game.addExecution(new AllianceExpireCheckExecution()); + // Background: periodically compute player capitals (geographic centers) + this.game.addExecution(new CapitalRecalculationExecution()); } public addTurn(turn: Turn): void { diff --git a/src/core/execution/CapitalRecalculationExecution.ts b/src/core/execution/CapitalRecalculationExecution.ts new file mode 100644 index 000000000..fbd8ac907 --- /dev/null +++ b/src/core/execution/CapitalRecalculationExecution.ts @@ -0,0 +1,106 @@ +import { Cell, Execution, Game, Player, Tick } from "../game/Game"; +import { PlayerImpl } from "../game/PlayerImpl"; +import { PseudoRandom } from "../PseudoRandom"; +import { simpleHash } from "../Util"; + +/** + * Periodically recomputes each player's capital (geographic center of owned tiles). + * - Recomputes at most once every 30 seconds per player + * - Spreads computations across 10 ticks by bucketing players by smallID % 10 + * - Uses up to 100 randomly sampled tiles (deterministic sampling per interval) + */ +export class CapitalRecalculationExecution implements Execution { + private mg!: Game; + private active = true; + + // Track last tick each player's capital was recalculated + private lastRecalc: Map = new Map(); + + // Precomputed interval in ticks (30 seconds) + private intervalTicks = 0; + + isActive(): boolean { + return this.active; + } + + activeDuringSpawnPhase(): boolean { + // Harmless during spawn; enables early UI if needed + return true; + } + + init(mg: Game, _ticks: number): void { + this.mg = mg; + const turnMs = this.mg.config().serverConfig().turnIntervalMs(); + this.intervalTicks = Math.max(1, Math.ceil(30_000 / turnMs)); + // Compute capitals immediately at game start for all players + for (const p of this.mg.players()) { + this.recomputeCapital(p, _ticks); + } + } + + tick(ticks: number): void { + const bucket = ticks % 10; + const players = this.mg.players(); + + for (const p of players) { + // Spread across 10 ticks based on smallID bucket + if (p.smallID() % 10 !== bucket) continue; + + const last = this.lastRecalc.get(p.id()) ?? -Infinity; + if (ticks - last < this.intervalTicks) continue; + + this.recomputeCapital(p, ticks); + } + } + + private recomputeCapital(player: Player, ticks: Tick): void { + const tiles = Array.from(player.tiles()); + let capital: Cell | null = null; + + if (tiles.length > 0) { + const sampleSize = Math.min(100, tiles.length); + const intervalIndex = Math.floor(ticks / Math.max(1, this.intervalTicks)); + const prng = new PseudoRandom( + simpleHash(`${player.id()}::${intervalIndex}`), + ); + // Deterministic reservoir sampling of up to 100 tiles + const sample: number[] = []; + for (let i = 0; i < tiles.length; i++) { + if (i < sampleSize) { + sample[i] = tiles[i]; + } else { + const j = prng.nextInt(0, i + 1); + if (j < sampleSize) sample[j] = tiles[i]; + } + } + + // Compute centroid + let sumX = 0; + let sumY = 0; + for (const t of sample) { + sumX += this.mg.x(t as any); + sumY += this.mg.y(t as any); + } + const cx = sumX / sample.length; + const cy = sumY / sample.length; + + // Snap to the nearest sampled owned tile to keep it on-land and owned + let best = sample[0]; + let bestD2 = Infinity; + for (const t of sample) { + const dx = this.mg.x(t as any) - cx; + const dy = this.mg.y(t as any) - cy; + const d2 = dx * dx + dy * dy; + if (d2 < bestD2) { + best = t; + bestD2 = d2; + } + } + + capital = new Cell(this.mg.x(best as any), this.mg.y(best as any)); + } + + (player as PlayerImpl)._setCapital(capital); + this.lastRecalc.set(player.id(), this.mg.ticks()); + } +} diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 1aec97136..d61f0ec3a 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -568,6 +568,9 @@ export interface Player { lastTileChange(): Tick; + // Capital (geographic center) of the player's territory, if any + capital(): Cell | null; + isDisconnected(): boolean; markDisconnected(isDisconnected: boolean): void; diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index a09886972..9fc8cc279 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -3,6 +3,7 @@ import { EmojiMessage, GameUpdates, Gold, + MapPos, MessageType, NameViewData, PlayerID, @@ -162,6 +163,8 @@ export interface PlayerUpdate { playerType: PlayerType; isAlive: boolean; isDisconnected: boolean; + // Geographic capital (center) of player's territory + capital?: MapPos; tilesOwned: number; gold: Gold; population: number; diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 6212c4a52..251b98635 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -133,6 +133,9 @@ export class PlayerImpl implements Player { private _autoBombingEnabled: boolean = false; public bombersOnTarget = new Map(); + // Cached capital (geographic center) of player's territory + private _capital: Cell | null = null; + constructor( private mg: GameImpl, private _smallID: number, @@ -168,6 +171,10 @@ export class PlayerImpl implements Player { playerType: this.type(), isAlive: this.isAlive(), isDisconnected: this.isDisconnected(), + capital: + this._capital !== null + ? { x: this._capital.x, y: this._capital.y } + : undefined, tilesOwned: this.numTilesOwned(), gold: this._gold, population: this.population(), @@ -1440,6 +1447,15 @@ export class PlayerImpl implements Player { return this._lastTileChange; } + capital(): Cell | null { + return this._capital; + } + + /** Internal setter used by background executions */ + public _setCapital(capital: Cell | null): void { + this._capital = capital; + } + isDisconnected(): boolean { return this._isDisconnected; } From 2d4f5609f9fc7d658b9e2909629a8082a2fedc70 Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Wed, 29 Oct 2025 18:08:13 +0100 Subject: [PATCH 02/20] feat(economy): implement GDP calculation based on population and config factor --- src/core/configuration/Config.ts | 2 ++ src/core/configuration/DefaultConfig.ts | 5 +++++ src/core/game/Game.ts | 2 ++ src/core/game/GameUpdates.ts | 2 ++ src/core/game/GameView.ts | 3 +++ src/core/game/PlayerImpl.ts | 11 +++++++++++ 6 files changed, 25 insertions(+) diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 60687124f..971d15e55 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -125,6 +125,8 @@ export interface Config { proximityBonusPortsNb(totalPorts: number): number; proximityBonusAirfieldsNumber(totalAirfields: number): number; maxPopulation(player: Player | PlayerView): number; + // Multiplier used to compute a player's GDP as: gdpFactor * maxPopulation(player) + gdpFactor(): number; cityPopulationIncrease(): number; boatAttackAmount(attacker: Player, defender: Player | TerraNullius): number; shellLifetime(): number; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 5e2f717f1..4bc3d1a1b 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -1118,6 +1118,11 @@ export class DefaultConfig implements Config { } } + // Multiplier for computing GDP relative to max population + gdpFactor(): number { + return 1.0; + } + populationIncreaseRate(player: Player): number { const max = this.maxPopulation(player); //population grows proportional to current population with growth decreasing as it approaches max diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index d61f0ec3a..c93fbe90f 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -598,6 +598,8 @@ export interface Player { removeProductivity(amount: number): void; investmentRate(): number; // Returns the investment rate (0 to 1) setInvestmentRate(rate: number): void; + // Economic: Gross Domestic Product proxy + gdp(): number; // Computed as config.gdpFactor() * maxPopulation(this) // Roads: investment ratio (0..1) of per-tick income allocated to roads roadInvestmentRate(): number; setRoadInvestmentRate(rate: number): void; diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index 9fc8cc279..27f1fa7b7 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -167,6 +167,8 @@ export interface PlayerUpdate { capital?: MapPos; tilesOwned: number; gold: Gold; + // Economic: GDP proxy = config.gdpFactor() * maxPopulation(player) + gdp: number; population: number; totalPopulation: number; hospitalReturns: number; diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index ea0f820ac..965553500 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -329,6 +329,9 @@ export class PlayerView { gold(): Gold { return this.data.gold; } + gdp(): number { + return this.data.gdp; + } population(): number { return this.data.population; } diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 251b98635..fa4515b7d 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -177,6 +177,7 @@ export class PlayerImpl implements Player { : undefined, tilesOwned: this.numTilesOwned(), gold: this._gold, + gdp: this.gdp(), population: this.population(), totalPopulation: this.totalPopulation(), hospitalReturns: this.hospitalReturns(), @@ -270,6 +271,16 @@ export class PlayerImpl implements Player { return this.playerInfo.playerType; } + // Economic: GDP proxy as parameter * max population + gdp(): number { + const factor = this.mg.config().gdpFactor(); + const maxPop = this.mg.config().maxPopulation(this); + const g = factor * maxPop; + // Ensure finite, non-negative number + if (!Number.isFinite(g) || g < 0) return 0; + return Math.floor(g); + } + clan(): string | null { return this.playerInfo.clan; } From 3ea8703159cd62068cec93372fe91a55637a0901 Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Wed, 29 Oct 2025 20:14:37 +0100 Subject: [PATCH 03/20] Implement trade system enhancements and warship interactions - Introduced a centralized trade system with demand accumulation and route management. - Enhanced WarshipExecution to engage trade ships based on war status and embargo rules. - Added trade route metadata to Unit interface for tracking trade route owners and cargo. - Implemented CapturedTradeShipReturnExecution to handle captured trade ships returning to ports. - Updated UnitImpl to manage trade route owners and cargo gold. - Created TradeManagerExecution to manage trade routes, including spawning trade ships and handling replacements. - Added tests for trade manager functionality, including trade completion, interception of neutral ships, and payout verification. --- src/core/GameRunner.ts | 3 + src/core/configuration/Config.ts | 6 + src/core/configuration/DefaultConfig.ts | 18 + src/core/execution/PortExecution.ts | 25 +- src/core/execution/TradeManagerExecution.ts | 583 ++++++++++++++++++++ src/core/execution/WarshipExecution.ts | 225 +++++++- src/core/game/Game.ts | 9 + src/core/game/UnitImpl.ts | 28 + tests/TradeManager.test.ts | 266 +++++++++ tests/Warship.test.ts | 3 +- tests/util/TestServerConfig.ts | 3 +- 11 files changed, 1135 insertions(+), 34 deletions(-) create mode 100644 src/core/execution/TradeManagerExecution.ts create mode 100644 tests/TradeManager.test.ts diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index c4219f58d..ae6688e55 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -3,6 +3,7 @@ import { getConfig } from "./configuration/ConfigLoader"; import { AllianceExpireCheckExecution } from "./execution/alliance/AllianceExpireCheckExecution"; import { CapitalRecalculationExecution } from "./execution/CapitalRecalculationExecution"; import { Executor } from "./execution/ExecutionManager"; +import { TradeManagerExecution } from "./execution/TradeManagerExecution"; import { WinCheckExecution } from "./execution/WinCheckExecution"; import { AllianceImpl } from "./game/AllianceImpl"; import { @@ -244,6 +245,8 @@ export class GameRunner { this.game.addExecution(new AllianceExpireCheckExecution()); // Background: periodically compute player capitals (geographic centers) this.game.addExecution(new CapitalRecalculationExecution()); + // Trade rework: central trade manager for demand/supply/assignment + this.game.addExecution(new TradeManagerExecution()); } public addTurn(turn: Turn): void { diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 971d15e55..2cf58c979 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -152,6 +152,12 @@ export interface Config { upgradeInfo(type: UpgradeType): UpgradeInfo; tradeShipGold(dist: number): Gold; tradeShipSpawnRate(numberOfPorts: number): number; + // Trade rework: gravity-based demand and port-supplied ships + tradeGravityK(): number; // Coefficient K in K * gdp_i * gdp_j / distance + tradeDemandTickInterval(): number; // Ticks between gravity accumulation (default 10) + tradeShipPerPortSupply(): number; // Number of trade ships each port supplies (default 1) + tradeIncomeFixed(): Gold; // Fixed income per completed trade (default 10k) + tradeShipReplacementDelayTicks(): number; // Ticks to generate a new/replacement trade ship (default 600 ~= 60s) cargoTruckSpawnRate(numberOfStructures: number): number; cargoTruckGold(distance: number): Gold; roadUpdatesPerTick(): number; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 4bc3d1a1b..492e556ee 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -352,6 +352,24 @@ export class DefaultConfig implements Config { tradeShipSpawnRate(numberOfPorts: number): number { return Math.round(10 * Math.pow(numberOfPorts, 0.37)); } + // Trade rework parameters + tradeGravityK(): number { + // Tunable coefficient for gravity model demand accumulation + return 1e-7; // conservative default to avoid flooding the queue + } + tradeDemandTickInterval(): number { + return 10; + } + tradeShipPerPortSupply(): number { + return 1; + } + tradeIncomeFixed(): Gold { + return BigInt(10_000); + } + tradeShipReplacementDelayTicks(): number { + // Assume ~10 ticks/sec => 600 ticks ~= 60s + return 600; + } // Roads and Cargo Trucks diff --git a/src/core/execution/PortExecution.ts b/src/core/execution/PortExecution.ts index bb9207a3d..89591b96f 100644 --- a/src/core/execution/PortExecution.ts +++ b/src/core/execution/PortExecution.ts @@ -1,7 +1,6 @@ import { Execution, Game, Player, Unit, UnitType } from "../game/Game"; import { TileRef } from "../game/GameMap"; import { PseudoRandom } from "../PseudoRandom"; -import { TradeShipExecution } from "./TradeShipExecution"; export class PortExecution implements Execution { private active = true; @@ -56,26 +55,10 @@ export class PortExecution implements Execution { return; } - const totalEffectivePorts = this.mg - .players() - .reduce((sum, p) => sum + p.effectiveUnits(UnitType.Port), 0); - - if ( - !this.random.chance( - this.mg.config().tradeShipSpawnRate(totalEffectivePorts), - ) - ) { - return; - } - - const ports = this.player.tradingPorts(this.port); - - if (ports.length === 0) { - return; - } - - const port = this.random.randElement(ports); - this.mg.addExecution(new TradeShipExecution(this.player, this.port, port)); + // Trade rework: trade ships are assigned by TradeManager; ports no longer + // spawn spontaneous trade routes here. Keep this execution responsible for + // ensuring the port exists and remains active. + return; } isActive(): boolean { diff --git a/src/core/execution/TradeManagerExecution.ts b/src/core/execution/TradeManagerExecution.ts new file mode 100644 index 000000000..88fcc14b2 --- /dev/null +++ b/src/core/execution/TradeManagerExecution.ts @@ -0,0 +1,583 @@ +import { + Cell, + Execution, + Game, + Player, + PlayerType, + Tick, + Unit, + UnitType, +} from "../game/Game"; +import { TileRef } from "../game/GameMap"; +import { PathFindResultType } from "../pathfinding/AStar"; +import { PathFinder } from "../pathfinding/PathFinding"; + +type PairKey = string; // `${fromId}->${toId}` + +interface DemandRoute { + from: Player; + to: Player; +} + +/** + * Centralized trade system: + * - Accumulates bilateral demand via gravity model every N ticks + * - Maintains a FIFO demand queue; when demand >= 1, enqueues route + * - Each port supplies X trade ships (default 1) available for assignment + * - Assigns routes to available ships, moving them to start port then to end port + * - On completion, awards fixed income split between both traders and the ship owner + * - Handles replacement timers for new/lost trade ships per port + */ +export class TradeManagerExecution implements Execution { + private mg!: Game; + private active = true; + private lastDemandTick: Tick = -1; + private demand: Map = new Map(); + private queue: DemandRoute[] = []; + // Port -> replacement due tick (if scheduled) + private replacementDueAt: Map = new Map(); + // Track trade ships to detect losses (capture/deletion) and their home ports + private shipOwnerById: Map = new Map(); + private shipHomePortById: Map = new Map(); + private knownPortIds: Set = new Set(); + + init(mg: Game, _ticks: number): void { + this.mg = mg; + } + + isActive(): boolean { + return this.active; + } + + activeDuringSpawnPhase(): boolean { + return false; + } + + tick(ticks: number): void { + if (!this.active) return; + + // 1) Periodic gravity-based demand accumulation + const interval = this.mg.config().tradeDemandTickInterval(); + if (this.lastDemandTick === -1) this.lastDemandTick = ticks; + if (ticks - this.lastDemandTick >= interval) { + this.lastDemandTick = ticks; + this.accumulateDemand(); + } + + // 2) Maintain per-port replacement timers and spawn replacements when due + this.processPortSupply(ticks); + + // 3) Drop any queued routes that are now embargoed + this.pruneEmbargoedRoutes(); + + // 4) Assign ships to queued routes when available + this.assignRoutes(); + } + + private playersForTrade(): Player[] { + // Consider all non-bot players who currently have at least one port, + // regardless of territory (alive()). This aligns with spec: exclude bots. + return this.mg + .players() + .filter( + (p) => p.type() !== PlayerType.Bot && p.units(UnitType.Port).length > 0, + ); + } + + private pruneEmbargoedRoutes(): void { + if (this.queue.length === 0) return; + this.queue = this.queue.filter(({ from, to }) => { + // Remove routes where either side embargoes the other + return !(from.hasEmbargoAgainst(to) || to.hasEmbargoAgainst(from)); + }); + } + + private key(from: Player, to: Player): PairKey { + return `${from.id()}->${to.id()}`; + } + + private accumulateDemand(): void { + const K = this.mg.config().tradeGravityK(); + const players = this.playersForTrade(); + for (let i = 0; i < players.length; i++) { + for (let j = 0; j < players.length; j++) { + if (i === j) continue; + const a = players[i]; + const b = players[j]; + // If either side has an embargo against the other, demand is zero + if (a.hasEmbargoAgainst(b) || b.hasEmbargoAgainst(a)) { + // Keep fractional demand at 0 for this pair + this.demand.set(this.key(a, b), 0); + continue; + } + const capA = a.capital(); + const capB = b.capital(); + if (capA === null || capB === null) continue; + + const dist = this.capitalDistance(capA, capB); + if (dist <= 0) continue; + const demandDelta = (K * a.gdp() * b.gdp()) / dist; + const k = this.key(a, b); + const prev = this.demand.get(k) ?? 0; + const next = prev + demandDelta; + // Enqueue integer demand, keep fractional remainder + if (next >= 1) { + const count = Math.floor(next); + for (let c = 0; c < count; c++) { + this.queue.push({ from: a, to: b }); + } + this.demand.set(k, next - count); + } else { + this.demand.set(k, next); + } + } + } + } + + private capitalDistance(a: Cell, b: Cell): number { + const refA = this.mg.ref(a.x, a.y); + const refB = this.mg.ref(b.x, b.y); + return Math.sqrt(this.mg.euclideanDistSquared(refA, refB)); + } + + private processPortSupply(ticks: Tick): void { + const perPort = this.mg.config().tradeShipPerPortSupply(); + const delay = this.mg.config().tradeShipReplacementDelayTicks(); + + // 1) Update current home-port assignments and track current owners + const currentShipIds = new Set(); + for (const ship of this.mg.units(UnitType.TradeShip)) { + if (!ship.isActive()) continue; + const sid = ship.id(); + currentShipIds.add(sid); + const prevOwner = this.shipOwnerById.get(sid); + const currOwner = ship.owner(); + if (prevOwner && prevOwner !== currOwner) { + // Captured by another nation -> schedule replacement for its last known home port + const homePortId = this.shipHomePortById.get(sid); + if (homePortId !== undefined) { + const port = this.mg + .units(UnitType.Port) + .find((p) => p.id() === homePortId && p.isActive()); + if (port && this.activeHomeSupplyCount(port) < perPort) { + if (!this.replacementDueAt.has(homePortId)) { + this.replacementDueAt.set(homePortId, ticks + delay); + } + } + } + // Clear home assignment after capture + this.shipHomePortById.delete(sid); + } + this.shipOwnerById.set(sid, currOwner); + + // If idle and docked at own port, assign/update home port + if (ship.targetUnit() === undefined) { + const dockPort = this.mg + .unitsAt(ship.tile()) + .find((u) => u.type() === UnitType.Port && u.owner() === currOwner); + if (dockPort) { + this.shipHomePortById.set(sid, dockPort.id()); + } + } + } + // Detect deletions (sunk etc.) -> schedule replacement at last known home port + for (const [sid, prevOwner] of Array.from(this.shipOwnerById.entries())) { + if (!currentShipIds.has(sid)) { + const homePortId = this.shipHomePortById.get(sid); + if (homePortId !== undefined) { + const port = this.mg + .units(UnitType.Port) + .find((p) => p.id() === homePortId && p.isActive()); + if (port && this.activeHomeSupplyCount(port) < perPort) { + if (!this.replacementDueAt.has(homePortId)) { + this.replacementDueAt.set(homePortId, ticks + delay); + } + } + } + this.shipOwnerById.delete(sid); + this.shipHomePortById.delete(sid); + } + } + + // 2) Handle new ports: schedule initial supply if needed + const currentPortIds = new Set(); + for (const port of this.mg.units(UnitType.Port)) { + if (!port.isActive()) continue; + currentPortIds.add(port.id()); + if (!this.knownPortIds.has(port.id())) { + // New port detected + if (this.activeHomeSupplyCount(port) < perPort) { + if (!this.replacementDueAt.has(port.id())) { + this.replacementDueAt.set(port.id(), ticks + delay); + } + } + this.knownPortIds.add(port.id()); + } + } + // Clear ports that no longer exist + for (const pid of Array.from(this.knownPortIds)) { + if (!currentPortIds.has(pid)) this.knownPortIds.delete(pid); + } + + // 3) Spawn replacements that are due (but only if still below target supply) + for (const [portID, due] of Array.from(this.replacementDueAt.entries())) { + if (ticks < due) continue; + const port = this.mg + .units(UnitType.Port) + .find((p) => p.id() === portID && p.isActive()); + if (!port) { + this.replacementDueAt.delete(portID); + continue; + } + if (this.activeHomeSupplyCount(port) >= perPort) { + // Supply already satisfied; drop schedule + this.replacementDueAt.delete(portID); + continue; + } + const owner = port.owner(); + const spawn = owner.canBuild(UnitType.TradeShip, port.tile()); + if (spawn !== false) { + const newShip = owner.buildUnit(UnitType.TradeShip, spawn, { + targetUnit: port, + }); + // Immediately clear target to mark the ship as idle/available at the port + newShip.setTargetUnit(undefined); + // Assign home to this port + this.shipOwnerById.set(newShip.id(), newShip.owner()); + this.shipHomePortById.set(newShip.id(), portID); + } + // Whether it succeeded or not, reset timer to avoid spamming + this.replacementDueAt.delete(portID); + } + } + + private selectRandomPort(player: Player): Unit | null { + const ports = player.units(UnitType.Port).filter((p) => p.isActive()); + if (ports.length === 0) return null; + const idx = Math.floor(Math.random() * ports.length); + return ports[idx]; + } + + private availableShips(): Unit[] { + const ships: Unit[] = []; + for (const ship of this.mg.units(UnitType.TradeShip)) { + if (!ship.isActive()) continue; + // Idle and docked: considered available + if (ship.targetUnit() !== undefined) continue; + // Only when docked at a port owned by the ship owner + const isDockedAtOwnPort = this.mg + .unitsAt(ship.tile()) + .some((u) => u.type() === UnitType.Port && u.owner() === ship.owner()); + if (!isDockedAtOwnPort) continue; + ships.push(ship); + } + return ships; + } + + private activeHomeSupplyCount(port: Unit): number { + let count = 0; + const pid = port.id(); + for (const ship of this.mg.units(UnitType.TradeShip)) { + if (!ship.isActive()) continue; + if (ship.owner() !== port.owner()) continue; + if (this.shipHomePortById.get(ship.id()) === pid) count++; + } + return count; + } + + private assignRoutes(): void { + if (this.queue.length === 0) return; + const available = this.availableShips(); + if (available.length === 0) return; + + // Take the next route in FIFO order but only if both endpoints have ports + // If not possible, keep it in queue for later. + const next = this.queue[0]; + const startPort = this.selectRandomPort(next.from); + const endPort = this.selectRandomPort(next.to); + if (!startPort || !endPort) return; + + // Pick a random available ship (uniform across ships) — equivalent to + // weighting owners by number of ships, but simpler and less redundant. + const ship = available[Math.floor(Math.random() * available.length)]; + + // Assign: set target to start port if not already there; an execution will handle move + this.queue.shift(); + this.mg.addExecution( + new AssignedTradeRouteExecution(ship, startPort, endPort), + ); + } +} + +export class AssignedTradeRouteExecution implements Execution { + private mg!: Game; + private path!: PathFinder; + private active = true; + private phase: "toStart" | "toEnd" = "toStart"; + private lastMoveTick = 0; + private lastPort: Unit | null = null; + + constructor( + private ship: Unit, + private startPort: Unit, + private endPort: Unit, + ) {} + + init(mg: Game, ticks: number): void { + this.mg = mg; + this.path = PathFinder.Mini(mg, 2500); + this.lastMoveTick = ticks; + // Store route owners on the ship for warship logic + this.ship.setTradeRouteOwners(this.startPort.owner(), this.endPort.owner()); + // Load cargo equal to the route's fixed income; used if captured and returned + this.ship.setCargoGold(this.mg.config().tradeIncomeFixed()); + // Record last port visited at assignment time (if currently docked) + const dockPort = this.mg + .unitsAt(this.ship.tile()) + .find((u) => u.type() === UnitType.Port) as Unit | undefined; + this.lastPort = dockPort ?? null; + + // Log assignment for human-owned trade ships + if (this.ship.owner().type() === PlayerType.Human) { + const ownerName = this.ship.owner().displayName(); + const fromOwner = this.startPort.owner().displayName(); + const toOwner = this.endPort.owner().displayName(); + const startId = this.startPort.id(); + const endId = this.endPort.id(); + const shipId = this.ship.id(); + console.log( + `[TRADE] Ship #${shipId} (${ownerName}) ASSIGNED: from ${fromOwner} (Port #${startId}) to ${toOwner} (Port #${endId})`, + ); + } + + if (this.ship.tile() !== this.startPort.tile()) { + this.ship.setTargetUnit(this.startPort); + this.phase = "toStart"; + } else { + // If already at start, note it for human debugging + if (this.ship.owner().type() === PlayerType.Human) { + const ownerName = this.ship.owner().displayName(); + const fromOwner = this.startPort.owner().displayName(); + const shipId = this.ship.id(); + console.log( + `[TRADE] Ship #${shipId} (${ownerName}) already at START port (${fromOwner}); departing towards destination...`, + ); + } + this.ship.setTargetUnit(this.endPort); + this.phase = "toEnd"; + } + } + + isActive(): boolean { + return this.active; + } + + activeDuringSpawnPhase(): boolean { + return false; + } + + tick(ticks: number): void { + if (!this.active) return; + if (!this.ship.isActive()) { + this.active = false; + return; + } + // Determine where this ship should be heading this tick + let expectedTargetUnit = + this.phase === "toStart" ? this.startPort : this.endPort; + if (this.ship.returning()) { + expectedTargetUnit = this.lastPort ?? this.startPort; + } + // If some external order changed the target while not returning, stop this assignment + if ( + !this.ship.returning() && + this.ship.targetUnit() !== expectedTargetUnit + ) { + this.active = false; + return; + } + // Ensure the ship's target matches the expected target we will navigate to + if (this.ship.targetUnit() !== expectedTargetUnit) { + this.ship.setTargetUnit(expectedTargetUnit); + } + + // Move at default cadence (every tick) + if (ticks - this.lastMoveTick < 1) return; + this.lastMoveTick = ticks; + + const targetTile: TileRef = expectedTargetUnit.tile(); + + // If adjacent to expected target port, dock onto the port tile and handle arrival + if (this.mg.manhattanDist(this.ship.tile(), targetTile) === 1) { + this.ship.move(targetTile); + // Update lastPort upon docking + const portHere = this.mg + .unitsAt(targetTile) + .find((u) => u.type() === UnitType.Port) as Unit | undefined; + if (portHere) this.lastPort = portHere; + // Log arrival for human-owned trade ships + if (this.ship.owner().type() === PlayerType.Human && portHere) { + const ownerName = this.ship.owner().displayName(); + const shipId = this.ship.id(); + const portOwner = portHere.owner().displayName(); + const portId = portHere.id(); + if (this.ship.returning()) { + console.log( + `[TRADE] Ship #${shipId} (${ownerName}) RETURNED to ${portOwner} (Port #${portId}) after turnaround`, + ); + } else if (this.phase === "toStart") { + const toOwner = this.endPort.owner().displayName(); + console.log( + `[TRADE] Ship #${shipId} (${ownerName}) ARRIVED at START port ${portOwner} (Port #${portId}); heading to ${toOwner}`, + ); + } else { + console.log( + `[TRADE] Ship #${shipId} (${ownerName}) ARRIVED at END port ${portOwner} (Port #${portId}); trade completed`, + ); + } + } + if (this.ship.returning()) { + // Cancel route on return to last port + this.ship.setTargetUnit(undefined); + this.ship.setTradeRouteOwners(null, null); + this.ship.setCargoGold(0n); + this.active = false; + return; + } + if (this.phase === "toStart") { + // Arrived at start; proceed to end + this.phase = "toEnd"; + this.ship.setTargetUnit(this.endPort); + return; + } + // Arrived at end + this.complete(); + return; + } + + // Compute a navigable water target near the destination port + const navTarget = this.navTargetForPort(targetTile); + if (navTarget === null) { + // Cannot navigate to this port (no adjacent water). Cancel. + this.active = false; + return; + } + + // If on land (port tile), step into adjacent ocean first + if (!this.mg.isOcean(this.ship.tile())) { + const step = this.stepIntoOceanTowards(navTarget); + if (step !== null) { + this.ship.move(step); + return; + } + this.active = false; + return; + } + + if (this.ship.tile() === targetTile) { + // Ensure lastPort is set if we're already on the port tile + if (!this.lastPort) { + const portHere = this.mg + .unitsAt(targetTile) + .find((u) => u.type() === UnitType.Port) as Unit | undefined; + if (portHere) this.lastPort = portHere; + } + // Log arrival for human-owned ships even if already on tile (edge case) + if (this.ship.owner().type() === PlayerType.Human && this.lastPort) { + const ownerName = this.ship.owner().displayName(); + const shipId = this.ship.id(); + const portOwner = this.lastPort.owner().displayName(); + const portId = this.lastPort.id(); + if (this.ship.returning()) { + console.log( + `[TRADE] Ship #${shipId} (${ownerName}) RETURNED to ${portOwner} (Port #${portId}) after turnaround`, + ); + } else if (this.phase === "toStart") { + const toOwner = this.endPort.owner().displayName(); + console.log( + `[TRADE] Ship #${shipId} (${ownerName}) ARRIVED at START port ${portOwner} (Port #${portId}); heading to ${toOwner}`, + ); + } else { + console.log( + `[TRADE] Ship #${shipId} (${ownerName}) ARRIVED at END port ${portOwner} (Port #${portId}); trade completed`, + ); + } + } + if (this.ship.returning()) { + this.ship.setTargetUnit(undefined); + this.active = false; + return; + } + if (this.phase === "toStart") { + this.phase = "toEnd"; + this.ship.setTargetUnit(this.endPort); + return; + } + this.complete(); + return; + } + + const res = this.path.nextTile(this.ship.tile(), navTarget); + switch (res.type) { + case PathFindResultType.Completed: + this.ship.move(navTarget); + break; + case PathFindResultType.Pending: + this.ship.move(this.ship.tile()); + break; + case PathFindResultType.NextTile: + this.ship.move(res.node); + break; + case PathFindResultType.PathNotFound: + this.active = false; + break; + } + } + + private complete(): void { + // Award fixed income split between traders and ship owner + const total = this.mg.config().tradeIncomeFixed(); + const third = total / 3n; + const remainder = total - third * 3n; + const owner = this.ship.owner(); + const a = this.startPort.owner(); + const b = this.endPort.owner(); + a.addGold(third); + b.addGold(third); + owner.addGold(third + remainder); + + this.ship.setTargetUnit(undefined); + this.ship.setTradeRouteOwners(null, null); + this.ship.setCargoGold(0n); + this.active = false; + } + + // Pick an ocean tile adjacent to the port (targetTile) as navigation target + private navTargetForPort(portTile: TileRef): TileRef | null { + if (this.mg.isOcean(portTile)) return portTile; + const candidates = this.mg + .neighbors(portTile) + .filter((t) => this.mg.isOcean(t)); + if (candidates.length === 0) return null; + candidates.sort( + (a, b) => + this.mg.manhattanDist(this.ship.tile(), a) - + this.mg.manhattanDist(this.ship.tile(), b), + ); + return candidates[0]; + } + + // If the ship is on land (port tile), take one step into ocean toward navTarget + private stepIntoOceanTowards(navTarget: TileRef): TileRef | null { + const oceanNeighbors = this.mg + .neighbors(this.ship.tile()) + .filter((t) => this.mg.isOcean(t)); + if (oceanNeighbors.length === 0) return null; + oceanNeighbors.sort( + (a, b) => + this.mg.manhattanDist(a, navTarget) - + this.mg.manhattanDist(b, navTarget), + ); + return oceanNeighbors[0]; + } +} diff --git a/src/core/execution/WarshipExecution.ts b/src/core/execution/WarshipExecution.ts index ec678d778..ad3b56d8f 100644 --- a/src/core/execution/WarshipExecution.ts +++ b/src/core/execution/WarshipExecution.ts @@ -3,6 +3,7 @@ import { Game, isUnit, OwnerComp, + Player, Unit, UnitParams, UnitType, @@ -111,19 +112,40 @@ export class WarshipExecution implements Execution { ) { continue; } - // Only engage if at war with the target's owner - if (!this.warship.owner().isAtWarWith(unit.owner())) { - continue; + // Decide engagement rules per unit type + if (unit.type() === UnitType.TradeShip) { + const shipOwner = unit.owner(); + const myOwner = this.warship.owner(); + const startOwner = unit.tradeRouteStartOwner(); + const endOwner = unit.tradeRouteEndOwner(); + const atWarWithAnyEndpoint = [startOwner, endOwner] + .filter((p): p is Player => !!p) + .some((p) => myOwner.isAtWarWith(p)); + + const embargoAgainstAnyEndpoint = [startOwner, endOwner] + .filter((p): p is Player => !!p) + .some( + (p) => myOwner.hasEmbargoAgainst(p) || p.hasEmbargoAgainst(myOwner), + ); + + const canTargetTrade = + myOwner.isAtWarWith(shipOwner) || + ((atWarWithAnyEndpoint || embargoAgainstAnyEndpoint) && + !myOwner.isFriendly(shipOwner)); + if (!canTargetTrade) { + continue; + } + } else { + // Non-trade ships: only target if at war with owner + if (!this.warship.owner().isAtWarWith(unit.owner())) { + continue; + } } if (unit.type() === UnitType.TradeShip) { - if ( - !hasPort || - unit.isSafeFromPirates() || - unit.targetUnit()?.owner() === this.warship.owner() || // trade ship is coming to my port - unit.targetUnit()?.owner().isFriendly(this.warship.owner()) // trade ship is coming to my ally - ) { + if (!hasPort || unit.isSafeFromPirates()) { continue; } + // Keep patrol range constraint for trade ships if ( this.mg.euclideanDistSquared( this.warship.patrolTile()!, @@ -241,11 +263,49 @@ export class WarshipExecution implements Execution { 5, ); switch (result.type) { - case PathFindResultType.Completed: - this.warship.owner().captureUnit(this.warship.targetUnit()!); + case PathFindResultType.Completed: { + const targetShip = this.warship.targetUnit()!; + const myOwner = this.warship.owner(); + const shipOwner = targetShip.owner(); + if (myOwner.isAtWarWith(shipOwner)) { + // Enemy trade ship -> capture + this.warship.owner().captureUnit(targetShip); + // Clear any trade route metadata on the captured ship + targetShip.setTradeRouteOwners(null, null); + // Send captured ship back to a home port of the new owner; cargo will be awarded on arrival + this.mg.addExecution( + new CapturedTradeShipReturnExecution(targetShip), + ); + this.warship.setTargetUnit(undefined); + this.warship.move(this.warship.tile()); + return; + } + // Neutral trade ship headed to/from my enemy -> turn around upon contact + const startOwner = targetShip.tradeRouteStartOwner(); + const endOwner = targetShip.tradeRouteEndOwner(); + const atWarWithAnyEndpoint = [startOwner, endOwner] + .filter((p): p is Player => !!p) + .some((p) => myOwner.isAtWarWith(p)); + const embargoAgainstAnyEndpoint = [startOwner, endOwner] + .filter((p): p is Player => !!p) + .some( + (p) => + myOwner.hasEmbargoAgainst(p) || p.hasEmbargoAgainst(myOwner), + ); + if ( + (atWarWithAnyEndpoint || embargoAgainstAnyEndpoint) && + !myOwner.isFriendly(shipOwner) + ) { + targetShip.setReturning(true); + this.warship.setTargetUnit(undefined); + this.warship.move(this.warship.tile()); + return; + } + // Otherwise, disengage this.warship.setTargetUnit(undefined); this.warship.move(this.warship.tile()); return; + } case PathFindResultType.NextTile: this.warship.move(result.node); break; @@ -420,3 +480,146 @@ export class WarshipExecution implements Execution { } } } + +class CapturedTradeShipReturnExecution implements Execution { + private mg!: Game; + private pathfinder!: PathFinder; + private active = true; + private lastMoveTick = 0; + private destPort: Unit | null = null; + + constructor(private ship: Unit) {} + + init(mg: Game, ticks: number): void { + this.mg = mg; + this.pathfinder = PathFinder.Mini(mg, 2500); + this.lastMoveTick = ticks; + // Pick nearest active port owned by the ship's owner + this.destPort = this.selectNearestPort(this.ship.owner()); + if (this.destPort) { + this.ship.setTargetUnit(this.destPort); + } else { + // No port to return to; cancel + this.active = false; + } + } + + isActive(): boolean { + return this.active; + } + + activeDuringSpawnPhase(): boolean { + return false; + } + + tick(ticks: number): void { + if (!this.active) return; + if (!this.ship.isActive()) { + this.active = false; + return; + } + if (!this.destPort || !this.destPort.isActive()) { + // Destination no longer valid; try to pick another + this.destPort = this.selectNearestPort(this.ship.owner()); + if (!this.destPort) { + this.active = false; + return; + } + this.ship.setTargetUnit(this.destPort); + } + + if (ticks - this.lastMoveTick < 1) return; + this.lastMoveTick = ticks; + + const targetTile = this.destPort.tile(); + if (this.mg.manhattanDist(this.ship.tile(), targetTile) === 1) { + // Dock + this.ship.move(targetTile); + const cargo = this.ship.cargoGold(); + if (cargo > 0n) { + this.ship.owner().addGold(cargo); + this.ship.setCargoGold(0n); + } + this.ship.setTargetUnit(undefined); + this.active = false; + return; + } + + const navTarget = this.navTargetForPort(targetTile); + if (navTarget === null) { + this.active = false; + return; + } + + // If on land (port tile), step into adjacent ocean first + if (!this.mg.isOcean(this.ship.tile())) { + const step = this.stepIntoOceanTowards(navTarget); + if (step !== null) { + this.ship.move(step); + return; + } + this.active = false; + return; + } + + const res = this.pathfinder.nextTile(this.ship.tile(), navTarget); + switch (res.type) { + case PathFindResultType.Completed: + this.ship.move(navTarget); + break; + case PathFindResultType.NextTile: + this.ship.move(res.node); + break; + case PathFindResultType.Pending: + this.ship.touch(); + break; + case PathFindResultType.PathNotFound: + this.active = false; + break; + } + } + + private selectNearestPort(owner: Player): Unit | null { + const ports = this.mg + .units(UnitType.Port) + .filter((p) => p.owner() === owner && p.isActive()); + if (ports.length === 0) return null; + let best: Unit | null = null; + let bestDist = Number.POSITIVE_INFINITY; + for (const p of ports) { + const d = this.mg.euclideanDistSquared(this.ship.tile(), p.tile()); + if (d < bestDist) { + bestDist = d; + best = p; + } + } + return best; + } + + private navTargetForPort(portTile: TileRef): TileRef | null { + if (this.mg.isOcean(portTile)) return portTile; + const candidates = this.mg + .neighbors(portTile) + .filter((t) => this.mg.isOcean(t)); + if (candidates.length === 0) return null; + candidates.sort( + (a, b) => + this.mg.manhattanDist(this.ship.tile(), a) - + this.mg.manhattanDist(this.ship.tile(), b), + ); + return candidates[0]; + } + + private stepIntoOceanTowards(navTarget: TileRef): TileRef | null { + const oceanNeighbors = this.mg + .neighbors(this.ship.tile()) + .filter((t) => this.mg.isOcean(t)); + if (oceanNeighbors.length === 0) return null; + oceanNeighbors.sort( + (a, b) => + this.mg.manhattanDist(a, navTarget) - + this.mg.manhattanDist(b, navTarget), + ); + return oceanNeighbors[0]; + } +} diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index c93fbe90f..946d8d8ba 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -514,6 +514,15 @@ export interface Unit { setSafeFromPirates(): void; // Only for trade ships isSafeFromPirates(): boolean; // Only for trade ships + // Trade route metadata (used primarily by trade ships) + setTradeRouteOwners(startOwner: Player | null, endOwner: Player | null): void; + tradeRouteStartOwner(): Player | null; + tradeRouteEndOwner(): Player | null; + + // Trade ship cargo (gold carried during a route; awarded on capture return) + setCargoGold(amount: Gold): void; + cargoGold(): Gold; + // Construction constructionType(): UnitType | null; setConstructionType(type: UnitType): void; diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index 6ada6de7a..2372700fe 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -57,6 +57,12 @@ export class UnitImpl implements Unit { private _trajectoryIndex: number = 0; private _trajectory: TrajectoryTile[]; + // Trade-ship specific: route owners for warship consideration + private _tradeRouteStartOwner: PlayerImpl | null = null; + private _tradeRouteEndOwner: PlayerImpl | null = null; + // Trade-ship specific: cargo carried (gold) + private _cargoGold: bigint = 0n; + constructor( private _type: UnitType, private mg: GameImpl, @@ -649,4 +655,26 @@ export class UnitImpl implements Unit { this.mg.config().safeFromPiratesCooldownMax() ); } + + // Trade route metadata API + setTradeRouteOwners( + startOwner: PlayerImpl | null, + endOwner: PlayerImpl | null, + ): void { + this._tradeRouteStartOwner = startOwner; + this._tradeRouteEndOwner = endOwner; + } + tradeRouteStartOwner(): PlayerImpl | null { + return this._tradeRouteStartOwner; + } + tradeRouteEndOwner(): PlayerImpl | null { + return this._tradeRouteEndOwner; + } + + setCargoGold(amount: bigint): void { + this._cargoGold = amount; + } + cargoGold(): bigint { + return this._cargoGold; + } } diff --git a/tests/TradeManager.test.ts b/tests/TradeManager.test.ts new file mode 100644 index 000000000..a9e7ce5d5 --- /dev/null +++ b/tests/TradeManager.test.ts @@ -0,0 +1,266 @@ +import { CapitalRecalculationExecution } from "../src/core/execution/CapitalRecalculationExecution"; +import { + AssignedTradeRouteExecution, + TradeManagerExecution, +} from "../src/core/execution/TradeManagerExecution"; +import { WarshipExecution } from "../src/core/execution/WarshipExecution"; +import { + Cell, + Game, + Player, + PlayerInfo, + PlayerType, + Unit, + UnitType, +} from "../src/core/game/Game"; +import { setup } from "./util/Setup"; +import { executeTicks } from "./util/utils"; + +const coastX = 7; + +let game: Game; +let a: Player; // trader A +let b: Player; // trader B +let c: Player; // third-party ship owner +let w: Player; // warship owner + +function goldOf(p: Player): bigint { + return p.gold(); +} + +function buildPort(p: Player, x: number, y: number): Unit { + const port = p.buildUnit(UnitType.Port, game.ref(x, y), {}); + return port; +} + +function findDockedShipAt(tile: number, owner: Player): Unit | undefined { + return game + .unitsAt(tile) + .find((u) => u.type() === UnitType.TradeShip && u.owner() === owner); +} + +describe("Trade Manager", () => { + beforeEach(async () => { + game = await setup( + "half_land_half_ocean", + { + infiniteGold: true, + instantBuild: true, + }, + [ + new PlayerInfo("us", "A", PlayerType.Human, null, "a"), + new PlayerInfo("us", "B", PlayerType.Human, null, "b"), + new PlayerInfo("us", "C", PlayerType.Human, null, "c"), + new PlayerInfo("us", "W", PlayerType.Human, null, "w"), + ], + ); + + while (game.inSpawnPhase()) game.executeNextTick(); + + a = game.player("a"); + b = game.player("b"); + c = game.player("c"); + w = game.player("w"); + + // Speed up trade system for tests + game.config().tradeDemandTickInterval = () => 1; + game.config().tradeGravityK = () => 1; // strong to enqueue quickly + game.config().tradeShipPerPortSupply = () => 1; + game.config().tradeIncomeFixed = () => 10_000n; + game.config().tradeShipReplacementDelayTicks = () => 1; + + // Background systems necessary for trade manager + game.addExecution(new CapitalRecalculationExecution()); + game.addExecution(new TradeManagerExecution()); + }); + + test("completes trade, pays split, and docks ship available", async () => { + const portA = buildPort(a, coastX, 10); + const portB = buildPort(b, coastX + 5, 10); + // Ensure capitals exist so gravity demand accumulates + (a as any)._setCapital(new Cell(coastX, 10)); + (b as any)._setCapital(new Cell(coastX + 5, 10)); + (c as any)._setCapital(new Cell(coastX + 2, 10)); + // Provide initial ship supply (dock and available) + const aShip = a.buildUnit(UnitType.TradeShip, portA.tile(), { + targetUnit: portA, + }); + aShip.setTargetUnit(undefined); + const bShip = b.buildUnit(UnitType.TradeShip, portB.tile(), { + targetUnit: portB, + }); + bShip.setTargetUnit(undefined); + executeTicks(game, 1); + + const goldA0 = goldOf(a); + const goldB0 = goldOf(b); + const goldC0 = goldOf(c); + + // Directly assign a route for determinism + const assigned = aShip ?? bShip; + expect(assigned).toBeDefined(); + if (!assigned) return; + game.addExecution(new AssignedTradeRouteExecution(assigned, portA, portB)); + + // Use generous ticks to allow move-to-start and then to end + executeTicks(game, 200); + + const goldA1 = goldOf(a); + const goldB1 = goldOf(b); + const goldC1 = goldOf(c); + + const deltaA = Number(goldA1 - goldA0); + const deltaB = Number(goldB1 - goldB0); + const deltaC = Number(goldC1 - goldC0); + + const total = deltaA + deltaB + deltaC; + expect(total).toBe(10_000); + // Each trader should receive at least one share (3,333) but could receive two if also ship owner + expect(deltaA).toBeGreaterThanOrEqual(3333); + expect(deltaB).toBeGreaterThanOrEqual(3333); + + // Verify a ship is docked and available at one of the end ports + const shipAtA = findDockedShipAt(portA.tile(), a); + const shipAtB = findDockedShipAt(portB.tile(), b); + const anyShip = shipAtA ?? shipAtB; + expect(anyShip).toBeDefined(); + if (anyShip) { + expect(anyShip.targetUnit()).toBeUndefined(); + } + }); + + test("neutral ship to enemy port is turned around; no payout; returns to last port (origin idling port)", async () => { + const portA = buildPort(a, coastX, 10); + const portB = buildPort(b, coastX + 5, 10); + const portC = buildPort(c, coastX + 2, 10); // neutral ship owner supply + // Ensure capitals exist so gravity demand accumulates + (a as any)._setCapital(new Cell(coastX, 10)); + (b as any)._setCapital(new Cell(coastX + 5, 10)); + (c as any)._setCapital(new Cell(coastX + 2, 10)); + + // Warship owner at war with B (destination), neutral with C (ship owner) + w.setWarWith(b); + + // Provide only neutral (C) ship supply and remove A/B ones for determinism + const cShip = c.buildUnit(UnitType.TradeShip, portC.tile(), { + targetUnit: portC, + }); + cShip.setTargetUnit(undefined); + executeTicks(game, 1); + game + .units(UnitType.TradeShip) + .filter((u) => u.owner() === a || u.owner() === b) + .forEach((u) => u.delete(false)); + + // Warship owner needs a port to engage trade ships per rules + buildPort(w, coastX + 3, 10); + // Patrol near middle so warship can intercept + const patrol = game.ref(coastX + 3, 10); + const warship = w.buildUnit(UnitType.Warship, patrol, { + patrolTile: patrol, + }); + game.addExecution(new WarshipExecution(warship)); + + const goldA0 = goldOf(a); + const goldB0 = goldOf(b); + const goldC0 = goldOf(c); + + // Directly assign a route from A to B using neutral ship C + game.addExecution(new AssignedTradeRouteExecution(cShip, portA, portB)); + + // Run long enough for interception and return + executeTicks(game, 250); + + const goldA1 = goldOf(a); + const goldB1 = goldOf(b); + const goldC1 = goldOf(c); + + // No payout should have occurred due to turnaround + expect(goldA1).toBe(goldA0); + expect(goldB1).toBe(goldB0); + expect(goldC1).toBe(goldC0); + + // Ship should be docked back at the last port it was at before assignment (C's idling port) + const dockedAtC = game + .unitsAt(portC.tile()) + .some( + (u) => + u.type() === UnitType.TradeShip && + u.owner() === c && + u.targetUnit() === undefined, + ); + expect(dockedAtC).toBe(true); + }); + + test("neutral ship intercepted after reaching start returns to start port; no payout", async () => { + const portA = buildPort(a, coastX, 10); + const portB = buildPort(b, coastX + 5, 10); + const portC = buildPort(c, coastX + 2, 10); + + // Capitals for demand (not strictly needed since we assign directly) + (a as any)._setCapital(new Cell(coastX, 10)); + (b as any)._setCapital(new Cell(coastX + 5, 10)); + (c as any)._setCapital(new Cell(coastX + 2, 10)); + + // Warship owner at war with B (destination), neutral with C (ship owner) + w.setWarWith(b); + + // Provide only neutral (C) ship supply and remove A/B ones for determinism + const cShip = c.buildUnit(UnitType.TradeShip, portC.tile(), { + targetUnit: portC, + }); + cShip.setTargetUnit(undefined); + executeTicks(game, 1); + game + .units(UnitType.TradeShip) + .filter((u) => u.owner() === a || u.owner() === b) + .forEach((u) => u.delete(false)); + + const goldA0 = goldOf(a); + const goldB0 = goldOf(b); + const goldC0 = goldOf(c); + + // Assign a route from A to B using neutral ship C + game.addExecution(new AssignedTradeRouteExecution(cShip, portA, portB)); + + // Advance until the ship reaches start port A (transition to heading to B) + let guard = 0; + while (cShip.targetUnit() !== portB && guard < 500) { + executeTicks(game, 1); + guard++; + } + // Sanity: should now be en route to end port + expect(cShip.targetUnit()).toBe(portB); + + // Now spawn a warship to intercept after start + buildPort(w, coastX + 3, 10); + const patrol = game.ref(coastX + 3, 10); + const warship = w.buildUnit(UnitType.Warship, patrol, { + patrolTile: patrol, + }); + game.addExecution(new WarshipExecution(warship)); + + // Run long enough for interception and return + executeTicks(game, 250); + + const goldA1 = goldOf(a); + const goldB1 = goldOf(b); + const goldC1 = goldOf(c); + + // No payout should have occurred due to turnaround + expect(goldA1).toBe(goldA0); + expect(goldB1).toBe(goldB0); + expect(goldC1).toBe(goldC0); + + // Ship should be docked back at start port A (last visited port) + const dockedAtA = game + .unitsAt(portA.tile()) + .some( + (u) => + u.type() === UnitType.TradeShip && + u.owner() === c && + u.targetUnit() === undefined, + ); + expect(dockedAtA).toBe(true); + }); +}); diff --git a/tests/Warship.test.ts b/tests/Warship.test.ts index 330e0fd25..5a2db707a 100644 --- a/tests/Warship.test.ts +++ b/tests/Warship.test.ts @@ -125,7 +125,8 @@ describe("Warship", () => { UnitType.TradeShip, game.ref(coastX + 1, 11), { - targetUnit: player1.buildUnit(UnitType.Port, game.ref(coastX, 11), {}), + // Destination should NOT grant the warship owner a port; keep player1 without ports + targetUnit: player2.buildUnit(UnitType.Port, game.ref(coastX, 11), {}), }, ); diff --git a/tests/util/TestServerConfig.ts b/tests/util/TestServerConfig.ts index 4c1f76eb4..639cb9d1e 100644 --- a/tests/util/TestServerConfig.ts +++ b/tests/util/TestServerConfig.ts @@ -29,7 +29,8 @@ export class TestServerConfig implements ServerConfig { return "test"; } turnIntervalMs(): number { - throw new Error("Method not implemented."); + // Keep tests fast; ~10 ticks/second + return 100; } gameCreationRate(): number { throw new Error("Method not implemented."); From 7aeb160eda117e1a8116cc409506e934b02ce044 Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Thu, 30 Oct 2025 00:48:04 +0100 Subject: [PATCH 04/20] feat(trade): update gravity model for trade demand and enhance logging in TradeManagerExecution --- src/core/configuration/Config.ts | 2 +- src/core/configuration/DefaultConfig.ts | 2 +- src/core/execution/TradeManagerExecution.ts | 315 ++++++++++++-------- src/core/pathfinding/MiniAStar.ts | 107 ++++++- 4 files changed, 287 insertions(+), 139 deletions(-) diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 2cf58c979..176257698 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -153,7 +153,7 @@ export interface Config { tradeShipGold(dist: number): Gold; tradeShipSpawnRate(numberOfPorts: number): number; // Trade rework: gravity-based demand and port-supplied ships - tradeGravityK(): number; // Coefficient K in K * gdp_i * gdp_j / distance + tradeGravityK(): number; // Coefficient K in K * gdp_i * gdp_j / distance / world_gdp tradeDemandTickInterval(): number; // Ticks between gravity accumulation (default 10) tradeShipPerPortSupply(): number; // Number of trade ships each port supplies (default 1) tradeIncomeFixed(): Gold; // Fixed income per completed trade (default 10k) diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 492e556ee..cb03b1f99 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -355,7 +355,7 @@ export class DefaultConfig implements Config { // Trade rework parameters tradeGravityK(): number { // Tunable coefficient for gravity model demand accumulation - return 1e-7; // conservative default to avoid flooding the queue + return 1e-4; // conservative default to avoid flooding the queue } tradeDemandTickInterval(): number { return 10; diff --git a/src/core/execution/TradeManagerExecution.ts b/src/core/execution/TradeManagerExecution.ts index 88fcc14b2..ac8ff0219 100644 --- a/src/core/execution/TradeManagerExecution.ts +++ b/src/core/execution/TradeManagerExecution.ts @@ -34,8 +34,12 @@ export class TradeManagerExecution implements Execution { private lastDemandTick: Tick = -1; private demand: Map = new Map(); private queue: DemandRoute[] = []; + // Periodic logger for queue length + private queueLogIntervalId: any; // Port -> replacement due tick (if scheduled) private replacementDueAt: Map = new Map(); + // Track last-known owner for each port to detect captures + private portOwnerById: Map = new Map(); // Track trade ships to detect losses (capture/deletion) and their home ports private shipOwnerById: Map = new Map(); private shipHomePortById: Map = new Map(); @@ -43,6 +47,13 @@ export class TradeManagerExecution implements Execution { init(mg: Game, _ticks: number): void { this.mg = mg; + // Start periodic queue length logging (every 5 seconds) + if (!this.queueLogIntervalId) { + this.queueLogIntervalId = setInterval(() => { + // Keep lightweight and consistent with other [TRADE] logs + console.log(`[TRADE] queue length=${this.queue.length}`); + }, 5000); + } } isActive(): boolean { @@ -85,7 +96,8 @@ export class TradeManagerExecution implements Execution { } private pruneEmbargoedRoutes(): void { - if (this.queue.length === 0) return; + const before = this.queue.length; + if (before === 0) return; this.queue = this.queue.filter(({ from, to }) => { // Remove routes where either side embargoes the other return !(from.hasEmbargoAgainst(to) || to.hasEmbargoAgainst(from)); @@ -98,6 +110,11 @@ export class TradeManagerExecution implements Execution { private accumulateDemand(): void { const K = this.mg.config().tradeGravityK(); + // World GDP = sum of all alive players' GDPs (bots and humans) + const worldGDP = this.mg + .players() + .filter((p) => p.isAlive()) + .reduce((sum, p) => sum + p.gdp(), 0); const players = this.playersForTrade(); for (let i = 0; i < players.length; i++) { for (let j = 0; j < players.length; j++) { @@ -116,7 +133,11 @@ export class TradeManagerExecution implements Execution { const dist = this.capitalDistance(capA, capB); if (dist <= 0) continue; - const demandDelta = (K * a.gdp() * b.gdp()) / dist; + // New gravity model scaling: + // demand += K * gdp_i * gdp_j / distance / world_gdp + // Safeguard zero world GDP + const demandDelta = + worldGDP > 0 ? (K * a.gdp() * b.gdp()) / dist / worldGDP : 0; const k = this.key(a, b); const prev = this.demand.get(k) ?? 0; const next = prev + demandDelta; @@ -140,13 +161,26 @@ export class TradeManagerExecution implements Execution { return Math.sqrt(this.mg.euclideanDistSquared(refA, refB)); } + // (removed) nearestOceanWithin helper was unused after direct undocking implementation + private processPortSupply(ticks: Tick): void { const perPort = this.mg.config().tradeShipPerPortSupply(); const delay = this.mg.config().tradeShipReplacementDelayTicks(); // 1) Update current home-port assignments and track current owners const currentShipIds = new Set(); - for (const ship of this.mg.units(UnitType.TradeShip)) { + const shipsSnapshot = [...this.mg.units(UnitType.TradeShip)]; + for (const ship of shipsSnapshot) { + // Remove trade ships owned by eliminated players + if (!ship.owner().isAlive()) { + // Delete without messages; considered a consequence of elimination + ship.delete(false); + // Clean up tracking + const sid = ship.id(); + this.shipOwnerById.delete(sid); + this.shipHomePortById.delete(sid); + continue; + } if (!ship.isActive()) continue; const sid = ship.id(); currentShipIds.add(sid); @@ -170,14 +204,14 @@ export class TradeManagerExecution implements Execution { } this.shipOwnerById.set(sid, currOwner); - // If idle and docked at own port, assign/update home port + // If idle and docked at own port (on the port tile), assign/update home port if (ship.targetUnit() === undefined) { const dockPort = this.mg .unitsAt(ship.tile()) - .find((u) => u.type() === UnitType.Port && u.owner() === currOwner); - if (dockPort) { - this.shipHomePortById.set(sid, dockPort.id()); - } + .find( + (u) => u.type() === UnitType.Port && u.owner() === currOwner, + ) as Unit | undefined; + if (dockPort) this.shipHomePortById.set(sid, dockPort.id()); } } // Detect deletions (sunk etc.) -> schedule replacement at last known home port @@ -204,6 +238,18 @@ export class TradeManagerExecution implements Execution { for (const port of this.mg.units(UnitType.Port)) { if (!port.isActive()) continue; currentPortIds.add(port.id()); + // Detect ownership change of an existing port + const prevOwner = this.portOwnerById.get(port.id()); + if (prevOwner && prevOwner !== port.owner()) { + // Port captured: ensure new owner can reach per-port supply target + if (this.activeHomeSupplyCount(port) < perPort) { + if (!this.replacementDueAt.has(port.id())) { + this.replacementDueAt.set(port.id(), ticks + delay); + } + } + } + // Track current owner + this.portOwnerById.set(port.id(), port.owner()); if (!this.knownPortIds.has(port.id())) { // New port detected if (this.activeHomeSupplyCount(port) < perPort) { @@ -216,7 +262,10 @@ export class TradeManagerExecution implements Execution { } // Clear ports that no longer exist for (const pid of Array.from(this.knownPortIds)) { - if (!currentPortIds.has(pid)) this.knownPortIds.delete(pid); + if (!currentPortIds.has(pid)) { + this.knownPortIds.delete(pid); + this.portOwnerById.delete(pid); + } } // 3) Spawn replacements that are due (but only if still below target supply) @@ -229,22 +278,29 @@ export class TradeManagerExecution implements Execution { this.replacementDueAt.delete(portID); continue; } + // No adjacency restriction: ships will undock directly to the nearest ocean if (this.activeHomeSupplyCount(port) >= perPort) { // Supply already satisfied; drop schedule this.replacementDueAt.delete(portID); continue; } const owner = port.owner(); - const spawn = owner.canBuild(UnitType.TradeShip, port.tile()); + const requested = port.tile(); + const spawn = owner.canBuild(UnitType.TradeShip, requested); if (spawn !== false) { - const newShip = owner.buildUnit(UnitType.TradeShip, spawn, { - targetUnit: port, - }); - // Immediately clear target to mark the ship as idle/available at the port - newShip.setTargetUnit(undefined); - // Assign home to this port - this.shipOwnerById.set(newShip.id(), newShip.owner()); - this.shipHomePortById.set(newShip.id(), portID); + const hasPortAtSpawn = this.mg + .unitsAt(spawn) + .some((u) => u.type() === UnitType.Port); + if (hasPortAtSpawn) { + const newShip = owner.buildUnit(UnitType.TradeShip, spawn, { + targetUnit: port, + }); + // Immediately clear target to mark the ship as idle/available at the port + newShip.setTargetUnit(undefined); + // Assign home to this port + this.shipOwnerById.set(newShip.id(), newShip.owner()); + this.shipHomePortById.set(newShip.id(), portID); + } } // Whether it succeeded or not, reset timer to avoid spamming this.replacementDueAt.delete(portID); @@ -262,13 +318,15 @@ export class TradeManagerExecution implements Execution { const ships: Unit[] = []; for (const ship of this.mg.units(UnitType.TradeShip)) { if (!ship.isActive()) continue; + // Do not consider ships that are flagged as returning + if (ship.returning()) continue; // Idle and docked: considered available if (ship.targetUnit() !== undefined) continue; - // Only when docked at a port owned by the ship owner - const isDockedAtOwnPort = this.mg + // Consider available if docked at ANY port tile (regardless of owner) + const isDockedAtAnyPort = this.mg .unitsAt(ship.tile()) - .some((u) => u.type() === UnitType.Port && u.owner() === ship.owner()); - if (!isDockedAtOwnPort) continue; + .some((u) => u.type() === UnitType.Port); + if (!isDockedAtAnyPort) continue; ships.push(ship); } return ships; @@ -290,22 +348,28 @@ export class TradeManagerExecution implements Execution { const available = this.availableShips(); if (available.length === 0) return; - // Take the next route in FIFO order but only if both endpoints have ports - // If not possible, keep it in queue for later. - const next = this.queue[0]; - const startPort = this.selectRandomPort(next.from); - const endPort = this.selectRandomPort(next.to); - if (!startPort || !endPort) return; - - // Pick a random available ship (uniform across ships) — equivalent to - // weighting owners by number of ships, but simpler and less redundant. - const ship = available[Math.floor(Math.random() * available.length)]; - - // Assign: set target to start port if not already there; an execution will handle move - this.queue.shift(); - this.mg.addExecution( - new AssignedTradeRouteExecution(ship, startPort, endPort), - ); + // Assign as many routes as possible this tick while ships and routes are available + while (this.queue.length > 0 && available.length > 0) { + // Peek next route; if endpoints invalid, skip it (drop) to avoid blocking + const next = this.queue[0]; + const startPort = this.selectRandomPort(next.from); + const endPort = this.selectRandomPort(next.to); + if (!startPort || !endPort) { + // Can't satisfy this route right now (no ports); drop it + this.queue.shift(); + continue; + } + + // Pick a random available ship (uniform) and remove it from availability for this tick + const idx = Math.floor(Math.random() * available.length); + const [ship] = available.splice(idx, 1); + + // Assign: set target to start port if not already there; execution will handle moves + this.queue.shift(); + this.mg.addExecution( + new AssignedTradeRouteExecution(ship, startPort, endPort), + ); + } } } @@ -327,47 +391,20 @@ export class AssignedTradeRouteExecution implements Execution { this.mg = mg; this.path = PathFinder.Mini(mg, 2500); this.lastMoveTick = ticks; + // Ensure ship is not in a stale 'returning' state from a prior turnaround + this.ship.setReturning(false); // Store route owners on the ship for warship logic this.ship.setTradeRouteOwners(this.startPort.owner(), this.endPort.owner()); // Load cargo equal to the route's fixed income; used if captured and returned this.ship.setCargoGold(this.mg.config().tradeIncomeFixed()); - // Record last port visited at assignment time (if currently docked) + // Record last port visited at assignment time (only if currently on a port tile) const dockPort = this.mg .unitsAt(this.ship.tile()) .find((u) => u.type() === UnitType.Port) as Unit | undefined; this.lastPort = dockPort ?? null; - // Log assignment for human-owned trade ships - if (this.ship.owner().type() === PlayerType.Human) { - const ownerName = this.ship.owner().displayName(); - const fromOwner = this.startPort.owner().displayName(); - const toOwner = this.endPort.owner().displayName(); - const startId = this.startPort.id(); - const endId = this.endPort.id(); - const shipId = this.ship.id(); - console.log( - `[TRADE] Ship #${shipId} (${ownerName}) ASSIGNED: from ${fromOwner} (Port #${startId}) to ${toOwner} (Port #${endId})`, - ); - } - - if (this.ship.tile() !== this.startPort.tile()) { - this.ship.setTargetUnit(this.startPort); - this.phase = "toStart"; - } else { - // If already at start, note it for human debugging - if (this.ship.owner().type() === PlayerType.Human) { - const ownerName = this.ship.owner().displayName(); - const fromOwner = this.startPort.owner().displayName(); - const shipId = this.ship.id(); - console.log( - `[TRADE] Ship #${shipId} (${ownerName}) already at START port (${fromOwner}); departing towards destination...`, - ); - } - this.ship.setTargetUnit(this.endPort); - this.phase = "toEnd"; - } + // (removed) assignment-time debug logs } - isActive(): boolean { return this.active; } @@ -388,9 +425,40 @@ export class AssignedTradeRouteExecution implements Execution { if (this.ship.returning()) { expectedTargetUnit = this.lastPort ?? this.startPort; } + // If the DESTINATION (expected target) was destroyed: + // - If not already returning, turn around (return to last port if valid; else any domestic port). + // - If already returning and that port is gone, pick a different domestic port. + if (!expectedTargetUnit.isActive()) { + const domesticFallback = this.selectRandomDomesticPort(this.ship.owner()); + if (!this.ship.returning()) { + const fallback = + this.lastPort && this.lastPort.isActive() + ? this.lastPort + : domesticFallback; + if (fallback) { + this.ship.setReturning(true); + this.ship.setTargetUnit(fallback); + } else { + // Nowhere sensible to return to -> scuttle the ship + this.ship.delete(false); + this.active = false; + } + } else { + if (domesticFallback) { + this.ship.setTargetUnit(domesticFallback); + } else { + // Already returning but no valid fallback -> scuttle the ship + this.ship.delete(false); + this.active = false; + } + } + return; + } // If some external order changed the target while not returning, stop this assignment + // Allow initial assignment where target is still undefined; we'll set it below. if ( !this.ship.returning() && + this.ship.targetUnit() !== undefined && this.ship.targetUnit() !== expectedTargetUnit ) { this.active = false; @@ -415,28 +483,10 @@ export class AssignedTradeRouteExecution implements Execution { .unitsAt(targetTile) .find((u) => u.type() === UnitType.Port) as Unit | undefined; if (portHere) this.lastPort = portHere; - // Log arrival for human-owned trade ships - if (this.ship.owner().type() === PlayerType.Human && portHere) { - const ownerName = this.ship.owner().displayName(); - const shipId = this.ship.id(); - const portOwner = portHere.owner().displayName(); - const portId = portHere.id(); - if (this.ship.returning()) { - console.log( - `[TRADE] Ship #${shipId} (${ownerName}) RETURNED to ${portOwner} (Port #${portId}) after turnaround`, - ); - } else if (this.phase === "toStart") { - const toOwner = this.endPort.owner().displayName(); - console.log( - `[TRADE] Ship #${shipId} (${ownerName}) ARRIVED at START port ${portOwner} (Port #${portId}); heading to ${toOwner}`, - ); - } else { - console.log( - `[TRADE] Ship #${shipId} (${ownerName}) ARRIVED at END port ${portOwner} (Port #${portId}); trade completed`, - ); - } - } + // (removed) arrival logs for human-owned trade ships if (this.ship.returning()) { + // Clear returning state upon successful return dock + this.ship.setReturning(false); // Cancel route on return to last port this.ship.setTargetUnit(undefined); this.ship.setTradeRouteOwners(null, null); @@ -458,18 +508,37 @@ export class AssignedTradeRouteExecution implements Execution { // Compute a navigable water target near the destination port const navTarget = this.navTargetForPort(targetTile); if (navTarget === null) { - // Cannot navigate to this port (no adjacent water). Cancel. + // Cannot navigate to this port (no adjacent ocean). End assignment. this.active = false; return; } - // If on land (port tile), step into adjacent ocean first + // If on land (port tile), leave port directly onto ocean: prefer adjacent ocean; otherwise jump to nearest ocean within a small radius. if (!this.mg.isOcean(this.ship.tile())) { - const step = this.stepIntoOceanTowards(navTarget); - if (step !== null) { - this.ship.move(step); + const here = this.ship.tile(); + // Must be docked at a port to undock + const dockPort = this.mg + .unitsAt(here) + .find((u) => u.type() === UnitType.Port) as Unit | undefined; + if (!dockPort) { + // On land without a port; do not move. End assignment. + this.active = false; return; } + // Try an adjacent ocean step first + const adjOcean = this.mg + .neighbors(here) + .filter((t) => this.mg.isOcean(t)) + .sort( + (a, b) => + this.mg.manhattanDist(a, navTarget) - + this.mg.manhattanDist(b, navTarget), + ); + if (adjOcean.length > 0) { + this.ship.move(adjOcean[0]); + return; + } + // No adjacent ocean: do not jump; end assignment this.active = false; return; } @@ -482,28 +551,10 @@ export class AssignedTradeRouteExecution implements Execution { .find((u) => u.type() === UnitType.Port) as Unit | undefined; if (portHere) this.lastPort = portHere; } - // Log arrival for human-owned ships even if already on tile (edge case) - if (this.ship.owner().type() === PlayerType.Human && this.lastPort) { - const ownerName = this.ship.owner().displayName(); - const shipId = this.ship.id(); - const portOwner = this.lastPort.owner().displayName(); - const portId = this.lastPort.id(); - if (this.ship.returning()) { - console.log( - `[TRADE] Ship #${shipId} (${ownerName}) RETURNED to ${portOwner} (Port #${portId}) after turnaround`, - ); - } else if (this.phase === "toStart") { - const toOwner = this.endPort.owner().displayName(); - console.log( - `[TRADE] Ship #${shipId} (${ownerName}) ARRIVED at START port ${portOwner} (Port #${portId}); heading to ${toOwner}`, - ); - } else { - console.log( - `[TRADE] Ship #${shipId} (${ownerName}) ARRIVED at END port ${portOwner} (Port #${portId}); trade completed`, - ); - } - } + // (removed) arrival logs when already on port tile if (this.ship.returning()) { + // Clear returning state upon successful return dock + this.ship.setReturning(false); this.ship.setTargetUnit(undefined); this.active = false; return; @@ -529,6 +580,7 @@ export class AssignedTradeRouteExecution implements Execution { this.ship.move(res.node); break; case PathFindResultType.PathNotFound: + // Path cannot be found; end assignment this.active = false; break; } @@ -567,17 +619,24 @@ export class AssignedTradeRouteExecution implements Execution { return candidates[0]; } - // If the ship is on land (port tile), take one step into ocean toward navTarget - private stepIntoOceanTowards(navTarget: TileRef): TileRef | null { - const oceanNeighbors = this.mg - .neighbors(this.ship.tile()) - .filter((t) => this.mg.isOcean(t)); - if (oceanNeighbors.length === 0) return null; - oceanNeighbors.sort( - (a, b) => - this.mg.manhattanDist(a, navTarget) - - this.mg.manhattanDist(b, navTarget), - ); - return oceanNeighbors[0]; + // Removed unused shoreline stepping helpers + + // Pick any active port owned by the given owner + private selectRandomDomesticPort(owner: Player): Unit | null { + const ports = this.mg + .units(UnitType.Port) + .filter((p) => p.isActive() && p.owner() === owner); + if (ports.length === 0) return null; + const idx = Math.floor(Math.random() * ports.length); + return ports[idx]; + } + + // Abort this trade assignment: clear any route metadata and leave ship idle + private abort(): void { + this.ship.setTargetUnit(undefined); + this.ship.setTradeRouteOwners(null, null); + this.ship.setCargoGold(0n); + this.ship.setReturning(false); + this.active = false; } } diff --git a/src/core/pathfinding/MiniAStar.ts b/src/core/pathfinding/MiniAStar.ts index 9e69ccfea..fc11691bd 100644 --- a/src/core/pathfinding/MiniAStar.ts +++ b/src/core/pathfinding/MiniAStar.ts @@ -67,6 +67,7 @@ export class MiniAStar implements AStar { } reconstructPath(): TileRef[] { + // Build start and destination cells at full resolution let cellSrc: Cell | undefined; if (!Array.isArray(this.src)) { cellSrc = new Cell(this.gameMap.x(this.src), this.gameMap.y(this.src)); @@ -75,16 +76,104 @@ export class MiniAStar implements AStar { this.gameMap.x(this.dst), this.gameMap.y(this.dst), ); - const upscaled = fixExtremes( - upscalePath( - this.aStar - .reconstructPath() - .map((tr) => new Cell(this.miniMap.x(tr), this.miniMap.y(tr))), - ), - cellDst, - cellSrc, + + // Get path in mini-map coords, upscale to full grid coordinates + const coarseCells = this.aStar + .reconstructPath() + .map((tr) => new Cell(this.miniMap.x(tr), this.miniMap.y(tr))); + const upscaled = fixExtremes(upscalePath(coarseCells), cellDst, cellSrc); + + // Convert to an orthogonal, water-preferred path on the full grid + const waterOrthogonal: Cell[] = []; + if (upscaled.length > 0) { + let curr = upscaled[0]; + waterOrthogonal.push(curr); + for (let i = 1; i < upscaled.length; i++) { + const target = upscaled[i]; + while (curr.x !== target.x || curr.y !== target.y) { + const stepX = Math.sign(target.x - curr.x); + const stepY = Math.sign(target.y - curr.y); + const candidates: Cell[] = []; + if (stepX !== 0) candidates.push(new Cell(curr.x + stepX, curr.y)); + if (stepY !== 0) candidates.push(new Cell(curr.x, curr.y + stepY)); + + // Prefer candidates that are water and reduce manhattan distance + let best: Cell | null = null; + let bestScore = Number.POSITIVE_INFINITY; + for (const cand of candidates) { + const ref = this.gameMap.ref(cand.x, cand.y); + const isWater = this.gameMap.isWater(ref); + const dist = + Math.abs(target.x - cand.x) + Math.abs(target.y - cand.y); + const score = (isWater ? 0 : 1000) + dist; // strong preference for water + if (score < bestScore) { + best = cand; + bestScore = score; + } + } + // If no candidates (shouldn't happen), break to avoid infinite loop + if (best === null) break; + curr = best; + waterOrthogonal.push(curr); + } + } + } + + // Remove any residual non-water cells if present (belt-and-suspenders) + let finalCells = waterOrthogonal.filter((c) => + this.gameMap.isWater(this.gameMap.ref(c.x, c.y)), ); - return upscaled.map((c) => this.gameMap.ref(c.x, c.y)); + + // Robustness: ensure path includes src and dst and has at least 2 cells + const srcCell = cellSrc ?? (upscaled.length > 0 ? upscaled[0] : undefined); + if (finalCells.length === 0) { + // Fallback to upscaled (pre-orthogonal) if filtering removed everything + finalCells = [...upscaled]; + } + if (srcCell) { + const first = finalCells[0]; + if (!first || first.x !== srcCell.x || first.y !== srcCell.y) { + finalCells.unshift(srcCell); + } + } + const last = finalCells[finalCells.length - 1]; + if (!last || last.x !== cellDst.x || last.y !== cellDst.y) { + finalCells.push(cellDst); + } + if (finalCells.length < 2) { + // Create a single orthogonal step toward dst + const start = finalCells[0]; + if (!start) { + finalCells = [cellDst]; + } else if (start.x !== cellDst.x || start.y !== cellDst.y) { + const stepX = Math.sign(cellDst.x - start.x); + const stepY = Math.sign(cellDst.y - start.y); + const candidates: Cell[] = []; + if (stepX !== 0) candidates.push(new Cell(start.x + stepX, start.y)); + if (stepY !== 0) candidates.push(new Cell(start.x, start.y + stepY)); + let best: Cell | null = null; + let bestScore = Number.POSITIVE_INFINITY; + for (const cand of candidates) { + const ref = this.gameMap.ref(cand.x, cand.y); + const isWater = this.gameMap.isWater(ref); + const dist = + Math.abs(cellDst.x - cand.x) + Math.abs(cellDst.y - cand.y); + const score = (isWater ? 0 : 1000) + dist; + if (score < bestScore) { + best = cand; + bestScore = score; + } + } + if (best) finalCells.push(best); + } + if (finalCells.length < 2) { + // As a final guard, ensure there are at least two cells + finalCells.push(cellDst); + } + } + + // Map to tile refs + return finalCells.map((c) => this.gameMap.ref(c.x, c.y)); } } From 6da77d9f685f03304f6165bab7d15741ec0ec7a7 Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Thu, 30 Oct 2025 01:49:02 +0100 Subject: [PATCH 05/20] feat(trade): enhance logging for active trade ships and queued replacements --- src/core/execution/TradeManagerExecution.ts | 187 ++++++++++++++++---- src/core/pathfinding/PathFinding.ts | 21 ++- 2 files changed, 176 insertions(+), 32 deletions(-) diff --git a/src/core/execution/TradeManagerExecution.ts b/src/core/execution/TradeManagerExecution.ts index ac8ff0219..f7825d59f 100644 --- a/src/core/execution/TradeManagerExecution.ts +++ b/src/core/execution/TradeManagerExecution.ts @@ -52,6 +52,21 @@ export class TradeManagerExecution implements Execution { this.queueLogIntervalId = setInterval(() => { // Keep lightweight and consistent with other [TRADE] logs console.log(`[TRADE] queue length=${this.queue.length}`); + // Also, per human player, report active trade ships and queued replacements + for (const p of this.mg.players()) { + if (p.type() !== PlayerType.Human) continue; + // Active trade ships owned by this player + const active = p + .units(UnitType.TradeShip) + .filter((u) => u.isActive()).length; + // Trade ships queued for replacement (scheduled but not yet built) for this player + const queued = Array.from(this.replacementDueAt.keys()).filter( + (portID) => this.portOwnerById.get(portID) === p, + ).length; + console.log( + `[TRADE] ${p.displayName()} trade ships: active=${active} queued=${queued}`, + ); + } }, 5000); } } @@ -423,36 +438,89 @@ export class AssignedTradeRouteExecution implements Execution { let expectedTargetUnit = this.phase === "toStart" ? this.startPort : this.endPort; if (this.ship.returning()) { - expectedTargetUnit = this.lastPort ?? this.startPort; + // Always return to the last port actually docked at. + if (this.lastPort) { + expectedTargetUnit = this.lastPort; + } else { + // No recorded last port: pick a domestic fallback or scuttle. + const domesticFallback = this.selectRandomDomesticPort( + this.ship.owner(), + ); + if (domesticFallback) { + expectedTargetUnit = domesticFallback; + } else { + // Nowhere sensible to return to -> scuttle the ship and end. + this.ship.delete(false); + this.active = false; + return; + } + } } - // If the DESTINATION (expected target) was destroyed: - // - If not already returning, turn around (return to last port if valid; else any domestic port). - // - If already returning and that port is gone, pick a different domestic port. + // If the DESTINATION (expected target) was destroyed, try to substitute another port of the same owner. if (!expectedTargetUnit.isActive()) { - const domesticFallback = this.selectRandomDomesticPort(this.ship.owner()); + let substituted = false; if (!this.ship.returning()) { - const fallback = - this.lastPort && this.lastPort.isActive() - ? this.lastPort - : domesticFallback; - if (fallback) { - this.ship.setReturning(true); - this.ship.setTargetUnit(fallback); + if (this.phase === "toEnd") { + // Replace endPort with nearest active port owned by the original end owner + const endOwner = + (this.ship.tradeRouteEndOwner && this.ship.tradeRouteEndOwner()) || + this.endPort.owner(); + const replacement = endOwner + ? this.selectNearestPort(endOwner) + : null; + if (replacement) { + this.endPort = replacement; + expectedTargetUnit = replacement; + this.ship.setTargetUnit(expectedTargetUnit); + substituted = true; + } } else { - // Nowhere sensible to return to -> scuttle the ship - this.ship.delete(false); - this.active = false; + // phase === "toStart": replace startPort similarly + const startOwner = + (this.ship.tradeRouteStartOwner && + this.ship.tradeRouteStartOwner()) || + this.startPort.owner(); + const replacement = startOwner + ? this.selectNearestPort(startOwner) + : null; + if (replacement) { + this.startPort = replacement; + expectedTargetUnit = replacement; + this.ship.setTargetUnit(expectedTargetUnit); + substituted = true; + } } - } else { - if (domesticFallback) { - this.ship.setTargetUnit(domesticFallback); + } + if (!substituted) { + // Fall back to previous returning/scuttle behavior + const domesticFallback = this.selectRandomDomesticPort( + this.ship.owner(), + ); + if (!this.ship.returning()) { + const fallback = + this.lastPort && this.lastPort.isActive() + ? this.lastPort + : domesticFallback; + if (fallback) { + this.ship.setReturning(true); + this.ship.setTargetUnit(fallback); + } else { + // Nowhere sensible to return to -> scuttle the ship + this.ship.delete(false); + this.active = false; + } } else { - // Already returning but no valid fallback -> scuttle the ship - this.ship.delete(false); - this.active = false; + if (domesticFallback) { + this.ship.setTargetUnit(domesticFallback); + } else { + // Already returning but no valid fallback -> scuttle the ship + this.ship.delete(false); + this.active = false; + } } + return; } - return; + // If substituted, fall through and continue navigation to the new target. } // If some external order changed the target while not returning, stop this assignment // Allow initial assignment where target is still undefined; we'll set it below. @@ -580,7 +648,27 @@ export class AssignedTradeRouteExecution implements Execution { this.ship.move(res.node); break; case PathFindResultType.PathNotFound: - // Path cannot be found; end assignment + // Path cannot be found; try another port of the same owner before giving up + if (!this.ship.returning()) { + if (this.phase === "toEnd") { + const owner = this.endPort.owner(); + const alt = this.selectAlternatePort(owner, this.endPort.id()); + if (alt) { + this.endPort = alt; + this.ship.setTargetUnit(alt); + return; + } + } else { + const owner = this.startPort.owner(); + const alt = this.selectAlternatePort(owner, this.startPort.id()); + if (alt) { + this.startPort = alt; + this.ship.setTargetUnit(alt); + return; + } + } + } + // No alternative available -> end assignment this.active = false; break; } @@ -631,12 +719,51 @@ export class AssignedTradeRouteExecution implements Execution { return ports[idx]; } - // Abort this trade assignment: clear any route metadata and leave ship idle - private abort(): void { - this.ship.setTargetUnit(undefined); - this.ship.setTradeRouteOwners(null, null); - this.ship.setCargoGold(0n); - this.ship.setReturning(false); - this.active = false; + // Pick the nearest active port owned by the given owner (to the ship's current position) + private selectNearestPort(owner: Player): Unit | null { + const ports = this.mg + .units(UnitType.Port) + .filter((p) => p.isActive() && p.owner() === owner); + if (ports.length === 0) return null; + let best: Unit | null = null; + let bestDist = Number.POSITIVE_INFINITY; + const here = this.ship.tile(); + for (const p of ports) { + const d = this.mg.euclideanDistSquared(here, p.tile()); + if (d < bestDist) { + bestDist = d; + best = p; + } + } + return best; + } + + // Pick the nearest alternative active port owned by the owner, excluding a specific port ID, + // and prefer ports with at least one adjacent ocean tile (dockable) + private selectAlternatePort( + owner: Player, + excludePortId: number, + ): Unit | null { + const candidates = this.mg + .units(UnitType.Port) + .filter( + (p) => p.isActive() && p.owner() === owner && p.id() !== excludePortId, + ); + if (candidates.length === 0) return null; + const dockable: Unit[] = []; + const here = this.ship.tile(); + for (const p of candidates) { + const neighbors = this.mg.neighbors(p.tile()); + if (neighbors.some((t) => this.mg.isOcean(t))) { + dockable.push(p); + } + } + const list = dockable.length > 0 ? dockable : candidates; + list.sort( + (a, b) => + this.mg.euclideanDistSquared(here, a.tile()) - + this.mg.euclideanDistSquared(here, b.tile()), + ); + return list[0] ?? null; } } diff --git a/src/core/pathfinding/PathFinding.ts b/src/core/pathfinding/PathFinding.ts index 236afc13e..898ba84ea 100644 --- a/src/core/pathfinding/PathFinding.ts +++ b/src/core/pathfinding/PathFinding.ts @@ -200,7 +200,17 @@ export class PathFinder { } else { const tile = this.path?.shift(); if (tile === undefined) { - throw new Error("missing tile"); + // Path exhausted unexpectedly. If already within step distance, report completion. + if (this.game.manhattanDist(curr, dst) < dist) { + return { type: PathFindResultType.Completed, node: curr }; + } + // Otherwise, recompute a fresh path from current position. + this.curr = curr; + this.dst = dst; + this.path = null; + this.aStar = this.newAStar(curr, dst); + this.computeFinished = false; + return this.nextTile(curr, dst); } return { type: PathFindResultType.NextTile, node: tile }; } @@ -212,7 +222,14 @@ export class PathFinder { this.path = this.aStar.reconstructPath(); // Remove the start tile this.path.shift(); - + // If the reconstructed path is empty, fall back to completion or path-not-found handling. + if (!this.path || this.path.length === 0) { + if (this.game.manhattanDist(curr, dst) < dist) { + return { type: PathFindResultType.Completed, node: curr }; + } else { + return { type: PathFindResultType.PathNotFound }; + } + } return this.nextTile(curr, dst); case PathFindResultType.Pending: return { type: PathFindResultType.Pending }; From 0255da6fde9fbff7e3536dd748a6cff89d9b2e04 Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Mon, 17 Nov 2025 01:42:33 +0100 Subject: [PATCH 06/20] feat(submarine): refine engagement logic for enemy trade ships based on war status and safety conditions --- src/core/execution/SubmarineExecution.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/core/execution/SubmarineExecution.ts b/src/core/execution/SubmarineExecution.ts index dc8377463..f95e767e3 100644 --- a/src/core/execution/SubmarineExecution.ts +++ b/src/core/execution/SubmarineExecution.ts @@ -127,15 +127,9 @@ export class SubmarineExecution implements Execution { continue; } if (unit.type() === UnitType.TradeShip) { - if ( - !hasPort || - unit.isSafeFromPirates() || - unit.targetUnit()?.owner() === this.submarine.owner() || // trade ship is coming to my port - unit - .targetUnit() - ?.owner() - .isFriendly(this.submarine.owner() as any) // trade ship is coming to my ally - ) { + if (!hasPort || unit.isSafeFromPirates()) { + // Submarines only engage enemy trade ships when at war, but still + // respect basic protections like safe-from-pirates and owner having a port. continue; } if ( From 37131c5f06805113aecaa8b6f96e890cdfca6745 Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Mon, 17 Nov 2025 03:34:12 +0100 Subject: [PATCH 07/20] feat(trade): implement trade tab in ControlPanel2 and enhance trade ship logging --- src/client/graphics/layers/ControlPanel2.ts | 94 ++++++++++++- src/core/execution/TradeManagerExecution.ts | 148 ++++++++++++++++++-- src/core/game/Game.ts | 3 + src/core/game/GameUpdates.ts | 5 + src/core/game/GameView.ts | 24 ++++ src/core/game/PlayerImpl.ts | 1 + src/core/game/UnitImpl.ts | 24 ++++ 7 files changed, 285 insertions(+), 14 deletions(-) diff --git a/src/client/graphics/layers/ControlPanel2.ts b/src/client/graphics/layers/ControlPanel2.ts index 774bb2e42..f39445c0a 100644 --- a/src/client/graphics/layers/ControlPanel2.ts +++ b/src/client/graphics/layers/ControlPanel2.ts @@ -10,7 +10,7 @@ import { UnitType, UpgradeType, } from "../../../core/game/Game"; -import { GameView, PlayerView } from "../../../core/game/GameView"; +import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; import { getTechMeta, RESEARCH_TECH_IDS } from "../../../core/tech/TechEffects"; // Ensure modal custom elements register at runtime import "../../BuildSettingsModal"; @@ -87,7 +87,8 @@ export class ControlPanel2 extends LitElement implements Layer { private init_: boolean = false; @state() - private activeTab: "Build" | "Attack" | "Economy" | "Bombers" = "Build"; + private activeTab: "Build" | "Attack" | "Economy" | "Bombers" | "Trade" = + "Build"; @state() private _lastAirfieldCount: number = 0; @@ -237,6 +238,11 @@ export class ControlPanel2 extends LitElement implements Layer { super.disconnectedCallback(); } + // Restore disabled shadow DOM so legacy global CSS and querySelector usage continue working + protected createRenderRoot(): HTMLElement | DocumentFragment { + return this; // Render into light DOM + } + init() { this.attackRatio = Number( localStorage.getItem("settings.attackRatio") ?? "0.3", @@ -950,7 +956,9 @@ export class ControlPanel2 extends LitElement implements Layer { return el; } - private _changeTab(tab: "Build" | "Attack" | "Economy" | "Bombers") { + private _changeTab( + tab: "Build" | "Attack" | "Economy" | "Bombers" | "Trade", + ) { this.activeTab = tab; if (this.uiState.pendingBuildUnitType) { this.uiState.pendingBuildUnitType = null; @@ -1170,6 +1178,15 @@ export class ControlPanel2 extends LitElement implements Layer { > Economy + ${this._hasAirfields ? html`