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