mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-03 20:20:40 +00:00
Merge branch 'v24' into evan-checkout-flow
This commit is contained in:
@@ -103,7 +103,7 @@ export function joinLobby(
|
||||
if (message.type === "error") {
|
||||
showErrorModal(
|
||||
message.error,
|
||||
"",
|
||||
message.message,
|
||||
lobbyConfig.gameID,
|
||||
lobbyConfig.clientID,
|
||||
true,
|
||||
@@ -329,7 +329,7 @@ export class ClientGameRunner {
|
||||
if (message.type === "error") {
|
||||
showErrorModal(
|
||||
message.error,
|
||||
"",
|
||||
message.message,
|
||||
this.lobby.gameID,
|
||||
this.lobby.clientID,
|
||||
true,
|
||||
@@ -385,7 +385,7 @@ export class ClientGameRunner {
|
||||
!this.gameView.hasOwner(tile) &&
|
||||
this.gameView.inSpawnPhase()
|
||||
) {
|
||||
this.eventBus.emit(new SendSpawnIntentEvent(cell));
|
||||
this.eventBus.emit(new SendSpawnIntentEvent(tile));
|
||||
return;
|
||||
}
|
||||
if (this.gameView.inSpawnPhase()) {
|
||||
@@ -582,27 +582,31 @@ export class ClientGameRunner {
|
||||
}
|
||||
|
||||
function showErrorModal(
|
||||
errMsg: string,
|
||||
stack: string,
|
||||
error: string,
|
||||
message: string | undefined,
|
||||
gameID: GameID,
|
||||
clientID: ClientID,
|
||||
closable = false,
|
||||
showDiscord = true,
|
||||
heading = "error_modal.crashed",
|
||||
) {
|
||||
const errorText = `Error: ${errMsg}\nStack: ${stack}`;
|
||||
|
||||
if (document.querySelector("#error-modal")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = document.createElement("div");
|
||||
|
||||
modal.id = "error-modal";
|
||||
|
||||
const discord = showDiscord ? translateText("error_modal.paste_discord") : "";
|
||||
|
||||
const content = `${discord}\n${translateText(heading)}\n game id: ${gameID}, client id: ${clientID}\n${errorText}`;
|
||||
const content = [
|
||||
showDiscord ? translateText("error_modal.paste_discord") : null,
|
||||
translateText(heading),
|
||||
`game id: ${gameID}`,
|
||||
`client id: ${clientID}`,
|
||||
`Error: ${error}`,
|
||||
message ? `Message: ${message}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
// Create elements
|
||||
const pre = document.createElement("pre");
|
||||
|
||||
+25
-10
@@ -13,20 +13,23 @@ import eo from "../../resources/lang/eo.json";
|
||||
import es from "../../resources/lang/es.json";
|
||||
import fi from "../../resources/lang/fi.json";
|
||||
import fr from "../../resources/lang/fr.json";
|
||||
import gl from "../../resources/lang/gl.json";
|
||||
import he from "../../resources/lang/he.json";
|
||||
import hi from "../../resources/lang/hi.json";
|
||||
import it from "../../resources/lang/it.json";
|
||||
import ja from "../../resources/lang/ja.json";
|
||||
import ko from "../../resources/lang/ko.json";
|
||||
import nl from "../../resources/lang/nl.json";
|
||||
import pl from "../../resources/lang/pl.json";
|
||||
import pt_br from "../../resources/lang/pt_br.json";
|
||||
import pt_BR from "../../resources/lang/pt-BR.json";
|
||||
import ru from "../../resources/lang/ru.json";
|
||||
import sh from "../../resources/lang/sh.json";
|
||||
import sv_se from "../../resources/lang/sv_se.json";
|
||||
import sl from "../../resources/lang/sl.json";
|
||||
import sv_SE from "../../resources/lang/sv-SE.json";
|
||||
import tp from "../../resources/lang/tp.json";
|
||||
import tr from "../../resources/lang/tr.json";
|
||||
import uk from "../../resources/lang/uk.json";
|
||||
import zh_cn from "../../resources/lang/zh_cn.json";
|
||||
import zh_CN from "../../resources/lang/zh-CN.json";
|
||||
|
||||
@customElement("lang-selector")
|
||||
export class LangSelector extends LitElement {
|
||||
@@ -53,7 +56,7 @@ export class LangSelector extends LitElement {
|
||||
ja,
|
||||
nl,
|
||||
pl,
|
||||
pt_br,
|
||||
"pt-BR": pt_BR,
|
||||
ru,
|
||||
sh,
|
||||
tr,
|
||||
@@ -63,8 +66,11 @@ export class LangSelector extends LitElement {
|
||||
he,
|
||||
da,
|
||||
fi,
|
||||
sv_se,
|
||||
zh_cn,
|
||||
"sv-SE": sv_SE,
|
||||
"zh-CN": zh_CN,
|
||||
ko,
|
||||
gl,
|
||||
sl,
|
||||
};
|
||||
|
||||
createRenderRoot() {
|
||||
@@ -89,15 +95,23 @@ export class LangSelector extends LitElement {
|
||||
private getClosestSupportedLang(lang: string): string {
|
||||
if (!lang) return "en";
|
||||
if (lang in this.languageMap) return lang;
|
||||
const base = lang.split("-")[0];
|
||||
if (base in this.languageMap) return base;
|
||||
|
||||
const base = lang.slice(0, 2);
|
||||
const candidates = Object.keys(this.languageMap).filter((key) =>
|
||||
key.startsWith(base),
|
||||
);
|
||||
if (candidates.length > 0) {
|
||||
candidates.sort((a, b) => b.length - a.length); // More specific first
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
return "en";
|
||||
}
|
||||
|
||||
private async initializeLanguage() {
|
||||
const browserLocale = navigator.language;
|
||||
const savedLang = localStorage.getItem("lang");
|
||||
const userLang = this.getClosestSupportedLang(savedLang || browserLocale);
|
||||
const userLang = this.getClosestSupportedLang(savedLang ?? browserLocale);
|
||||
|
||||
this.defaultTranslations = this.loadLanguage("en");
|
||||
this.translations = this.loadLanguage(userLang);
|
||||
@@ -108,7 +122,7 @@ export class LangSelector extends LitElement {
|
||||
}
|
||||
|
||||
private loadLanguage(lang: string): Record<string, string> {
|
||||
const language = this.languageMap[lang] || {};
|
||||
const language = this.languageMap[lang] ?? {};
|
||||
const flat = flattenTranslations(language);
|
||||
return flat;
|
||||
}
|
||||
@@ -204,6 +218,7 @@ export class LangSelector extends LitElement {
|
||||
"user-setting",
|
||||
"o-modal",
|
||||
"o-button",
|
||||
"territory-patterns-modal",
|
||||
];
|
||||
|
||||
document.title = this.translateText("main.title") ?? document.title;
|
||||
|
||||
@@ -162,7 +162,7 @@ export class LanguageModal extends LitElement {
|
||||
<div class="c-modal__wrapper">
|
||||
<header class="c-modal__header">
|
||||
${translateText("select_lang.title")}
|
||||
<div class="c-modal__close" @click=${this.close}>X</div>
|
||||
<div class="c-modal__close" @click=${this.close}>✕</div>
|
||||
</header>
|
||||
|
||||
<section class="c-modal__content">
|
||||
|
||||
@@ -460,6 +460,9 @@ class Client {
|
||||
"game-top-bar",
|
||||
"help-modal",
|
||||
"user-setting",
|
||||
"territory-patterns-modal",
|
||||
"language-modal",
|
||||
"news-modal",
|
||||
].forEach((tag) => {
|
||||
const modal = document.querySelector(tag) as HTMLElement & {
|
||||
close?: () => void;
|
||||
|
||||
@@ -110,7 +110,7 @@ export class PublicLobby extends LitElement {
|
||||
|
||||
const teamCount =
|
||||
lobby.gameConfig.gameMode === GameMode.Team
|
||||
? lobby.gameConfig.playerTeams || 0
|
||||
? (lobby.gameConfig.playerTeams ?? 0)
|
||||
: null;
|
||||
|
||||
const mapImageSrc = this.mapImages.get(lobby.gameID);
|
||||
|
||||
+16
-18
@@ -2,7 +2,6 @@ import { z } from "zod/v4";
|
||||
import { EventBus, GameEvent } from "../core/EventBus";
|
||||
import {
|
||||
AllPlayers,
|
||||
Cell,
|
||||
GameType,
|
||||
Gold,
|
||||
PlayerID,
|
||||
@@ -68,7 +67,7 @@ export class SendAllianceExtensionIntentEvent implements GameEvent {
|
||||
}
|
||||
|
||||
export class SendSpawnIntentEvent implements GameEvent {
|
||||
constructor(public readonly cell: Cell) {}
|
||||
constructor(public readonly tile: TileRef) {}
|
||||
}
|
||||
|
||||
export class SendAttackIntentEvent implements GameEvent {
|
||||
@@ -90,7 +89,7 @@ export class SendBoatAttackIntentEvent implements GameEvent {
|
||||
export class BuildUnitIntentEvent implements GameEvent {
|
||||
constructor(
|
||||
public readonly unit: UnitType,
|
||||
public readonly cell: Cell,
|
||||
public readonly tile: TileRef,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -245,16 +244,14 @@ export class Transport {
|
||||
}
|
||||
|
||||
private startPing() {
|
||||
if (this.isLocal || this.pingInterval) return;
|
||||
if (this.pingInterval === null) {
|
||||
this.pingInterval = window.setInterval(() => {
|
||||
if (this.socket !== null && this.socket.readyState === WebSocket.OPEN) {
|
||||
this.sendMsg({
|
||||
type: "ping",
|
||||
} satisfies ClientPingMessage);
|
||||
}
|
||||
}, 5 * 1000);
|
||||
}
|
||||
if (this.isLocal) return;
|
||||
this.pingInterval ??= window.setInterval(() => {
|
||||
if (this.socket !== null && this.socket.readyState === WebSocket.OPEN) {
|
||||
this.sendMsg({
|
||||
type: "ping",
|
||||
} satisfies ClientPingMessage);
|
||||
}
|
||||
}, 5 * 1000);
|
||||
}
|
||||
|
||||
private stopPing() {
|
||||
@@ -344,7 +341,10 @@ export class Transport {
|
||||
console.log(
|
||||
`WebSocket closed. Code: ${event.code}, Reason: ${event.reason}`,
|
||||
);
|
||||
if (event.code !== 1000 && event.code !== 1002) {
|
||||
if (event.code === 1002) {
|
||||
// TODO: make this a modal
|
||||
alert(`connection refused: ${event.reason}`);
|
||||
} else if (event.code !== 1000) {
|
||||
console.log(`recieved error code ${event.code}, reconnecting`);
|
||||
this.reconnect();
|
||||
}
|
||||
@@ -437,8 +437,7 @@ export class Transport {
|
||||
pattern: this.lobbyConfig.pattern,
|
||||
name: this.lobbyConfig.playerName,
|
||||
playerType: PlayerType.Human,
|
||||
x: event.cell.x,
|
||||
y: event.cell.y,
|
||||
tile: event.tile,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -539,8 +538,7 @@ export class Transport {
|
||||
type: "build_unit",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
unit: event.unit,
|
||||
x: event.cell.x,
|
||||
y: event.cell.y,
|
||||
tile: event.tile,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -172,7 +172,7 @@ export function getAltKey(): string {
|
||||
|
||||
export function getGamesPlayed(): number {
|
||||
try {
|
||||
return parseInt(localStorage.getItem("gamesPlayed") || "0", 10) || 0;
|
||||
return parseInt(localStorage.getItem("gamesPlayed") ?? "0", 10) || 0;
|
||||
} catch (error) {
|
||||
console.warn("Failed to read games played from localStorage:", error);
|
||||
return 0;
|
||||
|
||||
@@ -17,9 +17,7 @@ export class NewsButton extends LitElement {
|
||||
|
||||
private checkForNewVersion() {
|
||||
try {
|
||||
const lastSeenVersion = localStorage.getItem(
|
||||
"news-button-last-seen-version",
|
||||
);
|
||||
const lastSeenVersion = localStorage.getItem("version");
|
||||
this.isActive = lastSeenVersion !== version;
|
||||
} catch (error) {
|
||||
// Fallback to NOT showing notification if localStorage fails
|
||||
@@ -28,7 +26,7 @@ export class NewsButton extends LitElement {
|
||||
}
|
||||
|
||||
private handleClick() {
|
||||
localStorage.setItem("news-button-last-seen-version", version);
|
||||
localStorage.setItem("version", version);
|
||||
this.isActive = false;
|
||||
|
||||
const newsModal = document.querySelector("news-modal") as NewsModal;
|
||||
|
||||
@@ -79,7 +79,7 @@ export class OModal extends LitElement {
|
||||
${`${this.translationKey}` === ""
|
||||
? `${this.title}`
|
||||
: `${translateText(this.translationKey)}`}
|
||||
<div class="c-modal__close" @click=${this.close}>X</div>
|
||||
<div class="c-modal__close" @click=${this.close}>✕</div>
|
||||
</header>
|
||||
<section class="c-modal__content">
|
||||
<slot></slot>
|
||||
|
||||
@@ -15,7 +15,6 @@ import { EventsDisplay } from "./layers/EventsDisplay";
|
||||
import { FxLayer } from "./layers/FxLayer";
|
||||
import { GameLeftSidebar } from "./layers/GameLeftSidebar";
|
||||
import { GameRightSidebar } from "./layers/GameRightSidebar";
|
||||
import { GameTopBar } from "./layers/GameTopBar";
|
||||
import { GutterAdModal } from "./layers/GutterAdModal";
|
||||
import { HeadsUpMessage } from "./layers/HeadsUpMessage";
|
||||
import { Layer } from "./layers/Layer";
|
||||
@@ -75,8 +74,8 @@ export function createRenderer(
|
||||
buildMenu.transformHandler = transformHandler;
|
||||
|
||||
const leaderboard = document.querySelector("leader-board") as Leaderboard;
|
||||
if (!emojiTable || !(leaderboard instanceof Leaderboard)) {
|
||||
console.error("EmojiTable element not found in the DOM");
|
||||
if (!leaderboard || !(leaderboard instanceof Leaderboard)) {
|
||||
console.error("LeaderBoard element not found in the DOM");
|
||||
}
|
||||
leaderboard.eventBus = eventBus;
|
||||
leaderboard.game = game;
|
||||
@@ -90,8 +89,8 @@ export function createRenderer(
|
||||
gameLeftSidebar.game = game;
|
||||
|
||||
const teamStats = document.querySelector("team-stats") as TeamStats;
|
||||
if (!emojiTable || !(teamStats instanceof TeamStats)) {
|
||||
console.error("EmojiTable element not found in the DOM");
|
||||
if (!teamStats || !(teamStats instanceof TeamStats)) {
|
||||
console.error("TeamStats element not found in the DOM");
|
||||
}
|
||||
teamStats.eventBus = eventBus;
|
||||
teamStats.game = game;
|
||||
@@ -162,13 +161,6 @@ export function createRenderer(
|
||||
settingsModal.userSettings = userSettings;
|
||||
settingsModal.eventBus = eventBus;
|
||||
|
||||
const gameTopBar = document.querySelector("game-top-bar") as GameTopBar;
|
||||
if (!(gameTopBar instanceof GameTopBar)) {
|
||||
console.error("top bar not found");
|
||||
}
|
||||
gameTopBar.game = game;
|
||||
gameTopBar.eventBus = eventBus;
|
||||
|
||||
const unitDisplay = document.querySelector("unit-display") as UnitDisplay;
|
||||
if (!(unitDisplay instanceof UnitDisplay)) {
|
||||
console.error("unit display not found");
|
||||
@@ -255,7 +247,6 @@ export function createRenderer(
|
||||
new SpawnTimer(game, transformHandler),
|
||||
leaderboard,
|
||||
gameLeftSidebar,
|
||||
gameTopBar,
|
||||
unitDisplay,
|
||||
gameRightSidebar,
|
||||
controlPanel,
|
||||
|
||||
@@ -91,9 +91,9 @@ const getSpriteForUnit = (unit: UnitView): ImageBitmap | null => {
|
||||
const unitType = unit.type();
|
||||
if (unitType === UnitType.Train) {
|
||||
const trainType = trainTypeToSpriteType(unit);
|
||||
return spriteMap.get(trainType) || null;
|
||||
return spriteMap.get(trainType) ?? null;
|
||||
}
|
||||
return spriteMap.get(unitType) || null;
|
||||
return spriteMap.get(unitType) ?? null;
|
||||
};
|
||||
|
||||
export const isSpriteReady = (unit: UnitView): boolean => {
|
||||
|
||||
@@ -15,7 +15,6 @@ import { translateText } from "../../../client/Utils";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import {
|
||||
BuildableUnit,
|
||||
Cell,
|
||||
Gold,
|
||||
PlayerActions,
|
||||
UnitType,
|
||||
@@ -136,6 +135,11 @@ export class BuildMenu extends LitElement implements Layer {
|
||||
if (!this.game.myPlayer()?.isAlive()) {
|
||||
return;
|
||||
}
|
||||
if (!this._hidden) {
|
||||
// Players sometimes hold control while building a unit,
|
||||
// so if the menu is already open, ignore the event.
|
||||
return;
|
||||
}
|
||||
const clickedCell = this.transformHandler.screenToWorldCoordinates(
|
||||
e.x,
|
||||
e.y,
|
||||
@@ -379,7 +383,7 @@ export class BuildMenu extends LitElement implements Layer {
|
||||
return "?";
|
||||
}
|
||||
|
||||
return player.units(item.unitType).length.toString();
|
||||
return player.totalUnitLevels(item.unitType).toString();
|
||||
}
|
||||
|
||||
public sendBuildOrUpgrade(buildableUnit: BuildableUnit, tile: TileRef): void {
|
||||
@@ -391,12 +395,7 @@ export class BuildMenu extends LitElement implements Layer {
|
||||
),
|
||||
);
|
||||
} else if (buildableUnit.canBuild) {
|
||||
this.eventBus.emit(
|
||||
new BuildUnitIntentEvent(
|
||||
buildableUnit.type,
|
||||
new Cell(this.game.x(tile), this.game.y(tile)),
|
||||
),
|
||||
);
|
||||
this.eventBus.emit(new BuildUnitIntentEvent(buildableUnit.type, tile));
|
||||
}
|
||||
this.hideMenu();
|
||||
}
|
||||
|
||||
@@ -2,16 +2,19 @@ import { LitElement, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { translateText } from "../../../client/Utils";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { Gold } from "../../../core/game/Game";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { ClientID } from "../../../core/Schemas";
|
||||
import { AttackRatioEvent } from "../../InputHandler";
|
||||
import { SendSetTargetTroopRatioEvent } from "../../Transport";
|
||||
import { renderTroops } from "../../Utils";
|
||||
import { renderNumber, renderTroops } from "../../Utils";
|
||||
import { UIState } from "../UIState";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
@customElement("control-panel")
|
||||
export class ControlPanel extends LitElement implements Layer {
|
||||
public game: GameView;
|
||||
public clientID: ClientID;
|
||||
public eventBus: EventBus;
|
||||
public uiState: UIState;
|
||||
|
||||
@@ -27,13 +30,34 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
@state()
|
||||
private _population: number;
|
||||
|
||||
@state()
|
||||
private _maxPopulation: number;
|
||||
|
||||
@state()
|
||||
private popRate: number;
|
||||
|
||||
@state()
|
||||
private _troops: number;
|
||||
|
||||
@state()
|
||||
private _workers: number;
|
||||
|
||||
@state()
|
||||
private _isVisible = false;
|
||||
|
||||
@state()
|
||||
private _manpower: number = 0;
|
||||
|
||||
@state()
|
||||
private _gold: Gold;
|
||||
|
||||
@state()
|
||||
private _goldPerSecond: Gold;
|
||||
|
||||
private _popRateIsIncreasing: boolean = true;
|
||||
|
||||
private _lastPopulationIncreaseRate: number;
|
||||
|
||||
private init_: boolean = false;
|
||||
|
||||
init() {
|
||||
@@ -90,17 +114,31 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
return;
|
||||
}
|
||||
|
||||
const popIncreaseRate = player.population() - this._population;
|
||||
if (this.game.ticks() % 5 === 0) {
|
||||
this._lastPopulationIncreaseRate = popIncreaseRate;
|
||||
this.updatePopulationIncrease();
|
||||
}
|
||||
|
||||
this._population = player.population();
|
||||
this._maxPopulation = this.game.config().maxPopulation(player);
|
||||
this._gold = player.gold();
|
||||
this._troops = player.troops();
|
||||
this._workers = player.workers();
|
||||
this.popRate = this.game.config().populationIncreaseRate(player) * 10;
|
||||
this._goldPerSecond = this.game.config().goldAdditionRate(player) * 10n;
|
||||
|
||||
this.currentTroopRatio = player.troops() / player.population();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private updatePopulationIncrease() {
|
||||
const player = this.game?.myPlayer();
|
||||
if (player === null) return;
|
||||
const popIncreaseRate = this.game.config().populationIncreaseRate(player);
|
||||
this._popRateIsIncreasing =
|
||||
popIncreaseRate >= this._lastPopulationIncreaseRate;
|
||||
this._lastPopulationIncreaseRate = popIncreaseRate;
|
||||
}
|
||||
|
||||
onAttackRatioChange(newRatio: number) {
|
||||
this.uiState.attackRatio = newRatio;
|
||||
}
|
||||
@@ -174,21 +212,45 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
</style>
|
||||
<div
|
||||
class="${this._isVisible
|
||||
? "text-sm lg:text-m md:w-[320px] bg-gray-800/70 p-2 pr-3 lg:p-4 shadow-lg lg:rounded-lg backdrop-blur"
|
||||
? "w-full sm:max-w-[320px] text-sm sm:text-base bg-gray-800/70 p-2 pr-3 sm:p-4 shadow-lg sm:rounded-lg backdrop-blur"
|
||||
: "hidden"}"
|
||||
@contextmenu=${(e) => e.preventDefault()}
|
||||
>
|
||||
<div class="relative mb-4 lg:mb-4">
|
||||
<label class="flex justify-between text-white mb-1" translate="no">
|
||||
<span>
|
||||
${translateText("control_panel.troops")}:
|
||||
${(this.currentTroopRatio * 100).toFixed(0)}%
|
||||
</span>
|
||||
<span>
|
||||
${translateText("control_panel.workers")}:
|
||||
${((1 - this.currentTroopRatio) * 100).toFixed(0)}%
|
||||
</span>
|
||||
</label>
|
||||
<div class="block bg-black/30 text-white mb-4 p-2 rounded">
|
||||
<div class="flex justify-between mb-1">
|
||||
<span class="font-bold"
|
||||
>${translateText("control_panel.pop")}:</span
|
||||
>
|
||||
<span translate="no"
|
||||
>${renderTroops(this._population)} /
|
||||
${renderTroops(this._maxPopulation)}
|
||||
<span
|
||||
class="${this._popRateIsIncreasing
|
||||
? "text-green-500"
|
||||
: "text-yellow-500"}"
|
||||
translate="no"
|
||||
>(+${renderTroops(this.popRate)})</span
|
||||
></span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="font-bold"
|
||||
>${translateText("control_panel.gold")}:</span
|
||||
>
|
||||
<span translate="no"
|
||||
>${renderNumber(this._gold)}
|
||||
(+${renderNumber(this._goldPerSecond)})</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative mb-4 sm:mb-4">
|
||||
<label class="block text-white mb-1" translate="no"
|
||||
>${translateText("control_panel.troops")}:
|
||||
<span translate="no">${renderTroops(this._troops)}</span> |
|
||||
${translateText("control_panel.workers")}:
|
||||
<span translate="no">${renderTroops(this._workers)}</span></label
|
||||
>
|
||||
<div class="relative h-8">
|
||||
<!-- Background track -->
|
||||
<div
|
||||
@@ -215,7 +277,7 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative mb-0 lg:mb-4">
|
||||
<div class="relative mb-0 sm:mb-4">
|
||||
<label class="block text-white mb-1" translate="no"
|
||||
>${translateText("control_panel.attack_ratio")}:
|
||||
${(this.attackRatio * 100).toFixed(0)}%
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { AllPlayers } from "../../../core/game/Game";
|
||||
@@ -11,91 +11,14 @@ import { TransformHandler } from "../TransformHandler";
|
||||
|
||||
@customElement("emoji-table")
|
||||
export class EmojiTable extends LitElement {
|
||||
@state() public isVisible = false;
|
||||
public eventBus: EventBus;
|
||||
public transformHandler: TransformHandler;
|
||||
public game: GameView;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.emoji-table {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 9999;
|
||||
background-color: #1e1e1e;
|
||||
padding: 15px;
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-width: 95vw;
|
||||
max-height: 95vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.emoji-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
.emoji-button {
|
||||
font-size: 60px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border: 1px solid #333;
|
||||
background-color: #2c2c2c;
|
||||
color: white;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 8px;
|
||||
}
|
||||
.emoji-button:hover {
|
||||
background-color: #3a3a3a;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.emoji-button:active {
|
||||
background-color: #4a4a4a;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.emoji-button {
|
||||
font-size: 32px;
|
||||
/* Slightly smaller font size for mobile */
|
||||
width: 60px;
|
||||
/* Smaller width for mobile */
|
||||
height: 60px;
|
||||
/* Smaller height for mobile */
|
||||
margin: 5px;
|
||||
/* Smaller margin for mobile */
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.emoji-button {
|
||||
font-size: 28px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin: 3px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@state()
|
||||
private _hidden = true;
|
||||
|
||||
initEventBus() {
|
||||
this.eventBus.on(ShowEmojiMenuEvent, (e) => {
|
||||
this.isVisible = true;
|
||||
const cell = this.transformHandler.screenToWorldCoordinates(e.x, e.y);
|
||||
if (!this.game.isValidCoord(cell.x, cell.y)) {
|
||||
return;
|
||||
@@ -131,40 +54,66 @@ export class EmojiTable extends LitElement {
|
||||
private onEmojiClicked: (emoji: string) => void = () => {};
|
||||
|
||||
render() {
|
||||
if (!this.isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="emoji-table ${this._hidden ? "hidden" : ""}">
|
||||
${emojiTable.map(
|
||||
(row) => html`
|
||||
<div class="emoji-row">
|
||||
${row.map(
|
||||
(emoji) => html`
|
||||
<button
|
||||
class="emoji-button"
|
||||
@click=${() => this.onEmojiClicked(emoji)}
|
||||
>
|
||||
${emoji}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
<div
|
||||
class="bg-slate-800 max-w-[95vw] max-h-[95vh] pt-[15px] pb-[15px] fixed flex flex-col -translate-x-1/2 -translate-y-1/2
|
||||
items-center rounded-[10px] z-[9999] top-[50%] left-[50%] justify-center"
|
||||
@contextmenu=${(e) => e.preventDefault()}
|
||||
@wheel=${(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Close button -->
|
||||
<button
|
||||
class="absolute -top-2 -right-2 w-6 h-6 flex items-center justify-center
|
||||
bg-red-500 hover:bg-red-900 text-white rounded-full
|
||||
text-sm font-bold transition-colors"
|
||||
@click=${this.hideTable}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<div
|
||||
class="flex flex-col overflow-y-auto"
|
||||
style="scrollbar-gutter: stable both-edges;"
|
||||
>
|
||||
${emojiTable.map(
|
||||
(row) => html`
|
||||
<div class="w-full justify-center flex">
|
||||
${row.map(
|
||||
(emoji) => html`
|
||||
<button
|
||||
class="flex transition-transform duration-300 ease justify-center items-center cursor-pointer
|
||||
border border-solid border-slate-500 rounded-[12px] bg-slate-700 hover:bg-slate-600 active:bg-slate-500
|
||||
md:m-[8px] md:text-[60px] md:w-[80px] md:h-[80px] hover:scale-[1.1] active:scale-[0.95]
|
||||
sm:w-[60px] sm:h-[60px] sm:text-[32px] sm:m-[5px] text-[28px] w-[50px] h-[50px] m-[3px]"
|
||||
@click=${() => this.onEmojiClicked(emoji)}
|
||||
>
|
||||
${emoji}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
hideTable() {
|
||||
this._hidden = true;
|
||||
this.isVisible = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
showTable(oneEmojiClicked: (emoji: string) => void) {
|
||||
this.onEmojiClicked = oneEmojiClicked;
|
||||
this._hidden = false;
|
||||
this.isVisible = true;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
get isVisible() {
|
||||
return !this._hidden;
|
||||
createRenderRoot() {
|
||||
return this; // Disable shadow DOM to allow Tailwind styles
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
}
|
||||
|
||||
private toggleEventFilter(filterName: MessageCategory) {
|
||||
const currentState = this.eventsFilters.get(filterName) || false;
|
||||
const currentState = this.eventsFilters.get(filterName) ?? false;
|
||||
this.eventsFilters.set(filterName, !currentState);
|
||||
this.requestUpdate();
|
||||
}
|
||||
@@ -351,8 +351,15 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
}
|
||||
}
|
||||
|
||||
let description: string = event.message;
|
||||
if (event.params !== undefined) {
|
||||
if (event.message.startsWith("events_display.")) {
|
||||
description = translateText(event.message, event.params);
|
||||
}
|
||||
}
|
||||
|
||||
this.addEvent({
|
||||
description: event.message,
|
||||
description: description,
|
||||
createdAt: this.game.ticks(),
|
||||
highlight: true,
|
||||
type: event.messageType,
|
||||
@@ -375,7 +382,7 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
if (event.target) {
|
||||
try {
|
||||
const targetPlayer = this.game.player(event.target);
|
||||
const targetName = targetPlayer?.name() ?? event.target;
|
||||
const targetName = targetPlayer?.displayName() ?? event.target;
|
||||
translatedMessage = baseMessage.replace("[P1]", targetName);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
@@ -386,9 +393,16 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
}
|
||||
}
|
||||
|
||||
let otherPlayerDiplayName: string = "";
|
||||
if (event.recipient !== null) {
|
||||
//'recipient' parameter contains sender ID or recipient ID
|
||||
const player = this.game.player(event.recipient);
|
||||
otherPlayerDiplayName = player ? player.displayName() : "";
|
||||
}
|
||||
|
||||
this.addEvent({
|
||||
description: translateText(event.isFrom ? "chat.from" : "chat.to", {
|
||||
user: event.recipient,
|
||||
user: otherPlayerDiplayName,
|
||||
msg: translatedMessage,
|
||||
}),
|
||||
createdAt: this.game.ticks(),
|
||||
@@ -893,7 +907,7 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
: html`
|
||||
<!-- Main Events Display -->
|
||||
<div
|
||||
class="relative w-full lg:bottom-2.5 lg:right-2.5 z-50 lg:w-96 backdrop-blur"
|
||||
class="relative w-full sm:bottom-2.5 sm:right-2.5 z-50 sm:w-96 backdrop-blur"
|
||||
>
|
||||
<!-- Button Bar -->
|
||||
<div
|
||||
|
||||
@@ -22,6 +22,7 @@ export class GameLeftSidebar extends LitElement implements Layer {
|
||||
|
||||
private playerColor: Colord = new Colord("#FFFFFF");
|
||||
public game: GameView;
|
||||
private _shownOnInit = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
@@ -32,12 +33,15 @@ export class GameLeftSidebar extends LitElement implements Layer {
|
||||
if (this.isTeamGame) {
|
||||
this.isPlayerTeamLabelVisible = true;
|
||||
}
|
||||
// Make it visible by default on large screens
|
||||
if (window.innerWidth >= 1024) {
|
||||
// lg breakpoint
|
||||
this._shownOnInit = true;
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
tick() {
|
||||
if (!this.isPlayerTeamLabelVisible) return;
|
||||
|
||||
if (!this.playerTeam && this.game.myPlayer()?.team()) {
|
||||
this.playerTeam = this.game.myPlayer()!.team();
|
||||
if (this.playerTeam) {
|
||||
@@ -49,6 +53,12 @@ export class GameLeftSidebar extends LitElement implements Layer {
|
||||
}
|
||||
}
|
||||
|
||||
if (this._shownOnInit && !this.game.inSpawnPhase()) {
|
||||
this._shownOnInit = false;
|
||||
this.isLeaderboardShow = true;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
if (!this.game.inSpawnPhase()) {
|
||||
this.isPlayerTeamLabelVisible = false;
|
||||
this.requestUpdate();
|
||||
@@ -130,7 +140,7 @@ export class GameLeftSidebar extends LitElement implements Layer {
|
||||
<div class="block lg:flex flex-wrap gap-2">
|
||||
<leader-board .visible=${this.isLeaderboardShow}></leader-board>
|
||||
<team-stats
|
||||
class=${`flex-1 ${this.isTeamLeaderboardShow ? "sm:mt-4 lg:mt-12" : ""}`}
|
||||
class="flex-1"
|
||||
.visible=${this.isTeamLeaderboardShow && this.isTeamGame}
|
||||
></team-stats>
|
||||
</div>
|
||||
|
||||
@@ -76,7 +76,9 @@ export class GameRightSidebar extends LitElement implements Layer {
|
||||
|
||||
private toggleReplayPanel(): void {
|
||||
this._isReplayVisible = !this._isReplayVisible;
|
||||
this.eventBus.emit(new ShowReplayPanelEvent(this._isReplayVisible));
|
||||
this.eventBus.emit(
|
||||
new ShowReplayPanelEvent(this._isReplayVisible, this._isSinglePlayer),
|
||||
);
|
||||
}
|
||||
|
||||
private onPauseButtonClick() {
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import goldCoinIcon from "../../../../resources/images/GoldCoinIcon.svg";
|
||||
import populationIcon from "../../../../resources/images/PopulationIconSolidWhite.svg";
|
||||
import troopIcon from "../../../../resources/images/TroopIconWhite.svg";
|
||||
import workerIcon from "../../../../resources/images/WorkerIconWhite.svg";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { GameUpdateType } from "../../../core/game/GameUpdates";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { renderNumber, renderTroops } from "../../Utils";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
@customElement("game-top-bar")
|
||||
export class GameTopBar extends LitElement implements Layer {
|
||||
public game: GameView;
|
||||
public eventBus: EventBus;
|
||||
private _troops = 0;
|
||||
private _workers = 0;
|
||||
private _lastPopulationIncreaseRate = 0;
|
||||
private _popRateIsIncreasing = false;
|
||||
private hasWinner = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
tick() {
|
||||
this.updatePopulationIncrease();
|
||||
const player = this.game?.myPlayer();
|
||||
if (!player) return;
|
||||
this._troops = player.troops();
|
||||
this._workers = player.workers();
|
||||
const updates = this.game.updatesSinceLastTick();
|
||||
if (updates) {
|
||||
this.hasWinner = this.hasWinner || updates[GameUpdateType.Win].length > 0;
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private updatePopulationIncrease() {
|
||||
const player = this.game?.myPlayer();
|
||||
if (player === null) return;
|
||||
const popIncreaseRate = this.game.config().populationIncreaseRate(player);
|
||||
this._popRateIsIncreasing =
|
||||
popIncreaseRate >= this._lastPopulationIncreaseRate;
|
||||
this._lastPopulationIncreaseRate = popIncreaseRate;
|
||||
}
|
||||
|
||||
render() {
|
||||
const myPlayer = this.game?.myPlayer();
|
||||
if (!this.game || !myPlayer || this.game.inSpawnPhase()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isAlt = this.game.config().isReplay();
|
||||
if (isAlt) {
|
||||
return html`
|
||||
<div
|
||||
class="absolute top-4 left-1/2 transform -translate-x-1/2 flex justify-center items-center p-2"
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
const popRate = myPlayer
|
||||
? this.game.config().populationIncreaseRate(myPlayer) * 10
|
||||
: 0;
|
||||
const maxPop = myPlayer ? this.game.config().maxPopulation(myPlayer) : 0;
|
||||
const goldPerSecond = myPlayer
|
||||
? this.game.config().goldAdditionRate(myPlayer) * 10n
|
||||
: 0n;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="fixed top-4 left-1/2 transform -translate-x-1/2 flex justify-center items-center p-1 md:px-1.5 lg:px-4 z-[1100]"
|
||||
>
|
||||
<div class="flex justify-center items-center gap-1">
|
||||
${myPlayer?.isAlive() && !this.game.inSpawnPhase()
|
||||
? html`
|
||||
<div class="overflow-x-auto hide-scrollbar">
|
||||
<div
|
||||
class="grid gap-1 grid-cols-[80px_100px_80px] w-max md:gap-2 md:grid-cols-[90px_120px_90px]"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap gap-1 flex-col bg-gray-800/70 border border-slate-400 p-0.5 md:px-1 lg:px-2"
|
||||
>
|
||||
<div class="flex gap-2 items-center justify-between">
|
||||
<img
|
||||
src=${goldCoinIcon}
|
||||
alt="gold"
|
||||
width="20"
|
||||
height="20"
|
||||
style="vertical-align: middle;"
|
||||
/>
|
||||
<span class="text-white"
|
||||
>+${renderNumber(goldPerSecond)}</span
|
||||
>
|
||||
</div>
|
||||
<div class="text-white">
|
||||
${renderNumber(myPlayer.gold())}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap gap-1 flex-col bg-gray-800/70 border border-slate-400 p-0.5 md:px-1 lg:px-2"
|
||||
>
|
||||
<div class="flex gap-2 items-center justify-between">
|
||||
<img
|
||||
src=${populationIcon}
|
||||
alt="population"
|
||||
width="20"
|
||||
height="20"
|
||||
style="vertical-align: middle;"
|
||||
/>
|
||||
<span
|
||||
class="${this._popRateIsIncreasing
|
||||
? "text-green-500"
|
||||
: "text-yellow-500"}"
|
||||
translate="no"
|
||||
>
|
||||
+${renderTroops(popRate)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-white">
|
||||
${renderTroops(myPlayer.population())} /
|
||||
${renderTroops(maxPop)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex bg-gray-800/70 border border-slate-400 p-0.5 md:px-1 lg:px-2"
|
||||
>
|
||||
<div class="flex flex-col flex-grow gap-1 w-full ">
|
||||
<div class="flex gap-1">
|
||||
<img
|
||||
src=${troopIcon}
|
||||
alt="troops"
|
||||
width="20"
|
||||
height="20"
|
||||
style="vertical-align: middle;"
|
||||
/>
|
||||
<span class="text-white"
|
||||
>${renderTroops(this._troops)}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<img
|
||||
src=${workerIcon}
|
||||
alt="gold"
|
||||
width="20"
|
||||
height="20"
|
||||
style="vertical-align: middle;"
|
||||
/>
|
||||
<span class="text-white"
|
||||
>${renderTroops(this._workers)}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: html`<div></div>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,6 @@ export class Leaderboard extends LitElement implements Layer {
|
||||
players: Entry[] = [];
|
||||
|
||||
@property({ type: Boolean }) visible = false;
|
||||
private _shownOnInit = false;
|
||||
private showTopFive = true;
|
||||
|
||||
@state()
|
||||
@@ -57,10 +56,6 @@ export class Leaderboard extends LitElement implements Layer {
|
||||
|
||||
tick() {
|
||||
if (this.game === null) throw new Error("Not initialized");
|
||||
if (!this._shownOnInit && !this.game.inSpawnPhase()) {
|
||||
this._shownOnInit = true;
|
||||
this.updateLeaderboard();
|
||||
}
|
||||
if (!this.visible) return;
|
||||
if (this.game.ticks() % 10 === 0) {
|
||||
this.updateLeaderboard();
|
||||
@@ -174,37 +169,25 @@ export class Leaderboard extends LitElement implements Layer {
|
||||
}
|
||||
return html`
|
||||
<div
|
||||
class="max-h-[35vh] overflow-y-auto text-white text-xs md:text-sm md:max-h-[50vh] ${this
|
||||
class="max-h-[35vh] overflow-y-auto text-white text-xs md:text-xs lg:text-sm md:max-h-[50vh] ${this
|
||||
.visible
|
||||
? ""
|
||||
: "hidden"}"
|
||||
@contextmenu=${(e: Event) => e.preventDefault()}
|
||||
>
|
||||
<button
|
||||
class="mb-2 px-2 py-1 md:px-2.5 md:py-1.5 text-xs md:text-sm lg:text-base border border-white/20 hover:bg-white/10"
|
||||
@click=${() => {
|
||||
this.showTopFive = !this.showTopFive;
|
||||
this.updateLeaderboard();
|
||||
}}
|
||||
>
|
||||
${this.showTopFive
|
||||
? translateText("leaderboard.show_all")
|
||||
: translateText("leaderboard.show_top_5")}
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="grid bg-gray-800/70 w-full text-xs md:text-sm lg:text-base"
|
||||
style="grid-template-columns: 35px 100px 85px 65px 65px;"
|
||||
class="grid bg-gray-800/70 w-full text-xs md:text-xs lg:text-sm"
|
||||
style="grid-template-columns: 30px 100px 70px 55px 75px;"
|
||||
>
|
||||
<div class="contents font-bold bg-gray-700/50">
|
||||
<div class="py-1.5 md:py-2.5 text-center border-b border-slate-500">
|
||||
<div class="py-1 md:py-2 text-center border-b border-slate-500">
|
||||
#
|
||||
</div>
|
||||
<div class="py-1.5 md:py-2.5 text-center border-b border-slate-500">
|
||||
<div class="py-1 md:py-2 text-center border-b border-slate-500">
|
||||
${translateText("leaderboard.player")}
|
||||
</div>
|
||||
<div
|
||||
class="py-1.5 md:py-2.5 text-center border-b border-slate-500 cursor-pointer"
|
||||
class="py-1 md:py-2 text-center border-b border-slate-500 cursor-pointer whitespace-nowrap"
|
||||
@click=${() => this.setSort("tiles")}
|
||||
>
|
||||
${translateText("leaderboard.owned")}
|
||||
@@ -215,7 +198,7 @@ export class Leaderboard extends LitElement implements Layer {
|
||||
: ""}
|
||||
</div>
|
||||
<div
|
||||
class="py-1.5 md:py-2.5 text-center border-b border-slate-500 cursor-pointer"
|
||||
class="py-1 md:py-2 text-center border-b border-slate-500 cursor-pointer whitespace-nowrap"
|
||||
@click=${() => this.setSort("gold")}
|
||||
>
|
||||
${translateText("leaderboard.gold")}
|
||||
@@ -226,7 +209,7 @@ export class Leaderboard extends LitElement implements Layer {
|
||||
: ""}
|
||||
</div>
|
||||
<div
|
||||
class="py-1.5 md:py-2.5 text-center border-b border-slate-500 cursor-pointer"
|
||||
class="py-1 md:py-2 text-center border-b border-slate-500 cursor-pointer whitespace-nowrap"
|
||||
@click=${() => this.setSort("troops")}
|
||||
>
|
||||
${translateText("leaderboard.troops")}
|
||||
@@ -248,29 +231,21 @@ export class Leaderboard extends LitElement implements Layer {
|
||||
: ""} cursor-pointer"
|
||||
@click=${() => this.handleRowClickPlayer(player.player)}
|
||||
>
|
||||
<div
|
||||
class="py-1.5 md:py-2.5 text-center border-b border-slate-500"
|
||||
>
|
||||
<div class="py-1 md:py-2 text-center border-b border-slate-500">
|
||||
${player.position}
|
||||
</div>
|
||||
<div
|
||||
class="py-1.5 md:py-2.5 text-center border-b border-slate-500 truncate"
|
||||
class="py-1 md:py-2 text-center border-b border-slate-500 truncate"
|
||||
>
|
||||
${player.name}
|
||||
</div>
|
||||
<div
|
||||
class="py-1.5 md:py-2.5 text-center border-b border-slate-500"
|
||||
>
|
||||
<div class="py-1 md:py-2 text-center border-b border-slate-500">
|
||||
${player.score}
|
||||
</div>
|
||||
<div
|
||||
class="py-1.5 md:py-2.5 text-center border-b border-slate-500"
|
||||
>
|
||||
<div class="py-1 md:py-2 text-center border-b border-slate-500">
|
||||
${player.gold}
|
||||
</div>
|
||||
<div
|
||||
class="py-1.5 md:py-2.5 text-center border-b border-slate-500"
|
||||
>
|
||||
<div class="py-1 md:py-2 text-center border-b border-slate-500">
|
||||
${player.troops}
|
||||
</div>
|
||||
</div>
|
||||
@@ -278,6 +253,16 @@ export class Leaderboard extends LitElement implements Layer {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="mt-1 px-1.5 py-0.5 md:px-2 md:py-0.5 text-xs md:text-xs lg:text-sm border border-white/20 hover:bg-white/10 text-white mx-auto block"
|
||||
@click=${() => {
|
||||
this.showTopFive = !this.showTopFive;
|
||||
this.updateLeaderboard();
|
||||
}}
|
||||
>
|
||||
${this.showTopFive ? "+" : "-"}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { LitElement } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { PlayerActions, TerraNullius } from "../../../core/game/Game";
|
||||
import { PlayerActions } from "../../../core/game/Game";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import { GameView, PlayerView } from "../../../core/game/GameView";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
centerButtonElement,
|
||||
COLORS,
|
||||
MenuElementParams,
|
||||
rootMenuItems,
|
||||
rootMenuElement,
|
||||
} from "./RadialMenuElements";
|
||||
|
||||
import swordIcon from "../../../../resources/images/SwordIconWhite.svg";
|
||||
@@ -31,7 +31,6 @@ export class MainRadialMenu extends LitElement implements Layer {
|
||||
private chatIntegration: ChatIntegration;
|
||||
|
||||
private clickedTile: TileRef | null = null;
|
||||
private selectedPlayer: PlayerView | TerraNullius | null = null;
|
||||
|
||||
constructor(
|
||||
private eventBus: EventBus,
|
||||
@@ -57,7 +56,12 @@ export class MainRadialMenu extends LitElement implements Layer {
|
||||
`,
|
||||
};
|
||||
|
||||
this.radialMenu = new RadialMenu(menuConfig);
|
||||
this.radialMenu = new RadialMenu(
|
||||
this.eventBus,
|
||||
rootMenuElement,
|
||||
centerButtonElement,
|
||||
menuConfig,
|
||||
);
|
||||
|
||||
this.playerActionHandler = new PlayerActionHandler(
|
||||
this.eventBus,
|
||||
@@ -65,8 +69,6 @@ export class MainRadialMenu extends LitElement implements Layer {
|
||||
);
|
||||
|
||||
this.chatIntegration = new ChatIntegration(this.game, this.eventBus);
|
||||
|
||||
this.radialMenu.setRootMenuItems(rootMenuItems, centerButtonElement);
|
||||
}
|
||||
|
||||
init() {
|
||||
@@ -83,12 +85,11 @@ export class MainRadialMenu extends LitElement implements Layer {
|
||||
return;
|
||||
}
|
||||
this.clickedTile = this.game.ref(worldCoords.x, worldCoords.y);
|
||||
this.selectedPlayer = this.game.owner(this.clickedTile);
|
||||
this.game
|
||||
.myPlayer()!
|
||||
.actions(this.clickedTile)
|
||||
.then((actions) => {
|
||||
this.handlePlayerActions(
|
||||
this.updatePlayerActions(
|
||||
this.game.myPlayer()!,
|
||||
actions,
|
||||
this.clickedTile!,
|
||||
@@ -99,12 +100,12 @@ export class MainRadialMenu extends LitElement implements Layer {
|
||||
});
|
||||
}
|
||||
|
||||
private async handlePlayerActions(
|
||||
private async updatePlayerActions(
|
||||
myPlayer: PlayerView,
|
||||
actions: PlayerActions,
|
||||
tile: TileRef,
|
||||
screenX: number,
|
||||
screenY: number,
|
||||
screenX: number | null = null,
|
||||
screenY: number | null = null,
|
||||
) {
|
||||
this.buildMenu.playerActions = actions;
|
||||
|
||||
@@ -130,18 +131,27 @@ export class MainRadialMenu extends LitElement implements Layer {
|
||||
eventBus: this.eventBus,
|
||||
};
|
||||
|
||||
this.radialMenu.setRootMenuItems(rootMenuItems, centerButtonElement);
|
||||
this.radialMenu.setParams(params);
|
||||
this.radialMenu.showRadialMenu(screenX, screenY);
|
||||
if (screenX !== null && screenY !== null) {
|
||||
this.radialMenu.showRadialMenu(screenX, screenY);
|
||||
} else {
|
||||
this.radialMenu.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async tick() {
|
||||
if (!this.radialMenu.isMenuVisible() || this.clickedTile === null) return;
|
||||
if (this.selectedPlayer === null) return;
|
||||
const currentPlayer = this.game.owner(this.clickedTile);
|
||||
if (currentPlayer.id() !== this.selectedPlayer.id()) {
|
||||
this.closeMenu();
|
||||
return;
|
||||
if (this.game.ticks() % 5 === 0) {
|
||||
this.game
|
||||
.myPlayer()!
|
||||
.actions(this.clickedTile)
|
||||
.then((actions) => {
|
||||
this.updatePlayerActions(
|
||||
this.game.myPlayer()!,
|
||||
actions,
|
||||
this.clickedTile!,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { Cell, PlayerActions, PlayerID } from "../../../core/game/Game";
|
||||
import { PlayerActions, PlayerID } from "../../../core/game/Game";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import { PlayerView } from "../../../core/game/GameView";
|
||||
import {
|
||||
@@ -61,8 +61,9 @@ export class PlayerActionHandler {
|
||||
): Promise<TileRef | false> {
|
||||
return await player.bestTransportShipSpawn(tile);
|
||||
}
|
||||
handleSpawn(spawnCell: Cell) {
|
||||
this.eventBus.emit(new SendSpawnIntentEvent(spawnCell));
|
||||
|
||||
handleSpawn(tile: TileRef) {
|
||||
this.eventBus.emit(new SendSpawnIntentEvent(tile));
|
||||
}
|
||||
|
||||
handleAllianceRequest(player: PlayerView, recipient: PlayerView) {
|
||||
|
||||
@@ -13,10 +13,11 @@ import {
|
||||
} from "../../../core/game/Game";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
|
||||
import { MouseMoveEvent } from "../../InputHandler";
|
||||
import { ContextMenuEvent, MouseMoveEvent } from "../../InputHandler";
|
||||
import { renderNumber, renderTroops } from "../../Utils";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
import { CloseRadialMenuEvent } from "./RadialMenu";
|
||||
|
||||
function euclideanDistWorld(
|
||||
coord: { x: number; y: number },
|
||||
@@ -69,6 +70,10 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
this.eventBus.on(MouseMoveEvent, (e: MouseMoveEvent) =>
|
||||
this.onMouseEvent(e),
|
||||
);
|
||||
this.eventBus.on(ContextMenuEvent, (e: ContextMenuEvent) =>
|
||||
this.maybeShow(e.x, e.y),
|
||||
);
|
||||
this.eventBus.on(CloseRadialMenuEvent, () => this.hide());
|
||||
this._isActive = true;
|
||||
}
|
||||
|
||||
@@ -165,6 +170,18 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
}
|
||||
}
|
||||
|
||||
private displayUnitCount(
|
||||
player: PlayerView,
|
||||
type: UnitType,
|
||||
description: string,
|
||||
) {
|
||||
return !this.game.config().isUnitDisabled(type)
|
||||
? html`<div class="text-sm opacity-80" translate="no">
|
||||
${translateText(description)}: ${player.totalUnitLevels(type)}
|
||||
</div>`
|
||||
: "";
|
||||
}
|
||||
|
||||
private renderPlayerInfo(player: PlayerView) {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
const isFriendly = myPlayer?.isFriendly(player);
|
||||
@@ -250,66 +267,36 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
${translateText("player_info_overlay.gold")}:
|
||||
${renderNumber(player.gold())}
|
||||
</div>
|
||||
<div class="text-sm opacity-80" translate="no">
|
||||
${translateText("player_info_overlay.ports")}:
|
||||
${player.units(UnitType.Port).length}
|
||||
${player
|
||||
.units(UnitType.Port)
|
||||
.map((unit) => unit.level())
|
||||
.reduce((a, b) => a + b, 0) > 1
|
||||
? html`(${translateText("player_info_overlay.levels")}:
|
||||
${player
|
||||
.units(UnitType.Port)
|
||||
.map((unit) => unit.level())
|
||||
.reduce((a, b) => a + b, 0)})`
|
||||
: ""}
|
||||
</div>
|
||||
<div class="text-sm opacity-80" translate="no">
|
||||
${translateText("player_info_overlay.cities")}:
|
||||
${player.units(UnitType.City).length}
|
||||
${player
|
||||
.units(UnitType.City)
|
||||
.map((unit) => unit.level())
|
||||
.reduce((a, b) => a + b, 0) > 1
|
||||
? html`(${translateText("player_info_overlay.levels")}:
|
||||
${player
|
||||
.units(UnitType.City)
|
||||
.map((unit) => unit.level())
|
||||
.reduce((a, b) => a + b, 0)})`
|
||||
: ""}
|
||||
</div>
|
||||
<div class="text-sm opacity-80" translate="no">
|
||||
${translateText("player_info_overlay.missile_launchers")}:
|
||||
${player.units(UnitType.MissileSilo).length}
|
||||
${player
|
||||
.units(UnitType.MissileSilo)
|
||||
.map((unit) => unit.level())
|
||||
.reduce((a, b) => a + b, 0) > 1
|
||||
? html`(${translateText("player_info_overlay.levels")}:
|
||||
${player
|
||||
.units(UnitType.MissileSilo)
|
||||
.map((unit) => unit.level())
|
||||
.reduce((a, b) => a + b, 0)})`
|
||||
: ""}
|
||||
</div>
|
||||
<div class="text-sm opacity-80" translate="no">
|
||||
${translateText("player_info_overlay.sams")}:
|
||||
${player.units(UnitType.SAMLauncher).length}
|
||||
${player
|
||||
.units(UnitType.SAMLauncher)
|
||||
.map((unit) => unit.level())
|
||||
.reduce((a, b) => a + b, 0) > 1
|
||||
? html`(${translateText("player_info_overlay.levels")}:
|
||||
${player
|
||||
.units(UnitType.SAMLauncher)
|
||||
.map((unit) => unit.level())
|
||||
.reduce((a, b) => a + b, 0)})`
|
||||
: ""}
|
||||
</div>
|
||||
<div class="text-sm opacity-80" translate="no">
|
||||
${translateText("player_info_overlay.warships")}:
|
||||
${player.units(UnitType.Warship).length}
|
||||
</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>
|
||||
`;
|
||||
@@ -352,7 +339,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="hidden lg:flex fixed top-[245px] right-0 w-full z-50 flex-col max-w-[180px]"
|
||||
class="block lg:flex fixed top-[150px] right-0 w-full z-50 flex-col max-w-[180px]"
|
||||
@contextmenu=${(e) => e.preventDefault()}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -233,7 +233,7 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="fixed inset-0 flex items-center justify-center z-50 pointer-events-none overflow-auto"
|
||||
class="fixed inset-0 flex items-center justify-center z-[1001] pointer-events-none overflow-auto"
|
||||
@contextmenu=${(e) => e.preventDefault()}
|
||||
@wheel=${(e) => e.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as d3 from "d3";
|
||||
import backIcon from "../../../../resources/images/BackIconWhite.svg";
|
||||
import { EventBus, GameEvent } from "../../../core/EventBus";
|
||||
import { Layer } from "./Layer";
|
||||
import {
|
||||
CenterButtonElement,
|
||||
@@ -7,6 +8,10 @@ import {
|
||||
MenuElementParams,
|
||||
} from "./RadialMenuElements";
|
||||
|
||||
export class CloseRadialMenuEvent implements GameEvent {
|
||||
constructor() {}
|
||||
}
|
||||
|
||||
export interface TooltipItem {
|
||||
text: string;
|
||||
className: string;
|
||||
@@ -39,13 +44,11 @@ export class RadialMenu implements Layer {
|
||||
private currentLevel: number = 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)
|
||||
private rootMenuItems: MenuElement[] = []; // Store the original root menu items
|
||||
|
||||
private readonly config: RequiredRadialMenuConfig;
|
||||
private readonly backIconSize: number;
|
||||
|
||||
private centerButtonState: CenterButtonState = "default";
|
||||
private centerButtonElement: CenterButtonElement | null = null;
|
||||
|
||||
private isTransitioning: boolean = false;
|
||||
private lastHideTime: number = 0;
|
||||
@@ -72,7 +75,12 @@ export class RadialMenu implements Layer {
|
||||
|
||||
private params: MenuElementParams | null = null;
|
||||
|
||||
constructor(config: RadialMenuConfig = {}) {
|
||||
constructor(
|
||||
private eventBus: EventBus,
|
||||
private rootMenu: MenuElement,
|
||||
private centerButtonElement: CenterButtonElement,
|
||||
config: RadialMenuConfig = {},
|
||||
) {
|
||||
this.config = {
|
||||
menuSize: config.menuSize ?? 190,
|
||||
submenuScale: config.submenuScale ?? 1.5,
|
||||
@@ -112,10 +120,12 @@ export class RadialMenu implements Layer {
|
||||
.style("height", "100vh")
|
||||
.on("click", () => {
|
||||
this.hideRadialMenu();
|
||||
this.eventBus.emit(new CloseRadialMenuEvent());
|
||||
})
|
||||
.on("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
this.hideRadialMenu();
|
||||
this.eventBus.emit(new CloseRadialMenuEvent());
|
||||
});
|
||||
|
||||
// Calculate the total svg size needed for all potential nested menus
|
||||
@@ -217,7 +227,7 @@ export class RadialMenu implements Layer {
|
||||
}
|
||||
|
||||
private getInnerRadiusForLevel(level: number): number {
|
||||
return level === 0 ? 50 : 50 + 25;
|
||||
return level === 0 ? 40 : 50 + 25;
|
||||
}
|
||||
|
||||
private getOuterRadiusForLevel(level: number): number {
|
||||
@@ -237,10 +247,12 @@ export class RadialMenu implements Layer {
|
||||
.append("g")
|
||||
.attr("class", `menu-level-${level}`);
|
||||
|
||||
// Set initial animation styles
|
||||
// Set initial animation styles only for submenus (level > 0)
|
||||
if (level === 0) {
|
||||
menuGroup.style("opacity", 0.5).style("transform", "scale(0.2)");
|
||||
// Main menu appears immediately without animation
|
||||
menuGroup.style("opacity", 1).style("transform", "scale(1)");
|
||||
} else {
|
||||
// Submenus get the expansion animation
|
||||
menuGroup.style("opacity", 0).style("transform", "scale(0.5)");
|
||||
}
|
||||
|
||||
@@ -296,14 +308,14 @@ export class RadialMenu implements Layer {
|
||||
const disabled = this.params === null || d.data.disabled(this.params);
|
||||
const color = disabled
|
||||
? this.config.disabledColor
|
||||
: d.data.color || "#333333";
|
||||
: (d.data.color ?? "#333333");
|
||||
const opacity = disabled ? 0.5 : 0.7;
|
||||
|
||||
if (d.data.id === this.selectedItemId && this.currentLevel > level) {
|
||||
return color;
|
||||
}
|
||||
|
||||
return d3.color(color)?.copy({ opacity: opacity })?.toString() || color;
|
||||
return d3.color(color)?.copy({ opacity: opacity })?.toString() ?? color;
|
||||
})
|
||||
.attr("stroke", "#ffffff")
|
||||
.attr("stroke-width", "2")
|
||||
@@ -339,7 +351,7 @@ export class RadialMenu implements Layer {
|
||||
const color =
|
||||
this.params === null || d.data.disabled(this.params)
|
||||
? this.config.disabledColor
|
||||
: d.data.color || "#333333";
|
||||
: (d.data.color ?? "#333333");
|
||||
path.attr("fill", color);
|
||||
}
|
||||
});
|
||||
@@ -403,11 +415,11 @@ export class RadialMenu implements Layer {
|
||||
path.attr("stroke-width", "2");
|
||||
const color = disabled
|
||||
? this.config.disabledColor
|
||||
: d.data.color || "#333333";
|
||||
: (d.data.color ?? "#333333");
|
||||
const opacity = disabled ? 0.5 : 0.7;
|
||||
path.attr(
|
||||
"fill",
|
||||
d3.color(color)?.copy({ opacity: opacity })?.toString() || color,
|
||||
d3.color(color)?.copy({ opacity: opacity })?.toString() ?? color,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -435,6 +447,8 @@ export class RadialMenu implements Layer {
|
||||
this.updateCenterButtonState("back");
|
||||
} else {
|
||||
d.data.action?.(this.params);
|
||||
// Force transition state to false to ensure menu hides
|
||||
this.isTransitioning = false;
|
||||
this.hideRadialMenu();
|
||||
}
|
||||
};
|
||||
@@ -477,6 +491,14 @@ export class RadialMenu implements Layer {
|
||||
});
|
||||
}
|
||||
|
||||
private isItemDisabled(item: MenuElement): boolean {
|
||||
return (
|
||||
this.params === null ||
|
||||
this.params.game.inSpawnPhase() ||
|
||||
item.disabled(this.params)
|
||||
);
|
||||
}
|
||||
|
||||
private renderIconsAndText(
|
||||
arcs: d3.Selection<
|
||||
SVGGElement,
|
||||
@@ -494,10 +516,7 @@ export class RadialMenu implements Layer {
|
||||
.each((d) => {
|
||||
const contentId = d.data.id;
|
||||
const content = d3.select(`g[data-id="${contentId}"]`);
|
||||
const disabled =
|
||||
this.params === null ||
|
||||
this.params.game.inSpawnPhase() ||
|
||||
d.data.disabled(this.params);
|
||||
const disabled = this.isItemDisabled(d.data);
|
||||
|
||||
if (d.data.text) {
|
||||
content
|
||||
@@ -555,24 +574,43 @@ export class RadialMenu implements Layer {
|
||||
}
|
||||
|
||||
private updateMenuGroupVisibility() {
|
||||
// Hide all menus except the current and immediate previous one
|
||||
this.updateMenuVisibility("forward");
|
||||
}
|
||||
|
||||
private updateMenuVisibility(direction: "forward" | "backward" = "backward") {
|
||||
this.menuGroups.forEach((menuGroup, level) => {
|
||||
if (level === this.currentLevel) {
|
||||
// Current level - always visible and interactive
|
||||
menuGroup.style("display", "block");
|
||||
} else if (level === this.currentLevel - 1) {
|
||||
menuGroup.style("display", "block");
|
||||
|
||||
menuGroup
|
||||
.transition()
|
||||
.duration(this.config.menuTransitionDuration * 0.8)
|
||||
.style("transform", "scale(0.5)")
|
||||
.style("transform", "scale(1)")
|
||||
.style("opacity", 1);
|
||||
|
||||
// Enable pointer events for current level
|
||||
menuGroup.selectAll("path").style("pointer-events", "auto");
|
||||
} else if (level === this.currentLevel - 1 && this.currentLevel > 0) {
|
||||
// Previous level - visible but scaled down
|
||||
menuGroup.style("display", "block");
|
||||
menuGroup
|
||||
.transition()
|
||||
.duration(this.config.menuTransitionDuration * 0.8)
|
||||
.style(
|
||||
"transform",
|
||||
`scale(${this.currentLevel === 1 ? "0.65" : "0.5"})`,
|
||||
)
|
||||
.style("opacity", 0.8);
|
||||
|
||||
menuGroup.selectAll("path").each(function () {
|
||||
const pathElement = d3.select(this);
|
||||
pathElement.style("pointer-events", "none");
|
||||
});
|
||||
} else {
|
||||
// Disable pointer events for previous level when going forward
|
||||
if (direction === "forward") {
|
||||
menuGroup.selectAll("path").each(function () {
|
||||
const pathElement = d3.select(this);
|
||||
pathElement.style("pointer-events", "none");
|
||||
});
|
||||
}
|
||||
} else if (level !== this.currentLevel + 1) {
|
||||
// Hide all other levels
|
||||
menuGroup
|
||||
.transition()
|
||||
.duration(this.config.menuTransitionDuration * 0.5)
|
||||
@@ -610,7 +648,7 @@ export class RadialMenu implements Layer {
|
||||
|
||||
this.updateMenuLevels();
|
||||
this.clearSelectedItemHoverState();
|
||||
this.updateMenuVisibility();
|
||||
this.updateMenuVisibility("backward");
|
||||
this.animateMenuTransitions();
|
||||
}
|
||||
|
||||
@@ -623,7 +661,7 @@ export class RadialMenu implements Layer {
|
||||
this.selectedItemId = null;
|
||||
}
|
||||
|
||||
this.currentMenuItems = previousItems || [];
|
||||
this.currentMenuItems = previousItems ?? [];
|
||||
|
||||
if (this.currentLevel === 0) {
|
||||
this.updateCenterButtonState("default");
|
||||
@@ -637,54 +675,10 @@ export class RadialMenu implements Layer {
|
||||
if (selectedPath) {
|
||||
selectedPath.attr("filter", null);
|
||||
selectedPath.attr("stroke-width", "2");
|
||||
|
||||
const item = this.findMenuItem(this.selectedItemId);
|
||||
if (item) {
|
||||
const disabled = this.params === null || item.disabled(this.params);
|
||||
const color = disabled
|
||||
? this.config.disabledColor
|
||||
: item.color || "#333333";
|
||||
const opacity = disabled ? 0.5 : 0.7;
|
||||
selectedPath.attr(
|
||||
"fill",
|
||||
d3.color(color)?.copy({ opacity: opacity })?.toString() || color,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateMenuVisibility() {
|
||||
this.menuGroups.forEach((menuGroup, level) => {
|
||||
if (level === this.currentLevel) {
|
||||
menuGroup.style("display", "block");
|
||||
menuGroup
|
||||
.transition()
|
||||
.duration(this.config.menuTransitionDuration * 0.8)
|
||||
.style("transform", "scale(1)")
|
||||
.style("opacity", 1);
|
||||
|
||||
menuGroup.selectAll("path").style("pointer-events", "auto");
|
||||
} else if (level === this.currentLevel - 1 && this.currentLevel > 0) {
|
||||
menuGroup.style("display", "block");
|
||||
menuGroup
|
||||
.transition()
|
||||
.duration(this.config.menuTransitionDuration * 0.8)
|
||||
.style(
|
||||
"transform",
|
||||
`scale(${this.currentLevel === 1 ? "0.65" : "0.5"})`,
|
||||
)
|
||||
.style("opacity", 0.8);
|
||||
} else if (level !== this.currentLevel + 1) {
|
||||
menuGroup
|
||||
.transition()
|
||||
.duration(this.config.menuTransitionDuration * 0.5)
|
||||
.style("opacity", 0)
|
||||
.on("end", function () {
|
||||
d3.select(this).style("display", "none");
|
||||
});
|
||||
}
|
||||
});
|
||||
// Use refresh() to update all item appearances consistently
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
private animateMenuTransitions() {
|
||||
@@ -765,10 +759,13 @@ export class RadialMenu implements Layer {
|
||||
}
|
||||
|
||||
public hideRadialMenu() {
|
||||
if (!this.isVisible || this.isTransitioning) {
|
||||
if (!this.isVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Force transition state to false to ensure menu hides
|
||||
this.isTransitioning = false;
|
||||
|
||||
this.menuElement.style("display", "none");
|
||||
this.isVisible = false;
|
||||
this.selectedItemId = null;
|
||||
@@ -786,7 +783,7 @@ export class RadialMenu implements Layer {
|
||||
|
||||
private handleCenterButtonClick() {
|
||||
if (this.centerButtonState === "default") {
|
||||
if (this.params) {
|
||||
if (this.params && this.isCenterButtonEnabled()) {
|
||||
this.centerButtonElement?.action(this.params);
|
||||
}
|
||||
return;
|
||||
@@ -810,6 +807,28 @@ export class RadialMenu implements Layer {
|
||||
public updateCenterButtonState(state: CenterButtonState) {
|
||||
this.centerButtonState = state;
|
||||
if (state === "back") {
|
||||
const backButtonSize = this.config.centerButtonSize * 0.8; // Make back button 20% smaller
|
||||
this.menuElement
|
||||
.select(".center-button-hitbox")
|
||||
.transition()
|
||||
.duration(0)
|
||||
.attr("r", backButtonSize);
|
||||
this.menuElement
|
||||
.select(".center-button-visible")
|
||||
.transition()
|
||||
.duration(0)
|
||||
.attr("r", backButtonSize);
|
||||
|
||||
const backIconImg = this.menuElement.select(".center-button-icon");
|
||||
backIconImg
|
||||
.attr("xlink:href", backIcon)
|
||||
.attr("width", this.backIconSize)
|
||||
.attr("height", this.backIconSize)
|
||||
.attr("x", -this.backIconSize / 2)
|
||||
.attr("y", -this.backIconSize / 2);
|
||||
}
|
||||
if (state === "default") {
|
||||
// Restore original button size
|
||||
this.menuElement
|
||||
.select(".center-button-hitbox")
|
||||
.transition()
|
||||
@@ -821,15 +840,6 @@ export class RadialMenu implements Layer {
|
||||
.duration(0)
|
||||
.attr("r", this.config.centerButtonSize);
|
||||
|
||||
const backIconImg = this.menuElement.select(".center-button-icon");
|
||||
backIconImg
|
||||
.attr("xlink:href", backIcon)
|
||||
.attr("width", this.backIconSize)
|
||||
.attr("height", this.backIconSize)
|
||||
.attr("x", -this.backIconSize / 2)
|
||||
.attr("y", -this.backIconSize / 2);
|
||||
}
|
||||
if (state === "default") {
|
||||
const iconImg = this.menuElement.select(".center-button-icon");
|
||||
iconImg
|
||||
.attr("xlink:href", this.originalCenterButtonIcon)
|
||||
@@ -857,6 +867,11 @@ export class RadialMenu implements Layer {
|
||||
}
|
||||
|
||||
private isCenterButtonEnabled(): boolean {
|
||||
// Back button should always be enabled when in submenu levels
|
||||
if (this.currentLevel > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.params && this.centerButtonElement) {
|
||||
return !this.centerButtonElement.disabled(this.params);
|
||||
}
|
||||
@@ -889,18 +904,6 @@ export class RadialMenu implements Layer {
|
||||
return this.currentLevel;
|
||||
}
|
||||
|
||||
public setRootMenuItems(
|
||||
items: MenuElement[],
|
||||
centerButton: CenterButtonElement,
|
||||
) {
|
||||
this.currentMenuItems = [...items];
|
||||
this.rootMenuItems = [...items];
|
||||
this.centerButtonElement = centerButton;
|
||||
if (this.isVisible) {
|
||||
this.refreshMenu();
|
||||
}
|
||||
}
|
||||
|
||||
public setParams(params: MenuElementParams) {
|
||||
this.params = params;
|
||||
}
|
||||
@@ -913,7 +916,7 @@ export class RadialMenu implements Layer {
|
||||
this.currentLevel = 0;
|
||||
this.menuStack = [];
|
||||
|
||||
this.currentMenuItems = [...this.rootMenuItems];
|
||||
this.currentMenuItems = this.rootMenu.subMenu!(this.params!);
|
||||
|
||||
this.navigationInProgress = false;
|
||||
|
||||
@@ -944,6 +947,49 @@ export class RadialMenu implements Layer {
|
||||
this.renderMenuItems(this.currentMenuItems, this.currentLevel);
|
||||
}
|
||||
|
||||
public refresh() {
|
||||
if (!this.isVisible || !this.params) return;
|
||||
|
||||
// Refresh the disabled state of all menu items
|
||||
this.menuPaths.forEach((path, itemId) => {
|
||||
const item = this.findMenuItem(itemId);
|
||||
if (item) {
|
||||
const disabled = this.isItemDisabled(item);
|
||||
const color = disabled
|
||||
? this.config.disabledColor
|
||||
: (item.color ?? "#333333");
|
||||
const opacity = disabled ? 0.5 : 0.7;
|
||||
|
||||
// Update path appearance
|
||||
path.attr(
|
||||
"fill",
|
||||
d3.color(color)?.copy({ opacity: opacity })?.toString() ?? color,
|
||||
);
|
||||
path.style("opacity", disabled ? 0.5 : 1);
|
||||
path.style("cursor", disabled ? "not-allowed" : "pointer");
|
||||
|
||||
// Update icon/text appearance using the same logic as renderIconsAndText
|
||||
const icon = this.menuIcons.get(itemId);
|
||||
if (icon) {
|
||||
// Update text opacity
|
||||
const textElement = icon.select("text");
|
||||
if (!textElement.empty()) {
|
||||
textElement.style("opacity", disabled ? 0.5 : 1);
|
||||
}
|
||||
|
||||
// Update image opacity
|
||||
const imageElement = icon.select("image");
|
||||
if (!imageElement.empty()) {
|
||||
imageElement.attr("opacity", disabled ? 0.5 : 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh center button state
|
||||
this.updateCenterButtonState(this.centerButtonState);
|
||||
}
|
||||
|
||||
renderLayer(context: CanvasRenderingContext2D) {
|
||||
// No need to render anything on the canvas
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { Config } from "../../../core/configuration/Config";
|
||||
import {
|
||||
AllPlayers,
|
||||
Cell,
|
||||
PlayerActions,
|
||||
UnitType,
|
||||
} from "../../../core/game/Game";
|
||||
import { AllPlayers, PlayerActions, UnitType } from "../../../core/game/Game";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import { GameView, PlayerView } from "../../../core/game/GameView";
|
||||
import { flattenedEmojiTable } from "../../../core/Util";
|
||||
@@ -352,9 +347,9 @@ export const buildMenuElement: MenuElement = {
|
||||
: undefined,
|
||||
icon: item.icon,
|
||||
tooltipItems: [
|
||||
{ text: translateText(item.key || ""), className: "title" },
|
||||
{ text: translateText(item.key ?? ""), className: "title" },
|
||||
{
|
||||
text: translateText(item.description || ""),
|
||||
text: translateText(item.description ?? ""),
|
||||
className: "description",
|
||||
},
|
||||
{
|
||||
@@ -401,7 +396,7 @@ export const boatMenuElement: MenuElement = {
|
||||
|
||||
params.playerActionHandler.handleBoatAttack(
|
||||
params.myPlayer,
|
||||
params.selected?.id() || null,
|
||||
params.selected?.id() ?? null,
|
||||
params.tile,
|
||||
spawn !== false ? spawn : null,
|
||||
);
|
||||
@@ -423,15 +418,11 @@ export const centerButtonElement: CenterButtonElement = {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
return !params.playerActions.canAttack;
|
||||
},
|
||||
action: (params: MenuElementParams) => {
|
||||
if (params.game.inSpawnPhase()) {
|
||||
const cell = new Cell(
|
||||
params.game.x(params.tile),
|
||||
params.game.y(params.tile),
|
||||
);
|
||||
params.playerActionHandler.handleSpawn(cell);
|
||||
params.playerActionHandler.handleSpawn(params.tile);
|
||||
} else {
|
||||
params.playerActionHandler.handleAttack(
|
||||
params.myPlayer,
|
||||
@@ -442,8 +433,17 @@ export const centerButtonElement: CenterButtonElement = {
|
||||
},
|
||||
};
|
||||
|
||||
export const rootMenuItems: MenuElement[] = [
|
||||
infoMenuElement,
|
||||
boatMenuElement,
|
||||
buildMenuElement,
|
||||
];
|
||||
export const rootMenuElement: MenuElement = {
|
||||
id: "root",
|
||||
name: "root",
|
||||
disabled: () => false,
|
||||
icon: infoIcon,
|
||||
color: COLORS.info,
|
||||
subMenu: (params: MenuElementParams) => {
|
||||
let ally = allyRequestElement;
|
||||
if (params.selected?.isAlliedWith(params.myPlayer)) {
|
||||
ally = allyBreakElement;
|
||||
}
|
||||
return [infoMenuElement, boatMenuElement, ally, buildMenuElement];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,7 +11,10 @@ import { translateText } from "../../Utils";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
export class ShowReplayPanelEvent {
|
||||
constructor(public visible: boolean = true) {}
|
||||
constructor(
|
||||
public visible: boolean = true,
|
||||
public isSingleplayer: boolean = false,
|
||||
) {}
|
||||
}
|
||||
|
||||
@customElement("replay-panel")
|
||||
@@ -36,6 +39,7 @@ export class ReplayPanel extends LitElement implements Layer {
|
||||
if (this.eventBus) {
|
||||
this.eventBus.on(ShowReplayPanelEvent, (event: ShowReplayPanelEvent) => {
|
||||
this.visible = event.visible;
|
||||
this.isSingleplayer = event.isSingleplayer;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { translateText } from "../../../client/Utils";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { getGamesPlayed } from "../../Utils";
|
||||
import { Layer } from "./Layer";
|
||||
@@ -55,8 +56,8 @@ export class SpawnAd extends LitElement implements Layer {
|
||||
this.g.ticks() > 10 &&
|
||||
this.gamesPlayed > 5
|
||||
) {
|
||||
console.log("showing bottom left ad");
|
||||
this.show();
|
||||
console.log("not showing spawn ad");
|
||||
// this.show();
|
||||
}
|
||||
if (this.isVisible && !this.g.inSpawnPhase()) {
|
||||
console.log("hiding bottom left ad");
|
||||
@@ -123,7 +124,9 @@ export class SpawnAd extends LitElement implements Layer {
|
||||
class="w-full h-full flex items-center justify-center"
|
||||
>
|
||||
${!this.adLoaded
|
||||
? html`<span class="text-white text-sm">Loading ad...</span>`
|
||||
? html`<span class="text-white text-sm"
|
||||
>${translateText("spawn_ad.loading")}</span
|
||||
>`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -59,17 +59,11 @@ export class SpawnTimer implements Layer {
|
||||
|
||||
const barHeight = 10;
|
||||
const barWidth = this.transformHandler.width();
|
||||
let yOffset: number;
|
||||
|
||||
if (this.game.inSpawnPhase()) {
|
||||
// At spawn time, draw at top
|
||||
yOffset = 0;
|
||||
} else if (this.game.config().gameConfig().gameMode === GameMode.Team) {
|
||||
// After spawn, only in team mode, offset based on screen width
|
||||
const screenW = window.innerWidth;
|
||||
yOffset = screenW > 1024 ? 80 : 58;
|
||||
} else {
|
||||
// Not spawn and not team mode: no bar
|
||||
if (
|
||||
!this.game.inSpawnPhase() &&
|
||||
this.game.config().gameConfig().gameMode !== GameMode.Team
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -80,7 +74,7 @@ export class SpawnTimer implements Layer {
|
||||
const segmentWidth = barWidth * ratio;
|
||||
|
||||
context.fillStyle = this.colors[i];
|
||||
context.fillRect(x, yOffset, segmentWidth, barHeight);
|
||||
context.fillRect(x, 0, segmentWidth, barHeight);
|
||||
|
||||
x += segmentWidth;
|
||||
filledRatio += ratio;
|
||||
|
||||
@@ -52,7 +52,7 @@ export class TeamStats extends LitElement implements Layer {
|
||||
for (const player of players) {
|
||||
const team = player.team();
|
||||
if (team === null) continue;
|
||||
if (!grouped[team]) grouped[team] = [];
|
||||
grouped[team] ??= [];
|
||||
grouped[team].push(player);
|
||||
}
|
||||
|
||||
|
||||
@@ -317,10 +317,16 @@ export class UILayer implements Layer {
|
||||
if (constructionType === undefined) {
|
||||
return 1;
|
||||
}
|
||||
const constDuration =
|
||||
this.game.unitInfo(constructionType).constructionDuration;
|
||||
if (constDuration === undefined) {
|
||||
throw new Error("unit does not have constructionTime");
|
||||
}
|
||||
return (
|
||||
(this.game.ticks() - unit.createdAt()) /
|
||||
(this.game.unitInfo(constructionType).constructionDuration || 1)
|
||||
(constDuration === 0 ? 1 : constDuration)
|
||||
);
|
||||
|
||||
case UnitType.MissileSilo:
|
||||
case UnitType.SAMLauncher:
|
||||
return unit.missileReadinesss();
|
||||
|
||||
@@ -24,12 +24,21 @@ export class UnitDisplay extends LitElement implements Layer {
|
||||
private _port = 0;
|
||||
private _defensePost = 0;
|
||||
private _samLauncher = 0;
|
||||
private allDisabled = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
init() {
|
||||
const config = this.game.config();
|
||||
this.allDisabled =
|
||||
config.isUnitDisabled(UnitType.City) &&
|
||||
config.isUnitDisabled(UnitType.Factory) &&
|
||||
config.isUnitDisabled(UnitType.Port) &&
|
||||
config.isUnitDisabled(UnitType.DefensePost) &&
|
||||
config.isUnitDisabled(UnitType.MissileSilo) &&
|
||||
config.isUnitDisabled(UnitType.SAMLauncher);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
@@ -89,6 +98,10 @@ export class UnitDisplay extends LitElement implements Layer {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.allDisabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="fixed bottom-4 left-1/2 transform -translate-x-1/2 z-[1100] bg-gray-800/70 backdrop-blur-sm border border-slate-400 rounded-lg p-2 hidden lg:block"
|
||||
|
||||
+12
-2
@@ -332,10 +332,20 @@
|
||||
class="ml-2 mr-4"
|
||||
/>
|
||||
</a>
|
||||
<a href="/privacy-policy.html" class="t-link" target="_blank">
|
||||
<a
|
||||
href="/privacy-policy.html"
|
||||
data-i18n="main.privacy_policy"
|
||||
class="t-link"
|
||||
target="_blank"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
<a href="/terms-of-service.html" class="t-link" target="_blank">
|
||||
<a
|
||||
href="/terms-of-service.html"
|
||||
data-i18n="main.terms_of_service"
|
||||
class="t-link"
|
||||
target="_blank"
|
||||
>
|
||||
Terms of Service
|
||||
</a>
|
||||
<p style="text-align: center">
|
||||
|
||||
+2
-3
@@ -104,9 +104,8 @@ export type IsLoggedInResponse =
|
||||
| false;
|
||||
let __isLoggedIn: IsLoggedInResponse | undefined = undefined;
|
||||
export function isLoggedIn(): IsLoggedInResponse {
|
||||
if (__isLoggedIn === undefined) {
|
||||
__isLoggedIn = _isLoggedIn();
|
||||
}
|
||||
__isLoggedIn ??= _isLoggedIn();
|
||||
|
||||
return __isLoggedIn;
|
||||
}
|
||||
function _isLoggedIn(): IsLoggedInResponse {
|
||||
|
||||
@@ -369,6 +369,11 @@ label.option-card:hover {
|
||||
mask: url("../../resources/images/CityIconWhite.svg") no-repeat center / cover;
|
||||
}
|
||||
|
||||
#helpModal .factory-icon {
|
||||
mask: url("../../resources/images/FactoryIconWhite.svg") no-repeat center /
|
||||
cover;
|
||||
}
|
||||
|
||||
#helpModal .defense-post-icon {
|
||||
mask: url("../../resources/images/ShieldIconWhite.svg") no-repeat center /
|
||||
cover;
|
||||
@@ -626,7 +631,7 @@ label.option-card:hover {
|
||||
}
|
||||
|
||||
/* News Button Notification */
|
||||
.news-button .active button {
|
||||
news-button .active button {
|
||||
position: relative;
|
||||
border-color: #2563eb !important;
|
||||
border-width: 2px !important;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export interface GameEvent {}
|
||||
export type GameEvent = object;
|
||||
|
||||
export interface EventConstructor<T extends GameEvent = GameEvent> {
|
||||
new (...args: any[]): T;
|
||||
|
||||
+6
-7
@@ -162,7 +162,7 @@ export const TeamSchema = z.string();
|
||||
const SafeString = z
|
||||
.string()
|
||||
.regex(
|
||||
/^([a-zA-Z0-9\s.,!?@#$%&*()\-_+=\[\]{}|;:"'\/\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff]|üÜ])*$/,
|
||||
/^([a-zA-Z0-9\s.,!?@#$%&*()\-_+=[\]{}|;:"'/\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff]|[üÜ])*$/u,
|
||||
)
|
||||
.max(1000);
|
||||
|
||||
@@ -246,7 +246,7 @@ export const AllianceExtensionIntentSchema = BaseIntentSchema.extend({
|
||||
export const AttackIntentSchema = BaseIntentSchema.extend({
|
||||
type: z.literal("attack"),
|
||||
targetID: ID.nullable(),
|
||||
troops: z.number().nullable(),
|
||||
troops: z.number().nonnegative().nullable(),
|
||||
});
|
||||
|
||||
export const SpawnIntentSchema = BaseIntentSchema.extend({
|
||||
@@ -255,14 +255,13 @@ export const SpawnIntentSchema = BaseIntentSchema.extend({
|
||||
flag: FlagSchema,
|
||||
pattern: PatternSchema,
|
||||
playerType: PlayerTypeSchema,
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
tile: z.number(),
|
||||
});
|
||||
|
||||
export const BoatAttackIntentSchema = BaseIntentSchema.extend({
|
||||
type: z.literal("boat"),
|
||||
targetID: ID.nullable(),
|
||||
troops: z.number(),
|
||||
troops: z.number().nonnegative(),
|
||||
dst: z.number(),
|
||||
src: z.number().nullable(),
|
||||
});
|
||||
@@ -320,8 +319,7 @@ export const TargetTroopRatioIntentSchema = BaseIntentSchema.extend({
|
||||
export const BuildUnitIntentSchema = BaseIntentSchema.extend({
|
||||
type: z.literal("build_unit"),
|
||||
unit: z.enum(UnitType),
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
tile: z.number(),
|
||||
});
|
||||
|
||||
export const UpgradeStructureIntentSchema = BaseIntentSchema.extend({
|
||||
@@ -450,6 +448,7 @@ export const ServerDesyncSchema = z.object({
|
||||
export const ServerErrorSchema = z.object({
|
||||
type: z.literal("error"),
|
||||
error: z.string(),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ServerMessageSchema = z.discriminatedUnion("type", [
|
||||
|
||||
@@ -89,7 +89,7 @@ export const OTHER_INDEX_LOST = 3; // Structures/warships destroyed/captured by
|
||||
export const OTHER_INDEX_UPGRADE = 4; // Structures upgraded
|
||||
|
||||
const BigIntStringSchema = z.preprocess((val) => {
|
||||
if (typeof val === "string" && /^\d+$/.test(val)) return BigInt(val);
|
||||
if (typeof val === "string" && /^-?\d+$/.test(val)) return BigInt(val);
|
||||
if (typeof val === "bigint") return val;
|
||||
return val;
|
||||
}, z.bigint());
|
||||
|
||||
+2
-2
@@ -119,7 +119,7 @@ export function getMode(list: Set<number>): number {
|
||||
// Count occurrences
|
||||
const counts = new Map<number, number>();
|
||||
for (const item of list) {
|
||||
counts.set(item, (counts.get(item) || 0) + 1);
|
||||
counts.set(item, (counts.get(item) ?? 0) + 1);
|
||||
}
|
||||
|
||||
// Find the item with the highest count
|
||||
@@ -139,7 +139,7 @@ export function getMode(list: Set<number>): number {
|
||||
export function sanitize(name: string): string {
|
||||
return Array.from(name)
|
||||
.join("")
|
||||
.replace(/[^\p{L}\p{N}\s\p{Emoji}\p{Emoji_Component}\[\]_]/gu, "");
|
||||
.replace(/[^\p{L}\p{N}\s\p{Emoji}\p{Emoji_Component}[\]_]/gu, "");
|
||||
}
|
||||
|
||||
export function processName(name: string): string {
|
||||
|
||||
@@ -76,9 +76,7 @@ export class ColorAllocator {
|
||||
case ColoredTeams.Bot:
|
||||
return botColor;
|
||||
default:
|
||||
return this.availableColors[
|
||||
simpleHash(team) % this.availableColors.length
|
||||
];
|
||||
return this.assignColor(team);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Colord } from "colord";
|
||||
import { JWK } from "jose";
|
||||
import { GameConfig, GameID, TeamCountConfig } from "../Schemas";
|
||||
import {
|
||||
Difficulty,
|
||||
Game,
|
||||
@@ -18,6 +17,8 @@ import {
|
||||
import { GameMap, TileRef } from "../game/GameMap";
|
||||
import { PlayerView } from "../game/GameView";
|
||||
import { UserSettings } from "../game/UserSettings";
|
||||
import { GameConfig, GameID, TeamCountConfig } from "../Schemas";
|
||||
import { NukeType } from "../StatsSchemas";
|
||||
|
||||
export enum GameEnv {
|
||||
Dev,
|
||||
@@ -48,8 +49,7 @@ export interface ServerConfig {
|
||||
r2AccessKey(): string;
|
||||
r2SecretKey(): string;
|
||||
otelEndpoint(): string;
|
||||
otelUsername(): string;
|
||||
otelPassword(): string;
|
||||
otelAuthHeader(): string;
|
||||
otelEnabled(): boolean;
|
||||
jwtAudience(): string;
|
||||
jwtIssuer(): string;
|
||||
@@ -153,10 +153,17 @@ export interface Config {
|
||||
traitorDefenseDebuff(): number;
|
||||
traitorDuration(): number;
|
||||
nukeMagnitudes(unitType: UnitType): NukeMagnitude;
|
||||
// Number of tiles destroyed to break an alliance
|
||||
nukeAllianceBreakThreshold(): number;
|
||||
defaultNukeSpeed(): number;
|
||||
defaultNukeTargetableRange(): number;
|
||||
defaultSamRange(): number;
|
||||
nukeDeathFactor(humans: number, tilesOwned: number): number;
|
||||
nukeDeathFactor(
|
||||
nukeType: NukeType,
|
||||
humans: number,
|
||||
tilesOwned: number,
|
||||
maxPop: number,
|
||||
): number;
|
||||
structureMinDist(): number;
|
||||
isReplay(): boolean;
|
||||
allianceExtensionPromptOffset(): number;
|
||||
|
||||
@@ -23,6 +23,7 @@ import { TileRef } from "../game/GameMap";
|
||||
import { PlayerView } from "../game/GameView";
|
||||
import { UserSettings } from "../game/UserSettings";
|
||||
import { GameConfig, GameID, TeamCountConfig } from "../Schemas";
|
||||
import { NukeType } from "../StatsSchemas";
|
||||
import { assertNever, simpleHash, within } from "../Util";
|
||||
import { Config, GameEnv, NukeMagnitude, ServerConfig, Theme } from "./Config";
|
||||
import { PastelTheme } from "./PastelTheme";
|
||||
@@ -119,19 +120,16 @@ export abstract class DefaultServerConfig implements ServerConfig {
|
||||
}
|
||||
otelEnabled(): boolean {
|
||||
return (
|
||||
this.env() !== GameEnv.Dev &&
|
||||
Boolean(this.otelEndpoint()) &&
|
||||
Boolean(this.otelUsername()) &&
|
||||
Boolean(this.otelPassword())
|
||||
Boolean(this.otelAuthHeader())
|
||||
);
|
||||
}
|
||||
otelEndpoint(): string {
|
||||
return process.env.OTEL_ENDPOINT ?? "";
|
||||
return process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "";
|
||||
}
|
||||
otelUsername(): string {
|
||||
return process.env.OTEL_USERNAME ?? "";
|
||||
}
|
||||
otelPassword(): string {
|
||||
return process.env.OTEL_PASSWORD ?? "";
|
||||
otelAuthHeader(): string {
|
||||
return process.env.OTEL_AUTH_HEADER ?? "";
|
||||
}
|
||||
gitCommit(): string {
|
||||
return process.env.GIT_COMMIT ?? "";
|
||||
@@ -236,7 +234,7 @@ export class DefaultConfig implements Config {
|
||||
return 0.5;
|
||||
}
|
||||
traitorSpeedDebuff(): number {
|
||||
return 0.7;
|
||||
return 0.8;
|
||||
}
|
||||
traitorDuration(): number {
|
||||
return 30 * 10; // 30 seconds
|
||||
@@ -326,7 +324,7 @@ export class DefaultConfig implements Config {
|
||||
return this._gameConfig.infiniteTroops;
|
||||
}
|
||||
trainSpawnRate(numberOfStations: number): number {
|
||||
return Math.min(1400, Math.round(70 * Math.pow(numberOfStations, 0.8)));
|
||||
return Math.min(1400, Math.round(20 * Math.pow(numberOfStations, 0.5)));
|
||||
}
|
||||
trainGold(): Gold {
|
||||
return BigInt(10_000);
|
||||
@@ -342,9 +340,9 @@ export class DefaultConfig implements Config {
|
||||
}
|
||||
|
||||
tradeShipGold(dist: number, numPorts: number): Gold {
|
||||
const baseGold = Math.floor(50000 + 130 * dist);
|
||||
const basePortBonus = 0.2;
|
||||
const diminishingFactor = 0.95;
|
||||
const baseGold = Math.floor(50000 + 100 * dist);
|
||||
const basePortBonus = 0.25;
|
||||
const diminishingFactor = 0.9;
|
||||
|
||||
let totalMultiplier = 1;
|
||||
for (let i = 0; i < numPorts; i++) {
|
||||
@@ -356,17 +354,14 @@ export class DefaultConfig implements Config {
|
||||
|
||||
// Chance to spawn a trade ship in one second,
|
||||
tradeShipSpawnRate(numTradeShips: number): number {
|
||||
if (numTradeShips <= 20) {
|
||||
if (numTradeShips < 20) {
|
||||
return 5;
|
||||
}
|
||||
if (numTradeShips > this.tradeShipCap()) {
|
||||
return 1_000_000;
|
||||
if (numTradeShips <= 150) {
|
||||
const additional = numTradeShips - 20;
|
||||
return Math.floor(Math.pow(additional, 0.85) + 5);
|
||||
}
|
||||
return numTradeShips - 15;
|
||||
}
|
||||
|
||||
tradeShipCap(): number {
|
||||
return 100;
|
||||
return 1_000_000;
|
||||
}
|
||||
|
||||
unitInfo(type: UnitType): UnitInfo {
|
||||
@@ -378,15 +373,9 @@ export class DefaultConfig implements Config {
|
||||
};
|
||||
case UnitType.Warship:
|
||||
return {
|
||||
cost: (p: Player) =>
|
||||
p.type() === PlayerType.Human && this.infiniteGold()
|
||||
? 0n
|
||||
: BigInt(
|
||||
Math.min(
|
||||
1_000_000,
|
||||
(p.unitsOwned(UnitType.Warship) + 1) * 250_000,
|
||||
),
|
||||
),
|
||||
cost: this.costWrapper(UnitType.Warship, (numUnits: number) =>
|
||||
Math.min(1_000_000, (numUnits + 1) * 250_000),
|
||||
),
|
||||
territoryBound: false,
|
||||
maxHealth: 1000,
|
||||
};
|
||||
@@ -403,15 +392,9 @@ export class DefaultConfig implements Config {
|
||||
};
|
||||
case UnitType.Port:
|
||||
return {
|
||||
cost: (p: Player) =>
|
||||
p.type() === PlayerType.Human && this.infiniteGold()
|
||||
? 0n
|
||||
: BigInt(
|
||||
Math.min(
|
||||
1_000_000,
|
||||
Math.pow(2, p.unitsConstructed(UnitType.Port)) * 125_000,
|
||||
),
|
||||
),
|
||||
cost: this.costWrapper(UnitType.Port, (numUnits: number) =>
|
||||
Math.min(1_000_000, Math.pow(2, numUnits) * 125_000),
|
||||
),
|
||||
territoryBound: true,
|
||||
constructionDuration: this.instantBuild() ? 0 : 2 * 10,
|
||||
upgradable: true,
|
||||
@@ -419,26 +402,17 @@ export class DefaultConfig implements Config {
|
||||
};
|
||||
case UnitType.AtomBomb:
|
||||
return {
|
||||
cost: (p: Player) =>
|
||||
p.type() === PlayerType.Human && this.infiniteGold()
|
||||
? 0n
|
||||
: 750_000n,
|
||||
cost: this.costWrapper(UnitType.AtomBomb, () => 750_000),
|
||||
territoryBound: false,
|
||||
};
|
||||
case UnitType.HydrogenBomb:
|
||||
return {
|
||||
cost: (p: Player) =>
|
||||
p.type() === PlayerType.Human && this.infiniteGold()
|
||||
? 0n
|
||||
: 5_000_000n,
|
||||
cost: this.costWrapper(UnitType.HydrogenBomb, () => 5_000_000),
|
||||
territoryBound: false,
|
||||
};
|
||||
case UnitType.MIRV:
|
||||
return {
|
||||
cost: (p: Player) =>
|
||||
p.type() === PlayerType.Human && this.infiniteGold()
|
||||
? 0n
|
||||
: 25_000_000n,
|
||||
cost: this.costWrapper(UnitType.MIRV, () => 35_000_000),
|
||||
territoryBound: false,
|
||||
};
|
||||
case UnitType.MIRVWarhead:
|
||||
@@ -453,54 +427,33 @@ export class DefaultConfig implements Config {
|
||||
};
|
||||
case UnitType.MissileSilo:
|
||||
return {
|
||||
cost: (p: Player) =>
|
||||
p.type() === PlayerType.Human && this.infiniteGold()
|
||||
? 0n
|
||||
: 1_000_000n,
|
||||
cost: this.costWrapper(UnitType.MissileSilo, () => 1_000_000),
|
||||
territoryBound: true,
|
||||
constructionDuration: this.instantBuild() ? 0 : 10 * 10,
|
||||
upgradable: true,
|
||||
};
|
||||
case UnitType.DefensePost:
|
||||
return {
|
||||
cost: (p: Player) =>
|
||||
p.type() === PlayerType.Human && this.infiniteGold()
|
||||
? 0n
|
||||
: BigInt(
|
||||
Math.min(
|
||||
250_000,
|
||||
(p.unitsConstructed(UnitType.DefensePost) + 1) * 50_000,
|
||||
),
|
||||
),
|
||||
cost: this.costWrapper(UnitType.DefensePost, (numUnits: number) =>
|
||||
Math.min(250_000, (numUnits + 1) * 50_000),
|
||||
),
|
||||
territoryBound: true,
|
||||
constructionDuration: this.instantBuild() ? 0 : 5 * 10,
|
||||
};
|
||||
case UnitType.SAMLauncher:
|
||||
return {
|
||||
cost: (p: Player) =>
|
||||
p.type() === PlayerType.Human && this.infiniteGold()
|
||||
? 0n
|
||||
: BigInt(
|
||||
Math.min(
|
||||
3_000_000,
|
||||
(p.unitsConstructed(UnitType.SAMLauncher) + 1) * 1_500_000,
|
||||
),
|
||||
),
|
||||
cost: this.costWrapper(UnitType.SAMLauncher, (numUnits: number) =>
|
||||
Math.min(3_000_000, (numUnits + 1) * 1_500_000),
|
||||
),
|
||||
territoryBound: true,
|
||||
constructionDuration: this.instantBuild() ? 0 : 30 * 10,
|
||||
upgradable: true,
|
||||
};
|
||||
case UnitType.City:
|
||||
return {
|
||||
cost: (p: Player) =>
|
||||
p.type() === PlayerType.Human && this.infiniteGold()
|
||||
? 0n
|
||||
: BigInt(
|
||||
Math.min(
|
||||
1_000_000,
|
||||
Math.pow(2, p.unitsConstructed(UnitType.City)) * 125_000,
|
||||
),
|
||||
),
|
||||
cost: this.costWrapper(UnitType.City, (numUnits: number) =>
|
||||
Math.min(1_000_000, Math.pow(2, numUnits) * 125_000),
|
||||
),
|
||||
territoryBound: true,
|
||||
constructionDuration: this.instantBuild() ? 0 : 2 * 10,
|
||||
upgradable: true,
|
||||
@@ -508,15 +461,9 @@ export class DefaultConfig implements Config {
|
||||
};
|
||||
case UnitType.Factory:
|
||||
return {
|
||||
cost: (p: Player) =>
|
||||
p.type() === PlayerType.Human && this.infiniteGold()
|
||||
? 0n
|
||||
: BigInt(
|
||||
Math.min(
|
||||
1_000_000,
|
||||
Math.pow(2, p.unitsConstructed(UnitType.Factory)) * 125_000,
|
||||
),
|
||||
),
|
||||
cost: this.costWrapper(UnitType.Factory, (numUnits: number) =>
|
||||
Math.min(1_000_000, Math.pow(2, numUnits) * 125_000),
|
||||
),
|
||||
territoryBound: true,
|
||||
constructionDuration: this.instantBuild() ? 0 : 2 * 10,
|
||||
canBuildTrainStation: true,
|
||||
@@ -538,6 +485,20 @@ export class DefaultConfig implements Config {
|
||||
assertNever(type);
|
||||
}
|
||||
}
|
||||
|
||||
private costWrapper(
|
||||
type: UnitType,
|
||||
costFn: (units: number) => number,
|
||||
): (p: Player) => bigint {
|
||||
return (p: Player) => {
|
||||
if (p.type() === PlayerType.Human && this.infiniteGold()) {
|
||||
return 0n;
|
||||
}
|
||||
const numUnits = Math.min(p.unitsOwned(type), p.unitsConstructed(type));
|
||||
return BigInt(costFn(numUnits));
|
||||
};
|
||||
}
|
||||
|
||||
defaultDonationAmount(sender: Player): number {
|
||||
return Math.floor(sender.troops() / 3);
|
||||
}
|
||||
@@ -560,7 +521,7 @@ export class DefaultConfig implements Config {
|
||||
return 30 * 10;
|
||||
}
|
||||
allianceDuration(): Tick {
|
||||
return 600 * 10; // 10 minutes.
|
||||
return 300 * 10; // 5 minutes.
|
||||
}
|
||||
temporaryEmbargoDuration(): Tick {
|
||||
return 300 * 10; // 5 minutes.
|
||||
@@ -791,7 +752,7 @@ export class DefaultConfig implements Config {
|
||||
toAdd *= ratio;
|
||||
|
||||
if (player.type() === PlayerType.Bot) {
|
||||
toAdd *= 0.7;
|
||||
toAdd *= 0.6;
|
||||
}
|
||||
|
||||
if (player.type() === PlayerType.FakeHuman) {
|
||||
@@ -845,6 +806,10 @@ export class DefaultConfig implements Config {
|
||||
throw new Error(`Unknown nuke type: ${unitType}`);
|
||||
}
|
||||
|
||||
nukeAllianceBreakThreshold(): number {
|
||||
return 100;
|
||||
}
|
||||
|
||||
defaultNukeSpeed(): number {
|
||||
return 6;
|
||||
}
|
||||
@@ -858,8 +823,22 @@ export class DefaultConfig implements Config {
|
||||
}
|
||||
|
||||
// Humans can be population, soldiers attacking, soldiers in boat etc.
|
||||
nukeDeathFactor(humans: number, tilesOwned: number): number {
|
||||
return (5 * humans) / Math.max(1, tilesOwned);
|
||||
nukeDeathFactor(
|
||||
nukeType: NukeType,
|
||||
humans: number,
|
||||
tilesOwned: number,
|
||||
maxPop: number,
|
||||
): number {
|
||||
if (nukeType !== UnitType.MIRVWarhead) {
|
||||
return (5 * humans) / Math.max(1, tilesOwned);
|
||||
}
|
||||
const targetPop = 0.03 * maxPop;
|
||||
const excessPop = Math.max(0, humans - targetPop);
|
||||
const scalingFactor = 500;
|
||||
|
||||
const steepness = 2;
|
||||
const normalizedExcess = excessPop / maxPop;
|
||||
return scalingFactor * (1 - Math.exp(-steepness * normalizedExcess));
|
||||
}
|
||||
|
||||
structureMinDist(): number {
|
||||
|
||||
@@ -97,11 +97,9 @@ export class AttackExecution implements Execution {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.startTroops === null) {
|
||||
this.startTroops = this.mg
|
||||
.config()
|
||||
.attackAmount(this._owner, this.target);
|
||||
}
|
||||
this.startTroops ??= this.mg
|
||||
.config()
|
||||
.attackAmount(this._owner, this.target);
|
||||
if (this.removeTroops) {
|
||||
this.startTroops = Math.min(this._owner.troops(), this.startTroops);
|
||||
this._owner.removeTroops(this.startTroops);
|
||||
@@ -187,8 +185,11 @@ export class AttackExecution implements Execution {
|
||||
this.attack.delete();
|
||||
this.active = false;
|
||||
|
||||
// Record stats
|
||||
this.mg.stats().attackCancel(this._owner, this.target, survivors);
|
||||
// Not all retreats are canceled attacks
|
||||
if (this.attack.retreated()) {
|
||||
// Record stats
|
||||
this.mg.stats().attackCancel(this._owner, this.target, survivors);
|
||||
}
|
||||
}
|
||||
|
||||
tick(ticks: number) {
|
||||
@@ -344,6 +345,7 @@ export class AttackExecution implements Execution {
|
||||
);
|
||||
this.target.removeGold(gold);
|
||||
this._owner.addGold(gold);
|
||||
this.mg.stats().goldWar(this._owner, this.target, gold);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (const tile of this.target.tiles()) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
Cell,
|
||||
Execution,
|
||||
Game,
|
||||
Gold,
|
||||
@@ -27,12 +26,11 @@ export class ConstructionExecution implements Execution {
|
||||
private ticksUntilComplete: Tick;
|
||||
|
||||
private cost: Gold;
|
||||
private tile: TileRef;
|
||||
|
||||
constructor(
|
||||
private player: Player,
|
||||
private constructionType: UnitType,
|
||||
private tileOrCell: TileRef | Cell,
|
||||
private tile: TileRef,
|
||||
) {}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
@@ -46,17 +44,10 @@ export class ConstructionExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.tileOrCell instanceof Cell) {
|
||||
if (!this.mg.isValidCoord(this.tileOrCell.x, this.tileOrCell.y)) {
|
||||
console.warn(
|
||||
`cannot build construction invalid coordinates ${this.tileOrCell.x}, ${this.tileOrCell.y}`,
|
||||
);
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
this.tile = this.mg.ref(this.tileOrCell.x, this.tileOrCell.y);
|
||||
} else {
|
||||
this.tile = this.tileOrCell;
|
||||
if (!this.mg.isValidRef(this.tile)) {
|
||||
console.warn(`cannot build construction invalid tile ${this.tile}`);
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,9 +19,7 @@ export class DonateGoldExecution implements Execution {
|
||||
}
|
||||
|
||||
this.recipient = mg.player(this.recipientID);
|
||||
if (this.gold === null) {
|
||||
this.gold = this.sender.gold() / 3n;
|
||||
}
|
||||
this.gold ??= this.sender.gold() / 3n;
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
|
||||
@@ -19,9 +19,7 @@ export class DonateTroopsExecution implements Execution {
|
||||
}
|
||||
|
||||
this.recipient = mg.player(this.recipientID);
|
||||
if (this.troops === null) {
|
||||
this.troops = mg.config().defaultDonationAmount(this.sender);
|
||||
}
|
||||
this.troops ??= mg.config().defaultDonationAmount(this.sender);
|
||||
const maxDonation =
|
||||
mg.config().maxPopulation(this.recipient) - this.recipient.population();
|
||||
this.troops = Math.min(this.troops, maxDonation);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cell, Execution, Game } from "../game/Game";
|
||||
import { Execution, Game } from "../game/Game";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { ClientID, GameID, Intent, Turn } from "../Schemas";
|
||||
import { simpleHash } from "../Util";
|
||||
@@ -67,10 +67,7 @@ export class Executor {
|
||||
case "move_warship":
|
||||
return new MoveWarshipExecution(player, intent.unitId, intent.tile);
|
||||
case "spawn":
|
||||
return new SpawnExecution(
|
||||
player.info(),
|
||||
this.mg.ref(intent.x, intent.y),
|
||||
);
|
||||
return new SpawnExecution(player.info(), intent.tile);
|
||||
case "boat":
|
||||
return new TransportShipExecution(
|
||||
player,
|
||||
@@ -106,11 +103,7 @@ export class Executor {
|
||||
case "embargo":
|
||||
return new EmbargoExecution(player, intent.targetID, intent.action);
|
||||
case "build_unit":
|
||||
return new ConstructionExecution(
|
||||
player,
|
||||
intent.unit,
|
||||
new Cell(intent.x, intent.y),
|
||||
);
|
||||
return new ConstructionExecution(player, intent.unit, intent.tile);
|
||||
case "allianceExtension": {
|
||||
return new AllianceExtensionExecution(player, intent.recipient);
|
||||
}
|
||||
|
||||
@@ -51,11 +51,11 @@ export class FactoryExecution implements Execution {
|
||||
this.game.config().trainStationMaxRange(),
|
||||
[UnitType.City, UnitType.Port, UnitType.Factory],
|
||||
);
|
||||
// Use different seeds or trains will spawn simultaneously
|
||||
let seed = 0;
|
||||
|
||||
this.game.addExecution(new TrainStationExecution(this.factory, true));
|
||||
for (const { unit } of structures) {
|
||||
if (!unit.hasTrainStation()) {
|
||||
this.game.addExecution(new TrainStationExecution(unit, ++seed));
|
||||
this.game.addExecution(new TrainStationExecution(unit));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Cell,
|
||||
Difficulty,
|
||||
Execution,
|
||||
Game,
|
||||
Gold,
|
||||
@@ -196,22 +197,32 @@ export class FakeHumanExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
|
||||
const enemies = borderPlayers
|
||||
.filter((o) => o.isPlayer())
|
||||
.sort((a, b) => a.troops() - b.troops());
|
||||
|
||||
// 5% chance to send a random alliance request
|
||||
if (this.random.chance(20)) {
|
||||
const toAlly = this.random.randElement(enemies);
|
||||
if (this.player.canSendAllianceRequest(toAlly)) {
|
||||
this.player.createAllianceRequest(toAlly);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 50-50 attack weakest player vs random player
|
||||
const toAttack = this.random.chance(2)
|
||||
? enemies[0]
|
||||
: this.random.randElement(enemies);
|
||||
if (this.shouldAttack(toAttack)) {
|
||||
this.behavior.sendAttack(toAttack);
|
||||
return;
|
||||
}
|
||||
|
||||
this.behavior.forgetOldEnemies();
|
||||
this.behavior.assistAllies();
|
||||
const enemy = this.behavior.selectEnemy();
|
||||
if (!enemy) {
|
||||
// 5% chance to send a random alliance request
|
||||
if (this.random.chance(20)) {
|
||||
const enemies = borderPlayers
|
||||
.filter((o) => o.isPlayer())
|
||||
.sort((a, b) => a.troops() - b.troops());
|
||||
const toAlly = this.random.randElement(enemies);
|
||||
if (this.player.canSendAllianceRequest(toAlly)) {
|
||||
this.player.createAllianceRequest(toAlly);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!enemy) return;
|
||||
this.maybeSendEmoji(enemy);
|
||||
this.maybeSendNuke(enemy);
|
||||
if (this.player.sharesBorderWith(enemy)) {
|
||||
@@ -221,6 +232,42 @@ export class FakeHumanExecution implements Execution {
|
||||
}
|
||||
}
|
||||
|
||||
private shouldAttack(other: Player): boolean {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
if (this.player.isOnSameTeam(other)) {
|
||||
return false;
|
||||
}
|
||||
if (this.player.isFriendly(other)) {
|
||||
if (this.shouldDiscourageAttack(other)) {
|
||||
return this.random.chance(200);
|
||||
}
|
||||
return this.random.chance(50);
|
||||
} else {
|
||||
if (this.shouldDiscourageAttack(other)) {
|
||||
return this.random.chance(4);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private shouldDiscourageAttack(other: Player) {
|
||||
if (other.isTraitor()) {
|
||||
return false;
|
||||
}
|
||||
const difficulty = this.mg.config().gameConfig().difficulty;
|
||||
if (
|
||||
difficulty === Difficulty.Hard ||
|
||||
difficulty === Difficulty.Impossible
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (other.type() !== PlayerType.Human) {
|
||||
return false;
|
||||
}
|
||||
// Only discourage attacks on Humans who are not traitors on easy or medium difficulty.
|
||||
return true;
|
||||
}
|
||||
|
||||
private maybeSendEmoji(enemy: Player) {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
if (enemy.type() !== PlayerType.Human) return;
|
||||
|
||||
@@ -25,9 +25,7 @@ export class MissileSiloExecution implements Execution {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
this.silo = this.player.buildUnit(UnitType.MissileSilo, spawn, {
|
||||
cooldownDuration: this.mg.config().SiloCooldown(),
|
||||
});
|
||||
this.silo = this.player.buildUnit(UnitType.MissileSilo, spawn, {});
|
||||
|
||||
if (this.player !== this.silo.owner()) {
|
||||
this.player = this.silo.owner();
|
||||
|
||||
@@ -64,7 +64,7 @@ export class NukeExecution implements Execution {
|
||||
return this.tilesToDestroyCache;
|
||||
}
|
||||
|
||||
private breakAlliances(toDestroy: Set<TileRef>) {
|
||||
private maybeBreakAlliances(toDestroy: Set<TileRef>) {
|
||||
if (this.nuke === null) {
|
||||
throw new Error("Not initialized");
|
||||
}
|
||||
@@ -77,8 +77,12 @@ export class NukeExecution implements Execution {
|
||||
}
|
||||
}
|
||||
|
||||
const threshold = this.mg.config().nukeAllianceBreakThreshold();
|
||||
for (const [other, tilesDestroyed] of attacked) {
|
||||
if (tilesDestroyed > 100 && this.nuke.type() !== UnitType.MIRVWarhead) {
|
||||
if (
|
||||
tilesDestroyed > threshold &&
|
||||
this.nuke.type() !== UnitType.MIRVWarhead
|
||||
) {
|
||||
// Mirv warheads shouldn't break alliances
|
||||
const alliance = this.player.allianceWith(other);
|
||||
if (alliance !== null) {
|
||||
@@ -108,6 +112,7 @@ export class NukeExecution implements Execution {
|
||||
this.nuke = this.player.buildUnit(this.nukeType, spawn, {
|
||||
targetTile: this.dst,
|
||||
});
|
||||
this.maybeBreakAlliances(this.tilesToDestroy());
|
||||
if (this.mg.hasOwner(this.dst)) {
|
||||
const target = this.mg.owner(this.dst);
|
||||
if (!target.isPlayer()) {
|
||||
@@ -120,7 +125,6 @@ export class NukeExecution implements Execution {
|
||||
MessageType.NUKE_INBOUND,
|
||||
target.id(),
|
||||
);
|
||||
this.breakAlliances(this.tilesToDestroy());
|
||||
} else if (this.nukeType === UnitType.HydrogenBomb) {
|
||||
this.mg.displayIncomingUnit(
|
||||
this.nuke.id(),
|
||||
@@ -129,7 +133,6 @@ export class NukeExecution implements Execution {
|
||||
MessageType.HYDROGEN_BOMB_INBOUND,
|
||||
target.id(),
|
||||
);
|
||||
this.breakAlliances(this.tilesToDestroy());
|
||||
}
|
||||
|
||||
// Record stats
|
||||
@@ -198,7 +201,11 @@ export class NukeExecution implements Execution {
|
||||
|
||||
const magnitude = this.mg.config().nukeMagnitudes(this.nuke.type());
|
||||
const toDestroy = this.tilesToDestroy();
|
||||
this.breakAlliances(toDestroy);
|
||||
this.maybeBreakAlliances(toDestroy);
|
||||
|
||||
const maxPop = this.target().isPlayer()
|
||||
? this.mg.config().maxPopulation(this.target() as Player)
|
||||
: 1;
|
||||
|
||||
for (const tile of toDestroy) {
|
||||
const owner = this.mg.owner(tile);
|
||||
@@ -207,25 +214,45 @@ export class NukeExecution implements Execution {
|
||||
owner.removeTroops(
|
||||
this.mg
|
||||
.config()
|
||||
.nukeDeathFactor(owner.troops(), owner.numTilesOwned()),
|
||||
.nukeDeathFactor(
|
||||
this.nukeType,
|
||||
owner.troops(),
|
||||
owner.numTilesOwned(),
|
||||
maxPop,
|
||||
),
|
||||
);
|
||||
owner.removeWorkers(
|
||||
this.mg
|
||||
.config()
|
||||
.nukeDeathFactor(owner.workers(), owner.numTilesOwned()),
|
||||
.nukeDeathFactor(
|
||||
this.nukeType,
|
||||
owner.workers(),
|
||||
owner.numTilesOwned(),
|
||||
maxPop,
|
||||
),
|
||||
);
|
||||
owner.outgoingAttacks().forEach((attack) => {
|
||||
const deaths =
|
||||
this.mg
|
||||
?.config()
|
||||
.nukeDeathFactor(attack.troops(), owner.numTilesOwned()) ?? 0;
|
||||
.nukeDeathFactor(
|
||||
this.nukeType,
|
||||
attack.troops(),
|
||||
owner.numTilesOwned(),
|
||||
maxPop,
|
||||
) ?? 0;
|
||||
attack.setTroops(attack.troops() - deaths);
|
||||
});
|
||||
owner.units(UnitType.TransportShip).forEach((attack) => {
|
||||
const deaths =
|
||||
this.mg
|
||||
?.config()
|
||||
.nukeDeathFactor(attack.troops(), owner.numTilesOwned()) ?? 0;
|
||||
.nukeDeathFactor(
|
||||
this.nukeType,
|
||||
attack.troops(),
|
||||
owner.numTilesOwned(),
|
||||
maxPop,
|
||||
) ?? 0;
|
||||
attack.setTroops(attack.troops() - deaths);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Execution, Game, Player, Unit, UnitType } from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { TradeShipExecution } from "./TradeShipExecution";
|
||||
import { TrainStationExecution } from "./TrainStationExecution";
|
||||
|
||||
export class PortExecution implements Execution {
|
||||
private active = true;
|
||||
@@ -36,6 +37,7 @@ export class PortExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
this.port = this.player.buildUnit(UnitType.Port, spawn, {});
|
||||
this.createStation();
|
||||
}
|
||||
|
||||
if (!this.port.isActive()) {
|
||||
@@ -84,4 +86,18 @@ export class PortExecution implements Execution {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
createStation(): void {
|
||||
if (this.port !== null) {
|
||||
const nearbyFactory = this.mg.hasUnitNearby(
|
||||
this.port.tile()!,
|
||||
this.mg.config().trainStationMaxRange(),
|
||||
UnitType.Factory,
|
||||
this.player.id(),
|
||||
);
|
||||
if (nearbyFactory) {
|
||||
this.mg.addExecution(new TrainStationExecution(this.port));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export class QuickChatExecution implements Execution {
|
||||
this.target,
|
||||
this.recipient.id(),
|
||||
true,
|
||||
this.sender.name(),
|
||||
this.sender.id(),
|
||||
);
|
||||
|
||||
this.mg.displayChat(
|
||||
@@ -44,11 +44,11 @@ export class QuickChatExecution implements Execution {
|
||||
this.target,
|
||||
this.sender.id(),
|
||||
false,
|
||||
this.recipient.name(),
|
||||
this.recipient.id(),
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[QuickChat] ${this.sender.name} → ${this.recipient.name}: ${message}`,
|
||||
`[QuickChat] ${this.sender.name} → ${this.recipient.displayName}: ${message}`,
|
||||
);
|
||||
|
||||
this.active = false;
|
||||
|
||||
@@ -96,9 +96,7 @@ export class SAMLauncherExecution implements Execution {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
this.sam = this.player.buildUnit(UnitType.SAMLauncher, spawnTile, {
|
||||
cooldownDuration: this.mg.config().SAMCooldown(),
|
||||
});
|
||||
this.sam = this.player.buildUnit(UnitType.SAMLauncher, spawnTile, {});
|
||||
}
|
||||
if (!this.sam.isActive()) {
|
||||
this.active = false;
|
||||
@@ -109,9 +107,7 @@ export class SAMLauncherExecution implements Execution {
|
||||
this.player = this.sam.owner();
|
||||
}
|
||||
|
||||
if (this.pseudoRandom === undefined) {
|
||||
this.pseudoRandom = new PseudoRandom(this.sam.id());
|
||||
}
|
||||
this.pseudoRandom ??= new PseudoRandom(this.sam.id());
|
||||
|
||||
const mirvWarheadTargets = this.mg.nearbyUnits(
|
||||
this.sam.tile(),
|
||||
|
||||
@@ -31,13 +31,11 @@ export class SAMMissileExecution implements Execution {
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
if (this.SAMMissile === undefined) {
|
||||
this.SAMMissile = this._owner.buildUnit(
|
||||
UnitType.SAMMissile,
|
||||
this.spawn,
|
||||
{},
|
||||
);
|
||||
}
|
||||
this.SAMMissile ??= this._owner.buildUnit(
|
||||
UnitType.SAMMissile,
|
||||
this.spawn,
|
||||
{},
|
||||
);
|
||||
if (!this.SAMMissile.isActive()) {
|
||||
this.active = false;
|
||||
return;
|
||||
|
||||
@@ -23,9 +23,7 @@ export class ShellExecution implements Execution {
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
if (this.shell === undefined) {
|
||||
this.shell = this._owner.buildUnit(UnitType.Shell, this.spawn, {});
|
||||
}
|
||||
this.shell ??= this._owner.buildUnit(UnitType.Shell, this.spawn, {});
|
||||
if (!this.shell.isActive()) {
|
||||
this.active = false;
|
||||
return;
|
||||
|
||||
@@ -20,6 +20,11 @@ export class SpawnExecution implements Execution {
|
||||
tick(ticks: number) {
|
||||
this.active = false;
|
||||
|
||||
if (!this.mg.isValidRef(this.tile)) {
|
||||
console.warn(`SpawnExecution: tile ${this.tile} not valid`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.mg.inSpawnPhase()) {
|
||||
this.active = false;
|
||||
return;
|
||||
|
||||
@@ -54,14 +54,16 @@ export class TradeShipExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.origOwner !== this.tradeShip.owner()) {
|
||||
const tradeShipOwner = this.tradeShip.owner();
|
||||
const dstPortOwner = this._dstPort.owner();
|
||||
if (this.wasCaptured !== true && this.origOwner !== tradeShipOwner) {
|
||||
// Store as variable in case ship is recaptured by previous owner
|
||||
this.wasCaptured = true;
|
||||
}
|
||||
|
||||
// If a player captures another player's port while trading we should delete
|
||||
// the ship.
|
||||
if (this._dstPort.owner().id() === this.srcPort.owner().id()) {
|
||||
if (dstPortOwner.id() === this.srcPort.owner().id()) {
|
||||
this.tradeShip.delete(false);
|
||||
this.active = false;
|
||||
return;
|
||||
@@ -69,15 +71,17 @@ export class TradeShipExecution implements Execution {
|
||||
|
||||
if (
|
||||
!this.wasCaptured &&
|
||||
(!this._dstPort.isActive() ||
|
||||
!this.tradeShip.owner().canTrade(this._dstPort.owner()))
|
||||
(!this._dstPort.isActive() || !tradeShipOwner.canTrade(dstPortOwner))
|
||||
) {
|
||||
this.tradeShip.delete(false);
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.wasCaptured) {
|
||||
if (
|
||||
this.wasCaptured &&
|
||||
(tradeShipOwner !== dstPortOwner || !this._dstPort.isActive())
|
||||
) {
|
||||
const ports = this.tradeShip
|
||||
.owner()
|
||||
.units(UnitType.Port)
|
||||
@@ -92,18 +96,18 @@ export class TradeShipExecution implements Execution {
|
||||
}
|
||||
}
|
||||
|
||||
const result = this.pathFinder.nextTile(
|
||||
this.tradeShip.tile(),
|
||||
this._dstPort.tile(),
|
||||
);
|
||||
const curTile = this.tradeShip.tile();
|
||||
if (curTile === this.dstPort()) {
|
||||
this.complete();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = this.pathFinder.nextTile(curTile, this._dstPort.tile());
|
||||
|
||||
switch (result.type) {
|
||||
case PathFindResultType.Completed:
|
||||
this.complete();
|
||||
break;
|
||||
case PathFindResultType.Pending:
|
||||
// Fire unit event to rerender.
|
||||
this.tradeShip.move(this.tradeShip.tile());
|
||||
this.tradeShip.move(curTile);
|
||||
break;
|
||||
case PathFindResultType.NextTile:
|
||||
// Update safeFromPirates status
|
||||
@@ -113,6 +117,9 @@ export class TradeShipExecution implements Execution {
|
||||
this.tradeShip.move(result.node);
|
||||
this.tilesTraveled++;
|
||||
break;
|
||||
case PathFindResultType.Completed:
|
||||
this.complete();
|
||||
break;
|
||||
case PathFindResultType.PathNotFound:
|
||||
console.warn("captured trade ship cannot find route");
|
||||
if (this.tradeShip.isActive()) {
|
||||
|
||||
@@ -6,13 +6,17 @@ import { TrainExecution } from "./TrainExecution";
|
||||
export class TrainStationExecution implements Execution {
|
||||
private mg: Game;
|
||||
private active: boolean = true;
|
||||
private random: PseudoRandom | null = null;
|
||||
private random: PseudoRandom;
|
||||
private station: TrainStation | null = null;
|
||||
private numCars: number = 5;
|
||||
private lastSpawnTick: number = 0;
|
||||
private ticksCooldown: number = 10; // Minimum cooldown between two trains
|
||||
constructor(
|
||||
private unit: Unit,
|
||||
private randomSeed?: number,
|
||||
) {}
|
||||
private spawnTrains?: boolean, // If set, the station will spawn trains
|
||||
) {
|
||||
this.unit.setTrainStation(true);
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active;
|
||||
@@ -20,8 +24,9 @@ export class TrainStationExecution implements Execution {
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
this.mg = mg;
|
||||
this.random = new PseudoRandom(mg.ticks() + (this.randomSeed ?? 0));
|
||||
this.unit.setTrainStation(true);
|
||||
if (this.spawnTrains) {
|
||||
this.random = new PseudoRandom(mg.ticks());
|
||||
}
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
@@ -36,36 +41,57 @@ export class TrainStationExecution implements Execution {
|
||||
this.station = new TrainStation(this.mg, this.unit);
|
||||
this.mg.railNetwork().connectStation(this.station);
|
||||
}
|
||||
if (!this.station.isActive() || !this.random) {
|
||||
if (!this.station.isActive()) {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
const cluster = this.station.getCluster();
|
||||
this.spawnTrain(this.station, ticks);
|
||||
}
|
||||
|
||||
private shouldSpawnTrain(clusterSize: number): boolean {
|
||||
const spawnRate = this.mg.config().trainSpawnRate(clusterSize);
|
||||
for (let i = 0; i < this.unit!.level(); i++) {
|
||||
if (this.random.chance(spawnRate)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private spawnTrain(station: TrainStation, currentTick: number) {
|
||||
if (
|
||||
!this.spawnTrains ||
|
||||
currentTick - this.lastSpawnTick < this.ticksCooldown
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const cluster = station.getCluster();
|
||||
if (cluster === null) {
|
||||
return;
|
||||
}
|
||||
const availableForTrade = cluster.availableForTrade(this.unit.owner());
|
||||
if (
|
||||
availableForTrade.size === 0 ||
|
||||
!this.random.chance(
|
||||
this.mg.config().trainSpawnRate(availableForTrade.size),
|
||||
)
|
||||
) {
|
||||
if (availableForTrade.size === 0) {
|
||||
return;
|
||||
}
|
||||
if (!this.shouldSpawnTrain(availableForTrade.size)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Pick a destination randomly.
|
||||
// Could be improved to pick a lucrative trip
|
||||
const destination = this.random.randFromSet(availableForTrade);
|
||||
if (destination !== this.station) {
|
||||
const destination: TrainStation =
|
||||
this.random.randFromSet(availableForTrade);
|
||||
if (destination !== station) {
|
||||
this.mg.addExecution(
|
||||
new TrainExecution(
|
||||
this.mg.railNetwork(),
|
||||
this.unit.owner(),
|
||||
this.station,
|
||||
station,
|
||||
destination,
|
||||
this.numCars,
|
||||
),
|
||||
);
|
||||
this.lastSpawnTick = currentTick;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export class TransportShipExecution implements Execution {
|
||||
private attacker: Player,
|
||||
private targetID: PlayerID | null,
|
||||
private ref: TileRef,
|
||||
private troops: number,
|
||||
private startTroops: number,
|
||||
private src: TileRef | null,
|
||||
) {}
|
||||
|
||||
@@ -64,7 +64,7 @@ export class TransportShipExecution implements Execution {
|
||||
|
||||
this.lastMove = ticks;
|
||||
this.mg = mg;
|
||||
this.pathFinder = PathFinder.Mini(mg, 10_000, true, 10);
|
||||
this.pathFinder = PathFinder.Mini(mg, 10_000, true, 100);
|
||||
|
||||
if (
|
||||
this.attacker.unitCount(UnitType.TransportShip) >=
|
||||
@@ -76,7 +76,6 @@ export class TransportShipExecution implements Execution {
|
||||
this.attacker.id(),
|
||||
);
|
||||
this.active = false;
|
||||
this.attacker.addTroops(this.troops);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -89,13 +88,11 @@ export class TransportShipExecution implements Execution {
|
||||
this.target = mg.player(this.targetID);
|
||||
}
|
||||
|
||||
if (this.troops === null) {
|
||||
this.troops = this.mg
|
||||
.config()
|
||||
.boatAttackAmount(this.attacker, this.target);
|
||||
}
|
||||
this.startTroops ??= this.mg
|
||||
.config()
|
||||
.boatAttackAmount(this.attacker, this.target);
|
||||
|
||||
this.troops = Math.min(this.troops, this.attacker.troops());
|
||||
this.startTroops = Math.min(this.startTroops, this.attacker.troops());
|
||||
|
||||
this.dst = targetTransportTile(this.mg, this.ref);
|
||||
if (this.dst === null) {
|
||||
@@ -133,7 +130,7 @@ export class TransportShipExecution implements Execution {
|
||||
}
|
||||
|
||||
this.boat = this.attacker.buildUnit(UnitType.TransportShip, this.src, {
|
||||
troops: this.troops,
|
||||
troops: this.startTroops,
|
||||
});
|
||||
|
||||
// Notify the target player about the incoming naval invasion
|
||||
@@ -148,7 +145,9 @@ export class TransportShipExecution implements Execution {
|
||||
}
|
||||
|
||||
// Record stats
|
||||
this.mg.stats().boatSendTroops(this.attacker, this.target, this.troops);
|
||||
this.mg
|
||||
.stats()
|
||||
.boatSendTroops(this.attacker, this.target, this.boat.troops());
|
||||
}
|
||||
|
||||
tick(ticks: number) {
|
||||
@@ -183,16 +182,16 @@ export class TransportShipExecution implements Execution {
|
||||
// Record stats
|
||||
this.mg
|
||||
.stats()
|
||||
.boatArriveTroops(this.attacker, this.target, this.troops);
|
||||
.boatArriveTroops(this.attacker, this.target, this.boat.troops());
|
||||
return;
|
||||
}
|
||||
this.attacker.conquer(this.dst);
|
||||
if (this.target.isPlayer() && this.attacker.isFriendly(this.target)) {
|
||||
this.attacker.addTroops(this.troops);
|
||||
this.attacker.addTroops(this.boat.troops());
|
||||
} else {
|
||||
this.mg.addExecution(
|
||||
new AttackExecution(
|
||||
this.troops,
|
||||
this.boat.troops(),
|
||||
this.attacker,
|
||||
this.targetID,
|
||||
this.dst,
|
||||
@@ -206,7 +205,7 @@ export class TransportShipExecution implements Execution {
|
||||
// Record stats
|
||||
this.mg
|
||||
.stats()
|
||||
.boatArriveTroops(this.attacker, this.target, this.troops);
|
||||
.boatArriveTroops(this.attacker, this.target, this.boat.troops());
|
||||
return;
|
||||
case PathFindResultType.NextTile:
|
||||
this.boat.move(result.node);
|
||||
@@ -216,7 +215,7 @@ export class TransportShipExecution implements Execution {
|
||||
case PathFindResultType.PathNotFound:
|
||||
// TODO: add to poisoned port list
|
||||
console.warn(`path not found to dst`);
|
||||
this.attacker.addTroops(this.troops);
|
||||
this.attacker.addTroops(this.boat.troops());
|
||||
this.boat.delete(false);
|
||||
this.active = false;
|
||||
return;
|
||||
|
||||
@@ -18,12 +18,11 @@ export class UpgradeStructureExecution implements Execution {
|
||||
console.warn(`structure is undefined`);
|
||||
return;
|
||||
}
|
||||
if (!this.structure.info().upgradable) {
|
||||
console.warn(`unit type ${this.structure} cannot be upgraded`);
|
||||
return;
|
||||
}
|
||||
this.cost = this.structure.info().cost(this.player);
|
||||
if (this.player.gold() < this.cost) {
|
||||
|
||||
if (!this.player.canUpgradeUnit(this.structure.type())) {
|
||||
console.warn(
|
||||
`[UpgradeStructureExecution] unit type ${this.structure.type()} cannot be upgraded`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.player.upgradeUnit(this.structure);
|
||||
|
||||
@@ -27,7 +27,7 @@ export class WarshipExecution implements Execution {
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
this.mg = mg;
|
||||
this.pathfinder = PathFinder.Mini(mg, 5000);
|
||||
this.pathfinder = PathFinder.Mini(mg, 10_000, true, 100);
|
||||
this.random = new PseudoRandom(mg.ticks());
|
||||
if (isUnit(this.input)) {
|
||||
this.warship = this.input;
|
||||
|
||||
@@ -46,11 +46,15 @@ export class AllianceExtensionExecution implements Execution {
|
||||
"events_display.alliance_renewed",
|
||||
MessageType.ALLIANCE_ACCEPTED,
|
||||
this.from.id(),
|
||||
undefined,
|
||||
{ name: to.displayName() },
|
||||
);
|
||||
mg.displayMessage(
|
||||
"events_display.alliance_renewed",
|
||||
MessageType.ALLIANCE_ACCEPTED,
|
||||
this.toID,
|
||||
undefined,
|
||||
{ name: this.from.displayName() },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+24
-16
@@ -7,7 +7,6 @@ import {
|
||||
PlayerUpdate,
|
||||
UnitUpdate,
|
||||
} from "./GameUpdates";
|
||||
import { PlayerView } from "./GameView";
|
||||
import { RailNetwork } from "./RailNetwork";
|
||||
import { Stats } from "./Stats";
|
||||
|
||||
@@ -131,7 +130,7 @@ export enum GameMode {
|
||||
}
|
||||
|
||||
export interface UnitInfo {
|
||||
cost: (player: Player | PlayerView) => Gold;
|
||||
cost: (player: Player) => Gold;
|
||||
// Determines if its owner changes when its tile is conquered.
|
||||
territoryBound: boolean;
|
||||
maxHealth?: number;
|
||||
@@ -194,11 +193,11 @@ export interface UnitParamsMap {
|
||||
patrolTile: TileRef;
|
||||
};
|
||||
|
||||
[UnitType.Shell]: {};
|
||||
[UnitType.Shell]: Record<string, never>;
|
||||
|
||||
[UnitType.SAMMissile]: {};
|
||||
[UnitType.SAMMissile]: Record<string, never>;
|
||||
|
||||
[UnitType.Port]: {};
|
||||
[UnitType.Port]: Record<string, never>;
|
||||
|
||||
[UnitType.AtomBomb]: {
|
||||
targetTile?: number;
|
||||
@@ -219,25 +218,23 @@ export interface UnitParamsMap {
|
||||
loaded?: boolean;
|
||||
};
|
||||
|
||||
[UnitType.Factory]: {};
|
||||
[UnitType.Factory]: Record<string, never>;
|
||||
|
||||
[UnitType.MissileSilo]: {
|
||||
cooldownDuration?: number;
|
||||
};
|
||||
[UnitType.MissileSilo]: Record<string, never>;
|
||||
|
||||
[UnitType.DefensePost]: {};
|
||||
[UnitType.DefensePost]: Record<string, never>;
|
||||
|
||||
[UnitType.SAMLauncher]: {};
|
||||
[UnitType.SAMLauncher]: Record<string, never>;
|
||||
|
||||
[UnitType.City]: {};
|
||||
[UnitType.City]: Record<string, never>;
|
||||
|
||||
[UnitType.MIRV]: {};
|
||||
[UnitType.MIRV]: Record<string, never>;
|
||||
|
||||
[UnitType.MIRVWarhead]: {
|
||||
targetTile?: number;
|
||||
};
|
||||
|
||||
[UnitType.Construction]: {};
|
||||
[UnitType.Construction]: Record<string, never>;
|
||||
}
|
||||
|
||||
// Type helper to get params type for a specific unit type
|
||||
@@ -381,7 +378,12 @@ export class PlayerInfo {
|
||||
}
|
||||
|
||||
export function isUnit(unit: Unit | UnitParams<UnitType>): unit is Unit {
|
||||
return "isUnit" in unit && typeof unit.isUnit === "function" && unit.isUnit();
|
||||
return (
|
||||
unit !== undefined &&
|
||||
"isUnit" in unit &&
|
||||
typeof unit.isUnit === "function" &&
|
||||
unit.isUnit()
|
||||
);
|
||||
}
|
||||
|
||||
export interface Unit {
|
||||
@@ -530,8 +532,13 @@ export interface Player {
|
||||
params: UnitParams<T>,
|
||||
): Unit;
|
||||
|
||||
// Returns the existing unit that can be upgraded,
|
||||
// or false if it cannot be upgraded.
|
||||
// New units of the same type can upgrade existing units.
|
||||
// e.g. if a place a new city here, can it upgrade an existing city?
|
||||
findUnitToUpgrade(type: UnitType, targetTile: TileRef): Unit | false;
|
||||
canUpgradeUnit(unitType: UnitType): boolean;
|
||||
upgradeUnit(unit: Unit): void;
|
||||
|
||||
captureUnit(unit: Unit): void;
|
||||
|
||||
// Relations & Diplomacy
|
||||
@@ -660,6 +667,7 @@ export interface Game extends GameMap {
|
||||
type: MessageType,
|
||||
playerID: PlayerID | null,
|
||||
goldAmount?: bigint,
|
||||
params?: Record<string, string | number>,
|
||||
): void;
|
||||
displayIncomingUnit(
|
||||
unitID: number,
|
||||
|
||||
@@ -675,6 +675,7 @@ export class GameImpl implements Game {
|
||||
type: MessageType,
|
||||
playerID: PlayerID | null,
|
||||
goldAmount?: bigint,
|
||||
params?: Record<string, string | number>,
|
||||
): void {
|
||||
let id: number | null = null;
|
||||
if (playerID !== null) {
|
||||
@@ -686,6 +687,7 @@ export class GameImpl implements Game {
|
||||
message: message,
|
||||
playerID: id,
|
||||
goldAmount: goldAmount,
|
||||
params: params,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -738,6 +740,9 @@ export class GameImpl implements Game {
|
||||
this._railNetwork.removeStation(u);
|
||||
}
|
||||
}
|
||||
updateUnitTile(u: Unit) {
|
||||
this.unitGrid.updateUnitCell(u);
|
||||
}
|
||||
|
||||
hasUnitNearby(
|
||||
tile: TileRef,
|
||||
|
||||
@@ -216,6 +216,7 @@ export interface DisplayMessageUpdate {
|
||||
messageType: MessageType;
|
||||
goldAmount?: bigint;
|
||||
playerID: number | null;
|
||||
params?: Record<string, string | number>;
|
||||
}
|
||||
|
||||
export type DisplayChatMessageUpdate = {
|
||||
|
||||
@@ -459,11 +459,12 @@ export class GameView implements GameMap {
|
||||
} else {
|
||||
unit = new UnitView(this, update);
|
||||
this._units.set(update.id, unit);
|
||||
}
|
||||
if (update.isActive) {
|
||||
this.unitGrid.addUnit(unit);
|
||||
} else {
|
||||
}
|
||||
if (!update.isActive) {
|
||||
this.unitGrid.removeUnit(unit);
|
||||
} else if (unit.tile() !== unit.lastTile()) {
|
||||
this.unitGrid.updateUnitCell(unit);
|
||||
}
|
||||
if (!unit.isActive()) {
|
||||
// Wait until next tick to delete the unit.
|
||||
@@ -507,9 +508,7 @@ export class GameView implements GameMap {
|
||||
}
|
||||
|
||||
myPlayer(): PlayerView | null {
|
||||
if (this._myPlayer === null) {
|
||||
this._myPlayer = this.playerByClientID(this._myClientID);
|
||||
}
|
||||
this._myPlayer ??= this.playerByClientID(this._myClientID);
|
||||
return this._myPlayer;
|
||||
}
|
||||
|
||||
|
||||
+35
-32
@@ -398,7 +398,7 @@ export class PlayerImpl implements Player {
|
||||
if (other === this) {
|
||||
return false;
|
||||
}
|
||||
if (this.isFriendly(other)) {
|
||||
if (this.isFriendly(other) || !this.isAlive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -814,25 +814,32 @@ export class PlayerImpl implements Player {
|
||||
return b;
|
||||
}
|
||||
|
||||
// Returns the existing unit that can be upgraded,
|
||||
// or false if it cannot be upgraded.
|
||||
// New units of the same type can upgrade existing units.
|
||||
// e.g. if a place a new city here, can it upgrade an existing city?
|
||||
private canUpgradeExistingUnit(
|
||||
type: UnitType,
|
||||
targetTile: TileRef,
|
||||
): Unit | false {
|
||||
if (!this.mg.config().unitInfo(type).upgradable) {
|
||||
return false;
|
||||
}
|
||||
public findUnitToUpgrade(type: UnitType, targetTile: TileRef): Unit | false {
|
||||
const range = this.mg.config().structureMinDist();
|
||||
const existing = this.mg
|
||||
.nearbyUnits(targetTile, range, type)
|
||||
.sort((a, b) => a.distSquared - b.distSquared);
|
||||
if (existing.length > 0) {
|
||||
return existing[0].unit;
|
||||
if (existing.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
const unit = existing[0].unit;
|
||||
if (!this.canUpgradeUnit(unit.type())) {
|
||||
return false;
|
||||
}
|
||||
return unit;
|
||||
}
|
||||
|
||||
public canUpgradeUnit(unitType: UnitType): boolean {
|
||||
if (!this.mg.config().unitInfo(unitType).upgradable) {
|
||||
return false;
|
||||
}
|
||||
if (this.mg.config().isUnitDisabled(unitType)) {
|
||||
return false;
|
||||
}
|
||||
if (this._gold < this.mg.config().unitInfo(unitType).cost(this)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
upgradeUnit(unit: Unit) {
|
||||
@@ -847,7 +854,7 @@ export class PlayerImpl implements Player {
|
||||
return Object.values(UnitType).map((u) => {
|
||||
let canUpgrade: number | false = false;
|
||||
if (!this.mg.inSpawnPhase()) {
|
||||
const existingUnit = this.canUpgradeExistingUnit(u, tile);
|
||||
const existingUnit = this.findUnitToUpgrade(u, tile);
|
||||
if (existingUnit !== false) {
|
||||
canUpgrade = existingUnit.id();
|
||||
}
|
||||
@@ -1158,23 +1165,19 @@ export class PlayerImpl implements Player {
|
||||
);
|
||||
});
|
||||
|
||||
// Make close ports twice more likely by putting them again
|
||||
for (
|
||||
let i = 0;
|
||||
i < this.mg.config().proximityBonusPortsNb(ports.length);
|
||||
i++
|
||||
) {
|
||||
ports.push(ports[i]);
|
||||
const weightedPorts: Unit[] = [];
|
||||
|
||||
for (const [i, otherPort] of ports.entries()) {
|
||||
const expanded = new Array(otherPort.level()).fill(otherPort);
|
||||
weightedPorts.push(...expanded);
|
||||
if (i < this.mg.config().proximityBonusPortsNb(ports.length)) {
|
||||
weightedPorts.push(...expanded);
|
||||
}
|
||||
if (port.owner().isFriendly(otherPort.owner())) {
|
||||
weightedPorts.push(...expanded);
|
||||
}
|
||||
}
|
||||
|
||||
// Make ally ports twice more likely by putting them again
|
||||
this.mg
|
||||
.players()
|
||||
.filter((p) => p !== port.owner() && p.canTrade(port.owner()))
|
||||
.filter((p) => p.isAlliedWith(port.owner()))
|
||||
.flatMap((p) => p.units(UnitType.Port))
|
||||
.forEach((p) => ports.push(p));
|
||||
|
||||
return ports;
|
||||
return weightedPorts;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ export class StatsImpl implements Stats {
|
||||
private _addAttack(player: Player, index: number, value: BigIntLike) {
|
||||
const p = this._makePlayerStats(player);
|
||||
if (p === undefined) return;
|
||||
if (p.attacks === undefined) p.attacks = [0n];
|
||||
p.attacks ??= [0n];
|
||||
while (p.attacks.length <= index) p.attacks.push(0n);
|
||||
p.attacks[index] += _bigint(value);
|
||||
}
|
||||
@@ -89,8 +89,8 @@ export class StatsImpl implements Stats {
|
||||
) {
|
||||
const p = this._makePlayerStats(player);
|
||||
if (p === undefined) return;
|
||||
if (p.boats === undefined) p.boats = { [type]: [0n] };
|
||||
if (p.boats[type] === undefined) p.boats[type] = [0n];
|
||||
p.boats ??= { [type]: [0n] };
|
||||
p.boats[type] ??= [0n];
|
||||
while (p.boats[type].length <= index) p.boats[type].push(0n);
|
||||
p.boats[type][index] += _bigint(value);
|
||||
}
|
||||
@@ -104,8 +104,8 @@ export class StatsImpl implements Stats {
|
||||
const type = unitTypeToBombUnit[nukeType];
|
||||
const p = this._makePlayerStats(player);
|
||||
if (p === undefined) return;
|
||||
if (p.bombs === undefined) p.bombs = { [type]: [0n] };
|
||||
if (p.bombs[type] === undefined) p.bombs[type] = [0n];
|
||||
p.bombs ??= { [type]: [0n] };
|
||||
p.bombs[type] ??= [0n];
|
||||
while (p.bombs[type].length <= index) p.bombs[type].push(0n);
|
||||
p.bombs[type][index] += _bigint(value);
|
||||
}
|
||||
@@ -113,7 +113,7 @@ export class StatsImpl implements Stats {
|
||||
private _addGold(player: Player, index: number, value: BigIntLike) {
|
||||
const p = this._makePlayerStats(player);
|
||||
if (p === undefined) return;
|
||||
if (p.gold === undefined) p.gold = [0n];
|
||||
p.gold ??= [0n];
|
||||
while (p.gold.length <= index) p.gold.push(0n);
|
||||
p.gold[index] += _bigint(value);
|
||||
}
|
||||
@@ -127,8 +127,8 @@ export class StatsImpl implements Stats {
|
||||
const type = unitTypeToOtherUnit[otherUnitType];
|
||||
const p = this._makePlayerStats(player);
|
||||
if (p === undefined) return;
|
||||
if (p.units === undefined) p.units = { [type]: [0n] };
|
||||
if (p.units[type] === undefined) p.units[type] = [0n];
|
||||
p.units ??= { [type]: [0n] };
|
||||
p.units[type] ??= [0n];
|
||||
while (p.units[type].length <= index) p.units[type].push(0n);
|
||||
p.units[type][index] += _bigint(value);
|
||||
}
|
||||
|
||||
@@ -26,9 +26,7 @@ class GameMapLoader {
|
||||
private createLazyLoader<T>(importFn: () => Promise<T>): () => Promise<T> {
|
||||
let cache: Promise<T> | null = null;
|
||||
return () => {
|
||||
if (!cache) {
|
||||
cache = importFn();
|
||||
}
|
||||
cache ??= importFn();
|
||||
return cache;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,20 +45,13 @@ class PortStopHandler implements TrainStopHandler {
|
||||
}
|
||||
|
||||
class FactoryStopHandler implements TrainStopHandler {
|
||||
private factor: bigint = BigInt(2);
|
||||
onStop(
|
||||
mg: Game,
|
||||
station: TrainStation,
|
||||
trainExecution: TrainExecution,
|
||||
): void {
|
||||
const goldBonus = mg.config().trainGold();
|
||||
station.unit.owner().addGold(goldBonus);
|
||||
mg.addUpdate({
|
||||
type: GameUpdateType.BonusEvent,
|
||||
tile: station.tile(),
|
||||
gold: Number(goldBonus),
|
||||
workers: 0,
|
||||
troops: 0,
|
||||
});
|
||||
station.unit.owner().addGold(mg.config().trainGold(), station.tile());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ export function bestShoreDeploymentSource(
|
||||
if (t === null) return false;
|
||||
|
||||
const candidates = candidateShoreTiles(gm, player, t);
|
||||
const aStar = new MiniAStar(gm, gm.miniMap(), candidates, t, 500_000, 1);
|
||||
const aStar = new MiniAStar(gm, gm.miniMap(), candidates, t, 1_000_000, 1);
|
||||
const result = aStar.compute();
|
||||
if (result !== PathFindResultType.Completed) {
|
||||
console.warn(`bestShoreDeploymentSource: path not found: ${result}`);
|
||||
|
||||
@@ -34,6 +34,10 @@ export class UnitGrid {
|
||||
// Remove a unit from the grid
|
||||
removeUnit(unit: Unit | UnitView) {
|
||||
const tile = unit.tile();
|
||||
this.removeUnitByTile(unit, tile);
|
||||
}
|
||||
|
||||
removeUnitByTile(unit: Unit | UnitView, tile: TileRef) {
|
||||
const [gridX, gridY] = this.getGridCoords(this.gm.x(tile), this.gm.y(tile));
|
||||
|
||||
if (this.isValidCell(gridX, gridY)) {
|
||||
@@ -41,6 +45,26 @@ export class UnitGrid {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an unit to its new cell if it changed
|
||||
*/
|
||||
updateUnitCell(unit: Unit | UnitView) {
|
||||
const newTile = unit.tile();
|
||||
const oldTile = unit.lastTile();
|
||||
const [gridX, gridY] = this.getGridCoords(
|
||||
this.gm.x(oldTile),
|
||||
this.gm.y(oldTile),
|
||||
);
|
||||
const [newGridX, newGridY] = this.getGridCoords(
|
||||
this.gm.x(newTile),
|
||||
this.gm.y(newTile),
|
||||
);
|
||||
if (gridX !== newGridX || gridY !== newGridY) {
|
||||
this.removeUnitByTile(unit, oldTile);
|
||||
this.addUnit(unit);
|
||||
}
|
||||
}
|
||||
|
||||
private isValidCell(gridX: number, gridY: number): boolean {
|
||||
return (
|
||||
gridX >= 0 &&
|
||||
|
||||
@@ -147,10 +147,9 @@ export class UnitImpl implements Unit {
|
||||
if (tile === null) {
|
||||
throw new Error("tile cannot be null");
|
||||
}
|
||||
this.mg.removeUnit(this);
|
||||
this._lastTile = this._tile;
|
||||
this._tile = tile;
|
||||
this.mg.addUnit(this);
|
||||
this.mg.updateUnitTile(this);
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ const matcher = new RegExpMatcher({
|
||||
export const MIN_USERNAME_LENGTH = 3;
|
||||
export const MAX_USERNAME_LENGTH = 27;
|
||||
|
||||
const validPattern = /^[a-zA-Z0-9_\[\] 🐈🍀üÜ]+$/u;
|
||||
const validPattern = /^[a-zA-Z0-9_[\] 🐈🍀üÜ]+$/u;
|
||||
|
||||
const shadowNames = [
|
||||
"NicePeopleOnly",
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function archive(gameRecord: GameRecord) {
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`${gameRecord.info.gameID}: Final archive error: ${error}`, {
|
||||
message: error?.message || error,
|
||||
message: error?.message ?? error,
|
||||
stack: error?.stack,
|
||||
name: error?.name,
|
||||
...(error && typeof error === "object" ? error : {}),
|
||||
@@ -70,7 +70,7 @@ async function archiveAnalyticsToR2(gameRecord: GameRecord) {
|
||||
log.info(`${info.gameID}: successfully wrote game analytics to R2`);
|
||||
} catch (error) {
|
||||
log.error(`${info.gameID}: Error writing game analytics to R2: ${error}`, {
|
||||
message: error?.message || error,
|
||||
message: error?.message ?? error,
|
||||
stack: error?.stack,
|
||||
name: error?.name,
|
||||
...(error && typeof error === "object" ? error : {}),
|
||||
@@ -119,7 +119,7 @@ export async function readGameRecord(
|
||||
} catch (error) {
|
||||
// Log the error for monitoring purposes
|
||||
log.error(`${gameId}: Error reading game record from R2: ${error}`, {
|
||||
message: error?.message || error,
|
||||
message: error?.message ?? error,
|
||||
stack: error?.stack,
|
||||
name: error?.name,
|
||||
...(error && typeof error === "object" ? error : {}),
|
||||
@@ -142,7 +142,7 @@ export async function gameRecordExists(gameId: GameID): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
log.error(`${gameId}: Error checking archive existence: ${error}`, {
|
||||
message: error?.message || error,
|
||||
message: error?.message ?? error,
|
||||
stack: error?.stack,
|
||||
name: error?.name,
|
||||
...(error && typeof error === "object" ? error : {}),
|
||||
|
||||
@@ -162,7 +162,9 @@ export class Cloudflare {
|
||||
);
|
||||
|
||||
const credentials = {
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
AccountTag: tokenData.a || this.accountId,
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
TunnelID: tokenData.t || tunnelId,
|
||||
TunnelName: tunnelName,
|
||||
TunnelSecret: tokenData.s,
|
||||
|
||||
+15
-21
@@ -61,6 +61,8 @@ export class GameServer {
|
||||
private kickedClients: Set<ClientID> = new Set();
|
||||
private outOfSyncClients: Set<ClientID> = new Set();
|
||||
|
||||
private websockets: Set<WebSocket> = new Set();
|
||||
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
readonly log_: Logger,
|
||||
@@ -107,6 +109,7 @@ export class GameServer {
|
||||
}
|
||||
|
||||
public addClient(client: Client, lastTurn: number) {
|
||||
this.websockets.add(client.ws);
|
||||
if (this.kickedClients.has(client.clientID)) {
|
||||
this.log.warn(`cannot add client, already kicked`, {
|
||||
clientID: client.clientID,
|
||||
@@ -173,7 +176,6 @@ export class GameServer {
|
||||
client.isDisconnected = existing.isDisconnected;
|
||||
client.lastPing = existing.lastPing;
|
||||
|
||||
existing.ws.removeAllListeners();
|
||||
this.activeClients = this.activeClients.filter((c) => c !== existing);
|
||||
}
|
||||
|
||||
@@ -184,8 +186,6 @@ export class GameServer {
|
||||
this.allClients.set(client.clientID, client);
|
||||
|
||||
client.ws.removeAllListeners("message");
|
||||
client.ws.removeAllListeners("close");
|
||||
client.ws.removeAllListeners("error");
|
||||
client.ws.on(
|
||||
"message",
|
||||
gatekeeper.wsHandler(client.ip, async (message: string) => {
|
||||
@@ -199,13 +199,11 @@ export class GameServer {
|
||||
client.ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error: error.toString(),
|
||||
error,
|
||||
message,
|
||||
} satisfies ServerErrorMessage),
|
||||
);
|
||||
// Add a small delay before closing the connection to ensure the error message is received
|
||||
setTimeout(() => {
|
||||
client.ws.close(1002, "ClientMessageSchema");
|
||||
}, 100);
|
||||
client.ws.close(1002, "ClientMessageSchema");
|
||||
return;
|
||||
}
|
||||
const clientMsg = parsed.data;
|
||||
@@ -403,10 +401,9 @@ export class GameServer {
|
||||
async end() {
|
||||
// Close all WebSocket connections
|
||||
clearInterval(this.endTurnIntervalID);
|
||||
this.allClients.forEach((client) => {
|
||||
client.ws.removeAllListeners("message");
|
||||
if (client.ws.readyState === WebSocket.OPEN) {
|
||||
client.ws.close(1000, "game has ended");
|
||||
this.websockets.forEach((ws) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.close(1000, "game has ended");
|
||||
}
|
||||
});
|
||||
if (!this._hasPrestarted && !this._hasStarted) {
|
||||
@@ -552,14 +549,11 @@ export class GameServer {
|
||||
error: "Kicked from game (you may have been playing on another tab)",
|
||||
} satisfies ServerErrorMessage),
|
||||
);
|
||||
// Add a small delay before closing the connection to ensure the error message is received
|
||||
setTimeout(() => {
|
||||
client.ws.close(1000, "Kicked from game");
|
||||
this.activeClients = this.activeClients.filter(
|
||||
(c) => c.clientID !== clientID,
|
||||
);
|
||||
this.kickedClients.add(clientID);
|
||||
}, 100);
|
||||
client.ws.close(1000, "Kicked from game");
|
||||
this.activeClients = this.activeClients.filter(
|
||||
(c) => c.clientID !== clientID,
|
||||
);
|
||||
this.kickedClients.add(clientID);
|
||||
} else {
|
||||
this.log.warn(`cannot kick client, not found in game`, {
|
||||
clientID,
|
||||
@@ -694,7 +688,7 @@ export class GameServer {
|
||||
for (const client of this.activeClients) {
|
||||
if (client.hashes.has(turnNumber)) {
|
||||
const clientHash = client.hashes.get(turnNumber)!;
|
||||
counts.set(clientHash, (counts.get(clientHash) || 0) + 1);
|
||||
counts.set(clientHash, (counts.get(clientHash) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
import { OpenTelemetryTransportV3 } from "@opentelemetry/winston-transport";
|
||||
import * as dotenv from "dotenv";
|
||||
import winston from "winston";
|
||||
import { GameEnv } from "../core/configuration/Config";
|
||||
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
|
||||
import { getOtelResource } from "./OtelResource";
|
||||
dotenv.config();
|
||||
@@ -21,17 +20,11 @@ const loggerProvider = new LoggerProvider({
|
||||
resource,
|
||||
});
|
||||
|
||||
if (config.env() === GameEnv.Prod && config.otelEnabled()) {
|
||||
if (config.otelEnabled()) {
|
||||
console.log("OTEL enabled");
|
||||
// Configure OpenTelemetry endpoint with basic auth (if provided)
|
||||
const headers = {};
|
||||
if (config.otelUsername() && config.otelPassword()) {
|
||||
headers["Authorization"] =
|
||||
"Basic " +
|
||||
Buffer.from(`${config.otelUsername()}:${config.otelPassword()}`).toString(
|
||||
"base64",
|
||||
);
|
||||
}
|
||||
headers["Authorization"] = config.otelAuthHeader();
|
||||
|
||||
// Add OTLP exporter for logs
|
||||
const logExporter = new OTLPLogExporter({
|
||||
|
||||
@@ -146,8 +146,9 @@ app.get(
|
||||
"/api/env",
|
||||
gatekeeper.httpHandler(LimiterType.Get, async (req, res) => {
|
||||
const envConfig = {
|
||||
game_env: process.env.GAME_ENV || "prod",
|
||||
game_env: process.env.GAME_ENV,
|
||||
};
|
||||
if (!envConfig.game_env) return res.sendStatus(500);
|
||||
res.json(envConfig);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -11,6 +11,12 @@ export function getOtelResource() {
|
||||
return resourceFromAttributes({
|
||||
[ATTR_SERVICE_NAME]: "openfront",
|
||||
[ATTR_SERVICE_VERSION]: "1.0.0",
|
||||
...getPromLabels(),
|
||||
});
|
||||
}
|
||||
|
||||
export function getPromLabels() {
|
||||
return {
|
||||
"service.instance.id": process.env.HOSTNAME,
|
||||
"openfront.environment": config.env(),
|
||||
"openfront.host": process.env.HOST,
|
||||
@@ -19,9 +25,5 @@ export function getOtelResource() {
|
||||
"openfront.component": process.env.WORKER_ID
|
||||
? "Worker " + process.env.WORKER_ID
|
||||
: "Master",
|
||||
// The comma-separated list tells OpenTelemetry which resource attributes
|
||||
// should be converted to Loki labels
|
||||
"loki.resource.labels":
|
||||
"service.name,service.instance.id,openfront.environment,openfront.host,openfront.domain,openfront.subdomain,openfront.component",
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
+11
-3
@@ -29,7 +29,7 @@ import { initWorkerMetrics } from "./WorkerMetrics";
|
||||
|
||||
const config = getServerConfigFromServer();
|
||||
|
||||
const workerId = parseInt(process.env.WORKER_ID || "0");
|
||||
const workerId = parseInt(process.env.WORKER_ID ?? "0");
|
||||
const log = logger.child({ comp: `w_${workerId}` });
|
||||
|
||||
// Worker setup
|
||||
@@ -47,7 +47,7 @@ export function startWorker() {
|
||||
|
||||
const privilegeChecker = new PrivilegeChecker(COSMETICS, base64url.decode);
|
||||
|
||||
if (config.env() === GameEnv.Prod && config.otelEnabled()) {
|
||||
if (config.otelEnabled()) {
|
||||
initWorkerMetrics(gm);
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ export function startWorker() {
|
||||
log.warn(`cannot create game, id not found`);
|
||||
return res.status(400).json({ error: "Game ID is required" });
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
|
||||
const result = CreateGameInputSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
@@ -140,6 +141,7 @@ export function startWorker() {
|
||||
return;
|
||||
}
|
||||
if (game.isPublic()) {
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
|
||||
log.info(
|
||||
`cannot start public game ${game.id}, game is public, ip: ${ipAnonymize(clientIP)}`,
|
||||
@@ -171,6 +173,7 @@ export function startWorker() {
|
||||
return res.status(400).json({ error: "Game not found" });
|
||||
}
|
||||
if (game.isPublic()) {
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
|
||||
log.warn(
|
||||
`cannot update public game ${game.id}, ip: ${ipAnonymize(clientIP)}`,
|
||||
@@ -296,7 +299,8 @@ export function startWorker() {
|
||||
const forwarded = req.headers["x-forwarded-for"];
|
||||
const ip = Array.isArray(forwarded)
|
||||
? forwarded[0]
|
||||
: forwarded || req.socket.remoteAddress || "unknown";
|
||||
: // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
forwarded || req.socket.remoteAddress || "unknown";
|
||||
|
||||
try {
|
||||
// Parse and handle client messages
|
||||
@@ -445,6 +449,7 @@ export function startWorker() {
|
||||
|
||||
// Handle other message types
|
||||
} catch (error) {
|
||||
ws.close(1011, "Internal server error");
|
||||
log.warn(
|
||||
`error handling websocket message for ${ipAnonymize(ip)}: ${error}`.substring(
|
||||
0,
|
||||
@@ -460,6 +465,9 @@ export function startWorker() {
|
||||
ws.close(1002, "WS_ERR_UNEXPECTED_RSV_1");
|
||||
}
|
||||
});
|
||||
ws.on("close", () => {
|
||||
ws.removeAllListeners();
|
||||
});
|
||||
});
|
||||
|
||||
// The load balancer will handle routing to this server based on path
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import * as dotenv from "dotenv";
|
||||
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
|
||||
import { GameManager } from "./GameManager";
|
||||
import { getOtelResource } from "./OtelResource";
|
||||
import { getOtelResource, getPromLabels } from "./OtelResource";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -20,11 +20,7 @@ export function initWorkerMetrics(gameManager: GameManager): void {
|
||||
// Configure auth headers
|
||||
const headers = {};
|
||||
if (config.otelEnabled()) {
|
||||
headers["Authorization"] =
|
||||
"Basic " +
|
||||
Buffer.from(`${config.otelUsername()}:${config.otelPassword()}`).toString(
|
||||
"base64",
|
||||
);
|
||||
headers["Authorization"] = config.otelAuthHeader();
|
||||
}
|
||||
|
||||
// Create metrics exporter
|
||||
@@ -73,19 +69,19 @@ export function initWorkerMetrics(gameManager: GameManager): void {
|
||||
// Register callback for active games metric
|
||||
activeGamesGauge.addCallback((result) => {
|
||||
const count = gameManager.activeGames();
|
||||
result.observe(count);
|
||||
result.observe(count, getPromLabels());
|
||||
});
|
||||
|
||||
// Register callback for connected clients metric
|
||||
connectedClientsGauge.addCallback((result) => {
|
||||
const count = gameManager.activeClients();
|
||||
result.observe(count);
|
||||
result.observe(count, getPromLabels());
|
||||
});
|
||||
|
||||
// Register callback for memory usage metric
|
||||
memoryUsageGauge.addCallback((result) => {
|
||||
const memoryUsage = process.memoryUsage();
|
||||
result.observe(memoryUsage.heapUsed);
|
||||
result.observe(memoryUsage.heapUsed, getPromLabels());
|
||||
});
|
||||
|
||||
console.log("Metrics initialized with GameManager");
|
||||
|
||||
Reference in New Issue
Block a user