Merge branch 'main' into meta3

This commit is contained in:
1brucben
2025-05-06 01:13:38 +02:00
103 changed files with 4721 additions and 1613 deletions
+48
View File
@@ -0,0 +1,48 @@
import { z } from "zod";
import { base64urlToUuid } from "./Base64";
export const RefreshResponseSchema = z.object({
token: z.string(),
});
export type RefreshResponse = z.infer<typeof RefreshResponseSchema>;
export const TokenPayloadSchema = z.object({
jti: z.string(),
sub: z
.string()
.refine(
(val) => {
const uuid = base64urlToUuid(val);
return uuid != null;
},
{
message: "Invalid base64-encoded UUID",
},
)
.transform((val) => {
const uuid = base64urlToUuid(val);
if (!uuid) throw new Error("Invalid base64 UUID");
return uuid;
}),
iat: z.number(),
iss: z.string(),
aud: z.string(),
exp: z.number(),
rol: z
.string()
.optional()
.transform((val) => val.split(",")),
});
export type TokenPayload = z.infer<typeof TokenPayloadSchema>;
export const UserMeResponseSchema = z.object({
user: z.object({
id: z.string(),
avatar: z.string(),
username: z.string(),
global_name: z.string(),
discriminator: z.string(),
locale: z.string(),
}),
});
export type UserMeResponse = z.infer<typeof UserMeResponseSchema>;
+37
View File
@@ -0,0 +1,37 @@
import { base64url } from "jose";
/**
* Converts a UUID string to a base64url-encoded binary representation.
* @param uuid - The UUID string (e.g., '123e4567-e89b-12d3-a456-426614174000')
* @returns base64url string (e.g., 'Ej5FZ+i7EtOkVkJmFBdAAA')
*/
export function uuidToBase64url(uuid: string): string {
const hex = uuid.replace(/-/g, "");
const bytes = new Uint8Array(16);
for (let i = 0; i < 16; i++) {
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return base64url.encode(bytes);
}
/**
* Converts a base64url-encoded binary UUID back to its canonical UUID string.
* @param encoded - base64url string (e.g., 'Ej5FZ+i7EtOkVkJmFBdAAA')
* @returns UUID string (e.g., '123e4567-e89b-12d3-a456-426614174000')
*/
export function base64urlToUuid(encoded: string): string {
const bytes = base64url.decode(encoded);
const hex = Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20),
].join("-");
}
+6 -5
View File
@@ -6,6 +6,7 @@ import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { consolex } from "../core/Consolex";
import {
Difficulty,
Duos,
GameMapType,
GameMode,
mapCategories,
@@ -28,7 +29,7 @@ export class HostLobbyModal extends LitElement {
@state() private selectedDifficulty: Difficulty = Difficulty.Medium;
@state() private disableNPCs = false;
@state() private gameMode: GameMode = GameMode.FFA;
@state() private teamCount: number = 2;
@state() private teamCount: number | typeof Duos = 2;
@state() private disableNukes: boolean = false;
@state() private bots: number = 400;
@state() private infiniteGold: boolean = false;
@@ -194,7 +195,7 @@ export class HostLobbyModal extends LitElement {
${translateText("host_modal.team_count")}
</div>
<div class="option-cards">
${[2, 3, 4, 5, 6, 7].map(
${[Duos, 2, 3, 4, 5, 6, 7].map(
(o) => html`
<div
class="option-card ${this.teamCount === o
@@ -465,8 +466,8 @@ export class HostLobbyModal extends LitElement {
this.putGameConfig();
}
private async handleTeamCountSelection(value: number) {
this.teamCount = value;
private async handleTeamCountSelection(value: number | typeof Duos) {
this.teamCount = value === Duos ? Duos : Number(value);
this.putGameConfig();
}
@@ -489,7 +490,7 @@ export class HostLobbyModal extends LitElement {
infiniteTroops: this.infiniteTroops,
instantBuild: this.instantBuild,
gameMode: this.gameMode,
numPlayerTeams: this.teamCount,
playerTeams: this.teamCount,
} as GameConfig),
},
);
+56 -63
View File
@@ -113,6 +113,17 @@ export class InputHandler {
) {}
initialize() {
const keybinds = {
toggleView: "Space",
centerCamera: "KeyC",
moveUp: "KeyW",
moveDown: "KeyS",
moveLeft: "KeyA",
moveRight: "KeyD",
zoomOut: "KeyQ",
zoomIn: "KeyE",
...JSON.parse(localStorage.getItem("settings.keybinds") ?? "{}"),
};
this.canvas.addEventListener("pointerdown", (e) => this.onPointerDown(e));
window.addEventListener("pointerup", (e) => this.onPointerUp(e));
this.canvas.addEventListener(
@@ -122,59 +133,65 @@ export class InputHandler {
this.onShiftScroll(e);
e.preventDefault();
},
{
passive: false,
},
{ passive: false },
);
window.addEventListener("pointermove", this.onPointerMove.bind(this));
this.canvas.addEventListener("contextmenu", (e: MouseEvent) => {
this.onContextMenu(e);
});
this.canvas.addEventListener("contextmenu", (e) => this.onContextMenu(e));
window.addEventListener("mousemove", (e) => {
if (e.movementX == 0 && e.movementY == 0) {
return;
if (e.movementX || e.movementY) {
this.eventBus.emit(new MouseMoveEvent(e.clientX, e.clientY));
}
this.eventBus.emit(new MouseMoveEvent(e.clientX, e.clientY));
});
this.pointers.clear();
// Initialize the combined movement interval
this.moveInterval = setInterval(() => {
let deltaX = 0;
let deltaY = 0;
// Handle both WASD and arrow keys
if (this.activeKeys.has("KeyW") || this.activeKeys.has("ArrowUp"))
if (
this.activeKeys.has(keybinds.moveUp) ||
this.activeKeys.has("ArrowUp")
)
deltaY += this.PAN_SPEED;
if (this.activeKeys.has("KeyS") || this.activeKeys.has("ArrowDown"))
if (
this.activeKeys.has(keybinds.moveDown) ||
this.activeKeys.has("ArrowDown")
)
deltaY -= this.PAN_SPEED;
if (this.activeKeys.has("KeyA") || this.activeKeys.has("ArrowLeft"))
if (
this.activeKeys.has(keybinds.moveLeft) ||
this.activeKeys.has("ArrowLeft")
)
deltaX += this.PAN_SPEED;
if (this.activeKeys.has("KeyD") || this.activeKeys.has("ArrowRight"))
if (
this.activeKeys.has(keybinds.moveRight) ||
this.activeKeys.has("ArrowRight")
)
deltaX -= this.PAN_SPEED;
if (deltaX !== 0 || deltaY !== 0) {
if (deltaX || deltaY) {
this.eventBus.emit(new DragEvent(deltaX, deltaY));
}
// Handle zooming
const screenCenterX = window.innerWidth / 2;
const screenCenterY = window.innerHeight / 2;
const cx = window.innerWidth / 2;
const cy = window.innerHeight / 2;
if (this.activeKeys.has("Minus") || this.activeKeys.has("KeyQ")) {
this.eventBus.emit(
new ZoomEvent(screenCenterX, screenCenterY, this.ZOOM_SPEED),
);
if (
this.activeKeys.has(keybinds.zoomOut) ||
this.activeKeys.has("Minus")
) {
this.eventBus.emit(new ZoomEvent(cx, cy, this.ZOOM_SPEED));
}
if (this.activeKeys.has("Equal") || this.activeKeys.has("KeyE")) {
this.eventBus.emit(
new ZoomEvent(screenCenterX, screenCenterY, -this.ZOOM_SPEED),
);
if (
this.activeKeys.has(keybinds.zoomIn) ||
this.activeKeys.has("Equal")
) {
this.eventBus.emit(new ZoomEvent(cx, cy, -this.ZOOM_SPEED));
}
}, 1);
window.addEventListener("keydown", (e) => {
if (e.code === "Space") {
if (e.code === keybinds.toggleView) {
e.preventDefault();
if (!this.alternateView) {
this.alternateView = true;
@@ -187,24 +204,23 @@ export class InputHandler {
this.eventBus.emit(new CloseViewEvent());
}
// Add all movement keys to activeKeys
if (
[
"KeyW",
"KeyA",
"KeyS",
"KeyD",
keybinds.moveUp,
keybinds.moveDown,
keybinds.moveLeft,
keybinds.moveRight,
keybinds.zoomOut,
keybinds.zoomIn,
"ArrowUp",
"ArrowLeft",
"ArrowDown",
"ArrowRight",
"Minus",
"Equal",
"KeyE",
"KeyQ",
"Digit1",
"Digit2",
"KeyC",
keybinds.centerCamera,
"ControlLeft",
"ControlRight",
].includes(e.code)
@@ -212,13 +228,13 @@ export class InputHandler {
this.activeKeys.add(e.code);
}
});
window.addEventListener("keyup", (e) => {
if (e.code === "Space") {
if (e.code === keybinds.toggleView) {
e.preventDefault();
this.alternateView = false;
this.eventBus.emit(new AlternateViewEvent(false));
}
if (e.key.toLowerCase() === "r" && e.altKey && !e.ctrlKey) {
e.preventDefault();
this.eventBus.emit(new RefreshGraphicsEvent());
@@ -234,35 +250,12 @@ export class InputHandler {
this.eventBus.emit(new AttackRatioEvent(10));
}
if (e.code === "KeyC") {
if (e.code === keybinds.centerCamera) {
e.preventDefault();
this.eventBus.emit(new CenterCameraEvent());
}
// Remove all movement keys from activeKeys
if (
[
"KeyW",
"KeyA",
"KeyS",
"KeyD",
"ArrowUp",
"ArrowLeft",
"ArrowDown",
"ArrowRight",
"Minus",
"Equal",
"KeyE",
"KeyQ",
"Digit1",
"Digit2",
"KeyC",
"ControlLeft",
"ControlRight",
].includes(e.code)
) {
this.activeKeys.delete(e.code);
}
this.activeKeys.delete(e.code);
});
}
+1
View File
@@ -173,6 +173,7 @@ export class LangSelector extends LitElement {
"help-modal",
"username-input",
"public-lobby",
"user-setting",
"o-modal",
"o-button",
];
+49
View File
@@ -29,7 +29,9 @@ import "./UsernameInput";
import { UsernameInput } from "./UsernameInput";
import { generateCryptoRandomUUID } from "./Utils";
import "./components/baseComponents/Button";
import { OButton } from "./components/baseComponents/Button";
import "./components/baseComponents/Modal";
import { discordLogin, getUserMe, isLoggedIn, logOut } from "./jwt";
import "./styles.css";
export interface JoinLobbyEvent {
@@ -90,6 +92,13 @@ class Client {
consolex.warn("Random name button element not found");
}
const loginDiscordButton = document.getElementById(
"login-discord",
) as OButton;
const logoutDiscordButton = document.getElementById(
"logout-discord",
) as OButton;
this.usernameInput = document.querySelector(
"username-input",
) as UsernameInput;
@@ -129,6 +138,41 @@ class Client {
hlpModal.open();
});
const claims = isLoggedIn();
if (claims === false) {
// Not logged in
loginDiscordButton.disable = false;
loginDiscordButton.translationKey = "main.login_discord";
loginDiscordButton.addEventListener("click", discordLogin);
logoutDiscordButton.hidden = true;
} else {
// JWT appears to be valid, assume we are logged in
loginDiscordButton.disable = true;
loginDiscordButton.translationKey = "main.logged_in";
logoutDiscordButton.hidden = false;
logoutDiscordButton.addEventListener("click", () => {
// Log out
logOut();
loginDiscordButton.disable = false;
loginDiscordButton.translationKey = "main.login_discord";
loginDiscordButton.addEventListener("click", discordLogin);
logoutDiscordButton.hidden = true;
});
// Look up the discord user object.
// TODO: Add caching
getUserMe().then((userMeResponse) => {
if (userMeResponse === false) {
// Not logged in
loginDiscordButton.disable = false;
loginDiscordButton.translationKey = "main.login_discord";
loginDiscordButton.addEventListener("click", discordLogin);
logoutDiscordButton.hidden = true;
return;
}
// TODO: Update the page for logged in user
});
}
const settingsModal = document.querySelector(
"user-setting",
) as UserSettingModal;
@@ -293,6 +337,11 @@ function setFavicon(): void {
// WARNING: DO NOT EXPOSE THIS ID
export function getPersistentIDFromCookie(): string {
const claims = isLoggedIn();
if (claims !== false && claims.sub) {
return claims.sub;
}
const COOKIE_NAME = "player_persistent_id";
// Try to get existing cookie
+6 -1
View File
@@ -94,6 +94,11 @@ export class PublicLobby extends LitElement {
const playersRemainingBeforeMax =
lobby.gameConfig.maxPlayers - lobby.numClients;
const teamCount =
lobby.gameConfig.gameMode === GameMode.Team
? lobby.gameConfig.playerTeams || 0
: null;
return html`
<button
@click=${() => this.lobbyClicked(lobby)}
@@ -127,7 +132,7 @@ export class PublicLobby extends LitElement {
</div>
<div class="text-md font-medium text-blue-100">
${lobby.gameConfig.gameMode == GameMode.Team
? translateText("game_mode.teams")
? translateText("public_lobby.teams", { num: teamCount })
: translateText("game_mode.ffa")}
</div>
</div>
+6 -5
View File
@@ -5,6 +5,7 @@ import { translateText } from "../client/Utils";
import { consolex } from "../core/Consolex";
import {
Difficulty,
Duos,
GameMapType,
GameMode,
GameType,
@@ -36,7 +37,7 @@ export class SinglePlayerModal extends LitElement {
@state() private instantBuild: boolean = false;
@state() private useRandomMap: boolean = false;
@state() private gameMode: GameMode = GameMode.FFA;
@state() private teamCount: number = 2;
@state() private teamCount: number | typeof Duos = 2;
render() {
return html`
@@ -165,7 +166,7 @@ export class SinglePlayerModal extends LitElement {
${translateText("host_modal.team_count")}
</div>
<div class="option-cards">
${[2, 3, 4, 5, 6, 7].map(
${["Duos", 2, 3, 4, 5, 6, 7].map(
(o) => html`
<div
class="option-card ${this.teamCount === o
@@ -355,8 +356,8 @@ export class SinglePlayerModal extends LitElement {
this.gameMode = value;
}
private handleTeamCountSelection(value: number) {
this.teamCount = value;
private handleTeamCountSelection(value: number | string) {
this.teamCount = value === "Duos" ? Duos : Number(value);
}
private getRandomMap(): GameMapType {
@@ -410,7 +411,7 @@ export class SinglePlayerModal extends LitElement {
gameMap: this.selectedMap,
gameType: GameType.Singleplayer,
gameMode: this.gameMode,
numPlayerTeams: this.teamCount,
playerTeams: this.teamCount,
difficulty: this.selectedDifficulty,
disableNPCs: this.disableNPCs,
disableNukes: this.disableNukes,
+1 -1
View File
@@ -88,7 +88,7 @@ export class SendTargetPlayerIntentEvent implements GameEvent {
export class SendEmojiIntentEvent implements GameEvent {
constructor(
public readonly recipient: PlayerView | typeof AllPlayers,
public readonly emoji: string,
public readonly emoji: number,
) {}
}
+253 -86
View File
@@ -1,6 +1,9 @@
import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { translateText } from "../client/Utils";
import { UserSettings } from "../core/game/UserSettings";
import "./components/baseComponents/setting/SettingKeybind";
import { SettingKeybind } from "./components/baseComponents/setting/SettingKeybind";
import "./components/baseComponents/setting/SettingNumber";
import "./components/baseComponents/setting/SettingSlider";
import "./components/baseComponents/setting/SettingToggle";
@@ -9,7 +12,8 @@ import "./components/baseComponents/setting/SettingToggle";
export class UserSettingModal extends LitElement {
private userSettings: UserSettings = new UserSettings();
@state() private darkMode: boolean = this.userSettings.darkMode();
@state() private settingsMode: "basic" | "keybinds" = "basic";
@state() private keybinds: Record<string, string> = {};
@state() private keySequence: string[] = [];
@state() private showEasterEggSettings = false;
@@ -17,6 +21,15 @@ export class UserSettingModal extends LitElement {
connectedCallback() {
super.connectedCallback();
window.addEventListener("keydown", this.handleKeyDown);
const savedKeybinds = localStorage.getItem("settings.keybinds");
if (savedKeybinds) {
try {
this.keybinds = JSON.parse(savedKeybinds);
} catch (e) {
console.warn("Invalid keybinds JSON:", e);
}
}
}
@query("o-modal") private modalEl!: HTMLElement & {
@@ -119,96 +132,63 @@ export class UserSettingModal extends LitElement {
}
}
private handleKeybindChange(
e: CustomEvent<{ action: string; value: string }>,
) {
const { action, value } = e.detail;
const prevValue = this.keybinds[action] ?? "";
const values = Object.entries(this.keybinds)
.filter(([k]) => k !== action)
.map(([, v]) => v);
if (values.includes(value) && value !== "Null") {
const popup = document.createElement("div");
popup.className = "setting-popup";
popup.textContent = `The key "${value}" is already assigned to another action.`;
document.body.appendChild(popup);
const element = this.renderRoot.querySelector(
`setting-keybind[action="${action}"]`,
) as SettingKeybind;
if (element) {
element.value = prevValue;
element.requestUpdate();
}
return;
}
this.keybinds = { ...this.keybinds, [action]: value };
localStorage.setItem("settings.keybinds", JSON.stringify(this.keybinds));
}
render() {
return html`
<o-modal title="User Settings">
<o-modal title="${translateText("user_setting.title")}">
<div class="modal-overlay">
<div class="modal-content user-setting-modal">
<div class="flex mb-4 w-full justify-center">
<button
class="w-1/2 text-center px-3 py-1 rounded-l
${this.settingsMode === "basic"
? "bg-white/10 text-white"
: "bg-transparent text-gray-400"}"
@click=${() => (this.settingsMode = "basic")}
>
${translateText("user_setting.tab_basic")}
</button>
<button
class="w-1/2 text-center px-3 py-1 rounded-r
${this.settingsMode === "keybinds"
? "bg-white/10 text-white"
: "bg-transparent text-gray-400"}"
@click=${() => (this.settingsMode = "keybinds")}
>
${translateText("user_setting.tab_keybinds")}
</button>
</div>
<div class="settings-list">
<setting-toggle
label="🌙 Dark Mode"
description="Toggle the sites appearance between light and dark themes"
id="dark-mode-toggle"
.checked=${this.userSettings.darkMode()}
@change=${(e: CustomEvent<{ checked: boolean }>) =>
this.toggleDarkMode(e)}
></setting-toggle>
<setting-toggle
label="😊 Emojis"
description="Toggle whether emojis are shown in game"
id="emoji-toggle"
.checked=${this.userSettings.emojis()}
@change=${this.toggleEmojis}
></setting-toggle>
<setting-toggle
label="🖱️ Left Click to Open Menu"
description="When ON, left-click opens menu and sword button attacks. When OFF, right-click attacks directly."
id="left-click-toggle"
.checked=${this.userSettings.leftClickOpensMenu()}
@change=${this.toggleLeftClickOpensMenu}
></setting-toggle>
<setting-slider
label="⚔️ Attack Ratio"
description="What percentage of your troops to send in an attack (1100%)"
min="1"
max="100"
.value=${Number(
localStorage.getItem("settings.attackRatio") ?? "0.2",
) * 100}
@change=${this.sliderAttackRatio}
></setting-slider>
<setting-slider
label="🪖🛠️ Troops and Workers Ratio"
description="Adjust the balance between troops (for combat) and workers (for gold production) (1100%)"
min="1"
max="100"
.value=${Number(
localStorage.getItem("settings.troopRatio") ?? "0.95",
) * 100}
@change=${this.sliderTroopRatio}
></setting-slider>
${this.showEasterEggSettings
? html`
<setting-slider
label="Writing Speed Multiplier"
description="Adjust how fast you pretend to code (x1x100)"
min="0"
max="100"
value="40"
easter="true"
@change=${(e: CustomEvent) => {
const value = e.detail?.value;
if (typeof value !== "undefined") {
console.log("Changed:", value);
} else {
console.warn("Slider event missing detail.value", e);
}
}}
></setting-slider>
<setting-number
label="Bug Count"
description="How many bugs you're okay with (01000, emotionally)"
value="100"
min="0"
max="1000"
easter="true"
@change=${(e: CustomEvent) => {
const value = e.detail?.value;
if (typeof value !== "undefined") {
console.log("Changed:", value);
} else {
console.warn("Slider event missing detail.value", e);
}
}}
></setting-number>
`
: null}
${this.settingsMode === "basic"
? this.renderBasicSettings()
: this.renderKeybindSettings()}
</div>
</div>
</div>
@@ -216,7 +196,194 @@ export class UserSettingModal extends LitElement {
`;
}
private renderBasicSettings() {
return html`
<!-- 🌙 Dark Mode -->
<setting-toggle
label="${translateText("user_setting.dark_mode_label")}"
description="${translateText("user_setting.dark_mode_desc")}"
id="dark-mode-toggle"
.checked=${this.userSettings.darkMode()}
@change=${(e: CustomEvent<{ checked: boolean }>) =>
this.toggleDarkMode(e)}
></setting-toggle>
<!-- 😊 Emojis -->
<setting-toggle
label="${translateText("user_setting.emojis_label")}"
description="${translateText("user_setting.emojis_desc")}"
id="emoji-toggle"
.checked=${this.userSettings.emojis()}
@change=${this.toggleEmojis}
></setting-toggle>
<!-- 🖱️ Left Click Menu -->
<setting-toggle
label="${translateText("user_setting.left_click_label")}"
description="${translateText("user_setting.left_click_desc")}"
id="left-click-toggle"
.checked=${this.userSettings.leftClickOpensMenu()}
@change=${this.toggleLeftClickOpensMenu}
></setting-toggle>
<!-- ⚔️ Attack Ratio -->
<setting-slider
label="${translateText("user_setting.attack_ratio_label")}"
description="${translateText("user_setting.attack_ratio_desc")}"
min="1"
max="100"
.value=${Number(localStorage.getItem("settings.attackRatio") ?? "0.2") *
100}
@change=${this.sliderAttackRatio}
></setting-slider>
<!-- 🪖🛠️ Troop Ratio -->
<setting-slider
label="${translateText("user_setting.troop_ratio_label")}"
description="${translateText("user_setting.troop_ratio_desc")}"
min="1"
max="100"
.value=${Number(localStorage.getItem("settings.troopRatio") ?? "0.95") *
100}
@change=${this.sliderTroopRatio}
></setting-slider>
${this.showEasterEggSettings
? html`
<setting-slider
label="${translateText(
"user_setting.easter_writing_speed_label",
)}"
description="${translateText(
"user_setting.easter_writing_speed_desc",
)}"
min="0"
max="100"
value="40"
easter="true"
@change=${(e: CustomEvent) => {
const value = e.detail?.value;
if (typeof value !== "undefined") {
console.log("Changed:", value);
} else {
console.warn("Slider event missing detail.value", e);
}
}}
></setting-slider>
<setting-number
label="${translateText("user_setting.easter_bug_count_label")}"
description="${translateText(
"user_setting.easter_bug_count_desc",
)}"
value="100"
min="0"
max="1000"
easter="true"
@change=${(e: CustomEvent) => {
const value = e.detail?.value;
if (typeof value !== "undefined") {
console.log("Changed:", value);
} else {
console.warn("Slider event missing detail.value", e);
}
}}
></setting-number>
`
: null}
`;
}
private renderKeybindSettings() {
return html`
<div class="text-center text-white text-base font-semibold mt-5 mb-2">
${translateText("user_setting.view_options")}
</div>
<setting-keybind
action="toggleView"
label=${translateText("user_setting.toggle_view")}
description=${translateText("user_setting.toggle_view_desc")}
defaultKey="Space"
.value=${this.keybinds["toggleView"] ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
<div class="text-center text-white text-base font-semibold mt-5 mb-2">
${translateText("user_setting.zoom_controls")}
</div>
<setting-keybind
action="zoomOut"
label=${translateText("user_setting.zoom_out")}
description=${translateText("user_setting.zoom_out_desc")}
defaultKey="KeyQ"
.value=${this.keybinds["zoomOut"] ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="zoomIn"
label=${translateText("user_setting.zoom_in")}
description=${translateText("user_setting.zoom_in_desc")}
defaultKey="KeyE"
.value=${this.keybinds["zoomIn"] ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
<div class="text-center text-white text-base font-semibold mt-5 mb-2">
${translateText("user_setting.camera_movement")}
</div>
<setting-keybind
action="centerCamera"
label=${translateText("user_setting.center_camera")}
description=${translateText("user_setting.center_camera_desc")}
defaultKey="KeyC"
.value=${this.keybinds["centerCamera"] ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="moveUp"
label=${translateText("user_setting.move_up")}
description=${translateText("user_setting.move_up_desc")}
defaultKey="KeyW"
.value=${this.keybinds["moveUp"] ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="moveLeft"
label=${translateText("user_setting.move_left")}
description=${translateText("user_setting.move_left_desc")}
defaultKey="KeyA"
.value=${this.keybinds["moveLeft"] ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="moveDown"
label=${translateText("user_setting.move_down")}
description=${translateText("user_setting.move_down_desc")}
defaultKey="KeyS"
.value=${this.keybinds["moveDown"] ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="moveRight"
label=${translateText("user_setting.move_right")}
description=${translateText("user_setting.move_right_desc")}
defaultKey="KeyD"
.value=${this.keybinds["moveRight"] ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
`;
}
public open() {
this.requestUpdate();
this.modalEl?.open();
}
+1
View File
@@ -25,6 +25,7 @@ export const MapDescription: Record<keyof typeof GameMapType, string> = {
BetweenTwoSeas: "Between Two Seas",
KnownWorld: "Known World",
FaroeIslands: "Faroe Islands",
DeglaciatedAntarctica: "Deglaciated Antarctica",
};
@customElement("map-display")
@@ -0,0 +1,115 @@
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { translateText } from "../../../../client/Utils";
@customElement("setting-keybind")
export class SettingKeybind extends LitElement {
@property() label = "Setting";
@property() description = "";
@property({ type: String, reflect: true }) action = "";
@property({ type: String }) defaultKey = "";
@property({ type: String }) value = "";
@property({ type: Boolean }) easter = false;
createRenderRoot() {
return this;
}
private listening = false;
render() {
return html`
<div class="setting-item column${this.easter ? " easter-egg" : ""}">
<div class="setting-label-group">
<label class="setting-label block mb-1">${this.label}</label>
<div class="setting-keybind-box">
<div class="setting-keybind-description">${this.description}</div>
<div class="flex items-center gap-2">
<span
class="setting-key"
tabindex="0"
@keydown=${this.handleKeydown}
@click=${this.startListening}
>
${this.displayKey(this.value || this.defaultKey)}
</span>
<button
class="text-xs text-gray-400 hover:text-white border border-gray-500 px-2 py-0.5 rounded transition"
@click=${this.resetToDefault}
>
${translateText("user_setting.reset")}
</button>
<button
class="text-xs text-gray-400 hover:text-white border border-gray-500 px-2 py-0.5 rounded transition"
@click=${this.unbindKey}
>
${translateText("user_setting.unbind")}
</button>
</div>
</div>
</div>
</div>
`;
}
private displayKey(key: string): string {
if (key === " ") return "Space";
if (key.startsWith("Key") && key.length === 4) {
return key.slice(3);
}
return key.length
? key.charAt(0).toUpperCase() + key.slice(1)
: "Press a key";
}
private startListening() {
this.listening = true;
this.requestUpdate();
}
private handleKeydown(e: KeyboardEvent) {
if (!this.listening) return;
e.preventDefault();
const code = e.code;
this.value = code;
this.dispatchEvent(
new CustomEvent("change", {
detail: { action: this.action, value: code },
bubbles: true,
composed: true,
}),
);
this.listening = false;
this.requestUpdate();
}
private resetToDefault() {
this.value = this.defaultKey;
this.dispatchEvent(
new CustomEvent("change", {
detail: { action: this.action, value: this.defaultKey },
bubbles: true,
composed: true,
}),
);
}
private unbindKey() {
this.value = "";
this.dispatchEvent(
new CustomEvent("change", {
detail: { action: this.action, value: "Null" },
bubbles: true,
composed: true,
}),
);
this.requestUpdate();
}
}
+71 -72
View File
@@ -13,6 +13,11 @@
"continent": "Asia",
"name": "Achaemenid Empire"
},
{
"code": "ae",
"continent": "Asia",
"name": "United Arab Emirates"
},
{
"code": "af",
"continent": "Asia",
@@ -23,16 +28,21 @@
"continent": "Africa",
"name": "African Union"
},
{
"code": "ag",
"continent": "North America",
"name": "Antigua and Barbuda"
},
{
"code": "ai",
"continent": "North America",
"name": "Anguilla"
},
{
"code": "1_Airgialla",
"continent": "Europe",
"name": "Airgialla"
},
{
"code": "ax",
"continent": "Europe",
"name": "Aland Islands"
},
{
"code": "Alabama",
"continent": "North America",
@@ -48,16 +58,26 @@
"continent": "Europe",
"name": "Albania"
},
{
"code": "Alabama",
"continent": "North America",
"name": "Alabama"
},
{
"code": "ax",
"continent": "Europe",
"name": "Aland Islands"
},
{
"code": "Alaska",
"continent": "North America",
"name": "Alaska"
},
{
"code": "dz",
"continent": "Africa",
"name": "Algeria"
},
{
"code": "Alkebulan",
"continent": "Africa",
"name": "Alkebulan"
},
{
"code": "Amazigh flag",
"continent": "Africa",
@@ -92,19 +112,19 @@
"continent": "Africa",
"name": "Angola"
},
{
"code": "ai",
"continent": "North America",
"name": "Anguilla"
},
{
"code": "aq",
"name": "Antarctica"
},
{
"code": "ag",
"continent": "North America",
"name": "Antigua and Barbuda"
"code": "antipope",
"continent": "Europe",
"name": "Anti-Pope"
},
{
"code": "aquitaine",
"continent": "Europe",
"name": "Aquitaine"
},
{
"code": "antipope",
@@ -196,15 +216,6 @@
"continent": "North America",
"name": "Aztec Empire"
},
{
"code": "Babylonia",
"continent": "Asia",
"name": "Babylonia"
},
{
"code": "baguette",
"name": "Baguette"
},
{
"code": "bs",
"continent": "North America",
@@ -220,6 +231,10 @@
"continent": "Asia",
"name": "Bahrain"
},
{
"code": "baguette",
"name": "Baguette"
},
{
"code": "bd",
"continent": "Asia",
@@ -230,6 +245,11 @@
"continent": "North America",
"name": "Barbados"
},
{
"code": "Babylonia",
"continent": "Asia",
"name": "Babylonia"
},
{
"code": "es-pv",
"continent": "Europe",
@@ -486,11 +506,6 @@
"continent": "Europe",
"name": "Connacht"
},
{
"code": "Connecticut",
"continent": "North America",
"name": "Connecticut"
},
{
"code": "ck",
"continent": "Oceania",
@@ -545,31 +560,26 @@
"continent": "Europe",
"name": "Czech Republic"
},
{
"code": "1_Dalriata",
"continent": "Europe",
"name": "Dál Riata"
},
{
"code": "Danzig",
"continent": "Europe",
"name": "Danzig"
},
{
"code": "Delaware",
"continent": "North America",
"name": "Delaware"
},
{
"code": "cd",
"continent": "Africa",
"name": "Democratic Republic of the Congo"
"code": "1_Dalriata",
"continent": "Europe",
"name": "Dál Riata"
},
{
"code": "dk",
"continent": "Europe",
"name": "Denmark"
},
{
"code": "cd",
"continent": "Africa",
"name": "Democratic Republic of the Congo"
},
{
"code": "dg",
"continent": "Asia",
@@ -580,11 +590,6 @@
"continent": "Asia",
"name": "Dilmun"
},
{
"code": "District_of_Columbia",
"continent": "North America",
"name": "District of Columbia"
},
{
"code": "dj",
"continent": "Africa",
@@ -768,6 +773,10 @@
"continent": "Oceania",
"name": "French Polynesia"
},
{
"code": "frost_giant",
"name": "Frost Giant"
},
{
"code": "tf",
"continent": "Africa",
@@ -1006,6 +1015,11 @@
"continent": "Europe",
"name": "Italy"
},
{
"code": "italy",
"continent": "Europe",
"name": "Kingdom of Italy"
},
{
"code": "jm",
"continent": "North America",
@@ -1071,11 +1085,6 @@
"continent": "Asia",
"name": "Kingdom of Iraq"
},
{
"code": "italy",
"continent": "Europe",
"name": "Kingdom of Italy"
},
{
"code": "Kingdom of Jerusalem",
"continent": "Asia",
@@ -1183,11 +1192,6 @@
"continent": "Europe",
"name": "Liechtenstein"
},
{
"code": "Lihyan",
"continent": "Asia",
"name": "Lihyan"
},
{
"code": "Listenbourg",
"name": "Listenbourg"
@@ -1277,11 +1281,6 @@
"continent": "North America",
"name": "Maryland"
},
{
"code": "Massachusetts",
"continent": "North America",
"name": "Massachusetts"
},
{
"code": "mr",
"continent": "Africa",
@@ -1640,15 +1639,15 @@
"continent": "Europe",
"name": "Poland"
},
{
"code": "polar_bears",
"name": "Polar Bears"
},
{
"code": "PolishLithuanian Commonwealth",
"continent": "Europe",
"name": "PolishLithuanian Commonwealth"
},
{
"code": "polar_bears",
"name": "Polar Bears"
},
{
"code": "pt",
"continent": "Europe",
@@ -2151,7 +2150,7 @@
"name": "USA 1776"
},
{
"code": "USSR",
"code": "ussr",
"continent": "Europe",
"name": "USSR"
},
@@ -2176,9 +2175,9 @@
"name": "Umayyad Caliphate"
},
{
"code": "ae",
"code": "United Arab Republic",
"continent": "Asia",
"name": "United Arab Emirates"
"name": "United Arab Republic"
},
{
"code": "United Arab Republic",
+7 -15
View File
@@ -4,24 +4,11 @@ import { EventBus } from "../../../core/EventBus";
import { AllPlayers } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { TerraNulliusImpl } from "../../../core/game/TerraNulliusImpl";
import { emojiTable, flattenedEmojiTable } from "../../../core/Util";
import { ShowEmojiMenuEvent } from "../../InputHandler";
import { SendEmojiIntentEvent } from "../../Transport";
import { TransformHandler } from "../TransformHandler";
const emojiTable: string[][] = [
["😀", "😊", "🥰", "😇", "😎"],
["😞", "🥺", "😭", "😱", "😡"],
["😈", "🤡", "🖕", "🥱", "🤦‍♂️"],
["👋", "👏", "🤌", "💪", "🫡"],
["👍", "👎", "❓", "🐔", "🐀"],
["🤝", "🆘", "🕊️", "🏳️", "⏳"],
["🔥", "💥", "💀", "☢️", "⚠️"],
["↖️", "⬆️", "↗️", "👑", "🥇"],
["⬅️", "🎯", "➡️", "🥈", "🥉"],
["↙️", "⬇️", "↘️", "❤️", "💔"],
["💰", "⚓", "⛵", "🏡", "🛡️"],
];
@customElement("emoji-table")
export class EmojiTable extends LitElement {
public eventBus: EventBus;
@@ -130,7 +117,12 @@ export class EmojiTable extends LitElement {
targetPlayer == this.game.myPlayer()
? AllPlayers
: (targetPlayer as PlayerView);
this.eventBus.emit(new SendEmojiIntentEvent(recipient, emoji));
this.eventBus.emit(
new SendEmojiIntentEvent(
recipient,
flattenedEmojiTable.indexOf(emoji),
),
);
this.hideTable();
});
});
+10 -1
View File
@@ -271,10 +271,19 @@ export class EventsDisplay extends LitElement implements Layer {
const malusPercent = Math.round(
(1 - this.game.config().traitorDefenseDebuff()) * 100,
);
const traitorDurationRaw =
Number(this.game.config().traitorDuration) / 10;
const traitorDurationSeconds = Math.floor(traitorDurationRaw);
const durationText =
traitorDurationSeconds === 1
? "1 second"
: `${traitorDurationSeconds} seconds`;
this.addEvent({
description:
`You broke your alliance with ${betrayed.name()}, making you a TRAITOR ` +
`(${malusPercent}% defense debuff)`,
`(${malusPercent}% defense debuff for ${durationText})`,
type: MessageType.ERROR,
highlight: true,
createdAt: this.game.ticks(),
@@ -207,6 +207,9 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
: ""}
${player.name()}
</div>
${player.team() != null
? html`<div class="text-sm opacity-80">Team: ${player.team()}</div>`
: ""}
<div class="text-sm opacity-80">Type: ${playerType}</div>
${player.troops() >= 1
? html`<div class="text-sm opacity-80" translate="no">
+10 -2
View File
@@ -15,6 +15,7 @@ import {
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { flattenedEmojiTable } from "../../../core/Util";
import { MouseUpEvent } from "../../InputHandler";
import {
SendAllianceRequestIntentEvent,
@@ -122,9 +123,16 @@ export class PlayerPanel extends LitElement implements Layer {
e.stopPropagation();
this.emojiTable.showTable((emoji: string) => {
if (myPlayer == other) {
this.eventBus.emit(new SendEmojiIntentEvent(AllPlayers, emoji));
this.eventBus.emit(
new SendEmojiIntentEvent(
AllPlayers,
flattenedEmojiTable.indexOf(emoji),
),
);
} else {
this.eventBus.emit(new SendEmojiIntentEvent(other, emoji));
this.eventBus.emit(
new SendEmojiIntentEvent(other, flattenedEmojiTable.indexOf(emoji)),
);
}
this.emojiTable.hideTable();
this.hide();
+9 -1
View File
@@ -119,6 +119,14 @@ export class TerritoryLayer implements Layer {
if (!centerTile) {
continue;
}
let color = this.theme.spawnHighlightColor();
if (
this.game.myPlayer() != null &&
this.game.myPlayer() != human &&
this.game.myPlayer().isFriendly(human)
) {
color = this.theme.selfColor();
}
for (const tile of this.game.bfs(
centerTile,
euclDistFN(centerTile, 9, true),
@@ -126,7 +134,7 @@ export class TerritoryLayer implements Layer {
if (!this.game.hasOwner(tile)) {
this.paintHighlightCell(
new Cell(this.game.x(tile), this.game.y(tile)),
this.theme.spawnHighlightColor(),
color,
255,
);
}
+40 -27
View File
@@ -1,38 +1,24 @@
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import mastersIcon from "../../../../resources/images/MastersIcon.png";
import { EventBus } from "../../../core/EventBus";
import { Team } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { PseudoRandom } from "../../../core/PseudoRandom";
import { simpleHash } from "../../../core/Util";
import { SendWinnerEvent } from "../../Transport";
import { Layer } from "./Layer";
// Add this at the top of your file
declare global {
interface Window {
adsbygoogle: unknown[];
}
}
// Add this at the top of your file
declare let adsbygoogle: unknown[];
@customElement("win-modal")
export class WinModal extends LitElement implements Layer {
public game: GameView;
public eventBus: EventBus;
private rand: PseudoRandom;
private hasShownDeathModal = false;
@state()
isVisible = false;
private _title: string;
private won: boolean;
// Override to prevent shadow DOM creation
createRenderRoot() {
@@ -53,7 +39,7 @@ export class WinModal extends LitElement implements Layer {
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
backdrop-filter: blur(5px);
color: white;
width: 300px;
width: 350px;
transition:
opacity 0.3s ease-in-out,
visibility 0.3s ease-in-out;
@@ -77,7 +63,7 @@ export class WinModal extends LitElement implements Layer {
.win-modal h2 {
margin: 0 0 15px 0;
font-size: 24px;
font-size: 26px;
text-align: center;
color: white;
}
@@ -127,7 +113,7 @@ export class WinModal extends LitElement implements Layer {
}
.win-modal h2 {
font-size: 20px;
font-size: 26px;
}
.win-modal button {
@@ -160,7 +146,41 @@ export class WinModal extends LitElement implements Layer {
innerHtml() {
return html`
<div style="text-align: center; margin: 15px 0; line-height: 1.5;"></div>
<div
style="
text-align: center;
margin: 10px 0;
line-height: 1.5;
background-image: url(${mastersIcon});
background-size: 100px;
background-position: center;
background-repeat: no-repeat;
background-blend-mode: overlay;
position: relative;
"
>
<div
style="
margin: 10px 0;
padding: 14px;
background: rgba(0, 0, 0, 0.76);
border-radius: 5px;
position: relative;
z-index: 1;
font-size: 22px;
"
>
Watch the best compete in the
<br />
<a
href="https://openfrontmaster.com/"
target="_blank"
rel="noopener noreferrer"
style="color: #00bfff; font-weight: bold; text-decoration: underline;"
>OpenFront Masters</a
>
</div>
</div>
`;
}
@@ -179,9 +199,7 @@ export class WinModal extends LitElement implements Layer {
window.location.href = "/";
}
init() {
this.rand = new PseudoRandom(simpleHash(this.game.myClientID()));
}
init() {}
tick() {
const myPlayer = this.game.myPlayer();
@@ -194,7 +212,6 @@ export class WinModal extends LitElement implements Layer {
) {
this.hasShownDeathModal = true;
this._title = "You died";
this.won = false;
this.show();
}
this.game.updatesSinceLastTick()[GameUpdateType.Win].forEach((wu) => {
@@ -204,10 +221,8 @@ export class WinModal extends LitElement implements Layer {
);
if (wu.winner == this.game.myPlayer()?.team()) {
this._title = "Your team won!";
this.won = true;
} else {
this._title = `${wu.winner} team has won!`;
this.won = false;
}
this.show();
} else {
@@ -219,10 +234,8 @@ export class WinModal extends LitElement implements Layer {
);
if (winner == this.game.myPlayer()) {
this._title = "You Won!";
this.won = true;
} else {
this._title = `${winner.name()} has won!`;
this.won = false;
}
this.show();
}
+22 -2
View File
@@ -212,6 +212,21 @@
<!-- Main container with responsive padding -->
<main class="flex justify-center flex-grow">
<div class="container pt-12">
<o-button
id="login-discord"
title="Initializing..."
disable="true"
block
></o-button>
<o-button
id="logout-discord"
title="Log out"
translationKey="main.log_out"
visible="false"
block
></o-button>
<div class="container__row">
<flag-input class="w-[20%] md:w-[15%]"></flag-input>
<username-input class="w-full"></username-input>
@@ -328,13 +343,18 @@
</a>
</div>
<div class="l-footer__col t-text-white">
© 2025
<a
href="https://github.com/openfrontio/OpenFrontIO"
class="t-link"
target="_blank"
>
OpenFront™
©2025 OpenFront™
</a>
<a href="/privacy-policy.html" class="t-link" target="_blank">
Privacy Policy
</a>
<a href="/terms-of-service.html" class="t-link" target="_blank">
Terms of Service
</a>
</div>
</div>
+210
View File
@@ -0,0 +1,210 @@
import { decodeJwt } from "jose";
import {
RefreshResponseSchema,
TokenPayload,
TokenPayloadSchema,
UserMeResponse,
UserMeResponseSchema,
} from "./ApiSchemas";
function getAudience() {
const { hostname } = new URL(window.location.href);
const domainname = hostname.split(".").slice(-2).join(".");
return domainname;
}
function getApiBase() {
const domainname = getAudience();
return domainname === "localhost"
? (localStorage.getItem("apiHost") ?? "http://localhost:8787")
: `https://api.${domainname}`;
}
function getToken(): string | null {
const { hash } = window.location;
if (hash.startsWith("#")) {
const params = new URLSearchParams(hash.slice(1));
const token = params.get("token");
if (token) {
localStorage.setItem("token", token);
}
// Clean the URL
history.replaceState(
null,
"",
window.location.pathname + window.location.search,
);
}
return localStorage.getItem("token");
}
export function discordLogin() {
window.location.href = `${getApiBase()}/login/discord?redirect_uri=${window.location.href}`;
}
export async function logOut(allSessions: boolean = false) {
const token = localStorage.getItem("token");
if (token === null) return;
localStorage.removeItem("token");
__isLoggedIn = false;
const response = await fetch(
getApiBase() + allSessions ? "/revoke" : "/logout",
{
method: "POST",
headers: {
authorization: `Bearer ${token}`,
},
},
);
if (response.ok === false) {
console.error("Logout failed", response);
return false;
}
return true;
}
let __isLoggedIn: TokenPayload | false | undefined = undefined;
export function isLoggedIn(): TokenPayload | false {
if (__isLoggedIn === undefined) {
__isLoggedIn = _isLoggedIn();
}
return __isLoggedIn;
}
export function _isLoggedIn(): TokenPayload | false {
try {
const token = getToken();
if (!token) {
// console.log("No token found");
return false;
}
// Verify the JWT (requires browser support)
// const jwks = createRemoteJWKSet(
// new URL(getApiBase() + "/.well-known/jwks.json"),
// );
// const { payload, protectedHeader } = await jwtVerify(token, jwks, {
// issuer: getApiBase(),
// audience: getAudience(),
// });
// Decode the JWT
const payload = decodeJwt(token);
const { iss, aud, exp, iat } = payload;
if (iss !== getApiBase()) {
// JWT was not issued by the correct server
console.error(
'unexpected "iss" claim value',
// JSON.stringify(payload, null, 2),
);
logOut();
return false;
}
if (aud !== getAudience()) {
// JWT was not issued for this website
console.error(
'unexpected "aud" claim value',
// JSON.stringify(payload, null, 2),
);
logOut();
return false;
}
const now = Math.floor(Date.now() / 1000);
if (exp !== undefined && now >= exp) {
// JWT expired
console.error(
'after "exp" claim value',
// JSON.stringify(payload, null, 2),
);
logOut();
return false;
}
const refreshAge: number = 6 * 3600; // 6 hours
if (iat !== undefined && now >= iat + refreshAge) {
console.log("Refreshing access token...");
postRefresh().then((success) => {
if (success) {
console.log("Refreshed access token successfully.");
} else {
console.error("Failed to refresh access token.");
}
});
}
const result = TokenPayloadSchema.safeParse(payload);
if (!result.success) {
// Invalid response
console.error(
"Invalid payload",
// JSON.stringify(payload),
JSON.stringify(result.error),
);
return false;
}
return result.data;
} catch (e) {
console.log(e);
return false;
}
}
export async function postRefresh(): Promise<boolean> {
try {
const token = getToken();
if (!token) return false;
// Refresh the JWT
const response = await fetch(getApiBase() + "/refresh", {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
},
});
if (response.status !== 200) return false;
const body = await response.json();
const result = RefreshResponseSchema.safeParse(body);
if (!result.success) {
console.error(
"Invalid response",
JSON.stringify(body),
JSON.stringify(result.error),
);
return false;
}
localStorage.setItem("token", result.data.token);
return true;
} catch (e) {
return false;
}
}
export async function getUserMe(): Promise<UserMeResponse | false> {
try {
const token = getToken();
if (!token) return false;
// Get the user object
const response = await fetch(getApiBase() + "/users/@me", {
headers: {
authorization: `Bearer ${token}`,
},
});
if (response.status !== 200) return false;
const body = await response.json();
const result = UserMeResponseSchema.safeParse(body);
if (!result.success) {
console.error(
"Invalid response",
JSON.stringify(body),
JSON.stringify(result.error),
);
return false;
}
return result.data;
} catch (e) {
return false;
}
}
+5
View File
@@ -194,6 +194,11 @@ label.option-card:hover {
margin: 8px 0px 0px 0px;
}
#lobbyIdInput {
font-family: monospace;
font-weight: 600;
}
.lobby-id-button {
display: flex;
align-items: center;
+11
View File
@@ -78,3 +78,14 @@
font-size: 14px;
color: #ccc;
}
.setting-input.keybind:hover .key,
.setting-input.keybind:focus .key {
background-color: #333;
box-shadow: 0 2px 0 #222;
}
.setting-input.keybind.listening .key {
background-color: #1d4ed8; /* blue-700 */
box-shadow: 0 2px 0 #0f172a; /* darker blue */
}
+78 -9
View File
@@ -22,6 +22,10 @@
gap: 12px;
}
.setting-item.column {
flex-direction: column;
}
@keyframes rainbow-background {
0% {
background-position: 0% 50%;
@@ -64,6 +68,20 @@
z-index: 9999;
}
.setting-popup {
position: fixed;
top: 40px;
left: 50%;
transform: translate(-50%, -50%) scale(0.9);
padding: 16px 24px;
background: rgba(0, 0, 0, 0.8);
color: #fff;
font-size: 20px;
border-radius: 12px;
animation: fadePop_2 10s ease-out forwards;
z-index: 9999;
}
@keyframes fadePop {
0% {
opacity: 0;
@@ -82,6 +100,25 @@
}
}
@keyframes fadePop_2 {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.6);
}
5% {
opacity: 1;
transform: translate(-50%, -50%) scale(1.05);
}
95% {
opacity: 1;
transform: translate(-50%, -50%) scale(1.05);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.9);
}
}
.setting-item:hover {
background: #2a2a2a;
}
@@ -158,17 +195,14 @@
cursor: pointer;
}
.setting-input.slider::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
border: 2px solid #2196f3;
cursor: pointer;
.setting-input.slider::-moz-range-track {
background-color: #444;
height: 10px;
border-radius: 5px;
}
.setting-input.slider::-moz-range-track {
background: linear-gradient(to right, #2196f3 50%, #444 50%);
.setting-input.slider::-moz-range-progress {
background-color: #2196f3;
height: 10px;
border-radius: 5px;
}
@@ -255,3 +289,38 @@
white-space: normal;
word-break: break-word;
}
.setting-keybind-box {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.setting-keybind-description {
flex: 1;
font-size: 0.75rem;
color: #e5e5e5;
word-break: break-word;
overflow-wrap: break-word;
min-width: 0;
}
.setting-key {
background-color: black;
color: white;
font-weight: 600;
padding: 4px 12px;
border-radius: 6px;
font-family: monospace;
font-size: 0.875rem;
box-shadow: 0 2px 0 #444;
white-space: nowrap;
user-select: none;
outline: none;
}
.setting-key:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
+3
View File
@@ -4,6 +4,7 @@ import australia from "../../../resources/maps/AustraliaThumb.webp";
import betweenTwoSeas from "../../../resources/maps/BetweenTwoSeasThumb.webp";
import blackSea from "../../../resources/maps/BlackSeaThumb.webp";
import britannia from "../../../resources/maps/BritanniaThumb.webp";
import deglaciatedAntarctica from "../../../resources/maps/DeglaciatedAntarcticaThumb.webp";
import europeClassic from "../../../resources/maps/EuropeClassicThumb.webp";
import europe from "../../../resources/maps/EuropeThumb.webp";
import faroeislands from "../../../resources/maps/FaroeIslandsThumb.webp";
@@ -63,6 +64,8 @@ export function getMapsImage(map: GameMapType): string {
return knownworld;
case GameMapType.FaroeIslands:
return faroeislands;
case GameMapType.DeglaciatedAntarctica:
return deglaciatedAntarctica;
default:
return "";
}
+41 -16
View File
@@ -4,9 +4,11 @@ import { Executor } from "./execution/ExecutionManager";
import { WinCheckExecution } from "./execution/WinCheckExecution";
import {
AllPlayers,
Cell,
Game,
GameUpdates,
NameViewData,
Nation,
Player,
PlayerActions,
PlayerBorderTiles,
@@ -23,8 +25,9 @@ import {
GameUpdateViewData,
} from "./game/GameUpdates";
import { loadTerrainMap as loadGameMap } from "./game/TerrainMapLoader";
import { PseudoRandom } from "./PseudoRandom";
import { ClientID, GameStartInfo, Turn } from "./Schemas";
import { sanitize } from "./Util";
import { sanitize, simpleHash } from "./Util";
import { fixProfaneUsername } from "./validations/username";
export async function createGameRunner(
@@ -34,26 +37,48 @@ export async function createGameRunner(
): Promise<GameRunner> {
const config = await getConfig(gameStart.config, null);
const gameMap = await loadGameMap(gameStart.config.gameMap);
const game = createGame(
gameStart.players.map(
(p) =>
new PlayerInfo(
p.flag,
p.clientID == clientID
? sanitize(p.username)
: fixProfaneUsername(sanitize(p.username)),
PlayerType.Human,
p.clientID,
p.playerID,
),
),
const random = new PseudoRandom(simpleHash(gameStart.gameID));
const humans = gameStart.players.map(
(p) =>
new PlayerInfo(
p.flag,
p.clientID == clientID
? sanitize(p.username)
: fixProfaneUsername(sanitize(p.username)),
PlayerType.Human,
p.clientID,
p.playerID,
),
);
const nations = gameStart.config.disableNPCs
? []
: gameMap.nationMap.nations.map(
(n) =>
new Nation(
new Cell(n.coordinates[0], n.coordinates[1]),
n.strength,
new PlayerInfo(
n.flag || "",
n.name,
PlayerType.FakeHuman,
null,
random.nextID(),
),
),
);
const game: Game = createGame(
humans,
nations,
gameMap.gameMap,
gameMap.miniGameMap,
gameMap.nationMap,
config,
);
const gr = new GameRunner(
game as Game,
game,
new Executor(game, gameStart.gameID, clientID),
callBack,
);
+1 -1
View File
@@ -48,7 +48,7 @@ export class PseudoRandom {
return this.nextInt(0, odds) == 0;
}
shuffleArray(array: any[]) {
shuffleArray(array: any[]): any[] {
for (let i = array.length - 1; i >= 0; i--) {
const j = Math.floor(this.nextInt(0, i + 1));
[array[i], array[j]] = [array[j], array[i]];
+11 -15
View File
@@ -2,13 +2,14 @@ import { z } from "zod";
import {
AllPlayers,
Difficulty,
Duos,
GameMapType,
GameMode,
GameType,
PlayerType,
Team,
UnitType,
} from "./game/Game";
import { flattenedEmojiTable } from "./Util";
export type GameID = string;
export type ClientID = string;
@@ -121,9 +122,11 @@ const GameConfigSchema = z.object({
infiniteTroops: z.boolean(),
instantBuild: z.boolean(),
maxPlayers: z.number().optional(),
numPlayerTeams: z.number().optional(),
playerTeams: z.union([z.number().optional(), z.literal(Duos)]),
});
export const TeamSchema = z.string();
const SafeString = z
.string()
.regex(
@@ -131,14 +134,10 @@ const SafeString = z
)
.max(1000);
const EmojiSchema = z.string().refine(
(val) => {
return /\p{Emoji}/u.test(val);
},
{
message: "Must contain at least one emoji character",
},
);
const EmojiSchema = z
.number()
.nonnegative()
.max(flattenedEmojiTable.length - 1);
const ID = z
.string()
.regex(/^[a-zA-Z0-9]+$/)
@@ -364,7 +363,7 @@ const ClientBaseMessageSchema = z.object({
export const ClientSendWinnerSchema = ClientBaseMessageSchema.extend({
type: z.literal("winner"),
winner: ID.or(z.nativeEnum(Team)).nullable(),
winner: z.union([ID, TeamSchema]).nullable(),
allPlayersStats: AllPlayersStatsSchema,
winnerType: z.enum(["player", "team"]),
});
@@ -425,10 +424,7 @@ export const GameRecordSchema = z.object({
date: SafeString,
num_turns: z.number(),
turns: z.array(TurnSchema),
winner: z
.union([ID, z.nativeEnum(Team)])
.nullable()
.optional(),
winner: z.union([ID, SafeString]).nullable().optional(),
winnerType: z.enum(["player", "team"]).nullable().optional(),
allPlayersStats: z.record(ID, PlayerStatsSchema),
version: z.enum(["v0.0.1"]),
+16
View File
@@ -307,3 +307,19 @@ export function createRandomName(
}
return randomName;
}
export const emojiTable: string[][] = [
["😀", "😊", "🥰", "😇", "😎"],
["😞", "🥺", "😭", "😱", "😡"],
["😈", "🤡", "🖕", "🥱", "🤦‍♂️"],
["👋", "👏", "🤌", "💪", "🫡"],
["👍", "👎", "❓", "🐔", "🐀"],
["🤝", "🆘", "🕊️", "🏳️", "⏳"],
["🔥", "💥", "💀", "☢️", "⚠️"],
["↖️", "⬆️", "↗️", "👑", "🥇"],
["⬅️", "🎯", "➡️", "🥈", "🥉"],
["↙️", "⬇️", "↘️", "❤️", "💔"],
["💰", "⚓", "⛵", "🏡", "🛡️"],
];
// 2d to 1d array
export const flattenedEmojiTable: string[] = [].concat(...emojiTable);
+8 -2
View File
@@ -2,8 +2,10 @@ import { Colord } from "colord";
import { GameConfig, GameID } from "../Schemas";
import {
Difficulty,
Duos,
Game,
GameMapType,
GameMode,
Gold,
Player,
PlayerInfo,
@@ -26,7 +28,7 @@ export enum GameEnv {
export interface ServerConfig {
turnIntervalMs(): number;
gameCreationRate(): number;
lobbyMaxPlayers(map: GameMapType): number;
lobbyMaxPlayers(map: GameMapType, mode: GameMode): number;
discordRedirectURI(): string;
numWorkers(): number;
workerIndex(gameID: GameID): number;
@@ -43,6 +45,10 @@ export interface ServerConfig {
r2Endpoint(): string;
r2AccessKey(): string;
r2SecretKey(): string;
otelEndpoint(): string;
otelUsername(): string;
otelPassword(): string;
otelEnabled(): boolean;
}
export interface NukeMagnitude {
@@ -67,7 +73,7 @@ export interface Config {
instantBuild(): boolean;
numSpawnPhaseTurns(): number;
userSettings(): UserSettings;
numPlayerTeams(): number;
playerTeams(): number | typeof Duos;
startManpower(playerInfo: PlayerInfo): number;
populationIncreaseRate(player: Player | PlayerView): number;
+86 -59
View File
@@ -1,5 +1,6 @@
import {
Difficulty,
Duos,
Game,
GameMapType,
GameMode,
@@ -24,6 +25,20 @@ import { pastelTheme } from "./PastelTheme";
import { pastelThemeDark } from "./PastelThemeDark";
export abstract class DefaultServerConfig implements ServerConfig {
otelEnabled(): boolean {
return Boolean(
this.otelEndpoint() && this.otelUsername() && this.otelPassword(),
);
}
otelEndpoint(): string {
return process.env.OTEL_ENDPOINT;
}
otelUsername(): string {
return process.env.OTEL_USERNAME;
}
otelPassword(): string {
return process.env.OTEL_PASSWORD;
}
region(): string {
if (this.env() == GameEnv.Dev) {
return "dev";
@@ -42,7 +57,11 @@ export abstract class DefaultServerConfig implements ServerConfig {
r2SecretKey(): string {
return process.env.R2_SECRET_KEY;
}
abstract r2Bucket(): string;
r2Bucket(): string {
return process.env.R2_BUCKET;
}
adminHeader(): string {
return "x-admin-key";
}
@@ -58,60 +77,66 @@ export abstract class DefaultServerConfig implements ServerConfig {
gameCreationRate(): number {
return 60 * 1000;
}
lobbyMaxPlayers(map: GameMapType): number {
// Maps with ~4 mil pixels
if (
[
GameMapType.GatewayToTheAtlantic,
GameMapType.SouthAmerica,
GameMapType.NorthAmerica,
GameMapType.Africa,
GameMapType.Europe,
].includes(map)
) {
return Math.random() < 0.2 ? 100 : 50;
}
// Maps with ~2.5 - ~3.5 mil pixels
if (
[
GameMapType.Australia,
GameMapType.Iceland,
GameMapType.Britannia,
GameMapType.Asia,
].includes(map)
) {
return Math.random() < 0.3 ? 50 : 25;
}
// Maps with ~2 mil pixels
if (
[
GameMapType.Mena,
GameMapType.Mars,
GameMapType.Oceania,
GameMapType.Japan, // Japan at this level because its 2/3 water
GameMapType.FaroeIslands,
GameMapType.EuropeClassic,
].includes(map)
) {
return Math.random() < 0.3 ? 50 : 25;
}
// Maps smaller than ~2 mil pixels
if (
[
GameMapType.BetweenTwoSeas,
GameMapType.BlackSea,
GameMapType.Pangaea,
].includes(map)
) {
return Math.random() < 0.5 ? 30 : 15;
}
// world belongs with the ~2 mils, but these amounts never made sense so I assume the insanity is intended.
if (map == GameMapType.World) {
return Math.random() < 0.2 ? 150 : 50;
}
// default return for non specified map
return Math.random() < 0.2 ? 50 : 20;
lobbyMaxPlayers(map: GameMapType, mode: GameMode): number {
const numPlayers = () => {
// Maps with ~4 mil pixels
if (
[
GameMapType.GatewayToTheAtlantic,
GameMapType.SouthAmerica,
GameMapType.NorthAmerica,
GameMapType.Africa,
GameMapType.Europe,
].includes(map)
) {
return Math.random() < 0.2 ? 100 : 50;
}
// Maps with ~2.5 - ~3.5 mil pixels
if (
[
GameMapType.Australia,
GameMapType.Iceland,
GameMapType.Britannia,
GameMapType.Asia,
].includes(map)
) {
return Math.random() < 0.3 ? 50 : 25;
}
// Maps with ~2 mil pixels
if (
[
GameMapType.Mena,
GameMapType.Mars,
GameMapType.Oceania,
GameMapType.Japan, // Japan at this level because its 2/3 water
GameMapType.FaroeIslands,
GameMapType.DeglaciatedAntarctica,
GameMapType.EuropeClassic,
].includes(map)
) {
return Math.random() < 0.3 ? 50 : 25;
}
// Maps smaller than ~2 mil pixels
if (
[
GameMapType.BetweenTwoSeas,
GameMapType.BlackSea,
GameMapType.Pangaea,
].includes(map)
) {
return Math.random() < 0.5 ? 30 : 15;
}
// world belongs with the ~2 mils, but these amounts never made sense so I assume the insanity is intended.
if (map == GameMapType.World) {
return Math.random() < 0.2 ? 150 : 50;
}
// default return for non specified map
return Math.random() < 0.2 ? 50 : 20;
};
return Math.min(150, numPlayers() * (mode == GameMode.Team ? 2 : 1));
}
workerIndex(gameID: GameID): number {
return simpleHash(gameID) % this.numWorkers();
}
@@ -133,10 +158,6 @@ export class DefaultConfig implements Config {
private _userSettings: UserSettings,
) {}
numPlayerTeams(): number {
return this.gameConfig().numPlayerTeams;
}
samHittingChance(): number {
return 0.8;
}
@@ -202,9 +223,14 @@ export class DefaultConfig implements Config {
defensePostDefenseBonus(): number {
return 5;
}
playerTeams(): number | typeof Duos {
return this._gameConfig.playerTeams ?? 0;
}
spawnNPCs(): boolean {
return !this._gameConfig.disableNPCs;
}
disableNukes(): boolean {
return this._gameConfig.disableNukes;
}
@@ -688,7 +714,8 @@ export class DefaultConfig implements Config {
}
structureMinDist(): number {
return 12;
// TODO: Increase this to ~15 once upgradable structures are implemented.
return 1;
}
shellLifetime(): number {
-3
View File
@@ -5,9 +5,6 @@ import { GameEnv, ServerConfig } from "./Config";
import { DefaultConfig, DefaultServerConfig } from "./DefaultConfig";
export class DevServerConfig extends DefaultServerConfig {
r2Bucket(): string {
return "openfront-staging";
}
adminToken(): string {
return "WARNING_DEV_ADMIN_KEY_DO_NOT_USE_IN_PRODUCTION";
}
+11 -10
View File
@@ -1,7 +1,7 @@
import { Colord, colord } from "colord";
import { PseudoRandom } from "../PseudoRandom";
import { simpleHash } from "../Util";
import { PlayerType, Team, TerrainType } from "../game/Game";
import { ColoredTeams, PlayerType, Team, TerrainType } from "../game/Game";
import { GameMap, TileRef } from "../game/GameMap";
import { PlayerView } from "../game/GameView";
import {
@@ -43,24 +43,25 @@ export const pastelTheme = new (class implements Theme {
teamColor(team: Team): Colord {
switch (team) {
case Team.Blue:
case ColoredTeams.Blue:
return blue;
case Team.Red:
case ColoredTeams.Red:
return red;
case Team.Teal:
case ColoredTeams.Teal:
return teal;
case Team.Purple:
case ColoredTeams.Purple:
return purple;
case Team.Yellow:
case ColoredTeams.Yellow:
return yellow;
case Team.Orange:
case ColoredTeams.Orange:
return orange;
case Team.Green:
case ColoredTeams.Green:
return green;
case Team.Bot:
case ColoredTeams.Bot:
return botColor;
default:
return humanColors[simpleHash(team) % humanColors.length];
}
throw new Error(`Missing color for ${team}`);
}
territoryColor(player: PlayerView): Colord {
+11 -10
View File
@@ -1,7 +1,7 @@
import { Colord, colord } from "colord";
import { PseudoRandom } from "../PseudoRandom";
import { simpleHash } from "../Util";
import { PlayerType, Team, TerrainType } from "../game/Game";
import { ColoredTeams, PlayerType, Team, TerrainType } from "../game/Game";
import { GameMap, TileRef } from "../game/GameMap";
import { PlayerView } from "../game/GameView";
import {
@@ -43,24 +43,25 @@ export const pastelThemeDark = new (class implements Theme {
teamColor(team: Team): Colord {
switch (team) {
case Team.Blue:
case ColoredTeams.Blue:
return blue;
case Team.Red:
case ColoredTeams.Red:
return red;
case Team.Teal:
case ColoredTeams.Teal:
return teal;
case Team.Purple:
case ColoredTeams.Purple:
return purple;
case Team.Yellow:
case ColoredTeams.Yellow:
return yellow;
case Team.Orange:
case ColoredTeams.Orange:
return orange;
case Team.Green:
case ColoredTeams.Green:
return green;
case Team.Bot:
case ColoredTeams.Bot:
return botColor;
default:
return humanColors[simpleHash(team) % humanColors.length];
}
throw new Error(`Missing color for ${team}`);
}
territoryColor(player: PlayerView): Colord {
-3
View File
@@ -2,9 +2,6 @@ import { GameEnv } from "./Config";
import { DefaultServerConfig } from "./DefaultConfig";
export const preprodConfig = new (class extends DefaultServerConfig {
r2Bucket(): string {
return "openfront-staging";
}
env(): GameEnv {
return GameEnv.Preprod;
}
-3
View File
@@ -2,9 +2,6 @@ import { GameEnv } from "./Config";
import { DefaultServerConfig } from "./DefaultConfig";
export const prodConfig = new (class extends DefaultServerConfig {
r2Bucket(): string {
return "openfront-prod";
}
numWorkers(): number {
return 6;
}
+6 -3
View File
@@ -7,6 +7,7 @@ import {
PlayerID,
PlayerType,
} from "../game/Game";
import { flattenedEmojiTable } from "../Util";
export class EmojiExecution implements Execution {
private requestor: Player;
@@ -17,7 +18,7 @@ export class EmojiExecution implements Execution {
constructor(
private senderID: PlayerID,
private recipientID: PlayerID | typeof AllPlayers,
private emoji: string,
private emoji: number,
) {}
init(mg: Game, ticks: number): void {
@@ -38,10 +39,12 @@ export class EmojiExecution implements Execution {
}
tick(ticks: number): void {
const emojiString = flattenedEmojiTable.at(this.emoji);
if (this.requestor.canSendEmoji(this.recipient)) {
this.requestor.sendEmoji(this.recipient, this.emoji);
this.requestor.sendEmoji(this.recipient, emojiString);
if (
this.emoji == "🖕" &&
emojiString == "🖕" &&
this.recipient != AllPlayers &&
this.recipient.type() == PlayerType.FakeHuman
) {
+2 -14
View File
@@ -1,4 +1,4 @@
import { Execution, Game, PlayerInfo, PlayerType } from "../game/Game";
import { Execution, Game } from "../game/Game";
import { PseudoRandom } from "../PseudoRandom";
import { ClientID, GameID, Intent, Turn } from "../Schemas";
import { simpleHash } from "../Util";
@@ -120,19 +120,7 @@ export class Executor {
fakeHumanExecutions(): Execution[] {
const execs = [];
for (const nation of this.mg.nations()) {
execs.push(
new FakeHumanExecution(
this.gameID,
new PlayerInfo(
nation.flag || "",
nation.name,
PlayerType.FakeHuman,
null,
this.random.nextID(),
nation,
),
),
);
execs.push(new FakeHumanExecution(this.gameID, nation));
}
return execs;
}
+14 -10
View File
@@ -4,9 +4,9 @@ import {
Difficulty,
Execution,
Game,
Nation,
Player,
PlayerID,
PlayerInfo,
PlayerType,
Relation,
TerrainType,
@@ -43,6 +43,8 @@ export class FakeHumanExecution implements Execution {
private lastEmojiSent = new Map<Player, Tick>();
private lastNukeSent: [Tick, TileRef][] = [];
private embargoMalusApplied = new Set<PlayerID>();
private heckleEmoji: number[];
private dogpileEmoji: number;
private portTargetRatio: number = 0.00005; // desired ports per tile
private cityTargetRatio: number = 0.0001; // desired cities per tile
@@ -57,10 +59,10 @@ export class FakeHumanExecution implements Execution {
constructor(
gameID: GameID,
private playerInfo: PlayerInfo,
private nation: Nation,
) {
this.random = new PseudoRandom(
simpleHash(playerInfo.id) + simpleHash(gameID),
simpleHash(nation.playerInfo.id) + simpleHash(gameID),
);
this.attackRate = this.random.nextInt(40, 80);
this.attackTick = this.random.nextInt(0, this.attackRate);
@@ -124,15 +126,17 @@ export class FakeHumanExecution implements Execution {
if (this.mg.inSpawnPhase()) {
const rl = this.randomLand();
if (rl == null) {
consolex.warn(`cannot spawn ${this.playerInfo.name}`);
consolex.warn(`cannot spawn ${this.nation.playerInfo.name}`);
return;
}
this.mg.addExecution(new SpawnExecution(this.playerInfo, rl));
this.mg.addExecution(new SpawnExecution(this.nation.playerInfo, rl));
return;
}
if (this.player == null) {
this.player = this.mg.players().find((p) => p.id() == this.playerInfo.id);
this.player = this.mg
.players()
.find((p) => p.id() == this.nation.playerInfo.id);
if (this.player == null) {
return;
}
@@ -303,7 +307,7 @@ export class FakeHumanExecution implements Execution {
if (!this.lastEmojiSent.has(enemy)) {
this.lastEmojiSent.set(enemy, this.mg.ticks());
this.mg.addExecution(
new EmojiExecution(this.player.id(), enemy.id(), "🖕"),
new EmojiExecution(this.player.id(), enemy.id(), this.dogpileEmoji),
);
}
return;
@@ -317,7 +321,7 @@ export class FakeHumanExecution implements Execution {
new EmojiExecution(
this.player.id(),
enemy.id(),
this.random.randElement(["🤡", "😡"]),
this.random.randElement(this.heckleEmoji),
),
);
}
@@ -632,7 +636,7 @@ export class FakeHumanExecution implements Execution {
if (!builtDefensePost) {
consolex.log(
`[${this.playerInfo.name}] no valid tile found for Defense Post`,
`[${this.nation.playerInfo.name}] no valid tile found for Defense Post`,
);
}
}
@@ -825,7 +829,7 @@ export class FakeHumanExecution implements Execution {
let tries = 0;
while (tries < 50) {
tries++;
const cell = this.playerInfo.nation.cell;
const cell = this.nation.spawnCell;
const x = this.random.nextInt(cell.x - delta, cell.x + delta);
const y = this.random.nextInt(cell.y - delta, cell.y + delta);
if (!this.mg.isValidCoord(x, y)) {
+9 -2
View File
@@ -1,5 +1,12 @@
import { GameEvent } from "../EventBus";
import { Execution, Game, GameMode, Player, Team } from "../game/Game";
import {
ColoredTeams,
Execution,
Game,
GameMode,
Player,
Team,
} from "../game/Game";
export class WinEvent implements GameEvent {
constructor(public readonly winner: Player) {}
@@ -66,7 +73,7 @@ export class WinCheckExecution implements Execution {
this.mg.numLandTiles() - this.mg.numTilesWithFallout();
const percentage = (max[1] / numTilesWithoutFallout) * 100;
if (percentage > this.mg.config().percentageTilesOwnedToWin()) {
if (max[0] == Team.Bot) return;
if (max[0] == ColoredTeams.Bot) return;
this.mg.setWinner(max[0], this.mg.stats().stats());
console.log(`${max[0]} has won the game`);
this.active = false;
+13 -10
View File
@@ -8,7 +8,7 @@ import {
Tick,
} from "../../game/Game";
import { PseudoRandom } from "../../PseudoRandom";
import { within } from "../../Util";
import { flattenedEmojiTable, within } from "../../Util";
import { AttackExecution } from "../AttackExecution";
import { EmojiExecution } from "../EmojiExecution";
@@ -16,13 +16,17 @@ export class BotBehavior {
private enemy: Player | null = null;
private enemyUpdated: Tick;
private assistAcceptEmoji: number;
constructor(
private random: PseudoRandom,
private game: Game,
private player: Player,
private triggerRatio: number,
private reserveRatio: number,
) {}
) {
this.assistAcceptEmoji = flattenedEmojiTable.indexOf("👍");
}
handleAllianceRequests() {
for (const req of this.player.incomingAllianceRequests()) {
@@ -34,7 +38,7 @@ export class BotBehavior {
}
}
private emoji(player: Player, emoji: string) {
private emoji(player: Player, emoji: number) {
if (player.type() !== PlayerType.Human) return;
this.game.addExecution(
new EmojiExecution(this.player.id(), player.id(), emoji),
@@ -79,7 +83,7 @@ export class BotBehavior {
this.player.updateRelation(ally, -20);
this.enemy = target;
this.enemyUpdated = this.game.ticks();
this.emoji(ally, "👍");
this.emoji(ally, this.assistAcceptEmoji);
break outer;
}
}
@@ -129,12 +133,11 @@ export class BotBehavior {
// Choose a new enemy randomly
const neighbors = this.player.neighbors();
for (const neighbor of this.random.shuffleArray(neighbors)) {
if (neighbor.isPlayer()) {
if (this.player.isFriendly(neighbor)) continue;
if (neighbor.type() == PlayerType.FakeHuman) {
if (this.random.chance(2)) {
continue;
}
if (!neighbor.isPlayer()) continue;
if (this.player.isFriendly(neighbor)) continue;
if (neighbor.type() == PlayerType.FakeHuman) {
if (this.random.chance(2)) {
continue;
}
}
this.enemy = neighbor;
+23 -14
View File
@@ -37,16 +37,20 @@ export enum Difficulty {
Impossible = "Impossible",
}
export enum Team {
Red = "Red",
Blue = "Blue",
Teal = "Teal",
Purple = "Purple",
Yellow = "Yellow",
Orange = "Orange",
Green = "Green",
Bot = "Bot",
}
export type Team = string;
export const Duos = "Duos" as const;
export const ColoredTeams: Record<string, Team> = {
Red: "Red",
Blue: "Blue",
Teal: "Teal",
Purple: "Purple",
Yellow: "Yellow",
Orange: "Orange",
Green: "Green",
Bot: "Bot",
} as const;
export enum GameMapType {
World = "World",
@@ -69,6 +73,7 @@ export enum GameMapType {
BetweenTwoSeas = "Between Two Seas",
KnownWorld = "Known World",
FaroeIslands = "FaroeIslands",
DeglaciatedAntarctica = "Deglaciated Antarctica",
}
export const mapCategories: Record<string, GameMapType[]> = {
@@ -93,7 +98,12 @@ export const mapCategories: Record<string, GameMapType[]> = {
GameMapType.Australia,
GameMapType.FaroeIslands,
],
fantasy: [GameMapType.Pangaea, GameMapType.Mars, GameMapType.KnownWorld],
fantasy: [
GameMapType.Pangaea,
GameMapType.Mars,
GameMapType.KnownWorld,
GameMapType.DeglaciatedAntarctica,
],
};
export enum GameType {
@@ -152,10 +162,9 @@ export enum Relation {
export class Nation {
constructor(
public readonly flag: string,
public readonly name: string,
public readonly cell: Cell,
public readonly spawnCell: Cell,
public readonly strength: number,
public readonly playerInfo: PlayerInfo,
) {}
}
+37 -27
View File
@@ -8,6 +8,8 @@ import {
Alliance,
AllianceRequest,
Cell,
ColoredTeams,
Duos,
EmojiMessage,
Execution,
Game,
@@ -32,19 +34,18 @@ import { PlayerImpl } from "./PlayerImpl";
import { Stats } from "./Stats";
import { StatsImpl } from "./StatsImpl";
import { assignTeams } from "./TeamAssignment";
import { NationMap } from "./TerrainMapLoader";
import { TerraNulliusImpl } from "./TerraNulliusImpl";
import { UnitGrid } from "./UnitGrid";
import { UnitImpl } from "./UnitImpl";
export function createGame(
humans: PlayerInfo[],
nations: Nation[],
gameMap: GameMap,
miniGameMap: GameMap,
nationMap: NationMap,
config: Config,
): Game {
return new GameImpl(humans, gameMap, miniGameMap, nationMap, config);
return new GameImpl(humans, nations, gameMap, miniGameMap, config);
}
export type CellString = string;
@@ -54,8 +55,6 @@ export class GameImpl implements Game {
private unInitExecs: Execution[] = [];
private nations_: Nation[] = [];
_players: Map<PlayerID, PlayerImpl> = new Map<PlayerID, PlayerImpl>();
_playersBySmallID = [];
@@ -75,51 +74,62 @@ export class GameImpl implements Game {
private _stats: StatsImpl = new StatsImpl();
private playerTeams: Team[] = [Team.Red, Team.Blue];
private botTeam: Team = Team.Bot;
private playerTeams: Team[] = [ColoredTeams.Red, ColoredTeams.Blue];
private botTeam: Team = ColoredTeams.Bot;
constructor(
private _humans: PlayerInfo[],
private _nations: Nation[],
private _map: GameMap,
private miniGameMap: GameMap,
nationMap: NationMap,
private _config: Config,
) {
this.addHumans();
this._terraNullius = new TerraNulliusImpl();
this._width = _map.width();
this._height = _map.height();
this.nations_ = nationMap.nations.map(
(n) =>
new Nation(
n.flag || "",
n.name,
new Cell(n.coordinates[0], n.coordinates[1]),
n.strength,
),
);
this.unitGrid = new UnitGrid(this._map);
if (_config.gameConfig().gameMode === GameMode.Team) {
const numPlayerTeams = _config.numPlayerTeams();
this.populateTeams();
}
this.addPlayers();
}
private populateTeams() {
if (this._config.playerTeams() === Duos) {
this.playerTeams = [];
const numTeams = Math.ceil(
(this._humans.length + this._nations.length) / 2,
);
for (let i = 0; i < numTeams; i++) {
this.playerTeams.push("Team " + (i + 1));
}
} else {
const numPlayerTeams = this._config.playerTeams() as number;
if (numPlayerTeams < 2)
throw new Error(`Too few teams: ${numPlayerTeams}`);
if (numPlayerTeams >= 3) this.playerTeams.push(Team.Teal);
if (numPlayerTeams >= 4) this.playerTeams.push(Team.Purple);
if (numPlayerTeams >= 5) this.playerTeams.push(Team.Yellow);
if (numPlayerTeams >= 6) this.playerTeams.push(Team.Orange);
if (numPlayerTeams >= 7) this.playerTeams.push(Team.Green);
if (numPlayerTeams >= 3) this.playerTeams.push(ColoredTeams.Yellow);
if (numPlayerTeams >= 4) this.playerTeams.push(ColoredTeams.Green);
if (numPlayerTeams >= 5) this.playerTeams.push(ColoredTeams.Purple);
if (numPlayerTeams >= 6) this.playerTeams.push(ColoredTeams.Orange);
if (numPlayerTeams >= 7) this.playerTeams.push(ColoredTeams.Teal);
if (numPlayerTeams >= 8)
throw new Error(`Too many teams: ${numPlayerTeams}`);
}
}
private addHumans() {
private addPlayers() {
if (this.config().gameConfig().gameMode != GameMode.Team) {
this._humans.forEach((p) => this.addPlayer(p));
this._nations.forEach((n) => this.addPlayer(n.playerInfo));
return;
}
const playerToTeam = assignTeams(this._humans, this.playerTeams);
const isDuos = this.config().gameConfig().playerTeams === Duos;
const allPlayers = [
...this._humans,
...this._nations.map((n) => n.playerInfo),
];
const playerToTeam = assignTeams(allPlayers, this.playerTeams);
for (const [playerInfo, team] of playerToTeam.entries()) {
if (team == "kicked") {
console.warn(`Player ${playerInfo.name} was kicked from team`);
@@ -180,7 +190,7 @@ export class GameImpl implements Game {
return this.config().unitInfo(type);
}
nations(): Nation[] {
return this.nations_;
return this._nations;
}
createAllianceRequest(requestor: Player, recipient: Player): AllianceRequest {
+26 -13
View File
@@ -58,6 +58,11 @@ export class GameMapImpl implements GameMap {
private readonly width_: number;
private readonly height_: number;
// Lookup tables (LUTs) contain pre-computed values to avoid performing division at runtime
private readonly refToX: number[];
private readonly refToY: number[];
private readonly yToRef: number[];
// Terrain bits (Uint8Array)
private static readonly IS_LAND_BIT = 7;
private static readonly SHORELINE_BIT = 6;
@@ -87,6 +92,19 @@ export class GameMapImpl implements GameMap {
this.height_ = height;
this.terrain = terrainData;
this.state = new Uint16Array(width * height);
// Precompute the LUTs
let ref = 0;
this.refToX = new Array(width * height);
this.refToY = new Array(width * height);
this.yToRef = new Array(height);
for (let y = 0; y < height; y++) {
this.yToRef[y] = ref;
for (let x = 0; x < width; x++) {
this.refToX[ref] = x;
this.refToY[ref] = y;
ref++;
}
}
}
numTilesWithFallout(): number {
return this._numTilesWithFallout;
@@ -96,15 +114,15 @@ export class GameMapImpl implements GameMap {
if (!this.isValidCoord(x, y)) {
throw new Error(`Invalid coordinates: ${x},${y}`);
}
return y * this.width_ + x;
return this.yToRef[y] + x;
}
x(ref: TileRef): number {
return ref % this.width_;
return this.refToX[ref];
}
y(ref: TileRef): number {
return Math.floor(ref / this.width_);
return this.refToY[ref];
}
cell(ref: TileRef): Cell {
@@ -240,24 +258,19 @@ export class GameMapImpl implements GameMap {
neighbors(ref: TileRef): TileRef[] {
const neighbors: TileRef[] = [];
const w = this.width_;
const x = this.refToX[ref];
if (ref >= w) neighbors.push(ref - w);
if (ref < (this.height_ - 1) * w) neighbors.push(ref + w);
if (ref % w !== 0) neighbors.push(ref - 1);
if (ref % w !== w - 1) neighbors.push(ref + 1);
for (const n of neighbors) {
this.ref(this.x(n), this.y(n));
}
if (x !== 0) neighbors.push(ref - 1);
if (x !== w - 1) neighbors.push(ref + 1);
return neighbors;
}
forEachTile(fn: (tile: TileRef) => void): void {
for (let x = 0; x < this.width_; x++) {
for (let y = 0; y < this.height_; y++) {
fn(this.ref(x, y));
}
for (let ref: TileRef = 0; ref < this.width_ * this.height_; ref++) {
fn(ref);
}
}
+3 -9
View File
@@ -21,7 +21,6 @@ import {
BuildableUnit,
Cell,
EmojiMessage,
GameMode,
Gold,
MessageType,
MutableAlliance,
@@ -527,13 +526,6 @@ export class PlayerImpl implements Player {
}
canDonate(recipient: Player): boolean {
if (
recipient.type() == PlayerType.Human &&
this.mg.config().gameConfig().gameMode == GameMode.FFA
) {
return false;
}
if (!this.isFriendly(recipient)) {
return false;
}
@@ -754,7 +746,9 @@ export class PlayerImpl implements Player {
return Object.values(UnitType).map((u) => {
return {
type: u,
canBuild: this.canBuild(u, tile, validTiles),
canBuild: this.mg.inSpawnPhase()
? false
: this.canBuild(u, tile, validTiles),
cost: this.mg.config().unitInfo(u).cost(this),
} as BuildableUnit;
});
+1
View File
@@ -42,6 +42,7 @@ const MAP_FILE_NAMES: Record<GameMapType, string> = {
[GameMapType.BetweenTwoSeas]: "BetweenTwoSeas",
[GameMapType.KnownWorld]: "KnownWorld",
[GameMapType.FaroeIslands]: "FaroeIslands",
[GameMapType.DeglaciatedAntarctica]: "DeglaciatedAntarctica",
[GameMapType.EuropeClassic]: "EuropeClassic",
};
+1
View File
@@ -24,6 +24,7 @@ const maps = [
"Japan",
"KnownWorld",
"FaroeIslands",
"DeglaciatedAntarctica",
];
const removeSmall = true;
-61
View File
@@ -1,61 +0,0 @@
import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
import { Client, Events, GatewayIntentBits } from "discord.js";
export class DiscordBot {
private client: Client;
private secretManager: SecretManagerServiceClient;
constructor() {
this.client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
this.secretManager = new SecretManagerServiceClient();
this.setupEventHandlers();
}
private setupEventHandlers(): void {
this.client.once(Events.ClientReady, (c) => {
console.log(`Ready! Logged in as ${c.user.tag}`);
});
this.client.on(Events.MessageCreate, async (message) => {
if (message.author.bot) return;
if (message.content === "!ping") {
await message.reply("Pong! 🏓");
}
if (message.content === "!hello") {
await message.reply(`Hello ${message.author.username}! 👋`);
}
});
}
private async getToken(): Promise<string | undefined> {
const name =
"projects/openfrontio/secrets/discord-bot-token/versions/latest";
const [version] = await this.secretManager.accessSecretVersion({ name });
return version.payload?.data?.toString().trim();
}
public async start(): Promise<void> {
try {
const token = await this.getToken();
if (!token) {
throw new Error("Failed to retrieve Discord token");
}
await this.client.login(token);
} catch (error) {
console.error("Failed to start bot:", error);
throw error;
}
}
public stop(): void {
this.client.destroy();
}
}
+2 -2
View File
@@ -95,8 +95,8 @@ export class GameServer {
if (gameConfig.gameMode != null) {
this.gameConfig.gameMode = gameConfig.gameMode;
}
if (gameConfig.numPlayerTeams != null) {
this.gameConfig.numPlayerTeams = gameConfig.numPlayerTeams;
if (gameConfig.playerTeams != null) {
this.gameConfig.playerTeams = gameConfig.playerTeams;
}
}
+56 -1
View File
@@ -1,4 +1,56 @@
import * as logsAPI from "@opentelemetry/api-logs";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
import {
LoggerProvider,
SimpleLogRecordProcessor,
} from "@opentelemetry/sdk-logs";
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();
const config = getServerConfigFromServer();
const resource = getOtelResource();
// Initialize the OpenTelemetry Logger Provider
const loggerProvider = new LoggerProvider({
resource,
});
if (config.env() == GameEnv.Prod && config.otelEnabled()) {
console.log("OTEL enabled");
// Configure OpenTelemetry endpoint with basic auth (if provided)
const headers = {};
if (config.otelUsername() && config.otelPassword()) {
headers["Authorization"] =
"Basic " +
Buffer.from(`${config.otelUsername()}:${config.otelPassword()}`).toString(
"base64",
);
}
// Add OTLP exporter for logs
const logExporter = new OTLPLogExporter({
url: `${config.otelEndpoint()}/v1/logs`,
headers,
});
// Add a log processor with the exporter
loggerProvider.addLogRecordProcessor(
new SimpleLogRecordProcessor(logExporter),
);
// Set as the global logger provider
logsAPI.logs.setGlobalLoggerProvider(loggerProvider);
} else {
console.log(
"No OTLP endpoint and credentials provided, remote logging disabled",
);
}
// Custom format to add severity tag based on log level
const addSeverityFormat = winston.format((info) => {
@@ -20,7 +72,10 @@ const logger = winston.createLogger({
service: "openfront",
environment: process.env.NODE_ENV,
},
transports: [new winston.transports.Console()],
transports: [
new winston.transports.Console(),
new OpenTelemetryTransportV3(),
],
});
// Export both the main logger and the child logger factory
+108 -94
View File
@@ -1,119 +1,133 @@
import { GameMapType, GameMode } from "../core/game/Game";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { Difficulty, GameMapType, GameMode, GameType } from "../core/game/Game";
import { PseudoRandom } from "../core/PseudoRandom";
import { GameConfig } from "../core/Schemas";
import { logger } from "./Logger";
enum PlaylistType {
BigMaps,
SmallMaps,
const log = logger.child({});
const config = getServerConfigFromServer();
const frequency = {
World: 3,
Europe: 2,
Africa: 2,
Australia: 1,
NorthAmerica: 1,
Britannia: 1,
GatewayToTheAtlantic: 1,
Iceland: 1,
SouthAmerica: 1,
KnownWorld: 1,
DeglaciatedAntarctica: 1,
EuropeClassic: 1,
Mena: 1,
Pangaea: 1,
Asia: 1,
Mars: 1,
BetweenTwoSeas: 1,
Japan: 1,
BlackSea: 1,
FaroeIslands: 1,
};
interface MapWithMode {
map: GameMapType;
mode: GameMode;
}
const random = new PseudoRandom(123);
export class MapPlaylist {
private gameModeRotation = [GameMode.FFA, GameMode.FFA, GameMode.Team];
private currentGameModeIndex = 0;
private mapsPlaylist: MapWithMode[] = [];
private mapsPlaylistBig: GameMapType[] = [];
private mapsPlaylistSmall: GameMapType[] = [];
private currentPlaylistCounter = 0;
public gameConfig(): GameConfig {
const { map, mode } = this.getNextMap();
// Get the next map in rotation
public getNextMap(): GameMapType {
const playlistType: PlaylistType = this.getNextPlaylistType();
const mapsPlaylist: GameMapType[] = this.getNextMapsPlayList(playlistType);
return mapsPlaylist.shift()!;
const numPlayerTeams =
mode === GameMode.Team ? 2 + Math.floor(Math.random() * 5) : undefined;
// Create the default public game config (from your GameManager)
return {
gameMap: map,
maxPlayers: config.lobbyMaxPlayers(map, mode),
gameType: GameType.Public,
difficulty: Difficulty.Medium,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
disableNPCs: mode == GameMode.Team,
disableNukes: false,
gameMode: mode,
playerTeams: numPlayerTeams,
bots: 400,
} as GameConfig;
}
public getNextGameMode(): GameMode {
const nextGameMode = this.gameModeRotation[this.currentGameModeIndex];
this.currentGameModeIndex =
(this.currentGameModeIndex + 1) % this.gameModeRotation.length;
return nextGameMode;
}
private getNextMapsPlayList(playlistType: PlaylistType): GameMapType[] {
switch (playlistType) {
case PlaylistType.BigMaps:
if (!(this.mapsPlaylistBig.length > 0)) {
this.fillMapsPlaylist(playlistType, this.mapsPlaylistBig);
private getNextMap(): MapWithMode {
if (this.mapsPlaylist.length === 0) {
const numAttempts = 10000;
for (let i = 0; i < numAttempts; i++) {
if (this.shuffleMapsPlaylist()) {
log.info(`Generated map playlist in ${i} attempts`);
return this.mapsPlaylist.shift()!;
}
return this.mapsPlaylistBig;
case PlaylistType.SmallMaps:
if (!(this.mapsPlaylistSmall.length > 0)) {
this.fillMapsPlaylist(playlistType, this.mapsPlaylistSmall);
}
return this.mapsPlaylistSmall;
}
log.error("Failed to generate a valid map playlist");
}
// Even if it failed, playlist will be partially populated.
return this.mapsPlaylist.shift()!;
}
private fillMapsPlaylist(
playlistType: PlaylistType,
mapsPlaylist: GameMapType[],
): void {
const frequency = this.getFrequency(playlistType);
private shuffleMapsPlaylist(): boolean {
const maps: GameMapType[] = [];
Object.keys(GameMapType).forEach((key) => {
let count = parseInt(frequency[key]);
while (count > 0) {
mapsPlaylist.push(GameMapType[key]);
count--;
for (let i = 0; i < parseInt(frequency[key]); i++) {
maps.push(GameMapType[key]);
}
});
while (!this.allNonConsecutive(mapsPlaylist)) {
random.shuffleArray(mapsPlaylist);
}
}
// Specifically controls how the playlists rotate.
private getNextPlaylistType(): PlaylistType {
switch (this.currentPlaylistCounter) {
case 0:
case 1:
this.currentPlaylistCounter++;
return PlaylistType.BigMaps;
case 2:
this.currentPlaylistCounter = 0;
return PlaylistType.SmallMaps;
}
}
const rand = new PseudoRandom(Date.now());
private getFrequency(playlistType: PlaylistType) {
switch (playlistType) {
// Big Maps are those larger than ~2.5 mil pixels
case PlaylistType.BigMaps:
return {
Europe: 2,
NorthAmerica: 1,
Africa: 2,
Britannia: 1,
GatewayToTheAtlantic: 2,
Australia: 2,
Iceland: 2,
SouthAmerica: 1,
KnownWorld: 2,
};
case PlaylistType.SmallMaps:
return {
World: 4,
EuropeClassic: 3,
Mena: 2,
Pangaea: 1,
Asia: 1,
Mars: 1,
BetweenTwoSeas: 2,
Japan: 2,
BlackSea: 1,
FaroeIslands: 2,
};
}
}
const ffa1: GameMapType[] = rand.shuffleArray([...maps]);
const ffa2: GameMapType[] = rand.shuffleArray([...maps]);
const ffa3: GameMapType[] = rand.shuffleArray([...maps]);
const team: GameMapType[] = rand.shuffleArray([...maps]);
// Check for consecutive duplicates in the maps array
private allNonConsecutive(maps: GameMapType[]): boolean {
for (let i = 0; i < maps.length - 1; i++) {
if (maps[i] === maps[i + 1]) {
this.mapsPlaylist = [];
for (let i = 0; i < maps.length; i++) {
if (!this.addNextMap(this.mapsPlaylist, ffa1, GameMode.FFA)) {
return false;
}
if (!this.addNextMap(this.mapsPlaylist, ffa2, GameMode.FFA)) {
return false;
}
if (!this.addNextMap(this.mapsPlaylist, ffa3, GameMode.FFA)) {
return false;
}
if (!this.addNextMap(this.mapsPlaylist, team, GameMode.Team)) {
return false;
}
}
return true;
}
private addNextMap(
playlist: MapWithMode[],
nextEls: GameMapType[],
mode: GameMode,
): boolean {
const nonConsecutiveNum = 5;
const lastEls = playlist
.slice(playlist.length - nonConsecutiveNum)
.map((m) => m.map);
for (let i = 0; i < nextEls.length; i++) {
const next = nextEls[i];
if (lastEls.includes(next)) {
continue;
}
nextEls.splice(i, 1);
playlist.push({ map: next, mode: mode });
return true;
}
return false;
}
}
+2 -40
View File
@@ -5,13 +5,11 @@ import http from "http";
import path from "path";
import { fileURLToPath } from "url";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { Difficulty, GameMode, GameType } from "../core/game/Game";
import { GameConfig, GameInfo } from "../core/Schemas";
import { GameInfo } from "../core/Schemas";
import { generateID } from "../core/Util";
import { gatekeeper, LimiterType } from "./Gatekeeper";
import { logger } from "./Logger";
import { MapPlaylist } from "./MapPlaylist";
import { setupMetricsServer } from "./MasterMetrics";
const config = getServerConfigFromServer();
const playlist = new MapPlaylist();
@@ -20,10 +18,6 @@ const readyWorkers = new Set();
const app = express();
const server = http.createServer(app);
// Create a separate metrics server on port 9090
const metricsApp = express();
const metricsServer = http.createServer(metricsApp);
const log = logger.child({ comp: "m" });
const __filename = fileURLToPath(import.meta.url);
@@ -146,9 +140,6 @@ export async function startMaster() {
server.listen(PORT, () => {
log.info(`Master HTTP server listening on port ${PORT}`);
});
// Setup the metrics server
setupMetricsServer();
}
app.get(
@@ -222,40 +213,11 @@ async function fetchLobbies(): Promise<number> {
return publicLobbyIDs.size;
}
let lastGameMode: GameMode = GameMode.FFA;
// Function to schedule a new public game
async function schedulePublicGame(playlist: MapPlaylist) {
const gameID = generateID();
const map = playlist.getNextMap();
publicLobbyIDs.add(gameID);
if (lastGameMode == GameMode.FFA) {
lastGameMode = GameMode.Team;
} else {
lastGameMode = GameMode.FFA;
}
const gameMode = playlist.getNextGameMode();
const numPlayerTeams =
gameMode === GameMode.Team ? 2 + Math.floor(Math.random() * 5) : undefined;
// Create the default public game config (from your GameManager)
const defaultGameConfig: GameConfig = {
gameMap: map,
maxPlayers: config.lobbyMaxPlayers(map),
gameType: GameType.Public,
difficulty: Difficulty.Medium,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
disableNPCs: gameMode == GameMode.Team,
disableNukes: false,
gameMode,
numPlayerTeams,
bots: 400,
};
const workerPath = config.workerPath(gameID);
// Send request to the worker to start the game
@@ -269,7 +231,7 @@ async function schedulePublicGame(playlist: MapPlaylist) {
[config.adminHeader()]: config.adminToken(),
},
body: JSON.stringify({
gameConfig: defaultGameConfig,
gameConfig: playlist.gameConfig(),
}),
},
);
-189
View File
@@ -1,189 +0,0 @@
import express from "express";
import http from "http";
import promClient from "prom-client";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
const config = getServerConfigFromServer();
// Create a separate metrics server on port 9090
const metricsApp = express();
const metricsServer = http.createServer(metricsApp);
// Initialize the Prometheus registry for the master's own metrics
const register = new promClient.Registry();
// Default Prometheus metrics
promClient.collectDefaultMetrics({ register });
// Prometheus metrics endpoint that gathers metrics from workers
export function setupMetricsServer() {
metricsApp.get("/metrics", async (req, res) => {
// Set a timeout for the request to avoid hanging
const timeout = setTimeout(() => {
res.status(500).end("# Error: Request timed out after 30 seconds");
}, 30000);
console.log("Metrics requested");
try {
// Get the master's metrics
const masterMetrics = await register.metrics();
// Track seen metric names to avoid duplicate metadata
const seenMetrics = new Set();
const processedLines = [];
const allMetricValues = [];
// Process all metadata information in the master metrics first
const masterLines = masterMetrics.split("\n");
for (let j = 0; j < masterLines.length; j++) {
const line = masterLines[j];
if (line.startsWith("# HELP ")) {
const metricName = line.split(" ")[2];
seenMetrics.add(metricName);
processedLines.push(line);
} else if (line.startsWith("# TYPE ")) {
const metricName = line.split(" ")[2];
if (seenMetrics.has(metricName)) {
processedLines.push(line);
}
} else if (line.trim() && !line.startsWith("#")) {
// Add worker label to each metric line and collect for later
const processedLine = line.replace(
/^([a-z][a-z0-9_]*)(?:{([^}]*)})?(\s+[0-9.e+-]+.*)/,
(match, metricName, existingLabels, valueAndRest) => {
if (existingLabels) {
return `${metricName}{${existingLabels},worker="master"}${valueAndRest}`;
} else {
return `${metricName}{worker="master"}${valueAndRest}`;
}
},
);
allMetricValues.push(processedLine);
}
}
// Collect metrics from all workers
for (let i = 0; i < config.numWorkers(); i++) {
const workerPort = config.workerPortByIndex(i);
const workerUrl = `http://localhost:${workerPort}/metrics`;
console.log(`Fetching metrics from worker ${i} at ${workerUrl}`);
try {
const response = await fetch(workerUrl, {
headers: {
[config.adminHeader()]: config.adminToken(),
},
});
if (!response.ok) {
console.error(`Worker ${i} returned status ${response.status}`);
continue;
}
const metricsText = await response.text();
const lines = metricsText.split("\n");
for (let j = 0; j < lines.length; j++) {
const line = lines[j];
// Collect HELP and TYPE info if we haven't seen this metric before
if (line.startsWith("# HELP ")) {
const metricName = line.split(" ")[2];
if (!seenMetrics.has(metricName)) {
seenMetrics.add(metricName);
processedLines.push(line);
}
} else if (line.startsWith("# TYPE ")) {
const metricName = line.split(" ")[2];
if (
seenMetrics.has(metricName) &&
!processedLines.some((l) =>
l.startsWith(`# TYPE ${metricName}`),
)
) {
processedLines.push(line);
}
} else if (line.trim() && !line.startsWith("#")) {
// Process and collect actual metric values
try {
const processedLine = line.replace(
/^([a-z][a-z0-9_]*)(?:{([^}]*)})?(\s+[0-9.e+-]+.*)/,
(match, metricName, existingLabels, valueAndRest) => {
if (existingLabels) {
return `${metricName}{${existingLabels},worker="worker-${i}"}${valueAndRest}`;
} else {
return `${metricName}{worker="worker-${i}"}${valueAndRest}`;
}
},
);
// Make sure the line was actually processed (regex matched)
if (processedLine !== line) {
allMetricValues.push(processedLine);
} else if (
line.match(/^[a-z][a-z0-9_]*(?:{[^}]*})?\s+[0-9.e+-]+.*/)
) {
// This looks like a metric line but didn't match our regex, try a more general approach
const parts = line.split(/({|\s+)/);
if (parts.length >= 3) {
const metricName = parts[0];
if (line.includes("{")) {
// Has labels
const labelEndIndex = line.indexOf("}");
const valueStartIndex = labelEndIndex + 1;
if (labelEndIndex > 0 && valueStartIndex < line.length) {
const labels = line.substring(
line.indexOf("{") + 1,
labelEndIndex,
);
const valueAndRest = line.substring(valueStartIndex);
allMetricValues.push(
`${metricName}{${labels},worker="worker-${i}"}${valueAndRest}`,
);
}
} else {
// No labels
const valueAndRest = line.substring(metricName.length);
allMetricValues.push(
`${metricName}{worker="worker-${i}"}${valueAndRest}`,
);
}
}
}
} catch (error) {
console.error(`Error processing metric line: ${line}`, error);
// Skip this line if there's an error
}
}
}
} catch (error) {
console.error(`Error fetching metrics from worker ${i}:`, error);
allMetricValues.push(
`# Error fetching metrics from worker ${i}: ${error.message}`,
);
}
}
// Combine metadata with all metric values and ensure it ends with a newline
const combinedMetrics = [...processedLines, ...allMetricValues].join(
"\n",
);
// Send the combined response with a final newline to prevent unexpected end of input
clearTimeout(timeout);
res.set("Content-Type", register.contentType);
res.end(combinedMetrics + "\n");
} catch (error) {
console.error("Error collecting metrics:", error);
clearTimeout(timeout);
res.status(500).end(`# Error collecting metrics: ${error.message}`);
}
});
// Start the metrics server on port 9090
const METRICS_PORT = 9090;
metricsServer.listen(METRICS_PORT, () => {
console.log(`Metrics server listening on port ${METRICS_PORT}`);
});
}
+27
View File
@@ -0,0 +1,27 @@
import { resourceFromAttributes } from "@opentelemetry/resources";
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from "@opentelemetry/semantic-conventions";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
const config = getServerConfigFromServer();
export function getOtelResource() {
return resourceFromAttributes({
[ATTR_SERVICE_NAME]: "openfront",
[ATTR_SERVICE_VERSION]: "1.0.0",
"service.instance.id": process.env.HOSTNAME,
"openfront.environment": config.env(),
"openfront.host": process.env.HOST,
"openfront.domain": process.env.DOMAIN,
"openfront.subdomain": process.env.SUBDOMAIN,
"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",
});
}
+5 -24
View File
@@ -13,7 +13,7 @@ import { Client } from "./Client";
import { GameManager } from "./GameManager";
import { gatekeeper, LimiterType } from "./Gatekeeper";
import { logger } from "./Logger";
import { metrics } from "./WorkerMetrics";
import { initWorkerMetrics } from "./WorkerMetrics";
const config = getServerConfigFromServer();
@@ -33,10 +33,9 @@ export function startWorker() {
const gm = new GameManager(config, log);
// Set up periodic metrics updates
setInterval(() => {
metrics.updateGameMetrics(gm);
}, 15000); // Update every 15 seconds
if (config.env() == GameEnv.Prod && config.otelEnabled()) {
initWorkerMetrics(gm);
}
// Middleware to handle /wX path prefix
app.use((req, res, next) => {
@@ -165,7 +164,7 @@ export function startWorker() {
disableNPCs: req.body.disableNPCs,
disableNukes: req.body.disableNukes,
gameMode: req.body.gameMode,
numPlayerTeams: req.body.numPlayerTeams,
playerTeams: req.body.playerTeams,
});
res.status(200).json({ success: true });
}),
@@ -251,24 +250,6 @@ export function startWorker() {
}),
);
app.get(
"/metrics",
gatekeeper.httpHandler(LimiterType.Get, async (req, res) => {
if (req.headers[config.adminHeader()] !== config.adminToken()) {
return res.status(403).end("Access denied");
}
log.info(`metrics requested on worker ${workerId}`);
try {
const metricsData = await metrics.register.metrics();
res.set("Content-Type", metrics.register.contentType);
res.end(metricsData);
} catch (error) {
res.status(500).end(error.message);
}
}),
);
// WebSocket handling
wss.on("connection", (ws: WebSocket, req) => {
ws.on(
+82 -35
View File
@@ -1,45 +1,92 @@
import promClient from "prom-client";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import {
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import * as dotenv from "dotenv";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { GameManager } from "./GameManager";
import { getOtelResource } from "./OtelResource";
// Initialize the Prometheus registry
const register = new promClient.Registry();
dotenv.config();
// Enable default Node.js metrics collection
promClient.collectDefaultMetrics({ register });
export function initWorkerMetrics(gameManager: GameManager): void {
// Get server configuration
const config = getServerConfigFromServer();
// Add worker-specific metrics
const activeGamesGauge = new promClient.Gauge({
name: "openfront_active_games_count",
help: "Number of active games on this worker",
registers: [register],
});
// Create resource with worker information
const resource = getOtelResource();
const connectedClientsGauge = new promClient.Gauge({
name: "openfront_connected_clients_count",
help: "Number of connected clients on this worker",
registers: [register],
});
// Configure auth headers
const headers = {};
if (config.otelEnabled()) {
headers["Authorization"] =
"Basic " +
Buffer.from(`${config.otelUsername()}:${config.otelPassword()}`).toString(
"base64",
);
}
const memoryUsageGauge = new promClient.Gauge({
name: "openfront_memory_usage_bytes",
help: "Current memory usage of the worker process in bytes",
registers: [register],
});
// Create metrics exporter
const metricExporter = new OTLPMetricExporter({
url: `${config.otelEndpoint()}/v1/metrics`,
headers,
});
// Export the metrics for use in the worker
export const metrics = {
register,
activeGamesGauge,
connectedClientsGauge,
memoryUsageGauge,
// Configure the metric reader
const metricReader = new PeriodicExportingMetricReader({
exporter: metricExporter,
exportIntervalMillis: 15000, // Export metrics every 15 seconds
});
// Function to update game-related metrics
updateGameMetrics: (gameManager: GameManager) => {
activeGamesGauge.set(gameManager.activeGames());
connectedClientsGauge.set(gameManager.activeClients());
// Create a meter provider
const meterProvider = new MeterProvider({
resource,
readers: [metricReader],
});
// Update memory usage metrics
// Get meter for creating metrics
const meter = meterProvider.getMeter("worker-metrics");
// Create observable gauges
const activeGamesGauge = meter.createObservableGauge(
"openfront.active_games.gauge",
{
description: "Number of active games on this worker",
},
);
const connectedClientsGauge = meter.createObservableGauge(
"openfront.connected_clients.gauge",
{
description: "Number of connected clients on this worker",
},
);
const memoryUsageGauge = meter.createObservableGauge(
"openfront.memory_usage.bytes",
{
description: "Current memory usage of the worker process in bytes",
},
);
// Register callback for active games metric
activeGamesGauge.addCallback((result) => {
const count = gameManager.activeGames();
result.observe(count);
});
// Register callback for connected clients metric
connectedClientsGauge.addCallback((result) => {
const count = gameManager.activeClients();
result.observe(count);
});
// Register callback for memory usage metric
memoryUsageGauge.addCallback((result) => {
const memoryUsage = process.memoryUsage();
memoryUsageGauge.set(memoryUsage.heapUsed);
},
};
result.observe(memoryUsage.heapUsed);
});
console.log("Metrics initialized with GameManager");
}