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
+1
View File
@@ -45,6 +45,7 @@ import { createRenderer, GameRenderer } from "./graphics/GameRenderer";
export interface LobbyConfig {
serverConfig: ServerConfig;
pattern: string | undefined;
flag: string;
playerName: string;
clientID: ClientID;
+25
View File
@@ -21,6 +21,7 @@ import { NewsModal } from "./NewsModal";
import "./PublicLobby";
import { PublicLobby } from "./PublicLobby";
import { SinglePlayerModal } from "./SinglePlayerModal";
import { TerritoryPatternsModal } from "./TerritoryPatternsModal";
import { UserSettingModal } from "./UserSettingModal";
import "./UsernameInput";
import { UsernameInput } from "./UsernameInput";
@@ -178,6 +179,28 @@ class Client {
hlpModal.open();
});
const territoryModal = document.querySelector(
"territory-patterns-modal",
) as TerritoryPatternsModal;
const tpButton = document.getElementById(
"territory-patterns-input-preview-button",
);
territoryModal instanceof TerritoryPatternsModal;
if (tpButton === null)
throw new Error("territory-patterns-input-preview-button");
territoryModal.previewButton = tpButton;
territoryModal.updatePreview();
territoryModal.resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.target.classList.contains("preview-container")) {
territoryModal.buttonWidth = entry.contentRect.width;
}
}
});
tpButton.addEventListener("click", () => {
territoryModal.open();
});
if (isLoggedIn() === false) {
// Not logged in
loginDiscordButton.disable = false;
@@ -212,6 +235,7 @@ class Client {
loginDiscordButton.translationKey = "main.logged_in";
loginDiscordButton.hidden = true;
const { user, player } = userMeResponse;
territoryModal.onUserMe(userMeResponse);
});
}
@@ -316,6 +340,7 @@ class Client {
{
gameID: lobby.gameID,
serverConfig: config,
pattern: this.userSettings.getSelectedPattern(),
flag:
this.flagInput === null || this.flagInput.getCurrentFlag() === "xx"
? ""
+4
View File
@@ -11,6 +11,7 @@ import {
UnitType,
mapCategories,
} from "../core/game/Game";
import { UserSettings } from "../core/game/UserSettings";
import { generateID } from "../core/Util";
import "./components/baseComponents/Button";
import "./components/baseComponents/Modal";
@@ -41,6 +42,8 @@ export class SinglePlayerModal extends LitElement {
@state() private disabledUnits: UnitType[] = [];
private userSettings: UserSettings = new UserSettings();
render() {
return html`
<o-modal title=${translateText("single_modal.title")}>
@@ -410,6 +413,7 @@ export class SinglePlayerModal extends LitElement {
flagInput.getCurrentFlag() === "xx"
? ""
: flagInput.getCurrentFlag(),
pattern: this.userSettings.getSelectedPattern(),
},
],
config: {
+472
View File
@@ -0,0 +1,472 @@
import type { TemplateResult } from "lit";
import { html, LitElement, render } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { UserMeResponse } from "../core/ApiSchemas";
import { PatternDecoder, territoryPatterns } from "../core/Cosmetics";
import { UserSettings } from "../core/game/UserSettings";
import "./components/Difficulties";
import "./components/Maps";
import { translateText } from "./Utils";
@customElement("territory-patterns-modal")
export class TerritoryPatternsModal extends LitElement {
@query("o-modal") private modalEl!: HTMLElement & {
open: () => void;
close: () => void;
};
public previewButton: HTMLElement | null = null;
public buttonWidth: number = 100;
@state() private selectedPattern: string | undefined = undefined;
@state() private lockedPatterns: string[] = [];
@state() private lockedReasons: Record<string, string> = {};
@state() private hoveredPattern: string | null = null;
@state() private hoverPosition = { x: 0, y: 0 };
@state() private keySequence: string[] = [];
@state() private showChocoPattern = false;
@state() private roles: string[] = [];
@state() private flares: string[] = [];
public resizeObserver: ResizeObserver;
private userSettings: UserSettings = new UserSettings();
connectedCallback() {
super.connectedCallback();
const b64 = this.userSettings.getSelectedPattern();
if (b64) {
const found = Object.entries(territoryPatterns.pattern).find(
([key, pattern]) => pattern.pattern === b64,
);
this.selectedPattern = found ? found[0] : "custom";
} else {
this.selectedPattern = undefined;
}
window.addEventListener("keydown", this.handleKeyDown);
this.updateComplete.then(() => {
const containers = this.renderRoot.querySelectorAll(".preview-container");
if (this.resizeObserver) {
containers.forEach((container) =>
this.resizeObserver.observe(container),
);
}
this.updatePreview();
});
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("keydown", this.handleKeyDown);
this.resizeObserver.disconnect();
}
onUserMe(userMeResponse: UserMeResponse) {
const { user, player } = userMeResponse;
if (player) {
const { publicId, roles, flares } = player;
if (roles) {
this.roles = roles;
}
if (flares) {
this.flares = flares;
}
}
this.requestUpdate();
}
private checkPatternPermission(roles: string[]) {
const patterns = territoryPatterns.pattern ?? {};
for (const key in patterns) {
const patternData = patterns[key];
const roleGroup: string[] | string | undefined = patternData.role_group;
console.log(`pattern:${key}`);
if (
this.flares.includes("pattern:*") ||
this.flares.includes(`pattern:${key}`)
) {
continue;
}
if (!roleGroup || (Array.isArray(roleGroup) && roleGroup.length === 0)) {
if (roles.length === 0) {
const reason = translateText("territory_patterns.blocked.login");
this.setLockedPatterns([key], reason);
}
continue;
}
const groupList = Array.isArray(roleGroup) ? roleGroup : [roleGroup];
const isAllowed = groupList.some((required) => roles.includes(required));
if (!isAllowed) {
const reason = translateText("territory_patterns.blocked.role", {
role: groupList.join(", "),
});
this.setLockedPatterns([key], reason);
}
}
}
private handleKeyDown = (e: KeyboardEvent) => {
const key = e.key.toLowerCase();
const nextSequence = [...this.keySequence, key].slice(-5);
this.keySequence = nextSequence;
if (nextSequence.join("") === "choco") {
this.triggerChocoEasterEgg();
this.keySequence = [];
}
};
private triggerChocoEasterEgg() {
console.log("🍫 Choco pattern unlocked!");
this.showChocoPattern = true;
const popup = document.createElement("div");
popup.className = "easter-egg-popup";
popup.textContent = "🎉 You unlocked the Choco pattern!";
document.body.appendChild(popup);
setTimeout(() => {
popup.remove();
}, 5000);
this.requestUpdate();
}
createRenderRoot() {
return this;
}
private renderTooltip(): TemplateResult | null {
if (this.hoveredPattern && this.lockedReasons[this.hoveredPattern]) {
return html`
<div
class="fixed z-[10000] px-3 py-2 rounded bg-black text-white text-sm pointer-events-none shadow-md"
style="top: ${this.hoverPosition.y + 12}px; left: ${this.hoverPosition
.x + 12}px;"
>
${this.lockedReasons[this.hoveredPattern]}
</div>
`;
}
return null;
}
private renderPatternButton(
key: string,
pattern: (typeof territoryPatterns.pattern)[string],
): TemplateResult {
const isLocked = this.isPatternLocked(key);
const isSelected =
this.selectedPattern === key ||
(key === "custom" && this.selectedPattern === "custom");
let previewPattern = pattern;
if (key === "custom") {
const b64 = this.userSettings.getSelectedPattern();
if (b64) {
previewPattern = { pattern: b64 } as any;
}
}
return html`
<button
class="border p-2 rounded-lg shadow text-black dark:text-white text-left
${isSelected
? "bg-blue-500 text-white"
: "bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"}
${isLocked ? "opacity-50 cursor-not-allowed" : ""}"
style="flex: 0 1 calc(25% - 1rem); max-width: calc(25% - 1rem);"
@click=${() => !isLocked && this.selectPattern(key)}
@mouseenter=${(e: MouseEvent) => this.handleMouseEnter(key, e)}
@mousemove=${(e: MouseEvent) => this.handleMouseMove(e)}
@mouseleave=${() => this.handleMouseLeave()}
>
<div class="text-sm font-bold mb-1">
${key === "custom"
? "Custom"
: translateText(`territory_patterns.pattern.${key}`)}
</div>
<div
class="preview-container"
style="
width: 100%;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border-radius: 8px;
overflow: hidden;
"
>
${this.renderPatternPreview(
previewPattern,
this.buttonWidth,
this.buttonWidth,
)}
</div>
</button>
`;
}
private renderPatternGrid(): TemplateResult {
const patterns = territoryPatterns.pattern ?? {};
const buttons: TemplateResult[] = [];
for (const key in patterns) {
if (!this.showChocoPattern && key === "choco") continue;
const result = this.renderPatternButton(key, patterns[key]);
buttons.push(result);
}
return html`
<div
class="flex flex-wrap gap-4 p-2"
style="justify-content: center; align-items: flex-start;"
>
<button
class="border p-2 rounded-lg shadow text-black dark:text-white text-left
${this.selectedPattern === null
? "bg-blue-500 text-white"
: "bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"}"
style="flex: 0 1 calc(25% - 1rem); max-width: calc(25% - 1rem);"
@click=${() => this.selectPattern(null)}
>
<div class="text-sm font-bold mb-1">
${translateText("territory_patterns.pattern.default")}
</div>
<div
class="preview-container"
style="
width: 100%;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border-radius: 8px;
overflow: hidden;
"
>
${this.renderBlankPreview(this.buttonWidth, this.buttonWidth)}
</div>
</button>
${buttons}
</div>
`;
}
render() {
this.resetLockedPatterns();
this.checkPatternPermission(this.roles);
return html`
${this.renderTooltip()}
<o-modal
id="territoryPatternsModal"
title="${translateText("territory_patterns.title")}"
>
${this.renderPatternGrid()}
</o-modal>
`;
}
public open() {
this.modalEl?.open();
}
public close() {
this.modalEl?.close();
}
private selectPattern(patternKey: string | null) {
if (patternKey) {
const pattern = territoryPatterns.pattern[patternKey];
if (pattern) {
this.userSettings.setSelectedPattern(pattern.pattern);
this.selectedPattern = patternKey;
} else {
this.userSettings.setSelectedPattern("");
this.selectedPattern = undefined;
}
} else {
this.userSettings.setSelectedPattern("");
this.selectedPattern = undefined;
}
this.updatePreview();
this.close();
}
private renderPatternPreview(
pattern: (typeof territoryPatterns.pattern)[string],
width: number,
height: number,
): TemplateResult {
const decoder = new PatternDecoder(pattern.pattern);
const cellCountX = decoder.getTileWidth();
const cellCountY = decoder.getTileHeight();
const cellSize =
cellCountX > 0 && cellCountY > 0
? Math.min(height / cellCountY, width / cellCountX)
: 1;
return html`
<div
style="
display: flex;
align-items: center;
justify-content: center;
height: ${height}px;
width: ${width}px;
background-color: #f0f0f0;
border-radius: 4px;
box-sizing: border-box;
overflow: hidden;
position: relative;
"
>
<div
style="
display: grid;
grid-template-columns: repeat(${cellCountX}, ${cellSize}px);
grid-template-rows: repeat(${cellCountY}, ${cellSize}px);
background-color: #ccc;
padding: 2px;
border-radius: 4px;
"
>
${(() => {
const tiles: TemplateResult[] = [];
for (let py = 0; py < cellCountY; py++) {
for (let px = 0; px < cellCountX; px++) {
const x = px << decoder.getScale();
const y = py << decoder.getScale();
const bit = decoder.isSet(x, y);
tiles.push(html`
<div
style="
background-color: ${bit ? "#000" : "transparent"};
border: 1px solid rgba(0, 0, 0, 0.1);
width: ${cellSize}px;
height: ${cellSize}px;
border-radius: 1px;
"
></div>
`);
}
}
return tiles;
})()}
</div>
</div>
`;
}
private renderBlankPreview(width: number, height: number): TemplateResult {
return html`
<div
style="
display: flex;
align-items: center;
justify-content: center;
height: ${height}px;
width: ${width}px;
background-color: #ffffff;
border-radius: 4px;
box-sizing: border-box;
overflow: hidden;
position: relative;
border: 1px solid #ccc;
"
>
<div
style="display: grid; grid-template-columns: repeat(2, ${width /
2}px); grid-template-rows: repeat(2, ${height / 2}px);"
>
<div
style="background-color: #fff; border: 1px solid rgba(0, 0, 0, 0.1); width: ${width /
2}px; height: ${height / 2}px;"
></div>
<div
style="background-color: #fff; border: 1px solid rgba(0, 0, 0, 0.1); width: ${width /
2}px; height: ${height / 2}px;"
></div>
<div
style="background-color: #fff; border: 1px solid rgba(0, 0, 0, 0.1); width: ${width /
2}px; height: ${height / 2}px;"
></div>
<div
style="background-color: #fff; border: 1px solid rgba(0, 0, 0, 0.1); width: ${width /
2}px; height: ${height / 2}px;"
></div>
</div>
</div>
`;
}
public updatePreview() {
if (!this.previewButton) return;
const patternKey = this.selectedPattern ?? "default";
let pattern = territoryPatterns.pattern[patternKey];
if (!pattern && patternKey === "custom") {
// customパターンはbase64から生成
const b64 = this.userSettings.getSelectedPattern();
if (b64) {
pattern = { pattern: b64 } as any;
}
}
if (!pattern) {
const blankPreview = this.renderBlankPreview(48, 48);
render(blankPreview, this.previewButton);
return;
}
const previewHTML = this.renderPatternPreview(pattern, 48, 48);
render(previewHTML, 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 resetLockedPatterns() {
this.lockedPatterns = [];
this.lockedReasons = {};
}
private isPatternLocked(patternKey: string): boolean {
return this.lockedPatterns.includes(patternKey);
}
private handleMouseEnter(patternKey: string, event: MouseEvent) {
if (this.isPatternLocked(patternKey)) {
this.hoveredPattern = patternKey;
this.hoverPosition = { x: event.clientX, y: event.clientY };
}
}
private handleMouseMove(event: MouseEvent) {
if (this.hoveredPattern) {
this.hoverPosition = { x: event.clientX, y: event.clientY };
}
}
private handleMouseLeave() {
this.hoveredPattern = null;
}
}
+2
View File
@@ -370,6 +370,7 @@ export class Transport {
token: this.lobbyConfig.token,
username: this.lobbyConfig.playerName,
flag: this.lobbyConfig.flag,
pattern: this.lobbyConfig.pattern,
} satisfies ClientJoinMessage);
}
@@ -423,6 +424,7 @@ export class Transport {
type: "spawn",
clientID: this.lobbyConfig.clientID,
flag: this.lobbyConfig.flag,
pattern: this.lobbyConfig.pattern,
name: this.lobbyConfig.playerName,
playerType: PlayerType.Human,
x: event.cell.x,
+18
View File
@@ -150,6 +150,15 @@ export class UserSettingModal extends LitElement {
}
}
private toggleTerritoryPatterns(e: CustomEvent<{ checked: boolean }>) {
const enabled = e.detail?.checked;
if (typeof enabled !== "boolean") return;
this.userSettings.set("settings.territoryPatterns", enabled);
console.log("🏳️ Territory Patterns:", enabled ? "ON" : "OFF");
}
private handleKeybindChange(
e: CustomEvent<{ action: string; value: string }>,
) {
@@ -262,6 +271,15 @@ export class UserSettingModal extends LitElement {
@change=${this.toggleAnonymousNames}
></setting-toggle>
<!-- 🏳️ Territory Patterns -->
<setting-toggle
label="${translateText("user_setting.territory_patterns_label")}"
description="${translateText("user_setting.territory_patterns_desc")}"
id="territory-patterns-toggle"
.checked=${this.userSettings.territoryPatterns()}
@change=${this.toggleTerritoryPatterns}
></setting-toggle>
<!-- ⚔️ Attack Ratio -->
<setting-slider
label="${translateText("user_setting.attack_ratio_label")}"
+3 -1
View File
@@ -1,5 +1,6 @@
import { EventBus } from "../../core/EventBus";
import { GameView } from "../../core/game/GameView";
import { UserSettings } from "../../core/game/UserSettings";
import { GameStartingModal } from "../GameStartingModal";
import { RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler";
import { TransformHandler } from "./TransformHandler";
@@ -42,6 +43,7 @@ export function createRenderer(
eventBus: EventBus,
): GameRenderer {
const transformHandler = new TransformHandler(game, eventBus, canvas);
const userSettings = new UserSettings();
const uiState = { attackRatio: 20 };
@@ -215,7 +217,7 @@ export function createRenderer(
const layers: Layer[] = [
new TerrainLayer(game, transformHandler),
new TerritoryLayer(game, eventBus, transformHandler),
new TerritoryLayer(game, eventBus, transformHandler, userSettings),
new RailroadLayer(game),
structureLayer,
new UnitLayer(game, eventBus, transformHandler),
+11
View File
@@ -124,6 +124,11 @@ export class OptionsMenu extends LitElement implements Layer {
this.userSettings.toggleLeftClickOpenMenu();
}
private onToggleTerritoryPatterns() {
this.userSettings.toggleTerritoryPatterns();
this.requestUpdate();
}
init() {
console.log("init called from OptionsMenu");
this.showPauseButton =
@@ -207,6 +212,12 @@ export class OptionsMenu extends LitElement implements Layer {
title: "Toggle Special effects",
children: "💥: " + (this.userSettings.fxLayer() ? "On" : "Off"),
})}
${button({
onClick: this.onToggleTerritoryPatterns,
title: "Territory Patterns",
children:
"🏳️: " + (this.userSettings.territoryPatterns() ? "On" : "Off"),
})}
${button({
onClick: this.onToggleDarkModeButtonClick,
title: "Dark Mode",
+35 -2
View File
@@ -6,16 +6,24 @@ import { Cell, PlayerType, UnitType } from "../../../core/game/Game";
import { euclDistFN, TileRef } from "../../../core/game/GameMap";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import { PseudoRandom } from "../../../core/PseudoRandom";
import { AlternateViewEvent, DragEvent } from "../../InputHandler";
import {
AlternateViewEvent,
DragEvent,
RefreshGraphicsEvent,
} from "../../InputHandler";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
export class TerritoryLayer implements Layer {
private userSettings: UserSettings;
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private imageData: ImageData;
private cachedTerritoryPatternsEnabled: boolean | undefined;
private tileToRenderQueue: PriorityQueue<{
tile: TileRef;
lastUpdate: number;
@@ -42,8 +50,11 @@ export class TerritoryLayer implements Layer {
private game: GameView,
private eventBus: EventBus,
private transformHandler: TransformHandler,
userSettings: UserSettings,
) {
this.userSettings = userSettings;
this.theme = game.config().theme();
this.cachedTerritoryPatternsEnabled = undefined;
}
shouldTransform(): boolean {
@@ -58,6 +69,11 @@ export class TerritoryLayer implements Layer {
}
tick() {
const prev = this.cachedTerritoryPatternsEnabled;
this.cachedTerritoryPatternsEnabled = this.userSettings.territoryPatterns();
if (prev !== undefined && prev !== this.cachedTerritoryPatternsEnabled) {
this.eventBus.emit(new RefreshGraphicsEvent());
}
this.game.recentlyUpdatedTiles().forEach((t) => this.enqueueTile(t));
const updates = this.game.updatesSinceLastTick();
const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : [];
@@ -291,7 +307,24 @@ export class TerritoryLayer implements Layer {
this.paintTile(tile, useBorderColor, 255);
}
} else {
this.paintTile(tile, this.theme.territoryColor(owner), 150);
const pattern = owner.pattern();
const patternsEnabled = this.cachedTerritoryPatternsEnabled ?? false;
if (pattern === undefined || patternsEnabled === false) {
this.paintTile(tile, this.theme.territoryColor(owner), 150);
} else {
const x = this.game.x(tile);
const y = this.game.y(tile);
const baseColor = this.theme.territoryColor(owner);
const decoder = owner.patternDecoder();
if (decoder !== undefined) {
const bit = decoder.isSet(x, y) ? 1 : 0;
const colorToUse = bit ? baseColor.darken(0.2) : baseColor;
this.paintTile(tile, colorToUse, 150);
} else {
this.paintTile(tile, baseColor, 150);
}
}
}
}
+7
View File
@@ -184,6 +184,13 @@
<div class="container__row">
<flag-input class="w-[20%] md:w-[15%]"></flag-input>
<territory-patterns-modal class="w-[50%] md:w-[12%]">
<button
id="territory-patterns-input-preview-button"
class="w-full border p-[4px] rounded-lg flex cursor-pointer border-black/30 dark:border-gray-300/60 bg-white/70 dark:bg-[rgba(55,65,81,0.7)]"
title="Pick a pattern!"
></button>
</territory-patterns-modal>
<username-input class="relative w-full"></username-input>
<news-button
class="w-[20%] md:w-[15%] component-hideable"
+1
View File
@@ -43,6 +43,7 @@ export const UserMeResponseSchema = z.object({
player: z.object({
publicId: z.string(),
roles: z.string().array().optional(),
flares: z.string().array().optional(),
}),
});
export type UserMeResponse = z.infer<typeof UserMeResponseSchema>;
+15
View File
@@ -0,0 +1,15 @@
import { z } from "zod";
// Schema for resources/cosmetics/cosmetics.json
export const CosmeticsSchema = z.object({
role_group: z.record(z.string(), z.string().array()).optional(),
pattern: z.record(
z.string(),
z.object({
pattern: z.string().base64(),
role_group: z.string().array().optional(),
}),
),
});
export type Cosmetics = z.infer<typeof CosmeticsSchema>;
+65
View File
@@ -0,0 +1,65 @@
import { base64url } from "jose";
import rawTerritoryPatterns from "../../resources/cosmetics/cosmetics.json" with { type: "json" };
import { CosmeticsSchema } from "./CosmeticSchemas";
export const territoryPatterns = CosmeticsSchema.parse(rawTerritoryPatterns);
export class PatternDecoder {
private bytes: Uint8Array;
private tileWidth: number;
private tileHeight: number;
private scale: number;
constructor(base64: string) {
this.bytes = base64url.decode(base64);
if (this.bytes.length < 3) {
throw new Error(
"Pattern data is too short to contain required metadata.",
);
}
const version = this.bytes[0];
if (version !== 0) {
throw new Error(`Unrecognized pattern version ${version}.`);
}
const byte1 = this.bytes[1];
const byte2 = this.bytes[2];
this.scale = byte1 & 0x07;
this.tileWidth = (((byte2 & 0x03) << 5) | ((byte1 >> 3) & 0x1f)) + 2;
this.tileHeight = ((byte2 >> 2) & 0x3f) + 2;
const expectedBits = this.tileWidth * this.tileHeight;
const expectedBytes = (expectedBits + 7) >> 3; // Equivalent to: ceil(expectedBits / 8);
if (this.bytes.length - 3 < expectedBytes) {
throw new Error(
"Pattern data is too short for the specified dimensions.",
);
}
}
getTileWidth(): number {
return this.tileWidth;
}
getTileHeight(): number {
return this.tileHeight;
}
getScale(): number {
return this.scale;
}
isSet(x: number, y: number): boolean {
const px = (x >> this.scale) % this.tileWidth;
const py = (y >> this.scale) % this.tileHeight;
const idx = py * this.tileWidth + px;
const byteIndex = idx >> 3;
const bitIndex = idx & 7;
const byte = this.bytes[3 + byteIndex];
if (byte === undefined) throw new Error("Invalid pattern");
return (byte & (1 << bitIndex)) !== 0;
}
}
+2
View File
@@ -42,6 +42,7 @@ export async function createGameRunner(
const humans = gameStart.players.map(
(p) =>
new PlayerInfo(
p.pattern,
p.flag,
p.clientID === clientID
? sanitize(p.username)
@@ -60,6 +61,7 @@ export async function createGameRunner(
new Cell(n.coordinates[0], n.coordinates[1]),
n.strength,
new PlayerInfo(
undefined,
n.flag || "",
n.name,
PlayerType.FakeHuman,
+4
View File
@@ -178,6 +178,7 @@ export const AllPlayersStatsSchema = z.record(ID, PlayerStatsSchema);
export const UsernameSchema = SafeString;
export const FlagSchema = z.string().max(128).optional();
export const PatternSchema = z.string().max(128).base64().optional();
export const QuickChatKeySchema = z.enum(
Object.entries(quickChatData).flatMap(([category, entries]) =>
@@ -203,6 +204,7 @@ export const SpawnIntentSchema = BaseIntentSchema.extend({
type: z.literal("spawn"),
name: UsernameSchema,
flag: FlagSchema,
pattern: PatternSchema,
playerType: PlayerTypeSchema,
x: z.number(),
y: z.number(),
@@ -350,6 +352,7 @@ export const PlayerSchema = z.object({
clientID: ID,
username: UsernameSchema,
flag: FlagSchema,
pattern: PatternSchema,
});
export const GameStartInfoSchema = z.object({
@@ -454,6 +457,7 @@ export const ClientJoinMessageSchema = z.object({
lastTurn: z.number(), // The last turn the client saw.
username: UsernameSchema,
flag: FlagSchema,
pattern: PatternSchema,
});
export const ClientMessageSchema = z.discriminatedUnion("type", [
+8 -1
View File
@@ -46,7 +46,14 @@ export class BotSpawner {
}
}
return new SpawnExecution(
new PlayerInfo("", botName, PlayerType.Bot, null, this.random.nextID()),
new PlayerInfo(
undefined,
"",
botName,
PlayerType.Bot,
null,
this.random.nextID(),
),
tile,
);
}
+1
View File
@@ -349,6 +349,7 @@ export class PlayerInfo {
public readonly clan: string | null;
constructor(
public readonly pattern: string | undefined,
public readonly flag: string | undefined,
public readonly name: string,
public readonly playerType: PlayerType,
+1
View File
@@ -133,6 +133,7 @@ export interface PlayerUpdate {
type: GameUpdateType.Player;
nameViewData?: NameViewData;
clientID: ClientID | null;
pattern: string | undefined;
flag: string | undefined;
name: string;
displayName: string;
+14
View File
@@ -1,4 +1,5 @@
import { Config } from "../configuration/Config";
import { PatternDecoder } from "../Cosmetics";
import { ClientID, GameID } from "../Schemas";
import { createRandomName } from "../Util";
import { WorkerClient } from "../worker/WorkerClient";
@@ -138,6 +139,7 @@ export class UnitView {
export class PlayerView {
public anonymousName: string | null = null;
private decoder?: PatternDecoder;
constructor(
private game: GameView,
@@ -152,6 +154,12 @@ export class PlayerView {
this.data.playerType,
);
}
this.decoder =
data.pattern === undefined ? undefined : new PatternDecoder(data.pattern);
}
patternDecoder(): PatternDecoder | undefined {
return this.decoder;
}
async actions(tile: TileRef): Promise<PlayerActions> {
@@ -197,6 +205,11 @@ export class PlayerView {
flag(): string | undefined {
return this.data.flag;
}
pattern(): string | undefined {
return this.data.pattern;
}
name(): string {
return this.anonymousName !== null && userSettings.anonymousNames()
? this.anonymousName
@@ -295,6 +308,7 @@ export class PlayerView {
}
info(): PlayerInfo {
return new PlayerInfo(
this.pattern(),
this.flag(),
this.name(),
this.type(),
+6 -3
View File
@@ -81,7 +81,6 @@ export class PlayerImpl implements Player {
public _units: Unit[] = [];
public _tiles: Set<TileRef> = new Set();
private _flag: string | undefined;
private _name: string;
private _displayName: string;
@@ -109,7 +108,6 @@ export class PlayerImpl implements Player {
startTroops: number,
private readonly _team: Team | null,
) {
this._flag = playerInfo.flag;
this._name = sanitizeUsername(playerInfo.name);
this._targetTroopRatio = 95n;
this._troops = toInt(startTroops);
@@ -130,6 +128,7 @@ export class PlayerImpl implements Player {
return {
type: GameUpdateType.Player,
clientID: this.clientID(),
pattern: this.pattern(),
flag: this.flag(),
name: this.name(),
displayName: this.displayName(),
@@ -178,8 +177,12 @@ export class PlayerImpl implements Player {
return this._smallID;
}
pattern(): string | undefined {
return this.playerInfo.pattern;
}
flag(): string | undefined {
return this._flag;
return this.playerInfo.flag;
}
name(): string {
+18
View File
@@ -33,6 +33,10 @@ export class UserSettings {
return this.get("settings.leftClickOpensMenu", false);
}
territoryPatterns() {
return this.get("settings.territoryPatterns", true);
}
focusLocked() {
return false;
// TODO: renable when performance issues are fixed.
@@ -59,6 +63,10 @@ export class UserSettings {
this.set("settings.specialEffects", !this.fxLayer());
}
toggleTerritoryPatterns() {
this.set("settings.territoryPatterns", !this.territoryPatterns());
}
toggleDarkMode() {
this.set("settings.darkMode", !this.darkMode());
if (this.darkMode()) {
@@ -67,4 +75,14 @@ export class UserSettings {
document.documentElement.classList.remove("dark");
}
}
private readonly PATTERN_KEY = "territoryPattern";
getSelectedPattern(): string | undefined {
return localStorage.getItem(this.PATTERN_KEY) ?? undefined;
}
setSelectedPattern(base64: string): void {
localStorage.setItem(this.PATTERN_KEY, base64);
}
}
+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(