mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-24 10:35:03 +00:00
Merge branch 'main' into player-text-opacity
This commit is contained in:
+39
-15
@@ -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>;
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)) {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -53,6 +53,11 @@ export class WorkerClient {
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "game_error":
|
||||
if (this.gameUpdateCallback && message.error) {
|
||||
this.gameUpdateCallback(message.error);
|
||||
}
|
||||
break;
|
||||
|
||||
case "initialized":
|
||||
default:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user