diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 48f405d67f..79c9ee6979 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -129,6 +129,7 @@ export interface Config { unitInfo(type: UnitType): UnitInfo; tradeShipShortRangeDebuff(): number; tradeShipGold(dist: number, player: Player | PlayerView): Gold; + tradeShipSelfGoldMultiplier(): number; tradeShipSpawnRate( tradeShipSpawnRejections: number, numTradeShips: number, diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 642db819b3..ce164e0a5e 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -322,6 +322,10 @@ export class DefaultConfig implements Config { return BigInt(Math.floor(baseGold * this.goldMultiplierFor(player))); } + tradeShipSelfGoldMultiplier(): number { + return 0.4; + } + // Probability of trade ship spawn = 1 / tradeShipSpawnRate tradeShipSpawnRate( tradeShipSpawnRejections: number, diff --git a/src/core/execution/PortExecution.ts b/src/core/execution/PortExecution.ts index 3dc05724e5..b26aa7d68c 100644 --- a/src/core/execution/PortExecution.ts +++ b/src/core/execution/PortExecution.ts @@ -103,10 +103,18 @@ export class PortExecution implements Execution { const comp = this.mg.getWaterComponent(neighbor); if (comp !== null) sourceComponents.add(comp); } - const ports = this.mg + const owner = this.port!.owner(); + + // Other players' ports (existing logic) + const otherPorts = this.mg .players() - .filter((p) => p !== this.port!.owner() && p.canTrade(this.port!.owner())) - .flatMap((p) => p.units(UnitType.Port)) + .filter((p) => p !== owner && p.canTrade(owner)) + .flatMap((p) => p.units(UnitType.Port)); + + // Own ports (excluding the source port itself) + const ownPorts = owner.units(UnitType.Port).filter((p) => p !== this.port!); + + const ports = [...otherPorts, ...ownPorts] .filter((p) => { for (const comp of sourceComponents) { if (this.mg.hasWaterComponent(p.tile(), comp)) return true; @@ -123,8 +131,11 @@ export class PortExecution implements Execution { const weightedPorts: Unit[] = []; for (const [i, otherPort] of ports.entries()) { + const isSelfTrade = otherPort.owner() === owner; const expanded = new Array(otherPort.level()).fill(otherPort); + // Self-trade ports get base weight only (no proximity/friendly bonuses) weightedPorts.push(...expanded); + if (isSelfTrade) continue; const tooClose = this.mg.manhattanDist(this.port!.tile(), otherPort.tile()) < this.mg.config().tradeShipShortRangeDebuff(); @@ -135,7 +146,7 @@ export class PortExecution implements Execution { // to increase the chances of trading with it. weightedPorts.push(...expanded); } - if (!tooClose && this.port!.owner().isFriendly(otherPort.owner())) { + if (!tooClose && owner.isFriendly(otherPort.owner())) { weightedPorts.push(...expanded); } } diff --git a/src/core/execution/TradeShipExecution.ts b/src/core/execution/TradeShipExecution.ts index 5c9d4b484e..424d32d3d5 100644 --- a/src/core/execution/TradeShipExecution.ts +++ b/src/core/execution/TradeShipExecution.ts @@ -21,6 +21,7 @@ export class TradeShipExecution implements Execution { private tilesTraveled = 0; private motionPlanId = 1; private motionPlanDst: TileRef | null = null; + private readonly isSelfTrade: boolean; private static _staggerCounter = 0; @@ -28,7 +29,9 @@ export class TradeShipExecution implements Execution { private origOwner: Player, private srcPort: Unit, private _dstPort: Unit, - ) {} + ) { + this.isSelfTrade = srcPort.owner() === _dstPort.owner(); + } init(mg: Game, ticks: number): void { this.mg = mg; @@ -71,20 +74,19 @@ export class TradeShipExecution implements Execution { this.wasCaptured = true; } - // If a player captures another player's port while trading we should delete - // the ship. - if (dstPortOwner.id() === this.srcPort.owner().id()) { - this.tradeShip.delete(false); - this.active = false; - return; - } - - if ( - !this.wasCaptured && - (!this._dstPort.isActive() || !tradeShipOwner.canTrade(dstPortOwner)) - ) { - this.tradeShip.delete(false); - this.active = false; + const dstActive = this._dstPort.isActive(); + const shouldCancel = + // Non-self-trade: cancel if destination port was captured back, or + // (not yet captured) if trade is no longer viable + (!this.isSelfTrade && + (dstPortOwner.id() === this.srcPort.owner().id() || + (!this.wasCaptured && + (!dstActive || !tradeShipOwner.canTrade(dstPortOwner))))) || + // Self-trade: cancel if destination port is no longer active or was captured + (this.isSelfTrade && + (!dstActive || dstPortOwner !== this.srcPort.owner())); + if (shouldCancel) { + this.cancelTrade(); return; } @@ -106,8 +108,7 @@ export class TradeShipExecution implements Execution { this.mg.hasWaterComponent(port.tile(), myComponent), ); if (nearestPort === null) { - this.tradeShip.delete(false); - this.active = false; + this.cancelTrade(); return; } else { this._dstPort = nearestPort; @@ -165,10 +166,15 @@ export class TradeShipExecution implements Execution { } } + private cancelTrade() { + this.tradeShip!.delete(false); + this.active = false; + } + private complete() { this.active = false; this.tradeShip!.delete(false); - const gold = this.mg + let gold = this.mg .config() .tradeShipGold(this.tilesTraveled, this.tradeShip!.owner()); @@ -188,6 +194,25 @@ export class TradeShipExecution implements Execution { this.mg .stats() .boatCapturedTrade(this.tradeShip!.owner(), this.origOwner, gold); + } else if (this.isSelfTrade) { + // Self-trade: reduced gold, credited once + const multiplier = this.mg.config().tradeShipSelfGoldMultiplier(); + gold = BigInt(Math.floor(Number(gold) * multiplier)); + this.srcPort.owner().addGold(gold, this._dstPort.tile()); + this.mg.displayMessage( + "events_display.received_gold_from_trade", + MessageType.RECEIVED_GOLD_FROM_TRADE, + this.srcPort.owner().id(), + gold, + { + gold: renderNumber(gold), + name: this.srcPort.owner().displayName(), + }, + ); + // Record stats + this.mg + .stats() + .boatArriveTrade(this.srcPort.owner(), this._dstPort.owner(), gold); } else { this.srcPort.owner().addGold(gold); this._dstPort.owner().addGold(gold, this._dstPort.tile()); diff --git a/tests/PortExecution.test.ts b/tests/PortExecution.test.ts index 287a0f022f..2604902136 100644 --- a/tests/PortExecution.test.ts +++ b/tests/PortExecution.test.ts @@ -104,4 +104,76 @@ describe("PortExecution", () => { expect(ports.length).toBe(1); }); + + test("Self-trade: own ports appear as destinations", () => { + game.config().proximityBonusPortsNb = () => 0; + game.config().tradeShipShortRangeDebuff = () => 0; + game.config().structureMinDist = () => 0; + + player.conquer(game.ref(7, 5)); + player.conquer(game.ref(7, 15)); + const spawn1 = player.canBuild(UnitType.Port, game.ref(7, 5)); + const spawn2 = player.canBuild(UnitType.Port, game.ref(7, 15)); + if (spawn1 === false || spawn2 === false) { + throw new Error("Unable to build ports for test"); + } + const port1 = player.buildUnit(UnitType.Port, spawn1, {}); + player.buildUnit(UnitType.Port, spawn2, {}); + + const execution = new PortExecution(port1); + execution.init(game, 0); + execution.tick(0); + + const ports = execution.tradingPorts(); + + // Should include the player's other port + expect(ports.length).toBeGreaterThanOrEqual(1); + expect(ports.some((p) => p.owner() === player)).toBe(true); + }); + + test("Self-trade: port does not trade with itself", () => { + 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); + execution.tick(0); + + const ports = execution.tradingPorts(); + + // With only one own port and no other player ports, no destinations available + expect(ports.length).toBe(0); + }); + + test("Self-trade: own ports do not get proximity or friendly bonuses", () => { + game.config().proximityBonusPortsNb = () => 10; + game.config().tradeShipShortRangeDebuff = () => 0; + game.config().structureMinDist = () => 0; + + player.conquer(game.ref(7, 5)); + player.conquer(game.ref(7, 15)); + const spawn1 = player.canBuild(UnitType.Port, game.ref(7, 5)); + const spawn2 = player.canBuild(UnitType.Port, game.ref(7, 15)); + if (spawn1 === false || spawn2 === false) { + throw new Error("Unable to build ports for test"); + } + const port1 = player.buildUnit(UnitType.Port, spawn1, {}); + player.buildUnit(UnitType.Port, spawn2, {}); + + const execution = new PortExecution(port1); + execution.init(game, 0); + execution.tick(0); + + const ports = execution.tradingPorts(); + + // Own port at level 1 should appear once (base weight only, no bonuses) + expect(ports.length).toBe(1); + }); }); diff --git a/tests/core/executions/TradeShipExecution.test.ts b/tests/core/executions/TradeShipExecution.test.ts index 9c91442b5f..2eb223d332 100644 --- a/tests/core/executions/TradeShipExecution.test.ts +++ b/tests/core/executions/TradeShipExecution.test.ts @@ -141,4 +141,29 @@ describe("TradeShipExecution", () => { expect(tradeShipExecution.isActive()).toBe(false); expect(game.displayMessage).toHaveBeenCalled(); }); + + it("self-trade: cancels if destination port is captured by another player mid-journey", () => { + // srcPort and dstPort both owned by origOwner — self-trade + dstPort.owner = vi.fn(() => origOwner); + const selfTradeExec = new TradeShipExecution(origOwner, srcPort, dstPort); + selfTradeExec.init(game, 0); + selfTradeExec["pathFinder"] = { + next: vi.fn(() => ({ status: PathStatus.NEXT, node: 32 })), + findPath: vi.fn((from: number) => [from]), + } as any; + selfTradeExec["tradeShip"] = tradeShip; + + // First tick: self-trade is active, dstPort still owned by origOwner + selfTradeExec.tick(1); + expect(selfTradeExec.isActive()).toBe(true); + + // Mid-journey: Player B (dstOwner) captures dstPort — port stays active + dstPort.owner = vi.fn(() => dstOwner); + + selfTradeExec.tick(2); + expect(tradeShip.delete).toHaveBeenCalledWith(false); + expect(selfTradeExec.isActive()).toBe(false); + // No gold should have been awarded to origOwner + expect(origOwner.addGold).not.toHaveBeenCalled(); + }); });