better transport ship spawn (#587)

## Description:

Taken from PR #506

Improve transport source tile by considering border extremums

Only calculate better spawn tile for humans, and have the sender
calculate it and send the src tile in the intent for better performance.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
- [x] I understand that submitting code with bugs that could have been
caught through manual testing blocks releases and new features for all
contributors

## Please put your Discord username so you can be contacted if a bug or
regression is found:

<DISCORD USERNAME>
evan

Co-authored-by: evan <openfrontio@gmail.com>
This commit is contained in:
evanpelle
2025-04-21 19:49:17 -07:00
committed by GitHub
parent 5763bc21dd
commit 71849b47cd
19 changed files with 438 additions and 190 deletions
+9 -3
View File
@@ -353,7 +353,13 @@ export class ClientGameRunner {
}
}
this.myPlayer.actions(tile).then((actions) => {
console.log(`got actions: ${JSON.stringify(actions)}`);
const bu = actions.buildableUnits.find(
(bu) => bu.type == UnitType.TransportShip,
);
if (bu == null) {
console.warn(`no transport ship buildable units`);
return;
}
if (actions.canAttack) {
this.eventBus.emit(
new SendAttackIntentEvent(
@@ -362,8 +368,8 @@ export class ClientGameRunner {
),
);
} else if (
actions.canBoat !== false &&
this.shouldBoat(tile, actions.canBoat) &&
bu.canBuild !== false &&
this.shouldBoat(tile, bu.canBuild) &&
this.gameView.isLand(tile)
) {
this.eventBus.emit(
+6 -3
View File
@@ -68,8 +68,9 @@ export class SendAttackIntentEvent implements GameEvent {
export class SendBoatAttackIntentEvent implements GameEvent {
constructor(
public readonly targetID: PlayerID,
public readonly cell: Cell,
public readonly dst: Cell,
public readonly troops: number,
public readonly src: Cell | null = null,
) {}
}
@@ -414,8 +415,10 @@ export class Transport {
clientID: this.lobbyConfig.clientID,
targetID: event.targetID,
troops: event.troops,
x: event.cell.x,
y: event.cell.y,
dstX: event.dst.x,
dstY: event.dst.y,
srcX: event.src?.x,
srcY: event.src?.y,
});
}
+1 -1
View File
@@ -301,7 +301,7 @@ export class BuildMenu extends LitElement implements Layer {
if (!unit) {
return false;
}
return unit[0].canBuild;
return unit[0].canBuild !== false;
}
private cost(item: BuildItemDisplay): number {
+25 -9
View File
@@ -8,7 +8,12 @@ import swordIcon from "../../../../resources/images/SwordIconWhite.svg";
import traitorIcon from "../../../../resources/images/TraitorIconWhite.svg";
import { consolex } from "../../../core/Consolex";
import { EventBus } from "../../../core/EventBus";
import { Cell, PlayerActions, TerraNullius } from "../../../core/game/Game";
import {
Cell,
PlayerActions,
TerraNullius,
UnitType,
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { ClientID } from "../../../core/Schemas";
@@ -374,15 +379,26 @@ export class RadialMenu implements Layer {
);
});
}
if (actions.canBoat) {
if (
actions.buildableUnits.some((bu) => bu.type == UnitType.TransportShip)
) {
this.activateMenuElement(Slot.Boat, "#3f6ab1", boatIcon, () => {
this.eventBus.emit(
new SendBoatAttackIntentEvent(
this.g.owner(tile).id(),
this.clickedCell,
this.uiState.attackRatio * myPlayer.troops(),
),
);
// BestTransportShipSpawn is an expensive operation, so
// we calculate it here and send the spawn tile to other clients.
myPlayer.bestTransportShipSpawn(tile).then((spawn) => {
if (spawn == false) {
return;
}
this.eventBus.emit(
new SendBoatAttackIntentEvent(
this.g.owner(tile).id(),
this.clickedCell,
this.uiState.attackRatio * myPlayer.troops(),
new Cell(this.g.x(spawn), this.g.y(spawn)),
),
);
});
});
}
if (actions.canAttack) {
+11 -1
View File
@@ -16,6 +16,7 @@ import {
PlayerType,
} from "./game/Game";
import { createGame } from "./game/GameImpl";
import { TileRef } from "./game/GameMap";
import {
ErrorUpdate,
GameUpdateType,
@@ -157,7 +158,6 @@ export class GameRunner {
const player = this.game.player(playerID);
const tile = this.game.ref(x, y);
const actions = {
canBoat: player.canBoat(tile),
canAttack: player.canAttack(tile),
buildableUnits: player.buildableUnits(tile),
canSendEmojiAllPlayers: player.canSendEmoji(AllPlayers),
@@ -194,4 +194,14 @@ export class GameRunner {
borderTiles: player.borderTiles(),
} as PlayerBorderTiles;
}
public bestTransportShipSpawn(
playerID: PlayerID,
targetTile: TileRef,
): TileRef | false {
const player = this.game.player(playerID);
if (!player.isPlayer()) {
throw new Error(`player with id ${playerID} not found`);
}
return player.bestTransportShipSpawn(targetTile);
}
}
+4 -2
View File
@@ -196,8 +196,10 @@ export const BoatAttackIntentSchema = BaseIntentSchema.extend({
type: z.literal("boat"),
targetID: ID.nullable(),
troops: z.number().nullable(),
x: z.number(),
y: z.number(),
dstX: z.number(),
dstY: z.number(),
srcX: z.number(),
srcY: z.number(),
});
export const AllianceRequestIntentSchema = BaseIntentSchema.extend({
+2 -68
View File
@@ -1,8 +1,8 @@
import DOMPurify from "dompurify";
import { customAlphabet } from "nanoid";
import twemoji from "twemoji";
import { Cell, Game, Player, Team, Unit } from "./game/Game";
import { andFN, GameMap, manhattanDistFN, TileRef } from "./game/GameMap";
import { Cell, Team, Unit } from "./game/Game";
import { GameMap, TileRef } from "./game/GameMap";
import {
AllPlayersStats,
ClientID,
@@ -57,72 +57,6 @@ export function distSortUnit(
};
}
// TODO: refactor to new file
export function sourceDstOceanShore(
gm: Game,
src: Player,
tile: TileRef,
): [TileRef | null, TileRef | null] {
const dst = gm.owner(tile);
const srcTile = closestShoreFromPlayer(gm, src, tile);
let dstTile: TileRef | null = null;
if (dst.isPlayer()) {
dstTile = closestShoreFromPlayer(gm, dst as Player, tile);
} else {
dstTile = closestShoreTN(gm, tile, 50);
}
return [srcTile, dstTile];
}
export function targetTransportTile(gm: Game, tile: TileRef): TileRef | null {
const dst = gm.playerBySmallID(gm.ownerID(tile));
let dstTile: TileRef | null = null;
if (dst.isPlayer()) {
dstTile = closestShoreFromPlayer(gm, dst as Player, tile);
} else {
dstTile = closestShoreTN(gm, tile, 50);
}
return dstTile;
}
export function closestShoreFromPlayer(
gm: GameMap,
player: Player,
target: TileRef,
): TileRef | null {
const shoreTiles = Array.from(player.borderTiles()).filter((t) =>
gm.isShore(t),
);
if (shoreTiles.length == 0) {
return null;
}
return shoreTiles.reduce((closest, current) => {
const closestDistance = gm.manhattanDist(target, closest);
const currentDistance = gm.manhattanDist(target, current);
return currentDistance < closestDistance ? current : closest;
});
}
function closestShoreTN(
gm: GameMap,
tile: TileRef,
searchDist: number,
): TileRef {
const tn = Array.from(
gm.bfs(
tile,
andFN((_, t) => !gm.hasOwner(t), manhattanDistFN(tile, searchDist)),
),
)
.filter((t) => gm.isShore(t))
.sort((a, b) => gm.manhattanDist(tile, a) - gm.manhattanDist(tile, b));
if (tn.length == 0) {
return null;
}
return tn[0];
}
export function simpleHash(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
+2 -1
View File
@@ -68,8 +68,9 @@ export class Executor {
return new TransportShipExecution(
playerID,
intent.targetID,
this.mg.ref(intent.x, intent.y),
this.mg.ref(intent.dstX, intent.dstY),
intent.troops,
this.mg.ref(intent.srcX, intent.srcY),
);
case "allianceRequest":
return new AllianceRequestExecution(playerID, intent.recipient);
+2
View File
@@ -378,6 +378,7 @@ export class FakeHumanExecution implements Execution {
other.id(),
closest.y,
this.player.troops() / 5,
null,
),
);
}
@@ -538,6 +539,7 @@ export class FakeHumanExecution implements Execution {
this.mg.owner(dst).id(),
dst,
this.player.troops() / 5,
null,
),
);
return;
+13 -5
View File
@@ -10,9 +10,9 @@ import {
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { targetTransportTile } from "../game/TransportShipUtils";
import { PathFindResultType } from "../pathfinding/AStar";
import { PathFinder } from "../pathfinding/PathFinding";
import { targetTransportTile } from "../Util";
import { AttackExecution } from "./AttackExecution";
export class TransportShipExecution implements Execution {
@@ -30,7 +30,6 @@ export class TransportShipExecution implements Execution {
// TODO make private
public path: TileRef[];
private src: TileRef | null;
private dst: TileRef | null;
private boat: Unit;
@@ -42,6 +41,7 @@ export class TransportShipExecution implements Execution {
private targetID: PlayerID | null,
private ref: TileRef,
private troops: number | null,
private src: TileRef | null,
) {}
activeDuringSpawnPhase(): boolean {
@@ -113,14 +113,22 @@ export class TransportShipExecution implements Execution {
this.active = false;
return;
}
const src = this.attacker.canBuild(UnitType.TransportShip, this.dst);
if (src == false) {
const closestTileSrc = this.attacker.canBuild(
UnitType.TransportShip,
this.dst,
);
if (closestTileSrc == false) {
consolex.warn(`can't build transport ship`);
this.active = false;
return;
}
this.src = src;
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;
}
this.boat = this.attacker.buildUnit(
UnitType.TransportShip,
+3 -3
View File
@@ -444,8 +444,9 @@ export interface Player {
// Misc
toUpdate(): PlayerUpdate;
playerProfile(): PlayerProfile;
canBoat(tile: TileRef): TileRef | false;
tradingPorts(port: Unit): Unit[];
// WARNING: this operation is expensive.
bestTransportShipSpawn(tile: TileRef): TileRef | false;
}
export interface Game extends GameMap {
@@ -502,7 +503,6 @@ export interface Game extends GameMap {
}
export interface PlayerActions {
canBoat: TileRef | false;
canAttack: boolean;
buildableUnits: BuildableUnit[];
canSendEmojiAllPlayers: boolean;
@@ -510,7 +510,7 @@ export interface PlayerActions {
}
export interface BuildableUnit {
canBuild: boolean;
canBuild: TileRef | false;
type: UnitType;
cost: number;
}
+4
View File
@@ -242,6 +242,10 @@ export class PlayerView {
return this.game.worker.playerProfile(this.smallID());
}
bestTransportShipSpawn(targetTile: TileRef): Promise<TileRef | false> {
return this.game.worker.transportShipSpawn(this.id(), targetTile);
}
transitiveTargets(): PlayerView[] {
return [...this.targets(), ...this.allies().flatMap((p) => p.targets())];
}
+10 -87
View File
@@ -4,12 +4,10 @@ import { PseudoRandom } from "../PseudoRandom";
import { ClientID } from "../Schemas";
import {
assertNever,
closestShoreFromPlayer,
distSortUnit,
maxInt,
minInt,
simpleHash,
targetTransportTile,
toInt,
within,
} from "../Util";
@@ -44,6 +42,10 @@ import { GameImpl } from "./GameImpl";
import { andFN, manhattanDistFN, TileRef } from "./GameMap";
import { AttackUpdate, GameUpdateType, PlayerUpdate } from "./GameUpdates";
import { TerraNulliusImpl } from "./TerraNulliusImpl";
import {
bestShoreDeploymentSource as bestTranpsortShipSpawn,
canBuildTransportShip,
} from "./TransportShipUtils";
import { UnitImpl } from "./UnitImpl";
interface Target {
@@ -735,7 +737,7 @@ export class PlayerImpl implements Player {
return Object.values(UnitType).map((u) => {
return {
type: u,
canBuild: this.canBuild(u, tile, validTiles) != false,
canBuild: this.canBuild(u, tile, validTiles),
cost: this.mg.config().unitInfo(u).cost(this),
} as BuildableUnit;
});
@@ -784,7 +786,7 @@ export class PlayerImpl implements Player {
case UnitType.SAMMissile:
return targetTile;
case UnitType.TransportShip:
return this.transportShipSpawn(targetTile);
return canBuildTransportShip(this.mg, this, targetTile);
case UnitType.TradeShip:
return this.tradeShipSpawn(targetTile);
case UnitType.MissileSilo:
@@ -906,17 +908,6 @@ export class PlayerImpl implements Player {
return valid;
}
transportShipSpawn(targetTile: TileRef): TileRef | false {
if (!this.mg.isShore(targetTile)) {
return false;
}
const spawn = closestShoreFromPlayer(this.mg, this, targetTile);
if (spawn == null) {
return false;
}
return spawn;
}
tradeShipSpawn(targetTile: TileRef): TileRef | false {
const spawns = this.units(UnitType.Port).filter(
(u) => u.tile() == targetTile,
@@ -957,78 +948,6 @@ export class PlayerImpl implements Player {
return rel;
}
public canBoat(tile: TileRef): TileRef | false {
if (
this.units(UnitType.TransportShip).length >=
this.mg.config().boatMaxNumber()
) {
return false;
}
const dst = targetTransportTile(this.mg, tile);
if (dst == null) {
return false;
}
const other = this.mg.owner(tile);
if (other == this) {
return false;
}
if (other.isPlayer() && this.isFriendly(other)) {
return false;
}
if (this.mg.isOceanShore(dst)) {
let myPlayerBordersOcean = false;
for (const bt of this.borderTiles()) {
if (this.mg.isOceanShore(bt)) {
myPlayerBordersOcean = true;
break;
}
}
let otherPlayerBordersOcean = false;
if (!this.mg.hasOwner(tile)) {
otherPlayerBordersOcean = true;
} else {
for (const bt of (other as Player).borderTiles()) {
if (this.mg.isOceanShore(bt)) {
otherPlayerBordersOcean = true;
break;
}
}
}
if (myPlayerBordersOcean && otherPlayerBordersOcean) {
return this.canBuild(UnitType.TransportShip, dst);
} else {
return false;
}
}
// Now we are boating in a lake, so do a bfs from target until we find
// a border tile owned by the player
const tiles = this.mg.bfs(
dst,
andFN(
manhattanDistFN(dst, 300),
(_, t: TileRef) => this.mg.isLake(t) || this.mg.isShore(t),
),
);
const sorted = Array.from(tiles).sort(
(a, b) => this.mg.manhattanDist(dst, a) - this.mg.manhattanDist(dst, b),
);
for (const t of sorted) {
if (this.mg.owner(t) == this) {
return this.canBuild(UnitType.TransportShip, dst);
}
}
return false;
}
createAttack(
target: Player | TerraNullius,
troops: number,
@@ -1097,6 +1016,10 @@ export class PlayerImpl implements Player {
}
}
bestTransportShipSpawn(targetTile: TileRef): TileRef | false {
return bestTranpsortShipSpawn(this.mg, this, targetTile);
}
// It's a probability list, so if an element appears twice it's because it's
// twice more likely to be picked later.
tradingPorts(port: Unit): Unit[] {
+272
View File
@@ -0,0 +1,272 @@
import { PathFindResultType } from "../pathfinding/AStar";
import { PathFinder } from "../pathfinding/PathFinding";
import { Game, Player, UnitType } from "./Game";
import { andFN, GameMap, manhattanDistFN, TileRef } from "./GameMap";
export function canBuildTransportShip(
game: Game,
player: Player,
tile: TileRef,
): TileRef | false {
if (
player.units(UnitType.TransportShip).length >= game.config().boatMaxNumber()
) {
return false;
}
const dst = targetTransportTile(game, tile);
if (dst == null) {
return false;
}
const other = game.owner(tile);
if (other == player) {
return false;
}
if (other.isPlayer() && player.isFriendly(other)) {
return false;
}
if (game.isOceanShore(dst)) {
let myPlayerBordersOcean = false;
for (const bt of player.borderTiles()) {
if (game.isOceanShore(bt)) {
myPlayerBordersOcean = true;
break;
}
}
let otherPlayerBordersOcean = false;
if (!game.hasOwner(tile)) {
otherPlayerBordersOcean = true;
} else {
for (const bt of (other as Player).borderTiles()) {
if (game.isOceanShore(bt)) {
otherPlayerBordersOcean = true;
break;
}
}
}
if (myPlayerBordersOcean && otherPlayerBordersOcean) {
return transportShipSpawn(game, player, dst);
} else {
return false;
}
}
// Now we are boating in a lake, so do a bfs from target until we find
// a border tile owned by the player
const tiles = game.bfs(
dst,
andFN(
manhattanDistFN(dst, 300),
(_, t: TileRef) => game.isLake(t) || game.isShore(t),
),
);
const sorted = Array.from(tiles).sort(
(a, b) => game.manhattanDist(dst, a) - game.manhattanDist(dst, b),
);
for (const t of sorted) {
if (game.owner(t) == player) {
return transportShipSpawn(game, player, t);
}
}
return false;
}
function transportShipSpawn(
game: Game,
player: Player,
targetTile: TileRef,
): TileRef | false {
if (!game.isShore(targetTile)) {
return false;
}
const spawn = closestShoreFromPlayer(game, player, targetTile);
if (spawn == null) {
return false;
}
return spawn;
}
export function sourceDstOceanShore(
gm: Game,
src: Player,
tile: TileRef,
): [TileRef | null, TileRef | null] {
const dst = gm.owner(tile);
const srcTile = closestShoreFromPlayer(gm, src, tile);
let dstTile: TileRef | null = null;
if (dst.isPlayer()) {
dstTile = closestShoreFromPlayer(gm, dst as Player, tile);
} else {
dstTile = closestShoreTN(gm, tile, 50);
}
return [srcTile, dstTile];
}
export function targetTransportTile(gm: Game, tile: TileRef): TileRef | null {
const dst = gm.playerBySmallID(gm.ownerID(tile));
let dstTile: TileRef | null = null;
if (dst.isPlayer()) {
dstTile = closestShoreFromPlayer(gm, dst as Player, tile);
} else {
dstTile = closestShoreTN(gm, tile, 50);
}
return dstTile;
}
export function closestShoreFromPlayer(
gm: GameMap,
player: Player,
target: TileRef,
): TileRef | null {
const shoreTiles = Array.from(player.borderTiles()).filter((t) =>
gm.isShore(t),
);
if (shoreTiles.length == 0) {
return null;
}
return shoreTiles.reduce((closest, current) => {
const closestDistance = gm.manhattanDist(target, closest);
const currentDistance = gm.manhattanDist(target, current);
return currentDistance < closestDistance ? current : closest;
});
}
/**
* Finds the best shore tile for deployment among the player's shore tiles for the shortest route.
* Calculates paths from 4 extremum tiles and the Manhattan-closest tile.
*/
export function bestShoreDeploymentSource(
gm: Game,
player: Player,
target: TileRef,
): TileRef | null {
let closestManhattanDistance = Infinity;
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
let bestByManhattan: TileRef = null;
const extremumTiles: Record<string, TileRef> = {
minX: null,
minY: null,
maxX: null,
maxY: null,
};
for (const tile of player.borderTiles()) {
if (!gm.isShore(tile)) continue;
const distance = gm.manhattanDist(tile, target);
const cell = gm.cell(tile);
// Manhattan-closest tile
if (distance < closestManhattanDistance) {
closestManhattanDistance = distance;
bestByManhattan = tile;
}
// Extremum tiles
if (cell.x < minX) {
minX = cell.x;
extremumTiles.minX = tile;
} else if (cell.y < minY) {
minY = cell.y;
extremumTiles.minY = tile;
} else if (cell.x > maxX) {
maxX = cell.x;
extremumTiles.maxX = tile;
} else if (cell.y > maxY) {
maxY = cell.y;
extremumTiles.maxY = tile;
}
}
const candidates = [
bestByManhattan,
extremumTiles.minX,
extremumTiles.minY,
extremumTiles.maxX,
extremumTiles.maxY,
].filter(Boolean);
if (!candidates.length) {
return null;
}
// Find the shortest actual path distance
let closestShoreTile: TileRef | null = null;
let closestDistance = Infinity;
for (const shoreTile of candidates) {
const pathDistance = calculatePathDistance(gm, shoreTile, target);
if (pathDistance !== null && pathDistance < closestDistance) {
closestDistance = pathDistance;
closestShoreTile = shoreTile;
}
}
// Fall back to the Manhattan-closest tile if no path was found
return closestShoreTile || bestByManhattan;
}
/**
* Calculates the distance between two tiles using A*
* Returns null if no path is found
*/
function calculatePathDistance(
gm: Game,
start: TileRef,
target: TileRef,
): number | null {
let currentTile = start;
let tileDistance = 0;
const pathFinder = PathFinder.Mini(gm, 20_000, false);
while (true) {
const result = pathFinder.nextTile(currentTile, target);
if (result.type === PathFindResultType.Completed) {
return tileDistance;
} else if (result.type === PathFindResultType.NextTile) {
currentTile = result.tile;
tileDistance++;
} else if (
result.type === PathFindResultType.PathNotFound ||
result.type === PathFindResultType.Pending
) {
return null;
} else {
// @ts-expect-error type is never
throw new Error(`Unexpected pathfinding result type: ${result.type}`);
}
}
}
function closestShoreTN(
gm: GameMap,
tile: TileRef,
searchDist: number,
): TileRef {
const tn = Array.from(
gm.bfs(
tile,
andFN((_, t) => !gm.hasOwner(t), manhattanDistFN(tile, searchDist)),
),
)
.filter((t) => gm.isShore(t))
.sort((a, b) => gm.manhattanDist(tile, a) - gm.manhattanDist(tile, b));
if (tn.length == 0) {
return null;
}
return tn[0];
}
+20
View File
@@ -6,6 +6,7 @@ import {
PlayerActionsResultMessage,
PlayerBorderTilesResultMessage,
PlayerProfileResultMessage,
TransportShipSpawnResultMessage,
WorkerMessage,
} from "./WorkerMessages";
@@ -120,6 +121,25 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
throw error;
}
break;
case "transport_ship_spawn":
if (!gameRunner) {
throw new Error("Game runner not initialized");
}
try {
const spawnTile = (await gameRunner).bestTransportShipSpawn(
message.playerID,
message.targetTile,
);
sendMessage({
type: "transport_ship_spawn_result",
id: message.id,
result: spawnTile,
} as TransportShipSpawnResultMessage);
} catch (error) {
console.error("Failed to spawn transport ship:", error);
}
break;
default:
console.warn("Unknown message :", message);
}
+31
View File
@@ -4,6 +4,7 @@ import {
PlayerID,
PlayerProfile,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
import { ClientID, GameStartInfo, Turn } from "../Schemas";
import { generateID } from "../Util";
@@ -188,6 +189,36 @@ export class WorkerClient {
});
}
transportShipSpawn(
playerID: PlayerID,
targetTile: TileRef,
): Promise<TileRef | false> {
return new Promise((resolve, reject) => {
if (!this.isInitialized) {
reject(new Error("Worker not initialized"));
return;
}
const messageId = generateID();
this.messageHandlers.set(messageId, (message) => {
if (
message.type === "transport_ship_spawn_result" &&
message.result !== undefined
) {
resolve(message.result);
}
});
this.worker.postMessage({
type: "transport_ship_spawn",
id: messageId,
playerID: playerID,
targetTile: targetTile,
});
});
}
cleanup() {
this.worker.terminate();
this.messageHandlers.clear();
+19 -3
View File
@@ -4,6 +4,7 @@ import {
PlayerID,
PlayerProfile,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { GameUpdateViewData } from "../game/GameUpdates";
import { ClientID, GameStartInfo, Turn } from "../Schemas";
@@ -18,7 +19,9 @@ export type WorkerMessageType =
| "player_profile"
| "player_profile_result"
| "player_border_tiles"
| "player_border_tiles_result";
| "player_border_tiles_result"
| "transport_ship_spawn"
| "transport_ship_spawn_result";
// Base interface for all messages
interface BaseWorkerMessage {
@@ -84,6 +87,17 @@ export interface PlayerBorderTilesResultMessage extends BaseWorkerMessage {
result: PlayerBorderTiles;
}
export interface TransportShipSpawnMessage extends BaseWorkerMessage {
type: "transport_ship_spawn";
playerID: PlayerID;
targetTile: TileRef;
}
export interface TransportShipSpawnResultMessage extends BaseWorkerMessage {
type: "transport_ship_spawn_result";
result: TileRef | false;
}
// Union types for type safety
export type MainThreadMessage =
| HeartbeatMessage
@@ -91,7 +105,8 @@ export type MainThreadMessage =
| TurnMessage
| PlayerActionsMessage
| PlayerProfileMessage
| PlayerBorderTilesMessage;
| PlayerBorderTilesMessage
| TransportShipSpawnMessage;
// Message send from worker
export type WorkerMessage =
@@ -99,4 +114,5 @@ export type WorkerMessage =
| GameUpdateMessage
| PlayerActionsResultMessage
| PlayerProfileResultMessage
| PlayerBorderTilesResultMessage;
| PlayerBorderTilesResultMessage
| TransportShipSpawnResultMessage;
+3 -3
View File
@@ -19,9 +19,9 @@ let defender: Player;
let defenderSpawn: TileRef;
let attackerSpawn: TileRef;
function sendBoat(target: TileRef, troops: number) {
function sendBoat(target: TileRef, source: TileRef, troops: number) {
game.addExecution(
new TransportShipExecution(defender.id(), null, target, troops),
new TransportShipExecution(defender.id(), null, target, troops, source),
);
}
@@ -97,7 +97,7 @@ describe("Attack", () => {
constructionExecution(game, defender.id(), 1, 1, UnitType.MissileSilo);
expect(defender.units(UnitType.MissileSilo)).toHaveLength(1);
sendBoat(game.ref(15, 8), 100);
sendBoat(game.ref(15, 8), game.ref(10, 5), 100);
constructionExecution(game, defender.id(), 0, 15, UnitType.AtomBomb, 3);
const nuke = defender.units(UnitType.AtomBomb)[0];