Merge branch 'main' into embeddedurlfix

This commit is contained in:
Ryan
2026-01-28 00:49:30 +00:00
committed by GitHub
34 changed files with 1018 additions and 253 deletions
+5
View File
@@ -199,6 +199,11 @@
inline
class="hidden w-full h-full page-content"
></user-setting>
<troubleshooting-modal
id="page-troubleshooting"
inline
class="hidden w-full h-full page-content"
></troubleshooting-modal>
<stats-modal
id="page-stats"
inline
@@ -98,8 +98,7 @@
},
{
"coordinates": [404, 1146],
"name": "Fermanagh",
"flag": "gb-nir"
"name": "Fermanagh"
}
]
}
+6 -12
View File
@@ -3,8 +3,7 @@
"nations": [
{
"coordinates": [1038, 993],
"name": "Kingdom of the Two Sicilies",
"flag": "custom:Kingdom of the Two Sicilies"
"name": "Kingdom of the Two Sicilies"
},
{
"coordinates": [370, 1137],
@@ -18,8 +17,7 @@
},
{
"coordinates": [625, 534],
"name": "Tuscany",
"flag": "custom:Tuscany"
"name": "Tuscany"
},
{
"coordinates": [595, 190],
@@ -28,13 +26,11 @@
},
{
"coordinates": [469, 386],
"name": "Modena",
"flag": "custom:Modena"
"name": "Modena"
},
{
"coordinates": [391, 254],
"name": "Parma",
"flag": "custom:Parma"
"name": "Parma"
},
{
"coordinates": [361, 68],
@@ -43,8 +39,7 @@
},
{
"coordinates": [278, 774],
"name": "Kingdom of Sardinia",
"flag": "custom:Kingdom of Sardinia"
"name": "Kingdom of Sardinia"
},
{
"coordinates": [29, 266],
@@ -58,8 +53,7 @@
},
{
"coordinates": [1238, 349],
"name": "Ottoman Empire",
"flag": "custom:Ottoman Empire2"
"name": "Ottoman Empire"
}
]
}
+11 -11
View File
@@ -3,17 +3,17 @@
"nations": [
{
"coordinates": [800, 430],
"flag": "quebec",
"flag": "Quebec",
"name": "Laval"
},
{
"coordinates": [1110, 930],
"flag": "quebec",
"flag": "Quebec",
"name": "Royal Mount park"
},
{
"coordinates": [1220, 1360],
"flag": "quebec",
"flag": "Quebec",
"name": "Hochelaga Archipelago"
},
{
@@ -23,42 +23,42 @@
},
{
"coordinates": [1400, 1000],
"flag": "quebec",
"flag": "Quebec",
"name": "Saint-Lambert"
},
{
"coordinates": [500, 130],
"flag": "quebec",
"flag": "Quebec",
"name": "Blainville"
},
{
"coordinates": [350, 650],
"flag": "quebec",
"flag": "Quebec",
"name": "Saint-Eustache"
},
{
"coordinates": [200, 1350],
"flag": "quebec",
"flag": "Quebec",
"name": "Perrot Island"
},
{
"coordinates": [25, 950],
"flag": "quebec",
"flag": "Quebec",
"name": "Kanesatake Lands"
},
{
"coordinates": [50, 450],
"flag": "quebec",
"flag": "Quebec",
"name": "Mirabel"
},
{
"coordinates": [650, 1450],
"flag": "quebec",
"flag": "Quebec",
"name": "Chateauguay"
},
{
"coordinates": [1330, 300],
"flag": "quebec",
"flag": "Quebec",
"name": "Pointe-aux-Trembles"
}
]
@@ -3,8 +3,7 @@
"nations": [
{
"coordinates": [1941, 1031],
"name": "Rif",
"flag": "Rif"
"name": "Rif"
},
{
"coordinates": [2733, 1190],
@@ -28,13 +27,11 @@
},
{
"coordinates": [1271, 1393],
"name": "Shilha",
"flag": "Shilha"
"name": "Shilha"
},
{
"coordinates": [1555, 258],
"name": "Andalusia",
"flag": "Andalusia"
"name": "Andalusia"
}
]
}
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<path fill="#fff" d="M90.9 17.1c-3.9-3.9-10.2-3.9-14.1 0L37.5 56.4 23.2 42.1c-3.9-3.9-10.2-3.9-14.1 0s-3.9 10.2 0 14.1l21.4 21.4c1.9 1.9 4.4 2.9 7.1 2.9s5.1-1 7.1-2.9l46.4-46.4c3.8-3.9 3.8-10.2-.2-14.1z"/>
</svg>

After

Width:  |  Height:  |  Size: 278 B

