Files
galaxy-game/ui/frontend/tests/fog-paint-ops.test.ts
T
Ilia Denisov 53b892ae00
Tests · UI / test (push) Successful in 2m50s
Tests · UI / test (pull_request) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m47s
Tests · Go / test (pull_request) Successful in 2m5s
fix(ui-map): move fog overlay to a viewport-level layer below the copies
Two regressions surfaced once visible-hyperspace toggled on a real
dev-deploy map:

1. On the zero-turn map the bg holes painted ON TOP of the planet
   glyphs — every LOCAL planet looked like a hollow circle of
   background colour instead of the planet pixel inside an
   unfogged area.
2. On a legacy report with a drive tech that pushes the visibility
   radius well past the world dimensions the bg circles overlapped
   to cover the entire viewport. Combined with the wrong z-order
   the result was a uniformly black canvas with every primitive
   hidden.

The per-copy implementation added the fog container via
`copy.addChildAt(container, 0)` and trusted Pixi v8 to insert the
container at the start of the copy's children. Whether by a Pixi
quirk or by some interaction with how `populatePrimitives` orders
its `c.addChild(g)` calls, the fog ended up rendering after every
primitive in practice — the symptoms above are a perfect match for
that ordering.

Restructured the fog rendering so the z-order is structural
rather than relying on `addChildAt`:

- A single `fogLayer: Container` is added to the viewport BEFORE
  the nine torus copies. Pixi renders viewport children in order,
  so the layer is guaranteed to paint first; every copy renders
  on top.
- `fogPaintOps` now emits world-space coordinates with wrap
  offsets baked in (9 fog rects + 9 bg circles per visibility
  entry in torus mode, 1 + N in no-wrap mode). The renderer
  populates `fogLayer` with one `Graphics` per op — no per-copy
  iteration on the fog side.
- The previous `fogGraphics: Container[]` closure state is gone.
  Each `setVisibilityFog` flip drops every child of `fogLayer`
  and rebuilds it. The dispose path drops the children
  eagerly before `app.destroy({children: true})` walks the tree.

The fog-paint-ops test exercises the new contract: the no-wrap
path keeps one rect + N circles, the torus path expands to nine
rects + nine wrapped circles per entry (including the seam-fix
case at x = 950).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:26:06 +02:00

