diff --git a/resources/cosmetics/cosmetics.json b/resources/cosmetics/cosmetics.json new file mode 100644 index 000000000..b0cfa94b7 --- /dev/null +++ b/resources/cosmetics/cosmetics.json @@ -0,0 +1,72 @@ +{ + "role_groups": { + "donor": ["1359441841371480176", "1330243292306341969"], + "creator": ["1286745100411473930"] + }, + "pattern": { + "stripes_v": { + "pattern": "ABMIVVU=" + }, + "stripes_h": { + "pattern": "ABMIDw8=" + }, + "checkerboard": { + "pattern": "ABMIpaU=" + }, + "choco": { + "pattern": "AFIoAAABOEAHgkAc+AN/4AMcgAAA" + }, + "diagonal": { + "pattern": "AHE4AQACAAQACAAQACAAQACAAAABAAIABAAIABAAIABAAIA=", + "role_group": ["donor"] + }, + "cross": { + "pattern": "AHE4AYACQAQgCBAQCCAEQAKAAYABQAIgBBAICBAEIAJAAYA=", + "role_group": ["donor"] + }, + "mini_cross": { + "pattern": "AHEYA8AMMDAMwAPAAzAMDDADwA==", + "role_group": ["donor"] + }, + "horizontal_stripes": { + "pattern": "AHE4//8AAAAAAAAAAAAAAAAAAP//AAAAAAAAAAAAAAAAAAA=", + "role_group": ["donor"] + }, + "sparse_dots": { + "pattern": "AHE4AQEAAAAAAAAAAAAAAAAAAAEBAAAAAAAAAAAAAAAAAAA=", + "role_group": ["donor"] + }, + "evan": { + "pattern": "ALIUAAAAnsRIgiRZjuRpAiNJHiNJAAAA", + "role_group": ["creator"] + }, + "diagonal_stripe": { + "pattern": "AHEYAYACQAQgCBAQCCAEQAKAAQ==", + "role_group": ["donor"] + }, + "mountain_ridge": { + "pattern": "AHEYAAAYGDw8fn7//35+PDwYGA==", + "role_group": ["donor"] + }, + "scattered_dots": { + "pattern": "AHEYAAACIAAAAAAAAAAACBAAAA==", + "role_group": ["donor"] + }, + "circuit_board": { + "pattern": "AHEYw8PDwwwMDAwwDDAMw8PDww==", + "role_group": ["donor"] + }, + "vertical_bars": { + "pattern": "AHEYSZJJkkmSSZJJkkmSSZJJkg==", + "role_group": ["donor"] + }, + "-w-": { + "pattern": "AHEYAAAAAAAAAkCCQUQiLnQWaA==", + "role_group": ["donor"] + }, + "openfront": { + "pattern": "AAIiAAAAAAAAAAAAAAAAAAAAAIDD8YnweTiiD5FIYEIgEpkIRCKBCoFIpCIQeTwyPB6RjEAkEIgQKEQiApFAIEIgEYkIOAKfCIGIIyIAAAAAAAAAAAA=", + "role_group": ["creator"] + } + } +} diff --git a/resources/cosmetics/roles.txt b/resources/cosmetics/roles.txt new file mode 100644 index 000000000..6da05b458 --- /dev/null +++ b/resources/cosmetics/roles.txt @@ -0,0 +1,24 @@ +Admin 1286738076386856991 +OG 1286743849707769936 +Creator 1286745100411473930 +Bots 1286910984702791711 +Challenger 1292157381496799264 +OG100 1314802550314237952 +Contributor 1314972008362020957 +Ping 1316444187276738612 +Server Booster 1319387513206345770 +Content Creator 1320961080750637076 +Beta Tester 1327125593791397929 +Early Access Supporter 1330243292306341969 +Mod 1338654590043820148 +Support Staff 1343759662545244296 +DevChatAccess 1345831753528377425 +Member 1347621713852235808 +Active Contributor 1354828445489692692 +Retired Staff 1355753028099117147 +Head Mod 1357747869742010661 +Money Haters 1359441841371480176 +Translator 1367345579272831128 +Head Translator 1367345660852174930 +Development Stream Ping 1369340951109304340 +Core Contributor 1370238576868200488 diff --git a/resources/lang/en.json b/resources/lang/en.json index e23dd0534..e2cbcbd71 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -247,6 +247,8 @@ "attack_ratio_desc": "What percentage of your troops to send in an attack (1โ€“100%)", "troop_ratio_label": "๐Ÿช–๐Ÿ› ๏ธ Troops and Workers Ratio", "troop_ratio_desc": "Adjust the balance between troops (for combat) and workers (for gold production) (1โ€“100%)", + "territory_patterns_label": "๐Ÿณ๏ธ Territory Patterns", + "territory_patterns_desc": "Choose whether to display territory pattern designs in game", "easter_writing_speed_label": "Writing Speed Multiplier", "easter_writing_speed_desc": "Adjust how fast you pretend to code (x1โ€“x100)", "easter_bug_count_label": "Bug Count", @@ -463,5 +465,32 @@ }, "heads_up_message": { "choose_spawn": "Choose a starting location" + }, + "territory_patterns": { + "title": "Select Territory Pattern", + "blocked": { + "login": "You must be logged in to access this pattern.", + "role": "This pattern requires the {role} role." + }, + "pattern": { + "default": "Default", + "stripes_v": "Vertical Stripes", + "stripes_h": "Horizontal Stripes", + "checkerboard": "Checkerboard", + "choco": "Choco", + "diagonal": "Diagonal", + "cross": "Cross", + "mini_cross": "Mini Cross", + "horizontal_stripes": "Horizontal Stripes (Alt)", + "sparse_dots": "Sparse Dots", + "evan": "Evan", + "diagonal_stripe": "Diagonal Stripe", + "mountain_ridge": "Mountain Ridge", + "scattered_dots": "Scattered Dots", + "circuit_board": "Circuit Board", + "vertical_bars": "Vertical Bars", + "-w-": ".w.", + "openfront": "OpenFront" + } } } diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 40443ae3b..1df60c2f9 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -45,6 +45,7 @@ import { createRenderer, GameRenderer } from "./graphics/GameRenderer"; export interface LobbyConfig { serverConfig: ServerConfig; + pattern: string | undefined; flag: string; playerName: string; clientID: ClientID; diff --git a/src/client/Main.ts b/src/client/Main.ts index d1f226918..9ba4b94af 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -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" ? "" diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index f633c1e31..faef1d60b 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -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` @@ -410,6 +413,7 @@ export class SinglePlayerModal extends LitElement { flagInput.getCurrentFlag() === "xx" ? "" : flagInput.getCurrentFlag(), + pattern: this.userSettings.getSelectedPattern(), }, ], config: { diff --git a/src/client/TerritoryPatternsModal.ts b/src/client/TerritoryPatternsModal.ts new file mode 100644 index 000000000..143ca9352 --- /dev/null +++ b/src/client/TerritoryPatternsModal.ts @@ -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 = {}; + @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` +
+ ${this.lockedReasons[this.hoveredPattern]} +
+ `; + } + 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` + + `; + } + + 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` +
+ + ${buttons} +
+ `; + } + + render() { + this.resetLockedPatterns(); + this.checkPatternPermission(this.roles); + return html` + ${this.renderTooltip()} + + ${this.renderPatternGrid()} + + `; + } + + 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` +
+
+ ${(() => { + 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` +
+ `); + } + } + return tiles; + })()} +
+
+ `; + } + + private renderBlankPreview(width: number, height: number): TemplateResult { + return html` +
+
+
+
+
+
+
+
+ `; + } + + 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, + ), + }; + } + + 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; + } +} diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 5519c4e3d..3c8d7d364 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -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, diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index aca371552..a9ede2336 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -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} > + + + 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); + } + } } } diff --git a/src/client/index.html b/src/client/index.html index f0ca4c1ee..ff4bf6356 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -184,6 +184,13 @@
+ + + ; diff --git a/src/core/CosmeticSchemas.ts b/src/core/CosmeticSchemas.ts new file mode 100644 index 000000000..f47c1d7d9 --- /dev/null +++ b/src/core/CosmeticSchemas.ts @@ -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; diff --git a/src/core/Cosmetics.ts b/src/core/Cosmetics.ts new file mode 100644 index 000000000..2b929492f --- /dev/null +++ b/src/core/Cosmetics.ts @@ -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; + } +} diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index bd7e5519f..1d84bd205 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -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, diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index c3da2ba09..905b31f7b 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -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", [ diff --git a/src/core/execution/BotSpawner.ts b/src/core/execution/BotSpawner.ts index 644fbc803..9b9a00bb2 100644 --- a/src/core/execution/BotSpawner.ts +++ b/src/core/execution/BotSpawner.ts @@ -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, ); } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 948cdb60b..4d64c8dfe 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -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, diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index 4cf4d021a..0c76f3a95 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -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; diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index f4c2a2432..d2c6d8daa 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -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 { @@ -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(), diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 02c4bb98b..9bdbcb6e1 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -81,7 +81,6 @@ export class PlayerImpl implements Player { public _units: Unit[] = []; public _tiles: Set = 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 { diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts index c7005573c..c488a72ea 100644 --- a/src/core/game/UserSettings.ts +++ b/src/core/game/UserSettings.ts @@ -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); + } } diff --git a/src/server/Client.ts b/src/server/Client.ts index c367c0e04..cdd788764 100644 --- a/src/server/Client.ts +++ b/src/server/Client.ts @@ -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, ) {} } diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 19439a089..eda377740 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -333,6 +333,7 @@ export class GameServer { players: this.activeClients.map((c) => ({ username: c.username, clientID: c.clientID, + pattern: c.pattern, flag: c.flag, })), }); diff --git a/src/server/Privilege.ts b/src/server/Privilege.ts new file mode 100644 index 000000000..622d5b245 --- /dev/null +++ b/src/server/Privilege.ts @@ -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"; + } +} diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 427e369b0..b3091932b 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -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( diff --git a/tests/Attack.test.ts b/tests/Attack.test.ts index 0fcd709c1..71beae6b5 100644 --- a/tests/Attack.test.ts +++ b/tests/Attack.test.ts @@ -33,6 +33,7 @@ describe("Attack", () => { infiniteTroops: true, }); const attackerInfo = new PlayerInfo( + undefined, "us", "attacker dude", PlayerType.Human, @@ -41,6 +42,7 @@ describe("Attack", () => { ); game.addPlayer(attackerInfo); const defenderInfo = new PlayerInfo( + undefined, "us", "defender dude", PlayerType.Human, diff --git a/tests/BotBehavior.test.ts b/tests/BotBehavior.test.ts index 99df34764..6eeef9f10 100644 --- a/tests/BotBehavior.test.ts +++ b/tests/BotBehavior.test.ts @@ -20,6 +20,7 @@ describe("BotBehavior.handleAllianceRequests", () => { game = await setup("BigPlains", { infiniteGold: true, instantBuild: true }); const playerInfo = new PlayerInfo( + undefined, "us", "player_id", PlayerType.Bot, @@ -27,6 +28,7 @@ describe("BotBehavior.handleAllianceRequests", () => { "player_id", ); const requestorInfo = new PlayerInfo( + undefined, "fr", "requestor_id", PlayerType.Human, diff --git a/tests/Disconnected.test.ts b/tests/Disconnected.test.ts index 1932500be..ec58c5fdf 100644 --- a/tests/Disconnected.test.ts +++ b/tests/Disconnected.test.ts @@ -16,6 +16,7 @@ describe("Disconnected", () => { }); const player1Info = new PlayerInfo( + undefined, "us", "Active Player", PlayerType.Human, @@ -24,6 +25,7 @@ describe("Disconnected", () => { ); const player2Info = new PlayerInfo( + undefined, "fr", "Disconnected Player", PlayerType.Human, diff --git a/tests/MissileSilo.test.ts b/tests/MissileSilo.test.ts index 02e5875e6..14354fff4 100644 --- a/tests/MissileSilo.test.ts +++ b/tests/MissileSilo.test.ts @@ -33,6 +33,7 @@ describe("MissileSilo", () => { beforeEach(async () => { game = await setup("Plains", { infiniteGold: true, instantBuild: true }); const attacker_info = new PlayerInfo( + undefined, "fr", "attacker_id", PlayerType.Human, diff --git a/tests/PlayerInfo.test.ts b/tests/PlayerInfo.test.ts index 2d61676ab..eca14e173 100644 --- a/tests/PlayerInfo.test.ts +++ b/tests/PlayerInfo.test.ts @@ -4,6 +4,7 @@ describe("PlayerInfo", () => { describe("clan", () => { test("should extract clan from name when format is [XX]Name", () => { const playerInfo = new PlayerInfo( + undefined, "fr", "[CL]PlayerName", PlayerType.Human, @@ -15,6 +16,7 @@ describe("PlayerInfo", () => { test("should extract clan from name when format is [XXX]Name", () => { const playerInfo = new PlayerInfo( + undefined, "fr", "[ABC]PlayerName", PlayerType.Human, @@ -26,6 +28,7 @@ describe("PlayerInfo", () => { test("should extract clan from name when format is [XXXX]Name", () => { const playerInfo = new PlayerInfo( + undefined, "fr", "[ABCD]PlayerName", PlayerType.Human, @@ -37,6 +40,7 @@ describe("PlayerInfo", () => { test("should extract clan from name when format is [XXXXX]Name", () => { const playerInfo = new PlayerInfo( + undefined, "fr", "[ABCDE]PlayerName", PlayerType.Human, @@ -48,6 +52,7 @@ describe("PlayerInfo", () => { test("should extract clan from name when format is [xxxxx]Name", () => { const playerInfo = new PlayerInfo( + undefined, "fr", "[abcde]PlayerName", PlayerType.Human, @@ -59,6 +64,7 @@ describe("PlayerInfo", () => { test("should extract clan from name when format is [XxXxX]Name", () => { const playerInfo = new PlayerInfo( + undefined, "fr", "[AbCdE]PlayerName", PlayerType.Human, @@ -70,6 +76,7 @@ describe("PlayerInfo", () => { test("should return null when name doesn't start with [", () => { const playerInfo = new PlayerInfo( + undefined, "fr", "PlayerName", PlayerType.Human, @@ -81,6 +88,7 @@ describe("PlayerInfo", () => { test("should return null when name doesn't contain ]", () => { const playerInfo = new PlayerInfo( + undefined, "fr", "[ABCPlayerName", PlayerType.Human, @@ -92,6 +100,7 @@ describe("PlayerInfo", () => { test("should return null when clan tag is not 2-5 uppercase letters", () => { const playerInfo = new PlayerInfo( + undefined, "fr", "[A]PlayerName", PlayerType.Human, @@ -103,6 +112,7 @@ describe("PlayerInfo", () => { test("should return null when clan tag contains non alphanumeric characters", () => { const playerInfo = new PlayerInfo( + undefined, "fr", "[A1c]PlayerName", PlayerType.Human, @@ -114,6 +124,7 @@ describe("PlayerInfo", () => { test("should return null when clan tag is too long", () => { const playerInfo = new PlayerInfo( + undefined, "fr", "[ABCDEF]PlayerName", PlayerType.Human, diff --git a/tests/SAM.test.ts b/tests/SAM.test.ts index 0a14eae05..c10760805 100644 --- a/tests/SAM.test.ts +++ b/tests/SAM.test.ts @@ -21,6 +21,7 @@ describe("SAM", () => { beforeEach(async () => { game = await setup("BigPlains", { infiniteGold: true, instantBuild: true }); const defender_info = new PlayerInfo( + undefined, "us", "defender_id", PlayerType.Human, @@ -28,6 +29,7 @@ describe("SAM", () => { "defender_id", ); const far_defender_info = new PlayerInfo( + undefined, "us", "far_defender_id", PlayerType.Human, @@ -35,6 +37,7 @@ describe("SAM", () => { "far_defender_id", ); const attacker_info = new PlayerInfo( + undefined, "fr", "attacker_id", PlayerType.Human, diff --git a/tests/Stats.test.ts b/tests/Stats.test.ts index 819d895e4..b988210bf 100644 --- a/tests/Stats.test.ts +++ b/tests/Stats.test.ts @@ -20,6 +20,7 @@ describe("Stats", () => { stats = new StatsImpl(); game = await setup("half_land_half_ocean", {}, [ new PlayerInfo( + undefined, "us", "boat dude", PlayerType.Human, @@ -27,6 +28,7 @@ describe("Stats", () => { "player_1_id", ), new PlayerInfo( + undefined, "us", "boat dude", PlayerType.Human, diff --git a/tests/TeamAssignment.test.ts b/tests/TeamAssignment.test.ts index 33dace65d..bea1ea74f 100644 --- a/tests/TeamAssignment.test.ts +++ b/tests/TeamAssignment.test.ts @@ -7,6 +7,7 @@ describe("assignTeams", () => { const createPlayer = (id: string, clan?: string): PlayerInfo => { const name = clan ? `[${clan}]Player ${id}` : `Player ${id}`; return new PlayerInfo( + undefined, "๐Ÿณ๏ธ", // flag name, PlayerType.Human, diff --git a/tests/TerritoryCapture.test.ts b/tests/TerritoryCapture.test.ts index aee96aec6..cbc0c5a61 100644 --- a/tests/TerritoryCapture.test.ts +++ b/tests/TerritoryCapture.test.ts @@ -6,7 +6,14 @@ describe("Territory management", () => { test("player owns the tile it spawns on", async () => { const game = await setup("Plains"); game.addPlayer( - new PlayerInfo("us", "test_player", PlayerType.Human, null, "test_id"), + new PlayerInfo( + undefined, + "us", + "test_player", + PlayerType.Human, + null, + "test_id", + ), ); const spawnTile = game.map().ref(50, 50); game.addExecution( diff --git a/tests/UnitGrid.test.ts b/tests/UnitGrid.test.ts index 0fcfbf11f..dadbae594 100644 --- a/tests/UnitGrid.test.ts +++ b/tests/UnitGrid.test.ts @@ -11,7 +11,14 @@ async function checkRange( const game = await setup(mapName, { infiniteGold: true, instantBuild: true }); const grid = new UnitGrid(game.map()); const player = game.addPlayer( - new PlayerInfo("us", "test_player", PlayerType.Human, null, "test_id"), + new PlayerInfo( + undefined, + "us", + "test_player", + PlayerType.Human, + null, + "test_id", + ), ); const unitTile = game.map().ref(unitPosX, 0); grid.addUnit(player.buildUnit(UnitType.DefensePost, unitTile, {})); @@ -34,7 +41,14 @@ async function nearbyUnits( const game = await setup(mapName, { infiniteGold: true, instantBuild: true }); const grid = new UnitGrid(game.map()); const player = game.addPlayer( - new PlayerInfo("us", "test_player", PlayerType.Human, null, "test_id"), + new PlayerInfo( + undefined, + "us", + "test_player", + PlayerType.Human, + null, + "test_id", + ), ); const unitTile = game.map().ref(unitPosX, 0); for (const unitType of unitTypes) { @@ -108,7 +122,14 @@ describe("Unit Grid range tests", () => { }); const grid = new UnitGrid(game.map()); const player = game.addPlayer( - new PlayerInfo("us", "test_player", PlayerType.Human, null, "test_id"), + new PlayerInfo( + undefined, + "us", + "test_player", + PlayerType.Human, + null, + "test_id", + ), ); const unitTile = game.map().ref(0, 0); grid.addUnit(player.buildUnit(UnitType.City, unitTile, {})); @@ -125,7 +146,14 @@ describe("Unit Grid range tests", () => { }); const grid = new UnitGrid(game.map()); const player = game.addPlayer( - new PlayerInfo("us", "test_player", PlayerType.Human, null, "test_id"), + new PlayerInfo( + undefined, + "us", + "test_player", + PlayerType.Human, + null, + "test_id", + ), ); const unitType = UnitType.City; const unitTile = game.map().ref(0, 0); diff --git a/tests/Warship.test.ts b/tests/Warship.test.ts index 1fc8b2881..16a72e397 100644 --- a/tests/Warship.test.ts +++ b/tests/Warship.test.ts @@ -25,6 +25,7 @@ describe("Warship", () => { }, [ new PlayerInfo( + undefined, "us", "boat dude", PlayerType.Human, @@ -32,6 +33,7 @@ describe("Warship", () => { "player_1_id", ), new PlayerInfo( + undefined, "us", "boat dude", PlayerType.Human, diff --git a/tests/core/executions/NukeExecution.test.ts b/tests/core/executions/NukeExecution.test.ts index 631777a37..20bfe7542 100644 --- a/tests/core/executions/NukeExecution.test.ts +++ b/tests/core/executions/NukeExecution.test.ts @@ -22,6 +22,7 @@ describe("NukeExecution", () => { outer: 10, })); const player_info = new PlayerInfo( + undefined, "us", "player_id", PlayerType.Human, diff --git a/tests/util/Setup.ts b/tests/util/Setup.ts index 9f657bffc..448199d95 100644 --- a/tests/util/Setup.ts +++ b/tests/util/Setup.ts @@ -59,5 +59,5 @@ export async function setup( } export function playerInfo(name: string, type: PlayerType): PlayerInfo { - return new PlayerInfo("fr", name, type, null, name); + return new PlayerInfo(undefined, "fr", name, type, null, name); }