Merge branch 'main' into meta3

This commit is contained in:
1brucben
2025-04-22 16:52:55 +02:00
21 changed files with 455 additions and 194 deletions
+16 -4
View File
@@ -25,12 +25,16 @@ on:
required: false
default: ""
type: string
push:
branches:
- main
jobs:
deploy:
name: Deploy to ${{ inputs.target_environment }}
# Use different logic based on event type
name: Deploy to ${{ github.event_name == 'workflow_dispatch' && inputs.target_environment || 'staging' }}
runs-on: ubuntu-latest
environment: ${{ inputs.target_environment }}
environment: ${{ github.event_name == 'workflow_dispatch' && inputs.target_environment || 'staging' }}
steps:
- uses: actions/checkout@v4
@@ -44,7 +48,13 @@ jobs:
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H ${{ secrets.SERVER_HOST_STAGING }} >> ~/.ssh/known_hosts
cat >.env.${{ inputs.target_environment }} <<EOF
# Determine environment based on trigger type
TARGET_ENV="${{ github.event_name == 'workflow_dispatch' && inputs.target_environment || 'staging' }}"
TARGET_HOST="${{ github.event_name == 'workflow_dispatch' && inputs.target_host || 'staging' }}"
TARGET_SUBDOMAIN="${{ github.event_name == 'workflow_dispatch' && inputs.target_subdomain || 'main' }}"
cat >.env.$TARGET_ENV <<EOF
ADMIN_TOKEN=${{ secrets.ADMIN_TOKEN }}
CF_ACCOUNT_ID=${{ secrets.CF_ACCOUNT_ID }}
CF_API_TOKEN=${{ secrets.CF_API_TOKEN }}
@@ -62,4 +72,6 @@ jobs:
SSH_KEY=~/.ssh/id_rsa
VERSION_TAG="latest"
EOF
./deploy.sh ${{ inputs.target_environment }} ${{ inputs.target_host }} ${{ inputs.target_subdomain }}
./deploy.sh $TARGET_ENV $TARGET_HOST $TARGET_SUBDOMAIN
echo "Deployed to $TARGET_ENV environment on $TARGET_HOST host with subdomain $TARGET_SUBDOMAIN"
+1
View File
@@ -13,6 +13,7 @@ const gitignorePath = path.resolve(__dirname, ".gitignore");
/** @type {import('eslint').Linter.Config[]} */
export default [
includeIgnoreFile(gitignorePath),
{ ignores: ["src/server/gatekeeper/**"] },
{ files: ["**/*.{js,mjs,cjs,ts}"] },
{ languageOptions: { globals: { ...globals.browser, ...globals.node } } },
pluginJs.configs.recommended,
+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
@@ -398,6 +398,7 @@ export class FakeHumanExecution implements Execution {
other.id(),
closest.y,
this.player.troops() / 5,
null,
),
);
}
@@ -558,6 +559,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,
+4 -3
View File
@@ -89,6 +89,7 @@ export const mapCategories: Record<string, GameMapType[]> = {
GameMapType.Japan,
GameMapType.Mena,
GameMapType.Australia,
GameMapType.FaroeIslands,
],
fantasy: [GameMapType.Pangaea, GameMapType.Mars, GameMapType.KnownWorld],
};
@@ -443,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 {
@@ -501,7 +503,6 @@ export interface Game extends GameMap {
}
export interface PlayerActions {
canBoat: TileRef | false;
canAttack: boolean;
buildableUnits: BuildableUnit[];
canSendEmojiAllPlayers: boolean;
@@ -509,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];