diff --git a/jest.config.ts b/jest.config.ts
index 09a0b8677..80fb0365d 100644
--- a/jest.config.ts
+++ b/jest.config.ts
@@ -17,7 +17,7 @@ export default {
coverageThreshold: {
global: {
statements: 21.5,
- branches: 16.5,
+ branches: 16,
lines: 21.0,
functions: 20.5,
},
diff --git a/resources/lang/en.json b/resources/lang/en.json
index 27fe3366f..c31101bc9 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -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.",
diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts
index 1129b0947..c03820c2f 100644
--- a/src/client/ClientGameRunner.ts
+++ b/src/client/ClientGameRunner.ts
@@ -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;
diff --git a/src/client/Main.ts b/src/client/Main.ts
index 8b48f79cc..d5acbe4ab 100644
--- a/src/client/Main.ts
+++ b/src/client/Main.ts
@@ -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,
diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts
index 0b36b715a..01e62f928 100644
--- a/src/client/SinglePlayerModal.ts
+++ b/src/client/SinglePlayerModal.ts
@@ -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,
},
},
],
diff --git a/src/client/TerritoryPatternsModal.ts b/src/client/TerritoryPatternsModal.ts
index b0a9b45b4..baf75e7be 100644
--- a/src/client/TerritoryPatternsModal.ts
+++ b/src/client/TerritoryPatternsModal.ts
@@ -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`
+
+
+
+
+ `;
+ }
+
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`
+
+ ${hexCodes.map(
+ (hexCode) => html`
+
this.selectColor(hexCode)}
+ >
+ `,
+ )}
+
+ `;
+ }
+
render() {
if (!this.isActive) return html``;
return html`
- ${this.renderPatternGrid()}
+ ${this.renderTabNavigation()}
+ ${this.activeTab === "patterns"
+ ? this.renderPatternGrid()
+ : this.renderColorSwatchGrid()}
`;
}
@@ -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`
+
+ `;
+ }
+
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
diff --git a/src/client/Transport.ts b/src/client/Transport.ts
index ee49f0c4c..3cb6c71c6 100644
--- a/src/client/Transport.ts
+++ b/src/client/Transport.ts
@@ -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);
}
diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts
index 0cea2c8e8..270cc2297 100644
--- a/src/core/Schemas.ts
+++ b/src/core/Schemas.ts
@@ -115,6 +115,7 @@ export type Player = z.infer;
export type PlayerCosmetics = z.infer;
export type PlayerCosmeticRefs = z.infer;
export type PlayerPattern = z.infer;
+export type PlayerColor = z.infer;
export type Flag = z.infer;
export type GameStartInfo = z.infer;
@@ -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,
diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts
index 0a77dfb15..baea027af 100644
--- a/src/core/configuration/Config.ts
+++ b/src/core/configuration/Config.ts
@@ -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;
diff --git a/src/core/configuration/PastelTheme.ts b/src/core/configuration/PastelTheme.ts
index 8c22ac552..d40509e1c 100644
--- a/src/core/configuration/PastelTheme.ts
+++ b/src/core/configuration/PastelTheme.ts
@@ -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): {
diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts
index c2dec7c52..5e271d9aa 100644
--- a/src/core/game/GameView.ts
+++ b/src/core/game/GameView.ts
@@ -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()
diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts
index 819ea9496..fd5ac12a5 100644
--- a/src/core/game/UserSettings.ts
+++ b/src/core/game/UserSettings.ts
@@ -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);
}
diff --git a/src/server/Privilege.ts b/src/server/Privilege.ts
index 4454e6321..380dcfb14 100644
--- a/src/server/Privilege.ts
+++ b/src/server/Privilege.ts
@@ -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: {} };
}
}
diff --git a/src/server/Worker.ts b/src/server/Worker.ts
index e565f7ab7..9406f4fe3 100644
--- a/src/server/Worker.ts
+++ b/src/server/Worker.ts
@@ -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, () => {
diff --git a/tests/server/Privilege.customFlag.test.ts b/tests/server/Privilege.customFlag.test.ts
deleted file mode 100644
index b314b499f..000000000
--- a/tests/server/Privilege.customFlag.test.ts
+++ /dev/null
@@ -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",
- );
- });
-});