mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-08-05 04:36:49 +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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user