From db745dcf4a9fe765cdb32704a625fb14b5dc1b0a Mon Sep 17 00:00:00 2001 From: Vivacious Box Date: Wed, 28 Jan 2026 00:51:11 +0100 Subject: [PATCH] Add a troubleshooting panel (#2951) ## Description: Add a troobleshooting panel with the most common problems, and a button to copy the infos for better sharing image image ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: Mr. Box --- index.html | 5 + resources/lang/en.json | 33 ++++ src/client/HelpModal.ts | 64 +++++++- src/client/Main.ts | 1 + src/client/TroubleshootingModal.ts | 254 +++++++++++++++++++++++++++++ src/client/utilities/Diagnostic.ts | 141 ++++++++++++++++ 6 files changed, 497 insertions(+), 1 deletion(-) create mode 100644 src/client/TroubleshootingModal.ts create mode 100644 src/client/utilities/Diagnostic.ts diff --git a/index.html b/index.html index cad490b1c..6c90f20e2 100644 --- a/index.html +++ b/index.html @@ -199,6 +199,11 @@ inline class="hidden w-full h-full page-content" > + ${modalHeader({ - title: translateText("main.instructions"), + title: translateText("main.help"), onBack: this.close, ariaLabel: translateText("common.back"), })} @@ -120,6 +121,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" > + +
+
+ + + + + +
+

+ ${translateText("main.troubleshooting")} +

+
+
+
+
+

+ ${translateText("help_modal.troubleshooting_desc")} +

+ +
+
@@ -1137,6 +1185,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(); } diff --git a/src/client/Main.ts b/src/client/Main.ts index 8858b2f43..b89c7a3cd 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -813,6 +813,7 @@ class Client { "game-top-bar", "help-modal", "user-setting", + "troubleshooting-modal", "territory-patterns-modal", "language-modal", "news-modal", diff --git a/src/client/TroubleshootingModal.ts b/src/client/TroubleshootingModal.ts new file mode 100644 index 000000000..4a017a4fe --- /dev/null +++ b/src/client/TroubleshootingModal.ts @@ -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` +
+ ${modalHeader({ + titleContent: html`
+ + ${translateText("main.help")} + / ${translateText("troubleshooting.title")} + + +
`, + onBack: this.close, + ariaLabel: translateText("common.back"), + })} + ${this.loading + ? "" + : html` +
+ ${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"), + )} + `, + )} +
+ `} +
+ `; + + if (this.inline) { + return content; + } + + return html` + + ${content} + + `; + } + + private infoTip(text: string, warning?: boolean): unknown { + return html` +
+ + ${text} +
+ `; + } + + protected onOpen(): void { + if (!this.initialized) { + this.initialized = true; + this.loadDiagnostics(); + } + } + + private section(title: string, content: unknown) { + return html` +
+

+ ${title} +

+
${content}
+
+ `; + } + + private row(label: string, value: unknown) { + return html` +
+ ${label} + ${value} +
+ `; + } + + 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(); + } + } +} diff --git a/src/client/utilities/Diagnostic.ts b/src/client/utilities/Diagnostic.ts new file mode 100644 index 000000000..dc6553071 --- /dev/null +++ b/src/client/utilities/Diagnostic.ts @@ -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 { + /* ---------- 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"; +}