From 0e7c33a594ebadcc3617b00bcc6cf55643973120 Mon Sep 17 00:00:00 2001 From: Evan Date: Thu, 18 Jun 2026 12:34:45 -0700 Subject: [PATCH 1/5] Cap renderer device-pixel-ratio at 2 (#4339) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Routes every renderer call site that read `window.devicePixelRatio` through a single `renderDpr()` helper that caps the value at **2**. ```ts export function renderDpr(): number { return Math.min(window.devicePixelRatio || 2, 2); } ``` ## Why On very high-DPI displays (DPR 3, common on phones) the WebGL backing store was sized at 3× CSS pixels — ~9× the fragment work of 1× — for a marginal visual gain over 2×. Capping at 2 keeps retina (DPR 2) pixel-perfect while clamping the 3× case. ## How it stays correct DPR isn't just the canvas size — it's one coordinate system shared by: - the canvas backing-store size (`Renderer.resize`) - the camera's screen↔world math (`Camera.resize` / `screenToWorld` / `worldToScreen`) - the camera zoom scale (`ClientGameRunner.syncCamera`) - the constant-CSS-pixel-size world text (`WorldTextPass`) These must all use the same DPR value or pointer hit-testing and text sizing drift. Routing them through one helper guarantees that. The diagnostics reporter (`Diagnostic.ts`) is intentionally left reading the real hardware DPR, since its job is to report the actual device. ## Test - `tsc --noEmit` clean for all touched files (one pre-existing unrelated `marked` types error remains on `main`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) --- src/client/ClientGameRunner.ts | 3 ++- src/client/render/gl/Camera.ts | 8 +++++--- src/client/render/gl/Renderer.ts | 3 ++- src/client/render/gl/index.ts | 1 + src/client/render/gl/passes/WorldTextPass.ts | 5 +++-- src/client/render/gl/utils/Dpr.ts | 13 +++++++++++++ 6 files changed, 26 insertions(+), 7 deletions(-) create mode 100644 src/client/render/gl/utils/Dpr.ts diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index d6c3f7a32..d561c0e88 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -72,6 +72,7 @@ import { deepAssign, MapRenderer, preloadAtlasData, + renderDpr, type RenderSettings, } from "./render/gl"; import { ALL_UNIT_TYPES, UnitState } from "./render/types"; @@ -352,7 +353,7 @@ function mountWebGLFrameLoop( const syncCamera = (): void => { const scale = transformHandler.scale; - const dpr = window.devicePixelRatio || 1; + const dpr = renderDpr(); const centerX = transformHandler.offsetX + mapWidth / 2 + diff --git a/src/client/render/gl/Camera.ts b/src/client/render/gl/Camera.ts index 1bd4cbf8d..7b2c4abb7 100644 --- a/src/client/render/gl/Camera.ts +++ b/src/client/render/gl/Camera.ts @@ -15,6 +15,8 @@ * ty = -offsetY * sy */ +import { renderDpr } from "./utils/Dpr"; + const MIN_ZOOM = 0.2; const MAX_ZOOM = 20; const DBLCLICK_MIN_ZOOM = 0.7; @@ -44,7 +46,7 @@ export class Camera { /** Update canvas pixel dimensions. Triggers initial fitMap on first call. */ resize(cssWidth: number, cssHeight: number): void { - const dpr = window.devicePixelRatio || 1; + const dpr = renderDpr(); this.canvasW = Math.round(cssWidth * dpr); this.canvasH = Math.round(cssHeight * dpr); if (this.needsInitialFit) { @@ -163,7 +165,7 @@ export class Camera { /** Convert screen pixel position to world coordinates. */ screenToWorld(screenX: number, screenY: number): { x: number; y: number } { - const dpr = window.devicePixelRatio || 1; + const dpr = renderDpr(); const ndcX = ((screenX * dpr) / this.canvasW) * 2 - 1; const ndcY = -(((screenY * dpr) / this.canvasH) * 2 - 1); const sx = (this.zoom * 2) / this.canvasW; @@ -176,7 +178,7 @@ export class Camera { /** Convert world coordinates to screen pixel position (CSS pixels). */ worldToScreen(worldX: number, worldY: number): { x: number; y: number } { - const dpr = window.devicePixelRatio || 1; + const dpr = renderDpr(); return { x: (this.zoom * (worldX - this.offsetX)) / dpr + this.canvasW / (2 * dpr), y: (this.zoom * (worldY - this.offsetY)) / dpr + this.canvasH / (2 * dpr), diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index 87a504998..5d01e518e 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -60,6 +60,7 @@ import { WorldTextPass } from "./passes/WorldTextPass"; import type { RenderSettings } from "./RenderSettings"; import { AffiliationPalette } from "./utils/Affiliation"; import { getPaletteSize, hexToRgb } from "./utils/ColorUtils"; +import { renderDpr } from "./utils/Dpr"; import { createTexture2D, toScreen, @@ -569,7 +570,7 @@ export class GPURenderer { // --------------------------------------------------------------------------- resize(cssWidth: number, cssHeight: number): void { - const dpr = window.devicePixelRatio || 1; + const dpr = renderDpr(); this.canvas.width = Math.round(cssWidth * dpr); this.canvas.height = Math.round(cssHeight * dpr); this.camera.resize(cssWidth, cssHeight); diff --git a/src/client/render/gl/index.ts b/src/client/render/gl/index.ts index de14f07ff..f5de04d10 100644 --- a/src/client/render/gl/index.ts +++ b/src/client/render/gl/index.ts @@ -11,6 +11,7 @@ export { createRenderSettings, dumpSettings } from "./RenderSettings"; export type { RenderSettings } from "./RenderSettings"; export { deepAssign, deepDiff } from "./SettingsUtils"; export { buildTerrainRGBA, getPaletteSize } from "./utils/ColorUtils"; +export { renderDpr } from "./utils/Dpr"; export { buildNukeTrajectory, samRange } from "./utils/NukeTrajectory"; export type { SAMInfo } from "./utils/NukeTrajectory"; diff --git a/src/client/render/gl/passes/WorldTextPass.ts b/src/client/render/gl/passes/WorldTextPass.ts index 0a6b43540..6227f69e9 100644 --- a/src/client/render/gl/passes/WorldTextPass.ts +++ b/src/client/render/gl/passes/WorldTextPass.ts @@ -10,6 +10,7 @@ import type { Config } from "../../../../core/configuration/Config"; import type { BonusEvent, ConquestFx } from "../../types"; import type { RenderSettings } from "../RenderSettings"; +import { renderDpr } from "../utils/Dpr"; import { createProgram } from "../utils/GlUtils"; import type { GlyphTables } from "./name-pass/AtlasData"; import { buildGlyphTables, parseAtlasData } from "./name-pass/AtlasData"; @@ -399,7 +400,7 @@ export class WorldTextPass { let count = 0; // canvasW in Camera is cssWidth*dpr, so `zoom` is device-px-per-world-unit. // Multiply screen-relative scales by dpr to keep a constant CSS-pixel size. - const dpr = window.devicePixelRatio || 1; + const dpr = renderDpr(); for (const popup of this.active) { const elapsed = now - popup.startMs; @@ -541,7 +542,7 @@ export class WorldTextPass { gl.useProgram(this.program); gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix); gl.uniform1f(this.uZoom, zoom); - const dpr = window.devicePixelRatio || 1; + const dpr = renderDpr(); gl.uniform1f( this.uMinScreenScale, this.settings.bonusPopup.minScreenScale * dpr, diff --git a/src/client/render/gl/utils/Dpr.ts b/src/client/render/gl/utils/Dpr.ts new file mode 100644 index 000000000..ccad4f8f9 --- /dev/null +++ b/src/client/render/gl/utils/Dpr.ts @@ -0,0 +1,13 @@ +/** + * Device-pixel-ratio used by the WebGL renderer for its backing store and all + * screen↔world math. Capped at 2 to avoid rendering at 3x on very high-DPI + * (mobile) displays, which costs ~9x the fragment work of 1x for a marginal + * visual gain over 2x. + * + * Every renderer call site that previously read `window.devicePixelRatio` + * must go through this so the canvas size, camera math, and text scaling stay + * on the same coordinate system. + */ +export function renderDpr(): number { + return Math.min(window.devicePixelRatio || 2, 2); +} From 16996d489c1383f297e7f30d9176783117cebb47 Mon Sep 17 00:00:00 2001 From: Evan Date: Thu, 18 Jun 2026 12:52:54 -0700 Subject: [PATCH 2/5] Hide clan tag input on CrazyGames (#4341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Clans aren't supported on CrazyGames, so don't let players set a clan tag there. - Tag the clan tag input wrapper with the existing `no-crazygames` class so Main.ts's hiding logic removes it on CrazyGames, matching how other CrazyGames-hidden elements work. - Guard loading the stored clan tag (`loadStoredUsername`) so a tag saved on the main site isn't silently submitted in the handshake while on CrazyGames — CSS hiding alone wouldn't prevent that. - Guard storing the tag (`validateAndStore`) so a returning user's saved tag isn't clobbered with an empty value during a CrazyGames session. ## Testing - `npx tsc --noEmit` — clean (no UsernameInput errors) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 --- src/client/UsernameInput.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/client/UsernameInput.ts b/src/client/UsernameInput.ts index ccc68437d..3867c51ae 100644 --- a/src/client/UsernameInput.ts +++ b/src/client/UsernameInput.ts @@ -27,6 +27,9 @@ export class UsernameInput extends LitElement { @state() private baseUsername: string = ""; @state() private clanTag: string = ""; + // Clans aren't supported on CrazyGames — hide the tag input and never submit one. + private readonly onCrazyGames = crazyGamesSDK.isOnCrazyGames(); + @property({ type: String }) validationError: string = ""; // Ownership-check feedback (i18n key) shown inline beneath the tag input. Only // "not a member" gates the buttons (see emitValidity); the rest is advisory. @@ -124,7 +127,9 @@ export class UsernameInput extends LitElement { private loadStoredUsername() { const storedUsername = localStorage.getItem(usernameKey); if (storedUsername) { - this.clanTag = localStorage.getItem(clanTagKey) ?? ""; + if (!this.onCrazyGames) { + this.clanTag = localStorage.getItem(clanTagKey) ?? ""; + } this.baseUsername = storedUsername; this.validateAndStore(); this.startClanCheck(); @@ -137,7 +142,7 @@ export class UsernameInput extends LitElement { render() { return html`
-
+
Date: Thu, 18 Jun 2026 16:35:23 -0400 Subject: [PATCH 3/5] update location of color mapping in map generator readme (#4325) Resolves #4326 ## Description: Updates map-generator readme for terrain color info locations modified in v32. ## 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 ## Please put your Discord username so you can be contacted if a bug or regression is found: tidwell --- map-generator/README.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/map-generator/README.md b/map-generator/README.md index af089f8f3..53815b2ae 100644 --- a/map-generator/README.md +++ b/map-generator/README.md @@ -86,11 +86,10 @@ If you are doing work in image editing software or using automated tools, `./map - `Pixel` -> `Terrain Type & Magnitude` mapping in `GenerateMap` - `Terrain Type` -> `Thumbnail Color` mapping in `getThumbnailColor` -In-Game, terrain is rendered using themes. The color of a tile is determined dynamically based on -its **Terrain Type** and **Magnitude**. Theme Files: +In-Game, the color of a tile is determined dynamically based on its **Terrain Type** and **Magnitude**. -- `../src/core/configuration/PastelTheme.ts` (Light) -- `../src/core/configuration/PastelThemeDark.ts` (Dark). +- Ocean default color definition: `../src/client/render/gl/render-settings.json` (user changeable via settings) +- Terrain color calculations: `../src/client/render/gl/utils/ColorUtils.ts#L50` ## Create info.json From 117fa4394711b539f266f92f8d993f4a827df1c3 Mon Sep 17 00:00:00 2001 From: Evan Date: Thu, 18 Jun 2026 13:37:45 -0700 Subject: [PATCH 4/5] Fix nuke preview showing teammate SAMs as threats (#4342) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem In the nuke trajectory preview, the SAM-intercept **"X"** marker was drawn over **teammates'** SAMs — implying their SAM would shoot down your missile. It shouldn't: like allies, a teammate's SAM never engages your nuke. The bug only affected teammates; allies already worked. ## Cause The preview built its threat set from `myPlayer.allies()` only — formal alliances — and never considered teammates. That diverged from the sim ([`SAMLauncherExecution.ts`](src/core/execution/SAMLauncherExecution.ts#L118-L134)), which skips any nuke whose owner it's `isFriendly()` with (**same team OR allied**). ## Fix `samThreatensNukePreview` now takes a teammate set and excludes teammates **unconditionally**. The subtlety: allies keep the existing *betrayal* exception — a strike close enough to break the alliance makes that ally's SAM engage at launch (`listNukeBreakAlliance`, the same function the sim uses). Teammates get **no** such exception, because a strike can break an alliance but never a team relationship. So even a player who is both a teammate *and* a betrayed ally is correctly left off the threat set. ## Notes - The sim has an "aftergame fun" exception where teammate SAMs *do* target teammate nukes once there's a winner. The preview only appears while aiming a buildable mid-game (no winner yet), so that case doesn't apply here. ## Tests Updated `samThreatensNukePreview` unit tests for the new signature and added coverage for: teammate excluded, and teammate stays excluded even when listed as betrayed. All 11 tests pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) --- .../controllers/BuildPreviewController.ts | 30 +++++++++++++++---- .../BuildPreviewController.test.ts | 29 ++++++++++++++---- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/src/client/controllers/BuildPreviewController.ts b/src/client/controllers/BuildPreviewController.ts index 3dfaa22ca..7a523a6cd 100644 --- a/src/client/controllers/BuildPreviewController.ts +++ b/src/client/controllers/BuildPreviewController.ts @@ -43,15 +43,21 @@ export function shouldPreserveGhostAfterBuild(unitType: UnitType): boolean { /** * Whether a SAM belongs in the nuke trajectory preview's threat set. - * Allied SAMs are excluded unless the strike would betray that ally — - * the alliance breaks at launch, so their SAMs will engage the nuke. + * Mirrors SAMLauncherExecution: a SAM ignores a nuke whose owner it's + * friendly with (same team OR allied). + * Teammates are excluded unconditionally — a strike can break an alliance + * but never a team relationship, so a teammate's SAM never engages. + * Allied SAMs are excluded unless the strike would betray that ally — the + * alliance breaks at launch, so their SAMs will engage the nuke. * (Own SAMs never threaten; the caller filters those out first.) */ export function samThreatensNukePreview( samOwnerSmallId: number, + teammateSmallIds: ReadonlySet, allySmallIds: ReadonlySet, betrayedSmallIds: ReadonlySet, ): boolean { + if (teammateSmallIds.has(samOwnerSmallId)) return false; return ( !allySmallIds.has(samOwnerSmallId) || betrayedSmallIds.has(samOwnerSmallId) ); @@ -331,10 +337,15 @@ export class BuildPreviewController implements Controller { const srcX = this.game.x(bestSilo.tile()); const srcY = this.game.y(bestSilo.tile()); - // Non-allied SAMs threaten the trajectory; own + allied SAMs don't — - // except allies this strike would betray: the alliance breaks at launch - // (NukeExecution.maybeBreakAlliances), so their SAMs will intercept. + // Non-friendly SAMs threaten the trajectory; own + teammate + allied SAMs + // don't — except allies this strike would betray: the alliance breaks at + // launch (NukeExecution.maybeBreakAlliances), so their SAMs will intercept. + // Teammates have no such exception (a strike never breaks a team). // listNukeBreakAlliance is the same function the sim uses there. + const teammateIds = new Set(); + for (const p of this.game.players()) { + if (myPlayer.isOnSameTeam(p)) teammateIds.add(p.smallID()); + } const allyIds = new Set(); for (const a of myPlayer.allies()) allyIds.add(a.smallID()); const betrayedIds: ReadonlySet = @@ -351,7 +362,14 @@ export class BuildPreviewController implements Controller { if (!s.isActive()) continue; const owner = s.owner(); if (owner === myPlayer) continue; - if (!samThreatensNukePreview(owner.smallID(), allyIds, betrayedIds)) { + if ( + !samThreatensNukePreview( + owner.smallID(), + teammateIds, + allyIds, + betrayedIds, + ) + ) { continue; } const r = this.game.config().samRange(s.level()); diff --git a/tests/client/controllers/BuildPreviewController.test.ts b/tests/client/controllers/BuildPreviewController.test.ts index b0d475b63..267997cb4 100644 --- a/tests/client/controllers/BuildPreviewController.test.ts +++ b/tests/client/controllers/BuildPreviewController.test.ts @@ -35,21 +35,40 @@ describe("BuildPreviewController ghost preservation (locked nuke / Enter confirm }); describe("samThreatensNukePreview (nuke trajectory threat set, #4226)", () => { + const teammates = new Set([7, 8]); const allies = new Set([2, 3]); - test("non-allied SAM threatens the trajectory", () => { - expect(samThreatensNukePreview(5, allies, new Set())).toBe(true); + test("non-friendly SAM threatens the trajectory", () => { + expect(samThreatensNukePreview(5, teammates, allies, new Set())).toBe(true); }); test("allied SAM does not threaten when the strike breaks no alliance", () => { - expect(samThreatensNukePreview(2, allies, new Set())).toBe(false); + expect(samThreatensNukePreview(2, teammates, allies, new Set())).toBe( + false, + ); }); test("would-be-betrayed ally's SAM threatens (alliance breaks at launch)", () => { - expect(samThreatensNukePreview(2, allies, new Set([2]))).toBe(true); + expect(samThreatensNukePreview(2, teammates, allies, new Set([2]))).toBe( + true, + ); }); test("other allies' SAMs still excluded when a different ally is betrayed", () => { - expect(samThreatensNukePreview(3, allies, new Set([2]))).toBe(false); + expect(samThreatensNukePreview(3, teammates, allies, new Set([2]))).toBe( + false, + ); + }); + + test("teammate SAM does not threaten the trajectory", () => { + expect(samThreatensNukePreview(7, teammates, new Set(), new Set())).toBe( + false, + ); + }); + + test("teammate SAM stays excluded even if listed as betrayed (a strike never breaks a team)", () => { + expect( + samThreatensNukePreview(7, teammates, new Set([7]), new Set([7])), + ).toBe(false); }); }); From 58e8a5fabdf859450f3925e77ca8f6d7337ef814 Mon Sep 17 00:00:00 2001 From: Evan Date: Thu, 18 Jun 2026 13:43:09 -0700 Subject: [PATCH 5/5] Fix ocean color change reverting nuke-created water to land (#4343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Changing the ocean/water color in **Graphics settings** repaints the terrain — and any water tiles created by water nukes (land → water) snap back to their original land appearance. ## Root cause `TerrainPass` captures the `terrainBytes` buffer at construction and reuses it in two places: - `setOceanColor()` does a **full** terrain texture re-upload from `terrainBytes` when the ocean color changes. - `applyTerrainDelta()` applies live land→water nuke conversions, but only wrote to the **GPU texture** — never back into `terrainBytes`. So the CPU buffer stayed frozen at the map's original terrain. Changing the ocean color rebuilt the whole texture from that stale buffer, reverting every nuke crater to land. ## Fix Write each delta byte back into `terrainBytes` inside `applyTerrainDelta()`, so the buffer stays the live source of truth and full re-uploads reflect conversions. ```ts this.terrainBytes[ref] = bytes[i]; ``` The indexing already lines up — `terrainBytes` is indexed by linear ref (`y * mapW + x`), the same `ref` the delta loop iterates. The buffer is only otherwise read once at construction by `RailroadPass`/`TerrainPass` to seed GPU textures (which copy), so mutating it has no side effects elsewhere. ## Testing The WebGL passes have no unit-test harness (they need a live GL context), so this isn't covered by an automated test. Verified by reasoning through the data flow; can confirm in-game by nuking land into water and then changing the ocean color. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) --- src/client/render/gl/passes/TerrainPass.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/client/render/gl/passes/TerrainPass.ts b/src/client/render/gl/passes/TerrainPass.ts index 1df627b8c..9e1050c3d 100644 --- a/src/client/render/gl/passes/TerrainPass.ts +++ b/src/client/render/gl/passes/TerrainPass.ts @@ -91,6 +91,9 @@ export class TerrainPass { * nuke). `bytes[i]` is the new terrain byte for `refs[i]` (parallel arrays). * One 1×1 texSubImage2D per ref — fine for the small bursts a single nuke * produces. + * + * Also writes back into `terrainBytes` so a later full re-upload (e.g. + * setOceanColor) reflects these conversions instead of reverting them. */ applyTerrainDelta(refs: readonly number[], bytes: Uint8Array): void { if (refs.length === 0) return; @@ -101,6 +104,7 @@ export class TerrainPass { const ref = refs[i]; const x = ref % this.mapW; const y = (ref - x) / this.mapW; + this.terrainBytes[ref] = bytes[i]; encodeTerrainTile(bytes[i], this.pixelScratch, 0, this.oceanColor); gl.texSubImage2D( gl.TEXTURE_2D,