Merge branch 'v30'

This commit is contained in:
evanpelle
2026-03-25 13:34:34 -07:00
32 changed files with 1223 additions and 1179 deletions
+7 -5
View File
@@ -208,11 +208,11 @@
inline
class="hidden w-full h-full page-content relative z-50"
></join-lobby-modal>
<territory-patterns-modal
<store-modal
id="page-item-store"
inline
class="hidden w-full h-full page-content relative z-50"
></territory-patterns-modal>
></store-modal>
<user-setting
id="page-settings"
inline
@@ -249,6 +249,11 @@
inline
class="hidden w-full h-full page-content relative z-50"
></flag-input-modal>
<territory-patterns-modal
id="territory-patterns-modal"
inline
class="hidden w-full h-full page-content relative z-50"
></territory-patterns-modal>
<ranked-modal
id="page-ranked"
inline
@@ -263,9 +268,6 @@
</div>
<!-- Global Modals -->
<territory-patterns-modal
id="territory-patterns-modal"
></territory-patterns-modal>
</div>
<!-- Game components -->
-531
View File
@@ -1,531 +0,0 @@
{
"role_groups": {
"donor": ["1359441841371480176", "1330243292306341969"],
"creator": ["1286745100411473930"]
},
"patterns": {
"ABMIVVU": {
"name": "stripes_v"
},
"ABMIDw8": {
"name": "stripes_h"
},
"AAEYAwA": {
"name": "horizontal_stripes",
"role_group": "donor"
},
"AAoACQ": {
"name": "vertical_bars",
"role_group": "donor"
},
"ABMIpaU": {
"name": "checkerboard"
},
"AFIoAAABOEAHgkAc-AN_4AMcgAAA": {
"name": "choco"
},
"AHE4AQACAAQACAAQACAAQACAAAABAAIABAAIABAAIABAAIA": {
"name": "diagonal",
"role_group": "donor"
},
"AHE4AYACQAQgCBAQCCAEQAKAAYABQAIgBBAICBAEIAJAAYA": {
"name": "cross",
"role_group": "donor"
},
"AHEYA8AMMDAMwAPAAzAMDDADwA": {
"name": "mini_cross",
"role_group": "donor"
},
"AHI4AOAAkACIAEQAIgARjAhUBCgCKAHQACgB1AILAwUABwA": {
"name": "sword",
"role_group": "donor"
},
"AHE4AQEAAAAAAAAAAAAAAAAAAAEBAAAAAAAAAAAAAAAAAAA": {
"name": "sparse_dots",
"role_group": "donor"
},
"ALIUAAAAnsRIgiRZjuRpAiNJHiNJAAAA": {
"name": "evan",
"role_group": "creator"
},
"AHEYAYACQAQgCBAQCCAEQAKAAQ": {
"name": "diagonal_stripe",
"role_group": "donor"
},
"AHEYAAAYGDw8fn7__35-PDwYGA": {
"name": "mountain_ridge",
"role_group": "donor"
},
"AHEYAAACIAAAAAAAAAAACBAAAA": {
"name": "scattered_dots",
"role_group": "donor"
},
"AHEYw8PDwwwMDAwwDDAMw8PDww": {
"name": "circuit_board",
"role_group": "donor"
},
"AHEgzGfznzu43XPoL2fMn_O4O3fdL-g": {
"name": "shells",
"role_group": "donor"
},
"AHEYAAAAAAAAAkCCQUQiLnQWaA": {
"name": "-w-",
"role_group": "donor"
},
"AHE4AAAAAKAAUAFQAVABCC4EUCQgJCAEIDgm0BBwDwAAAAA": {
"name": "white_rabbit",
"role_group": "donor"
},
"AKFwAAAADMAABSiAAgVAoQBQKAA0CwB6AfDhAwIAQQKQkAAkMgCTSkhlGpYBAQJAjAAgEAAQAgB6AUCAABAgAAoUgAIFoFIBqFcAKhWASgXA8wAAAAA": {
"name": "goat",
"role_group": "donor"
},
"ALF1AAAAAAAAABABAAAAAACwAQAAAAAA8AEAAAAAIFABIoABABDwATbAA4gQ9AU-wBPZIPgDKoSx-UD4Az4C9Kmf8AEcUlj5v_ABPvb9-X_wAX7k__A_8AN-_H_gH_AFfuJ_wBiwJf_BP0AIEBkAAAAAAAAAAAAAAAAAAAAAAAAAIgAAAAAAADYAiAAA-AA-ANgAELEBKgD4ALDhI_4HqgDwsSf-H_oIUPlH_C9xBP_BT_gn-YT_wD_8J_rEf-Af_hP8yD_gHwAM-NAf4B8AAvzhP8APAAAAAAAAAA": {
"name": "cats",
"role_group": "donor"
},
"AJhYAAAAGACABACQAAASAEACAMgBAMkBIMkAJCngJAkSIEECIEgABAKAQAAQEAACAiCAAAQQgAAECIAAAfA_AAAA": {
"name": "hand",
"role_group": "donor"
},
"AMFYAAAAAAAAAAzAADgAB_ABPuAH-IE_8Af_4T_8h__wz_zDv_cPAB4AADAAAAAAAIAHAAAeAAD8AADwAwDgHwCAfwAA_wMA8AMAAAAAAAAA": {
"name": "radiation",
"role_group": "donor"
},
"AJhMAAAABACAAQBwAAAeAMAHAPgBAH8A4B8A_AeA_wHwfwD-H8D_A_gHAO8B4DwADA-A4AEAGAAAAAA": {
"name": "cursor",
"role_group": "donor"
},
"AMpkAAAA8DfCnyAQgnTl0KVrvS5d73UJkinIX1V_ABQD8BQ8l5WRRViBLOAEHw7_CtbhfP-ItdU0AmI8wrTwH4DbqPxpVCdIMhJdOL_our9PV681gg6s8BeFHwAAAAA": {
"name": "openfront_qr",
"role_group": "donor"
},
"AAIiAAAAAAAAAAAAAAAAAAAAAIDD8YnweTiiD5FIYEIgEpkIRCKBCoFIpCIQeTwyPB6RjEAkEIgQKEQiApFAIEIgEYkIOAKfCIGIIyIAAAAAAAAAAAA": {
"name": "openfront",
"role_group": "creator"
},
"AJlMAAAAAP8A8D8A9gfA_wD4HwAfAOAfAn5A4A8Y_wf3v8D_B_j_AP4PgP8B4B0AGAEAIQBgDAAAAAA": {
"name": "t_rex",
"role_group": "donor"
},
"AAqFAAACAAAAOAAAAOADAAAAHwAAAPgAAEDABwQAwZxBAAzuDgYw8H9ggAH-AAMO8Ac4OAAfgMMB-AAcDsAH4HgAPgDPB_ABfD6AD-DzAXwAnx_gA_z8gT_w5x_-wz______-f___4____8__P___8H___8H_v__P-D___8A____B_D__x8A__9_APD__wEA_v8DAMD_BwAA-A8AAA": {
"name": "embelem",
"role_group": "donor"
},
"AMo0AAAAAAAAAAAAAIAAJAACEAIIQCAgAAGCAAQgCBCAgEAAAggBCIAEIAAAAAAAAAAAAAAA": {
"name": "contributor"
},
"AMlNAAAAAAAAAAAAAPAfAACAHwDwgAcAAMQf4ADgAAAgwM8BAAwACEPABwDA__8xEACIAAAACAMOgCQAAGEwwAAoAPCAAQGMCCBhAAYIQPwAnwEYgADSB_QEQAAIkD_wJwABgAH8gx8ABAAYwE99ABgAAAcAACBwAADgBDCA-AAAADwQoH8AAAAA_v8DAAAAAAAMAAAAAAAAAAAAAAA": {
"name": "grogu_head",
"role_group": "donor"
},
"AMl9AAAAAAAAAAAAAPAfAACAHwDwgAcAAMQf4ADgAAAgwM8BAAwACEPABwDA__8xEACIAAAACAMOgCQAAGEwwAAoAPCAAQGMCCBhAAYIQPwAnwEYgADSB_QEQAAIkD_wJwABgAH8gx8ABAAYwE99ABgAAAcAACBwAADgBDCA-AAAADwQoH8AAABA_v8DBwAAAAIMACwAAAAwAAAwAQAAAAMAoBgAAAA4COCFAAAAYEbAIAcAAICjgzEbAAAALPwHSQAAAGAjAAoDAAAAJknIDAAAAFBEIj8AAACAOpA4AAAAAHyAwAEAAAAAQQQIAAAAAAAAAAAAAA": {
"name": "grogu",
"role_group": "donor"
}
},
"flag": {
"layers": {
"a": {
"name": "center_circle",
"role_group": "donor"
},
"b": {
"name": "center_hline",
"role_group": "donor"
},
"c": {
"name": "center_vline",
"role_group": "donor"
},
"d": {
"name": "center_star",
"role_group": "donor"
},
"e": {
"name": "center_flower",
"role_group": "donor"
},
"f": {
"name": "flower_tl",
"role_group": "donor"
},
"g": {
"name": "flower_tc",
"role_group": "donor"
},
"h": {
"name": "flower_tr",
"role_group": "donor"
},
"i": {
"name": "diag_br",
"role_group": "donor"
},
"j": {
"name": "diag_bl",
"role_group": "donor"
},
"k": {
"name": "frame"
},
"l": {
"name": "full"
},
"m": {
"name": "triangle_tl",
"role_group": "donor"
},
"n": {
"name": "triangle_bl",
"role_group": "donor"
},
"o": {
"name": "triangle_tr",
"role_group": "donor"
},
"p": {
"name": "triangle_br",
"role_group": "donor"
},
"q": {
"name": "half_l",
"role_group": "donor"
},
"r": {
"name": "half_r",
"role_group": "donor"
},
"s": {
"name": "half_t",
"role_group": "donor"
},
"t": {
"name": "half_b",
"role_group": "donor"
},
"u": {
"name": "mini_tr_bl",
"role_group": "donor"
},
"v": {
"name": "mini_tr_br",
"role_group": "donor"
},
"w": {
"name": "mini_tr_tl",
"role_group": "donor"
},
"x": {
"name": "mini_tr_tr",
"role_group": "donor"
},
"y": {
"name": "triangle_t",
"role_group": "donor"
},
"z": {
"name": "triangle_l",
"role_group": "donor"
},
"aa": {
"name": "triangle_b",
"role_group": "donor"
},
"ab": {
"name": "triangle_r",
"role_group": "donor"
},
"ac": {
"name": "tricolor_l",
"role_group": "donor"
},
"ad": {
"name": "tricolor_c",
"role_group": "donor"
},
"ae": {
"name": "tricolor_r",
"role_group": "donor"
},
"af": {
"name": "tricolor_t",
"role_group": "donor"
},
"ag": {
"name": "tricolor_m",
"role_group": "donor"
},
"ah": {
"name": "tricolor_b",
"role_group": "donor"
},
"ai": {
"name": "nato_emblem",
"role_group": "donor"
},
"aj": {
"name": "eu_star",
"role_group": "donor"
},
"ak": {
"name": "laurel_wreath",
"role_group": "donor"
},
"al": {
"name": "ofm_2025",
"role_group": "donor"
},
"am": {
"name": "octagram",
"role_group": "donor"
},
"an": {
"name": "octagram_2",
"role_group": "donor"
},
"ao": {
"name": "og",
"role_group": "donor"
},
"ap": {
"name": "og_plus",
"role_group": "donor"
},
"aq": {
"name": "beta_tester",
"role_group": "donor"
},
"ar": {
"name": "beta_tester_circle",
"role_group": "donor"
},
"as": {
"name": "rocket",
"role_group": "donor"
},
"at": {
"name": "rocket_mini",
"role_group": "donor"
},
"au": {
"name": "translator",
"role_group": "donor"
},
"av": {
"name": "admin_shield",
"role_group": "donor"
},
"aw": {
"name": "admin_shield_r",
"role_group": "donor"
},
"ax": {
"name": "admin_evan",
"role_group": "donor"
}
},
"color": {
"a": {
"color": "#ff0000",
"name": "red",
"role_group": "donor"
},
"b": {
"color": "#ffa500",
"name": "orange",
"role_group": "donor"
},
"c": {
"color": "#ffff00",
"name": "yellow",
"role_group": "donor"
},
"d": {
"color": "#008000",
"name": "green",
"role_group": "donor"
},
"e": {
"color": "#00ffff",
"name": "cyan",
"role_group": "donor"
},
"f": {
"color": "#0000ff",
"name": "blue",
"role_group": "donor"
},
"g": {
"color": "#000000",
"name": "black",
"role_group": "donor"
},
"h": {
"color": "#ffffff",
"name": "white",
"role_group": "donor"
},
"i": {
"color": "#800080",
"name": "purple",
"role_group": "donor"
},
"j": {
"color": "#ff69b4",
"name": "hotpink",
"role_group": "donor"
},
"k": {
"color": "#a52a2a",
"name": "brown",
"role_group": "donor"
},
"l": {
"color": "#808080",
"name": "gray",
"role_group": "donor"
},
"m": {
"color": "#20b2aa",
"name": "teal",
"role_group": "donor"
},
"n": {
"color": "#ff6347",
"name": "tomato",
"role_group": "donor"
},
"o": {
"color": "#4682b4",
"name": "steelblue",
"role_group": "donor"
},
"p": {
"color": "#90ee90",
"name": "lightgreen",
"role_group": "donor"
},
"q": {
"color": "#8b0000",
"name": "darkred",
"role_group": "donor"
},
"r": {
"color": "#191970",
"name": "navy",
"role_group": "donor"
},
"s": {
"color": "#ffd700",
"name": "gold",
"role_group": "donor"
},
"t": {
"color": "#add8e6",
"name": "lightblue",
"role_group": "donor"
},
"u": {
"color": "#f5f5dc",
"name": "beige",
"role_group": "donor"
},
"v": {
"color": "#ffb6c1",
"name": "lightpink",
"role_group": "donor"
},
"w": {
"color": "#708090",
"name": "slategray",
"role_group": "donor"
},
"x": {
"color": "#00ff7f",
"name": "springgreen",
"role_group": "donor"
},
"y": {
"color": "#dc143c",
"name": "crimson",
"role_group": "donor"
},
"z": {
"color": "#ffbf00",
"name": "amber",
"role_group": "donor"
},
"0": {
"color": "#3d9970",
"name": "olive_green",
"role_group": "donor"
},
"1": {
"color": "#87ceeb",
"name": "sky_blue",
"role_group": "donor"
},
"2": {
"color": "#6a5acd",
"name": "slate_blue",
"role_group": "donor"
},
"3": {
"color": "#ff66cc",
"name": "rose_pink",
"role_group": "donor"
},
"4": {
"color": "#36454f",
"name": "charcoal",
"role_group": "donor"
},
"5": {
"color": "#fffff0",
"name": "ivory",
"role_group": "donor"
},
"A": {
"color": "rainbow",
"name": "rainbow",
"role_group": "donor"
},
"B": {
"color": "bright-rainbow",
"name": "bright_rainbow",
"role_group": "donor"
},
"C": {
"color": "gold-glow",
"name": "gold_glow",
"role_group": "donor"
},
"D": {
"color": "silver-glow",
"name": "silver_glow",
"role_group": "donor"
},
"E": {
"color": "copper-glow",
"name": "copper_glow",
"role_group": "donor"
},
"F": {
"color": "neon",
"name": "neon",
"role_group": "donor"
},
"G": {
"color": "lava",
"name": "lava",
"role_group": "donor"
},
"H": {
"color": "water",
"name": "water",
"role_group": "donor"
}
}
}
}
+10 -3
View File
@@ -928,12 +928,16 @@
"pvp_immunity_active": "PVP immunity active for {seconds}s",
"catching_up": "Catching up..."
},
"store": {
"title": "Store",
"patterns": "Skins",
"flags": "Flags",
"no_flags": "No flags available. Check back later for new items.",
"no_skins": "No skins available. Check back later for new items."
},
"territory_patterns": {
"title": "Skins",
"colors": "Colors",
"purchase": "Purchase",
"show_only_owned": "My Skins",
"all_owned": "All skins owned! Check back later for new items.",
"not_logged_in": "Not logged in",
"pattern": {
"default": "Default"
@@ -941,6 +945,9 @@
"select_skin": "Select Skin",
"selected": "selected"
},
"cosmetics": {
"artist_label": "Artist:"
},
"flag_input": {
"title": "Select Flag",
"button_title": "Pick a flag!",
+1 -1
View File
@@ -91,7 +91,7 @@ export async function getUserMe(): Promise<UserMeResponse | false> {
export async function createCheckoutSession(
priceId: string,
colorPaletteName: string | null,
colorPaletteName?: string,
): Promise<string | false> {
try {
const response = await fetch(
+3
View File
@@ -1,4 +1,5 @@
import { decodeJwt } from "jose";
import { UserSettings } from "src/core/game/UserSettings";
import { z } from "zod";
import { TokenPayload, TokenPayloadSchema } from "../core/ApiSchemas";
import { base64urlToUuid } from "../core/Base64";
@@ -63,6 +64,8 @@ export async function logOut(allSessions: boolean = false): Promise<boolean> {
} finally {
__jwt = null;
localStorage.removeItem(PERSISTENT_ID_KEY);
new UserSettings().clearFlag();
new UserSettings().setSelectedPatternName(undefined);
}
}
+141 -45
View File
@@ -1,9 +1,11 @@
import { assetUrl } from "src/core/AssetUrls";
import { UserMeResponse } from "../core/ApiSchemas";
import {
ColorPalette,
Cosmetics,
CosmeticsSchema,
Flag,
Pattern,
Product,
} from "../core/CosmeticSchemas";
import {
PlayerCosmeticRefs,
@@ -12,34 +14,26 @@ import {
} from "../core/Schemas";
import { UserSettings } from "../core/game/UserSettings";
import { createCheckoutSession, getApiBase, getUserMe } from "./Api";
import { translateText } from "./Utils";
export const TEMP_FLARE_OFFSET = 1 * 60 * 1000; // 1 minute
export async function handlePurchase(
pattern: Pattern,
colorPalette: ColorPalette | null,
) {
if (pattern.product === null) {
alert("This pattern is not available for purchase.");
return;
}
let __cosmetics: Promise<Cosmetics | null> | null = null;
let __cosmeticsHash: string | null = null;
const url = await createCheckoutSession(
pattern.product.priceId,
colorPalette?.name ?? null,
);
export async function handlePurchase(
product: Product,
colorPaletteName?: string,
) {
const url = await createCheckoutSession(product.priceId, colorPaletteName);
if (url === false) {
alert("Failed to create checkout session.");
return;
}
// Redirect to Stripe checkout
window.location.href = url;
}
let __cosmetics: Promise<Cosmetics | null> | null = null;
let __cosmeticsHash: string | null = null;
function simpleHash(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
@@ -80,54 +74,118 @@ export async function fetchCosmetics(): Promise<Cosmetics | null> {
return __cosmetics;
}
export async function resolveFlagUrl(
flagRef: string,
): Promise<string | undefined> {
if (flagRef.startsWith("flag:")) {
const key = flagRef.slice("flag:".length);
const cosmetics = await fetchCosmetics();
const flagData = cosmetics?.flags?.[key];
return flagData?.url;
}
if (flagRef.startsWith("country:")) {
const code = flagRef.slice("country:".length);
return assetUrl(`flags/${code}.svg`);
}
return undefined;
}
export async function getCosmeticsHash(): Promise<string | null> {
await fetchCosmetics();
return __cosmeticsHash;
}
export function cosmeticRelationship(
opts: {
wildcardFlare: string;
requiredFlare: string;
product: Product | null;
affiliateCode: string | null;
itemAffiliateCode: string | null;
},
userMeResponse: UserMeResponse | false,
): "owned" | "purchasable" | "blocked" {
const flares =
userMeResponse === false ? [] : (userMeResponse.player.flares ?? []);
if (flares.includes(opts.wildcardFlare)) {
return "owned";
}
if (flares.includes(opts.requiredFlare)) {
return "owned";
}
if (opts.product === null) {
return "blocked";
}
if (opts.affiliateCode !== opts.itemAffiliateCode) {
return "blocked";
}
return "purchasable";
}
export function patternRelationship(
pattern: Pattern,
colorPalette: { name: string; isArchived?: boolean } | null,
userMeResponse: UserMeResponse | false,
affiliateCode: string | null,
): "owned" | "purchasable" | "blocked" {
const flares =
userMeResponse === false ? [] : (userMeResponse.player.flares ?? []);
if (flares.includes("pattern:*")) {
return "owned";
}
if (colorPalette === null) {
// For backwards compatibility only show non-colored patterns if they are owned.
if (flares.includes(`pattern:${pattern.name}`)) {
const flares =
userMeResponse === false ? [] : (userMeResponse.player.flares ?? []);
if (
flares.includes("pattern:*") ||
flares.includes(`pattern:${pattern.name}`)
) {
return "owned";
}
return "blocked";
}
const requiredFlare = `pattern:${pattern.name}:${colorPalette.name}`;
if (flares.includes(requiredFlare)) {
return "owned";
}
if (pattern.product === null) {
// We don't own it and it's not for sale, so don't show it.
if (colorPalette.isArchived) {
// Check ownership first — if owned, show it even if archived.
const flares =
userMeResponse === false ? [] : (userMeResponse.player.flares ?? []);
if (
flares.includes("pattern:*") ||
flares.includes(`pattern:${pattern.name}:${colorPalette.name}`)
) {
return "owned";
}
return "blocked";
}
if (colorPalette?.isArchived) {
// We don't own the color palette, and it's archived, so don't show it.
return "blocked";
}
return cosmeticRelationship(
{
wildcardFlare: "pattern:*",
requiredFlare: `pattern:${pattern.name}:${colorPalette.name}`,
product: pattern.product,
affiliateCode,
itemAffiliateCode: pattern.affiliateCode,
},
userMeResponse,
);
}
if (affiliateCode !== pattern.affiliateCode) {
// Pattern is for sale, but it's not the right store to show it on.
return "blocked";
}
// Patterns is for sale, and it's the right store to show it on.
return "purchasable";
export function flagRelationship(
flag: Flag,
userMeResponse: UserMeResponse | false,
affiliateCode: string | null,
): "owned" | "purchasable" | "blocked" {
return cosmeticRelationship(
{
wildcardFlare: "flag:*",
requiredFlare: `flag:${flag.name}`,
product: flag.product,
affiliateCode,
itemAffiliateCode: flag.affiliateCode,
},
userMeResponse,
);
}
export async function getPlayerCosmeticsRefs(): Promise<PlayerCosmeticRefs> {
@@ -154,8 +212,34 @@ export async function getPlayerCosmeticsRefs(): Promise<PlayerCosmeticRefs> {
}
}
let flag = userSettings.getFlag();
if (flag?.startsWith("flag:")) {
const key = flag.slice("flag:".length);
const flagData = cosmetics?.flags?.[key];
if (!flagData) {
// Only clear if cosmetics loaded successfully but the key is missing
if (cosmetics) {
flag = null;
}
} else {
const userMe = await getUserMe();
if (!userMe) {
flag = null;
} else {
const flares = userMe.player.flares ?? [];
const hasWildcard = flares.includes("flag:*");
if (!hasWildcard && !flares.includes(`flag:${flagData.name}`)) {
flag = null;
}
}
}
}
if (flag === null) {
userSettings.clearFlag();
}
return {
flag: userSettings.getFlag(),
flag: flag ?? undefined,
color: userSettings.getSelectedColor() ?? undefined,
patternName: pattern?.name ?? undefined,
patternColorPaletteName: pattern?.colorPalette?.name ?? undefined,
@@ -169,7 +253,7 @@ export async function getPlayerCosmetics(): Promise<PlayerCosmetics> {
const result: PlayerCosmetics = {};
if (refs.flag) {
result.flag = refs.flag;
result.flag = await resolveFlagUrl(refs.flag);
}
if (refs.color) {
@@ -202,3 +286,15 @@ export async function getPlayerCosmetics(): Promise<PlayerCosmetics> {
return result;
}
export function translateCosmetic(prefix: string, name: string): string {
const translation = translateText(`${prefix}.${name}`);
if (translation.startsWith(prefix)) {
return name
.split("_")
.filter((word) => word.length > 0)
.map((word) => word[0].toUpperCase() + word.substring(1))
.join(" ");
}
return translation;
}
+30 -56
View File
@@ -1,12 +1,10 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { assetUrl } from "../core/AssetUrls";
import { renderPlayerFlag } from "../core/CustomFlag";
import { FlagSchema } from "../core/Schemas";
import { FlagName } from "../core/Schemas";
import { UserSettings } from "../core/game/UserSettings";
import { resolveFlagUrl } from "./Cosmetics";
import { translateText } from "./Utils";
const flagKey: string = "flag";
@customElement("flag-input")
export class FlagInput extends LitElement {
@state() public flag: string = "";
@@ -15,36 +13,16 @@ export class FlagInput extends LitElement {
public showSelectLabel: boolean = false;
private isDefaultFlagValue(flag: string): boolean {
return !flag || flag === "xx";
return !flag || flag === "xx" || flag === "country:xx";
}
public getCurrentFlag(): string {
return this.flag;
}
private getStoredFlag(): string {
const storedFlag = localStorage.getItem(flagKey);
if (storedFlag) {
return storedFlag;
private updateFlag = (e: CustomEvent) => {
const parsed = FlagName.safeParse(e.detail);
if (!parsed.success) {
console.warn(`error parsing flag ${e.detail.value}, ${parsed.error}`);
}
return "";
}
private dispatchFlagEvent() {
this.dispatchEvent(
new CustomEvent("flag-change", {
detail: { flag: this.flag },
bubbles: true,
composed: true,
}),
);
}
private updateFlag = (ev: Event) => {
const e = ev as CustomEvent<{ flag: string }>;
if (!FlagSchema.safeParse(e.detail.flag).success) return;
if (this.flag !== e.detail.flag) {
this.flag = e.detail.flag;
if (this.flag !== e.detail) {
this.flag = e.detail;
}
};
@@ -61,14 +39,19 @@ export class FlagInput extends LitElement {
connectedCallback() {
super.connectedCallback();
this.flag = this.getStoredFlag();
this.dispatchFlagEvent();
window.addEventListener("flag-change", this.updateFlag as EventListener);
this.flag = new UserSettings().getFlag() ?? "";
window.addEventListener(
"event:user-settings-changed:flag",
this.updateFlag as EventListener,
);
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("flag-change", this.updateFlag as EventListener);
window.removeEventListener(
"event:user-settings-changed:flag",
this.updateFlag as EventListener,
);
}
createRenderRoot() {
@@ -95,7 +78,7 @@ export class FlagInput extends LitElement {
></span>
${showSelect
? html`<span
class="text-[10px] font-medium tracking-wider text-white uppercase leading-none break-words w-full text-center px-1"
class="text-[7px] lg:text-[10px] font-black tracking-wider text-white uppercase leading-tight lg:leading-none w-full text-center px-0.5 lg:px-1"
>
${translateText("flag_input.title")}
</span>`
@@ -104,35 +87,26 @@ export class FlagInput extends LitElement {
`;
}
updated() {
async updated() {
const preview = this.renderRoot.querySelector(
"#flag-preview",
) as HTMLElement;
if (!preview) return;
if (this.showSelectLabel && this.isDefaultFlagValue(this.flag)) {
if (this.isDefaultFlagValue(this.flag)) {
preview.innerHTML = "";
return;
}
preview.innerHTML = "";
if (this.flag?.startsWith("!")) {
renderPlayerFlag(this.flag, preview);
} else {
const img = document.createElement("img");
const fallbackFlagUrl = assetUrl("flags/xx.svg");
img.src = this.flag
? assetUrl(`flags/${this.flag}.svg`)
: fallbackFlagUrl;
img.className = "w-full h-full object-cover pointer-events-none";
img.draggable = false;
img.onerror = () => {
if (!img.src.endsWith(fallbackFlagUrl)) {
img.src = fallbackFlagUrl;
}
};
preview.appendChild(img);
}
const url = await resolveFlagUrl(this.flag);
if (!url) return;
const img = document.createElement("img");
img.src = url;
img.className = "w-full h-full object-cover pointer-events-none";
img.draggable = false;
preview.appendChild(img);
}
}
+90 -50
View File
@@ -1,22 +1,98 @@
import { html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { customElement, state } from "lit/decorators.js";
import Countries from "resources/countries.json" with { type: "json" };
import { assetUrl } from "../core/AssetUrls";
import { UserMeResponse } from "src/core/ApiSchemas";
import { Cosmetics } from "src/core/CosmeticSchemas";
import { UserSettings } from "src/core/game/UserSettings";
import { getUserMe } from "./Api";
import { fetchCosmetics, flagRelationship } from "./Cosmetics";
import { translateText } from "./Utils";
import { BaseModal } from "./components/BaseModal";
import "./components/FlagButton";
import { modalHeader } from "./components/ui/ModalHeader";
@customElement("flag-input-modal")
export class FlagInputModal extends BaseModal {
@query("#flag-input-modal") private modalRef!: HTMLElement;
@state() private search = "";
@state() private cosmetics: Cosmetics | null = null;
@state() private userMe: UserMeResponse | false = false;
public returnTo = "";
updated(changedProperties: Map<string | number | symbol, unknown>) {
super.updated(changedProperties);
}
private renderFlags() {
const userSettings = new UserSettings();
const selectedFlag = userSettings.getFlag() ?? "";
const onSelect = (flagKey: string) => {
this.setFlag(flagKey);
this.close();
};
const cosmeticFlags = Object.entries(this.cosmetics?.flags ?? {})
.filter(([, flag]) => {
if (!this.includedInSearch({ name: flag.name, code: flag.name }))
return false;
return flagRelationship(flag, this.userMe, null) === "owned";
})
.map(
([key, flag]) => html`
<flag-button
.flag=${{
key: `flag:${key}`,
name: flag.name,
url: flag.url,
artist: flag.artist,
}}
.selected=${selectedFlag === `flag:${key}`}
.onSelect=${onSelect}
></flag-button>
`,
);
const noFlag = this.search
? null
: html`
<flag-button
.flag=${{
key: "country:xx",
name: "None",
url: "/flags/xx.svg",
}}
.selected=${selectedFlag === "" || selectedFlag === "country:xx"}
.onSelect=${onSelect}
></flag-button>
`;
const countryFlags = Countries.filter(
(country) =>
country.code !== "xx" &&
!country.restricted &&
this.includedInSearch(country),
).map(
(country) => html`
<flag-button
.flag=${{
key: `country:${country.code}`,
name: country.name,
url: `/flags/${country.code}.svg`,
}}
.selected=${selectedFlag === `country:${country.code}`}
.onSelect=${onSelect}
></flag-button>
`,
);
return html`
<div
class="pt-1 flex flex-wrap gap-1.5 justify-center items-stretch content-start"
>
${noFlag} ${cosmeticFlags} ${countryFlags}
</div>
`;
}
render() {
const content = html`
<div class="${this.modalContainerClass}">
@@ -36,6 +112,7 @@ export class FlagInputModal extends BaseModal {
focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 text-white placeholder-white/30 transition-all"
type="text"
placeholder=${translateText("flag_input.search_flag")}
.value=${this.search}
@change=${this.handleSearch}
@keyup=${this.handleSearch}
/>
@@ -43,43 +120,9 @@ export class FlagInputModal extends BaseModal {
</div>
<div
class="flex-1 overflow-y-auto px-6 pb-6 scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent mr-1"
class="flex-1 overflow-y-auto px-3 pb-3 scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent mr-1"
>
<div class="pt-2 flex flex-wrap justify-center gap-4 min-h-min">
${Countries.filter(
(country) =>
!country.restricted && this.includedInSearch(country),
).map(
(country) => html`
<button
@click=${() => {
this.setFlag(country.code);
this.close();
}}
class="group relative flex flex-col items-center gap-2 p-3 rounded-xl border border-white/5 bg-white/5 hover:bg-white/10 hover:border-white/20 transition-all cursor-pointer
w-[100px] sm:w-[120px]"
>
<img
class="w-full h-auto rounded group-hover:scale-105 transition-transform duration-200 pointer-events-none"
draggable="false"
src=${assetUrl(`flags/${country.code}.svg`)}
loading="lazy"
@error=${(e: Event) => {
const img = e.currentTarget as HTMLImageElement;
const fallback = assetUrl("flags/xx.svg");
if (img.src && !img.src.endsWith(fallback)) {
img.src = fallback;
}
}}
/>
<span
class="text-xs font-bold text-gray-300 group-hover:text-white text-center leading-tight w-full whitespace-normal break-words"
>${country.name}</span
>
</button>
`,
)}
</div>
${this.renderFlags()}
</div>
</div>
`;
@@ -113,21 +156,18 @@ export class FlagInputModal extends BaseModal {
}
private setFlag(flag: string) {
localStorage.setItem("flag", flag);
this.dispatchEvent(
new CustomEvent("flag-change", {
detail: { flag },
bubbles: true,
composed: true,
}),
);
new UserSettings().setFlag(flag);
}
protected onOpen(): void {
// No custom logic needed
protected async onOpen(): Promise<void> {
[this.cosmetics, this.userMe] = await Promise.all([
fetchCosmetics(),
getUserMe().then((r) => r || (false as const)),
]);
}
protected onClose(): void {
this.search = "";
if (this.returnTo) {
const returnEl = document.querySelector(this.returnTo) as any;
if (returnEl?.open) {
+1
View File
@@ -227,6 +227,7 @@ export class LangSelector extends LitElement {
"o-modal",
"o-button",
"territory-patterns-modal",
"store-modal",
"pattern-input",
"fluent-slider",
"news-modal",
+31 -38
View File
@@ -41,6 +41,8 @@ import { initNavigation } from "./Navigation";
import "./NewsModal";
import "./PatternInput";
import "./SinglePlayerModal";
import { StoreModal } from "./Store";
import "./TerritoryPatternsModal";
import { TerritoryPatternsModal } from "./TerritoryPatternsModal";
import { TokenLoginModal } from "./TokenLoginModal";
import {
@@ -246,7 +248,7 @@ class Client {
private joinModal: JoinLobbyModal;
private gameModeSelector: GameModeSelector;
private userSettings: UserSettings = new UserSettings();
private patternsModal: TerritoryPatternsModal;
private storeModal: StoreModal;
private tokenLoginModal: TokenLoginModal;
private matchmakingModal: MatchmakingModal;
@@ -360,30 +362,22 @@ class Client {
});
});
this.patternsModal = document.getElementById(
this.storeModal = document.getElementById("page-item-store") as StoreModal;
if (!this.storeModal || !(this.storeModal instanceof StoreModal)) {
console.warn("Store modal element not found");
}
const patternsModal = document.getElementById(
"territory-patterns-modal",
) as TerritoryPatternsModal;
if (
!this.patternsModal ||
!(this.patternsModal instanceof TerritoryPatternsModal)
) {
console.warn("Territory patterns modal element not found");
if (!patternsModal || !(patternsModal instanceof TerritoryPatternsModal)) {
console.warn("Patterns modal element not found");
}
// Attach listener to any pattern-input component
document.querySelectorAll("pattern-input").forEach((patternInput) => {
patternInput.addEventListener("pattern-input-click", () => {
// Open the Store page which contains the patterns UI
window.showPage?.("page-item-store");
const skinStoreModal = document.getElementById(
"page-item-store",
) as HTMLElement & { open?: (opts: any) => void };
if (skinStoreModal) {
skinStoreModal.classList.remove("hidden");
if (typeof skinStoreModal.open === "function") {
skinStoreModal.open({ showOnlyOwned: true });
}
}
patternsModal.open();
});
});
@@ -392,29 +386,20 @@ class Client {
if (mobilePat) mobilePat.style.display = "none";
}
if (
!this.patternsModal ||
!(this.patternsModal instanceof TerritoryPatternsModal)
) {
console.warn("Territory patterns modal element not found");
if (!this.storeModal || !(this.storeModal instanceof StoreModal)) {
console.warn("Store modal element not found");
}
// We no longer need to manually manage the preview button as PatternInput handles it component-side.
// However, we still want to ensure the modal can be opened.
// The setupPatternInput above handles the click event for the new buttons.
this.patternsModal.refresh();
// Listen for pattern selection to update any other listeners if needed,
// though PatternInput handles its own updates via window event.
this.patternsModal.addEventListener("pattern-selected", () => {
// PatternInput components will update themselves.
});
this.storeModal.refresh();
window.addEventListener("showPage", (e: any) => {
if (typeof e?.detail === "string" && e.detail === "page-play") {
setTimeout(() => {
this.patternsModal.refresh();
this.storeModal.refresh();
}, 50);
}
});
@@ -646,14 +631,20 @@ class Client {
return;
}
const patternName = params.get("cosmetic");
if (!patternName) {
const cosmeticName = params.get("cosmetic");
if (!cosmeticName) {
alert("Something went wrong. Please contact support.");
console.error("purchase-completed but no pattern name");
return;
}
this.userSettings.setSelectedPatternName(patternName);
const setCosmetic = () => {
if (cosmeticName.startsWith("pattern:")) {
this.userSettings.setSelectedPatternName(cosmeticName);
} else if (cosmeticName.startsWith("flag:")) {
this.userSettings.setFlag(cosmeticName);
}
};
const token = params.get("login-token");
if (token) {
@@ -661,12 +652,13 @@ class Client {
window.addEventListener("beforeunload", () => {
// The page reloads after token login, so we need to save the pattern name
// in case it is unset during reload.
this.userSettings.setSelectedPatternName(patternName);
setCosmetic();
});
this.tokenLoginModal.openWithToken(token);
} else {
alertAndStrip(`purchase succeeded: ${patternName}`);
this.patternsModal.refresh();
alertAndStrip(`purchase succeeded: ${cosmeticName}`);
setCosmetic();
this.storeModal.refresh();
}
return;
}
@@ -701,7 +693,7 @@ class Client {
const affiliateCode = decodedHash.replace("#affiliate=", "");
strip();
if (affiliateCode) {
this.patternsModal?.open(affiliateCode);
this.storeModal?.open(affiliateCode);
}
}
if (decodedHash.startsWith("#refresh")) {
@@ -785,6 +777,7 @@ class Client {
"user-setting",
"troubleshooting-modal",
"territory-patterns-modal",
"store-modal",
"language-modal",
"news-modal",
"flag-input-modal",
+12 -6
View File
@@ -46,9 +46,13 @@ export class PatternInput extends LitElement {
this.pattern = cosmetics.pattern ?? null;
if (!this.isConnected) return;
this.isLoading = false;
window.addEventListener("pattern-selected", this._onPatternSelected, {
signal: this._abortController.signal,
});
window.addEventListener(
"event:user-settings-changed:pattern",
this._onPatternSelected,
{
signal: this._abortController.signal,
},
);
}
disconnectedCallback() {
@@ -79,10 +83,10 @@ export class PatternInput extends LitElement {
}
const showSelect = this.showSelectLabel && this.getIsDefaultPattern();
this.style.setProperty("height", "3rem");
this.style.setProperty("height", "2.5rem");
this.style.setProperty(
"width",
showSelect ? "clamp(6.5rem, 28vw, 9.5rem)" : "3rem",
showSelect ? "clamp(3.25rem, 14vw, 4.75rem)" : "2.5rem",
);
}
@@ -136,7 +140,9 @@ export class PatternInput extends LitElement {
</span>
${showSelect
? html`<span
class="text-[10px] font-black text-white uppercase leading-none break-words w-full text-center px-1"
class="${this.adaptiveSize
? "text-[7px] leading-tight px-0.5"
: "text-[10px] leading-none break-words px-1"} font-black text-white uppercase w-full text-center"
>
${translateText("territory_patterns.select_skin")}
</span>`
+328
View File
@@ -0,0 +1,328 @@
import type { TemplateResult } from "lit";
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { UserMeResponse } from "../core/ApiSchemas";
import { ColorPalette, Cosmetics, Pattern } from "../core/CosmeticSchemas";
import { UserSettings } from "../core/game/UserSettings";
import { PlayerPattern } from "../core/Schemas";
import { hasLinkedAccount } from "./Api";
import { BaseModal } from "./components/BaseModal";
import "./components/FlagButton";
import "./components/PatternButton";
import { modalHeader } from "./components/ui/ModalHeader";
import {
fetchCosmetics,
flagRelationship,
getPlayerCosmetics,
handlePurchase,
patternRelationship,
} from "./Cosmetics";
import { translateText } from "./Utils";
@customElement("store-modal")
export class StoreModal extends BaseModal {
@state() private selectedPattern: PlayerPattern | null;
@state() private selectedColor: string | null = null;
@state() private activeTab: "patterns" | "flags" = "patterns";
private cosmetics: Cosmetics | null = null;
private userSettings: UserSettings = new UserSettings();
private isActive = false;
private affiliateCode: string | null = null;
private userMeResponse: UserMeResponse | false = false;
private _onPatternSelected = async () => {
await this.updateFromSettings();
this.refresh();
};
connectedCallback() {
super.connectedCallback();
document.addEventListener(
"userMeResponse",
(event: CustomEvent<UserMeResponse | false>) => {
this.onUserMe(event.detail);
},
);
window.addEventListener(
"event:user-settings-changed:pattern",
this._onPatternSelected,
);
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener(
"event:user-settings-changed:pattern",
this._onPatternSelected,
);
}
private async updateFromSettings() {
const cosmetics = await getPlayerCosmetics();
this.selectedPattern = cosmetics.pattern ?? null;
this.selectedColor = cosmetics.color?.color ?? null;
}
async onUserMe(userMeResponse: UserMeResponse | false) {
this.userMeResponse = userMeResponse;
this.cosmetics = await fetchCosmetics();
await this.updateFromSettings();
this.refresh();
}
private renderHeader(): TemplateResult {
return html`
${modalHeader({
title: translateText("store.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
rightContent: !hasLinkedAccount(this.userMeResponse)
? html`<div class="flex items-center">
${this.renderNotLoggedInWarning()}
</div>`
: undefined,
})}
<div class="flex items-center gap-2 justify-center pt-2">
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "patterns"
? "bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
@click=${() => (this.activeTab = "patterns")}
>
${translateText("store.patterns")}
</button>
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "flags"
? "bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
@click=${() => (this.activeTab = "flags")}
>
${translateText("store.flags")}
</button>
</div>
`;
}
private renderPatternGrid(): TemplateResult {
const buttons: TemplateResult[] = [];
const patterns: (Pattern | null)[] = [
null,
...Object.values(this.cosmetics?.patterns ?? {}),
];
for (const pattern of patterns) {
const colorPalettes = pattern
? [...(pattern.colorPalettes ?? []), null]
: [null];
for (const colorPalette of colorPalettes) {
let rel = "owned";
if (pattern) {
rel = patternRelationship(
pattern,
colorPalette,
this.userMeResponse,
this.affiliateCode,
);
}
if (rel === "blocked" || rel === "owned") {
continue;
}
const isDefaultPattern = pattern === null;
const isSelected =
(isDefaultPattern && this.selectedPattern === null) ||
(!isDefaultPattern &&
this.selectedPattern &&
this.selectedPattern.name === pattern?.name &&
(this.selectedPattern.colorPalette?.name ?? null) ===
(colorPalette?.name ?? null));
buttons.push(html`
<pattern-button
.pattern=${pattern}
.colorPalette=${this.cosmetics?.colorPalettes?.[
colorPalette?.name ?? ""
] ?? null}
.requiresPurchase=${rel === "purchasable"}
.selected=${isSelected}
.onSelect=${(p: PlayerPattern | null) => this.selectPattern(p)}
.onPurchase=${(p: Pattern, cp: ColorPalette | null) =>
handlePurchase(p.product!, cp?.name)}
></pattern-button>
`);
}
}
if (buttons.length === 0) {
return html`<div
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
>
${translateText("store.no_skins")}
</div>`;
}
return html`
<div
class="flex flex-wrap gap-4 p-2 justify-center items-stretch content-start"
>
${buttons}
</div>
`;
}
private renderFlagGrid(): TemplateResult {
const buttons: TemplateResult[] = [];
const flags = Object.entries(this.cosmetics?.flags ?? {});
for (const [key, flag] of flags) {
const rel = flagRelationship(
flag,
this.userMeResponse,
this.affiliateCode,
);
if (rel === "blocked" || rel === "owned") continue;
const selectedFlag = new UserSettings().getFlag() ?? "";
buttons.push(html`
<flag-button
.flag=${{
key: `flag:${key}`,
name: flag.name,
url: flag.url,
product: flag.product,
artist: flag.artist,
}}
.selected=${selectedFlag === `flag:${key}`}
.requiresPurchase=${rel === "purchasable"}
.onPurchase=${() => handlePurchase(flag.product!)}
></flag-button>
`);
}
if (buttons.length === 0) {
return html`<div
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
>
${translateText("store.no_flags")}
</div>`;
}
return html`
<div
class="flex flex-wrap gap-4 p-2 justify-center items-stretch content-start"
>
${buttons}
</div>
`;
}
private renderNotLoggedInWarning(): TemplateResult {
return html`<button
class="px-4 py-2 text-xs font-bold uppercase tracking-wider transition-colors duration-200 rounded-lg bg-red-500/20 text-red-400 border border-red-500/30 cursor-pointer hover:bg-red-500/30"
@click=${() => {
this.close();
window.showPage?.("page-account");
}}
>
${translateText("territory_patterns.not_logged_in")}
</button>`;
}
render() {
if (!this.isActive && !this.inline) return html``;
const content = html`
<div class="${this.modalContainerClass}">
${this.renderHeader()}
<div class="overflow-y-auto pr-2 custom-scrollbar mr-1">
${this.activeTab === "patterns"
? this.renderPatternGrid()
: this.renderFlagGrid()}
</div>
</div>
`;
if (this.inline) {
return content;
}
return html`
<o-modal
id="storeModal"
title="${translateText("store.title")}"
?inline=${this.inline}
?hideHeader=${true}
?hideCloseButton=${true}
>
${content}
</o-modal>
`;
}
public async open(options?: string | { affiliateCode?: string }) {
if (this.isModalOpen) return;
this.isActive = true;
if (typeof options === "string") {
this.affiliateCode = options;
} else if (
options !== null &&
typeof options === "object" &&
!Array.isArray(options)
) {
this.affiliateCode = options.affiliateCode ?? null;
} else {
this.affiliateCode = null;
}
this.cosmetics ??= await fetchCosmetics();
await this.refresh();
super.open();
}
public close() {
this.isActive = false;
this.affiliateCode = null;
super.close();
}
private selectPattern(pattern: PlayerPattern | null) {
this.selectedColor = null;
this.userSettings.setSelectedColor(undefined);
if (pattern === null) {
this.userSettings.setSelectedPatternName(undefined);
} else {
const name =
pattern.colorPalette?.name === undefined
? pattern.name
: `${pattern.name}:${pattern.colorPalette.name}`;
this.userSettings.setSelectedPatternName(`pattern:${name}`);
}
this.selectedPattern = pattern;
this.refresh();
this.showSelectedPopup(pattern);
this.close();
}
private showSelectedPopup(pattern: PlayerPattern | null) {
let skinName = translateText("territory_patterns.pattern.default");
if (pattern && pattern.name) {
skinName = pattern.name
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
if (pattern.colorPalette && pattern.colorPalette.name) {
skinName += ` (${pattern.colorPalette.name})`;
}
}
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message: `${skinName} ${translateText("territory_patterns.selected")}`,
duration: 2000,
},
}),
);
}
public async refresh() {
this.requestUpdate();
}
}
+36 -189
View File
@@ -2,18 +2,16 @@ import type { TemplateResult } from "lit";
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { UserMeResponse } from "../core/ApiSchemas";
import { ColorPalette, Cosmetics, Pattern } from "../core/CosmeticSchemas";
import { Cosmetics, Pattern } from "../core/CosmeticSchemas";
import { UserSettings } from "../core/game/UserSettings";
import { PlayerPattern } from "../core/Schemas";
import { hasLinkedAccount } from "./Api";
import { BaseModal } from "./components/BaseModal";
import "./components/Difficulties";
import "./components/PatternButton";
import { modalHeader } from "./components/ui/ModalHeader";
import {
fetchCosmetics,
getPlayerCosmetics,
handlePurchase,
patternRelationship,
} from "./Cosmetics";
import { translateText } from "./Utils";
@@ -25,17 +23,8 @@ export class TerritoryPatternsModal extends BaseModal {
@state() private selectedPattern: PlayerPattern | null;
@state() private selectedColor: string | null = null;
@state() private activeTab: "patterns" | "colors" = "patterns";
@state() private showOnlyOwned: boolean = false;
private cosmetics: Cosmetics | null = null;
private userSettings: UserSettings = new UserSettings();
private isActive = false;
private affiliateCode: string | null = null;
private userMeResponse: UserMeResponse | false = false;
private _onPatternSelected = async () => {
@@ -43,10 +32,6 @@ export class TerritoryPatternsModal extends BaseModal {
this.refresh();
};
constructor() {
super();
}
connectedCallback() {
super.connectedCallback();
document.addEventListener(
@@ -55,12 +40,18 @@ export class TerritoryPatternsModal extends BaseModal {
this.onUserMe(event.detail);
},
);
window.addEventListener("pattern-selected", this._onPatternSelected);
window.addEventListener(
"event:user-settings-changed:pattern",
this._onPatternSelected,
);
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("pattern-selected", this._onPatternSelected);
window.removeEventListener(
"event:user-settings-changed:pattern",
this._onPatternSelected,
);
}
private async updateFromSettings() {
@@ -76,42 +67,6 @@ export class TerritoryPatternsModal extends BaseModal {
this.refresh();
}
private renderTabNavigation(): TemplateResult {
return html`
${modalHeader({
title: translateText("territory_patterns.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
rightContent: !hasLinkedAccount(this.userMeResponse)
? html`<div class="flex items-center">
${this.renderNotLoggedInWarning()}
</div>`
: undefined,
})}
<!-- TEMP DISABlE TAB SWITCHING
<div class="flex items-center gap-2 justify-center">
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "patterns"
? "bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
@click=${() => (this.activeTab = "patterns")}
>
${translateText("territory_patterns.title")}
</button>
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "colors"
? "bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
@click=${() => (this.activeTab = "colors")}
>
${translateText("territory_patterns.colors")}
</button>
TEMP DISABlE TAB SWITCHING -->
`;
}
private renderPatternGrid(): TemplateResult {
const buttons: TemplateResult[] = [];
const patterns: (Pattern | null)[] = [
@@ -129,19 +84,12 @@ export class TerritoryPatternsModal extends BaseModal {
pattern,
colorPalette,
this.userMeResponse,
this.affiliateCode,
null,
);
}
if (rel === "blocked") {
if (rel !== "owned") {
continue;
}
if (this.showOnlyOwned) {
if (rel !== "owned") continue;
} else {
// Store mode: hide owned items
if (rel === "owned") continue;
}
// Determine if this pattern/color is selected
const isDefaultPattern = pattern === null;
const isSelected =
(isDefaultPattern && this.selectedPattern === null) ||
@@ -156,11 +104,9 @@ export class TerritoryPatternsModal extends BaseModal {
.colorPalette=${this.cosmetics?.colorPalettes?.[
colorPalette?.name ?? ""
] ?? null}
.requiresPurchase=${rel === "purchasable"}
.requiresPurchase=${false}
.selected=${isSelected}
.onSelect=${(p: PlayerPattern | null) => this.selectPattern(p)}
.onPurchase=${(p: Pattern, colorPalette: ColorPalette | null) =>
handlePurchase(p, colorPalette)}
></pattern-button>
`);
}
@@ -168,42 +114,15 @@ export class TerritoryPatternsModal extends BaseModal {
return html`
<div class="flex flex-col">
<div class="pt-4 flex justify-center">
${hasLinkedAccount(this.userMeResponse)
? this.renderMySkinsButton()
: html``}
<div
class="flex flex-wrap gap-4 p-2 justify-center items-stretch content-start"
>
${buttons}
</div>
${!this.showOnlyOwned && buttons.length === 0
? html`<div
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
>
${translateText("territory_patterns.all_owned")}
</div>`
: html`
<div
class="flex flex-wrap gap-4 p-2 justify-center items-stretch content-start"
>
${buttons}
</div>
`}
</div>
`;
}
private renderMySkinsButton(): TemplateResult {
return html`<button
class="px-4 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-wider border mb-4 ${this
.showOnlyOwned
? "bg-blue-500/20 text-blue-400 border-blue-500/50 shadow-[0_0_10px_rgba(59,130,246,0.3)]"
: "bg-white/5 text-white/60 border-white/10 hover:bg-white/10 hover:text-white"}"
@click=${() => {
this.showOnlyOwned = !this.showOnlyOwned;
}}
>
${translateText("territory_patterns.show_only_owned")}
</button>`;
}
private renderNotLoggedInWarning(): TemplateResult {
return html`<button
class="px-4 py-2 text-xs font-bold uppercase tracking-wider transition-colors duration-200 rounded-lg bg-red-500/20 text-red-400 border border-red-500/30 cursor-pointer hover:bg-red-500/30"
@@ -216,44 +135,27 @@ export class TerritoryPatternsModal extends BaseModal {
</button>`;
}
private renderColorSwatchGrid(): TemplateResult {
const hexCodes = (
this.userMeResponse === false
? []
: (this.userMeResponse.player.flares ?? [])
)
.filter((flare) => flare.startsWith("color:"))
.map((flare) => flare.split(":")[1]);
return html`
<div class="flex flex-wrap gap-3 p-2 justify-center items-center">
${hexCodes.map(
(hexCode) => html`
<div
class="w-12 h-12 rounded-xl border-2 border-white/10 cursor-pointer transition-all duration-200 hover:scale-110 hover:shadow-[0_0_15px_rgba(255,255,255,0.3)] hover:border-white relative group"
style="background-color: ${hexCode};"
title="${hexCode}"
@click=${() => this.selectColor(hexCode)}
>
<div
class="absolute inset-0 rounded-xl ring-2 ring-inset ring-black/20"
></div>
</div>
`,
)}
</div>
`;
}
render() {
if (!this.isActive && !this.inline) return html``;
const content = html`
<div class="${this.modalContainerClass}">
${this.renderTabNavigation()}
<div class="overflow-y-auto pr-2 custom-scrollbar mr-1">
${this.activeTab === "patterns"
? this.renderPatternGrid()
: this.renderColorSwatchGrid()}
<div
class="relative flex flex-col border-b border-white/10 pb-4 shrink-0"
>
${modalHeader({
title: translateText("territory_patterns.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
rightContent: !hasLinkedAccount(this.userMeResponse)
? html`<div class="flex items-center">
${this.renderNotLoggedInWarning()}
</div>`
: undefined,
})}
</div>
<div
class="flex-1 overflow-y-auto px-3 pb-3 scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent mr-1"
>
${this.renderPatternGrid()}
</div>
</div>
`;
@@ -265,9 +167,7 @@ export class TerritoryPatternsModal extends BaseModal {
return html`
<o-modal
id="territoryPatternsModal"
title="${this.activeTab === "patterns"
? translateText("territory_patterns.title")
: translateText("territory_patterns.colors")}"
title="${translateText("territory_patterns.title")}"
?inline=${this.inline}
?hideHeader=${true}
?hideCloseButton=${true}
@@ -277,33 +177,8 @@ export class TerritoryPatternsModal extends BaseModal {
`;
}
public async open(
options?: string | { affiliateCode?: string; showOnlyOwned?: boolean },
) {
this.isActive = true;
if (typeof options === "string") {
this.affiliateCode = options;
this.showOnlyOwned = false;
} else if (
options !== null &&
typeof options === "object" &&
!Array.isArray(options)
) {
this.affiliateCode = options.affiliateCode ?? null;
this.showOnlyOwned = options.showOnlyOwned ?? false;
} else {
this.affiliateCode = null;
this.showOnlyOwned = false;
}
protected async onOpen(): Promise<void> {
await this.refresh();
super.open();
}
public close() {
this.isActive = false;
this.affiliateCode = null;
super.close();
}
private selectPattern(pattern: PlayerPattern | null) {
@@ -320,16 +195,11 @@ export class TerritoryPatternsModal extends BaseModal {
}
this.selectedPattern = pattern;
this.refresh();
// Dispatch event so Main.ts can refresh the preview button
this.dispatchEvent(new CustomEvent("pattern-selected", { bubbles: true }));
// Show popup/modal for skin selection
this.showSkinSelectedPopup();
// Close the skin store
this.close();
}
private showSkinSelectedPopup() {
// Use unified heads-up-message for feedback
let skinName = translateText("territory_patterns.pattern.default");
if (this.selectedPattern && this.selectedPattern.name) {
skinName = this.selectedPattern.name
@@ -353,29 +223,6 @@ export class TerritoryPatternsModal extends BaseModal {
);
}
private selectColor(hexCode: string) {
this.selectedPattern = null;
this.userSettings.setSelectedPatternName(undefined);
this.selectedColor = hexCode;
this.userSettings.setSelectedColor(hexCode);
this.refresh();
this.dispatchEvent(new CustomEvent("pattern-selected", { bubbles: true }));
this.close();
}
private renderColorPreview(
hexCode: string,
width: number,
height: number,
): TemplateResult {
return html`
<div
class="w-full h-full rounded"
style="background-color: ${hexCode};"
></div>
`;
}
public async refresh() {
this.requestUpdate();
}
-45
View File
@@ -10,14 +10,8 @@ import "./components/baseComponents/setting/SettingSlider";
import "./components/baseComponents/setting/SettingToggle";
import { BaseModal } from "./components/BaseModal";
import { modalHeader } from "./components/ui/ModalHeader";
import "./FlagInputModal";
import { Platform } from "./Platform";
interface FlagInputModalElement extends HTMLElement {
open(): void;
returnTo?: string;
}
const isMac = Platform.isMac;
const DefaultKeybinds: Record<string, string> = {
@@ -399,16 +393,6 @@ export class UserSettingModal extends BaseModal {
this.userSettings.set("settings.performanceOverlay", enabled);
}
private openFlagSelector = () => {
const flagInputModal =
document.querySelector<FlagInputModalElement>("#flag-input-modal");
if (flagInputModal?.open) {
this.close();
flagInputModal.returnTo = "#" + (this.id || "page-settings");
flagInputModal.open();
}
};
render() {
const activeContent =
this.activeTab === "basic"
@@ -819,35 +803,6 @@ export class UserSettingModal extends BaseModal {
private renderBasicSettings() {
return html`
<!-- 🚩 Flag Selector -->
<div
class="flex flex-row items-center justify-between w-full p-4 bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 transition-all gap-4 cursor-pointer"
role="button"
tabindex="0"
@click=${this.openFlagSelector}
@keydown=${(e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
this.openFlagSelector();
}
}}
>
<div class="flex flex-col flex-1 min-w-0 mr-4">
<div class="text-white font-bold text-base block mb-1">
${translateText("flag_input.title")}
</div>
<div class="text-white/50 text-sm leading-snug">
${translateText("flag_input.button_title")}
</div>
</div>
<div
class="relative inline-block w-12 h-8 shrink-0 rounded overflow-hidden border border-white/20"
>
<flag-input class="w-full h-full pointer-events-none"></flag-input>
</div>
</div>
<!-- 🌙 Dark Mode -->
<setting-toggle
label="${translateText("user_setting.dark_mode_label")}"
+37
View File
@@ -0,0 +1,37 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { translateText } from "../Utils";
@customElement("artist-info")
export class ArtistInfo extends LitElement {
@property({ type: String })
artist?: string;
createRenderRoot() {
return this;
}
render() {
if (!this.artist) {
return nothing;
}
return html`
<div
class="absolute -top-1 -right-1 z-10 group/artist"
@click=${(e: Event) => e.stopPropagation()}
>
<div
class="w-6 h-6 rounded-full bg-white/20 hover:bg-white/40 flex items-center justify-center cursor-help transition-colors duration-150"
>
<span class="text-xs font-bold text-white/70">?</span>
</div>
<div
class="hidden group-hover/artist:block absolute top-7 right-0 bg-zinc-800 text-white text-xs px-2.5 py-1.5 rounded shadow-lg whitespace-nowrap z-20 border border-white/10"
>
${translateText("cosmetics.artist_label")} ${this.artist}
</div>
</div>
`;
}
}
+99
View File
@@ -0,0 +1,99 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Product } from "../../core/CosmeticSchemas";
import { translateCosmetic } from "../Cosmetics";
import "./ArtistInfo";
import "./PurchaseButton";
export interface FlagItem {
key: string;
name: string;
url: string;
product?: Product | null;
artist?: string;
}
@customElement("flag-button")
export class FlagButton extends LitElement {
@property({ type: Boolean })
selected: boolean = false;
@property({ type: Object })
flag!: FlagItem;
@property({ type: Boolean })
requiresPurchase: boolean = false;
@property({ type: Function })
onSelect?: (flagKey: string) => void;
@property({ type: Function })
onPurchase?: () => void;
createRenderRoot() {
return this;
}
private handleClick() {
this.onSelect?.(this.flag.key);
}
render() {
return html`
<div
class="flex flex-col items-center justify-between gap-1 p-1.5 bg-white/5 backdrop-blur-sm border rounded-lg w-36 h-full transition-all duration-200 ${this
.selected
? "border-green-500 shadow-[0_0_15px_rgba(34,197,94,0.5)]"
: "hover:bg-white/10 hover:border-white/20 hover:shadow-xl border-white/10"}"
>
<button
class="group relative flex flex-col items-center w-full gap-1 rounded-lg cursor-pointer transition-all duration-200
disabled:cursor-not-allowed flex-1"
?disabled=${this.requiresPurchase}
@click=${this.handleClick}
>
<artist-info .artist=${this.flag.artist}></artist-info>
<div
class="text-[10px] font-bold text-white uppercase tracking-wider mt-1 ${this
.flag.artist
? "pr-5"
: ""} text-center truncate w-full ${this.requiresPurchase
? "opacity-50"
: ""}"
title="${translateCosmetic("flags", this.flag.name)}"
>
${translateCosmetic("flags", this.flag.name)}
</div>
<div
class="w-full aspect-square flex items-center justify-center bg-white/5 rounded-lg p-2 border border-white/10 group-hover:border-white/20 transition-colors duration-200 overflow-hidden"
>
<img
src=${this.flag.url}
alt=${this.flag.name}
class="w-full h-full object-contain pointer-events-none"
draggable="false"
loading="lazy"
@error=${(e: Event) => {
const img = e.currentTarget as HTMLImageElement;
const fallback = "/flags/xx.svg";
if (img.src && !img.src.endsWith(fallback)) {
img.src = fallback;
}
}}
/>
</div>
</button>
${this.requiresPurchase && this.flag.product
? html`
<purchase-button
.product=${this.flag.product}
.onPurchase=${() => this.onPurchase?.()}
></purchase-button>
`
: null}
</div>
`;
}
}
+16 -31
View File
@@ -9,7 +9,10 @@ import {
} from "../../core/CosmeticSchemas";
import { PatternDecoder } from "../../core/PatternDecoder";
import { PlayerPattern } from "../../core/Schemas";
import { translateCosmetic } from "../Cosmetics";
import { translateText } from "../Utils";
import "./ArtistInfo";
import "./PurchaseButton";
export const BUTTON_WIDTH = 150;
@@ -36,18 +39,6 @@ export class PatternButton extends LitElement {
return this;
}
private translateCosmetic(prefix: string, patternName: string): string {
const translation = translateText(`${prefix}.${patternName}`);
if (translation.startsWith(prefix)) {
return patternName
.split("_")
.filter((word) => word.length > 0)
.map((word) => word[0].toUpperCase() + word.substring(1))
.join(" ");
}
return translation;
}
private handleClick() {
if (this.pattern === null) {
this.onSelect?.(null);
@@ -60,8 +51,7 @@ export class PatternButton extends LitElement {
} satisfies PlayerPattern);
}
private handlePurchase(e: Event) {
e.stopPropagation();
private handlePurchase() {
if (this.pattern?.product) {
this.onPurchase?.(this.pattern, this.colorPalette ?? null);
}
@@ -83,22 +73,25 @@ export class PatternButton extends LitElement {
?disabled=${this.requiresPurchase}
@click=${this.handleClick}
>
<artist-info .artist=${this.pattern?.artist}></artist-info>
<div class="flex flex-col items-center w-full">
<div
class="text-xs font-bold text-white uppercase tracking-wider mb-1 text-center truncate w-full ${this
.requiresPurchase
class="text-xs font-bold text-white uppercase tracking-wider mb-1 ${this
.pattern?.artist
? "pr-5"
: ""} text-center truncate w-full ${this.requiresPurchase
? "opacity-50"
: ""}"
title="${isDefaultPattern
? translateText("territory_patterns.pattern.default")
: this.translateCosmetic(
: translateCosmetic(
"territory_patterns.pattern",
this.pattern!.name,
)}"
>
${isDefaultPattern
? translateText("territory_patterns.pattern.default")
: this.translateCosmetic(
: translateCosmetic(
"territory_patterns.pattern",
this.pattern!.name,
)}
@@ -111,7 +104,7 @@ export class PatternButton extends LitElement {
? "opacity-50"
: ""}"
>
${this.translateCosmetic(
${translateCosmetic(
"territory_patterns.color_palette",
this.colorPalette!.name,
)}
@@ -139,18 +132,10 @@ export class PatternButton extends LitElement {
${this.requiresPurchase && this.pattern?.product
? html`
<div class="w-full mt-2">
<button
class="w-full px-4 py-2 bg-green-500/20 text-green-400 border border-green-500/30 rounded-lg text-xs font-bold uppercase tracking-wider cursor-pointer transition-all duration-200
hover:bg-green-500/30 hover:shadow-[0_0_15px_rgba(74,222,128,0.2)]"
@click=${this.handlePurchase}
>
${translateText("territory_patterns.purchase")}
<span class="ml-1 text-white/60"
>(${this.pattern.product.price})</span
>
</button>
</div>
<purchase-button
.product=${this.pattern.product}
.onPurchase=${() => this.handlePurchase()}
></purchase-button>
`
: null}
</div>
+5
View File
@@ -121,6 +121,11 @@ export class PlayPage extends LitElement {
adaptive-size
class="shrink-0 lg:hidden"
></pattern-input>
<flag-input
id="flag-input-mobile"
show-select-label
class="shrink-0 lg:hidden h-10 w-10"
></flag-input>
</div>
</div>
+37
View File
@@ -0,0 +1,37 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Product } from "../../core/CosmeticSchemas";
import { translateText } from "../Utils";
@customElement("purchase-button")
export class PurchaseButton extends LitElement {
@property({ type: Object })
product!: Product;
@property({ type: Function })
onPurchase?: () => void;
createRenderRoot() {
return this;
}
private handleClick(e: Event) {
e.stopPropagation();
this.onPurchase?.();
}
render() {
return html`
<div class="no-crazygames w-full mt-2">
<button
class="w-full px-4 py-2 bg-green-500/20 text-green-400 border border-green-500/30 rounded-lg text-xs font-bold uppercase tracking-wider cursor-pointer transition-all duration-200
hover:bg-green-500/30 hover:shadow-[0_0_15px_rgba(74,222,128,0.2)]"
@click=${this.handleClick}
>
${translateText("territory_patterns.purchase")}
<span class="ml-1 text-white/60">(${this.product.price})</span>
</button>
</div>
`;
}
}
+7 -15
View File
@@ -1,5 +1,4 @@
import { assetUrl } from "../../../core/AssetUrls";
import { renderPlayerFlag } from "../../../core/CustomFlag";
import { assetUrl } from "src/core/AssetUrls";
import { EventBus } from "../../../core/EventBus";
import { PseudoRandom } from "../../../core/PseudoRandom";
import { Theme } from "../../../core/configuration/Config";
@@ -217,22 +216,15 @@ export class NameLayer implements Layer {
element.classList.add(PLAYER_FLAG);
element.style.opacity = "0.8";
element.style.zIndex = "1";
element.style.aspectRatio = "3/4";
element.style.objectFit = "contain";
};
if (player.cosmetics.flag) {
const flag = player.cosmetics.flag;
if (flag !== undefined && flag !== null && flag.startsWith("!")) {
const flagWrapper = document.createElement("div");
applyFlagStyles(flagWrapper);
renderPlayerFlag(flag, flagWrapper);
nameDiv.appendChild(flagWrapper);
} else if (flag !== undefined && flag !== null) {
const flagImg = document.createElement("img");
applyFlagStyles(flagImg);
flagImg.src = assetUrl(`flags/${flag}.svg`);
nameDiv.appendChild(flagImg);
}
const flag = assetUrl(player.cosmetics.flag);
const flagImg = document.createElement("img");
flagImg.src = flag;
applyFlagStyles(flagImg);
nameDiv.appendChild(flagImg);
}
nameDiv.classList.add(PLAYER_NAME);
nameDiv.style.color = this.theme.textColor(player);
@@ -476,14 +476,8 @@ export class PerformanceOverlay extends LitElement implements Layer {
this.updateTickMetrics(event.tickExecutionDuration, event.tickDelay);
};
private onUserSettingsChanged = (event: Event) => {
const customEvent = event as CustomEvent<{
key?: string;
value?: unknown;
}>;
if (customEvent.detail?.key !== "settings.performanceOverlay") return;
const nextVisible = customEvent.detail.value === true;
private onUserSettingsChanged = (event: CustomEvent) => {
const nextVisible = (event.detail as boolean) === true;
if (this.isVisible === nextVisible) return;
this.setVisible(nextVisible);
};
@@ -511,7 +505,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
if (!this.isUserSettingsListenerAttached) {
globalThis.addEventListener(
"user-settings-changed",
"event:user-settings-changed:settings.performanceOverlay",
this.onUserSettingsChanged,
);
this.isUserSettingsListenerAttached = true;
@@ -523,7 +517,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
if (this.isUserSettingsListenerAttached) {
globalThis.removeEventListener(
"user-settings-changed",
"event:user-settings-changed:settings.performanceOverlay",
this.onUserSettingsChanged,
);
this.isUserSettingsListenerAttached = false;
@@ -1,8 +1,6 @@
import { LitElement, TemplateResult, html } from "lit";
import { ref } from "lit-html/directives/ref.js";
import { customElement, property, state } from "lit/decorators.js";
import { assetUrl } from "../../../core/AssetUrls";
import { renderPlayerFlag } from "../../../core/CustomFlag";
import { EventBus } from "../../../core/EventBus";
import {
PlayerProfile,
@@ -365,21 +363,10 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
)}"
>
${player.cosmetics.flag
? player.cosmetics.flag!.startsWith("!")
? html`<div
class="h-6 aspect-3/4 player-flag"
${ref((el) => {
if (el instanceof HTMLElement) {
requestAnimationFrame(() => {
renderPlayerFlag(player.cosmetics.flag!, el);
});
}
})}
></div>`
: html`<img
class="h-6 aspect-3/4"
src=${assetUrl(`flags/${player.cosmetics.flag!}.svg`)}
/>`
? html`<img
class="h-6 object-contain"
src=${assetUrl(player.cosmetics.flag!)}
/>`
: html``}
<span>${player.displayName()}</span>
${playerTeam !== "" && player.type() !== PlayerType.Bot
+1 -1
View File
@@ -203,7 +203,7 @@ export class WinModal extends LitElement implements Layer {
.requiresPurchase=${true}
.onSelect=${(p: Pattern | null) => {}}
.onPurchase=${(p: Pattern, colorPalette: ColorPalette | null) =>
handlePurchase(p, colorPalette)}
handlePurchase(p.product!, colorPalette?.name)}
></pattern-button>
`,
)}
+8
View File
@@ -44,10 +44,18 @@ export function normalizeAssetPath(path: string): string {
return normalizedPath;
}
function isAbsoluteUrl(path: string): boolean {
return /^https?:\/\//i.test(path);
}
export function buildAssetUrl(
path: string,
assetManifest: AssetManifest = {},
): string {
if (isAbsoluteUrl(path)) {
return path;
}
const normalizedPath = normalizeAssetPath(path);
const directUrl = assetManifest[normalizedPath];
+14 -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({
@@ -62,31 +63,22 @@ export const PatternSchema = z.object({
.optional(),
affiliateCode: z.string().nullable(),
product: ProductSchema.nullable(),
artist: z.string().optional(),
});
export const FlagSchema = z.object({
name: CosmeticNameSchema,
url: z.string(),
affiliateCode: z.string().nullable(),
product: ProductSchema.nullable(),
artist: z.string().optional(),
});
// 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 = {
-81
View File
@@ -1,81 +0,0 @@
import { assetUrl } from "./AssetUrls";
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 = assetUrl(`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 -17
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>;
@@ -296,8 +294,6 @@ export const ID = z.string().regex(GAME_ID_REGEX);
export const AllPlayersStatsSchema = z.record(ID, PlayerStatsSchema);
const countryCodes = countries.filter((c) => !c.restricted).map((c) => c.code);
export const QuickChatKeySchema = z.enum(
Object.entries(quickChatData).flatMap(([category, entries]) =>
entries.map((entry) => `${category}.${entry.key}`),
@@ -483,28 +479,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(),
});
@@ -513,6 +504,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
@@ -671,7 +671,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 {
@@ -200,6 +200,7 @@ export class UserSettings {
} else {
localStorage.setItem(PATTERN_KEY, patternName);
}
this.emitChange("pattern", patternName);
}
getSelectedColor(): string | undefined {
@@ -216,12 +217,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);
}
+30 -8
View File
@@ -9,10 +9,11 @@ import {
skipNonAlphabeticTransformer,
toAsciiLowerCaseTransformer,
} from "obscenity";
import countries from "resources/countries.json";
import { Cosmetics } from "../core/CosmeticSchemas";
import { decodePatternData } from "../core/PatternDecoder";
import {
FlagSchema,
PlayerColor,
PlayerCosmeticRefs,
PlayerCosmetics,
@@ -20,6 +21,8 @@ import {
} from "../core/Schemas";
import { simpleHash } from "../core/Util";
const countryCodes = countries.filter((c) => !c.restricted).map((c) => c.code);
export const shadowNames = [
"UnhuggedToday",
"DaddysLilChamp",
@@ -148,14 +151,11 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
}
}
if (refs.flag) {
const result = FlagSchema.safeParse(refs.flag);
if (!result.success) {
return {
type: "forbidden",
reason: "invalid flag: " + result.error.message,
};
try {
cosmetics.flag = this.isFlagAllowed(flares, refs.flag);
} catch (e) {
return { type: "forbidden", reason: "invalid flag: " + e.message };
}
cosmetics.flag = result.data;
}
return { type: "allowed", cosmetics };
@@ -202,6 +202,28 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
}
}
isFlagAllowed(flares: string[], flagRef: string): string {
if (flagRef.startsWith("flag:")) {
const key = flagRef.slice("flag:".length);
const found = this.cosmetics.flags[key];
if (!found) throw new Error(`Flag ${key} not found`);
if (flares.includes("flag:*") || flares.includes(`flag:${found.name}`)) {
return found.url;
}
throw new Error(`No flares for flag ${key}`);
} else if (flagRef.startsWith("country:")) {
const code = flagRef.slice("country:".length);
if (!countryCodes.includes(code)) {
throw new Error(`invalid country code`);
}
return `/flags/${code}.svg`;
} else {
throw new Error(`invalid flag prefix`);
}
}
isColorAllowed(flares: string[], color: string): PlayerColor {
const allowedColors = flares
.filter((flare) => flare.startsWith("color:"))
+147
View File
@@ -0,0 +1,147 @@
import { cosmeticRelationship } from "../src/client/Cosmetics";
import { UserMeResponse } from "../src/core/ApiSchemas";
const product = { productId: "prod_123", priceId: "price_123", price: "$4.99" };
function makeUserMe(flares: string[]): UserMeResponse {
return {
player: { flares },
} as unknown as UserMeResponse;
}
describe("cosmeticRelationship", () => {
it("returns owned when user has wildcard flare", () => {
expect(
cosmeticRelationship(
{
wildcardFlare: "flag:*",
requiredFlare: "flag:cool",
product,
affiliateCode: null,
itemAffiliateCode: null,
},
makeUserMe(["flag:*"]),
),
).toBe("owned");
});
it("returns owned when user has the specific flare", () => {
expect(
cosmeticRelationship(
{
wildcardFlare: "flag:*",
requiredFlare: "flag:cool",
product,
affiliateCode: null,
itemAffiliateCode: null,
},
makeUserMe(["flag:cool"]),
),
).toBe("owned");
});
it("returns blocked when no product and user does not own it", () => {
expect(
cosmeticRelationship(
{
wildcardFlare: "flag:*",
requiredFlare: "flag:cool",
product: null,
affiliateCode: null,
itemAffiliateCode: null,
},
makeUserMe([]),
),
).toBe("blocked");
});
it("returns blocked when affiliate codes do not match", () => {
expect(
cosmeticRelationship(
{
wildcardFlare: "flag:*",
requiredFlare: "flag:cool",
product,
affiliateCode: "storeA",
itemAffiliateCode: "storeB",
},
makeUserMe([]),
),
).toBe("blocked");
});
it("returns purchasable when product exists and affiliate matches", () => {
expect(
cosmeticRelationship(
{
wildcardFlare: "flag:*",
requiredFlare: "flag:cool",
product,
affiliateCode: null,
itemAffiliateCode: null,
},
makeUserMe([]),
),
).toBe("purchasable");
});
it("returns purchasable when affiliate codes match", () => {
expect(
cosmeticRelationship(
{
wildcardFlare: "pattern:*",
requiredFlare: "pattern:stripes:red",
product,
affiliateCode: "storeA",
itemAffiliateCode: "storeA",
},
makeUserMe([]),
),
).toBe("purchasable");
});
it("returns blocked when user is not logged in and no product", () => {
expect(
cosmeticRelationship(
{
wildcardFlare: "flag:*",
requiredFlare: "flag:cool",
product: null,
affiliateCode: null,
itemAffiliateCode: null,
},
false,
),
).toBe("blocked");
});
it("returns purchasable when user is not logged in but product exists", () => {
expect(
cosmeticRelationship(
{
wildcardFlare: "flag:*",
requiredFlare: "flag:cool",
product,
affiliateCode: null,
itemAffiliateCode: null,
},
false,
),
).toBe("purchasable");
});
it("returns owned when user has wildcard flare for patterns", () => {
expect(
cosmeticRelationship(
{
wildcardFlare: "pattern:*",
requiredFlare: "pattern:stripes:red",
product,
affiliateCode: null,
itemAffiliateCode: null,
},
makeUserMe(["pattern:*"]),
),
).toBe("owned");
});
});
+79 -1
View File
@@ -18,7 +18,7 @@ const bannedWords = [
const matcher = createMatcher(bannedWords);
// Create a minimal PrivilegeCheckerImpl for testing censor
const mockCosmetics = { patterns: {}, colorPalettes: {} };
const mockCosmetics = { patterns: {}, colorPalettes: {}, flags: {} };
const mockDecoder = () => new Uint8Array();
const checker = new PrivilegeCheckerImpl(
mockCosmetics,
@@ -27,6 +27,24 @@ const checker = new PrivilegeCheckerImpl(
);
const emptyChecker = new PrivilegeCheckerImpl(mockCosmetics, mockDecoder, []);
const flagCosmetics = {
patterns: {},
colorPalettes: {},
flags: {
cool_flag: {
name: "cool_flag",
url: "https://example.com/cool.png",
affiliateCode: null,
product: { productId: "prod_1", priceId: "price_1", price: "$4.99" },
},
},
};
const flagChecker = new PrivilegeCheckerImpl(
flagCosmetics,
mockDecoder,
bannedWords,
);
describe("UsernameCensor", () => {
describe("isProfane (via matcher.hasMatch)", () => {
test("detects exact banned words", () => {
@@ -154,3 +172,63 @@ describe("UsernameCensor", () => {
});
});
});
describe("Flag validation in isAllowed", () => {
test("allows valid country flag and resolves to SVG path", () => {
const result = flagChecker.isAllowed([], { flag: "country:us" });
expect(result.type).toBe("allowed");
if (result.type === "allowed") {
expect(result.cosmetics.flag).toBe("/flags/us.svg");
}
});
test("rejects invalid country code", () => {
const result = flagChecker.isAllowed([], { flag: "country:zzzz" });
expect(result.type).toBe("forbidden");
});
test("rejects flag with no prefix", () => {
const result = flagChecker.isAllowed([], { flag: "us" });
expect(result.type).toBe("forbidden");
});
test("allows cosmetic flag when user has wildcard flare", () => {
const result = flagChecker.isAllowed(["flag:*"], {
flag: "flag:cool_flag",
});
expect(result.type).toBe("allowed");
if (result.type === "allowed") {
expect(result.cosmetics.flag).toBe("https://example.com/cool.png");
}
});
test("allows cosmetic flag when user has specific flare", () => {
const result = flagChecker.isAllowed(["flag:cool_flag"], {
flag: "flag:cool_flag",
});
expect(result.type).toBe("allowed");
if (result.type === "allowed") {
expect(result.cosmetics.flag).toBe("https://example.com/cool.png");
}
});
test("rejects cosmetic flag when user lacks flare", () => {
const result = flagChecker.isAllowed([], { flag: "flag:cool_flag" });
expect(result.type).toBe("forbidden");
});
test("rejects cosmetic flag that does not exist", () => {
const result = flagChecker.isAllowed(["flag:*"], {
flag: "flag:nonexistent",
});
expect(result.type).toBe("forbidden");
});
test("allows no flag", () => {
const result = flagChecker.isAllowed([], {});
expect(result.type).toBe("allowed");
if (result.type === "allowed") {
expect(result.cosmetics.flag).toBeUndefined();
}
});
});