add support for custom colors (#2103)

## Description:

Added a colors tab in territory patterns modal so players can select
their color.

Refactored the PrivilegeChecker, removed custom flag checks since we no
longer support custom flags.

<img width="479" height="345" alt="Screenshot 2025-09-27 at 5 01 17 PM"
src="https://github.com/user-attachments/assets/ad96da65-f0eb-4731-a861-e6e5fcb4566a"
/>
## 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:
evanpelle
2025-10-09 20:47:20 -07:00
committed by GitHub
parent 2521466191
commit 584fa9fb5d
15 changed files with 220 additions and 336 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ export default {
coverageThreshold: {
global: {
statements: 21.5,
branches: 16.5,
branches: 16,
lines: 21.0,
functions: 20.5,
},
+2 -1
View File
@@ -656,7 +656,8 @@
"choose_spawn": "Choose a starting location"
},
"territory_patterns": {
"title": "Select Territory Skin",
"title": "Skins",
"colors": "Colors",
"purchase": "Purchase",
"blocked": {
"login": "You must be logged in to access this pattern.",
+2 -3
View File
@@ -5,7 +5,7 @@ import {
GameID,
GameRecord,
GameStartInfo,
PlayerPattern,
PlayerCosmeticRefs,
PlayerRecord,
ServerMessage,
} from "../core/Schemas";
@@ -51,8 +51,7 @@ import SoundManager from "./sound/SoundManager";
export interface LobbyConfig {
serverConfig: ServerConfig;
pattern: PlayerPattern | undefined;
flag: string;
cosmetics: PlayerCosmeticRefs;
playerName: string;
clientID: ClientID;
gameID: GameID;
+13 -7
View File
@@ -509,18 +509,24 @@ class Client {
}
const config = await getServerConfigFromClient();
const pattern = this.userSettings.getSelectedPatternName(
await fetchCosmetics(),
);
this.gameStop = joinLobby(
this.eventBus,
{
gameID: lobby.gameID,
serverConfig: config,
pattern:
this.userSettings.getSelectedPatternName(await fetchCosmetics()) ??
undefined,
flag:
this.flagInput === null || this.flagInput.getCurrentFlag() === "xx"
? ""
: this.flagInput.getCurrentFlag(),
cosmetics: {
color: this.userSettings.getSelectedColor() ?? undefined,
patternName: pattern?.name ?? undefined,
patternColorPaletteName: pattern?.colorPalette?.name ?? undefined,
flag:
this.flagInput === null || this.flagInput.getCurrentFlag() === "xx"
? ""
: this.flagInput.getCurrentFlag(),
},
playerName: this.usernameInput?.getCurrentUsername() ?? "",
token: getPlayToken(),
clientID: lobby.clientID,
+3
View File
@@ -449,6 +449,8 @@ export class SinglePlayerModal extends LitElement {
? (this.userSettings.getDevOnlyPattern() ?? null)
: null;
const selectedColor = this.userSettings.getSelectedColor();
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
@@ -466,6 +468,7 @@ export class SinglePlayerModal extends LitElement {
? ""
: flagInput.getCurrentFlag(),
pattern: selectedPattern ?? undefined,
color: selectedColor ? { color: selectedColor } : undefined,
},
},
],
+84 -3
View File
@@ -25,6 +25,9 @@ export class TerritoryPatternsModal extends LitElement {
public previewButton: HTMLElement | null = null;
@state() private selectedPattern: PlayerPattern | null;
@state() private selectedColor: string | null = null;
@state() private activeTab: "patterns" | "colors" = "patterns";
private cosmetics: Cosmetics | null = null;
@@ -44,6 +47,7 @@ export class TerritoryPatternsModal extends LitElement {
if (userMeResponse === null) {
this.userSettings.setSelectedPatternName(undefined);
this.selectedPattern = null;
this.selectedColor = null;
}
this.userMeResponse = userMeResponse;
this.cosmetics = await fetchCosmetics();
@@ -51,6 +55,7 @@ export class TerritoryPatternsModal extends LitElement {
this.cosmetics !== null
? this.userSettings.getSelectedPatternName(this.cosmetics)
: null;
this.selectedColor = this.userSettings.getSelectedColor() ?? null;
this.refresh();
}
@@ -58,6 +63,31 @@ export class TerritoryPatternsModal extends LitElement {
return this;
}
private renderTabNavigation(): TemplateResult {
return html`
<div class="flex border-b border-gray-600 mb-4 justify-center">
<button
class="px-4 py-2 text-sm font-medium transition-colors duration-200 ${this
.activeTab === "patterns"
? "text-blue-400 border-b-2 border-blue-400 bg-blue-400/10"
: "text-gray-400 hover:text-white"}"
@click=${() => (this.activeTab = "patterns")}
>
${translateText("territory_patterns.title")}
</button>
<button
class="px-4 py-2 text-sm font-medium transition-colors duration-200 ${this
.activeTab === "colors"
? "text-blue-400 border-b-2 border-blue-400 bg-blue-400/10"
: "text-gray-400 hover:text-white"}"
@click=${() => (this.activeTab = "colors")}
>
${translateText("territory_patterns.colors")}
</button>
</div>
`;
}
private renderPatternGrid(): TemplateResult {
const buttons: TemplateResult[] = [];
for (const pattern of Object.values(this.cosmetics?.patterns ?? {})) {
@@ -105,14 +135,39 @@ export class TerritoryPatternsModal extends LitElement {
`;
}
private renderColorSwatchGrid(): TemplateResult {
const hexCodes = (this.userMeResponse?.player.flares ?? [])
.filter((flare) => flare.startsWith("color:"))
.map((flare) => "#" + flare.split(":")[1]);
return html`
<div class="flex flex-wrap gap-3 p-2 justify-center items-center">
${hexCodes.map(
(hexCode) => html`
<div
class="w-12 h-12 rounded-lg border-2 border-white/30 cursor-pointer transition-all duration-200 hover:scale-110 hover:shadow-lg"
style="background-color: ${hexCode};"
title="${hexCode}"
@click=${() => this.selectColor(hexCode)}
></div>
`,
)}
</div>
`;
}
render() {
if (!this.isActive) return html``;
return html`
<o-modal
id="territoryPatternsModal"
title="${translateText("territory_patterns.title")}"
title="${this.activeTab === "patterns"
? translateText("territory_patterns.title")
: translateText("territory_patterns.colors")}"
>
${this.renderPatternGrid()}
${this.renderTabNavigation()}
${this.activeTab === "patterns"
? this.renderPatternGrid()
: this.renderColorSwatchGrid()}
</o-modal>
`;
}
@@ -130,6 +185,8 @@ export class TerritoryPatternsModal extends LitElement {
}
private selectPattern(pattern: PlayerPattern | null) {
this.selectedColor = null;
this.userSettings.setSelectedColor(undefined);
if (pattern === null) {
this.userSettings.setSelectedPatternName(undefined);
} else {
@@ -145,8 +202,32 @@ export class TerritoryPatternsModal extends LitElement {
this.close();
}
private selectColor(hexCode: string) {
this.selectedPattern = null;
this.userSettings.setSelectedPatternName(undefined);
this.selectedColor = hexCode;
this.userSettings.setSelectedColor(hexCode);
this.refresh();
this.close();
}
private renderColorPreview(
hexCode: string,
width: number,
height: number,
): TemplateResult {
return html`
<div
class="rounded"
style="width: ${width}px; height: ${height}px; background-color: ${hexCode};"
></div>
`;
}
public async refresh() {
const preview = renderPatternPreview(this.selectedPattern ?? null, 48, 48);
const preview = this.selectedColor
? this.renderColorPreview(this.selectedColor, 48, 48)
: renderPatternPreview(this.selectedPattern ?? null, 48, 48);
this.requestUpdate();
// Wait for the DOM to be updated and the o-modal element to be available
+1 -5
View File
@@ -377,11 +377,7 @@ export class Transport {
lastTurn: numTurns,
token: this.lobbyConfig.token,
username: this.lobbyConfig.playerName,
cosmetics: {
flag: this.lobbyConfig.flag,
patternName: this.lobbyConfig.pattern?.name,
patternColorPaletteName: this.lobbyConfig.pattern?.colorPalette?.name,
},
cosmetics: this.lobbyConfig.cosmetics,
} satisfies ClientJoinMessage);
}
+9
View File
@@ -115,6 +115,7 @@ export type Player = z.infer<typeof PlayerSchema>;
export type PlayerCosmetics = z.infer<typeof PlayerCosmeticsSchema>;
export type PlayerCosmeticRefs = z.infer<typeof PlayerCosmeticRefsSchema>;
export type PlayerPattern = z.infer<typeof PlayerPatternSchema>;
export type PlayerColor = z.infer<typeof PlayerColorSchema>;
export type Flag = z.infer<typeof FlagSchema>;
export type GameStartInfo = z.infer<typeof GameStartInfoSchema>;
@@ -386,6 +387,7 @@ export const FlagSchema = z
export const PlayerCosmeticRefsSchema = z.object({
flag: FlagSchema.optional(),
color: z.string().optional(),
patternName: PatternNameSchema.optional(),
patternColorPaletteName: z.string().optional(),
});
@@ -395,10 +397,17 @@ export const PlayerPatternSchema = z.object({
patternData: PatternDataSchema,
colorPalette: ColorPaletteSchema.optional(),
});
export const PlayerColorSchema = z.object({
color: z.string(),
});
export const PlayerCosmeticsSchema = z.object({
flag: FlagSchema.optional(),
pattern: PlayerPatternSchema.optional(),
color: PlayerColorSchema.optional(),
});
export const PlayerSchema = z.object({
clientID: ID,
username: UsernameSchema,
+1 -1
View File
@@ -183,7 +183,7 @@ export interface Theme {
// Don't call directly, use PlayerView
territoryColor(playerInfo: PlayerView): Colord;
// Don't call directly, use PlayerView
borderColor(playerInfo: PlayerView): Colord;
borderColor(territoryColor: Colord): Colord;
// Don't call directly, use PlayerView
defendedBorderColors(territoryColor: Colord): { light: Colord; dark: Colord };
focusedBorderColor(): Colord;
+2 -13
View File
@@ -55,19 +55,8 @@ export class PastelTheme implements Theme {
}
// Don't call directly, use PlayerView
borderColor(player: PlayerView): Colord {
if (this.borderColorCache.has(player.id())) {
return this.borderColorCache.get(player.id())!;
}
const tc = this.territoryColor(player).rgba;
const color = colord({
r: Math.max(tc.r - 40, 0),
g: Math.max(tc.g - 40, 0),
b: Math.max(tc.b - 40, 0),
});
this.borderColorCache.set(player.id(), color);
return color;
borderColor(territoryColor: Colord): Colord {
return territoryColor.darken(0.125);
}
defendedBorderColors(territoryColor: Colord): {
+26 -18
View File
@@ -197,36 +197,44 @@ export class PlayerView {
);
}
const defaultTerritoryColor = this.game
.config()
.theme()
.territoryColor(this);
const defaultBorderColor = this.game
.config()
.theme()
.borderColor(defaultTerritoryColor);
const pattern = this.cosmetics.pattern;
if (pattern) {
const territoryColor = this.game.config().theme().territoryColor(this);
pattern.colorPalette ??= {
name: "",
primaryColor: territoryColor.toHex(),
secondaryColor: territoryColor.darken(0.125).toHex(),
primaryColor: defaultTerritoryColor.toHex(),
secondaryColor: defaultBorderColor.toHex(),
} satisfies ColorPalette;
}
if (
this.team() === null &&
this.cosmetics.pattern?.colorPalette?.primaryColor !== undefined
) {
if (this.team() === null) {
this._territoryColor = colord(
this.cosmetics.pattern.colorPalette.primaryColor,
this.cosmetics.color?.color ??
this.cosmetics.pattern?.colorPalette?.primaryColor ??
defaultTerritoryColor.toHex(),
);
} else {
this._territoryColor = this.game.config().theme().territoryColor(this);
this._territoryColor = defaultTerritoryColor;
}
if (this.cosmetics.pattern?.colorPalette?.secondaryColor !== undefined) {
this._borderColor = colord(
this.cosmetics.pattern.colorPalette.secondaryColor,
);
} else if (this.game.myClientID() === this.data.clientID) {
this._borderColor = this.game.config().theme().focusedBorderColor();
} else {
this._borderColor = this.game.config().theme().borderColor(this);
}
const maybeFocusedBorderColor =
this.game.myClientID() === this.data.clientID
? this.game.config().theme().focusedBorderColor()
: defaultBorderColor;
this._borderColor = new Colord(
pattern?.colorPalette?.secondaryColor ??
this.cosmetics.color?.color ??
maybeFocusedBorderColor.toHex(),
);
this._defendedBorderColors = this.game
.config()
+14
View File
@@ -169,6 +169,20 @@ export class UserSettings {
}
}
getSelectedColor(): string | undefined {
const data = localStorage.getItem("settings.territoryColor") ?? undefined;
if (data === undefined) return undefined;
return data;
}
setSelectedColor(color: string | undefined): void {
if (color === undefined) {
localStorage.removeItem("settings.territoryColor");
} else {
localStorage.setItem("settings.territoryColor", color);
}
}
backgroundMusicVolume(): number {
return this.getFloat("settings.backgroundMusicVolume", 0);
}
+54 -106
View File
@@ -1,22 +1,18 @@
import { Cosmetics } from "../core/CosmeticSchemas";
import { decodePatternData } from "../core/PatternDecoder";
import { PlayerPattern } from "../core/Schemas";
import {
PlayerColor,
PlayerCosmeticRefs,
PlayerCosmetics,
PlayerPattern,
} from "../core/Schemas";
type PatternResult =
| { type: "allowed"; pattern: PlayerPattern }
| { type: "unknown" }
type CosmeticResult =
| { type: "allowed"; cosmetics: PlayerCosmetics }
| { type: "forbidden"; reason: string };
export interface PrivilegeChecker {
isPatternAllowed(
flares: readonly string[],
name: string,
colorPaletteName: string | null,
): PatternResult;
isCustomFlagAllowed(
flag: string,
flares: readonly string[] | undefined,
): true | "restricted" | "invalid";
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult;
}
export class PrivilegeCheckerImpl implements PrivilegeChecker {
@@ -25,28 +21,53 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
private b64urlDecode: (base64: string) => Uint8Array,
) {}
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult {
const cosmetics: PlayerCosmetics = {};
if (refs.patternName) {
try {
cosmetics.pattern = this.isPatternAllowed(
flares,
refs.patternName,
refs.patternColorPaletteName ?? null,
);
} catch (e) {
return { type: "forbidden", reason: "invalid pattern: " + e.message };
}
}
if (refs.color) {
try {
cosmetics.color = this.isColorAllowed(flares, refs.color);
} catch (e) {
return { type: "forbidden", reason: "invalid color: " + e.message };
}
}
return { type: "allowed", cosmetics };
}
isPatternAllowed(
flares: readonly string[],
name: string,
colorPaletteName: string | null,
): PatternResult {
): PlayerPattern {
// Look for the pattern in the cosmetics.json config
const found = this.cosmetics.patterns[name];
if (!found) return { type: "forbidden", reason: "pattern not found" };
if (!found) throw new Error(`Pattern ${name} not found`);
try {
decodePatternData(found.pattern, this.b64urlDecode);
} catch (e) {
return { type: "forbidden", reason: "invalid pattern" };
throw new Error(`Invalid pattern ${name}`);
}
const colorPalette = this.cosmetics.colorPalettes?.[colorPaletteName ?? ""];
if (flares.includes("pattern:*")) {
return {
type: "allowed",
pattern: { name: found.name, patternData: found.pattern, colorPalette },
};
name: found.name,
patternData: found.pattern,
colorPalette,
} satisfies PlayerPattern;
}
const flareName =
@@ -56,101 +77,28 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
if (flares.includes(flareName)) {
// Player has a flare for this pattern
return {
type: "allowed",
pattern: { name: found.name, patternData: found.pattern, colorPalette },
};
name: found.name,
patternData: found.pattern,
colorPalette,
} satisfies PlayerPattern;
} else {
return { type: "forbidden", reason: "no flares for pattern" };
throw new Error(`No flares for pattern ${name}`);
}
}
isCustomFlagAllowed(
flag: string,
flares: readonly string[] | undefined,
): true | "restricted" | "invalid" {
if (!flag.startsWith("!")) return "invalid";
const code = flag.slice(1);
if (!code) return "invalid";
const segments = code.split("_");
if (segments.length === 0) return "invalid";
const MAX_LAYERS = 6; // Maximum number of layers allowed
if (segments.length > MAX_LAYERS) return "invalid";
const superFlare = flares?.includes("flag:*") ?? false;
for (const segment of segments) {
const [layerKey, colorKey] = segment.split("-");
if (!layerKey || !colorKey) return "invalid";
const layer = this.cosmetics.flag?.layers[layerKey];
const color = this.cosmetics.flag?.color[colorKey];
if (!layer || !color) return "invalid";
// Super-flare bypasses all restrictions
if (superFlare) {
continue;
}
// Check layer restrictions
const layerSpec = layer;
let layerAllowed = false;
if (!layerSpec.flares) {
layerAllowed = true;
} else {
// By flare
if (
layerSpec.flares &&
flares?.some((f) => layerSpec.flares?.includes(f))
) {
layerAllowed = true;
}
// By named flag:layer:{name}
if (flares?.includes(`flag:layer:${layerSpec.name}`)) {
layerAllowed = true;
}
}
// Check color restrictions
const colorSpec = color;
let colorAllowed = false;
if (!colorSpec.flares) {
colorAllowed = true;
} else {
// By flare
if (
colorSpec.flares &&
flares?.some((f) => colorSpec.flares?.includes(f))
) {
colorAllowed = true;
}
// By named flag:color:{name}
if (flares?.includes(`flag:color:${colorSpec.name}`)) {
colorAllowed = true;
}
}
// If either part is restricted, block
if (!(layerAllowed && colorAllowed)) {
return "restricted";
}
isColorAllowed(flares: string[], color: string): PlayerColor {
const allowedColors = flares
.filter((flare) => flare.startsWith("color:"))
.map((flare) => "#" + flare.split(":")[1]);
if (!allowedColors.includes(color)) {
throw new Error(`Color ${color} not allowed`);
}
return true;
return { color };
}
}
export class FailOpenPrivilegeChecker implements PrivilegeChecker {
isPatternAllowed(
flares: readonly string[],
name: string,
colorPaletteName: string | null,
): PatternResult {
return { type: "unknown" };
}
isCustomFlagAllowed(
flag: string,
flares: readonly string[] | undefined,
): true | "restricted" | "invalid" {
return true;
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult {
return { type: "allowed", cosmetics: {} };
}
}
+8 -82
View File
@@ -13,9 +13,6 @@ import {
ClientMessageSchema,
ID,
PartialGameRecordSchema,
PlayerCosmeticRefs,
PlayerCosmetics,
PlayerPattern,
ServerErrorMessage,
} from "../core/Schemas";
import { replacer } from "../core/Util";
@@ -26,7 +23,6 @@ import { GameManager } from "./GameManager";
import { getUserMe, verifyClientToken } from "./jwt";
import { logger } from "./Logger";
import { assertNever } from "../core/Util";
import { PrivilegeRefresher } from "./PrivilegeRefresher";
import { initWorkerMetrics } from "./WorkerMetrics";
@@ -366,15 +362,15 @@ export async function startWorker() {
}
}
const { perm, cosmetics, error } = checkCosmetics(
clientMsg.cosmetics,
flares ?? [],
);
if (perm === "forbidden") {
log.warn(`Forbidden: ${error}`, {
const cosmeticResult = privilegeRefresher
.get()
.isAllowed(flares ?? [], clientMsg.cosmetics ?? {});
if (cosmeticResult.type === "forbidden") {
log.warn(`Forbidden: ${cosmeticResult.reason}`, {
clientID: clientMsg.clientID,
});
ws.close(1002, error);
ws.close(1002, cosmeticResult.reason);
return;
}
@@ -388,7 +384,7 @@ export async function startWorker() {
ip,
clientMsg.username,
ws,
cosmetics,
cosmeticResult.cosmetics,
);
const wasFound = gm.addClient(
@@ -424,76 +420,6 @@ export async function startWorker() {
});
});
function checkCosmetics(
cosmetics: PlayerCosmeticRefs | undefined,
flares: readonly string[],
): {
perm: "forbidden" | "allowed";
cosmetics?: PlayerCosmetics | undefined;
error?: string;
} {
if (cosmetics === undefined) {
return {
perm: "allowed",
cosmetics: undefined,
};
}
// Check if the flag is allowed
if (cosmetics.flag !== undefined) {
if (cosmetics.flag.startsWith("!")) {
const allowed = privilegeRefresher
.get()
.isCustomFlagAllowed(cosmetics.flag, flares);
if (allowed !== true) {
log.warn(`Custom flag ${allowed}: ${cosmetics.flag}`);
return {
perm: "forbidden",
error: `Custom flag ${allowed}`,
};
}
}
}
let pattern: PlayerPattern | undefined;
// Check if the pattern is allowed
if (cosmetics.patternName !== undefined) {
const result = privilegeRefresher
.get()
.isPatternAllowed(
flares,
cosmetics.patternName,
cosmetics.patternColorPaletteName ?? null,
);
switch (result.type) {
case "allowed":
pattern = result.pattern;
break;
case "unknown":
log.warn(`Pattern ${cosmetics.patternName} unknown`);
return {
perm: "forbidden",
error: "Could not look up pattern, backend may be offline",
};
case "forbidden":
log.warn(`Pattern ${cosmetics.patternName}: ${result.reason}`);
return {
perm: "forbidden",
error: `Pattern ${cosmetics.patternName}: ${result.reason}`,
};
default:
assertNever(result);
}
}
return {
perm: "allowed",
cosmetics: {
flag: cosmetics.flag,
pattern: pattern,
},
};
}
// The load balancer will handle routing to this server based on path
const PORT = config.workerPortByIndex(workerId);
server.listen(PORT, () => {
-96
View File
@@ -1,96 +0,0 @@
import type { Cosmetics } from "../../src/core/CosmeticSchemas";
import { PrivilegeCheckerImpl } from "../../src/server/Privilege";
describe("PrivilegeChecker.isCustomFlagAllowed (with mock cosmetics)", () => {
const dummyPatternDecoder = (_base64: string) => {
throw new Error("Method not implemented");
};
const mockCosmetics: Cosmetics = {
patterns: {},
flag: {
layers: {
a: {
name: "chocolate",
flares: ["cosmetic:flags"],
},
b: { name: "center_hline" },
c: { name: "admin_layer" },
},
color: {
a: { color: "#ff0000", name: "red", flares: ["cosmetic:red"] },
b: { color: "#00ff00", name: "green" },
c: { color: "#0000ff", name: "blue", flares: ["cosmetic:blue"] },
},
},
};
const checker = new PrivilegeCheckerImpl(mockCosmetics, dummyPatternDecoder);
it("allowed: unrestricted layer/color", () => {
expect(checker.isCustomFlagAllowed("!b-b", [])).toBe(true);
});
it("allowed: donor layer with correct flare", () => {
expect(checker.isCustomFlagAllowed("!a-b", ["cosmetic:flags"])).toBe(true);
});
it("allowed: color with correct flare", () => {
expect(checker.isCustomFlagAllowed("!b-c", ["cosmetic:blue"])).toBe(true);
});
it("invalid: non-existent layer", () => {
expect(checker.isCustomFlagAllowed("!zzz-a", [])).toBe("invalid");
});
it("invalid: non-existent color", () => {
expect(checker.isCustomFlagAllowed("!a-zzz", [])).toBe("invalid");
});
it("allowed: superFlare allows all listed", () => {
expect(checker.isCustomFlagAllowed("!a-a", ["flag:*"])).toBe(true);
expect(checker.isCustomFlagAllowed("!b-b", ["flag:*"])).toBe(true);
expect(checker.isCustomFlagAllowed("!c-a", ["flag:*"])).toBe(true);
expect(checker.isCustomFlagAllowed("!a-c", ["flag:*"])).toBe(true);
});
it("invalid: superFlare does not allow non-existent", () => {
expect(checker.isCustomFlagAllowed("!zzz-zzz", ["flag:*"])).toBe("invalid");
});
it("allowed: flare flag:layer:chocolate allows chocolate layer", () => {
expect(checker.isCustomFlagAllowed("!a-b", ["flag:layer:chocolate"])).toBe(
true,
);
});
it("allowed: flare flag:color:blue allows blue color", () => {
expect(checker.isCustomFlagAllowed("!b-c", ["flag:color:blue"])).toBe(true);
});
it("restricted: only color flare, layer still restricted", () => {
expect(checker.isCustomFlagAllowed("!a-c", ["cosmetic:blue"])).toBe(
"restricted",
);
});
it("restricted: only layer flare, color still restricted", () => {
expect(checker.isCustomFlagAllowed("!c-a", ["cosmetic:flags"])).toBe(
"restricted",
);
});
it("allowed: two segments, both unrestricted", () => {
expect(checker.isCustomFlagAllowed("!b-b_b-b", [])).toBe(true);
});
it("allowed: two segments, both by flare", () => {
expect(
checker.isCustomFlagAllowed("!a-c_a-c", [
"cosmetic:flags",
"cosmetic:blue",
]),
).toBe(true);
expect(checker.isCustomFlagAllowed("!a-c_a-c", ["cosmetic:flags"])).toBe(
"restricted",
);
expect(checker.isCustomFlagAllowed("!a-c_a-c", ["cosmetic:blue"])).toBe(
"restricted",
);
});
});