Merge branch 'main' into local-attack

This commit is contained in:
Aotumuri
2026-01-29 12:45:45 +09:00
committed by GitHub
161 changed files with 9232 additions and 2455 deletions
+10 -4
View File
@@ -112,12 +112,12 @@ export class GameRunner {
this.turns.push(turn);
}
public executeNextTick() {
public executeNextTick(): boolean {
if (this.isExecuting) {
return;
return false;
}
if (this.currTurn >= this.turns.length) {
return;
return false;
}
this.isExecuting = true;
@@ -144,7 +144,8 @@ export class GameRunner {
} else {
console.error("Game tick error:", error);
}
return;
this.isExecuting = false;
return false;
}
if (this.game.inSpawnPhase() && this.game.ticks() % 2 === 0) {
@@ -177,6 +178,11 @@ export class GameRunner {
tickExecutionDuration: tickExecutionDuration,
});
this.isExecuting = false;
return true;
}
public pendingTurns(): number {
return Math.max(0, this.turns.length - this.currTurn);
}
public playerActions(
+5 -3
View File
@@ -190,6 +190,8 @@ export const GameConfigSchema = z.object({
.object({
isCompact: z.boolean(),
isRandomSpawn: z.boolean(),
isCrowded: z.boolean(),
startingGold: z.number().int().min(0).optional(),
})
.optional(),
disableNations: z.boolean(),
@@ -204,6 +206,8 @@ export const GameConfigSchema = z.object({
spawnImmunityDuration: z.number().int().min(0).optional(), // In ticks
disabledUnits: z.enum(UnitType).array().optional(),
playerTeams: TeamCountConfigSchema.optional(),
goldMultiplier: z.number().min(0.1).max(1000).optional(),
startingGold: z.number().int().min(0).max(1000000000).optional(),
});
export const TeamSchema = z.string();
@@ -244,7 +248,7 @@ export const AllPlayersStatsSchema = z.record(ID, PlayerStatsSchema);
export const UsernameSchema = z
.string()
.regex(/^[a-zA-Z0-9_ [\]üÜ]+$/u)
.regex(/^[a-zA-Z0-9_ [\]üÜ.]+$/u)
.min(3)
.max(27);
const countryCodes = countries.filter((c) => !c.restricted).map((c) => c.code);
@@ -282,10 +286,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({
+6 -1
View File
@@ -62,6 +62,11 @@ export const ATTACK_INDEX_SENT = 0; // Outgoing attack troops
export const ATTACK_INDEX_RECV = 1; // Incmoing attack troops
export const ATTACK_INDEX_CANCEL = 2; // Cancelled attack troops
// Player types
export const PLAYER_INDEX_HUMAN = 0;
export const PLAYER_INDEX_NATION = 1;
export const PLAYER_INDEX_BOT = 2;
// Boats
export const BOAT_INDEX_SENT = 0; // Boats launched
export const BOAT_INDEX_ARRIVE = 1; // Boats arrived
@@ -102,7 +107,7 @@ export const PlayerStatsSchema = z
attacks: AtLeastOneNumberSchema.optional(),
betrayals: BigIntStringSchema.optional(),
killedAt: BigIntStringSchema.optional(),
conquests: BigIntStringSchema.optional(),
conquests: AtLeastOneNumberSchema.optional(),
boats: z.partialRecord(BoatUnitSchema, AtLeastOneNumberSchema).optional(),
bombs: z.partialRecord(BombUnitSchema, AtLeastOneNumberSchema).optional(),
gold: AtLeastOneNumberSchema.optional(),
+2
View File
@@ -76,6 +76,8 @@ export interface Config {
numSpawnPhaseTurns(): number;
userSettings(): UserSettings;
playerTeams(): TeamCountConfig;
goldMultiplier(): number;
startingGold(playerInfo: PlayerInfo): Gold;
startManpower(playerInfo: PlayerInfo): number;
troopIncreaseRate(player: Player | PlayerView): number;
+26 -6
View File
@@ -245,6 +245,15 @@ export class DefaultConfig implements Config {
donateTroops(): boolean {
return this._gameConfig.donateTroops;
}
goldMultiplier(): number {
return this._gameConfig.goldMultiplier ?? 1;
}
startingGold(playerInfo: PlayerInfo): Gold {
if (playerInfo.playerType === PlayerType.Bot) {
return 0n;
}
return BigInt(this._gameConfig.startingGold ?? 0);
}
trainSpawnRate(numPlayerFactories: number): number {
// hyperbolic decay, midpoint at 10 factories
@@ -252,15 +261,21 @@ export class DefaultConfig implements Config {
return (numPlayerFactories + 10) * 18;
}
trainGold(rel: "self" | "team" | "ally" | "other"): Gold {
const multiplier = this.goldMultiplier();
let baseGold: bigint;
switch (rel) {
case "ally":
return 35_000n;
baseGold = 35_000n;
break;
case "team":
case "other":
return 25_000n;
baseGold = 25_000n;
break;
case "self":
return 10_000n;
baseGold = 10_000n;
break;
}
return BigInt(Math.floor(Number(baseGold) * multiplier));
}
trainStationMinRange(): number {
@@ -281,7 +296,8 @@ export class DefaultConfig implements Config {
const numPortBonus = numPorts - 1;
// Hyperbolic decay, midpoint at 5 ports, 3x bonus max.
const bonus = 1 + 2 * (numPortBonus / (numPortBonus + 5));
return BigInt(Math.floor(baseGold * bonus));
const multiplier = this.goldMultiplier();
return BigInt(Math.floor(baseGold * bonus * multiplier));
}
// Probability of trade ship spawn = 1 / tradeShipSpawnRate
@@ -794,10 +810,14 @@ export class DefaultConfig implements Config {
}
goldAdditionRate(player: Player): Gold {
const multiplier = this.goldMultiplier();
let baseRate: bigint;
if (player.type() === PlayerType.Bot) {
return 50n;
baseRate = 50n;
} else {
baseRate = 100n;
}
return 100n;
return BigInt(Math.floor(Number(baseRate) * multiplier));
}
nukeMagnitudes(unitType: UnitType): NukeMagnitude {
+5 -1
View File
@@ -401,7 +401,11 @@ export class AttackExecution implements Execution {
} else {
for (const neighbor of this.mg.neighbors(tile)) {
const no = this.mg.owner(neighbor);
if (no.isPlayer() && no !== this.target) {
if (
no.isPlayer() &&
no !== this.target &&
!no.isFriendly(this.target)
) {
this.mg.player(no.id()).conquer(tile);
break;
}
+1 -1
View File
@@ -101,7 +101,7 @@ export class BotExecution implements Execution {
}
if (this.neighborsTerraNullius) {
if (this.bot.sharesBorderWith(this.mg.terraNullius())) {
if (this.bot.neighbors().some((n) => !n.isPlayer())) {
this.attackBehavior.sendAttack(this.mg.terraNullius());
return;
}
+1 -7
View File
@@ -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":
+13 -27
View File
@@ -4,7 +4,6 @@ import {
isStructureType,
MessageType,
Player,
StructureTypes,
TerraNullius,
TrajectoryTile,
Unit,
@@ -16,7 +15,7 @@ import { ParabolaUniversalPathFinder } from "../pathfinding/PathFinder.Parabola"
import { PathStatus } from "../pathfinding/types";
import { PseudoRandom } from "../PseudoRandom";
import { NukeType } from "../StatsSchemas";
import { computeNukeBlastCounts } from "./Util";
import { listNukeBreakAlliance } from "./Util";
const SPRITE_RADIUS = 16;
@@ -85,36 +84,22 @@ export class NukeExecution implements Execution {
}
const magnitude = this.mg.config().nukeMagnitudes(this.nuke.type());
const threshold = this.mg.config().nukeAllianceBreakThreshold();
// Use shared utility to compute weighted tile counts per player
const blastCounts = computeNukeBlastCounts({
gm: this.mg,
const playersToBreakAllianceWith = listNukeBreakAlliance({
game: this.mg,
targetTile: this.dst,
magnitude,
allySmallIds: new Set(this.player.allies().map((a) => a.smallID())),
threshold: this.mg.config().nukeAllianceBreakThreshold(),
});
// Collect all players that should have alliance broken:
// either exceeds tile threshold OR has a structure in blast radius
const playersToBreakAllianceWith = new Set<number>();
for (const [playerSmallId, totalWeight] of blastCounts) {
if (totalWeight > threshold) {
playersToBreakAllianceWith.add(playerSmallId);
// Automatically reject incoming alliance requests.
for (const incoming of this.player.incomingAllianceRequests()) {
if (playersToBreakAllianceWith.has(incoming.requestor().smallID())) {
incoming.reject();
}
}
// Also check if any allied structures would be destroyed
this.mg
.nearbyUnits(this.dst, magnitude.outer, [...StructureTypes])
.filter(
({ unit }) =>
unit.owner().isPlayer() && this.player.isAlliedWith(unit.owner()),
)
.forEach(({ unit }) =>
playersToBreakAllianceWith.add(unit.owner().smallID()),
);
for (const playerSmallId of playersToBreakAllianceWith) {
const attackedPlayer = this.mg.playerBySmallID(playerSmallId);
if (!attackedPlayer.isPlayer()) {
@@ -123,11 +108,12 @@ export class NukeExecution implements Execution {
// Resolves exploit of alliance breaking in which a pending alliance request
// was accepted in the middle of a missile attack.
const allianceRequest = attackedPlayer
const outgoingAllianceRequest = attackedPlayer
.incomingAllianceRequests()
.find((ar) => ar.requestor() === this.player);
if (allianceRequest) {
allianceRequest.reject();
if (outgoingAllianceRequest) {
outgoingAllianceRequest.reject();
continue;
}
const alliance = this.player.allianceWith(attackedPlayer);
+5
View File
@@ -42,6 +42,11 @@ export class SpawnExecution implements Execution {
player = this.mg.addPlayer(this.playerInfo);
}
// Security: If random spawn is enabled, prevent players from re-rolling their spawn location
if (this.mg.config().isRandomSpawn() && player.hasSpawned()) {
return;
}
this.tile ??= this.randomSpawnLand();
if (this.tile === undefined) {
+27 -69
View File
@@ -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<TileRef>;
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,78 +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,
troops: this.troops,
targetTile: this.dst,
});
if (this.dst !== null) {
this.boat.setTargetTile(this.dst);
} else {
this.boat.setTargetTile(undefined);
}
// 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()}`,
`Naval invasion incoming from ${this.attacker.displayName()} (${renderTroops(this.boat.troops())})`,
MessageType.NAVAL_INVASION_INBOUND,
this.targetID,
this.target.id(),
);
}
@@ -259,7 +217,7 @@ export class TransportShipExecution implements Execution {
new AttackExecution(
this.boat.troops(),
this.attacker,
this.targetID,
this.target.id(),
this.dst,
false,
),
@@ -283,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);
+35 -1
View File
@@ -36,7 +36,7 @@ export function computeNukeBlastCounts(
}
export interface NukeAllianceCheckParams {
game: GameView;
game: Game | GameView;
targetTile: TileRef;
magnitude: NukeMagnitude;
allySmallIds: Set<number>;
@@ -93,6 +93,40 @@ export function wouldNukeBreakAlliance(
return result;
}
// Same as wouldNukeBreakAlliance(), but takes time to find every player
// that would be "angered" from this nuke.
// This includes unallied players!
export function listNukeBreakAlliance(
params: NukeAllianceCheckParams,
): Set<number> {
const { game, targetTile, magnitude, threshold } = params;
// Collect all players that should have alliance broken:
// either exceeds tile threshold OR has a structure in blast radius
const playersToBreakAllianceWith = new Set<number>();
// compute tile breakage threshold
const blastCounts = computeNukeBlastCounts({
gm: game,
targetTile,
magnitude,
});
for (const [playerSmallId, totalWeight] of blastCounts) {
if (totalWeight > threshold) {
playersToBreakAllianceWith.add(playerSmallId);
}
}
// Also check if any allied structures would be destroyed
game
.nearbyUnits(targetTile, magnitude.outer, [...StructureTypes])
.forEach(({ unit }) =>
playersToBreakAllianceWith.add(unit.owner().smallID()),
);
return playersToBreakAllianceWith;
}
export function getSpawnTiles(gm: GameMap, tile: TileRef): TileRef[] {
return Array.from(gm.bfs(tile, euclDistFN(tile, 4, true))).filter(
(t) => !gm.hasOwner(t) && gm.isLand(t),
@@ -1,4 +1,11 @@
import { Execution, Game, Player, PlayerID } from "../../game/Game";
import {
Execution,
Game,
MessageType,
Player,
PlayerID,
UnitType,
} from "../../game/Game";
export class AllianceRequestReplyExecution implements Execution {
private active = true;
@@ -10,6 +17,57 @@ export class AllianceRequestReplyExecution implements Execution {
private accept: boolean,
) {}
private cancelNukesBetweenAlliedPlayers(
mg: Game,
p1: Player,
p2: Player,
): void {
const neutralized = new Map<Player, number>();
const players = [p1, p2];
for (const launcher of players) {
for (const unit of launcher.units(
UnitType.AtomBomb,
UnitType.HydrogenBomb,
)) {
if (!unit.isActive() || unit.reachedTarget()) continue;
const targetTile = unit.targetTile();
if (!targetTile) continue;
const targetOwner = mg.owner(targetTile);
if (!targetOwner.isPlayer()) continue;
const other = launcher === p1 ? p2 : p1;
if (targetOwner !== other) continue;
unit.delete(false);
neutralized.set(launcher, (neutralized.get(launcher) ?? 0) + 1);
}
}
for (const [launcher, count] of neutralized) {
const other = launcher === p1 ? p2 : p1;
mg.displayMessage(
"events_display.alliance_nukes_destroyed_outgoing",
MessageType.ALLIANCE_ACCEPTED,
launcher.id(),
undefined,
{ name: other.displayName(), count },
);
mg.displayMessage(
"events_display.alliance_nukes_destroyed_incoming",
MessageType.ALLIANCE_ACCEPTED,
other.id(),
undefined,
{ name: launcher.displayName(), count },
);
}
}
init(mg: Game, ticks: number): void {
if (!mg.hasPlayer(this.requestorID)) {
console.warn(
@@ -33,6 +91,12 @@ export class AllianceRequestReplyExecution implements Execution {
request.accept();
this.requestor.updateRelation(this.recipient, 100);
this.recipient.updateRelation(this.requestor, 100);
this.cancelNukesBetweenAlliedPlayers(
mg,
this.requestor,
this.recipient,
);
} else {
request.reject();
}
@@ -240,13 +240,13 @@ export class NationAllianceBehavior {
const { difficulty } = this.game.config().gameConfig();
switch (difficulty) {
case Difficulty.Easy:
return false; // 0% chance to reject on easy
return this.random.nextInt(0, 100) < 25; // 25% chance to reject on easy
case Difficulty.Medium:
return this.random.nextInt(0, 100) < 20; // 20% chance to reject on medium
return this.random.nextInt(0, 100) < 50; // 50% chance to reject on medium
case Difficulty.Hard:
return this.random.nextInt(0, 100) < 40; // 40% chance to reject on hard
return this.random.nextInt(0, 100) < 75; // 75% chance to reject on hard
case Difficulty.Impossible:
return this.random.nextInt(0, 100) < 60; // 60% chance to reject on impossible
return true; // 100% chance to reject on impossible
default:
assertNever(difficulty);
}
@@ -6,7 +6,6 @@ import {
Player,
PlayerType,
Relation,
Team,
Tick,
} from "../../game/Game";
import { PseudoRandom } from "../../PseudoRandom";
@@ -55,6 +54,8 @@ export class NationEmojiBehavior {
) {}
maybeSendCasualEmoji() {
if (this.gameOver) return;
this.checkOverwhelmedByAttacks();
this.checkVerySmallAttack();
this.congratulateWinner();
@@ -107,60 +108,23 @@ export class NationEmojiBehavior {
// Check if game is over - send congratulations
private congratulateWinner(): void {
if (this.gameOver) return;
const winner = this.game.getWinner();
if (winner === null) return;
this.gameOver = true;
const percentToWin = this.game.config().percentageTilesOwnedToWin();
const numTilesWithoutFallout =
this.game.numLandTiles() - this.game.numTilesWithFallout();
const isTeamGame =
this.game.config().gameConfig().gameMode === GameMode.Team;
if (isTeamGame) {
// Team game: all nations congratulate if another team won
const teamToTiles = new Map<Team, number>();
for (const player of this.game.players()) {
const team = player.team();
if (team === null) continue;
teamToTiles.set(
team,
(teamToTiles.get(team) ?? 0) + player.numTilesOwned(),
);
}
const sorted = Array.from(teamToTiles.entries()).sort(
(a, b) => b[1] - a[1],
);
if (sorted.length === 0) return;
const [winningTeam, winningTiles] = sorted[0];
const winningPercent = (winningTiles / numTilesWithoutFallout) * 100;
if (winningPercent < percentToWin) return;
this.gameOver = true;
// Don't congratulate if it's our own team
if (winningTeam === this.player.team()) return;
if (winner === this.player.team()) return;
this.sendEmoji(AllPlayers, EMOJI_CONGRATULATE);
} else {
// FFA game: The largest nation congratulates if a human player won
const sorted = this.game
.players()
.sort((a, b) => b.numTilesOwned() - a.numTilesOwned());
if (sorted.length === 0) return;
const firstPlace = sorted[0];
// Check if first place has won (crossed the win threshold)
const firstPlacePercent =
(firstPlace.numTilesOwned() / numTilesWithoutFallout) * 100;
if (firstPlacePercent < percentToWin) return;
this.gameOver = true;
// Only send if first place is a human
if (firstPlace.type() !== PlayerType.Human) return;
if (typeof winner === "string") return; // It's a team, not a player
// Only the largest nation sends the congratulation
const largestNation = this.game
@@ -169,13 +133,12 @@ export class NationEmojiBehavior {
.sort((a, b) => b.numTilesOwned() - a.numTilesOwned())[0];
if (largestNation !== this.player) return;
this.sendEmoji(firstPlace, EMOJI_CONGRATULATE);
this.sendEmoji(winner, EMOJI_CONGRATULATE);
}
}
// Brag with our crown
private brag(): void {
if (this.gameOver) return;
if (!this.random.chance(300)) return;
const sorted = this.game
+170 -52
View File
@@ -1,11 +1,14 @@
import {
Difficulty,
Game,
GameMode,
HumansVsNations,
Player,
PlayerID,
PlayerType,
Relation,
TerraNullius,
UnitType,
} from "../../game/Game";
import { TileRef } from "../../game/GameMap";
import { canBuildTransportShip } from "../../game/TransportShipUtils";
@@ -16,6 +19,7 @@ import {
calculateBoundingBoxCenter,
} from "../../Util";
import { AttackExecution } from "../AttackExecution";
import { DonateTroopsExecution } from "../DonateTroopExecution";
import { NationAllianceBehavior } from "../nation/NationAllianceBehavior";
import {
EMOJI_ASSIST_ACCEPT,
@@ -94,6 +98,16 @@ export class AiAttackBehavior {
private attackWithRandomBoat(borderingEnemies: Player[] = []) {
if (this.player === null) throw new Error("not initialized");
// Check if we've already sent out the maximum number of transport ships
if (
this.player.unitCount(UnitType.TransportShip) >=
this.game.config().boatMaxNumber()
) {
return;
}
// Check if we have any ocean shore tiles to launch from
const oceanShore = Array.from(this.player.borderTiles()).filter((t) =>
this.game.isOceanShore(t),
);
@@ -114,13 +128,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;
}
@@ -315,6 +323,8 @@ export class AiAttackBehavior {
return false;
};
const donate = (): boolean => this.donateTroops();
// Return strategies in order based on difficulty
// Easy nations get the dumbest order, impossible nations get the smartest order
switch (difficulty) {
@@ -323,13 +333,13 @@ export class AiAttackBehavior {
return [nuked, bots, retaliate, assist, betray, hated, weakest];
case Difficulty.Medium:
// prettier-ignore
return [bots, nuked, retaliate, assist, betray, hated, afk, traitor, weakest, island];
return [bots, nuked, retaliate, assist, betray, hated, afk, traitor, weakest, island, donate];
case Difficulty.Hard:
// prettier-ignore
return [bots, retaliate, assist, betray, nuked, traitor, afk, hated, veryWeak, victim, weakest, island];
return [bots, retaliate, assist, betray, nuked, traitor, afk, hated, veryWeak, victim, weakest, island, donate];
case Difficulty.Impossible:
// prettier-ignore
return [retaliate, bots, veryWeak, assist, traitor, afk, betray, victim, nuked, hated, weakest, island];
return [retaliate, bots, veryWeak, assist, traitor, afk, betray, victim, nuked, hated, weakest, island, donate];
default:
assertNever(difficulty);
}
@@ -525,54 +535,67 @@ export class AiAttackBehavior {
}
private findNearestIslandEnemy(): Player | null {
const myBorder = this.player.borderTiles();
if (myBorder.size === 0) return null;
// Check if we've already sent out the maximum number of transport ships
if (
this.player.unitCount(UnitType.TransportShip) >=
this.game.config().boatMaxNumber()
) {
return null;
}
// Check if we have any ocean shore tiles to launch from
const hasOceanShore = Array.from(this.player.borderTiles()).some((t) =>
this.game.isOceanShore(t),
);
if (!hasOceanShore) return null;
const filteredPlayers = this.game.players().filter((p) => {
if (p === this.player) return false;
if (!p.isAlive()) return false;
if (p.borderTiles().size === 0) return false;
if (this.player.isFriendly(p)) return false;
// Don't spam boats into players with more troops
return p.troops() < this.player.troops();
});
if (filteredPlayers.length > 0) {
const playerCenter = this.getPlayerCenter(this.player);
if (filteredPlayers.length === 0) return null;
const sortedPlayers = filteredPlayers
.map((filteredPlayer) => {
const filteredPlayerCenter = this.getPlayerCenter(filteredPlayer);
const playerCenter = this.getPlayerCenter(this.player);
const playerCenterTile = this.game.ref(
playerCenter.x,
playerCenter.y,
);
const filteredPlayerCenterTile = this.game.ref(
filteredPlayerCenter.x,
filteredPlayerCenter.y,
);
const sortedPlayers = filteredPlayers
.map((filteredPlayer) => {
const filteredPlayerCenter = this.getPlayerCenter(filteredPlayer);
const distance = this.game.manhattanDist(
playerCenterTile,
filteredPlayerCenterTile,
);
return { player: filteredPlayer, distance };
})
.sort((a, b) => a.distance - b.distance); // Sort by distance (ascending)
const playerCenterTile = this.game.ref(playerCenter.x, playerCenter.y);
const filteredPlayerCenterTile = this.game.ref(
filteredPlayerCenter.x,
filteredPlayerCenter.y,
);
// Select the nearest or second-nearest enemy (So our boat doesn't always run into the same warship, if there is one)
let selectedEnemy: Player | null;
if (sortedPlayers.length > 1 && this.random.chance(2)) {
selectedEnemy = sortedPlayers[1].player;
} else {
selectedEnemy = sortedPlayers[0].player;
}
const distance = this.game.manhattanDist(
playerCenterTile,
filteredPlayerCenterTile,
);
return { player: filteredPlayer, distance };
})
.sort((a, b) => a.distance - b.distance); // Sort by distance (ascending)
if (selectedEnemy !== null) {
return selectedEnemy;
// Try players in order of distance until we find one reachable by boat
for (const entry of sortedPlayers) {
const closest = closestTwoTiles(
this.game,
Array.from(this.player.borderTiles()).filter((t) =>
this.game.isOceanShore(t),
),
Array.from(entry.player.borderTiles()).filter((t) =>
this.game.isOceanShore(t),
),
);
if (closest === null) continue;
if (canBuildTransportShip(this.game, this.player, closest.y)) {
return entry.player;
}
}
return null;
}
@@ -652,12 +675,14 @@ export class AiAttackBehavior {
}
shouldAttack(other: Player | TerraNullius): boolean {
// Always attack Terra Nullius, non-humans and traitors (or if we are a bot)
if (
// Always attack Terra Nullius, non-humans and traitors
other.isPlayer() === false ||
other.type() !== PlayerType.Human ||
other.isTraitor() ||
this.player.type() === PlayerType.Bot
// Always attack if we are a bot or in an HvN game
this.player.type() === PlayerType.Bot ||
this.game.config().gameConfig().playerTeams === HumansVsNations
) {
return true;
}
@@ -724,6 +749,10 @@ export class AiAttackBehavior {
return;
}
if (!canBuildTransportShip(this.game, this.player, closest.y)) {
return;
}
let troops;
if (target.type() === PlayerType.Bot) {
troops = this.calculateBotAttackTroops(target, this.player.troops() / 5);
@@ -741,13 +770,7 @@ export class AiAttackBehavior {
}
this.game.addExecution(
new TransportShipExecution(
this.player,
target.id(),
closest.y,
troops,
null,
),
new TransportShipExecution(this.player, closest.y, troops),
);
}
@@ -771,4 +794,99 @@ export class AiAttackBehavior {
this.botAttackTroopsSent += troops;
return troops;
}
private donateTroops(): boolean {
// Only donate in team games
if (this.game.config().gameConfig().gameMode !== GameMode.Team) {
return false;
}
// Check if donating troops is allowed
if (this.game.config().donateTroops() === false) {
return false;
}
// Don't donate if the game has a winner
if (this.game.getWinner() !== null) {
return false;
}
// Skip donating based on difficulty
const { difficulty } = this.game.config().gameConfig();
switch (difficulty) {
case Difficulty.Easy:
// Easy nations don't donate
return false;
case Difficulty.Medium:
// Medium nations donate 25% of the time
if (!this.random.chance(4)) {
return false;
}
break;
case Difficulty.Hard:
// Hard nations donate 50% of the time
if (!this.random.chance(2)) {
return false;
}
break;
case Difficulty.Impossible:
// Impossible nations always try to donate
break;
default:
assertNever(difficulty);
}
// Find teammates who are currently in combat
const teammates = this.game
.players()
.filter((p) => this.player.isOnSameTeam(p))
.filter(
(p) => p.incomingAttacks().length > 0 || p.outgoingAttacks().length > 0,
);
if (teammates.length === 0) {
return false;
}
// Find teammate with lowest troop percentage (troops / maxTroops)
const teammatesWithTroopPercentage = teammates
.map((teammate) => {
const maxTroops = this.game.config().maxTroops(teammate);
const troopPercentage = teammate.troops() / Math.max(maxTroops, 1);
return { teammate, troopPercentage };
})
.sort((a, b) => a.troopPercentage - b.troopPercentage);
// Try to donate to teammates in order of lowest troop percentage
let selectedTeammate: Player | null = null;
for (const entry of teammatesWithTroopPercentage) {
if (this.player.canDonateTroops(entry.teammate)) {
selectedTeammate = entry.teammate;
break;
}
}
if (selectedTeammate === null) {
return false;
}
// Donate a portion of our troops (keeping reserve)
const maxTroops = this.game.config().maxTroops(this.player);
const troopsToKeep = maxTroops * this.reserveRatio;
const availableTroops = this.player.troops() - troopsToKeep;
if (availableTroops < 1) {
return false;
}
this.game.addExecution(
new DonateTroopsExecution(
this.player,
selectedTeammate.id(),
availableTroops,
),
);
return true;
}
}
+4
View File
@@ -51,6 +51,7 @@ export class FetchGameMapLoader implements GameMapLoader {
}
private async loadBinaryFromUrl(url: string) {
const startTime = performance.now();
const response = await fetch(url);
if (!response.ok) {
@@ -58,6 +59,9 @@ export class FetchGameMapLoader implements GameMapLoader {
}
const data = await response.arrayBuffer();
console.log(
`[MapLoader] ${url}: ${(performance.now() - startTime).toFixed(0)}ms`,
);
return new Uint8Array(data);
}
+4 -1
View File
@@ -103,7 +103,7 @@ export enum GameMapType {
Montreal = "Montreal",
NewYorkCity = "New York City",
Achiran = "Achiran",
BaikalNukeWars = "Baikal (Nuke Wars)",
BaikalNukeWars = "Baikal Nuke Wars",
FourIslands = "Four Islands",
Svalmel = "Svalmel",
GulfOfStLawrence = "Gulf of St. Lawrence",
@@ -211,6 +211,8 @@ export enum GameMapSize {
export interface PublicGameModifiers {
isCompact: boolean;
isRandomSpawn: boolean;
isCrowded: boolean;
startingGold?: number;
}
export interface UnitInfo {
@@ -754,6 +756,7 @@ export interface Game extends GameMap {
inSpawnPhase(): boolean;
executeNextTick(): GameUpdates;
setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void;
getWinner(): Player | Team | null;
config(): Config;
isPaused(): boolean;
setPaused(paused: boolean): void;
+12
View File
@@ -92,6 +92,7 @@ export class GameImpl implements Game {
private nextAllianceID: number = 0;
private _isPaused: boolean = false;
private _winner: Player | Team | null = null;
private _miniWaterGraph: AbstractGraph | null = null;
private _miniWaterHPA: AStarWaterHierarchical | null = null;
@@ -103,6 +104,8 @@ export class GameImpl implements Game {
private _config: Config,
private _stats: Stats,
) {
const constructorStart = performance.now();
this._terraNullius = new TerraNulliusImpl();
this._width = _map.width();
this._height = _map.height();
@@ -123,6 +126,10 @@ export class GameImpl implements Game {
{ cachePaths: true },
);
}
console.log(
`[GameImpl] Constructor total: ${(performance.now() - constructorStart).toFixed(0)}ms`,
);
}
private populateTeams() {
@@ -712,6 +719,7 @@ export class GameImpl implements Game {
}
setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void {
this._winner = winner;
this.addUpdate({
type: GameUpdateType.Win,
winner: this.makeWinner(winner),
@@ -719,6 +727,10 @@ export class GameImpl implements Game {
});
}
getWinner(): Player | Team | null {
return this._winner;
}
private makeWinner(winner: string | Player): Winner | undefined {
if (typeof winner === "string") {
return [
+1 -1
View File
@@ -112,7 +112,7 @@ export class PlayerImpl implements Player {
) {
this._name = playerInfo.name;
this._troops = toInt(startTroops);
this._gold = 0n;
this._gold = mg.config().startingGold(playerInfo);
this._displayName = this._name;
this._pseudo_random = new PseudoRandom(simpleHash(this.playerInfo.id));
}
+66 -3
View File
@@ -4,6 +4,7 @@ import { Game, Unit, UnitType } from "./Game";
import { TileRef } from "./GameMap";
import { RailNetwork } from "./RailNetwork";
import { Railroad } from "./Railroad";
import { RailSpatialGrid } from "./RailroadSpatialGrid";
import { Cluster, TrainStation } from "./TrainStation";
/**
@@ -23,7 +24,7 @@ export interface StationManager {
export class StationManagerImpl implements StationManager {
private stations: Set<TrainStation> = new Set();
private stationsById: (TrainStation | undefined)[] = [];
private nextId = 0;
private nextId = 1; // Start from 1; 0 is reserved as invalid/sentinel
addStation(station: TrainStation) {
station.id = this.nextId++;
@@ -81,12 +82,17 @@ export function createRailNetwork(game: Game): RailNetwork {
export class RailNetworkImpl implements RailNetwork {
private maxConnectionDistance: number = 4;
private stationRadius: number = 3;
private gridCellSize: number = 4;
private railGrid: RailSpatialGrid;
constructor(
private game: Game,
private _stationManager: StationManager,
private pathService: RailPathFinderService,
) {}
) {
this.railGrid = new RailSpatialGrid(game, this.gridCellSize); // 4x4 tiles spatial grid
}
stationManager(): StationManager {
return this._stationManager;
@@ -94,7 +100,9 @@ export class RailNetworkImpl implements RailNetwork {
connectStation(station: TrainStation) {
this._stationManager.addStation(station);
this.connectToNearbyStations(station);
if (!this.connectToExistingRails(station)) {
this.connectToNearbyStations(station);
}
}
removeStation(unit: Unit): void {
@@ -126,6 +134,59 @@ export class RailNetworkImpl implements RailNetwork {
return this.pathService.findStationsPath(from, to);
}
private connectToExistingRails(station: TrainStation): boolean {
const rails = this.railGrid.query(station.tile(), this.stationRadius);
const editedClusters = new Set<Cluster>();
for (const rail of rails) {
const from = rail.from;
const to = rail.to;
const closestRailIndex = rail.getClosestTileIndex(
this.game,
station.tile(),
);
if (closestRailIndex === 0 || closestRailIndex >= rail.tiles.length) {
continue;
}
// Disconnect current rail as it will become invalid
from.removeRailroad(rail);
to.removeRailroad(rail);
this.railGrid.unregister(rail);
const newRailFrom = new Railroad(
from,
station,
rail.tiles.slice(0, closestRailIndex),
);
const newRailTo = new Railroad(
station,
to,
rail.tiles.slice(closestRailIndex),
);
// New station is connected to both new rails
station.addRailroad(newRailFrom);
station.addRailroad(newRailTo);
// From and to are connected to the new segments
from.addRailroad(newRailFrom);
to.addRailroad(newRailTo);
this.railGrid.register(newRailTo);
this.railGrid.register(newRailFrom);
const cluster = from.getCluster();
if (cluster) {
cluster.addStation(station);
editedClusters.add(cluster);
}
}
// If multiple clusters own the new station, merge them into a single cluster
if (editedClusters.size > 1) {
this.mergeClusters(editedClusters);
}
return editedClusters.size !== 0;
}
private connectToNearbyStations(station: TrainStation) {
const neighbors = this.game.nearbyUnits(
station.tile(),
@@ -176,6 +237,7 @@ export class RailNetworkImpl implements RailNetwork {
private disconnectFromNetwork(station: TrainStation) {
for (const rail of station.getRailroads()) {
rail.delete(this.game);
this.railGrid.unregister(rail);
}
station.clearRailroads();
const cluster = station.getCluster();
@@ -198,6 +260,7 @@ export class RailNetworkImpl implements RailNetwork {
this.game.addExecution(new RailroadExecution(railRoad));
from.addRailroad(railRoad);
to.addRailroad(railRoad);
this.railGrid.register(railRoad);
return true;
}
return false;
+20
View File
@@ -23,6 +23,26 @@ export class Railroad {
this.from.removeRailroad(this);
this.to.removeRailroad(this);
}
getClosestTileIndex(game: Game, to: TileRef): number {
if (this.tiles.length === 0) return -1;
const toX = game.x(to);
const toY = game.y(to);
let closestIndex = 0;
let minDistSquared = Infinity;
for (let i = 0; i < this.tiles.length; i++) {
const tile = this.tiles[i];
const dx = game.x(tile) - toX;
const dy = game.y(tile) - toY;
const distSquared = dx * dx + dy * dy;
if (distSquared < minDistSquared) {
minDistSquared = distSquared;
closestIndex = i;
}
}
return closestIndex;
}
}
export function getOrientedRailroad(
+97
View File
@@ -0,0 +1,97 @@
import { GameMap, TileRef } from "./GameMap";
import { Railroad } from "./Railroad";
export class RailSpatialGrid {
private cells = new Map<string, Set<Railroad>>();
// Quick access to avoid iterating over the cells
private railToCells = new Map<Railroad, Set<string>>();
constructor(
private game: GameMap,
private cellSize: number,
) {
if (cellSize <= 0) {
throw new Error("cellSize must be > 0");
}
}
register(rail: Railroad) {
// Defensive: avoid double-registration but it should never happen
this.unregister(rail);
const railCells = new Set<string>();
for (const tile of rail.tiles) {
const { cx, cy } = this.cellOf(this.game.x(tile), this.game.y(tile));
const k = this.key(cx, cy);
if (railCells.has(k)) continue;
let set = this.cells.get(k);
if (!set) {
set = new Set();
this.cells.set(k, set);
}
railCells.add(k);
set.add(rail);
}
if (railCells.size > 0) {
this.railToCells.set(rail, railCells);
}
}
unregister(rail: Railroad) {
const keys = this.railToCells.get(rail);
if (!keys) return;
for (const k of keys) {
const set = this.cells.get(k);
if (!set) continue;
set.delete(rail);
if (set.size === 0) {
this.cells.delete(k);
}
}
this.railToCells.delete(rail);
}
query(tile: TileRef, radius: number): Set<Railroad> {
const x = this.game.x(tile);
const y = this.game.y(tile);
const minX = x - radius;
const minY = y - radius;
const maxX = x + radius;
const maxY = y + radius;
const c0 = this.cellOf(minX, minY);
const c1 = this.cellOf(maxX, maxY);
const result = new Set<Railroad>();
for (let cx = c0.cx; cx <= c1.cx; cx++) {
for (let cy = c0.cy; cy <= c1.cy; cy++) {
const set = this.cells.get(this.key(cx, cy));
if (!set) continue;
for (const rail of set) {
result.add(rail);
}
}
}
return result;
}
private key(cx: number, cy: number): string {
return `${cx}:${cy}`;
}
private cellOf(x: number, y: number): { cx: number; cy: number } {
return {
cx: Math.floor(x / this.cellSize),
cy: Math.floor(y / this.cellSize),
};
}
}
+18 -8
View File
@@ -24,11 +24,14 @@ import {
OTHER_INDEX_LOST,
OTHER_INDEX_UPGRADE,
OtherUnitType,
PLAYER_INDEX_BOT,
PLAYER_INDEX_HUMAN,
PLAYER_INDEX_NATION,
PlayerStats,
unitTypeToBombUnit,
unitTypeToOtherUnit,
} from "../StatsSchemas";
import { Player, TerraNullius, UnitType } from "./Game";
import { Player, PlayerType, TerraNullius, UnitType } from "./Game";
import { Stats } from "./Stats";
type BigIntLike = bigint | number;
@@ -41,6 +44,12 @@ function _bigint(value: BigIntLike): bigint {
}
}
const conquest_by_type: Record<PlayerType, number> = {
[PlayerType.Human]: PLAYER_INDEX_HUMAN,
[PlayerType.Nation]: PLAYER_INDEX_NATION,
[PlayerType.Bot]: PLAYER_INDEX_BOT,
};
export class StatsImpl implements Stats {
private readonly data: AllPlayersStats = {};
@@ -138,14 +147,12 @@ export class StatsImpl implements Stats {
p.units[type][index] += _bigint(value);
}
private _addConquest(player: Player) {
private _addConquest(player: Player, index: number) {
const p = this._makePlayerStats(player);
if (p === undefined) return;
if (p.conquests === undefined) {
p.conquests = _bigint(1);
} else {
p.conquests += _bigint(1);
}
p.conquests ??= [0n];
while (p.conquests.length <= index) p.conquests.push(0n);
p.conquests[index] += _bigint(1);
}
private _addPlayerKilled(player: Player, tick: number) {
@@ -249,7 +256,10 @@ export class StatsImpl implements Stats {
goldWar(player: Player, captured: Player, gold: BigIntLike): void {
this._addGold(player, GOLD_INDEX_WAR, gold);
this._addConquest(player);
const conquestType = conquest_by_type[captured.type()];
if (conquestType !== undefined) {
this._addConquest(player, conquestType);
}
}
unitBuild(player: Player, type: OtherUnitType): void {
+1 -1
View File
@@ -42,7 +42,7 @@ class StationGraphAdapter implements AStarAdapter {
}
maxNeighbors(): number {
return 8;
return 32;
}
maxPriority(): number {
@@ -1,6 +1,6 @@
import { GameMap, TileRef } from "../../game/GameMap";
import { PathFinder } from "../types";
import { BucketQueue, PriorityQueue } from "./PriorityQueue";
import { MinHeap, PriorityQueue } from "./PriorityQueue";
const LAND_BIT = 7; // Bit 7 in terrain indicates land
const MAGNITUDE_MASK = 0x1f;
@@ -45,11 +45,7 @@ export class AStarWater implements PathFinder<number> {
this.gScore = new Uint32Array(this.numNodes);
this.cameFrom = new Int32Array(this.numNodes);
// Account for scaled costs + tie-breaker headroom
const maxDim = map.width() + map.height();
const maxF =
(this.heuristicWeight + 1) * BASE_COST * maxDim + COST_SCALE * maxDim;
this.queue = new BucketQueue(maxF);
this.queue = new MinHeap(this.numNodes);
}
findPath(start: number | number[], goal: number): number[] | null {
@@ -1,6 +1,6 @@
import { GameMap, TileRef } from "../../game/GameMap";
import { PathFinder } from "../types";
import { BucketQueue } from "./PriorityQueue";
import { MinHeap } from "./PriorityQueue";
const LAND_BIT = 7;
const MAGNITUDE_MASK = 0x1f;
@@ -33,7 +33,7 @@ export class AStarWaterBounded implements PathFinder<number> {
private readonly gScoreStamp: Uint32Array;
private readonly gScore: Uint32Array;
private readonly cameFrom: Int32Array;
private readonly queue: BucketQueue;
private readonly queue: MinHeap;
private readonly terrain: Uint8Array;
private readonly mapWidth: number;
private readonly heuristicWeight: number;
@@ -54,11 +54,7 @@ export class AStarWaterBounded implements PathFinder<number> {
this.gScore = new Uint32Array(maxSearchArea);
this.cameFrom = new Int32Array(maxSearchArea);
const maxDim = Math.ceil(Math.sqrt(maxSearchArea));
// Account for scaled costs + tie-breaker headroom
const maxF =
(this.heuristicWeight + 1) * BASE_COST * maxDim * 2 + COST_SCALE * maxDim;
this.queue = new BucketQueue(maxF);
this.queue = new MinHeap(maxSearchArea * 4);
}
findPath(start: number | number[], goal: number): number[] | null {
@@ -209,6 +205,8 @@ export class AStarWaterBounded implements PathFinder<number> {
closedStamp[neighborLocal] !== stamp &&
(neighbor === goal || (neighborTerrain & landMask) === 0)
) {
const ny = currentY - 1;
const distToGoal = Math.abs(currentX - goalX) + Math.abs(ny - goalY);
const magnitude = neighborTerrain & MAGNITUDE_MASK;
const cost = BASE_COST + getMagnitudePenalty(magnitude);
const tentativeG = currentG + cost;
@@ -219,11 +217,7 @@ export class AStarWaterBounded implements PathFinder<number> {
cameFrom[neighborLocal] = currentLocal;
gScore[neighborLocal] = tentativeG;
gScoreStamp[neighborLocal] = stamp;
const ny = currentY - 1;
const h =
weight *
BASE_COST *
(Math.abs(currentX - goalX) + Math.abs(ny - goalY));
const h = weight * BASE_COST * distToGoal;
const f = tentativeG + h + crossTieBreaker(currentX, ny);
queue.push(neighborLocal, f);
}
@@ -238,6 +232,8 @@ export class AStarWaterBounded implements PathFinder<number> {
closedStamp[neighborLocal] !== stamp &&
(neighbor === goal || (neighborTerrain & landMask) === 0)
) {
const ny = currentY + 1;
const distToGoal = Math.abs(currentX - goalX) + Math.abs(ny - goalY);
const magnitude = neighborTerrain & MAGNITUDE_MASK;
const cost = BASE_COST + getMagnitudePenalty(magnitude);
const tentativeG = currentG + cost;
@@ -248,11 +244,7 @@ export class AStarWaterBounded implements PathFinder<number> {
cameFrom[neighborLocal] = currentLocal;
gScore[neighborLocal] = tentativeG;
gScoreStamp[neighborLocal] = stamp;
const ny = currentY + 1;
const h =
weight *
BASE_COST *
(Math.abs(currentX - goalX) + Math.abs(ny - goalY));
const h = weight * BASE_COST * distToGoal;
const f = tentativeG + h + crossTieBreaker(currentX, ny);
queue.push(neighborLocal, f);
}
@@ -267,6 +259,8 @@ export class AStarWaterBounded implements PathFinder<number> {
closedStamp[neighborLocal] !== stamp &&
(neighbor === goal || (neighborTerrain & landMask) === 0)
) {
const nx = currentX - 1;
const distToGoal = Math.abs(nx - goalX) + Math.abs(currentY - goalY);
const magnitude = neighborTerrain & MAGNITUDE_MASK;
const cost = BASE_COST + getMagnitudePenalty(magnitude);
const tentativeG = currentG + cost;
@@ -277,11 +271,7 @@ export class AStarWaterBounded implements PathFinder<number> {
cameFrom[neighborLocal] = currentLocal;
gScore[neighborLocal] = tentativeG;
gScoreStamp[neighborLocal] = stamp;
const nx = currentX - 1;
const h =
weight *
BASE_COST *
(Math.abs(nx - goalX) + Math.abs(currentY - goalY));
const h = weight * BASE_COST * distToGoal;
const f = tentativeG + h + crossTieBreaker(nx, currentY);
queue.push(neighborLocal, f);
}
@@ -296,6 +286,8 @@ export class AStarWaterBounded implements PathFinder<number> {
closedStamp[neighborLocal] !== stamp &&
(neighbor === goal || (neighborTerrain & landMask) === 0)
) {
const nx = currentX + 1;
const distToGoal = Math.abs(nx - goalX) + Math.abs(currentY - goalY);
const magnitude = neighborTerrain & MAGNITUDE_MASK;
const cost = BASE_COST + getMagnitudePenalty(magnitude);
const tentativeG = currentG + cost;
@@ -306,11 +298,7 @@ export class AStarWaterBounded implements PathFinder<number> {
cameFrom[neighborLocal] = currentLocal;
gScore[neighborLocal] = tentativeG;
gScoreStamp[neighborLocal] = stamp;
const nx = currentX + 1;
const h =
weight *
BASE_COST *
(Math.abs(nx - goalX) + Math.abs(currentY - goalY));
const h = weight * BASE_COST * distToGoal;
const f = tentativeG + h + crossTieBreaker(nx, currentY);
queue.push(neighborLocal, f);
}
@@ -12,6 +12,7 @@ export class AStarWaterHierarchical implements PathFinder<number> {
private abstractAStar: AbstractGraphAStar;
private localAStar: AStarWaterBounded;
private localAStarMultiCluster: AStarWaterBounded;
private localAStarShortPath: AStarWaterBounded;
private sourceResolver: SourceResolver;
constructor(
@@ -41,6 +42,11 @@ export class AStarWaterHierarchical implements PathFinder<number> {
maxMultiClusterNodes,
);
// BoundedAStar for short path multi-source
const shortPathSize = 260; // 2 * (120 + padding 10)
const maxShortPathNodes = shortPathSize * shortPathSize;
this.localAStarShortPath = new AStarWaterBounded(map, maxShortPathNodes);
// SourceResolver for multi-source search
this.sourceResolver = new SourceResolver(this.map, this.graph);
}
@@ -62,6 +68,10 @@ export class AStarWaterHierarchical implements PathFinder<number> {
sources: TileRef[],
target: TileRef,
): TileRef[] | null {
// Early exit: try bounded A* for sources close to target
const shortPath = this.tryShortPathMultiSource(sources, target);
if (shortPath) return shortPath;
// 1. Resolve target to abstract node
const targetNode = this.sourceResolver.resolveTarget(target);
if (!targetNode) return null;
@@ -82,6 +92,44 @@ export class AStarWaterHierarchical implements PathFinder<number> {
return this.findPathSingle(winningSource, target);
}
private tryShortPathMultiSource(
sources: TileRef[],
target: TileRef,
): TileRef[] | null {
const SHORT_PATH_THRESHOLD = 120;
const PADDING = 10;
const candidates = sources.filter(
(s) => this.map.manhattanDist(s, target) <= SHORT_PATH_THRESHOLD,
);
if (candidates.length === 0) return null;
const toX = this.map.x(target);
const toY = this.map.y(target);
let minX = toX,
maxX = toX,
minY = toY,
maxY = toY;
for (const s of candidates) {
const sx = this.map.x(s);
const sy = this.map.y(s);
minX = Math.min(minX, sx);
maxX = Math.max(maxX, sx);
minY = Math.min(minY, sy);
maxY = Math.max(maxY, sy);
}
const bounds = {
minX: Math.max(0, minX - PADDING),
maxX: Math.min(this.map.width() - 1, maxX + PADDING),
minY: Math.max(0, minY - PADDING),
maxY: Math.min(this.map.height() - 1, maxY + PADDING),
};
return this.localAStarShortPath.searchBounded(candidates, target, bounds);
}
findPathSingle(from: TileRef, to: TileRef): TileRef[] | null {
const dist = this.map.manhattanDist(from, to);
@@ -18,7 +18,20 @@ export class MinHeap implements PriorityQueue {
push(node: number, priority: number): void {
if (this.size >= this.capacity) {
throw new Error(`MinHeap capacity exceeded: ${this.capacity}`);
console.error(
`MinHeap capacity exceeded (${this.capacity}). ` +
"Resizing, but this indicates a bug. Please investigate.",
);
this.capacity *= 2;
const newHeap = new Int32Array(this.capacity);
const newPri = new Float32Array(this.capacity);
newHeap.set(this.heap);
newPri.set(this.priorities);
this.heap = newHeap;
this.priorities = newPri;
}
let i = this.size++;
@@ -94,6 +107,8 @@ export class MinHeap implements PriorityQueue {
export class BucketQueue implements PriorityQueue {
private buckets: Int32Array[];
private bucketSizes: Int32Array;
private bucketStamp: Uint32Array;
private stamp = 0;
private minBucket: number;
private maxBucket: number;
private size: number;
@@ -102,6 +117,7 @@ export class BucketQueue implements PriorityQueue {
this.maxBucket = maxPriority + 1;
this.buckets = new Array(this.maxBucket);
this.bucketSizes = new Int32Array(this.maxBucket);
this.bucketStamp = new Uint32Array(this.maxBucket);
this.minBucket = this.maxBucket;
this.size = 0;
}
@@ -113,7 +129,9 @@ export class BucketQueue implements PriorityQueue {
this.buckets[bucket] = new Int32Array(64);
}
const size = this.bucketSizes[bucket];
const size =
this.bucketStamp[bucket] === this.stamp ? this.bucketSizes[bucket] : 0;
if (size >= this.buckets[bucket].length) {
const newBucket = new Int32Array(this.buckets[bucket].length * 2);
newBucket.set(this.buckets[bucket]);
@@ -121,7 +139,8 @@ export class BucketQueue implements PriorityQueue {
}
this.buckets[bucket][size] = node;
this.bucketSizes[bucket]++;
this.bucketSizes[bucket] = size + 1;
this.bucketStamp[bucket] = this.stamp;
this.size++;
if (bucket < this.minBucket) {
@@ -131,11 +150,13 @@ export class BucketQueue implements PriorityQueue {
pop(): number {
while (this.minBucket < this.maxBucket) {
const size = this.bucketSizes[this.minBucket];
if (size > 0) {
this.bucketSizes[this.minBucket]--;
this.size--;
return this.buckets[this.minBucket][size - 1];
if (this.bucketStamp[this.minBucket] === this.stamp) {
const size = this.bucketSizes[this.minBucket];
if (size > 0) {
this.bucketSizes[this.minBucket]--;
this.size--;
return this.buckets[this.minBucket][size - 1];
}
}
this.minBucket++;
}
@@ -147,7 +168,11 @@ export class BucketQueue implements PriorityQueue {
}
clear(): void {
this.bucketSizes.fill(0);
this.stamp++;
if (this.stamp > 0xffffffff) {
this.bucketStamp.fill(0);
this.stamp = 1;
}
this.minBucket = this.maxBucket;
this.size = 0;
}
+128 -15
View File
@@ -1,12 +1,27 @@
import { Game, Player, TerraNullius } from "../../game/Game";
import { TileRef } from "../../game/GameMap";
import { DebugSpan } from "../../utilities/DebugSpan";
import { PathFinding } from "../PathFinder";
import { AStarWaterBounded } from "../algorithms/AStar.WaterBounded";
type Owner = Player | TerraNullius;
const REFINE_MAX_SEARCH_AREA = 100 * 100;
export class SpatialQuery {
private boundedAStar: AStarWaterBounded | null = null;
constructor(private game: Game) {}
private getBoundedAStar(): AStarWaterBounded {
this.boundedAStar ??= new AStarWaterBounded(
this.game.map(),
REFINE_MAX_SEARCH_AREA,
);
return this.boundedAStar;
}
/**
* Find nearest tile matching predicate using BFS traversal.
* Uses Manhattan distance filter, ignores terrain barriers.
@@ -64,27 +79,125 @@ export class SpatialQuery {
* Returns null for terra nullius (no borderTiles).
*/
closestShoreByWater(owner: Owner, target: TileRef): TileRef | null {
if (!owner.isPlayer()) return null;
return DebugSpan.wrap("SpatialQuery.closestShoreByWater", () => {
if (!owner.isPlayer()) return null;
const gm = this.game;
const player = owner as Player;
const gm = this.game;
const player = owner as Player;
// Target must be water or shore (land adjacent to water)
if (!gm.isWater(target) && !gm.isShore(target)) return null;
// Target must be water or shore (land adjacent to water)
if (!gm.isWater(target) && !gm.isShore(target)) return null;
const targetComponent = gm.getWaterComponent(target);
if (targetComponent === null) return null;
const targetComponent = gm.getWaterComponent(target);
if (targetComponent === null) return null;
const isValidTile = (t: TileRef) => {
if (!gm.isShore(t) || !gm.isLand(t)) return false;
const tComponent = gm.getWaterComponent(t);
return tComponent === targetComponent;
const isValidTile = (t: TileRef) => {
if (!gm.isShore(t) || !gm.isLand(t)) return false;
const tComponent = gm.getWaterComponent(t);
return tComponent === targetComponent;
};
const shores = Array.from(player.borderTiles()).filter(isValidTile);
if (shores.length === 0) return null;
const path = PathFinding.Water(gm).findPath(shores, target);
if (!path || path.length === 0) return null;
return DebugSpan.wrap("SpatialQuery.refineStartTile", () =>
this.refineStartTile(path, shores, gm),
);
});
}
private refineStartTile(
path: TileRef[],
shores: TileRef[],
gm: Game,
): TileRef {
const CANDIDATE_RADIUS = 20;
const MIN_WAYPOINT_DIST = 50;
const MAX_WAYPOINT_DIST = 200;
const PADDING = 10;
if (path.length <= MIN_WAYPOINT_DIST) {
return path[0];
}
const bestTile = path[0];
const map = gm.map();
const candidates = shores.filter(
(s) => map.manhattanDist(s, bestTile) <= CANDIDATE_RADIUS,
);
if (candidates.length <= 1) return bestTile;
// Precompute candidate bounds
let candMinX = map.x(candidates[0]);
let candMaxX = candMinX;
let candMinY = map.y(candidates[0]);
let candMaxY = candMinY;
for (let i = 1; i < candidates.length; i++) {
const sx = map.x(candidates[i]);
const sy = map.y(candidates[i]);
candMinX = Math.min(candMinX, sx);
candMaxX = Math.max(candMaxX, sx);
candMinY = Math.min(candMinY, sy);
candMaxY = Math.max(candMaxY, sy);
}
// Binary search for furthest waypoint that keeps bounds within limit
let lo = MIN_WAYPOINT_DIST;
let hi = Math.min(MAX_WAYPOINT_DIST, path.length - 1);
let bestWaypointIdx = lo;
for (let i = 0; i < 5 && lo <= hi; i++) {
const mid = (lo + hi) >> 1;
const wp = path[mid];
const wpX = map.x(wp);
const wpY = map.y(wp);
const minX = Math.min(candMinX, wpX) - PADDING;
const maxX = Math.max(candMaxX, wpX) + PADDING;
const minY = Math.min(candMinY, wpY) - PADDING;
const maxY = Math.max(candMaxY, wpY) + PADDING;
const area = (maxX - minX + 1) * (maxY - minY + 1);
if (area <= REFINE_MAX_SEARCH_AREA) {
bestWaypointIdx = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
const waypoint = path[bestWaypointIdx];
const wpX = map.x(waypoint);
const wpY = map.y(waypoint);
const bounds = {
minX: Math.max(0, Math.min(candMinX, wpX) - PADDING),
maxX: Math.min(map.width() - 1, Math.max(candMaxX, wpX) + PADDING),
minY: Math.max(0, Math.min(candMinY, wpY) - PADDING),
maxY: Math.min(map.height() - 1, Math.max(candMaxY, wpY) + PADDING),
};
const shores = Array.from(player.borderTiles()).filter(isValidTile);
if (shores.length === 0) return null;
const boundsArea =
(bounds.maxX - bounds.minX + 1) * (bounds.maxY - bounds.minY + 1);
if (boundsArea > REFINE_MAX_SEARCH_AREA) return bestTile;
const path = PathFinding.Water(gm).findPath(shores, target);
return path?.[0] ?? null;
const refinedPath = this.getBoundedAStar().searchBounded(
candidates,
waypoint,
bounds,
);
DebugSpan.set("$candidates", () => candidates);
DebugSpan.set("$refinedPath", () => refinedPath);
DebugSpan.set("$originalBestTile", () => bestTile);
DebugSpan.set("$newBestTile", () => refinedPath?.[0] ?? bestTile);
return refinedPath?.[0] ?? bestTile;
}
}
@@ -1,5 +1,3 @@
// Shore-coercing transformer that converts shore tiles to water tiles for pathfinding
import { GameMap, TileRef } from "../../game/GameMap";
import { PathFinder } from "../types";
@@ -7,9 +5,6 @@ import { PathFinder } from "../types";
* Wraps a PathFinder to handle shore tiles.
* Coerces shore tiles to nearby water tiles before pathfinding,
* then fixes the path extremes to include the original shore tiles.
*
* Works at whatever resolution the map provides - can be used with
* full map or minimap-based pathfinders.
*/
export class ShoreCoercingTransformer implements PathFinder<number> {
constructor(
@@ -34,20 +29,18 @@ export class ShoreCoercingTransformer implements PathFinder<number> {
return null;
}
// Coerce to tile
const coercedTo = this.coerceToWater(to);
if (coercedTo.water === null) {
return null;
}
// Search on water tiles
const fromTiles = waterFrom.length === 1 ? waterFrom[0] : waterFrom;
const path = this.inner.findPath(fromTiles, coercedTo.water);
if (!path || path.length === 0) {
return null;
}
// Look up the actual path start in the map
// Restore original start shore tile
const originalShore = waterToOriginal.get(path[0]);
if (originalShore !== undefined && originalShore !== null) {
path.unshift(originalShore);
@@ -67,25 +60,43 @@ export class ShoreCoercingTransformer implements PathFinder<number> {
/**
* Coerce a tile to water for pathfinding.
* If tile is already water, returns it unchanged.
* If tile is shore (land with water neighbor), finds the nearest water neighbor.
* If tile is shore, finds the best adjacent water neighbor.
*/
private coerceToWater(tile: TileRef): {
water: TileRef | null;
original: TileRef | null;
} {
// If already water, no coercion needed
if (this.map.isWater(tile)) {
return { water: tile, original: null };
}
// Find adjacent water neighbor
let best: TileRef | null = null;
let maxScore = -1;
for (const n of this.map.neighbors(tile)) {
if (this.map.isWater(n)) {
return { water: n, original: tile };
if (!this.map.isWater(n)) continue;
// Score by water neighbor count (connectivity)
const score = this.countWaterNeighbors(n);
// Pick highest connectivity
if (score > maxScore) {
maxScore = score;
best = n;
}
}
// No water neighbor found - let HPA* handle at minimap level
if (best !== null) {
return { water: best, original: tile };
}
return { water: null, original: tile };
}
private countWaterNeighbors(tile: TileRef): number {
let count = 0;
for (const n of this.map.neighbors(tile)) {
if (this.map.isWater(n)) count++;
}
return count;
}
}
@@ -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<TileRef> {
private readonly mapWidth: number;
@@ -47,17 +49,24 @@ export class SmoothingWaterTransformer implements PathFinder<TileRef> {
}
// 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, 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;
@@ -69,7 +78,7 @@ export class SmoothingWaterTransformer implements PathFinder<TileRef> {
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 {
@@ -185,7 +194,7 @@ export class SmoothingWaterTransformer implements PathFinder<TileRef> {
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;
@@ -211,7 +220,7 @@ export class SmoothingWaterTransformer implements PathFinder<TileRef> {
// 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;
@@ -226,10 +235,7 @@ export class SmoothingWaterTransformer implements PathFinder<TileRef> {
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;
@@ -238,7 +244,7 @@ export class SmoothingWaterTransformer implements PathFinder<TileRef> {
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;
+13 -2
View File
@@ -16,6 +16,7 @@ import {
const ctx: Worker = self as any;
let gameRunner: Promise<GameRunner> | null = null;
const mapLoader = new FetchGameMapLoader(`/maps`, version);
const MAX_TICKS_PER_HEARTBEAT = 4;
function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) {
// skip if ErrorUpdate
@@ -36,9 +37,19 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
const message = e.data;
switch (message.type) {
case "heartbeat":
(await gameRunner)?.executeNextTick();
case "heartbeat": {
const gr = await gameRunner;
if (!gr) {
break;
}
const ticksToRun = Math.min(gr.pendingTurns(), MAX_TICKS_PER_HEARTBEAT);
for (let i = 0; i < ticksToRun; i++) {
if (!gr.executeNextTick()) {
break;
}
}
break;
}
case "init":
try {
gameRunner = createGameRunner(
+1 -1
View File
@@ -80,7 +80,7 @@ export class WorkerClient {
this.messageHandlers.delete(messageId);
reject(new Error("Worker initialization timeout"));
}
}, 5000); // 5 second timeout
}, 20000); // 20 second timeout
});
}