+
,
allySmallIds: ReadonlySet,
betrayedSmallIds: ReadonlySet,
): boolean {
+ if (teammateSmallIds.has(samOwnerSmallId)) return false;
return (
!allySmallIds.has(samOwnerSmallId) || betrayedSmallIds.has(samOwnerSmallId)
);
@@ -339,10 +345,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 =
@@ -359,7 +370,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/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/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,
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);
+}
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);
});
});