+38
View File
@@ -7,6 +7,7 @@
},
"common": {
"close": "Close",
"copy": "Copy",
"back": "Back",
"available": "Available",
"preset_max": "Max",
@@ -51,12 +52,43 @@
"account": "Account",
"help": "Help",
"menu": "Menu",
"troubleshooting": "Troubleshooting",
"go_to_troubleshooting": "Go to our troubleshooting page",
"pick_pattern": "Pick a pattern!"
},
"news": {
"github_link": "on GitHub",
"title": "Release Notes"
},
"troubleshooting": {
"title": "Troubleshooting",
"loading": "Loading...",
"environment": "Environment",
"rendering": "Rendering",
"power": "Power",
"browser": "Browser",
"platform": "Platform",
"copied_to_clipboard": "Info copied to the clipboard! Feel free to share it on our Discord if you need help.",
"os": "OS",
"device_pixel_ratio": "Device Pixel Ratio",
"chromium_tip": "OpenFront runs best on Chromium-based browsers.",
"hardware_acceleration_tip": "Make sure hardware acceleration is enabled in your browser settings for optimal performance.",
"renderer": "Renderer",
"max_texture_size": "Max Texture Size",
"high_precision_shaders": "High Precision Shaders",
"gpu": "GPU",
"unavailable": "Unavailable",
"gpu_tip": "Verify that this is the dedicated GPU, if one is available.",
"battery": "Battery",
"charging": "Charging",
"battery_level": "Battery Level",
"power_saving_tip": "Make sure that your browser is not set to power saving mode.",
"yes": "Yes",
"no": "No",
"unknown": "Unknown",
"software_rendering": "Software rendering",
"canvas_2d_no_gpu": "Canvas 2D (no GPU)"
},
"help_modal": {
"hotkeys": "Hotkeys",
"table_key": "Key",
@@ -141,6 +173,7 @@
"build_mirv": "MIRV",
"build_mirv_desc": "The most powerful bomb in the game. Splits up into smaller bombs that will cover a huge range of territory. Only damages the player that you first clicked on to build it. Spawns from the nearest Missile Silo and lands in the area you first clicked to build it.",
"player_icons": "Player icons",
"troubleshooting_desc": "If you experience performance issues, crashes, or other problems while playing OpenFront, please visit our Troubleshooting page for help diagnosing and fixing common issues:",
"icon_desc": "Examples of some of the ingame icons you will encounter and what they mean:",
"icon_crown": "Crown - Number 1. This is the top player in the leaderboard.",
"icon_traitor": "Broken shield - Traitor. This player attacked an ally.",
@@ -166,6 +199,7 @@
"infinite_gold": "Infinite gold",
"infinite_troops": "Infinite troops",
"compact_map": "Compact Map",
"crowded": "Crowded",
"max_timer": "Game length (minutes)",
"max_timer_placeholder": "Mins",
"max_timer_invalid": "Please enter a valid max timer value (1-120 minutes)",
@@ -246,6 +280,8 @@
},
"map": {
"map": "Map",
"featured": "Featured",
"all": "All",
"world": "World",
"giantworldmap": "Giant World Map",
"europe": "Europe",
@@ -296,6 +332,7 @@
"amazonriver": "Amazon River"
},
"map_categories": {
"featured": "Featured",
"continental": "Continental",
"regional": "Regional",
"fantasy": "Other",
@@ -421,6 +458,7 @@
"public_game_modifier": {
"random_spawn": "Random Spawn",
"compact_map": "Compact Map",
"crowded": "Crowded",
"starting_gold": "5M Starting Gold"
},
"select_lang": {
-1
View File
@@ -113,7 +113,6 @@
},
{
"coordinates": [404, 1146],
"flag": "gb-nir",
"name": "Fermanagh"
}
]
-6
View File
@@ -18,7 +18,6 @@
"nations": [
{
"coordinates": [1038, 993],
"flag": "custom:Kingdom of the Two Sicilies",
"name": "Kingdom of the Two Sicilies"
},
{
@@ -33,7 +32,6 @@
},
{
"coordinates": [625, 534],
"flag": "custom:Tuscany",
"name": "Tuscany"
},
{
@@ -43,12 +41,10 @@
},
{
"coordinates": [469, 386],
"flag": "custom:Modena",
"name": "Modena"
},
{
"coordinates": [391, 254],
"flag": "custom:Parma",
"name": "Parma"
},
{
@@ -58,7 +54,6 @@
},
{
"coordinates": [278, 774],
"flag": "custom:Kingdom of Sardinia",
"name": "Kingdom of Sardinia"
},
{
@@ -73,7 +68,6 @@
},
{
"coordinates": [1238, 349],
"flag": "custom:Ottoman Empire2",
"name": "Ottoman Empire"
}
]
+11 -11
View File
@@ -18,17 +18,17 @@
"nations": [
{
"coordinates": [800, 430],
"flag": "quebec",
"flag": "Quebec",
"name": "Laval"
},
{
"coordinates": [1110, 930],
"flag": "quebec",
"flag": "Quebec",
"name": "Royal Mount park"
},
{
"coordinates": [1220, 1360],
"flag": "quebec",
"flag": "Quebec",
"name": "Hochelaga Archipelago"
},
{
@@ -38,42 +38,42 @@
},
{
"coordinates": [1400, 1000],
"flag": "quebec",
"flag": "Quebec",
"name": "Saint-Lambert"
},
{
"coordinates": [500, 130],
"flag": "quebec",
"flag": "Quebec",
"name": "Blainville"
},
{
"coordinates": [350, 650],
"flag": "quebec",
"flag": "Quebec",
"name": "Saint-Eustache"
},
{
"coordinates": [200, 1350],
"flag": "quebec",
"flag": "Quebec",
"name": "Perrot Island"
},
{
"coordinates": [25, 950],
"flag": "quebec",
"flag": "Quebec",
"name": "Kanesatake Lands"
},
{
"coordinates": [50, 450],
"flag": "quebec",
"flag": "Quebec",
"name": "Mirabel"
},
{
"coordinates": [650, 1450],
"flag": "quebec",
"flag": "Quebec",
"name": "Chateauguay"
},
{
"coordinates": [1330, 300],
"flag": "quebec",
"flag": "Quebec",
"name": "Pointe-aux-Trembles"
}
]
@@ -18,7 +18,6 @@
"nations": [
{
"coordinates": [1941, 1031],
"flag": "Rif",
"name": "Rif"
},
{
@@ -43,12 +42,10 @@
},
{
"coordinates": [1271, 1393],
"flag": "Shilha",
"name": "Shilha"
},
{
"coordinates": [1555, 258],
"flag": "Andalusia",
"name": "Andalusia"
}
]
+63 -2
View File
@@ -3,8 +3,8 @@ import { customElement, state } from "lit/decorators.js";
import { translateText } from "../client/Utils";
import { BaseModal } from "./components/BaseModal";
import "./components/Difficulties";
import "./components/Maps";
import { modalHeader } from "./components/ui/ModalHeader";
import { TroubleshootingModal } from "./TroubleshootingModal";
@customElement("help-modal")
export class HelpModal extends BaseModal {
@@ -104,7 +104,7 @@ export class HelpModal extends BaseModal {
: ""}"
>
${modalHeader({
title: translateText("main.instructions"),
title: translateText("main.help"),
onBack: this.close,
ariaLabel: translateText("common.back"),
})}
@@ -120,6 +120,53 @@ export class HelpModal extends BaseModal {
[&_p]:text-gray-300 [&_p]:mb-3 [&_strong]:text-white [&_strong]:font-bold
scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent"
>
<!-- Troubleshooting Section -->
<div class="flex items-center gap-3 mb-3">
<div class="text-blue-400">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M2 20 L12 0 L22 20 L2 20"></path>
<line x1="12" y1="8" x2="12" y2="14"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
</div>
<h3
class="text-xl font-bold uppercase tracking-widest text-white/90"
>
${translateText("main.troubleshooting")}
</h3>
<div
class="flex-1 h-px bg-gradient-to-r from-blue-500/50 to-transparent"
></div>
</div>
<section>
<div class="w-full flex flex-col items-center">
<p class="mb-6 text-white/70 text-sm">
${translateText("help_modal.troubleshooting_desc")}
</p>
<button
id="troubleshooting-button"
class="hover:bg-white/5 px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
data-page="page-troubleshooting"
@click="${this.openTroubleshooting}"
data-i18n="main.go_to_troubleshooting"
>
<span
class="relative z-10 text-2xl"
data-i18n="main.go_to_troubleshooting"
></span>
</button>
</div>
</section>
<!-- Hotkeys Section -->
<div class="flex items-center gap-3 mb-3">
<div class="text-blue-400">
@@ -1137,6 +1184,20 @@ export class HelpModal extends BaseModal {
`;
}
openTroubleshooting() {
const troubleshootingModal = document.querySelector(
"troubleshooting-modal",
) as TroubleshootingModal;
if (
!troubleshootingModal ||
!(troubleshootingModal instanceof TroubleshootingModal)
) {
console.warn("Troubleshooting modal element not found");
return;
}
troubleshootingModal.open();
}
protected onOpen(): void {
this.keybinds = this.getKeybinds();
}
+10 -77
View File
@@ -12,7 +12,6 @@ import {
Quads,
Trios,
UnitType,
mapCategories,
} from "../core/game/Game";
import {
ClientInfo,
@@ -28,7 +27,7 @@ import "./components/CopyButton";
import "./components/Difficulties";
import "./components/FluentSlider";
import "./components/LobbyPlayerView";
import "./components/Maps";
import "./components/map/MapPicker";
import { modalHeader } from "./components/ui/ModalHeader";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import { JoinLobbyEvent } from "./Main";
@@ -38,7 +37,6 @@ import {
renderToggleInputCardInput,
} from "./utilities/RenderToggleInputCard";
import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions";
import randomMap from "/images/RandomMap.webp?url";
@customElement("host-lobby-modal")
export class HostLobbyModal extends BaseModal {
@state() private selectedMap: GameMapType = GameMapType.World;
@@ -209,80 +207,14 @@ export class HostLobbyModal extends BaseModal {
${translateText("map.map")}
</h3>
</div>
<div class="space-y-8">
<!-- Use the imported mapCategories -->
${Object.entries(mapCategories).map(
([categoryKey, maps]) => html`
<div class="w-full">
<h4
class="text-xs font-bold text-white/40 uppercase tracking-widest mb-4 pl-2"
>
${translateText(`map_categories.${categoryKey}`)}
</h4>
<div
class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"
>
${maps.map((mapValue) => {
const mapKey = Object.entries(GameMapType).find(
([, v]) => v === mapValue,
)?.[0];
return html`
<div
@click=${() => this.handleMapSelection(mapValue)}
class="cursor-pointer transition-transform duration-200 active:scale-95"
>
<map-display
.mapKey=${mapKey}
.selected=${!this.useRandomMap &&
this.selectedMap === mapValue}
.translation=${translateText(
`map.${mapKey?.toLowerCase()}`,
)}
></map-display>
</div>
`;
})}
</div>
</div>
`,
)}
<!-- Random Map Card -->
<div class="w-full pt-4 border-t border-white/5">
<h4
class="text-xs font-bold text-white/40 uppercase tracking-widest mb-4 pl-2"
>
${translateText("map_categories.special")}
</h4>
<div
class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"
>
<button
class="relative group rounded-xl border transition-all duration-200 overflow-hidden flex flex-col items-stretch ${this
.useRandomMap
? "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.3)]"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20"}"
@click=${this.handleSelectRandomMap}
>
<div
class="aspect-[2/1] w-full relative overflow-hidden bg-black/20"
>
<img
src=${randomMap}
alt=${translateText("map.random")}
class="w-full h-full object-cover opacity-60 group-hover:opacity-100 transition-opacity"
/>
</div>
<div class="p-3 text-center border-t border-white/5">
<div
class="text-xs font-bold text-white uppercase tracking-wider break-words hyphens-auto"
>
${translateText("map.random")}
</div>
</div>
</button>
</div>
</div>
</div>
<map-picker
.selectedMap=${this.selectedMap}
.useRandomMap=${this.useRandomMap}
.randomMapDivider=${true}
.onSelectMap=${(mapValue: GameMapType) =>
this.handleMapSelection(mapValue)}
.onSelectRandom=${() => this.handleSelectRandomMap()}
></map-picker>
</div>
<!-- Difficulty Selection -->
@@ -653,6 +585,7 @@ export class HostLobbyModal extends BaseModal {
.gameMode=${this.gameMode}
.clients=${this.clients}
.lobbyCreatorClientID=${this.lobbyCreatorClientID}
.currentClientID=${this.lobbyCreatorClientID}
.teamCount=${this.teamCount}
.nationCount=${this.nationCount}
.disableNations=${this.disableNations}
+7 -2
View File
@@ -28,6 +28,7 @@ export class JoinPrivateLobbyModal extends BaseModal {
@state() private gameConfig: GameConfig | null = null;
@state() private lobbyCreatorClientID: string | null = null;
@state() private currentLobbyId: string = "";
@state() private currentClientID: string = "";
@state() private nationCount: number = 0;
private playersInterval: NodeJS.Timeout | null = null;
@@ -101,6 +102,7 @@ export class JoinPrivateLobbyModal extends BaseModal {
.gameMode=${this.gameConfig?.gameMode ?? GameMode.FFA}
.clients=${this.players}
.lobbyCreatorClientID=${this.lobbyCreatorClientID}
.currentClientID=${this.currentClientID}
.teamCount=${this.gameConfig?.playerTeams ?? 2}
.nationCount=${this.nationCount}
.disableNations=${this.gameConfig?.disableNations ?? false}
@@ -290,6 +292,7 @@ export class JoinPrivateLobbyModal extends BaseModal {
this.hasJoined = false;
this.message = "";
this.currentLobbyId = "";
this.currentClientID = "";
this.nationCount = 0;
this.leaveLobbyOnClose = true;
@@ -418,6 +421,7 @@ export class JoinPrivateLobbyModal extends BaseModal {
this.showMessage(translateText("private_lobby.joined_waiting"));
this.message = "";
this.hasJoined = true;
this.currentClientID = generateID();
// If the modal closes as part of joining the game, do not leave the lobby
this.leaveLobbyOnClose = false;
@@ -426,7 +430,7 @@ export class JoinPrivateLobbyModal extends BaseModal {
new CustomEvent("join-lobby", {
detail: {
gameID: lobbyId,
clientID: generateID(),
clientID: this.currentClientID,
} as JoinLobbyEvent,
bubbles: true,
composed: true,
@@ -477,12 +481,13 @@ export class JoinPrivateLobbyModal extends BaseModal {
return "version_mismatch";
}
this.currentClientID = generateID();
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
gameID: lobbyId,
gameRecord: parsed.data,
clientID: generateID(),
clientID: this.currentClientID,
} as JoinLobbyEvent,
bubbles: true,
composed: true,
+15 -2
View File
@@ -20,7 +20,13 @@ import {
import { getPersistentID } from "./Auth";
import { LobbyConfig } from "./ClientGameRunner";
import { ReplaySpeedChangeEvent } from "./InputHandler";
import { defaultReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier";
import {
defaultReplaySpeedMultiplier,
ReplaySpeedMultiplier,
} from "./utilities/ReplaySpeedMultiplier";
// build a small backlog so MAX can catch up.
const MAX_REPLAY_BACKLOG_TURNS = 60;
export class LocalServer {
// All turns from the game record on replay.
@@ -64,9 +70,16 @@ export class LocalServer {
const turnIntervalMs =
this.lobbyConfig.serverConfig.turnIntervalMs() *
this.replaySpeedMultiplier;
const backlog = Math.max(0, this.turns.length - this.turnsExecuted);
const allowReplayBacklog =
this.replaySpeedMultiplier === ReplaySpeedMultiplier.fastest &&
this.lobbyConfig.gameRecord !== undefined;
const maxBacklog = allowReplayBacklog ? MAX_REPLAY_BACKLOG_TURNS : 0;
const canQueueNextTurn =
backlog === 0 || (maxBacklog > 0 && backlog < maxBacklog);
if (
this.turnsExecuted === this.turns.length &&
canQueueNextTurn &&
Date.now() > this.turnStartTime + turnIntervalMs
) {
this.turnStartTime = Date.now();
+1
View File
@@ -813,6 +813,7 @@ class Client {
"game-top-bar",
"help-modal",
"user-setting",
"troubleshooting-modal",
"territory-patterns-modal",
"language-modal",
"news-modal",
+1 -1
View File
@@ -65,7 +65,7 @@ export class NewsModal extends BaseModal {
protected onOpen(): void {
if (!this.initialized) {
this.initialized = true;
fetch(changelog)
fetch(`${changelog}?v=${encodeURIComponent(version.trim())}`)
.then((response) => (response.ok ? response.text() : "Failed to load"))
.then((markdown) =>
markdown
+3
View File
@@ -374,6 +374,9 @@ export class PublicLobby extends LitElement {
if (publicGameModifiers.isCompact) {
labels.push(translateText("public_game_modifier.compact_map"));
}
if (publicGameModifiers.isCrowded) {
labels.push(translateText("public_game_modifier.crowded"));
}
if (publicGameModifiers.startingGold) {
labels.push(translateText("public_game_modifier.starting_gold"));
}
+10 -81
View File
@@ -13,7 +13,6 @@ import {
Quads,
Trios,
UnitType,
mapCategories,
} from "../core/game/Game";
import { UserSettings } from "../core/game/UserSettings";
import { TeamCountConfig } from "../core/Schemas";
@@ -24,7 +23,7 @@ import "./components/baseComponents/Modal";
import { BaseModal } from "./components/BaseModal";
import "./components/Difficulties";
import "./components/FluentSlider";
import "./components/Maps";
import "./components/map/MapPicker";
import { modalHeader } from "./components/ui/ModalHeader";
import { fetchCosmetics } from "./Cosmetics";
import { FlagInput } from "./FlagInput";
@@ -35,7 +34,6 @@ import {
renderToggleInputCardInput,
} from "./utilities/RenderToggleInputCard";
import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions";
import randomMap from "/images/RandomMap.webp?url";
@customElement("single-player-modal")
export class SinglePlayerModal extends BaseModal {
@@ -197,84 +195,15 @@ export class SinglePlayerModal extends BaseModal {
</h3>
</div>
<div class="space-y-8">
${Object.entries(mapCategories).map(
([categoryKey, maps]) => html`
<div class="w-full">
<h4
class="text-xs font-bold text-white/40 uppercase tracking-widest mb-4 pl-2"
>
${translateText(`map_categories.${categoryKey}`)}
</h4>
<div
class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"
>
${maps.map((mapValue) => {
const mapKey = Object.keys(GameMapType).find(
(key) =>
GameMapType[key as keyof typeof GameMapType] ===
mapValue,
);
return html`
<div
@click=${() => this.handleMapSelection(mapValue)}
class="cursor-pointer transition-transform duration-200 active:scale-95"
>
<map-display
.mapKey=${mapKey}
.selected=${!this.useRandomMap &&
this.selectedMap === mapValue}
.showMedals=${this.showAchievements}
.wins=${this.mapWins.get(mapValue) ?? new Set()}
.translation=${translateText(
`map.${mapKey?.toLowerCase()}`,
)}
></map-display>
</div>
`;
})}
</div>
</div>
`,
)}
<!-- Random Map Card -->
<div class="w-full">
<h4
class="text-xs font-bold text-white/40 uppercase tracking-widest mb-4 pl-2"
>
${translateText("map_categories.special")}
</h4>
<div
class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"
>
<button
class="relative group rounded-xl border transition-all duration-200 overflow-hidden flex flex-col items-stretch ${this
.useRandomMap
? "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.3)]"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20"}"
@click=${this.handleSelectRandomMap}
>
<div
class="aspect-[2/1] w-full relative overflow-hidden bg-black/20"
>
<img
src=${randomMap}
alt=${translateText("map.random")}
class="w-full h-full object-cover opacity-60 group-hover:opacity-100 transition-opacity"
/>
</div>
<div class="p-3 text-center border-t border-white/5">
<div
class="text-xs font-bold text-white uppercase tracking-wider break-words hyphens-auto"
>
${translateText("map.random")}
</div>
</div>
</button>
</div>
</div>
</div>
<map-picker
.selectedMap=${this.selectedMap}
.useRandomMap=${this.useRandomMap}
.showMedals=${this.showAchievements}
.mapWins=${this.mapWins}
.onSelectMap=${(mapValue: GameMapType) =>
this.handleMapSelection(mapValue)}
.onSelectRandom=${() => this.handleSelectRandomMap()}
></map-picker>
</div>
<!-- Difficulty Selection -->
+254
View File
@@ -0,0 +1,254 @@
import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { translateText } from "./Utils";
import { BaseModal } from "./components/BaseModal";
import "./components/baseComponents/Modal";
import { modalHeader } from "./components/ui/ModalHeader";
import {
collectGraphicsDiagnostics,
GraphicsDiagnostics,
} from "./utilities/Diagnostic";
import infoIcon from "/images/InfoIcon.svg?url";
@customElement("troubleshooting-modal")
export class TroubleshootingModal extends BaseModal {
@property({ type: String }) markdown = "Loading...";
@property({ type: Object })
diagnostics?: GraphicsDiagnostics;
@property({ type: Boolean }) loading = true;
private initialized: boolean = false;
private async loadDiagnostics() {
const canvas = document.createElement("canvas");
this.diagnostics = await collectGraphicsDiagnostics(canvas);
this.loading = false;
this.initialized = true;
}
render() {
const content = html`
<div
class="h-full select-text flex flex-col ${this.inline
? "bg-black/60 backdrop-blur-md rounded-2xl border border-white/10"
: ""}"
>
${modalHeader({
titleContent: html` <div
class="w-full flex flex-col sm:flex-row justify-between gap-2"
>
<span
class="text-white text-xl sm:text-2xl md:text-3xl font-bold uppercase tracking-widest break-words hyphens-auto"
>
<a
class="hover:text-blue-200 text-blue-400 cursor-pointer"
@click=${this.close}
>${translateText("main.help")}</a
>
/ ${translateText("troubleshooting.title")}
</span>
<button
class="hover:bg-white/5 px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
@click=${this.copyDiagnostics}
>
${translateText("common.copy")}
</button>
</div>`,
onBack: this.close,
ariaLabel: translateText("common.back"),
})}
${this.loading
? ""
: html`
<div
class="flex-1 overflow-y-auto px-1 scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent mr-1"
>
${this.section(
"",
html`${this.infoTip(
translateText("troubleshooting.hardware_acceleration_tip"),
true,
)}`,
)}
${this.section(
translateText("troubleshooting.environment"),
html`
${this.row(
translateText("troubleshooting.browser"),
this.diagnostics!.browser.engine,
)}
${this.row(
translateText("troubleshooting.platform"),
this.diagnostics!.browser.platform,
)}
${this.row(
translateText("troubleshooting.os"),
this.diagnostics!.browser.os,
)}
${this.row(
translateText("troubleshooting.device_pixel_ratio"),
this.diagnostics!.browser.dpr,
)}
${this.infoTip(
translateText("troubleshooting.chromium_tip"),
)}
`,
)}
${this.section(
translateText("troubleshooting.rendering"),
html`
${this.row(
translateText("troubleshooting.renderer"),
this.describeRenderer(this.diagnostics!.rendering),
)}
${this.row(
translateText("troubleshooting.max_texture_size"),
this.diagnostics!.rendering.maxTextureSize ??
translateText("troubleshooting.unknown"),
)}
${this.row(
translateText("troubleshooting.high_precision_shaders"),
this.diagnostics!.rendering.shaderHighp === true
? translateText("troubleshooting.yes")
: translateText("troubleshooting.no"),
)}${this.row(
translateText("troubleshooting.gpu"),
!this.diagnostics!.rendering.gpu ||
this.diagnostics!.rendering.gpu.unavailable
? translateText("troubleshooting.unavailable")
: `${this.diagnostics!.rendering.gpu.vendor}${this.diagnostics!.rendering.gpu.renderer}`,
)}
${this.infoTip(translateText("troubleshooting.gpu_tip"))}
`,
)}
${this.section(
translateText("troubleshooting.power"),
html`
${this.diagnostics!.power.unavailable
? this.row(
translateText("troubleshooting.battery"),
translateText("troubleshooting.unavailable"),
)
: html`
${this.row(
translateText("troubleshooting.charging"),
this.diagnostics!.power.charging
? translateText("troubleshooting.yes")
: translateText("troubleshooting.no"),
)}
${this.row(
translateText("troubleshooting.battery_level"),
this.diagnostics!.power.level,
)}
`}
${this.infoTip(
translateText("troubleshooting.power_saving_tip"),
)}
`,
)}
</div>
`}
</div>
`;
if (this.inline) {
return content;
}
return html`
<o-modal
title=${translateText("troubleshooting.title")}
?inline=${this.inline}
hideCloseButton
hideHeader
>
${content}
</o-modal>
`;
}
private infoTip(text: string, warning?: boolean): unknown {
return html`
<div
class="mt-2 ${warning
? "bg-orange-500/10"
: "bg-white/10"} flex gap-2 text-white py-1 px-3 rounded-sm border-1 ${warning
? "border-orange-400"
: "border-white/40"}"
>
<img src="${infoIcon}" class="w-4" />
${text}
</div>
`;
}
protected onOpen(): void {
if (!this.initialized) {
this.initialized = true;
this.loadDiagnostics();
}
}
private section(title: string, content: unknown) {
return html`
<div class="px-4 py-3">
<h4
class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400"
>
${title}
</h4>
<div class="space-y-1">${content}</div>
</div>
`;
}
private row(label: string, value: unknown) {
return html`
<div class="flex justify-between gap-4 text-sm">
<span class="text-slate-400">${label}</span>
<span class="text-right text-white max-w-100">${value}</span>
</div>
`;
}
private async copyDiagnostics() {
if (!this.diagnostics) return;
const formatted =
"```json\n" + JSON.stringify(this.diagnostics, null, 2) + "\n```";
await navigator.clipboard.writeText(formatted);
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message: html`${translateText("troubleshooting.copied_to_clipboard")}`,
type: "info",
duration: 3000,
},
}),
);
}
private describeRenderer(rendering: any): string {
if (rendering.gpu?.software) {
return translateText("troubleshooting.software_rendering");
}
if (rendering.type === "Canvas2D") {
return translateText("troubleshooting.canvas_2d_no_gpu");
}
return `${rendering.type}`;
}
public close(): void {
this.unregisterEscapeHandler();
if (this.inline) {
this.style.pointerEvents = "none";
if (window.showPage) {
window.showPage?.("page-help");
}
} else {
this.modalEl?.close();
}
}
}
+38 -14
View File
@@ -15,7 +15,9 @@ import {
} from "../../core/game/Game";
import { getCompactMapNationCount } from "../../core/game/NationCreation";
import { assignTeamsLobbyPreview } from "../../core/game/TeamAssignment";
import { UserSettings } from "../../core/game/UserSettings";
import { ClientInfo, TeamCountConfig } from "../../core/Schemas";
import { createRandomName } from "../../core/Util";
import { translateText } from "../Utils";
export interface TeamPreviewData {
@@ -30,6 +32,7 @@ export class LobbyTeamView extends LitElement {
@state() private teamPreview: TeamPreviewData[] = [];
@state() private teamMaxSize: number = 0;
@property({ type: String }) lobbyCreatorClientID: string = "";
@property({ type: String }) currentClientID: string = "";
@property({ attribute: "team-count" }) teamCount: TeamCountConfig = 2;
@property({ type: Function }) onKickPlayer?: (clientID: string) => void;
@property({ type: Number }) nationCount: number = 0;
@@ -38,6 +41,7 @@ export class LobbyTeamView extends LitElement {
private theme: PastelTheme = new PastelTheme();
@state() private showTeamColors: boolean = false;
private userSettings: UserSettings = new UserSettings();
willUpdate(changedProperties: Map<string, any>) {
// Recompute team preview when relevant properties change
@@ -108,12 +112,14 @@ export class LobbyTeamView extends LitElement {
${repeat(
this.clients,
(c) => c.clientID ?? c.username,
(client) =>
html`<div
(client) => {
const displayName = this.displayUsername(client);
return html`<div
class="px-2 py-1 rounded-sm bg-gray-700/70 mb-1 text-xs text-white"
>
${client.username}
</div>`,
${displayName}
</div>`;
},
)}
</div>
<div class="flex-1 flex flex-col gap-3 md:gap-4 md:pr-1">
@@ -151,9 +157,10 @@ export class LobbyTeamView extends LitElement {
return html`${repeat(
this.clients,
(c) => c.clientID ?? c.username,
(client) =>
html`<span class="player-tag">
<span class="text-white">${client.username}</span>
(client) => {
const displayName = this.displayUsername(client);
return html`<span class="player-tag">
<span class="text-white">${displayName}</span>
${client.clientID === this.lobbyCreatorClientID
? html`<span class="host-badge"
>(${translateText("host_modal.host_badge")})</span
@@ -163,13 +170,14 @@ export class LobbyTeamView extends LitElement {
class="remove-player-btn"
@click=${() => this.onKickPlayer?.(client.clientID)}
aria-label=${translateText("host_modal.remove_player", {
username: client.username,
username: displayName,
})}
>
×
</button>`
: html``}
</span>`,
</span>`;
},
)} `;
}
@@ -207,11 +215,12 @@ export class LobbyTeamView extends LitElement {
: repeat(
preview.players,
(p) => p.clientID ?? p.username,
(p) =>
html` <div
(p) => {
const displayName = this.displayUsername(p);
return html` <div
class="bg-gray-700/70 px-2 py-1 rounded-sm text-xs flex items-center justify-between"
>
<span class="truncate text-white">${p.username}</span>
<span class="truncate text-white">${displayName}</span>
${p.clientID === this.lobbyCreatorClientID
? html`<span class="ml-2 text-[11px] text-green-300"
>(${translateText("host_modal.host_badge")})</span
@@ -223,14 +232,15 @@ export class LobbyTeamView extends LitElement {
aria-label=${translateText(
"host_modal.remove_player",
{
username: p.username,
username: displayName,
},
)}
>
×
</button>`
: html``}
</div>`,
</div>`;
},
)}
</div>
</div>
@@ -353,4 +363,18 @@ export class LobbyTeamView extends LitElement {
}
return getCompactMapNationCount(this.nationCount, this.isCompactMap);
}
private displayUsername(client: ClientInfo): string {
if (!this.userSettings.anonymousNames()) {
return client.username;
}
if (this.currentClientID && client.clientID === this.currentClientID) {
return client.username;
}
return (
createRandomName(client.username, PlayerType.Human) ?? client.username
);
}
}
@@ -1,8 +1,8 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { Difficulty, GameMapType } from "../../core/game/Game";
import { terrainMapFileLoader } from "../TerrainMapFileLoader";
import { translateText } from "../Utils";
import { Difficulty, GameMapType } from "../../../core/game/Game";
import { terrainMapFileLoader } from "../../TerrainMapFileLoader";
import { translateText } from "../../Utils";
@customElement("map-display")
export class MapDisplay extends LitElement {
+183
View File
@@ -0,0 +1,183 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import {
Difficulty,
GameMapType,
mapCategories,
} from "../../../core/game/Game";
import { translateText } from "../../Utils";
import "./MapDisplay";
import randomMap from "/images/RandomMap.webp?url";
const featuredMaps: GameMapType[] = [
GameMapType.World,
GameMapType.Europe,
GameMapType.NorthAmerica,
GameMapType.SouthAmerica,
GameMapType.Asia,
GameMapType.Africa,
GameMapType.Japan,
];
@customElement("map-picker")
export class MapPicker extends LitElement {
@property({ type: String }) selectedMap: GameMapType = GameMapType.World;
@property({ type: Boolean }) useRandomMap = false;
@property({ type: Boolean }) showMedals = false;
@property({ type: Boolean }) randomMapDivider = false;
@property({ attribute: false }) mapWins: Map<GameMapType, Set<Difficulty>> =
new Map();
@property({ attribute: false }) onSelectMap?: (map: GameMapType) => void;
@property({ attribute: false }) onSelectRandom?: () => void;
@state() private showAllMaps = false;
createRenderRoot() {
return this;
}
private handleMapSelection(mapValue: GameMapType) {
this.onSelectMap?.(mapValue);
}
private handleSelectRandomMap = () => {
this.onSelectRandom?.();
};
private getWins(mapValue: GameMapType): Set<Difficulty> {
return this.mapWins?.get(mapValue) ?? new Set();
}
private renderMapCard(mapValue: GameMapType) {
const mapKey = Object.entries(GameMapType).find(
([_, value]) => value === mapValue,
)?.[0];
return html`
<div
@click=${() => this.handleMapSelection(mapValue)}
class="cursor-pointer transition-transform duration-200 active:scale-95"
>
<map-display
.mapKey=${mapKey}
.selected=${!this.useRandomMap && this.selectedMap === mapValue}
.showMedals=${this.showMedals}
.wins=${this.getWins(mapValue)}
.translation=${translateText(`map.${mapKey?.toLowerCase()}`)}
></map-display>
</div>
`;
}
private renderAllMaps() {
const mapCategoryEntries = Object.entries(mapCategories);
return html`<div class="space-y-8">
${mapCategoryEntries.map(
([categoryKey, maps]) => html`
<div class="w-full">
<h4
class="text-xs font-bold text-white/40 uppercase tracking-widest mb-4 pl-2"
>
${translateText(`map_categories.${categoryKey}`)}
</h4>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
${maps.map((mapValue) => this.renderMapCard(mapValue))}
</div>
</div>
`,
)}
</div>`;
}
private renderFeaturedMaps() {
let featuredMapList = featuredMaps;
if (!featuredMapList.includes(this.selectedMap)) {
featuredMapList = [this.selectedMap, ...featuredMaps];
}
return html`<div class="w-full">
<h4
class="text-xs font-bold text-white/40 uppercase tracking-widest mb-4 pl-2"
>
${translateText("map_categories.featured")}
</h4>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
${featuredMapList.map((mapValue) => this.renderMapCard(mapValue))}
</div>
</div>`;
}
render() {
return html`
<div class="space-y-8">
<div class="w-full">
<div
role="tablist"
aria-label="${translateText("map.map")}"
class="grid grid-cols-2 gap-2 rounded-xl border border-white/10 bg-black/20 p-1"
>
<button
type="button"
role="tab"
aria-selected=${!this.showAllMaps}
class="px-3 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all ${this
.showAllMaps
? "text-white/60 hover:text-white"
: "bg-blue-500/20 text-blue-100 shadow-[0_0_12px_rgba(59,130,246,0.2)]"}"
@click=${() => (this.showAllMaps = false)}
>
${translateText("map.featured")}
</button>
<button
type="button"
role="tab"
aria-selected=${this.showAllMaps}
class="px-3 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all ${this
.showAllMaps
? "bg-blue-500/20 text-blue-100 shadow-[0_0_12px_rgba(59,130,246,0.2)]"
: "text-white/60 hover:text-white"}"
@click=${() => (this.showAllMaps = true)}
>
${translateText("map.all")}
</button>
</div>
</div>
${this.showAllMaps ? this.renderAllMaps() : this.renderFeaturedMaps()}
<div
class="w-full ${this.randomMapDivider
? "pt-4 border-t border-white/5"
: ""}"
>
<h4
class="text-xs font-bold text-white/40 uppercase tracking-widest mb-4 pl-2"
>
${translateText("map_categories.special")}
</h4>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<button
class="relative group rounded-xl border transition-all duration-200 overflow-hidden flex flex-col items-stretch ${this
.useRandomMap
? "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.3)]"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20"}"
@click=${this.handleSelectRandomMap}
>
<div
class="aspect-[2/1] w-full relative overflow-hidden bg-black/20"
>
<img
src=${randomMap}
alt=${translateText("map.random")}
class="w-full h-full object-cover opacity-60 group-hover:opacity-100 transition-opacity"
/>
</div>
<div class="p-3 text-center border-t border-white/5">
<div
class="text-xs font-bold text-white uppercase tracking-wider break-words hyphens-auto"
>
${translateText("map.random")}
</div>
</div>
</button>
</div>
</div>
</div>
`;
}
}
+4 -1
View File
@@ -907,9 +907,12 @@ export class RadialMenu implements Layer {
.select(".center-button-hitbox")
.style("cursor", enabled ? "pointer" : "not-allowed");
// Use default color for back button, otherwise use the current center button color
const buttonColor =
state === "back" ? this.defaultCenterButtonColor : this.centerButtonColor;
centerButton
.select(".center-button-visible")
.attr("fill", enabled ? this.centerButtonColor : "#999999");
.attr("fill", enabled ? buttonColor : "#999999");
centerButton
.select(".center-button-icon")
@@ -17,6 +17,7 @@ import allianceIcon from "/images/AllianceIconWhite.svg?url";
import boatIcon from "/images/BoatIconWhite.svg?url";
import buildIcon from "/images/BuildIconWhite.svg?url";
import chatIcon from "/images/ChatIconWhite.svg?url";
import checkmarkIcon from "/images/CheckmarkIconWhite.svg?url";
import donateGoldIcon from "/images/DonateGoldIconWhite.svg?url";
import donateTroopIcon from "/images/DonateTroopIconWhite.svg?url";
import emojiIcon from "/images/EmojiIconWhite.svg?url";
@@ -218,6 +219,15 @@ const allyBreakElement: MenuElement = {
!!params.playerActions?.interaction?.canBreakAlliance,
color: COLORS.breakAlly,
icon: traitorIcon,
subMenu: () => [allyBreakCancelElement, allyBreakConfirmElement],
};
const allyBreakConfirmElement: MenuElement = {
id: "ally_break_confirm",
name: "confirm",
disabled: () => false,
color: COLORS.breakAlly,
icon: checkmarkIcon,
action: (params: MenuElementParams) => {
params.playerActionHandler.handleBreakAlliance(
params.myPlayer,
@@ -227,6 +237,17 @@ const allyBreakElement: MenuElement = {
},
};
const allyBreakCancelElement: MenuElement = {
id: "ally_break_cancel",
name: "cancel",
disabled: () => false,
color: COLORS.info,
icon: xIcon,
action: (params: MenuElementParams) => {
params.closeMenu();
},
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const allyDonateGoldElement: MenuElement = {
id: "ally_donate_gold",
+141
View File
@@ -0,0 +1,141 @@
export type RendererType = "Canvas2D" | "WebGL1" | "WebGL2";
export interface BrowserInfo {
engine: string;
platform: string;
os: string;
dpr: number;
}
export interface GraphicsDiagnostics {
browser: BrowserInfo;
rendering: RenderingInfo;
power: PowerInfo;
}
export interface GPUInfo {
vendor?: string;
renderer?: string;
software?: boolean;
unavailable?: boolean;
}
export interface RenderingInfo {
type: RendererType;
antialias?: boolean;
maxTextureSize?: number;
shaderHighp?: boolean;
gpu?: GPUInfo;
}
export interface PerformanceInfo {
fps: number;
worstFrameMs: number;
jankPercent: number;
throttlingLikely: boolean;
}
export interface PowerInfo {
charging?: boolean;
level?: string;
unavailable?: boolean;
}
export async function collectGraphicsDiagnostics(
canvas: HTMLCanvasElement,
): Promise<GraphicsDiagnostics> {
/* ---------- Browser / OS ---------- */
const uaData = (navigator as any).userAgentData;
const os = uaData?.platform ?? detectOS(navigator.userAgent);
const browser: BrowserInfo = {
engine: uaData?.brands
? uaData.brands.map((b: any) => b.brand).join(", ")
: navigator.userAgent,
platform: navigator.platform,
os,
dpr: window.devicePixelRatio,
};
/* ---------- Rendering ---------- */
let gl: WebGLRenderingContext | WebGL2RenderingContext | null = null;
let type: RendererType = "Canvas2D";
gl =
canvas.getContext("webgl2", { antialias: true }) ??
canvas.getContext("webgl", { antialias: true });
if (gl) {
const isWebGL2 =
typeof WebGL2RenderingContext !== "undefined" &&
gl instanceof WebGL2RenderingContext;
type = isWebGL2 ? "WebGL2" : "WebGL1";
}
const rendering: RenderingInfo = { type };
if (gl) {
rendering.antialias = gl.getContextAttributes()?.antialias ?? false;
rendering.maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE);
const precision = gl.getShaderPrecisionFormat(
gl.FRAGMENT_SHADER,
gl.HIGH_FLOAT,
);
rendering.shaderHighp = precision !== null && precision.precision > 0;
const debugInfo = gl.getExtension("WEBGL_debug_renderer_info");
if (debugInfo) {
const renderer = gl.getParameter(
(debugInfo as any).UNMASKED_RENDERER_WEBGL,
) as string;
const vendor = gl.getParameter(
(debugInfo as any).UNMASKED_VENDOR_WEBGL,
) as string;
rendering.gpu = {
vendor,
renderer,
software: /swiftshader|llvmpipe|software/i.test(renderer),
};
} else {
rendering.gpu = { unavailable: true };
}
}
/* ---------- Power ---------- */
let power: PowerInfo = {};
if ("getBattery" in navigator) {
try {
const battery = await (navigator as any).getBattery();
power = {
charging: battery.charging,
level: Math.round(battery.level * 100) + "%",
};
} catch {
power = { unavailable: true };
}
} else {
power = { unavailable: true };
}
return {
browser,
rendering,
power,
};
}
function detectOS(ua: string): string {
if (/windows nt/i.test(ua)) return "Windows";
if (/mac os x/i.test(ua)) return "macOS";
if (/android/i.test(ua)) return "Android";
if (/iphone|ipad|ipod/i.test(ua)) return "iOS";
if (/linux/i.test(ua)) return "Linux";
return "Unknown";
}
+10 -4
View File
@@ -112,12 +112,12 @@ export class GameRunner {
this.turns.push(turn);
}
public executeNextTick() {
public executeNextTick(): boolean {
if (this.isExecuting) {
return;
return false;
}
if (this.currTurn >= this.turns.length) {
return;
return false;
}
this.isExecuting = true;
@@ -144,7 +144,8 @@ export class GameRunner {
} else {
console.error("Game tick error:", error);
}
return;
this.isExecuting = false;
return false;
}
if (this.game.inSpawnPhase() && this.game.ticks() % 2 === 0) {
@@ -177,6 +178,11 @@ export class GameRunner {
tickExecutionDuration: tickExecutionDuration,
});
this.isExecuting = false;
return true;
}
public pendingTurns(): number {
return Math.max(0, this.turns.length - this.currTurn);
}
public playerActions(
+1
View File
@@ -219,6 +219,7 @@ export const GameConfigSchema = z.object({
.object({
isCompact: z.boolean(),
isRandomSpawn: z.boolean(),
isCrowded: z.boolean(),
startingGold: z.number().int().min(0).optional(),
})
.optional(),
+1
View File
@@ -211,6 +211,7 @@ export enum GameMapSize {
export interface PublicGameModifiers {
isCompact: boolean;
isRandomSpawn: boolean;
isCrowded: boolean;
startingGold?: number;
}
+13 -2
View File
@@ -16,6 +16,7 @@ import {
const ctx: Worker = self as any;
let gameRunner: Promise<GameRunner> | null = null;
const mapLoader = new FetchGameMapLoader(`/maps`, version);
const MAX_TICKS_PER_HEARTBEAT = 4;
function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) {
// skip if ErrorUpdate
@@ -36,9 +37,19 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
const message = e.data;
switch (message.type) {
case "heartbeat":
(await gameRunner)?.executeNextTick();
case "heartbeat": {
const gr = await gameRunner;
if (!gr) {
break;
}
const ticksToRun = Math.min(gr.pendingTurns(), MAX_TICKS_PER_HEARTBEAT);
for (let i = 0; i < ticksToRun; i++) {
if (!gr.executeNextTick()) {
break;
}
}
break;
}
case "init":
try {
gameRunner = createGameRunner(
+59 -9
View File
@@ -97,7 +97,7 @@ export class MapPlaylist {
const modifiers = this.getRandomPublicGameModifiers();
const { startingGold } = modifiers;
let { isCompact, isRandomSpawn } = modifiers;
let { isCompact, isRandomSpawn, isCrowded } = modifiers;
// Duos, Trios, and Quads should not get random spawn (as it defeats the purpose)
if (
@@ -108,8 +108,8 @@ export class MapPlaylist {
isRandomSpawn = false;
}
// Maps with smallest player count < 50 don't support compact map in team games
// The smallest player count is the 3rd number in the player counts array
// Maps with smallest player count (third number of calculateMapPlayerCounts) < 50 don't support compact map in team games
// (not enough players after 75% player reduction for compact maps)
if (
mode === GameMode.Team &&
!(await this.supportsCompactMapForTeams(map))
@@ -117,15 +117,34 @@ export class MapPlaylist {
isCompact = false;
}
// Crowded modifier: if the map's biggest player count (first number of calculateMapPlayerCounts) is 60 or lower (small maps),
// set player count to 125 (or 60 if compact map is also enabled)
let crowdedMaxPlayers: number | undefined;
if (isCrowded) {
crowdedMaxPlayers = await this.getCrowdedMaxPlayers(map, isCompact);
if (crowdedMaxPlayers === undefined) {
isCrowded = false;
} else {
crowdedMaxPlayers = this.adjustForTeams(crowdedMaxPlayers, playerTeams);
}
}
// Create the default public game config (from your GameManager)
return {
donateGold: mode === GameMode.Team,
donateTroops: mode === GameMode.Team,
gameMap: map,
maxPlayers: await this.lobbyMaxPlayers(map, mode, playerTeams, isCompact),
maxPlayers:
crowdedMaxPlayers ??
(await this.lobbyMaxPlayers(map, mode, playerTeams, isCompact)),
gameType: GameType.Public,
gameMapSize: isCompact ? GameMapSize.Compact : GameMapSize.Normal,
publicGameModifiers: { isCompact, isRandomSpawn, startingGold },
publicGameModifiers: {
isCompact,
isRandomSpawn,
isCrowded,
startingGold,
},
startingGold,
difficulty:
playerTeams === HumansVsNations ? Difficulty.Medium : Difficulty.Easy,
@@ -171,7 +190,7 @@ export class MapPlaylist {
disableNations: true,
gameMode: GameMode.FFA,
bots: 100,
spawnImmunityDuration: 5 * 10,
spawnImmunityDuration: 30 * 10,
disabledUnits: [],
} satisfies GameConfig;
}
@@ -209,18 +228,31 @@ export class MapPlaylist {
return {
isRandomSpawn: Math.random() < 0.1, // 10% chance
isCompact: Math.random() < 0.05, // 5% chance
isCrowded: Math.random() < 0.05, // 5% chance
startingGold: Math.random() < 0.05 ? 5_000_000 : undefined, // 5% chance
};
}
// Maps with smallest player count (third number of calculateMapPlayerCounts) < 50 don't support compact map in team games
// (not enough players after 75% player reduction for compact maps)
private async supportsCompactMapForTeams(map: GameMapType): Promise<boolean> {
// Maps with smallest player count < 50 don't support compact map in team games
// The smallest player count is the 3rd number in the player counts array
const landTiles = await getMapLandTiles(map);
const [, , smallest] = this.calculateMapPlayerCounts(landTiles);
return smallest >= 50;
}
private async getCrowdedMaxPlayers(
map: GameMapType,
isCompact: boolean,
): Promise<number | undefined> {
const landTiles = await getMapLandTiles(map);
const [firstPlayerCount] = this.calculateMapPlayerCounts(landTiles);
if (firstPlayerCount <= 60) {
return isCompact ? 60 : 125;
}
return undefined;
}
private async lobbyMaxPlayers(
map: GameMapType,
mode: GameMode,
@@ -236,7 +268,15 @@ export class MapPlaylist {
if (isCompactMap) {
p = Math.max(3, Math.floor(p * 0.25));
}
if (numPlayerTeams === undefined) return p;
return this.adjustForTeams(p, numPlayerTeams);
}
private adjustForTeams(
playerCount: number,
numPlayerTeams: TeamCountConfig | undefined,
): number {
if (numPlayerTeams === undefined) return playerCount;
let p = playerCount;
switch (numPlayerTeams) {
case Duos:
p -= p % 2;
@@ -292,6 +332,8 @@ export class MapPlaylist {
const ffa1: GameMapType[] = rand.shuffleArray([...maps]);
const team1: GameMapType[] = rand.shuffleArray([...maps]);
const ffa2: GameMapType[] = rand.shuffleArray([...maps]);
const team2: GameMapType[] = rand.shuffleArray([...maps]);
const ffa3: GameMapType[] = rand.shuffleArray([...maps]);
this.mapsPlaylist = [];
for (let i = 0; i < maps.length; i++) {
@@ -306,6 +348,14 @@ export class MapPlaylist {
if (!this.addNextMap(this.mapsPlaylist, ffa2, GameMode.FFA)) {
return false;
}
if (!this.disableTeams) {
if (!this.addNextMap(this.mapsPlaylist, team2, GameMode.Team)) {
return false;
}
}
if (!this.addNextMap(this.mapsPlaylist, ffa3, GameMode.FFA)) {
return false;
}
}
return true;
}
+61
View File
@@ -0,0 +1,61 @@
import fs from "fs";
import { globSync } from "glob";
import path from "path";
type Nation = {
flag?: string;
};
type Manifest = {
nations?: Nation[];
};
describe("Map manifests: nation flags exist", () => {
test("All nations' flags reference existing SVG files", () => {
const manifestPaths = globSync("resources/maps/**/manifest.json");
expect(manifestPaths.length).toBeGreaterThan(0);
const flagDir = path.join(__dirname, "../resources/flags");
const errors: string[] = [];
for (const manifestPath of manifestPaths) {
try {
const raw = fs.readFileSync(manifestPath, "utf8");
const manifest = JSON.parse(raw) as Manifest;
(manifest.nations ?? []).forEach((nation, idx) => {
const flag = nation?.flag;
if (flag === undefined || flag === null) return;
if (typeof flag !== "string") {
errors.push(
`${manifestPath} -> nations[${idx}].flag is not a string`,
);
return;
}
if (flag.trim().length === 0) return;
if (flag.startsWith("!")) return;
const svgFile = flag.endsWith(".svg") ? flag : `${flag}.svg`;
const flagPath = path.join(flagDir, svgFile);
if (!fs.existsSync(flagPath)) {
errors.push(
`${manifestPath} -> nations[${idx}].flag "${flag}" does not exist in resources/flags`,
);
}
});
} catch (err) {
errors.push(
`Failed to parse ${manifestPath}: ${(err as Error).message}`,
);
}
}
if (errors.length > 0) {
throw new Error(
"Map manifest flag file violations:\n" + errors.join("\n"),
);
}
});
});
@@ -75,6 +75,12 @@ const makeParams = (opts?: Partial<MenuElementParams>): MenuElementParams => {
const findAllyBreak = (items: any[]) =>
items.find((i) => i && i.id === "ally_break");
const findAllyBreakConfirm = (items: any[]) =>
items.find((i) => i && i.id === "ally_break_confirm");
const findAllyBreakCancel = (items: any[]) =>
items.find((i) => i && i.id === "ally_break_cancel");
describe("RadialMenuElements ally break", () => {
test("shows break option with correct color when allied", () => {
const params = makeParams();
@@ -85,12 +91,29 @@ describe("RadialMenuElements ally break", () => {
expect(ally.color).toBe(COLORS.breakAlly);
});
test("action calls handleBreakAlliance and closes menu", () => {
test("break option opens confirmation submenu", () => {
const params = makeParams();
const items = rootMenuElement.subMenu!(params);
const ally = findAllyBreak(items)!;
ally.action!(params);
expect(ally.subMenu).toBeDefined();
const subMenuItems = ally.subMenu!(params);
expect(subMenuItems.length).toBe(2);
const confirmItem = findAllyBreakConfirm(subMenuItems);
const cancelItem = findAllyBreakCancel(subMenuItems);
expect(confirmItem).toBeTruthy();
expect(cancelItem).toBeTruthy();
});
test("confirm action calls handleBreakAlliance and closes menu", () => {
const params = makeParams();
const items = rootMenuElement.subMenu!(params);
const ally = findAllyBreak(items)!;
const subMenuItems = ally.subMenu!(params);
const confirmItem = findAllyBreakConfirm(subMenuItems)!;
confirmItem.action!(params);
expect(params.playerActionHandler.handleBreakAlliance).toHaveBeenCalledWith(
params.myPlayer,
@@ -98,4 +121,19 @@ describe("RadialMenuElements ally break", () => {
);
expect(params.closeMenu).toHaveBeenCalled();
});
test("cancel action closes menu without breaking alliance", () => {
const params = makeParams();
const items = rootMenuElement.subMenu!(params);
const ally = findAllyBreak(items)!;
const subMenuItems = ally.subMenu!(params);
const cancelItem = findAllyBreakCancel(subMenuItems)!;
cancelItem.action!(params);
expect(
params.playerActionHandler.handleBreakAlliance,
).not.toHaveBeenCalled();
expect(params.closeMenu).toHaveBeenCalled();
});
});
+1 -1
View File
@@ -80,7 +80,7 @@ export class TestServerConfig implements ServerConfig {
throw new Error("Method not implemented.");
}
getRandomPublicGameModifiers(): PublicGameModifiers {
return { isCompact: false, isRandomSpawn: false };
return { isCompact: false, isRandomSpawn: false, isCrowded: false };
}
async supportsCompactMapForTeams(): Promise<boolean> {
throw new Error("Method not implemented.");