diff --git a/src/core/execution/TradeShipExecution.ts b/src/core/execution/TradeShipExecution.ts index 5c9d4b484e..489f0265e3 100644 --- a/src/core/execution/TradeShipExecution.ts +++ b/src/core/execution/TradeShipExecution.ts @@ -66,6 +66,8 @@ export class TradeShipExecution implements Execution { const tradeShipOwner = this.tradeShip.owner(); const dstPortOwner = this._dstPort.owner(); + const curTile = this.tradeShip.tile(); + if (this.wasCaptured !== true && this.origOwner !== tradeShipOwner) { // Store as variable in case ship is recaptured by previous owner this.wasCaptured = true; @@ -79,42 +81,51 @@ export class TradeShipExecution implements Execution { return; } + if (!this.tradeShip.isActive()) { + this.active = false; + return; + } + + // Handle embargo or inactives ports destinations if ( !this.wasCaptured && (!this._dstPort.isActive() || !tradeShipOwner.canTrade(dstPortOwner)) ) { - this.tradeShip.delete(false); - this.active = false; - return; - } + const newPort = this.findNearestValidPort( + curTile, + (p) => p !== this.origOwner && p.canTrade(tradeShipOwner), + ); - const curTile = this.tradeShip.tile(); + if (newPort === null) { + this.tradeShip.delete(false); + this.active = false; + return; + } + this._dstPort = newPort; + this.tradeShip.setTargetUnit(this._dstPort); + this.tradeShip.touch(); + this.motionPlanDst = null; // Force motion plan re-recording + } + // Handle captured ship that needs rerouting to nearest new owner port if ( this.wasCaptured && (tradeShipOwner !== dstPortOwner || !this._dstPort.isActive()) ) { - const myComponent = this.mg.getWaterComponent(curTile); - const nearestPort = findClosestBy( - tradeShipOwner.units(UnitType.Port), - (port) => this.mg.manhattanDist(port.tile(), curTile), - (port) => - port.isActive() && - !port.isMarkedForDeletion() && - !port.isUnderConstruction() && - myComponent !== null && - this.mg.hasWaterComponent(port.tile(), myComponent), + const newPort = this.findNearestValidPort( + curTile, + (p) => p === tradeShipOwner, ); - if (nearestPort === null) { + + if (newPort === null) { this.tradeShip.delete(false); this.active = false; return; - } else { - this._dstPort = nearestPort; - this.tradeShip.setTargetUnit(this._dstPort); - // Plan-driven units don't emit per-tick unit updates, so force a sync for the new target. - this.tradeShip.touch(); } + this._dstPort = newPort; + this.tradeShip.setTargetUnit(this._dstPort); + this.tradeShip.touch(); + this.motionPlanDst = null; // Force path recalc } if (curTile === this.dstPort()) { @@ -230,4 +241,32 @@ export class TradeShipExecution implements Execution { dstPort(): TileRef { return this._dstPort.tile(); } + + private findNearestValidPort( + curTile: TileRef, + playerFilter: (p: Player) => boolean, + ): Unit | null { + const myComponent = this.mg.getWaterComponent(curTile); + if (myComponent === null) return null; + + const eligiblePlayers = this.mg.players().filter(playerFilter); + + const candidatePorts = eligiblePlayers.flatMap((p) => + p.units(UnitType.Port), + ); + + const validPorts = candidatePorts.filter( + (port) => + port.isActive() && + !port.isMarkedForDeletion() && + !port.isUnderConstruction() && + this.mg.hasWaterComponent(port.tile(), myComponent), + ); + + if (validPorts.length === 0) return null; + + return findClosestBy(validPorts, (port) => + this.mg.manhattanDist(port.tile(), curTile), + ); + } } diff --git a/tests/core/executions/TradeShipExecution.test.ts b/tests/core/executions/TradeShipExecution.test.ts index 72e6834dac..2c07e84d5e 100644 --- a/tests/core/executions/TradeShipExecution.test.ts +++ b/tests/core/executions/TradeShipExecution.test.ts @@ -8,12 +8,15 @@ describe("TradeShipExecution", () => { let origOwner: Player; let dstOwner: Player; let pirate: Player; + let halfPirate: Player; let srcPort: Unit; let piratePort: Unit; let piratePort2: Unit; + let halfPiratePort: Unit; let tradeShip: Unit; let dstPort: Unit; let tradeShipExecution: TradeShipExecution; + let currentTarget: Unit | null = null; beforeEach(async () => { // Mock Game, Player, Unit, and required methods @@ -48,12 +51,21 @@ describe("TradeShipExecution", () => { pirate = { id: vi.fn(() => 3), addGold: vi.fn(), - displayName: vi.fn(() => "Destination"), + displayName: vi.fn(() => "Destination 1"), units: vi.fn(() => [piratePort, piratePort2]), unitCount: vi.fn(() => 2), canTrade: vi.fn(() => true), } as any; + halfPirate = { + id: vi.fn(() => 4), + addGold: vi.fn(), + displayName: vi.fn(() => "Destination 2"), + units: vi.fn(() => [halfPiratePort]), + unitCount: vi.fn(() => 1), + canTrade: vi.fn(() => true), + } as any; + piratePort = { id: vi.fn(() => 201), tile: vi.fn(() => 56), @@ -72,6 +84,15 @@ describe("TradeShipExecution", () => { isMarkedForDeletion: vi.fn(() => false), } as any; + halfPiratePort = { + id: vi.fn(() => 301), + tile: vi.fn(() => 11), + owner: vi.fn(() => halfPirate), + isActive: vi.fn(() => true), + isUnderConstruction: vi.fn(() => false), + isMarkedForDeletion: vi.fn(() => false), + } as any; + srcPort = { id: vi.fn(() => 101), tile: vi.fn(() => 10), @@ -95,7 +116,10 @@ describe("TradeShipExecution", () => { owner: vi.fn(() => origOwner), id: vi.fn(() => 123), move: vi.fn(), - setTargetUnit: vi.fn(), + setTargetUnit: vi.fn((port: Unit) => { + currentTarget = port; + }), + targetUnit: vi.fn(() => currentTarget), setSafeFromPirates: vi.fn(), touch: vi.fn(), delete: vi.fn(), @@ -109,6 +133,18 @@ describe("TradeShipExecution", () => { findPath: vi.fn((from: number) => [from]), } as any; tradeShipExecution["tradeShip"] = tradeShip; + + vi.spyOn(game, "players").mockReturnValue([ + origOwner, + dstOwner, + pirate, + halfPirate, + ]); + vi.spyOn(game, "getWaterComponent").mockReturnValue(1); + vi.spyOn(game, "hasWaterComponent").mockReturnValue(true); + vi.spyOn(game, "manhattanDist").mockReturnValue(10); + + currentTarget = null; }); it("should initialize and tick without errors", () => { @@ -135,6 +171,22 @@ describe("TradeShipExecution", () => { expect(tradeShip.setTargetUnit).toHaveBeenCalledWith(piratePort); }); + it("should pick another port if destination is embargoed and ship not captured", () => { + dstOwner.canTrade = vi.fn(() => false); + halfPirate.canTrade = vi.fn(() => true); + pirate.canTrade = vi.fn(() => false); + origOwner.canTrade = vi.fn((target: Player) => { + if (target.id() === halfPirate.id()) return true; + return false; + }); + + tradeShipExecution.tick(1); + expect(tradeShip.delete).not.toHaveBeenCalled(); + expect(tradeShipExecution.isActive()).toBe(true); + expect(tradeShip.targetUnit()).toBe(halfPiratePort); + expect(tradeShip.touch).toHaveBeenCalled(); + }); + it("should complete trade and award gold", () => { tradeShipExecution["pathFinder"] = { next: vi.fn(() => ({ status: PathStatus.COMPLETE, node: 32 })),