Refactor cosmetics.json (#1263)

## Description:

- Refactor cosmetics.json to use base64 as the lookup key, to reduce the
complexity of lookup.
- Add refine() logic to PatternSchema to check if the pattern is valid.
- Split PatternDecoder class out of Cosmetic.ts to resolve temporal
deadzone caused by static parsing of cosmetics.json with the new
refine().

## 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 understand that submitting code with bugs that could have been
caught through manual testing blocks releases and new features for all
contributors
This commit is contained in:
Scott Anderson
2025-06-23 23:14:36 -04:00
committed by GitHub
parent 1ce282d41b
commit 4680b04656
10 changed files with 135 additions and 169 deletions
+48 -48
View File
@@ -3,70 +3,70 @@
"donor": ["1359441841371480176", "1330243292306341969"],
"creator": ["1286745100411473930"]
},
"pattern": {
"stripes_v": {
"pattern": "ABMIVVU="
"patterns": {
"ABMIVVU=": {
"name": "stripes_v"
},
"stripes_h": {
"pattern": "ABMIDw8="
"ABMIDw8=": {
"name": "stripes_h"
},
"checkerboard": {
"pattern": "ABMIpaU="
"ABMIpaU=": {
"name": "checkerboard"
},
"choco": {
"pattern": "AFIoAAABOEAHgkAc+AN/4AMcgAAA"
"AFIoAAABOEAHgkAc+AN/4AMcgAAA": {
"name": "choco"
},
"diagonal": {
"pattern": "AHE4AQACAAQACAAQACAAQACAAAABAAIABAAIABAAIABAAIA=",
"role_group": ["donor"]
"AHE4AQACAAQACAAQACAAQACAAAABAAIABAAIABAAIABAAIA=": {
"name": "diagonal",
"role_group": "donor"
},
"cross": {
"pattern": "AHE4AYACQAQgCBAQCCAEQAKAAYABQAIgBBAICBAEIAJAAYA=",
"role_group": ["donor"]
"AHE4AYACQAQgCBAQCCAEQAKAAYABQAIgBBAICBAEIAJAAYA=": {
"name": "cross",
"role_group": "donor"
},
"mini_cross": {
"pattern": "AHEYA8AMMDAMwAPAAzAMDDADwA==",
"role_group": ["donor"]
"AHEYA8AMMDAMwAPAAzAMDDADwA==": {
"name": "mini_cross",
"role_group": "donor"
},
"horizontal_stripes": {
"pattern": "AHE4//8AAAAAAAAAAAAAAAAAAP//AAAAAAAAAAAAAAAAAAA=",
"role_group": ["donor"]
"AHE4//8AAAAAAAAAAAAAAAAAAP//AAAAAAAAAAAAAAAAAAA=": {
"name": "horizontal_stripes",
"role_group": "donor"
},
"sparse_dots": {
"pattern": "AHE4AQEAAAAAAAAAAAAAAAAAAAEBAAAAAAAAAAAAAAAAAAA=",
"role_group": ["donor"]
"AHE4AQEAAAAAAAAAAAAAAAAAAAEBAAAAAAAAAAAAAAAAAAA=": {
"name": "sparse_dots",
"role_group": "donor"
},
"evan": {
"pattern": "ALIUAAAAnsRIgiRZjuRpAiNJHiNJAAAA",
"role_group": ["creator"]
"ALIUAAAAnsRIgiRZjuRpAiNJHiNJAAAA": {
"name": "evan",
"role_group": "creator"
},
"diagonal_stripe": {
"pattern": "AHEYAYACQAQgCBAQCCAEQAKAAQ==",
"role_group": ["donor"]
"AHEYAYACQAQgCBAQCCAEQAKAAQ==": {
"name": "diagonal_stripe",
"role_group": "donor"
},
"mountain_ridge": {
"pattern": "AHEYAAAYGDw8fn7//35+PDwYGA==",
"role_group": ["donor"]
"AHEYAAAYGDw8fn7//35+PDwYGA==": {
"name": "mountain_ridge",
"role_group": "donor"
},
"scattered_dots": {
"pattern": "AHEYAAACIAAAAAAAAAAACBAAAA==",
"role_group": ["donor"]
"AHEYAAACIAAAAAAAAAAACBAAAA==": {
"name": "scattered_dots",
"role_group": "donor"
},
"circuit_board": {
"pattern": "AHEYw8PDwwwMDAwwDDAMw8PDww==",
"role_group": ["donor"]
"AHEYw8PDwwwMDAwwDDAMw8PDww==": {
"name": "circuit_board",
"role_group": "donor"
},
"vertical_bars": {
"pattern": "AHEYSZJJkkmSSZJJkkmSSZJJkg==",
"role_group": ["donor"]
"AHEYSZJJkkmSSZJJkkmSSZJJkg==": {
"name": "vertical_bars",
"role_group": "donor"
},
"-w-": {
"pattern": "AHEYAAAAAAAAAkCCQUQiLnQWaA==",
"role_group": ["donor"]
"AHEYAAAAAAAAAkCCQUQiLnQWaA==": {
"name": "-w-",
"role_group": "donor"
},
"openfront": {
"pattern": "AAIiAAAAAAAAAAAAAAAAAAAAAIDD8YnweTiiD5FIYEIgEpkIRCKBCoFIpCIQeTwyPB6RjEAkEIgQKEQiApFAIEIgEYkIOAKfCIGIIyIAAAAAAAAAAAA=",
"role_group": ["creator"]
"AAIiAAAAAAAAAAAAAAAAAAAAAIDD8YnweTiiD5FIYEIgEpkIRCKBCoFIpCIQeTwyPB6RjEAkEIgQKEQiApFAIEIgEYkIOAKfCIGIIyIAAAAAAAAAAAA=": {
"name": "openfront",
"role_group": "creator"
}
}
}
+1
View File
@@ -479,6 +479,7 @@
},
"pattern": {
"default": "Default",
"custom": "Custom",
"stripes_v": "Vertical Stripes",
"stripes_h": "Horizontal Stripes",
"checkerboard": "Checkerboard",
+27 -76
View File
@@ -2,8 +2,9 @@ import type { TemplateResult } from "lit";
import { html, LitElement, render } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { UserMeResponse } from "../core/ApiSchemas";
import { PatternDecoder, territoryPatterns } from "../core/Cosmetics";
import { COSMETICS } from "../core/CosmeticSchemas";
import { UserSettings } from "../core/game/UserSettings";
import { PatternDecoder } from "../core/PatternDecoder";
import "./components/Difficulties";
import "./components/Maps";
import { translateText } from "./Utils";
@@ -18,7 +19,7 @@ export class TerritoryPatternsModal extends LitElement {
public previewButton: HTMLElement | null = null;
public buttonWidth: number = 100;
@state() private selectedPattern: string | undefined = undefined;
@state() private selectedPattern: string | undefined;
@state() private lockedPatterns: string[] = [];
@state() private lockedReasons: Record<string, string> = {};
@@ -37,15 +38,7 @@ export class TerritoryPatternsModal extends LitElement {
connectedCallback() {
super.connectedCallback();
const b64 = this.userSettings.getSelectedPattern();
if (b64) {
const found = Object.entries(territoryPatterns.pattern).find(
([key, pattern]) => pattern.pattern === b64,
);
this.selectedPattern = found ? found[0] : "custom";
} else {
this.selectedPattern = undefined;
}
this.selectedPattern = this.userSettings.getSelectedPattern();
window.addEventListener("keydown", this.handleKeyDown);
this.updateComplete.then(() => {
const containers = this.renderRoot.querySelectorAll(".preview-container");
@@ -79,8 +72,7 @@ export class TerritoryPatternsModal extends LitElement {
}
private checkPatternPermission(roles: string[]) {
const patterns = territoryPatterns.pattern ?? {};
const patterns = COSMETICS.patterns;
for (const key in patterns) {
const patternData = patterns[key];
const roleGroup: string[] | string | undefined = patternData.role_group;
@@ -158,21 +150,10 @@ export class TerritoryPatternsModal extends LitElement {
return null;
}
private renderPatternButton(
key: string,
pattern: (typeof territoryPatterns.pattern)[string],
): TemplateResult {
private renderPatternButton(key: string): TemplateResult {
const isLocked = this.isPatternLocked(key);
const isSelected =
this.selectedPattern === key ||
(key === "custom" && this.selectedPattern === "custom");
let previewPattern = pattern;
if (key === "custom") {
const b64 = this.userSettings.getSelectedPattern();
if (b64) {
previewPattern = { pattern: b64 } as any;
}
}
const isSelected = this.selectedPattern === key;
const name = COSMETICS.patterns[key]?.name ?? "custom";
return html`
<button
class="border p-2 rounded-lg shadow text-black dark:text-white text-left
@@ -187,9 +168,7 @@ export class TerritoryPatternsModal extends LitElement {
@mouseleave=${() => this.handleMouseLeave()}
>
<div class="text-sm font-bold mb-1">
${key === "custom"
? "Custom"
: translateText(`territory_patterns.pattern.${key}`)}
${translateText(`territory_patterns.pattern.${name}`)}
</div>
<div
class="preview-container"
@@ -204,23 +183,18 @@ export class TerritoryPatternsModal extends LitElement {
overflow: hidden;
"
>
${this.renderPatternPreview(
previewPattern,
this.buttonWidth,
this.buttonWidth,
)}
${this.renderPatternPreview(key, this.buttonWidth, this.buttonWidth)}
</div>
</button>
`;
}
private renderPatternGrid(): TemplateResult {
const patterns = territoryPatterns.pattern ?? {};
const buttons: TemplateResult[] = [];
for (const key in patterns) {
if (!this.showChocoPattern && key === "choco") continue;
const result = this.renderPatternButton(key, patterns[key]);
for (const key in COSMETICS.patterns) {
const value = COSMETICS.patterns[key];
if (!this.showChocoPattern && value.name === "choco") continue;
const result = this.renderPatternButton(key);
buttons.push(result);
}
@@ -231,11 +205,11 @@ export class TerritoryPatternsModal extends LitElement {
>
<button
class="border p-2 rounded-lg shadow text-black dark:text-white text-left
${this.selectedPattern === null
${this.selectedPattern === undefined
? "bg-blue-500 text-white"
: "bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"}"
style="flex: 0 1 calc(25% - 1rem); max-width: calc(25% - 1rem);"
@click=${() => this.selectPattern(null)}
@click=${() => this.selectPattern(undefined)}
>
<div class="text-sm font-bold mb-1">
${translateText("territory_patterns.pattern.default")}
@@ -283,30 +257,19 @@ export class TerritoryPatternsModal extends LitElement {
this.modalEl?.close();
}
private selectPattern(patternKey: string | null) {
if (patternKey) {
const pattern = territoryPatterns.pattern[patternKey];
if (pattern) {
this.userSettings.setSelectedPattern(pattern.pattern);
this.selectedPattern = patternKey;
} else {
this.userSettings.setSelectedPattern("");
this.selectedPattern = undefined;
}
} else {
this.userSettings.setSelectedPattern("");
this.selectedPattern = undefined;
}
private selectPattern(pattern: string | undefined) {
this.userSettings.setSelectedPattern(pattern);
this.selectedPattern = pattern;
this.updatePreview();
this.close();
}
private renderPatternPreview(
pattern: (typeof territoryPatterns.pattern)[string],
pattern: string,
width: number,
height: number,
): TemplateResult {
const decoder = new PatternDecoder(pattern.pattern);
const decoder = new PatternDecoder(pattern);
const cellCountX = decoder.getTileWidth();
const cellCountY = decoder.getTileHeight();
@@ -410,24 +373,12 @@ export class TerritoryPatternsModal extends LitElement {
}
public updatePreview() {
if (!this.previewButton) return;
const patternKey = this.selectedPattern ?? "default";
let pattern = territoryPatterns.pattern[patternKey];
if (!pattern && patternKey === "custom") {
// customパターンはbase64から生成
const b64 = this.userSettings.getSelectedPattern();
if (b64) {
pattern = { pattern: b64 } as any;
}
}
if (!pattern) {
const blankPreview = this.renderBlankPreview(48, 48);
render(blankPreview, this.previewButton);
return;
}
const previewHTML = this.renderPatternPreview(pattern, 48, 48);
render(previewHTML, this.previewButton);
if (this.previewButton === null) return;
const preview =
this.selectedPattern === undefined
? this.renderBlankPreview(48, 48)
: this.renderPatternPreview(this.selectedPattern, 48, 48);
render(preview, this.previewButton);
}
private setLockedPatterns(lockedPatterns: string[], reason: string) {
+9 -7
View File
@@ -1,15 +1,17 @@
import { z } from "zod";
import { z } from "zod/v4";
import cosmetics_json from "../../resources/cosmetics/cosmetics.json" with { type: "json" };
import { RequiredPatternSchema } from "./Schemas";
// Schema for resources/cosmetics/cosmetics.json
export const CosmeticsSchema = z.object({
role_group: z.record(z.string(), z.string().array()).optional(),
pattern: z.record(
z.string(),
role_groups: z.record(z.string(), z.string().array().min(1)),
patterns: z.record(
RequiredPatternSchema,
z.object({
pattern: z.string().base64(),
role_group: z.string().array().optional(),
name: z.string(),
role_group: z.string().optional(),
}),
),
});
export type Cosmetics = z.infer<typeof CosmeticsSchema>;
export const COSMETICS: Cosmetics = CosmeticsSchema.parse(cosmetics_json);
@@ -1,8 +1,4 @@
import { base64url } from "jose";
import rawTerritoryPatterns from "../../resources/cosmetics/cosmetics.json" with { type: "json" };
import { CosmeticsSchema } from "./CosmeticSchemas";
export const territoryPatterns = CosmeticsSchema.parse(rawTerritoryPatterns);
export class PatternDecoder {
private bytes: Uint8Array;
+20 -1
View File
@@ -10,6 +10,7 @@ import {
PlayerType,
UnitType,
} from "./game/Game";
import { PatternDecoder } from "./PatternDecoder";
import { PlayerStatsSchema } from "./StatsSchemas";
import { flattenedEmojiTable } from "./Util";
@@ -178,7 +179,25 @@ export const AllPlayersStatsSchema = z.record(ID, PlayerStatsSchema);
export const UsernameSchema = SafeString;
export const FlagSchema = z.string().max(128).optional();
export const PatternSchema = z.string().max(128).base64().optional();
export const RequiredPatternSchema = z
.string()
.max(128)
.base64()
.refine(
(val) => {
try {
new PatternDecoder(val);
return true;
} catch (e) {
console.error(JSON.stringify(e.message, null, 2));
return false;
}
},
{
message: "Invalid pattern",
},
);
export const PatternSchema = RequiredPatternSchema.optional();
export const QuickChatKeySchema = z.enum(
Object.entries(quickChatData).flatMap(([category, entries]) =>
+1 -1
View File
@@ -1,5 +1,5 @@
import { Config } from "../configuration/Config";
import { PatternDecoder } from "../Cosmetics";
import { PatternDecoder } from "../PatternDecoder";
import { ClientID, GameID } from "../Schemas";
import { createRandomName } from "../Util";
import { WorkerClient } from "../worker/WorkerClient";
+9 -5
View File
@@ -1,3 +1,5 @@
const PATTERN_KEY = "territoryPattern";
export class UserSettings {
get(key: string, defaultValue: boolean): boolean {
const value = localStorage.getItem(key);
@@ -85,13 +87,15 @@ export class UserSettings {
}
}
private readonly PATTERN_KEY = "territoryPattern";
getSelectedPattern(): string | undefined {
return localStorage.getItem(this.PATTERN_KEY) ?? undefined;
return localStorage.getItem(PATTERN_KEY) ?? undefined;
}
setSelectedPattern(base64: string): void {
localStorage.setItem(this.PATTERN_KEY, base64);
setSelectedPattern(base64: string | undefined): void {
if (base64 === undefined) {
localStorage.removeItem(PATTERN_KEY);
} else {
localStorage.setItem(PATTERN_KEY, base64);
}
}
}
+17 -24
View File
@@ -1,9 +1,6 @@
import { PatternDecoder } from "../core/Cosmetics";
import { Cosmetics } from "../core/CosmeticSchemas";
type PatternEntry = {
pattern: string;
role_group?: string[];
};
import { PatternDecoder } from "../core/PatternDecoder";
export class PrivilegeChecker {
constructor(private cosmetics: Cosmetics) {}
@@ -13,16 +10,8 @@ export class PrivilegeChecker {
flares: readonly string[] | undefined,
): true | "restricted" | "unlisted" | "invalid" {
// Look for the pattern in the cosmetics.json config
let found: [string, PatternEntry] | undefined;
for (const key in this.cosmetics.pattern) {
const entry = this.cosmetics.pattern[key];
if (entry.pattern === base64) {
found = [key, entry];
break;
}
}
if (!found) {
const found = this.cosmetics.patterns[base64];
if (found === undefined) {
try {
// Ensure that the pattern will not throw for clients
new PatternDecoder(base64);
@@ -32,33 +21,37 @@ export class PrivilegeChecker {
}
// Pattern is unlisted
if (flares !== undefined && flares.includes("pattern:*")) {
// Player has the super-flare
return true;
}
return "unlisted";
}
const [key, entry] = found;
const allowedGroups = entry.role_group;
if (allowedGroups === undefined) {
const { role_group, name } = found;
if (role_group === undefined) {
// Pattern has no restrictions
return true;
}
for (const groupName of allowedGroups) {
const groupRoles = this.cosmetics.role_group?.[groupName] || [];
for (const groupName of role_group) {
if (
roles !== undefined &&
roles.some((role) => groupRoles.includes(role))
roles.some((role) =>
this.cosmetics.role_groups[groupName].includes(role),
)
) {
// Player is in a role group for this pattern
return true;
}
}
if (
flares !== undefined &&
(flares.includes(`pattern:${key}`) || flares.includes("pattern:*"))
)
(flares.includes(`pattern:${name}`) || flares.includes("pattern:*"))
) {
// Player has a flare for this pattern
return true;
}
return "restricted";
}
+3 -3
View File
@@ -8,7 +8,7 @@ import { WebSocket, WebSocketServer } from "ws";
import { z } from "zod/v4";
import { GameEnv } from "../core/configuration/Config";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { territoryPatterns } from "../core/Cosmetics";
import { COSMETICS } from "../core/CosmeticSchemas";
import { GameType } from "../core/game/Game";
import {
ClientJoinMessageSchema,
@@ -31,8 +31,6 @@ const config = getServerConfigFromServer();
const workerId = parseInt(process.env.WORKER_ID || "0");
const log = logger.child({ comp: `w_${workerId}` });
const privilegeChecker = new PrivilegeChecker(territoryPatterns);
// Worker setup
export function startWorker() {
log.info(`Worker starting...`);
@@ -46,6 +44,8 @@ export function startWorker() {
const gm = new GameManager(config, log);
const privilegeChecker = new PrivilegeChecker(COSMETICS);
if (config.env() === GameEnv.Prod && config.otelEnabled()) {
initWorkerMetrics(gm);
}