Merge main into multi-lobby

This commit is contained in:
Scott Anderson
2025-06-28 15:28:52 -04:00
139 changed files with 4775 additions and 432 deletions
+15
View File
@@ -12,6 +12,21 @@ export const CosmeticsSchema = z.object({
role_group: z.string().optional(),
}),
),
flag: z.object({
layers: z.record(
z.string(),
z.object({
name: z.string(),
}),
),
color: z.record(
z.string(),
z.object({
color: z.string(),
name: z.string(),
}),
),
}),
});
export type Cosmetics = z.infer<typeof CosmeticsSchema>;
export const COSMETICS: Cosmetics = CosmeticsSchema.parse(cosmetics_json);
+69
View File
@@ -0,0 +1,69 @@
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,
};
export function renderPlayerFlag(flag: string, target: HTMLElement) {
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 = `/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 -1
View File
@@ -171,7 +171,7 @@ export const FlagSchema = z.string().max(128).optional();
export const RequiredPatternSchema = z
.string()
.max(128)
.base64()
.base64url()
.refine(
(val) => {
try {
+3 -3
View File
@@ -98,10 +98,10 @@ export const PlayerStatsSchema = z
.object({
attacks: AtLeastOneNumberSchema.optional(),
betrayals: BigIntStringSchema.optional(),
boats: z.record(BoatUnitSchema, AtLeastOneNumberSchema).optional(),
bombs: z.record(BombUnitSchema, AtLeastOneNumberSchema).optional(),
boats: z.partialRecord(BoatUnitSchema, AtLeastOneNumberSchema).optional(),
bombs: z.partialRecord(BombUnitSchema, AtLeastOneNumberSchema).optional(),
gold: AtLeastOneNumberSchema.optional(),
units: z.record(OtherUnitSchema, AtLeastOneNumberSchema).optional(),
units: z.partialRecord(OtherUnitSchema, AtLeastOneNumberSchema).optional(),
})
.optional();
export type PlayerStats = z.infer<typeof PlayerStatsSchema>;
+2
View File
@@ -153,6 +153,8 @@ export interface Config {
traitorDuration(): number;
nukeMagnitudes(unitType: UnitType): NukeMagnitude;
defaultNukeSpeed(): number;
defaultNukeTargetableRange(): number;
defaultSamRange(): number;
nukeDeathFactor(humans: number, tilesOwned: number): number;
structureMinDist(): number;
isReplay(): boolean;
+12 -1
View File
@@ -302,7 +302,7 @@ export class DefaultConfig implements Config {
return Math.min(50, Math.round(10 * Math.pow(numberOfPorts, 0.6)));
}
trainSpawnRate(numberOfStations: number): number {
return Math.round(50 * Math.pow(numberOfStations, 0.8));
return Math.min(1400, Math.round(60 * Math.pow(numberOfStations, 0.8)));
}
trainGold(): Gold {
return BigInt(10_000);
@@ -363,6 +363,7 @@ export class DefaultConfig implements Config {
territoryBound: true,
constructionDuration: this.instantBuild() ? 0 : 2 * 10,
upgradable: true,
canBuildTrainStation: true,
};
case UnitType.AtomBomb:
return {
@@ -452,6 +453,7 @@ export class DefaultConfig implements Config {
territoryBound: true,
constructionDuration: this.instantBuild() ? 0 : 2 * 10,
upgradable: true,
canBuildTrainStation: true,
};
case UnitType.Factory:
return {
@@ -466,6 +468,7 @@ export class DefaultConfig implements Config {
),
territoryBound: true,
constructionDuration: this.instantBuild() ? 0 : 2 * 10,
canBuildTrainStation: true,
};
case UnitType.Construction:
return {
@@ -790,6 +793,14 @@ export class DefaultConfig implements Config {
return 6;
}
defaultNukeTargetableRange(): number {
return 120;
}
defaultSamRange(): number {
return 80;
}
// Humans can be population, soldiers attacking, soldiers in boat etc.
nukeDeathFactor(humans: number, tilesOwned: number): number {
return (5 * humans) / Math.max(1, tilesOwned);
+3 -3
View File
@@ -13,8 +13,6 @@ import { ParabolaPathFinder } from "../pathfinding/PathFinding";
import { PseudoRandom } from "../PseudoRandom";
import { NukeType } from "../StatsSchemas";
const NUKE_TARGETABLE_RADIUS = 120;
const SPRITE_RADIUS = 16;
export class NukeExecution implements Execution {
@@ -179,7 +177,9 @@ export class NukeExecution implements Execution {
if (this.nuke === null || this.nuke.targetTile() === undefined) {
return;
}
const targetRangeSquared = NUKE_TARGETABLE_RADIUS * NUKE_TARGETABLE_RADIUS;
const targetRangeSquared =
this.mg.config().defaultNukeTargetableRange() *
this.mg.config().defaultNukeTargetableRange();
const targetTile = this.nuke.targetTile();
this.nuke.setTargetable(
this.mg.euclideanDistSquared(this.nuke.tile(), targetTile!) <
+1 -3
View File
@@ -14,8 +14,6 @@ export class SAMLauncherExecution implements Execution {
private mg: Game;
private active: boolean = true;
private searchRangeRadius = 80;
private targetRangeRadius = 120; // Nuke's target should be in this range to be focusable
// As MIRV go very fast we have to detect them very early but we only
// shoot the one targeting very close (MIRVWarheadProtectionRadius)
private MIRVWarheadSearchRadius = 400;
@@ -41,7 +39,7 @@ export class SAMLauncherExecution implements Execution {
if (this.sam === null) return null;
const nukes = this.mg.nearbyUnits(
this.sam.tile(),
this.searchRangeRadius,
this.mg.config().defaultSamRange(),
[UnitType.AtomBomb, UnitType.HydrogenBomb],
({ unit }) =>
unit.owner() !== this.player &&
+1
View File
@@ -132,6 +132,7 @@ export interface UnitInfo {
damage?: number;
constructionDuration?: number;
upgradable?: boolean;
canBuildTrainStation?: boolean;
}
export enum UnitType {
+7 -1
View File
@@ -684,8 +684,14 @@ export class GameImpl implements Game {
tile: TileRef,
searchRange: number,
types: UnitType | UnitType[],
predicate?: (value: { unit: Unit; distSquared: number }) => boolean,
): Array<{ unit: Unit; distSquared: number }> {
return this.unitGrid.nearbyUnits(tile, searchRange, types) as Array<{
return this.unitGrid.nearbyUnits(
tile,
searchRange,
types,
predicate,
) as Array<{
unit: Unit;
distSquared: number;
}>;
+33
View File
@@ -401,6 +401,39 @@ export function rectDistFN(
}
}
function isInIsometricTile(
center: { x: number; y: number },
tile: { x: number; y: number },
yOffset: number,
distance: number,
): boolean {
const dx = Math.abs(tile.x - center.x);
const dy = Math.abs(tile.y - (center.y + yOffset));
return dx + dy * 2 <= distance + 1;
}
export function isometricDistFN(
root: TileRef,
dist: number,
center: boolean = false,
): (gm: GameMap, tile: TileRef) => boolean {
if (!center) {
return (gm: GameMap, n: TileRef) => gm.manhattanDist(root, n) <= dist;
} else {
return (gm: GameMap, n: TileRef) => {
const rootX = gm.x(root) - 0.5;
const rootY = gm.y(root) - 0.5;
return isInIsometricTile(
{ x: rootX, y: rootY },
{ x: gm.x(n), y: gm.y(n) },
0,
dist,
);
};
}
}
export function hexDistFN(
root: TileRef,
dist: number,
+45 -4
View File
@@ -88,6 +88,8 @@ export function createRailNetwork(game: Game): RailNetwork {
}
export class RailNetworkImpl implements RailNetwork {
private maxConnectionDistance: number = 4;
constructor(
private game: Game,
private stationManager: StationManager,
@@ -142,12 +144,20 @@ export class RailNetworkImpl implements RailNetwork {
const neighborStation = this.stationManager.findStation(neighbor.unit);
if (!neighborStation) continue;
const neighborCluster = neighborStation.getCluster();
if (!neighborCluster || neighborCluster.has(station)) continue;
const distanceToStation = this.distanceFrom(
neighborStation,
station,
this.maxConnectionDistance,
);
const neighborCluster = neighborStation.getCluster();
if (neighborCluster === null) continue;
const connectionAvailable =
distanceToStation > this.maxConnectionDistance ||
distanceToStation === -1;
if (
neighbor.distSquared >
this.game.config().trainStationMinRange() ** 2
connectionAvailable &&
neighbor.distSquared > this.game.config().trainStationMinRange() ** 2
) {
if (this.connect(station, neighborStation)) {
neighborCluster.addStation(station);
@@ -196,6 +206,37 @@ export class RailNetworkImpl implements RailNetwork {
return false;
}
private distanceFrom(
start: TrainStation,
dest: TrainStation,
maxDistance: number,
): number {
if (start === dest) return 0;
const visited = new Set<TrainStation>();
const queue: Array<{ station: TrainStation; distance: number }> = [
{ station: start, distance: 0 },
];
while (queue.length > 0) {
const { station, distance } = queue.shift()!;
if (visited.has(station)) continue;
visited.add(station);
if (distance >= maxDistance) continue;
for (const neighbor of station.neighbors()) {
if (neighbor === dest) return distance + 1;
if (!visited.has(neighbor)) {
queue.push({ station: neighbor, distance: distance + 1 });
}
}
}
// If destination not found within maxDistance
return -1;
}
private computeCluster(start: TrainStation): Set<TrainStation> {
const visited = new Set<TrainStation>();
const queue = [start];
+11 -11
View File
@@ -1,4 +1,4 @@
import { PriorityQueue } from "@datastructures-js/priority-queue";
import FastPriorityQueue from "fastpriorityqueue";
import { AStar, PathFindResultType } from "./AStar";
/**
@@ -12,11 +12,11 @@ export interface GraphAdapter<NodeType> {
}
export class SerialAStar<NodeType> implements AStar<NodeType> {
private fwdOpenSet: PriorityQueue<{
private fwdOpenSet: FastPriorityQueue<{
tile: NodeType;
fScore: number;
}>;
private bwdOpenSet: PriorityQueue<{
private bwdOpenSet: FastPriorityQueue<{
tile: NodeType;
fScore: number;
}>;
@@ -39,15 +39,15 @@ export class SerialAStar<NodeType> implements AStar<NodeType> {
private graph: GraphAdapter<NodeType>,
private directionChangePenalty: number = 0,
) {
this.fwdOpenSet = new PriorityQueue((a, b) => a.fScore - b.fScore);
this.bwdOpenSet = new PriorityQueue((a, b) => a.fScore - b.fScore);
this.fwdOpenSet = new FastPriorityQueue((a, b) => a.fScore < b.fScore);
this.bwdOpenSet = new FastPriorityQueue((a, b) => a.fScore < b.fScore);
this.sources = Array.isArray(src) ? src : [src];
this.closestSource = this.findClosestSource(dst);
// Initialize forward search with source point(s)
this.sources.forEach((startPoint) => {
this.fwdGScore.set(startPoint, 0);
this.fwdOpenSet.enqueue({
this.fwdOpenSet.add({
tile: startPoint,
fScore: this.heuristic(startPoint, dst),
});
@@ -55,7 +55,7 @@ export class SerialAStar<NodeType> implements AStar<NodeType> {
// Initialize backward search from destination
this.bwdGScore.set(dst, 0);
this.bwdOpenSet.enqueue({
this.bwdOpenSet.add({
tile: dst,
fScore: this.heuristic(dst, this.findClosestSource(dst)),
});
@@ -85,7 +85,7 @@ export class SerialAStar<NodeType> implements AStar<NodeType> {
}
// Process forward search
const fwdCurrent = this.fwdOpenSet.dequeue()!.tile;
const fwdCurrent = this.fwdOpenSet.poll()!.tile;
// Check if we've found a meeting point
if (this.bwdGScore.has(fwdCurrent)) {
@@ -96,7 +96,7 @@ export class SerialAStar<NodeType> implements AStar<NodeType> {
this.expandNode(fwdCurrent, true);
// Process backward search
const bwdCurrent = this.bwdOpenSet.dequeue()!.tile;
const bwdCurrent = this.bwdOpenSet.poll()!.tile;
// Check if we've found a meeting point
if (this.fwdGScore.has(bwdCurrent)) {
@@ -145,7 +145,7 @@ export class SerialAStar<NodeType> implements AStar<NodeType> {
const fScore =
totalG +
this.heuristic(neighbor, isForward ? this.dst : this.closestSource);
openSet.enqueue({ tile: neighbor, fScore: fScore });
openSet.add({ tile: neighbor, fScore: fScore });
}
}
}
@@ -153,7 +153,7 @@ export class SerialAStar<NodeType> implements AStar<NodeType> {
private heuristic(a: NodeType, b: NodeType): number {
const posA = this.graph.position(a);
const posB = this.graph.position(b);
return 1.1 * (Math.abs(posA.x - posB.x) + Math.abs(posA.y - posB.y));
return 2 * (Math.abs(posA.x - posB.x) + Math.abs(posA.y - posB.y));
}
private getDirection(from: NodeType, to: NodeType): string {