mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-30 09:52:19 +00:00
Merge branch 'main' into local-attack
This commit is contained in:
+10
-4
@@ -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
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user