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
This commit is contained in:
evanpelle
2025-08-16 18:08:16 -07:00
committed by GitHub
parent 77fd82b4b4
commit b57a409b8a
12 changed files with 160 additions and 159 deletions
+39 -8
View File
@@ -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<typeof CosmeticsSchema>;
export type Pattern = z.infer<typeof PatternInfoSchema>;
export type PatternName = z.infer<typeof PatternNameSchema>;
export type Product = z.infer<typeof ProductSchema>;
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<typeof CosmeticsSchema>;
export type Pattern = z.infer<typeof PatternSchema>;
export type Product = z.infer<typeof ProductSchema>;
+3 -31
View File
@@ -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", [
+2 -2
View File
@@ -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,
]),
+2 -2
View File
@@ -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 {