Merge main into join-fail

This commit is contained in:
Scott Anderson
2025-08-17 03:56:36 -04:00
162 changed files with 1780 additions and 923 deletions
-1
View File
@@ -8,7 +8,6 @@ Describe the PR.
- [ ] I process any text displayed to the user through translateText() and I've added it to the en.json file
- [ ] I have added relevant tests to the test directory
- [ ] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced
- [ ] I have read and accepted the CLA agreement (only required once).
## Please put your Discord username so you can be contacted if a bug or regression is found:
-1
View File
@@ -44,7 +44,6 @@ jobs:
/- \[x\] I process any text displayed to the user through translateText\(\) and I\'ve added it to the en\.json file/i,
/- \[x\] I have added relevant tests to the test directory/i,
/- \[x\] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced/i,
/- \[x\] I have read and accepted the CLA agreement \(only required once\)\./i
];
for (const box of requiredBoxes) {
+1
View File
@@ -0,0 +1 @@
*.[tj]s
+1 -1
View File
@@ -46,7 +46,7 @@ ENV NPM_CONFIG_IGNORE_SCRIPTS=1
# Copy package.json and package-lock.json
COPY package*.json ./
# Install dependencies
RUN npm ci --omit=dev
RUN npm ci
# Final image
FROM base
+7
View File
@@ -0,0 +1,7 @@
import noZArray from "./rules/no-z-array.js";
export default {
rules: {
"no-z-array": noZArray,
},
};
+35
View File
@@ -0,0 +1,35 @@
export default {
create(context) {
return {
CallExpression(node) {
if (
node.callee.type === "MemberExpression" &&
node.callee.object.name === "z" &&
node.callee.property.name === "array" &&
node.arguments.length === 1
) {
const argSource = context.sourceCode.getText(node.arguments[0]);
context.report({
data: { type: argSource },
fix(fixer) {
return fixer.replaceText(node, `${argSource}.array()`);
},
messageId: "noZArray",
node,
});
}
},
};
},
meta: {
docs: {
description: "Disallow z.array(type) in favor of type.array()",
},
fixable: "code",
messages: {
noZArray: "Use `{{type}}.array()` instead of `z.array({{type}})`.",
},
schema: [],
type: "suggestion",
},
};
+104 -9
View File
@@ -1,10 +1,12 @@
import { includeIgnoreFile } from "@eslint/compat";
import pluginJs from "@eslint/js";
import eslintConfigPrettier from "eslint-config-prettier/flat";
import globals from "globals";
import path from "node:path";
import { fileURLToPath } from "node:url";
import pluginJs from "@eslint/js";
import stylistic from "@stylistic/eslint-plugin";
import tseslint from "typescript-eslint";
import { fileURLToPath } from "node:url";
import { includeIgnoreFile } from "@eslint/compat";
import eslintPluginLocal from "./eslint-plugin-local/plugin.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -25,6 +27,8 @@ export default [
projectService: {
allowDefaultProject: [
"__mocks__/fileMock.js",
"eslint-plugin-local/plugin.js",
"eslint-plugin-local/rules/no-z-array.js",
"eslint.config.js",
"jest.config.ts",
"postcss.config.js",
@@ -39,28 +43,119 @@ export default [
{
rules: {
// Disable rules that would fail. The failures should be fixed, and the entries here removed.
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-expressions": "off",
"@typescript-eslint/no-unused-vars": "off",
"no-case-declarations": "off",
"@typescript-eslint/no-unused-expressions": "off", // https://github.com/openfrontio/OpenFrontIO/issues/1790
"no-case-declarations": "off", // https://github.com/openfrontio/OpenFrontIO/issues/1791
},
},
{
plugins: {
"@stylistic": stylistic,
},
rules: {
// Enable rules
// '@stylistic/quotes': ['error', 'single'], TODO: Enable this rule, https://github.com/openfrontio/OpenFrontIO/issues/1788
"@stylistic/indent": ["error", 2],
"@stylistic/semi": "error",
"@stylistic/space-infix-ops": "error",
"@stylistic/type-annotation-spacing": [
"error",
{
after: true,
before: true,
overrides: {
colon: {
before: false,
},
},
},
],
"@stylistic/eol-last": "error",
"@typescript-eslint/consistent-type-definitions": [
"error",
"type",
],
"@typescript-eslint/no-duplicate-enum-values": "error",
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-inferrable-types": "error",
"@typescript-eslint/no-mixed-enums": "error",
"@typescript-eslint/no-require-imports": "error",
"@typescript-eslint/no-unnecessary-type-assertion": "error",
"@typescript-eslint/prefer-as-const": "error",
"@typescript-eslint/prefer-function-type": "error",
"@typescript-eslint/prefer-includes": "error",
"@typescript-eslint/prefer-literal-enum-member": "error",
"@typescript-eslint/prefer-nullish-coalescing": "error",
eqeqeq: "error",
"eqeqeq": "error",
"indent": "off", // @stylistic/indent
"sort-keys": "error",
"@typescript-eslint/no-unsafe-argument": "error",
"@typescript-eslint/no-unsafe-assignment": "error",
"@typescript-eslint/no-unsafe-member-access": "error",
// "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], // TODO: Enable this rule, https://github.com/openfrontio/OpenFrontIO/issues/1784
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/prefer-for-of": "error",
"array-bracket-newline": ["error", "consistent"],
"array-bracket-spacing": ["error", "never"],
"array-element-newline": ["error", "consistent"],
"arrow-parens": ["error", "always"],
"comma-dangle": ["error", "always-multiline"],
"comma-spacing": ["error", { before: false, after: true }],
"func-call-spacing": ["error", "never"],
"function-call-argument-newline": ["error", "consistent"],
"max-depth": ["error", { max: 5 }],
// "max-len": ["error", { code: 120 }], // TODO: Enable this rule, https://github.com/openfrontio/OpenFrontIO/issues/1785
"max-lines": ["error", { max: 1065, skipBlankLines: true, skipComments: true }],
"max-lines-per-function": ["error", { max: 561 }],
"no-loss-of-precision": "error",
"no-multi-spaces": "error",
"no-trailing-spaces": "error",
"object-curly-newline": ["error", { multiline: true, consistent: true }],
"object-curly-spacing": ["error", "always"],
"object-property-newline": ["error", { allowAllPropertiesOnSameLine: true }],
// "no-undef": "error", // TODO: Enable this rule, https://github.com/openfrontio/OpenFrontIO/issues/1786
"no-unused-vars": "off", // @typescript-eslint/no-unused-vars
"quote-props": ["error", "consistent-as-needed"],
// 'sort-imports': 'error', // TODO: Enable this rule, https://github.com/openfrontio/OpenFrontIO/issues/1787
"space-before-blocks": ["error", "always"],
"space-before-function-paren": ["error", {
anonymous: "always",
named: "never",
asyncArrow: "always",
}],
"space-infix-ops": "off",
},
},
{
files: [
"**/*.config.{js,ts,jsx,tsx}",
"**/*.test.{js,ts,jsx,tsx}",
"src/client/**/*.{js,ts,jsx,tsx}",
"tests/**/*.{js,ts,jsx,tsx}",
"eslint-plugin-local/**/*.{js,ts,jsx,tsx}",
],
rules: {
// Disabled rules for tests, configs
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"sort-keys": "off",
},
},
{
files: [
"src/client/**/*.{js,ts,jsx,tsx}",
],
rules: {
// Disabled rules for frontend
"sort-keys": "off",
},
},
{
plugins: {
local: eslintPluginLocal,
},
rules: {
"local/no-z-array": "error",
},
},
];
+57
View File
@@ -42,7 +42,9 @@
"@datastructures-js/priority-queue": "^6.3.3",
"@eslint/compat": "^1.2.7",
"@eslint/js": "^9.21.0",
"@stylistic/eslint-plugin": "^5.2.3",
"@swc/jest": "^0.2.39",
"@total-typescript/ts-reset": "^0.6.1",
"@types/benchmark": "^2.1.5",
"@types/chai": "^4.3.17",
"@types/d3": "^7.4.3",
@@ -6358,6 +6360,54 @@
"node": ">=18.0.0"
}
},
"node_modules/@stylistic/eslint-plugin": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.2.3.tgz",
"integrity": "sha512-oY7GVkJGVMI5benlBDCaRrSC1qPasafyv5dOBLLv5MTilMGnErKhO6ziEfodDDIZbo5QxPUNW360VudJOFODMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/types": "^8.38.0",
"eslint-visitor-keys": "^4.2.1",
"espree": "^10.4.0",
"estraverse": "^5.3.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"peerDependencies": {
"eslint": ">=9.0.0"
}
},
"node_modules/@stylistic/eslint-plugin/node_modules/@typescript-eslint/types": {
"version": "8.39.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.1.tgz",
"integrity": "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@stylistic/eslint-plugin/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@swc/core": {
"version": "1.13.3",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.3.tgz",
@@ -6604,6 +6654,13 @@
"@swc/counter": "^0.1.3"
}
},
"node_modules/@total-typescript/ts-reset": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/@total-typescript/ts-reset/-/ts-reset-0.6.1.tgz",
"integrity": "sha512-cka47fVSo6lfQDIATYqb/vO1nvFfbPw7uWLayIXIhGETj0wcOOlrlkobOMDNQOFr9QOafegUPq13V2+6vtD7yg==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node10": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
+2
View File
@@ -29,7 +29,9 @@
"@datastructures-js/priority-queue": "^6.3.3",
"@eslint/compat": "^1.2.7",
"@eslint/js": "^9.21.0",
"@stylistic/eslint-plugin": "^5.2.3",
"@swc/jest": "^0.2.39",
"@total-typescript/ts-reset": "^0.6.1",
"@types/benchmark": "^2.1.5",
"@types/chai": "^4.3.17",
"@types/d3": "^7.4.3",
+4
View File
@@ -130,7 +130,9 @@
"disable_nations": "Disable Nations",
"instant_build": "Instant build",
"infinite_gold": "Infinite gold",
"donate_gold": "Donate gold",
"infinite_troops": "Infinite troops",
"donate_troops": "Donate troops",
"disable_nukes": "Disable Nukes",
"enables_title": "Enable Settings",
"start": "Start Game"
@@ -212,7 +214,9 @@
"disable_nations": "Disable Nations",
"instant_build": "Instant build",
"infinite_gold": "Infinite gold",
"donate_gold": "Donate gold",
"infinite_troops": "Infinite troops",
"donate_troops": "Donate troops",
"enables_title": "Enable Settings",
"player": "Player",
"players": "Players",
-6
View File
@@ -23,12 +23,6 @@
"name": "New Zealand",
"strength": 1
},
{
"coordinates": [686, 407],
"flag": "pg",
"name": "Papua New Guinea",
"strength": 1
},
{
"coordinates": [436, 407],
"flag": "tl",
+8 -8
View File
@@ -47,7 +47,7 @@ import {
import { createCanvas } from "./Utils";
import { createRenderer, GameRenderer } from "./graphics/GameRenderer";
export interface LobbyConfig {
export type LobbyConfig = {
serverConfig: ServerConfig;
pattern: string | undefined;
flag: string;
@@ -59,7 +59,7 @@ export interface LobbyConfig {
gameStartInfo?: GameStartInfo;
// GameRecord exists when replaying an archived game.
gameRecord?: GameRecord;
}
};
export function joinLobby(
eventBus: EventBus,
@@ -192,7 +192,7 @@ export class ClientGameRunner {
private lastMousePosition: { x: number; y: number } | null = null;
private lastMessageTime: number = 0;
private lastMessageTime = 0;
private connectionCheckInterval: NodeJS.Timeout | null = null;
constructor(
@@ -229,7 +229,7 @@ export class ClientGameRunner {
players,
// Not saving turns locally
[],
startTime(),
startTime() ?? 0,
Date.now(),
update.winner,
this.lobby.serverConfig,
@@ -364,7 +364,7 @@ export class ClientGameRunner {
this.transport.connect(onconnect, onmessage);
}
public stop(saveFullGame: boolean = false) {
public stop(saveFullGame = false) {
if (!this.isActive) return;
this.isActive = false;
@@ -420,7 +420,7 @@ export class ClientGameRunner {
const owner = this.gameView.owner(tile);
if (owner.isPlayer()) {
this.gameView.setFocusedPlayer(owner as PlayerView);
this.gameView.setFocusedPlayer(owner);
} else {
this.gameView.setFocusedPlayer(null);
}
@@ -623,7 +623,7 @@ export class ClientGameRunner {
if (this.gameView.isLand(tile)) {
const owner = this.gameView.owner(tile);
if (owner.isPlayer()) {
this.gameView.setFocusedPlayer(owner as PlayerView);
this.gameView.setFocusedPlayer(owner);
} else {
this.gameView.setFocusedPlayer(null);
}
@@ -637,7 +637,7 @@ export class ClientGameRunner {
.sort((a, b) => a.distSquared - b.distSquared);
if (units.length > 0) {
this.gameView.setFocusedPlayer(units[0].unit.owner() as PlayerView);
this.gameView.setFocusedPlayer(units[0].unit.owner());
} else {
this.gameView.setFocusedPlayer(null);
}
+15 -3
View File
@@ -1,4 +1,8 @@
import { UserMeResponse } from "../core/ApiSchemas";
import { z } from "zod";
import {
StripeCreateCheckoutSessionResponseSchema,
UserMeResponse,
} from "../core/ApiSchemas";
import { Cosmetics, CosmeticsSchema, Pattern } from "../core/CosmeticSchemas";
import { getApiBase, getAuthHeader } from "./jwt";
@@ -37,7 +41,7 @@ export async function handlePurchase(priceId: string) {
method: "POST",
headers: {
"Content-Type": "application/json",
authorization: getAuthHeader(),
"authorization": getAuthHeader(),
},
body: JSON.stringify({
priceId: priceId,
@@ -59,7 +63,15 @@ export async function handlePurchase(priceId: string) {
return;
}
const { url } = await response.json();
const json = await response.json();
const parsed = StripeCreateCheckoutSessionResponseSchema.safeParse(json);
if (!parsed.success) {
const error = z.prettifyError(parsed.error);
console.error("Invalid checkout session response:", error);
alert("Checkout failed. Please try again later.");
return;
}
const { url } = parsed.data;
// Redirect to Stripe checkout
window.location.href = url;
+2 -2
View File
@@ -1,11 +1,11 @@
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { renderPlayerFlag } from "../core/CustomFlag";
const flagKey: string = "flag";
const flagKey = "flag";
@customElement("flag-input")
export class FlagInput extends LitElement {
@state() public flag: string = "";
@state() public flag = "";
static styles = css`
@media (max-width: 768px) {
+1 -1
View File
@@ -9,7 +9,7 @@ export class FlagInputModal extends LitElement {
close: () => void;
};
@state() private search: string = "";
@state() private search = "";
createRenderRoot() {
return this;
+2 -1
View File
@@ -2,6 +2,7 @@ import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface Window {
adsbygoogle: unknown[];
}
@@ -108,7 +109,7 @@ const isElectron = () => {
if (
typeof navigator === "object" &&
typeof navigator.userAgent === "string" &&
navigator.userAgent.indexOf("Electron") >= 0
navigator.userAgent.includes("Electron")
) {
return true;
}
+60 -12
View File
@@ -18,6 +18,7 @@ import {
ClientInfo,
GameConfig,
GameInfo,
GameInfoSchema,
TeamCountConfig,
} from "../core/Schemas";
import { generateID } from "../core/Util";
@@ -39,17 +40,19 @@ export class HostLobbyModal extends LitElement {
@state() private disableNPCs = false;
@state() private gameMode: GameMode = GameMode.FFA;
@state() private teamCount: TeamCountConfig = 2;
@state() private bots: number = 400;
@state() private infiniteGold: boolean = false;
@state() private infiniteTroops: boolean = false;
@state() private instantBuild: boolean = false;
@state() private bots = 400;
@state() private infiniteGold = false;
@state() private donateGold = false;
@state() private infiniteTroops = false;
@state() private donateTroops = false;
@state() private instantBuild = false;
@state() private lobbyId = "";
@state() private copySuccess = false;
@state() private clients: ClientInfo[] = [];
@state() private useRandomMap: boolean = false;
@state() private useRandomMap = false;
@state() private disabledUnits: UnitType[] = [];
@state() private lobbyCreatorClientID: string = "";
@state() private lobbyIdVisible: boolean = true;
@state() private lobbyCreatorClientID = "";
@state() private lobbyIdVisible = true;
private playersInterval: NodeJS.Timeout | null = null;
// Add a new timer for debouncing bot changes
@@ -293,8 +296,8 @@ export class HostLobbyModal extends LitElement {
${typeof o === "string"
? translateText(`public_lobby.teams_${o}`)
: translateText("public_lobby.teams", {
num: o,
})}
num: o,
})}
</div>
</div>
`,
@@ -362,6 +365,38 @@ export class HostLobbyModal extends LitElement {
</div>
</label>
<label
for="donate-gold"
class="option-card ${this.donateGold ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="donate-gold"
@change=${this.handleDonateGoldChange}
.checked=${this.donateGold}
/>
<div class="option-card-title">
${translateText("host_modal.donate_gold")}
</div>
</label>
<label
for="donate-troops"
class="option-card ${this.donateTroops ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="donate-troops"
@change=${this.handleDonateTroopsChange}
.checked=${this.donateTroops}
/>
<div class="option-card-title">
${translateText("host_modal.donate_troops")}
</div>
</label>
<label
for="infinite-gold"
class="option-card ${this.infiniteGold ? "selected" : ""}"
@@ -406,9 +441,9 @@ export class HostLobbyModal extends LitElement {
style="display: flex; flex-wrap: wrap; justify-content: center; gap: 12px;"
>
${renderUnitTypeOptions({
disabledUnits: this.disabledUnits,
toggleUnit: this.toggleUnit.bind(this),
})}
disabledUnits: this.disabledUnits,
toggleUnit: this.toggleUnit.bind(this),
})}
</div>
</div>
</div>
@@ -562,11 +597,21 @@ export class HostLobbyModal extends LitElement {
this.putGameConfig();
}
private handleDonateGoldChange(e: Event) {
this.donateGold = Boolean((e.target as HTMLInputElement).checked);
this.putGameConfig();
}
private handleInfiniteTroopsChange(e: Event) {
this.infiniteTroops = Boolean((e.target as HTMLInputElement).checked);
this.putGameConfig();
}
private handleDonateTroopsChange(e: Event) {
this.donateTroops = Boolean((e.target as HTMLInputElement).checked);
this.putGameConfig();
}
private async handleDisableNPCsChange(e: Event) {
this.disableNPCs = Boolean((e.target as HTMLInputElement).checked);
console.log(`updating disable npcs to ${this.disableNPCs}`);
@@ -598,7 +643,9 @@ export class HostLobbyModal extends LitElement {
disableNPCs: this.disableNPCs,
bots: this.bots,
infiniteGold: this.infiniteGold,
donateGold: this.donateGold,
infiniteTroops: this.infiniteTroops,
donateTroops: this.donateTroops,
instantBuild: this.instantBuild,
gameMode: this.gameMode,
disabledUnits: this.disabledUnits,
@@ -671,6 +718,7 @@ export class HostLobbyModal extends LitElement {
},
})
.then((response) => response.json())
.then(GameInfoSchema.parse)
.then((data: GameInfo) => {
console.log(`got game info response: ${JSON.stringify(data)}`);
+8 -8
View File
@@ -115,17 +115,17 @@ export class AutoUpgradeEvent implements GameEvent {
}
export class InputHandler {
private lastPointerX: number = 0;
private lastPointerY: number = 0;
private lastPointerX = 0;
private lastPointerY = 0;
private lastPointerDownX: number = 0;
private lastPointerDownY: number = 0;
private lastPointerDownX = 0;
private lastPointerDownY = 0;
private pointers: Map<number, PointerEvent> = new Map();
private lastPinchDistance: number = 0;
private lastPinchDistance = 0;
private pointerDown: boolean = false;
private pointerDown = false;
private alternateView = false;
@@ -159,11 +159,11 @@ export class InputHandler {
groundAttack: "KeyG",
modifierKey: "ControlLeft",
altKey: "AltLeft",
...JSON.parse(localStorage.getItem("settings.keybinds") ?? "{}"),
...(JSON.parse(localStorage.getItem("settings.keybinds") ?? "{}") ?? {}),
};
// Mac users might have different keybinds
const isMac = /Mac/.test(navigator.userAgent);
const isMac = navigator.userAgent.includes("Mac");
if (isMac) {
this.keybinds.modifierKey = "MetaLeft"; // Use Command key on Mac
}
+14 -8
View File
@@ -1,12 +1,17 @@
import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { translateText } from "../client/Utils";
import { GameInfo, GameRecord } from "../core/Schemas";
import { GameInfo, GameInfoSchema } from "../core/Schemas";
import { generateID } from "../core/Util";
import {
WorkerApiArchivedGameLobbySchema,
WorkerApiGameIdExistsSchema,
} from "../core/WorkerSchemas";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { JoinLobbyEvent } from "./Main";
import "./components/baseComponents/Button";
import "./components/baseComponents/Modal";
@customElement("join-private-lobby-modal")
export class JoinPrivateLobbyModal extends LitElement {
@query("o-modal") private modalEl!: HTMLElement & {
@@ -14,7 +19,7 @@ export class JoinPrivateLobbyModal extends LitElement {
close: () => void;
};
@query("#lobbyIdInput") private lobbyIdInput!: HTMLInputElement;
@state() private message: string = "";
@state() private message = "";
@state() private hasJoined = false;
@state() private players: string[] = [];
@@ -105,7 +110,7 @@ export class JoinPrivateLobbyModal extends LitElement {
return this; // light DOM
}
public open(id: string = "") {
public open(id = "") {
this.modalEl?.open();
if (id) {
this.setLobbyId(id);
@@ -198,7 +203,8 @@ export class JoinPrivateLobbyModal extends LitElement {
headers: { "Content-Type": "application/json" },
});
const gameInfo = await response.json();
const json = await response.json();
const gameInfo = WorkerApiGameIdExistsSchema.parse(json);
if (gameInfo.exists) {
this.message = translateText("private_lobby.joined_waiting");
@@ -231,7 +237,8 @@ export class JoinPrivateLobbyModal extends LitElement {
headers: { "Content-Type": "application/json" },
});
const archiveData = await archiveResponse.json();
const json = await archiveResponse.json();
const archiveData = WorkerApiArchivedGameLobbySchema.parse(json);
if (
archiveData.success === false &&
@@ -247,13 +254,11 @@ export class JoinPrivateLobbyModal extends LitElement {
}
if (archiveData.exists) {
const gameRecord = archiveData.gameRecord as GameRecord;
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
gameID: lobbyId,
gameRecord: gameRecord,
gameRecord: archiveData.gameRecord,
clientID: generateID(),
} as JoinLobbyEvent,
bubbles: true,
@@ -281,6 +286,7 @@ export class JoinPrivateLobbyModal extends LitElement {
},
)
.then((response) => response.json())
.then(GameInfoSchema.parse)
.then((data: GameInfo) => {
this.players = data.clients?.map((p) => p.username) ?? [];
})
+27 -13
View File
@@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import "./LanguageModal";
@@ -35,13 +37,15 @@ import zh_CN from "../../resources/lang/zh-CN.json";
export class LangSelector extends LitElement {
@state() public translations: Record<string, string> | undefined;
@state() public defaultTranslations: Record<string, string> | undefined;
@state() public currentLang: string = "en";
@state() public currentLang = "en";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@state() private languageList: any[] = [];
@state() private showModal: boolean = false;
@state() private debugMode: boolean = false;
@state() private showModal = false;
@state() private debugMode = false;
private debugKeyPressed: boolean = false;
private debugKeyPressed = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private languageMap: Record<string, any> = {
ar,
bg,
@@ -123,6 +127,7 @@ export class LangSelector extends LitElement {
private loadLanguage(lang: string): Record<string, string> {
const language = this.languageMap[lang] ?? {};
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const flat = flattenTranslations(language);
return flat;
}
@@ -130,6 +135,7 @@ export class LangSelector extends LitElement {
private async loadLanguageList() {
try {
const data = this.languageMap;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let list: any[] = [];
const browserLang = new Intl.Locale(navigator.language).language;
@@ -146,6 +152,7 @@ export class LangSelector extends LitElement {
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let debugLang: any = null;
if (this.debugKeyPressed) {
debugLang = {
@@ -177,10 +184,12 @@ export class LangSelector extends LitElement {
list.sort((a, b) => a.en.localeCompare(b.en));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const finalList: any[] = [];
if (currentLangEntry) finalList.push(currentLangEntry);
if (englishEntry) finalList.push(englishEntry);
if (browserLangEntry) finalList.push(browserLangEntry);
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
finalList.push(...list);
if (debugLang) finalList.push(debugLang);
@@ -236,7 +245,9 @@ export class LangSelector extends LitElement {
components.forEach((tag) => {
document.querySelectorAll(tag).forEach((el) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (typeof (el as any).requestUpdate === "function") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(el as any).requestUpdate();
}
});
@@ -276,16 +287,16 @@ export class LangSelector extends LitElement {
this.languageList.find((l) => l.code === this.currentLang) ??
(this.currentLang === "debug"
? {
code: "debug",
native: "Debug",
en: "Debug",
svg: "xx",
}
code: "debug",
native: "Debug",
en: "Debug",
svg: "xx",
}
: {
native: "English",
en: "English",
svg: "uk_us_flag",
});
native: "English",
en: "English",
svg: "uk_us_flag",
});
return html`
<div class="container__row">
@@ -309,6 +320,7 @@ export class LangSelector extends LitElement {
.languageList=${this.languageList}
.currentLang=${this.currentLang}
@language-selected=${(e: CustomEvent) =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
this.changeLanguage(e.detail.lang)}
@close-modal=${() => (this.showModal = false)}
></language-modal>
@@ -317,6 +329,7 @@ export class LangSelector extends LitElement {
}
function flattenTranslations(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
obj: Record<string, any>,
parentKey = "",
result: Record<string, string> = {},
@@ -328,6 +341,7 @@ function flattenTranslations(
if (typeof value === "string") {
result[fullKey] = value;
} else if (value && typeof value === "object" && !Array.isArray(value)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
flattenTranslations(value, fullKey, result);
} else {
console.warn("Unknown type", typeof value, value);
+5 -1
View File
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { translateText } from "../client/Utils";
@@ -5,6 +6,7 @@ import { translateText } from "../client/Utils";
@customElement("language-modal")
export class LanguageModal extends LitElement {
@property({ type: Boolean }) visible = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@property({ type: Array }) languageList: any[] = [];
@property({ type: String }) currentLang = "en";
@@ -105,7 +107,9 @@ export class LanguageModal extends LitElement {
return html`
<button
class="${buttonClasses}"
@click=${() => this.selectLanguage(lang.code)}
@click=${() =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
this.selectLanguage(lang.code)}
>
<img
src="/flags/${lang.svg}.svg"
+25 -10
View File
@@ -1,19 +1,34 @@
import { GameConfig, GameID, GameRecord } from "../core/Schemas";
import { z } from "zod";
import {
GameConfig,
GameConfigSchema,
GameID,
GameRecord,
GameRecordSchema,
ID,
} from "../core/Schemas";
import { replacer } from "../core/Util";
export interface LocalStatsData {
[key: GameID]: {
lobby: Partial<GameConfig>;
const LocalStatsDataSchema = z.record(
ID,
z.object({
lobby: GameConfigSchema.partial(),
// Only once the game is over
gameRecord?: GameRecord;
};
}
gameRecord: GameRecordSchema.optional(),
}),
);
type LocalStatsData = z.infer<typeof LocalStatsDataSchema>;
let _startTime: number;
let _startTime: number | undefined;
function getStats(): LocalStatsData {
const statsStr = localStorage.getItem("game-records");
return statsStr ? JSON.parse(statsStr) : {};
try {
return LocalStatsDataSchema.parse(
JSON.parse(localStorage.getItem("game-records") ?? "{}"),
);
} catch (e) {
return {};
}
}
function save(stats: LocalStatsData) {
+1 -1
View File
@@ -169,7 +169,7 @@ export class LocalServer {
});
}
public endGame(saveFullGame: boolean = false) {
public endGame(saveFullGame = false) {
console.log("local server ending game");
clearInterval(this.turnCheckInterval);
if (this.isReplay) {
+11 -4
View File
@@ -43,6 +43,7 @@ import { discordLogin, getUserMe, isLoggedIn, logOut } from "./jwt";
import "./styles.css";
declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface Window {
PageOS: {
session: {
@@ -55,6 +56,7 @@ declare global {
spaAddAds: (ads: Array<{ type: string; selectorId: string }>) => void;
destroyUnits: (adType: string) => void;
settings?: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
slots?: any;
};
spaNewPage: (url: string) => void;
@@ -62,13 +64,14 @@ declare global {
}
// Extend the global interfaces to include your custom events
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface DocumentEventMap {
"join-lobby": CustomEvent<JoinLobbyEvent>;
"kick-player": CustomEvent;
"kick-player": CustomEvent<KickPlayerEvent>;
}
}
export interface JoinLobbyEvent {
export type JoinLobbyEvent = {
clientID: string;
// Multiplayer games only have gameID, gameConfig is not known until game starts.
gameID: string;
@@ -76,7 +79,11 @@ export interface JoinLobbyEvent {
gameStartInfo?: GameStartInfo;
// GameRecord exists when replaying an archived game.
gameRecord?: GameRecord;
}
};
export type KickPlayerEvent = {
target: string;
};
class Client {
private gameStop: (() => void) | null = null;
@@ -548,7 +555,7 @@ class Client {
this.publicLobby.leaveLobby();
}
private handleKickPlayer(event: CustomEvent) {
private handleKickPlayer(event: CustomEvent<KickPlayerEvent>) {
const { target } = event.detail;
// Forward to eventBus if available
+9 -2
View File
@@ -1,3 +1,10 @@
import { z } from "zod";
const LockSchema = z.object({
owner: z.string(),
timestamp: z.number(),
});
export class MultiTabDetector {
private readonly tabId = `${Date.now()}-${Math.random()}`;
private readonly lockKey = "multi-tab-lock";
@@ -60,7 +67,7 @@ export class MultiTabDetector {
if (e.key === this.lockKey && e.newValue) {
let other: { owner: string; timestamp: number };
try {
other = JSON.parse(e.newValue);
other = LockSchema.parse(JSON.parse(e.newValue));
} catch (e) {
console.error("Failed to parse lock", e);
return;
@@ -99,7 +106,7 @@ export class MultiTabDetector {
const raw = localStorage.getItem(this.lockKey);
if (!raw) return null;
try {
return JSON.parse(raw);
return LockSchema.parse(JSON.parse(raw));
} catch (e) {
console.error("Failed to parse lock", raw, e);
return null;
+1 -1
View File
@@ -32,7 +32,7 @@ export class NewsModal extends LitElement {
@property({ type: String }) markdown = "Loading...";
private initialized: boolean = false;
private initialized = false;
static styles = css`
:host {
+8 -6
View File
@@ -1,6 +1,7 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { translateText } from "../client/Utils";
import { ApiPublicLobbiesResponseSchema } from "../core/ExpressSchemas";
import { GameMapType, GameMode } from "../core/game/Game";
import { GameID, GameInfo } from "../core/Schemas";
import { generateID } from "../core/Util";
@@ -10,12 +11,12 @@ import { terrainMapFileLoader } from "./TerrainMapFileLoader";
@customElement("public-lobby")
export class PublicLobby extends LitElement {
@state() private lobbies: GameInfo[] = [];
@state() public isLobbyHighlighted: boolean = false;
@state() private isButtonDebounced: boolean = false;
@state() public isLobbyHighlighted = false;
@state() private isButtonDebounced = false;
@state() private mapImages: Map<GameID, string> = new Map();
private lobbiesInterval: number | null = null;
private currLobby: GameInfo | null = null;
private debounceDelay: number = 750;
private debounceDelay = 750;
private lobbyIDToStart = new Map<GameID, number>();
createRenderRoot() {
@@ -77,7 +78,8 @@ export class PublicLobby extends LitElement {
const response = await fetch(`/api/public_lobbies`);
if (!response.ok)
throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
const json = await response.json();
const data = ApiPublicLobbiesResponseSchema.parse(json);
return data.lobbies;
} catch (error) {
console.error("Error fetching lobbies:", error);
@@ -154,8 +156,8 @@ export class PublicLobby extends LitElement {
? typeof teamCount === "string"
? translateText(`public_lobby.teams_${teamCount}`)
: translateText("public_lobby.teams", {
num: teamCount ?? 0,
})
num: teamCount ?? 0,
})
: translateText("game_mode.ffa")}</span
>
<span
+10 -6
View File
@@ -34,12 +34,14 @@ export class SinglePlayerModal extends LitElement {
};
@state() private selectedMap: GameMapType = GameMapType.World;
@state() private selectedDifficulty: Difficulty = Difficulty.Medium;
@state() private disableNPCs: boolean = false;
@state() private bots: number = 400;
@state() private infiniteGold: boolean = false;
@state() private infiniteTroops: boolean = false;
@state() private instantBuild: boolean = false;
@state() private useRandomMap: boolean = false;
@state() private disableNPCs = false;
@state() private bots = 400;
@state() private infiniteGold = false;
@state() private donateGold = false;
@state() private infiniteTroops = false;
@state() private donateTroops = false;
@state() private instantBuild = false;
@state() private useRandomMap = false;
@state() private gameMode: GameMode = GameMode.FFA;
@state() private teamCount: TeamCountConfig = 2;
@@ -451,7 +453,9 @@ export class SinglePlayerModal extends LitElement {
disableNPCs: this.disableNPCs,
bots: this.bots,
infiniteGold: this.infiniteGold,
donateGold: this.donateGold,
infiniteTroops: this.infiniteTroops,
donateTroops: this.donateTroops,
instantBuild: this.instantBuild,
disabledUnits: this.disabledUnits
.map((u) => Object.values(UnitType).find((ut) => ut === u))
+2 -2
View File
@@ -19,7 +19,7 @@ export class TerritoryPatternsModal extends LitElement {
};
public previewButton: HTMLElement | null = null;
public buttonWidth: number = 150;
public buttonWidth = 150;
@state() private selectedPattern: string | undefined;
@@ -172,7 +172,7 @@ export class TerritoryPatternsModal extends LitElement {
}}
>
${translateText("territory_patterns.purchase")}
(${pattern.product!.price})
(${pattern.product.price})
</button>
`
: null}
+2 -1
View File
@@ -328,6 +328,7 @@ export class Transport {
};
this.socket.onmessage = (event: MessageEvent) => {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const parsed = JSON.parse(event.data);
const result = ServerMessageSchema.safeParse(parsed);
if (!result.success) {
@@ -383,7 +384,7 @@ export class Transport {
} satisfies ClientJoinMessage);
}
leaveGame(saveFullGame: boolean = false) {
leaveGame(saveFullGame = false) {
if (this.isLocal) {
this.localServer.endGame(saveFullGame);
return;
+10 -17
View File
@@ -1,5 +1,6 @@
import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { z } from "zod";
import { translateText } from "../client/Utils";
import { UserSettings } from "../core/game/UserSettings";
import "./components/baseComponents/setting/SettingKeybind";
@@ -8,6 +9,8 @@ import "./components/baseComponents/setting/SettingNumber";
import "./components/baseComponents/setting/SettingSlider";
import "./components/baseComponents/setting/SettingToggle";
const KeybindSchema = z.record(z.string(), z.string());
@customElement("user-setting")
export class UserSettingModal extends LitElement {
private userSettings: UserSettings = new UserSettings();
@@ -25,7 +28,7 @@ export class UserSettingModal extends LitElement {
const savedKeybinds = localStorage.getItem("settings.keybinds");
if (savedKeybinds) {
try {
this.keybinds = JSON.parse(savedKeybinds);
this.keybinds = KeybindSchema.parse(JSON.parse(savedKeybinds));
} catch (e) {
console.warn("Invalid keybinds JSON:", e);
}
@@ -179,16 +182,6 @@ export class UserSettingModal extends LitElement {
}
}
private sliderTroopRatio(e: CustomEvent<{ value: number }>) {
const value = e.detail?.value;
if (typeof value === "number") {
const ratio = value / 100;
localStorage.setItem("settings.troopRatio", ratio.toString());
} else {
console.warn("Slider event missing detail.value", e);
}
}
private toggleTerritoryPatterns(e: CustomEvent<{ checked: boolean }>) {
const enabled = e.detail?.checked;
if (typeof enabled !== "boolean") return;
@@ -241,8 +234,8 @@ export class UserSettingModal extends LitElement {
<button
class="w-1/2 text-center px-3 py-1 rounded-l
${this.settingsMode === "basic"
? "bg-white/10 text-white"
: "bg-transparent text-gray-400"}"
? "bg-white/10 text-white"
: "bg-transparent text-gray-400"}"
@click=${() => (this.settingsMode = "basic")}
>
${translateText("user_setting.tab_basic")}
@@ -250,8 +243,8 @@ export class UserSettingModal extends LitElement {
<button
class="w-1/2 text-center px-3 py-1 rounded-r
${this.settingsMode === "keybinds"
? "bg-white/10 text-white"
: "bg-transparent text-gray-400"}"
? "bg-white/10 text-white"
: "bg-transparent text-gray-400"}"
@click=${() => (this.settingsMode = "keybinds")}
>
${translateText("user_setting.tab_keybinds")}
@@ -386,7 +379,7 @@ export class UserSettingModal extends LitElement {
max="100"
value="40"
easter="true"
@change=${(e: CustomEvent) => {
@change=${(e: CustomEvent<{ value: unknown }>) => {
const value = e.detail?.value;
if (value !== undefined) {
console.log("Changed:", value);
@@ -405,7 +398,7 @@ export class UserSettingModal extends LitElement {
min="0"
max="1000"
easter="true"
@change=${(e: CustomEvent) => {
@change=${(e: CustomEvent<{ value: unknown }>) => {
const value = e.detail?.value;
if (value !== undefined) {
console.log("Changed:", value);
+4 -4
View File
@@ -8,13 +8,13 @@ import {
validateUsername,
} from "../core/validations/username";
const usernameKey: string = "username";
const usernameKey = "username";
@customElement("username-input")
export class UsernameInput extends LitElement {
@state() private username: string = "";
@property({ type: String }) validationError: string = "";
private _isValid: boolean = true;
@state() private username = "";
@property({ type: String }) validationError = "";
private _isValid = true;
private userSettings: UserSettings = new UserSettings();
// Remove static styles since we're using Tailwind
+12 -2
View File
@@ -57,6 +57,7 @@ export function generateCryptoRandomUUID(): string {
// Fallback using crypto.getRandomValues
if (crypto !== undefined && "getRandomValues" in crypto) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
return (([1e7] as any) + -1e3 + -4e3 + -8e3 + -1e11).replace(
/[018]/g,
(c: number): string =>
@@ -83,8 +84,11 @@ export const translateText = (
key: string,
params: Record<string, string | number> = {},
): string => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
const self = translateText as any;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
self.formatterCache ??= new Map();
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
self.lastLang ??= null;
const langSelector = document.querySelector("lang-selector") as LangSelector;
@@ -100,8 +104,11 @@ export const translateText = (
return key;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (self.lastLang !== langSelector.currentLang) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
self.formatterCache.clear();
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
self.lastLang = langSelector.currentLang;
}
@@ -122,13 +129,16 @@ export const translateText = (
? "en"
: langSelector.currentLang;
const cacheKey = `${key}:${locale}:${message}`;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
let formatter = self.formatterCache.get(cacheKey);
if (!formatter) {
formatter = new IntlMessageFormat(message, locale);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
self.formatterCache.set(cacheKey, formatter);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
return formatter.format(params) as string;
} catch (e) {
console.warn("ICU format error", e);
@@ -192,7 +202,7 @@ export function getMessageTypeClasses(type: MessageType): string {
}
export function getModifierKey(): string {
const isMac = /Mac/.test(navigator.userAgent);
const isMac = navigator.userAgent.includes("Mac");
if (isMac) {
return "⌘"; // Command key
} else {
@@ -201,7 +211,7 @@ export function getModifierKey(): string {
}
export function getAltKey(): string {
const isMac = /Mac/.test(navigator.userAgent);
const isMac = navigator.userAgent.includes("Mac");
if (isMac) {
return "⌥"; // Option key
} else {
+1 -1
View File
@@ -41,7 +41,7 @@ export const MapDescription: Record<keyof typeof GameMapType, string> = {
export class MapDisplay extends LitElement {
@property({ type: String }) mapKey = "";
@property({ type: Boolean }) selected = false;
@property({ type: String }) translation: string = "";
@property({ type: String }) translation = "";
@state() private mapWebpPath: string | null = null;
@state() private mapName: string | null = null;
@state() private isLoading = true;
+1 -1
View File
@@ -3,7 +3,7 @@ import { customElement, property } from "lit/decorators";
@customElement("modal-overlay")
export class ModalOverlay extends LitElement {
@property({ reflect: true }) public visible: boolean = false;
@property({ reflect: true }) public visible = false;
static styles = css`
.overlay {
@@ -28,17 +28,6 @@ export class SettingSlider extends LitElement {
);
}
private handleSliderChange(e: Event) {
const detail = (e as CustomEvent)?.detail;
if (!detail || detail.value === undefined) {
console.warn("Invalid slider change event", e);
return;
}
const value = detail.value;
console.log("Slider changed to", value);
}
private updateSliderStyle(slider: HTMLInputElement) {
const percent = ((this.value - this.min) / (this.max - this.min)) * 100;
slider.style.background = `linear-gradient(to right, #2196f3 ${percent}%, #444 ${percent}%)`;
+4 -4
View File
@@ -1,15 +1,15 @@
export class AnimatedSprite {
private frameHeight: number;
private currentFrame: number = 0;
private elapsedTime: number = 0;
private active: boolean = true;
private currentFrame = 0;
private elapsedTime = 0;
private active = true;
constructor(
private image: CanvasImageSource,
private frameWidth: number,
private frameCount: number,
private frameDuration: number, // in milliseconds
private looping: boolean = false,
private looping = false,
private originX: number,
private originY: number,
) {
+1 -1
View File
@@ -246,7 +246,7 @@ export function createRenderer(
eventBus,
game,
transformHandler,
emojiTable as EmojiTable,
emojiTable,
buildMenu,
uiState,
playerPanel,
+8 -8
View File
@@ -1,17 +1,17 @@
import { Cell, Game, NameViewData, Player } from "../../core/game/Game";
import { calculateBoundingBox } from "../../core/Util";
export interface Point {
export type Point = {
x: number;
y: number;
}
};
export interface Rectangle {
export type Rectangle = {
x: number;
y: number;
width: number;
height: number;
}
};
export function placeName(game: Game, player: Player): NameViewData {
const boundingBox =
@@ -81,9 +81,9 @@ export function createGrid(
const width = scaledBoundingBox.max.x - scaledBoundingBox.min.x + 1;
const height = scaledBoundingBox.max.y - scaledBoundingBox.min.y + 1;
const grid: boolean[][] = Array(width)
.fill(null)
.map(() => Array(height).fill(false));
const grid: boolean[][] = Array<Array<boolean>>(width)
.fill(null as unknown as boolean[])
.map(() => Array<boolean>(height).fill(false));
for (let x = scaledBoundingBox.min.x; x <= scaledBoundingBox.max.x; x++) {
for (let y = scaledBoundingBox.min.y; y <= scaledBoundingBox.max.y; y++) {
@@ -102,7 +102,7 @@ export function createGrid(
export function findLargestInscribedRectangle(grid: boolean[][]): Rectangle {
const rows = grid[0].length;
const cols = grid.length;
const heights: number[] = new Array(cols).fill(0);
const heights: number[] = new Array<number>(cols).fill(0);
let largestRect: Rectangle = { x: 0, y: 0, width: 0, height: 0 };
for (let row = 0; row < rows; row++) {
+1 -1
View File
@@ -7,7 +7,7 @@ export class ProgressBar {
private y: number,
private w: number,
private h: number,
private progress: number = 0, // Progress from 0 to 1
private progress = 0, // Progress from 0 to 1
) {
this.setProgress(progress);
}
+5 -5
View File
@@ -13,10 +13,10 @@ export const CAMERA_MAX_SPEED = 15;
export const CAMERA_SMOOTHING = 0.03;
export class TransformHandler {
public scale: number = 1.8;
public scale = 1.8;
private _boundingRect: DOMRect;
private offsetX: number = -350;
private offsetY: number = -200;
private offsetX = -350;
private offsetY = -200;
private lastGoToCallTime: number | null = null;
private target: Cell | null;
@@ -267,7 +267,7 @@ export class TransformHandler {
this.target = null;
}
override(x: number = 0, y: number = 0, s: number = 1) {
override(x = 0, y = 0, s = 1) {
//hardset view position
this.clearTarget();
this.offsetX = x;
@@ -276,7 +276,7 @@ export class TransformHandler {
this.changed = true;
}
centerAll(fit: number = 1) {
centerAll(fit = 1) {
//position entire map centered on the screen
const vpWidth = this.boundingRect().width;
+2 -2
View File
@@ -1,3 +1,3 @@
export interface UIState {
export type UIState = {
attackRatio: number;
}
};
+2 -2
View File
@@ -1,6 +1,6 @@
export interface Fx {
export type Fx = {
renderTick(duration: number, ctx: CanvasRenderingContext2D): boolean;
}
};
export enum FxType {
MiniFire = "MiniFire",
+1 -1
View File
@@ -7,7 +7,7 @@ import { FadeFx, SpriteFx } from "./SpriteFx";
* Shockwave effect: draw a growing 1px white circle
*/
export class ShockwaveFx implements Fx {
private lifeTime: number = 0;
private lifeTime = 0;
constructor(
private x: number,
private y: number,
+1 -5
View File
@@ -4,11 +4,7 @@ import { AnimatedSprite } from "../AnimatedSprite";
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
import { Fx, FxType } from "./Fx";
function fadeInOut(
t: number,
fadeIn: number = 0.3,
fadeOut: number = 0.7,
): number {
function fadeInOut(t: number, fadeIn = 0.3, fadeOut = 0.7): number {
if (t < fadeIn) {
const f = t / fadeIn; // Map to [0, 1]
return f * f;
+3 -3
View File
@@ -1,15 +1,15 @@
import { Fx } from "./Fx";
export class TextFx implements Fx {
private lifeTime: number = 0;
private lifeTime = 0;
constructor(
private text: string,
private x: number,
private y: number,
private duration: number,
private riseDistance: number = 30,
private font: string = "11px sans-serif",
private riseDistance = 30,
private font = "11px sans-serif",
private color: { r: number; g: number; b: number } = {
r: 255,
g: 255,
+1 -1
View File
@@ -80,7 +80,7 @@ export class AlertFrame extends LitElement implements Layer {
this.game
.updatesSinceLastTick()
?.[GameUpdateType.BrokeAlliance]?.forEach((update) => {
this.onBrokeAllianceUpdate(update as BrokeAllianceUpdate);
this.onBrokeAllianceUpdate(update);
});
}
+2 -2
View File
@@ -35,13 +35,13 @@ import { renderNumber } from "../../Utils";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
export interface BuildItemDisplay {
export type BuildItemDisplay = {
unitType: UnitType;
icon: string;
description?: string;
key?: string;
countable?: boolean;
}
};
export const buildTable: BuildItemDisplay[][] = [
[
+5 -5
View File
@@ -12,22 +12,22 @@ import { GameView } from "../../../core/game/GameView";
import { onlyImages } from "../../../core/Util";
import { Layer } from "./Layer";
interface ChatEvent {
type ChatEvent = {
description: string;
unsafeDescription?: boolean;
createdAt: number;
highlight?: boolean;
}
};
@customElement("chat-display")
export class ChatDisplay extends LitElement implements Layer {
public eventBus: EventBus;
public game: GameView;
private active: boolean = false;
private active = false;
@state() private _hidden: boolean = false;
@state() private newEvents: number = 0;
@state() private _hidden = false;
@state() private newEvents = 0;
@state() private chatEvents: ChatEvent[] = [];
private toggleHidden() {
+2 -2
View File
@@ -32,9 +32,9 @@ export class ChatModal extends LitElement {
private players: PlayerView[] = [];
private playerSearchQuery: string = "";
private playerSearchQuery = "";
private previewText: string | null = null;
private requiresPlayerSelection: boolean = false;
private requiresPlayerSelection = false;
private selectedCategory: string | null = null;
private selectedPhraseText: string | null = null;
private selectedPhraseTemplate: string | null = null;
+2 -2
View File
@@ -18,7 +18,7 @@ export class ControlPanel extends LitElement implements Layer {
public uiState: UIState;
@state()
private attackRatio: number = 0.2;
private attackRatio = 0.2;
@state()
private _maxTroops: number;
@@ -35,7 +35,7 @@ export class ControlPanel extends LitElement implements Layer {
@state()
private _gold: Gold;
private _troopRateIsIncreasing: boolean = true;
private _troopRateIsIncreasing = true;
private _lastTroopIncreaseRate: number;
+47 -47
View File
@@ -1,4 +1,4 @@
import { html, LitElement } from "lit";
import { html, LitElement, TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import { DirectiveResult } from "lit/directive.js";
import { unsafeHTML, UnsafeHTMLDirective } from "lit/directives/unsafe-html.js";
@@ -48,7 +48,7 @@ import {
import { getMessageTypeClasses, translateText } from "../../Utils";
interface GameEvent {
type GameEvent = {
description: string;
unsafeDescription?: boolean;
buttons?: {
@@ -66,14 +66,14 @@ interface GameEvent {
duration?: Tick;
focusID?: number;
unitView?: UnitView;
}
};
@customElement("events-display")
export class EventsDisplay extends LitElement implements Layer {
public eventBus: EventBus;
public game: GameView;
private active: boolean = false;
private active = false;
private events: GameEvent[] = [];
// allianceID -> last checked at tick
@@ -82,11 +82,11 @@ export class EventsDisplay extends LitElement implements Layer {
@state() private outgoingAttacks: AttackUpdate[] = [];
@state() private outgoingLandAttacks: AttackUpdate[] = [];
@state() private outgoingBoats: UnitView[] = [];
@state() private _hidden: boolean = false;
@state() private _isVisible: boolean = false;
@state() private newEvents: number = 0;
@state() private _hidden = false;
@state() private _isVisible = false;
@state() private newEvents = 0;
@state() private latestGoldAmount: bigint | null = null;
@state() private goldAmountAnimating: boolean = false;
@state() private goldAmountAnimating = false;
private goldAmountTimeoutId: ReturnType<typeof setTimeout> | null = null;
@state() private eventsFilters: Map<MessageCategory, boolean> = new Map([
[MessageCategory.ATTACK, false],
@@ -96,7 +96,7 @@ export class EventsDisplay extends LitElement implements Layer {
]);
private renderButton(options: {
content: any; // Can be string, TemplateResult, or other renderable content
content: string | TemplateResult | DirectiveResult<typeof UnsafeHTMLDirective>;
onClick?: () => void;
className?: string;
disabled?: boolean;
@@ -261,7 +261,7 @@ export class EventsDisplay extends LitElement implements Layer {
this.alliancesCheckedAt.set(alliance.id, this.game.ticks());
const other = this.game.player(alliance.other) as PlayerView;
const other = this.game.player(alliance.other);
if (!other.isAlive()) continue;
this.addEvent({
@@ -393,7 +393,7 @@ export class EventsDisplay extends LitElement implements Layer {
}
}
let otherPlayerDiplayName: string = "";
let otherPlayerDiplayName = "";
if (event.recipient !== null) {
//'recipient' parameter contains sender ID or recipient ID
const player = this.game.player(event.recipient);
@@ -529,8 +529,8 @@ export class EventsDisplay extends LitElement implements Layer {
traitorDuration === 1
? translateText("events_display.duration_second")
: translateText("events_display.duration_seconds_plural", {
seconds: traitorDuration,
});
seconds: traitorDuration,
});
this.addEvent({
description: translateText("events_display.betrayal_description", {
@@ -768,11 +768,11 @@ export class EventsDisplay extends LitElement implements Layer {
})}
${!attack.retreating
? this.renderButton({
content: "❌",
onClick: () => this.emitCancelAttackIntent(attack.id),
className: "text-left flex-shrink-0",
disabled: attack.retreating,
})
content: "❌",
onClick: () => this.emitCancelAttackIntent(attack.id),
className: "text-left flex-shrink-0",
disabled: attack.retreating,
})
: html`<span class="flex-shrink-0 text-blue-400"
>(${translateText(
"events_display.retreating",
@@ -803,12 +803,12 @@ export class EventsDisplay extends LitElement implements Layer {
})}
${!landAttack.retreating
? this.renderButton({
content: "❌",
onClick: () =>
this.emitCancelAttackIntent(landAttack.id),
className: "text-left flex-shrink-0",
disabled: landAttack.retreating,
})
content: "❌",
onClick: () =>
this.emitCancelAttackIntent(landAttack.id),
className: "text-left flex-shrink-0",
disabled: landAttack.retreating,
})
: html`<span class="flex-shrink-0 text-blue-400"
>(${translateText(
"events_display.retreating",
@@ -840,11 +840,11 @@ export class EventsDisplay extends LitElement implements Layer {
})}
${!boat.retreating()
? this.renderButton({
content: "❌",
onClick: () => this.emitBoatCancelIntent(boat.id()),
className: "text-left flex-shrink-0",
disabled: boat.retreating(),
})
content: "❌",
onClick: () => this.emitBoatCancelIntent(boat.id()),
className: "text-left flex-shrink-0",
disabled: boat.retreating(),
})
: html`<span class="flex-shrink-0 text-blue-400"
>(${translateText(
"events_display.retreating",
@@ -1033,24 +1033,24 @@ export class EventsDisplay extends LitElement implements Layer {
>
${event.focusID
? this.renderButton({
content: this.getEventDescription(event),
onClick: () => {
event.focusID &&
content: this.getEventDescription(event),
onClick: () => {
event.focusID &&
this.emitGoToPlayerEvent(event.focusID);
},
className: "text-left",
})
},
className: "text-left",
})
: event.unitView
? this.renderButton({
content: this.getEventDescription(event),
onClick: () => {
event.unitView &&
content: this.getEventDescription(event),
onClick: () => {
event.unitView &&
this.emitGoToUnitEvent(
event.unitView,
);
},
className: "text-left",
})
},
className: "text-left",
})
: this.getEventDescription(event)}
<!-- Events with buttons (Alliance requests) -->
${event.buttons
@@ -1061,12 +1061,12 @@ export class EventsDisplay extends LitElement implements Layer {
<button
class="inline-block px-3 py-1 text-white rounded text-md md:text-sm cursor-pointer transition-colors duration-300
${btn.className.includes("btn-info")
? "bg-blue-500 hover:bg-blue-600"
: btn.className.includes(
"btn-gray",
)
? "bg-gray-500 hover:bg-gray-600"
: "bg-green-600 hover:bg-green-700"}"
? "bg-blue-500 hover:bg-blue-600"
: btn.className.includes(
"btn-gray",
)
? "bg-gray-500 hover:bg-gray-600"
: "bg-green-600 hover:bg-green-700"}"
@click=${() => {
btn.action();
if (!btn.preventClose) {
+9 -9
View File
@@ -14,29 +14,29 @@ export class FPSDisplay extends LitElement implements Layer {
public userSettings!: UserSettings;
@state()
private currentFPS: number = 0;
private currentFPS = 0;
@state()
private averageFPS: number = 0;
private averageFPS = 0;
@state()
private frameTime: number = 0;
private frameTime = 0;
@state()
private isVisible: boolean = false;
private isVisible = false;
@state()
private isDragging: boolean = false;
private isDragging = false;
@state()
private position: { x: number; y: number } = { x: 50, y: 20 }; // Percentage values
private frameCount: number = 0;
private lastTime: number = 0;
private frameCount = 0;
private lastTime = 0;
private frameTimes: number[] = [];
private fpsHistory: number[] = [];
private lastSecondTime: number = 0;
private framesThisSecond: number = 0;
private lastSecondTime = 0;
private framesThisSecond = 0;
private dragStart: { x: number; y: number } = { x: 0, y: 0 };
static styles = css`
+2 -2
View File
@@ -20,8 +20,8 @@ export class FxLayer implements Layer {
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private lastRefresh: number = 0;
private refreshRate: number = 10;
private lastRefresh = 0;
private refreshRate = 10;
private theme: Theme;
private animatedSpriteLoader: AnimatedSpriteLoader =
new AnimatedSpriteLoader();
@@ -22,19 +22,19 @@ export class GameRightSidebar extends LitElement implements Layer {
public eventBus: EventBus;
@state()
private _isSinglePlayer: boolean = false;
private _isSinglePlayer = false;
@state()
private _isReplayVisible: boolean = false;
private _isReplayVisible = false;
@state()
private _isVisible: boolean = true;
private _isVisible = true;
@state()
private isPaused: boolean = false;
private isPaused = false;
@state()
private timer: number = 0;
private timer = 0;
private hasWinner = false;
@@ -99,7 +99,9 @@ export class GameRightSidebar extends LitElement implements Layer {
}
private onSettingsButtonClick() {
this.eventBus.emit(new ShowSettingsModalEvent(true));
this.eventBus.emit(
new ShowSettingsModalEvent(true, this._isSinglePlayer, this.isPaused),
);
}
render() {
+7 -7
View File
@@ -13,16 +13,16 @@ export class GutterAdModal extends LitElement implements Layer {
public eventBus: EventBus;
@state()
private isVisible: boolean = false;
private isVisible = false;
@state()
private adLoaded: boolean = false;
private adLoaded = false;
private leftAdType: string = "left_rail";
private rightAdType: string = "right_rail";
private leftContainerId: string = "gutter-ad-container-left";
private rightContainerId: string = "gutter-ad-container-right";
private margin: string = "10px";
private leftAdType = "left_rail";
private rightAdType = "right_rail";
private leftContainerId = "gutter-ad-container-left";
private rightContainerId = "gutter-ad-container-right";
private margin = "10px";
// Override createRenderRoot to disable shadow DOM
createRenderRoot() {
+2 -2
View File
@@ -1,7 +1,7 @@
export interface Layer {
export type Layer = {
init?: () => void;
tick?: () => void;
renderLayer?: (context: CanvasRenderingContext2D) => void;
shouldTransform?: () => boolean;
redraw?: () => void;
}
};
+2 -2
View File
@@ -7,7 +7,7 @@ import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { renderNumber } from "../../Utils";
import { Layer } from "./Layer";
interface Entry {
type Entry = {
name: string;
position: number;
score: string;
@@ -15,7 +15,7 @@ interface Entry {
troops: string;
isMyPlayer: boolean;
player: PlayerView;
}
};
export class GoToPlayerEvent implements GameEvent {
constructor(public player: PlayerView) {}
+1 -1
View File
@@ -110,7 +110,7 @@ export class MainRadialMenu extends LitElement implements Layer {
this.buildMenu.playerActions = actions;
const tileOwner = this.game.owner(tile);
const recipient = tileOwner.isPlayer() ? (tileOwner as PlayerView) : null;
const recipient = tileOwner.isPlayer() ? tileOwner : null;
if (myPlayer && recipient) {
this.chatIntegration.setupChatModal(myPlayer, recipient);
+6 -6
View File
@@ -13,12 +13,12 @@ export class MultiTabModal extends LitElement implements Layer {
private detector: MultiTabDetector;
@property({ type: Number }) duration: number = 5000;
@state() private countdown: number = 5;
@state() private isVisible: boolean = false;
@state() private fakeIp: string = "";
@state() private deviceFingerprint: string = "";
@state() private reported: boolean = true;
@property({ type: Number }) duration = 5000;
@state() private countdown = 5;
@state() private isVisible = false;
@state() private fakeIp = "";
@state() private deviceFingerprint = "";
@state() private reported = true;
private intervalId?: number;
+2 -2
View File
@@ -59,7 +59,7 @@ export class NameLayer implements Layer {
private firstPlace: PlayerView | null = null;
private theme: Theme = this.game.config().theme();
private userSettings: UserSettings = new UserSettings();
private isVisible: boolean = true;
private isVisible = true;
constructor(
private game: GameView,
@@ -617,7 +617,7 @@ export class NameLayer implements Layer {
src: string,
size: number,
id: string,
center: boolean = false,
center = false,
): HTMLImageElement {
const icon = document.createElement("img");
icon.src = src;
+5 -5
View File
@@ -48,23 +48,23 @@ export class OptionsMenu extends LitElement implements Layer {
private userSettings: UserSettings = new UserSettings();
@state()
private showPauseButton: boolean = true;
private showPauseButton = true;
@state()
private isPaused: boolean = false;
private isPaused = false;
@state()
private timer: number = 0;
private timer = 0;
@state()
private showSettings: boolean = false;
private showSettings = false;
private isVisible = false;
private hasWinner = false;
@state()
private alternateView: boolean = false;
private alternateView = false;
private onTerrainButtonClick() {
this.alternateView = !this.alternateView;
@@ -97,8 +97,8 @@ export class PlayerActionHandler {
this.eventBus.emit(new SendEmojiIntentEvent(targetPlayer, emojiIndex));
}
handleQuickChat(recipient: PlayerView, chatKey: string, params: any = {}) {
this.eventBus.emit(new SendQuickChatEvent(recipient, chatKey, params));
handleQuickChat(recipient: PlayerView, chatKey: string, target?: PlayerID) {
this.eventBus.emit(new SendQuickChatEvent(recipient, chatKey, target));
}
handleDeleteUnit(unitId: number) {
+73 -60
View File
@@ -60,12 +60,14 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
private unit: UnitView | null = null;
@state()
private _isInfoVisible: boolean = false;
private _isInfoVisible = false;
private _isActive = false;
private lastMouseUpdate = 0;
private showDetails = true;
init() {
this.eventBus.on(MouseMoveEvent, (e: MouseMoveEvent) =>
this.onMouseEvent(e),
@@ -105,7 +107,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
const owner = this.game.owner(tile);
if (owner && owner.isPlayer()) {
this.player = owner as PlayerView;
this.player = owner;
this.player.profile().then((p) => {
this.playerProfile = p;
});
@@ -219,13 +221,17 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
return html`
<div class="p-2">
<div
<button
class="text-bold text-sm lg:text-lg font-bold mb-1 inline-flex break-all ${isFriendly
? "text-green-500"
: "text-white"}"
@click=${() => {
this.showDetails = !this.showDetails;
this.requestUpdate?.();
}}
>
${player.cosmetics.flag
? player.cosmetics.flag!.startsWith("!")
? player.cosmetics.flag.startsWith("!")
? html`<div
class="h-8 mr-1 aspect-[3/4] player-flag"
${ref((el) => {
@@ -238,66 +244,73 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
></div>`
: html`<img
class="h-8 mr-1 aspect-[3/4]"
src=${"/flags/" + player.cosmetics.flag! + ".svg"}
src=${"/flags/" + player.cosmetics.flag + ".svg"}
/>`
: html``}
${player.name()}
</div>
${player.team() !== null
? html`<div class="text-sm opacity-80">
${translateText("player_info_overlay.team")}: ${player.team()}
</div>`
</button>
<!-- Collapsible section -->
${this.showDetails
? html`
${player.team() !== null
? html`<div class="text-sm opacity-80">
${translateText("player_info_overlay.team")}:
${player.team()}
</div>`
: ""}
<div class="text-sm opacity-80">
${translateText("player_info_overlay.type")}: ${playerType}
</div>
${player.troops() >= 1
? html`<div class="text-sm opacity-80" translate="no">
${translateText("player_info_overlay.d_troops")}:
${renderTroops(player.troops())}
</div>`
: ""}
${attackingTroops >= 1
? html`<div class="text-sm opacity-80" translate="no">
${translateText("player_info_overlay.a_troops")}:
${renderTroops(attackingTroops)}
</div>`
: ""}
<div class="text-sm opacity-80" translate="no">
${translateText("player_info_overlay.gold")}:
${renderNumber(player.gold())}
</div>
${this.displayUnitCount(
player,
UnitType.Port,
"player_info_overlay.ports",
)}
${this.displayUnitCount(
player,
UnitType.City,
"player_info_overlay.cities",
)}
${this.displayUnitCount(
player,
UnitType.Factory,
"player_info_overlay.factories",
)}
${this.displayUnitCount(
player,
UnitType.MissileSilo,
"player_info_overlay.missile_launchers",
)}
${this.displayUnitCount(
player,
UnitType.SAMLauncher,
"player_info_overlay.sams",
)}
${this.displayUnitCount(
player,
UnitType.Warship,
"player_info_overlay.warships",
)}
${relationHtml}
`
: ""}
<div class="text-sm opacity-80">
${translateText("player_info_overlay.type")}: ${playerType}
</div>
${player.troops() >= 1
? html`<div class="text-sm opacity-80" translate="no">
${translateText("player_info_overlay.d_troops")}:
${renderTroops(player.troops())}
</div>`
: ""}
${attackingTroops >= 1
? html`<div class="text-sm opacity-80" translate="no">
${translateText("player_info_overlay.a_troops")}:
${renderTroops(attackingTroops)}
</div>`
: ""}
<div class="text-sm opacity-80" translate="no">
${translateText("player_info_overlay.gold")}:
${renderNumber(player.gold())}
</div>
${this.displayUnitCount(
player,
UnitType.Port,
"player_info_overlay.ports",
)}
${this.displayUnitCount(
player,
UnitType.City,
"player_info_overlay.cities",
)}
${this.displayUnitCount(
player,
UnitType.Factory,
"player_info_overlay.factories",
)}
${this.displayUnitCount(
player,
UnitType.MissileSilo,
"player_info_overlay.missile_launchers",
)}
${this.displayUnitCount(
player,
UnitType.SAMLauncher,
"player_info_overlay.sams",
)}
${this.displayUnitCount(
player,
UnitType.Warship,
"player_info_overlay.warships",
)}
${relationHtml}
</div>
`;
}
+9 -9
View File
@@ -40,7 +40,7 @@ export class PlayerPanel extends LitElement implements Layer {
private tile: TileRef | null = null;
@state()
public isVisible: boolean = false;
public isVisible = false;
@state()
private allianceExpiryText: string | null = null;
@@ -224,15 +224,15 @@ export class PlayerPanel extends LitElement implements Layer {
const myPlayer = this.g.myPlayer();
if (myPlayer === null) return;
if (this.tile === null) return;
let other = this.g.owner(this.tile);
const other = this.g.owner(this.tile);
if (!other.isPlayer()) {
this.hide();
console.warn("Tile is not owned by a player");
return;
}
other = other as PlayerView;
const canDonate = this.actions?.interaction?.canDonate;
const canDonateGold = this.actions?.interaction?.canDonateGold;
const canDonateTroops = this.actions?.interaction?.canDonateTroops;
const canSendAllianceRequest =
this.actions?.interaction?.canSendAllianceRequest;
const canSendEmoji =
@@ -351,9 +351,9 @@ export class PlayerPanel extends LitElement implements Layer {
>
${other.allies().length > 0
? other
.allies()
.map((p) => p.name())
.join(", ")
.allies()
.map((p) => p.name())
.join(", ")
: translateText("player_panel.none")}
</div>
</div>
@@ -421,7 +421,7 @@ export class PlayerPanel extends LitElement implements Layer {
<img src=${allianceIcon} alt="Alliance" class="w-6 h-6" />
</button>`
: ""}
${canDonate
${canDonateTroops
? html`<button
@click=${(e: MouseEvent) =>
this.handleDonateTroopClick(e, myPlayer, other)}
@@ -436,7 +436,7 @@ export class PlayerPanel extends LitElement implements Layer {
/>
</button>`
: ""}
${canDonate
${canDonateGold
? html`<button
@click=${(e: MouseEvent) =>
this.handleDonateGoldClick(e, myPlayer, other)}
+63 -32
View File
@@ -15,12 +15,12 @@ export class CloseRadialMenuEvent implements GameEvent {
constructor() {}
}
export interface TooltipItem {
export type TooltipItem = {
text: string;
className: string;
}
};
export interface RadialMenuConfig {
export type RadialMenuConfig = {
menuSize?: number;
submenuScale?: number;
centerButtonSize?: number;
@@ -33,7 +33,7 @@ export interface RadialMenuConfig {
maxNestedLevels?: number;
innerRadiusIncrement?: number;
tooltipStyle?: string;
}
};
type CenterButtonState = "default" | "back";
@@ -42,9 +42,9 @@ type RequiredRadialMenuConfig = Required<RadialMenuConfig>;
export class RadialMenu implements Layer {
private menuElement: d3.Selection<HTMLDivElement, unknown, null, undefined>;
private tooltipElement: HTMLDivElement | null = null;
private isVisible: boolean = false;
private isVisible = false;
private currentLevel: number = 0; // Current menu level (0 = main menu, 1 = submenu, etc.)
private currentLevel = 0; // Current menu level (0 = main menu, 1 = submenu, etc.)
private menuStack: MenuElement[][] = []; // Stack to track menu navigation history
private currentMenuItems: MenuElement[] = []; // Current active menu items (changes based on level)
@@ -53,9 +53,12 @@ export class RadialMenu implements Layer {
private centerButtonState: CenterButtonState = "default";
private isTransitioning: boolean = false;
private lastHideTime: number = 0;
private reopenCooldownMs: number = 300;
private isTransitioning = false;
private lastHideTime = 0;
private reopenCooldownMs = 300;
private anchorX = 0;
private anchorY = 0;
private menuGroups: Map<
number,
@@ -73,8 +76,8 @@ export class RadialMenu implements Layer {
private selectedItemId: string | null = null;
private submenuHoverTimeout: number | null = null;
private backButtonHoverTimeout: number | null = null;
private navigationInProgress: boolean = false;
private originalCenterButtonIcon: string = "";
private navigationInProgress = false;
private originalCenterButtonIcon = "";
private params: MenuElementParams | null = null;
@@ -128,7 +131,7 @@ export class RadialMenu implements Layer {
this.hideRadialMenu();
this.eventBus.emit(new CloseRadialMenuEvent());
})
.on("contextmenu", (e) => {
.on("contextmenu", (e: Event) => {
e.preventDefault();
this.hideRadialMenu();
this.eventBus.emit(new CloseRadialMenuEvent());
@@ -146,6 +149,7 @@ export class RadialMenu implements Layer {
.style("position", "absolute")
.style("top", "50%")
.style("left", "50%")
.style("transition", `top ${this.config.menuTransitionDuration}ms ease, left ${this.config.menuTransitionDuration}ms ease`)
.style("transform", "translate(-50%, -50%)")
.style("pointer-events", "all")
.on("click", (event) => this.hideRadialMenu());
@@ -174,7 +178,7 @@ export class RadialMenu implements Layer {
.attr("r", this.config.centerButtonSize)
.attr("fill", "transparent")
.style("cursor", "pointer")
.on("click", (event) => {
.on("click", (event: Event) => {
event.stopPropagation();
this.handleCenterButtonClick();
})
@@ -262,7 +266,7 @@ export class RadialMenu implements Layer {
menuGroup.style("opacity", 0).style("transform", "scale(0.5)");
}
this.menuGroups.set(level, menuGroup as any);
this.menuGroups.set(level, menuGroup);
const offset = -Math.PI / items.length;
@@ -303,7 +307,7 @@ export class RadialMenu implements Layer {
SVGGElement,
unknown
>,
arc: d3.Arc<any, d3.PieArcDatum<MenuElement>>,
arc: d3.Arc<unknown, d3.PieArcDatum<MenuElement>>,
level: number,
) {
arcs
@@ -344,7 +348,7 @@ export class RadialMenu implements Layer {
arcs.each((d) => {
const pathId = d.data.id;
const path = d3.select(`path[data-id="${pathId}"]`);
this.menuPaths.set(pathId, path as any);
this.menuPaths.set(pathId, path as never);
if (
pathId === this.selectedItemId &&
@@ -384,7 +388,9 @@ export class RadialMenu implements Layer {
>,
level: number,
) {
const onHover = (d: d3.PieArcDatum<MenuElement>, path: any) => {
const onHover = (d: d3.PieArcDatum<MenuElement>, path: d3.Selection<
d3.BaseType, unknown, HTMLElement, unknown
>) => {
const disabled = this.params === null || d.data.disabled(this.params);
if (d.data.tooltipItems && d.data.tooltipItems.length > 0) {
this.showTooltip(d.data.tooltipItems);
@@ -403,7 +409,9 @@ export class RadialMenu implements Layer {
path.attr("stroke-width", "3");
};
const onMouseOut = (d: d3.PieArcDatum<MenuElement>, path: any) => {
const onMouseOut = (d: d3.PieArcDatum<MenuElement>, path: d3.Selection<
d3.BaseType, unknown, HTMLElement, unknown
>) => {
const disabled = this.params === null || d.data.disabled(this.params);
if (this.submenuHoverTimeout !== null) {
window.clearTimeout(this.submenuHoverTimeout);
@@ -483,15 +491,15 @@ export class RadialMenu implements Layer {
onMouseOut(d, path);
});
path.on("mousemove", function (event) {
handleMouseMove(event as MouseEvent);
path.on("mousemove", function (event: MouseEvent) {
handleMouseMove(event);
});
path.on("click", function (event) {
path.on("click", function (event: Event) {
onClick(d, event);
});
path.on("touchstart", function (event) {
path.on("touchstart", function (event: Event) {
event.preventDefault();
event.stopPropagation();
onClick(d, event);
@@ -514,7 +522,7 @@ export class RadialMenu implements Layer {
SVGGElement,
unknown
>,
arc: d3.Arc<any, d3.PieArcDatum<MenuElement>>,
arc: d3.Arc<unknown, d3.PieArcDatum<MenuElement>>,
) {
arcs
.append("g")
@@ -549,7 +557,7 @@ export class RadialMenu implements Layer {
.attr("opacity", disabled ? 0.5 : 1);
}
this.menuIcons.set(contentId, content as any);
this.menuIcons.set(contentId, content as never);
});
}
@@ -576,6 +584,7 @@ export class RadialMenu implements Layer {
this.currentMenuItems = children;
this.currentLevel++;
this.clampAndSetMenuPositionForLevel(this.currentLevel);
this.renderMenuItems(this.currentMenuItems, this.currentLevel);
this.updateMenuGroupVisibility();
this.animatePreviousMenu();
@@ -655,6 +664,7 @@ export class RadialMenu implements Layer {
this.isTransitioning = true;
this.updateMenuLevels();
this.clampAndSetMenuPositionForLevel(this.currentLevel);
this.clearSelectedItemHoverState();
this.updateMenuVisibility("backward");
this.animateMenuTransitions();
@@ -729,8 +739,8 @@ export class RadialMenu implements Layer {
});
}
private animateExistingMenu(
previousMenu: d3.Selection<any, unknown, null, undefined>,
private animateExistingMenu<T extends d3.BaseType>(
previousMenu: d3.Selection<T, unknown, null, undefined>,
) {
previousMenu
.transition()
@@ -751,19 +761,17 @@ export class RadialMenu implements Layer {
this.resetMenu();
this.isTransitioning = false;
this.selectedItemId = null;
this.anchorX = x;
this.anchorY = y;
this.menuElement.style("display", "block");
this.menuElement
.select("svg")
.style("top", `${y}px`)
.style("left", `${x}px`)
.style("transform", `translate(-50%, -50%)`);
this.clampAndSetMenuPositionForLevel(this.currentLevel);
this.isVisible = true;
this.renderMenuItems(this.currentMenuItems, this.currentLevel);
this.onCenterButtonHover(true);
window.addEventListener("resize", this.handleResize);
}
public hideRadialMenu() {
@@ -787,6 +795,7 @@ export class RadialMenu implements Layer {
this.menuIcons.clear();
this.lastHideTime = Date.now();
window.removeEventListener("resize", this.handleResize);
}
private handleCenterButtonClick() {
@@ -1038,4 +1047,26 @@ export class RadialMenu implements Layer {
this.tooltipElement.style.display = "none";
}
}
// Ensure the menu's SVG center stays within viewport given the current level's outer radius
private clampAndSetMenuPositionForLevel(level: number) {
const outerRadius = this.getOuterRadiusForLevel(level);
const margin = Math.max(outerRadius, this.config.centerButtonSize) + 10;
const vw = window.innerWidth;
const vh = window.innerHeight;
// If the menu cannot fully fit on an axis, pin it to the viewport center on that axis.
const clampedX = 2 * margin > vw ? vw / 2 : Math.min(Math.max(this.anchorX, margin), vw - margin);
const clampedY = 2 * margin > vh ? vh / 2 : Math.min(Math.max(this.anchorY, margin), vh - margin);
const svgSel = this.menuElement.select("svg");
svgSel
.style("top", `${clampedY}px`)
.style("left", `${clampedX}px`);
}
private handleResize = () => {
if (this.isVisible) this.clampAndSetMenuPositionForLevel(this.currentLevel);
};
}
@@ -25,7 +25,7 @@ import traitorIcon from "../../../../resources/images/TraitorIconWhite.svg";
import xIcon from "../../../../resources/images/XIcon.svg";
import { EventBus } from "../../../core/EventBus";
export interface MenuElementParams {
export type MenuElementParams = {
myPlayer: PlayerView;
selected: PlayerView | null;
tile: TileRef;
@@ -38,9 +38,9 @@ export interface MenuElementParams {
chatIntegration: ChatIntegration;
eventBus: EventBus;
closeMenu: () => void;
}
};
export interface MenuElement {
export type MenuElement = {
id: string;
name: string;
displayed?: boolean | ((params: MenuElementParams) => boolean);
@@ -54,18 +54,18 @@ export interface MenuElement {
disabled: (params: MenuElementParams) => boolean;
action?: (params: MenuElementParams) => void; // For leaf items that perform actions
subMenu?: (params: MenuElementParams) => MenuElement[]; // For non-leaf items that open submenus
}
};
export interface TooltipKey {
export type TooltipKey = {
key: string;
className: string;
params?: Record<string, string | number>;
}
};
export interface CenterButtonElement {
export type CenterButtonElement = {
disabled: (params: MenuElementParams) => boolean;
action: (params: MenuElementParams) => void;
}
};
export const COLORS = {
build: "#ebe250",
@@ -208,7 +208,7 @@ const allyDonateGoldElement: MenuElement = {
id: "ally_donate_gold",
name: "donate gold",
disabled: (params: MenuElementParams) =>
!params.playerActions?.interaction?.canDonate,
!params.playerActions?.interaction?.canDonateGold,
color: COLORS.ally,
icon: donateGoldIcon,
action: (params: MenuElementParams) => {
@@ -221,7 +221,7 @@ const allyDonateTroopsElement: MenuElement = {
id: "ally_donate_troops",
name: "donate troops",
disabled: (params: MenuElementParams) =>
!params.playerActions?.interaction?.canDonate,
!params.playerActions?.interaction?.canDonateTroops,
color: COLORS.ally,
icon: donateTroopIcon,
action: (params: MenuElementParams) => {
@@ -566,8 +566,7 @@ export const rootMenuElement: MenuElement = {
const tileOwner = params.game.owner(params.tile);
const isOwnTerritory =
tileOwner.isPlayer() &&
(tileOwner as PlayerView).id() === params.myPlayer.id();
tileOwner.isPlayer() && tileOwner.id() === params.myPlayer.id();
const menuItems: (MenuElement | null)[] = [
infoMenuElement,
+2 -2
View File
@@ -8,7 +8,7 @@ import {
RailTile,
RailType,
} from "../../../core/game/GameUpdates";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { GameView } from "../../../core/game/GameView";
import { Layer } from "./Layer";
import { getRailroadRects } from "./RailroadSprites";
@@ -151,7 +151,7 @@ export class RailroadLayer implements Layer {
const x = this.game.x(railRoad.tile);
const y = this.game.y(railRoad.tile);
const owner = this.game.owner(railRoad.tile);
const recipient = owner.isPlayer() ? (owner as PlayerView) : null;
const recipient = owner.isPlayer() ? owner : null;
const color = recipient
? this.theme.railroadColor(recipient)
: new Colord({ r: 255, g: 255, b: 255, a: 1 });
+3 -3
View File
@@ -12,8 +12,8 @@ import { Layer } from "./Layer";
export class ShowReplayPanelEvent {
constructor(
public visible: boolean = true,
public isSingleplayer: boolean = false,
public visible = true,
public isSingleplayer = false,
) {}
}
@@ -23,7 +23,7 @@ export class ReplayPanel extends LitElement implements Layer {
public eventBus: EventBus | undefined;
@property({ type: Boolean })
visible: boolean = false;
visible = false;
@state()
private _replaySpeedMultiplier: number = defaultReplaySpeedMultiplier;
+26 -6
View File
@@ -1,5 +1,5 @@
import { html, LitElement } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { customElement, property, query, state } from "lit/decorators.js";
import structureIcon from "../../../../resources/images/CityIconWhite.svg";
import darkModeIcon from "../../../../resources/images/DarkModeIconWhite.svg";
import emojiIcon from "../../../../resources/images/EmojiIconWhite.svg";
@@ -12,11 +12,16 @@ import treeIcon from "../../../../resources/images/TreeIconWhite.svg";
import { EventBus } from "../../../core/EventBus";
import { UserSettings } from "../../../core/game/UserSettings";
import { AlternateViewEvent, RefreshGraphicsEvent } from "../../InputHandler";
import { PauseGameEvent } from "../../Transport";
import { translateText } from "../../Utils";
import { Layer } from "./Layer";
export class ShowSettingsModalEvent {
constructor(public readonly isVisible: boolean = true) {}
constructor(
public readonly isVisible = true,
public readonly shouldPause = false,
public readonly isPaused = false,
) {}
}
@customElement("settings-modal")
@@ -25,17 +30,26 @@ export class SettingsModal extends LitElement implements Layer {
public userSettings: UserSettings;
@state()
private isVisible: boolean = false;
private isVisible = false;
@state()
private alternateView: boolean = false;
private alternateView = false;
@query(".modal-overlay")
private modalOverlay!: HTMLElement;
@property({ type: Boolean })
shouldPause = false;
@property({ type: Boolean })
wasPausedWhenOpened = false;
init() {
this.eventBus.on(ShowSettingsModalEvent, (event) => {
this.isVisible = event.isVisible;
this.shouldPause = event.shouldPause;
this.wasPausedWhenOpened = event.isPaused;
this.pauseGame(true);
});
}
@@ -81,6 +95,12 @@ export class SettingsModal extends LitElement implements Layer {
this.isVisible = false;
document.body.style.overflow = "";
this.requestUpdate();
this.pauseGame(false);
}
private pauseGame(pause: boolean) {
if (this.shouldPause && !this.wasPausedWhenOpened)
this.eventBus.emit(new PauseGameEvent(pause));
}
private onTerrainButtonClick() {
@@ -354,8 +374,8 @@ export class SettingsModal extends LitElement implements Layer {
${this.userSettings.performanceOverlay()
? translateText("user_setting.performance_overlay_enabled")
: translateText(
"user_setting.performance_overlay_disabled",
)}
"user_setting.performance_overlay_disabled",
)}
</div>
</div>
<div class="text-sm text-slate-400">
+3 -3
View File
@@ -13,12 +13,12 @@ export class SpawnAd extends LitElement implements Layer {
public g: GameView;
@state()
private isVisible: boolean = false;
private isVisible = false;
@state()
private adLoaded: boolean = false;
private adLoaded = false;
private gamesPlayed: number = 0;
private gamesPlayed = 0;
// Override createRenderRoot to disable shadow DOM
createRenderRoot() {
@@ -19,15 +19,15 @@ import { Layer } from "./Layer";
type ShapeType = "triangle" | "square" | "pentagon" | "octagon" | "circle";
class StructureRenderInfo {
public isOnScreen: boolean = false;
public isOnScreen = false;
constructor(
public unit: UnitView,
public owner: PlayerID,
public iconContainer: PIXI.Container,
public levelContainer: PIXI.Container,
public dotContainer: PIXI.Container,
public level: number = 0,
public underConstruction: boolean = true,
public level = 0,
public underConstruction = true,
) {}
}
@@ -58,7 +58,7 @@ export class StructureIconsLayer implements Layer {
private iconsStage: PIXI.Container;
private levelsStage: PIXI.Container;
private dotsStage: PIXI.Container;
private shouldRedraw: boolean = true;
private shouldRedraw = true;
private textureCache: Map<string, PIXI.Texture> = new Map();
private theme: Theme;
private renderer: PIXI.Renderer;
@@ -353,12 +353,12 @@ export class StructureIconsLayer implements Layer {
const shape = STRUCTURE_SHAPES[structureType];
const texture = shape
? this.createIcon(
unit.owner(),
structureType,
isConstruction,
shape,
renderIcon,
)
unit.owner(),
structureType,
isConstruction,
shape,
renderIcon,
)
: PIXI.Texture.EMPTY;
this.textureCache.set(cacheKey, texture);
+2 -2
View File
@@ -23,11 +23,11 @@ const BASE_TERRITORY_RADIUS = 13.5;
const RADIUS_SCALE_FACTOR = 0.5;
const ZOOM_THRESHOLD = 4.3; // below this zoom level, structures are not rendered
interface UnitRenderConfig {
type UnitRenderConfig = {
icon: string;
borderRadius: number;
territoryRadius: number;
}
};
export class StructureLayer implements Layer {
private canvas: HTMLCanvasElement;
+2 -2
View File
@@ -6,14 +6,14 @@ import { GameView, PlayerView } from "../../../core/game/GameView";
import { renderNumber, translateText } from "../../Utils";
import { Layer } from "./Layer";
interface TeamEntry {
type TeamEntry = {
teamName: string;
totalScoreStr: string;
totalGold: string;
totalTroops: string;
totalScoreSort: number;
players: PlayerView[];
}
};
@customElement("team-stats")
export class TeamStats extends LitElement implements Layer {
+1 -1
View File
@@ -391,7 +391,7 @@ export class TerritoryLayer implements Layer {
}
}
paintTerritory(tile: TileRef, isBorder: boolean = false) {
paintTerritory(tile: TileRef, isBorder = false) {
if (isBorder && !this.game.hasOwner(tile)) {
return;
}
+1 -1
View File
@@ -77,7 +77,7 @@ export function getAuthHeader(): string {
return `Bearer ${token}`;
}
export async function logOut(allSessions: boolean = false) {
export async function logOut(allSessions = false) {
const token = getToken();
if (token === null) return;
clearToken();
@@ -3,10 +3,10 @@ import { html, TemplateResult } from "lit";
import { UnitType } from "../../core/game/Game";
import { translateText } from "../Utils";
export interface UnitTypeRenderContext {
export type UnitTypeRenderContext = {
disabledUnits: UnitType[];
toggleUnit: (unit: UnitType, checked: boolean) => void;
}
};
const unitOptions: { type: UnitType; translationKey: string }[] = [
{ type: UnitType.City, translationKey: "unit_type.city" },
+17
View File
@@ -1,3 +1,4 @@
// This file contains schemas for api.openfront.io
import { z } from "zod";
import { base64urlToUuid } from "./Base64";
@@ -48,3 +49,19 @@ export const UserMeResponseSchema = z.object({
}),
});
export type UserMeResponse = z.infer<typeof UserMeResponseSchema>;
export const StripeCreateCheckoutSessionResponseSchema = z.object({
id: z.string(),
object: z.literal("checkout.session"),
url: z.string(),
payment_status: z.enum(["paid", "unpaid", "no_payment_required"]),
status: z.enum(["open", "complete", "expired"]),
client_reference_id: z.string().optional(),
customer: z.string().optional(),
payment_intent: z.string().optional(),
subscription: z.string().optional(),
metadata: z.partialRecord(z.string(), z.string()),
});
export type StripeCreateCheckoutSessionResponse = z.infer<
typeof StripeCreateCheckoutSessionResponseSchema
>;
+3 -3
View File
@@ -1,4 +1,4 @@
import { z } from "zod/v4";
import { z } from "zod";
import { RequiredPatternSchema } from "./Schemas";
export const ProductSchema = z.object({
@@ -25,7 +25,7 @@ export const CosmeticsSchema = z.object({
z.string(),
z.object({
name: z.string(),
flares: z.array(z.string()).optional(),
flares: z.string().array().optional(),
}),
),
color: z.record(
@@ -33,7 +33,7 @@ export const CosmeticsSchema = z.object({
z.object({
color: z.string(),
name: z.string(),
flares: z.array(z.string()).optional(),
flares: z.string().array().optional(),
}),
),
})
+5 -7
View File
@@ -1,16 +1,14 @@
import { Cosmetics } from "./CosmeticSchemas";
const ANIMATION_DURATIONS: Record<string, number> = {
rainbow: 4000,
/* eslint-disable sort-keys */
"bright-rainbow": 4000,
"copper-glow": 3000,
"silver-glow": 3000,
"gold-glow": 3000,
neon: 3000,
lava: 6000,
/* eslint-enable sort-keys */
water: 6200,
"lava": 6000,
"neon": 3000,
"rainbow": 4000,
"silver-glow": 3000,
"water": 6200,
};
// TODO: Pass in cosmetics as a parameter when
+4 -3
View File
@@ -1,8 +1,9 @@
export type GameEvent = object;
export interface EventConstructor<T extends GameEvent = GameEvent> {
new (...args: any[]): T;
}
export type EventConstructor<T extends GameEvent = GameEvent> = new (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...args: any[]
) => T;
export class EventBus {
private listeners: Map<EventConstructor, Array<(event: GameEvent) => void>> =
+15
View File
@@ -0,0 +1,15 @@
// This file contians schemas for the primary openfront express server
import { z } from "zod";
import { GameInfoSchema } from "./Schemas";
export const ApiEnvResponseSchema = z.object({
game_env: z.string(),
});
export type ApiEnvResponse = z.infer<typeof ApiEnvResponseSchema>;
export const ApiPublicLobbiesResponseSchema = z.object({
lobbies: GameInfoSchema.array(),
});
export type ApiPublicLobbiesResponse = z.infer<
typeof ApiPublicLobbiesResponseSchema
>;
+10 -9
View File
@@ -57,13 +57,13 @@ export async function createGameRunner(
const nations = gameStart.config.disableNPCs
? []
: gameMap.manifest.nations.map(
(n) =>
new Nation(
new Cell(n.coordinates[0], n.coordinates[1]),
n.strength,
new PlayerInfo(n.name, PlayerType.FakeHuman, null, random.nextID()),
),
);
(n) =>
new Nation(
new Cell(n.coordinates[0], n.coordinates[1]),
n.strength,
new PlayerInfo(n.name, PlayerType.FakeHuman, null, random.nextID()),
),
);
const game: Game = createGame(
humans,
@@ -190,14 +190,15 @@ export class GameRunner {
const other = this.game.owner(tile) as Player;
actions.interaction = {
canBreakAlliance: player.isAlliedWith(other),
canDonate: player.canDonate(other),
canDonateGold: player.canDonateGold(other),
canDonateTroops: player.canDonateTroops(other),
canEmbargo: !player.hasEmbargoAgainst(other),
canSendAllianceRequest: player.canSendAllianceRequest(other),
canSendEmoji: player.canSendEmoji(other),
canTarget: player.canTarget(other),
sharedBorder: player.sharesBorderWith(other),
};
const alliance = player.allianceWith(other as Player);
const alliance = player.allianceWith(other);
if (alliance) {
actions.interaction.allianceExpiresAt = alliance.expiresAt();
}
+3 -3
View File
@@ -4,9 +4,9 @@ export class PseudoRandom {
private state1: number;
// Keep these variables to maintain the exact same interface
private m: number = 0x80000000; // 2**31
private a: number = 1103515245;
private c: number = 12345;
private m = 0x80000000; // 2**31
private a = 1103515245;
private c = 12345;
private state: number;
private static readonly POW36_8 = Math.pow(36, 8); // Pre-compute 36^8
+18 -11
View File
@@ -112,17 +112,6 @@ export type Player = z.infer<typeof PlayerSchema>;
export type GameStartInfo = z.infer<typeof GameStartInfoSchema>;
const PlayerTypeSchema = z.enum(PlayerType);
export interface GameInfo {
gameID: GameID;
clients?: ClientInfo[];
numClients?: number;
msUntilStart?: number;
gameConfig?: GameConfig;
}
export interface ClientInfo {
clientID: ClientID;
username: string;
}
export enum LogSeverity {
Debug = "DEBUG",
Info = "INFO",
@@ -148,6 +137,8 @@ export const GameConfigSchema = z.object({
difficulty: z.enum(Difficulty),
disableNPCs: z.boolean(),
disabledUnits: z.enum(UnitType).array().optional(),
donateGold: z.boolean(),
donateTroops: z.boolean(),
gameMap: z.enum(GameMapType),
gameMode: z.enum(GameMode),
gameType: z.enum(GameType),
@@ -192,6 +183,22 @@ export const ID = z
export const AllPlayersStatsSchema = z.record(ID, PlayerStatsSchema);
export const UsernameSchema = SafeString;
export const ClientInfoSchema = z.object({
clientID: ID,
username: UsernameSchema,
});
export type ClientInfo = z.infer<typeof ClientInfoSchema>;
export const GameInfoSchema = z.object({
clients: ClientInfoSchema.array().optional(),
gameConfig: GameConfigSchema.optional(),
gameID: ID,
msUntilStart: z.number().int().nonnegative().optional(),
numClients: z.number().int().nonnegative().optional(),
});
export type GameInfo = z.infer<typeof GameInfoSchema>;
const countryCodes = countries.map((c) => c.code);
export const FlagSchema = z
.string()
+1
View File
@@ -285,6 +285,7 @@ export const flattenedEmojiTable: string[] = emojiTable.flat();
/**
* JSON.stringify replacer function that converts bigint values to strings.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function replacer(_key: string, value: any): any {
return typeof value === "bigint" ? value.toString() : value;
}
+32 -1
View File
@@ -1,5 +1,6 @@
// This file contians schemas for the openfront worker express server
import { z } from "zod";
import { GameConfigSchema } from "./Schemas";
import { GameConfigSchema, GameRecordSchema } from "./Schemas";
export const CreateGameInputSchema = GameConfigSchema.or(
z
@@ -9,3 +10,33 @@ export const CreateGameInputSchema = GameConfigSchema.or(
);
export const GameInputSchema = GameConfigSchema.partial();
export const WorkerApiGameIdExistsSchema = z.object({
exists: z.boolean(),
});
export type WorkerApiGameIdExists = z.infer<typeof WorkerApiGameIdExistsSchema>;
export const WorkerApiArchivedGameLobbySchema = z.union([
z.object({
error: z.literal("Game not found"),
exists: z.literal(false),
success: z.literal(false),
}),
z.object({
details: z.object({
actualCommit: z.string(),
expectedCommit: z.string(),
}),
error: z.literal("Version mismatch"),
exists: z.literal(true),
success: z.literal(false),
}),
z.object({
exists: z.literal(true),
gameRecord: GameRecordSchema,
success: z.literal(true),
}),
]);
export type WorkerApiArchivedGameLobby = z.infer<
typeof WorkerApiArchivedGameLobbySchema
>;
+10 -8
View File
@@ -26,7 +26,7 @@ export enum GameEnv {
Prod,
}
export interface ServerConfig {
export type ServerConfig = {
turnIntervalMs(): number;
gameCreationRate(): number;
lobbyMaxPlayers(
@@ -62,14 +62,14 @@ export interface ServerConfig {
cloudflareCredsPath(): string;
stripePublishableKey(): string;
allowedFlares(): string[] | undefined;
}
};
export interface NukeMagnitude {
export type NukeMagnitude = {
inner: number;
outer: number;
}
};
export interface Config {
export type Config = {
samHittingChance(): number;
samWarheadHittingChance(): number;
spawnImmunityDuration(): Tick;
@@ -82,7 +82,9 @@ export interface Config {
isUnitDisabled(unitType: UnitType): boolean;
bots(): number;
infiniteGold(): boolean;
donateGold(): boolean;
infiniteTroops(): boolean;
donateTroops(): boolean;
instantBuild(): boolean;
numSpawnPhaseTurns(): number;
userSettings(): UserSettings;
@@ -168,9 +170,9 @@ export interface Config {
structureMinDist(): number;
isReplay(): boolean;
allianceExtensionPromptOffset(): number;
}
};
export interface Theme {
export type Theme = {
teamColor(team: Team): Colord;
territoryColor(playerInfo: PlayerView): Colord;
specialBuildingColor(playerInfo: PlayerView): Colord;
@@ -188,4 +190,4 @@ export interface Theme {
allyColor(): Colord;
enemyColor(): Colord;
spawnHighlightColor(): Colord;
}
};
+4 -2
View File
@@ -1,3 +1,4 @@
import { ApiEnvResponseSchema } from "../ExpressSchemas";
import { UserSettings } from "../game/UserSettings";
import { GameConfig } from "../Schemas";
import { Config, GameEnv, ServerConfig } from "./Config";
@@ -11,7 +12,7 @@ export let cachedSC: ServerConfig | null = null;
export async function getConfig(
gameConfig: GameConfig,
userSettings: UserSettings | null,
isReplay: boolean = false,
isReplay = false,
): Promise<Config> {
const sc = await getServerConfigFromClient();
switch (sc.env()) {
@@ -36,7 +37,8 @@ export async function getServerConfigFromClient(): Promise<ServerConfig> {
`Failed to fetch server config: ${response.status} ${response.statusText}`,
);
}
const config = await response.json();
const json = await response.json();
const config = ApiEnvResponseSchema.parse(json);
// Log the retrieved configuration
console.log("Server config loaded:", config);
+6
View File
@@ -323,9 +323,15 @@ export class DefaultConfig implements Config {
infiniteGold(): boolean {
return this._gameConfig.infiniteGold;
}
donateGold(): boolean {
return this._gameConfig.donateGold;
}
infiniteTroops(): boolean {
return this._gameConfig.infiniteTroops;
}
donateTroops(): boolean {
return this._gameConfig.donateTroops;
}
trainSpawnRate(numberOfStations: number): number {
return Math.min(1400, Math.round(20 * Math.pow(numberOfStations, 0.5)));
}
+3 -3
View File
@@ -18,7 +18,7 @@ const malusForRetreat = 25;
export class AttackExecution implements Execution {
private breakAlliance = false;
private wasAlliedAtInit = false; // Store alliance state at initialization
private active: boolean = true;
private active = true;
private toConquer = new FlatBinaryHeap();
private random = new PseudoRandom(123);
@@ -34,7 +34,7 @@ export class AttackExecution implements Execution {
private _owner: Player,
private _targetID: PlayerID | null,
private sourceTile: TileRef | null = null,
private removeTroops: boolean = true,
private removeTroops = true,
) {}
public targetID(): PlayerID | null {
@@ -69,7 +69,7 @@ export class AttackExecution implements Execution {
}
if (this.target && this.target.isPlayer()) {
const targetPlayer = this.target as Player;
const targetPlayer = this.target;
if (
targetPlayer.type() !== PlayerType.Bot &&
this._owner.type() !== PlayerType.Bot
+2 -2
View File
@@ -5,7 +5,7 @@ import { TrainStationExecution } from "./TrainStationExecution";
export class CityExecution implements Execution {
private mg: Game;
private city: Unit | null = null;
private active: boolean = true;
private active = true;
constructor(
private player: Player,
@@ -48,7 +48,7 @@ export class CityExecution implements Execution {
createStation(): void {
if (this.city !== null) {
const nearbyFactory = this.mg.hasUnitNearby(
this.city.tile()!,
this.city.tile(),
this.mg.config().trainStationMaxRange(),
UnitType.Factory,
this.player.id(),
+1 -1
View File
@@ -20,7 +20,7 @@ import { WarshipExecution } from "./WarshipExecution";
export class ConstructionExecution implements Execution {
private construction: Unit | null = null;
private active: boolean = true;
private active = true;
private mg: Game;
private ticksUntilComplete: Tick;
+1 -1
View File
@@ -5,7 +5,7 @@ import { ShellExecution } from "./ShellExecution";
export class DefensePostExecution implements Execution {
private mg: Game;
private post: Unit | null = null;
private active: boolean = true;
private active = true;
private target: Unit | null = null;
private lastShellAttack = 0;
+1 -1
View File
@@ -1,7 +1,7 @@
import { Execution, Game, MessageType, Player } from "../game/Game";
export class DeleteUnitExecution implements Execution {
private active: boolean = true;
private active = true;
private mg: Game;
constructor(
+1 -1
View File
@@ -25,7 +25,7 @@ export class DonateGoldExecution implements Execution {
tick(ticks: number): void {
if (this.gold === null) throw new Error("not initialized");
if (
this.sender.canDonate(this.recipient) &&
this.sender.canDonateGold(this.recipient) &&
this.sender.donateGold(this.recipient, this.gold)
) {
this.recipient.updateRelation(this.sender, 50);
+1 -1
View File
@@ -28,7 +28,7 @@ export class DonateTroopsExecution implements Execution {
tick(ticks: number): void {
if (this.troops === null) throw new Error("not initialized");
if (
this.sender.canDonate(this.recipient) &&
this.sender.canDonateTroops(this.recipient) &&
this.sender.donateTroops(this.recipient, this.troops)
) {
this.recipient.updateRelation(this.sender, 50);
+2 -2
View File
@@ -4,7 +4,7 @@ import { TrainStationExecution } from "./TrainStationExecution";
export class FactoryExecution implements Execution {
private factory: Unit | null = null;
private active: boolean = true;
private active = true;
private game: Game;
constructor(
private player: Player,
@@ -47,7 +47,7 @@ export class FactoryExecution implements Execution {
createStation(): void {
if (this.factory !== null) {
const structures = this.game.nearbyUnits(
this.factory.tile()!,
this.factory.tile(),
this.game.config().trainStationMaxRange(),
[UnitType.City, UnitType.Port, UnitType.Factory],
);
+3 -3
View File
@@ -297,7 +297,7 @@ export class FakeHumanExecution implements Execution {
UnitType.SAMLauncher,
);
const structureTiles = structures.map((u) => u.tile());
const randomTiles: (TileRef | null)[] = new Array(10);
const randomTiles: (TileRef | null)[] = new Array<TileRef | null>(10).fill(null);
for (let i = 0; i < randomTiles.length; i++) {
randomTiles[i] = this.randTerritoryTile(other);
}
@@ -455,8 +455,8 @@ export class FakeHumanExecution implements Execution {
const tiles =
type === UnitType.Port
? Array.from(this.player.borderTiles()).filter((t) =>
this.mg.isOceanShore(t),
)
this.mg.isOceanShore(t),
)
: Array.from(this.player.tiles());
if (tiles.length === 0) return null;
return this.random.randElement(tiles);
+1 -1
View File
@@ -31,7 +31,7 @@ export class MirvExecution implements Execution {
private separateDst: TileRef;
private speed: number = -1;
private speed = -1;
constructor(
private player: Player,

Some files were not shown because too many files have changed in this diff Show More