+
+
Trade Ships
+
+
+ Trade Demand: ${demandLabel}
+
+
+
+ ${pendingRows.length > 0
+ ? html`
+
Under Construction
+
+
${pendingRows}
+
`
+ : ""}
+ ${ships.length > 0
+ ? html`
${rows}
`
+ : ships.length === 0 && pendingRows.length === 0
+ ? html`
No active trade ships.
`
+ : ""}
+
+ `;
+ }
+
+ private _computeTradeShipStatus(ship: UnitView): string {
+ // Debug ship status logging removed
+ const ownerName = (pv: PlayerView | null) => pv?.displayName() ?? "Unknown";
+ const dockOwner = ship.dockedAtPortOwner();
+ const startOwner = ship.tradeRouteStartOwner();
+ const endOwner = ship.tradeRouteEndOwner();
+ const targetId = ship.targetUnitId();
+ const targetUnit =
+ targetId !== undefined ? this.game.unit(targetId) : undefined;
+
+ if (dockOwner && !ship.returning() && targetId === undefined) {
+ return `in port owned by ${ownerName(dockOwner)}`;
+ }
+
+ if (ship.returning()) {
+ if (targetUnit && targetUnit.type() === UnitType.Port) {
+ return `returning to port owned by ${ownerName(targetUnit.owner())}`;
+ }
+ return "returning to port";
+ }
+
+ const phase = ship.tradePhase();
+
+ if (phase === "toStart") {
+ return `traveling to start port owned by ${ownerName(startOwner)}`;
+ }
+ if (phase === "toEnd") {
+ if (startOwner || endOwner) {
+ return `trading between ${ownerName(startOwner)} and ${ownerName(endOwner)}`;
+ }
+ if (targetUnit && targetUnit.type() === UnitType.Port) {
+ return `traveling to port owned by ${ownerName(targetUnit.owner())}`;
+ }
+ }
+
+ return "at sea";
}
}
diff --git a/src/client/graphics/layers/PointerCoordsLayer.ts b/src/client/graphics/layers/PointerCoordsLayer.ts
new file mode 100644
index 000000000..2fa131d80
--- /dev/null
+++ b/src/client/graphics/layers/PointerCoordsLayer.ts
@@ -0,0 +1,93 @@
+import { EventBus } from "../../../core/EventBus";
+import { GameView } from "../../../core/game/GameView";
+import { MouseMoveEvent } from "../../InputHandler";
+import { TransformHandler } from "../TransformHandler";
+import { Layer } from "./Layer";
+
+/**
+ * PointerCoordsLayer
+ * Renders the world tile coordinates next to the mouse pointer.
+ * Y is displayed using a bottom-left origin: y' = (H - 1) - y.
+ * Drawn in screen-space (no transform) so it sticks to the pointer.
+ */
+export class PointerCoordsLayer implements Layer {
+ private lastScreenX: number | null = null;
+ private lastScreenY: number | null = null;
+
+ constructor(
+ private game: GameView,
+ private eventBus: EventBus,
+ private transform: TransformHandler,
+ ) {}
+
+ shouldTransform(): boolean {
+ return false; // screen-space overlay
+ }
+
+ init() {
+ this.eventBus.on(MouseMoveEvent, (e: MouseMoveEvent) => {
+ this.lastScreenX = e.x;
+ this.lastScreenY = e.y;
+ });
+ }
+
+ renderLayer(ctx: CanvasRenderingContext2D) {
+ if (this.lastScreenX === null || this.lastScreenY === null) return;
+
+ const world = this.transform.screenToWorldCoordinates(
+ this.lastScreenX,
+ this.lastScreenY,
+ );
+ if (!this.game.isValidCoord(world.x, world.y)) return;
+
+ const x = world.x;
+ const yBL = this.game.height() - 1 - world.y; // bottom-left origin
+
+ const label = `(${x}, ${yBL})`;
+
+ // Position slightly offset from the pointer
+ const px = this.lastScreenX + 12;
+ const py = this.lastScreenY + 18;
+
+ // Draw background pill for readability
+ ctx.save();
+ ctx.font = "12px Inter, system-ui, -apple-system, Segoe UI, Roboto";
+ ctx.textBaseline = "top";
+ const paddingX = 6;
+ const paddingY = 3;
+ const metrics = ctx.measureText(label);
+ const w = Math.ceil(metrics.width) + paddingX * 2;
+ const h = 16 + paddingY * 2;
+
+ ctx.fillStyle = "rgba(20, 20, 24, 0.8)";
+ ctx.strokeStyle = "rgba(255, 255, 255, 0.15)";
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ const r = 6;
+ this.roundRect(ctx, px - 2, py - 2, w + 4, h + 4, r);
+ ctx.fill();
+ ctx.stroke();
+
+ // Draw text
+ ctx.fillStyle = "#E5E7EB"; // light gray for contrast
+ ctx.fillText(label, px + paddingX, py + paddingY);
+ ctx.restore();
+ }
+
+ private roundRect(
+ ctx: CanvasRenderingContext2D,
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ radius: number,
+ ) {
+ const r = Math.min(radius, width / 2, height / 2);
+ ctx.moveTo(x + r, y);
+ ctx.arcTo(x + width, y, x + width, y + height, r);
+ ctx.arcTo(x + width, y + height, x, y + height, r);
+ ctx.arcTo(x, y + height, x, y, r);
+ ctx.arcTo(x, y, x + width, y, r);
+ ctx.closePath();
+ }
+}
diff --git a/src/client/index.html b/src/client/index.html
index 833cbdc0b..19ffe7bb6 100644
--- a/src/client/index.html
+++ b/src/client/index.html
@@ -68,7 +68,6 @@
left: 0;
width: 100%;
height: 100%;
- filter: blur(0px);
z-index: -1;
}
diff --git a/src/client/styles/modal/lobby-team.css b/src/client/styles/modal/lobby-team.css
index 0fd02f710..3a3125c83 100644
--- a/src/client/styles/modal/lobby-team.css
+++ b/src/client/styles/modal/lobby-team.css
@@ -95,7 +95,9 @@
background: var(--ui-alert);
color: var(--ui-button-text);
border-radius: 50%;
- font-size: 0.6875em;
+ font-size: 14px; /* Larger glyph, same button size */
+ font-weight: 700; /* Thicker X for readability */
+ line-height: 1;
cursor: pointer;
display: inline-flex;
align-items: center;
diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts
index f68119c2d..ae6688e55 100644
--- a/src/core/GameRunner.ts
+++ b/src/core/GameRunner.ts
@@ -1,7 +1,9 @@
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 { TradeManagerExecution } from "./execution/TradeManagerExecution";
import { WinCheckExecution } from "./execution/WinCheckExecution";
import { AllianceImpl } from "./game/AllianceImpl";
import {
@@ -241,6 +243,10 @@ 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());
+ // 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 60687124f..176257698 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;
@@ -150,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 / 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)
+ 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 5e2f717f1..d1268852c 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 3e-6; // 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
@@ -1118,6 +1136,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/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