mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-08-18 09:14:09 +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: {
|
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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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, () => {
|
||||||
|
|||||||
@@ -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