Merge main into nations-nuke

This commit is contained in:
Scott Anderson
2025-04-02 21:40:19 -04:00
63 changed files with 3153 additions and 1581 deletions
+37 -10
View File
@@ -25,7 +25,7 @@ import { loadTerrainMap } from "../core/game/TerrainMapLoader";
import { UserSettings } from "../core/game/UserSettings";
import { WorkerClient } from "../core/worker/WorkerClient";
import { InputHandler, MouseMoveEvent, MouseUpEvent } from "./InputHandler";
import { LocalPersistantStats } from "./LocalPersistantStats";
import { endGame, startGame, startTime } from "./LocalPersistantStats";
import { getPersistentIDFromCookie } from "./Main";
import {
SendAttackIntentEvent,
@@ -36,6 +36,7 @@ import {
import { createCanvas } from "./Utils";
import { createRenderer, GameRenderer } from "./graphics/GameRenderer";
export // Is this function needed?
function distSortUnitWorld(tile: TileRef, game: GameView) {
return (a: Unit | UnitView, b: Unit | UnitView) => {
return (
@@ -69,10 +70,7 @@ export function joinLobby(
);
const userSettings: UserSettings = new UserSettings();
LocalPersistantStats.startGame(
lobbyConfig.gameID,
lobbyConfig.gameStartInfo?.config,
);
startGame(lobbyConfig.gameID, lobbyConfig.gameStartInfo?.config);
const transport = new Transport(lobbyConfig, eventBus);
@@ -158,8 +156,9 @@ export class ClientGameRunner {
private hasJoined = false;
private lastMousePosition: { x: number; y: number } | null = null;
private mouseHoverTimer: number | null = null;
private readonly HOVER_DELAY = 200;
private lastMessageTime: number = 0;
private connectionCheckInterval: NodeJS.Timeout | null = null;
constructor(
private lobby: LobbyConfig,
@@ -169,7 +168,9 @@ export class ClientGameRunner {
private transport: Transport,
private worker: WorkerClient,
private gameView: GameView,
) {}
) {
this.lastMessageTime = Date.now();
}
private saveGame(update: WinUpdate) {
const players: PlayerRecord[] = [
@@ -195,18 +196,25 @@ export class ClientGameRunner {
players,
// Not saving turns locally
[],
LocalPersistantStats.startTime(),
startTime(),
Date.now(),
winner,
update.winnerType,
update.allPlayersStats,
);
LocalPersistantStats.endGame(record);
endGame(record);
}
public start() {
consolex.log("starting client game");
this.isActive = true;
this.lastMessageTime = Date.now();
setTimeout(() => {
this.connectionCheckInterval = setInterval(
() => this.onConnectionCheck(),
1000,
);
}, 20000);
this.eventBus.on(MouseUpEvent, (e) => this.inputEvent(e));
this.eventBus.on(MouseMoveEvent, (e) => this.onMouseMove(e));
@@ -245,6 +253,7 @@ export class ClientGameRunner {
this.transport.joinGame(this.turnsSeen);
};
const onmessage = (message: ServerMessage) => {
this.lastMessageTime = Date.now();
if (message.type == "start") {
this.hasJoined = true;
consolex.log("starting game!");
@@ -296,6 +305,10 @@ export class ClientGameRunner {
this.worker.cleanup();
this.isActive = false;
this.transport.leaveGame(saveFullGame);
if (this.connectionCheckInterval) {
clearInterval(this.connectionCheckInterval);
this.connectionCheckInterval = null;
}
}
private inputEvent(event: MouseUpEvent) {
@@ -391,6 +404,20 @@ export class ClientGameRunner {
}
}
}
private onConnectionCheck() {
if (this.transport.isLocal) {
return;
}
const timeSinceLastMessage = Date.now() - this.lastMessageTime;
if (timeSinceLastMessage > 5000) {
console.log(
`No message from server for ${timeSinceLastMessage} ms, reconnecting`,
);
this.lastMessageTime = Date.now();
this.transport.reconnect();
}
}
}
function showErrorModal(
+2 -2
View File
@@ -3,7 +3,7 @@ import { customElement, property } from "lit/decorators.js";
declare global {
interface Window {
adsbygoogle: any[];
adsbygoogle: unknown[];
}
}
@@ -89,7 +89,7 @@ const isElectron = () => {
if (
typeof window !== "undefined" &&
typeof window.process === "object" &&
// @ts-ignore
// @ts-expect-error hidden
window.process.type === "renderer"
) {
return true;
+2
View File
@@ -432,6 +432,7 @@ export class HostLobbyModal extends LitElement {
} as GameConfig),
},
);
return response;
}
private getRandomMap(): GameMapType {
@@ -460,6 +461,7 @@ export class HostLobbyModal extends LitElement {
},
},
);
return response;
}
private async copyToClipboard() {
+1 -1
View File
@@ -93,7 +93,7 @@ export class InputHandler {
private alternateView = false;
private moveInterval: any = null;
private moveInterval: NodeJS.Timeout = null;
private activeKeys = new Set<string>();
private readonly PAN_SPEED = 5;
+35 -33
View File
@@ -20,10 +20,12 @@ const translations = {
es: esTranslations,
};
type Translation = Partial<(typeof translations)[keyof typeof translations]>;
@customElement("lang-selector")
export class LangSelector extends LitElement {
@state() public translations: any = {};
@state() private defaultTranslations: any = {};
@state() public translations: Translation = {};
@state() private defaultTranslations = {};
@state() private currentLang: string = "en";
createRenderRoot() {
@@ -44,10 +46,10 @@ export class LangSelector extends LitElement {
this.translations = await this.loadLanguage(userLang);
this.currentLang = userLang;
this.applyTranslation(this.translations);
this.applyTranslation();
}
private async loadLanguage(lang: string): Promise<any> {
private async loadLanguage(lang: string): Promise<Translation> {
try {
const translation = translations[lang as keyof typeof translations];
if (!translation) throw new Error(`Language file not found: ${lang}`);
@@ -58,7 +60,7 @@ export class LangSelector extends LitElement {
}
}
private applyTranslation(translations: any) {
private applyTranslation() {
const components = [
"single-player-modal",
"host-lobby-modal",
@@ -75,24 +77,14 @@ export class LangSelector extends LitElement {
"public-lobby",
];
document.title = translations.main?.title || document.title;
const main = this.translations.main;
if (main && "title" in main) {
document.title = main.title;
}
document.querySelectorAll("[data-i18n]").forEach((element) => {
const key = element.getAttribute("data-i18n");
const keys = key.split(".");
let text = translations;
for (const k of keys) {
text = text?.[k];
if (!text) break;
}
if (!text && this.defaultTranslations) {
let fallback = this.defaultTranslations;
for (const k of keys) {
fallback = fallback?.[k];
if (!fallback) break;
}
text = fallback;
}
const text = this.translateText(key);
if (text) {
element.innerHTML = text;
} else {
@@ -101,7 +93,7 @@ export class LangSelector extends LitElement {
});
components.forEach((tagName) => {
const el = document.querySelector(tagName) as any;
const el = document.querySelector(tagName) as LitElement;
if (el && typeof el.requestUpdate === "function") {
el.requestUpdate();
} else {
@@ -117,19 +109,13 @@ export class LangSelector extends LitElement {
params: Record<string, string | number> = {},
): string {
const keys = key.split(".");
let text: any = this.translations;
for (const k of keys) {
text = text?.[k];
if (!text) break;
let text = findTranslation(keys, this.translations);
if (!text && this.defaultTranslations) {
text = findTranslation(keys, this.defaultTranslations);
}
if (!text && this.defaultTranslations) {
text = this.defaultTranslations;
for (const k of keys) {
text = text?.[k];
if (!text) return key;
}
if (text == null || typeof text !== "string") {
return null;
}
for (const [param, value] of Object.entries(params)) {
@@ -143,7 +129,7 @@ export class LangSelector extends LitElement {
localStorage.setItem("lang", lang);
this.translations = await this.loadLanguage(lang);
this.currentLang = lang;
this.applyTranslation(this.translations);
this.applyTranslation();
}
render() {
@@ -178,3 +164,19 @@ export class LangSelector extends LitElement {
`;
}
}
function findTranslation(
keys: string[],
translations: Translation,
): string | null {
let ptr: unknown = translations;
for (const k of keys) {
ptr = ptr?.[k];
if (!ptr) break;
}
if (ptr && typeof ptr === "string") {
return ptr;
} else {
return null;
}
}
+46 -48
View File
@@ -9,53 +9,51 @@ export interface LocalStatsData {
};
}
export namespace LocalPersistantStats {
let _startTime: number;
let _startTime: number;
function getStats(): LocalStatsData {
const statsStr = localStorage.getItem("game-records");
return statsStr ? JSON.parse(statsStr) : {};
}
function save(stats: LocalStatsData) {
// To execute asynchronously
setTimeout(
() => localStorage.setItem("game-records", JSON.stringify(stats)),
0,
);
}
// The user can quit the game anytime so better save the lobby as soon as the
// game starts.
export function startGame(id: GameID, lobby: GameConfig) {
if (typeof localStorage === "undefined") {
return;
}
_startTime = Date.now();
const stats = getStats();
stats[id] = { lobby };
save(stats);
}
export function startTime() {
return _startTime;
}
export function endGame(gameRecord: GameRecord) {
if (typeof localStorage === "undefined") {
return;
}
const stats = getStats();
const gameStat = stats[gameRecord.id];
if (!gameStat) {
consolex.log("LocalPersistantStats: game not found");
return;
}
gameStat.gameRecord = gameRecord;
save(stats);
}
function getStats(): LocalStatsData {
const statsStr = localStorage.getItem("game-records");
return statsStr ? JSON.parse(statsStr) : {};
}
function save(stats: LocalStatsData) {
// To execute asynchronously
setTimeout(
() => localStorage.setItem("game-records", JSON.stringify(stats)),
0,
);
}
// The user can quit the game anytime so better save the lobby as soon as the
// game starts.
export function startGame(id: GameID, lobby: GameConfig) {
if (typeof localStorage === "undefined") {
return;
}
_startTime = Date.now();
const stats = getStats();
stats[id] = { lobby };
save(stats);
}
export function startTime() {
return _startTime;
}
export function endGame(gameRecord: GameRecord) {
if (typeof localStorage === "undefined") {
return;
}
const stats = getStats();
const gameStat = stats[gameRecord.id];
if (!gameStat) {
consolex.log("LocalPersistantStats: game not found");
return;
}
gameStat.gameRecord = gameRecord;
save(stats);
}
+2 -2
View File
@@ -84,7 +84,7 @@ class Client {
"google-ad",
) as NodeListOf<GoogleAdElement>;
window.addEventListener("beforeunload", (event) => {
window.addEventListener("beforeunload", () => {
consolex.log("Browser is closing");
if (this.gameStop != null) {
this.gameStop();
@@ -213,7 +213,7 @@ class Client {
);
}
private async handleLeaveLobby(event: CustomEvent) {
private async handleLeaveLobby(/* event: CustomEvent */) {
if (this.gameStop == null) {
return;
}
+12 -11
View File
@@ -2,6 +2,7 @@ import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { translateText } from "../client/Utils";
import { consolex } from "../core/Consolex";
import { GameMode } from "../core/game/Game";
import { GameID, GameInfo } from "../core/Schemas";
import { generateID } from "../core/Util";
import { JoinLobbyEvent } from "./Main";
@@ -115,26 +116,26 @@ export class PublicLobby extends LitElement {
style="border: 1px solid rgba(255, 255, 255, 0.5)"
/>
<div
class="w-full flex flex-col md:flex-row items-center justify-center gap-4"
class="w-full flex flex-col md:flex-row items-center justify-center md:justify-evenly"
>
<div class="flex flex-col items-start">
<div class="text-md font-medium text-blue-100">
<div class="flex flex-col items-center">
<div class="text-md font-medium text-blue-100 mb-4">
<!-- ${lobby.gameConfig.gameMap} -->
${translateText(
`map.${lobby.gameConfig.gameMap.toLowerCase().replace(/\s+/g, "")}`,
)}
</div>
</div>
<div class="flex flex-col items-start">
<div class="text-md font-medium text-blue-100">
${lobby.numClients} / ${lobby.gameConfig.maxPlayers}
${translateText("public_lobby.waiting")}
${lobby.gameConfig.gameMode == GameMode.Team
? translateText("game_mode.teams")
: translateText("game_mode.ffa")}
</div>
</div>
<div class="flex items-center">
<div
class="min-w-20 text-sm font-medium px-2 py-1 bg-white/10 rounded-xl text-blue-100 text-center"
>
<div class="flex flex-col items-center">
<div class="text-md font-medium text-blue-100 mb-2">
${lobby.numClients} / ${lobby.gameConfig.maxPlayers}
</div>
<div class="text-md font-medium text-blue-100">
${timeDisplay}
</div>
</div>
+8 -5
View File
@@ -158,8 +158,7 @@ export class Transport {
private onmessage: (msg: ServerMessage) => void;
private pingInterval: number | null = null;
private isLocal: boolean;
public readonly isLocal: boolean;
constructor(
private lobbyConfig: LobbyConfig,
private eventBus: EventBus,
@@ -267,7 +266,7 @@ export class Transport {
onmessage: (message: ServerMessage) => void,
) {
this.startPing();
this.maybeKillSocket();
this.killExistingSocket();
const wsHost = window.location.host;
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const workerPath = this.lobbyConfig.serverConfig.workerPath(
@@ -304,11 +303,15 @@ export class Transport {
);
if (event.code != 1000) {
console.log(`reconnecting`);
this.connect(onconnect, onmessage);
this.reconnect();
}
};
}
public reconnect() {
this.connect(this.onconnect, this.onmessage);
}
private onSendLogEvent(event: SendLogEvent) {
this.sendMsg(
JSON.stringify(
@@ -586,7 +589,7 @@ export class Transport {
}
}
private maybeKillSocket(): void {
private killExistingSocket(): void {
if (this.socket == null) {
return;
}
+2
View File
@@ -21,6 +21,8 @@ export const MapDescription: Record<keyof typeof GameMapType, string> = {
Australia: "Australia",
Iceland: "Iceland",
Japan: "Japan",
TwoSeas: "Between Two Seas",
KnownWorld: "Known World",
};
@customElement("map-display")
-1
View File
@@ -40,7 +40,6 @@ export function createRenderer(
const startingModal = document.querySelector(
"game-starting-modal",
) as GameStartingModal;
startingModal instanceof GameStartingModal;
startingModal.hide();
// TODO maybe append this to dcoument instead of querying for them?
+11 -10
View File
@@ -2,16 +2,17 @@ import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
const emojiTable: string[][] = [
["😀", "😱", "🤡", "😡", "🥺"],
["😈", "👏", "🥉", "🥈", "🥇"],
["🤙", "🥰", "😇", "😊", "🔥"],
["💪", "🏳️", "💀", "😭", "🫡"],
["🤦‍♂️", "👎", "👍", "🥱", "💔"],
["😎", "❤️", "💰", "🤝", "🖕"],
["💥", "🆘", "🕊️", "", ""],
["", "", "↗️", "⬆️", "↘️"],
["", "", "", "☢️", "⚠️"],
["😭", "😞", "👋", "🐀", ""],
["😀", "😊", "🥰", "😇", "😎"],
["😞", "🥺", "😭", "😱", "😡"],
["😈", "🤡", "🖕", "🥱", "🤦‍♂️"],
["👋", "👏", "🤌", "💪", "🫡"],
["👍", "👎", "", "🐔", "🐀"],
["🤝", "🆘", "🕊️", "🏳️", ""],
["🔥", "💥", "💀", "", ""],
["", "", "↗️", "👑", "🥇"],
["", "🎯", "➡️", "🥈", "🥉"],
["↙️", "⬇️", "↘️", "❤️", "💔"],
["💰", "⚓", "⛵", "🏡", "🛡️"],
];
@customElement("emoji-table")
+44 -17
View File
@@ -61,6 +61,7 @@ export class EventsDisplay extends LitElement implements Layer {
private events: Event[] = [];
@state() private incomingAttacks: AttackUpdate[] = [];
@state() private outgoingAttacks: AttackUpdate[] = [];
@state() private outgoingLandAttacks: AttackUpdate[] = [];
@state() private outgoingBoats: UnitView[] = [];
@state() private _hidden: boolean = false;
@state() private newEvents: number = 0;
@@ -134,6 +135,10 @@ export class EventsDisplay extends LitElement implements Layer {
.outgoingAttacks()
.filter((a) => a.targetID != 0);
this.outgoingLandAttacks = myPlayer
.outgoingAttacks()
.filter((a) => a.targetID == 0);
this.outgoingBoats = myPlayer
.units()
.filter((u) => u.type() === UnitType.TransportShip);
@@ -396,14 +401,7 @@ export class EventsDisplay extends LitElement implements Layer {
: event.description;
}
private renderAttacks() {
if (
this.incomingAttacks.length === 0 &&
this.outgoingAttacks.length === 0
) {
return html``;
}
private renderIncomingAttacks() {
return html`
${this.incomingAttacks.length > 0
? html`
@@ -431,6 +429,11 @@ export class EventsDisplay extends LitElement implements Layer {
</tr>
`
: ""}
`;
}
private renderOutgoingAttacks() {
return html`
${this.outgoingAttacks.length > 0
? html`
<tr class="border-t border-gray-700">
@@ -467,6 +470,37 @@ export class EventsDisplay extends LitElement implements Layer {
`;
}
private renderOutgoingLandAttacks() {
return html`
${this.outgoingLandAttacks.length > 0
? html`
<tr class="border-t border-gray-700">
<td class="lg:p-3 p-1 text-left text-gray-400">
${this.outgoingLandAttacks.map(
(landAttack) => html`
<button translate="no" class="ml-2">
${renderTroops(landAttack.troops)} Wilderness
</button>
${!landAttack.retreating
? html`<button
${landAttack.retreating ? "disabled" : ""}
@click=${() => {
this.emitCancelAttackIntent(landAttack.id);
}}
>
</button>`
: "(retreating...)"}
`,
)}
</td>
</tr>
`
: ""}
`;
}
private renderBoats() {
if (this.outgoingBoats.length === 0) {
return html``;
@@ -497,14 +531,6 @@ export class EventsDisplay extends LitElement implements Layer {
}
render() {
if (
this.events.length === 0 &&
this.incomingAttacks.length === 0 &&
this.outgoingAttacks.length === 0 &&
this.outgoingBoats.length === 0
) {
return html``;
}
this.events.sort((a, b) => {
const aPrior = a.priority ?? 100000;
const bPrior = b.priority ?? 100000;
@@ -602,7 +628,8 @@ export class EventsDisplay extends LitElement implements Layer {
</tr>
`,
)}
${this.renderAttacks()} ${this.renderBoats()}
${this.renderIncomingAttacks()} ${this.renderOutgoingAttacks()}
${this.renderOutgoingLandAttacks()} ${this.renderBoats()}
</tbody>
</table>
</div>
+14
View File
@@ -106,6 +106,11 @@ export class OptionsMenu extends LitElement implements Layer {
this.eventBus.emit(new RefreshGraphicsEvent());
}
private onToggleFocusLockedButtonClick() {
this.userSettings.toggleFocusLocked();
this.requestUpdate();
}
private onToggleLeftClickOpensMenu() {
this.userSettings.toggleLeftClickOpenMenu();
}
@@ -200,6 +205,15 @@ export class OptionsMenu extends LitElement implements Layer {
? "Opens menu"
: "Attack"),
})}
${button({
onClick: this.onToggleFocusLockedButtonClick,
title: "Lock Focus",
children:
"🗺: " +
(this.userSettings.focusLocked()
? "Focus locked"
: "Hover focus"),
})}
</div>
</div>
`;
+37 -8
View File
@@ -1,34 +1,63 @@
import { blue, red } from "../../../core/configuration/Colors";
import { GameMode, TeamName } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
export class SpawnTimer implements Layer {
private ratio = 0;
private leftColor = "rgba(0, 128, 255, 0.7)";
private rightColor = "rgba(0, 0, 0, 0.5)";
constructor(
private game: GameView,
private transformHandler: TransformHandler,
) {}
init() {}
tick() {}
tick() {
if (this.game.inSpawnPhase()) {
this.ratio = this.game.ticks() / this.game.config().numSpawnPhaseTurns();
return;
}
if (this.game.config().gameConfig().gameMode != GameMode.Team) {
this.ratio = 0;
return;
}
const numBlueTiles = this.game
.players()
.filter((p) => p.teamName() == TeamName.Blue)
.reduce((acc, p) => acc + p.numTilesOwned(), 0);
const numRedTiles = this.game
.players()
.filter((p) => p.teamName() == TeamName.Red)
.reduce((acc, p) => acc + p.numTilesOwned(), 0);
this.ratio = numBlueTiles / (numBlueTiles + numRedTiles);
this.leftColor = blue.toRgbString();
this.rightColor = red.toRgbString();
}
shouldTransform(): boolean {
return false;
}
renderLayer(context: CanvasRenderingContext2D) {
if (!this.game.inSpawnPhase()) {
if (this.ratio == 0) {
return;
}
const barHeight = 15;
const barHeight = 10;
const barBackgroundWidth = this.transformHandler.width();
const ratio = this.game.ticks() / this.game.config().numSpawnPhaseTurns();
// Draw bar background
context.fillStyle = "rgba(0, 0, 0, 0.5)";
context.fillStyle = this.rightColor;
context.fillRect(0, 0, barBackgroundWidth, barHeight);
context.fillStyle = "rgba(0, 128, 255, 0.7)";
context.fillRect(0, 0, barBackgroundWidth * ratio, barHeight);
context.fillStyle = this.leftColor;
context.fillRect(0, 0, barBackgroundWidth * this.ratio, barHeight);
}
}
+1 -1
View File
@@ -216,7 +216,7 @@ export class StructureLayer implements Layer {
private handleUnitRendering(unit: UnitView) {
const unitType = unit.constructionType() ?? unit.type();
let iconType = unitType;
const iconType = unitType;
if (!this.isUnitTypeSupported(unitType)) return;
const config = this.unitConfigs[unitType];
+4 -3
View File
@@ -12,11 +12,12 @@ import { Layer } from "./Layer";
// Add this at the top of your file
declare global {
interface Window {
adsbygoogle: any[];
adsbygoogle: unknown[];
}
}
// Add this at the top of your file
declare let adsbygoogle: any[];
declare let adsbygoogle: unknown[];
@customElement("win-modal")
export class WinModal extends LitElement implements Layer {
@@ -257,7 +258,7 @@ export class WinModal extends LitElement implements Layer {
});
}
renderLayer(context: CanvasRenderingContext2D) {}
renderLayer(/* context: CanvasRenderingContext2D */) {}
shouldTransform(): boolean {
return false;
+5
View File
@@ -327,6 +327,11 @@ label.option-card:hover {
center / cover;
}
#helpModal .sam-launcher-icon {
mask: url("../../resources/images/SamLauncherIconWhite.svg") no-repeat
center / cover;
}
#helpModal .atom-bomb-icon {
mask: url("../../resources/images/NukeIconWhite.svg") no-repeat center / cover;
}
+6
View File
@@ -7,12 +7,14 @@ import europe from "../../../resources/maps/EuropeThumb.webp";
import gatewayToTheAtlantic from "../../../resources/maps/GatewayToTheAtlanticThumb.webp";
import iceland from "../../../resources/maps/IcelandThumb.webp";
import japan from "../../../resources/maps/JapanThumb.webp";
import knownworld from "../../../resources/maps/KnownWorldThumb.webp";
import mars from "../../../resources/maps/MarsThumb.webp";
import mena from "../../../resources/maps/MenaThumb.webp";
import northAmerica from "../../../resources/maps/NorthAmericaThumb.webp";
import oceania from "../../../resources/maps/OceaniaThumb.webp";
import pangaea from "../../../resources/maps/PangaeaThumb.webp";
import southAmerica from "../../../resources/maps/SouthAmericaThumb.webp";
import twoSeas from "../../../resources/maps/TwoSeasThumb.webp";
import world from "../../../resources/maps/WorldMapThumb.webp";
import { GameMapType } from "../../core/game/Game";
@@ -51,6 +53,10 @@ export function getMapsImage(map: GameMapType): string {
return iceland;
case GameMapType.Japan:
return japan;
case GameMapType.TwoSeas:
return twoSeas;
case GameMapType.KnownWorld:
return knownworld;
default:
return "";
}
+49 -6
View File
@@ -2,6 +2,7 @@ import {
Difficulty,
Game,
GameMapType,
GameMode,
GameType,
Gold,
Player,
@@ -58,15 +59,54 @@ export abstract class DefaultServerConfig implements ServerConfig {
return 60 * 1000;
}
lobbyMaxPlayers(map: GameMapType): number {
if (map == GameMapType.World) {
return Math.random() < 0.3 ? 150 : 60;
}
// Maps with ~4 mil pixels
if (
[GameMapType.Mars, GameMapType.Africa, GameMapType.BlackSea].includes(map)
[
GameMapType.GatewayToTheAtlantic,
GameMapType.SouthAmerica,
GameMapType.NorthAmerica,
GameMapType.Africa,
GameMapType.Europe,
].includes(map)
) {
return Math.random() < 0.3 ? 70 : 50;
return Math.random() < 0.2 ? 150 : 70;
}
return Math.random() < 0.3 ? 60 : 40;
// Maps with ~2.5 - ~3.5 mil pixels
if (
[
GameMapType.Australia,
GameMapType.Iceland,
GameMapType.Britannia,
GameMapType.Asia,
].includes(map)
) {
return Math.random() < 0.2 ? 100 : 50;
}
// Maps with ~2 mil pixels
if (
[
GameMapType.Mena,
GameMapType.Mars,
GameMapType.Oceania,
GameMapType.Japan, // Japan at this level because its 2/3 water
].includes(map)
) {
return Math.random() < 0.2 ? 70 : 40;
}
// Maps smaller than ~2 mil pixels
if (
[GameMapType.TwoSeas, GameMapType.BlackSea, GameMapType.Pangaea].includes(
map,
)
) {
return Math.random() < 0.2 ? 60 : 35;
}
// 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 : 60;
}
// default return for non specified map
return Math.random() < 0.2 ? 85 : 45;
}
workerIndex(gameID: GameID): number {
return simpleHash(gameID) % this.numWorkers();
@@ -337,6 +377,9 @@ export class DefaultConfig implements Config {
return 600 * 10; // 10 minutes.
}
percentageTilesOwnedToWin(): number {
if (this._gameConfig.gameMode == GameMode.Team) {
return 95;
}
return 80;
}
boatMaxNumber(): number {
+5 -1
View File
@@ -190,7 +190,11 @@ export class AttackExecution implements Execution {
tick(ticks: number) {
if (this.attack.retreated()) {
this.retreat(malusForRetreat);
if (this.attack.target().isPlayer()) {
this.retreat(malusForRetreat);
} else {
this.retreat();
}
this.active = false;
return;
}
+4 -2
View File
@@ -60,6 +60,8 @@ export enum GameMapType {
Australia = "Australia",
Iceland = "Iceland",
Japan = "Japan",
TwoSeas = "Between Two Seas",
KnownWorld = "Known World",
}
export enum GameType {
@@ -135,8 +137,8 @@ export class Cell {
private strRepr: string;
constructor(
public readonly x,
public readonly y,
public readonly x: number,
public readonly y: number,
) {
this.strRepr = `Cell[${this.x},${this.y}]`;
}
+2 -2
View File
@@ -141,7 +141,7 @@ export class GameImpl implements Game {
}
addUpdate(update: GameUpdate) {
(this.updates[update.type] as any[]).push(update);
(this.updates[update.type] as GameUpdate[]).push(update);
}
nextUnitID(): number {
@@ -383,7 +383,7 @@ export class GameImpl implements Game {
}
playerByClientID(id: ClientID): Player | null {
for (const [pID, player] of this._players) {
for (const [, player] of this._players) {
if (player.clientID() == id) {
return player;
}
+8
View File
@@ -32,6 +32,9 @@ import {
} from "./GameUpdates";
import { TerraNulliusImpl } from "./TerraNulliusImpl";
import { UnitGrid } from "./UnitGrid";
import { UserSettings } from "./UserSettings";
const userSettings: UserSettings = new UserSettings();
export class UnitView {
public _wasUpdated = true;
@@ -384,6 +387,10 @@ export class GameView implements GameMap {
throw Error(`player id ${id} not found`);
}
players(): PlayerView[] {
return Array.from(this._players.values());
}
playerBySmallID(id: number): PlayerView | TerraNullius {
if (id == 0) {
return new TerraNulliusImpl();
@@ -542,6 +549,7 @@ export class GameView implements GameMap {
}
focusedPlayer(): PlayerView | null {
if (userSettings.focusLocked()) return this.myPlayer();
return this._focusedPlayer;
}
setFocusedPlayer(player: PlayerView | null): void {
+2 -1
View File
@@ -92,6 +92,7 @@ export class PlayerImpl implements Player {
public _incomingAttacks: Attack[] = [];
public _outgoingAttacks: Attack[] = [];
public _outgoingLandAttacks: Attack[] = [];
constructor(
private mg: GameImpl,
@@ -1003,7 +1004,7 @@ export class PlayerImpl implements Player {
// 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[] {
let ports = this.mg
const ports = this.mg
.players()
.filter((p) => p != port.owner() && p.canTrade(port.owner()))
.flatMap((p) => p.units(UnitType.Port))
+2
View File
@@ -39,6 +39,8 @@ const MAP_FILE_NAMES: Record<GameMapType, string> = {
[GameMapType.Australia]: "Australia",
[GameMapType.Iceland]: "Iceland",
[GameMapType.Japan]: "Japan",
[GameMapType.TwoSeas]: "TwoSeas",
[GameMapType.KnownWorld]: "KnownWorld",
};
class GameMapLoader {
+8
View File
@@ -24,10 +24,18 @@ export class UserSettings {
return this.get("settings.leftClickOpensMenu", false);
}
focusLocked() {
return this.get("settings.focusLocked", false);
}
toggleLeftClickOpenMenu() {
this.set("settings.leftClickOpensMenu", !this.leftClickOpensMenu());
}
toggleFocusLocked() {
this.set("settings.focusLocked", !this.focusLocked());
}
toggleEmojis() {
this.set("settings.emojis", !this.emojis());
}
-4
View File
@@ -32,7 +32,3 @@ declare module "*.html" {
const content: string;
export default content;
}
declare module "*.json" {
const value: any;
export default value;
}
+43 -13
View File
@@ -120,19 +120,49 @@ function processShore(map: Terrain[][]): Coord[] {
}
function processDistToLand(shorelineWaters: Coord[], map: Terrain[][]) {
console.log("Setting Water tiles magnitude = distance from land");
for (let x = 0; x < map.length; x++) {
for (let y = 0; y < map[0].length; y++) {
const tile = map[x][y];
if (tile.type == TerrainType.Water) {
if (shorelineWaters.some((coord) => coord.x == x && coord.y == y)) {
tile.magnitude = 0;
} else {
const dist = shorelineWaters.map(
(coord) => Math.abs(x - coord.x) + Math.abs(y - coord.y),
);
tile.magnitude = Math.min(...dist);
}
console.log(
"Setting Water tiles magnitude = Manhattan distance from nearest land",
);
const width = map.length;
const height = map[0].length;
const visited = Array.from({ length: width }, () =>
Array(height).fill(false),
);
const queue: { x: number; y: number; dist: number }[] = [];
for (const { x, y } of shorelineWaters) {
queue.push({ x, y, dist: 0 });
visited[x][y] = true;
map[x][y].magnitude = 0;
}
const directions = [
{ dx: 0, dy: 1 },
{ dx: 1, dy: 0 },
{ dx: 0, dy: -1 },
{ dx: -1, dy: 0 },
];
while (queue.length > 0) {
const { x, y, dist } = queue.shift()!;
for (const { dx, dy } of directions) {
const nx = x + dx;
const ny = y + dy;
if (
nx >= 0 &&
ny >= 0 &&
nx < width &&
ny < height &&
!visited[nx][ny] &&
map[nx][ny].type === TerrainType.Water
) {
visited[nx][ny] = true;
map[nx][ny].magnitude = dist + 1;
queue.push({ x: nx, y: ny, dist: dist + 1 });
}
}
}
+3
View File
@@ -19,6 +19,9 @@ const maps = [
"Australia",
"Pangaea",
"Iceland",
"TwoSeas",
"Japan",
"KnownWorld",
];
const removeSmall = true;
+7 -7
View File
@@ -456,7 +456,7 @@ export class GameServer {
const lastHashTurn = this.turns.length - 10;
let { mostCommonHash, outOfSyncClients } =
const { mostCommonHash, outOfSyncClients } =
this.findOutOfSyncClients(lastHashTurn);
if (outOfSyncClients.length == 0) {
@@ -464,11 +464,6 @@ export class GameServer {
return;
}
if (outOfSyncClients.length >= Math.floor(this.activeClients.length / 2)) {
// If half clients out of sync assume all are out of sync.
outOfSyncClients = this.activeClients;
}
const serverDesync = ServerDesyncSchema.safeParse({
type: "desync",
turn: lastHashTurn,
@@ -519,7 +514,7 @@ export class GameServer {
}
// Create a list of clients whose hash doesn't match the most common one
const outOfSyncClients: Client[] = [];
let outOfSyncClients: Client[] = [];
for (const client of this.activeClients) {
if (client.hashes.has(turnNumber)) {
@@ -530,6 +525,11 @@ export class GameServer {
}
}
// If half clients out of sync assume all are out of sync.
if (outOfSyncClients.length >= Math.floor(this.activeClients.length / 2)) {
outOfSyncClients = this.activeClients;
}
return {
mostCommonHash,
outOfSyncClients,
+5 -5
View File
@@ -16,7 +16,7 @@ export interface Gatekeeper {
// The wrapper for request handlers with optional rate limiting
httpHandler: (
limiterType: LimiterType,
fn: (req: Request, res: Response, next: NextFunction) => Promise<any>,
fn: (req: Request, res: Response, next: NextFunction) => Promise<unknown>,
) => (req: Request, res: Response, next: NextFunction) => Promise<void>;
// The wrapper for WebSocket message handlers with rate limiting
@@ -67,8 +67,8 @@ async function getGatekeeper(): Promise<Gatekeeper> {
// Use dynamic import for ES modules
// Using a type assertion to avoid TypeScript errors for optional modules
const module = await import(
"./gatekeeper/RealGatekeeper.js" as any
).catch(() => import("./gatekeeper/RealGatekeeper.js" as any));
"./gatekeeper/RealGatekeeper.js" as string
).catch(() => import("./gatekeeper/RealGatekeeper.js" as string));
if (!module || !module.RealGatekeeper) {
console.log(
@@ -95,7 +95,7 @@ export class GatekeeperWrapper implements Gatekeeper {
httpHandler(
limiterType: LimiterType,
fn: (req: Request, res: Response, next: NextFunction) => Promise<any>,
fn: (req: Request, res: Response, next: NextFunction) => Promise<unknown>,
) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
@@ -129,7 +129,7 @@ export class NoOpGatekeeper implements Gatekeeper {
// Simple pass-through with no rate limiting
httpHandler(
limiterType: LimiterType,
fn: (req: Request, res: Response, next: NextFunction) => Promise<any>,
fn: (req: Request, res: Response, next: NextFunction) => Promise<unknown>,
) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
+9 -6
View File
@@ -5,7 +5,7 @@ import http from "http";
import path from "path";
import { fileURLToPath } from "url";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { Difficulty, GameMapType, GameType } from "../core/game/Game";
import { Difficulty, GameMapType, GameMode, GameType } from "../core/game/Game";
import { PseudoRandom } from "../core/PseudoRandom";
import { GameConfig, GameInfo } from "../core/Schemas";
import { generateID } from "../core/Util";
@@ -237,6 +237,7 @@ async function schedulePublicGame() {
instantBuild: false,
disableNPCs: false,
disableNukes: false,
gameMode: Math.random() < 0.7 ? GameMode.FFA : GameMode.Team,
bots: 400,
} as GameConfig;
@@ -280,20 +281,22 @@ function getNextMap(): GameMapType {
}
const frequency = {
World: 2,
World: 1,
Europe: 3,
Mena: 2,
NorthAmerica: 3,
BlackSea: 2,
Pangaea: 2,
NorthAmerica: 2,
BlackSea: 1,
Pangaea: 1,
Africa: 2,
Asia: 1,
Mars: 1,
Britannia: 2,
GatewayToTheAtlantic: 3,
GatewayToTheAtlantic: 2,
Australia: 2,
Iceland: 2,
SouthAmerica: 3,
Japan: 3,
TwoSeas: 3,
};
Object.keys(GameMapType).forEach((key) => {
+3 -3
View File
@@ -50,7 +50,7 @@ export function setupMetricsServer() {
} 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+-]+.*)/,
/^([a-z][a-z0-9_]*)(?:{([^}]*)})?(\s+[0-9.e+-]+.*)/,
(match, metricName, existingLabels, valueAndRest) => {
if (existingLabels) {
return `${metricName}{${existingLabels},worker="master"}${valueAndRest}`;
@@ -108,7 +108,7 @@ export function setupMetricsServer() {
// Process and collect actual metric values
try {
const processedLine = line.replace(
/^([a-z][a-z0-9_]*)(?:{([^}]*)})?(\s+[0-9\.e+-]+.*)/,
/^([a-z][a-z0-9_]*)(?:{([^}]*)})?(\s+[0-9.e+-]+.*)/,
(match, metricName, existingLabels, valueAndRest) => {
if (existingLabels) {
return `${metricName}{${existingLabels},worker="worker-${i}"}${valueAndRest}`;
@@ -122,7 +122,7 @@ export function setupMetricsServer() {
if (processedLine !== line) {
allMetricValues.push(processedLine);
} else if (
line.match(/^[a-z][a-z0-9_]*(?:{[^}]*})?\s+[0-9\.e+-]+.*/)
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+)/);
+7 -1
View File
@@ -3,7 +3,13 @@ import { ClientID, GameID, LogSeverity } from "../core/Schemas";
export interface slogMsg {
logKey: string;
msg: string;
data?: any;
data?: {
stack?: unknown;
clientID?: unknown;
clientIP?: unknown;
gameID?: unknown;
isRejoin?: unknown;
};
severity?: LogSeverity;
gameID?: GameID;
clientID?: ClientID;