Merge main into v0.25.x

This commit is contained in:
Scott Anderson
2025-07-25 18:56:22 -04:00
66 changed files with 1446 additions and 564 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");
+19 -8
View File
@@ -13,6 +13,7 @@ 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";
@@ -20,14 +21,14 @@ 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 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 {
@@ -54,7 +55,7 @@ export class LangSelector extends LitElement {
ja,
nl,
pl,
pt_br,
"pt-BR": pt_BR,
ru,
sh,
tr,
@@ -64,9 +65,10 @@ export class LangSelector extends LitElement {
he,
da,
fi,
sv_se,
zh_cn,
"sv-SE": sv_SE,
"zh-CN": zh_CN,
ko,
gl,
};
createRenderRoot() {
@@ -91,8 +93,16 @@ 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";
}
@@ -206,6 +216,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
@@ -447,6 +447,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;
+8 -8
View File
@@ -2,7 +2,6 @@ import { z } from "zod";
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,
) {}
}
@@ -342,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();
}
@@ -435,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,
});
}
@@ -537,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,
});
}
@@ -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 -4
View File
@@ -74,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;
@@ -89,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;
+2 -8
View File
@@ -15,7 +15,6 @@ import { translateText } from "../../../client/Utils";
import { EventBus } from "../../../core/EventBus";
import {
BuildableUnit,
Cell,
Gold,
PlayerActions,
UnitType,
@@ -384,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 {
@@ -396,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();
}
+4 -4
View File
@@ -212,11 +212,11 @@ export class ControlPanel extends LitElement implements Layer {
</style>
<div
class="${this._isVisible
? "w-[320px] text-sm lg:text-m 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="hidden lg:block bg-black/30 text-white mb-4 p-2 rounded">
<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
@@ -244,7 +244,7 @@ export class ControlPanel extends LitElement implements Layer {
</div>
</div>
<div class="relative mb-4 lg:mb-4">
<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> |
@@ -277,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
}
}
+9 -2
View File
@@ -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,
@@ -900,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
@@ -42,8 +42,6 @@ export class GameLeftSidebar extends LitElement implements Layer {
}
tick() {
if (!this.isPlayerTeamLabelVisible) return;
if (!this.playerTeam && this.game.myPlayer()?.team()) {
this.playerTeam = this.game.myPlayer()!.team();
if (this.playerTeam) {
@@ -142,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>
+1 -1
View File
@@ -56,7 +56,7 @@ export class MainRadialMenu extends LitElement implements Layer {
`,
};
this.radialMenu = new RadialMenu(menuConfig);
this.radialMenu = new RadialMenu(this.eventBus, menuConfig);
this.playerActionHandler = new PlayerActionHandler(
this.eventBus,
@@ -1,5 +1,5 @@
import { EventBus } from "../../../core/EventBus";
import { Cell, PlayerActions, PlayerID } from "../../../core/game/Game";
import { PlayerActions, PlayerID } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { PlayerView } from "../../../core/game/GameView";
import {
@@ -61,8 +61,9 @@ export class PlayerActionHandler {
): Promise<TileRef | false> {
return await player.bestTransportShipSpawn(tile);
}
handleSpawn(spawnCell: Cell) {
this.eventBus.emit(new SendSpawnIntentEvent(spawnCell));
handleSpawn(tile: TileRef) {
this.eventBus.emit(new SendSpawnIntentEvent(tile));
}
handleAllianceRequest(player: PlayerView, recipient: PlayerView) {
@@ -13,10 +13,11 @@ import {
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { MouseMoveEvent } from "../../InputHandler";
import { ContextMenuEvent, MouseMoveEvent } from "../../InputHandler";
import { renderNumber, renderTroops } from "../../Utils";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
import { CloseRadialMenuEvent } from "./RadialMenu";
function euclideanDistWorld(
coord: { x: number; y: number },
@@ -69,6 +70,10 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
this.eventBus.on(MouseMoveEvent, (e: MouseMoveEvent) =>
this.onMouseEvent(e),
);
this.eventBus.on(ContextMenuEvent, (e: ContextMenuEvent) =>
this.maybeShow(e.x, e.y),
);
this.eventBus.on(CloseRadialMenuEvent, () => this.hide());
this._isActive = true;
}
@@ -312,7 +317,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
return html`
<div
class="hidden lg:flex fixed top-[150px] 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()}
>
+11 -1
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;
@@ -72,7 +77,10 @@ export class RadialMenu implements Layer {
private params: MenuElementParams | null = null;
constructor(config: RadialMenuConfig = {}) {
constructor(
private eventBus: EventBus,
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
@@ -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";
@@ -427,11 +422,7 @@ export const centerButtonElement: CenterButtonElement = {
},
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,
+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>
+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">
+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());
+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;
+68 -87
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.6;
return 0.8;
}
traitorDuration(): number {
return 30 * 10; // 30 seconds
@@ -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++) {
@@ -361,7 +359,7 @@ export class DefaultConfig implements Config {
}
if (numTradeShips <= 150) {
const additional = numTradeShips - 20;
return Math.pow(additional, 0.8) + 5;
return Math.floor(Math.pow(additional, 0.85) + 5);
}
return 1_000_000;
}
@@ -375,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,
};
@@ -400,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,
@@ -416,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:
@@ -450,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,
@@ -505,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,
@@ -535,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);
}
@@ -557,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.
@@ -788,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) {
@@ -842,6 +806,10 @@ export class DefaultConfig implements Config {
throw new Error(`Unknown nuke type: ${unitType}`);
}
nukeAllianceBreakThreshold(): number {
return 100;
}
defaultNukeSpeed(): number {
return 6;
}
@@ -855,8 +823,21 @@ 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.05 * maxPop;
const excessPop = Math.max(0, humans - targetPop);
const scalingFactor = 20000;
return (scalingFactor * excessPop * excessPop) / (maxPop * maxPop);
}
structureMinDist(): number {
+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;
}
}
+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);
}
+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);
});
}
+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;
@@ -76,7 +76,6 @@ export class TransportShipExecution implements Execution {
this.attacker.id(),
);
this.active = false;
this.attacker.addTroops(this.troops);
return;
}
@@ -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() },
);
}
}
+2 -2
View File
@@ -7,7 +7,6 @@ import {
PlayerUpdate,
UnitUpdate,
} from "./GameUpdates";
import { PlayerView } from "./GameView";
import { RailNetwork } from "./RailNetwork";
import { Stats } from "./Stats";
@@ -133,7 +132,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;
@@ -670,6 +669,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 = {
+4 -3
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.
+13 -17
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;
}
@@ -1165,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;
}
}
+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());
}
+20 -20
View File
@@ -62,6 +62,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,
@@ -108,6 +110,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,
@@ -184,9 +187,7 @@ export class GameServer {
this.allClients.set(client.clientID, client);
client.ws.removeAllListeners("message");
client.ws.removeAllListeners("close");
client.ws.removeAllListeners("error");
client.ws.removeAllListeners();
client.ws.on(
"message",
gatekeeper.wsHandler(client.ip, async (message: string) => {
@@ -200,13 +201,12 @@ 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");
client.ws.removeAllListeners();
return;
}
const clientMsg = parsed.data;
@@ -264,6 +264,7 @@ export class GameServer {
});
client.ws.on("error", (error: Error) => {
if ((error as any).code === "WS_ERR_UNEXPECTED_RSV_1") {
client.ws.removeAllListeners();
client.ws.close(1002, "WS_ERR_UNEXPECTED_RSV_1");
}
});
@@ -406,10 +407,10 @@ export class GameServer {
if (this.endTurnIntervalID) {
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) => {
ws.removeAllListeners();
if (ws.readyState === WebSocket.OPEN) {
ws.close(1000, "game has ended");
}
});
if (!this._hasPrestarted && !this._hasStarted) {
@@ -465,6 +466,7 @@ export class GameServer {
});
if (client.ws.readyState === WebSocket.OPEN) {
client.ws.close(1000, "no heartbeats received, closing connection");
client.ws.removeAllListeners();
}
} else {
alive.push(client);
@@ -555,14 +557,12 @@ 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,
);
client.ws.removeAllListeners();
this.kickedClients.add(clientID);
} else {
this.log.warn(`cannot kick client, not found in game`, {
clientID,
+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: Record<string, string> = {};
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({
+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",
});
};
}
+15 -1
View File
@@ -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);
}
@@ -316,6 +316,7 @@ export function startWorker() {
error: error.toString(),
} satisfies ServerErrorMessage),
);
ws.removeAllListeners();
ws.close(1002, "ClientJoinMessageSchema");
return;
}
@@ -333,6 +334,7 @@ export function startWorker() {
error,
} satisfies ServerErrorMessage),
);
ws.removeAllListeners();
ws.close(1002, "ClientJoinMessageSchema");
return;
}
@@ -350,6 +352,7 @@ export function startWorker() {
const result = await verifyClientToken(clientMsg.token, config);
if (result === false) {
log.warn("Unauthorized: Invalid token");
ws.removeAllListeners();
ws.close(1002, "Unauthorized");
return;
}
@@ -362,6 +365,7 @@ export function startWorker() {
if (claims === null) {
if (allowedFlares !== undefined) {
log.warn("Unauthorized: Anonymous user attempted to join game");
ws.removeAllListeners();
ws.close(1002, "Unauthorized");
return;
}
@@ -370,6 +374,7 @@ export function startWorker() {
const result = await getUserMe(clientMsg.token, config);
if (result === false) {
log.warn("Unauthorized: Invalid session");
ws.removeAllListeners();
ws.close(1002, "Unauthorized");
return;
}
@@ -384,6 +389,7 @@ export function startWorker() {
log.warn(
"Forbidden: player without an allowed flare attempted to join game",
);
ws.removeAllListeners();
ws.close(1002, "Forbidden");
return;
}
@@ -400,6 +406,7 @@ export function startWorker() {
);
if (allowed !== true) {
log.warn(`Custom flag ${allowed}: ${clientMsg.flag}`);
ws.removeAllListeners();
ws.close(1002, `Custom flag ${allowed}`);
return;
}
@@ -415,6 +422,7 @@ export function startWorker() {
);
if (allowed !== true) {
log.warn(`Pattern ${allowed}: ${clientMsg.pattern}`);
ws.removeAllListeners();
ws.close(1002, `Pattern ${allowed}`);
return;
}
@@ -449,6 +457,8 @@ export function startWorker() {
// Handle other message types
} catch (error) {
ws.removeAllListeners();
ws.close(1011, "Internal server error");
log.warn(
`error handling websocket message for ${ipAnonymize(ip)}: ${error}`.substring(
0,
@@ -460,10 +470,14 @@ export function startWorker() {
);
ws.on("error", (error: Error) => {
ws.removeAllListeners();
if ((error as any).code === "WS_ERR_UNEXPECTED_RSV_1") {
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: Record<string, string> = {};
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");