mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-27 21:14:18 +00:00
Merge branch 'main' into meta3
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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++) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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[] {
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Submodule src/server/gatekeeper deleted from 4d3fd72121
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user