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";
|
||||
|
||||
Reference in New Issue
Block a user