diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 971a9cd71..bd377d35d 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -713,17 +713,12 @@ export class ClientGameRunner { private sendBoatAttackIntent(tile: TileRef) { if (!this.myPlayer) return; - this.myPlayer.bestTransportShipSpawn(tile).then((spawn: number | false) => { - if (this.myPlayer === null) throw new Error("not initialized"); - this.eventBus.emit( - new SendBoatAttackIntentEvent( - this.gameView.owner(tile).id(), - tile, - this.myPlayer.troops() * this.renderer.uiState.attackRatio, - spawn === false ? null : spawn, - ), - ); - }); + this.eventBus.emit( + new SendBoatAttackIntentEvent( + tile, + this.myPlayer.troops() * this.renderer.uiState.attackRatio, + ), + ); } private canAutoBoat(actions: PlayerActions, tile: TileRef): boolean { diff --git a/src/client/Transport.ts b/src/client/Transport.ts index d370c6ba8..58307e113 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -81,10 +81,8 @@ export class SendAttackIntentEvent implements GameEvent { export class SendBoatAttackIntentEvent implements GameEvent { constructor( - public readonly targetID: PlayerID | null, public readonly dst: TileRef, public readonly troops: number, - public readonly src: TileRef | null = null, ) {} } @@ -498,10 +496,8 @@ export class Transport { this.sendIntent({ type: "boat", clientID: this.lobbyConfig.clientID, - targetID: event.targetID, troops: event.troops, dst: event.dst, - src: event.src, }); } diff --git a/src/client/graphics/layers/PlayerActionHandler.ts b/src/client/graphics/layers/PlayerActionHandler.ts index 672cc2baf..54714cadb 100644 --- a/src/client/graphics/layers/PlayerActionHandler.ts +++ b/src/client/graphics/layers/PlayerActionHandler.ts @@ -1,5 +1,5 @@ import { EventBus } from "../../../core/EventBus"; -import { PlayerActions, PlayerID } from "../../../core/game/Game"; +import { PlayerActions } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { PlayerView } from "../../../core/game/GameView"; import { @@ -39,18 +39,11 @@ export class PlayerActionHandler { ); } - handleBoatAttack( - player: PlayerView, - targetId: PlayerID | null, - targetTile: TileRef, - spawnTile: TileRef | null, - ) { + handleBoatAttack(player: PlayerView, targetTile: TileRef) { this.eventBus.emit( new SendBoatAttackIntentEvent( - targetId, targetTile, this.uiState.attackRatio * player.troops(), - spawnTile, ), ); } diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index 44d63bb1f..1dcf3eb90 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -548,17 +548,7 @@ export const boatMenuElement: MenuElement = { color: COLORS.boat, action: async (params: MenuElementParams) => { - const spawn = await params.playerActionHandler.findBestTransportShipSpawn( - params.myPlayer, - params.tile, - ); - - params.playerActionHandler.handleBoatAttack( - params.myPlayer, - params.selected?.id() ?? null, - params.tile, - spawn !== false ? spawn : null, - ); + params.playerActionHandler.handleBoatAttack(params.myPlayer, params.tile); params.closeMenu(); }, diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 28362063f..d225857c5 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -284,10 +284,8 @@ export const SpawnIntentSchema = BaseIntentSchema.extend({ export const BoatAttackIntentSchema = BaseIntentSchema.extend({ type: z.literal("boat"), - targetID: ID.nullable(), troops: z.number().nonnegative(), dst: z.number(), - src: z.number().nullable(), }); export const AllianceRequestIntentSchema = BaseIntentSchema.extend({ diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index a161e5eb1..56d66e547 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -72,13 +72,7 @@ export class Executor { case "spawn": return new SpawnExecution(this.gameID, player.info(), intent.tile); case "boat": - return new TransportShipExecution( - player, - intent.targetID, - intent.dst, - intent.troops, - intent.src, - ); + return new TransportShipExecution(player, intent.dst, intent.troops); case "allianceRequest": return new AllianceRequestExecution(player, intent.recipient); case "allianceRequestReply": diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index 6020d0489..0b93aa9fc 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -4,7 +4,6 @@ import { Game, MessageType, Player, - PlayerID, TerraNullius, Unit, UnitType, @@ -16,33 +15,28 @@ import { PathStatus, SteppingPathFinder } from "../pathfinding/types"; import { AttackExecution } from "./AttackExecution"; const malusForRetreat = 25; + export class TransportShipExecution implements Execution { - private lastMove: number; + private active = true; // TODO: make this configurable private ticksPerMove = 1; - - private active = true; + private lastMove: number; private mg: Game; private target: Player | TerraNullius; - - // TODO make private - public path: TileRef[]; - private dst: TileRef | null; - - private boat: Unit; - private pathFinder: SteppingPathFinder; + private dst: TileRef | null; + private src: TileRef | null; + private boat: Unit; + private originalOwner: Player; constructor( private attacker: Player, - private targetID: PlayerID | null, private ref: TileRef, - private startTroops: number, - private src: TileRef | null, + private troops: number, ) { this.originalOwner = this.attacker; } @@ -52,24 +46,15 @@ export class TransportShipExecution implements Execution { } init(mg: Game, ticks: number) { - if (this.targetID !== null && !mg.hasPlayer(this.targetID)) { - console.warn(`TransportShipExecution: target ${this.targetID} not found`); - this.active = false; - return; - } if (!mg.isValidRef(this.ref)) { console.warn(`TransportShipExecution: ref ${this.ref} not valid`); this.active = false; return; } - if (this.src !== null && !mg.isValidRef(this.src)) { - console.warn(`TransportShipExecution: src ${this.src} not valid`); - this.active = false; - return; - } this.lastMove = ticks; this.mg = mg; + this.target = mg.owner(this.ref); this.pathFinder = PathFinding.Water(mg); if ( @@ -87,73 +72,51 @@ export class TransportShipExecution implements Execution { return; } - if ( - this.targetID === null || - this.targetID === this.mg.terraNullius().id() - ) { - this.target = mg.terraNullius(); - } else { - this.target = mg.player(this.targetID); - } if (this.target.isPlayer() && !this.attacker.canAttackPlayer(this.target)) { this.active = false; return; } - this.startTroops ??= this.mg + this.troops ??= this.mg .config() .boatAttackAmount(this.attacker, this.target); - - this.startTroops = Math.min(this.startTroops, this.attacker.troops()); + this.troops = Math.min(this.troops, this.attacker.troops()); this.dst = targetTransportTile(this.mg, this.ref); + if (this.dst === null) { console.warn( - `${this.attacker} cannot send ship to ${this.target}, cannot find attack tile`, + `${this.attacker} cannot send ship to ${this.target}, cannot find target tile`, ); this.active = false; return; } - const closestTileSrc = this.attacker.canBuild( - UnitType.TransportShip, - this.dst, - ); - if (closestTileSrc === false) { - console.warn(`can't build transport ship`); + const src = this.attacker.canBuild(UnitType.TransportShip, this.dst); + + if (src === false) { + console.warn( + `${this.attacker} cannot send ship to ${this.target}, cannot find start tile`, + ); this.active = false; return; } - if (this.src === null) { - // Only update the src if it's not already set - // because we assume that the src is set to the best spawn tile - this.src = closestTileSrc; - } else { - if ( - this.mg.owner(this.src) !== this.attacker || - !this.mg.isShore(this.src) - ) { - console.warn( - `src is not a shore tile or not owned by: ${this.attacker.name()}`, - ); - this.src = closestTileSrc; - } - } + this.src = src; this.boat = this.attacker.buildUnit(UnitType.TransportShip, this.src, { - troops: this.startTroops, - targetTile: this.dst ?? undefined, + troops: this.troops, + targetTile: this.dst, }); // Notify the target player about the incoming naval invasion - if (this.targetID && this.targetID !== mg.terraNullius().id()) { + if (this.target.id() !== mg.terraNullius().id()) { mg.displayIncomingUnit( this.boat.id(), // TODO TranslateText `Naval invasion incoming from ${this.attacker.displayName()}`, MessageType.NAVAL_INVASION_INBOUND, - this.targetID, + this.target.id(), ); } @@ -254,7 +217,7 @@ export class TransportShipExecution implements Execution { new AttackExecution( this.boat.troops(), this.attacker, - this.targetID, + this.target.id(), this.dst, false, ), @@ -278,7 +241,7 @@ export class TransportShipExecution implements Execution { const map = this.mg.map(); const boatTile = this.boat.tile(); console.warn( - `TransportShip path not found: boat@(${map.x(boatTile)},${map.y(boatTile)}) -> dst@(${map.x(this.dst)},${map.y(this.dst)}), attacker=${this.attacker.id()}, target=${this.targetID}`, + `TransportShip path not found: boat@(${map.x(boatTile)},${map.y(boatTile)}) -> dst@(${map.x(this.dst)},${map.y(this.dst)}), attacker=${this.attacker.id()}, target=${this.target.id()}`, ); this.attacker.addTroops(this.boat.troops()); this.boat.delete(false); diff --git a/src/core/execution/utils/AiAttackBehavior.ts b/src/core/execution/utils/AiAttackBehavior.ts index 4e27b6bb4..fd252316b 100644 --- a/src/core/execution/utils/AiAttackBehavior.ts +++ b/src/core/execution/utils/AiAttackBehavior.ts @@ -114,13 +114,7 @@ export class AiAttackBehavior { } this.game.addExecution( - new TransportShipExecution( - this.player, - this.game.owner(dst).id(), - dst, - this.player.troops() / 5, - null, - ), + new TransportShipExecution(this.player, dst, this.player.troops() / 5), ); return; } @@ -741,13 +735,7 @@ export class AiAttackBehavior { } this.game.addExecution( - new TransportShipExecution( - this.player, - target.id(), - closest.y, - troops, - null, - ), + new TransportShipExecution(this.player, closest.y, troops), ); } diff --git a/src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts b/src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts index 78a8ff6bc..2958de79a 100644 --- a/src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts +++ b/src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts @@ -42,8 +42,8 @@ export class AStarWaterHierarchical implements PathFinder { maxMultiClusterNodes, ); - // BoundedAStar for short path multi-source (120 + 2*10 padding = 140) - const shortPathSize = 140; + // BoundedAStar for short path multi-source + const shortPathSize = 260; // 2 * (120 + padding 10) const maxShortPathNodes = shortPathSize * shortPathSize; this.localAStarShortPath = new AStarWaterBounded(map, maxShortPathNodes); diff --git a/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts b/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts index 5b4bd0b0c..549e047b5 100644 --- a/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts +++ b/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts @@ -8,13 +8,15 @@ import { PathFinder } from "../types"; const ENDPOINT_REFINEMENT_TILES = 50; const LOCAL_ASTAR_MAX_AREA = 100 * 100; -const LOS_MIN_MAGNITUDE = 3; +const LOS_MIN_MAGNITUDE_PASS1 = 2; +const LOS_MIN_MAGNITUDE_PASS2 = 3; const MAGNITUDE_MASK = 0x1f; /** - * Water path smoother transformer with two passes: + * Water path smoother transformer: * 1. Binary search LOS smoothing (avoids shallow water) * 2. Local A* refinement on endpoints (first/last N tiles) + * 3. Binary search LOS smoothing again (farther from shore) */ export class SmoothingWaterTransformer implements PathFinder { private readonly mapWidth: number; @@ -47,20 +49,24 @@ export class SmoothingWaterTransformer implements PathFinder { } // Pass 1: LOS smoothing with binary search - let smoothed = DebugSpan.wrap("smoother:los", () => this.losSmooth(path)); + let smoothed = DebugSpan.wrap("smoother:los", () => + this.losSmooth(path, LOS_MIN_MAGNITUDE_PASS1), + ); // Pass 2: Local A* refinement on endpoints smoothed = DebugSpan.wrap("smoother:refine", () => this.refineEndpoints(smoothed), ); - // Pass 3: LOS smoothing again (refinement may create new shortcut opportunities) - smoothed = DebugSpan.wrap("smoother:los2", () => this.losSmooth(smoothed)); + // Pass 3: LOS smoothing again, farther from the shore + smoothed = DebugSpan.wrap("smoother:los2", () => + this.losSmooth(smoothed, LOS_MIN_MAGNITUDE_PASS2), + ); return smoothed; } - private losSmooth(path: TileRef[]): TileRef[] { + private losSmooth(path: TileRef[], minMagnitude: number): TileRef[] { const result: TileRef[] = [path[0]]; let current = 0; @@ -72,7 +78,7 @@ export class SmoothingWaterTransformer implements PathFinder { while (lo <= hi) { const mid = (lo + hi) >>> 1; - if (this.canSee(path[current], path[mid])) { + if (this.canSee(path[current], path[mid], minMagnitude)) { farthest = mid; lo = mid + 1; } else { @@ -188,7 +194,7 @@ export class SmoothingWaterTransformer implements PathFinder { return this.localAStar.searchBounded(from, to, bounds); } - private canSee(from: TileRef, to: TileRef): boolean { + private canSee(from: TileRef, to: TileRef, minMagnitude: number): boolean { const x0 = from % this.mapWidth; const y0 = (from / this.mapWidth) | 0; const x1 = to % this.mapWidth; @@ -214,7 +220,7 @@ export class SmoothingWaterTransformer implements PathFinder { // Check magnitude - avoid shallow water const magnitude = this.terrain[tile] & MAGNITUDE_MASK; - if (magnitude < LOS_MIN_MAGNITUDE) return false; + if (magnitude < minMagnitude) return false; if (x === x1 && y === y1) return true; @@ -229,10 +235,7 @@ export class SmoothingWaterTransformer implements PathFinder { const intermediateTile = (y * this.mapWidth + x) as TileRef; const intMag = this.terrain[intermediateTile] & MAGNITUDE_MASK; - if ( - !this.isTraversable(intermediateTile) || - intMag < LOS_MIN_MAGNITUDE - ) { + if (!this.isTraversable(intermediateTile) || intMag < minMagnitude) { // Try alternative path x -= sx; err += dy; @@ -241,7 +244,7 @@ export class SmoothingWaterTransformer implements PathFinder { const altTile = (y * this.mapWidth + x) as TileRef; const altMag = this.terrain[altTile] & MAGNITUDE_MASK; - if (!this.isTraversable(altTile) || altMag < LOS_MIN_MAGNITUDE) + if (!this.isTraversable(altTile) || altMag < minMagnitude) return false; x += sx; diff --git a/tests/Attack.test.ts b/tests/Attack.test.ts index 75e536f08..e2bf619dc 100644 --- a/tests/Attack.test.ts +++ b/tests/Attack.test.ts @@ -21,10 +21,8 @@ let defender: Player; let defenderSpawn: TileRef; let attackerSpawn: TileRef; -function sendBoat(target: TileRef, source: TileRef, troops: number) { - game.addExecution( - new TransportShipExecution(defender, null, target, troops, source), - ); +function sendBoat(target: TileRef, troops: number) { + game.addExecution(new TransportShipExecution(defender, target, troops)); } const immunityPhaseTicks = 10; @@ -114,7 +112,7 @@ describe("Attack", () => { constructionExecution(game, defender, 1, 1, UnitType.MissileSilo); expect(defender.units(UnitType.MissileSilo)).toHaveLength(1); - sendBoat(game.ref(15, 8), game.ref(10, 5), 100); + sendBoat(game.ref(15, 8), 100); constructionExecution(game, defender, 0, 15, UnitType.AtomBomb, 3); const nuke = defender.units(UnitType.AtomBomb)[0]; @@ -133,7 +131,7 @@ describe("Attack", () => { const player_start_troops = defender.troops(); const boat_troops = player_start_troops * 0.5; - sendBoat(game.ref(15, 8), game.ref(10, 5), boat_troops); + sendBoat(game.ref(15, 8), boat_troops); game.executeNextTick(); @@ -357,7 +355,7 @@ describe("Attack immunity", () => { null, "playerB_id", ); - playerB = addPlayerToGame(playerBInfo, game, game.ref(0, 11)); + playerB = addPlayerToGame(playerBInfo, game, game.ref(7, 15)); while (game.inSpawnPhase()) { game.executeNextTick(); @@ -412,15 +410,7 @@ describe("Attack immunity", () => { test("Should not be able to send a boat during immunity phase", async () => { // Player A sends a boat targeting Player B - game.addExecution( - new TransportShipExecution( - playerA, - playerB.id(), - game.ref(15, 8), - 10, - game.ref(10, 5), - ), - ); + game.addExecution(new TransportShipExecution(playerA, game.ref(7, 15), 10)); game.executeNextTick(); expect(playerA.units(UnitType.TransportShip)).toHaveLength(0); }); @@ -428,15 +418,7 @@ describe("Attack immunity", () => { test("Should be able to send a boat after immunity phase", async () => { waitForImmunityToEnd(); // Player A sends a boat targeting Player B - game.addExecution( - new TransportShipExecution( - playerA, - playerB.id(), - game.ref(15, 8), - 10, - game.ref(7, 0), - ), - ); + game.addExecution(new TransportShipExecution(playerA, game.ref(7, 15), 10)); game.executeNextTick(); expect(playerA.units(UnitType.TransportShip)).toHaveLength(1); }); diff --git a/tests/Disconnected.test.ts b/tests/Disconnected.test.ts index 3dcc7011f..c52f00911 100644 --- a/tests/Disconnected.test.ts +++ b/tests/Disconnected.test.ts @@ -350,13 +350,7 @@ describe("Disconnected", () => { const enemyShoreTile = game.map().ref(coastX, 15); game.addExecution( - new TransportShipExecution( - player2, - null, - enemyShoreTile, - 100, - game.map().ref(coastX, 1), - ), + new TransportShipExecution(player2, enemyShoreTile, 100), ); executeTicks(game, 1); @@ -387,13 +381,7 @@ describe("Disconnected", () => { const enemyShoreTile = game.map().ref(coastX, 15); game.addExecution( - new TransportShipExecution( - player2, - null, - enemyShoreTile, - 100, - game.map().ref(coastX, 1), - ), + new TransportShipExecution(player2, enemyShoreTile, 100), ); executeTicks(game, 1); @@ -425,13 +413,7 @@ describe("Disconnected", () => { const boatTroops = 100; game.addExecution( - new TransportShipExecution( - player2, - null, - enemyShoreTile, - boatTroops, - game.map().ref(coastX, 1), - ), + new TransportShipExecution(player2, enemyShoreTile, boatTroops), ); executeTicks(game, 1);