00e84579ca
Tests · UI / test (push) Successful in 3m23s
Two visible regressions in the in-game map's fog overlay surfaced
on dev-deploy:
1. With three LOCAL planets close together, only the last planet
glyph stayed visible inside the bg holes — the other two were
obscured. The previous implementation stacked the fog rectangle
plus every bg circle onto a single `Graphics` via repeated
`g.rect(...).fill(...).circle(...).fill(...)...`. Pixi v8's
multi-shape Graphics is supported in theory, but in practice
only the last shape's fill seems to land, dropping the earlier
bg holes (and the planet glyphs on top look like they vanished
along with their hole). Splitting each op onto its own
`Graphics` inside a per-copy `Container` removes the ambiguity
— one shape, one fill, one render pass.
2. A planet near the right world edge produced a "sector" — the
bg circle painted into the area past the seam, but the
neighbouring tile's fog rectangle then overpainted that bleed,
leaving a quarter-circle hole. In torus mode each visibility
circle is now drawn at the nine wrapped positions
(`(dx, dy) ∈ {-1, 0, 1}²`); the wrapped copies in the
neighbour-tile-aligned positions keep the hole continuous
across the seam. No-wrap mode keeps a single emission per
circle, because wrapped circles would leak into the visible
world rectangle as unwanted holes.
The `fogPaintOps` helper now takes the wrap mode as a parameter;
`tests/fog-paint-ops.test.ts` covers the torus expansion
(nine-wrap product per circle, the seam-fix case at x = 950) and
re-asserts the no-wrap path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
176 lines
4.7 KiB
TypeScript
176 lines
4.7 KiB
TypeScript
// Phase 29 unit coverage for the Phase 29 fog overlay's layered
|
||
// overpaint logic. `fogPaintOps` lives in `src/map/render.ts` next
|
||
// to its sole consumer (`RendererHandle.setVisibilityFog`) — the
|
||
// renderer dispatches each op straight onto its own Pixi `Graphics`
|
||
// (one per shape) inside a per-copy `Container`, so the unit test
|
||
// exercises the public ordering contract: a single fog-coloured
|
||
// rectangle followed by one background-coloured circle per
|
||
// visibility entry (multiplied by the torus wrap offsets when the
|
||
// renderer is in torus mode). The natural rendering order unions
|
||
// overlapping circles for free, replacing the earlier `cut()`
|
||
// implementation that produced disconnected arc segments.
|
||
|
||
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 fog rect + one bg circle in that order", () => {
|
||
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("each circle is emitted at nine wrapped positions", () => {
|
||
const ops = fogPaintOps(
|
||
WORLD,
|
||
[{ x: 100, y: 200, radius: 50 }],
|
||
FOG_COLOR,
|
||
BG_COLOR,
|
||
"torus",
|
||
);
|
||
// 1 fog rect + 9 wrapped circles.
|
||
expect(ops.length).toBe(10);
|
||
expect(ops[0].kind).toBe("fillRect");
|
||
const positions = ops
|
||
.slice(1)
|
||
.map((op) => (op.kind === "fillCircle" ? `${op.x},${op.y}` : ""))
|
||
.sort();
|
||
// Every neighbour offset is emitted with width=1000 / height=800.
|
||
const expected: string[] = [];
|
||
for (const dx of [-1, 0, 1]) {
|
||
for (const dy of [-1, 0, 1]) {
|
||
expected.push(`${100 + dx * 1000},${200 + dy * 800}`);
|
||
}
|
||
}
|
||
expected.sort();
|
||
expect(positions).toEqual(expected);
|
||
});
|
||
|
||
test("multiple circles produce 9 × N wrapped circles after the fog rect", () => {
|
||
const ops = fogPaintOps(
|
||
WORLD,
|
||
[
|
||
{ x: 100, y: 100, radius: 50 },
|
||
{ x: 700, y: 600, radius: 30 },
|
||
],
|
||
FOG_COLOR,
|
||
BG_COLOR,
|
||
"torus",
|
||
);
|
||
// 1 fog rect + (9 wraps × 2 circles) = 19 ops.
|
||
expect(ops.length).toBe(19);
|
||
expect(ops[0].kind).toBe("fillRect");
|
||
// Each circle keeps its own radius across every wrap.
|
||
const radii = ops
|
||
.slice(1)
|
||
.map((op) => (op.kind === "fillCircle" ? op.radius : 0))
|
||
.filter((r) => r > 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 a wrapped circle at (950 - 1000, 400) =
|
||
// (-50, 400) so the next tile (with its own fog rect) keeps a
|
||
// matching unfogged hole at the seam — this is the fix for
|
||
// the "sector" artifact at the wrap boundary.
|
||
const ops = fogPaintOps(
|
||
WORLD,
|
||
[{ x: 950, y: 400, radius: 300 }],
|
||
FOG_COLOR,
|
||
BG_COLOR,
|
||
"torus",
|
||
);
|
||
const xs = ops
|
||
.slice(1)
|
||
.map((op) => (op.kind === "fillCircle" ? op.x : 0));
|
||
expect(xs).toContain(-50);
|
||
expect(xs).toContain(950);
|
||
expect(xs).toContain(1950);
|
||
});
|
||
|
||
test("empty input still returns no ops in torus mode", () => {
|
||
expect(fogPaintOps(WORLD, [], FOG_COLOR, BG_COLOR, "torus")).toEqual([]);
|
||
});
|
||
});
|