From b57a409b8a5c2a344246d471f82fbc4a3637abef Mon Sep 17 00:00:00 2001 From: evanpelle Date: Sat, 16 Aug 2025 18:08:16 -0700 Subject: [PATCH] store & reference pattern by name (#1766) ## Description: Store pattern by name instead of value. The worker replaces the pattern name with it's base64 when joining. This ensures the client & server are never out of sync after patterns are updated. * removed resizeObserver on the territory modal, it was causing some race conditions, and the modal is not resizable so it's unnecessary. * Moved PatternSchema to CosmeticSchema ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I have read and accepted the CLA agreement (only required once). ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- src/client/ClientGameRunner.ts | 2 +- src/client/Cosmetics.ts | 12 ++-- src/client/Main.ts | 9 +-- src/client/SinglePlayerModal.ts | 9 ++- src/client/TerritoryPatternsModal.ts | 102 +++++++++++++-------------- src/client/Transport.ts | 7 +- src/core/CosmeticSchemas.ts | 47 +++++++++--- src/core/Schemas.ts | 34 +-------- src/core/game/GameView.ts | 4 +- src/core/game/UserSettings.ts | 4 +- src/server/Privilege.ts | 58 +++++++-------- src/server/Worker.ts | 31 +++++--- 12 files changed, 160 insertions(+), 159 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index b0c3150d8..c6f17706b 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -47,7 +47,7 @@ import { createRenderer, GameRenderer } from "./graphics/GameRenderer"; export interface LobbyConfig { serverConfig: ServerConfig; - pattern: string | undefined; + patternName: string | undefined; flag: string; playerName: string; clientID: ClientID; diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts index bfd991c4a..f388e24e2 100644 --- a/src/client/Cosmetics.ts +++ b/src/client/Cosmetics.ts @@ -4,14 +4,14 @@ import { getApiBase, getAuthHeader } from "./jwt"; export async function patterns( userMe: UserMeResponse | null, -): Promise { +): Promise> { const cosmetics = await getCosmetics(); if (cosmetics === undefined) { - return []; + return new Map(); } - const patterns: Pattern[] = []; + const patterns: Map = new Map(); const playerFlares = new Set(userMe?.player.flares); for (const name in cosmetics.patterns) { @@ -20,10 +20,10 @@ export async function patterns( if (hasAccess) { // Remove product info because player already has access. patternData.product = null; - patterns.push(patternData); + patterns.set(name, patternData); } else if (patternData.product !== null) { // Player doesn't have access, but product is available for purchase. - patterns.push(patternData); + patterns.set(name, patternData); } // If player doesn't have access and product is null, don't show it. } @@ -65,7 +65,7 @@ export async function handlePurchase(priceId: string) { window.location.href = url; } -async function getCosmetics(): Promise { +export async function getCosmetics(): Promise { try { const response = await fetch(`${getApiBase()}/cosmetics.json`); if (!response.ok) { diff --git a/src/client/Main.ts b/src/client/Main.ts index 97cca212c..7d0d3681b 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -216,13 +216,6 @@ class Client { throw new Error("territory-patterns-input-preview-button"); territoryModal.previewButton = patternButton; territoryModal.updatePreview(); - territoryModal.resizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - if (entry.target.classList.contains("preview-container")) { - territoryModal.buttonWidth = entry.contentRect.width; - } - } - }); patternButton.addEventListener("click", () => { territoryModal.open(); }); @@ -464,7 +457,7 @@ class Client { { gameID: lobby.gameID, serverConfig: config, - pattern: this.userSettings.getSelectedPattern(), + patternName: this.userSettings.getSelectedPatternName(), flag: this.flagInput === null || this.flagInput.getCurrentFlag() === "xx" ? "" diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 0ed5ff179..420ad3c6f 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -21,6 +21,7 @@ import "./components/baseComponents/Modal"; import "./components/Difficulties"; import { DifficultyDescription } from "./components/Difficulties"; import "./components/Maps"; +import { getCosmetics } from "./Cosmetics"; import { FlagInput } from "./FlagInput"; import { JoinLobbyEvent } from "./Main"; import { UsernameInput } from "./UsernameInput"; @@ -401,7 +402,7 @@ export class SinglePlayerModal extends LitElement { : this.disabledUnits.filter((u) => u !== unit); } - private startGame() { + private async startGame() { // If random map is selected, choose a random map now if (this.useRandomMap) { this.selectedMap = this.getRandomMap(); @@ -424,6 +425,10 @@ export class SinglePlayerModal extends LitElement { if (!flagInput) { console.warn("Flag input element not found"); } + const patternName = this.userSettings.getSelectedPatternName(); + const pattern = patternName + ? (await getCosmetics())?.patterns[patternName] + : undefined; this.dispatchEvent( new CustomEvent("join-lobby", { detail: { @@ -439,7 +444,7 @@ export class SinglePlayerModal extends LitElement { flagInput.getCurrentFlag() === "xx" ? "" : flagInput.getCurrentFlag(), - pattern: this.userSettings.getSelectedPattern(), + pattern: pattern?.pattern, }, ], config: { diff --git a/src/client/TerritoryPatternsModal.ts b/src/client/TerritoryPatternsModal.ts index 74e8e6857..245f4fb2a 100644 --- a/src/client/TerritoryPatternsModal.ts +++ b/src/client/TerritoryPatternsModal.ts @@ -11,6 +11,8 @@ import "./components/Maps"; import { handlePurchase, patterns } from "./Cosmetics"; import { translateText } from "./Utils"; +const BUTTON_WIDTH = 150; + @customElement("territory-patterns-modal") export class TerritoryPatternsModal extends LitElement { @query("o-modal") private modalEl!: HTMLElement & { @@ -19,22 +21,16 @@ export class TerritoryPatternsModal extends LitElement { }; public previewButton: HTMLElement | null = null; - public buttonWidth: number = 150; - @state() private selectedPattern: string | undefined; + @state() private selectedPattern: Pattern | undefined; - @state() private lockedPatterns: string[] = []; - @state() private lockedReasons: Record = {}; @state() private hoveredPattern: Pattern | null = null; @state() private hoverPosition = { x: 0, y: 0 }; @state() private keySequence: string[] = []; @state() private showChocoPattern = false; - private patterns: Pattern[] = []; - private me: UserMeResponse | null = null; - - public resizeObserver: ResizeObserver; + private patterns: Map = new Map(); private userSettings: UserSettings = new UserSettings(); @@ -47,17 +43,11 @@ export class TerritoryPatternsModal extends LitElement { connectedCallback() { super.connectedCallback(); window.addEventListener("keydown", this.handleKeyDown); - this.selectedPattern = this.userSettings.getSelectedPattern(); this.updateComplete.then(() => { - const containers = this.renderRoot.querySelectorAll(".preview-container"); - if (this.resizeObserver) { - containers.forEach((container) => - this.resizeObserver.observe(container), - ); - } - this.updatePreview(); + this.open().then(() => { + this.updatePreview(); + }); }); - this.open(); } disconnectedCallback() { @@ -67,7 +57,10 @@ export class TerritoryPatternsModal extends LitElement { async onUserMe(userMeResponse: UserMeResponse | null) { this.patterns = await patterns(userMeResponse); - this.me = userMeResponse; + const storedPatternName = this.userSettings.getSelectedPatternName(); + if (storedPatternName) { + this.selectedPattern = this.patterns.get(storedPatternName); + } this.requestUpdate(); } @@ -123,7 +116,7 @@ export class TerritoryPatternsModal extends LitElement { } private renderPatternButton(pattern: Pattern): TemplateResult { - const isSelected = this.selectedPattern === pattern.pattern; + const isSelected = this.selectedPattern?.name === pattern.name; return html`
@@ -134,7 +127,7 @@ export class TerritoryPatternsModal extends LitElement { : "bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"} ${pattern.product !== null ? "opacity-50 cursor-not-allowed" : ""}" @click=${() => - pattern.product === null && this.selectPattern(pattern.pattern)} + pattern.product === null && this.selectPattern(pattern)} @mouseenter=${(e: MouseEvent) => this.handleMouseEnter(pattern, e)} @mousemove=${(e: MouseEvent) => this.handleMouseMove(e)} @mouseleave=${() => this.handleMouseLeave()} @@ -157,8 +150,8 @@ export class TerritoryPatternsModal extends LitElement { > ${this.renderPatternPreview( pattern.pattern, - this.buttonWidth, - this.buttonWidth, + BUTTON_WIDTH, + BUTTON_WIDTH, )}
@@ -182,8 +175,8 @@ export class TerritoryPatternsModal extends LitElement { private renderPatternGrid(): TemplateResult { const buttons: TemplateResult[] = []; - for (const pattern of this.patterns) { - if (!this.showChocoPattern && pattern.name === "choco") continue; + for (const [name, pattern] of this.patterns) { + if (!this.showChocoPattern && name === "choco") continue; const result = this.renderPatternButton(pattern); buttons.push(result); @@ -218,7 +211,7 @@ export class TerritoryPatternsModal extends LitElement { overflow: hidden; " > - ${this.renderBlankPreview(this.buttonWidth, this.buttonWidth)} + ${this.renderBlankPreview(BUTTON_WIDTH, BUTTON_WIDTH)} ${buttons} @@ -239,22 +232,31 @@ export class TerritoryPatternsModal extends LitElement { `; } - public open() { - this.modalEl?.open(); - window.addEventListener("keydown", this.handleKeyDown); + public async open() { this.isActive = true; this.requestUpdate(); + + // Wait for the DOM to be updated and the o-modal element to be available + await this.updateComplete; + + // Now modalEl should be available + if (this.modalEl) { + this.modalEl.open(); + } else { + console.warn("modalEl is still null after updateComplete"); + } + + window.addEventListener("keydown", this.handleKeyDown); } public close() { + this.isActive = false; this.modalEl?.close(); window.removeEventListener("keydown", this.handleKeyDown); - this.resizeObserver?.disconnect(); - this.isActive = false; } - private selectPattern(pattern: string | undefined) { - this.userSettings.setSelectedPattern(pattern); + private selectPattern(pattern: Pattern | undefined) { + this.userSettings.setSelectedPatternName(pattern?.name); this.selectedPattern = pattern; this.updatePreview(); this.close(); @@ -314,24 +316,14 @@ export class TerritoryPatternsModal extends LitElement { public updatePreview() { if (this.previewButton === null) return; - const preview = this.renderPatternPreview(this.selectedPattern, 48, 48); + const preview = this.renderPatternPreview( + this.selectedPattern?.pattern, + 48, + 48, + ); render(preview, this.previewButton); } - private setLockedPatterns(lockedPatterns: string[], reason: string) { - this.lockedPatterns = [...this.lockedPatterns, ...lockedPatterns]; - this.lockedReasons = { - ...this.lockedReasons, - ...lockedPatterns.reduce( - (acc, key) => { - acc[key] = reason; - return acc; - }, - {} as Record, - ), - }; - } - private handleMouseEnter(pattern: Pattern, event: MouseEvent) { if (pattern.product !== null) { this.hoveredPattern = pattern; @@ -360,13 +352,21 @@ export function generatePreviewDataUrl( height?: number, ): string { pattern ??= DEFAULT_PATTERN_B64; + const patternLookupKey = `${pattern}-${width}-${height}`; - if (patternCache.has(pattern)) { - return patternCache.get(pattern)!; + if (patternCache.has(patternLookupKey)) { + return patternCache.get(patternLookupKey)!; } // Calculate canvas size - const decoder = new PatternDecoder(pattern, base64url.decode); + let decoder: PatternDecoder; + try { + decoder = new PatternDecoder(pattern, base64url.decode); + } catch (e) { + console.error("Error decoding pattern", e); + return ""; + } + const scaledWidth = decoder.scaledWidth(); const scaledHeight = decoder.scaledHeight(); @@ -403,6 +403,6 @@ export function generatePreviewDataUrl( // Create a data URL ctx.putImageData(imageData, 0, 0); const dataUrl = canvas.toDataURL("image/png"); - patternCache.set(pattern, dataUrl); + patternCache.set(patternLookupKey, dataUrl); return dataUrl; } diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 3a630e60e..abd0410ff 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -5,7 +5,6 @@ import { GameType, Gold, PlayerID, - PlayerType, Tick, UnitType, } from "../core/game/Game"; @@ -370,7 +369,7 @@ export class Transport { token: this.lobbyConfig.token, username: this.lobbyConfig.playerName, flag: this.lobbyConfig.flag, - pattern: this.lobbyConfig.pattern, + patternName: this.lobbyConfig.patternName, } satisfies ClientJoinMessage); } @@ -433,10 +432,6 @@ export class Transport { this.sendIntent({ type: "spawn", clientID: this.lobbyConfig.clientID, - flag: this.lobbyConfig.flag, - pattern: this.lobbyConfig.pattern, - name: this.lobbyConfig.playerName, - playerType: PlayerType.Human, tile: event.tile, }); } diff --git a/src/core/CosmeticSchemas.ts b/src/core/CosmeticSchemas.ts index f4318fb5e..f52c811c1 100644 --- a/src/core/CosmeticSchemas.ts +++ b/src/core/CosmeticSchemas.ts @@ -1,5 +1,11 @@ +import { base64url } from "jose"; import { z } from "zod/v4"; -import { RequiredPatternSchema } from "./Schemas"; +import { PatternDecoder } from "./PatternDecoder"; + +export type Cosmetics = z.infer; +export type Pattern = z.infer; +export type PatternName = z.infer; +export type Product = z.infer; export const ProductSchema = z.object({ productId: z.string(), @@ -7,15 +13,43 @@ export const ProductSchema = z.object({ price: z.string(), }); -const PatternSchema = z.object({ - name: z.string(), - pattern: RequiredPatternSchema, +export const PatternNameSchema = z + .string() + .regex(/^[a-z0-9_]+$/) + .max(32); + +export const PatternSchema = z + .string() + .max(1403) + .base64url() + .refine( + (val) => { + try { + new PatternDecoder(val, base64url.decode); + return true; + } catch (e) { + if (e instanceof Error) { + console.error(JSON.stringify(e.message, null, 2)); + } else { + console.error(String(e)); + } + return false; + } + }, + { + message: "Invalid pattern", + }, + ); + +export const PatternInfoSchema = z.object({ + name: PatternNameSchema, + pattern: PatternSchema, product: ProductSchema.nullable(), }); // Schema for resources/cosmetics/cosmetics.json export const CosmeticsSchema = z.object({ - patterns: z.record(z.string(), PatternSchema), + patterns: z.record(z.string(), PatternInfoSchema), flag: z .object({ layers: z.record( @@ -36,6 +70,3 @@ export const CosmeticsSchema = z.object({ }) .optional(), }); -export type Cosmetics = z.infer; -export type Pattern = z.infer; -export type Product = z.infer; diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 70ecd5c5a..ee06566ed 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -1,7 +1,7 @@ -import { base64url } from "jose"; import { z } from "zod"; import quickChatData from "../../resources/QuickChat.json" with { type: "json" }; import countries from "../client/data/countries.json" with { type: "json" }; +import { PatternSchema } from "./CosmeticSchemas"; import { AllPlayers, Difficulty, @@ -14,7 +14,6 @@ import { Trios, UnitType, } from "./game/Game"; -import { PatternDecoder } from "./PatternDecoder"; import { PlayerStatsSchema } from "./StatsSchemas"; import { flattenedEmojiTable } from "./Util"; @@ -203,29 +202,6 @@ export const FlagSchema = z }, { message: "Invalid flag: must be a valid country code or start with !" }, ); -export const RequiredPatternSchema = z - .string() - .max(1403) - .base64url() - .refine( - (val) => { - try { - new PatternDecoder(val, base64url.decode); - return true; - } catch (e) { - if (e instanceof Error) { - console.error(JSON.stringify(e.message, null, 2)); - } else { - console.error(String(e)); - } - return false; - } - }, - { - message: "Invalid pattern", - }, - ); -export const PatternSchema = RequiredPatternSchema.optional(); export const QuickChatKeySchema = z.enum( Object.entries(quickChatData).flatMap(([category, entries]) => @@ -254,10 +230,6 @@ export const AttackIntentSchema = BaseIntentSchema.extend({ export const SpawnIntentSchema = BaseIntentSchema.extend({ type: z.literal("spawn"), - name: UsernameSchema, - flag: FlagSchema, - pattern: PatternSchema, - playerType: PlayerTypeSchema, tile: z.number(), }); @@ -397,7 +369,7 @@ export const PlayerSchema = z.object({ clientID: ID, username: UsernameSchema, flag: FlagSchema, - pattern: PatternSchema, + pattern: PatternSchema.optional(), }); export const GameStartInfoSchema = z.object({ @@ -503,7 +475,7 @@ export const ClientJoinMessageSchema = z.object({ lastTurn: z.number(), // The last turn the client saw. username: UsernameSchema, flag: FlagSchema, - pattern: PatternSchema, + patternName: z.string().optional(), }); export const ClientMessageSchema = z.discriminatedUnion("type", [ diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 9103c4c6c..0956542fa 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -381,13 +381,13 @@ export class GameView implements GameMap { private _mapData: TerrainMapData, private _myClientID: ClientID, private _gameID: GameID, - private _hunans: Player[], + private humans: Player[], ) { this._map = this._mapData.gameMap; this.lastUpdate = null; this.unitGrid = new UnitGrid(this._map); this._cosmetics = new Map( - this._hunans.map((h) => [ + this.humans.map((h) => [ h.clientID, { flag: h.flag, pattern: h.pattern } satisfies PlayerCosmetics, ]), diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts index 35f3c727b..b2a9fa08a 100644 --- a/src/core/game/UserSettings.ts +++ b/src/core/game/UserSettings.ts @@ -111,11 +111,11 @@ export class UserSettings { } } - getSelectedPattern(): string | undefined { + getSelectedPatternName(): string | undefined { return localStorage.getItem(PATTERN_KEY) ?? undefined; } - setSelectedPattern(base64: string | undefined): void { + setSelectedPatternName(base64: string | undefined): void { if (base64 === undefined) { localStorage.removeItem(PATTERN_KEY); } else { diff --git a/src/server/Privilege.ts b/src/server/Privilege.ts index 88908215f..3a26081ba 100644 --- a/src/server/Privilege.ts +++ b/src/server/Privilege.ts @@ -1,11 +1,16 @@ -import { Cosmetics, Pattern } from "../core/CosmeticSchemas"; +import { Cosmetics } from "../core/CosmeticSchemas"; import { PatternDecoder } from "../core/PatternDecoder"; +type PatternResult = + | { type: "allowed"; pattern: string } + | { type: "unknown" } + | { type: "forbidden"; reason: string }; + export interface PrivilegeChecker { isPatternAllowed( base64: string, flares: readonly string[] | undefined, - ): true | "restricted" | "unlisted" | "invalid"; + ): PatternResult; isCustomFlagAllowed( flag: string, flares: readonly string[] | undefined, @@ -13,49 +18,34 @@ export interface PrivilegeChecker { } export class PrivilegeCheckerImpl implements PrivilegeChecker { - private b64ToPattern: Record = {}; - constructor( private cosmetics: Cosmetics, private b64urlDecode: (base64: string) => Uint8Array, - ) { - for (const name in this.cosmetics.patterns) { - const pattern = this.cosmetics.patterns[name]; - this.b64ToPattern[pattern.pattern] = pattern; - } - } + ) {} isPatternAllowed( - base64: string, + name: string, flares: readonly string[] | undefined, - ): true | "restricted" | "unlisted" | "invalid" { + ): PatternResult { // Look for the pattern in the cosmetics.json config - const found = this.b64ToPattern[base64]; - if (found === undefined) { - try { - // Ensure that the pattern will not throw for clients - new PatternDecoder(base64, this.b64urlDecode); - } catch (e) { - // Pattern is invalid - return "invalid"; - } - // Pattern is unlisted - if (flares !== undefined && flares.includes("pattern:*")) { - // Player has the super-flare - return true; - } - return "unlisted"; + const found = this.cosmetics.patterns[name]; + if (!found) return { type: "forbidden", reason: "pattern not found" }; + + try { + new PatternDecoder(found.pattern, this.b64urlDecode); + } catch (e) { + return { type: "forbidden", reason: "invalid pattern" }; } if ( - flares !== undefined && - (flares.includes(`pattern:${found.name}`) || flares.includes("pattern:*")) + flares?.includes(`pattern:${found.name}`) || + flares?.includes("pattern:*") ) { // Player has a flare for this pattern - return true; + return { type: "allowed", pattern: found.pattern }; + } else { + return { type: "forbidden", reason: "no flares for pattern" }; } - - return "restricted"; } isCustomFlagAllowed( @@ -136,8 +126,8 @@ export class FailOpenPrivilegeChecker implements PrivilegeChecker { isPatternAllowed( name: string, flares: readonly string[] | undefined, - ): true | "restricted" | "unlisted" | "invalid" { - return true; + ): PatternResult { + return { type: "unknown" }; } isCustomFlagAllowed( diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 9f7da2eb1..b8b7aa909 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -24,6 +24,7 @@ import { gatekeeper, LimiterType } from "./Gatekeeper"; import { getUserMe, verifyClientToken } from "./jwt"; import { logger } from "./Logger"; +import { assertNever } from "../core/Util"; import { PrivilegeRefresher } from "./PrivilegeRefresher"; import { initWorkerMetrics } from "./WorkerMetrics"; @@ -410,15 +411,29 @@ export async function startWorker() { } } + let pattern: string | undefined; // Check if the pattern is allowed - if (clientMsg.pattern !== undefined) { - const allowed = privilegeRefresher + if (clientMsg.patternName !== undefined) { + const result = privilegeRefresher .get() - .isPatternAllowed(clientMsg.pattern, flares); - if (allowed !== true) { - log.warn(`Pattern ${allowed}: ${clientMsg.pattern}`); - ws.close(1002, `Pattern ${allowed}`); - return; + .isPatternAllowed(clientMsg.patternName, flares); + switch (result.type) { + case "allowed": + pattern = result.pattern; + break; + case "unknown": + // Api could be down, so allow player to join but disable pattern. + log.warn(`Pattern ${clientMsg.patternName} unknown`); + break; + case "forbidden": + log.warn(`Pattern ${clientMsg.patternName}: ${result.reason}`); + ws.close( + 1002, + `Pattern ${clientMsg.patternName}: ${result.reason}`, + ); + return; + default: + assertNever(result); } } @@ -433,7 +448,7 @@ export async function startWorker() { clientMsg.username, ws, clientMsg.flag, - clientMsg.pattern, + pattern, ); const wasFound = gm.addClient(