import { Colord, colord } from "colord"; import { base64url } from "jose"; import { Config } from "../configuration/Config"; import { ColorPalette } from "../CosmeticSchemas"; import { PatternDecoder } from "../PatternDecoder"; import { ClientID, GameID, Player, PlayerCosmetics } from "../Schemas"; import { createRandomName } from "../Util"; import { WorkerClient } from "../worker/WorkerClient"; import { Cell, EmojiMessage, GameUpdates, Gold, NameViewData, PlayerActions, PlayerBorderTiles, PlayerID, PlayerProfile, PlayerType, Team, TerrainType, TerraNullius, Tick, TrainType, UnitInfo, UnitType, } from "./Game"; import { GameMap, TileRef, TileUpdate } from "./GameMap"; import { AllianceView, AttackUpdate, GameUpdateType, GameUpdateViewData, PlayerUpdate, UnitUpdate, } from "./GameUpdates"; import { TerrainMapData } from "./TerrainMapLoader"; import { TerraNulliusImpl } from "./TerraNulliusImpl"; import { UnitGrid, UnitPredicate } from "./UnitGrid"; import { UserSettings } from "./UserSettings"; const userSettings: UserSettings = new UserSettings(); const FRIENDLY_TINT_TARGET = { r: 0, g: 255, b: 0, a: 1 }; const EMBARGO_TINT_TARGET = { r: 255, g: 0, b: 0, a: 1 }; const BORDER_TINT_RATIO = 0.35; export class UnitView { public _wasUpdated = true; public lastPos: TileRef[] = []; private _createdAt: Tick; constructor( private gameView: GameView, private data: UnitUpdate, ) { this.lastPos.push(data.pos); this._createdAt = this.gameView.ticks(); } createdAt(): Tick { return this._createdAt; } wasUpdated(): boolean { return this._wasUpdated; } lastTiles(): TileRef[] { return this.lastPos; } lastTile(): TileRef { if (this.lastPos.length === 0) { return this.data.pos; } return this.lastPos[0]; } update(data: UnitUpdate) { this.lastPos.push(data.pos); this._wasUpdated = true; this.data = data; } id(): number { return this.data.id; } targetable(): boolean { return this.data.targetable; } markedForDeletion(): number | false { return this.data.markedForDeletion; } type(): UnitType { return this.data.unitType; } troops(): number { return this.data.troops; } retreating(): boolean { if (this.type() !== UnitType.TransportShip) { throw Error("Must be a transport ship"); } return this.data.retreating; } tile(): TileRef { return this.data.pos; } owner(): PlayerView { return this.gameView.playerBySmallID(this.data.ownerID)! as PlayerView; } isActive(): boolean { return this.data.isActive; } reachedTarget(): boolean { return this.data.reachedTarget; } hasHealth(): boolean { return this.data.health !== undefined; } health(): number { return this.data.health ?? 0; } isUnderConstruction(): boolean { return this.data.underConstruction === true; } targetUnitId(): number | undefined { return this.data.targetUnitId; } targetTile(): TileRef | undefined { return this.data.targetTile; } // How "ready" this unit is from 0 to 1. missileReadinesss(): number { const maxMissiles = this.data.level; const missilesReloading = this.data.missileTimerQueue.length; if (missilesReloading === 0) { return 1; } const missilesReady = maxMissiles - missilesReloading; if (missilesReady === 0 && maxMissiles > 1) { // Unless we have just one missile (level 1), // show 0% readiness so user knows no missiles are ready. return 0; } let readiness = missilesReady / maxMissiles; const cooldownDuration = this.data.unitType === UnitType.SAMLauncher ? this.gameView.config().SAMCooldown() : this.gameView.config().SiloCooldown(); for (const cooldown of this.data.missileTimerQueue) { const cooldownProgress = this.gameView.ticks() - cooldown; const cooldownRatio = cooldownProgress / cooldownDuration; const adjusted = cooldownRatio / maxMissiles; readiness += adjusted; } return readiness; } level(): number { return this.data.level; } hasTrainStation(): boolean { return this.data.hasTrainStation; } trainType(): TrainType | undefined { return this.data.trainType; } isLoaded(): boolean | undefined { return this.data.loaded; } } export class PlayerView { public anonymousName: string | null = null; private decoder?: PatternDecoder; private _territoryColor: Colord; private _borderColor: Colord; // Update here to include structure light and dark colors private _structureColors: { light: Colord; dark: Colord }; // Pre-computed border color variants private _borderColorNeutral: Colord; private _borderColorFriendly: Colord; private _borderColorEmbargo: Colord; private _borderColorDefendedNeutral: { light: Colord; dark: Colord }; private _borderColorDefendedFriendly: { light: Colord; dark: Colord }; private _borderColorDefendedEmbargo: { light: Colord; dark: Colord }; constructor( private game: GameView, public data: PlayerUpdate, public nameData: NameViewData, public cosmetics: PlayerCosmetics, ) { if (data.clientID === game.myClientID()) { this.anonymousName = this.data.name; } else { this.anonymousName = createRandomName( this.data.name, this.data.playerType, ); } const defaultTerritoryColor = this.game .config() .theme() .territoryColor(this); const defaultBorderColor = this.game .config() .theme() .borderColor(defaultTerritoryColor); const pattern = userSettings.territoryPatterns() ? this.cosmetics.pattern : undefined; if (pattern) { pattern.colorPalette ??= { name: "", primaryColor: defaultTerritoryColor.toHex(), secondaryColor: defaultBorderColor.toHex(), } satisfies ColorPalette; } if (this.team() === null) { this._territoryColor = colord( this.cosmetics.color?.color ?? pattern?.colorPalette?.primaryColor ?? defaultTerritoryColor.toHex(), ); } else { this._territoryColor = defaultTerritoryColor; } this._structureColors = this.game .config() .theme() .structureColors(this._territoryColor); const maybeFocusedBorderColor = this.game.myClientID() === this.data.clientID ? this.game.config().theme().focusedBorderColor() : defaultBorderColor; this._borderColor = new Colord( pattern?.colorPalette?.secondaryColor ?? this.cosmetics.color?.color ?? maybeFocusedBorderColor.toHex(), ); // Pre-compute all border color variants once const theme = this.game.config().theme(); const baseRgb = this._borderColor.toRgb(); // Neutral is just the base color this._borderColorNeutral = this._borderColor; // Compute friendly tint this._borderColorFriendly = colord({ r: Math.round( baseRgb.r * (1 - BORDER_TINT_RATIO) + FRIENDLY_TINT_TARGET.r * BORDER_TINT_RATIO, ), g: Math.round( baseRgb.g * (1 - BORDER_TINT_RATIO) + FRIENDLY_TINT_TARGET.g * BORDER_TINT_RATIO, ), b: Math.round( baseRgb.b * (1 - BORDER_TINT_RATIO) + FRIENDLY_TINT_TARGET.b * BORDER_TINT_RATIO, ), a: baseRgb.a, }); // Compute embargo tint this._borderColorEmbargo = colord({ r: Math.round( baseRgb.r * (1 - BORDER_TINT_RATIO) + EMBARGO_TINT_TARGET.r * BORDER_TINT_RATIO, ), g: Math.round( baseRgb.g * (1 - BORDER_TINT_RATIO) + EMBARGO_TINT_TARGET.g * BORDER_TINT_RATIO, ), b: Math.round( baseRgb.b * (1 - BORDER_TINT_RATIO) + EMBARGO_TINT_TARGET.b * BORDER_TINT_RATIO, ), a: baseRgb.a, }); // Pre-compute defended variants this._borderColorDefendedNeutral = theme.defendedBorderColors( this._borderColorNeutral, ); this._borderColorDefendedFriendly = theme.defendedBorderColors( this._borderColorFriendly, ); this._borderColorDefendedEmbargo = theme.defendedBorderColors( this._borderColorEmbargo, ); this.decoder = pattern === undefined ? undefined : new PatternDecoder(pattern, base64url.decode); } territoryColor(tile?: TileRef): Colord { if (tile === undefined || this.decoder === undefined) { return this._territoryColor; } const isPrimary = this.decoder.isPrimary( this.game.x(tile), this.game.y(tile), ); return isPrimary ? this._territoryColor : this._borderColor; } structureColors(): { light: Colord; dark: Colord } { return this._structureColors; } /** * Border color for a tile: * - Tints by neighbor relations (embargo → red, friendly → green, else neutral). * - If defended, applies theme checkerboard to the tinted color. */ borderColor(tile?: TileRef, isDefended: boolean = false): Colord { if (tile === undefined) { return this._borderColor; } const { hasEmbargo, hasFriendly } = this.borderRelationFlags(tile); let baseColor: Colord; let defendedColors: { light: Colord; dark: Colord }; if (hasEmbargo) { baseColor = this._borderColorEmbargo; defendedColors = this._borderColorDefendedEmbargo; } else if (hasFriendly) { baseColor = this._borderColorFriendly; defendedColors = this._borderColorDefendedFriendly; } else { baseColor = this._borderColorNeutral; defendedColors = this._borderColorDefendedNeutral; } if (!isDefended) { return baseColor; } const x = this.game.x(tile); const y = this.game.y(tile); const lightTile = (x % 2 === 0 && y % 2 === 0) || (y % 2 === 1 && x % 2 === 1); return lightTile ? defendedColors.light : defendedColors.dark; } /** * Border relation flags for a tile, used by both CPU and WebGL renderers. */ borderRelationFlags(tile: TileRef): { hasEmbargo: boolean; hasFriendly: boolean; } { const mySmallID = this.smallID(); let hasEmbargo = false; let hasFriendly = false; for (const n of this.game.neighbors(tile)) { if (!this.game.hasOwner(n)) { continue; } const otherOwner = this.game.owner(n); if (!otherOwner.isPlayer() || otherOwner.smallID() === mySmallID) { continue; } if (this.hasEmbargo(otherOwner)) { hasEmbargo = true; break; } if (this.isFriendly(otherOwner) || otherOwner.isFriendly(this)) { hasFriendly = true; } } return { hasEmbargo, hasFriendly }; } async actions(tile?: TileRef, units?: UnitType[]): Promise { return this.game.worker.playerInteraction( this.id(), tile && this.game.x(tile), tile && this.game.y(tile), units, ); } async borderTiles(): Promise { return this.game.worker.playerBorderTiles(this.id()); } outgoingAttacks(): AttackUpdate[] { return this.data.outgoingAttacks; } incomingAttacks(): AttackUpdate[] { return this.data.incomingAttacks; } async attackAveragePosition( playerID: number, attackID: string, ): Promise { return this.game.worker.attackAveragePosition(playerID, attackID); } units(...types: UnitType[]): UnitView[] { return this.game .units(...types) .filter((u) => u.owner().smallID() === this.smallID()); } nameLocation(): NameViewData { return this.nameData; } smallID(): number { return this.data.smallID; } name(): string { return this.anonymousName !== null && userSettings.anonymousNames() ? this.anonymousName : this.data.name; } displayName(): string { return this.anonymousName !== null && userSettings.anonymousNames() ? this.anonymousName : this.data.name; } clientID(): ClientID | null { return this.data.clientID; } id(): PlayerID { return this.data.id; } team(): Team | null { return this.data.team ?? null; } type(): PlayerType { return this.data.playerType; } isAlive(): boolean { return this.data.isAlive; } isPlayer(): this is PlayerView { return true; } numTilesOwned(): number { return this.data.tilesOwned; } allies(): PlayerView[] { return this.data.allies.map( (a) => this.game.playerBySmallID(a) as PlayerView, ); } targets(): PlayerView[] { return this.data.targets.map( (id) => this.game.playerBySmallID(id) as PlayerView, ); } gold(): Gold { return this.data.gold; } troops(): number { return this.data.troops; } totalUnitLevels(type: UnitType): number { return this.units(type) .filter((unit) => !unit.isUnderConstruction()) .map((unit) => unit.level()) .reduce((a, b) => a + b, 0); } isMe(): boolean { return this.smallID() === this.game.myPlayer()?.smallID(); } isLobbyCreator(): boolean { return this.data.isLobbyCreator; } isAlliedWith(other: PlayerView): boolean { return this.data.allies.some((n) => other.smallID() === n); } isOnSameTeam(other: PlayerView): boolean { return this.data.team !== undefined && this.data.team === other.data.team; } isFriendly(other: PlayerView): boolean { return this.isAlliedWith(other) || this.isOnSameTeam(other); } isRequestingAllianceWith(other: PlayerView) { return this.data.outgoingAllianceRequests.some((id) => other.id() === id); } alliances(): AllianceView[] { return this.data.alliances; } hasEmbargoAgainst(other: PlayerView): boolean { return this.data.embargoes.has(other.id()); } hasEmbargo(other: PlayerView): boolean { return this.hasEmbargoAgainst(other) || other.hasEmbargoAgainst(this); } profile(): Promise { return this.game.worker.playerProfile(this.smallID()); } bestTransportShipSpawn(targetTile: TileRef): Promise { return this.game.worker.transportShipSpawn(this.id(), targetTile); } transitiveTargets(): PlayerView[] { return [...this.targets(), ...this.allies().flatMap((p) => p.targets())]; } isTraitor(): boolean { return this.data.isTraitor; } getTraitorRemainingTicks(): number { return Math.max(0, this.data.traitorRemainingTicks ?? 0); } outgoingEmojis(): EmojiMessage[] { return this.data.outgoingEmojis; } hasSpawned(): boolean { return this.data.hasSpawned; } isDisconnected(): boolean { return this.data.isDisconnected; } lastDeleteUnitTick(): Tick { return this.data.lastDeleteUnitTick; } deleteUnitCooldown(): number { return ( Math.max( 0, this.game.config().deleteUnitCooldown() - (this.game.ticks() + 1 - this.lastDeleteUnitTick()), ) / 10 ); } } export class GameView implements GameMap { private lastUpdate: GameUpdateViewData | null; private smallIDToID = new Map(); private _players = new Map(); private _units = new Map(); private updatedTiles: TileRef[] = []; private _myPlayer: PlayerView | null = null; private unitGrid: UnitGrid; private toDelete = new Set(); private _cosmetics: Map = new Map(); private _map: GameMap; constructor( public worker: WorkerClient, private _config: Config, private _mapData: TerrainMapData, private _myClientID: ClientID, private _myUsername: string, private _gameID: GameID, private humans: Player[], ) { this._map = this._mapData.gameMap; this.lastUpdate = null; this.unitGrid = new UnitGrid(this._map); // Replace the local player's username with their own stored username. // This way the user does not know they are being censored. for (const h of this.humans) { if (h.clientID === this._myClientID) { h.username = this._myUsername; } } this._cosmetics = new Map( this.humans.map((h) => [h.clientID, h.cosmetics ?? {}]), ); 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, } satisfies PlayerCosmetics); } } isOnEdgeOfMap(ref: TileRef): boolean { return this._map.isOnEdgeOfMap(ref); } public updatesSinceLastTick(): GameUpdates | null { return this.lastUpdate?.updates ?? null; } public isCatchingUp(): boolean { return (this.lastUpdate?.pendingTurns ?? 0) > 1; } public update(gu: GameUpdateViewData) { this.toDelete.forEach((id) => this._units.delete(id)); this.toDelete.clear(); this.lastUpdate = gu; this.updatedTiles = []; this.lastUpdate.packedTileUpdates.forEach((tu) => { this.updatedTiles.push(this.updateTile(tu)); }); if (gu.updates === null) { throw new Error("lastUpdate.updates not initialized"); } gu.updates[GameUpdateType.Player].forEach((pu) => { this.smallIDToID.set(pu.smallID, pu.id); const player = this._players.get(pu.id); if (player !== undefined) { player.data = pu; player.nameData = gu.playerNameViewData[pu.id]; } else { this._players.set( pu.id, new PlayerView( this, pu, gu.playerNameViewData[pu.id], // First check human by clientID, then check nation by name. this._cosmetics.get(pu.clientID ?? "") ?? this._cosmetics.get(pu.name) ?? {}, ), ); } }); this._myPlayer ??= this.playerByClientID(this._myClientID); for (const unit of this._units.values()) { unit._wasUpdated = false; unit.lastPos = unit.lastPos.slice(-1); } gu.updates[GameUpdateType.Unit].forEach((update) => { let unit = this._units.get(update.id); if (unit !== undefined) { unit.update(update); } else { unit = new UnitView(this, update); this._units.set(update.id, unit); this.unitGrid.addUnit(unit); } if (!update.isActive) { this.unitGrid.removeUnit(unit); } else if (unit.tile() !== unit.lastTile()) { this.unitGrid.updateUnitCell(unit); } if (!unit.isActive()) { // Wait until next tick to delete the unit. this.toDelete.add(unit.id()); } }); } recentlyUpdatedTiles(): TileRef[] { return this.updatedTiles; } nearbyUnits( tile: TileRef, searchRange: number, types: UnitType | UnitType[], predicate?: UnitPredicate, ): Array<{ unit: UnitView; distSquared: number }> { return this.unitGrid.nearbyUnits( tile, searchRange, types, predicate, ) as Array<{ unit: UnitView; distSquared: number; }>; } hasUnitNearby( tile: TileRef, searchRange: number, type: UnitType, playerId?: PlayerID, includeUnderConstruction?: boolean, ) { return this.unitGrid.hasUnitNearby( tile, searchRange, type, playerId, includeUnderConstruction, ); } anyUnitNearby( tile: TileRef, searchRange: number, types: readonly UnitType[], predicate: (unit: UnitView) => boolean, playerId?: PlayerID, includeUnderConstruction?: boolean, ): boolean { return this.unitGrid.anyUnitNearby( tile, searchRange, types, predicate, playerId, includeUnderConstruction, ); } myClientID(): ClientID { return this._myClientID; } myPlayer(): PlayerView | null { return this._myPlayer; } player(id: PlayerID): PlayerView { const player = this._players.get(id); if (player === undefined) { throw Error(`player id ${id} not found`); } return player; } players(): PlayerView[] { return Array.from(this._players.values()); } playerBySmallID(id: number): PlayerView | TerraNullius { if (id === 0) { return new TerraNulliusImpl(); } const playerId = this.smallIDToID.get(id); if (playerId === undefined) { throw new Error(`small id ${id} not found`); } return this.player(playerId); } playerByClientID(id: ClientID): PlayerView | null { const player = Array.from(this._players.values()).filter( (p) => p.clientID() === id, )[0] ?? null; if (player === null) { return null; } return player; } hasPlayer(id: PlayerID): boolean { return false; } playerViews(): PlayerView[] { return Array.from(this._players.values()); } owner(tile: TileRef): PlayerView | TerraNullius { return this.playerBySmallID(this.ownerID(tile)); } ticks(): Tick { if (this.lastUpdate === null) return 0; return this.lastUpdate.tick; } inSpawnPhase(): boolean { return this.ticks() <= this._config.numSpawnPhaseTurns(); } isSpawnImmunityActive(): boolean { return ( this._config.numSpawnPhaseTurns() + this._config.spawnImmunityDuration() > this.ticks() ); } config(): Config { return this._config; } units(...types: UnitType[]): UnitView[] { if (types.length === 0) { return Array.from(this._units.values()).filter((u) => u.isActive()); } return Array.from(this._units.values()).filter( (u) => u.isActive() && types.includes(u.type()), ); } unit(id: number): UnitView | undefined { return this._units.get(id); } unitInfo(type: UnitType): UnitInfo { return this._config.unitInfo(type); } ref(x: number, y: number): TileRef { return this._map.ref(x, y); } isValidRef(ref: TileRef): boolean { return this._map.isValidRef(ref); } x(ref: TileRef): number { return this._map.x(ref); } y(ref: TileRef): number { return this._map.y(ref); } cell(ref: TileRef): Cell { return this._map.cell(ref); } width(): number { return this._map.width(); } height(): number { return this._map.height(); } numLandTiles(): number { return this._map.numLandTiles(); } isValidCoord(x: number, y: number): boolean { return this._map.isValidCoord(x, y); } isLand(ref: TileRef): boolean { return this._map.isLand(ref); } isOceanShore(ref: TileRef): boolean { return this._map.isOceanShore(ref); } isOcean(ref: TileRef): boolean { return this._map.isOcean(ref); } isShoreline(ref: TileRef): boolean { return this._map.isShoreline(ref); } magnitude(ref: TileRef): number { return this._map.magnitude(ref); } ownerID(ref: TileRef): number { return this._map.ownerID(ref); } hasOwner(ref: TileRef): boolean { return this._map.hasOwner(ref); } setOwnerID(ref: TileRef, playerId: number): void { return this._map.setOwnerID(ref, playerId); } hasFallout(ref: TileRef): boolean { return this._map.hasFallout(ref); } setFallout(ref: TileRef, value: boolean): void { return this._map.setFallout(ref, value); } isBorder(ref: TileRef): boolean { return this._map.isBorder(ref); } neighbors(ref: TileRef): TileRef[] { return this._map.neighbors(ref); } isWater(ref: TileRef): boolean { return this._map.isWater(ref); } isLake(ref: TileRef): boolean { return this._map.isLake(ref); } isShore(ref: TileRef): boolean { return this._map.isShore(ref); } cost(ref: TileRef): number { return this._map.cost(ref); } terrainType(ref: TileRef): TerrainType { return this._map.terrainType(ref); } forEachTile(fn: (tile: TileRef) => void): void { return this._map.forEachTile(fn); } manhattanDist(c1: TileRef, c2: TileRef): number { return this._map.manhattanDist(c1, c2); } euclideanDistSquared(c1: TileRef, c2: TileRef): number { return this._map.euclideanDistSquared(c1, c2); } circleSearch( tile: TileRef, radius: number, filter?: (tile: TileRef, d2: number) => boolean, ): Set { return this._map.circleSearch(tile, radius, filter); } bfs( tile: TileRef, filter: (gm: GameMap, tile: TileRef) => boolean, ): Set { return this._map.bfs(tile, filter); } toTileUpdate(tile: TileRef): bigint { return this._map.toTileUpdate(tile); } updateTile(tu: TileUpdate): TileRef { return this._map.updateTile(tu); } numTilesWithFallout(): number { return this._map.numTilesWithFallout(); } gameID(): GameID { return this._gameID; } focusedPlayer(): PlayerView | null { return this.myPlayer(); } }