mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 11:20:45 +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:
+1
-1
@@ -17,7 +17,7 @@ export default {
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
statements: 21.5,
|
||||
branches: 16.5,
|
||||
branches: 16,
|
||||
lines: 21.0,
|
||||
functions: 20.5,
|
||||
},
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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, () => {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user