mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:30:45 +00:00
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:
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ import { createRenderer, GameRenderer } from "./graphics/GameRenderer";
|
||||
|
||||
export interface LobbyConfig {
|
||||
serverConfig: ServerConfig;
|
||||
pattern: string | undefined;
|
||||
flag: string;
|
||||
playerName: string;
|
||||
clientID: ClientID;
|
||||
|
||||
@@ -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"
|
||||
? ""
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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")}"
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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", [
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -333,6 +333,7 @@ export class GameServer {
|
||||
players: this.activeClients.map((c) => ({
|
||||
username: c.username,
|
||||
clientID: c.clientID,
|
||||
pattern: c.pattern,
|
||||
flag: c.flag,
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
+32
-4
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -22,6 +22,7 @@ describe("NukeExecution", () => {
|
||||
outer: 10,
|
||||
}));
|
||||
const player_info = new PlayerInfo(
|
||||
undefined,
|
||||
"us",
|
||||
"player_id",
|
||||
PlayerType.Human,
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user