support for unlockable flags (#3479)

## Description:

Add support for purchasable/gated flags.

* Create a new "Store" modal that renders both skins & flags
* move all store related logic out of TerritoryPatternsModal
* use nation:code for existing nation flags & flag:key for gated flags
* check if user has the appropriate flags before purchasing

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evan
This commit is contained in:
Evan
2026-03-23 17:09:18 -07:00
committed by GitHub
parent 426806299f
commit 39ad547c04
30 changed files with 1144 additions and 1155 deletions
+147
View File
@@ -0,0 +1,147 @@
import { cosmeticRelationship } from "../src/client/Cosmetics";
import { UserMeResponse } from "../src/core/ApiSchemas";
const product = { productId: "prod_123", priceId: "price_123", price: "$4.99" };
function makeUserMe(flares: string[]): UserMeResponse {
return {
player: { flares },
} as unknown as UserMeResponse;
}
describe("cosmeticRelationship", () => {
it("returns owned when user has wildcard flare", () => {
expect(
cosmeticRelationship(
{
wildcardFlare: "flag:*",
requiredFlare: "flag:cool",
product,
affiliateCode: null,
itemAffiliateCode: null,
},
makeUserMe(["flag:*"]),
),
).toBe("owned");
});
it("returns owned when user has the specific flare", () => {
expect(
cosmeticRelationship(
{
wildcardFlare: "flag:*",
requiredFlare: "flag:cool",
product,
affiliateCode: null,
itemAffiliateCode: null,
},
makeUserMe(["flag:cool"]),
),
).toBe("owned");
});
it("returns blocked when no product and user does not own it", () => {
expect(
cosmeticRelationship(
{
wildcardFlare: "flag:*",
requiredFlare: "flag:cool",
product: null,
affiliateCode: null,
itemAffiliateCode: null,
},
makeUserMe([]),
),
).toBe("blocked");
});
it("returns blocked when affiliate codes do not match", () => {
expect(
cosmeticRelationship(
{
wildcardFlare: "flag:*",
requiredFlare: "flag:cool",
product,
affiliateCode: "storeA",
itemAffiliateCode: "storeB",
},
makeUserMe([]),
),
).toBe("blocked");
});
it("returns purchasable when product exists and affiliate matches", () => {
expect(
cosmeticRelationship(
{
wildcardFlare: "flag:*",
requiredFlare: "flag:cool",
product,
affiliateCode: null,
itemAffiliateCode: null,
},
makeUserMe([]),
),
).toBe("purchasable");
});
it("returns purchasable when affiliate codes match", () => {
expect(
cosmeticRelationship(
{
wildcardFlare: "pattern:*",
requiredFlare: "pattern:stripes:red",
product,
affiliateCode: "storeA",
itemAffiliateCode: "storeA",
},
makeUserMe([]),
),
).toBe("purchasable");
});
it("returns blocked when user is not logged in and no product", () => {
expect(
cosmeticRelationship(
{
wildcardFlare: "flag:*",
requiredFlare: "flag:cool",
product: null,
affiliateCode: null,
itemAffiliateCode: null,
},
false,
),
).toBe("blocked");
});
it("returns purchasable when user is not logged in but product exists", () => {
expect(
cosmeticRelationship(
{
wildcardFlare: "flag:*",
requiredFlare: "flag:cool",
product,
affiliateCode: null,
itemAffiliateCode: null,
},
false,
),
).toBe("purchasable");
});
it("returns owned when user has wildcard flare for patterns", () => {
expect(
cosmeticRelationship(
{
wildcardFlare: "pattern:*",
requiredFlare: "pattern:stripes:red",
product,
affiliateCode: null,
itemAffiliateCode: null,
},
makeUserMe(["pattern:*"]),
),
).toBe("owned");
});
});
+79 -1
View File
@@ -18,7 +18,7 @@ const bannedWords = [
const matcher = createMatcher(bannedWords);
// Create a minimal PrivilegeCheckerImpl for testing censorUsername
const mockCosmetics = { patterns: {}, colorPalettes: {} };
const mockCosmetics = { patterns: {}, colorPalettes: {}, flags: {} };
const mockDecoder = () => new Uint8Array();
const checker = new PrivilegeCheckerImpl(
mockCosmetics,
@@ -27,6 +27,24 @@ const checker = new PrivilegeCheckerImpl(
);
const emptyChecker = new PrivilegeCheckerImpl(mockCosmetics, mockDecoder, []);
const flagCosmetics = {
patterns: {},
colorPalettes: {},
flags: {
cool_flag: {
name: "cool_flag",
url: "https://example.com/cool.png",
affiliateCode: null,
product: { productId: "prod_1", priceId: "price_1", price: "$4.99" },
},
},
};
const flagChecker = new PrivilegeCheckerImpl(
flagCosmetics,
mockDecoder,
bannedWords,
);
describe("UsernameCensor", () => {
describe("isProfane (via matcher.hasMatch)", () => {
test("detects exact banned words", () => {
@@ -145,3 +163,63 @@ describe("UsernameCensor", () => {
});
});
});
describe("Flag validation in isAllowed", () => {
test("allows valid country flag and resolves to SVG path", () => {
const result = flagChecker.isAllowed([], { flag: "country:us" });
expect(result.type).toBe("allowed");
if (result.type === "allowed") {
expect(result.cosmetics.flag).toBe("/flags/us.svg");
}
});
test("rejects invalid country code", () => {
const result = flagChecker.isAllowed([], { flag: "country:zzzz" });
expect(result.type).toBe("forbidden");
});
test("rejects flag with no prefix", () => {
const result = flagChecker.isAllowed([], { flag: "us" });
expect(result.type).toBe("forbidden");
});
test("allows cosmetic flag when user has wildcard flare", () => {
const result = flagChecker.isAllowed(["flag:*"], {
flag: "flag:cool_flag",
});
expect(result.type).toBe("allowed");
if (result.type === "allowed") {
expect(result.cosmetics.flag).toBe("https://example.com/cool.png");
}
});
test("allows cosmetic flag when user has specific flare", () => {
const result = flagChecker.isAllowed(["flag:cool_flag"], {
flag: "flag:cool_flag",
});
expect(result.type).toBe("allowed");
if (result.type === "allowed") {
expect(result.cosmetics.flag).toBe("https://example.com/cool.png");
}
});
test("rejects cosmetic flag when user lacks flare", () => {
const result = flagChecker.isAllowed([], { flag: "flag:cool_flag" });
expect(result.type).toBe("forbidden");
});
test("rejects cosmetic flag that does not exist", () => {
const result = flagChecker.isAllowed(["flag:*"], {
flag: "flag:nonexistent",
});
expect(result.type).toBe("forbidden");
});
test("allows no flag", () => {
const result = flagChecker.isAllowed([], {});
expect(result.type).toBe("allowed");
if (result.type === "allowed") {
expect(result.cosmetics.flag).toBeUndefined();
}
});
});