mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 19:55:49 +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:
@@ -786,6 +786,52 @@ describe("effect selection slots", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("crowns in the cosmetics catalog", () => {
|
||||
const goldCrown = {
|
||||
name: "gold_crown",
|
||||
url: "http://localhost:8787/public/cosmetics/crown/gold",
|
||||
affiliateCode: null,
|
||||
product: null,
|
||||
priceHard: 5,
|
||||
artist: "sadfas",
|
||||
rarity: "common",
|
||||
};
|
||||
|
||||
it("parses a crowns catalog entry", () => {
|
||||
const result = CosmeticsSchema.safeParse({
|
||||
patterns: {},
|
||||
flags: {},
|
||||
crowns: { gold_crown: goldCrown },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.crowns?.gold_crown?.name).toBe("gold_crown");
|
||||
expect(result.data.crowns?.gold_crown?.url).toBe(
|
||||
"http://localhost:8787/public/cosmetics/crown/gold",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("parses a catalog without crowns (older cosmetics.json)", () => {
|
||||
const result = CosmeticsSchema.safeParse({ patterns: {}, flags: {} });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.crowns).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a crown without a url", () => {
|
||||
const noUrl = { ...goldCrown, url: undefined };
|
||||
expect(
|
||||
CosmeticsSchema.safeParse({
|
||||
patterns: {},
|
||||
flags: {},
|
||||
crowns: { gold_crown: noUrl },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SubscriptionSchema unlimitedRanked", () => {
|
||||
const base = {
|
||||
name: "gold",
|
||||
|
||||
@@ -88,6 +88,37 @@ const skinChecker = new PrivilegeCheckerImpl(
|
||||
bannedWords,
|
||||
);
|
||||
|
||||
const crownCosmetics = {
|
||||
patterns: {},
|
||||
colorPalettes: {},
|
||||
flags: {},
|
||||
crowns: {
|
||||
gold_crown: {
|
||||
name: "gold_crown",
|
||||
url: "https://example.com/gold.png",
|
||||
affiliateCode: null,
|
||||
product: null,
|
||||
priceSoft: undefined,
|
||||
priceHard: 5,
|
||||
rarity: "common",
|
||||
},
|
||||
silver_crown: {
|
||||
name: "silver_crown",
|
||||
url: "https://example.com/silver.png",
|
||||
affiliateCode: null,
|
||||
product: null,
|
||||
priceSoft: undefined,
|
||||
priceHard: undefined,
|
||||
rarity: "rare",
|
||||
},
|
||||
},
|
||||
};
|
||||
const crownChecker = new PrivilegeCheckerImpl(
|
||||
crownCosmetics,
|
||||
mockDecoder,
|
||||
bannedWords,
|
||||
);
|
||||
|
||||
const effectCosmetics = {
|
||||
patterns: {},
|
||||
colorPalettes: {},
|
||||
@@ -591,6 +622,133 @@ describe("Skin validation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Crown validation", () => {
|
||||
describe("isCrownAllowed (direct)", () => {
|
||||
test("returns crown when user has wildcard flare", () => {
|
||||
const result = crownChecker.isCrownAllowed(["crown:*"], "gold_crown");
|
||||
expect(result).toEqual({
|
||||
name: "gold_crown",
|
||||
url: "https://example.com/gold.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns crown when user has exact-match flare", () => {
|
||||
const result = crownChecker.isCrownAllowed(
|
||||
["crown:gold_crown"],
|
||||
"gold_crown",
|
||||
);
|
||||
expect(result).toEqual({
|
||||
name: "gold_crown",
|
||||
url: "https://example.com/gold.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("ignores unrelated flares", () => {
|
||||
expect(() =>
|
||||
crownChecker.isCrownAllowed(
|
||||
["crown:silver_crown", "skin:*", "flag:*"],
|
||||
"gold_crown",
|
||||
),
|
||||
).toThrow(/No flares for crown gold_crown/);
|
||||
});
|
||||
|
||||
test("throws when user has no crown flares", () => {
|
||||
expect(() => crownChecker.isCrownAllowed([], "gold_crown")).toThrow(
|
||||
/No flares for crown gold_crown/,
|
||||
);
|
||||
});
|
||||
|
||||
test("throws when crown does not exist in cosmetics", () => {
|
||||
expect(() =>
|
||||
crownChecker.isCrownAllowed(["crown:*"], "nonexistent"),
|
||||
).toThrow(/Crown nonexistent not found/);
|
||||
});
|
||||
|
||||
test("throws when crown does not exist even with exact-match flare", () => {
|
||||
// Forged refs.crownName must not bypass the existence check.
|
||||
expect(() =>
|
||||
crownChecker.isCrownAllowed(["crown:nonexistent"], "nonexistent"),
|
||||
).toThrow(/Crown nonexistent not found/);
|
||||
});
|
||||
|
||||
test("throws when checker has no crowns map at all", () => {
|
||||
// checker is constructed with mockCosmetics (no crowns key).
|
||||
expect(() => checker.isCrownAllowed(["crown:*"], "anything")).toThrow(
|
||||
/Crown anything not found/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAllowed integration", () => {
|
||||
test("allows valid crown with wildcard flare", () => {
|
||||
const result = crownChecker.isAllowed(["crown:*"], {
|
||||
crownName: "gold_crown",
|
||||
});
|
||||
expect(result.type).toBe("allowed");
|
||||
if (result.type === "allowed") {
|
||||
expect(result.cosmetics.crown).toEqual({
|
||||
name: "gold_crown",
|
||||
url: "https://example.com/gold.png",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("allows valid crown with exact-match flare", () => {
|
||||
const result = crownChecker.isAllowed(["crown:silver_crown"], {
|
||||
crownName: "silver_crown",
|
||||
});
|
||||
expect(result.type).toBe("allowed");
|
||||
if (result.type === "allowed") {
|
||||
expect(result.cosmetics.crown).toEqual({
|
||||
name: "silver_crown",
|
||||
url: "https://example.com/silver.png",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects crown when user lacks flare", () => {
|
||||
const result = crownChecker.isAllowed([], { crownName: "gold_crown" });
|
||||
expect(result.type).toBe("forbidden");
|
||||
if (result.type === "forbidden") {
|
||||
expect(result.reason).toMatch(/invalid crown/);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects crown when flare is for a different crown", () => {
|
||||
const result = crownChecker.isAllowed(["crown:silver_crown"], {
|
||||
crownName: "gold_crown",
|
||||
});
|
||||
expect(result.type).toBe("forbidden");
|
||||
});
|
||||
|
||||
test("rejects nonexistent crown", () => {
|
||||
const result = crownChecker.isAllowed(["crown:*"], {
|
||||
crownName: "ghost",
|
||||
});
|
||||
expect(result.type).toBe("forbidden");
|
||||
if (result.type === "forbidden") {
|
||||
expect(result.reason).toMatch(/Crown ghost not found/);
|
||||
}
|
||||
});
|
||||
|
||||
test("no crown in refs leaves cosmetics.crown undefined", () => {
|
||||
const result = crownChecker.isAllowed(["crown:*"], {});
|
||||
expect(result.type).toBe("allowed");
|
||||
if (result.type === "allowed") {
|
||||
expect(result.cosmetics.crown).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("invalid crown short-circuits and does not return other cosmetics", () => {
|
||||
const result = crownChecker.isAllowed(["color:red"], {
|
||||
color: "red",
|
||||
crownName: "gold_crown",
|
||||
});
|
||||
expect(result.type).toBe("forbidden");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Effect validation in isAllowed", () => {
|
||||
test("allows valid effect with wildcard flare", () => {
|
||||
const result = effectChecker.isAllowed(["effect:*"], {
|
||||
|
||||
@@ -294,6 +294,75 @@ describe("resolveCosmetics", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("crowns", () => {
|
||||
const crown = {
|
||||
name: "gold_crown",
|
||||
url: "http://localhost:8787/public/cosmetics/crown/gold",
|
||||
affiliateCode: null,
|
||||
product,
|
||||
priceSoft: undefined,
|
||||
priceHard: 5,
|
||||
artist: "sadfas",
|
||||
rarity: "common",
|
||||
};
|
||||
|
||||
test("includes crowns with correct key", () => {
|
||||
const cosmetics = makeCosmetics({
|
||||
crowns: { gold_crown: crown as any },
|
||||
});
|
||||
const result = resolveCosmetics(cosmetics, false, null);
|
||||
const crownItem = result.find((r) => r.key === "crown:gold_crown");
|
||||
expect(crownItem).toBeDefined();
|
||||
expect(crownItem?.cosmetic).toEqual(crown);
|
||||
expect(crownItem?.colorPalette).toBeNull();
|
||||
});
|
||||
|
||||
test("purchasable when user has no flares and priceHard exists", () => {
|
||||
const cosmetics = makeCosmetics({
|
||||
crowns: { gold_crown: crown as any },
|
||||
});
|
||||
const result = resolveCosmetics(cosmetics, makeUserMe(), null);
|
||||
const crownItem = result.find((r) => r.key === "crown:gold_crown");
|
||||
expect(crownItem?.relationship).toBe("purchasable");
|
||||
});
|
||||
|
||||
test("owned with wildcard flare", () => {
|
||||
const cosmetics = makeCosmetics({
|
||||
crowns: { gold_crown: crown as any },
|
||||
});
|
||||
const result = resolveCosmetics(cosmetics, makeUserMe(["crown:*"]), null);
|
||||
const crownItem = result.find((r) => r.key === "crown:gold_crown");
|
||||
expect(crownItem?.relationship).toBe("owned");
|
||||
});
|
||||
|
||||
test("owned with specific flare", () => {
|
||||
const cosmetics = makeCosmetics({
|
||||
crowns: { gold_crown: crown as any },
|
||||
});
|
||||
const result = resolveCosmetics(
|
||||
cosmetics,
|
||||
makeUserMe(["crown:gold_crown"]),
|
||||
null,
|
||||
);
|
||||
const crownItem = result.find((r) => r.key === "crown:gold_crown");
|
||||
expect(crownItem?.relationship).toBe("owned");
|
||||
});
|
||||
|
||||
test("blocked with no product and no price", () => {
|
||||
const freeCrown = {
|
||||
...crown,
|
||||
product: null,
|
||||
priceHard: undefined,
|
||||
};
|
||||
const cosmetics = makeCosmetics({
|
||||
crowns: { gold_crown: freeCrown as any },
|
||||
});
|
||||
const result = resolveCosmetics(cosmetics, makeUserMe(), null);
|
||||
const crownItem = result.find((r) => r.key === "crown:gold_crown");
|
||||
expect(crownItem?.relationship).toBe("blocked");
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupCosmeticVariants", () => {
|
||||
const patternVariant = (
|
||||
patternName: string,
|
||||
|
||||
Reference in New Issue
Block a user