Merge branch 'main' into custom-flag

This commit is contained in:
Aotumuri
2025-05-11 09:14:56 +09:00
committed by GitHub
717 changed files with 12138 additions and 11162 deletions
+48
View File
@@ -0,0 +1,48 @@
import { z } from "zod";
import { base64urlToUuid } from "./Base64";
export const RefreshResponseSchema = z.object({
token: z.string(),
});
export type RefreshResponse = z.infer<typeof RefreshResponseSchema>;
export const TokenPayloadSchema = z.object({
jti: z.string(),
sub: z
.string()
.refine(
(val) => {
const uuid = base64urlToUuid(val);
return uuid != null;
},
{
message: "Invalid base64-encoded UUID",
},
)
.transform((val) => {
const uuid = base64urlToUuid(val);
if (!uuid) throw new Error("Invalid base64 UUID");
return uuid;
}),
iat: z.number(),
iss: z.string(),
aud: z.string(),
exp: z.number(),
rol: z
.string()
.optional()
.transform((val) => val.split(",")),
});
export type TokenPayload = z.infer<typeof TokenPayloadSchema>;
export const UserMeResponseSchema = z.object({
user: z.object({
id: z.string(),
avatar: z.string(),
username: z.string(),
global_name: z.string(),
discriminator: z.string(),
locale: z.string(),
}),
});
export type UserMeResponse = z.infer<typeof UserMeResponseSchema>;
+37
View File
@@ -0,0 +1,37 @@
import { base64url } from "jose";
/**
* Converts a UUID string to a base64url-encoded binary representation.
* @param uuid - The UUID string (e.g., '123e4567-e89b-12d3-a456-426614174000')
* @returns base64url string (e.g., 'Ej5FZ+i7EtOkVkJmFBdAAA')
*/
export function uuidToBase64url(uuid: string): string {
const hex = uuid.replace(/-/g, "");
const bytes = new Uint8Array(16);
for (let i = 0; i < 16; i++) {
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return base64url.encode(bytes);
}
/**
* Converts a base64url-encoded binary UUID back to its canonical UUID string.
* @param encoded - base64url string (e.g., 'Ej5FZ+i7EtOkVkJmFBdAAA')
* @returns UUID string (e.g., '123e4567-e89b-12d3-a456-426614174000')
*/
export function base64urlToUuid(encoded: string): string {
const bytes = base64url.decode(encoded);
const hex = Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20),
].join("-");
}
+31 -13
View File
@@ -11,7 +11,7 @@ import {
import { createGameRecord } from "../core/Util";
import { ServerConfig } from "../core/configuration/Config";
import { getConfig } from "../core/configuration/ConfigLoader";
import { Team, UnitType } from "../core/game/Game";
import { Cell, Team, UnitType } from "../core/game/Game";
import { TileRef } from "../core/game/GameMap";
import {
ErrorUpdate,
@@ -82,7 +82,7 @@ export function joinLobby(
if (message.type == "start") {
// Trigger prestart for singleplayer games
onPrestart();
consolex.log(`lobby: game started: ${JSON.stringify(message)}`);
consolex.log(`lobby: game started: ${JSON.stringify(message, null, 2)}`);
onJoin();
// For multiplayer games, GameStartInfo is not known until game starts.
lobbyConfig.gameStartInfo = message.gameStartInfo;
@@ -239,6 +239,7 @@ export class ClientGameRunner {
this.lobby.gameStartInfo.gameID,
this.lobby.clientID,
);
console.error(gu.stack);
this.stop(true);
return;
}
@@ -275,7 +276,6 @@ export class ClientGameRunner {
while (turn.turnNumber - 1 > this.turnsSeen) {
this.worker.sendTurn({
turnNumber: this.turnsSeen,
gameID: turn.gameID,
intents: [],
});
this.turnsSeen++;
@@ -353,7 +353,13 @@ export class ClientGameRunner {
}
}
this.myPlayer.actions(tile).then((actions) => {
console.log(`got actions: ${JSON.stringify(actions)}`);
const bu = actions.buildableUnits.find(
(bu) => bu.type == UnitType.TransportShip,
);
if (bu == null) {
console.warn(`no transport ship buildable units`);
return;
}
if (actions.canAttack) {
this.eventBus.emit(
new SendAttackIntentEvent(
@@ -362,17 +368,29 @@ export class ClientGameRunner {
),
);
} else if (
actions.canBoat !== false &&
this.shouldBoat(tile, actions.canBoat) &&
bu.canBuild !== false &&
this.shouldBoat(tile, bu.canBuild) &&
this.gameView.isLand(tile)
) {
this.eventBus.emit(
new SendBoatAttackIntentEvent(
this.gameView.owner(tile).id(),
cell,
this.myPlayer.troops() * this.renderer.uiState.attackRatio,
),
);
this.myPlayer
.bestTransportShipSpawn(this.gameView.ref(cell.x, cell.y))
.then((spawn: number | false) => {
let spawnCell = null;
if (spawn !== false) {
spawnCell = new Cell(
this.gameView.x(spawn),
this.gameView.y(spawn),
);
}
this.eventBus.emit(
new SendBoatAttackIntentEvent(
this.gameView.owner(tile).id(),
cell,
this.myPlayer.troops() * this.renderer.uiState.attackRatio,
spawnCell,
),
);
});
}
const owner = this.gameView.owner(tile);
+73 -17
View File
@@ -6,8 +6,10 @@ import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { consolex } from "../core/Consolex";
import {
Difficulty,
Duos,
GameMapType,
GameMode,
UnitType,
mapCategories,
} from "../core/game/Game";
import { GameConfig, GameInfo } from "../core/Schemas";
@@ -28,7 +30,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;
@@ -38,6 +40,7 @@ export class HostLobbyModal extends LitElement {
@state() private copySuccess = false;
@state() private players: string[] = [];
@state() private useRandomMap: boolean = false;
@state() private disabledUnits: string[] = [];
private playersInterval = null;
// Add a new timer for debouncing bot changes
@@ -194,7 +197,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
@@ -301,21 +304,72 @@ export class HostLobbyModal extends LitElement {
</div>
</label>
<label
for="disable-nukes"
class="option-card ${this.disableNukes ? "selected" : ""}"
<hr style="width: 100%; border-top: 1px solid #444; margin: 16px 0;" />
<!-- Individual disables for structures/weapons -->
<div
style="margin: 8px 0 12px 0; font-weight: bold; color: #ccc; text-align: center;"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="disable-nukes"
@change=${this.handleDisableNukesChange}
.checked=${this.disableNukes}
/>
<div class="option-card-title">
${translateText("host_modal.disable_nukes")}
${translateText("host_modal.enables_title")}
</div>
<div
style="display: flex; flex-wrap: wrap; justify-content: center; gap: 12px;"
>
${[
[UnitType.City, "unit_type.city"],
[UnitType.DefensePost, "unit_type.defense_post"],
[UnitType.Port, "unit_type.port"],
[UnitType.Warship, "unit_type.warship"],
[UnitType.MissileSilo, "unit_type.missile_silo"],
[UnitType.SAMLauncher, "unit_type.sam_launcher"],
[UnitType.AtomBomb, "unit_type.atom_bomb"],
[UnitType.HydrogenBomb, "unit_type.hydrogen_bomb"],
[UnitType.MIRV, "unit_type.mirv"],
].map(
([unitType, translationKey]) => html`
<label
class="option-card ${this.disabledUnits.includes(
unitType,
)
? ""
: "selected"}"
style="width: 140px;"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
@change=${(e: Event) => {
const checked = (e.target as HTMLInputElement)
.checked;
const parsedUnitType =
UnitType[unitType as keyof typeof UnitType];
if (parsedUnitType) {
if (checked) {
this.disabledUnits = [
...this.disabledUnits,
parsedUnitType,
];
} else {
this.disabledUnits = this.disabledUnits.filter(
(u) => u !== parsedUnitType,
);
}
this.putGameConfig();
}
}}
.checked=${this.disabledUnits.includes(unitType)}
/>
<div
class="option-card-title"
style="text-align: center;"
>
${translateText(translationKey)}
</div>
</label>
`,
)}
</div>
</label>
</div>
</div>
</div>
</div>
@@ -465,8 +519,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();
}
@@ -490,6 +544,8 @@ export class HostLobbyModal extends LitElement {
instantBuild: this.instantBuild,
gameMode: this.gameMode,
numPlayerTeams: this.teamCount,
disabledUnits: this.disabledUnits,
playerTeams: this.teamCount,
} as GameConfig),
},
);
+56 -63
View File
@@ -113,6 +113,17 @@ export class InputHandler {
) {}
initialize() {
const keybinds = {
toggleView: "Space",
centerCamera: "KeyC",
moveUp: "KeyW",
moveDown: "KeyS",
moveLeft: "KeyA",
moveRight: "KeyD",
zoomOut: "KeyQ",
zoomIn: "KeyE",
...JSON.parse(localStorage.getItem("settings.keybinds") ?? "{}"),
};
this.canvas.addEventListener("pointerdown", (e) => this.onPointerDown(e));
window.addEventListener("pointerup", (e) => this.onPointerUp(e));
this.canvas.addEventListener(
@@ -122,59 +133,65 @@ export class InputHandler {
this.onShiftScroll(e);
e.preventDefault();
},
{
passive: false,
},
{ passive: false },
);
window.addEventListener("pointermove", this.onPointerMove.bind(this));
this.canvas.addEventListener("contextmenu", (e: MouseEvent) => {
this.onContextMenu(e);
});
this.canvas.addEventListener("contextmenu", (e) => this.onContextMenu(e));
window.addEventListener("mousemove", (e) => {
if (e.movementX == 0 && e.movementY == 0) {
return;
if (e.movementX || e.movementY) {
this.eventBus.emit(new MouseMoveEvent(e.clientX, e.clientY));
}
this.eventBus.emit(new MouseMoveEvent(e.clientX, e.clientY));
});
this.pointers.clear();
// Initialize the combined movement interval
this.moveInterval = setInterval(() => {
let deltaX = 0;
let deltaY = 0;
// Handle both WASD and arrow keys
if (this.activeKeys.has("KeyW") || this.activeKeys.has("ArrowUp"))
if (
this.activeKeys.has(keybinds.moveUp) ||
this.activeKeys.has("ArrowUp")
)
deltaY += this.PAN_SPEED;
if (this.activeKeys.has("KeyS") || this.activeKeys.has("ArrowDown"))
if (
this.activeKeys.has(keybinds.moveDown) ||
this.activeKeys.has("ArrowDown")
)
deltaY -= this.PAN_SPEED;
if (this.activeKeys.has("KeyA") || this.activeKeys.has("ArrowLeft"))
if (
this.activeKeys.has(keybinds.moveLeft) ||
this.activeKeys.has("ArrowLeft")
)
deltaX += this.PAN_SPEED;
if (this.activeKeys.has("KeyD") || this.activeKeys.has("ArrowRight"))
if (
this.activeKeys.has(keybinds.moveRight) ||
this.activeKeys.has("ArrowRight")
)
deltaX -= this.PAN_SPEED;
if (deltaX !== 0 || deltaY !== 0) {
if (deltaX || deltaY) {
this.eventBus.emit(new DragEvent(deltaX, deltaY));
}
// Handle zooming
const screenCenterX = window.innerWidth / 2;
const screenCenterY = window.innerHeight / 2;
const cx = window.innerWidth / 2;
const cy = window.innerHeight / 2;
if (this.activeKeys.has("Minus") || this.activeKeys.has("KeyQ")) {
this.eventBus.emit(
new ZoomEvent(screenCenterX, screenCenterY, this.ZOOM_SPEED),
);
if (
this.activeKeys.has(keybinds.zoomOut) ||
this.activeKeys.has("Minus")
) {
this.eventBus.emit(new ZoomEvent(cx, cy, this.ZOOM_SPEED));
}
if (this.activeKeys.has("Equal") || this.activeKeys.has("KeyE")) {
this.eventBus.emit(
new ZoomEvent(screenCenterX, screenCenterY, -this.ZOOM_SPEED),
);
if (
this.activeKeys.has(keybinds.zoomIn) ||
this.activeKeys.has("Equal")
) {
this.eventBus.emit(new ZoomEvent(cx, cy, -this.ZOOM_SPEED));
}
}, 1);
window.addEventListener("keydown", (e) => {
if (e.code === "Space") {
if (e.code === keybinds.toggleView) {
e.preventDefault();
if (!this.alternateView) {
this.alternateView = true;
@@ -187,24 +204,23 @@ export class InputHandler {
this.eventBus.emit(new CloseViewEvent());
}
// Add all movement keys to activeKeys
if (
[
"KeyW",
"KeyA",
"KeyS",
"KeyD",
keybinds.moveUp,
keybinds.moveDown,
keybinds.moveLeft,
keybinds.moveRight,
keybinds.zoomOut,
keybinds.zoomIn,
"ArrowUp",
"ArrowLeft",
"ArrowDown",
"ArrowRight",
"Minus",
"Equal",
"KeyE",
"KeyQ",
"Digit1",
"Digit2",
"KeyC",
keybinds.centerCamera,
"ControlLeft",
"ControlRight",
].includes(e.code)
@@ -212,13 +228,13 @@ export class InputHandler {
this.activeKeys.add(e.code);
}
});
window.addEventListener("keyup", (e) => {
if (e.code === "Space") {
if (e.code === keybinds.toggleView) {
e.preventDefault();
this.alternateView = false;
this.eventBus.emit(new AlternateViewEvent(false));
}
if (e.key.toLowerCase() === "r" && e.altKey && !e.ctrlKey) {
e.preventDefault();
this.eventBus.emit(new RefreshGraphicsEvent());
@@ -234,35 +250,12 @@ export class InputHandler {
this.eventBus.emit(new AttackRatioEvent(10));
}
if (e.code === "KeyC") {
if (e.code === keybinds.centerCamera) {
e.preventDefault();
this.eventBus.emit(new CenterCameraEvent());
}
// Remove all movement keys from activeKeys
if (
[
"KeyW",
"KeyA",
"KeyS",
"KeyD",
"ArrowUp",
"ArrowLeft",
"ArrowDown",
"ArrowRight",
"Minus",
"Equal",
"KeyE",
"KeyQ",
"Digit1",
"Digit2",
"KeyC",
"ControlLeft",
"ControlRight",
].includes(e.code)
) {
this.activeKeys.delete(e.code);
}
this.activeKeys.delete(e.code);
});
}
+1
View File
@@ -174,6 +174,7 @@ export class LangSelector extends LitElement {
"username-input",
"public-lobby",
"flag-input-modal",
"user-setting",
"o-modal",
"o-button",
];
-1
View File
@@ -127,7 +127,6 @@ export class LocalServer {
}
const pastTurn: Turn = {
turnNumber: this.turns.length,
gameID: this.lobbyConfig.gameStartInfo.gameID,
intents: this.intents,
};
this.turns.push(pastTurn);
+75
View File
@@ -20,6 +20,7 @@ import { JoinPrivateLobbyModal } from "./JoinPrivateLobbyModal";
import "./LangSelector";
import { LangSelector } from "./LangSelector";
import { LanguageModal } from "./LanguageModal";
import { NewsModal } from "./NewsModal";
import "./PublicLobby";
import { PublicLobby } from "./PublicLobby";
import { SinglePlayerModal } from "./SinglePlayerModal";
@@ -27,8 +28,12 @@ import { UserSettingModal } from "./UserSettingModal";
import "./UsernameInput";
import { UsernameInput } from "./UsernameInput";
import { generateCryptoRandomUUID } from "./Utils";
import "./components/NewsButton";
import { NewsButton } from "./components/NewsButton";
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 {
@@ -56,6 +61,23 @@ class Client {
constructor() {}
initialize(): void {
const newsModal = document.querySelector("news-modal") as NewsModal;
if (!newsModal) {
consolex.warn("News modal element not found");
} else {
consolex.log("News modal element found");
}
newsModal instanceof NewsModal;
const newsButton = document.querySelector("news-button") as NewsButton;
if (!newsButton) {
consolex.warn("News button element not found");
} else {
consolex.log("News button element found");
}
// Comment out to show news button.
newsButton.hidden = true;
const langSelector = document.querySelector(
"lang-selector",
) as LangSelector;
@@ -81,6 +103,13 @@ class Client {
consolex.warn("Dark mode 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;
@@ -114,6 +143,12 @@ class Client {
}
});
// const ctModal = document.querySelector("chat-modal") as ChatModal;
// ctModal instanceof ChatModal;
// document.getElementById("chat-button").addEventListener("click", () => {
// ctModal.open();
// });
const hlpModal = document.querySelector("help-modal") as HelpModal;
hlpModal instanceof HelpModal;
document.getElementById("help-button").addEventListener("click", () => {
@@ -127,6 +162,41 @@ class Client {
document.getElementById("flag-input_").addEventListener("click", () => {
flagInputModal.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",
@@ -310,6 +380,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
+77 -99
View File
@@ -1,130 +1,108 @@
export class MultiTabDetector {
private focusChanges: number[] = [];
private readonly maxFocusChanges: number = 10;
private readonly timeWindow: number = 60_000;
private readonly punishmentDelays: number[] = [
2_000, 3_000, 5_000, 10_000, 30_000, 60_000,
];
private lastFocusChangeTime: number = 0;
private isPunished: boolean = false;
private isMonitoring: boolean = false;
private startPenaltyCallback?: (duration: number) => void;
private readonly tabId = `${Date.now()}-${Math.random()}`;
private readonly lockKey = "multi-tab-lock";
private readonly heartbeatIntervalMs = 1_000;
private readonly staleThresholdMs = 3_000;
private numPunishmentsGiven = 0;
private heartbeatTimer: number | null = null;
private isPunished = false;
private punishmentCount = 0;
private startPenaltyCallback: (duration: number) => void = () => {};
constructor() {
window.addEventListener("storage", this.onStorageEvent.bind(this));
window.addEventListener("beforeunload", this.onBeforeUnload.bind(this));
}
/**
* Start monitoring for multi-tabbing behavior
*
* @param startPenalty Callback function when punishment starts
*/
public startMonitoring(startPenalty: (duration: number) => void): void {
if (this.isMonitoring) return;
this.isMonitoring = true;
this.startPenaltyCallback = startPenalty;
// Event listeners for window focus/blur
window.addEventListener("blur", this.handleFocusChange.bind(this));
window.addEventListener("focus", this.handleFocusChange.bind(this));
// Also track visibility changes for tab switching
document.addEventListener(
"visibilitychange",
this.handleVisibilityChange.bind(this),
this.writeLock();
this.heartbeatTimer = window.setInterval(
() => this.heartbeat(),
this.heartbeatIntervalMs,
);
}
public stopMonitoring(): void {
if (!this.isMonitoring) return;
this.isMonitoring = false;
// Remove event listeners
window.removeEventListener("blur", this.handleFocusChange.bind(this));
window.removeEventListener("focus", this.handleFocusChange.bind(this));
document.removeEventListener(
"visibilitychange",
this.handleVisibilityChange.bind(this),
);
// Clear data
this.focusChanges = [];
this.isPunished = false;
}
private handleFocusChange(): void {
const currentTime = Date.now();
this.recordFocusChange(currentTime);
// Check for multi-tabbing when focus is gained
if (document.hasFocus() && !this.isPunished) {
this.checkForMultiTabbing(currentTime);
if (this.heartbeatTimer !== null) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
private handleVisibilityChange(): void {
const currentTime = Date.now();
// Record and check regardless of current focus state
this.recordFocusChange(currentTime);
// Only check when tab becomes visible
if (document.visibilityState === "visible" && !this.isPunished) {
this.checkForMultiTabbing(currentTime);
const lock = this.readLock();
if (lock?.owner === this.tabId) {
localStorage.removeItem(this.lockKey);
}
window.removeEventListener("storage", this.onStorageEvent.bind(this));
window.removeEventListener("beforeunload", this.onBeforeUnload.bind(this));
}
private recordFocusChange(timestamp: number): void {
if (Math.abs(this.lastFocusChangeTime - timestamp) < 100) {
// Don't count multiple triggers at same time
private heartbeat(): void {
const now = Date.now();
const lock = this.readLock();
if (
!lock ||
lock.owner === this.tabId ||
now - lock.timestamp > this.staleThresholdMs
) {
this.writeLock();
this.isPunished = false;
return;
}
this.focusChanges.push(timestamp);
console.log(`pushing focus change at ${timestamp}`);
this.lastFocusChangeTime = timestamp;
// Keep only recent changes
if (this.focusChanges.length > this.maxFocusChanges) {
this.focusChanges.shift();
if (!this.isPunished) {
this.applyPunishment();
}
}
private checkForMultiTabbing(currentTime: number): void {
// Only if we have enough data points
if (this.focusChanges.length >= this.maxFocusChanges) {
const oldestChange = this.focusChanges[0];
const timeSpan = currentTime - oldestChange;
// If changes happened within detection window
if (timeSpan <= this.timeWindow) {
private onStorageEvent(e: StorageEvent): void {
if (e.key === this.lockKey && e.newValue) {
let other: { owner: string; timestamp: number };
try {
other = JSON.parse(e.newValue);
} catch (e) {
console.error("Failed to parse lock", e);
return;
}
if (other.owner !== this.tabId && !this.isPunished) {
this.applyPunishment();
}
}
}
private onBeforeUnload(): void {
const lock = this.readLock();
if (lock?.owner === this.tabId) {
localStorage.removeItem(this.lockKey);
}
}
private applyPunishment(): void {
// Prevent multiple punishments
if (this.isPunished) return;
this.isPunished = true;
let punishmentDelay = 0;
if (this.numPunishmentsGiven >= this.punishmentDelays.length) {
punishmentDelay = this.punishmentDelays[this.punishmentDelays.length - 1];
} else {
punishmentDelay = this.punishmentDelays[this.numPunishmentsGiven];
}
this.numPunishmentsGiven++;
// Call the start penalty callback
if (this.startPenaltyCallback) {
this.startPenaltyCallback(punishmentDelay);
}
// Remove penalty after delay
this.punishmentCount++;
const delay = 10_000;
this.startPenaltyCallback(delay);
setTimeout(() => {
this.isPunished = false;
}, punishmentDelay);
}, delay);
}
private writeLock(): void {
localStorage.setItem(
this.lockKey,
JSON.stringify({ owner: this.tabId, timestamp: Date.now() }),
);
}
private readLock(): { owner: string; timestamp: number } | null {
const raw = localStorage.getItem(this.lockKey);
if (!raw) return null;
try {
return JSON.parse(raw);
} catch (e) {
console.error("Failed to parse lock", raw, e);
return null;
}
}
}
+65
View File
@@ -0,0 +1,65 @@
import { LitElement, css, html } from "lit";
import { customElement, query } from "lit/decorators.js";
import { translateText } from "../client/Utils";
import "./components/baseComponents/Button";
import "./components/baseComponents/Modal";
@customElement("news-modal")
export class NewsModal extends LitElement {
@query("o-modal") private modalEl!: HTMLElement & {
open: () => void;
close: () => void;
};
static styles = css`
.news-container {
max-height: 60vh;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.news-content {
color: #ddd;
line-height: 1.5;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 1rem;
}
`;
render() {
return html`
<o-modal title=${translateText("news.title")}>
<div class="options-layout">
<div class="options-section">
<div class="news-container">
<div class="news-content">INSERT NEWS HERE</div>
</div>
</div>
</div>
<o-button
title=${translateText("common.close")}
@click=${this.close}
blockDesktop
></o-button>
</o-modal>
`;
}
public open() {
this.requestUpdate();
this.modalEl?.open();
}
private close() {
this.modalEl?.close();
}
createRenderRoot() {
return this; // light DOM
}
}
+6 -1
View File
@@ -94,6 +94,11 @@ export class PublicLobby extends LitElement {
const playersRemainingBeforeMax =
lobby.gameConfig.maxPlayers - lobby.numClients;
const teamCount =
lobby.gameConfig.gameMode === GameMode.Team
? lobby.gameConfig.playerTeams || 0
: null;
return html`
<button
@click=${() => this.lobbyClicked(lobby)}
@@ -127,7 +132,7 @@ export class PublicLobby extends LitElement {
</div>
<div class="text-md font-medium text-blue-100">
${lobby.gameConfig.gameMode == GameMode.Team
? translateText("game_mode.teams")
? translateText("public_lobby.teams", { num: teamCount })
: translateText("game_mode.ffa")}
</div>
</div>
+64 -20
View File
@@ -5,9 +5,11 @@ import { translateText } from "../client/Utils";
import { consolex } from "../core/Consolex";
import {
Difficulty,
Duos,
GameMapType,
GameMode,
GameType,
UnitType,
mapCategories,
} from "../core/game/Game";
import { generateID } from "../core/Util";
@@ -36,7 +38,9 @@ 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;
@state() private disabledUnits: string[] = [];
render() {
return html`
@@ -165,7 +169,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
@@ -268,22 +272,61 @@ export class SinglePlayerModal extends LitElement {
${translateText("single_modal.infinite_troops")}
</div>
</label>
</div>
<label
for="singleplayer-modal-disable-nukes"
class="option-card ${this.disableNukes ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="singleplayer-modal-disable-nukes"
@change=${this.handleDisableNukesChange}
.checked=${this.disableNukes}
/>
<div class="option-card-title">
${translateText("single_modal.disable_nukes")}
</div>
</label>
<hr
style="width: 100%; border-top: 1px solid #444; margin: 16px 0;"
/>
<div
style="margin: 8px 0 12px 0; font-weight: bold; color: #ccc; text-align: center;"
>
${translateText("single_modal.enables_title")}
</div>
<div
style="display: flex; flex-wrap: wrap; justify-content: center; gap: 12px;"
>
${[
[UnitType.City, "unit_type.city"],
[UnitType.DefensePost, "unit_type.defense_post"],
[UnitType.Port, "unit_type.port"],
[UnitType.Warship, "unit_type.warship"],
[UnitType.MissileSilo, "unit_type.missile_silo"],
[UnitType.SAMLauncher, "unit_type.sam_launcher"],
[UnitType.AtomBomb, "unit_type.atom_bomb"],
[UnitType.HydrogenBomb, "unit_type.hydrogen_bomb"],
[UnitType.MIRV, "unit_type.mirv"],
].map(
([unitType, translationKey]) => html`
<label
class="option-card ${this.disabledUnits.includes(unitType)
? ""
: "selected"}"
style="width: 140px;"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
@change=${(e: Event) => {
const checked = (e.target as HTMLInputElement).checked;
if (checked) {
this.disabledUnits = [
...this.disabledUnits,
unitType,
];
} else {
this.disabledUnits = this.disabledUnits.filter(
(u) => u !== unitType,
);
}
}}
.checked=${this.disabledUnits.includes(unitType)}
/>
<div class="option-card-title" style="text-align: center;">
${translateText(translationKey)}
</div>
</label>
`,
)}
</div>
</div>
</div>
@@ -355,8 +398,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 +453,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,
@@ -418,6 +461,7 @@ export class SinglePlayerModal extends LitElement {
infiniteGold: this.infiniteGold,
infiniteTroops: this.infiniteTroops,
instantBuild: this.instantBuild,
disabledUnits: this.disabledUnits,
},
},
} as JoinLobbyEvent,
+61 -58
View File
@@ -13,13 +13,13 @@ import {
import { PlayerView } from "../core/game/GameView";
import {
AllPlayersStats,
ClientHashMessage,
ClientID,
ClientIntentMessageSchema,
ClientJoinMessageSchema,
ClientLogMessageSchema,
ClientMessageSchema,
ClientPingMessageSchema,
ClientSendWinnerSchema,
ClientIntentMessage,
ClientJoinMessage,
ClientLogMessage,
ClientPingMessage,
ClientSendWinnerMessage,
Intent,
ServerMessage,
ServerMessageSchema,
@@ -68,8 +68,9 @@ export class SendAttackIntentEvent implements GameEvent {
export class SendBoatAttackIntentEvent implements GameEvent {
constructor(
public readonly targetID: PlayerID,
public readonly cell: Cell,
public readonly dst: Cell,
public readonly troops: number,
public readonly src: Cell | null = null,
) {}
}
@@ -87,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,
) {}
}
@@ -107,6 +108,15 @@ export class SendDonateTroopsIntentEvent implements GameEvent {
) {}
}
export class SendQuickChatEvent implements GameEvent {
constructor(
public readonly sender: PlayerView,
public readonly recipient: PlayerView,
public readonly quickChatKey: string,
public readonly variables: { [key: string]: string },
) {}
}
export class SendEmbargoIntentEvent implements GameEvent {
constructor(
public readonly sender: PlayerView,
@@ -195,6 +205,7 @@ export class Transport {
this.eventBus.on(SendDonateTroopsIntentEvent, (e) =>
this.onSendDonateTroopIntent(e),
);
this.eventBus.on(SendQuickChatEvent, (e) => this.onSendQuickChatIntent(e));
this.eventBus.on(SendEmbargoIntentEvent, (e) =>
this.onSendEmbargoIntent(e),
);
@@ -221,14 +232,9 @@ export class Transport {
this.pingInterval = window.setInterval(() => {
if (this.socket != null && this.socket.readyState === WebSocket.OPEN) {
this.sendMsg(
JSON.stringify(
ClientPingMessageSchema.parse({
type: "ping",
clientID: this.lobbyConfig.clientID,
persistentID: this.lobbyConfig.persistentID,
gameID: this.lobbyConfig.gameID,
}),
),
JSON.stringify({
type: "ping",
} satisfies ClientPingMessage),
);
}
}, 5 * 1000);
@@ -314,32 +320,25 @@ export class Transport {
private onSendLogEvent(event: SendLogEvent) {
this.sendMsg(
JSON.stringify(
ClientLogMessageSchema.parse({
type: "log",
gameID: this.lobbyConfig.gameID,
clientID: this.lobbyConfig.clientID,
persistentID: this.lobbyConfig.persistentID,
log: event.log,
severity: event.severity,
}),
),
JSON.stringify({
type: "log",
log: event.log,
severity: event.severity,
} satisfies ClientLogMessage),
);
}
joinGame(numTurns: number) {
this.sendMsg(
JSON.stringify(
ClientJoinMessageSchema.parse({
type: "join",
gameID: this.lobbyConfig.gameID,
clientID: this.lobbyConfig.clientID,
lastTurn: numTurns,
persistentID: this.lobbyConfig.persistentID,
username: this.lobbyConfig.playerName,
flag: this.lobbyConfig.flag,
}),
),
JSON.stringify({
type: "join",
gameID: this.lobbyConfig.gameID,
clientID: this.lobbyConfig.clientID,
lastTurn: numTurns,
persistentID: this.lobbyConfig.persistentID,
username: this.lobbyConfig.playerName,
flag: this.lobbyConfig.flag,
} satisfies ClientJoinMessage),
);
}
@@ -414,8 +413,10 @@ export class Transport {
clientID: this.lobbyConfig.clientID,
targetID: event.targetID,
troops: event.troops,
x: event.cell.x,
y: event.cell.y,
dstX: event.dst.x,
dstY: event.dst.y,
srcX: event.src?.x,
srcY: event.src?.y,
});
}
@@ -455,6 +456,16 @@ export class Transport {
});
}
private onSendQuickChatIntent(event: SendQuickChatEvent) {
this.sendIntent({
type: "quick_chat",
clientID: this.lobbyConfig.clientID,
recipient: event.recipient.id(),
quickChatKey: event.quickChatKey,
variables: event.variables,
});
}
private onSendEmbargoIntent(event: SendEmbargoIntentEvent) {
this.sendIntent({
type: "embargo",
@@ -496,15 +507,12 @@ export class Transport {
private onSendWinnerEvent(event: SendWinnerEvent) {
if (this.isLocal || this.socket.readyState === WebSocket.OPEN) {
const msg = ClientSendWinnerSchema.parse({
const msg = {
type: "winner",
clientID: this.lobbyConfig.clientID,
persistentID: this.lobbyConfig.persistentID,
gameID: this.lobbyConfig.gameID,
winner: event.winner,
allPlayersStats: event.allPlayersStats,
winnerType: event.winnerType,
});
} satisfies ClientSendWinnerMessage;
this.sendMsg(JSON.stringify(msg));
} else {
console.log(
@@ -517,15 +525,13 @@ export class Transport {
private onSendHashEvent(event: SendHashEvent) {
if (this.isLocal || this.socket.readyState === WebSocket.OPEN) {
const msg = ClientMessageSchema.parse({
type: "hash",
clientID: this.lobbyConfig.clientID,
persistentID: this.lobbyConfig.persistentID,
gameID: this.lobbyConfig.gameID,
turnNumber: event.tick,
hash: event.hash,
});
this.sendMsg(JSON.stringify(msg));
this.sendMsg(
JSON.stringify({
type: "hash",
turnNumber: event.tick,
hash: event.hash,
} satisfies ClientHashMessage),
);
} else {
console.log(
"WebSocket is not open. Current state:",
@@ -554,13 +560,10 @@ export class Transport {
private sendIntent(intent: Intent) {
if (this.isLocal || this.socket.readyState === WebSocket.OPEN) {
const msg = ClientIntentMessageSchema.parse({
const msg = {
type: "intent",
clientID: this.lobbyConfig.clientID,
persistentID: this.lobbyConfig.persistentID,
gameID: this.lobbyConfig.gameID,
intent: intent,
});
} satisfies ClientIntentMessage;
this.sendMsg(JSON.stringify(msg));
} else {
console.log(
+271 -86
View File
@@ -1,6 +1,9 @@
import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { translateText } from "../client/Utils";
import { UserSettings } from "../core/game/UserSettings";
import "./components/baseComponents/setting/SettingKeybind";
import { SettingKeybind } from "./components/baseComponents/setting/SettingKeybind";
import "./components/baseComponents/setting/SettingNumber";
import "./components/baseComponents/setting/SettingSlider";
import "./components/baseComponents/setting/SettingToggle";
@@ -9,7 +12,8 @@ import "./components/baseComponents/setting/SettingToggle";
export class UserSettingModal extends LitElement {
private userSettings: UserSettings = new UserSettings();
@state() private darkMode: boolean = this.userSettings.darkMode();
@state() private settingsMode: "basic" | "keybinds" = "basic";
@state() private keybinds: Record<string, string> = {};
@state() private keySequence: string[] = [];
@state() private showEasterEggSettings = false;
@@ -17,6 +21,15 @@ export class UserSettingModal extends LitElement {
connectedCallback() {
super.connectedCallback();
window.addEventListener("keydown", this.handleKeyDown);
const savedKeybinds = localStorage.getItem("settings.keybinds");
if (savedKeybinds) {
try {
this.keybinds = JSON.parse(savedKeybinds);
} catch (e) {
console.warn("Invalid keybinds JSON:", e);
}
}
}
@query("o-modal") private modalEl!: HTMLElement & {
@@ -89,6 +102,15 @@ export class UserSettingModal extends LitElement {
console.log("🤡 Emojis:", enabled ? "ON" : "OFF");
}
private toggleAnonymousNames(e: CustomEvent<{ checked: boolean }>) {
const enabled = e.detail?.checked;
if (typeof enabled !== "boolean") return;
this.userSettings.set("settings.anonymousNames", enabled);
console.log("🙈 Anonymous Names:", enabled ? "ON" : "OFF");
}
private toggleLeftClickOpensMenu(e: CustomEvent<{ checked: boolean }>) {
const enabled = e.detail?.checked;
if (typeof enabled !== "boolean") return;
@@ -119,96 +141,63 @@ export class UserSettingModal extends LitElement {
}
}
private handleKeybindChange(
e: CustomEvent<{ action: string; value: string }>,
) {
const { action, value } = e.detail;
const prevValue = this.keybinds[action] ?? "";
const values = Object.entries(this.keybinds)
.filter(([k]) => k !== action)
.map(([, v]) => v);
if (values.includes(value) && value !== "Null") {
const popup = document.createElement("div");
popup.className = "setting-popup";
popup.textContent = `The key "${value}" is already assigned to another action.`;
document.body.appendChild(popup);
const element = this.renderRoot.querySelector(
`setting-keybind[action="${action}"]`,
) as SettingKeybind;
if (element) {
element.value = prevValue;
element.requestUpdate();
}
return;
}
this.keybinds = { ...this.keybinds, [action]: value };
localStorage.setItem("settings.keybinds", JSON.stringify(this.keybinds));
}
render() {
return html`
<o-modal title="User Settings">
<o-modal title="${translateText("user_setting.title")}">
<div class="modal-overlay">
<div class="modal-content user-setting-modal">
<div class="flex mb-4 w-full justify-center">
<button
class="w-1/2 text-center px-3 py-1 rounded-l
${this.settingsMode === "basic"
? "bg-white/10 text-white"
: "bg-transparent text-gray-400"}"
@click=${() => (this.settingsMode = "basic")}
>
${translateText("user_setting.tab_basic")}
</button>
<button
class="w-1/2 text-center px-3 py-1 rounded-r
${this.settingsMode === "keybinds"
? "bg-white/10 text-white"
: "bg-transparent text-gray-400"}"
@click=${() => (this.settingsMode = "keybinds")}
>
${translateText("user_setting.tab_keybinds")}
</button>
</div>
<div class="settings-list">
<setting-toggle
label="🌙 Dark Mode"
description="Toggle the sites appearance between light and dark themes"
id="dark-mode-toggle"
.checked=${this.userSettings.darkMode()}
@change=${(e: CustomEvent<{ checked: boolean }>) =>
this.toggleDarkMode(e)}
></setting-toggle>
<setting-toggle
label="😊 Emojis"
description="Toggle whether emojis are shown in game"
id="emoji-toggle"
.checked=${this.userSettings.emojis()}
@change=${this.toggleEmojis}
></setting-toggle>
<setting-toggle
label="🖱️ Left Click to Open Menu"
description="When ON, left-click opens menu and sword button attacks. When OFF, right-click attacks directly."
id="left-click-toggle"
.checked=${this.userSettings.leftClickOpensMenu()}
@change=${this.toggleLeftClickOpensMenu}
></setting-toggle>
<setting-slider
label="⚔️ Attack Ratio"
description="What percentage of your troops to send in an attack (1100%)"
min="1"
max="100"
.value=${Number(
localStorage.getItem("settings.attackRatio") ?? "0.2",
) * 100}
@change=${this.sliderAttackRatio}
></setting-slider>
<setting-slider
label="🪖🛠️ Troops and Workers Ratio"
description="Adjust the balance between troops (for combat) and workers (for gold production) (1100%)"
min="1"
max="100"
.value=${Number(
localStorage.getItem("settings.troopRatio") ?? "0.95",
) * 100}
@change=${this.sliderTroopRatio}
></setting-slider>
${this.showEasterEggSettings
? html`
<setting-slider
label="Writing Speed Multiplier"
description="Adjust how fast you pretend to code (x1x100)"
min="0"
max="100"
value="40"
easter="true"
@change=${(e: CustomEvent) => {
const value = e.detail?.value;
if (typeof value !== "undefined") {
console.log("Changed:", value);
} else {
console.warn("Slider event missing detail.value", e);
}
}}
></setting-slider>
<setting-number
label="Bug Count"
description="How many bugs you're okay with (01000, emotionally)"
value="100"
min="0"
max="1000"
easter="true"
@change=${(e: CustomEvent) => {
const value = e.detail?.value;
if (typeof value !== "undefined") {
console.log("Changed:", value);
} else {
console.warn("Slider event missing detail.value", e);
}
}}
></setting-number>
`
: null}
${this.settingsMode === "basic"
? this.renderBasicSettings()
: this.renderKeybindSettings()}
</div>
</div>
</div>
@@ -216,7 +205,203 @@ 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>
<!-- 🙈 Anonymous Names -->
<setting-toggle
label="${translateText("user_setting.anonymous_names_label")}"
description="${translateText("user_setting.anonymous_names_desc")}"
id="anonymous-names-toggle"
.checked=${this.userSettings.anonymousNames()}
@change=${this.toggleAnonymousNames}
></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();
}
+4
View File
@@ -7,6 +7,7 @@ import { getMapsImage } from "../utilities/Maps";
export const MapDescription: Record<keyof typeof GameMapType, string> = {
World: "World",
Europe: "Europe",
EuropeClassic: "Europe Classic",
Mena: "MENA",
NorthAmerica: "North America",
Oceania: "Oceania",
@@ -24,6 +25,9 @@ export const MapDescription: Record<keyof typeof GameMapType, string> = {
BetweenTwoSeas: "Between Two Seas",
KnownWorld: "Known World",
FaroeIslands: "Faroe Islands",
DeglaciatedAntarctica: "Deglaciated Antarctica",
FalklandIslands: "Falkland Islands",
Baikal: "Baikal",
};
@customElement("map-display")
+64
View File
@@ -0,0 +1,64 @@
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import megaphone from "../../../resources/images/Megaphone.svg";
import { NewsModal } from "../NewsModal";
import { translateText } from "../Utils";
@customElement("news-button")
export class NewsButton extends LitElement {
@property({ type: Boolean })
hidden = false;
static styles = css`
.news-button {
opacity: 0.75;
transition: opacity 0.2s ease;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
border: none;
background: none;
cursor: pointer;
}
.news-button:hover {
opacity: 1;
}
.news-button img {
width: 24px;
height: 24px;
display: block;
margin-left: 12px;
}
.hidden {
display: none !important;
}
`;
private handleClick() {
const newsModal = document.querySelector("news-modal") as NewsModal;
if (newsModal) {
newsModal.open();
}
}
render() {
return html`
<div class="text-center mb-0.5 ${this.hidden ? "hidden" : ""}">
<button class="news-button" @click=${this.handleClick}>
<img src="${megaphone}" alt=${translateText("news.title")} />
</button>
</div>
`;
}
createRenderRoot() {
return this;
}
}
@@ -12,7 +12,7 @@ export class OModal extends LitElement {
.c-modal {
position: fixed;
padding: 1rem;
z-index: 1000;
z-index: 9999;
left: 0;
bottom: 0;
right: 0;
@@ -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();
}
}
File diff suppressed because it is too large Load Diff
+18
View File
@@ -7,6 +7,8 @@ import { RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler";
import { TransformHandler } from "./TransformHandler";
import { UIState } from "./UIState";
import { BuildMenu } from "./layers/BuildMenu";
import { ChatDisplay } from "./layers/ChatDisplay";
import { ChatModal } from "./layers/ChatModal";
import { ControlPanel } from "./layers/ControlPanel";
import { EmojiTable } from "./layers/EmojiTable";
import { EventsDisplay } from "./layers/EventsDisplay";
@@ -87,6 +89,14 @@ export function createRenderer(
eventsDisplay.game = game;
eventsDisplay.clientID = clientID;
const chatDisplay = document.querySelector("chat-display") as ChatDisplay;
if (!(chatDisplay instanceof ChatDisplay)) {
consolex.error("chat display not found");
}
chatDisplay.eventBus = eventBus;
chatDisplay.game = game;
chatDisplay.clientID = clientID;
const playerInfo = document.querySelector(
"player-info-overlay",
) as PlayerInfoOverlay;
@@ -126,6 +136,13 @@ export function createRenderer(
playerPanel.eventBus = eventBus;
playerPanel.emojiTable = emojiTable;
const chatModal = document.querySelector("chat-modal") as ChatModal;
if (!(chatModal instanceof ChatModal)) {
console.error("chat modal not found");
}
chatModal.g = game;
chatModal.eventBus = eventBus;
const multiTabModal = document.querySelector(
"multi-tab-modal",
) as MultiTabModal;
@@ -142,6 +159,7 @@ export function createRenderer(
new UILayer(game, eventBus, clientID, transformHandler),
new NameLayer(game, transformHandler, clientID),
eventsDisplay,
chatDisplay,
buildMenu,
new RadialMenu(
eventBus,
+2 -4
View File
@@ -82,10 +82,8 @@ export const getColoredSprite = (
const territoryColor = customTerritoryColor ?? theme.territoryColor(owner);
const borderColor = customBorderColor ?? theme.borderColor(owner);
const spawnHighlightColor = theme.spawnHighlightColor();
const colorKey = customTerritoryColor
? customTerritoryColor.toRgbString()
: "";
const key = owner.id() + unit.type() + colorKey;
const colorKey = territoryColor.toRgbString() + borderColor.toRgbString();
const key = unit.type() + colorKey;
if (coloredSpriteCache.has(key)) {
return coloredSpriteCache.get(key)!;
+4 -16
View File
@@ -301,7 +301,7 @@ export class BuildMenu extends LitElement implements Layer {
if (!unit) {
return false;
}
return unit[0].canBuild;
return unit[0].canBuild !== false;
}
private cost(item: BuildItemDisplay): number {
@@ -409,21 +409,9 @@ export class BuildMenu extends LitElement implements Layer {
}
private getBuildableUnits(): BuildItemDisplay[][] {
if (this.game?.config()?.disableNukes()) {
return buildTable.map((row) =>
row.filter(
(item) =>
![
UnitType.AtomBomb,
UnitType.MIRV,
UnitType.HydrogenBomb,
UnitType.MissileSilo,
UnitType.SAMLauncher,
].includes(item.unitType),
),
);
}
return buildTable;
return buildTable.map((row) =>
row.filter((item) => !this.game?.config()?.isUnitDisabled(item.unitType)),
);
}
get isVisible() {
+193
View File
@@ -0,0 +1,193 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { DirectiveResult } from "lit/directive.js";
import { unsafeHTML, UnsafeHTMLDirective } from "lit/directives/unsafe-html.js";
import { EventBus } from "../../../core/EventBus";
import { MessageType } from "../../../core/game/Game";
import {
DisplayMessageUpdate,
GameUpdateType,
} from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { ClientID } from "../../../core/Schemas";
import { onlyImages } from "../../../core/Util";
import { Layer } from "./Layer";
interface ChatEvent {
description: string;
unsafeDescription?: boolean;
createdAt: number;
highlight?: boolean;
}
@customElement("chat-display")
export class ChatDisplay extends LitElement implements Layer {
public eventBus: EventBus;
public game: GameView;
public clientID: ClientID;
private active: boolean = false;
private updateMap = new Map([
[GameUpdateType.DisplayEvent, (u) => this.onDisplayMessageEvent(u)],
]);
@state() private _hidden: boolean = false;
@state() private newEvents: number = 0;
@state() private chatEvents: ChatEvent[] = [];
private toggleHidden() {
this._hidden = !this._hidden;
if (this._hidden) {
this.newEvents = 0;
}
this.requestUpdate();
}
private addEvent(event: ChatEvent) {
this.chatEvents = [...this.chatEvents, event];
if (this._hidden) {
this.newEvents++;
}
this.requestUpdate();
}
private removeEvent(index: number) {
this.chatEvents = [
...this.chatEvents.slice(0, index),
...this.chatEvents.slice(index + 1),
];
}
onDisplayMessageEvent(event: DisplayMessageUpdate) {
if (event.messageType !== MessageType.CHAT) return;
const myPlayer = this.game.playerByClientID(this.clientID);
if (
event.playerID != null &&
(!myPlayer || myPlayer.smallID() !== event.playerID)
) {
return;
}
this.addEvent({
description: event.message,
createdAt: this.game.ticks(),
highlight: true,
unsafeDescription: true,
});
}
init() {}
tick() {
// this.active = true;
const updates = this.game.updatesSinceLastTick();
const messages = updates[GameUpdateType.DisplayEvent] as
| DisplayMessageUpdate[]
| undefined;
if (messages) {
for (const msg of messages) {
if (msg.messageType === MessageType.CHAT) {
const myPlayer = this.game.playerByClientID(this.clientID);
if (
msg.playerID != null &&
(!myPlayer || myPlayer.smallID() !== msg.playerID)
) {
continue;
}
this.chatEvents = [
...this.chatEvents,
{
description: msg.message,
unsafeDescription: true,
createdAt: this.game.ticks(),
},
];
}
}
}
if (this.chatEvents.length > 100) {
this.chatEvents = this.chatEvents.slice(-100);
}
this.requestUpdate();
}
private getChatContent(
chat: ChatEvent,
): string | DirectiveResult<typeof UnsafeHTMLDirective> {
return chat.unsafeDescription
? unsafeHTML(onlyImages(chat.description))
: chat.description;
}
render() {
if (!this.active) {
return html``;
}
return html`
<div
class="${this._hidden
? "w-fit px-[10px] py-[5px]"
: ""} rounded-md bg-black bg-opacity-60 relative max-h-[30vh] flex flex-col-reverse overflow-y-auto w-full lg:bottom-2.5 lg:right-2.5 z-50 lg:max-w-[30vw] lg:w-full lg:w-auto"
style="pointer-events: auto"
>
<div>
<div class="w-full bg-black/80 sticky top-0 px-[10px]">
<button
class="text-white cursor-pointer pointer-events-auto ${this
._hidden
? "hidden"
: ""}"
@click=${this.toggleHidden}
>
Hide
</button>
</div>
<button
class="text-white cursor-pointer pointer-events-auto ${this._hidden
? ""
: "hidden"}"
@click=${this.toggleHidden}
>
Chat
<span
class="${this.newEvents
? ""
: "hidden"} inline-block px-2 bg-red-500 rounded-sm"
>${this.newEvents}</span
>
</button>
<table
class="w-full border-collapse text-white shadow-lg lg:text-xl text-xs ${this
._hidden
? "hidden"
: ""}"
style="pointer-events: auto;"
>
<tbody>
${this.chatEvents.map(
(chat) => html`
<tr class="border-b border-opacity-0">
<td class="lg:p-3 p-1 text-left">
${this.getChatContent(chat)}
</td>
</tr>
`,
)}
</tbody>
</table>
</div>
</div>
`;
}
createRenderRoot() {
return this;
}
}
+306
View File
@@ -0,0 +1,306 @@
import { LitElement, html } from "lit";
import { customElement, query } from "lit/decorators.js";
import { PlayerType } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
import quickChatData from "../../../../resources/QuickChat.json";
import { EventBus } from "../../../core/EventBus";
import { SendQuickChatEvent } from "../../Transport";
import { translateText } from "../../Utils";
type QuickChatPhrase = {
key: string;
requiresPlayer: boolean;
};
type QuickChatPhrases = Record<string, QuickChatPhrase[]>;
const quickChatPhrases: QuickChatPhrases = quickChatData;
@customElement("chat-modal")
export class ChatModal extends LitElement {
@query("o-modal") private modalEl!: HTMLElement & {
open: () => void;
close: () => void;
};
createRenderRoot() {
return this;
}
private players: string[] = [];
private playerSearchQuery: string = "";
private previewText: string | null = null;
private requiresPlayerSelection: boolean = false;
private selectedCategory: string | null = null;
private selectedPhraseText: string | null = null;
private selectedPlayer: string | null = null;
private selectedPhraseTemplate: string | null = null;
private selectedQuickChatKey: string | null = null;
private recipient: PlayerView;
private sender: PlayerView;
public eventBus: EventBus;
public g: GameView;
quickChatPhrases: Record<
string,
Array<{ text: string; requiresPlayer: boolean }>
> = {
help: [{ text: "Please give me troops!", requiresPlayer: false }],
attack: [{ text: "Attack [P1]!", requiresPlayer: true }],
defend: [{ text: "Defend [P1]!", requiresPlayer: true }],
greet: [{ text: "Hello!", requiresPlayer: false }],
misc: [{ text: "Let's go!", requiresPlayer: false }],
};
private categories = [
{ id: "help" },
{ id: "attack" },
{ id: "defend" },
{ id: "greet" },
{ id: "misc" },
{ id: "warnings" },
];
private getPhrasesForCategory(categoryId: string) {
return quickChatPhrases[categoryId] ?? [];
}
render() {
const sortedPlayers = [...this.players].sort((a, b) => a.localeCompare(b));
const filteredPlayers = sortedPlayers.filter((player) =>
player.toLowerCase().includes(this.playerSearchQuery),
);
const otherPlayers = sortedPlayers.filter(
(player) => !player.toLowerCase().includes(this.playerSearchQuery),
);
const displayPlayers = [...filteredPlayers, ...otherPlayers];
return html`
<o-modal title="${translateText("chat.title")}">
<div class="chat-columns">
<div class="chat-column">
<div class="column-title">${translateText("chat.category")}</div>
${this.categories.map(
(category) => html`
<button
class="chat-option-button ${this.selectedCategory ===
category.id
? "selected"
: ""}"
@click=${() => this.selectCategory(category.id)}
>
${translateText(`chat.cat.${category.id}`)}
</button>
`,
)}
</div>
${this.selectedCategory
? html`
<div class="chat-column">
<div class="column-title">
${translateText("chat.phrase")}
</div>
<div class="phrase-scroll-area">
${this.getPhrasesForCategory(this.selectedCategory).map(
(phrase) => html`
<button
class="chat-option-button ${this
.selectedPhraseText ===
translateText(
`chat.${this.selectedCategory}.${phrase.key}`,
)
? "selected"
: ""}"
@click=${() => this.selectPhrase(phrase)}
>
${this.renderPhrasePreview(phrase)}
</button>
`,
)}
</div>
</div>
`
: null}
${this.requiresPlayerSelection || this.selectedPlayer
? html`
<div class="chat-column">
<div class="column-title">
${translateText("chat.player")}
</div>
<input
class="player-search-input"
type="text"
placeholder="${translateText("chat.search")}"
.value=${this.playerSearchQuery}
@input=${this.onPlayerSearchInput}
/>
<div class="player-scroll-area">
${this.getSortedFilteredPlayers().map(
(player) => html`
<button
class="chat-option-button ${this.selectedPlayer ===
player
? "selected"
: ""}"
@click=${() => this.selectPlayer(player)}
>
${player}
</button>
`,
)}
</div>
</div>
`
: null}
</div>
<div class="chat-preview">
${this.previewText
? translateText(this.previewText)
: translateText("chat.build")}
</div>
<div class="chat-send">
<button
class="chat-send-button"
@click=${this.sendChatMessage}
?disabled=${!this.previewText}
>
${translateText("chat.send")}
</button>
</div>
</o-modal>
`;
}
private selectCategory(categoryId: string) {
this.selectedCategory = categoryId;
this.selectedPhraseText = null;
this.previewText = null;
this.requiresPlayerSelection = false;
this.selectedPlayer = null;
this.requestUpdate();
}
private selectPhrase(phrase: QuickChatPhrase) {
this.selectedQuickChatKey = this.getFullQuickChatKey(
this.selectedCategory!,
phrase.key,
);
this.selectedPhraseTemplate = translateText(
`chat.${this.selectedCategory}.${phrase.key}`,
);
this.selectedPhraseText = translateText(
`chat.${this.selectedCategory}.${phrase.key}`,
);
this.previewText = `chat.${this.selectedCategory}.${phrase.key}`;
this.requiresPlayerSelection = phrase.requiresPlayer;
this.selectedPlayer = null;
this.requestUpdate();
}
private renderPhrasePreview(phrase: { key: string }) {
return translateText(`chat.${this.selectedCategory}.${phrase.key}`);
}
private selectPlayer(player: string) {
if (this.previewText) {
this.previewText = this.selectedPhraseTemplate.replace("[P1]", player);
this.selectedPlayer = player;
this.requiresPlayerSelection = false;
this.requestUpdate();
}
}
private sendChatMessage() {
console.log("Sent message:", this.previewText);
console.log("Sender:", this.sender);
console.log("Recipient:", this.recipient);
console.log("Key:", this.selectedQuickChatKey);
if (this.sender && this.recipient && this.selectedQuickChatKey) {
const variables = this.selectedPlayer ? { P1: this.selectedPlayer } : {};
this.eventBus.emit(
new SendQuickChatEvent(
this.sender,
this.recipient,
this.selectedQuickChatKey,
variables,
),
);
}
this.previewText = null;
this.selectedCategory = null;
this.requiresPlayerSelection = false;
this.close();
this.requestUpdate();
}
private onPlayerSearchInput(e: Event) {
const target = e.target as HTMLInputElement;
this.playerSearchQuery = target.value.toLowerCase();
this.requestUpdate();
}
private getSortedFilteredPlayers(): string[] {
const sorted = [...this.players].sort((a, b) => a.localeCompare(b));
const filtered = sorted.filter((p) =>
p.toLowerCase().includes(this.playerSearchQuery),
);
const others = sorted.filter(
(p) => !p.toLowerCase().includes(this.playerSearchQuery),
);
return [...filtered, ...others];
}
private getFullQuickChatKey(category: string, phraseKey: string): string {
return `${category}.${phraseKey}`;
}
public open(sender?: PlayerView, recipient?: PlayerView) {
if (sender && recipient) {
console.log("Sent message:", recipient);
console.log("Sent message:", sender);
const alivePlayerNames = this.g
.players()
.filter((p) => p.isAlive() && !(p.data.playerType === PlayerType.Bot))
.map((p) => p.data.name);
console.log("Alive player names:", alivePlayerNames);
this.players = alivePlayerNames;
this.recipient = recipient;
this.sender = sender;
}
this.requestUpdate();
this.modalEl?.open();
}
public close() {
this.selectedCategory = null;
this.selectedPhraseText = null;
this.previewText = null;
this.requiresPlayerSelection = false;
this.selectedPlayer = null;
this.modalEl?.close();
}
public setRecipient(value: PlayerView) {
this.recipient = value;
}
public setSender(value: PlayerView) {
this.sender = value;
}
}
+7 -15
View File
@@ -4,24 +4,11 @@ import { EventBus } from "../../../core/EventBus";
import { AllPlayers } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { TerraNulliusImpl } from "../../../core/game/TerraNulliusImpl";
import { emojiTable, flattenedEmojiTable } from "../../../core/Util";
import { ShowEmojiMenuEvent } from "../../InputHandler";
import { SendEmojiIntentEvent } from "../../Transport";
import { TransformHandler } from "../TransformHandler";
const emojiTable: string[][] = [
["😀", "😊", "🥰", "😇", "😎"],
["😞", "🥺", "😭", "😱", "😡"],
["😈", "🤡", "🖕", "🥱", "🤦‍♂️"],
["👋", "👏", "🤌", "💪", "🫡"],
["👍", "👎", "❓", "🐔", "🐀"],
["🤝", "🆘", "🕊️", "🏳️", "⏳"],
["🔥", "💥", "💀", "☢️", "⚠️"],
["↖️", "⬆️", "↗️", "👑", "🥇"],
["⬅️", "🎯", "➡️", "🥈", "🥉"],
["↙️", "⬇️", "↘️", "❤️", "💔"],
["💰", "⚓", "⛵", "🏡", "🛡️"],
];
@customElement("emoji-table")
export class EmojiTable extends LitElement {
public eventBus: EventBus;
@@ -130,7 +117,12 @@ export class EmojiTable extends LitElement {
targetPlayer == this.game.myPlayer()
? AllPlayers
: (targetPlayer as PlayerView);
this.eventBus.emit(new SendEmojiIntentEvent(recipient, emoji));
this.eventBus.emit(
new SendEmojiIntentEvent(
recipient,
flattenedEmojiTable.indexOf(emoji),
),
);
this.hideTable();
});
});
+44 -1
View File
@@ -16,6 +16,7 @@ import {
AllianceRequestUpdate,
AttackUpdate,
BrokeAllianceUpdate,
DisplayChatMessageUpdate,
DisplayMessageUpdate,
EmojiUpdate,
GameUpdateType,
@@ -33,6 +34,8 @@ import { onlyImages } from "../../../core/Util";
import { renderTroops } from "../../Utils";
import { GoToPlayerEvent, GoToUnitEvent } from "./Leaderboard";
import { translateText } from "../../Utils";
interface Event {
description: string;
unsafeDescription?: boolean;
@@ -77,6 +80,7 @@ export class EventsDisplay extends LitElement implements Layer {
private updateMap = new Map([
[GameUpdateType.DisplayEvent, (u) => this.onDisplayMessageEvent(u)],
[GameUpdateType.DisplayChatEvent, (u) => this.onDisplayChatEvent(u)],
[GameUpdateType.AllianceRequest, (u) => this.onAllianceRequestEvent(u)],
[
GameUpdateType.AllianceRequestReply,
@@ -187,6 +191,34 @@ export class EventsDisplay extends LitElement implements Layer {
});
}
onDisplayChatEvent(event: DisplayChatMessageUpdate) {
const myPlayer = this.game.playerByClientID(this.clientID);
if (
event.playerID === null ||
!myPlayer ||
myPlayer.smallID() !== event.playerID
) {
return;
}
const baseMessage = translateText(`chat.${event.category}.${event.key}`);
const translatedMessage = baseMessage.replace(
/\[([^\]]+)\]/g,
(_, key) => event.variables?.[key] || `[${key}]`,
);
this.addEvent({
description: translateText(event.isFrom ? "chat.from" : "chat.to", {
user: event.recipient,
msg: translatedMessage,
}),
createdAt: this.game.ticks(),
highlight: true,
type: MessageType.CHAT,
unsafeDescription: false,
});
}
onAllianceRequestEvent(update: AllianceRequestUpdate) {
const myPlayer = this.game.playerByClientID(this.clientID);
if (!myPlayer || update.recipientID !== myPlayer.smallID()) {
@@ -271,10 +303,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(),
@@ -386,6 +427,8 @@ export class EventsDisplay extends LitElement implements Layer {
return "text-green-300";
case MessageType.INFO:
return "text-gray-200";
case MessageType.CHAT:
return "text-gray-200";
case MessageType.WARN:
return "text-yellow-300";
case MessageType.ERROR:
+2 -2
View File
@@ -141,7 +141,7 @@ export class Leaderboard extends LitElement implements Layer {
position: fixed;
top: 10px;
left: 10px;
z-index: 9999;
z-index: 9998;
background-color: rgb(31 41 55 / 0.7);
padding: 10px;
padding-top: 0px;
@@ -192,7 +192,7 @@ export class Leaderboard extends LitElement implements Layer {
position: fixed;
left: 10px;
top: 10px;
z-index: 9999;
z-index: 9998;
background-color: rgb(31 41 55 / 0.7);
color: white;
border: none;
+63 -4
View File
@@ -1,5 +1,6 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { GameEnv } from "../../../core/configuration/Config";
import { GameType } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { MultiTabDetector } from "../../MultiTabDetector";
@@ -15,6 +16,9 @@ export class MultiTabModal extends LitElement implements Layer {
@property({ type: Number }) duration: number = 5000;
@state() private countdown: number = 5;
@state() private isVisible: boolean = false;
@state() private fakeIp: string = "";
@state() private deviceFingerprint: string = "";
@state() private reported: boolean = true;
private intervalId?: number;
@@ -26,7 +30,8 @@ export class MultiTabModal extends LitElement implements Layer {
tick() {
if (
this.game.inSpawnPhase() ||
this.game.config().gameConfig().gameType == GameType.Singleplayer
this.game.config().gameConfig().gameType == GameType.Singleplayer ||
this.game.config().serverConfig().env() == GameEnv.Dev
) {
return;
}
@@ -38,6 +43,26 @@ export class MultiTabModal extends LitElement implements Layer {
}
}
init() {
this.fakeIp = this.generateFakeIp();
this.deviceFingerprint = this.generateDeviceFingerprint();
this.reported = true;
}
// Generate fake IP in format xxx.xxx.xxx.xxx
private generateFakeIp(): string {
return Array.from({ length: 4 }, () =>
Math.floor(Math.random() * 255),
).join(".");
}
// Generate fake device fingerprint (32 character hex)
private generateDeviceFingerprint(): string {
return Array.from({ length: 32 }, () =>
Math.floor(Math.random() * 16).toString(16),
).join("");
}
// Show the modal with penalty information
public show(duration: number): void {
if (!this.game.myPlayer()?.isAlive()) {
@@ -98,14 +123,44 @@ export class MultiTabModal extends LitElement implements Layer {
<div
class="relative p-6 bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full m-4 transition-all transform"
>
<h2 class="text-2xl font-bold mb-4 text-red-600 dark:text-red-400">
${translateText("multi_tab.warning")}
</h2>
<div class="flex items-center justify-between mb-4">
<h2 class="text-2xl font-bold text-red-600 dark:text-red-400">
${translateText("multi_tab.warning")}
</h2>
<div
class="px-2 py-1 bg-red-600 text-white text-xs font-bold rounded-full animate-pulse"
>
RECORDING
</div>
</div>
<p class="mb-4 text-gray-800 dark:text-gray-200">
${translateText("multi_tab.detected")}
</p>
<div
class="mb-4 p-3 bg-gray-100 dark:bg-gray-900 rounded-md text-sm font-mono"
>
<div class="flex justify-between mb-1">
<span class="text-gray-500 dark:text-gray-400">IP:</span>
<span class="text-red-600 dark:text-red-400">${this.fakeIp}</span>
</div>
<div class="flex justify-between mb-1">
<span class="text-gray-500 dark:text-gray-400"
>Device Fingerprint:</span
>
<span class="text-red-600 dark:text-red-400"
>${this.deviceFingerprint}</span
>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Reported:</span>
<span class="text-red-600 dark:text-red-400"
>${this.reported ? "TRUE" : "FALSE"}</span
>
</div>
</div>
<p class="mb-4 text-gray-800 dark:text-gray-200">
${translateText("multi_tab.please_wait")}
<span class="font-bold text-xl">${this.countdown}</span>
@@ -124,6 +179,10 @@ export class MultiTabModal extends LitElement implements Layer {
<p class="text-sm text-gray-600 dark:text-gray-400">
${translateText("multi_tab.explanation")}
</p>
<p class="mt-3 text-xs text-red-500 font-semibold">
Repeated violations may result in permanent account suspension.
</p>
</div>
</div>
`;
+44
View File
@@ -4,6 +4,7 @@ import crownIcon from "../../../../resources/images/CrownIcon.svg";
import embargoIcon from "../../../../resources/images/EmbargoIcon.svg";
import nukeRedIcon from "../../../../resources/images/NukeIconRed.svg";
import nukeWhiteIcon from "../../../../resources/images/NukeIconWhite.svg";
import shieldIcon from "../../../../resources/images/ShieldIconBlack.svg";
import targetIcon from "../../../../resources/images/TargetIcon.svg";
import traitorIcon from "../../../../resources/images/TraitorIcon.svg";
import { PseudoRandom } from "../../../core/PseudoRandom";
@@ -45,6 +46,7 @@ export class NameLayer implements Layer {
private embargoIconImage: HTMLImageElement;
private nukeWhiteIconImage: HTMLImageElement;
private nukeRedIconImage: HTMLImageElement;
private shieldIconImage: HTMLImageElement;
private container: HTMLDivElement;
private myPlayer: PlayerView | null = null;
private firstPlace: PlayerView | null = null;
@@ -71,6 +73,8 @@ export class NameLayer implements Layer {
this.nukeWhiteIconImage.src = nukeWhiteIcon;
this.nukeRedIconImage = new Image();
this.nukeRedIconImage.src = nukeRedIcon;
this.shieldIconImage = new Image();
this.shieldIconImage.src = shieldIcon;
}
resizeCanvas() {
@@ -209,6 +213,7 @@ export class NameLayer implements Layer {
nameDiv.style.alignItems = "center";
const nameSpan = document.createElement("span");
nameSpan.className = "player-name-span";
nameSpan.innerHTML = player.name();
nameDiv.appendChild(nameSpan);
element.appendChild(nameDiv);
@@ -223,6 +228,21 @@ export class NameLayer implements Layer {
troopsDiv.style.marginTop = "-5%";
element.appendChild(troopsDiv);
// TODO: enable this for new meta.
// const shieldDiv = document.createElement("div");
// shieldDiv.classList.add("player-shield");
// shieldDiv.style.zIndex = "3";
// shieldDiv.style.marginTop = "-5%";
// shieldDiv.style.display = "flex";
// shieldDiv.style.alignItems = "center";
// shieldDiv.style.gap = "0px";
// shieldDiv.innerHTML = `
// <img src="${this.shieldIconImage.src}" style="width: 16px; height: 16px;" />
// <span style="color: black; font-size: 10px; margin-top: -2px;">0</span>
// `;
// element.appendChild(shieldDiv);
// Start off invisible so it doesn't flash at 0,0
element.style.display = "none";
@@ -276,6 +296,10 @@ export class NameLayer implements Layer {
nameDiv.style.fontSize = `${render.fontSize}px`;
nameDiv.style.lineHeight = `${render.fontSize}px`;
nameDiv.style.color = render.fontColor;
const span = nameDiv.querySelector(".player-name-span");
if (span) {
span.innerHTML = render.player.name();
}
if (flagDiv) {
flagDiv.style.height = `${render.fontSize}px`;
}
@@ -283,6 +307,26 @@ export class NameLayer implements Layer {
troopsDiv.style.color = render.fontColor;
troopsDiv.textContent = renderTroops(render.player.troops());
// TODO: enable this for new meta.
// const density = renderNumber(
// render.player.troops() / render.player.numTilesOwned(),
// );
// const shieldDiv = render.element.querySelector(
// ".player-shield",
// ) as HTMLDivElement;
// const shieldImg = shieldDiv.querySelector("img");
// const shieldNumber = shieldDiv.querySelector("span");
// if (shieldImg) {
// shieldImg.style.width = `${render.fontSize * 0.8}px`;
// shieldImg.style.height = `${render.fontSize * 0.8}px`;
// }
// if (shieldNumber) {
// shieldNumber.style.fontSize = `${render.fontSize * 0.6}px`;
// shieldNumber.style.marginTop = `${-render.fontSize * 0.1}px`;
// shieldNumber.textContent = density;
// }
// Handle icons
const iconsDiv = render.element.querySelector(
".player-icons",
+10
View File
@@ -106,6 +106,10 @@ export class OptionsMenu extends LitElement implements Layer {
this.eventBus.emit(new RefreshGraphicsEvent());
}
private onToggleRandomNameModeButtonClick() {
this.userSettings.toggleRandomName();
}
private onToggleFocusLockedButtonClick() {
this.userSettings.toggleFocusLocked();
this.requestUpdate();
@@ -196,6 +200,12 @@ export class OptionsMenu extends LitElement implements Layer {
title: "Dark Mode",
children: "🌙: " + (this.userSettings.darkMode() ? "On" : "Off"),
})}
${button({
onClick: this.onToggleRandomNameModeButtonClick,
title: "Random name mode",
children:
"🥷: " + (this.userSettings.anonymousNames() ? "On" : "Off"),
})}
${button({
onClick: this.onToggleLeftClickOpensMenu,
title: "Left click",
@@ -218,6 +218,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">
+32 -3
View File
@@ -1,6 +1,7 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import allianceIcon from "../../../../resources/images/AllianceIconWhite.svg";
import chatIcon from "../../../../resources/images/ChatIconWhite.svg";
import donateGoldIcon from "../../../../resources/images/DonateGoldIconWhite.svg";
import donateTroopIcon from "../../../../resources/images/DonateTroopIconWhite.svg";
import emojiIcon from "../../../../resources/images/EmojiIconWhite.svg";
@@ -15,6 +16,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,
@@ -26,6 +28,7 @@ import {
SendTargetPlayerIntentEvent,
} from "../../Transport";
import { renderNumber, renderTroops } from "../../Utils";
import { ChatModal } from "./ChatModal";
import { EmojiTable } from "./EmojiTable";
import { Layer } from "./Layer";
@@ -122,15 +125,27 @@ 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();
});
}
private handleChat(e: Event, sender: PlayerView, other: PlayerView) {
this.ctModal.open(sender, other);
this.hide();
}
private handleTargetClick(e: Event, other: PlayerView) {
e.stopPropagation();
this.eventBus.emit(new SendTargetPlayerIntentEvent(other.id()));
@@ -141,8 +156,12 @@ export class PlayerPanel extends LitElement implements Layer {
return this;
}
private ctModal;
init() {
this.eventBus.on(MouseUpEvent, (e: MouseEvent) => this.hide());
this.ctModal = document.querySelector("chat-modal") as ChatModal;
}
async tick() {
@@ -184,7 +203,9 @@ export class PlayerPanel extends LitElement implements Layer {
let other = this.g.owner(this.tile);
if (!other.isPlayer()) {
throw new Error("Tile is not owned by a player");
this.hide();
console.warn("Tile is not owned by a player");
return;
}
other = other as PlayerView;
@@ -285,6 +306,14 @@ export class PlayerPanel extends LitElement implements Layer {
<!-- Action buttons -->
<div class="flex justify-center gap-2">
<button
@click=${(e) => this.handleChat(e, myPlayer, other)}
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
<img src=${chatIcon} alt="Target" class="w-6 h-6" />
</button>
${canTarget
? html`<button
@click=${(e) => this.handleTargetClick(e, other)}
+33 -12
View File
@@ -8,7 +8,12 @@ import swordIcon from "../../../../resources/images/SwordIconWhite.svg";
import traitorIcon from "../../../../resources/images/TraitorIconWhite.svg";
import { consolex } from "../../../core/Consolex";
import { EventBus } from "../../../core/EventBus";
import { Cell, PlayerActions, TerraNullius } from "../../../core/game/Game";
import {
Cell,
PlayerActions,
TerraNullius,
UnitType,
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { ClientID } from "../../../core/Schemas";
@@ -345,9 +350,12 @@ export class RadialMenu implements Layer {
actions: PlayerActions,
tile: TileRef,
) {
this.activateMenuElement(Slot.Build, "#ebe250", buildIcon, () => {
this.buildMenu.showMenu(tile);
});
if (!this.g.inSpawnPhase()) {
this.activateMenuElement(Slot.Build, "#ebe250", buildIcon, () => {
this.buildMenu.showMenu(tile);
});
}
if (this.g.hasOwner(tile)) {
this.activateMenuElement(Slot.Info, "#64748B", infoIcon, () => {
this.playerPanel.show(actions, tile);
@@ -374,15 +382,28 @@ export class RadialMenu implements Layer {
);
});
}
if (actions.canBoat) {
if (
actions.buildableUnits.find((bu) => bu.type == UnitType.TransportShip)
?.canBuild
) {
this.activateMenuElement(Slot.Boat, "#3f6ab1", boatIcon, () => {
this.eventBus.emit(
new SendBoatAttackIntentEvent(
this.g.owner(tile).id(),
this.clickedCell,
this.uiState.attackRatio * myPlayer.troops(),
),
);
// BestTransportShipSpawn is an expensive operation, so
// we calculate it here and send the spawn tile to other clients.
myPlayer.bestTransportShipSpawn(tile).then((spawn) => {
let spawnTile: Cell | null = null;
if (spawn !== false) {
spawnTile = new Cell(this.g.x(spawn), this.g.y(spawn));
}
this.eventBus.emit(
new SendBoatAttackIntentEvent(
this.g.owner(tile).id(),
this.clickedCell,
this.uiState.attackRatio * myPlayer.troops(),
spawnTile,
),
);
});
});
}
if (actions.canAttack) {
+16 -10
View File
@@ -119,6 +119,14 @@ export class TerritoryLayer implements Layer {
if (!centerTile) {
continue;
}
let color = this.theme.spawnHighlightColor();
if (
this.game.myPlayer() != null &&
this.game.myPlayer() != human &&
this.game.myPlayer().isFriendly(human)
) {
color = this.theme.selfColor();
}
for (const tile of this.game.bfs(
centerTile,
euclDistFN(centerTile, 9, true),
@@ -126,7 +134,7 @@ export class TerritoryLayer implements Layer {
if (!this.game.hasOwner(tile)) {
this.paintHighlightCell(
new Cell(this.game.x(tile), this.game.y(tile)),
this.theme.spawnHighlightColor(),
color,
255,
);
}
@@ -259,15 +267,13 @@ export class TerritoryLayer implements Layer {
)
.filter((u) => u.unit.owner() == owner).length > 0
) {
const useDefendedBorderColor = playerIsFocused
? this.theme.focusedDefendedBorderColor()
: this.theme.defendedBorderColor(owner);
this.paintCell(
this.game.x(tile),
this.game.y(tile),
useDefendedBorderColor,
255,
);
const borderColors = this.theme.defendedBorderColors(owner);
const x = this.game.x(tile);
const y = this.game.y(tile);
const lightTile =
(x % 2 == 0 && y % 2 == 0) || (y % 2 == 1 && x % 2 == 1);
const borderColor = lightTile ? borderColors.light : borderColors.dark;
this.paintCell(x, y, borderColor, 255);
} else {
const useBorderColor = playerIsFocused
? this.theme.focusedBorderColor()
+79 -106
View File
@@ -3,11 +3,7 @@ import { EventBus } from "../../../core/EventBus";
import { ClientID } from "../../../core/Schemas";
import { Theme } from "../../../core/configuration/Config";
import { UnitType } from "../../../core/game/Game";
import {
euclDistFN,
manhattanDistFN,
TileRef,
} from "../../../core/game/GameMap";
import { TileRef } from "../../../core/game/GameMap";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import {
@@ -19,7 +15,11 @@ import { MoveWarshipIntentEvent } from "../../Transport";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
import { getColoredSprite, loadAllSprites } from "../SpriteLoader";
import {
getColoredSprite,
isSpriteReady,
loadAllSprites,
} from "../SpriteLoader";
enum Relationship {
Self,
@@ -69,9 +69,8 @@ export class UnitLayer implements Layer {
if (this.myPlayer == null) {
this.myPlayer = this.game.playerByClientID(this.clientID);
}
this.game.updatesSinceLastTick()?.[GameUpdateType.Unit]?.forEach((unit) => {
this.onUnitEvent(this.game.unit(unit.id));
});
this.updateUnitsSprites();
}
init() {
@@ -198,11 +197,9 @@ export class UnitLayer implements Layer {
this.canvas.height = this.game.height();
this.transportShipTrailCanvas.width = this.game.width();
this.transportShipTrailCanvas.height = this.game.height();
this.game
?.updatesSinceLastTick()
?.[GameUpdateType.Unit]?.forEach((unit) => {
this.onUnitEvent(this.game.unit(unit.id));
});
this.updateUnitsSprites();
this.boatToTrail.forEach((trail, unit) => {
for (const t of trail) {
this.paintCell(
@@ -217,6 +214,34 @@ export class UnitLayer implements Layer {
});
}
private updateUnitsSprites() {
const unitsToUpdate = this.game
.updatesSinceLastTick()
?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id));
unitsToUpdate
?.filter((UnitView) => isSpriteReady(UnitView.type()))
.forEach((unitView) => {
this.clearUnitCells(unitView);
});
unitsToUpdate?.forEach((unitView) => {
this.onUnitEvent(unitView);
});
}
private clearUnitCells(unit: UnitView) {
const sprite = getColoredSprite(unit, this.theme);
const clearsize = sprite.width + 1;
const lastX = this.game.x(unit.lastTile());
const lastY = this.game.y(unit.lastTile());
this.context.clearRect(
lastX - clearsize / 2,
lastY - clearsize / 2,
clearsize,
clearsize,
);
}
private relationship(unit: UnitView): Relationship {
if (this.myPlayer == null) {
return Relationship.Enemy;
@@ -264,22 +289,10 @@ export class UnitLayer implements Layer {
}
private handleWarShipEvent(unit: UnitView) {
const rel = this.relationship(unit);
// Clear previous area
for (const t of this.game.bfs(
unit.lastTile(),
euclDistFN(unit.lastTile(), 6, false),
)) {
this.clearCell(this.game.x(t), this.game.y(t));
}
if (unit.isActive()) {
if (unit.warshipTargetId()) {
this.drawSprite(unit, colord({ r: 200, b: 0, g: 0 }));
} else {
this.drawSprite(unit);
}
if (unit.warshipTargetId()) {
this.drawSprite(unit, colord({ r: 200, b: 0, g: 0 }));
} else {
this.drawSprite(unit);
}
}
@@ -317,46 +330,11 @@ export class UnitLayer implements Layer {
// interception missle from SAM
private handleMissileEvent(unit: UnitView) {
const rel = this.relationship(unit);
const range = 2;
for (const t of this.game.bfs(
unit.lastTile(),
euclDistFN(unit.lastTile(), range, false),
)) {
this.clearCell(this.game.x(t), this.game.y(t));
}
if (unit.isActive()) {
this.drawSprite(unit);
}
this.drawSprite(unit);
}
private handleNuke(unit: UnitView) {
let range = 0;
switch (unit.type()) {
case UnitType.AtomBomb:
range = 4;
break;
case UnitType.HydrogenBomb:
range = 6;
break;
case UnitType.MIRV:
range = 9;
break;
}
for (const t of this.game.bfs(
unit.lastTile(),
euclDistFN(unit.lastTile(), range, false),
)) {
this.clearCell(this.game.x(t), this.game.y(t));
}
if (unit.isActive()) {
this.drawSprite(unit);
}
this.drawSprite(unit);
}
private handleMIRVWarhead(unit: UnitView) {
@@ -377,17 +355,7 @@ export class UnitLayer implements Layer {
}
private handleTradeShipEvent(unit: UnitView) {
// Clear previous area
for (const t of this.game.bfs(
unit.lastTile(),
euclDistFN(unit.lastTile(), 3, false),
)) {
this.clearCell(this.game.x(t), this.game.y(t));
}
if (unit.isActive()) {
this.drawSprite(unit);
}
this.drawSprite(unit);
}
private handleBoatEvent(unit: UnitView) {
@@ -399,29 +367,21 @@ export class UnitLayer implements Layer {
const trail = this.boatToTrail.get(unit);
trail.push(unit.lastTile());
// Clear previous area
for (const t of this.game.bfs(
unit.lastTile(),
manhattanDistFN(unit.lastTile(), 4),
)) {
this.clearCell(this.game.x(t), this.game.y(t));
// Paint trail
for (const t of trail.slice(-1)) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(unit.owner()),
150,
this.transportShipTrailContext,
);
}
if (unit.isActive()) {
// Paint trail
for (const t of trail.slice(-1)) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(unit.owner()),
150,
this.transportShipTrailContext,
);
}
this.drawSprite(unit);
this.drawSprite(unit);
} else {
if (!unit.isActive()) {
for (const t of trail) {
this.clearCell(
this.game.x(t),
@@ -492,7 +452,18 @@ export class UnitLayer implements Layer {
let alternateViewColor = null;
if (this.alternateView) {
const rel = this.relationship(unit);
let rel = this.relationship(unit);
if (unit.type() == UnitType.TradeShip && unit.dstPortId() != null) {
const target = this.game.unit(unit.dstPortId())?.owner();
const myPlayer = this.game.myPlayer();
if (myPlayer != null && target != null) {
if (myPlayer == target) {
rel = Relationship.Self;
} else if (myPlayer.isFriendly(target)) {
rel = Relationship.Ally;
}
}
}
switch (rel) {
case Relationship.Self:
alternateViewColor = this.theme.selfColor();
@@ -513,12 +484,14 @@ export class UnitLayer implements Layer {
alternateViewColor,
);
this.context.drawImage(
sprite,
Math.round(x - sprite.width / 2),
Math.round(y - sprite.height / 2),
sprite.width,
sprite.width,
);
if (unit.isActive()) {
this.context.drawImage(
sprite,
Math.round(x - sprite.width / 2),
Math.round(y - sprite.height / 2),
sprite.width,
sprite.width,
);
}
}
}
+40 -27
View File
@@ -1,38 +1,24 @@
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import mastersIcon from "../../../../resources/images/MastersIcon.png";
import { EventBus } from "../../../core/EventBus";
import { Team } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { PseudoRandom } from "../../../core/PseudoRandom";
import { simpleHash } from "../../../core/Util";
import { SendWinnerEvent } from "../../Transport";
import { Layer } from "./Layer";
// Add this at the top of your file
declare global {
interface Window {
adsbygoogle: unknown[];
}
}
// Add this at the top of your file
declare let adsbygoogle: unknown[];
@customElement("win-modal")
export class WinModal extends LitElement implements Layer {
public game: GameView;
public eventBus: EventBus;
private rand: PseudoRandom;
private hasShownDeathModal = false;
@state()
isVisible = false;
private _title: string;
private won: boolean;
// Override to prevent shadow DOM creation
createRenderRoot() {
@@ -53,7 +39,7 @@ export class WinModal extends LitElement implements Layer {
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
backdrop-filter: blur(5px);
color: white;
width: 300px;
width: 350px;
transition:
opacity 0.3s ease-in-out,
visibility 0.3s ease-in-out;
@@ -77,7 +63,7 @@ export class WinModal extends LitElement implements Layer {
.win-modal h2 {
margin: 0 0 15px 0;
font-size: 24px;
font-size: 26px;
text-align: center;
color: white;
}
@@ -127,7 +113,7 @@ export class WinModal extends LitElement implements Layer {
}
.win-modal h2 {
font-size: 20px;
font-size: 26px;
}
.win-modal button {
@@ -160,7 +146,41 @@ export class WinModal extends LitElement implements Layer {
innerHtml() {
return html`
<div style="text-align: center; margin: 15px 0; line-height: 1.5;"></div>
<div
style="
text-align: center;
margin: 10px 0;
line-height: 1.5;
background-image: url(${mastersIcon});
background-size: 100px;
background-position: center;
background-repeat: no-repeat;
background-blend-mode: overlay;
position: relative;
"
>
<div
style="
margin: 10px 0;
padding: 14px;
background: rgba(0, 0, 0, 0.76);
border-radius: 5px;
position: relative;
z-index: 1;
font-size: 22px;
"
>
Watch the best compete in the
<br />
<a
href="https://openfrontmaster.com/"
target="_blank"
rel="noopener noreferrer"
style="color: #00bfff; font-weight: bold; text-decoration: underline;"
>OpenFront Masters</a
>
</div>
</div>
`;
}
@@ -179,9 +199,7 @@ export class WinModal extends LitElement implements Layer {
window.location.href = "/";
}
init() {
this.rand = new PseudoRandom(simpleHash(this.game.myClientID()));
}
init() {}
tick() {
const myPlayer = this.game.myPlayer();
@@ -194,7 +212,6 @@ export class WinModal extends LitElement implements Layer {
) {
this.hasShownDeathModal = true;
this._title = "You died";
this.won = false;
this.show();
}
this.game.updatesSinceLastTick()[GameUpdateType.Win].forEach((wu) => {
@@ -204,10 +221,8 @@ export class WinModal extends LitElement implements Layer {
);
if (wu.winner == this.game.myPlayer()?.team()) {
this._title = "Your team won!";
this.won = true;
} else {
this._title = `${wu.winner} team has won!`;
this.won = false;
}
this.show();
} else {
@@ -219,10 +234,8 @@ export class WinModal extends LitElement implements Layer {
);
if (winner == this.game.myPlayer()) {
this._title = "You Won!";
this.won = true;
} else {
this._title = `${winner.name()} has won!`;
this.won = false;
}
this.show();
}
+39 -4
View File
@@ -203,17 +203,34 @@
/>
</g>
</svg>
<div class="l-header__highlightText">v21.2</div>
<div class="l-header__highlightText">v22.0</div>
</div>
</header>
<div class="bg-image"></div>
<dark-mode-button></dark-mode-button>
<!-- 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>
<news-button class="mt-3"></news-button>
</div>
<div></div>
<div>
@@ -234,6 +251,12 @@
block
secondary
></o-button>
<!-- <o-button
id="chat-button"
title="Chat Test"
block
secondary
></o-button> -->
</div>
<o-button
@@ -291,6 +314,7 @@
class="w-full sm:w-2/3 sm:fixed sm:right-0 sm:bottom-0 sm:flex justify-end"
style="pointer-events: none"
>
<chat-display></chat-display>
<events-display></events-display>
</div>
<div class="w-full sm:w-1/3 md:max-w-72" style="pointer-events: auto">
@@ -318,18 +342,27 @@
>
Wiki
</a>
<a target="_blank" href="https://discord.gg/openfront" class="t-link">
<a
target="_blank"
href="https://discord.gg/jRpxXvG42t"
class="t-link"
>
<span data-i18n="main.join_discord"> Join the Discord! </span>
</a>
</div>
<div class="l-footer__col t-text-white">
© 2025
<a
href="https://github.com/openfrontio/OpenFrontIO"
class="t-link"
target="_blank"
>
OpenFront.io
©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>
@@ -347,9 +380,11 @@
<player-panel></player-panel>
<help-modal></help-modal>
<dark-mode-button></dark-mode-button>
<chat-modal></chat-modal>
<user-setting></user-setting>
<multi-tab-modal></multi-tab-modal>
<flag-input-modal></flag-input-modal>
<news-modal></news-modal>
<div
id="language-modal"
class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex justify-center items-center"
+210
View File
@@ -0,0 +1,210 @@
import { decodeJwt } from "jose";
import {
RefreshResponseSchema,
TokenPayload,
TokenPayloadSchema,
UserMeResponse,
UserMeResponseSchema,
} from "./ApiSchemas";
function getAudience() {
const { hostname } = new URL(window.location.href);
const domainname = hostname.split(".").slice(-2).join(".");
return domainname;
}
function getApiBase() {
const domainname = getAudience();
return domainname === "localhost"
? (localStorage.getItem("apiHost") ?? "http://localhost:8787")
: `https://api.${domainname}`;
}
function getToken(): string | null {
const { hash } = window.location;
if (hash.startsWith("#")) {
const params = new URLSearchParams(hash.slice(1));
const token = params.get("token");
if (token) {
localStorage.setItem("token", token);
}
// Clean the URL
history.replaceState(
null,
"",
window.location.pathname + window.location.search,
);
}
return localStorage.getItem("token");
}
export function discordLogin() {
window.location.href = `${getApiBase()}/login/discord?redirect_uri=${window.location.href}`;
}
export async function logOut(allSessions: boolean = false) {
const token = localStorage.getItem("token");
if (token === null) return;
localStorage.removeItem("token");
__isLoggedIn = false;
const response = await fetch(
getApiBase() + allSessions ? "/revoke" : "/logout",
{
method: "POST",
headers: {
authorization: `Bearer ${token}`,
},
},
);
if (response.ok === false) {
console.error("Logout failed", response);
return false;
}
return true;
}
let __isLoggedIn: TokenPayload | false | undefined = undefined;
export function isLoggedIn(): TokenPayload | false {
if (__isLoggedIn === undefined) {
__isLoggedIn = _isLoggedIn();
}
return __isLoggedIn;
}
export function _isLoggedIn(): TokenPayload | false {
try {
const token = getToken();
if (!token) {
// console.log("No token found");
return false;
}
// Verify the JWT (requires browser support)
// const jwks = createRemoteJWKSet(
// new URL(getApiBase() + "/.well-known/jwks.json"),
// );
// const { payload, protectedHeader } = await jwtVerify(token, jwks, {
// issuer: getApiBase(),
// audience: getAudience(),
// });
// Decode the JWT
const payload = decodeJwt(token);
const { iss, aud, exp, iat } = payload;
if (iss !== getApiBase()) {
// JWT was not issued by the correct server
console.error(
'unexpected "iss" claim value',
// JSON.stringify(payload, null, 2),
);
logOut();
return false;
}
if (aud !== getAudience()) {
// JWT was not issued for this website
console.error(
'unexpected "aud" claim value',
// JSON.stringify(payload, null, 2),
);
logOut();
return false;
}
const now = Math.floor(Date.now() / 1000);
if (exp !== undefined && now >= exp) {
// JWT expired
console.error(
'after "exp" claim value',
// JSON.stringify(payload, null, 2),
);
logOut();
return false;
}
const refreshAge: number = 6 * 3600; // 6 hours
if (iat !== undefined && now >= iat + refreshAge) {
console.log("Refreshing access token...");
postRefresh().then((success) => {
if (success) {
console.log("Refreshed access token successfully.");
} else {
console.error("Failed to refresh access token.");
}
});
}
const result = TokenPayloadSchema.safeParse(payload);
if (!result.success) {
// Invalid response
console.error(
"Invalid payload",
// JSON.stringify(payload),
JSON.stringify(result.error),
);
return false;
}
return result.data;
} catch (e) {
console.log(e);
return false;
}
}
export async function postRefresh(): Promise<boolean> {
try {
const token = getToken();
if (!token) return false;
// Refresh the JWT
const response = await fetch(getApiBase() + "/refresh", {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
},
});
if (response.status !== 200) return false;
const body = await response.json();
const result = RefreshResponseSchema.safeParse(body);
if (!result.success) {
console.error(
"Invalid response",
JSON.stringify(body),
JSON.stringify(result.error),
);
return false;
}
localStorage.setItem("token", result.data.token);
return true;
} catch (e) {
return false;
}
}
export async function getUserMe(): Promise<UserMeResponse | false> {
try {
const token = getToken();
if (!token) return false;
// Get the user object
const response = await fetch(getApiBase() + "/users/@me", {
headers: {
authorization: `Bearer ${token}`,
},
});
if (response.status !== 200) return false;
const body = await response.json();
const result = UserMeResponseSchema.safeParse(body);
if (!result.success) {
console.error(
"Invalid response",
JSON.stringify(body),
JSON.stringify(result.error),
);
return false;
}
return result.data;
} catch (e) {
return false;
}
}
+6
View File
@@ -9,6 +9,7 @@
@import url("./styles/layout/container.css");
@import url("./styles/components/button.css");
@import url("./styles/components/modal.css");
@import url("./styles/modal/chat.css");
@import url("./styles/components/setting.css");
@import url("./styles/components/controls.css");
* {
@@ -195,6 +196,11 @@ label.option-card:hover {
margin: 8px 0px 0px 0px;
}
#lobbyIdInput {
font-family: monospace;
font-weight: 600;
}
.lobby-id-button {
display: flex;
align-items: center;
+11
View File
@@ -78,3 +78,14 @@
font-size: 14px;
color: #ccc;
}
.setting-input.keybind:hover .key,
.setting-input.keybind:focus .key {
background-color: #333;
box-shadow: 0 2px 0 #222;
}
.setting-input.keybind.listening .key {
background-color: #1d4ed8; /* blue-700 */
box-shadow: 0 2px 0 #0f172a; /* darker blue */
}
+78 -9
View File
@@ -22,6 +22,10 @@
gap: 12px;
}
.setting-item.column {
flex-direction: column;
}
@keyframes rainbow-background {
0% {
background-position: 0% 50%;
@@ -64,6 +68,20 @@
z-index: 9999;
}
.setting-popup {
position: fixed;
top: 40px;
left: 50%;
transform: translate(-50%, -50%) scale(0.9);
padding: 16px 24px;
background: rgba(0, 0, 0, 0.8);
color: #fff;
font-size: 20px;
border-radius: 12px;
animation: fadePop_2 10s ease-out forwards;
z-index: 9999;
}
@keyframes fadePop {
0% {
opacity: 0;
@@ -82,6 +100,25 @@
}
}
@keyframes fadePop_2 {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.6);
}
5% {
opacity: 1;
transform: translate(-50%, -50%) scale(1.05);
}
95% {
opacity: 1;
transform: translate(-50%, -50%) scale(1.05);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.9);
}
}
.setting-item:hover {
background: #2a2a2a;
}
@@ -158,17 +195,14 @@
cursor: pointer;
}
.setting-input.slider::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
border: 2px solid #2196f3;
cursor: pointer;
.setting-input.slider::-moz-range-track {
background-color: #444;
height: 10px;
border-radius: 5px;
}
.setting-input.slider::-moz-range-track {
background: linear-gradient(to right, #2196f3 50%, #444 50%);
.setting-input.slider::-moz-range-progress {
background-color: #2196f3;
height: 10px;
border-radius: 5px;
}
@@ -255,3 +289,38 @@
white-space: normal;
word-break: break-word;
}
.setting-keybind-box {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.setting-keybind-description {
flex: 1;
font-size: 0.75rem;
color: #e5e5e5;
word-break: break-word;
overflow-wrap: break-word;
min-width: 0;
}
.setting-key {
background-color: black;
color: white;
font-weight: 600;
padding: 4px 12px;
border-radius: 6px;
font-family: monospace;
font-size: 0.875rem;
box-shadow: 0 2px 0 #444;
white-space: nowrap;
user-select: none;
outline: none;
}
.setting-key:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
+94
View File
@@ -0,0 +1,94 @@
/* .w. */
.chat-columns {
display: flex;
gap: 16px;
padding: 12px;
overflow-x: auto;
}
.chat-column {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 120px;
}
.column-title {
font-weight: bold;
margin-bottom: 4px;
}
.chat-option-button {
background: #333;
color: white;
border: none;
padding: 8px 12px;
border-radius: 4px;
text-align: left;
cursor: pointer;
}
.chat-option-button.selected {
background-color: #66c;
}
.chat-preview {
margin: 10px 12px;
padding: 10px;
background: #222;
color: white;
border-radius: 6px;
text-align: center;
}
.chat-send {
display: flex;
justify-content: flex-end;
padding: 0 12px 12px;
}
.chat-send-button {
background: #4caf50;
color: white;
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.chat-column {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 140px;
}
.player-search-input {
padding: 6px 8px;
border-radius: 4px;
border: 1px solid #666;
font-size: 14px;
outline: none;
background-color: #fff;
color: #000;
}
.player-scroll-area {
max-height: 240px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
padding-right: 4px;
}
.phrase-scroll-area {
max-height: 280px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
padding-right: 4px;
}
+12
View File
@@ -1,10 +1,14 @@
import africa from "../../../resources/maps/AfricaThumb.webp";
import asia from "../../../resources/maps/AsiaThumb.webp";
import australia from "../../../resources/maps/AustraliaThumb.webp";
import baikal from "../../../resources/maps/BaikalThumb.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 falklandislands from "../../../resources/maps/FalklandIslandsThumb.webp";
import faroeislands from "../../../resources/maps/FaroeIslandsThumb.webp";
import gatewayToTheAtlantic from "../../../resources/maps/GatewayToTheAtlanticThumb.webp";
import iceland from "../../../resources/maps/IcelandThumb.webp";
@@ -28,6 +32,8 @@ export function getMapsImage(map: GameMapType): string {
return oceania;
case GameMapType.Europe:
return europe;
case GameMapType.EuropeClassic:
return europeClassic;
case GameMapType.Mena:
return mena;
case GameMapType.NorthAmerica:
@@ -60,6 +66,12 @@ export function getMapsImage(map: GameMapType): string {
return knownworld;
case GameMapType.FaroeIslands:
return faroeislands;
case GameMapType.DeglaciatedAntarctica:
return deglaciatedAntarctica;
case GameMapType.FalklandIslands:
return falklandislands;
case GameMapType.Baikal:
return baikal;
default:
return "";
}
+52 -17
View File
@@ -4,9 +4,11 @@ import { Executor } from "./execution/ExecutionManager";
import { WinCheckExecution } from "./execution/WinCheckExecution";
import {
AllPlayers,
Cell,
Game,
GameUpdates,
NameViewData,
Nation,
Player,
PlayerActions,
PlayerBorderTiles,
@@ -16,14 +18,16 @@ import {
PlayerType,
} from "./game/Game";
import { createGame } from "./game/GameImpl";
import { TileRef } from "./game/GameMap";
import {
ErrorUpdate,
GameUpdateType,
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(
@@ -33,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,
);
@@ -157,7 +183,6 @@ export class GameRunner {
const player = this.game.player(playerID);
const tile = this.game.ref(x, y);
const actions = {
canBoat: player.canBoat(tile),
canAttack: player.canAttack(tile),
buildableUnits: player.buildableUnits(tile),
canSendEmojiAllPlayers: player.canSendEmoji(AllPlayers),
@@ -194,4 +219,14 @@ export class GameRunner {
borderTiles: player.borderTiles(),
} as PlayerBorderTiles;
}
public bestTransportShipSpawn(
playerID: PlayerID,
targetTile: TileRef,
): TileRef | false {
const player = this.game.player(playerID);
if (!player.isPlayer()) {
throw new Error(`player with id ${playerID} not found`);
}
return player.bestTransportShipSpawn(targetTile);
}
}
+68 -6
View File
@@ -1,11 +1,51 @@
export class PseudoRandom {
// Internal state (two 32-bit integers)
private state0: number;
private state1: number;
// Keep these variables to maintain the exact same interface
private m: number = 0x80000000; // 2**31
private a: number = 1103515245;
private c: number = 12345;
private state: number;
constructor(seed: number) {
// Initialize the XorShift state with seed
this.state0 = seed | 0; // Force to 32-bit integer with bitwise OR
this.state1 = 0x6e2d786c; // Fixed value as second seed (arbitrary prime)
// Ensure non-zero state
if (this.state0 === 0) this.state0 = 1;
// Also set the LCG state variable to maintain interface
this.state = seed % this.m;
if (this.state < 0) this.state += this.m;
// Warm up the generator to improve initial distribution
for (let i = 0; i < 20; i++) {
this._nextIntInternal();
}
}
/**
* Internal function that implements XorShift algorithm
* @returns A 32-bit integer
*/
private _nextIntInternal(): number {
// Get current state
let s1 = this.state0;
const s0 = this.state1;
// Update state using XorShift algorithm (all operations are bitwise)
this.state0 = s0;
s1 ^= s1 << 23;
s1 ^= s1 >>> 17;
s1 ^= s0;
s1 ^= s0 >>> 26;
this.state1 = s1;
// Generate output (force 32-bit integer)
return (this.state0 + this.state1) | 0;
}
/**
@@ -13,7 +53,14 @@ export class PseudoRandom {
* @returns A number between 0 (inclusive) and 1 (exclusive).
*/
next(): number {
this.state = (this.a * this.state + this.c) % this.m;
// Get a 32-bit integer and convert to [0,1) range
// Using >>> 0 to get unsigned interpretation (positive number)
const int = this._nextIntInternal() >>> 0;
// Update the state variable to maintain compatibility with original interface
this.state = int % this.m;
// Convert to [0,1) range - using division for same interface
return this.state / this.m;
}
@@ -31,12 +78,18 @@ export class PseudoRandom {
return this.next() * (max - min) + min;
}
/**
* Generates a random ID (8 characters, alphanumeric).
*/
nextID(): string {
return this.nextInt(0, Math.pow(36, 8)) // 36^8 possibilities
.toString(36) // Convert to base36 (0-9 and a-z)
.padStart(8, "0"); // Ensure 8 chars by padding with zeros
}
/**
* Selects a random element from an array.
*/
randElement<T>(arr: T[]): T {
if (arr.length == 0) {
throw new Error("array must not be empty");
@@ -44,16 +97,25 @@ export class PseudoRandom {
return arr[this.nextInt(0, arr.length)];
}
/**
* Returns true with probability 1/odds.
*/
chance(odds: number): boolean {
return this.nextInt(0, odds) == 0;
}
shuffleArray(array: 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]];
/**
* Returns a shuffled copy of the array using Fisher-Yates algorithm.
*/
shuffleArray<T>(array: T[]): T[] {
// Create a copy to avoid modifying the original array
const arrayCopy = [...array];
for (let i = arrayCopy.length - 1; i >= 0; i--) {
const j = this.nextInt(0, i + 1);
[arrayCopy[i], arrayCopy[j]] = [arrayCopy[j], arrayCopy[i]];
}
return array;
return arrayCopy;
}
}
+45 -33
View File
@@ -1,14 +1,16 @@
import { z } from "zod";
import quickChatData from "../../resources/QuickChat.json" with { type: "json" };
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;
@@ -28,6 +30,7 @@ export type Intent =
| TargetTroopRatioIntent
| BuildUnitIntent
| EmbargoIntent
| QuickChatIntent
| MoveWarshipIntent;
export type AttackIntent = z.infer<typeof AttackIntentSchema>;
@@ -49,6 +52,7 @@ export type TargetTroopRatioIntent = z.infer<
>;
export type BuildUnitIntent = z.infer<typeof BuildUnitIntentSchema>;
export type MoveWarshipIntent = z.infer<typeof MoveWarshipIntentSchema>;
export type QuickChatIntent = z.infer<typeof QuickChatIntentSchema>;
export type Turn = z.infer<typeof TurnSchema>;
export type GameConfig = z.infer<typeof GameConfigSchema>;
@@ -115,15 +119,18 @@ const GameConfigSchema = z.object({
gameType: z.nativeEnum(GameType),
gameMode: z.nativeEnum(GameMode),
disableNPCs: z.boolean(),
disableNukes: z.boolean(),
bots: z.number().int().min(0).max(400),
infiniteGold: z.boolean(),
infiniteTroops: z.boolean(),
instantBuild: z.boolean(),
maxPlayers: z.number().optional(),
numPlayerTeams: z.number().optional(),
disabledUnits: z.array(z.nativeEnum(UnitType)).optional(),
playerTeams: z.union([z.number().optional(), z.literal(Duos)]),
});
export const TeamSchema = z.string();
const SafeString = z
.string()
.regex(
@@ -131,14 +138,12 @@ 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 PersistentIdSchema = z.string().uuid();
const EmojiSchema = z
.number()
.nonnegative()
.max(flattenedEmojiTable.length - 1);
const ID = z
.string()
.regex(/^[a-zA-Z0-9]+$/)
@@ -196,8 +201,10 @@ export const BoatAttackIntentSchema = BaseIntentSchema.extend({
type: z.literal("boat"),
targetID: ID.nullable(),
troops: z.number().nullable(),
x: z.number(),
y: z.number(),
dstX: z.number(),
dstY: z.number(),
srcX: z.number().nullable().optional(),
srcY: z.number().nullable().optional(),
});
export const AllianceRequestIntentSchema = BaseIntentSchema.extend({
@@ -268,6 +275,19 @@ export const MoveWarshipIntentSchema = BaseIntentSchema.extend({
tile: z.number(),
});
export const QuickChatKeySchema = z.enum(
Object.entries(quickChatData).flatMap(([category, entries]) =>
entries.map((entry) => `${category}.${entry.key}`),
) as [string, ...string[]],
);
export const QuickChatIntentSchema = BaseIntentSchema.extend({
type: z.literal("quick_chat"),
recipient: ID,
quickChatKey: QuickChatKeySchema,
variables: z.record(SafeString).optional(),
});
const IntentSchema = z.union([
AttackIntentSchema,
CancelAttackIntentSchema,
@@ -284,11 +304,11 @@ const IntentSchema = z.union([
BuildUnitIntentSchema,
EmbargoIntentSchema,
MoveWarshipIntentSchema,
QuickChatIntentSchema,
]);
export const TurnSchema = z.object({
turnNumber: z.number(),
gameID: ID,
intents: z.array(IntentSchema),
// The hash of the game state at the end of the turn.
hash: z.number().nullable().optional(),
@@ -353,45 +373,40 @@ export const ServerMessageSchema = z.union([
// Client
const ClientBaseMessageSchema = z.object({
type: z.enum(["winner", "join", "intent", "ping", "log", "hash"]),
clientID: ID,
persistentID: SafeString.nullable(), // WARNING: persistent id is private.
gameID: ID,
});
export const ClientSendWinnerSchema = ClientBaseMessageSchema.extend({
export const ClientSendWinnerSchema = z.object({
type: z.literal("winner"),
winner: ID.or(z.nativeEnum(Team)).nullable(),
winner: z.union([ID, TeamSchema]).nullable(),
allPlayersStats: AllPlayersStatsSchema,
winnerType: z.enum(["player", "team"]),
});
export const ClientHashSchema = ClientBaseMessageSchema.extend({
export const ClientHashSchema = z.object({
type: z.literal("hash"),
hash: z.number(),
turnNumber: z.number(),
});
export const ClientLogMessageSchema = ClientBaseMessageSchema.extend({
export const ClientLogMessageSchema = z.object({
type: z.literal("log"),
severity: z.nativeEnum(LogSeverity),
log: ID,
persistentID: SafeString,
});
export const ClientPingMessageSchema = ClientBaseMessageSchema.extend({
export const ClientPingMessageSchema = z.object({
type: z.literal("ping"),
});
export const ClientIntentMessageSchema = ClientBaseMessageSchema.extend({
export const ClientIntentMessageSchema = z.object({
type: z.literal("intent"),
intent: IntentSchema,
});
// WARNING: never send this message to clients.
export const ClientJoinMessageSchema = ClientBaseMessageSchema.extend({
export const ClientJoinMessageSchema = z.object({
type: z.literal("join"),
clientID: ID,
persistentID: PersistentIdSchema, // WARNING: PII
gameID: ID,
lastTurn: z.number(), // The last turn the client saw.
username: SafeString,
flag: SafeString.nullable().optional(),
@@ -410,7 +425,7 @@ export const PlayerRecordSchema = z.object({
clientID: ID,
username: SafeString,
ip: SafeString.nullable(), // WARNING: PII
persistentID: SafeString, // WARNING: PII
persistentID: PersistentIdSchema, // WARNING: PII
});
export const GameRecordSchema = z.object({
@@ -423,10 +438,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"]),
+39 -68
View File
@@ -1,8 +1,8 @@
import DOMPurify from "dompurify";
import { customAlphabet } from "nanoid";
import twemoji from "twemoji";
import { Cell, Game, Player, Team, Unit } from "./game/Game";
import { andFN, GameMap, manhattanDistFN, TileRef } from "./game/GameMap";
import { Cell, Team, Unit } from "./game/Game";
import { GameMap, TileRef } from "./game/GameMap";
import {
AllPlayersStats,
ClientID,
@@ -13,6 +13,11 @@ import {
Turn,
} from "./Schemas";
import {
BOT_NAME_PREFIXES,
BOT_NAME_SUFFIXES,
} from "./execution/utils/BotNames";
export function manhattanDistWrapped(
c1: Cell,
c2: Cell,
@@ -57,72 +62,6 @@ export function distSortUnit(
};
}
// TODO: refactor to new file
export function sourceDstOceanShore(
gm: Game,
src: Player,
tile: TileRef,
): [TileRef | null, TileRef | null] {
const dst = gm.owner(tile);
const srcTile = closestShoreFromPlayer(gm, src, tile);
let dstTile: TileRef | null = null;
if (dst.isPlayer()) {
dstTile = closestShoreFromPlayer(gm, dst as Player, tile);
} else {
dstTile = closestShoreTN(gm, tile, 50);
}
return [srcTile, dstTile];
}
export function targetTransportTile(gm: Game, tile: TileRef): TileRef | null {
const dst = gm.playerBySmallID(gm.ownerID(tile));
let dstTile: TileRef | null = null;
if (dst.isPlayer()) {
dstTile = closestShoreFromPlayer(gm, dst as Player, tile);
} else {
dstTile = closestShoreTN(gm, tile, 50);
}
return dstTile;
}
export function closestShoreFromPlayer(
gm: GameMap,
player: Player,
target: TileRef,
): TileRef | null {
const shoreTiles = Array.from(player.borderTiles()).filter((t) =>
gm.isShore(t),
);
if (shoreTiles.length == 0) {
return null;
}
return shoreTiles.reduce((closest, current) => {
const closestDistance = gm.manhattanDist(target, closest);
const currentDistance = gm.manhattanDist(target, current);
return currentDistance < closestDistance ? current : closest;
});
}
function closestShoreTN(
gm: GameMap,
tile: TileRef,
searchDist: number,
): TileRef {
const tn = Array.from(
gm.bfs(
tile,
andFN((_, t) => !gm.hasOwner(t), manhattanDistFN(tile, searchDist)),
),
)
.filter((t) => gm.isShore(t))
.sort((a, b) => gm.manhattanDist(tile, a) - gm.manhattanDist(tile, b));
if (tn.length == 0) {
return null;
}
return tn[0];
}
export function simpleHash(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
@@ -352,3 +291,35 @@ export function withinInt(num: bigint, min: bigint, max: bigint): bigint {
const atLeastMin = maxInt(num, min);
return minInt(atLeastMin, max);
}
export function createRandomName(
name: string,
playerType: string,
): string | null {
let randomName = null;
if (playerType === "HUMAN") {
const hash = simpleHash(name);
const prefixIndex = hash % BOT_NAME_PREFIXES.length;
const suffixIndex =
Math.floor(hash / BOT_NAME_PREFIXES.length) % BOT_NAME_SUFFIXES.length;
randomName = `👤 ${BOT_NAME_PREFIXES[prefixIndex]} ${BOT_NAME_SUFFIXES[suffixIndex]}`;
}
return randomName;
}
export const emojiTable: string[][] = [
["😀", "😊", "🥰", "😇", "😎"],
["😞", "🥺", "😭", "😱", "😡"],
["😈", "🤡", "🖕", "🥱", "🤦‍♂️"],
["👋", "👏", "🤌", "💪", "🫡"],
["👍", "👎", "❓", "🐔", "🐀"],
["🤝", "🆘", "🕊️", "🏳️", "⏳"],
["🔥", "💥", "💀", "☢️", "⚠️"],
["↖️", "⬆️", "↗️", "👑", "🥇"],
["⬅️", "🎯", "➡️", "🥈", "🥉"],
["↙️", "⬇️", "↘️", "❤️", "💔"],
["💰", "⚓", "⛵", "🏡", "🛡️"],
];
// 2d to 1d array
export const flattenedEmojiTable: string[] = [].concat(...emojiTable);
+10 -5
View File
@@ -2,8 +2,10 @@ import { Colord } from "colord";
import { GameConfig, GameID } from "../Schemas";
import {
Difficulty,
Duos,
Game,
GameMapType,
GameMode,
Gold,
Player,
PlayerInfo,
@@ -26,7 +28,7 @@ export enum GameEnv {
export interface ServerConfig {
turnIntervalMs(): number;
gameCreationRate(): number;
lobbyMaxPlayers(map: GameMapType): number;
lobbyMaxPlayers(map: GameMapType, mode: GameMode): number;
discordRedirectURI(): string;
numWorkers(): number;
workerIndex(gameID: GameID): number;
@@ -43,6 +45,10 @@ export interface ServerConfig {
r2Endpoint(): string;
r2AccessKey(): string;
r2SecretKey(): string;
otelEndpoint(): string;
otelUsername(): string;
otelPassword(): string;
otelEnabled(): boolean;
}
export interface NukeMagnitude {
@@ -60,14 +66,14 @@ export interface Config {
percentageTilesOwnedToWin(): number;
numBots(): number;
spawnNPCs(): boolean;
disableNukes(): boolean;
isUnitDisabled(unitType: UnitType): boolean;
bots(): number;
infiniteGold(): boolean;
infiniteTroops(): boolean;
instantBuild(): boolean;
numSpawnPhaseTurns(): number;
userSettings(): UserSettings;
numPlayerTeams(): number;
playerTeams(): number | typeof Duos;
startManpower(playerInfo: PlayerInfo): number;
populationIncreaseRate(player: Player | PlayerView): number;
@@ -137,9 +143,8 @@ export interface Theme {
territoryColor(playerInfo: PlayerView): Colord;
specialBuildingColor(playerInfo: PlayerView): Colord;
borderColor(playerInfo: PlayerView): Colord;
defendedBorderColor(playerInfo: PlayerView): Colord;
defendedBorderColors(playerInfo: PlayerView): { light: Colord; dark: Colord };
focusedBorderColor(): Colord;
focusedDefendedBorderColor(): Colord;
terrainColor(gm: GameMap, tile: TileRef): Colord;
backgroundColor(): Colord;
falloutColor(): Colord;
+101 -82
View File
@@ -1,5 +1,6 @@
import {
Difficulty,
Duos,
Game,
GameMapType,
GameMode,
@@ -24,6 +25,20 @@ import { pastelTheme } from "./PastelTheme";
import { pastelThemeDark } from "./PastelThemeDark";
export abstract class DefaultServerConfig implements ServerConfig {
otelEnabled(): boolean {
return Boolean(
this.otelEndpoint() && this.otelUsername() && this.otelPassword(),
);
}
otelEndpoint(): string {
return process.env.OTEL_ENDPOINT;
}
otelUsername(): string {
return process.env.OTEL_USERNAME;
}
otelPassword(): string {
return process.env.OTEL_PASSWORD;
}
region(): string {
if (this.env() == GameEnv.Dev) {
return "dev";
@@ -34,7 +49,7 @@ export abstract class DefaultServerConfig implements ServerConfig {
return process.env.GIT_COMMIT;
}
r2Endpoint(): string {
return `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`;
return `https://${process.env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`;
}
r2AccessKey(): string {
return process.env.R2_ACCESS_KEY;
@@ -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,59 +77,68 @@ 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,
].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,
GameMapType.FalklandIslands,
GameMapType.Baikal,
].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();
}
@@ -197,15 +225,18 @@ export class DefaultConfig implements Config {
defensePostDefenseBonus(): number {
return 5;
}
numPlayerTeams(): number {
return this._gameConfig.numPlayerTeams ?? 0;
playerTeams(): number | typeof Duos {
return this._gameConfig.playerTeams ?? 0;
}
spawnNPCs(): boolean {
return !this._gameConfig.disableNPCs;
}
disableNukes(): boolean {
return this._gameConfig.disableNukes;
isUnitDisabled(unitType: UnitType): boolean {
return this._gameConfig.disabledUnits?.includes(unitType) ?? false;
}
bots(): number {
return this._gameConfig.bots;
}
@@ -222,12 +253,7 @@ export class DefaultConfig implements Config {
return 10000 + 150 * Math.pow(dist, 1.1);
}
tradeShipSpawnRate(numberOfPorts: number): number {
if (numberOfPorts <= 3) return 18;
if (numberOfPorts <= 5) return 25;
if (numberOfPorts <= 8) return 35;
if (numberOfPorts <= 10) return 40;
if (numberOfPorts <= 12) return 45;
return 50;
return Math.round(10 * Math.pow(numberOfPorts, 0.6));
}
unitInfo(type: UnitType): UnitInfo {
@@ -396,7 +422,7 @@ export class DefaultConfig implements Config {
return 80;
}
boatMaxNumber(): number {
return 9;
return 3;
}
numSpawnPhaseTurns(): number {
return this._gameConfig.gameType == GameType.Singleplayer ? 100 : 300;
@@ -484,25 +510,18 @@ export class DefaultConfig implements Config {
}
if (defender.isPlayer()) {
const ratio = within(
Math.pow(defender.troops() / attackTroops, 0.4),
0.1,
10,
);
const speedRatio = within(
defender.troops() / (5 * attackTroops),
0.1,
10,
);
return {
attackerTroopLoss:
ratio *
within(defender.troops() / attackTroops, 0.6, 2) *
mag *
0.8 *
largeLossModifier *
(defender.isTraitor() ? this.traitorDefenseDebuff() : 1),
defenderTroopLoss: defender.population() / defender.numTilesOwned(),
tilesPerTickUsed: Math.floor(speedRatio * speed * largeSpeedMalus),
defenderTroopLoss: defender.troops() / defender.numTilesOwned(),
tilesPerTickUsed:
within(defender.troops() / (5 * attackTroops), 0.2, 1.5) *
speed *
largeSpeedMalus,
};
} else {
return {
@@ -638,8 +657,7 @@ export class DefaultConfig implements Config {
}
goldAdditionRate(player: Player): number {
const ratio = Math.pow(player.workers() / player.population(), 1.3);
return Math.floor(Math.sqrt(player.workers()) * ratio * 5);
return Math.sqrt(player.workers() * player.numTilesOwned()) / 200;
}
troopAdjustmentRate(player: Player): number {
@@ -678,7 +696,8 @@ export class DefaultConfig implements Config {
}
structureMinDist(): number {
return 18;
// TODO: Increase this to ~15 once upgradable structures are implemented.
return 1;
}
shellLifetime(): number {
-3
View File
@@ -5,9 +5,6 @@ import { GameEnv, ServerConfig } from "./Config";
import { DefaultConfig, DefaultServerConfig } from "./DefaultConfig";
export class DevServerConfig extends DefaultServerConfig {
r2Bucket(): string {
return "openfront-staging";
}
adminToken(): string {
return "WARNING_DEV_ADMIN_KEY_DO_NOT_USE_IN_PRODUCTION";
}
+17 -20
View File
@@ -1,7 +1,7 @@
import { Colord, colord } from "colord";
import { PseudoRandom } from "../PseudoRandom";
import { simpleHash } from "../Util";
import { PlayerType, Team, TerrainType } from "../game/Game";
import { ColoredTeams, PlayerType, Team, TerrainType } from "../game/Game";
import { GameMap, TileRef } from "../game/GameMap";
import { PlayerView } from "../game/GameView";
import {
@@ -43,24 +43,25 @@ export const pastelTheme = new (class implements Theme {
teamColor(team: Team): Colord {
switch (team) {
case Team.Blue:
case ColoredTeams.Blue:
return blue;
case Team.Red:
case ColoredTeams.Red:
return red;
case Team.Teal:
case ColoredTeams.Teal:
return teal;
case Team.Purple:
case ColoredTeams.Purple:
return purple;
case Team.Yellow:
case ColoredTeams.Yellow:
return yellow;
case Team.Orange:
case ColoredTeams.Orange:
return orange;
case Team.Green:
case ColoredTeams.Green:
return green;
case Team.Bot:
case ColoredTeams.Bot:
return botColor;
default:
return humanColors[simpleHash(team) % humanColors.length];
}
throw new Error(`Missing color for ${team}`);
}
territoryColor(player: PlayerView): Colord {
@@ -97,21 +98,17 @@ export const pastelTheme = new (class implements Theme {
b: Math.max(tc.b - 40, 0),
});
}
defendedBorderColor(player: PlayerView): Colord {
const bc = this.borderColor(player).rgba;
return colord({
r: Math.max(bc.r - 40, 0),
g: Math.max(bc.g - 40, 0),
b: Math.max(bc.b - 40, 0),
});
defendedBorderColors(player: PlayerView): { light: Colord; dark: Colord } {
return {
light: this.territoryColor(player).darken(0.2),
dark: this.territoryColor(player).darken(0.4),
};
}
focusedBorderColor(): Colord {
return colord({ r: 230, g: 230, b: 230 });
}
focusedDefendedBorderColor(): Colord {
return colord({ r: 200, g: 200, b: 200 });
}
terrainColor(gm: GameMap, tile: TileRef): Colord {
const mag = gm.magnitude(tile);
+17 -20
View File
@@ -1,7 +1,7 @@
import { Colord, colord } from "colord";
import { PseudoRandom } from "../PseudoRandom";
import { simpleHash } from "../Util";
import { PlayerType, Team, TerrainType } from "../game/Game";
import { ColoredTeams, PlayerType, Team, TerrainType } from "../game/Game";
import { GameMap, TileRef } from "../game/GameMap";
import { PlayerView } from "../game/GameView";
import {
@@ -43,24 +43,25 @@ export const pastelThemeDark = new (class implements Theme {
teamColor(team: Team): Colord {
switch (team) {
case Team.Blue:
case ColoredTeams.Blue:
return blue;
case Team.Red:
case ColoredTeams.Red:
return red;
case Team.Teal:
case ColoredTeams.Teal:
return teal;
case Team.Purple:
case ColoredTeams.Purple:
return purple;
case Team.Yellow:
case ColoredTeams.Yellow:
return yellow;
case Team.Orange:
case ColoredTeams.Orange:
return orange;
case Team.Green:
case ColoredTeams.Green:
return green;
case Team.Bot:
case ColoredTeams.Bot:
return botColor;
default:
return humanColors[simpleHash(team) % humanColors.length];
}
throw new Error(`Missing color for ${team}`);
}
territoryColor(player: PlayerView): Colord {
@@ -97,21 +98,17 @@ export const pastelThemeDark = new (class implements Theme {
b: Math.max(tc.b - 40, 0),
});
}
defendedBorderColor(player: PlayerView): Colord {
const bc = this.borderColor(player).rgba;
return colord({
r: Math.max(bc.r - 40, 0),
g: Math.max(bc.g - 40, 0),
b: Math.max(bc.b - 40, 0),
});
defendedBorderColors(player: PlayerView): { light: Colord; dark: Colord } {
return {
light: this.territoryColor(player).darken(0.2),
dark: this.territoryColor(player).darken(0.4),
};
}
focusedBorderColor(): Colord {
return colord({ r: 255, g: 255, b: 255 });
}
focusedDefendedBorderColor(): Colord {
return colord({ r: 215, g: 215, b: 215 });
}
terrainColor(gm: GameMap, tile: TileRef): Colord {
const mag = gm.magnitude(tile);
-3
View File
@@ -2,9 +2,6 @@ import { GameEnv } from "./Config";
import { DefaultServerConfig } from "./DefaultConfig";
export const preprodConfig = new (class extends DefaultServerConfig {
r2Bucket(): string {
return "openfront-staging";
}
env(): GameEnv {
return GameEnv.Preprod;
}
-3
View File
@@ -2,9 +2,6 @@ import { GameEnv } from "./Config";
import { DefaultServerConfig } from "./DefaultConfig";
export const prodConfig = new (class extends DefaultServerConfig {
r2Bucket(): string {
return "openfront-prod";
}
numWorkers(): number {
return 6;
}
+4 -17
View File
@@ -21,13 +21,6 @@ export class AttackExecution implements Execution {
private active: boolean = true;
private toConquer: PriorityQueue<TileContainer> =
new PriorityQueue<TileContainer>((a: TileContainer, b: TileContainer) => {
if (a.priority == b.priority) {
if (a.tick == b.tick) {
return 0;
// return this.random.nextInt(-1, 1)
}
return a.tick - b.tick;
}
return a.priority - b.priority;
});
private random = new PseudoRandom(123);
@@ -227,8 +220,6 @@ export class AttackExecution implements Execution {
this.target,
this.border.size + this.random.nextInt(0, 5),
);
// consolex.log(`num tiles per tick: ${numTilesPerTick}`)
// consolex.log(`num execs: ${this.mg.executions().length}`)
while (numTilesPerTick > 0) {
if (this.attack.troops() < 1) {
@@ -279,13 +270,9 @@ export class AttackExecution implements Execution {
continue;
}
this.border.add(neighbor);
let numOwnedByMe = this.mg
const numOwnedByMe = this.mg
.neighbors(neighbor)
.filter((t) => this.mg.owner(t) == this._owner).length;
const dist = 0;
if (numOwnedByMe > 2) {
numOwnedByMe = 10;
}
let mag = 0;
switch (this.mg.terrainType(tile)) {
case TerrainType.Plains:
@@ -301,8 +288,9 @@ export class AttackExecution implements Execution {
this.toConquer.enqueue(
new TileContainer(
neighbor,
dist / 100 + this.random.nextInt(0, 2) - numOwnedByMe + mag,
this.mg.ticks(),
(this.random.nextInt(0, 7) + 10) *
(1 - numOwnedByMe * 0.5 + mag / 2) +
this.mg.ticks(),
),
);
}
@@ -355,6 +343,5 @@ class TileContainer {
constructor(
public readonly tile: TileRef,
public readonly priority: number,
public readonly tick: number,
) {}
}
+20 -11
View File
@@ -6,15 +6,21 @@ import { BotBehavior } from "./utils/BotBehavior";
export class BotExecution implements Execution {
private active = true;
private random: PseudoRandom;
private attackRate: number;
private mg: Game;
private neighborsTerraNullius = true;
private behavior: BotBehavior | null = null;
private attackRate: number;
private attackTick: number;
private triggerRatio: number;
private reserveRatio: number;
constructor(private bot: Player) {
this.random = new PseudoRandom(simpleHash(bot.id()));
this.attackRate = this.random.nextInt(10, 50);
this.attackRate = this.random.nextInt(40, 80);
this.attackTick = this.random.nextInt(0, this.attackRate);
this.triggerRatio = this.random.nextInt(60, 90) / 100;
this.reserveRatio = this.random.nextInt(30, 60) / 100;
}
activeDuringSpawnPhase(): boolean {
@@ -27,17 +33,21 @@ export class BotExecution implements Execution {
}
tick(ticks: number) {
if (ticks % this.attackRate != this.attackTick) return;
if (!this.bot.isAlive()) {
this.active = false;
return;
}
if (ticks % this.attackRate != 0) {
return;
}
if (this.behavior === null) {
this.behavior = new BotBehavior(this.random, this.mg, this.bot, 1 / 20);
this.behavior = new BotBehavior(
this.random,
this.mg,
this.bot,
this.triggerRatio,
this.reserveRatio,
);
}
this.behavior.handleAllianceRequests();
@@ -65,15 +75,14 @@ export class BotExecution implements Execution {
this.neighborsTerraNullius = false;
}
this.behavior.forgetOldEnemies();
this.behavior.checkIncomingAttacks();
const enemy = this.behavior.selectRandomEnemy();
if (!enemy) return;
if (!this.bot.sharesBorderWith(enemy)) return;
this.behavior.sendAttack(enemy);
}
owner(): Player {
return this.bot;
}
isActive(): boolean {
return this.active;
}
+1 -1
View File
@@ -38,7 +38,7 @@ export class CityExecution implements Execution {
this.active = false;
return;
}
this.city = this.player.buildUnit(UnitType.City, 0, spawnTile);
this.city = this.player.buildUnit(UnitType.City, spawnTile, {});
}
if (!this.city.isActive()) {
this.active = false;
+1 -1
View File
@@ -60,8 +60,8 @@ export class ConstructionExecution implements Execution {
}
this.construction = this.player.buildUnit(
UnitType.Construction,
0,
spawnTile,
{},
);
this.cost = this.mg.unitInfo(this.constructionType).cost(this.player);
this.player.removeGold(this.cost);
+4 -1
View File
@@ -65,7 +65,7 @@ export class DefensePostExecution implements Execution {
this.active = false;
return;
}
this.post = this.player.buildUnit(UnitType.DefensePost, 0, spawnTile);
this.post = this.player.buildUnit(UnitType.DefensePost, spawnTile, {});
}
if (!this.post.isActive()) {
this.active = false;
@@ -80,6 +80,9 @@ export class DefensePostExecution implements Execution {
this.target = null;
}
// TODO: Reconsider how/if defense posts target ships.
return;
const ships = this.mg
.nearbyUnits(
this.post.tile(),
+6 -3
View File
@@ -7,6 +7,7 @@ import {
PlayerID,
PlayerType,
} from "../game/Game";
import { flattenedEmojiTable } from "../Util";
export class EmojiExecution implements Execution {
private requestor: Player;
@@ -17,7 +18,7 @@ export class EmojiExecution implements Execution {
constructor(
private senderID: PlayerID,
private recipientID: PlayerID | typeof AllPlayers,
private emoji: string,
private emoji: number,
) {}
init(mg: Game, ticks: number): void {
@@ -38,10 +39,12 @@ export class EmojiExecution implements Execution {
}
tick(ticks: number): void {
const emojiString = flattenedEmojiTable.at(this.emoji);
if (this.requestor.canSendEmoji(this.recipient)) {
this.requestor.sendEmoji(this.recipient, this.emoji);
this.requestor.sendEmoji(this.recipient, emojiString);
if (
this.emoji == "🖕" &&
emojiString == "🖕" &&
this.recipient != AllPlayers &&
this.recipient.type() == PlayerType.FakeHuman
) {
+16 -15
View File
@@ -1,4 +1,4 @@
import { Execution, Game, PlayerInfo, PlayerType } from "../game/Game";
import { Execution, Game } from "../game/Game";
import { PseudoRandom } from "../PseudoRandom";
import { ClientID, GameID, Intent, Turn } from "../Schemas";
import { simpleHash } from "../Util";
@@ -15,6 +15,7 @@ import { EmojiExecution } from "./EmojiExecution";
import { FakeHumanExecution } from "./FakeHumanExecution";
import { MoveWarshipExecution } from "./MoveWarshipExecution";
import { NoOpExecution } from "./NoOpExecution";
import { QuickChatExecution } from "./QuickChatExecution";
import { RetreatExecution } from "./RetreatExecution";
import { SetTargetTroopRatioExecution } from "./SetTargetTroopRatioExecution";
import { SpawnExecution } from "./SpawnExecution";
@@ -65,11 +66,16 @@ export class Executor {
this.mg.ref(intent.x, intent.y),
);
case "boat":
let src = null;
if (intent.srcX != null || intent.srcY != null) {
src = this.mg.ref(intent.srcX, intent.srcY);
}
return new TransportShipExecution(
playerID,
intent.targetID,
this.mg.ref(intent.x, intent.y),
this.mg.ref(intent.dstX, intent.dstY),
intent.troops,
src,
);
case "allianceRequest":
return new AllianceRequestExecution(playerID, intent.recipient);
@@ -103,6 +109,13 @@ export class Executor {
this.mg.ref(intent.x, intent.y),
intent.unit,
);
case "quick_chat":
return new QuickChatExecution(
playerID,
intent.recipient,
intent.quickChatKey,
intent.variables ?? {},
);
default:
throw new Error(`intent type ${intent} not found`);
}
@@ -115,19 +128,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;
}
+52 -45
View File
@@ -4,9 +4,9 @@ import {
Difficulty,
Execution,
Game,
Nation,
Player,
PlayerID,
PlayerInfo,
PlayerType,
Relation,
TerrainType,
@@ -17,7 +17,7 @@ import {
import { euclDistFN, manhattanDistFN, TileRef } from "../game/GameMap";
import { PseudoRandom } from "../PseudoRandom";
import { GameID } from "../Schemas";
import { calculateBoundingBox, simpleHash } from "../Util";
import { calculateBoundingBox, flattenedEmojiTable, simpleHash } from "../Util";
import { ConstructionExecution } from "./ConstructionExecution";
import { EmojiExecution } from "./EmojiExecution";
import { NukeExecution } from "./NukeExecution";
@@ -35,17 +35,28 @@ export class FakeHumanExecution implements Execution {
private mg: Game;
private player: Player = null;
private attackRate: number;
private attackTick: number;
private triggerRatio: number;
private reserveRatio: number;
private lastEmojiSent = new Map<Player, Tick>();
private lastNukeSent: [Tick, TileRef][] = [];
private embargoMalusApplied = new Set<PlayerID>();
private heckleEmoji: number[];
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);
this.triggerRatio = this.random.nextInt(60, 90) / 100;
this.reserveRatio = this.random.nextInt(30, 60) / 100;
this.heckleEmoji = ["🤡", "😡"].map((e) => flattenedEmojiTable.indexOf(e));
}
init(mg: Game) {
@@ -96,19 +107,22 @@ export class FakeHumanExecution implements Execution {
}
tick(ticks: number) {
if (ticks % this.attackRate != this.attackTick) return;
if (this.mg.inSpawnPhase()) {
if (ticks % this.random.nextInt(5, 30) == 0) {
const rl = this.randomLand();
if (rl == null) {
consolex.warn(`cannot spawn ${this.playerInfo.name}`);
return;
}
this.mg.addExecution(new SpawnExecution(this.playerInfo, rl));
const rl = this.randomLand();
if (rl == null) {
consolex.warn(`cannot spawn ${this.nation.playerInfo.name}`);
return;
}
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;
}
@@ -121,7 +135,13 @@ export class FakeHumanExecution implements Execution {
if (this.behavior === null) {
// Player is unavailable during init()
this.behavior = new BotBehavior(this.random, this.mg, this.player, 1 / 5);
this.behavior = new BotBehavior(
this.random,
this.mg,
this.player,
this.triggerRatio,
this.reserveRatio,
);
}
if (this.firstMove) {
@@ -130,10 +150,6 @@ export class FakeHumanExecution implements Execution {
return;
}
if (ticks % this.random.nextInt(40, 80) != 0) {
return;
}
if (
this.player.troops() > 100_000 &&
this.player.targetTroopRatio() > 0.7
@@ -146,7 +162,10 @@ export class FakeHumanExecution implements Execution {
this.handleEnemies();
this.handleUnits();
this.handleEmbargoesToHostileNations();
this.maybeAttack();
}
private maybeAttack() {
const enemyborder = Array.from(this.player.borderTiles())
.flatMap((t) => this.mg.neighbors(t))
.filter(
@@ -174,9 +193,9 @@ export class FakeHumanExecution implements Execution {
const enemies = enemiesWithTN
.filter((o) => o.isPlayer())
.map((o) => o as Player)
.sort((a, b) => a.troops() - b.troops());
// 5% chance to send a random alliance request
if (this.random.chance(20)) {
const toAlly = this.random.randElement(enemies);
if (this.player.canSendAllianceRequest(toAlly)) {
@@ -211,7 +230,7 @@ export class FakeHumanExecution implements Execution {
}
}
shouldDiscourageAttack(other: Player) {
private shouldDiscourageAttack(other: Player) {
if (other.isTraitor()) {
return false;
}
@@ -227,6 +246,8 @@ export class FakeHumanExecution implements Execution {
}
handleEnemies() {
this.behavior.forgetOldEnemies();
this.behavior.checkIncomingAttacks();
this.behavior.assistAllies();
const enemy = this.behavior.selectEnemy();
if (!enemy) return;
@@ -248,7 +269,7 @@ export class FakeHumanExecution implements Execution {
new EmojiExecution(
this.player.id(),
enemy.id(),
this.random.randElement(["🤡", "😡"]),
this.random.randElement(this.heckleEmoji),
),
);
}
@@ -257,8 +278,7 @@ export class FakeHumanExecution implements Execution {
const silos = this.player.units(UnitType.MissileSilo);
if (
silos.length == 0 ||
this.player.gold() <
this.mg.config().unitInfo(UnitType.AtomBomb).cost(this.player) ||
this.player.gold() < this.cost(UnitType.AtomBomb) ||
other.type() == PlayerType.Bot ||
this.player.isOnSameTeam(other)
) {
@@ -378,6 +398,7 @@ export class FakeHumanExecution implements Execution {
other.id(),
closest.y,
this.player.troops() / 5,
null,
),
);
}
@@ -396,36 +417,19 @@ export class FakeHumanExecution implements Execution {
}
return;
}
this.maybeSpawnStructure(
UnitType.City,
2,
(t) => new ConstructionExecution(this.player.id(), t, UnitType.City),
);
this.maybeSpawnStructure(UnitType.City, 2);
if (this.maybeSpawnWarship()) {
return;
}
if (!this.mg.config().disableNukes()) {
this.maybeSpawnStructure(
UnitType.MissileSilo,
1,
(t) =>
new ConstructionExecution(this.player.id(), t, UnitType.MissileSilo),
);
}
this.maybeSpawnStructure(UnitType.MissileSilo, 1);
}
private maybeSpawnStructure(
type: UnitType,
maxNum: number,
build: (tile: TileRef) => Execution,
) {
private maybeSpawnStructure(type: UnitType, maxNum: number) {
const units = this.player.units(type);
if (units.length >= maxNum) {
return;
}
if (
this.player.gold() < this.mg.config().unitInfo(type).cost(this.player)
) {
if (this.player.gold() < this.cost(type)) {
return;
}
const tile = this.randTerritoryTile(this.player);
@@ -436,7 +440,9 @@ export class FakeHumanExecution implements Execution {
if (canBuild == false) {
return;
}
this.mg.addExecution(build(tile));
this.mg.addExecution(
new ConstructionExecution(this.player.id(), tile, type),
);
}
private maybeSpawnWarship(): boolean {
@@ -538,6 +544,7 @@ export class FakeHumanExecution implements Execution {
this.mg.owner(dst).id(),
dst,
this.player.troops() / 5,
null,
),
);
return;
@@ -548,7 +555,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)) {
+10 -22
View File
@@ -10,8 +10,7 @@ import {
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { PathFindResultType } from "../pathfinding/AStar";
import { PathFinder } from "../pathfinding/PathFinding";
import { AirPathFinder } from "../pathfinding/PathFinding";
import { PseudoRandom } from "../PseudoRandom";
import { simpleHash } from "../Util";
import { NukeExecution } from "./NukeExecution";
@@ -30,7 +29,7 @@ export class MirvExecution implements Execution {
private random: PseudoRandom;
private pathFinder: PathFinder;
private pathFinder: AirPathFinder;
private targetPlayer: Player | TerraNullius;
@@ -50,7 +49,7 @@ export class MirvExecution implements Execution {
this.random = new PseudoRandom(mg.ticks() + simpleHash(this.senderID));
this.mg = mg;
this.pathFinder = PathFinder.Mini(mg, 10_000, true);
this.pathFinder = new AirPathFinder(mg, this.random);
this.player = mg.player(this.senderID);
this.targetPlayer = this.mg.owner(this.dst);
@@ -71,7 +70,7 @@ export class MirvExecution implements Execution {
this.active = false;
return;
}
this.nuke = this.player.buildUnit(UnitType.MIRV, 0, spawn);
this.nuke = this.player.buildUnit(UnitType.MIRV, spawn, {});
const x = Math.floor(
(this.mg.x(this.dst) + this.mg.x(this.mg.x(this.nuke.tile()))) / 2,
);
@@ -90,23 +89,12 @@ export class MirvExecution implements Execution {
this.nuke.tile(),
this.separateDst,
);
switch (result.type) {
case PathFindResultType.Completed:
this.nuke.move(result.tile);
this.separate();
this.active = false;
return;
case PathFindResultType.NextTile:
this.nuke.move(result.tile);
break;
case PathFindResultType.Pending:
break;
case PathFindResultType.PathNotFound:
consolex.warn(
`nuke cannot find path from ${this.nuke.tile()} to ${this.dst}`,
);
this.active = false;
return;
if (result === true) {
this.separate();
this.active = false;
return;
} else {
this.nuke.move(result);
}
}
}
+1 -1
View File
@@ -41,7 +41,7 @@ export class MissileSiloExecution implements Execution {
this.active = false;
return;
}
this.silo = this.player.buildUnit(UnitType.MissileSilo, 0, spawn, {
this.silo = this.player.buildUnit(UnitType.MissileSilo, spawn, {
cooldownDuration: this.mg.config().SiloCooldown(),
});
+7 -35
View File
@@ -11,6 +11,7 @@ import {
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { AirPathFinder } from "../pathfinding/PathFinding";
import { PseudoRandom } from "../PseudoRandom";
export class NukeExecution implements Execution {
@@ -20,6 +21,7 @@ export class NukeExecution implements Execution {
private nuke: Unit;
private random: PseudoRandom;
private pathFinder: AirPathFinder;
constructor(
private type: NukeType,
@@ -43,6 +45,7 @@ export class NukeExecution implements Execution {
if (this.speed == -1) {
this.speed = this.mg.config().defaultNukeSpeed();
}
this.pathFinder = new AirPathFinder(mg, this.random);
}
public target(): Player | TerraNullius {
@@ -92,7 +95,7 @@ export class NukeExecution implements Execution {
this.active = false;
return;
}
this.nuke = this.player.buildUnit(this.type, 0, spawn, {
this.nuke = this.player.buildUnit(this.type, spawn, {
detonationDst: this.dst,
});
if (this.mg.hasOwner(this.dst)) {
@@ -143,45 +146,14 @@ export class NukeExecution implements Execution {
return;
}
const r = (this.mg.y(this.dst) * this.mg.x(this.dst)) % 10;
const s = this.speed + (this.mg.ticks() % r);
for (let i = 0; i < this.speed; i++) {
const x = this.mg.x(this.nuke.tile());
const y = this.mg.y(this.nuke.tile());
const dstX = this.mg.x(this.dst);
const dstY = this.mg.y(this.dst);
// If we've reached the destination, detonate
if (x === dstX && y === dstY) {
// Move to next tile
const nextTile = this.pathFinder.nextTile(this.nuke.tile(), this.dst);
if (nextTile === true) {
this.detonate();
return;
}
// Calculate next position
let nextX = x;
let nextY = y;
const ratio = Math.floor(
1 + Math.abs(dstY - y) / (Math.abs(dstX - x) + 1),
);
if (this.random.chance(ratio) && x != dstX) {
if (x < dstX) nextX++;
else if (x > dstX) nextX--;
} else {
if (y < dstY) nextY++;
else if (y > dstY) nextY--;
}
// Move to next tile
const nextTile = this.mg.ref(nextX, nextY);
if (nextTile !== undefined) {
this.nuke.move(nextTile);
} else {
consolex.warn(`invalid tile position ${nextX},${nextY}`);
this.active = false;
return;
}
}
}
+2 -2
View File
@@ -45,7 +45,7 @@ export class PortExecution implements Execution {
this.active = false;
return;
}
this.port = player.buildUnit(UnitType.Port, 0, spawn);
this.port = player.buildUnit(UnitType.Port, spawn, {});
}
if (!this.port.isActive()) {
@@ -76,7 +76,7 @@ export class PortExecution implements Execution {
}
const port = this.random.randElement(ports);
const pf = PathFinder.Mini(this.mg, 2500, false);
const pf = PathFinder.Mini(this.mg, 2500);
this.mg.addExecution(
new TradeShipExecution(this.player().id(), this.port, port, pf),
);
+84
View File
@@ -0,0 +1,84 @@
import { consolex } from "../Consolex";
import { Execution, Game, Player, PlayerID } from "../game/Game";
export class QuickChatExecution implements Execution {
private sender: Player;
private recipient: Player;
private mg: Game;
private active = true;
constructor(
private senderID: PlayerID,
private recipientID: PlayerID,
private quickChatKey: string,
private variables: Record<string, string>,
) {}
init(mg: Game, ticks: number): void {
this.mg = mg;
if (!mg.hasPlayer(this.senderID)) {
consolex.warn(`QuickChatExecution: sender ${this.senderID} not found`);
this.active = false;
return;
}
if (!mg.hasPlayer(this.recipientID)) {
consolex.warn(
`QuickChatExecution: recipient ${this.recipientID} not found`,
);
this.active = false;
return;
}
this.sender = mg.player(this.senderID);
this.recipient = mg.player(this.recipientID);
}
tick(ticks: number): void {
const message = this.getMessageFromKey(this.quickChatKey, this.variables);
this.mg.displayChat(
message[1],
message[0],
this.variables,
this.recipient.id(),
true,
this.recipient.name(),
);
this.mg.displayChat(
message[1],
message[0],
this.variables,
this.sender.id(),
false,
this.recipient.name(),
);
consolex.log(
`[QuickChat] ${this.sender.name}${this.recipient.name}: ${message}`,
);
this.active = false;
}
owner(): Player {
return this.sender;
}
isActive(): boolean {
return this.active;
}
activeDuringSpawnPhase(): boolean {
return false;
}
private getMessageFromKey(
fullKey: string,
vars: Record<string, string>,
): string[] {
const translated = fullKey.split(".");
return translated;
}
}
+13 -18
View File
@@ -17,9 +17,6 @@ export class SAMLauncherExecution implements Execution {
private mg: Game;
private active: boolean = true;
private target: Unit = null;
private warheadTargets: Unit[] = [];
private searchRangeRadius = 80;
// As MIRV go very fast we have to detect them very early but we only
// shoot the one targeting very close (MIRVWarheadProtectionRadius)
@@ -102,7 +99,7 @@ export class SAMLauncherExecution implements Execution {
this.active = false;
return;
}
this.sam = this.player.buildUnit(UnitType.SAMLauncher, 0, spawnTile, {
this.sam = this.player.buildUnit(UnitType.SAMLauncher, spawnTile, {
cooldownDuration: this.mg.config().SAMCooldown(),
});
}
@@ -119,7 +116,7 @@ export class SAMLauncherExecution implements Execution {
this.pseudoRandom = new PseudoRandom(this.sam.id());
}
this.warheadTargets = this.mg
const mirvWarheadTargets = this.mg
.nearbyUnits(
this.sam.tile(),
this.MIRVWarheadSearchRadius,
@@ -136,8 +133,9 @@ export class SAMLauncherExecution implements Execution {
this.MIRVWarheadProtectionRadius,
);
if (this.warheadTargets.length == 0) {
this.target = this.getSingleTarget();
let target: Unit | null = null;
if (mirvWarheadTargets.length == 0) {
target = this.getSingleTarget();
}
if (
@@ -147,16 +145,14 @@ export class SAMLauncherExecution implements Execution {
this.sam.setCooldown(false);
}
const isSingleTarget = this.target && !this.target.targetedBySAM();
const isSingleTarget = target && !target.targetedBySAM();
if (
(isSingleTarget || this.warheadTargets.length > 0) &&
(isSingleTarget || mirvWarheadTargets.length > 0) &&
!this.sam.isCooldown()
) {
this.sam.setCooldown(true);
const type =
this.warheadTargets.length > 0
? UnitType.MIRVWarhead
: this.target.type();
mirvWarheadTargets.length > 0 ? UnitType.MIRVWarhead : target.type();
const random = this.pseudoRandom.next();
const hit = this.isHit(type, random);
if (!hit) {
@@ -166,26 +162,25 @@ export class SAMLauncherExecution implements Execution {
this.sam.owner().id(),
);
} else {
if (this.warheadTargets.length > 0) {
if (mirvWarheadTargets.length > 0) {
// Message
this.mg.displayMessage(
`${this.warheadTargets.length} MIRV warheads intercepted`,
`${mirvWarheadTargets.length} MIRV warheads intercepted`,
MessageType.SUCCESS,
this.sam.owner().id(),
);
// Delete warheads
this.warheadTargets.forEach((u) => u.delete());
mirvWarheadTargets.forEach((u) => u.delete());
} else {
this.target.setTargetedBySAM(true);
target.setTargetedBySAM(true);
this.mg.addExecution(
new SAMMissileExecution(
this.sam.tile(),
this.sam.owner(),
this.sam,
this.target,
target,
),
);
this.warheadTargets = [];
}
}
}
+17 -28
View File
@@ -1,4 +1,3 @@
import { consolex } from "../Consolex";
import {
Execution,
Game,
@@ -8,12 +7,12 @@ import {
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { PathFindResultType } from "../pathfinding/AStar";
import { PathFinder } from "../pathfinding/PathFinding";
import { AirPathFinder } from "../pathfinding/PathFinding";
import { PseudoRandom } from "../PseudoRandom";
export class SAMMissileExecution implements Execution {
private active = true;
private pathFinder: PathFinder;
private pathFinder: AirPathFinder;
private SAMMissile: Unit;
private mg: Game;
@@ -26,7 +25,7 @@ export class SAMMissileExecution implements Execution {
) {}
init(mg: Game, ticks: number): void {
this.pathFinder = PathFinder.Mini(mg, 2000, true, 10);
this.pathFinder = new AirPathFinder(mg, new PseudoRandom(mg.ticks()));
this.mg = mg;
}
@@ -34,8 +33,8 @@ export class SAMMissileExecution implements Execution {
if (this.SAMMissile == null) {
this.SAMMissile = this._owner.buildUnit(
UnitType.SAMMissile,
0,
this.spawn,
{},
);
}
if (!this.SAMMissile.isActive()) {
@@ -58,29 +57,19 @@ export class SAMMissileExecution implements Execution {
const result = this.pathFinder.nextTile(
this.SAMMissile.tile(),
this.target.tile(),
3,
);
switch (result.type) {
case PathFindResultType.Completed:
this.mg.displayMessage(
`Missile intercepted ${this.target.type()}`,
MessageType.SUCCESS,
this._owner.id(),
);
this.active = false;
this.target.delete();
this.SAMMissile.delete(false);
return;
case PathFindResultType.NextTile:
this.SAMMissile.move(result.tile);
break;
case PathFindResultType.Pending:
return;
case PathFindResultType.PathNotFound:
consolex.log(`Missile ${this.SAMMissile} could not find target`);
this.active = false;
this.SAMMissile.delete(false);
return;
if (result === true) {
this.mg.displayMessage(
`Missile intercepted ${this.target.type()}`,
MessageType.SUCCESS,
this._owner.id(),
);
this.active = false;
this.target.delete();
this.SAMMissile.delete(false);
return;
} else {
this.SAMMissile.move(result);
}
}
}
+12 -23
View File
@@ -1,12 +1,11 @@
import { consolex } from "../Consolex";
import { Execution, Game, Player, Unit, UnitType } from "../game/Game";
import { TileRef } from "../game/GameMap";
import { PathFindResultType } from "../pathfinding/AStar";
import { PathFinder } from "../pathfinding/PathFinding";
import { AirPathFinder } from "../pathfinding/PathFinding";
import { PseudoRandom } from "../PseudoRandom";
export class ShellExecution implements Execution {
private active = true;
private pathFinder: PathFinder;
private pathFinder: AirPathFinder;
private shell: Unit;
private mg: Game;
private destroyAtTick: number = -1;
@@ -19,13 +18,13 @@ export class ShellExecution implements Execution {
) {}
init(mg: Game, ticks: number): void {
this.pathFinder = PathFinder.Mini(mg, 2000, true, 10);
this.pathFinder = new AirPathFinder(mg, new PseudoRandom(mg.ticks()));
this.mg = mg;
}
tick(ticks: number): void {
if (this.shell == null) {
this.shell = this._owner.buildUnit(UnitType.Shell, 0, this.spawn);
this.shell = this._owner.buildUnit(UnitType.Shell, this.spawn, {});
}
if (!this.shell.isActive()) {
this.active = false;
@@ -49,24 +48,14 @@ export class ShellExecution implements Execution {
const result = this.pathFinder.nextTile(
this.shell.tile(),
this.target.tile(),
3,
);
switch (result.type) {
case PathFindResultType.Completed:
this.active = false;
this.target.modifyHealth(-this.effectOnTarget());
this.shell.delete(false);
return;
case PathFindResultType.NextTile:
this.shell.move(result.tile);
break;
case PathFindResultType.Pending:
return;
case PathFindResultType.PathNotFound:
consolex.log(`Shell ${this.shell} could not find target`);
this.active = false;
this.shell.delete(false);
return;
if (result === true) {
this.active = false;
this.target.modifyHealth(-this.effectOnTarget());
this.shell.delete(false);
return;
} else {
this.shell.move(result);
}
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ export class TradeShipExecution implements Execution {
this.active = false;
return;
}
this.tradeShip = this.origOwner.buildUnit(UnitType.TradeShip, 0, spawn, {
this.tradeShip = this.origOwner.buildUnit(UnitType.TradeShip, spawn, {
dstPort: this._dstPort,
lastSetSafeFromPirates: ticks,
});
+27 -16
View File
@@ -10,9 +10,9 @@ import {
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { targetTransportTile } from "../game/TransportShipUtils";
import { PathFindResultType } from "../pathfinding/AStar";
import { PathFinder } from "../pathfinding/PathFinding";
import { targetTransportTile } from "../Util";
import { AttackExecution } from "./AttackExecution";
export class TransportShipExecution implements Execution {
@@ -26,11 +26,9 @@ export class TransportShipExecution implements Execution {
private mg: Game;
private attacker: Player;
private target: Player | TerraNullius;
private embarkDelay = 10;
// TODO make private
public path: TileRef[];
private src: TileRef | null;
private dst: TileRef | null;
private boat: Unit;
@@ -42,6 +40,7 @@ export class TransportShipExecution implements Execution {
private targetID: PlayerID | null,
private ref: TileRef,
private troops: number | null,
private src: TileRef | null,
) {}
activeDuringSpawnPhase(): boolean {
@@ -64,7 +63,7 @@ export class TransportShipExecution implements Execution {
this.lastMove = ticks;
this.mg = mg;
this.pathFinder = PathFinder.Mini(mg, 10_000, false, 10);
this.pathFinder = PathFinder.Mini(mg, 10_000, 10);
this.attacker = mg.player(this.attackerID);
@@ -113,20 +112,36 @@ export class TransportShipExecution implements Execution {
this.active = false;
return;
}
const src = this.attacker.canBuild(UnitType.TransportShip, this.dst);
if (src == false) {
const closestTileSrc = this.attacker.canBuild(
UnitType.TransportShip,
this.dst,
);
if (closestTileSrc == false) {
consolex.warn(`can't build transport ship`);
this.active = false;
return;
}
this.src = src;
if (this.src == null) {
// Only update the src if it's not already set
// because we assume that the src is set to the best spawn tile
this.src = closestTileSrc;
} else {
if (
this.mg.owner(this.src) != this.attacker ||
!this.mg.isShore(this.src)
) {
console.warn(
`src is not a shore tile or not owned by: ${this.attacker.name()}`,
);
this.src = closestTileSrc;
}
}
this.boat = this.attacker.buildUnit(
UnitType.TransportShip,
this.troops,
this.src,
);
this.boat = this.attacker.buildUnit(UnitType.TransportShip, this.src, {
troops: this.troops,
});
}
tick(ticks: number) {
@@ -137,10 +152,6 @@ export class TransportShipExecution implements Execution {
this.active = false;
return;
}
if (this.embarkDelay > 0) {
this.embarkDelay--;
return;
}
if (ticks - this.lastMove < this.ticksPerMove) {
return;
}
+2 -2
View File
@@ -40,7 +40,7 @@ export class WarshipExecution implements Execution {
this.active = false;
return;
}
this.pathfinder = PathFinder.Mini(mg, 5000, false);
this.pathfinder = PathFinder.Mini(mg, 5000);
this._owner = mg.player(this.playerID);
this.mg = mg;
this.patrolTile = this.patrolCenterTile;
@@ -119,7 +119,7 @@ export class WarshipExecution implements Execution {
this.active = false;
return;
}
this.warship = this._owner.buildUnit(UnitType.Warship, 0, spawn);
this.warship = this._owner.buildUnit(UnitType.Warship, spawn, {});
return;
}
if (!this.warship.isActive()) {
+9 -2
View File
@@ -1,5 +1,12 @@
import { GameEvent } from "../EventBus";
import { Execution, Game, GameMode, Player, Team } from "../game/Game";
import {
ColoredTeams,
Execution,
Game,
GameMode,
Player,
Team,
} from "../game/Game";
export class WinEvent implements GameEvent {
constructor(public readonly winner: Player) {}
@@ -66,7 +73,7 @@ export class WinCheckExecution implements Execution {
this.mg.numLandTiles() - this.mg.numTilesWithFallout();
const percentage = (max[1] / numTilesWithoutFallout) * 100;
if (percentage > this.mg.config().percentageTilesOwnedToWin()) {
if (max[0] == Team.Bot) return;
if (max[0] == ColoredTeams.Bot) return;
this.mg.setWinner(max[0], this.mg.stats().stats());
console.log(`${max[0]} has won the game`);
this.active = false;
+72 -12
View File
@@ -8,6 +8,7 @@ import {
Tick,
} from "../../game/Game";
import { PseudoRandom } from "../../PseudoRandom";
import { flattenedEmojiTable } from "../../Util";
import { AttackExecution } from "../AttackExecution";
import { EmojiExecution } from "../EmojiExecution";
@@ -15,11 +16,16 @@ export class BotBehavior {
private enemy: Player | null = null;
private enemyUpdated: Tick;
private assistAcceptEmoji = flattenedEmojiTable.indexOf("👍");
private firstAttackSent = false;
constructor(
private random: PseudoRandom,
private game: Game,
private player: Player,
private attackRatio: number,
private triggerRatio: number,
private reserveRatio: number,
) {}
handleAllianceRequests() {
@@ -32,13 +38,31 @@ 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),
);
}
forgetOldEnemies() {
// Forget old enemies
if (this.game.ticks() - this.enemyUpdated > 100) {
this.enemy = null;
}
}
checkIncomingAttacks() {
// Switch enemies if we're under attack
const incomingAttacks = this.player.incomingAttacks();
if (incomingAttacks.length > 0) {
this.enemy = incomingAttacks
.sort((a, b) => b.troops() - a.troops())[0]
.attacker();
this.enemyUpdated = this.game.ticks();
}
}
assistAllies() {
outer: for (const ally of this.player.allies()) {
if (ally.targets().length === 0) continue;
@@ -59,16 +83,18 @@ 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;
}
}
}
selectEnemy(): Player | null {
// Forget old enemies
if (this.game.ticks() - this.enemyUpdated > 100) {
this.enemy = null;
if (this.enemy === null) {
// Save up troops until we reach the trigger ratio
const maxPop = this.game.config().maxPopulation(this.player);
const ratio = this.player.population() / maxPop;
if (ratio < this.triggerRatio) return null;
}
// Prefer neighboring bots
@@ -100,25 +126,59 @@ export class BotBehavior {
}
selectRandomEnemy(): Player | TerraNullius | null {
const neighbors = this.player.neighbors();
for (const neighbor of this.random.shuffleArray(neighbors)) {
if (neighbor.isPlayer()) {
if (this.enemy === null) {
// Save up troops until we reach the trigger ratio
const maxPop = this.game.config().maxPopulation(this.player);
const ratio = this.player.population() / maxPop;
if (ratio < this.triggerRatio) return null;
// Choose a new enemy randomly
const neighbors = this.player.neighbors();
for (const neighbor of this.random.shuffleArray(neighbors)) {
if (!neighbor.isPlayer()) continue;
if (this.player.isFriendly(neighbor)) continue;
if (neighbor.type() == PlayerType.FakeHuman) {
if (this.random.chance(2)) {
continue;
}
}
this.enemy = neighbor;
this.enemyUpdated = this.game.ticks();
}
// Select a traitor as an enemy
const traitors = this.player
.neighbors()
.filter((n) => n.isPlayer() && n.isTraitor()) as Player[];
if (traitors.length > 0) {
const toAttack = this.random.randElement(traitors);
const odds = this.player.isFriendly(toAttack) ? 6 : 3;
if (this.random.chance(odds)) {
this.enemy = toAttack;
this.enemyUpdated = this.game.ticks();
}
}
return neighbor;
}
return null;
// Sanity check, don't attack our allies or teammates
if (this.enemy && this.player.isFriendly(this.enemy)) {
this.enemy = null;
}
return this.enemy;
}
sendAttack(target: Player | TerraNullius) {
if (target.isPlayer() && this.player.isOnSameTeam(target)) return;
const troops = this.player.troops() * this.attackRatio;
const maxPop = this.game.config().maxPopulation(this.player);
const maxTroops = maxPop * this.player.targetTroopRatio();
const targetTroops = maxTroops * this.reserveRatio;
// Don't wait until it has sufficient reserves to send the first attack
// to prevent the bot from waiting too long at the start of the game.
const troops = this.firstAttackSent
? this.player.troops() - targetTroops
: this.player.troops() / 5;
if (troops < 1) return;
this.firstAttackSent = true;
this.game.addExecution(
new AttackExecution(
troops,
+94 -32
View File
@@ -37,20 +37,25 @@ 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",
Europe = "Europe",
EuropeClassic = "Europe Classic",
Mena = "Mena",
NorthAmerica = "North America",
SouthAmerica = "South America",
@@ -67,7 +72,10 @@ export enum GameMapType {
Japan = "Japan",
BetweenTwoSeas = "Between Two Seas",
KnownWorld = "Known World",
FaroeIslands = "FaroeIslands",
FaroeIslands = "Faroe Islands",
DeglaciatedAntarctica = "Deglaciated Antarctica",
FalklandIslands = "Falkland Islands",
Baikal = "Baikal",
}
export const mapCategories: Record<string, GameMapType[]> = {
@@ -76,6 +84,7 @@ export const mapCategories: Record<string, GameMapType[]> = {
GameMapType.NorthAmerica,
GameMapType.SouthAmerica,
GameMapType.Europe,
GameMapType.EuropeClassic,
GameMapType.Asia,
GameMapType.Africa,
GameMapType.Oceania,
@@ -89,8 +98,16 @@ export const mapCategories: Record<string, GameMapType[]> = {
GameMapType.Japan,
GameMapType.Mena,
GameMapType.Australia,
GameMapType.FaroeIslands,
GameMapType.FalklandIslands,
GameMapType.Baikal,
],
fantasy: [
GameMapType.Pangaea,
GameMapType.Mars,
GameMapType.KnownWorld,
GameMapType.DeglaciatedAntarctica,
],
fantasy: [GameMapType.Pangaea, GameMapType.Mars, GameMapType.KnownWorld],
};
export enum GameType {
@@ -131,6 +148,51 @@ export enum UnitType {
Construction = "Construction",
}
export interface UnitParamsMap {
[UnitType.TransportShip]: {
troops?: number;
destination?: TileRef;
};
[UnitType.Warship]: {};
[UnitType.Shell]: {};
[UnitType.SAMMissile]: {};
[UnitType.Port]: {};
[UnitType.AtomBomb]: {};
[UnitType.HydrogenBomb]: {};
[UnitType.TradeShip]: {
dstPort: Unit;
lastSetSafeFromPirates?: number;
};
[UnitType.MissileSilo]: {
cooldownDuration?: number;
};
[UnitType.DefensePost]: {};
[UnitType.SAMLauncher]: {};
[UnitType.City]: {};
[UnitType.MIRV]: {};
[UnitType.MIRVWarhead]: {};
[UnitType.Construction]: {};
}
// Type helper to get params type for a specific unit type
export type UnitParams<T extends UnitType> = UnitParamsMap[T];
export type AllUnitParams = UnitParamsMap[keyof UnitParamsMap];
export const nukeTypes = [
UnitType.AtomBomb,
UnitType.HydrogenBomb,
@@ -149,10 +211,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,
) {}
}
@@ -260,15 +321,6 @@ export class PlayerInfo {
}
}
// Some units have info specific to them
export interface UnitSpecificInfos {
dstPort?: Unit; // Only for trade ships
lastSetSafeFromPirates?: number; // Only for trade ships
detonationDst?: TileRef; // Only for nukes
warshipTarget?: Unit;
cooldownDuration?: number;
}
export interface Unit {
id(): number;
@@ -375,12 +427,12 @@ export interface Player {
unitsIncludingConstruction(type: UnitType): Unit[];
buildableUnits(tile: TileRef): BuildableUnit[];
canBuild(type: UnitType, targetTile: TileRef): TileRef | false;
buildUnit(
type: UnitType,
troops: number,
tile: TileRef,
unitSpecificInfos?: UnitSpecificInfos,
buildUnit<T extends UnitType>(
type: T,
spawnTile: TileRef,
params: UnitParams<T>,
): Unit;
captureUnit(unit: Unit): void;
// Relations & Diplomacy
@@ -443,8 +495,9 @@ export interface Player {
// Misc
toUpdate(): PlayerUpdate;
playerProfile(): PlayerProfile;
canBoat(tile: TileRef): TileRef | false;
tradingPorts(port: Unit): Unit[];
// WARNING: this operation is expensive.
bestTransportShipSpawn(tile: TileRef): TileRef | false;
}
export interface Game extends GameMap {
@@ -492,6 +545,15 @@ export interface Game extends GameMap {
playerID: PlayerID | null,
): void;
displayChat(
message: string,
category: string,
variables: Record<string, string>,
playerID: PlayerID | null,
isFrom: boolean,
recipient: string,
): void;
// Nations
nations(): Nation[];
@@ -501,7 +563,6 @@ export interface Game extends GameMap {
}
export interface PlayerActions {
canBoat: TileRef | false;
canAttack: boolean;
buildableUnits: BuildableUnit[];
canSendEmojiAllPlayers: boolean;
@@ -509,7 +570,7 @@ export interface PlayerActions {
}
export interface BuildableUnit {
canBuild: boolean;
canBuild: TileRef | false;
type: UnitType;
cost: number;
}
@@ -545,6 +606,7 @@ export enum MessageType {
INFO,
WARN,
ERROR,
CHAT,
}
export interface NameViewData {
+62 -29
View File
@@ -8,6 +8,8 @@ import {
Alliance,
AllianceRequest,
Cell,
ColoredTeams,
Duos,
EmojiMessage,
Execution,
Game,
@@ -32,19 +34,18 @@ import { PlayerImpl } from "./PlayerImpl";
import { Stats } from "./Stats";
import { StatsImpl } from "./StatsImpl";
import { assignTeams } from "./TeamAssignment";
import { NationMap } from "./TerrainMapLoader";
import { TerraNulliusImpl } from "./TerraNulliusImpl";
import { UnitGrid } from "./UnitGrid";
import { UnitImpl } from "./UnitImpl";
export function createGame(
humans: PlayerInfo[],
nations: Nation[],
gameMap: GameMap,
miniGameMap: GameMap,
nationMap: NationMap,
config: Config,
): Game {
return new GameImpl(humans, gameMap, miniGameMap, nationMap, config);
return new GameImpl(humans, nations, gameMap, miniGameMap, config);
}
export type CellString = string;
@@ -54,8 +55,6 @@ export class GameImpl implements Game {
private unInitExecs: Execution[] = [];
private nations_: Nation[] = [];
_players: Map<PlayerID, PlayerImpl> = new Map<PlayerID, PlayerImpl>();
_playersBySmallID = [];
@@ -75,49 +74,61 @@ 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();
if (numPlayerTeams < 2) throw new Error("Too few teams!");
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 >= 8) throw new Error("Too many teams!");
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(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 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`);
@@ -178,7 +189,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 {
@@ -595,6 +606,28 @@ export class GameImpl implements Game {
});
}
displayChat(
message: string,
category: string,
variables: Record<string, string> = {},
playerID: PlayerID | null,
isFrom: boolean | null = null,
recipient: string,
): void {
let id = null;
if (playerID != null) {
id = this.player(playerID).smallID();
}
this.addUpdate({
type: GameUpdateType.DisplayChatEvent,
key: message,
category: category,
variables: variables,
playerID: id,
isFrom: isFrom,
recipient: recipient,
});
}
addUnit(u: Unit) {
this.unitGrid.addUnit(u);
}
+31 -14
View File
@@ -58,6 +58,11 @@ export class GameMapImpl implements GameMap {
private readonly width_: number;
private readonly height_: number;
// Lookup tables (LUTs) contain pre-computed values to avoid performing division at runtime
private readonly refToX: number[];
private readonly refToY: number[];
private readonly yToRef: number[];
// Terrain bits (Uint8Array)
private static readonly IS_LAND_BIT = 7;
private static readonly SHORELINE_BIT = 6;
@@ -87,6 +92,19 @@ export class GameMapImpl implements GameMap {
this.height_ = height;
this.terrain = terrainData;
this.state = new Uint16Array(width * height);
// Precompute the LUTs
let ref = 0;
this.refToX = new Array(width * height);
this.refToY = new Array(width * height);
this.yToRef = new Array(height);
for (let y = 0; y < height; y++) {
this.yToRef[y] = ref;
for (let x = 0; x < width; x++) {
this.refToX[ref] = x;
this.refToY[ref] = y;
ref++;
}
}
}
numTilesWithFallout(): number {
return this._numTilesWithFallout;
@@ -96,15 +114,15 @@ export class GameMapImpl implements GameMap {
if (!this.isValidCoord(x, y)) {
throw new Error(`Invalid coordinates: ${x},${y}`);
}
return y * this.width_ + x;
return this.yToRef[y] + x;
}
x(ref: TileRef): number {
return ref % this.width_;
return this.refToX[ref];
}
y(ref: TileRef): number {
return Math.floor(ref / this.width_);
return this.refToY[ref];
}
cell(ref: TileRef): Cell {
@@ -240,24 +258,19 @@ export class GameMapImpl implements GameMap {
neighbors(ref: TileRef): TileRef[] {
const neighbors: TileRef[] = [];
const w = this.width_;
const x = this.refToX[ref];
if (ref >= w) neighbors.push(ref - w);
if (ref < (this.height_ - 1) * w) neighbors.push(ref + w);
if (ref % w !== 0) neighbors.push(ref - 1);
if (ref % w !== w - 1) neighbors.push(ref + 1);
for (const n of neighbors) {
this.ref(this.x(n), this.y(n));
}
if (x !== 0) neighbors.push(ref - 1);
if (x !== w - 1) neighbors.push(ref + 1);
return neighbors;
}
forEachTile(fn: (tile: TileRef) => void): void {
for (let x = 0; x < this.width_; x++) {
for (let y = 0; y < this.height_; y++) {
fn(this.ref(x, y));
}
for (let ref: TileRef = 0; ref < this.width_ * this.height_; ref++) {
fn(ref);
}
}
@@ -277,7 +290,11 @@ export class GameMapImpl implements GameMap {
): Set<TileRef> {
const seen = new Set<TileRef>();
const q: TileRef[] = [];
q.push(tile);
if (filter(this, tile)) {
seen.add(tile);
q.push(tile);
}
while (q.length > 0) {
const curr = q.pop();
for (const n of this.neighbors(curr)) {
+12
View File
@@ -29,6 +29,7 @@ export enum GameUpdateType {
Unit,
Player,
DisplayEvent,
DisplayChatEvent,
AllianceRequest,
AllianceRequestReply,
BrokeAlliance,
@@ -48,6 +49,7 @@ export type GameUpdate =
| BrokeAllianceUpdate
| AllianceExpiredUpdate
| DisplayMessageUpdate
| DisplayChatMessageUpdate
| TargetPlayerUpdate
| EmojiUpdate
| WinUpdate
@@ -157,6 +159,16 @@ export interface DisplayMessageUpdate {
playerID: number | null;
}
export type DisplayChatMessageUpdate = {
type: GameUpdateType.DisplayChatEvent;
key: string;
category: string;
variables?: Record<string, string>;
playerID: number | null;
isFrom: boolean;
recipient: string;
};
export interface WinUpdate {
type: GameUpdateType.Win;
allPlayersStats: AllPlayersStats;
+24 -3
View File
@@ -1,5 +1,6 @@
import { Config } from "../configuration/Config";
import { ClientID, GameID, PlayerStats } from "../Schemas";
import { createRandomName } from "../Util";
import { WorkerClient } from "../worker/WorkerClient";
import {
Cell,
@@ -123,11 +124,22 @@ export class UnitView {
}
export class PlayerView {
public anonymousName: string;
constructor(
private game: GameView,
public data: PlayerUpdate,
public nameData: NameViewData,
) {}
) {
if (data.clientID == game.myClientID()) {
this.anonymousName = this.data.name;
} else {
this.anonymousName = createRandomName(
this.data.name,
this.data.playerType,
);
}
}
async actions(tile: TileRef): Promise<PlayerActions> {
return this.game.worker.playerInteraction(
@@ -166,11 +178,16 @@ export class PlayerView {
return this.data.flag;
}
name(): string {
return this.data.name;
return userSettings.anonymousNames() && this.anonymousName !== null
? this.anonymousName
: this.data.name;
}
displayName(): string {
return this.data.displayName;
return userSettings.anonymousNames() && this.anonymousName !== null
? this.anonymousName
: this.data.name;
}
clientID(): ClientID {
return this.data.clientID;
}
@@ -242,6 +259,10 @@ export class PlayerView {
return this.game.worker.playerProfile(this.smallID());
}
bestTransportShipSpawn(targetTile: TileRef): Promise<TileRef | false> {
return this.game.worker.transportShipSpawn(this.id(), targetTile);
}
transitiveTargets(): PlayerView[] {
return [...this.targets(), ...this.allies().flatMap((p) => p.targets())];
}
+32 -119
View File
@@ -4,12 +4,10 @@ import { PseudoRandom } from "../PseudoRandom";
import { ClientID } from "../Schemas";
import {
assertNever,
closestShoreFromPlayer,
distSortUnit,
maxInt,
minInt,
simpleHash,
targetTransportTile,
toInt,
within,
} from "../Util";
@@ -22,8 +20,8 @@ import {
Attack,
BuildableUnit,
Cell,
ColoredTeams,
EmojiMessage,
GameMode,
Gold,
MessageType,
MutableAlliance,
@@ -37,13 +35,17 @@ import {
TerraNullius,
Tick,
Unit,
UnitSpecificInfos,
UnitParams,
UnitType,
} from "./Game";
import { GameImpl } from "./GameImpl";
import { andFN, manhattanDistFN, TileRef } from "./GameMap";
import { AttackUpdate, GameUpdateType, PlayerUpdate } from "./GameUpdates";
import { TerraNulliusImpl } from "./TerraNulliusImpl";
import {
bestShoreDeploymentSource,
canBuildTransportShip,
} from "./TransportShipUtils";
import { UnitImpl } from "./UnitImpl";
interface Target {
@@ -467,12 +469,12 @@ export class PlayerImpl implements Player {
this.mg.target(this, other);
}
targets(): PlayerImpl[] {
targets(): Player[] {
return this.targets_
.filter(
(t) => this.mg.ticks() - t.tick < this.mg.config().targetDuration(),
)
.map((t) => t.target as PlayerImpl);
.map((t) => t.target);
}
transitiveTargets(): Player[] {
@@ -525,13 +527,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;
}
@@ -612,6 +607,9 @@ export class PlayerImpl implements Player {
if (this.team() == null || other.team() == null) {
return false;
}
if (this.team() == ColoredTeams.Bot || other.team() == ColoredTeams.Bot) {
return false;
}
return this._team == other.team();
}
@@ -705,25 +703,29 @@ export class PlayerImpl implements Player {
);
}
buildUnit(
type: UnitType,
troops: number,
buildUnit<T extends UnitType>(
type: T,
spawnTile: TileRef,
unitSpecificInfos: UnitSpecificInfos = {},
params: UnitParams<T>,
): UnitImpl {
if (this.mg.config().isUnitDisabled(type)) {
throw new Error(
`Attempted to build disabled unit ${type} at tile ${spawnTile} by player ${this.name()}`,
);
}
const cost = this.mg.unitInfo(type).cost(this);
const b = new UnitImpl(
type,
this.mg,
spawnTile,
troops,
this.mg.nextUnitID(),
this,
unitSpecificInfos,
params,
);
this._units.push(b);
this.removeGold(cost);
this.removeTroops(troops);
this.removeTroops("troops" in params ? params.troops : 0);
this.mg.addUpdate(b.toUpdate());
this.mg.addUnit(b);
@@ -735,7 +737,9 @@ export class PlayerImpl implements Player {
return Object.values(UnitType).map((u) => {
return {
type: u,
canBuild: this.canBuild(u, tile, validTiles) != false,
canBuild: this.mg.inSpawnPhase()
? false
: this.canBuild(u, tile, validTiles),
cost: this.mg.config().unitInfo(u).cost(this),
} as BuildableUnit;
});
@@ -746,19 +750,8 @@ export class PlayerImpl implements Player {
targetTile: TileRef,
validTiles: TileRef[] | null = null,
): TileRef | false {
// prevent the building of nukes and nuke related buildings
if (this.mg.config().disableNukes()) {
if (
unitType === UnitType.MissileSilo ||
unitType === UnitType.MIRV ||
unitType === UnitType.AtomBomb ||
unitType === UnitType.HydrogenBomb ||
unitType === UnitType.SAMLauncher ||
unitType === UnitType.SAMMissile ||
unitType === UnitType.MIRVWarhead
) {
return false;
}
if (this.mg.config().isUnitDisabled(unitType)) {
return false;
}
const cost = this.mg.unitInfo(unitType).cost(this);
@@ -784,7 +777,7 @@ export class PlayerImpl implements Player {
case UnitType.SAMMissile:
return targetTile;
case UnitType.TransportShip:
return this.transportShipSpawn(targetTile);
return canBuildTransportShip(this.mg, this, targetTile);
case UnitType.TradeShip:
return this.tradeShipSpawn(targetTile);
case UnitType.MissileSilo:
@@ -807,7 +800,6 @@ export class PlayerImpl implements Player {
}
// only get missilesilos that are not on cooldown
const spawns = this.units(UnitType.MissileSilo)
.map((u) => u as Unit)
.filter((silo) => {
return !silo.isCooldown();
})
@@ -906,17 +898,6 @@ export class PlayerImpl implements Player {
return valid;
}
transportShipSpawn(targetTile: TileRef): TileRef | false {
if (!this.mg.isShore(targetTile)) {
return false;
}
const spawn = closestShoreFromPlayer(this.mg, this, targetTile);
if (spawn == null) {
return false;
}
return spawn;
}
tradeShipSpawn(targetTile: TileRef): TileRef | false {
const spawns = this.units(UnitType.Port).filter(
(u) => u.tile() == targetTile,
@@ -957,78 +938,6 @@ export class PlayerImpl implements Player {
return rel;
}
public canBoat(tile: TileRef): TileRef | false {
if (
this.units(UnitType.TransportShip).length >=
this.mg.config().boatMaxNumber()
) {
return false;
}
const dst = targetTransportTile(this.mg, tile);
if (dst == null) {
return false;
}
const other = this.mg.owner(tile);
if (other == this) {
return false;
}
if (other.isPlayer() && this.isFriendly(other)) {
return false;
}
if (this.mg.isOceanShore(dst)) {
let myPlayerBordersOcean = false;
for (const bt of this.borderTiles()) {
if (this.mg.isOceanShore(bt)) {
myPlayerBordersOcean = true;
break;
}
}
let otherPlayerBordersOcean = false;
if (!this.mg.hasOwner(tile)) {
otherPlayerBordersOcean = true;
} else {
for (const bt of (other as Player).borderTiles()) {
if (this.mg.isOceanShore(bt)) {
otherPlayerBordersOcean = true;
break;
}
}
}
if (myPlayerBordersOcean && otherPlayerBordersOcean) {
return this.canBuild(UnitType.TransportShip, dst);
} else {
return false;
}
}
// Now we are boating in a lake, so do a bfs from target until we find
// a border tile owned by the player
const tiles = this.mg.bfs(
dst,
andFN(
manhattanDistFN(dst, 300),
(_, t: TileRef) => this.mg.isLake(t) || this.mg.isShore(t),
),
);
const sorted = Array.from(tiles).sort(
(a, b) => this.mg.manhattanDist(dst, a) - this.mg.manhattanDist(dst, b),
);
for (const t of sorted) {
if (this.mg.owner(t) == this) {
return this.canBuild(UnitType.TransportShip, dst);
}
}
return false;
}
createAttack(
target: Player | TerraNullius,
troops: number,
@@ -1097,6 +1006,10 @@ export class PlayerImpl implements Player {
}
}
bestTransportShipSpawn(targetTile: TileRef): TileRef | false {
return bestShoreDeploymentSource(this.mg, this, targetTile);
}
// It's a probability list, so if an element appears twice it's because it's
// twice more likely to be picked later.
tradingPorts(port: Unit): Unit[] {
+4
View File
@@ -42,6 +42,10 @@ const MAP_FILE_NAMES: Record<GameMapType, string> = {
[GameMapType.BetweenTwoSeas]: "BetweenTwoSeas",
[GameMapType.KnownWorld]: "KnownWorld",
[GameMapType.FaroeIslands]: "FaroeIslands",
[GameMapType.DeglaciatedAntarctica]: "DeglaciatedAntarctica",
[GameMapType.EuropeClassic]: "EuropeClassic",
[GameMapType.FalklandIslands]: "FalklandIslands",
[GameMapType.Baikal]: "Baikal",
};
class GameMapLoader {
+262
View File
@@ -0,0 +1,262 @@
import { PathFindResultType } from "../pathfinding/AStar";
import { MiniAStar } from "../pathfinding/MiniAStar";
import { Game, Player, UnitType } from "./Game";
import { andFN, GameMap, manhattanDistFN, TileRef } from "./GameMap";
export function canBuildTransportShip(
game: Game,
player: Player,
tile: TileRef,
): TileRef | false {
if (
player.units(UnitType.TransportShip).length >= game.config().boatMaxNumber()
) {
return false;
}
const dst = targetTransportTile(game, tile);
if (dst == null) {
return false;
}
const other = game.owner(tile);
if (other == player) {
return false;
}
if (other.isPlayer() && player.isFriendly(other)) {
return false;
}
if (game.isOceanShore(dst)) {
let myPlayerBordersOcean = false;
for (const bt of player.borderTiles()) {
if (game.isOceanShore(bt)) {
myPlayerBordersOcean = true;
break;
}
}
let otherPlayerBordersOcean = false;
if (!game.hasOwner(tile)) {
otherPlayerBordersOcean = true;
} else {
for (const bt of (other as Player).borderTiles()) {
if (game.isOceanShore(bt)) {
otherPlayerBordersOcean = true;
break;
}
}
}
if (myPlayerBordersOcean && otherPlayerBordersOcean) {
return transportShipSpawn(game, player, dst);
} else {
return false;
}
}
// Now we are boating in a lake, so do a bfs from target until we find
// a border tile owned by the player
const tiles = game.bfs(
dst,
andFN(
manhattanDistFN(dst, 300),
(_, t: TileRef) => game.isLake(t) || game.isShore(t),
),
);
const sorted = Array.from(tiles).sort(
(a, b) => game.manhattanDist(dst, a) - game.manhattanDist(dst, b),
);
for (const t of sorted) {
if (game.owner(t) == player) {
return transportShipSpawn(game, player, t);
}
}
return false;
}
function transportShipSpawn(
game: Game,
player: Player,
targetTile: TileRef,
): TileRef | false {
if (!game.isShore(targetTile)) {
return false;
}
const spawn = closestShoreFromPlayer(game, player, targetTile);
if (spawn == null) {
return false;
}
return spawn;
}
export function sourceDstOceanShore(
gm: Game,
src: Player,
tile: TileRef,
): [TileRef | null, TileRef | null] {
const dst = gm.owner(tile);
const srcTile = closestShoreFromPlayer(gm, src, tile);
let dstTile: TileRef | null = null;
if (dst.isPlayer()) {
dstTile = closestShoreFromPlayer(gm, dst as Player, tile);
} else {
dstTile = closestShoreTN(gm, tile, 50);
}
return [srcTile, dstTile];
}
export function targetTransportTile(gm: Game, tile: TileRef): TileRef | null {
const dst = gm.playerBySmallID(gm.ownerID(tile));
let dstTile: TileRef | null = null;
if (dst.isPlayer()) {
dstTile = closestShoreFromPlayer(gm, dst as Player, tile);
} else {
dstTile = closestShoreTN(gm, tile, 50);
}
return dstTile;
}
export function closestShoreFromPlayer(
gm: GameMap,
player: Player,
target: TileRef,
): TileRef | null {
const shoreTiles = Array.from(player.borderTiles()).filter((t) =>
gm.isShore(t),
);
if (shoreTiles.length == 0) {
return null;
}
return shoreTiles.reduce((closest, current) => {
const closestDistance = gm.manhattanDist(target, closest);
const currentDistance = gm.manhattanDist(target, current);
return currentDistance < closestDistance ? current : closest;
});
}
export function bestShoreDeploymentSource(
gm: Game,
player: Player,
target: TileRef,
): TileRef | false {
target = targetTransportTile(gm, target);
if (target == null) {
return false;
}
const candidates = candidateShoreTiles(gm, player, target);
const aStar = new MiniAStar(gm, gm.miniMap(), candidates, target, 500_000, 1);
const result = aStar.compute();
if (result != PathFindResultType.Completed) {
console.warn(`bestShoreDeploymentSource: path not found: ${result}`);
return false;
}
const path = aStar.reconstructPath();
if (path.length == 0) {
return false;
}
const potential = path[0];
// Since mini a* downscales the map, we need to check the neighbors
// of the potential tile to find a valid deployment point
const neighbors = gm
.neighbors(potential)
.filter((n) => gm.isShore(n) && gm.owner(n) == player);
if (neighbors.length == 0) {
return false;
}
return neighbors[0];
}
export function candidateShoreTiles(
gm: Game,
player: Player,
target: TileRef,
): TileRef[] {
let closestManhattanDistance = Infinity;
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
let bestByManhattan: TileRef = null;
const extremumTiles: Record<string, TileRef> = {
minX: null,
minY: null,
maxX: null,
maxY: null,
};
const borderShoreTiles = Array.from(player.borderTiles()).filter((t) =>
gm.isShore(t),
);
for (const tile of borderShoreTiles) {
const distance = gm.manhattanDist(tile, target);
const cell = gm.cell(tile);
// Manhattan-closest tile
if (distance < closestManhattanDistance) {
closestManhattanDistance = distance;
bestByManhattan = tile;
}
// Extremum tiles
if (cell.x < minX) {
minX = cell.x;
extremumTiles.minX = tile;
} else if (cell.y < minY) {
minY = cell.y;
extremumTiles.minY = tile;
} else if (cell.x > maxX) {
maxX = cell.x;
extremumTiles.maxX = tile;
} else if (cell.y > maxY) {
maxY = cell.y;
extremumTiles.maxY = tile;
}
}
// Calculate sampling interval to ensure we get at most 50 tiles
const samplingInterval = Math.max(
10,
Math.ceil(borderShoreTiles.length / 50),
);
const sampledTiles = borderShoreTiles.filter(
(_, index) => index % samplingInterval === 0,
);
const candidates = [
bestByManhattan,
extremumTiles.minX,
extremumTiles.minY,
extremumTiles.maxX,
extremumTiles.maxY,
...sampledTiles,
].filter(Boolean);
return candidates;
}
function closestShoreTN(
gm: GameMap,
tile: TileRef,
searchDist: number,
): TileRef {
const tn = Array.from(
gm.bfs(
tile,
andFN((_, t) => !gm.hasOwner(t), manhattanDistFN(tile, searchDist)),
),
)
.filter((t) => gm.isShore(t))
.sort((a, b) => gm.manhattanDist(tile, a) - gm.manhattanDist(tile, b));
if (tn.length == 0) {
return null;
}
return tn[0];
}
+12 -10
View File
@@ -1,11 +1,11 @@
import { simpleHash, toInt, withinInt } from "../Util";
import {
AllUnitParams,
MessageType,
Player,
Tick,
Unit,
UnitInfo,
UnitSpecificInfos,
UnitType,
} from "./Game";
import { GameImpl } from "./GameImpl";
@@ -24,6 +24,7 @@ export class UnitImpl implements Unit {
private _lastSetSafeFromPirates: number; // Only for trade ships
private _constructionType: UnitType = undefined;
private _troops: number;
private _cooldownTick: Tick | null = null;
private _dstPort: Unit | null = null; // Only for trade ships
private _detonationDst: TileRef | null = null; // Only for nukes
@@ -34,21 +35,22 @@ export class UnitImpl implements Unit {
private _type: UnitType,
private mg: GameImpl,
private _tile: TileRef,
private _troops: number,
private _id: number,
public _owner: PlayerImpl,
unitsSpecificInfos: UnitSpecificInfos = {},
params: AllUnitParams = {},
) {
this._health = toInt(this.mg.unitInfo(_type).maxHealth ?? 1);
this._lastTile = _tile;
this._dstPort = unitsSpecificInfos.dstPort;
this._detonationDst = unitsSpecificInfos.detonationDst;
this._warshipTarget = unitsSpecificInfos.warshipTarget;
this._cooldownDuration = unitsSpecificInfos.cooldownDuration;
this._lastSetSafeFromPirates = unitsSpecificInfos.lastSetSafeFromPirates;
this._health = toInt(this.mg.unitInfo(_type).maxHealth ?? 1);
this._safeFromPiratesCooldown = this.mg
.config()
.safeFromPiratesCooldownMax();
this._troops = "troops" in params ? params.troops : 0;
this._dstPort = "dstPort" in params ? params.dstPort : null;
this._cooldownDuration =
"cooldownDuration" in params ? params.cooldownDuration : null;
this._lastSetSafeFromPirates =
"lastSetSafeFromPirates" in params ? params.lastSetSafeFromPirates : 0;
}
id() {
@@ -88,9 +90,9 @@ export class UnitImpl implements Unit {
if (tile == null) {
throw new Error("tile cannot be null");
}
this.mg.removeUnit(this);
this._lastTile = this._tile;
this._tile = tile;
this.mg.removeUnit(this);
this.mg.addUnit(this);
this.mg.addUpdate(this.toUpdate());
}
+7
View File
@@ -15,6 +15,9 @@ export class UserSettings {
emojis() {
return this.get("settings.emojis", true);
}
anonymousNames() {
return this.get("settings.anonymousNames", false);
}
darkMode() {
return this.get("settings.darkMode", false);
@@ -42,6 +45,10 @@ export class UserSettings {
this.set("settings.emojis", !this.emojis());
}
toggleRandomName() {
this.set("settings.anonymousNames", !this.anonymousNames());
}
toggleDarkMode() {
this.set("settings.darkMode", !this.darkMode());
if (this.darkMode()) {
+12 -10
View File
@@ -3,31 +3,33 @@ import { GameMap, TileRef } from "../game/GameMap";
import { AStar, PathFindResultType } from "./AStar";
import { SerialAStar } from "./SerialAStar";
// TODO: test this, get it work
export class MiniAStar implements AStar {
private aStar: SerialAStar;
private aStar: AStar;
constructor(
private gameMap: GameMap,
private miniMap: GameMap,
private src: TileRef,
src: TileRef | TileRef[],
private dst: TileRef,
private canMove: (t: TileRef) => boolean,
private iterations: number,
private maxTries: number,
iterations: number,
maxTries: number,
) {
const miniSrc = this.miniMap.ref(
Math.floor(gameMap.x(src) / 2),
Math.floor(gameMap.y(src) / 2),
const srcArray: TileRef[] = Array.isArray(src) ? src : [src];
const miniSrc = srcArray.map((srcPoint) =>
this.miniMap.ref(
Math.floor(gameMap.x(srcPoint) / 2),
Math.floor(gameMap.y(srcPoint) / 2),
),
);
const miniDst = this.miniMap.ref(
Math.floor(gameMap.x(dst) / 2),
Math.floor(gameMap.y(dst) / 2),
);
this.aStar = new SerialAStar(
miniSrc,
miniDst,
canMove,
iterations,
maxTries,
this.miniMap,
+39 -13
View File
@@ -1,9 +1,46 @@
import { consolex } from "../Consolex";
import { Game } from "../game/Game";
import { TileRef } from "../game/GameMap";
import { GameMap, TileRef } from "../game/GameMap";
import { PseudoRandom } from "../PseudoRandom";
import { AStar, PathFindResultType, TileResult } from "./AStar";
import { MiniAStar } from "./MiniAStar";
export class AirPathFinder {
constructor(
private mg: GameMap,
private random: PseudoRandom,
) {}
nextTile(tile: TileRef, dst: TileRef): TileRef | true {
const x = this.mg.x(tile);
const y = this.mg.y(tile);
const dstX = this.mg.x(dst);
const dstY = this.mg.y(dst);
if (x === dstX && y === dstY) {
return true;
}
// Calculate next position
let nextX = x;
let nextY = y;
const ratio = Math.floor(1 + Math.abs(dstY - y) / (Math.abs(dstX - x) + 1));
if (this.random.chance(ratio) && x != dstX) {
if (x < dstX) nextX++;
else if (x > dstX) nextX--;
} else {
if (y < dstY) nextY++;
else if (y > dstY) nextY--;
}
if (nextX == x && nextY == y) {
return true;
}
return this.mg.ref(nextX, nextY);
}
}
export class PathFinder {
private curr: TileRef = null;
private dst: TileRef = null;
@@ -16,24 +53,13 @@ export class PathFinder {
private newAStar: (curr: TileRef, dst: TileRef) => AStar,
) {}
public static Mini(
game: Game,
iterations: number,
canMoveOnLand: boolean,
maxTries: number = 20,
) {
public static Mini(game: Game, iterations: number, maxTries: number = 20) {
return new PathFinder(game, (curr: TileRef, dst: TileRef) => {
return new MiniAStar(
game.map(),
game.miniMap(),
curr,
dst,
(tr: TileRef): boolean => {
if (canMoveOnLand) {
return true;
}
return game.miniMap().isWater(tr);
},
iterations,
maxTries,
);
+60 -23
View File
@@ -4,29 +4,42 @@ import { GameMap, TileRef } from "../game/GameMap";
import { AStar, PathFindResultType } from "./AStar";
export class SerialAStar implements AStar {
private fwdOpenSet: PriorityQueue<{ tile: TileRef; fScore: number }>;
private bwdOpenSet: PriorityQueue<{ tile: TileRef; fScore: number }>;
private fwdOpenSet: PriorityQueue<{
tile: TileRef;
fScore: number;
}>;
private bwdOpenSet: PriorityQueue<{
tile: TileRef;
fScore: number;
}>;
private fwdCameFrom: Map<TileRef, TileRef>;
private bwdCameFrom: Map<TileRef, TileRef>;
private fwdGScore: Map<TileRef, number>;
private bwdGScore: Map<TileRef, number>;
private meetingPoint: TileRef | null;
public completed: boolean;
private sources: TileRef[];
private closestSource: TileRef;
constructor(
private src: TileRef,
src: TileRef | TileRef[],
private dst: TileRef,
private canMove: (t: TileRef) => boolean,
private iterations: number,
private maxTries: number,
private gameMap: GameMap,
) {
this.fwdOpenSet = new PriorityQueue<{ tile: TileRef; fScore: number }>(
(a, b) => a.fScore - b.fScore,
);
this.bwdOpenSet = new PriorityQueue<{ tile: TileRef; fScore: number }>(
(a, b) => a.fScore - b.fScore,
);
this.fwdOpenSet = new PriorityQueue<{
tile: TileRef;
fScore: number;
}>((a, b) => a.fScore - b.fScore);
this.bwdOpenSet = new PriorityQueue<{
tile: TileRef;
fScore: number;
}>((a, b) => a.fScore - b.fScore);
this.fwdCameFrom = new Map<TileRef, TileRef>();
this.bwdCameFrom = new Map<TileRef, TileRef>();
this.fwdGScore = new Map<TileRef, number>();
@@ -34,13 +47,32 @@ export class SerialAStar implements AStar {
this.meetingPoint = null;
this.completed = false;
// Initialize forward search
this.fwdGScore.set(src, 0);
this.fwdOpenSet.enqueue({ tile: src, fScore: this.heuristic(src, dst) });
this.sources = Array.isArray(src) ? src : [src];
this.closestSource = this.findClosestSource(dst);
// Initialize backward search
// Initialize forward search with source point(s)
this.sources.forEach((startPoint) => {
this.fwdGScore.set(startPoint, 0);
this.fwdOpenSet.enqueue({
tile: startPoint,
fScore: this.heuristic(startPoint, dst),
});
});
// Initialize backward search from destination
this.bwdGScore.set(dst, 0);
this.bwdOpenSet.enqueue({ tile: dst, fScore: this.heuristic(dst, src) });
this.bwdOpenSet.enqueue({
tile: dst,
fScore: this.heuristic(dst, this.findClosestSource(dst)),
});
}
private findClosestSource(tile: TileRef): TileRef {
return this.sources.reduce((closest, source) =>
this.heuristic(tile, source) < this.heuristic(tile, closest)
? source
: closest,
);
}
compute(): PathFindResultType {
@@ -60,8 +92,9 @@ export class SerialAStar implements AStar {
// Process forward search
const fwdCurrent = this.fwdOpenSet.dequeue()!.tile;
// Check if we've found a meeting point
if (this.bwdGScore.has(fwdCurrent)) {
// We found a meeting point!
this.meetingPoint = fwdCurrent;
this.completed = true;
return PathFindResultType.Completed;
@@ -71,8 +104,9 @@ export class SerialAStar implements AStar {
// Process backward search
const bwdCurrent = this.bwdOpenSet.dequeue()!.tile;
// Check if we've found a meeting point
if (this.fwdGScore.has(bwdCurrent)) {
// We found a meeting point!
this.meetingPoint = bwdCurrent;
this.completed = true;
return PathFindResultType.Completed;
@@ -89,8 +123,8 @@ export class SerialAStar implements AStar {
private expandTileRef(current: TileRef, isForward: boolean) {
for (const neighbor of this.gameMap.neighbors(current)) {
if (
neighbor != (isForward ? this.dst : this.src) &&
!this.canMove(neighbor)
neighbor != (isForward ? this.dst : this.closestSource) &&
!this.gameMap.isWater(neighbor)
)
continue;
@@ -106,21 +140,22 @@ export class SerialAStar implements AStar {
gScore.set(neighbor, tentativeGScore);
const fScore =
tentativeGScore +
this.heuristic(neighbor, isForward ? this.dst : this.src);
this.heuristic(neighbor, isForward ? this.dst : this.closestSource);
openSet.enqueue({ tile: neighbor, fScore: fScore });
}
}
}
private heuristic(a: TileRef, b: TileRef): number {
// TODO use wrapped
try {
return (
1.1 * Math.abs(this.gameMap.x(a) - this.gameMap.x(b)) +
Math.abs(this.gameMap.y(a) - this.gameMap.y(b))
1.1 *
(Math.abs(this.gameMap.x(a) - this.gameMap.x(b)) +
Math.abs(this.gameMap.y(a) - this.gameMap.y(b)))
);
} catch {
consolex.log("uh oh");
return 0;
}
}
@@ -130,6 +165,7 @@ export class SerialAStar implements AStar {
// Reconstruct path from start to meeting point
const fwdPath: TileRef[] = [this.meetingPoint];
let current = this.meetingPoint;
while (this.fwdCameFrom.has(current)) {
current = this.fwdCameFrom.get(current)!;
fwdPath.unshift(current);
@@ -137,6 +173,7 @@ export class SerialAStar implements AStar {
// Reconstruct path from meeting point to goal
current = this.meetingPoint;
while (this.bwdCameFrom.has(current)) {
current = this.bwdCameFrom.get(current)!;
fwdPath.push(current);
+20
View File
@@ -6,6 +6,7 @@ import {
PlayerActionsResultMessage,
PlayerBorderTilesResultMessage,
PlayerProfileResultMessage,
TransportShipSpawnResultMessage,
WorkerMessage,
} from "./WorkerMessages";
@@ -120,6 +121,25 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
throw error;
}
break;
case "transport_ship_spawn":
if (!gameRunner) {
throw new Error("Game runner not initialized");
}
try {
const spawnTile = (await gameRunner).bestTransportShipSpawn(
message.playerID,
message.targetTile,
);
sendMessage({
type: "transport_ship_spawn_result",
id: message.id,
result: spawnTile,
} as TransportShipSpawnResultMessage);
} catch (error) {
console.error("Failed to spawn transport ship:", error);
}
break;
default:
console.warn("Unknown message :", message);
}
+31
View File
@@ -4,6 +4,7 @@ import {
PlayerID,
PlayerProfile,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
import { ClientID, GameStartInfo, Turn } from "../Schemas";
import { generateID } from "../Util";
@@ -188,6 +189,36 @@ export class WorkerClient {
});
}
transportShipSpawn(
playerID: PlayerID,
targetTile: TileRef,
): Promise<TileRef | false> {
return new Promise((resolve, reject) => {
if (!this.isInitialized) {
reject(new Error("Worker not initialized"));
return;
}
const messageId = generateID();
this.messageHandlers.set(messageId, (message) => {
if (
message.type === "transport_ship_spawn_result" &&
message.result !== undefined
) {
resolve(message.result);
}
});
this.worker.postMessage({
type: "transport_ship_spawn",
id: messageId,
playerID: playerID,
targetTile: targetTile,
});
});
}
cleanup() {
this.worker.terminate();
this.messageHandlers.clear();
+19 -3
View File
@@ -4,6 +4,7 @@ import {
PlayerID,
PlayerProfile,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { GameUpdateViewData } from "../game/GameUpdates";
import { ClientID, GameStartInfo, Turn } from "../Schemas";
@@ -18,7 +19,9 @@ export type WorkerMessageType =
| "player_profile"
| "player_profile_result"
| "player_border_tiles"
| "player_border_tiles_result";
| "player_border_tiles_result"
| "transport_ship_spawn"
| "transport_ship_spawn_result";
// Base interface for all messages
interface BaseWorkerMessage {
@@ -84,6 +87,17 @@ export interface PlayerBorderTilesResultMessage extends BaseWorkerMessage {
result: PlayerBorderTiles;
}
export interface TransportShipSpawnMessage extends BaseWorkerMessage {
type: "transport_ship_spawn";
playerID: PlayerID;
targetTile: TileRef;
}
export interface TransportShipSpawnResultMessage extends BaseWorkerMessage {
type: "transport_ship_spawn_result";
result: TileRef | false;
}
// Union types for type safety
export type MainThreadMessage =
| HeartbeatMessage
@@ -91,7 +105,8 @@ export type MainThreadMessage =
| TurnMessage
| PlayerActionsMessage
| PlayerProfileMessage
| PlayerBorderTilesMessage;
| PlayerBorderTilesMessage
| TransportShipSpawnMessage;
// Message send from worker
export type WorkerMessage =
@@ -99,4 +114,5 @@ export type WorkerMessage =
| GameUpdateMessage
| PlayerActionsResultMessage
| PlayerProfileResultMessage
| PlayerBorderTilesResultMessage;
| PlayerBorderTilesResultMessage
| TransportShipSpawnResultMessage;
+4
View File
@@ -9,6 +9,7 @@ const maps = [
"WorldMap",
"BlackSea",
"Europe",
"EuropeClassic",
"Mars",
"Mena",
"Oceania",
@@ -23,6 +24,9 @@ const maps = [
"Japan",
"KnownWorld",
"FaroeIslands",
"DeglaciatedAntarctica",
"FalklandIslands",
"Baikal",
];
const removeSmall = true;
-61
View File
@@ -1,61 +0,0 @@
import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
import { Client, Events, GatewayIntentBits } from "discord.js";
export class DiscordBot {
private client: Client;
private secretManager: SecretManagerServiceClient;
constructor() {
this.client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
this.secretManager = new SecretManagerServiceClient();
this.setupEventHandlers();
}
private setupEventHandlers(): void {
this.client.once(Events.ClientReady, (c) => {
console.log(`Ready! Logged in as ${c.user.tag}`);
});
this.client.on(Events.MessageCreate, async (message) => {
if (message.author.bot) return;
if (message.content === "!ping") {
await message.reply("Pong! 🏓");
}
if (message.content === "!hello") {
await message.reply(`Hello ${message.author.username}! 👋`);
}
});
}
private async getToken(): Promise<string | undefined> {
const name =
"projects/openfrontio/secrets/discord-bot-token/versions/latest";
const [version] = await this.secretManager.accessSecretVersion({ name });
return version.payload?.data?.toString().trim();
}
public async start(): Promise<void> {
try {
const token = await this.getToken();
if (!token) {
throw new Error("Failed to retrieve Discord token");
}
await this.client.login(token);
} catch (error) {
console.error("Failed to start bot:", error);
throw error;
}
}
public stop(): void {
this.client.destroy();
}
}
+1 -1
View File
@@ -34,12 +34,12 @@ export class GameManager {
gameType: GameType.Private,
difficulty: Difficulty.Medium,
disableNPCs: false,
disableNukes: false,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
gameMode: GameMode.FFA,
bots: 400,
disabledUnits: [],
...gameConfig,
});
this.games.set(id, game);
+64 -34
View File
@@ -1,3 +1,4 @@
import ipAnonymize from "ip-anonymize";
import { Logger } from "winston";
import WebSocket from "ws";
import {
@@ -57,6 +58,8 @@ export class GameServer {
private _hasPrestarted = false;
private kickedClients: Set<ClientID> = new Set();
constructor(
public readonly id: string,
readonly log_: Logger,
@@ -77,9 +80,6 @@ export class GameServer {
if (gameConfig.disableNPCs != null) {
this.gameConfig.disableNPCs = gameConfig.disableNPCs;
}
if (gameConfig.disableNukes != null) {
this.gameConfig.disableNukes = gameConfig.disableNukes;
}
if (gameConfig.bots != null) {
this.gameConfig.bots = gameConfig.bots;
}
@@ -95,13 +95,27 @@ export class GameServer {
if (gameConfig.gameMode != null) {
this.gameConfig.gameMode = gameConfig.gameMode;
}
if (gameConfig.disabledUnits != null) {
this.gameConfig.disabledUnits = gameConfig.disabledUnits;
}
if (gameConfig.playerTeams != null) {
this.gameConfig.playerTeams = gameConfig.playerTeams;
}
}
public addClient(client: Client, lastTurn: number) {
if (this.kickedClients.has(client.clientID)) {
this.log.warn(`cannot add client, already kicked`, {
clientID: client.clientID,
});
return;
}
this.log.info("client (re)joining game", {
clientID: client.clientID,
persistentID: client.persistentID,
clientIP: client.ip,
clientIP: ipAnonymize(client.ip),
isRejoin: lastTurn > 0,
});
@@ -113,7 +127,7 @@ export class GameServer {
) {
this.log.warn("cannot add client, already have 3 ips", {
clientID: client.clientID,
clientIP: client.ip,
clientIP: ipAnonymize(client.ip),
});
return;
}
@@ -123,11 +137,21 @@ export class GameServer {
(c) => c.clientID == client.clientID,
);
if (existing != null) {
if (client.persistentID !== existing.persistentID) {
this.log.error("persistent ids do not match", {
clientID: client.clientID,
clientIP: ipAnonymize(client.ip),
clientPersistentID: client.persistentID,
existingIP: ipAnonymize(existing.ip),
existingPersistentID: existing.persistentID,
});
return;
}
existing.ws.removeAllListeners("message");
this.activeClients = this.activeClients.filter(
(c) => c.clientID != client.clientID,
);
}
this.activeClients = this.activeClients.filter(
(c) => c.clientID != client.clientID,
);
this.activeClients.push(client);
client.lastPing = Date.now();
@@ -141,34 +165,16 @@ export class GameServer {
try {
clientMsg = ClientMessageSchema.parse(JSON.parse(message));
} catch (error) {
throw Error(`error parsing schema for ${client.ip}`);
throw Error(`error parsing schema for ${ipAnonymize(client.ip)}`);
}
if (this.allClients.has(clientMsg.clientID)) {
const client = this.allClients.get(clientMsg.clientID);
if (client.persistentID != clientMsg.persistentID) {
if (clientMsg.type == "intent") {
if (clientMsg.intent.clientID != client.clientID) {
this.log.warn(
`Client ID ${clientMsg.clientID} sent incorrect id ${clientMsg.persistentID}, does not match persistent id ${client.persistentID}`,
{
clientID: clientMsg.clientID,
persistentID: clientMsg.persistentID,
},
`client id mismatch, client: ${client.clientID}, intent: ${clientMsg.intent.clientID}`,
);
return;
}
}
// Clear out persistent id to make sure it doesn't get sent to other clients.
clientMsg.persistentID = null;
if (clientMsg.type == "intent") {
if (clientMsg.gameID == this.id) {
this.addIntent(clientMsg.intent);
} else {
this.log.warn("client sent to wrong game", {
clientID: clientMsg.clientID,
persistentID: clientMsg.persistentID,
});
}
this.addIntent(clientMsg.intent);
}
if (clientMsg.type == "ping") {
this.lastPingUpdate = Date.now();
@@ -318,7 +324,6 @@ export class GameServer {
private endTurn() {
const pastTurn: Turn = {
turnNumber: this.turns.length,
gameID: this.id,
intents: this.intents,
};
this.turns.push(pastTurn);
@@ -356,7 +361,7 @@ export class GameServer {
client.ws.close(1000, "game has ended");
}
});
if (!this._hasPrestarted || !this._hasStarted) {
if (!this._hasPrestarted && !this._hasStarted) {
this.log.info(`game not started, not archiving game`);
return;
}
@@ -366,7 +371,7 @@ export class GameServer {
const playerRecords: PlayerRecord[] = Array.from(
this.allClients.values(),
).map((client) => ({
ip: client.ip,
ip: ipAnonymize(client.ip),
clientID: client.clientID,
username: client.username,
persistentID: client.persistentID,
@@ -496,6 +501,31 @@ export class GameServer {
return this.gameConfig.gameType == GameType.Public;
}
public kickClient(clientID: ClientID): void {
if (this.kickedClients.has(clientID)) {
this.log.warn(`cannot kick client, already kicked`, {
clientID,
});
return;
}
const client = this.activeClients.find((c) => c.clientID === clientID);
if (client) {
this.log.info("Kicking client from game", {
clientID: client.clientID,
persistentID: client.persistentID,
});
client.ws.close(1000, "Kicked from game");
this.activeClients = this.activeClients.filter(
(c) => c.clientID !== clientID,
);
this.kickedClients.add(clientID);
} else {
this.log.warn(`cannot kick client, not found in game`, {
clientID,
});
}
}
private handleSynchronization() {
if (this.activeClients.length <= 1) {
return;
+56 -1
View File
@@ -1,4 +1,56 @@
import * as logsAPI from "@opentelemetry/api-logs";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
import {
LoggerProvider,
SimpleLogRecordProcessor,
} from "@opentelemetry/sdk-logs";
import { OpenTelemetryTransportV3 } from "@opentelemetry/winston-transport";
import * as dotenv from "dotenv";
import winston from "winston";
import { GameEnv } from "../core/configuration/Config";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { getOtelResource } from "./OtelResource";
dotenv.config();
const config = getServerConfigFromServer();
const resource = getOtelResource();
// Initialize the OpenTelemetry Logger Provider
const loggerProvider = new LoggerProvider({
resource,
});
if (config.env() == GameEnv.Prod && config.otelEnabled()) {
console.log("OTEL enabled");
// Configure OpenTelemetry endpoint with basic auth (if provided)
const headers = {};
if (config.otelUsername() && config.otelPassword()) {
headers["Authorization"] =
"Basic " +
Buffer.from(`${config.otelUsername()}:${config.otelPassword()}`).toString(
"base64",
);
}
// Add OTLP exporter for logs
const logExporter = new OTLPLogExporter({
url: `${config.otelEndpoint()}/v1/logs`,
headers,
});
// Add a log processor with the exporter
loggerProvider.addLogRecordProcessor(
new SimpleLogRecordProcessor(logExporter),
);
// Set as the global logger provider
logsAPI.logs.setGlobalLoggerProvider(loggerProvider);
} else {
console.log(
"No OTLP endpoint and credentials provided, remote logging disabled",
);
}
// Custom format to add severity tag based on log level
const addSeverityFormat = winston.format((info) => {
@@ -20,7 +72,10 @@ const logger = winston.createLogger({
service: "openfront",
environment: process.env.NODE_ENV,
},
transports: [new winston.transports.Console()],
transports: [
new winston.transports.Console(),
new OpenTelemetryTransportV3(),
],
});
// Export both the main logger and the child logger factory
+110 -93
View File
@@ -1,118 +1,135 @@
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,
FalklandIslands: 1,
Baikal: 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,
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;
}
}
+35 -40
View File
@@ -5,13 +5,11 @@ import http from "http";
import path from "path";
import { fileURLToPath } from "url";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { Difficulty, GameMode, GameType } from "../core/game/Game";
import { GameConfig, GameInfo } from "../core/Schemas";
import { GameInfo } from "../core/Schemas";
import { generateID } from "../core/Util";
import { gatekeeper, LimiterType } from "./Gatekeeper";
import { logger } from "./Logger";
import { MapPlaylist } from "./MapPlaylist";
import { setupMetricsServer } from "./MasterMetrics";
const config = getServerConfigFromServer();
const playlist = new MapPlaylist();
@@ -20,10 +18,6 @@ const readyWorkers = new Set();
const app = express();
const server = http.createServer(app);
// Create a separate metrics server on port 9090
const metricsApp = express();
const metricsServer = http.createServer(metricsApp);
const log = logger.child({ comp: "m" });
const __filename = fileURLToPath(import.meta.url);
@@ -146,9 +140,6 @@ export async function startMaster() {
server.listen(PORT, () => {
log.info(`Master HTTP server listening on port ${PORT}`);
});
// Setup the metrics server
setupMetricsServer();
}
app.get(
@@ -169,6 +160,39 @@ app.get(
}),
);
app.post(
"/api/kick_player/:gameID/:clientID",
gatekeeper.httpHandler(LimiterType.Post, async (req, res) => {
if (req.headers[config.adminHeader()] !== config.adminToken()) {
res.status(401).send("Unauthorized");
return;
}
const { gameID, clientID } = req.params;
try {
const response = await fetch(
`http://localhost:${config.workerPort(gameID)}/api/kick_player/${gameID}/${clientID}`,
{
method: "POST",
headers: {
[config.adminHeader()]: config.adminToken(),
},
},
);
if (!response.ok) {
throw new Error(`Failed to kick player: ${response.statusText}`);
}
res.status(200).send("Player kicked successfully");
} catch (error) {
log.error(`Error kicking player from game ${gameID}:`, error);
res.status(500).send("Failed to kick player");
}
}),
);
async function fetchLobbies(): Promise<number> {
const fetchPromises = [];
@@ -222,40 +246,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 +264,7 @@ async function schedulePublicGame(playlist: MapPlaylist) {
[config.adminHeader()]: config.adminToken(),
},
body: JSON.stringify({
gameConfig: defaultGameConfig,
gameConfig: playlist.gameConfig(),
}),
},
);
-189
View File
@@ -1,189 +0,0 @@
import express from "express";
import http from "http";
import promClient from "prom-client";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
const config = getServerConfigFromServer();
// Create a separate metrics server on port 9090
const metricsApp = express();
const metricsServer = http.createServer(metricsApp);
// Initialize the Prometheus registry for the master's own metrics
const register = new promClient.Registry();
// Default Prometheus metrics
promClient.collectDefaultMetrics({ register });
// Prometheus metrics endpoint that gathers metrics from workers
export function setupMetricsServer() {
metricsApp.get("/metrics", async (req, res) => {
// Set a timeout for the request to avoid hanging
const timeout = setTimeout(() => {
res.status(500).end("# Error: Request timed out after 30 seconds");
}, 30000);
console.log("Metrics requested");
try {
// Get the master's metrics
const masterMetrics = await register.metrics();
// Track seen metric names to avoid duplicate metadata
const seenMetrics = new Set();
const processedLines = [];
const allMetricValues = [];
// Process all metadata information in the master metrics first
const masterLines = masterMetrics.split("\n");
for (let j = 0; j < masterLines.length; j++) {
const line = masterLines[j];
if (line.startsWith("# HELP ")) {
const metricName = line.split(" ")[2];
seenMetrics.add(metricName);
processedLines.push(line);
} else if (line.startsWith("# TYPE ")) {
const metricName = line.split(" ")[2];
if (seenMetrics.has(metricName)) {
processedLines.push(line);
}
} else if (line.trim() && !line.startsWith("#")) {
// Add worker label to each metric line and collect for later
const processedLine = line.replace(
/^([a-z][a-z0-9_]*)(?:{([^}]*)})?(\s+[0-9.e+-]+.*)/,
(match, metricName, existingLabels, valueAndRest) => {
if (existingLabels) {
return `${metricName}{${existingLabels},worker="master"}${valueAndRest}`;
} else {
return `${metricName}{worker="master"}${valueAndRest}`;
}
},
);
allMetricValues.push(processedLine);
}
}
// Collect metrics from all workers
for (let i = 0; i < config.numWorkers(); i++) {
const workerPort = config.workerPortByIndex(i);
const workerUrl = `http://localhost:${workerPort}/metrics`;
console.log(`Fetching metrics from worker ${i} at ${workerUrl}`);
try {
const response = await fetch(workerUrl, {
headers: {
[config.adminHeader()]: config.adminToken(),
},
});
if (!response.ok) {
console.error(`Worker ${i} returned status ${response.status}`);
continue;
}
const metricsText = await response.text();
const lines = metricsText.split("\n");
for (let j = 0; j < lines.length; j++) {
const line = lines[j];
// Collect HELP and TYPE info if we haven't seen this metric before
if (line.startsWith("# HELP ")) {
const metricName = line.split(" ")[2];
if (!seenMetrics.has(metricName)) {
seenMetrics.add(metricName);
processedLines.push(line);
}
} else if (line.startsWith("# TYPE ")) {
const metricName = line.split(" ")[2];
if (
seenMetrics.has(metricName) &&
!processedLines.some((l) =>
l.startsWith(`# TYPE ${metricName}`),
)
) {
processedLines.push(line);
}
} else if (line.trim() && !line.startsWith("#")) {
// Process and collect actual metric values
try {
const processedLine = line.replace(
/^([a-z][a-z0-9_]*)(?:{([^}]*)})?(\s+[0-9.e+-]+.*)/,
(match, metricName, existingLabels, valueAndRest) => {
if (existingLabels) {
return `${metricName}{${existingLabels},worker="worker-${i}"}${valueAndRest}`;
} else {
return `${metricName}{worker="worker-${i}"}${valueAndRest}`;
}
},
);
// Make sure the line was actually processed (regex matched)
if (processedLine !== line) {
allMetricValues.push(processedLine);
} else if (
line.match(/^[a-z][a-z0-9_]*(?:{[^}]*})?\s+[0-9.e+-]+.*/)
) {
// This looks like a metric line but didn't match our regex, try a more general approach
const parts = line.split(/({|\s+)/);
if (parts.length >= 3) {
const metricName = parts[0];
if (line.includes("{")) {
// Has labels
const labelEndIndex = line.indexOf("}");
const valueStartIndex = labelEndIndex + 1;
if (labelEndIndex > 0 && valueStartIndex < line.length) {
const labels = line.substring(
line.indexOf("{") + 1,
labelEndIndex,
);
const valueAndRest = line.substring(valueStartIndex);
allMetricValues.push(
`${metricName}{${labels},worker="worker-${i}"}${valueAndRest}`,
);
}
} else {
// No labels
const valueAndRest = line.substring(metricName.length);
allMetricValues.push(
`${metricName}{worker="worker-${i}"}${valueAndRest}`,
);
}
}
}
} catch (error) {
console.error(`Error processing metric line: ${line}`, error);
// Skip this line if there's an error
}
}
}
} catch (error) {
console.error(`Error fetching metrics from worker ${i}:`, error);
allMetricValues.push(
`# Error fetching metrics from worker ${i}: ${error.message}`,
);
}
}
// Combine metadata with all metric values and ensure it ends with a newline
const combinedMetrics = [...processedLines, ...allMetricValues].join(
"\n",
);
// Send the combined response with a final newline to prevent unexpected end of input
clearTimeout(timeout);
res.set("Content-Type", register.contentType);
res.end(combinedMetrics + "\n");
} catch (error) {
console.error("Error collecting metrics:", error);
clearTimeout(timeout);
res.status(500).end(`# Error collecting metrics: ${error.message}`);
}
});
// Start the metrics server on port 9090
const METRICS_PORT = 9090;
metricsServer.listen(METRICS_PORT, () => {
console.log(`Metrics server listening on port ${METRICS_PORT}`);
});
}
+27
View File
@@ -0,0 +1,27 @@
import { resourceFromAttributes } from "@opentelemetry/resources";
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from "@opentelemetry/semantic-conventions";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
const config = getServerConfigFromServer();
export function getOtelResource() {
return resourceFromAttributes({
[ATTR_SERVICE_NAME]: "openfront",
[ATTR_SERVICE_VERSION]: "1.0.0",
"service.instance.id": process.env.HOSTNAME,
"openfront.environment": config.env(),
"openfront.host": process.env.HOST,
"openfront.domain": process.env.DOMAIN,
"openfront.subdomain": process.env.SUBDOMAIN,
"openfront.component": process.env.WORKER_ID
? "Worker " + process.env.WORKER_ID
: "Master",
// The comma-separated list tells OpenTelemetry which resource attributes
// should be converted to Loki labels
"loki.resource.labels":
"service.name,service.instance.id,openfront.environment,openfront.host,openfront.domain,openfront.subdomain,openfront.component",
});
}

Some files were not shown because too many files have changed in this diff Show More