198 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Phase 29 unit coverage for the visible-hyperspace overlay's
// layered overpaint logic. `fogPaintOps` lives in `src/map/render.ts`
// next to its sole consumer (`RendererHandle.setVisibilityFog`) —
// the renderer dispatches each op onto its own Pixi `Graphics`
// inside a `fogLayer` container that sits below every primitive
// copy. The natural rendering order paints fog underneath the
// world, replacing the earlier `cut()` implementation that
// produced disconnected arc segments.
//
// Coordinates returned by `fogPaintOps` are in world space because
// `fogLayer` has no transform — wraps for torus mode are baked
// into the ops directly.
import { describe, expect, test } from "vitest";
import { FOG_COLOR, fogPaintOps } from "../src/map/render";
const BG_COLOR = 0x0a0e1a;
const WORLD = { width: 1000, height: 800 };
describe("fogPaintOps — no-wrap mode", () => {
test("empty input returns no ops", () => {
expect(fogPaintOps(WORLD, [], FOG_COLOR, BG_COLOR, "no-wrap")).toEqual([]);
});
test("single circle emits a single fog rect + one bg circle", () => {
const ops = fogPaintOps(
WORLD,
[{ x: 100, y: 200, radius: 50 }],
FOG_COLOR,
BG_COLOR,
"no-wrap",
);
expect(ops).toEqual([
{
kind: "fillRect",
x: 0,
y: 0,
width: 1000,
height: 800,
color: FOG_COLOR,
alpha: 1,
},
{
kind: "fillCircle",
x: 100,
y: 200,
radius: 50,
color: BG_COLOR,
alpha: 1,
},
]);
});
test("multiple circles produce one fog rect followed by N bg circles", () => {
const ops = fogPaintOps(
WORLD,
[
{ x: 100, y: 100, radius: 50 },
{ x: 300, y: 200, radius: 80 },
{ x: 500, y: 600, radius: 30 },
],
FOG_COLOR,
BG_COLOR,
"no-wrap",
);
expect(ops.length).toBe(4);
expect(ops[0].kind).toBe("fillRect");
for (let i = 1; i < ops.length; i++) {
expect(ops[i].kind).toBe("fillCircle");
const op = ops[i];
if (op.kind === "fillCircle") {
expect(op.color).toBe(BG_COLOR);
expect(op.alpha).toBe(1);
}
}
});
test("zero or negative world dimensions return no ops", () => {
expect(
fogPaintOps(
{ width: 0, height: 800 },
[{ x: 0, y: 0, radius: 10 }],
FOG_COLOR,
BG_COLOR,
"no-wrap",
),
).toEqual([]);
expect(
fogPaintOps(
{ width: 1000, height: -1 },
[{ x: 0, y: 0, radius: 10 }],
FOG_COLOR,
BG_COLOR,
"no-wrap",
),
).toEqual([]);
});
});
describe("fogPaintOps — torus mode", () => {
test("single circle expands to 9 fog rects + 9 bg circles in world space", () => {
const ops = fogPaintOps(
WORLD,
[{ x: 100, y: 200, radius: 50 }],
FOG_COLOR,
BG_COLOR,
"torus",
);
// 9 fog rects + 9 wrapped circles.
expect(ops.length).toBe(18);
// The first 9 ops are fog rects, one per neighbour tile.
const rectPositions = ops
.slice(0, 9)
.map((op) =>
op.kind === "fillRect" ? `${op.x},${op.y}` : "non-rect",
)
.sort();
const expectedRectPositions: string[] = [];
for (const dx of [-1, 0, 1]) {
for (const dy of [-1, 0, 1]) {
expectedRectPositions.push(`${dx * 1000},${dy * 800}`);
}
}
expectedRectPositions.sort();
expect(rectPositions).toEqual(expectedRectPositions);
// The next 9 ops are bg circles at every wrapped planet position.
const circlePositions = ops
.slice(9)
.map((op) =>
op.kind === "fillCircle" ? `${op.x},${op.y}` : "non-circle",
)
.sort();
const expectedCirclePositions: string[] = [];
for (const dx of [-1, 0, 1]) {
for (const dy of [-1, 0, 1]) {
expectedCirclePositions.push(
`${100 + dx * 1000},${200 + dy * 800}`,
);
}
}
expectedCirclePositions.sort();
expect(circlePositions).toEqual(expectedCirclePositions);
});
test("multiple circles produce 9 fog rects + 9N bg circles", () => {
const ops = fogPaintOps(
WORLD,
[
{ x: 100, y: 100, radius: 50 },
{ x: 700, y: 600, radius: 30 },
],
FOG_COLOR,
BG_COLOR,
"torus",
);
// 9 fog rects + (9 wraps × 2 circles) = 27 ops.
expect(ops.length).toBe(27);
expect(
ops.slice(0, 9).every((op) => op.kind === "fillRect"),
).toBe(true);
expect(
ops.slice(9).every((op) => op.kind === "fillCircle"),
).toBe(true);
const radii = ops
.slice(9)
.map((op) => (op.kind === "fillCircle" ? op.radius : 0));
expect(radii.filter((r) => r === 50).length).toBe(9);
expect(radii.filter((r) => r === 30).length).toBe(9);
});
test("a circle near the right edge produces a wrapped copy past the seam", () => {
// Planet at (950, 400) with radius 300 — the painted area
// extends to x = 1250 in the central tile. In torus mode the
// renderer also draws wrapped circles at (-50, 400) and
// (1950, 400) so the circle stays continuous across the seam
// instead of appearing as a sector clipped by the neighbour
// tile's fog rectangle.
const ops = fogPaintOps(
WORLD,
[{ x: 950, y: 400, radius: 300 }],
FOG_COLOR,
BG_COLOR,
"torus",
);
const circleXs = ops
.filter((op) => op.kind === "fillCircle")
.map((op) => (op.kind === "fillCircle" ? op.x : 0));
expect(circleXs).toContain(-50);
expect(circleXs).toContain(950);
expect(circleXs).toContain(1950);
});
test("empty input still returns no ops in torus mode", () => {
expect(fogPaintOps(WORLD, [], FOG_COLOR, BG_COLOR, "torus")).toEqual([]);
});
});