Block captured trade routes from port destination selection for 100 ticks

This commit is contained in:
scamiv
2026-04-06 20:37:12 +02:00
parent e7029564a2
commit d994db70ce
6 changed files with 217 additions and 1 deletions
+10
View File
@@ -111,6 +111,16 @@ export class PortExecution implements Execution {
const weightedPorts: Unit[] = [];
for (const [i, otherPort] of ports.entries()) {
if (
this.mg.isTradeRouteBlocked(
this.port.id(),
otherPort.id(),
this.mg.ticks(),
)
) {
continue;
}
const expanded = new Array(otherPort.level()).fill(otherPort);
weightedPorts.push(...expanded);
const tooClose =
+12 -1
View File
@@ -7,6 +7,7 @@ import {
Unit,
UnitType,
} from "../game/Game";
import { TRADE_ROUTE_BLOCK_DURATION_TICKS } from "../game/GameImpl";
import { TileRef } from "../game/GameMap";
import { PathFinding } from "../pathfinding/PathFinder";
import { PathStatus, SteppingPathFinder } from "../pathfinding/types";
@@ -21,12 +22,17 @@ export class TradeShipExecution implements Execution {
private tilesTraveled = 0;
private motionPlanId = 1;
private motionPlanDst: TileRef | null = null;
private readonly srcPortId: number;
private readonly dstPortId: number;
constructor(
private origOwner: Player,
private srcPort: Unit,
private _dstPort: Unit,
) {}
) {
this.srcPortId = srcPort.id();
this.dstPortId = _dstPort.id();
}
init(mg: Game, ticks: number): void {
this.mg = mg;
@@ -61,6 +67,11 @@ export class TradeShipExecution implements Execution {
if (this.wasCaptured !== true && this.origOwner !== tradeShipOwner) {
// Store as variable in case ship is recaptured by previous owner
this.wasCaptured = true;
this.mg.blockTradeRouteUntil(
this.srcPortId,
this.dstPortId,
this.mg.ticks() + TRADE_ROUTE_BLOCK_DURATION_TICKS,
);
}
// If a player captures another player's port while trading we should delete
+6
View File
@@ -913,6 +913,12 @@ export interface Game extends GameMap {
miniWaterGraph(): AbstractGraph | null;
getWaterComponent(tile: TileRef): number | null;
hasWaterComponent(tile: TileRef, component: number): boolean;
blockTradeRouteUntil(srcPortId: number, dstPortId: number, tick: Tick): void;
isTradeRouteBlocked(
srcPortId: number,
dstPortId: number,
nowTick: Tick,
): boolean;
}
export interface PlayerActions {
+50
View File
@@ -36,6 +36,7 @@ import {
TeamGameSpawnAreas,
TerrainType,
TerraNullius,
Tick,
Trios,
Unit,
UnitInfo,
@@ -75,6 +76,8 @@ export function createGame(
export type CellString = string;
const TRADE_ROUTE_BLOCK_DURATION_TICKS = 100;
export class GameImpl implements Game {
private _ticks = 0;
@@ -112,6 +115,7 @@ export class GameImpl implements Game {
private _miniWaterGraph: AbstractGraph | null = null;
private _miniWaterHPA: AStarWaterHierarchical | null = null;
private _teamGameSpawnAreas: TeamGameSpawnAreas | undefined;
private readonly tradeRouteBlockedUntil = new Map<string, number>();
constructor(
private _humans: PlayerInfo[],
@@ -487,11 +491,55 @@ export class GameImpl implements Game {
return packed;
}
private makeTradeRouteKey(srcPortId: number, dstPortId: number): string {
return `${srcPortId}:${dstPortId}`;
}
private pruneExpiredTradeRouteBlocks(nowTick: Tick): void {
for (const [routeKey, blockedUntil] of this.tradeRouteBlockedUntil) {
if (blockedUntil <= nowTick) {
this.tradeRouteBlockedUntil.delete(routeKey);
}
}
}
blockTradeRouteUntil(srcPortId: number, dstPortId: number, tick: Tick): void {
this.pruneExpiredTradeRouteBlocks(this._ticks);
const routeKey = this.makeTradeRouteKey(srcPortId, dstPortId);
const existing = this.tradeRouteBlockedUntil.get(routeKey) ?? 0;
if (tick > existing) {
this.tradeRouteBlockedUntil.set(routeKey, tick);
}
}
isTradeRouteBlocked(
srcPortId: number,
dstPortId: number,
nowTick: Tick,
): boolean {
const routeKey = this.makeTradeRouteKey(srcPortId, dstPortId);
const blockedUntil = this.tradeRouteBlockedUntil.get(routeKey);
if (blockedUntil === undefined) {
return false;
}
if (blockedUntil <= nowTick) {
this.tradeRouteBlockedUntil.delete(routeKey);
return false;
}
return true;
}
private hash(): number {
this.pruneExpiredTradeRouteBlocks(this._ticks);
let hash = 1;
this._players.forEach((p) => {
hash += p.hash();
});
for (const [routeKey, blockedUntil] of Array.from(
this.tradeRouteBlockedUntil.entries(),
).sort(([a], [b]) => a.localeCompare(b))) {
hash += simpleHash(routeKey) + blockedUntil;
}
return hash;
}
@@ -1247,6 +1295,8 @@ export class GameImpl implements Game {
}
}
export { TRADE_ROUTE_BLOCK_DURATION_TICKS };
// Or a more dynamic approach that will catch new enum values:
const createGameUpdatesMap = (): GameUpdates => {
const map = {} as GameUpdates;
+75
View File
@@ -104,4 +104,79 @@ describe("PortExecution", () => {
expect(ports.length).toBe(1);
});
test("Blocked trade route is omitted from trading ports", () => {
game.config().proximityBonusPortsNb = () => 0;
game.config().tradeShipShortRangeDebuff = () => 0;
player.conquer(game.ref(7, 10));
const spawn = player.canBuild(UnitType.Port, game.ref(7, 10));
if (spawn === false) {
throw new Error("Unable to build port for test");
}
const port = player.buildUnit(UnitType.Port, spawn, {});
const execution = new PortExecution(port);
execution.init(game, 0);
other.conquer(game.ref(0, 0));
const blockedPort = other.buildUnit(UnitType.Port, game.ref(0, 0), {});
other.conquer(game.ref(0, 1));
const openPort = other.buildUnit(UnitType.Port, game.ref(0, 1), {});
game.blockTradeRouteUntil(port.id(), blockedPort.id(), game.ticks() + 100);
const ports = execution.tradingPorts();
expect(ports).toContain(openPort);
expect(ports).not.toContain(blockedPort);
});
test("Blocked trade route becomes eligible again after expiry", () => {
game.config().proximityBonusPortsNb = () => 0;
game.config().tradeShipShortRangeDebuff = () => 0;
player.conquer(game.ref(7, 10));
const spawn = player.canBuild(UnitType.Port, game.ref(7, 10));
if (spawn === false) {
throw new Error("Unable to build port for test");
}
const port = player.buildUnit(UnitType.Port, spawn, {});
const execution = new PortExecution(port);
execution.init(game, 0);
other.conquer(game.ref(0, 0));
const blockedPort = other.buildUnit(UnitType.Port, game.ref(0, 0), {});
game.blockTradeRouteUntil(port.id(), blockedPort.id(), game.ticks() + 1);
expect(execution.tradingPorts()).not.toContain(blockedPort);
expect(
game.isTradeRouteBlocked(port.id(), blockedPort.id(), game.ticks()),
).toBe(true);
expect(
game.isTradeRouteBlocked(port.id(), blockedPort.id(), game.ticks() + 1),
).toBe(false);
expect(execution.tradingPorts()).toContain(blockedPort);
});
test("Trade route blacklist affects hash and expires cleanly", () => {
player.conquer(game.ref(7, 10));
const spawn = player.canBuild(UnitType.Port, game.ref(7, 10));
if (spawn === false) {
throw new Error("Unable to build port for test");
}
const port = player.buildUnit(UnitType.Port, spawn, {});
other.conquer(game.ref(0, 0));
const otherPort = other.buildUnit(UnitType.Port, game.ref(0, 0), {});
const baseHash = (game as any).hash();
game.blockTradeRouteUntil(port.id(), otherPort.id(), game.ticks() + 100);
const blockedHash = (game as any).hash();
(game as any)._ticks += 100;
const expiredHash = (game as any).hash();
expect(blockedHash).not.toBe(baseHash);
expect(expiredHash).toBe(baseHash);
});
});
@@ -55,6 +55,7 @@ describe("TradeShipExecution", () => {
} as any;
piratePort = {
id: vi.fn(() => 201),
tile: vi.fn(() => 56),
owner: vi.fn(() => pirate),
isActive: vi.fn(() => true),
@@ -63,6 +64,7 @@ describe("TradeShipExecution", () => {
} as any;
piratePort2 = {
id: vi.fn(() => 202),
tile: vi.fn(() => 75),
owner: vi.fn(() => pirate),
isActive: vi.fn(() => true),
@@ -71,6 +73,7 @@ describe("TradeShipExecution", () => {
} as any;
srcPort = {
id: vi.fn(() => 101),
tile: vi.fn(() => 10),
owner: vi.fn(() => origOwner),
isActive: vi.fn(() => true),
@@ -79,6 +82,7 @@ describe("TradeShipExecution", () => {
} as any;
dstPort = {
id: vi.fn(() => 102),
tile: vi.fn(() => 100),
owner: vi.fn(() => dstOwner),
isActive: vi.fn(() => true),
@@ -131,6 +135,43 @@ describe("TradeShipExecution", () => {
expect(tradeShip.setTargetUnit).toHaveBeenCalledWith(piratePort);
});
it("blacklists the original route immediately on first capture", () => {
tradeShip.owner = vi.fn(() => pirate);
tradeShipExecution.tick(1);
expect(
game.isTradeRouteBlocked(srcPort.id(), dstPort.id(), game.ticks()),
).toBe(true);
});
it("keeps the blacklist on the original route after retargeting", () => {
tradeShip.owner = vi.fn(() => pirate);
tradeShipExecution.tick(1);
expect(
game.isTradeRouteBlocked(srcPort.id(), dstPort.id(), game.ticks()),
).toBe(true);
expect(
game.isTradeRouteBlocked(srcPort.id(), piratePort.id(), game.ticks()),
).toBe(false);
});
it("does not add a second blacklist event when the ship is recaptured", () => {
const routeKey = `${srcPort.id()}:${dstPort.id()}`;
tradeShip.owner = vi.fn(() => pirate);
tradeShipExecution.tick(1);
const blockedUntil = (game as any).tradeRouteBlockedUntil.get(routeKey);
tradeShip.owner = vi.fn(() => origOwner);
tradeShipExecution.tick(2);
expect((game as any).tradeRouteBlockedUntil.get(routeKey)).toBe(
blockedUntil,
);
});
it("should complete trade and award gold", () => {
tradeShipExecution["pathFinder"] = {
next: vi.fn(() => ({ status: PathStatus.COMPLETE, node: 32 })),
@@ -141,4 +182,27 @@ describe("TradeShipExecution", () => {
expect(tradeShipExecution.isActive()).toBe(false);
expect(game.displayMessage).toHaveBeenCalled();
});
it("does not blacklist on NOT_FOUND", () => {
tradeShipExecution["pathFinder"] = {
next: vi.fn(() => ({ status: PathStatus.NOT_FOUND, node: 32 })),
findPath: vi.fn((from: number) => [from]),
} as any;
tradeShipExecution.tick(1);
expect(
game.isTradeRouteBlocked(srcPort.id(), dstPort.id(), game.ticks()),
).toBe(false);
});
it("does not blacklist when destination becomes invalid", () => {
dstPort.isActive = vi.fn(() => false);
tradeShipExecution.tick(1);
expect(
game.isTradeRouteBlocked(srcPort.id(), dstPort.id(), game.ticks()),
).toBe(false);
});
});