mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-30 18:01:17 +00:00
Merge main into v0.25.x
This commit is contained in:
@@ -103,7 +103,7 @@ export function joinLobby(
|
||||
if (message.type === "error") {
|
||||
showErrorModal(
|
||||
message.error,
|
||||
"",
|
||||
message.message,
|
||||
lobbyConfig.gameID,
|
||||
lobbyConfig.clientID,
|
||||
true,
|
||||
@@ -329,7 +329,7 @@ export class ClientGameRunner {
|
||||
if (message.type === "error") {
|
||||
showErrorModal(
|
||||
message.error,
|
||||
"",
|
||||
message.message,
|
||||
this.lobby.gameID,
|
||||
this.lobby.clientID,
|
||||
true,
|
||||
@@ -385,7 +385,7 @@ export class ClientGameRunner {
|
||||
!this.gameView.hasOwner(tile) &&
|
||||
this.gameView.inSpawnPhase()
|
||||
) {
|
||||
this.eventBus.emit(new SendSpawnIntentEvent(cell));
|
||||
this.eventBus.emit(new SendSpawnIntentEvent(tile));
|
||||
return;
|
||||
}
|
||||
if (this.gameView.inSpawnPhase()) {
|
||||
@@ -582,27 +582,31 @@ export class ClientGameRunner {
|
||||
}
|
||||
|
||||
function showErrorModal(
|
||||
errMsg: string,
|
||||
stack: string,
|
||||
error: string,
|
||||
message: string | undefined,
|
||||
gameID: GameID,
|
||||
clientID: ClientID,
|
||||
closable = false,
|
||||
showDiscord = true,
|
||||
heading = "error_modal.crashed",
|
||||
) {
|
||||
const errorText = `Error: ${errMsg}\nStack: ${stack}`;
|
||||
|
||||
if (document.querySelector("#error-modal")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = document.createElement("div");
|
||||
|
||||
modal.id = "error-modal";
|
||||
|
||||
const discord = showDiscord ? translateText("error_modal.paste_discord") : "";
|
||||
|
||||
const content = `${discord}\n${translateText(heading)}\n game id: ${gameID}, client id: ${clientID}\n${errorText}`;
|
||||
const content = [
|
||||
showDiscord ? translateText("error_modal.paste_discord") : null,
|
||||
translateText(heading),
|
||||
`game id: ${gameID}`,
|
||||
`client id: ${clientID}`,
|
||||
`Error: ${error}`,
|
||||
message ? `Message: ${message}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
// Create elements
|
||||
const pre = document.createElement("pre");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)}%
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -233,7 +233,7 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="fixed inset-0 flex items-center justify-center z-50 pointer-events-none overflow-auto"
|
||||
class="fixed inset-0 flex items-center justify-center z-[1001] pointer-events-none overflow-auto"
|
||||
@contextmenu=${(e) => e.preventDefault()}
|
||||
@wheel=${(e) => e.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as d3 from "d3";
|
||||
import backIcon from "../../../../resources/images/BackIconWhite.svg";
|
||||
import { EventBus, GameEvent } from "../../../core/EventBus";
|
||||
import { Layer } from "./Layer";
|
||||
import {
|
||||
CenterButtonElement,
|
||||
@@ -7,6 +8,10 @@ import {
|
||||
MenuElementParams,
|
||||
} from "./RadialMenuElements";
|
||||
|
||||
export class CloseRadialMenuEvent implements GameEvent {
|
||||
constructor() {}
|
||||
}
|
||||
|
||||
export interface TooltipItem {
|
||||
text: string;
|
||||
className: string;
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -24,12 +24,21 @@ export class UnitDisplay extends LitElement implements Layer {
|
||||
private _port = 0;
|
||||
private _defensePost = 0;
|
||||
private _samLauncher = 0;
|
||||
private allDisabled = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
init() {
|
||||
const config = this.game.config();
|
||||
this.allDisabled =
|
||||
config.isUnitDisabled(UnitType.City) &&
|
||||
config.isUnitDisabled(UnitType.Factory) &&
|
||||
config.isUnitDisabled(UnitType.Port) &&
|
||||
config.isUnitDisabled(UnitType.DefensePost) &&
|
||||
config.isUnitDisabled(UnitType.MissileSilo) &&
|
||||
config.isUnitDisabled(UnitType.SAMLauncher);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
@@ -89,6 +98,10 @@ export class UnitDisplay extends LitElement implements Layer {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.allDisabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="fixed bottom-4 left-1/2 transform -translate-x-1/2 z-[1100] bg-gray-800/70 backdrop-blur-sm border border-slate-400 rounded-lg p-2 hidden lg:block"
|
||||
|
||||
+12
-2
@@ -332,10 +332,20 @@
|
||||
class="ml-2 mr-4"
|
||||
/>
|
||||
</a>
|
||||
<a href="/privacy-policy.html" class="t-link" target="_blank">
|
||||
<a
|
||||
href="/privacy-policy.html"
|
||||
data-i18n="main.privacy_policy"
|
||||
class="t-link"
|
||||
target="_blank"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
<a href="/terms-of-service.html" class="t-link" target="_blank">
|
||||
<a
|
||||
href="/terms-of-service.html"
|
||||
data-i18n="main.terms_of_service"
|
||||
class="t-link"
|
||||
target="_blank"
|
||||
>
|
||||
Terms of Service
|
||||
</a>
|
||||
<p style="text-align: center">
|
||||
|
||||
+6
-7
@@ -162,7 +162,7 @@ export const TeamSchema = z.string();
|
||||
const SafeString = z
|
||||
.string()
|
||||
.regex(
|
||||
/^([a-zA-Z0-9\s.,!?@#$%&*()\-_+=[\]{}|;:"'/\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff]|üÜ])*$/,
|
||||
/^([a-zA-Z0-9\s.,!?@#$%&*()\-_+=[\]{}|;:"'/\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff]|[üÜ])*$/u,
|
||||
)
|
||||
.max(1000);
|
||||
|
||||
@@ -246,7 +246,7 @@ export const AllianceExtensionIntentSchema = BaseIntentSchema.extend({
|
||||
export const AttackIntentSchema = BaseIntentSchema.extend({
|
||||
type: z.literal("attack"),
|
||||
targetID: ID.nullable(),
|
||||
troops: z.number().nullable(),
|
||||
troops: z.number().nonnegative().nullable(),
|
||||
});
|
||||
|
||||
export const SpawnIntentSchema = BaseIntentSchema.extend({
|
||||
@@ -255,14 +255,13 @@ export const SpawnIntentSchema = BaseIntentSchema.extend({
|
||||
flag: FlagSchema,
|
||||
pattern: PatternSchema,
|
||||
playerType: PlayerTypeSchema,
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
tile: z.number(),
|
||||
});
|
||||
|
||||
export const BoatAttackIntentSchema = BaseIntentSchema.extend({
|
||||
type: z.literal("boat"),
|
||||
targetID: ID.nullable(),
|
||||
troops: z.number(),
|
||||
troops: z.number().nonnegative(),
|
||||
dst: z.number(),
|
||||
src: z.number().nullable(),
|
||||
});
|
||||
@@ -320,8 +319,7 @@ export const TargetTroopRatioIntentSchema = BaseIntentSchema.extend({
|
||||
export const BuildUnitIntentSchema = BaseIntentSchema.extend({
|
||||
type: z.literal("build_unit"),
|
||||
unit: z.enum(UnitType),
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
tile: z.number(),
|
||||
});
|
||||
|
||||
export const UpgradeStructureIntentSchema = BaseIntentSchema.extend({
|
||||
@@ -450,6 +448,7 @@ export const ServerDesyncSchema = z.object({
|
||||
export const ServerErrorSchema = z.object({
|
||||
type: z.literal("error"),
|
||||
error: z.string(),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ServerMessageSchema = z.discriminatedUnion("type", [
|
||||
|
||||
@@ -89,7 +89,7 @@ export const OTHER_INDEX_LOST = 3; // Structures/warships destroyed/captured by
|
||||
export const OTHER_INDEX_UPGRADE = 4; // Structures upgraded
|
||||
|
||||
const BigIntStringSchema = z.preprocess((val) => {
|
||||
if (typeof val === "string" && /^\d+$/.test(val)) return BigInt(val);
|
||||
if (typeof val === "string" && /^-?\d+$/.test(val)) return BigInt(val);
|
||||
if (typeof val === "bigint") return val;
|
||||
return val;
|
||||
}, z.bigint());
|
||||
|
||||
@@ -76,9 +76,7 @@ export class ColorAllocator {
|
||||
case ColoredTeams.Bot:
|
||||
return botColor;
|
||||
default:
|
||||
return this.availableColors[
|
||||
simpleHash(team) % this.availableColors.length
|
||||
];
|
||||
return this.assignColor(team);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Colord } from "colord";
|
||||
import { JWK } from "jose";
|
||||
import { GameConfig, GameID, TeamCountConfig } from "../Schemas";
|
||||
import {
|
||||
Difficulty,
|
||||
Game,
|
||||
@@ -18,6 +17,8 @@ import {
|
||||
import { GameMap, TileRef } from "../game/GameMap";
|
||||
import { PlayerView } from "../game/GameView";
|
||||
import { UserSettings } from "../game/UserSettings";
|
||||
import { GameConfig, GameID, TeamCountConfig } from "../Schemas";
|
||||
import { NukeType } from "../StatsSchemas";
|
||||
|
||||
export enum GameEnv {
|
||||
Dev,
|
||||
@@ -48,8 +49,7 @@ export interface ServerConfig {
|
||||
r2AccessKey(): string;
|
||||
r2SecretKey(): string;
|
||||
otelEndpoint(): string;
|
||||
otelUsername(): string;
|
||||
otelPassword(): string;
|
||||
otelAuthHeader(): string;
|
||||
otelEnabled(): boolean;
|
||||
jwtAudience(): string;
|
||||
jwtIssuer(): string;
|
||||
@@ -153,10 +153,17 @@ export interface Config {
|
||||
traitorDefenseDebuff(): number;
|
||||
traitorDuration(): number;
|
||||
nukeMagnitudes(unitType: UnitType): NukeMagnitude;
|
||||
// Number of tiles destroyed to break an alliance
|
||||
nukeAllianceBreakThreshold(): number;
|
||||
defaultNukeSpeed(): number;
|
||||
defaultNukeTargetableRange(): number;
|
||||
defaultSamRange(): number;
|
||||
nukeDeathFactor(humans: number, tilesOwned: number): number;
|
||||
nukeDeathFactor(
|
||||
nukeType: NukeType,
|
||||
humans: number,
|
||||
tilesOwned: number,
|
||||
maxPop: number,
|
||||
): number;
|
||||
structureMinDist(): number;
|
||||
isReplay(): boolean;
|
||||
allianceExtensionPromptOffset(): number;
|
||||
|
||||
@@ -23,6 +23,7 @@ import { TileRef } from "../game/GameMap";
|
||||
import { PlayerView } from "../game/GameView";
|
||||
import { UserSettings } from "../game/UserSettings";
|
||||
import { GameConfig, GameID, TeamCountConfig } from "../Schemas";
|
||||
import { NukeType } from "../StatsSchemas";
|
||||
import { assertNever, simpleHash, within } from "../Util";
|
||||
import { Config, GameEnv, NukeMagnitude, ServerConfig, Theme } from "./Config";
|
||||
import { PastelTheme } from "./PastelTheme";
|
||||
@@ -119,19 +120,16 @@ export abstract class DefaultServerConfig implements ServerConfig {
|
||||
}
|
||||
otelEnabled(): boolean {
|
||||
return (
|
||||
this.env() !== GameEnv.Dev &&
|
||||
Boolean(this.otelEndpoint()) &&
|
||||
Boolean(this.otelUsername()) &&
|
||||
Boolean(this.otelPassword())
|
||||
Boolean(this.otelAuthHeader())
|
||||
);
|
||||
}
|
||||
otelEndpoint(): string {
|
||||
return process.env.OTEL_ENDPOINT ?? "";
|
||||
return process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "";
|
||||
}
|
||||
otelUsername(): string {
|
||||
return process.env.OTEL_USERNAME ?? "";
|
||||
}
|
||||
otelPassword(): string {
|
||||
return process.env.OTEL_PASSWORD ?? "";
|
||||
otelAuthHeader(): string {
|
||||
return process.env.OTEL_AUTH_HEADER ?? "";
|
||||
}
|
||||
gitCommit(): string {
|
||||
return process.env.GIT_COMMIT ?? "";
|
||||
@@ -236,7 +234,7 @@ export class DefaultConfig implements Config {
|
||||
return 0.5;
|
||||
}
|
||||
traitorSpeedDebuff(): number {
|
||||
return 0.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 {
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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() },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -675,6 +675,7 @@ export class GameImpl implements Game {
|
||||
type: MessageType,
|
||||
playerID: PlayerID | null,
|
||||
goldAmount?: bigint,
|
||||
params?: Record<string, string | number>,
|
||||
): void {
|
||||
let id: number | null = null;
|
||||
if (playerID !== null) {
|
||||
@@ -686,6 +687,7 @@ export class GameImpl implements Game {
|
||||
message: message,
|
||||
playerID: id,
|
||||
goldAmount: goldAmount,
|
||||
params: params,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -738,6 +740,9 @@ export class GameImpl implements Game {
|
||||
this._railNetwork.removeStation(u);
|
||||
}
|
||||
}
|
||||
updateUnitTile(u: Unit) {
|
||||
this.unitGrid.updateUnitCell(u);
|
||||
}
|
||||
|
||||
hasUnitNearby(
|
||||
tile: TileRef,
|
||||
|
||||
@@ -216,6 +216,7 @@ export interface DisplayMessageUpdate {
|
||||
messageType: MessageType;
|
||||
goldAmount?: bigint;
|
||||
playerID: number | null;
|
||||
params?: Record<string, string | number>;
|
||||
}
|
||||
|
||||
export type DisplayChatMessageUpdate = {
|
||||
|
||||
@@ -459,11 +459,12 @@ export class GameView implements GameMap {
|
||||
} else {
|
||||
unit = new UnitView(this, update);
|
||||
this._units.set(update.id, unit);
|
||||
}
|
||||
if (update.isActive) {
|
||||
this.unitGrid.addUnit(unit);
|
||||
} else {
|
||||
}
|
||||
if (!update.isActive) {
|
||||
this.unitGrid.removeUnit(unit);
|
||||
} else if (unit.tile() !== unit.lastTile()) {
|
||||
this.unitGrid.updateUnitCell(unit);
|
||||
}
|
||||
if (!unit.isActive()) {
|
||||
// Wait until next tick to delete the unit.
|
||||
|
||||
+13
-17
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,10 @@ export class UnitGrid {
|
||||
// Remove a unit from the grid
|
||||
removeUnit(unit: Unit | UnitView) {
|
||||
const tile = unit.tile();
|
||||
this.removeUnitByTile(unit, tile);
|
||||
}
|
||||
|
||||
removeUnitByTile(unit: Unit | UnitView, tile: TileRef) {
|
||||
const [gridX, gridY] = this.getGridCoords(this.gm.x(tile), this.gm.y(tile));
|
||||
|
||||
if (this.isValidCell(gridX, gridY)) {
|
||||
@@ -41,6 +45,26 @@ export class UnitGrid {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an unit to its new cell if it changed
|
||||
*/
|
||||
updateUnitCell(unit: Unit | UnitView) {
|
||||
const newTile = unit.tile();
|
||||
const oldTile = unit.lastTile();
|
||||
const [gridX, gridY] = this.getGridCoords(
|
||||
this.gm.x(oldTile),
|
||||
this.gm.y(oldTile),
|
||||
);
|
||||
const [newGridX, newGridY] = this.getGridCoords(
|
||||
this.gm.x(newTile),
|
||||
this.gm.y(newTile),
|
||||
);
|
||||
if (gridX !== newGridX || gridY !== newGridY) {
|
||||
this.removeUnitByTile(unit, oldTile);
|
||||
this.addUnit(unit);
|
||||
}
|
||||
}
|
||||
|
||||
private isValidCell(gridX: number, gridY: number): boolean {
|
||||
return (
|
||||
gridX >= 0 &&
|
||||
|
||||
@@ -147,10 +147,9 @@ export class UnitImpl implements Unit {
|
||||
if (tile === null) {
|
||||
throw new Error("tile cannot be null");
|
||||
}
|
||||
this.mg.removeUnit(this);
|
||||
this._lastTile = this._tile;
|
||||
this._tile = tile;
|
||||
this.mg.addUnit(this);
|
||||
this.mg.updateUnitTile(this);
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
}
|
||||
|
||||
|
||||
+20
-20
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user