mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-01 14:02:04 +00:00
Merge branch 'main' into meta3
This commit is contained in:
@@ -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>;
|
||||
@@ -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,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
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -173,6 +173,7 @@ export class LangSelector extends LitElement {
|
||||
"help-modal",
|
||||
"username-input",
|
||||
"public-lobby",
|
||||
"user-setting",
|
||||
"o-modal",
|
||||
"o-button",
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 site’s 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 (1–100%)"
|
||||
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) (1–100%)"
|
||||
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 (x1–x100)"
|
||||
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 (0–1000, 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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": "Polish–Lithuanian Commonwealth",
|
||||
"continent": "Europe",
|
||||
"name": "Polish–Lithuanian 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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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
@@ -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"]),
|
||||
|
||||
@@ -307,3 +307,19 @@ export function createRandomName(
|
||||
}
|
||||
return randomName;
|
||||
}
|
||||
|
||||
export const emojiTable: string[][] = [
|
||||
["😀", "😊", "🥰", "😇", "😎"],
|
||||
["😞", "🥺", "😭", "😱", "😡"],
|
||||
["😈", "🤡", "🖕", "🥱", "🤦♂️"],
|
||||
["👋", "👏", "🤌", "💪", "🫡"],
|
||||
["👍", "👎", "❓", "🐔", "🐀"],
|
||||
["🤝", "🆘", "🕊️", "🏳️", "⏳"],
|
||||
["🔥", "💥", "💀", "☢️", "⚠️"],
|
||||
["↖️", "⬆️", "↗️", "👑", "🥇"],
|
||||
["⬅️", "🎯", "➡️", "🥈", "🥉"],
|
||||
["↙️", "⬇️", "↘️", "❤️", "💔"],
|
||||
["💰", "⚓", "⛵", "🏡", "🛡️"],
|
||||
];
|
||||
// 2d to 1d array
|
||||
export const flattenedEmojiTable: string[] = [].concat(...emojiTable);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ const maps = [
|
||||
"Japan",
|
||||
"KnownWorld",
|
||||
"FaroeIslands",
|
||||
"DeglaciatedAntarctica",
|
||||
];
|
||||
|
||||
const removeSmall = true;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user