Merge branch 'v24' into evan-checkout-flow

This commit is contained in:
Drills Kibo
2025-07-28 17:22:47 +02:00
committed by GitHub
144 changed files with 5573 additions and 1394 deletions
+15 -11
View File
@@ -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
View File
@@ -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;
+1 -1
View File
@@ -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">
+3
View File
@@ -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;
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+2 -4
View File
@@ -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>
+4 -13
View File
@@ -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,
+2 -2
View File
@@ -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 => {
+7 -8
View File
@@ -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();
}
+78 -16
View File
@@ -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)}%
+50 -101
View File
@@ -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
}
}
+19 -5
View File
@@ -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
+13 -3
View File
@@ -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() {
-168
View File
@@ -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>
`;
}
}
+23 -38
View File
@@ -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>
`;
}
}
+28 -18
View File
@@ -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) {
+49 -62
View File
@@ -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
+1 -1
View File
@@ -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()}
>
+143 -97
View File
@@ -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];
},
};
+5 -1
View File
@@ -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;
});
}
}
+6 -3
View File
@@ -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>
+5 -11
View File
@@ -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;
+1 -1
View File
@@ -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);
}
+7 -1
View File
@@ -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();
+13
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+6 -1
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
export interface GameEvent {}
export type GameEvent = object;
export interface EventConstructor<T extends GameEvent = GameEvent> {
new (...args: any[]): T;
+6 -7
View File
@@ -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", [
+1 -1
View File
@@ -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
View File
@@ -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 {
+1 -3
View File
@@ -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);
}
}
}
+11 -4
View File
@@ -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;
+74 -95
View File
@@ -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 {
+9 -7
View File
@@ -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()) {
+5 -14
View File
@@ -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;
}
}
+1 -3
View File
@@ -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 {
+1 -3
View File
@@ -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);
+3 -10
View File
@@ -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);
}
+3 -3
View File
@@ -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));
}
}
}
+60 -13
View File
@@ -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;
+1 -3
View File
@@ -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();
+36 -9
View File
@@ -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);
});
}
+16
View File
@@ -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));
}
}
}
}
+3 -3
View File
@@ -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;
+2 -6
View File
@@ -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(),
+5 -7
View File
@@ -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;
+1 -3
View File
@@ -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;
+5
View File
@@ -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;
+20 -13
View File
@@ -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()) {
+42 -16
View File
@@ -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;
}
}
+15 -16
View File
@@ -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);
+1 -1
View File
@@ -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
View File
@@ -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,
+5
View File
@@ -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,
+1
View File
@@ -216,6 +216,7 @@ export interface DisplayMessageUpdate {
messageType: MessageType;
goldAmount?: bigint;
playerID: number | null;
params?: Record<string, string | number>;
}
export type DisplayChatMessageUpdate = {
+5 -6
View File
@@ -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
View File
@@ -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;
}
}
+8 -8
View File
@@ -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);
}
+1 -3
View File
@@ -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;
};
}
+2 -9
View File
@@ -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());
}
}
+1 -1
View File
@@ -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}`);
+24
View File
@@ -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 &&
+1 -2
View File
@@ -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());
}
+1 -1
View File
@@ -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",
+4 -4
View File
@@ -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 : {}),
+2
View File
@@ -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
View File
@@ -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);
}
}
+2 -9
View File
@@ -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({
+2 -1
View File
@@ -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);
}),
);
+7 -5
View File
@@ -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
View File
@@ -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
+5 -9
View File
@@ -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");