mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 14:36:34 +00:00
Add crown cosmetic type (#4618)
## What Adds **crowns** as a new cosmetic category, wired end-to-end like flags/skins, plus a Crowns tab in the lobby cosmetics modal (#4617). Catalog shape in cosmetics.json: ```json "crowns": { "gold_crown": { "name": "gold_crown", "url": "...", "affiliateCode": null, "product": null, "priceHard": 5, "artist": "...", "rarity": "common" } } ``` ## Changes - **Core**: `CrownSchema` + optional `crowns` record in `CosmeticsSchema`; `crownName` in `PlayerCosmeticRefs`; resolved `crown: { name, url }` in `PlayerCosmetics` - **Server**: `isCrownAllowed` in the privilege checker — requires a `crown:*` or `crown:<name>` flare, resolves the ref to the catalog URL - **Client**: crown selection persisted under the `crown` localStorage key (stale/unowned selections self-clear), `crownRelationship` + resolve/refs wiring, `"crown"` purchase type, Crowns tab in the store, and a Crowns tab in the cosmetics modal (owned crowns + Default tile; selecting persists and keeps the modal open) - **i18n**: `store.crowns`, `store.no_crowns` - Tests for schema parsing, privilege checks, and cosmetic resolution ## Not in this PR - In-game rendering of `player.cosmetics.crown` (name pass / leaderboards / player panel) - API-side support (`/shop/purchase` accepting `cosmeticType: "crown"`, granting `crown:<name>` flares, serving `crowns` in cosmetics.json) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -192,7 +192,7 @@ export async function setMarketingConsent(
|
||||
}
|
||||
|
||||
export async function purchaseWithCurrency(
|
||||
cosmeticType: "pattern" | "skin" | "flag" | "effect",
|
||||
cosmeticType: "pattern" | "skin" | "flag" | "crown" | "effect",
|
||||
cosmeticName: string,
|
||||
currencyType: "hard" | "soft",
|
||||
colorPaletteName?: string,
|
||||
|
||||
+84
-7
@@ -4,6 +4,7 @@ import {
|
||||
ColorPalette,
|
||||
Cosmetics,
|
||||
CosmeticsSchema,
|
||||
Crown,
|
||||
Effect,
|
||||
findEffectForSlot,
|
||||
Flag,
|
||||
@@ -165,10 +166,14 @@ export async function purchaseCosmetic(
|
||||
const currencyName = translateText(
|
||||
method === "hard" ? "cosmetics.hard" : "cosmetics.soft",
|
||||
);
|
||||
const itemName =
|
||||
resolved.type === "flag"
|
||||
? translateCosmetic("flags", c.name)
|
||||
: translateCosmetic("territory_patterns.pattern", c.name);
|
||||
let itemName: string;
|
||||
if (resolved.type === "flag") {
|
||||
itemName = translateCosmetic("flags", c.name);
|
||||
} else if (resolved.type === "crown") {
|
||||
itemName = translateCosmetic("crowns", c.name);
|
||||
} else {
|
||||
itemName = translateCosmetic("territory_patterns.pattern", c.name);
|
||||
}
|
||||
return {
|
||||
currency: currencyName,
|
||||
shortfall: price - balance,
|
||||
@@ -178,7 +183,12 @@ export async function purchaseCosmetic(
|
||||
};
|
||||
}
|
||||
|
||||
const cosmeticType = resolved.type as "pattern" | "skin" | "flag" | "effect";
|
||||
const cosmeticType = resolved.type as
|
||||
| "pattern"
|
||||
| "skin"
|
||||
| "flag"
|
||||
| "crown"
|
||||
| "effect";
|
||||
const success = await purchaseWithCurrency(
|
||||
cosmeticType,
|
||||
c.name,
|
||||
@@ -360,6 +370,25 @@ export function flagRelationship(
|
||||
);
|
||||
}
|
||||
|
||||
export function crownRelationship(
|
||||
crown: Crown,
|
||||
userMeResponse: UserMeResponse | false,
|
||||
affiliateCode: string | null,
|
||||
): "owned" | "purchasable" | "blocked" {
|
||||
return cosmeticRelationship(
|
||||
{
|
||||
wildcardFlare: "crown:*",
|
||||
requiredFlare: `crown:${crown.name}`,
|
||||
product: crown.product,
|
||||
priceSoft: crown.priceSoft,
|
||||
priceHard: crown.priceHard,
|
||||
affiliateCode,
|
||||
itemAffiliateCode: crown.affiliateCode ?? null,
|
||||
},
|
||||
userMeResponse,
|
||||
);
|
||||
}
|
||||
|
||||
export function skinRelationship(
|
||||
skin: Skin,
|
||||
userMeResponse: UserMeResponse | false,
|
||||
@@ -399,8 +428,15 @@ export function effectRelationship(
|
||||
}
|
||||
|
||||
export type ResolvedCosmetic = {
|
||||
type: "pattern" | "skin" | "flag" | "effect" | "pack" | "subscription";
|
||||
cosmetic: Pattern | Skin | Flag | Effect | Pack | Subscription | null;
|
||||
type:
|
||||
| "pattern"
|
||||
| "skin"
|
||||
| "flag"
|
||||
| "crown"
|
||||
| "effect"
|
||||
| "pack"
|
||||
| "subscription";
|
||||
cosmetic: Pattern | Skin | Flag | Crown | Effect | Pack | Subscription | null;
|
||||
colorPalette: ColorPalette | null;
|
||||
relationship: "owned" | "purchasable" | "blocked";
|
||||
/** Unique key for selection/identity, e.g. "pattern:hearts:red" or "skin:mountain" */
|
||||
@@ -468,6 +504,18 @@ export function resolveCosmetics(
|
||||
});
|
||||
}
|
||||
|
||||
// Crowns
|
||||
for (const [crownKey, crown] of Object.entries(cosmetics.crowns ?? {})) {
|
||||
const rel = crownRelationship(crown, userMeResponse, affiliateCode);
|
||||
result.push({
|
||||
type: "crown",
|
||||
cosmetic: crown,
|
||||
colorPalette: null,
|
||||
relationship: rel,
|
||||
key: `crown:${crownKey}`,
|
||||
});
|
||||
}
|
||||
|
||||
// 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 ?? {})) {
|
||||
@@ -645,6 +693,27 @@ export async function getPlayerCosmeticsRefs(): Promise<PlayerCosmeticRefs> {
|
||||
}
|
||||
}
|
||||
|
||||
let crownName = userSettings.getSelectedCrownName() ?? undefined;
|
||||
if (crownName) {
|
||||
const crown = cosmetics?.crowns?.[crownName];
|
||||
if (cosmetics && !crown) {
|
||||
// Cosmetics loaded but the saved crown no longer exists.
|
||||
crownName = undefined;
|
||||
} else if (crown) {
|
||||
const userMe = await getUserMe();
|
||||
if (userMe) {
|
||||
const flares = userMe.player.flares ?? [];
|
||||
const hasWildcard = flares.includes("crown:*");
|
||||
if (!hasWildcard && !flares.includes(`crown:${crown.name}`)) {
|
||||
crownName = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (crownName === undefined) {
|
||||
userSettings.setSelectedCrownName(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
// Effects: a per-slot map (slot -> effect name). A slot is the effectType for
|
||||
// trails and the nukeType for nuke explosions (see effectTypeForSlot). Drop any
|
||||
// entry whose effect no longer exists, doesn't fit the slot, or the user can't
|
||||
@@ -677,6 +746,7 @@ export async function getPlayerCosmeticsRefs(): Promise<PlayerCosmeticRefs> {
|
||||
patternName: pattern?.name ?? undefined,
|
||||
patternColorPaletteName: pattern?.colorPalette?.name ?? undefined,
|
||||
skinName,
|
||||
crownName,
|
||||
effects: Object.keys(effects).length > 0 ? effects : undefined,
|
||||
};
|
||||
}
|
||||
@@ -720,6 +790,13 @@ export async function getPlayerCosmetics(): Promise<PlayerCosmetics> {
|
||||
}
|
||||
}
|
||||
|
||||
if (refs.crownName && cosmetics) {
|
||||
const crown = cosmetics.crowns?.[refs.crownName];
|
||||
if (crown) {
|
||||
result.crown = { name: refs.crownName, url: crown.url };
|
||||
}
|
||||
}
|
||||
|
||||
if (refs.effects && cosmetics) {
|
||||
const effects: Record<string, PlayerEffect> = {};
|
||||
for (const [slot, name] of Object.entries(refs.effects)) {
|
||||
|
||||
@@ -2,8 +2,9 @@ import type { TemplateResult } from "lit";
|
||||
import { html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import { Cosmetics, Skin } from "../core/CosmeticSchemas";
|
||||
import { Cosmetics, Crown, Skin } from "../core/CosmeticSchemas";
|
||||
import {
|
||||
CROWN_KEY,
|
||||
PATTERN_KEY,
|
||||
USER_SETTINGS_CHANGED_EVENT,
|
||||
UserSettings,
|
||||
@@ -24,9 +25,9 @@ import {
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
/**
|
||||
* One modal for every non-flag cosmetic: a Skins tab (patterns + image skins)
|
||||
* and an Effects tab (all effect types via the tabbed effects-grid). Opened
|
||||
* from the lobby's "Cosmetics" button.
|
||||
* One modal for every non-flag cosmetic: a Skins tab (patterns + image skins),
|
||||
* a Crowns tab, and an Effects tab (all effect types via the tabbed
|
||||
* effects-grid). Opened from the lobby's "Cosmetics" button.
|
||||
*/
|
||||
@customElement("cosmetics-modal")
|
||||
export class CosmeticsModal extends BaseModal {
|
||||
@@ -45,6 +46,7 @@ export class CosmeticsModal extends BaseModal {
|
||||
return {
|
||||
tabs: [
|
||||
{ key: "skins", label: translateText("store.patterns") },
|
||||
{ key: "crowns", label: translateText("store.crowns") },
|
||||
{ key: "effects", label: translateText("store.effects") },
|
||||
],
|
||||
};
|
||||
@@ -67,6 +69,10 @@ export class CosmeticsModal extends BaseModal {
|
||||
`${USER_SETTINGS_CHANGED_EVENT}:${PATTERN_KEY}`,
|
||||
this._onCosmeticSelected,
|
||||
);
|
||||
window.addEventListener(
|
||||
`${USER_SETTINGS_CHANGED_EVENT}:${CROWN_KEY}`,
|
||||
this._onCosmeticSelected,
|
||||
);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
@@ -75,6 +81,10 @@ export class CosmeticsModal extends BaseModal {
|
||||
`${USER_SETTINGS_CHANGED_EVENT}:${PATTERN_KEY}`,
|
||||
this._onCosmeticSelected,
|
||||
);
|
||||
window.removeEventListener(
|
||||
`${USER_SETTINGS_CHANGED_EVENT}:${CROWN_KEY}`,
|
||||
this._onCosmeticSelected,
|
||||
);
|
||||
}
|
||||
|
||||
private async updateFromSettings() {
|
||||
@@ -146,6 +156,52 @@ export class CosmeticsModal extends BaseModal {
|
||||
`;
|
||||
}
|
||||
|
||||
/** Owned crowns + a Default (none) tile; selecting persists to UserSettings. */
|
||||
private renderCrownGrid(): TemplateResult {
|
||||
const items = resolveCosmetics(
|
||||
this.cosmetics,
|
||||
this.userMeResponse,
|
||||
null,
|
||||
).filter(
|
||||
(r) =>
|
||||
r.type === "crown" &&
|
||||
r.relationship === "owned" &&
|
||||
r.cosmetic !== null &&
|
||||
this.includedInSearch(r.cosmetic.name),
|
||||
);
|
||||
|
||||
// The Default tile has no name to match — hide it while searching.
|
||||
const noneTile: ResolvedCosmetic = {
|
||||
type: "crown",
|
||||
cosmetic: null,
|
||||
colorPalette: null,
|
||||
relationship: "owned",
|
||||
key: "crown:none",
|
||||
};
|
||||
const tiles = this.search ? items : [noneTile, ...items];
|
||||
|
||||
const selectedCrown = this.userSettings.getSelectedCrownName();
|
||||
return html`
|
||||
<div
|
||||
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
|
||||
>
|
||||
${tiles.map((r) => {
|
||||
const name = (r.cosmetic as Crown | null)?.name ?? null;
|
||||
const isSelected =
|
||||
(name === null && selectedCrown === null) ||
|
||||
(name !== null && selectedCrown === name);
|
||||
return html`
|
||||
<cosmetic-button
|
||||
.resolved=${r}
|
||||
.selected=${isSelected}
|
||||
.onSelect=${() => this.selectCrown(name)}
|
||||
></cosmetic-button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
protected renderHeaderSlot() {
|
||||
return html`
|
||||
<div
|
||||
@@ -176,7 +232,9 @@ export class CosmeticsModal extends BaseModal {
|
||||
|
||||
protected renderBody(tab: string) {
|
||||
let grid: TemplateResult;
|
||||
if (tab === "effects") {
|
||||
if (tab === "crowns") {
|
||||
grid = this.renderCrownGrid();
|
||||
} else if (tab === "effects") {
|
||||
grid = html`<effects-grid
|
||||
mode="select"
|
||||
tabbed
|
||||
@@ -230,6 +288,12 @@ export class CosmeticsModal extends BaseModal {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
private selectCrown(crownName: string | null) {
|
||||
this.userSettings.setSelectedCrownName(crownName ?? undefined);
|
||||
// Stay open — the tile highlight moves to the new selection.
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
private selectPattern(pattern: PlayerPattern | null) {
|
||||
this.selectedColor = null;
|
||||
if (pattern === null) {
|
||||
|
||||
+49
-1
@@ -19,7 +19,13 @@ import {
|
||||
} from "./Cosmetics";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
type StoreTab = "patterns" | "flags" | "effects" | "packs" | "subscriptions";
|
||||
type StoreTab =
|
||||
| "patterns"
|
||||
| "flags"
|
||||
| "crowns"
|
||||
| "effects"
|
||||
| "packs"
|
||||
| "subscriptions";
|
||||
|
||||
@customElement("store-modal")
|
||||
export class StoreModal extends BaseModal {
|
||||
@@ -39,6 +45,7 @@ export class StoreModal extends BaseModal {
|
||||
{ key: "subscriptions", label: translateText("store.subscriptions") },
|
||||
{ key: "patterns", label: translateText("store.patterns") },
|
||||
{ key: "flags", label: translateText("store.flags") },
|
||||
{ key: "crowns", label: translateText("store.crowns") },
|
||||
{ key: "effects", label: translateText("store.effects") },
|
||||
],
|
||||
};
|
||||
@@ -158,6 +165,44 @@ export class StoreModal extends BaseModal {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCrownGrid(): TemplateResult {
|
||||
const items = resolveCosmetics(
|
||||
this.cosmetics,
|
||||
this.userMeResponse,
|
||||
this.affiliateCode,
|
||||
).filter(
|
||||
(r) =>
|
||||
r.type === "crown" &&
|
||||
r.relationship !== "blocked" &&
|
||||
r.relationship !== "owned",
|
||||
);
|
||||
|
||||
if (items.length === 0) {
|
||||
return html`<div
|
||||
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
|
||||
>
|
||||
${translateText("store.no_crowns")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const selectedCrown = new UserSettings().getSelectedCrownName() ?? "";
|
||||
return html`
|
||||
<div
|
||||
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
|
||||
>
|
||||
${items.map(
|
||||
(r) => html`
|
||||
<cosmetic-button
|
||||
.resolved=${r}
|
||||
.selected=${`crown:${selectedCrown}` === r.key}
|
||||
.onPurchase=${purchaseCosmetic}
|
||||
></cosmetic-button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderEffectGrid(): TemplateResult {
|
||||
// A sub-tab per effectType (Boat Trail / Nuke Trail); each tab opens that
|
||||
// type's grid. Tabs are always present, even when a type has nothing to buy.
|
||||
@@ -249,6 +294,8 @@ export class StoreModal extends BaseModal {
|
||||
return this.renderPatternGrid();
|
||||
case "flags":
|
||||
return this.renderFlagGrid();
|
||||
case "crowns":
|
||||
return this.renderCrownGrid();
|
||||
case "effects":
|
||||
return this.renderEffectGrid();
|
||||
case "subscriptions":
|
||||
@@ -269,6 +316,7 @@ export class StoreModal extends BaseModal {
|
||||
(r.type === "pattern" ||
|
||||
r.type === "skin" ||
|
||||
r.type === "flag" ||
|
||||
r.type === "crown" ||
|
||||
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 {
|
||||
Crown,
|
||||
Effect,
|
||||
Flag,
|
||||
isNukeExplosionEffect,
|
||||
@@ -91,6 +92,9 @@ export class CosmeticButton extends LitElement {
|
||||
if (this.activeResolved.type === "effect") {
|
||||
return translateCosmetic("effects", c.name);
|
||||
}
|
||||
if (this.activeResolved.type === "crown") {
|
||||
return translateCosmetic("crowns", c.name);
|
||||
}
|
||||
return translateCosmetic("flags", c.name);
|
||||
}
|
||||
|
||||
@@ -209,6 +213,25 @@ export class CosmeticButton extends LitElement {
|
||||
></trail-swatch>`;
|
||||
}
|
||||
|
||||
if (this.activeResolved.type === "crown") {
|
||||
const c = this.activeResolved.cosmetic as Crown | null;
|
||||
if (c === null) {
|
||||
// "Default" (none) tile — selecting it clears the crown.
|
||||
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>`;
|
||||
}
|
||||
return html`<img
|
||||
src=${c.url}
|
||||
alt=${c.name}
|
||||
class="w-full h-full object-contain pointer-events-none"
|
||||
draggable="false"
|
||||
loading="lazy"
|
||||
/>`;
|
||||
}
|
||||
|
||||
if (this.activeResolved.type === "pack") {
|
||||
const pack = this.activeResolved.cosmetic as Pack;
|
||||
const isHard = pack.currency === "hard";
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PlayerPattern } from "./Schemas";
|
||||
export type Cosmetics = z.infer<typeof CosmeticsSchema>;
|
||||
export type Pattern = z.infer<typeof PatternSchema>;
|
||||
export type Flag = z.infer<typeof FlagSchema>;
|
||||
export type Crown = z.infer<typeof CrownSchema>;
|
||||
export type Skin = z.infer<typeof SkinSchema>;
|
||||
export type Pack = z.infer<typeof PackSchema>;
|
||||
export type Subscription = z.infer<typeof SubscriptionSchema>;
|
||||
@@ -96,6 +97,10 @@ export const FlagSchema = CosmeticSchema.extend({
|
||||
url: z.string(),
|
||||
});
|
||||
|
||||
export const CrownSchema = CosmeticSchema.extend({
|
||||
url: z.string(),
|
||||
});
|
||||
|
||||
export const SkinSchema = CosmeticSchema.extend({
|
||||
url: z.string(),
|
||||
});
|
||||
@@ -363,6 +368,7 @@ export const CosmeticsSchema = z.object({
|
||||
colorPalettes: z.record(z.string(), ColorPaletteSchema).optional(),
|
||||
patterns: z.record(z.string(), PatternSchema),
|
||||
flags: z.record(z.string(), FlagSchema),
|
||||
crowns: z.record(z.string(), CrownSchema).optional(),
|
||||
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
|
||||
|
||||
@@ -145,6 +145,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 PlayerCrown = z.infer<typeof PlayerCrownSchema>;
|
||||
export type PlayerEffect = z.infer<typeof PlayerEffectSchema>;
|
||||
export type GameStartInfo = z.infer<typeof GameStartInfoSchema>;
|
||||
export type GameInfo = z.infer<typeof GameInfoSchema>;
|
||||
@@ -653,6 +654,7 @@ export const PlayerCosmeticRefsSchema = z.object({
|
||||
patternName: CosmeticNameSchema.optional(),
|
||||
patternColorPaletteName: z.string().optional(),
|
||||
skinName: CosmeticNameSchema.optional(),
|
||||
crownName: CosmeticNameSchema.optional(),
|
||||
// One selected effect per slot: key = slot (effectType for trails, nukeType for
|
||||
// nuke explosions — see effectTypeForSlot), value = effect name.
|
||||
effects: z.record(z.string(), CosmeticNameSchema).optional(),
|
||||
@@ -663,6 +665,11 @@ export const PlayerSkinSchema = z.object({
|
||||
url: z.string(),
|
||||
});
|
||||
|
||||
export const PlayerCrownSchema = z.object({
|
||||
name: CosmeticNameSchema,
|
||||
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
|
||||
@@ -678,6 +685,7 @@ export const PlayerCosmeticsSchema = z.object({
|
||||
pattern: PlayerPatternSchema.optional(),
|
||||
color: PlayerColorSchema.optional(),
|
||||
skin: PlayerSkinSchema.optional(),
|
||||
crown: PlayerCrownSchema.optional(),
|
||||
// Resolved effects keyed by slot (effectType for trails, nukeType for nuke
|
||||
// explosions).
|
||||
effects: z.record(z.string(), PlayerEffectSchema).optional(),
|
||||
|
||||
@@ -54,6 +54,7 @@ export const USER_SETTINGS_CHANGED_EVENT = "event:user-settings-changed";
|
||||
*/
|
||||
export const PATTERN_KEY = "territoryPattern";
|
||||
export const FLAG_KEY = "flag";
|
||||
export const CROWN_KEY = "crown";
|
||||
export const COLOR_KEY = "settings.territoryColor";
|
||||
export const PERFORMANCE_OVERLAY_KEY = "settings.performanceOverlay";
|
||||
export const KEYBINDS_KEY = "settings.keybinds";
|
||||
@@ -301,6 +302,19 @@ export class UserSettings {
|
||||
return data.startsWith(skinPrefix) ? data.slice(skinPrefix.length) : null;
|
||||
}
|
||||
|
||||
/** Returns the selected crown name, or null if none is selected. */
|
||||
getSelectedCrownName(): string | null {
|
||||
return this.getCached(CROWN_KEY);
|
||||
}
|
||||
|
||||
setSelectedCrownName(name: string | undefined): void {
|
||||
if (name === undefined) {
|
||||
this.removeCached(CROWN_KEY);
|
||||
} else {
|
||||
this.setCached(CROWN_KEY, name);
|
||||
}
|
||||
}
|
||||
|
||||
getFlag(): string | null {
|
||||
let flag = this.getCached(FLAG_KEY);
|
||||
if (!flag) return null;
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
PlayerColor,
|
||||
PlayerCosmeticRefs,
|
||||
PlayerCosmetics,
|
||||
PlayerCrown,
|
||||
PlayerEffect,
|
||||
PlayerPattern,
|
||||
PlayerSkin,
|
||||
@@ -258,6 +259,14 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
|
||||
return { type: "forbidden", reason: "invalid skin: " + message };
|
||||
}
|
||||
}
|
||||
if (refs.crownName) {
|
||||
try {
|
||||
cosmetics.crown = this.isCrownAllowed(flares, refs.crownName);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return { type: "forbidden", reason: "invalid crown: " + message };
|
||||
}
|
||||
}
|
||||
if (refs.effects) {
|
||||
for (const [slot, name] of Object.entries(refs.effects)) {
|
||||
try {
|
||||
@@ -300,6 +309,15 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
|
||||
throw new Error(`No flares for skin ${name}`);
|
||||
}
|
||||
|
||||
isCrownAllowed(flares: string[], name: string): PlayerCrown {
|
||||
const found = this.cosmetics.crowns?.[name];
|
||||
if (!found) throw new Error(`Crown ${name} not found`);
|
||||
if (flares.includes("crown:*") || flares.includes(`crown:${found.name}`)) {
|
||||
return { name: found.name, url: found.url };
|
||||
}
|
||||
throw new Error(`No flares for crown ${name}`);
|
||||
}
|
||||
|
||||
isPatternAllowed(
|
||||
flares: readonly string[],
|
||||
name: string,
|
||||
|
||||
Reference in New Issue
Block a user