Files
OpenFrontIO/src/client/Cosmetics.ts
T
Evan bd9ef9a317 feat: effects cosmetic category (transport-ship trail) + UI (#4418)
## What

Adds a new **`effects`** cosmetic category alongside `skins`/`flags`.
Each effect is discriminated by **`effectType`** (only
`transportShipTrail` today), whose visual config lives in
**`attributes`** (`solid` / `rainbow` / `pulse` / `gradient`). Schema
matches the production cosmetics.json shape exactly (incl. the `url`
field).

**This PR is UI + taxonomy only — the in-game WebGL trail rendering is
intentionally deferred.**

## UI

- **Store** gains an **"Effects"** tab.
- **Home page** gains an **"Effects"** button opening a picker modal.
- Both render effects **grouped by `effectType` with a sub-header per
type**, via a shared `<effects-grid>` Lit element (`mode="select"` for
the picker, `mode="purchase"` for the store). The picker shows owned
effects + a Default tile and persists per-type; the store shows
purchasable effects.

## Data flow

- Ownership via `effect:*` / `effect:<name>` flares (reuses
`cosmeticRelationship`).
- Selection is a per-`effectType` map persisted in UserSettings
(`settings.effects`).
- Server validates in `isEffectAllowed`, wired into `isAllowed`.
- `getPlayerCosmeticsRefs` / `getPlayerCosmetics` resolve effects the
same way as skins/flags (kept-on-fetch-failure, server is authority).

## Tests

- `tsc --noEmit`, ESLint, Prettier clean; full suite green.
- New: `CosmeticSchemas` parse tests (incl. parsing the **real**
`read_transport_trail` entry), `UserSettings` per-type selection, and
`Privilege` effect validation.

## Notes / follow-ups

- The effect's display label shows **"Boat Trail"** for the
`transportShipTrail` type (friendlier than the id).
- Closed-source API gap: `/shop/purchase` (`purchaseWithCurrency`) needs
to learn `"effect"` for **currency** purchase of effects; the
**dollar/product** purchase path already works. Client types were
widened accordingly.
- In-game wake rendering can be ported from #4416.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 13:13:48 -07:00

735 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { assetUrl } from "src/core/AssetUrls";
import { UserMeResponse } from "../core/ApiSchemas";
import {
ColorPalette,
Cosmetics,
CosmeticsSchema,
Effect,
EffectType,
findEffect,
Flag,
Pack,
Pattern,
Product,
Skin,
Subscription,
} from "../core/CosmeticSchemas";
import {
PlayerCosmeticRefs,
PlayerCosmetics,
PlayerEffect,
PlayerPattern,
} from "../core/Schemas";
import { UserSettings } from "../core/game/UserSettings";
import {
changeSubscriptionTier,
createCheckoutSession,
getApiBase,
getUserMe,
invalidateUserMe,
purchaseWithCurrency,
} from "./Api";
import { translateText } from "./Utils";
export const TEMP_FLARE_OFFSET = 1 * 60 * 1000; // 1 minute
// Subscriptions are not ready yet — flip to true to show them in the store
// and on the account/profile modal.
export const SUBSCRIPTIONS_ENABLED = false;
let __cosmetics: Promise<Cosmetics | null> | null = null;
let __cosmeticsHash: string | null = null;
let __cosmeticsCache: Cosmetics | null = null;
/**
* Synchronous accessor for the most recently resolved cosmetics. Returns null
* before the first successful `fetchCosmetics()` call. Useful when a code path
* cannot await (e.g. WebGL per-frame sync).
*/
export function getCachedCosmetics(): Cosmetics | null {
return __cosmeticsCache;
}
/**
* Resolve the local player's selected skin from UserSettings + cached
* cosmetics. Returns null if no skin is selected, cosmetics aren't loaded,
* or the saved skin no longer exists.
*/
export function getLocalSelectedSkin(): { name: string; url: string } | null {
const skinName = new UserSettings().getSelectedSkinName();
if (!skinName) return null;
const skin = __cosmeticsCache?.skins?.[skinName];
if (!skin) return null;
return { name: skin.name, url: skin.url };
}
export type PaymentMethod = "dollar" | "hard" | "soft";
export async function purchaseCosmetic(
resolved: ResolvedCosmetic,
method: PaymentMethod,
): Promise<void> {
if (!resolved.cosmetic) return;
const c = resolved.cosmetic;
const colorPaletteName = resolved.colorPalette?.name;
if (resolved.type === "subscription") {
const sub = c as Subscription;
const userMe = await getUserMe();
const currentSub =
userMe === false ? null : (userMe.player.subscription ?? null);
if (currentSub) {
if (currentSub.tier === sub.name) {
alert(translateText("store.already_subscribed"));
return;
}
// Direction-aware confirm based on priceMonthly. We don't have the
// server's sortOrder client-side — priceMonthly is a good proxy.
const currentCosmetic =
(await fetchCosmetics())?.subscriptions?.[currentSub.tier] ?? null;
const isUpgrade =
currentCosmetic !== null
? sub.priceMonthly > currentCosmetic.priceMonthly
: true;
const targetName = translateCosmetic("subscriptions", sub.name);
const confirmKey = isUpgrade
? "store.confirm_upgrade"
: "store.confirm_downgrade";
const confirmed = window.confirm(
translateText(confirmKey, { tier: targetName }),
);
if (!confirmed) return;
const ok = await changeSubscriptionTier(sub.name);
if (!ok) {
alert(translateText("store.change_tier_failed"));
return;
}
alert(translateText("store.change_tier_success", { tier: targetName }));
window.location.reload();
return;
}
}
if (method === "dollar") {
if (!c.product) {
alert(translateText("store.checkout_failed"));
return;
}
const url = await createCheckoutSession(
c.product.priceId,
colorPaletteName,
);
if (url === false) {
alert(translateText("store.checkout_failed"));
return;
}
window.location.href = url;
return;
}
// Currency purchase (hard or soft) — not valid for subscriptions.
if (resolved.type === "subscription") {
console.error(
"purchaseCosmetic: currency purchase not supported for subscriptions",
);
return;
}
// ResolvedCosmetic isn't a discriminated union, so the guard above doesn't
// narrow cosmetic's type. Subscriptions are excluded by the runtime check.
const priced = c as Pattern | Flag | Pack;
const price =
method === "hard" ? (priced.priceHard ?? 0) : (priced.priceSoft ?? 0);
const userMe = await getUserMe();
if (userMe === false) {
alert(translateText("store.login_required"));
return;
}
const balance =
method === "hard"
? (userMe.player.currency?.hard ?? 0)
: (userMe.player.currency?.soft ?? 0);
if (balance < price) {
alert(translateText("store.not_enough_currency"));
if (method === "hard") {
// Send the user to the packs tab so they can top up plutonium.
window.location.hash = "#modal=store&tab=packs";
}
return;
}
const cosmeticType = resolved.type as "pattern" | "skin" | "flag" | "effect";
const success = await purchaseWithCurrency(
cosmeticType,
c.name,
method,
colorPaletteName,
);
if (!success) {
alert(translateText("store.purchase_failed"));
return;
}
alert(translateText("store.purchase_success", { name: c.name }));
invalidateUserMe();
window.location.reload();
}
function simpleHash(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
return hash.toString(36);
}
export async function fetchCosmetics(): Promise<Cosmetics | null> {
if (__cosmetics !== null) {
return __cosmetics;
}
__cosmetics = (async () => {
try {
const response = await fetch(`${getApiBase()}/cosmetics.json`);
if (!response.ok) {
console.error(`HTTP error! status: ${response.status}`);
return null;
}
const result = CosmeticsSchema.safeParse(await response.json());
if (!result.success) {
console.error(`Invalid cosmetics: ${result.error.message}`);
return null;
}
const patternKeys = Object.keys(result.data.patterns).sort();
const hashInput = patternKeys
.map((k) => k + (result.data.patterns[k].product ? "sale" : ""))
.join(",");
__cosmeticsHash = simpleHash(hashInput);
__cosmeticsCache = result.data;
return result.data;
} catch (error) {
console.error("Error getting cosmetics:", error);
return 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;
priceSoft?: number;
priceHard?: number;
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.affiliateCode !== opts.itemAffiliateCode) {
return "blocked";
}
// Purchasable if any purchase method is available
if (opts.priceSoft !== undefined || opts.priceHard !== undefined) {
return "purchasable";
}
if (opts.product === null) {
return "blocked";
}
return "purchasable";
}
export function patternRelationship(
pattern: Pattern,
colorPalette: { name: string; isArchived?: boolean } | null,
userMeResponse: UserMeResponse | false,
affiliateCode: string | null,
): "owned" | "purchasable" | "blocked" {
if (colorPalette === null) {
// For backwards compatibility only show non-colored patterns if they are owned.
const flares =
userMeResponse === false ? [] : (userMeResponse.player.flares ?? []);
if (
flares.includes("pattern:*") ||
flares.includes(`pattern:${pattern.name}`)
) {
return "owned";
}
return "blocked";
}
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";
}
return cosmeticRelationship(
{
wildcardFlare: "pattern:*",
requiredFlare: `pattern:${pattern.name}:${colorPalette.name}`,
product: pattern.product,
priceSoft: pattern.priceSoft,
priceHard: pattern.priceHard,
affiliateCode,
itemAffiliateCode: pattern.affiliateCode ?? null,
},
userMeResponse,
);
}
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,
priceSoft: flag.priceSoft,
priceHard: flag.priceHard,
affiliateCode,
itemAffiliateCode: flag.affiliateCode ?? null,
},
userMeResponse,
);
}
export function skinRelationship(
skin: Skin,
userMeResponse: UserMeResponse | false,
affiliateCode: string | null,
): "owned" | "purchasable" | "blocked" {
return cosmeticRelationship(
{
wildcardFlare: "skin:*",
requiredFlare: `skin:${skin.name}`,
product: skin.product,
priceSoft: skin.priceSoft,
priceHard: skin.priceHard,
affiliateCode,
itemAffiliateCode: skin.affiliateCode ?? null,
},
userMeResponse,
);
}
export function effectRelationship(
effect: Effect,
userMeResponse: UserMeResponse | false,
affiliateCode: string | null,
): "owned" | "purchasable" | "blocked" {
return cosmeticRelationship(
{
wildcardFlare: "effect:*",
requiredFlare: `effect:${effect.name}`,
product: effect.product,
priceSoft: effect.priceSoft,
priceHard: effect.priceHard,
affiliateCode,
itemAffiliateCode: effect.affiliateCode ?? null,
},
userMeResponse,
);
}
export type ResolvedCosmetic = {
type: "pattern" | "skin" | "flag" | "effect" | "pack" | "subscription";
cosmetic: Pattern | Skin | Flag | Effect | Pack | Subscription | null;
colorPalette: ColorPalette | null;
relationship: "owned" | "purchasable" | "blocked";
/** Unique key for selection/identity, e.g. "pattern:hearts:red" or "skin:mountain" */
key: string;
/** For effects only: the effectType (also the catalog's outer key). */
effectType?: string;
};
/**
* Resolves all cosmetics into a flat display-ready list with relationship
* status and resolved color palettes. Callers can filter by relationship.
*/
export function resolveCosmetics(
cosmetics: Cosmetics | null,
userMeResponse: UserMeResponse | false,
affiliateCode: string | null,
): ResolvedCosmetic[] {
if (!cosmetics) return [];
const result: ResolvedCosmetic[] = [];
// Default pattern (always owned)
result.push({
type: "pattern",
cosmetic: null,
colorPalette: null,
relationship: "owned",
key: "pattern:default",
});
// Patterns × color palettes
for (const [patternKey, pattern] of Object.entries(cosmetics.patterns)) {
const colorPalettes = [...(pattern.colorPalettes ?? []), null];
for (const cp of colorPalettes) {
const rel = patternRelationship(
pattern,
cp,
userMeResponse,
affiliateCode,
);
const resolvedPalette = cp
? (cosmetics.colorPalettes?.[cp.name] ?? null)
: null;
const key = cp
? `pattern:${patternKey}:${cp.name}`
: `pattern:${patternKey}`;
result.push({
type: "pattern",
cosmetic: pattern,
colorPalette: resolvedPalette,
relationship: rel,
key,
});
}
}
// Flags
for (const [flagKey, flag] of Object.entries(cosmetics.flags)) {
const rel = flagRelationship(flag, userMeResponse, affiliateCode);
result.push({
type: "flag",
cosmetic: flag,
colorPalette: null,
relationship: rel,
key: `flag:${flagKey}`,
});
}
// Skins (image-based territory cosmetics). No separate "default" entry —
// the pattern default doubles as "no skin": selecting it clears both.
for (const [skinKey, skin] of Object.entries(cosmetics.skins ?? {})) {
const rel = skinRelationship(skin, userMeResponse, affiliateCode);
result.push({
type: "skin",
cosmetic: skin,
colorPalette: null,
relationship: rel,
key: `skin:${skinKey}`,
});
}
// Effects (boat-trail wakes, etc.) — a cosmetic category like skins/flags.
// Catalog is nested: effects[effectType][effectName]. We carry effectType (the
// outer key, which each effect also stores) on the resolved item.
for (const [effectType, byName] of Object.entries(cosmetics.effects ?? {})) {
for (const [effectKey, effect] of Object.entries(byName ?? {})) {
const rel = effectRelationship(effect, userMeResponse, affiliateCode);
result.push({
type: "effect",
cosmetic: effect,
colorPalette: null,
relationship: rel,
key: `effect:${effectType}:${effectKey}`,
effectType,
});
}
}
// Packs
for (const [packKey, pack] of Object.entries(cosmetics.currencyPacks ?? {})) {
const rel = pack.product ? "purchasable" : "blocked";
result.push({
type: "pack",
cosmetic: pack,
colorPalette: null,
relationship: rel,
key: `pack:${packKey}`,
});
}
// Subscriptions
const flares =
userMeResponse === false ? [] : (userMeResponse.player.flares ?? []);
const currentSubTier =
userMeResponse === false
? null
: (userMeResponse.player.subscription?.tier ?? null);
for (const [subKey, sub] of Object.entries(cosmetics.subscriptions ?? {})) {
const key = `subscription:${subKey}`;
const isCurrent = subKey === currentSubTier || flares.includes(key);
const rel: ResolvedCosmetic["relationship"] = isCurrent
? "owned"
: sub.product
? "purchasable"
: "blocked";
result.push({
type: "subscription",
cosmetic: sub,
colorPalette: null,
relationship: rel,
key,
});
}
return result;
}
/**
* Groups resolved cosmetics so that colour-palette variants of the same pattern
* collapse into a single entry. Returns an array of groups in first-seen order
*/
export function groupCosmeticVariants(
items: ResolvedCosmetic[],
): ResolvedCosmetic[][] {
const groups: ResolvedCosmetic[][] = [];
const patternGroupByName = new Map<string, number>();
for (const item of items) {
if (item.type === "pattern" && item.cosmetic !== null) {
const name = item.cosmetic.name;
const existing = patternGroupByName.get(name);
if (existing !== undefined) {
groups[existing].push(item);
continue;
}
patternGroupByName.set(name, groups.length);
}
groups.push([item]);
}
return groups;
}
export function resolvedToPlayerPattern(
resolved: ResolvedCosmetic,
): PlayerPattern | null {
if (resolved.type !== "pattern") return null;
const c = resolved.cosmetic;
if (c === null) return null;
return {
name: c.name,
patternData: (c as Pattern).pattern,
colorPalette: resolved.colorPalette ?? undefined,
};
}
export async function getPlayerCosmeticsRefs(): Promise<PlayerCosmeticRefs> {
const userSettings = new UserSettings();
const cosmetics = await fetchCosmetics();
let pattern: PlayerPattern | null =
userSettings.getSelectedPatternName(cosmetics);
if (pattern) {
const userMe = await getUserMe();
if (userMe) {
const flareName =
pattern.colorPalette?.name === undefined
? `pattern:${pattern.name}`
: `pattern:${pattern.name}:${pattern.colorPalette.name}`;
const flares = userMe.player.flares ?? [];
const hasWildcard = flares.includes("pattern:*");
if (!hasWildcard && !flares.includes(flareName)) {
pattern = null;
}
}
if (pattern === null) {
userSettings.setSelectedPatternName(undefined);
}
}
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();
}
let skinName = userSettings.getSelectedSkinName() ?? undefined;
if (skinName) {
const skin = cosmetics?.skins?.[skinName];
if (cosmetics && !skin) {
// Cosmetics loaded but the saved skin no longer exists.
skinName = undefined;
} else if (skin) {
const userMe = await getUserMe();
if (userMe) {
const flares = userMe.player.flares ?? [];
const hasWildcard = flares.includes("skin:*");
if (!hasWildcard && !flares.includes(`skin:${skin.name}`)) {
skinName = undefined;
}
}
}
if (skinName === undefined) {
userSettings.setSelectedPatternName(undefined);
}
}
// Effects: a per-effectType map (effectType -> effect name). Drop any entry
// whose effect no longer exists or the user can't access. Like
// skins/flags/patterns above, a selection is kept (and left to the server to
// validate) when cosmetics or userMe fail to load.
const selectedEffects = userSettings.getSelectedEffects();
const effects: Record<string, string> = {};
for (const [effectType, name] of Object.entries(selectedEffects)) {
const effect = findEffect(cosmetics, effectType, name);
if (cosmetics && !effect) {
userSettings.setSelectedEffectName(effectType as EffectType, undefined);
continue;
}
if (effect) {
const userMe = await getUserMe();
if (userMe) {
const flares = userMe.player.flares ?? [];
const hasWildcard = flares.includes("effect:*");
if (!hasWildcard && !flares.includes(`effect:${effect.name}`)) {
userSettings.setSelectedEffectName(
effectType as EffectType,
undefined,
);
continue;
}
}
}
effects[effectType] = name;
}
return {
flag: flag ?? undefined,
patternName: pattern?.name ?? undefined,
patternColorPaletteName: pattern?.colorPalette?.name ?? undefined,
skinName,
effects: Object.keys(effects).length > 0 ? effects : undefined,
};
}
export async function getPlayerCosmetics(): Promise<PlayerCosmetics> {
const refs = await getPlayerCosmeticsRefs();
const cosmetics = await fetchCosmetics();
const result: PlayerCosmetics = {};
if (refs.flag) {
result.flag = await resolveFlagUrl(refs.flag);
}
const devPattern = new UserSettings().getDevOnlyPattern();
if (devPattern) {
result.pattern = {
name: devPattern.name,
patternData: devPattern.patternData,
colorPalette: devPattern.colorPalette,
};
} else if (refs.patternName && cosmetics) {
const pattern = cosmetics.patterns[refs.patternName];
if (pattern) {
result.pattern = {
name: refs.patternName,
patternData: pattern.pattern,
colorPalette: refs.patternColorPaletteName
? cosmetics.colorPalettes?.[refs.patternColorPaletteName]
: undefined,
};
}
}
if (refs.skinName && cosmetics) {
const skin = cosmetics.skins?.[refs.skinName];
if (skin) {
result.skin = { name: refs.skinName, url: skin.url };
}
}
if (refs.effects && cosmetics) {
const effects: Record<string, PlayerEffect> = {};
for (const [effectType, name] of Object.entries(refs.effects)) {
const effect = findEffect(cosmetics, effectType, name);
if (effect) {
effects[effectType] = {
name: effect.name,
effectType: effect.effectType,
};
}
}
if (Object.keys(effects).length > 0) result.effects = effects;
}
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;
}