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
@@ -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);
}
}