import DOMPurify from "dompurify"; import { customAlphabet } from "nanoid"; import { Cell, PlayerType, Unit } from "./game/Game"; import { GameMap, TileRef } from "./game/GameMap"; import { GameConfig, GameID, GameRecord, PartialGameRecord, PlayerRecord, Turn, Winner, } from "./Schemas"; import { TRIBE_NAME_PREFIXES, TRIBE_NAME_SUFFIXES, } from "./execution/utils/TribeNames"; export function manhattanDistWrapped( c1: Cell, c2: Cell, width: number, ): number { // Calculate x distance let dx = Math.abs(c1.x - c2.x); // Check if wrapping around the x-axis is shorter dx = Math.min(dx, width - dx); // Calculate y distance (no wrapping for y-axis) const dy = Math.abs(c1.y - c2.y); // Return the sum of x and y distances return dx + dy; } export function within(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } export function distSort( gm: GameMap, target: TileRef, ): (a: TileRef, b: TileRef) => number { return (a: TileRef, b: TileRef) => { return gm.manhattanDist(a, target) - gm.manhattanDist(b, target); }; } export function distSortUnit( gm: GameMap, target: Unit | TileRef, ): (a: Unit, b: Unit) => number { const targetRef = typeof target === "number" ? target : target.tile(); return (a: Unit, b: Unit) => { return ( gm.manhattanDist(a.tile(), targetRef) - gm.manhattanDist(b.tile(), targetRef) ); }; } /** * Finds minimum, by score, with single pass search * Faster than array.reduce() */ export function findMinimumBy( values: readonly T[], score: (value: T) => number, isCandidate?: (value: T) => boolean, ): T | null { let best: T | null = null; let bestScore = Infinity; if (isCandidate === undefined) { for (let i = 0, len = values.length; i < len; i++) { const value = values[i]; const currentScore = score(value); if (currentScore < bestScore) { bestScore = currentScore; best = value; } } return best; } for (let i = 0, len = values.length; i < len; i++) { const value = values[i]; if (!isCandidate(value)) continue; const currentScore = score(value); if (currentScore < bestScore) { bestScore = currentScore; best = value; } } return best; } /** * Finds closest by fast. Example usage: * findClosestBy( * this.units(UnitType.MissileSilo), * (silo) => mg.manhattanDist(silo.tile(), tile), * (silo) => !silo.isInCooldown() && !silo.isUnderConstruction(), * ) */ export function findClosestBy( values: readonly T[], distance: (value: T) => number, isCandidate?: (value: T) => boolean, ): T | null { return findMinimumBy(values, distance, isCandidate); } export function simpleHash(str: string): number { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = (hash << 5) - hash + char; hash = hash & hash; // Convert to 32-bit integer } return Math.abs(hash); } export function calculateBoundingBox( gm: GameMap, borderTiles: ReadonlySet, ): { min: Cell; max: Cell } { let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const tile of borderTiles) { const x = gm.x(tile); const y = gm.y(tile); minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); } return { min: new Cell(minX, minY), max: new Cell(maxX, maxY) }; } export function boundingBoxTiles( gm: GameMap, center: TileRef, radius: number, ): TileRef[] { const tiles: TileRef[] = []; const centerX = gm.x(center); const centerY = gm.y(center); const minX = centerX - radius; const maxX = centerX + radius; const minY = centerY - radius; const maxY = centerY + radius; // Top and bottom edges (full width) for (let x = minX; x <= maxX; x++) { if (gm.isValidCoord(x, minY)) { tiles.push(gm.ref(x, minY)); } if (gm.isValidCoord(x, maxY) && minY !== maxY) { tiles.push(gm.ref(x, maxY)); } } // Left and right edges (exclude corners already added) for (let y = minY + 1; y < maxY; y++) { if (gm.isValidCoord(minX, y)) { tiles.push(gm.ref(minX, y)); } if (gm.isValidCoord(maxX, y) && minX !== maxX) { tiles.push(gm.ref(maxX, y)); } } return tiles; } export function getMode(counts: Map): T | null { let mode: T | null = null; let maxCount = 0; for (const [item, count] of counts) { if (count > maxCount) { maxCount = count; mode = item; } } return mode; } export function calculateBoundingBoxCenter( gm: GameMap, borderTiles: ReadonlySet, ): Cell { const { min, max } = calculateBoundingBox(gm, borderTiles); return boundingBoxCenter({ min, max }); } export function boundingBoxCenter(box: { min: Cell; max: Cell }): Cell { return new Cell( box.min.x + Math.floor((box.max.x - box.min.x) / 2), box.min.y + Math.floor((box.max.y - box.min.y) / 2), ); } export function inscribed( outer: { min: Cell; max: Cell }, inner: { min: Cell; max: Cell }, ): boolean { return ( outer.min.x <= inner.min.x && outer.min.y <= inner.min.y && outer.max.x >= inner.max.x && outer.max.y >= inner.max.y ); } export function sanitize(name: string): string { return Array.from(name) .join("") .replace(/[^\p{L}\p{N}\s\p{Emoji}\p{Emoji_Component}[\]_]/gu, ""); } export function onlyImages(html: string) { return DOMPurify.sanitize(html, { ALLOWED_TAGS: ["span", "img"], ALLOWED_ATTR: ["src", "alt", "class", "style"], ALLOWED_URI_REGEXP: /^https:\/\/cdn\.jsdelivr\.net\/gh\/twitter\/twemoji/, ADD_ATTR: ["style"], }); } export function createPartialGameRecord( gameID: GameID, config: GameConfig, // username does not need to be set. players: PlayerRecord[], allTurns: Turn[], start: number, end: number, 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; const turns = allTurns.filter( (t) => t.intents.length !== 0 || t.hash !== undefined, ); // Use start time as lobby creation time for singleplayer const actualLobbyCreatedAt = lobbyCreatedAt ?? start; const lobbyFillTime = Math.max( 0, start - (visibleAt ?? actualLobbyCreatedAt), ); const record: PartialGameRecord = { info: { gameID, lobbyCreatedAt: actualLobbyCreatedAt, visibleAt, lobbyFillTime, config, players, start, end, duration, num_turns, winner, }, version: "v0.0.2", turns, }; return record; } export function decompressGameRecord(gameRecord: GameRecord) { const turns: Turn[] = []; let lastTurnNum = -1; for (const turn of gameRecord.turns) { while (lastTurnNum < turn.turnNumber - 1) { lastTurnNum++; turns.push({ turnNumber: lastTurnNum, intents: [], }); } turns.push(turn); lastTurnNum = turn.turnNumber; } const turnLength = turns.length; for (let i = turnLength; i < gameRecord.info.num_turns; i++) { turns.push({ turnNumber: i, intents: [], }); } gameRecord.turns = turns; return gameRecord; } export function assertNever(x: never): never { throw new Error("Unexpected value: " + x); } export function generateID(): GameID { const nanoid = customAlphabet( "123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ", 8, ); return nanoid(); } export function toInt(num: number): bigint { if (num === Infinity) { return BigInt(Number.MAX_SAFE_INTEGER); } if (num === -Infinity) { return BigInt(Number.MIN_SAFE_INTEGER); } return BigInt(Math.floor(num)); } export function maxInt(a: bigint, b: bigint): bigint { return a > b ? a : b; } export function minInt(a: bigint, b: bigint): bigint { return a < b ? a : b; } export function withinInt(num: bigint, min: bigint, max: bigint): bigint { const atLeastMin = maxInt(num, min); return minInt(atLeastMin, max); } export function createRandomName( name: string, playerType: PlayerType, ): string | null { let randomName: string | null = null; if (playerType === PlayerType.Human) { const hash = simpleHash(name); const prefixIndex = hash % TRIBE_NAME_PREFIXES.length; const suffixIndex = Math.floor(hash / TRIBE_NAME_PREFIXES.length) % TRIBE_NAME_SUFFIXES.length; randomName = `👤 ${TRIBE_NAME_PREFIXES[prefixIndex]} ${TRIBE_NAME_SUFFIXES[suffixIndex]}`; } return randomName; } export const emojiTable = [ ["😀", "😊", "🥰", "😇", "😎"], ["😞", "🥺", "😭", "😱", "😡"], ["😈", "🤡", "🥱", "🫡", "🖕"], ["👋", "👏", "✋", "🙏", "💪"], ["👍", "👎", "🫴", "🤌", "🤦‍♂️"], ["🤝", "🆘", "🕊️", "🏳️", "⏳"], ["🔥", "💥", "💀", "☢️", "⚠️"], ["↖️", "⬆️", "↗️", "👑", "🥇"], ["⬅️", "🎯", "➡️", "🥈", "🥉"], ["↙️", "⬇️", "↘️", "❤️", "💔"], ["💰", "⚓", "⛵", "🏡", "🛡️"], ["🏭", "🚂", "❓", "🐔", "🐀"], ] as const; // 2d to 1d array export const flattenedEmojiTable = emojiTable.flat(); export type Emoji = (typeof flattenedEmojiTable)[number]; /** * JSON.stringify replacer function that converts bigint values to strings. */ export function replacer(_key: string, value: any): any { return typeof value === "bigint" ? value.toString() : value; } export function sigmoid( value: number, decayRate: number, midpoint: number, ): number { return 1 / (1 + Math.exp(-decayRate * (value - midpoint))); } export function formatPlayerDisplayName( username: string, clanTag?: string | null, ): string { return clanTag ? `[${clanTag}] ${username}` : username; } const CLAN_TAG_CHARS = "a-zA-Z0-9"; const CLAN_TAG_INVALID_CHARS = new RegExp(`[^${CLAN_TAG_CHARS}]`, "g"); export function sanitizeClanTag(tag: string): string { return tag.replace(CLAN_TAG_INVALID_CHARS, "").substring(0, 5).toUpperCase(); }