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: { coverageThreshold: {
global: { global: {
statements: 21.5, statements: 21.5,
branches: 16.5, branches: 16,
lines: 21.0, lines: 21.0,
functions: 20.5, functions: 20.5,
}, },
+2 -1
View File
@@ -656,7 +656,8 @@
"choose_spawn": "Choose a starting location" "choose_spawn": "Choose a starting location"
}, },
"territory_patterns": { "territory_patterns": {
"title": "Select Territory Skin", "title": "Skins",
"colors": "Colors",
"purchase": "Purchase", "purchase": "Purchase",
"blocked": { "blocked": {
"login": "You must be logged in to access this pattern.", "login": "You must be logged in to access this pattern.",
+2 -3
View File
@@ -5,7 +5,7 @@ import {
GameID, GameID,
GameRecord, GameRecord,
GameStartInfo, GameStartInfo,
PlayerPattern, PlayerCosmeticRefs,
PlayerRecord, PlayerRecord,
ServerMessage, ServerMessage,
} from "../core/Schemas"; } from "../core/Schemas";
@@ -51,8 +51,7 @@ import SoundManager from "./sound/SoundManager";
export interface LobbyConfig { export interface LobbyConfig {
serverConfig: ServerConfig; serverConfig: ServerConfig;
pattern: PlayerPattern | undefined; cosmetics: PlayerCosmeticRefs;
flag: string;
playerName: string; playerName: string;
clientID: ClientID; clientID: ClientID;
gameID: GameID; gameID: GameID;
+13 -7
View File
@@ -509,18 +509,24 @@ class Client {
} }
const config = await getServerConfigFromClient(); const config = await getServerConfigFromClient();
const pattern = this.userSettings.getSelectedPatternName(
await fetchCosmetics(),
);
this.gameStop = joinLobby( this.gameStop = joinLobby(
this.eventBus, this.eventBus,
{ {
gameID: lobby.gameID, gameID: lobby.gameID,
serverConfig: config, serverConfig: config,
pattern: cosmetics: {
this.userSettings.getSelectedPatternName(await fetchCosmetics()) ?? color: this.userSettings.getSelectedColor() ?? undefined,
undefined, patternName: pattern?.name ?? undefined,
flag: patternColorPaletteName: pattern?.colorPalette?.name ?? undefined,
this.flagInput === null || this.flagInput.getCurrentFlag() === "xx" flag:
? "" this.flagInput === null || this.flagInput.getCurrentFlag() === "xx"
: this.flagInput.getCurrentFlag(), ? ""
: this.flagInput.getCurrentFlag(),
},
playerName: this.usernameInput?.getCurrentUsername() ?? "", playerName: this.usernameInput?.getCurrentUsername() ?? "",
token: getPlayToken(), token: getPlayToken(),
clientID: lobby.clientID, clientID: lobby.clientID,
+3
View File
@@ -449,6 +449,8 @@ export class SinglePlayerModal extends LitElement {
? (this.userSettings.getDevOnlyPattern() ?? null) ? (this.userSettings.getDevOnlyPattern() ?? null)
: null; : null;
const selectedColor = this.userSettings.getSelectedColor();
this.dispatchEvent( this.dispatchEvent(
new CustomEvent("join-lobby", { new CustomEvent("join-lobby", {
detail: { detail: {
@@ -466,6 +468,7 @@ export class SinglePlayerModal extends LitElement {
? "" ? ""
: flagInput.getCurrentFlag(), : flagInput.getCurrentFlag(),
pattern: selectedPattern ?? undefined, 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; public previewButton: HTMLElement | null = null;
@state() private selectedPattern: PlayerPattern | null; @state() private selectedPattern: PlayerPattern | null;
@state() private selectedColor: string | null = null;
@state() private activeTab: "patterns" | "colors" = "patterns";
private cosmetics: Cosmetics | null = null; private cosmetics: Cosmetics | null = null;
@@ -44,6 +47,7 @@ export class TerritoryPatternsModal extends LitElement {
if (userMeResponse === null) { if (userMeResponse === null) {
this.userSettings.setSelectedPatternName(undefined); this.userSettings.setSelectedPatternName(undefined);
this.selectedPattern = null; this.selectedPattern = null;
this.selectedColor = null;
} }
this.userMeResponse = userMeResponse; this.userMeResponse = userMeResponse;
this.cosmetics = await fetchCosmetics(); this.cosmetics = await fetchCosmetics();
@@ -51,6 +55,7 @@ export class TerritoryPatternsModal extends LitElement {
this.cosmetics !== null this.cosmetics !== null
? this.userSettings.getSelectedPatternName(this.cosmetics) ? this.userSettings.getSelectedPatternName(this.cosmetics)
: null; : null;
this.selectedColor = this.userSettings.getSelectedColor() ?? null;
this.refresh(); this.refresh();
} }
@@ -58,6 +63,31 @@ export class TerritoryPatternsModal extends LitElement {
return this; 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 { private renderPatternGrid(): TemplateResult {
const buttons: TemplateResult[] = []; const buttons: TemplateResult[] = [];
for (const pattern of Object.values(this.cosmetics?.patterns ?? {})) { 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() { render() {
if (!this.isActive) return html``; if (!this.isActive) return html``;
return html` return html`
<o-modal <o-modal
id="territoryPatternsModal" 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> </o-modal>
`; `;
} }
@@ -130,6 +185,8 @@ export class TerritoryPatternsModal extends LitElement {
} }
private selectPattern(pattern: PlayerPattern | null) { private selectPattern(pattern: PlayerPattern | null) {
this.selectedColor = null;
this.userSettings.setSelectedColor(undefined);
if (pattern === null) { if (pattern === null) {
this.userSettings.setSelectedPatternName(undefined); this.userSettings.setSelectedPatternName(undefined);
} else { } else {
@@ -145,8 +202,32 @@ export class TerritoryPatternsModal extends LitElement {
this.close(); 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() { 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(); this.requestUpdate();
// Wait for the DOM to be updated and the o-modal element to be available // 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, lastTurn: numTurns,
token: this.lobbyConfig.token, token: this.lobbyConfig.token,
username: this.lobbyConfig.playerName, username: this.lobbyConfig.playerName,
cosmetics: { cosmetics: this.lobbyConfig.cosmetics,
flag: this.lobbyConfig.flag,
patternName: this.lobbyConfig.pattern?.name,
patternColorPaletteName: this.lobbyConfig.pattern?.colorPalette?.name,
},
} satisfies ClientJoinMessage); } 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 PlayerCosmetics = z.infer<typeof PlayerCosmeticsSchema>;
export type PlayerCosmeticRefs = z.infer<typeof PlayerCosmeticRefsSchema>; export type PlayerCosmeticRefs = z.infer<typeof PlayerCosmeticRefsSchema>;
export type PlayerPattern = z.infer<typeof PlayerPatternSchema>; export type PlayerPattern = z.infer<typeof PlayerPatternSchema>;
export type PlayerColor = z.infer<typeof PlayerColorSchema>;
export type Flag = z.infer<typeof FlagSchema>; export type Flag = z.infer<typeof FlagSchema>;
export type GameStartInfo = z.infer<typeof GameStartInfoSchema>; export type GameStartInfo = z.infer<typeof GameStartInfoSchema>;
@@ -386,6 +387,7 @@ export const FlagSchema = z
export const PlayerCosmeticRefsSchema = z.object({ export const PlayerCosmeticRefsSchema = z.object({
flag: FlagSchema.optional(), flag: FlagSchema.optional(),
color: z.string().optional(),
patternName: PatternNameSchema.optional(), patternName: PatternNameSchema.optional(),
patternColorPaletteName: z.string().optional(), patternColorPaletteName: z.string().optional(),
}); });
@@ -395,10 +397,17 @@ export const PlayerPatternSchema = z.object({
patternData: PatternDataSchema, patternData: PatternDataSchema,
colorPalette: ColorPaletteSchema.optional(), colorPalette: ColorPaletteSchema.optional(),
}); });
export const PlayerColorSchema = z.object({
color: z.string(),
});
export const PlayerCosmeticsSchema = z.object({ export const PlayerCosmeticsSchema = z.object({
flag: FlagSchema.optional(), flag: FlagSchema.optional(),
pattern: PlayerPatternSchema.optional(), pattern: PlayerPatternSchema.optional(),
color: PlayerColorSchema.optional(),
}); });
export const PlayerSchema = z.object({ export const PlayerSchema = z.object({
clientID: ID, clientID: ID,
username: UsernameSchema, username: UsernameSchema,
+1 -1
View File
@@ -183,7 +183,7 @@ export interface Theme {
// Don't call directly, use PlayerView // Don't call directly, use PlayerView
territoryColor(playerInfo: PlayerView): Colord; territoryColor(playerInfo: PlayerView): Colord;
// Don't call directly, use PlayerView // Don't call directly, use PlayerView
borderColor(playerInfo: PlayerView): Colord; borderColor(territoryColor: Colord): Colord;
// Don't call directly, use PlayerView // Don't call directly, use PlayerView
defendedBorderColors(territoryColor: Colord): { light: Colord; dark: Colord }; defendedBorderColors(territoryColor: Colord): { light: Colord; dark: Colord };
focusedBorderColor(): Colord; focusedBorderColor(): Colord;
+2 -13
View File
@@ -55,19 +55,8 @@ export class PastelTheme implements Theme {
} }
// Don't call directly, use PlayerView // Don't call directly, use PlayerView
borderColor(player: PlayerView): Colord { borderColor(territoryColor: Colord): Colord {
if (this.borderColorCache.has(player.id())) { return territoryColor.darken(0.125);
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;
} }
defendedBorderColors(territoryColor: Colord): { 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; const pattern = this.cosmetics.pattern;
if (pattern) { if (pattern) {
const territoryColor = this.game.config().theme().territoryColor(this);
pattern.colorPalette ??= { pattern.colorPalette ??= {
name: "", name: "",
primaryColor: territoryColor.toHex(), primaryColor: defaultTerritoryColor.toHex(),
secondaryColor: territoryColor.darken(0.125).toHex(), secondaryColor: defaultBorderColor.toHex(),
} satisfies ColorPalette; } satisfies ColorPalette;
} }
if ( if (this.team() === null) {
this.team() === null &&
this.cosmetics.pattern?.colorPalette?.primaryColor !== undefined
) {
this._territoryColor = colord( this._territoryColor = colord(
this.cosmetics.pattern.colorPalette.primaryColor, this.cosmetics.color?.color ??
this.cosmetics.pattern?.colorPalette?.primaryColor ??
defaultTerritoryColor.toHex(),
); );
} else { } else {
this._territoryColor = this.game.config().theme().territoryColor(this); this._territoryColor = defaultTerritoryColor;
} }
if (this.cosmetics.pattern?.colorPalette?.secondaryColor !== undefined) { const maybeFocusedBorderColor =
this._borderColor = colord( this.game.myClientID() === this.data.clientID
this.cosmetics.pattern.colorPalette.secondaryColor, ? this.game.config().theme().focusedBorderColor()
); : defaultBorderColor;
} else if (this.game.myClientID() === this.data.clientID) {
this._borderColor = this.game.config().theme().focusedBorderColor(); this._borderColor = new Colord(
} else { pattern?.colorPalette?.secondaryColor ??
this._borderColor = this.game.config().theme().borderColor(this); this.cosmetics.color?.color ??
} maybeFocusedBorderColor.toHex(),
);
this._defendedBorderColors = this.game this._defendedBorderColors = this.game
.config() .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 { backgroundMusicVolume(): number {
return this.getFloat("settings.backgroundMusicVolume", 0); return this.getFloat("settings.backgroundMusicVolume", 0);
} }
+54 -106
View File
@@ -1,22 +1,18 @@
import { Cosmetics } from "../core/CosmeticSchemas"; import { Cosmetics } from "../core/CosmeticSchemas";
import { decodePatternData } from "../core/PatternDecoder"; import { decodePatternData } from "../core/PatternDecoder";
import { PlayerPattern } from "../core/Schemas"; import {
PlayerColor,
PlayerCosmeticRefs,
PlayerCosmetics,
PlayerPattern,
} from "../core/Schemas";
type PatternResult = type CosmeticResult =
| { type: "allowed"; pattern: PlayerPattern } | { type: "allowed"; cosmetics: PlayerCosmetics }
| { type: "unknown" }
| { type: "forbidden"; reason: string }; | { type: "forbidden"; reason: string };
export interface PrivilegeChecker { export interface PrivilegeChecker {
isPatternAllowed( isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult;
flares: readonly string[],
name: string,
colorPaletteName: string | null,
): PatternResult;
isCustomFlagAllowed(
flag: string,
flares: readonly string[] | undefined,
): true | "restricted" | "invalid";
} }
export class PrivilegeCheckerImpl implements PrivilegeChecker { export class PrivilegeCheckerImpl implements PrivilegeChecker {
@@ -25,28 +21,53 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
private b64urlDecode: (base64: string) => Uint8Array, 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( isPatternAllowed(
flares: readonly string[], flares: readonly string[],
name: string, name: string,
colorPaletteName: string | null, colorPaletteName: string | null,
): PatternResult { ): PlayerPattern {
// Look for the pattern in the cosmetics.json config // Look for the pattern in the cosmetics.json config
const found = this.cosmetics.patterns[name]; 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 { try {
decodePatternData(found.pattern, this.b64urlDecode); decodePatternData(found.pattern, this.b64urlDecode);
} catch (e) { } catch (e) {
return { type: "forbidden", reason: "invalid pattern" }; throw new Error(`Invalid pattern ${name}`);
} }
const colorPalette = this.cosmetics.colorPalettes?.[colorPaletteName ?? ""]; const colorPalette = this.cosmetics.colorPalettes?.[colorPaletteName ?? ""];
if (flares.includes("pattern:*")) { if (flares.includes("pattern:*")) {
return { return {
type: "allowed", name: found.name,
pattern: { name: found.name, patternData: found.pattern, colorPalette }, patternData: found.pattern,
}; colorPalette,
} satisfies PlayerPattern;
} }
const flareName = const flareName =
@@ -56,101 +77,28 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
if (flares.includes(flareName)) { if (flares.includes(flareName)) {
// Player has a flare for this pattern // Player has a flare for this pattern
return { return {
type: "allowed", name: found.name,
pattern: { name: found.name, patternData: found.pattern, colorPalette }, patternData: found.pattern,
}; colorPalette,
} satisfies PlayerPattern;
} else { } else {
return { type: "forbidden", reason: "no flares for pattern" }; throw new Error(`No flares for pattern ${name}`);
} }
} }
isCustomFlagAllowed( isColorAllowed(flares: string[], color: string): PlayerColor {
flag: string, const allowedColors = flares
flares: readonly string[] | undefined, .filter((flare) => flare.startsWith("color:"))
): true | "restricted" | "invalid" { .map((flare) => "#" + flare.split(":")[1]);
if (!flag.startsWith("!")) return "invalid"; if (!allowedColors.includes(color)) {
const code = flag.slice(1); throw new Error(`Color ${color} not allowed`);
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";
}
} }
return true; return { color };
} }
} }
export class FailOpenPrivilegeChecker implements PrivilegeChecker { export class FailOpenPrivilegeChecker implements PrivilegeChecker {
isPatternAllowed( isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult {
flares: readonly string[], return { type: "allowed", cosmetics: {} };
name: string,
colorPaletteName: string | null,
): PatternResult {
return { type: "unknown" };
}
isCustomFlagAllowed(
flag: string,
flares: readonly string[] | undefined,
): true | "restricted" | "invalid" {
return true;
} }
} }
+8 -82
View File
@@ -13,9 +13,6 @@ import {
ClientMessageSchema, ClientMessageSchema,
ID, ID,
PartialGameRecordSchema, PartialGameRecordSchema,
PlayerCosmeticRefs,
PlayerCosmetics,
PlayerPattern,
ServerErrorMessage, ServerErrorMessage,
} from "../core/Schemas"; } from "../core/Schemas";
import { replacer } from "../core/Util"; import { replacer } from "../core/Util";
@@ -26,7 +23,6 @@ import { GameManager } from "./GameManager";
import { getUserMe, verifyClientToken } from "./jwt"; import { getUserMe, verifyClientToken } from "./jwt";
import { logger } from "./Logger"; import { logger } from "./Logger";
import { assertNever } from "../core/Util";
import { PrivilegeRefresher } from "./PrivilegeRefresher"; import { PrivilegeRefresher } from "./PrivilegeRefresher";
import { initWorkerMetrics } from "./WorkerMetrics"; import { initWorkerMetrics } from "./WorkerMetrics";
@@ -366,15 +362,15 @@ export async function startWorker() {
} }
} }
const { perm, cosmetics, error } = checkCosmetics( const cosmeticResult = privilegeRefresher
clientMsg.cosmetics, .get()
flares ?? [], .isAllowed(flares ?? [], clientMsg.cosmetics ?? {});
);
if (perm === "forbidden") { if (cosmeticResult.type === "forbidden") {
log.warn(`Forbidden: ${error}`, { log.warn(`Forbidden: ${cosmeticResult.reason}`, {
clientID: clientMsg.clientID, clientID: clientMsg.clientID,
}); });
ws.close(1002, error); ws.close(1002, cosmeticResult.reason);
return; return;
} }
@@ -388,7 +384,7 @@ export async function startWorker() {
ip, ip,
clientMsg.username, clientMsg.username,
ws, ws,
cosmetics, cosmeticResult.cosmetics,
); );
const wasFound = gm.addClient( 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 // The load balancer will handle routing to this server based on path
const PORT = config.workerPortByIndex(workerId); const PORT = config.workerPortByIndex(workerId);
server.listen(PORT, () => { 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",
);
});
});