Merge branch 'main' into icslucas-patch-1

This commit is contained in:
icslucas
2025-08-02 17:00:12 +02:00
committed by GitHub
77 changed files with 1162 additions and 773 deletions
+2 -1
View File
@@ -1,6 +1,7 @@
name: 🧪 CI
on:
merge_group:
pull_request:
push:
branches: [main]
@@ -41,7 +42,7 @@ jobs:
with:
node-version: 20
- run: npm ci
- run: npm test
- run: npm run test:coverage
eslint:
name: 🔍 ESLint
+6 -1
View File
@@ -34,6 +34,11 @@ RUN npm run build-prod
# https://openfront.io/commit.txt
RUN echo "$GIT_COMMIT" > static/commit.txt
# Remove maps data from final image
FROM base AS prod-files
COPY . .
RUN rm -rf resources/maps
FROM dependencies AS npm-dependencies
# Disable Husky hooks
ENV HUSKY=0
@@ -67,7 +72,7 @@ COPY --from=npm-dependencies /usr/src/app/node_modules node_modules
COPY package.json .
# Copy the rest of the application code
COPY . .
COPY --from=prod-files /usr/src/app/ /usr/src/app/
# Copy frontend
COPY --from=build /usr/src/app/static static
+4 -4
View File
@@ -29,10 +29,10 @@ export default {
collectCoverageFrom: ["src/**/*.ts", "!src/**/*.d.ts"],
coverageThreshold: {
global: {
branches: 0,
functions: 0,
lines: 0,
statements: 0,
statements: 21.5,
branches: 17.0,
lines: 22.0,
functions: 20.5,
},
},
coverageReporters: ["text", "lcov", "html"],
+1 -63
View File
@@ -29,7 +29,6 @@
"nanoid": "^3.3.6",
"obscenity": "^0.4.3",
"ts-node": "^10.9.2",
"twemoji": "^14.0.2",
"uuid": "^11.1.0",
"winston": "^3.17.0",
"ws": "^8.18.0",
@@ -11664,29 +11663,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
},
"engines": {
"node": ">=6 <7 || >=8"
}
},
"node_modules/fs-extra/node_modules/jsonfile": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
"license": "MIT",
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -11937,6 +11913,7 @@
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/graphemer": {
@@ -14761,18 +14738,6 @@
"node": ">=6"
}
},
"node_modules/jsonfile": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-5.0.0.tgz",
"integrity": "sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==",
"license": "MIT",
"dependencies": {
"universalify": "^0.1.2"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -19383,24 +19348,6 @@
"node": "*"
}
},
"node_modules/twemoji": {
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/twemoji/-/twemoji-14.0.2.tgz",
"integrity": "sha512-BzOoXIe1QVdmsUmZ54xbEH+8AgtOKUiG53zO5vVP2iUu6h5u9lN15NcuS6te4OY96qx0H7JK9vjjl9WQbkTRuA==",
"license": "MIT",
"dependencies": {
"fs-extra": "^8.0.1",
"jsonfile": "^5.0.0",
"twemoji-parser": "14.0.0",
"universalify": "^0.1.2"
}
},
"node_modules/twemoji-parser": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/twemoji-parser/-/twemoji-parser-14.0.0.tgz",
"integrity": "sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA==",
"license": "MIT"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -19536,15 +19483,6 @@
"node": ">=4"
}
},
"node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
-1
View File
@@ -119,7 +119,6 @@
"nanoid": "^3.3.6",
"obscenity": "^0.4.3",
"ts-node": "^10.9.2",
"twemoji": "^14.0.2",
"uuid": "^11.1.0",
"winston": "^3.17.0",
"ws": "^8.18.0",
+3
View File
@@ -112,6 +112,9 @@
"name": "embelem",
"role_group": "donor"
},
"AMo0AAAAAAAAAAAAAIAAJAACEAIIQCAgAAGCAAQgCBCAgEAAAggBCIAEIAAAAAAAAAAAAAAA": {
"name": "contributor"
},
"AMlNAAAAAAAAAAAAAPAfAACAHwDwgAcAAMQf4ADgAAAgwM8BAAwACEPABwDA__8xEACIAAAACAMOgCQAAGEwwAAoAPCAAQGMCCBhAAYIQPwAnwEYgADSB_QEQAAIkD_wJwABgAH8gx8ABAAYwE99ABgAAAcAACBwAADgBDCA-AAAADwQoH8AAAAA_v8DAAAAAAAMAAAAAAAAAAAAAAA": {
"name": "grogu_head",
"role_group": "donor"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

+5 -5
View File
@@ -51,7 +51,6 @@
"ui_control_desc": "The control panel contains the following elements:",
"ui_pop": "Pop - The amount of units you have, your max population and the rate at which you gain them.",
"ui_gold": "Gold - The amount of gold you have and the rate at which you gain it.",
"ui_troops_workers": "Troops and Workers - The amount of allocated troops and workers. Troops are used to attack or defend against attacks. Workers are used to generate gold. You can adjust the number of troops and workers using the slider.",
"ui_attack_ratio": "Attack ratio - The amount of troops that will be used when you attack. You can adjust the attack ratio using the slider. Having more attacking troops than defending troops will make you lose fewer troops in the attack, while having less will increase the damage dealt to your attacking troops. The effect doesn't go beyond ratios of 2:1.",
"ui_events": "Event panel",
"ui_events_desc": "The Event panel displays the latest events, requests and Quick Chat messages. Some examples are:",
@@ -215,7 +214,8 @@
"player": "Player",
"players": "Players",
"waiting": "Waiting for players...",
"start": "Start Game"
"start": "Start Game",
"host_badge": "Host"
},
"team_colors": {
"red": "Red",
@@ -288,10 +288,11 @@
"right_click_opens_menu": "Right click opens menu",
"attack_ratio_label": "⚔️ Attack Ratio",
"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",
"performance_overlay_label": "Performance Overlay",
"performance_overlay_desc": "Toggle the performance overlay. When enabled, the performance overlay will be displayed. Press shift-D during game to toggle.",
"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",
@@ -501,10 +502,8 @@
"default": "Default"
},
"control_panel": {
"pop": "Pop",
"gold": "Gold",
"troops": "Troops",
"workers": "Workers",
"attack_ratio": "Attack Ratio"
},
"player_panel": {
@@ -577,6 +576,7 @@
"openfront": "OpenFront",
"t_rex": "T-Rex",
"embelem": "Emblem",
"contributor": "Contributor",
"grogu_head": "Grogu Head",
"grogu": "Grogu"
}
+10 -4
View File
@@ -13,6 +13,7 @@ import { ServerConfig } from "../core/configuration/Config";
import { getConfig } from "../core/configuration/ConfigLoader";
import { PlayerActions, UnitType } from "../core/game/Game";
import { TileRef } from "../core/game/GameMap";
import { GameMapLoader } from "../core/game/GameMapLoader";
import {
ErrorUpdate,
GameUpdateType,
@@ -33,6 +34,7 @@ import {
} from "./InputHandler";
import { endGame, startGame, startTime } from "./LocalPersistantStats";
import { getPersistentID } from "./Main";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
import {
SendAttackIntentEvent,
SendBoatAttackIntentEvent,
@@ -58,12 +60,11 @@ export interface LobbyConfig {
}
export function joinLobby(
eventBus: EventBus,
lobbyConfig: LobbyConfig,
onPrestart: () => void,
onJoin: () => void,
): () => void {
const eventBus = new EventBus();
console.log(
`joining lobby: gameID: ${lobbyConfig.gameID}, clientID: ${lobbyConfig.clientID}`,
);
@@ -82,7 +83,7 @@ export function joinLobby(
const onmessage = (message: ServerMessage) => {
if (message.type === "prestart") {
console.log(`lobby: game prestarting: ${JSON.stringify(message)}`);
terrainLoad = loadTerrainMap(message.gameMap);
terrainLoad = loadTerrainMap(message.gameMap, terrainMapFileLoader);
onPrestart();
}
if (message.type === "start") {
@@ -98,6 +99,7 @@ export function joinLobby(
transport,
userSettings,
terrainLoad,
terrainMapFileLoader,
).then((r) => r.start());
}
if (message.type === "error") {
@@ -125,6 +127,7 @@ async function createClientGame(
transport: Transport,
userSettings: UserSettings,
terrainLoad: Promise<TerrainMapData> | null,
mapLoader: GameMapLoader,
): Promise<ClientGameRunner> {
if (lobbyConfig.gameStartInfo === undefined) {
throw new Error("missing gameStartInfo");
@@ -139,7 +142,10 @@ async function createClientGame(
if (terrainLoad) {
gameMap = await terrainLoad;
} else {
gameMap = await loadTerrainMap(lobbyConfig.gameStartInfo.config.gameMap);
gameMap = await loadTerrainMap(
lobbyConfig.gameStartInfo.config.gameMap,
mapLoader,
);
}
const worker = new WorkerClient(
lobbyConfig.gameStartInfo,
+13
View File
@@ -68,8 +68,21 @@ export class FlagInput extends LitElement {
super.connectedCallback();
this.flag = this.getStoredFlag();
this.dispatchFlagEvent();
window.addEventListener("keydown", this.handleKeyDown);
}
disconnectedCallback() {
window.removeEventListener("keydown", this.handleKeyDown);
super.disconnectedCallback();
}
private handleKeyDown = (e: KeyboardEvent) => {
if (e.code === "Escape") {
e.preventDefault();
this.showModal = false;
}
};
createRenderRoot() {
return this;
}
+17 -4
View File
@@ -15,6 +15,23 @@ export class HelpModal extends LitElement {
return this;
}
connectedCallback() {
super.connectedCallback();
window.addEventListener("keydown", this.handleKeyDown);
}
disconnectedCallback() {
window.removeEventListener("keydown", this.handleKeyDown);
super.disconnectedCallback();
}
private handleKeyDown = (e: KeyboardEvent) => {
if (e.code === "Escape") {
e.preventDefault();
this.close();
}
};
render() {
return html`
<o-modal
@@ -166,11 +183,7 @@ export class HelpModal extends LitElement {
<div>
<p class="mb-4">${translateText("help_modal.ui_control_desc")}</p>
<ul>
<li class="mb-4">${translateText("help_modal.ui_pop")}</li>
<li class="mb-4">${translateText("help_modal.ui_gold")}</li>
<li class="mb-4">
${translateText("help_modal.ui_troops_workers")}
</li>
<li class="mb-4">
${translateText("help_modal.ui_attack_ratio")}
</li>
+70 -16
View File
@@ -14,7 +14,12 @@ import {
mapCategories,
} from "../core/game/Game";
import { UserSettings } from "../core/game/UserSettings";
import { GameConfig, GameInfo, TeamCountConfig } from "../core/Schemas";
import {
ClientInfo,
GameConfig,
GameInfo,
TeamCountConfig,
} from "../core/Schemas";
import { generateID } from "../core/Util";
import "./components/baseComponents/Modal";
import "./components/Difficulties";
@@ -40,9 +45,10 @@ export class HostLobbyModal extends LitElement {
@state() private instantBuild: boolean = false;
@state() private lobbyId = "";
@state() private copySuccess = false;
@state() private players: string[] = [];
@state() private clients: ClientInfo[] = [];
@state() private useRandomMap: boolean = false;
@state() private disabledUnits: UnitType[] = [UnitType.Factory];
@state() private lobbyCreatorClientID: string = "";
@state() private lobbyIdVisible: boolean = true;
private playersInterval: NodeJS.Timeout | null = null;
@@ -50,6 +56,23 @@ export class HostLobbyModal extends LitElement {
private botsUpdateTimer: number | null = null;
private userSettings: UserSettings = new UserSettings();
connectedCallback() {
super.connectedCallback();
window.addEventListener("keydown", this.handleKeyDown);
}
disconnectedCallback() {
window.removeEventListener("keydown", this.handleKeyDown);
super.disconnectedCallback();
}
private handleKeyDown = (e: KeyboardEvent) => {
if (e.code === "Escape") {
e.preventDefault();
this.close();
}
};
render() {
return html`
<o-modal title=${translateText("host_modal.title")}>
@@ -111,7 +134,7 @@ export class HostLobbyModal extends LitElement {
<span class="lobby-id" @click=${this.copyToClipboard} style="cursor: pointer;">
${this.lobbyIdVisible ? this.lobbyId : "••••••••"}
</span>
<!-- Copy icon/success indicator -->
<div @click=${this.copyToClipboard} style="margin-left: 8px; cursor: pointer;">
${
@@ -395,29 +418,45 @@ export class HostLobbyModal extends LitElement {
<!-- Lobby Selection -->
<div class="options-section">
<div class="option-title">
${this.players.length}
${this.clients.length}
${
this.players.length === 1
this.clients.length === 1
? translateText("host_modal.player")
: translateText("host_modal.players")
}
</div>
<div class="players-list">
${this.players.map(
(player) => html`<span class="player-tag">${player}</span>`,
${this.clients.map(
(client) => html`
<span class="player-tag">
${client.username}
${client.clientID === this.lobbyCreatorClientID
? html`<span class="host-badge"
>(${translateText("host_modal.host_badge")})</span
>`
: html`
<button
class="remove-player-btn"
@click=${() => this.kickPlayer(client.clientID)}
title="Remove ${client.username}"
>
×
</button>
`}
</span>
`,
)}
</div>
</div>
<div class="start-game-button-container">
<button
@click=${this.startGame}
?disabled=${this.players.length < 2}
?disabled=${this.clients.length < 2}
class="start-game-button"
>
${
this.players.length === 1
this.clients.length === 1
? translateText("host_modal.waiting")
: translateText("host_modal.start")
}
@@ -434,12 +473,13 @@ export class HostLobbyModal extends LitElement {
}
public open() {
this.lobbyCreatorClientID = generateID();
this.lobbyIdVisible = this.userSettings.get(
"settings.lobbyIdVisibility",
true,
);
createLobby()
createLobby(this.lobbyCreatorClientID)
.then((lobby) => {
this.lobbyId = lobby.gameID;
// join lobby
@@ -449,7 +489,7 @@ export class HostLobbyModal extends LitElement {
new CustomEvent("join-lobby", {
detail: {
gameID: this.lobbyId,
clientID: generateID(),
clientID: this.lobbyCreatorClientID,
} as JoinLobbyEvent,
bubbles: true,
composed: true,
@@ -633,17 +673,29 @@ export class HostLobbyModal extends LitElement {
.then((response) => response.json())
.then((data: GameInfo) => {
console.log(`got game info response: ${JSON.stringify(data)}`);
this.players = data.clients?.map((p) => p.username) ?? [];
this.clients = data.clients ?? [];
});
}
private kickPlayer(clientID: string) {
// Dispatch event to be handled by WebSocket instead of HTTP
this.dispatchEvent(
new CustomEvent("kick-player", {
detail: { target: clientID },
bubbles: true,
composed: true,
}),
);
}
}
async function createLobby(): Promise<GameInfo> {
async function createLobby(creatorClientID: string): Promise<GameInfo> {
const config = await getServerConfigFromClient();
try {
const id = generateID();
const response = await fetch(
`/${config.workerPath(id)}/api/create_game/${id}`,
`/${config.workerPath(id)}/api/create_game/${id}?creatorClientID=${encodeURIComponent(creatorClientID)}`,
{
method: "POST",
headers: {
@@ -654,6 +706,8 @@ async function createLobby(): Promise<GameInfo> {
);
if (!response.ok) {
const errorText = await response.text();
console.error("Server error response:", errorText);
throw new Error(`HTTP error! status: ${response.status}`);
}
@@ -663,6 +717,6 @@ async function createLobby(): Promise<GameInfo> {
return data as GameInfo;
} catch (error) {
console.error("Error creating lobby:", error);
throw error; // Re-throw the error so the caller can handle it
throw error;
}
}
+22 -1
View File
@@ -72,6 +72,8 @@ export class CloseViewEvent implements GameEvent {}
export class RefreshGraphicsEvent implements GameEvent {}
export class TogglePerformanceOverlayEvent implements GameEvent {}
export class ToggleStructureEvent implements GameEvent {
constructor(public readonly structureType: UnitType | null) {}
}
@@ -183,6 +185,14 @@ export class InputHandler {
let deltaX = 0;
let deltaY = 0;
// Skip if shift is held down
if (
this.activeKeys.has("ShiftLeft") ||
this.activeKeys.has("ShiftRight")
) {
return;
}
if (
this.activeKeys.has(this.keybinds.moveUp) ||
this.activeKeys.has("ArrowUp")
@@ -258,6 +268,8 @@ export class InputHandler {
this.keybinds.centerCamera,
"ControlLeft",
"ControlRight",
"ShiftLeft",
"ShiftRight",
].includes(e.code)
) {
this.activeKeys.add(e.code);
@@ -300,6 +312,14 @@ export class InputHandler {
this.eventBus.emit(new CenterCameraEvent());
}
// Shift-D to toggle performance overlay
console.log(e.code, e.shiftKey, e.ctrlKey, e.altKey, e.metaKey);
if (e.code === "KeyD" && e.shiftKey) {
e.preventDefault();
console.log("TogglePerformanceOverlayEvent");
this.eventBus.emit(new TogglePerformanceOverlayEvent());
}
this.activeKeys.delete(e.code);
});
}
@@ -371,7 +391,8 @@ export class InputHandler {
private onShiftScroll(event: WheelEvent) {
if (event.shiftKey) {
const ratio = event.deltaY > 0 ? -10 : 10;
const scrollValue = event.deltaY === 0 ? event.deltaX : event.deltaY;
const ratio = scrollValue > 0 ? -10 : 10;
this.eventBus.emit(new AttackRatioEvent(ratio));
}
}
+17
View File
@@ -20,6 +20,23 @@ export class JoinPrivateLobbyModal extends LitElement {
private playersInterval: NodeJS.Timeout | null = null;
connectedCallback() {
super.connectedCallback();
window.addEventListener("keydown", this.handleKeyDown);
}
disconnectedCallback() {
window.removeEventListener("keydown", this.handleKeyDown);
super.disconnectedCallback();
}
private handleKeyDown = (e: KeyboardEvent) => {
if (e.code === "Escape") {
e.preventDefault();
this.close();
}
};
render() {
return html`
<o-modal title=${translateText("private_lobby.title")}>
+1 -1
View File
@@ -292,7 +292,7 @@ export class LangSelector extends LitElement {
<button
id="lang-selector"
@click=${this.openModal}
class="text-center appearance-none w-full bg-blue-100 hover:bg-blue-200 text-blue-900 p-3 sm:p-4 lg:p-5 font-medium text-sm sm:text-base lg:text-lg rounded-md border-none cursor-pointer transition-colors duration-300 flex items-center gap-2 justify-center"
class="text-center appearance-none w-full bg-blue-100 dark:bg-gray-700 hover:bg-blue-200 dark:hover:bg-gray-600 text-blue-900 dark:text-gray-100 p-3 sm:p-4 lg:p-5 font-medium text-sm sm:text-base lg:text-lg rounded-md border-none cursor-pointer transition-colors duration-300 flex items-center gap-2 justify-center"
>
<img
id="lang-flag"
+53 -122
View File
@@ -1,4 +1,4 @@
import { LitElement, css, html } from "lit";
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { translateText } from "../client/Utils";
@@ -8,117 +8,9 @@ export class LanguageModal extends LitElement {
@property({ type: Array }) languageList: any[] = [];
@property({ type: String }) currentLang = "en";
static styles = css`
.c-modal {
position: fixed;
padding: 1rem;
z-index: 1000;
left: 0;
bottom: 0;
right: 0;
top: 0;
background-color: rgba(0, 0, 0, 0.5);
overflow-y: auto;
display: flex;
align-items: center;
justify-content: center;
}
.c-modal__wrapper {
background: #23232382;
border-radius: 8px;
min-width: 340px;
max-width: 480px;
width: 100%;
}
.c-modal__header {
position: relative;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
font-size: 18px;
background: #000000a1;
text-align: center;
color: #fff;
padding: 1rem 2.4rem 1rem 1.4rem;
}
.c-modal__close {
cursor: pointer;
position: absolute;
right: 1rem;
top: 1rem;
font-weight: bold;
}
.c-modal__content {
position: relative;
color: #fff;
padding: 1.4rem;
max-height: 60dvh;
overflow-y: auto;
backdrop-filter: blur(8px);
}
.lang-button {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
margin-bottom: 0.5rem;
border-radius: 0.375rem;
transition: background-color 0.3s;
border: 1px solid #aaa;
background-color: #505050;
color: #fff;
}
.lang-button:hover {
background-color: #969696;
}
.lang-button.active {
background-color: #aaaaaa;
border-color: #bbb;
color: #000;
}
.flag-icon {
width: 24px;
height: 16px;
object-fit: contain;
}
@keyframes rainbow {
0% {
background-color: #990033;
}
20% {
background-color: #996600;
}
40% {
background-color: #336600;
}
60% {
background-color: #008080;
}
80% {
background-color: #1c3f99;
}
100% {
background-color: #5e0099;
}
}
.lang-button.debug {
animation: rainbow 10s infinite;
font-weight: bold;
color: #fff;
border: 2px dashed aqua;
box-shadow: 0 0 4px aqua;
}
`;
createRenderRoot() {
return this; // Use Light DOM for TailwindCSS classes
}
private close = () => {
this.dispatchEvent(
@@ -139,11 +31,24 @@ export class LanguageModal extends LitElement {
}
}
connectedCallback() {
super.connectedCallback();
window.addEventListener("keydown", this.handleKeyDown);
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("keydown", this.handleKeyDown);
document.body.style.overflow = "auto";
}
private handleKeyDown = (e: KeyboardEvent) => {
if (e.code === "Escape") {
e.preventDefault();
this.close();
}
};
private selectLanguage = (lang: string) => {
this.dispatchEvent(
new CustomEvent("language-selected", {
@@ -158,27 +63,53 @@ export class LanguageModal extends LitElement {
if (!this.visible) return null;
return html`
<aside class="c-modal">
<div class="c-modal__wrapper">
<header class="c-modal__header">
<aside
class="fixed p-4 z-[1000] inset-0 bg-black/50 overflow-y-auto flex items-center justify-center"
>
<div
class="bg-gray-800/80 dark:bg-gray-900/90 backdrop-blur-md rounded-lg min-w-[340px] max-w-[480px] w-full"
>
<header
class="relative rounded-t-md text-lg bg-black/60 dark:bg-black/80 text-center text-white px-6 py-4 pr-10"
>
${translateText("select_lang.title")}
<div class="c-modal__close" @click=${this.close}>✕</div>
<div
class="cursor-pointer absolute right-4 top-4 font-bold hover:text-gray-300"
@click=${this.close}
>
</div>
</header>
<section class="c-modal__content">
<section
class="relative text-white dark:text-gray-100 p-6 max-h-[60dvh] overflow-y-auto"
>
${this.languageList.map((lang) => {
const isActive = this.currentLang === lang.code;
const isDebug = lang.code === "debug";
let buttonClasses =
"w-full flex items-center gap-2 p-2 mb-2 rounded-md transition-colors duration-300 border";
if (isDebug) {
buttonClasses +=
" animate-pulse font-bold text-white border-2 border-dashed border-cyan-400 shadow-lg shadow-cyan-400/25 bg-gradient-to-r from-red-600 via-yellow-600 via-green-600 via-blue-600 to-purple-600";
} else if (isActive) {
buttonClasses +=
" bg-gray-400 dark:bg-gray-500 border-gray-300 dark:border-gray-400 text-black dark:text-white";
} else {
buttonClasses +=
" bg-gray-600 dark:bg-gray-700 border-gray-500 dark:border-gray-600 text-white dark:text-gray-100 hover:bg-gray-500 dark:hover:bg-gray-600";
}
return html`
<button
class="lang-button ${isActive ? "active" : ""} ${lang.code ===
"debug"
? "debug"
: ""}"
class="${buttonClasses}"
@click=${() => this.selectLanguage(lang.code)}
>
<img
src="/flags/${lang.svg}.svg"
class="flag-icon"
class="w-6 h-4 object-contain"
alt="${lang.code}"
/>
<span>${lang.native} (${lang.en})</span>
-5
View File
@@ -97,11 +97,6 @@ export class LocalServer {
return;
}
if (this.paused) {
if (clientMsg.intent.type === "troop_ratio") {
// Store troop change events because otherwise they are
// not registered when game is paused.
this.intents.push(clientMsg.intent);
}
return;
}
this.intents.push(clientMsg.intent);
+15
View File
@@ -1,6 +1,7 @@
import favicon from "../../resources/images/Favicon.svg";
import version from "../../resources/version.txt";
import { UserMeResponse } from "../core/ApiSchemas";
import { EventBus } from "../core/EventBus";
import { GameRecord, GameStartInfo, ID } from "../core/Schemas";
import { ServerConfig } from "../core/configuration/Config";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
@@ -24,6 +25,7 @@ import "./PublicLobby";
import { PublicLobby } from "./PublicLobby";
import { SinglePlayerModal } from "./SinglePlayerModal";
import { TerritoryPatternsModal } from "./TerritoryPatternsModal";
import { SendKickPlayerIntentEvent } from "./Transport";
import { UserSettingModal } from "./UserSettingModal";
import "./UsernameInput";
import { UsernameInput } from "./UsernameInput";
@@ -77,6 +79,7 @@ export interface JoinLobbyEvent {
class Client {
private gameStop: (() => void) | null = null;
private eventBus: EventBus = new EventBus();
private usernameInput: UsernameInput | null = null;
private flagInput: FlagInput | null = null;
@@ -163,11 +166,13 @@ class Client {
setFavicon();
document.addEventListener("join-lobby", this.handleJoinLobby.bind(this));
document.addEventListener("leave-lobby", this.handleLeaveLobby.bind(this));
document.addEventListener("kick-player", this.handleKickPlayer.bind(this));
const spModal = document.querySelector(
"single-player-modal",
) as SinglePlayerModal;
spModal instanceof SinglePlayerModal;
const singlePlayer = document.getElementById("single-player");
if (singlePlayer === null) throw new Error("Missing single-player");
singlePlayer.addEventListener("click", () => {
@@ -429,6 +434,7 @@ class Client {
const config = await getServerConfigFromClient();
this.gameStop = joinLobby(
this.eventBus,
{
gameID: lobby.gameID,
serverConfig: config,
@@ -514,6 +520,15 @@ class Client {
this.gameStop = null;
this.publicLobby.leaveLobby();
}
private handleKickPlayer(event: CustomEvent) {
const { target } = event.detail;
// Forward to eventBus if available
if (this.eventBus) {
this.eventBus.emit(new SendKickPlayerIntentEvent(target));
}
}
}
// Initialize the client when the DOM is loaded
+17
View File
@@ -13,6 +13,23 @@ export class NewsModal extends LitElement {
close: () => void;
};
connectedCallback() {
super.connectedCallback();
window.addEventListener("keydown", this.handleKeyDown);
}
disconnectedCallback() {
window.removeEventListener("keydown", this.handleKeyDown);
super.disconnectedCallback();
}
private handleKeyDown = (e: KeyboardEvent) => {
if (e.code === "Escape") {
e.preventDefault();
this.close();
}
};
@property({ type: String }) markdown = "Loading...";
private initialized: boolean = false;
+1 -1
View File
@@ -2,10 +2,10 @@ import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { translateText } from "../client/Utils";
import { GameMapType, GameMode } from "../core/game/Game";
import { terrainMapFileLoader } from "../core/game/TerrainMapFileLoader";
import { GameID, GameInfo } from "../core/Schemas";
import { generateID } from "../core/Util";
import { JoinLobbyEvent } from "./Main";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
@customElement("public-lobby")
export class PublicLobby extends LitElement {
+17
View File
@@ -47,6 +47,23 @@ export class SinglePlayerModal extends LitElement {
private userSettings: UserSettings = new UserSettings();
connectedCallback() {
super.connectedCallback();
window.addEventListener("keydown", this.handleKeyDown);
}
disconnectedCallback() {
window.removeEventListener("keydown", this.handleKeyDown);
super.disconnectedCallback();
}
private handleKeyDown = (e: KeyboardEvent) => {
if (e.code === "Escape") {
e.preventDefault();
this.close();
}
};
render() {
return html`
<o-modal title=${translateText("single_modal.title")}>
+4
View File
@@ -0,0 +1,4 @@
import version from "../../resources/version.txt";
import { FetchGameMapLoader } from "../core/game/FetchGameMapLoader";
export const terrainMapFileLoader = new FetchGameMapLoader(`/maps`, version);
+27 -8
View File
@@ -37,14 +37,16 @@ export class TerritoryPatternsModal extends LitElement {
private userSettings: UserSettings = new UserSettings();
private isActive = false;
constructor() {
super();
}
connectedCallback() {
super.connectedCallback();
this.selectedPattern = this.userSettings.getSelectedPattern();
window.addEventListener("keydown", this.handleKeyDown);
this.selectedPattern = this.userSettings.getSelectedPattern();
this.updateComplete.then(() => {
const containers = this.renderRoot.querySelectorAll(".preview-container");
if (this.resizeObserver) {
@@ -54,12 +56,12 @@ export class TerritoryPatternsModal extends LitElement {
}
this.updatePreview();
});
this.open();
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("keydown", this.handleKeyDown);
this.resizeObserver.disconnect();
super.disconnectedCallback();
}
async onUserMe(userMeResponse: UserMeResponse | null) {
@@ -69,6 +71,11 @@ export class TerritoryPatternsModal extends LitElement {
}
private handleKeyDown = (e: KeyboardEvent) => {
if (e.code === "Escape") {
e.preventDefault();
this.close();
}
const key = e.key.toLowerCase();
const nextSequence = [...this.keySequence, key].slice(-5);
this.keySequence = nextSequence;
@@ -220,6 +227,7 @@ export class TerritoryPatternsModal extends LitElement {
}
render() {
if (!this.isActive) return html``;
return html`
${this.renderTooltip()}
<o-modal
@@ -233,10 +241,15 @@ export class TerritoryPatternsModal extends LitElement {
public open() {
this.modalEl?.open();
window.addEventListener("keydown", this.handleKeyDown);
this.isActive = true;
}
public close() {
this.modalEl?.close();
window.removeEventListener("keydown", this.handleKeyDown);
this.resizeObserver?.disconnect();
this.isActive = false;
}
private selectPattern(pattern: string | undefined) {
@@ -336,6 +349,7 @@ export class TerritoryPatternsModal extends LitElement {
}
}
const patternCache = new Map<string, string>();
const DEFAULT_PATTERN_B64 = "AAAAAA"; // Empty 2x2 pattern
const COLOR_SET = [0, 0, 0, 255]; // Black
const COLOR_UNSET = [255, 255, 255, 255]; // White
@@ -344,11 +358,14 @@ export function generatePreviewDataUrl(
width?: number,
height?: number,
): string {
pattern ??= DEFAULT_PATTERN_B64;
if (patternCache.has(pattern)) {
return patternCache.get(pattern)!;
}
// Calculate canvas size
const decoder = new PatternDecoder(
pattern ?? DEFAULT_PATTERN_B64,
base64url.decode,
);
const decoder = new PatternDecoder(pattern, base64url.decode);
const scaledWidth = decoder.scaledWidth();
const scaledHeight = decoder.scaledHeight();
@@ -384,5 +401,7 @@ export function generatePreviewDataUrl(
// Create a data URL
ctx.putImageData(imageData, 0, 0);
return canvas.toDataURL("image/png");
const dataUrl = canvas.toDataURL("image/png");
patternCache.set(pattern, dataUrl);
return dataUrl;
}
+15 -15
View File
@@ -141,10 +141,6 @@ export class CancelBoatIntentEvent implements GameEvent {
constructor(public readonly unitID: number) {}
}
export class SendSetTargetTroopRatioEvent implements GameEvent {
constructor(public readonly ratio: number) {}
}
export class SendWinnerEvent implements GameEvent {
constructor(
public readonly winner: Winner,
@@ -165,6 +161,10 @@ export class MoveWarshipIntentEvent implements GameEvent {
) {}
}
export class SendKickPlayerIntentEvent implements GameEvent {
constructor(public readonly target: string) {}
}
export class Transport {
private socket: WebSocket | null = null;
@@ -223,9 +223,6 @@ export class Transport {
this.eventBus.on(SendEmbargoIntentEvent, (e) =>
this.onSendEmbargoIntent(e),
);
this.eventBus.on(SendSetTargetTroopRatioEvent, (e) =>
this.onSendSetTargetTroopRatioEvent(e),
);
this.eventBus.on(BuildUnitIntentEvent, (e) => this.onBuildUnitIntent(e));
this.eventBus.on(PauseGameEvent, (e) => this.onPauseGameEvent(e));
@@ -241,6 +238,9 @@ export class Transport {
this.eventBus.on(MoveWarshipIntentEvent, (e) => {
this.onMoveWarshipEvent(e);
});
this.eventBus.on(SendKickPlayerIntentEvent, (e) =>
this.onSendKickPlayerIntent(e),
);
}
private startPing() {
@@ -525,14 +525,6 @@ export class Transport {
});
}
private onSendSetTargetTroopRatioEvent(event: SendSetTargetTroopRatioEvent) {
this.sendIntent({
type: "troop_ratio",
clientID: this.lobbyConfig.clientID,
ratio: event.ratio,
});
}
private onBuildUnitIntent(event: BuildUnitIntentEvent) {
this.sendIntent({
type: "build_unit",
@@ -611,6 +603,14 @@ export class Transport {
});
}
private onSendKickPlayerIntent(event: SendKickPlayerIntentEvent) {
this.sendIntent({
type: "kick_player",
clientID: this.lobbyConfig.clientID,
target: event.target,
});
}
private sendIntent(intent: Intent) {
if (this.isLocal || this.socket?.readyState === WebSocket.OPEN) {
const msg = {
+21 -11
View File
@@ -51,6 +51,11 @@ export class UserSettingModal extends LitElement {
private handleKeyDown = (e: KeyboardEvent) => {
if (!this.modalEl?.isModalOpen || this.showEasterEggSettings) return;
if (e.code === "Escape") {
e.preventDefault();
this.close();
}
const key = e.key.toLowerCase();
const nextSequence = [...this.keySequence, key].slice(-4);
this.keySequence = nextSequence;
@@ -176,6 +181,13 @@ export class UserSettingModal extends LitElement {
console.log("🏳️ Territory Patterns:", enabled ? "ON" : "OFF");
}
private togglePerformanceOverlay(e: CustomEvent<{ checked: boolean }>) {
const enabled = e.detail?.checked;
if (typeof enabled !== "boolean") return;
this.userSettings.set("settings.performanceOverlay", enabled);
}
private handleKeybindChange(
e: CustomEvent<{ action: string; value: string }>,
) {
@@ -315,6 +327,15 @@ export class UserSettingModal extends LitElement {
@change=${this.toggleTerritoryPatterns}
></setting-toggle>
<!-- 📱 Performance Overlay -->
<setting-toggle
label="${translateText("user_setting.performance_overlay_label")}"
description="${translateText("user_setting.performance_overlay_desc")}"
id="performance-overlay-toggle"
.checked=${this.userSettings.performanceOverlay()}
@change=${this.togglePerformanceOverlay}
></setting-toggle>
<!-- ⚔️ Attack Ratio -->
<setting-slider
label="${translateText("user_setting.attack_ratio_label")}"
@@ -326,17 +347,6 @@ export class UserSettingModal extends LitElement {
@change=${this.sliderAttackRatio}
></setting-slider>
<!-- 🪖🛠️ Troop Ratio -->
<setting-slider
label="${translateText("user_setting.troop_ratio_label")}"
description="${translateText("user_setting.troop_ratio_desc")}"
min="1"
max="100"
.value=${Number(localStorage.getItem("settings.troopRatio") ?? "0.95") *
100}
@change=${this.sliderTroopRatio}
></setting-slider>
${this.showEasterEggSettings
? html`
<setting-slider
+1 -1
View File
@@ -1,7 +1,7 @@
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { GameMapType } from "../../core/game/Game";
import { terrainMapFileLoader } from "../../core/game/TerrainMapFileLoader";
import { terrainMapFileLoader } from "../TerrainMapFileLoader";
import { translateText } from "../Utils";
// Add map descriptions
+1 -1
View File
@@ -48,7 +48,7 @@ export class NewsButton extends LitElement {
@click=${this.handleClick}
>
<img
class="size-[48px]"
class="size-[48px] dark:invert"
src="${megaphone}"
alt=${translateText("news.title")}
/>
+34 -14
View File
@@ -12,6 +12,7 @@ import { ChatModal } from "./layers/ChatModal";
import { ControlPanel } from "./layers/ControlPanel";
import { EmojiTable } from "./layers/EmojiTable";
import { EventsDisplay } from "./layers/EventsDisplay";
import { FPSDisplay } from "./layers/FPSDisplay";
import { FxLayer } from "./layers/FxLayer";
import { GameLeftSidebar } from "./layers/GameLeftSidebar";
import { GameRightSidebar } from "./layers/GameRightSidebar";
@@ -60,10 +61,9 @@ export function createRenderer(
if (!emojiTable || !(emojiTable instanceof EmojiTable)) {
console.error("EmojiTable element not found in the DOM");
}
emojiTable.eventBus = eventBus;
emojiTable.transformHandler = transformHandler;
emojiTable.game = game;
emojiTable.initEventBus();
emojiTable.initEventBus(eventBus);
const buildMenu = document.querySelector("build-menu") as BuildMenu;
if (!buildMenu || !(buildMenu instanceof BuildMenu)) {
@@ -173,7 +173,7 @@ export function createRenderer(
console.error("player panel not found");
}
playerPanel.g = game;
playerPanel.eventBus = eventBus;
playerPanel.initEventBus(eventBus);
playerPanel.emojiTable = emojiTable;
playerPanel.uiState = uiState;
@@ -182,7 +182,7 @@ export function createRenderer(
console.error("chat modal not found");
}
chatModal.g = game;
chatModal.eventBus = eventBus;
chatModal.initEventBus(eventBus);
const multiTabModal = document.querySelector(
"multi-tab-modal",
@@ -202,6 +202,13 @@ export function createRenderer(
const structureLayer = new StructureLayer(game, eventBus, transformHandler);
const fpsDisplay = document.querySelector("fps-display") as FPSDisplay;
if (!(fpsDisplay instanceof FPSDisplay)) {
console.error("fps display not found");
}
fpsDisplay.eventBus = eventBus;
fpsDisplay.userSettings = userSettings;
const spawnAd = document.querySelector("spawn-ad") as SpawnAd;
if (!(spawnAd instanceof SpawnAd)) {
console.error("spawn ad not found");
@@ -261,6 +268,7 @@ export function createRenderer(
spawnAd,
gutterAdModal,
alertFrame,
fpsDisplay,
];
return new GameRenderer(
@@ -270,6 +278,7 @@ export function createRenderer(
transformHandler,
uiState,
layers,
fpsDisplay,
);
}
@@ -283,6 +292,7 @@ export class GameRenderer {
public transformHandler: TransformHandler,
public uiState: UIState,
private layers: Layer[],
private fpsDisplay: FPSDisplay,
) {
const context = canvas.getContext("2d");
if (context === null) throw new Error("2d context not supported");
@@ -290,14 +300,7 @@ export class GameRenderer {
}
initialize() {
this.eventBus.on(RedrawGraphicsEvent, (e) => {
this.layers.forEach((l) => {
if (l.redraw) {
l.redraw();
}
});
});
this.eventBus.on(RedrawGraphicsEvent, () => this.redraw());
this.layers.forEach((l) => l.init?.());
document.body.appendChild(this.canvas);
@@ -307,7 +310,14 @@ export class GameRenderer {
//show whole map on startup
this.transformHandler.centerAll(0.9);
requestAnimationFrame(() => this.renderGame());
let rafId = requestAnimationFrame(() => this.renderGame());
this.canvas.addEventListener("contextlost", () => {
cancelAnimationFrame(rafId);
});
this.canvas.addEventListener("contextrestored", () => {
this.redraw();
rafId = requestAnimationFrame(() => this.renderGame());
});
}
resizeCanvas() {
@@ -317,6 +327,14 @@ export class GameRenderer {
//this.redraw()
}
redraw() {
this.layers.forEach((l) => {
if (l.redraw) {
l.redraw();
}
});
}
renderGame() {
const start = performance.now();
// Set background
@@ -356,8 +374,10 @@ export class GameRenderer {
this.transformHandler.resetChanged();
requestAnimationFrame(() => this.renderGame());
const duration = performance.now() - start;
this.fpsDisplay.updateFPS(duration);
if (duration > 50) {
console.warn(
`tick ${this.game.ticks()} took ${duration}ms to render frame`,
+10
View File
@@ -6,6 +6,7 @@ import { GameView, PlayerView } from "../../../core/game/GameView";
import quickChatData from "../../../../resources/QuickChat.json";
import { EventBus } from "../../../core/EventBus";
import { CloseViewEvent } from "../../InputHandler";
import { SendQuickChatEvent } from "../../Transport";
import { translateText } from "../../Utils";
@@ -172,6 +173,15 @@ export class ChatModal extends LitElement {
`;
}
initEventBus(eventBus: EventBus) {
this.eventBus = eventBus;
eventBus.on(CloseViewEvent, (e) => {
if (!this.hidden) {
this.close();
}
});
}
private selectCategory(categoryId: string) {
this.selectedCategory = categoryId;
this.selectedPhraseText = null;
+18 -105
View File
@@ -6,7 +6,6 @@ import { Gold } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { ClientID } from "../../../core/Schemas";
import { AttackRatioEvent } from "../../InputHandler";
import { SendSetTargetTroopRatioEvent } from "../../Transport";
import { renderNumber, renderTroops } from "../../Utils";
import { UIState } from "../UIState";
import { Layer } from "./Layer";
@@ -22,54 +21,29 @@ export class ControlPanel extends LitElement implements Layer {
private attackRatio: number = 0.2;
@state()
private targetTroopRatio = 0.95;
private _maxTroops: number;
@state()
private currentTroopRatio = 0.95;
@state()
private _population: number;
@state()
private _maxPopulation: number;
@state()
private popRate: number;
private troopRate: number;
@state()
private _troops: number;
@state()
private _workers: number;
@state()
private _isVisible = false;
@state()
private _manpower: number = 0;
@state()
private _gold: Gold;
@state()
private _goldPerSecond: Gold;
private _troopRateIsIncreasing: boolean = true;
private _popRateIsIncreasing: boolean = true;
private _lastPopulationIncreaseRate: number;
private init_: boolean = false;
private _lastTroopIncreaseRate: number;
init() {
this.attackRatio = Number(
localStorage.getItem("settings.attackRatio") ?? "0.2",
);
this.targetTroopRatio = Number(
localStorage.getItem("settings.troopRatio") ?? "0.95",
);
this.init_ = true;
this.uiState.attackRatio = this.attackRatio;
this.currentTroopRatio = this.targetTroopRatio;
this.eventBus.on(AttackRatioEvent, (event) => {
let newAttackRatio =
(parseInt(
@@ -97,13 +71,6 @@ export class ControlPanel extends LitElement implements Layer {
}
tick() {
if (this.init_) {
this.eventBus.emit(
new SendSetTargetTroopRatioEvent(this.targetTroopRatio),
);
this.init_ = false;
}
if (!this._isVisible && !this.game.inSpawnPhase()) {
this.setVisibile(true);
}
@@ -115,28 +82,24 @@ export class ControlPanel extends LitElement implements Layer {
}
if (this.game.ticks() % 5 === 0) {
this.updatePopulationIncrease();
this.updateTroopIncrease();
}
this._population = player.population();
this._maxPopulation = this.game.config().maxPopulation(player);
this._troops = player.troops();
this._maxTroops = this.game.config().maxTroops(player);
this._gold = player.gold();
this._troops = player.troops();
this._workers = player.workers();
this.popRate = this.game.config().populationIncreaseRate(player) * 10;
this._goldPerSecond = this.game.config().goldAdditionRate(player) * 10n;
this.currentTroopRatio = player.troops() / player.population();
this.troopRate = this.game.config().troopIncreaseRate(player) * 10;
this.requestUpdate();
}
private updatePopulationIncrease() {
private updateTroopIncrease() {
const player = this.game?.myPlayer();
if (player === null) return;
const popIncreaseRate = this.game.config().populationIncreaseRate(player);
this._popRateIsIncreasing =
popIncreaseRate >= this._lastPopulationIncreaseRate;
this._lastPopulationIncreaseRate = popIncreaseRate;
const troopIncreaseRate = this.game.config().troopIncreaseRate(player);
this._troopRateIsIncreasing =
troopIncreaseRate >= this._lastTroopIncreaseRate;
this._lastTroopIncreaseRate = troopIncreaseRate;
}
onAttackRatioChange(newRatio: number) {
@@ -156,19 +119,6 @@ export class ControlPanel extends LitElement implements Layer {
this.requestUpdate();
}
targetTroops(): number {
return this._manpower * this.targetTroopRatio;
}
onTroopChange(newRatio: number) {
this.eventBus.emit(new SendSetTargetTroopRatioEvent(newRatio));
}
delta(): number {
const d = this._population - this.targetTroops();
return d;
}
render() {
return html`
<style>
@@ -219,17 +169,16 @@ export class ControlPanel extends LitElement implements Layer {
<div class="block bg-black/30 text-white mb-4 p-2 rounded">
<div class="flex justify-between mb-1">
<span class="font-bold"
>${translateText("control_panel.pop")}:</span
>${translateText("control_panel.troops")}:</span
>
<span translate="no"
>${renderTroops(this._population)} /
${renderTroops(this._maxPopulation)}
>${renderTroops(this._troops)} / ${renderTroops(this._maxTroops)}
<span
class="${this._popRateIsIncreasing
class="${this._troopRateIsIncreasing
? "text-green-500"
: "text-yellow-500"}"
translate="no"
>(+${renderTroops(this.popRate)})</span
>(+${renderTroops(this.troopRate)})</span
></span
>
</div>
@@ -237,43 +186,7 @@ export class ControlPanel extends LitElement implements Layer {
<span class="font-bold"
>${translateText("control_panel.gold")}:</span
>
<span translate="no"
>${renderNumber(this._gold)}
(+${renderNumber(this._goldPerSecond)})</span
>
</div>
</div>
<div class="relative mb-4 sm:mb-4">
<label class="block text-white mb-1" translate="no"
>${translateText("control_panel.troops")}:
<span translate="no">${renderTroops(this._troops)}</span> |
${translateText("control_panel.workers")}:
<span translate="no">${renderTroops(this._workers)}</span></label
>
<div class="relative h-8">
<!-- Background track -->
<div
class="absolute left-0 right-0 top-3 h-2 bg-white/20 rounded"
></div>
<!-- Fill track -->
<div
class="absolute left-0 top-3 h-2 bg-blue-500/60 rounded transition-all duration-300"
style="width: ${this.currentTroopRatio * 100}%"
></div>
<!-- Range input - exactly overlaying the visual elements -->
<input
type="range"
min="1"
max="100"
.value=${(this.targetTroopRatio * 100).toString()}
@input=${(e: Event) => {
this.targetTroopRatio =
parseInt((e.target as HTMLInputElement).value) / 100;
this.onTroopChange(this.targetTroopRatio);
}}
class="absolute left-0 right-0 top-2 m-0 h-4 cursor-pointer targetTroopRatio"
/>
<span translate="no">${renderNumber(this._gold)}</span>
</div>
</div>
+9 -5
View File
@@ -5,19 +5,18 @@ import { AllPlayers } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { TerraNulliusImpl } from "../../../core/game/TerraNulliusImpl";
import { emojiTable, flattenedEmojiTable } from "../../../core/Util";
import { ShowEmojiMenuEvent } from "../../InputHandler";
import { CloseViewEvent, ShowEmojiMenuEvent } from "../../InputHandler";
import { SendEmojiIntentEvent } from "../../Transport";
import { TransformHandler } from "../TransformHandler";
@customElement("emoji-table")
export class EmojiTable extends LitElement {
@state() public isVisible = false;
public eventBus: EventBus;
public transformHandler: TransformHandler;
public game: GameView;
initEventBus() {
this.eventBus.on(ShowEmojiMenuEvent, (e) => {
initEventBus(eventBus: EventBus) {
eventBus.on(ShowEmojiMenuEvent, (e) => {
this.isVisible = true;
const cell = this.transformHandler.screenToWorldCoordinates(e.x, e.y);
if (!this.game.isValidCoord(cell.x, cell.y)) {
@@ -40,7 +39,7 @@ export class EmojiTable extends LitElement {
targetPlayer === this.game.myPlayer()
? AllPlayers
: (targetPlayer as PlayerView);
this.eventBus.emit(
eventBus.emit(
new SendEmojiIntentEvent(
recipient,
flattenedEmojiTable.indexOf(emoji),
@@ -49,6 +48,11 @@ export class EmojiTable extends LitElement {
this.hideTable();
});
});
eventBus.on(CloseViewEvent, (e) => {
if (!this.hidden) {
this.hideTable();
}
});
}
private onEmojiClicked: (emoji: string) => void = () => {};
@@ -475,7 +475,6 @@ export class EventsDisplay extends LitElement implements Layer {
const recipient = this.game.playerBySmallID(
update.request.recipientID,
) as PlayerView;
this.addEvent({
description: translateText("events_display.alliance_request_status", {
name: recipient.name(),
+268
View File
@@ -0,0 +1,268 @@
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { UserSettings } from "../../../core/game/UserSettings";
import { TogglePerformanceOverlayEvent } from "../../InputHandler";
import { Layer } from "./Layer";
@customElement("fps-display")
export class FPSDisplay extends LitElement implements Layer {
@property({ type: Object })
public eventBus!: EventBus;
@property({ type: Object })
public userSettings!: UserSettings;
@state()
private currentFPS: number = 0;
@state()
private averageFPS: number = 0;
@state()
private frameTime: number = 0;
@state()
private isVisible: boolean = false;
@state()
private isDragging: boolean = false;
@state()
private position: { x: number; y: number } = { x: 50, y: 20 }; // Percentage values
private frameCount: number = 0;
private lastTime: number = 0;
private frameTimes: number[] = [];
private fpsHistory: number[] = [];
private lastSecondTime: number = 0;
private framesThisSecond: number = 0;
private dragStart: { x: number; y: number } = { x: 0, y: 0 };
static styles = css`
.fps-display {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 8px 12px;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
z-index: 9999;
user-select: none;
cursor: move;
transition: none;
}
.fps-display.dragging {
cursor: grabbing;
transition: none;
opacity: 0.5;
}
.fps-line {
margin: 2px 0;
}
.fps-good {
color: #4ade80; /* green-400 */
}
.fps-warning {
color: #fbbf24; /* amber-400 */
}
.fps-bad {
color: #f87171; /* red-400 */
}
.close-button {
position: absolute;
top: 8px;
right: 8px;
width: 20px;
height: 20px;
background-color: rgba(0, 0, 0, 0.8);
border-radius: 4px;
color: white;
font-size: 14px;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
user-select: none;
pointer-events: auto;
}
`;
constructor() {
super();
}
init() {
this.eventBus.on(TogglePerformanceOverlayEvent, () => {
this.userSettings.togglePerformanceOverlay();
});
}
setVisible(visible: boolean) {
this.isVisible = visible;
}
private handleClose() {
this.userSettings.togglePerformanceOverlay();
}
private handleMouseDown = (e: MouseEvent) => {
// Don't start dragging if clicking on close button
if ((e.target as HTMLElement).classList.contains("close-button")) {
return;
}
this.isDragging = true;
this.dragStart = {
x: e.clientX - this.position.x,
y: e.clientY - this.position.y,
};
document.addEventListener("mousemove", this.handleMouseMove);
document.addEventListener("mouseup", this.handleMouseUp);
e.preventDefault();
};
private handleMouseMove = (e: MouseEvent) => {
if (!this.isDragging) return;
const newX = e.clientX - this.dragStart.x;
const newY = e.clientY - this.dragStart.y;
// Convert to percentage of viewport
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
this.position = {
x: Math.max(0, Math.min(viewportWidth - 100, newX)), // Keep within viewport bounds
y: Math.max(0, Math.min(viewportHeight - 100, newY)),
};
this.requestUpdate();
};
private handleMouseUp = () => {
this.isDragging = false;
document.removeEventListener("mousemove", this.handleMouseMove);
document.removeEventListener("mouseup", this.handleMouseUp);
};
updateFPS(frameDuration: number) {
this.isVisible = this.userSettings.performanceOverlay();
if (!this.isVisible) return;
const now = performance.now();
// Initialize timing on first call
if (this.lastTime === 0) {
this.lastTime = now;
this.lastSecondTime = now;
return;
}
const deltaTime = now - this.lastTime;
// Track frame times for current FPS calculation (last 60 frames)
this.frameTimes.push(deltaTime);
if (this.frameTimes.length > 60) {
this.frameTimes.shift();
}
// Calculate current FPS based on average frame time
if (this.frameTimes.length > 0) {
const avgFrameTime =
this.frameTimes.reduce((a, b) => a + b, 0) / this.frameTimes.length;
this.currentFPS = Math.round(1000 / avgFrameTime);
this.frameTime = Math.round(avgFrameTime);
}
// Track FPS for 60-second average
this.framesThisSecond++;
// Update every second
if (now - this.lastSecondTime >= 1000) {
this.fpsHistory.push(this.framesThisSecond);
if (this.fpsHistory.length > 60) {
this.fpsHistory.shift();
}
// Calculate 60-second average
if (this.fpsHistory.length > 0) {
this.averageFPS = Math.round(
this.fpsHistory.reduce((a, b) => a + b, 0) / this.fpsHistory.length,
);
}
this.framesThisSecond = 0;
this.lastSecondTime = now;
}
this.lastTime = now;
this.frameCount++;
this.requestUpdate();
}
shouldTransform(): boolean {
return false;
}
private getFPSColor(fps: number): string {
if (fps >= 55) return "fps-good";
if (fps >= 30) return "fps-warning";
return "fps-bad";
}
render() {
if (!this.isVisible) {
return html``;
}
const style = `
left: ${this.position.x}px;
top: ${this.position.y}px;
transform: none;
`;
return html`
<div
class="fps-display ${this.isDragging ? "dragging" : ""}"
style="${style}"
@mousedown="${this.handleMouseDown}"
>
<button class="close-button" @click="${this.handleClose}">×</button>
<div class="fps-line">
FPS:
<span class="${this.getFPSColor(this.currentFPS)}"
>${this.currentFPS}</span
>
</div>
<div class="fps-line">
Avg (60s):
<span class="${this.getFPSColor(this.averageFPS)}"
>${this.averageFPS}</span
>
</div>
<div class="fps-line">
Frame:
<span class="${this.getFPSColor(1000 / this.frameTime)}"
>${this.frameTime}ms</span
>
</div>
</div>
`;
}
}
+1 -4
View File
@@ -269,9 +269,6 @@ export class Leaderboard extends LitElement implements Layer {
function formatPercentage(value: number): string {
const perc = value * 100;
if (perc > 99.5) return "100%";
if (perc < 0.01) return "0%";
if (perc < 0.1) return perc.toPrecision(1) + "%";
if (Number.isNaN(perc)) return "0%";
return perc.toPrecision(2) + "%";
return perc.toFixed(1) + "%";
}
+11
View File
@@ -137,6 +137,11 @@ export class OptionsMenu extends LitElement implements Layer {
this.requestUpdate();
}
private onTogglePerformanceOverlayButtonClick() {
this.userSettings.togglePerformanceOverlay();
this.requestUpdate();
}
init() {
console.log("init called from OptionsMenu");
this.showPauseButton =
@@ -251,6 +256,12 @@ export class OptionsMenu extends LitElement implements Layer {
? "Opens menu"
: "Attack"),
})}
${button({
onClick: this.onTogglePerformanceOverlayButtonClick,
title: "Performance Overlay",
children:
"🚀: " + (this.userSettings.performanceOverlay() ? "On" : "Off"),
})}
<!-- ${button({
onClick: this.onToggleFocusLockedButtonClick,
title: "Lock Focus",
+13 -1
View File
@@ -13,7 +13,7 @@ import { AllPlayers, PlayerActions } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { flattenedEmojiTable } from "../../../core/Util";
import { MouseUpEvent } from "../../InputHandler";
import { CloseViewEvent, MouseUpEvent } from "../../InputHandler";
import {
SendAllianceRequestIntentEvent,
SendBreakAllianceIntentEvent,
@@ -164,8 +164,20 @@ export class PlayerPanel extends LitElement implements Layer {
private ctModal: ChatModal;
initEventBus(eventBus: EventBus) {
this.eventBus = eventBus;
eventBus.on(CloseViewEvent, (e) => {
if (!this.hidden) {
this.hide();
}
});
}
init() {
this.eventBus.on(MouseUpEvent, () => this.hide());
this.eventBus.on(CloseViewEvent, (e) => {
this.hide();
});
this.ctModal = document.querySelector("chat-modal") as ChatModal;
}
+4
View File
@@ -1,6 +1,7 @@
import * as d3 from "d3";
import backIcon from "../../../../resources/images/BackIconWhite.svg";
import { EventBus, GameEvent } from "../../../core/EventBus";
import { CloseViewEvent } from "../../InputHandler";
import { Layer } from "./Layer";
import {
CenterButtonElement,
@@ -102,6 +103,9 @@ export class RadialMenu implements Layer {
init() {
this.createMenuElement();
this.createTooltipElement();
this.eventBus.on(CloseViewEvent, (e) => {
this.hideRadialMenu();
});
}
private createMenuElement() {
@@ -114,6 +114,11 @@ export class SettingsModal extends LitElement implements Layer {
this.requestUpdate();
}
private onTogglePerformanceOverlayButtonClick() {
this.userSettings.togglePerformanceOverlay();
this.requestUpdate();
}
private onExitButtonClick() {
// redirect to the home page
window.location.href = "/";
@@ -298,6 +303,35 @@ export class SettingsModal extends LitElement implements Layer {
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded text-white transition-colors"
@click="${this.onTogglePerformanceOverlayButtonClick}"
>
<img
src=${settingsIcon}
alt="performanceIcon"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.performance_overlay_label")}
</div>
<div class="text-sm text-slate-400">
${this.userSettings.performanceOverlay()
? translateText("user_setting.performance_overlay_enabled")
: translateText(
"user_setting.performance_overlay_disabled",
)}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.performanceOverlay()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<div class="border-t border-slate-600 pt-3 mt-4">
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-red-600/20 rounded text-red-400 transition-colors"
+8 -1
View File
@@ -135,7 +135,14 @@ export class StructureLayer implements Layer {
this.canvas.width = this.game.width() * 2;
this.canvas.height = this.game.height() * 2;
this.game.units().forEach((u) => this.handleUnitRendering(u));
Promise.all(
Array.from(this.unitIcons.values()).map((img) =>
img.decode?.().catch(() => {}),
),
).finally(() => {
this.game.units().forEach((u) => this.handleUnitRendering(u));
});
}
renderLayer(context: CanvasRenderingContext2D) {
+1 -3
View File
@@ -167,8 +167,6 @@ export class TeamStats extends LitElement implements Layer {
function formatPercentage(value: number): string {
const perc = value * 100;
if (perc > 99.5) return "100%";
if (perc < 0.01) return "0%";
if (perc < 0.1) return perc.toPrecision(1) + "%";
if (Number.isNaN(perc)) return "0%";
return perc.toPrecision(2) + "%";
}
+2 -35
View File
@@ -1,6 +1,5 @@
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import logo from "../../../../resources/images/ofm/logo_MASTER_2025.png";
import { translateText } from "../../../client/Utils";
import { EventBus } from "../../../core/EventBus";
import { GameUpdateType } from "../../../core/game/GameUpdates";
@@ -22,9 +21,6 @@ export class WinModal extends LitElement implements Layer {
@state()
showButtons = false;
@state()
private showSteamContent = Math.random() > 0.5;
private _title: string;
// Override to prevent shadow DOM creation
@@ -142,9 +138,7 @@ export class WinModal extends LitElement implements Layer {
return html`
<div class="win-modal ${this.isVisible ? "visible" : ""}">
<h2>${this._title || ""}</h2>
${this.showSteamContent
? this.steamWishlist()
: this.openfrontMasters()}
${this.innerHtml()}
<div
class="button-container ${this.showButtons ? "visible" : "hidden"}"
>
@@ -159,7 +153,7 @@ export class WinModal extends LitElement implements Layer {
`;
}
steamWishlist() {
innerHtml() {
return html`<p>
<a
href="https://store.steampowered.com/app/3560670"
@@ -180,33 +174,6 @@ export class WinModal extends LitElement implements Layer {
</p>`;
}
openfrontMasters() {
return html`<p>
<img
src="${logo}"
alt="OpenFront Masters"
style="max-width: 100%; height: auto; margin-bottom: 16px;"
/>
<a
href="https://discord.gg/gStsGh5vWR"
target="_blank"
rel="noopener noreferrer"
style="
color: #4a9eff;
text-decoration: underline;
font-weight: 500;
transition: color 0.2s ease;
font-size: 24px;
"
onmouseover="this.style.color='#6db3ff'"
onmouseout="this.style.color='#4a9eff'"
>
Watch the best compete in the
<span style="font-weight: bold;">OpenFront Masters</span>
</a>
</p>`;
}
show() {
this.eventBus.emit(new GutterAdModalEvent(true));
setTimeout(() => {
+6 -1
View File
@@ -42,7 +42,7 @@
}
.dark .bg-image {
filter: blur(4px) brightness(0.7);
filter: blur(4px) brightness(0.4) saturate(0.3) contrast(1.2);
}
/* display:none if child has class parent-hidden since we can't use shadow DOM in Lit due to Tailwind */
@@ -386,6 +386,7 @@
<news-modal></news-modal>
<game-left-sidebar></game-left-sidebar>
<spawn-ad></spawn-ad>
<fps-display></fps-display>
<div
id="language-modal"
class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex justify-center items-center"
@@ -412,6 +413,10 @@
document.documentElement.classList.remove("preload");
});
});
window.addEventListener("beforeunload", function (e) {
e.preventDefault();
e.returnValue = "Are you sure you want to leave?";
});
</script>
<!-- Playwire ads -->
+31 -18
View File
@@ -251,15 +251,15 @@ label.option-card:hover {
}
.player-tag {
display: flex;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
padding: 4px 16px;
gap: 8px;
background: #2a2a2a;
color: #fff;
padding: 8px 12px;
margin: 4px;
border-radius: 16px;
font-size: 14px;
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
}
#bots-count,
@@ -625,18 +625,6 @@ label.option-card:hover {
padding: 0 16px;
}
.player-tag {
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
padding: 4px 16px;
border-radius: 16px;
font-size: 14px;
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
}
/* News Button Notification */
news-button .active button {
position: relative;
@@ -670,3 +658,28 @@ news-button .active button::after {
transform: scale(1.4);
}
}
.host-badge {
font-size: 11px;
color: #4caf50;
font-weight: bold;
}
.remove-player-btn {
width: 16px;
height: 16px;
border: none;
background: #ff4444;
color: white;
border-radius: 50%;
font-size: 11px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
margin-left: 4px;
}
.remove-player-btn:hover {
background: #ff6666;
}
+27
View File
@@ -55,3 +55,30 @@
margin: 0 auto;
}
}
.dark .c-button {
background: var(--primaryColorDark);
color: var(--fontColorLight);
}
.dark .c-button:hover,
.dark .c-button:active,
.dark .c-button:focus {
background: var(--primaryColorHoverDark);
}
.dark .c-button:disabled {
background: var(--primaryColorDisabledDark);
opacity: 0.7;
}
.dark .c-button--secondary {
background: var(--secondaryColorDark);
color: var(--fontColorDark);
}
.dark .c-button--secondary:hover,
.dark .c-button--secondary:active,
.dark .c-button--secondary:focus {
background: var(--secondaryColorHoverDark);
}
+7
View File
@@ -16,4 +16,11 @@
--secondaryColor: #dbeafe;
--secondaryColorHover: #bfdbfe;
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
--primaryColorDark: #3b82f6;
--primaryColorHoverDark: #2563eb;
--primaryColorDisabledDark: #4b5563;
--secondaryColorDark: #374151;
--secondaryColorHoverDark: #4b5563;
--fontColorDark: #f3f4f6;
}
+3 -1
View File
@@ -19,6 +19,7 @@ import {
} from "./game/Game";
import { createGame } from "./game/GameImpl";
import { TileRef } from "./game/GameMap";
import { GameMapLoader } from "./game/GameMapLoader";
import {
ErrorUpdate,
GameUpdateType,
@@ -33,10 +34,11 @@ import { fixProfaneUsername } from "./validations/username";
export async function createGameRunner(
gameStart: GameStartInfo,
clientID: ClientID,
mapLoader: GameMapLoader,
callBack: (gu: GameUpdateViewData) => void,
): Promise<GameRunner> {
const config = await getConfig(gameStart.config, null);
const gameMap = await loadGameMap(gameStart.config.gameMap);
const gameMap = await loadGameMap(gameStart.config.gameMap, mapLoader);
const random = new PseudoRandom(simpleHash(gameStart.gameID));
const humans = gameStart.players.map(
+10 -11
View File
@@ -35,13 +35,14 @@ export type Intent =
| EmojiIntent
| DonateGoldIntent
| DonateTroopsIntent
| TargetTroopRatioIntent
| BuildUnitIntent
| EmbargoIntent
| QuickChatIntent
| MoveWarshipIntent
| MarkDisconnectedIntent
| UpgradeStructureIntent;
| UpgradeStructureIntent
| KickPlayerIntent;
export type AttackIntent = z.infer<typeof AttackIntentSchema>;
export type CancelAttackIntent = z.infer<typeof CancelAttackIntentSchema>;
export type SpawnIntent = z.infer<typeof SpawnIntentSchema>;
@@ -57,9 +58,6 @@ export type EmojiIntent = z.infer<typeof EmojiIntentSchema>;
export type DonateGoldIntent = z.infer<typeof DonateGoldIntentSchema>;
export type DonateTroopsIntent = z.infer<typeof DonateTroopIntentSchema>;
export type EmbargoIntent = z.infer<typeof EmbargoIntentSchema>;
export type TargetTroopRatioIntent = z.infer<
typeof TargetTroopRatioIntentSchema
>;
export type BuildUnitIntent = z.infer<typeof BuildUnitIntentSchema>;
export type UpgradeStructureIntent = z.infer<
typeof UpgradeStructureIntentSchema
@@ -72,6 +70,7 @@ export type MarkDisconnectedIntent = z.infer<
export type AllianceExtensionIntent = z.infer<
typeof AllianceExtensionIntentSchema
>;
export type KickPlayerIntent = z.infer<typeof KickPlayerIntentSchema>;
export type Turn = z.infer<typeof TurnSchema>;
export type GameConfig = z.infer<typeof GameConfigSchema>;
@@ -311,11 +310,6 @@ export const DonateTroopIntentSchema = BaseIntentSchema.extend({
troops: z.number().nullable(),
});
export const TargetTroopRatioIntentSchema = BaseIntentSchema.extend({
type: z.literal("troop_ratio"),
ratio: z.number().min(0).max(1),
});
export const BuildUnitIntentSchema = BaseIntentSchema.extend({
type: z.literal("build_unit"),
unit: z.enum(UnitType),
@@ -356,6 +350,11 @@ export const MarkDisconnectedIntentSchema = BaseIntentSchema.extend({
isDisconnected: z.boolean(),
});
export const KickPlayerIntentSchema = BaseIntentSchema.extend({
type: z.literal("kick_player"),
target: ID,
});
const IntentSchema = z.discriminatedUnion("type", [
AttackIntentSchema,
CancelAttackIntentSchema,
@@ -370,13 +369,13 @@ const IntentSchema = z.discriminatedUnion("type", [
EmojiIntentSchema,
DonateGoldIntentSchema,
DonateTroopIntentSchema,
TargetTroopRatioIntentSchema,
BuildUnitIntentSchema,
UpgradeStructureIntentSchema,
EmbargoIntentSchema,
MoveWarshipIntentSchema,
QuickChatIntentSchema,
AllianceExtensionIntentSchema,
KickPlayerIntentSchema,
]);
//
-34
View File
@@ -1,6 +1,5 @@
import DOMPurify from "dompurify";
import { customAlphabet } from "nanoid";
import twemoji from "twemoji";
import { Cell, Unit } from "./game/Game";
import { GameMap, TileRef } from "./game/GameMap";
import {
@@ -142,39 +141,6 @@ export function sanitize(name: string): string {
.replace(/[^\p{L}\p{N}\s\p{Emoji}\p{Emoji_Component}[\]_]/gu, "");
}
export function processName(name: string): string {
// First sanitize the raw input - strip everything except text and emojis
const sanitizedName = sanitize(name);
// Process emojis with twemoji
const withEmojis = twemoji.parse(sanitizedName, {
base: "https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/",
folder: "svg",
ext: ".svg",
});
// Add CSS styles inline to the wrapper span
const styledHTML = `
<span class="player-name" style="
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-weight: 500;
vertical-align: middle;
">
${withEmojis}
</span>
`;
// Add CSS for the emoji images
const withEmojiStyles = styledHTML.replace(
/<img/g,
'<img style="height: 1.2em; width: 1.2em; vertical-align: -0.2em; margin: 0 0.05em 0 0.1em;"',
);
// Sanitize the final HTML, allowing styles and specific attributes
return onlyImages(withEmojiStyles);
}
export function onlyImages(html: string) {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ["span", "img"],
+4 -5
View File
@@ -89,9 +89,8 @@ export interface Config {
playerTeams(): TeamCountConfig;
startManpower(playerInfo: PlayerInfo): number;
populationIncreaseRate(player: Player | PlayerView): number;
troopIncreaseRate(player: Player | PlayerView): number;
goldAdditionRate(player: Player | PlayerView): Gold;
troopAdjustmentRate(player: Player): number;
attackTilesPerTick(
attckTroops: number,
attacker: Player,
@@ -114,8 +113,8 @@ export interface Config {
// When computing likelihood of trading for any given port, the X closest port
// are twice more likely to be selected. X is determined below.
proximityBonusPortsNb(totalPorts: number): number;
maxPopulation(player: Player | PlayerView): number;
cityPopulationIncrease(): number;
maxTroops(player: Player | PlayerView): number;
cityTroopIncrease(): number;
boatAttackAmount(attacker: Player, defender: Player | TerraNullius): number;
shellLifetime(): number;
boatMaxNumber(): number;
@@ -162,7 +161,7 @@ export interface Config {
nukeType: NukeType,
humans: number,
tilesOwned: number,
maxPop: number,
maxTroops: number,
): number;
structureMinDist(): number;
isReplay(): boolean;
+21 -36
View File
@@ -273,7 +273,7 @@ export class DefaultConfig implements Config {
}
}
cityPopulationIncrease(): number {
cityTroopIncrease(): number {
return 250_000;
}
@@ -714,8 +714,8 @@ export class DefaultConfig implements Config {
return this.infiniteTroops() ? 1_000_000 : 25_000;
}
maxPopulation(player: Player | PlayerView): number {
const maxPop =
maxTroops(player: Player | PlayerView): number {
const maxTroops =
player.type() === PlayerType.Human && this.infiniteTroops()
? 1_000_000_000
: 2 * (Math.pow(player.numTilesOwned(), 0.6) * 1000 + 50000) +
@@ -723,34 +723,34 @@ export class DefaultConfig implements Config {
.units(UnitType.City)
.map((city) => city.level())
.reduce((a, b) => a + b, 0) *
this.cityPopulationIncrease();
this.cityTroopIncrease();
if (player.type() === PlayerType.Bot) {
return maxPop / 2;
return maxTroops / 2;
}
if (player.type() === PlayerType.Human) {
return maxPop;
return maxTroops;
}
switch (this._gameConfig.difficulty) {
case Difficulty.Easy:
return maxPop * 0.5;
return maxTroops * 0.5;
case Difficulty.Medium:
return maxPop * 1;
return maxTroops * 1;
case Difficulty.Hard:
return maxPop * 1.5;
return maxTroops * 1.5;
case Difficulty.Impossible:
return maxPop * 2;
return maxTroops * 2;
}
}
populationIncreaseRate(player: Player): number {
const max = this.maxPopulation(player);
troopIncreaseRate(player: Player): number {
const max = this.maxTroops(player);
let toAdd = 10 + Math.pow(player.population(), 0.73) / 4;
let toAdd = 10 + Math.pow(player.troops(), 0.73) / 4;
const ratio = 1 - player.population() / max;
const ratio = 1 - player.troops() / max;
toAdd *= ratio;
if (player.type() === PlayerType.Bot) {
@@ -774,26 +774,11 @@ export class DefaultConfig implements Config {
}
}
return Math.min(player.population() + toAdd, max) - player.population();
return Math.min(player.troops() + toAdd, max) - player.troops();
}
goldAdditionRate(player: Player): Gold {
return BigInt(Math.floor(0.045 * player.workers() ** 0.7));
}
troopAdjustmentRate(player: Player): number {
const maxDiff = this.maxPopulation(player) / 1000;
const target = player.population() * player.targetTroopRatio();
const diff = target - player.troops();
if (Math.abs(diff) < maxDiff) {
return diff;
}
const adjustment = maxDiff * Math.sign(diff);
// Can ramp down troops much faster
if (adjustment < 0) {
return adjustment * 5;
}
return adjustment;
return 100n;
}
nukeMagnitudes(unitType: UnitType): NukeMagnitude {
@@ -824,22 +809,22 @@ export class DefaultConfig implements Config {
return 80;
}
// Humans can be population, soldiers attacking, soldiers in boat etc.
// Humans can be soldiers, soldiers attacking, soldiers in boat etc.
nukeDeathFactor(
nukeType: NukeType,
humans: number,
tilesOwned: number,
maxPop: number,
maxTroops: number,
): number {
if (nukeType !== UnitType.MIRVWarhead) {
return (5 * humans) / Math.max(1, tilesOwned);
}
const targetPop = 0.03 * maxPop;
const excessPop = Math.max(0, humans - targetPop);
const targetTroops = 0.03 * maxTroops;
const excessTroops = Math.max(0, humans - targetTroops);
const scalingFactor = 500;
const steepness = 2;
const normalizedExcess = excessPop / maxPop;
const normalizedExcess = excessTroops / maxTroops;
return scalingFactor * (1 - Math.exp(-steepness * normalizedExcess));
}
-4
View File
@@ -74,10 +74,6 @@ export class DevConfig extends DefaultConfig {
// return 1
// }
// populationIncreaseRate(player: Player): number {
// return this.maxPopulation(player)
// }
// boatMaxDistance(): number {
// return 5000
// }
-1
View File
@@ -31,7 +31,6 @@ export class BotExecution implements Execution {
init(mg: Game) {
this.mg = mg;
this.bot.setTargetTroopRatio(0.7);
}
tick(ticks: number) {
+1 -1
View File
@@ -21,7 +21,7 @@ export class DonateTroopsExecution implements Execution {
this.recipient = mg.player(this.recipientID);
this.troops ??= mg.config().defaultDonationAmount(this.sender);
const maxDonation =
mg.config().maxPopulation(this.recipient) - this.recipient.population();
mg.config().maxTroops(this.recipient) - this.recipient.troops();
this.troops = Math.min(this.troops, maxDonation);
}
-3
View File
@@ -20,7 +20,6 @@ import { MoveWarshipExecution } from "./MoveWarshipExecution";
import { NoOpExecution } from "./NoOpExecution";
import { QuickChatExecution } from "./QuickChatExecution";
import { RetreatExecution } from "./RetreatExecution";
import { SetTargetTroopRatioExecution } from "./SetTargetTroopRatioExecution";
import { SpawnExecution } from "./SpawnExecution";
import { TargetPlayerExecution } from "./TargetPlayerExecution";
import { TransportShipExecution } from "./TransportShipExecution";
@@ -98,8 +97,6 @@ export class Executor {
);
case "donate_gold":
return new DonateGoldExecution(player, intent.recipient, intent.gold);
case "troop_ratio":
return new SetTargetTroopRatioExecution(player, intent.ratio);
case "embargo":
return new EmbargoExecution(player, intent.targetID, intent.action);
case "build_unit":
-7
View File
@@ -153,13 +153,6 @@ export class FakeHumanExecution implements Execution {
return;
}
if (
this.player.troops() > 100_000 &&
this.player.targetTroopRatio() > 0.7
) {
this.player.setTargetTroopRatio(0.7);
}
this.updateRelationsFromEmbargos();
this.behavior.handleAllianceRequests();
this.handleUnits();
+11 -9
View File
@@ -47,6 +47,17 @@ export class MirvExecution implements Execution {
// Record stats
this.mg.stats().bombLaunch(this.player, this.targetPlayer, UnitType.MIRV);
// Betrayal on launch
if (this.targetPlayer.isPlayer()) {
const alliance = this.player.allianceWith(this.targetPlayer);
if (alliance !== null) {
this.player.breakAlliance(alliance);
}
if (this.targetPlayer !== this.player) {
this.targetPlayer.updateRelation(this.player, -100);
}
}
}
tick(ticks: number): void {
@@ -118,15 +129,6 @@ export class MirvExecution implements Execution {
),
);
}
if (this.targetPlayer.isPlayer()) {
const alliance = this.player.allianceWith(this.targetPlayer);
if (alliance !== null) {
this.player.breakAlliance(alliance);
}
if (this.targetPlayer !== this.player) {
this.targetPlayer.updateRelation(this.player, -100);
}
}
this.nuke.delete(false);
}
+5 -15
View File
@@ -203,8 +203,8 @@ export class NukeExecution implements Execution {
const toDestroy = this.tilesToDestroy();
this.maybeBreakAlliances(toDestroy);
const maxPop = this.target().isPlayer()
? this.mg.config().maxPopulation(this.target() as Player)
const maxTroops = this.target().isPlayer()
? this.mg.config().maxTroops(this.target() as Player)
: 1;
for (const tile of toDestroy) {
@@ -218,17 +218,7 @@ export class NukeExecution implements Execution {
this.nukeType,
owner.troops(),
owner.numTilesOwned(),
maxPop,
),
);
owner.removeWorkers(
this.mg
.config()
.nukeDeathFactor(
this.nukeType,
owner.workers(),
owner.numTilesOwned(),
maxPop,
maxTroops,
),
);
owner.outgoingAttacks().forEach((attack) => {
@@ -239,7 +229,7 @@ export class NukeExecution implements Execution {
this.nukeType,
attack.troops(),
owner.numTilesOwned(),
maxPop,
maxTroops,
) ?? 0;
attack.setTroops(attack.troops() - deaths);
});
@@ -251,7 +241,7 @@ export class NukeExecution implements Execution {
this.nukeType,
attack.troops(),
owner.numTilesOwned(),
maxPop,
maxTroops,
) ?? 0;
attack.setTroops(attack.troops() - deaths);
});
+5 -8
View File
@@ -41,7 +41,9 @@ export class PlayerExecution implements Execution {
});
if (!this.player.isAlive()) {
// Player has no tiles, delete any remaining units
// Player has no tiles, delete any remaining units and gold
const gold = this.player.gold();
this.player.removeGold(gold);
this.player.units().forEach((u) => {
if (
u.type() !== UnitType.AtomBomb &&
@@ -56,19 +58,14 @@ export class PlayerExecution implements Execution {
return;
}
const popInc = this.config.populationIncreaseRate(this.player);
this.player.addWorkers(popInc * (1 - this.player.targetTroopRatio()));
this.player.addTroops(popInc * this.player.targetTroopRatio());
const troopInc = this.config.troopIncreaseRate(this.player);
this.player.addTroops(troopInc);
const goldFromWorkers = this.config.goldAdditionRate(this.player);
this.player.addGold(goldFromWorkers);
// Record stats
this.mg.stats().goldWork(this.player, goldFromWorkers);
const adjustRate = this.config.troopAdjustmentRate(this.player);
this.player.addTroops(adjustRate);
this.player.removeWorkers(adjustRate);
const alliances = Array.from(this.player.alliances());
for (const alliance of alliances) {
if (alliance.expiresAt() <= this.mg.ticks()) {
@@ -1,31 +0,0 @@
import { Execution, Game, Player } from "../game/Game";
export class SetTargetTroopRatioExecution implements Execution {
private active = true;
constructor(
private player: Player,
private targetTroopsRatio: number,
) {}
init(mg: Game, ticks: number): void {}
tick(ticks: number): void {
if (this.targetTroopsRatio < 0 || this.targetTroopsRatio > 1) {
console.warn(
`target troop ratio of ${this.targetTroopsRatio} for player ${this.player} invalid`,
);
} else {
this.player.setTargetTroopRatio(this.targetTroopsRatio);
}
this.active = false;
}
isActive(): boolean {
return this.active;
}
activeDuringSpawnPhase(): boolean {
return false;
}
}
+3 -4
View File
@@ -59,8 +59,8 @@ export class BotBehavior {
}
private hasSufficientTroops(): boolean {
const maxPop = this.game.config().maxPopulation(this.player);
const ratio = this.player.population() / maxPop;
const maxTroops = this.game.config().maxTroops(this.player);
const ratio = this.player.troops() / maxTroops;
return ratio >= this.triggerRatio;
}
@@ -208,8 +208,7 @@ export class BotBehavior {
sendAttack(target: Player | TerraNullius) {
if (target.isPlayer() && this.player.isOnSameTeam(target)) return;
const maxPop = this.game.config().maxPopulation(this.player);
const maxTroops = maxPop * this.player.targetTroopRatio();
const maxTroops = this.game.config().maxTroops(this.player);
const reserveRatio = target.isPlayer()
? this.reserveRatio
: this.expandRatio;
@@ -1,13 +1,7 @@
import { GameMapType } from "./Game";
import { GameMapLoader, MapData } from "./GameMapLoader";
import { MapManifest } from "./TerrainMapLoader";
interface MapData {
mapBin: () => Promise<string>;
miniMapBin: () => Promise<string>;
manifest: () => Promise<MapManifest>;
webpPath: () => Promise<string>;
}
export interface BinModule {
default: string;
}
@@ -16,7 +10,7 @@ interface NationMapModule {
default: MapManifest;
}
class GameMapLoader {
export class BinaryLoaderGameMapLoader implements GameMapLoader {
private maps: Map<GameMapType, MapData>;
constructor() {
@@ -31,7 +25,7 @@ class GameMapLoader {
};
}
public getMapData(map: GameMapType): MapData {
getMapData(map: GameMapType): MapData {
const cachedMap = this.maps.get(map);
if (cachedMap) {
return cachedMap;
@@ -46,14 +40,14 @@ class GameMapLoader {
import(
`!!binary-loader!../../../resources/maps/${fileName}/map.bin`
) as Promise<BinModule>
).then((m) => m.default),
).then((m) => this.toUInt8Array(m.default)),
),
miniMapBin: this.createLazyLoader(() =>
(
import(
`!!binary-loader!../../../resources/maps/${fileName}/mini_map.bin`
) as Promise<BinModule>
).then((m) => m.default),
).then((m) => this.toUInt8Array(m.default)),
),
manifest: this.createLazyLoader(() =>
(
@@ -74,6 +68,18 @@ class GameMapLoader {
this.maps.set(map, mapData);
return mapData;
}
}
export const terrainMapFileLoader = new GameMapLoader();
/**
* Converts a given string into a UInt8Array where each character in the string
* is represented as an 8-bit unsigned integer.
*/
private toUInt8Array(data: string) {
const rawData = new Uint8Array(data.length);
for (let i = 0; i < data.length; i++) {
rawData[i] = data.charCodeAt(i);
}
return rawData;
}
}
+69
View File
@@ -0,0 +1,69 @@
import { GameMapType } from "./Game";
import { GameMapLoader, MapData } from "./GameMapLoader";
export class FetchGameMapLoader implements GameMapLoader {
private maps: Map<GameMapType, MapData>;
public constructor(
private readonly prefix: string,
private readonly cacheBuster?: string,
) {
this.maps = new Map<GameMapType, MapData>();
}
public getMapData(map: GameMapType): MapData {
const cachedMap = this.maps.get(map);
if (cachedMap) {
return cachedMap;
}
const key = Object.keys(GameMapType).find((k) => GameMapType[k] === map);
const fileName = key?.toLowerCase();
if (!fileName) {
throw new Error(`Unknown map: ${map}`);
}
const mapData = {
mapBin: () => this.loadBinaryFromUrl(this.url(fileName, "map.bin")),
miniMapBin: () =>
this.loadBinaryFromUrl(this.url(fileName, "mini_map.bin")),
manifest: () => this.loadJsonFromUrl(this.url(fileName, "manifest.json")),
webpPath: async () => this.url(fileName, "thumbnail.webp"),
} satisfies MapData;
this.maps.set(map, mapData);
return mapData;
}
private url(map: string, path: string) {
let url = `${this.prefix}/${map}/${path}`;
if (this.cacheBuster) {
url += `${url.includes("?") ? "&" : "?"}v=${this.cacheBuster}`;
}
return url;
}
private async loadBinaryFromUrl(url: string) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to load ${url}: ${response.statusText}`);
}
const data = await response.arrayBuffer();
return new Uint8Array(data);
}
private async loadJsonFromUrl(url: string) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to load ${url}: ${response.statusText}`);
}
return response.json();
}
}
+2 -8
View File
@@ -510,17 +510,11 @@ export interface Player {
conquer(tile: TileRef): void;
relinquish(tile: TileRef): void;
// Resources & Population
// Resources & Troops
gold(): Gold;
population(): number;
workers(): number;
troops(): number;
targetTroopRatio(): number;
addGold(toAdd: Gold, tile?: TileRef): void;
removeGold(toRemove: Gold): Gold;
addWorkers(toAdd: number): void;
removeWorkers(toRemove: number): void;
setTargetTroopRatio(target: number): void;
troops(): number;
setTroops(troops: number): void;
addTroops(troops: number): void;
removeTroops(troops: number): number;
+13
View File
@@ -0,0 +1,13 @@
import { GameMapType } from "./Game";
import { MapManifest } from "./TerrainMapLoader";
export interface GameMapLoader {
getMapData(map: GameMapType): MapData;
}
export interface MapData {
mapBin: () => Promise<Uint8Array>;
miniMapBin: () => Promise<Uint8Array>;
manifest: () => Promise<MapManifest>;
webpPath: () => Promise<string>;
}
-3
View File
@@ -154,10 +154,7 @@ export interface PlayerUpdate {
isDisconnected: boolean;
tilesOwned: number;
gold: Gold;
population: number;
workers: number;
troops: number;
targetTroopRatio: number;
allies: number[];
embargoes: Set<PlayerID>;
isTraitor: boolean;
-9
View File
@@ -291,15 +291,6 @@ export class PlayerView {
gold(): Gold {
return this.data.gold;
}
population(): number {
return this.data.population;
}
workers(): number {
return this.data.workers;
}
targetTroopRatio(): number {
return this.data.targetTroopRatio;
}
troops(): number {
return this.data.troops;
+2 -38
View File
@@ -4,7 +4,6 @@ import { ClientID } from "../Schemas";
import {
assertNever,
distSortUnit,
maxInt,
minInt,
simpleHash,
toInt,
@@ -72,10 +71,6 @@ export class PlayerImpl implements Player {
private _gold: bigint;
private _troops: bigint;
private _workers: bigint;
// 0 to 100
private _targetTroopRatio: bigint;
markedTraitorTick = -1;
@@ -115,11 +110,9 @@ export class PlayerImpl implements Player {
private readonly _team: Team | null,
) {
this._name = sanitizeUsername(playerInfo.name);
this._targetTroopRatio = 95n;
this._troops = toInt(startTroops);
this._workers = 0n;
this._gold = 0n;
this._displayName = this._name; // processName(this._name)
this._displayName = this._name;
this._pseudo_random = new PseudoRandom(simpleHash(this.playerInfo.id));
}
@@ -144,10 +137,7 @@ export class PlayerImpl implements Player {
isDisconnected: this.isDisconnected(),
tilesOwned: this.numTilesOwned(),
gold: this._gold,
population: this.population(),
workers: this.workers(),
troops: this.troops(),
targetTroopRatio: this.targetTroopRatio(),
allies: this.alliances().map((a) => a.other(this).smallID()),
embargoes: new Set([...this.embargoes.keys()].map((p) => p.toString())),
isTraitor: this.isTraitor(),
@@ -731,32 +721,6 @@ export class PlayerImpl implements Player {
return actualRemoved;
}
population(): number {
return Number(this._troops + this._workers);
}
workers(): number {
return Math.max(1, Number(this._workers));
}
addWorkers(toAdd: number): void {
this._workers += toInt(toAdd);
}
removeWorkers(toRemove: number): void {
this._workers = maxInt(1n, this._workers - toInt(toRemove));
}
targetTroopRatio(): number {
return Number(this._targetTroopRatio) / 100;
}
setTargetTroopRatio(target: number): void {
if (target < 0 || target > 1) {
throw new Error(
`invalid targetTroopRatio ${target} set on player ${PlayerImpl}`,
);
}
this._targetTroopRatio = toInt(target * 100);
}
troops(): number {
return Number(this._troops);
}
@@ -1051,7 +1015,7 @@ export class PlayerImpl implements Player {
hash(): number {
return (
simpleHash(this.id()) * (this.population() + this.numTilesOwned()) +
simpleHash(this.id()) * (this.troops() + this.numTilesOwned()) +
this._units.reduce((acc, unit) => acc + unit.hash(), 0)
);
}
+4 -12
View File
@@ -1,6 +1,6 @@
import { GameMapType } from "./Game";
import { GameMap, GameMapImpl } from "./GameMap";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
import { GameMapLoader } from "./GameMapLoader";
export type TerrainMapData = {
manifest: MapManifest;
@@ -32,6 +32,7 @@ export interface Nation {
export async function loadTerrainMap(
map: GameMapType,
terrainMapFileLoader: GameMapLoader,
): Promise<TerrainMapData> {
const cached = loadedMaps.get(map);
if (cached !== undefined) return cached;
@@ -57,7 +58,7 @@ export async function loadTerrainMap(
export async function genTerrainFromBin(
mapData: MapMetadata,
data: string,
data: Uint8Array,
): Promise<GameMap> {
if (data.length !== mapData.width * mapData.height) {
throw new Error(
@@ -65,19 +66,10 @@ export async function genTerrainFromBin(
);
}
// Store raw data in Uint8Array
const rawData = new Uint8Array(mapData.width * mapData.height);
// Copy data starting after the header
for (let i = 0; i < mapData.width * mapData.height; i++) {
const packedByte = data.charCodeAt(i);
rawData[i] = packedByte;
}
return new GameMapImpl(
mapData.width,
mapData.height,
rawData,
data,
mapData.num_land_tiles,
);
}
+8
View File
@@ -20,6 +20,10 @@ export class UserSettings {
return this.get("settings.emojis", true);
}
performanceOverlay() {
return this.get("settings.performanceOverlay", false);
}
alertFrame() {
return this.get("settings.alertFrame", true);
}
@@ -66,6 +70,10 @@ export class UserSettings {
this.set("settings.emojis", !this.emojis());
}
togglePerformanceOverlay() {
this.set("settings.performanceOverlay", !this.performanceOverlay());
}
toggleAlertFrame() {
this.set("settings.alertFrame", !this.alertFrame());
}
+4
View File
@@ -1,4 +1,6 @@
import version from "../../../resources/version.txt";
import { createGameRunner, GameRunner } from "../GameRunner";
import { FetchGameMapLoader } from "../game/FetchGameMapLoader";
import { GameUpdateViewData } from "../game/GameUpdates";
import {
AttackAveragePositionResultMessage,
@@ -13,6 +15,7 @@ import {
const ctx: Worker = self as any;
let gameRunner: Promise<GameRunner> | null = null;
const mapLoader = new FetchGameMapLoader(`/maps`, version);
function gameUpdate(gu: GameUpdateViewData) {
sendMessage({
@@ -37,6 +40,7 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
gameRunner = createGameRunner(
message.gameStartInfo,
message.clientID,
mapLoader,
gameUpdate,
).then((gr) => {
sendMessage({
-1
View File
@@ -5,7 +5,6 @@ import { ClientID } from "../core/Schemas";
export class Client {
public lastPing: number = Date.now();
public isDisconnected: boolean = false;
public hashes: Map<Tick, number> = new Map();
+25 -14
View File
@@ -28,20 +28,31 @@ export class GameManager {
return false;
}
createGame(id: GameID, gameConfig: GameConfig | undefined) {
const game = new GameServer(id, this.log, Date.now(), this.config, {
gameMap: GameMapType.World,
gameType: GameType.Private,
difficulty: Difficulty.Medium,
disableNPCs: false,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
gameMode: GameMode.FFA,
bots: 400,
disabledUnits: [],
...gameConfig,
});
createGame(
id: GameID,
gameConfig: GameConfig | undefined,
creatorClientID?: string,
) {
const game = new GameServer(
id,
this.log,
Date.now(),
this.config,
{
gameMap: GameMapType.World,
gameType: GameType.Private,
difficulty: Difficulty.Medium,
disableNPCs: false,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
gameMode: GameMode.FFA,
bots: 400,
disabledUnits: [],
...gameConfig,
},
creatorClientID,
);
this.games.set(id, game);
return game;
}
+113 -41
View File
@@ -41,8 +41,9 @@ export class GameServer {
private turns: Turn[] = [];
private intents: Intent[] = [];
public activeClients: Client[] = [];
// Used for record record keeping
private LobbyCreatorID: string | undefined;
private allClients: Map<ClientID, Client> = new Map();
private clientsDisconnectedStatus: Map<ClientID, boolean> = new Map();
private _hasStarted = false;
private _startTime: number | null = null;
@@ -70,8 +71,10 @@ export class GameServer {
public readonly createdAt: number,
private config: ServerConfig,
public gameConfig: GameConfig,
lobbyCreatorID?: string,
) {
this.log = log_.child({ gameID: id });
this.LobbyCreatorID = lobbyCreatorID ?? undefined;
}
public updateGameConfig(gameConfig: Partial<GameConfig>): void {
@@ -117,6 +120,13 @@ export class GameServer {
});
return;
}
// Log when lobby creator joins private game
if (client.clientID === this.LobbyCreatorID) {
this.log.info("Lobby creator joined", {
gameID: this.id,
creatorID: this.LobbyCreatorID,
});
}
this.log.info("client (re)joining game", {
clientID: client.clientID,
persistentID: client.persistentID,
@@ -174,7 +184,6 @@ export class GameServer {
return;
}
client.isDisconnected = existing.isDisconnected;
client.lastPing = existing.lastPing;
this.activeClients = this.activeClients.filter((c) => c !== existing);
@@ -184,6 +193,8 @@ export class GameServer {
this.activeClients.push(client);
client.lastPing = Date.now();
this.markClientDisconnected(client.clientID, false);
this.allClients.set(client.clientID, client);
client.ws.removeAllListeners("message");
@@ -208,38 +219,93 @@ export class GameServer {
return;
}
const clientMsg = parsed.data;
if (clientMsg.type === "intent") {
if (clientMsg.intent.clientID !== client.clientID) {
switch (clientMsg.type) {
case "intent": {
if (clientMsg.intent.clientID !== client.clientID) {
this.log.warn(
`client id mismatch, client: ${client.clientID}, intent: ${clientMsg.intent.clientID}`,
);
return;
}
switch (clientMsg.intent.type) {
case "mark_disconnected": {
this.log.warn(
`Should not receive mark_disconnected intent from client`,
);
return;
}
// Handle kick_player intent via WebSocket
case "kick_player": {
const authenticatedClientID = client.clientID;
// Check if the authenticated client is the lobby creator
if (authenticatedClientID !== this.LobbyCreatorID) {
this.log.warn(`Only lobby creator can kick players`, {
clientID: authenticatedClientID,
creatorID: this.LobbyCreatorID,
target: clientMsg.intent.target,
gameID: this.id,
});
return;
}
// Don't allow lobby creator to kick themselves
if (authenticatedClientID === clientMsg.intent.target) {
this.log.warn(`Cannot kick yourself`, {
clientID: authenticatedClientID,
});
return;
}
// Log and execute the kick
this.log.info(`Lobby creator initiated kick of player`, {
creatorID: authenticatedClientID,
target: clientMsg.intent.target,
gameID: this.id,
kickMethod: "websocket",
});
this.kickClient(clientMsg.intent.target);
return;
}
default: {
this.addIntent(clientMsg.intent);
break;
}
}
break;
}
case "ping": {
this.lastPingUpdate = Date.now();
client.lastPing = Date.now();
break;
}
case "hash": {
client.hashes.set(clientMsg.turnNumber, clientMsg.hash);
break;
}
case "winner": {
if (
this.outOfSyncClients.has(client.clientID) ||
this.kickedClients.has(client.clientID) ||
this.winner !== null
) {
return;
}
this.winner = clientMsg;
this.archiveGame();
break;
}
default: {
this.log.warn(
`client id mismatch, client: ${client.clientID}, intent: ${clientMsg.intent.clientID}`,
`Unknown message type: ${(clientMsg as any).type}`,
{
clientID: client.clientID,
},
);
return;
break;
}
if (clientMsg.intent.type === "mark_disconnected") {
this.log.warn(
`Should not receive mark_disconnected intent from client`,
);
return;
}
this.addIntent(clientMsg.intent);
}
if (clientMsg.type === "ping") {
this.lastPingUpdate = Date.now();
client.lastPing = Date.now();
}
if (clientMsg.type === "hash") {
client.hashes.set(clientMsg.turnNumber, clientMsg.hash);
}
if (clientMsg.type === "winner") {
if (
this.outOfSyncClients.has(client.clientID) ||
this.kickedClients.has(client.clientID) ||
this.winner !== null
) {
return;
}
this.winner = clientMsg;
this.archiveGame();
}
} catch (error) {
this.log.info(
@@ -451,6 +517,10 @@ export class GameServer {
}
}
public isPrivateLobbyCreator(clientID: string): boolean {
return this.LobbyCreatorID === clientID;
}
phase(): GamePhase {
const now = Date.now();
const alive: Client[] = [];
@@ -571,25 +641,27 @@ export class GameServer {
const now = Date.now();
for (const [clientID, client] of this.allClients) {
if (
client.isDisconnected === false &&
now - client.lastPing > this.disconnectedTimeout
) {
this.markClientDisconnected(client, true);
const isDisconnected = this.isClientDisconnected(clientID);
if (!isDisconnected && now - client.lastPing > this.disconnectedTimeout) {
this.markClientDisconnected(clientID, true);
} else if (
client.isDisconnected &&
isDisconnected &&
now - client.lastPing < this.disconnectedTimeout
) {
this.markClientDisconnected(client, false);
this.markClientDisconnected(clientID, false);
}
}
}
private markClientDisconnected(client: Client, isDisconnected: boolean) {
client.isDisconnected = isDisconnected;
public isClientDisconnected(clientID: string): boolean {
return this.clientsDisconnectedStatus.get(clientID) ?? true;
}
private markClientDisconnected(clientID: string, isDisconnected: boolean) {
this.clientsDisconnectedStatus.set(clientID, isDisconnected);
this.addIntent({
type: "mark_disconnected",
clientID: client.clientID,
clientID: clientID,
isDisconnected: isDisconnected,
});
}
+11 -2
View File
@@ -15,6 +15,7 @@ import {
ClientMessageSchema,
GameRecord,
GameRecordSchema,
ID,
ServerErrorMessage,
} from "../core/Schemas";
import { CreateGameInputSchema, GameInputSchema } from "../core/WorkerSchemas";
@@ -90,6 +91,13 @@ export function startWorker() {
"/api/create_game/:id",
gatekeeper.httpHandler(LimiterType.Post, async (req, res) => {
const id = req.params.id;
const creatorClientID = (() => {
if (typeof req.query.creatorClientID !== "string") return undefined;
const trimmed = req.query.creatorClientID.trim();
return ID.safeParse(trimmed).success ? trimmed : undefined;
})();
if (!id) {
log.warn(`cannot create game, id not found`);
return res.status(400).json({ error: "Game ID is required" });
@@ -122,10 +130,11 @@ export function startWorker() {
return res.status(400).json({ error: "Worker, game id mismatch" });
}
const game = gm.createGame(id, gc);
// Pass creatorClientID to createGame
const game = gm.createGame(id, gc, creatorClientID);
log.info(
`Worker ${workerId}: IP ${ipAnonymize(clientIP)} creating game ${game.isPublic() ? "Public" : "Private"} with id ${id}`,
`Worker ${workerId}: IP ${ipAnonymize(clientIP)} creating ${game.isPublic() ? "Public" : "Private"}${gc?.gameMode ? ` ${gc.gameMode}` : ""} game with id ${id}${creatorClientID ? `, creator: ${creatorClientID}` : ""}`,
);
res.json(game.gameInfo());
}),
+2 -6
View File
@@ -48,14 +48,10 @@ export async function setup(
fs.readFileSync(manifestPath, "utf8"),
) satisfies MapManifest;
// Convert Buffer to string (binary encoding)
const mapBinString = mapBinBuffer.toString("binary");
const miniMapBinString = miniMapBinBuffer.toString("binary");
const gameMap = await genTerrainFromBin(manifest.map, mapBinString);
const gameMap = await genTerrainFromBin(manifest.map, mapBinBuffer);
const miniGameMap = await genTerrainFromBin(
manifest.mini_map,
miniMapBinString,
miniMapBinBuffer,
);
// Configure the game
-3
View File
@@ -136,9 +136,6 @@ export default async (env, argv) => {
from: path.resolve(__dirname, "resources"),
to: path.resolve(__dirname, "static"),
noErrorOnMissing: true,
globOptions: {
ignore: ["resources/maps/**/*"],
},
},
],
options: { concurrency: 100 },