Patterned territory (#786)

## Description:
This is meant to give players more customization options.
Permission handling hasn’t really been implemented yet.
## 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 understand that submitting code with bugs that could have been
caught through manual testing blocks releases and new features for all
contributors

## Please put your Discord username so you can be contacted if a bug or
regression is found:

aotumuri
This commit is contained in:
Aotumuri
2025-06-23 12:57:24 +09:00
committed by GitHub
parent c5ada4d384
commit b71acdc993
41 changed files with 1001 additions and 14 deletions
+2
View File
@@ -14,9 +14,11 @@ export class Client {
public readonly persistentID: string,
public readonly claims: TokenPayload | null,
public readonly roles: string[] | undefined,
public readonly flares: string[] | undefined,
public readonly ip: string,
public readonly username: string,
public readonly ws: WebSocket,
public readonly flag: string | undefined,
public readonly pattern: string | undefined,
) {}
}
+1
View File
@@ -333,6 +333,7 @@ export class GameServer {
players: this.activeClients.map((c) => ({
username: c.username,
clientID: c.clientID,
pattern: c.pattern,
flag: c.flag,
})),
});
+65
View File
@@ -0,0 +1,65 @@
import { PatternDecoder } from "../core/Cosmetics";
import { Cosmetics } from "../core/CosmeticSchemas";
type PatternEntry = {
pattern: string;
role_group?: string[];
};
export class PrivilegeChecker {
constructor(private cosmetics: Cosmetics) {}
isPatternAllowed(
base64: string,
roles: readonly string[] | undefined,
flares: readonly string[] | undefined,
): true | "restricted" | "unlisted" | "invalid" {
// Look for the pattern in the cosmetics.json config
let found: [string, PatternEntry] | undefined;
for (const key in this.cosmetics.pattern) {
const entry = this.cosmetics.pattern[key];
if (entry.pattern === base64) {
found = [key, entry];
break;
}
}
if (!found) {
try {
// Ensure that the pattern will not throw for clients
new PatternDecoder(base64);
} catch (e) {
// Pattern is invalid
return "invalid";
}
// Pattern is unlisted
if (flares !== undefined && flares.includes("pattern:*")) {
return true;
}
return "unlisted";
}
const [key, entry] = found;
const allowedGroups = entry.role_group;
if (allowedGroups === undefined) {
return true;
}
for (const groupName of allowedGroups) {
const groupRoles = this.cosmetics.role_group?.[groupName] || [];
if (
roles !== undefined &&
roles.some((role) => groupRoles.includes(role))
) {
return true;
}
}
if (
flares !== undefined &&
(flares.includes(`pattern:${key}`) || flares.includes("pattern:*"))
)
return true;
return "restricted";
}
}
+27 -1
View File
@@ -8,6 +8,7 @@ import { WebSocket, WebSocketServer } from "ws";
import { z } from "zod/v4";
import { GameEnv } from "../core/configuration/Config";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { territoryPatterns } from "../core/Cosmetics";
import { GameType } from "../core/game/Game";
import {
ClientJoinMessageSchema,
@@ -22,6 +23,7 @@ import { GameManager } from "./GameManager";
import { gatekeeper, LimiterType } from "./Gatekeeper";
import { getUserMe, verifyClientToken } from "./jwt";
import { logger } from "./Logger";
import { PrivilegeChecker } from "./Privilege";
import { initWorkerMetrics } from "./WorkerMetrics";
const config = getServerConfigFromServer();
@@ -29,6 +31,8 @@ const config = getServerConfigFromServer();
const workerId = parseInt(process.env.WORKER_ID || "0");
const log = logger.child({ comp: `w_${workerId}` });
const privilegeChecker = new PrivilegeChecker(territoryPatterns);
// Worker setup
export function startWorker() {
log.info(`Worker starting...`);
@@ -321,6 +325,7 @@ export function startWorker() {
return;
}
// Verify token signature
const result = await verifyClientToken(clientMsg.token, config);
if (result === false) {
log.warn("Failed to verify token");
@@ -330,6 +335,7 @@ export function startWorker() {
const { persistentId, claims } = result;
let roles: string[] | undefined;
let flares: string[] | undefined;
if (claims === null) {
// TODO: Verify that the persistendId is is not a registered player
@@ -342,9 +348,27 @@ export function startWorker() {
return;
}
roles = result.player.roles;
flares = result.player.flares;
}
// TODO: Validate client settings based on roles
// Check if the flag is allowed
if (clientMsg.flag !== undefined) {
// TODO: Implement custom flag validation
}
// Check if the pattern is allowed
if (clientMsg.pattern !== undefined) {
const allowed = privilegeChecker.isPatternAllowed(
clientMsg.pattern,
roles,
flares,
);
if (allowed !== true) {
log.warn(`Pattern ${allowed}: ${clientMsg.pattern}`);
ws.close(1002, `Pattern ${allowed}`);
return;
}
}
// Create client and add to game
const client = new Client(
@@ -352,10 +376,12 @@ export function startWorker() {
persistentId,
claims,
roles,
flares,
ip,
clientMsg.username,
ws,
clientMsg.flag,
clientMsg.pattern,
);
const wasFound = gm.addClient(