Merge branch 'main' into player-text-opacity

This commit is contained in:
bijx
2026-04-24 00:17:07 -04:00
committed by GitHub
343 changed files with 20725 additions and 4841 deletions
+39 -15
View File
@@ -43,9 +43,19 @@ export const TokenPayloadSchema = z.object({
iss: z.string(),
aud: z.string(),
exp: z.number(),
role: z
.enum(["root", "admin", "mod", "flagged", "banned"])
// In case new roles are added in the future.
.or(z.string())
.optional(),
});
export type TokenPayload = z.infer<typeof TokenPayloadSchema>;
export const ADMIN_ROLES = ["admin", "root"] as const;
export function isAdminRole(role: string | null | undefined): boolean {
return role === "admin" || role === "root";
}
export const DiscordUserSchema = z.object({
id: z.string(),
avatar: z.string().nullable(),
@@ -67,16 +77,10 @@ export const UserMeResponseSchema = z.object({
}),
player: z.object({
publicId: z.string(),
roles: z.string().array().optional(),
flares: z.string().array().optional(),
achievements: z
.array(
z.object({
type: z.literal("singleplayer-map"), // TODO: change the shape to be more flexible when we have more achievements
data: z.array(SingleplayerMapAchievementSchema),
}),
)
.optional(),
achievements: z.object({
singleplayerMap: z.array(SingleplayerMapAchievementSchema),
}),
leaderboard: z
.object({
oneVone: z
@@ -86,6 +90,12 @@ export const UserMeResponseSchema = z.object({
.optional(),
})
.optional(),
currency: z
.object({
soft: z.coerce.number(),
hard: z.coerce.number(),
})
.optional(),
}),
});
export type UserMeResponse = z.infer<typeof UserMeResponseSchema>;
@@ -98,13 +108,17 @@ export const PlayerStatsLeafSchema = z.object({
});
export type PlayerStatsLeaf = z.infer<typeof PlayerStatsLeafSchema>;
export const PlayerStatsTreeSchema = z.partialRecord(
z.enum(GameType),
z.partialRecord(
z.enum(GameMode),
z.partialRecord(z.enum(Difficulty), PlayerStatsLeafSchema),
),
const GameModeStatsSchema = z.partialRecord(
z.enum(GameMode),
z.partialRecord(z.enum(Difficulty), PlayerStatsLeafSchema),
);
export const PlayerStatsTreeSchema = z.object({
Singleplayer: GameModeStatsSchema.optional(),
Public: GameModeStatsSchema.optional(),
Private: GameModeStatsSchema.optional(),
Ranked: z.partialRecord(z.enum(RankedType), PlayerStatsLeafSchema).optional(),
});
export type PlayerStatsTree = z.infer<typeof PlayerStatsTreeSchema>;
export const PlayerGameSchema = z.object({
@@ -192,3 +206,13 @@ export const RankedLeaderboardResponseSchema = z.object({
export type RankedLeaderboardResponse = z.infer<
typeof RankedLeaderboardResponseSchema
>;
export const NewsItemSchema = z.object({
id: z.string(),
title: z.string(),
description: z.string().optional(),
descriptionTranslationKey: z.string().optional(),
url: z.string().nullable().optional(),
type: z.enum(["tournament", "tutorial", "announcement"]).or(z.string()),
});
export type NewsItem = z.infer<typeof NewsItemSchema>;
+8
View File
@@ -44,10 +44,18 @@ export function normalizeAssetPath(path: string): string {
return normalizedPath;
}
function isAbsoluteUrl(path: string): boolean {
return /^https?:\/\//i.test(path);
}
export function buildAssetUrl(
path: string,
assetManifest: AssetManifest = {},
): string {
if (isAbsoluteUrl(path)) {
return path;
}
const normalizedPath = normalizeAssetPath(path);
const directUrl = assetManifest[normalizedPath];
+29 -25
View File
@@ -5,7 +5,9 @@ import { PlayerPattern } from "./Schemas";
export type Cosmetics = z.infer<typeof CosmeticsSchema>;
export type Pattern = z.infer<typeof PatternSchema>;
export type PatternName = z.infer<typeof PatternNameSchema>;
export type Flag = z.infer<typeof FlagSchema>;
export type Pack = z.infer<typeof PackSchema>;
export type PatternName = z.infer<typeof CosmeticNameSchema>;
export type Product = z.infer<typeof ProductSchema>;
export type ColorPalette = z.infer<typeof ColorPaletteSchema>;
export type PatternData = z.infer<typeof PatternDataSchema>;
@@ -16,7 +18,7 @@ export const ProductSchema = z.object({
price: z.string(),
});
export const PatternNameSchema = z
export const CosmeticNameSchema = z
.string()
.regex(/^[a-z0-9_]+$/)
.max(32);
@@ -50,8 +52,19 @@ export const ColorPaletteSchema = z.object({
secondaryColor: z.string(),
});
export const PatternSchema = z.object({
name: PatternNameSchema,
const CosmeticSchema = z.object({
name: CosmeticNameSchema,
affiliateCode: z.string().nullable(),
product: ProductSchema.nullable(),
priceSoft: z.number().optional(),
priceHard: z.number().optional(),
artist: z.string().optional(),
rarity: z
.enum(["common", "uncommon", "rare", "epic", "legendary"])
.or(z.string()),
});
export const PatternSchema = CosmeticSchema.extend({
pattern: PatternDataSchema,
colorPalettes: z
.object({
@@ -60,33 +73,24 @@ export const PatternSchema = z.object({
})
.array()
.optional(),
affiliateCode: z.string().nullable(),
product: ProductSchema.nullable(),
});
export const FlagSchema = CosmeticSchema.extend({
url: z.string(),
});
export const PackSchema = CosmeticSchema.extend({
displayName: z.string(),
currency: z.enum(["hard", "soft"]),
amount: z.number().int().positive(),
});
// Schema for resources/cosmetics/cosmetics.json
export const CosmeticsSchema = z.object({
colorPalettes: z.record(z.string(), ColorPaletteSchema).optional(),
patterns: z.record(z.string(), PatternSchema),
flag: z
.object({
layers: z.record(
z.string(),
z.object({
name: z.string(),
flares: z.array(z.string()).optional(),
}),
),
color: z.record(
z.string(),
z.object({
color: z.string(),
name: z.string(),
flares: z.array(z.string()).optional(),
}),
),
})
.optional(),
flags: z.record(z.string(), FlagSchema),
currencyPacks: z.record(z.string(), PackSchema).optional(),
});
export const DefaultPattern = {
-81
View File
@@ -1,81 +0,0 @@
import { assetUrl } from "./AssetUrls";
import { Cosmetics } from "./CosmeticSchemas";
const ANIMATION_DURATIONS: Record<string, number> = {
rainbow: 4000,
"bright-rainbow": 4000,
"copper-glow": 3000,
"silver-glow": 3000,
"gold-glow": 3000,
neon: 3000,
lava: 6000,
water: 6200,
};
// TODO: Pass in cosmetics as a parameter when
// remote cosmetics are implemented for custom flags
export function renderPlayerFlag(
flag: string,
target: HTMLElement,
cosmetics: Cosmetics | undefined = undefined,
) {
if (cosmetics === undefined) {
console.warn("No cosmetics provided for flag", flag);
return;
}
if (!flag.startsWith("!")) return;
const code = flag.slice("!".length);
const layers = code.split("_").map((segment) => {
const [layerKey, colorKey] = segment.split("-");
return { layerKey, colorKey };
});
target.innerHTML = "";
target.style.overflow = "hidden";
target.style.position = "relative";
target.style.aspectRatio = "3/4";
for (const { layerKey, colorKey } of layers) {
const layerName = cosmetics?.flag?.layers[layerKey]?.name ?? layerKey;
const mask = assetUrl(`flags/custom/${layerName}.svg`);
if (!mask) continue;
const layer = document.createElement("div");
layer.style.position = "absolute";
layer.style.top = "0";
layer.style.left = "0";
layer.style.width = "100%";
layer.style.height = "100%";
const colorValue = cosmetics?.flag?.color[colorKey]?.color ?? colorKey;
const isSpecial =
!colorValue.startsWith("#") &&
!/^([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/.test(colorValue);
if (isSpecial) {
const duration = ANIMATION_DURATIONS[colorValue] ?? 5000;
const now = performance.now();
const offset = now % duration;
if (!duration) console.warn(`No animation duration for: ${colorValue}`);
layer.classList.add(`flag-color-${colorValue}`);
layer.style.animationDelay = `-${offset}ms`;
} else {
layer.style.backgroundColor = colorValue;
}
layer.style.maskImage = `url(${mask})`;
layer.style.maskRepeat = "no-repeat";
layer.style.maskPosition = "center";
layer.style.maskSize = "contain";
layer.style.webkitMaskImage = `url(${mask})`;
layer.style.webkitMaskRepeat = "no-repeat";
layer.style.webkitMaskPosition = "center";
layer.style.webkitMaskSize = "contain";
target.appendChild(layer);
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { placeName } from "../client/graphics/NameBoxCalculator";
import { getConfig } from "./configuration/ConfigLoader";
import { getGameLogicConfig } from "./configuration/ConfigLoader";
import { Executor } from "./execution/ExecutionManager";
import { RecomputeRailClusterExecution } from "./execution/RecomputeRailClusterExecution";
import { WinCheckExecution } from "./execution/WinCheckExecution";
@@ -35,7 +35,7 @@ export async function createGameRunner(
mapLoader: GameMapLoader,
callBack: (gu: GameUpdateViewData | ErrorUpdate) => void,
): Promise<GameRunner> {
const config = await getConfig(gameStart.config, null);
const config = await getGameLogicConfig(gameStart.config, null);
const gameMap = await loadGameMap(
gameStart.config.gameMap,
gameStart.config.gameMapSize,
+3 -1
View File
@@ -16,7 +16,9 @@ export class PseudoRandom {
// Generates a random integer between min (inclusive) and max (exclusive).
nextInt(min: number, max: number): number {
return Math.floor(this.rng() * (max - min)) + min;
const lo = Math.floor(min);
const hi = Math.floor(max);
return Math.floor(this.rng() * (hi - lo)) + lo;
}
// Generates a random float between min (inclusive) and max (exclusive).
+50 -28
View File
@@ -1,10 +1,9 @@
import countries from "resources/countries.json";
import quickChatData from "resources/QuickChat.json";
import { z } from "zod";
import {
ColorPaletteSchema,
CosmeticNameSchema,
PatternDataSchema,
PatternNameSchema,
} from "./CosmeticSchemas";
import type { GameEvent } from "./EventBus";
import {
@@ -132,7 +131,6 @@ export type PlayerCosmetics = z.infer<typeof PlayerCosmeticsSchema>;
export type PlayerCosmeticRefs = z.infer<typeof PlayerCosmeticRefsSchema>;
export type PlayerPattern = z.infer<typeof PlayerPatternSchema>;
export type PlayerColor = z.infer<typeof PlayerColorSchema>;
export type Flag = z.infer<typeof FlagSchema>;
export type GameStartInfo = z.infer<typeof GameStartInfoSchema>;
export type GameInfo = z.infer<typeof GameInfoSchema>;
export type PublicGames = z.infer<typeof PublicGamesSchema>;
@@ -225,13 +223,18 @@ export const GameConfigSchema = z.object({
gameMapSize: z.enum(GameMapSize),
publicGameModifiers: z
.object({
isCompact: z.boolean(),
isRandomSpawn: z.boolean(),
isCrowded: z.boolean(),
isHardNations: z.boolean(),
isCompact: z.boolean().optional(),
isRandomSpawn: z.boolean().optional(),
isCrowded: z.boolean().optional(),
isHardNations: z.boolean().optional(),
startingGold: z.number().int().min(0).optional(),
goldMultiplier: z.number().min(0.1).max(1000).optional(),
isAlliancesDisabled: z.boolean(),
isAlliancesDisabled: z.boolean().optional(),
isPortsDisabled: z.boolean().optional(),
isNukesDisabled: z.boolean().optional(),
isSAMsDisabled: z.boolean().optional(),
isPeaceTime: z.boolean().optional(),
isWaterNukes: z.boolean().optional(),
})
.optional(),
nations: z
@@ -245,15 +248,30 @@ export const GameConfigSchema = z.object({
infiniteTroops: z.boolean(),
instantBuild: z.boolean(),
disableNavMesh: z.boolean().optional(),
disableAlliances: z.boolean().optional(),
disableAlliances: z.boolean().nullable().optional(),
waterNukes: z.boolean().nullable().optional(),
randomSpawn: z.boolean(),
maxPlayers: z.number().optional(),
maxTimerValue: z.number().int().min(1).max(120).optional(), // In minutes
spawnImmunityDuration: z.number().int().min(0).optional(), // In ticks
maxTimerValue: z.number().int().min(1).max(120).nullable().optional(), // In minutes
spawnImmunityDuration: z.number().int().min(0).nullable().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(),
goldMultiplier: z.number().min(0.1).max(1000).nullable().optional(),
startingGold: z.number().int().min(0).max(1000000000).nullable().optional(),
hostCheats: z
.object({
infiniteGold: z.boolean().optional(),
infiniteTroops: z.boolean().optional(),
goldMultiplier: z.number().min(0.1).max(1000).nullable().optional(),
startingGold: z
.number()
.int()
.min(0)
.max(1000000000)
.nullable()
.optional(),
})
.optional(),
});
export const TeamSchema = z.string();
@@ -292,8 +310,6 @@ export const ID = z.string().regex(GAME_ID_REGEX);
export const AllPlayersStatsSchema = z.record(ID, PlayerStatsSchema);
const countryCodes = countries.filter((c) => !c.restricted).map((c) => c.code);
export const QuickChatKeySchema = z.enum(
Object.entries(quickChatData).flatMap(([category, entries]) =>
entries.map((entry) => `${category}.${entry.key}`),
@@ -400,7 +416,7 @@ export const CancelBoatIntentSchema = z.object({
export const MoveWarshipIntentSchema = z.object({
type: z.literal("move_warship"),
unitId: z.number(),
unitIds: z.array(z.number().int()).nonempty(),
tile: z.number(),
});
@@ -479,28 +495,23 @@ export const TurnSchema = z.object({
hash: z.number().nullable().optional(),
});
export const FlagSchema = z
export const FlagName = z
.string()
.max(128)
.optional()
.refine(
(val) => {
if (val === undefined || val === "") return true;
if (val.startsWith("!")) return true;
return countryCodes.includes(val);
return val.startsWith("flag:") || val.startsWith("country:");
},
{
message: "Invalid flag: must start with country: or flag:",
},
{ message: "Invalid flag: must be a valid country code or start with !" },
);
export const PlayerCosmeticRefsSchema = z.object({
flag: FlagSchema.optional(),
color: z.string().optional(),
patternName: PatternNameSchema.optional(),
patternColorPaletteName: z.string().optional(),
});
export const FlagSchema = z.string();
export const PlayerPatternSchema = z.object({
name: PatternNameSchema,
name: CosmeticNameSchema,
patternData: PatternDataSchema,
colorPalette: ColorPaletteSchema.optional(),
});
@@ -509,6 +520,16 @@ export const PlayerColorSchema = z.object({
color: z.string(),
});
// Refs contain cosmetics names, will be replaced by the actual
// content in the server
export const PlayerCosmeticRefsSchema = z.object({
flag: FlagName.optional(),
color: z.string().optional(),
patternName: CosmeticNameSchema.optional(),
patternColorPaletteName: z.string().optional(),
});
// Server converts refs to the actual cosmetics here
export const PlayerCosmeticsSchema = z.object({
flag: FlagSchema.optional(),
pattern: PlayerPatternSchema.optional(),
@@ -526,6 +547,7 @@ export const PlayerSchema = z.object({
export const GameStartInfoSchema = z.object({
gameID: ID,
lobbyCreatedAt: z.number(),
visibleAt: z.number().optional(),
config: GameConfigSchema,
players: PlayerSchema.array(),
});
+1
View File
@@ -94,6 +94,7 @@ export const OTHER_INDEX_LOST = 3; // Structures/warships destroyed/captured by
export const OTHER_INDEX_UPGRADE = 4; // Structures upgraded
export const BigIntStringSchema = z.preprocess((val) => {
if (val === null) return 0n;
if (typeof val === "string" && /^-?\d+$/.test(val)) return BigInt(val);
if (typeof val === "bigint") return val;
return val;
+4 -1
View File
@@ -251,6 +251,8 @@ export function createPartialGameRecord(
winner: Winner,
// lobby creation time (ms). Defaults to start time for singleplayer.
lobbyCreatedAt?: number,
// Time the lobby became visible to players (ms).
visibleAt?: number,
): PartialGameRecord {
const duration = Math.floor((end - start) / 1000);
const num_turns = allTurns.length;
@@ -262,13 +264,14 @@ export function createPartialGameRecord(
const actualLobbyCreatedAt = lobbyCreatedAt ?? start;
const lobbyFillTime = Math.max(
0,
start - Math.min(actualLobbyCreatedAt, start),
start - (visibleAt ?? actualLobbyCreatedAt),
);
const record: PartialGameRecord = {
info: {
gameID,
lobbyCreatedAt: actualLobbyCreatedAt,
visibleAt,
lobbyFillTime,
config,
players,
+3 -1
View File
@@ -75,6 +75,7 @@ export interface Config {
instantBuild(): boolean;
disableNavMesh(): boolean;
disableAlliances(): boolean;
waterNukes(): boolean;
isRandomSpawn(): boolean;
numSpawnPhaseTurns(): number;
userSettings(): UserSettings;
@@ -127,7 +128,7 @@ export interface Config {
defaultDonationAmount(sender: Player): number;
unitInfo(type: UnitType): UnitInfo;
tradeShipShortRangeDebuff(): number;
tradeShipGold(dist: number): Gold;
tradeShipGold(dist: number, player: Player | PlayerView): Gold;
tradeShipSpawnRate(
tradeShipSpawnRejections: number,
numTradeShips: number,
@@ -135,6 +136,7 @@ export interface Config {
trainGold(
rel: "self" | "team" | "ally" | "other",
citiesVisited: number,
player: Player | PlayerView,
): Gold;
trainSpawnRate(numPlayerFactories: number): number;
trainStationMinRange(): number;
+69 -22
View File
@@ -1,13 +1,18 @@
import { UserSettings } from "../game/UserSettings";
import { GameConfig } from "../Schemas";
import { Config, GameEnv, ServerConfig } from "./Config";
import { Config, ServerConfig } from "./Config";
import { DefaultConfig } from "./DefaultConfig";
import { DevConfig, DevServerConfig } from "./DevConfig";
import { Env } from "./Env";
import { preprodConfig } from "./PreprodConfig";
import { prodConfig } from "./ProdConfig";
export let cachedSC: ServerConfig | null = null;
export enum GameLogicEnv {
Dev = "dev",
Default = "default",
}
export let cachedRuntimeClientServerConfig: ServerConfig | null = null;
declare global {
interface Window {
@@ -17,35 +22,77 @@ declare global {
}
}
export async function getConfig(
export async function getGameLogicConfig(
gameConfig: GameConfig,
userSettings: UserSettings | null,
isReplay: boolean = false,
): Promise<Config> {
const sc = await getServerConfigFromClient();
switch (sc.env()) {
case GameEnv.Dev:
return new DevConfig(sc, gameConfig, userSettings, isReplay);
case GameEnv.Preprod:
case GameEnv.Prod:
console.log("using prod config");
return new DefaultConfig(sc, gameConfig, userSettings, isReplay);
const gameLogicEnv = getBuildTimeGameLogicEnv();
const serverConfig = getServerConfigForGameLogicEnv(gameLogicEnv);
switch (gameLogicEnv) {
case GameLogicEnv.Dev:
return new DevConfig(serverConfig, gameConfig, userSettings, isReplay);
case GameLogicEnv.Default:
return new DefaultConfig(
serverConfig,
gameConfig,
userSettings,
isReplay,
);
default:
throw Error(`unsupported server configuration: ${Env.GAME_ENV}`);
throw Error(`unsupported game logic environment: ${gameLogicEnv}`);
}
}
export async function getServerConfigFromClient(): Promise<ServerConfig> {
if (cachedSC) {
return cachedSC;
export function getBuildTimeGameLogicEnv(): GameLogicEnv {
const bundledGameEnv = process.env.GAME_ENV;
switch (bundledGameEnv) {
case "dev":
return GameLogicEnv.Dev;
case "staging":
case "prod":
return GameLogicEnv.Default;
case undefined:
throw new Error("Missing bundled game logic env");
default:
throw Error(`unsupported bundled game logic env: ${bundledGameEnv}`);
}
}
export function getServerConfigForGameLogicEnv(
gameLogicEnv: GameLogicEnv,
): ServerConfig {
switch (gameLogicEnv) {
case GameLogicEnv.Dev:
return new DevServerConfig();
case GameLogicEnv.Default:
console.log("using default game logic config");
return prodConfig;
default:
throw Error(`unsupported game logic environment: ${gameLogicEnv}`);
}
}
export async function getRuntimeClientServerConfig(): Promise<ServerConfig> {
if (cachedRuntimeClientServerConfig) {
return cachedRuntimeClientServerConfig;
}
const bootstrapGameEnv = window.BOOTSTRAP_CONFIG?.gameEnv;
if (!bootstrapGameEnv) {
throw new Error("Missing bootstrap server config");
if (typeof window === "undefined") {
throw new Error(
"Runtime client server config is only available on the browser main thread",
);
}
cachedSC = getServerConfig(bootstrapGameEnv);
return cachedSC;
const runtimeClientEnv = window.BOOTSTRAP_CONFIG?.gameEnv;
if (!runtimeClientEnv) {
throw new Error("Missing runtime client server config");
}
cachedRuntimeClientServerConfig = getServerConfig(runtimeClientEnv);
return cachedRuntimeClientServerConfig;
}
export function getServerConfigFromServer(): ServerConfig {
const gameEnv = Env.GAME_ENV;
@@ -67,6 +114,6 @@ export function getServerConfig(gameEnv: string) {
}
}
export function clearCachedServerConfig(): void {
cachedSC = null;
export function clearCachedRuntimeClientServerConfig(): void {
cachedRuntimeClientServerConfig = null;
}
+63 -13
View File
@@ -246,6 +246,9 @@ export class DefaultConfig implements Config {
disableAlliances(): boolean {
return this._gameConfig.disableAlliances ?? false;
}
waterNukes(): boolean {
return this._gameConfig.waterNukes ?? false;
}
isRandomSpawn(): boolean {
return this._gameConfig.randomSpawn;
}
@@ -268,7 +271,7 @@ export class DefaultConfig implements Config {
if (playerInfo.playerType === PlayerType.Bot) {
return 0n;
}
return BigInt(this._gameConfig.startingGold ?? 0);
return this.startingGoldFor(playerInfo);
}
trainSpawnRate(numPlayerFactories: number): number {
@@ -279,6 +282,7 @@ export class DefaultConfig implements Config {
trainGold(
rel: "self" | "team" | "ally" | "other",
citiesVisited: number,
player: Player | PlayerView,
): Gold {
// No penalty for the first 10 cities.
citiesVisited = Math.max(0, citiesVisited - 9);
@@ -297,7 +301,7 @@ export class DefaultConfig implements Config {
}
const distPenalty = citiesVisited * 5_000;
const gold = Math.max(5000, baseGold - distPenalty);
return toInt(gold * this.goldMultiplier());
return toInt(gold * this.goldMultiplierFor(player));
}
trainStationMinRange(): number {
@@ -310,13 +314,12 @@ export class DefaultConfig implements Config {
return 120;
}
tradeShipGold(dist: number): Gold {
tradeShipGold(dist: number, player: Player | PlayerView): Gold {
// Sigmoid: concave start, sharp S-curve middle, linear end - heavily punishes trades under range debuff.
const debuff = this.tradeShipShortRangeDebuff();
const baseGold =
75_000 / (1 + Math.exp(-0.03 * (dist - debuff))) + 50 * dist;
const multiplier = this.goldMultiplier();
return BigInt(Math.floor(baseGold * multiplier));
return BigInt(Math.floor(baseGold * this.goldMultiplierFor(player)));
}
// Probability of trade ship spawn = 1 / tradeShipSpawnRate
@@ -393,7 +396,10 @@ export class DefaultConfig implements Config {
case UnitType.MIRV:
info = {
cost: (game: Game, player: Player) => {
if (player.type() === PlayerType.Human && this.infiniteGold()) {
if (
player.type() === PlayerType.Human &&
this.hasInfiniteGoldFor(player)
) {
return 0n;
}
return 25_000_000n + game.stats().numMirvsLaunched() * 15_000_000n;
@@ -475,12 +481,55 @@ export class DefaultConfig implements Config {
return info;
}
private hasInfiniteGoldFor(player: Player | PlayerView): boolean {
if (this.infiniteGold()) return true;
const hc = this._gameConfig.hostCheats;
return (hc?.infiniteGold ?? false) && player.isLobbyCreator();
}
private hasInfiniteTroopsFor(player: Player | PlayerView): boolean {
if (this.infiniteTroops()) return true;
return (
(this._gameConfig.hostCheats?.infiniteTroops ?? false) &&
player.isLobbyCreator()
);
}
private hasInfiniteTroopsForInfo(playerInfo: PlayerInfo): boolean {
if (this.infiniteTroops()) return true;
return (
(this._gameConfig.hostCheats?.infiniteTroops ?? false) &&
playerInfo.isLobbyCreator
);
}
private goldMultiplierFor(player: Player | PlayerView): number {
const base = this.goldMultiplier();
const hc = this._gameConfig.hostCheats;
if (hc?.goldMultiplier && player.isLobbyCreator()) {
return hc.goldMultiplier;
}
return base;
}
private startingGoldFor(playerInfo: PlayerInfo): Gold {
const base = BigInt(this._gameConfig.startingGold ?? 0);
const hc = this._gameConfig.hostCheats;
if (hc?.startingGold && playerInfo.isLobbyCreator) {
return base + BigInt(hc.startingGold);
}
return base;
}
private costWrapper(
costFn: (units: number) => number,
...types: UnitType[]
): (g: Game, p: Player) => bigint {
return (game: Game, player: Player) => {
if (player.type() === PlayerType.Human && this.infiniteGold()) {
if (
player.type() === PlayerType.Human &&
this.hasInfiniteGoldFor(player)
) {
return 0n;
}
const numUnits = types.reduce(
@@ -669,7 +718,7 @@ export class DefaultConfig implements Config {
const altAttackerLoss =
1.3 * defenderTroopLoss * (mag / 100) * traitorMod;
const attackerTroopLoss =
0.7 * currentAttackerLoss + 0.3 * altAttackerLoss;
0.4 * currentAttackerLoss + 0.6 * altAttackerLoss;
return {
attackerTroopLoss,
@@ -758,16 +807,17 @@ export class DefaultConfig implements Config {
assertNever(this._gameConfig.difficulty);
}
}
return this.infiniteTroops() ? 1_000_000 : 25_000;
return this.hasInfiniteTroopsForInfo(playerInfo) ? 1_000_000 : 25_000;
}
maxTroops(player: Player | PlayerView): number {
const maxTroops =
player.type() === PlayerType.Human && this.infiniteTroops()
player.type() === PlayerType.Human && this.hasInfiniteTroopsFor(player)
? 1_000_000_000
: 2 * (Math.pow(player.numTilesOwned(), 0.6) * 1000 + 50000) +
: 2 * (Math.pow(player.numTilesOwned(), 0.7) * 1000 + 50000) +
player
.units(UnitType.City)
.filter((u) => !u.isUnderConstruction())
.map((city) => city.level())
.reduce((a, b) => a + b, 0) *
this.cityTroopIncrease();
@@ -797,7 +847,7 @@ export class DefaultConfig implements Config {
troopIncreaseRate(player: Player): number {
const max = this.maxTroops(player);
let toAdd = 10 + Math.pow(player.troops(), 0.73) / 4;
let toAdd = 10 + Math.pow(player.troops(), 0.8) / 4;
const ratio = 1 - player.troops() / max;
toAdd *= ratio;
@@ -829,7 +879,7 @@ export class DefaultConfig implements Config {
}
goldAdditionRate(player: Player): Gold {
const multiplier = this.goldMultiplier();
const multiplier = this.goldMultiplierFor(player);
let baseRate: bigint;
if (player.type() === PlayerType.Bot) {
baseRate = 50n;
+3
View File
@@ -283,6 +283,9 @@ export class AttackExecution implements Execution {
if (this.mg.owner(tileToConquer) !== this.target || !onBorder) {
continue;
}
if (!this.mg.isLand(tileToConquer)) {
continue;
}
this.addNeighbors(tileToConquer);
const { attackerTroopLoss, defenderTroopLoss, tilesPerTickUsed } = this.mg
.config()
+1 -1
View File
@@ -68,7 +68,7 @@ export class Executor {
case "cancel_boat":
return new BoatRetreatExecution(player, intent.unitID);
case "move_warship":
return new MoveWarshipExecution(player, intent.unitId, intent.tile);
return new MoveWarshipExecution(player, intent.unitIds, intent.tile);
case "spawn":
return new SpawnExecution(this.gameID, player.info(), intent.tile);
case "boat":
+20 -15
View File
@@ -4,31 +4,36 @@ import { TileRef } from "../game/GameMap";
export class MoveWarshipExecution implements Execution {
constructor(
private readonly owner: Player,
private readonly unitId: number,
private readonly unitIds: number[],
private readonly position: TileRef,
) {}
init(mg: Game, ticks: number): void {
init(mg: Game, _ticks: number): void {
if (!mg.isValidRef(this.position)) {
console.warn(`MoveWarshipExecution: position ${this.position} not valid`);
return;
}
const warship = this.owner
.units(UnitType.Warship)
.find((u) => u.id() === this.unitId);
if (!warship) {
console.warn("MoveWarshipExecution: warship not found");
return;
// Cache warship list and build a lookup map — avoids repeated iteration
const warshipMap = new Map(
this.owner.units(UnitType.Warship).map((u) => [u.id(), u]),
);
// Deduplicate ids so each warship is only moved once
for (const unitId of new Set(this.unitIds)) {
const warship = warshipMap.get(unitId);
if (!warship) {
console.warn(`MoveWarshipExecution: warship ${unitId} not found`);
continue;
}
if (!warship.isActive()) {
console.warn(`MoveWarshipExecution: warship ${unitId} is not active`);
continue;
}
warship.setPatrolTile(this.position);
warship.setTargetTile(undefined);
}
if (!warship.isActive()) {
console.warn("MoveWarshipExecution: warship is not active");
return;
}
warship.setPatrolTile(this.position);
warship.setTargetTile(undefined);
}
tick(ticks: number): void {}
tick(_ticks: number): void {}
isActive(): boolean {
return false;
+56 -7
View File
@@ -63,10 +63,60 @@ export class NukeExecution implements Execution {
const rand = new PseudoRandom(this.mg.ticks());
const inner2 = magnitude.inner * magnitude.inner;
const outer2 = magnitude.outer * magnitude.outer;
this.tilesToDestroyCache = this.mg.bfs(this.dst, (_, n: TileRef) => {
const d2 = this.mg?.euclideanDistSquared(this.dst, n) ?? 0;
return d2 <= outer2 && (d2 <= inner2 || rand.chance(2));
});
if (this.mg.config().waterNukes()) {
// Smooth irregular boundary for water nukes.
// Generate random radii at angular samples, then smooth them so the
// boundary undulates gently instead of creating spiky flower shapes.
// This avoids scattered land pixels that players would have to boat
// to individually in order to reclaim.
const NUM_SAMPLES = 16;
const radiiSq: number[] = new Array(NUM_SAMPLES);
for (let i = 0; i < NUM_SAMPLES; i++) {
radiiSq[i] = rand.nextFloat(inner2, outer2);
}
// Smooth the ring: 1 light pass (60% original, 20% each neighbour)
const prev = [...radiiSq];
for (let i = 0; i < NUM_SAMPLES; i++) {
const l = (i - 1 + NUM_SAMPLES) % NUM_SAMPLES;
const r = (i + 1) % NUM_SAMPLES;
radiiSq[i] = prev[i] * 0.6 + prev[l] * 0.2 + prev[r] * 0.2;
}
const cx = this.mg.x(this.dst);
const cy = this.mg.y(this.dst);
const outer = magnitude.outer;
const result = new Set<TileRef>();
const x0 = Math.max(0, cx - outer);
const y0 = Math.max(0, cy - outer);
const x1 = Math.min(this.mg.width() - 1, cx + outer);
const y1 = Math.min(this.mg.height() - 1, cy + outer);
for (let py = y0; py <= y1; py++) {
for (let px = x0; px <= x1; px++) {
const dx = px - cx;
const dy = py - cy;
const d2 = dx * dx + dy * dy;
if (d2 > outer2) continue;
if (d2 > inner2) {
const angle = Math.atan2(dy, dx) + Math.PI; // [0, 2π]
const t = (angle / (2 * Math.PI)) * NUM_SAMPLES;
const i0 = Math.floor(t) % NUM_SAMPLES;
const i1 = (i0 + 1) % NUM_SAMPLES;
const frac = t - Math.floor(t);
const threshold = radiiSq[i0] * (1 - frac) + radiiSq[i1] * frac;
if (d2 > threshold) continue;
}
result.add(this.mg.ref(px, py));
}
}
this.tilesToDestroyCache = result;
} else {
this.tilesToDestroyCache = this.mg.bfs(this.dst, (_, n: TileRef) => {
const d2 = this.mg?.euclideanDistSquared(this.dst, n) ?? 0;
return d2 <= outer2 && (d2 <= inner2 || rand.chance(2));
});
}
return this.tilesToDestroyCache;
}
@@ -89,7 +139,6 @@ export class NukeExecution implements Execution {
game: this.mg,
targetTile: this.dst,
magnitude,
allySmallIds: new Set(this.player.allies().map((a) => a.smallID())),
threshold: this.mg.config().nukeAllianceBreakThreshold(),
});
@@ -180,7 +229,6 @@ export class NukeExecution implements Execution {
// make the nuke unactive if it was intercepted
if (!this.nuke.isActive()) {
console.log(`Nuke destroyed before reaching target`);
this.active = false;
return;
}
@@ -267,8 +315,9 @@ export class NukeExecution implements Execution {
tilesPerPlayers.set(owner, (tilesPerPlayers.get(owner) ?? 0) + 1);
}
// Queue land tiles for batched water conversion
if (mg.isLand(tile)) {
mg.setFallout(tile, true);
mg.queueWaterConversion(tile);
}
}
+12
View File
@@ -97,10 +97,22 @@ export class PortExecution implements Execution {
// It's a probability list, so if an element appears twice it's because it's
// twice more likely to be picked later.
tradingPorts(): Unit[] {
const sourceComponents = new Set<number>();
for (const neighbor of this.mg.neighbors(this.port!.tile())) {
if (!this.mg.isWater(neighbor)) continue;
const comp = this.mg.getWaterComponent(neighbor);
if (comp !== null) sourceComponents.add(comp);
}
const ports = this.mg
.players()
.filter((p) => p !== this.port!.owner() && p.canTrade(this.port!.owner()))
.flatMap((p) => p.units(UnitType.Port))
.filter((p) => {
for (const comp of sourceComponents) {
if (this.mg.hasWaterComponent(p.tile(), comp)) return true;
}
return false;
})
.sort((p1, p2) => {
return (
this.mg.manhattanDist(this.port!.tile(), p1.tile()) -
@@ -304,9 +304,6 @@ export class SAMLauncherExecution implements Execution {
let target: Target | null = null;
if (mirvWarheadTargets.length === 0) {
target = this.targetingSystem.getSingleTarget(ticks);
if (target !== null) {
console.log("Target acquired");
}
}
// target is already filtered to exclude nukes targeted by other SAMs
+19 -6
View File
@@ -8,8 +8,8 @@ import {
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { PathFinding } from "../pathfinding/PathFinder";
import { PathStatus, SteppingPathFinder } from "../pathfinding/types";
import { WaterPathFinder } from "../pathfinding/PathFinder";
import { PathStatus } from "../pathfinding/types";
import { findClosestBy } from "../Util";
export class TradeShipExecution implements Execution {
@@ -17,11 +17,13 @@ export class TradeShipExecution implements Execution {
private mg: Game;
private tradeShip: Unit | undefined;
private wasCaptured = false;
private pathFinder: SteppingPathFinder<TileRef>;
private pathFinder: WaterPathFinder;
private tilesTraveled = 0;
private motionPlanId = 1;
private motionPlanDst: TileRef | null = null;
private static _staggerCounter = 0;
constructor(
private origOwner: Player,
private srcPort: Unit,
@@ -30,10 +32,16 @@ export class TradeShipExecution implements Execution {
init(mg: Game, ticks: number): void {
this.mg = mg;
this.pathFinder = PathFinding.Water(mg);
const stagger =
TradeShipExecution._staggerCounter++ % WaterPathFinder.STAGGER_SPREAD;
this.pathFinder = new WaterPathFinder(mg, stagger);
}
tick(ticks: number): void {
if (this.pathFinder.rebuilt) {
this.motionPlanDst = null; // Force motion plan re-recording
}
if (this.tradeShip === undefined) {
const spawn = this.origOwner.canBuild(
UnitType.TradeShip,
@@ -86,13 +94,16 @@ export class TradeShipExecution implements Execution {
this.wasCaptured &&
(tradeShipOwner !== dstPortOwner || !this._dstPort.isActive())
) {
const myComponent = this.mg.getWaterComponent(curTile);
const nearestPort = findClosestBy(
tradeShipOwner.units(UnitType.Port),
(port) => this.mg.manhattanDist(port.tile(), curTile),
(port) =>
port.isActive() &&
!port.isMarkedForDeletion() &&
!port.isUnderConstruction(),
!port.isUnderConstruction() &&
myComponent !== null &&
this.mg.hasWaterComponent(port.tile(), myComponent),
);
if (nearestPort === null) {
this.tradeShip.delete(false);
@@ -157,7 +168,9 @@ export class TradeShipExecution implements Execution {
private complete() {
this.active = false;
this.tradeShip!.delete(false);
const gold = this.mg.config().tradeShipGold(this.tilesTraveled);
const gold = this.mg
.config()
.tradeShipGold(this.tilesTraveled, this.tradeShip!.owner());
if (this.wasCaptured) {
this.tradeShip!.owner().addGold(gold, this._dstPort.tile());
+23 -4
View File
@@ -12,8 +12,8 @@ import {
import { TileRef } from "../game/GameMap";
import { MotionPlanRecord } from "../game/MotionPlans";
import { targetTransportTile } from "../game/TransportShipUtils";
import { PathFinding } from "../pathfinding/PathFinder";
import { PathStatus, SteppingPathFinder } from "../pathfinding/types";
import { WaterPathFinder } from "../pathfinding/PathFinder";
import { PathStatus } from "../pathfinding/types";
import { AttackExecution } from "./AttackExecution";
const malusForRetreat = 25;
@@ -27,7 +27,9 @@ export class TransportShipExecution implements Execution {
private mg: Game;
private target: Player | TerraNullius;
private pathFinder: SteppingPathFinder<TileRef>;
private pathFinder: WaterPathFinder;
private static _staggerCounter = 0;
private dst: TileRef | null;
private src: TileRef | null;
@@ -60,7 +62,9 @@ export class TransportShipExecution implements Execution {
this.lastMove = ticks;
this.mg = mg;
this.target = mg.owner(this.ref);
this.pathFinder = PathFinding.Water(mg);
const stagger =
TransportShipExecution._staggerCounter++ % WaterPathFinder.STAGGER_SPREAD;
this.pathFinder = new WaterPathFinder(mg, stagger);
if (
this.attacker.unitCount(UnitType.TransportShip) >=
@@ -186,6 +190,21 @@ export class TransportShipExecution implements Execution {
this.originalOwner = boatOwner; // for when this owner disconnects too
}
if (this.pathFinder.rebuilt) {
this.motionPlanDst = null; // Force motion plan re-recording
}
// Auto-retreat if destination was destroyed by nuke (turned to water)
// Checked every tick (not just on graph rebuild) because graph rebuilds
// are throttled and the tile may already be water before the version bumps.
if (this.dst !== null && this.mg.isWater(this.dst)) {
if (!this.boat.retreating()) {
this.boat.orderBoatRetreat();
}
// Reset cached retreat destination so it's recomputed from current position
this.retreatDst = null;
}
if (this.boat.retreating()) {
// Resolve retreat destination once, based on current boat location when retreat begins.
this.retreatDst ??= this.attacker.bestTransportShipSpawn(
+2 -2
View File
@@ -39,7 +39,7 @@ export interface NukeAllianceCheckParams {
game: Game | GameView;
targetTile: TileRef;
magnitude: NukeMagnitude;
allySmallIds: Set<number>;
allySmallIds?: Set<number>;
threshold: number;
}
@@ -52,7 +52,7 @@ export function wouldNukeBreakAlliance(
): boolean {
const { game, targetTile, magnitude, allySmallIds, threshold } = params;
if (allySmallIds.size === 0) {
if (!allySmallIds || allySmallIds.size === 0) {
return false;
}
+28 -7
View File
@@ -8,8 +8,8 @@ import {
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { PathFinding } from "../pathfinding/PathFinder";
import { PathStatus, SteppingPathFinder } from "../pathfinding/types";
import { WaterPathFinder } from "../pathfinding/PathFinder";
import { PathStatus } from "../pathfinding/types";
import { PseudoRandom } from "../PseudoRandom";
import { ShellExecution } from "./ShellExecution";
@@ -17,7 +17,7 @@ export class WarshipExecution implements Execution {
private random: PseudoRandom;
private warship: Unit;
private mg: Game;
private pathfinder: SteppingPathFinder<TileRef>;
private pathfinder: WaterPathFinder;
private lastShellAttack = 0;
private alreadySentShell = new Set<Unit>();
@@ -27,7 +27,7 @@ export class WarshipExecution implements Execution {
init(mg: Game, ticks: number): void {
this.mg = mg;
this.pathfinder = PathFinding.Water(mg);
this.pathfinder = new WaterPathFinder(mg);
this.random = new PseudoRandom(mg.ticks());
if (isUnit(this.input)) {
this.warship = this.input;
@@ -83,6 +83,10 @@ export class WarshipExecution implements Execution {
const patrolTile = this.warship.patrolTile()!;
const patrolRangeSquared = config.warshipPatrolRange() ** 2;
// Lazy: only computed if a TradeShip candidate forces the component check.
// `undefined` = not yet computed; `null` = computed, no component found.
let warshipComponent: number | null | undefined = undefined;
const ships = mg.nearbyUnits(
this.warship.tile()!,
config.warshipTargettingRange(),
@@ -113,6 +117,17 @@ export class WarshipExecution implements Execution {
) {
continue;
}
if (warshipComponent === undefined) {
warshipComponent = mg.getWaterComponent(this.warship.tile());
}
if (
warshipComponent !== null &&
!mg.hasWaterComponent(unit.tile(), warshipComponent)
) {
continue;
}
if (
mg.euclideanDistSquared(patrolTile, unit.tile()) > patrolRangeSquared
) {
@@ -220,6 +235,7 @@ export class WarshipExecution implements Execution {
break;
case PathStatus.NOT_FOUND: {
console.log(`path not found to target`);
this.warship.setTargetTile(undefined);
break;
}
}
@@ -242,19 +258,24 @@ export class WarshipExecution implements Execution {
// Get warship's water component for connectivity check
const warshipComponent = this.mg.getWaterComponent(this.warship.tile());
const patrolTile = this.warship.patrolTile();
if (patrolTile === undefined) {
return undefined;
}
while (expandCount < 3) {
const x =
this.mg.x(this.warship.patrolTile()!) +
this.mg.x(patrolTile) +
this.random.nextInt(-warshipPatrolRange / 2, warshipPatrolRange / 2);
const y =
this.mg.y(this.warship.patrolTile()!) +
this.mg.y(patrolTile) +
this.random.nextInt(-warshipPatrolRange / 2, warshipPatrolRange / 2);
if (!this.mg.isValidCoord(x, y)) {
continue;
}
const tile = this.mg.ref(x, y);
if (
!this.mg.isOcean(tile) ||
!this.mg.isWater(tile) ||
(!allowShoreline && this.mg.isShoreline(tile))
) {
attempts++;
@@ -90,6 +90,7 @@ export class NationStructureBehavior {
cluster: Cluster | null;
weight: number;
}> | null = null;
private _sharedWaterComponents: Set<number> | null = null;
constructor(
private random: PseudoRandom,
@@ -107,7 +108,8 @@ export class NationStructureBehavior {
Math.floor(this.player.numTilesOwned() / TILES_PER_CITY_EQUIVALENT),
)
: this.player.unitsOwned(UnitType.City);
const hasCoastalTiles = this.hasCoastalTiles();
this._sharedWaterComponents = this.game.sharedWaterComponents(this.player);
const hasCoastalTiles = this._sharedWaterComponents !== null;
// Build order for non-city structures (priority order)
const buildOrder: UnitType[] = [
@@ -165,13 +167,6 @@ export class NationStructureBehavior {
return false;
}
private hasCoastalTiles(): boolean {
for (const tile of this.player.borderTiles()) {
if (this.game.isOceanShore(tile)) return true;
}
return false;
}
/**
* Determines if we should build more of this structure type based on
* the current city count and the configured ratio.
@@ -471,10 +466,22 @@ export class NationStructureBehavior {
return bestTile;
}
/** Samples shore tiles adjacent to water reachable by another player (=> trading possible) */
private randCoastalTileArray(numTiles: number): TileRef[] {
const tiles = Array.from(this.player.borderTiles()).filter((t) =>
this.game.isOceanShore(t),
);
const shared = this._sharedWaterComponents;
const tiles = Array.from(this.player.borderTiles()).filter((t) => {
if (!this.game.isShore(t)) return false;
if (shared === null) return false;
for (const neighbor of this.game.neighbors(t)) {
if (!this.game.isWater(neighbor)) continue;
// Ocean is always considered shared, so any ocean neighbor makes the
// tile a valid port site — skip the component lookup.
if (this.game.isOcean(neighbor)) return true;
const comp = this.game.getWaterComponent(neighbor);
if (comp !== null && shared.has(comp)) return true;
}
return false;
});
return Array.from(this.arraySampler(tiles, numTiles));
}
@@ -698,7 +705,7 @@ export class NationStructureBehavior {
}
const maxTradeGold = Math.max(
Number(game.config().trainGold("ally", 0)),
Number(game.config().trainGold("ally", 0, player)),
1,
);
const result: Array<{
@@ -709,7 +716,7 @@ export class NationStructureBehavior {
// Own structures — weighted by "self" trade gold.
const selfWeight =
Number(game.config().trainGold("self", 0)) / maxTradeGold;
Number(game.config().trainGold("self", 0, player)) / maxTradeGold;
for (const unit of player.units(
UnitType.City,
UnitType.Port,
@@ -734,7 +741,8 @@ export class NationStructureBehavior {
: player.isAlliedWith(neighbor)
? "ally"
: "other";
const weight = Number(game.config().trainGold(relType, 0)) / maxTradeGold;
const weight =
Number(game.config().trainGold(relType, 0, player)) / maxTradeGold;
for (const unit of neighbor.units(
UnitType.City,
UnitType.Port,
+1 -1
View File
@@ -41,7 +41,7 @@ function randTerritoryTile(
}
}
if (p.numTilesOwned() <= 100) {
if (p.numTilesOwned() > 0 && p.numTilesOwned() <= 100) {
return random.randElement(Array.from(p.tiles()));
}
@@ -74,7 +74,7 @@ export class NationWarshipBehavior {
}
const tile = this.game.ref(randX, randY);
// Sanity check
if (!this.game.isOcean(tile)) {
if (!this.game.isWater(tile)) {
continue;
}
return tile;
@@ -0,0 +1,99 @@
import { Game, Player, PlayerType } from "../../game/Game";
/**
* Cache for "which water components does each nation share with a
* valid trade partner". Used by nation AI to decide whether to spend cycles
* trying to place a port on a given coastline.
*
* Rebuilt at most once every TTL_TICKS (3s at 10 ticks/s). Port placement is
* not time-critical - a nation noticing a newly-valid port site a few seconds
* late is fine and lets us amortize the O(total_border_tiles) build across
* far more callers than a per-tick cache would.
*/
const TTL_TICKS = 30;
/** Sentinel added to a player's shared-water set to signal "touches ocean". */
const OCEAN_SENTINEL = -1;
export class SharedWaterCache {
private tick: number = -Infinity;
private byPlayer: Map<Player, Set<number> | null> | null = null;
constructor(private game: Game) {}
get(player: Player): Set<number> | null {
const tick = this.game.ticks();
if (this.byPlayer === null || tick - this.tick >= TTL_TICKS) {
this.byPlayer = this.build();
this.tick = tick;
}
return this.byPlayer.get(player) ?? null;
}
private build(): Map<Player, Set<number> | null> {
const game = this.game;
// Pass 1: for each non-bot player, record which water bodies they touch
// and which lakes have them as a candidate trade partner. Bots are skipped
// entirely — nation AI is the only caller, and bots are never candidate
// trade partners.
const playerToWater = new Map<
Player,
{ hasOcean: boolean; lakes: Set<number> }
>();
const lakePartners = new Map<number, Player[]>();
for (const player of game.players()) {
if (player.type() === PlayerType.Bot) continue;
let hasOcean = false;
const lakes = new Set<number>();
for (const tile of player.borderTiles()) {
if (!game.isShore(tile)) continue;
for (const neighbor of game.neighbors(tile)) {
if (!game.isWater(neighbor)) continue;
if (game.isOcean(neighbor)) {
hasOcean = true;
continue;
}
const comp = game.getWaterComponent(neighbor);
if (comp !== null) lakes.add(comp);
}
}
playerToWater.set(player, { hasOcean, lakes });
for (const c of lakes) {
let arr = lakePartners.get(c);
if (arr === undefined) {
arr = [];
lakePartners.set(c, arr);
}
arr.push(player);
}
}
// Pass 2: ocean is treated as always shared (nation AI short-circuits on
// ocean neighbors). Lake components are shared only if some *other* player
// on that component can trade with P (i.e. no mutual embargo).
const result = new Map<Player, Set<number> | null>();
for (const [player, { hasOcean, lakes }] of playerToWater) {
const shared = new Set<number>();
if (hasOcean) shared.add(OCEAN_SENTINEL);
for (const c of lakes) {
const partners = lakePartners.get(c);
if (partners === undefined) continue;
for (const other of partners) {
if (other !== player && player.canTrade(other)) {
shared.add(c);
break;
}
}
}
result.set(player, shared.size > 0 ? shared : null);
}
return result;
}
}
+16 -16
View File
@@ -109,15 +109,15 @@ export class AiAttackBehavior {
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),
// Check if we have any shore tiles to launch from
const shore = Array.from(this.player.borderTiles()).filter((t) =>
this.game.isShore(t),
);
if (oceanShore.length === 0) {
if (shore.length === 0) {
return;
}
const src = this.random.randElement(oceanShore);
const src = this.random.randElement(shore);
// First look for high-interest targets (unowned or bot-owned). Mainly relevant for earlygame
let dst = this.findRandomBoatTarget(src, borderingEnemies, true);
@@ -379,8 +379,10 @@ export class AiAttackBehavior {
}
findIncomingAttackPlayer(): Player | null {
let incomingAttacks = this.player
.incomingAttacks()
.filter((attack) => !this.player.isFriendly(attack.attacker()));
// Ignore bot attacks if we are not a bot.
let incomingAttacks = this.player.incomingAttacks();
if (this.player.type() !== PlayerType.Bot) {
incomingAttacks = incomingAttacks.filter(
(attack) => attack.attacker().type() !== PlayerType.Bot,
@@ -574,11 +576,11 @@ export class AiAttackBehavior {
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),
// Check if we have any shore tiles to launch from
const hasShore = Array.from(this.player.borderTiles()).some((t) =>
this.game.isShore(t),
);
if (!hasOceanShore) return null;
if (!hasShore) return null;
const filteredPlayers = this.game.players().filter((p) => {
if (p === this.player) return false;
@@ -615,10 +617,10 @@ export class AiAttackBehavior {
const closest = closestTwoTiles(
this.game,
Array.from(this.player.borderTiles()).filter((t) =>
this.game.isOceanShore(t),
this.game.isShore(t),
),
Array.from(entry.player.borderTiles()).filter((t) =>
this.game.isOceanShore(t),
this.game.isShore(t),
),
);
if (closest === null) continue;
@@ -786,10 +788,8 @@ export class AiAttackBehavior {
private sendBoatAttack(target: Player) {
const closest = closestTwoTiles(
this.game,
Array.from(this.player.borderTiles()).filter((t) =>
this.game.isOceanShore(t),
),
Array.from(target.borderTiles()).filter((t) => this.game.isOceanShore(t)),
Array.from(this.player.borderTiles()).filter((t) => this.game.isShore(t)),
Array.from(target.borderTiles()).filter((t) => this.game.isShore(t)),
);
if (closest === null) {
return;
+41 -5
View File
@@ -143,6 +143,16 @@ export enum GameMapType {
Arctic = "Arctic",
SanFrancisco = "San Francisco",
Aegean = "Aegean",
MilkyWay = "MilkyWay",
Mediterranean = "Mediterranean",
Dyslexdria = "Dyslexdria",
GreatLakes = "Great Lakes",
StraitOfMalacca = "Strait Of Malacca",
Luna = "Luna",
Conakry = "Conakry",
Caucasus = "Caucasus",
BeringSea = "Bering Sea",
Antarctica = "Antarctica",
}
export type GameMapName = keyof typeof GameMapType;
@@ -158,6 +168,7 @@ export const mapCategories: Record<string, GameMapType[]> = {
GameMapType.Asia,
GameMapType.Africa,
GameMapType.Oceania,
GameMapType.Antarctica,
],
regional: [
GameMapType.BritanniaClassic,
@@ -194,6 +205,12 @@ export const mapCategories: Record<string, GameMapType[]> = {
GameMapType.Arctic,
GameMapType.SanFrancisco,
GameMapType.Aegean,
GameMapType.Mediterranean,
GameMapType.GreatLakes,
GameMapType.StraitOfMalacca,
GameMapType.Conakry,
GameMapType.Caucasus,
GameMapType.BeringSea,
],
fantasy: [
GameMapType.Pangaea,
@@ -207,6 +224,9 @@ export const mapCategories: Record<string, GameMapType[]> = {
GameMapType.Surrounded,
GameMapType.TradersDream,
GameMapType.Passage,
GameMapType.MilkyWay,
GameMapType.Dyslexdria,
GameMapType.Luna,
],
arcade: [
GameMapType.TheBox,
@@ -248,13 +268,18 @@ export enum GameMapSize {
}
export interface PublicGameModifiers {
isCompact: boolean;
isRandomSpawn: boolean;
isCrowded: boolean;
isHardNations: boolean;
isCompact?: boolean;
isRandomSpawn?: boolean;
isCrowded?: boolean;
isHardNations?: boolean;
startingGold?: number;
goldMultiplier?: number;
isAlliancesDisabled: boolean;
isAlliancesDisabled?: boolean;
isPortsDisabled?: boolean;
isNukesDisabled?: boolean;
isSAMsDisabled?: boolean;
isPeaceTime?: boolean;
isWaterNukes?: boolean;
}
export interface UnitInfo {
@@ -901,6 +926,17 @@ export interface Game extends GameMap {
miniWaterGraph(): AbstractGraph | null;
getWaterComponent(tile: TileRef): number | null;
hasWaterComponent(tile: TileRef, component: number): boolean;
/**
* Returns the set of water components that `player` shares with at least one
* valid trade partner (cached). Used by nation AI for port-placement
* heuristics. `null` means no usable water body for ports.
*/
sharedWaterComponents(player: Player): Set<number> | null;
/** Incremented each time the water navigation graph is rebuilt (e.g. after nuke terrain change). */
waterGraphVersion(): number;
/** Queue a land tile for conversion to water (batched every few ticks). Tile must be unowned. */
queueWaterConversion(tile: TileRef): void;
}
export interface PlayerActions {
+74 -87
View File
@@ -1,10 +1,7 @@
import { renderNumber } from "../../client/Utils";
import { Config } from "../configuration/Config";
import {
AbstractGraph,
AbstractGraphBuilder,
} from "../pathfinding/algorithms/AbstractGraph";
import { AStarWaterHierarchical } from "../pathfinding/algorithms/AStar.WaterHierarchical";
import { SharedWaterCache } from "../execution/nation/SharedWaterCache";
import { AbstractGraph } from "../pathfinding/algorithms/AbstractGraph";
import { PathFinder } from "../pathfinding/types";
import { AllPlayersStats, ClientID, Winner } from "../Schemas";
import { ATTACK_INDEX_SENT } from "../StatsSchemas";
@@ -52,6 +49,7 @@ import { StatsImpl } from "./StatsImpl";
import { assignTeams } from "./TeamAssignment";
import { TerraNulliusImpl } from "./TerraNulliusImpl";
import { UnitGrid, UnitPredicate } from "./UnitGrid";
import { WaterManager } from "./WaterManager";
export function createGame(
humans: PlayerInfo[],
@@ -109,8 +107,8 @@ export class GameImpl implements Game {
private _isPaused: boolean = false;
private _winner: Player | Team | null = null;
private _miniWaterGraph: AbstractGraph | null = null;
private _miniWaterHPA: AStarWaterHierarchical | null = null;
private _waterManager: WaterManager;
private _sharedWaterCache: SharedWaterCache;
private _teamGameSpawnAreas: TeamGameSpawnAreas | undefined;
constructor(
@@ -129,23 +127,18 @@ export class GameImpl implements Game {
this._width = _map.width();
this._height = _map.height();
this.unitGrid = new UnitGrid(this._map);
this._waterManager = new WaterManager(
this._map,
this.miniGameMap,
_config.disableNavMesh(),
);
this._sharedWaterCache = new SharedWaterCache(this);
if (_config.gameConfig().gameMode === GameMode.Team) {
this.populateTeams();
}
this.addPlayers();
if (!_config.disableNavMesh()) {
const graphBuilder = new AbstractGraphBuilder(this.miniGameMap);
this._miniWaterGraph = graphBuilder.build();
this._miniWaterHPA = new AStarWaterHierarchical(
this.miniGameMap,
this._miniWaterGraph,
{ cachePaths: true },
);
}
console.log(
`[GameImpl] Constructor total: ${(performance.now() - constructorStart).toFixed(0)}ms`,
);
@@ -269,6 +262,31 @@ export class GameImpl implements Game {
this.recordTileUpdate(tile);
}
setWater(tile: TileRef): void {
if (!this.isLand(tile)) return;
if (this.hasOwner(tile)) {
throw Error(`cannot set water, tile ${tile} has owner`);
}
// Clear fallout if present (water tiles shouldn't have fallout)
if (this._map.hasFallout(tile)) {
this._map.setFallout(tile, false);
}
this._map.setWater(tile);
this.recordTileUpdate(tile);
}
queueWaterConversion(tile: TileRef): void {
if (!this.isLand(tile)) return;
if (this.hasOwner(tile)) {
throw Error(`cannot queue water conversion, tile ${tile} has owner`);
}
if (!this._config.waterNukes()) {
this.setFallout(tile, true);
return;
}
this._waterManager.queueTile(tile);
}
units(...types: UnitType[]): Unit[] {
return Array.from(this._players.values()).flatMap((p) => p.units(...types));
}
@@ -429,12 +447,22 @@ export class GameImpl implements Game {
hash: this.hash(),
});
}
// Flush pending water conversions + throttled graph rebuild
const waterChangedTiles = this._waterManager.tick(this._ticks);
for (const tile of waterChangedTiles) {
this.recordTileUpdate(tile);
}
this._ticks++;
return this.updates;
}
private recordTileUpdate(tile: TileRef): void {
this.tileUpdatePairs.push(tile, this._map.tileState(tile));
// Low 16 bits: tile state, bits 16-23: terrain byte
this.tileUpdatePairs.push(
tile,
(this._map.tileState(tile) & 0xffff) |
(this._map.terrainByte(tile) << 16),
);
}
drainPackedTileUpdates(): Uint32Array {
@@ -1034,6 +1062,21 @@ export class GameImpl implements Game {
magnitude(ref: TileRef): number {
return this._map.magnitude(ref);
}
terrainByte(ref: TileRef): number {
return this._map.terrainByte(ref);
}
setShorelineBit(ref: TileRef): void {
this._map.setShorelineBit(ref);
}
clearShorelineBit(ref: TileRef): void {
this._map.clearShorelineBit(ref);
}
setOcean(ref: TileRef): void {
this._map.setOcean(ref);
}
setMagnitude(ref: TileRef, value: number): void {
this._map.setMagnitude(ref, value);
}
ownerID(ref: TileRef): number {
return this._map.ownerID(ref);
}
@@ -1101,8 +1144,8 @@ export class GameImpl implements Game {
tileState(tile: TileRef): number {
return this._map.tileState(tile);
}
updateTile(tile: TileRef, state: number): void {
this._map.updateTile(tile, state);
updateTile(tile: TileRef, state: number): boolean {
return this._map.updateTile(tile, state);
}
numTilesWithFallout(): number {
return this._map.numTilesWithFallout();
@@ -1114,78 +1157,22 @@ export class GameImpl implements Game {
return this._railNetwork;
}
miniWaterHPA(): PathFinder<number> | null {
return this._miniWaterHPA;
return this._waterManager.miniWaterHPA();
}
miniWaterGraph(): AbstractGraph | null {
return this._miniWaterGraph;
return this._waterManager.miniWaterGraph();
}
waterGraphVersion(): number {
return this._waterManager.waterGraphVersion();
}
getWaterComponent(tile: TileRef): number | null {
// Permissive fallback for tests with disableNavMesh
if (!this._miniWaterGraph) return 0;
const miniX = Math.floor(this._map.x(tile) / 2);
const miniY = Math.floor(this._map.y(tile) / 2);
const miniTile = this.miniGameMap.ref(miniX, miniY);
if (this.miniGameMap.isWater(miniTile)) {
return this._miniWaterGraph.getComponentId(miniTile);
}
// Shore tile: find water neighbor (expand search for minimap resolution loss)
for (const n of this.miniGameMap.neighbors(miniTile)) {
if (this.miniGameMap.isWater(n)) {
return this._miniWaterGraph.getComponentId(n);
}
}
// Extended search: check 2-hop neighbors for narrow straits
for (const n of this.miniGameMap.neighbors(miniTile)) {
for (const n2 of this.miniGameMap.neighbors(n)) {
if (this.miniGameMap.isWater(n2)) {
return this._miniWaterGraph.getComponentId(n2);
}
}
}
return null;
return this._waterManager.getWaterComponent(tile);
}
hasWaterComponent(tile: TileRef, component: number): boolean {
// Permissive fallback for tests with disableNavMesh
if (!this._miniWaterGraph) return true;
const miniX = Math.floor(this._map.x(tile) / 2);
const miniY = Math.floor(this._map.y(tile) / 2);
const miniTile = this.miniGameMap.ref(miniX, miniY);
// Check miniTile itself (shore in full map may be water in minimap)
if (
this.miniGameMap.isWater(miniTile) &&
this._miniWaterGraph.getComponentId(miniTile) === component
) {
return true;
}
// Check neighbors
for (const n of this.miniGameMap.neighbors(miniTile)) {
if (
this.miniGameMap.isWater(n) &&
this._miniWaterGraph.getComponentId(n) === component
) {
return true;
}
}
// Extended search: check 2-hop neighbors for narrow straits
for (const n of this.miniGameMap.neighbors(miniTile)) {
for (const n2 of this.miniGameMap.neighbors(n)) {
if (
this.miniGameMap.isWater(n2) &&
this._miniWaterGraph.getComponentId(n2) === component
) {
return true;
}
}
}
return false;
return this._waterManager.hasWaterComponent(tile, component);
}
sharedWaterComponents(player: Player): Set<number> | null {
return this._sharedWaterCache.get(player);
}
conquerPlayer(conqueror: Player, conquered: Player) {
if (conquered.isDisconnected() && conqueror.isOnSameTeam(conquered)) {
+67 -4
View File
@@ -13,12 +13,19 @@ export interface GameMap {
numLandTiles(): number;
isValidCoord(x: number, y: number): boolean;
// Terrain getters (immutable)
// Terrain getters
isLand(ref: TileRef): boolean;
isOceanShore(ref: TileRef): boolean;
isOcean(ref: TileRef): boolean;
isShoreline(ref: TileRef): boolean;
magnitude(ref: TileRef): number;
terrainByte(ref: TileRef): number;
// Terrain setters
setWater(ref: TileRef): void;
setShorelineBit(ref: TileRef): void;
clearShorelineBit(ref: TileRef): void;
setOcean(ref: TileRef): void;
setMagnitude(ref: TileRef, value: number): void;
// State getters and setters (mutable)
ownerID(ref: TileRef): number;
hasOwner(ref: TileRef): boolean;
@@ -60,8 +67,10 @@ export interface GameMap {
*
* `state` must be an unsigned 16-bit value (`0..65535`). Implementations may
* store this in a `Uint16Array` and will truncate higher bits if provided.
*
* Returns `true` when the terrain byte changed (land/water/shoreline/magnitude).
*/
updateTile(tile: TileRef, state: number): void;
updateTile(tile: TileRef, state: number): boolean;
numTilesWithFallout(): number;
}
@@ -158,7 +167,14 @@ export class GameMapImpl implements GameMap {
}
isValidCoord(x: number, y: number): boolean {
return x >= 0 && x < this.width_ && y >= 0 && y < this.height_;
return (
Number.isInteger(x) &&
Number.isInteger(y) &&
x >= 0 &&
x < this.width_ &&
y >= 0 &&
y < this.height_
);
}
// Terrain getters (immutable)
@@ -184,6 +200,34 @@ export class GameMapImpl implements GameMap {
return this.terrain[ref] & GameMapImpl.MAGNITUDE_MASK;
}
terrainByte(ref: TileRef): number {
return this.terrain[ref];
}
setWater(ref: TileRef): void {
if (!this.isLand(ref)) return;
this.terrain[ref] = 0; // Lake water: no land, no ocean, no shoreline, magnitude 0
this.numLandTiles_--;
}
setShorelineBit(ref: TileRef): void {
this.terrain[ref] |= 1 << GameMapImpl.SHORELINE_BIT;
}
clearShorelineBit(ref: TileRef): void {
this.terrain[ref] &= ~(1 << GameMapImpl.SHORELINE_BIT);
}
setOcean(ref: TileRef): void {
this.terrain[ref] |= 1 << GameMapImpl.OCEAN_BIT;
}
setMagnitude(ref: TileRef, value: number): void {
this.terrain[ref] =
(this.terrain[ref] & ~GameMapImpl.MAGNITUDE_MASK) |
(value & GameMapImpl.MAGNITUDE_MASK);
}
// State getters and setters (mutable)
ownerID(ref: TileRef): number {
return this.state[ref] & GameMapImpl.PLAYER_ID_MASK;
@@ -357,7 +401,15 @@ export class GameMapImpl implements GameMap {
return this.state[tile];
}
updateTile(tile: TileRef, state: number): void {
/**
* Update a tile from a packed uint32:
* bits 0-15: tile state (owner, fallout, etc.)
* bits 16-23: terrain byte (land, ocean, shoreline, magnitude)
*/
updateTile(tile: TileRef, packed: number): boolean {
const state = packed & 0xffff;
const terrainByte = (packed >>> 16) & 0xff;
const existingFallout = this.hasFallout(tile);
this.state[tile] = state;
const newFallout = this.hasFallout(tile);
@@ -367,6 +419,17 @@ export class GameMapImpl implements GameMap {
if (!existingFallout && newFallout) {
this._numTilesWithFallout++;
}
// Update terrain if the packed value includes a terrain byte that differs
const terrainChanged = this.terrain[tile] !== terrainByte;
if (terrainChanged) {
const wasLand = this.isLand(tile);
this.terrain[tile] = terrainByte;
const isNowLand = Boolean(terrainByte & (1 << GameMapImpl.IS_LAND_BIT));
if (wasLand && !isNowLand) this.numLandTiles_--;
else if (!wasLand && isNowLand) this.numLandTiles_++;
}
return terrainChanged;
}
}
+64 -19
View File
@@ -228,14 +228,10 @@ export class PlayerView {
);
}
const defaultTerritoryColor = this.game
.config()
.theme()
.territoryColor(this);
const defaultBorderColor = this.game
.config()
.theme()
.borderColor(defaultTerritoryColor);
const theme = this.game.config().theme();
const defaultTerritoryColor = theme.territoryColor(this);
const defaultBorderColor = theme.borderColor(defaultTerritoryColor);
const pattern = userSettings.territoryPatterns()
? this.cosmetics.pattern
@@ -258,14 +254,11 @@ export class PlayerView {
this._territoryColor = defaultTerritoryColor;
}
this._structureColors = this.game
.config()
.theme()
.structureColors(this._territoryColor);
this._structureColors = theme.structureColors(this._territoryColor);
const maybeFocusedBorderColor =
this.game.myClientID() === this.data.clientID
? this.game.config().theme().focusedBorderColor()
? theme.focusedBorderColor()
: defaultBorderColor;
this._borderColor = new Colord(
@@ -275,7 +268,6 @@ export class PlayerView {
);
// Pre-compute all border color variants once
const theme = this.game.config().theme();
const baseRgb = this._borderColor.toRgb();
// Neutral is just the base color
@@ -575,7 +567,33 @@ export class PlayerView {
}
transitiveTargets(): PlayerView[] {
return [...this.targets(), ...this.allies().flatMap((p) => p.targets())];
const result: PlayerView[] = [];
// Add own targets
for (const id of this.data.targets) {
result.push(this.game.playerBySmallID(id) as PlayerView);
}
// Add allies' targets
for (const allyID of this.data.allies) {
const ally = this.game.playerBySmallID(allyID) as PlayerView;
for (const targetId of ally.data.targets) {
result.push(this.game.playerBySmallID(targetId) as PlayerView);
}
}
// Add teammates' targets
if (this.data.team !== undefined) {
for (const p of this.game.playerViews()) {
if (p !== this && p.data.team === this.data.team) {
for (const targetId of p.data.targets) {
result.push(this.game.playerBySmallID(targetId) as PlayerView);
}
}
}
}
return result;
}
isTraitor(): boolean {
@@ -630,6 +648,7 @@ export class GameView implements GameMap {
private _players = new Map<PlayerID, PlayerView>();
private _units = new Map<number, UnitView>();
private updatedTiles: TileRef[] = [];
private updatedTerrainTiles: TileRef[] = [];
private _myPlayer: PlayerView | null = null;
@@ -671,7 +690,7 @@ export class GameView implements GameMap {
for (const nation of this._mapData.nations) {
// Nations don't have client ids, so we use their name as the key instead.
this._cosmetics.set(nation.name, {
flag: nation.flag,
flag: nation.flag ? `/flags/${nation.flag}.svg` : undefined,
} satisfies PlayerCosmetics);
}
}
@@ -740,12 +759,16 @@ export class GameView implements GameMap {
this.lastUpdate = gu;
this.updatedTiles = [];
this.updatedTerrainTiles = [];
const packed = this.lastUpdate.packedTileUpdates;
for (let i = 0; i + 1 < packed.length; i += 2) {
const tile = packed[i];
const state = packed[i + 1];
this.updateTile(tile, state);
const terrainChanged = this.updateTile(tile, state);
this.updatedTiles.push(tile);
if (terrainChanged) {
this.updatedTerrainTiles.push(tile);
}
}
if (gu.packedMotionPlans) {
@@ -1060,6 +1083,10 @@ export class GameView implements GameMap {
return this.updatedTiles;
}
recentlyUpdatedTerrainTiles(): TileRef[] {
return this.updatedTerrainTiles;
}
nearbyUnits(
tile: TileRef,
searchRange: number,
@@ -1243,6 +1270,24 @@ export class GameView implements GameMap {
magnitude(ref: TileRef): number {
return this._map.magnitude(ref);
}
terrainByte(ref: TileRef): number {
return this._map.terrainByte(ref);
}
setWater(ref: TileRef): void {
this._map.setWater(ref);
}
setShorelineBit(ref: TileRef): void {
this._map.setShorelineBit(ref);
}
clearShorelineBit(ref: TileRef): void {
this._map.clearShorelineBit(ref);
}
setOcean(ref: TileRef): void {
this._map.setOcean(ref);
}
setMagnitude(ref: TileRef, value: number): void {
this._map.setMagnitude(ref, value);
}
ownerID(ref: TileRef): number {
return this._map.ownerID(ref);
}
@@ -1304,8 +1349,8 @@ export class GameView implements GameMap {
tileState(tile: TileRef): number {
return this._map.tileState(tile);
}
updateTile(tile: TileRef, state: number): void {
this._map.updateTile(tile, state);
updateTile(tile: TileRef, state: number): boolean {
return this._map.updateTile(tile, state);
}
numTilesWithFallout(): number {
return this._map.numTilesWithFallout();
+11 -6
View File
@@ -144,7 +144,7 @@ export class PlayerImpl implements Player {
traitorRemainingTicks: this.getTraitorRemainingTicks(),
targets: this.targets().map((p) => p.smallID()),
outgoingEmojis: this.outgoingEmojis(),
outgoingAttacks: this._outgoingAttacks.map((a) => {
outgoingAttacks: this.outgoingAttacks().map((a) => {
return {
attackerID: a.attacker().smallID(),
targetID: a.target().smallID(),
@@ -153,7 +153,7 @@ export class PlayerImpl implements Player {
retreating: a.retreating(),
} satisfies AttackUpdate;
}),
incomingAttacks: this._incomingAttacks.map((a) => {
incomingAttacks: this.incomingAttacks().map((a) => {
return {
attackerID: a.attacker().smallID(),
targetID: a.target().smallID(),
@@ -1222,7 +1222,7 @@ export class PlayerImpl implements Player {
manhattanDistFN(tile, this.mg.config().radiusPortSpawn()),
),
)
.filter((t) => this.mg.owner(t) === this && this.mg.isOceanShore(t))
.filter((t) => this.mg.owner(t) === this && this.mg.isShore(t))
.sort(
(a, b) =>
this.mg.manhattanDist(a, tile) - this.mg.manhattanDist(b, tile),
@@ -1239,14 +1239,19 @@ export class PlayerImpl implements Player {
}
warshipSpawn(tile: TileRef): TileRef | false {
if (!this.mg.isOcean(tile)) {
if (!this.mg.isWater(tile)) {
return false;
}
const tileComponent = this.mg.getWaterComponent(tile);
const bestPort = findClosestBy(
this.units(UnitType.Port),
(port) => this.mg.manhattanDist(port.tile(), tile),
(port) => port.isActive() && !port.isUnderConstruction(),
(port) =>
port.isActive() &&
!port.isUnderConstruction() &&
tileComponent !== null &&
this.mg.hasWaterComponent(port.tile(), tileComponent),
);
return bestPort?.tile() ?? false;
@@ -1376,7 +1381,7 @@ export class PlayerImpl implements Player {
return this._outgoingAttacks;
}
incomingAttacks(): Attack[] {
return this._incomingAttacks;
return this._incomingAttacks.filter((a) => a.attacker().isAlive());
}
public isImmune(): boolean {
+2 -1
View File
@@ -25,11 +25,12 @@ class TradeStationStopHandler implements TrainStopHandler {
.trainGold(
rel(trainOwner, stationOwner),
trainExecution.tradeStopsVisited(),
trainOwner,
);
// Share revenue with the station owner if it's not the current player
if (trainOwner !== stationOwner) {
stationOwner.addGold(gold, station.tile());
mg.stats().trainExternalTrade(trainOwner, gold);
mg.stats().trainExternalTrade(stationOwner, gold);
}
trainOwner.addGold(gold, station.tile());
mg.stats().trainSelfTrade(trainOwner, gold);
+221 -77
View File
@@ -1,16 +1,63 @@
import { Cosmetics } from "../CosmeticSchemas";
import { PlayerPattern } from "../Schemas";
const PATTERN_KEY = "territoryPattern";
export function getDefaultKeybinds(isMac: boolean): Record<string, string> {
return {
toggleView: "Space",
coordinateGrid: "KeyM",
buildCity: "Digit1",
buildFactory: "Digit2",
buildPort: "Digit3",
buildDefensePost: "Digit4",
buildMissileSilo: "Digit5",
buildSamLauncher: "Digit6",
buildWarship: "Digit7",
buildAtomBomb: "Digit8",
buildHydrogenBomb: "Digit9",
buildMIRV: "Digit0",
attackRatioDown: "KeyT",
attackRatioUp: "KeyY",
boatAttack: "KeyB",
groundAttack: "KeyG",
requestAlliance: "KeyK",
breakAlliance: "KeyL",
swapDirection: "KeyU",
zoomOut: "KeyQ",
zoomIn: "KeyE",
centerCamera: "KeyC",
moveUp: "KeyW",
moveLeft: "KeyA",
moveDown: "KeyS",
moveRight: "KeyD",
modifierKey: isMac ? "MetaLeft" : "ControlLeft",
altKey: "AltLeft",
shiftKey: "ShiftLeft",
resetGfx: "KeyR",
selectAllWarships: "KeyF",
pauseGame: "KeyP",
gameSpeedUp: "Period",
gameSpeedDown: "Comma",
};
}
export const USER_SETTINGS_CHANGED_EVENT = "event:user-settings-changed";
export const PATTERN_KEY = "territoryPattern";
export const FLAG_KEY = "flag";
export const COLOR_KEY = "settings.territoryColor";
export const DARK_MODE_KEY = "settings.darkMode";
export const PERFORMANCE_OVERLAY_KEY = "settings.performanceOverlay";
export const KEYBINDS_KEY = "settings.keybinds";
export class UserSettings {
private emitChange(key: string, value: boolean | number): void {
private static cache = new Map<string, string | null>();
private emitChange(key: string, value: any): void {
try {
const maybeDispatch = (globalThis as any)?.dispatchEvent;
if (typeof maybeDispatch !== "function") return;
(globalThis as any).dispatchEvent(
new CustomEvent("user-settings-changed", {
detail: { key, value },
new CustomEvent(`${USER_SETTINGS_CHANGED_EVENT}:${key}`, {
detail: value,
}),
);
} catch {
@@ -18,147 +65,167 @@ export class UserSettings {
}
}
get(key: string, defaultValue: boolean): boolean {
const value = localStorage.getItem(key);
private getCached(key: string): string | null {
if (!UserSettings.cache.has(key)) {
UserSettings.cache.set(key, localStorage.getItem(key));
}
return UserSettings.cache.get(key) ?? null;
}
private setCached(key: string, value: string, emitChange: boolean = true) {
localStorage.setItem(key, value);
UserSettings.cache.set(key, value);
if (emitChange) {
this.emitChange(key, value);
}
}
public removeCached(key: string, emitChange: boolean = true) {
localStorage.removeItem(key);
UserSettings.cache.set(key, null);
if (emitChange) {
this.emitChange(key, null);
}
}
private getBool(key: string, defaultValue: boolean): boolean {
const value = this.getCached(key);
if (!value) return defaultValue;
if (value === "true") return true;
if (value === "false") return false;
return defaultValue;
}
set(key: string, value: boolean) {
localStorage.setItem(key, value ? "true" : "false");
this.emitChange(key, value);
private setBool(key: string, value: boolean) {
this.setCached(key, value ? "true" : "false");
}
getFloat(key: string, defaultValue: number): number {
const value = localStorage.getItem(key);
private getString(key: string, defaultValue: string = ""): string {
const value = this.getCached(key);
if (value === null) return defaultValue;
return value;
}
private setString(key: string, value: string) {
this.setCached(key, value);
}
private getFloat(key: string, defaultValue: number): number {
const value = this.getCached(key);
if (!value) return defaultValue;
const floatValue = parseFloat(value);
if (isNaN(floatValue)) return defaultValue;
return floatValue;
}
setFloat(key: string, value: number) {
localStorage.setItem(key, value.toString());
this.emitChange(key, value);
private setFloat(key: string, value: number) {
this.setCached(key, value.toString());
}
emojis() {
return this.get("settings.emojis", true);
return this.getBool("settings.emojis", true);
}
performanceOverlay() {
return this.get("settings.performanceOverlay", false);
return this.getBool(PERFORMANCE_OVERLAY_KEY, false);
}
alertFrame() {
return this.get("settings.alertFrame", true);
return this.getBool("settings.alertFrame", true);
}
anonymousNames() {
return this.get("settings.anonymousNames", false);
return this.getBool("settings.anonymousNames", false);
}
lobbyIdVisibility() {
return this.get("settings.lobbyIdVisibility", true);
return this.getBool("settings.lobbyIdVisibility", true);
}
fxLayer() {
return this.get("settings.specialEffects", true);
return this.getBool("settings.specialEffects", true);
}
structureSprites() {
return this.get("settings.structureSprites", true);
return this.getBool("settings.structureSprites", true);
}
darkMode() {
return this.get("settings.darkMode", false);
return this.getBool(DARK_MODE_KEY, false);
}
leftClickOpensMenu() {
return this.get("settings.leftClickOpensMenu", false);
return this.getBool("settings.leftClickOpensMenu", false);
}
territoryPatterns() {
return this.get("settings.territoryPatterns", true);
return this.getBool("settings.territoryPatterns", true);
}
attackingTroopsOverlay() {
return this.get("settings.attackingTroopsOverlay", true);
return this.getBool("settings.attackingTroopsOverlay", true);
}
toggleAttackingTroopsOverlay() {
this.set("settings.attackingTroopsOverlay", !this.attackingTroopsOverlay());
this.setBool(
"settings.attackingTroopsOverlay",
!this.attackingTroopsOverlay(),
);
}
cursorCostLabel() {
const legacy = this.get("settings.ghostPricePill", true);
return this.get("settings.cursorCostLabel", legacy);
}
focusLocked() {
return false;
// TODO: re-enable when performance issues are fixed.
this.get("settings.focusLocked", true);
const legacy = this.getBool("settings.ghostPricePill", true);
return this.getBool("settings.cursorCostLabel", legacy);
}
toggleLeftClickOpenMenu() {
this.set("settings.leftClickOpensMenu", !this.leftClickOpensMenu());
}
toggleFocusLocked() {
this.set("settings.focusLocked", !this.focusLocked());
this.setBool("settings.leftClickOpensMenu", !this.leftClickOpensMenu());
}
toggleEmojis() {
this.set("settings.emojis", !this.emojis());
this.setBool("settings.emojis", !this.emojis());
}
// Performance overlay specifically needs a direct setter for Shift-D
setPerformanceOverlay(value: boolean) {
this.setBool(PERFORMANCE_OVERLAY_KEY, value);
}
togglePerformanceOverlay() {
this.set("settings.performanceOverlay", !this.performanceOverlay());
this.setBool(PERFORMANCE_OVERLAY_KEY, !this.performanceOverlay());
}
toggleAlertFrame() {
this.set("settings.alertFrame", !this.alertFrame());
this.setBool("settings.alertFrame", !this.alertFrame());
}
toggleRandomName() {
this.set("settings.anonymousNames", !this.anonymousNames());
this.setBool("settings.anonymousNames", !this.anonymousNames());
}
toggleLobbyIdVisibility() {
this.set("settings.lobbyIdVisibility", !this.lobbyIdVisibility());
this.setBool("settings.lobbyIdVisibility", !this.lobbyIdVisibility());
}
toggleFxLayer() {
this.set("settings.specialEffects", !this.fxLayer());
this.setBool("settings.specialEffects", !this.fxLayer());
}
toggleStructureSprites() {
this.set("settings.structureSprites", !this.structureSprites());
this.setBool("settings.structureSprites", !this.structureSprites());
}
toggleCursorCostLabel() {
this.set("settings.cursorCostLabel", !this.cursorCostLabel());
this.setBool("settings.cursorCostLabel", !this.cursorCostLabel());
}
toggleTerritoryPatterns() {
this.set("settings.territoryPatterns", !this.territoryPatterns());
this.setBool("settings.territoryPatterns", !this.territoryPatterns());
}
toggleDarkMode() {
this.set("settings.darkMode", !this.darkMode());
if (this.darkMode()) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
this.setBool(DARK_MODE_KEY, !this.darkMode());
}
// For development only. Used for testing patterns, set in the console manually.
@@ -178,7 +245,7 @@ export class UserSettings {
getSelectedPatternName(cosmetics: Cosmetics | null): PlayerPattern | null {
if (cosmetics === null) return null;
let data = localStorage.getItem(PATTERN_KEY) ?? null;
let data = this.getCached(PATTERN_KEY);
if (data === null) return null;
const patternPrefix = "pattern:";
if (data.startsWith(patternPrefix)) {
@@ -196,32 +263,36 @@ export class UserSettings {
setSelectedPatternName(patternName: string | undefined): void {
if (patternName === undefined) {
localStorage.removeItem(PATTERN_KEY);
this.removeCached(PATTERN_KEY);
} else {
localStorage.setItem(PATTERN_KEY, patternName);
this.setCached(PATTERN_KEY, patternName);
}
}
getSelectedColor(): string | undefined {
const data = localStorage.getItem("settings.territoryColor") ?? undefined;
if (data === undefined) return undefined;
return data;
}
setSelectedColor(color: string | undefined): void {
if (color === undefined) {
localStorage.removeItem("settings.territoryColor");
} else {
localStorage.setItem("settings.territoryColor", color);
getFlag(): string | null {
let flag = this.getCached(FLAG_KEY);
if (!flag) return null;
// Migrate bare country codes to country: prefix
if (!flag.startsWith("flag:") && !flag.startsWith("country:")) {
flag = `country:${flag}`;
// Silent migration: don't emit change event for FlagInput
this.setCached(FLAG_KEY, flag, false);
}
}
getFlag(): string | undefined {
const flag = localStorage.getItem("flag");
if (!flag || flag === "xx") return undefined;
return flag;
}
setFlag(flag: string): void {
if (flag === "country:xx") {
this.clearFlag(true);
} else {
this.setCached(FLAG_KEY, flag);
}
}
clearFlag(emitChange: boolean = false): void {
this.removeCached(FLAG_KEY, emitChange);
}
backgroundMusicVolume(): number {
return this.getFloat("settings.backgroundMusicVolume", 0);
}
@@ -230,6 +301,7 @@ export class UserSettings {
this.setFloat("settings.backgroundMusicVolume", volume);
}
// What % attack ratio increments per click/scroll
attackRatioIncrement(): number {
const increment = Math.round(
this.getFloat("settings.attackRatioIncrement", 10),
@@ -238,6 +310,78 @@ export class UserSettings {
return increment;
}
setAttackRatioIncrement(value: number): void {
this.setFloat("settings.attackRatioIncrement", value);
}
// What % attack ratio is set to
attackRatio(): number {
return this.getFloat("settings.attackRatio", 0.2);
}
setAttackRatio(value: number): void {
this.setFloat("settings.attackRatio", value);
}
// In case localStorage was manually edited to be invalid, return an empty object
parsedUserKeybinds(): Record<string, any> {
const raw = this.getString(KEYBINDS_KEY, "{}");
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed;
}
} catch (e) {
console.warn("Invalid keybinds JSON:", e);
}
return {};
}
// Returns a flat keybind map { action: "keyCode" }, handling nested objects and legacy strings
private normalizedUserKeybinds(): Record<string, string> {
const parsed = this.parsedUserKeybinds();
return Object.fromEntries(
Object.entries(parsed)
// Extract value from nested object or plain string, filter out non-string values
.map(([k, v]) => {
let val = v;
if (v && typeof v === "object" && !Array.isArray(v) && "value" in v) {
val = v.value;
}
if (Array.isArray(val) && typeof val[0] === "string") {
val = val[0];
}
return [k, val];
})
.filter(([, v]) => typeof v === "string"),
) as Record<string, string>;
}
keybinds(isMac: boolean): Record<string, string> {
const merged = {
...getDefaultKeybinds(isMac),
...this.normalizedUserKeybinds(),
};
// Actually unbind key: if Unbind is clicked in UserSettingsModal, eg. for Attack Ratio Up,
// keybind is "Null". Even if it is in default kindbinds (Y), it should not work anymore.
// The key (Y) can now be bound to another action like Boat Attack, and no two actions listen to the same key.
for (const k in merged) {
if (merged[k] === "Null") {
delete merged[k];
}
}
return merged;
}
setKeybinds(value: string | Record<string, any>): void {
if (typeof value === "string") {
this.setString(KEYBINDS_KEY, value);
} else {
this.setString(KEYBINDS_KEY, JSON.stringify(value));
}
}
soundEffectsVolume(): number {
return this.getFloat("settings.soundEffectsVolume", 1);
}
+444
View File
@@ -0,0 +1,444 @@
import {
AbstractGraph,
AbstractGraphBuilder,
} from "../pathfinding/algorithms/AbstractGraph";
import { AStarWaterHierarchical } from "../pathfinding/algorithms/AStar.WaterHierarchical";
import { PathFinder } from "../pathfinding/types";
import { GameMap, TileRef } from "./GameMap";
const WATER_GRAPH_REBUILD_INTERVAL = 20;
export class WaterManager {
private _miniWaterGraph: AbstractGraph | null = null;
private _miniWaterHPA: AStarWaterHierarchical | null = null;
private _waterGraphVersion: number = 0;
private _waterGraphDirty: boolean = false;
private _waterGraphLastRebuildTick: number = 0;
private _pendingWaterTiles: Set<TileRef> = new Set();
private _dirtyMiniTiles: Set<TileRef> = new Set();
// Reusable stamp-based distance tracking for magnitude BFS (avoids allocation per nuke)
private _waterDistArr: Uint16Array | null = null;
private _waterStampArr: Uint16Array | null = null;
private _waterStamp: number = 0;
constructor(
private map: GameMap,
private miniMap: GameMap,
private disableNavMesh: boolean,
) {
if (!disableNavMesh) {
const graphBuilder = new AbstractGraphBuilder(miniMap);
this._miniWaterGraph = graphBuilder.build();
this._miniWaterHPA = new AStarWaterHierarchical(
miniMap,
this._miniWaterGraph,
{ cachePaths: true },
);
}
}
queueTile(tile: TileRef): void {
this._pendingWaterTiles.add(tile);
}
/**
* Flush pending water conversions, run terrain fixup (ocean/magnitude/shoreline/minimap),
* and throttled graph rebuild. Returns tiles whose terrain changed (for recording).
*/
tick(currentTick: number): TileRef[] {
const changedTiles: TileRef[] = [];
if (this._pendingWaterTiles.size > 0) {
const converted: TileRef[] = [];
for (const tile of this._pendingWaterTiles) {
// Tile may have been conquered between queueing and flushing
if (this.map.isLand(tile) && !this.map.hasOwner(tile)) {
if (this.map.hasFallout(tile)) {
this.map.setFallout(tile, false);
}
this.map.setWater(tile);
converted.push(tile);
}
}
this._pendingWaterTiles.clear();
if (converted.length > 0) {
this.finalizeWaterChanges(converted, changedTiles);
}
}
// Throttled water graph rebuild: at most once every 20 ticks
if (
this._waterGraphDirty &&
!this.disableNavMesh &&
currentTick - this._waterGraphLastRebuildTick >=
WATER_GRAPH_REBUILD_INTERVAL
) {
this._waterGraphDirty = false;
this._waterGraphLastRebuildTick = currentTick;
const graphBuilder = new AbstractGraphBuilder(
this.miniMap,
AbstractGraphBuilder.CLUSTER_SIZE,
this._miniWaterGraph ?? undefined,
this._dirtyMiniTiles.size > 0 ? this._dirtyMiniTiles : undefined,
);
this._miniWaterGraph = graphBuilder.build();
this._dirtyMiniTiles.clear();
this._miniWaterHPA = new AStarWaterHierarchical(
this.miniMap,
this._miniWaterGraph,
{ cachePaths: true },
);
this._waterGraphVersion++;
}
return changedTiles;
}
waterGraphVersion(): number {
return this._waterGraphVersion;
}
miniWaterHPA(): PathFinder<number> | null {
return this._miniWaterHPA;
}
miniWaterGraph(): AbstractGraph | null {
return this._miniWaterGraph;
}
getWaterComponent(tile: TileRef): number | null {
// Permissive fallback for tests with disableNavMesh
if (!this._miniWaterGraph) return 0;
const miniX = Math.floor(this.map.x(tile) / 2);
const miniY = Math.floor(this.map.y(tile) / 2);
const miniTile = this.miniMap.ref(miniX, miniY);
if (this.miniMap.isWater(miniTile)) {
return this._miniWaterGraph.getComponentId(miniTile);
}
// Shore tile: find water neighbor (expand search for minimap resolution loss)
for (const n of this.miniMap.neighbors(miniTile)) {
if (this.miniMap.isWater(n)) {
return this._miniWaterGraph.getComponentId(n);
}
}
// Extended search: check 2-hop neighbors for narrow straits
for (const n of this.miniMap.neighbors(miniTile)) {
for (const n2 of this.miniMap.neighbors(n)) {
if (this.miniMap.isWater(n2)) {
return this._miniWaterGraph.getComponentId(n2);
}
}
}
return null;
}
hasWaterComponent(tile: TileRef, component: number): boolean {
// Permissive fallback for tests with disableNavMesh
if (!this._miniWaterGraph) return true;
const miniX = Math.floor(this.map.x(tile) / 2);
const miniY = Math.floor(this.map.y(tile) / 2);
const miniTile = this.miniMap.ref(miniX, miniY);
// Check miniTile itself (shore in full map may be water in minimap)
if (
this.miniMap.isWater(miniTile) &&
this._miniWaterGraph.getComponentId(miniTile) === component
) {
return true;
}
// Check neighbors
for (const n of this.miniMap.neighbors(miniTile)) {
if (
this.miniMap.isWater(n) &&
this._miniWaterGraph.getComponentId(n) === component
) {
return true;
}
}
// Extended search: check 2-hop neighbors for narrow straits
for (const n of this.miniMap.neighbors(miniTile)) {
for (const n2 of this.miniMap.neighbors(n)) {
if (
this.miniMap.isWater(n2) &&
this._miniWaterGraph.getComponentId(n2) === component
) {
return true;
}
}
}
return false;
}
private finalizeWaterChanges(
convertedTiles: TileRef[],
changedTiles: TileRef[],
): void {
const converted = new Set<TileRef>(convertedTiles);
if (converted.size === 0) return;
const map = this.map;
const w = map.width();
const totalTiles = w * map.height();
// Track changed tiles in a set for dedup, drain into output at end
const changed = new Set<TileRef>();
// All converted tiles definitely changed (they just became water).
for (const tile of converted) changed.add(tile);
// Inline neighbor helper (no allocation, cardinal only)
const pushNeighbors = (
tile: TileRef,
out: TileRef[],
start: number,
): number => {
if (tile >= w) out[start++] = (tile - w) as TileRef;
if (tile < totalTiles - w) out[start++] = (tile + w) as TileRef;
const x = tile % w;
if (x > 0) out[start++] = (tile - 1) as TileRef;
if (x < w - 1) out[start++] = (tile + 1) as TileRef;
return start;
};
// Reusable scratch buffer for neighbors.
const nb: TileRef[] = new Array(8);
// ── 1. Propagate ocean bit ─────────────────────────────────────
const oceanQueue: TileRef[] = [];
for (const tile of converted) {
const end = pushNeighbors(tile, nb, 0);
for (let i = 0; i < end; i++) {
if (!converted.has(nb[i]) && map.isOcean(nb[i])) {
map.setOcean(tile);
oceanQueue.push(tile);
break;
}
}
}
let oHead = 0;
while (oHead < oceanQueue.length) {
const tile = oceanQueue[oHead++];
const end = pushNeighbors(tile, nb, 0);
for (let i = 0; i < end; i++) {
if (map.isWater(nb[i]) && !map.isOcean(nb[i])) {
map.setOcean(nb[i]);
changed.add(nb[i]);
oceanQueue.push(nb[i]);
}
}
}
// ── 2. Recompute magnitude via BFS from remaining land outward ─
if (!this._waterDistArr || this._waterDistArr.length !== totalTiles) {
this._waterDistArr = new Uint16Array(totalTiles);
this._waterStampArr = new Uint16Array(totalTiles);
this._waterStamp = 0;
}
this._waterStamp++;
if (this._waterStamp >= 0xffff) {
this._waterStampArr!.fill(0);
this._waterStamp = 1;
}
const stamp = this._waterStamp;
const stampArr = this._waterStampArr!;
const distArr = this._waterDistArr;
const magQueue: TileRef[] = [];
const h = map.height();
// Magnitude BFS: recompute ceil(manhattan_dist_to_nearest_coast / 2)
// for tiles affected by the nuke.
//
// Dirty box (±MAX_MAG_DIST from crater bounds): the region where
// magnitudes may have changed. Only tiles here get updated.
//
// Seed box (±2*MAX_MAG_DIST from crater bounds): coastlines here are
// seeded for BFS. This ensures that every coastline that could be
// nearest to a dirty-box tile is included (a dirty-box tile is at most
// MAX_MAG_DIST from the crater, and the nearest coast is at most
// MAX_MAG_DIST from the tile, so the coast is at most 2*MAX_MAG_DIST
// from the crater).
//
// The BFS runs WITHOUT convergence inside the seed box so that
// wavefronts from distant coastlines correctly reach the dirty box.
// BFS is clipped at the seed box boundary for performance.
const MAX_MAG_DIST = 62; // magnitude 31 ≈ 62 tile hops from coast
let cMinX = w,
cMaxX = 0,
cMinY = h,
cMaxY = 0;
for (const tile of converted) {
const tx = tile % w;
const ty = (tile - tx) / w;
if (tx < cMinX) cMinX = tx;
if (tx > cMaxX) cMaxX = tx;
if (ty < cMinY) cMinY = ty;
if (ty > cMaxY) cMaxY = ty;
}
// Dirty box: tiles whose magnitude may need updating.
const dMinX = Math.max(0, cMinX - MAX_MAG_DIST);
const dMaxX = Math.min(w - 1, cMaxX + MAX_MAG_DIST);
const dMinY = Math.max(0, cMinY - MAX_MAG_DIST);
const dMaxY = Math.min(h - 1, cMaxY + MAX_MAG_DIST);
// Seed box: coastlines here are seeded; BFS is clipped here.
const sMinX = Math.max(0, cMinX - MAX_MAG_DIST * 2);
const sMaxX = Math.min(w - 1, cMaxX + MAX_MAG_DIST * 2);
const sMinY = Math.max(0, cMinY - MAX_MAG_DIST * 2);
const sMaxY = Math.min(h - 1, cMaxY + MAX_MAG_DIST * 2);
// Seed from coastline water tiles inside the seed box.
for (let by = sMinY; by <= sMaxY; by++) {
const rowStart = by * w;
for (let bx = sMinX; bx <= sMaxX; bx++) {
const tile = (rowStart + bx) as TileRef;
if (!map.isWater(tile) || stampArr[tile] === stamp) continue;
const end = pushNeighbors(tile, nb, 0);
for (let i = 0; i < end; i++) {
if (map.isLand(nb[i])) {
stampArr[tile] = stamp;
distArr[tile] = 0;
magQueue.push(tile);
break;
}
}
}
}
// BFS outward through water, clipped to seed box.
// No convergence — every reachable tile inside the seed box is visited
// to ensure correct shortest distances reach the dirty box.
// Only DIRTY BOX tiles get their magnitude updated.
let magHead = 0;
while (magHead < magQueue.length) {
const tile = magQueue[magHead++];
const dist = distArr[tile];
const nextDist = dist + 1;
const end = pushNeighbors(tile, nb, 0);
for (let i = 0; i < end; i++) {
const n = nb[i];
if (!map.isWater(n) || stampArr[n] === stamp) continue;
// Clip to seed box
const nx = n % w;
const ny = (n - nx) / w;
if (nx < sMinX || nx > sMaxX || ny < sMinY || ny > sMaxY) continue;
stampArr[n] = stamp;
distArr[n] = nextDist;
magQueue.push(n);
}
}
// Update magnitudes only for dirty-box tiles.
for (let dy = dMinY; dy <= dMaxY; dy++) {
const rowStart = dy * w;
for (let dx = dMinX; dx <= dMaxX; dx++) {
const tile = (rowStart + dx) as TileRef;
if (!map.isWater(tile)) continue;
const oldMag = map.magnitude(tile);
let newMag: number;
if (stampArr[tile] === stamp) {
// Reached by BFS — compute magnitude from distance
newMag = Math.min(Math.ceil(distArr[tile] / 2), 31);
} else {
// Unreached: nearest coast is >MAX_MAG_DIST away → magnitude 31
newMag = 31;
}
if (oldMag !== newMag) {
map.setMagnitude(tile, newMag);
changed.add(tile);
}
}
}
// ── 3. Fix shoreline bits ──────────────────────────────────────
// Only converted tiles changed terrain type (land→water), so only
// they and their 2-ring neighborhood can have shoreline bit changes.
const tilesToCheck = new Set<TileRef>();
for (const tile of converted) {
tilesToCheck.add(tile);
const end = pushNeighbors(tile, nb, 0);
for (let i = 0; i < end; i++) {
tilesToCheck.add(nb[i]);
const end2 = pushNeighbors(nb[i], nb, end);
for (let j = end; j < end2; j++) {
tilesToCheck.add(nb[j]);
}
}
}
for (const tile of tilesToCheck) {
const tileIsLand = map.isLand(tile);
let hasOpposite = false;
const end = pushNeighbors(tile, nb, 0);
for (let i = 0; i < end; i++) {
if (map.isLand(nb[i]) !== tileIsLand) {
hasOpposite = true;
break;
}
}
const oldShoreline = map.isShoreline(tile);
if (hasOpposite) {
if (!oldShoreline) {
map.setShorelineBit(tile);
changed.add(tile);
}
} else {
if (oldShoreline) {
map.clearShorelineBit(tile);
changed.add(tile);
}
}
}
// ── 4. Update minimap terrain ──────────────────────────────────
const miniTilesToCheck = new Set<TileRef>();
const convertedMiniTiles = new Set<TileRef>();
for (const tile of converted) {
const miniX = Math.floor(map.x(tile) / 2);
const miniY = Math.floor(map.y(tile) / 2);
if (this.miniMap.isValidCoord(miniX, miniY)) {
miniTilesToCheck.add(this.miniMap.ref(miniX, miniY));
}
}
for (const miniTile of miniTilesToCheck) {
if (!this.miniMap.isLand(miniTile)) continue;
const fx = this.miniMap.x(miniTile) * 2;
const fy = this.miniMap.y(miniTile) * 2;
let waterCount = 0;
let totalCount = 0;
for (let dy = 0; dy < 2; dy++) {
for (let dx = 0; dx < 2; dx++) {
if (map.isValidCoord(fx + dx, fy + dy)) {
totalCount++;
if (map.isWater(map.ref(fx + dx, fy + dy))) {
waterCount++;
}
}
}
}
if (waterCount >= Math.min(3, totalCount)) {
this.miniMap.setWater(miniTile);
convertedMiniTiles.add(miniTile);
}
}
// ── 5. Mark water graph dirty (rebuilt lazily, throttled) ─────
if (convertedMiniTiles.size > 0) {
this._waterGraphDirty = true;
for (const mt of convertedMiniTiles) {
this._dirtyMiniTiles.add(mt);
}
}
// Drain changed set into output array
for (const tile of changed) {
changedTiles.push(tile);
}
}
}
+76 -1
View File
@@ -15,7 +15,7 @@ import { ComponentCheckTransformer } from "./transformers/ComponentCheckTransfor
import { MiniMapTransformer } from "./transformers/MiniMapTransformer";
import { ShoreCoercingTransformer } from "./transformers/ShoreCoercingTransformer";
import { SmoothingWaterTransformer } from "./transformers/SmoothingWaterTransformer";
import { PathStatus, SteppingPathFinder } from "./types";
import { PathResult, PathStatus, SteppingPathFinder } from "./types";
/**
* Pathfinders that work with GameMap - usable in both simulation and UI layers
@@ -89,6 +89,81 @@ export class PathFinding {
}
}
/**
* Water pathfinder that auto-rebuilds when the water graph changes.
* Wraps SteppingPathFinder and tracks waterGraphVersion internally.
*/
export class WaterPathFinder implements SteppingPathFinder<TileRef> {
private inner: SteppingPathFinder<TileRef>;
private _waterGraphVersion: number;
private _rebuilt = false;
// Stagger support: spread pathfinder rebuilds over multiple ticks so all
// ships don't re-run A* simultaneously after a water-nuke.
private _staggerCountdown: number;
private _pendingVersion: number = -1;
/**
* @param stagger - How many ticks to wait before rebuilding when the water
* graph changes. 0 = immediate (default). Pass a value spread across
* [0, STAGGER_SPREAD) to distribute rebuilds over time.
*/
constructor(
private game: Game,
private _stagger: number = 0,
) {
this.inner = PathFinding.Water(game);
this._waterGraphVersion = game.waterGraphVersion();
this._staggerCountdown = 0;
}
/** Spread to use when auto-staggering ship pathfinders */
static readonly STAGGER_SPREAD = 50;
/** True if the pathfinder was rebuilt since the last call to `rebuilt`. Resets on read. */
get rebuilt(): boolean {
this.ensureFresh();
const v = this._rebuilt;
this._rebuilt = false;
return v;
}
private ensureFresh(): void {
const v = this.game.waterGraphVersion();
if (v === this._waterGraphVersion) return;
// New graph version detected — start or continue the stagger countdown.
if (this._pendingVersion !== v) {
this._pendingVersion = v;
this._staggerCountdown = this._stagger;
}
if (this._staggerCountdown > 0) {
this._staggerCountdown--;
return; // Keep using old pathfinder for now
}
// Countdown complete — rebuild.
this._waterGraphVersion = v;
this.inner = PathFinding.Water(this.game);
this._rebuilt = true;
}
next(from: TileRef, to: TileRef, dist?: number): PathResult<TileRef> {
this.ensureFresh();
return this.inner.next(from, to, dist);
}
findPath(from: TileRef | TileRef[], to: TileRef): TileRef[] | null {
this.ensureFresh();
return this.inner.findPath(from, to);
}
invalidate(): void {
this.inner.invalidate();
}
}
function tileStepperConfig(game: Game): StepperConfig<TileRef> {
return {
equals: (a, b) => a === b,
@@ -234,12 +234,15 @@ export class AbstractGraphAStar implements PathFinder<number> {
return null;
}
private buildPathFromGoal(goalId: number): number[] {
private buildPathFromGoal(goalId: number): number[] | null {
const path: number[] = [];
let current = goalId;
const maxLen = this.cameFrom.length;
while (current !== -1) {
if (current < 0 || current >= maxLen) return null;
path.push(current);
if (path.length > maxLen) return null;
current = this.cameFrom[current];
}
@@ -68,7 +68,12 @@ export class AbstractGraph {
getNodeEdges(nodeId: number): AbstractEdge[] {
const edgeIds = this._nodeEdgeIds[nodeId];
if (!edgeIds) return [];
return edgeIds.map((id) => this._edges[id]);
const edges: AbstractEdge[] = [];
for (let i = 0; i < edgeIds.length; i++) {
const e = this._edges[edgeIds[i]];
if (e) edges.push(e);
}
return edges;
}
getEdgeBetween(nodeA: number, nodeB: number): AbstractEdge | undefined {
@@ -203,7 +208,7 @@ export class AbstractGraphBuilder {
private readonly clustersX: number;
private readonly clustersY: number;
private readonly tileBFS: BFSGrid;
private readonly waterComponents: ConnectedComponents;
private waterComponents: ConnectedComponents;
// Build state
private graph!: AbstractGraph;
@@ -212,9 +217,18 @@ export class AbstractGraphBuilder {
private nextEdgeId = 0;
private edgeBetween = new Map<number, Map<number, AbstractEdge>>();
// Partial rebuild state
private cleanClusters: Set<number> | null = null;
private oldEdgeCosts: Map<
number,
Map<number, { cost: number; clusterX: number; clusterY: number }>
> | null = null;
constructor(
private readonly map: GameMap,
private readonly clusterSize: number = AbstractGraphBuilder.CLUSTER_SIZE,
private readonly oldGraph?: AbstractGraph,
private readonly dirtyMiniTiles?: Set<TileRef>,
) {
this.width = map.width();
this.height = map.height();
@@ -236,6 +250,11 @@ export class AbstractGraphBuilder {
// Initialize water components
this.waterComponents.initialize();
// Compute partial rebuild info (which clusters can skip BFS)
if (this.oldGraph && this.dirtyMiniTiles && this.dirtyMiniTiles.size > 0) {
this.computePartialRebuildInfo();
}
// Pre-create all clusters
for (let cy = 0; cy < this.clustersY; cy++) {
for (let cx = 0; cx < this.clustersX; cx++) {
@@ -416,6 +435,14 @@ export class AbstractGraphBuilder {
}
private buildClusterConnections(cx: number, cy: number): void {
const clusterKey = cy * this.clustersX + cx;
// For clean clusters, copy edge costs from old graph instead of BFS
if (this.cleanClusters?.has(clusterKey)) {
this.buildClusterConnectionsFromCache(cx, cy);
return;
}
const cluster = this.graph.getCluster(cx, cy);
if (!cluster) return;
@@ -577,4 +604,114 @@ export class AbstractGraphBuilder {
return reachable;
}
/**
* Compute which clusters are "clean" (unaffected by water changes) and
* build a lookup of old edge costs by tile-pair for fast edge recreation.
*/
private computePartialRebuildInfo(): void {
const dirtyMiniTiles = this.dirtyMiniTiles!;
const oldGraph = this.oldGraph!;
// Map dirty minimap tiles to their cluster indices
const primaryDirty = new Set<number>();
for (const tile of dirtyMiniTiles) {
const x = this.map.x(tile);
const y = this.map.y(tile);
const cx = Math.floor(x / this.clusterSize);
const cy = Math.floor(y / this.clusterSize);
primaryDirty.add(cy * this.clustersX + cx);
}
// Expand by 1-ring neighbors (gateway nodes sit on cluster boundaries
// and belong to both adjacent clusters)
const expandedDirty = new Set<number>();
for (const key of primaryDirty) {
const cy = Math.floor(key / this.clustersX);
const cx = key - cy * this.clustersX;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const nx = cx + dx;
const ny = cy + dy;
if (
nx >= 0 &&
nx < this.clustersX &&
ny >= 0 &&
ny < this.clustersY
) {
expandedDirty.add(ny * this.clustersX + nx);
}
}
}
}
// Everything not in the expanded dirty set is clean
this.cleanClusters = new Set();
const totalClusters = this.clustersX * this.clustersY;
for (let k = 0; k < totalClusters; k++) {
if (!expandedDirty.has(k)) this.cleanClusters.add(k);
}
// Build old edge cost lookup: (minTile, maxTile) → cost
this.oldEdgeCosts = new Map();
for (const edge of oldGraph.getAllEdges()) {
const nodeA = oldGraph.getNode(edge.nodeA);
const nodeB = oldGraph.getNode(edge.nodeB);
if (!nodeA || !nodeB) continue;
const tileMin = Math.min(nodeA.tile, nodeB.tile);
const tileMax = Math.max(nodeA.tile, nodeB.tile);
let inner = this.oldEdgeCosts.get(tileMin);
if (!inner) {
inner = new Map();
this.oldEdgeCosts.set(tileMin, inner);
}
const existing = inner.get(tileMax);
if (existing === undefined || edge.cost < existing.cost) {
inner.set(tileMax, {
cost: edge.cost,
clusterX: edge.clusterX,
clusterY: edge.clusterY,
});
}
}
}
/**
* For clean clusters: recreate edges by looking up costs from the old graph
* instead of running expensive BFS. The gateway nodes are at the same positions
* and the intra-cluster water topology hasn't changed.
*/
private buildClusterConnectionsFromCache(cx: number, cy: number): void {
const cluster = this.graph.getCluster(cx, cy);
if (!cluster) return;
const nodeIds = cluster.nodeIds;
const nodes = nodeIds.map((id) => this.graph.getNode(id)!);
const oldEdgeCosts = this.oldEdgeCosts!;
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
// Skip nodes in different water components
if (nodes[i].componentId !== nodes[j].componentId) continue;
const tileMin = Math.min(nodes[i].tile, nodes[j].tile);
const tileMax = Math.max(nodes[i].tile, nodes[j].tile);
const entry = oldEdgeCosts.get(tileMin)?.get(tileMax);
if (entry !== undefined) {
// Preserve the ORIGINAL (clusterX, clusterY) from the old graph.
// The path for a boundary edge between two clusters lives in whichever
// cluster's BFS originally found it; attributing it to `cx,cy` here
// would break query-time single-cluster bounded A*.
this.addOrUpdateEdge(
nodes[i].id,
nodes[j].id,
entry.cost,
entry.clusterX,
entry.clusterY,
);
}
}
}
}
}
@@ -194,10 +194,6 @@ export class ConnectedComponents {
}
}
/**
* Get the component ID for a tile.
* Returns 0 for land tiles or if not initialized.
*/
getComponentId(tile: TileRef): number {
if (!this.componentIds) return 0;
return this.componentIds[tile] ?? 0;
+3
View File
@@ -60,6 +60,9 @@ async function drain(): Promise<void> {
const batch: GameUpdateViewData[] = [];
const onTickUpdate = (gu: GameUpdateViewData | ErrorUpdate) => {
if (!("updates" in gu)) {
if ("errMsg" in gu) {
sendMessage({ type: "game_error", error: gu } as WorkerMessage);
}
return;
}
batch.push(gu);
+5
View File
@@ -53,6 +53,11 @@ export class WorkerClient {
}
}
break;
case "game_error":
if (this.gameUpdateCallback && message.error) {
this.gameUpdateCallback(message.error);
}
break;
case "initialized":
default:
+8 -1
View File
@@ -7,7 +7,7 @@ import {
PlayerProfile,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { GameUpdateViewData } from "../game/GameUpdates";
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
import { ClientID, GameStartInfo, Turn } from "../Schemas";
export type WorkerMessageType =
@@ -16,6 +16,7 @@ export type WorkerMessageType =
| "turn"
| "game_update"
| "game_update_batch"
| "game_error"
| "player_actions"
| "player_actions_result"
| "player_buildables"
@@ -62,6 +63,11 @@ export interface GameUpdateBatchMessage extends BaseWorkerMessage {
gameUpdates: GameUpdateViewData[];
}
export interface GameErrorMessage extends BaseWorkerMessage {
type: "game_error";
error: ErrorUpdate;
}
export interface PlayerActionsMessage extends BaseWorkerMessage {
type: "player_actions";
playerID: PlayerID;
@@ -147,6 +153,7 @@ export type WorkerMessage =
| InitializedMessage
| GameUpdateMessage
| GameUpdateBatchMessage
| GameErrorMessage
| PlayerActionsResultMessage
| PlayerBuildablesResultMessage
| PlayerProfileResultMessage