mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-02 09:58:08 +00:00
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:
+54
-106
@@ -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
@@ -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, () => {
|
||||
|
||||
Reference in New Issue
Block a user