mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-05 12:52:05 +00:00
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>
This commit is contained in:
@@ -272,6 +272,11 @@
|
||||
inline
|
||||
class="hidden w-full h-full page-content relative z-50"
|
||||
></territory-patterns-modal>
|
||||
<effects-modal
|
||||
id="effects-modal"
|
||||
inline
|
||||
class="hidden w-full h-full page-content relative z-50"
|
||||
></effects-modal>
|
||||
<ranked-modal
|
||||
id="page-ranked"
|
||||
inline
|
||||
|
||||
+11
-2
@@ -392,6 +392,13 @@
|
||||
"discord_user_header": {
|
||||
"avatar_alt": "Avatar"
|
||||
},
|
||||
"effects": {
|
||||
"button_title": "Pick an effect!",
|
||||
"title": "Effects",
|
||||
"type": {
|
||||
"transportShipTrail": "Boat Trail"
|
||||
}
|
||||
},
|
||||
"error_modal": {
|
||||
"connection_error": "Connection error!",
|
||||
"copied": "Copied!",
|
||||
@@ -450,7 +457,7 @@
|
||||
"flag_input": {
|
||||
"button_title": "Pick a flag!",
|
||||
"search_flag": "Search...",
|
||||
"title": "Select Flag"
|
||||
"title": "Flag"
|
||||
},
|
||||
"friends": {
|
||||
"accept": "Accept",
|
||||
@@ -1255,8 +1262,10 @@
|
||||
"confirm_downgrade": "Downgrade to {tier}? You'll get account credit for the unused portion of your current plan.",
|
||||
"confirm_upgrade": "Upgrade to {tier}? You'll be charged the prorated difference now.",
|
||||
"currency_pack_purchase_success": "Currency pack purchase successful!",
|
||||
"effects": "Effects",
|
||||
"flags": "Flags",
|
||||
"login_required": "You must be logged in to purchase with currency.",
|
||||
"no_effects": "No effects available. Check back later for new items.",
|
||||
"no_flags": "No flags available. Check back later for new items.",
|
||||
"no_packs": "No packs available. Check back later for new items.",
|
||||
"no_skins": "No skins available. Check back later for new items.",
|
||||
@@ -1290,7 +1299,7 @@
|
||||
"default": "Default"
|
||||
},
|
||||
"search": "Search...",
|
||||
"select_skin": "Select Skin",
|
||||
"select_skin": "Skin",
|
||||
"selected": "selected",
|
||||
"title": "Skins"
|
||||
},
|
||||
|
||||
+1
-1
@@ -95,7 +95,7 @@ export function invalidateUserMe() {
|
||||
}
|
||||
|
||||
export async function purchaseWithCurrency(
|
||||
cosmeticType: "pattern" | "skin" | "flag",
|
||||
cosmeticType: "pattern" | "skin" | "flag" | "effect",
|
||||
cosmeticName: string,
|
||||
currencyType: "hard" | "soft",
|
||||
colorPaletteName?: string,
|
||||
|
||||
+89
-3
@@ -4,6 +4,9 @@ import {
|
||||
ColorPalette,
|
||||
Cosmetics,
|
||||
CosmeticsSchema,
|
||||
Effect,
|
||||
EffectType,
|
||||
findEffect,
|
||||
Flag,
|
||||
Pack,
|
||||
Pattern,
|
||||
@@ -14,6 +17,7 @@ import {
|
||||
import {
|
||||
PlayerCosmeticRefs,
|
||||
PlayerCosmetics,
|
||||
PlayerEffect,
|
||||
PlayerPattern,
|
||||
} from "../core/Schemas";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
@@ -156,7 +160,7 @@ export async function purchaseCosmetic(
|
||||
return;
|
||||
}
|
||||
|
||||
const cosmeticType = resolved.type as "pattern" | "skin" | "flag";
|
||||
const cosmeticType = resolved.type as "pattern" | "skin" | "flag" | "effect";
|
||||
const success = await purchaseWithCurrency(
|
||||
cosmeticType,
|
||||
c.name,
|
||||
@@ -357,13 +361,34 @@ export function skinRelationship(
|
||||
);
|
||||
}
|
||||
|
||||
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" | "pack" | "subscription";
|
||||
cosmetic: Pattern | Skin | Flag | Pack | Subscription | null;
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -438,6 +463,23 @@ export function resolveCosmetics(
|
||||
});
|
||||
}
|
||||
|
||||
// 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";
|
||||
@@ -585,11 +627,41 @@ export async function getPlayerCosmeticsRefs(): Promise<PlayerCosmeticRefs> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -632,6 +704,20 @@ export async function getPlayerCosmetics(): Promise<PlayerCosmetics> {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import {
|
||||
findEffect,
|
||||
TransportShipTrailAttributes,
|
||||
} from "../core/CosmeticSchemas";
|
||||
import {
|
||||
EFFECTS_KEY,
|
||||
USER_SETTINGS_CHANGED_EVENT,
|
||||
} from "../core/game/UserSettings";
|
||||
import { renderTransportShipTrailSwatch } from "./components/EffectPreview";
|
||||
import { fetchCosmetics, getPlayerCosmetics } from "./Cosmetics";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
@customElement("effects-input")
|
||||
export class EffectsInput extends LitElement {
|
||||
// The selected transport-ship-trail attributes, if any (one effectType today).
|
||||
// Not named `attributes` — that collides with HTMLElement.attributes.
|
||||
@state() private trailAttributes: TransportShipTrailAttributes | null = null;
|
||||
|
||||
private _abortController: AbortController | null = null;
|
||||
|
||||
// PlayerEffect is just { name, effectType }; resolve the visual style from the
|
||||
// cosmetics catalog by (effectType, name).
|
||||
private async resolveTrailAttributes(): Promise<TransportShipTrailAttributes | null> {
|
||||
const cosmetics = await getPlayerCosmetics();
|
||||
const name = cosmetics.effects?.["transportShipTrail"]?.name;
|
||||
if (!name) return null;
|
||||
const effect = findEffect(
|
||||
await fetchCosmetics(),
|
||||
"transportShipTrail",
|
||||
name,
|
||||
);
|
||||
return effect?.effectType === "transportShipTrail"
|
||||
? effect.attributes
|
||||
: null;
|
||||
}
|
||||
|
||||
private _onCosmeticSelected = async () => {
|
||||
this.trailAttributes = await this.resolveTrailAttributes();
|
||||
};
|
||||
|
||||
private onInputClick(e: Event) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("effects-input-click", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._abortController = new AbortController();
|
||||
this.trailAttributes = await this.resolveTrailAttributes();
|
||||
window.addEventListener(
|
||||
`${USER_SETTINGS_CHANGED_EVENT}:${EFFECTS_KEY}`,
|
||||
this._onCosmeticSelected,
|
||||
{ signal: this._abortController.signal },
|
||||
);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this._abortController) {
|
||||
this._abortController.abort();
|
||||
this._abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (crazyGamesSDK.isOnCrazyGames()) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const preview =
|
||||
this.trailAttributes === null
|
||||
? html`<span
|
||||
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("effects.title")}
|
||||
</span>`
|
||||
: html`<span class="w-full h-full p-1.5"
|
||||
>${renderTransportShipTrailSwatch(this.trailAttributes)}</span
|
||||
>`;
|
||||
|
||||
return html`
|
||||
<button
|
||||
id="effects-input"
|
||||
class="p-0 m-0 border-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 transition-all duration-200 hover:scale-105 bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:shadow-[var(--shadow-action-card-hover)] rounded-lg overflow-hidden"
|
||||
title=${translateText("effects.button_title")}
|
||||
@click=${this.onInputClick}
|
||||
>
|
||||
${preview}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import { Cosmetics } from "../core/CosmeticSchemas";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/EffectsGrid";
|
||||
import "./components/NotLoggedInWarning";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import { fetchCosmetics } from "./Cosmetics";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
@customElement("effects-modal")
|
||||
export class EffectsModal extends BaseModal {
|
||||
protected routerName = "effects";
|
||||
|
||||
@state() private cosmetics: Cosmetics | null = null;
|
||||
@state() private userMeResponse: UserMeResponse | false = false;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
document.addEventListener(
|
||||
"userMeResponse",
|
||||
(event: CustomEvent<UserMeResponse | false>) => {
|
||||
this.onUserMe(event.detail);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async onUserMe(userMeResponse: UserMeResponse | false) {
|
||||
this.userMeResponse = userMeResponse;
|
||||
this.cosmetics = await fetchCosmetics();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
protected renderHeaderSlot() {
|
||||
return html`
|
||||
<div
|
||||
class="relative flex flex-col border-b border-white/10 pb-4 shrink-0"
|
||||
>
|
||||
${modalHeader({
|
||||
title: translateText("effects.title"),
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
rightContent: html`<not-logged-in-warning></not-logged-in-warning>`,
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
protected renderBody() {
|
||||
return html`
|
||||
<div class="flex flex-col">
|
||||
<div class="flex justify-center py-3 shrink-0">
|
||||
<o-button
|
||||
class="no-crazygames"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
translationKey="main.store"
|
||||
@click=${() => {
|
||||
this.close();
|
||||
window.showPage?.("page-item-store");
|
||||
}}
|
||||
></o-button>
|
||||
</div>
|
||||
<effects-grid
|
||||
mode="select"
|
||||
.cosmetics=${this.cosmetics}
|
||||
.userMeResponse=${this.userMeResponse}
|
||||
></effects-grid>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -235,6 +235,9 @@ export class LangSelector extends LitElement {
|
||||
"leaderboard-modal",
|
||||
"flag-input-modal",
|
||||
"flag-input",
|
||||
"effects-modal",
|
||||
"effects-input",
|
||||
"effects-grid",
|
||||
"matchmaking-button",
|
||||
"token-login",
|
||||
];
|
||||
|
||||
@@ -20,6 +20,9 @@ import "./ClanModal";
|
||||
import { joinLobby, type JoinLobbyResult } from "./ClientGameRunner";
|
||||
import { getPlayerCosmeticsRefs } from "./Cosmetics";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
import "./EffectsInput";
|
||||
import "./EffectsModal";
|
||||
import { EffectsModal } from "./EffectsModal";
|
||||
import "./FlagInput";
|
||||
import { FlagInput } from "./FlagInput";
|
||||
import "./FlagInputModal";
|
||||
@@ -311,6 +314,7 @@ class Client {
|
||||
tag: "territory-patterns-modal",
|
||||
});
|
||||
modalRouter.register("flag-input", { tag: "flag-input-modal" });
|
||||
modalRouter.register("effects", { tag: "effects-modal" });
|
||||
|
||||
// Prefetch turnstile token so it is available when
|
||||
// the user joins a lobby.
|
||||
@@ -421,6 +425,20 @@ class Client {
|
||||
});
|
||||
});
|
||||
|
||||
const effectsModal = document.querySelector(
|
||||
"effects-modal",
|
||||
) as EffectsModal;
|
||||
if (!effectsModal || !(effectsModal instanceof EffectsModal)) {
|
||||
console.warn("Effects modal element not found");
|
||||
}
|
||||
document.querySelectorAll("effects-input").forEach((effectsInput) => {
|
||||
effectsInput.addEventListener("effects-input-click", () => {
|
||||
if (effectsModal && effectsModal instanceof EffectsModal) {
|
||||
effectsModal.open();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.storeModal = document.getElementById("page-item-store") as StoreModal;
|
||||
if (!this.storeModal || !(this.storeModal instanceof StoreModal)) {
|
||||
console.warn("Store modal element not found");
|
||||
@@ -864,6 +882,7 @@ class Client {
|
||||
"language-modal",
|
||||
"news-modal",
|
||||
"flag-input-modal",
|
||||
"effects-modal",
|
||||
"account-button",
|
||||
"leaderboard-button",
|
||||
"token-login",
|
||||
|
||||
+17
-1
@@ -6,6 +6,7 @@ import { Cosmetics } from "../core/CosmeticSchemas";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/CosmeticButton";
|
||||
import "./components/EffectsGrid";
|
||||
import "./components/NotLoggedInWarning";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import {
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
} from "./Cosmetics";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
type StoreTab = "patterns" | "flags" | "packs" | "subscriptions";
|
||||
type StoreTab = "patterns" | "flags" | "effects" | "packs" | "subscriptions";
|
||||
|
||||
@customElement("store-modal")
|
||||
export class StoreModal extends BaseModal {
|
||||
@@ -44,6 +45,7 @@ export class StoreModal extends BaseModal {
|
||||
: []),
|
||||
{ key: "patterns", label: translateText("store.patterns") },
|
||||
{ key: "flags", label: translateText("store.flags") },
|
||||
{ key: "effects", label: translateText("store.effects") },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -150,6 +152,17 @@ export class StoreModal extends BaseModal {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderEffectGrid(): TemplateResult {
|
||||
// Grouped by effectType with a sub-header per type (see <effects-grid>),
|
||||
// matching the home selection modal.
|
||||
return html`<effects-grid
|
||||
mode="purchase"
|
||||
.cosmetics=${this.cosmetics}
|
||||
.userMeResponse=${this.userMeResponse}
|
||||
.affiliateCode=${this.affiliateCode}
|
||||
></effects-grid>`;
|
||||
}
|
||||
|
||||
private renderPackGrid(): TemplateResult {
|
||||
const items = resolveCosmetics(
|
||||
this.cosmetics,
|
||||
@@ -234,6 +247,8 @@ export class StoreModal extends BaseModal {
|
||||
return this.renderPatternGrid();
|
||||
case "flags":
|
||||
return this.renderFlagGrid();
|
||||
case "effects":
|
||||
return this.renderEffectGrid();
|
||||
case "subscriptions":
|
||||
return this.renderSubscriptionGrid();
|
||||
case "packs":
|
||||
@@ -252,6 +267,7 @@ export class StoreModal extends BaseModal {
|
||||
(r.type === "pattern" ||
|
||||
r.type === "skin" ||
|
||||
r.type === "flag" ||
|
||||
r.type === "effect" ||
|
||||
r.type === "pack") &&
|
||||
r.relationship === "purchasable",
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { html, LitElement, nothing, TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import {
|
||||
Effect,
|
||||
Flag,
|
||||
Pack,
|
||||
Pattern,
|
||||
@@ -17,6 +18,7 @@ import { translateText } from "../Utils";
|
||||
import "./CapIcon";
|
||||
import "./CosmeticContainer";
|
||||
import "./CosmeticInfo";
|
||||
import { renderTransportShipTrailSwatch } from "./EffectPreview";
|
||||
import { renderPatternPreview } from "./PatternPreview";
|
||||
import "./PlutoniumIcon";
|
||||
|
||||
@@ -81,6 +83,9 @@ export class CosmeticButton extends LitElement {
|
||||
if (this.activeResolved.type === "subscription") {
|
||||
return translateCosmetic("subscriptions", c.name);
|
||||
}
|
||||
if (this.activeResolved.type === "effect") {
|
||||
return translateCosmetic("effects", c.name);
|
||||
}
|
||||
return translateCosmetic("flags", c.name);
|
||||
}
|
||||
|
||||
@@ -167,6 +172,20 @@ export class CosmeticButton extends LitElement {
|
||||
/>`;
|
||||
}
|
||||
|
||||
if (this.activeResolved.type === "effect") {
|
||||
const c = this.activeResolved.cosmetic as Effect | null;
|
||||
if (c === null) {
|
||||
// "Default" tile — selecting it clears the effect for that type.
|
||||
return html`<div
|
||||
class="w-full h-full flex items-center justify-center text-white/40 text-xs uppercase"
|
||||
>
|
||||
${translateText("territory_patterns.pattern.default")}
|
||||
</div>`;
|
||||
}
|
||||
// Only effectType today is transportShipTrail; c.attributes is its style.
|
||||
return renderTransportShipTrailSwatch(c.attributes);
|
||||
}
|
||||
|
||||
if (this.activeResolved.type === "pack") {
|
||||
const pack = this.activeResolved.cosmetic as Pack;
|
||||
const isHard = pack.currency === "hard";
|
||||
@@ -254,7 +273,7 @@ export class CosmeticButton extends LitElement {
|
||||
render() {
|
||||
const active = this.activeResolved;
|
||||
const c = active.cosmetic;
|
||||
const priced = c as Pattern | Skin | Flag | Pack | null;
|
||||
const priced = c as Pattern | Skin | Flag | Effect | Pack | null;
|
||||
const priceHard = priced?.priceHard;
|
||||
const priceSoft = priced?.priceSoft;
|
||||
const artist = priced?.artist;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { html, TemplateResult } from "lit";
|
||||
import { TransportShipTrailAttributes } from "../../core/CosmeticSchemas";
|
||||
|
||||
// A flowing spectrum used for the "rainbow" transport-ship-trail preview.
|
||||
const RAINBOW_GRADIENT =
|
||||
"linear-gradient(90deg,#ff0000,#ff8a00,#ffe600,#28c76f,#00a8ff,#7d5fff,#ff0000)";
|
||||
|
||||
// Neutral fallback for attribute types we don't recognize.
|
||||
const UNKNOWN_BG = "#444";
|
||||
|
||||
/**
|
||||
* Render a swatch preview of a transport-ship-trail's attributes, filling its
|
||||
* container: solid = flat color, pulse = same color pulsing, rainbow = full
|
||||
* spectrum, gradient = two-color blend. Unknown attribute types render a neutral
|
||||
* swatch (we ignore types we don't know about).
|
||||
*/
|
||||
export function renderTransportShipTrailSwatch(
|
||||
attributes: TransportShipTrailAttributes,
|
||||
): TemplateResult {
|
||||
switch (attributes.type) {
|
||||
case "rainbow":
|
||||
return html`<div
|
||||
class="w-full h-full rounded-md"
|
||||
style="background:${RAINBOW_GRADIENT};"
|
||||
></div>`;
|
||||
case "gradient":
|
||||
return html`<div
|
||||
class="w-full h-full rounded-md"
|
||||
style="background:linear-gradient(90deg,${attributes.color},${attributes.color2});"
|
||||
></div>`;
|
||||
case "pulse":
|
||||
return html`<div
|
||||
class="w-full h-full rounded-md animate-pulse"
|
||||
style="background:${attributes.color};"
|
||||
></div>`;
|
||||
case "solid":
|
||||
return html`<div
|
||||
class="w-full h-full rounded-md"
|
||||
style="background:${attributes.color};"
|
||||
></div>`;
|
||||
default:
|
||||
// Unknown / unrecognized style — neutral swatch.
|
||||
return html`<div
|
||||
class="w-full h-full rounded-md"
|
||||
style="background:${UNKNOWN_BG};"
|
||||
></div>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { html, LitElement, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { UserMeResponse } from "../../core/ApiSchemas";
|
||||
import {
|
||||
Cosmetics,
|
||||
Effect,
|
||||
EFFECT_TYPES,
|
||||
EffectType,
|
||||
} from "../../core/CosmeticSchemas";
|
||||
import {
|
||||
EFFECTS_KEY,
|
||||
USER_SETTINGS_CHANGED_EVENT,
|
||||
UserSettings,
|
||||
} from "../../core/game/UserSettings";
|
||||
import {
|
||||
purchaseCosmetic,
|
||||
resolveCosmetics,
|
||||
ResolvedCosmetic,
|
||||
} from "../Cosmetics";
|
||||
import { translateText } from "../Utils";
|
||||
import "./CosmeticButton";
|
||||
|
||||
// "Default" (none) tile — selecting it clears the effect for that effectType.
|
||||
function noneTile(effectType: EffectType): ResolvedCosmetic {
|
||||
return {
|
||||
type: "effect",
|
||||
cosmetic: null,
|
||||
colorPalette: null,
|
||||
relationship: "owned",
|
||||
key: `effect:none:${effectType}`,
|
||||
effectType,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders effect cosmetics grouped by effectType, one sub-header per type.
|
||||
* Shared by the home selection modal and the Store's Effects tab.
|
||||
*
|
||||
* - mode="select": owned effects + a Default tile per type; clicking persists
|
||||
* the selection to UserSettings and re-renders.
|
||||
* - mode="purchase": purchasable effects per type with the buy flow.
|
||||
*/
|
||||
@customElement("effects-grid")
|
||||
export class EffectsGrid extends LitElement {
|
||||
@property({ attribute: false }) cosmetics: Cosmetics | null = null;
|
||||
@property({ attribute: false }) userMeResponse: UserMeResponse | false =
|
||||
false;
|
||||
@property({ type: String }) mode: "select" | "purchase" = "select";
|
||||
@property({ attribute: false }) affiliateCode: string | null = null;
|
||||
|
||||
private userSettings = new UserSettings();
|
||||
private _onChange = () => this.requestUpdate();
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener(
|
||||
`${USER_SETTINGS_CHANGED_EVENT}:${EFFECTS_KEY}`,
|
||||
this._onChange,
|
||||
);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener(
|
||||
`${USER_SETTINGS_CHANGED_EVENT}:${EFFECTS_KEY}`,
|
||||
this._onChange,
|
||||
);
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private select(effectType: EffectType, name: string | null) {
|
||||
this.userSettings.setSelectedEffectName(effectType, name ?? undefined);
|
||||
// Stay rendered; the change event re-renders this grid and the home button.
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private itemsForType(
|
||||
all: ResolvedCosmetic[],
|
||||
effectType: EffectType,
|
||||
): ResolvedCosmetic[] {
|
||||
const ofType = all.filter(
|
||||
(r) =>
|
||||
r.type === "effect" &&
|
||||
r.cosmetic !== null &&
|
||||
r.effectType === effectType,
|
||||
);
|
||||
if (this.mode === "purchase") {
|
||||
return ofType.filter((r) => r.relationship === "purchasable");
|
||||
}
|
||||
return [
|
||||
noneTile(effectType),
|
||||
...ofType.filter((r) => r.relationship === "owned"),
|
||||
];
|
||||
}
|
||||
|
||||
private renderTile(
|
||||
effectType: EffectType,
|
||||
r: ResolvedCosmetic,
|
||||
): TemplateResult {
|
||||
if (this.mode === "purchase") {
|
||||
return html`<cosmetic-button
|
||||
.resolved=${r}
|
||||
.onPurchase=${purchaseCosmetic}
|
||||
></cosmetic-button>`;
|
||||
}
|
||||
const name = (r.cosmetic as Effect | null)?.name ?? null;
|
||||
const selected = this.userSettings.getSelectedEffectName(effectType);
|
||||
const isSelected =
|
||||
(name === null && selected === null) ||
|
||||
(name !== null && selected === name);
|
||||
return html`<cosmetic-button
|
||||
.resolved=${r}
|
||||
.selected=${isSelected}
|
||||
.onSelect=${() => this.select(effectType, name)}
|
||||
></cosmetic-button>`;
|
||||
}
|
||||
|
||||
render() {
|
||||
const all = resolveCosmetics(
|
||||
this.cosmetics,
|
||||
this.userMeResponse,
|
||||
this.affiliateCode,
|
||||
);
|
||||
const sections = EFFECT_TYPES.map((type) => ({
|
||||
type,
|
||||
items: this.itemsForType(all, type),
|
||||
})).filter((s) => s.items.length > 0);
|
||||
|
||||
if (sections.length === 0) {
|
||||
return html`<div
|
||||
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
|
||||
>
|
||||
${translateText("store.no_effects")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
${sections.map(
|
||||
(s) => html`
|
||||
<div class="flex flex-col">
|
||||
<h3
|
||||
class="text-white/70 text-sm font-black uppercase tracking-wider px-2 pb-2 mb-2 border-b border-white/10"
|
||||
>
|
||||
${translateText(`effects.type.${s.type}`)}
|
||||
</h3>
|
||||
<div
|
||||
class="flex flex-wrap gap-4 justify-center items-stretch content-start"
|
||||
>
|
||||
${s.items.map((r) => this.renderTile(s.type, r))}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,10 @@ export class PlayPage extends LitElement {
|
||||
show-select-label
|
||||
class="shrink-0 lg:hidden h-10 w-10"
|
||||
></flag-input>
|
||||
<effects-input
|
||||
id="effects-input-mobile"
|
||||
class="shrink-0 lg:hidden h-10 w-10"
|
||||
></effects-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -111,6 +115,10 @@ export class PlayPage extends LitElement {
|
||||
show-select-label
|
||||
class="flex-1 h-full"
|
||||
></flag-input>
|
||||
<effects-input
|
||||
id="effects-input-desktop"
|
||||
class="flex-1 h-full"
|
||||
></effects-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,6 +9,13 @@ export type Flag = z.infer<typeof FlagSchema>;
|
||||
export type Skin = z.infer<typeof SkinSchema>;
|
||||
export type Pack = z.infer<typeof PackSchema>;
|
||||
export type Subscription = z.infer<typeof SubscriptionSchema>;
|
||||
// An effect cosmetic of any type — discriminated on effectType (today only
|
||||
// transportShipTrail; gains a member per effectType).
|
||||
export type Effect = z.infer<typeof EffectSchema>;
|
||||
export type EffectType = z.infer<typeof EffectTypeSchema>;
|
||||
export type TransportShipTrailAttributes = z.infer<
|
||||
typeof TransportShipTrailAttributesSchema
|
||||
>;
|
||||
export type PatternName = z.infer<typeof CosmeticNameSchema>;
|
||||
export type Product = z.infer<typeof ProductSchema>;
|
||||
export type ColorPalette = z.infer<typeof ColorPaletteSchema>;
|
||||
@@ -85,6 +92,49 @@ export const SkinSchema = CosmeticSchema.extend({
|
||||
url: z.string(),
|
||||
});
|
||||
|
||||
// "effects" is a cosmetic category alongside skins/flags. The catalog is nested
|
||||
// effects[effectType][effectName], and each effect also carries an effectType
|
||||
// field matching its outer key (so an Effect can stand alone / discriminate).
|
||||
// effectTypes are listed explicitly in CosmeticsSchema so each type's attributes
|
||||
// stay precisely typed; an effectType the client doesn't list is dropped at parse
|
||||
// (the UI only handles EFFECT_TYPES), so a new server-side effectType never fails
|
||||
// the whole cosmetics parse.
|
||||
export const EFFECT_TYPES = ["transportShipTrail"] as const;
|
||||
export const EffectTypeSchema = z.enum(EFFECT_TYPES);
|
||||
|
||||
// Boat-trail styles, discriminated on `type`: each known style carries exactly
|
||||
// the fields it uses (rainbow has none; solid/pulse need a color; gradient needs
|
||||
// both). A `type` we don't recognize — a style shipped to cosmetics.json before
|
||||
// this client updated — normalizes to { type: "unknown" } instead of failing the
|
||||
// catalog parse, so one new style never wipes the whole catalog; the renderer
|
||||
// shows a neutral swatch. `type` itself stays required.
|
||||
export const TransportShipTrailAttributesSchema = z.union([
|
||||
z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("solid"), color: z.string() }),
|
||||
z.object({ type: z.literal("rainbow") }),
|
||||
z.object({ type: z.literal("pulse"), color: z.string() }),
|
||||
z.object({
|
||||
type: z.literal("gradient"),
|
||||
color: z.string(),
|
||||
color2: z.string(),
|
||||
}),
|
||||
]),
|
||||
z
|
||||
.object({ type: z.string() })
|
||||
.transform(() => ({ type: "unknown" as const })),
|
||||
]);
|
||||
|
||||
const TransportShipTrailEffectSchema = CosmeticSchema.extend({
|
||||
effectType: z.literal("transportShipTrail"),
|
||||
attributes: TransportShipTrailAttributesSchema,
|
||||
url: z.string().optional(),
|
||||
});
|
||||
|
||||
// Any catalog effect, discriminated on effectType. Add a member per effectType.
|
||||
export const EffectSchema = z.discriminatedUnion("effectType", [
|
||||
TransportShipTrailEffectSchema,
|
||||
]);
|
||||
|
||||
export const PackSchema = CosmeticSchema.extend({
|
||||
displayName: z.string(),
|
||||
currency: z.enum(["hard", "soft"]),
|
||||
@@ -105,10 +155,42 @@ export const CosmeticsSchema = z.object({
|
||||
patterns: z.record(z.string(), PatternSchema),
|
||||
flags: z.record(z.string(), FlagSchema),
|
||||
skins: z.record(z.string(), SkinSchema).optional(),
|
||||
// Grouped by effectType. Each effect also carries its own effectType (matching
|
||||
// this outer key) so an Effect stands alone and EffectSchema can discriminate
|
||||
// on it. Add a key per new effectType.
|
||||
effects: z
|
||||
.object({
|
||||
transportShipTrail: z
|
||||
.record(z.string(), TransportShipTrailEffectSchema)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
currencyPacks: z.record(z.string(), PackSchema).optional(),
|
||||
subscriptions: z.record(z.string(), SubscriptionSchema).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolve an effect in the nested catalog (effects[effectType][effectKey]). The
|
||||
* catalog object key is normally identical to the effect's `name`, but selection
|
||||
* and ownership flares are both name-based, so fall back to a `name`-field search
|
||||
* when the object key differs. Without this fallback a catalog whose key !== name
|
||||
* would make the effect silently unselectable (the selected name never resolves).
|
||||
*/
|
||||
export function findEffect(
|
||||
cosmetics: Cosmetics | null | undefined,
|
||||
effectType: string,
|
||||
name: string,
|
||||
): Effect | undefined {
|
||||
// effects is keyed by the known effectTypes; index it by an arbitrary runtime
|
||||
// string (a selection/ref may name a type this client doesn't list).
|
||||
const byType = cosmetics?.effects as
|
||||
| Record<string, Record<string, Effect>>
|
||||
| undefined;
|
||||
const byName = byType?.[effectType];
|
||||
if (!byName) return undefined;
|
||||
return byName[name] ?? Object.values(byName).find((e) => e.name === name);
|
||||
}
|
||||
|
||||
export const DefaultPattern = {
|
||||
name: "default",
|
||||
patternData: "AAAAAA",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import {
|
||||
ColorPaletteSchema,
|
||||
CosmeticNameSchema,
|
||||
EffectTypeSchema,
|
||||
PatternDataSchema,
|
||||
} from "./CosmeticSchemas";
|
||||
import type { GameEvent } from "./EventBus";
|
||||
@@ -142,6 +143,7 @@ export type PlayerCosmeticRefs = z.infer<typeof PlayerCosmeticRefsSchema>;
|
||||
export type PlayerPattern = z.infer<typeof PlayerPatternSchema>;
|
||||
export type PlayerColor = z.infer<typeof PlayerColorSchema>;
|
||||
export type PlayerSkin = z.infer<typeof PlayerSkinSchema>;
|
||||
export type PlayerEffect = z.infer<typeof PlayerEffectSchema>;
|
||||
export type GameStartInfo = z.infer<typeof GameStartInfoSchema>;
|
||||
export type GameInfo = z.infer<typeof GameInfoSchema>;
|
||||
export type PublicGames = z.infer<typeof PublicGamesSchema>;
|
||||
@@ -593,6 +595,8 @@ export const PlayerCosmeticRefsSchema = z.object({
|
||||
patternName: CosmeticNameSchema.optional(),
|
||||
patternColorPaletteName: z.string().optional(),
|
||||
skinName: CosmeticNameSchema.optional(),
|
||||
// At most one selected effect per effectType: key = effectType, value = effect name.
|
||||
effects: z.record(z.string(), CosmeticNameSchema).optional(),
|
||||
});
|
||||
|
||||
export const PlayerSkinSchema = z.object({
|
||||
@@ -600,12 +604,23 @@ export const PlayerSkinSchema = z.object({
|
||||
url: z.string(),
|
||||
});
|
||||
|
||||
// A resolved effect is just an identity: which effect, of which type. Its
|
||||
// attributes (the visual style) are resolved from the cosmetics catalog by
|
||||
// (effectType, name), so this needs no per-type variants — a new effectType
|
||||
// just becomes a new EFFECT_TYPES entry, no change here.
|
||||
export const PlayerEffectSchema = z.object({
|
||||
name: CosmeticNameSchema,
|
||||
effectType: EffectTypeSchema,
|
||||
});
|
||||
|
||||
// Server converts refs to the actual cosmetics here
|
||||
export const PlayerCosmeticsSchema = z.object({
|
||||
flag: FlagSchema.optional(),
|
||||
pattern: PlayerPatternSchema.optional(),
|
||||
color: PlayerColorSchema.optional(),
|
||||
skin: PlayerSkinSchema.optional(),
|
||||
// Resolved effects keyed by effectType.
|
||||
effects: z.record(z.string(), PlayerEffectSchema).optional(),
|
||||
});
|
||||
|
||||
export const PlayerSchema = z.object({
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
GraphicsOverrides,
|
||||
GraphicsOverridesSchema,
|
||||
} from "../../client/render/gl/GraphicsOverrides";
|
||||
import { Cosmetics } from "../CosmeticSchemas";
|
||||
import { Cosmetics, EffectType } from "../CosmeticSchemas";
|
||||
import { PlayerPattern } from "../Schemas";
|
||||
|
||||
export function getDefaultKeybinds(isMac: boolean): Record<string, string> {
|
||||
@@ -57,6 +57,7 @@ export const COLOR_KEY = "settings.territoryColor";
|
||||
export const PERFORMANCE_OVERLAY_KEY = "settings.performanceOverlay";
|
||||
export const KEYBINDS_KEY = "settings.keybinds";
|
||||
export const GRAPHICS_KEY = "settings.graphics";
|
||||
export const EFFECTS_KEY = "settings.effects";
|
||||
|
||||
export class UserSettings {
|
||||
private static cache = new Map<string, string | null>();
|
||||
@@ -312,6 +313,38 @@ export class UserSettings {
|
||||
this.removeCached(FLAG_KEY, emitChange);
|
||||
}
|
||||
|
||||
/**
|
||||
* Selected effect cosmetics, keyed by effectType (at most one per type).
|
||||
* Persisted as a single JSON blob under EFFECTS_KEY.
|
||||
*/
|
||||
getSelectedEffects(): Record<string, string> {
|
||||
const raw = this.getString(EFFECTS_KEY, "");
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? parsed
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
getSelectedEffectName(effectType: EffectType): string | null {
|
||||
return this.getSelectedEffects()[effectType] ?? null;
|
||||
}
|
||||
|
||||
setSelectedEffectName(
|
||||
effectType: EffectType,
|
||||
name: string | undefined,
|
||||
): void {
|
||||
const map = this.getSelectedEffects();
|
||||
if (name === undefined) delete map[effectType];
|
||||
else map[effectType] = name;
|
||||
if (Object.keys(map).length === 0) this.removeCached(EFFECTS_KEY);
|
||||
else this.setString(EFFECTS_KEY, JSON.stringify(map));
|
||||
}
|
||||
|
||||
backgroundMusicVolume(): number {
|
||||
return this.getFloat("settings.backgroundMusicVolume", 0);
|
||||
}
|
||||
|
||||
+36
-1
@@ -11,12 +11,13 @@ import {
|
||||
} from "obscenity";
|
||||
import countries from "resources/countries.json";
|
||||
|
||||
import { Cosmetics } from "../core/CosmeticSchemas";
|
||||
import { Cosmetics, EffectType, findEffect } from "../core/CosmeticSchemas";
|
||||
import { decodePatternData } from "../core/PatternDecoder";
|
||||
import {
|
||||
PlayerColor,
|
||||
PlayerCosmeticRefs,
|
||||
PlayerCosmetics,
|
||||
PlayerEffect,
|
||||
PlayerPattern,
|
||||
PlayerSkin,
|
||||
} from "../core/Schemas";
|
||||
@@ -257,10 +258,44 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
|
||||
return { type: "forbidden", reason: "invalid skin: " + message };
|
||||
}
|
||||
}
|
||||
if (refs.effects) {
|
||||
for (const [type, name] of Object.entries(refs.effects)) {
|
||||
try {
|
||||
cosmetics.effects ??= {};
|
||||
cosmetics.effects[type] = this.isEffectAllowed(
|
||||
flares,
|
||||
type as EffectType,
|
||||
name,
|
||||
);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return { type: "forbidden", reason: "invalid effect: " + message };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "allowed", cosmetics };
|
||||
}
|
||||
|
||||
isEffectAllowed(
|
||||
flares: string[],
|
||||
effectType: EffectType,
|
||||
name: string,
|
||||
): PlayerEffect {
|
||||
const found = findEffect(this.cosmetics, effectType, name);
|
||||
if (!found) throw new Error(`Effect ${name} not found`);
|
||||
if (
|
||||
flares.includes("effect:*") ||
|
||||
flares.includes(`effect:${found.name}`)
|
||||
) {
|
||||
return {
|
||||
name: found.name,
|
||||
effectType: found.effectType,
|
||||
};
|
||||
}
|
||||
throw new Error(`No flares for effect ${name}`);
|
||||
}
|
||||
|
||||
isSkinAllowed(flares: string[], name: string): PlayerSkin {
|
||||
const found = this.cosmetics.skins?.[name];
|
||||
if (!found) throw new Error(`Skin ${name} not found`);
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import {
|
||||
Cosmetics,
|
||||
CosmeticsSchema,
|
||||
EffectSchema,
|
||||
findEffect,
|
||||
TransportShipTrailAttributesSchema,
|
||||
} from "../src/core/CosmeticSchemas";
|
||||
import { PlayerEffectSchema } from "../src/core/Schemas";
|
||||
|
||||
describe("Effect cosmetic schemas", () => {
|
||||
const base = {
|
||||
name: "spectrum",
|
||||
effectType: "transportShipTrail",
|
||||
product: null,
|
||||
rarity: "common",
|
||||
};
|
||||
|
||||
describe("TransportShipTrailAttributesSchema (lenient)", () => {
|
||||
it("parses the known attribute variants", () => {
|
||||
expect(
|
||||
TransportShipTrailAttributesSchema.safeParse({
|
||||
type: "solid",
|
||||
color: "#f00",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
TransportShipTrailAttributesSchema.safeParse({ type: "rainbow" })
|
||||
.success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
TransportShipTrailAttributesSchema.safeParse({
|
||||
type: "pulse",
|
||||
color: "#0f0",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
TransportShipTrailAttributesSchema.safeParse({
|
||||
type: "gradient",
|
||||
color: "#f00",
|
||||
color2: "#00f",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("tolerates an unknown attribute type (ignored at render time)", () => {
|
||||
expect(
|
||||
TransportShipTrailAttributesSchema.safeParse({ type: "sparkle" })
|
||||
.success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires a `type`", () => {
|
||||
expect(TransportShipTrailAttributesSchema.safeParse({}).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TransportShipTrailAttributesSchema (discriminated styles)", () => {
|
||||
it("keeps the fields of a known style", () => {
|
||||
const solid = TransportShipTrailAttributesSchema.parse({
|
||||
type: "solid",
|
||||
color: "#f00",
|
||||
});
|
||||
expect(solid).toEqual({ type: "solid", color: "#f00" });
|
||||
const gradient = TransportShipTrailAttributesSchema.parse({
|
||||
type: "gradient",
|
||||
color: "#f00",
|
||||
color2: "#00f",
|
||||
});
|
||||
expect(gradient).toEqual({
|
||||
type: "gradient",
|
||||
color: "#f00",
|
||||
color2: "#00f",
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes an unrecognized style to { type: "unknown" }', () => {
|
||||
expect(
|
||||
TransportShipTrailAttributesSchema.parse({ type: "sparkle" }),
|
||||
).toEqual({ type: "unknown" });
|
||||
});
|
||||
|
||||
it("normalizes a known style missing required fields to unknown", () => {
|
||||
// solid without color / gradient without color2 don't match their strict
|
||||
// variant, so they degrade to the neutral unknown swatch rather than
|
||||
// failing the parse.
|
||||
expect(
|
||||
TransportShipTrailAttributesSchema.parse({ type: "solid" }),
|
||||
).toEqual({ type: "unknown" });
|
||||
expect(
|
||||
TransportShipTrailAttributesSchema.parse({
|
||||
type: "gradient",
|
||||
color: "#f00",
|
||||
}),
|
||||
).toEqual({ type: "unknown" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("EffectSchema", () => {
|
||||
it("parses an effect (discriminated on effectType)", () => {
|
||||
expect(
|
||||
EffectSchema.safeParse({
|
||||
...base,
|
||||
attributes: { type: "rainbow" },
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an effect with no attributes", () => {
|
||||
expect(EffectSchema.safeParse({ ...base }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an effect with an unknown effectType (no union member)", () => {
|
||||
expect(
|
||||
EffectSchema.safeParse({
|
||||
...base,
|
||||
effectType: "glow",
|
||||
attributes: { type: "rainbow" },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("tolerates an effect with an unknown attribute type", () => {
|
||||
expect(
|
||||
EffectSchema.safeParse({
|
||||
...base,
|
||||
attributes: { type: "sparkle" },
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Exact shape served by the production cosmetics.json: nested
|
||||
// effects[effectType][effectName], each effect carrying its effectType, and
|
||||
// extras (e.g. product.priceInCents) stripped.
|
||||
it("parses the real nested cosmetics.json effects", () => {
|
||||
const result = CosmeticsSchema.safeParse({
|
||||
patterns: {},
|
||||
flags: {},
|
||||
effects: {
|
||||
transportShipTrail: {
|
||||
rainbow_ship: {
|
||||
name: "rainbow_ship",
|
||||
effectType: "transportShipTrail",
|
||||
attributes: { type: "rainbow" },
|
||||
affiliateCode: null,
|
||||
product: null,
|
||||
priceHard: 123,
|
||||
rarity: "common",
|
||||
},
|
||||
gradient: {
|
||||
name: "gradient",
|
||||
effectType: "transportShipTrail",
|
||||
attributes: {
|
||||
type: "gradient",
|
||||
color: "#aea2a2",
|
||||
color2: "#a80000",
|
||||
},
|
||||
affiliateCode: null,
|
||||
product: {
|
||||
price: "$0.99",
|
||||
priceInCents: 99,
|
||||
productId: "prod_x",
|
||||
priceId: "price_x",
|
||||
},
|
||||
rarity: "common",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(
|
||||
result.data.effects?.transportShipTrail?.rainbow_ship?.attributes?.type,
|
||||
).toBe("rainbow");
|
||||
}
|
||||
});
|
||||
|
||||
it("tolerates an unknown effectType (outer key) without failing the parse", () => {
|
||||
const result = CosmeticsSchema.safeParse({
|
||||
patterns: {},
|
||||
flags: {},
|
||||
effects: {
|
||||
transportShipTrail: {
|
||||
ship: {
|
||||
name: "ship",
|
||||
effectType: "transportShipTrail",
|
||||
attributes: { type: "solid", color: "#fff" },
|
||||
product: null,
|
||||
rarity: "common",
|
||||
},
|
||||
},
|
||||
someFutureEffect: {
|
||||
thing: {
|
||||
name: "thing",
|
||||
attributes: { type: "whatever" },
|
||||
product: null,
|
||||
rarity: "common",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findEffect", () => {
|
||||
const effect = (name: string) => ({
|
||||
name,
|
||||
attributes: { type: "solid", color: "#fff" } as const,
|
||||
product: null,
|
||||
rarity: "common" as const,
|
||||
});
|
||||
|
||||
it("resolves by the catalog object key (the common key === name case)", () => {
|
||||
const cosmetics = {
|
||||
effects: { transportShipTrail: { spectrum: effect("spectrum") } },
|
||||
} as unknown as Cosmetics;
|
||||
expect(findEffect(cosmetics, "transportShipTrail", "spectrum")?.name).toBe(
|
||||
"spectrum",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the name field when the object key differs", () => {
|
||||
// Catalog key "trail_01" but the effect's name is "spectrum"; selection and
|
||||
// flares are name-based, so the name must still resolve the effect.
|
||||
const cosmetics = {
|
||||
effects: { transportShipTrail: { trail_01: effect("spectrum") } },
|
||||
} as unknown as Cosmetics;
|
||||
expect(findEffect(cosmetics, "transportShipTrail", "spectrum")?.name).toBe(
|
||||
"spectrum",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined for an unknown effect name", () => {
|
||||
const cosmetics = {
|
||||
effects: { transportShipTrail: { spectrum: effect("spectrum") } },
|
||||
} as unknown as Cosmetics;
|
||||
expect(
|
||||
findEffect(cosmetics, "transportShipTrail", "ghost"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for an unknown effectType or missing catalog", () => {
|
||||
const cosmetics = {
|
||||
effects: { transportShipTrail: { spectrum: effect("spectrum") } },
|
||||
} as unknown as Cosmetics;
|
||||
expect(findEffect(cosmetics, "wrongType", "spectrum")).toBeUndefined();
|
||||
expect(findEffect(null, "transportShipTrail", "spectrum")).toBeUndefined();
|
||||
expect(
|
||||
findEffect({} as Cosmetics, "transportShipTrail", "x"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlayerEffectSchema (identity: name + effectType)", () => {
|
||||
it("parses a name + effectType (attributes live in the catalog)", () => {
|
||||
expect(
|
||||
PlayerEffectSchema.safeParse({
|
||||
name: "spectrum",
|
||||
effectType: "transportShipTrail",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an unknown effectType (not in EFFECT_TYPES)", () => {
|
||||
expect(
|
||||
PlayerEffectSchema.safeParse({
|
||||
name: "spectrum",
|
||||
effectType: "glow",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("requires an effectType", () => {
|
||||
expect(PlayerEffectSchema.safeParse({ name: "spectrum" }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -88,6 +88,45 @@ const skinChecker = new PrivilegeCheckerImpl(
|
||||
bannedWords,
|
||||
);
|
||||
|
||||
const effectCosmetics = {
|
||||
patterns: {},
|
||||
colorPalettes: {},
|
||||
flags: {},
|
||||
effects: {
|
||||
// Each effect carries its effectType field (matching the outer key), as the
|
||||
// schema requires.
|
||||
transportShipTrail: {
|
||||
spectrum: {
|
||||
name: "spectrum",
|
||||
effectType: "transportShipTrail" as const,
|
||||
attributes: { type: "rainbow" } as const,
|
||||
url: "",
|
||||
affiliateCode: null,
|
||||
product: null,
|
||||
priceSoft: undefined,
|
||||
priceHard: undefined,
|
||||
rarity: "legendary",
|
||||
},
|
||||
crimson: {
|
||||
name: "crimson",
|
||||
effectType: "transportShipTrail" as const,
|
||||
attributes: { type: "solid", color: "#e01b24" } as const,
|
||||
url: "",
|
||||
affiliateCode: null,
|
||||
product: { productId: "prod_1", priceId: "price_1", price: "$4.99" },
|
||||
priceSoft: undefined,
|
||||
priceHard: undefined,
|
||||
rarity: "common",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const effectChecker = new PrivilegeCheckerImpl(
|
||||
effectCosmetics,
|
||||
mockDecoder,
|
||||
bannedWords,
|
||||
);
|
||||
|
||||
describe("UsernameCensor", () => {
|
||||
describe("isProfane (via matcher.hasMatch)", () => {
|
||||
test("detects exact banned words", () => {
|
||||
@@ -521,6 +560,109 @@ describe("Skin validation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Effect validation in isAllowed", () => {
|
||||
test("allows valid effect with wildcard flare", () => {
|
||||
const result = effectChecker.isAllowed(["effect:*"], {
|
||||
effects: { transportShipTrail: "spectrum" },
|
||||
});
|
||||
expect(result.type).toBe("allowed");
|
||||
if (result.type === "allowed") {
|
||||
expect(result.cosmetics.effects?.transportShipTrail).toEqual({
|
||||
name: "spectrum",
|
||||
effectType: "transportShipTrail",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("allows valid effect with exact-match flare", () => {
|
||||
const result = effectChecker.isAllowed(["effect:crimson"], {
|
||||
effects: { transportShipTrail: "crimson" },
|
||||
});
|
||||
expect(result.type).toBe("allowed");
|
||||
if (result.type === "allowed") {
|
||||
expect(result.cosmetics.effects?.transportShipTrail).toEqual({
|
||||
name: "crimson",
|
||||
effectType: "transportShipTrail",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects effect when user lacks flare", () => {
|
||||
const result = effectChecker.isAllowed([], {
|
||||
effects: { transportShipTrail: "spectrum" },
|
||||
});
|
||||
expect(result.type).toBe("forbidden");
|
||||
if (result.type === "forbidden") {
|
||||
expect(result.reason).toMatch(/invalid effect/);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects effect under an unknown effectType key", () => {
|
||||
const result = effectChecker.isAllowed(["effect:*"], {
|
||||
effects: { wrongType: "spectrum" },
|
||||
});
|
||||
expect(result.type).toBe("forbidden");
|
||||
if (result.type === "forbidden") {
|
||||
expect(result.reason).toMatch(/Effect spectrum not found/);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects nonexistent effect", () => {
|
||||
const result = effectChecker.isAllowed(["effect:*"], {
|
||||
effects: { transportShipTrail: "ghost" },
|
||||
});
|
||||
expect(result.type).toBe("forbidden");
|
||||
if (result.type === "forbidden") {
|
||||
expect(result.reason).toMatch(/Effect ghost not found/);
|
||||
}
|
||||
});
|
||||
|
||||
test("no effects in refs leaves cosmetics.effects undefined", () => {
|
||||
const result = effectChecker.isAllowed(["effect:*"], {});
|
||||
expect(result.type).toBe("allowed");
|
||||
if (result.type === "allowed") {
|
||||
expect(result.cosmetics.effects).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("resolves an effect whose catalog key differs from its name", () => {
|
||||
// Catalog key "trail_01" but name "spectrum"; selection/flares are
|
||||
// name-based, so the name must still resolve and validate.
|
||||
const checker = new PrivilegeCheckerImpl(
|
||||
{
|
||||
patterns: {},
|
||||
colorPalettes: {},
|
||||
flags: {},
|
||||
effects: {
|
||||
transportShipTrail: {
|
||||
trail_01: {
|
||||
name: "spectrum",
|
||||
effectType: "transportShipTrail" as const,
|
||||
attributes: { type: "rainbow" } as const,
|
||||
url: "",
|
||||
affiliateCode: null,
|
||||
product: null,
|
||||
rarity: "legendary",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
mockDecoder,
|
||||
bannedWords,
|
||||
);
|
||||
const result = checker.isAllowed(["effect:spectrum"], {
|
||||
effects: { transportShipTrail: "spectrum" },
|
||||
});
|
||||
expect(result.type).toBe("allowed");
|
||||
if (result.type === "allowed") {
|
||||
expect(result.cosmetics.effects?.transportShipTrail).toEqual({
|
||||
name: "spectrum",
|
||||
effectType: "transportShipTrail",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PrivilegeCheckerImpl#resolveClanTag", () => {
|
||||
// Reserved tags are stored uppercase, exactly as PrivilegeRefresher loads them.
|
||||
const makeChecker = (reservedTags: string[]) =>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { EFFECTS_KEY, UserSettings } from "../src/core/game/UserSettings";
|
||||
|
||||
describe("UserSettings effect selection", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
// UserSettings keeps a static in-memory cache; reset it too so each test
|
||||
// reads fresh from the (cleared) localStorage.
|
||||
(
|
||||
UserSettings as unknown as { cache: Map<string, string | null> }
|
||||
).cache.clear();
|
||||
});
|
||||
|
||||
it("sets and reads a per-effectType selection", () => {
|
||||
const s = new UserSettings();
|
||||
s.setSelectedEffectName("transportShipTrail", "spectrum");
|
||||
expect(s.getSelectedEffectName("transportShipTrail")).toBe("spectrum");
|
||||
});
|
||||
|
||||
it("returns null when nothing is selected", () => {
|
||||
expect(
|
||||
new UserSettings().getSelectedEffectName("transportShipTrail"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("clearing the last selection removes the storage key", () => {
|
||||
const s = new UserSettings();
|
||||
s.setSelectedEffectName("transportShipTrail", "spectrum");
|
||||
s.setSelectedEffectName("transportShipTrail", undefined);
|
||||
expect(s.getSelectedEffectName("transportShipTrail")).toBeNull();
|
||||
expect(localStorage.getItem(EFFECTS_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("clearing one effectType leaves other types intact", () => {
|
||||
const s = new UserSettings();
|
||||
// Seed two types directly (only one real effectType exists today).
|
||||
localStorage.setItem(
|
||||
EFFECTS_KEY,
|
||||
JSON.stringify({ transportShipTrail: "spectrum", future: "x" }),
|
||||
);
|
||||
s.setSelectedEffectName("transportShipTrail", undefined);
|
||||
expect(s.getSelectedEffects()).toEqual({ future: "x" });
|
||||
});
|
||||
|
||||
it("returns an empty map for a corrupt blob", () => {
|
||||
localStorage.setItem(EFFECTS_KEY, "not json");
|
||||
expect(new UserSettings().getSelectedEffects()).toEqual({});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user