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
+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, () => {