mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 18:30:53 +00:00
store & reference pattern by name (#1766)
## Description: Store pattern by name instead of value. The worker replaces the pattern name with it's base64 when joining. This ensures the client & server are never out of sync after patterns are updated. * removed resizeObserver on the territory modal, it was causing some race conditions, and the modal is not resizable so it's unnecessary. * Moved PatternSchema to CosmeticSchema ## 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 - [x] I have read and accepted the CLA agreement (only required once). ## Please put your Discord username so you can be contacted if a bug or regression is found: evan
This commit is contained in:
@@ -47,7 +47,7 @@ import { createRenderer, GameRenderer } from "./graphics/GameRenderer";
|
||||
|
||||
export interface LobbyConfig {
|
||||
serverConfig: ServerConfig;
|
||||
pattern: string | undefined;
|
||||
patternName: string | undefined;
|
||||
flag: string;
|
||||
playerName: string;
|
||||
clientID: ClientID;
|
||||
|
||||
@@ -4,14 +4,14 @@ import { getApiBase, getAuthHeader } from "./jwt";
|
||||
|
||||
export async function patterns(
|
||||
userMe: UserMeResponse | null,
|
||||
): Promise<Pattern[]> {
|
||||
): Promise<Map<string, Pattern>> {
|
||||
const cosmetics = await getCosmetics();
|
||||
|
||||
if (cosmetics === undefined) {
|
||||
return [];
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const patterns: Pattern[] = [];
|
||||
const patterns: Map<string, Pattern> = new Map();
|
||||
const playerFlares = new Set(userMe?.player.flares);
|
||||
|
||||
for (const name in cosmetics.patterns) {
|
||||
@@ -20,10 +20,10 @@ export async function patterns(
|
||||
if (hasAccess) {
|
||||
// Remove product info because player already has access.
|
||||
patternData.product = null;
|
||||
patterns.push(patternData);
|
||||
patterns.set(name, patternData);
|
||||
} else if (patternData.product !== null) {
|
||||
// Player doesn't have access, but product is available for purchase.
|
||||
patterns.push(patternData);
|
||||
patterns.set(name, patternData);
|
||||
}
|
||||
// If player doesn't have access and product is null, don't show it.
|
||||
}
|
||||
@@ -65,7 +65,7 @@ export async function handlePurchase(priceId: string) {
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
async function getCosmetics(): Promise<Cosmetics | undefined> {
|
||||
export async function getCosmetics(): Promise<Cosmetics | undefined> {
|
||||
try {
|
||||
const response = await fetch(`${getApiBase()}/cosmetics.json`);
|
||||
if (!response.ok) {
|
||||
|
||||
+1
-8
@@ -216,13 +216,6 @@ class Client {
|
||||
throw new Error("territory-patterns-input-preview-button");
|
||||
territoryModal.previewButton = patternButton;
|
||||
territoryModal.updatePreview();
|
||||
territoryModal.resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.target.classList.contains("preview-container")) {
|
||||
territoryModal.buttonWidth = entry.contentRect.width;
|
||||
}
|
||||
}
|
||||
});
|
||||
patternButton.addEventListener("click", () => {
|
||||
territoryModal.open();
|
||||
});
|
||||
@@ -464,7 +457,7 @@ class Client {
|
||||
{
|
||||
gameID: lobby.gameID,
|
||||
serverConfig: config,
|
||||
pattern: this.userSettings.getSelectedPattern(),
|
||||
patternName: this.userSettings.getSelectedPatternName(),
|
||||
flag:
|
||||
this.flagInput === null || this.flagInput.getCurrentFlag() === "xx"
|
||||
? ""
|
||||
|
||||
@@ -21,6 +21,7 @@ import "./components/baseComponents/Modal";
|
||||
import "./components/Difficulties";
|
||||
import { DifficultyDescription } from "./components/Difficulties";
|
||||
import "./components/Maps";
|
||||
import { getCosmetics } from "./Cosmetics";
|
||||
import { FlagInput } from "./FlagInput";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
import { UsernameInput } from "./UsernameInput";
|
||||
@@ -401,7 +402,7 @@ export class SinglePlayerModal extends LitElement {
|
||||
: this.disabledUnits.filter((u) => u !== unit);
|
||||
}
|
||||
|
||||
private startGame() {
|
||||
private async startGame() {
|
||||
// If random map is selected, choose a random map now
|
||||
if (this.useRandomMap) {
|
||||
this.selectedMap = this.getRandomMap();
|
||||
@@ -424,6 +425,10 @@ export class SinglePlayerModal extends LitElement {
|
||||
if (!flagInput) {
|
||||
console.warn("Flag input element not found");
|
||||
}
|
||||
const patternName = this.userSettings.getSelectedPatternName();
|
||||
const pattern = patternName
|
||||
? (await getCosmetics())?.patterns[patternName]
|
||||
: undefined;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("join-lobby", {
|
||||
detail: {
|
||||
@@ -439,7 +444,7 @@ export class SinglePlayerModal extends LitElement {
|
||||
flagInput.getCurrentFlag() === "xx"
|
||||
? ""
|
||||
: flagInput.getCurrentFlag(),
|
||||
pattern: this.userSettings.getSelectedPattern(),
|
||||
pattern: pattern?.pattern,
|
||||
},
|
||||
],
|
||||
config: {
|
||||
|
||||
@@ -11,6 +11,8 @@ import "./components/Maps";
|
||||
import { handlePurchase, patterns } from "./Cosmetics";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
const BUTTON_WIDTH = 150;
|
||||
|
||||
@customElement("territory-patterns-modal")
|
||||
export class TerritoryPatternsModal extends LitElement {
|
||||
@query("o-modal") private modalEl!: HTMLElement & {
|
||||
@@ -19,22 +21,16 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
};
|
||||
|
||||
public previewButton: HTMLElement | null = null;
|
||||
public buttonWidth: number = 150;
|
||||
|
||||
@state() private selectedPattern: string | undefined;
|
||||
@state() private selectedPattern: Pattern | undefined;
|
||||
|
||||
@state() private lockedPatterns: string[] = [];
|
||||
@state() private lockedReasons: Record<string, string> = {};
|
||||
@state() private hoveredPattern: Pattern | null = null;
|
||||
@state() private hoverPosition = { x: 0, y: 0 };
|
||||
|
||||
@state() private keySequence: string[] = [];
|
||||
@state() private showChocoPattern = false;
|
||||
|
||||
private patterns: Pattern[] = [];
|
||||
private me: UserMeResponse | null = null;
|
||||
|
||||
public resizeObserver: ResizeObserver;
|
||||
private patterns: Map<string, Pattern> = new Map();
|
||||
|
||||
private userSettings: UserSettings = new UserSettings();
|
||||
|
||||
@@ -47,17 +43,11 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("keydown", this.handleKeyDown);
|
||||
this.selectedPattern = this.userSettings.getSelectedPattern();
|
||||
this.updateComplete.then(() => {
|
||||
const containers = this.renderRoot.querySelectorAll(".preview-container");
|
||||
if (this.resizeObserver) {
|
||||
containers.forEach((container) =>
|
||||
this.resizeObserver.observe(container),
|
||||
);
|
||||
}
|
||||
this.updatePreview();
|
||||
this.open().then(() => {
|
||||
this.updatePreview();
|
||||
});
|
||||
});
|
||||
this.open();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
@@ -67,7 +57,10 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
|
||||
async onUserMe(userMeResponse: UserMeResponse | null) {
|
||||
this.patterns = await patterns(userMeResponse);
|
||||
this.me = userMeResponse;
|
||||
const storedPatternName = this.userSettings.getSelectedPatternName();
|
||||
if (storedPatternName) {
|
||||
this.selectedPattern = this.patterns.get(storedPatternName);
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
@@ -123,7 +116,7 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
}
|
||||
|
||||
private renderPatternButton(pattern: Pattern): TemplateResult {
|
||||
const isSelected = this.selectedPattern === pattern.pattern;
|
||||
const isSelected = this.selectedPattern?.name === pattern.name;
|
||||
|
||||
return html`
|
||||
<div style="flex: 0 1 calc(25% - 1rem); max-width: calc(25% - 1rem);">
|
||||
@@ -134,7 +127,7 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
: "bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"}
|
||||
${pattern.product !== null ? "opacity-50 cursor-not-allowed" : ""}"
|
||||
@click=${() =>
|
||||
pattern.product === null && this.selectPattern(pattern.pattern)}
|
||||
pattern.product === null && this.selectPattern(pattern)}
|
||||
@mouseenter=${(e: MouseEvent) => this.handleMouseEnter(pattern, e)}
|
||||
@mousemove=${(e: MouseEvent) => this.handleMouseMove(e)}
|
||||
@mouseleave=${() => this.handleMouseLeave()}
|
||||
@@ -157,8 +150,8 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
>
|
||||
${this.renderPatternPreview(
|
||||
pattern.pattern,
|
||||
this.buttonWidth,
|
||||
this.buttonWidth,
|
||||
BUTTON_WIDTH,
|
||||
BUTTON_WIDTH,
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
@@ -182,8 +175,8 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
|
||||
private renderPatternGrid(): TemplateResult {
|
||||
const buttons: TemplateResult[] = [];
|
||||
for (const pattern of this.patterns) {
|
||||
if (!this.showChocoPattern && pattern.name === "choco") continue;
|
||||
for (const [name, pattern] of this.patterns) {
|
||||
if (!this.showChocoPattern && name === "choco") continue;
|
||||
|
||||
const result = this.renderPatternButton(pattern);
|
||||
buttons.push(result);
|
||||
@@ -218,7 +211,7 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
overflow: hidden;
|
||||
"
|
||||
>
|
||||
${this.renderBlankPreview(this.buttonWidth, this.buttonWidth)}
|
||||
${this.renderBlankPreview(BUTTON_WIDTH, BUTTON_WIDTH)}
|
||||
</div>
|
||||
</button>
|
||||
${buttons}
|
||||
@@ -239,22 +232,31 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
public open() {
|
||||
this.modalEl?.open();
|
||||
window.addEventListener("keydown", this.handleKeyDown);
|
||||
public async open() {
|
||||
this.isActive = true;
|
||||
this.requestUpdate();
|
||||
|
||||
// Wait for the DOM to be updated and the o-modal element to be available
|
||||
await this.updateComplete;
|
||||
|
||||
// Now modalEl should be available
|
||||
if (this.modalEl) {
|
||||
this.modalEl.open();
|
||||
} else {
|
||||
console.warn("modalEl is still null after updateComplete");
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", this.handleKeyDown);
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.isActive = false;
|
||||
this.modalEl?.close();
|
||||
window.removeEventListener("keydown", this.handleKeyDown);
|
||||
this.resizeObserver?.disconnect();
|
||||
this.isActive = false;
|
||||
}
|
||||
|
||||
private selectPattern(pattern: string | undefined) {
|
||||
this.userSettings.setSelectedPattern(pattern);
|
||||
private selectPattern(pattern: Pattern | undefined) {
|
||||
this.userSettings.setSelectedPatternName(pattern?.name);
|
||||
this.selectedPattern = pattern;
|
||||
this.updatePreview();
|
||||
this.close();
|
||||
@@ -314,24 +316,14 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
|
||||
public updatePreview() {
|
||||
if (this.previewButton === null) return;
|
||||
const preview = this.renderPatternPreview(this.selectedPattern, 48, 48);
|
||||
const preview = this.renderPatternPreview(
|
||||
this.selectedPattern?.pattern,
|
||||
48,
|
||||
48,
|
||||
);
|
||||
render(preview, this.previewButton);
|
||||
}
|
||||
|
||||
private setLockedPatterns(lockedPatterns: string[], reason: string) {
|
||||
this.lockedPatterns = [...this.lockedPatterns, ...lockedPatterns];
|
||||
this.lockedReasons = {
|
||||
...this.lockedReasons,
|
||||
...lockedPatterns.reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = reason;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private handleMouseEnter(pattern: Pattern, event: MouseEvent) {
|
||||
if (pattern.product !== null) {
|
||||
this.hoveredPattern = pattern;
|
||||
@@ -360,13 +352,21 @@ export function generatePreviewDataUrl(
|
||||
height?: number,
|
||||
): string {
|
||||
pattern ??= DEFAULT_PATTERN_B64;
|
||||
const patternLookupKey = `${pattern}-${width}-${height}`;
|
||||
|
||||
if (patternCache.has(pattern)) {
|
||||
return patternCache.get(pattern)!;
|
||||
if (patternCache.has(patternLookupKey)) {
|
||||
return patternCache.get(patternLookupKey)!;
|
||||
}
|
||||
|
||||
// Calculate canvas size
|
||||
const decoder = new PatternDecoder(pattern, base64url.decode);
|
||||
let decoder: PatternDecoder;
|
||||
try {
|
||||
decoder = new PatternDecoder(pattern, base64url.decode);
|
||||
} catch (e) {
|
||||
console.error("Error decoding pattern", e);
|
||||
return "";
|
||||
}
|
||||
|
||||
const scaledWidth = decoder.scaledWidth();
|
||||
const scaledHeight = decoder.scaledHeight();
|
||||
|
||||
@@ -403,6 +403,6 @@ export function generatePreviewDataUrl(
|
||||
// Create a data URL
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
const dataUrl = canvas.toDataURL("image/png");
|
||||
patternCache.set(pattern, dataUrl);
|
||||
patternCache.set(patternLookupKey, dataUrl);
|
||||
return dataUrl;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
GameType,
|
||||
Gold,
|
||||
PlayerID,
|
||||
PlayerType,
|
||||
Tick,
|
||||
UnitType,
|
||||
} from "../core/game/Game";
|
||||
@@ -370,7 +369,7 @@ export class Transport {
|
||||
token: this.lobbyConfig.token,
|
||||
username: this.lobbyConfig.playerName,
|
||||
flag: this.lobbyConfig.flag,
|
||||
pattern: this.lobbyConfig.pattern,
|
||||
patternName: this.lobbyConfig.patternName,
|
||||
} satisfies ClientJoinMessage);
|
||||
}
|
||||
|
||||
@@ -433,10 +432,6 @@ export class Transport {
|
||||
this.sendIntent({
|
||||
type: "spawn",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
flag: this.lobbyConfig.flag,
|
||||
pattern: this.lobbyConfig.pattern,
|
||||
name: this.lobbyConfig.playerName,
|
||||
playerType: PlayerType.Human,
|
||||
tile: event.tile,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { base64url } from "jose";
|
||||
import { z } from "zod/v4";
|
||||
import { RequiredPatternSchema } from "./Schemas";
|
||||
import { PatternDecoder } from "./PatternDecoder";
|
||||
|
||||
export type Cosmetics = z.infer<typeof CosmeticsSchema>;
|
||||
export type Pattern = z.infer<typeof PatternInfoSchema>;
|
||||
export type PatternName = z.infer<typeof PatternNameSchema>;
|
||||
export type Product = z.infer<typeof ProductSchema>;
|
||||
|
||||
export const ProductSchema = z.object({
|
||||
productId: z.string(),
|
||||
@@ -7,15 +13,43 @@ export const ProductSchema = z.object({
|
||||
price: z.string(),
|
||||
});
|
||||
|
||||
const PatternSchema = z.object({
|
||||
name: z.string(),
|
||||
pattern: RequiredPatternSchema,
|
||||
export const PatternNameSchema = z
|
||||
.string()
|
||||
.regex(/^[a-z0-9_]+$/)
|
||||
.max(32);
|
||||
|
||||
export const PatternSchema = z
|
||||
.string()
|
||||
.max(1403)
|
||||
.base64url()
|
||||
.refine(
|
||||
(val) => {
|
||||
try {
|
||||
new PatternDecoder(val, base64url.decode);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
console.error(JSON.stringify(e.message, null, 2));
|
||||
} else {
|
||||
console.error(String(e));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
message: "Invalid pattern",
|
||||
},
|
||||
);
|
||||
|
||||
export const PatternInfoSchema = z.object({
|
||||
name: PatternNameSchema,
|
||||
pattern: PatternSchema,
|
||||
product: ProductSchema.nullable(),
|
||||
});
|
||||
|
||||
// Schema for resources/cosmetics/cosmetics.json
|
||||
export const CosmeticsSchema = z.object({
|
||||
patterns: z.record(z.string(), PatternSchema),
|
||||
patterns: z.record(z.string(), PatternInfoSchema),
|
||||
flag: z
|
||||
.object({
|
||||
layers: z.record(
|
||||
@@ -36,6 +70,3 @@ export const CosmeticsSchema = z.object({
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
export type Cosmetics = z.infer<typeof CosmeticsSchema>;
|
||||
export type Pattern = z.infer<typeof PatternSchema>;
|
||||
export type Product = z.infer<typeof ProductSchema>;
|
||||
|
||||
+3
-31
@@ -1,7 +1,7 @@
|
||||
import { base64url } from "jose";
|
||||
import { z } from "zod";
|
||||
import quickChatData from "../../resources/QuickChat.json" with { type: "json" };
|
||||
import countries from "../client/data/countries.json" with { type: "json" };
|
||||
import { PatternSchema } from "./CosmeticSchemas";
|
||||
import {
|
||||
AllPlayers,
|
||||
Difficulty,
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
Trios,
|
||||
UnitType,
|
||||
} from "./game/Game";
|
||||
import { PatternDecoder } from "./PatternDecoder";
|
||||
import { PlayerStatsSchema } from "./StatsSchemas";
|
||||
import { flattenedEmojiTable } from "./Util";
|
||||
|
||||
@@ -203,29 +202,6 @@ export const FlagSchema = z
|
||||
},
|
||||
{ message: "Invalid flag: must be a valid country code or start with !" },
|
||||
);
|
||||
export const RequiredPatternSchema = z
|
||||
.string()
|
||||
.max(1403)
|
||||
.base64url()
|
||||
.refine(
|
||||
(val) => {
|
||||
try {
|
||||
new PatternDecoder(val, base64url.decode);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
console.error(JSON.stringify(e.message, null, 2));
|
||||
} else {
|
||||
console.error(String(e));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
message: "Invalid pattern",
|
||||
},
|
||||
);
|
||||
export const PatternSchema = RequiredPatternSchema.optional();
|
||||
|
||||
export const QuickChatKeySchema = z.enum(
|
||||
Object.entries(quickChatData).flatMap(([category, entries]) =>
|
||||
@@ -254,10 +230,6 @@ export const AttackIntentSchema = BaseIntentSchema.extend({
|
||||
|
||||
export const SpawnIntentSchema = BaseIntentSchema.extend({
|
||||
type: z.literal("spawn"),
|
||||
name: UsernameSchema,
|
||||
flag: FlagSchema,
|
||||
pattern: PatternSchema,
|
||||
playerType: PlayerTypeSchema,
|
||||
tile: z.number(),
|
||||
});
|
||||
|
||||
@@ -397,7 +369,7 @@ export const PlayerSchema = z.object({
|
||||
clientID: ID,
|
||||
username: UsernameSchema,
|
||||
flag: FlagSchema,
|
||||
pattern: PatternSchema,
|
||||
pattern: PatternSchema.optional(),
|
||||
});
|
||||
|
||||
export const GameStartInfoSchema = z.object({
|
||||
@@ -503,7 +475,7 @@ export const ClientJoinMessageSchema = z.object({
|
||||
lastTurn: z.number(), // The last turn the client saw.
|
||||
username: UsernameSchema,
|
||||
flag: FlagSchema,
|
||||
pattern: PatternSchema,
|
||||
patternName: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ClientMessageSchema = z.discriminatedUnion("type", [
|
||||
|
||||
@@ -381,13 +381,13 @@ export class GameView implements GameMap {
|
||||
private _mapData: TerrainMapData,
|
||||
private _myClientID: ClientID,
|
||||
private _gameID: GameID,
|
||||
private _hunans: Player[],
|
||||
private humans: Player[],
|
||||
) {
|
||||
this._map = this._mapData.gameMap;
|
||||
this.lastUpdate = null;
|
||||
this.unitGrid = new UnitGrid(this._map);
|
||||
this._cosmetics = new Map(
|
||||
this._hunans.map((h) => [
|
||||
this.humans.map((h) => [
|
||||
h.clientID,
|
||||
{ flag: h.flag, pattern: h.pattern } satisfies PlayerCosmetics,
|
||||
]),
|
||||
|
||||
@@ -111,11 +111,11 @@ export class UserSettings {
|
||||
}
|
||||
}
|
||||
|
||||
getSelectedPattern(): string | undefined {
|
||||
getSelectedPatternName(): string | undefined {
|
||||
return localStorage.getItem(PATTERN_KEY) ?? undefined;
|
||||
}
|
||||
|
||||
setSelectedPattern(base64: string | undefined): void {
|
||||
setSelectedPatternName(base64: string | undefined): void {
|
||||
if (base64 === undefined) {
|
||||
localStorage.removeItem(PATTERN_KEY);
|
||||
} else {
|
||||
|
||||
+24
-34
@@ -1,11 +1,16 @@
|
||||
import { Cosmetics, Pattern } from "../core/CosmeticSchemas";
|
||||
import { Cosmetics } from "../core/CosmeticSchemas";
|
||||
import { PatternDecoder } from "../core/PatternDecoder";
|
||||
|
||||
type PatternResult =
|
||||
| { type: "allowed"; pattern: string }
|
||||
| { type: "unknown" }
|
||||
| { type: "forbidden"; reason: string };
|
||||
|
||||
export interface PrivilegeChecker {
|
||||
isPatternAllowed(
|
||||
base64: string,
|
||||
flares: readonly string[] | undefined,
|
||||
): true | "restricted" | "unlisted" | "invalid";
|
||||
): PatternResult;
|
||||
isCustomFlagAllowed(
|
||||
flag: string,
|
||||
flares: readonly string[] | undefined,
|
||||
@@ -13,49 +18,34 @@ export interface PrivilegeChecker {
|
||||
}
|
||||
|
||||
export class PrivilegeCheckerImpl implements PrivilegeChecker {
|
||||
private b64ToPattern: Record<string, Pattern> = {};
|
||||
|
||||
constructor(
|
||||
private cosmetics: Cosmetics,
|
||||
private b64urlDecode: (base64: string) => Uint8Array,
|
||||
) {
|
||||
for (const name in this.cosmetics.patterns) {
|
||||
const pattern = this.cosmetics.patterns[name];
|
||||
this.b64ToPattern[pattern.pattern] = pattern;
|
||||
}
|
||||
}
|
||||
) {}
|
||||
|
||||
isPatternAllowed(
|
||||
base64: string,
|
||||
name: string,
|
||||
flares: readonly string[] | undefined,
|
||||
): true | "restricted" | "unlisted" | "invalid" {
|
||||
): PatternResult {
|
||||
// Look for the pattern in the cosmetics.json config
|
||||
const found = this.b64ToPattern[base64];
|
||||
if (found === undefined) {
|
||||
try {
|
||||
// Ensure that the pattern will not throw for clients
|
||||
new PatternDecoder(base64, this.b64urlDecode);
|
||||
} catch (e) {
|
||||
// Pattern is invalid
|
||||
return "invalid";
|
||||
}
|
||||
// Pattern is unlisted
|
||||
if (flares !== undefined && flares.includes("pattern:*")) {
|
||||
// Player has the super-flare
|
||||
return true;
|
||||
}
|
||||
return "unlisted";
|
||||
const found = this.cosmetics.patterns[name];
|
||||
if (!found) return { type: "forbidden", reason: "pattern not found" };
|
||||
|
||||
try {
|
||||
new PatternDecoder(found.pattern, this.b64urlDecode);
|
||||
} catch (e) {
|
||||
return { type: "forbidden", reason: "invalid pattern" };
|
||||
}
|
||||
|
||||
if (
|
||||
flares !== undefined &&
|
||||
(flares.includes(`pattern:${found.name}`) || flares.includes("pattern:*"))
|
||||
flares?.includes(`pattern:${found.name}`) ||
|
||||
flares?.includes("pattern:*")
|
||||
) {
|
||||
// Player has a flare for this pattern
|
||||
return true;
|
||||
return { type: "allowed", pattern: found.pattern };
|
||||
} else {
|
||||
return { type: "forbidden", reason: "no flares for pattern" };
|
||||
}
|
||||
|
||||
return "restricted";
|
||||
}
|
||||
|
||||
isCustomFlagAllowed(
|
||||
@@ -136,8 +126,8 @@ export class FailOpenPrivilegeChecker implements PrivilegeChecker {
|
||||
isPatternAllowed(
|
||||
name: string,
|
||||
flares: readonly string[] | undefined,
|
||||
): true | "restricted" | "unlisted" | "invalid" {
|
||||
return true;
|
||||
): PatternResult {
|
||||
return { type: "unknown" };
|
||||
}
|
||||
|
||||
isCustomFlagAllowed(
|
||||
|
||||
+23
-8
@@ -24,6 +24,7 @@ import { gatekeeper, LimiterType } from "./Gatekeeper";
|
||||
import { getUserMe, verifyClientToken } from "./jwt";
|
||||
import { logger } from "./Logger";
|
||||
|
||||
import { assertNever } from "../core/Util";
|
||||
import { PrivilegeRefresher } from "./PrivilegeRefresher";
|
||||
import { initWorkerMetrics } from "./WorkerMetrics";
|
||||
|
||||
@@ -410,15 +411,29 @@ export async function startWorker() {
|
||||
}
|
||||
}
|
||||
|
||||
let pattern: string | undefined;
|
||||
// Check if the pattern is allowed
|
||||
if (clientMsg.pattern !== undefined) {
|
||||
const allowed = privilegeRefresher
|
||||
if (clientMsg.patternName !== undefined) {
|
||||
const result = privilegeRefresher
|
||||
.get()
|
||||
.isPatternAllowed(clientMsg.pattern, flares);
|
||||
if (allowed !== true) {
|
||||
log.warn(`Pattern ${allowed}: ${clientMsg.pattern}`);
|
||||
ws.close(1002, `Pattern ${allowed}`);
|
||||
return;
|
||||
.isPatternAllowed(clientMsg.patternName, flares);
|
||||
switch (result.type) {
|
||||
case "allowed":
|
||||
pattern = result.pattern;
|
||||
break;
|
||||
case "unknown":
|
||||
// Api could be down, so allow player to join but disable pattern.
|
||||
log.warn(`Pattern ${clientMsg.patternName} unknown`);
|
||||
break;
|
||||
case "forbidden":
|
||||
log.warn(`Pattern ${clientMsg.patternName}: ${result.reason}`);
|
||||
ws.close(
|
||||
1002,
|
||||
`Pattern ${clientMsg.patternName}: ${result.reason}`,
|
||||
);
|
||||
return;
|
||||
default:
|
||||
assertNever(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,7 +448,7 @@ export async function startWorker() {
|
||||
clientMsg.username,
|
||||
ws,
|
||||
clientMsg.flag,
|
||||
clientMsg.pattern,
|
||||
pattern,
|
||||
);
|
||||
|
||||
const wasFound = gm.addClient(
|
||||
|
||||
Reference in New Issue
Block a user