support for unlockable flags (#3479)

## Description:

Add support for purchasable/gated flags.

* Create a new "Store" modal that renders both skins & flags
* move all store related logic out of TerritoryPatternsModal
* use nation:code for existing nation flags & flag:key for gated flags
* check if user has the appropriate flags before purchasing

## 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

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evan
This commit is contained in:
Evan
2026-03-23 17:09:18 -07:00
committed by GitHub
parent 426806299f
commit 39ad547c04
30 changed files with 1144 additions and 1155 deletions
+12 -22
View File
@@ -5,7 +5,8 @@ import { PlayerPattern } from "./Schemas";
export type Cosmetics = z.infer<typeof CosmeticsSchema>;
export type Pattern = z.infer<typeof PatternSchema>;
export type PatternName = z.infer<typeof PatternNameSchema>;
export type Flag = z.infer<typeof FlagSchema>;
export type PatternName = z.infer<typeof CosmeticNameSchema>;
export type Product = z.infer<typeof ProductSchema>;
export type ColorPalette = z.infer<typeof ColorPaletteSchema>;
export type PatternData = z.infer<typeof PatternDataSchema>;
@@ -16,7 +17,7 @@ export const ProductSchema = z.object({
price: z.string(),
});
export const PatternNameSchema = z
export const CosmeticNameSchema = z
.string()
.regex(/^[a-z0-9_]+$/)
.max(32);
@@ -51,7 +52,7 @@ export const ColorPaletteSchema = z.object({
});
export const PatternSchema = z.object({
name: PatternNameSchema,
name: CosmeticNameSchema,
pattern: PatternDataSchema,
colorPalettes: z
.object({
@@ -64,29 +65,18 @@ export const PatternSchema = z.object({
product: ProductSchema.nullable(),
});
export const FlagSchema = z.object({
name: CosmeticNameSchema,
url: z.string(),
affiliateCode: z.string().nullable(),
product: ProductSchema.nullable(),
});
// Schema for resources/cosmetics/cosmetics.json
export const CosmeticsSchema = z.object({
colorPalettes: z.record(z.string(), ColorPaletteSchema).optional(),
patterns: z.record(z.string(), PatternSchema),
flag: z
.object({
layers: z.record(
z.string(),
z.object({
name: z.string(),
flares: z.array(z.string()).optional(),
}),
),
color: z.record(
z.string(),
z.object({
color: z.string(),
name: z.string(),
flares: z.array(z.string()).optional(),
}),
),
})
.optional(),
flags: z.record(z.string(), FlagSchema),
});
export const DefaultPattern = {
-80
View File
@@ -1,80 +0,0 @@
import { Cosmetics } from "./CosmeticSchemas";
const ANIMATION_DURATIONS: Record<string, number> = {
rainbow: 4000,
"bright-rainbow": 4000,
"copper-glow": 3000,
"silver-glow": 3000,
"gold-glow": 3000,
neon: 3000,
lava: 6000,
water: 6200,
};
// TODO: Pass in cosmetics as a parameter when
// remote cosmetics are implemented for custom flags
export function renderPlayerFlag(
flag: string,
target: HTMLElement,
cosmetics: Cosmetics | undefined = undefined,
) {
if (cosmetics === undefined) {
console.warn("No cosmetics provided for flag", flag);
return;
}
if (!flag.startsWith("!")) return;
const code = flag.slice("!".length);
const layers = code.split("_").map((segment) => {
const [layerKey, colorKey] = segment.split("-");
return { layerKey, colorKey };
});
target.innerHTML = "";
target.style.overflow = "hidden";
target.style.position = "relative";
target.style.aspectRatio = "3/4";
for (const { layerKey, colorKey } of layers) {
const layerName = cosmetics?.flag?.layers[layerKey]?.name ?? layerKey;
const mask = `/flags/custom/${layerName}.svg`;
if (!mask) continue;
const layer = document.createElement("div");
layer.style.position = "absolute";
layer.style.top = "0";
layer.style.left = "0";
layer.style.width = "100%";
layer.style.height = "100%";
const colorValue = cosmetics?.flag?.color[colorKey]?.color ?? colorKey;
const isSpecial =
!colorValue.startsWith("#") &&
!/^([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/.test(colorValue);
if (isSpecial) {
const duration = ANIMATION_DURATIONS[colorValue] ?? 5000;
const now = performance.now();
const offset = now % duration;
if (!duration) console.warn(`No animation duration for: ${colorValue}`);
layer.classList.add(`flag-color-${colorValue}`);
layer.style.animationDelay = `-${offset}ms`;
} else {
layer.style.backgroundColor = colorValue;
}
layer.style.maskImage = `url(${mask})`;
layer.style.maskRepeat = "no-repeat";
layer.style.maskPosition = "center";
layer.style.maskSize = "contain";
layer.style.webkitMaskImage = `url(${mask})`;
layer.style.webkitMaskRepeat = "no-repeat";
layer.style.webkitMaskPosition = "center";
layer.style.webkitMaskSize = "contain";
target.appendChild(layer);
}
}
+18 -16
View File
@@ -1,10 +1,9 @@
import countries from "resources/countries.json";
import quickChatData from "resources/QuickChat.json";
import { z } from "zod";
import {
ColorPaletteSchema,
CosmeticNameSchema,
PatternDataSchema,
PatternNameSchema,
} from "./CosmeticSchemas";
import type { GameEvent } from "./EventBus";
import {
@@ -132,7 +131,6 @@ export type PlayerCosmetics = z.infer<typeof PlayerCosmeticsSchema>;
export type PlayerCosmeticRefs = z.infer<typeof PlayerCosmeticRefsSchema>;
export type PlayerPattern = z.infer<typeof PlayerPatternSchema>;
export type PlayerColor = z.infer<typeof PlayerColorSchema>;
export type Flag = z.infer<typeof FlagSchema>;
export type GameStartInfo = z.infer<typeof GameStartInfoSchema>;
export type GameInfo = z.infer<typeof GameInfoSchema>;
export type PublicGames = z.infer<typeof PublicGamesSchema>;
@@ -284,7 +282,6 @@ export const UsernameSchema = z
.regex(/^[a-zA-Z0-9_ [\]üÜ.]+$/u)
.min(3)
.max(27);
const countryCodes = countries.filter((c) => !c.restricted).map((c) => c.code);
export const QuickChatKeySchema = z.enum(
Object.entries(quickChatData).flatMap(([category, entries]) =>
@@ -471,28 +468,23 @@ export const TurnSchema = z.object({
hash: z.number().nullable().optional(),
});
export const FlagSchema = z
export const FlagName = z
.string()
.max(128)
.optional()
.refine(
(val) => {
if (val === undefined || val === "") return true;
if (val.startsWith("!")) return true;
return countryCodes.includes(val);
return val.startsWith("flag:") || val.startsWith("country:");
},
{
message: "Invalid flag: must start with country: or flag:",
},
{ message: "Invalid flag: must be a valid country code or start with !" },
);
export const PlayerCosmeticRefsSchema = z.object({
flag: FlagSchema.optional(),
color: z.string().optional(),
patternName: PatternNameSchema.optional(),
patternColorPaletteName: z.string().optional(),
});
export const FlagSchema = z.string();
export const PlayerPatternSchema = z.object({
name: PatternNameSchema,
name: CosmeticNameSchema,
patternData: PatternDataSchema,
colorPalette: ColorPaletteSchema.optional(),
});
@@ -501,6 +493,16 @@ export const PlayerColorSchema = z.object({
color: z.string(),
});
// Refs contain cosmetics names, will be replaced by the actual
// content in the server
export const PlayerCosmeticRefsSchema = z.object({
flag: FlagName.optional(),
color: z.string().optional(),
patternName: CosmeticNameSchema.optional(),
patternColorPaletteName: z.string().optional(),
});
// Server converts refs to the actual cosmetics here
export const PlayerCosmeticsSchema = z.object({
flag: FlagSchema.optional(),
pattern: PlayerPatternSchema.optional(),
+1 -1
View File
@@ -678,7 +678,7 @@ export class GameView implements GameMap {
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,
flag: nation.flag ? `/flags/${nation.flag}.svg` : undefined,
} satisfies PlayerCosmetics);
}
}
+26 -6
View File
@@ -4,13 +4,13 @@ import { PlayerPattern } from "../Schemas";
const PATTERN_KEY = "territoryPattern";
export class UserSettings {
private emitChange(key: string, value: boolean | number): void {
private emitChange(key: string, value: any): void {
try {
const maybeDispatch = (globalThis as any)?.dispatchEvent;
if (typeof maybeDispatch !== "function") return;
(globalThis as any).dispatchEvent(
new CustomEvent("user-settings-changed", {
detail: { key, value },
new CustomEvent(`event:user-settings-changed:${key}`, {
detail: value,
}),
);
} catch {
@@ -192,6 +192,7 @@ export class UserSettings {
} else {
localStorage.setItem(PATTERN_KEY, patternName);
}
this.emitChange("pattern", patternName);
}
getSelectedColor(): string | undefined {
@@ -208,12 +209,31 @@ export class UserSettings {
}
}
getFlag(): string | undefined {
const flag = localStorage.getItem("flag");
if (!flag || flag === "xx") return undefined;
getFlag(): string | null {
let flag = localStorage.getItem("flag");
if (!flag) return null;
// Migrate bare country codes to country: prefix
if (!flag.startsWith("flag:") && !flag.startsWith("country:")) {
flag = `country:${flag}`;
localStorage.setItem("flag", flag);
}
return flag;
}
setFlag(flag: string): void {
if (flag === "country:xx") {
this.clearFlag();
} else {
localStorage.setItem("flag", flag);
}
console.log("emitting change!");
this.emitChange("flag", flag);
}
clearFlag(): void {
localStorage.removeItem("flag");
}
backgroundMusicVolume(): number {
return this.getFloat("settings.backgroundMusicVolume", 0);
}