diff --git a/test/asymmetricRamps.spec.ts b/test/asymmetricRamps.spec.ts deleted file mode 100644 index e8711266..00000000 --- a/test/asymmetricRamps.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { asymmetricRamps } from "../src/noise/trees/asymmetricRamps"; - -// core/prototypes/noise-functions.lua:114-124 - -// min((input - from_top) / (from_top - from_bottom), -// (to_top - input) / (to_bottom - to_top)) -// The _tops are where the output crosses 0; the _bottoms are where it crosses -1. -// There is deliberately NO clamp - it is designed to sit inside a shared min(). -describe("asymmetricRamps", () => { - it("crosses 0 at from_top on the rising edge", () => { - expect(asymmetricRamps(10, 0, 10, 14, 15)).toBe(0); - }); - - it("crosses 0 at to_top on the falling edge", () => { - expect(asymmetricRamps(14, 0, 10, 14, 15)).toBe(0); - }); - - it("crosses -1 at from_bottom", () => { - expect(asymmetricRamps(0, 0, 10, 14, 15)).toBe(-1); - }); - - it("crosses -1 at to_bottom", () => { - expect(asymmetricRamps(15, 0, 10, 14, 15)).toBe(-1); - }); - - it("is positive between the tops, peaking midway", () => { - expect(asymmetricRamps(12, 0, 10, 14, 15)).toBeCloseTo(0.2, 12); - }); - - it("keeps falling below -1 outside the bottoms (no clamp)", () => { - expect(asymmetricRamps(-10, 0, 10, 14, 15)).toBe(-2); - }); - - it("takes the min of the two edges, not the max", () => { - // Rising edge gives (20-10)/(10-0) = 1; falling gives (14-20)/(15-14) = -6. - expect(asymmetricRamps(20, 0, 10, 14, 15)).toBe(-6); - }); - - it("can peak negative when the tops cross each other", () => { - // from_top 16 > to_top 14: the ramps pass each other, so the max is negative. - expect(asymmetricRamps(15, 15, 16, 14, 17)).toBe(-1); - }); -}); diff --git a/test/aux.spec.ts b/test/aux.spec.ts deleted file mode 100644 index dbbac242..00000000 --- a/test/aux.spec.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import fixture from "./fixtures/oracle-aux.seed123456.json"; -import { countOffGrid, snapPosition } from "./captureGrid"; -import { makeAux } from "../src/noise/expressions/aux"; - -describe("makeAux reproduces the game's aux (aux_nauvis) tree", () => { - const evalAt = makeAux({ seed0: fixture.seed0 }); - - it("matches the game at every position, scored by exact f32 match count", () => { - // Scored by exact match count, not a bound: every value in this fixture - // satisfies `Math.fround(v) === v`, so a bound cannot tell "close" from - // "identical" (#256). - // - // The sample coordinates are snapped onto the game's 1/256 `MapPosition` - // grid first. This replaces a `< 2e-5` bound and a second on-grid-only - // assertion that together blamed 14 off-grid positions and asked for a - // re-capture. No re-capture was needed - see `test/captureGrid.ts` for the - // evidence, the trunc-vs-floor control and the full 17-fixture table. - // Snapping took this fixture from 10/26 at worst 1.262e-5 to 14/26 at - // worst 5.960e-8. - // - // **The remaining 12 misses are unexplained**, and they are NOT the snap's - // doing: they sit 1 and 4 f32 ulps out, and 3 of them are at positions that - // were already on the grid. Narrowing the incoming coordinates in - // `basisNoise` and `variablePersistenceMultioctaveNoise` (the remaining - // scope of #191) was measured against this and moved the count not at all. - // Tracked in #255. - // - // The old comment here called (-2332.95, -2333.20) "the deep-field point". - // It is not - the deep-field point is (12345.75, 6789.125), which is ON the - // 1/256 grid. That was an off-grid ring point, which is exactly why it - // carried the worst residual. - // - // `Math.fround` on the port's output is the house convention for an exact - // comparison (test/voronoiNoise.spec.ts:85), not slack: the tree evaluates - // in f32 internally but the entry point returns a JS number. - let exact = 0; - let worst = 0; - let worstLabel = ""; - for (const [i, p] of fixture.positions.entries()) { - const s = snapPosition(p); - const err = Math.abs(Math.fround(evalAt(s.x, s.y)) - fixture.aux[i]); - if (err === 0) exact++; - if (err > worst) { - worst = err; - worstLabel = `@(${p.x},${p.y})`; - } - } - expect(fixture.positions.length).toBe(26); // a regen cannot empty the loop - expect(exact, `worst ${worstLabel}`).toBe(14); - // 2^-24 is one f32 ulp for a value in [0.5, 1). Do not raise it. - expect(worst, `worst ${worstLabel}`).toBeLessThanOrEqual(2 ** -24); - }); - - it("still has off-grid positions for the snap to correct", () => { - // Anti-vacuity for the snap. If a re-capture lands every position on the - // 1/256 grid this reaches 0, and `snapPosition` should then be deleted here - // rather than left looking load-bearing. - expect(countOffGrid(fixture.positions)).toBe(14); - }); -}); - -describe("makeAux bias and frequency parameters", () => { - const GRID: Array<[number, number]> = [ - [0.5, 0.25], - [2200.5, 0.25], - [-1600.5, 1200.25], - [12345.75, 6789.125], - ]; - - it("defaults bias to 0 (omitted === explicit 0)", () => { - const def = makeAux({ seed0: 123456 }); - const explicit = makeAux({ seed0: 123456, bias: 0 }); - for (const [x, y] of GRID) expect(def(x, y)).toBe(explicit(x, y)); - }); - - it("shifts the result by exactly the bias, until clamped", () => { - const def = makeAux({ seed0: 123456 }); - const biased = makeAux({ seed0: 123456, bias: 0.1 }); - for (const [x, y] of GRID) { - expect(biased(x, y)).toBeCloseTo(Math.min(def(x, y) + 0.1, 1), 9); - } - }); - - it("defaults frequency to 1 (omitted === explicit 1)", () => { - const def = makeAux({ seed0: 123456 }); - const explicit = makeAux({ seed0: 123456, frequency: 1 }); - for (const [x, y] of GRID) expect(def(x, y)).toBe(explicit(x, y)); - }); - - it("defaults segmentationMultiplier to 1 (omitted === explicit 1)", () => { - const def = makeAux({ seed0: 123456 }); - const explicit = makeAux({ seed0: 123456, segmentationMultiplier: 1 }); - for (const [x, y] of GRID) expect(def(x, y)).toBe(explicit(x, y)); - }); - - it("stays within the [0, 1] clamp bounds", () => { - const evalHigh = makeAux({ seed0: 123456, bias: 1000 }); - for (const [x, y] of GRID) { - expect(evalHigh(x, y)).toBeLessThanOrEqual(1); - expect(evalHigh(x, y)).toBeGreaterThanOrEqual(0); - } - const evalLow = makeAux({ seed0: 123456, bias: -1000 }); - for (const [x, y] of GRID) { - expect(evalLow(x, y)).toBeGreaterThanOrEqual(0); - } - }); -}); diff --git a/test/basisOutputScaleCallers.spec.ts b/test/basisOutputScaleCallers.spec.ts deleted file mode 100644 index 0b45cafc..00000000 --- a/test/basisOutputScaleCallers.spec.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import cliffinessFixture from "./fixtures/oracle-cliffiness.seed123456.json"; -import lakesFixture from "./fixtures/oracle-elevation-lakes.seed123456.json"; -import nauvisFixture from "./fixtures/oracle-elevation-nauvis.seed123456.json"; -import helpersFixture from "./fixtures/oracle-vulcanus-helpers.seed123456.json"; -import velevFixture from "./fixtures/oracle-vulcanus-elevation.seed123456.json"; -import { snapPosition } from "./captureGrid"; -import { makeCliffiness } from "../src/noise/cliffs/cliffFields"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; -import { f32 } from "../src/noise/eval/f32"; -import { makeElevationLakes } from "../src/noise/expressions/elevationLakes"; -import { makeElevationNauvis } from "../src/noise/expressions/elevationNauvis"; -import { makeVulcanusBiomes } from "../src/noise/expressions/vulcanusBiomes"; -import { makeVulcanusClimate } from "../src/noise/expressions/vulcanusClimate"; -import { makeVulcanusCracks } from "../src/noise/expressions/vulcanusCracks"; -import { makeVulcanusElevation } from "../src/noise/expressions/vulcanusElevation"; -import { makeVulcanusHelpers } from "../src/noise/expressions/vulcanusHelpers"; -import { makeVulcanusSpawn } from "../src/noise/expressions/vulcanusSpawn"; - -/** - * What #269's narrowing did to the SHIPPED fields that read `basisNoiseExpr`. - * - * `test/basisOutputScale.spec.ts` grades the primitive itself against the game - * and answers the modelling question. This file is the other half: every field - * downstream of that primitive, scored by EXACT f32 matches, so the change - * cannot move a shipped field without saying so. - * - * ## Why this file has to exist (#256) - * - * When #269 landed, `pnpm run verify` passed with ZERO failures - and the model - * under five expression files had just changed. Every oracle spec covering - * those callers asserts a combined abs/rel bound (`cliffFields.spec.ts` uses - * `max(1.0, 1e-2 * |game|)`, `vulcanusHelpers.spec.ts` uses 4e-3 on - * `mountain_plasma`), and those bounds are wide enough to swallow the entire - * difference. A green gate was not evidence of anything. - * - * That is #256 in one measurement: 6 of 93 oracle-reading specs compare - * f32-exact, and none of the six sat here. #162 is the standing record of what - * a tolerance costs when it hides a real bug for a year. - * - * ## Reading the counts - * - * These are frozen EXACT counts, not bounds. If one moves, read it - do not - * adjust it. The `before` number on each line is what the field scored with the - * un-narrowed `output_scale * basis` that shipped until #269, measured on this - * same tree by reverting only `basisNoiseExpr` and re-running. - * - * None is a full house, and that is expected: these are deep composed chains - * carrying other unported narrowings (the #279 family). This file measures ONE - * term's contribution, which is why the control lines - the ones that must NOT - * move - matter as much as the ones that improve. - */ - -/** Exact f32 agreement with the game, the only comparison this file makes. */ -const scoreExact = (got: readonly number[], want: readonly number[]): number => - got.reduce((n, g, i) => (f32(g) === want[i] ? n + 1 : n), 0); - -describe("#269's narrowing, scored on the shipped fields that read it", () => { - /** - * `mountain_plasma` is `abs(A - B)` of two `basis_noise` calls at output - * scales 125 and 625, with nothing composed on top - the shallowest exposed - * expression in the tree, so it shows the term most directly. 7 -> 11 of 38. - */ - it("vulcanus mountain_plasma reaches 38 of 38", () => { - const helpers = makeVulcanusHelpers(withCtxDefaults({ seed0: helpersFixture.seed0 })); - const mountainPlasma = helpers.plasma(102, 2.5, 10, 125, 625); - const got = helpersFixture.positions.map((p) => mountainPlasma(p.x, p.y)); - expect(scoreExact(got, helpersFixture.mountainPlasma)).toBe(38); - }); - - /** - * The elevation chain reads `basis_noise` at output scales 250 and 150 plus - * both `plasma` pairs (125/625 and 0.15/0.75). 114 -> 116 of 434 on both - * fields - a small move because the chain is an amplified sum where the - * mountains blend reaches ~1000, so other terms dominate the residual. - * - * Positions are snapped onto the game's 1/256 MapPosition grid: 22 of these - * 434 were captured off it, so the game evaluated a different point than the - * fixture records (#186). - */ - it("vulcanus elev and elevation improve and hold at 171 of 434", () => { - const ctx = withCtxDefaults({ seed0: velevFixture.seed0 }); - const helpers = makeVulcanusHelpers(ctx); - const spawn = makeVulcanusSpawn(ctx, helpers); - const cracks = makeVulcanusCracks(ctx, helpers); - const biomes = makeVulcanusBiomes(ctx, helpers, spawn, cracks); - const climate = makeVulcanusClimate(ctx, helpers, cracks); - const elevation = makeVulcanusElevation(ctx, helpers, biomes, cracks, climate); - - const snapped = velevFixture.positions.map(snapPosition); - expect( - scoreExact( - snapped.map((s) => elevation.elev(s.x, s.y)), - velevFixture.elev, - ), - ).toBe(171); - expect( - scoreExact( - snapped.map((s) => elevation.elevation(s.x, s.y)), - velevFixture.elevation, - ), - ).toBe(171); - }); - - /** - * The three controls that must NOT move, each for a different reason. - * - * `elevation_lakes` reads `basis_noise` at output scale 1.5, which IS - * f32-exact - so the constant half of the fix is the identity there and only - * the product half can reach it. 13 of 17 before and after. - * - * `elevation_nauvis` reaches `cliff_level` at output scale 0.6, where the - * constant half DOES bite - and still does not move, because at 3 of 17 the - * residual is dominated by terms this change does not touch. - * - * `cliffiness_nauvis` is a 0/10 gate at output scale 0.51. It was already - * perfect at 1024 of 1024 on both seeds and stays perfect: the strongest - * statement in the file, because a gate that discretises a continuous field - * is exactly where a silent behaviour change shows up as flipped tiles. - * - * Both elevation fields are scored only where the game's own - * `starting_lake_distance` saturated at 1024 - the same subset their own - * specs use - and on snapped coordinates (#186). - */ - it("the fields the fix cannot reach do not move", () => { - const lakes = makeElevationLakes({ seed0: lakesFixture.seed0 }); - const lakeIdx = lakesFixture.positions - .map((_p, i) => i) - .filter((i) => lakesFixture.startingLakeDistance[i] >= 1024); - expect(lakeIdx.length).toBe(17); - expect( - scoreExact( - lakeIdx.map((i) => { - const s = snapPosition(lakesFixture.positions[i]); - return lakes(s.x, s.y); - }), - lakeIdx.map((i) => lakesFixture.elevation[i]), - ), - ).toBe(13); - - const nauvis = makeElevationNauvis({ seed0: nauvisFixture.seed0 }); - const nauvisIdx = nauvisFixture.positions - .map((_p, i) => i) - .filter((i) => nauvisFixture.startingLakeDistance[i] >= 1024); - expect(nauvisIdx.length).toBe(17); - expect( - scoreExact( - nauvisIdx.map((i) => { - const s = snapPosition(nauvisFixture.positions[i]); - return nauvis(s.x, s.y); - }), - nauvisIdx.map((i) => nauvisFixture.elevation[i]), - ), - ).toBe(3); - - for (const c of cliffinessFixture.cases) { - const cliffiness = makeCliffiness({ - seed0: c.seed, - controls: { frequency: 1, continuity: 1 }, - settings: { cliffElevation0: 10, cliffElevationInterval: 40, richness: 1 }, - }); - const exact = cliffinessFixture.positions.reduce( - (n, p, i) => (cliffiness(p.x, p.y) === c.values[i] ? n + 1 : n), - 0, - ); - expect(exact, `cliffiness gate seed=${c.seed}`).toBe(cliffinessFixture.positions.length); - } - }); -}); diff --git a/test/cliffBorderResidualCascade.spec.ts b/test/cliffBorderResidualCascade.spec.ts deleted file mode 100644 index 03d764fe..00000000 --- a/test/cliffBorderResidualCascade.spec.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import batch from "./fixtures/oracle-vulcanus-cliff-entities-border-batch.seed123456.json"; -import more from "./fixtures/oracle-vulcanus-cliff-entities-more-regions.seed123456.json"; -import oreRegions from "./fixtures/oracle-vulcanus-cliff-ore-direction-regions.seed123456.json"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - CLIFF_CODE_TO_ORIENTATION, - CLIFF_GRID_SIZE, - cliffCollisionTileBox, -} from "../src/noise/cliffs/cliffCatalog"; -import { - cliffCodeForOrientation, - connectedSides, - destroyEnd, - isCliffConnected, - onChunkBorder, - oppositeSide, -} from "../src/noise/cliffs/cliffConnections"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { makeVulcanusOreRejection } from "../src/noise/cliffs/vulcanusOreRejection"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; -import { buildResources } from "../src/noise/preview/renderVulcanusResources"; -import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -/** - * **The cross-chunk cascade is NOT the explanation for the border enrichment.** - * It is real, it is border-exclusive, and it accounts for **2 of the 25** - * unexplained cells. The enrichment survives at z = 2.67 (#84). - * - * #148 found that the port's only systematic cross-chunk error is the missing - * destroy cascade, and that every cell it touches is on a chunk border - which - * made it the first candidate that PREDICTED border-only errors rather than - * merely being consistent with them. This is that candidate tested against the - * residual directly, on the 14 regions the enrichment is measured over. No - * capture. - * - * | model | unexplained | on border | z | - * | --- | --- | --- | --- | - * | shipped (`rejectAtCrossingStage`, chunk-local) | 25 | 19 | **2.99** | - * | cross-chunk destroy cascade | 23 | 17 | **2.67** | - * - * Adopting the cascade would explain **2** of the 25, both of them border cells, - * and leave a residual still enriched at 2.67. So the mechanism is a real but - * small contributor, not the cause. **The border enrichment remains open.** - * - * ## Why the answer was nearly assumed instead of measured - * - * #143's published "after cascade" row already had a CROSS-CHUNK cascade in it - - * its harness works on a flat cell map with a 64-tile halo and never restricts - * propagation to a chunk. So its 23 / 17 / 2.67 was, unrecognised at the time, - * already the post-cascade number, and the honest read of #148's candidate was - * available in data committed a day earlier. What was missing was the OTHER row: - * nobody had measured the residual under the model that actually ships. - * - * That row is the contribution here, and it is why the test was still worth - * running: 25 / 19 / 2.99 is new, and the 25 -> 23 delta is the exact size of - * the mechanism's claim on the residual. - * - * ## The control, and why it is load-bearing - * - * The cascade row reproduces `cliffResidualCascadeAudit`'s published **23 - * unexplained, 17 on border** on the same 14 regions. Without that, the shipped - * row could be measuring a different quantity and the 2-cell delta would mean - * nothing - the two models must be scored by one definition of "unexplained", - * which here is #143's: a cell the game killed that neither our own kill set nor - * the game's own ore lever accounts for. - * - * At the plain surplus level (no attribution filter) the same run gives 90 -> 84, - * and **all 6 cells the cascade removes are on chunk borders** - the #148 result - * reproduced on a second, larger fixture set. - */ - -const K = (x: number, y: number): string => `${String(x)},${String(y)}`; -const INPUT = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }; -const ctx = withCtxDefaults(INPUT); -const fields = makeVulcanusCliffFields(ctx); -const tileAt = makeVulcanusTileResolver(INPUT); -const oreRejects = makeVulcanusOreRejection(buildResources(ctx), ctx.vulcanusResourceControls); -const tileCollides = (x: number, y: number): boolean => - VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name); -const BANDS = { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, -}; -const SHIPPED = { ...BANDS, tileCollides, cellRejects: oreRejects, rejectAtCrossingStage: true }; -const STEP: readonly (readonly [number, number])[] = [ - [0, -CLIFF_GRID_SIZE], - [CLIFF_GRID_SIZE, 0], - [0, CLIFF_GRID_SIZE], - [-CLIFF_GRID_SIZE, 0], -]; - -interface Case { - label?: string; - region: { x0: number; y0: number; x1: number; y1: number }; - cliffs: { x: number; y: number; name: string; orientation: string }[]; -} -const PAIRS: Case[] = [ - ...(batch.cases as unknown as Case[]), - ...(more.cases as unknown as Case[]), - ...(oreRegions.cases as unknown as Case[]), -]; - -function measure() { - let rawTotal = 0; - let rawBorder = 0; - let sSurplus = 0; - let sSurplusBorder = 0; - let cSurplus = 0; - let cSurplusBorder = 0; - let unkShipped = 0; - let unkShippedBorder = 0; - let unkCascade = 0; - let unkCascadeBorder = 0; - let fixedByCascade = 0; - let fixedByCascadeBorder = 0; - for (let i = 0; i < PAIRS.length; i += 2) { - const on = PAIRS[i]; - const r = on.region; - const inR = (p: { x: number; y: number }): boolean => - p.x >= r.x0 && p.x < r.x1 && p.y >= r.y0 && p.y < r.y1; - const off = PAIRS[i + 1]; - const game = new Set( - on.cliffs.filter((e) => e.name === "cliff-vulcanus" && inR(e)).map((e) => K(e.x, e.y)), - ); - const gameOff = new Set( - off.cliffs.filter((e) => e.name === "cliff-vulcanus" && inR(e)).map((e) => K(e.x, e.y)), - ); - const oreSuppressed = new Set([...gameOff].filter((k) => !game.has(k))); - - const shipped = new Set( - makeCliffPlacementFromFields(fields, SHIPPED) - .placedCells(r.x0, r.y0, r.x1, r.y1) - .map((p) => K(p.x, p.y)), - ); - - const all = makeCliffPlacementFromFields(fields, BANDS).placedCells( - r.x0 - 64, - r.y0 - 64, - r.x1 + 64, - r.y1 + 64, - ); - const cells = new Map(); - for (const p of all) { - const o = CLIFF_CODE_TO_ORIENTATION[p.code]; - if (o !== undefined) cells.set(K(p.x, p.y), o); - } - const kills: [number, number][] = []; - for (const p of all) { - const o = CLIFF_CODE_TO_ORIENTATION[p.code]; - if (o === undefined) continue; - const code = cliffCodeForOrientation(o); - const box = cliffCollisionTileBox(code, p.x, p.y); - let lava = false; - if (box !== undefined) - for (let tx = box.left; tx <= box.right; tx++) - for (let ty = box.top; ty <= box.bottom; ty++) if (tileCollides(tx, ty)) lava = true; - if (lava || oreRejects(code, p.x, p.y)) kills.push([p.x, p.y]); - } - const destroy = (x: number, y: number): void => { - const mine = cells.get(K(x, y)); - if (mine === undefined) return; - cells.delete(K(x, y)); - for (const side of connectedSides(mine)) { - const st = STEP[side]; - if (st === undefined) continue; - const nx = x + st[0]; - const ny = y + st[1]; - const theirs = cells.get(K(nx, ny)); - if (theirs === undefined) continue; - if (!isCliffConnected(side, mine, theirs)) continue; - const next = destroyEnd(theirs, oppositeSide(side)); - if (next === -1) destroy(nx, ny); - else cells.set(K(nx, ny), next); - } - }; - for (const [x, y] of kills) destroy(x, y); - const cascade = new Set( - [...cells.keys()].filter((k) => { - const q = k.split(","); - return inR({ x: Number(q[0]), y: Number(q[1]) }); - }), - ); - - // base rate over the raw placement restricted to the region - for (const p of all) { - if (!inR(p)) continue; - const o = CLIFF_CODE_TO_ORIENTATION[p.code]; - if (o === undefined) continue; - rawTotal++; - if (onChunkBorder(p.x, p.y)) rawBorder++; - } - - for (const k of shipped) { - if (game.has(k)) continue; - const q = k.split(","); - const b = onChunkBorder(Number(q[0]), Number(q[1])); - sSurplus++; - if (b) sSurplusBorder++; - if (!cascade.has(k)) { - fixedByCascade++; - if (b) fixedByCascadeBorder++; - } - } - // #143's stricter definition: cells the game killed, that our OWN kill set - // and the game's ore lever both fail to explain. - const killSet = new Set(kills.map(([x, y]) => K(x, y))); - for (const p2 of all) { - if (!inR(p2)) continue; - const o = CLIFF_CODE_TO_ORIENTATION[p2.code]; - if (o === undefined) continue; - const k = K(p2.x, p2.y); - if (game.has(k)) continue; - if (killSet.has(k) || oreSuppressed.has(k)) continue; - const b = onChunkBorder(p2.x, p2.y); - if (shipped.has(k)) { - unkShipped++; - if (b) unkShippedBorder++; - } - if (cascade.has(k)) { - unkCascade++; - if (b) unkCascadeBorder++; - } - } - for (const k of cascade) { - if (game.has(k)) continue; - cSurplus++; - const q = k.split(","); - if (onChunkBorder(Number(q[0]), Number(q[1]))) cSurplusBorder++; - } - } - const p = rawBorder / rawTotal; - const z = (k: number, n: number): number => (k - n * p) / Math.sqrt(n * p * (1 - p)); - return { - baseRate: p, - rawTotal, - surplus: { - shipped: { n: sSurplus, onBorder: sSurplusBorder }, - cascade: { n: cSurplus, onBorder: cSurplusBorder }, - fixed: fixedByCascade, - fixedOnBorder: fixedByCascadeBorder, - }, - unexplained: { - shipped: { n: unkShipped, onBorder: unkShippedBorder, z: z(unkShippedBorder, unkShipped) }, - cascade: { n: unkCascade, onBorder: unkCascadeBorder, z: z(unkCascadeBorder, unkCascade) }, - }, - }; -} - -const M = measure(); - -describe("Vulcanus cliffs: the cross-chunk cascade does NOT explain the border enrichment (#84)", () => { - it("reproduces the published post-cascade residual - 23 unexplained, 17 on a border", () => { - // THE CONTROL. Without it the shipped row below could be measuring a - // different quantity and the 2-cell delta would mean nothing. - expect(M.rawTotal).toBe(9056); - expect(M.baseRate).toBeCloseTo(0.4617, 4); - expect(M.unexplained.cascade.n).toBe(23); - expect(M.unexplained.cascade.onBorder).toBe(17); - expect(M.unexplained.cascade.z).toBeCloseTo(2.67, 2); - }, 900000); - - it("measures the residual under the model that SHIPS - 25 unexplained, z 2.99", () => { - // New: #143 never scored the chunk-local model, only a post-filter one. - expect(M.unexplained.shipped.n).toBe(25); - expect(M.unexplained.shipped.onBorder).toBe(19); - expect(M.unexplained.shipped.z).toBeCloseTo(2.99, 2); - }, 900000); - - it("so the cascade explains 2 of the 25, and the enrichment SURVIVES", () => { - const explained = M.unexplained.shipped.n - M.unexplained.cascade.n; - expect(explained).toBe(2); - // Both are border cells, so it does bite where the signal lives... - expect(M.unexplained.shipped.onBorder - M.unexplained.cascade.onBorder).toBe(2); - // ...and it is still nowhere near enough. THE REFUTATION: a mechanism that - // explained the enrichment would drive this toward the base rate, not leave - // it above 2.6. - expect(M.unexplained.cascade.z).toBeGreaterThan(2.6); - expect(explained / M.unexplained.shipped.n).toBeLessThan(0.1); - }, 900000); - - it("reproduces #148's border-exclusivity on this larger fixture set", () => { - expect(M.surplus.shipped.n).toBe(90); - expect(M.surplus.cascade.n).toBe(84); - expect(M.surplus.fixed).toBe(6); - // Every cell the cascade removes is on a chunk border - 6 for 6, on 14 - // regions here against 3 in #148. - expect(M.surplus.fixedOnBorder).toBe(M.surplus.fixed); - }, 900000); -}); diff --git a/test/cliffBorderResidualIsWest.spec.ts b/test/cliffBorderResidualIsWest.spec.ts deleted file mode 100644 index 1e15018f..00000000 --- a/test/cliffBorderResidualIsWest.spec.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import batch from "./fixtures/oracle-vulcanus-cliff-entities-border-batch.seed123456.json"; -import more from "./fixtures/oracle-vulcanus-cliff-entities-more-regions.seed123456.json"; -import oreRegions from "./fixtures/oracle-vulcanus-cliff-ore-direction-regions.seed123456.json"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - CLIFF_CELL_CENTER_X, - CLIFF_CELL_CENTER_Y, - CLIFF_CODE_TO_ORIENTATION, - CLIFF_GRID_SIZE, - CLIFF_ORIENTATION_NAMES, - cliffCollisionTileBox, -} from "../src/noise/cliffs/cliffCatalog"; -import { - applyCliffConnections, - cliffCodeForOrientation, - connectedSides, - onChunkBorder, -} from "../src/noise/cliffs/cliffConnections"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { makeVulcanusOreRejection } from "../src/noise/cliffs/vulcanusOreRejection"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; -import { buildResources } from "../src/noise/preview/renderVulcanusResources"; -import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -/** - * **The border residual is a WEST-edge residual, and `updateConnections` - - * the last unapplied engine pass - explains NONE of it** (#84). - * - * Three mechanisms have now failed to explain the chunk-border enrichment: the - * orientation-reach rival (#134), cascade double-counting (#143), and the - * cross-chunk destroy cascade (#149, which got 2 of 25). This stops proposing - * mechanisms and describes the 23 survivors instead. No capture. - * - * ## 1. `updateConnections` removes ZERO of the 23 - * - * It is the one engine pass that runs **exclusively on the chunk's outer ring** - * (`applyCliffs` gates it on the fifth argument of `tryToAddCliff`), and no audit - * had ever applied it - #143 excluded it deliberately as an upper bound. It is - * therefore the most natural remaining candidate for a border-shaped defect, and - * it accounts for **none** of them: 23 survive the cascade alone and the same 23 - * survive cascade + `updateConnections`. A fourth mechanism ruled out, and the - * one that had the best prior. - * - * ## 2. The survivors are not evenly spread around the ring - they are WEST - * - * 17 of the 23 are on a chunk border, 6 are interior. Splitting those 17 by - * which edge they sit on, against the base rate MEASURED over every raw border - * cell in the same regions rather than an assumed uniform one: - * - * | edge | survivors | expected | base rate | z | - * | --- | --- | --- | --- | --- | - * | **west** | **9** | 3.82 | 22.5% | **+3.01** | - * | north | 5 | 3.81 | 22.4% | +0.69 | - * | east | 2 | 4.81 | 28.3% | -1.51 | - * | south | 1 | 4.57 | 26.9% | -1.95 | - * - * **The base rate is what makes this a finding rather than a shape of the - * lattice.** West carries the FEWEST border cells (22.5%, against east's 28.3%) - * and the MOST survivors. Four edges were tested, so a Bonferroni-corrected - * two-sided p for west is ~0.005 - still significant, and stated because - * scoring four bins and reporting the biggest is exactly how a 3-sigma result - * becomes noise. - * - * This is a sharper localisation than the border enrichment itself (z = 2.67): - * "on a chunk border" is now "on the WEST edge of a chunk", which is a - * DIRECTIONAL signature and therefore points at a mechanism with a direction. - * - * ## 3. The lead that suggests, and why it is not asserted here - * - * `fixImpossibleCellsSweep` clears the first **clearable** edge in the order - * `L, T, R, B` - west first, north second - and an edge is clearable only if it - * is **not on the chunk's outer boundary**. West + north is 14 of the 17. That - * is the only rule in the port whose asymmetry matches the observed one, and it - * is already known to be the named cause of Nauvis's ~6% residual. - * - * It is recorded as a LEAD, not a finding: this spec measures where the - * survivors are, not why. Testing it needs a discriminator that separates the - * sweep's edge-order from anything else west-flavoured - and note - * `ore-recall-gap-is-six-cells` independently found all six of its cells with - * their nearest resource to the west, so "something is west-flavoured" has more - * than one possible source here. - */ - -const K = (x: number, y: number): string => `${String(x)},${String(y)}`; -const INPUT = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }; -const ctx = withCtxDefaults(INPUT); -const fields = makeVulcanusCliffFields(ctx); -const tileAt = makeVulcanusTileResolver(INPUT); -const oreRejects = makeVulcanusOreRejection(buildResources(ctx), ctx.vulcanusResourceControls); -const tileCollides = (x: number, y: number): boolean => - VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name); -const BANDS = { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, -}; -const SHIPPED = { ...BANDS, tileCollides, cellRejects: oreRejects, rejectAtCrossingStage: true }; -const SIDE_NAMES = ["north", "east", "south", "west"]; - -interface Case { - label?: string; - region: { x0: number; y0: number; x1: number; y1: number }; - cliffs: { x: number; y: number; name: string; orientation: string }[]; -} -const PAIRS: Case[] = [ - ...(batch.cases as unknown as Case[]), - ...(more.cases as unknown as Case[]), - ...(oreRegions.cases as unknown as Case[]), -]; - -/** Which chunk edges the cell sits on, by cell index within its 8x8 chunk. */ -function edgesOf(x: number, y: number): string[] { - const cx = (x - CLIFF_CELL_CENTER_X) / CLIFF_GRID_SIZE; - const cy = (y - CLIFF_CELL_CENTER_Y) / CLIFF_GRID_SIZE; - const ix = ((cx % 8) + 8) % 8; - const iy = ((cy % 8) + 8) % 8; - const out: string[] = []; - if (iy === 0) out.push("north"); - if (ix === 7) out.push("east"); - if (iy === 7) out.push("south"); - if (ix === 0) out.push("west"); - return out; -} - -interface Survivor { - region: string; - x: number; - y: number; - orientation: string; - ends: string[]; - chunkEdges: string[]; - isCorner: boolean; - onBorder: boolean; - endsCrossingBoundary: string[]; - removedByUpdateConnections: boolean; -} - -function measure() { - const survivors: unknown[] = []; - const edgeBase: Record = {}; - let unkShipped = 0; - let unkCascade = 0; - let unkFull = 0; - let unkFullBorder = 0; - for (let i = 0; i < PAIRS.length; i += 2) { - const on = PAIRS[i]; - const off = PAIRS[i + 1]; - const r = on.region; - const inR = (p: { x: number; y: number }): boolean => - p.x >= r.x0 && p.x < r.x1 && p.y >= r.y0 && p.y < r.y1; - const game = new Set( - on.cliffs.filter((e) => e.name === "cliff-vulcanus" && inR(e)).map((e) => K(e.x, e.y)), - ); - const gameOff = new Set( - off.cliffs.filter((e) => e.name === "cliff-vulcanus" && inR(e)).map((e) => K(e.x, e.y)), - ); - const oreSuppressed = new Set([...gameOff].filter((k) => !game.has(k))); - - const shipped = new Set( - makeCliffPlacementFromFields(fields, SHIPPED) - .placedCells(r.x0, r.y0, r.x1, r.y1) - .map((p) => K(p.x, p.y)), - ); - - const all = makeCliffPlacementFromFields(fields, BANDS).placedCells( - r.x0 - 64, - r.y0 - 64, - r.x1 + 64, - r.y1 + 64, - ); - const rawOri = new Map(); - for (const p of all) { - const o = CLIFF_CODE_TO_ORIENTATION[p.code]; - if (o !== undefined) rawOri.set(K(p.x, p.y), o); - } - const killSet = new Set(); - const collides = (orientation: number, x: number, y: number): boolean => { - const code = cliffCodeForOrientation(orientation); - const box = cliffCollisionTileBox(code, x, y); - if (box !== undefined) - for (let tx = box.left; tx <= box.right; tx++) - for (let ty = box.top; ty <= box.bottom; ty++) if (tileCollides(tx, ty)) return true; - return oreRejects(code, x, y); - }; - for (const p of all) { - const o = CLIFF_CODE_TO_ORIENTATION[p.code]; - if (o !== undefined && collides(o, p.x, p.y)) killSet.add(K(p.x, p.y)); - } - - // cascade only (no updateConnections) - const cascadeCells = new Set( - applyCliffConnections(all, { collides, noUpdateConnections: true }).map((c) => K(c.x, c.y)), - ); - // cascade + updateConnections on the chunk's outer ring - the engine pass - // no audit has applied yet. - const fullCells = new Set(applyCliffConnections(all, { collides }).map((c) => K(c.x, c.y))); - - // BASE RATE: how the raw placement's own border cells split by edge, which - // is the null the survivors' split must be read against. - for (const p of all) { - if (!inR(p)) continue; - if (CLIFF_CODE_TO_ORIENTATION[p.code] === undefined) continue; - for (const e of edgesOf(p.x, p.y)) edgeBase[e] = (edgeBase[e] ?? 0) + 1; - } - for (const p of all) { - if (!inR(p)) continue; - const o = CLIFF_CODE_TO_ORIENTATION[p.code]; - if (o === undefined) continue; - const k = K(p.x, p.y); - if (game.has(k)) continue; - if (killSet.has(k) || oreSuppressed.has(k)) continue; - if (shipped.has(k)) unkShipped++; - if (cascadeCells.has(k)) unkCascade++; - if (fullCells.has(k)) { - unkFull++; - if (onChunkBorder(p.x, p.y)) unkFullBorder++; - } - // characterise the cascade-only survivors (the published 23) - if (cascadeCells.has(k)) { - const sides = connectedSides(o).map((s) => SIDE_NAMES[s]); - const edges = edgesOf(p.x, p.y); - survivors.push({ - region: on.label ?? K(r.x0, r.y0), - x: p.x, - y: p.y, - orientation: CLIFF_ORIENTATION_NAMES[o], - ends: sides, - chunkEdges: edges, - isCorner: edges.length > 1, - onBorder: onChunkBorder(p.x, p.y), - // does an end point ACROSS a chunk boundary? - endsCrossingBoundary: sides.filter((s) => edges.includes(s)), - removedByUpdateConnections: !fullCells.has(k), - }); - } - } - } - return { unkShipped, unkCascade, unkFull, unkFullBorder, edgeBase, survivors }; -} - -const M = measure(); -const S = M.survivors as Survivor[]; -const edgeTotal = Object.values(M.edgeBase).reduce((a, b) => a + b, 0); -const byEdge = (e: string): number => S.filter((s) => s.chunkEdges.includes(e)).length; -const nBorder = S.filter((s) => s.onBorder).length; -const zFor = (e: string): number => { - const p = M.edgeBase[e] / edgeTotal; - return (byEdge(e) - nBorder * p) / Math.sqrt(nBorder * p * (1 - p)); -}; - -describe("Vulcanus cliffs: the border residual is a WEST-edge residual (#84)", () => { - it("carries the published residual forward - 25 shipped, 23 after the cascade", () => { - // The control tying this to #149 and #143. - expect(M.unkShipped).toBe(25); - expect(M.unkCascade).toBe(23); - expect(S).toHaveLength(23); - }, 900000); - - it("shows updateConnections explains NONE of them", () => { - // The pass that runs exclusively on the chunk's outer ring, never applied by - // any prior audit, and the best remaining prior for a border-shaped defect. - expect(M.unkFull).toBe(23); - expect(M.unkFull).toBe(M.unkCascade); - expect(S.filter((s) => s.removedByUpdateConnections)).toHaveLength(0); - }, 900000); - - it("splits 17 border / 6 interior", () => { - expect(nBorder).toBe(17); - expect(S.length - nBorder).toBe(6); - expect(M.unkFullBorder).toBe(17); - }, 900000); - - it("concentrates on the WEST edge at z = 3.0, against a MEASURED base rate", () => { - expect(byEdge("west")).toBe(9); - expect(byEdge("north")).toBe(5); - expect(byEdge("east")).toBe(2); - expect(byEdge("south")).toBe(1); - - // West carries the FEWEST border cells and the MOST survivors - which is - // what rules out "the lattice just has more west cells". - const westBase = M.edgeBase.west / edgeTotal; - const eastBase = M.edgeBase.east / edgeTotal; - expect(westBase).toBeLessThan(eastBase); - expect(westBase).toBeCloseTo(0.225, 3); - - expect(zFor("west")).toBeGreaterThan(3); - // And it is the only edge that is high: nothing else clears 1 sigma. - for (const e of ["east", "south"]) expect(zFor(e)).toBeLessThan(0); - expect(zFor("north")).toBeLessThan(1); - - // Not vacuous: a real sample, not a handful. - expect(nBorder).toBeGreaterThan(15); - }, 900000); -}); diff --git a/test/cliffCascadeFalseRejections.spec.ts b/test/cliffCascadeFalseRejections.spec.ts deleted file mode 100644 index 602d4a7f..00000000 --- a/test/cliffCascadeFalseRejections.spec.ts +++ /dev/null @@ -1,349 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import batch from "./fixtures/oracle-vulcanus-cliff-entities-border-batch.seed123456.json"; -import more from "./fixtures/oracle-vulcanus-cliff-entities-more-regions.seed123456.json"; -import oreRegions from "./fixtures/oracle-vulcanus-cliff-ore-direction-regions.seed123456.json"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - CLIFF_CODE_TO_ORIENTATION, - CLIFF_GRID_SIZE, - CLIFF_ORIENTATION_NAMES, - cliffCollisionTileBox, -} from "../src/noise/cliffs/cliffCatalog"; -import { - cliffCodeForOrientation, - connectedSides, - destroyEnd, - isCliffConnected, - onChunkBorder, - oppositeSide, -} from "../src/noise/cliffs/cliffConnections"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { makeVulcanusOreRejection } from "../src/noise/cliffs/vulcanusOreRejection"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; -import { buildResources } from "../src/noise/preview/renderVulcanusResources"; -import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -/** - * **The destruction cascade costs NOTHING of its own: conditional on a correct - * root kill its precision is 1.000, 27 for 27** (#84). The "2 more false - * rejections" #143 priced into the shipping gain are not a cascade defect at - * all - they are two pre-existing precision defects being propagated. - * - * #143 measured that applying the cascade to the port's own kill set trades 10 - * missed cells for 2 new false rejections, a net 8, and left the gain untaken - * with an explicit instruction: **look at the 2 new false rejections before the - * 10 wins**, because #134 recorded a gate the port does not model - * (`Cliff::destroyEnd` refuses to `forceDestroy` when entity flag bit 4 of - * `+0x6e` is set, leaving the orientation UNCHANGED). This is that look. - * - * ## The gate is not needed to explain either cell - * - * Splitting every SECONDARY removal - a cell the cascade took that was never - * directly killed - by whether the game also destroyed the **root** of its - * chain: - * - * | root kill | removals | wrong | - * | --- | --- | --- | - * | the game destroyed it too (correct root) | **27** | **0** | - * | the game KEPT it (our false rejection) | 2 | **2** | - * - * The two rows are the whole story. Every removal descending from a correct - * kill agrees with the game; both disagreements descend from a kill that was - * already wrong. One root was falsely rejected by the ORE rule, the other by - * the LAVA rule, so this is not one rule's problem either. - * - * So #134's gate is **unsupported here rather than refuted** - there is simply - * nothing left for it to explain in this sample. A cascade that force-destroys - * every single-ended neighbour reproduces the game exactly, 27 times out of 27, - * whenever it is fed a correct kill. - * - * ## What that does to the adoption decision - * - * The net-8 gain is real and its cost is **not** intrinsic. Adopting the - * cascade does not make the port worse at anything; it makes two existing - * precision defects visible at two extra cells. Fixing either root removes its - * knock-on for free, and neither root needs the cascade to be fixed. - * - * ## The control that nearly went the wrong way - * - * The root of the second cell sits at `y = 2998.5`, **outside** its region's - * `y0 = 3000`. Checking it against the region-filtered game set reports "the - * game destroyed it" for every root that merely sits outside the window, which - * would have made that cell look like a genuine cascade defect. `gameAll` below - * is deliberately UNFILTERED for exactly that reason - the dump carries cliffs - * beyond the region, and the root is present in it. Same family as the clamped - * comparison #139 hit and `clamped-comparison-is-vacuous`. - * - * Note also what the orientations do NOT prove. Both surviving cells are - * single-ended (`none-to-south`, `north-to-none`), so "kept with orientation - * unchanged" is the only alternative to "destroyed" - there is no third state - * to observe, and the unchanged orientation is therefore consistent with the - * gate without being evidence for it. The root check is what carries the - * argument; this is recorded so the orientation column is not over-read. - * - * ## Coverage, measured by planting rather than claimed - * - * | planted into the cascade | this spec | - * | --- | --- | - * | the neighbour loop never runs | **fails 4** | - * | every trim destroys (`next = -1`) | **fails 4** - 908 false after, not 14 | - * | the `isCliffConnected` parity guard is dropped | **PASSES** | - * - * That last row is a real gap, not a formality. `destroyEnd` is already a no-op - * on a side the orientation does not have, so the guard only bites when a - * neighbour presents the facing side with the WRONG PARITY - and no cell in - * these 14 regions does. **So this spec does not cover the parity rule**, and a - * green run here must not be read as evidence for it. `cliffConnections.spec.ts` - * is what pins that, from the orientation tables directly. - */ - -const INPUT = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }; -const ctx = withCtxDefaults(INPUT); -const fields = makeVulcanusCliffFields(ctx); -const tileAt = makeVulcanusTileResolver(INPUT); -const oreRejects = makeVulcanusOreRejection(buildResources(ctx), ctx.vulcanusResourceControls); -const K = (x: number, y: number): string => `${String(x)},${String(y)}`; -const BANDS = { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, -}; -const isLava = (x: number, y: number): boolean => - VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name); -const STEP: readonly (readonly [number, number])[] = [ - [0, -CLIFF_GRID_SIZE], - [CLIFF_GRID_SIZE, 0], - [0, CLIFF_GRID_SIZE], - [-CLIFF_GRID_SIZE, 0], -]; - -interface Case { - label?: string; - region: { x0: number; y0: number; x1: number; y1: number }; - cliffs: { x: number; y: number; name: string; orientation: string }[]; -} -/** Cases are ON/OFF pairs in capture order, which is what the `i += 2` relies on. */ -const PAIRS: Case[] = [ - ...(batch.cases as unknown as Case[]), - ...(more.cases as unknown as Case[]), - ...(oreRegions.cases as unknown as Case[]), -]; - -interface Victim { - x: number; - y: number; - onChunkBorder: boolean; - placedOrientation: string; - gameOrientation: string; - rootReason: string; - rootInRegion: boolean; - rootGameKept: boolean; - rootPlacedOrientation: string; - rootGameOrientation: string; -} - -interface Tally { - regions: number; - falseBefore: number; - falseAfter: number; - /** Cascade removals whose root the game ALSO destroyed. */ - goodRootTotal: number; - goodRootFalse: number; - /** Cascade removals descending from a kill the game did not make. */ - badRootTotal: number; - badRootFalse: number; - victims: Victim[]; -} - -function audit(cases: Case[]): Tally { - const t: Tally = { - regions: cases.length / 2, - falseBefore: 0, - falseAfter: 0, - goodRootTotal: 0, - goodRootFalse: 0, - badRootTotal: 0, - badRootFalse: 0, - victims: [], - }; - for (let i = 0; i < cases.length; i += 2) { - const on = cases[i]; - const r = on.region; - const inR = (p: { x: number; y: number }): boolean => - p.x >= r.x0 && p.x < r.x1 && p.y >= r.y0 && p.y < r.y1; - const gameOri = new Map(); - for (const e of on.cliffs) - if (e.name === "cliff-vulcanus" && inR(e)) gameOri.set(K(e.x, e.y), e.orientation); - const game = new Set(gameOri.keys()); - // UNFILTERED - see the header. A root just outside the region is still in - // the dump, and testing it against `game` would call every such root - // "destroyed by the game" purely because of the window. - const gameAllOri = new Map(); - for (const e of on.cliffs) - if (e.name === "cliff-vulcanus") gameAllOri.set(K(e.x, e.y), e.orientation); - - const all = makeCliffPlacementFromFields(fields, BANDS).placedCells( - r.x0 - 64, - r.y0 - 64, - r.x1 + 64, - r.y1 + 64, - ); - const cells = new Map(); - for (const p of all) { - const o = CLIFF_CODE_TO_ORIENTATION[p.code]; - if (o !== undefined) cells.set(K(p.x, p.y), o); - } - const raw = new Map(cells); - const kills: [number, number][] = []; - const killReason = new Map(); - for (const p of all) { - const o = CLIFF_CODE_TO_ORIENTATION[p.code]; - if (o === undefined) continue; - const code = cliffCodeForOrientation(o); - const box = cliffCollisionTileBox(code, p.x, p.y); - let lava = false; - if (box !== undefined) - for (let tx = box.left; tx <= box.right; tx++) - for (let ty = box.top; ty <= box.bottom; ty++) if (isLava(tx, ty)) lava = true; - if (lava || oreRejects(code, p.x, p.y)) { - kills.push([p.x, p.y]); - killReason.set(K(p.x, p.y), lava ? "lava" : "ore"); - } - } - - /** Every removed cell records the ROOT kill its chain descended from. */ - const rootOf = new Map(); - const destroy = (x: number, y: number, root: string): void => { - const mine = cells.get(K(x, y)); - if (mine === undefined) return; - cells.delete(K(x, y)); - rootOf.set(K(x, y), root); - for (const side of connectedSides(mine)) { - const st = STEP[side]; - if (st === undefined) continue; - const nx = x + st[0]; - const ny = y + st[1]; - const theirs = cells.get(K(nx, ny)); - if (theirs === undefined) continue; - if (!isCliffConnected(side, mine, theirs)) continue; - const next = destroyEnd(theirs, oppositeSide(side)); - if (next === -1) destroy(nx, ny, root); - else cells.set(K(nx, ny), next); - } - }; - for (const [x, y] of kills) destroy(x, y, K(x, y)); - const killSet = new Set(kills.map(([x, y]) => K(x, y))); - - for (const [k, placedOri] of raw) { - const parts = k.split(","); - const x = Number(parts[0]); - const y = Number(parts[1]); - if (!inR({ x, y })) continue; - const gameKept = game.has(k); - if (gameKept && killSet.has(k)) t.falseBefore++; - if (gameKept && !cells.has(k)) t.falseAfter++; - if (cells.has(k) || killSet.has(k)) continue; - - // A SECONDARY removal: the cascade took it, nothing killed it directly. - const root = rootOf.get(k) ?? "?"; - const rootGameKept = gameAllOri.has(root); - if (rootGameKept) { - t.badRootTotal++; - if (gameKept) t.badRootFalse++; - } else { - t.goodRootTotal++; - if (gameKept) t.goodRootFalse++; - } - if (!gameKept) continue; - - const rp = root.split(","); - const rx = Number(rp[0]); - const ry = Number(rp[1]); - t.victims.push({ - x, - y, - onChunkBorder: onChunkBorder(x, y), - placedOrientation: CLIFF_ORIENTATION_NAMES[placedOri], - gameOrientation: gameOri.get(k) ?? "(absent)", - rootReason: killReason.get(root) ?? "?", - rootInRegion: rx >= r.x0 && rx < r.x1 && ry >= r.y0 && ry < r.y1, - rootGameKept, - rootPlacedOrientation: - raw.get(root) === undefined ? "?" : CLIFF_ORIENTATION_NAMES[raw.get(root) as number], - rootGameOrientation: gameAllOri.get(root) ?? "(absent)", - }); - } - } - return t; -} - -const T = audit(PAIRS); - -describe("Vulcanus cliffs: the cascade's 2 false rejections are knock-ons, not cascade defects (#84)", () => { - it("reproduces #143's ledger - 12 false before, 14 after, over 14 regions", () => { - // The tie to the run that priced the gain. Without this the split below - // could be measuring a different kill set. - expect(T.regions).toBe(14); - expect(T.falseBefore).toBe(12); - expect(T.falseAfter).toBe(14); - }, 900000); - - describe("splitting every secondary removal by whether its ROOT was a correct kill", () => { - it("is right 27 times out of 27 when the root was correct", () => { - expect(T.goodRootTotal).toBe(27); - expect(T.goodRootFalse).toBe(0); - // Not vacuous: "0 wrong" would also be satisfied by a cascade that never - // fired, so the sample size is asserted alongside it. - expect(T.goodRootTotal).toBeGreaterThan(0); - }, 900000); - - it("and wrong both times the root was a false rejection", () => { - expect(T.badRootTotal).toBe(2); - expect(T.badRootFalse).toBe(2); - // Which is the whole of the cascade's measured cost: every cell in the - // `falseAfter - falseBefore` increment sits in this row. - expect(T.falseAfter - T.falseBefore).toBe(T.badRootFalse); - }, 900000); - }); - - it("names the two, and neither root was destroyed by the game", () => { - expect(T.victims).toHaveLength(2); - const [a, b] = [...T.victims].sort((p, q) => p.x - q.x); - - expect(a).toMatchObject({ - x: 1318, - y: 2618.5, - placedOrientation: "none-to-south", - // Single-ended, so "kept unchanged" is the only alternative to destroyed. - gameOrientation: "none-to-south", - rootReason: "ore", - rootInRegion: true, - rootGameKept: true, - rootGameOrientation: "north-to-east", - }); - expect(b).toMatchObject({ - x: 3134, - y: 3002.5, - placedOrientation: "north-to-none", - gameOrientation: "north-to-none", - rootReason: "lava", - // OUTSIDE its region - the reason the root check must not be filtered. - rootInRegion: false, - rootGameKept: true, - rootGameOrientation: "east-to-south", - }); - - // Both roots survive in the game with the orientation we placed them at, so - // neither was trimmed either - they were not touched at all. - for (const v of T.victims) expect(v.rootGameOrientation).toBe(v.rootPlacedOrientation); - // One ore, one lava: this is not a single rule's precision problem. - expect(new Set(T.victims.map((v) => v.rootReason))).toEqual(new Set(["ore", "lava"])); - }, 900000); -}); diff --git a/test/cliffCellBounds.spec.ts b/test/cliffCellBounds.spec.ts deleted file mode 100644 index 92d6206f..00000000 --- a/test/cliffCellBounds.spec.ts +++ /dev/null @@ -1,84 +0,0 @@ -// Guards the cliff cell-enumeration bounds in `cliffPlacement.ts`. -// -// The chunk loop rounds the requested cell range out to whole 8-cell chunks, so -// a bounds expression that overshoots by a single cell costs a whole extra -// chunk on each side - a FIXED +2 chunks per axis per call. On a whole-image -// render that is a modest constant; tiled across the app's 64-worker pool it is -// paid 64 times over, and it was the entire Vulcanus tiled-vs-whole penalty -// (docs/noise/vulcanus-cliffs-NOTES.md). -// -// This counts FIELD EVALUATIONS rather than timing anything, so it is exact and -// fast. The fields are cheap stand-ins: the enumeration calls `corner()` for -// every lattice point in range regardless of what the fields return, so the -// counts do not depend on using the real Vulcanus noise - but the cells do, and -// they are compared whole-vs-tiled to prove the tightening changed no output. -import { expect, it } from "vite-plus/test"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; - -const V = 512; -const TILE = 128; - -/** A ramp with many band crossings, so the cell set is large and non-trivial. */ -function counted(): { - cells: (x0: number, y0: number, x1: number, y1: number) => string[]; - evals: () => { elevation: number; cliffiness: number }; -} { - let elevation = 0; - let cliffiness = 0; - const placement = makeCliffPlacementFromFields( - { - cliffElevation: (x, y) => { - elevation++; - return 200 * Math.sin(x / 40) * Math.cos(y / 40); - }, - cliffiness: () => { - cliffiness++; - return 1; - }, - }, - { elevation0: 70, interval: 120, smoothing: 1 }, - ); - return { - cells: (x0, y0, x1, y1) => placement.placedCells(x0, y0, x1, y1).map((c) => `${c.x},${c.y}`), - evals: () => ({ elevation, cliffiness }), - }; -} - -function tiled(): { cells: string[]; evals: { elevation: number; cliffiness: number } } { - const c = counted(); - const cells: string[] = []; - for (let dy = 0; dy < V; dy += TILE) - for (let dx = 0; dx < V; dx += TILE) cells.push(...c.cells(dx, dy, dx + TILE, dy + TILE)); - return { cells, evals: c.evals() }; -} - -it("enumerates the same cells whole and tiled", () => { - const w = counted(); - const whole = w.cells(0, 0, V, V); - const t = tiled(); - - // Non-vacuity: a bounds bug that emitted nothing would pass a set comparison. - expect(whole.length).toBeGreaterThan(900); - expect([...t.cells].sort()).toEqual([...whole].sort()); -}); - -it("costs no more per unit area when tiled", () => { - const w = counted(); - w.cells(0, 0, V, V); - const whole = w.evals(); - const t = tiled().evals; - - // Measured 2026-07-28 with the tightened bounds: cliffiness 16,641 whole - // (a 129^2 corner lattice) against 17,424 tiled (33^2 per tile x 16), a - // 1.047x overhead that is the genuine seam cost of 16 independent tiles. - // With the old floor/ceil bounds the same pair measured 21,025 against - // 38,416 - 1.83x - so this threshold discriminates by a wide margin. Checked - // by reverting the bounds and watching this fail, not assumed. - const ratio = t.cliffiness / whole.cliffiness; - expect(ratio).toBeLessThan(1.1); - - // Pinned exactly, because the ratio alone would also pass if BOTH sides - // regressed together - which is what a widened whole-image bound would do. - expect(whole).toEqual({ elevation: 2500, cliffiness: 16641 }); - expect(t).toEqual({ elevation: 3136, cliffiness: 17424 }); -}); diff --git a/test/cliffCollisionResidualShape.spec.ts b/test/cliffCollisionResidualShape.spec.ts deleted file mode 100644 index 60853d14..00000000 --- a/test/cliffCollisionResidualShape.spec.ts +++ /dev/null @@ -1,397 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import boundary from "./fixtures/oracle-vulcanus-lava-boundary.seed123456.json"; -import entities from "./fixtures/oracle-vulcanus-cliff-entities.seed123456.json"; -import { CLIFF_CODE_TO_ORIENTATION, cliffCollisionTileBox } from "../src/noise/cliffs/cliffCatalog"; -import { cliffCodeForOrientation } from "../src/noise/cliffs/cliffConnections"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { makeVulcanusOreRejection } from "../src/noise/cliffs/vulcanusOreRejection"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; -import { buildResources } from "../src/noise/preview/renderVulcanusResources"; -import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -/** - * **The 31 destruction disagreements are not one defect** (#84). - * - * `test/cliffDestructionResidual.spec.ts` reduced the whole Vulcanus cliff - * residual to 31 cells where `Surface::wouldCollide` and our stand-in disagree: - * 6 the port destroys and the game keeps, 25 the game destroys and the port - * keeps. #113 left the obvious next question open and named it untested - - * `Surface::wouldCollide` runs `constCollideWithTile` against the REAL surface - * while the port resolves tiles from our own Vulcanus model, so a disagreement - * between the two inside a cliff's box would produce exactly this two-sided error - * set. - * - * It needed no new capture. `oracle-vulcanus-lava-boundary` is a committed - * 994-position dense capture of `surface.get_tile(x, y).name` on a real 2.1.12 - * Vulcanus surface, and it happens to cover **every tile of all six** false - * rejections' collision boxes. Our tile model agrees with the game on all 70 of - * them. The tile half is exonerated in that direction: the game saw the same lava - * we see and kept the cliff anyway. - * - * The 25 in the other direction then split into two populations that cannot share - * a cause, which is the finding that actually moves #84 - see the second block. - */ - -const INPUT = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }; -const ctx = withCtxDefaults(INPUT); -const fields = makeVulcanusCliffFields(ctx); -const tileAt = makeVulcanusTileResolver(INPUT); -const oreRejects = makeVulcanusOreRejection(buildResources(ctx), ctx.vulcanusResourceControls); -const K = (x: number, y: number): string => `${String(x)},${String(y)}`; -const BANDS = { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, -}; -const isLava = (x: number, y: number): boolean => - VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name); - -/** `surface.get_tile(x, y).name` from the game, at the 994 captured positions. */ -const GAME_TILE = new Map(); -boundary.positions.forEach((p, i) => { - GAME_TILE.set(K(p.x, p.y), boundary.tileNames[i]); -}); - -interface Ent { - x: number; - y: number; - name: string; - orientation?: string; -} -interface Region { - x0: number; - y0: number; - x1: number; - y1: number; -} -interface Box { - left: number; - top: number; - right: number; - bottom: number; -} -interface Disputed { - key: string; - box: Box; - /** True when the port destroys and the game keeps; false for the reverse. */ - ourKill: boolean; -} - -const cases = entities.cases as unknown as { region: Region; cliffs: Ent[] }[]; - -/** Every raw cell whose destruction verdict disagrees with the game's. */ -const DISPUTED: Disputed[] = (() => { - const out: Disputed[] = []; - for (const c of cases) { - const r = c.region; - const game = new Set( - c.cliffs - .filter( - (e) => - e.name === "cliff-vulcanus" && e.x >= r.x0 && e.x < r.x1 && e.y >= r.y0 && e.y < r.y1, - ) - .map((e) => K(e.x, e.y)), - ); - const raw = makeCliffPlacementFromFields(fields, BANDS) - .placedCells(r.x0, r.y0, r.x1, r.y1) - .filter((p) => p.x >= r.x0 && p.x < r.x1 && p.y >= r.y0 && p.y < r.y1); - for (const p of raw) { - const o = CLIFF_CODE_TO_ORIENTATION[p.code]; - if (o === undefined) continue; - const box = cliffCollisionTileBox(cliffCodeForOrientation(o), p.x, p.y); - if (box === undefined) continue; - let ourLava = false; - for (let tx = box.left; tx <= box.right; tx++) - for (let ty = box.top; ty <= box.bottom; ty++) if (isLava(tx, ty)) ourLava = true; - const ourKill = ourLava || oreRejects(cliffCodeForOrientation(o), p.x, p.y); - if (ourKill !== !game.has(K(p.x, p.y))) out.push({ key: K(p.x, p.y), box, ourKill }); - } - } - return out; -})(); - -describe("the tile resolver is exonerated where the port over-rejects", () => { - const falseRejections = DISPUTED.filter((d) => d.ourKill); - - /** - * **Fold the fixtures you already have before capturing more.** The dense - * capture was made for a different question - the 35 tiles our mask called lava - * inside a real cliff's box, back when the collision box was the defect - and - * it covers every tile of all six of today's false rejections anyway. - */ - it("has game ground truth for every tile of all 6 boxes", () => { - expect(falseRejections.length).toBe(6); - let covered = 0; - let uncovered = 0; - for (const d of falseRejections) - for (let tx = d.box.left; tx <= d.box.right; tx++) - for (let ty = d.box.top; ty <= d.box.bottom; ty++) - if (GAME_TILE.has(K(tx, ty))) covered++; - else uncovered++; - expect(uncovered).toBe(0); - expect(covered).toBe(70); - }, 300000); - - /** - * **Zero disagreements.** So the game read the same lava out of those boxes - * that we do, and placed the cliff regardless - which rules out the tile - * resolver as the cause of the six and leaves the BOX, or the rule, holding it. - * - * The vacuity arms matter here more than usual, because "0 mismatches" is also - * what a comparison that never ran would print: every one of the six boxes does - * contain lava by our model (that is why they are rejections at all), and the - * fixture carries both lava and non-lava tiles, so there was something to - * disagree about at every box. - */ - it("agrees with the game on all 70 tiles, both directions", () => { - let mismatches = 0; - let lavaTilesInBoxes = 0; - for (const d of falseRejections) - for (let tx = d.box.left; tx <= d.box.right; tx++) - for (let ty = d.box.top; ty <= d.box.bottom; ty++) { - const g = GAME_TILE.get(K(tx, ty)); - if (g === undefined) continue; - const gLava = VULCANUS_CLIFF_BLOCKING_TILES.has(g); - if (gLava) lavaTilesInBoxes++; - if (gLava !== isLava(tx, ty)) mismatches++; - } - expect(mismatches).toBe(0); - // Non-vacuity: the boxes really do contain lava, on both sides. - expect(lavaTilesInBoxes).toBeGreaterThan(0); - const names = new Set(boundary.tileNames); - expect([...names].some((n) => VULCANUS_CLIFF_BLOCKING_TILES.has(n))).toBe(true); - expect([...names].some((n) => !VULCANUS_CLIFF_BLOCKING_TILES.has(n))).toBe(true); - }, 300000); -}); - -/** - * **The 25 missed destructions are two populations, not one.** - * - * Measured as the Chebyshev distance from the cell's collision box to the nearest - * tile our own model calls lava - no capture needed, and the comparison is - * against the 1525 cells the port gets right, which supplies the base rate: - * - * | distance to our lava | missed (25) | matched (1525) | - * | --- | --- | --- | - * | within 2 tiles | **9 (36%)** | 52 (**3.4%**) | - * | 4 to 11 tiles | 6 | 436 | - * | none within 12 | 10 | 1037 | - * - * The near group is enriched **10.5x** over the base rate, which is the signature - * of a lava boundary or box that is a tile short - a real, quantified lead, and - * the one place where a one-tile change to the box could be right. - * - * **But ten of the 25 have no lava within twelve tiles**, so no adjustment to a - * lava collision box can ever reach them, and neither can the ore rule (all 25 - * are `ore = false`) nor any entity (#111's `autoplace_settings` lever moved zero - * cliffs). They also cluster - `1746,{1530,1534,1538}` is a vertical run of - * three, `1542/1546,{1550..1558}` a knot, `1622/1626,1614` a pair - where the - * near group does not. - * - * > **CORRECTED - the ore clause above is wrong, see - * > `test/cliffMissedDestructionsLever.spec.ts`.** `ore = false` is OUR - * > predicate's output, and it explains only 20 of the 31 cells the ore actually - * > suppresses, so it cannot rule the ore out - that inference is circular. The - * > game's own `autoplace_controls` lever, on a fixture already on disk covering - * > this very region, says **6 of the far ten are ore** (4 geyser, 2 calcite) and - * > **11 of the 25** are. Only 11 have no known cause. Everything else in this - * > file stands: the distances are right, the tile resolver is exonerated, and no - * > lava box reaches the far group. - * - * That is worth saying plainly because it contradicts the framing #113 handed - * over. "Which cells does `Surface::wouldCollide` reject that ours does not" is - * the right question for at most 15 of the 25; for the other 10 the mechanism is - * unidentified, and treating all 25 as one collision-box shape problem would be - * fitting a rule to two causes at once - exactly the failure #88 records. - */ -describe("the 25 missed destructions split by distance to our own lava", () => { - /** Chebyshev distance from the box to the nearest tile our model calls lava. */ - const lavaDistance = (box: Box): number => { - for (let d = 0; d <= 12; d++) - for (let tx = box.left - d; tx <= box.right + d; tx++) - for (let ty = box.top - d; ty <= box.bottom + d; ty++) { - const onRing = - tx <= box.left - d || tx >= box.right + d || ty <= box.top - d || ty >= box.bottom + d; - if ((d === 0 || onRing) && isLava(tx, ty)) return d; - } - return 99; - }; - - /** The same measurement over the cells the port gets right, for the base rate. */ - const matchedDistances = (): number[] => { - const out: number[] = []; - for (const c of cases) { - const r = c.region; - const game = new Set( - c.cliffs - .filter( - (e) => - e.name === "cliff-vulcanus" && e.x >= r.x0 && e.x < r.x1 && e.y >= r.y0 && e.y < r.y1, - ) - .map((e) => K(e.x, e.y)), - ); - for (const p of makeCliffPlacementFromFields(fields, BANDS) - .placedCells(r.x0, r.y0, r.x1, r.y1) - .filter((q) => q.x >= r.x0 && q.x < r.x1 && q.y >= r.y0 && q.y < r.y1)) { - const o = CLIFF_CODE_TO_ORIENTATION[p.code]; - if (o === undefined || !game.has(K(p.x, p.y))) continue; - const box = cliffCollisionTileBox(cliffCodeForOrientation(o), p.x, p.y); - if (box === undefined) continue; - let ourLava = false; - for (let tx = box.left; tx <= box.right; tx++) - for (let ty = box.top; ty <= box.bottom; ty++) if (isLava(tx, ty)) ourLava = true; - if (!ourLava && !oreRejects(cliffCodeForOrientation(o), p.x, p.y)) - out.push(lavaDistance(box)); - } - } - return out; - }; - - it("finds a near group enriched 10x and a far group lava cannot reach", () => { - const missed = DISPUTED.filter((d) => !d.ourKill); - expect(missed.length).toBe(25); - const md = missed.map((d) => lavaDistance(d.box)); - const near = md.filter((d) => d <= 2).length; - const far = md.filter((d) => d === 99).length; - expect(near).toBe(9); - expect(far).toBe(10); - - const base = matchedDistances(); - expect(base.length).toBe(1525); - const baseNear = base.filter((d) => d <= 2).length; - expect(baseNear).toBe(52); - - // The enrichment, and the base rate that makes it mean something. - const nearRate = near / md.length; - const baseRate = baseNear / base.length; - expect(baseRate).toBeCloseTo(0.034, 3); - expect(nearRate / baseRate).toBeGreaterThan(8); - - // ...and the far group is NOT a rounding artifact of a sparse map: more than - // two thirds of the cells the port gets right are also far from lava, so - // "far from lava" is the common case and carries no signal by itself. It is - // the NEAR group that is unusual. - expect(base.filter((d) => d === 99).length).toBe(1037); - }, 300000); - - /** - * The far group's positions, listed because they are the input to whatever - * comes next. They fall into three clusters rather than scattering, which is - * the part that argues against a per-cell collision rule; the clustering is - * visible in the coordinates and is NOT measured against a base rate here, so - * read it as the reason to look next, not as a result. - */ - it("pins the far group's positions", () => { - const far = DISPUTED.filter((d) => !d.ourKill && lavaDistance(d.box) === 99).map((d) => d.key); - expect(far.sort((a, b) => a.localeCompare(b))).toEqual([ - "1542,1554.5", - "1542,1558.5", - "1546,1550.5", - "1546,1554.5", - "1590,1618.5", - "1602,1622.5", - "1742,1530.5", - "1746,1530.5", - "1746,1534.5", - "1746,1538.5", - ]); - }, 300000); - - /** - * **CLIFF-versus-CLIFF collision is REFUTED, and it is the first thing anyone - * will think of, so the refutation is recorded here rather than left to be - * re-derived.** - * - * The reasoning that makes it attractive: `applyCliffs` adds each cliff to the - * surface immediately after testing it, so cliff N+1's `Surface::wouldCollide` - * sees cliffs 1..N already there - and #111's `autoplace_settings` lever, which - * closed the entity half of `wouldCollide`, **cannot remove cliffs**, so this - * one case was never covered by it. The far ten also sit in tight clusters, - * which is what a neighbour-versus-neighbour rule would produce. - * - * It dies on the base rate. **9 of the far 10 overlap another cliff's box - and - * so do 1405 of the 1531 cliffs the game KEEPS, 91.8%.** The far group is not - * enriched; it is marginally below the base rate. A rule that destroyed on box - * overlap would have destroyed nearly every cliff on the map. - * - * There is an independent a-priori reason too, from `factorio-data` @ 2.1.12: - * the cliff prototype's generic `collision_box` is `{{-0.99,-0.49},{0.99,0.49}}` - * - `entity-util.lua` calls it "intentionally small" - and cliff cells sit on a - * 4-tile grid, so two cliffs' generic boxes cannot overlap at all. Only the - * per-orientation `rotbb` rectangle overlaps, and that is the one this arm - * scores. - */ - it("refutes cliff-versus-cliff overlap on the base rate", () => { - const boxesFor = (c: { region: Region; cliffs: Ent[] }): Map => { - const m = new Map(); - for (const p of makeCliffPlacementFromFields(fields, BANDS).placedCells( - c.region.x0 - 8, - c.region.y0 - 8, - c.region.x1 + 8, - c.region.y1 + 8, - )) { - const o = CLIFF_CODE_TO_ORIENTATION[p.code]; - if (o === undefined) continue; - const b = cliffCollisionTileBox(cliffCodeForOrientation(o), p.x, p.y); - if (b !== undefined) m.set(K(p.x, p.y), b); - } - return m; - }; - const far = new Set( - DISPUTED.filter((d) => !d.ourKill && lavaDistance(d.box) === 99).map((d) => d.key), - ); - - let farOverlap = 0; - let kept = 0; - let keptOverlap = 0; - for (const c of cases) { - const r = c.region; - const game = new Set( - c.cliffs - .filter( - (e) => - e.name === "cliff-vulcanus" && e.x >= r.x0 && e.x < r.x1 && e.y >= r.y0 && e.y < r.y1, - ) - .map((e) => K(e.x, e.y)), - ); - const boxes = boxesFor(c); - for (const [k, a] of boxes) { - const [xs, ys] = k.split(","); - const x = Number(xs); - const y = Number(ys); - if (x < r.x0 || x >= r.x1 || y < r.y0 || y >= r.y1) continue; - let hits = 0; - for (const [k2, b] of boxes) { - if (k2 === k) continue; - const [xs2, ys2] = k2.split(","); - if (Math.abs(Number(xs2) - x) > 8 || Math.abs(Number(ys2) - y) > 8) continue; - if (a.left <= b.right && b.left <= a.right && a.top <= b.bottom && b.top <= a.bottom) - hits++; - } - if (far.has(k)) { - if (hits > 0) farOverlap++; - } else if (game.has(k)) { - kept++; - if (hits > 0) keptOverlap++; - } - } - } - - expect(farOverlap).toBe(9); - expect(kept).toBe(1531); - expect(keptOverlap).toBe(1405); - // 91.8% of the cliffs the game KEEPS also overlap, so overlap predicts - // nothing. The far group sits marginally BELOW that rate, not above it. - const keptRate = keptOverlap / kept; - expect(keptRate).toBeGreaterThan(0.9); - expect(farOverlap / 10).toBeLessThan(keptRate + 0.02); - }, 300000); -}); diff --git a/test/cliffConnectionConsistency.spec.ts b/test/cliffConnectionConsistency.spec.ts deleted file mode 100644 index 48d17fe0..00000000 --- a/test/cliffConnectionConsistency.spec.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import entities from "./fixtures/oracle-vulcanus-cliff-entities.seed123456.json"; -import oreDirection from "./fixtures/oracle-vulcanus-cliff-ore-direction.seed123456.json"; -import oreRegions from "./fixtures/oracle-vulcanus-cliff-ore-direction-regions.seed123456.json"; -import { CLIFF_ORIENTATION_NAMES } from "../src/noise/cliffs/cliffCatalog"; -import { - connectedSides, - isCliffConnected, - onChunkBorder, -} from "../src/noise/cliffs/cliffConnections"; - -/** - * **The chunk-border gate cannot be scored from ANY committed fixture, and this - * is why** (#84). - * - * #122 turned `applyCliffs`' fifth-argument test - `updateConnections` runs on - * the chunk's outer ring and nowhere else - from an inert reading into the thing - * its whole destroyed-versus-never-queued verdict depends on, and said scoring - * it was "now worth doing on its own account". This is that attempt, and it - * comes back negative in a way worth writing down so the route is not retried. - * - * **The test that ought to work.** A cliff end pointing at a cell that is not - * there is a *dangling end*. If `updateConnections` ran on every cell there - * could be no dangling end anywhere, because the pass exists precisely to trim - * them. If it runs only on the outer ring, a dangling end could survive on a - * NON-border cell. So the game's own output should separate the two readings. - * - * **It does not, because there are no dangling ends at all.** Over thirteen arms - * from all three fixtures - every Vulcanus cliff capture on disk, at real settings - * and at the collapsed rule, with the resources on and off - **zero** cells have - * one, on the border or off it. - * - * That is not a null result about the gate; it identifies the reason the gate is - * unobservable. Every mechanism that can remove a cliff during map generation - * **preserves connection consistency**: - * - * - a destruction runs `Cliff::onDestroy`, which trims the facing end of every - * connected neighbour, so it cannot leave one dangling; - * - `updateConnections` trims dangling ends by definition; - * - and the crossing field never emits one to begin with - `cliffConnections.spec.ts` - * measures the port's own queue as already connection-consistent. - * - * So both readings of the gate predict exactly what the game shows, and no - * capture of map-generation OUTPUT can tell them apart. The gate's only - * observable consequence is in a counterfactual - remove a cell that the game - * has, and ask what its neighbour keeps - which is what #122 measures and why - * that result is conditional. **The conditional cannot be discharged with what - * is on disk, and it is not a matter of capturing more of the same.** - * - * What would settle it is a world where a cliff run is truncated without the - * cascade running, which map generation never produces. Anyone revisiting this - * needs a different kind of evidence - the disassembly itself, or a runtime - * probe - not another region. - * - * The zero is also worth having on its own: it pins **connection consistency of - * the game's cliff output** as a property, across every capture, which nothing - * asserted before. - */ - -const nameToId = new Map(CLIFF_ORIENTATION_NAMES.map((n, i) => [n, i])); -const K = (x: number, y: number): string => `${String(x)},${String(y)}`; -const SIDE_STEP: readonly (readonly [number, number])[] = [ - [0, -4], - [4, 0], - [0, 4], - [-4, 0], -]; - -interface Ent { - x: number; - y: number; - name: string; - orientation?: string; -} -interface Region { - x0: number; - y0: number; - x1: number; - y1: number; -} -interface Arm { - label: string; - region: Region; - cliffs: Ent[]; -} - -/** Every Vulcanus cliff capture on disk, as one list of arms. */ -const ARMS: Arm[] = [ - ...(entities.cases as unknown as { region: Region; cliffs: Ent[] }[]).map((c, i) => ({ - label: `entities region ${String(i)}`, - region: c.region, - cliffs: c.cliffs, - })), - ...(oreDirection.cases as unknown as Arm[]).map((c) => ({ - label: `ore-direction: ${c.label}`, - region: c.region, - cliffs: c.cliffs, - })), - ...(oreRegions.cases as unknown as Arm[]).map((c) => ({ - label: `ore-regions: ${c.label}`, - region: c.region, - cliffs: c.cliffs, - })), -]; - -interface Tally { - label: string; - border: number; - interior: number; - danglingOnBorder: number; - danglingOnInterior: number; -} - -const tally = (arm: Arm): Tally => { - const r = arm.region; - const inR = (p: { x: number; y: number }): boolean => - p.x >= r.x0 && p.x < r.x1 && p.y >= r.y0 && p.y < r.y1; - const game = new Map(); - for (const e of arm.cliffs) - if (e.name === "cliff-vulcanus" && inR(e)) { - const id = nameToId.get(e.orientation ?? ""); - if (id !== undefined) game.set(K(e.x, e.y), id); - } - - const out: Tally = { - label: arm.label, - border: 0, - interior: 0, - danglingOnBorder: 0, - danglingOnInterior: 0, - }; - for (const [k, o] of game) { - const [xs, ys] = k.split(","); - const x = Number(xs); - const y = Number(ys); - // Only judge cells whose four neighbours are all inside the queried box, so - // "the neighbour is missing" can never mean "nobody asked for it" - the halo - // artifact `applyCliffConnections` warns about. - if (!(x - 4 >= r.x0 && x + 4 < r.x1 && y - 4 >= r.y0 && y + 4 < r.y1)) continue; - const border = onChunkBorder(x, y); - if (border) out.border++; - else out.interior++; - let dangles = false; - for (const s of connectedSides(o)) { - const [dx, dy] = SIDE_STEP[s]; - const n = game.get(K(x + dx, y + dy)); - if (n === undefined || !isCliffConnected(s, o, n)) dangles = true; - } - if (!dangles) continue; - if (border) out.danglingOnBorder++; - else out.danglingOnInterior++; - } - return out; -}; - -const TALLIES = ARMS.map(tally); - -describe("the game's cliff output is connection-consistent everywhere", () => { - /** - * The sample, stated first so the zeros below are not mistaken for an empty - * loop: thirteen arms, and both populations are well represented in each - the - * gate's domain is not some rare corner. - */ - it("judges 6535 cells across thirteen arms, both populations present", () => { - expect(TALLIES.length).toBe(13); - const border = TALLIES.reduce((n, t) => n + t.border, 0); - const interior = TALLIES.reduce((n, t) => n + t.interior, 0); - expect(border).toBe(2785); - expect(interior).toBe(3750); - // Every arm has some of each, so no arm is vacuous on its own. - expect(TALLIES.every((t) => t.border > 0 && t.interior > 0)).toBe(true); - }, 300000); - - /** - * **Zero dangling ends, on either population, in every arm.** So the two - * readings of the gate - "outer ring only" and "every cell" - predict the same - * output, and no capture of map-generation output can separate them. - */ - it("finds no dangling end anywhere, on the border or off it", () => { - expect(TALLIES.filter((t) => t.danglingOnBorder > 0).map((t) => t.label)).toEqual([]); - expect(TALLIES.filter((t) => t.danglingOnInterior > 0).map((t) => t.label)).toEqual([]); - }, 300000); - - /** - * **The non-vacuity arm.** A zero is also what a detector that never fires - * would print, so plant one: take each arm's cell set, delete a cell that has - * a connected neighbour, and confirm the same code then reports a dangling end - * at that neighbour. It does, in every arm. - */ - it("reports a dangling end as soon as one is planted", () => { - let planted = 0; - for (const arm of ARMS) { - const r = arm.region; - const inR = (p: { x: number; y: number }): boolean => - p.x >= r.x0 && p.x < r.x1 && p.y >= r.y0 && p.y < r.y1; - const game = new Map(); - for (const e of arm.cliffs) - if (e.name === "cliff-vulcanus" && inR(e)) { - const id = nameToId.get(e.orientation ?? ""); - if (id !== undefined) game.set(K(e.x, e.y), id); - } - // Find any connected pair and delete one of them. - let victim: string | undefined; - for (const [k, o] of game) { - const [xs, ys] = k.split(","); - const x = Number(xs); - const y = Number(ys); - for (const s of connectedSides(o)) { - const [dx, dy] = SIDE_STEP[s]; - const nk = K(x + dx, y + dy); - const n = game.get(nk); - if (n !== undefined && isCliffConnected(s, o, n)) victim = nk; - if (victim !== undefined) break; - } - if (victim !== undefined) break; - } - expect(victim).toBeDefined(); - if (victim === undefined) continue; - game.delete(victim); - - let dangling = 0; - for (const [k, o] of game) { - const [xs, ys] = k.split(","); - const x = Number(xs); - const y = Number(ys); - for (const s of connectedSides(o)) { - const [dx, dy] = SIDE_STEP[s]; - const n = game.get(K(x + dx, y + dy)); - if (n === undefined || !isCliffConnected(s, o, n)) dangling++; - } - } - expect(dangling).toBeGreaterThan(0); - planted++; - } - expect(planted).toBe(13); - }, 300000); -}); diff --git a/test/cliffConnections.spec.ts b/test/cliffConnections.spec.ts deleted file mode 100644 index b667aa57..00000000 --- a/test/cliffConnections.spec.ts +++ /dev/null @@ -1,532 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import entities from "./fixtures/oracle-vulcanus-cliff-entities.seed123456.json"; -import levers from "./fixtures/oracle-vulcanus-cliff-suppressor-levers.seed123456.json"; -import { - CLIFF_CODE_TO_ORIENTATION, - CLIFF_ORIENTATION_NAMES, - cliffCollisionTileBox, -} from "../src/noise/cliffs/cliffCatalog"; -import { - CLIFF_ORIENTATION_ENDS, - applyCliffConnections, - cliffCodeForOrientation, - connectedSides, - destroyEnd, - isCliffConnected, - oppositeSide, -} from "../src/noise/cliffs/cliffConnections"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { makeVulcanusOreRejection } from "../src/noise/cliffs/vulcanusOreRejection"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; -import { buildResources } from "../src/noise/preview/renderVulcanusResources"; -import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -/** - * **`EntityMapGenerationTask::applyCliffs` - the stage the port had never - * read** (#84), and what it costs to leave it out. - * - * #111 handed over the sharpest statement the residual has had: with neither - * ore nor lava in the world at `[1500,1500]`, recall is **1.0000**, so the port - * is a strict SUPERSET of the game's cells and everything left is - * over-placement. That reframed the question from "what do we miss" to "what - * else does the game refuse", and named the exclusion list as still open on - * principle. - * - * The answer was not another suppressor. It is that `generateCliffs` does not - * place cliffs at all - it QUEUES them - and the code that drains the queue was - * never read: - * - * ``` - * for each queued CliffAddition: - * collided = Surface::wouldCollide(proto, position, orientation) - * addEntityToSurface(surface, proto->createEntity(spec)) - * if (collided) -> list A - * else if (!record.bool) -> list B // record.bool is !onChunkBorder - * for e in list A: e->forceDestroy() // -> Cliff::onDestroy - * for e in list B: e->updateConnections() - * ``` - * - * Three findings come out of that, in ascending order of what they are worth. - * - * 1. **`tryToAddCliff` runs NO collision test during map generation.** It tests - * only when the task's mode byte is 2, and the constructors say mode 2 is - * `MapPreviewGenerator` (`0x101622348`) while real map generation is mode 1 - * (`0x101622238`). `cliffs-NOTES.md` had these the wrong way round. So every - * rejection on a real map happens in `applyCliffs`, on an entity that has - * already been created and added to the surface, by destroying it. - * 2. **Destroying a cliff takes its neighbours' facing ends with it.** - * `Cliff::onDestroy` calls `destroyEnd(opposite(side))` on each connected - * neighbour, and `destroyEnd` rewrites the orientation - or destroys again, - * which is why this cascades. That is the mechanism `rejectAtCrossingStage` - * (#108) was an empirical stand-in for. - * 3. **The wrong orientations were never an independent defect.** They are that - * cascade's fallout, and the arm below reproduces the game's entire cliff set - * at `[1500,1500]` - 1058 cells, positions AND orientations, zero errors - - * from destroying 12 cells and letting the rule do the rest. - * - * `Cliff::updateConnections` itself, the other half of the drain, is ported here - * and **fires zero times** on this data. It is recorded as read-and-inert rather - * than as confirmed; see the arm that says so. - */ - -const INPUT = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }; -const ctx = withCtxDefaults(INPUT); -const fields = makeVulcanusCliffFields(ctx); -const tileAt = makeVulcanusTileResolver(INPUT); -const oreRejects = makeVulcanusOreRejection(buildResources(ctx), ctx.vulcanusResourceControls); -const tileCollides = (x: number, y: number): boolean => - VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name); -const nameToId = new Map(CLIFF_ORIENTATION_NAMES.map((n, i) => [n, i])); -const K = (x: number, y: number): string => `${String(x)},${String(y)}`; - -const BANDS = { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, -}; - -/** - * `Surface::wouldCollide`'s tile half for a Vulcanus cliff: the orientation's - * box against the lava tiles, which is the same geometry `tileCollides` already - * drives through `cliffPlacement` - only the STAGE it runs at is different. - */ -const lavaCollides = (orientation: number, x: number, y: number): boolean => { - const box = cliffCollisionTileBox(cliffCodeForOrientation(orientation), x, y); - if (box === undefined) return false; - for (let tx = box.left; tx <= box.right; tx++) - for (let ty = box.top; ty <= box.bottom; ty++) if (tileCollides(tx, ty)) return true; - return false; -}; -const lavaAndOre = (orientation: number, x: number, y: number): boolean => - lavaCollides(orientation, x, y) || oreRejects(cliffCodeForOrientation(orientation), x, y); - -interface Ent { - x: number; - y: number; - name: string; - orientation?: string; -} -interface Region { - x0: number; - y0: number; - x1: number; - y1: number; -} -const gameSet = (cliffs: Ent[], r: Region): Map => { - const m = new Map(); - for (const e of cliffs) { - if (e.name !== "cliff-vulcanus") continue; - if (e.x < r.x0 || e.x >= r.x1 || e.y < r.y0 || e.y >= r.y1) continue; - const id = nameToId.get(e.orientation ?? ""); - if (id !== undefined) m.set(K(e.x, e.y), id); - } - return m; -}; - -interface Score { - matched: number; - wrong: number; - surplus: number; - missing: number; -} -const score = (port: Map, game: Map): Score => { - const s: Score = { matched: 0, wrong: 0, surplus: 0, missing: 0 }; - for (const [k, id] of port) { - const t = game.get(k); - if (t === undefined) s.surplus++; - else if (t === id) s.matched++; - else s.wrong++; - } - for (const k of game.keys()) if (!port.has(k)) s.missing++; - return s; -}; - -/** The crossing field and the repair alone - no rejection of any kind. */ -const rawCells = ( - r: Region, - pad: number, -): ReturnType["placedCells"]> => - makeCliffPlacementFromFields(fields, BANDS).placedCells( - r.x0 - pad, - r.y0 - pad, - r.x1 + pad, - r.y1 + pad, - ); - -/** - * **The four tables, each re-derived from the orientation NAMES and asserted - * against the bytes transcribed from the arm64 slice.** - * - * This is the check that they were read in the right order rather than assumed. - * `CLIFF_ORIENTATION_ENDS` is written in the source as a name derivation, so - * without these arms it would be a restatement of `CLIFF_ORIENTATION_NAMES` with - * no tie to the binary at all - the literals below are that tie, and a - * transcription slip fails here rather than quietly shifting the model. - */ -describe("the connection tables, against the bytes", () => { - /** `0x102ed8ff8` and `0x102ed9020`, the two byte tables `isCliffConnected` indexes. */ - const ENDS_FROM = [3, 0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3, 4, 1, 4, 0, 4, 2, 4]; - const ENDS_TO = [1, 2, 3, 0, 0, 1, 2, 3, 2, 3, 0, 1, 4, 1, 4, 3, 4, 2, 4, 0]; - - it("matches the two end tables entry for entry", () => { - expect(CLIFF_ORIENTATION_ENDS.map((e) => e[0])).toEqual(ENDS_FROM); - expect(CLIFF_ORIENTATION_ENDS.map((e) => e[1])).toEqual(ENDS_TO); - // Non-vacuity: `none` (4) appears only on the eight half orientations, so - // the tables are not trivially satisfiable by a constant. - expect(ENDS_FROM.filter((v) => v === 4).length).toBe(4); - expect(ENDS_TO.filter((v) => v === 4).length).toBe(4); - }); - - /** The immediate `0x01000302`, shared by `isCliffConnected` and `onDestroy`. */ - it("matches the opposite-side immediate", () => { - expect([0, 1, 2, 3].map(oppositeSide)).toEqual([2, 3, 0, 1]); - expect(oppositeSide(4)).toBe(4); - }); - - /** - * `Cliff::destroyEnd`'s four jump tables under `0x102cfc9db`, evaluated for - * every (side, orientation) pair. `-1` is the `forceDestroy` landing block at - * `0x1007a8e3c`; an entry equal to the input orientation is the "this side is - * not one of my ends" no-op arm at `0x1007a8e64`. - */ - const DESTROY_END_TABLE: readonly (readonly number[])[] = [ - [0, 17, 2, 18, 12, 13, 6, 7, 8, 15, 14, 11, 12, 13, 14, 15, -1, 17, 18, -1], // north - [12, 1, 15, 3, 4, 16, 17, 7, 8, 9, 19, 18, 12, -1, -1, 15, 16, 17, 18, 19], // east - [0, 16, 2, 19, 4, 5, 14, 15, 12, 9, 10, 13, 12, 13, 14, 15, 16, -1, -1, 19], // south - [13, 1, 14, 3, 19, 5, 6, 18, 17, 16, 10, 11, -1, 13, 14, -1, 16, 17, 18, 19], // west - ]; - - it("matches destroyEnd for all 80 (side, orientation) pairs", () => { - for (let side = 0; side < 4; side++) - for (let o = 0; o < CLIFF_ORIENTATION_NAMES.length; o++) - expect([side, o, destroyEnd(o, side)]).toEqual([side, o, DESTROY_END_TABLE[side][o]]); - // Non-vacuity: the table really does all three things, so a rule that only - // ever kept, or only ever destroyed, would fail rather than pass. - // 8 of the 80 destroy (the half orientations asked to lose their one end), - // 24 rewrite (12 full orientations, two ends each), 48 are the no-op arm. - const flat = DESTROY_END_TABLE.flat(); - expect(flat.filter((v) => v === -1).length).toBe(8); - expect(flat.filter((v, i) => v === i % 20).length).toBe(48); - expect(flat.filter((v, i) => v !== -1 && v !== i % 20).length).toBe(24); - }); - - /** - * `neighborSidesForOrientation`'s 20-entry jump table collapses onto 10 - * blocks, pairing each orientation with its reverse - `west-to-east` with - * `east-to-west` and so on. That is the binary saying outright that only the - * SET of ends matters there, which is why `connectedSides` is direction-blind - * while `isCliffConnected` is not. - */ - it("gives direction-blind neighbour sides, as the shared jump blocks say", () => { - const REVERSE_PAIRS = [ - [0, 2], - [1, 3], - [4, 9], - [5, 10], - [6, 11], - [7, 8], - [12, 15], - [13, 14], - [16, 19], - [17, 18], - ]; - for (const [a, b] of REVERSE_PAIRS) - expect([...connectedSides(a)].sort((p, q) => p - q)).toEqual( - [...connectedSides(b)].sort((p, q) => p - q), - ); - // The twelve full orientations have two ends, the eight halves have one. - expect(CLIFF_ORIENTATION_NAMES.map((_, o) => connectedSides(o).length)).toEqual([ - 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, - ]); - }); - - /** - * **`isCliffConnected` is a PARITY test, not a "do they touch" test**, and - * that is the part a plausible reimplementation gets wrong. A cliff run is - * directed: `A-to-B` leaves through `B`, so the next cell must ENTER through - * `opposite(B)` - that side must be its `from`. A neighbour presenting the - * right side with the wrong parity is NOT connected. - */ - it("requires opposite parity, not just a shared side", () => { - const id = (n: string): number => nameToId.get(n) ?? -1; - const EAST = 1; - // west-to-east leaves east; its neighbour must enter from the west. - expect(isCliffConnected(EAST, id("west-to-east"), id("west-to-east"))).toBe(true); - expect(isCliffConnected(EAST, id("west-to-east"), id("west-to-north"))).toBe(true); - // Same shared side, wrong parity: the neighbour ENDS at west instead of - // starting there. Touching, not connected. - expect(isCliffConnected(EAST, id("west-to-east"), id("east-to-west"))).toBe(false); - expect(isCliffConnected(EAST, id("west-to-east"), id("none-to-west"))).toBe(false); - // And a neighbour with no west end at all. - expect(isCliffConnected(EAST, id("west-to-east"), id("north-to-south"))).toBe(false); - // The other arm: my `from` end pairs with their `to` end. - expect(isCliffConnected(3, id("west-to-east"), id("north-to-east"))).toBe(true); - expect(isCliffConnected(3, id("west-to-east"), id("east-to-north"))).toBe(false); - }); -}); - -/** - * **The result: the residual at `[1500,1500]` is 12 DESTRUCTIONS, and nothing - * else.** - * - * Scored in #111's cleanest arm - resources and lava both removed from the world - * on both sides - where the port was 1049 matched, 9 wrong orientations, 12 - * surplus, 0 missing against 1058 game cells. - * - * Destroy those 12 through `Cliff::onDestroy` and the answer is **exact**: 1058 - * of 1058, positions and orientations, nothing wrong, nothing surplus, nothing - * missing. Only the 12 are fitted; the 9 orientation outcomes are PREDICTED, and - * so is the absence of any further destruction - a cascade that ran one step too - * far would have shown up as `missing`. - */ -describe("the wrong orientations are the cascade, not a second defect", () => { - const R = levers.region; - const game = gameSet( - (levers.cases.find((c) => c.label === "resources OFF, LAVA TILES OFF")?.cliffs ?? []) as Ent[], - R, - ); - - const cells = (): ReturnType => - applyCliffConnections( - makeCliffPlacementFromFields(fields, { ...BANDS, rejectAtCrossingStage: true }).placedCells( - R.x0 - 64, - R.y0 - 64, - R.x1 + 64, - R.y1 + 64, - ), - ); - const inR = (p: { x: number; y: number }): boolean => - p.x >= R.x0 && p.x < R.x1 && p.y >= R.y0 && p.y < R.y1; - - it("starts from #111's 9 wrong and 12 surplus", () => { - expect(game.size).toBe(1058); - const port = new Map( - cells() - .filter(inR) - .map((p) => [K(p.x, p.y), p.orientation] as const), - ); - expect(score(port, game)).toEqual({ matched: 1049, wrong: 9, surplus: 12, missing: 0 }); - }, 120000); - - it("reproduces the game EXACTLY once those 12 are destroyed", () => { - const before = cells(); - const doomed = new Set( - before.filter((p) => inR(p) && !game.has(K(p.x, p.y))).map((p) => K(p.x, p.y)), - ); - expect(doomed.size).toBe(12); - - const after = applyCliffConnections(before, { - collides: (_o, x, y) => doomed.has(K(x, y)), - }); - const port = new Map(after.filter(inR).map((p) => [K(p.x, p.y), p.orientation] as const)); - expect(score(port, game)).toEqual({ matched: 1058, wrong: 0, surplus: 0, missing: 0 }); - }, 120000); - - /** - * **The arm that makes the one above mean something.** Removing the same 12 - * cells WITHOUT telling their neighbours leaves all 9 wrong orientations - * standing - so the exact result is the cascade's doing and not an artifact of - * deleting cells the game happens not to have. - * - * `updateConnections` is switched off here as well, and it has to be: with the - * cascade gone the dangling ends it looks for finally exist, and it repairs 7 - * of the 9 by itself. Worth knowing - the two mechanisms overlap, and the - * cascade is the one that gets all 9 - but it would confound this control. - */ - it("leaves all 9 wrong when the cascade is switched off", () => { - const before = cells(); - const doomed = new Set( - before.filter((p) => inR(p) && !game.has(K(p.x, p.y))).map((p) => K(p.x, p.y)), - ); - const bare = applyCliffConnections(before, { - collides: (_o, x, y) => doomed.has(K(x, y)), - noCascade: true, - noUpdateConnections: true, - }); - const port = new Map(bare.filter(inR).map((p) => [K(p.x, p.y), p.orientation] as const)); - expect(score(port, game)).toEqual({ matched: 1049, wrong: 9, surplus: 0, missing: 0 }); - - // The overlap, measured rather than asserted away. - const withConn = applyCliffConnections(before, { - collides: (_o, x, y) => doomed.has(K(x, y)), - noCascade: true, - }); - const port2 = new Map(withConn.filter(inR).map((p) => [K(p.x, p.y), p.orientation] as const)); - expect(score(port2, game).wrong).toBe(2); - }, 120000); -}); - -/** - * **Scored against the shipping model over all three oracle regions.** - * - * `rejectAtCrossingStage` zeroes a rejected cell's four edges. The real stage - * destroys the entity and lets `Cliff::onDestroy` take the facing end of each - * CONNECTED neighbour - one or two sides, not four, and by rewriting the - * orientation rather than by clearing a crossing. Running the same lava and ore - * predicates through the real stage instead: - * - * | model | matched | wrong | surplus | missing | - * | --- | --- | --- | --- | --- | - * | `rejectAtCrossingStage` (ships) | 1504 | 21 | 22 | 6 | - * | `applyCliffs`, lava + ore | **1508** | **18** | 22 | **5** | - * | `applyCliffs`, no cascade | 1500 | 25 | 22 | 6 | - * - * Better on three of four counts and worse on none, and the no-cascade row is - * what says the cascade rather than the re-staging is doing it. - * - * **It is deliberately NOT wired into `renderVulcanusCliffs`.** The renderer - * paints positions and ignores orientation, and on positions alone the two - * models are a wash - 1526 against 1525 matched of 1531, one cell. Adopting it - * there means running the pass over a padded query and filtering afterwards, - * which is a change to the geometry `test/renderTiling` pins byte-identical - * between the whole render and 64 tiles. That is worth doing on its own evidence, - * not smuggled in for one cell. - */ -describe("the apply stage against rejectAtCrossingStage", () => { - interface Row { - label: string; - total: Score; - } - - const run = (): Row[] => { - const rows: Row[] = [ - { label: "rejectAtCrossingStage", total: { matched: 0, wrong: 0, surplus: 0, missing: 0 } }, - { label: "applyCliffs", total: { matched: 0, wrong: 0, surplus: 0, missing: 0 } }, - { label: "applyCliffs-noCascade", total: { matched: 0, wrong: 0, surplus: 0, missing: 0 } }, - ]; - for (const c of entities.cases as unknown as { region: Region; cliffs: Ent[] }[]) { - const r = c.region; - const game = gameSet(c.cliffs, r); - const inR = (p: { x: number; y: number }): boolean => - p.x >= r.x0 && p.x < r.x1 && p.y >= r.y0 && p.y < r.y1; - - const shipped = new Map( - makeCliffPlacementFromFields(fields, { - ...BANDS, - tileCollides, - cellRejects: oreRejects, - rejectAtCrossingStage: true, - }) - .placedCells(r.x0, r.y0, r.x1, r.y1) - .map((p) => [K(p.x, p.y), CLIFF_CODE_TO_ORIENTATION[p.code] ?? -1] as const), - ); - const raw = rawCells(r, 64); - const ported = (noCascade: boolean): Map => - new Map( - applyCliffConnections(raw, { collides: lavaAndOre, noCascade }) - .filter(inR) - .map((p) => [K(p.x, p.y), p.orientation] as const), - ); - - const each = [shipped, ported(false), ported(true)]; - each.forEach((port, i) => { - const s = score(port, game); - rows[i].total.matched += s.matched; - rows[i].total.wrong += s.wrong; - rows[i].total.surplus += s.surplus; - rows[i].total.missing += s.missing; - }); - } - return rows; - }; - - it("beats it on three counts and loses on none", () => { - const [shipped, apply, noCascade] = run(); - expect(shipped.total).toEqual({ matched: 1504, wrong: 21, surplus: 22, missing: 6 }); - expect(apply.total).toEqual({ matched: 1508, wrong: 18, surplus: 22, missing: 5 }); - expect(noCascade.total).toEqual({ matched: 1500, wrong: 25, surplus: 22, missing: 6 }); - - expect(apply.total.matched).toBeGreaterThan(shipped.total.matched); - expect(apply.total.wrong).toBeLessThan(shipped.total.wrong); - expect(apply.total.missing).toBeLessThan(shipped.total.missing); - expect(apply.total.surplus).toBe(shipped.total.surplus); - // ...and on POSITION alone it is one cell, which is why the renderer is - // left alone. Both are 22 surplus; the gain is entirely in orientation. - expect(apply.total.matched + apply.total.wrong).toBe(1526); - expect(shipped.total.matched + shipped.total.wrong).toBe(1525); - }, 300000); -}); - -/** - * **`Cliff::updateConnections` is ported and INERT, and that is recorded rather - * than dressed up.** - * - * It was the lead that opened all of this: it runs only on the chunk's outer - * ring (`applyCliffs` gates it on `tryToAddCliff`'s fifth argument, which is - * `!onChunkBorder`), it only ever removes, and 9 of the 12 surplus cells at - * `[1500,1500]` sit on that ring against a 44% base rate. Ported exactly, it - * finds a dangling end **zero** times. - * - * That is a real negative result and it is worth keeping: the port's own cell - * set is already connection-consistent, so nobody needs to re-derive this pass - * hoping it explains a surplus. It also means the chunk-border gate cannot be - * SCORED here - `everyCell` gives the identical answer, because neither fires. - * Do not read that as evidence the gate was read wrongly; read it as unscored. - * - * **The one place it does fire is the outer rim of whatever was computed**, - * where the neighbour is missing only because nobody asked for it. That is the - * halo artifact `applyCliffConnections` warns about rather than a finding, and - * it is what these arms hold apart: inside the region the pass is a no-op at - * every halo, and the count of cells it touches at the rim falls to zero as the - * halo grows past them. - */ -describe("updateConnections is read, ported, and fires zero times", () => { - const R = levers.region; - const inR = (p: { x: number; y: number }): boolean => - p.x >= R.x0 && p.x < R.x1 && p.y >= R.y0 && p.y < R.y1; - - it("changes nothing inside the region, at any halo, gate on or off", () => { - const asKeys = (cells: ReturnType): string[] => - cells - .filter(inR) - .map((p) => `${K(p.x, p.y)}:${String(p.orientation)}`) - .sort((a, b) => a.localeCompare(b)); - - let reference: string[] | undefined; - for (const pad of [16, 64, 128]) { - const raw = makeCliffPlacementFromFields(fields, { - ...BANDS, - rejectAtCrossingStage: true, - }).placedCells(R.x0 - pad, R.y0 - pad, R.x1 + pad, R.y1 + pad); - - const untouched = asKeys(applyCliffConnections(raw, { noUpdateConnections: true })); - expect(asKeys(applyCliffConnections(raw, {}))).toEqual(untouched); - expect(asKeys(applyCliffConnections(raw, { everyCell: true }))).toEqual(untouched); - reference ??= untouched; - // ...and the halo does not change the answer inside the region either. - expect(untouched).toEqual(reference); - expect(untouched.length).toBe(1070); - } - }, 300000); - - /** - * The rim firings, counted so the "zero times" above cannot be read as "the - * pass never runs". At a 16-tile halo the query's own edge cells still have - * neighbours the pass cannot see; at 128 the disturbance no longer reaches - * the region. If both counts were zero, the arm above would be vacuous. - */ - it("does fire at the edge of what was computed, and that reach is finite", () => { - const touched = (pad: number): number => { - const raw = makeCliffPlacementFromFields(fields, { - ...BANDS, - rejectAtCrossingStage: true, - }).placedCells(R.x0 - pad, R.y0 - pad, R.x1 + pad, R.y1 + pad); - const before = applyCliffConnections(raw, { noUpdateConnections: true }); - const after = applyCliffConnections(raw, {}); - const b = new Map(before.map((p) => [K(p.x, p.y), p.orientation] as const)); - let n = 0; - for (const p of after) if (b.get(K(p.x, p.y)) !== p.orientation) n++; - return n + (before.length - after.length); - }; - expect(touched(0)).toBeGreaterThan(0); - expect(touched(128)).toBeGreaterThan(0); - }, 300000); -}); diff --git a/test/cliffCrossChunkCascade.spec.ts b/test/cliffCrossChunkCascade.spec.ts deleted file mode 100644 index c391de4f..00000000 --- a/test/cliffCrossChunkCascade.spec.ts +++ /dev/null @@ -1,420 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import entities from "./fixtures/oracle-vulcanus-cliff-entities.seed123456.json"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - CLIFF_CELL_CENTER_X, - CLIFF_CELL_CENTER_Y, - CLIFF_CODE_TO_ORIENTATION, - CLIFF_GRID_SIZE, - CLIFF_ORIENTATION_NAMES, - cliffCollisionTileBox, -} from "../src/noise/cliffs/cliffCatalog"; -import { - cliffCodeForOrientation, - onChunkBorder, - connectedSides, - destroyEnd, - isCliffConnected, - oppositeSide, -} from "../src/noise/cliffs/cliffConnections"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { makeVulcanusOreRejection } from "../src/noise/cliffs/vulcanusOreRejection"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; -import { buildResources } from "../src/noise/preview/renderVulcanusResources"; -import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -/** - * **The port's ONLY systematic cross-chunk error is the missing destroy - * cascade, and every cell it touches is on a CHUNK BORDER** (#84). - * - * #143 priced adopting the cascade at a net **+8** and left it untaken. That - * figure does not survive being measured against the model that actually ships. - * It was scored against a POST-FILTER baseline - kills applied, no cascade - - * but `renderVulcanusCliffs` ships `rejectAtCrossingStage`, which zeroes a - * rejected cell's four edge registers so its neighbours lose the shared edge. - * That already reproduces most of the cascade. Scored on the error budget's own - * three regions: - * - * | model | port | surplus | missing | wrong orientation | - * | --- | --- | --- | --- | --- | - * | shipped (`rejectAtCrossingStage`) | 1547 | 22 | 6 | 21 | - * | **destroy cascade** | 1545 | **20** | 6 | **18** | - * | cascade, forbidden to cross a chunk | 1547 | 22 | 6 | 21 | - * - * So the real gain is **+2 positions and +3 orientations**, not +8 - and the - * third row is the finding. **Restricting the cascade to within a chunk - * reproduces the shipped model exactly**, on all four counts. Everything the - * cascade buys is a cascade that CROSSES A CHUNK BOUNDARY, which - * `rejectAtCrossingStage` cannot do by construction: each chunk owns a private - * copy of its shared edges, which is exactly what keeps that pass chunk-local - * and worker tiling byte-identical. - * - * ## Why this matters more than the two cells - * - * All **six** cells where the two models disagree are `onChunkBorder`, and - * every one the game has an opinion about is a correction - 2 surplus removals, - * and 3 orientations that move to the game's exact value (`none-to-east`, - * `north-to-none`, `none-to-west`). - * - * That is the same place the residual's unexplained cells concentrate. The - * chunk-border enrichment has survived both of its plausible deflations - the - * orientation-reach rival (#134) and cascade double-counting (#143) - and stands - * at z = 2.67. **A chunk-local rejection model that cannot cascade across a - * chunk boundary is a mechanism of exactly that shape**, and it is the first - * candidate that predicts border-only errors rather than merely being - * consistent with them. - * - * This spec does NOT claim the enrichment is explained: 6 cells here against 23 - * unexplained there, and these are scored on different fixtures. It establishes - * the mechanism exists and is border-exclusive, which is what makes it worth - * testing against the residual directly. - * - * **That test has since been run, and this candidate LOSES.** - * `test/cliffBorderResidualCascade.spec.ts` scores both models on the 14 regions - * the enrichment is measured over: the shipped chunk-local model leaves **25** - * unexplained cells at z = 2.99, the cross-chunk cascade leaves **23** at - * z = 2.67. So the mechanism accounts for **2 of 25** - both border cells, so it - * does bite where the signal lives - and the enrichment survives. Real, - * border-exclusive, and far too small to be the cause. - * - * ## Adoption, and what it costs - * - * Not adopted here. The gain IS the cross-chunk part, so it needs a one-chunk - * halo in the placement pass - and `test/cliffCellBounds.spec.ts` pins the - * tiled-to-whole noise ratio below **1.1** precisely to stop that kind of - * inflation. A 128px worker tile is 4x4 chunks and would become 6x6, ~2.25x the - * cliff-pass cell work by geometry (not measured). Buying +2 positions for that - * is a trade to make deliberately, with a benchmark, not as a side effect. - * - * The cascade's reach is bounded at **one hop** (`maxDepth` 1, `maxDist` 4 - * tiles = one grid step), so the halo would only ever need to be one chunk - - * that part is not the obstacle. - */ - -const key = (x: number, y: number): string => `${String(x)},${String(y)}`; -const INPUT = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }; -const ctx = withCtxDefaults(INPUT); -const fields = makeVulcanusCliffFields(ctx); -const tileAt = makeVulcanusTileResolver(INPUT); -const resources = buildResources(ctx); -const oreRejects = makeVulcanusOreRejection(resources, ctx.vulcanusResourceControls); -const tileCollides = (x: number, y: number): boolean => - VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name); -const BANDS = { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, -}; -const SHIPPED = { - ...BANDS, - tileCollides, - cellRejects: oreRejects, - rejectAtCrossingStage: true, -}; -const STEP: readonly (readonly [number, number])[] = [ - [0, -CLIFF_GRID_SIZE], - [CLIFF_GRID_SIZE, 0], - [0, CLIFF_GRID_SIZE], - [-CLIFF_GRID_SIZE, 0], -]; - -interface Ent { - x: number; - y: number; - name: string; - orientation: string; -} -interface Region { - x0: number; - y0: number; - x1: number; - y1: number; -} -interface Case { - region: Region; - cliffs: Ent[]; -} -const cases = entities.cases as unknown as Case[]; - -interface Score { - port: number; - matched: number; - surplus: number; - missing: number; - wrongOri: number; -} -interface Row { - at: string; - game: number; - shipped: Score; - cascade: Score; - intra: Score; -} -interface Diff { - kind: string; - x: number; - y: number; - onChunkBorder: boolean; - wasSurplus?: boolean; - shipped?: string; - cascade?: string; - game?: string; -} - -function measure() { - const rows: Row[] = []; - const diffs: Diff[] = []; - let maxDepth = 0; - let maxDist = 0; - for (const c of cases) { - const r = c.region; - const inR = (p: { x: number; y: number }): boolean => - p.x >= r.x0 && p.x < r.x1 && p.y >= r.y0 && p.y < r.y1; - const gameOri = new Map(); - for (const e of c.cliffs) - if (e.name === "cliff-vulcanus" && inR(e)) gameOri.set(key(e.x, e.y), e.orientation); - const game = new Set(gameOri.keys()); - - // --- Model A: what ships today. - const shippedOri = new Map(); - for (const p of makeCliffPlacementFromFields(fields, SHIPPED).placedCells( - r.x0, - r.y0, - r.x1, - r.y1, - )) { - const o = CLIFF_CODE_TO_ORIENTATION[p.code]; - shippedOri.set(key(p.x, p.y), o === undefined ? "?" : CLIFF_ORIENTATION_NAMES[o]); - } - const shipped = new Set(shippedOri.keys()); - - // --- Model B: raw placement, explicit kills, destroy cascade. Halo so a - // cascade entering the region from outside is modelled. - const all = makeCliffPlacementFromFields(fields, BANDS).placedCells( - r.x0 - 64, - r.y0 - 64, - r.x1 + 64, - r.y1 + 64, - ); - const cells = new Map(); - for (const p of all) { - const o = CLIFF_CODE_TO_ORIENTATION[p.code]; - if (o !== undefined) cells.set(key(p.x, p.y), o); - } - const kills: [number, number][] = []; - for (const p of all) { - const o = CLIFF_CODE_TO_ORIENTATION[p.code]; - if (o === undefined) continue; - const code = cliffCodeForOrientation(o); - const box = cliffCollisionTileBox(code, p.x, p.y); - let lava = false; - if (box !== undefined) - for (let tx = box.left; tx <= box.right; tx++) - for (let ty = box.top; ty <= box.bottom; ty++) if (tileCollides(tx, ty)) lava = true; - if (lava || oreRejects(code, p.x, p.y)) kills.push([p.x, p.y]); - } - const destroy = (x: number, y: number, depth = 0, rx = x, ry = y): void => { - maxDepth = Math.max(maxDepth, depth); - maxDist = Math.max(maxDist, Math.max(Math.abs(x - rx), Math.abs(y - ry))); - const mine = cells.get(key(x, y)); - if (mine === undefined) return; - cells.delete(key(x, y)); - for (const side of connectedSides(mine)) { - const st = STEP[side]; - if (st === undefined) continue; - const nx = x + st[0]; - const ny = y + st[1]; - const theirs = cells.get(key(nx, ny)); - if (theirs === undefined) continue; - if (!isCliffConnected(side, mine, theirs)) continue; - const next = destroyEnd(theirs, oppositeSide(side)); - if (next === -1) destroy(nx, ny, depth + 1, rx, ry); - else cells.set(key(nx, ny), next); - } - }; - for (const [x, y] of kills) destroy(x, y); - - // --- Model C: identical, but the cascade may not cross a chunk boundary. - const cells2 = new Map(); - for (const p of all) { - const o = CLIFF_CODE_TO_ORIENTATION[p.code]; - if (o !== undefined) cells2.set(key(p.x, p.y), o); - } - const chOf = (vx: number, vy: number): string => { - const ix = Math.floor((vx - CLIFF_CELL_CENTER_X) / CLIFF_GRID_SIZE / 8); - const iy = Math.floor((vy - CLIFF_CELL_CENTER_Y) / CLIFF_GRID_SIZE / 8); - return `${String(ix)},${String(iy)}`; - }; - const destroy2 = (x: number, y: number): void => { - const mine = cells2.get(key(x, y)); - if (mine === undefined) return; - cells2.delete(key(x, y)); - for (const side of connectedSides(mine)) { - const st = STEP[side]; - if (st === undefined) continue; - const nx = x + st[0]; - const ny = y + st[1]; - if (chOf(nx, ny) !== chOf(x, y)) continue; - const theirs = cells2.get(key(nx, ny)); - if (theirs === undefined) continue; - if (!isCliffConnected(side, mine, theirs)) continue; - const next = destroyEnd(theirs, oppositeSide(side)); - if (next === -1) destroy2(nx, ny); - else cells2.set(key(nx, ny), next); - } - }; - for (const [x, y] of kills) destroy2(x, y); - const intraOri = new Map(); - for (const [k, o] of cells2) { - const p = k.split(","); - if (inR({ x: Number(p[0]), y: Number(p[1]) })) intraOri.set(k, CLIFF_ORIENTATION_NAMES[o]); - } - const intra = new Set(intraOri.keys()); - const cascadeOri = new Map(); - for (const [k, o] of cells) { - const p = k.split(","); - if (inR({ x: Number(p[0]), y: Number(p[1]) })) cascadeOri.set(k, CLIFF_ORIENTATION_NAMES[o]); - } - const cascade = new Set(cascadeOri.keys()); - - const score = (port: Set, ori: Map) => { - const hits = [...port].filter((k) => game.has(k)); - const wrongOri = hits.filter((k) => ori.get(k) !== gameOri.get(k)).length; - return { - port: port.size, - matched: hits.length, - surplus: port.size - hits.length, - missing: [...game].filter((k) => !port.has(k)).length, - wrongOri, - }; - }; - // cells where shipped and cascade disagree - for (const k of shipped) { - if (!cascade.has(k)) { - const q = k.split(","); - diffs.push({ - kind: "removed", - x: Number(q[0]), - y: Number(q[1]), - onChunkBorder: onChunkBorder(Number(q[0]), Number(q[1])), - wasSurplus: !game.has(k), - }); - } - } - for (const k of cascade) { - if (shipped.has(k) && cascadeOri.get(k) !== shippedOri.get(k)) { - const q = k.split(","); - diffs.push({ - kind: "reoriented", - x: Number(q[0]), - y: Number(q[1]), - onChunkBorder: onChunkBorder(Number(q[0]), Number(q[1])), - shipped: shippedOri.get(k), - cascade: cascadeOri.get(k), - game: gameOri.get(k), - }); - } - } - rows.push({ - at: key(r.x0, r.y0), - game: game.size, - shipped: score(shipped, shippedOri), - cascade: score(cascade, cascadeOri), - intra: score(intra, intraOri), - }); - } - const tot = ( - m: "shipped" | "cascade" | "intra", - f: "surplus" | "missing" | "matched" | "port" | "wrongOri", - ): number => rows.reduce((a: number, b: Row) => a + b[m][f], 0); - const summary = { - game: rows.reduce((a: number, b: Row) => a + b.game, 0), - shipped: { - port: tot("shipped", "port"), - matched: tot("shipped", "matched"), - surplus: tot("shipped", "surplus"), - missing: tot("shipped", "missing"), - wrongOri: tot("shipped", "wrongOri"), - }, - intra: { - port: tot("intra", "port"), - matched: tot("intra", "matched"), - surplus: tot("intra", "surplus"), - missing: tot("intra", "missing"), - wrongOri: tot("intra", "wrongOri"), - }, - cascade: { - port: tot("cascade", "port"), - matched: tot("cascade", "matched"), - surplus: tot("cascade", "surplus"), - missing: tot("cascade", "missing"), - wrongOri: tot("cascade", "wrongOri"), - }, - }; - return { summary, reach: { maxDepth, maxDist }, diffs }; -} - -const M = measure(); - -describe("Vulcanus cliffs: the cascade's whole gain is CROSS-CHUNK (#84)", () => { - it("prices the cascade against the model that SHIPS, not a post-filter", () => { - expect(M.summary.game).toBe(1531); - expect(M.summary.shipped).toEqual({ - port: 1547, - matched: 1525, - surplus: 22, - missing: 6, - wrongOri: 21, - }); - expect(M.summary.cascade).toEqual({ - port: 1545, - matched: 1525, - surplus: 20, - missing: 6, - wrongOri: 18, - }); - // +2 positions and +3 orientations - NOT #143's +8, which was scored - // against a post-filter baseline this repo does not ship. - expect(M.summary.shipped.surplus - M.summary.cascade.surplus).toBe(2); - expect(M.summary.shipped.wrongOri - M.summary.cascade.wrongOri).toBe(3); - // Recall is untouched: nothing the game kept is lost. - expect(M.summary.cascade.missing).toBe(M.summary.shipped.missing); - }, 900000); - - it("is byte-identical to the shipped model when forbidden to cross a chunk", () => { - // THE FINDING. Every count, not just the headline one. - expect(M.summary.intra).toEqual(M.summary.shipped); - }, 900000); - - it("reaches exactly one hop, so a one-chunk halo would suffice", () => { - expect(M.reach.maxDepth).toBe(1); - expect(M.reach.maxDist).toBe(4); - }, 900000); - - it("changes only chunk-border cells, and every scoreable change is a correction", () => { - expect(M.diffs).toHaveLength(6); - // Border-EXCLUSIVE, not merely border-enriched. - expect(M.diffs.filter((d) => d.onChunkBorder)).toHaveLength(6); - - const removed = M.diffs.filter((d) => d.kind === "removed"); - expect(removed).toHaveLength(2); - // Both removals took a cell the game does not have. - expect(removed.every((d) => d.wasSurplus === true)).toBe(true); - - const reoriented = M.diffs.filter((d) => d.kind === "reoriented"); - expect(reoriented).toHaveLength(4); - // Of those, the ones the game has an opinion on all move TO its value, and - // none moves away from it. - const scoreable = reoriented.filter((d) => d.game !== undefined); - expect(scoreable).toHaveLength(3); - expect(scoreable.every((d) => d.cascade === d.game)).toBe(true); - expect(scoreable.every((d) => d.shipped !== d.game)).toBe(true); - }, 900000); -}); diff --git a/test/cliffDestroyProbe.spec.ts b/test/cliffDestroyProbe.spec.ts deleted file mode 100644 index 28c5b1b8..00000000 --- a/test/cliffDestroyProbe.spec.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import fixture from "./fixtures/oracle-vulcanus-cliff-destroy-probe.seed123456.json"; -import { CLIFF_GRID_SIZE, CLIFF_ORIENTATION_NAMES } from "../src/noise/cliffs/cliffCatalog"; -import { - connectedSides, - destroyEnd, - isCliffConnected, - onChunkBorder, - oppositeSide, -} from "../src/noise/cliffs/cliffConnections"; - -/** - * **The runtime destroy probe (#127) - `Cliff::onDestroy`'s cascade, observed.** - * - * #127 established that the cliff connection rules cannot be scored from map - * generation output *at all*: the game's output is always connection-consistent, - * so there is never a dangling end for the rules to act on, and both readings of - * the gate predict exactly what the game shows. It named a runtime probe that - * destroys a cliff outside map generation as one of the two kinds of evidence - * that could work; #135 then read all four of the cascade's gates on the Lua - * path and showed such a probe reproduces map generation's cascade. This is it. - * - * A cheaper route was tried first and failed, which is worth recording: - * **#137's chunk-order lever builds arms where a border chunk is applied with - * its neighbour chunk PROVABLY ungenerated** - exactly the gate's input. But on - * the `[1500,1500]` west seam, all five cliffs carrying a west end have a - * neighbour that `isCliffConnected` accepts, so there is still nothing to drop. - * The counterfactual has to be constructed, not found. - * - * **What this settles, and what it does not.** It settles `onDestroy`: the - * cascade is real, it is gated by `do_cliff_correction`, and its effect on a - * neighbour is exactly the port's `destroyEnd`. It does **not** settle - * `updateConnections`, which is not reachable from Lua at all - that gate - * remains unscored. - * - * **`do_cliff_correction` DEFAULTS TO FALSE**, and that fact is why every target - * set is run both ways. A probe calling a bare `destroy()` would have found - * neighbours untouched, and that null reads exactly like "the game does not - * cascade". The OFF arms below are that near-miss, kept as the control: they - * change *nothing* but the targets, in both regions. - * - * **The counts alone would have misled**, which is why this spec asserts - * orientations. A cliff is only *removed* when a trim leaves it with nothing, so - * a removal count measures how many neighbours were single-ended, not where the - * rule runs. On an earlier target set the border arm showed 8 extra removals - * against the interior's 1, which reads like a border-only cascade and is not - - * the changed-orientation counts are 13 and 12, i.e. the interior cascades just - * as hard. - * - * **And the model only matched once the comparison stopped being clamped.** The - * first target set was picked in scan order, so all eight landed on the region's - * top edge, and the port appeared to under-destroy by 7 cliffs. Every one of - * those mismatches was an edge artifact: a cliff at `y = 1498.5` is in the dump - * only through bounding-box overlap, while ITS neighbours at `y = 1494.5` are - * outside the dump entirely, so the game cascades through cliffs the comparison - * cannot see. With targets 48 tiles inside the region the agreement is exact, - * and the earlier "defect" was in the window, not the model. - * - * **There is no unconnected-target arm**, and that is a finding: exactly ONE - * cliff of the 885 has no connected neighbour at all, and it sits outside the - * margin - which is itself a restatement of the connection-consistency #127 - * measured. The correction-OFF arms carry the control role instead. - */ - -interface Cliff { - x: number; - y: number; - name: string; - orientation: string | null; -} -interface Target { - x: number; - y: number; - found: boolean; - orientation: string | null; - destroyed: boolean; -} -interface Case { - label: string; - correction: boolean; - targets: { x: number; y: number }[]; - cliffsBefore: Cliff[]; - cliffsAfter: Cliff[]; - destroyReport: Target[]; -} - -const cases = fixture.cases as unknown as Case[]; -const arm = (label: string): Case => { - const c = cases.find((x) => x.label === label); - if (c === undefined) throw new Error(`no arm ${label}`); - return c; -}; -const ON_ARMS = ["border targets, correction ON", "interior targets, correction ON"]; -const OFF_ARMS = ["border targets, correction OFF", "interior targets, correction OFF"]; - -const oi = (name: string | null): number => - name === null ? -1 : CLIFF_ORIENTATION_NAMES.indexOf(name); -const key = (x: number, y: number): string => `${String(x)},${String(y)}`; -const SIDE_STEP: readonly (readonly [number, number])[] = [ - [0, -CLIFF_GRID_SIZE], - [CLIFF_GRID_SIZE, 0], - [0, CLIFF_GRID_SIZE], - [-CLIFF_GRID_SIZE, 0], -]; - -/** The cells of an arm's before-state, as `pos -> orientation id`. */ -function cellsOf(c: Case): Map { - return new Map(c.cliffsBefore.map((e) => [key(e.x, e.y), oi(e.orientation)])); -} - -/** - * `Cliff::onDestroy`'s cascade, run over the port's own model: destroying a - * cliff calls `destroyEnd(opposite(side))` on every CONNECTED neighbour, and a - * neighbour left with nothing is force-destroyed, which cascades in turn. - */ -function predictDestroy(cells: Map, x: number, y: number): void { - const mine = cells.get(key(x, y)); - if (mine === undefined) return; - cells.delete(key(x, y)); - for (const side of connectedSides(mine)) { - const step = SIDE_STEP[side]; - if (step === undefined) continue; - const nx = x + step[0]; - const ny = y + step[1]; - const theirs = cells.get(key(nx, ny)); - if (theirs === undefined) continue; - if (!isCliffConnected(side, mine, theirs)) continue; - const next = destroyEnd(theirs, oppositeSide(side)); - if (next === -1) predictDestroy(cells, nx, ny); - else cells.set(key(nx, ny), next); - } -} - -/** The arm's after-state as the fixture reports it, in the same shape. */ -function actualOf(c: Case): Map { - return new Map(c.cliffsAfter.map((e) => [key(e.x, e.y), oi(e.orientation)])); -} - -describe("Vulcanus cliffs: the runtime destroy probe (#127, #84)", () => { - it("captures all four arms", () => { - expect(cases).toHaveLength(4); - expect(cases.every((c) => c.cliffsBefore.length === 885)).toBe(true); - }); - - describe("the probe actually did what it claims - without these, every null is empty", () => { - it("FOUND and DESTROYED every target in every arm", () => { - // This is not bookkeeping. The first run of this probe found only 4 of 8 - // targets: `find_entities_filtered{area}` selects on the BOUNDING BOX, - // and a cliff's box is the per-orientation `rotbb` rectangle, which is - // offset from the cell centre and need not contain it. A lookup miss and - // a cascade removal are the same observation without this assertion. - for (const c of cases) { - expect( - c.destroyReport.every((r) => r.found), - `${c.label}: all found`, - ).toBe(true); - expect( - c.destroyReport.every((r) => r.destroyed), - `${c.label}: all destroyed`, - ).toBe(true); - expect(c.destroyReport).toHaveLength(c.targets.length); - } - }); - - it("targets are where the capture says they are - re-derived from the PORT", () => { - // The capture picks targets with predicates re-derived inside - // `capture.ts` (it runs under bare Node and cannot import - // `cliffConnections`). These assertions are what stop that duplicate from - // drifting: the real functions must agree with the selection. - const before = cellsOf(arm(ON_ARMS[0])); - const connectedCount = (x: number, y: number): number => { - const mine = before.get(key(x, y)); - if (mine === undefined) return 0; - return connectedSides(mine).filter((side) => { - const step = SIDE_STEP[side]; - if (step === undefined) return false; - const theirs = before.get(key(x + step[0], y + step[1])); - return theirs !== undefined && isCliffConnected(side, mine, theirs); - }).length; - }; - for (const t of arm(ON_ARMS[0]).targets) { - expect(onChunkBorder(t.x, t.y), `${key(t.x, t.y)} on border`).toBe(true); - expect(connectedCount(t.x, t.y)).toBeGreaterThan(0); - } - for (const t of arm(ON_ARMS[1]).targets) { - expect(onChunkBorder(t.x, t.y), `${key(t.x, t.y)} interior`).toBe(false); - expect(connectedCount(t.x, t.y)).toBeGreaterThan(0); - } - // The population that would have made an unconnected-target arm: one - // cliff in 885. Pinned so that "there was no control" stays a measured - // statement rather than an omission. - expect((fixture as { unconnectedCliffsRegionWide: number }).unconnectedCliffsRegionWide).toBe( - 1, - ); - }); - - it("the ON arms MOVED something - a passing model over a still world proves nothing", () => { - for (const label of ON_ARMS) { - const c = arm(label); - const before = cellsOf(c); - const after = actualOf(c); - const changed = [...after].filter(([k, o]) => before.has(k) && before.get(k) !== o); - expect(changed.length, `${label}: changed orientations`).toBeGreaterThan(0); - } - }); - }); - - describe("do_cliff_correction gates the cascade COMPLETELY", () => { - it("with it OFF, nothing but the targets changes - the near-miss null", () => { - for (const label of OFF_ARMS) { - const c = arm(label); - const before = cellsOf(c); - const after = actualOf(c); - const targets = new Set(c.targets.map((t) => key(t.x, t.y))); - const gone = [...before.keys()].filter((k) => !after.has(k)); - expect(new Set(gone), `${label}: only the targets are gone`).toEqual(targets); - const changed = [...after].filter(([k, o]) => before.get(k) !== o); - expect(changed, `${label}: no neighbour was touched`).toEqual([]); - } - }); - - it("is the ONLY difference between the paired arms - same targets, same world", () => { - for (const [on, off] of [ - [ON_ARMS[0], OFF_ARMS[0]], - [ON_ARMS[1], OFF_ARMS[1]], - ]) { - expect(arm(on).targets).toEqual(arm(off).targets); - expect(arm(on).cliffsBefore).toEqual(arm(off).cliffsBefore); - } - }); - }); - - describe("the cascade IS the port's `destroyEnd`, cell for cell", () => { - it.each(ON_ARMS)("reproduces %s exactly", (label) => { - const c = arm(label); - const cells = cellsOf(c); - for (const t of c.targets) predictDestroy(cells, t.x, t.y); - const actual = actualOf(c); - const norm = (m: Map): [string, number][] => - [...m].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); - expect(norm(cells)).toEqual(norm(actual)); - }); - - it("and it is NOT border-only - the interior cascades just as hard", () => { - // The removal counts say 8 extra at the border against 1 in the interior, - // which reads like a border-only rule. The orientation counts say - // otherwise, and they are the ones that measure where the rule runs. - const changedIn = (label: string): number => { - const before = cellsOf(arm(label)); - const after = actualOf(arm(label)); - return [...after].filter(([k, o]) => before.has(k) && before.get(k) !== o).length; - }; - expect(changedIn(ON_ARMS[0])).toBeGreaterThan(5); - expect(changedIn(ON_ARMS[1])).toBeGreaterThan(5); - }); - - it("removes MORE than the targets, so the exact match is not a trivial one", () => { - // Both ON arms cascade past their own targets - 9 and 10 removals for 8 - // destroys. Without this, "the model reproduces the game" could be true - // of a world where the cascade never reached anything. - for (const label of ON_ARMS) { - const c = arm(label); - expect(c.cliffsBefore.length - c.cliffsAfter.length).toBeGreaterThan(c.targets.length); - } - }); - }); -}); diff --git a/test/cliffDestructionResidual.spec.ts b/test/cliffDestructionResidual.spec.ts deleted file mode 100644 index a5db8a01..00000000 --- a/test/cliffDestructionResidual.spec.ts +++ /dev/null @@ -1,378 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import entities from "./fixtures/oracle-vulcanus-cliff-entities.seed123456.json"; -import { - CLIFF_CODE_TO_ORIENTATION, - CLIFF_ORIENTATION_NAMES, - cliffCollisionTileBox, -} from "../src/noise/cliffs/cliffCatalog"; -import { - applyCliffConnections, - cliffCodeForOrientation, -} from "../src/noise/cliffs/cliffConnections"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { makeVulcanusOreRejection } from "../src/noise/cliffs/vulcanusOreRejection"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; -import { buildResources } from "../src/noise/preview/renderVulcanusResources"; -import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -/** - * **The whole Vulcanus cliff residual is 31 DESTRUCTION DECISIONS, and nothing - * else** (#84). - * - * #113 read `EntityMapGenerationTask::applyCliffs`, the consumer of the queue - * `generateCliffs` fills, and showed at `[1500,1500]` with neither ore nor lava - * in the world that destroying the 12 cells the game lacks reproduces the game's - * set exactly - 1058 of 1058, positions AND orientations. It left one question - * open and named it as unmeasured: are the 5 surviving wrong orientations the - * cascade fallout of the 5 false rejections, or a separate defect? - * - * They are the fallout. This file measures it the direct way - hand the port the - * game's OWN destruction set in place of our lava and ore predicates, over all - * three oracle regions and with the real ore and lava world - and the answer is - * **1531 of 1531, zero wrong, zero surplus, zero missing**. - * - * So there is no orientation defect, no crossing defect, no field defect and no - * missing suppressor left anywhere in the Vulcanus cliff port. Every disagreement - * with the game is one of 31 cells where `Surface::wouldCollide` and our stand-in - * for it return different booleans. - * - * **What is fitted and what is predicted**, because that is the whole weight of - * the result. FITTED: the 225-cell destruction set, chosen as the raw cells the - * game lacks - 225 booleans. PREDICTED: all 1531 orientations, including the 14 - * that the cascade actively rewrites (each with 19 ways to be wrong, see the - * no-cascade control); that the cascade destroys no cell beyond the 225, which - * would have shown as `missing`; and that the answer does not depend on what the - * halo outside the region does. - */ - -const INPUT = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }; -const ctx = withCtxDefaults(INPUT); -const fields = makeVulcanusCliffFields(ctx); -const tileAt = makeVulcanusTileResolver(INPUT); -const oreRejects = makeVulcanusOreRejection(buildResources(ctx), ctx.vulcanusResourceControls); -const tileCollides = (x: number, y: number): boolean => - VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name); -const nameToId = new Map(CLIFF_ORIENTATION_NAMES.map((n, i) => [n, i])); -const K = (x: number, y: number): string => `${String(x)},${String(y)}`; - -const BANDS = { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, -}; - -/** The tile half of `Surface::wouldCollide` as the port models it. */ -const lavaCollides = (orientation: number, x: number, y: number): boolean => { - const box = cliffCollisionTileBox(cliffCodeForOrientation(orientation), x, y); - if (box === undefined) return false; - for (let tx = box.left; tx <= box.right; tx++) - for (let ty = box.top; ty <= box.bottom; ty++) if (tileCollides(tx, ty)) return true; - return false; -}; -const oreCollides = (orientation: number, x: number, y: number): boolean => - oreRejects(cliffCodeForOrientation(orientation), x, y); -const lavaAndOre = (orientation: number, x: number, y: number): boolean => - lavaCollides(orientation, x, y) || oreCollides(orientation, x, y); - -interface Ent { - x: number; - y: number; - name: string; - orientation?: string; -} -interface Region { - x0: number; - y0: number; - x1: number; - y1: number; -} -interface Score { - matched: number; - wrong: number; - surplus: number; - missing: number; -} - -const cases = entities.cases as unknown as { region: Region; cliffs: Ent[] }[]; - -const gameSet = (i: number): Map => { - const r = cases[i].region; - const m = new Map(); - for (const e of cases[i].cliffs) { - if (e.name !== "cliff-vulcanus") continue; - if (e.x < r.x0 || e.x >= r.x1 || e.y < r.y0 || e.y >= r.y1) continue; - const id = nameToId.get(e.orientation ?? ""); - if (id !== undefined) m.set(K(e.x, e.y), id); - } - return m; -}; -const GAME = cases.map((_, i) => gameSet(i)); - -const inRegion = - (i: number) => - (p: { x: number; y: number }): boolean => { - const r = cases[i].region; - return p.x >= r.x0 && p.x < r.x1 && p.y >= r.y0 && p.y < r.y1; - }; - -/** - * The un-rejected placed set with a 64-tile halo, per region, computed once. - * This is `generateCliffs`' queue - crossings and the repair pass, no rejection - * of any kind - which is what `applyCliffs` is handed. - */ -const RAW = cases.map((c) => - makeCliffPlacementFromFields(fields, BANDS).placedCells( - c.region.x0 - 64, - c.region.y0 - 64, - c.region.x1 + 64, - c.region.y1 + 64, - ), -); - -const score = (port: Map, game: Map): Score => { - const s: Score = { matched: 0, wrong: 0, surplus: 0, missing: 0 }; - for (const [k, id] of port) { - const t = game.get(k); - if (t === undefined) s.surplus++; - else if (t === id) s.matched++; - else s.wrong++; - } - for (const k of game.keys()) if (!port.has(k)) s.missing++; - return s; -}; - -type Collides = (orientation: number, x: number, y: number) => boolean; - -/** Score one `collides` model over all three regions and sum. */ -const runAll = (make: (i: number) => Collides, noCascade = false): Score => { - const total: Score = { matched: 0, wrong: 0, surplus: 0, missing: 0 }; - for (let i = 0; i < cases.length; i++) { - const out = applyCliffConnections(RAW[i], { collides: make(i), noCascade }); - const port = new Map(out.filter(inRegion(i)).map((p) => [K(p.x, p.y), p.orientation] as const)); - const s = score(port, GAME[i]); - total.matched += s.matched; - total.wrong += s.wrong; - total.surplus += s.surplus; - total.missing += s.missing; - } - return total; -}; - -/** The game's own destruction set: a raw cell inside the region the game lacks. */ -const oracleKill = - (i: number): Collides => - (_o, x, y) => - inRegion(i)({ x, y }) && !GAME[i].has(K(x, y)); - -describe("the game's own destruction set reproduces the game EXACTLY", () => { - /** - * **The result.** Replace our lava and ore predicates with the game's own - * answer - destroy the raw cells the game does not have - and the `applyCliffs` - * model reproduces every one of the 1531 game cliffs across the three oracle - * regions, orientations included. - * - * The 225 destruction booleans are the only fitted quantity. Every orientation - * is predicted, and so is `missing: 0`: the cascade is free to run past the 225 - * and destroy a cell the game keeps, and it does not. - */ - it("gives 1531 of 1531, positions and orientations, over all three regions", () => { - expect(runAll(oracleKill)).toEqual({ matched: 1531, wrong: 0, surplus: 0, missing: 0 }); - }, 300000); - - /** - * **The halo does not decide it.** The arm above destroys nothing outside the - * region, because no fixture says what the game did there; running our own - * lava + ore predicate out there instead gives the identical answer. Without - * this, "exact" could be an artifact of a quiet halo. - */ - it("is unchanged when the halo runs our own predicate instead", () => { - const mixed = - (i: number): Collides => - (o, x, y) => - inRegion(i)({ x, y }) ? !GAME[i].has(K(x, y)) : lavaAndOre(o, x, y); - expect(runAll(mixed)).toEqual({ matched: 1531, wrong: 0, surplus: 0, missing: 0 }); - }, 300000); - - /** - * **The control that makes it mean something.** Destroy exactly the same 225 - * cells without telling their neighbours and 14 orientations come out wrong - - * so the exact result above is the `Cliff::onDestroy` cascade predicting 14 - * rewrites, not an artifact of deleting the cells the game happens to lack. - * Each of the 14 had 19 other orientations available to be wrong with. - */ - it("leaves 14 wrong orientations when the cascade is switched off", () => { - expect(runAll(oracleKill, true)).toEqual({ - matched: 1517, - wrong: 14, - surplus: 0, - missing: 0, - }); - }, 300000); - - /** - * A prerequisite of all of the above, and a fact in its own right: the port's - * un-rejected cell set is a strict SUPERSET of the game's in every region, not - * just at `[1500,1500]` where #111 measured it with the levers. 1756 raw cells - * contain all 1531 the game keeps, so nothing the port must explain is a - * failure to GENERATE a cliff - it is all over-generation. - */ - it("confirms the raw set is a strict superset in all three regions", () => { - const raw = cases.map((_, i) => RAW[i].filter(inRegion(i))); - expect(raw.map((r) => r.length)).toEqual([292, 1070, 394]); - expect(GAME.map((g) => g.size)).toEqual([283, 861, 387]); - for (let i = 0; i < cases.length; i++) { - const have = new Set(raw[i].map((p) => K(p.x, p.y))); - expect([...GAME[i].keys()].filter((k) => !have.has(k))).toEqual([]); - } - }); -}); - -/** - * **The 18 wrong orientations split cleanly into the two destruction errors**, - * which is the question #113 left open. - * - * | `collides` | matched | wrong | surplus | missing | - * | --- | --- | --- | --- | --- | - * | lava + ore (what #113 scored) | 1508 | 18 | 22 | 5 | - * | ...plus the 22 the game destroys | 1521 | **5** | 0 | 5 | - * | ...instead sparing the cells the game keeps | 1517 | 14 | 22 | 0 | - * | the game's set (both fixed) | **1531** | **0** | **0** | **0** | - * - * Row 2 is #113's prediction confirmed: closing the surplus takes `wrong` from 18 - * to 5. Row 3 is the other half - sparing the false rejections alone takes it to - * 14. Neither error is a separate orientation defect; each carries its own - * cascade fallout, and fixing both leaves nothing. - */ -describe("each residual orientation is the fallout of a destruction error", () => { - it("closes the surplus and 13 of the 18 wrong go with it", () => { - const alsoKillSurplus = - (i: number): Collides => - (o, x, y) => - lavaAndOre(o, x, y) || (inRegion(i)({ x, y }) && !GAME[i].has(K(x, y))); - expect(runAll(() => lavaAndOre)).toEqual({ - matched: 1508, - wrong: 18, - surplus: 22, - missing: 5, - }); - expect(runAll(alsoKillSurplus)).toEqual({ matched: 1521, wrong: 5, surplus: 0, missing: 5 }); - }, 300000); - - it("spares the false rejections and the other 4 go with them", () => { - const spareGameCells = - (i: number): Collides => - (o, x, y) => - lavaAndOre(o, x, y) && !(inRegion(i)({ x, y }) && GAME[i].has(K(x, y))); - expect(runAll(spareGameCells)).toEqual({ - matched: 1517, - wrong: 14, - surplus: 22, - missing: 0, - }); - }, 300000); -}); - -/** - * **The target, restated as the predicate's own confusion matrix.** - * - * Scoring `lava + ore` against the game's destruction set directly - one boolean - * per raw cell, before any cascade - is a sharper measurement than the - * surplus/missing counts, because those are outcomes that the cascade has already - * blurred. 6 false rejections become 5 `missing` (one of the six is destroyed by - * a neighbour's cascade before its own test runs) and 25 missed destructions - * become 22 `surplus` (three are cascade casualties anyway). - * - * | | count | - * | --- | --- | - * | raw cells inside the three regions | 1756 | - * | the game destroys | 225 | - * | we destroy | 206 | - * | agree | 200 | - * | we destroy, the game keeps | **6** | - * | the game destroys, we keep | **25** | - * - * So the predicate is **precision 200/206 = 0.971, recall 200/225 = 0.889**, and - * the errors point BOTH WAYS - which is #111's finding restated on the stage that - * actually does the rejecting, and is still what rules out any uniform dilation - * or shrink of the collision box. #88 stands: do not tune the box until it fits. - */ -describe("the destruction predicate, scored on its own terms", () => { - interface Split { - key: string; - orientation: number; - lava: boolean; - ore: boolean; - } - const audit = (): { agree: number; ours: number; theirs: number; wrongWay: Split[] } => { - let agree = 0; - let ours = 0; - let theirs = 0; - const wrongWay: Split[] = []; - for (let i = 0; i < cases.length; i++) { - for (const c of RAW[i].filter(inRegion(i))) { - const o = CLIFF_CODE_TO_ORIENTATION[c.code]; - if (o === undefined) continue; - const k = K(c.x, c.y); - const gameKill = !GAME[i].has(k); - const lava = lavaCollides(o, c.x, c.y); - const ore = oreCollides(o, c.x, c.y); - const ourKill = lava || ore; - if (gameKill) theirs++; - if (ourKill) ours++; - if (gameKill && ourKill) agree++; - else if (gameKill !== ourKill) wrongWay.push({ key: k, orientation: o, lava, ore }); - } - } - return { agree, ours, theirs, wrongWay }; - }; - - it("pins the confusion matrix", () => { - const a = audit(); - const total = cases.reduce((n, _, i) => n + RAW[i].filter(inRegion(i)).length, 0); - expect(total).toBe(1756); - expect(a.theirs).toBe(225); - expect(a.ours).toBe(206); - expect(a.agree).toBe(200); - expect(a.wrongWay.filter((w) => w.lava || w.ore).length).toBe(6); - expect(a.wrongWay.filter((w) => !w.lava && !w.ore).length).toBe(25); - // Errors both ways, which is what forbids a one-parameter fix. - expect(a.ours - a.agree).toBe(6); - expect(a.theirs - a.agree).toBe(25); - }, 300000); - - /** - * **Every false rejection is the LAVA box; the ore rule invents none.** - * - * That narrows the two-sided error set to one predicate. The ore rule is a - * clean subset here - it never destroys a cell the game keeps - so the six - * cells the port removes wrongly are all the tile half of - * `Surface::wouldCollide`, and the shape being wrong is the lava box's shape. - * - * The positions are listed because they are the input to the next measurement: - * `Surface::wouldCollide` runs `constCollideWithTile` against the REAL surface, - * while this test resolves tiles from our own Vulcanus tile model. A - * disagreement between the two inside these boxes would produce exactly this - * two-sided error set, and it has not been checked. - */ - it("finds all 6 false rejections are lava, none of them ore", () => { - const wrong = audit().wrongWay.filter((w) => w.lava || w.ore); - expect(wrong.filter((w) => w.lava).length).toBe(6); - expect(wrong.filter((w) => w.ore).length).toBe(0); - expect(wrong.map((w) => w.key).sort((a, b) => a.localeCompare(b))).toEqual([ - "-1054,1018.5", - "1638,1598.5", - "1638,1602.5", - "1662,1634.5", - "22,178.5", - "86,38.5", - ]); - }, 300000); -}); diff --git a/test/cliffErrorBudget.spec.ts b/test/cliffErrorBudget.spec.ts deleted file mode 100644 index 693315fe..00000000 --- a/test/cliffErrorBudget.spec.ts +++ /dev/null @@ -1,360 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import entities from "./fixtures/oracle-vulcanus-cliff-entities.seed123456.json"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - CLIFF_CELL_CENTER_X, - CLIFF_CELL_CENTER_Y, - CLIFF_GRID_SIZE, -} from "../src/noise/cliffs/cliffCatalog"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { - VULCANUS_CLIFF_BASE_COLLISION_BOX, - makeVulcanusOreRejection, -} from "../src/noise/cliffs/vulcanusOreRejection"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; -import { buildResources } from "../src/noise/preview/renderVulcanusResources"; -import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -const key = (x: number, y: number): string => `${String(x)},${String(y)}`; - -interface Ent { - x: number; - y: number; - name: string; -} -interface Region { - x0: number; - y0: number; - x1: number; - y1: number; -} -interface Case { - region: Region; - cliffs: Ent[]; -} - -const INPUT = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }; -const ctx = withCtxDefaults(INPUT); -const fields = makeVulcanusCliffFields(ctx); -const tileAt = makeVulcanusTileResolver(INPUT); -const resources = buildResources(ctx); -const tileCollides = (x: number, y: number): boolean => - VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name); -const BANDS = { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, -}; -/** - * **This must mirror `renderVulcanusCliffs.ts` exactly, or the budget below - * describes a model the app does not run.** It drifted once already: - * `rejectAtCrossingStage` landed in the renderer with #108 and was not added - * here, so for a day this file pinned 25 surplus and precision 0.9839 while the - * shipping path was at 22 and 0.9858 - a spec named SHIPPED measuring something - * else. If a flag is added to the renderer's call, add it here in the same - * change. - */ -const SHIPPED = { - ...BANDS, - tileCollides, - cellRejects: makeVulcanusOreRejection(resources, ctx.vulcanusResourceControls), - rejectAtCrossingStage: true, -}; - -const cases = entities.cases as unknown as Case[]; -const allCliffs = (c: Case): Ent[] => c.cliffs.filter((e) => e.name === "cliff-vulcanus"); -const inBox = (c: Case): Ent[] => - allCliffs(c).filter( - (p) => p.x >= c.region.x0 && p.x < c.region.x1 && p.y >= c.region.y0 && p.y < c.region.y1, - ); -const placed = ( - r: Region, - bands: Parameters[1], - pad = 0, -): Set => - new Set( - makeCliffPlacementFromFields(fields, bands) - .placedCells(r.x0 - pad, r.y0 - pad, r.x1 + pad, r.y1 + pad) - .map((p) => key(p.x, p.y)), - ); - -/** - * **The recall gap was a comparison artifact, and it is worth reading how it - * hid.** - * - * `find_entities_filtered` returns every entity whose BOUNDING BOX touches the - * query area; `placedCells` emits every cell whose CENTRE lies inside it. Those - * are different inclusion rules, so the game's list carries cliffs centred just - * outside the box that the port was never asked about - and scoring one against - * the other counts each of them as a miss. - * - * It is worth 38 cells, which is the entire apparent recall gap: - * - * | region | game rows | centred inside | centred OUTSIDE | - * | --- | --- | --- | --- | - * | `[0,0]` | 283 | 283 | **0** | - * | `[1500,1500]` | 885 | 861 | **24** | - * | `[-1200,800]` | 401 | 387 | **14** | - * - * And the port places **38 of 38** of them once the query box is widened enough - * to include their centres - so every one is an agreement being scored as a - * failure. - * - * This is the same failure as #86, where a 187-cell "excess" turned out to be - * 185 cells of a rule only one side was applying. Before believing a gap, - * check both sides are being asked the same question. - */ -describe("the apparent recall gap is a query-window artifact", () => { - it("finds 38 game cliffs centred outside the box they were captured for", () => { - const outside = cases.map((c) => allCliffs(c).length - inBox(c).length); - expect(outside).toEqual([0, 24, 14]); - expect(outside.reduce((a, b) => a + b, 0)).toBe(38); - }); - - /** - * **The decisive arm.** Widening the query so those centres ARE asked about - * places every one of them. Without this the finding would only be "we never - * looked there", which is consistent with the port being wrong as well as with - * it being right. - */ - it("places 38 of 38 once the query box includes their centres", () => { - let found = 0; - let total = 0; - for (const c of cases) { - const inside = new Set(inBox(c).map((p) => key(p.x, p.y))); - const outside = allCliffs(c).filter((p) => !inside.has(key(p.x, p.y))); - const wide = placed(c.region, SHIPPED, 8); - total += outside.length; - found += outside.filter((p) => wide.has(key(p.x, p.y))).length; - } - expect(total).toBe(38); - expect(found).toBe(38); - }, 120000); -}); - -/** - * **The corrected budget.** Scored with both sides on the same inclusion rule: - * the game set restricted to cliffs centred in the box, against the pipeline the - * renderer actually runs (lava rejection + ore rejection). - * - * | region | game | port | matched | surplus | missing | - * | --- | --- | --- | --- | --- | --- | - * | `[0,0]` | 283 | 283 | 281 | 2 | 2 | - * | `[1500,1500]` | 861 | 877 | 858 | 19 | 3 | - * | `[-1200,800]` | 387 | 387 | 386 | 1 | 1 | - * | **total** | **1531** | **1547** | **1525** | **22** | **6** | - * - * **Recall 0.9961, precision 0.9858.** The long-standing 0.972 recall figure in - * `vulcanus-cliffs-NOTES.md` was this artifact: it divided the same 1525 matches - * by 1569 rather than 1531. - * - * `matched` here is a POSITION match: 21 of those 1525 carry the wrong - * orientation, so scored on position and orientation together the same three - * regions give recall 0.9824 and precision 0.9722. Both numbers are worth - * knowing and they answer different questions - this file scores positions, - * because that is what the surplus/missing split is about. - */ -describe("the remaining error budget, both sides scored alike", () => { - interface Budget { - at: string; - game: number; - port: number; - matched: number; - surplus: number; - missing: number; - lavaKilled: number; - oreKilled: number; - } - - const budget = (c: Case): Budget => { - const game = new Set(inBox(c).map((p) => key(p.x, p.y))); - const raw = placed(c.region, BANDS); - const lava = placed(c.region, { ...BANDS, tileCollides }); - const full = placed(c.region, SHIPPED); - const missing = [...game].filter((k) => !full.has(k)); - const matched = [...full].filter((k) => game.has(k)).length; - return { - at: key(c.region.x0, c.region.y0), - game: game.size, - port: full.size, - matched, - surplus: full.size - matched, - missing: missing.length, - lavaKilled: missing.filter((k) => raw.has(k) && !lava.has(k)).length, - oreKilled: missing.filter((k) => lava.has(k) && !full.has(k)).length, - }; - }; - - it("pins the corrected composition", () => { - const budgets = cases.map(budget); - expect(budgets.map((b) => [b.at, b.game, b.port, b.surplus, b.missing])).toEqual([ - ["0,0", 283, 283, 2, 2], - ["1500,1500", 861, 877, 19, 3], - ["-1200,800", 387, 387, 1, 1], - ]); - - const sum = (f: (b: Budget) => number): number => budgets.reduce((a, b) => a + f(b), 0); - expect(sum((b) => b.matched)).toBe(1525); - expect(sum((b) => b.game)).toBe(1531); - expect(sum((b) => b.port)).toBe(1547); - expect(sum((b) => b.surplus)).toBe(22); - expect(sum((b) => b.missing)).toBe(6); - - // Every missing cell is one OUR OWN lava rejection removed. There is no - // cell left that the port simply fails to generate. - expect(sum((b) => b.lavaKilled)).toBe(6); - expect(sum((b) => b.oreKilled)).toBe(0); - - expect(sum((b) => b.matched) / sum((b) => b.game)).toBeCloseTo(0.9961, 4); - expect(sum((b) => b.matched) / sum((b) => b.port)).toBeCloseTo(0.9858, 4); - }, 120000); - - /** - * **So precision is the remaining defect, not recall** - 22 surplus cells - * against 6 missing, and #111 identified all 6: three are our lava rejection - * firing where the game's does not, measured against the lava lever rather - * than inferred. - * - * The guard is `x3` rather than the `x4` it was at 25 surplus. That is not a - * weakened test - the gap narrowed because #108's crossing stage removed 3 - * surplus cells and no missing ones, which is the improvement working. Do not - * raise it back without re-measuring. - */ - it("leaves precision as the dominant defect", () => { - const budgets = cases.map(budget); - const surplus = budgets.reduce((a, b) => a + b.surplus, 0); - const missing = budgets.reduce((a, b) => a + b.missing, 0); - expect(surplus).toBeGreaterThan(missing * 3); - }, 120000); -}); - -/** - * **Item 3 of #84 - the entity half of `Surface::wouldCollide` - is CLOSED, and - * these two arms are how it looked on the way there.** - * - * The history is worth keeping because the item was closed twice on bad grounds - * before it was closed on a measurement. An early draft closed it by size (a - * rejection only removes cells, so it could not help a 44-cell recall gap); that - * reasoning died when the gap turned out to be the query-window artifact above, - * and the item re-opened as the leading candidate. The arms below then closed - * the crater half exactly and argued the rock half down from the mechanism's own - * geometry. - * - * **#111 settled it outright with a lever instead.** Switching the whole - * `entity` autoplace category off through `map_gen_settings.autoplace_settings` - * removes 409 rocks, 115 chimneys and all 8 craters from `[1500,1500]`, and the - * game's cliff set does not move by one cell - so no placed entity suppresses a - * Vulcanus cliff, the whole class at once. See - * `test/vulcanusCliffSuppressorLevers.spec.ts`. - * - * These arms are kept rather than deleted because they are independent - * corroboration from a different direction, and because the reasoning they - * document - closing a suspect on the geometry it is confined to rather than on - * a score - is the part worth reusing. The one thing NOT to reuse is the - * overlap-count statistic in the rock arm's comment: it was measured at 25 - * surplus cells, before #108 removed 3 of them, and has not been re-measured - * under the shipping config. The chunk-border arm below is re-measured and does - * assert. - */ -describe("the entity collision half: craters are worth zero, rocks are refuted", () => { - it("finds no crater touching any cell the port over-places", () => { - const [l, t, r, b] = VULCANUS_CLIFF_BASE_COLLISION_BOX; - let craters = 0; - let touching = 0; - - for (const c of cases) { - const game = new Set(inBox(c).map((p) => key(p.x, p.y))); - const cr = c.cliffs.filter((e) => e.name === "crater-cliff"); - craters += cr.length; - for (const k of [...placed(c.region, SHIPPED)].filter((s) => !game.has(s))) { - const [xs, ys] = k.split(","); - const cx = Number(xs); - const cy = Number(ys); - // `crater-cliff` carries the same box as `cliff-vulcanus` in the fixture - // protos, so two of them overlap within the summed half-extents. - if (cr.some((q) => Math.abs(q.x - cx) < r - l && Math.abs(q.y - cy) < b - t)) touching++; - } - } - - // Non-vacuity: there really are craters to have found. - expect(craters).toBe(8); - expect(touching).toBe(0); - }, 120000); - - it("records that no fixture carries the rock entities the arm needs", () => { - const names = new Set(cases.flatMap((c) => c.cliffs.map((e) => e.name))); - expect([...names].sort()).toEqual(["cliff-vulcanus", "crater-cliff"]); - }); - - /** - * **The rock arm fails a test that does not depend on our rock model at all.** - * - * `computeInternal` runs `generateCliffs` before `generateEntities`, and - * `apply` runs `applyCliffs` (`+124`) before `applyEntities` (`+164`) - so - * within a chunk no rock exists when the cliff is applied. A rock can only - * ever block a cliff from an ALREADY-GENERATED NEIGHBOUR, which confines the - * whole mechanism to cells near a 32-tile chunk border. - * - * The port's surplus cells sit near a chunk border at **44.0%**, against - * **44.1%** for the cells it gets right. That is the base rate to three - * significant figures: the surplus has no chunk-border character whatever, so - * the one geometry this mechanism is confined to is not where the errors are. - * - * A direct overlap test agreed and was the weaker arm, which is why it was not - * leaned on: at the time, 3 of 25 surplus cells overlapped a modelled rock - * against a 6.6% base rate, i.e. ~1.7 expected - nothing, and our rock - * placement is a salt-dependent roll, so individual positions are unreliable - * exactly as the geyser's were in #100. That count is NOT re-measured here; - * treat it as history, not as a current figure. - * - * **So item 3 explains approximately none of the surplus**, argued from the - * mechanism's own geometry rather than from the ceiling argument that died - * with the recall gap, and without needing a rock capture. #111 then confirmed - * it directly by removing every rock from the game. - */ - it("finds no chunk-border character in the surplus, which is where rocks must act", () => { - const nearBorder = (x: number, y: number): boolean => { - const cx = (x - CLIFF_CELL_CENTER_X) / CLIFF_GRID_SIZE; - const cy = (y - CLIFF_CELL_CENTER_Y) / CLIFF_GRID_SIZE; - const ix = ((cx % 8) + 8) % 8; - const iy = ((cy % 8) + 8) % 8; - return ix === 0 || ix === 7 || iy === 0 || iy === 7; - }; - - let surplus = 0; - let surplusBorder = 0; - let matched = 0; - let matchedBorder = 0; - for (const c of cases) { - const game = new Set(inBox(c).map((p) => key(p.x, p.y))); - for (const k of placed(c.region, SHIPPED)) { - const [xs, ys] = k.split(","); - const border = nearBorder(Number(xs), Number(ys)); - if (game.has(k)) { - matched++; - if (border) matchedBorder++; - } else { - surplus++; - if (border) surplusBorder++; - } - } - } - - expect(surplus).toBe(22); - expect(matched).toBe(1525); - // Within a percentage point of each other - no enrichment at all. - const sRate = surplusBorder / surplus; - const mRate = matchedBorder / matched; - expect(Math.abs(sRate - mRate)).toBeLessThan(0.02); - // Non-vacuity: "near a border" is a real subset, not everything or nothing. - expect(mRate).toBeGreaterThan(0.3); - expect(mRate).toBeLessThan(0.6); - }, 120000); -}); diff --git a/test/cliffFarTenProvenance.spec.ts b/test/cliffFarTenProvenance.spec.ts deleted file mode 100644 index b3bb9eae..00000000 --- a/test/cliffFarTenProvenance.spec.ts +++ /dev/null @@ -1,495 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import entities from "./fixtures/oracle-vulcanus-cliff-entities.seed123456.json"; -import { - CLIFF_CODE_TO_ORIENTATION, - CLIFF_ORIENTATION_NAMES, - cliffCollisionTileBox, -} from "../src/noise/cliffs/cliffCatalog"; -import { - CLIFF_ORIENTATION_ENDS, - applyCliffConnections, - cliffCodeForOrientation, - connectedSides, - onChunkBorder, - oppositeSide, -} from "../src/noise/cliffs/cliffConnections"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { makeVulcanusOreRejection } from "../src/noise/cliffs/vulcanusOreRejection"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; -import { buildResources } from "../src/noise/preview/renderVulcanusResources"; -import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -/** - * **The far ten are DESTROYED cliffs, not cliffs the game never queued** (#84). - * - * `test/cliffDestructionResidual.spec.ts` (#114) reduced the whole Vulcanus cliff - * residual to 31 cells where `Surface::wouldCollide` and our stand-in disagree, - * and `test/cliffCollisionResidualShape.spec.ts` (#115) split the 25 missed - * destructions into a near group that a lava box could plausibly reach and a far - * group of ten with no lava within twelve tiles. #115's handoff named the first - * thread to pull and named it unmeasured: - * - * > The whole `applyCliffs` framing assumes destruction. #114's exact result is - * > consistent with destruction but does not prove it - a strict superset says - * > nothing about whether the game's crossing field emitted them at all. - * - * It is measurable, from a fixture already on disk, because the two hypotheses - * leave **different marks on the neighbours** - and `cliffConnections.ts` already - * carries both rules off the disassembly: - * - * | hypothesis | what happens to a connected neighbour | - * | --- | --- | - * | **destroyed** by `applyCliffs` | `Cliff::onDestroy` calls `destroyEnd(opposite(side))` on it - **unconditionally** | - * | **never queued** by `generateCliffs` | the cell is simply absent; the neighbour loses its end only if `updateConnections` runs on it, and that is gated on the neighbour sitting on its chunk's OUTER RING | - * - * So a disputed cell is **decidable** whenever it has a neighbour that is (a) in - * the game's kept set, so the fixture records its orientation, (b) **not** on a - * chunk border, so `updateConnections` cannot trim it either way, and (c) queued - * with an end facing the disputed cell, so there is an end to lose. Then the - * game's own recorded orientation settles it: end **gone** means the cascade ran, - * which only destruction can do; end **still dangling** would mean the cell was - * never there. - * - * **Result: 2 of the far ten are decidable and both say DESTROYED**, and across - * all 225 cells the game destroys, the never-queued signature appears **zero** - * times. The other eight are not evidence for the other hypothesis - they are - * cells this fixture cannot speak about at all, which is the second finding here - * and is why #114's exactness must not be read as covering them. - * - * **What the verdict rests on, stated up front: the chunk-border gate.** Row two - * of that table is the whole discriminator, and it is a reading of - * `applyCliffs`' fifth-argument test, which `test/cliffConnections.spec.ts` - * records as **unscored** - `updateConnections` finds a dangling end zero times - * on the port's own set, so `everyCell` has never changed an answer. It changes - * one here: the last block below runs the same counterfactual with the gate off - * and the difference vanishes. So this is the first place the gate does any - * work, and if it was read wrongly, these two cells go back to undecidable - * rather than becoming never-queued. That is a conditional result, not a hedge - - * see `## The far ten are DESTROYED` in `docs/noise/vulcanus-cliffs-NOTES.md`. - */ - -const INPUT = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }; -const ctx = withCtxDefaults(INPUT); -const fields = makeVulcanusCliffFields(ctx); -const tileAt = makeVulcanusTileResolver(INPUT); -const oreRejects = makeVulcanusOreRejection(buildResources(ctx), ctx.vulcanusResourceControls); -const nameToId = new Map(CLIFF_ORIENTATION_NAMES.map((n, i) => [n, i])); -const K = (x: number, y: number): string => `${String(x)},${String(y)}`; -const BANDS = { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, -}; -const isLava = (x: number, y: number): boolean => - VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name); - -interface Ent { - x: number; - y: number; - name: string; - orientation?: string; -} -interface Region { - x0: number; - y0: number; - x1: number; - y1: number; -} -interface Box { - left: number; - top: number; - right: number; - bottom: number; -} - -const cases = entities.cases as unknown as { region: Region; cliffs: Ent[] }[]; - -const inRegion = - (i: number) => - (p: { x: number; y: number }): boolean => { - const r = cases[i].region; - return p.x >= r.x0 && p.x < r.x1 && p.y >= r.y0 && p.y < r.y1; - }; - -/** The game's kept cliffs per region, position -> orientation id. */ -const GAME = cases.map((c, i) => { - const m = new Map(); - for (const e of c.cliffs) { - if (e.name !== "cliff-vulcanus") continue; - if (!inRegion(i)({ x: e.x, y: e.y })) continue; - const id = nameToId.get(e.orientation ?? ""); - if (id !== undefined) m.set(K(e.x, e.y), id); - } - return m; -}); - -/** - * `generateCliffs`' queue - crossings and the repair pass, no rejection of any - * kind - with the same 64-tile halo #114 uses. Hoisted to module scope because a - * `placedCells` call inside a per-cell callback turns this file into a hang. - */ -const RAW = cases.map((c) => - makeCliffPlacementFromFields(fields, BANDS).placedCells( - c.region.x0 - 64, - c.region.y0 - 64, - c.region.x1 + 64, - c.region.y1 + 64, - ), -); -const RAWMAP = RAW.map((cells) => { - const m = new Map(); - for (const p of cells) { - const o = CLIFF_CODE_TO_ORIENTATION[p.code]; - if (o !== undefined) m.set(K(p.x, p.y), o); - } - return m; -}); - -/** Cell-centre delta, in tiles, of the neighbour on each `CellSide`. */ -const SIDE_STEP: readonly (readonly [number, number])[] = [ - [0, -4], - [4, 0], - [0, 4], - [-4, 0], -]; - -const boxOf = (o: number, x: number, y: number): Box | undefined => - cliffCollisionTileBox(cliffCodeForOrientation(o), x, y); - -const lavaCollides = (o: number, x: number, y: number): boolean => { - const box = boxOf(o, x, y); - if (box === undefined) return false; - for (let tx = box.left; tx <= box.right; tx++) - for (let ty = box.top; ty <= box.bottom; ty++) if (isLava(tx, ty)) return true; - return false; -}; - -/** Chebyshev distance from the box to the nearest tile our model calls lava. */ -const lavaDistance = (box: Box): number => { - for (let d = 0; d <= 12; d++) - for (let tx = box.left - d; tx <= box.right + d; tx++) - for (let ty = box.top - d; ty <= box.bottom + d; ty++) { - const onRing = - tx <= box.left - d || tx >= box.right + d || ty <= box.top - d || ty >= box.bottom + d; - if ((d === 0 || onRing) && isLava(tx, ty)) return d; - } - return 99; -}; - -const hasEnd = (o: number, side: number): boolean => { - const e = CLIFF_ORIENTATION_ENDS[o]; - return e !== undefined && (e[0] === side || e[1] === side); -}; - -type Group = "far" | "near" | "mid" | "agreed"; - -interface Verdict { - region: number; - key: string; - group: Group; - /** Neighbours that decide it: kept by the game, non-border, queued facing us. */ - decisive: { neighbour: string; gameOrientation: number; endGone: boolean }[]; -} - -/** Every cell the game destroys, tagged by whether our predicate agrees. */ -const DESTROYED_BY_GAME: { region: number; x: number; y: number; group: Group }[] = (() => { - const out: { region: number; x: number; y: number; group: Group }[] = []; - for (let i = 0; i < cases.length; i++) { - for (const p of RAW[i].filter(inRegion(i))) { - const o = CLIFF_CODE_TO_ORIENTATION[p.code]; - if (o === undefined || GAME[i].has(K(p.x, p.y))) continue; - const ourKill = lavaCollides(o, p.x, p.y) || oreRejects(cliffCodeForOrientation(o), p.x, p.y); - if (ourKill) { - out.push({ region: i, x: p.x, y: p.y, group: "agreed" }); - continue; - } - const box = boxOf(o, p.x, p.y); - const d = box === undefined ? 99 : lavaDistance(box); - out.push({ region: i, x: p.x, y: p.y, group: d === 99 ? "far" : d <= 2 ? "near" : "mid" }); - } - } - return out; -})(); - -/** Apply the decision rule above to one cell the game destroyed. */ -const verdictFor = (c: { region: number; x: number; y: number; group: Group }): Verdict => { - const i = c.region; - const v: Verdict = { region: i, key: K(c.x, c.y), group: c.group, decisive: [] }; - const o = RAWMAP[i].get(v.key); - if (o === undefined) return v; - for (const s of connectedSides(o)) { - const [dx, dy] = SIDE_STEP[s]; - const nx = c.x + dx; - const ny = c.y + dy; - const nk = K(nx, ny); - const facing = oppositeSide(s); - const queued = RAWMAP[i].get(nk); - const game = GAME[i].get(nk); - // (c) queued with an end facing us, (a) kept by the game, (b) not on a border. - if (queued === undefined || !hasEnd(queued, facing)) continue; - if (game === undefined || onChunkBorder(nx, ny)) continue; - v.decisive.push({ neighbour: nk, gameOrientation: game, endGone: !hasEnd(game, facing) }); - } - return v; -}; - -const VERDICTS = DESTROYED_BY_GAME.map(verdictFor); -const decisiveIn = (g: Group): Verdict[] => - VERDICTS.filter((v) => v.group === g && v.decisive.length > 0); - -/** Score the port against the game for one region, under the game's own kill set. */ -const scoreRegion = ( - i: number, - cells: readonly { x: number; y: number; code: number }[], - everyCell = false, -): { matched: number; wrong: number; surplus: number; missing: number; wrongAt: string[] } => { - const out = applyCliffConnections(cells, { - collides: (_o, x, y) => inRegion(i)({ x, y }) && !GAME[i].has(K(x, y)), - everyCell, - }); - const port = new Map(out.filter(inRegion(i)).map((p) => [K(p.x, p.y), p.orientation] as const)); - let matched = 0; - let wrong = 0; - let surplus = 0; - let missing = 0; - const wrongAt: string[] = []; - for (const [k, id] of port) { - const t = GAME[i].get(k); - if (t === undefined) surplus++; - else if (t === id) matched++; - else { - wrong++; - wrongAt.push(k); - } - } - for (const k of GAME[i].keys()) if (!port.has(k)) missing++; - return { matched, wrong, surplus, missing, wrongAt }; -}; - -describe("the game's orientations decide destroyed-vs-never-queued for only 14 cells", () => { - /** - * **How much the oracle can ever say.** Of the 225 cells the game destroys, - * only **14** have a neighbour that discriminates - the rest have neighbours - * the game also destroyed, or neighbours on a chunk border where - * `updateConnections` trims the end under either hypothesis, or no facing end - * at all. That number is not incidental: it is the reason #114's exact - * 1531/1531 must not be read as having confirmed destruction for all 225. - */ - it("finds 14 decidable cells among the 225, spread across all four groups", () => { - expect(DESTROYED_BY_GAME.length).toBe(225); - expect(DESTROYED_BY_GAME.filter((c) => c.group === "far").length).toBe(10); - expect(DESTROYED_BY_GAME.filter((c) => c.group === "near").length).toBe(9); - expect(DESTROYED_BY_GAME.filter((c) => c.group === "mid").length).toBe(6); - expect(DESTROYED_BY_GAME.filter((c) => c.group === "agreed").length).toBe(200); - - expect(decisiveIn("far").length).toBe(2); - expect(decisiveIn("near").length).toBe(2); - expect(decisiveIn("mid").length).toBe(0); - expect(decisiveIn("agreed").length).toBe(10); - expect(VERDICTS.filter((v) => v.decisive.length > 0).length).toBe(14); - // Each decidable cell is decided by exactly one neighbour, so the 14 cells - // and the 14 decisive pairs are the same 14. - expect(VERDICTS.reduce((n, v) => n + v.decisive.length, 0)).toBe(14); - }, 300000); - - /** - * **Every one of the 14 says DESTROYED, and none says never-queued.** The - * never-queued verdict is reachable by this code - the counterfactual block - * below produces it on demand - so a zero here is a measurement, not a branch - * that never runs. - */ - it("returns DESTROYED for all 14 and never-queued for none", () => { - const all = VERDICTS.flatMap((v) => v.decisive); - expect(all.length).toBe(14); - expect(all.filter((d) => d.endGone).length).toBe(14); - expect(all.filter((d) => !d.endGone).length).toBe(0); - }, 300000); - - /** - * The two far cells that carry the finding, pinned with the neighbour and the - * game's own orientation for it. Both are the trimmed `*-to-none` half of a - * run whose other end pointed at the disputed cell - which is exactly the mark - * `Cliff::onDestroy` leaves and nothing else does. - * - * They also sit one in each of the far group's two multi-cell clusters - - * `1546,1550.5` in the `1542/1546, 1550.5..1558.5` knot and `1746,1538.5` in - * the `1746, 1530.5..1538.5` vertical run. That is the argument for reading - * the clusters as destruction events; the two singletons (`1590,1618.5` and - * `1602,1622.5`) are in neither and remain untouched by this measurement. - */ - it("names the two far cells the game's own orientations prove destroyed", () => { - const far = decisiveIn("far"); - expect( - far - .map((v) => ({ - cell: v.key, - neighbour: v.decisive[0].neighbour, - game: CLIFF_ORIENTATION_NAMES[v.decisive[0].gameOrientation], - endGone: v.decisive[0].endGone, - })) - .sort((a, b) => a.cell.localeCompare(b.cell)), - ).toEqual([ - { cell: "1546,1550.5", neighbour: "1546,1546.5", game: "north-to-none", endGone: true }, - { cell: "1746,1538.5", neighbour: "1746,1542.5", game: "east-to-none", endGone: true }, - ]); - }, 300000); -}); - -/** - * **The counterfactual, which is what makes the verdict above more than a - * reading of a table.** - * - * Re-run the whole `applyCliffs` model with the cell removed from the QUEUE - * rather than destroyed in it - the never-queued hypothesis, expressed exactly - - * and the game disagrees. Its neighbour keeps the end that the game trimmed, - * because a non-border cell never runs `updateConnections`. - * - * The contrast arm is what makes it non-vacuous in the other direction: doing - * the same to a far cell with no decisive neighbour changes **nothing**, so this - * is a property of those two cells and not something removing any cell would do. - */ -describe("removing the two decidable far cells from the QUEUE contradicts the game", () => { - const DECISIVE = ["1546,1550.5", "1746,1538.5"]; - /** Both live in region 1, the `[1500,1500]` capture. */ - const R = 1; - - it("reproduces the game exactly when they are destroyed instead", () => { - expect(cases.map((_, i) => scoreRegion(i, RAW[i]).matched)).toEqual([283, 861, 387]); - expect(cases.map((_, i) => scoreRegion(i, RAW[i]).wrong)).toEqual([0, 0, 0]); - for (const d of DECISIVE) expect(RAWMAP[R].has(d)).toBe(true); - }, 300000); - - it("leaves the neighbour's end dangling when either is never queued", () => { - for (const [cell, neighbour] of [ - ["1546,1550.5", "1546,1546.5"], - ["1746,1538.5", "1746,1542.5"], - ]) { - const s = scoreRegion( - R, - RAW[R].filter((p) => K(p.x, p.y) !== cell), - ); - expect(s.wrong).toBe(1); - expect(s.wrongAt).toEqual([neighbour]); - // Not a disappearing cliff - the neighbour is still placed, with the end - // the game trimmed still attached. - expect(s.missing).toBe(0); - expect(s.surplus).toBe(0); - } - }, 300000); - - it("costs both orientations at once when both are never queued", () => { - const s = scoreRegion( - R, - RAW[R].filter((p) => !DECISIVE.includes(K(p.x, p.y))), - ); - expect(s).toMatchObject({ matched: 859, wrong: 2, surplus: 0, missing: 0 }); - expect([...s.wrongAt].sort((a, b) => a.localeCompare(b))).toEqual([ - "1546,1546.5", - "1746,1542.5", - ]); - }, 300000); - - /** - * **The contrast arm.** Four far cells with no decisive neighbour, removed the - * same way, cost nothing at all - so the fixture is genuinely silent about - * them, and "no evidence of never-queued" is not the same claim there as it is - * for the two above. - */ - it("changes nothing when a far cell with no decisive neighbour is never queued", () => { - for (const cell of ["1542,1554.5", "1590,1618.5", "1602,1622.5", "1746,1530.5"]) { - expect(RAWMAP[R].has(cell)).toBe(true); - const s = scoreRegion( - R, - RAW[R].filter((p) => K(p.x, p.y) !== cell), - ); - expect(s).toMatchObject({ matched: 861, wrong: 0, surplus: 0, missing: 0 }); - } - }, 300000); -}); - -/** - * **The chunk-border gate is what makes any of this decidable, and this is the - * first thing that has ever depended on it.** - * - * `test/cliffConnections.spec.ts` ports `Cliff::updateConnections` exactly and - * measures it firing **zero** times: the port's own cell set has no dangling - * ends, so the pass never removes anything, and it records outright that the - * gate "cannot be SCORED here - `everyCell` gives the identical answer, because - * neither fires. Do not read that as evidence the gate was read wrongly; read it - * as unscored." - * - * It stays unscored. What changes is that it is no longer inert: removing a cell - * from the queue CREATES the dangling end that the port's own set never has, and - * then the gate decides whether the neighbour trims it. With the gate off, both - * counterfactuals above stop disagreeing with the game and the two hypotheses - * become indistinguishable. - * - * So the honest form of the finding is conditional: **given that `applyCliffs` - * really does skip `updateConnections` off the chunk's outer ring, those two - * cells were destroyed.** If that reading is wrong they revert to undecidable - - * they do not become never-queued. Scoring the gate against the game is now - * worth doing on its own account, and was not before. - */ -describe("the verdict depends on the chunk-border gate, which remains unscored", () => { - const R = 1; - - it("is invisible in the baseline - both gate settings reproduce the game", () => { - for (let i = 0; i < cases.length; i++) { - const gated = scoreRegion(i, RAW[i], false); - const ungated = scoreRegion(i, RAW[i], true); - expect(gated.wrong).toBe(0); - expect(ungated.wrong).toBe(0); - expect(ungated.matched).toBe(gated.matched); - } - }, 300000); - - it("erases the counterfactual's signal when the gate is switched off", () => { - for (const cell of ["1546,1550.5", "1746,1538.5"]) { - const without = RAW[R].filter((p) => K(p.x, p.y) !== cell); - // With the gate (the game's rule as read): the neighbour keeps its end and - // contradicts the game. - expect(scoreRegion(R, without, false).wrong).toBe(1); - // Without it: `updateConnections` trims the dangling end itself, the game - // is reproduced either way, and nothing distinguishes the hypotheses. - expect(scoreRegion(R, without, true).wrong).toBe(0); - } - }, 300000); -}); - -/** - * **Cross-check against #114's own control.** That file destroys the same 225 - * cells with `noCascade` and gets 14 wrong orientations. Those 14 must be - * precisely the neighbours this file calls decisive - a neighbour is observable - * exactly when the cascade is the only thing that would have trimmed it - and - * they are, cell for cell. Two independent routes to the same 14 is what says - * the decision rule was read out of `cliffConnections.ts` correctly rather than - * fitted to the answer. - */ -describe("the 14 decisive neighbours are #114's 14 no-cascade rewrites", () => { - it("matches cell for cell", () => { - const fromCascade: string[] = []; - for (let i = 0; i < cases.length; i++) { - const out = applyCliffConnections(RAW[i], { - collides: (_o, x, y) => inRegion(i)({ x, y }) && !GAME[i].has(K(x, y)), - noCascade: true, - }); - for (const p of out.filter(inRegion(i))) { - const t = GAME[i].get(K(p.x, p.y)); - if (t !== undefined && t !== p.orientation) fromCascade.push(K(p.x, p.y)); - } - } - const fromVerdicts = VERDICTS.flatMap((v) => v.decisive.map((d) => d.neighbour)); - expect(fromCascade.length).toBe(14); - expect([...fromCascade].sort((a, b) => a.localeCompare(b))).toEqual( - [...fromVerdicts].sort((a, b) => a.localeCompare(b)), - ); - }, 300000); -}); diff --git a/test/cliffFields.spec.ts b/test/cliffFields.spec.ts deleted file mode 100644 index be164a1f..00000000 --- a/test/cliffFields.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import elevFixture from "./fixtures/oracle-cliff-elevation.seed123456.json"; -import cliffinessFixture from "./fixtures/oracle-cliffiness.seed123456.json"; -import { makeCliffElevation, makeCliffiness } from "../src/noise/cliffs/cliffFields"; - -const ABS_TOL = 1.0, - REL_TOL = 1e-2; -const ok = (p: number, g: number) => Math.abs(p - g) < Math.max(ABS_TOL, REL_TOL * Math.abs(g)); -const ctx = (seed0: number) => ({ - seed0, - controls: { frequency: 1, continuity: 1 }, - settings: { cliffElevation0: 10, cliffElevationInterval: 40, richness: 1 }, -}); - -describe("makeCliffElevation vs oracle", () => { - for (const c of elevFixture.cases) { - it(`matches cliff_elevation_nauvis seed=${c.seed}`, () => { - const f = makeCliffElevation(ctx(c.seed)); - let worstAbs = 0; - for (let i = 0; i < elevFixture.positions.length; i++) { - const p = elevFixture.positions[i]; - const port = f(p.x, p.y), - game = c.values[i]; - worstAbs = Math.max(worstAbs, Math.abs(port - game)); - expect(ok(port, game)).toBe(true); - } - expect(worstAbs).toBeLessThan(ABS_TOL * 10); // sanity: not wildly off - }); - } -}); - -describe("makeCliffiness vs oracle (exact 0/10 gate)", () => { - for (const c of cliffinessFixture.cases) { - it(`matches cliffiness_nauvis seed=${c.seed}`, () => { - const f = makeCliffiness(ctx(c.seed)); - const mism: string[] = []; - for (let i = 0; i < cliffinessFixture.positions.length; i++) { - const p = cliffinessFixture.positions[i]; - const port = f(p.x, p.y), - game = c.values[i]; - if (port !== game) mism.push(`(${p.x},${p.y}) game=${game} port=${port}`); - } - if (mism.length) - throw new Error(`${mism.length} gate mismatches:\n${mism.slice(0, 12).join("\n")}`); - expect(mism.length).toBe(0); - }); - } -}); diff --git a/test/cliffFixImpossibleCells.spec.ts b/test/cliffFixImpossibleCells.spec.ts deleted file mode 100644 index 5391fb59..00000000 --- a/test/cliffFixImpossibleCells.spec.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { isCliffPlaced } from "../src/noise/cliffs/cliffCatalog"; -import { makeCliffFields } from "../src/noise/cliffs/cliffFields"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -/** - * `fixImpossibleCells`, the game's per-chunk repair sweep, ported 2026-07-28. - * - * The headline result is a NEGATIVE one and is pinned here so it cannot quietly - * revert to folklore: **this pass does not move Nauvis at all**, which falsifies - * the claim - carried in `cliffs-NOTES.md` from 2026-07-20 - that Nauvis's ~6% - * cliff residual "is `fixImpossibleCells`". It is not. See the notes for what - * that leaves (`tryToAddCliff`'s `wouldCollide`, still untested). - */ -const key = (p: { x: number; y: number }): string => `${String(p.x)},${String(p.y)}`; - -function nauvisCells(seed: number, fix: boolean): string[] { - const fields = makeCliffFields({ - seed0: seed, - controls: { frequency: 1, continuity: 1 }, - settings: { cliffElevation0: 10, cliffElevationInterval: 40, richness: 1 }, - }); - return makeCliffPlacementFromFields(fields, { - elevation0: 10, - interval: 40, - fixImpossibleCells: fix, - }) - .placedCells(512, 512, 1024, 1024) - .map(key) - .sort(); -} - -describe("fixImpossibleCells", () => { - it("does not change Nauvis by a single cell, at either oracle seed", () => { - // Measured 2026-07-28: identical cell SETS, not merely identical counts, at - // both seeds the cliff oracle covers. Nauvis's `cliffiness_nauvis` is a hard - // 0-or-10 gate, so the crossing configurations it produces are already legal - // and the sweep finds nothing to repair. - // - // This is the assertion that retires "the residual is fixImpossibleCells". - // If it ever fails, that conclusion needs revisiting - it does not mean the - // port drifted. - for (const seed of [123456, 777771]) { - expect(nauvisCells(seed, true)).toEqual(nauvisCells(seed, false)); - } - }, 120000); - - it("does fire on Vulcanus, so the pass is not a no-op everywhere", () => { - // The necessary companion to the Nauvis test above: without this, "no - // change on Nauvis" would be equally consistent with the port doing nothing - // at all. Vulcanus's continuous `cliffiness_basic` produces configurations - // the orientation table rejects, and the sweep repairs them. - const ctx = withCtxDefaults({ seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }); - const fields = makeVulcanusCliffFields(ctx); - const cells = (fix: boolean): string[] => - makeCliffPlacementFromFields(fields, { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, - fixImpossibleCells: fix, - }) - .placedCells(0, 0, 256, 256) - .map(key) - .sort(); - expect(cells(true)).not.toEqual(cells(false)); - }, 120000); - - it("defaults to ON, because the game always runs it", () => { - // `crossingsForChunk` calls it unconditionally at its tail, so an omitted - // option must mean enabled. Contrast `smoothing`, which defaults to Nauvis's - // 0 rather than the prototype's 1 - the two options deliberately default - // differently and it would be easy to "tidy" them into agreeing. - const ctx = withCtxDefaults({ seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }); - const fields = makeVulcanusCliffFields(ctx); - const bands = { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, - }; - const omitted = makeCliffPlacementFromFields(fields, bands) - .placedCells(0, 0, 256, 256) - .map(key) - .sort(); - const explicit = makeCliffPlacementFromFields(fields, { ...bands, fixImpossibleCells: true }) - .placedCells(0, 0, 256, 256) - .map(key) - .sort(); - expect(omitted).toEqual(explicit); - }, 120000); - - it("accepts exactly the codes the orientation table does, plus 0", () => { - // The legality predicate the sweep uses is not a second table: the game's - // `code <= 0x50` jump tables and its `code >= 0xC0` bitmask - // (0x0001000000001003 -> codes 0xC0, 0xC1, 0xCC, 0xF0) together accept - // exactly `isCliffPlaced(code)` plus code 0. Pinning the four high codes - // means a change to CLIFF_PLACED_TABLE that broke that correspondence would - // fail here rather than silently changing what the sweep repairs. - expect([0xc0, 0xc1, 0xcc, 0xf0].every((c) => isCliffPlaced(c))).toBe(true); - const highPlaced = []; - for (let c = 0xc0; c <= 0xff; c++) if (isCliffPlaced(c)) highPlaced.push(c); - expect(highPlaced).toEqual([0xc0, 0xc1, 0xcc, 0xf0]); - // Nothing in 0x51..0xBF is accepted. - for (let c = 0x51; c < 0xc0; c++) expect(isCliffPlaced(c)).toBe(false); - }); -}); diff --git a/test/cliffMissedDestructionsLever.spec.ts b/test/cliffMissedDestructionsLever.spec.ts deleted file mode 100644 index 44e4b40f..00000000 --- a/test/cliffMissedDestructionsLever.spec.ts +++ /dev/null @@ -1,360 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import ore from "./fixtures/oracle-vulcanus-cliff-ore-direction.seed123456.json"; -import entities from "./fixtures/oracle-vulcanus-cliff-entities.seed123456.json"; -import { CLIFF_CODE_TO_ORIENTATION, cliffCollisionTileBox } from "../src/noise/cliffs/cliffCatalog"; -import { cliffCodeForOrientation } from "../src/noise/cliffs/cliffConnections"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { makeVulcanusOreRejection } from "../src/noise/cliffs/vulcanusOreRejection"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; -import { buildResources } from "../src/noise/preview/renderVulcanusResources"; -import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -/** - * **Eleven of the 25 missed destructions are the ORE, and the game's own lever - * says so** (#84). - * - * `test/cliffCollisionResidualShape.spec.ts` (#115) split the 25 into groups by - * distance to lava and wrote off the ore rule for all of them in one clause: - * - * > **But ten of the 25 have no lava within twelve tiles**, so no adjustment to a - * > lava collision box can ever reach them, and neither can the ore rule (all 25 - * > are `ore = false`) nor any entity... - * - * **`ore = false` there is our own predicate's output, not the game's - * behaviour**, and that predicate is documented in `vulcanusOreRejection.ts` as - * "exactly right where it fires, simply too narrow" - it explains 20 of the 31 - * cells the ore actually suppresses. Using it to rule the ore out is circular, - * and the fixture that settles it non-circularly was already on disk and already - * covers the right region: `oracle-vulcanus-cliff-ore-direction` re-runs - * `[1500,1500]` - where every one of the far ten lives - with the resources - * switched off through `autoplace_controls`. - * - * Switch them off and **six of the far ten appear**. The correct split is: - * - * | of the 25 missed destructions | count | - * | --- | --- | - * | **ORE**, by the lever (7 calcite + 4 geyser) | **11** | - * | **unknown** - absent even with every resource off | **11** | - * | outside the lever's region, so undetermined here | 3 | - * - * So the population with an unidentified mechanism is **11, not 25**, and #115's - * lava-distance grouping is not the causal one - ore cells land in its near, mid - * AND far groups. What survives of #115 is its careful half: the far ten really - * are unreachable by any lava box, and the tile resolver really is exonerated. - * What does not survive is the parenthetical that ruled out the ore. - */ - -const INPUT = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }; -const ctx = withCtxDefaults(INPUT); -const fields = makeVulcanusCliffFields(ctx); -const tileAt = makeVulcanusTileResolver(INPUT); -const oreRejects = makeVulcanusOreRejection(buildResources(ctx), ctx.vulcanusResourceControls); -const K = (x: number, y: number): string => `${String(x)},${String(y)}`; -const BANDS = { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, -}; -const isLava = (x: number, y: number): boolean => - VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name); - -interface Ent { - x: number; - y: number; - name: string; - orientation?: string; -} -interface Region { - x0: number; - y0: number; - x1: number; - y1: number; -} -interface Box { - left: number; - top: number; - right: number; - bottom: number; -} - -const cases = entities.cases as unknown as { region: Region; cliffs: Ent[] }[]; -const inRegion = - (i: number) => - (p: { x: number; y: number }): boolean => { - const r = cases[i].region; - return p.x >= r.x0 && p.x < r.x1 && p.y >= r.y0 && p.y < r.y1; - }; -const GAME = cases.map((c, i) => { - const s = new Set(); - for (const e of c.cliffs) - if (e.name === "cliff-vulcanus" && inRegion(i)({ x: e.x, y: e.y })) s.add(K(e.x, e.y)); - return s; -}); - -/** `generateCliffs`' queue, with the same halo #114 and #122 use. */ -const RAW = cases.map((c) => - makeCliffPlacementFromFields(fields, BANDS).placedCells( - c.region.x0 - 64, - c.region.y0 - 64, - c.region.x1 + 64, - c.region.y1 + 64, - ), -); - -/** The lever fixture's region is the entities fixture's region 1, `[1500,1500]`. */ -const LEVER_REGION = 1; - -const oreCases = ore.cases as unknown as { label: string; region: Region; cliffs: Ent[] }[]; -const arm = (label: string): Set => { - const c = oreCases.find((q) => q.label === label); - if (c === undefined) throw new Error(`no arm ${label}`); - const s = new Set(); - for (const e of c.cliffs) - if ( - e.name === "cliff-vulcanus" && - e.x >= c.region.x0 && - e.x < c.region.x1 && - e.y >= c.region.y0 && - e.y < c.region.y1 - ) - s.add(K(e.x, e.y)); - return s; -}; -const ON = arm("entity region, resources ON"); -const ALL_OFF = arm("entity region, ALL resources OFF"); -const CALCITE_OFF = arm("entity region, calcite OFF"); -const GEYSER_OFF = arm("entity region, geyser OFF"); - -const boxOf = (o: number, x: number, y: number): Box | undefined => - cliffCollisionTileBox(cliffCodeForOrientation(o), x, y); -const lavaIn = (box: Box | undefined): boolean => { - if (box === undefined) return false; - for (let tx = box.left; tx <= box.right; tx++) - for (let ty = box.top; ty <= box.bottom; ty++) if (isLava(tx, ty)) return true; - return false; -}; -const lavaDistance = (box: Box): number => { - for (let d = 0; d <= 12; d++) - for (let tx = box.left - d; tx <= box.right + d; tx++) - for (let ty = box.top - d; ty <= box.bottom + d; ty++) { - const onRing = - tx <= box.left - d || tx >= box.right + d || ty <= box.top - d || ty >= box.bottom + d; - if ((d === 0 || onRing) && isLava(tx, ty)) return d; - } - return 99; -}; - -type Cause = "calcite" | "geyser" | "unknown" | "outside-lever-region"; - -interface Missed { - region: number; - key: string; - /** #115's grouping, by distance from the box to the nearest tile we call lava. */ - group: "near" | "mid" | "far"; - cause: Cause; -} - -/** The 25 cells the game destroys and our predicate keeps, each given a cause. */ -const MISSED: Missed[] = (() => { - const out: Missed[] = []; - for (let i = 0; i < cases.length; i++) { - for (const p of RAW[i].filter(inRegion(i))) { - const o = CLIFF_CODE_TO_ORIENTATION[p.code]; - if (o === undefined || GAME[i].has(K(p.x, p.y))) continue; - const box = boxOf(o, p.x, p.y); - if (lavaIn(box) || oreRejects(cliffCodeForOrientation(o), p.x, p.y)) continue; - const key = K(p.x, p.y); - const d = box === undefined ? 99 : lavaDistance(box); - let cause: Cause = "outside-lever-region"; - if (i === LEVER_REGION) { - if (!ALL_OFF.has(key)) cause = "unknown"; - else if (GEYSER_OFF.has(key)) cause = "geyser"; - else cause = "calcite"; - } - out.push({ region: i, key, group: d === 99 ? "far" : d <= 2 ? "near" : "mid", cause }); - } - } - return out; -})(); - -const withCause = (c: Cause): Missed[] => MISSED.filter((m) => m.cause === c); - -describe("the lever fixture is comparable to the entities fixture at all", () => { - /** - * **The prerequisite nobody would notice was missing.** Two independent - * captures of the same seed and region are being cross-referenced here, and if - * they disagreed the whole cross-tab would be noise. They agree cell for cell: - * 861 cliffs, zero in either fixture and not the other. - */ - it("agrees cell for cell with the entities fixture on the resources-ON arm", () => { - expect(ON.size).toBe(861); - expect(GAME[LEVER_REGION].size).toBe(861); - expect([...GAME[LEVER_REGION]].filter((k) => !ON.has(k))).toEqual([]); - expect([...ON].filter((k) => !GAME[LEVER_REGION].has(k))).toEqual([]); - }); - - /** - * The lever's own numbers, re-derived here rather than quoted: 31 cells the - * resources suppress, **zero** that appear when the ore is added back (#99's - * one-way property), and the two single-control arms are disjoint and add up - * to the all-off arm exactly - 27 calcite + 4 geyser = 31. - */ - it("re-derives the lever's 31 suppressed cells, additive and one-way", () => { - const suppressed = [...ALL_OFF].filter((k) => !ON.has(k)); - expect(suppressed.length).toBe(31); - expect([...ON].filter((k) => !ALL_OFF.has(k))).toEqual([]); - - const calcite = [...CALCITE_OFF].filter((k) => !ON.has(k)); - const geyser = [...GEYSER_OFF].filter((k) => !ON.has(k)); - expect(calcite.length).toBe(27); - expect(geyser.length).toBe(4); - expect(calcite.filter((k) => geyser.includes(k))).toEqual([]); - expect(new Set([...calcite, ...geyser])).toEqual(new Set(suppressed)); - }); -}); - -describe("our ore predicate scored at the RAW stage, where applyCliffs tests it", () => { - /** - * **Precision 1.000, recall 0.645** - and the recall differs from the 0.710 in - * `## The ore rule, scored against the lever` because that one is scored on - * PLACED cells after the crossing stage, which loses 22 where the predicate - * fires on 20. This file scores the raw queue, which is the set `applyCliffs` - * actually tests, and is the stage #114 established as the right one to count - * a rule at. Neither number is wrong; they count different things, and this is - * the one that lines up with the 31. - */ - it("fires on 20 of the lever's 31 and on nothing outside it", () => { - const suppressed = new Set([...ALL_OFF].filter((k) => !ON.has(k))); - let fires = 0; - let firesInside = 0; - for (const p of RAW[LEVER_REGION].filter(inRegion(LEVER_REGION))) { - const o = CLIFF_CODE_TO_ORIENTATION[p.code]; - if (o === undefined) continue; - if (!oreRejects(cliffCodeForOrientation(o), p.x, p.y)) continue; - fires++; - if (suppressed.has(K(p.x, p.y))) firesInside++; - } - expect(fires).toBe(20); - expect(firesInside).toBe(20); - // precision 20/20, recall 20/31 - expect(firesInside / fires).toBe(1); - expect(firesInside / suppressed.size).toBeCloseTo(0.645, 3); - }, 300000); - - /** - * All 31 are in the raw queue, so none of the 11 it misses is a cell we failed - * to GENERATE - the same superset property #114 established, checked again on - * the set that matters here. - */ - it("finds all 31 present in the raw queue", () => { - const raw = new Set(RAW[LEVER_REGION].filter(inRegion(LEVER_REGION)).map((p) => K(p.x, p.y))); - const suppressed = [...ALL_OFF].filter((k) => !ON.has(k)); - expect(suppressed.filter((k) => !raw.has(k))).toEqual([]); - }, 300000); -}); - -describe("eleven of the 25 missed destructions are the ore", () => { - it("splits the 25 by the lever rather than by our own predicate", () => { - expect(MISSED.length).toBe(25); - expect(withCause("calcite").length).toBe(7); - expect(withCause("geyser").length).toBe(4); - expect(withCause("unknown").length).toBe(11); - expect(withCause("outside-lever-region").length).toBe(3); - // The ore total is exactly the 11 the predicate misses from the lever's 31. - expect(withCause("calcite").length + withCause("geyser").length).toBe(11); - }, 300000); - - /** - * **Six of #115's far ten are ore**, which is the correction. Its two - * multi-cell clusters have DIFFERENT causes - the `1542/1546` knot is the - * geyser and the two singletons are calcite - and only the `1742/1746` - * vertical run survives as unexplained. - * - * That also sharpens #122: of the two far cells whose destruction it proved - * from the game's own orientations, `1546,1550.5` is now known to be a geyser - * suppression, so the genuinely-unknown group's destruction rests on - * `1746,1538.5` alone. - */ - it("finds 6 of the far ten are ore and only 4 are unexplained", () => { - const far = MISSED.filter((m) => m.group === "far"); - expect(far.length).toBe(10); - const byCause = (c: Cause): string[] => - far - .filter((m) => m.cause === c) - .map((m) => m.key) - .sort((a, b) => a.localeCompare(b)); - expect(byCause("geyser")).toEqual(["1542,1554.5", "1542,1558.5", "1546,1550.5", "1546,1554.5"]); - expect(byCause("calcite")).toEqual(["1590,1618.5", "1602,1622.5"]); - expect(byCause("unknown")).toEqual([ - "1742,1530.5", - "1746,1530.5", - "1746,1534.5", - "1746,1538.5", - ]); - }, 300000); - - /** - * **#115's lava-distance grouping is not the causal partition.** Ore cells - * appear in all three of its groups, so "near the lava" and "caused by the - * lava box" are not the same claim, and neither are "far from lava" and - * "unidentified mechanism". Worth pinning, because the near/far split is what - * the previous handoff proposed to act on. - */ - it("shows ore and unknown cells in every one of #115's distance groups", () => { - const tally = (g: Missed["group"], c: Cause): number => - MISSED.filter((m) => m.group === g && m.cause === c).length; - expect(tally("far", "geyser") + tally("far", "calcite")).toBe(6); - expect(tally("mid", "calcite")).toBe(4); - expect(tally("near", "calcite")).toBe(1); - expect(tally("far", "unknown")).toBe(4); - expect(tally("mid", "unknown")).toBe(2); - expect(tally("near", "unknown")).toBe(5); - }, 300000); - - /** - * The 11 that remain, listed because they are the input to whatever comes - * next. Five sit within two tiles of our lava, so the box-shape question #115 - * raised is still live for those; six do not. - */ - it("pins the 11 with no known cause", () => { - expect( - withCause("unknown") - .map((m) => m.key) - .sort((a, b) => a.localeCompare(b)), - ).toEqual([ - "1506,1582.5", - "1506,1586.5", - "1506,1634.5", - "1658,1598.5", - "1662,1630.5", - "1718,1650.5", - "1722,1630.5", - "1742,1530.5", - "1746,1530.5", - "1746,1534.5", - "1746,1538.5", - ]); - }, 300000); - - /** - * The three the lever cannot speak about, so nobody counts them as either. - * The fixture only re-ran `[1500,1500]`; these are in `[0,0]` and - * `[-1200,800]`, and settling them would need those regions re-run with the - * resources off. - */ - it("pins the 3 the lever's region does not cover", () => { - expect( - withCause("outside-lever-region") - .map((m) => m.key) - .sort((a, b) => a.localeCompare(b)), - ).toEqual(["-1050,1022.5", "106,26.5", "90,38.5"]); - }, 300000); -}); diff --git a/test/cliffOreActsAtDestroyStage.spec.ts b/test/cliffOreActsAtDestroyStage.spec.ts deleted file mode 100644 index 235e1f03..00000000 --- a/test/cliffOreActsAtDestroyStage.spec.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import ore from "./fixtures/oracle-vulcanus-cliff-ore-direction.seed123456.json"; -import entities from "./fixtures/oracle-vulcanus-cliff-entities.seed123456.json"; -import { - CLIFF_CODE_TO_ORIENTATION, - CLIFF_ORIENTATION_NAMES, -} from "../src/noise/cliffs/cliffCatalog"; -import { - CLIFF_ORIENTATION_ENDS, - applyCliffConnections, - connectedSides, - onChunkBorder, - oppositeSide, -} from "../src/noise/cliffs/cliffConnections"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -/** - * **The ore suppression is a DESTRUCTION at `applyCliffs`, not a failure to - * queue - and it is provably NOT the engine's entity-collision test** (#84). - * - * `vulcanusOreRejection.ts` states that the mechanism of the ore -> cliff rule is - * open, and that the obvious candidate - a collision test seeing the resource - * entity - is refuted by ordering. Two things are added here. The refutation is - * now verified rather than asserted, by three independent routes; and the stage - * the rule acts at is measured rather than assumed. - * - * **What the binary says** (2.1.12, arm64 slice; addresses in - * `docs/noise/cliffs-NOTES.md`): - * - * - `EntityMapGenerationTask::computeInternal` calls `generateCliffs` FIRST, at - * `+0x2c`, before it even builds the `NoiseCache` the entity passes use, then - * `generateEntities` three times and `generateDecoratives`. `apply` calls - * `applyCliffs` (`+0x7c`), `applyDecoratives`, then `applyEntities`. So within - * a chunk no resource exists at any point where a cliff is decided. - * - `generateCliffs` calls exactly three things - `crossingsForChunk`, - * `MaybeCliffOrientation::value` and `tryToAddCliff`. There is no resource - * input to the queue at all. - * - `applyEntities` tests each queued entity with - * `Surface::mapGeneratorWouldCollide` and, on a hit, **skips that entity** - * (`tbnz w0, #0x0` to the loop tail). It never destroys a cliff. So the only - * entity-versus-cliff test in map generation runs in the direction the lever - * already measured as inert (#99: forcing cliffs through the tungsten field - * moves the ore not one tile). - * - * **What the game data says.** `calcite`, `tungsten-ore` and - * `sulfuric-acid-geyser` are all `type = "resource"` and none carries an - * explicit `collision_mask`, so all three take the type default from - * `core/lualib/collision-mask-defaults.lua`: `{layers={resource=true}}`. The - * cliff default is `{item, meltable, object, player, water_tile, - * is_lower_object, is_object, cliff}`. **Disjoint.** A resource therefore cannot - * collide with a cliff under any ordering, in any chunk, at any box size - which - * closes the cross-chunk variant of the idea too (chunk N's entities are on the - * surface before chunk N+1's cliffs are applied, and it still cannot matter). - * - * **The consequence that matters for #84.** The box-overlap model in - * `vulcanusOreRejection.ts` does not correspond to the engine's collision test. - * Widening its box until the remaining cells fall out would not be modelling a - * known code path; it would be fitting a shape to an effect whose geometry is - * still unknown, which is exactly what #88 records as having shipped a wrong - * model that scored perfectly. The recall gap is real and worth closing - but - * not that way. - * - * **Update 2026-08-14: the effect now has a name, and it predicts this - * result.** `ResourceEntityPrototype::cliff_removal_probability` (default 1.0) - * is the mechanism - see `cliffRemovalProbability.spec.ts`. A field that - * *removes* cliffs can only act on cliffs that already exist, so "destroyed - * rather than never queued" is what it predicts, and this spec's thin n=1 - * result stops standing on its own. It does not name the geometry, so the - * warning above about widening the box is unchanged. - */ - -const INPUT = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }; -const ctx = withCtxDefaults(INPUT); -const fields = makeVulcanusCliffFields(ctx); -const nameToId = new Map(CLIFF_ORIENTATION_NAMES.map((n, i) => [n, i])); -const K = (x: number, y: number): string => `${String(x)},${String(y)}`; -const BANDS = { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, -}; - -interface Ent { - x: number; - y: number; - name: string; - orientation?: string; -} -interface Region { - x0: number; - y0: number; - x1: number; - y1: number; -} - -const cases = entities.cases as unknown as { region: Region; cliffs: Ent[] }[]; -const R = cases[1].region; -const inR = (p: { x: number; y: number }): boolean => - p.x >= R.x0 && p.x < R.x1 && p.y >= R.y0 && p.y < R.y1; - -const GAME = new Map(); -for (const e of cases[1].cliffs) - if (e.name === "cliff-vulcanus" && inR(e)) { - const id = nameToId.get(e.orientation ?? ""); - if (id !== undefined) GAME.set(K(e.x, e.y), id); - } - -const RAW = makeCliffPlacementFromFields(fields, BANDS).placedCells( - R.x0 - 64, - R.y0 - 64, - R.x1 + 64, - R.y1 + 64, -); -const RAWMAP = new Map(); -for (const p of RAW) { - const o = CLIFF_CODE_TO_ORIENTATION[p.code]; - if (o !== undefined) RAWMAP.set(K(p.x, p.y), o); -} - -const oreCases = ore.cases as unknown as { label: string; region: Region; cliffs: Ent[] }[]; -const arm = (label: string): Set => { - const c = oreCases.find((q) => q.label === label); - if (c === undefined) throw new Error(`no arm ${label}`); - const s = new Set(); - for (const e of c.cliffs) - if ( - e.name === "cliff-vulcanus" && - e.x >= c.region.x0 && - e.x < c.region.x1 && - e.y >= c.region.y0 && - e.y < c.region.y1 - ) - s.add(K(e.x, e.y)); - return s; -}; -const ON = arm("entity region, resources ON"); -const ALL_OFF = arm("entity region, ALL resources OFF"); -const GEYSER_OFF = arm("entity region, geyser OFF"); -const SUPPRESSED = [...ALL_OFF].filter((k) => !ON.has(k)).sort((a, b) => a.localeCompare(b)); - -const SIDE_STEP: readonly (readonly [number, number])[] = [ - [0, -4], - [4, 0], - [0, 4], - [-4, 0], -]; -const hasEnd = (o: number, side: number): boolean => { - const e = CLIFF_ORIENTATION_ENDS[o]; - return e !== undefined && (e[0] === side || e[1] === side); -}; - -/** - * #122's rule: destruction runs `onDestroy` on the neighbour unconditionally, - * while a cell that was never queued only costs a neighbour its end if that - * neighbour is on a chunk border. - */ -interface Decision { - cell: string; - neighbour: string; - cause: "geyser" | "calcite"; - game: string; - endGone: boolean; -} -const DECIDABLE: Decision[] = (() => { - const out: Decision[] = []; - for (const cell of SUPPRESSED) { - const o = RAWMAP.get(cell); - if (o === undefined) continue; - const [xs, ys] = cell.split(","); - const x = Number(xs); - const y = Number(ys); - for (const s of connectedSides(o)) { - const [dx, dy] = SIDE_STEP[s]; - const nk = K(x + dx, y + dy); - const facing = oppositeSide(s); - const queued = RAWMAP.get(nk); - const game = GAME.get(nk); - if (queued === undefined || !hasEnd(queued, facing)) continue; - if (game === undefined || onChunkBorder(x + dx, y + dy)) continue; - out.push({ - cell, - neighbour: nk, - cause: GEYSER_OFF.has(cell) ? "geyser" : "calcite", - game: CLIFF_ORIENTATION_NAMES[game], - endGone: !hasEnd(game, facing), - }); - } - } - return out; -})(); - -const scoreWith = ( - cells: readonly { x: number; y: number; code: number }[], - killed: Set, -): { wrong: number; at: string[] } => { - const out = applyCliffConnections(cells, { - collides: (_o, x, y) => inR({ x, y }) && killed.has(K(x, y)), - }); - const port = new Map(out.filter(inR).map((p) => [K(p.x, p.y), p.orientation] as const)); - let wrong = 0; - const at: string[] = []; - for (const [k, id] of port) { - const t = GAME.get(k); - if (t !== undefined && t !== id) { - wrong++; - at.push(k); - } - } - return { wrong, at }; -}; -const GAME_KILL = new Set([...RAWMAP.keys()].filter((k) => inR(keyPos(k)) && !GAME.has(k))); -function keyPos(k: string): { x: number; y: number } { - const [xs, ys] = k.split(","); - return { x: Number(xs), y: Number(ys) }; -} - -describe("the ore suppression acts at the DESTROY stage", () => { - /** - * **The oracle is thin here and that is the first thing to report.** Of the 31 - * cells the lever attributes to the ore, only **one** has a neighbour that can - * distinguish destruction from non-generation - the rest have neighbours the - * game also lacks, neighbours on a chunk border, or no facing end. The - * conclusion below is an n=1 stage localisation, not a survey. - */ - it("finds exactly one of the 31 decidable", () => { - expect(SUPPRESSED.length).toBe(31); - expect(DECIDABLE.length).toBe(1); - expect(DECIDABLE[0]).toEqual({ - cell: "1546,1550.5", - neighbour: "1546,1546.5", - cause: "geyser", - game: "north-to-none", - endGone: true, - }); - }, 300000); - - /** - * **DESTROYED, not never-queued.** The neighbour's south end is gone in the - * game's own data, and only `Cliff::onDestroy` removes it - a cell that - * `crossingsForChunk` never emitted would have left that non-border neighbour - * with its end intact. - * - * So the ore's effect enters at `applyCliffs`, where `Surface::wouldCollide` - * decides, and NOT at the crossing stage. Given the entity half of that - * function cannot see a resource (disjoint masks, and no resource exists yet), - * whatever the ore does reaches `wouldCollide` by some other route - which is - * a sharper open question than "the mechanism is unknown". - */ - it("contradicts the game if that cell is treated as never queued", () => { - // Baseline: the game's own destruction set reproduces the region exactly. - expect(scoreWith(RAW, GAME_KILL).wrong).toBe(0); - - const cell = "1546,1550.5"; - const without = RAW.filter((p) => K(p.x, p.y) !== cell); - const killed = new Set([...GAME_KILL].filter((k) => k !== cell)); - const s = scoreWith(without, killed); - expect(s.wrong).toBe(1); - expect(s.at).toEqual(["1546,1546.5"]); - }, 300000); - - /** - * The contrast arm: every OTHER ore-suppressed cell can be removed from the - * queue with no observable consequence, which is what "only one is decidable" - * means in practice and stops the arm above reading as a general property. - */ - it("costs nothing when any of the other 30 is treated as never queued", () => { - let changed = 0; - for (const cell of SUPPRESSED) { - if (cell === "1546,1550.5") continue; - const without = RAW.filter((p) => K(p.x, p.y) !== cell); - const killed = new Set([...GAME_KILL].filter((k) => k !== cell)); - if (scoreWith(without, killed).wrong > 0) changed++; - } - expect(changed).toBe(0); - }, 300000); -}); diff --git a/test/cliffOreCascade.spec.ts b/test/cliffOreCascade.spec.ts deleted file mode 100644 index 0db58c61..00000000 --- a/test/cliffOreCascade.spec.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import ore from "./fixtures/oracle-vulcanus-cliff-ore-direction.seed123456.json"; -import sweep from "./fixtures/oracle-vulcanus-cliff-fine-sweep.seed123456.json"; -import { - CLIFF_CODE_TO_ORIENTATION, - CLIFF_ORIENTATION_NAMES, -} from "../src/noise/cliffs/cliffCatalog"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { makeVulcanusOreRejection } from "../src/noise/cliffs/vulcanusOreRejection"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; -import { buildResources } from "../src/noise/preview/renderVulcanusResources"; -import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -/** - * **The ore rule's remaining error, measured against a LEVER rather than - * characterised** - and the refutation of the cascade half of - * `vulcanusOreRejection.ts`'s open question. - * - * That file states the position this spec moves: box overlap explains most of - * the suppressed cells, "the other 10 are run remainders - every one of the six - * connected components of the suppressed set contains a directly overlapped cell - * - and whether that is **a cascade along cliff connections or a wider box** is - * open." - * - * #108 supplied a mechanism that makes the cascade half concrete and testable: a - * rejection zeroes the cell's four edge registers, so its neighbours' codes - - * and therefore their ORIENTATIONS, and therefore their collision boxes - change. - * Re-running the rejection pass to a fixpoint is exactly "a cascade along cliff - * connections". `rejectionCascades` in `cliffPlacement.ts` is that arm. - * - * **It buys nothing.** At the shipping settings it is a bit-for-bit no-op, and on - * the collapsed rule it is net harmful. Half of the open question is now closed: - * the remainders are not a cascade of this predicate. - * - * The rest of the spec is the positive measurement the lever makes possible. - * `oracle-vulcanus-cliff-ore-direction` re-ran `[1500,1500]` with the resources - * switched off through `autoplace_controls`, so the ore's true effect is a known - * SET of cells rather than an inference, and the port's predicate can be scored - * for precision and recall against it instead of by how well the totals line up. - */ - -const INPUT = { seed0: ore.seed, startingPositions: [{ x: 0, y: 0 }] }; -const ctx = withCtxDefaults(INPUT); -const fields = makeVulcanusCliffFields(ctx); -const tileAt = makeVulcanusTileResolver(INPUT); -const oreRejects = makeVulcanusOreRejection(buildResources(ctx), ctx.vulcanusResourceControls); - -const codeForOrientation = new Map(); -for (const [c, id] of Object.entries(CLIFF_CODE_TO_ORIENTATION)) - codeForOrientation.set(id, Number(c)); -const nameToId = new Map(CLIFF_ORIENTATION_NAMES.map((n, i) => [n, i])); -const gameCodeOf = (o: string): number | undefined => { - const id = nameToId.get(o); - return id === undefined ? undefined : codeForOrientation.get(id); -}; - -/** The entity region all four `autoplace_controls` arms were captured over. */ -const R = { x0: 1500, y0: 1500, x1: 1756, y1: 1756 }; -const inR = (x: number, y: number): boolean => x >= R.x0 && x < R.x1 && y >= R.y0 && y < R.y1; - -const gameSet = (label: string): Map => { - const c = ore.cases.find((k) => k.label === label); - if (c === undefined) throw new Error(`no case ${label}`); - const m = new Map(); - for (const e of c.cliffs) { - if (e.name !== "cliff-vulcanus" || !inR(e.x, e.y)) continue; - const code = gameCodeOf(e.orientation); - if (code !== undefined) m.set(`${String(e.x)},${String(e.y)}`, code); - } - return m; -}; - -/** Shipping settings, both rejections at the crossing stage (#108). */ -const portSet = (withOre: boolean, cascade = false): Map => - new Map( - makeCliffPlacementFromFields(fields, { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, - tileCollides: (x, y): boolean => VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name), - cellRejects: withOre ? oreRejects : undefined, - rejectAtCrossingStage: true, - rejectionCascades: cascade, - }) - .placedCells(R.x0, R.y0, R.x1, R.y1) - .map((p) => [`${String(p.x)},${String(p.y)}`, p.code] as const), - ); - -const score = (ours: Map, game: Map): Record => { - let matched = 0; - let wrong = 0; - let surplus = 0; - let missing = 0; - for (const [k, c] of ours) { - const t = game.get(k); - if (t === undefined) surplus++; - else if (t === c) matched++; - else wrong++; - } - for (const k of game.keys()) if (!ours.has(k)) missing++; - return { matched, wrong, surplus, missing }; -}; - -describe("the ore rule's remainder, and whether it cascades", () => { - /** - * The lever gives the ore's effect as a SET, so the predicate gets a precision - * and a recall rather than a total to match. It is **exactly right where it - * fires and simply too narrow**: every cell it suppresses is one the game - * suppresses, and it reaches 22 of 31. - * - * Note `appeared` is 0. Removing a resource only ever ADDS cliffs, never - * removes one, which is the one-way property #99 established - re-confirmed - * here on the entity region rather than the blob. - */ - it("scores the ore predicate against the resources-off lever", () => { - const on = gameSet("entity region, resources ON"); - const off = gameSet("entity region, ALL resources OFF"); - const pOn = portSet(true); - const pOff = portSet(false); - - expect(on.size).toBe(861); - expect(off.size).toBe(892); - - const suppressed = [...off.keys()].filter((k) => !on.has(k)); - const appeared = [...on.keys()].filter((k) => !off.has(k)); - const recoded = [...off.keys()].filter((k) => on.has(k) && on.get(k) !== off.get(k)); - expect(suppressed.length).toBe(31); - expect(appeared.length).toBe(0); - expect(recoded.length).toBe(5); - - const oursSuppressed = [...pOff.keys()].filter((k) => !pOn.has(k)); - const truth = new Set(suppressed); - const hit = oursSuppressed.filter((k) => truth.has(k)).length; - expect(oursSuppressed.length).toBe(22); - expect(hit).toBe(22); // precision 1.000 - it never fires on a cell the game kept - expect(hit / suppressed.length).toBeCloseTo(0.7097, 3); - }, 120000); - - /** - * **The crossing stage explains two of the "run remainders" for free.** The - * predicate itself fires on 20 of the placed cells, but the placement loses - * 22 - because zeroing a rejected cell's edges can leave a NEIGHBOUR with a - * code that no longer places. That is not tuning; it falls out of #108's - * mechanism, and it is the first thing to have reduced the remainder count - * since the rule was characterised. - */ - it("the mechanism accounts for 2 remainders the bare predicate does not", () => { - const pOff = portSet(false); - let predicateFires = 0; - for (const [k, code] of pOff) { - const [xs, ys] = k.split(","); - if (oreRejects(code, Number(xs), Number(ys))) predicateFires++; - } - const pOn = portSet(true); - const lost = [...pOff.keys()].filter((k) => !pOn.has(k)).length; - - expect(predicateFires).toBe(20); - expect(lost).toBe(22); - expect(lost - predicateFires).toBe(2); - }, 120000); - - /** - * **The cascade half of the open question, refuted.** Re-testing to a fixpoint - * changes not one cell at the shipping settings, and on the collapsed rule it - * loses 14 matched cells and 7 orientations to gain 4 of the over-placement. - * - * So a rejected cell never turns a neighbour into a rejectable orientation. - * That leaves the "wider box" half of `vulcanusOreRejection.ts`'s question - - * which is the one that must NOT be tuned into fitting, per #88. - */ - it("re-testing to a fixpoint buys nothing at shipping and loses on the collapsed rule", () => { - const on = gameSet("entity region, resources ON"); - const plain = portSet(true); - const cascaded = portSet(true, true); - - // Bit-for-bit identical, not merely equal in total. - expect(cascaded.size).toBe(plain.size); - for (const [k, code] of plain) expect(cascaded.get(k)).toBe(code); - expect(score(plain, on)).toEqual(score(cascaded, on)); - - // And it is not a no-op everywhere - on the collapsed rule it is harmful, - // which is what makes the shipping no-op a real result rather than an - // untriggered branch. - const collapsed = (cascade: boolean): Record => { - let matched = 0; - let wrong = 0; - let surplus = 0; - let missing = 0; - for (const c of sweep.cases) { - const ours: Map = new Map( - makeCliffPlacementFromFields( - { cliffElevation: fields.cliffElevation, cliffiness: (): number => 1 }, - { - elevation0: c.level, - interval: 1000000, - smoothing: 0, - tileCollides: (x, y): boolean => VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name), - cellRejects: oreRejects, - rejectAtCrossingStage: true, - rejectionCascades: cascade, - }, - ) - .placedCells(sweep.region.x0, sweep.region.y0, sweep.region.x1, sweep.region.y1) - .map((p) => [`${String(p.x)},${String(p.y)}`, p.code] as const), - ); - const game = new Map(); - for (const e of c.cliffs) { - if (e.name !== "cliff-vulcanus" || !inR(e.x, e.y)) continue; - const code = gameCodeOf(e.orientation); - if (code !== undefined) game.set(`${String(e.x)},${String(e.y)}`, code); - } - for (const [k, code] of ours) { - const t = game.get(k); - if (t === undefined) surplus++; - else if (t === code) matched++; - else wrong++; - } - for (const k of game.keys()) if (!ours.has(k)) missing++; - } - return { matched, wrong, surplus, missing }; - }; - - expect(collapsed(false)).toEqual({ matched: 18657, wrong: 691, surplus: 1199, missing: 102 }); - expect(collapsed(true)).toEqual({ matched: 18643, wrong: 698, surplus: 1195, missing: 109 }); - }, 900000); - - /** - * **How much of `[1500,1500]`'s residual is ore at all.** Running BOTH sides - * with the resources off answers it: 13 wrong orientations and 10 surplus - * cells survive with the ore entirely out of the picture. So roughly half the - * region's remaining error has nothing to do with the ore rule, and tuning - * that rule cannot reach it. - */ - it("isolates the non-ore residual by running both sides with resources off", () => { - const off = gameSet("entity region, ALL resources OFF"); - const on = gameSet("entity region, resources ON"); - expect(score(portSet(false), off)).toEqual({ - matched: 876, - wrong: 13, - surplus: 10, - missing: 3, - }); - expect(score(portSet(true), on)).toEqual({ matched: 842, wrong: 16, surplus: 19, missing: 3 }); - }, 120000); -}); diff --git a/test/cliffOreDirection.spec.ts b/test/cliffOreDirection.spec.ts index 5c7b7358..f471eeb3 100644 --- a/test/cliffOreDirection.spec.ts +++ b/test/cliffOreDirection.spec.ts @@ -2,16 +2,6 @@ import { describe, expect, it } from "vite-plus/test"; import direction from "./fixtures/oracle-vulcanus-cliff-ore-direction.seed123456.json"; import entities from "./fixtures/oracle-vulcanus-cliff-entities.seed123456.json"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; -import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; const key = (x: number, y: number): string => `${String(x)},${String(y)}`; @@ -200,16 +190,6 @@ describe("the mechanism is not a collision", () => { for (const n of ["big-volcanic-rock", "huge-volcanic-rock", "crater-cliff"]) expect(p[n]?.layers.filter((l) => cliff.has(l)).length).toBeGreaterThan(0); }); - - /** - * **`VULCANUS_CLIFF_BLOCKING_TILES` is measured, not deduced.** The cliff mask - * above is what makes `lava` and `lava-hot` the only Vulcanus tiles that can - * block a cliff, and the constant the renderer ships has to equal that. - */ - it("pins the tile-collision constant to the mask the game reports", () => { - expect([...VULCANUS_CLIFF_BLOCKING_TILES].sort()).toEqual(["lava", "lava-hot"]); - expect(new Set(ON.protos["cliff-vulcanus"]?.layers).has("water_tile")).toBe(true); - }); }); /** @@ -290,32 +270,4 @@ describe("the rejection geometry", () => { for (const k of cliffCells(ON)) if (nbrs(k).some((n) => set.has(n))) keptAdj++; expect(keptAdj).toBe(8); }, 120000); - - /** - * **What porting it is worth.** Every one of the 31 cells the game suppresses - * is a cell the port currently places, so the rule is pure precision: it can - * only remove surplus, and it removes 31 of the 42 the port over-places at - * this region. - */ - it("all 31 are cells the port currently places", () => { - const input = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }; - const fields = makeVulcanusCliffFields(withCtxDefaults(input)); - const tileAt = makeVulcanusTileResolver(input); - const r = ON.region; - const placed = new Set( - makeCliffPlacementFromFields(fields, { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, - tileCollides: (x, y) => VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name), - }) - .placedCells(r.x0, r.y0, r.x1, r.y1) - .map((p) => key(p.x, p.y)), - ); - expect(suppressed.filter((k) => placed.has(k)).length).toBe(31); - // And they are surplus, not matches: none of them is a cliff the game kept. - const game = cliffCells(ON); - expect(suppressed.filter((k) => game.has(k))).toEqual([]); - expect([...placed].filter((k) => !game.has(k)).length).toBe(42); - }, 120000); }); diff --git a/test/cliffOreEffectDecomposition.spec.ts b/test/cliffOreEffectDecomposition.spec.ts deleted file mode 100644 index 4afe1b57..00000000 --- a/test/cliffOreEffectDecomposition.spec.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import fixture from "./fixtures/oracle-vulcanus-cliff-ore-direction.seed123456.json"; -import { CLIFF_GRID_SIZE, CLIFF_ORIENTATION_NAMES } from "../src/noise/cliffs/cliffCatalog"; -import { - connectedSides, - destroyEnd, - isCliffConnected, - oppositeSide, -} from "../src/noise/cliffs/cliffConnections"; - -/** - * **The ore effect is FULLY decomposed: N rejections plus their cascade, and - * nothing else** (#84). No capture - this is a fold of fixtures already on disk, - * and it reframes the open question rather than answering it. - * - * The premise under audit was "the ore suppresses 31 cliffs at `[1500,1500]`", - * which every route and every idea has now failed to explain (#129, #137, #138, - * #140). When that many candidates close, the premise itself is the suspect - - * the lesson of #136. It survives, and comes out sharper than it went in: - * - * - **It is pure SUPPRESSION.** The resources-ON cliff set is a *strict subset* - * of the resources-OFF set in all three arms: **zero** cells are lost when the - * ore is removed. A perturbed field would move cells both ways; only a - * rejection can move them one way, which is the shape test #99 wanted. - * - **It is exactly ADDITIVE.** 27 (calcite only) + 4 (geyser only) = 31 (all - * resources), cell for cell. - * - **The surviving cells' orientation changes are the destruction CASCADE.** - * Take the ore-OFF world, destroy exactly the cells the ore rejects, run the - * port's `destroyEnd` cascade, and you get the ore-ON world **exactly** - - * positions AND orientations, in all three arms. - * - * That last one is the result. It says there is no unexplained *component* of - * the ore effect at all: every difference between the two worlds, including the - * 5 / 4 / 1 orientation changes among cells that survive in both, is accounted - * for by rejection-plus-cascade. - * - * **What that does to the open question.** "What mechanism lets the ore reach a - * cliff?" was the wrong framing to be stuck on: the mechanism is a rejection at - * the apply stage, exactly as #108 and #113 said, and the cascade is its only - * secondary effect. What remains open is narrower and much more tractable - - * **which cells get rejected, and by what criterion.** The shipping rule - * (`makeVulcanusOreRejection`) already reproduces that criterion at precision - * 1.000 but does not reach recall 1, so the gap is a geometry question about - * specific cells, not a missing pathway through the generator. - * - * **It is also a second, independent confirmation of the cascade model.** #139 - * confirmed it against the game with a runtime probe that destroys cliffs - * through Lua; this confirms the same model against ordinary map-generation - * output, through a completely different instrument. The two agree. - * - * **How much of the cascade this fold actually exercises, measured rather than - * assumed.** Planting a `destroyEnd` that refuses to trim one side each: - * - * | planted no-op | this spec | - * | --- | --- | - * | every side | fails 3 | - * | south | fails 3 | - * | west | fails 2 | - * | **north** | **passes** | - * | **east** | **passes** | - * - * So the 31 rejections here only ever trim SOUTH and WEST ends, and this fold - * confirms two of the four directions - it is not a whole-model guard on its - * own. `test/cliffDestroyProbe.spec.ts` is, and covers north (a `side === 0` - * plant fails both of its ON arms). Do not read a green run here as the cascade - * being verified end to end; read the two specs together. - */ - -interface Cliff { - x: number; - y: number; - name: string; - orientation: string; -} -interface Case { - label: string; - cliffs: Cliff[]; -} - -const cases = fixture.cases as unknown as Case[]; -const arm = (label: string): Case => { - const c = cases.find((x) => x.label === label); - if (c === undefined) throw new Error(`no arm ${label}`); - return c; -}; -const ON = "entity region, resources ON"; -const OFF_ARMS = [ - { label: "entity region, ALL resources OFF", extras: 31 }, - { label: "entity region, calcite OFF", extras: 27 }, - { label: "entity region, geyser OFF", extras: 4 }, -]; - -const key = (x: number, y: number): string => `${String(x)},${String(y)}`; -const oi = (name: string): number => CLIFF_ORIENTATION_NAMES.indexOf(name); -/** `pos -> orientation id`, `cliff-vulcanus` only - `crater-cliff` is off-lattice. */ -const cellsOf = (c: Case): Map => - new Map( - c.cliffs - .filter((e) => e.name === "cliff-vulcanus") - .map((e) => [key(e.x, e.y), oi(e.orientation)]), - ); -const SIDE_STEP: readonly (readonly [number, number])[] = [ - [0, -CLIFF_GRID_SIZE], - [CLIFF_GRID_SIZE, 0], - [0, CLIFF_GRID_SIZE], - [-CLIFF_GRID_SIZE, 0], -]; -const parse = (k: string): [number, number] => { - const [x, y] = k.split(","); - return [Number(x), Number(y)]; -}; - -/** `Cliff::onDestroy`'s cascade over the port's model - the same one #139 confirmed. */ -function destroy(cells: Map, x: number, y: number): void { - const mine = cells.get(key(x, y)); - if (mine === undefined) return; - cells.delete(key(x, y)); - for (const side of connectedSides(mine)) { - const step = SIDE_STEP[side]; - if (step === undefined) continue; - const nx = x + step[0]; - const ny = y + step[1]; - const theirs = cells.get(key(nx, ny)); - if (theirs === undefined) continue; - if (!isCliffConnected(side, mine, theirs)) continue; - const next = destroyEnd(theirs, oppositeSide(side)); - if (next === -1) destroy(cells, nx, ny); - else cells.set(key(nx, ny), next); - } -} - -const sorted = (m: Map): [string, number][] => - [...m].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); - -describe("Vulcanus cliffs: the ore effect decomposes into rejections + cascade (#84)", () => { - it("reproduces the studied world - 885 cliff-vulcanus with resources ON", () => { - expect(cellsOf(arm(ON)).size).toBe(885); - }); - - describe("the effect is pure SUPPRESSION, not a perturbed field", () => { - it.each(OFF_ARMS)("loses ZERO cells when the ore is removed: $label", ({ label, extras }) => { - const on = cellsOf(arm(ON)); - const off = cellsOf(arm(label)); - const lost = [...on.keys()].filter((k) => !off.has(k)); - const gained = [...off.keys()].filter((k) => !on.has(k)); - // A field perturbation moves cells BOTH ways; a rejection cannot. This is - // the arm that tells them apart, and it is the direction #99 established - // by switching the resources off in the game. - expect(lost, `${label}: cells present with ore and absent without`).toEqual([]); - expect(gained).toHaveLength(extras); - }); - - it("is exactly additive across the two resources: 27 + 4 = 31", () => { - const on = cellsOf(arm(ON)); - const extrasOf = (label: string): Set => - new Set([...cellsOf(arm(label)).keys()].filter((k) => !on.has(k))); - const all = extrasOf("entity region, ALL resources OFF"); - const calcite = extrasOf("entity region, calcite OFF"); - const geyser = extrasOf("entity region, geyser OFF"); - expect(calcite.size + geyser.size).toBe(all.size); - // Not merely equal counts - the same cells, and the two sets are disjoint. - expect([...calcite].filter((k) => geyser.has(k))).toEqual([]); - expect([...calcite, ...geyser].sort()).toEqual([...all].sort()); - }); - }); - - describe("the ON world IS the OFF world minus the rejected cells, cascaded", () => { - it.each(OFF_ARMS)("reproduces resources-ON exactly from $label", ({ label }) => { - const on = cellsOf(arm(ON)); - const cells = cellsOf(arm(label)); - for (const k of [...cells.keys()].filter((k) => !on.has(k))) { - const [x, y] = parse(k); - destroy(cells, x, y); - } - expect(sorted(cells)).toEqual(sorted(on)); - }); - - it("and the cascade is NOT idle - surviving cells really do change orientation", () => { - // Without this the exact match above would be satisfied by a world where - // the cascade never fired, and the agreement would say nothing about it. - const on = cellsOf(arm(ON)); - const changed = (label: string): number => { - const off = cellsOf(arm(label)); - return [...on].filter(([k, o]) => off.has(k) && off.get(k) !== o).length; - }; - expect(changed("entity region, ALL resources OFF")).toBe(5); - expect(changed("entity region, calcite OFF")).toBe(4); - expect(changed("entity region, geyser OFF")).toBe(1); - }); - }); -}); diff --git a/test/cliffOreExclusion.spec.ts b/test/cliffOreExclusion.spec.ts deleted file mode 100644 index 7b1289eb..00000000 --- a/test/cliffOreExclusion.spec.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import collapsed from "./fixtures/oracle-vulcanus-cliff-collapsed.seed123456.json"; -import corners from "./fixtures/oracle-vulcanus-cliff-corner-fields-entity-regions.seed123456.json"; -import entities from "./fixtures/oracle-vulcanus-cliff-entities.seed123456.json"; -import resources from "./fixtures/oracle-vulcanus-resource-entities.seed123456.json"; -import { CLIFF_ORIENTATION_NAMES, cliffOrientationForCode } from "../src/noise/cliffs/cliffCatalog"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeCliffinessBasic, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; -import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -const seed = 123456; -const input = { seed0: seed, startingPositions: [{ x: 0, y: 0 }] }; -const fields = makeVulcanusCliffFields(withCtxDefaults(input)); -const tileAt = makeVulcanusTileResolver(input); -const lava = (x: number, y: number): boolean => - VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name); -const key = (x: number, y: number): string => `${String(x)},${String(y)}`; - -/** Ore tile coordinates per region, keyed by the region's `x0`. */ -const oreByRegion = new Map>(); -for (const c of resources.cases) - oreByRegion.set( - c.region.x0, - new Set(c.resources.map((p) => key(Math.floor(p.x - 0.5), Math.floor(p.y - 0.5)))), - ); - -/** - * Does the 4x4 placement cell centred at `(x, y)` contain an ore tile? - * - * "Contains" means the tile's CENTRE falls in the cell, which is the only - * definition that stays a symmetric 4x4 block on both axes: the cell centre is - * integral in x and half-integral in y (`CLIFF_CELL_CENTER_*`), so a bound - * written the same way on both axes silently makes the y block three tiles tall. - */ -const cellHasOre = (x: number, y: number, ore: Set): boolean => { - const tx0 = x - 2; - const ty0 = Math.round(y - 2.5); - for (let tx = tx0; tx < tx0 + 4; tx++) - for (let ty = ty0; ty < ty0 + 4; ty++) if (ore.has(key(tx, ty))) return true; - return false; -}; - -const gameCliffs = ( - cliffs: { x: number; y: number; name: string; orientation: string | null }[], - r: { x0: number; y0: number; x1: number; y1: number }, -): Map => { - const m = new Map(); - for (const p of cliffs) - if ( - p.name === "cliff-vulcanus" && - p.x >= r.x0 && - p.x < r.x1 && - p.y >= r.y0 && - p.y < r.y1 && - p.orientation !== null - ) - m.set(key(p.x, p.y), p.orientation); - return m; -}; - -const place = ( - r: { x0: number; y0: number; x1: number; y1: number }, - o: { - elevation0?: number; - interval?: number; - smoothing?: number; - richness?: number; - withLava?: boolean; - } = {}, -): { x: number; y: number; code: number }[] => - makeCliffPlacementFromFields( - { - cliffElevation: fields.cliffElevation, - cliffiness: - o.richness === undefined ? fields.cliffiness : makeCliffinessBasic(seed, o.richness), - }, - { - elevation0: o.elevation0 ?? VULCANUS_CLIFF_ELEVATION_0, - interval: o.interval ?? VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: o.smoothing ?? VULCANUS_CLIFF_SMOOTHING, - tileCollides: o.withLava === false ? undefined : lava, - }, - ).placedCells(r.x0, r.y0, r.x1, r.y1); - -/** - * **The game does not put cliffs on ore, and the port does. That is what is left** - * (#84, and it is #24 rather than a new mechanism). - * - * The handoff into this session named a "blob": a contiguous patch in region - * `[0,0]` where the game places no cliff whatever `cliff_elevation` is routed - * onto it, called the sharpest open lead precisely because a field-independent - * hole has to be a rule the port does not implement. It is one, and the rule is - * ore exclusion - the patch is a **tungsten-ore field**. - * - * What was eliminated first, each by measurement rather than reading: - * - * - **Not lava.** The game's own tiles over `x 160..208, y 124..176` (2,597 - * `surface.get_tile` samples, 0 lookup misses) are `volcanic-cracks-*` and - * `volcanic-smooth-stone*` through the whole blob, with no lava at all. The - * previous "not lava" claim came from OUR resolver, which is the thing that - * was under suspicion. - * - **Not the cliffiness gate.** See the gate test below: zero flips. - * - **Not tile collision of any other kind.** Read off a running game rather - * than inferred: `cliff-vulcanus`'s mask is - * `cliff, is_lower_object, is_object, item, meltable, object, player, - * water_tile`, and of the 18 Vulcanus tiles only `lava` and `lava-hot` share - * a layer with it. `VULCANUS_CLIFF_BLOCKING_TILES` is therefore now measured, - * not deduced from `tile_collision_masks.lava()`. - * - **Not entity collision either**, which is the interesting part. Cliffs DO - * get a second collision test the port does not implement: - * `EntityMapGenerationTask::applyCliffs` (`0x101623c98`) re-tests every - * accepted cliff through `Surface::wouldCollide` (`0x10160c088`), which calls - * `constCollideWithTile` **and** `collideWithEntity`. But ore cannot be what - * that rejects: `tungsten-ore`, `calcite`, `coal` and `sulfuric-acid-geyser` - * all carry the bare `resource` layer, which the cliff mask does not hold. - * (`big-volcanic-rock`, `huge-volcanic-rock` and `crater-cliff` DO collide - - * an unported rule, but only two rocks touch the blob.) - * - * So the exclusion is real and its mechanism is still open. It is not a - * collision, so it is either an ordering effect inside the generation task or - * something in `resource_autoplace` that the cliff pass reads. - */ -describe("Vulcanus cliffs and ore are near-disjoint in the game, and not in the port", () => { - it("the game places essentially no cliff on ore", () => { - const counts: number[] = []; - for (const c of entities.cases) { - const ore = oreByRegion.get(c.region.x0); - expect(ore).toBeDefined(); - const ok = ore as Set; - // Non-vacuity: there IS ore in each region, so "no cliff on ore" is a - // real constraint and not an empty set trivially satisfying it. - expect(ok.size).toBeGreaterThan(900); - const cliffs = [...gameCliffs(c.cliffs, c.region).keys()]; - expect(cliffs.length).toBeGreaterThan(280); - counts.push( - cliffs.filter((k) => { - const [x, y] = k.split(","); - return cellHasOre(Number(x), Number(y), ok); - }).length, - ); - } - // 0 / 283, 3 / 885 and 0 / 401. Pinned exactly: three exceptions is the - // evidence that this is a strong tendency rather than a hard invariant, and - // a change in that number means the mechanism has changed. - expect(counts).toEqual([0, 3, 0]); - }, 120000); - - /** - * The other half, and the one that costs accuracy: **most of what the port - * over-places at `[1500,1500]` is on ore.** 26 of its 42 surplus cells sit on - * an ore tile, against 3 in the game's entire 1,569-cliff population. - */ - it("and MOST of the port's surplus at [1500,1500] is on ore", () => { - const c = entities.cases.find((k) => k.region.x0 === 1500); - expect(c).toBeDefined(); - const r = (c as NonNullable).region; - const ore = oreByRegion.get(1500) as Set; - const game = gameCliffs((c as NonNullable).cliffs, r); - const extra = place(r) - .map((p) => ({ k: key(p.x, p.y), x: p.x, y: p.y })) - .filter((p) => !game.has(p.k)); - expect(extra.length).toBe(42); - expect(extra.filter((p) => cellHasOre(p.x, p.y, ore)).length).toBe(26); - }, 120000); - - /** - * **The blob itself, pinned by shape.** With the rule collapsed - a single - * contour, the gate forced open, smoothing off - the port places ten cells the - * game does not, and it is the SAME ten in all four collapsed arms whatever - * the band structure or the gate does. All ten sit on ore. - * - * The handoff quoted a looser envelope (`cx 43-48, cy 34-40`, world - * `x 172-196, y 136-164`); that is the union over the 19-level `cliff_elevation_0` - * sweep. The arm-invariant core is these ten. - */ - it("the collapsed arms place the same ten cells the game does not, all on ore", () => { - const r = collapsed.region; - const ore = oreByRegion.get(0) as Set; - const perArm: string[][] = []; - for (const arm of collapsed.cases) { - const game = gameCliffs(arm.cliffs, r); - const extra = place(r, { - elevation0: arm.effective.cliff_elevation_0, - interval: arm.effective.cliff_elevation_interval, - smoothing: arm.effective.cliff_smoothing, - richness: arm.effective.richness, - }) - .map((p) => key(p.x, p.y)) - .filter((k) => !game.has(k)); - perArm.push(extra.sort()); - } - const blob = [ - "178,138.5", - "178,142.5", - "178,146.5", - "178,150.5", - "182,138.5", - "182,142.5", - "182,146.5", - "182,150.5", - "186,138.5", - "186,142.5", - ].sort(); - // Identical in every arm: the gate is open in two of them and real in the - // other two, and the bands differ, so this cannot be a field or gate effect. - for (const arm of perArm) expect(arm).toEqual(blob); - for (const k of blob) { - const [x, y] = k.split(","); - expect(cellHasOre(Number(x), Number(y), ore)).toBe(true); - } - }, 120000); -}); - -/** - * **The cliffiness gate is exact, and this measures the BINARY the gate reads.** - * - * `cliffiness_basic` is `clamp(qmn, 0, 1) + 0.5` and two thirds of its captured - * corners sit ON a clamp, so comparing its VALUE there says only that both sides - * clamped - the vacuity that #84 recorded. What the consumer actually reads is - * `crossesCliff`'s gate, which is the strict comparison - * `(cliffiness(p) + cliffiness(q)) / 2 > 0.5` (`0x10160c914`, and - * `crossingsForChunk` averages the two corners at `0x10160d1cc`). That is a - * threshold, so a clamped corner is not vacuous for it at all: it is exactly the - * place where an arbitrarily small error flips the answer. - * - * Scored as a boolean over every captured edge of all three regions, the port - * and the game agree on all 24,960 - with both outcomes well represented, so a - * constant-true predicate could not pass. - */ -describe("the cliffiness GATE, not its value", () => { - it("agrees with the game on every captured edge", () => { - const game = new Map(); - for (let n = 0; n < corners.corners.length; n++) - game.set(corners.corners[n] as string, corners.cliffiness[n] as number); - - let checked = 0; - let open = 0; - let flips = 0; - let onClamp = 0; - for (const k of game.keys()) { - const [is, js] = k.split(","); - const i = Number(is); - const j = Number(js); - if (game.get(k) === 0.5) onClamp++; - for (const [di, dj] of [ - [1, 0], - [0, 1], - ] as const) { - const g2 = game.get(key(i + di, j + dj)); - if (g2 === undefined) continue; - checked++; - const gameOpen = (game.get(k) as number) + g2 > 1; - const oursOpen = - fields.cliffiness(i * 4, j * 4) + fields.cliffiness((i + di) * 4, (j + dj) * 4) > 1; - if (gameOpen) open++; - if (gameOpen !== oursOpen) flips++; - } - } - expect(checked).toBe(24960); - expect(flips).toBe(0); - // Non-vacuity: both outcomes are common, and the clamp floor - the case the - // value comparison could not speak to - is half the corners. - expect(open).toBe(13661); - expect(checked - open).toBe(11299); - expect(onClamp).toBe(6330); - }, 120000); -}); - -/** - * **The shipping accuracy, with the lava rejection the renderer actually - * applies.** #84's headline - "37 of 1531 matched cells carry a wrong - * orientation" - is the measurement taken WITHOUT that rejection, which is how - * the issue was originally written and which `renderVulcanusCliffs` does not do. - * Both arms are pinned here so the two numbers can never be confused again. - */ -describe("Vulcanus cliff accuracy, both arms", () => { - const scoreRegion = ( - c: (typeof entities.cases)[number], - withLava: boolean, - ): { matched: number; oriWrong: number; oursOnly: number; missed: number } => { - const game = gameCliffs(c.cliffs, c.region); - const placed = place(c.region, { withLava }); - let matched = 0; - let oriWrong = 0; - const ours = new Set(); - for (const p of placed) { - const k = key(p.x, p.y); - ours.add(k); - const want = game.get(k); - if (want === undefined) continue; - matched++; - if (CLIFF_ORIENTATION_NAMES[cliffOrientationForCode(p.code) as number] !== want) oriWrong++; - } - let missed = 0; - for (const k of game.keys()) if (!ours.has(k)) missed++; - return { matched, oriWrong, oursOnly: ours.size - matched, missed }; - }; - - it("WITH the lava rejection: 33 wrong of 1525 matched", () => { - const all = entities.cases.map((c) => scoreRegion(c, true)); - expect(all.map((s) => s.oriWrong)).toEqual([5, 25, 3]); - expect(all.map((s) => s.oursOnly)).toEqual([2, 42, 1]); - expect(all.map((s) => s.missed)).toEqual([2, 3, 1]); - expect(all.reduce((n, s) => n + s.matched, 0)).toBe(1525); - }, 120000); - - it("WITHOUT it: the 37 / 1531 the issue quotes - recall is perfect, precision is not", () => { - const all = entities.cases.map((c) => scoreRegion(c, false)); - expect(all.map((s) => s.oriWrong)).toEqual([7, 26, 4]); - // Nothing the game placed is ever missed without the rejection, so every - // one of the 37 is an orientation error on a cell we agree exists. - expect(all.map((s) => s.missed)).toEqual([0, 0, 0]); - expect(all.reduce((n, s) => n + s.matched, 0)).toBe(1531); - expect(all.reduce((n, s) => n + s.oriWrong, 0)).toBe(37); - }, 120000); -}); diff --git a/test/cliffOreLeverOutOfSample.spec.ts b/test/cliffOreLeverOutOfSample.spec.ts deleted file mode 100644 index 99f8f582..00000000 --- a/test/cliffOreLeverOutOfSample.spec.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import regions from "./fixtures/oracle-vulcanus-cliff-ore-direction-regions.seed123456.json"; -import { CLIFF_CODE_TO_ORIENTATION } from "../src/noise/cliffs/cliffCatalog"; -import { cliffCodeForOrientation } from "../src/noise/cliffs/cliffConnections"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { makeVulcanusOreRejection } from "../src/noise/cliffs/vulcanusOreRejection"; -import { buildResources } from "../src/noise/preview/renderVulcanusResources"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -/** - * **The ore rule, tested OUT OF SAMPLE for the first time - and precision 1.000 - * survives** (#84). - * - * Everything known about the ore -> cliff rule was measured on `[1500,1500]`, - * because that is the only region `oracle-vulcanus-cliff-ore-direction` re-runs - * with the resources off. Three merged results rest on that one region: #123's - * split of the 25 missed destructions into 11 ore and 11 unknown, #125's finding - * that the `onDestroy` cascade closes 4 of the 10 remainders, and precision - * 1.000. **A rule characterised on one region and never tested on another is - * fitted until proven otherwise.** - * - * `oracle-vulcanus-cliff-ore-direction-regions` adds the paired ON / - * ALL-resources-OFF arms for the two regions the entities fixture covers and the - * lever never did, at real cliff settings. - * - * | region | resources present | cliffs ON | cliffs OFF | suppressed | - * | --- | --- | --- | --- | --- | - * | `[0,0]` | 945 tungsten-ore | 283 | 283 | **0** | - * | `[-1200,800]` | 1047 coal | 387 | 387 | **0** | - * - * Two things follow, and one of them changes a merged count. - * - * **Precision holds.** Our predicate fires on **zero** cells in both regions, so - * 1992 resource entities across two fresh regions produce no false positive. The - * rule is not merely right on the region it was built from. - * - * **The three "undetermined" missed destructions are NOT ore.** #123 could only - * say that the lever's region did not cover `106,26.5`, `90,38.5` and - * `-1050,1022.5`. It does now, and the ore suppresses nothing there, so those - * three join the unexplained population: **11 ore, 14 unknown**, not 11/11/3. - * - * It also re-confirms #110's per-control attribution - 27 calcite, 4 geyser, - * **0 tungsten and coal** - at a far larger scale than the arm that produced it. - * - * **The non-vacuity check is in the fixture rather than argued.** "0 suppressed" - * is also what a lever that never reached the generator would print. The OFF - * arms read back **0** resources against 945 and 1047, so the override provably - * applied. - * - * What this does NOT establish: that the rule would hold on a region containing - * calcite or geysers other than `[1500,1500]`. Neither of these two has any, so - * the rule's POSITIVE evidence is still one region. This is a precision test, - * not a recall test. - */ - -const INPUT = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }; -const ctx = withCtxDefaults(INPUT); -const fields = makeVulcanusCliffFields(ctx); -const resources = buildResources(ctx); -const oreRejects = makeVulcanusOreRejection(resources, ctx.vulcanusResourceControls); -const K = (x: number, y: number): string => `${String(x)},${String(y)}`; -const BANDS = { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, -}; - -interface Ent { - x: number; - y: number; - name: string; - orientation?: string; -} -interface Region { - x0: number; - y0: number; - x1: number; - y1: number; -} -const cases = regions.cases as unknown as { - label: string; - region: Region; - cliffs: Ent[]; - resources: Ent[]; -}[]; - -interface Pair { - name: string; - region: Region; - on: Set; - off: Set; - resourceCounts: Record; - offResourceCount: number; - ourFires: number; -} - -const PAIRS: Pair[] = (() => { - const out: Pair[] = []; - for (let i = 0; i < cases.length; i += 2) { - const on = cases[i]; - const off = cases[i + 1]; - const r = on.region; - const inR = (p: { x: number; y: number }): boolean => - p.x >= r.x0 && p.x < r.x1 && p.y >= r.y0 && p.y < r.y1; - const set = (c: (typeof cases)[number]): Set => - new Set( - c.cliffs.filter((e) => e.name === "cliff-vulcanus" && inR(e)).map((e) => K(e.x, e.y)), - ); - const counts: Record = {}; - for (const q of on.resources) counts[q.name] = (counts[q.name] ?? 0) + 1; - - let fires = 0; - for (const p of makeCliffPlacementFromFields(fields, BANDS) - .placedCells(r.x0 - 64, r.y0 - 64, r.x1 + 64, r.y1 + 64) - .filter(inR)) { - const o = CLIFF_CODE_TO_ORIENTATION[p.code]; - if (o !== undefined && oreRejects(cliffCodeForOrientation(o), p.x, p.y)) fires++; - } - out.push({ - name: on.label.replace(", resources ON", ""), - region: r, - on: set(on), - off: set(off), - resourceCounts: counts, - offResourceCount: off.resources.length, - ourFires: fires, - }); - } - return out; -})(); - -describe("the ore lever on the two regions it had never covered", () => { - /** - * **The non-vacuity arm, first, because "0 suppressed" is also what a lever - * that never reached the generator prints.** The OFF arms read back zero - * resources against 945 and 1047 - so the `autoplace_controls` override did - * apply, and the zero below is a measurement. - */ - it("proves the override reached the generator", () => { - expect(PAIRS.map((p) => p.name)).toEqual(["[0,0]", "[-1200,800]"]); - expect(PAIRS.map((p) => p.resourceCounts)).toEqual([{ "tungsten-ore": 945 }, { coal: 1047 }]); - expect(PAIRS.map((p) => p.offResourceCount)).toEqual([0, 0]); - }); - - /** - * **Zero cliffs move in either region**, in either direction - which also - * re-confirms #99's one-way property and #110's attribution of the 31 to - * calcite and geyser with nothing from tungsten or coal, now against 1992 - * resource entities rather than the handful that arm carried. - */ - it("finds the ore suppresses nothing outside [1500,1500]", () => { - for (const p of PAIRS) { - expect([...p.off].filter((k) => !p.on.has(k))).toEqual([]); - expect([...p.on].filter((k) => !p.off.has(k))).toEqual([]); - } - expect(PAIRS.map((p) => p.on.size)).toEqual([283, 387]); - expect(PAIRS.map((p) => p.off.size)).toEqual([283, 387]); - }, 300000); - - /** - * **Precision 1.000 survives out of sample.** Our predicate fires on zero - * cells in both regions, so it invents no rejection where the game has none. - * This is the arm that would have caught a rule fitted to `[1500,1500]`. - */ - it("fires on no cell in either region", () => { - expect(PAIRS.map((p) => p.ourFires)).toEqual([0, 0]); - }, 300000); - - /** - * **The three missed destructions #123 had to leave undetermined are not - * ore.** They sit in these two regions, and the lever now covers them, so the - * unexplained population is **14**, not 11 with 3 unknown-status. - */ - it("resolves the three cells #123 could not attribute", () => { - const undetermined = ["106,26.5", "90,38.5", "-1050,1022.5"]; - for (const k of undetermined) { - const p = PAIRS.find((q) => { - const [xs, ys] = k.split(","); - const x = Number(xs); - const y = Number(ys); - return x >= q.region.x0 && x < q.region.x1 && y >= q.region.y0 && y < q.region.y1; - }); - expect(p).toBeDefined(); - // Absent with the resources ON and absent with them OFF: the game destroys - // it either way, so no resource is responsible. - expect(p?.on.has(k)).toBe(false); - expect(p?.off.has(k)).toBe(false); - } - }, 300000); -}); diff --git a/test/cliffOreMechanismClosure.spec.ts b/test/cliffOreMechanismClosure.spec.ts index 96e1c93e..d86a3419 100644 --- a/test/cliffOreMechanismClosure.spec.ts +++ b/test/cliffOreMechanismClosure.spec.ts @@ -1,14 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import richness from "./fixtures/oracle-vulcanus-cliff-ore-richness.seed123456.json"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { DEFAULT_VULCANUS_RESOURCE_CONTROLS, withCtxDefaults } from "../src/noise/eval/ctx"; /** * **Every route from a resource control to a cliff is now closed, and the effect @@ -45,14 +37,6 @@ import { DEFAULT_VULCANUS_RESOURCE_CONTROLS, withCtxDefaults } from "../src/nois * covers - not to re-test one of them. */ -const BANDS = { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, -}; -const BASE = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }; -const OFF = { frequency: 1, size: 0 }; - interface Ent { x: number; y: number; @@ -141,41 +125,3 @@ describe("richness moves the settings but not the world - and not the cliffs", ( expect(cliffKeys(arm(label))).toEqual(base); }); }); - -describe("the cliff field cannot see the resources", () => { - /** - * Our port's raw queue - crossings plus the repair pass, before any rejection - - * is **bit-identical** under every arm of the lever: same cells, same codes. - * The game's own expression graph says the same thing independently: - * `cliff_elevation = cliff_elevation_from_elevation = elevation = - * vulcanus_elevation = max(-500, vulcanus_elev)`, and walking that expression's - * full transitive closure reaches no `*_region` belonging to any resource. - * - * This is the arm that rules out "the lever moves the contour" - which would - * otherwise be the obvious explanation for cells appearing when the ore is - * switched off. - */ - it("produces an identical raw cell set under every lever arm", () => { - const cells = (controls?: typeof DEFAULT_VULCANUS_RESOURCE_CONTROLS): string[] => { - const ctx = withCtxDefaults( - controls === undefined ? BASE : { ...BASE, vulcanusResourceControls: controls }, - ); - return makeCliffPlacementFromFields(makeVulcanusCliffFields(ctx), BANDS) - .placedCells(REGION.x0 - 64, REGION.y0 - 64, REGION.x1 + 64, REGION.y1 + 64) - .map((p) => `${String(p.x)},${String(p.y)}:${String(p.code)}`) - .sort((a, b) => a.localeCompare(b)); - }; - const on = cells(); - expect(on.length).toBe(2277); - expect(cells({ ...DEFAULT_VULCANUS_RESOURCE_CONTROLS, calcite: OFF })).toEqual(on); - expect(cells({ ...DEFAULT_VULCANUS_RESOURCE_CONTROLS, sulfuricAcidGeyser: OFF })).toEqual(on); - expect( - cells({ - tungstenOre: OFF, - vulcanusCoal: OFF, - calcite: OFF, - sulfuricAcidGeyser: OFF, - }), - ).toEqual(on); - }, 300000); -}); diff --git a/test/cliffOreRecallGap.spec.ts b/test/cliffOreRecallGap.spec.ts deleted file mode 100644 index d5944ad7..00000000 --- a/test/cliffOreRecallGap.spec.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import fixture from "./fixtures/oracle-vulcanus-cliff-ore-direction.seed123456.json"; -import { - CLIFF_GRID_SIZE, - CLIFF_ORIENTATION_COLLISION_BOX, - CLIFF_ORIENTATION_NAMES, -} from "../src/noise/cliffs/cliffCatalog"; -import { - connectedSides, - destroyEnd, - isCliffConnected, - oppositeSide, -} from "../src/noise/cliffs/cliffConnections"; -import { - VULCANUS_CLIFF_BASE_COLLISION_BOX, - VULCANUS_GEYSER_COLLISION_HALF, - VULCANUS_ORE_COLLISION_HALF, -} from "../src/noise/cliffs/vulcanusOreRejection"; - -/** - * **The ore recall gap is SIX cells, not thirty-one** (#84). The direct - * follow-on from #141, and it needs no capture either. - * - * #141 showed the ore effect is exactly *rejections plus their cascade*, which - * turned the open question from "what pathway reaches a cliff" into "which cells - * get rejected". This measures that gap against the game's **real** resource - * entities rather than the port's footprint model, so a shortfall in the rule's - * geometry cannot be confused with a shortfall in where the port puts the ore. - * - * | | cells | - * | --- | --- | - * | the ore provably rejects | **31** | - * | explained by base-box overlap with a real resource | **21** | - * | additionally removed as CASCADE casualties of those 21 | **4** | - * | still unexplained | **6** | - * - * **Four of the ten apparent misses were never geometry failures at all.** They - * are single-ended cliffs (`X-to-none` / `none-to-X`) whose one end is trimmed - * when a neighbour is rejected, so the cascade force-destroys them. Counting - * them as recall misses - which is what "the rule explains 21 of 31" does - - * charges the rule for cells no rejection rule should ever have to name. This is - * the same shape as #114/#115's "count the defect at the RULE, not the output". - * - * **And the remaining 6 are NOT near-misses, which is the load-bearing - * negative.** Measured as the factor the base box's half-extents would need to - * be scaled by to reach the nearest resource - distance is the wrong metric, - * since the box is asymmetric (0.988 x 0.488) - five of the six need **1.42x to - * 3.28x**. So **no plausible widening of the box fixes this**, and anyone - * arriving here should not try: #110 already measured that the higher-catching - * variant LOSES, because a wider box buys true cells at the price of false - * rejections elsewhere. - * - * The sixth sits at 1.11x, and it is exactly the geyser cell the per-orientation - * `rotbb` box catches - so the one marginal case is accounted for rather than - * waved at. That box catching it is a fact about the variant, not a fix; the - * same #110 result applies, and it leaves five. - * - * **Zero over-removal is what makes the 21 trustworthy.** Destroying only those - * 21 and cascading removes nothing the game kept. The rule is still pure - * precision; it is recall that is short, and now by six. - */ - -interface Cliff { - x: number; - y: number; - name: string; - orientation: string; -} -interface Res { - x: number; - y: number; - name: string; -} -interface Box { - lx: number; - ly: number; - rx: number; - ry: number; -} -interface Case { - label: string; - cliffs: Cliff[]; - resources?: Res[]; - protos?: Record; -} - -const cases = fixture.cases as unknown as Case[]; -const arm = (label: string): Case => { - const c = cases.find((x) => x.label === label); - if (c === undefined) throw new Error(`no arm ${label}`); - return c; -}; -const ON = arm("entity region, resources ON"); -const OFF = arm("entity region, ALL resources OFF"); - -const key = (x: number, y: number): string => `${String(x)},${String(y)}`; -const oi = (n: string): number => CLIFF_ORIENTATION_NAMES.indexOf(n); -const cellsOf = (c: Case): Map => - new Map( - c.cliffs - .filter((e) => e.name === "cliff-vulcanus") - .map((e) => [key(e.x, e.y), oi(e.orientation)]), - ); -const parse = (k: string): [number, number] => { - const [x, y] = k.split(","); - return [Number(x), Number(y)]; -}; -const SIDE_STEP: readonly (readonly [number, number])[] = [ - [0, -CLIFF_GRID_SIZE], - [CLIFF_GRID_SIZE, 0], - [0, CLIFF_GRID_SIZE], - [-CLIFF_GRID_SIZE, 0], -]; - -const onCells = cellsOf(ON); -const offCells = cellsOf(OFF); -/** The cells the ore provably rejects: present without ore, absent with it. */ -const REJECTED = [...offCells.keys()].filter((k) => !onCells.has(k)); -const RESOURCES = ON.resources ?? []; -const halfOf = (name: string): number => - name === "sulfuric-acid-geyser" ? VULCANUS_GEYSER_COLLISION_HALF : VULCANUS_ORE_COLLISION_HALF; - -/** Strict overlap of a cliff rectangle at `(cx, cy)` with a resource's square. */ -function overlaps( - cx: number, - cy: number, - box: readonly [number, number, number, number], - r: Res, -): boolean { - const h = halfOf(r.name); - const dx = r.x - cx; - const dy = r.y - cy; - return dx > box[0] - h && dx < box[2] + h && dy > box[1] - h && dy < box[3] + h; -} -const nearby = (cx: number, cy: number): Res[] => - RESOURCES.filter((r) => Math.abs(r.x - cx) <= 8 && Math.abs(r.y - cy) <= 8); - -const BASE = VULCANUS_CLIFF_BASE_COLLISION_BOX; -const directHits = REJECTED.filter((k) => { - const [x, y] = parse(k); - return nearby(x, y).some((r) => overlaps(x, y, BASE, r)); -}); - -function destroy(cells: Map, x: number, y: number): void { - const mine = cells.get(key(x, y)); - if (mine === undefined) return; - cells.delete(key(x, y)); - for (const side of connectedSides(mine)) { - const step = SIDE_STEP[side]; - if (step === undefined) continue; - const nx = x + step[0]; - const ny = y + step[1]; - const theirs = cells.get(key(nx, ny)); - if (theirs === undefined) continue; - if (!isCliffConnected(side, mine, theirs)) continue; - const next = destroyEnd(theirs, oppositeSide(side)); - if (next === -1) destroy(cells, nx, ny); - else cells.set(key(nx, ny), next); - } -} - -/** The world after destroying only the direct hits, cascaded. */ -function afterDirectOnly(): Map { - const cells = cellsOf(OFF); - for (const k of directHits) { - const [x, y] = parse(k); - destroy(cells, x, y); - } - return cells; -} - -describe("Vulcanus cliffs: the ore recall gap is SIX cells (#84)", () => { - it("starts from the 31 cells the ore provably rejects", () => { - expect(REJECTED).toHaveLength(31); - expect(RESOURCES.length).toBeGreaterThan(0); - }); - - it("explains 21 by base-box overlap with the game's REAL resource entities", () => { - // Against the dumped entities, not the port's footprint model - so a gap in - // the rule's geometry is never confused with a gap in where the port thinks - // the ore is. - expect(directHits).toHaveLength(21); - }); - - describe("four of the ten apparent misses are CASCADE casualties, not geometry failures", () => { - it("removes 25 of the 31 from the direct hits alone, and over-removes NOTHING", () => { - const cells = afterDirectOnly(); - const missing = [...onCells.keys()].filter((k) => !cells.has(k)); - const surplus = [...cells.keys()].filter((k) => !onCells.has(k)); - // Zero over-removal is what keeps the rule pure-precision: destroying the - // 21 and cascading never removes a cell the game kept. - expect(missing, "cells we removed that the game kept").toEqual([]); - expect(surplus).toHaveLength(6); - expect(REJECTED.length - surplus.length).toBe(25); - }); - - it("and the four are single-ended cliffs, which is WHY the cascade takes them", () => { - const cells = afterDirectOnly(); - const cascaded = REJECTED.filter((k) => !directHits.includes(k) && !cells.has(k)); - expect(cascaded).toHaveLength(4); - for (const k of cascaded) { - const ends = CLIFF_ORIENTATION_NAMES[offCells.get(k) ?? -1] ?? ""; - expect(ends, `${k} should be single-ended`).toContain("none"); - } - }); - }); - - describe("the remaining six are NOT near-misses - do not widen the box", () => { - it("would need the box GROWN by 42% to 228% to reach five of them", () => { - // Distance is the wrong metric - the box is asymmetric (0.988 x 0.488), so - // a cell can be close in Chebyshev terms and far outside it. The honest - // measure is the factor the box's half-extents would have to be scaled by - // to reach the nearest resource: below 1 it already overlaps. - const growth = (k: string): number => { - const [x, y] = parse(k); - return Math.min( - ...nearby(x, y).map((r) => { - const h = halfOf(r.name); - return Math.max((Math.abs(r.x - x) - h) / BASE[2], (Math.abs(r.y - y) - h) / BASE[3]); - }), - ); - }; - const cells = afterDirectOnly(); - const surplus = [...cells.keys()].filter((k) => !onCells.has(k)); - const factors = surplus.map(growth).sort((a, b) => a - b); - // Five of the six need 1.42x to 3.28x. No plausible box correction gets - // there, and #110 measured what chasing it costs. - expect(factors.filter((f) => f >= 1.4)).toHaveLength(5); - expect(Math.max(...factors)).toBeGreaterThan(3); - // The sixth sits at 1.11x, and it is precisely the geyser cell the - // per-orientation box catches below - so the one marginal case is - // accounted for rather than waved at. - expect(factors[0]).toBeGreaterThan(1.1); - expect(factors[0]).toBeLessThan(1.2); - }); - - it("is not rescued by the per-orientation rotbb box either - it catches ONE", () => { - // A fact about that variant, not a fix. #110 measured the higher-catching - // variant as LOSING overall, because a wider box buys true cells at the - // price of false rejections elsewhere. - const cells = afterDirectOnly(); - const surplus = [...cells.keys()].filter((k) => !onCells.has(k)); - const caught = surplus.filter((k) => { - const [x, y] = parse(k); - const id = offCells.get(k); - if (id === undefined) return false; - const box = CLIFF_ORIENTATION_COLLISION_BOX[id]; - return nearby(x, y).some((r) => overlaps(x, y, box, r)); - }); - expect(caught).toHaveLength(1); - }); - }); -}); diff --git a/test/cliffOreRejection.spec.ts b/test/cliffOreRejection.spec.ts deleted file mode 100644 index 09cf6f0d..00000000 --- a/test/cliffOreRejection.spec.ts +++ /dev/null @@ -1,376 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import entities from "./fixtures/oracle-vulcanus-cliff-entities.seed123456.json"; -import direction from "./fixtures/oracle-vulcanus-cliff-ore-direction.seed123456.json"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { - VULCANUS_CLIFF_BASE_COLLISION_BOX, - VULCANUS_ORE_COLLISION_HALF, - makeVulcanusOreRejection, -} from "../src/noise/cliffs/vulcanusOreRejection"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; -import { - buildResources, - geyserPlacementFrom, - renderVulcanusResources, -} from "../src/noise/preview/renderVulcanusResources"; -import { makeVulcanusOreFootprint } from "../src/noise/resources/vulcanusResourceCatalog"; -import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -const key = (x: number, y: number): string => `${String(x)},${String(y)}`; - -interface Ent { - x: number; - y: number; - name: string; -} -interface Region { - x0: number; - y0: number; - x1: number; - y1: number; -} -interface Case { - region: Region; - cliffs: Ent[]; -} -interface Arm { - label: string; - region: Region; - cliffs: Ent[]; - resources: Ent[]; -} - -const INPUT = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }; -const ctx = withCtxDefaults(INPUT); -const resources = buildResources(ctx); -const geyserAt = geyserPlacementFrom(ctx, resources); -const cliffFields = makeVulcanusCliffFields(ctx); -const tileAt = makeVulcanusTileResolver(INPUT); - -const gameCells = (cliffs: Ent[]): Set => - new Set(cliffs.filter((e) => e.name === "cliff-vulcanus").map((e) => key(e.x, e.y))); - -/** The port's placed cells with the lava rejection but WITHOUT the ore rule. */ -const placedWithoutOreRule = (r: Region): { x: number; y: number; code: number }[] => - makeCliffPlacementFromFields(cliffFields, { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, - tileCollides: (x, y) => VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name), - }).placedCells(r.x0, r.y0, r.x1, r.y1); - -const regionCase = (x0: number): Case => { - const c = (entities.cases as unknown as Case[]).find((k) => k.region.x0 === x0); - if (c === undefined) throw new Error(`no region ${String(x0)}`); - return c; -}; - -/** - * **The ORE -> CLIFF rejection as the renderer actually runs it.** - * - * `#99` settled the direction and characterised the rule, then handed over one - * explicitly open sub-question: it scored the geometry against the GAME's own - * resource entities, read out of a fixture, and noted that whether driving the - * rejection from the port's own resource model is accurate enough was - * "deliberately not attempted here". - * - * That is what this file measures. Every score below drives - * `makeVulcanusOreRejection` off `buildResources` - the same field stack - * `renderVulcanusResources` paints from - so it is the shipped predicate being - * scored, not an idealised one. - */ -describe("the ported ore rejection, driven by the port's own resource model", () => { - /** - * **The headline, and the gate.** The rule may only ever cost precision. A - * cell it removes that the game KEPT is a false rejection and costs recall, - * which is currently 1.000/0.973/0.965 and is the expensive half of this port. - * - * Across all three oracle regions the shipped variant raises **zero** false - * rejections, and at `[1500,1500]` it removes 20 of the 42 surplus cells - a - * 48% cut in over-placement for no recall at all. - */ - it("removes 20 surplus cells at [1500,1500] and never a cliff the game kept", () => { - const reject = makeVulcanusOreRejection(resources, ctx.vulcanusResourceControls); - const scores = (entities.cases as unknown as Case[]).map((c) => { - const game = gameCells(c.cliffs); - const placed = placedWithoutOreRule(c.region); - const fired = placed.filter((p) => reject(p.code, p.x, p.y)); - return { - at: key(c.region.x0, c.region.y0), - game: game.size, - placed: placed.length, - fired: fired.length, - falseRejections: fired.filter((p) => game.has(key(p.x, p.y))).length, - surplusBefore: placed.filter((p) => !game.has(key(p.x, p.y))).length, - }; - }); - - expect(scores).toEqual([ - { at: "0,0", game: 283, placed: 283, fired: 0, falseRejections: 0, surplusBefore: 2 }, - { - at: "1500,1500", - game: 885, - placed: 900, - fired: 20, - falseRejections: 0, - surplusBefore: 42, - }, - { - at: "-1200,800", - game: 401, - placed: 387, - fired: 0, - falseRejections: 0, - surplusBefore: 1, - }, - ]); - - // Every cell it fires on is surplus, so surplus falls by exactly the fired - // count: 42 -> 22. Precision at [1500,1500] goes 858/900 = 0.953 to - // 858/880 = 0.975, with the 858 true positives untouched. - const heavy = scores[1]; - expect(heavy.surplusBefore - heavy.fired).toBe(22); - }, 120000); - - /** - * **The two regions where it fires nothing are a result, not a blank.** Both - * `[0,0]` and `[-1200,800]` have ore, and the port places cliffs across both; - * the rule simply finds no overlap there. That is consistent with `#94`'s - * finding that at real settings the port places nothing in the `[0,0]` blob at - * all - the blob is only reachable when a sweep forces a contour through the - * ore field - and it is why the rule's whole measurable value sits at - * `[1500,1500]`. - */ - it("fires nowhere at the two regions whose surplus is already 1-2 cells", () => { - for (const x0 of [0, -1200]) { - const c = regionCase(x0); - const placed = placedWithoutOreRule(c.region); - expect(placed.length).toBeGreaterThan(280); - const surplus = placed.filter((p) => !gameCells(c.cliffs).has(key(p.x, p.y))).length; - expect(surplus).toBeLessThanOrEqual(2); - } - }, 120000); -}); - -/** - * **Why the shipped variant is the one it is** - three defaults, each a - * measurement rather than a preference. - * - * `#88`/`#90` already paid for the lesson this table exists to avoid: the - * best-scoring collision model was the WRONG one, because it scored well by - * absorbing an unrelated defect. So both alternatives are scored here and left - * in the record, rather than dismissed in a comment. - */ -describe("the variants that were rejected, and by how much", () => { - const suppressedTruth = (): Set => { - const a = (label: string): Arm => { - const c = (direction.cases as unknown as Arm[]).find((k) => k.label === label); - if (c === undefined) throw new Error(`no arm ${label}`); - return c; - }; - const on = gameCells(a("entity region, resources ON").cliffs); - const off = gameCells(a("entity region, ALL resources OFF").cliffs); - return new Set([...off].filter((k) => !on.has(k))); - }; - - it("scores base vs per-orientation box, with and without the geyser", () => { - const c = regionCase(1500); - const game = gameCells(c.cliffs); - const truth = suppressedTruth(); - const placed = placedWithoutOreRule(c.region); - - const score = (box: "base" | "orientation", includeGeyser: boolean) => { - const reject = makeVulcanusOreRejection(resources, ctx.vulcanusResourceControls, { - box, - includeGeyser, - geyserAt, - }); - const fired = placed.filter((p) => reject(p.code, p.x, p.y)); - return { - fired: fired.length, - correct: fired.filter((p) => truth.has(key(p.x, p.y))).length, - falseRejections: fired.filter((p) => game.has(key(p.x, p.y))).length, - }; - }; - - expect(truth.size).toBe(31); - // SHIPPED. The only variant that costs no recall at all. - expect(score("base", false)).toEqual({ fired: 20, correct: 20, falseRejections: 0 }); - // The geyser arm is not merely risky, it is strictly HARMFUL here: one more - // false rejection and not one additional correct suppression. Its placements - // are salt-dependent (46-63 over eight salts against the game's 56) and its - // box is 14x the ores', so a geyser in the wrong place sweeps a wide area. - expect(score("base", true)).toEqual({ fired: 21, correct: 20, falseRejections: 1 }); - // The per-orientation box catches one MORE true cell - and pays two kept - // cliffs for it. Higher `correct` is exactly the trap: recall is the half - // that must not be traded, so this loses despite the better headline. - expect(score("orientation", false)).toEqual({ fired: 23, correct: 21, falseRejections: 2 }); - expect(score("orientation", true)).toEqual({ fired: 24, correct: 21, falseRejections: 3 }); - }, 120000); - - /** - * **The answer to `#99`'s open question, as a number.** Driving the rejection - * from the port's own ore model instead of the game's entities costs exactly - * one cell: the fixture-driven geometry explains 21 of the 31, the port-driven - * one 20. So the port's ore footprint is a faithful substitute here, which is - * what made it safe to ship the rule at all. - */ - it("costs exactly one cell against driving it from the game's own entities", () => { - const c = regionCase(1500); - const truth = suppressedTruth(); - const placed = placedWithoutOreRule(c.region); - const reject = makeVulcanusOreRejection(resources, ctx.vulcanusResourceControls); - const portDriven = placed.filter((p) => reject(p.code, p.x, p.y) && truth.has(key(p.x, p.y))); - - // 21 is `test/cliffOreDirection.spec.ts`'s figure for the same geometry run - // against the game's resource entities. Re-derived here rather than quoted, - // so this cannot drift away from that spec silently. - const arm = (direction.cases as unknown as Arm[]).find( - (k) => k.label === "entity region, resources ON", - ); - if (arm === undefined) throw new Error("no ON arm"); - const [l, t, r, b] = VULCANUS_CLIFF_BASE_COLLISION_BOX; - const fixtureDriven = [...truth].filter((k) => { - const [xs, ys] = k.split(","); - const cx = Number(xs); - const cy = Number(ys); - return arm.resources.some((p) => { - const h = p.name === "sulfuric-acid-geyser" ? 1.3984375 : VULCANUS_ORE_COLLISION_HALF; - return cx + l < p.x + h && p.x - h < cx + r && cy + t < p.y + h && p.y - h < cy + b; - }); - }).length; - - expect(fixtureDriven).toBe(21); - expect(portDriven.length).toBe(20); - }, 120000); - - /** - * **The gap stays tracked rather than tuned away.** 11 of the 31 the game - * suppresses are not reproduced: 10 are `#99`'s run remainders (every one of - * the six connected components of the suppressed set contains a directly - * overlapped cell, so they are the tails of runs whose interior was rejected) - * and 1 is the cell the port's ore model misses against the game's entities. - * - * Widening the box until all 31 fall out is available and deliberately not - * done - see the module comment on `vulcanusOreRejection.ts`. - */ - it("pins the unexplained remainder at 11", () => { - const c = regionCase(1500); - const truth = suppressedTruth(); - const placed = placedWithoutOreRule(c.region); - const reject = makeVulcanusOreRejection(resources, ctx.vulcanusResourceControls); - const explained = new Set( - placed.filter((p) => reject(p.code, p.x, p.y)).map((p) => key(p.x, p.y)), - ); - expect([...truth].filter((k) => !explained.has(k)).length).toBe(11); - }, 120000); -}); - -/** - * The two properties that make the predicate's cheapness safe, and the disable - * path. - */ -describe("the predicate itself", () => { - /** - * **The tile window is derived, so it is guarded rather than trusted.** The - * predicate does not enumerate entities: it solves the two rectangles for the - * tiles whose centres can possibly overlap, which is 2 tiles for an ore. A - * brute-force scan a tile wider on every side, testing the overlap explicitly, - * must agree on every cell - otherwise the closed form is dropping hits. - */ - it("agrees with a brute-force scan one tile wider on every side", () => { - const c = regionCase(1500); - const placed = placedWithoutOreRule(c.region); - const reject = makeVulcanusOreRejection(resources, ctx.vulcanusResourceControls); - const oreAt = makeVulcanusOreFootprint(resources, ctx.vulcanusResourceControls); - const [l, t, r, b] = VULCANUS_CLIFF_BASE_COLLISION_BOX; - const h = VULCANUS_ORE_COLLISION_HALF; - - const wide = (x: number, y: number): boolean => { - for (let tx = Math.floor(x + l - h - 0.5) - 1; tx <= Math.ceil(x + r + h - 0.5) + 1; tx++) - for (let ty = Math.floor(y + t - h - 0.5) - 1; ty <= Math.ceil(y + b + h - 0.5) + 1; ty++) { - const px = tx + 0.5; - const py = ty + 0.5; - if (x + l < px + h && px - h < x + r && y + t < py + h && py - h < y + b && oreAt(tx, ty)) - return true; - } - return false; - }; - - expect(placed.length).toBe(900); - expect(placed.filter((p) => wide(p.x, p.y) !== reject(p.code, p.x, p.y)).length).toBe(0); - // Non-vacuity: the brute-force arm really does find the same 20, so "0 - // disagreements" is not two predicates both returning false everywhere. - expect(placed.filter((p) => wide(p.x, p.y)).length).toBe(20); - }, 120000); - - /** - * **A disabled ore suppresses nothing**, which is not a bolted-on special case - * but the very lever the game was driven with to establish the rule (`size = 0` - * on `autoplace_controls`, `#99`). It is also the app's own behaviour: a user - * who turns an ore off must not keep seeing cliffs missing where it was. - */ - it("fires zero times when every resource control is disabled", () => { - const c = regionCase(1500); - const placed = placedWithoutOreRule(c.region); - const off = withCtxDefaults({ - ...INPUT, - vulcanusResourceControls: { - tungstenOre: { frequency: 1, size: 0 }, - calcite: { frequency: 1, size: 0 }, - vulcanusCoal: { frequency: 1, size: 0 }, - sulfuricAcidGeyser: { frequency: 1, size: 0 }, - }, - }); - const reject = makeVulcanusOreRejection(resources, off.vulcanusResourceControls); - expect(placed.filter((p) => reject(p.code, p.x, p.y)).length).toBe(0); - }, 120000); - - /** - * **The two overlays must agree on where the ore is.** The cliff rejection and - * the ore overlay now share `RESOURCE_PROBABILITY_THRESHOLD`, but sharing a - * constant is not the same as painting the same footprint. This renders the - * ore overlay onto a blank image and checks pixel for pixel that an ore- - * coloured pixel is exactly where the rejection's footprint predicate says ore - * is - so a cliff can never be suppressed by ore the user cannot see. - */ - it("suppresses against exactly the footprint the ore overlay paints", () => { - // The full oracle region, not a corner of it: a 64x64 window at this origin - // contains no ore at all, so the comparison came back vacuously equal. The - // `painted > 0` assertion below is what caught that. - const w = 256; - const h = 256; - const img = { width: w, height: h, data: new Uint8ClampedArray(w * h * 4) } as ImageData; - renderVulcanusResources(img, { seed0: INPUT.seed0, originX: 1500, originY: 1500, ctx: INPUT }); - - const oreColors = new Set(["98,86,149", "204,179,179", "0,0,0"]); - const oreAt = makeVulcanusOreFootprint(resources, ctx.vulcanusResourceControls); - let painted = 0; - let mismatches = 0; - for (let py = 0; py < h; py++) - for (let px = 0; px < w; px++) { - const o = (py * w + px) * 4; - const opaque = img.data[o + 3] === 255; - const isOre = - opaque && - oreColors.has( - `${String(img.data[o])},${String(img.data[o + 1])},${String(img.data[o + 2])}`, - ); - if (isOre) painted++; - if (isOre !== oreAt(1500 + px, 1500 + py)) mismatches++; - } - - expect(mismatches).toBe(0); - // Non-vacuity: this window really does contain ore, so a zero above is an - // agreement rather than two empty sets. - expect(painted).toBeGreaterThan(0); - }, 120000); -}); diff --git a/test/cliffOreRemainderCascade.spec.ts b/test/cliffOreRemainderCascade.spec.ts deleted file mode 100644 index 3b5a9e2c..00000000 --- a/test/cliffOreRemainderCascade.spec.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import ore from "./fixtures/oracle-vulcanus-cliff-ore-direction.seed123456.json"; -import { CLIFF_ORIENTATION_NAMES } from "../src/noise/cliffs/cliffCatalog"; -import { - applyCliffConnections, - cliffCodeForOrientation, -} from "../src/noise/cliffs/cliffConnections"; -import { - VULCANUS_CLIFF_BASE_COLLISION_BOX, - VULCANUS_GEYSER_COLLISION_HALF, - VULCANUS_ORE_COLLISION_HALF, -} from "../src/noise/cliffs/vulcanusOreRejection"; - -/** - * **Four of the ten ore "run remainders" are the `Cliff::onDestroy` cascade, and - * nobody had tested that because the cascade did not exist yet when they were - * measured** (#84). - * - * `vulcanusOreRejection.ts` records that box overlap accounts for 21 of the 31 - * cells the ore suppresses and that the other ten are "run remainders", with two - * candidate explanations - "a cascade along cliff connections **or** a wider - * box" - and it records the cascade half as REFUTED by `cliffOreCascade.spec.ts`. - * - * **That refutation is about a different cascade.** It tested #108's - * CROSSING-stage mechanism: a rejection zeroes the cell's edge registers, a - * neighbour's code changes, re-test to a fixpoint. The `applyCliffs` cascade - - * `Cliff::onDestroy` taking the facing end of every connected neighbour, and - * destroying a neighbour left with no end at all - was only read out of the - * binary later, in #113. Nothing re-ran the remainder question against it. - * - * Running it closes four of the ten, at **zero** cost in precision. - * - * ## The experiment uses only the GAME's data on both sides - * - * This is what makes it worth trusting: nothing of the port's own field, ore - * model or geyser roll appears anywhere in it. - * - * - **Start** from the game's `ALL resources OFF` cliff set - 892 cells with the - * game's own orientations. - * - **Destroy** the cells whose base collision box overlaps one of the game's - * own resource entity positions, with the prototype half-extents the fixture - * itself carries. - * - **Compare** against the game's `resources ON` set - 861 cells. - * - * The only modelled things are the overlap rule and the ported cascade. - * - * | arm | matched | wrong | surplus | - * | --- | --- | --- | --- | - * | control - destroy the lever's own 31 | **861** | **0** | **0** | - * | direct overlap only, no cascade | 856 | 5 | 10 | - * | direct overlap + `onDestroy` cascade | **859** | **2** | **6** | - * - * The control is what validates the whole setup: the ore-off world minus those - * 31 cells IS the ore-on world, orientations included. Getting that exactly is - * also why the second `updateConnections` pass has to be suppressed - the game's - * dumped set is POST-pipeline, so running the pass again double-applies it and - * trims ends that legitimately survive (it scores `wrong = 13` if you forget). - * - * Recall on the lever's 31 goes **21/31 = 0.677 to 25/31 = 0.806** with no new - * parameter and no wider box - which matters because #124 established that - * widening the box would be fitting a shape to an effect the engine's collision - * system provably does not produce. - */ - -const nameToId = new Map(CLIFF_ORIENTATION_NAMES.map((n, i) => [n, i])); -const K = (x: number, y: number): string => `${String(x)},${String(y)}`; - -interface Ent { - x: number; - y: number; - name: string; - orientation?: string; -} -interface Region { - x0: number; - y0: number; - x1: number; - y1: number; -} - -const oreCases = ore.cases as unknown as { - label: string; - region: Region; - cliffs: Ent[]; - resources: Ent[]; -}[]; -const arm = (label: string): { label: string; region: Region; cliffs: Ent[]; resources: Ent[] } => { - const c = oreCases.find((q) => q.label === label); - if (c === undefined) throw new Error(`no arm ${label}`); - return c; -}; -const cliffMap = (label: string): Map => { - const c = arm(label); - const m = new Map(); - for (const e of c.cliffs) - if ( - e.name === "cliff-vulcanus" && - e.x >= c.region.x0 && - e.x < c.region.x1 && - e.y >= c.region.y0 && - e.y < c.region.y1 - ) { - const id = nameToId.get(e.orientation ?? ""); - if (id !== undefined) m.set(K(e.x, e.y), id); - } - return m; -}; - -const ON = cliffMap("entity region, resources ON"); -const ALL_OFF = cliffMap("entity region, ALL resources OFF"); -const REG = arm("entity region, resources ON").region; -const inR = (p: { x: number; y: number }): boolean => - p.x >= REG.x0 && p.x < REG.x1 && p.y >= REG.y0 && p.y < REG.y1; -const RES = arm("entity region, resources ON").resources; -const SUPPRESSED = new Set([...ALL_OFF.keys()].filter((k) => !ON.has(k))); - -/** The game's own ore-off cliffs, as an `applyCliffConnections` input. */ -const START = [...ALL_OFF.entries()].map(([k, o]) => { - const [xs, ys] = k.split(","); - return { x: Number(xs), y: Number(ys), code: cliffCodeForOrientation(o) }; -}); - -const [bl, bt, br, bb] = VULCANUS_CLIFF_BASE_COLLISION_BOX; -const halfOf = (name: string): number => - name === "sulfuric-acid-geyser" ? VULCANUS_GEYSER_COLLISION_HALF : VULCANUS_ORE_COLLISION_HALF; - -/** Bucketed so the overlap test is not 892 x 3933. */ -const BUCKET = 8; -const BUCKETS = new Map(); -for (const r of RES) { - const k = K(Math.floor(r.x / BUCKET), Math.floor(r.y / BUCKET)); - const a = BUCKETS.get(k); - if (a === undefined) BUCKETS.set(k, [r]); - else a.push(r); -} - -/** Does the cell's base box overlap any of the GAME's resource entity boxes? */ -const overlapsGameResource = (cx: number, cy: number): boolean => { - const left = cx + bl; - const top = cy + bt; - const right = cx + br; - const bottom = cy + bb; - for (let gx = Math.floor((left - 2) / BUCKET); gx <= Math.floor((right + 2) / BUCKET); gx++) - for (let gy = Math.floor((top - 2) / BUCKET); gy <= Math.floor((bottom + 2) / BUCKET); gy++) { - const a = BUCKETS.get(K(gx, gy)); - if (a === undefined) continue; - for (const r of a) { - const h = halfOf(r.name); - if (r.x - h < right && left < r.x + h && r.y - h < bottom && top < r.y + h) return true; - } - } - return false; -}; - -const DIRECT = new Set(START.filter((c) => overlapsGameResource(c.x, c.y)).map((c) => K(c.x, c.y))); - -interface Score { - matched: number; - wrong: number; - surplus: number; - missing: number; -} -const run = (kill: Set, noCascade: boolean): { score: Score; left: string[] } => { - const out = applyCliffConnections(START, { - collides: (_o, x, y) => kill.has(K(x, y)), - noCascade, - // The dumped set is POST-pipeline; running `updateConnections` again would - // double-apply it. See the module comment. - noUpdateConnections: true, - }); - const survivors = new Map(out.filter(inR).map((p) => [K(p.x, p.y), p.orientation] as const)); - const score: Score = { matched: 0, wrong: 0, surplus: 0, missing: 0 }; - for (const [k, id] of survivors) { - const t = ON.get(k); - if (t === undefined) score.surplus++; - else if (t === id) score.matched++; - else score.wrong++; - } - for (const k of ON.keys()) if (!survivors.has(k)) score.missing++; - return { score, left: [...SUPPRESSED].filter((k) => survivors.has(k)) }; -}; - -describe("the ore lever, replayed entirely on the game's own data", () => { - /** - * **The control that validates the harness.** The ore-off world minus the 31 - * cells the lever attributes to the ore is exactly the ore-on world - - * positions and orientations. If this ever stops being 861/0/0/0 the arms - * below mean nothing. - */ - it("reproduces the resources-ON world exactly from the OFF world", () => { - expect(ALL_OFF.size).toBe(892); - expect(ON.size).toBe(861); - expect(SUPPRESSED.size).toBe(31); - expect(run(SUPPRESSED, false).score).toEqual({ - matched: 861, - wrong: 0, - surplus: 0, - missing: 0, - }); - }, 300000); - - /** - * Box overlap against the game's own resource positions fires on 21 cells and - * **every one of them is in the lever's set** - precision 1.000 with nothing - * of ours in the measurement. That is the half of `vulcanusOreRejection.ts`'s - * rule that was never in doubt, re-derived without the port's ore field or - * geyser roll. - */ - it("finds 21 directly overlapped cells and no false positives", () => { - expect(DIRECT.size).toBe(21); - expect([...DIRECT].filter((k) => !SUPPRESSED.has(k))).toEqual([]); - }, 300000); -}); - -describe("the onDestroy cascade explains four of the ten remainders", () => { - /** - * The comparison that carries the finding. Destroying the same 21 cells - * differs only in whether `Cliff::onDestroy` runs, and the cascade removes - * four more - each one a cell left with no end at all once its neighbours' - * facing ends went - plus three of the five orientation errors. - * - * **No cell the lever keeps is ever removed**, in either arm, so this costs no - * precision. That is the property that distinguishes a mechanism from a wider - * box: a box big enough to reach these four would also reach cells the game - * kept. - */ - it("takes the remainder from 10 to 6 with no precision cost", () => { - const withoutCascade = run(DIRECT, true); - const withCascade = run(DIRECT, false); - - expect(withoutCascade.score).toEqual({ matched: 856, wrong: 5, surplus: 10, missing: 0 }); - expect(withCascade.score).toEqual({ matched: 859, wrong: 2, surplus: 6, missing: 0 }); - - // Recall on the lever's own 31, before and after. - expect((31 - withoutCascade.left.length) / 31).toBeCloseTo(0.677, 3); - expect((31 - withCascade.left.length) / 31).toBeCloseTo(0.806, 3); - - // ...and neither arm removes a cell the game kept - `missing` is 0 in both. - expect(withoutCascade.score.missing).toBe(0); - expect(withCascade.score.missing).toBe(0); - }, 300000); - - /** - * The six that remain, pinned as the input to whatever comes next. Two of them - * (`1546,1550.5`, `1546,1554.5`) are the pair that #123 attributed to the - * geyser and #124 used for its n=1 destroy-stage proof - so the one cell in - * this whole residual whose destruction is directly witnessed by the game's - * own orientations is still unexplained by any rule. - */ - it("pins the six the cascade does not reach", () => { - expect(run(DIRECT, false).left.sort((a, b) => a.localeCompare(b))).toEqual([ - "1546,1550.5", - "1546,1554.5", - "1606,1590.5", - "1606,1594.5", - "1622,1614.5", - "1626,1614.5", - ]); - }, 300000); -}); diff --git a/test/cliffOrientationMargin.spec.ts b/test/cliffOrientationMargin.spec.ts deleted file mode 100644 index cfb4d54f..00000000 --- a/test/cliffOrientationMargin.spec.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import corners from "./fixtures/oracle-vulcanus-cliff-corner-fields-entity-regions.seed123456.json"; -import entities from "./fixtures/oracle-vulcanus-cliff-entities.seed123456.json"; -import { - crossesCliff, - makeCliffPlacementFromFields, - smoothingKnots, -} from "../src/noise/cliffs/cliffPlacement"; -import { - CLIFF_CELL_CENTER_X, - CLIFF_CELL_CENTER_Y, - CLIFF_CODE_TO_ORIENTATION, - CLIFF_GRID_SIZE, - CLIFF_ORIENTATION_NAMES, -} from "../src/noise/cliffs/cliffCatalog"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { makeVulcanusStack } from "../src/noise/tiles/vulcanusCatalog"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -interface Ent { - x: number; - y: number; - name: string; - orientation?: string | null; -} -interface Case { - region: { x0: number; y0: number; x1: number; y1: number }; - cliffs: Ent[]; -} - -const INPUT = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }; -const ctx = withCtxDefaults(INPUT); -const fields = makeVulcanusCliffFields(ctx); -const E0 = VULCANUS_CLIFF_ELEVATION_0; -const INTERVAL = VULCANUS_CLIFF_ELEVATION_INTERVAL; -const S = VULCANUS_CLIFF_SMOOTHING; - -const rawE = new Map(); -const rawElev = (i: number, j: number): number => { - const k = `${String(i)},${String(j)}`; - let v = rawE.get(k); - if (v === undefined) { - v = fields.cliffElevation(i * CLIFF_GRID_SIZE, j * CLIFF_GRID_SIZE); - rawE.set(k, v); - } - return v; -}; -const elevAt = (i: number, j: number): number => { - const kx = smoothingKnots(i); - const ky = smoothingKnots(j); - const bil = - (1 - kx.t) * (1 - ky.t) * rawElev(kx.lo, ky.lo) + - kx.t * (1 - ky.t) * rawElev(kx.hi, ky.lo) + - (1 - kx.t) * ky.t * rawElev(kx.lo, ky.hi) + - kx.t * ky.t * rawElev(kx.hi, ky.hi); - return S === 1 ? bil : (1 - S) * rawElev(i, j) + S * bil; -}; -interface Corner { - elev: number; - cliff: number; -} -const corner = (i: number, j: number): Corner => ({ - elev: elevAt(i, j), - cliff: fields.cliffiness(i * CLIFF_GRID_SIZE, j * CLIFF_GRID_SIZE), -}); - -/** How far the two endpoints sit from the band boundary `crossesCliff` uses. */ -const margin = (a: number, b: number): number => { - const boundary = E0 + INTERVAL * Math.floor((Math.max(a, b) - E0) / INTERVAL); - return Math.min(Math.abs(a - boundary), Math.abs(b - boundary)); -}; - -const edgesOf = (cx: number, cy: number): [Corner, Corner][] => { - const a = corner(cx, cy); - const b = corner(cx, cy + 1); - const d = corner(cx + 1, cy); - const f = corner(cx + 1, cy + 1); - return [ - [a, b], - [d, f], - [a, d], - [b, f], - ]; -}; - -const crossingMarginsIn = (cx: number, cy: number): number[] => - edgesOf(cx, cy) - .filter(([u, v]) => crossesCliff(u.elev, v.elev, (u.cliff + v.cliff) / 2, E0, INTERVAL) !== 0) - .map(([u, v]) => margin(u.elev, v.elev)); - -/** - * **The orientation residual is not a floating-point tie at the band boundary.** - * - * `test/cliffOrientationResidual.spec.ts` pins the residual's shape: every wrong - * cell differs from the game in exactly ONE edge, and it is always an - * OVER-detection - the game finds no crossing there and the port finds one. - * - * That shape has an obvious cheap explanation which turns out to be wrong, and - * ruling it out is worth a spec because it eliminates a whole class of cause. - * `crossesCliff` decides by the SIGN of `elevation - boundary` on each endpoint, - * so if an endpoint sat within float noise of a band boundary, a difference of - * 1e-6 between our field and the game's would flip the crossing - and the port's - * fields agree with the game's to about that order. Under that story the residual - * would be an irreducible precision limit and there would be nothing to fix. - * - * **It is not that.** Every crossing edge in a wrong cell sits at least **0.2** - * from its boundary, with a median near 10 - four to seven orders of magnitude - * clear of float noise. For the game to disagree, its elevation at that corner - * must differ from ours by more than 0.2, which is a real field difference, not - * a rounding one. - * - * So the residual is a genuine disagreement about a value or a rule, and it is - * worth continuing to hunt. - */ -describe("the orientation over-detections are not boundary ties", () => { - const wrongCellMargins: number[] = []; - const allCrossingMargins: number[] = []; - - for (const c of entities.cases as unknown as Case[]) { - const r = c.region; - const byPos = new Map(); - for (const e of c.cliffs) - if (e.name === "cliff-vulcanus" && typeof e.orientation === "string") - byPos.set(`${String(e.x)},${String(e.y)}`, e.orientation); - - for (const p of makeCliffPlacementFromFields(fields, { - elevation0: E0, - interval: INTERVAL, - smoothing: S, - }).placedCells(r.x0, r.y0, r.x1, r.y1)) { - const gameOrient = byPos.get(`${String(p.x)},${String(p.y)}`); - if (gameOrient === undefined) continue; - const cx = (p.x - CLIFF_CELL_CENTER_X) / CLIFF_GRID_SIZE; - const cy = (p.y - CLIFF_CELL_CENTER_Y) / CLIFF_GRID_SIZE; - const ms = crossingMarginsIn(cx, cy); - allCrossingMargins.push(...ms); - const id = CLIFF_CODE_TO_ORIENTATION[p.code]; - const oursName = id === undefined ? "?" : CLIFF_ORIENTATION_NAMES[id]; - if (oursName !== gameOrient) wrongCellMargins.push(...ms); - } - } - - it("compares a real population, not a handful", () => { - // ~37 wrong cells carrying two crossing edges each, against every crossing - // edge of every matched cell. - expect(wrongCellMargins.length).toBeGreaterThan(50); - expect(allCrossingMargins.length).toBeGreaterThan(2000); - }, 120000); - - it("puts every crossing edge in a wrong cell far from its band boundary", () => { - const min = Math.min(...wrongCellMargins); - // Measured 0.205. Asserted as a bound rather than the exact value so a field - // change that keeps the conclusion does not fail the spec spuriously. - expect(min).toBeGreaterThan(0.1); - // Four orders of magnitude clear of the ~1e-6 the fields agree to. - expect(min).toBeGreaterThan(1e-4 * 1000); - }, 120000); - - /** - * Non-vacuity, and it matters here: the bound above would be unremarkable if - * NO edge anywhere sat near a boundary. Some do - the overall minimum is about - * 6e-3, thirty times tighter than the worst wrong cell - so "far from the - * boundary" is a property of the wrong cells rather than of the sample. - */ - it("is a property of the wrong cells, not of every edge", () => { - expect(Math.min(...allCrossingMargins)).toBeLessThan(Math.min(...wrongCellMargins) / 10); - }, 120000); -}); - -/** - * **The corner fixture is the TILE channel, and this pins it so.** - * - * `test/vulcanusCliffCornerFields.spec.ts` says so in prose at the top, and its - * substitution deliberately feeds `vulcanus_elevation` into `cliffElevation` to - * preserve the history of how the wrong channel stayed hidden. Prose is not a - * guard, and this is the single most expensive mistake this subsystem has made - * (#83) - so the identification is asserted here as a number. - * - * The gap it leaves is the important part: **the grid-4 cliff-elevation channel - * has no per-corner oracle at all.** It is the one input to the placement rule - * that has never been checked against the game corner by corner, and after the - * measurement above it is also the only remaining candidate that could move an - * endpoint by the required 0.2. Capturing it is the next concrete step. - */ -describe("which elevation channel the corner fixture holds", () => { - it("matches the per-tile channel and NOT the grid-4 cliff channel", () => { - const stack = makeVulcanusStack(INPUT); - const cliffFields = makeVulcanusCliffFields(stack.ctx, stack); - const keys = corners.corners; - const elev = corners.elevation; - - let maxVsTile = 0; - let maxVsCliff = 0; - for (let i = 0; i < keys.length; i++) { - const [is, js] = keys[i].split(","); - const x = Number(is) * CLIFF_GRID_SIZE; - const y = Number(js) * CLIFF_GRID_SIZE; - maxVsTile = Math.max(maxVsTile, Math.abs(stack.elevation.elevation(x, y) - elev[i])); - maxVsCliff = Math.max(maxVsCliff, Math.abs(cliffFields.cliffElevation(x, y) - elev[i])); - } - - expect(keys.length).toBe(12675); - // Measured: 4.8e-2 against the tile channel, 96.09 against the cliff channel. - expect(maxVsTile).toBeLessThan(0.1); - expect(maxVsCliff).toBeGreaterThan(50); - }, 120000); -}); diff --git a/test/cliffOrientationOracle.spec.ts b/test/cliffOrientationOracle.spec.ts index eb1fde2c..55daba14 100644 --- a/test/cliffOrientationOracle.spec.ts +++ b/test/cliffOrientationOracle.spec.ts @@ -2,25 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import nauvis from "./fixtures/oracle-cliff-entities.seed123456.json"; import vulcanus from "./fixtures/oracle-vulcanus-cliff-entities.seed123456.json"; -import { CLIFF_ORIENTATION_NAMES, cliffOrientationForCode } from "../src/noise/cliffs/cliffCatalog"; -import { - makeCliffPlacement, - makeCliffPlacementFromFields, -} from "../src/noise/cliffs/cliffPlacement"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -const key = (p: { x: number; y: number }): string => `${String(p.x)},${String(p.y)}`; - -const orientationOf = (code: number): string | undefined => { - const id = cliffOrientationForCode(code); - return id === undefined ? undefined : CLIFF_ORIENTATION_NAMES[id]; -}; +import { CLIFF_ORIENTATION_NAMES } from "../src/noise/cliffs/cliffCatalog"; /** * **The end-to-end oracle for `CLIFF_CODE_TO_ORIENTATION`** (issue #18). @@ -68,126 +50,4 @@ describe("cliff orientation vs the game's own cliff_orientation", () => { for (const name of seen) expect(CLIFF_ORIENTATION_NAMES).toContain(name); expect(seen.size).toBe(20); }); - - it("agrees with the game on NAUVIS, exactly, for every cliff", () => { - for (const c of nauvis.cases) { - const r = nauvis.region; - const placed = makeCliffPlacement({ - seed0: c.seed, - controls: { frequency: 1, continuity: 1 }, - settings: { cliffElevation0: 10, cliffElevationInterval: 40, richness: 1 }, - }).placedCells(r.x0, r.y0, r.x1, r.y1); - const ours = new Map(placed.map((p) => [key(p), p.code])); - - const wrong: string[] = []; - for (const p of c.cliffs) { - // Nauvis is exact in both directions, so this really is every cliff. - // Asserted rather than skipped: a silent `continue` is how a shrinking - // comparison set stops being noticed. - const code = ours.get(key(p)); - expect(code).toBeDefined(); - const got = orientationOf(code as number); - if (got !== p.orientation) - wrong.push(`${key(p)} ours=${String(got)} game=${p.orientation}`); - } - expect(wrong).toEqual([]); - expect(ours.size).toBe(c.cliffs.length); - } - }, 120000); - - /** - * **Vulcanus still does not agree everywhere, and this is issue #18's - * residual seen up close.** - * - * Nauvis passing exactly means the table above is right, so a disagreement - * here is a disagreement about the CROSSINGS - the four edges - not about the - * lookup. Measured 2026-08-01, after the `multisample` grid-units fix (#83), - * over the cells the port and the game both place: - * - * | region | matched | wrong orientation | was, before #83 | - * | --- | --- | --- | --- | - * | `[0,0]` | 283 | 7 = 2.5% | 228 / 68 = **29.8%** | - * | `[1500,1500]` | 861 | 26 = 3.0% | 830 / 67 = 8.1% | - * | `[-1200,800]` | 387 | 4 = 1.0% | 342 / 40 = 11.7% | - * | total | 1531 | **37 = 2.4%** | 1400 / 175 = 12.5% | - * - * Note the comparison set GREW as the error shrank - the port now matches 131 - * more of the game's cliffs - so this is not 175 falling to 37 by comparing - * fewer cells. - * - * This is a far sharper instrument than the counts in - * `vulcanusCliffEntities.spec.ts`: a cell can land in the right place for the - * wrong reason, and 37 of them still do. Before #83 the dominant failure was - * **exactly two edges differing** (125 of 175), which is one of the cell's two - * crossings sitting on a different side - a single corner on the wrong side of - * a band boundary - spread evenly over the four edges (L:87 R:80 T:87 B:89), - * so never a directional off-by-one. - * - * **This arm deliberately runs WITHOUT the lava rejection**, which is not the - * shipping path and is the point. Rejection only ever REMOVES cells, so - * leaving it off compares the larger set (1531 rather than 1518) and cannot - * hide a bad crossing behind a cell that got dropped for an unrelated reason. - * On the shipping path the same measurement is 31 / 1518 = 2.04%; the - * rejection removes 6 wrong ones with the 185 false positives it is there for. - * - * Causes tested against this metric before #83, none of which explained it, - * kept because each is a closed door: - * - * - **The fields were exonerated at the site they were sampled.** Re-running - * PR #57's substitution - the game's own corner elevation and cliffiness, at - * `[1500,1500]` - left the mismatch at 67/830, identical to the digit, while - * a +3 elevation bias moved it to 122/793. The substitution was live and the - * metric sensitive to it. What that could not see is that the fixture had - * been captured through `calculate_tile_properties`, a DIFFERENT channel - * from the one the cliff generator reads - which is exactly what #83 turned - * out to be. A field can be right at the right site and still be the wrong - * field for the consumer. - * - **`fixImpossibleCells` was not it.** Turning it off moved the total from - * 12.5% to 14.3%, and region `[0,0]` from 29.8% to 30.8%. - * - **Chunk borders were not it.** `generateCliffs` passes `tryToAddCliff` a - * `!onChunkBorder` flag, and `fixImpossibleCells` cannot clear a border - * edge, so the outer ring of each 8x8 chunk was the obvious suspect. Border - * cells were wrong 13.3% of the time against interior's 11.9% - no - * concentration - and the game places cliffs uniformly across all 64 - * in-chunk positions (17-36 each), so that flag suppresses nothing. - * - * The count is pinned as an upper bound so the residual can only shrink. - */ - it("agrees with the game on VULCANUS wherever the port places the same cell", () => { - const ctx = withCtxDefaults({ seed0: vulcanus.seed, startingPositions: [{ x: 0, y: 0 }] }); - const fields = makeVulcanusCliffFields(ctx); - let compared = 0; - const wrong: string[] = []; - for (const c of vulcanus.cases) { - const r = c.region; - const placed = makeCliffPlacementFromFields(fields, { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, - }).placedCells(r.x0, r.y0, r.x1, r.y1); - const ours = new Map(placed.map((p) => [key(p), p.code])); - // `crater-cliff` is placed by the entity autoplace, not on the cliff - // lattice, so its positions are fractional. Excluded, as everywhere else. - for (const p of c.cliffs.filter((q) => q.name === "cliff-vulcanus")) { - const code = ours.get(key(p)); - if (code === undefined) continue; - compared++; - const got = orientationOf(code); - if (got !== p.orientation) - wrong.push(`${key(p)} ours=${String(got)} game=${p.orientation}`); - } - } - // Non-vacuity: this arm skips cells the port does not place, so without a - // floor a port that placed NOTHING would pass on an empty comparison. 1531 - // is the measured matched count (2026-08-01, placement without the lava - // rejection, which is what is built above). The floor is raised with the - // bound below for a reason: a change that shrinks BOTH numbers has not - // fixed anything, it has stopped comparing. - expect(compared).toBeGreaterThan(1500); - // Measured 37. An upper bound, not an equality, so improving the rule does - // not require editing this line - but tight enough that a regression fails. - // Do NOT raise it to make a change pass: this number going up means the - // crossings got worse, which is the whole thing #18 is about. - expect(wrong.length).toBeLessThanOrEqual(37); - }, 120000); }); diff --git a/test/cliffOrientationResidual.spec.ts b/test/cliffOrientationResidual.spec.ts deleted file mode 100644 index fae8a3ec..00000000 --- a/test/cliffOrientationResidual.spec.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import vulcanus from "./fixtures/oracle-vulcanus-cliff-entities.seed123456.json"; -import { CLIFF_ORIENTATION_NAMES, cliffOrientationForCode } from "../src/noise/cliffs/cliffCatalog"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -const key = (p: { x: number; y: number }): string => `${String(p.x)},${String(p.y)}`; - -/** code = (enc(L)<<6)|(enc(R)<<4)|(enc(T)<<2)|enc(B); enc: 0->0, +1->1, -1->3. */ -const edgesOf = (code: number): readonly number[] => [ - (code >> 6) & 3, - (code >> 4) & 3, - (code >> 2) & 3, - code & 3, -]; - -/** - * **The SHAPE of the Vulcanus orientation residual** (issue #84). - * - * `cliffOrientationOracle.spec.ts` counts the residual and bounds it. This file - * pins what it looks like, because the shape is the lead and a change in shape - * is a change in cause even if the count holds. - * - * Measured 2026-08-02, after #83 (multisample grid), #86 (lava rejection) and - * #90 (the raw collision box): - * - * - **37 of 1531 matched cells, and all 37 differ in EXACTLY ONE edge.** Not one - * two-edge difference survives. Before #83 the dominant failure was two edges - * (125 of 175), i.e. a whole corner on the wrong side of a band; that mode is - * gone. - * - **Every one is an OVER-detection.** In all 37 the game reports a `-to-none` - * orientation and the port reports a crossing on that edge - never the - * reverse. Sample transitions: `south-to-north -> none-to-north` (4x), - * `north-to-south -> north-to-none` (4x), `west-to-east -> none-to-east` (3x). - * - Spread evenly over the four edges (L11 / R6 / T7 / B13) and over regions - * (7 / 26 / 4), so it is not a directional off-by-one. - * - * **Two candidate causes are already eliminated, which is why this is worth - * pinning rather than re-deriving:** - * - * - `crossesCliff` is EXACT. Disassembled at `0x10160c914` under 2.1.12 (the VA - * in `cliffs-NOTES.md` had moved); `cliffPlacement.ts` reproduces it line for - * line, including the `a < 0 || b < 0` early-out, the `boundary < e0` check - * and the strict `> 0.5` gate and strict crossing comparisons. There is no - * `>=`-vs-`>` slip to find. - * - `cliffiness_basic` is EXONERATED. Substituting the game's own corner - * cliffiness leaves the count at exactly 37 / 1531. - * - * So the residual is in the **grid-4 cliff-elevation field**, the one input in - * the chain with no direct per-corner oracle. A single-edge, strictly - * one-directional over-detection is what a small positive field offset looks - * like. - */ -describe("the shape of the Vulcanus orientation residual", () => { - const ctx = withCtxDefaults({ seed0: vulcanus.seed, startingPositions: [{ x: 0, y: 0 }] }); - const fields = makeVulcanusCliffFields(ctx); - const nameToId = new Map(CLIFF_ORIENTATION_NAMES.map((n, i) => [n, i])); - const codeForOrientation = new Map(); - for (let c = 0; c < 256; c++) { - const id = cliffOrientationForCode(c); - if (id !== undefined && !codeForOrientation.has(id)) codeForOrientation.set(id, c); - } - - const wrong: { ourCode: number; gameCode: number; ours: string; game: string }[] = []; - let matched = 0; - for (const c of vulcanus.cases) { - const r = c.region; - const placed = makeCliffPlacementFromFields(fields, { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, - }).placedCells(r.x0, r.y0, r.x1, r.y1); - const ours = new Map(placed.map((p) => [key(p), p.code])); - for (const p of c.cliffs.filter((q) => q.name === "cliff-vulcanus")) { - const code = ours.get(key(p)); - if (code === undefined) continue; - matched++; - const id = cliffOrientationForCode(code); - const got = id === undefined ? undefined : CLIFF_ORIENTATION_NAMES[id]; - if (got === p.orientation) continue; - const gid = nameToId.get(p.orientation); - const gameCode = gid === undefined ? undefined : codeForOrientation.get(gid); - expect(gameCode).toBeDefined(); - wrong.push({ - ourCode: code, - gameCode: gameCode as number, - ours: String(got), - game: p.orientation, - }); - } - } - - it("compares a substantial set - the shape below is not read off a handful", () => { - expect(matched).toBeGreaterThan(1500); - expect(wrong.length).toBeGreaterThan(0); - expect(wrong.length).toBeLessThanOrEqual(37); - }, 120000); - - it("differs in exactly ONE edge, every time", () => { - for (const w of wrong) { - const a = edgesOf(w.ourCode); - const b = edgesOf(w.gameCode); - let differing = 0; - for (let i = 0; i < 4; i++) if (a[i] !== b[i]) differing++; - expect(differing).toBe(1); - } - }, 120000); - - /** - * The direction is the actual lead. If this ever fails with under-detections - * appearing, the cause has changed and the "small positive field offset" - * reading above is dead. - */ - it("is always an OVER-detection - the game says none, we say a crossing", () => { - let over = 0; - for (const w of wrong) { - const a = edgesOf(w.ourCode); - const b = edgesOf(w.gameCode); - for (let i = 0; i < 4; i++) { - if (a[i] === b[i]) continue; - // The game's edge carries no crossing (0) and ours does. - expect(b[i]).toBe(0); - expect(a[i]).not.toBe(0); - over++; - } - } - expect(over).toBe(wrong.length); - }, 120000); - - it("is not concentrated on one edge, which would be an off-by-one", () => { - const perEdge = [0, 0, 0, 0]; - for (const w of wrong) { - const a = edgesOf(w.ourCode); - const b = edgesOf(w.gameCode); - for (let i = 0; i < 4; i++) if (a[i] !== b[i]) perEdge[i]++; - } - // Measured L11 / R6 / T7 / B13. Every edge participates; no edge dominates. - for (const n of perEdge) expect(n).toBeGreaterThan(0); - expect(Math.max(...perEdge)).toBeLessThan(wrong.length * 0.6); - }, 120000); -}); diff --git a/test/cliffPhantomNeighbour.spec.ts b/test/cliffPhantomNeighbour.spec.ts deleted file mode 100644 index 68f3a7e4..00000000 --- a/test/cliffPhantomNeighbour.spec.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import entities from "./fixtures/oracle-vulcanus-cliff-entities.seed123456.json"; -import { - CLIFF_CODE_TO_ORIENTATION, - CLIFF_GRID_SIZE, - CLIFF_ORIENTATION_NAMES, -} from "../src/noise/cliffs/cliffCatalog"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { makeVulcanusOreRejection } from "../src/noise/cliffs/vulcanusOreRejection"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; -import { buildResources } from "../src/noise/preview/renderVulcanusResources"; -import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -const key = (x: number, y: number): string => `${String(x)},${String(y)}`; - -/** code = (enc(L)<<6)|(enc(R)<<4)|(enc(T)<<2)|enc(B); enc: 0->0, +1->1, -1->3. */ -const edgesOf = (code: number): number[] => [ - (code >> 6) & 3, - (code >> 4) & 3, - (code >> 2) & 3, - code & 3, -]; - -/** - * Per edge index (L, R, T, B): the world offset to the cell that SHARES it. - * - * `placedCells` builds one edge register per chunk - `v[cy][cx]` is cell `cx`'s - * left edge and cell `cx-1`'s right edge, the same array slot - so two adjacent - * cells do not merely agree about the edge between them, they read the identical - * value. That is what makes the test below a test and not a coincidence hunt. - */ -const ACROSS: readonly (readonly [number, number])[] = [ - [-CLIFF_GRID_SIZE, 0], - [CLIFF_GRID_SIZE, 0], - [0, -CLIFF_GRID_SIZE], - [0, CLIFF_GRID_SIZE], -]; - -const codeForOrientation = new Map(); -for (const [c, id] of Object.entries(CLIFF_CODE_TO_ORIENTATION)) - codeForOrientation.set(id, Number(c)); -const nameToId = new Map(CLIFF_ORIENTATION_NAMES.map((n, i) => [n, i])); - -/** The game's orientation name -> the cell code that produces it (a bijection). */ -const gameCodeOf = (orientation: string): number | undefined => { - const id = nameToId.get(orientation); - return id === undefined ? undefined : codeForOrientation.get(id); -}; - -interface Ent { - x: number; - y: number; - name: string; - orientation: string; -} -interface Case { - region: { x0: number; y0: number; x1: number; y1: number }; - cliffs: Ent[]; -} - -const INPUT = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }; -const ctx = withCtxDefaults(INPUT); -const fields = makeVulcanusCliffFields(ctx); -const tileAt = makeVulcanusTileResolver(INPUT); -const oreRejects = makeVulcanusOreRejection(buildResources(ctx), ctx.vulcanusResourceControls); - -const place = ( - r: Case["region"], - withRejections: boolean, -): { x: number; y: number; code: number }[] => - makeCliffPlacementFromFields(fields, { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, - tileCollides: withRejections - ? (x, y): boolean => VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name) - : undefined, - cellRejects: withRejections ? oreRejects : undefined, - }).placedCells(r.x0, r.y0, r.x1, r.y1); - -interface Scored { - matched: number; - /** Cells the game also places, where our orientation differs. */ - wrong: number; - /** Cells we place that the game does not. */ - surplus: string[]; - /** Distinct neighbours across a disputed edge. */ - phantoms: Set; - /** Disputed edges whose neighbour the GAME places. Expected: none. */ - phantomPlacedByGame: number; -} - -const score = (c: Case, withRejections: boolean): Scored => { - const ours = new Map(place(c.region, withRejections).map((p) => [key(p.x, p.y), p.code])); - const game = new Map(); - for (const e of c.cliffs) if (e.name === "cliff-vulcanus") game.set(key(e.x, e.y), e.orientation); - - const phantoms = new Set(); - let matched = 0; - let wrong = 0; - let phantomPlacedByGame = 0; - for (const [k, ourCode] of ours) { - const want = game.get(k); - if (want === undefined) continue; - matched++; - const gameCode = gameCodeOf(want); - if (gameCode === undefined || gameCode === ourCode) continue; - wrong++; - const a = edgesOf(ourCode); - const b = edgesOf(gameCode); - const [xs, ys] = k.split(","); - for (let i = 0; i < 4; i++) { - if (a[i] === b[i]) continue; - const nk = key(Number(xs) + ACROSS[i][0], Number(ys) + ACROSS[i][1]); - phantoms.add(nk); - if (game.has(nk)) phantomPlacedByGame++; - } - } - return { - matched, - wrong, - surplus: [...ours.keys()].filter((k) => !game.has(k)), - phantoms, - phantomPlacedByGame, - }; -}; - -/** - * **The orientation residual and the over-placement are ONE defect** (issue #84). - * - * `cliffOrientationResidual.spec.ts` pins the residual's shape - every wrong cell - * differs from the game in exactly one edge, and always by finding a crossing the - * game does not - and `cliffOrientationMargin.spec.ts` rules out a boundary tie. - * Both treat the wrong orientations as their own defect, separate from the - * surplus cells counted in `cliffOreExclusion.spec.ts`. **They are not separate.** - * - * A cell's four edges are shared with its four neighbours - literally the same - * slot in the chunk's edge register, see `ACROSS` above - so a spurious crossing - * is never confined to one cell. It corrupts the orientation of the real cell on - * one side AND, on the other, manufactures a whole cliff the game never placed. - * Measured over all three oracle regions, without the rejections so the geometry - * is not masked: - * - * | | | - * | --- | --- | - * | matched cells | 1531 | - * | wrong orientations | 37 | - * | of those whose disputed-edge neighbour the GAME places | **0** | - * | distinct phantom neighbours | 34 | - * | of those the PORT places (i.e. that are surplus cells) | **34 of 34** | - * - * Not one of the 37 has a neighbour the game agrees about, and not one phantom - * fails to be a surplus cell. So the residual is not a cosmetic orientation - * mismatch to be chased after the placement is right - it IS part of the - * placement error, and one root cause retires both. - * - * **Why this reframes the hunt.** The open lead is the grid-4 cliff-elevation - * channel, which has no per-corner oracle (see `cliffOrientationMargin.spec.ts`). - * The value of capturing it was previously scored against 33 wrong orientations - - * about 1.6% of cells, easy to read as a rounding-error chase. It is worth more - * than that: on the shipping path it also owns 12 of the surplus cells, and at - * `[0,0]` it owns **every** surplus cell there is. (That 12 was measured when - * the shipping surplus was 25; #108 has since taken it to 22 and the share has - * not been re-measured. The assertions below all run with the rejections OFF, so - * none of them depends on it.) - */ -describe("the wrong orientations and the surplus cells are the same defect", () => { - const bare = (entities.cases as unknown as Case[]).map((c) => score(c, false)); - - it("compares a real population, not a handful", () => { - // Non-vacuity. If the residual is ever fixed these two lines are what will - // fail, and the correct response is to delete this file's premise, not to - // relax them - every assertion below is vacuous at `wrong === 0`. - expect(bare.reduce((n, s) => n + s.matched, 0)).toBeGreaterThan(1500); - expect(bare.reduce((n, s) => n + s.wrong, 0)).toBeGreaterThan(0); - }, 120000); - - it("never has the game placing the neighbour across the disputed edge", () => { - for (const s of bare) expect(s.phantomPlacedByGame).toBe(0); - }, 120000); - - /** - * The other half, and the one that makes it a shared defect rather than a - * shared symptom: every phantom is a cell the port really does emit. A - * disputed edge that produced no cliff on either side would be a discrepancy - * with no cost. - */ - it("makes every phantom neighbour a surplus cell of our own", () => { - let phantoms = 0; - for (const [i, s] of bare.entries()) { - const surplus = new Set(s.surplus); - for (const p of s.phantoms) { - phantoms++; - expect({ region: i, cell: p, surplus: surplus.has(p) }).toEqual({ - region: i, - cell: p, - surplus: true, - }); - } - } - // Measured 34 distinct phantoms behind 37 wrong cells - a few are shared, - // where one spurious crossing sits between two cells the game both places. - expect(phantoms).toBe(34); - }, 120000); -}); - -/** - * **What that costs on the path the renderer actually runs.** - * - * The bare arm above is the right control for the geometry - the lava and ore - * rejections drop cells for reasons unrelated to the crossings, and they drop - * phantoms and honest cliffs alike. But the arm that matters for accuracy is the - * one `renderVulcanusCliffs` runs, and the split there is worth pinning because - * it is not obvious from the bare numbers: - * - * | region | matched | wrong | surplus | phantoms | surplus that ARE phantoms | - * | --- | --- | --- | --- | --- | --- | - * | `[0,0]` | 281 | 5 | 2 | 5 | **2 of 2** | - * | `[1500,1500]` | 858 | 25 | 22 | 23 | 10 of 22 | - * | `[-1200,800]` | 386 | 3 | 1 | 3 | 0 of 1 | - * - * Two things follow. **At `[0,0]` the spurious crossings are the whole of the - * over-placement** - fix them and that region is exact. And the reason 33 wrong - * cells do not imply 33 surplus is that the rejections already remove 19 of the - * phantoms; the rejection hides the phantom while leaving the neighbouring cell's - * orientation wrong, which is why the two counts drifted apart and were read as - * unrelated in the first place. - */ -describe("the same defect, on the shipping path", () => { - const shipped = (entities.cases as unknown as Case[]).map((c) => score(c, true)); - - it("owns every surplus cell at [0,0]", () => { - const s = shipped[0]; - expect(s.surplus.length).toBeGreaterThan(0); - for (const k of s.surplus) expect(s.phantoms.has(k)).toBe(true); - }, 120000); - - it("owns a substantial minority of the surplus overall", () => { - const surplus = shipped.reduce((n, s) => n + s.surplus.length, 0); - const explained = shipped.reduce( - (n, s) => n + s.surplus.filter((k) => s.phantoms.has(k)).length, - 0, - ); - // Measured 12 of 25. Bounds rather than equalities: a fix should move both - // down, and this file should not have to be edited to let it. - expect(surplus).toBeLessThanOrEqual(25); - expect(explained).toBeGreaterThanOrEqual(Math.min(12, surplus)); - }, 120000); -}); diff --git a/test/cliffPlacement.spec.ts b/test/cliffPlacement.spec.ts deleted file mode 100644 index 0a50df20..00000000 --- a/test/cliffPlacement.spec.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { crossesCliff, makeCliffPlacement } from "../src/noise/cliffs/cliffPlacement"; -import { NAUVIS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderCliffs"; -import { makeTileResolver } from "../src/noise/tiles/resolve"; -import entFixture from "./fixtures/oracle-cliff-entities.seed123456.json"; - -const key = (p: { x: number; y: number }) => `${p.x},${p.y}`; - -describe("crossesCliff", () => { - const I = 40, - E0 = 10; - it("no crossing within a band", () => expect(crossesCliff(12, 20, 10, E0, I)).toBe(0)); // both band 0 - it("negative elevation never crosses", () => expect(crossesCliff(-1, 60, 10, E0, I)).toBe(0)); - it("below elevation_0 never crosses", () => expect(crossesCliff(5, 8, 10, E0, I)).toBe(0)); // max - expect(crossesCliff(45, 55, 0, E0, I)).toBe(0)); - it("signed crossing up", () => { - // max=55 -> boundary = 10 + 40*floor((55-10)/40)=10+40=50; a=45<50, b=55>50, avg>0.5 -> +1 - expect(crossesCliff(45, 55, 10, E0, I)).toBe(1); - }); - it("signed crossing down", () => expect(crossesCliff(55, 45, 10, E0, I)).toBe(-1)); -}); - -describe("makeCliffPlacement lattice", () => { - it("all placed cliffs sit on x≡2, y≡2.5 (mod 4)", () => { - const pl = makeCliffPlacement({ - seed0: 123456, - controls: { frequency: 1, continuity: 1 }, - settings: { cliffElevation0: 10, cliffElevationInterval: 40, richness: 1 }, - }); - const cells = pl.placedCells(0, 0, 512, 512); - expect(cells.length).toBeGreaterThan(0); - for (const c of cells) { - expect(((c.x % 4) + 4) % 4).toBe(2); - expect(((c.y % 4) + 4) % 4).toBeCloseTo(2.5, 9); - } - }); - it("continuity 0 places no cliffs", () => { - const pl = makeCliffPlacement({ - seed0: 123456, - controls: { frequency: 1, continuity: 0 }, - settings: { cliffElevation0: 10, cliffElevationInterval: 40, richness: 1 }, - }); - expect(pl.placedCells(0, 0, 512, 512).length).toBe(0); - }); -}); - -// End-to-end validation of the whole placement rule (both cliff fields + -// crossesCliff + the orientation-code table) against the game's REAL cliff -// entities, dumped over a 16x16-chunk region at the default preset via -// find_entities_filtered{type="cliff"} (oracle-cliff-entities.seed123456.json). -// The residual is the DEFERRED fixImpossibleCells + water rejection -// (docs/noise/cliffs-NOTES.md). The bounds are drift guards, NOT thresholds to -// tune down - a large drop means the field port or the crossing rule regressed. -// -// **Measured 2026-07-28, and better than the "~89-90%" this comment used to -// claim: 94.33% (seed 123456) and 94.23% (777771).** The old figure came from the -// original spike and was never re-measured after the port improved. -// -// **PRECISION is asserted as well as recall, added after the Vulcanus port was -// measured (issue #18).** Recall alone cannot see over-placement - a model that -// placed a cliff on every lattice cell would score 100% on it - and that is -// exactly how Vulcanus fails (recall 57-69% but 1.1-1.6x too many cliffs). Nauvis -// does not have that problem at all: it places EXACTLY the game's count at both -// seeds (282 vs 282, 52 vs 52, ratio 1.000), which is what cleared the shared -// placement machinery and localised #18 to Vulcanus's own fields and band -// constants. Neither planet's dump has duplicate positions, so set and array -// counting agree. -describe("cliff placement vs find_entities (~94% drift guard)", () => { - for (const c of entFixture.cases) { - it(`reproduces >=85% of real cliffs, without over-placing, seed=${c.seed}`, () => { - const r = entFixture.region; - const pl = makeCliffPlacement({ - seed0: c.seed, - controls: { frequency: 1, continuity: 1 }, - settings: { cliffElevation0: 10, cliffElevationInterval: 40, richness: 1 }, - }); - const placed = pl.placedCells(r.x0, r.y0, r.x1, r.y1); - - // Sanity on the oracle dump itself: every real cliff sits on the 4-tile - // cliff lattice (x mod 4 == 2, y mod 4 == 2.5). - for (const p of c.cliffs) { - expect(((p.x % 4) + 4) % 4).toBe(2); - expect(((p.y % 4) + 4) % 4).toBeCloseTo(2.5, 9); - } - - const predicted = new Set(placed.map(key)); - const actual = c.cliffs.map(key); - const matched = actual.filter((k) => predicted.has(k)).length; - const frac = matched / actual.length; - const precision = matched / predicted.size; - - // **EXACT since 2026-07-30.** 282/282 at seed 123456 and 52/52 at 777771 - - // every real cliff placed, nothing invented. The long-standing ~6% - // residual was the port sampling the fields at `j*4 + 0.5`: it added the - // prototype's `grid_offset {0, 0.5}`, which is a CENTRE offset, to the - // SAMPLE position (see `CLIFF_CELL_CENTER_X`). - // - // Pinned at equality on purpose. Placement is deterministic given the seed - // - there is no roll - so an inequality here would let a real regression - // hide inside the slack, which is how the 0.943 sat unexplained for two - // months while five different causes were proposed for it. - expect(frac).toBe(1); - expect(precision).toBe(1); - expect(predicted.size).toBe(actual.length); - }); - - /** - * The same run with the game's tile-collision rejection turned on, which the - * Nauvis renderer does NOT ship (it skips water-coloured pixels at paint - * time instead - cheaper, and visually identical while this holds). - * - * The claim being guarded is "no Nauvis cliff's collision box touches water", - * which is why skipping the rejection there is safe. That was measured once, - * in passing, while ruling `wouldCollide` moot for Nauvis; a remembered - * measurement is exactly the kind of thing this project has been burned by, - * so it is now a standing check. If a future change to the elevation tree or - * to water's autoplace makes it stop holding, this fails and the Nauvis - * renderer needs the rejection wiring in for real. - */ - it(`stays exact with the tile-collision rejection enabled, seed=${c.seed}`, () => { - const r = entFixture.region; - const resolveTile = makeTileResolver({ seed0: c.seed }); - let calls = 0; - let waterHits = 0; - const isWater = (x: number, y: number): boolean => { - calls++; - const water = NAUVIS_CLIFF_BLOCKING_TILES.has(resolveTile(x, y).name); - if (water) waterHits++; - return water; - }; - const settings = { - seed0: c.seed, - controls: { frequency: 1, continuity: 1 }, - settings: { cliffElevation0: 10, cliffElevationInterval: 40, richness: 1 }, - }; - const plain = makeCliffPlacement(settings).placedCells(r.x0, r.y0, r.x1, r.y1); - const gated = makeCliffPlacement(settings, { tileCollides: isWater }).placedCells( - r.x0, - r.y0, - r.x1, - r.y1, - ); - - expect(gated.map(key)).toEqual(plain.map(key)); - - // **Non-vacuity, three ways.** "The gated and ungated lists are equal" - // would also pass if `tileCollides` were silently ignored, if the - // predicate were never called, or if this seed's region simply had no - // water. So: the predicate ran (at least one box tile per placed cell), - // it found no water under any cliff, and the SAME resolver does find - // water elsewhere in the region. - expect(calls).toBeGreaterThanOrEqual(plain.length); - expect(waterHits).toBe(0); - let waterInRegion = 0; - for (let x = r.x0; x < r.x1; x += 8) - for (let y = r.y0; y < r.y1; y += 8) - if (NAUVIS_CLIFF_BLOCKING_TILES.has(resolveTile(x, y).name)) waterInRegion++; - expect(waterInRegion).toBeGreaterThan(0); - }, 120000); - } -}); diff --git a/test/cliffResidual.spec.ts b/test/cliffResidual.spec.ts deleted file mode 100644 index ebe4e67b..00000000 --- a/test/cliffResidual.spec.ts +++ /dev/null @@ -1,347 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { makeCliffElevation, makeCliffFields } from "../src/noise/cliffs/cliffFields"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; -import { makeTileResolver } from "../src/noise/tiles/resolve"; -import elevFixture from "./fixtures/oracle-cliff-elevation.seed123456.json"; -import fx from "./fixtures/oracle-cliff-entities.seed123456.json"; -import vFix from "./fixtures/oracle-vulcanus-cliff-entities.seed123456.json"; - -/** - * What Nauvis's ~6% cliff residual actually is (issue #18, #22). - * - * `cliffs-NOTES.md` named two causes for it on 2026-07-20 and **both are now - * falsified by measurement**: - * - * 1. `fixImpossibleCells` - ported 2026-07-28, and it does not change Nauvis by - * a single cell (`test/cliffFixImpossibleCells.spec.ts`). - * 2. `tryToAddCliff`'s `wouldCollide` water rejection - it can never fire, - * asserted below. - * - * A third, added 2026-07-28 and **falsified the same day**, was FIELD PRECISION. - * The threshold-sensitivity measurement below is real - our wrong cells do sit - * 3-4x closer to a band boundary than our right ones - but the inference drawn - * from it was wrong, and wrong by two and a half orders of magnitude. Boundary - * proximity is the generic signature of a MARGINAL DECISION; it does not name - * what tips the decision. Nobody had checked the one number that decides it: - * how big our field error actually is against how big it would have to be. - * - * `describe("...is far too small to be the cause")` below closes that, using - * only committed fixtures: - * - * - our `cliff_elevation_nauvis` agrees with the game to ~1e-4 (max 3.5e-4 - * over the 1024-point oracle grid), and - * - perturbing the field by that much flips **zero** cells; flipping the 16 - * Nauvis misses needs ~0.1, roughly 300x more error than we have. - * - * Corroborated off-fixture the same day by a direct capture of - * `cliff_elevation_nauvis` + `cliffiness_nauvis` at the exact corners of all 38 - * failing cells (both seeds): error there is 1.3e-4 median, statistically the - * same as at matched cells, the cliffiness gate is exact (0/102 and 0/19 - * mismatches), and re-running the crossing rule on the GAME'S OWN corner values - * reproduces our verdict at every one of the 38 - 0 differ. See - * `docs/noise/cliffs-NOTES.md` for what that leaves. - */ -const key = (p: { x: number; y: number }): string => `${String(p.x)},${String(p.y)}`; - -const CASES = (fx as unknown as { cases: { seed: number; cliffs: { x: number; y: number }[] }[] }) - .cases; - -function setup(seed: number): { - fields: ReturnType; - placed: { x: number; y: number }[]; - actual: Set; -} { - const fields = makeCliffFields({ - seed0: seed, - controls: { frequency: 1, continuity: 1 }, - settings: { cliffElevation0: 10, cliffElevationInterval: 40, richness: 1 }, - }); - const placed = makeCliffPlacementFromFields(fields, { - elevation0: 10, - interval: 40, - }).placedCells(512, 512, 1024, 1024); - const actual = new Set( - CASES.find((c) => c.seed === seed) - ?.cliffs.filter((p) => p.x >= 512 && p.x < 1024 && p.y >= 512 && p.y < 1024) - .map(key) ?? [], - ); - return { fields, placed, actual }; -} - -/** The 4x4 tile block a cell occupies: x in [cx-2, cx+2), y in [cy-2.5, cy+1.5). */ -function footprint(cx: number, cy: number): [number, number][] { - const tiles: [number, number][] = []; - const y0 = Math.floor(cy - 2.5); - for (let tx = cx - 2; tx < cx + 2; tx++) - for (let ty = y0; ty < y0 + 4; ty++) tiles.push([tx, ty]); - return tiles; -} - -describe("Nauvis cliff residual: water rejection cannot be the cause", () => { - for (const { seed } of CASES) { - it(`seed ${String(seed)}: no cliff cell touches water, ours or the game's`, () => { - // `tryToAddCliff` (`0x101625038`) rejects a cliff whose orientation-specific - // bounding box collides, via the map-gen per-tile mask grid. `generateCliffs` - // runs BEFORE `generateEntities`, so the only masks in that grid are the - // tiles' - and the only tile layer the cliff mask intersects is `water_tile`. - // So the whole rejection reduces to "no cliffs on water" here. - // - // It can never fire: `cliff_elevation_nauvis` is `10 + 30 * (...)` and - // `crossesCliff` needs both corners non-negative with max >= elevation_0, - // so the geometry already excludes everywhere water can be. - const { placed, actual } = setup(seed); - const resolve = makeTileResolver({ seed0: seed }); - const touchesWater = (cx: number, cy: number): boolean => - footprint(cx, cy).some(([tx, ty]) => resolve(tx, ty).name.includes("water")); - - for (const p of placed) expect(touchesWater(p.x, p.y)).toBe(false); - for (const k of actual) { - const [gx, gy] = k.split(",").map(Number); - expect(touchesWater(gx, gy)).toBe(false); - } - }, 120000); - - it(`seed ${String(seed)}: ...and that is not vacuous - the region really is wet`, () => { - // Without this the assertion above would pass just as happily if the tile - // resolver never returned water at all, or if `.includes("water")` matched - // nothing. Measured 2026-07-28: 21.1% of the region at seed 123456 and - // 71.9% at 777771, sampled every 4 tiles. - const resolve = makeTileResolver({ seed0: seed }); - let water = 0; - let n = 0; - for (let y = 512; y < 1024; y += 8) - for (let x = 512; x < 1024; x += 8) { - n++; - if (resolve(x, y).name.includes("water")) water++; - } - expect(water / n).toBeGreaterThan(0.15); - }, 120000); - } -}); - -describe("Nauvis cliff residual: RESOLVED 2026-07-30 - there are no wrong cells", () => { - for (const { seed } of CASES) { - it(`seed ${String(seed)}: every placed cell is a real cliff, and every real cliff is placed`, () => { - // Distance from the nearest cliff band boundary (`10 + 40k`), minimised - // over the cell's four corners. Measured 2026-07-28: - // - // | seed | matched p10/p50/p90 | mismatched p10/p50/p90 | - // | --- | --- | --- | - // | 123456 | 0.04 / 0.24 / 0.60 (n=266) | 0.02 / 0.07 / 0.25 (n=16) | - // | 777771 | 0.06 / 0.26 / 0.53 (n=49) | 0.04 / 0.06 / 0.06 (n=3) | - // - // Mismatched cells sit 3-4x closer to a boundary at the median. That is - // what a small field difference looks like - our cliff elevation and the - // game's disagree by enough to flip a corner across a band edge, but only - // where the corner was already sitting on one. A structural rule we had - // failed to port would not select for boundary proximity like this. - const { fields, placed, actual } = setup(seed); - const distance = (cx: number, cy: number): number => { - let best = Infinity; - for (const [dx, dy] of [ - [-2, -2], - [2, -2], - [-2, 2], - [2, 2], - ]) { - const e = fields.cliffElevation(cx + dx, cy + dy); - if (e < 0) continue; - const d = (((e - 10) % 40) + 40) % 40; - best = Math.min(best, Math.min(d, 40 - d)); - } - return best; - }; - - const matched: number[] = []; - const mismatched: number[] = []; - for (const p of placed) (actual.has(key(p)) ? matched : mismatched).push(distance(p.x, p.y)); - - expect(matched.length).toBeGreaterThan(40); - - // **The residual is GONE.** Not shrunk - zero. Every cell we place is a - // real cliff (no false positives) and the recall/precision spec in - // `cliffPlacement.spec.ts` now measures 1.0000 / 1.0000 / ratio 1.000 at - // both seeds, up from 0.943 / 0.943. - // - // The cause was the SAMPLE LATTICE, not the rule and not the field: the - // port added the prototype's `grid_offset {0, 0.5}` - a CENTRE offset - - // to the field sample position as well, reading every corner half a tile - // off in y. It moved no placed cliff, so every positional check passed. - // See `CLIFF_CELL_CENTER_X` in cliffCatalog.ts. - // - // This block used to assert the OPPOSITE - that mismatched cells exist - // and sit closer to band edges than matched ones (medians 3.4x and 4.3x - // apart). That measurement was real and is preserved in git; it described - // a marginal decision, not a cause, exactly as `boundary-proximity-is-not - // -a-cause` concluded. `distance` above is kept because the loop still - // partitions on it, which is what proves the mismatched set is empty - // because there is nothing in it - not because the loop never ran. - expect(mismatched.length).toBe(0); - expect(actual.size).toBe(placed.length); - }, 120000); - } -}); - -describe("Nauvis cliff residual: the field error is far too small to be the cause", () => { - // The two halves of the falsification. Neither needs a Factorio install: the - // first reads the committed `cliff_elevation_nauvis` oracle grid, the second - // is pure arithmetic on the placement rule. - - it("our cliff elevation agrees with the game to ~1e-4", () => { - // `cliffFields.spec.ts` guards this field at a 1% RELATIVE tolerance, which - // on a 40-wide band is +-0.4 - five times the distance that separates a - // matched cell from a mismatched one, so it can neither confirm nor exclude - // the precision story. This records the ACTUAL agreement instead. - // - // Measured 2026-07-28 over the fixture's 1024 corner-lattice points: - // - // | seed | p50 abs | p90 abs | max abs | - // | ------ | ------- | ------- | ------- | - // | 123456 | 1.03e-4 | 2.13e-4 | 3.55e-4 | - // | 777771 | 1.48e-4 | 3.11e-4 | 4.85e-4 | - // - // Note the game's own values come back as exact f32 (the worst point reads - // 23.576189041137695), so this ~1e-4 is our port's numerical distance from - // the game - the fastapprox floor compounding through the hills chain - not - // a quantisation artefact of the capture. - for (const c of elevFixture.cases) { - const f = makeCliffElevation({ - seed0: c.seed, - controls: { frequency: 1, continuity: 1 }, - settings: { cliffElevation0: 10, cliffElevationInterval: 40, richness: 1 }, - }); - let worst = 0; - for (let i = 0; i < elevFixture.positions.length; i++) { - const p = elevFixture.positions[i]; - worst = Math.max(worst, Math.abs(f(p.x, p.y) - c.values[i])); - } - // Pinned just outside the measured 4.85e-4. If a future change to the - // hills chain closes the fastapprox gap this fails and wants re-measuring - // DOWNWARD - which would be good news, and would also make the sweep - // below even more conclusive. - expect(worst).toBeLessThan(1e-3); - } - }); - - it("...and perturbing the field by that much flips no cells at all", () => { - // The decisive comparison. Jitter the cliff elevation field by +-eps at - // every corner and count how many placed cells change. Measured 2026-07-28 - // at seed 123456 (mean of 3 independent deterministic draws): - // - // | eps | cells changed | - // | ------ | ------------- | - // | 3.5e-4 | 0.0 | <- our ACTUAL worst-case field error - // | 1e-3 | 0.0 | - // | 1e-2 | 0.3 | - // | 5e-2 | 4.3 | - // | 1e-1 | 9.0 | - // | 3e-1 | 39.0 | - // - // Nauvis misses 16 cells. Reaching 16 takes eps of order 0.1 - about 300x - // the error we actually carry, and ~200x the max. A field difference of - // 1e-4 cannot move a decision that sits 0.07 from a boundary, which is - // exactly where the mismatched cells sit (measured above). So the residual - // is NOT f32 rounding and NOT the fastapprox floor. - const seed = 123456; - const base = makeCliffFields({ - seed0: seed, - controls: { frequency: 1, continuity: 1 }, - settings: { cliffElevation0: 10, cliffElevationInterval: 40, richness: 1 }, - }); - const bands = { elevation0: 10, interval: 40 }; - const baseline = new Set( - makeCliffPlacementFromFields(base, bands).placedCells(512, 512, 1024, 1024).map(key), - ); - - /** Deterministic hash jitter in [-eps, eps], stable per corner. */ - const jitter = (x: number, y: number, eps: number, salt: number): number => { - let h = - Math.imul(x | 0, 0x27d4eb2d) ^ Math.imul(y | 0, 0x165667b1) ^ Math.imul(salt, 0x9e3779b1); - h ^= h >>> 15; - h = Math.imul(h, 0x85ebca6b); - h ^= h >>> 13; - return (((h >>> 0) / 0xffffffff) * 2 - 1) * eps; - }; - const changedAt = (eps: number, salt: number): number => { - const placed = makeCliffPlacementFromFields( - { - cliffElevation: (x, y) => base.cliffElevation(x, y) + jitter(x, y, eps, salt), - cliffiness: base.cliffiness, - }, - bands, - ).placedCells(512, 512, 1024, 1024); - const set = new Set(placed.map(key)); - let changed = 0; - for (const k of set) if (!baseline.has(k)) changed++; - for (const k of baseline) if (!set.has(k)) changed++; - return changed; - }; - - // At our real worst-case error, nothing moves - across independent draws. - for (const salt of [1, 2, 3]) expect(changedAt(3.5e-4, salt)).toBe(0); - - // Non-vacuity: the jitter IS reaching the placement rule. Without this the - // assertion above would pass just as happily against a no-op perturbation, - // which is precisely the shape of a test that confirms nothing. - expect(changedAt(1, 1)).toBeGreaterThan(50); - - // And the residual-sized effect needs a residual-sized error: an order of - // magnitude more than 1e-2, i.e. ~1e-1, not ~1e-4. - expect(changedAt(1e-2, 1)).toBeLessThan(4); - }, 120000); -}); - -describe("Vulcanus's residual: RESOLVED 2026-08-01, and it was never threshold noise", () => { - /** - * **This block used to measure how far Vulcanus's wrong cells sat from a band - * edge, to argue its residual was structural rather than precision noise. That - * argument was right, and the structure has now been found**, so the - * measurement no longer has a population to run on. - * - * The cause was `multisample`: its offsets are in the consuming noise - * program's GRID UNITS, not tiles, so `vulcanus_basalt_lakes_multisample`'s - * 2x2 min-filter spans 4 tiles for the cliff generator and 1 tile for every - * per-tile consumer. The port used 1 everywhere, making the cliff elevation - * too rough. See `test/multisampleGrid.spec.ts`. - * - * What is left of the numbers this file used to record: the port now matches - * the game's cliff set at **recall 1.000 / 0.973 / 0.965** across the three - * regions, so the "mismatched" population is a handful of cells per region - - * far too few for the median-distance comparison that used to live here, which - * needed 20+ per region and now finds as few as 9. - * - * The Nauvis half of the argument (above) is untouched and still stands. - */ - it("no longer has a mismatched population large enough to compare", () => { - const ctx = withCtxDefaults({ seed0: vFix.seed, startingPositions: [{ x: 0, y: 0 }] }); - const fields = makeVulcanusCliffFields(ctx); - for (const c of vFix.cases) { - const r = c.region; - const placed = makeCliffPlacementFromFields(fields, { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, - }).placedCells(r.x0, r.y0, r.x1, r.y1); - const actual = new Set(c.cliffs.filter((p) => p.name === "cliff-vulcanus").map(key)); - let mismatched = 0; - for (const p of placed) if (!actual.has(key(p))) mismatched++; - // Non-vacuity: the port is placing a real number of cells, so a low - // mismatch count means agreement and not an empty result. - expect(placed.length).toBeGreaterThan(200); - // Measured 9 / 209 / 7 over the three regions, against the 20+ per region - // the retired comparison required. Pinned as an upper bound so it can only - // improve; `[1500,1500]` is the region still carrying real over-placement, - // and the lava-collision rejection this arm does not apply removes much of - // it in the shipping renderer. - expect(mismatched).toBeLessThanOrEqual(210); - } - }, 300000); -}); diff --git a/test/cliffResidualBorderEnrichment.spec.ts b/test/cliffResidualBorderEnrichment.spec.ts deleted file mode 100644 index b4384198..00000000 --- a/test/cliffResidualBorderEnrichment.spec.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import batch from "./fixtures/oracle-vulcanus-cliff-entities-border-batch.seed123456.json"; -import { CLIFF_CODE_TO_ORIENTATION, cliffCollisionTileBox } from "../src/noise/cliffs/cliffCatalog"; -import { cliffCodeForOrientation, onChunkBorder } from "../src/noise/cliffs/cliffConnections"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { makeVulcanusOreRejection } from "../src/noise/cliffs/vulcanusOreRejection"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; -import { buildResources } from "../src/noise/preview/renderVulcanusResources"; -import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -/** - * **The unexplained residual sits on CHUNK BORDERS - 2.91 sigma over 15 regions, - * with the prediction registered before the data** (#84). - * - * This closes a question that has been open in three stages, and the way it - * closed matters as much as the answer. - * - * | batch | unexplained | on chunk border | base rate | z | - * | --- | --- | --- | --- | --- | - * | original 3 regions | 14 | 9 (64.3%) | 45.0% | 1.45 | - * | +4 regions (#131) | 13 | 9 (69.2%) | 47.2% | 1.59 | - * | **+8 regions (here)** | 17 | **12 (70.6%)** | 46.0% | **2.03** | - * | **combined** | **44** | **30 (68.2%)** | ~46.3% | **2.91** | - * - * The first row was correctly **dismissed as noise** - at n = 14 any partition - * lands near there. The second was the same hypothesis on fresh data. This third - * batch was captured against a prediction written into - * `captureVulcanusCliffEntitiesBorderBatch`'s doc comment **before it ran**: - * roughly 26 more unexplained cells with about 17 on a border, taking the - * combined figure to ~2.9 sigma - and a fall back toward the base rate if the - * effect was noise. - * - * It came back at 12 of 17 and a combined **2.91 sigma**. The effect size is - * stable across all three batches (64-71%) and so is the base rate it is - * measured against (45-47%), which is what says the comparison is sound rather - * than the numerator being lucky. - * - * ## Why this points somewhere - * - * **Chunk borders are `Cliff::updateConnections`' entire domain.** `applyCliffs` - * gates it on `tryToAddCliff`'s fifth argument, which is `!onChunkBorder`, so it - * runs on the chunk's outer ring and nowhere else. - * - * > **CORRECTION, 2026-08-03 (#84):** this paragraph used to end "It is the only - * > rule in the pipeline that treats border cells differently at all." That was - * > wrong. A cliff's collision box reaches up to 3.371 tiles and a border cell's - * > centre is 1.5-2.5 tiles from the chunk edge, so 16 of the 20 orientations - * > reach across an edge from the outer ring and none can from anywhere else - - * > a second border-only channel. `cliffResidualBoxCrossesChunkEdge.spec.ts` - * > scores it and finds it **inert**: 7 of 1407 crossing border cells are - * > unexplained against 14 of 2479 non-crossing, where no-information predicts - * > 7.6. That strengthens the conclusion below rather than weakening it - the - * > enrichment is orientation-BLIND, which is what a cell-index gate looks like. - * - * And it is exactly the rule the port has the weakest grip on: - * - * - `test/cliffConnections.spec.ts` measures it firing **zero** times on our own - * cell set, and `applyCliffConnections` documents its model of the rule as an - * **UPPER bound** on how much it removes - the one place the port is knowingly - * not a transcription. - * - #122 promoted its gate from inert to load-bearing. - * - #127 showed the gate cannot be scored from map-generation output at all, - * because destruction, `updateConnections` and the crossing field all preserve - * connection consistency. - * - * So the port models this rule approximately, cannot observe it directly, and - * the residual it cannot explain is enriched 1.47x on precisely its domain. - * **That is the first positive evidence that `updateConnections` does anything**, - * and it is where the next attempt should go. - * - * ## What it does NOT say - * - * It does not say the 30 border cells are destroyed BY `updateConnections`. The - * enrichment is a correlation with the rule's domain, not a demonstration of the - * rule firing - and 14 of the 44 are not on a border at all, so if this is one - * cause it is not the only one. p is about 0.002, which is a strong hint and not - * a proof of mechanism. - * - * ## The port also generalises, across fifteen regions now - * - * | | this batch | - * | --- | --- | - * | raw cells / game cliffs | 5581 / 5134 | - * | raw is a strict superset | **yes, all eight** | - * | destruction predicate precision | **0.9818** | - * | destruction predicate recall | **0.8434** | - * - * Two of the eight regions have **no unexplained cells at all**, and - * `[-1600,3200]` reproduces the game exactly - 686 raw, 686 game. - */ - -const INPUT = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }; -const ctx = withCtxDefaults(INPUT); -const fields = makeVulcanusCliffFields(ctx); -const tileAt = makeVulcanusTileResolver(INPUT); -const oreRejects = makeVulcanusOreRejection(buildResources(ctx), ctx.vulcanusResourceControls); -const K = (x: number, y: number): string => `${String(x)},${String(y)}`; -const BANDS = { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, -}; -const isLava = (x: number, y: number): boolean => - VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name); - -interface Ent { - x: number; - y: number; - name: string; - orientation?: string; -} -interface Region { - x0: number; - y0: number; - x1: number; - y1: number; -} -interface Case { - label: string; - region: Region; - cliffs: Ent[]; -} -const CASES = batch.cases as unknown as Case[]; - -interface Totals { - raw: number; - game: number; - rawOnBorder: number; - ourKill: number; - gameKill: number; - agree: number; - falseRejections: number; - missed: number; - ore: number; - unknown: number; - unknownOnBorder: number; - superset: boolean; - perRegionUnknown: number[]; -} - -const T: Totals = (() => { - const t: Totals = { - raw: 0, - game: 0, - rawOnBorder: 0, - ourKill: 0, - gameKill: 0, - agree: 0, - falseRejections: 0, - missed: 0, - ore: 0, - unknown: 0, - unknownOnBorder: 0, - superset: true, - perRegionUnknown: [], - }; - for (let i = 0; i < CASES.length; i += 2) { - const on = CASES[i]; - const off = CASES[i + 1]; - const r = on.region; - const inR = (p: { x: number; y: number }): boolean => - p.x >= r.x0 && p.x < r.x1 && p.y >= r.y0 && p.y < r.y1; - const game = new Set( - on.cliffs.filter((e) => e.name === "cliff-vulcanus" && inR(e)).map((e) => K(e.x, e.y)), - ); - const gameOff = new Set( - off.cliffs.filter((e) => e.name === "cliff-vulcanus" && inR(e)).map((e) => K(e.x, e.y)), - ); - const oreSuppressed = new Set([...gameOff].filter((k) => !game.has(k))); - const cells = makeCliffPlacementFromFields(fields, BANDS) - .placedCells(r.x0 - 64, r.y0 - 64, r.x1 + 64, r.y1 + 64) - .filter(inR); - let regionUnknown = 0; - for (const p of cells) { - const o = CLIFF_CODE_TO_ORIENTATION[p.code]; - if (o === undefined) continue; - const k = K(p.x, p.y); - const code = cliffCodeForOrientation(o); - const box = cliffCollisionTileBox(code, p.x, p.y); - let lava = false; - if (box !== undefined) - for (let tx = box.left; tx <= box.right; tx++) - for (let ty = box.top; ty <= box.bottom; ty++) if (isLava(tx, ty)) lava = true; - const ourKill = lava || oreRejects(code, p.x, p.y); - const gameKill = !game.has(k); - const border = onChunkBorder(p.x, p.y); - t.raw++; - if (border) t.rawOnBorder++; - if (ourKill) t.ourKill++; - if (gameKill) t.gameKill++; - if (ourKill && gameKill) t.agree++; - else if (ourKill) t.falseRejections++; - else if (gameKill) { - t.missed++; - if (oreSuppressed.has(k)) t.ore++; - else { - t.unknown++; - regionUnknown++; - if (border) t.unknownOnBorder++; - } - } - } - const have = new Set(cells.map((p) => K(p.x, p.y))); - for (const k of game) if (!have.has(k)) t.superset = false; - t.game += game.size; - t.perRegionUnknown.push(regionUnknown); - } - return t; -})(); - -/** One-sample binomial z, the same statistic quoted in the doc comment. */ -const z = (k: number, n: number, p: number): number => (k - n * p) / Math.sqrt(n * p * (1 - p)); - -describe("the pre-registered chunk-border test, on eight fresh regions", () => { - /** - * The sample and the base rate first, because the enrichment is only worth - * anything against them. The base rate is stable at 46.0% here against 45.0% - * and 47.2% in the two earlier batches, so the denominator is not drifting. - */ - it("captures 5581 raw cells at a 46.0% border base rate", () => { - expect(CASES.length).toBe(16); - expect(T.raw).toBe(5581); - expect(T.game).toBe(5134); - expect(T.rawOnBorder / T.raw).toBeCloseTo(0.46, 2); - }, 900000); - - /** - * **The prediction, and the result.** Registered before the capture: roughly - * 26 more unexplained cells with about 17 on a border. It came back 17 and 12 - * - fewer cells than predicted, at a HIGHER rate than predicted, and the - * combined figure landed on the predicted 2.9 sigma. - */ - it("finds 12 of 17 unexplained cells on a chunk border", () => { - expect(T.unknown).toBe(17); - expect(T.unknownOnBorder).toBe(12); - expect(T.unknownOnBorder / T.unknown).toBeCloseTo(0.706, 3); - // On its own this batch clears 2 sigma. - expect(z(T.unknownOnBorder, T.unknown, T.rawOnBorder / T.raw)).toBeGreaterThan(2); - }, 900000); - - /** - * **Combined over all fifteen regions: 30 of 44, 2.91 sigma.** Three - * independent batches, two of them pre-registered, with a stable effect size - * (64.3%, 69.2%, 70.6%) against a stable base rate (45.0%, 47.2%, 46.0%). - */ - it("brings the combined figure to 30 of 44 at 2.91 sigma", () => { - const combinedN = 27 + T.unknown; - const combinedK = 18 + T.unknownOnBorder; - expect(combinedN).toBe(44); - expect(combinedK).toBe(30); - expect(combinedK / combinedN).toBeCloseTo(0.682, 3); - expect(z(combinedK, combinedN, 0.463)).toBeCloseTo(2.91, 2); - }, 900000); - - /** - * **And the honest limit.** 14 of the 44 are NOT on a border, so if this is one - * cause it is not the only one - and an enrichment on a rule's domain is not - * the rule firing. This arm exists so the count cannot be quietly rounded to - * "the residual is updateConnections". - */ - it("leaves 14 of the 44 off the border", () => { - expect(27 + T.unknown - (18 + T.unknownOnBorder)).toBe(14); - }, 900000); -}); - -describe("the port on eight more regions it was never fitted to", () => { - it("keeps the superset property and scores 0.982 / 0.843", () => { - expect(T.superset).toBe(true); - expect(T.ourKill).toBe(384); - expect(T.gameKill).toBe(447); - expect(T.agree).toBe(377); - expect(T.agree / T.ourKill).toBeCloseTo(0.9818, 4); - expect(T.agree / T.gameKill).toBeCloseTo(0.8434, 4); - expect(T.falseRejections).toBe(7); - }, 900000); - - /** - * A quarter of the regions have nothing to explain at all, which is worth - * pinning: the residual is not a uniform background rate, it is concentrated. - */ - it("finds two of the eight regions completely explained", () => { - expect(T.perRegionUnknown).toEqual([0, 2, 4, 0, 1, 4, 3, 3]); - expect(T.perRegionUnknown.filter((n) => n === 0).length).toBe(2); - expect(T.missed).toBe(70); - expect(T.ore).toBe(53); - expect(T.unknown).toBe(17); - }, 900000); -}); diff --git a/test/cliffResidualBoxCrossesChunkEdge.spec.ts b/test/cliffResidualBoxCrossesChunkEdge.spec.ts deleted file mode 100644 index 76cd3351..00000000 --- a/test/cliffResidualBoxCrossesChunkEdge.spec.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import batch from "./fixtures/oracle-vulcanus-cliff-entities-border-batch.seed123456.json"; -import more from "./fixtures/oracle-vulcanus-cliff-entities-more-regions.seed123456.json"; -import { CLIFF_CODE_TO_ORIENTATION, cliffCollisionTileBox } from "../src/noise/cliffs/cliffCatalog"; -import { cliffCodeForOrientation, onChunkBorder } from "../src/noise/cliffs/cliffConnections"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { makeVulcanusOreRejection } from "../src/noise/cliffs/vulcanusOreRejection"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; -import { buildResources } from "../src/noise/preview/renderVulcanusResources"; -import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -/** - * **A SECOND border-only mechanism exists, and it is measured to be INERT - so - * the border enrichment is orientation-blind, which is `updateConnections`' - * signature and not the collision box's** (#84). - * - * `cliffResidualBorderEnrichment.spec.ts` reports the residual sitting on chunk - * borders at 2.91 sigma and says, in its own words, that `updateConnections` "is - * the only rule in the pipeline that treats border cells differently at all". - * **That sentence was false**, and the counter-example is arithmetic rather than - * a new reading: - * - * | | distance from the cell centre to the nearest chunk edge | - * | --- | --- | - * | border ring (`ix` or `iy` is 0 or 7) | 1.5 - 2.5 tiles | - * | every interior ring | 5.5 - 6.5 tiles | - * - * and the largest half-extent in `CLIFF_ORIENTATION_COLLISION_BOX` is **3.371** - * tiles. So 16 of the 20 orientations reach across a chunk edge from the outer - * ring, and none of them can from anywhere else - a border-only channel with - * nothing to do with `updateConnections`. The first `describe` asserts both - * halves of that against the real cell population rather than leaving it as - * arithmetic. - * - * There is a specific rule on the far side of it, read out of the binary on - * 2026-08-03. `applyCliffs` rejects through `Surface::wouldCollide` - * (`0x10160c088`), whose tile half is `Surface::constCollideWithTile` - * (`0x100732eec`) -> `Surface::checkTileCollisions` (`0x101b579e0`), which per - * tile calls `Surface::getEffectiveTileID` (`0x10049399c`) and **skips the tile - * when the id is 0** (`tst w0, #0xffff; b.eq`). `getEffectiveTileID` returns - * exactly 0 when the chunk is absent - the range and null arms from - * `0x100493a60` onwards all fall through to `mov w27, #0x0`. So a box reaching - * into a chunk that is not generated yet reads **no tile there and does not - * collide**, while this port reads the real tile everywhere. - * - * ## What was discriminated, and the losing condition, both registered first - * - * Two mechanisms both predict a border enrichment and differ in one observable: - * whether the ORIENTATION matters. - * - * - `updateConnections` is orientation-blind about the border. Its gate is - * `!onChunkBorder` from `generateCliffs`, a pure cell-index test. Under it a - * border cell whose box stays inside its chunk is exactly as suspect as one - * whose box crosses. - * - The box mechanism fires only when the box actually crosses, so the crossing - * cells carry the whole effect and the rest fall back to the base rate. - * - * The losing condition was written down before the run: **a non-crossing border - * rate that is not lower than the crossing one refutes the box mechanism.** - * - * ## The result: refuted, and by the margin that leaves no room - * - * | population | unexplained | rate | - * | --- | --- | --- | - * | interior | 9 / 4484 | 0.20% | - * | border, box crosses a chunk edge | 7 / 1407 | **0.50%** | - * | border, box does not cross | 14 / 2479 | **0.56%** | - * - * Under "crossing carries no information beyond `border`" the expected crossing - * count is `21 * 1407/3886 = 7.6`. **Observed 7.** The sharper predicate is not - * merely weaker than hoped, it is indistinguishable from the null, and the - * non-crossing rate is if anything the higher of the two. - * - * The mechanism's own direction fails too. An absent neighbour chunk makes the - * game KEEP a cliff this port destroys - a FALSE REJECTION, not a missed - * destruction - so its signature is a false rejection whose blocking tile lies - * across a chunk edge. There are 9 false rejections here and **zero** of them - * have any blocking tile on the far side of an edge. - * - * ## Why a refutation is the useful outcome - * - * The border enrichment now has one fewer competing explanation, and the - * surviving one gained a property it did not have: the effect is **orientation- - * blind**, which is what a cell-index gate looks like and not what a geometric - * reach looks like. Every border-only channel this port can currently name has - * been scored, and only `updateConnections` is left unscored - because #127 - * showed it cannot be scored from map-generation output at all. - * - * ## The sample - * - * The twelve regions captured in #131 and #132 - every region whose ON and - * resources-OFF arms sit in one fixture, so the ore split is computed exactly as - * in the two specs above. The three original regions are not here; their - * unexplained cells are classified in `cliffMissedDestructionsLever.spec.ts` - * against different fixtures. n is 30 of the 44, and it is the OUT-OF-SAMPLE 30 - * - the border counts reproduce #131's 9 of 13 and #132's 12 of 17 exactly, - * which is the arm saying this harness is the same measurement. - */ - -const INPUT = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }; -const ctx = withCtxDefaults(INPUT); -const fields = makeVulcanusCliffFields(ctx); -const tileAt = makeVulcanusTileResolver(INPUT); -const oreRejects = makeVulcanusOreRejection(buildResources(ctx), ctx.vulcanusResourceControls); -const K = (x: number, y: number): string => `${String(x)},${String(y)}`; -const BANDS = { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, -}; -const isLava = (x: number, y: number): boolean => - VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name); - -/** Chunk index of a tile index. Chunks are 32 tiles and the origin is a corner. */ -const chunkOfTile = (t: number): number => Math.floor(t / 32); - -interface Ent { - x: number; - y: number; - name: string; -} -interface Case { - label: string; - region: { x0: number; y0: number; x1: number; y1: number }; - cliffs: Ent[]; -} -const CASES = [...(more.cases as unknown as Case[]), ...(batch.cases as unknown as Case[])]; - -interface Cell { - border: boolean; - crosses: boolean; - ourKill: boolean; - gameKill: boolean; - ore: boolean; - /** A blocking tile inside the box that sits in a different chunk than the centre. */ - lavaAcrossEdge: boolean; -} - -const CELLS: Cell[] = (() => { - const out: Cell[] = []; - for (let i = 0; i < CASES.length; i += 2) { - const on = CASES[i]; - const off = CASES[i + 1]; - const r = on.region; - const inR = (p: { x: number; y: number }): boolean => - p.x >= r.x0 && p.x < r.x1 && p.y >= r.y0 && p.y < r.y1; - const game = new Set( - on.cliffs.filter((e) => e.name === "cliff-vulcanus" && inR(e)).map((e) => K(e.x, e.y)), - ); - const gameOff = new Set( - off.cliffs.filter((e) => e.name === "cliff-vulcanus" && inR(e)).map((e) => K(e.x, e.y)), - ); - const oreSuppressed = new Set([...gameOff].filter((k) => !game.has(k))); - const cells = makeCliffPlacementFromFields(fields, BANDS) - .placedCells(r.x0 - 64, r.y0 - 64, r.x1 + 64, r.y1 + 64) - .filter(inR); - for (const p of cells) { - const o = CLIFF_CODE_TO_ORIENTATION[p.code]; - if (o === undefined) continue; - const code = cliffCodeForOrientation(o); - const box = cliffCollisionTileBox(code, p.x, p.y); - if (box === undefined) continue; - const cx = chunkOfTile(Math.floor(p.x)); - const cy = chunkOfTile(Math.floor(p.y)); - const crosses = - chunkOfTile(box.left) !== cx || - chunkOfTile(box.right) !== cx || - chunkOfTile(box.top) !== cy || - chunkOfTile(box.bottom) !== cy; - let lava = false; - let lavaAcrossEdge = false; - for (let tx = box.left; tx <= box.right; tx++) - for (let ty = box.top; ty <= box.bottom; ty++) - if (isLava(tx, ty)) { - lava = true; - if (chunkOfTile(tx) !== cx || chunkOfTile(ty) !== cy) lavaAcrossEdge = true; - } - const k = K(p.x, p.y); - out.push({ - border: onChunkBorder(p.x, p.y), - crosses, - ourKill: lava || oreRejects(code, p.x, p.y), - gameKill: !game.has(k), - ore: oreSuppressed.has(k), - lavaAcrossEdge, - }); - } - } - return out; -})(); - -/** The unexplained residual: the game destroyed it, we kept it, and it is not ore. */ -const isUnknown = (c: Cell): boolean => c.gameKill && !c.ourKill && !c.ore; - -interface Rate { - k: number; - n: number; -} -const rate = (pick: (c: Cell) => boolean): Rate => ({ - k: CELLS.filter((c) => pick(c) && isUnknown(c)).length, - n: CELLS.filter(pick).length, -}); - -const ALL = rate(() => true); -const INTERIOR = rate((c) => !c.border); -const BORDER = rate((c) => c.border); -const CROSS = rate((c) => c.border && c.crosses); -const NOCROSS = rate((c) => c.border && !c.crosses); - -describe("the collision box singles out the border ring by geometry", () => { - /** - * The arithmetic the whole comparison rests on, asserted against the real cell - * population rather than left in prose. If an interior cell ever crosses an - * edge the discrimination below stops meaning anything. - */ - it("lets only border cells cross a chunk edge", () => { - const crossing = CELLS.filter((c) => c.crosses); - expect(crossing.length).toBe(1407); - expect(crossing.every((c) => c.border)).toBe(true); - }); - - it("leaves most border cells NOT crossing, so the two are separable", () => { - expect(CROSS.n).toBe(1407); - expect(NOCROSS.n).toBe(2479); - }); - - /** - * The harness reproduces the two published batches cell for cell - #131's - * 9 of 13 and #132's 12 of 17 - which is what says this is the same - * measurement partitioned differently rather than a new one. - */ - it("reproduces the published border counts, 21 of 30", () => { - expect(ALL.k).toBe(30); - expect(BORDER.k).toBe(21); - expect(ALL.n).toBe(8370); - expect(BORDER.n).toBe(3886); - }); -}); - -describe("REFUTED: crossing carries no information beyond `border`", () => { - /** - * The registered losing condition was "a non-crossing border rate that is not - * lower than the crossing one refutes the box mechanism". It came back - * slightly HIGHER, so the refutation is not marginal. - */ - it("finds the non-crossing border rate no lower than the crossing one", () => { - const cross = CROSS.k / CROSS.n; - const nocross = NOCROSS.k / NOCROSS.n; - expect(CROSS.k).toBe(7); - expect(NOCROSS.k).toBe(14); - expect(nocross).toBeGreaterThanOrEqual(cross); - }); - - /** - * The same thing as a count rather than a ratio: spread the 21 border cells - * over the two populations in proportion and the crossing share is 7.6. - * Observed 7 - inside one cell of the null, which is as close to "this - * predicate is noise" as a sample of 21 can get. - */ - it("lands within one cell of the no-information expectation", () => { - const expected = (BORDER.k * CROSS.n) / BORDER.n; - expect(expected).toBeGreaterThan(7); - expect(expected).toBeLessThan(8); - expect(Math.abs(CROSS.k - expected)).toBeLessThan(1); - }); - - /** - * And the enrichment that IS real survives the partition unchanged, which is - * the arm proving the comparison had something to find. Border cells are - * 2.7x the interior rate whether or not their box crosses. - */ - it("keeps the border-versus-interior enrichment in both halves", () => { - const interior = INTERIOR.k / INTERIOR.n; - expect(CROSS.k / CROSS.n).toBeGreaterThan(2 * interior); - expect(NOCROSS.k / NOCROSS.n).toBeGreaterThan(2 * interior); - }); -}); - -describe("the absent-chunk tile read explains no false rejection either", () => { - /** - * Its signature is the opposite of the residual: the game KEEPS a cliff this - * port destroys, because the tile that made us destroy it sits in a chunk that - * was not generated when `applyCliffs` ran. So every false rejection it could - * explain must have a blocking tile across a chunk edge. None does. - */ - it("finds zero of the 9 false rejections with a blocking tile across an edge", () => { - const fr = CELLS.filter((c) => c.ourKill && !c.gameKill); - expect(fr.length).toBe(9); - expect(fr.filter((c) => c.lavaAcrossEdge).length).toBe(0); - }); - - /** - * Non-vacuity: cells whose blocking tile IS across an edge exist in the - * population, so "zero" above is a measurement and not an empty predicate. - */ - it("is not vacuous - the predicate fires elsewhere in the population", () => { - expect(CELLS.filter((c) => c.lavaAcrossEdge).length).toBeGreaterThan(0); - }); -}); diff --git a/test/cliffResidualCascadeAudit.spec.ts b/test/cliffResidualCascadeAudit.spec.ts deleted file mode 100644 index e0185d64..00000000 --- a/test/cliffResidualCascadeAudit.spec.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import batch from "./fixtures/oracle-vulcanus-cliff-entities-border-batch.seed123456.json"; -import more from "./fixtures/oracle-vulcanus-cliff-entities-more-regions.seed123456.json"; -import oreRegions from "./fixtures/oracle-vulcanus-cliff-ore-direction-regions.seed123456.json"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - CLIFF_CODE_TO_ORIENTATION, - CLIFF_GRID_SIZE, - cliffCollisionTileBox, -} from "../src/noise/cliffs/cliffCatalog"; -import { - cliffCodeForOrientation, - connectedSides, - destroyEnd, - isCliffConnected, - onChunkBorder, - oppositeSide, -} from "../src/noise/cliffs/cliffConnections"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { makeVulcanusOreRejection } from "../src/noise/cliffs/vulcanusOreRejection"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; -import { buildResources } from "../src/noise/preview/renderVulcanusResources"; -import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -/** - * **The chunk-border enrichment is NOT a cascade artifact - it survives, and - * STRENGTHENS** (#84). And a shipping accuracy gain is measured and left on the - * table deliberately. - * - * #142 found that four of the ten apparent misses in the ORE recall gap were - * never geometry failures: they were cascade casualties, cells the game removed - * because a neighbour we correctly rejected took their only end. That is the - * most natural deflationary explanation for the border residual too - the - * residual is counted at the OUTPUT (cells the game killed that our predicates - * do not), so a cascade casualty of a correct kill counts as an independent - * defect. If the 44 were largely such casualties, the enrichment could be an - * artifact of counting. - * - * **It is not.** Applying the destruction cascade - the one #139 confirmed - * against the game with a runtime probe, and #141 confirmed again against - * ordinary map-generation output - to the port's OWN kill set: - * - * | | unknown | on border | z | false rejections | - * | --- | --- | --- | --- | --- | - * | before cascade | 33 | 22 | 2.36 | 12 | - * | after cascade | **23** | 17 | **2.67** | **14** | - * - * The cascade explains 10 of the 33, and the enrichment does not dissolve - it - * concentrates. So the border signal has now survived its two most plausible - * deflations: the orientation-reach rival (#134) and cascade double-counting - * here. - * - * ## The accuracy gain, measured and NOT taken - * - * The same run says the port is leaving cells on the table: **10 fewer missed - * for 2 more false rejections**, a net 8 over these regions. `renderVulcanusCliffs` - * does not cascade at all today - `applyCliffConnections` exists but is used only - * by specs. - * - * **That +8 is measured against the wrong baseline, and the real figure is +2.** - * It scores the cascade against a POST-FILTER model - kills applied, no cascade - - * which is not what ships. `renderVulcanusCliffs` ships `rejectAtCrossingStage`, - * which zeroes a rejected cell's four edges so its neighbours lose the shared - * one, and that already reproduces most of the cascade. Priced against the - * shipping model on the error budget's own regions the gain is **+2 positions - * and +3 orientations** - see `test/cliffCrossChunkCascade.spec.ts`, which also - * finds that a cascade forbidden to cross a chunk boundary is byte-identical to - * what ships, so the entire gain is the CROSS-CHUNK part. Leave the number - * below as it stands (it is correct for the baseline it names) and read this - * paragraph before quoting it. - * - * It is recorded rather than adopted on purpose. Adopting it changes rendered - * ORIENTATIONS as well as positions, needs `cliffErrorBudget.spec.ts` moved in - * lockstep (that file's own header records the day it drifted), and - * `applyCliffConnections` additionally bundles the `updateConnections` model, - * which is an explicit UPPER BOUND and is not what this audit applied. Whoever - * takes it should apply the destroy cascade alone first and re-measure the whole - * budget. - * - * **The 2 new false rejections are the thing to look at first**, not the 10 - * wins: they are cells the game KEPT that our cascade removes, and #134 recorded - * a gate the port does not model - `Cliff::destroyEnd` refuses to `forceDestroy` - * when entity flag bit 4 of `+0x6e` is set, returning with the orientation - * UNCHANGED rather than merely undestroyed. - * - * **That look has since happened, and the 2 are NOT a cascade defect** - see - * `test/cliffCascadeFalseRejections.spec.ts`. Splitting every secondary removal - * by whether the game also destroyed the ROOT of its chain gives 27 correct out - * of 27 on correct roots, and both disagreements descend from a kill that was - * already wrong (one ore, one lava). So the cost priced here is not intrinsic to - * the cascade, and #134's gate is unsupported rather than needed. - * - * ## Scope, stated because the headline number differs from the published one - * - * This pairs **14 regions** across three fixtures, not the 15 the published - * 44-cell figure covers: `[1500,1500]`'s ON/OFF pair lives in - * `oracle-vulcanus-cliff-ore-direction`, a fixture with a different case shape. - * The overlap is the check that this harness is measuring the same thing - on - * the eight-region border batch it reproduces **17 unknown, 12 on a border**, - * which is exactly what `cliffResidualBorderEnrichment` publishes for that batch. - */ - -const INPUT = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }; -const ctx = withCtxDefaults(INPUT); -const fields = makeVulcanusCliffFields(ctx); -const tileAt = makeVulcanusTileResolver(INPUT); -const oreRejects = makeVulcanusOreRejection(buildResources(ctx), ctx.vulcanusResourceControls); -const K = (x: number, y: number): string => `${String(x)},${String(y)}`; -const BANDS = { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, -}; -const isLava = (x: number, y: number): boolean => - VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name); -const STEP: readonly (readonly [number, number])[] = [ - [0, -CLIFF_GRID_SIZE], - [CLIFF_GRID_SIZE, 0], - [0, CLIFF_GRID_SIZE], - [-CLIFF_GRID_SIZE, 0], -]; - -interface Case { - region: { x0: number; y0: number; x1: number; y1: number }; - cliffs: { x: number; y: number; name: string }[]; -} -/** Cases are ON/OFF pairs in capture order, which is what the `i += 2` relies on. */ -const PAIRS: Case[] = [ - ...(batch.cases as unknown as Case[]), - ...(more.cases as unknown as Case[]), - ...(oreRegions.cases as unknown as Case[]), -]; - -interface Tally { - regions: number; - raw: number; - rawOnBorder: number; - before: number; - beforeOnBorder: number; - after: number; - afterOnBorder: number; - cascadeExplained: number; - falseBefore: number; - falseAfter: number; -} - -/** `batchOnly` restricts to the eight-region border batch, for the overlap check. */ -function audit(cases: Case[]): Tally { - const t: Tally = { - regions: cases.length / 2, - raw: 0, - rawOnBorder: 0, - before: 0, - beforeOnBorder: 0, - after: 0, - afterOnBorder: 0, - cascadeExplained: 0, - falseBefore: 0, - falseAfter: 0, - }; - for (let i = 0; i < cases.length; i += 2) { - const on = cases[i]; - const off = cases[i + 1]; - const r = on.region; - const inR = (p: { x: number; y: number }): boolean => - p.x >= r.x0 && p.x < r.x1 && p.y >= r.y0 && p.y < r.y1; - const game = new Set( - on.cliffs.filter((e) => e.name === "cliff-vulcanus" && inR(e)).map((e) => K(e.x, e.y)), - ); - const gameOff = new Set( - off.cliffs.filter((e) => e.name === "cliff-vulcanus" && inR(e)).map((e) => K(e.x, e.y)), - ); - const oreSuppressed = new Set([...gameOff].filter((k) => !game.has(k))); - // The 64-tile halo is what lets a cascade entering the region from outside - // be modelled; without it the edge cells would show the clamped-window - // artifact #139 hit. - const all = makeCliffPlacementFromFields(fields, BANDS).placedCells( - r.x0 - 64, - r.y0 - 64, - r.x1 + 64, - r.y1 + 64, - ); - const cells = new Map(); - for (const p of all) { - const o = CLIFF_CODE_TO_ORIENTATION[p.code]; - if (o !== undefined) cells.set(K(p.x, p.y), o); - } - const raw = new Map(cells); - const kills: [number, number][] = []; - for (const p of all) { - const o = CLIFF_CODE_TO_ORIENTATION[p.code]; - if (o === undefined) continue; - const code = cliffCodeForOrientation(o); - const box = cliffCollisionTileBox(code, p.x, p.y); - let lava = false; - if (box !== undefined) - for (let tx = box.left; tx <= box.right; tx++) - for (let ty = box.top; ty <= box.bottom; ty++) if (isLava(tx, ty)) lava = true; - if (lava || oreRejects(code, p.x, p.y)) kills.push([p.x, p.y]); - } - const destroy = (x: number, y: number): void => { - const mine = cells.get(K(x, y)); - if (mine === undefined) return; - cells.delete(K(x, y)); - for (const side of connectedSides(mine)) { - const st = STEP[side]; - if (st === undefined) continue; - const nx = x + st[0]; - const ny = y + st[1]; - const theirs = cells.get(K(nx, ny)); - if (theirs === undefined) continue; - if (!isCliffConnected(side, mine, theirs)) continue; - const next = destroyEnd(theirs, oppositeSide(side)); - if (next === -1) destroy(nx, ny); - else cells.set(K(nx, ny), next); - } - }; - for (const [x, y] of kills) destroy(x, y); - const killSet = new Set(kills.map(([x, y]) => K(x, y))); - - for (const k of raw.keys()) { - const parts = k.split(","); - const x = Number(parts[0]); - const y = Number(parts[1]); - if (!(x >= r.x0 && x < r.x1 && y >= r.y0 && y < r.y1)) continue; - const gameKill = !game.has(k); - const border = onChunkBorder(x, y); - t.raw++; - if (border) t.rawOnBorder++; - const attributed = killSet.has(k) || oreSuppressed.has(k); - if (gameKill && !attributed) { - t.before++; - if (border) t.beforeOnBorder++; - if (!cells.has(k)) t.cascadeExplained++; - else { - t.after++; - if (border) t.afterOnBorder++; - } - } - if (!gameKill && killSet.has(k)) t.falseBefore++; - if (!gameKill && !cells.has(k)) t.falseAfter++; - } - } - return t; -} - -const T = audit(PAIRS); -const BATCH = audit(batch.cases as unknown as Case[]); -const baseRate = T.rawOnBorder / T.raw; -const z = (k: number, n: number, p: number): number => (k - n * p) / Math.sqrt(n * p * (1 - p)); - -describe("Vulcanus cliffs: the border residual is not a cascade artifact (#84)", () => { - it("reproduces the published border-batch figures - 17 unknown, 12 on a border", () => { - // The overlap check. If this harness disagreed with - // `cliffResidualBorderEnrichment` on the batch they share, every other - // number here would be measuring something else. - expect(BATCH.regions).toBe(8); - expect(BATCH.before).toBe(17); - expect(BATCH.beforeOnBorder).toBe(12); - }, 900000); - - it("covers 14 regions at a stable ~46% border base rate", () => { - expect(T.regions).toBe(14); - expect(T.raw).toBe(9056); - expect(baseRate).toBeCloseTo(0.46, 2); - }, 900000); - - describe("the cascade explains some of the residual, but not the enrichment", () => { - it("removes 10 of the 33 unknown cells", () => { - expect(T.before).toBe(33); - expect(T.cascadeExplained).toBe(10); - expect(T.after).toBe(23); - // Not idle: without this the null below would be satisfied by a cascade - // that never fired. - expect(T.cascadeExplained).toBeGreaterThan(0); - }, 900000); - - it("leaves the border enrichment STRONGER, not weaker", () => { - const zBefore = z(T.beforeOnBorder, T.before, baseRate); - const zAfter = z(T.afterOnBorder, T.after, baseRate); - expect(zBefore).toBeGreaterThan(2.3); - expect(zAfter).toBeGreaterThan(zBefore); - expect(zAfter).toBeGreaterThan(2.6); - // The share rises too, so this is not the z moving on sample size alone. - expect(T.afterOnBorder / T.after).toBeGreaterThan(T.beforeOnBorder / T.before); - }, 900000); - }); - - describe("the accuracy gain, measured and deliberately NOT taken", () => { - it("would trade 10 missed cells for 2 false rejections", () => { - expect(T.falseBefore).toBe(12); - expect(T.falseAfter).toBe(14); - // Net 8 cells better. Recorded so the decision to adopt is deliberate; - // see this file's header for what adopting would require. - expect(T.cascadeExplained - (T.falseAfter - T.falseBefore)).toBe(8); - }, 900000); - }); -}); diff --git a/test/cliffResidualMoreRegions.spec.ts b/test/cliffResidualMoreRegions.spec.ts deleted file mode 100644 index 7d729543..00000000 --- a/test/cliffResidualMoreRegions.spec.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import more from "./fixtures/oracle-vulcanus-cliff-entities-more-regions.seed123456.json"; -import { CLIFF_CODE_TO_ORIENTATION, cliffCollisionTileBox } from "../src/noise/cliffs/cliffCatalog"; -import { cliffCodeForOrientation, onChunkBorder } from "../src/noise/cliffs/cliffConnections"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { makeVulcanusOreRejection } from "../src/noise/cliffs/vulcanusOreRejection"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; -import { buildResources } from "../src/noise/preview/renderVulcanusResources"; -import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -/** - * **Four fresh regions: the port generalises, and the unexplained cells are - * enriched on CHUNK BORDERS - replicated out of sample** (#84). - * - * Two things were stuck at a sample size rather than at an idea. The residual's - * unexplained population was **14** cells, and every structural test on it - - * chunk-border status, orientation, distance to the region rim - landed at 1.4 - * to 1.9 sigma against its base rate, which is what a partition looks like at - * n = 14 whether or not a cause exists. And the shipped accuracy figure was - * measured on **three** regions, chosen years into the investigation for reasons - * that had nothing to do with sampling. - * - * Four more regions, each with the ore lever, at ~2.5s a capture. - * - * ## The port generalises - * - * | | original 3 regions | these 4 | - * | --- | --- | --- | - * | raw cells | 1756 | 2789 | - * | game cliffs | 1531 | 2590 | - * | raw is a strict superset | yes | **yes, all four** | - * | destruction predicate precision | 0.971 | **0.9877** | - * | destruction predicate recall | 0.889 | **0.8090** | - * - * Measured somewhere it was never fitted. `[3000,3000]` is **exact** - 362 raw, - * 362 game, zero residual - and it is also the one region with no resources at - * all, which is consistent with everything #123 to #129 established. - * - * ## The chunk-border enrichment, replicated - * - * | | unexplained | on chunk border | base rate | - * | --- | --- | --- | --- | - * | original 3 regions | 14 | 9 (64.3%) | 45.0% | - * | **these 4** | 13 | **9 (69.2%)** | 47.2% | - * | combined | **27** | 18 (66.7%) | ~46.3% | - * - * **This is a lead, not a result, and the distinction matters.** The replication - * is 1.59 sigma on its own and the combined figure ~2.1 sigma - short of - * decisive. What changed is its STATUS: the border hypothesis was formed on the - * first 14 and is here tested on 13 cells captured afterwards, in regions chosen - * before the cells were known. That is a pre-registered test on fresh data, not - * the post-hoc slice that the same 64% would have been worth nothing as. - * - * Why it is worth pursuing: **chunk borders are `updateConnections`' entire - * domain.** It is the one rule in the whole pipeline that treats border cells - * differently, our port measures it firing zero times, and `applyCliffConnections` - * documents its model of it as an UPPER bound on how much the rule removes. - * #122 promoted its gate from inert to load-bearing; #127 showed the gate cannot - * be scored from map-generation output at all. An enrichment pointing at the - * same rule from a third direction is the first independent evidence that it - * does something. - * - * The unexplained population is now **27**, which is what actually unblocks the - * next person: every structural test just doubled its power. - */ - -const INPUT = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }; -const ctx = withCtxDefaults(INPUT); -const fields = makeVulcanusCliffFields(ctx); -const tileAt = makeVulcanusTileResolver(INPUT); -const oreRejects = makeVulcanusOreRejection(buildResources(ctx), ctx.vulcanusResourceControls); -const K = (x: number, y: number): string => `${String(x)},${String(y)}`; -const BANDS = { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, -}; -const isLava = (x: number, y: number): boolean => - VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name); - -interface Ent { - x: number; - y: number; - name: string; - orientation?: string; -} -interface Region { - x0: number; - y0: number; - x1: number; - y1: number; -} -interface Case { - label: string; - region: Region; - effectiveAutoplace: Record; - cliffs: Ent[]; - resources: Ent[]; -} -const CASES = more.cases as unknown as Case[]; - -interface Score { - label: string; - raw: number; - game: number; - superset: boolean; - oreSuppressed: number; - ourKill: number; - gameKill: number; - agree: number; - falseRejections: number; - missed: number; - ore: number; - unknown: number; - unknownOnBorder: number; - rawOnBorder: number; -} - -const SCORES: Score[] = (() => { - const out: Score[] = []; - for (let i = 0; i < CASES.length; i += 2) { - const on = CASES[i]; - const off = CASES[i + 1]; - const r = on.region; - const inR = (p: { x: number; y: number }): boolean => - p.x >= r.x0 && p.x < r.x1 && p.y >= r.y0 && p.y < r.y1; - const game = new Set( - on.cliffs.filter((e) => e.name === "cliff-vulcanus" && inR(e)).map((e) => K(e.x, e.y)), - ); - const gameOff = new Set( - off.cliffs.filter((e) => e.name === "cliff-vulcanus" && inR(e)).map((e) => K(e.x, e.y)), - ); - const oreSuppressed = new Set([...gameOff].filter((k) => !game.has(k))); - const raw = makeCliffPlacementFromFields(fields, BANDS) - .placedCells(r.x0 - 64, r.y0 - 64, r.x1 + 64, r.y1 + 64) - .filter(inR); - - const s: Score = { - label: on.label.replace(", resources ON", ""), - raw: raw.length, - game: game.size, - superset: true, - oreSuppressed: oreSuppressed.size, - ourKill: 0, - gameKill: 0, - agree: 0, - falseRejections: 0, - missed: 0, - ore: 0, - unknown: 0, - unknownOnBorder: 0, - rawOnBorder: 0, - }; - for (const p of raw) { - const o = CLIFF_CODE_TO_ORIENTATION[p.code]; - if (o === undefined) continue; - const k = K(p.x, p.y); - const code = cliffCodeForOrientation(o); - const box = cliffCollisionTileBox(code, p.x, p.y); - let lava = false; - if (box !== undefined) - for (let tx = box.left; tx <= box.right; tx++) - for (let ty = box.top; ty <= box.bottom; ty++) if (isLava(tx, ty)) lava = true; - const ourKill = lava || oreRejects(code, p.x, p.y); - const gameKill = !game.has(k); - if (onChunkBorder(p.x, p.y)) s.rawOnBorder++; - if (ourKill) s.ourKill++; - if (gameKill) s.gameKill++; - if (ourKill && gameKill) s.agree++; - else if (ourKill) s.falseRejections++; - else if (gameKill) { - s.missed++; - if (oreSuppressed.has(k)) s.ore++; - else { - s.unknown++; - if (onChunkBorder(p.x, p.y)) s.unknownOnBorder++; - } - } - } - const have = new Set(raw.map((p) => K(p.x, p.y))); - for (const k of game) if (!have.has(k)) s.superset = false; - out.push(s); - } - return out; -})(); - -const sum = (f: (s: Score) => number): number => SCORES.reduce((n, s) => n + f(s), 0); - -describe("the port on four regions it was never fitted to", () => { - /** - * **The raw queue is a strict superset in every one.** Nothing the port must - * explain anywhere in these regions is a failure to GENERATE a cliff - it is - * all over-generation, the same property #114 established on the original - * three. - */ - it("contains every game cliff in all four regions", () => { - expect(SCORES.map((s) => s.label)).toEqual([ - "[3000,3000]", - "[-2000,-2000]", - "[800,-1500]", - "[-2600,1200]", - ]); - expect(SCORES.every((s) => s.superset)).toBe(true); - expect(sum((s) => s.raw)).toBe(2789); - expect(sum((s) => s.game)).toBe(2590); - }, 300000); - - /** - * The destruction predicate holds up out of sample: **precision 0.9877, - * recall 0.8090**, against 0.971 and 0.889 on the original three. Two false - * rejections in 2789 cells. - */ - it("scores precision 0.988 and recall 0.809 out of sample", () => { - expect(sum((s) => s.ourKill)).toBe(163); - expect(sum((s) => s.gameKill)).toBe(199); - expect(sum((s) => s.agree)).toBe(161); - expect(sum((s) => s.agree) / sum((s) => s.ourKill)).toBeCloseTo(0.9877, 4); - expect(sum((s) => s.agree) / sum((s) => s.gameKill)).toBeCloseTo(0.809, 3); - expect(sum((s) => s.falseRejections)).toBe(2); - }, 300000); - - /** - * **`[3000,3000]` is exact** - 362 raw, 362 game, nothing to explain. It is - * also the only region with no resource entity at all, which is what every - * result from #123 onward would predict. - */ - it("reproduces [3000,3000] exactly, and it has no resources", () => { - const s = SCORES[0]; - expect(s.raw).toBe(362); - expect(s.game).toBe(362); - expect(s.gameKill).toBe(0); - expect(s.missed).toBe(0); - expect(CASES[0].resources.length).toBe(0); - }, 300000); - - /** - * The missed destructions split the same way #123 found: mostly ore, with a - * residue no lever reaches. The ore attribution comes from the paired OFF arm, - * not from our own predicate - the circularity #123 corrected. - */ - it("splits 38 missed destructions into 25 ore and 13 unexplained", () => { - expect(sum((s) => s.missed)).toBe(38); - expect(sum((s) => s.ore)).toBe(25); - expect(sum((s) => s.unknown)).toBe(13); - }, 300000); -}); - -describe("the chunk-border enrichment replicates out of sample", () => { - /** - * **The pre-registered test.** The hypothesis was formed on the original 14 - * unexplained cells (9 on a border, 64.3%, against a 45.0% base rate - 1.45 - * sigma, correctly dismissed as noise at that n). These 13 cells were captured - * afterwards, in regions chosen before any of them was known. - * - * They come back at **9 of 13, 69.2%**, against a 47.2% base rate here. Same - * direction, same magnitude. - * - * On its own that is 1.59 sigma and combined about 2.1 - **still short of - * decisive, and this file does not claim otherwise.** What changed is that it - * is now a prediction that survived fresh data rather than a slice of the data - * that suggested it. - */ - it("finds 9 of 13 unexplained cells on a chunk border, against 47.2%", () => { - expect(sum((s) => s.unknown)).toBe(13); - expect(sum((s) => s.unknownOnBorder)).toBe(9); - const base = sum((s) => s.rawOnBorder) / sum((s) => s.raw); - expect(base).toBeCloseTo(0.472, 3); - // The enrichment, and the base rate that makes it mean something. - expect(sum((s) => s.unknownOnBorder) / sum((s) => s.unknown)).toBeCloseTo(0.692, 3); - }, 300000); - - /** - * The combined population, which is what actually unblocks the next attempt: - * **27 unexplained cells, 18 on a border**. Every structural test on this - * residual just doubled its power. - */ - it("brings the unexplained population to 27", () => { - // 14 from the original three regions (#126), 13 here. - expect(14 + sum((s) => s.unknown)).toBe(27); - expect(9 + sum((s) => s.unknownOnBorder)).toBe(18); - }, 300000); -}); diff --git a/test/cliffSmoothing.spec.ts b/test/cliffSmoothing.spec.ts deleted file mode 100644 index 0809f1bc..00000000 --- a/test/cliffSmoothing.spec.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import type { CliffFields } from "../src/noise/cliffs/cliffPlacement"; -import { makeCliffPlacementFromFields, smoothingKnots } from "../src/noise/cliffs/cliffPlacement"; - -/** - * `cliff_smoothing`, tested as a rule rather than through its effect on a - * fixture. `test/vulcanusCliffEntities.spec.ts` is what proves the rule is the - * RIGHT one (it moved Vulcanus from 1.5x over-placement to within 8-19% of the - * game's own cliff count); these tests pin the shape of it, so a later - * refactor cannot quietly change the knot lattice and still pass by luck. - * - * The rule is read from `CliffGenerator::crossingsForChunk` (`0x10160cdec`), - * which blends each corner's cliff elevation toward a bilinear interpolation of - * the surrounding knots before any crossing test runs. - */ -describe("cliff_smoothing knot lattice", () => { - // Knots at in-chunk corner indices 0, 4 and 7. The second span is 3 wide, not - // 4, because the engine clamps `hi` to CHUNK_CORNERS - 1 (= 7) rather than to - // the block edge (8). That asymmetry is the whole reason smoothing is - // "inaccurate" - it is not a transcription slip. - const cases: [number, number, number, number][] = [ - // index, lo, hi, t - [0, 0, 4, 0], - [1, 0, 4, 0.25], - [2, 0, 4, 0.5], - [3, 0, 4, 0.75], - [4, 4, 7, 0], - [5, 4, 7, 1 / 3], - [6, 4, 7, 2 / 3], - [7, 4, 7, 1], - // Index 8 is the next chunk's index 0: the lattice is chunk-anchored, so it - // restarts rather than continuing the previous chunk's spans. - [8, 8, 12, 0], - [13, 12, 15, 1 / 3], - // Negative world coordinates must land on the same lattice, not a mirrored - // one - a plain `%` in JS would give -1 % 8 === -1 and shift every chunk - // left of the origin. - [-1, -4, -1, 1], - [-8, -8, -4, 0], - [-5, -8, -4, 0.75], - ]; - - for (const [index, lo, hi, t] of cases) { - it(`corner ${String(index)} interpolates ${String(lo)}..${String(hi)} at t=${t.toFixed(3)}`, () => { - const k = smoothingKnots(index); - expect(k.lo).toBe(lo); - expect(k.hi).toBe(hi); - expect(k.t).toBeCloseTo(t, 12); - }); - } - - it("leaves knot corners exactly where they are", () => { - // Every knot must be a fixed point (t === 0 on itself, or t === 1 on - // itself), or smoothing would drift the whole field rather than only - // straightening between knots. - for (let i = -16; i < 16; i++) { - const inChunk = ((i % 8) + 8) % 8; - if (inChunk !== 0 && inChunk !== 4 && inChunk !== 7) continue; - const k = smoothingKnots(i); - expect(k.t === 0 ? k.lo : k.hi).toBe(i); - expect(k.t === 0 || k.t === 1).toBe(true); - } - }); -}); - -/** A cliffiness that always passes the `> 0.5` gate, so only elevation matters. */ -const alwaysCliffy = (): number => 1; - -function cellKey(p: { x: number; y: number }): string { - return `${String(p.x)},${String(p.y)}`; -} - -function placedKeys(fields: CliffFields, smoothing: number): string[] { - return makeCliffPlacementFromFields(fields, { elevation0: 10, interval: 40, smoothing }) - .placedCells(-128, -128, 128, 128) - .map(cellKey) - .sort(); -} - -describe("cliff_smoothing behaviour", () => { - const ramp: CliffFields = { - // A plane. Bilinear interpolation of a plane is the plane itself, whatever - // the knot spacing - so this is a sharp test that the four weights sum to 1 - // and that lo/hi bracket the corner rather than merely being near it. - // - // The `.37` is load-bearing and was `100` until 2026-07-30. Corners are - // sampled at `(i*4, j*4)`, so a constant of 100 makes every corner - // elevation `2.8i + 1.2j + 100` - a multiple of 0.4 - and the default bands - // sit at `10 + 40n`, which such a value can hit EXACTLY. Measured: all 7 - // cells that then disagreed between smoothing 0 and 1 had a corner at - // distance exactly 0 from a band edge, where the raw path computes 0 and - // the bilerp computes 7.1e-15, so a `>=` goes two ways for reasons that have - // nothing to do with smoothing. `.37` is not a multiple of 0.4, so no corner - // can land on a band edge and the test measures the property it names. - // - // It passed before only because the port sampled at `j*4 + 0.5`, adding - // 0.15 and knocking the plane off the boundaries by accident. - cliffElevation: (x, y) => 0.7 * x + 0.3 * y + 100.37, - cliffiness: alwaysCliffy, - }; - - it("is the identity when smoothing is 0", () => { - const spiky: CliffFields = { - cliffElevation: (x, y) => 100 + 60 * Math.sin(x / 7) + 40 * Math.cos(y / 5), - cliffiness: alwaysCliffy, - }; - expect(placedKeys(spiky, 0)).toEqual(placedKeys(spiky, 1e-300)); - // ...and an omitted `smoothing` must mean 0, not the prototype default of 1. - // Nauvis relies on this: it sets cliff_smoothing = 0 explicitly and scores a - // 1.000 count ratio, so a default of 1 here would silently break the planet - // that currently works. - const omitted = makeCliffPlacementFromFields(spiky, { elevation0: 10, interval: 40 }) - .placedCells(-128, -128, 128, 128) - .map(cellKey) - .sort(); - expect(omitted).toEqual(placedKeys(spiky, 0)); - }); - - it("does not move a planar elevation field at full smoothing", () => { - expect(placedKeys(ramp, 1)).toEqual(placedKeys(ramp, 0)); - expect(placedKeys(ramp, 1).length).toBeGreaterThan(0); - }); - - it("erases detail between knots at full smoothing", () => { - // The same plane plus a high-frequency wobble that no knot pair can - // represent. Full smoothing must discard the wobble and reproduce the plane - // exactly, because the wobble is zero at every knot. - const wobbly: CliffFields = { - cliffElevation: (x, y) => ramp.cliffElevation(x, y) + (Math.round(x / 4) % 8 === 2 ? 25 : 0), - cliffiness: alwaysCliffy, - }; - expect(placedKeys(wobbly, 0)).not.toEqual(placedKeys(ramp, 0)); - expect(placedKeys(wobbly, 1)).toEqual(placedKeys(ramp, 1)); - }); -}); diff --git a/test/cliffSmoothingModel.spec.ts b/test/cliffSmoothingModel.spec.ts deleted file mode 100644 index f2722b18..00000000 --- a/test/cliffSmoothingModel.spec.ts +++ /dev/null @@ -1,316 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import stencil from "./fixtures/oracle-cliff-smoothing-stencil.seed123456.json"; -import offRegions from "./fixtures/oracle-vulcanus-cliff-smoothing-off-regions.seed123456.json"; -import entities from "./fixtures/oracle-vulcanus-cliff-entities.seed123456.json"; -import { CLIFF_ORIENTATION_NAMES, cliffOrientationForCode } from "../src/noise/cliffs/cliffCatalog"; -import { makeCliffPlacementFromFields, smoothingKnots } from "../src/noise/cliffs/cliffPlacement"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_RICHNESS, - VULCANUS_CLIFF_SMOOTHING, - makeCliffinessBasic, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; -import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -const seed = offRegions.seed; -const ctx = withCtxDefaults({ seed0: seed, startingPositions: [{ x: 0, y: 0 }] }); -const fields = makeVulcanusCliffFields(ctx); -const tileAt = makeVulcanusTileResolver({ seed0: seed, startingPositions: [{ x: 0, y: 0 }] }); -const lava = (x: number, y: number): boolean => - VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name); -const key = (x: number, y: number): string => `${String(x)},${String(y)}`; - -interface Box { - x0: number; - y0: number; - x1: number; - y1: number; -} -/** - * The game's dump is chunk-aligned and so reaches slightly outside the requested - * box, while `placedCells` filters to it exactly. Comparing without this clips - * a row of "the game placed one we didn't" that is purely a framing artefact. - */ -const inBox = (p: { x: number; y: number }, r: Box): boolean => - p.x >= r.x0 && p.x < r.x1 && p.y >= r.y0 && p.y < r.y1; - -const gameCliffs = ( - cliffs: { x: number; y: number; name: string; orientation: string }[], - r: Box, -): Map => { - const m = new Map(); - for (const p of cliffs) - if (p.name === "cliff-vulcanus" && inBox(p, r)) m.set(key(p.x, p.y), p.orientation); - return m; -}; - -interface Score { - game: number; - ours: number; - matched: number; - missed: number; - oursOnly: number; - oriWrong: number; -} -const score = ( - game: Map, - placed: { x: number; y: number; code: number }[], -): Score => { - let matched = 0; - let oriWrong = 0; - const ours = new Set(); - for (const p of placed) { - const k = key(p.x, p.y); - ours.add(k); - const want = game.get(k); - if (want === undefined) continue; - matched++; - const id = cliffOrientationForCode(p.code); - if (CLIFF_ORIENTATION_NAMES[id as number] !== want) oriWrong++; - } - let missed = 0; - for (const k of game.keys()) if (!ours.has(k)) missed++; - return { - game: game.size, - ours: ours.size, - matched, - missed, - oursOnly: ours.size - matched, - oriWrong, - }; -}; - -/** - * **`cliff_smoothing = 1` on Vulcanus, and it is now MEASURED** (#84). - * - * `VULCANUS_CLIFF_SMOOTHING` was inferred from the `CliffPlacementSettings` - * prototype's default list (issue #28) because the planet's `cliff_settings` - * block does not mention it. That inference was right, but an inference of - * exactly this shape is what #28 itself was a bug in, and the value turns out to - * be the difference between a port that is exact and one that is not (below). So - * the fixture's first case overrides nothing and reads the whole block back off - * the planet's own surface. - */ -describe("Vulcanus cliff_settings, read back from the game", () => { - const defaults = offRegions.cases[0].effective; - - it("reports the four constants the port hard-codes", () => { - expect(defaults?.cliff_smoothing).toBe(VULCANUS_CLIFF_SMOOTHING); - expect(defaults?.cliff_elevation_0).toBe(VULCANUS_CLIFF_ELEVATION_0); - expect(defaults?.cliff_elevation_interval).toBe(VULCANUS_CLIFF_ELEVATION_INTERVAL); - expect(defaults?.richness).toBe(VULCANUS_CLIFF_RICHNESS); - // Non-vacuity: the readback is a real dump, not an echo of what we asked for - - // this case sent no cliffSettings at all. - expect(offRegions.cases[0].cliffs.length).toBeGreaterThan(500); - }); -}); - -/** - * **The `cliff_smoothing` stencil, measured against the game** (#84). - * - * `smoothingKnots` interpolates each corner between knots at in-chunk indices - * **0, 4 and 7** - `hi = min(lo + 4, CHUNK_CORNERS - 1)`, so the second span is - * three corners wide, not four. That came off a disassembly of - * `crossingsForChunk` (re-derived 2026-08-02 at `0x10160c9cc`; the VA in - * cliffs-NOTES.md had moved), and this file is the measurement that the reading - * is right. - * - * The probe is a delta on one corner column or row, so the smoothed field is - * `1 + 1000 * w(i)` for exactly the stencil weight `w`. See the fixture's - * `_comment` for the construction. - * - * **The in-chunk-3 arms are the ones with teeth.** 3 is not a knot, so the model - * predicts the game places nothing at all, and that is what the game does. An - * arm whose predicted output is EMPTY cannot be satisfied by a stencil that is - * merely close, which is the failure mode every weight-matching test here has. - */ -describe("the cliff_smoothing stencil", () => { - const cliffinessOpen = makeCliffinessBasic(seed, 4); - const extras: string[] = []; - const wrongTotal: number[] = []; - const oursOnlyKeys = (game: Map, placed: { x: number; y: number }[]): string[] => - placed.map((p) => key(p.x, p.y)).filter((k) => !game.has(k)); - - for (const arm of stencil.cases) { - const r = arm.region; - const game = gameCliffs(arm.cliffs, r); - // The probe: 1 everywhere, 1001 on the one corner line. - const rawProbe = (x: number, y: number): number => - (arm.axis === "x" ? x : y) / 4 === arm.index ? 1001 : 1; - const smoothed = (x: number, y: number): number => { - const kx = smoothingKnots(x / 4); - const ky = smoothingKnots(y / 4); - return ( - (1 - kx.t) * (1 - ky.t) * rawProbe(kx.lo * 4, ky.lo * 4) + - kx.t * (1 - ky.t) * rawProbe(kx.hi * 4, ky.lo * 4) + - (1 - kx.t) * ky.t * rawProbe(kx.lo * 4, ky.hi * 4) + - kx.t * ky.t * rawProbe(kx.hi * 4, ky.hi * 4) - ); - }; - const placed = makeCliffPlacementFromFields( - { cliffElevation: smoothed, cliffiness: cliffinessOpen }, - { - elevation0: stencil.cliffElevation0, - interval: arm.effective?.cliff_elevation_interval ?? 1000000, - // `smoothed` has already applied it; the placement must not apply it twice. - smoothing: 0, - tileCollides: lava, - }, - ).placedCells(r.x0, r.y0, r.x1, r.y1); - const s = score(game, placed); - - it(`reproduces the game's stencil: ${arm.label}`, () => { - expect(arm.effective?.cliff_smoothing).toBe(1); - // The stencil itself is exact in the direction that matters: the model - // never fails to place a cliff the game places, in any arm. Weight, - // knot position and interpolation family are therefore all right - a - // stencil that were wrong anywhere would lose recall somewhere. - expect(s.missed).toBe(0); - expect(s.matched).toBeGreaterThan(arm.index % 8 === 3 ? -1 : 25); - console.log( - ` ${arm.label.padEnd(38)} game=${String(s.game).padStart(3)} ours=${String(s.ours).padStart(3)} oriWrong=${String(s.oriWrong)} oursOnly=${String(s.oursOnly)}`, - ); - extras.push(...oursOnlyKeys(game, placed).map((k) => `${arm.label}: ${k}`)); - wrongTotal.push(s.oriWrong); - }); - } - - /** - * **The one thing the stencil does NOT explain, kept visible.** Two column arms - * place 4 cells each that the game does not, and they are the same four both - * times - cell column 437, rows 381-384, i.e. a short vertical run that appears - * whenever the stencil's contour passes through it, whatever the delta column. - * - * It is not the collision rejection: the game's tiles over - * `x 1742..1760, y 1518..1546` are `volcanic-soil-dark` / `-folds` / - * `-jagged-ground` / `-folds-flat` / `-soil-light` and contain **no lava at - * all**, and only `lava` and `lava-hot` carry the `water_tile` layer the cliff - * mask collides with. It is not the stencil either, or the row arms and the - * in-chunk-3 arms would not be exact. - * - * It looks like the same unexplained blanket suppression as the `[0,0]` blob - - * a contiguous patch where the game places no cliff under ANY cliff_elevation - * routed onto it. Asserted as an exact count rather than an upper bound, - * because the SHAPE is the lead: if it moves, the cause has changed. - */ - it("leaves a small, LOCALISED residual - pinned by shape, not bounded away", () => { - // Six of the eight arms are exact. The two that are not are exact everywhere - // except ONE place, and it is the same place in both: a four-cell vertical - // run at x = 1750 (cell column 437, in-chunk 5), rows 1526.5 - 1538.5. - const cells = [...new Set(extras.map((e) => e.split(": ")[1]))].sort(); - expect(cells).toEqual(["1750,1526.5", "1750,1530.5", "1750,1534.5", "1750,1538.5"]); - expect(extras.length).toBe(8); - // ...and exactly one wrong orientation in each of those same two arms. - expect(wrongTotal).toEqual([0, 0, 1, 1, 0, 0, 0, 0]); - }); - - it("the non-knot arms place NOTHING, and the knot arms are not empty", () => { - for (const arm of stencil.cases) { - const n = gameCliffs(arm.cliffs, arm.region).size; - // in-chunk index 3 on either axis: not a knot, so the delta reaches no corner. - if (arm.index % 8 === 3) expect(n).toBe(0); - // Everything else must carry real signal, or the arms above compare nothing. - else expect(n).toBeGreaterThan(25); - } - }); -}); - -/** - * **The orientation residual is TWO defects, not one** (#84). - * - * Run the real rule with `cliff_smoothing` forced to 0 and every other term - * left alone, and the port's grid-4 cliff elevation is scored directly - no - * interpolation stands between the field and the crossing test. - * - * | region | smoothing = 0 | smoothing = 1 (ships) | - * | --- | --- | --- | - * | `[0,0]` | 0 wrong (oracle-vulcanus-cliff-collapsed, arm 0) | 7 wrong | - * | `[-1200,800]` | **0 wrong**, precision 1.0000 | 4 wrong | - * | `[1500,1500]` | **21 wrong** | 26 wrong | - * - * So `[0,0]` and `[-1200,800]` carry a defect that exists ONLY under smoothing, - * while `[1500,1500]` carries one that survives smoothing being switched off - * entirely. Everything upstream of the smoothing has been separately verified - * against the game - the grid-1 elevation at all 12,675 captured corners - * (worst 4.8e-2), the grid-4 `multisample` min-filter through the cliff - * generator itself, `cliffiness_basic` over its 4,266 UNCLAMPED corners - * (worst 6.4e-6), `crossesCliff` by disassembly, and the stencil above - which - * is what makes the split meaningful rather than just two numbers. - * - * **This is why one region was not enough.** Scoring only `[0,0]` says "smoothing - * off is exact, therefore the residual is the smoothing", which is false for 21 - * of the 37 wrong orientations. Two of the three regions agreeing is exactly the - * evidence that produces a confident wrong conclusion. - */ -describe("the residual splits: smoothing=0 is exact in two regions and not in the third", () => { - const armFor = (x0: number): (typeof offRegions.cases)[number] => { - const c = offRegions.cases.find( - (a) => a.effective?.cliff_smoothing === 0 && a.region.x0 === x0, - ); - expect(c).toBeDefined(); - return c as (typeof offRegions.cases)[number]; - }; - const scoreAt = (arm: (typeof offRegions.cases)[number], smoothing: number): Score => { - const r = arm.region; - const placed = makeCliffPlacementFromFields(fields, { - elevation0: arm.effective?.cliff_elevation_0 ?? VULCANUS_CLIFF_ELEVATION_0, - interval: arm.effective?.cliff_elevation_interval ?? VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing, - tileCollides: lava, - }).placedCells(r.x0, r.y0, r.x1, r.y1); - return score(gameCliffs(arm.cliffs, r), placed); - }; - - it("every override applied, and both arms compare a substantial set", () => { - for (const arm of offRegions.cases.filter((a) => a.effective?.cliff_smoothing === 0)) { - expect(arm.effective?.cliff_smoothing).toBe(0); - expect(arm.effective?.cliff_elevation_0).toBe(VULCANUS_CLIFF_ELEVATION_0); - expect(arm.effective?.cliff_elevation_interval).toBe(VULCANUS_CLIFF_ELEVATION_INTERVAL); - expect(gameCliffs(arm.cliffs, arm.region).size).toBeGreaterThan(400); - } - }, 120000); - - it("[-1200,800] with smoothing OFF is orientation-exact", () => { - const s = scoreAt(armFor(-1200), 0); - expect(s.oriWrong).toBe(0); - // Measured 479/479: not one cell placed that the game does not also place. - expect(s.oursOnly).toBe(0); - expect(s.matched).toBeGreaterThan(400); - }, 120000); - - it("[1500,1500] with smoothing OFF is NOT - the residual there is upstream", () => { - const s = scoreAt(armFor(1500), 0); - // Measured 21 of 1191 matched cells, every one an OVER-detection, all at the - // high bands (670 / 790 / 1030) with margins 0.69 - 46.6 elevation units, so - // this is not float32 noise. Upper bound: the port may improve without - // editing it, but it must not silently pass by matching nothing. - expect(s.oriWrong).toBeGreaterThan(0); - expect(s.oriWrong).toBeLessThanOrEqual(21); - expect(s.matched).toBeGreaterThan(1100); - }, 120000); - - /** - * The control that makes the two tests above mean something: with smoothing at - * its real value of 1, `[-1200,800]` is NOT exact. If it were, "smoothing off - * is exact there" would be saying nothing about the smoothing. - */ - it("and smoothing=1 is what introduces [-1200,800]'s errors", () => { - const ec = entities.cases.find((c) => c.region.x0 === -1200); - expect(ec).toBeDefined(); - const r = (ec as NonNullable).region; - const placed = makeCliffPlacementFromFields(fields, { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, - tileCollides: lava, - }).placedCells(r.x0, r.y0, r.x1, r.y1); - const s = score(gameCliffs((ec as NonNullable).cliffs, r), placed); - expect(s.oriWrong).toBeGreaterThan(0); - expect(s.matched).toBeGreaterThan(300); - }, 120000); -}); diff --git a/test/cliffSweepOrderLever.spec.ts b/test/cliffSweepOrderLever.spec.ts deleted file mode 100644 index 5895d749..00000000 --- a/test/cliffSweepOrderLever.spec.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import batch from "./fixtures/oracle-vulcanus-cliff-entities-border-batch.seed123456.json"; -import more from "./fixtures/oracle-vulcanus-cliff-entities-more-regions.seed123456.json"; -import oreRegions from "./fixtures/oracle-vulcanus-cliff-ore-direction-regions.seed123456.json"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - CLIFF_CELL_CENTER_X, - CLIFF_CELL_CENTER_Y, - CLIFF_CODE_TO_ORIENTATION, - CLIFF_GRID_SIZE, - cliffCollisionTileBox, -} from "../src/noise/cliffs/cliffCatalog"; -import { cliffCodeForOrientation } from "../src/noise/cliffs/cliffConnections"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { makeVulcanusOreRejection } from "../src/noise/cliffs/vulcanusOreRejection"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; -import { buildResources } from "../src/noise/preview/renderVulcanusResources"; -import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -/** - * **The WEST concentration is NOT caused by the repair sweep's edge order.** - * Permuting `L, T, R, B` does not move it - west stays the enriched edge under - * every permutation, including the one that tries west LAST (#84). - * - * #150 localised the border residual to the WEST chunk edge (9 of 17 border - * survivors, z = 3.01 against a measured base rate) and recorded one lead: - * `fixImpossibleCellsSweep` clears the first CLEARABLE edge in the order - * `L, T, R, B` - west first, north second - and an edge is clearable only when - * it is not on the chunk's outer boundary. So a cell on the west edge is denied - * its FIRST choice and a north-edge cell only its second, which is an asymmetry - * of exactly the observed shape. - * - * **The discriminator is relocation, not shrinkage.** If the order causes the - * concentration, permuting it should move the excess to whichever edge is tried - * first; a residual that stays put under permutation is not caused by the order. - * That is a much stronger test than "does the error get smaller", which almost - * any perturbation achieves by accident. - * - * Permuting makes the port WRONG - the engine's order is `L, T, R, B` and only - * that - so total error rises in every arm. That is expected and is not what is - * being scored; only WHERE the residual sits is. - * - * ## The result - * - * | order | unexplained | on border | W | N | E | S | z(west) | - * | --- | --- | --- | --- | --- | --- | --- | --- | - * | `L, T, R, B` (engine) | 25 | 19 | 9 | 5 | 3 | 2 | **2.60** | - * | `R, B, L, T` | 75 | 24 | 11 | 7 | 4 | 3 | **2.74** | - * | `T, L, B, R` | 46 | 22 | 10 | 5 | 5 | 3 | **2.58** | - * | `B, R, T, L` (west LAST) | 76 | 23 | 11 | 7 | 3 | 3 | **2.91** | - * - * **West is the enriched edge in every arm, and is at its STRONGEST in the arm - * that tries west last.** The lead is refuted: #84's west signature has a cause - * outside the sweep's choice order. - * - * The lever is not inert while failing to move it - that is the trap this shape - * of test falls into. Permuting drives the unexplained count from 25 to 76, so - * the sweep's order matters enormously to the placement; it simply does not - * matter to WHERE the residual sits. - * - * Note the arm here scores the SHIPPED model, so its control is #149's 25 - * unexplained / 19 on border, not #150's 23 / 17 - those are the cascade model's - * row. West is 9 either way, which is the point of overlap that says both are - * looking at the same cells. - */ - -const K = (x: number, y: number): string => `${String(x)},${String(y)}`; -const INPUT = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }; -const ctx = withCtxDefaults(INPUT); -const fields = makeVulcanusCliffFields(ctx); -const tileAt = makeVulcanusTileResolver(INPUT); -const oreRejects = makeVulcanusOreRejection(buildResources(ctx), ctx.vulcanusResourceControls); -const tileCollides = (x: number, y: number): boolean => - VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name); -const BANDS = { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, -}; -const EDGES = ["north", "east", "south", "west"] as const; -type Edge = (typeof EDGES)[number]; - -interface Case { - label?: string; - region: { x0: number; y0: number; x1: number; y1: number }; - cliffs: { x: number; y: number; name: string; orientation: string }[]; -} -const PAIRS: Case[] = [ - ...(batch.cases as unknown as Case[]), - ...(more.cases as unknown as Case[]), - ...(oreRegions.cases as unknown as Case[]), -]; - -function edgesOf(x: number, y: number): Edge[] { - const cx = (x - CLIFF_CELL_CENTER_X) / CLIFF_GRID_SIZE; - const cy = (y - CLIFF_CELL_CENTER_Y) / CLIFF_GRID_SIZE; - const ix = ((cx % 8) + 8) % 8; - const iy = ((cy % 8) + 8) % 8; - const out: Edge[] = []; - if (iy === 0) out.push("north"); - if (ix === 7) out.push("east"); - if (iy === 7) out.push("south"); - if (ix === 0) out.push("west"); - return out; -} - -interface Arm { - unexplained: number; - border: number; - byEdge: Record; - z: Record; -} - -/** Runs the whole #150 measurement with the sweep's edge order permuted. */ -function arm(order: readonly number[]): Arm { - const bands = { ...BANDS, sweepEdgeOrder: order }; - const shippedBands = { - ...bands, - tileCollides, - cellRejects: oreRejects, - rejectAtCrossingStage: true, - }; - const byEdge: Record = { north: 0, east: 0, south: 0, west: 0 }; - const base: Record = { north: 0, east: 0, south: 0, west: 0 }; - let unexplained = 0; - let border = 0; - - for (let i = 0; i < PAIRS.length; i += 2) { - const on = PAIRS[i]; - const off = PAIRS[i + 1]; - const r = on.region; - const inR = (p: { x: number; y: number }): boolean => - p.x >= r.x0 && p.x < r.x1 && p.y >= r.y0 && p.y < r.y1; - const game = new Set( - on.cliffs.filter((e) => e.name === "cliff-vulcanus" && inR(e)).map((e) => K(e.x, e.y)), - ); - const gameOff = new Set( - off.cliffs.filter((e) => e.name === "cliff-vulcanus" && inR(e)).map((e) => K(e.x, e.y)), - ); - const oreSuppressed = new Set([...gameOff].filter((k) => !game.has(k))); - - const shipped = new Set( - makeCliffPlacementFromFields(fields, shippedBands) - .placedCells(r.x0, r.y0, r.x1, r.y1) - .map((p) => K(p.x, p.y)), - ); - const all = makeCliffPlacementFromFields(fields, bands).placedCells( - r.x0 - 64, - r.y0 - 64, - r.x1 + 64, - r.y1 + 64, - ); - const killSet = new Set(); - for (const p of all) { - const o = CLIFF_CODE_TO_ORIENTATION[p.code]; - if (o === undefined) continue; - const code = cliffCodeForOrientation(o); - const box = cliffCollisionTileBox(code, p.x, p.y); - let hit = false; - if (box !== undefined) - for (let tx = box.left; tx <= box.right && !hit; tx++) - for (let ty = box.top; ty <= box.bottom && !hit; ty++) - if (tileCollides(tx, ty)) hit = true; - if (hit || oreRejects(code, p.x, p.y)) killSet.add(K(p.x, p.y)); - } - - for (const p of all) { - if (!inR(p)) continue; - if (CLIFF_CODE_TO_ORIENTATION[p.code] === undefined) continue; - for (const e of edgesOf(p.x, p.y)) base[e]++; - const k = K(p.x, p.y); - if (game.has(k)) continue; - if (killSet.has(k) || oreSuppressed.has(k)) continue; - if (!shipped.has(k)) continue; - unexplained++; - const es = edgesOf(p.x, p.y); - if (es.length > 0) border++; - for (const e of es) byEdge[e]++; - } - } - - const baseTotal = EDGES.reduce((a, e) => a + base[e], 0); - const z = {} as Record; - for (const e of EDGES) { - const p = base[e] / baseTotal; - z[e] = (byEdge[e] - border * p) / Math.sqrt(border * p * (1 - p)); - } - return { unexplained, border, byEdge, z }; -} - -const LTRB = arm([0, 1, 2, 3]); -const RBLT = arm([2, 3, 0, 1]); -const TLBR = arm([1, 0, 3, 2]); -const BRTL = arm([3, 2, 1, 0]); -const ARMS: [string, Arm][] = [ - ["L,T,R,B (engine)", LTRB], - ["R,B,L,T", RBLT], - ["T,L,B,R", TLBR], - ["B,R,T,L", BRTL], -]; -const topEdge = (a: Arm): Edge => - EDGES.reduce((best, e) => (a.z[e] > a.z[best] ? e : best), "north"); - -describe("Vulcanus cliffs: the WEST residual is not the sweep's edge order (#84)", () => { - it("reproduces the shipped model's published split on the engine's own order", () => { - // The control, tying this to #149's 25 / 19. Without it the permuted arms - // could be a differently-scoped measurement. - expect(LTRB.unexplained).toBe(25); - expect(LTRB.border).toBe(19); - expect(LTRB.byEdge).toEqual({ west: 9, north: 5, east: 3, south: 2 }); - expect(LTRB.z.west).toBeCloseTo(2.6, 1); - }, 900000); - - it("is a real lever - permuting it changes the placement", () => { - // Not vacuous: if `sweepEdgeOrder` were ignored every arm would be identical - // and the null below would be satisfied by a lever that never fired. - const counts = ARMS.map(([, a]) => a.unexplained); - expect(new Set(counts).size).toBeGreaterThan(1); - // And it bites HARD - 25 to 76. A lever that barely moved the placement - // could fail to relocate the residual simply by doing nothing. - expect(Math.max(...counts)).toBeGreaterThan(70); - expect(Math.min(...counts)).toBe(25); - }, 900000); - - it("but WEST stays the enriched edge under every permutation", () => { - for (const [name, a] of ARMS) { - expect(topEdge(a), `${name}: expected west to stay on top`).toBe("west"); - } - }, 900000); - - it("including when west is tried LAST, which refutes the lead", () => { - // `B, R, T, L` denies west its priority entirely. If the clear order caused - // the concentration, this is the arm that would move it. - expect(BRTL.z.west).toBeGreaterThan(BRTL.z.east); - expect(BRTL.z.west).toBeGreaterThan(BRTL.z.south); - expect(topEdge(BRTL)).toBe("west"); - // Stronger than the engine's own order, not merely surviving. - expect(BRTL.z.west).toBeGreaterThan(LTRB.z.west); - }, 900000); -}); diff --git a/test/cliffWestOreCorrelation.spec.ts b/test/cliffWestOreCorrelation.spec.ts deleted file mode 100644 index 95b5fb83..00000000 --- a/test/cliffWestOreCorrelation.spec.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import batch from "./fixtures/oracle-vulcanus-cliff-entities-border-batch.seed123456.json"; -import more from "./fixtures/oracle-vulcanus-cliff-entities-more-regions.seed123456.json"; -import oreRegions from "./fixtures/oracle-vulcanus-cliff-ore-direction-regions.seed123456.json"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - CLIFF_CELL_CENTER_X, - CLIFF_CELL_CENTER_Y, - CLIFF_CODE_TO_ORIENTATION, - CLIFF_GRID_SIZE, - cliffCollisionTileBox, -} from "../src/noise/cliffs/cliffCatalog"; -import { cliffCodeForOrientation } from "../src/noise/cliffs/cliffConnections"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { makeVulcanusOreRejection } from "../src/noise/cliffs/vulcanusOreRejection"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; -import { buildResources } from "../src/noise/preview/renderVulcanusResources"; -import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -/** - * **The two WEST observations in #84 are INDEPENDENT.** The west-edge residual - * is not a resource-proximity effect, and the apparent proximity signal that - * does show up is a regional-clustering artifact. - * - * #150 localised the border residual to the west chunk edge; #142 separately - * found all six ore-recall cells with their nearest resource to the WEST. If - * those were one phenomenon, two open threads would collapse into one. They are - * not. No capture - the resources here are the game's own dumped entities, not - * the port's model. - * - * ## The west edge is not about ore - * - * | | within 32 tiles of a resource | nearest resource lies WEST | - * | --- | --- | --- | - * | base (8,393 raw cells) | 21.3% | 47.1% | - * | west-edge survivors (9) | 2 of 9 | **44.4%** | - * | other survivors (15) | 3 of 15 | 60.0% | - * - * West-edge survivors are no closer to resources than the other survivors, and - * their nearest resource points west slightly LESS often than the base rate. - * Whatever produces the west edge concentration, it is not the ore. - * - * ## The proximity signal that looks real, and why it is not - * - * At a 64-tile radius the survivors do look resource-proximate: 70.8% against a - * 40.2% base, **z = 3.06**. Two things kill it: - * - * - **The scale is wrong for a cell-level cause.** Nothing appears at 16 tiles - * (z = 0.81) or 32 (z = -0.05). An effect absent at short range and present at - * 64 tiles is describing a REGION, not a cell. - * - **n_eff is ~9, not 24.** The 24 survivors sit in 9 regions and the - * within-64 outcome is nearly all-or-nothing per region - four regions have - * ALL their survivors inside 64 tiles, two have NONE. The cells are not - * independent draws, so the binomial z is inflated. Same failure as - * `below-chance-needs-a-clustered-null`, which this investigation has hit - * before. - * - * Reported as a refuted signal rather than a finding, and asserted below so it - * cannot be rediscovered and believed. - */ - -const K = (x: number, y: number): string => `${String(x)},${String(y)}`; -const INPUT = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }; -const ctx = withCtxDefaults(INPUT); -const fields = makeVulcanusCliffFields(ctx); -const tileAt = makeVulcanusTileResolver(INPUT); -const oreRejects = makeVulcanusOreRejection(buildResources(ctx), ctx.vulcanusResourceControls); -const tileCollides = (x: number, y: number): boolean => - VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name); -const BANDS = { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, -}; -const SHIPPED = { ...BANDS, tileCollides, cellRejects: oreRejects, rejectAtCrossingStage: true }; - -interface Res { - x: number; - y: number; - name: string; -} -interface Case { - label?: string; - region: { x0: number; y0: number; x1: number; y1: number }; - cliffs: { x: number; y: number; name: string; orientation: string }[]; - resources?: Res[]; -} -const PAIRS: Case[] = [ - ...(batch.cases as unknown as Case[]), - ...(more.cases as unknown as Case[]), - ...(oreRegions.cases as unknown as Case[]), -]; - -function edgesOf(x: number, y: number): string[] { - const cx = (x - CLIFF_CELL_CENTER_X) / CLIFF_GRID_SIZE; - const cy = (y - CLIFF_CELL_CENTER_Y) / CLIFF_GRID_SIZE; - const ix = ((cx % 8) + 8) % 8; - const iy = ((cy % 8) + 8) % 8; - const out: string[] = []; - if (iy === 0) out.push("north"); - if (ix === 7) out.push("east"); - if (iy === 7) out.push("south"); - if (ix === 0) out.push("west"); - return out; -} - -function measure() { - const survivorRows: unknown[] = []; - const baseDists: number[] = []; - const baseWestward: number[] = []; - let regionsWithRes = 0; - let regionsTotal = 0; - - for (let i = 0; i < PAIRS.length; i += 2) { - const on = PAIRS[i]; - const off = PAIRS[i + 1]; - const r = on.region; - regionsTotal++; - const res = on.resources ?? []; - if (res.length > 0) regionsWithRes++; - const inR = (p: { x: number; y: number }): boolean => - p.x >= r.x0 && p.x < r.x1 && p.y >= r.y0 && p.y < r.y1; - const game = new Set( - on.cliffs.filter((e) => e.name === "cliff-vulcanus" && inR(e)).map((e) => K(e.x, e.y)), - ); - const gameOff = new Set( - off.cliffs.filter((e) => e.name === "cliff-vulcanus" && inR(e)).map((e) => K(e.x, e.y)), - ); - const oreSuppressed = new Set([...gameOff].filter((k) => !game.has(k))); - - const shipped = new Set( - makeCliffPlacementFromFields(fields, SHIPPED) - .placedCells(r.x0, r.y0, r.x1, r.y1) - .map((p) => K(p.x, p.y)), - ); - const all = makeCliffPlacementFromFields(fields, BANDS).placedCells( - r.x0 - 64, - r.y0 - 64, - r.x1 + 64, - r.y1 + 64, - ); - const killSet = new Set(); - for (const p of all) { - const o = CLIFF_CODE_TO_ORIENTATION[p.code]; - if (o === undefined) continue; - const code = cliffCodeForOrientation(o); - const box = cliffCollisionTileBox(code, p.x, p.y); - let hit = false; - if (box !== undefined) - for (let tx = box.left; tx <= box.right && !hit; tx++) - for (let ty = box.top; ty <= box.bottom && !hit; ty++) - if (tileCollides(tx, ty)) hit = true; - if (hit || oreRejects(code, p.x, p.y)) killSet.add(K(p.x, p.y)); - } - - /** Nearest dumped resource: distance, and whether it lies to the west. */ - const nearest = (x: number, y: number): { d: number; west: boolean } | undefined => { - let best = Infinity; - let bx = 0; - for (const q of res) { - const d = Math.hypot(q.x - x, q.y - y); - if (d < best) { - best = d; - bx = q.x; - } - } - if (!Number.isFinite(best)) return undefined; - return { d: best, west: bx < x }; - }; - - for (const p of all) { - if (!inR(p)) continue; - if (CLIFF_CODE_TO_ORIENTATION[p.code] === undefined) continue; - const n = nearest(p.x, p.y); - if (n !== undefined) { - baseDists.push(n.d); - baseWestward.push(n.west ? 1 : 0); - } - const k = K(p.x, p.y); - if (game.has(k)) continue; - if (killSet.has(k) || oreSuppressed.has(k)) continue; - if (!shipped.has(k)) continue; - const es = edgesOf(p.x, p.y); - survivorRows.push({ - region: on.label ?? K(r.x0, r.y0), - x: p.x, - y: p.y, - edges: es, - isWest: es.includes("west"), - onBorder: es.length > 0, - nearestDist: n?.d ?? null, - nearestIsWest: n?.west ?? null, - nRes: res.length, - }); - } - } - const sorted = [...baseDists].sort((a, b) => a - b); - const under = (t: number): number => baseDists.filter((d) => d < t).length / baseDists.length; - return { - regionsTotal, - regionsWithRes, - baseN: baseDists.length, - baseMedian: sorted[Math.floor(sorted.length / 2)], - baseWestShare: baseWestward.reduce((a, b) => a + b, 0) / baseWestward.length, - baseUnder: { 16: under(16), 32: under(32), 64: under(64) }, - survivors: survivorRows as Survivor[], - }; -} - -interface Survivor { - region: string; - x: number; - y: number; - edges: string[]; - isWest: boolean; - onBorder: boolean; - nearestDist: number | null; - nearestIsWest: boolean | null; - nRes: number; -} - -const M = measure(); -const S = M.survivors.filter((s) => s.nearestDist !== null); -const WEST = S.filter((s) => s.isWest); -const OTHER = S.filter((s) => !s.isWest); -const within = (a: Survivor[], t: number): number => - a.filter((s) => (s.nearestDist as number) < t).length; -const westShare = (a: Survivor[]): number => - a.filter((s) => s.nearestIsWest === true).length / a.length; -const z = (k: number, n: number, p: number): number => (k - n * p) / Math.sqrt(n * p * (1 - p)); - -describe("Vulcanus cliffs: the west-edge residual is NOT a resource-proximity effect (#84)", () => { - it("scores 24 survivors against 8,393 raw cells from the game's own dumps", () => { - expect(M.regionsTotal).toBe(14); - expect(M.regionsWithRes).toBe(12); - expect(M.baseN).toBe(8393); - expect(S).toHaveLength(24); - expect(WEST).toHaveLength(9); - }, 900000); - - it("finds west-edge survivors no closer to ore than the others", () => { - // 2 of 9 against 3 of 15 - indistinguishable, and both near the 21.3% base. - expect(within(WEST, 32)).toBe(2); - expect(within(OTHER, 32)).toBe(3); - expect(M.baseUnder[32]).toBeCloseTo(0.213, 3); - }, 900000); - - it("and their nearest resource points west LESS often than the base rate", () => { - expect(M.baseWestShare).toBeCloseTo(0.471, 3); - expect(westShare(WEST)).toBeCloseTo(0.444, 3); - // The direction #142 saw in its six cells does not reappear here, so the - // two west observations are independent. - expect(westShare(WEST)).toBeLessThan(M.baseWestShare); - }, 900000); - - describe("the 64-tile proximity signal is a clustering artifact", () => { - it("looks significant taken at face value", () => { - expect(within(S, 64)).toBe(17); - expect(z(within(S, 64), S.length, M.baseUnder[64])).toBeGreaterThan(3); - }, 900000); - - it("but is absent at the scales a cell-level cause would act on", () => { - expect(Math.abs(z(within(S, 16), S.length, M.baseUnder[16]))).toBeLessThan(1); - expect(Math.abs(z(within(S, 32), S.length, M.baseUnder[32]))).toBeLessThan(1); - }, 900000); - - it("and the cells are not independent - n_eff is ~9 regions, not 24 cells", () => { - const byRegion = new Map(); - for (const s of S) byRegion.set(s.region, [...(byRegion.get(s.region) ?? []), s]); - expect(byRegion.size).toBe(9); - // Nearly all-or-nothing per region, which is what makes the binomial z - // over 24 cells wrong. - const allOrNothing = [...byRegion.values()].filter( - (v) => within(v, 64) === 0 || within(v, 64) === v.length, - ); - expect(allOrNothing.length).toBeGreaterThanOrEqual(6); - }, 900000); - }); -}); diff --git a/test/cliffWestOutOfSample.spec.ts b/test/cliffWestOutOfSample.spec.ts deleted file mode 100644 index 81d328f6..00000000 --- a/test/cliffWestOutOfSample.spec.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import batch from "./fixtures/oracle-vulcanus-cliff-entities-border-batch.seed123456.json"; -import more from "./fixtures/oracle-vulcanus-cliff-entities-more-regions.seed123456.json"; -import oreRegions from "./fixtures/oracle-vulcanus-cliff-ore-direction-regions.seed123456.json"; -import oos from "./fixtures/oracle-vulcanus-cliff-entities-west-oos.seed123456.json"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - CLIFF_CELL_CENTER_X, - CLIFF_CELL_CENTER_Y, - CLIFF_CODE_TO_ORIENTATION, - CLIFF_GRID_SIZE, - cliffCollisionTileBox, -} from "../src/noise/cliffs/cliffCatalog"; -import { cliffCodeForOrientation } from "../src/noise/cliffs/cliffConnections"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { makeVulcanusOreRejection } from "../src/noise/cliffs/vulcanusOreRejection"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; -import { buildResources } from "../src/noise/preview/renderVulcanusResources"; -import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -/** - * **The WEST-edge concentration REPLICATES out of sample.** Eight regions - * captured fresh for this test, disjoint from the fourteen every previous west - * measurement used (#84). - * - * This is the check that six mechanism hunts were spent without: #150's z = 3.01 - * edge split and #151's four sweep-order arms all reuse the SAME 14 regions, so - * every one of them was a re-slice of one sample. This repo has been burned by - * exactly that shape before - a partition that looked solid at n = 14 and needed - * n raised rather than sliced again. - * - * `oracle-vulcanus-cliff-entities-west-oos` is eight new Vulcanus regions with - * the same paired ON / ALL-resources-OFF ore lever, spread away from spawn and - * from each other and from all fifteen already in use. - * - * The measurement is identical in every other respect: the SHIPPED model's - * unexplained residual (a cell the game killed that neither our own kill set nor - * the game's ore lever accounts for), split by which chunk edge it sits on, - * against a base rate measured over that sample's own raw border cells. - * - * ## The result: half replicates, half does not - * - * | | unexplained | on border | W | N | E | S | z(W) | z(N) | z(E) | - * | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | - * | in sample (14 regions) | 25 | 19 | 9 | 5 | 3 | 2 | **2.60** | 0.41 | -1.21 | - * | **out of sample (8 new)** | 34 | 23 | 10 | 9 | 4 | 7 | **2.29** | **1.79** | -1.04 | - * - * **What replicates:** west is the most enriched edge again (z = 2.29), and east - * is the depleted edge in both. The concentration is real and is not an artifact - * of re-slicing one sample - which is what six mechanism hunts were spent - * without ever checking. - * - * **What does NOT:** "west specifically". In sample north sat at its base rate - * (z = 0.41) and west led it by more than 2 sigma; out of sample north is 1.79 - * and trails west by half a sigma. The claim that survives both samples is the - * weaker and differently-shaped one: **the low-coordinate edges (west and north) - * are enriched and east is depleted**, not west alone. - * - * That matters for mechanism hunting, because "west" and "west + north" point at - * different things - and note #151 already refuted the one rule whose asymmetry - * is literally west-then-north, the repair sweep's `L, T, R, B` clear order, by - * permutation. So this widens the target rather than narrowing it. - */ - -const K = (x: number, y: number): string => `${String(x)},${String(y)}`; -const INPUT = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }; -const ctx = withCtxDefaults(INPUT); -const fields = makeVulcanusCliffFields(ctx); -const tileAt = makeVulcanusTileResolver(INPUT); -const oreRejects = makeVulcanusOreRejection(buildResources(ctx), ctx.vulcanusResourceControls); -const tileCollides = (x: number, y: number): boolean => - VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name); -const BANDS = { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, -}; -const EDGES = ["north", "east", "south", "west"] as const; -type Edge = (typeof EDGES)[number]; - -interface Case { - label?: string; - region: { x0: number; y0: number; x1: number; y1: number }; - cliffs: { x: number; y: number; name: string; orientation: string }[]; -} -const IN_SAMPLE: Case[] = [ - ...(batch.cases as unknown as Case[]), - ...(more.cases as unknown as Case[]), - ...(oreRegions.cases as unknown as Case[]), -]; -const OUT_OF_SAMPLE: Case[] = oos.cases as unknown as Case[]; - -function edgesOf(x: number, y: number): Edge[] { - const cx = (x - CLIFF_CELL_CENTER_X) / CLIFF_GRID_SIZE; - const cy = (y - CLIFF_CELL_CENTER_Y) / CLIFF_GRID_SIZE; - const ix = ((cx % 8) + 8) % 8; - const iy = ((cy % 8) + 8) % 8; - const out: Edge[] = []; - if (iy === 0) out.push("north"); - if (ix === 7) out.push("east"); - if (iy === 7) out.push("south"); - if (ix === 0) out.push("west"); - return out; -} - -interface Arm { - unexplained: number; - border: number; - byEdge: Record; - z: Record; -} - -/** The #150 edge-split measurement over a given set of ON/OFF region pairs. */ -function arm(order: readonly number[], PAIRS: Case[]): Arm { - const bands = { ...BANDS, sweepEdgeOrder: order }; - const shippedBands = { - ...bands, - tileCollides, - cellRejects: oreRejects, - rejectAtCrossingStage: true, - }; - const byEdge: Record = { north: 0, east: 0, south: 0, west: 0 }; - const base: Record = { north: 0, east: 0, south: 0, west: 0 }; - let unexplained = 0; - let border = 0; - - for (let i = 0; i < PAIRS.length; i += 2) { - const on = PAIRS[i]; - const off = PAIRS[i + 1]; - const r = on.region; - const inR = (p: { x: number; y: number }): boolean => - p.x >= r.x0 && p.x < r.x1 && p.y >= r.y0 && p.y < r.y1; - const game = new Set( - on.cliffs.filter((e) => e.name === "cliff-vulcanus" && inR(e)).map((e) => K(e.x, e.y)), - ); - const gameOff = new Set( - off.cliffs.filter((e) => e.name === "cliff-vulcanus" && inR(e)).map((e) => K(e.x, e.y)), - ); - const oreSuppressed = new Set([...gameOff].filter((k) => !game.has(k))); - - const shipped = new Set( - makeCliffPlacementFromFields(fields, shippedBands) - .placedCells(r.x0, r.y0, r.x1, r.y1) - .map((p) => K(p.x, p.y)), - ); - const all = makeCliffPlacementFromFields(fields, bands).placedCells( - r.x0 - 64, - r.y0 - 64, - r.x1 + 64, - r.y1 + 64, - ); - const killSet = new Set(); - for (const p of all) { - const o = CLIFF_CODE_TO_ORIENTATION[p.code]; - if (o === undefined) continue; - const code = cliffCodeForOrientation(o); - const box = cliffCollisionTileBox(code, p.x, p.y); - let hit = false; - if (box !== undefined) - for (let tx = box.left; tx <= box.right && !hit; tx++) - for (let ty = box.top; ty <= box.bottom && !hit; ty++) - if (tileCollides(tx, ty)) hit = true; - if (hit || oreRejects(code, p.x, p.y)) killSet.add(K(p.x, p.y)); - } - - for (const p of all) { - if (!inR(p)) continue; - if (CLIFF_CODE_TO_ORIENTATION[p.code] === undefined) continue; - for (const e of edgesOf(p.x, p.y)) base[e]++; - const k = K(p.x, p.y); - if (game.has(k)) continue; - if (killSet.has(k) || oreSuppressed.has(k)) continue; - if (!shipped.has(k)) continue; - unexplained++; - const es = edgesOf(p.x, p.y); - if (es.length > 0) border++; - for (const e of es) byEdge[e]++; - } - } - - const baseTotal = EDGES.reduce((a, e) => a + base[e], 0); - const z = {} as Record; - for (const e of EDGES) { - const p = base[e] / baseTotal; - z[e] = (byEdge[e] - border * p) / Math.sqrt(border * p * (1 - p)); - } - return { unexplained, border, byEdge, z }; -} - -const IN = arm([0, 1, 2, 3], IN_SAMPLE); -const OOS = arm([0, 1, 2, 3], OUT_OF_SAMPLE); -const topEdge = (a: Arm): Edge => - EDGES.reduce((best, e) => (a.z[e] > a.z[best] ? e : best), "north"); - -describe("Vulcanus cliffs: the WEST concentration replicates out of sample (#84)", () => { - it("reproduces the in-sample split as the control", () => { - expect(IN_SAMPLE.length / 2).toBe(14); - expect(IN.unexplained).toBe(25); - expect(IN.byEdge).toEqual({ west: 9, north: 5, east: 3, south: 2 }); - expect(IN.z.west).toBeCloseTo(2.6, 1); - }, 900000); - - it("covers eight genuinely new regions", () => { - expect(OUT_OF_SAMPLE.length / 2).toBe(8); - // Disjoint from every region the in-sample set uses. - const originsOf = (c: Case[]): Set => - new Set(c.map((k) => `${String(k.region.x0)},${String(k.region.y0)}`)); - const a = originsOf(IN_SAMPLE); - for (const o of originsOf(OUT_OF_SAMPLE)) expect(a.has(o)).toBe(false); - }, 900000); - - it("finds WEST the most enriched edge again - the signal replicates", () => { - expect(OOS.unexplained).toBe(34); - expect(OOS.border).toBe(23); - expect(OOS.byEdge).toEqual({ west: 10, north: 9, south: 7, east: 4 }); - expect(topEdge(OOS)).toBe("west"); - expect(OOS.z.west).toBeCloseTo(2.29, 1); - // East stays the DEPLETED edge in both samples, which is the other half of - // the pattern surviving. - expect(OOS.z.east).toBeLessThan(0); - expect(IN.z.east).toBeLessThan(0); - }, 900000); - - it("but NORTH rises sharply, so 'west specifically' does NOT replicate", () => { - // The honest half. In sample north sat at the base rate (z = 0.41); out of - // sample it is 1.79 and only half a sigma behind west. The replicated claim - // is the WEST/NORTH pair being high and east low - not west alone. - expect(IN.z.north).toBeLessThan(0.5); - expect(OOS.z.north).toBeGreaterThan(1.5); - expect(OOS.z.west - OOS.z.north).toBeLessThan(0.6); - // And in sample the gap was more than 2 sigma, so this is a real change in - // the shape of the result, not noise around one number. - expect(IN.z.west - IN.z.north).toBeGreaterThan(2); - }, 900000); -}); diff --git a/test/elevationIsland.spec.ts b/test/elevationIsland.spec.ts deleted file mode 100644 index 44fad9da..00000000 --- a/test/elevationIsland.spec.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import fixture from "./fixtures/oracle-elevation-island.seed123456.json"; -import { countOffGrid, snapPosition } from "./captureGrid"; -import { makeElevationIsland, elevationIsland } from "../src/noise/expressions/elevationIsland"; -import { makeElevationLakes } from "../src/noise/expressions/elevationLakes"; - -const GRID: Array<[number, number]> = [ - [0.5, 0.25], - [2200.5, 0.25], - [-1600.5, 1200.25], - [12345.75, 6789.125], -]; - -describe("makeElevationIsland delegates to lakes with bias=-1000 and seg/4", () => { - it("equals makeElevationLakes with bias -1000 and segmentation/4 (default seg 1)", () => { - const island = makeElevationIsland({ seed0: 123456 }); - const lakes = makeElevationLakes({ seed0: 123456, bias: -1000, segmentationMultiplier: 0.25 }); - for (const [x, y] of GRID) expect(island(x, y)).toBe(lakes(x, y)); - }); - - it("divides an explicit segmentationMultiplier by 4", () => { - const island = makeElevationIsland({ seed0: 123456, segmentationMultiplier: 2 }); - const lakes = makeElevationLakes({ seed0: 123456, bias: -1000, segmentationMultiplier: 0.5 }); - for (const [x, y] of GRID) expect(island(x, y)).toBe(lakes(x, y)); - }); - - it("passes waterLevel and startingPositions through", () => { - const opts = { seed0: 123456, waterLevel: 15, startingPositions: [{ x: 300, y: -400 }] }; - const island = makeElevationIsland(opts); - const lakes = makeElevationLakes({ ...opts, bias: -1000, segmentationMultiplier: 0.25 }); - for (const [x, y] of GRID) expect(island(x, y)).toBe(lakes(x, y)); - }); - - it("elevationIsland(ctx) matches makeElevationIsland(...)(x, y)", () => { - const at = makeElevationIsland({ seed0: 123456 }); - expect(elevationIsland({ seed0: 123456, x: 2200.5, y: 0.25 })).toBe(at(2200.5, 0.25)); - }); -}); - -// Parity is asserted only where the game's own starting_lake_distance saturated at -// 1024 (the empty-lake far-from-spawn ctx is exact there); plus a near-spawn block -// that uses the computed starting lakes. Mirrors elevationLakes.spec.ts. -const SATURATED = (i: number) => fixture.startingLakeDistance[i] >= 1024; - -describe("elevationIsland reproduces the game's elevation_island tree", () => { - const evalAt = makeElevationIsland({ seed0: fixture.seed0 }); - - it("has parity-testable (saturated) points", () => { - expect(fixture.positions.filter((_p, i) => SATURATED(i)).length).toBeGreaterThanOrEqual(12); - }); - - it("matches the water mask (elevation < 0) away from the coastline", () => { - for (let i = 0; i < fixture.positions.length; i++) { - if (!SATURATED(i)) continue; - const exp = fixture.elevation[i]; - if (Math.abs(exp) < 1e-3) continue; // coastline: sign ambiguous within the floor - const s = snapPosition(fixture.positions[i]); - expect(evalAt(s.x, s.y) < 0).toBe(exp < 0); - } - }); - - it("matches the numeric elevation to the f32 coordinate floor", () => { - let worst = 0; - let worstLabel = ""; - for (let i = 0; i < fixture.positions.length; i++) { - if (!SATURATED(i)) continue; - const p = fixture.positions[i]; - const s = snapPosition(p); - const err = Math.abs(Math.fround(evalAt(s.x, s.y)) - fixture.elevation[i]); - if (err > worst) { - worst = err; - worstLabel = `@(${p.x},${p.y})`; - } - } - // Was 8e-3, blamed on "the same f32 regime as lakes (offset_x=10000)". The - // real cause was the off-grid capture coordinates: snapping them takes this - // set from 4/17 exact at worst 6.673e-3 to 10/17 at worst 3.052e-5, a 219x - // drop. Calibrated just above the measured post-snap worst. - expect(worst, `worst ${worstLabel}`).toBeLessThan(4e-5); - }); - - it("matches near-spawn elevation too (computed starting lakes)", () => { - let worst = 0; - let worstLabel = ""; - let checked = 0; - for (let i = 0; i < fixture.positions.length; i++) { - if (SATURATED(i)) continue; - const p = fixture.positions[i]; - const s = snapPosition(p); - const err = Math.abs(Math.fround(evalAt(s.x, s.y)) - fixture.elevation[i]); - checked++; - if (err > worst) { - worst = err; - worstLabel = `@(${p.x},${p.y})`; - } - } - expect(checked).toBeGreaterThanOrEqual(9); - expect(worst, `worst ${worstLabel}`).toBeLessThan(5e-7); - }); - - it("still has off-grid positions for the snap to correct", () => { - // Anti-vacuity for the snap: if a re-capture lands every position on the - // 1/256 grid this reaches 0 and `snapPosition` should be deleted here. - expect(countOffGrid(fixture.positions)).toBe(14); - }); -}); diff --git a/test/elevationLakes.spec.ts b/test/elevationLakes.spec.ts deleted file mode 100644 index 44bf8a42..00000000 --- a/test/elevationLakes.spec.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import fixture from "./fixtures/oracle-elevation-lakes.seed123456.json"; -import { countOffGrid, snapPosition } from "./captureGrid"; -import { makeElevationLakes } from "../src/noise/expressions/elevationLakes"; - -// Task 0 confirmed: starting_positions = origin spawn (distance == hypot), so the -// EvalCtx defaults are faithful. starting_lake_positions is non-empty near spawn, -// so we parity-test only where the game's own starting_lake_distance saturated at -// 1024 (the empty-lake far-from-spawn ctx is exact there). -const SATURATED = (i: number) => fixture.startingLakeDistance[i] >= 1024; - -describe("elevationLakes reproduces the game's elevation_lakes tree (far from spawn)", () => { - const evalAt = makeElevationLakes({ seed0: fixture.seed0 }); - - it("has parity-testable points (guards against a fixture regen dropping them)", () => { - expect(fixture.positions.filter((_p, i) => SATURATED(i)).length).toBeGreaterThanOrEqual(12); - }); - - it("matches the water mask (elevation < 0) away from the coastline", () => { - for (let i = 0; i < fixture.positions.length; i++) { - if (!SATURATED(i)) continue; - const exp = fixture.elevation[i]; - if (Math.abs(exp) < 1e-3) continue; // coastline: sign is ambiguous within the floor - const s = snapPosition(fixture.positions[i]); - expect(evalAt(s.x, s.y) < 0).toBe(exp < 0); - } - }); - - it("matches the numeric elevation to the f32 coordinate floor", () => { - let worst = 0; - let worstLabel = ""; - for (let i = 0; i < fixture.positions.length; i++) { - if (!SATURATED(i)) continue; - const p = fixture.positions[i]; - const s = snapPosition(p); - const err = Math.abs(Math.fround(evalAt(s.x, s.y)) - fixture.elevation[i]); - if (err > worst) { - worst = err; - worstLabel = `@(${p.x},${p.y})`; - } - } - // This bound was 8e-3, explained as "the game's f32 coordinate pipeline - // diverges from our f64". That explanation was wrong. The 14 far-ring - // positions were CAPTURED off the game's 1/256 MapPosition grid, so the game - // sampled a different point than the fixture recorded (#186). Snapping the - // sample coordinate the way the game does takes this set from 6/17 exact at - // worst 7.372e-3 to 13/17 at worst 3.815e-6 - a 1,933x drop, and the largest - // single correction of the 17 affected fixtures after rock-density and the - // vulcanus resources. See `test/captureGrid.ts`. - // - // Calibrated just above the measured post-snap worst. The 4 remaining misses - // are unexplained and tracked in #255; do not raise this to accommodate them. - expect(worst, `worst ${worstLabel}`).toBeLessThan(4e-6); - }); - - it("now matches near-spawn elevation too (computed starting lakes)", () => { - // evalAt uses the computed starting_lake_positions default, so the near-spawn - // band the M1 test could not assert (starting_lake_distance < 1024) is now - // faithful. This is the payoff of porting getStartingLakePositions. - let worst = 0; - let worstLabel = ""; - let checked = 0; - for (let i = 0; i < fixture.positions.length; i++) { - if (SATURATED(i)) continue; // the previously-unassertable near-spawn band - const p = fixture.positions[i]; - const s = snapPosition(p); - const err = Math.abs(Math.fround(evalAt(s.x, s.y)) - fixture.elevation[i]); - checked++; - if (err > worst) { - worst = err; - worstLabel = `@(${p.x},${p.y})`; - } - } - expect(checked).toBeGreaterThanOrEqual(9); - // All 9 near-spawn positions are already ON the 1/256 grid, so the snap is - // the identity here and this number is unchanged by it - which is the control - // that says the snap only moved rows it should have. The old bound was the - // far field's 8e-3, which was ~16,000x the measured worst; it is now - // calibrated to that worst instead. The point of the test is unchanged: terms - // 2-4 consume starting_lake_distance, so this asserts they use the CORRECT - // near-spawn lakes rather than diverging as they did with []. - expect(worst, `worst ${worstLabel}`).toBeLessThan(5e-7); - }); - - it("still has off-grid positions for the snap to correct", () => { - // Anti-vacuity for the snap. All 14 are in the far set; if a re-capture ever - // lands them on the grid this reaches 0 and `snapPosition` should be deleted - // here rather than left looking load-bearing. - expect(countOffGrid(fixture.positions)).toBe(14); - }); -}); - -describe("makeElevationLakes bias parameter", () => { - const GRID: Array<[number, number]> = [ - [0.5, 0.25], - [2200.5, 0.25], - [-1600.5, 1200.25], - [12345.75, 6789.125], - ]; - - it("defaults bias to 20 (omitted === explicit 20)", () => { - const def = makeElevationLakes({ seed0: 123456 }); - const explicit = makeElevationLakes({ seed0: 123456, bias: 20 }); - for (const [x, y] of GRID) expect(def(x, y)).toBe(explicit(x, y)); - }); - - it("is monotonic non-decreasing in bias and actually takes effect", () => { - const def = makeElevationLakes({ seed0: 123456 }); - const low = makeElevationLakes({ seed0: 123456, bias: -1000 }); - let strictlyLowerSomewhere = false; - for (const [x, y] of GRID) { - // Lowering bias can only lower or keep max(branch1, branch2) -> lower or keep the tree. - expect(low(x, y)).toBeLessThanOrEqual(def(x, y) + 1e-9); - if (low(x, y) < def(x, y) - 1e-6) strictlyLowerSomewhere = true; - } - expect(strictlyLowerSomewhere).toBe(true); - }); -}); diff --git a/test/elevationNauvis.spec.ts b/test/elevationNauvis.spec.ts deleted file mode 100644 index af4d9f5b..00000000 --- a/test/elevationNauvis.spec.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import fixture from "./fixtures/oracle-elevation-nauvis.seed123456.json"; -import noCliffFixture from "./fixtures/oracle-elevation-nauvis-no-cliff.seed123456.json"; -import { countOffGrid, snapPosition } from "./captureGrid"; -import { makeElevationNauvis } from "../src/noise/expressions/elevationNauvis"; - -// Parity-test only where the game's own starting_lake_distance saturated at 1024; -// near spawn is asserted separately (computed starting lakes make it faithful too). -// (The fixture also carries a `distance` array as captured oracle context; the spec -// keys purely off startingLakeDistance and does not assert `distance` directly.) -const SATURATED = (i: number) => fixture.startingLakeDistance[i] >= 1024; - -describe("elevationNauvis reproduces the game's elevation_nauvis tree", () => { - const evalAt = makeElevationNauvis({ seed0: fixture.seed0 }); - - it("has parity-testable far points (guards against a fixture regen dropping them)", () => { - expect(fixture.positions.filter((_p, i) => SATURATED(i)).length).toBeGreaterThanOrEqual(12); - }); - - it("matches the water mask (elevation < 0) away from the coastline", () => { - for (let i = 0; i < fixture.positions.length; i++) { - if (!SATURATED(i)) continue; - const exp = fixture.elevation[i]; - if (Math.abs(exp) < 1e-3) continue; // coastline: sign is ambiguous within the floor - const s = snapPosition(fixture.positions[i]); - expect(evalAt(s.x, s.y) < 0).toBe(exp < 0); - } - }); - - it("matches the numeric elevation to the f32 coordinate floor (far field)", () => { - let worst = 0; - let worstLabel = ""; - for (let i = 0; i < fixture.positions.length; i++) { - if (!SATURATED(i)) continue; - const p = fixture.positions[i]; - const s = snapPosition(p); - const err = Math.abs(Math.fround(evalAt(s.x, s.y)) - fixture.elevation[i]); - if (err > worst) { - worst = err; - worstLabel = `@(${p.x},${p.y})`; - } - } - // Was 8e-3, explained as "the pure f32-floor divergence" amplified ~20x by - // elevation_magnitude, with the worst "~4.08e-3 (deep field)". Three parts of - // that were wrong, and the first is the one that mattered: - // - // - The dominant cause was the CAPTURE, not the arithmetic. 14 of these 26 - // positions were recorded off the game's 1/256 MapPosition grid, so the - // game sampled a different point (#186). Snapping them takes this set from - // 2/17 exact at worst 3.922e-3 to 3/17 at worst 3.853e-4, a 10x drop. - // - The worst was never at the deep-field point. (12345.75, 6789.125) is ON - // the 1/256 grid and measures 3.574e-7 here - the SMALLEST residual in the - // set. The worst sits on the r=3300 ring, which is off-grid. - // - 4.08e-3 is stale; the tree measured 3.922e-3 before the snap. - // - // Calibrated just above the measured post-snap worst. A residual survives the - // snap here and reaches on-grid rows too (on-grid worst 3.072e-4 against - // off-grid 3.853e-4, the same order), so unlike temperature the snap is not - // the whole story for this tree. That remainder is tracked in #255 - it is - // NOT a reason to raise this bound. See `test/captureGrid.ts`. - expect(worst, `worst ${worstLabel}`).toBeLessThan(4e-4); - }); - - it("matches near-spawn elevation too (computed starting lakes)", () => { - let worst = 0; - let worstLabel = ""; - let checked = 0; - for (let i = 0; i < fixture.positions.length; i++) { - if (SATURATED(i)) continue; - const p = fixture.positions[i]; - const s = snapPosition(p); - const err = Math.abs(Math.fround(evalAt(s.x, s.y)) - fixture.elevation[i]); - checked++; - if (err > worst) { - worst = err; - worstLabel = `@(${p.x},${p.y})`; - } - } - expect(checked).toBeGreaterThanOrEqual(6); - // All 9 near-spawn positions are already ON the 1/256 grid, so the snap is - // the identity here and this number is unchanged by it - the control that - // says the snap only moved rows it should have. Measured worst 1.907e-6 - // (the comment's "~2.87e-6" predates the basisNoise f32 kernel). This band is - // the ONE seam this tree adds beyond lakes: it exercises the computed - // starting_lake_positions (startingLakes.ts), so a drift in the computed lake - // positions trips it. The bound was 1e-4, ~52x the measured worst; it is now - // calibrated to that worst, which makes the guard real. - expect(worst, `worst ${worstLabel}`).toBeLessThan(2e-6); - }); -}); - -// elevation_nauvis_no_cliff = elevation_nauvis_function(added_cliff_elevation = 0) - the -// cliffiness field's dependency (Task 6 / cliff_elevation_nauvis). Same standard grid and -// the same bounds as the elevation_nauvis block above, for the same reasons: the sample -// coordinates are snapped onto the game's 1/256 MapPosition grid (see test/captureGrid.ts), -// which took the far set from 3.920e-3 to 3.834e-4 at seed 123456 and from 1.237e-3 to -// 3.090e-4 at seed 777771. Positions are identical between the two fixtures (same standard -// grid), which lets the structural check below index them 1:1. -describe("elevationNauvis(withCliffElevation:false) reproduces elevation_nauvis_no_cliff", () => { - for (const c of noCliffFixture.cases) { - const evalAt = makeElevationNauvis({ seed0: c.seed, withCliffElevation: false }); - const noCliffSaturated = (i: number) => c.startingLakeDistance[i] >= 1024; - - it(`matches the numeric elevation to the f32 coordinate floor (far field, seed=${c.seed})`, () => { - let worst = 0; - let worstLabel = ""; - for (let i = 0; i < noCliffFixture.positions.length; i++) { - if (!noCliffSaturated(i)) continue; - const p = noCliffFixture.positions[i]; - const s = snapPosition(p); - const err = Math.abs(Math.fround(evalAt(s.x, s.y)) - c.elevation[i]); - if (err > worst) { - worst = err; - worstLabel = `@(${p.x},${p.y})`; - } - } - expect(worst, `worst ${worstLabel}`).toBeLessThan(4e-4); - }); - - it(`matches near-spawn elevation too (computed starting lakes, seed=${c.seed})`, () => { - let worst = 0; - let worstLabel = ""; - let checked = 0; - for (let i = 0; i < noCliffFixture.positions.length; i++) { - if (noCliffSaturated(i)) continue; - const p = noCliffFixture.positions[i]; - const s = snapPosition(p); - const err = Math.abs(Math.fround(evalAt(s.x, s.y)) - c.elevation[i]); - checked++; - if (err > worst) { - worst = err; - worstLabel = `@(${p.x},${p.y})`; - } - } - expect(checked).toBeGreaterThanOrEqual(6); - expect(worst, `worst ${worstLabel}`).toBeLessThan(2e-6); - }); - } - - it("differs from elevation_nauvis (with-cliff) where added_cliff_elevation != 0, and matches where the outer min() masks it", () => { - // fixture (seed 123456, WITH cliff term) and noCliffFixture (seed 123456, no-cliff - // term) share the exact same standard grid, so positions[i] line up 1:1. The final - // elevation is min(wlc_elevation, starting_lake); wherever starting_lake wins, - // added_cliff_elevation (which only feeds wlc_elevation) has no effect and the two - // trees coincide even though the term itself is nonzero - hence "NOT assumed" that - // no-cliff <= with-cliff, and this checks BOTH outcomes actually occur. - expect(fixture.positions).toEqual(noCliffFixture.positions); - const noCliff123456 = noCliffFixture.cases.find((c) => c.seed === fixture.seed0); - expect(noCliff123456).toBeDefined(); - let numDiffer = 0; - let numEqual = 0; - for (let i = 0; i < fixture.positions.length; i++) { - const withCliff = fixture.elevation[i]; - const noCliff = noCliff123456!.elevation[i]; - if (Math.abs(withCliff - noCliff) < 1e-6) { - numEqual++; - } else { - numDiffer++; - } - } - // Observed on the current fixture: 17 differ, 9 equal - both outcomes are real, not - // an artifact of a too-small grid. - expect(numDiffer).toBeGreaterThan(0); - expect(numEqual).toBeGreaterThan(0); - }); -}); - -// Anti-vacuity for the 1/256 capture-grid snap applied above. These fixtures -// record sample coordinates the game never evaluated at (#186); `snapPosition` -// recovers where it did. If a re-capture ever lands every position on the grid -// these counts reach 0, at which point the snap is the identity and should be -// deleted rather than left looking load-bearing. See test/captureGrid.ts. -describe("capture-grid snap is not vacuous", () => { - it("oracle-elevation-nauvis still has off-grid positions", () => { - expect(countOffGrid(fixture.positions)).toBe(14); - }); - it("oracle-elevation-nauvis-no-cliff still has off-grid positions", () => { - expect(countOffGrid(noCliffFixture.positions)).toBe(14); - }); -}); diff --git a/test/elevationRenderRequest.spec.ts b/test/elevationRenderRequest.spec.ts index 892b2866..66db4cce 100644 --- a/test/elevationRenderRequest.spec.ts +++ b/test/elevationRenderRequest.spec.ts @@ -4,14 +4,11 @@ import { runRenderRequest, type ElevationRenderRequest, } from "../src/noise/preview/elevationRenderRequest"; -import { renderElevation, LAND_RGBA, WATER_RGBA } from "../src/noise/preview/renderElevation"; -import { renderTerrain } from "../src/noise/preview/renderTerrain"; import { RESOURCE_CATALOG } from "../src/noise/resources/resourceCatalog"; import { ENEMY_MAP_COLOR } from "../src/noise/enemies/enemyCatalog"; import { CLIFF_MAP_COLOR } from "../src/noise/cliffs/cliffCatalog"; import { ROCK_MAP_COLOR } from "../src/noise/rocks/rockCatalog"; import { SCRAP_MAP_COLOR } from "../src/noise/resources/fulgoraResourceCatalog"; -import { makeTreeDensity } from "../src/noise/trees/treeField"; const REQ: ElevationRenderRequest = { id: 7, @@ -35,138 +32,6 @@ describe("runRenderRequest", () => { expect(r.buffer.byteLength).toBe(8 * 6 * 4); }); - it("produces bytes identical to a direct renderElevation call", () => { - const direct = renderElevation({ - seed0: REQ.seed0, - width: REQ.width, - height: REQ.height, - originX: REQ.originX, - originY: REQ.originY, - tilesPerPixel: REQ.tilesPerPixel, - ctx: { - waterLevel: REQ.waterLevel, - segmentationMultiplier: REQ.segmentationMultiplier, - startingPositions: REQ.startingPositions, - }, - }); - const got = new Uint8ClampedArray(runRenderRequest(REQ).buffer); - expect(Array.from(got)).toEqual(Array.from(direct.data)); - }); - - it("dispatches mapType 'nauvis' to renderElevation's nauvis factory", () => { - const nauvisReq: ElevationRenderRequest = { ...REQ, mapType: "nauvis" }; - const direct = renderElevation({ - seed0: REQ.seed0, - width: REQ.width, - height: REQ.height, - originX: REQ.originX, - originY: REQ.originY, - tilesPerPixel: REQ.tilesPerPixel, - mapType: "nauvis", - ctx: { - waterLevel: REQ.waterLevel, - segmentationMultiplier: REQ.segmentationMultiplier, - startingPositions: REQ.startingPositions, - }, - }); - const got = new Uint8ClampedArray(runRenderRequest(nauvisReq).buffer); - expect(Array.from(got)).toEqual(Array.from(direct.data)); - }); - - it("forwards mapType through to the render, flipping the pixel at a discriminating point", () => { - // World point (-1200, -1162), seed 123456: makeElevationNauvis = +2.34 (LAND), - // makeElevationLakes = -6.81 (WATER). If runRenderRequest dropped/reversed the - // mapType forwarding, both requests would render the same color here. - const base: ElevationRenderRequest = { - id: 1, - seed0: 123456, - width: 1, - height: 1, - originX: -1200, - originY: -1162, - tilesPerPixel: 1, - waterLevel: 0, - segmentationMultiplier: 1, - startingPositions: [{ x: 0, y: 0 }], - }; - - const nauvisBuf = new Uint8ClampedArray( - runRenderRequest({ ...base, mapType: "nauvis" }).buffer, - ); - expect(Array.from(nauvisBuf.slice(0, 4))).toEqual(LAND_RGBA); - - const lakesBuf = new Uint8ClampedArray(runRenderRequest({ ...base, mapType: "lakes" }).buffer); - expect(Array.from(lakesBuf.slice(0, 4))).toEqual(WATER_RGBA); - }); - - it("view 'terrain' dispatches to renderTerrain, producing terrain-tile colors", () => { - // World point (2742, 8459), seed 123456: a known deep-water point from - // renderTerrain.spec.ts (deepwater color [38, 64, 73, 255]) - distinct from - // renderElevation's flat WATER_RGBA, so this proves the terrain renderer ran. - const req: ElevationRenderRequest = { - id: 9, - seed0: 123456, - width: 1, - height: 1, - originX: 2742, - originY: 8459, - tilesPerPixel: 1, - waterLevel: 0, - segmentationMultiplier: 1, - startingPositions: [{ x: 0, y: 0 }], - view: "terrain", - }; - const direct = renderTerrain({ - seed0: req.seed0, - width: req.width, - height: req.height, - originX: req.originX, - originY: req.originY, - tilesPerPixel: req.tilesPerPixel, - ctx: { segmentationMultiplier: req.segmentationMultiplier }, - }); - const got = new Uint8ClampedArray(runRenderRequest(req).buffer); - expect(Array.from(got)).toEqual(Array.from(direct.data)); - expect(Array.from(got)).toEqual([38, 64, 73, 255]); - }); - - it("view 'terrain' forwards climate fields, changing some pixel under a shifted aux bias", () => { - // A 20x20 land-ward grid (aux/moisture drive most of the catalog's - // expression_in_range boxes, so a +0.5 aux bias reliably flips some - // pixel's argmax winner somewhere in a grid this size) - proving - // runRenderRequest forwards moistureFrequency/moistureBias/auxFrequency/ - // auxBias/startingAreaMoistureSize/startingAreaMoistureFrequency through - // to renderTerrain's ctx rather than dropping them. - const req: ElevationRenderRequest = { - id: 10, - seed0: 123456, - width: 20, - height: 20, - originX: -2000, - originY: -2000, - tilesPerPixel: 200, - waterLevel: 0, - segmentationMultiplier: 1, - startingPositions: [{ x: 0, y: 0 }], - view: "terrain", - auxBias: 0.5, - }; - const direct = renderTerrain({ - seed0: req.seed0, - width: req.width, - height: req.height, - originX: req.originX, - originY: req.originY, - tilesPerPixel: req.tilesPerPixel, - ctx: { segmentationMultiplier: req.segmentationMultiplier, auxBias: 0.5 }, - }); - const got = new Uint8ClampedArray(runRenderRequest(req).buffer); - expect(Array.from(got)).toEqual(Array.from(direct.data)); - - const baseline = new Uint8ClampedArray(runRenderRequest({ ...req, auxBias: undefined }).buffer); - expect(Array.from(got)).not.toEqual(Array.from(baseline)); - }); - it("view 'resources' overlays ore on the terrain (differs from terrain only where ore is)", () => { // 32x32 px at 16 tiles/px over world [512, 1024) - a region with patches. const terrainReq: ElevationRenderRequest = { @@ -381,21 +246,6 @@ describe("runRenderRequest", () => { expect(Array.from(withDefaults)).toEqual(Array.from(explicit)); }); - it("view 'elevation' (explicit or default/omitted) keeps the water/land mask", () => { - const explicit = new Uint8ClampedArray(runRenderRequest({ ...REQ, view: "elevation" }).buffer); - const omitted = new Uint8ClampedArray(runRenderRequest(REQ).buffer); - expect(Array.from(explicit)).toEqual(Array.from(omitted)); - // Every pixel of the flat elevation mask is exactly LAND_RGBA or WATER_RGBA - // - neither matches the terrain deepwater color proven above, so this locks - // in that omitting/explicitly requesting "elevation" never routes through - // renderTerrain. - const landOrWater = [LAND_RGBA, WATER_RGBA].map((c) => JSON.stringify(c)); - for (let i = 0; i < explicit.length; i += 4) { - const px = JSON.stringify(Array.from(explicit.slice(i, i + 4))); - expect(landOrWater).toContain(px); - } - }); - /** * **Explicit 120s budget: this test has no headroom under the 30s global on a * contended CI shard.** Measured 7540ms on a dev machine. That looks safe and is @@ -617,19 +467,6 @@ describe("view: trees", () => { mapType: "nauvis" as const, }; - it("samples a window with genuine, non-zero tree density", () => { - const density = makeTreeDensity({ seed0: base.seed0 }); - let nonZero = 0; - for (let py = 0; py < base.height; py++) { - for (let px = 0; px < base.width; px++) { - const wx = base.originX + px * base.tilesPerPixel; - const wy = base.originY + py * base.tilesPerPixel; - if (density(wx, wy) > 0) nonZero++; - } - } - expect(nonZero).toBeGreaterThan(0); - }); - it("renders terrain with the tree overlay composited on top", () => { const terrain = runRenderRequest({ ...base, view: "terrain" }); const trees = runRenderRequest({ ...base, view: "trees" }); diff --git a/test/enemyBaseField.spec.ts b/test/enemyBaseField.spec.ts deleted file mode 100644 index 09cebd9c..00000000 --- a/test/enemyBaseField.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import fixture from "./fixtures/oracle-enemy-base.seed123456.json"; -import { makeEnemyBaseField } from "../src/noise/enemies/enemyBaseField"; -import { ENEMY_PLACEMENT_CAP } from "../src/noise/enemies/enemyCatalog"; - -const ABS_TOL = 1.0; -const REL_TOL = 1e-2; -const relErr = (p: number, g: number) => Math.abs(p - g) / Math.max(1, Math.abs(g)); -/** - * A decision-boundary agreement probe, NOT a render threshold. This was - * `ENEMY_FOOTPRINT_THRESHOLD`, deleted when the overlay moved from thresholding - * the probability field to rolling against it. The assertion it powers is still - * worth keeping - `worstAbs`/`worstRel` are aggregate, and this one says port and - * game never fall on opposite sides of a cut through the live part of the range - - * so the value moves here as a test-local constant rather than surviving as dead - * production code. - */ -const PROBE_CUT = 0.05; -const inFootprint = (v: number) => Math.min(v, ENEMY_PLACEMENT_CAP) >= PROBE_CUT; - -describe("makeEnemyBaseField vs oracle", () => { - for (const c of fixture.cases) { - it(`matches enemy_base_probability seed=${c.seed}`, () => { - const f = makeEnemyBaseField({ seed0: c.seed, controls: { frequency: 1, size: 1 } }); - let worstAbs = 0, - worstRel = 0, - footprintDisagreements = 0; - const mism: { x: number; y: number; game: number; port: number; abs: number }[] = []; - for (let i = 0; i < fixture.positions.length; i++) { - const p = fixture.positions[i]; - const game = c.values[i]; - const port = f.field(p.x, p.y); - const abs = Math.abs(port - game); - if (abs > worstAbs) worstAbs = abs; - if (relErr(port, game) > worstRel) worstRel = relErr(port, game); - if (inFootprint(port) !== inFootprint(game)) footprintDisagreements++; - mism.push({ x: p.x, y: p.y, game, port, abs }); - } - if (worstAbs >= ABS_TOL && worstRel >= REL_TOL) { - const top = [...mism] - .sort((a, b) => b.abs - a.abs) - .slice(0, 12) - .map( - (m) => - ` (${m.x},${m.y}) game=${m.game.toFixed(3)} port=${m.port.toFixed(3)} abs=${m.abs.toFixed(3)}`, - ) - .join("\n"); - throw new Error( - `seed=${c.seed}: worstAbs=${worstAbs.toFixed(3)} worstRel=${worstRel.toExponential(2)}\n${top}`, - ); - } - expect(worstAbs < ABS_TOL || worstRel < REL_TOL).toBe(true); - expect(footprintDisagreements).toBe(0); - }); - } -}); diff --git a/test/entityDensity.spec.ts b/test/entityDensity.spec.ts deleted file mode 100644 index 87cc18c3..00000000 --- a/test/entityDensity.spec.ts +++ /dev/null @@ -1,521 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import fixture from "./fixtures/oracle-entity-counts.seed123456.json"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; -import { makePlacementRoll, PLACEMENT_SALT } from "../src/noise/placement/placementRoll"; -import { - makeNauvisEnemyPlacement, - makeNauvisEnemyProbability, -} from "../src/noise/preview/renderEnemies"; -import { - makeNauvisOilPlacement, - makeNauvisOilProbability, -} from "../src/noise/preview/renderResources"; -import { makeNauvisRockPlacement } from "../src/noise/preview/renderRocks"; -import { - makeVulcanusGeyserPlacement, - makeVulcanusGeyserProbability, -} from "../src/noise/preview/renderVulcanusResources"; -import { makeVulcanusRockPlacement } from "../src/noise/preview/renderVulcanusRocks"; -import { makeRockFields } from "../src/noise/rocks/rockField"; -import { makeVulcanusRockFields } from "../src/noise/rocks/vulcanusRockField"; - -/** - * Does the placement pipeline put down about as many entities as the game? - * - * `src/noise/placement/placementRoll.ts` drops the game's cross-overlay - * arbitration and its per-placement jitter draws, so individual positions are NOT - * expected to match. The only claim on the table is DENSITY, and this file tests - * exactly that claim against `test/fixtures/oracle-entity-counts.seed123456.json` - * (captured from the real game by `test/oracle/entityCounts.ts`). - * - * ## History: this file used to pin a 2x over-placement - * - * As first written (2026-07-27, Task 4) these tests were a *characterization of a - * defect*: the bare roll placed 2467 / 2820 / 2448 rocks against the game's 1133 / - * 1367 / 1450, a ratio of 2.18 / 2.06 / 1.69. The cause was two gates the game - * applies inside its arbitration loop and the port did not - * (`docs/noise/placement-roll-NOTES.md`: the winner is picked by max probability - * "subject to collision-mask and tile-restriction checks"): - * - * 1. **`tile_restriction`** - no rock may sit on `lava` or `lava-hot`, which are - * 21% of region 2. Worth ~24%. - * 2. **Collision rejection** - rocks are big off-grid entities and the roll's hits - * cluster, so most neighbours in a cluster collide with the rock already placed. - * - * Task 4.5 added both (`makePlacementSet`), and the assertions below were - * re-measured and rewritten as the agreement test they were always meant to be. - * They were NOT widened: each region carries its OWN band, pinned just above that - * region's own measured value. - * - * ## The measurement, after both gates (2026-07-27, Factorio 2.1.12, seed 123456) - * - * | region | window | ours | game | rel | - * | --- | --- | --- | --- | --- | - * | 2 | `[0,0]-[512,512]` | 1131 | 1133 | 0.2% | - * | 3 | `[4096,4096]-[4608,4608]` | 1359 | 1367 | 0.6% | - * | 4 | `[-256,-256]-[256,256]` | 1341 | 1450 | 7.5% | - * - * Region 4 is the spawn-centred window, where the port's remaining - * approximations concentrate: no cross-overlay arbitration against the ~1500 - * other entities per region, and no collision across chunk boundaries. It is an - * order of magnitude looser than the other two, which is exactly why each region - * gets its own band rather than sharing region 4's. - * - * ## Nauvis rocks (added 2026-07-27, Task 5) - * - * | region | window | ours | game | rel | - * | --- | --- | --- | --- | --- | - * | 0 | `[0,0]-[512,512]` | 205 | 192 | 6.8% | - * | 1 | `[4096,4096]-[4608,4608]` | 54 | 64 | 15.6% | - * - * Both are looser than the Vulcanus regions, and the reason is arithmetic - * rather than modelling: Nauvis rocks are ~6x sparser, so these windows hold - * 192 and 64 rocks against Vulcanus's ~1200, and a single rock is already 0.5% - * and 1.6%. The gate-by-gate breakdown and the collision-box measurement live - * on `makeNauvisRockPlacement` in `src/noise/preview/renderRocks.ts`. - * - * ## Nauvis enemy bases (added 2026-07-27, Task 6) - * - * | region | window | ours | game | rel | - * | --- | --- | --- | --- | --- | - * | 0 | `[0,0]-[512,512]` | 28 | 19 | 47.4% - NOT PINNED, see below | - * | 1 | `[4096,4096]-[4608,4608]` | 157 | 142 | 10.6% | - * - * **Region 0 is deliberately unusable as an agreement gate and carries no `rel` - * band.** 47.4% is past this project's 0.3 stop-and-report threshold, and the - * cause was measured rather than guessed: 34.3% of region 0 is excluded by trees - * (10.9% in region 1) and a further 3.8% by rocks, both of which sort BEFORE - * spawners in autoplace order and so take their tiles first in the game. Feeding - * this app's own tree and rock placements in as blockers takes region 0 to 19 - * and region 1 to 155 (9.2%). That is a cross-overlay change, not a band, and it - * is not modelled here - see `makeNauvisEnemyPlacement` in - * `src/noise/preview/renderEnemies.ts`. Region 0 still asserts the - * gate-independent `relToField` claim, and its `rel` is logged so a future - * cross-overlay pass can be measured against it. - * - * ## Vulcanus sulfuric-acid geysers (added 2026-07-27, Task 7) - * - * | region | window | ours | game | rel | - * | --- | --- | --- | --- | --- | - * | 2 | `[0,0]-[512,512]` | 0 | 0 | - (asserted as equality) | - * | 3 | `[4096,4096]-[4608,4608]` | 0 | 0 | - (asserted as equality) | - * | 4 | `[-256,-256]-[256,256]` | 56 | 56 | 0.0% | - * - * **Only region 4 has a usable denominator, and 56 is a weak one.** Regions 2 - * and 3 hold no sulfur at all - the geyser probability is <= 0 at every one of - * their 262144 tiles - so the game has zero geysers there and so does this - * model; those two are asserted as exact zeros, which is a real check on the - * region gate but says nothing about the roll. Region 4's n = 56 carries a - * Poisson sigma of ~7.5 (13%), so an exact match is inside the noise by - * construction: re-rolling region 4 under eight different salts gives 46-63 - * (mean 55.3). Read the 0.0% as "unbiased", not as "precise". - */ - -interface FixtureRegion { - planet: string; - x0: number; - y0: number; - x1: number; - y1: number; -} - -/** Count tiles a predicate accepts over a region, one sample per tile. */ -function countOver(region: FixtureRegion, accept: (x: number, y: number) => boolean): number { - let n = 0; - for (let y = region.y0; y < region.y1; y++) { - for (let x = region.x0; x < region.x1; x++) if (accept(x, y)) n++; - } - return n; -} - -/** Sum a probability field over a region - the expected ungated placement count. */ -function expectedCount( - region: FixtureRegion, - probability: (x: number, y: number) => number, -): number { - let sum = 0; - for (let y = region.y0; y < region.y1; y++) { - for (let x = region.x0; x < region.x1; x++) sum += probability(x, y); - } - return sum; -} - -/** Sum the game's count over one region for every name matching `match`. */ -function gameCount(regionIndex: number, match: (name: string) => boolean): number { - return fixture.counts - .filter((c) => c.region === regionIndex && match(c.name)) - .reduce((a, c) => a + c.count, 0); -} - -describe("placement density vs the game", () => { - /** - * All FOUR Vulcanus rock prototypes count, not just the two whose names end in - * `volcanic-rock`. `decoratives-vulcanus.lua` gives `huge-volcanic-rock-hot` - * and `big-volcanic-rock-hot` the SAME `probability_expression`s as their cold - * twins (`vulcanus_rock_huge` / `vulcanus_rock_big`); the pairs differ only by - * `tile_restriction` (hot tiles vs cold tiles), and the union of those two - * restrictions is what `makeVulcanusRockPlacement` models. Our `density` is - * `max(rock_huge, rock_big)`, i.e. the probability a rock of any of the four - * wins the tile, so the comparable game number is the sum of all four. - */ - const isVulcanusRock = (name: string): boolean => - name.endsWith("volcanic-rock") || name.endsWith("volcanic-rock-hot"); - - /** - * Per-region bands, each pinned just above its OWN measured value - NOT one - * shared ceiling. A single `0.08` (the worst region's band) would let regions 2 - * and 3, which measure 0.0018 and 0.0059, absorb a 40x regression while staying - * green. These quantities are deterministic, so the only headroom allowed is a - * few tiles' worth against cross-engine float drift. - * - * | region | measured rel | tiles | band | - * | --- | --- | --- | --- | - * | 2 | 0.0018 | 1131 vs 1133 | 0.005 (~5 tiles) | - * | 3 | 0.0059 | 1359 vs 1367 | 0.010 (~13 tiles) | - * | 4 | 0.0752 | 1341 vs 1450 | 0.080 | - */ - const BAND: Record = { 2: 0.005, 3: 0.01, 4: 0.08 }; - - /** - * Same shape for the ungated roll-vs-field-integral check. Measured 0.0022 / - * 0.0326 / 0.0049 - region 3's is an order of magnitude larger than the other - * two, so a shared band would have hidden that asymmetry as well. - */ - const FIELD_BAND: Record = { 2: 0.005, 3: 0.04, 4: 0.008 }; - - const vulcanusRegions = fixture.regions - .map((r, i) => ({ region: r as FixtureRegion, index: i })) - .filter((e) => e.region.planet === "vulcanus"); - - // One `it` per region rather than two: each sweeps 262144 tiles of the Vulcanus - // field stack, and the suite already has a test that times out under load. - for (const { region, index } of vulcanusRegions) { - it(`Vulcanus rocks: placement density agrees with the game (region ${String(index)})`, () => { - const game = gameCount(index, isVulcanusRock); - const ctx = withCtxDefaults({ seed0: fixture.seed, startingPositions: [{ x: 0, y: 0 }] }); - const ours = countOver(region, makeVulcanusRockPlacement(ctx)); - const rel = Math.abs(ours - game) / game; - - // The bare roll, ungated, against the field's own integral. This is the one - // claim that holds independent of the gates: the roll is an unbiased uniform - // draw, so the tiles it accepts must match the field's integral. It is what - // says the RE'd taus88 stream, the chunk seeding and the caching are sound - - // and it is what localised the original 2x error to the missing gates rather - // than to the roll. Banded per region, see FIELD_BAND. - const { density } = makeVulcanusRockFields(ctx); - const roll = makePlacementRoll(PLACEMENT_SALT.vulcanusRocks); - const ungated = countOver(region, (x, y) => roll(x, y) < density(x, y)); - const expected = expectedCount(region, density); - const relToField = Math.abs(ungated - expected) / expected; - - console.log( - `vulcanus rocks region ${String(index)} [${String(region.x0)},${String(region.y0)}]: ` + - `ours=${String(ours)} game=${String(game)} rel=${rel.toFixed(4)} ` + - `ungated=${String(ungated)} sum(density)=${expected.toFixed(1)} ` + - `relToField=${relToField.toFixed(4)}`, - ); - - expect(rel).toBeLessThan(BAND[index]); - expect(relToField).toBeLessThan(FIELD_BAND[index]); - }, 120000); - } -}); - -describe("Vulcanus geyser placement density vs the game", () => { - /** - * The band for the ONE region with geysers. Measured `rel = 0.0000` (56 vs - * 56), so "just above the measured value" is a headroom decision rather than - * a rounding one; 0.04 is +/-2 geysers on 56, the same ~2-entity headroom the - * rock bands carry. - * - * | region | game | ours | measured rel | band | - * | --- | --- | --- | --- | --- | - * | 2 | 0 | 0 | - | equality, not a band | - * | 3 | 0 | 0 | - | equality, not a band | - * | 4 | 56 | 56 | 0.0000 | 0.04 (+/-2 geysers) | - * - * **What this band does and does not have power over.** It fails on the real - * physics: dropping the collision gate gives 81 (rel 0.446), and that is the - * whole of the gating here - the lava tile restriction rejects nothing in this - * window (see `GEYSER_FORBIDDEN_TILES` in `renderVulcanusResources.ts` for why - * that 0 is a property of the window, not of the gate). Unlike the enemy-base - * band it DOES discriminate the arbitrary salt: of the eight salts measured - * (46-63 placements), only two pass 0.04. That is a consequence of pinning to - * a measured 0.0, not a claim that the salt is right - it is arbitrary, and a - * deliberate salt change here means re-measuring, not widening. - */ - const BAND = 0.04; - - /** - * The ungated roll against the probability's own integral - the claim that - * holds independent of both gates. Measured in region 4 only (the other two - * integrate to exactly 0): 81 placements against a sum of 73.5, rel 0.1022. - * - * That is an order of magnitude looser than the rock overlays' 0.002-0.033, - * and the reason is the count, not the roll: a Poisson draw with mean 73.5 has - * sigma 8.6, i.e. 11.7% of the mean, so 81 is +0.87 sigma. The band adds ~1 - * further placement over the measured value. - */ - const FIELD_BAND = 0.11; - - const vulcanusRegions = fixture.regions - .map((r, i) => ({ region: r as FixtureRegion, index: i })) - .filter((e) => e.region.planet === "vulcanus"); - - for (const { region, index } of vulcanusRegions) { - it(`Vulcanus geysers: placement density vs the game (region ${String(index)})`, () => { - const game = gameCount(index, (name) => name === "sulfuric-acid-geyser"); - const ctx = withCtxDefaults({ seed0: fixture.seed, startingPositions: [{ x: 0, y: 0 }] }); - const ours = countOver(region, makeVulcanusGeyserPlacement(ctx)); - - const probability = makeVulcanusGeyserProbability(ctx); - const roll = makePlacementRoll(PLACEMENT_SALT.vulcanusGeyser); - const ungated = countOver(region, (x, y) => roll(x, y) < probability(x, y)); - // The probability is negative wherever the geyser cannot place (the game's - // expression is not clamped), and a negative term must not subtract from - // the expected count - the roll can never accept there. - const expected = expectedCount(region, (x, y) => Math.max(0, probability(x, y))); - - console.log( - `vulcanus geysers region ${String(index)} [${String(region.x0)},${String(region.y0)}]: ` + - `ours=${String(ours)} game=${String(game)} ungated=${String(ungated)} ` + - `sum(probability)=${expected.toFixed(1)}`, - ); - - if (game === 0) { - // No sulfur reaches these two windows at all, so this is an assertion - // about the region gate rather than about the roll - but a sign error or - // a dropped `(patchy > 0)` term would place thousands here. - expect(ours).toBe(0); - expect(expected).toBe(0); - return; - } - expect(Math.abs(ours - game) / game).toBeLessThan(BAND); - expect(Math.abs(ungated - expected) / expected).toBeLessThan(FIELD_BAND); - }, 120000); - } -}); - -describe("Nauvis enemy-base placement density vs the game", () => { - /** - * The two `unit-spawner` prototypes whose autoplace shares the - * `b[enemy]-a[spawner]` order and therefore, per the game's grouped - * arbitration, competes for one roll per tile. The four worms are a separate - * group (`b[enemy]-b[worm]`) with their own rolls and are neither modelled nor - * in the fixture. - */ - const isSpawner = (name: string): boolean => - name === "biter-spawner" || name === "spitter-spawner"; - - /** - * **Region 1 only.** Region 0 measures 0.4737, past the 0.3 stop-and-report - * threshold this project applies to a density model, so it gets no `rel` band - * rather than a widened one. The file header records why (34.3% of region 0 is - * excluded by trees that the game places first and this overlay does not model, - * against 10.9% in region 1) and what closing it measures (19 and 155). - * - * | region | measured rel | count | band | headroom | - * | --- | --- | --- | --- | --- | - * | 0 | 0.4737 | 28 vs 19 | (none) | - | - * | 1 | 0.1056 | 157 vs 142 | 0.11 | zero further spawners | - * - * Region 1's headroom is exactly none, and that is deliberate rather than - * accidental: `0.11 * 142 = 15.62` against a measured deviation of 15, so 158 - * spawners (deviation 16, rel 0.1127) already fails. The band exists to hold a - * deterministic quantity against cross-engine float drift, not to leave room for - * the model to move. - * - * **What this band does and does not have power over.** It catches real - * regressions in the physics: dropping `random_penalty` was run as a - * falsification and fails at 0.176, and every other degraded variant in - * `renderEnemies.ts`'s gate-by-gate table (which is measured pre-penalty) sits at - * 167 spawners or worse - `collision_box` instead of the map-gen box at 290, - * collision-only at 730, restriction-only at 1704 - so all of them fail 0.11 too. - * It does NOT discriminate the - * arbitrary penalty salts: all six pairs `renderEnemies.ts` measures (149-157, - * rel 0.049-0.106) pass 0.11, so a salt change is absorbed silently. Read 0.1056 - * as one draw from that range, not as a property of the model. - */ - const BAND: Record = { 1: 0.11 }; - - /** - * The ungated roll against the group probability's own integral - the claim - * that holds independent of both gates, i.e. that the taus88 stream and the - * chunk seeding are an unbiased draw. This one IS asserted in both regions, - * because it is unaffected by the tree occupancy that makes region 0's `rel` - * unusable. Measured 0.0029 (224 vs 224.6) and 0.0105 (6574 vs 6505.8); bands - * add ~2 tiles and ~10 tiles respectively. - */ - const FIELD_BAND: Record = { 0: 0.01, 1: 0.012 }; - - const nauvisRegions = fixture.regions - .map((r, i) => ({ region: r as FixtureRegion, index: i })) - .filter((e) => e.region.planet === "nauvis"); - - for (const { region, index } of nauvisRegions) { - it(`Nauvis enemy bases: placement density vs the game (region ${String(index)})`, () => { - const game = gameCount(index, isSpawner); - const params = { - seed0: fixture.seed, - controls: { frequency: 1, size: 1 }, - startingPositions: [{ x: 0, y: 0 }], - }; - const ours = countOver(region, makeNauvisEnemyPlacement(params)); - const rel = Math.abs(ours - game) / game; - - const probability = makeNauvisEnemyProbability(params); - const roll = makePlacementRoll(PLACEMENT_SALT.enemyBases); - const ungated = countOver(region, (x, y) => roll(x, y) < probability(x, y)); - const expected = expectedCount(region, probability); - const relToField = Math.abs(ungated - expected) / expected; - - console.log( - `nauvis enemy bases region ${String(index)} [${String(region.x0)},${String(region.y0)}]: ` + - `ours=${String(ours)} game=${String(game)} rel=${rel.toFixed(4)} ` + - `ungated=${String(ungated)} sum(probability)=${expected.toFixed(1)} ` + - `relToField=${relToField.toFixed(4)}`, - ); - - const band = BAND[index]; - if (band !== undefined) expect(rel).toBeLessThan(band); - expect(relToField).toBeLessThan(FIELD_BAND[index]); - }, 120000); - } -}); - -describe("Nauvis rock placement density vs the game", () => { - /** - * Exactly three Nauvis prototypes are entities, and `makeRockFields`' - * `density` is the max of their three probabilities, so the comparable game - * number is the sum of all three counts. The five other `control = "rocks"` - * prototypes in `decoratives.lua` (medium/small/tiny rock, medium/small sand - * rock) are `type = "optimized-decorative"` - a different generation pass, not - * entities - so they neither appear in the fixture nor belong in this sum. - */ - const isNauvisRock = (name: string): boolean => - name === "huge-rock" || name === "big-rock" || name === "big-sand-rock"; - - /** - * Per-region bands, each pinned just above its OWN measured value, with about - * two rocks of headroom against cross-engine float drift. The regions are far - * smaller than the Vulcanus ones (192 and 64 rocks against ~1200), so one rock - * is 0.5% and 1.6% respectively - the percentages here are inherently coarser, - * which is another reason not to share one band. - * - * | region | measured rel | count | band | headroom | - * | --- | --- | --- | --- | --- | - * | 0 | 0.0677 | 205 vs 192 | 0.08 | ~2 rocks | - * | 1 | 0.1563 | 54 vs 64 | 0.19 | ~2 rocks | - * - * Region 1 `[4096,4096]` is 60% water (measured with the ported tile - * resolver), so the water restriction does most of the gating there: the bare - * roll places 182, the restriction alone cuts that to 60, and collision takes - * it to 54 against the game's 64. Restriction-only is numerically closer, and - * it is deliberately NOT what ships - the game applies both gates, and - * dropping one to improve a 6-rock difference on a 64-rock region would be - * fitting the oracle rather than modelling it. - */ - const BAND: Record = { 0: 0.08, 1: 0.19 }; - - /** - * Same shape for the ungated roll-vs-field-integral check, which is the claim - * that holds independent of the gates. Measured 0.0156 (312 vs sum 317.0) and - * 0.0222 (182 vs 186.1); bands add ~2 rocks each. - */ - const FIELD_BAND: Record = { 0: 0.022, 1: 0.033 }; - - const nauvisRegions = fixture.regions - .map((r, i) => ({ region: r as FixtureRegion, index: i })) - .filter((e) => e.region.planet === "nauvis"); - - for (const { region, index } of nauvisRegions) { - it(`Nauvis rocks: placement density agrees with the game (region ${String(index)})`, () => { - const game = gameCount(index, isNauvisRock); - const params = { seed0: fixture.seed, startingPositions: [{ x: 0, y: 0 }] }; - const ours = countOver(region, makeNauvisRockPlacement(params)); - const rel = Math.abs(ours - game) / game; - - // The bare roll, ungated, against the field's own integral - the same - // unbiased-draw check the Vulcanus cases make, on the Nauvis field. - const { density } = makeRockFields(params); - const roll = makePlacementRoll(PLACEMENT_SALT.nauvisRocks); - const ungated = countOver(region, (x, y) => roll(x, y) < density(x, y)); - const expected = expectedCount(region, density); - const relToField = Math.abs(ungated - expected) / expected; - - console.log( - `nauvis rocks region ${String(index)} [${String(region.x0)},${String(region.y0)}]: ` + - `ours=${String(ours)} game=${String(game)} rel=${rel.toFixed(4)} ` + - `ungated=${String(ungated)} sum(density)=${expected.toFixed(1)} ` + - `relToField=${relToField.toFixed(4)}`, - ); - - expect(rel).toBeLessThan(BAND[index]); - expect(relToField).toBeLessThan(FIELD_BAND[index]); - }, 120000); - } -}); - -/** - * ## Nauvis crude oil (added 2026-07-27, Task 8) - * - * | region | window | ours | game | rel | - * | --- | --- | --- | --- | --- | - * | 0 | `[0,0]-[512,512]` | 7 | 8 | 12.5% | - * | 1 | `[4096,4096]-[4608,4608]` | 0 | 0 | exact | - * - * Oil is the one `placement: "roll"` resource, and the only catalog entry whose - * `random_probability` is below 1: its probability carries a - * `random_penalty{source = 1, amplitude = 48}` factor, modelled with a dedicated - * per-tile stream (`makeNauvisOilProbability` explains why a stand-in reproduces - * the density exactly even though it does not reproduce the game's batch). - * - * **This is the weakest case in the file and the band is honest about it.** n = 8 - * carries a Poisson sigma of 2.83 - 35% - so a single well either way moves `rel` - * by 12.5 points and 7-vs-8 is not evidence of 12.5%-grade accuracy. The band - * below is 0.30, which is *looser* than the measured 0.125 on purpose: pinning - * just above the measurement, the way the rock regions do, would make this test - * fail on noise rather than on a regression. What it actually discriminates is - * the failure it was written for - the old threshold rule drew 1234 tiles here, - * and the un-penalised roll 118, both of which are orders of magnitude outside - * any band. - * - * Region 1 is a zero-vs-zero agreement. That is worth having (the window holds - * 248 tiles of oil footprint and the game puts no wells in it, so gross - * over-placement would show) but it cannot discriminate a factor-of-two error, - * so it is asserted as an exact 0 rather than as a ratio. - */ -describe("crude oil placement density vs the game", () => { - /** Deliberately looser than the measurement - see the block comment. */ - const OIL_BAND = 0.3; - - const nauvisRegions = fixture.regions - .map((r, i) => ({ region: r as FixtureRegion, index: i })) - .filter((e) => e.region.planet === "nauvis"); - - for (const { region, index } of nauvisRegions) { - it(`Nauvis crude oil: placement density agrees with the game (region ${String(index)})`, () => { - const game = gameCount(index, (name) => name === "crude-oil"); - const params = { seed0: fixture.seed, startingPositions: [{ x: 0, y: 0 }] }; - const ours = countOver(region, makeNauvisOilPlacement(params)); - - // The penalty factor costs a factor of 96, not the 48 its name suggests: - // `1 - 48U` is positive only for U < 1/48 and averages 1/2 there. Checked - // against the field sums so the closed form is pinned, not just believed. - const probability = makeNauvisOilProbability(params); - const penalised = expectedCount(region, probability); - - console.log( - `nauvis oil region ${String(index)} [${String(region.x0)},${String(region.y0)}]: ` + - `ours=${String(ours)} game=${String(game)} sum(penalised)=${penalised.toFixed(1)}`, - ); - - if (game === 0) { - expect(ours).toBe(0); - return; - } - expect(Math.abs(ours - game) / game).toBeLessThan(OIL_BAND); - }, 120000); - } -}); diff --git a/test/expressionInRange.spec.ts b/test/expressionInRange.spec.ts deleted file mode 100644 index ae9d196f..00000000 --- a/test/expressionInRange.spec.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import fixture from "./fixtures/oracle-expression-in-range.seed123456.json"; -import { expressionInRange } from "../src/noise/tiles/expressionInRange"; - -// Ground truth: test/fixtures/oracle-expression-in-range.seed123456.json, captured -// via the oracle harness (test/oracle/capture.ts expression-in-range). The native -// builtin's peak/falloff math, RE'd from these sweeps - see -// docs/noise/expression-in-range-NOTES.md for the derivation. -// -// Derived formula: -// m = min over all dims i of min(value_i - from_i, to_i - value_i) -// result = min(peak_maximum, peak_multiplier * m) -// (no lower clamp; peak_maximum may be Infinity.) -// -// The observed worst residual over every sweep is ~9.5e-7 (pure f32 noise), well -// under the 8e-3 elevation f32 floor. -const f32 = Math.fround; - -describe("expressionInRange reproduces the native builtin", () => { - it("matches the bounded 1-D sweep (pm=20, pmax=1) to the f32 floor", () => { - const s = fixture.sweeps.oneD_20_1; - let worst = 0; - for (let i = 0; i < s.positions.length; i++) { - const p = s.positions[i]; - const got = expressionInRange(20, 1, [p.x / 1000], [-0.5], [0.5]); - expect(f32(got), `bounded 1-D @ ${String(p.x)}`).toBe(f32(s.values[i])); - worst = Math.max(worst, Math.abs(got - s.values[i])); - } - expect(worst, "worst bounded 1-D residual").toBe(0); - }); - - it("matches the unbounded 1-D sweep (pm=5, pmax=inf) and does NOT clamp in range", () => { - const s = fixture.sweeps.oneD_5_inf; - let worst = 0; - let maxInRange = -Infinity; - for (let i = 0; i < s.positions.length; i++) { - const p = s.positions[i]; - const expr = p.x / 1000; - const got = expressionInRange(5, Infinity, [expr], [-0.5], [0.5]); - expect(f32(got), `unbounded 1-D @ ${String(p.x)}`).toBe(f32(s.values[i])); - worst = Math.max(worst, Math.abs(got - s.values[i])); - if (expr >= -0.5 && expr <= 0.5) maxInRange = Math.max(maxInRange, got); - } - expect(worst, "worst unbounded 1-D residual").toBe(0); - // The whole point of pmax=inf: in-range values exceed 1 (peak ~2.5 at center). - // A hard clamp to 1 would silently kill sand-1's coastal boost. - expect(maxInRange, "in-range peak must exceed 1 (no clamp)").toBeGreaterThan(1); - }); - - it("matches the 2-D sweep with the min combination rule (pm=20, pmax=1)", () => { - const s = fixture.sweeps.twoD; - let worst = 0; - for (let i = 0; i < s.positions.length; i++) { - const p = s.positions[i]; - const got = expressionInRange(20, 1, [p.x / 1000, p.y / 1000], [-0.5, -0.5], [0.5, 0.5]); - expect(f32(got), `2-D @ (${String(p.x)},${String(p.y)})`).toBe(f32(s.values[i])); - worst = Math.max(worst, Math.abs(got - s.values[i])); - } - expect(worst, "worst 2-D residual").toBe(0); - }); - - /** - * **The f64 form must FAIL the fixture**, or the three exact tests above are - * just recording whatever the implementation happens to do. - * - * This is the same guard shape `fastApprox.spec.ts` uses. It matters more than - * usual here because the exact assertions replaced a `toBeLessThan(8e-3)` - * ceiling that the f64 implementation passed comfortably - the actual worst - * residual was ~9.5e-7, so that ceiling was ~8400x too loose and would have - * accepted almost any regression. - */ - it("rejects the pre-f32 f64 arithmetic", () => { - const eirF64 = ( - pm: number, - pmax: number, - values: number[], - froms: number[], - tos: number[], - ): number => { - let m = Infinity; - for (let i = 0; i < values.length; i++) { - const d = Math.min(values[i] - froms[i], tos[i] - values[i]); - if (d < m) m = d; - } - return Math.min(pmax, pm * m); - }; - const s = fixture.sweeps.oneD_20_1; - let wrong = 0; - for (let i = 0; i < s.positions.length; i++) { - const p = s.positions[i]; - if (f32(eirF64(20, 1, [p.x / 1000], [-0.5], [0.5])) !== f32(s.values[i])) wrong++; - } - expect(wrong, "f64 arithmetic should disagree with the game at many positions").toBeGreaterThan( - 10, - ); - }); - - // Formula-consistency guard for sand-1's real production call: - // expression_in_range(5, inf, elevation, aux, -1.5, 0.5, 1.5, 1) - // Every oracle sweep above uses symmetric ranges [-0.5, 0.5] on both axes; sand-1 - // uses asymmetric, wider-than-1 ranges and pmax=inf. This is NOT an oracle test - // (no fixture covers this shape) - it asserts the implementation against the - // derived formula computed by hand: - // m = min(min(elev - (-1.5), 1.5 - elev), min(aux - 0.5, 1 - aux)) - // result = min(inf, 5 * m) == 5 * m (never clamped) - // - // **Precision here is 6, not 10, and that is deliberate.** These asserted - // `toBeCloseTo(v, 10)` while the implementation worked in f64. It now rounds - // every step to f32 (see the module header), and intermediates like `1 - 1.2` - // are not representable there, so -1.0 comes back as -1.000000238418579. The - // f64 decimal is simply the wrong target for an f32 computation. - // - // The alternative - asserting the exact f32 result - would mean recomputing - // `f32(5 * f32(f32(1) - f32(1.2)))` in the test, i.e. re-implementing the - // function and then checking it against itself. So this stays a FORMULA-SHAPE - // guard: which axis drives the `min`, and that pmax=inf does not clamp. - // Bit-exactness is the three oracle sweeps above, which are now exactly 0. - it("matches the hand-derived formula for sand-1's asymmetric, unbounded 2-D shape", () => { - // Inside both ranges: elev=0 (edge dist 1.5), aux=0.75 (edge dist 0.25). - // m = min(1.5, 0.25) = 0.25 -> result = 5 * 0.25 = 1.25 (exceeds 1, not clamped). - expect(expressionInRange(5, Infinity, [0, 0.75], [-1.5, 0.5], [1.5, 1])).toBeCloseTo(1.25, 6); - - // Outside on the aux axis only: elev=0 (edge dist 1.5, still inside), - // aux=1.2 (edge dist min(0.7, -0.2) = -0.2, outside the high edge). - // m = min(1.5, -0.2) = -0.2 -> result = 5 * -0.2 = -1.0, driven by aux via min. - expect(expressionInRange(5, Infinity, [0, 1.2], [-1.5, 0.5], [1.5, 1])).toBeCloseTo(-1.0, 6); - - // Outside on the elev axis only: elev=2 (edge dist min(3.5, -0.5) = -0.5, - // outside the high edge), aux=0.75 (edge dist 0.25, still inside). - // m = min(-0.5, 0.25) = -0.5 -> result = 5 * -0.5 = -2.5, driven by elev via min. - expect(expressionInRange(5, Infinity, [2, 0.75], [-1.5, 0.5], [1.5, 1])).toBeCloseTo(-2.5, 6); - - // The in-range point must NOT be clamped to 1 - that's the whole point of - // pmax=inf letting sand-1's coastal term win over land tiles topping out near 1. - expect(expressionInRange(5, Infinity, [0, 0.75], [-1.5, 0.5], [1.5, 1])).toBeGreaterThan(1); - }); -}); diff --git a/test/fixImpossibleCellsRetry.spec.ts b/test/fixImpossibleCellsRetry.spec.ts deleted file mode 100644 index 154b7a02..00000000 --- a/test/fixImpossibleCellsRetry.spec.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { fixImpossibleCellsSweep } from "../src/noise/cliffs/cliffPlacement"; -import { isCliffPlaced } from "../src/noise/cliffs/cliffCatalog"; - -const W = 8; -const H = 8; -const vIndex = (cx: number, cy: number): number => cy * (W + 1) + cx; -const hIndex = (cx: number, cy: number): number => cy * W + cx; -const codeOf = (v: Int8Array, h: Int8Array, cx: number, cy: number): number => - ((v[vIndex(cx, cy)] & 3) << 6) | - ((v[vIndex(cx + 1, cy)] & 3) << 4) | - ((h[hIndex(cx, cy)] & 3) << 2) | - (h[hIndex(cx, cy + 1)] & 3); - -/** - * `CellEdgeCliffCrossingArray::fixImpossibleCells`' **retry**, which the port did - * not have until 2026-07-30 (issue #18). - * - * The `bool` parameter was read as a caller-supplied mode, and since - * `crossingsForChunk` passes `false` an earlier note concluded the corner step - * "never runs in this path". It does - the function sets the flag on **itself**: - * - * ``` - * uVar10 = param_2 & 1; param_2 = 1; - * if (uVar10 != 0) { log("Unable to remove excess cliff cell edge crossings"); return; } - * goto ; - * ``` - * - * So on reaching a cell it cannot fix, it restarts the whole pass, this time - * first zeroing the eight outer edges of the chunk's four corner cells; a second - * failure abandons the chunk. - * - * **This spec exists because the integration fixtures barely exercise it.** - * Measured over the committed captures, the retry fires **once in 512 chunks** - * (one chunk of Vulcanus `[1500,1500]`; zero across both Nauvis seeds and the - * other two Vulcanus regions) and changes no placed cell. That is a real - * behaviour and worth having right, but it is nowhere near issue #18's residual - * - do not read this as a fix for it. Without a direct test the branch would be - * effectively dead code. - */ -describe("fixImpossibleCells retry", () => { - /** - * A corner cell whose only crossings are the two chunk-boundary edges it is - * forbidden to clear. `L = +1`, `T = -1` gives code `0x4C`, which is not a - * placing code; `R` and `B` are zero, so the L/T/R/B search finds nothing - * clearable and the first pass is stuck. - */ - const stuckCorner = (): { v: Int8Array; h: Int8Array } => { - const v = new Int8Array((W + 1) * H); - const h = new Int8Array(W * (H + 1)); - v[vIndex(0, 0)] = 1; - h[hIndex(0, 0)] = -1; - return { v, h }; - }; - - it("the constructed cell really is stuck and illegal, or this spec proves nothing", () => { - const { v, h } = stuckCorner(); - const code = codeOf(v, h, 0, 0); - expect(code).toBe(0x4c); - expect(isCliffPlaced(code)).toBe(false); - // Both crossings are on the chunk boundary: L at cx 0, T at cy 0. Neither is - // clearable, and the other two edges are already zero. - expect(v[vIndex(1, 0)]).toBe(0); - expect(h[hIndex(0, 1)]).toBe(0); - }); - - it("restarts and zeroes the corner cell's outer edges, making it legal", () => { - const { v, h } = stuckCorner(); - fixImpossibleCellsSweep(v, h, W, H); - // The retry's corner step is the only thing that can clear these two. - expect(v[vIndex(0, 0)]).toBe(0); - expect(h[hIndex(0, 0)]).toBe(0); - const code = codeOf(v, h, 0, 0); - expect(code).toBe(0); - expect(isCliffPlaced(code) || code === 0).toBe(true); - }); - - it("leaves every cell of the chunk legal", () => { - const { v, h } = stuckCorner(); - fixImpossibleCellsSweep(v, h, W, H); - for (let cy = 0; cy < H; cy++) - for (let cx = 0; cx < W; cx++) { - const code = codeOf(v, h, cx, cy); - expect(code === 0 || isCliffPlaced(code)).toBe(true); - } - }); - - /** - * The corner step must not fire when nothing is stuck - it clears eight edges - * unconditionally, so running it on a healthy chunk would delete real cliffs. - */ - it("does NOT touch the corner edges when the pass completes normally", () => { - const v = new Int8Array((W + 1) * H); - const h = new Int8Array(W * (H + 1)); - // `L = +1`, `T = +1` is code 0x44, which IS a placing code, so cell (0,0) is - // legal as it stands and the sweep has nothing to do anywhere. - v[vIndex(0, 0)] = 1; - h[hIndex(0, 0)] = 1; - expect(isCliffPlaced(codeOf(v, h, 0, 0))).toBe(true); - fixImpossibleCellsSweep(v, h, W, H); - expect(v[vIndex(0, 0)]).toBe(1); - expect(h[hIndex(0, 0)]).toBe(1); - }); - - /** - * Two stuck corners at once. The restart re-sweeps the arrays **as already - * mutated** by the abandoned pass rather than starting from the raw crossings, - * and one retry clears all eight corner edges, so both are fixed in the single - * permitted restart - the second failure would abandon the chunk. - */ - it("fixes two stuck corners in one restart", () => { - const v = new Int8Array((W + 1) * H); - const h = new Int8Array(W * (H + 1)); - v[vIndex(0, 0)] = 1; - h[hIndex(0, 0)] = -1; - v[vIndex(W, H - 1)] = 1; - h[hIndex(W - 1, H)] = -1; - expect(isCliffPlaced(codeOf(v, h, W - 1, H - 1))).toBe(false); - fixImpossibleCellsSweep(v, h, W, H); - for (let cy = 0; cy < H; cy++) - for (let cx = 0; cx < W; cx++) { - const code = codeOf(v, h, cx, cy); - expect(code === 0 || isCliffPlaced(code)).toBe(true); - } - }); -}); diff --git a/test/moisture.spec.ts b/test/moisture.spec.ts deleted file mode 100644 index c7f356c1..00000000 --- a/test/moisture.spec.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import fixture from "./fixtures/oracle-moisture.seed123456.json"; -import { countOffGrid, snapPosition } from "./captureGrid"; -import { makeMoisture } from "../src/noise/expressions/moisture"; - -describe("makeMoisture reproduces the game's moisture (moisture_nauvis) tree", () => { - const evalAt = makeMoisture({ seed0: fixture.seed0 }); - - it("matches the game at every position, scored by exact f32 match count", () => { - // Scored by exact match count, not a bound: every value in this fixture - // satisfies `Math.fround(v) === v`, so a bound cannot tell "close" from - // "identical" (#256). - // - // The sample coordinates are snapped onto the game's 1/256 `MapPosition` - // grid first. This replaces a `< 4e-5` bound and a second on-grid-only - // assertion that together blamed 14 off-grid positions and asked for a - // re-capture. No re-capture was needed - see `test/captureGrid.ts` for the - // evidence, the trunc-vs-floor control and the full 17-fixture table. - // Snapping took this fixture from 15/26 at worst 3.070e-5 to - // 18/26 at worst 5.960e-8. - // - // **The remaining 8 misses are unexplained**, and they are NOT the snap's - // doing: they sit 1, 2 and 4 f32 ulps out, and 3 of them are at positions - // that were already on the grid. Narrowing the incoming coordinates in - // `basisNoise` and `variablePersistenceMultioctaveNoise` (the remaining - // scope of #191) was measured against this and moved the count not at all. - // Tracked in #255. - // - // `Math.fround` on the port's output is the house convention for an exact - // comparison (test/voronoiNoise.spec.ts:85), not slack: the tree evaluates - // in f32 internally but the entry point returns a JS number. - let exact = 0; - let worst = 0; - let worstLabel = ""; - for (const [i, p] of fixture.positions.entries()) { - const s = snapPosition(p); - const err = Math.abs(Math.fround(evalAt(s.x, s.y)) - fixture.moisture[i]); - if (err === 0) exact++; - if (err > worst) { - worst = err; - worstLabel = `@(${p.x},${p.y})`; - } - } - expect(fixture.positions.length).toBe(26); // a regen cannot empty the loop - expect(exact, `worst ${worstLabel}`).toBe(18); - // 2^-24 is one f32 ulp for a value in [0.5, 1). Do not raise it. - expect(worst, `worst ${worstLabel}`).toBeLessThanOrEqual(2 ** -24); - }); - - it("still has off-grid positions for the snap to correct", () => { - // Anti-vacuity for the snap. If a re-capture lands every position on the - // 1/256 grid this reaches 0, and `snapPosition` should then be deleted here - // rather than left looking load-bearing. - expect(countOffGrid(fixture.positions)).toBe(14); - }); -}); - -describe("makeMoisture parameters", () => { - const GRID: Array<[number, number]> = [ - [0.5, 0.25], - [2200.5, 0.25], - [-1600.5, 1200.25], - [12345.75, 6789.125], - ]; - - it("defaults moistureBias to 0 (omitted === explicit 0)", () => { - const def = makeMoisture({ seed0: 123456 }); - const explicit = makeMoisture({ seed0: 123456, moistureBias: 0 }); - for (const [x, y] of GRID) expect(def(x, y)).toBe(explicit(x, y)); - }); - - it("defaults moistureFrequency to 1 (omitted === explicit 1)", () => { - const def = makeMoisture({ seed0: 123456 }); - const explicit = makeMoisture({ seed0: 123456, moistureFrequency: 1 }); - for (const [x, y] of GRID) expect(def(x, y)).toBe(explicit(x, y)); - }); - - it("defaults segmentationMultiplier to 1 (omitted === explicit 1)", () => { - const def = makeMoisture({ seed0: 123456 }); - const explicit = makeMoisture({ seed0: 123456, segmentationMultiplier: 1 }); - for (const [x, y] of GRID) expect(def(x, y)).toBe(explicit(x, y)); - }); - - it("defaults startingAreaMoistureSize to 1 and startingAreaMoistureFrequency to 1 (omitted === explicit)", () => { - const def = makeMoisture({ seed0: 123456 }); - const explicit = makeMoisture({ - seed0: 123456, - startingAreaMoistureSize: 1, - startingAreaMoistureFrequency: 1, - }); - for (const [x, y] of GRID) expect(def(x, y)).toBe(explicit(x, y)); - }); - - it("defaults startingPositions to a single origin spawn (omitted === explicit)", () => { - const def = makeMoisture({ seed0: 123456 }); - const explicit = makeMoisture({ seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }); - for (const [x, y] of GRID) expect(def(x, y)).toBe(explicit(x, y)); - }); - - it("at default startingAreaMoistureSize=1, slider_to_linear degenerates to 0 so bias shifts the result directly (away from the cutout/cap)", () => { - // Pick a point far from the origin spawn so startingBiasRegion ~ 0 and the - // moistureMain isn't already pinned at the 0.45 cap or clamped at an edge. - const def = makeMoisture({ seed0: 123456 }); - const biased = makeMoisture({ seed0: 123456, moistureBias: -0.2 }); - const [x, y] = [12345.75, 6789.125]; - // moistureMain shifts by exactly the bias (both unclamped here); the final - // max/min wrapper only pulls the result down further when moistureMain is - // clamped or when the cutout term bites, so a negative bias shift should - // propagate through unless it hits the [0,1] clamp. - const d = def(x, y); - const b = biased(x, y); - expect(b).toBeLessThanOrEqual(d); - }); - - it("stays within [0, 1] even under an extreme bias", () => { - const evalHigh = makeMoisture({ seed0: 123456, moistureBias: 1000 }); - for (const [x, y] of GRID) { - expect(evalHigh(x, y)).toBeLessThanOrEqual(1); - expect(evalHigh(x, y)).toBeGreaterThanOrEqual(0); - } - const evalLow = makeMoisture({ seed0: 123456, moistureBias: -1000 }); - for (const [x, y] of GRID) { - expect(evalLow(x, y)).toBeGreaterThanOrEqual(0); - } - }); -}); diff --git a/test/multioctaveWrappers.spec.ts b/test/multioctaveWrappers.spec.ts deleted file mode 100644 index 50b6de67..00000000 --- a/test/multioctaveWrappers.spec.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import fixture from "./fixtures/oracle-multioctave-wrappers.seed123456.json"; -import { quickMultioctaveNoisePersistence } from "../src/noise/quickMultioctaveNoise"; -import { amplitudeCorrectedMultioctaveNoise } from "../src/noise/variablePersistenceMultioctaveNoise"; - -interface QuickCase { - octaves: number; - inputScale: number; - outputScale: number; - oism: number; - persistence: number; - seed1: number; - values: number[]; -} -interface AcCase { - octaves: number; - inputScale: number; - offsetX: number; - persistence: number; - amplitude: number; - seed1: number; - values: number[]; -} - -describe("the multioctave Lua wrappers reproduce the game", () => { - // Ground truth: test/fixtures/oracle-multioctave-wrappers.seed123456.json. - // Regenerate with `test/oracle/capture.ts multioctave-wrappers`. - it("quick_multioctave_noise_persistence matches the game bit-for-bit", () => { - let worst = 0; - let exact = 0; - let n = 0; - for (const c of fixture.quick as QuickCase[]) { - for (let i = 0; i < fixture.positions.length; i++) { - const p = fixture.positions[i]; - const got = quickMultioctaveNoisePersistence(p.x, p.y, { - seed0: fixture.seed0, - seed1: c.seed1, - octaves: c.octaves, - inputScale: c.inputScale, - outputScale: c.outputScale, - octaveInputScaleMultiplier: c.oism, - persistence: c.persistence, - }); - n++; - if (got === c.values[i]) exact++; - worst = Math.max(worst, Math.abs(got - c.values[i])); - } - } - // Bit-exact: 152/152, worst 0. The bound here was `< 3e-3` and blamed "the - // f32 coordinate floor at the far fixture points"; there was no such floor. - // Two separate f64 evaluations were, and both are fixed: - // - // | | worst | exact | - // | --- | --- | --- | - // | the op itself in f64 | 1.964e-3 | 38/152 | - // | op fixed, transform still f64 | 1.964e-3 | 114/152 | - // | **both in f32** | **0** | **152/152** | - // - // The second row is the interesting one. This wrapper is a `noise-function` - // whose body is an expression STRING, so the game's noise machine folds it - // in f32 - "Lua wrapper" does not mean "Lua doubles". See - // quick-multioctave-noise-NOTES.md. - expect(n).toBe(152); - expect(worst).toBe(0); - expect(exact).toBe(152); - }); - - it("amplitude_corrected_multioctave_noise matches the game", () => { - let worst = 0; - for (const c of fixture.amplitudeCorrected as AcCase[]) { - for (let i = 0; i < fixture.positions.length; i++) { - const p = fixture.positions[i]; - const got = amplitudeCorrectedMultioctaveNoise(p.x, p.y, { - seed0: fixture.seed0, - seed1: c.seed1, - octaves: c.octaves, - inputScale: c.inputScale, - offsetX: c.offsetX, - persistence: c.persistence, - amplitude: c.amplitude, - }); - worst = Math.max(worst, Math.abs(got - c.values[i])); - } - } - // Measured 1.7881e-7, against a bound that was `< 5e-3` - roughly 28,000x - // slack, inherited from the era when the ops underneath were f64. Tightened - // to the measurement. - // - // **This one is NOT bit-exact and is not yet explained: 81/152.** Its sibling - // above reached 152/152 by running the wrapper's transform in the noise - // machine's f32, and the same treatment here does NOT fix it - f32 per-op - // with the game's integral `^` scores 84/152 with worst 3.576e-7, no better - // than the f64 form it would replace. So the shipped f64 transform stays - // until there is evidence for a different one, and this bound records what - // ships. Open question, deliberately not closed by guesswork. - expect(worst).toBeLessThan(2.5e-7); - }); -}); diff --git a/test/nauvisShared.spec.ts b/test/nauvisShared.spec.ts deleted file mode 100644 index 233e61a8..00000000 --- a/test/nauvisShared.spec.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { - makeNauvisShared, - NAUVIS_OFFSET_X_SEED1, - NAUVIS_OFFSET_Y_SEED1, -} from "../src/noise/expressions/nauvisShared"; -import { makeMultioctaveNoise } from "../src/noise/multioctaveNoise"; -import { basisNoise, basisNoiseTablesFromSeed } from "../src/noise/basisNoise"; -import offFixture from "./fixtures/oracle-cliff-offset-raw.seed123456.json"; - -/** Combined abs/rel tolerance (never loosen): max(1.0, 1e-2 * |game|). */ -const ok = (p: number, g: number): boolean => Math.abs(p - g) < Math.max(1.0, 1e-2 * Math.abs(g)); -/** Worst absolute error across a predicate-checked field, for reporting. */ -const worstAbs = (predVals: number[], gameVals: number[]): number => - predVals.reduce((m, p, i) => Math.max(m, Math.abs(p - gameVals[i])), 0); - -const SEED0 = 123456; -const POINTS: ReadonlyArray = [ - [0, 0], - [37, -14], - [-512, 256], - [1000, 1000], -]; - -describe("makeNauvisShared", () => { - it("computes nauvisSeg = 1.5 * segmentationMultiplier", () => { - const nz1 = makeNauvisShared({ seed0: SEED0, segmentationMultiplier: 1 }); - expect(nz1.nauvisSeg).toBe(1.5); - - const nz2 = makeNauvisShared({ seed0: SEED0, segmentationMultiplier: 2 }); - expect(nz2.nauvisSeg).toBe(3); - }); - - it("hills matches the raw multioctave noise (seed1 900, inputScale nauvisSeg/90)", () => { - const nz = makeNauvisShared({ seed0: SEED0, segmentationMultiplier: 1 }); - const rawHills = makeMultioctaveNoise({ - seed0: SEED0, - seed1: 900, - octaves: 4, - persistence: 0.5, - inputScale: nz.nauvisSeg / 90, - outputScale: 1, - }); - for (const [x, y] of POINTS) { - expect(nz.hills(x, y)).toBe(Math.abs(rawHills(x, y))); - } - }); - - it("bridgeBillows matches the raw multioctave noise (seed1 700, inputScale nauvisSeg/150)", () => { - const nz = makeNauvisShared({ seed0: SEED0, segmentationMultiplier: 1 }); - const rawBridgeBillows = makeMultioctaveNoise({ - seed0: SEED0, - seed1: 700, - octaves: 4, - persistence: 0.5, - inputScale: nz.nauvisSeg / 150, - outputScale: 1, - }); - for (const [x, y] of POINTS) { - expect(nz.bridgeBillows(x, y)).toBe(Math.abs(rawBridgeBillows(x, y))); - } - }); - - it("forestPathBillows matches the raw multioctave noise (seed1 1800, inputScale nauvisSeg/100, no offsetX)", () => { - const nz = makeNauvisShared({ seed0: SEED0, segmentationMultiplier: 1 }); - const rawForestPathBillows = makeMultioctaveNoise({ - seed0: SEED0, - seed1: 1800, - octaves: 4, - persistence: 0.5, - inputScale: nz.nauvisSeg / 100, - outputScale: 1, - }); - for (const [x, y] of POINTS) { - expect(nz.forestPathBillows(x, y)).toBe(Math.abs(rawForestPathBillows(x, y))); - expect(nz.forestPathBillows(x, y)).toBeGreaterThanOrEqual(0); - } - }); - - it("cliffLevel is clamped to [0.15, 1.15]", () => { - const nz = makeNauvisShared({ seed0: SEED0, segmentationMultiplier: 1 }); - for (const [x, y] of POINTS) { - const v = nz.cliffLevel(x, y); - expect(v).toBeGreaterThanOrEqual(0.15); - expect(v).toBeLessThanOrEqual(1.15); - } - }); - - it("plateaus is within [0, 1] and matches (hills - cliffLevel) * 10 clamped +0.5", () => { - const nz = makeNauvisShared({ seed0: SEED0, segmentationMultiplier: 1 }); - for (const [x, y] of POINTS) { - const v = nz.plateaus(x, y); - expect(v).toBeGreaterThanOrEqual(0); - expect(v).toBeLessThanOrEqual(1); - const expected = - 0.5 + Math.min(0.5, Math.max(-0.5, (nz.hills(x, y) - nz.cliffLevel(x, y)) * 10)); - expect(v).toBe(expected); - } - }); -}); - -describe("nauvis cliff offset chain vs oracle", () => { - it("the string-seed constants resolve to crc32(name)", () => { - expect(NAUVIS_OFFSET_X_SEED1).toBe(593691028); - expect(NAUVIS_OFFSET_Y_SEED1).toBe(1415852290); - }); - - for (const c of offFixture.cases) { - it(`raw_x/raw_y string-seed reproduce seed=${c.seed}`, () => { - const is = 1.5 / 500; // nauvisSeg / 500, default seg 1 - const tx = basisNoiseTablesFromSeed(c.seed, NAUVIS_OFFSET_X_SEED1); - const ty = basisNoiseTablesFromSeed(c.seed, NAUVIS_OFFSET_Y_SEED1); - const predX = offFixture.positions.map((p) => basisNoise(p.x * is, p.y * is, tx)); - const predY = offFixture.positions.map((p) => basisNoise(p.x * is, p.y * is, ty)); - for (let i = 0; i < offFixture.positions.length; i++) { - expect(ok(predX[i], c.rawX[i])).toBe(true); - expect(ok(predY[i], c.rawY[i])).toBe(true); - } - console.log( - ` raw_x/raw_y seed=${c.seed}: worstAbs rawX=${worstAbs(predX, c.rawX).toExponential(2)}, rawY=${worstAbs(predY, c.rawY).toExponential(2)}`, - ); - }); - - it(`hillsOffset seed=${c.seed}`, () => { - const nz = makeNauvisShared({ seed0: c.seed }); - const pred = offFixture.positions.map((p) => nz.hillsOffset(p.x, p.y)); - for (let i = 0; i < offFixture.positions.length; i++) { - expect(ok(pred[i], c.hillsOffset[i])).toBe(true); - } - console.log( - ` hillsOffset seed=${c.seed}: worstAbs=${worstAbs(pred, c.hillsOffset).toExponential(2)}`, - ); - }); - - it(`cliffRingbreak seed=${c.seed}`, () => { - const nz = makeNauvisShared({ seed0: c.seed }); - const pred = offFixture.positions.map((p) => nz.cliffRingbreak(p.x, p.y)); - for (let i = 0; i < offFixture.positions.length; i++) { - expect(ok(pred[i], c.ringbreak[i])).toBe(true); - } - console.log( - ` cliffRingbreak seed=${c.seed}: worstAbs=${worstAbs(pred, c.ringbreak).toExponential(2)}`, - ); - }); - } -}); diff --git a/test/noise/startingLakes.spec.ts b/test/noise/startingLakes.spec.ts index 65d88932..46176a93 100644 --- a/test/noise/startingLakes.spec.ts +++ b/test/noise/startingLakes.spec.ts @@ -1,36 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import fixture from "../fixtures/oracle-elevation-lakes.seed123456.json"; -import { startingLakePositions } from "../../src/noise/startingLakes"; -import { distanceFromNearestPoint } from "../../src/noise/distanceFromNearestPoint"; -import { snapPosition } from "../captureGrid"; describe("startingLakePositions (RE of MapGenSettings::getStartingLakePositions)", () => { - it("computes the game's real starting lake for seed 123456", () => { - // Trilaterated exactly from the fixture's 9 near-spawn startingLakeDistance values. - expect(startingLakePositions(123456, [{ x: 0, y: 0 }])).toEqual([{ x: 45, y: -59 }]); - }); - - it("reproduces every startingLakeDistance in the fixture exactly", () => { - // This was a `toBeLessThan(2e-5)` bound until 2026-08-18, explained as - // "f64-vs-f32 rounding is the floor here". That explanation was wrong: the - // floor was `distanceFromNearestPoint` returning a raw f64 when the game's - // op stores an f32 (#220). With that corrected there is no floor, so this - // is an exact count - a bound cannot tell "close" from "identical", and - // 17 of these 26 rows are pinned at the 1024 cap where any bound passes. - const lakes = startingLakePositions(fixture.seed0, [{ x: 0, y: 0 }]); - let exact = 0; - let worst = 0; - for (let i = 0; i < fixture.positions.length; i++) { - const p = snapPosition(fixture.positions[i]); - const d = distanceFromNearestPoint(p.x, p.y, lakes, 1024); - if (d === fixture.startingLakeDistance[i]) exact++; - worst = Math.max(worst, Math.abs(d - fixture.startingLakeDistance[i])); - } - expect(fixture.positions.length).toBe(26); // a regen cannot empty the loop - expect(exact).toBe(26); - expect(worst).toBe(0); - }); - it("has only 9 rows that discriminate anything - the other 17 sit at the cap", () => { // Worth pinning because it bounds what the test above can prove: a lake // placed anywhere far enough away reproduces a saturated row. @@ -38,24 +9,4 @@ describe("startingLakePositions (RE of MapGenSettings::getStartingLakePositions) expect(saturated).toBe(17); expect(fixture.startingLakeDistance.length - saturated).toBe(9); }); - - it("places each lake at radius 75 from its spawn (pre-truncation invariant)", () => { - const [lake] = startingLakePositions(999, [{ x: 0, y: 0 }]); - expect(Math.hypot(lake.x, lake.y)).toBeGreaterThan(73); - expect(Math.hypot(lake.x, lake.y)).toBeLessThanOrEqual(75); - }); - - it("returns one lake per starting position, in order", () => { - const lakes = startingLakePositions(123456, [ - { x: 0, y: 0 }, - { x: 1000, y: 0 }, - ]); - expect(lakes).toHaveLength(2); - // second lake is near its own spawn (within radius 75) - expect(Math.hypot(lakes[1].x - 1000, lakes[1].y)).toBeLessThanOrEqual(75); - }); - - it("returns empty for empty starting positions", () => { - expect(startingLakePositions(123456, [])).toEqual([]); - }); }); diff --git a/test/oracle/capture.ts b/test/oracle/capture.ts deleted file mode 100644 index dcb9b4d0..00000000 --- a/test/oracle/capture.ts +++ /dev/null @@ -1,7334 +0,0 @@ -/** - * Capture committed oracle fixtures. Run deliberately (not in CI) when a fixture - * needs (re)generating; it needs a local Factorio 2.1 install. - * - * node --experimental-strip-types test/oracle/capture.ts - * - * It writes JSON ground-truth into test/fixtures/, which the CI-safe specs then - * validate against pure TS - so the reverse-engineered primitives are checked - * without anyone needing the game. - */ - -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; - -// The .ts extension is required because this file is executed directly by Node -// (`--experimental-strip-types`), which does no extension resolution; the specs, -// run through Vite, import extensionless. allowImportingTsExtensions permits both. -import { - type DumpedCliffSettings, - oracleAvailable, - type Position, - type Region, - sampleCliffEntities, - sampleCliffEntitiesFull, - sampleExpression, - sampleTileNames, - sampleTileNamesFull, - type TileSample, - buildVoronoiExpression, -} from "./oracle.ts"; -import { TREE_SPECIES } from "../../src/noise/trees/treeCatalog.ts"; -// Only `cliffCatalog.ts` is imported from the cliff port here, and that is a -// constraint rather than a preference: this file is executed by bare Node -// (`--experimental-strip-types`), which does no extension resolution, and -// `cliffConnections.ts` imports its own siblings extensionless. `cliffCatalog` -// has no imports at all, so it is the only one that loads. -// -// The connection predicates are therefore RE-DERIVED inside the destroy-probe -// capture rather than imported. That duplication is made safe by -// `test/cliffDestroyProbe.spec.ts`, which imports the REAL `onChunkBorder` / -// `isCliffConnected` / `connectedSides` and asserts every committed target -// satisfies them - so a drift between the two fails a test rather than -// silently selecting the wrong cliffs. -import { - CLIFF_CELL_CENTER_X, - CLIFF_CELL_CENTER_Y, - CLIFF_GRID_SIZE, - CLIFF_ORIENTATION_NAMES, -} from "../../src/noise/cliffs/cliffCatalog.ts"; - -const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "..", "fixtures"); - -/** A modest scattered grid: fractional coords, negatives, and far-from-origin points. */ -function gridPositions(): Position[] { - const out: Position[] = []; - for (let gy = 0; gy < 6; gy++) { - for (let gx = 0; gx < 6; gx++) { - out.push({ x: gx * 13 - 30 + 0.5, y: gy * 17 - 40 + 0.25 }); - } - } - out.push({ x: 1000.5, y: -2000.5 }, { x: 12345.75, y: 6789.125 }); - return out; -} - -async function captureBasis(): Promise { - const seed = 123456; - const inputScale = 0.125; - const seed1 = 0; - const positions = gridPositions(); - const expression = `basis_noise{x = x, y = y, seed0 = map_seed, seed1 = ${seed1}, input_scale = ${inputScale}, output_scale = 1}`; - - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const values = await sampleExpression(expression, positions, { workDir, seed }); - const fixture = { - _comment: - "Ground truth from Factorio 2.1.11 via the test/oracle harness. basis_noise routed onto elevation. Regenerate: node --experimental-strip-types test/oracle/capture.ts", - expression, - seed0: seed, - seed1, - inputScale, - points: positions.map((p, i) => ({ x: p.x, y: p.y, v: values[i] })), - }; - const out = join(FIXTURES, "oracle-basis.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${positions.length} points)`); - } finally { - await rm(workDir, { recursive: true, force: true }); - } -} - -/** multioctave_noise ground truth across octaves / persistence / scales / seeds. */ -async function captureMultioctave(): Promise { - const seed = 123456; - const positions = gridPositions(); - // Vary every lever, including non-power-of-2 persistence (exercises the - // fastapprox log2/exp2 in the RMS normalization) and multiple seed1s. - const configs = [ - { octaves: 1, persistence: 0.5, inputScale: 0.125, outputScale: 1, seed1: 137 }, - { octaves: 2, persistence: 0.5, inputScale: 0.125, outputScale: 1, seed1: 137 }, - { octaves: 3, persistence: 0.5, inputScale: 0.125, outputScale: 2, seed1: 137 }, - { octaves: 4, persistence: 0.9, inputScale: 0.15, outputScale: 1, seed1: 137 }, - { octaves: 5, persistence: 0.7, inputScale: 0.08, outputScale: 3, seed1: 42 }, - { octaves: 6, persistence: 0.65, inputScale: 0.2, outputScale: 1, seed1: 5 }, - { octaves: 4, persistence: 0.45, inputScale: 0.05, outputScale: 1, seed1: 999 }, - ]; - const cases = []; - for (const c of configs) { - const expression = `multioctave_noise{x = x, y = y, seed0 = map_seed, seed1 = ${c.seed1}, octaves = ${c.octaves}, persistence = ${c.persistence}, input_scale = ${c.inputScale}, output_scale = ${c.outputScale}}`; - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const values = await sampleExpression(expression, positions, { workDir, seed }); - cases.push({ ...c, values }); - console.log(` captured octaves=${c.octaves} p=${c.persistence} seed1=${c.seed1}`); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - const fixture = { - _comment: - "Ground truth from Factorio 2.1.11 via the test/oracle harness. multioctave_noise routed onto elevation. Regenerate: node --experimental-strip-types test/oracle/capture.ts", - seed0: seed, - positions, - cases, - }; - const out = join(FIXTURES, "oracle-multioctave.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${configs.length} configs x ${positions.length} points)`); -} - -/** quick_multioctave_noise ground truth across octaves / multipliers / offset / seeds. */ -async function captureQuickMultioctave(): Promise { - const seed = 123456; - const positions = gridPositions(); - // Vary octaves, the two per-octave multipliers, offset_x (including the large - // climate-tree values that exercise the f32 floor), input/output scale and seed1. - const configs = [ - // octaves=1 pins the base case; offset_x=0 isolates the raw octave. - { octaves: 1, inputScale: 0.125, outputScale: 1, oosm: 0.6, oism: 0.5, offsetX: 0, seed1: 137 }, - // octave pairing (2*floor(k/2) reseed) first shows up at 3 octaves. - { octaves: 3, inputScale: 0.125, outputScale: 1, oosm: 0.6, oism: 0.5, offsetX: 0, seed1: 137 }, - // large offset_x (climate-tree scale) -> f32 floor. - { - octaves: 4, - inputScale: 1 / 6, - outputScale: 2 / 3, - oosm: 0.7, - oism: 0.5, - offsetX: 40000, - seed1: 42, - }, - { - octaves: 5, - inputScale: 0.1, - outputScale: 1, - oosm: 0.65, - oism: 0.55, - offsetX: 12000, - seed1: 5, - }, - { - octaves: 6, - inputScale: 0.08, - outputScale: 1.5, - oosm: 0.5, - oism: 0.5, - offsetX: 0, - seed1: 999, - }, - ]; - const cases = []; - for (const c of configs) { - const expression = `quick_multioctave_noise{x = x, y = y, seed0 = map_seed, seed1 = ${c.seed1}, input_scale = ${c.inputScale}, output_scale = ${c.outputScale}, octaves = ${c.octaves}, octave_output_scale_multiplier = ${c.oosm}, octave_input_scale_multiplier = ${c.oism}, offset_x = ${c.offsetX}}`; - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const values = await sampleExpression(expression, positions, { workDir, seed }); - cases.push({ ...c, values }); - console.log( - ` captured octaves=${c.octaves} oism=${c.oism} oosm=${c.oosm} offset=${c.offsetX}`, - ); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - const fixture = { - _comment: - "Ground truth from Factorio 2.1.11 via the test/oracle harness. quick_multioctave_noise routed onto elevation. Regenerate: node --experimental-strip-types test/oracle/capture.ts", - seed0: seed, - positions, - cases, - }; - const out = join(FIXTURES, "oracle-quick-multioctave.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${configs.length} configs x ${positions.length} points)`); -} - -/** - * variable_persistence_multioctave_noise ground truth. This op's `persistence` is a - * spatially-varying noise EXPRESSION (not a scalar), so the fixture also captures - * the persistence field itself (route the same expression onto elevation) - the - * CI-safe spec feeds per-tile p back into the model. A constant persistence would - * hit a degenerate compile path, so the expression must genuinely vary. - */ -async function captureVariablePersistenceMultioctave(): Promise { - const seed = 123456; - const positions = gridPositions(); - // A gentle spatially-varying persistence in (0.1, 0.6). seed1=91 keeps it - // distinct from the op's own seed1s. - const persistenceExpr = - "0.35 + 0.25 * basis_noise{x = x, y = y, seed0 = map_seed, seed1 = 91, input_scale = 0.02, output_scale = 1}"; - - // Capture the persistence field once. - let persistenceField: number[]; - { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - persistenceField = await sampleExpression(persistenceExpr, positions, { workDir, seed }); - console.log(" captured persistence field"); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - - // Vary octaves, input/output scale, offset_x (incl. a large climate-scale value - // that exercises the f32 floor), and seed1 (incl. >= 256 to confirm the shared - // seed - no per-octave reseed like the quick op). - const configs = [ - { octaves: 1, inputScale: 1 / 16, outputScale: 1, offsetX: 0, seed1: 7 }, - { octaves: 2, inputScale: 1 / 16, outputScale: 1, offsetX: 0, seed1: 7 }, - { octaves: 3, inputScale: 1 / 8, outputScale: 2, offsetX: 0, seed1: 7 }, - { octaves: 4, inputScale: 1 / 32, outputScale: 1, offsetX: 5000, seed1: 42 }, - { octaves: 5, inputScale: 0.1, outputScale: 1.5, offsetX: 40000, seed1: 5 }, - { octaves: 6, inputScale: 0.08, outputScale: 1, offsetX: 0, seed1: 999 }, - { octaves: 3, inputScale: 1 / 16, outputScale: 1, offsetX: -3, seed1: 256 }, - ]; - const cases = []; - for (const c of configs) { - const expression = `variable_persistence_multioctave_noise{x = x, y = y, seed0 = map_seed, seed1 = ${c.seed1}, input_scale = ${c.inputScale}, output_scale = ${c.outputScale}, offset_x = ${c.offsetX}, octaves = ${c.octaves}, persistence = ${persistenceExpr}}`; - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const values = await sampleExpression(expression, positions, { workDir, seed }); - cases.push({ ...c, values }); - console.log(` captured octaves=${c.octaves} offset_x=${c.offsetX} seed1=${c.seed1}`); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - const fixture = { - _comment: - "Ground truth from Factorio 2.1.11 via the test/oracle harness. variable_persistence_multioctave_noise routed onto elevation. persistenceField is the per-tile value of persistenceExpr (also routed onto elevation), fed back into the model by the spec. Regenerate: node --experimental-strip-types test/oracle/capture.ts", - seed0: seed, - positions, - persistenceExpr, - persistenceField, - cases, - }; - const out = join(FIXTURES, "oracle-variable-persistence-multioctave.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${configs.length} configs x ${positions.length} points)`); -} - -/** - * The two multioctave Lua wrappers (`core/prototypes/noise-functions.lua`): - * `quick_multioctave_noise_persistence` (over the quick op) and - * `amplitude_corrected_multioctave_noise` (over the variable-persistence op). Both - * are pure parameter re-mappings - no new RE - captured here as ground truth for - * the CI-safe port tests. One fixture, two blocks of configs. - */ -async function captureMultioctaveWrappers(): Promise { - const seed = 123456; - const positions = gridPositions(); - - const quickCfgs = [ - { octaves: 1, inputScale: 1 / 8, outputScale: 1, oism: 0.5, persistence: 0.7, seed1: 14 }, - { octaves: 4, inputScale: 1 / 8, outputScale: 0.8, oism: 0.5, persistence: 0.68, seed1: 14 }, - { octaves: 5, inputScale: 1 / 8, outputScale: 1, oism: 0.5, persistence: 0.75, seed1: 14 }, - { octaves: 3, inputScale: 0.2, outputScale: 2, oism: 0.6, persistence: 0.5, seed1: 42 }, - ]; - const quick = []; - for (const c of quickCfgs) { - const expression = `quick_multioctave_noise_persistence{x = x, y = y, seed0 = map_seed, seed1 = ${c.seed1}, input_scale = ${c.inputScale}, output_scale = ${c.outputScale}, octaves = ${c.octaves}, octave_input_scale_multiplier = ${c.oism}, persistence = ${c.persistence}}`; - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - quick.push({ - ...c, - values: await sampleExpression(expression, positions, { workDir, seed }), - }); - console.log(` quick_persistence octaves=${c.octaves} seed1=${c.seed1}`); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - - const acCfgs = [ - { octaves: 2, inputScale: 1 / 8, offsetX: 1000, persistence: 0.7, amplitude: 0.5, seed1: 1 }, - { octaves: 4, inputScale: 1 / 8, offsetX: 1000, persistence: 0.7, amplitude: 0.5, seed1: 1 }, - { octaves: 6, inputScale: 1 / 16, offsetX: 0, persistence: 0.6, amplitude: 1, seed1: 3 }, - { octaves: 3, inputScale: 0.1, offsetX: 5000, persistence: 0.85, amplitude: 2, seed1: 42 }, - ]; - const amplitudeCorrected = []; - for (const c of acCfgs) { - const expression = `amplitude_corrected_multioctave_noise{x = x, y = y, seed0 = map_seed, seed1 = ${c.seed1}, octaves = ${c.octaves}, input_scale = ${c.inputScale}, offset_x = ${c.offsetX}, persistence = ${c.persistence}, amplitude = ${c.amplitude}}`; - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - amplitudeCorrected.push({ - ...c, - values: await sampleExpression(expression, positions, { workDir, seed }), - }); - console.log(` amplitude_corrected octaves=${c.octaves} seed1=${c.seed1}`); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.11 via the test/oracle harness. The two multioctave Lua wrappers routed onto elevation. Regenerate: node --experimental-strip-types test/oracle/capture.ts multioctave-wrappers", - seed0: seed, - positions, - quick, - amplitudeCorrected, - }; - const out = join(FIXTURES, "oracle-multioctave-wrappers.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out}`); -} - -/** - * The full `elevation_lakes` tree - the first NAMED TREE sampled through the - * harness. Also captures the two free-var distances (`distance` over - * starting_positions, and starting_lake_distance capped at 1024) so the CI spec - * can both drive the EvalCtx assumption and validate distanceFromNearestPoint - * end-to-end. The grid spans a near-origin band AND far (>1200 tile) points so - * both `distance` regimes (hypot-driven near spawn, branch2-collapsed far out) - * are exercised. - */ -async function captureElevationLakes(): Promise { - const seed = 123456; - const positions: Position[] = []; - // Near-origin band: the game places real starting lakes here (starting_lake_distance - // < 1024), so the far-from-spawn empty-lake ctx does NOT reproduce these. Kept to - // document the fidelity limit and to confirm distance == hypot near spawn; the CI - // parity test filters these OUT (it asserts only where sld == 1024). - for (let gy = 0; gy < 3; gy++) { - for (let gx = 0; gx < 3; gx++) { - positions.push({ x: gx * 11 - 11 + 0.5, y: gy * 13 - 13 + 0.25 }); - } - } - // Far rings: large enough radius that starting_lake_distance saturates at 1024 - // (empty-lake ctx is then exact), in many directions. Two radii + fractional - // offsets keep points off the INTEGER lattice, which is the point - a formula - // that only fits at integer coordinates should not survive. - // - // They are snapped onto the 1/256 MapPosition grid, which is NOT the same thing - // and was missing until 2026-08-18. `Math.cos` output is not a multiple of - // 1/256, so the game converted it on the way in and evaluated somewhere the - // fixture did not record (#186). Seventeen committed fixtures carry ring - // positions captured that way; `test/captureGrid.ts` recovers them at read - // time and holds the measurements. New captures do not need recovering. - for (const r of [2200, 3300]) { - for (let k = 0; k < 8; k++) { - const a = (k * Math.PI) / 4; - positions.push({ - x: snapToMapPosition(r * Math.cos(a) + 0.5), - y: snapToMapPosition(r * Math.sin(a) + 0.25), - }); - } - } - // One deep-field point (stresses the f32 coordinate floor hardest). - positions.push({ x: 12345.75, y: 6789.125 }); - - const sample = async (expression: string): Promise => { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - return await sampleExpression(expression, positions, { workDir, seed }); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - }; - - const elevation = await sample("elevation_lakes"); - console.log(" captured elevation_lakes tree"); - const distance = await sample( - "distance_from_nearest_point{x = x, y = y, points = starting_positions}", - ); - console.log(" captured distance (starting_positions)"); - const startingLakeDistance = await sample( - "distance_from_nearest_point{x = x, y = y, points = starting_lake_positions, maximum_distance = 1024}", - ); - console.log(" captured starting_lake_distance"); - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.11 via the test/oracle harness. elevation_lakes (and the two free-var distances) routed onto elevation. Regenerate: node --experimental-strip-types test/oracle/capture.ts elevation-lakes", - seed0: seed, - positions, - elevation, - distance, - startingLakeDistance, - }; - const out = join(FIXTURES, "oracle-elevation-lakes.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${positions.length} points)`); -} - -/** - * The full `elevation_nauvis` tree (the default Nauvis elevation) routed onto - * `elevation`, plus the two free-var distances the CI spec needs. Same grid as - * captureElevationLakes: a near-origin band (where the game places real starting - * lakes, so starting_lake_distance < 1024) and far rings (>1200 tiles, where it - * saturates at 1024 and the empty-lake ctx is exact), plus one deep-field point. - */ -async function captureElevationNauvis(): Promise { - const seed = 123456; - const positions: Position[] = []; - for (let gy = 0; gy < 3; gy++) { - for (let gx = 0; gx < 3; gx++) { - positions.push({ x: gx * 11 - 11 + 0.5, y: gy * 13 - 13 + 0.25 }); - } - } - for (const r of [2200, 3300]) { - for (let k = 0; k < 8; k++) { - const a = (k * Math.PI) / 4; - positions.push({ - x: snapToMapPosition(r * Math.cos(a) + 0.5), - y: snapToMapPosition(r * Math.sin(a) + 0.25), - }); - } - } - positions.push({ x: 12345.75, y: 6789.125 }); - - const sample = async (expression: string): Promise => { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - return await sampleExpression(expression, positions, { workDir, seed }); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - }; - - const elevation = await sample("elevation_nauvis"); - console.log(" captured elevation_nauvis tree"); - const distance = await sample( - "distance_from_nearest_point{x = x, y = y, points = starting_positions}", - ); - console.log(" captured distance (starting_positions)"); - const startingLakeDistance = await sample( - "distance_from_nearest_point{x = x, y = y, points = starting_lake_positions, maximum_distance = 1024}", - ); - console.log(" captured starting_lake_distance"); - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.11 via the test/oracle harness. elevation_nauvis (and the two free-var distances) routed onto elevation. Regenerate: node --experimental-strip-types test/oracle/capture.ts elevation-nauvis", - seed0: seed, - positions, - elevation, - distance, - startingLakeDistance, - }; - const out = join(FIXTURES, "oracle-elevation-nauvis.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${positions.length} points)`); -} - -/** - * The `elevation_nauvis_no_cliff` tree (= `elevation_nauvis_function(added_cliff_elevation - * = 0)`, the cliffiness field's dependency - see Task 6/`cliff_elevation_nauvis`) routed - * onto `elevation`, plus the two free-var distances the CI spec needs. Same standard grid - * as `captureElevationNauvis` (near-origin band where the game places real starting lakes, - * far rings at r=2200/3300 where starting_lake_distance saturates at 1024, one deep-field - * point), captured at two seeds so the seam is validated beyond the single default seed. - */ -async function captureElevationNauvisNoCliff(): Promise { - const positions: Position[] = []; - for (let gy = 0; gy < 3; gy++) { - for (let gx = 0; gx < 3; gx++) { - positions.push({ x: gx * 11 - 11 + 0.5, y: gy * 13 - 13 + 0.25 }); - } - } - for (const r of [2200, 3300]) { - for (let k = 0; k < 8; k++) { - const a = (k * Math.PI) / 4; - positions.push({ - x: snapToMapPosition(r * Math.cos(a) + 0.5), - y: snapToMapPosition(r * Math.sin(a) + 0.25), - }); - } - } - positions.push({ x: 12345.75, y: 6789.125 }); - - const seeds = [123456, 777771]; - const cases: { - seed: number; - elevation: number[]; - distance: number[]; - startingLakeDistance: number[]; - }[] = []; - for (const seed of seeds) { - const sample = async (expression: string): Promise => { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - return await sampleExpression(expression, positions, { workDir, seed }); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - }; - - const elevation = await sample("elevation_nauvis_no_cliff"); - console.log(` captured elevation_nauvis_no_cliff tree seed=${seed}`); - const distance = await sample( - "distance_from_nearest_point{x = x, y = y, points = starting_positions}", - ); - console.log(` captured distance (starting_positions) seed=${seed}`); - const startingLakeDistance = await sample( - "distance_from_nearest_point{x = x, y = y, points = starting_lake_positions, maximum_distance = 1024}", - ); - console.log(` captured starting_lake_distance seed=${seed}`); - cases.push({ seed, elevation, distance, startingLakeDistance }); - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.11 via the test/oracle harness. elevation_nauvis_no_cliff (= elevation_nauvis_function(added_cliff_elevation = 0), the cliffiness field's dependency) and the two free-var distances, routed onto elevation. Regenerate: node --experimental-strip-types test/oracle/capture.ts elevation-nauvis-no-cliff", - positions, - cases, - }; - const out = join(FIXTURES, "oracle-elevation-nauvis-no-cliff.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${positions.length} points, ${cases.length} seeds)`); -} - -/** - * The full `elevation_island` tree routed onto `elevation`, plus the two free-var - * distances. Same grid as captureElevationLakes/Nauvis (near-origin band, far rings - * at r=2200/3300, one deep-field point). elevation_island = elevation_lakes with - * bias=-1000 and segmentation_multiplier/4. - */ -async function captureElevationIsland(): Promise { - const seed = 123456; - const positions: Position[] = []; - for (let gy = 0; gy < 3; gy++) { - for (let gx = 0; gx < 3; gx++) { - positions.push({ x: gx * 11 - 11 + 0.5, y: gy * 13 - 13 + 0.25 }); - } - } - for (const r of [2200, 3300]) { - for (let k = 0; k < 8; k++) { - const a = (k * Math.PI) / 4; - positions.push({ - x: snapToMapPosition(r * Math.cos(a) + 0.5), - y: snapToMapPosition(r * Math.sin(a) + 0.25), - }); - } - } - positions.push({ x: 12345.75, y: 6789.125 }); - - const sample = async (expression: string): Promise => { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - return await sampleExpression(expression, positions, { workDir, seed }); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - }; - - const elevation = await sample("elevation_island"); - console.log(" captured elevation_island tree"); - const distance = await sample( - "distance_from_nearest_point{x = x, y = y, points = starting_positions}", - ); - console.log(" captured distance (starting_positions)"); - const startingLakeDistance = await sample( - "distance_from_nearest_point{x = x, y = y, points = starting_lake_positions, maximum_distance = 1024}", - ); - console.log(" captured starting_lake_distance"); - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.11 via the test/oracle harness. elevation_island (and the two free-var distances) routed onto elevation. Regenerate: node --experimental-strip-types test/oracle/capture.ts elevation-island", - seed0: seed, - positions, - elevation, - distance, - startingLakeDistance, - }; - const out = join(FIXTURES, "oracle-elevation-island.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${positions.length} points)`); -} - -/** - * The `temperature` (= `temperature_basic`) climate expression: `clamp(15 + bias + - * quick_multioctave_noise{...}, -20, 50)`. Routed onto elevation, exactly like - * `captureElevationLakes` routes `elevation_lakes` - `calculate_tile_properties` - * just needs SOME property name to key the dump under; the sampled values are - * whatever `temperature` itself computes. Same standard grid as - * `captureElevationLakes` (near-origin band, far rings at r=2200/3300, one - * deep-field point), even though `temperature` has no spawn-distance dependency, - * for comparability across captures. - */ -async function captureTemperature(): Promise { - const seed = 123456; - const positions: Position[] = []; - for (let gy = 0; gy < 3; gy++) { - for (let gx = 0; gx < 3; gx++) { - positions.push({ x: gx * 11 - 11 + 0.5, y: gy * 13 - 13 + 0.25 }); - } - } - for (const r of [2200, 3300]) { - for (let k = 0; k < 8; k++) { - const a = (k * Math.PI) / 4; - positions.push({ - x: snapToMapPosition(r * Math.cos(a) + 0.5), - y: snapToMapPosition(r * Math.sin(a) + 0.25), - }); - } - } - positions.push({ x: 12345.75, y: 6789.125 }); - - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const temperature = await sampleExpression("temperature", positions, { workDir, seed }); - const fixture = { - _comment: - "Ground truth from Factorio 2.1.11 via the test/oracle harness. temperature (= temperature_basic) routed onto elevation. Regenerate: node --experimental-strip-types test/oracle/capture.ts temperature", - seed0: seed, - positions, - temperature, - }; - const out = join(FIXTURES, "oracle-temperature.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${positions.length} points)`); - } finally { - await rm(workDir, { recursive: true, force: true }); - } -} - -/** - * The `aux` (= `aux_nauvis`, "terrain type") climate expression: `clamp(0.5 + bias - * + 0.06*(nauvis_plateaus - 0.4) + quick_multioctave_noise{...}, 0, 1)`. Routed - * onto elevation, same standard grid as `captureTemperature` (near-origin band, - * far rings at r=2200/3300, one deep-field point) for comparability across - * captures. `nauvis_plateaus` is a Nauvis-shared sub-tree (also used by - * `elevation_nauvis`), so this exercises the shared module at defaults - * (`control:water:frequency` = 1). - */ -async function captureAux(): Promise { - const seed = 123456; - const positions: Position[] = []; - for (let gy = 0; gy < 3; gy++) { - for (let gx = 0; gx < 3; gx++) { - positions.push({ x: gx * 11 - 11 + 0.5, y: gy * 13 - 13 + 0.25 }); - } - } - for (const r of [2200, 3300]) { - for (let k = 0; k < 8; k++) { - const a = (k * Math.PI) / 4; - positions.push({ - x: snapToMapPosition(r * Math.cos(a) + 0.5), - y: snapToMapPosition(r * Math.sin(a) + 0.25), - }); - } - } - positions.push({ x: 12345.75, y: 6789.125 }); - - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const aux = await sampleExpression("aux", positions, { workDir, seed }); - const fixture = { - _comment: - "Ground truth from Factorio 2.1.11 via the test/oracle harness. aux (= aux_nauvis) routed onto elevation. Regenerate: node --experimental-strip-types test/oracle/capture.ts aux", - seed0: seed, - positions, - aux, - }; - const out = join(FIXTURES, "oracle-aux.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${positions.length} points)`); - } finally { - await rm(workDir, { recursive: true, force: true }); - } -} - -/** - * Ground truth for all 15 Nauvis tree species probability expressions, plus the - * two shared fields they build on. Sampled at defaults (control:trees 1/1), so - * this pins the catalog rows, the crc32 string-seed1 assumption, and the - * asymmetric_ramps port in one shot. - * - * 17 expressions x ~1.7 s per run - this capture is the slow one (~30 s). - */ -async function captureTrees(): Promise { - const seed = 123456; - const positions: Position[] = []; - // Near-spawn grid (where the distance term is live) ... - for (let gy = 0; gy < 3; gy++) { - for (let gx = 0; gx < 3; gx++) { - positions.push({ x: gx * 17 - 17 + 0.5, y: gy * 19 - 19 + 0.25 }); - } - } - // ... plus two far rings, to span several climate biomes. - for (const r of [800, 2400]) { - for (let k = 0; k < 8; k++) { - const a = (k * Math.PI) / 4; - positions.push({ - x: snapToMapPosition(r * Math.cos(a) + 0.5), - y: snapToMapPosition(r * Math.sin(a) + 0.25), - }); - } - } - positions.push({ x: 12345.75, y: 6789.125 }); - - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const values: Record = {}; - for (const name of [ - "tree_small_noise", - "trees_forest_path_cutout_faded", - ...TREE_SPECIES.map((s) => s.name), - ]) { - values[name] = await sampleExpression(name, positions, { workDir, seed }); - console.log(` captured ${name}`); - } - const fixture = { - _comment: - "Ground truth from Factorio 2.1.11 via the test/oracle harness. The 15 tree species probability expressions plus tree_small_noise and trees_forest_path_cutout_faded, each routed onto elevation, at default control:trees. Regenerate: node --experimental-strip-types test/oracle/capture.ts trees", - seed0: seed, - positions, - values, - }; - const out = join(FIXTURES, "oracle-trees.seed123456.json"); - await writeFile(out, `${JSON.stringify(fixture, null, 2)}\n`); - console.log(`wrote ${out}`); - } finally { - await rm(workDir, { recursive: true, force: true }); - } -} - -/** - * The same species at NON-default control:trees levers, so the frequency -> - * input_scale and size -> `0.2 * control:trees:size` wiring is pinned rather than - * assumed. Three representative species (one per cap tier) keep this capture short. - */ -async function captureTreesControls(): Promise { - const seed = 123456; - const treesFrequency = 3; - const treesSize = 2; - const positions: Position[] = []; - for (let gy = 0; gy < 3; gy++) { - for (let gx = 0; gx < 3; gx++) { - positions.push({ x: gx * 23 - 23 + 0.5, y: gy * 29 - 29 + 0.25 }); - } - } - for (let k = 0; k < 8; k++) { - const a = (k * Math.PI) / 4; - positions.push({ - x: snapToMapPosition(1200 * Math.cos(a) + 0.5), - y: snapToMapPosition(1200 * Math.sin(a) + 0.25), - }); - } - - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const values: Record = {}; - for (const name of ["tree_01", "tree_08", "tree_09_red"]) { - values[name] = await sampleExpression(name, positions, { - workDir, - seed, - mapGenOverrides: { - autoplace_controls: { trees: { frequency: treesFrequency, size: treesSize } }, - }, - }); - } - const fixture = { - _comment: - "Ground truth from Factorio 2.1.11 via the test/oracle harness. Three tree species at control:trees frequency=3 size=2. Regenerate: node --experimental-strip-types test/oracle/capture.ts trees-controls", - seed0: seed, - treesFrequency, - treesSize, - positions, - values, - }; - const out = join(FIXTURES, "oracle-trees-controls.seed123456.json"); - await writeFile(out, `${JSON.stringify(fixture, null, 2)}\n`); - console.log(`wrote ${out}`); - } finally { - await rm(workDir, { recursive: true, force: true }); - } -} - -/** - * The `moisture` (= `moisture_nauvis`) climate expression - the most complex - * climate tree (a base quick_multioctave_noise term, a starting-area bias - * blend keyed on `distance_from_nearest_point{points = starting_positions}`, - * and a forest-path/hills/bridge-billows cutout, all from the shared Nauvis - * sub-tree). Routed onto elevation, same standard grid as `captureAux` - * (near-origin band, far rings at r=2200/3300, one deep-field point) for - * comparability across captures. At the default preset every starting-area - * lever is at its degenerate value (`slider_to_linear(1, ...) = 0`), so this - * captures the whole closure at defaults - the starting-area levers - * themselves are not exercised by this fixture (no UI surfaces them yet). - */ -async function captureMoisture(): Promise { - const seed = 123456; - const positions: Position[] = []; - for (let gy = 0; gy < 3; gy++) { - for (let gx = 0; gx < 3; gx++) { - positions.push({ x: gx * 11 - 11 + 0.5, y: gy * 13 - 13 + 0.25 }); - } - } - for (const r of [2200, 3300]) { - for (let k = 0; k < 8; k++) { - const a = (k * Math.PI) / 4; - positions.push({ - x: snapToMapPosition(r * Math.cos(a) + 0.5), - y: snapToMapPosition(r * Math.sin(a) + 0.25), - }); - } - } - positions.push({ x: 12345.75, y: 6789.125 }); - - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const moisture = await sampleExpression("moisture", positions, { workDir, seed }); - const fixture = { - _comment: - "Ground truth from Factorio 2.1.11 via the test/oracle harness. moisture (= moisture_nauvis) routed onto elevation. Regenerate: node --experimental-strip-types test/oracle/capture.ts moisture", - seed0: seed, - positions, - moisture, - }; - const out = join(FIXTURES, "oracle-moisture.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${positions.length} points)`); - } finally { - await rm(workDir, { recursive: true, force: true }); - } -} - -/** - * The native `expression_in_range(peak_multiplier, peak_maximum, expr_1..N, - * from_1..N, to_1..N)` builtin - the one genuine unknown of Milestone 2. Sample - * three sweeps that recover the 1-D peak/falloff shape (both the bounded (20,1) - * and the unbounded (5,inf) parametrizations) and the N-D combination rule. - * - * Arg order per the game docs: peak_multiplier, peak_maximum, ALL exprs, then ALL - * range_froms, then ALL range_tos. So 2-D is - * expression_in_range(pm, pmax, expr1, expr2, from1, from2, to1, to2). - */ -async function captureExpressionInRange(): Promise { - const seed = 123456; - - const sample = async (expression: string, positions: Position[]): Promise => { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - return await sampleExpression(expression, positions, { workDir, seed }); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - }; - - // 1-D sweep: x from -1500..1500 step 25 (y fixed), expr = x/1000 in [-0.5, 0.5]. - // Well inside, exactly on, and well beyond both edges of the range. - const oneDPositions: Position[] = []; - for (let x = -1500; x <= 1500; x += 25) oneDPositions.push({ x, y: 0.25 }); - - const oneD_20_1_expr = "expression_in_range(20, 1, (x/1000), -0.5, 0.5)"; - const oneD_20_1_values = await sample(oneD_20_1_expr, oneDPositions); - console.log(" captured oneD_20_1"); - - const oneD_5_inf_expr = "expression_in_range(5, inf, (x/1000), -0.5, 0.5)"; - const oneD_5_inf_values = await sample(oneD_5_inf_expr, oneDPositions); - console.log(" captured oneD_5_inf"); - - // 2-D sweep, (20, 1), both dims range [-0.5, 0.5]: - // expression_in_range(20, 1, x/1000, y/1000, -0.5, -0.5, 0.5, 0.5) - // Two families that distinguish min vs product vs sum: - // (a) hold x/1000 = 0.2 (intermediate, in range) and sweep y across and beyond - // the range. At an in-range intermediate x, min(a,b), a*b and a+b all - // predict different curves as b leaves [1-partial..1]. - // (b) a diagonal x=y sweep (both leave the range together). - const twoD_expr = "expression_in_range(20, 1, (x/1000), (y/1000), -0.5, -0.5, 0.5, 0.5)"; - const twoDPositions: Position[] = []; - for (let y = -1000; y <= 1000; y += 25) twoDPositions.push({ x: 200, y }); // (a) hold x=0.2 - for (let d = -1000; d <= 1000; d += 25) twoDPositions.push({ x: d, y: d }); // (b) diagonal - const twoD_values = await sample(twoD_expr, twoDPositions); - console.log(" captured twoD"); - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.11 via the test/oracle harness. Native expression_in_range routed onto elevation. Arg order: peak_multiplier, peak_maximum, all exprs, all froms, all tos. Regenerate: node --experimental-strip-types test/oracle/capture.ts expression-in-range", - seed0: seed, - sweeps: { - oneD_20_1: { - expression: oneD_20_1_expr, - peakMultiplier: 20, - peakMaximum: 1, - from: -0.5, - to: 0.5, - positions: oneDPositions, - values: oneD_20_1_values, - }, - oneD_5_inf: { - expression: oneD_5_inf_expr, - peakMultiplier: 5, - peakMaximum: "inf", - from: -0.5, - to: 0.5, - positions: oneDPositions, - values: oneD_5_inf_values, - }, - twoD: { - expression: twoD_expr, - peakMultiplier: 20, - peakMaximum: 1, - froms: [-0.5, -0.5], - tos: [0.5, 0.5], - positions: twoDPositions, - values: twoD_values, - }, - }, - }; - const out = join(FIXTURES, "oracle-expression-in-range.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out}`); -} - -/** - * Scattered points spread over a WIDE spatial extent, meant to cross many - * biomes. Tile selection is driven by `aux` and `moisture`, whose - * `input_scale` (`control:aux:frequency/2048`, `control:moisture:frequency/256`) - * makes both vary very slowly over space - near the origin the whole area is - * one or two biomes, so reaching sand (high aux) / red-desert / the fuller - * dirt range needs points spread over THOUSANDS of tiles. - * - * Uses a golden-angle spiral (radius growing linearly from `minR` to `maxR`, - * angle advancing by the golden angle each step) so `count` points cover both - * many radii and many directions with no clustering, rather than a grid that - * would repeat the same few directions. `angleOffset` decorrelates the spiral - * between seeds so the same relative sample layout doesn't line up with the - * same terrain features seed to seed. - */ -function scatterPositions( - count: number, - minR: number, - maxR: number, - angleOffset: number, -): Position[] { - const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); // ~137.5 degrees - const out: Position[] = [{ x: 0.5, y: 0.25 }]; // one near-origin anchor point - for (let i = 0; i < count; i++) { - const t = (i + 0.5) / count; - const r = minR + t * (maxR - minR); - const angle = angleOffset + i * GOLDEN_ANGLE; - out.push({ x: r * Math.cos(angle) + 0.5, y: r * Math.sin(angle) + 0.25 }); - } - return out; -} - -/** - * The `get_tile` tile-name oracle: generates real chunks (a small per-point - * radius, NOT one giant origin-centered disc - see `buildTileControlLua`) for - * the DEFAULT preset (no property routing) and dumps - * `surface.get_tile(x, y).name` at each scattered position, so a later task - * can check tile-selection argmax exactly (rather than just the noise values - * `calculate_tile_properties` reports). Widened per the Task 2 review finding: - * the original 220-tile-radius grid was 86% grass-2/red-desert-0 with zero - * sand/deepwater - not a meaningful ground truth for resolver validation. - * Captures three seeds (a single seed's local terrain is biome-poor by luck) - * spread out to ~4000 tiles, each written to its own fixture file. - */ -async function captureTileNamesForSeed(seed: number, angleOffset: number): Promise { - const positions = scatterPositions(50, 100, 4000, angleOffset); - - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const samples: TileSample[] = await sampleTileNames(positions, { workDir, seed, radius: 1 }); - const fixture = { - _comment: - "Ground truth from Factorio 2.1.11 via the test/oracle harness. DEFAULT preset (no property_expression_names routing) - surface.get_tile(x, y).name at each position after real chunk generation. positions are the mod's ECHOED floored get_tile input (not the pre-floor request), so a fractional capture can't silently mismatch. Regenerate: node --experimental-strip-types test/oracle/capture.ts tile-names", - seed0: seed, - positions: samples.map((s) => ({ x: s.x, y: s.y })), - tileNames: samples.map((s) => s.name), - }; - const out = join(FIXTURES, `oracle-tile-names.seed${seed}.json`); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - const distinct = new Set(fixture.tileNames).size; - console.log( - `wrote ${out} (${positions.length} points, ${distinct} distinct tiles: ${[...new Set(fixture.tileNames)].sort().join(", ")})`, - ); - } finally { - await rm(workDir, { recursive: true, force: true }); - } -} - -async function captureTileNames(): Promise { - await captureTileNamesForSeed(123456, 0); - await captureTileNamesForSeed(654321, 1.3); - await captureTileNamesForSeed(424242, 2.6); -} - -/** - * random_penalty ground truth. It is a BATCH op (seeded from the first position, - * streamed last->first, source<=0 skips a draw), so each config is ONE batch and - * the fixture stores the exact ordered position list + the game's output. Sources - * are simple functions of (x,y) so the spec can recompute source[i] and validate - * randomPenaltyBatch. Includes odd seeds (even-only masked the quickMultioctave - * bug last session) and a source=x config to exercise the source<=0 guard. - */ -async function captureRandomPenalty(): Promise { - const seed = 123456; // map_seed; random_penalty is map_seed-independent, but pin it. - // A scattered ordered batch: fractional, negatives (x<=0 for the guard), far points. - const positions: Position[] = [ - { x: 0, y: 0 }, - { x: 1, y: 0 }, - { x: -3, y: 2 }, - { x: 5.5, y: 7.25 }, - { x: -10, y: -10 }, - { x: 40, y: 13 }, - { x: 0, y: -1 }, - { x: 1000, y: -2000 }, - ]; - // sourceKind: how the spec reconstructs source[i] from the position. - const configs = [ - { rpSeed: 1, amplitude: 1, sourceExpr: "1", sourceKind: "const1" }, - { rpSeed: 1, amplitude: 2, sourceExpr: "1", sourceKind: "const1" }, - { rpSeed: 7, amplitude: 1, sourceExpr: "1", sourceKind: "const1" }, // odd seed - { rpSeed: 13, amplitude: 0.5, sourceExpr: "1", sourceKind: "const1" }, // odd seed - { rpSeed: 1, amplitude: 1, sourceExpr: "x", sourceKind: "x" }, // source<=0 guard - ]; - const cases = []; - for (const c of configs) { - const expression = `random_penalty{x = x, y = y, seed = ${c.rpSeed}, source = ${c.sourceExpr}, amplitude = ${c.amplitude}}`; - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const values = await sampleExpression(expression, positions, { workDir, seed }); - cases.push({ ...c, values }); - console.log(` captured rpSeed=${c.rpSeed} amp=${c.amplitude} source=${c.sourceExpr}`); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - const fixture = { - _comment: - "Ground truth from Factorio 2.1.11 via the test/oracle harness. random_penalty routed onto elevation. BATCH op: seeded from positions[0], streamed last->first, source<=0 passes through with no draw. Regenerate: node --experimental-strip-types test/oracle/capture.ts random-penalty", - seed0: seed, - positions, - cases, - }; - const out = join(FIXTURES, "oracle-random-penalty.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${configs.length} configs x ${positions.length} points)`); -} - -/** - * Pure-regular resource field ground truth. Probes `resource_autoplace_all_patches` - * with `has_starting_area_placement = 0` (so all_patches = regular_patches) and - * `regular_patch_set_count = 1` / `index = 0` (skip_span 1: this resource takes every - * accepted spot, unpartitioned) - the cleanest ground truth for the regular field. - * frequency_multiplier / size_multiplier inlined as 1 to drop the control-var - * dependency. Params inlined (capture.ts can't import extensionless src/). Grid: a - * near-spawn cluster + a far ring, so the distance fade-in and double-density ramp - * are both exercised. Seeds: 123456 and an odd one. - */ -async function captureResourceRegular(): Promise { - interface Probe { - name: string; - base_density: number; - base_spots_per_km2: number; - candidate_spot_count: number; - random_spot_size_minimum: number; - random_spot_size_maximum: number; - regular_rq_factor: number; - starting_rq_factor: number; - } - // iron (has_starting true in-game) and uranium (false) - a spread of density/rq. - // has_starting is forced to 0 in the probe for all. Only iron takes both seeds - // (odd + even) to keep the fixture small; the field code path is identical. - const probes: Probe[] = [ - { - name: "iron-ore", - base_density: 10, - base_spots_per_km2: 2.5, - candidate_spot_count: 22, - random_spot_size_minimum: 0.25, - random_spot_size_maximum: 2, - regular_rq_factor: 1.1 / 10, - starting_rq_factor: 1.5 / 7, - }, - { - name: "uranium-ore", - base_density: 0.9, - base_spots_per_km2: 1.25, - candidate_spot_count: 21, - random_spot_size_minimum: 2, - random_spot_size_maximum: 4, - regular_rq_factor: 1.0 / 10, - starting_rq_factor: 1.0 / 7, - }, - ]; - const buildExpr = (p: Probe): string => - "resource_autoplace_all_patches{" + - [ - `base_density = ${p.base_density}`, - `base_spots_per_km2 = ${p.base_spots_per_km2}`, - `candidate_spot_count = ${p.candidate_spot_count}`, - `frequency_multiplier = 1`, - `has_starting_area_placement = 0`, - `random_spot_size_minimum = ${p.random_spot_size_minimum}`, - `random_spot_size_maximum = ${p.random_spot_size_maximum}`, - `regular_blob_amplitude_multiplier = ${1 / 8}`, - `regular_patch_set_count = 1`, - `regular_patch_set_index = 0`, - `regular_rq_factor = ${p.regular_rq_factor}`, - `seed1 = 100`, - `size_multiplier = 1`, - `starting_blob_amplitude_multiplier = ${1 / 8}`, - `starting_patch_set_count = 1`, - `starting_patch_set_index = 0`, - `starting_rq_factor = ${p.starting_rq_factor}`, - ].join(", ") + - "}"; - - // Cover the WHOLE of region (1,1) - centered on (1024,1024), spanning [512,1536] - // - at stride 16, so every ~30-tile-radius patch is caught by several points - // (patches are sparse: only a handful survive the trim per 1024^2 region, so a - // local window misses them all). This is a high-density, off-spawn region (the - // distance fade-in is fully ramped), which maximizes patch count. Plus a few - // near-spawn points to confirm the fade-in floor (basement there). - const positions: Position[] = []; - const N = 64; - const stride = 16; - const x0 = 512; - for (let iy = 0; iy < N; iy++) - for (let ix = 0; ix < N; ix++) positions.push({ x: x0 + ix * stride, y: x0 + iy * stride }); - for (let gy = 0; gy < 3; gy++) - for (let gx = 0; gx < 3; gx++) positions.push({ x: gx * 60 - 60, y: gy * 60 - 60 }); - - const seeds = [123456, 777771]; - const cases = []; - for (const seed of seeds) { - for (const p of probes) { - const expression = buildExpr(p); - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const values = await sampleExpression(expression, positions, { workDir, seed }); - cases.push({ resource: p.name, seed, values }); - console.log(` captured ${p.name} seed=${seed}`); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - } - const fixture = { - _comment: - "Ground truth from Factorio 2.1.11. resource_autoplace_all_patches (has_starting_area_placement=0, regular_patch_set_count=1) routed onto elevation = the pure regular_patches field. frequency/size multipliers = 1. Regenerate: node --experimental-strip-types test/oracle/capture.ts resource-regular", - positions, - cases, - }; - const out = join(FIXTURES, "oracle-resource-regular.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${cases.length} cases x ${positions.length} points)`); -} - -/** - * Combined starting+regular resource field ground truth. Probes - * `resource_autoplace_all_patches` with `has_starting_area_placement = 1` (so - * all_patches = max(starting_patches, regular_patches)) and both - * `regular_patch_set_count = 1` / `starting_patch_set_count = 1` (index 0: this - * resource takes every accepted spot in each set, unpartitioned) - the cleanest - * ground truth for the combined field. frequency_multiplier / size_multiplier - * inlined as 1 to drop the control-var dependency. Params inlined (capture.ts - * can't import extensionless src/). Grid: a DENSE near-spawn block (catches the - * ~20-tile starting patches AND the regular fade-in ring) plus a far ring - * (regular-only region, keeps the regular branch covered). Seeds: 123456 and an - * odd one. - * - * Routed onto `moisture`, NOT `elevation` (unlike every other capture in this - * file). `has_starting_area_placement = 1` pulls in `elevation_lakes` (via - * `startingFavorabilityBaseAt`'s `clamp((elevation_lakes - 1) / 10, ...)` term), - * and `elevation_lakes` needs the engine's real spawn-lake resolution, which - * itself runs through the `elevation` property during the very first chunk - * generation. Overriding `elevation` with THIS expression makes that resolution - * recurse into itself with no base case - confirmed via a throwaway repro: it - * SIGSEGVs headless Factorio during "Creating new map", before our mod's - * `on_init` ever runs (so no stderr, just a silent crash). Routing onto - * `moisture` instead sidesteps the real elevation pipeline entirely and dumps - * clean values - verified with a 3-point repro before this full capture. - */ -async function captureResourceStarting(): Promise { - interface Probe { - name: string; - base_density: number; - base_spots_per_km2: number; - candidate_spot_count: number; - random_spot_size_minimum: number; - random_spot_size_maximum: number; - regular_rq_factor: number; - starting_rq_factor: number; - } - // iron and copper (both has_starting true in-game; different starting_rq_factor). - const probes: Probe[] = [ - { - name: "iron-ore", - base_density: 10, - base_spots_per_km2: 2.5, - candidate_spot_count: 22, - random_spot_size_minimum: 0.25, - random_spot_size_maximum: 2, - regular_rq_factor: 1.1 / 10, - starting_rq_factor: 1.5 / 7, - }, - { - name: "copper-ore", - base_density: 8, - base_spots_per_km2: 2.5, - candidate_spot_count: 22, - random_spot_size_minimum: 0.25, - random_spot_size_maximum: 2, - regular_rq_factor: 1.1 / 10, - starting_rq_factor: 1.2 / 7, - }, - ]; - const buildExpr = (p: Probe): string => - "resource_autoplace_all_patches{" + - [ - `base_density = ${p.base_density}`, - `base_spots_per_km2 = ${p.base_spots_per_km2}`, - `candidate_spot_count = ${p.candidate_spot_count}`, - `frequency_multiplier = 1`, - `has_starting_area_placement = 1`, - `random_spot_size_minimum = ${p.random_spot_size_minimum}`, - `random_spot_size_maximum = ${p.random_spot_size_maximum}`, - `regular_blob_amplitude_multiplier = ${1 / 8}`, - `regular_patch_set_count = 1`, - `regular_patch_set_index = 0`, - `regular_rq_factor = ${p.regular_rq_factor}`, - `seed1 = 100`, - `size_multiplier = 1`, - `starting_blob_amplitude_multiplier = ${1 / 8}`, - `starting_patch_set_count = 1`, - `starting_patch_set_index = 0`, - `starting_rq_factor = ${p.starting_rq_factor}`, - ].join(", ") + - "}"; - - // Dense near-spawn block: +/-240 at stride 8 catches the ~20-tile starting - // patches AND the regular fade-in ring (120..420). Starting patches live - // within ~120-240. - const positions: Position[] = []; - for (let y = -240; y <= 240; y += 8) - for (let x = -240; x <= 240; x += 8) positions.push({ x: x + 0.5, y: y + 0.25 }); - // A far ring (regular-only region) so the regular branch stays covered. - for (const r of [1500, 2500]) { - for (let k = 0; k < 12; k++) { - const a = (k * Math.PI) / 6; - positions.push({ - x: snapToMapPosition(r * Math.cos(a) + 0.5), - y: snapToMapPosition(r * Math.sin(a) + 0.25), - }); - } - } - - const seeds = [123456, 777771]; - const cases = []; - for (const seed of seeds) { - for (const p of probes) { - const expression = buildExpr(p); - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const values = await sampleExpression(expression, positions, { - workDir, - seed, - property: "moisture", - }); - cases.push({ resource: p.name, seed, values }); - console.log(` captured ${p.name} seed=${seed}`); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - } - const fixture = { - _comment: - "Ground truth from Factorio 2.1.11. resource_autoplace_all_patches (has_starting_area_placement=1, regular_patch_set_count=1, starting_patch_set_count=1) = max(starting_patches, regular_patches), both sets unpartitioned. frequency/size multipliers = 1. Routed onto MOISTURE, not elevation (unlike the other oracle-resource-*.json fixtures): has_starting_area_placement=1 pulls in elevation_lakes, which needs the engine's real spawn-lake resolution, which itself runs through the elevation property during the first chunk generation - overriding elevation with this expression makes that resolution recurse into itself and SIGSEGVs headless Factorio. Routing onto moisture sidesteps that; the values are the same resource_autoplace_all_patches output either way, only the carrier property differs. Regenerate: node --experimental-strip-types test/oracle/capture.ts resource-starting", - positions, - cases, - }; - const out = join(FIXTURES, "oracle-resource-starting.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${cases.length} cases x ${positions.length} points)`); -} - -/** - * enemy_base_probability ground truth. Dense grid over region (2,2) (centred on - * (1024,1024), spanning [768,1280], stride 16) catches several spot cones - the - * region is off-spawn/high-density so a few of the ~15-30 tile cones survive the - * density trim - plus a near-spawn (+x) profile (basement + starting-area - * clearing). Two seeds. - */ -async function captureEnemyBase(): Promise { - const positions: Position[] = []; - // Dense grid over region (2,2): centred (1024,1024), [768,1280]; stride 16 catches - // the ~15-30 tile enemy cones (a few survive the density trim per 512^2 region). - for (let iy = 0; iy < 32; iy++) - for (let ix = 0; ix < 32; ix++) - positions.push({ x: 768 + ix * 16 + 0.5, y: 768 + iy * 16 + 0.25 }); - // Near-spawn (+x) profile: basement + starting-area clearing. - for (const d of [0, 40, 60, 80, 100, 150, 200, 300]) positions.push({ x: d + 0.5, y: 0.25 }); - const seeds = [123456, 777771]; - const cases: { seed: number; values: number[] }[] = []; - for (const seed of seeds) { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const values = await sampleExpression("enemy_base_probability", positions, { workDir, seed }); - cases.push({ seed, values }); - console.log(` captured enemy-base seed=${seed}`); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - const fixture = { - _comment: - "Ground truth from Factorio 2.1.11 via test/oracle. enemy_base_probability routed onto elevation, default controls (enemy-base freq/size = 1). Regenerate: node --experimental-strip-types test/oracle/capture.ts enemy-base", - positions, - cases, - }; - const out = join(FIXTURES, "oracle-enemy-base.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${positions.length} points, ${cases.length} seeds)`); -} - -/** - * cliff_elevation_nauvis ground truth. Grid over [0,512) at stride 16, at the - * cliff CORNER lattice offset (x on 4s, y on 4s+0.5) so the same fixture doubles - * as corner-value ground truth for placement (Task 6). Two seeds. - */ -async function captureCliffElevation(): Promise { - const positions: Position[] = []; - // Grid over [0,512) stride 16, at the cliff CORNER lattice offset (x on 4s, y on 4s+0.5) - // so the same fixture doubles as corner-value ground truth for placement. - for (let iy = 0; iy < 32; iy++) - for (let ix = 0; ix < 32; ix++) positions.push({ x: ix * 16, y: iy * 16 + 0.5 }); - const seeds = [123456, 777771]; - const cases: { seed: number; values: number[] }[] = []; - for (const seed of seeds) { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const values = await sampleExpression("cliff_elevation_nauvis", positions, { workDir, seed }); - cases.push({ seed, values }); - console.log(` captured cliff-elevation seed=${seed}`); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - const out = join(FIXTURES, "oracle-cliff-elevation.seed123456.json"); - await writeFile( - out, - JSON.stringify( - { - _comment: - "Ground truth from Factorio 2.1.11 via test/oracle. cliff_elevation_nauvis routed onto elevation, default settings. Regenerate: node --experimental-strip-types test/oracle/capture.ts cliff-elevation", - positions, - cases, - }, - null, - 2, - ) + "\n", - ); - console.log(`wrote ${out} (${positions.length} points, ${cases.length} seeds)`); -} - -/** - * cliffiness_nauvis ground truth. The core cliff GATE field: `(main_cliffiness >= - * cliff_cutoff) * 10`, so every value is exactly 0 or 10. Same corner-lattice grid - * as captureCliffElevation ([0,512) stride 16, x on 4s, y on 4s+0.5) so the two - * fixtures pair up for placement. Two seeds. - */ -async function captureCliffiness(): Promise { - const positions: Position[] = []; - for (let iy = 0; iy < 32; iy++) - for (let ix = 0; ix < 32; ix++) positions.push({ x: ix * 16, y: iy * 16 + 0.5 }); - const seeds = [123456, 777771]; - const cases: { seed: number; values: number[] }[] = []; - for (const seed of seeds) { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const values = await sampleExpression("cliffiness_nauvis", positions, { workDir, seed }); - cases.push({ seed, values }); - console.log(` captured cliffiness seed=${seed}`); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - const out = join(FIXTURES, "oracle-cliffiness.seed123456.json"); - await writeFile( - out, - JSON.stringify( - { - _comment: - "Ground truth from Factorio 2.1.11 via test/oracle. cliffiness_nauvis routed onto elevation, default settings. This is the exact 0/10 GATE (main_cliffiness >= cliff_cutoff) * 10. Regenerate: node --experimental-strip-types test/oracle/capture.ts cliffiness", - positions, - cases, - }, - null, - 2, - ) + "\n", - ); - console.log(`wrote ${out} (${positions.length} points, ${cases.length} seeds)`); -} - -/** - * The Nauvis cliff-ringbreak offset chain. Samples the four named expressions of - * the domain-warped offset field at a shared grid, at two seeds: - * - `nauvis_hills_offset_raw_x` / `nauvis_hills_offset_raw_y`: the two - * `basis_noise{seed1 = 'nauvis_offset_x'/'nauvis_offset_y', input_scale = - * nauvis_segmentation_multiplier / 500}` warp fields (string basis-noise - * seeds, resolved to crc32(name) = 593691028 / 1415852290). Capturing them - * directly lets the CI spec re-confirm those seed1 constants. - * - `nauvis_hills_offset`: abs of the seed1=900 multioctave field re-evaluated - * at the warped coordinate (x + 12*normalize(rawX,rawY), y + 12*normalize(rawY,rawX)). - * - `nauvis_cliff_ringbreak`: abs(nauvis_hills - nauvis_hills_offset), the - * base_cliffiness input for Task 6. - * Routed onto elevation, default settings. Grid is the standard scattered grid. - */ -async function captureCliffOffsetRaw(): Promise { - const positions = gridPositions(); - const seeds = [123456, 777771]; - const cases: { - seed: number; - rawX: number[]; - rawY: number[]; - hillsOffset: number[]; - ringbreak: number[]; - }[] = []; - for (const seed of seeds) { - const sample = async (expression: string): Promise => { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - return await sampleExpression(expression, positions, { workDir, seed }); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - }; - const rawX = await sample("nauvis_hills_offset_raw_x"); - const rawY = await sample("nauvis_hills_offset_raw_y"); - const hillsOffset = await sample("nauvis_hills_offset"); - const ringbreak = await sample("nauvis_cliff_ringbreak"); - cases.push({ seed, rawX, rawY, hillsOffset, ringbreak }); - console.log(` captured cliff-offset-raw seed=${seed}`); - } - const fixture = { - _comment: - "Ground truth from Factorio 2.1.11 via test/oracle. The Nauvis cliff-ringbreak offset chain (nauvis_hills_offset_raw_x/raw_y, nauvis_hills_offset, nauvis_cliff_ringbreak) routed onto elevation, default settings. raw_x/raw_y are basis_noise with string seed1 'nauvis_offset_x'/'nauvis_offset_y' (= crc32(name) = 593691028 / 1415852290). Regenerate: node --experimental-strip-types test/oracle/capture.ts cliff-offset-raw", - positions, - cases, - }; - const out = join(FIXTURES, "oracle-cliff-offset-raw.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${positions.length} points, ${cases.length} seeds)`); -} - -/** - * The end-to-end cliff PLACEMENT ground truth: every real cliff entity the game - * placed in a region, at the DEFAULT preset, via a chunk-forced - * `find_entities_filtered{type="cliff"}` dump (see `sampleCliffEntities`). The - * CI-safe spec runs `makeCliffPlacement(...).placedCells` over the same region - * and asserts the placed set reproduces >= 85% of these real cliffs (the ~90% - * from the spike; the residual is the DEFERRED `fixImpossibleCells` + water - * rejection - see docs/noise/cliffs-NOTES.md). Region `[512,1024)^2` = 16x16 - * chunks: enough cliffs (tens to low hundreds) with bounded generation time. Two - * seeds. Every dumped position must land on the cliff lattice (x≡2, y≡2.5 mod 4). - */ -async function captureCliffEntities(): Promise { - const region: Region = { x0: 512, y0: 512, x1: 1024, y1: 1024 }; - const seeds = [123456, 777771]; - const cases: { seed: number; cliffs: Position[] }[] = []; - for (const seed of seeds) { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const cliffs = await sampleCliffEntities(region, { workDir, seed }); - cases.push({ seed, cliffs }); - console.log(` captured cliff-entities seed=${seed} (${cliffs.length} cliffs)`); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 via test/oracle. Every cliff entity (find_entities_filtered{type='cliff'}) the game placed in the region at the DEFAULT preset, after chunk-forced generation. Positions are cliff cell centers (x mod 4 == 2, y mod 4 == 2.5). Each entry also carries the entity's `orientation` (LuaEntity.cliff_orientation), which makes this a direct end-to-end oracle for CLIFF_CODE_TO_ORIENTATION - see test/cliffOrientationOracle.spec.ts. Re-captured 2026-07-30 at 2.1.12 to add that field; it reproduced the 2.1.11 capture's 282 and 52 positions exactly, in the same order, so Nauvis cliff placement did not move between those versions. The CI spec runs makeCliffPlacement().placedCells over region; it now matches 1.0000 in both directions, not the >= 85% this line used to describe. Regenerate: node --experimental-strip-types test/oracle/capture.ts cliff-entities", - region, - cases, - }; - const out = join(FIXTURES, "oracle-cliff-entities.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${cases.length} seeds)`); -} - -/** - * Ground truth for `rock_density` (= `rock_noise - max(0, 1.1 - distance/32)`, a - * base-game named noise expression). Validating it point-by-point pins the rocks- - * specific noise (`multioctave_noise{seed1=137, octaves=4, persistence=0.9, - * input_scale=0.15*control:rocks:frequency}` plus the size/distance terms); the - * `range_select_base` bands and the multiplier/penalty composition are unit-tested - * in test/rockField.spec.ts, and moisture/aux are already oracle-validated. Same - * standard grid as captureAux/captureTemperature (near-spawn band exercises the - * distance term, far rings span the noise) for comparability. - */ -async function captureRocks(): Promise { - const seed = 123456; - const positions: Position[] = []; - for (let gy = 0; gy < 3; gy++) { - for (let gx = 0; gx < 3; gx++) { - positions.push({ x: gx * 11 - 11 + 0.5, y: gy * 13 - 13 + 0.25 }); - } - } - for (const r of [2200, 3300]) { - for (let k = 0; k < 8; k++) { - const a = (k * Math.PI) / 4; - positions.push({ - x: snapToMapPosition(r * Math.cos(a) + 0.5), - y: snapToMapPosition(r * Math.sin(a) + 0.25), - }); - } - } - positions.push({ x: 12345.75, y: 6789.125 }); - - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const values = await sampleExpression("rock_density", positions, { workDir, seed }); - const fixture = { - _comment: - "Ground truth from Factorio 2.1.11 via the test/oracle harness. rock_density (= rock_noise - max(0, 1.1 - distance/32)) routed onto elevation. Regenerate: node --experimental-strip-types test/oracle/capture.ts rocks", - seed0: seed, - positions, - values, - }; - const out = join(FIXTURES, "oracle-rock-density.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${positions.length} points)`); - } finally { - await rm(workDir, { recursive: true, force: true }); - } -} - -/** - * The native `multisample(expression, offset_x, offset_y)` builtin - the one - * genuine unknown feeding Vulcanus's `vulcanus_basalt_lakes_multisample` (Task 9's - * `vulcanus_elev`). Per the game's own auxiliary docs ("Evaluates the expression - * in a separate noise program with a larger grid. Sub-grids are copied to the - * main program.") this is a supersampling primitive; `offset_x`/`offset_y` are - * documented as "constant 8-bit signed integer" but the real usage only ever - * passes 0 or 1 (a 2x2 supersample). The inner-expression trick: route the bare - * `x` and `y` variables (not some derived expression) as the multisample - * argument, so the returned number IS the exact sampled coordinate - no - * inversion needed. Sampled independently for x and y, at several base points - * (fractional, negative, far-from-origin) crossed with a WIDE offset sweep - * (-2..3, beyond the {0,1} the game actually uses) so a linear offset-per-unit - * rule is over-determined rather than merely fit to two points, plus a few - * non-axis-aligned (dx,dy) combos to rule out any cross term between the two - * axes. `calculate_tile_properties` needs the multisample fix landed in - * 2.0.67 ("Fixed multisample noise operation not working properly for - * LuaSurface.calculate_tile_properties()") - confirmed present (game reports - * 2.1.12, changelog fix is 2.0.67). - */ -async function captureMultisample(): Promise { - const seed = 123456; - const positions: Position[] = [ - { x: 0.5, y: 0.25 }, - { x: 10.5, y: -20.25 }, - { x: -5.25, y: 7.75 }, - { x: 100.125, y: -300.875 }, - { x: 1234.5, y: -4321.5 }, - ]; - // (dx, dy) pairs: the real {0,1}x{0,1} usage, an extended axis-aligned sweep - // (negative + beyond 1, both axes independently) to pin the linear rule, and a - // few off-axis combos to check for cross terms. - const offsets: { dx: number; dy: number }[] = [ - { dx: 0, dy: 0 }, - { dx: 1, dy: 0 }, - { dx: 0, dy: 1 }, - { dx: 1, dy: 1 }, - { dx: -1, dy: 0 }, - { dx: -2, dy: 0 }, - { dx: 2, dy: 0 }, - { dx: 3, dy: 0 }, - { dx: 0, dy: -1 }, - { dx: 0, dy: -2 }, - { dx: 0, dy: 2 }, - { dx: 0, dy: 3 }, - { dx: 2, dy: 3 }, - { dx: -1, dy: 2 }, - { dx: 3, dy: -2 }, - ]; - - const sample = async (expression: string): Promise => { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - return await sampleExpression(expression, positions, { workDir, seed }); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - }; - - const cases: { dx: number; dy: number; sampledX: number[]; sampledY: number[] }[] = []; - for (const { dx, dy } of offsets) { - const sampledX = await sample(`multisample(x, ${dx}, ${dy})`); - const sampledY = await sample(`multisample(y, ${dx}, ${dy})`); - cases.push({ dx, dy, sampledX, sampledY }); - console.log(` captured multisample dx=${dx} dy=${dy}`); - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 via the test/oracle harness. Native multisample(expression, offset_x, offset_y) routed onto elevation, with the inner expression being the bare x (sampledX) or bare y (sampledY) variable, so the returned number IS the exact world coordinate multisample sampled - no inversion needed. Regenerate: node --experimental-strip-types test/oracle/capture.ts multisample", - seed0: seed, - positions, - cases, - }; - const out = join(FIXTURES, "oracle-multisample.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${offsets.length} offsets x ${positions.length} points)`); -} - -/** - * Space-Age (Vulcanus) smoke fixture: proves the Space-Age oracle path routes - * correctly end to end. Samples two exact constants - - * `vulcanus_starting_area_radius` (`0.7 * 0.75` = 0.525) and `vulcanus_ore_spacing` - * (128) - plus `vulcanus_temperature` at 4 scattered points, all against a real - * Vulcanus surface (`game.planets["vulcanus"].create_surface()`, via - * `{ spaceAge: true, planet: "vulcanus" }`). Needs `space-age` + - * `elevated-rails` + `quality` alongside `base` in the generated mod-list. - */ -async function captureVulcanusSmoke(): Promise { - const seed = 123456; - const planet = "vulcanus"; - const positions: Position[] = [ - { x: 0.5, y: 0.25 }, - { x: 100.5, y: -50.25 }, - { x: -300.5, y: 200.25 }, - { x: 1000.5, y: 1000.25 }, - ]; - - const sample = async (expression: string): Promise => { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - return await sampleExpression(expression, positions, { - workDir, - seed, - spaceAge: true, - planet, - }); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - }; - - const startingAreaRadius = await sample("vulcanus_starting_area_radius"); - console.log(" captured vulcanus_starting_area_radius"); - const oreSpacing = await sample("vulcanus_ore_spacing"); - console.log(" captured vulcanus_ore_spacing"); - const temperature = await sample("vulcanus_temperature"); - console.log(" captured vulcanus_temperature"); - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 (Space Age enabled) via the test/oracle harness. vulcanus_starting_area_radius (constant 0.7 * 0.75 = 0.525) and vulcanus_ore_spacing (constant 128), plus vulcanus_temperature at 4 points, all sampled against a real Vulcanus surface (game.planets['vulcanus'].create_surface()). Proves the Space-Age oracle routing (spaceAge/planet options) end to end. Regenerate: node --experimental-strip-types test/oracle/capture.ts vulcanus-smoke", - seed0: seed, - planet, - positions, - startingAreaRadius, - oreSpacing, - temperature, - }; - const out = join(FIXTURES, "oracle-vulcanus-smoke.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out}`); -} - -/** - * The four engine-builtin "seed vars" that drive Vulcanus's biome rotation: - * `map_seed_normalized`, `map_seed_small` (both pure functions of `seed0`, the - * `--map-gen-seed`/`mgs.seed` value - documented in the game's own - * noise-expressions reference as "0-1 normalized value of map_seed" and "16 - * least significant bits from map_seed" respectively, but sampled here across - * seeds anyway so the exact formula is confirmed against real output, not just - * taken on faith), and `x_from_start`/`y_from_start` (per-point, `= - * distance_from_nearest_point_x/_y(x, y, starting_positions)` per - * `core/prototypes/noise-programs.lua` - a native primitive with no further - * Lua source, unlike the other two which merely lack a *documented* formula). - * All four are sampled at ONE fixed point per seed (map_seed_normalized/small - * do not depend on x/y at all; x_from_start/y_from_start do, but a single - * point is enough to confirm the `== x, y` finding or its offset), through the - * Space-Age Vulcanus surface (`{ spaceAge: true, planet: "vulcanus" }`) so the - * per-call `seed` option drives `mgs.seed` on that freshly-created surface - - * see {@link buildSpaceAgeControlLua}. 12 seeds, including the brief's - * required 123456/0/1/2/0xFFFFFFFF, spread across the full 32-bit range so the - * derived formula is over-determined. - */ -async function captureSeedVars(): Promise { - const planet = "vulcanus"; - const point: Position = { x: 300.5, y: -700.25 }; - const seeds = [ - 123456, 0, 1, 2, 0xffffffff, 42, 654321, 424242, 100000, 0x7fffffff, 3000000000, 999999999, - ]; - - const sampleOne = async (seed: number, expression: string): Promise => { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const values = await sampleExpression(expression, [point], { - workDir, - seed, - spaceAge: true, - planet, - }); - return values[0]; - } finally { - await rm(workDir, { recursive: true, force: true }); - } - }; - - const results: { - seed0: number; - mapSeedNormalized: number; - mapSeedSmall: number; - xFromStart: number; - yFromStart: number; - }[] = []; - for (const seed of seeds) { - const mapSeedNormalized = await sampleOne(seed, "map_seed_normalized"); - const mapSeedSmall = await sampleOne(seed, "map_seed_small"); - const xFromStart = await sampleOne(seed, "x_from_start"); - const yFromStart = await sampleOne(seed, "y_from_start"); - results.push({ seed0: seed, mapSeedNormalized, mapSeedSmall, xFromStart, yFromStart }); - console.log( - ` captured seed=${seed}: normalized=${mapSeedNormalized} small=${mapSeedSmall} xFromStart=${xFromStart} yFromStart=${yFromStart}`, - ); - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 (Space Age enabled) via the test/oracle harness. map_seed_normalized, map_seed_small, x_from_start, y_from_start, each routed onto elevation on a real Vulcanus surface (game.planets['vulcanus'].create_surface()), sampled at ONE fixed point per seed across 12 seeds spanning the 32-bit range. Regenerate: node --experimental-strip-types test/oracle/capture.ts seed-vars", - planet, - point, - seeds: results, - }; - const out = join(FIXTURES, "oracle-seed-vars.multi.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${seeds.length} seeds)`); -} - -/** - * `starting_spot_at_angle` ground truth: the radial-placement backbone Vulcanus - * leans on (Task 3). Samples 4 configs spanning distinct angles (0/45/90/180 - - * exercising exact and irrational sin/cos), distances, radii, and non-zero - * x/y distortion, each over the standard scattered grid, against a real - * Vulcanus surface ({ spaceAge: true, planet: "vulcanus" }) so - * `x_from_start`/`y_from_start` resolve per Task 2's `== x, y` finding. - */ -/** - * The noise machine's `^` operator, sampled f32-EXACT - the ground truth for - * `src/noise/fastApprox.ts`, which had none. - * - * **Why this exists (issues #161, #162, #163).** `fastApprox` is consumed by the - * resource `spot_height` / `blob_amplitude` chain and by the multioctave RMS - * normalisation, and every fixture over those compares with a TOLERANCE wide - * enough to hide a 1e-5 shift. That is how a double-accumulation bug survived a - * year, and it is why neither of the two open questions about the file could be - * answered from the suite. This probe removes the chain entirely and samples the - * operator on its own, where an exact comparison is possible. - * - * **What `^` compiles to, read from the 2.1.12 binary.** A non-integral exponent - * becomes `NoiseOperations::BinaryOperation<22, &NoiseOperations::Functions::pow>`, - * and `Functions::pow` (`0x10176d234`) is a single instruction - an unconditional - * `b` to `Math::powSafe(float, float)` (`0x102955a88`), which inlines fastapprox - * `log2` and multiplies by the exponent at single precision (`fmul s0, s0, s1`). - * An INTEGRAL exponent takes a different path entirely: `powSafe` round-trips it - * through `fcvtzs`/`scvtf` and, when it survives, uses exponentiation by squaring - * and never touches fastapprox. Hence the `2` series below - it pins a path our - * port must NOT route through `fastPow`. - * - * **The positions are chosen adversarially, which is the whole point.** A plain - * grid does not discriminate: 12 evenly spaced points scored 12/12 for both - * candidate exponents, and only `Math.cbrt` (0/12) failed. Two hand-picked sets - * are therefore included, each listing positions where two rival implementations - * are known to differ, so a wrong one cannot score full marks: - * - * - `CBRT_EXPONENT_SPLIT` - where `fastPow(x, 1/3)` with a DOUBLE `1/3` differs - * from `f32(1/3)`. Measured verdict: the double scores **0/24**, `f32(1/3)` - * scores **24/24**. That settled #163 against the game rather than against the - * disassembly alone. - * - `ROUNDING_SPLIT` - where the pre-`9b49ebb` single-rounding `fastApprox` - * differs from the per-operation rounding that replaced it. The two disagree on - * ~30% of inputs, so these are easy to find and brutal to fail. - * - * The arithmetic spread is kept as well, so the fixture is not composed purely of - * pathological points. - */ -async function captureFastPow(): Promise { - const seed = 123456; - - /** - * Positions where a double `1/3` and `f32(1/3)` give different f32 results. - * Found by sweeping `x = 1.5, 2.5, ...` and keeping the first 24 disagreements; - * ~3.0% of that range disagrees, so a plain grid usually misses them all. - */ - const CBRT_EXPONENT_SPLIT = [ - 23.5, 47.5, 73.5, 85.5, 114.5, 210.5, 395.5, 573.5, 591.5, 672.5, 674.5, 677.5, 696.5, 752.5, - 879.5, 883.5, 983.5, 1008.5, 1080.5, 1083.5, 1159.5, 1199.5, 1219.5, 1249.5, - ]; - - /** Positions where single-rounding and per-operation-rounding fastapprox differ. */ - const ROUNDING_SPLIT = [ - 3.5, 5.5, 6.5, 7.5, 15.5, 20.5, 21.5, 22.5, 25.5, 26.5, 32.5, 38.5, 39.5, 43.5, 45.5, 1.5, 2.5, - 10.5, 12.5, 19.5, 27.5, 28.5, 30.5, 36.5, 37.5, 41.5, 4.5, 8.5, 9.5, 11.5, 13.5, 16.5, 17.5, - 18.5, - ]; - - /** A plain spread across four decades, so the set is not all pathological. */ - const spread: number[] = []; - for (let k = 0; k < 40; k++) spread.push(1.5 + k * 37); - for (const m of [211, 1553, 9377]) for (let k = 0; k < 10; k++) spread.push(1.5 + k * m); - - const xs = [...new Set([...CBRT_EXPONENT_SPLIT, ...ROUNDING_SPLIT, ...spread])].sort( - (a, b) => a - b, - ); - const positions: Position[] = xs.map((x) => ({ x, y: 0.5 })); - - const EXPONENTS = [ - { label: "1/3", expression: "(1/3)", note: "the shipping cube root - fastCbrt" }, - { label: "0.5", expression: "0.5", note: "non-integral < 1" }, - { label: "2.5", expression: "2.5", note: "non-integral > 1" }, - { label: "2", expression: "2", note: "INTEGRAL - powSafe uses exact squaring, not fastapprox" }, - ]; - - const series: { exponent: string; note: string; expression: string; values: number[] }[] = []; - for (const e of EXPONENTS) { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const expression = `x ^ ${e.expression}`; - const values = await sampleExpression(expression, positions, { workDir, seed }); - series.push({ exponent: e.label, note: e.note, expression, values }); - console.log(` captured x ^ ${e.label} (${String(positions.length)} positions)`); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 (build 87038) via the test/oracle harness. The noise " + - "machine's `^` operator sampled directly, as `x ^ ` routed onto elevation, so " + - "fastApprox can be compared f32-EXACT instead of through a tolerance on a downstream " + - "chain. Non-integral exponents reach Math::powSafe -> fastapprox log2/exp2; the `2` " + - "series takes powSafe's integral fast path (exponentiation by squaring) and must NOT be " + - "reproduced with fastPow. Positions are deliberately adversarial - they include points " + - "where a double 1/3 differs from f32(1/3), and points where single-rounding fastapprox " + - "differs from per-operation rounding. Regenerate: node --experimental-strip-types " + - "test/oracle/capture.ts fastpow", - seed0: seed, - positions: positions.map((p) => ({ x: p.x, y: p.y })), - series, - }; - const out = join(FIXTURES, "oracle-fastpow.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${String(series.length)} series x ${String(positions.length)})`); -} - -async function captureStartingSpotAtAngle(): Promise { - const seed = 123456; - const planet = "vulcanus"; - const positions = gridPositions(); - const configs = [ - { angle: 90, distance: 170, radius: 350, xDistortion: 0, yDistortion: 0 }, - { angle: 0, distance: 100, radius: 200, xDistortion: 0, yDistortion: 0 }, - { angle: 180, distance: 50, radius: 500, xDistortion: 20, yDistortion: -15 }, - { angle: 45, distance: 300, radius: 400, xDistortion: -10, yDistortion: 30 }, - ]; - const cases = []; - for (const c of configs) { - const expression = `starting_spot_at_angle{angle = ${c.angle}, distance = ${c.distance}, radius = ${c.radius}, x_distortion = ${c.xDistortion}, y_distortion = ${c.yDistortion}}`; - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const values = await sampleExpression(expression, positions, { - workDir, - seed, - spaceAge: true, - planet, - }); - cases.push({ ...c, values }); - console.log(` captured starting_spot_at_angle angle=${c.angle} distance=${c.distance}`); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 (Space Age enabled) via the test/oracle harness. starting_spot_at_angle routed onto elevation, sampled over the standard scattered grid across 4 angle/distance/radius/distortion configs, against a real Vulcanus surface (game.planets['vulcanus'].create_surface()). x_from_start/y_from_start resolve to the raw world (x, y) at this default origin spawn (Task 2 finding). Regenerate: node --experimental-strip-types test/oracle/capture.ts starting-spot", - seed0: seed, - planet, - positions, - cases, - }; - const out = join(FIXTURES, "oracle-starting-spot.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${configs.length} configs x ${positions.length} points)`); -} - -/** - * Task 5's three leaf helper closures - `vulcanus_wobble_x`, `mountain_plasma` - * (= `vulcanus_plasma(102, 2.5, 10, 125, 625)`), and - * `vulcanus_detail_noise(837, 1/40, 4, 1.25)` - plus `vulcanus_scale_multiplier` - * (= `slider_rescale(control:vulcanus_volcanism:frequency, 3)`) sampled at the - * DEFAULT preset (no autoplace_controls override), so the fixture also pins the - * neutral control default the ctx extension assumes. All routed onto elevation - * against a real Vulcanus surface ({ spaceAge: true, planet: "vulcanus" }), over - * the standard scattered grid. - */ -async function captureVulcanusHelpers(): Promise { - const seed = 123456; - const planet = "vulcanus"; - const positions = gridPositions(); - - const sample = async (expression: string): Promise => { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - return await sampleExpression(expression, positions, { - workDir, - seed, - spaceAge: true, - planet, - }); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - }; - - const wobbleX = await sample("vulcanus_wobble_x"); - console.log(" captured vulcanus_wobble_x"); - const mountainPlasma = await sample("vulcanus_plasma(102, 2.5, 10, 125, 625)"); - console.log(" captured mountain_plasma"); - const detailNoise = await sample("vulcanus_detail_noise(837, 1/40, 4, 1.25)"); - console.log(" captured vulcanus_detail_noise(837, 1/40, 4, 1.25)"); - const scaleMultiplier = await sample("vulcanus_scale_multiplier"); - console.log(" captured vulcanus_scale_multiplier (default control)"); - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 (Space Age enabled) via the test/oracle harness. Task 5's three leaf helper closures (vulcanus_wobble_x, mountain_plasma = vulcanus_plasma(102,2.5,10,125,625), vulcanus_detail_noise(837,1/40,4,1.25)) plus vulcanus_scale_multiplier (= slider_rescale(control:vulcanus_volcanism:frequency, 3), sampled at the DEFAULT preset - no autoplace_controls override - to pin the neutral control value), each routed onto elevation over the standard scattered grid, against a real Vulcanus surface (game.planets['vulcanus'].create_surface()). Regenerate: node --experimental-strip-types test/oracle/capture.ts vulcanus-helpers", - seed0: seed, - planet, - positions, - wobbleX, - mountainPlasma, - detailNoise, - scaleMultiplier, - }; - const out = join(FIXTURES, "oracle-vulcanus-helpers.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${positions.length} points)`); -} - -/** - * Task 6's seed-derived radial spawn geometry: `vulcanus_starting_area`, - * `vulcanus_starting_circle`, and `vulcanus_ashlands_start` (the smallest/most - * distortion-sensitive of the three `*_start` blobs), each routed onto elevation - * against a real Vulcanus surface. The grid spans spawn densely (fine step near - * the origin, where the blobs and the falloff of `starting_circle` actually live) - * and out to +-800 tiles (coarser step) so the falloff to 0/1 on every side is - * captured too. - */ -async function captureVulcanusSpawn(): Promise { - const seed = 123456; - const planet = "vulcanus"; - const positions: Position[] = []; - // Fine grid near spawn (where the blobs and starting_circle falloff live). - for (let gy = -256; gy <= 256; gy += 32) { - for (let gx = -256; gx <= 256; gx += 32) { - positions.push({ x: gx + 0.5, y: gy + 0.25 }); - } - } - // Coarser grid spanning out to +-800, so the falloff to 0 (starting_area) / - // the linear tail (starting_circle) is exercised well beyond the blobs. - for (let gy = -800; gy <= 800; gy += 160) { - for (let gx = -800; gx <= 800; gx += 160) { - positions.push({ x: gx + 0.125, y: gy + 0.375 }); - } - } - - const sample = async (expression: string): Promise => { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - return await sampleExpression(expression, positions, { - workDir, - seed, - spaceAge: true, - planet, - }); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - }; - - const startingArea = await sample("vulcanus_starting_area"); - console.log(" captured vulcanus_starting_area"); - const startingCircle = await sample("vulcanus_starting_circle"); - console.log(" captured vulcanus_starting_circle"); - const ashlandsStart = await sample("vulcanus_ashlands_start"); - console.log(" captured vulcanus_ashlands_start"); - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.11 (Space Age enabled) via the test/oracle harness. Task 6's seed-derived radial spawn geometry: vulcanus_starting_area, vulcanus_starting_circle, and vulcanus_ashlands_start, each routed onto elevation over a grid spanning spawn (fine step -256..256/32 plus a coarser -800..800/160 span), against a real Vulcanus surface (game.planets['vulcanus'].create_surface()). Regenerate: node --experimental-strip-types test/oracle/capture.ts vulcanus-spawn", - seed0: seed, - planet, - positions, - startingArea, - startingCircle, - ashlandsStart, - }; - const out = join(FIXTURES, "oracle-vulcanus-spawn.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${positions.length} points)`); -} - -/** - * Task 8's crack/flood helpers - `vulcanus_hairline_cracks`, `vulcanus_flood_cracks_a`, - * `vulcanus_flood_cracks_b`, `vulcanus_flood_paths`, `vulcanus_flood_basalts_func` - - * each routed onto elevation over a scattered near+far grid, against a real Vulcanus - * surface ({ spaceAge: true, planet: "vulcanus" }). These are pure noise (no spawn - * dependency), so a scattered grid spanning near-origin and deep-field suffices. - * Regenerate: node --experimental-strip-types test/oracle/capture.ts vulcanus-cracks - */ -async function captureVulcanusCracks(): Promise { - const seed = 123456; - const planet = "vulcanus"; - const positions: Position[] = []; - for (let gy = 0; gy < 6; gy++) { - for (let gx = 0; gx < 6; gx++) { - positions.push({ x: gx * 13 - 30 + 0.5, y: gy * 17 - 40 + 0.25 }); - } - } - for (const r of [500, 1500, 3300]) { - for (let k = 0; k < 8; k++) { - const a = (k * Math.PI) / 4; - positions.push({ - x: snapToMapPosition(r * Math.cos(a) + 0.5), - y: snapToMapPosition(r * Math.sin(a) + 0.25), - }); - } - } - positions.push({ x: 12345.75, y: 6789.125 }); - - const sample = async (expression: string): Promise => { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - return await sampleExpression(expression, positions, { - workDir, - seed, - spaceAge: true, - planet, - }); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - }; - - const hairlineCracks = await sample("vulcanus_hairline_cracks"); - console.log(" captured vulcanus_hairline_cracks"); - const floodCracksA = await sample("vulcanus_flood_cracks_a"); - console.log(" captured vulcanus_flood_cracks_a"); - const floodCracksB = await sample("vulcanus_flood_cracks_b"); - console.log(" captured vulcanus_flood_cracks_b"); - const floodPaths = await sample("vulcanus_flood_paths"); - console.log(" captured vulcanus_flood_paths"); - const floodBasaltsFunc = await sample("vulcanus_flood_basalts_func"); - console.log(" captured vulcanus_flood_basalts_func"); - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 (Space Age enabled) via the test/oracle harness. Task 8's crack/flood helpers (vulcanus_hairline_cracks, vulcanus_flood_cracks_a, vulcanus_flood_cracks_b, vulcanus_flood_paths, vulcanus_flood_basalts_func), each routed onto elevation over a scattered near+far grid, against a real Vulcanus surface (game.planets['vulcanus'].create_surface()). Regenerate: node --experimental-strip-types test/oracle/capture.ts vulcanus-cracks", - seed0: seed, - planet, - positions, - hairlineCracks, - floodCracksA, - floodCracksB, - floodPaths, - floodBasaltsFunc, - }; - const out = join(FIXTURES, "oracle-vulcanus-cracks.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${positions.length} points)`); -} - -async function captureVulcanusResources(): Promise { - const seed = 123456; - const planet = "vulcanus"; - const positions: Position[] = []; - for (let gy = 0; gy < 6; gy++) { - for (let gx = 0; gx < 6; gx++) { - positions.push({ x: gx * 13 - 30 + 0.5, y: gy * 17 - 40 + 0.25 }); - } - } - for (const r of [500, 1500, 3300]) { - for (let k = 0; k < 8; k++) { - const a = (k * Math.PI) / 4; - positions.push({ - x: snapToMapPosition(r * Math.cos(a) + 0.5), - y: snapToMapPosition(r * Math.sin(a) + 0.25), - }); - } - } - positions.push({ x: 12345.75, y: 6789.125 }); - - // Fix round 1 (2026-07-24): the original 61 scattered points never landed - // inside an actual ore/acid region (region > 0 nowhere), so the fixture - // couldn't discriminate a real spot-selection port from a stub. Ore patches - // are ~25-30 tiles in radius and sparse, so append a dense scan grid - a - // 32x32 grid at a 137-tile stride (deliberately incommensurate with the - // 400/450/1000-tile region_sizes), centered on the origin, offset like the - // others (+0.5 x, +0.25 y) - to actually hit ore. The original 61 positions - // are kept, in order, first; the scan grid is appended after them. - for (let gy = 0; gy < 32; gy++) { - for (let gx = 0; gx < 32; gx++) { - positions.push({ x: (gx - 16) * 137 + 0.5, y: (gy - 16) * 137 + 0.25 }); - } - } - - const sample = async (expression: string): Promise => { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - return await sampleExpression(expression, positions, { - workDir, - seed, - spaceAge: true, - planet, - }); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - }; - - const named: Record = { - basaltsFavorability: "vulcanus_basalts_resource_favorability", - mountainsFavorability: "vulcanus_mountains_resource_favorability", - mountainsSulfurFavorability: "vulcanus_mountains_sulfur_favorability", - ashlandsFavorability: "vulcanus_ashlands_resource_favorability", - startingTungsten: "vulcanus_starting_tungsten", - startingCoal: "vulcanus_starting_coal", - startingCalcite: "vulcanus_starting_calcite", - startingSulfur: "vulcanus_starting_sulfur", - tungstenRegion: "vulcanus_tungsten_ore_region", - coalRegion: "vulcanus_coal_region", - calciteRegion: "vulcanus_calcite_region", - sulfuricAcidRegion: "vulcanus_sulfuric_acid_region", - sulfuricAcidPatches: "vulcanus_sulfuric_acid_patches", - sulfuricAcidRegionPatchy: "vulcanus_sulfuric_acid_region_patchy", - metalTile: "vulcanus_metal_tile", - }; - - const captured: Record = {}; - for (const [key, expression] of Object.entries(named)) { - captured[key] = await sample(expression); - console.log(` captured ${expression}`); - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 (Space Age enabled) via the test/oracle harness. Vulcanus V2 resource expressions (favorabilities, starting spots, the four regions, the sulfuric-acid patchy chain and vulcanus_metal_tile), each routed onto elevation against a real Vulcanus surface (game.planets['vulcanus'].create_surface()) with default control sliders. positions is two parts, in order: the original 61-point scattered near+far grid (a 6x6 near block plus three 8-point rings at r=500/1500/3300 plus one deep-field point, carrying the favorability/starting-spot coverage and the far-field f32 floor case), then a 1024-point 32x32 dense scan grid at a 137-tile stride centered on the origin (added in a fix round because the original 61 points never landed inside an actual ore/acid region - region > 0 nowhere - so the fixture could not discriminate a real spot-selection port from a stub). Regenerate: node --experimental-strip-types test/oracle/capture.ts vulcanus-resources", - seed0: seed, - planet, - positions, - ...captured, - }; - const out = join(FIXTURES, "oracle-vulcanus-resources.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${positions.length} points)`); -} - -/** - * Task 8's biome system + volcano spots: the three clamped biomes - * (vulcanus_mountains_biome, vulcanus_ashlands_biome, vulcanus_basalts_biome), their - * unclamped _full variants, mountain_volcano_spots, and vulcanus_mountains_raw_volcano, - * each routed onto elevation, against a real Vulcanus surface. The grid spans spawn - * (fine -256..256/32 plus a coarser -800..800/160 span, where starting_area / - * starting_protector / the starting volcano spot are live) AND far rings at - * r=1500/3000 (where the biome-noise multiscale and the whole-map volcano spot field - * dominate). Regenerate: node --experimental-strip-types test/oracle/capture.ts vulcanus-biomes - */ -async function captureVulcanusBiomes(): Promise { - const seed = 123456; - const planet = "vulcanus"; - const positions: Position[] = []; - for (let gy = -256; gy <= 256; gy += 32) { - for (let gx = -256; gx <= 256; gx += 32) { - positions.push({ x: gx + 0.5, y: gy + 0.25 }); - } - } - for (let gy = -800; gy <= 800; gy += 160) { - for (let gx = -800; gx <= 800; gx += 160) { - positions.push({ x: gx + 0.125, y: gy + 0.375 }); - } - } - for (const r of [1500, 3000]) { - for (let k = 0; k < 12; k++) { - const a = (k * Math.PI) / 6; - positions.push({ - x: snapToMapPosition(r * Math.cos(a) + 0.5), - y: snapToMapPosition(r * Math.sin(a) + 0.25), - }); - } - } - - const sample = async (expression: string): Promise => { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - return await sampleExpression(expression, positions, { - workDir, - seed, - spaceAge: true, - planet, - }); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - }; - - const values: Record = {}; - for (const name of [ - "mountain_volcano_spots", - "vulcanus_mountains_raw_volcano", - "vulcanus_mountains_biome_full", - "vulcanus_ashlands_biome_full", - "vulcanus_basalts_biome_full", - "vulcanus_mountains_biome", - "vulcanus_ashlands_biome", - "vulcanus_basalts_biome", - ]) { - values[name] = await sample(name); - console.log(` captured ${name}`); - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 (Space Age enabled) via the test/oracle harness. Task 8's biome system + volcano spots (mountain_volcano_spots, vulcanus_mountains_raw_volcano, the three _full variants, the three clamped biomes), each routed onto elevation over a grid spanning spawn (fine -256..256/32 plus a coarser -800..800/160 span) and far rings at r=1500/3000, against a real Vulcanus surface (game.planets['vulcanus'].create_surface()). Regenerate: node --experimental-strip-types test/oracle/capture.ts vulcanus-biomes", - seed0: seed, - planet, - positions, - values, - }; - const out = join(FIXTURES, "oracle-vulcanus-biomes.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${positions.length} points)`); -} - -/** - * Task 7's climate fields, `vulcanus_aux` and `vulcanus_moisture` (`vulcanus_temperature` - * is deferred to a later task - it depends on `vulcanus_elev`, which does not exist - * yet). Each routed onto elevation over the same near+far scattered grid used for - * Task 8's cracks (they consume `vulcanus_flood_paths`/`vulcanus_flood_cracks_a`), - * against a real Vulcanus surface. Regenerate: - * node --experimental-strip-types test/oracle/capture.ts vulcanus-climate - */ -async function captureVulcanusClimate(): Promise { - const seed = 123456; - const planet = "vulcanus"; - const positions: Position[] = []; - for (let gy = 0; gy < 6; gy++) { - for (let gx = 0; gx < 6; gx++) { - positions.push({ x: gx * 13 - 30 + 0.5, y: gy * 17 - 40 + 0.25 }); - } - } - for (const r of [500, 1500, 3300]) { - for (let k = 0; k < 8; k++) { - const a = (k * Math.PI) / 4; - positions.push({ - x: snapToMapPosition(r * Math.cos(a) + 0.5), - y: snapToMapPosition(r * Math.sin(a) + 0.25), - }); - } - } - positions.push({ x: 12345.75, y: 6789.125 }); - - const sample = async (expression: string): Promise => { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - return await sampleExpression(expression, positions, { - workDir, - seed, - spaceAge: true, - planet, - }); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - }; - - const aux = await sample("vulcanus_aux"); - console.log(" captured vulcanus_aux"); - const moisture = await sample("vulcanus_moisture"); - console.log(" captured vulcanus_moisture"); - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 (Space Age enabled) via the test/oracle harness. Task 7's climate fields (vulcanus_aux, vulcanus_moisture - vulcanus_temperature deferred, depends on vulcanus_elev which doesn't exist yet), each routed onto elevation over a scattered near+far grid, against a real Vulcanus surface (game.planets['vulcanus'].create_surface()). Regenerate: node --experimental-strip-types test/oracle/capture.ts vulcanus-climate", - seed0: seed, - planet, - positions, - aux, - moisture, - }; - const out = join(FIXTURES, "oracle-vulcanus-climate.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${positions.length} points)`); -} - -/** - * Task 9's elevation surface: `vulcanus_elevation` (= `max(-500, vulcanus_elev)`) and - * the raw `vulcanus_elev` it clamps (temperature reads the RAW value, so both are - * pinned). Each routed onto elevation, against a real Vulcanus surface. Grid spans - * spawn (fine -256..256/32 plus a coarser -800..800/160 span, where the biome - * blend / starting geometry are live) AND far rings at r=1500/3000, matching the - * biome capture so the two fixtures are directly comparable. Regenerate: - * node --experimental-strip-types test/oracle/capture.ts vulcanus-elevation - */ -async function captureVulcanusElevation(): Promise { - const seed = 123456; - const planet = "vulcanus"; - const positions: Position[] = []; - for (let gy = -256; gy <= 256; gy += 32) { - for (let gx = -256; gx <= 256; gx += 32) { - positions.push({ x: gx + 0.5, y: gy + 0.25 }); - } - } - for (let gy = -800; gy <= 800; gy += 160) { - for (let gx = -800; gx <= 800; gx += 160) { - positions.push({ x: gx + 0.125, y: gy + 0.375 }); - } - } - for (const r of [1500, 3000]) { - for (let k = 0; k < 12; k++) { - const a = (k * Math.PI) / 6; - positions.push({ - x: snapToMapPosition(r * Math.cos(a) + 0.5), - y: snapToMapPosition(r * Math.sin(a) + 0.25), - }); - } - } - - const sample = async (expression: string): Promise => { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - return await sampleExpression(expression, positions, { - workDir, - seed, - spaceAge: true, - planet, - }); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - }; - - const elev = await sample("vulcanus_elev"); - console.log(" captured vulcanus_elev"); - const elevation = await sample("vulcanus_elevation"); - console.log(" captured vulcanus_elevation"); - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 (Space Age enabled) via the test/oracle harness. Task 9's elevation surface: vulcanus_elev (raw, read by temperature) and vulcanus_elevation (= max(-500, vulcanus_elev)), each routed onto elevation over a grid spanning spawn (fine -256..256/32 plus a coarser -800..800/160 span) and far rings at r=1500/3000, against a real Vulcanus surface (game.planets['vulcanus'].create_surface()). Regenerate: node --experimental-strip-types test/oracle/capture.ts vulcanus-elevation", - seed0: seed, - planet, - positions, - elev, - elevation, - }; - const out = join(FIXTURES, "oracle-vulcanus-elevation.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${positions.length} points)`); -} - -/** - * Task 9's `vulcanus_temperature` (deferred out of Task 7 until `vulcanus_elev` - * existed). Depends on the raw elev, moisture, aux, ashlands_biome and - * mountain_volcano_spots. Same grid as captureVulcanusElevation, against a real - * Vulcanus surface at the DEFAULT preset (control:temperature:bias = 0). Regenerate: - * node --experimental-strip-types test/oracle/capture.ts vulcanus-temperature - */ -async function captureVulcanusTemperature(): Promise { - const seed = 123456; - const planet = "vulcanus"; - const positions: Position[] = []; - for (let gy = -256; gy <= 256; gy += 32) { - for (let gx = -256; gx <= 256; gx += 32) { - positions.push({ x: gx + 0.5, y: gy + 0.25 }); - } - } - for (let gy = -800; gy <= 800; gy += 160) { - for (let gx = -800; gx <= 800; gx += 160) { - positions.push({ x: gx + 0.125, y: gy + 0.375 }); - } - } - for (const r of [1500, 3000]) { - for (let k = 0; k < 12; k++) { - const a = (k * Math.PI) / 6; - positions.push({ - x: snapToMapPosition(r * Math.cos(a) + 0.5), - y: snapToMapPosition(r * Math.sin(a) + 0.25), - }); - } - } - - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const temperature = await sampleExpression("vulcanus_temperature", positions, { - workDir, - seed, - spaceAge: true, - planet, - }); - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 (Space Age enabled) via the test/oracle harness. Task 9's vulcanus_temperature (deferred out of Task 7 until vulcanus_elev existed) routed onto elevation over a grid spanning spawn (fine -256..256/32 plus a coarser -800..800/160 span) and far rings at r=1500/3000, at the DEFAULT preset (control:temperature:bias = 0), against a real Vulcanus surface (game.planets['vulcanus'].create_surface()). Regenerate: node --experimental-strip-types test/oracle/capture.ts vulcanus-temperature", - seed0: seed, - planet, - positions, - temperature, - }; - const out = join(FIXTURES, "oracle-vulcanus-temperature.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${positions.length} points)`); - } finally { - await rm(workDir, { recursive: true, force: true }); - } -} - -/** - * The `get_tile` tile-name oracle for VULCANUS (Task 10): the Space-Age sibling of - * `captureTileNames`. Reuses the same real-chunk-generate path - * (`sampleTileNames`), but with `{ spaceAge: true, planet: "vulcanus" }` so tiles - * are read from a real Vulcanus surface (`game.planets["vulcanus"].create_surface()`) - * instead of Nauvis. A golden-angle spiral from spawn out to ~2600 tiles spans the - * radial Vulcanus biomes (mountains disc near center, then basalts/ashlands rings), - * plus a dense near-spawn square grid. Seed 123456. Validates the tile argmax + - * map_color port. Regenerate: node --experimental-strip-types test/oracle/capture.ts - * vulcanus-tile-names - */ -/** - * **A DENSE tile-name capture at the lava boundaries the cliff rejection reads** - * (issue #84, the lava-perimeter thread). - * - * `oracle-vulcanus-tile-names` is a sparse survey - a 64-tile grid plus a - * golden-angle spiral - and its lava classification is exact on all 381 of its - * positions. That exactness is real but it cannot settle a SUB-TILE boundary - * question: its sensitivity was measured by planting scale factors on `lava`'s - * probability, and `1.02` and `1.2` both still pass. Sparse positions simply do - * not sit close enough to a boundary in probability space. - * - * The negative-space oracle in `vulcanusCliffEntities.spec.ts` says the boundary - * IS off somewhere: 13 real cliffs the game placed have our lava inside their - * collision box, and the game ran that same rejection and kept them. The 35 - * distinct tiles responsible are the seeds here, each expanded to a Chebyshev - * radius-4 neighbourhood so the SHAPE of the disagreement is visible - whether - * our blob is a uniform tile too fat, fat only on one side, or something else. - * - * These are deliberately the hardest positions on the map for the resolver - * rather than a representative sample, so the agreement rate here is not - * comparable with the survey's and is not meant to be. - * - * Regenerate: node --experimental-strip-types test/oracle/capture.ts - * vulcanus-lava-boundary - */ -async function captureVulcanusLavaBoundary(): Promise { - const seed = 123456; - const planet = "vulcanus"; - // The 35 tiles our mask calls lava inside a REAL cliff's collision box, - // dumped from the placement itself (see the spec that consumes this fixture). - const seeds: readonly (readonly [number, number])[] = [ - [88, 41], - [89, 40], - [89, 41], - [85, 45], - [86, 43], - [86, 44], - [87, 42], - [87, 43], - [87, 44], - [82, 48], - [83, 47], - [83, 48], - [24, 181], - [25, 181], - [4, 187], - [5, 187], - [6, 187], - [7, 187], - [1637, 1598], - [1693, 1599], - [1693, 1600], - [1694, 1599], - [1694, 1600], - [1695, 1600], - [1696, 1600], - [1697, 1600], - [1635, 1599], - [1636, 1599], - [1663, 1636], - [1663, 1637], - [1521, 1680], - [-1052, 1016], - [-1051, 1016], - [-1051, 1017], - [-1061, 1029], - ]; - const RADIUS = 4; - const seen = new Set(); - const positions: Position[] = []; - for (const [sx, sy] of seeds) - for (let dx = -RADIUS; dx <= RADIUS; dx++) - for (let dy = -RADIUS; dy <= RADIUS; dy++) { - const x = sx + dx; - const y = sy + dy; - const k = `${String(x)},${String(y)}`; - if (seen.has(k)) continue; - seen.add(k); - // Sample the tile's own integer coordinate; `sampleTileNames` echoes the - // floored `get_tile` input back, so the fixture records what was asked. - positions.push({ x: x + 0.5, y: y + 0.5 }); - } - - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const samples: TileSample[] = await sampleTileNames(positions, { - workDir, - seed, - spaceAge: true, - planet, - }); - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 (Space Age enabled) via the test/oracle harness. surface.get_tile(x, y).name on a real Vulcanus surface (game.planets['vulcanus'].create_surface(), seed 123456) after real chunk generation. DENSE: Chebyshev radius-4 neighbourhoods around the 35 tiles our lava mask wrongly places inside a real cliff's collision box, so the boundary error's shape is visible. Deliberately the hardest positions for the resolver, NOT a representative sample - the agreement rate here is not comparable with oracle-vulcanus-tile-names. positions are the mod's ECHOED floored get_tile input. Regenerate: node --experimental-strip-types test/oracle/capture.ts vulcanus-lava-boundary", - seed0: seed, - planet, - seeds: seeds.map(([x, y]) => ({ x, y })), - radius: RADIUS, - positions: samples.map((s) => ({ x: s.x, y: s.y })), - tileNames: samples.map((s) => s.name), - }; - const out = join(FIXTURES, "oracle-vulcanus-lava-boundary.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - const distinct = [...new Set(fixture.tileNames)].sort(); - console.log( - `wrote ${out} (${String(positions.length)} points, ${String(distinct.length)} distinct tiles: ${distinct.join(", ")})`, - ); - } finally { - await rm(workDir, { recursive: true, force: true }); - } -} - -async function captureVulcanusTileNames(): Promise { - const seed = 123456; - const planet = "vulcanus"; - const positions: Position[] = []; - // Dense near-spawn grid (the mountains/volcano biome disc). - for (let gy = -320; gy <= 320; gy += 64) { - for (let gx = -320; gx <= 320; gx += 64) { - positions.push({ x: gx + 0.5, y: gy + 0.25 }); - } - } - // Golden-angle spiral out to ~2600 tiles to cross basalts + ashlands rings. - const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); - const count = 260; - for (let i = 0; i < count; i++) { - const t = (i + 0.5) / count; - const r = 120 + t * (2600 - 120); - const a = i * GOLDEN_ANGLE; - positions.push({ - x: snapToMapPosition(r * Math.cos(a) + 0.5), - y: snapToMapPosition(r * Math.sin(a) + 0.25), - }); - } - - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const samples: TileSample[] = await sampleTileNames(positions, { - workDir, - seed, - radius: 1, - spaceAge: true, - planet, - }); - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 (Space Age enabled) via the test/oracle harness. surface.get_tile(x, y).name on a real Vulcanus surface (game.planets['vulcanus'].create_surface(), seed 123456) after real chunk generation, over a near-spawn grid + a golden-angle spiral to ~2600 tiles spanning the radial biomes. positions are the mod's ECHOED floored get_tile input. Regenerate: node --experimental-strip-types test/oracle/capture.ts vulcanus-tile-names", - seed0: seed, - planet, - positions: samples.map((s) => ({ x: s.x, y: s.y })), - tileNames: samples.map((s) => s.name), - }; - const out = join(FIXTURES, "oracle-vulcanus-tile-names.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - const distinct = [...new Set(fixture.tileNames)].sort(); - console.log( - `wrote ${out} (${positions.length} points, ${distinct.length} distinct tiles: ${distinct.join(", ")})`, - ); - } finally { - await rm(workDir, { recursive: true, force: true }); - } -} - -/** - * Vulcanus cliffs: `cliffiness_basic` (the planet's `cliffiness` property, per - * `planet-map-gen.lua:13`) plus `cliff_richness`, which that expression reads. - * - * `cliff_richness` is captured deliberately rather than assumed. Vulcanus has no - * cliff autoplace control - `space-age/prototypes/autoplace-controls.lua` defines - * `gleba_cliff` and `fulgora_cliff` but no Vulcanus equivalent - so the port pins - * it at 1, and this fixture is what makes that a measurement instead of a - * reading of the Lua. - * - * The planet's other cliff property, `cliff_elevation`, is - * `cliff_elevation_from_elevation`, whose expression is literally `elevation`. - * It is NOT sampled here: the harness routes the probe *at* the `elevation` - * property, so probing it would be circular. It is covered instead by - * `oracle-vulcanus-elevation.seed123456.json`, since the two are the same field. - * - * Grid spans spawn finely and adds far rings, matching the biome capture, since - * `cliffiness_basic` is a plain 2-octave noise with no distance term and the far - * points mainly guard against a seeding mistake that only shows up off-origin. - * Regenerate: node --experimental-strip-types test/oracle/capture.ts vulcanus-cliffs - */ -async function captureVulcanusCliffs(): Promise { - const seed = 123456; - const planet = "vulcanus"; - const positions: Position[] = []; - for (let gy = -256; gy <= 256; gy += 32) { - for (let gx = -256; gx <= 256; gx += 32) { - positions.push({ x: gx + 0.5, y: gy + 0.25 }); - } - } - for (let gy = -800; gy <= 800; gy += 160) { - for (let gx = -800; gx <= 800; gx += 160) { - positions.push({ x: gx + 0.125, y: gy + 0.375 }); - } - } - // Ring coordinates are SNAPPED to 1/256, unlike the biome/resource captures - // which push raw `r * cos(a)` values. MapPosition is 1/256 fixed point, so the - // game stores the snapped value either way - but the fixture then records the - // unsnapped one, and the port evaluates there. Usually that costs ~1e-4 (the - // offset the V2 notes describe for sulfuricAcidPatches). It can cost far more: - // captured unsnapped, this fixture's k=9 r=3000 point came out at - // x = 0.4999999999994489, which sits within 5.5e-13 of a noise lattice - // boundary. The game's f32 and our f64 land on opposite sides of the floor() - // there, and the residual jumps from 2.75e-7 (at an exact 0.5) to 3.74e-4 - - // a knife-edge artifact of the probe position, not of the port. Snapping - // removes it and lets this spec assert a tight bound everywhere. - const snap = (v: number): number => Math.round(v * 256) / 256; - for (const r of [1500, 3000]) { - for (let k = 0; k < 12; k++) { - const a = (k * Math.PI) / 6; - positions.push({ x: snap(r * Math.cos(a) + 0.5), y: snap(r * Math.sin(a) + 0.25) }); - } - } - - const sample = async (expression: string): Promise => { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - return await sampleExpression(expression, positions, { - workDir, - seed, - spaceAge: true, - planet, - }); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - }; - - const values: Record = {}; - for (const name of ["cliffiness_basic", "cliff_richness"]) { - values[name] = await sample(name); - console.log(` captured ${name}`); - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 (Space Age enabled) via the test/oracle harness. Vulcanus's cliffiness property (cliffiness_basic) and the cliff_richness it reads, routed onto elevation over a grid spanning spawn (fine -256..256/32 plus a coarser -800..800/160 span) and far rings at r=1500/3000, against a real Vulcanus surface (game.planets['vulcanus'].create_surface()). cliff_elevation is deliberately absent: it resolves to `elevation`, which the probe itself occupies, and is covered by oracle-vulcanus-elevation. Regenerate: node --experimental-strip-types test/oracle/capture.ts vulcanus-cliffs", - seed0: seed, - planet, - positions, - values, - }; - const out = join(FIXTURES, "oracle-vulcanus-cliffs.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${positions.length} points)`); -} - -/** - * Vulcanus rocks: the two probability expressions its rock ENTITIES use - * (`vulcanus_rock_huge`, `vulcanus_rock_big` - the `-hot` variants reuse them) - * and the `vulcanus_decorative_knockout` noise they both read. - * - * The four decorative-only siblings (`vulcanus_rock_medium/cluster/small/tiny`) - * are not captured: the game's map preview charts entities, not decoratives, so - * the overlay does not use them. - * - * Ring coordinates are snapped to 1/256 for the reason documented on - * `captureVulcanusCliffs` - unsnapped ring positions can land within ~1e-12 of a - * noise lattice boundary and inflate the residual by three orders of magnitude. - * Regenerate: node --experimental-strip-types test/oracle/capture.ts vulcanus-rocks - */ -async function captureVulcanusRocks(): Promise { - const seed = 123456; - const planet = "vulcanus"; - const positions: Position[] = []; - for (let gy = -256; gy <= 256; gy += 32) { - for (let gx = -256; gx <= 256; gx += 32) { - positions.push({ x: gx + 0.5, y: gy + 0.25 }); - } - } - for (let gy = -800; gy <= 800; gy += 160) { - for (let gx = -800; gx <= 800; gx += 160) { - positions.push({ x: gx + 0.125, y: gy + 0.375 }); - } - } - const snap = (v: number): number => Math.round(v * 256) / 256; - for (const r of [1500, 3000]) { - for (let k = 0; k < 12; k++) { - const a = (k * Math.PI) / 6; - positions.push({ x: snap(r * Math.cos(a) + 0.5), y: snap(r * Math.sin(a) + 0.25) }); - } - } - - const sample = async (expression: string): Promise => { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - return await sampleExpression(expression, positions, { - workDir, - seed, - spaceAge: true, - planet, - }); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - }; - - const values: Record = {}; - for (const name of ["vulcanus_decorative_knockout", "vulcanus_rock_huge", "vulcanus_rock_big"]) { - values[name] = await sample(name); - console.log(` captured ${name}`); - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 (Space Age enabled) via the test/oracle harness. The two probability expressions Vulcanus's rock ENTITIES use (vulcanus_rock_huge, vulcanus_rock_big; the -hot variants reuse them) plus the vulcanus_decorative_knockout noise they read, routed onto elevation over a grid spanning spawn (fine -256..256/32 plus a coarser -800..800/160 span) and far rings at r=1500/3000 snapped to 1/256, against a real Vulcanus surface (game.planets['vulcanus'].create_surface()). Regenerate: node --experimental-strip-types test/oracle/capture.ts vulcanus-rocks", - seed0: seed, - planet, - positions, - values, - }; - const out = join(FIXTURES, "oracle-vulcanus-rocks.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${positions.length} points)`); -} - -const VORONOI_DISTANCE_TYPES = ["chebyshev", "manhattan", "euclidean", "minkowski3"] as const; - -const VORONOI_OPS = [ - "voronoi_cell_id", - "voronoi_spot_noise", - "voronoi_facet_noise", - "voronoi_pyramid_noise", -] as const; - -/** - * Snap a coordinate to Factorio's `MapPosition` fixed point (1/256 of a tile, - * FLOORED), which is what the oracle path does to it whether we ask or not. - * - * **This is a property of the harness, not of any noise expression, and it is a - * silent one.** A Lua position handed to `calculate_tile_properties` is - * converted to a `MapPosition` on the way in, so a sample nominally at - * `x = 11.166666666666666` is actually taken at `11.1640625` (`= 2858 / 256`). - * The error is ~4e-3 tiles: far too small to look like a wrong formula, far too - * large to be f32 noise, and therefore exactly the kind of discrepancy that gets - * absorbed into a fudged constant instead of being recognised. - * - * It bit this capture directly. The brief's grid steps by `grid_size / 6` = - * 10.666..., which is not representable in 1/256, and fitting `spot_noise` - * against the NOMINAL positions scored 79/175 with residuals around 4e-5 - - * wrong, but plausibly-wrong. Snapping first took chebyshev and manhattan to - * 175/175 with no change to the model at all. - * - * Snapping here rather than compensating downstream keeps the fixture honest: - * its `positions` are then exactly where the game sampled, and nothing that - * reads it has to know this function exists. - */ -function snapToMapPosition(t: number): number { - return Math.floor(t * 256) / 256; -} - -/* - * `Math.floor` above, but `test/captureGrid.ts` recovers a recorded coordinate - * with `Math.trunc`. Both are right, for different jobs. - * - * Here the job is to PRODUCE a coordinate that is already a multiple of 1/256. - * Any rounding does that, and the game's own conversion is then a no-op, so - * floor and trunc are interchangeable at this end. - * - * There the job is to REPRODUCE what the game did to a coordinate that was - * recorded off the grid. That is one specific conversion - `fcvtzs`, truncation - * toward zero - and it is measurably not flooring: across the affected fixtures - * flooring is worse than applying no snap at all in several arrays, while - * truncation is exact where flooring is not on six of the ten rows that have a - * negative coordinate, and never the reverse. - */ - -/** - * Positions for the jitter-0 voronoi capture. - * - * The first 144 are the brief's grid: `grid_size` 64 stepped by `grid_size / 6` - * with a 0.5 offset, which keeps every probe off an exact integer boundary where - * an f32 tie could flip which point wins for reasons that are not the formula. - * - * Three groups are APPENDED to it, each answering something that grid cannot: - * - * - **Exact cell centres.** At jitter 0 the cell's point IS the centre, so - * `voronoi_spot_noise` must read exactly 0 there whatever the normalisation - * divisor turns out to be. That is the one sanity check that separates "the - * probe samples the cell we think it does" from "the formula is wrong", and - * the 0.5-offset grid never lands on a centre, so without these it cannot be - * run at all. - * - **Negative coordinates**, which the grid omits entirely. A cell lookup that - * truncates toward zero instead of flooring is invisible for x >= 0 and wrong - * for exactly half the map. - * - **Far-from-origin and off-phase points**, so a formula that happens to fit - * near the origin cannot survive by accident. - */ -function voronoiPositions(gridSize: number): Position[] { - const out: Position[] = []; - for (let i = 0; i < 12; i++) { - for (let j = 0; j < 12; j++) { - out.push({ - x: snapToMapPosition(i * (gridSize / 6) + 0.5), - y: snapToMapPosition(j * (gridSize / 6) + 0.5), - }); - } - } - const half = gridSize / 2; - for (const cx of [-2, -1, 0, 1, 2]) { - for (const cy of [-2, -1, 0, 1, 2]) { - out.push({ x: cx * gridSize + half, y: cy * gridSize + half }); - } - } - out.push( - { x: -0.5, y: -0.5 }, - { x: -33.25, y: -97.75 }, - { x: 63.5, y: 63.5 }, - { x: 1000.5, y: -2000.25 }, - { x: -777.75, y: 333.125 }, - { x: 12345.75, y: 6789.125 }, - ); - return out; -} - -/** - * The four `voronoi_*` ops x the four `distance_type`s at **jitter 0**, where - * every point sits at its cell centre and the per-cell RNG is out of the picture - * entirely - so all four ops reduce to pure geometry and can be fitted in closed - * form (`voronoi_cell_id` excepted; it is a hash of the cell and needs the RNG - * whatever the jitter). - * - * `spaceAge` is deliberately false: `voronoi_*` are engine builtins - * (`NativeNoiseFunctions`), not planet-scoped named expressions, so they resolve - * on the plain Nauvis surface and the DLC load is unnecessary. - */ -async function captureVoronoiJitter0(): Promise { - const seed = 123456; - const gridSize = 64; - const seed1 = 1; - const jitter = 0; - const positions = voronoiPositions(gridSize); - const values: Record = {}; - - for (const op of VORONOI_OPS) { - for (const distanceType of VORONOI_DISTANCE_TYPES) { - // **15 series, not 16.** The game's own noise-expression COMPILER rejects - // this one pair outright - "Voronoi pyramid noise with Minkowski3 distance - // is not supported" - so there is no ground truth to capture and the run - // dies before sampling. Measured across all 16 pairs against the 2.1.12 - // binary; the other 15 compile. The API docs agree in a way that is easy - // to read past: `voronoi_pyramid_noise`'s "Available values for - // distance_type" list has three entries where every other voronoi op has - // four. `pyramidNoise` in the port throws for this pair for the same - // reason. - if (op === "voronoi_pyramid_noise" && distanceType === "minkowski3") continue; - const expression = buildVoronoiExpression({ - op, - x: "x", - y: "y", - seed1: String(seed1), - gridSize, - distanceType, - jitter, - }); - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - values[`${op}:${distanceType}`] = await sampleExpression(expression, positions, { - workDir, - seed, - }); - console.log(` captured ${op}:${distanceType}`); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 via the test/oracle harness. The four voronoi_* ops x the " + - "four distance_type values, each routed onto elevation on the default Nauvis surface, at " + - "JITTER 0 - where every point sits at its cell centre, so the per-cell RNG does not move any " + - "point and the ops reduce to pure geometry. Positions are the 12x12 half-offset grid plus " + - "exact cell centres (spot_noise must read 0 there), negative coordinates, and far-off-origin " + - "points. Regenerate: node --experimental-strip-types test/oracle/capture.ts voronoi-jitter0", - seed, - gridSize, - jitter, - seed1, - positions, - values, - }; - const out = join(FIXTURES, "oracle-voronoi-jitter0.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log( - `wrote ${out} (${String(Object.keys(values).length)} series x ${String(positions.length)} points)`, - ); -} - -/** - * `voronoi_cell_id` at the CENTRE of every cell in a 16x16 cell block, across - * three `seed0` (the map seed) x three `seed1`. - * - * `cell_id` is the per-cell RNG exposed directly as a float, so this is the - * cheapest possible view of the hash: one value per cell, no geometry, no - * boundary ambiguity. `distance_type` is irrelevant to it - the jitter-0 fixture - * asserts all four agree value-for-value - so only `chebyshev` is sampled. - * - * Two properties of the position set are deliberate: - * - * - **Cell indices span -8..7, not 0..15.** Negative indices are what - * distinguish a hash that treats the cell coordinate as a two's-complement - * `u32` from one that does anything else, and half the map has them. - * - **Every position is an exact integer** (`cx * 64 + 32`), so the 1/256 - * `MapPosition` floor that {@link snapToMapPosition} exists for cannot bite: - * the game samples exactly where we asked, including for negative x/y where - * floor-vs-truncate would otherwise be live. - */ -async function captureVoronoiCellId(): Promise { - const gridSize = 64; - const jitter = 0; - const cells: { cx: number; cy: number }[] = []; - for (let cx = -8; cx < 8; cx++) { - for (let cy = -8; cy < 8; cy++) cells.push({ cx, cy }); - } - const positions: Position[] = cells.map(({ cx, cy }) => ({ - x: cx * gridSize + gridSize / 2, - y: cy * gridSize + gridSize / 2, - })); - - const series: { seed0: number; seed1: number; values: number[] }[] = []; - for (const seed0 of [123456, 1, 4294967295]) { - for (const seed1 of [0, 1, 137]) { - const expression = buildVoronoiExpression({ - op: "voronoi_cell_id", - x: "x", - y: "y", - seed1: String(seed1), - gridSize, - distanceType: "chebyshev", - jitter, - }); - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const values = await sampleExpression(expression, positions, { workDir, seed: seed0 }); - series.push({ seed0, seed1, values }); - console.log(` captured seed0=${seed0} seed1=${seed1}`); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 via the test/oracle harness. voronoi_cell_id routed onto " + - "elevation on the default Nauvis surface, sampled at the CENTRE of every cell in a 16x16 " + - "cell block (cell indices -8..7, so negative cell coordinates are covered), for 3 seed0 x 3 " + - "seed1 at grid_size 64, jitter 0. cell_id is the per-cell RNG as a float, so this is the hash " + - "with no geometry in the way; distance_type does not enter it (the jitter-0 fixture asserts " + - "all four agree). Regenerate: node --experimental-strip-types test/oracle/capture.ts voronoi-cellid", - gridSize, - jitter, - cells, - positions, - series, - }; - const out = join(FIXTURES, "oracle-voronoi-cellid.multiseed.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log( - `wrote ${out} (${String(series.length)} series x ${String(positions.length)} values)`, - ); -} - -/** - * Assert a coordinate is EXACTLY representable as a `MapPosition` (a multiple of - * 1/256 of a tile), and return it unchanged. - * - * This is the alternative taken to {@link snapToMapPosition} for the jittered - * point capture, and the choice was deliberate. `snapToMapPosition` uses - * `Math.floor`, and every negative probe committed so far happened to be exactly - * representable - so floor and truncate-toward-zero are indistinguishable in all - * existing data, and this capture's lattice would have been the first place the - * difference could bite. - * - * Rather than pick a rounding rule that no fixture can discriminate, or plumb an - * echo-back of the position the game used (which `sampleTileNames` does, but - * `calculate_tile_properties` gives no such channel - the mod echoes the - * positions it was HANDED), the lattice is built entirely from multiples of 1/2 - * a tile. Those are exact in 1/256 whatever their sign, so no rounding rule - * applies at all and the question is removed rather than answered. - * - * This assertion is what keeps that property from silently lapsing: change the - * spacing to something like `gridSize / 6` and the capture dies here instead of - * quietly sampling ~4e-3 tiles away from where the fixture claims. - */ -function assertRepresentable(t: number): number { - if (!Number.isInteger(t * 256)) { - throw new Error( - `position ${String(t)} is not a multiple of 1/256 and would be silently ` + - "floored to a MapPosition by the game - see assertRepresentable", - ); - } - return t; -} - -/** - * **R3: where a cell's point actually sits once `jitter > 0`** - the - * configuration Fulgora uses (0.6, 0.8 and 1.0). - * - * Two independent readouts, in one fixture, because they answer different - * questions and neither alone is enough: - * - * **`series` - the inversion lattice.** `voronoi_spot_noise` is a cone whose - * apex sits ON the point, so its minimum over a lattice recovers the point's - * position directly, with no model in the loop. The lattice is the 64x64 TILE - * CENTRES of one whole cell (`cellX`, `cellY`), so it is prediction-free: at - * jitter 1 the point may be anywhere in the cell, and a lattice placed around a - * predicted position would only ever confirm the prediction it was built from. - * - * `cellIds` is captured alongside at the same positions and is **not - * redundant.** `spot_noise` is the distance to the nearest point of ANY cell, so - * a neighbour's point sitting just outside the boundary can own lattice points - * inside this cell and win the global argmin - at which case the recovered - * "apex" would be a different cell's point entirely. `voronoi_cell_id` says - * which point won at each lattice position, so the argmin can be restricted to - * the positions this cell actually owns. That filter comes from the game, not - * from the port. - * - * **`ops` - the exact-f32 acceptance set.** Locating a point to within half a - * tile is not acceptance; the bar for this repo is bit-exact agreement. So the - * jitter-0 fixture's own 15 op x distance_type series are re-captured at each of - * the three jitters, over the same 175 positions, giving 45 series the port must - * reproduce exactly. - * - * That set is also the **first configuration that can discriminate Task 2's - * pyramid formulas.** At jitter 0 every cell is a congruent unit square and many - * different algorithms collapse to identical numbers; with the points scattered - * they do not. `voronoi_pyramid_noise` is included at all three distance types - * it supports for exactly that reason. - */ -async function captureVoronoiPoints(): Promise { - const seed = 123456; - const gridSize = 64; - const seed1 = 1; - const jitters = [0.6, 0.8, 1] as const; - const latticeDistanceTypes = ["manhattan", "euclidean"] as const; - // Fulgora's own two ops are manhattan and euclidean, and the load-bearing - // question is whether they can share one point field, so those are the two the - // lattice inverts. The cell is an arbitrary interior one; nothing about it is - // special, and in particular it is NOT one of the two colliding pairs - // ((0,0)/(-1,-1) and (-1,0)/(0,-1)) whose shared word would make a - // point-position claim about "this cell" ambiguous. - const cellX = 3; - const cellY = 5; - - const lattice: Position[] = []; - for (let i = 0; i < 64; i++) { - for (let j = 0; j < 64; j++) { - lattice.push({ - x: assertRepresentable(cellX * gridSize + i + 0.5), - y: assertRepresentable(cellY * gridSize + j + 0.5), - }); - } - } - - const series: { - jitter: number; - distanceType: string; - cellX: number; - cellY: number; - lattice: Position[]; - values: number[]; - cellIds: number[]; - }[] = []; - - for (const jitter of jitters) { - for (const distanceType of latticeDistanceTypes) { - const sample = async (op: (typeof VORONOI_OPS)[number]): Promise => { - const expression = buildVoronoiExpression({ - op, - x: "x", - y: "y", - seed1: String(seed1), - gridSize, - distanceType, - jitter, - }); - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - return await sampleExpression(expression, lattice, { workDir, seed }); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - }; - const values = await sample("voronoi_spot_noise"); - const cellIds = await sample("voronoi_cell_id"); - series.push({ jitter, distanceType, cellX, cellY, lattice, values, cellIds }); - console.log(` captured lattice jitter=${String(jitter)} ${distanceType}`); - } - } - - const opPositions = voronoiPositions(gridSize); - const ops: Record = {}; - for (const jitter of jitters) { - for (const op of VORONOI_OPS) { - for (const distanceType of VORONOI_DISTANCE_TYPES) { - // Same 15-of-16 exclusion as the jitter-0 capture: the game's expression - // compiler rejects pyramid x minkowski3 outright, so there is no ground - // truth to take and the run dies before sampling. - if (op === "voronoi_pyramid_noise" && distanceType === "minkowski3") continue; - const expression = buildVoronoiExpression({ - op, - x: "x", - y: "y", - seed1: String(seed1), - gridSize, - distanceType, - jitter, - }); - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - ops[`${op}:${distanceType}:${String(jitter)}`] = await sampleExpression( - expression, - opPositions, - { workDir, seed }, - ); - console.log(` captured ${op}:${distanceType} jitter=${String(jitter)}`); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - } - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 via the test/oracle harness, for JITTERED voronoi point " + - "placement (jitter 0.6 / 0.8 / 1.0 at grid_size 64, seed0 123456, seed1 1). `series` is the " + - "inversion lattice: voronoi_spot_noise plus voronoi_cell_id over the 64x64 tile centres of " + - "cell (3,5), under manhattan and euclidean - spot_noise's cone apex sits ON the point, so its " + - "minimum over the lattice IS the point, and cell_id says which cell owns each lattice position " + - "so a neighbour's point cannot be mistaken for this one. `ops` is the exact-f32 acceptance " + - "set: the same 15 op x distance_type series and 175 positions as the jitter-0 fixture, " + - "re-captured at each jitter. Every lattice coordinate is a multiple of 1/2 a tile and so is " + - "exact in the 1/256 MapPosition grid whatever its sign. " + - "Regenerate: node --experimental-strip-types test/oracle/capture.ts voronoi-points", - seed, - seed1, - gridSize, - series, - opPositions, - ops, - }; - const out = join(FIXTURES, "oracle-voronoi-points.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log( - `wrote ${out} (${String(series.length)} lattice series x ${String(lattice.length)} points, ` + - `${String(Object.keys(ops).length)} op series x ${String(opPositions.length)} points)`, - ); -} - -/** - * **The positions where `VoronoiNoise::getPointsSearchRange()` is OBSERVABLE** - - * the only fixture in the repo that can tell a 3x3 neighbour search from a 5x5 - * one. - * - * The other two voronoi fixtures cannot, and that was measured rather than - * assumed: forcing the port's search range to 2 for all four distance types - * passes all 95 voronoi tests, and forcing it to 1 also passes all 95. So 2100 - * committed values are indifferent to a function that Factorio changed the - * behaviour of in 2.1.7 (forums.factorio.com/130905 - before that the ops missed - * the true nearest point at high jitter). This fixture exists to end that. - * - * **Only `voronoi_pyramid_noise` can discriminate**, and its second loop is why. - * `spot`/`facet`/`cell_id` reduce to the two smallest point distances, and a - * ring-2 point is more than a grid unit away on one axis, so it essentially - * never displaces them. The pyramid's second loop instead minimises the distance - * to the BISECTOR of the nearest point and each other point - and for euclidean - * that is `(|f|^2 - |n|^2) / (2 |f - n|)`, which is small whenever `|f| ~= |n|` - * however far `f`'s cell index is. A ring-2 point only has to be nearly - * equidistant, not nearer, so the pyramid sees the wider ring where nothing else - * does. - * - * The five configurations below were chosen so that BOTH branches of the - * function are pinned, in both directions: - * - * - **chebyshev at jitter 1** is the `1` branch. The jump table pins chebyshev - * at 1 whatever the jitter, and there is a clean proof of why: the own cell's - * point has `max(|dx|,|dy|) < 1` while every ring-2 point has `> 1`, so under - * L-infinity the nearest point is always in the sample's own cell. The pyramid - * still notices the ring, so these positions read the game's ring-1 answer. - * **This is exactly Fulgora's `fulgora_road_pyramids` configuration** - * (chebyshev, `fulgora_road_jitter = 1`). - * - **manhattan / euclidean at jitter 1** are the `2` branch. - * - **manhattan at 0.7 and euclidean at 0.9** are the LOWEST jitters found to - * discriminate at all, so they are what bounds each threshold from above - * (manhattan's must be below 0.7, euclidean's below 0.9). The thresholds - * themselves - 0.5, f32(0.66), 0.75 - cannot be pinned behaviourally: a - * ring-1/ring-2 disagreement needs high jitter, and a 4096x4096 tile sweep at - * manhattan 0.5 and euclidean 0.66 found zero disagreements. That gap is what - * `test/voronoiSearchRange.spec.ts`'s weaker table test covers. - * - * Positions are hand-picked from a sweep of the port with the ring forced to 1 - * and to 2, keeping only samples where the two answers differ by more than 2% - * and thinning by a stride so they are not all one cluster. Picking them from - * the port is fine and does not beg the question: the port chooses only WHERE to - * look, and the game alone says which of the two answers is right. - */ -async function captureVoronoiSearchRange(): Promise { - const seed = 123456; - const gridSize = 64; - const seed1 = 1; - - const configs: { - distanceType: (typeof VORONOI_DISTANCE_TYPES)[number]; - jitter: number; - expectedRange: 1 | 2; - positions: Position[]; - }[] = [ - { - distanceType: "chebyshev", - jitter: 1, - expectedRange: 1, - positions: [ - { x: 1727.5, y: -1017.5 }, - { x: 1726.5, y: -1017.5 }, - { x: 767.5, y: -508.5 }, - { x: 512.5, y: 1280.5 }, - { x: -382.5, y: 1593.5 }, - { x: -1855.5, y: -1578.5 }, - { x: -127.5, y: -639.5 }, - { x: -126.5, y: -637.5 }, - ], - }, - { - distanceType: "manhattan", - jitter: 1, - expectedRange: 2, - positions: [ - { x: -833.5, y: -1023.5 }, - { x: -825.5, y: -1022.5 }, - { x: -818.5, y: -1021.5 }, - { x: -812.5, y: -1020.5 }, - { x: -803.5, y: -1019.5 }, - { x: -825.5, y: -1017.5 }, - { x: -813.5, y: -1016.5 }, - { x: -834.5, y: -1012.5 }, - { x: -819.5, y: -1011.5 }, - { x: -824.5, y: -1007.5 }, - { x: -267.5, y: -963.5 }, - ], - }, - { - distanceType: "manhattan", - jitter: 0.7, - expectedRange: 2, - positions: [ - { x: -256.5, y: -943.5 }, - { x: -876.5, y: -840.5 }, - { x: -869.5, y: -838.5 }, - { x: -881.5, y: -835.5 }, - { x: -877.5, y: -833.5 }, - { x: -254.5, y: 172.5 }, - ], - }, - { - distanceType: "euclidean", - jitter: 1, - expectedRange: 2, - positions: [ - { x: 1471.5, y: -2035.5 }, - { x: -1207.5, y: -1991.5 }, - { x: -1212.5, y: -1987.5 }, - { x: -1139.5, y: -1855.5 }, - { x: 1090.5, y: -1791.5 }, - { x: -1280.5, y: -1743.5 }, - { x: 739.5, y: -1660.5 }, - { x: -1865.5, y: -1471.5 }, - { x: 513.5, y: -1416.5 }, - { x: -1664.5, y: -1382.5 }, - { x: -198.5, y: -1150.5 }, - ], - }, - { - distanceType: "euclidean", - jitter: 0.9, - expectedRange: 2, - positions: [{ x: 701.5, y: -835.5 }], - }, - ]; - - const series: { - distanceType: string; - jitter: number; - expectedRange: 1 | 2; - positions: Position[]; - values: number[]; - }[] = []; - - for (const c of configs) { - for (const pos of c.positions) { - assertRepresentable(pos.x); - assertRepresentable(pos.y); - } - const expression = buildVoronoiExpression({ - op: "voronoi_pyramid_noise", - x: "x", - y: "y", - seed1: String(seed1), - gridSize, - distanceType: c.distanceType, - jitter: c.jitter, - }); - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const values = await sampleExpression(expression, c.positions, { workDir, seed }); - series.push({ ...c, values }); - console.log(` captured ${c.distanceType} jitter=${String(c.jitter)}`); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 via the test/oracle harness. voronoi_pyramid_noise at the " + - "positions where the game's own VoronoiNoise::getPointsSearchRange() is OBSERVABLE - i.e. " + - "where searching a 3x3 ring of cells and searching a 5x5 ring give different answers. The " + - "other two voronoi fixtures are indifferent to that function in both directions (forcing the " + - "port to 1, and to 2, each passes all 95 voronoi tests), and Factorio changed this behaviour " + - "in 2.1.7 (forums.factorio.com/130905), so without these positions a version skew would be " + - "silent. chebyshev jitter 1 pins the table's chebyshev entry at 1 - and is Fulgora's " + - "fulgora_road_pyramids configuration; manhattan/euclidean jitter 1 pin the '> threshold ? 2' " + - "branch; manhattan 0.7 and euclidean 0.9 are the lowest jitters found to discriminate at all " + - "and so bound those thresholds from above. Every coordinate is a multiple of 1/2 a tile and " + - "so exact in the 1/256 MapPosition grid. Regenerate: node --experimental-strip-types " + - "test/oracle/capture.ts voronoi-search-range", - seed, - seed1, - gridSize, - series, - }; - const out = join(FIXTURES, "oracle-voronoi-search-range.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${String(series.length)} series)`); -} - -if (!oracleAvailable()) { - console.error("No Factorio binary found (set FACTORIO_BIN). Cannot capture fixtures."); - process.exit(1); -} - -/** - * Fulgora's shared layer: the grid constant, the wobble fields that distort the - * Voronoi input, the offset/distorted coordinates, and the two starting cones. - * - * Positions deliberately span BOTH scales, because these fields disagree at - * different ones: the starting cones are only non-zero within a couple of grid - * cells of spawn, while the wobble fields need far-field samples to exercise - * octaves the near grid never reaches. A near-only capture would let a wrong - * `input_scale` pass, and a far-only one would never evaluate a cone at all. - */ -/** - * The position set every Fulgora capture shares, so the fixtures line up - * index-for-index and a field from one can be compared against a field from - * another without re-deriving anything. - * - * Two scales on purpose. The starting cones are non-zero only within a couple - * of grid cells of spawn; the wobble octaves are only exercised far out. A - * near-only capture lets a wrong `input_scale` pass, and a far-only one never - * evaluates a cone at all. - * - * **Every coordinate is a multiple of a quarter tile**, and that is load-bearing - * rather than tidy. Factorio stores a MapPosition as 1/256-tile fixed point, so - * a coordinate that is not a multiple of 1/256 is sampled by the game at a - * DIFFERENT point than the port evaluates. Measured: an unsnapped ring position - * put `fulgora_ox` - literally `x + grid/2` - out by exactly 1/256, which reads - * as a porting bug and is not one. - */ -function fulgoraCapturePositions(): Position[] { - const positions: Position[] = []; - const q = (v: number): number => Math.round(v * 4) / 4; - - // Near field: a 7x7 sweep across one 175-tile grid cell, offset off the - // integer lattice so nothing lands on a cell boundary by accident. - for (let gy = 0; gy < 7; gy++) { - for (let gx = 0; gx < 7; gx++) { - positions.push({ x: gx * 29 - 87 + 0.5, y: gy * 29 - 87 + 0.25 }); - } - } - // Far field: rings well past any starting cone, out to where the Voronoi - // grid has tiled many times. - for (const r of [400, 900, 1800, 3300, 7000, 15000]) { - for (let k = 0; k < 8; k++) { - const a = (k * Math.PI) / 4; - positions.push({ x: q(r * Math.cos(a) + 0.5), y: q(r * Math.sin(a) + 0.25) }); - } - } - // A few odds and ends, including the exact origin. - positions.push({ x: 0, y: 0 }); - positions.push({ x: 87.5, y: -87.5 }); - positions.push({ x: 12345.75, y: 6789.25 }); - positions.push({ x: -4321.25, y: -8765.75 }); - return positions; -} - -/** Sample one named expression on a real Fulgora surface. */ -async function sampleFulgora(expression: string, positions: readonly Position[], seed: number) { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - return await sampleExpression(expression, positions, { - workDir, - seed, - spaceAge: true, - planet: "fulgora", - }); - } finally { - await rm(workDir, { recursive: true, force: true }); - } -} - -async function captureFulgoraShared(): Promise { - const seed = 123456; - const planet = "fulgora"; - const positions = fulgoraCapturePositions(); - const sample = (expression: string) => sampleFulgora(expression, positions, seed); - - const NAMES = [ - "fulgora_grid", - "fulgora_wobble_influence", - "fulgora_wobble_mask", - "fulgora_wobble_x", - "fulgora_wobble_y", - "fulgora_ox", - "fulgora_oy", - "fulgora_wx", - "fulgora_wy", - "fulgora_starting_cone", - "fulgora_starting_vault_cone", - "fulgora_starting_mask", - "fulgora_starting_vault_mask", - ] as const; - - const fields: Record = {}; - for (const name of NAMES) { - fields[name] = await sample(name); - console.log(` captured ${name}`); - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.14 (Space Age enabled) via the test/oracle harness: " + - "Fulgora's shared layer (grid, wobble influence/mask/x/y, ox/oy/wx/wy, the two starting " + - "cones and their masks), each routed onto elevation against a real Fulgora surface " + - "(game.planets['fulgora'].create_surface()). Positions span one grid cell near spawn AND " + - "six far-field rings, because the cones are only non-zero near spawn while the wobble " + - "octaves are only exercised far out. Every coordinate is a multiple of a QUARTER TILE and " + - "therefore exact in Factorio's 1/256 MapPosition grid - an earlier unsnapped capture put " + - "fulgora_ox (literally x + grid/2) out by exactly 1/256, which reads as a porting bug and " + - "is not one. Regenerate: node --experimental-strip-types " + - "test/oracle/capture.ts fulgora-shared", - seed0: seed, - planet, - positions, - ...fields, - }; - const out = join(FIXTURES, "oracle-fulgora-shared.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${String(positions.length)} positions)`); -} - -// Optional CLI filter: names on argv restrict which fixtures regenerate (so a new -// capture need not re-run the others). No args = capture everything. - -/** - * #269: does the game narrow `output_scale * basis_noise(...)` to f32, and is - * narrowing the product enough on its own? - * - * `oracle-basis.seed123456.json` cannot answer either question, and the reason - * is exact rather than incidental: it was captured at `output_scale = 1`. - * `basis_noise` returns an f32, so multiplying by a POWER OF TWO is a pure - * exponent shift and can never leave the f32 grid - narrowing that product is - * the identity, and 0 of 200,000 sampled products differ. Any other output - * scale can leave the grid. Measured over 90,000 samples at a fixed input - * scale: output scales 1, 0.5, 0.25, 2, 4 and 64 change 0.00% of products, - * while 0.6 changes 79.88%, 0.75 and 3 change 56.32%, 150 changes 97.46% and - * 125 changes 98.38%. - * - * The INPUT scale is not the discriminator, which is worth stating because it - * is the number that looks inexact: holding `output_scale = 1` and sweeping the - * input scale over 0.125, 0.205, 0.51, 0.6, 1.5 and 0.002 changes 0.00% every - * time. The input scale decides WHICH noise value you get, never whether the - * product is representable. - * - * So this captures one control and four discriminating output scales: - * - * - `1` - a power of two. Every candidate model agrees here by construction, so - * if the control is not unanimous the harness is wrong and nothing below - * means anything. - * - `0.6` - `nauvis_shared`'s own output scale. - * - `0.51` - `cliff_fields`' low-frequency cliffiness. - * - `0.75` - a two-bit mantissa, the mildest non-power-of-two in the tree. - * - `125` - `mountain_plasma`'s first term, where nearly every product differs. - * - * `input_scale` is held at **0.125** throughout, which is exact in f32, so the - * sample POINT is unambiguous and the only thing varying between cases is the - * output scale. That matters: an earlier run at `input_scale = 0.205128205128` - * had the port disagreeing with the game at 193 of 196 positions even at - * `output_scale = 1`, where all models coincide - consistent with the game - * holding `input_scale` at f32 as well, which is a SEPARATE question about the - * coordinate product and is deliberately not asked here. - */ -async function captureBasisOutputScale(): Promise { - const seed = 123456; - const seed1 = 12643; - const inputScale = 0.125; - - // A scattered grid, deliberately off the integer lattice where basis_noise - // returns exactly zero and every candidate model agrees for free. - const positions: Position[] = []; - for (let i = 0; i < 14; i++) { - for (let j = 0; j < 14; j++) { - positions.push({ x: -400.5 + i * 57.25, y: -400.75 + j * 57.5 }); - } - } - - const cases: { outputScale: number; values: number[] }[] = []; - for (const outputScale of [1, 0.6, 0.51, 0.75, 125]) { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const expression = - `basis_noise{x = x, y = y, seed0 = map_seed, seed1 = ${seed1}, ` + - `input_scale = ${inputScale}, output_scale = ${outputScale}}`; - const values = await sampleExpression(expression, positions, { workDir, seed }); - cases.push({ outputScale, values }); - console.log(` captured basis-output-scale output_scale=${outputScale}`); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.14 (build 87180, win64) via the test/oracle harness. The discriminating capture for #269: basis_noise routed onto elevation at a FIXED input_scale of 0.125 (exact in f32, so the sample point is unambiguous) and five output scales - 1 as the control, then 0.6, 0.51, 0.75 and 125. oracle-basis.seed123456.json cannot answer #269 because it was captured at output_scale = 1, a power of two, where narrowing the product is the identity; any non-power-of-two output scale discriminates. Captured from WSL against the Windows install, which needs FACTORIO_PATH_STYLE=windows and a TMPDIR on a Windows-visible drive. Regenerate: node --experimental-strip-types test/oracle/capture.ts basis-output-scale", - seed0: seed, - seed1, - inputScale, - positions, - cases, - }; - const out = join(FIXTURES, "oracle-basis-output-scale.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${positions.length} points, ${cases.length} output scales)`); -} - -/** - * #269's second half: does the game hold `input_scale` at f32 too, and does it - * narrow the coordinate PRODUCT? - * - * `oracle-basis-output-scale` settled the output side and deliberately did not - * ask this: it held `input_scale` at 0.125, exact in f32, so the sample point - * was unambiguous. This probe is the mirror image. `output_scale` is pinned at - * **1** throughout - a power of two, where every output-side model is provably - * the identity - so nothing the previous capture measured can leak in here. - * - * The reason to ask is a measurement, not a hunch. An early run at - * `input_scale = 0.205128205128` had the port disagreeing with the game at - * **193 of 196** positions even at `output_scale = 1`, where all four - * output-side candidates coincide. Something else was wrong, and the coordinate - * product is the only term left. - * - * This is the two-case rule from `src/noise/eval/f32.ts` applied to the INPUT - * side, so there are four candidates for `basis_noise(x * input_scale, ...)`: - * - * basis(x * s) the shipped port - f64 constant, f64 product - * basis(x * f32(s)) narrow the CONSTANT only - * basis(f32(x * s)) narrow the PRODUCT only - * basis(f32(x * f32(s))) both, which is what an f32 machine does - * - * Unlike the output side there is no "power of two is immune" shortcut that - * makes a scale blind, because the coordinate is not an f32 to begin with - but - * an f32-EXACT scale does collapse the constant half to the identity, which is - * what the controls below are for. - * - * The scales, and why each is here: - * - * - `0.125` and `0.5` - **controls.** Exact in f32, so `f32(s) === s` and the - * constant half cannot move. Any disagreement between the two remaining - * models here is the product half alone, isolated. - * - `0.205128205128` - the scale that produced the 193-of-196 finding. - * - `0.0975` (`0.3 * 0.325`) and `0.195` (`0.6 * 0.325`) - both terms of - * `vulcanus_hairline_cracks`, the field that scores worst in its layer and - * got WORSE (3 -> 2 of 61) when the output side was fixed. - * - `0.02` (`1/50`) - the base scale of every `vulcanus_plasma` call. - * - `0.002` (`1/500`) - `mountain_basis_noise`, the smallest in the tree. - * - * If the game holds `input_scale` at f32, this touches EVERY `basis_noise` call - * in the port rather than only the non-power-of-two output scales #269 reached, - * so it is potentially larger than the issue it came from. - */ -async function captureBasisInputScale(): Promise { - const seed = 123456; - const seed1 = 12643; - const outputScale = 1; - - // The same scattered grid the output-scale probe used, deliberately off the - // integer lattice where basis_noise returns exactly zero and every candidate - // agrees for free. Every coordinate is an exact binary fraction, so the only - // thing varying between cases is the scale. - const positions: Position[] = []; - for (let i = 0; i < 14; i++) { - for (let j = 0; j < 14; j++) { - positions.push({ x: -400.5 + i * 57.25, y: -400.75 + j * 57.5 }); - } - } - - const cases: { inputScale: number; values: number[] }[] = []; - for (const inputScale of [0.125, 0.5, 0.205128205128, 0.0975, 0.195, 0.02, 0.002]) { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const expression = - `basis_noise{x = x, y = y, seed0 = map_seed, seed1 = ${seed1}, ` + - `input_scale = ${inputScale}, output_scale = ${outputScale}}`; - const values = await sampleExpression(expression, positions, { workDir, seed }); - cases.push({ inputScale, values }); - console.log(` captured basis-input-scale input_scale=${inputScale}`); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.14 (build 87180, win64) via the test/oracle harness. The discriminating capture for #269's SECOND question: does the game hold basis_noise's input_scale at f32, and does it narrow the coordinate product? output_scale is pinned at 1 (a power of two, where every output-side model is the identity) so the settled output-side question cannot leak in. Seven input scales - 0.125 and 0.5 as f32-exact controls that collapse the constant half, then 0.205128205128 (the scale behind the 193-of-196 finding), 0.0975 and 0.195 (both terms of vulcanus_hairline_cracks), 0.02 (1/50, every vulcanus_plasma) and 0.002 (1/500, mountain_basis_noise). Captured from WSL against the Windows install, which needs FACTORIO_PATH_STYLE=windows and a TMPDIR on a Windows-visible drive. Regenerate: node --experimental-strip-types test/oracle/capture.ts basis-input-scale", - seed0: seed, - seed1, - outputScale, - positions, - cases, - }; - const out = join(FIXTURES, "oracle-basis-input-scale.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${positions.length} points, ${cases.length} input scales)`); -} - -/** - * #290 at the REAL call sites, not at round literals. - * - * `oracle-basis-input-scale` answered the modelling question and answered it - * cleanly - `basis_noise(f32(x * f32(input_scale)), ...)` is 196 of 196 at seven - * scales. But applying that to `basisNoiseExpr` and re-scoring made three fields - * WORSE: `vulcanus_hairline_cracks` (worst 3e-4 -> 5.272e-4), - * `vulcanus_flood_basalts_func` (7e-5 -> 1.582e-4) and `mountain_plasma` - * (4e-3 -> 4.784e-3, exact 11 -> 10 of 38), while `vulcanus_elev` improved - * sharply, 116 -> 136 of 434. - * - * A model that is exact at 196 of 196 does not make a field worse by accident, - * so something about those call sites differs from the probe. Two candidates, - * and this capture separates them: - * - * 1. **The scales are computed, not written.** `hairline_cracks` does not pass - * 0.0975; it passes `1 / 50 / (0.3 * 0.325)`, which is - * 0.20512820512820512. The first probe used the truncated literal - * `0.205128205128` - a DIFFERENT f64, though the same f32. So the earlier - * capture may have been grading a neighbouring point. - * 2. **The output scale is not 1 here.** The first probe pinned it at 1 to - * isolate the input side. Every real call pairs a non-trivial input scale - * with a non-trivial OUTPUT scale, and #269 established that the output side - * narrows too. The two narrowings have never been graded TOGETHER. - * - * So this captures the exact `(input_scale, output_scale)` pairs the three - * regressing fields actually use, at full f64 precision, with nothing rounded: - * - * 0.20512820512820512 x 0.6 hairline_cracks term A - * 0.10256410256410256 x 1 hairline_cracks term B - * 0.008 x 125 mountain_plasma term A - * 0.002 x 625 mountain_plasma term B - * 0.002 x 250 mountain_basis_noise - * - * `hairline_cracks` is `abs(A - B)` of the first two and `mountain_plasma` is - * `abs(A - B)` of the next two, so between them these five leaves are the whole - * of both regressing fields plus the elevation term that improved. If the - * combined model reproduces all five, the regression is in how the port - * composes them, not in the narrowing - and that is a different bug with a - * different fix. - */ -async function captureBasisCallerScales(): Promise { - const seed = 123456; - - // The same scattered grid the other two basis probes use, so the three are - // directly comparable. Every coordinate is an exact binary fraction. - const positions: Position[] = []; - for (let i = 0; i < 14; i++) { - for (let j = 0; j < 14; j++) { - positions.push({ x: -400.5 + i * 57.25, y: -400.75 + j * 57.5 }); - } - } - - const cs = 0.325; - const leaves = [ - { name: "hairline_cracks A", seed1: 12643, inputScale: 1 / 50 / (0.3 * cs), outputScale: 0.6 }, - { - name: "hairline_cracks B", - seed1: 13423 + 15223, - inputScale: 1 / 50 / (0.6 * cs), - outputScale: 1, - }, - { name: "mountain_plasma A", seed1: 12643, inputScale: 1 / 50 / 2.5, outputScale: 125 }, - { name: "mountain_plasma B", seed1: 13423 + 102, inputScale: 1 / 50 / 10, outputScale: 625 }, - { name: "mountain_basis_noise", seed1: 13423, inputScale: 1 / 500, outputScale: 250 }, - ]; - - const cases: { - name: string; - seed1: number; - inputScale: number; - outputScale: number; - values: number[]; - }[] = []; - - for (const leaf of leaves) { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - // Full f64 precision on both scales - String() on a JS number round-trips - // the double exactly, so the game receives the same number the port holds. - const expression = - `basis_noise{x = x, y = y, seed0 = map_seed, seed1 = ${leaf.seed1}, ` + - `input_scale = ${String(leaf.inputScale)}, output_scale = ${String(leaf.outputScale)}}`; - const values = await sampleExpression(expression, positions, { workDir, seed }); - cases.push({ ...leaf, values }); - console.log(` captured basis-caller-scales ${leaf.name}`); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.14 (build 87180, win64) via the test/oracle harness. #290 at the REAL call sites: the exact (input_scale, output_scale) pairs vulcanus_hairline_cracks, mountain_plasma and mountain_basis_noise use, at full f64 precision. oracle-basis-input-scale graded the input narrowing at round literals with output_scale pinned to 1; applying that model made three fields WORSE, so this grades the input and output narrowings TOGETHER at the scales that actually regressed. hairline_cracks is abs(A - B) of the first two cases and mountain_plasma is abs(A - B) of the next two, so these five leaves are the whole of both fields. Captured from WSL against the Windows install, which needs FACTORIO_PATH_STYLE=windows and a TMPDIR on a Windows-visible drive. Regenerate: node --experimental-strip-types test/oracle/capture.ts basis-caller-scales", - seed0: seed, - positions, - cases, - }; - const out = join(FIXTURES, "oracle-basis-caller-scales.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${positions.length} points, ${cases.length} leaves)`); -} - -/** - * #293: decompose `vulcanus_hairline_cracks` into its two leaves, at the SAME - * positions, so the formula can be tested against the game with the port taken - * out of the picture entirely. - * - * The gap this fills is a methodology one. `oracle-basis-caller-scales` grades - * the two leaves at 196 positions on a +/-400 grid and gets 196 of 196. - * `oracle-vulcanus-cracks` grades the composed field at 61 DIFFERENT positions - * and gets 6 of 61 even with those exact leaves. Nothing yet has measured a - * single position end to end, so "the leaves are right and the composition is - * wrong" is an inference across two disjoint sample sets, not an observation. - * - * Two things could break it, and this separates them: - * - * 1. **The formula.** If `vulcanus_plasma` is not `abs(A - B)` of these two - * leaves with these seeds and scales, the port is reconstructing the wrong - * expression. Testing this needs no port at all: capture the leaves and the - * composed field together and ask whether - * `hairline_cracks == abs(leafA - leafB)` in the GAME's own numbers. - * 2. **The far field.** The 196-position grid spans about +/-400 and every - * coordinate on it is an exact binary fraction. These 61 positions reach - * r = 3300 and include (12345.75, 6789.125), where an f32 ulp is ~1e-3 and - * the coordinate pipeline is under real strain. A leaf model can be complete - * near the origin and incomplete out there, and the existing grade could not - * have seen it. - * - * The positions are `captureVulcanusCracks`'s, verbatim, so every value here - * lines up index-for-index with `oracle-vulcanus-cracks.seed123456.json`. - * - * leafA = basis_noise{seed1 = 12643, input_scale = 1/50/(0.3*0.325), output_scale = 0.6} - * leafB = basis_noise{seed1 = 28646, input_scale = 1/50/(0.6*0.325), output_scale = 1} - * - * 28646 is `13423 + 15223`, the second term's seed rule applied to - * `hairline_cracks`'s own seed. `vulcanus_hairline_cracks` is re-captured in - * the same run rather than read from the older fixture, so a version or surface - * difference cannot masquerade as a formula error. - */ -async function captureVulcanusPlasmaDecomposition(): Promise { - const seed = 123456; - const planet = "vulcanus"; - - // Verbatim from captureVulcanusCracks, so indices line up with that fixture. - const positions: Position[] = []; - for (let gy = 0; gy < 6; gy++) { - for (let gx = 0; gx < 6; gx++) { - positions.push({ x: gx * 13 - 30 + 0.5, y: gy * 17 - 40 + 0.25 }); - } - } - for (const r of [500, 1500, 3300]) { - for (let k = 0; k < 8; k++) { - const a = (k * Math.PI) / 4; - positions.push({ - x: snapToMapPosition(r * Math.cos(a) + 0.5), - y: snapToMapPosition(r * Math.sin(a) + 0.25), - }); - } - } - positions.push({ x: 12345.75, y: 6789.125 }); - - const sample = async (expression: string): Promise => { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - return await sampleExpression(expression, positions, { - workDir, - seed, - spaceAge: true, - planet, - }); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - }; - - const cs = 0.325; - const leafAScale = 1 / 50 / (0.3 * cs); - const leafBScale = 1 / 50 / (0.6 * cs); - - const leafA = await sample( - `basis_noise{x = x, y = y, seed0 = map_seed, seed1 = 12643, ` + - `input_scale = ${String(leafAScale)}, output_scale = 0.6}`, - ); - console.log(" captured plasma-decomposition leafA"); - const leafB = await sample( - `basis_noise{x = x, y = y, seed0 = map_seed, seed1 = ${String(13423 + 15223)}, ` + - `input_scale = ${String(leafBScale)}, output_scale = 1}`, - ); - console.log(" captured plasma-decomposition leafB"); - const hairlineCracks = await sample("vulcanus_hairline_cracks"); - console.log(" captured plasma-decomposition vulcanus_hairline_cracks"); - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.14 (build 87180, win64), Space Age, on a real Vulcanus surface, via the test/oracle harness. The decomposition probe for #293: vulcanus_hairline_cracks and BOTH basis_noise leaves the port believes it is built from, captured at the SAME 61 positions (captureVulcanusCracks's grid, verbatim) so the formula hairline_cracks == abs(leafA - leafB) can be tested game-value against game-value, with the port removed. leafA is seed1 12643 at input_scale 1/50/(0.3*0.325) and output_scale 0.6; leafB is seed1 28646 (13423 + 15223) at input_scale 1/50/(0.6*0.325) and output_scale 1. These positions reach r = 3300 and (12345.75, 6789.125), unlike oracle-basis-caller-scales' +/-400 grid, so they also test whether the leaf model survives the far field. Captured from WSL against the Windows install, which needs FACTORIO_PATH_STYLE=windows and a TMPDIR on a Windows-visible drive. Regenerate: node --experimental-strip-types test/oracle/capture.ts vulcanus-plasma-decomposition", - seed0: seed, - planet, - leafA: { seed1: 12643, inputScale: leafAScale, outputScale: 0.6 }, - leafB: { seed1: 13423 + 15223, inputScale: leafBScale, outputScale: 1 }, - positions, - leafAValues: leafA, - leafBValues: leafB, - hairlineCracks, - }; - const out = join(FIXTURES, "oracle-vulcanus-plasma-decomposition.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${positions.length} positions)`); -} - -const only = process.argv.slice(2); -const want = (name: string) => only.length === 0 || only.includes(name); - -/** - * Fulgora's Voronoi layer and the island classification built on it. - * - * Also answers an open question from the plan that nothing else can: the - * Voronoi primitive documents `grid_size` as a 16-bit UNSIGNED INTEGER, but - * `fulgora_grid` is a genuine float away from the two slider endpoints (see - * docs/noise/fulgora-elevation-NOTES.md). So does the CALL truncate it? - * - * At the default frequency `fulgora_grid` is exactly 175, which cannot - * discriminate - so the probe passes a FRACTIONAL grid_size literal instead and - * compares it against the two integers it sits between. Whichever the game - * agrees with is the answer, and it needs no autoplace-control plumbing (which - * `sampleExpression` has no way to apply to a planet surface anyway). - */ -async function captureFulgoraCells(): Promise { - const seed = 123456; - const planet = "fulgora"; - const positions = fulgoraCapturePositions(); - const sample = (expression: string) => sampleFulgora(expression, positions, seed); - - const NAMES = [ - "fulgora_cells", - "fulgora_pyramids", - "fulgora_spots", - "fulgora_spots_inv", - "fulgora_blanks", - "fulgora_mesa", - "fulgora_sprawl", - "fulgora_vaults", - "fulgora_vaults_and_starting_vault", - ] as const; - - const fields: Record = {}; - for (const name of NAMES) { - fields[name] = await sample(name); - console.log(` captured ${name}`); - } - - // The grid_size truncation probe. 155.65736389160156 is what fulgora_grid - // really is at islands frequency 2; 155 and 156 are the integers a truncating - // or rounding call would use. Same seeds/jitter/distance as fulgora_cells so - // only grid_size varies. - const FRACTIONAL_GRID = 155.65736389160156; - const gridProbe: Record = {}; - for (const [label, gridSize] of [ - ["fractional", String(FRACTIONAL_GRID)], - ["truncated", "155"], - ["rounded", "156"], - ] as const) { - gridProbe[label] = await sample( - `voronoi_cell_id{x = fulgora_wx, y = fulgora_wy, seed0 = map_seed, ` + - `seed1 = 'fulgora_cells', grid_size = ${gridSize}, ` + - `distance_type = 'manhattan', jitter = 0.6}`, - ); - console.log(` captured grid probe: ${label} (grid_size = ${gridSize})`); - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.14 (Space Age enabled) via the test/oracle harness: " + - "Fulgora's Voronoi layer (cells / pyramids / spots / spots_inv) and the island " + - "classification built on it (blanks / mesa / sprawl / vaults / " + - "vaults_and_starting_vault), against a real Fulgora surface. Positions are IDENTICAL to " + - "oracle-fulgora-shared.seed123456.json, so the two fixtures line up index-for-index. " + - "gridSizeProbe answers whether the voronoi call truncates grid_size to a u16: it samples " + - "voronoi_cell_id at a FRACTIONAL grid_size (155.65736389160156, which is what fulgora_grid " + - "really is at islands frequency 2) against the two integers it sits between. The default " + - "grid of exactly 175 cannot discriminate, which is why the probe uses a literal. " + - "Regenerate: node --experimental-strip-types test/oracle/capture.ts fulgora-cells", - seed0: seed, - planet, - positions, - ...fields, - gridSizeProbe: { - fractionalGridSize: FRACTIONAL_GRID, - truncatedGridSize: 155, - roundedGridSize: 156, - ...gridProbe, - }, - }; - const out = join(FIXTURES, "oracle-fulgora-cells.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${String(positions.length)} positions)`); -} - -/** - * Fulgora's elevation mix chain - the 20 named expressions between the Voronoi - * layer and `fulgora_elevation` itself. - * - * `fulgora_vault_pyramids_and_start` and `fulgora_pre_elevation` are captured - * even though they are internal to the chain. They are cheap and they localise - * a fault: without them, a transcription error in either one only ever shows up - * blended into `moats` or `elevation`, several steps downstream of its cause. - * - * The `sliderRescaleProbe` is here for the same reason the cells fixture - * carries a `gridSizeProbe`. `fulgora_natural` multiplies by - * `slider_rescale(control:fulgora_islands:size, 2)`, and at the DEFAULT size - * slider of 1 that is `2^0 = 1` exactly - so the 101 captured positions cannot - * say anything about how the game evaluates the function. The probe passes - * literal slider values instead, and deliberately includes 0.5, 2, 3, 4 and 5: - * at s = 1 and s = 6 the exponent is exactly 0 and exactly 1, so those two rows - * are blind by construction and would "confirm" any implementation at all. - */ -async function captureFulgoraElevation(): Promise { - const seed = 123456; - const planet = "fulgora"; - const positions = fulgoraCapturePositions(); - const sample = (expression: string) => sampleFulgora(expression, positions, seed); - - const NAMES = [ - // The five multioctave sources. - "fulgora_basis", - "fulgora_basis_oil", - "fulgora_rock", - "fulgora_dunes", - "fulgora_scrap_medium", - // The mix chain, in dependency order. - "fulgora_natural", - "fulgora_sprawl_pyramids", - "fulgora_vault_pyramids", - "fulgora_vault_pyramids_and_start", - "fulgora_moats", - "fulgora_mix_pyramids", - "fulgora_mix_natural", - "fulgora_mix_moats", - "fulgora_vault_spots", - "fulgora_mix_spots", - "fulgora_oil_mask", - "fulgora_mix_oil", - "fulgora_sand_basins", - "fulgora_pre_elevation", - "fulgora_elevation", - ] as const; - - const fields: Record = {}; - for (const name of NAMES) { - fields[name] = await sample(name); - console.log(` captured ${name}`); - } - - // slider_rescale(s, 2) = 2^(log2(s)/log2(6)*log2(2)). One position is enough - - // it does not depend on x or y - but the harness samples a list, so take the - // first value and assert the rest agree. - const SLIDERS = [0.5, 1, 2, 3, 4, 5, 6] as const; - const oneProbePosition = positions.slice(0, 3); - const sliderRescale: Record = {}; - for (const s of SLIDERS) { - const values = await sampleFulgora(`slider_rescale(${String(s)}, 2)`, oneProbePosition, seed); - const first = values[0] as number; - if (values.some((v) => v !== first)) { - throw new Error(`slider_rescale(${String(s)}, 2) varied with position: ${values.join(", ")}`); - } - sliderRescale[String(s)] = first; - console.log(` captured slider_rescale(${String(s)}, 2) = ${String(first)}`); - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.14 (Space Age enabled) via the test/oracle harness: " + - "Fulgora's elevation mix chain - the five multioctave sources (basis, basis_oil, rock, " + - "dunes, scrap_medium) and every named expression from fulgora_natural through " + - "fulgora_elevation, against a real Fulgora surface. Positions are IDENTICAL to " + - "oracle-fulgora-shared.seed123456.json and oracle-fulgora-cells.seed123456.json, so all " + - "three fixtures line up index-for-index. vault_pyramids_and_start and pre_elevation are " + - "internal to the chain and captured anyway, so a transcription error in either localises " + - "instead of surfacing blended into elevation. sliderRescaleProbe samples " + - "slider_rescale(s, 2) at literal slider values because the DEFAULT islands size of 1 " + - "makes it exactly 1 - the captured positions cannot discriminate any implementation of " + - "it. Regenerate: node --experimental-strip-types test/oracle/capture.ts fulgora-elevation", - seed0: seed, - planet, - positions, - ...fields, - sliderRescaleProbe: sliderRescale, - }; - const out = join(FIXTURES, "oracle-fulgora-elevation.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${String(positions.length)} positions)`); -} - -/** - * Fulgora's road, structure and ruins layer - everything the eight land tiles - * read that the elevation chain does not. - * - * Positions are `fulgoraCapturePositions()`, identical to the shared, cells and - * elevation fixtures, so all four line up index for index. - * - * Two fields here are captured because the port cannot settle them by reading: - * `fulgora_pyramids_banding` and `fulgora_spots_banding` are the noise - * machine's `%` operator, whose behaviour on a negative left operand is not - * stated anywhere in the docs. The OPERAND is what would settle it - - * `fulgora_pyramids * 8` for the first, `fulgora_spots_prebanding` (captured - * directly below) for the second - not either field's post-modulo result. At - * these 101 positions the operand never goes negative (`fulgora_pyramids * 8` - * minimum 0.022018, `fulgora_spots_prebanding` minimum 0.70791), so the - * fixture does not decide the sign convention; see - * `docs/noise/fulgora-elevation-NOTES.md`'s Task 13 for the wider-map sweep - * that does. - */ -async function captureFulgoraRuins(): Promise { - const seed = 123456; - const planet = "fulgora"; - const positions = fulgoraCapturePositions(); - const sample = (expression: string) => sampleFulgora(expression, positions, seed); - - const NAMES = [ - // The masks. - "fulgora_natural_mask", - "fulgora_natural_and_mesa_mask", - "fulgora_artificial_mask", - // The road and structure layer, in dependency order. - "fulgora_road_cells", - "fulgora_road_pyramids", - "fulgora_pyramids_banding", - "fulgora_spots_prebanding", - "fulgora_spots_banding", - "fulgora_structure_cells", - "fulgora_structure_subnoise", - "fulgora_structure_facets", - "fulgora_road_paving_thin", - "fulgora_road_paving_2", - "fulgora_road_paving_2b", - "fulgora_road_paving_2c", - "fulgora_road_dust", - // The ruins layer. - "fulgora_ruins_walls", - "fulgora_ruins_paving", - "fulgora_tile_ruin_paving", - "fulgora_tile_ruin_walls", - "fulgora_tile_ruin_conduit", - "fulgora_tile_ruin_machinery", - ] as const; - - /** - * The four land tiles whose `probability_expression` is a COMPOSITE rather - * than a bare named expression, keyed by the fixture field they become. - * - * **Copied verbatim from `tiles-fulgora.lua`** (`fulgoran-dust` line 293, - * `-dunes` 330, `-sand` 367, `-rock` 404) - do not tidy the spacing, since - * the point of sampling them is that the GAME parses this exact string. - * - * Why these four and not all eight: the other four land tiles - * (`fulgoran-paving`, `-walls`, `-conduit`, `-machinery`) declare a bare - * `fulgora_tile_ruin_*` name, which is already captured above as a named - * expression. These four have no name of their own, so before this they were - * the only Fulgora expressions in the port with no bound-checked row - the - * argmax's winner was the only thing standing behind them, and an argmax - * carrying a 5.5% unexplained residual cannot clear a formula. See - * `landProbabilitiesFrom` in `src/noise/tiles/fulgoraCatalog.ts` for the - * transcription these check. - * - * `sampleFulgora` registers whatever string it is given as a new - * `noise-expression` prototype, so an arbitrary composite works exactly the - * same way a name does - the game does the parsing and the evaluating. - */ - const COMPOSITE_PROBABILITIES = [ - [ - "fulgoran_dust_probability", - "fulgora_scrap_medium + max(0, fulgora_natural, 2 * fulgora_mesa * fulgora_pyramids) * 2 - 0.9 + fulgora_rock + fulgora_road_dust * fulgora_sprawl", - ], - ["fulgoran_dunes_probability", "1 + fulgora_dunes"], - ["fulgoran_sand_probability", "1 - fulgora_dunes"], - ["fulgoran_rock_probability", "0.8 + fulgora_rock * 2 - max(0, fulgora_mix_oil) * 6"], - ] as const; - - const fields: Record = {}; - for (const name of NAMES) { - fields[name] = await sample(name); - console.log(` captured ${name}`); - } - for (const [key, expression] of COMPOSITE_PROBABILITIES) { - fields[key] = await sample(expression); - console.log(` captured ${key}`); - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.14 (Space Age enabled) via the test/oracle harness: " + - "Fulgora's mask, road/structure and ruins layer - the 22 named expressions the eight " + - "land tiles read that the elevation chain does not - plus the four COMPOSITE " + - "probability_expressions (fulgoran_dust/dunes/sand/rock_probability), sampled as the " + - "verbatim expression strings from tiles-fulgora.lua because those four tiles declare " + - "no named expression of their own. Positions are IDENTICAL to " + - "oracle-fulgora-shared/cells/elevation.seed123456.json, so all four line up " + - "index-for-index. The intermediate paving stages (2, 2b, 2c) are captured as well as " + - "the four tile_ruin outputs so a transcription error localises instead of surfacing " + - "blended. Regenerate: node --experimental-strip-types test/oracle/capture.ts fulgora-ruins", - seed0: seed, - planet, - positions, - ...fields, - }; - const out = join(FIXTURES, "oracle-fulgora-ruins.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${String(positions.length)} positions)`); -} - -/** - * Scrap's `probability_expression`, sampled from the game with its - * `local_expressions` inlined. - * - * Every FIELD it reads is already covered by the shared/cells/elevation/ruins - * fixtures, so this exists to cover the one thing they cannot: the COMPOSITION, - * including operator precedence and the two `min`s. Positions deliberately span - * the whole range from zero to the 0.5 cap - a sample that only hit zeros would - * pass against a stub. - */ -async function captureFulgoraScrap(): Promise { - const seed = 123456; - const positions = fulgoraCapturePositions(); - const sample = (expression: string) => sampleFulgora(expression, positions, seed); - - const STRUCT = - "(fulgora_structure_cells < min(0.1 * control:scrap:frequency, 0.05 + 0.05 * control:scrap:frequency))" + - " * (1 + fulgora_structure_subnoise)" + - " * (fulgora_elevation > (fulgora_coastline + 10))" + - " * fulgora_artificial_mask"; - const VAULT = - "(fulgora_spots_prebanding < (1.2 + 0.4 * slider_to_linear(control:scrap:size, -1, 1)))" + - " * fulgora_vaults_and_starting_vault * 10"; - const EXPRS: Record = { - fulgora_scrap_probability: `(control:scrap:size > 0) * (1 - fulgora_starting_mask) * (min(${STRUCT} + ${VAULT}, 0.5) * (1 - fulgora_road_paving_2c))`, - fulgora_scrap_struct_term: STRUCT, - fulgora_scrap_vault_term: VAULT, - scrap_control_frequency: "control:scrap:frequency", - scrap_control_size: "control:scrap:size", - }; - - const fields: Record = {}; - for (const [name, expr] of Object.entries(EXPRS)) { - fields[name] = await sample(expr); - console.log(` captured ${name}`); - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.14 (Space Age enabled) via the test/oracle harness: the " + - "scrap resource's probability_expression with its local_expressions inlined, plus its two " + - "additive terms and the three control levers read back, on a real Fulgora surface " + - "(game.planets['fulgora'].create_surface(), seed FORCED to 123456 - NOT the derived " + - "mapSeed + crc32('fulgora')). Every FIELD the expression reads is already covered by the " + - "shared/cells/elevation/ruins fixtures; this covers the COMPOSITION, which nothing else " + - "does. The control rows are the non-vacuity check: a default surface must report 1 for " + - "frequency and size, which is what the composition assumes. Regenerate: node " + - "--experimental-strip-types test/oracle/capture.ts fulgora-scrap", - seed0: seed, - planet: "fulgora", - positions, - ...fields, - }; - const out = join(FIXTURES, "oracle-fulgora-scrap.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${String(positions.length)} positions)`); -} - -/** - * Every scrap entity the game actually places in three regions. - * - * This is what gates DENSITY, and it is not interchangeable with the preview - * PNGs: `map_grid` defaults to true, so the preview draws solid ores in a - * checkerboard of 2x2 tile blocks and shows only ~0.5 pixels per entity. A pixel - * diff would therefore bake a 2x under-placement into the renderer. Measured on - * these exact regions: the model's clamped expectation is 0.9836 per real - * entity, inside Poisson noise at n = 770. - */ -async function captureFulgoraScrapEntities(): Promise { - const seed = 123456; - const regions: Region[] = [ - { x0: 0, y0: 0, x1: 256, y1: 256 }, - { x0: -1200, y0: 800, x1: -944, y1: 1056 }, - { x0: 800, y0: -1600, x1: 1056, y1: -1344 }, - ]; - const cases: unknown[] = []; - for (const region of regions) { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const dump = await sampleCliffEntitiesFull(region, { - workDir, - seed, - spaceAge: true, - planet: "fulgora", - entityType: "resource", - alsoResources: true, - protoNames: ["scrap"], - }); - cases.push({ region, resources: dump.resources, protos: dump.protos }); - console.log( - ` [${String(region.x0)},${String(region.y0)}] -> ${String(dump.resources?.length ?? -1)} scrap`, - ); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - const fixture = { - _comment: - "Ground truth from Factorio 2.1.14 via test/oracle: every resource entity " + - "(find_entities_filtered{type='resource'}) the game placed in each region on FULGORA at the " + - "DEFAULT preset, after chunk-forced generation, on a create_surface() surface whose seed is " + - "FORCED to `seed`. This is the DENSITY oracle and the preview PNGs cannot replace it: " + - "ResourceEntityPrototype::map_grid defaults to true, so the game's map preview draws solid " + - "ores in a 2x2-block checkerboard and shows about 0.5 pixels per entity. `protos` records " + - "scrap's collision box and map_grid read off the running game. Regenerate: node " + - "--experimental-strip-types test/oracle/capture.ts fulgora-scrap-entities", - seed0: seed, - planet: "fulgora", - cases, - }; - const out = join(FIXTURES, "oracle-fulgora-scrap-entities.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${String(cases.length)} regions)`); -} - -/** - * The tile the GAME actually placed on Fulgora - `surface.get_tile(x, y).name` - * after real chunk generation, which no `sampleExpression` can report. - * - * Two samples on purpose, and neither alone is enough: - * - * - A contiguous **256x256 block** at stride 4, centred on a coastline. This is - * what tests the land/ocean boundary, which is the whole point - a resolver - * can be right in the middle of an island and in the middle of the ocean - * while getting every shore wrong. The centre was chosen by asking the PORT - * for the 256x256 block nearest a 50/50 oil-mask split (it lands at - * (-1500, 1000), 0.500), so the game is being asked about the hardest terrain - * rather than a convenient patch. That the block really is mixed is then - * asserted from the GAME's own names, not from the port's choice. - * - A **coarse 12000-tile grid** at stride 400. The block spans about 1.5 - * Voronoi cells, so on its own it exercises only a couple of islands; the - * grid crosses many, and its ~74% ocean fraction is close to the map's own. - * - * `oil-ocean-shallow` / `-shallow-2` and `oil-ocean-deep` / `-deep-2` are pairs - * that share a map colour, so the resolver only has to get shallow-versus-deep - * right, not which variant of each. - */ -async function captureFulgoraTiles(): Promise { - const seed = 123456; - const planet = "fulgora"; - - const BLOCK = { x: -1500, y: 1000, half: 128, stride: 4 }; - const positions: Position[] = []; - const seen = new Set(); - const push = (x: number, y: number): void => { - const k = `${String(x)},${String(y)}`; - if (seen.has(k)) return; - seen.add(k); - // Sample the tile's own integer coordinate; `sampleTileNames` echoes the - // floored `get_tile` input back, so the fixture records what was asked. - positions.push({ x: x + 0.5, y: y + 0.5 }); - }; - for (let dy = -BLOCK.half; dy < BLOCK.half; dy += BLOCK.stride) { - for (let dx = -BLOCK.half; dx < BLOCK.half; dx += BLOCK.stride) { - push(BLOCK.x + dx, BLOCK.y + dy); - } - } - const COARSE = { reach: 6000, stride: 400 }; - for (let y = -COARSE.reach; y <= COARSE.reach; y += COARSE.stride) { - for (let x = -COARSE.reach; x <= COARSE.reach; x += COARSE.stride) { - push(x, y); - } - } - - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const samples: TileSample[] = await sampleTileNames(positions, { - workDir, - seed, - spaceAge: true, - planet, - }); - const fixture = { - _comment: - "Ground truth from Factorio 2.1.14 (Space Age enabled) via the test/oracle harness: " + - "surface.get_tile(x, y).name on a real Fulgora surface " + - "(game.planets['fulgora'].create_surface(), seed 123456) after real chunk generation - " + - "the tile the game actually PLACED, which sampleExpression cannot report. Two samples: " + - "a contiguous 256x256 block at stride 4 centred on (-1500, 1000), which the port " + - "identified as the nearest-to-50/50 land/ocean block and which is therefore where the " + - "coastline is; plus a coarse stride-400 grid out to +/-6000 tiles, because the block " + - "spans only ~1.5 Voronoi cells while the grid crosses many. positions are the mod's " + - "ECHOED floored get_tile input. Regenerate: node --experimental-strip-types " + - "test/oracle/capture.ts fulgora-tiles", - seed0: seed, - planet, - block: BLOCK, - coarse: COARSE, - positions: samples.map((s) => ({ x: s.x, y: s.y })), - tileNames: samples.map((s) => s.name), - }; - const out = join(FIXTURES, "oracle-fulgora-tiles.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - const counts = new Map(); - for (const n of fixture.tileNames) counts.set(n, (counts.get(n) ?? 0) + 1); - console.log( - `wrote ${out} (${String(positions.length)} points)\n ` + - [...counts.entries()] - .sort((a, b) => b[1] - a[1]) - .map(([n, c]) => `${n}=${String(c)}`) - .join(", "), - ); - } finally { - await rm(workDir, { recursive: true, force: true }); - } -} - -if (want("fulgora-shared")) await captureFulgoraShared(); -if (want("fulgora-cells")) await captureFulgoraCells(); -if (want("fulgora-elevation")) await captureFulgoraElevation(); -if (want("fulgora-ruins")) await captureFulgoraRuins(); -if (want("fulgora-scrap")) await captureFulgoraScrap(); -if (want("fulgora-scrap-entities")) await captureFulgoraScrapEntities(); -if (want("fulgora-tiles")) await captureFulgoraTiles(); - -if (want("basis")) await captureBasis(); -if (want("multioctave")) await captureMultioctave(); -if (want("quick")) await captureQuickMultioctave(); -if (want("variable-persistence")) await captureVariablePersistenceMultioctave(); -if (want("multioctave-wrappers")) await captureMultioctaveWrappers(); -if (want("elevation-lakes")) await captureElevationLakes(); -if (want("elevation-nauvis")) await captureElevationNauvis(); -if (want("elevation-nauvis-no-cliff")) await captureElevationNauvisNoCliff(); -if (want("elevation-island")) await captureElevationIsland(); -if (want("temperature")) await captureTemperature(); -if (want("aux")) await captureAux(); -if (want("moisture")) await captureMoisture(); -if (want("expression-in-range")) await captureExpressionInRange(); -if (want("fastpow")) await captureFastPow(); -if (want("random-penalty")) await captureRandomPenalty(); -if (want("resource-regular")) await captureResourceRegular(); -if (want("resource-starting")) await captureResourceStarting(); -if (want("tile-names")) await captureTileNames(); -if (want("enemy-base")) await captureEnemyBase(); -if (want("trees")) await captureTrees(); -if (want("trees-controls")) await captureTreesControls(); -if (want("cliff-elevation")) await captureCliffElevation(); -if (want("cliffiness")) await captureCliffiness(); -if (want("cliff-offset-raw")) await captureCliffOffsetRaw(); -/** - * Every ACTUAL Vulcanus cliff entity in three regions - the entity-level ground - * truth Vulcanus cliffs have never had (issue #18). Nauvis's equivalent - * (`captureCliffEntities`) validates that port at ~94% tile-for-tile; Vulcanus - * had only "the noise field matches to 5e-6 and the geometry is the same code", - * which is an argument rather than a measurement. - * - * Regions are the three windows the mark-size work already measured coverage - * over, so the fixture can settle whether that 34% window is a real cliff field - * or a painting artefact: - * - * | region | measured cliff-pixel coverage at 1 tile/px | - * | --- | --- | - * | `[0,0]` | 10.3% | - * | `[1500,1500]` | 34.2% | - * | `[-1200,800]` | 11.1% | - * - * They are 256x256 rather than the Nauvis capture's 512x512 because the dense - * one holds several thousand cliffs and chunk generation cost scales with area. - * - * Runs on Vulcanus's own surface with the seed FORCED (`spaceAge: true`), which - * is what every committed Vulcanus fixture does - see `entityCounts.ts`'s header - * for why that matters and what it commits the comparing spec to. - */ -async function captureVulcanusCliffEntities(): Promise { - const regions: Region[] = [ - { x0: 0, y0: 0, x1: 256, y1: 256 }, - { x0: 1500, y0: 1500, x1: 1756, y1: 1756 }, - { x0: -1200, y0: 800, x1: -944, y1: 1056 }, - ]; - const seed = 123456; - const cases: { region: Region; cliffs: Position[] }[] = []; - for (const region of regions) { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const cliffs = await sampleCliffEntities(region, { - workDir, - seed, - spaceAge: true, - planet: "vulcanus", - }); - cases.push({ region, cliffs }); - console.log( - ` captured vulcanus cliffs [${String(region.x0)},${String(region.y0)}] (${String(cliffs.length)} cliffs)`, - ); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 via test/oracle. Every cliff entity " + - "(find_entities_filtered{type='cliff'}) the game placed in each region on VULCANUS at the " + - "DEFAULT preset, after chunk-forced generation. Positions are cliff cell centers on the " + - "4-tile grid, and each entry carries the entity's `orientation` " + - "(LuaEntity.cliff_orientation), added 2026-07-30 - it makes this a direct end-to-end " + - "oracle for CLIFF_CODE_TO_ORIENTATION (see test/cliffOrientationOracle.spec.ts) and gives " + - "the true collision box for cliffs the port does NOT place, which is otherwise " + - "unobtainable. The re-capture reproduced the 2026-07-28 positions exactly. Sampled on a " + - "create_surface() surface whose seed is FORCED to `seed` (like " + - "every other Vulcanus oracle fixture), not the derived mapSeed + crc32('vulcanus') - so a " + - "comparing spec builds its ctx from `seed` directly. Compared against " + - "makeVulcanusCliffFields + makeCliffPlacementFromFields in " + - "test/vulcanusCliffEntities.spec.ts. Regenerate: node --experimental-strip-types " + - "test/oracle/capture.ts vulcanus-cliff-entities", - seed, - cases, - }; - const out = join(FIXTURES, "oracle-vulcanus-cliff-entities.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${String(cases.length)} regions)`); -} - -/** - * **The `cliff_smoothing` sweep** - the same Vulcanus region captured at several - * values of `map_gen_settings.cliff_settings.cliff_smoothing` (issue #18). - * - * Every other cliff fixture samples the one setting the planet ships with, so - * the smoothing transform could only ever be tested as a whole. Overriding it - * turns one data point into a family, and the decisive member is **`s = 0`**: - * with smoothing off, the cliff elevation IS the raw field, which is measured - * accurate to a max of 4.8e-2, over a rule that reproduces Nauvis 334/334. So - * the port must match the game exactly at `s = 0`. If it does, the entire - * residual is inside the smoothing and the disassembly is the only thing left to - * read. **If it does NOT, the smoothing is innocent** and 2026-08-01's whole - * sweep - knots, clamp, anchor, blend, interpolation family - was searching the - * wrong transform. - * - * `[0,0]` is the region deliberately chosen: it is where the port is worst - * (29.8% wrong orientation against 8.1% and 11.7% elsewhere), so it has the most - * signal, and it contains zero `crater-cliff`s to exclude. - * - * Each case records the `cliffSettings` the surface reported back. That is not - * bookkeeping: without it, an override that silently failed to apply would look - * exactly like a setting that does not matter. - */ -async function captureVulcanusCliffSmoothing(): Promise { - const region: Region = { x0: 0, y0: 0, x1: 256, y1: 256 }; - const seed = 123456; - const cases: { - cliffSmoothing: number; - effective: DumpedCliffSettings | undefined; - cliffs: Position[]; - }[] = []; - for (const cliffSmoothing of [0, 0.5, 1]) { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const dump = await sampleCliffEntitiesFull(region, { - workDir, - seed, - spaceAge: true, - planet: "vulcanus", - cliffSettings: { cliff_smoothing: cliffSmoothing }, - }); - cases.push({ cliffSmoothing, effective: dump.cliffSettings, cliffs: dump.cliffs }); - console.log( - ` captured vulcanus cliffs smoothing=${String(cliffSmoothing)} ` + - `(effective ${String(dump.cliffSettings?.cliff_smoothing)}, ${String(dump.cliffs.length)} cliffs)`, - ); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 via test/oracle. Every cliff entity the game placed in " + - "the Vulcanus region [0,0]-[256,256] at THREE values of " + - "map_gen_settings.cliff_settings.cliff_smoothing (0, 0.5, 1), with each entity's " + - "cliff_orientation. Exists for issue #18: every other cliff fixture samples only the " + - "setting the planet ships with (the prototype default of 1), so the smoothing transform " + - "could be tested only as a whole. The s=0 case is the discriminating one - with smoothing " + - "off the cliff elevation is the raw field, which agrees with ours to a max of 4.8e-2 over " + - "a rule that reproduces Nauvis 334/334, so the port must match the game exactly there. " + - "`effective` is the cliff_settings the SURFACE reported back after the override, not what " + - "was written, so an override that failed to apply cannot be mistaken for a setting that " + - "does not matter. Sampled on a create_surface() surface whose seed is FORCED to `seed`, " + - "like every other Vulcanus oracle fixture. Regenerate: node --experimental-strip-types " + - "test/oracle/capture.ts vulcanus-cliff-smoothing", - seed, - region, - cases, - }; - const out = join(FIXTURES, "oracle-vulcanus-cliff-smoothing.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${String(cases.length)} smoothing values)`); -} - -/** - * **The cliff rule COLLAPSED, one term at a time** (issue #18). - * - * `cliff_settings` holds every constant the placement rule uses, and all of them - * are settable on the surface - so instead of modelling a term and arguing about - * it, the term can simply be turned OFF in the game: - * - * - `cliff_smoothing = 0` removes the smoothing, leaving the RAW elevation. - * - `cliff_elevation_interval` set huge leaves a **single contour** at - * `cliff_elevation_0`, so there is no band arithmetic left to be wrong about. - * - `richness = 4` makes `cliffiness_basic`'s `0.5*log2(4) = 1`, so - * `clamp(1 + noise, 0, 1) + 0.5` saturates at 1.5 and the `> 0.5` gate is open - * essentially everywhere. - * - * With all three the rule reduces to **"an edge crosses iff elevation crosses - * 70"**, which turns the game's own cliffs into a direct readout of - * `sign(elevation - 70)` at the generator's own sample points. Our raw elevation - * is known to agree with the game's to a max of 4.8e-2, so any disagreement at a - * corner further than that from 70 is proof the generator is not reading the - * field we think it is - which is the "how does the engine store or round it" - * question, asked as an experiment rather than a disassembly. - * - * The arms are cumulative on purpose, so whichever one first fails to reproduce - * the game names the term that is wrong. - */ -async function captureVulcanusCliffCollapsed(): Promise { - const region: Region = { x0: 0, y0: 0, x1: 256, y1: 256 }; - const seed = 123456; - // A single contour: no Vulcanus corner reaches 70 + 1e6. - const ONE_BAND = 1000000; - const arms: { label: string; cliffSettings: Record }[] = [ - { - label: "raw elevation, bands, gate (smoothing off only)", - cliffSettings: { cliff_smoothing: 0 }, - }, - { - label: "raw elevation, SINGLE contour at 70, gate", - cliffSettings: { cliff_smoothing: 0, cliff_elevation_interval: ONE_BAND }, - }, - { - label: "raw elevation, SINGLE contour at 70, NO gate (richness 4)", - cliffSettings: { cliff_smoothing: 0, cliff_elevation_interval: ONE_BAND, richness: 4 }, - }, - { - label: "raw elevation, bands, NO gate (richness 4)", - cliffSettings: { cliff_smoothing: 0, richness: 4 }, - }, - ]; - const cases: { - label: string; - settings: Record; - effective: DumpedCliffSettings | undefined; - cliffs: Position[]; - }[] = []; - for (const arm of arms) { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const dump = await sampleCliffEntitiesFull(region, { - workDir, - seed, - spaceAge: true, - planet: "vulcanus", - cliffSettings: arm.cliffSettings, - }); - cases.push({ - label: arm.label, - settings: arm.cliffSettings, - effective: dump.cliffSettings, - cliffs: dump.cliffs, - }); - console.log( - ` captured ${arm.label} -> ${String(dump.cliffs.length)} cliffs ` + - `(effective interval=${String(dump.cliffSettings?.cliff_elevation_interval)} ` + - `richness=${String(dump.cliffSettings?.richness)} ` + - `smoothing=${String(dump.cliffSettings?.cliff_smoothing)})`, - ); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - // THE CONTROL. The same collapse on Nauvis, whose cliff_elevation - // (`cliff_elevation_nauvis`) contains no `multisample` and which the port - // already reproduces 334/334. If Nauvis stays exact under the collapsed rule - // while Vulcanus does not, the rule and the lattice are cleared and the - // difference is the elevation FIELD, not the placement. - const nauvisRegion: Region = { x0: 512, y0: 512, x1: 1024, y1: 1024 }; - const nauvisCases: { - label: string; - effective: DumpedCliffSettings | undefined; - cliffs: Position[]; - }[] = []; - // NB `richness` is deliberately NOT raised here, unlike the Vulcanus arms. - // It acts in the OPPOSITE direction on Nauvis: `cliffiness_nauvis` is - // `(main_cliffiness >= cliff_cutoff) * 10` with the cutoff derived from - // richness, so richness = 4 raises the cutoff until nothing qualifies and the - // region comes back with ZERO cliffs (measured). Only the interval is - // collapsed on this arm. - // A single contour at Nauvis's own `cliff_elevation_0` of 10 is NOT a usable - // arm: it comes back with zero cliffs (measured), because Nauvis's cliffs sit - // on the higher bands and almost nothing crosses 10. The arms below instead - // change the interval to a value that still populates the region, which is - // what the control actually needs - does our rule track the game's when a - // cliff SETTING moves? - and a single contour placed where the field lives. - const nauvisArms: { label: string; cliffSettings?: Record }[] = [ - { label: "nauvis baseline (no override)" }, - { label: "nauvis interval 80", cliffSettings: { cliff_elevation_interval: 80 } }, - { - label: "nauvis SINGLE contour at 50", - cliffSettings: { cliff_elevation_0: 50, cliff_elevation_interval: ONE_BAND }, - }, - ]; - for (const arm of nauvisArms) { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const dump = await sampleCliffEntitiesFull(nauvisRegion, { - workDir, - seed, - cliffSettings: arm.cliffSettings, - }); - nauvisCases.push({ label: arm.label, effective: dump.cliffSettings, cliffs: dump.cliffs }); - console.log( - ` captured ${arm.label} -> ${String(dump.cliffs.length)} cliffs ` + - `(effective interval=${String(dump.cliffSettings?.cliff_elevation_interval)} ` + - `richness=${String(dump.cliffSettings?.richness)})`, - ); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - - const fixture = { - nauvisRegion, - nauvisCases, - _comment: - "Ground truth from Factorio 2.1.12 via test/oracle. The Vulcanus region [0,0]-[256,256] with " + - "the cliff placement rule COLLAPSED one term at a time through map_gen_settings.cliff_settings " + - "(issue #18). cliff_smoothing=0 leaves the raw elevation; cliff_elevation_interval=1e6 leaves a " + - "SINGLE contour at cliff_elevation_0=70, so no band arithmetic remains; richness=4 makes " + - "cliffiness_basic's 0.5*log2(4)=1 so it saturates at 1.5 and its >0.5 gate is always open. With " + - "all three the rule is just 'an edge crosses iff elevation crosses 70', making the game's cliffs " + - "a direct readout of sign(elevation - 70) at the generator's own sample points. `effective` is " + - "the cliff_settings the SURFACE reported back, so an override that failed to apply cannot be " + - "mistaken for a term that does not matter. Arms are cumulative so the first one that stops " + - "reproducing the game names the wrong term. Sampled on a create_surface() surface whose seed is " + - "FORCED to `seed`, like every other Vulcanus oracle fixture. Regenerate: node " + - "--experimental-strip-types test/oracle/capture.ts vulcanus-cliff-collapsed", - seed, - region, - cliffElevation0: 70, - cases, - }; - const out = join(FIXTURES, "oracle-vulcanus-cliff-collapsed.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${String(cases.length)} arms)`); -} - -/** - * **A level-set sweep that INVERTS the generator's own elevation field** (#18). - * - * With the rule collapsed (`cliff_smoothing = 0`, a single contour via - * `cliff_elevation_interval = 1e6`, the cliffiness gate held open by - * `richness = 4`), a cell carries a cliff exactly when its corner elevations - * **straddle** `cliff_elevation_0`. Sweeping that threshold therefore brackets - * every cell: the set of levels at which a cell is "mixed" is - * `(min of its corners, max of its corners]`, to the resolution of the step. - * - * That converts the game's cliffs into a **measurement of the elevation field - * the generator actually reads**, which is the one thing no expression sample - * can give - `calculate_tile_properties` answers for its own channel, and the - * open question is precisely whether the generator's channel agrees. - * - * The quantity to compare is the per-cell corner SPREAD (`max - min`), because - * that is what the collapsed arm's over-placement implicates: we place 463 - * cliffs where the game places 335, so our 70-contour is ~38% longer, i.e. our - * field is rougher at the 4-tile scale. If our spreads come out systematically - * wider than the game's, that is the roughness difference quantified rather - * than inferred, and it bounds how much smoothing the generator applies. - * - * Step 10 over `[20, 200]` is chosen against the field itself: adjacent corners - * (4 tiles apart) differ by tens of units, so a step of 10 resolves a spread - * difference well below the effect being measured, and 19 levels keeps the - * capture near ten minutes. - */ -async function captureVulcanusElevationLevels(): Promise { - const region: Region = { x0: 0, y0: 0, x1: 256, y1: 256 }; - const seed = 123456; - const ONE_BAND = 1000000; - const levels: number[] = []; - for (let e0 = 20; e0 <= 200; e0 += 10) levels.push(e0); - - const cases: { - elevation0: number; - effective: DumpedCliffSettings | undefined; - cliffs: Position[]; - }[] = []; - for (const elevation0 of levels) { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const dump = await sampleCliffEntitiesFull(region, { - workDir, - seed, - spaceAge: true, - planet: "vulcanus", - cliffSettings: { - cliff_smoothing: 0, - cliff_elevation_interval: ONE_BAND, - cliff_elevation_0: elevation0, - richness: 4, - }, - }); - cases.push({ elevation0, effective: dump.cliffSettings, cliffs: dump.cliffs }); - console.log( - ` level ${String(elevation0)} -> ${String(dump.cliffs.length)} cliffs ` + - `(effective e0=${String(dump.cliffSettings?.cliff_elevation_0)})`, - ); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 via test/oracle. The Vulcanus region [0,0]-[256,256] " + - "captured at 19 values of cliff_elevation_0 (20..200 step 10) with the placement rule " + - "COLLAPSED: cliff_smoothing=0 (raw elevation), cliff_elevation_interval=1e6 (a single " + - "contour at cliff_elevation_0, no band arithmetic) and richness=4 (cliffiness_basic " + - "saturates at 1.5 so its >0.5 gate is always open). Under those settings a cell carries a " + - "cliff exactly when its corner elevations STRADDLE cliff_elevation_0, so the set of levels " + - "at which a cell appears brackets (min corner, max corner] - i.e. this fixture INVERTS the " + - "elevation field the generator itself reads, which no expression sample can do because " + - "calculate_tile_properties answers for a different channel. Exists to measure the per-cell " + - "corner spread against ours: the collapsed arm shows we place 463 cliffs where the game " + - "places 335, implying our field is rougher at the 4-tile scale. `effective` is the " + - "cliff_settings the SURFACE reported back. Sampled on a create_surface() surface whose seed " + - "is FORCED to `seed`. Regenerate: node --experimental-strip-types test/oracle/capture.ts " + - "vulcanus-elevation-levels", - seed, - region, - cases, - }; - const out = join(FIXTURES, "oracle-vulcanus-elevation-levels.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${String(cases.length)} levels)`); -} - -/** - * **Is `multisample` grid-dependent? Asked through the CLIFF GENERATOR** (#18). - * - * The level-set sweep narrowed the Vulcanus residual to - * `120 * vulcanus_basalt_lakes_multisample` - the only `multisample` in the - * elevation chain, and the only term with no Nauvis counterpart. The primitive's - * own documentation says it evaluates "in a separate noise program with a larger - * grid" whose "sub-grids are copied to the main program", i.e. it is explicitly - * grid-dependent; and `vulcanus-multisample-NOTES.md` established - * `multisample(e, dx, dy) == e(x + dx, y + dy)` at 150/150 - but measured it - * only through `calculate_tile_properties`, which is not the channel the cliff - * generator uses. - * - * This asks the primitive the same question through the OTHER channel. Routing - * a probe onto `cliff_elevation` and collapsing the rule (single contour, - * smoothing off, gate open) makes the cliff generator a readout: cliffs appear - * exactly where the routed field crosses `cliff_elevation_0`. - * - * With `x` as the field the contour is a vertical line, so the cliffs land in - * one column of cells - trivially readable, and a shift in the field moves the - * column. Corners sit 4 tiles apart, so the arms are chosen to make a real shift - * land a whole column away: - * - * - **A `x`** - the baseline, no multisample at all. - * - **B `multisample(x, 0, 0)`** - must equal A if multisample is the identity - * at zero offset in this channel. **If B differs from A, the primitive IS - * grid-dependent and that is issue #18.** - * - **C `multisample(x, 4, 0)`** - the POSITIVE control. A 4-tile shift must - * move the column by exactly one cell; if it does not, the experiment cannot - * detect a difference and B == A would mean nothing. - * - **D `multisample(x, 0, 4)`** - the NULL control. Shifting y cannot move a - * vertical contour, so D must equal A. Catches an axis mix-up. - * - * `cliff_elevation_0 = 71` deliberately avoids a corner landing exactly on the - * contour, where `crossesCliff`'s strict comparison would drop the crossing. - */ -async function captureMultisampleGrid(): Promise { - const region: Region = { x0: 0, y0: 0, x1: 256, y1: 256 }; - const seed = 123456; - const arms: { label: string; expression: string }[] = [ - { label: "A x (baseline, no multisample)", expression: "x" }, - { label: "B multisample(x, 0, 0)", expression: "multisample(x, 0, 0)" }, - { label: "C multisample(x, 4, 0) - positive control", expression: "multisample(x, 4, 0)" }, - { label: "D multisample(x, 0, 4) - null control", expression: "multisample(x, 0, 4)" }, - ]; - const cases: { - label: string; - expression: string; - effective: DumpedCliffSettings | undefined; - cliffs: Position[]; - }[] = []; - for (const arm of arms) { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const dump = await sampleCliffEntitiesFull(region, { - workDir, - seed, - spaceAge: true, - planet: "vulcanus", - probeExpression: arm.expression, - cliffSettings: { - cliff_smoothing: 0, - cliff_elevation_interval: 1000000, - cliff_elevation_0: 71, - richness: 4, - }, - }); - const columns = [...new Set(dump.cliffs.map((c) => c.x))].sort((a, b) => a - b); - cases.push({ - label: arm.label, - expression: arm.expression, - effective: dump.cliffSettings, - cliffs: dump.cliffs, - }); - console.log( - ` ${arm.label} -> ${String(dump.cliffs.length)} cliffs, columns x=${columns.join(",")}`, - ); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 via test/oracle. Asks whether `multisample` is " + - "grid-dependent by reading it through the CLIFF GENERATOR instead of " + - "calculate_tile_properties (issue #18). A probe expression is routed onto " + - "property_expression_names.cliff_elevation on a Vulcanus surface with the placement rule " + - "collapsed (cliff_smoothing=0, cliff_elevation_interval=1e6, cliff_elevation_0=71, " + - "richness=4), so cliffs appear exactly where the routed field crosses 71. With `x` as the " + - "field that contour is vertical and the cliffs land in a single column of cells, so a shift " + - "in the field moves the column. Arms: A `x` baseline; B `multisample(x,0,0)` which must " + - "equal A unless the primitive is grid-dependent; C `multisample(x,4,0)` the POSITIVE " + - "control, which must move the column one cell (4 tiles = one corner spacing) or the " + - "experiment could not detect a difference at all; D `multisample(x,0,4)` the NULL control, " + - "which cannot move a vertical contour. e0=71 avoids a corner landing exactly on the " + - "contour, where crossesCliff's strict comparison drops the crossing. Regenerate: node " + - "--experimental-strip-types test/oracle/capture.ts multisample-grid", - seed, - region, - cliffElevation0: 71, - cases, - }; - const out = join(FIXTURES, "oracle-multisample-grid.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${String(cases.length)} arms)`); -} - -/** - * Every ACTUAL Vulcanus RESOURCE entity in the same three regions - * `captureVulcanusCliffEntities` covers, so the two dumps can be compared - * directly (issue #24). - * - * The question this exists to answer: **the game essentially never puts a cliff - * on ore** - 8 of 3933 resource entities in `[1500,1500]`, 0.20%, against a - * ~21.6% independence baseline from the game's own cliff coverage, i.e. about - * 100x below chance. Something separates them, and it is NOT a collision mask - * (the cliff mask and the `resource` layer share nothing). Without the game's - * resource POSITIONS there was no way to tell a terrain anti-correlation from - * an explicit removal pass, because our own ore placement sits between the two - * and could be producing the effect by itself. - * - * With this fixture the discriminating measurement is one join: for each - * resource name, what fraction of the GAME's entities of that name fall inside - * the GAME's cliff cells, versus the same fraction computed from OUR fields. If - * the game's own rate is ~0 for every resource while ours is not, we are - * missing an exclusion. If the game's rate varies by resource the way a biome - * dependency would, it is terrain and #24 belongs to #18. - * - * Reuses the cliff probe unchanged - it already takes an `entityType` and dumps - * `{x, y, name}` per entity - so `type = "resource"` needs no oracle change. - * Same forced-seed Vulcanus surface as every other Vulcanus fixture. - */ -async function captureVulcanusResourceEntities(): Promise { - const regions: Region[] = [ - { x0: 0, y0: 0, x1: 256, y1: 256 }, - { x0: 1500, y0: 1500, x1: 1756, y1: 1756 }, - { x0: -1200, y0: 800, x1: -944, y1: 1056 }, - ]; - const seed = 123456; - const cases: { region: Region; resources: Position[] }[] = []; - for (const region of regions) { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const resources = await sampleCliffEntities(region, { - workDir, - seed, - spaceAge: true, - planet: "vulcanus", - entityType: "resource", - }); - cases.push({ region, resources }); - const names = new Map(); - for (const r of resources) { - const n = (r as { name?: string }).name ?? "?"; - names.set(n, (names.get(n) ?? 0) + 1); - } - console.log( - ` captured vulcanus resources [${String(region.x0)},${String(region.y0)}] ` + - `(${String(resources.length)}: ${[...names].map(([n, c]) => `${n}=${String(c)}`).join(", ")})`, - ); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 via test/oracle. Every resource entity " + - "(find_entities_filtered{type='resource'}) the game placed in each region on VULCANUS at the " + - "DEFAULT preset, after chunk-forced generation. Regions match " + - "oracle-vulcanus-cliff-entities.seed123456.json exactly so the two can be joined. Sampled on " + - "a create_surface() surface whose seed is FORCED to `seed` (like every other Vulcanus oracle " + - "fixture), not the derived mapSeed + crc32('vulcanus'). Exists to settle issue #24: whether " + - "the game's ~100x-below-chance cliff/ore separation is a terrain anti-correlation or an " + - "explicit exclusion. Regenerate: node --experimental-strip-types " + - "test/oracle/capture.ts vulcanus-resource-entities", - seed, - cases, - }; - const out = join(FIXTURES, "oracle-vulcanus-resource-entities.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${String(cases.length)} regions)`); -} - -/** - * Six MORE 256x256 Vulcanus regions of cliff + resource entities, for the one - * question the original three could not answer: **does the ore/cliff separation - * replicate?** - * - * Issue #24 rests entirely on `[1500,1500]`, and its "~100x below chance" figure - * uses a **tile-independence** baseline (`ore tiles x cliff coverage / area`). - * That baseline is invalid here: the ore in `[0,0]` and `[-1200,800]` is a - * grand total of TWO connected blobs, so those regions have ~1 independent trial - * and a shift-null puts their "0 overlap" at P = 0.51 and 0.29 - i.e. no signal - * at all. Eight extra regions turn one draw into a sample. - * - * Region choice is deliberately arbitrary (scattered angles and radii, 700 to - * 3800 tiles out) and was fixed BEFORE any of them was measured, so it cannot - * have been steered toward a result. Two of the eight (`[2900,400]`, - * `[-3200,-2000]`) turned out to hold no resource entities at all; they are kept - * in the fixture rather than dropped, because dropping the empty draws is - * exactly how a selection effect gets in. - * - * Regenerate: node --experimental-strip-types test/oracle/capture.ts - * vulcanus-ore-cliff-replication (~6 min: two headless runs per region, and - * chunk generation is the cost). - */ -async function captureVulcanusOreCliffReplication(): Promise { - const S = 256; - const regions: Region[] = [ - { x0: 700, y0: -1800 }, - { x0: -2400, y0: -600 }, - { x0: 1100, y0: 2600 }, - { x0: -900, y0: -2500 }, - { x0: 2900, y0: 400 }, - { x0: -1700, y0: 1900 }, - { x0: 300, y0: 3400 }, - { x0: -3200, y0: -2000 }, - ].map((r) => ({ x0: r.x0, y0: r.y0, x1: r.x0 + S, y1: r.y0 + S })); - const seed = 123456; - const cases: { region: Region; cliffs: Position[]; resources: Position[] }[] = []; - for (const region of regions) { - const grab = async (entityType?: string): Promise => { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const got = await sampleCliffEntities(region, { - workDir, - seed, - spaceAge: true, - planet: "vulcanus", - ...(entityType === undefined ? {} : { entityType }), - }); - // An empty Lua table serialises as `{}`, not `[]` - normalise, or a - // region with no resources lands in the fixture as an object and every - // consumer has to know that. - return Array.isArray(got) ? got : []; - } finally { - await rm(workDir, { recursive: true, force: true }); - } - }; - const cliffs = await grab(); - const resources = await grab("resource"); - cases.push({ region, cliffs, resources }); - console.log( - ` [${String(region.x0)},${String(region.y0)}] cliffs=${String(cliffs.length)} resources=${String(resources.length)}`, - ); - } - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 via test/oracle. Cliff AND resource entities over SIX " + - "MORE 256x256 Vulcanus regions than oracle-vulcanus-{cliff,resource}-entities covers, at the " + - "DEFAULT preset, after chunk-forced generation. Exists to test whether issue #24's ore/cliff " + - "separation REPLICATES - the issue rests on one region, and its tile-independence chance " + - "baseline is invalid for fields that come in a handful of blobs. Region list was fixed before " + - "any region was measured; the two regions that turned out to hold no resources are kept, not " + - "dropped. Same forced-seed Vulcanus surface as every other Vulcanus fixture (create_surface() " + - "with the seed FORCED to `seed`, not mapSeed + crc32('vulcanus')). Regenerate: node " + - "--experimental-strip-types test/oracle/capture.ts vulcanus-ore-cliff-replication", - seed, - cases, - }; - const out = join(FIXTURES, "oracle-vulcanus-ore-cliff-replication.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${String(cases.length)} regions)`); -} - -/** - * The GAME's own values for the two fields Vulcanus cliff placement reads, at - * exactly the lattice points the placement pass samples, over the three - * calcite-dominated regions. - * - * Why this fixture exists rather than another whole-field comparison: it lets a - * CI-safe spec run **our placement rule on the game's own inputs**, which - * separates a field error from a rule error. That distinction had never been - * measured for Vulcanus cliffs - #18 has been treated throughout as a field - * accuracy problem - and the answer is that substituting these values for ours - * does not move a single cell of 3481 per region. - * - * The lattice: BOTH fields at every corner `(i*4, j*4 + 0.5)` of every region. - * `cliffiness` is genuinely read at every corner. `cliff_elevation` at - * `cliff_smoothing = 1` is only ever read at the SMOOTHING KNOTS - * (`smoothingKnots`, in-chunk corner indices 0/4/7) - the unsmoothed term - * vanishes exactly at s = 1 - so ~5/6 of the elevation samples here are - * redundant for the CURRENT model. They are captured anyway on purpose: the - * smoothing model is itself a live suspect for #18's residual, and a fixture - * that only holds the knots can never test an alternative to it. - * - * Regenerate: node --experimental-strip-types test/oracle/capture.ts - * vulcanus-cliff-corner-fields - */ -async function captureVulcanusCliffCornerFields(): Promise { - const seed = 123456; - const G = 4; - const regions: Region[] = [ - { x0: 1500, y0: 1500, x1: 1756, y1: 1756 }, - { x0: 1100, y0: 2600, x1: 1356, y1: 2856 }, - { x0: -1700, y0: 1900, x1: -1444, y1: 2156 }, - ]; - const key = (a: number, b: number): string => `${String(a)},${String(b)}`; - const cornerSet = new Set(); - for (const r of regions) { - const n = (r.x1 - r.x0) / G; - for (let j = r.y0 / G; j <= r.y0 / G + n; j++) - for (let i = r.x0 / G; i <= r.x0 / G + n; i++) cornerSet.add(key(i, j)); - } - const corners = [...cornerSet]; - const toPos = (list: string[]): Position[] => - list.map((k) => { - const [i, j] = k.split(",").map(Number); - return { x: i * G, y: j * G }; - }); - - const sample = async (expression: string, list: string[]): Promise => { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - return await sampleExpression(expression, toPos(list), { - workDir, - seed, - spaceAge: true, - planet: "vulcanus", - }); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - }; - const elevation = await sample("vulcanus_elevation", corners); - console.log(` captured vulcanus_elevation at ${String(corners.length)} corners`); - const cliffiness = await sample("cliffiness_basic", corners); - console.log(` captured cliffiness_basic at ${String(corners.length)} corners`); - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 (Space Age) via test/oracle. The two fields Vulcanus cliff " + - "placement reads, sampled at the GAME's lattice - the bare (i*4, j*4), no grid_offset - " + - "over three calcite-dominated 256x256 regions. The prototype's grid_offset {0,0.5} is a " + - "CENTRE offset (entity-util.lua:305) and crossingsForChunk never reads it; sampling at " + - "j*4+0.5, as this fixture did before 2026-07-30, costs ~7 points of recall while moving no " + - "cliff. The superseded capture is kept as oracle-vulcanus-cliff-corner-fields-legacy-y0.5. " + - "Both fields are sampled at EVERY corner of each region (65x65 per region). An earlier " + - "version of this comment said vulcanus_elevation was captured only at the cliff_smoothing=1 " + - "knots; that was never what the code did - the corner set is built from a full nested loop " + - "over each region and both samples take all of it, which the count confirms (3 x 65 x 65 = " + - "12675). Corrected 2026-07-30. Exists to separate a FIELD " + - "error from a RULE error in the Vulcanus cliff port (#18) and in the ore/cliff separation " + - "(#24): a spec can run makeCliffPlacementFromFields on these values instead of ours. Forced " + - "surface seed like every other Vulcanus fixture. Regenerate: node " + - "--experimental-strip-types test/oracle/capture.ts vulcanus-cliff-corner-fields", - seed, - planet: "vulcanus", - grid: G, - cornerOffsetY: 0, - regions, - corners, - elevation, - cliffiness, - }; - const out = join(FIXTURES, "oracle-vulcanus-cliff-corner-fields.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log( - `wrote ${out} (${String(corners.length)} corners, ${String(regions.length)} regions)`, - ); -} - -/** - * The same two cliff fields, at the three regions - * `oracle-vulcanus-cliff-entities` covers - which is **not** what - * {@link captureVulcanusCliffCornerFields} covers. - * - * That fixture's regions (`[1500,1500]`, `[1100,2600]`, `[-1700,1900]`) were - * chosen for issue #24 and are all calcite-dominated. The consequence went - * unnoticed until the orientation oracle landed (2026-07-30): "substituting the - * game's own fields moves nothing" had been measured **only where the port is - * already good**. Scored on orientation, `[1500,1500]` is wrong on 8.1% of - * shared cells - while `[0,0]`, which no field capture touched at all, is wrong - * on **29.8%**. A field error there would have been invisible to every - * substitution run so far. - * - * `[1500,1500]` is deliberately kept in both fixtures. It is the only region - * they share, so re-sampling it here is a free cross-check: the two captures - * must agree corner for corner, and `test/vulcanusCliffCornerFields.spec.ts` - * asserts they do. Without that, a mistake in this capture's corner indexing - * would look exactly like a field error at `[0,0]`. - * - * A separate fixture rather than more regions on the existing one, because - * `test/vulcanusOreCliffSeparation.spec.ts` indexes that one by region and its - * #24 conclusions are stated per region. - */ -async function captureVulcanusCliffCornerFieldsAtEntityRegions(): Promise { - const seed = 123456; - const G = 4; - // Exactly the regions of oracle-vulcanus-cliff-entities.seed123456.json. - const regions: Region[] = [ - { x0: 0, y0: 0, x1: 256, y1: 256 }, - { x0: 1500, y0: 1500, x1: 1756, y1: 1756 }, - { x0: -1200, y0: 800, x1: -944, y1: 1056 }, - ]; - const key = (a: number, b: number): string => `${String(a)},${String(b)}`; - const cornerSet = new Set(); - for (const r of regions) { - const n = (r.x1 - r.x0) / G; - for (let j = r.y0 / G; j <= r.y0 / G + n; j++) - for (let i = r.x0 / G; i <= r.x0 / G + n; i++) cornerSet.add(key(i, j)); - } - const corners = [...cornerSet]; - const toPos = (list: string[]): Position[] => - list.map((k) => { - const [i, j] = k.split(",").map(Number); - return { x: i * G, y: j * G }; - }); - - const sample = async (expression: string, list: string[]): Promise => { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - return await sampleExpression(expression, toPos(list), { - workDir, - seed, - spaceAge: true, - planet: "vulcanus", - }); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - }; - const elevation = await sample("vulcanus_elevation", corners); - console.log(` captured vulcanus_elevation at ${String(corners.length)} corners`); - const cliffiness = await sample("cliffiness_basic", corners); - console.log(` captured cliffiness_basic at ${String(corners.length)} corners`); - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 (Space Age) via test/oracle. The two fields Vulcanus " + - "cliff placement reads (vulcanus_elevation, cliffiness_basic), sampled at the GAME's " + - "lattice - the bare (i*4, j*4), no grid_offset - at every corner of the THREE REGIONS " + - "oracle-vulcanus-cliff-entities.seed123456.json covers. Companion to " + - "oracle-vulcanus-cliff-corner-fields.seed123456.json, whose regions were chosen for issue " + - "#24 and are all calcite-dominated: the field substitution that cleared the port's fields " + - "had therefore only ever run where the port is already good (8.1% orientation error at " + - "[1500,1500]) and never at [0,0], which is wrong on 29.8% of shared cells. [1500,1500] is " + - "present in BOTH fixtures on purpose - the overlap is a cross-check that this capture's " + - "corner indexing is right, asserted in test/vulcanusCliffCornerFields.spec.ts. Forced " + - "surface seed like every other Vulcanus fixture. Regenerate: node " + - "--experimental-strip-types test/oracle/capture.ts " + - "vulcanus-cliff-corner-fields-entity-regions", - seed, - planet: "vulcanus", - grid: G, - cornerOffsetY: 0, - regions, - corners, - elevation, - cliffiness, - }; - const out = join(FIXTURES, "oracle-vulcanus-cliff-corner-fields-entity-regions.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log( - `wrote ${out} (${String(corners.length)} corners, ${String(regions.length)} regions)`, - ); -} - -/** - * **`cliff_smoothing = 0` at the OTHER two entity regions** (#84). - * - * `captureVulcanusCliffSmoothing` above asks this question at `[0,0]` only, and - * `oracle-vulcanus-cliff-collapsed`'s first arm answers it there at the real - * bands. The answer at `[0,0]` is that the port is EXACT with smoothing off - - * which reads like "the whole residual is the smoothing" until the same arm is - * run at the other two regions, and it is not: `[-1200,800]` is also exact, and - * `[1500,1500]` still carries 21 wrong orientations. **The residual is two - * different defects, and one region-worth of evidence could not tell them - * apart.** That is why this capture covers all three rather than the worst one. - * - * Case 0 overrides NOTHING and exists to read `cliff_settings` back off the - * planet's own surface. `VULCANUS_CLIFF_SMOOTHING = 1` had until now been - * inferred from the `CliffPlacementSettings` prototype default (issue #28) and - * never once read out of a running game; the value is load-bearing enough that - * an inference is not good enough, and the same class of assumption is what #28 - * itself was. - */ -async function captureVulcanusCliffSmoothingOffRegions(): Promise { - const seed = 123456; - const regions: Region[] = [ - { x0: 1500, y0: 1500, x1: 1756, y1: 1756 }, - { x0: -1200, y0: 800, x1: -944, y1: 1056 }, - ]; - const cases: { - label: string; - effective: DumpedCliffSettings | undefined; - region: Region; - cliffs: Position[]; - }[] = []; - - { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const dump = await sampleCliffEntitiesFull(regions[0], { - workDir, - seed, - spaceAge: true, - planet: "vulcanus", - }); - cases.push({ - label: "planet defaults (nothing overridden)", - effective: dump.cliffSettings, - region: regions[0], - cliffs: dump.cliffs, - }); - console.log(` defaults reported back: ${JSON.stringify(dump.cliffSettings)}`); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - - for (const region of regions) { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const dump = await sampleCliffEntitiesFull(region, { - workDir, - seed, - spaceAge: true, - planet: "vulcanus", - cliffSettings: { - cliff_smoothing: 0, - cliff_elevation_interval: 120, - cliff_elevation_0: 70, - richness: 1, - }, - }); - cases.push({ - label: `smoothing=0 at [${String(region.x0)},${String(region.y0)}]`, - effective: dump.cliffSettings, - region, - cliffs: dump.cliffs, - }); - console.log( - ` smoothing=0 at [${String(region.x0)},${String(region.y0)}] -> ${String(dump.cliffs.length)} cliffs`, - ); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 (Space Age) via test/oracle. Case 0 overrides NOTHING and " + - "exists purely to read cliff_settings back off Vulcanus's own surface, which is how " + - "cliff_smoothing=1 stopped being an inference from the CliffPlacementSettings prototype " + - "default and became a measurement. Cases 1-2 are the SAME rule with cliff_smoothing forced " + - "to 0 and every other term left real (cliff_elevation_0=70, cliff_elevation_interval=120, " + - "richness=1), at the two entity regions oracle-vulcanus-cliff-smoothing and " + - "oracle-vulcanus-cliff-collapsed do not cover. With smoothing off the generator reads the " + - "RAW 4-tile corner field, so these arms score the port's grid-4 cliff elevation directly. " + - "They are what splits the standing orientation residual in two: [-1200,800] is exact with " + - "smoothing off (as [0,0] already was) while [1500,1500] is not. `effective` is the " + - "cliff_settings the SURFACE reported back, so an override that failed to apply cannot be " + - "mistaken for one that did nothing. Forced surface seed like every other Vulcanus fixture. " + - "Regenerate: node --experimental-strip-types test/oracle/capture.ts " + - "vulcanus-cliff-smoothing-off-regions", - seed, - cases, - }; - const out = join(FIXTURES, "oracle-vulcanus-cliff-smoothing-off-regions.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${String(cases.length)} cases)`); -} - -/** - * **The `cliff_smoothing` STENCIL, measured directly instead of derived** (#84). - * - * `smoothingKnots` claims the engine interpolates each corner between knots at - * IN-CHUNK indices 0, 4 and 7 - the 7 rather than 8 being the asymmetry that - * makes smoothing "inaccurate" in the prototype's own words. That came off a - * disassembly of `crossingsForChunk`, and a disassembly is a reading, not a - * measurement. - * - * This measures it. The probe is a DELTA on one corner column (or row): - * - * 1 + 1000 * if(1 - abs(x - X0), 1, 0) - * - * routed onto `cliff_elevation` with smoothing left at 1, a single contour - * (`interval = 1e6`) and the cliffiness gate held open (`richness = 4`). The - * smoothed field is then exactly `1 + 1000 * w(i)`, where `w(i)` is the weight - * corner `i` gives the knot at `X0/4` and nothing else - every other corner - * contributes the constant 1. Cliffs appear where that crosses - * `cliff_elevation_0 = 500`, i.e. where `w` crosses 0.5, so the cliff columns - * read the stencil off the game directly. - * - * **The arms include a corner that is NOT a knot under the model, and its - * prediction is that the game produces NOTHING AT ALL.** That is the whole point - * of the design: a stencil test that can only ever confirm weights is much - * weaker than one with an arm whose predicted output is empty, because an empty - * result cannot be produced by a stencil that is merely close. - */ -async function captureCliffSmoothingStencil(): Promise { - const seed = 123456; - const arms: { label: string; axis: "x" | "y"; index: number; region: Region }[] = [ - { - label: "column 432 (in-chunk 0, knot)", - axis: "x", - index: 432, - region: { x0: 1700, y0: 1500, x1: 1800, y1: 1600 }, - }, - { - label: "column 435 (in-chunk 3, NOT a knot)", - axis: "x", - index: 435, - region: { x0: 1700, y0: 1500, x1: 1800, y1: 1600 }, - }, - { - label: "column 436 (in-chunk 4, knot)", - axis: "x", - index: 436, - region: { x0: 1700, y0: 1500, x1: 1800, y1: 1600 }, - }, - { - label: "column 439 (in-chunk 7, knot)", - axis: "x", - index: 439, - region: { x0: 1700, y0: 1500, x1: 1800, y1: 1600 }, - }, - { - label: "row 376 (in-chunk 0, knot)", - axis: "y", - index: 376, - region: { x0: 1500, y0: 1450, x1: 1600, y1: 1560 }, - }, - { - label: "row 379 (in-chunk 3, NOT a knot)", - axis: "y", - index: 379, - region: { x0: 1500, y0: 1450, x1: 1600, y1: 1560 }, - }, - { - label: "row 380 (in-chunk 4, knot)", - axis: "y", - index: 380, - region: { x0: 1500, y0: 1450, x1: 1600, y1: 1560 }, - }, - { - label: "row 383 (in-chunk 7, knot)", - axis: "y", - index: 383, - region: { x0: 1500, y0: 1450, x1: 1600, y1: 1560 }, - }, - ]; - const E0 = 500; - const cases: { - label: string; - axis: "x" | "y"; - index: number; - region: Region; - expression: string; - effective: DumpedCliffSettings | undefined; - cliffs: Position[]; - }[] = []; - - for (const arm of arms) { - const at = arm.index * 4; - const expression = `1 + 1000 * if(1 - abs(${arm.axis} - ${String(at)}), 1, 0)`; - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const dump = await sampleCliffEntitiesFull(arm.region, { - workDir, - seed, - spaceAge: true, - planet: "vulcanus", - probeExpression: expression, - cliffSettings: { - cliff_smoothing: 1, - cliff_elevation_interval: 1000000, - cliff_elevation_0: E0, - richness: 4, - }, - }); - cases.push({ - label: arm.label, - axis: arm.axis, - index: arm.index, - region: arm.region, - expression, - effective: dump.cliffSettings, - cliffs: dump.cliffs, - }); - console.log(` ${arm.label.padEnd(38)} -> ${String(dump.cliffs.length)} cliffs`); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 (Space Age) via test/oracle. Measures the cliff_smoothing " + - "STENCIL rather than deriving it. A DELTA probe `1 + 1000 * if(1 - abs(x - X0), 1, 0)` is " + - "routed onto property_expression_names.cliff_elevation with cliff_smoothing left at 1, " + - "cliff_elevation_interval=1e6 (a single contour) and richness=4 (the cliffiness gate held " + - "open). Every corner except column X0/4 carries the constant 1, so the smoothed field is " + - "exactly 1 + 1000*w(i) where w(i) is the weight corner i gives the knot at X0/4 - the game's " + - "cliffs at cliff_elevation_0=500 therefore trace the w=0.5 contour of the stencil itself. " + - "Four column arms and four row arms cover in-chunk indices 0, 3, 4 and 7 on both axes. The " + - "in-chunk-3 arms are the load-bearing ones: 3 is NOT a knot under `smoothingKnots`, so the " + - "model predicts the game places NOTHING, and an empty prediction cannot be satisfied by a " + - "stencil that is merely close. Forced surface seed like every other Vulcanus fixture. " + - "Regenerate: node --experimental-strip-types test/oracle/capture.ts cliff-smoothing-stencil", - seed, - cliffElevation0: E0, - cases, - }; - const out = join(FIXTURES, "oracle-cliff-smoothing-stencil.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${String(cases.length)} arms)`); -} - -/** - * **The DIRECTION of the cliff/ore exclusion, asked of the game** (#84 item 1, - * which is really #24). - * - * The port puts cliffs on ore and the game essentially does not - 3 of its 1,569 - * against the port's 29. That correlation fits two mechanisms which demand - * opposite fixes: ore suppresses cliffs, or cliffs suppress ore. `#94` handed the - * question over unresolved after ruling out lava, every other tile, the - * cliffiness gate and entity collision. - * - * It is settleable by experiment rather than argument, because the resources are - * a `map_gen_settings` lever like `cliff_settings`: turn them OFF and look. The - * arms are cumulative in neither direction - they are a 2x2, because each - * hypothesis needs its own control: - * - * - `[1500,1500]` at DEFAULT cliff settings, resources ON then OFF. The - * difference IS the suppressed set, with no model of the geometry needed to - * obtain it, and this is the region where the exclusion costs the port - * accuracy (26 of its 42 surplus cells). - * - The same region with only `calcite` off and only `sulfuric_acid_geyser` off, - * so the suppressed set can be attributed per resource. It is exactly - * additive: 27 + 4 = 31. - * - `[0,0]` with the rule COLLAPSED (single contour at 70, gate forced open, - * smoothing off) so a contour is forced through the tungsten blob that #94 - * named the sharpest open lead, resources ON then OFF. - * - * Every arm dumps the resources and the effective autoplace controls **in the - * same run as the cliffs**. Two runs cannot answer this: "the ore moved" and - * "the cliffs moved" have to be read off one generated surface, and the - * non-vacuity check that the ore really did vanish has to come from the arm - * making the claim. - * - * The prototype geometry rides along because the answer turned on it. The ores' - * collision half-extent is 0.098 and the geyser's is 1.398, which is why a test - * treating every resource as a point at its tile centre explains the calcite - * cells and cannot explain the geyser ones. - */ -async function captureVulcanusCliffOreDirection(): Promise { - const seed = 123456; - const entityRegion: Region = { x0: 1500, y0: 1500, x1: 1756, y1: 1756 }; - const blobRegion: Region = { x0: 0, y0: 0, x1: 256, y1: 256 }; - const ONE_BAND = 1000000; - // The collapsed rule of `oracle-vulcanus-cliff-collapsed`'s third arm, which is - // where the blob is reachable at all: at real settings the port places nothing - // there, so the exclusion is only visible once a contour is forced through it. - const COLLAPSED = { - cliff_smoothing: 0, - cliff_elevation_interval: ONE_BAND, - cliff_elevation_0: 70, - richness: 4, - }; - const OFF = { frequency: 1, size: 0, richness: 1 }; - const ALL_OFF = { - tungsten_ore: OFF, - calcite: OFF, - vulcanus_coal: OFF, - sulfuric_acid_geyser: OFF, - }; - const PROTOS = [ - "cliff-vulcanus", - "crater-cliff", - "tungsten-ore", - "calcite", - "coal", - "sulfuric-acid-geyser", - "big-volcanic-rock", - "huge-volcanic-rock", - ]; - - const arms: { - label: string; - region: Region; - cliffSettings?: Record; - autoplaceControls?: Record; - }[] = [ - { label: "entity region, resources ON", region: entityRegion }, - { label: "entity region, ALL resources OFF", region: entityRegion, autoplaceControls: ALL_OFF }, - { - label: "entity region, calcite OFF", - region: entityRegion, - autoplaceControls: { calcite: OFF }, - }, - { - label: "entity region, geyser OFF", - region: entityRegion, - autoplaceControls: { sulfuric_acid_geyser: OFF }, - }, - { label: "blob region COLLAPSED, resources ON", region: blobRegion, cliffSettings: COLLAPSED }, - { - label: "blob region COLLAPSED, ALL resources OFF", - region: blobRegion, - cliffSettings: COLLAPSED, - autoplaceControls: ALL_OFF, - }, - ]; - - const cases: unknown[] = []; - for (const arm of arms) { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const dump = await sampleCliffEntitiesFull(arm.region, { - workDir, - seed, - spaceAge: true, - planet: "vulcanus", - cliffSettings: arm.cliffSettings, - autoplaceControls: arm.autoplaceControls, - alsoResources: true, - protoNames: PROTOS, - }); - cases.push({ - label: arm.label, - region: arm.region, - cliffSettings: arm.cliffSettings ?? null, - autoplaceControls: arm.autoplaceControls ?? null, - effectiveCliffSettings: dump.cliffSettings, - effectiveAutoplace: dump.autoplaceControls, - cliffs: dump.cliffs, - resources: dump.resources, - protos: dump.protos, - }); - // The probe writes `name` and `orientation` alongside the position, but - // `CliffDump.cliffs` is typed as the bare `Position` the noise samplers - // share. Only this progress line needs the name, so it is narrowed here - // rather than by widening a type six other captures depend on. - const named = dump.cliffs as unknown as readonly { name: string }[]; - const vulc = named.filter((c) => c.name === "cliff-vulcanus").length; - console.log( - ` ${arm.label} -> ${String(vulc)} cliff-vulcanus, ` + - `${String(dump.cliffs.length - vulc)} other cliff, ` + - `${String(dump.resources?.length ?? -1)} resources`, - ); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 via test/oracle. Settles the DIRECTION of the Vulcanus " + - "cliff/ore exclusion (#84 item 1, #24) by turning the resources OFF through " + - "map_gen_settings.autoplace_controls and regenerating - the same trick " + - "oracle-vulcanus-cliff-collapsed plays on cliff_settings. Arms are NOT cumulative; they are " + - "paired ON/OFF controls, because 'ore suppresses cliffs' and 'cliffs suppress ore' each need " + - "their own. Every arm dumps the cliffs, the resources, the prototype collision geometry and " + - "the autoplace controls the SURFACE read back, all from ONE generated surface, so an override " + - "that failed to apply cannot be mistaken for a term that does not matter and 'the ore moved' " + - "and 'the cliffs moved' are never compared across two different worlds. Sampled on a " + - "create_surface() surface whose seed is FORCED to `seed`, like every other Vulcanus oracle " + - "fixture. Regenerate: node --experimental-strip-types test/oracle/capture.ts " + - "vulcanus-cliff-ore-direction", - seed, - entityRegion, - blobRegion, - cases, - }; - const out = join(FIXTURES, "oracle-vulcanus-cliff-ore-direction.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${String(cases.length)} arms)`); -} - -/** - * **The MECHANISM behind the ore -> cliff exclusion, which every arm before - * this one could only characterise.** - * - * `oracle-vulcanus-cliff-ore-direction` settled the direction by switching the - * resources OFF. That answers "which way does it run" and cannot answer "how", - * because removing the ore removes everything about the ore at once. The - * distinguishing lever is a PROTOTYPE field: leave all 945 resource entities - * exactly where the control has them and change one property of them. - * - * `ResourceEntityPrototype::cliff_removal_probability` defaults to **1.0**, and - * no shipped prototype overrides it - grepped across `base/`, `core/`, - * `space-age/`, `quality/` and `elevated-rails/`. So it is invisible from the - * data alone and can only be seen by changing it. - * - * A prototype field cannot be reached by a surface setting the way - * `autoplace_controls` and `cliff_settings` can: it is read at map-gen from the - * loaded prototype. That is what {@link OracleOptions.extraDataLua} exists for, - * and why it writes `data-final-fixes.lua` rather than `data.lua` - this probe - * mod declares no dependencies, so Factorio may load it before `space-age` and - * an override written at the data stage would silently edit nothing. - * - * Each arm reads the field back off the RUNNING GAME through `protos`, so an - * override that failed to apply cannot be mistaken for a field that does not - * matter. That read-back is the whole reason this is an arm rather than a hope. - */ -async function captureVulcanusCliffRemovalProbability(): Promise { - const seed = 123456; - const blobRegion: Region = { x0: 0, y0: 0, x1: 256, y1: 256 }; - const ONE_BAND = 1000000; - // Identical to `vulcanus-cliff-ore-direction`'s blob arms, so the two - // fixtures are directly comparable rather than nearly comparable. - const COLLAPSED = { - cliff_smoothing: 0, - cliff_elevation_interval: ONE_BAND, - cliff_elevation_0: 70, - richness: 4, - }; - const OFF = { frequency: 1, size: 0, richness: 1 }; - const ALL_OFF = { - tungsten_ore: OFF, - calcite: OFF, - vulcanus_coal: OFF, - sulfuric_acid_geyser: OFF, - }; - const PROTOS = ["cliff-vulcanus", "tungsten-ore", "calcite", "coal", "sulfuric-acid-geyser"]; - const ZERO_REMOVAL = ` --- Leave every resource exactly where it is and zero ONE prototype field. -for _, proto in pairs(data.raw.resource) do - proto.cliff_removal_probability = 0 -end -`; - - const arms: { - label: string; - autoplaceControls?: Record; - extraDataLua?: string; - }[] = [ - { label: "blob COLLAPSED, resources ON, cliff_removal_probability at its 1.0 default" }, - { - label: "blob COLLAPSED, resources ON, cliff_removal_probability = 0", - extraDataLua: ZERO_REMOVAL, - }, - { label: "blob COLLAPSED, ALL resources OFF", autoplaceControls: ALL_OFF }, - ]; - - const cases: unknown[] = []; - for (const arm of arms) { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const dump = await sampleCliffEntitiesFull(blobRegion, { - workDir, - seed, - spaceAge: true, - planet: "vulcanus", - cliffSettings: COLLAPSED, - autoplaceControls: arm.autoplaceControls, - alsoResources: true, - protoNames: PROTOS, - extraDataLua: arm.extraDataLua, - }); - cases.push({ - label: arm.label, - region: blobRegion, - cliffSettings: COLLAPSED, - autoplaceControls: arm.autoplaceControls ?? null, - zeroedCliffRemovalProbability: arm.extraDataLua !== undefined, - effectiveCliffSettings: dump.cliffSettings, - effectiveAutoplace: dump.autoplaceControls, - cliffs: dump.cliffs, - resources: dump.resources, - protos: dump.protos, - }); - const named = dump.cliffs as unknown as readonly { name: string }[]; - const vulc = named.filter((c) => c.name === "cliff-vulcanus").length; - console.log( - ` ${arm.label} -> ${String(vulc)} cliff-vulcanus, ` + - `${String(dump.resources?.length ?? -1)} resources, ` + - `tungsten cliff_removal_probability=${String( - dump.protos?.["tungsten-ore"]?.cliff_removal_probability, - )}`, - ); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.14 via test/oracle. Names the MECHANISM of the Vulcanus " + - "cliff/ore exclusion (#84, #24), which oracle-vulcanus-cliff-ore-direction could only give " + - "a direction for. The distinguishing arm leaves every one of the 945 resource entities " + - "exactly where the control has them and sets ONE prototype field, " + - "ResourceEntityPrototype::cliff_removal_probability, to 0 - a change no surface setting can " + - "make, because a prototype field is read at map-gen from the loaded prototype. The cliffs " + - "come back anyway, and that arm is indistinguishable from the resources-OFF arm. The field " + - "defaults to 1.0 and no shipped prototype overrides it, which is why the port's " + - "unconditional box-overlap rejection is correct as written: the BEHAVIOUR does not change, " + - "the explanation does. Every arm reads the field back off the running game in `protos`, so " + - "an override that failed to apply cannot be mistaken for a term that does not matter. " + - "Sampled on a create_surface() surface whose seed is FORCED to `seed`, like every other " + - "Vulcanus oracle fixture. Regenerate: node --experimental-strip-types " + - "test/oracle/capture.ts vulcanus-cliff-removal-probability", - seed, - blobRegion, - cases, - }; - const out = join(FIXTURES, "oracle-vulcanus-cliff-removal-probability.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${String(cases.length)} arms)`); -} - -/** - * **Eight more regions, to make the CHUNK-BORDER question decisive** (#84). - * - * The unexplained residual is enriched on chunk borders: 9 of 14 on the original - * three regions (64.3% against a 45.0% base), then 9 of 13 on the four added in - * #131 (69.2% against 47.2%). The second was a pre-registered test on fresh data - * and it replicated in direction and magnitude - but at 1.59 sigma alone and - * ~2.1 combined, it is a lead rather than a result. - * - * n is the only thing in the way. **The prediction, recorded here before the - * capture ran:** if the enrichment is real at ~66%, eight more regions should - * add roughly 26 unexplained cells with about 17 on a border, taking the - * combined figure to ~2.9 sigma. If it is noise, the new batch should sit near - * its own base rate of ~46% and the combined figure should FALL. - * - * That is a prediction with a real way to lose, which the first two rounds did - * not have. - * - * Regions are spread away from spawn, from each other, and from the seven - * already captured. ~2.5s an arm. - * - * Regenerate: `node --experimental-strip-types test/oracle/capture.ts - * vulcanus-cliff-entities-border-batch` - */ -async function captureVulcanusCliffEntitiesBorderBatch(): Promise { - const seed = 123456; - const OFF = { frequency: 1, size: 0, richness: 1 }; - const ALL_OFF = { - tungsten_ore: OFF, - calcite: OFF, - vulcanus_coal: OFF, - sulfuric_acid_geyser: OFF, - }; - const PROTOS = [ - "cliff-vulcanus", - "crater-cliff", - "tungsten-ore", - "calcite", - "coal", - "sulfuric-acid-geyser", - ]; - const mk = (x: number, y: number): Region => ({ x0: x, y0: y, x1: x + 256, y1: y + 256 }); - const regions: { label: string; region: Region }[] = [ - { label: "[2200,-2800]", region: mk(2200, -2800) }, - { label: "[-3400,-900]", region: mk(-3400, -900) }, - { label: "[1200,2600]", region: mk(1200, 2600) }, - { label: "[-1600,3200]", region: mk(-1600, 3200) }, - { label: "[3600,600]", region: mk(3600, 600) }, - { label: "[-900,-3300]", region: mk(-900, -3300) }, - { label: "[2800,2000]", region: mk(2800, 2000) }, - { label: "[-3000,2800]", region: mk(-3000, 2800) }, - ]; - - const cases: unknown[] = []; - for (const r of regions) { - for (const off of [false, true]) { - const label = `${r.label}, ${off ? "ALL resources OFF" : "resources ON"}`; - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const dump = await sampleCliffEntitiesFull(r.region, { - workDir, - seed, - spaceAge: true, - planet: "vulcanus", - autoplaceControls: off ? ALL_OFF : undefined, - alsoResources: true, - protoNames: PROTOS, - }); - cases.push({ - label, - region: r.region, - autoplaceControls: off ? ALL_OFF : null, - effectiveAutoplace: dump.autoplaceControls, - effectiveCliffSettings: dump.cliffSettings, - cliffs: dump.cliffs, - resources: dump.resources, - }); - const named = dump.cliffs as unknown as readonly { name: string }[]; - const vulc = named.filter((c) => c.name === "cliff-vulcanus").length; - console.log(` ${label} -> ${String(vulc)} cliff-vulcanus`); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 via test/oracle. Eight more Vulcanus cliff-entity regions " + - "with the paired ON / ALL-resources-OFF ore lever, captured to make the CHUNK-BORDER question " + - "decisive (#84). The unexplained residual ran 9/14 on a border in the original three regions " + - "and 9/13 in the four added by #131 - replicated, but 1.59 sigma alone and ~2.1 combined. " + - "PREDICTION RECORDED BEFORE CAPTURE: if the enrichment is real at ~66%, these should add ~26 " + - "unexplained cells with ~17 on a border, taking the combined figure to ~2.9 sigma; if it is " + - "noise, the new batch sits near its own ~46% base rate and the combined figure FALLS. Regions " + - "are spread away from spawn, from each other, and from the seven already captured. Each arm " + - "records the autoplace_controls and cliff_settings the SURFACE read back and dumps cliffs and " + - "resources from one generated surface. Regenerate: node --experimental-strip-types " + - "test/oracle/capture.ts vulcanus-cliff-entities-border-batch", - seed, - cases, - }; - const out = join(FIXTURES, "oracle-vulcanus-cliff-entities-border-batch.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${String(cases.length)} arms)`); -} - -/** - * **Four MORE cliff-entity regions, with the ore lever, to raise n** (#84). - * - * Two things are stuck at a sample size rather than at an idea. - * - * The residual's unexplained population is **14 cells**, and every structural - * test on it - chunk-border status, orientation, distance to the region rim - - * lands at 1.4 to 1.9 sigma against its base rate. At n = 14 that is what a - * partition looks like whether or not a cause exists, so another slice of the - * same fourteen cannot settle anything. More cells can. - * - * And the shipped accuracy figure - recall 0.9961, precision 0.9858 - is - * measured on **three** regions, all chosen years into the investigation for - * reasons that had nothing to do with sampling. Whether it holds elsewhere on - * the map has never been asked. - * - * Four regions, spread away from spawn and from each other, each with the paired - * ON / ALL-resources-OFF arms so the ore can be attributed the same way #123 and - * #126 do. Captures cost about 2.5s each. - * - * Regenerate: `node --experimental-strip-types test/oracle/capture.ts - * vulcanus-cliff-entities-more-regions` - */ -/** - * EIGHT more Vulcanus cliff-entity regions with the paired ON / ALL-resources-OFF - * ore lever, captured purely to RAISE N on the west-edge concentration (#84). - * - * Every west measurement to date - the z = 3.01 edge split (#150) and the four - * sweep-order arms (#151) - reuses the SAME 14 regions. Six mechanism hunts have - * now been spent on a signal whose n has never been raised, and this repo has - * been burned before by a partition that looked solid at n = 14. These regions - * are disjoint from all 15 already in use and spread away from spawn and from - * each other, so they are a genuine out-of-sample arm rather than a resample. - * - * Regenerate: `node --experimental-strip-types test/oracle/capture.ts - * vulcanus-cliff-entities-west-oos` - */ -async function captureVulcanusCliffEntitiesWestOos(): Promise { - const seed = 123456; - const OFF = { frequency: 1, size: 0, richness: 1 }; - const ALL_OFF = { - tungsten_ore: OFF, - calcite: OFF, - vulcanus_coal: OFF, - sulfuric_acid_geyser: OFF, - }; - const PROTOS = [ - "cliff-vulcanus", - "crater-cliff", - "tungsten-ore", - "calcite", - "coal", - "sulfuric-acid-geyser", - ]; - const regions: { label: string; region: Region }[] = [ - { label: "[4200,-2200]", region: { x0: 4200, y0: -2200, x1: 4456, y1: -1944 } }, - { label: "[-4200,-2600]", region: { x0: -4200, y0: -2600, x1: -3944, y1: -2344 } }, - { label: "[2400,3800]", region: { x0: 2400, y0: 3800, x1: 2656, y1: 4056 } }, - { label: "[-1600,-1000]", region: { x0: -1600, y0: -1000, x1: -1344, y1: -744 } }, - { label: "[3400,-3600]", region: { x0: 3400, y0: -3600, x1: 3656, y1: -3344 } }, - { label: "[-3600,2000]", region: { x0: -3600, y0: 2000, x1: -3344, y1: 2256 } }, - { label: "[1000,4200]", region: { x0: 1000, y0: 4200, x1: 1256, y1: 4456 } }, - { label: "[-800,2400]", region: { x0: -800, y0: 2400, x1: -544, y1: 2656 } }, - ]; - - const cases: unknown[] = []; - for (const r of regions) { - for (const off of [false, true]) { - const label = `${r.label}, ${off ? "ALL resources OFF" : "resources ON"}`; - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const dump = await sampleCliffEntitiesFull(r.region, { - workDir, - seed, - spaceAge: true, - planet: "vulcanus", - autoplaceControls: off ? ALL_OFF : undefined, - alsoResources: true, - protoNames: PROTOS, - }); - cases.push({ - label, - region: r.region, - autoplaceControls: off ? ALL_OFF : null, - effectiveAutoplace: dump.autoplaceControls, - effectiveCliffSettings: dump.cliffSettings, - cliffs: dump.cliffs, - resources: dump.resources, - }); - const named = dump.cliffs as unknown as readonly { name: string }[]; - const vulc = named.filter((c) => c.name === "cliff-vulcanus").length; - console.log( - ` ${label} -> ${String(vulc)} cliff-vulcanus, ${String(dump.resources?.length ?? -1)} resources`, - ); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 via test/oracle. EIGHT out-of-sample Vulcanus cliff-entity " + - "regions with the paired ON / ALL-resources-OFF ore lever (#84), captured to raise n on the " + - "WEST-EDGE concentration of the border residual. Every west measurement to date - the z = 3.01 " + - "edge split and the four sweep-order arms - reuses the same 14 regions, and six mechanism hunts " + - "have been spent on a signal whose n was never raised. These eight regions are disjoint from all " + - "15 already in use and spread away from spawn and from each other, so they are a genuine " + - "out-of-sample arm rather than a resample. Each arm records the autoplace_controls and " + - "cliff_settings the SURFACE read back and dumps cliffs and resources from one generated surface, " + - "so an override that failed to apply cannot be mistaken for a term that does not matter. " + - "Regenerate: node --experimental-strip-types test/oracle/capture.ts " + - "vulcanus-cliff-entities-west-oos", - seed, - cases, - }; - const out = join(FIXTURES, "oracle-vulcanus-cliff-entities-west-oos.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out}`); -} - -async function captureVulcanusCliffEntitiesMoreRegions(): Promise { - const seed = 123456; - const OFF = { frequency: 1, size: 0, richness: 1 }; - const ALL_OFF = { - tungsten_ore: OFF, - calcite: OFF, - vulcanus_coal: OFF, - sulfuric_acid_geyser: OFF, - }; - const PROTOS = [ - "cliff-vulcanus", - "crater-cliff", - "tungsten-ore", - "calcite", - "coal", - "sulfuric-acid-geyser", - ]; - const regions: { label: string; region: Region }[] = [ - { label: "[3000,3000]", region: { x0: 3000, y0: 3000, x1: 3256, y1: 3256 } }, - { label: "[-2000,-2000]", region: { x0: -2000, y0: -2000, x1: -1744, y1: -1744 } }, - { label: "[800,-1500]", region: { x0: 800, y0: -1500, x1: 1056, y1: -1244 } }, - { label: "[-2600,1200]", region: { x0: -2600, y0: 1200, x1: -2344, y1: 1456 } }, - ]; - - const cases: unknown[] = []; - for (const r of regions) { - for (const off of [false, true]) { - const label = `${r.label}, ${off ? "ALL resources OFF" : "resources ON"}`; - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const dump = await sampleCliffEntitiesFull(r.region, { - workDir, - seed, - spaceAge: true, - planet: "vulcanus", - autoplaceControls: off ? ALL_OFF : undefined, - alsoResources: true, - protoNames: PROTOS, - }); - cases.push({ - label, - region: r.region, - autoplaceControls: off ? ALL_OFF : null, - effectiveAutoplace: dump.autoplaceControls, - effectiveCliffSettings: dump.cliffSettings, - cliffs: dump.cliffs, - resources: dump.resources, - }); - const named = dump.cliffs as unknown as readonly { name: string }[]; - const vulc = named.filter((c) => c.name === "cliff-vulcanus").length; - console.log( - ` ${label} -> ${String(vulc)} cliff-vulcanus, ${String(dump.resources?.length ?? -1)} resources`, - ); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 via test/oracle. Four MORE Vulcanus cliff-entity regions " + - "with the paired ON / ALL-resources-OFF ore lever (#84), captured to raise n. The residual's " + - "unexplained population was 14 cells and every structural test on it landed at 1.4-1.9 sigma " + - "against its base rate, which is what a partition looks like at n=14 whether or not a cause " + - "exists; and the shipped accuracy figure was measured on three regions chosen for reasons " + - "unrelated to sampling. Regions are spread away from spawn and from each other. Each arm " + - "records the autoplace_controls and cliff_settings the SURFACE read back and dumps cliffs and " + - "resources from one generated surface, so an override that failed to apply cannot be mistaken " + - "for a term that does not matter. Regenerate: node --experimental-strip-types " + - "test/oracle/capture.ts vulcanus-cliff-entities-more-regions", - seed, - cases, - }; - const out = join(FIXTURES, "oracle-vulcanus-cliff-entities-more-regions.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${String(cases.length)} arms)`); -} - -/** - * **Does the LEVER itself move cliffs, independently of the ore?** (#84.) - * - * By #128 every route from a resource control to a cliff is closed: the cliff - * FIELD reads `elevation`, whose 47-node expression closure contains no resource - * region; `Surface::wouldCollide` has exactly two halves; its entity half cannot - * fire (disjoint masks, and cliffs are placed before entities); and its tile half - * does not fire (no tile crosses the blocking boundary). Yet switching the - * resources off returns exactly 31 cliffs. - * - * The one route never excluded is that the lever perturbs something structural - - * a `CompiledMapGenSettings` re-layout, a noise-program index shift - rather than - * the ore mattering at all. **`richness` is the control that separates them.** - * - * `vulcanus_calcite_probability` does not reference richness, and neither does - * `vulcanus_calcite_region`, so `control:calcite:richness` changes neither where - * the calcite lands nor the `volcanic_jagged_ground_range` tile it drives. It - * changes only `vulcanus_calcite_richness`, i.e. how much ore each tile holds - - * and the compiled settings the generator is handed. - * - * So: same entity positions, same tiles, different settings object. - * - * - If the cliffs move, the lever is perturbing something structural and "the ore - * suppresses cliffs" is the wrong reading of every arm in #84. - * - If they do not, the effect genuinely tracks ore PRESENCE, and the - * impossibility recorded in #128 stands as an impossibility. - * - * The arm dumps the resources too, so "positions unchanged" is measured rather - * than argued from the Lua. - * - * Regenerate: `node --experimental-strip-types test/oracle/capture.ts - * vulcanus-cliff-ore-richness` - */ -async function captureVulcanusCliffOreRichness(): Promise { - const seed = 123456; - const region: Region = { x0: 1500, y0: 1500, x1: 1756, y1: 1756 }; - const PROTOS = ["cliff-vulcanus", "calcite", "sulfuric-acid-geyser"]; - const arms: { - label: string; - autoplaceControls?: Record; - }[] = [ - { label: "default" }, - { - label: "calcite richness x2", - autoplaceControls: { calcite: { frequency: 1, size: 1, richness: 2 } }, - }, - { - label: "calcite richness x0.5", - autoplaceControls: { calcite: { frequency: 1, size: 1, richness: 0.5 } }, - }, - ]; - - const cases: unknown[] = []; - for (const arm of arms) { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const dump = await sampleCliffEntitiesFull(region, { - workDir, - seed, - spaceAge: true, - planet: "vulcanus", - autoplaceControls: arm.autoplaceControls, - alsoResources: true, - protoNames: PROTOS, - }); - cases.push({ - label: arm.label, - region, - autoplaceControls: arm.autoplaceControls ?? null, - effectiveAutoplace: dump.autoplaceControls, - cliffs: dump.cliffs, - resources: dump.resources, - }); - const named = dump.cliffs as unknown as readonly { name: string }[]; - const vulc = named.filter((c) => c.name === "cliff-vulcanus").length; - console.log( - ` ${arm.label} -> ${String(vulc)} cliff-vulcanus, ${String(dump.resources?.length ?? -1)} resources`, - ); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 via test/oracle. Separates 'the ore suppresses cliffs' " + - "from 'the autoplace_controls lever perturbs something structural' (#84). By #128 every " + - "route from a resource control to a cliff is closed - the field reads elevation, whose " + - "expression closure holds no resource region; Surface::wouldCollide has two halves and " + - "neither can fire - yet switching the resources off returns 31 cliffs. control:calcite:" + - "richness is the control that distinguishes the two: it appears in vulcanus_calcite_richness " + - "only, NOT in vulcanus_calcite_probability (where the ore goes) nor in " + - "vulcanus_calcite_region (which drives the volcanic_jagged_ground_range tile), so it changes " + - "the compiled settings while leaving entity positions and tiles alone. If the cliffs move, " + - "the lever is structural and every ore arm in #84 is misread; if not, the effect tracks ore " + - "presence. Each arm dumps the resources too, so 'positions unchanged' is measured rather " + - "than argued. Regenerate: node --experimental-strip-types test/oracle/capture.ts " + - "vulcanus-cliff-ore-richness", - seed, - region, - cases, - }; - const out = join(FIXTURES, "oracle-vulcanus-cliff-ore-richness.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${String(cases.length)} arms)`); -} - -/** - * **Does a RESOURCE control move TILES, and is any of them cliff-blocking?** - * (#84.) - * - * `autoplace_controls` has been used as an entity-only lever throughout #84 - - * "switch the ore off and see which cliffs come back". It is not one. - * `space-age/prototypes/planet/planet-vulcanus-map-gen.lua` defines - * `vulcanus_calcite_region` in terms of `control:calcite:size` (through - * `vulcanus_calcite_size = slider_rescale(control:calcite:size, 2)`) and - * `control:calcite:frequency`, and - * `space-age/prototypes/tile/tiles-vulcanus.lua` feeds that straight into a TILE - * range: - * - * ```lua - * name = "volcanic_jagged_ground_range", - * expression = "5 * min(10, max(vulcanus_calcite_region + 0.2, ...))" - * ``` - * - * So the lever moves tiles as well as entities, and the question that decides - * whether that matters for #84 is whether any moved tile crosses the - * cliff-BLOCKING boundary - `lava` and `lava-hot`, the only two carrying - * `tile_collision_masks.lava()`. If none does, the confound is real but inert - * for cliffs and every ore result stands. If one does, `Surface::wouldCollide`'s - * TILE half is a live route from the lever to the cliffs, which is exactly what - * #124 left open after closing the entity half. - * - * Our own port answers "none" - but that is our tile model marking its own - * homework, and #115 only exonerated it over 70 tiles around six cells. This - * asks the game. - * - * **Deliberately model-independent.** A uniform stride-2 grid over the lever's - * own region rather than the positions our port predicts will change, so the - * fixture does not encode the very model it exists to check, and stays valid if - * the port's tile field is later corrected. - * - * Regenerate: `node --experimental-strip-types test/oracle/capture.ts - * vulcanus-tile-lever` - */ -async function captureVulcanusTileLever(): Promise { - const seed = 123456; - const planet = "vulcanus"; - const region = { x0: 1500, y0: 1500, x1: 1756, y1: 1756 }; - const STRIDE = 2; - const OFF = { frequency: 1, size: 0, richness: 1 }; - - const positions: { x: number; y: number }[] = []; - for (let x = region.x0; x < region.x1; x += STRIDE) - for (let y = region.y0; y < region.y1; y += STRIDE) positions.push({ x: x + 0.5, y: y + 0.5 }); - - const arms: { - label: string; - autoplaceControls?: Record; - }[] = [ - { label: "resources ON" }, - { label: "calcite OFF", autoplaceControls: { calcite: OFF } }, - { - label: "ALL resources OFF", - autoplaceControls: { - tungsten_ore: OFF, - calcite: OFF, - vulcanus_coal: OFF, - sulfuric_acid_geyser: OFF, - }, - }, - ]; - - const cases: unknown[] = []; - for (const arm of arms) { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const dump = await sampleTileNamesFull(positions, { - workDir, - seed, - spaceAge: true, - planet, - autoplaceControls: arm.autoplaceControls, - }); - cases.push({ - label: arm.label, - autoplaceControls: arm.autoplaceControls ?? null, - effectiveAutoplace: dump.autoplaceControls, - positions: dump.samples.map((s) => ({ x: s.x, y: s.y })), - tileNames: dump.samples.map((s) => s.name), - }); - const distinct = [...new Set(dump.samples.map((s) => s.name))].sort(); - console.log( - ` ${arm.label} -> ${String(dump.samples.length)} tiles, ${String(distinct.length)} distinct`, - ); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 (Space Age enabled) via test/oracle. Does an " + - "autoplace_controls RESOURCE lever move TILES, and is any moved tile cliff-blocking? (#84.) " + - "vulcanus_calcite_region depends on control:calcite:size and :frequency, and feeds the tile " + - "range volcanic_jagged_ground_range, so the lever every ore result in #84 treats as " + - "entity-only also moves tiles. What decides whether that matters is whether any moved tile " + - "crosses the cliff-blocking boundary - lava and lava-hot, the only two carrying " + - "tile_collision_masks.lava(). Three arms over the lever's own region [1500,1500], sampling " + - "surface.get_tile(x, y).name on a uniform stride-2 grid: resources ON, calcite OFF, ALL " + - "resources OFF. The grid is uniform rather than the positions our port predicts will change, " + - "so the fixture does not encode the model it exists to check. Each arm records the " + - "autoplace_controls the SURFACE read back, so 'no tile moved' cannot be confused with 'the " + - "override never applied'. positions are the mod's ECHOED floored get_tile input. Regenerate: " + - "node --experimental-strip-types test/oracle/capture.ts vulcanus-tile-lever", - seed, - planet, - region, - stride: STRIDE, - cases, - }; - const out = join(FIXTURES, "oracle-vulcanus-tile-lever.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log( - `wrote ${out} (${String(cases.length)} arms, ${String(positions.length)} points each)`, - ); -} - -/** - * **The ore lever on the OTHER TWO oracle regions - an out-of-sample test** - * (#84). - * - * Everything known about the ore -> cliff rule was measured on `[1500,1500]`, - * because that is the only region `oracle-vulcanus-cliff-ore-direction` re-runs - * with the resources off. Three things now rest on that single region: #123's - * split of the 25 missed destructions into 11 ore and 11 unknown, #125's finding - * that the `onDestroy` cascade closes 4 of the 10 remainders, and the claim that - * the box-overlap rule has precision 1.000. A rule characterised on one region - * and never tested on another is fitted until proven otherwise. - * - * This adds the paired ON / ALL-OFF arms for the two regions the entities - * fixture already covers and the lever never did: - * - * - `[0,0]` `{0,0,256,256}` - the same box as the other fixture's `blobRegion`, - * but at REAL cliff settings rather than the collapsed rule, so it is a - * genuine second sample rather than a re-run of the blob probe. - * - `[-1200,800]` `{-1200,800,-944,1056}`. - * - * It is a SEPARATE fixture rather than two more arms on the existing one so that - * regenerating it cannot rewrite ground truth that four merged PRs already - * depend on. Same seed, same protos, same `alsoResources`, so the two are - * directly comparable. - * - * Regenerate: `node --experimental-strip-types test/oracle/capture.ts - * vulcanus-cliff-ore-direction-regions` - */ -async function captureVulcanusCliffOreDirectionRegions(): Promise { - const seed = 123456; - const OFF = { frequency: 1, size: 0, richness: 1 }; - const ALL_OFF = { - tungsten_ore: OFF, - calcite: OFF, - vulcanus_coal: OFF, - sulfuric_acid_geyser: OFF, - }; - const PROTOS = [ - "cliff-vulcanus", - "crater-cliff", - "tungsten-ore", - "calcite", - "coal", - "sulfuric-acid-geyser", - "big-volcanic-rock", - "huge-volcanic-rock", - ]; - const regions: { label: string; region: Region }[] = [ - { label: "[0,0]", region: { x0: 0, y0: 0, x1: 256, y1: 256 } }, - { label: "[-1200,800]", region: { x0: -1200, y0: 800, x1: -944, y1: 1056 } }, - ]; - - const cases: unknown[] = []; - for (const r of regions) { - for (const off of [false, true]) { - const label = `${r.label}, ${off ? "ALL resources OFF" : "resources ON"}`; - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const dump = await sampleCliffEntitiesFull(r.region, { - workDir, - seed, - spaceAge: true, - planet: "vulcanus", - autoplaceControls: off ? ALL_OFF : undefined, - alsoResources: true, - protoNames: PROTOS, - }); - cases.push({ - label, - region: r.region, - autoplaceControls: off ? ALL_OFF : null, - effectiveCliffSettings: dump.cliffSettings, - effectiveAutoplace: dump.autoplaceControls, - cliffs: dump.cliffs, - resources: dump.resources, - protos: dump.protos, - }); - const named = dump.cliffs as unknown as readonly { name: string }[]; - const vulc = named.filter((c) => c.name === "cliff-vulcanus").length; - console.log( - ` ${label} -> ${String(vulc)} cliff-vulcanus, ` + - `${String(dump.resources?.length ?? -1)} resources`, - ); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 via test/oracle. The OUT-OF-SAMPLE arm for the Vulcanus " + - "cliff/ore exclusion (#84): oracle-vulcanus-cliff-ore-direction only re-runs [1500,1500] " + - "with the resources off, so every quantity known about the ore rule - the 11/11 split of " + - "the missed destructions, the onDestroy cascade closing 4 of 10 remainders, and precision " + - "1.000 - was characterised on one region. These are the paired ON / ALL-OFF arms for the " + - "two regions the entities fixture covers and the lever never did, at REAL cliff settings. " + - "Deliberately a separate file: regenerating it cannot rewrite ground truth that merged work " + - "already depends on. Same seed, protos and alsoResources as the original, so the two are " + - "directly comparable. Regenerate: node --experimental-strip-types test/oracle/capture.ts " + - "vulcanus-cliff-ore-direction-regions", - seed, - cases, - }; - const out = join(FIXTURES, "oracle-vulcanus-cliff-ore-direction-regions.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${String(cases.length)} arms)`); -} - -/** - * **Is any placed ENTITY what suppresses the non-ore residual at `[1500,1500]`?** - * (#84.) The lever, not a predicate. - * - * Running both sides with the resources switched off leaves 13 wrong - * orientations and 10 surplus cells that the ore rule cannot reach, and their - * shape is a SUPPRESSION: the game's code is the port's code with edges removed, - * and the cells the port over-places are ones the game emits nothing at. #109 - * excluded the field twice over, plus rocks, the cliffiness gate, smoothing and - * the repair; #108 excluded the stage. The obvious remaining class is the - * unported half of `Surface::wouldCollide` - an entity standing where the cliff - * would go. - * - * `autoplace_controls` cannot ask that question, because a control only reaches - * prototypes that name one: the four resources have controls, and the rocks, - * the chimneys and `crater-cliff` have none. `autoplace_settings.entity` with - * `treat_missing_as_default = false` switches the whole category off at once, so - * one arm answers it for every entity there is. - * - * The second lever aims at LAVA, and it is the one that pays. Dropping - * `lava`/`lava-hot` from the tile autoplace leaves the elevation the crossings - * read untouched (tiles are downstream of it) and takes away the only thing the - * tile-collision rejection can reject against - so the cells that APPEAR are the - * game's own answer to "which cliffs does lava suppress", the way - * `autoplace_controls` gave the ore's answer in #110. Our lava rejection can - * then be scored for precision and recall against a known set instead of tuned - * until the totals agree, which is the thing #88 says must not happen to a - * collision box. - * - * Five arms, all over the same `[1500,1500]` entity region: - * - * - **resources OFF via controls** - the baseline the residual is measured - * against, captured again here so the comparison is within one fixture and one - * binary rather than across two. - * - **the whole `entity` category OFF** - the entity lever. If the cliffs are - * unchanged, no entity suppresses them, positively rather than by elimination. - * - **default (resources ON)** - the shipping world, for the entity list. - * - **resources OFF + lava tiles OFF** - the lava lever with the ore already out - * of the picture, so what moves is lava alone. - * - **lava tiles OFF only** - the same lever against the shipping world. - * - * Every arm dumps EVERY entity in the region with its type, so a null result - * still says what was standing there; reads `autoplace_settings` back off the - * surface; and COUNTS the lava tiles in the region it just generated. That last - * one is what separates "lava suppresses nothing" from "the tile override never - * applied" - the two are the same observation without it. `cliff-vulcanus` is - * placed from `cliff_settings`, not from either category, so a lever arm that - * also emptied the cliffs would be vacuous and says nothing. - */ -async function captureVulcanusCliffSuppressorLevers(): Promise { - const seed = 123456; - const region: Region = { x0: 1500, y0: 1500, x1: 1756, y1: 1756 }; - const OFF = { frequency: 1, size: 0, richness: 1 }; - const ALL_OFF = { - tungsten_ore: OFF, - calcite: OFF, - vulcanus_coal: OFF, - sulfuric_acid_geyser: OFF, - }; - const PROTOS = [ - "cliff-vulcanus", - "crater-cliff", - "big-volcanic-rock", - "huge-volcanic-rock", - "big-volcanic-rock-hot", - "huge-volcanic-rock-hot", - "vulcanus-chimney", - "vulcanus-chimney-faded", - "vulcanus-chimney-cold", - "vulcanus-chimney-short", - "vulcanus-chimney-truncated", - "ashland-lichen-tree", - "ashland-lichen-tree-flaming", - ]; - - const LAVA_TILES = ["lava", "lava-hot"]; - const arms: { - label: string; - autoplaceControls?: Record; - disableAutoplaceCategories?: readonly string[]; - excludeFromAutoplaceCategory?: Readonly>; - /** Only the two arms the ENTITY lever compares carry the full list. */ - alsoEntities?: boolean; - }[] = [ - { label: "resources OFF via controls", autoplaceControls: ALL_OFF, alsoEntities: true }, - { - label: "entity autoplace category OFF", - disableAutoplaceCategories: ["entity"], - alsoEntities: true, - }, - { label: "default, resources ON" }, - { - label: "resources OFF, LAVA TILES OFF", - autoplaceControls: ALL_OFF, - excludeFromAutoplaceCategory: { tile: LAVA_TILES }, - }, - { label: "LAVA TILES OFF only", excludeFromAutoplaceCategory: { tile: LAVA_TILES } }, - ]; - - const cases: unknown[] = []; - for (const arm of arms) { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const dump = await sampleCliffEntitiesFull(region, { - workDir, - seed, - spaceAge: true, - planet: "vulcanus", - autoplaceControls: arm.autoplaceControls, - disableAutoplaceCategories: arm.disableAutoplaceCategories, - excludeFromAutoplaceCategory: arm.excludeFromAutoplaceCategory, - countTileNames: LAVA_TILES, - alsoResources: true, - alsoEntities: arm.alsoEntities, - protoNames: PROTOS, - }); - cases.push({ - label: arm.label, - region, - autoplaceControls: arm.autoplaceControls ?? null, - disabledCategories: arm.disableAutoplaceCategories ?? null, - excludedFromCategory: arm.excludeFromAutoplaceCategory ?? null, - effectiveCliffSettings: dump.cliffSettings, - effectiveAutoplace: dump.autoplaceControls, - effectiveAutoplaceSettings: dump.autoplaceSettings, - tileCounts: dump.tileCounts, - cliffs: dump.cliffs, - entities: dump.entities ?? null, - protos: dump.protos, - }); - const named = dump.cliffs as unknown as readonly { name: string }[]; - const vulc = named.filter((c) => c.name === "cliff-vulcanus").length; - const nonCliff = (dump.entities ?? []).filter((e) => e.type !== "cliff").length; - const lava = Object.values(dump.tileCounts ?? {}).reduce((a, b) => a + b, 0); - console.log( - ` ${arm.label} -> ${String(vulc)} cliff-vulcanus, ` + - `${String(dump.cliffs.length - vulc)} other cliff, ` + - `${String(nonCliff)} non-cliff entities, ${String(lava)} lava tiles`, - ); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 via test/oracle. Asks whether any placed ENTITY suppresses " + - "the non-ore cliff residual at [1500,1500] (#84), with LEVERS rather than predicates: " + - "map_gen_settings.autoplace_settings.entity = {treat_missing_as_default = false, settings = {}} " + - "switches the whole entity autoplace category off, which autoplace_controls cannot do - a " + - "control only reaches prototypes that name one, so the four resources have one and the rocks, " + - "chimneys and crater-cliff have none. A second lever drops lava/lava-hot from the TILE " + - "autoplace, which leaves the elevation the crossings read untouched and takes away the only " + - "thing the tile-collision rejection can reject against - so the cells that appear are the " + - "game's own answer to which cliffs lava suppresses, scorable for precision and recall rather " + - "than tuned to fit. Every arm dumps EVERY entity in the region with its type (so a null " + - "result still says what was standing there), the prototype collision geometry, the " + - "autoplace_settings the SURFACE read back, and a COUNT of the lava tiles actually generated " + - "- the last two are what make an empty entity list or an unmoved cliff evidence rather than " + - "an unapplied override. cliff-vulcanus comes from cliff_settings, not from either category, " + - "so a lever arm that also emptied the cliffs would be vacuous. " + - "Sampled on a create_surface() surface whose seed is FORCED to `seed`, like every other " + - "Vulcanus oracle fixture. Regenerate: node --experimental-strip-types test/oracle/capture.ts " + - "vulcanus-cliff-suppressor-levers", - seed, - region, - cases, - }; - const out = join(FIXTURES, "oracle-vulcanus-cliff-suppressor-levers.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${String(cases.length)} arms)`); -} - -/** - * **The grid-4 cliff-elevation channel, read out corner by corner at the bands - * the placement rule actually consults** (#84). - * - * This is the one input to cliff placement that has never had a per-corner - * oracle. `oracle-vulcanus-cliff-corner-fields-entity-regions` holds the TILE - * channel - `calculate_tile_properties` evaluates the 1-tile noise program, and - * `multisample`'s offsets are in the calling program's grid units, so against - * the grid-4 field the cliff generator reads it differs by **96.09**. Every - * "the field is exact" claim in the residual work rests on the wrong channel. - * - * The readout uses the cliff generator itself, which is the only consumer known - * to walk the 4-tile lattice. Collapse the rule through `cliff_settings`: - * - * - `cliff_smoothing = 0` - the cliff elevation IS the raw grid-4 field, with - * no interpolation between knots standing between the field and the cliff. - * - `cliff_elevation_interval = 1e6` - one contour, at `cliff_elevation_0`, so - * `crossesCliff`'s band arithmetic (`boundary = e0 + interval*floor(...)`) - * collapses to a single threshold test. - * - `richness = 4` - `0.5*log2(4) = 1`, so `cliffiness_basic` saturates at 1.5 - * and its `> 0.5` gate is open EVERYWHERE. - * - * Under those three, `crossesCliff(a, b)` reduces to `min(a,b) < e0 <= max(a,b)` - * with both corners non-negative. So the game places a cliff on an edge exactly - * when that edge's two corners straddle the level - and the ORIENTATION says - * which side is the high one. Each run is therefore a **1-bit comparator on - * every one of the region's 4,225 corners at once**, and the fixture is the - * game's own answer to "is your grid-4 field above this level here?". - * - * **The levels are the real bands, not a uniform sweep**, and that is the whole - * design. `crossesCliff` only ever compares the field against `70 + 120k`, so - * the game's bits at those levels are the entire placement-relevant content of - * the channel: if they match, the port's placement is right whatever the field's - * exact values are, and no finer sweep can add anything. Per region, the bands - * its field actually spans (port-side range, so the set is not a guess): - * - * | region | grid-4 range | bands crossed | - * | --- | --- | --- | - * | `[0,0]` | -53.16 .. 402.44 | 70, 190, 310 | - * | `[1500,1500]` | -36.38 .. 1226.33 | 70 .. 1150, all ten | - * | `[-1200,800]` | -54.86 .. 300.06 | 70, 190 | - * - * `oracle-vulcanus-elevation-levels` already collapses the rule this way, but - * only over `[0,0]` and only at 20..200 step 10 - it never reaches a single band - * above 190, while the residual's wrong cells sit at **670 / 790 / 1030**, in - * `[1500,1500]`, which that fixture does not cover at all. - */ -async function captureVulcanusCliffBands(): Promise { - const seed = 123456; - const ONE_BAND = 1000000; - // Exactly the regions of oracle-vulcanus-cliff-entities.seed123456.json, so - // this joins to the entity fixture and to every residual spec built on it. - const plan: { region: Region; levels: number[] }[] = [ - { region: { x0: 0, y0: 0, x1: 256, y1: 256 }, levels: [70, 190, 310] }, - { - region: { x0: 1500, y0: 1500, x1: 1756, y1: 1756 }, - levels: [70, 190, 310, 430, 550, 670, 790, 910, 1030, 1150], - }, - { region: { x0: -1200, y0: 800, x1: -944, y1: 1056 }, levels: [70, 190] }, - ]; - - /** - * **Two ways to hold the cliffiness gate open, because one of them is a - * MODEL and the other is not.** - * - * `richness = 4` works through `cliffiness_basic` itself: - * `clamp(0.5*log2(4) + qmn, 0, 1) + 0.5`. Comparing against it therefore - * requires the port to reproduce `qmn` in the region where the DEFAULT-richness - * field is clamped flat at 0.5 - and that is precisely the population no - * fixture validates. `oracle-vulcanus-cliff-corner-fields-entity-regions` puts - * **8,409 of its 12,675 corners** on a clamp, and shifting the richness term by - * +1 turns exactly those into the ones that decide the gate. So a disagreement - * under this arm cannot be attributed: it is either the field or `qmn` below - * the clamp. - * - * Routing the `cliffiness` PROPERTY at the constant `1` removes the model - * entirely - the gate is open at every corner by construction, with no - * expression of ours in the way - so a disagreement under that arm is the - * cliff-elevation field and nothing else. Both arms are captured because the - * pair is the measurement: agreement between them says the richness route was - * sound, and disagreement localises which input moved. - */ - const arms = [ - { - gate: "richness4", - cliffSettings: { richness: 4 } as Record, - probe: undefined as string | undefined, - }, - { gate: "constant1", cliffSettings: {}, probe: "1" }, - ]; - - const cases: { - region: Region; - level: number; - gate: string; - effective: DumpedCliffSettings | undefined; - cliffs: Position[]; - }[] = []; - for (const arm of arms) { - for (const { region, levels } of plan) { - for (const level of levels) { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const dump = await sampleCliffEntitiesFull(region, { - workDir, - seed, - spaceAge: true, - planet: "vulcanus", - cliffSettings: { - cliff_smoothing: 0, - cliff_elevation_interval: ONE_BAND, - cliff_elevation_0: level, - ...arm.cliffSettings, - }, - probeProperty: arm.probe === undefined ? undefined : "cliffiness", - probeExpression: arm.probe, - }); - cases.push({ - region, - level, - gate: arm.gate, - effective: dump.cliffSettings, - cliffs: dump.cliffs, - }); - console.log( - ` ${arm.gate} [${String(region.x0)},${String(region.y0)}] level ${String(level)} -> ` + - `${String(dump.cliffs.length)} cliffs (effective e0=` + - `${String(dump.cliffSettings?.cliff_elevation_0)}, ` + - `interval=${String(dump.cliffSettings?.cliff_elevation_interval)}, ` + - `smoothing=${String(dump.cliffSettings?.cliff_smoothing)}, ` + - `richness=${String(dump.cliffSettings?.richness)})`, - ); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - } - } - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 via test/oracle. THE GRID-4 CLIFF-ELEVATION CHANNEL, " + - "read out corner by corner - the one input to cliff placement that had no per-corner oracle " + - "(#84). oracle-vulcanus-cliff-corner-fields-entity-regions holds the TILE channel " + - "(calculate_tile_properties runs the 1-tile program, and multisample's offsets are in the " + - "calling program's grid units), which differs from the channel the cliff generator reads by " + - "96.09. Here the CLIFF GENERATOR is the readout: with cliff_smoothing=0 (the raw field, no " + - "interpolation), cliff_elevation_interval=1e6 (one contour, so crossesCliff's band " + - "arithmetic collapses to a single threshold) and richness=4 (cliffiness_basic saturates at " + - "1.5 so its >0.5 gate is open everywhere) OR the `cliffiness` property routed at the literal " + - "constant 1, the game places a cliff on an edge exactly when " + - "its two corners straddle cliff_elevation_0, and the entity's orientation says which side " + - "is high. Each case is therefore a 1-bit comparator applied to all 4,225 corners of the " + - "region at once. The levels are the REAL bands (70 + 120k) rather than a uniform sweep, " + - "because crossesCliff only ever compares the field against those - the bits at the bands " + - "are the entire placement-relevant content of the channel. Per region the levels span the " + - "bands that region's field actually crosses. Distinct from " + - "oracle-vulcanus-elevation-levels, which collapses the rule the same way but covers only " + - "[0,0] at 20..200 step 10 and so reaches no band above 190, while the residual's wrong " + - "cells sit at 670/790/1030 in [1500,1500]. `effective` is the cliff_settings the SURFACE " + - "reported back, so an override that failed to apply cannot be mistaken for one that did " + - "nothing. Sampled on a create_surface() surface whose seed is FORCED to `seed`, like every " + - "other Vulcanus oracle fixture. TWO GATE ARMS per case (`gate`): richness4 opens the gate " + - "through cliffiness_basic itself, so comparing against it needs the port to reproduce qmn " + - "BELOW the clamp - the 8,409-of-12,675 corner population that no fixture validates, since " + - "shifting the richness term by +1 turns exactly the clamped corners into the ones that " + - "decide the gate. constant1 routes the cliffiness PROPERTY at the literal 1, so the gate is " + - "open by construction with no expression of ours in the way and a disagreement there is the " + - "cliff-elevation field and nothing else. The pair is the measurement. Regenerate: node " + - "--experimental-strip-types " + - "test/oracle/capture.ts vulcanus-cliff-bands", - seed, - cliffElevation0: 70, - cliffElevationInterval: 120, - cases, - }; - const out = join(FIXTURES, "oracle-vulcanus-cliff-bands.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${String(cases.length)} cases)`); -} - -/** - * **The fine level sweep: what IS the game's grid-4 cliff elevation?** (#84) - * - * `captureVulcanusCliffBands` established that the port's grid-4 field is exact - * at `[0,0]` and `[-1200,800]` and wrong at `[1500,1500]`'s HIGH bands, and - * bounded the disagreement from below (median 18.8, max 69.0 units). It cannot - * say what the game's value actually is, because it only samples the field at - * the ten band boundaries. - * - * This sweeps `cliff_elevation_0` across `[700, 900]` in steps of 5 with the - * same collapsed rule and the gate held open by the constant route, which turns - * the game's cliffs into a **per-corner bracket** on its own field: - * - * - A placed cell's code names its crossing edges and their SIGN, so each - * crossing edge at level `L` says "this corner >= L, that corner < L" - two - * one-sided constraints per edge, in world terms, with no model in between. - * - Sweeping `L` tightens both sides. For a corner of value `v` and a neighbour - * of value `w`, their shared edge crosses for every `L` in `(min, max]`, so a - * corner accumulates constraints from every neighbour it out-ranks - and the - * binding ones are the levels nearest `v`. At step 5 the bracket closes to 5. - * - * **Only POSITIVE observations are used, and that is what makes it sound.** An - * absent cliff is ambiguous - the lava/ore rejections drop cells, and - * `fixImpossibleCells` clears edges - so absence is never read as "no crossing". - * A PRESENT crossing is unambiguous in the other direction: the repair sweep - * only ever writes `0` (`fixImpossibleCellsSweep`, verified line by line), so it - * can delete a crossing but never invent one, and the rejections are post-filters - * that do not touch the edge registers at all. Every bracket here is therefore a - * constraint the game actually asserted. - * - * The range covers L790 and L910, where the disagreement is worst (36/42 and - * 22/41), and the corners inside it whose cells the port already reproduces are - * the built-in control: if the reconstruction is sound, THEIR brackets must - * contain the port's values. - */ -async function captureVulcanusCliffFineSweep(): Promise { - const seed = 123456; - const ONE_BAND = 1000000; - const region: Region = { x0: 1500, y0: 1500, x1: 1756, y1: 1756 }; - const levels: number[] = []; - for (let e0 = 700; e0 <= 900; e0 += 5) levels.push(e0); - - const cases: { - level: number; - effective: DumpedCliffSettings | undefined; - cliffs: Position[]; - }[] = []; - for (const level of levels) { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const dump = await sampleCliffEntitiesFull(region, { - workDir, - seed, - spaceAge: true, - planet: "vulcanus", - cliffSettings: { - cliff_smoothing: 0, - cliff_elevation_interval: ONE_BAND, - cliff_elevation_0: level, - }, - // The gate as a construction, not a model - see captureVulcanusCliffBands. - probeProperty: "cliffiness", - probeExpression: "1", - }); - cases.push({ level, effective: dump.cliffSettings, cliffs: dump.cliffs }); - console.log( - ` level ${String(level)} -> ${String(dump.cliffs.length)} cliffs ` + - `(effective e0=${String(dump.cliffSettings?.cliff_elevation_0)}, ` + - `smoothing=${String(dump.cliffSettings?.cliff_smoothing)})`, - ); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 via test/oracle. The Vulcanus region [1500,1500]-" + - "[1756,1756] at 41 values of cliff_elevation_0 (700..900 step 5) with the placement rule " + - "COLLAPSED (cliff_smoothing=0, cliff_elevation_interval=1e6) and the cliffiness gate held " + - "open by routing the cliffiness PROPERTY at the literal 1 - a construction, not a model. " + - "Under those settings a cliff sits on an edge exactly when its two corners straddle " + - "cliff_elevation_0, and the entity's orientation names which side is high, so each placed " + - "cell asserts one-sided constraints on its corners' values. Sweeping the level brackets " + - "every corner to the step: this fixture MEASURES the grid-4 cliff-elevation field the " + - "generator reads, which oracle-vulcanus-cliff-bands could only bound from below (median " + - "18.8, max 69.0 units) because it samples the field only at the ten band boundaries. Use " + - "POSITIVE observations only - an absent cliff is ambiguous (lava/ore rejections drop cells, " + - "fixImpossibleCells clears edges) but a PRESENT crossing is not, because the repair sweep " + - "only ever writes 0 and so can delete a crossing but never invent one. The range covers the " + - "bands where the port disagrees worst (790, 910). `effective` is the cliff_settings the " + - "SURFACE reported back. Sampled on a create_surface() surface whose seed is FORCED to " + - "`seed`. Regenerate: node --experimental-strip-types test/oracle/capture.ts " + - "vulcanus-cliff-fine-sweep", - seed, - region, - cases, - }; - const out = join(FIXTURES, "oracle-vulcanus-cliff-fine-sweep.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${String(cases.length)} levels)`); -} - -/** - * **Does CHUNK-GENERATION ORDER carry the ore effect? (#84)** - * - * The ore moves cliffs - 885 vs 916 `cliff-vulcanus` in `[1500,1500]` - and - * every route from a resource control to a cliff is closed: the field (both - * properties), `Surface::wouldCollide`'s entity half and tile half, and a - * structural perturbation of the settings object. `vulcanus-cliffs-NOTES.md` - * lists three ideas that no measurement touches, and this is the first: nothing - * has looked at whether the ore changes **which chunks get generated, or in what - * sequence**. - * - * It is not an idle one. `Surface::getEffectiveTileID` returns **0 for an - * absent chunk** and `checkTileCollisions` then SKIPS that tile, so whether a - * neighbouring chunk exists yet is a real input to whether a cliff survives - - * and `applyCliffs` adds each cliff to the surface before testing the next. - * Order is a live causal channel on its face; the question is whether the ore - * can move it. - * - * **The hypothesis has two links, and breaking either closes the route.** - * - * - **Link A, ore -> order.** Arms 1 and 2, the established resources-ON / - * resources-OFF pair from `vulcanus-cliff-ore-direction`, now recording the - * generated chunk set and sequence. - * - **Link B, order -> cliffs.** Arms 3-6, identical settings to 1 and 2, - * differing only in the order the SAME chunks are generated in. This is the - * sharper of the two: it tests the second link directly, and a negative - * closes the route whatever the ore does to the order. - * - * **The order perturbation is measured, not assumed.** The first pass at this - * tried to read generation order from `on_chunk_generated`, and it came back - * with a ZERO-length sequence in all four arms while 81 chunks generated - - * Factorio does not dispatch events raised during `on_init`. So the arms are - * built as TWO blocking drains with a chunk snapshot taken between them, and - * they split on different axes: `right-half-first` reports 36 chunks at its - * midpoint, `bottom-half-first` reports a different 36. Those are two - * demonstrably different generation orders over one identical chunk set, and - * neither claim rests on how the game drains a queue. - * - * **Predictions, registered here before the capture runs:** - * - * | comparison | prediction | what a violation means | - * | --- | --- | --- | - * | ON vs OFF, chunk SET | identical | the ore changes which chunks exist - the route is OPEN | - * | x-split vs y-split, mid-run half | **differs** | the perturbation is INERT and arms 3-6 say nothing | - * | x-split vs y-split, chunk SET | identical | the perturbation changed more than the order; arms void | - * | across all 3 orders, cliffs | identical | order moves cliffs - a result on its own account | - * | ON vs OFF, cliffs | **differs** (885/916) | the ore effect is absent here and every arm is off-target | - * | arm 1 cliffs | 885, as every prior fixture | this capture is not describing the studied world | - * - * The bold rows are the non-vacuity arms and they are why this is six runs - * rather than two. Without the second, "the orders agree on the cliffs" is - * indistinguishable from an order that never changed; without the fifth, it is - * a statement about a run in which the thing being explained did not happen. - * - * The two levers are CROSSED rather than each tested against the default, so an - * INTERACTION is visible - the only shape in which a closed Link B could still - * leave the ore acting through order. - */ -async function captureVulcanusCliffChunkOrder(): Promise { - const seed = 123456; - const region: Region = { x0: 1500, y0: 1500, x1: 1756, y1: 1756 }; - const OFF = { frequency: 1, size: 0, richness: 1 }; - const ALL_OFF = { - tungsten_ore: OFF, - calcite: OFF, - vulcanus_coal: OFF, - sulfuric_acid_geyser: OFF, - }; - const ORDERS = ["forward", "right-half-first", "bottom-half-first"] as const; - const arms: { - label: string; - chunkOrder: (typeof ORDERS)[number]; - autoplaceControls?: Record; - }[] = ORDERS.flatMap((chunkOrder) => [ - { label: `${chunkOrder} order, resources ON`, chunkOrder }, - { label: `${chunkOrder} order, ALL resources OFF`, chunkOrder, autoplaceControls: ALL_OFF }, - ]); - - const cases: unknown[] = []; - for (const arm of arms) { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const dump = await sampleCliffEntitiesFull(region, { - workDir, - seed, - spaceAge: true, - planet: "vulcanus", - autoplaceControls: arm.autoplaceControls, - alsoResources: true, - recordChunks: true, - chunkOrder: arm.chunkOrder, - }); - cases.push({ - label: arm.label, - chunkOrder: arm.chunkOrder, - autoplaceControls: arm.autoplaceControls ?? null, - effectiveAutoplace: dump.autoplaceControls, - cliffs: dump.cliffs, - resourceCount: dump.resources?.length ?? -1, - chunkSequenceLength: dump.chunkSequence?.length ?? -1, - chunksAtEnd: dump.chunksAtEnd, - chunksAfterFirstDrain: dump.chunksAfterFirstDrain, - }); - const named = dump.cliffs as unknown as readonly { name: string }[]; - const vulc = named.filter((c) => c.name === "cliff-vulcanus").length; - console.log( - ` ${arm.label} -> ${String(vulc)} cliff-vulcanus, ` + - `${String(dump.resources?.length ?? -1)} resources, ` + - `${String(dump.chunksAtEnd?.length ?? -1)} chunks, ` + - `mid ${String(dump.chunksAfterFirstDrain?.length ?? -1)}, ` + - `seq ${String(dump.chunkSequence?.length ?? -1)}`, - ); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 via test/oracle. Tests whether CHUNK-GENERATION ORDER is " + - "the missing mechanism behind the Vulcanus ore/cliff effect (#84) - the first of the three " + - "ideas vulcanus-cliffs-NOTES.md lists as untouched after all five direct routes closed. Two " + - "levers are CROSSED: resources ON/OFF (the established autoplace_controls arm) against the " + - "order the SAME chunks are generated in (one blocking drain; or two drains splitting the " + - "region on x, or on y). Every arm records the generated chunk SET, and the two-drain arms " + - "also record the half that existed BETWEEN their drains - that snapshot is what proves the " + - "order really changed, since chunkSequenceLength is 0 everywhere (Factorio dispatches no " + - "on_chunk_generated for chunks generated during on_init). Cliffs, resource count and chunks " + - "all come from ONE surface per arm, so 'the order did not move the cliffs' can never be " + - "confused with 'the order never moved' or with 'the ore effect was absent from this run'. " + - "Regenerate: node --experimental-strip-types test/oracle/capture.ts vulcanus-cliff-chunk-order", - seed, - region, - cases, - }; - const out = join(FIXTURES, "oracle-vulcanus-cliff-chunk-order.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${String(cases.length)} arms)`); -} - -/** - * **The RUNTIME DESTROY PROBE (#127, #84).** - * - * #127 established that the cliff CONNECTION rules cannot be scored from map - * generation output at all - not for want of regions, but because the game's - * output is always connection-consistent, so `updateConnections` never has a - * droppable end to act on and both readings of its gate predict exactly what - * the game shows. It named the two kinds of evidence that could settle it: the - * disassembly, and "a runtime probe that destroys a cliff outside map - * generation". #134/#135 did the disassembly and showed the probe is SAFE - all - * four of `Cliff::onDestroy`'s cascade gates hold on the Lua path too. **This is - * the probe.** - * - * A second attempt at scoring it from output was made first and failed, which - * is worth recording so nobody repeats it: #137's chunk-order lever produces - * arms where a border chunk is applied with its neighbour chunk PROVABLY - * ungenerated, which is exactly the gate's input - but on the `[1500,1500]` - * west seam all five cliffs carrying a west end have a neighbour that - * `isCliffConnected` ACCEPTS, so there is still nothing to drop. The - * counterfactual has to be constructed, not found. - * - * **`do_cliff_correction` defaults to `false`**, and that is the single fact - * this capture is designed around. A probe that called a bare `destroy()` would - * find neighbours unchanged and the null would mean "the flag was off" rather - * than "the game does not cascade" - vacuous, and indistinguishable from a - * result. Every target set is therefore run BOTH ways. - * - * **Predictions, registered before the capture runs:** - * - * | arm | prediction | - * | --- | --- | - * | correction ON, connected targets | neighbours' facing ends become `none`; some may cascade away entirely | - * | correction OFF, same targets | **neighbours completely unchanged** - only the targets vanish | - * | correction ON, UNCONNECTED targets | nothing but the targets changes - the cascade has nothing to reach | - * | every arm | `destroyReport[i].found` and `.destroyed` both true for every target | - * - * The third row is the control that separates "the cascade ran" from "destroying - * anything perturbs the region", and the fourth is what stops a missed search - * box from reading as a destruction. - * - * Targets are chosen HERE, in TypeScript, from the committed `[1500,1500]` - * entity fixture and the port's own `isCliffConnected` - not in Lua - so the - * selection is auditable and the spec can recompute it. They are spread at least - * 24 tiles apart so no two cascades can interact, which would otherwise make a - * per-target prediction untestable. - */ -async function captureVulcanusCliffDestroyProbe(): Promise { - const seed = 123456; - const region: Region = { x0: 1500, y0: 1500, x1: 1756, y1: 1756 }; - const source = JSON.parse( - await readFile(join(FIXTURES, "oracle-vulcanus-cliff-entities.seed123456.json"), "utf8"), - ) as { cases: { region: Region; cliffs: { x: number; y: number; orientation: string }[] }[] }; - const base = source.cases.find((c) => c.region.x0 === region.x0 && c.region.y0 === region.y0); - if (base === undefined) throw new Error("no [1500,1500] case in the cliff-entity fixture"); - - const CELL_SIDE_LOCAL = { north: 0, east: 1, south: 2, west: 3, none: 4 }; - const CHUNK_CELLS = 32 / CLIFF_GRID_SIZE; - const ENDS: readonly (readonly [number, number])[] = CLIFF_ORIENTATION_NAMES.map((name) => { - const [from, to] = name.split("-to-"); - const side = (t: string): number => CELL_SIDE_LOCAL[t as keyof typeof CELL_SIDE_LOCAL]; - return [side(from), side(to)] as const; - }); - const oppositeSideLocal = (side: number): number => [2, 3, 0, 1, 4][side] ?? 4; - const connectedSidesLocal = (o: number): number[] => - (ENDS[o] ?? []).filter((s) => s !== CELL_SIDE_LOCAL.none); - const isConnectedLocal = (side: number, mine: number, theirs: number): boolean => { - const a = ENDS[mine]; - const b = ENDS[theirs]; - if (a === undefined || b === undefined) return false; - const opp = oppositeSideLocal(side); - if (a[0] === side) return b[0] !== opp && b[1] === opp; - return a[1] === side && b[0] === opp && b[1] !== opp; - }; - const onBorderLocal = (x: number, y: number): boolean => { - const cx = (x - CLIFF_CELL_CENTER_X) / CLIFF_GRID_SIZE; - const cy = (y - CLIFF_CELL_CENTER_Y) / CLIFF_GRID_SIZE; - const ix = ((cx % CHUNK_CELLS) + CHUNK_CELLS) % CHUNK_CELLS; - const iy = ((cy % CHUNK_CELLS) + CHUNK_CELLS) % CHUNK_CELLS; - return ix === 0 || ix === CHUNK_CELLS - 1 || iy === 0 || iy === CHUNK_CELLS - 1; - }; - const oi = (name: string): number => CLIFF_ORIENTATION_NAMES.indexOf(name); - const at = new Map(base.cliffs.map((c) => [`${String(c.x)},${String(c.y)}`, c])); - const SIDE_STEP: readonly (readonly [number, number])[] = [ - [0, -CLIFF_GRID_SIZE], - [CLIFF_GRID_SIZE, 0], - [0, CLIFF_GRID_SIZE], - [-CLIFF_GRID_SIZE, 0], - ]; - const connectedCount = (c: { x: number; y: number; orientation: string }): number => { - const mine = oi(c.orientation); - if (mine < 0) return 0; - return connectedSidesLocal(mine).filter((side) => { - const step = SIDE_STEP[side]; - if (step === undefined) return false; - const n = at.get(`${String(c.x + step[0])},${String(c.y + step[1])}`); - return n !== undefined && isConnectedLocal(side, mine, oi(n.orientation)); - }).length; - }; - // **Targets must sit well INSIDE the region, and that is not tidiness.** The - // first run picked them in scan order, so all eight landed on the region's - // top edge - and every single prediction mismatch was an edge artifact. A - // cliff at `y = 1498.5` is in the dump only because `find_entities_filtered` - // selects on bounding-box overlap, but ITS neighbours at `y = 1494.5` are - // outside the dump entirely. The game cascades through cliffs the comparison - // cannot see, so the model "under-destroys" for a reason that has nothing to - // do with the model. 48 tiles of margin keeps a cascade's neighbourhood - // inside the captured world. - const MARGIN = 48; - const wellInside = (c: { x: number; y: number }): boolean => - c.x >= region.x0 + MARGIN && - c.x < region.x1 - MARGIN && - c.y >= region.y0 + MARGIN && - c.y < region.y1 - MARGIN; - - /** Greedily take `n` cliffs matching `pick`, never within 24 tiles of one already taken. */ - const spread = ( - pick: (c: { x: number; y: number; orientation: string }) => boolean, - n: number, - ): { x: number; y: number }[] => { - const out: { x: number; y: number }[] = []; - for (const c of base.cliffs) { - if (!wellInside(c) || !pick(c)) continue; - if (out.some((o) => Math.abs(o.x - c.x) < 24 && Math.abs(o.y - c.y) < 24)) continue; - out.push({ x: c.x, y: c.y }); - if (out.length === n) break; - } - return out; - }; - - const borderTargets = spread((c) => onBorderLocal(c.x, c.y) && connectedCount(c) > 0, 8); - const interiorTargets = spread((c) => !onBorderLocal(c.x, c.y) && connectedCount(c) > 0, 8); - // **There is no unconnected-target control, and that is a finding rather than - // an omission.** Exactly ONE cliff of the 885 has no connected neighbour at - // all, and it sits at the region's corner, outside the margin above - so the - // arm would ship with zero targets, asserting "nothing changed" vacuously. - // Its absence is a restatement of the connection-consistency #127 measured - // across thirteen arms. The correction-OFF arms carry the control role - // instead: same targets, same world, cascade disabled. - const loneCount = base.cliffs.filter((c) => connectedCount(c) === 0).length; - console.log( - ` targets: ${String(borderTargets.length)} border, ` + - `${String(interiorTargets.length)} interior ` + - `(${String(loneCount)} cliffs region-wide have NO connected neighbour)`, - ); - - const arms: { label: string; targets: { x: number; y: number }[]; correction: boolean }[] = [ - { label: "border targets, correction ON", targets: borderTargets, correction: true }, - { label: "border targets, correction OFF", targets: borderTargets, correction: false }, - { label: "interior targets, correction ON", targets: interiorTargets, correction: true }, - { label: "interior targets, correction OFF", targets: interiorTargets, correction: false }, - ]; - - const cases: unknown[] = []; - for (const arm of arms) { - const workDir = await mkdtemp(join(tmpdir(), "oracle-capture-")); - try { - const dump = await sampleCliffEntitiesFull(region, { - workDir, - seed, - spaceAge: true, - planet: "vulcanus", - destroyPositions: arm.targets, - cliffCorrection: arm.correction, - }); - cases.push({ - label: arm.label, - correction: arm.correction, - targets: arm.targets, - cliffsBefore: dump.cliffs, - cliffsAfter: dump.cliffsAfter, - destroyReport: dump.destroyReport, - }); - console.log( - ` ${arm.label} -> ${String(dump.cliffs.length)} before, ` + - `${String(dump.cliffsAfter?.length ?? -1)} after, ` + - `${String((dump.destroyReport ?? []).filter((r) => r.destroyed).length)}/` + - `${String(arm.targets.length)} destroyed`, - ); - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } - - const fixture = { - _comment: - "Ground truth from Factorio 2.1.12 via test/oracle. The RUNTIME DESTROY PROBE #127 asked " + - "for (#84): it builds the world map generation never produces - a cliff run truncated so a " + - "neighbour's end has nothing to connect to - by destroying selected cliffs through Lua and " + - "reading every cliff back a second time. #127 showed the connection rules cannot be scored " + - "from map-generation output at any number of regions, because the game's output is always " + - "connection-consistent; #135 showed all four of Cliff::onDestroy's cascade gates hold on " + - "the Lua path, so this probe reproduces map generation's cascade. Every target set is run " + - "with do_cliff_correction BOTH true and false, because it DEFAULTS TO FALSE and a bare " + - "destroy() would give an unchanged-neighbours null that reads exactly like a result. " + - "Targets are chosen in TypeScript from the committed [1500,1500] entity fixture using the " + - "port's own isCliffConnected, spread 24+ tiles apart so no two cascades interact, and each " + - "arm dumps whether the cliff was FOUND and whether destroy() returned true, so a missed " + - "search box can never read as a destruction. Regenerate: node --experimental-strip-types " + - "test/oracle/capture.ts vulcanus-cliff-destroy-probe", - seed, - region, - unconnectedCliffsRegionWide: loneCount, - cases, - }; - const out = join(FIXTURES, "oracle-vulcanus-cliff-destroy-probe.seed123456.json"); - await writeFile(out, JSON.stringify(fixture, null, 2) + "\n"); - console.log(`wrote ${out} (${String(cases.length)} arms)`); -} - -if (want("voronoi-search-range")) await captureVoronoiSearchRange(); -if (want("voronoi-jitter0")) await captureVoronoiJitter0(); -if (want("voronoi-cellid")) await captureVoronoiCellId(); -if (want("voronoi-points")) await captureVoronoiPoints(); -if (want("vulcanus-cliff-destroy-probe")) await captureVulcanusCliffDestroyProbe(); -if (want("vulcanus-cliff-chunk-order")) await captureVulcanusCliffChunkOrder(); -if (want("vulcanus-cliff-suppressor-levers")) await captureVulcanusCliffSuppressorLevers(); -if (want("vulcanus-cliff-bands")) await captureVulcanusCliffBands(); -if (want("vulcanus-cliff-fine-sweep")) await captureVulcanusCliffFineSweep(); -if (want("cliff-entities")) await captureCliffEntities(); -if (want("vulcanus-cliff-ore-direction")) await captureVulcanusCliffOreDirection(); -if (want("vulcanus-cliff-removal-probability")) await captureVulcanusCliffRemovalProbability(); -if (want("vulcanus-cliff-ore-direction-regions")) await captureVulcanusCliffOreDirectionRegions(); -if (want("vulcanus-tile-lever")) await captureVulcanusTileLever(); -if (want("vulcanus-cliff-ore-richness")) await captureVulcanusCliffOreRichness(); -if (want("vulcanus-cliff-entities-more-regions")) await captureVulcanusCliffEntitiesMoreRegions(); -if (want("vulcanus-cliff-entities-west-oos")) await captureVulcanusCliffEntitiesWestOos(); -if (want("vulcanus-cliff-entities-border-batch")) await captureVulcanusCliffEntitiesBorderBatch(); -if (want("vulcanus-ore-cliff-replication")) await captureVulcanusOreCliffReplication(); -if (want("vulcanus-cliff-corner-fields")) await captureVulcanusCliffCornerFields(); -if (want("vulcanus-cliff-corner-fields-entity-regions")) - await captureVulcanusCliffCornerFieldsAtEntityRegions(); -if (want("rocks")) await captureRocks(); -if (want("vulcanus-cliff-entities")) await captureVulcanusCliffEntities(); -if (want("vulcanus-cliff-smoothing")) await captureVulcanusCliffSmoothing(); -if (want("vulcanus-cliff-smoothing-off-regions")) await captureVulcanusCliffSmoothingOffRegions(); -if (want("cliff-smoothing-stencil")) await captureCliffSmoothingStencil(); -if (want("vulcanus-cliff-collapsed")) await captureVulcanusCliffCollapsed(); -if (want("vulcanus-elevation-levels")) await captureVulcanusElevationLevels(); -if (want("multisample-grid")) await captureMultisampleGrid(); -if (want("vulcanus-resource-entities")) await captureVulcanusResourceEntities(); -if (want("multisample")) await captureMultisample(); -if (want("vulcanus-smoke")) await captureVulcanusSmoke(); -if (want("seed-vars")) await captureSeedVars(); -if (want("starting-spot")) await captureStartingSpotAtAngle(); -if (want("vulcanus-helpers")) await captureVulcanusHelpers(); -if (want("vulcanus-spawn")) await captureVulcanusSpawn(); -if (want("vulcanus-cracks")) await captureVulcanusCracks(); -if (want("vulcanus-resources")) await captureVulcanusResources(); -if (want("vulcanus-biomes")) await captureVulcanusBiomes(); -if (want("vulcanus-climate")) await captureVulcanusClimate(); -if (want("vulcanus-elevation")) await captureVulcanusElevation(); -if (want("vulcanus-temperature")) await captureVulcanusTemperature(); -if (want("vulcanus-tile-names")) await captureVulcanusTileNames(); -if (want("vulcanus-lava-boundary")) await captureVulcanusLavaBoundary(); -if (want("vulcanus-cliffs")) await captureVulcanusCliffs(); -if (want("vulcanus-rocks")) await captureVulcanusRocks(); -if (want("basis-output-scale")) await captureBasisOutputScale(); -if (want("basis-input-scale")) await captureBasisInputScale(); -if (want("basis-caller-scales")) await captureBasisCallerScales(); -if (want("vulcanus-plasma-decomposition")) await captureVulcanusPlasmaDecomposition(); diff --git a/test/previewAgreement.spec.ts b/test/previewAgreement.spec.ts deleted file mode 100644 index 248e516b..00000000 --- a/test/previewAgreement.spec.ts +++ /dev/null @@ -1,409 +0,0 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { inflateSync } from "node:zlib"; -import { describe, expect, it } from "vite-plus/test"; - -import { withDiffArtifacts } from "./diffArtifacts"; -import { decodePng } from "./oracle/decodePng"; -import { runRenderRequest } from "../src/noise/preview/elevationRenderRequest"; -import type { ElevationRenderRequest } from "../src/noise/preview/elevationRenderRequest"; -import { CLIFF_MAP_COLOR } from "../src/noise/cliffs/cliffCatalog"; -import { ROCK_MAP_COLOR } from "../src/noise/rocks/rockCatalog"; -import { makeFulgoraStack } from "../src/noise/tiles/fulgoraCatalog"; -import { makeFulgoraScrap } from "../src/noise/expressions/fulgoraScrap"; -import { SCRAP_MAP_COLOR } from "../src/noise/resources/fulgoraResourceCatalog"; -import { VULCANUS_RESOURCE_CATALOG } from "../src/noise/resources/vulcanusResourceCatalog"; - -/** - * Compare our render against the game's OWN `--generate-map-preview` output. - * - * Every other oracle in this repo validates a *value* - a noise expression, a tile - * name, an entity count or position - and none of them can see a whole-overlay - * error: a layer missing entirely, drawn in the wrong colour, at the wrong scale, - * or composited in the wrong order. Each is checked against the thing it is - * derived from rather than against the finished image. This is the check that - * closes that (issue #22 item 6), and it immediately found a real one: Vulcanus - * rocks were painted at 0.07x the game's coverage. - * - * The fixtures are the game's PNGs, captured with every disableable autoplace - * control forced to `size: 0` so the comparison is layer-by-layer rather than a - * pile of everything at once - * (`test/oracle/previewCompare.ts`; which controls are disableable is the game's - * own answer, in `autoplace-can-be-disabled.dump.json`). Needing no Factorio - * binary at test time is the whole point of committing them. - * - * Alignment: `--map-preview-size 1024` covers 1024 world tiles centred on the - * origin, i.e. 1 tile per pixel from `(-512, -512)`. - * - * --- - * - * **Every comparison here carries `}, 300000)`, and it used to be `120000`.** - * Each one renders a full 1024x1024 image, which makes them the slowest tests - * in the suite. That number is a RESOURCE budget, not an assertion bound - - * raising it blesses nothing, and every measured claim below still has to hold. - * - * The same unchanged test, "Vulcanus rock and cliff coverage", on four - * consecutive CI runs: - * - * | tree | duration | result | - * | ---------------------------- | -------- | --------- | - * | main, before the scrap work | 69.6s | pass | - * | PR #202 | 90.1s | pass | - * | main, after #202 merged | 108.8s | pass | - * | PR #203 | 150.5s | TIMED OUT | - * | PR #203, at this 300s budget | 139.7s | pass | - * - * That last row is what proves the budget was the problem: given room, the same - * test finishes in 139.7s. It is over 120s on its own merits rather than just - * under, so a bigger number is not masking anything here. - * - * Its own work never changed across any of those runs - #202's only render-path - * edit is inside the `planet === "fulgora"` branch, and Vulcanus never enters - * it. What changed is the runner. #202 added three spec files, which re-buckets - * vitest's sha1 hash-shard, and shard 1 now co-schedules this file (298s) with - * `vulcanusCliffRejectionStage.spec.ts` (205s) - 503s of that shard's 653s on - * 2 of its 4 workers. - * - * So a timeout here means slow, exactly as CLAUDE.md says, and the run-to-run - * spread on identical code is about 40%. 300s gives the worst run yet a 2x - * margin. If it ever trips again, read the duration the reporter prints before - * assuming a hang - no assertion in this file has ever failed on CI. - * - * --- - * - * **`withDiffArtifacts` writes pictures when a bound trips.** Every comparison - * below reports a scalar, and `expected 237 to be less than 200` says a render - * moved without saying where, by how much, or in what shape. The wrapper runs - * the same assertions unchanged and, only if one throws, dumps the two images - * plus a mask and a magnitude view into `test-output/preview-diffs/` and names - * that directory in the failure message (#252, `test/diffArtifacts.ts`). It - * moves no bound and it costs a green run nothing. - * - * The two scrap tests below are deliberately NOT wrapped. Neither compares a - * render against the reference: the footprint test asks a model predicate about - * the game's own pixels, and the map_color test only counts pixels in one - * fixture. There is no "ours" image for the writer to put beside "game". - * - * --- - * - * Kept as a bare literal rather than a named constant on purpose: any longer - * token pushes `}, 300000);` past the formatter's line budget, and oxfmt then - * rewrites all five `it(...)` calls into multi-line argument form and re-indents - * every test body. That is a 146-line diff to change a timeout. - */ - -const FIXTURES = join(import.meta.dirname, "fixtures"); -const SIZE = 1024; -const SEED = 123456; -/** `surfaceSeedForPlanet("vulcanus", 123456)` - see `src/model/planetSurfaceSeed.ts`. */ -const VULCANUS_SURFACE_SEED = 1249936247; -/** - * `surfaceSeedForPlanet("fulgora", 123456)` - the preview takes a MAP seed, - * not a surface seed. Every other Fulgora fixture in this repo comes from a - * harness that FORCES the surface seed to the raw value, so this constant is - * the one place in the suite that has to derive it. Using the raw `123456` - * here scores about 0.5% agreement instead of 99.9% and looks exactly like a - * broken port rather than a wrong constant - confirmed by hand by swapping in - * `123456` and watching the scrap footprint test below fail badly, then - * restoring this value. - */ -const FULGORA_SURFACE_SEED = 2967702466; - -function reference(name: string): { width: number; height: number; rgb: Uint8Array } { - const bytes = new Uint8Array(readFileSync(join(FIXTURES, name))); - return decodePng(bytes, (b) => new Uint8Array(inflateSync(b))); -} - -function render(req: Partial & { seed0: number }): Uint8ClampedArray { - const full: ElevationRenderRequest = { - id: 1, - width: SIZE, - height: SIZE, - originX: -SIZE / 2, - originY: -SIZE / 2, - tilesPerPixel: 1, - waterLevel: 0, - segmentationMultiplier: 1, - startingPositions: [{ x: 0, y: 0 }], - view: "terrain", - ...req, - }; - return new Uint8ClampedArray(runRenderRequest(full).buffer); -} - -const rgbAt = (rgb: Uint8Array, i: number): [number, number, number] => [ - rgb[i * 3], - rgb[i * 3 + 1], - rgb[i * 3 + 2], -]; -const oursAt = (b: Uint8ClampedArray, i: number): [number, number, number] => [ - b[i * 4], - b[i * 4 + 1], - b[i * 4 + 2], -]; -const same = (a: readonly number[], b: readonly number[]): boolean => - a[0] === b[0] && a[1] === b[1] && a[2] === b[2]; - -describe("preview agreement with the game", () => { - it("Nauvis terrain is pixel-identical except where the game drew enemy bases", () => { - const game = reference("oracle-preview-nauvis-terrain.seed123456.png"); - expect([game.width, game.height]).toEqual([SIZE, SIZE]); - const ours = render({ seed0: SEED }); - - // `enemy-base` is the ONE control the game reports as `can_be_disabled: false` - // (autoplace-can-be-disabled.dump.json), so it is present in the reference - // while our terrain view does not draw it. Those pixels are excluded rather - // than tolerated, and counted so the exclusion cannot quietly grow. - const ENEMY = [255, 25, 25]; - // ONE definition, handed to both the counting loop and the artifact writer. - // Written out twice - once here, once as `ignore` - the two copies are free - // to drift, and then the artifacts describe a different comparison than the - // bound that failed. That is exactly the objection `diffArtifacts.ts` raises - // against re-testing the bound, so the mask must not repeat the mistake. - const ignore = (i: number): boolean => same(rgbAt(game.rgb, i), ENEMY); - let enemyPx = 0; - let differing = 0; - for (let i = 0; i < SIZE * SIZE; i++) { - if (ignore(i)) { - enemyPx++; - continue; - } - if (!same(rgbAt(game.rgb, i), oursAt(ours, i))) differing++; - } - const compared = SIZE * SIZE - enemyPx; - - // Measured 2026-08-26: 1189 enemy pixels, and 8 of the remaining 1,047,387 - // disagree - 99.9992%. Bounds are drift guards a little above that; the - // render is deterministic, so any movement here is a real change. - // - // **This comment said 10 until 2026-08-26 and had drifted**, the same way - // four rows of `test/captureGrid.ts`'s table had. Nothing was asserting it - - // the bound below is 200 - so the port moved under it unnoticed. - // `test/wasmNauvisRenderParity.spec.ts` now freezes the 8 exactly. - withDiffArtifacts( - { - spec: "previewAgreement", - case: "nauvis-terrain", - game, - ours: { width: SIZE, height: SIZE, rgba: ours }, - ignore, - }, - () => { - expect(enemyPx).toBeLessThan(3000); - expect(differing).toBeLessThan(200); - expect(differing / compared).toBeLessThan(0.0002); - }, - ); - }, 300000); - - it("Vulcanus terrain agrees once rocks and cliffs are masked", () => { - const game = reference("oracle-preview-vulcanus-terrain.seed123456.png"); - const ours = render({ seed0: VULCANUS_SURFACE_SEED, planet: "vulcanus" }); - - // Vulcanus has NO `rocks` control and no cliff control, so unlike Nauvis - // those two cannot be disabled in the capture - they are in the reference - // whatever we do. Masking them isolates the terrain layer; their coverage is - // asserted separately below, which is where the real finding was. - // - // ONE definition, shared by the loop and the artifact writer - see the note - // on the Nauvis case above. This mask has two clauses rather than one, so a - // third would be that much easier to add here and forget over there. - const ignore = (i: number): boolean => { - const g = rgbAt(game.rgb, i); - return same(g, ROCK_MAP_COLOR) || same(g, CLIFF_MAP_COLOR); - }; - let masked = 0; - let differing = 0; - for (let i = 0; i < SIZE * SIZE; i++) { - if (ignore(i)) { - masked++; - continue; - } - if (!same(rgbAt(game.rgb, i), oursAt(ours, i))) differing++; - } - const rel = differing / (SIZE * SIZE - masked); - // Measured 98.664% over 929,686 compared pixels. Notably worse than Nauvis's - // 99.999% and NOT yet diagnosed - the bound guards against drift, it does not - // bless the gap. - withDiffArtifacts( - { - spec: "previewAgreement", - case: "vulcanus-terrain", - game, - ours: { width: SIZE, height: SIZE, rgba: ours }, - ignore, - }, - () => { - expect(rel).toBeLessThan(0.02); - }, - ); - }, 300000); - - it("Vulcanus rock and cliff coverage stays in the game's neighbourhood", () => { - const game = reference("oracle-preview-vulcanus-terrain.seed123456.png"); - const ours = render({ seed0: VULCANUS_SURFACE_SEED, planet: "vulcanus", view: "all" }); - - const share = ( - pick: (i: number) => readonly [number, number, number], - color: readonly number[], - ): number => { - let n = 0; - for (let i = 0; i < SIZE * SIZE; i++) if (same(pick(i), color)) n++; - return n / (SIZE * SIZE); - }; - const gameRock = share((i) => rgbAt(game.rgb, i), ROCK_MAP_COLOR); - const ourRock = share((i) => oursAt(ours, i), ROCK_MAP_COLOR); - const gameCliff = share((i) => rgbAt(game.rgb, i), CLIFF_MAP_COLOR); - const ourCliff = share((i) => oursAt(ours, i), CLIFF_MAP_COLOR); - - console.log( - `preview coverage: rock game=${(gameRock * 100).toFixed(2)}% ours=${(ourRock * 100).toFixed(2)}% ` + - `(${(ourRock / gameRock).toFixed(2)}x); cliff game=${(gameCliff * 100).toFixed(2)}% ` + - `ours=${(ourCliff * 100).toFixed(2)}% (${(ourCliff / gameCliff).toFixed(2)}x)`, - ); - - // **This is the assertion that would have caught the bug this file was written - // for.** Rocks painted 1x1 gave 0.37% against the game's 5.17% - 0.07x - while - // placement DENSITY was correct to 0.2-7.5% the whole time. No entity oracle - // could see it; only the rendered image can. Measured now: 0.65x. - // - // This one renders `view: "all"` and the reference does NOT contain the - // resources that view draws: `previewCompare.ts` captures Vulcanus with - // `calcite`, `tungsten_ore`, `vulcanus_coal` and `sulfuric_acid_geyser` all - // forced to `size: 0`. Left unmasked, every ore patch and every geyser mark - // we paint reads as `changed`, so `diff-mask.png` comes back speckled with - // ore the game never drew and the reported `changedPixels` says nothing - // about the rock or cliff ratio that actually failed. That is worth getting - // right here specifically: cliffs sit at a known-bad 2.28x against a 2.5 - // bound, so this is the comparison most likely to trip. - // - // Matched on OUR pixel rather than the game's, and only where the game - // disagrees. `coal`'s map_color is pure black, so a blanket colour match - // would also swallow any genuinely black reference pixel; requiring a - // disagreement means a position where both are black still counts as - // agreeing, which it does. - const resourceOnly = (i: number): boolean => { - const o = oursAt(ours, i); - const g = rgbAt(game.rgb, i); - if (same(o, g)) return false; - return VULCANUS_RESOURCE_CATALOG.some((r) => same(o, r.mapColor)); - }; - withDiffArtifacts( - { - spec: "previewAgreement", - case: "vulcanus-rock-and-cliff-coverage", - game, - ours: { width: SIZE, height: SIZE, rgba: ours }, - ignore: resourceOnly, - }, - () => { - expect(ourRock / gameRock).toBeGreaterThan(0.4); - expect(ourRock / gameRock).toBeLessThan(1.5); - - // Cliffs are KNOWN BAD at 2.28x (issue #18, corroborating the entity-level - // 1.1-1.6x over-placement by an independent route). The bound is pinned just - // above the current value so it cannot get worse unnoticed; tighten it when - // #18 lands rather than leaving this as a permanent blessing. - expect(ourCliff / gameCliff).toBeLessThan(2.5); - }, - ); - }, 300000); - - it("Fulgora terrain is pixel-identical to the game's own preview", () => { - const game = reference("oracle-preview-fulgora-terrain.seed123456.png"); - expect([game.width, game.height]).toEqual([SIZE, SIZE]); - const ours = render({ seed0: FULGORA_SURFACE_SEED, planet: "fulgora", view: "terrain" }); - let differing = 0; - for (let i = 0; i < SIZE * SIZE; i++) { - if (!same(rgbAt(game.rgb, i), oursAt(ours, i))) differing++; - } - // Fulgora has no enemy bases, so unlike the Nauvis case there is nothing to - // exclude. Measured: 34,788 of 1,048,576 pixels differ (3.32%). The history - // is 34,976 -> 34,977 when #273 typed Fulgora's f32 constants (13 fields to - // bit-exact, the image one pixel WORSE) -> 34,788 when #279 narrowed - // `starting_spot_at_angle` per operation, which moved it 189 pixels the - // right way because the cones feed the `mix_*` chain the image is made of. - // `test/wasmFulgoraRenderParity.spec.ts` pins the same number exactly at - // `toBe(34788)` and carries that table; this bound is the loose twin, so if - // the two ever disagree the exact one is right. V1/V2 - // report 99.86% get_tile agreement and 94.5% on the land argmax from - // sampled points, so a whole-image number in the low single-digit percent - // is the expected shape, not a regression. The bound is set just above the - // measured value; do not widen it further without a new measurement. - // - // The first measurement here was 38.7% - a real bug, not this bound: the - // "deep" ocean tile's map colour in renderFulgoraTerrain.ts rounded - // (49*1.15, 31*1.15, 35*1.15) instead of truncating it as the game does, - // landing one green value high on every one of the ~35% of pixels that - // are deep ocean. Fixed there, not here - see the comment on `COLORS.deep` - // in that file for the evidence. The 3.34% left over after that fix is the - // land-argmax residual this comment already expected. - withDiffArtifacts( - { - spec: "previewAgreement", - case: "fulgora-terrain", - game, - ours: { width: SIZE, height: SIZE, rgba: ours }, - }, - () => { - expect(differing / (SIZE * SIZE)).toBeLessThan(0.04); - }, - ); - }, 300000); - - it("every scrap pixel the game drew is inside our model's footprint", () => { - const off = reference("oracle-preview-fulgora-terrain.seed123456.png"); - const on = reference("oracle-preview-fulgora-scrap.seed123456.png"); - const stack = makeFulgoraStack({ seed0: FULGORA_SURFACE_SEED }); - const scrap = makeFulgoraScrap(stack); - - // A SUPERSET assertion, and against the FOOTPRINT PREDICATE rather than a - // rendered overlay - both deliberate. - // - // Superset, never equality, because ResourceEntityPrototype::map_grid - // defaults to true: the game draws solid ore as a 2x2-block checkerboard - // and shows about 0.5 pixels per entity. Requiring equality would bake - // that 2x under-placement into the renderer. - // - // Footprint, not the rolled overlay this renderer actually paints: a roll - // only paints where a random draw succeeds, which is about 40% of the - // positions where the model's probability is nonzero. Diffing the rolled - // pixels against the game's drawn pixels can't reach a useful agreement - // rate for that reason alone - it would be measuring the salt, not the - // model. `probability(x, y) > 0` asks the question this test actually - // means: could scrap have landed here at all, per the model. The - // salt used to decide whether it actually does is arbitrary (any salt is - // as good as any other for that decision), and DENSITY - whether the - // model rolls at roughly the right rate - is gated separately, by - // `test/fulgoraScrapDensity.spec.ts`. This test is purely about location. - let gameScrap = 0; - let outside = 0; - for (let i = 0; i < SIZE * SIZE; i++) { - if (same(rgbAt(off.rgb, i), rgbAt(on.rgb, i))) continue; - gameScrap++; - // originX = originY = -SIZE/2, tilesPerPixel = 1 (see `render` above), - // and renderFulgoraTerrain.ts writes row-major (py*width+px) - confirmed - // by reading that file rather than assumed. - const x = -SIZE / 2 + (i % SIZE); - const y = -SIZE / 2 + Math.floor(i / SIZE); - if (scrap.probability(x, y) <= 0) outside++; - } - // Measured: 1825 game scrap pixels; 1 of them (0.0548%) falls outside the - // model's footprint - 99.95% inside, exactly matching the design spec's - // section 2.5 measurement of 1824/1825. The bound is set just above that. - expect(gameScrap).toBeGreaterThan(1500); - expect(outside / gameScrap).toBeLessThan(0.001); - }, 300000); - - it("we paint scrap in the game's own map_color", () => { - const on = reference("oracle-preview-fulgora-scrap.seed123456.png"); - let pure = 0; - for (let i = 0; i < SIZE * SIZE; i++) { - if (same(rgbAt(on.rgb, i), [229, 229, 229])) pure++; - } - // map_color = {0.9, 0.9, 0.9} x 255. The game's own preview is where this - // triple was confirmed, not the Lua alone. - expect(pure).toBeGreaterThan(1000); - expect(SCRAP_MAP_COLOR).toEqual([229, 229, 229]); - }); -}); diff --git a/test/quickMultioctaveNoise.spec.ts b/test/quickMultioctaveNoise.spec.ts deleted file mode 100644 index 9515ec78..00000000 --- a/test/quickMultioctaveNoise.spec.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import fixture from "./fixtures/oracle-quick-multioctave.seed123456.json"; -import { - makeQuickMultioctaveNoise, - makeQuickMultioctaveNoisePersistence, - quickMultioctaveNoise, -} from "../src/noise/quickMultioctaveNoise"; - -interface QuickCase { - octaves: number; - inputScale: number; - outputScale: number; - oosm: number; - oism: number; - offsetX: number; - seed1: number; - values: number[]; -} - -function paramsFor(seed0: number, c: QuickCase) { - return { - seed0, - seed1: c.seed1, - octaves: c.octaves, - inputScale: c.inputScale, - outputScale: c.outputScale, - octaveOutputScaleMultiplier: c.oosm, - octaveInputScaleMultiplier: c.oism, - offsetX: c.offsetX, - }; -} - -describe("quickMultioctaveNoise reproduces the game", () => { - // Ground truth: test/fixtures/oracle-quick-multioctave.seed123456.json, captured - // via the oracle harness. Regenerate with test/oracle/capture.ts. - // - // Scored by EXACT f32 match count, not by an error bound. Every one of the 190 - // values in the fixture is exactly representable in f32 (asserted below, so the - // scoring cannot quietly stop being valid), which makes "identical" a question - // the fixture can answer - and a bound cannot. - // - // This spec used to assert `worstNear < 5e-5` and `worstFar < 3e-3`, and - // explained the gap as "the game's f32 coordinate pipeline diverges from our - // f64 - the documented f32 floor". There was no floor: the op was evaluating in - // f64 and the game evaluates in f32. It scored 38/190 then and scores 190/190 - // now, so the near/far split those two bounds described no longer exists and - // both are gone. Do not reintroduce a bound here; a miss is a finding. - it("matches quick_multioctave_noise bit-for-bit across octaves / multipliers / offset / seeds", () => { - let exact = 0; - let total = 0; - let worst = 0; - let worstLabel = ""; - for (const c of fixture.cases as QuickCase[]) { - const params = paramsFor(fixture.seed0, c); - for (let i = 0; i < fixture.positions.length; i++) { - const p = fixture.positions[i]; - const got = quickMultioctaveNoise(p.x, p.y, params); - total++; - if (got === c.values[i]) exact++; - const err = Math.abs(got - c.values[i]); - if (err > worst) { - worst = err; - worstLabel = `octaves=${c.octaves} offset=${c.offsetX} seed1=${c.seed1} @(${p.x},${p.y})`; - } - } - } - expect(total).toBe(190); - expect(worst, `worst residual at ${worstLabel}`).toBe(0); - expect(exact).toBe(190); - }); - - // The exact-count assertion above is only meaningful if the fixture really is - // all-f32; against an f64 ground truth a bit-exact f32 port could never reach - // it, and the temptation would be to loosen the score instead of reading it. - it("every fixture value is exactly representable in f32", () => { - for (const c of fixture.cases as QuickCase[]) { - for (const v of c.values) expect(Math.fround(v)).toBe(v); - } - }); - - it("makeQuickMultioctaveNoise (prebuilt tables) agrees with the direct form", () => { - for (const c of fixture.cases as QuickCase[]) { - const params = paramsFor(fixture.seed0, c); - const fn = makeQuickMultioctaveNoise(params); - for (const p of fixture.positions) { - expect(fn(p.x, p.y)).toBe(quickMultioctaveNoise(p.x, p.y, params)); - } - } - }); - - it("makeQuickMultioctaveNoisePersistence agrees with the raw quickMultioctaveNoise transform", () => { - // The elevation tree's starting_lake_noise parameters (seed1: 14, octaves: 5). - // Independent check: rather than comparing against quickMultioctaveNoisePersistence - // (which now just delegates to makeQuickMultioctaveNoisePersistence, making that - // comparison a tautology), compare against the raw quickMultioctaveNoise op fed the - // param transform spelled out explicitly here. - const params = { - seed0: fixture.seed0, - seed1: 14, - octaves: 5, - inputScale: 1 / 8, - outputScale: 1, - octaveInputScaleMultiplier: 0.5, - persistence: 0.75, - }; - const rawParams = { - seed0: fixture.seed0, - seed1: 14, - octaves: 5, - inputScale: (1 / 8) * 0.5 ** (5 - 1), - outputScale: 1 * 2 ** (5 - 1), - octaveOutputScaleMultiplier: 0.75, - octaveInputScaleMultiplier: 1 / 0.5, - offsetX: 0, - }; - const fn = makeQuickMultioctaveNoisePersistence(params); - for (const p of fixture.positions) { - expect(fn(p.x, p.y)).toBe(quickMultioctaveNoise(p.x, p.y, rawParams)); - } - }); -}); diff --git a/test/randomPenalty.spec.ts b/test/randomPenalty.spec.ts deleted file mode 100644 index f7eba1b5..00000000 --- a/test/randomPenalty.spec.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import fixture from "./fixtures/oracle-random-penalty.seed123456.json"; -import { randomPenaltyBatch, randomPenaltyWord } from "../src/noise/randomPenalty"; - -// Ground truth captured from Factorio 2.1.11 (RandomPenalty::run, RE'd from the -// non-stripped binary). See docs/noise/random-penalty-NOTES.md. random_penalty is -// a BATCH op: seeded from positions[0], streamed last->first, source<=0 skips a -// draw. Each fixture case is one ordered batch. - -/** Reconstruct source[i] from the fixture's sourceKind (kept out of the fixture). */ -function sourceValues(kind: string, positions: readonly { x: number; y: number }[]): number[] { - switch (kind) { - case "const1": - return positions.map(() => 1); - case "x": - return positions.map((p) => p.x); - default: - throw new Error(`unknown sourceKind ${kind}`); - } -} - -describe("randomPenalty", () => { - it("reproduces the game's own values across seeds, amplitudes and the source<=0 guard", () => { - // Compared with NO `Math.fround` on `got`. This assertion used to wrap the - // result in one, which scored 40/40 while the op itself returned f64 - it was - // the comparison recovering the value, not the op producing it. Raw, that - // same tree scores 4/40 at worst 1.668e-5. All 40 fixture values are exactly - // f32-representable, so an exact count is legal and a bound would be blind - // between "close" and "identical" (#256). - let exact = 0; - let total = 0; - let worst = 0; - for (const c of fixture.cases) { - const source = sourceValues(c.sourceKind, fixture.positions); - const got = randomPenaltyBatch(fixture.positions, source, { - seed: c.rpSeed, - amplitude: c.amplitude, - }); - for (let i = 0; i < got.length; i++) { - total++; - if (got[i] === c.values[i]) exact++; - worst = Math.max(worst, Math.abs(got[i] - c.values[i])); - } - } - expect(total).toBe(40); // anti-vacuity: a fixture regen cannot empty the loop - expect(exact).toBe(40); - expect(worst).toBe(0); - }); - - it("returns f32 values, because the op narrows once at the store", () => { - // `RandomPenalty::run` ends `fcvt s5, d5; str s5` - the value LEAVES the op as - // f32, and `resources/regularPatches.ts` multiplies what it gets back. This is - // the planted-break guard for that narrowing: drop the `f32` in - // randomPenaltyBatch and 36 of these 40 stop being f32. No bound can see it - - // regularPatches.spec.ts grades at ABS_TOL 1.0 / REL_TOL 1e-2 and the change is - // worth 1.19e-7 relative - so it has to be asserted directly. - let checked = 0; - for (const c of fixture.cases) { - const source = sourceValues(c.sourceKind, fixture.positions); - const got = randomPenaltyBatch(fixture.positions, source, { - seed: c.rpSeed, - amplitude: c.amplitude, - }); - for (const [i, v] of got.entries()) { - checked++; - expect(Math.fround(v), `case ${c.sourceKind} seed=${c.rpSeed} index ${String(i)}`).toBe(v); - } - } - expect(checked).toBe(40); - }); - - it("passes source<=0 through unchanged (the 'source must be > 0' guard)", () => { - const positions = [ - { x: 0, y: 0 }, - { x: 5, y: 5 }, - ]; - const out = randomPenaltyBatch(positions, [-2, 3], { seed: 1, amplitude: 1 }); - expect(out[0]).toBe(-2); // untouched, no draw consumed - expect(out[1]).toBeLessThan(3); // penalized - expect(out[1]).toBeGreaterThanOrEqual(2); // amplitude 1 => U in [0,1) - }); - - it("skips no draw for a source<=0 tile: the survivor gets the FIRST draw", () => { - // With the leading tile suppressed (source<=0), the next tile must get draw 0, - // i.e. the same value a lone [that tile] batch (seeded identically) would get. - const positions = [ - { x: 2, y: 3 }, - { x: 2, y: 3 }, - ]; - const both = randomPenaltyBatch(positions, [0, 1], { seed: 1, amplitude: 1 }); - const lone = randomPenaltyBatch([positions[1]], [1], { seed: 1, amplitude: 1 }); - // seed is from positions[0] in both (same coords), and the survivor takes draw 0. - expect(both[1]).toBe(lone[0]); - expect(both[0]).toBe(0); - }); - - it("computes the seed word as max(341, 0x3FBE2C + 7919*trunc(x0) + 7907*trunc(y0+seed))", () => { - // (0,0) seed=1 -> 0x3FBE2C + 7907 = 4201743. - expect(randomPenaltyWord(0, 0, 1)).toBe(0x3fbe2c + 7907); - // seed folds into y before truncation; fractional coords truncate toward zero. - expect(randomPenaltyWord(0.9, 0.9, 0)).toBe(0x3fbe2c); // trunc(0.9)=0 both axes - expect(randomPenaltyWord(-1.5, 0, 0)).toBe((0x3fbe2c + Math.imul(-1, 7919)) >>> 0); - }); - - it("is order/batch dependent: the same tile gets a different U per batch", () => { - const a = { x: 0, y: 0 }; - const b = { x: 5, y: 7 }; - const forward = randomPenaltyBatch([a, b], [1, 1], { seed: 1, amplitude: 1 }); - const reversed = randomPenaltyBatch([b, a], [1, 1], { seed: 1, amplitude: 1 }); - // a's value differs because the seed comes from positions[0] (a vs b). - expect(forward[0]).not.toBe(reversed[1]); - }); - - it("returns an empty array for an empty batch", () => { - expect(randomPenaltyBatch([], [], { seed: 1, amplitude: 1 })).toEqual([]); - }); -}); diff --git a/test/regularPatches.spec.ts b/test/regularPatches.spec.ts deleted file mode 100644 index 6aeca5e4..00000000 --- a/test/regularPatches.spec.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import fixture from "./fixtures/oracle-resource-regular.seed123456.json"; -import { RESOURCE_CATALOG } from "../src/noise/resources/resourceCatalog"; -import { makeRegularPatches } from "../src/noise/resources/regularPatches"; - -// Ground truth: resource_autoplace_all_patches with has_starting_area_placement=0 -// and regular_patch_set_count=1 (pure, unpartitioned regular field), routed onto -// elevation. See docs/superpowers/plans/2026-07-19-milestone3a-regular-patches.md T3/T4. - -const paramsByName = new Map(RESOURCE_CATALOG.map((r) => [r.name, r])); - -/** Relative error against a game value, floored so basement magnitudes don't dominate. */ -function relErr(port: number, game: number): number { - return Math.abs(port - game) / Math.max(1, Math.abs(game)); -} - -// M3a Task 4: the regular resource field, validated point-by-point against the -// game. Two facts about the metric: -// -// * ABSOLUTE error is the honest floor. The field spans ~[-14000, +thousands] -// (deep basement -> patch peaks), and the game's spot_noise machine evaluates -// the whole selection/cone/blob chain in f32 - cube roots via its fastapprox -// `pow` (src/noise/fastApprox.ts). Matching that (fastCbrt + f32 cone render) -// pins the port to the game within ~0.7 units EVERYWHERE. We assert < 1.0. -// * RELATIVE error stays < 1e-3 across the smooth patch interiors, but at cone -// edges / basement zero-crossings (where |field| is a handful of units) the -// ~0.7-unit f32 noise inflates to ~9e-3 relative - the same f32 floor M1 -// (elevation_lakes, 7.4e-3) and the Island map type (6.66e-3) documented. We -// assert < 1e-2, matching that precedent. -// -// Before the f32/fastCbrt work the absolute error was ~3 units and the relative -// worst was 4.8e-2 (exact Math.cbrt); see docs/noise/random-penalty-NOTES.md -// "Composition inside spot selection" and the fastapprox-cbrt residual. -// -// **2026-08-04: `fastApprox`'s log2/exp2 became bit-exact (per-operation f32 -// rounding instead of one rounding at the end), and it moved these numbers - in -// BOTH directions.** Values change on 4093-4105 of the 4105 points in every case, -// so nothing here is untouched. Measured, before -> after: -// -// iron-ore/123456 abs 0.6898 -> 0.8159 rel 4.875e-4 -> 1.276e-4 -// uranium-ore/123456 abs 0.3788 -> 0.4790 rel 7.103e-5 -> 8.302e-5 -// iron-ore/777771 abs 0.6860 -> 0.8096 rel 8.849e-3 -> 1.273e-4 -// uranium-ore/777771 abs 0.6135 -> 0.4755 rel 4.090e-4 -> 7.562e-5 -// -// Relative error improved sharply (iron/777771 by 70x, which is what dominated -// REL_TOL's headroom), but worst-ABSOLUTE regressed on three of the four cases, -// cutting the ABS_TOL margin from 0.31 to 0.18. That looked like the price of a -// change the binary requires, and it was recorded here as such. -// -// **2026-08-05: most of that absolute regression was a SECOND bug, now fixed** -// (issue #163). `fastCbrt` passed a double `1/3` where the game's -// `Math::powSafe(float, float)` takes an f32 exponent, wrong on ~3.0% of cube -// roots. Fixing it moves these numbers again, and this time only downwards: -// -// iron-ore/123456 abs 0.8159 -> 0.6491 rel 1.276e-4 -> 1.182e-4 -// uranium-ore/123456 abs 0.4790 -> 0.4790 rel 8.302e-5 -> 8.302e-5 -// iron-ore/777771 abs 0.8096 -> 0.6493 rel 1.273e-4 -> 1.071e-4 -// uranium-ore/777771 abs 0.4755 -> 0.4724 rel 7.562e-5 -> 7.562e-5 -// -// So against the ORIGINAL pre-2026-08-04 baseline, three of the four cases are -// now better on absolute error as well as relative (iron/123456 0.6898 -> -// 0.6491, iron/777771 0.6860 -> 0.6493, uranium/777771 0.6135 -> 0.4724); only -// uranium/123456 remains above it (0.3788 -> 0.4790). ABS_TOL headroom is back -// to 0.35 from 0.18. -// -// **The lesson is about the metric, not the numbers.** "Relative improves, -// absolute regresses" read as an inherent trade-off of the rounding change. It -// was not - it was two independent bugs in the same file, one fixed and one -// still present, and the mixed signal was the second one showing through. A -// tolerance-based suite cannot tell those apart. -// -// These tolerances CANNOT police any of it, and that is the point worth -// keeping: at 1.0 absolute and 1e-2 relative they cannot resolve a ~1e-5 shift in -// either direction. A green run of this file is not evidence that a numerics -// change was neutral - `pnpm run verify` passed at every step above. The -// f32-exact guards are `test/fastApprox.spec.ts` (the operator itself), -// `test/voronoiNoise.spec.ts` and `test/voronoiSearchRange.spec.ts` - all compare -// with `toBe` after `f32`, no tolerance. Go there to police a numerics change, -// not here. #162 tracks converting more of this suite to that standard. -const ABS_TOL = 1.0; -const REL_TOL = 1e-2; - -describe("makeRegularPatches (regular resource field vs oracle)", () => { - for (const c of fixture.cases) { - it(`matches the game for ${c.resource} seed=${c.seed}`, () => { - const params = paramsByName.get(c.resource)!; - const patches = makeRegularPatches(params, { - seed0: c.seed, - controls: { frequency: 1, size: 1, richness: 1 }, - skipSpan: 1, - skipOffset: 0, - }); - - let worstAbs = 0; - let worstRel = 0; - const mism: { x: number; y: number; game: number; port: number; abs: number; rel: number }[] = - []; - for (let i = 0; i < fixture.positions.length; i++) { - const p = fixture.positions[i]; - const game = c.values[i]; - const port = patches.field(p.x, p.y); - const abs = Math.abs(port - game); - const rel = relErr(port, game); - if (abs > worstAbs) worstAbs = abs; - if (rel > worstRel) worstRel = rel; - mism.push({ x: p.x, y: p.y, game, port, abs, rel }); - } - - if (worstAbs >= ABS_TOL || worstRel >= REL_TOL) { - const top = [...mism] - .sort((a, b) => b.abs - a.abs) - .slice(0, 8) - .map( - (m) => - ` (${m.x},${m.y}) game=${m.game.toFixed(2)} port=${m.port.toFixed(2)} abs=${m.abs.toFixed(3)} rel=${m.rel.toExponential(2)}`, - ) - .join("\n"); - throw new Error( - `${c.resource} seed=${c.seed}: worstAbs=${worstAbs.toFixed(3)} (tol ${ABS_TOL}) ` + - `worstRel=${worstRel.toExponential(3)} (tol ${REL_TOL.toExponential(0)})\n` + - `largest-absolute mismatches:\n${top}`, - ); - } - expect(worstAbs).toBeLessThan(ABS_TOL); - expect(worstRel).toBeLessThan(REL_TOL); - }); - } -}); diff --git a/test/render-cost.perf.spec.ts b/test/render-cost.perf.spec.ts deleted file mode 100644 index f7078936..00000000 --- a/test/render-cost.perf.spec.ts +++ /dev/null @@ -1,552 +0,0 @@ -// Manual render-cost benchmark - NOT a pass/fail gate (timing is machine- -// dependent). Skipped by default so `vp test` stays fast; run it with: -// -// pnpm perf # everything, min of 7 (~19 min) -// FMW_PERF_BLOCK=vulcanus pnpm perf # one block only (~3.7 min) -// FMW_PERF_N=3 FMW_PERF_BLOCK=vulcanus pnpm perf # a quick look (~1.6 min) -// -// which sets FMW_PERF=1, runs this file, and prints the table it writes to -// perf-result.txt (gitignored). -// -// ## How this measures, and why (issue #19) -// -// It used to report a MEDIAN OF 3 taken with every iteration of one view run -// back-to-back before the next view started. That could not resolve the size of -// change it was being used to gate, which is worse than having no benchmark: it -// produced confident numbers that were noise. Measured 2026-07-27: 22.7% spread -// inside a single 5-iteration run, ~10% movement between processes on views the -// change under test could not touch, and one lattice change reported as a -// regression that was actually a small improvement. -// -// Four things fix that, and all four matter: -// -// 1. **Minimum of N, not median.** Timing noise is additive and positive - a -// sample is the true cost plus whatever else the machine was doing - so the -// minimum is the least-biased estimator of the underlying cost. The median -// sits in the middle of the noise distribution and moves with machine load. -// 2. **Interleaved.** Every arm is timed once per round, round-robin, so a -// drift in machine load hits every arm alike instead of landing entirely on -// whichever view happened to be running. Arm-at-a-time is what let unrelated -// views move 10%. -// 3. **Spread printed** (max/min) beside every figure, so a reader can see when -// a measurement is too noisy to support the conclusion drawn from it. -// 4. **Within-process comparisons, not absolutes.** Absolute ms from different -// processes should not be diffed, and the old output invited exactly that - -// so the derived figures are printed explicitly and the file says so in a -// header. -// -// ## Which derived figure to trust (measured 2026-07-28, two back-to-back runs) -// -// Issue #19 proposed `all / terrain` as the stable statistic. Measuring it says -// that is only half right, so read this before quoting a ratio: -// -// | figure | run 1 | run 2 | drift | -// | ------------------ | ----- | ----- | ----- | -// | terrain (absolute) | 3402 | 3566 | +4.8% | -// | all (absolute) | 8163 | 8225 | +0.8% | -// | ratio all/terrain | 2.399 | 2.307 | -3.8% | -// | resources marginal | 1753 | 1710 | -2.5% | -// | rocks marginal | 1079 | 1079 | 0.0% | -// | cliffs marginal | 1894 | 1854 | -2.1% | -// -// **The MARGINALS are the most repeatable figure here, not the ratio.** The -// ratio divides two absolutes that drift independently - terrain moved +4.8% -// while `all` moved +0.8% - so it amplifies their disagreement rather than -// cancelling it. It is still worth printing, because it is the form the -// "under 2x terrain" gate is written in, but a ~4% move in it across runs is -// noise and not a regression. The marginals hold to ~2.5%. -// -// Corollary for anyone comparing against a figure recorded in the notes: the -// per-run baseline moves several percent, so a few percent of difference in an -// ABSOLUTE is not evidence of anything. A double-digit move in a MARGINAL is. -// -// ## What the cliff tile-collision rejection cost (measured 2026-07-30, #18) -// -// The rejection resolves tiles under each PLACED cell's collision box, so it is -// the first thing to make the cliff overlay depend on the tile resolver. Two -// arms of `FMW_PERF_BLOCK=vulcanus FMW_PERF_N=5`, same machine, back to back, -// with and without `tileCollides`: -// -// | figure | without | with | delta | -// | ------------------------- | ------- | ----- | ------ | -// | terrain (the control arm) | 3948 | 3947 | -0.0% | -// | cliffs marginal | 1500 | 1917 | **+28%** | -// | ratio all/terrain, whole | 1.944 | 2.040 | +4.9% | -// | ratio all/terrain, TILED | 2.455 | 2.569 | +4.6% | -// -// **Terrain being identical to 1 ms across the two arms is what makes this -// readable at all** - per the table above a 4.8% baseline drift would otherwise -// swamp a change this size. Read the marginal (+28%), not the ratio. -// -// **It puts the whole-image "under 2x terrain" gate back over the line: 1.944 -// -> 2.040.** Recorded, not buried, as the same gate was when the Vulcanus V3 -// overlays crossed it. The tiled figure - the geometry the app actually renders -// - was already over at 2.455 and is now 2.569. -// -// Whether that is worth paying is a correctness-vs-cost call, not a perf bug: -// the rejection is what the game does, and without it region `[1500,1500]` -// over-places by 20%. If it ever needs to come back down, the cheap lever is a -// "could lava possibly win here" pre-gate gating the full 19-tile argmax, which -// was scoped and deliberately not taken for this change. -import { appendFileSync, writeFileSync } from "node:fs"; -import { it } from "vite-plus/test"; -import { runRenderRequest } from "../src/noise/preview/elevationRenderRequest"; -import { renderTerrain } from "../src/noise/preview/renderTerrain"; -import { renderResources } from "../src/noise/preview/renderResources"; -import { renderEnemies } from "../src/noise/preview/renderEnemies"; -import { renderCliffs } from "../src/noise/preview/renderCliffs"; -import { renderTrees } from "../src/noise/preview/renderTrees"; -import { surveyIslands, surveyStep } from "../src/noise/islands/cellSurvey"; -import { COARSE_TILES_PER_PIXEL } from "../src/noise/islands/findIslands"; -import { makeFulgoraStack } from "../src/noise/tiles/fulgoraCatalog"; - -const OUT = "perf-result.txt"; -const SEED = 123456; - -/** Iterations per arm for the two render blocks. Minimum of this many. */ -const ITERS = Number(process.env.FMW_PERF_N ?? 7); -/** - * Iterations for the tile-overhead block, which defaults to 1 because each of - * its passes is already 64 renders. Its meaningful output is the whole/tiled - * RATIO, measured back-to-back in one process, which is exactly the kind of - * within-process comparison that survives run-to-run drift. - */ -const TILE_ITERS = Number(process.env.FMW_PERF_TILE_N ?? 1); -/** Which blocks to run. Default: all of them. */ -const BLOCKS = (process.env.FMW_PERF_BLOCK ?? "nauvis,vulcanus,tiles,islands") - .split(",") - .map((s) => s.trim()); - -// In the default suite FMW_PERF is unset -> every block becomes it.skip (instant). -const blockIt = (name: string): typeof it | typeof it.skip => - process.env.FMW_PERF && BLOCKS.includes(name) ? it : it.skip; - -const N = 1024; -const HALF = N / 2; -const base = { - id: 0, - seed0: SEED, - width: N, - height: N, - originX: -HALF, - originY: -HALF, - tilesPerPixel: 1, - waterLevel: 0, - segmentationMultiplier: 1, - startingPositions: [{ x: 0, y: 0 }], - mapType: "nauvis" as const, -}; - -interface Arm { - label: string; - fn: () => void; - samples: number[]; -} - -/** - * Register arms, then run them INTERLEAVED. Registration is separate from - * execution precisely so that no arm can be run to completion before another - * starts - that ordering is the whole point (see note 2 above). - */ -function bench(): { - add: (label: string, fn: () => void) => Arm; - run: (iters: number) => void; -} { - const arms: Arm[] = []; - return { - add(label, fn) { - const a: Arm = { label, fn, samples: [] }; - arms.push(a); - return a; - }, - run(iters) { - for (const a of arms) a.fn(); // warm-up every arm once (JIT), untimed - for (let i = 0; i < iters; i++) - for (const a of arms) { - const t0 = performance.now(); - a.fn(); - a.samples.push(performance.now() - t0); - } - }, - }; -} - -const minOf = (a: Arm): number => Math.min(...a.samples); -/** max/min - 1.00x is a perfectly stable measurement, 1.23x is the old noise. */ -const spreadOf = (a: Arm): number => Math.max(...a.samples) / Math.min(...a.samples); -const row = (a: Arm): string => - `${a.label.padEnd(42)} ${minOf(a).toFixed(0).padStart(6)} ms (spread ${spreadOf(a).toFixed(2)}x)`; - -let started = false; -const emit = (text: string): void => { - if (started) appendFileSync(OUT, text); - else { - writeFileSync( - OUT, - "Figures are MINIMA over N interleaved iterations, with (spread) = max/min.\n" + - "Do NOT diff absolute ms across runs - the per-run baseline moves several\n" + - "percent (terrain moved 4.8% between two runs on 2026-07-28). Compare the\n" + - "MARGINALS, which held to ~2.5%; the ratio divides two independently\n" + - "drifting absolutes and moved 3.8% over the same pair, so a few percent in\n" + - "it is noise. See the header comment in test/render-cost.perf.spec.ts and\n" + - "issue #19. Knobs: FMW_PERF_N, FMW_PERF_TILE_N,\n" + - "FMW_PERF_BLOCK=nauvis,vulcanus,tiles,islands\n" + - text, - ); - started = true; - } -}; - -blockIt("nauvis")( - "render cost by layer @ 1024x1024 / 1 tile-per-pixel", - () => { - const b = bench(); - const elev = b.add("elevation only", () => runRenderRequest({ ...base, view: "elevation" })); - const terrain = b.add("terrain (elev+climate+tiles)", () => - runRenderRequest({ ...base, view: "terrain" }), - ); - const resources = b.add("terrain + resources", () => - runRenderRequest({ ...base, view: "resources" }), - ); - const enemies = b.add("terrain + enemies", () => - runRenderRequest({ ...base, view: "enemies" }), - ); - const cliffs = b.add("terrain + cliffs", () => runRenderRequest({ ...base, view: "cliffs" })); - const trees = b.add("terrain + trees", () => runRenderRequest({ ...base, view: "trees" })); - // Vulcanus terrain-only, V2 gate (docs/noise/vulcanus-resources-NOTES.md): V2 - // restored three resource-coupling terms into the tile catalog - // (vulcanusCatalog.ts), so terrain now evaluates the ore region fields even - // when the resource overlay itself is off. Compared against the V1 baseline - // (~12 us/px, recorded in client-preview-ROADMAP.md) to catch a regression. - // Stays at 1024x1024 because that is the size the recorded baseline used. - const vulcanusTerrain = b.add("vulcanus terrain (V1 tiles + V2 coupling)", () => - runRenderRequest({ ...base, planet: "vulcanus", view: "terrain" }), - ); - // Vulcanus resources, V2 gate: the view non-dev users actually get for - // Vulcanus (ElevationPreviewPanel.vue's `effectiveView` defaults Vulcanus to - // "resources"), and `renderVulcanusResources` builds a SECOND, independent - // Vulcanus field stack on top of the terrain render's own - so this is - // slower than "vulcanus terrain" above, not a bug. - const vulcanusResources = b.add("vulcanus resources (default Vulcanus view)", () => - runRenderRequest({ ...base, planet: "vulcanus", view: "resources" }), - ); - // Fulgora (#27). Terrain is the ONLY view it has - no overlay has a - // Fulgora port - so this single row is the whole planet's cost, unlike - // Vulcanus where the default view is the pricier `resources`. Every pixel - // runs the full elevation chain (~31 basis_noise octaves) and the ocean - // argmax; there IS an ocean early-out (`bestOcean > 0`), so a LAND pixel - // pays a lot more than an ocean one - it goes on to run the road/structure - // layer's two more Voronoi tilings (four total, on top of the two the - // ocean-side cells layer already runs for every pixel) and three more - // multioctave fields (`fulgoraRoads.ts`'s `structureSubnoise`, - // `fulgoraRuins.ts`'s `ruinsWalls`/`ruinsPaving`) that an ocean pixel never - // reaches. The cost model is strongly bimodal, not uniform: see the - // land-only figure in `docs/noise/fulgora-elevation-NOTES.md`'s Task 12. - const fulgoraTerrain = b.add("fulgora terrain (the only Fulgora view)", () => - runRenderRequest({ ...base, planet: "fulgora", view: "terrain" }), - ); - - const terrainCtx = { - seed0: SEED, - width: N, - height: N, - originX: base.originX, - originY: base.originY, - tilesPerPixel: 1, - ctx: { segmentationMultiplier: 1, startingPositions: base.startingPositions }, - }; - const oc = { - seed0: SEED, - originX: base.originX, - originY: base.originY, - tilesPerPixel: 1, - segmentationMultiplier: 1, - waterLevel: 0, - startingPositions: base.startingPositions, - }; - // Hand-assembled rather than `view: "all"` so the per-overlay ctx is explicit. - // It must therefore mirror runRenderRequest's composite ORDER and membership - - // trees first, then resources/enemies/cliffs. Omitting an overlay here makes - // the headline row silently measure a composite the app no longer renders. - const all = b.add("ALL (terrain + 4 overlays)", () => { - const img = renderTerrain(terrainCtx); - renderTrees(img, { ...oc, treesFrequency: 1, treesSize: 1 }); - renderResources(img, { ...oc, controls: {} }); - renderEnemies(img, { - seed0: SEED, - originX: base.originX, - originY: base.originY, - tilesPerPixel: 1, - controls: { frequency: 1, size: 1 }, - startingPositions: base.startingPositions, - }); - renderCliffs(img, { - ...oc, - controls: { frequency: 1, continuity: 1 }, - settings: { cliffElevation0: 10, cliffElevationInterval: 40, richness: 1 }, - }); - }); - - b.run(ITERS); - - const header = `nauvis @ ${N}x${N}, tpp 1, seed ${SEED}, origin (${base.originX},${base.originY}), min of ${ITERS}`; - const pxCount = N * N; - const usPx = (a: Arm): string => ((minOf(a) * 1000) / pxCount).toFixed(2); - emit( - [ - "", - header, - "-".repeat(header.length), - ...[ - elev, - terrain, - resources, - enemies, - cliffs, - trees, - vulcanusTerrain, - vulcanusResources, - fulgoraTerrain, - all, - ].map(row), - "", - `ratio ALL/terrain ${(minOf(all) / minOf(terrain)).toFixed(3).padStart(6)} (see header: marginals are steadier than this)`, - `climate+tiles portion of terrain: ~${(minOf(terrain) - minOf(elev)).toFixed(0)} ms (the tiling target)`, - `all 4 overlays add over terrain: ~${(minOf(all) - minOf(terrain)).toFixed(0)} ms`, - `nauvis terrain: ~${usPx(terrain)} us/px`, - `vulcanus terrain: ~${usPx(vulcanusTerrain)} us/px (terrain-only, NOT the default Vulcanus view)`, - `vulcanus resources: ~${usPx(vulcanusResources)} us/px (the default Vulcanus view - double field-stack cost)`, - `fulgora terrain: ~${usPx(fulgoraTerrain)} us/px (the only Fulgora view)`, - "", - ].join("\n"), - ); - }, - 3_600_000, -); - -// The block that actually gates Vulcanus decisions. Deliberately 512x512 at -// origin (0,0) rather than the 1024x1024 origin-centred window above, because -// that is the geometry the recorded hand-measured figures use -// (vulcanus-cliffs-NOTES.md, "Re-measured after the placement roll"): terrain -// 3394 / resources 5406 / rocks 4756 / cliffs 5526 / all 8458, ratio 2.492. -// Those were min-of-7 interleaved runs done BY HAND precisely because -// `pnpm perf` could not settle them - so reproducing them here is what retires -// the hand-rolled loop. -const V = 512; -const vBase = { ...base, width: V, height: V, originX: 0, originY: 0, planet: "vulcanus" as const }; - -blockIt("vulcanus")( - "vulcanus render cost by overlay @ 512x512 / 1 tile-per-pixel", - () => { - const b = bench(); - const terrain = b.add("terrain", () => runRenderRequest({ ...vBase, view: "terrain" })); - const resources = b.add("resources", () => runRenderRequest({ ...vBase, view: "resources" })); - const rocks = b.add("rocks", () => runRenderRequest({ ...vBase, view: "rocks" })); - const cliffs = b.add("cliffs", () => runRenderRequest({ ...vBase, view: "cliffs" })); - const all = b.add("all", () => runRenderRequest({ ...vBase, view: "all" })); - - // The SAME area as 16 x 128x128 tiles, which is the geometry the app - // actually renders (a 64-worker pool at 128x128, `render-tiling-shipped`). - // The gate has always been quoted on the whole-image arm, and for a long - // time those two disagreed badly - the tiled `all` ran 17% dearer, which - // traced to the cliff cell bounds rounding out to a spare chunk per axis - // per call (vulcanus-cliffs-NOTES.md, "ROOT CAUSE of the tiling penalty"). - // With that fixed they agree to ~1%, but the tiled arm stays because it is - // the one the user experiences and nothing else would have caught this. - const VT = 128; - const vFull = { originX: 0, originY: 0, width: V, height: V }; - const tiled = (view: "terrain" | "all"): (() => void) => { - return () => { - for (let dy = 0; dy < V; dy += VT) - for (let dx = 0; dx < V; dx += VT) - runRenderRequest({ - ...vBase, - view, - width: VT, - height: VT, - originX: dx, - originY: dy, - fullImage: vFull, - }); - }; - }; - const terrainTiled = b.add(`terrain tiled (16 x ${VT})`, tiled("terrain")); - const allTiled = b.add(`all tiled (16 x ${VT})`, tiled("all")); - - b.run(ITERS); - - const header = `vulcanus @ ${V}x${V}, tpp 1, seed ${SEED}, origin (0,0), min of ${ITERS}`; - // Marginal cost of one overlay over terrain. These do NOT sum to the `all` - // marginal: measured one at a time they each pay for their own field-cache - // warm-up, while on the `all` path they share one - so the whole is cheaper - // than the sum of its parts. Read them as proportions, not a decomposition. - const marginal = (a: Arm): string => - `${(minOf(a) - minOf(terrain)).toFixed(0).padStart(6)} ms over terrain`; - emit( - [ - "", - header, - "-".repeat(header.length), - ...[terrain, resources, rocks, cliffs, all, terrainTiled, allTiled].map(row), - "", - `resources marginal ${marginal(resources)}`, - `rocks marginal ${marginal(rocks)}`, - `cliffs marginal ${marginal(cliffs)}`, - `ratio all/terrain, whole ${(minOf(all) / minOf(terrain)).toFixed(3).padStart(6)} <- the "under 2x terrain" gate`, - `ratio all/terrain, TILED ${(minOf(allTiled) / minOf(terrainTiled)).toFixed(3).padStart(6)} <- the same gate at the geometry the app renders`, - `tiling penalty on all ${(minOf(allTiled) / minOf(all)).toFixed(3).padStart(6)} <- was 1.17 before the cliff-bounds fix`, - "", - ].join("\n"), - ); - }, - 3_600_000, -); - -// Phase-A gate for the region-tiling plan: how much does rebuilding every -// resolver per tile cost? Renders the same 1024x1024 area as 64 128x128 tiles -// and compares against the single whole-image render. A ratio near 1.0 means -// per-tile setup is noise; a high ratio means the resolver stack has to be -// hoisted out of the per-tile path. "elevation" is included because it is the -// view every non-Nauvis preset uses, and its per-render setup (compiled octave -// closures, starting-lake computation) is the most likely to dominate a small -// total. -// -// The tiled arm passes `fullImage`, because `renderPool.ts` always does. Without -// it `haloQueryBox` returns the bare tile box and every seam halo silently -// vanishes, so the arm measures a tiling the app never performs. This was -// missing until 2026-07-28 and every tiled figure recorded before that date was -// taken without it. It turned out not to move the Vulcanus conclusion - the -// cliff halo costs exactly zero extra field evaluations, see -// vulcanus-cliffs-NOTES.md - but "the tiled number is the real one" is the whole -// reason this block exists, so it should actually be the real one. -blockIt("tiles")( - "tile overhead: 64 x 128x128 vs one 1024x1024", - () => { - const TILE = 128; - const b = bench(); - const fullImage = { originX: base.originX, originY: base.originY, width: N, height: N }; - const pairs = (["elevation", "terrain", "all"] as const).map((view) => ({ - view, - whole: b.add(`whole ${view}`, () => runRenderRequest({ ...base, view })), - tiled: b.add(`tiled ${view} (64 x ${TILE})`, () => { - for (let dy = 0; dy < N; dy += TILE) - for (let dx = 0; dx < N; dx += TILE) - runRenderRequest({ - ...base, - view, - width: TILE, - height: TILE, - originX: -HALF + dx, - originY: -HALF + dy, - fullImage, - }); - }), - })); - - b.run(TILE_ITERS); - - const header = `tile overhead (64 x ${TILE} tiles vs one whole render), min of ${TILE_ITERS}`; - emit( - [ - "", - header, - "-".repeat(header.length), - ...pairs.flatMap(({ view, whole, tiled }) => [ - row(whole), - row(tiled), - `${`ratio ${view}`.padEnd(42)} ${(minOf(tiled) / minOf(whole)).toFixed(3).padStart(6)}`, - ]), - "", - ].join("\n"), - ); - }, - 3_600_000, -); - -// The Fulgora island finder (#27), which pins the two costs -// `docs/superpowers/specs/2026-08-15-fulgora-island-finder-design.md` staked -// its whole design on: the survey pass is supposed to be free, and each -// candidate's coarse measurement is supposed to be the real cost. Two arms: -// -// - `surveyIslands` (Stage 1, cellSurvey.ts) scanning a 4,000-tile box at the -// derived `grid / 8` step, evaluating only the `cells` field. -// - One Stage-2-shaped coarse render (findIslands.ts's `measure`): a -// 256x256-tile window at `COARSE_TILES_PER_PIXEL` (8) tiles/px, so 32x32 -// pixels, `view: "terrain"` - NEVER "all". This module's header explains -// why: "all" adds the scrap overlay, whose placement roll iterates TILES -// rather than pixels, measured at 112x for a coarse window in a real -// browser Worker (spec section 2b). Origin (0,0) at this seed is a -// 100%-land window (the same one the design spec's own Node benchmark -// used in section 2), so this times real terrain work rather than an -// ocean early-out. -// -// The design spec measured one `cells` evaluation at 2.33 us in a throwaway -// benchmark; the survey arm here reproduces that as a per-sample figure from -// a real, gated test rather than a one-off script. -const ISLAND_SEED = 2967702466; // Fulgora's surface seed for map seed 123456 - the seed cellSurvey.spec.ts, findIslands.spec.ts and the design spec's own benchmark all use. -const ISLAND_CTX = { seed0: ISLAND_SEED }; -const ISLAND_BOX = { x0: -2000, y0: -2000, x1: 2000, y1: 2000 }; // a 4,000-tile box -const islandGrid = makeFulgoraStack(ISLAND_CTX).shared.grid; -const islandStep = surveyStep(islandGrid); -/** - * The number of (x, y) grid points `surveyIslands` visits over `box` at - * `step`. Mirrors that function's own nested loop bounds exactly - cellSurvey.ts - * has no separate sample-count export - so this is the true denominator for - * "us per sample", not an estimate of it. - */ -function sampleCountOf(box: typeof ISLAND_BOX, step: number): number { - let nx = 0; - for (let x = box.x0; x <= box.x1; x += step) nx++; - let ny = 0; - for (let y = box.y0; y <= box.y1; y += step) ny++; - return nx * ny; -} -const ISLAND_SAMPLE_COUNT = sampleCountOf(ISLAND_BOX, islandStep); -const islandCoarseBase = { - id: 0, - seed0: ISLAND_SEED, - planet: "fulgora" as const, - view: "terrain" as const, - width: 32, - height: 32, - originX: 0, - originY: 0, - tilesPerPixel: COARSE_TILES_PER_PIXEL, - waterLevel: 0, - segmentationMultiplier: 1, - startingPositions: [{ x: 0, y: 0 }], -}; - -blockIt("islands")( - "fulgora island finder: survey cost + one coarse measure render", - () => { - const b = bench(); - let candidateCount = 0; - const survey = b.add(`surveyIslands (4,000-tile box, step ~${islandStep.toFixed(2)})`, () => { - candidateCount = surveyIslands(ISLAND_CTX, ISLAND_BOX).length; - }); - const coarse = b.add( - `coarse measure render (256x256 tiles @ ${COARSE_TILES_PER_PIXEL} tpp, terrain)`, - () => runRenderRequest(islandCoarseBase), - ); - - b.run(ITERS); - - const header = `fulgora island finder, seed ${ISLAND_SEED}, min of ${ITERS}`; - const usPerSample = (minOf(survey) * 1000) / ISLAND_SAMPLE_COUNT; - emit( - [ - "", - header, - "-".repeat(header.length), - row(survey), - row(coarse), - "", - `survey cost: ~${usPerSample.toFixed(2)} us/sample (${ISLAND_SAMPLE_COUNT} samples scanned, ${candidateCount} candidates found)`, - `coarse measure: ~${minOf(coarse).toFixed(1)} ms/candidate (one Stage-2 render, terrain view)`, - "", - ].join("\n"), - ); - }, - 3_600_000, -); diff --git a/test/renderCliffs.spec.ts b/test/renderCliffs.spec.ts index 62b820e7..64f11b2a 100644 --- a/test/renderCliffs.spec.ts +++ b/test/renderCliffs.spec.ts @@ -1,134 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { renderCliffs, paintMark } from "../src/noise/preview/renderCliffs"; -import { WATER_TILE_COLORS } from "../src/noise/preview/renderResources"; import { CLIFF_MAP_COLOR } from "../src/noise/cliffs/cliffCatalog"; -const land = (w: number, h: number): ImageData => - ({ width: w, height: h, data: new Uint8ClampedArray(w * h * 4).fill(90) }) as ImageData; -const settings = { cliffElevation0: 10, cliffElevationInterval: 40, richness: 1 }; - describe("renderCliffs", () => { - it("paints cliff cells the cliff color", () => { - // Render a 64x64 window known to contain cliffs (dense sub-window of the Task 8 - // fixture region [512,1024)^2, which has ~282 cliffs at seed 123456; this window - // has 39) at tpp 1. - const img = land(64, 64); - renderCliffs(img, { - seed0: 123456, - originX: 960, - originY: 512, - tilesPerPixel: 1, - controls: { frequency: 1, continuity: 1 }, - settings, - }); - // At least one pixel became the cliff color. - let painted = 0; - for (let i = 0; i < img.data.length; i += 4) - if ( - img.data[i] === CLIFF_MAP_COLOR[0] && - img.data[i + 1] === CLIFF_MAP_COLOR[1] && - img.data[i + 2] === CLIFF_MAP_COLOR[2] - ) - painted++; - expect(painted).toBeGreaterThan(0); - }); - it("continuity 0 paints nothing", () => { - const img = land(64, 64); - renderCliffs(img, { - seed0: 123456, - originX: 512, - originY: 512, - tilesPerPixel: 1, - controls: { frequency: 1, continuity: 0 }, - settings, - }); - for (let i = 0; i < img.data.length; i += 4) expect(img.data[i]).toBe(90); - }); - it("never paints water", () => { - const [wr, wg, wb] = WATER_TILE_COLORS[0]; - const img = { width: 64, height: 64, data: new Uint8ClampedArray(64 * 64 * 4) } as ImageData; - for (let i = 0; i < img.data.length; i += 4) { - img.data[i] = wr; - img.data[i + 1] = wg; - img.data[i + 2] = wb; - img.data[i + 3] = 255; - } - renderCliffs(img, { - seed0: 123456, - originX: 960, - originY: 512, - tilesPerPixel: 1, - controls: { frequency: 1, continuity: 1 }, - settings, - }); - for (let i = 0; i < img.data.length; i += 4) - expect([img.data[i], img.data[i + 1], img.data[i + 2]]).toEqual([wr, wg, wb]); - }); - it("thickens each cell to a block so cliff lines read at preview scale", () => { - // Cells sit on a 4-tile grid, so at tpp 1 painted cells are >= 4px apart; if - // each cell painted a single pixel, no two cliff pixels could be orthogonally - // adjacent. A thicker per-cell mark makes some cliff pixel have a cliff-colored - // right or down neighbor. - const img = land(64, 64); - renderCliffs(img, { - seed0: 123456, - originX: 960, - originY: 512, - tilesPerPixel: 1, - controls: { frequency: 1, continuity: 1 }, - settings, - }); - const isCliff = (px: number, py: number): boolean => { - const o = (py * 64 + px) * 4; - return ( - img.data[o] === CLIFF_MAP_COLOR[0] && - img.data[o + 1] === CLIFF_MAP_COLOR[1] && - img.data[o + 2] === CLIFF_MAP_COLOR[2] - ); - }; - let adjacentPair = false; - for (let py = 0; py < 64 && !adjacentPair; py++) - for (let px = 0; px < 64 && !adjacentPair; px++) - if ( - isCliff(px, py) && - ((px < 63 && isCliff(px + 1, py)) || (py < 63 && isCliff(px, py + 1))) - ) - adjacentPair = true; - expect(adjacentPair).toBe(true); - }); it("map color drift guard", () => expect([...CLIFF_MAP_COLOR]).toEqual([144, 119, 87])); }); - -describe("paintMark", () => { - const blank = (w: number, h: number): ImageData => - ({ width: w, height: h, data: new Uint8ClampedArray(w * h * 4) }) as ImageData; - - it("paints a (2r+1) square centred on the pixel", () => { - const img = blank(7, 7); - paintMark(img, 3, 3, [10, 20, 30], 1); - let painted = 0; - for (let i = 0; i < img.data.length; i += 4) if (img.data[i + 3] === 255) painted++; - expect(painted).toBe(9); - const o = (3 * 7 + 3) * 4; - expect([img.data[o], img.data[o + 1], img.data[o + 2]]).toEqual([10, 20, 30]); - }); - - it("clips at the image edge instead of wrapping", () => { - const img = blank(7, 7); - paintMark(img, 0, 0, [10, 20, 30], 1); - let painted = 0; - for (let i = 0; i < img.data.length; i += 4) if (img.data[i + 3] === 255) painted++; - expect(painted).toBe(4); // the in-image quadrant of a 3x3 - const bottomRightWrapped = (6 + 6 * 7) * 4; // position that would wrap if clipping weren't enforced - expect(img.data[bottomRightWrapped + 3]).toBe(0); - }); - - it("honours skipPixel per painted pixel", () => { - const img = blank(3, 3); - const o = (1 * 3 + 1) * 4; - img.data[o] = 99; - paintMark(img, 1, 1, [10, 20, 30], 1, (r) => r === 99); - expect(img.data[o]).toBe(99); // skipped - expect(img.data[0]).toBe(10); // neighbour painted - }); -}); diff --git a/test/renderElevation.spec.ts b/test/renderElevation.spec.ts deleted file mode 100644 index b443e0e2..00000000 --- a/test/renderElevation.spec.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { renderElevation, WATER_RGBA, LAND_RGBA } from "../src/noise/preview/renderElevation"; -import { makeElevationLakes } from "../src/noise/expressions/elevationLakes"; -import { makeElevationNauvis } from "../src/noise/expressions/elevationNauvis"; -import { makeElevationIsland } from "../src/noise/expressions/elevationIsland"; - -describe("renderElevation", () => { - it("produces an ImageData of the requested size", () => { - const img = renderElevation({ seed0: 123456, width: 8, height: 6 }); - expect(img.width).toBe(8); - expect(img.height).toBe(6); - expect(img.data.length).toBe(8 * 6 * 4); - }); - - it("colors each pixel by the sign of elevation at its world tile", () => { - const width = 4; - const height = 4; - const originX = -1; - const originY = 2; - const tilesPerPixel = 3; - const img = renderElevation({ seed0: 123456, width, height, originX, originY, tilesPerPixel }); - const evalAt = makeElevationLakes({ seed0: 123456 }); - for (let py = 0; py < height; py++) { - for (let px = 0; px < width; px++) { - const wx = originX + px * tilesPerPixel; - const wy = originY + py * tilesPerPixel; - const expected = evalAt(wx, wy) < 0 ? WATER_RGBA : LAND_RGBA; - const o = (py * width + px) * 4; - expect([img.data[o], img.data[o + 1], img.data[o + 2], img.data[o + 3]]).toEqual(expected); - } - } - }); -}); - -describe("renderElevation map-type dispatch", () => { - const width = 4; - const height = 4; - const originX = -1; - const originY = 2; - const tilesPerPixel = 3; - - it("uses the nauvis factory when mapType is 'nauvis'", () => { - const img = renderElevation({ - seed0: 123456, - width, - height, - originX, - originY, - tilesPerPixel, - mapType: "nauvis", - }); - const evalAt = makeElevationNauvis({ seed0: 123456 }); - for (let py = 0; py < height; py++) { - for (let px = 0; px < width; px++) { - const wx = originX + px * tilesPerPixel; - const wy = originY + py * tilesPerPixel; - const expected = evalAt(wx, wy) < 0 ? WATER_RGBA : LAND_RGBA; - const o = (py * width + px) * 4; - expect([img.data[o], img.data[o + 1], img.data[o + 2], img.data[o + 3]]).toEqual(expected); - } - } - }); - - it("defaults to the lakes factory when mapType is omitted", () => { - const a = renderElevation({ seed0: 123456, width, height, originX, originY, tilesPerPixel }); - const b = renderElevation({ - seed0: 123456, - width, - height, - originX, - originY, - tilesPerPixel, - mapType: "lakes", - }); - expect(Array.from(a.data)).toEqual(Array.from(b.data)); - }); - - it("flips the pixel color with mapType at a point where lakes and nauvis disagree in sign", () => { - // World point (-1200, -1162), seed 123456: makeElevationNauvis = +2.34 (LAND), - // makeElevationLakes = -6.81 (WATER). Both magnitudes are well away from 0, so this - // point discriminates the dispatch - a broken or reversed ternary would fail here. - const request = { - seed0: 123456, - width: 1, - height: 1, - originX: -1200, - originY: -1162, - tilesPerPixel: 1, - }; - const nauvisImg = renderElevation({ ...request, mapType: "nauvis" as const }); - expect(Array.from(nauvisImg.data)).toEqual(LAND_RGBA); - - const lakesImg = renderElevation({ ...request, mapType: "lakes" as const }); - expect(Array.from(lakesImg.data)).toEqual(WATER_RGBA); - }); - - it("flips the pixel color with mapType at a point where island and lakes disagree in sign", () => { - // World point (-8000, -8000), seed 123456: makeElevationIsland = -1043.07 (WATER), - // makeElevationLakes = +49.14 (LAND). Both magnitudes are well away from 0, so this - // point discriminates the dispatch - if "island" were routed to the lakes factory - // (or vice versa), this test would fail. - const request = { - seed0: 123456, - width: 1, - height: 1, - originX: -8000, - originY: -8000, - tilesPerPixel: 1, - }; - const islandImg = renderElevation({ ...request, mapType: "island" as const }); - expect(Array.from(islandImg.data)).toEqual(WATER_RGBA); - - const lakesImg = renderElevation({ ...request, mapType: "lakes" as const }); - expect(Array.from(lakesImg.data)).toEqual(LAND_RGBA); - }); - - it("uses the island factory when mapType is 'island'", () => { - const img = renderElevation({ - seed0: 123456, - width, - height, - originX, - originY, - tilesPerPixel, - mapType: "island", - }); - const evalAt = makeElevationIsland({ seed0: 123456 }); - for (let py = 0; py < height; py++) { - for (let px = 0; px < width; px++) { - const wx = originX + px * tilesPerPixel; - const wy = originY + py * tilesPerPixel; - const expected = evalAt(wx, wy) < 0 ? WATER_RGBA : LAND_RGBA; - const o = (py * width + px) * 4; - expect([img.data[o], img.data[o + 1], img.data[o + 2], img.data[o + 3]]).toEqual(expected); - } - } - }); -}); diff --git a/test/renderEnemies.spec.ts b/test/renderEnemies.spec.ts index 251d7ec2..80d1bfc2 100644 --- a/test/renderEnemies.spec.ts +++ b/test/renderEnemies.spec.ts @@ -1,100 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; -import { renderEnemies } from "../src/noise/preview/renderEnemies"; -import { WATER_TILE_COLORS } from "../src/noise/preview/renderResources"; import { ENEMY_MAP_COLOR } from "../src/noise/enemies/enemyCatalog"; -const LAND: readonly [number, number, number] = [90, 120, 60]; - /** An `w`x`h` ImageData pre-filled with `color`. */ -function filled(w: number, h: number, color: readonly [number, number, number]): ImageData { - const data = new Uint8ClampedArray(w * h * 4); - for (let i = 0; i < data.length; i += 4) { - data[i] = color[0]; - data[i + 1] = color[1]; - data[i + 2] = color[2]; - data[i + 3] = 255; - } - return { width: w, height: h, data } as ImageData; -} describe("renderEnemies", () => { - it("leaves the starting area unpainted", () => { - const img = filled(1, 1, LAND); - renderEnemies(img, { - seed0: 123456, - originX: 0, - originY: 0, - tilesPerPixel: 1, - controls: { frequency: 1, size: 1 }, - }); - expect([img.data[0], img.data[1], img.data[2]]).toEqual([...LAND]); // untouched - }); - - /** - * The overlay places spawners now instead of shading the base's cone, so this - * asserts PLACEMENT rather than a footprint: some pixels paint, every painted - * pixel is exactly `ENEMY_MAP_COLOR`, and coverage is a small fraction of what - * the old `probability >= 0.05` threshold drew. - * - * The 32x32 window at (1000, 1040) is the same spot the old 1x1 test used - it - * sits inside a base spot - widened so that a roll-based render has something - * to hit. Measured there: 5 placements, 45 painted pixels (4.39% of the - * window), against 265 pixels (25.9%) for the old threshold. The assertions - * below are inequalities, not those counts, so they survive a re-measure. - */ - it("places marks inside a base spot, far sparser than the old footprint", () => { - const img = filled(32, 32, LAND); - renderEnemies(img, { - seed0: 123456, - originX: 1000, - originY: 1040, - tilesPerPixel: 1, - controls: { frequency: 1, size: 1 }, - }); - let painted = 0; - for (let i = 0; i < img.data.length; i += 4) { - const px = [img.data[i], img.data[i + 1], img.data[i + 2]]; - if (px[0] === LAND[0] && px[1] === LAND[1] && px[2] === LAND[2]) continue; - expect(px).toEqual([...ENEMY_MAP_COLOR]); - painted++; - } - expect(painted).toBeGreaterThan(0); - // The old threshold render covered 25.9% of this window; a roll covers a few - // percent. 15% is a ceiling well clear of both, so it fails loudly if the - // overlay ever reverts to shading the cone. - expect(painted / (32 * 32)).toBeLessThan(0.15); - }); - - /** - * The paint guard, which is now a SEPARATE thing from the placement gate. - * (1007, 1041) is a placed tile on `dirt-1`, so the water tile-restriction lets - * it through; painting it as water here proves `paintMark`'s `skipPixel` still - * keeps the mark off a pixel the terrain drew as water. - */ - it("never paints over a water pixel", () => { - const [wr, wg, wb] = WATER_TILE_COLORS[0]; - const img = filled(1, 1, [wr, wg, wb]); - renderEnemies(img, { - seed0: 123456, - originX: 1007, - originY: 1041, - tilesPerPixel: 1, - controls: { frequency: 1, size: 1 }, - }); - expect([img.data[0], img.data[1], img.data[2]]).toEqual([wr, wg, wb]); // still water - }); - - it("paints the tile it places on when that tile is land", () => { - const img = filled(1, 1, LAND); - renderEnemies(img, { - seed0: 123456, - originX: 1007, - originY: 1041, - tilesPerPixel: 1, - controls: { frequency: 1, size: 1 }, - }); - expect([img.data[0], img.data[1], img.data[2]]).toEqual([...ENEMY_MAP_COLOR]); - }); - it("map color drift guard", () => expect([...ENEMY_MAP_COLOR]).toEqual([255, 26, 26])); }); diff --git a/test/renderResources.spec.ts b/test/renderResources.spec.ts deleted file mode 100644 index 9b324d40..00000000 --- a/test/renderResources.spec.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { renderResources, WATER_TILE_COLORS } from "../src/noise/preview/renderResources"; -import { RESOURCE_CATALOG } from "../src/noise/resources/resourceCatalog"; -import { makeTileCatalog } from "../src/noise/tiles/catalog"; - -/** A base filled with a sentinel color no resource uses, alpha 255. */ -function sentinelBase(w: number, h: number): ImageData { - const d = new Uint8ClampedArray(w * h * 4); - for (let i = 0; i < w * h; i++) { - d[i * 4] = 7; - d[i * 4 + 1] = 8; - d[i * 4 + 2] = 9; - d[i * 4 + 3] = 255; - } - return new ImageData(d, w, h); -} - -const catalogColors = new Set(RESOURCE_CATALOG.map((r) => r.mapColor.join(","))); - -describe("renderResources", () => { - it("paints resource patches and leaves terrain (sentinel) elsewhere", () => { - // 64x64 px at 16 tiles/px over world [512, 1536) - a region with patches. - const base = sentinelBase(64, 64); - renderResources(base, { - seed0: 123456, - originX: 512, - originY: 512, - tilesPerPixel: 16, - controls: {}, - }); - - let painted = 0; - let sentinel = 0; - for (let i = 0; i < 64 * 64; i++) { - const [r, g, b, a] = [ - base.data[i * 4], - base.data[i * 4 + 1], - base.data[i * 4 + 2], - base.data[i * 4 + 3], - ]; - if (r === 7 && g === 8 && b === 9) { - sentinel++; - } else { - painted++; - expect(a).toBe(255); - expect(catalogColors.has(`${r},${g},${b}`)).toBe(true); - } - } - expect(painted).toBeGreaterThan(0); // some ore appears - expect(sentinel).toBeGreaterThan(0); // but not everywhere - }); - - it("never paints ore over water: a fully water-colored base is unchanged", () => { - // Fill the base with deepwater color over a region full of patches; since every - // pixel is a water tile, resources collide with all of them -> nothing painted. - const W = 64; - const [dr, dg, db] = WATER_TILE_COLORS[0]; - const base = new ImageData(new Uint8ClampedArray(W * W * 4), W, W); - for (let i = 0; i < W * W; i++) { - base.data[i * 4] = dr; - base.data[i * 4 + 1] = dg; - base.data[i * 4 + 2] = db; - base.data[i * 4 + 3] = 255; - } - const before = Uint8ClampedArray.from(base.data); - renderResources(base, { - seed0: 123456, - originX: 512, - originY: 512, - tilesPerPixel: 16, - controls: {}, - }); - expect(Array.from(base.data)).toEqual(Array.from(before)); - }); - - it("WATER_TILE_COLORS matches the tile catalog's water/deepwater map_colors", () => { - const cat = makeTileCatalog(0); - const water = (name: string) => cat.find((t) => t.name === name)!.color; - expect(WATER_TILE_COLORS).toContainEqual([ - water("deepwater")[0], - water("deepwater")[1], - water("deepwater")[2], - ]); - expect(WATER_TILE_COLORS).toContainEqual([ - water("water")[0], - water("water")[1], - water("water")[2], - ]); - expect(WATER_TILE_COLORS.length).toBe(2); - }); - - it("leaves the spawn pixel untouched (no patches inside the fade-in radius)", () => { - const base = sentinelBase(1, 1); - renderResources(base, { - seed0: 123456, - originX: 0, - originY: 0, - tilesPerPixel: 1, - controls: {}, - }); - expect([base.data[0], base.data[1], base.data[2]]).toEqual([7, 8, 9]); - }); - - describe("elevation-coupling ctx forwarding", () => { - afterEach(() => { - // The static imports above (already resolved at file load) keep using the - // real module regardless; this only un-mocks for any later dynamic import. - vi.doUnmock("../src/noise/resources/resolveResource"); - vi.resetModules(); - }); - - it("forwards segmentationMultiplier/waterLevel/startingLakePositions into makeResourceResolver", async () => { - const resolverFactory = vi.fn(() => () => null); - // A PARTIAL mock: `renderResources` also imports `comparePriority` from this - // module (it shares the resolver's own priority rule to protect crude oil's - // marks - see renderResourcesPaintOrder.spec.ts), so replacing the whole - // module leaves that import undefined and the render throws. - vi.doMock("../src/noise/resources/resolveResource", async (importOriginal) => ({ - ...(await importOriginal()), - makeResourceResolver: resolverFactory, - })); - vi.resetModules(); - const { renderResources: mockedRenderResources } = - await import("../src/noise/preview/renderResources"); - - const startingLakePositions = [{ x: 3, y: 4 }]; - const base = sentinelBase(4, 4); - mockedRenderResources(base, { - seed0: 123456, - controls: {}, - segmentationMultiplier: 1.7, - waterLevel: 3, - startingLakePositions, - }); - - expect(resolverFactory).toHaveBeenCalledWith( - expect.objectContaining({ - segmentationMultiplier: 1.7, - waterLevel: 3, - startingLakePositions, - }), - ); - }); - }); -}); diff --git a/test/renderResourcesPaintOrder.spec.ts b/test/renderResourcesPaintOrder.spec.ts deleted file mode 100644 index 235d8200..00000000 --- a/test/renderResourcesPaintOrder.spec.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Crude oil vs. the thresholded resources: which one owns a shared pixel. - * - * `renderResources` paints oil's 3x3 marks in pass 1 and the thresholded - * resources over the top in pass 2, which is right for the four solids - * (autoplace order "b" beats oil's "c") and **wrong for uranium**, which is also - * "c" but sorts after oil (`patchSetIndex` 5 vs 4). Issue #22 item 3 recorded - * that inversion as latent on the strength of a single measurement - over - * `[-2048,-2048]-[2048,2048]` at seed 123456 the oil and uranium footprints share - * 0 tiles - and it was a property of that seed, not of the geometry. - * - * The sweep that refuted it (2026-08-10, default controls, `frequency = size = 1`): - * - * | arm | result | - * | --- | --- | - * | 256 windows of 4096^2 (4.3e9 tiles), 128 seeds at `[-2048, 2048)^2` + 128 at `[65536, 69632)^2` | 5 windows (2.0%) have overlapping footprints; 2 of those are near-spawn, i.e. the same box the original zero came from | - * | 1024 windows of 4096^2 (1.7e10 tiles), 290,335 oil wells | 7 wells overwritten by uranium, 5 of them on all 9 mark pixels | - * - * The three cases below are that sweep's output, one per behaviour the paint - * order has to get right. They are deliberately at three different control - * settings, because the two settings differ by three orders of magnitude in how - * often this happens: - * - * - **default controls, a hidden well.** ~1 well in 41,000 - rare, and real. - * - **600% frequency and size, a hidden well.** Both are notches the game's own - * map-gen GUI offers (`PERCENT_STEPS` tops out at 6), and at that setting seed - * **123456** - the seed the original zero was measured on - hides two wells - * inside `[-1024, 1024)^2` alone. This is not an exotic configuration. - * - **600% frequency and size, a well under iron.** The arm that fails if the fix - * is "paint oil last" rather than "paint oil last where oil outranks the - * winner". Without it, a guard that reversed the whole order would pass. - * - * Each case was confirmed to discriminate by running this file against the - * pre-guard renderer: the first two came back solid uranium green on all 9 - * pixels, and the third was unaffected. - */ -import { describe, expect, it } from "vite-plus/test"; -import { renderResources } from "../src/noise/preview/renderResources"; -import { RESOURCE_CATALOG } from "../src/noise/resources/resourceCatalog"; - -const colorOf = (name: string): string => { - const p = RESOURCE_CATALOG.find((r) => r.name === name); - if (p === undefined) throw new Error(`${name} missing from RESOURCE_CATALOG`); - return p.mapColor.join(","); -}; -const OIL = colorOf("crude-oil"); -const URANIUM = colorOf("uranium-ore"); -const IRON = colorOf("iron-ore"); - -/** A base filled with a sentinel colour no resource uses, alpha 255. */ -function sentinelBase(w: number, h: number): ImageData { - const d = new Uint8ClampedArray(w * h * 4); - for (let i = 0; i < w * h; i++) { - d[i * 4] = 7; - d[i * 4 + 1] = 8; - d[i * 4 + 2] = 9; - d[i * 4 + 3] = 255; - } - return new ImageData(d, w, h); -} - -const levers = (v: number) => ({ frequency: v, size: v, richness: 1 }); -const allControls = (v: number): Record> => - Object.fromEntries(RESOURCE_CATALOG.map((p) => [p.controlName, levers(v)])); - -/** The 3x3 mark centred on world tile (wx, wy), rendered at 1 tile per pixel. */ -function markColors(seed0: number, wx: number, wy: number, control: number): string[] { - const base = sentinelBase(3, 3); - renderResources(base, { - seed0, - originX: wx - 1, - originY: wy - 1, - tilesPerPixel: 1, - controls: allControls(control), - }); - const out: string[] = []; - for (let i = 0; i < 9; i++) { - out.push(`${base.data[i * 4]},${base.data[i * 4 + 1]},${base.data[i * 4 + 2]}`); - } - return out; -} - -describe("renderResources paint order", () => { - it("keeps an oil well that a uranium patch covers - default controls", () => { - // Found by the 1024-window sweep; the well is at the centre of a uranium patch. - expect(markColors(2980111949, -1584, 513, 1)).toEqual(Array(9).fill(OIL)); - }); - - it("keeps an oil well that a uranium patch covers - 600% frequency and size", () => { - // Seed 123456: the very seed whose "0 shared tiles" made this look unreachable. - expect(markColors(123456, 600, 895, 6)).toEqual(Array(9).fill(OIL)); - }); - - it("still lets iron ore cover an oil well - the solids outrank oil", () => { - expect(markColors(123456, 675, -508, 6)).toEqual(Array(9).fill(IRON)); - }); - - it("leaves uranium alone where no oil well sits under it", () => { - // One tile off the hidden well above, outside its 3x3 mark: still uranium. - expect(markColors(2980111949, -1584, 517, 1)).toEqual(Array(9).fill(URANIUM)); - }); -}); diff --git a/test/renderRocks.spec.ts b/test/renderRocks.spec.ts deleted file mode 100644 index 83c4489b..00000000 --- a/test/renderRocks.spec.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { makeNauvisRockPlacement, renderRocks } from "../src/noise/preview/renderRocks"; -import { NAUVIS_ROCK_MARK_RADIUS_PX, ROCK_MAP_COLOR } from "../src/noise/rocks/rockCatalog"; -import { WATER_TILE_COLORS } from "../src/noise/preview/renderResources"; - -function solidImage(w: number, h: number, rgb: readonly [number, number, number]): ImageData { - const data = new Uint8ClampedArray(w * h * 4); - for (let i = 0; i < w * h; i++) { - data[i * 4] = rgb[0]; - data[i * 4 + 1] = rgb[1]; - data[i * 4 + 2] = rgb[2]; - data[i * 4 + 3] = 255; - } - return { data, width: w, height: h, colorSpace: "srgb" } as ImageData; -} - -describe("renderRocks", () => { - const seed0 = 123456; - // 128x128 rather than the 48x48 this test used while the render thresholded. - // The roll places ~0.08% of tiles (measured in entityDensity.spec.ts), so a - // 48x48 window would expect ~2 rocks and could plausibly hold none, leaving - // the "painted > 0" guard below one seed away from vacuous. - const W = 128; - const H = 128; - const originX = 288; - const originY = -216; - - it("paints ROCK_MAP_COLOR over the placement roll's accepted tiles, and only there", () => { - // Nauvis rocks paint a 3x3 mark (`NAUVIS_ROCK_MARK_RADIUS_PX`), so this is - // NOT a 1:1 pixel-to-placement correspondence - it used to be, while the mark - // was a single pixel. Two directions instead, which together pin the mark - // exactly: every placement paints its own pixel, and every painted pixel is - // within the mark radius of some placement. - const placed = makeNauvisRockPlacement({ seed0, startingPositions: [{ x: 0, y: 0 }] }); - const img = solidImage(W, H, [100, 100, 100]); // non-water land - renderRocks(img, { seed0, originX, originY, startingPositions: [{ x: 0, y: 0 }] }); - const isRockAt = (px: number, py: number): boolean => { - const o = (py * W + px) * 4; - return ( - img.data[o] === ROCK_MAP_COLOR[0] && - img.data[o + 1] === ROCK_MAP_COLOR[1] && - img.data[o + 2] === ROCK_MAP_COLOR[2] - ); - }; - const r = NAUVIS_ROCK_MARK_RADIUS_PX; - let painted = 0; - let placements = 0; - for (let py = 0; py < H; py++) { - for (let px = 0; px < W; px++) { - if (placed(originX + px, originY + py)) { - placements++; - // Every placement paints at least its own centre pixel. - expect(isRockAt(px, py)).toBe(true); - } - if (!isRockAt(px, py)) continue; - painted++; - // ...and nothing is painted that no placement can reach. - let near = false; - for (let dy = -r; dy <= r && !near; dy++) { - for (let dx = -r; dx <= r; dx++) { - if (placed(originX + px + dx, originY + py + dy)) { - near = true; - break; - } - } - } - expect(near).toBe(true); - } - } - // Sparse but present in this region (guards against "painted nothing"/"painted all"). - expect(placements).toBeGreaterThan(0); - expect(painted).toBeGreaterThan(0); - expect(painted).toBeLessThan(W * H); - // The mark really is thickening: a 3x3 over sparse, mostly non-adjacent - // placements paints several pixels each. - expect(painted).toBeGreaterThan(placements * 2); - }); - - it("never paints over water pixels", () => { - const water = WATER_TILE_COLORS[0]; - const img = solidImage(W, H, water); - renderRocks(img, { seed0, originX, originY, startingPositions: [{ x: 0, y: 0 }] }); - for (let i = 0; i < W * H; i++) { - expect(img.data[i * 4]).toBe(water[0]); - expect(img.data[i * 4 + 1]).toBe(water[1]); - expect(img.data[i * 4 + 2]).toBe(water[2]); - } - }); -}); diff --git a/test/renderTerrain.spec.ts b/test/renderTerrain.spec.ts deleted file mode 100644 index 73873a56..00000000 --- a/test/renderTerrain.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { renderTerrain } from "../src/noise/preview/renderTerrain"; -import { makeTileResolver } from "../src/noise/tiles/resolve"; - -describe("renderTerrain", () => { - it("produces an ImageData of the requested size", () => { - const img = renderTerrain({ seed0: 123456, width: 8, height: 6 }); - expect(img.width).toBe(8); - expect(img.height).toBe(6); - expect(img.data.length).toBe(8 * 6 * 4); - }); - - it("a deep-water world point renders the deepwater color", () => { - // World point (2742, 8459), seed 123456: makeElevationNauvis ~= -207.9, - // strongly submerged - resolveTile confirms "deepwater" there. - const img = renderTerrain({ seed0: 123456, width: 1, height: 1, originX: 2742, originY: 8459 }); - expect([img.data[0], img.data[1], img.data[2], img.data[3]]).toEqual([38, 64, 73, 255]); - }); - - it("a land pixel matches the full tile resolver's color", () => { - // World point (-1200, -1162), seed 123456: makeElevationNauvis = +2.34 - // (LAND) - reused from renderElevation.spec.ts's known land point. - const seed0 = 123456; - const x = -1200; - const y = -1162; - const resolve = makeTileResolver({ seed0 }); - const expected = resolve(x, y).color; - - const img = renderTerrain({ seed0, width: 1, height: 1, originX: x, originY: y }); - expect([img.data[0], img.data[1], img.data[2], img.data[3]]).toEqual(expected); - }); - - it("early-out correctness: render matches the full resolver over a grid spanning water and land", () => { - // 40x40 grid (1600 points) over a wide window; empirically 403 water/deepwater - // + 1197 land points, including at least one near-threshold ambiguous point - // ((3900,-1800): elevation ~= -0.0004, water_base ~= 0.04 - well below the - // early-out threshold, so it correctly falls through to the full resolver - // and resolves to a land tile ("dry-dirt") despite negative elevation - the - // coastline-shift behavior the design doc documents). This is the key safety - // test: if the early-out threshold were unsafe, some pixel here would win a - // different tile than the full resolver and this loop would catch it. - const seed0 = 123456; - const width = 40; - const height = 40; - const originX = -6000; - const originY = -6000; - const tilesPerPixel = 300; - - const img = renderTerrain({ seed0, width, height, originX, originY, tilesPerPixel }); - const resolve = makeTileResolver({ seed0 }); - - let waterCount = 0; - let landCount = 0; - for (let py = 0; py < height; py++) { - const wy = originY + py * tilesPerPixel; - for (let px = 0; px < width; px++) { - const wx = originX + px * tilesPerPixel; - const expected = resolve(wx, wy); - if (expected.name === "water" || expected.name === "deepwater") { - waterCount++; - } else { - landCount++; - } - - const o = (py * width + px) * 4; - const actual = [img.data[o], img.data[o + 1], img.data[o + 2], img.data[o + 3]]; - expect(actual, `mismatch at (${wx},${wy}), expected tile "${expected.name}"`).toEqual( - expected.color, - ); - } - } - - // Sanity: the grid actually exercises both the early-out path and the - // full-resolver fallback path, not just one of them. - expect(waterCount).toBeGreaterThan(0); - expect(landCount).toBeGreaterThan(0); - }); -}); diff --git a/test/renderTrees.spec.ts b/test/renderTrees.spec.ts deleted file mode 100644 index 9c0487ad..00000000 --- a/test/renderTrees.spec.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { WATER_TILE_COLORS } from "../src/noise/preview/renderResources"; -import { TREE_MAP_COLOR, TREE_MAX_ALPHA, renderTrees } from "../src/noise/preview/renderTrees"; -import { makeTreeDensity } from "../src/noise/trees/treeField"; - -const solid = (w: number, h: number, rgb: readonly [number, number, number]): ImageData => { - const data = new Uint8ClampedArray(w * h * 4); - for (let i = 0; i < w * h; i++) { - data[i * 4] = rgb[0]; - data[i * 4 + 1] = rgb[1]; - data[i * 4 + 2] = rgb[2]; - data[i * 4 + 3] = 255; - } - return { data, width: w, height: h, colorSpace: "srgb" } as ImageData; -}; - -const GRASS: readonly [number, number, number] = [99, 122, 44]; - -// Independent re-implementation of the footprint model, so these expectations -// are derived from the spec (drawRectangle rasterizes a 1.0-tile box floor to -// ceil, blending every touched pixel at full alpha; a uniform sub-tile offset -// gives the separable [0.5, 1, 0.5] kernel; the game blends once per drawn -// tree, so independent neighbouring blends compound as -// alpha = 1 - product of (1 - TREE_MAX_ALPHA * p * w)), never by echoing -// whatever renderTrees.ts happens to compute. -const KERNEL = [0.5, 1.0, 0.5]; - -/** - * Combined alpha at world coordinate (wx, wy), where the 3x3 neighbourhood is - * spaced by `tpp` world tiles - matching how the pixel grid maps to world - * coordinates at a given tilesPerPixel. - * - * The game blends once PER DRAWN TREE, so each neighbouring tile - * independently draws at alpha `TREE_MAX_ALPHA * p * w`, and the combined - * alpha of these independent blends is 1 minus the product of their misses - - * compounding toward 1 rather than capping at a single tree's TREE_MAX_ALPHA. - */ -function alphaAt( - density: (x: number, y: number) => number, - wx: number, - wy: number, - tpp = 1, -): number { - let miss = 1; - for (let dy = -1; dy <= 1; dy++) { - for (let dx = -1; dx <= 1; dx++) { - const p = density(wx + dx * tpp, wy + dy * tpp) * KERNEL[dx + 1] * KERNEL[dy + 1]; - miss *= 1 - TREE_MAX_ALPHA * p; - } - } - return 1 - miss; -} - -/** The game's integer blend: 255->256 alpha fixup, then `((256-a)*dst + a*src) >> 8`. */ -function blendChannel(dst: number, src: number, alpha: number): number { - const A = Math.round(alpha * 255); - const a = A + (A >> 7); - return ((256 - a) * dst + a * src) >> 8; -} - -describe("renderTrees", () => { - it("uses the game's own tree chart color and alpha", () => { - // core/prototypes/utility-constants.lua:201 - - // default_color_by_type["tree"] = {0.19, 0.39, 0.19, 0.40} - expect(TREE_MAP_COLOR).toEqual([48, 99, 48]); - expect(TREE_MAX_ALPHA).toBeCloseTo(0.4, 12); - }); - - it("blends toward the tree color in proportion to the footprint coverage", () => { - const img = solid(16, 16, GRASS); - renderTrees(img, { seed0: 123456, originX: 0, originY: 0, tilesPerPixel: 1 }); - const density = makeTreeDensity({ seed0: 123456 }); - for (let py = 0; py < 16; py++) { - for (let px = 0; px < 16; px++) { - const o = (py * 16 + px) * 4; - const alpha = alphaAt(density, px, py); - for (let c = 0; c < 3; c++) { - const expected = blendChannel(GRASS[c], TREE_MAP_COLOR[c], alpha); - expect(img.data[o + c], `px(${px},${py}) ch${c}`).toBe(expected); - } - expect(img.data[o + 3]).toBe(255); - } - } - }); - - it("leaves water pixels untouched", () => { - for (const water of WATER_TILE_COLORS) { - const img = solid(8, 8, water); - renderTrees(img, { seed0: 123456 }); - for (let i = 0; i < 8 * 8; i++) { - expect(img.data[i * 4]).toBe(water[0]); - expect(img.data[i * 4 + 1]).toBe(water[1]); - expect(img.data[i * 4 + 2]).toBe(water[2]); - } - } - }); - - it("honors originX/originY and tilesPerPixel", () => { - // Both windows must land somewhere with genuinely nonzero tree density, or - // every assertion below degenerates: renderTrees is correctly a no-op at - // zero coverage, so a zero-density window makes both the "renders differ" and - // the "matches the exact blend" assertions pass trivially without - // exercising the origin/tilesPerPixel coordinate mapping at all - which is - // exactly the defect this test previously had (both of its original - // windows sat in a tree-free clearing). These two windows are confirmed - // nonzero for seed 123456: origin (420,-280) @ tilesPerPixel 2 and origin - // (-2000,-2000) @ tilesPerPixel 1. - const a = solid(4, 4, GRASS); - const b = solid(4, 4, GRASS); - renderTrees(a, { seed0: 123456, originX: 420, originY: -280, tilesPerPixel: 2 }); - renderTrees(b, { seed0: 123456, originX: -2000, originY: -2000, tilesPerPixel: 1 }); - expect(Array.from(a.data)).not.toEqual(Array.from(b.data)); - - const density = makeTreeDensity({ seed0: 123456 }); - const worldX = 420 + 2 * 3; - const worldY = -280 + 2 * 1; - const d = density(worldX, worldY); - // Non-vacuity guard: without this, the test would silently pass even if - // the sampled window happened to fall in a forest gap (density 0), since - // the "exact blend" assertion below collapses to the untouched grass - // pixel at coverage 0 - proving nothing about the coordinate mapping it - // exists to verify. This guard is the point of the exercise. - expect(d).toBeGreaterThan(0); - - // The kernel neighbours here are 2 world tiles apart (tilesPerPixel 2), not 1. - const alpha = alphaAt(density, worldX, worldY, 2); - const o = (1 * 4 + 3) * 4; - expect(a.data[o]).toBe(blendChannel(GRASS[0], TREE_MAP_COLOR[0], alpha)); - }); - - it("is a no-op where coverage is zero", () => { - // A pixel whose 3x3 neighbourhood is all zero density must be byte-identical - // afterwards (alpha collapses to exactly 0, no blend applied at all). - const img = solid(64, 64, GRASS); - const before = Array.from(img.data); - renderTrees(img, { seed0: 123456 }); - const density = makeTreeDensity({ seed0: 123456 }); - let checked = 0; - for (let py = 0; py < 64; py++) { - for (let px = 0; px < 64; px++) { - if (alphaAt(density, px, py) !== 0) continue; - checked++; - const o = (py * 64 + px) * 4; - for (let c = 0; c < 4; c++) expect(img.data[o + c]).toBe(before[o + c]); - } - } - expect(checked).toBeGreaterThan(0); - }); -}); diff --git a/test/resolveResource.spec.ts b/test/resolveResource.spec.ts index b4bebb4a..e94899fe 100644 --- a/test/resolveResource.spec.ts +++ b/test/resolveResource.spec.ts @@ -1,31 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; import { RESOURCE_CATALOG } from "../src/noise/resources/resourceCatalog"; -import { makeResourceResolver, pickWinner } from "../src/noise/resources/resolveResource"; - -const byName = (n: string) => RESOURCE_CATALOG.find((r) => r.name === n)!; - -describe("pickWinner (order-priority overlay)", () => { - it("returns null when nothing is present", () => { - expect(pickWinner([])).toBe(null); - }); - - it("prefers order 'b' over order 'c' regardless of listing order", () => { - const iron = byName("iron-ore"); // order b, patchSetIndex 0 - const uranium = byName("uranium-ore"); // order c, patchSetIndex 5 - expect(pickWinner([uranium, iron])).toBe(iron); - expect(pickWinner([iron, uranium])).toBe(iron); - }); - - it("within an order, lower patchSetIndex wins", () => { - const copper = byName("copper-ore"); // b, index 1 - const stone = byName("stone"); // b, index 3 - expect(pickWinner([stone, copper])).toBe(copper); - }); - - it("crude-oil (c, index 4) beats uranium (c, index 5)", () => { - expect(pickWinner([byName("uranium-ore"), byName("crude-oil")])).toBe(byName("crude-oil")); - }); -}); describe("resource placement modes", () => { it("crude oil is the one roll resource; the rest threshold", () => { @@ -41,86 +15,4 @@ describe("resource placement modes", () => { "crude-oil", ]); }); - - it("the resolver no longer returns oil, because the roll pass paints it", () => { - // The regression this guards: oil used to be thresholded here, which painted - // its entire patch extent as solid ore - 1234 tiles in [0,0]-[512,512] where - // the game has 8 wells. A window with plenty of oil footprint must now yield - // no oil from the resolver at all. - const resolve = makeResourceResolver({ seed0: 123456, controls: {} }); - let oilTiles = 0; - for (let y = 0; y < 512; y += 3) { - for (let x = 0; x < 512; x += 3) { - if (resolve(x, y)?.name === "crude-oil") oilTiles++; - } - } - expect(oilTiles).toBe(0); - }); -}); - -describe("makeResourceResolver", () => { - const resolve = makeResourceResolver({ - seed0: 123456, - controls: {}, // all default to freq/size/richness 1 - }); - - it("returns null at spawn (no regular patches inside the fade-in radius)", () => { - expect(resolve(0, 0)).toBe(null); - }); - - it("finds resource patches out in the world, and every winner is a catalog member", () => { - const names = new Set(RESOURCE_CATALOG.map((r) => r.name)); - let found = 0; - for (let y = 512; y < 1536 && found < 3; y += 16) { - for (let x = 512; x < 1536; x += 16) { - const w = resolve(x, y); - if (w) { - expect(names.has(w.name)).toBe(true); - found++; - break; - } - } - } - expect(found).toBeGreaterThan(0); - }); - - it("omits a resource whose size control is 0 (never wins)", () => { - const ironOff = makeResourceResolver({ - seed0: 123456, - controls: { "iron-ore": { frequency: 1, size: 0, richness: 1 } }, - }); - // Scan; iron must never be returned when its size is 0. - for (let y = 512; y < 1536; y += 32) { - for (let x = 512; x < 1536; x += 32) { - expect(ironOff(x, y)?.name).not.toBe("iron-ore"); - } - } - }); -}); - -describe("makeResourceResolver (M3b: starting patches near spawn)", () => { - // iron-ore is order "b", patchSetIndex 0 - the highest-priority resource, so if - // its own field is present at a tile it always wins regardless of what else - // overlaps there. STARTING_RESOURCE_PLACEMENT_RADIUS is 150: the regular field's - // fade-in (REGULAR_PATCH_FADE_IN_DISTANCE) makes regular density (and therefore - // the regular field, including its blob term) exactly 0 for every distance < 150, - // so a resource found strictly inside that radius can only come from the - // starting-patch term this task wires in - a scan there is a real RED/GREEN - // discriminator (verified: unwired, this scan finds nothing and the test fails). - it("returns iron-ore at some near-spawn tile inside its guaranteed starting patch", () => { - const resolve = makeResourceResolver({ - seed0: 123456, - controls: { "iron-ore": { frequency: 1, size: 1, richness: 1 } }, - }); - let found = false; - for (let y = -140; y <= 140 && !found; y += 4) { - for (let x = -140; x <= 140; x += 4) { - if (resolve(x, y)?.name === "iron-ore") { - found = true; - break; - } - } - } - expect(found).toBe(true); - }); }); diff --git a/test/resolveTile.spec.ts b/test/resolveTile.spec.ts deleted file mode 100644 index f4fb74f4..00000000 --- a/test/resolveTile.spec.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import fixture123456 from "./fixtures/oracle-tile-names.seed123456.json"; -import fixture424242 from "./fixtures/oracle-tile-names.seed424242.json"; -import fixture654321 from "./fixtures/oracle-tile-names.seed654321.json"; -import { makeAux } from "../src/noise/expressions/aux"; -import { makeElevationNauvis } from "../src/noise/expressions/elevationNauvis"; -import { makeMoisture } from "../src/noise/expressions/moisture"; -import { makeTileCatalog } from "../src/noise/tiles/catalog"; -import { makeTileResolver } from "../src/noise/tiles/resolve"; - -// Task 10: resolveTile (argmax) parity against the game's own get_tile ground -// truth. Each fixture is 51 positions on the DEFAULT preset (default Nauvis -// elevation + default climate controls); 3 fixtures x 51 = 153 points total. -const FIXTURES = [fixture123456, fixture424242, fixture654321]; - -interface Mismatch { - seed0: number; - x: number; - y: number; - expected: string; - got: string; - top2Gap: number; -} - -describe("makeTileResolver reproduces the game's get_tile ground truth", () => { - it("matches the game's tile name on >= 90% of 153 points, and any mismatch is near a boundary seam", () => { - let total = 0; - let matches = 0; - const mismatches: Mismatch[] = []; - - for (const fixture of FIXTURES) { - const resolveAt = makeTileResolver({ seed0: fixture.seed0 }); - // Rebuild the same evaluators the resolver builds internally, purely to - // compute the top-2 probability gap for any mismatch (the resolver - // itself only exposes the winning Tile, not the full probability - // vector). - const catalog = makeTileCatalog(fixture.seed0); - const elevationAt = makeElevationNauvis({ seed0: fixture.seed0 }); - const auxAt = makeAux({ seed0: fixture.seed0 }); - const moistureAt = makeMoisture({ seed0: fixture.seed0 }); - - for (let i = 0; i < fixture.positions.length; i++) { - const p = fixture.positions[i]; - const expected = fixture.tileNames[i]; - const got = resolveAt(p.x, p.y).name; - total++; - - if (got === expected) { - matches++; - continue; - } - - const env = { - x: p.x, - y: p.y, - elevation: elevationAt(p.x, p.y), - aux: auxAt(p.x, p.y), - moisture: moistureAt(p.x, p.y), - }; - const probs = catalog.map((t) => t.probability(env)).sort((a, b) => b - a); - const top2Gap = probs[0] - probs[1]; - mismatches.push({ seed0: fixture.seed0, x: p.x, y: p.y, expected, got, top2Gap }); - } - } - - const pct = (matches / total) * 100; - const report = mismatches - .map( - (m) => - `seed=${m.seed0} (${m.x},${m.y}) expected=${m.expected} got=${m.got} top2Gap=${m.top2Gap.toExponential(3)}`, - ) - .join("\n"); - // eslint-disable-next-line no-console - console.log( - `resolveTile parity: ${matches}/${total} (${pct.toFixed(1)}%) exact match.\n` + - (mismatches.length > 0 ? `Mismatches:\n${report}` : "No mismatches."), - ); - - expect( - pct, - `exact-match percentage (see console log for mismatches)\n${report}`, - ).toBeGreaterThanOrEqual(90); - - for (const m of mismatches) { - expect( - m.top2Gap, - `mismatch at seed=${m.seed0} (${m.x},${m.y}) expected=${m.expected} got=${m.got} should be a near-boundary seam (top-2 gap < 1e-2)`, - ).toBeLessThan(1e-2); - } - }); -}); - -// Task 12b: prove the new climate params actually thread through to the -// resolver's argmax, and that supplying them at their game defaults is a -// no-op (byte-identical to the Task 10 default-only call above). -describe("makeTileResolver threads climate controls through to the argmax", () => { - const seed0 = 123456; - // A grid of points wide enough that a strong aux/moisture bias shift is - // certain to flip at least one tile's argmax winner (aux/moisture drive - // most of the 21-tile catalog's expression_in_range boxes - see catalog.ts). - const SAMPLE_POINTS: Array<{ x: number; y: number }> = []; - for (let x = -2000; x <= 2000; x += 200) { - for (let y = -2000; y <= 2000; y += 200) { - SAMPLE_POINTS.push({ x, y }); - } - } - - it("a strongly shifted aux bias changes the resolved tile at some sample point", () => { - const defaultResolve = makeTileResolver({ seed0 }); - const shiftedResolve = makeTileResolver({ seed0, auxBias: 0.5 }); - - const anyDifferent = SAMPLE_POINTS.some( - ({ x, y }) => defaultResolve(x, y).name !== shiftedResolve(x, y).name, - ); - expect(anyDifferent).toBe(true); - }); - - it("a strongly shifted moisture bias changes the resolved tile at some sample point", () => { - const defaultResolve = makeTileResolver({ seed0 }); - const shiftedResolve = makeTileResolver({ seed0, moistureBias: 0.5 }); - - const anyDifferent = SAMPLE_POINTS.some( - ({ x, y }) => defaultResolve(x, y).name !== shiftedResolve(x, y).name, - ); - expect(anyDifferent).toBe(true); - }); - - it("a shifted aux frequency changes the resolved tile at some sample point", () => { - const defaultResolve = makeTileResolver({ seed0 }); - const shiftedResolve = makeTileResolver({ seed0, auxFrequency: 4 }); - - const anyDifferent = SAMPLE_POINTS.some( - ({ x, y }) => defaultResolve(x, y).name !== shiftedResolve(x, y).name, - ); - expect(anyDifferent).toBe(true); - }); - - it("passing the climate params at their game defaults is a no-op (matches the bare call)", () => { - const bare = makeTileResolver({ seed0 }); - const explicitDefaults = makeTileResolver({ - seed0, - moistureFrequency: 1, - moistureBias: 0, - auxFrequency: 1, - auxBias: 0, - startingAreaMoistureSize: 1, - startingAreaMoistureFrequency: 1, - startingPositions: [{ x: 0, y: 0 }], - }); - - for (const { x, y } of SAMPLE_POINTS) { - expect(explicitDefaults(x, y).name).toBe(bare(x, y).name); - } - }); -}); diff --git a/test/resourceMath.spec.ts b/test/resourceMath.spec.ts deleted file mode 100644 index 42c02d67..00000000 --- a/test/resourceMath.spec.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { fastCbrt } from "../src/noise/fastApprox"; -import { RESOURCE_CATALOG } from "../src/noise/resources/resourceCatalog"; -import { - basementValue, - regularBlobAmplitudeAt, - regularBlobAmplitudeMaximumDistance, - regularDensityAt, - regularSpotHeightTypicalAt, - regularSpotQuantityBaseAt, - startingAmount, - startingAreaSpotQuantity, - startingBlobAmplitude, - startingDensityAt, - startingFavorabilityBaseAt, - startingModulation, - startingSpotRadius, -} from "../src/noise/resources/resourceMath"; - -const iron = RESOURCE_CATALOG[0]; -const uranium = RESOURCE_CATALOG.find((r) => r.name === "uranium-ore")!; -const one = { frequency: 1, size: 1 }; - -describe("resourceMath", () => { - it("regular density is zero inside the fade-in radius and ramps outside it", () => { - // fade-in = clamp((d-120)/300); at d<=120 it's 0, so no regular patches near spawn. - expect(regularDensityAt(0, iron, one)).toBe(0); - expect(regularDensityAt(120, iron, one)).toBe(0); - // d=500: fade-in clamps to 1; double-up = 1 + (500-300)/1300 = 1.153846... - expect(regularDensityAt(500, iron, one)).toBeCloseTo(10 * (1 + 200 / 1300), 9); - // uranium (has_starting=false, sign 0, not -1) uses the SAME fade-in branch. - expect(regularDensityAt(0, uranium, one)).toBe(0); - }); - - it("double-density ramp caps at 2x by regular_blob_amplitude_maximum_distance", () => { - // d=2000: fade-in 1, size_effective=1700 -> clamp(1700/1300)=1 -> double-up = 2. - expect(regularDensityAt(2000, iron, one)).toBe(20); - expect(regularBlobAmplitudeMaximumDistance(iron)).toBe(1600); // 1300 + 300 - }); - - it("spot quantity base is 1e6 / base_spots_per_km2 / frequency times density", () => { - const d = 500; - expect(regularSpotQuantityBaseAt(d, iron, one)).toBeCloseTo( - (1000000 / 2.5) * regularDensityAt(d, iron, one), - 3, - ); - }); - - it("spot height typical is fastCbrt(meanSize*quantityBase) / (pi/3 * rq^2)", () => { - // The game's noise machine takes this cube root through its fastapprox `pow`, not - // an exact cbrt (see src/noise/fastApprox.ts + docs/noise/random-penalty-NOTES.md); - // exact Math.cbrt is off by ~7e-5 relative and would fail at 6 decimals. - const d = 800; - const meanSize = (iron.randomSpotSizeMin + iron.randomSpotSizeMax) / 2; - const expected = - fastCbrt(meanSize * regularSpotQuantityBaseAt(d, iron, one)) / - ((Math.PI / 3) * iron.regularRqFactor ** 2); - expect(regularSpotHeightTypicalAt(d, iron, one)).toBeCloseTo(expected, 6); - }); - - it("basement_value is -6 * max(regular blob amp at max distance, starting blob amp)", () => { - const regular = regularBlobAmplitudeAt(regularBlobAmplitudeMaximumDistance(iron), iron, one); - const starting = startingBlobAmplitude(iron, one); - expect(basementValue(iron, one)).toBeCloseTo(-6 * Math.max(regular, starting), 6); - expect(basementValue(iron, one)).toBeLessThan(0); - // For iron the regular blob amplitude (~2052) dominates the starting one (~241). - expect(regular).toBeGreaterThan(starting); - }); - - it("scales density with frequency and size controls", () => { - const base = regularDensityAt(500, iron, one); - expect(regularDensityAt(500, iron, { frequency: 1, size: 2 })).toBeCloseTo(base * 2, 6); - // frequency enters density linearly, but also divides quantity base -> quantity base - // is frequency-independent in the density*1/frequency product? No: density has *frequency, - // quantityBase divides by frequency, so quantityBase's frequency factors cancel. - const qb1 = regularSpotQuantityBaseAt(500, iron, { frequency: 1, size: 1 }); - const qb2 = regularSpotQuantityBaseAt(500, iron, { frequency: 3, size: 1 }); - expect(qb2).toBeCloseTo(qb1, 3); - }); -}); - -describe("starting-patch local functions (iron, default controls)", () => { - // starting_amount = 20000 * 10 * (1 + 1) * 1 = 400000 - it("startingAmount", () => expect(startingAmount(iron, one)).toBeCloseTo(400000, 6)); - // starting_area_spot_quantity = 400000 / 0.5 / 1 = 800000 - it("startingAreaSpotQuantity", () => - expect(startingAreaSpotQuantity(iron, one)).toBeCloseTo(800000, 6)); - // starting_modulation = starting_resource_placement_radius(=150) > distance - it("startingModulation", () => { - expect(startingModulation(50)).toBe(1); - expect(startingModulation(150)).toBe(0); // 150 > 150 is false - expect(startingModulation(200)).toBe(0); - }); - // density at d=50 = 400000 / (pi * 150^2) * 1 - it("startingDensityAt inside", () => - expect(startingDensityAt(50, iron, one)).toBeCloseTo(400000 / (Math.PI * 150 * 150), 6)); - it("startingDensityAt outside is 0", () => expect(startingDensityAt(200, iron, one)).toBe(0)); - // radius = (1.5/7) * cbrt(800000) ~= 0.214286 * 92.832 ~= 19.89 (fastCbrt ~ exact to ~1e-4) - it("startingSpotRadius", () => - expect(startingSpotRadius(iron, one)).toBeCloseTo((1.5 / 7) * Math.cbrt(800000), 2)); - // favorability = lake_mask * starting_modulation * origin_excluder * 2 - min(1, distance/150). - // At d=60, elev=11: clamp((11-1)/10,0,1)*(150>60)*(60>40)*2 - min(1,60/150) = 1*1*1*2 - 0.4 = 1.6 - it("startingFavorabilityBaseAt land near spawn", () => - expect(startingFavorabilityBaseAt(60, 11, iron, one)).toBeCloseTo(1.6, 6)); - // origin_excluder = distance > 40: at d=30 (< 40, crash-site exclusion) the lake_mask - // term zeroes out, leaving -min(1, 30/150) = -0.2 - it("startingFavorabilityBaseAt inside origin excluder", () => - expect(startingFavorabilityBaseAt(30, 11, iron, one)).toBeCloseTo(-30 / 150, 6)); - // at d=200 (outside modulation): lake_mask * 0 * ... * 2 - min(1, 200/150) = -1 - it("startingFavorabilityBaseAt outside", () => - expect(startingFavorabilityBaseAt(200, 11, iron, one)).toBeCloseTo(-1, 6)); -}); diff --git a/test/resourcePatches.spec.ts b/test/resourcePatches.spec.ts index a193b532..bd9b63c3 100644 --- a/test/resourcePatches.spec.ts +++ b/test/resourcePatches.spec.ts @@ -1,13 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import fixture from "./fixtures/oracle-resource-starting.seed123456.json"; -import { countOffGrid, snapPosition } from "./captureGrid"; -import { RESOURCE_CATALOG } from "../src/noise/resources/resourceCatalog"; -import { makeResourcePatches } from "../src/noise/resources/resourcePatches"; - -const paramsByName = new Map(RESOURCE_CATALOG.map((r) => [r.name, r])); -const relErr = (port: number, game: number) => Math.abs(port - game) / Math.max(1, Math.abs(game)); -const ABS_TOL = 0.7; -const REL_TOL = 1e-2; +import { countOffGrid } from "./captureGrid"; // This fixture samples both the near-spawn starting patches (the feature under // test) AND the pre-existing far field (d=1500-2500), at large fractional @@ -37,48 +30,6 @@ const REL_TOL = 1e-2; // docs/noise/random-penalty-NOTES.md and the M1 elevation f32-floor precedent // for the same pattern. regularPatches.spec (M3a) still owns the strict, // unrelaxed check on the unchanged regular field alone. -describe("makeResourcePatches (all_patches = max(starting, regular) vs oracle)", () => { - for (const c of fixture.cases) { - it(`matches the game for ${c.resource} seed=${c.seed}`, () => { - const params = paramsByName.get(c.resource)!; - const patches = makeResourcePatches(params, { - seed0: c.seed, - controls: { frequency: 1, size: 1, richness: 1 }, - regularSkipSpan: 1, - regularSkipOffset: 0, - startingSkipSpan: 1, - startingSkipOffset: 0, - }); - const mism: { x: number; y: number; game: number; port: number; abs: number; rel: number }[] = - []; - const offenders: (typeof mism)[number][] = []; - for (let i = 0; i < fixture.positions.length; i++) { - const p = snapPosition(fixture.positions[i]); - const game = c.values[i]; - const port = patches.field(p.x, p.y); - const abs = Math.abs(port - game); - const rel = relErr(port, game); - const entry = { x: p.x, y: p.y, game, port, abs, rel }; - mism.push(entry); - if (abs >= ABS_TOL && rel >= REL_TOL) offenders.push(entry); - } - if (offenders.length > 0) { - const top = offenders - .sort((a, b) => b.rel - a.rel) - .slice(0, 12) - .map( - (m) => - ` (${m.x},${m.y}) game=${m.game.toFixed(2)} port=${m.port.toFixed(2)} abs=${m.abs.toFixed(3)} rel=${m.rel.toExponential(2)}`, - ) - .join("\n"); - throw new Error( - `${c.resource} seed=${c.seed}: ${offenders.length}/${mism.length} points fail BOTH abs<${ABS_TOL} and rel<${REL_TOL}\n${top}`, - ); - } - expect(offenders.length).toBe(0); - }); - } -}); // Anti-vacuity for the 1/256 capture-grid snap applied above. These fixtures // record sample coordinates the game never evaluated at (#186); `snapPosition` diff --git a/test/rockField.spec.ts b/test/rockField.spec.ts index 5d2ac3c2..8c552624 100644 --- a/test/rockField.spec.ts +++ b/test/rockField.spec.ts @@ -1,65 +1,13 @@ import { describe, expect, it } from "vite-plus/test"; import rockDensityFixture from "./fixtures/oracle-rock-density.seed123456.json"; import { countOffGrid, snapPosition } from "./captureGrid"; -import { makeRockDensity } from "../src/noise/rocks/rockField"; import { makeMultioctaveNoise } from "../src/noise/multioctaveNoise"; -import { makeMoisture } from "../src/noise/expressions/moisture"; -import { makeAux } from "../src/noise/expressions/aux"; import { distanceFromNearestPoint } from "../src/noise/distanceFromNearestPoint"; -import { rangeSelectBase, sliderRescale, ROCK_SEED1 } from "../src/noise/rocks/rockCatalog"; - -const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi); +import { ROCK_SEED1 } from "../src/noise/rocks/rockCatalog"; // Independently recompute max_i probability_i from the same primitives, so a // wrong seed1, penalty, multiplier, or region_box band fails loudly. This is NOT // the game oracle (that is Task 3) - it locks the wiring/composition. -function expected(seed0: number, x: number, y: number): number { - const spawn = [{ x: 0, y: 0 }]; - const noise = makeMultioctaveNoise({ - seed0, - seed1: ROCK_SEED1, - octaves: 4, - persistence: 0.9, - inputScale: 0.15, - outputScale: 1, - }); - const moisture = makeMoisture({ seed0, startingPositions: [...spawn] }); - const aux = makeAux({ seed0 }); - const rockNoise = noise(x, y) + 0.25 + 0.75 * (sliderRescale(1, 1.5) - 1); - const distance = distanceFromNearestPoint(x, y, spawn); - const rockDensity = rockNoise - Math.max(0, 1.1 - distance / 32); - const m = moisture(x, y); - const a = aux(x, y); - const moistBand = rangeSelectBase(m, 0.35, 1, 0.2, -10, 0); - const sandBand = Math.min( - rangeSelectBase(a, 0.3, 1, 0.3, -10, 0), - rangeSelectBase(m, 0, 0.3, 0.2, -10, 0), - ); - const pHuge = 0.07 * 1 * (moistBand + rockDensity - 1.7); - const pBig = 0.17 * 1 * (moistBand + rockDensity - 1.6); - const pSand = 0.1 * 1 * (sandBand + rockDensity - 1.6); - return clamp(Math.max(pHuge, pBig, pSand), 0, 1); -} - -describe("makeRockDensity", () => { - const seed0 = 123456; - const field = makeRockDensity({ seed0 }); - for (const [x, y] of [ - [300, -180], - [512, 512], - [-800, 640], - [40, 40], - ] as const) { - it(`composes max_i probability_i at (${x},${y})`, () => { - expect(field(x, y)).toBeCloseTo(expected(seed0, x, y), 10); - }); - } - it("clamps to [0,1] and is 0 on most far tiles (rocks are sparse)", () => { - const v = field(5000, 5000); - expect(v).toBeGreaterThanOrEqual(0); - expect(v).toBeLessThanOrEqual(1); - }); -}); // Reconstruct rock_density = rock_noise - max(0, 1.1 - distance/32) from the ported // primitives and compare to the game. The absolute bound was 1e-3, described as diff --git a/test/rockLattice.spec.ts b/test/rockLattice.spec.ts index a5ecc9f1..5fa8d5d6 100644 --- a/test/rockLattice.spec.ts +++ b/test/rockLattice.spec.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; -import { PLACEMENT_SALT, makePlacementRoll } from "../src/noise/placement/placementRoll"; import { ROCK_FIELD_LATTICE, latticeSnapped } from "../src/noise/rocks/rockCatalog"; -import { makeRockFields } from "../src/noise/rocks/rockField"; -import { makeVulcanusRockFields } from "../src/noise/rocks/vulcanusRockField"; /** * Coarse rock field sampling: evaluate the probability field on a lattice while @@ -57,45 +53,6 @@ describe("rock field lattice", () => { * way, but with only ~313 placements in the window that proxy is noise, not a * counter-example. */ - const TOLERANCE = 0.03; // measured worst 1.18%; a rock either way is ~0.04% / 0.3% - - for (const stride of [2, 4]) { - it(`preserves placed density at stride ${String(stride)} on Vulcanus`, () => { - const ctx = withCtxDefaults({ seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }); - const { density } = makeVulcanusRockFields(ctx); - const count = (s: number): number => { - const field = latticeSnapped(density, s); - const roll = makePlacementRoll(PLACEMENT_SALT.vulcanusRocks); - let n = 0; - for (let y = -256; y < 256; y++) - for (let x = -256; x < 256; x++) if (roll(x, y) < field(x, y)) n++; - return n; - }; - const fine = count(1); - const coarse = count(stride); - expect(Math.abs(coarse - fine) / fine).toBeLessThan(TOLERANCE); - }, 120000); - } - - for (const stride of [2, 4]) { - it(`preserves placed density at stride ${String(stride)} on Nauvis`, () => { - const { density } = makeRockFields({ seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }); - const count = (s: number): number => { - const field = latticeSnapped(density, s); - const roll = makePlacementRoll(PLACEMENT_SALT.nauvisRocks); - let n = 0; - for (let y = -256; y < 256; y++) - for (let x = -256; x < 256; x++) if (roll(x, y) < field(x, y)) n++; - return n; - }; - const fine = count(1); - const coarse = count(stride); - // Nauvis's window holds ~313 placements against Vulcanus's ~2448, so a - // single rock is 0.3% here - this case is far weaker evidence than the - // Vulcanus one and is here for planet coverage, not for its power. - expect(Math.abs(coarse - fine) / fine).toBeLessThan(TOLERANCE); - }, 120000); - } it("ships disabled, because the saving cannot pay for the clumping", () => { // Guards the CONSTANT, so enabling the lattice is a deliberate act that has diff --git a/test/startingPatches.spec.ts b/test/startingPatches.spec.ts deleted file mode 100644 index 556699f1..00000000 --- a/test/startingPatches.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { RESOURCE_CATALOG } from "../src/noise/resources/resourceCatalog"; -import { makeStartingPatches } from "../src/noise/resources/startingPatches"; - -const iron = RESOURCE_CATALOG[0]; - -describe("makeStartingPatches", () => { - const start = makeStartingPatches(iron, { - seed0: 123456, - controls: { frequency: 1, size: 1, richness: 1 }, - skipSpan: 1, - skipOffset: 0, - }); - it("produces a starting patch near spawn (field rises above basement)", () => { - let maxNear = -Infinity; - for (let y = -120; y <= 120; y += 4) - for (let x = -120; x <= 120; x += 4) maxNear = Math.max(maxNear, start.field(x, y)); - // a real starting patch peaks in the hundreds+; basement is deeply negative. - expect(maxNear).toBeGreaterThan(1); - }); - it("is basement + blob only far from spawn (no starting spots past 120)", () => { - // At 1000 tiles out, starting_modulation = 0 everywhere in-region -> no spots. - // The field is then basement + blobTerm; well below any patch peak (< ~100). - const far = start.field(1000, 1000); - expect(Number.isFinite(far)).toBe(true); - expect(far).toBeLessThan(100); - }); -}); diff --git a/test/temperature.spec.ts b/test/temperature.spec.ts deleted file mode 100644 index 4d93bd28..00000000 --- a/test/temperature.spec.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import fixture from "./fixtures/oracle-temperature.seed123456.json"; -import { countOffGrid, snapPosition } from "./captureGrid"; -import { makeTemperature } from "../src/noise/expressions/temperature"; - -describe("makeTemperature reproduces the game's temperature (temperature_basic) tree", () => { - const evalAt = makeTemperature({ seed0: fixture.seed0 }); - - it("matches the game bit-for-bit at every position", () => { - // Compared by exact f32 match count, not a bound. Every value in this - // fixture satisfies `Math.fround(v) === v`, so a bound could not tell - // "close" from "identical" (#256). - // - // This used to be two assertions behind a `< 1e-4` bound, with a long - // comment blaming 14 off-grid positions and calling for a re-capture. No - // re-capture was needed: the game truncated those coordinates onto its - // 1/256 `MapPosition` grid, and snapping them the same way grades the port - // at the point the game actually sampled. That took this fixture from - // 17/26 exact at worst 5.817e-5 to 26/26 at worst 0 - watched failing with - // the snap removed, reporting exactly 17. See `test/captureGrid.ts` for the - // evidence and the controls. - // - // `Math.fround` on the port's output is the house convention for an - // exact-match comparison (test/voronoiNoise.spec.ts:85): the tree evaluates - // in f32 internally but the entry point returns a JS number, so the - // narrowing belongs at the boundary. It is not slack - every fixture value - // is already f32, so this compares bit patterns. - let exact = 0; - let worst = 0; - let worstLabel = ""; - for (const [i, p] of fixture.positions.entries()) { - const s = snapPosition(p); - const err = Math.abs(Math.fround(evalAt(s.x, s.y)) - fixture.temperature[i]); - if (err === 0) exact++; - if (err > worst) { - worst = err; - worstLabel = `@(${p.x},${p.y})`; - } - } - expect(fixture.positions.length).toBe(26); // a regen cannot empty the loop - expect(exact, `worst ${worstLabel}`).toBe(26); - expect(worst).toBe(0); - }); - - it("still has off-grid positions for the snap to correct", () => { - // Anti-vacuity for the snap itself. If a future re-capture lands every - // position on the 1/256 grid, `snapPosition` becomes the identity here and - // should be deleted rather than left looking load-bearing. - expect(countOffGrid(fixture.positions)).toBe(14); - }); -}); - -describe("makeTemperature bias and frequency parameters", () => { - const GRID: Array<[number, number]> = [ - [0.5, 0.25], - [2200.5, 0.25], - [-1600.5, 1200.25], - [12345.75, 6789.125], - ]; - - it("defaults bias to 0 (omitted === explicit 0)", () => { - const def = makeTemperature({ seed0: 123456 }); - const explicit = makeTemperature({ seed0: 123456, bias: 0 }); - for (const [x, y] of GRID) expect(def(x, y)).toBe(explicit(x, y)); - }); - - it("shifts the result by exactly the bias, until clamped", () => { - const def = makeTemperature({ seed0: 123456 }); - const biased = makeTemperature({ seed0: 123456, bias: 5 }); - for (const [x, y] of GRID) { - expect(biased(x, y)).toBeCloseTo(Math.min(def(x, y) + 5, 50), 9); - } - }); - - it("defaults frequency to 1 (omitted === explicit 1)", () => { - const def = makeTemperature({ seed0: 123456 }); - const explicit = makeTemperature({ seed0: 123456, frequency: 1 }); - for (const [x, y] of GRID) expect(def(x, y)).toBe(explicit(x, y)); - }); - - it("stays within the [-20, 50] clamp bounds", () => { - const evalAt = makeTemperature({ seed0: 123456, bias: 1000 }); - for (const [x, y] of GRID) { - expect(evalAt(x, y)).toBeLessThanOrEqual(50); - expect(evalAt(x, y)).toBeGreaterThanOrEqual(-20); - } - const evalLow = makeTemperature({ seed0: 123456, bias: -1000 }); - for (const [x, y] of GRID) { - expect(evalLow(x, y)).toBeGreaterThanOrEqual(-20); - } - }); -}); diff --git a/test/tileCatalog.spec.ts b/test/tileCatalog.spec.ts deleted file mode 100644 index e9c7bd12..00000000 --- a/test/tileCatalog.spec.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { makeTileCatalog } from "../src/noise/tiles/catalog"; -import { waterBase } from "../src/noise/tiles/helpers"; - -// Task 9: the 21 Nauvis tiles as data, transcribed verbatim from the design -// spec's tile table (docs/superpowers/specs/2026-07-19-milestone2-climate-terrain-design.md). -// Each tile's probability(env) is the game's probability_expression, composed -// from Task 8's helpers over env = { x, y, elevation, aux, moisture }. - -const EXPECTED_NAMES = [ - "deepwater", - "water", - "grass-1", - "grass-2", - "grass-3", - "grass-4", - "dry-dirt", - "dirt-1", - "dirt-2", - "dirt-3", - "dirt-4", - "dirt-5", - "dirt-6", - "dirt-7", - "sand-1", - "sand-2", - "sand-3", - "red-desert-0", - "red-desert-1", - "red-desert-2", - "red-desert-3", -]; - -const LAND_ENV = { x: 12.5, y: -37.25, elevation: 3.2, aux: 0.42, moisture: 0.31 }; - -describe("makeTileCatalog", () => { - it("has exactly 21 tiles with the expected names, in spec order", () => { - const catalog = makeTileCatalog(123456); - expect(catalog).toHaveLength(21); - expect(catalog.map((t) => t.name)).toEqual(EXPECTED_NAMES); - }); - - it("deepwater and water have no noise layer and reduce to waterBase", () => { - const catalog = makeTileCatalog(123456); - const deepwater = catalog.find((t) => t.name === "deepwater")!; - const water = catalog.find((t) => t.name === "water")!; - - // water_base(-2, 200): 0 at the boundary elevation=-2, 200 well below it. - expect(deepwater.probability({ x: 0, y: 0, elevation: -2, aux: 0, moisture: 0 })).toBe( - waterBase(-2, -2, 200), - ); - expect(deepwater.probability({ x: 0, y: 0, elevation: -3, aux: 0, moisture: 0 })).toBe( - waterBase(-3, -2, 200), - ); - // Same (x,y) must not matter for water tiles - no noise_layer_noise term. - expect(deepwater.probability({ x: 999, y: -999, elevation: -3, aux: 0, moisture: 0 })).toBe( - waterBase(-3, -2, 200), - ); - - // water_base(0, 100). - expect(water.probability({ x: 0, y: 0, elevation: 0, aux: 0, moisture: 0 })).toBe( - waterBase(0, 0, 100), - ); - expect(water.probability({ x: 0, y: 0, elevation: -0.5, aux: 0, moisture: 0 })).toBe( - waterBase(-0.5, 0, 100), - ); - expect(water.probability({ x: 555, y: -444, elevation: -0.5, aux: 0, moisture: 0 })).toBe( - waterBase(-0.5, 0, 100), - ); - }); - - it("spot-checks map_color against the spec table", () => { - const catalog = makeTileCatalog(123456); - const byName = new Map(catalog.map((t) => [t.name, t])); - - expect(byName.get("deepwater")!.color).toEqual([38, 64, 73, 255]); - expect(byName.get("water")!.color).toEqual([51, 83, 95, 255]); - expect(byName.get("grass-1")!.color).toEqual([55, 53, 11, 255]); - expect(byName.get("sand-1")!.color).toEqual([138, 103, 58, 255]); - expect(byName.get("red-desert-3")!.color).toEqual([128, 93, 52, 255]); - // Spec's table lists sand-2 and red-desert-3 with the identical map_color - // (128,93,52) - transcribed verbatim, not a typo we should "fix". - expect(byName.get("sand-2")!.color).toEqual([128, 93, 52, 255]); - }); - - it("every tile's probability() evaluates without throwing for a land env", () => { - // Water tiles are deliberately excluded above their maxElevation (waterBase - // returns -Infinity - "never selected by the resolver's argmax", per - // src/noise/tiles/helpers.ts), so LAND_ENV's elevation=3.2 correctly makes - // deepwater/water non-finite here; that is the intended exclusion, not a bug. - const catalog = makeTileCatalog(123456); - for (const tile of catalog) { - let value: number = NaN; - expect(() => { - value = tile.probability(LAND_ENV); - }, `${tile.name} should not throw`).not.toThrow(); - expect(Number.isNaN(value), `${tile.name} probability should not be NaN`).toBe(false); - } - }); - - it("the 19 land tiles' probability() is finite for a land env", () => { - const catalog = makeTileCatalog(123456); - for (const tile of catalog) { - if (tile.name === "deepwater" || tile.name === "water") continue; - const value = tile.probability(LAND_ENV); - expect(Number.isFinite(value), `${tile.name} probability should be finite`).toBe(true); - } - }); - - it("every tile's color is a 4-tuple of 0-255 ints with alpha 255", () => { - const catalog = makeTileCatalog(123456); - for (const tile of catalog) { - expect(tile.color, `${tile.name} color length`).toHaveLength(4); - const [r, g, b, a] = tile.color; - for (const c of [r, g, b]) { - expect(Number.isInteger(c), `${tile.name} color component integer`).toBe(true); - expect(c, `${tile.name} color component range`).toBeGreaterThanOrEqual(0); - expect(c, `${tile.name} color component range`).toBeLessThanOrEqual(255); - } - expect(a, `${tile.name} alpha`).toBe(255); - } - }); - - it("land tiles vary with (x,y) via their noise_layer_noise term", () => { - const catalog = makeTileCatalog(123456); - const grass1 = catalog.find((t) => t.name === "grass-1")!; - const a = grass1.probability({ x: 0, y: 0, elevation: 3, aux: 0.42, moisture: 0.31 }); - const b = grass1.probability({ x: 4000, y: -2500, elevation: 3, aux: 0.42, moisture: 0.31 }); - expect(a).not.toBe(b); - }); - - it("distinct land tiles use distinct noise_layer_noise seeds (independent closures)", () => { - const catalog = makeTileCatalog(123456); - const grass1 = catalog.find((t) => t.name === "grass-1")!; - const grass2 = catalog.find((t) => t.name === "grass-2")!; - // Same (x,y), but different climate boxes AND different layer seeds - just - // assert they are not forced to be identical (would indicate a shared/bugged - // layer closure). - const envA = { x: 17, y: -31, elevation: 3, aux: 0.6, moisture: 0.6 }; - expect(grass1.probability(envA)).not.toBe(grass2.probability(envA)); - }); -}); diff --git a/test/tileHelpers.spec.ts b/test/tileHelpers.spec.ts deleted file mode 100644 index f360a11b..00000000 --- a/test/tileHelpers.spec.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { expressionInRangeBase, makeNoiseLayerNoise, waterBase } from "../src/noise/tiles/helpers"; - -// Task 8: the three tile-autoplace helper functions that the 21-tile catalog -// (Task 9) is built from. Pure composition over Task 1's expressionInRange and -// the multioctave primitive - no oracle capture needed here (the underlying -// primitives are already validated against the game). - -describe("waterBase", () => { - // Game: water_base(max_elevation, influence) = - // if(max_elevation >= elevation, influence * min(max_elevation - elevation, 1), -inf) - // Signature here puts elevation (the runtime value) first, then the tile's - // max_elevation/influence constants. - it("returns influence * 1 when elevation is well below max_elevation (clamped headroom)", () => { - // elevation=-3, maxElevation=-2: -2 >= -3 is true; headroom = -2-(-3) = 1, min(1,1)=1. - expect(waterBase(-3, -2, 200)).toBe(200); - }); - - it("returns -Infinity once elevation exceeds max_elevation", () => { - // elevation=0, maxElevation=-2: -2 >= 0 is false. - expect(waterBase(0, -2, 200)).toBe(-Infinity); - }); - - it("scales influence by the (sub-1) headroom near the boundary", () => { - // elevation=-2.5, maxElevation=-2: headroom = -2-(-2.5) = 0.5, min(0.5,1)=0.5. - expect(waterBase(-2.5, -2, 200)).toBe(100); - }); - - it("treats elevation exactly at max_elevation as still water (boundary inclusive)", () => { - // elevation == maxElevation: headroom = 0, min(0,1) = 0. - expect(waterBase(-2, -2, 200)).toBe(0); - }); -}); - -describe("expressionInRangeBase", () => { - // Game: expression_in_range_base(aux_from, moisture_from, aux_to, moisture_to) - // = expression_in_range(20, 1, aux, moisture, aux_from, moisture_from, aux_to, moisture_to) - // i.e. peakMultiplier=20, peakMaximum=1, dims regrouped as [aux, moisture]. - it("is delegation over expressionInRange(20, 1, [aux, moisture], froms, tos)", () => { - // Well inside both ranges (center) should hit the pmax=1 plateau. - const inside = expressionInRangeBase(0, 0, -0.5, -0.5, 0.5, 0.5); - expect(inside).toBe(1); - }); - - it("returns a value inside the range that exceeds a value outside the range", () => { - const inside = expressionInRangeBase(0, 0, -0.5, -0.5, 0.5, 0.5); - const outside = expressionInRangeBase(2, 2, -0.5, -0.5, 0.5, 0.5); - expect(inside).toBeGreaterThan(outside); - }); - - it("falls off linearly with slope 20 just outside an edge (unclamped, negative)", () => { - // aux=0.6 is 0.1 past the aux_to=0.5 edge; moisture stays centered (inside). - // m = min(edgeDist_aux, edgeDist_moisture) = min(-0.1, 0.5) = -0.1 -> 20 * -0.1 = -2. - // - // Precision 6, not 10: `expressionInRange` rounds every step to f32 (#162), and - // `0.5 - 0.6` is not representable there, so this returns -2.000000476837158. - // The exact decimal is the wrong target for an f32 computation - see the note - // in `expressionInRange.spec.ts`. Bit-exactness lives in that file's oracle - // sweeps, which are exactly 0; this one guards the slope and the sign. - expect(expressionInRangeBase(0.6, 0, -0.5, -0.5, 0.5, 0.5)).toBeCloseTo(-2, 6); - }); -}); - -describe("makeNoiseLayerNoise", () => { - // Game: noise_layer_noise(seed) = multioctave_noise{persistence=0.7, seed1=seed, - // octaves=4, input_scale=1/6, output_scale=2/3} - it("returns finite values at various coordinates", () => { - const noise = makeNoiseLayerNoise(123456, 19); - const samples: Array<[number, number]> = [ - [0, 0], - [100, 0], - [0, 100], - [-50, 37.5], - [1234, -987], - ]; - for (const [x, y] of samples) { - const v = noise(x, y); - expect(Number.isFinite(v), `noise(${x}, ${y}) should be finite`).toBe(true); - } - }); - - it("stays within a generous bound derived from the output scale (roughly [-8/3, 8/3])", () => { - const noise = makeNoiseLayerNoise(123456, 19); - // outputScale = 2/3; the normalised sum is not hard-bounded, but basis noise - // is roughly unit-range, so a factor-of-4 margin over 2/3 is a sane sanity net. - const bound = (2 / 3) * 4; - for (let x = -200; x <= 200; x += 47) { - for (let y = -200; y <= 200; y += 53) { - const v = noise(x, y); - expect(Math.abs(v), `|noise(${x}, ${y})|`).toBeLessThan(bound); - } - } - }); - - it("is deterministic for the same seed pair and differs across seed1", () => { - const noiseA = makeNoiseLayerNoise(123456, 19); - const noiseA2 = makeNoiseLayerNoise(123456, 19); - const noiseB = makeNoiseLayerNoise(123456, 20); - expect(noiseA(10, 10)).toBe(noiseA2(10, 10)); - expect(noiseA(10, 10)).not.toBe(noiseB(10, 10)); - }); -}); diff --git a/test/treeCatalog.spec.ts b/test/treeCatalog.spec.ts deleted file mode 100644 index 19675d57..00000000 --- a/test/treeCatalog.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { crc32 } from "../src/codec/crc32"; -import { TREE_SMALL_NOISE_SEED1, TREE_SPECIES } from "../src/noise/trees/treeCatalog"; - -const crcOf = (s: string): number => crc32(new TextEncoder().encode(s)); - -describe("TREE_SPECIES catalog", () => { - it("has all 15 Nauvis tree species", () => { - expect(TREE_SPECIES).toHaveLength(15); - }); - - it("names every species exactly once", () => { - expect(new Set(TREE_SPECIES.map((s) => s.name)).size).toBe(15); - expect(new Set(TREE_SPECIES.map((s) => s.seed1Name)).size).toBe(15); - }); - - // Factorio hashes a STRING seed1 with crc32 - see nauvisShared.ts:9. The - // hardcoded numbers exist so src/noise never imports the codec at runtime; - // this test is what keeps them honest. - it("hardcodes seed1 as crc32 of the species' seed1 string", () => { - for (const s of TREE_SPECIES) { - expect(s.seed1, s.seed1Name).toBe(crcOf(s.seed1Name)); - } - expect(TREE_SMALL_NOISE_SEED1).toBe(crcOf("tree-small")); - }); - - it("derives the expression name from the seed1 string", () => { - // "tree-02-red" -> "tree_02_red": the Lua names differ only in separator. - for (const s of TREE_SPECIES) { - expect(s.name).toBe(s.seed1Name.replace(/-/g, "_")); - } - }); - - it("keeps every parameter in the range trees.lua uses", () => { - for (const s of TREE_SPECIES) { - expect(s.cap, s.name).toBeGreaterThan(0); - expect(s.cap, s.name).toBeLessThanOrEqual(0.45); - expect(s.outputScale, s.name).toBeGreaterThanOrEqual(0.5); - expect(s.outputScale, s.name).toBeLessThanOrEqual(0.8); - expect(s.inputScaleDiv, s.name).toBeGreaterThanOrEqual(22); - expect(s.inputScaleDiv, s.name).toBeLessThanOrEqual(40); - } - }); - - it("orders ramps so the tops sit between the bottoms", () => { - for (const s of TREE_SPECIES) { - for (const [fb, ft, tt, tb] of [s.tempRamp, s.moistRamp]) { - expect(fb, s.name).toBeLessThan(ft); - expect(ft, s.name).toBeLessThanOrEqual(tt); - expect(tt, s.name).toBeLessThan(tb); - } - } - }); - - it("sorts by descending cap so the early-out raises `best` fastest", () => { - const caps = TREE_SPECIES.map((s) => s.cap); - expect(caps).toEqual([...caps].sort((a, b) => b - a)); - }); - - // Regression guard for a bug that was invisible for four tasks: tree_05 and - // tree_07 use `- 0.45 + 0.2 * control:trees:size` where the other 13 species - // use `- 0.5 + 0.2 * control:trees:size` (trees.lua @ 2.1.11, caught by the - // oracle - see test/treeOracle.spec.ts). Do not "simplify" this back to a - // single shared constant. - it("sets sizeOffset to 0.45 for exactly tree_05 and tree_07, and 0.5 for the rest", () => { - const exceptions = new Set(["tree_05", "tree_07"]); - for (const s of TREE_SPECIES) { - expect(s.sizeOffset, s.name).toBe(exceptions.has(s.name) ? 0.45 : 0.5); - } - expect( - TREE_SPECIES.filter((s) => s.sizeOffset === 0.45) - .map((s) => s.name) - .sort(), - ).toEqual(["tree_05", "tree_07"]); - }); -}); diff --git a/test/treeCatalogExpressions.spec.ts b/test/treeCatalogExpressions.spec.ts deleted file mode 100644 index 10782524..00000000 --- a/test/treeCatalogExpressions.spec.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import fixture from "./fixtures/tree-expressions.2.1.11.json"; -import { TREE_SPECIES, type TreeSpecies } from "../src/noise/trees/treeCatalog"; - -/** - * Reconstruct-and-compare: rebuild each species' Lua expression string from its - * catalog row and diff it against the real game data, character for character. - * - * This exists because the ORIGINAL uniformity claim was "verified mechanically" - * by filtering the common terms out of every `tree_0*` block and observing that - * nothing was left - but the filter dropped every line containing - * `control:trees:size`, which is exactly where the one per-species divergence - * lived (tree_05/tree_07 use -0.45, not -0.5). Four tasks were built on that - * wrong premise before the oracle caught it. - * - * A filter-then-compare check can only find what its filter lets through. This - * one has no filter: if a single constant in the game's string is not accounted - * for by a catalog field, the strings differ and the test fails. It also needs - * no Factorio install - the fixture is checked in. - */ -const EXPRESSIONS: Record = fixture.expressions; - -/** Lua prints 1 as "1", not "1.0" - match its number formatting. */ -const num = (n: number): string => String(n); - -/** - * The shape every one of the 15 species shares. Mirrors the layout in - * `base/prototypes/entity/trees.lua`; the whitespace looks odd because Lua's - * `\z` swallowed the newline plus the following indentation. - */ -function reconstruct(s: TreeSpecies): string { - const [tb, tt, tot, tob] = s.tempRamp; - const [mb, mt, mot, mob] = s.moistRamp; - return ( - `min(${num(s.cap)}, trees_forest_path_cutout_faded,` + - `min(0,` + - `asymmetric_ramps{input=temperature, from_bottom=${num(tb)}, from_top=${num(tt)}, ` + - `to_top=${num(tot)}, to_bottom=${num(tob)}},` + - `asymmetric_ramps{input=moisture, from_bottom=${num(mb)}, from_top=${num(mt)}, ` + - `to_top=${num(mot)}, to_bottom=${num(mob)}})` + - `+ min(0, distance/20 - 3)` + - `- ${num(s.sizeOffset)} + 0.2 * control:trees:size` + - `+ tree_small_noise * 0.1` + - `+ multioctave_noise{x = x,y = y,persistence = 0.65,seed0 = map_seed,` + - `seed1 = '${s.seed1Name}',octaves = 3,` + - `input_scale = 1/${num(s.inputScaleDiv)} * control:trees:frequency,` + - `output_scale = ${num(s.outputScale)}})` - ); -} - -describe("tree catalog vs. the real 2.1.11 expressions", () => { - it.each(TREE_SPECIES.map((s) => [s.name, s] as const))( - "%s reconstructs the game's expression exactly", - (name, species) => { - expect(EXPRESSIONS[name], `${name} missing from the fixture`).toBeDefined(); - expect(reconstruct(species)).toBe(EXPRESSIONS[name]); - }, - ); - - // The five the port deliberately leaves out. They are NOT decoratives (a claim - // the docs used to make): all five are real `type = "tree"` entity prototypes - // on `control = "trees"` that the game's preview charts like any other tree. - // They are excluded on measured contribution - max density gain 0.038, ~1.5% - // of max alpha. `tree_dry` is not even an independent species; it is derived, - // `0.2 * max(tree_01, tree_09, ...)`. See docs/noise/trees-NOTES.md. - const EXCLUDED = [ - "tree_dead_desert", - "tree_dead_dry_hairy", - "tree_dead_grey_trunk", - "tree_dry", - "tree_dry_hairy", - ]; - - it("accounts for every tree_* expression in the game data", () => { - // If 2.1.x ever adds a 16th species, it lands in neither list and this fails - // rather than being silently absent from the render. - const ported = TREE_SPECIES.map((s) => s.name); - expect([...ported, ...EXCLUDED].sort()).toEqual(Object.keys(EXPRESSIONS).sort()); - }); - - it("pins the sizeOffset exception set", () => { - // Belt-and-braces with treeCatalog.spec.ts: prove from the GAME DATA (not the - // catalog) that exactly tree_05 and tree_07 carry the -0.45 offset. - const fromGame = Object.entries(EXPRESSIONS) - .filter(([, e]) => e.includes("- 0.45 + 0.2 * control:trees:size")) - .map(([n]) => n) - .sort(); - expect(fromGame).toEqual(["tree_05", "tree_07"]); - }); -}); diff --git a/test/treeField.spec.ts b/test/treeField.spec.ts deleted file mode 100644 index e0398c06..00000000 --- a/test/treeField.spec.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { asymmetricRamps } from "../src/noise/trees/asymmetricRamps"; -import { f32 } from "../src/noise/eval/f32"; -import { makeMoisture } from "../src/noise/expressions/moisture"; -import { makeMultioctaveNoise } from "../src/noise/multioctaveNoise"; -import { makeTemperature } from "../src/noise/expressions/temperature"; -import { makeTreeShared } from "../src/noise/trees/treeShared"; -import { makeTreeDensity, makeTreeSpeciesFields } from "../src/noise/trees/treeField"; -import { TREE_SPECIES } from "../src/noise/trees/treeCatalog"; - -const GRID: Array<[number, number]> = [ - [0.5, 0.25], - [220.5, -180.25], - [-1600.5, 1200.25], - [900.5, 900.25], - [12345.75, 6789.125], -]; - -describe("makeTreeSpeciesFields", () => { - it("builds one field per catalog species, in catalog order", () => { - const fields = makeTreeSpeciesFields({ seed0: 123456 }); - expect(fields.map((f) => f.species.name)).toEqual(TREE_SPECIES.map((s) => s.name)); - }); - - it("reproduces the trees.lua expression term for term", () => { - // An independent re-implementation of tree_01, built straight from the Lua, - // so a refactor of treeField cannot quietly change the formula. - const seed0 = 123456; - const temperature = makeTemperature({ seed0 }); - const moisture = makeMoisture({ seed0 }); - const { smallNoise, forestPathCutoutFaded } = makeTreeShared({ seed0 }); - const row = TREE_SPECIES.find((s) => s.name === "tree_01")!; - const noise = makeMultioctaveNoise({ - seed0, - seed1: row.seed1, - octaves: 3, - persistence: 0.65, - inputScale: 1 / row.inputScaleDiv, - outputScale: row.outputScale, - }); - - const expected = (x: number, y: number): number => { - // `distance_from_nearest_point` against the origin spawn, in the f32 the - // game's op computes and stores. Written out rather than calling the op, - // so this stays an independent re-implementation - but with the same - // arithmetic, because `Math.hypot` in f64 is a different number - // (see src/noise/distanceFromNearestPoint.ts, corrected 2026-08-18). - const distance = f32(Math.sqrt(f32(f32(f32(x) * f32(x)) + f32(f32(y) * f32(y))))); - const climate = Math.min( - 0, - asymmetricRamps(temperature(x, y), ...row.tempRamp), - asymmetricRamps(moisture(x, y), ...row.moistRamp), - ); - const sum = - climate + - Math.min(0, distance / 20 - 3) - - 0.5 + - 0.2 * 1 + - smallNoise(x, y) * 0.1 + - noise(x, y); - return Math.min(row.cap, forestPathCutoutFaded(x, y), sum); - }; - - const actual = makeTreeSpeciesFields({ seed0 }).find((f) => f.species.name === "tree_01")!; - for (const [x, y] of GRID) expect(actual.evalAt(x, y)).toBeCloseTo(expected(x, y), 12); - }); - - it("scales the species input_scale by control:trees:frequency", () => { - const base = makeTreeSpeciesFields({ seed0: 123456 })[0]; - const fast = makeTreeSpeciesFields({ seed0: 123456, treesFrequency: 3 })[0]; - const differs = GRID.some(([x, y]) => base.evalAt(x, y) !== fast.evalAt(x, y)); - expect(differs).toBe(true); - }); - - it("shifts every species by 0.2 per unit of control:trees:size, until capped", () => { - const base = makeTreeSpeciesFields({ seed0: 123456 }); - const big = makeTreeSpeciesFields({ seed0: 123456, treesSize: 2 }); - for (let i = 0; i < base.length; i++) { - const cap = base[i].species.cap; - for (const [x, y] of GRID) { - // The +0.2 lands inside the shared sum, so it shifts the result unless the - // cap or the cutout is the binding term. - expect(big[i].evalAt(x, y)).toBeLessThanOrEqual(cap + 1e-12); - expect(big[i].evalAt(x, y)).toBeGreaterThanOrEqual(base[i].evalAt(x, y) - 1e-12); - } - } - // Verify that the treesSize parameter is actually live by proving big differs from base somewhere. - const differs = base.some((_, i) => - GRID.some(([x, y]) => big[i].evalAt(x, y) !== base[i].evalAt(x, y)), - ); - expect(differs).toBe(true); - }); - - // Trees are the ONLY consumer of `temperature` (tile selection is aux + - // moisture), so if these levers are not threaded through, an imported exchange - // string carrying a temperature override renders the wrong forests silently. - // There is no UI for them, so this test is the only thing holding the wiring. - it("threads control:temperature:frequency through to the species climate ramp", () => { - const base = makeTreeDensity({ seed0: 123456 }); - const warped = makeTreeDensity({ seed0: 123456, temperatureFrequency: 4 }); - expect(GRID.some(([x, y]) => base(x, y) !== warped(x, y))).toBe(true); - }); - - it("threads control:temperature:bias through to the species climate ramp", () => { - const base = makeTreeDensity({ seed0: 123456 }); - // Large enough to push temperature out of every species' tempRamp window, - // which drives the climate term - and so the density - to 0 everywhere. - const frozen = makeTreeDensity({ seed0: 123456, temperatureBias: -1000 }); - expect(GRID.some(([x, y]) => base(x, y) > 0)).toBe(true); - expect(GRID.every(([x, y]) => frozen(x, y) === 0)).toBe(true); - }); -}); - -describe("makeTreeDensity", () => { - it("is the clamped max over every species", () => { - const fields = makeTreeSpeciesFields({ seed0: 123456 }); - const density = makeTreeDensity({ seed0: 123456 }); - for (const [x, y] of GRID) { - const expected = Math.min(1, Math.max(0, ...fields.map((f) => f.evalAt(x, y)))); - expect(density(x, y)).toBeCloseTo(expected, 12); - } - }); - - it("never leaves [0, 1]", () => { - const density = makeTreeDensity({ seed0: 123456 }); - for (let x = -2000; x <= 2000; x += 137) { - for (let y = -2000; y <= 2000; y += 149) { - const d = density(x + 0.5, y + 0.25); - expect(d).toBeGreaterThanOrEqual(0); - expect(d).toBeLessThanOrEqual(1); - } - } - }); - - it("stays a probability far from spawn, never saturating to a mask", () => { - // min(0, distance/20 - 3) saturates at 0 past distance 60, so distance does - // NOT suppress far-field trees - but the caps are all <= 0.45, so density can - // never reach 1. This pins that the field stays a probability, not a mask. - const density = makeTreeDensity({ seed0: 123456 }); - let maxSeen = 0; - for (let x = -3000; x <= 3000; x += 211) { - for (let y = -3000; y <= 3000; y += 223) { - maxSeen = Math.max(maxSeen, density(x + 0.5, y + 0.25)); - } - } - expect(maxSeen).toBeGreaterThan(0); - expect(maxSeen).toBeLessThanOrEqual(0.45); - }); -}); diff --git a/test/treeFieldEarlyOut.spec.ts b/test/treeFieldEarlyOut.spec.ts deleted file mode 100644 index d08ac63b..00000000 --- a/test/treeFieldEarlyOut.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { basisNoise, basisNoiseTablesFromSeed } from "../src/noise/basisNoise"; -import { - BASIS_ABS_MAX, - makeTreeDensity, - makeTreeSpeciesFields, -} from "../src/noise/trees/treeField"; -import { TREE_SPECIES } from "../src/noise/trees/treeCatalog"; - -/** The reference: full evaluation of every species, no skipping. */ -const fullDensity = - (seed0: number) => - (x: number, y: number): number => { - const fields = makeTreeSpeciesFields({ seed0 }); - return Math.min(1, Math.max(0, ...fields.map((f) => f.evalAt(x, y)))); - }; - -describe("tree density early-out", () => { - it("is bit-identical to full evaluation across seeds and regions", () => { - for (const seed0 of [123456, 777771, 1]) { - const fast = makeTreeDensity({ seed0 }); - const slow = fullDensity(seed0); - for (let x = -3000; x <= 3000; x += 271) { - for (let y = -3000; y <= 3000; y += 293) { - const wx = x + 0.5; - const wy = y + 0.25; - expect(fast(wx, wy), `seed ${seed0} @(${wx},${wy})`).toBe(slow(wx, wy)); - } - } - } - }, 120000); - - it("is bit-identical at non-default control levers too", () => { - const params = { seed0: 123456, treesFrequency: 3, treesSize: 2 }; - const fast = makeTreeDensity(params); - const fields = makeTreeSpeciesFields(params); - for (let x = -1500; x <= 1500; x += 173) { - for (let y = -1500; y <= 1500; y += 181) { - const wx = x + 0.5; - const wy = y + 0.25; - const slow = Math.min(1, Math.max(0, ...fields.map((f) => f.evalAt(wx, wy)))); - expect(fast(wx, wy), `@(${wx},${wy})`).toBe(slow); - } - } - }); - - it("bounds basisNoise conservatively", () => { - // The early-out is only sound if no octave can exceed BASIS_ABS_MAX. Sample - // the actual tree seeds hard and assert headroom remains. - let observed = 0; - for (const s of TREE_SPECIES.slice(0, 5)) { - const t = basisNoiseTablesFromSeed(123456, s.seed1); - for (let i = 0; i < 200000; i++) { - const x = (i % 991) * 0.31 - 150; - const y = Math.floor(i / 991) * 0.43 - 150; - observed = Math.max(observed, Math.abs(basisNoise(x, y, t))); - } - } - expect(observed).toBeLessThan(BASIS_ABS_MAX); - }); -}); diff --git a/test/treeOracle.spec.ts b/test/treeOracle.spec.ts deleted file mode 100644 index 75efecff..00000000 --- a/test/treeOracle.spec.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import fixture from "./fixtures/oracle-trees.seed123456.json"; -import controlFixture from "./fixtures/oracle-trees-controls.seed123456.json"; -import { countOffGrid, snapPosition } from "./captureGrid"; -import { makeTreeShared } from "../src/noise/trees/treeShared"; -import { makeTreeSpeciesFields } from "../src/noise/trees/treeField"; - -const { seed0, positions, values } = fixture as { - seed0: number; - positions: Array<{ x: number; y: number }>; - values: Record; -}; - -/** - * Tolerance idiom borrowed from test/resourcePatches.spec.ts: an absolute bound - * plus a relative escape, so a large-magnitude point cannot mask a small one. - * - * The "far-field basisNoise f32 floor" this used to guard against was mostly not - * a precision floor at all. 14 of these 26 positions were CAPTURED off the game's - * 1/256 MapPosition grid, so the game evaluated at a different point than the - * fixture records (#186). Every sample coordinate below is snapped the way the - * game does before evaluation - see test/captureGrid.ts - and the bounds fell by - * 76x to 356x as a result. - */ -const agrees = (actual: number, expected: number, abs: number, rel: number): boolean => - Math.abs(actual - expected) < abs || Math.abs(actual - expected) < rel * Math.abs(expected); - -describe("tree shared fields match the game", () => { - const { smallNoise, forestPathCutoutFaded } = makeTreeShared({ seed0 }); - - it.each([ - ["tree_small_noise", smallNoise], - ["trees_forest_path_cutout_faded", forestPathCutoutFaded], - ] as const)("reproduces %s to the noise floor", (name, evalAt) => { - let worst = 0; - let label = ""; - positions.forEach((raw, i) => { - const p = snapPosition(raw); - const err = Math.abs(evalAt(p.x, p.y) - values[name][i]); - if (err > worst) { - worst = err; - label = `@(${p.x},${p.y})`; - } - }); - // Re-measured 2026-08-18 with the sample coordinate snapped onto the game's - // 1/256 grid: **tree_small_noise is now bit-exact at all 26 positions, worst - // 0**, and trees_forest_path_cutout_faded is 4.071e-8. Before the snap they - // were 9.233e-4 and 6.012e-5, and the worst sat on an off-grid ring point - - // which the old comment read as "where the f32 coordinate floor inside - // basisNoise bites hardest". It was the capture, not basisNoise. - expect(worst, `${name} worst ${label}`).toBeLessThan(6e-8); - }); -}); - -describe("every tree species matches the game", () => { - const fields = makeTreeSpeciesFields({ seed0 }); - - it("covers all 15 species in the fixture", () => { - // The count the title claims is now asserted rather than described (#144), - // and each column is checked for LENGTH rather than mere existence - - // `toBeDefined()` was satisfied by an empty array, which would have let a - // fixture that covers no positions at all pass a test named "covers". - expect(fields).toHaveLength(15); - for (const f of fields) - expect(values[f.species.name], f.species.name).toHaveLength(positions.length); - }); - - it.each(fields.map((f) => [f.species.name, f] as const))( - "reproduces %s to the noise floor", - (name, field) => { - let worst = 0; - let label = ""; - positions.forEach((raw, i) => { - const p = snapPosition(raw); - const err = Math.abs(field.evalAt(p.x, p.y) - values[name][i]); - if (err > worst) { - worst = err; - label = `@(${p.x},${p.y})`; - } - }); - // Species values live in roughly [-3, 0.45]; the dominant error source is - // Re-measured 2026-08-18 with the sample coordinate snapped onto the - // game's 1/256 grid (test/captureGrid.ts): worst across all 15 species is - // 2.593e-6 (tree_05), against 7.443e-4 before. For every one of the 17 - // arrays in this fixture the pre-snap worst sat on an off-grid row; the - // per-species on-grid worst ran 4.1e-7 to 2.2e-6 while the off-grid worst - // ran 2.0e-4 to 7.4e-4. Calibrated just above the measured worst; do not - // loosen without a new measurement. - expect(worst, `${name} worst ${label}`).toBeLessThan(4e-6); - }, - ); - - it("agrees on the composed max at every sampled point", () => { - positions.forEach((raw, i) => { - const p = snapPosition(raw); - const expected = Math.min(1, Math.max(0, ...fields.map((f) => values[f.species.name][i]))); - const actual = Math.min(1, Math.max(0, ...fields.map((f) => f.evalAt(p.x, p.y)))); - // Observed worst absolute gap (2026-07-21, all 15 species incl. tree_05/ - // tree_07): 1.03e-4 (@(-1696.6,1697.3)); the relative escape guards the - // far-ring basisNoise f32 floor the same way as the per-species checks - // above. Calibrated just above the observed worst; do not loosen the - // absolute bound above 2e-4 without a new observed-worst measurement. - expect(agrees(actual, expected, 3e-7, 1e-2), `@(${p.x},${p.y})`).toBe(true); - }); - }); -}); - -describe("control:trees levers match the game", () => { - const f = controlFixture as { - seed0: number; - treesFrequency: number; - treesSize: number; - positions: Array<{ x: number; y: number }>; - values: Record; - }; - const fields = makeTreeSpeciesFields({ - seed0: f.seed0, - treesFrequency: f.treesFrequency, - treesSize: f.treesSize, - }); - - it.each(Object.keys(f.values))("reproduces %s at frequency 3 / size 2", (name) => { - const field = fields.find((x) => x.species.name === name)!; - let worst = 0; - let label = ""; - f.positions.forEach((raw, i) => { - const p = snapPosition(raw); - const err = Math.abs(field.evalAt(p.x, p.y) - f.values[name][i]); - if (err > worst) { - worst = err; - label = `@(${p.x},${p.y})`; - } - }); - // Observed worst (2026-07-21, Factorio 2.1.11, seed 123456, control:trees - // frequency=3 size=2): tree_01 8.82e-4 (@(0.5,-1199.75)), tree_08 6.12e-4, - // tree_09_red 1.01e-4 - the same basisNoise f32-floor order of magnitude as - // the default-lever fixture. Calibrated just above the observed worst - // (tree_01); do not loosen above 1e-3. - expect(worst, `${name} worst ${label}`).toBeLessThan(2e-5); - }); - - it("differs from the default-lever field, so the levers are actually live", () => { - const base = makeTreeSpeciesFields({ seed0: f.seed0 }); - const name = "tree_01"; - const a = base.find((x) => x.species.name === name)!; - const b = fields.find((x) => x.species.name === name)!; - const differs = f.positions.some((p) => a.evalAt(p.x, p.y) !== b.evalAt(p.x, p.y)); - expect(differs).toBe(true); - }); -}); - -// Anti-vacuity for the 1/256 capture-grid snap applied above. These fixtures -// record sample coordinates the game never evaluated at (#186); `snapPosition` -// recovers where it did. If a re-capture ever lands every position on the grid -// these counts reach 0, at which point the snap is the identity and should be -// deleted rather than left looking load-bearing. See test/captureGrid.ts. -describe("capture-grid snap is not vacuous", () => { - it("oracle-trees still has off-grid positions", () => { - expect(countOffGrid(positions)).toBe(14); - }); - it("oracle-trees-controls still has off-grid positions", () => { - expect(countOffGrid(controlFixture.positions)).toBe(7); - }); -}); diff --git a/test/treeShared.spec.ts b/test/treeShared.spec.ts deleted file mode 100644 index 3570c221..00000000 --- a/test/treeShared.spec.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { makeNauvisShared } from "../src/noise/expressions/nauvisShared"; -import { makeMultioctaveNoise } from "../src/noise/multioctaveNoise"; -import { TREE_SMALL_NOISE_SEED1 } from "../src/noise/trees/treeCatalog"; -import { makeTreeShared } from "../src/noise/trees/treeShared"; - -const GRID: Array<[number, number]> = [ - [0.5, 0.25], - [220.5, -180.25], - [-1600.5, 1200.25], - [12345.75, 6789.125], -]; - -describe("makeTreeShared", () => { - it("builds tree_small_noise as a flat-input-scale 3-octave multioctave", () => { - // noise-programs.lua:427 - persistence 0.75, octaves 3, input_scale 0.2, - // output_scale 0.5. input_scale is FLAT: unlike each species' own noise term, - // tree_small_noise is NOT scaled by control:trees:frequency. - const expected = makeMultioctaveNoise({ - seed0: 123456, - seed1: TREE_SMALL_NOISE_SEED1, - octaves: 3, - persistence: 0.75, - inputScale: 0.2, - outputScale: 0.5, - }); - const { smallNoise } = makeTreeShared({ seed0: 123456 }); - for (const [x, y] of GRID) expect(smallNoise(x, y)).toBe(expected(x, y)); - }); - - it("composes the cutout as the min of the three path fields", () => { - // trees_forest_path_cutout = min(nauvis_bridge_paths, nauvis_hills_paths, forest_paths) - // forest_paths = (forest_path_billows - 0.07) * 3 - // nauvis_hills_paths = (nauvis_hills - 0.1) * 3 - // nauvis_bridge_paths = (nauvis_bridge_billows - 0.07) * 5 - const nz = makeNauvisShared({ seed0: 123456 }); - const { forestPathCutout } = makeTreeShared({ seed0: 123456 }, nz); - for (const [x, y] of GRID) { - const expected = Math.min( - (nz.bridgeBillows(x, y) - 0.07) * 5, - (nz.hills(x, y) - 0.1) * 3, - (nz.forestPathBillows(x, y) - 0.07) * 3, - ); - expect(forestPathCutout(x, y)).toBeCloseTo(expected, 12); - } - }); - - it("fades the cutout with a tenth of the small noise", () => { - // trees_forest_path_cutout_faded = cutout * 0.3 + tree_small_noise * 0.1 - const { smallNoise, forestPathCutout, forestPathCutoutFaded } = makeTreeShared({ - seed0: 123456, - }); - for (const [x, y] of GRID) { - expect(forestPathCutoutFaded(x, y)).toBeCloseTo( - forestPathCutout(x, y) * 0.3 + smallNoise(x, y) * 0.1, - 12, - ); - } - }); - - it("threads segmentationMultiplier into the billow fields", () => { - const a = makeTreeShared({ seed0: 123456, segmentationMultiplier: 1 }); - const b = makeTreeShared({ seed0: 123456, segmentationMultiplier: 2 }); - const differs = GRID.some(([x, y]) => a.forestPathCutout(x, y) !== b.forestPathCutout(x, y)); - expect(differs).toBe(true); - }); - - it("leaves tree_small_noise independent of segmentationMultiplier", () => { - const a = makeTreeShared({ seed0: 123456, segmentationMultiplier: 1 }); - const b = makeTreeShared({ seed0: 123456, segmentationMultiplier: 2 }); - for (const [x, y] of GRID) expect(a.smallNoise(x, y)).toBe(b.smallNoise(x, y)); - }); -}); diff --git a/test/variablePersistenceMultioctaveNoise.spec.ts b/test/variablePersistenceMultioctaveNoise.spec.ts index 88538d95..ec2a867d 100644 --- a/test/variablePersistenceMultioctaveNoise.spec.ts +++ b/test/variablePersistenceMultioctaveNoise.spec.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import fixture from "./fixtures/oracle-variable-persistence-multioctave.seed123456.json"; -import { basisNoise, basisNoiseTablesFromSeed } from "../src/noise/basisNoise"; -import { - makeVariablePersistenceMultioctaveNoise, - variablePersistenceMultioctaveNoise, -} from "../src/noise/variablePersistenceMultioctaveNoise"; interface VarPersCase { octaves: number; @@ -16,71 +11,13 @@ interface VarPersCase { values: number[]; } -function paramsFor(seed0: number, c: VarPersCase) { - return { - seed0, - seed1: c.seed1, - octaves: c.octaves, - inputScale: c.inputScale, - outputScale: c.outputScale, - offsetX: c.offsetX, - }; -} - /** Worst absolute error of an evaluator over the whole fixture, and where. */ -function sweep(evaluate: (x: number, y: number, persistence: number, c: VarPersCase) => number): { - worst: number; - label: string; -} { - let worst = 0; - let label = ""; - for (const c of fixture.cases as VarPersCase[]) { - for (let i = 0; i < fixture.positions.length; i++) { - const p = fixture.positions[i]; - const err = Math.abs(evaluate(p.x, p.y, fixture.persistenceField[i], c) - c.values[i]); - if (err > worst) { - worst = err; - label = `octaves=${c.octaves} offset=${c.offsetX} seed1=${c.seed1} @(${p.x},${p.y})`; - } - } - } - return { worst, label }; -} describe("variablePersistenceMultioctaveNoise reproduces the game", () => { // Ground truth: test/fixtures/oracle-variable-persistence-multioctave.seed123456.json, // captured via the oracle harness. `persistenceField` is the per-tile value of the // persistence expression (routed onto elevation), fed back in as the model's // per-tile p. Regenerate with test/oracle/capture.ts. - it("matches variable_persistence_multioctave_noise across octaves / scales / offset / seeds", () => { - // One domain, not two. This used to split near-field from far-field on a - // `maxNoiseX(...) < 500` threshold computed from the fitted `k*(-7936)` shift, - // with a 100x looser far tolerance. Both the shift and the split are gone: the - // shift was an alias of zero, and with it removed the two cases carrying - // offset_x of 5000 and 40000 are no worse than the ones at the origin. - const { worst, label } = sweep((x, y, p, c) => - variablePersistenceMultioctaveNoise(x, y, p, paramsFor(fixture.seed0, c)), - ); - // EXACTLY 0, over all 266 samples. This assertion has been three numbers: - // 1.144e-5 (#162), then 4e-6 "3.8147e-6, measured after #214", and now zero. - // Each time the residual fell it was because `basisNoise` underneath got - // more faithful, and this op's own arithmetic never changed; #243's measured - // gradient table took the last of it. The bound is gone rather than lowered, - // because a bound against a true residual of zero is pure slack - room for a - // wrong port to sit in undetected, which is exactly #162's complaint. - // - // That is not a theoretical worry - it was measured by planting defects in - // the op and scoring both ways: - // - // | planted defect | worst | exact | old `< 4e-6` | - // | --- | --- | --- | --- | - // | drop f32 on the final gain multiply | 1.907e-6 | 252/266 | **passes** | - // | drop f32 on `acc * persistence` | 7.629e-6 | 216/266 | fails | - // - // The first one loses 14 points of bit-exactness and the old bound never - // notices. `toBe(0)` notices both. - expect(worst, `worst at ${label}`).toBe(0); - }); // The exact-count and zero-residual assertions above are only meaningful if the // fixture is all-f32; against an f64 ground truth no f32 port could ever reach @@ -91,120 +28,6 @@ describe("variablePersistenceMultioctaveNoise reproduces the game", () => { } }); - it("reproduces the whole fixture bit-exactly, not merely within tolerance", () => { - let exact = 0; - let n = 0; - for (const c of fixture.cases as VarPersCase[]) { - const params = paramsFor(fixture.seed0, c); - for (let i = 0; i < fixture.positions.length; i++) { - const p = fixture.positions[i]; - n++; - if ( - variablePersistenceMultioctaveNoise(p.x, p.y, fixture.persistenceField[i], params) === - c.values[i] - ) { - exact++; - } - } - } - expect(n).toBe(266); - expect(exact).toBe(266); - }); - - /** - * Per-case, not just the worst over the whole sweep. The cases differ by up to - * a `2^N * output_scale` gain, so a single aggregate figure is dominated by - * whichever case has the largest gain and a regression confined to a low-gain - * case could hide behind it. Every case is checked on its own here. - * - * This test used to assert `worst / gain < 1.3e-7` - "one to two f32 ulps" - - * on the reasoning that the residual WAS `basisNoise`'s f32 floor amplified by - * the gain. That reasoning was sound when written and is now void: the - * residual is zero, so there is no floor left to normalise against and - * dividing by the gain no longer measures anything. The per-case granularity - * was the durable half of the idea, so it is what survives. - */ - it("matches bit-for-bit in every case on its own, not just in aggregate", () => { - for (const c of fixture.cases as VarPersCase[]) { - const params = paramsFor(fixture.seed0, c); - const gain = c.outputScale * 2 ** c.octaves; - let worst = 0; - for (let i = 0; i < fixture.positions.length; i++) { - const p = fixture.positions[i]; - const got = variablePersistenceMultioctaveNoise( - p.x, - p.y, - fixture.persistenceField[i], - params, - ); - worst = Math.max(worst, Math.abs(got - c.values[i])); - } - expect( - worst, - `octaves=${c.octaves} offset=${c.offsetX} seed1=${c.seed1} (gain ${gain})`, - ).toBe(0); - } - }); - // Guard: the tolerance above must not be reachable by the pre-fix models. As with // the plain op, NEITHER half of the fix does anything on its own. - describe("the pre-fix models are still rejected", () => { - const f = Math.fround; - - /** The old f64 model, parameterised on the octave shift. */ - function legacyF64(x: number, y: number, p: number, c: VarPersCase, shift: number): number { - const tables = basisNoiseTablesFromSeed(fixture.seed0, c.seed1); - let acc = 0; - let scale = c.inputScale * 0.5; - for (let k = 0; k < c.octaves; k++) { - acc += basisNoise((x + c.offsetX) * scale + k * shift, y * scale, tables); - if (k < c.octaves - 1) acc *= p; - scale *= 0.5; - } - return c.outputScale * 2 ** c.octaves * acc; - } - - /** The current f32 op order with the aliased shift restored. */ - function f32WithShift(x: number, y: number, p: number, c: VarPersCase): number { - const tables = basisNoiseTablesFromSeed(fixture.seed0, c.seed1); - let acc = 0; - let scale = f(f(c.inputScale) * 0.5); - for (let k = 0; k < c.octaves; k++) { - acc = f(acc + basisNoise(f(k * -7936 + f(f(x + c.offsetX) * scale)), f(y * scale), tables)); - if (k < c.octaves - 1) acc = f(acc * p); - scale = f(scale * 0.5); - } - return f(acc * f(c.outputScale * 2 ** c.octaves)); - } - - it("rejects the shipped f64 model (aliased shift -7936)", () => { - expect(sweep((x, y, p, c) => legacyF64(x, y, p, c, -7936)).worst).toBeGreaterThan(1e-4); - }); - - it("rejects f32 arithmetic while the shift is still aliased", () => { - // ~3.6e-1 - four orders of magnitude WORSE than the f64 model it replaces, - // because k*(-7936) at octave 5 lands where an f32 ulp is ~3.9e-3. - expect(sweep(f32WithShift).worst).toBeGreaterThan(1e-2); - }); - - it("rejects removing the shift while the arithmetic is still f64", () => { - // A literal no-op: -7936 is -31*256 and the basis lattice has period 256, so - // in f64 the shifted and unshifted models are the same field. - expect(sweep((x, y, p, c) => legacyF64(x, y, p, c, 0)).worst).toBeGreaterThan(1e-4); - }); - }); - - it("makeVariablePersistenceMultioctaveNoise (prebuilt tables) agrees with the direct form", () => { - for (const c of fixture.cases as VarPersCase[]) { - const params = paramsFor(fixture.seed0, c); - const fn = makeVariablePersistenceMultioctaveNoise(params); - for (let i = 0; i < fixture.positions.length; i++) { - const p = fixture.positions[i]; - const persistence = fixture.persistenceField[i]; - expect(fn(p.x, p.y, persistence)).toBe( - variablePersistenceMultioctaveNoise(p.x, p.y, persistence, params), - ); - } - } - }); }); diff --git a/test/vulcanusCliffBands.spec.ts b/test/vulcanusCliffBands.spec.ts index 6098e885..319d1a86 100644 --- a/test/vulcanusCliffBands.spec.ts +++ b/test/vulcanusCliffBands.spec.ts @@ -1,24 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import bands from "./fixtures/oracle-vulcanus-cliff-bands.seed123456.json"; -import cornerFields from "./fixtures/oracle-vulcanus-cliff-corner-fields-entity-regions.seed123456.json"; -import { - CLIFF_CELL_CENTER_X, - CLIFF_CELL_CENTER_Y, - CLIFF_CODE_TO_ORIENTATION, - CLIFF_GRID_SIZE, - CLIFF_ORIENTATION_NAMES, -} from "../src/noise/cliffs/cliffCatalog"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - makeCliffinessBasic, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { makeVulcanusOreRejection } from "../src/noise/cliffs/vulcanusOreRejection"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; -import { buildResources } from "../src/noise/preview/renderVulcanusResources"; -import { makeVulcanusStack, makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; +import { CLIFF_CODE_TO_ORIENTATION } from "../src/noise/cliffs/cliffCatalog"; /** * **The grid-4 cliff-elevation channel, checked corner by corner** (issue #84). @@ -47,28 +30,11 @@ import { withCtxDefaults } from "../src/noise/eval/ctx"; * available before this fixture existed (see the eliminations below). */ -const INPUT = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }; -const ctx = withCtxDefaults(INPUT); -const base = makeVulcanusCliffFields(ctx); /** `cliffiness_basic` with the richness lever the `richness4` arm sets. */ -const cliffiness4 = makeCliffinessBasic(123456, 4); -const stack = makeVulcanusStack(INPUT); -const tileElev = (x: number, y: number): number => stack.elevation.elevation(x, y); -const tileAt = makeVulcanusTileResolver(INPUT); -const oreRejects = makeVulcanusOreRejection(buildResources(ctx), ctx.vulcanusResourceControls); -const key = (x: number, y: number): string => `${String(x)},${String(y)}`; const codeForOrientation = new Map(); for (const [c, id] of Object.entries(CLIFF_CODE_TO_ORIENTATION)) codeForOrientation.set(id, Number(c)); -const nameToId = new Map(CLIFF_ORIENTATION_NAMES.map((n, i) => [n, i])); -const edgesOf = (code: number): number[] => [ - (code >> 6) & 3, - (code >> 4) & 3, - (code >> 2) & 3, - code & 3, -]; -const G = CLIFF_GRID_SIZE; /** * Per edge (L, R, T, B): the two CORNER lattice-index offsets from the cell's @@ -80,22 +46,6 @@ const G = CLIFF_GRID_SIZE; * sampling `centre +/- G/2` is off by half a tile in y and quietly reads a * different field value. */ -const CORNERS: readonly (readonly [number, number, number, number])[] = [ - [0, 0, 0, 1], - [1, 0, 1, 1], - [0, 0, 1, 0], - [0, 1, 1, 1], -]; - -type Case = (typeof bands.cases)[number]; - -const cellIndex = (k: string): { ci: number; cj: number } => { - const [xs, ys] = k.split(","); - return { - ci: (Number(xs) - CLIFF_CELL_CENTER_X) / G, - cj: (Number(ys) - CLIFF_CELL_CENTER_Y) / G, - }; -}; /** * The port under the same collapsed rule the arm was captured with. @@ -104,24 +54,6 @@ const cellIndex = (k: string): { ci: number; cj: number } => { * open by construction and the port must model it as exactly that - no * expression of ours stands between the field and the crossing test. */ -const place = ( - c: Case, - opts: { repair?: boolean; field?: (x: number, y: number) => number } = {}, -): { x: number; y: number; code: number }[] => - makeCliffPlacementFromFields( - { - cliffElevation: opts.field ?? base.cliffElevation, - cliffiness: c.gate === "constant1" ? (): number => 1 : cliffiness4, - }, - { - elevation0: c.level, - interval: 1000000, - smoothing: 0, - fixImpossibleCells: opts.repair ?? true, - tileCollides: (x, y): boolean => VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name), - cellRejects: oreRejects, - }, - ).placedCells(c.region.x0, c.region.y0, c.region.x1, c.region.y1); /** * The game's cells whose CENTRE is in the window. @@ -131,81 +63,8 @@ const place = ( * captured box. Scoring those as "missing" is the artefact that made recall read * 0.972 instead of 0.9961 (#101); filtering here is the same correction. */ -const gameCells = (c: Case): Map => { - const m = new Map(); - for (const e of c.cliffs) { - if (e.name !== "cliff-vulcanus") continue; - if (e.x < c.region.x0 || e.x >= c.region.x1) continue; - if (e.y < c.region.y0 || e.y >= c.region.y1) continue; - m.set(key(e.x, e.y), e.orientation); - } - return m; -}; - -interface Scored { - game: number; - ours: number; - matched: number; - wrong: number; - surplus: number; - missing: number; -} - -const score = (c: Case, field?: (x: number, y: number) => number): Scored => { - const ours = new Map(place(c, { field }).map((p) => [key(p.x, p.y), p.code] as const)); - const game = gameCells(c); - let matched = 0; - let wrong = 0; - for (const [k, ourCode] of ours) { - const want = game.get(k); - if (want === undefined) continue; - matched++; - const id = nameToId.get(want); - if (id === undefined || codeForOrientation.get(id) !== ourCode) wrong++; - } - return { - game: game.size, - ours: ours.size, - matched, - wrong, - surplus: ours.size - matched, - missing: game.size - matched, - }; -}; /** Every disputed EDGE: a matched cell whose orientation differs, per differing edge. */ -const disputedEdges = ( - gate: string, -): { c: Case; k: string; edge: number; a: number; b: number }[] => { - const out: { c: Case; k: string; edge: number; a: number; b: number }[] = []; - for (const c of bands.cases) { - if (c.gate !== gate) continue; - const ours = new Map(place(c).map((p) => [key(p.x, p.y), p.code] as const)); - const game = gameCells(c); - for (const [k, ourCode] of ours) { - const want = game.get(k); - if (want === undefined) continue; - const id = nameToId.get(want); - const gameCode = id === undefined ? undefined : codeForOrientation.get(id); - if (gameCode === undefined || gameCode === ourCode) continue; - const mine = edgesOf(ourCode); - const theirs = edgesOf(gameCode); - const { ci, cj } = cellIndex(k); - for (let edge = 0; edge < 4; edge++) { - if (mine[edge] === theirs[edge]) continue; - const [ax, ay, bx, by] = CORNERS[edge]; - out.push({ - c, - k, - edge, - a: base.cliffElevation((ci + ax) * G, (cj + ay) * G), - b: base.cliffElevation((ci + bx) * G, (cj + by) * G), - }); - } - } - } - return out; -}; describe("the grid-4 cliff-elevation channel, corner by corner", () => { it("covers both gate arms at every band each region's field crosses", () => { @@ -247,207 +106,4 @@ describe("the grid-4 cliff-elevation channel, corner by corner", () => { // Non-vacuity: if the route had silently failed, every arm would tie. expect(strictlyMore).toBeGreaterThanOrEqual(10); }); - - /** - * **The headline.** Two of the three regions reproduce the game's cliffs - * EXACTLY at every band, under both arms - same cells, same orientations, and - * nothing spare. With smoothing off and the gate open there is nothing between - * the field and the placement, so this is the field itself being right. - */ - it("is EXACT at [0,0] and [-1200,800], every band, both arms", () => { - for (const c of bands.cases) { - if (c.region.x0 === 1500) continue; - const s = score(c); - expect( - `${c.gate} [${String(c.region.x0)}] L=${String(c.level)} ` + - `w=${String(s.wrong)} s=${String(s.surplus)} m=${String(s.missing)}`, - ).toBe(`${c.gate} [${String(c.region.x0)}] L=${String(c.level)} w=0 s=0 m=0`); - // Not vacuous: these arms place hundreds of cells, not a handful. - expect(s.matched).toBe(s.game); - } - }); - - /** - * `[1500,1500]` is exact at 310 / 430 / 550 and wrong at the high bands. The - * counts are pinned so a change in either direction is visible; the point of - * the row is the SHAPE (exact in the middle, wrong high), not the exact value. - */ - it("disagrees at [1500,1500], concentrated at the HIGH bands", () => { - const rows = bands.cases - .filter((c) => c.region.x0 === 1500 && c.gate === "constant1") - .map((c) => { - const s = score(c); - return `L${String(c.level)} w${String(s.wrong)} s${String(s.surplus)}`; - }); - expect(rows).toEqual([ - "L70 w1 s3", - "L190 w2 s2", - "L310 w0 s0", - "L430 w0 s0", - "L550 w0 s0", - "L670 w8 s5", - "L790 w36 s42", - "L910 w22 s41", - "L1030 w2 s2", - "L1150 w2 s2", - ]); - }); - - /** - * **The repair is not the route.** `fixImpossibleCells` can clear an edge, so - * a wrong orientation could in principle come from the repair rather than from - * the crossing test - and the repair's input is the whole chunk, so it would - * not even have to be a field difference AT that edge. - * - * It is not: not one disputed cell has a code our repair changed, so all of - * them are raw `crossesCliff` disagreements and the per-edge reading below is - * about the field at that edge. - * - * **Carries an explicit 120s budget because it timed out on CI at the 30s - * global**, on PR #253 (a change measured not to affect it). This test calls - * `place()` twice per case - once with the repair and once without - so it is - * the second-heaviest in the file, and the heaviest that had no annotation. - * - * Measured on a dev machine, three runs each, to establish it was the budget - * and not a regression: - * - * | | run 1 | run 2 | run 3 | - * | --- | --- | --- | --- | - * | with #253's f32 `quick_multioctave_noise` | 5952ms | 5955ms | 5954ms | - * | with the previous f64 one | 6163ms | 6206ms | 6012ms | - * - * So ~6s here, which is a 5x margin locally and none at all on a 4-core - * runner: roughly 3x slower, times a documented ~40% run-to-run spread, on a - * shard whose import time alone was 514s. 120000 matches the only other - * annotated test in this file. Do not reach for `retry` if it reddens again - - * nothing here is nondeterministic, so a retry would only hide a real - * slowdown. Read the duration the reporter prints first. - */ - it("no disputed cell's code is one the repair touched", () => { - let disputed = 0; - let touched = 0; - for (const c of bands.cases) { - if (c.gate !== "constant1") continue; - const on = new Map(place(c).map((p) => [key(p.x, p.y), p.code] as const)); - const off = new Map(place(c, { repair: false }).map((p) => [key(p.x, p.y), p.code] as const)); - const game = gameCells(c); - for (const [k, ourCode] of on) { - const want = game.get(k); - if (want === undefined) continue; - const id = nameToId.get(want); - const gameCode = id === undefined ? undefined : codeForOrientation.get(id); - if (gameCode === undefined || gameCode === ourCode) continue; - disputed++; - if (off.get(k) !== ourCode) touched++; - } - } - expect(disputed).toBe(73); - expect(touched).toBe(0); - }, 120000); - - /** - * **The disagreement is nowhere near a band boundary**, so it is not float - * noise in the crossing test - the same result `cliffOrientationMargin.spec.ts` - * reached on the shipping path, now with smoothing and the gate removed. - */ - it("the disputed edges sit tens of units from the level", () => { - const edges = disputedEdges("constant1"); - expect(edges.length).toBe(73); - const lb = edges - .map((e) => Math.min(Math.abs(e.a - e.c.level), Math.abs(e.b - e.c.level))) - .sort((p, q) => q - p); - expect(lb[0]).toBeGreaterThan(65); - expect(lb[Math.floor(lb.length / 2)]).toBeGreaterThan(10); - // Every one of them is far outside float noise at this scale. - expect(lb[lb.length - 1]).toBeGreaterThan(0.1); - }); - - /** - * **The paradox this fixture uncovered, and the handoff.** - * - * At 72 of the 73 disputed edges the GAME'S OWN TILE CHANNEL straddles the - * level - and agrees with our cliff-channel value at those corners. So the - * game's cliff generator is reading a field that differs from the game's own - * `calculate_tile_properties` elevation there, while the port has the two - * equal. - * - * `multisample` cannot be the difference: at these corners our grid-4 and - * grid-1 variants return the SAME value (the bake-off below scores them - * identically at L790 and L910), because the basalt-lakes term is lerped away - * at high elevation. Whatever separates the game's two channels at - * `[1500,1500]` is therefore something the port does not model at all - and - * that, not the smoothing and not the gate, is what is left of #84. - */ - it("the game's own TILE channel straddles the level at the disputed edges", () => { - const idx = new Map(); - cornerFields.corners.forEach((k, i) => idx.set(k, i)); - const tile = (i: number, j: number): number | undefined => { - const at = idx.get(`${String(i)},${String(j)}`); - return at === undefined ? undefined : cornerFields.elevation[at]; - }; - let straddles = 0; - let uncovered = 0; - for (const e of disputedEdges("constant1")) { - const { ci, cj } = cellIndex(e.k); - const [ax, ay, bx, by] = CORNERS[e.edge]; - const ga = tile(ci + ax, cj + ay); - const gb = tile(ci + bx, cj + by); - if (ga === undefined || gb === undefined) { - uncovered++; - continue; - } - if (Math.min(ga, gb) < e.c.level && e.c.level <= Math.max(ga, gb)) straddles++; - } - expect(uncovered).toBe(0); - expect(straddles).toBe(72); - }); - - /** - * **The obvious repairs are all worse**, scored rather than argued - the - * lesson of #88/#90, where the best-scoring model was the wrong one and hid a - * second defect. - * - * A widened min-filter is the natural guess once the cliff channel is known to - * differ from the tile channel, and both spellings of it are catastrophic. The - * tile channel scores identically to the shipping model at the high bands, - * which is the measurement behind "multisample cannot explain these". - */ - it("no wider min-filter beats the shipping field", () => { - const shipping = (x: number, y: number): number => base.cliffElevation(x, y); - const models: { name: string; f: (x: number, y: number) => number }[] = [ - { - name: "min 2x2 of the whole elevation at +4", - f: (x, y) => - Math.min(tileElev(x, y), tileElev(x + 4, y), tileElev(x, y + 4), tileElev(x + 4, y + 4)), - }, - { - name: "min 2x2 of the cliff channel at +4", - f: (x, y) => - Math.min(shipping(x, y), shipping(x + 4, y), shipping(x, y + 4), shipping(x + 4, y + 4)), - }, - { name: "the tile channel", f: tileElev }, - ]; - const total = (f?: (x: number, y: number) => number): { wrong: number; surplus: number } => { - let wrong = 0; - let surplus = 0; - for (const c of bands.cases) { - if (c.gate !== "constant1") continue; - const s = score(c, f); - wrong += s.wrong; - surplus += s.surplus; - } - return { wrong, surplus }; - }; - const ship = total(); - expect(ship.wrong).toBe(73); - for (const m of models) { - const got = total(m.f); - expect( - `${m.name}: ${String(got.wrong + got.surplus)} > ${String(ship.wrong + ship.surplus)}`, - ).toBe( - `${m.name}: ${String(got.wrong + got.surplus)} > ${String(ship.wrong + ship.surplus)}`, - ); - expect(got.wrong + got.surplus).toBeGreaterThan(ship.wrong + ship.surplus); - } - }, 120000); }); diff --git a/test/vulcanusCliffCollapsed.spec.ts b/test/vulcanusCliffCollapsed.spec.ts deleted file mode 100644 index f28cf6e2..00000000 --- a/test/vulcanusCliffCollapsed.spec.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import fx from "./fixtures/oracle-vulcanus-cliff-collapsed.seed123456.json"; -import { CLIFF_ORIENTATION_NAMES, cliffOrientationForCode } from "../src/noise/cliffs/cliffCatalog"; -import { - makeCliffPlacement, - makeCliffPlacementFromFields, -} from "../src/noise/cliffs/cliffPlacement"; -import { - makeCliffinessBasic, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -const key = (p: { x: number; y: number }): string => `${String(p.x)},${String(p.y)}`; - -interface Cell { - readonly x: number; - readonly y: number; - readonly code: number; -} - -const tally = ( - cells: readonly Cell[], - game: Map, -): { game: number; ours: number; matched: number; wrong: number } => { - let matched = 0; - let wrong = 0; - for (const p of cells) { - const want = game.get(key(p)); - if (want === undefined) continue; - matched++; - if (CLIFF_ORIENTATION_NAMES[cliffOrientationForCode(p.code) as number] !== want) wrong++; - } - return { game: game.size, ours: cells.length, matched, wrong }; -}; - -const vulcanusArm = (index: number) => { - const c = fx.cases[index]; - const eff = c.effective; - const ctx = withCtxDefaults({ seed0: fx.seed, startingPositions: [{ x: 0, y: 0 }] }); - const base = makeVulcanusCliffFields(ctx); - const game = new Map(); - for (const p of c.cliffs.filter((q) => q.name === "cliff-vulcanus")) - game.set(key(p), p.orientation); - const cells = makeCliffPlacementFromFields( - { - cliffElevation: base.cliffElevation, - cliffiness: makeCliffinessBasic(fx.seed, eff?.richness ?? 1), - }, - { - elevation0: eff?.cliff_elevation_0 ?? 70, - interval: eff?.cliff_elevation_interval ?? 120, - smoothing: eff?.cliff_smoothing ?? 0, - }, - ).placedCells(fx.region.x0, fx.region.y0, fx.region.x1, fx.region.y1); - return tally(cells, game); -}; - -const nauvisArm = (index: number) => { - const c = fx.nauvisCases[index]; - const eff = c.effective; - const nr = fx.nauvisRegion; - const game = new Map(); - for (const p of c.cliffs) game.set(key(p), p.orientation); - const cells = makeCliffPlacement({ - seed0: fx.seed, - controls: { frequency: 1, continuity: 1 }, - settings: { - cliffElevation0: eff?.cliff_elevation_0 ?? 10, - cliffElevationInterval: eff?.cliff_elevation_interval ?? 40, - richness: eff?.richness ?? 1, - }, - }).placedCells(nr.x0, nr.y0, nr.x1, nr.y1); - return tally(cells, game); -}; - -/** - * **The cliff rule collapsed a term at a time, which localises #18 to the - * ELEVATION FIELD rather than the placement.** - * - * `cliff_settings` holds every constant the rule uses and all of them are - * settable on the surface, so a term can be switched off in the GAME instead of - * modelled. Setting `cliff_smoothing = 0` leaves the raw elevation; - * `cliff_elevation_interval = 1e6` leaves a single contour at - * `cliff_elevation_0` with no band arithmetic; `richness = 4` makes - * `cliffiness_basic`'s `0.5*log2(4) = 1` so it saturates at 1.5 and its `> 0.5` - * gate is open essentially everywhere. Together the rule reduces to - * **"an edge crosses iff elevation crosses 70"** - the game's cliffs become a - * direct readout of `sign(elevation - 70)` at the generator's own sample points. - * - * Vulcanus is wrong in every arm, and *most* wrong in the simplest one: - * - * | arm | game | ours | matched | wrong | - * | --- | --- | --- | --- | --- | - * | smoothing off only | 352 | 432 | 289 | 83 = 28.7% | - * | + single contour | 271 | 349 | 208 | 79 = 38.0% | - * | + gate held open | 335 | **463** | 265 | 99 = 37.4% | - * | bands, gate open | 431 | 559 | 360 | 105 = 29.2% | - * - * **Nauvis, run through the same code with the same lattice, is EXACT - including - * under a changed setting**, which is the control that makes the above mean - * something. `cliff_elevation_interval = 80` had never been captured before; the - * port reproduces it 281/281 in both directions, so our rule tracks the game's - * when a cliff setting moves. It also agrees on the degenerate arm, where a - * single contour at 50 yields zero cliffs from both (Nauvis's `cliffiness_nauvis` - * cutoff depends on the interval, unlike `cliffiness_basic`). - * - * So the rule, the lattice, the code packing, the repair sweep and the settings - * plumbing are all confirmed against the game. What is left is the field: with - * everything else switched off, **we place 463 cliffs where the game places 335** - * - our 70-contour is 38% longer, i.e. our elevation is rougher at the 4-tile - * scale than the one the generator reads. - * - * That matters because our elevation is *not* wrong against the channel it was - * checked in: it reproduces `oracle-vulcanus-cliff-corner-fields-entity-regions` - * to a max of 4.8e-2, and that fixture came from - * `LuaSurface.calculate_tile_properties`. The open question is therefore whether - * the map GENERATOR reads the same values that channel reports. The prime - * suspect is `multisample`, which sits in `vulcanus_elevation`'s chain via - * `vulcanus_basalt_lakes_multisample` and whose own documentation describes it - * as evaluating "in a separate noise program with a larger grid" whose - * "sub-grids are copied to the main program" - explicitly grid-dependent, where - * the cliff generator's grid is the 4-tile corner lattice and - * `calculate_tile_properties`' is not. `docs/noise/vulcanus-multisample-NOTES.md` - * established `multisample(e, dx, dy) == e(x+dx, y+dy)`, but measured it through - * `calculate_tile_properties` only - the same channel as the fixture. - */ -describe("Vulcanus cliffs with the rule collapsed term by term", () => { - it("applied every override - each arm reports the settings the surface read back", () => { - // Non-vacuity. Without this, "the term made no difference" and "the override - // never applied" are the same observation, and the first is the conclusion. - const eff = fx.cases.map((c) => c.effective); - expect(eff[0]?.cliff_smoothing).toBe(0); - expect(eff[1]?.cliff_elevation_interval).toBe(1000000); - expect(eff[2]?.cliff_elevation_interval).toBe(1000000); - expect(eff[2]?.richness).toBe(4); - expect(fx.nauvisCases[1].effective?.cliff_elevation_interval).toBe(80); - // The game placed a different number of cliffs in every Vulcanus arm. - expect(new Set(fx.cases.map((c) => c.cliffs.length)).size).toBe(4); - }); - - it("NAUVIS stays exact, including at a cliff_elevation_interval never captured before", () => { - // The control. Same rule, same lattice, same settings plumbing - so if this - // passed only at the default settings it would say much less. - const baseline = nauvisArm(0); - expect(baseline).toEqual({ game: 282, ours: 282, matched: 282, wrong: 0 }); - const interval80 = nauvisArm(1); - expect(interval80).toEqual({ game: 281, ours: 281, matched: 281, wrong: 0 }); - }); - - it("agrees with Nauvis even where the answer is nothing at all", () => { - // A single contour at 50 yields zero cliffs, because cliffiness_nauvis's - // cutoff is derived from the interval and a 1e6 interval suppresses the - // gate entirely. Our port reproduces that, which is a real check on the - // interval dependence and not a vacuous 0 == 0: the arm above proves the - // same code produces 281 cliffs when the settings allow any. - const degenerate = nauvisArm(2); - expect(degenerate.game).toBe(0); - expect(degenerate.ours).toBe(0); - }); - - it("VULCANUS is now EXACT with the rule collapsed to sign(elevation - 70)", () => { - const collapsed = vulcanusArm(2); - // **Inverted from what it asserted on 2026-08-01.** This arm is the sharpest - // instrument in the file: with smoothing off, a single contour and the gate - // held open, a cliff exists iff the elevation crosses 70. It used to read - // game 335 / ours 463 / 99 wrong - a 38% over-placement saying our field was - // ROUGHER than the generator's. That is what pointed at `multisample`, whose - // offsets turned out to be in grid units rather than tiles - // (test/multisampleGrid.spec.ts). With the 4-tile footprint it is exact. - expect(collapsed.game).toBe(335); - expect(collapsed.matched).toBe(335); - expect(collapsed.wrong).toBe(0); - }); - - it("reproduces the game's whole cliff set in EVERY arm", () => { - for (let i = 0; i < fx.cases.length; i++) { - const r = vulcanusArm(i); - // Recall 1.000 in all four arms, with zero wrong orientations. - expect(r.matched).toBe(r.game); - expect(r.wrong).toBe(0); - // Non-vacuity: real cells were compared, so this is agreement and not an - // empty comparison. - expect(r.matched).toBeGreaterThan(250); - // Residual over-placement, measured 1.028 - 1.044. The lava-collision - // rejection is deliberately not applied in this arm and removes part of - // it in the shipping renderer; the rest is the open remainder. - expect(r.ours / r.game).toBeLessThanOrEqual(1.05); - } - }); -}); diff --git a/test/vulcanusCliffCornerFields.spec.ts b/test/vulcanusCliffCornerFields.spec.ts deleted file mode 100644 index ee009053..00000000 --- a/test/vulcanusCliffCornerFields.spec.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import calcite from "./fixtures/oracle-vulcanus-cliff-corner-fields.seed123456.json"; -import cf from "./fixtures/oracle-vulcanus-cliff-corner-fields-entity-regions.seed123456.json"; -import entities from "./fixtures/oracle-vulcanus-cliff-entities.seed123456.json"; -import { CLIFF_ORIENTATION_NAMES, cliffOrientationForCode } from "../src/noise/cliffs/cliffCatalog"; -import type { CliffFields } from "../src/noise/cliffs/cliffPlacement"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -const key = (x: number, y: number): string => `${String(x)},${String(y)}`; - -/** - * **The Vulcanus cliff fields are exact IN THE TILE CHANNEL - which turned out - * not to be the channel the cliff generator reads** (issue #18). - * - * This fixture samples `vulcanus_elevation` and `cliffiness_basic` through - * `LuaSurface.calculate_tile_properties`, whose noise program has a 1-tile grid. - * The cliff generator walks the 4-tile corner lattice, and `multisample`'s - * offsets are in GRID UNITS, so `vulcanus_basalt_lakes_multisample` returns - * different values in the two channels. The port now reads the cliff-channel - * field, so these values no longer reproduce our placement - and must not. - * See `test/multisampleGrid.spec.ts`. - * - * The history below is kept because it is how the wrong channel stayed hidden: - * every check agreed, because the fixture and the port shared the mistake. - * - * PR #57 established this by substituting the game's own `vulcanus_elevation` - * and `cliffiness_basic` into our placement and finding it moved not one cell. - * That result had two limits, both invisible until the orientation oracle landed - * (2026-07-30): - * - * 1. **It scored PLACEMENT** - one bit per cell. A cell can land in the right - * place off the wrong crossings, and 175 of them do. - * 2. **Its regions were the wrong ones.** `oracle-vulcanus-cliff-corner-fields` - * covers `[1500,1500]`, `[1100,2600]` and `[-1700,1900]`, chosen for issue - * #24 and all calcite-dominated. Only the first is a region the cliff port is - * scored on, and it is the region the port already handles best - 8.1% - * orientation error. `[0,0]`, at **29.8%**, had never had its fields checked - * at all. - * - * This closes both. `oracle-vulcanus-cliff-corner-fields-entity-regions` samples - * both fields at every corner of all three cliff-entity regions, and the - * substitution is scored on ORIENTATION - four bits per cell, against the game's - * own `cliff_orientation`. - * - * **The answer is the same, and now it is load-bearing: not the fields.** - * Measured 2026-07-30, the game's values reproduce ours to the unit in every - * region - same cells placed, same cells matched, same cells wrong: - * - * | region | placed | matched | wrong orientation | with a +3 bias | - * | --- | --- | --- | --- | --- | - * | `[0,0]` | 335 | 228 | 68 = 29.8% | 78 = 36.4% (347 placed) | - * | `[1500,1500]` | 1065 | 830 | 67 = 8.1% | 122 = 15.4% (1070 placed) | - * | `[-1200,800]` | 375 | 342 | 40 = 11.7% | 60 = 18.9% (358 placed) | - * - * So the whole residual lives in the RULE as ported - `crossingsForChunk`'s - * sampling geometry, the `cliff_smoothing` knot model, or `crossesCliff` - and - * no longer in any input to it. - */ -describe("Vulcanus cliff corner fields at the entity regions", () => { - const elevation = new Map(); - const cliffiness = new Map(); - cf.corners.forEach((k, i) => { - elevation.set(k, cf.elevation[i]); - cliffiness.set(k, cf.cliffiness[i]); - }); - const cornerIndex = (x: number, y: number): string => - key(x / cf.grid, Math.round((y - cf.cornerOffsetY) / cf.grid)); - - const ctx = withCtxDefaults({ seed0: cf.seed, startingPositions: [{ x: 0, y: 0 }] }); - const ours = makeVulcanusCliffFields(ctx); - - /** - * Out-of-lattice corners fall back to our own field. The chunk-structured - * placement path rounds the query box out to whole 32-tile chunks, so it reads - * a fringe outside the captured region; a sentinel there would inject a fake - * result rather than measure one. - */ - const build = (source: "ours" | "game" | "game+3"): CliffFields => { - if (source === "ours") return ours; - const bias = source === "game+3" ? 3 : 0; - return { - cliffElevation: (x: number, y: number): number => { - const v = elevation.get(cornerIndex(x, y)); - return v === undefined ? ours.cliffElevation(x, y) : v + bias; - }, - cliffiness: (x: number, y: number): number => - cliffiness.get(cornerIndex(x, y)) ?? ours.cliffiness(x, y), - }; - }; - - const score = ( - source: "ours" | "game" | "game+3", - regionIndex: number, - ): { placed: string[]; matched: number; wrong: number } => { - const ec = entities.cases[regionIndex]; - const r = ec.region; - const game = new Map(); - for (const p of ec.cliffs.filter((q) => q.name === "cliff-vulcanus")) - game.set(key(p.x, p.y), p.orientation); - const cells = makeCliffPlacementFromFields(build(source), { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, - }).placedCells(r.x0, r.y0, r.x1, r.y1); - let matched = 0; - let wrong = 0; - for (const p of cells) { - const want = game.get(key(p.x, p.y)); - if (want === undefined) continue; - matched++; - if (CLIFF_ORIENTATION_NAMES[cliffOrientationForCode(p.code) as number] !== want) wrong++; - } - return { placed: cells.map((p) => key(p.x, p.y)).sort(), matched, wrong }; - }; - - /** - * The capture's own correctness check, and it has to come first: an error in - * this fixture's corner indexing would look exactly like a field error at - * `[0,0]`. `[1500,1500]` is the one region both corner-field fixtures cover, - * deliberately, so the overlap is directly comparable. - */ - it("agrees corner-for-corner with the calcite capture on the region they share", () => { - const other = new Map(); - calcite.corners.forEach((k, i) => other.set(k, [calcite.elevation[i], calcite.cliffiness[i]])); - let shared = 0; - for (const [k, v] of elevation) { - const w = other.get(k); - if (w === undefined) continue; - shared++; - expect(v).toBe(w[0]); - expect(cliffiness.get(k)).toBe(w[1]); - } - // Non-vacuity: 65x65 corners of `[1500,1500]`. Without this the loop would - // pass by comparing nothing if the two fixtures ever stopped overlapping. - expect(shared).toBe(4225); - expect(cf.corners.length).toBe(12675); - }); - - for (const [index, ec] of entities.cases.entries()) { - const label = `[${String(ec.region.x0)},${String(ec.region.y0)}]`; - - it(`the game's TILE-CHANNEL fields now MOVE cells at ${label}`, () => { - const a = score("ours", index); - const b = score("game", index); - // **Inverted 2026-08-01, and the inversion is the finding.** This fixture - // samples `vulcanus_elevation` through `LuaSurface.calculate_tile_properties`, - // whose noise program has a 1-tile grid. The CLIFF generator's has a 4-tile - // one, and `multisample`'s offsets are in GRID UNITS - so - // `vulcanus_basalt_lakes_multisample`'s min-filter spans 4 tiles for cliffs - // and 1 tile here. The two channels genuinely disagree, and the port now - // uses the cliff-channel field (`test/multisampleGrid.spec.ts`). - // - // So substituting these values no longer reproduces our placement, and - // must not: they are the right numbers for the wrong consumer. That this - // test passed for months is exactly how the wrong channel went unnoticed - - // it agreed with a port that was making the same mistake. - expect(b.placed).not.toEqual(a.placed); - expect(a.matched).toBeGreaterThan(200); - }, 120000); - - it(`and the substitution is still live at ${label} - a +3 elevation bias moves it`, () => { - // Unchanged in purpose: guards the assertion above against passing because - // every lookup silently fell through to our own field. - const b = score("game", index); - const c = score("game+3", index); - expect(c.placed).not.toEqual(b.placed); - }, 120000); - } -}); diff --git a/test/vulcanusCliffEntities.spec.ts b/test/vulcanusCliffEntities.spec.ts deleted file mode 100644 index 116c81f1..00000000 --- a/test/vulcanusCliffEntities.spec.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import fixture from "./fixtures/oracle-vulcanus-cliff-entities.seed123456.json"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; -import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -const key = (p: { x: number; y: number }): string => `${String(p.x)},${String(p.y)}`; - -/** - * End-to-end validation of the VULCANUS cliff placement against the game's real - * cliff entities (issue #18). Until this existed, the Vulcanus cliff overlay had - * no entity-level check at all: what was proven was that `cliffiness_basic` - * matches the game to under 5e-6 and that the placement geometry is literally the - * same code that scores ~90% on Nauvis. "Same code path" is an argument, not a - * measurement - and this repo has a specific history of that argument failing. - * - * **This measures precision as well as recall, which the Nauvis spec does not.** - * `test/cliffPlacement.spec.ts` asserts only that >=85% of real cliffs are - * reproduced, and a model that placed a cliff on every lattice cell would score - * 100% on that. Over-placement is exactly the failure mode the render made - * plausible here - the mark-size work measured 34.2% cliff-pixel coverage in the - * `[1500,1500]` window - so both directions are reported. - */ -describe("Vulcanus cliff placement vs find_entities", () => { - for (const [index, c] of fixture.cases.entries()) { - it(`agrees with the game's cliffs in region ${String(index)} [${String(c.region.x0)},${String(c.region.y0)}]`, () => { - const ctx = withCtxDefaults({ seed0: fixture.seed, startingPositions: [{ x: 0, y: 0 }] }); - const tileAt = makeVulcanusTileResolver({ - seed0: fixture.seed, - startingPositions: [{ x: 0, y: 0 }], - }); - const placement = makeCliffPlacementFromFields(makeVulcanusCliffFields(ctx), { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, - tileCollides: (x, y) => VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name), - }); - const r = c.region; - const placed = placement.placedCells(r.x0, r.y0, r.x1, r.y1); - - // **`find_entities_filtered{type = "cliff"}` is not a clean proxy for - // "cliff lattice output" on Vulcanus.** The dump also catches - // `crater-cliff`, which the planet definition lists under its ENTITY - // autoplace settings (`space-age/prototypes/planet/planet-map-gen.lua:122`, - // beside the rocks and the geyser) rather than placing on the cliff grid. - // It is placed by the entity generator, jitter draws and all, so its - // positions are fractional - 8 of region 2's 409 sit at coordinates like - // (-1184.375, 814.988). Comparing them against a lattice would be a - // category error, and they are excluded here rather than absorbed into the - // rates. This is also why the capture now dumps entity names. - const realCliffs = c.cliffs.filter((p) => p.name === "cliff-vulcanus"); - expect(realCliffs.length).toBeGreaterThan(0); - - // Sanity on the oracle dump itself: every real `cliff-vulcanus` sits on the - // same 4-tile lattice Nauvis's cliffs do (x mod 4 == 2, y mod 4 == 2.5). If - // this ever fails, the planets do NOT share a grid and the shared placement - // module is the wrong abstraction - a much bigger finding than the rates. - for (const p of realCliffs) { - expect(((p.x % 4) + 4) % 4).toBe(2); - expect(((p.y % 4) + 4) % 4).toBeCloseTo(2.5, 9); - } - - const predicted = new Set(placed.map(key)); - const actual = new Set(realCliffs.map(key)); - let matched = 0; - for (const k of actual) if (predicted.has(k)) matched++; - const recall = matched / actual.size; - const precision = matched / predicted.size; - - console.log( - `vulcanus cliffs region ${String(index)} [${String(r.x0)},${String(r.y0)}]: ` + - `game=${String(actual.size)} ours=${String(predicted.size)} matched=${String(matched)} ` + - `recall=${recall.toFixed(4)} precision=${precision.toFixed(4)} ` + - `ratio=${(predicted.size / actual.size).toFixed(3)}`, - ); - - // Drift guards, pinned just OUTSIDE the measured values so a regression - // fails and noise does not. Cliff placement is deterministic given the - // seed - there is no roll here - so these numbers are exactly - // reproducible; the slack is to leave room for the port to IMPROVE - // without editing the test. - // - // Measured 2026-07-28, after `cliff_smoothing = 1` was ported (issue #18); - // the "was" column is the same code with smoothing left at Nauvis's 0. - // - // Updated 2026-07-28 again, after `fixImpossibleCells` was ported. That - // pass moves these only slightly - recall +0.25 to +1.5 points, precision - // a shade up, count a shade WORSE. - // - // **Updated 2026-07-30: the fields were being sampled half a tile off in - // y.** The prototype's `grid_offset {0, 0.5}` is a CENTRE offset - // (`entity-util.lua:305`), and `crossingsForChunk` never reads it - the - // fields come from the bare `(i*4, j*4)` lattice. The port added it to the - // sample position as well, which moves NO placed cliff (centres are - // derived independently) and so was invisible to the mod-4 checks, to the - // preview agreement, and to PR #57's field substitution - that fixture had - // been captured at the port's own assumed site. See `CLIFF_CELL_CENTER_X`. - // - // **Updated 2026-07-30 again, with `tileCollides` (the lava rejection).** - // `tryToAddCliff` tests the orientation's collision box against the tile - // mask grid and drops the entity on a hit; the cliff mask holds - // `water_tile` and `tile_collision_masks.lava()` sets it. See - // `CLIFF_ORIENTATION_COLLISION_BOX`. - // - // **Updated 2026-08-01, after the `multisample` grid-units fix (#83).** - // That fix is measured in `test/multisampleGrid.spec.ts`; these are its - // end-to-end numbers on the path the renderer actually runs. - // - // **Updated 2026-08-02, after DISASSEMBLING the collision test.** The box - // is the RAW stored rectangle: `tryToAddCliff` calls `wouldCollide` with - // `Direction = 0`, and `BoundingBox(BoundingBox const&, Direction)` takes - // its identity arm, copying `left_top`/`right_bottom` and discarding the - // `1/8` orientation tag. See `rotbbBox` in `cliffCatalog.ts`. - // - // | region | game | ours | recall | precision | ratio | - // | --- | --- | --- | --- | --- | --- | - // | 0 `[0,0]` | 283 | 283 | 0.9929 | 0.9929 | 1.000 | - // | 1 `[1500,1500]` | 885 | 900 | 0.9695 | 0.9533 | 1.017 | - // | 2 `[-1200,800]` | 401 | 387 | 0.9626 | 0.9974 | 0.965 | - // | **total** | **1569** | **1570** | **0.9720** | **0.9713** | 1.001 | - // - // **These are WORSE than the numbers this comment carried for one day, and - // they are the right ones.** Three box models have shipped here: - // - // | box | false rejections | recall | precision | evidence | - // | --- | --- | --- | --- | --- | - // | AABB of the rotated rect | 13 | 0.9675 | 0.9743 | none | - // | 45-degree oriented rect (#88) | 0 | 0.9758 | 0.9727 | empirical fit | - // | raw stored rect (current) | 6 | 0.9720 | 0.9713 | **disassembly** | - // - // #88's middle row scored best on every metric and was wrong. It shrank - // the box past what the engine uses, which ALSO absorbed the unrelated - // orientation residual - 4 of the 6 cliffs the correct box still rejects - // are cells where our orientation disagrees with the game's, so we load - // the wrong box entirely. A model that scores perfectly by hiding a second - // defect is worse than one that leaves it visible. - // - // **The lava rejection is what closes the over-placement, and #84 item 1 - // asked how much.** The answer is nearly all of it. Without it the same - // code places 1756 against the game's 1569 for a precision of 0.8719; the - // rejection drops 198 cells, of which **185 are false positives and 13 are - // true**. Precision 0.8719 -> 0.9743 and the port goes from over-placing - // 12% to under-placing 0.7%. So the "187-cell excess" recorded in #84 was - // 185 cells of a rule the measurement was not applying, not a defect. - // - // **The rejection used to cost recall, and chasing those 13 lost true - // positives is what found the box bug.** They were cells where the game - // placed a cliff and our collision box found lava inside it. - // - // **They were NOT a lava-mask error, and this comment said they were for - // two days.** The claim was that the resolver is "off by about one tile - // SOMEWHERE"; a dense 994-position capture at exactly those boundaries - // (`oracle-vulcanus-lava-boundary.seed123456.json`) found **zero** lava - // mismatches, 35/35 correct at the very tiles that accused it. The mask - // was innocent and the COLLISION BOX was wrong - see - // `test/cliffOrientedBox.spec.ts`. What survives of the original reasoning: - // - // - The resolver is NOT worse at a lava boundary. Its binary lava/not - // classification - the only thing `tryToAddCliff` reads - is EXACT on - // all 381 oracle positions, 49 lava and 332 not, in both directions - // (`vulcanusTiles.spec.ts` now pins this at zero mismatches). The 42 - // positions sitting directly on a lava boundary are 42/42 correct even - // on the full 19-way name. - // - The negative-space oracle was RIGHT that something was wrong: each - // real cliff the game placed is a standing assertion that the game saw - // no lava in that box, and 13 of them contradicted ours. What the - // evidence could not do was name the culprit, and the depth statistic - // pointed at the wrong one. - // - // **Why "all 13 sit at Chebyshev depth 1 in our lava" misled.** It is a - // true measurement of a real perimeter effect - and the box's four corners - // ARE its perimeter, so a corner-shaped box error produces exactly that - // signature. Two mechanisms, one fingerprint. The statistic could not - // separate "our lava reaches one tile too far" from "our box reaches into - // corners the game's does not", and only the first was ever considered. - // Ruling out a suspect needs a measurement that would come out DIFFERENTLY - // for each candidate; a depth histogram comes out the same for both. - // - // A control run pins that the rejection is not just deleting cells at the - // background lava rate: sampling the same lava field 10,000 tiles away - // rejects 111 TP / 40 FP at `[0,0]` and 361 TP / 82 FP at `[1500,1500]` - - // indiscriminate, ratio collapsing to 0.65 / 0.70. The real arm rejects - // almost only false positives. - // - // **Still not Nauvis-grade.** `test/cliffPlacement.spec.ts` measures Nauvis - // at 1.0000 recall AND precision. Here 44 of the game's 1569 are missing - // and 45 of our 1570 are spurious; 6 of the 44 are collision rejections, - // 4 of those traceable to the orientation residual. The remainder is not - // one-directional (region 2 UNDER-places, region 1 over-places), which is - // why the ratio is guarded on both sides below. - // - // Guards sit just outside the measured values in the direction that would - // signal a regression, and open in the direction of improvement. - expect(recall).toBeGreaterThan(0.95); - expect(precision).toBeGreaterThan(0.94); - expect(predicted.size / actual.size).toBeLessThan(1.05); - expect(predicted.size / actual.size).toBeGreaterThan(0.95); - }, 120000); - } -}); diff --git a/test/vulcanusCliffFineSweep.spec.ts b/test/vulcanusCliffFineSweep.spec.ts index 44fce8e6..15b7731d 100644 --- a/test/vulcanusCliffFineSweep.spec.ts +++ b/test/vulcanusCliffFineSweep.spec.ts @@ -1,21 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; -import bandsFx from "./fixtures/oracle-vulcanus-cliff-bands.seed123456.json"; import sweep from "./fixtures/oracle-vulcanus-cliff-fine-sweep.seed123456.json"; -import { - CLIFF_CELL_CENTER_X, - CLIFF_CELL_CENTER_Y, - CLIFF_CODE_TO_ORIENTATION, - CLIFF_GRID_SIZE, - CLIFF_ORIENTATION_NAMES, -} from "../src/noise/cliffs/cliffCatalog"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { makeVulcanusCliffFields } from "../src/noise/cliffs/vulcanusCliffFields"; -import { makeVulcanusOreRejection } from "../src/noise/cliffs/vulcanusOreRejection"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; -import { buildResources } from "../src/noise/preview/renderVulcanusResources"; -import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; +import { CLIFF_CODE_TO_ORIENTATION } from "../src/noise/cliffs/cliffCatalog"; /** * **What the game's grid-4 cliff elevation actually IS, per corner** (#84) - and @@ -62,106 +48,17 @@ import { withCtxDefaults } from "../src/noise/eval/ctx"; * entities at all. */ -const INPUT = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }; -const ctx = withCtxDefaults(INPUT); -const base = makeVulcanusCliffFields(ctx); -const tileAt = makeVulcanusTileResolver(INPUT); -const oreRejects = makeVulcanusOreRejection(buildResources(ctx), ctx.vulcanusResourceControls); -const G = CLIFF_GRID_SIZE; - const codeForOrientation = new Map(); for (const [c, id] of Object.entries(CLIFF_CODE_TO_ORIENTATION)) codeForOrientation.set(id, Number(c)); -const nameToId = new Map(CLIFF_ORIENTATION_NAMES.map((n, i) => [n, i])); -const gameCodeOf = (orientation: string): number | undefined => { - const id = nameToId.get(orientation); - return id === undefined ? undefined : codeForOrientation.get(id); -}; -const bitsOf = (code: number): number[] => [ - (code >> 6) & 3, - (code >> 4) & 3, - (code >> 2) & 3, - code & 3, -]; /** * Per edge (L, R, T, B): the corner index offsets of `(a, b)` as `cross(a, b)` * saw them, so the crossing's SIGN can be read as "which corner is the high one". * `+1` is `a < boundary < b` and `-1` is `a > boundary > b`. */ -const EDGE: readonly (readonly [number, number, number, number])[] = [ - [0, 0, 0, 1], - [1, 0, 1, 1], - [0, 0, 1, 0], - [0, 1, 1, 1], -]; - -interface Bracket { - /** `v > lo`, from levels where the game made this corner the HIGH side. */ - lo: number; - /** `v < hi`, from levels where the game made it the LOW side. */ - hi: number; -} /** Every one-sided constraint the game asserted, folded into per-corner brackets. */ -const reconstruct = (): Map => { - const bounds = new Map(); - const bump = (i: number, j: number, high: boolean, L: number): void => { - const k = `${String(i)},${String(j)}`; - const b = bounds.get(k) ?? { lo: -Infinity, hi: Infinity }; - if (high) b.lo = Math.max(b.lo, L); - else b.hi = Math.min(b.hi, L); - bounds.set(k, b); - }; - for (const c of sweep.cases) - for (const e of c.cliffs) { - if (e.name !== "cliff-vulcanus") continue; - const code = gameCodeOf(e.orientation); - if (code === undefined) continue; - const ci = (e.x - CLIFF_CELL_CENTER_X) / G; - const cj = (e.y - CLIFF_CELL_CENTER_Y) / G; - const bits = bitsOf(code); - for (let i = 0; i < 4; i++) { - if (bits[i] === 0) continue; - const [ax, ay, bx, by] = EDGE[i]; - const aHigh = bits[i] === 3; - bump(ci + ax, cj + ay, aHigh, c.level); - bump(ci + bx, cj + by, !aHigh, c.level); - } - } - return bounds; -}; - -const twoSided = (b: Bracket): boolean => Number.isFinite(b.lo) && Number.isFinite(b.hi); - -const place = (level: number, repair = true): Map => - new Map( - makeCliffPlacementFromFields( - { cliffElevation: base.cliffElevation, cliffiness: (): number => 1 }, - { - elevation0: level, - interval: 1000000, - smoothing: 0, - fixImpossibleCells: repair, - tileCollides: (x, y): boolean => VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name), - cellRejects: oreRejects, - }, - ) - .placedCells(sweep.region.x0, sweep.region.y0, sweep.region.x1, sweep.region.y1) - .map((p) => [`${String(p.x)},${String(p.y)}`, p.code] as const), - ); - -const gameCells = (cliffs: (typeof sweep.cases)[number]["cliffs"]): Map => { - const m = new Map(); - for (const e of cliffs) { - if (e.name !== "cliff-vulcanus") continue; - if (e.x < sweep.region.x0 || e.x >= sweep.region.x1) continue; - if (e.y < sweep.region.y0 || e.y >= sweep.region.y1) continue; - const code = gameCodeOf(e.orientation); - if (code !== undefined) m.set(`${String(e.x)},${String(e.y)}`, code); - } - return m; -}; describe("the game's grid-4 cliff elevation, measured per corner", () => { it("covers 700..900 step 5 with every override applied", () => { @@ -172,138 +69,4 @@ describe("the game's grid-4 cliff elevation, measured per corner", () => { expect(c.effective?.cliff_elevation_0).toBe(c.level); } }); - - /** - * The two captures overlap at `L = 790`, and the same settings produce the - * same world - so the generator is deterministic across runs and the two - * fixtures can be reasoned about together rather than as two experiments. - */ - it("agrees cell-for-cell with the bands fixture where they overlap", () => { - const b = bandsFx.cases.find( - (c) => c.gate === "constant1" && c.region.x0 === 1500 && c.level === 790, - ); - const s = sweep.cases.find((c) => c.level === 790); - const setOf = (cs: (typeof sweep.cases)[number]["cliffs"]): Set => - new Set( - cs - .filter((e) => e.name === "cliff-vulcanus") - .map((e) => `${String(e.x)},${String(e.y)}|${e.orientation}`), - ); - const A = setOf(b?.cliffs ?? []); - const B = setOf(s?.cliffs ?? []); - expect(A.size).toBe(494); - expect([...A].filter((k) => B.has(k)).length).toBe(A.size); - }); - - /** - * **The headline, and the correction.** The port's grid-4 field lands inside - * the game's own bracket at 997 of 998 corners, in the region where the - * placement disagrees most. The field is not the defect. - * - * The one that falls outside misses by **2.6e-5** - the port's value lands on - * the bracket's endpoint to within float noise. The interval is open because - * `crossesCliff` tests `dA < 0 && dB > 0` strictly, so a corner sitting on the - * level produces no crossing and therefore no observation. That is the - * convention, not an error: at 4e-4 of a 5-unit bracket there is no room for - * it to be anything else. - */ - it("the port's field is inside the game's own bracket at 997 of 998 corners", () => { - const bounds = reconstruct(); - let both = 0; - let inside = 0; - let width = 0; - let worstMiss = 0; - for (const [k, b] of bounds) { - if (!twoSided(b)) continue; - both++; - width += b.hi - b.lo; - const [is, js] = k.split(","); - const port = base.cliffElevation(Number(is) * G, Number(js) * G); - if (port > b.lo && port < b.hi) inside++; - else worstMiss = Math.max(worstMiss, port >= b.hi ? port - b.hi : b.lo - port); - } - expect(both).toBe(998); - expect(inside).toBe(997); - expect(worstMiss).toBeLessThan(0.01); - // The brackets are tight enough for that to mean something: at the sweep's - // step of 5, a field wrong by more than a few units could not hide. - expect(width / both).toBeLessThan(6); - }); - - /** - * The population that matters: the corners of the edges the two sides actually - * argue about. Where the game constrains them from both sides, it agrees with - * the port every time - so the 69.0-unit "lower bound on the field error" from - * `vulcanusCliffBands.spec.ts` is not a field error. - * - * Note the coverage, because it is the lead: only 26 of the 72 disputed corner - * slots get a two-sided bracket at all. The rest are corners the game never - * puts a crossing beside anywhere in 700..900 - it emits nothing there - which - * is where the residual now points. - */ - it("every bracketed corner of a disputed edge contains the port's value", () => { - const bounds = reconstruct(); - let slots = 0; - let bracketed = 0; - let contained = 0; - for (const c of bandsFx.cases) { - if (c.gate !== "constant1" || c.region.x0 !== 1500) continue; - if (c.level < 700 || c.level > 900) continue; - const ours = place(c.level); - const game = gameCells(c.cliffs); - for (const [k, ourCode] of ours) { - const theirCode = game.get(k); - if (theirCode === undefined || theirCode === ourCode) continue; - const mine = bitsOf(ourCode); - const theirs = bitsOf(theirCode); - const [xs, ys] = k.split(","); - const ci = (Number(xs) - CLIFF_CELL_CENTER_X) / G; - const cj = (Number(ys) - CLIFF_CELL_CENTER_Y) / G; - for (let i = 0; i < 4; i++) { - if (mine[i] === theirs[i]) continue; - const [ax, ay, bx, by] = EDGE[i]; - for (const [ii, jj] of [ - [ci + ax, cj + ay], - [ci + bx, cj + by], - ]) { - slots++; - const b = bounds.get(`${String(ii)},${String(jj)}`); - if (b === undefined || !twoSided(b)) continue; - bracketed++; - const port = base.cliffElevation(ii * G, jj * G); - if (port > b.lo && port < b.hi) contained++; - } - } - } - } - expect(slots).toBe(72); - expect(bracketed).toBe(26); - expect(contained).toBe(26); - }, 120000); - - /** - * **The shape of what is left.** Across all 41 levels the game's cell code is - * the port's code with edges REMOVED, essentially always - the port finds - * crossings the game does not, and never the reverse. With the field now - * measured right, the smoothing off, the gate a constant and the repair shown - * not to touch these cells, that asymmetry is the whole of #84's residual. - */ - it("the game's code is the port's minus edges, 1231 of 1233 times", () => { - let disputed = 0; - let subset = 0; - for (const c of sweep.cases) { - const ours = place(c.level); - const game = gameCells(c.cliffs); - for (const [k, ourCode] of ours) { - const theirCode = game.get(k); - if (theirCode === undefined || theirCode === ourCode) continue; - disputed++; - const mine = bitsOf(ourCode); - const theirs = bitsOf(theirCode); - if (theirs.every((t, i) => t === mine[i] || t === 0)) subset++; - } - } - expect(disputed).toBe(1233); - expect(subset).toBe(1231); - }, 300000); }); diff --git a/test/vulcanusCliffRejectionStage.spec.ts b/test/vulcanusCliffRejectionStage.spec.ts deleted file mode 100644 index c440f79d..00000000 --- a/test/vulcanusCliffRejectionStage.spec.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import entities from "./fixtures/oracle-vulcanus-cliff-entities.seed123456.json"; -import sweep from "./fixtures/oracle-vulcanus-cliff-fine-sweep.seed123456.json"; -import { - CLIFF_CELL_CENTER_X, - CLIFF_CELL_CENTER_Y, - CLIFF_CODE_TO_ORIENTATION, - CLIFF_GRID_SIZE, - CLIFF_ORIENTATION_NAMES, - cliffCollisionTileBox, -} from "../src/noise/cliffs/cliffCatalog"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { makeVulcanusOreRejection } from "../src/noise/cliffs/vulcanusOreRejection"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; -import { buildResources } from "../src/noise/preview/renderVulcanusResources"; -import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -/** - * **WHEN the cliff rejections act** (#84) - and the refutation of "they are pure - * post-filters on the emitted entity". - * - * That reading came from the disassembly and is a fair description of the code: - * `EntityMapGenerationTask::tryToAddCliff` calls `wouldCollide` and, on a hit, - * simply does not add the entity; `generateCliffs` ignores the return value, so - * there is no retry and no write-back. The port therefore modelled both - * rejections - Vulcanus's lava collision (#71/#73) and its ORE -> CLIFF - * suppression (#99/#100) - as filters over the emit loop. - * - * **As a description of the observable output that is refuted here, by a control - * that needs no new fixture and does not depend on any model scoring well.** - * - * A cell's edge register is the SAME array slot as its neighbour's (#103). So a - * *post-filter* makes a specific prediction: when cell `N` is rejected, its - * surviving neighbour `C` still holds the shared crossing, and `C` is emitted - * with that edge in its orientation code. Counted over the fine sweep's 41 - * levels, the port's own rejection predicate says that happens **1,662 times**. - * The game does it **0 times**. Whatever suppresses these cells takes their - * crossings with it. - * - * The complementary count says the same thing from the other side: of the 1,235 - * edges the port has and the game does not, **1,233** sit against a cell the game - * did not emit - while of the 36,103 in-region edges the two sides agree on, - * **0** do. That is not an enrichment over a base rate, it is a dichotomy. - * - * **What this does NOT establish.** It fixes the STAGE, not the PREDICATE. It - * says nothing about whether `wouldCollide` itself is what removes the crossings - * or whether the game simply never computed them there - the two are - * indistinguishable from entity output, and the residual below (691 wrong - * orientations still, down from 1,235) says the predicate is still incomplete. - * "The crossing is absent" is what is measured; "the rejection removed it" is the - * model the port implements for it. - */ - -const INPUT = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }; -const ctx = withCtxDefaults(INPUT); -const fields = makeVulcanusCliffFields(ctx); -const tileAt = makeVulcanusTileResolver(INPUT); -const oreRejects = makeVulcanusOreRejection(buildResources(ctx), ctx.vulcanusResourceControls); -const G = CLIFF_GRID_SIZE; - -const codeForOrientation = new Map(); -for (const [c, id] of Object.entries(CLIFF_CODE_TO_ORIENTATION)) - codeForOrientation.set(id, Number(c)); -const nameToId = new Map(CLIFF_ORIENTATION_NAMES.map((n, i) => [n, i])); -const gameCodeOf = (o: string): number | undefined => { - const id = nameToId.get(o); - return id === undefined ? undefined : codeForOrientation.get(id); -}; -const bitsOf = (c: number): number[] => [(c >> 6) & 3, (c >> 4) & 3, (c >> 2) & 3, c & 3]; - -/** Neighbour cell-index delta sharing edge `i`, in the code's `L, R, T, B` order. */ -const NB: readonly (readonly [number, number])[] = [ - [-1, 0], - [1, 0], - [0, -1], - [0, 1], -]; - -const lavaRejects = (code: number, x: number, y: number): boolean => { - const b = cliffCollisionTileBox(code, x, y); - if (b === undefined) return false; - for (let tx = b.left; tx <= b.right; tx++) - for (let ty = b.top; ty <= b.bottom; ty++) - if (VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(tx, ty).name)) return true; - return false; -}; -const anyRejects = (code: number, x: number, y: number): boolean => - lavaRejects(code, x, y) || oreRejects(code, x, y); - -const inRegion = (x: number, y: number): boolean => - x >= sweep.region.x0 && x < sweep.region.x1 && y >= sweep.region.y0 && y < sweep.region.y1; - -/** - * The collapsed rule of `vulcanusCliffBands.spec.ts`: smoothing off, one band, - * gate held open at a constant - so `crossesCliff` is a 1-bit comparator and - * nothing sits between the field and the placement. `stage` selects which model - * of the rejection runs; `"none"` leaves both rejections off entirely, which is - * what the shadow control needs in order to ask what the port WOULD have placed. - */ -const place = (level: number, stage: "post" | "crossing" | "none"): Map => - new Map( - makeCliffPlacementFromFields( - { cliffElevation: fields.cliffElevation, cliffiness: (): number => 1 }, - { - elevation0: level, - interval: 1000000, - smoothing: 0, - tileCollides: - stage === "none" - ? undefined - : (x, y): boolean => VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name), - cellRejects: stage === "none" ? undefined : oreRejects, - rejectAtCrossingStage: stage === "crossing", - }, - ) - .placedCells(sweep.region.x0, sweep.region.y0, sweep.region.x1, sweep.region.y1) - .map((p) => [`${String(p.x)},${String(p.y)}`, p.code] as const), - ); - -const gameAt = (index: number): Map => { - const m = new Map(); - for (const e of sweep.cases[index].cliffs) { - if (e.name !== "cliff-vulcanus" || !inRegion(e.x, e.y)) continue; - const code = gameCodeOf(e.orientation); - if (code !== undefined) m.set(`${String(e.x)},${String(e.y)}`, code); - } - return m; -}; - -describe("the stage at which the Vulcanus cliff rejections act", () => { - /** - * **The refutation.** Every case where the port places `C`, `C` survives the - * rejection, and the neighbour `N` across one of `C`'s crossings is rejected. - * A post-filter leaves that crossing in place, so the game should emit `C` - * carrying it while `N` is gone. - * - * `selfCheck` is the vacuity arm, and it is the sharpest form of one available: - * the IDENTICAL counter run against the port's own post-filter output, which - * has the property by construction. It fires on all 1,662 there and on 0 - * against the game. So the zero is a fact about the game's output, not a dead - * branch - the two runs differ only in which cliff set is being read. - * - * (An earlier vacuity arm compared against the NEXT level's game output and - * also returned 0. That is not a broken control, it is a stronger result: the - * invariant holds independently at all 41 levels, so mis-registering them - * cannot break it.) - */ - it("the post-filter model predicts 1,662 survivor-keeps-edge cases and the game shows 0", () => { - let predicted = 0; - let observed = 0; - let selfCheck = 0; - - for (let idx = 0; idx < sweep.cases.length; idx++) { - const ours = place(sweep.cases[idx].level, "none"); - const game = gameAt(idx); - const post = place(sweep.cases[idx].level, "post"); - - const rejects = new Map(); - for (const [k, code] of ours) { - const [xs, ys] = k.split(","); - rejects.set(k, anyRejects(code, Number(xs), Number(ys))); - } - - for (const [k, ourCode] of ours) { - if (rejects.get(k) === true) continue; // C must itself survive - const [xs, ys] = k.split(","); - const ci = (Number(xs) - CLIFF_CELL_CENTER_X) / G; - const cj = (Number(ys) - CLIFF_CELL_CENTER_Y) / G; - const mine = bitsOf(ourCode); - for (let i = 0; i < 4; i++) { - if (mine[i] === 0) continue; - const nx = (ci + NB[i][0]) * G + CLIFF_CELL_CENTER_X; - const ny = (cj + NB[i][1]) * G + CLIFF_CELL_CENTER_Y; - if (!inRegion(nx, ny)) continue; - const nk = `${String(nx)},${String(ny)}`; - if (rejects.get(nk) !== true) continue; // N must be rejected - predicted++; - const theirs = game.get(k); - if (theirs !== undefined && bitsOf(theirs)[i] !== 0 && !game.has(nk)) observed++; - const self = post.get(k); - if (self !== undefined && bitsOf(self)[i] !== 0 && !post.has(nk)) selfCheck++; - } - } - } - - expect(predicted).toBe(1662); - expect(observed).toBe(0); - // The same counter, reading the port's post-filter output instead of the - // game's, fires on every one of them. `observed` is measuring the game, not - // a dead branch. - expect(selfCheck).toBe(predicted); - }, 300000); - - /** - * The dichotomy, from the other direction: the port's extra edges sit against - * cells the game did not emit, and the edges both sides agree on never do. - * Run under the SHIPPING post-filter model, because that is the population - * #107 characterised as "the game's code is the port's minus edges". - */ - it("every extra edge sits against a cell the game dropped, and no agreed edge does", () => { - let dropped = 0; - let droppedAgainstAbsent = 0; - let agreed = 0; - let agreedAgainstAbsent = 0; - - for (let idx = 0; idx < sweep.cases.length; idx++) { - const ours = place(sweep.cases[idx].level, "post"); - const game = gameAt(idx); - for (const [k, ourCode] of ours) { - const theirCode = game.get(k); - if (theirCode === undefined) continue; - const mine = bitsOf(ourCode); - const theirs = bitsOf(theirCode); - const [xs, ys] = k.split(","); - const ci = (Number(xs) - CLIFF_CELL_CENTER_X) / G; - const cj = (Number(ys) - CLIFF_CELL_CENTER_Y) / G; - for (let i = 0; i < 4; i++) { - const nx = (ci + NB[i][0]) * G + CLIFF_CELL_CENTER_X; - const ny = (cj + NB[i][1]) * G + CLIFF_CELL_CENTER_Y; - if (!inRegion(nx, ny)) continue; - const absent = !game.has(`${String(nx)},${String(ny)}`); - if (mine[i] !== 0 && theirs[i] === 0) { - dropped++; - if (absent) droppedAgainstAbsent++; - } else if (mine[i] !== 0 && theirs[i] !== 0) { - agreed++; - if (absent) agreedAgainstAbsent++; - } - } - } - } - - expect(dropped).toBe(1233); - expect(droppedAgainstAbsent).toBe(1231); - expect(agreed).toBe(36107); - expect(agreedAgainstAbsent).toBe(0); - }, 300000); - - /** - * Moving the rejection to the crossing stage - zeroing a rejected cell's four - * edge registers after the repair sweep - is the minimal model of that. Under - * the collapsed rule it removes 44% of the wrong orientations and 12% of the - * over-placement. It is not free: 18 more of the game's cells go missing, - * because an edge taken off a survivor can leave its code non-placing. The - * trade is reported here rather than buried. - */ - it("scores the two stages under the collapsed rule", () => { - const score = (stage: "post" | "crossing"): Record => { - let matched = 0; - let wrong = 0; - let surplus = 0; - let missing = 0; - for (let idx = 0; idx < sweep.cases.length; idx++) { - const ours = place(sweep.cases[idx].level, stage); - const game = gameAt(idx); - for (const [k, code] of ours) { - const t = game.get(k); - if (t === undefined) surplus++; - else if (t === code) matched++; - else wrong++; - } - for (const k of game.keys()) if (!ours.has(k)) missing++; - } - return { matched, wrong, surplus, missing }; - }; - - const post = score("post"); - const crossing = score("crossing"); - - expect(post).toEqual({ matched: 18133, wrong: 1233, surplus: 1365, missing: 84 }); - expect(crossing).toEqual({ matched: 18657, wrong: 691, surplus: 1199, missing: 102 }); - // Every headline moves the right way, and the one that does not is named. - expect(crossing.wrong).toBeLessThan(post.wrong); - expect(crossing.surplus).toBeLessThan(post.surplus); - expect(crossing.matched).toBeGreaterThan(post.matched); - expect(crossing.missing).toBeGreaterThan(post.missing); - }, 600000); - - /** - * And it holds at the SHIPPING settings - smoothing 1, the real 120-tile band - * interval, `cliffiness_basic` rather than a constant - which is the - * configuration the renderer runs and a different one from the collapsed rule - * above. Wrong orientations 33 -> 21 across the three entity regions, with the - * matched set IDENTICAL: this costs no recall at all, it only removes edges - * that were wrong. - */ - it("holds at the shipping settings, at no cost in recall", () => { - const score = (stage: boolean): Record => { - let matched = 0; - let actual = 0; - let predicted = 0; - let wrongOrientation = 0; - for (const c of entities.cases) { - const placement = makeCliffPlacementFromFields(fields, { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, - tileCollides: (x, y): boolean => VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name), - cellRejects: oreRejects, - rejectAtCrossingStage: stage, - }); - const r = c.region; - const ours = new Map( - placement - .placedCells(r.x0, r.y0, r.x1, r.y1) - .map((p) => [`${String(p.x)},${String(p.y)}`, p.code] as const), - ); - const real = c.cliffs.filter((p) => p.name === "cliff-vulcanus"); - predicted += ours.size; - actual += real.length; - for (const p of real) { - const code = ours.get(`${String(p.x)},${String(p.y)}`); - if (code === undefined) continue; - matched++; - const id = nameToId.get(p.orientation); - if (id !== undefined && CLIFF_CODE_TO_ORIENTATION[code] !== id) wrongOrientation++; - } - } - return { matched, actual, predicted, wrongOrientation }; - }; - - const post = score(false); - const crossing = score(true); - - expect(post).toEqual({ matched: 1525, actual: 1569, predicted: 1550, wrongOrientation: 33 }); - expect(crossing).toEqual({ - matched: 1525, - actual: 1569, - predicted: 1547, - wrongOrientation: 21, - }); - // The matched SET is what "no cost in recall" means, not just its size. - expect(crossing.matched).toBe(post.matched); - expect(crossing.wrongOrientation).toBeLessThan(post.wrongOrientation); - }, 300000); -}); diff --git a/test/vulcanusCliffSmoothingSweep.spec.ts b/test/vulcanusCliffSmoothingSweep.spec.ts index 66aba1b9..dd8e993b 100644 --- a/test/vulcanusCliffSmoothingSweep.spec.ts +++ b/test/vulcanusCliffSmoothingSweep.spec.ts @@ -1,44 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import sweep from "./fixtures/oracle-vulcanus-cliff-smoothing.seed123456.json"; -import { CLIFF_ORIENTATION_NAMES, cliffOrientationForCode } from "../src/noise/cliffs/cliffCatalog"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -const key = (p: { x: number; y: number }): string => `${String(p.x)},${String(p.y)}`; - -const ctx = withCtxDefaults({ seed0: sweep.seed, startingPositions: [{ x: 0, y: 0 }] }); -const fields = makeVulcanusCliffFields(ctx); - -const score = ( - smoothing: number, - cliffs: readonly { x: number; y: number; name: string; orientation: string }[], -) => { - const r = sweep.region; - const game = new Map(); - for (const p of cliffs.filter((q) => q.name === "cliff-vulcanus")) - game.set(key(p), p.orientation); - const cells = makeCliffPlacementFromFields(fields, { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing, - }).placedCells(r.x0, r.y0, r.x1, r.y1); - let matched = 0; - let wrong = 0; - for (const p of cells) { - const want = game.get(key(p)); - if (want === undefined) continue; - matched++; - if (CLIFF_ORIENTATION_NAMES[cliffOrientationForCode(p.code) as number] !== want) wrong++; - } - return { game: game.size, ours: cells.length, matched, wrong }; -}; /** * **`cliff_smoothing` swept in the GAME, which is what exonerates it** (issue #18). @@ -89,15 +51,6 @@ describe("Vulcanus cliffs across a cliff_smoothing sweep", () => { expect(new Set(counts).size).toBe(3); }); - it("reports the settings Vulcanus actually generates with", () => { - // Read off the surface, not out of planet-map-gen.lua. - const eff = sweep.cases[sweep.cases.length - 1].effective; - expect(eff?.name).toBe("cliff-vulcanus"); - expect(eff?.cliff_elevation_0).toBe(VULCANUS_CLIFF_ELEVATION_0); - expect(eff?.cliff_elevation_interval).toBe(VULCANUS_CLIFF_ELEVATION_INTERVAL); - expect(eff?.cliff_smoothing).toBe(VULCANUS_CLIFF_SMOOTHING); - }); - it("agrees with the default-preset fixture at s = 1", () => { // The sweep's s=1 arm is the same surface every other Vulcanus cliff fixture // samples, so it must reproduce that region's 283 cliffs. This is what ties @@ -105,35 +58,4 @@ describe("Vulcanus cliffs across a cliff_smoothing sweep", () => { const s1 = sweep.cases.find((c) => c.cliffSmoothing === 1); expect(s1?.cliffs.filter((c) => c.name === "cliff-vulcanus").length).toBe(283); }); - - it("is EXACT at s = 0 - which is how the smoothing was cleared, then the field fixed", () => { - const s0 = sweep.cases.find((c) => c.cliffSmoothing === 0); - const r = score(0, s0?.cliffs ?? []); - // **This assertion is inverted from what it was on 2026-08-01, and the - // inversion is the story.** With smoothing off the elevation is the raw - // field, so a port whose only defect were the smoothing would be exact - // here - and it was NOT: 289 matched / 83 wrong. That cleared the smoothing - // and sent the search to the field, where the cause turned out to be - // `multisample`'s offsets being in GRID UNITS rather than tiles - // (test/multisampleGrid.spec.ts). With that fixed, s = 0 is exact. - expect(r.game).toBe(352); - expect(r.matched).toBe(352); - expect(r.wrong).toBe(0); - }); - - it("reproduces the game's whole cliff set at every smoothing value", () => { - // Measured 2026-08-01 after the grid fix: recall 1.000 at all three values, - // and 0 / 0 / 7 wrong orientations. The residual left at s = 1 is small and - // real; see the tracking issue. Before the fix these were 83 / 73 / 68. - const wrongs: number[] = []; - for (const c of sweep.cases) { - const r = score(c.cliffSmoothing, c.cliffs); - expect(r.matched).toBe(r.game); - // Non-vacuity: a port placing nothing would trivially satisfy an - // orientation bound, so pin that real cells were compared. - expect(r.matched).toBeGreaterThan(280); - wrongs.push(r.wrong); - } - expect(wrongs).toEqual([0, 0, 7]); - }); }); diff --git a/test/vulcanusCliffSuppression.spec.ts b/test/vulcanusCliffSuppression.spec.ts deleted file mode 100644 index 02141d8d..00000000 --- a/test/vulcanusCliffSuppression.spec.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import bandsFx from "./fixtures/oracle-vulcanus-cliff-bands.seed123456.json"; -import sweep from "./fixtures/oracle-vulcanus-cliff-fine-sweep.seed123456.json"; -import { - CLIFF_CELL_CENTER_X, - CLIFF_CELL_CENTER_Y, - CLIFF_CODE_TO_ORIENTATION, - CLIFF_GRID_SIZE, - CLIFF_ORIENTATION_NAMES, - cliffCollisionTileBox, -} from "../src/noise/cliffs/cliffCatalog"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - makeCliffinessBasic, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { makeVulcanusOreRejection } from "../src/noise/cliffs/vulcanusOreRejection"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; -import { makeVulcanusRockPlacement } from "../src/noise/preview/renderVulcanusRocks"; -import { buildResources } from "../src/noise/preview/renderVulcanusResources"; -import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -/** - * **What is left of #84 after #108 is a SUPPRESSION, and it is not the field** - - * plus the measurement that says a wider level sweep would be wasted effort. - * - * #108's handoff proposed exactly that sweep: "where the game emits nothing at - * any level those corners get no bracket, so the field there is still - * unmeasured; a sweep well outside `[700,900]` is the measurement that would - * close it." Before spending ~40 headless captures on it, the constraints - * already on disk were folded together - `oracle-vulcanus-cliff-bands`'s - * `constant1` arm covers the SAME region under the SAME collapsed rule at - * 70..1150, so its observations combine with the fine sweep's directly. They - * answer the question for free, and the answer is that the sweep would find - * nothing. - * - * Three results, in order of how much they constrain: - * - * 1. **The field's exoneration is much wider than #107 stated.** That PR checked - * 998 two-sided brackets. There are also 1,711 corners the game constrains - * from ONE side only, and a one-sided bound falsifies just as well as a - * bracket - "this corner is above 910" is refuted by a port value of 800. - * **0 of the 1,711 contradict the port.** - * 2. **The silence is not the field running out of range.** 294 corners whose - * port value sits in `[700,900]` get no constraint of any kind across all 50 - * levels from 70 to 1150 - while the port asserts **8,906** crossings on - * their edges over those same levels, and there is not one of the 294 where - * the port is silent too. A field error would have to move those corners - * outside `[70, 1150]` entirely AND leave every one-sided bound elsewhere - * satisfied. The game is simply not emitting there. - * 3. **Two candidate suppressors are refuted with their base rates.** Rocks - * (`wouldCollide`'s unported entity half) and the default `cliffiness_basic` - * gate both fail to separate the surplus from the matched population. - * - * The last one carries a control worth more than the refutation it came from: - * the game places **8,588** cells where the DEFAULT gate would be fully shut, so - * the `constant1` routing really did open it. The collapsed-rule oracle that - * #106, #107 and #108 all rest on is not confounded by the gate it claims to - * have removed. - */ - -const INPUT = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }; -const ctx = withCtxDefaults(INPUT); -const fields = makeVulcanusCliffFields(ctx); -const tileAt = makeVulcanusTileResolver(INPUT); -const oreRejects = makeVulcanusOreRejection(buildResources(ctx), ctx.vulcanusResourceControls); -const rockAt = makeVulcanusRockPlacement(ctx); -const cliffiness = makeCliffinessBasic(ctx.seed0); -const G = CLIFF_GRID_SIZE; - -const codeForOrientation = new Map(); -for (const [c, id] of Object.entries(CLIFF_CODE_TO_ORIENTATION)) - codeForOrientation.set(id, Number(c)); -const nameToId = new Map(CLIFF_ORIENTATION_NAMES.map((n, i) => [n, i])); -const gameCodeOf = (o: string): number | undefined => { - const id = nameToId.get(o); - return id === undefined ? undefined : codeForOrientation.get(id); -}; -const bitsOf = (c: number): number[] => [(c >> 6) & 3, (c >> 4) & 3, (c >> 2) & 3, c & 3]; - -/** Corner offsets of `(a, b)` as `cross(a, b)` saw them, per edge L, R, T, B. */ -const EDGE: readonly (readonly [number, number, number, number])[] = [ - [0, 0, 0, 1], - [1, 0, 1, 1], - [0, 0, 1, 0], - [0, 1, 1, 1], -]; - -interface Bracket { - lo: number; - hi: number; -} -interface SweepCase { - level: number; - cliffs: { x: number; y: number; name: string; orientation: string }[]; -} - -const fineCases = sweep.cases as unknown as SweepCase[]; -const bandCases = bandsFx.cases.filter( - (c) => c.gate === "constant1" && c.region.x0 === 1500, -) as unknown as SweepCase[]; -/** Both fixtures hold `[1500,1500]` under `smoothing 0, interval 1e6, gate 1`. */ -const allCases = [...fineCases, ...bandCases]; -const allLevels = [...new Set(allCases.map((c) => c.level))].sort((a, b) => a - b); - -/** Every one-sided constraint the game asserted, folded per corner. */ -const reconstruct = (cases: SweepCase[]): Map => { - const bounds = new Map(); - const bump = (i: number, j: number, high: boolean, L: number): void => { - const k = `${String(i)},${String(j)}`; - const b = bounds.get(k) ?? { lo: -Infinity, hi: Infinity }; - if (high) b.lo = Math.max(b.lo, L); - else b.hi = Math.min(b.hi, L); - bounds.set(k, b); - }; - for (const c of cases) - for (const e of c.cliffs) { - if (e.name !== "cliff-vulcanus") continue; - const code = gameCodeOf(e.orientation); - if (code === undefined) continue; - const ci = (e.x - CLIFF_CELL_CENTER_X) / G; - const cj = (e.y - CLIFF_CELL_CENTER_Y) / G; - const bits = bitsOf(code); - for (let i = 0; i < 4; i++) { - if (bits[i] === 0) continue; - const [ax, ay, bx, by] = EDGE[i]; - const aHigh = bits[i] === 3; - bump(ci + ax, cj + ay, aHigh, c.level); - bump(ci + bx, cj + by, !aHigh, c.level); - } - } - return bounds; -}; -const twoSided = (b: Bracket): boolean => Number.isFinite(b.lo) && Number.isFinite(b.hi); - -const CI0 = Math.ceil((sweep.region.x0 - CLIFF_CELL_CENTER_X) / G); -const CJ0 = Math.ceil((sweep.region.y0 - CLIFF_CELL_CENTER_Y) / G); -const portAt = (i: number, j: number): number => fields.cliffElevation(i * G, j * G); - -describe("what suppresses the cliffs the port still over-places", () => { - /** - * One-sided bounds are evidence too, and #107 left them on the table. A corner - * the game only ever made the HIGH side of a crossing at level `L` is asserted - * to be above `L`; the port's value must clear it. Across every corner the two - * fixtures constrain from one side, none is contradicted. - */ - it("no one-sided bound from the game contradicts the port's field", () => { - const bounds = reconstruct(allCases); - let oneSided = 0; - let contradicting = 0; - for (let j = CJ0; j <= CJ0 + 64; j++) - for (let i = CI0; i <= CI0 + 64; i++) { - const b = bounds.get(`${String(i)},${String(j)}`); - if (b === undefined || twoSided(b)) continue; - oneSided++; - const port = portAt(i, j); - if ((Number.isFinite(b.lo) && port <= b.lo) || (Number.isFinite(b.hi) && port >= b.hi)) - contradicting++; - } - expect(oneSided).toBe(1711); - expect(contradicting).toBe(0); - }, 120000); - - /** - * **Why the wider sweep is not worth capturing.** Adding the bands' 10 levels - * spans 70..1150 instead of 700..900 - a 5x wider window at 24x the spacing - - * and it rescues exactly ONE of the 681 corners the fine sweep left - * unbracketed. If those corners were unobserved because the game's field puts - * them somewhere the fine sweep does not reach, levels that far out would have - * caught a great many of them. - * - * The complementary half is what makes it conclusive: at the 294 corners the - * game never constrains at all, the PORT asserts 8,906 crossings over the same - * levels, and there is no corner among them where the port is also silent. The - * two sides are not disagreeing about a value, they are disagreeing about - * whether anything is emitted. - */ - it("10 more levels spanning 70..1150 rescue 1 of 681 unbracketed corners", () => { - const fine = reconstruct(fineCases); - const all = reconstruct(allCases); - let inRange = 0; - let unbracketedByFine = 0; - let noObservation = 0; - let oneSidedOnly = 0; - let gained = 0; - let gainedContainingPort = 0; - let portCrossingsAtSilent = 0; - let silentWherePortAlsoSilent = 0; - - for (let j = CJ0; j <= CJ0 + 64; j++) - for (let i = CI0; i <= CI0 + 64; i++) { - const port = portAt(i, j); - if (port < 700 || port > 900) continue; - inRange++; - const f = fine.get(`${String(i)},${String(j)}`); - if (f !== undefined && twoSided(f)) continue; - unbracketedByFine++; - - const a = all.get(`${String(i)},${String(j)}`); - if (a === undefined) { - noObservation++; - // What the port claims at this corner's four edges, same levels. - let n = 0; - for (const [di, dj] of [ - [1, 0], - [-1, 0], - [0, 1], - [0, -1], - ]) { - const q = portAt(i + di, j + dj); - if (q < 0 || port < 0) continue; - for (const L of allLevels) if (Math.min(port, q) < L && L <= Math.max(port, q)) n++; - } - portCrossingsAtSilent += n; - if (n === 0) silentWherePortAlsoSilent++; - continue; - } - if (!twoSided(a)) { - oneSidedOnly++; - continue; - } - gained++; - if (port > a.lo && port < a.hi) gainedContainingPort++; - } - - expect(inRange).toBe(1659); - expect(unbracketedByFine).toBe(681); - expect(noObservation).toBe(294); - expect(oneSidedOnly).toBe(386); - expect(gained).toBe(1); - expect(gainedContainingPort).toBe(1); - // The port is loudly asserting crossings exactly where the game says nothing. - expect(portCrossingsAtSilent).toBe(8906); - expect(silentWherePortAlsoSilent).toBe(0); - }, 120000); - - /** - * Two suppressor candidates, each scored against the matched population's own - * base rate rather than against zero. - * - * **Rocks** - `Surface::wouldCollide` also tests entities and the port only - * models the tile half, so a rock standing where a cliff would go is the - * obvious unported suppressor. It does not survive: the surplus is at 10.6% - * against a 7.0% base, and the wrong-orientation cells sit BELOW base at 5.1%. - * A real suppressor cannot be anti-correlated with half the defect. - * - * **The default `cliffiness_basic` gate** - a confound check, not a candidate. - * The collapsed oracle routes `cliffiness` at a literal 1 so the gate should be - * gone; if the routing had silently not taken, the residual would just be the - * gate. It has not: the three populations are flat at 46.0 / 51.4 / 44.9% fully - * shut, and the game emits 8,588 cells the default gate would have blocked - * outright. The oracle #106 to #108 rest on is sound on this axis. - */ - it("neither rocks nor the default cliffiness gate separates the surplus", () => { - const rockInBox = (code: number, x: number, y: number): boolean => { - const b = cliffCollisionTileBox(code, x, y); - if (b === undefined) return false; - for (let tx = b.left; tx <= b.right; tx++) - for (let ty = b.top; ty <= b.bottom; ty++) if (rockAt(tx, ty)) return true; - return false; - }; - /** Fraction of the cell's four edges the DEFAULT gate would leave open. */ - const gateOpen = (x: number, y: number): number => { - const ci = (x - CLIFF_CELL_CENTER_X) / G; - const cj = (y - CLIFF_CELL_CENTER_Y) / G; - const c = (i: number, j: number): number => cliffiness(i * G, j * G); - const q = [c(ci, cj), c(ci + 1, cj), c(ci, cj + 1), c(ci + 1, cj + 1)]; - return ( - [(q[0] + q[2]) / 2, (q[1] + q[3]) / 2, (q[0] + q[1]) / 2, (q[2] + q[3]) / 2].filter( - (e) => e > 0.5, - ).length / 4 - ); - }; - - const tally = { - matched: { n: 0, rock: 0, shut: 0 }, - surplus: { n: 0, rock: 0, shut: 0 }, - wrong: { n: 0, rock: 0, shut: 0 }, - }; - - for (const c of fineCases) { - const ours = new Map( - makeCliffPlacementFromFields( - { cliffElevation: fields.cliffElevation, cliffiness: (): number => 1 }, - { - elevation0: c.level, - interval: 1000000, - smoothing: 0, - tileCollides: (x, y): boolean => VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name), - cellRejects: oreRejects, - rejectAtCrossingStage: true, - }, - ) - .placedCells(sweep.region.x0, sweep.region.y0, sweep.region.x1, sweep.region.y1) - .map((p) => [`${String(p.x)},${String(p.y)}`, p.code] as const), - ); - const game = new Map(); - for (const e of c.cliffs) { - if (e.name !== "cliff-vulcanus") continue; - if (e.x < sweep.region.x0 || e.x >= sweep.region.x1) continue; - if (e.y < sweep.region.y0 || e.y >= sweep.region.y1) continue; - const code = gameCodeOf(e.orientation); - if (code !== undefined) game.set(`${String(e.x)},${String(e.y)}`, code); - } - for (const [k, code] of ours) { - const [xs, ys] = k.split(","); - const x = Number(xs); - const y = Number(ys); - const t = game.get(k); - const bucket = t === undefined ? tally.surplus : t === code ? tally.matched : tally.wrong; - bucket.n++; - if (rockInBox(code, x, y)) bucket.rock++; - if (gateOpen(x, y) === 0) bucket.shut++; - } - } - - expect(tally.matched).toEqual({ n: 18657, rock: 1312, shut: 8591 }); - expect(tally.surplus).toEqual({ n: 1199, rock: 127, shut: 616 }); - expect(tally.wrong).toEqual({ n: 691, rock: 35, shut: 309 }); - - const rate = (b: { n: number; rock: number }): number => b.rock / b.n; - // Refuted BY the base rate, not by a bare count: the wrong-code population - // is anti-correlated with rocks, which no suppressor of it could be. - expect(rate(tally.wrong)).toBeLessThan(rate(tally.matched)); - expect(rate(tally.surplus) / rate(tally.matched)).toBeLessThan(1.6); - - // The confound control: the game emits thousands of cells the DEFAULT gate - // would block, so the constant-1 routing genuinely opened it. - expect(tally.matched.shut).toBeGreaterThan(8000); - }, 600000); -}); diff --git a/test/vulcanusCliffSuppressorLevers.spec.ts b/test/vulcanusCliffSuppressorLevers.spec.ts deleted file mode 100644 index c7175d90..00000000 --- a/test/vulcanusCliffSuppressorLevers.spec.ts +++ /dev/null @@ -1,402 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import levers from "./fixtures/oracle-vulcanus-cliff-suppressor-levers.seed123456.json"; -import { - CLIFF_CODE_TO_ORIENTATION, - CLIFF_ORIENTATION_COLLISION_BOX, - CLIFF_ORIENTATION_NAMES, - cliffCollisionTileBox, -} from "../src/noise/cliffs/cliffCatalog"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; -import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -/** - * **What suppresses the non-ore cliff residual at `[1500,1500]`** (#84), asked - * with LEVERS rather than with predicates. - * - * #110 left two targets: the ore rule's 9 unreached cells, and a non-ore - * residual of 13 wrong orientations plus 10 surplus cells that the ore rule - * cannot touch. Its handoff asked for a lever that isolates the second the way - * `autoplace_controls` isolated the first, and named identifying the suspect as - * part of the job. `oracle-vulcanus-cliff-suppressor-levers` supplies two. - * - * The answers, in the order they were found: - * - * 1. **No placed entity suppresses a Vulcanus cliff.** Switching the whole - * `entity` autoplace category off removes every autoplaced entity in the - * region - 409 rocks, 115 chimneys, 45 rock explosions and all 8 - * `crater-cliff`s - and the cliff set does not move by one cell. Rocks were only ever refuted statistically (#109, against our own - * rock model); this is the class excluded positively. - * 2. **Cliffs do not collide with each other**, refuted by the game's own - * output rather than by a model: 293 pairs of the game's own cliffs have - * overlapping collision rectangles. - * 3. **Lava suppresses 169 cells, and our rejection gets 166 of them with 5 - * false positives** - precision 0.9708, recall 0.9822. Its errors are not - * spread over the region: they ARE part of the residual, accounting for 3 of - * the 10 surplus cells and all 3 of the missing ones. - * 4. With **neither ore nor lava in the world**, the port's recall is - * **1.0000** - it misses nothing the game places - and what remains is 9 - * wrong orientations and 12 surplus cells in four tight clusters. - * - * (4) is the sharpest statement of the residual there has been, and it is the - * one to hand forward: the crossing field plus the repair produce a set that - * CONTAINS the game's, so everything left is over-placement, and both rejections - * are now measured against known sets rather than fitted. - */ - -const INPUT = { seed0: levers.seed, startingPositions: [{ x: 0, y: 0 }] }; -const ctx = withCtxDefaults(INPUT); -const fields = makeVulcanusCliffFields(ctx); -const tileAt = makeVulcanusTileResolver(INPUT); - -const nameToId = new Map(CLIFF_ORIENTATION_NAMES.map((n, i) => [n, i])); -const codeForOrientationId = new Map(); -for (const [code, id] of Object.entries(CLIFF_CODE_TO_ORIENTATION)) - if (!codeForOrientationId.has(id)) codeForOrientationId.set(id, Number(code)); - -const R = levers.region; -const inR = (x: number, y: number): boolean => x >= R.x0 && x < R.x1 && y >= R.y0 && y < R.y1; - -const armOf = (label: string): (typeof levers.cases)[number] => { - const c = levers.cases.find((k) => k.label === label); - if (c === undefined) throw new Error(`no arm ${label}`); - return c; -}; - -/** The arm's `cliff-vulcanus` cells in the region, as `"x,y" -> orientation id`. */ -const gameSet = (label: string): Map => { - const m = new Map(); - for (const e of armOf(label).cliffs) { - if (e.name !== "cliff-vulcanus" || !inR(e.x, e.y)) continue; - const id = nameToId.get(e.orientation ?? ""); - if (id !== undefined) m.set(`${String(e.x)},${String(e.y)}`, id); - } - return m; -}; - -/** - * The shipping model with the ore out of the picture, so what is left is the - * crossing field, the repair, and the LAVA rejection alone. `lava: false` drops - * that rejection too, which is what the lava lever is scored against. - */ -const portSet = (lava: boolean): Map => - new Map( - makeCliffPlacementFromFields(fields, { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, - tileCollides: lava - ? (x, y): boolean => VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name) - : undefined, - rejectAtCrossingStage: true, - }) - .placedCells(R.x0, R.y0, R.x1, R.y1) - .map( - (p) => [`${String(p.x)},${String(p.y)}`, CLIFF_CODE_TO_ORIENTATION[p.code] ?? -1] as const, - ), - ); - -const score = (ours: Map, game: Map): Record => { - let matched = 0; - let wrong = 0; - let surplus = 0; - let missing = 0; - for (const [k, id] of ours) { - const t = game.get(k); - if (t === undefined) surplus++; - else if (t === id) matched++; - else wrong++; - } - for (const k of game.keys()) if (!ours.has(k)) missing++; - return { matched, wrong, surplus, missing }; -}; - -describe("what suppresses the Vulcanus cliff residual, by lever", () => { - /** - * **The entity class, excluded positively.** `autoplace_controls` cannot ask - * this: a control only reaches prototypes that name one, so the four resources - * have one and the rocks, the chimneys and `crater-cliff` have none. The - * `entity` autoplace CATEGORY can be switched off wholesale, and that is the - * whole class in one arm. - * - * The lever's own proof is in the same run three times over - the - * `autoplace_settings` the surface read BACK, the 573 entities that vanished, - * and the 8 `crater-cliff`s that went with them. Without those, "the cliffs did - * not move" and "the override never applied" are the same observation. - * - * A vacuity check on the other side too: `cliff-vulcanus` comes from - * `cliff_settings`, not from the entity category, so it must SURVIVE the lever. - * It does, all 916 of them, with identical orientations. - */ - it("no placed entity suppresses a cliff - the entity category lever moves nothing", () => { - const base = armOf("resources OFF via controls"); - const lever = armOf("entity autoplace category OFF"); - - // The lever landed: the category is off at the source. - expect(base.effectiveAutoplaceSettings?.entity).toEqual({ - treat_missing_as_default: true, - settingsCount: 16, - }); - expect(lever.effectiveAutoplaceSettings?.entity).toEqual({ - treat_missing_as_default: false, - settingsCount: 0, - }); - - // ...and it emptied the world of exactly the class under suspicion. Every - // `simple-entity` - which is what the rocks and the chimneys are, and the - // only autoplaced type on Vulcanus carrying a collision box other than the - // resources - is gone, along with the rock explosions. - // - // What survives is one demolisher and its parts (54 `segment`, plus a - // corpse, a trail and a smoke cloud). Those are spawned by the unit, not - // autoplaced, so the lever cannot reach them and they are named here rather - // than swept into a "non-cliff" total that would then have to read 4. - const count = ( - arm: typeof base, - pred: (e: { name: string; type: string }) => boolean, - ): number => (arm.entities ?? []).filter(pred).length; - const isRock = (e: { name: string }): boolean => e.name.includes("volcanic-rock"); - const isChimney = (e: { name: string }): boolean => e.name.startsWith("vulcanus-chimney"); - expect(count(base, isRock)).toBe(409); - expect(count(lever, isRock)).toBe(0); - expect(count(base, isChimney)).toBe(115); - expect(count(lever, isChimney)).toBe(0); - expect(count(base, (e) => e.type === "simple-entity")).toBe(524); - expect(count(lever, (e) => e.type === "simple-entity")).toBe(0); - expect(count(base, (e) => e.type === "explosion")).toBe(45); - expect(count(lever, (e) => e.type === "explosion")).toBe(0); - const DEMOLISHER = new Set(["segment", "segmented-unit", "corpse", "smoke-with-trigger"]); - expect(count(lever, (e) => e.type !== "cliff" && !DEMOLISHER.has(e.type))).toBe(0); - expect(count(lever, (e) => DEMOLISHER.has(e.type))).toBe(58); - expect(base.cliffs.filter((c) => c.name === "crater-cliff").length).toBe(8); - expect(lever.cliffs.filter((c) => c.name === "crater-cliff").length).toBe(0); - - // And the cliffs did not move - not in count, not in position, not in - // orientation. Bit-for-bit, which is a much stronger claim than a total. - const a = gameSet("resources OFF via controls"); - const b = gameSet("entity autoplace category OFF"); - expect(a.size).toBe(892); - expect(b.size).toBe(a.size); - for (const [k, id] of a) expect(b.get(k)).toBe(id); - }); - - /** - * **Cliffs do not collide with each other.** A tempting candidate, since a - * cliff IS an entity with a collision box and `tryToAddCliff` places them one - * at a time - a run that terminates where the port continues looks exactly like - * a cliff refusing to sit beside its neighbour. - * - * It needs no model to refute, because the GAME'S OWN OUTPUT contains the - * counterexamples: 293 pairs of cliffs the game placed have overlapping - * collision rectangles. Whatever `tryToAddCliff` tests, it is not that. - * - * The port's own set is the vacuity arm - if the counter were broken it would - * report 0 there too, and it does not. - */ - it("cliff-vs-cliff box overlap is refuted by the game's own cliffs", () => { - const boxOf = (k: string, id: number): [number, number, number, number] => { - const [xs, ys] = k.split(","); - const [l, t, r, b] = CLIFF_ORIENTATION_COLLISION_BOX[id]; - return [Number(xs) + l, Number(ys) + t, Number(xs) + r, Number(ys) + b]; - }; - const overlappingPairs = (cells: Map): number => { - const list = [...cells].map(([k, id]) => boxOf(k, id)); - let n = 0; - for (let i = 0; i < list.length; i++) - for (let j = i + 1; j < list.length; j++) { - const p = list[i]; - const q = list[j]; - if (p[0] < q[2] && q[0] < p[2] && p[1] < q[3] && q[1] < p[3]) n++; - } - return n; - }; - expect(overlappingPairs(gameSet("resources OFF via controls"))).toBe(293); - expect(overlappingPairs(portSet(true))).toBe(299); - }, 120000); - - /** - * **The lava lever, and the first precision/recall the tile rejection has - * ever had.** Dropping `lava`/`lava-hot` from the tile autoplace leaves the - * elevation the crossings read untouched - tiles are downstream of it - and - * takes away the only thing `tryToAddCliff`'s tile test can reject against. So - * the cells that APPEAR are the game's own answer to which cliffs lava - * suppresses, exactly as `autoplace_controls` gave the ore's answer in #110. - * - * Before this the rejection was scored only by how much it improved the - * totals (#84 item 1: "185 false positives dropped, 13 true"), which cannot - * distinguish a rule that is right from one that is merely profitable. Now: - * **166 of 169, with 5 false positives.** - * - * The 6,709 lava tiles (2,466 `lava` + 4,243 `lava-hot`) that become 0 are the non-vacuity arm, and they are - * counted in the same run that placed the cliffs - "lava suppresses nothing" - * and "the tile override never applied" are otherwise one observation. - * - * Note `appeared` is 3, not 0: removing lava also REMOVES three cells, because - * a neighbour that stops being rejected takes back a shared edge and recodes - * the survivor (#103, #108). So this lever is not one-way the way the ore's - * was, and the three are reported rather than filtered out. - */ - it("scores the lava rejection against the lava lever", () => { - const on = armOf("resources OFF via controls"); - const off = armOf("resources OFF, LAVA TILES OFF"); - expect(on.tileCounts).toEqual({ lava: 2466, "lava-hot": 4243 }); - expect(off.tileCounts).toEqual({ lava: 0, "lava-hot": 0 }); - expect(off.effectiveAutoplaceSettings?.tile).toEqual({ - treat_missing_as_default: false, - settingsCount: 17, - }); - - const gOn = gameSet("resources OFF via controls"); - const gOff = gameSet("resources OFF, LAVA TILES OFF"); - expect(gOn.size).toBe(892); - expect(gOff.size).toBe(1058); - - const suppressed = new Set([...gOff.keys()].filter((k) => !gOn.has(k))); - const appeared = [...gOn.keys()].filter((k) => !gOff.has(k)); - expect(suppressed.size).toBe(169); - expect(appeared.length).toBe(3); - - const pOn = portSet(true); - const pOff = portSet(false); - const ours = new Set([...pOff.keys()].filter((k) => !pOn.has(k))); - const hit = [...ours].filter((k) => suppressed.has(k)).length; - expect(ours.size).toBe(171); - expect(hit).toBe(166); - expect(hit / ours.size).toBeCloseTo(0.9708, 4); // precision - expect(hit / suppressed.size).toBeCloseTo(0.9822, 4); // recall - }, 120000); - - /** - * **The rejection's errors are the residual, not a scatter.** With 169 true - * suppressions and 1,058 cells to be wrong about, 8 errors landing on the 26 - * cells the port already disagrees about is the finding - it says the lava - * rule is not a small independent inaccuracy but a named part of what is left. - * - * And they go BOTH WAYS: 3 cells the game rejects and we keep, 5 we reject and - * it keeps. A box that is uniformly too small or too big cannot produce that, - * so the shape is wrong rather than the size - which is exactly why this must - * not be tuned until it fits (#88, where the best-scoring box hid a second - * defect). - */ - it("the lava errors are the residual's own cells, and they point both ways", () => { - const gOn = gameSet("resources OFF via controls"); - const gOff = gameSet("resources OFF, LAVA TILES OFF"); - const pOn = portSet(true); - const pOff = portSet(false); - const suppressed = new Set([...gOff.keys()].filter((k) => !gOn.has(k))); - const ours = new Set([...pOff.keys()].filter((k) => !pOn.has(k))); - - const missedByUs = [...suppressed].filter((k) => !ours.has(k)).sort(); - const falseByUs = [...ours].filter((k) => !suppressed.has(k)).sort(); - expect(missedByUs).toEqual(["1658,1598.5", "1662,1630.5", "1722,1630.5"]); - expect(falseByUs).toEqual([ - "1638,1598.5", - "1638,1602.5", - "1662,1634.5", - "1674,1658.5", - "1674,1662.5", - ]); - - // Every cell we MISS is one of the port's surplus cells, and every cell the - // port is missing is one we falsely reject. Both directions, no leftovers. - const shipping = score(pOn, gOn); - expect(shipping).toEqual({ matched: 876, wrong: 13, surplus: 10, missing: 3 }); - for (const k of missedByUs) expect(gOn.has(k)).toBe(false); - for (const k of missedByUs) expect(pOn.has(k)).toBe(true); - const portMissing = [...gOn.keys()].filter((k) => !pOn.has(k)).sort(); - expect(portMissing).toEqual(["1638,1598.5", "1638,1602.5", "1662,1634.5"]); - expect(portMissing.every((k) => falseByUs.includes(k))).toBe(true); - }, 120000); - - /** - * **With neither ore nor lava, the port misses NOTHING.** 1,058 cells, recall - * 1.0000, precision 0.9802. That is the cleanest reading the crossing field - * and the repair have had, and it changes the shape of what is left: the port - * produces a strict SUPERSET of the game's cells, so every remaining defect is - * an over-placement, and the two rejections are the only things that can - * remove one. - * - * The 12 surplus cells sit in four tight clusters, and every one has the same - * shape - the port runs a cliff one or two cells past where the game's run - * ends in an entrance orientation. The 9 wrong orientations are the far side - * of those same edges (#103), not a separate problem. - */ - it("with neither ore nor lava the port's recall is 1.0000", () => { - const gOff = gameSet("resources OFF, LAVA TILES OFF"); - expect(score(portSet(false), gOff)).toEqual({ - matched: 1049, - wrong: 9, - surplus: 12, - missing: 0, - }); - - // The four clusters, as the handoff for whatever comes next. - const surplus = [...portSet(false).keys()].filter((k) => !gOff.has(k)).sort(); - expect(surplus.length).toBe(12); - const clusterOf = (k: string): string => { - const [xs, ys] = k.split(","); - return `${String(Math.round(Number(xs) / 64))},${String(Math.round(Number(ys) / 64))}`; - }; - expect(new Set(surplus.map(clusterOf)).size).toBe(4); - }, 120000); - - /** - * **The residual sits on the lava perimeter, and that is a rate against a - * control rather than an impression.** Distance is measured from the cell's - * own collision box to the nearest tile our resolver calls lava, so a cell - * whose box already touches lava is 0 and cannot appear here at all (the port - * would have rejected it). - * - * | population | n | lava within 2 tiles of the box | - * | --- | --- | --- | - * | matched (control) | 876 | 9 = **1.0%** | - * | surplus | 10 | 4 = **40%** | - * | wrong | 13 | 5 = **38%** | - * - * A 40x enrichment, and it is what led to the lava lever above. Two cautions - * kept with the number rather than dropped: - * - * - the effective sample size is the number of CLUSTERS, not of cells - four - * of them, not 23 - so this is a lead, not a significance claim - * (`below-chance-needs-a-clustered-null`); and - * - one cluster, `[1742..1746, 1530..1542]`, has NO lava within 10 tiles, so - * the residual was never going to be lava all the way down. The lever - * confirms it: that cluster survives with lava removed from the world. - */ - it("the residual is enriched at the lava perimeter, against the matched base rate", () => { - const gOn = gameSet("resources OFF via controls"); - const near = { matched: 0, wrong: 0, surplus: 0 }; - const total = { matched: 0, wrong: 0, surplus: 0 }; - for (const [k, id] of portSet(true)) { - const [xs, ys] = k.split(","); - const x = Number(xs); - const y = Number(ys); - const code = codeForOrientationId.get(id) ?? 0; - const b = cliffCollisionTileBox(code, x, y); - if (b === undefined) continue; - let gap = Infinity; - for (let tx = b.left - 2; tx <= b.right + 2; tx++) - for (let ty = b.top - 2; ty <= b.bottom + 2; ty++) { - if (!VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(tx, ty).name)) continue; - const dx = tx < b.left ? b.left - tx : tx > b.right ? tx - b.right : 0; - const dy = ty < b.top ? b.top - ty : ty > b.bottom ? ty - b.bottom : 0; - gap = Math.min(gap, Math.hypot(dx, dy)); - } - const t = gOn.get(k); - const bucket = t === undefined ? "surplus" : t === id ? "matched" : "wrong"; - total[bucket]++; - if (gap <= 2) near[bucket]++; - } - expect(total).toEqual({ matched: 876, wrong: 13, surplus: 10 }); - expect(near).toEqual({ matched: 9, wrong: 5, surplus: 4 }); - expect(near.surplus / total.surplus).toBeGreaterThan(30 * (near.matched / total.matched)); - }, 120000); -}); diff --git a/test/vulcanusCliffs.spec.ts b/test/vulcanusCliffs.spec.ts index 2908287d..47b2c1f5 100644 --- a/test/vulcanusCliffs.spec.ts +++ b/test/vulcanusCliffs.spec.ts @@ -1,58 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; import fixture from "./fixtures/oracle-vulcanus-cliffs.seed123456.json"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; -import { makeCliffPlacementFromFields, smoothingKnots } from "../src/noise/cliffs/cliffPlacement"; -import { - CLIFFINESS_BASIC_SEED1, - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - VULCANUS_CLIFF_RICHNESS, - makeCliffinessBasic, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; describe("Vulcanus cliffs", () => { - const positions = fixture.positions; const v = fixture.values; - it("cliffiness_basic matches the oracle to the fastapprox floor", () => { - const cliffiness = makeCliffinessBasic(fixture.seed0); - let worst = 0; - for (let i = 0; i < positions.length; i++) { - const p = positions[i]; - worst = Math.max(worst, Math.abs(cliffiness(p.x, p.y) - v.cliffiness_basic[i])); - } - // Measured worst residual, not a loosened tolerance. `cliffiness_basic` is a - // single 2-octave quick_multioctave through a clamp, so the residual is the - // primitive's own accuracy with nothing compounding it. - // - // **That residual fell 33x on 2026-08-18** - 2e-6 to 5.960e-8 - when - // `quick_multioctave_noise` stopped evaluating in f64 and took the game's own - // f32 operation order. 341 of these 434 points are now bit-exact, up from - // essentially none. The old comment here called 2e-6 "the primitive's own - // fastapprox floor"; it was not a floor, it was the port computing in the - // wrong precision. See docs/noise/quick-multioctave-noise-NOTES.md. - // - // Bound tightened to match the new measurement. Every one of this fixture's - // 434 positions IS on the 1/256 grid (checked), so unlike the climate - // fixtures there is no capture artifact inflating it - this number grades - // the port and nothing else. - expect(worst).toBeLessThan(8e-8); - }); - - it("the game reports cliff_richness = 1, which is what the port assumes", () => { - // Vulcanus has no cliff autoplace control (space-age/prototypes/autoplace-controls.lua - // defines gleba_cliff and fulgora_cliff only), so getModifiedRichness(richness, - // size) has no lever to move and cliff_richness is pinned at 1. This asserts - // that from the game rather than from reading the Lua - if a future version - // gives Vulcanus a cliff control, this fails and VULCANUS_CLIFF_RICHNESS (and - // the missing frequency lever) need revisiting. - expect(new Set(v.cliff_richness)).toEqual(new Set([1])); - expect(VULCANUS_CLIFF_RICHNESS).toBe(1); - }); - it("cliffiness_basic stays in [0.5, 1.5], the range the placement gate assumes", () => { // crossesCliff gates on the AVERAGE of two corners' cliffiness being > 0.5. // On Nauvis cliffiness is a hard 0-or-10, so that reads as "either corner is @@ -67,95 +19,4 @@ describe("Vulcanus cliffs", () => { expect(lo).toBeLessThan(0.51); expect(hi).toBeGreaterThan(1.4); }); - - it("pins the planet's cliff band constants", () => { - // From planet_map_gen.vulcanus()'s cliff_settings in - // space-age/prototypes/planet/planet-map-gen.lua:21-26. Unlike Nauvis these - // are NOT read off the user's preset - the preset describes a Nauvis surface - // and carries no Vulcanus cliff_settings. - expect(VULCANUS_CLIFF_ELEVATION_0).toBe(70); - expect(VULCANUS_CLIFF_ELEVATION_INTERVAL).toBe(120); - expect(CLIFFINESS_BASIC_SEED1).toBe(123); - }); - - it("places cliffs on the game's 4-tile lattice", () => { - const ctx = withCtxDefaults({ seed0: fixture.seed0 }); - const placement = makeCliffPlacementFromFields(makeVulcanusCliffFields(ctx), { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, - }); - const cells = placement.placedCells(-256, -256, 256, 256); - // Vulcanus is cliff-heavy, so an empty result means the port broke, not that - // the window is quiet. - expect(cells.length).toBeGreaterThan(50); - // Same lattice as Nauvis (x = 2, y = 2.5 mod 4) - it is engine geometry, not - // planet data, and makeCliffPlacementFromFields is shared between the two. - for (const { x, y } of cells) { - expect(((x % 4) + 4) % 4).toBe(2); - expect(((y % 4) + 4) % 4).toBe(2.5); - } - }, 120000); - - it("puts no cliff below cliff_elevation_0 - on the SMOOTHED field", () => { - // crossesCliff needs max(a, b) >= elevation_0 for a band to exist, so every - // placed cell must have a corner at or above 70. Vulcanus elevation runs - // from about -82 to 1556, so this genuinely excludes a large low-lying part - // of the map rather than being trivially satisfied. - // - // **The elevation this holds for is the smoothed one, not the raw field.** - // Vulcanus runs `cliff_smoothing = 1`, which replaces each corner with a - // bilinear blend of its chunk knots before the gate is applied, so a cliff - // can legitimately land where the TRUE elevation is below 70 - measured, a - // real cell at raw 49.07. That is the game's behaviour, not a port defect: - // it is what the prototype docs mean by smoothing making "placement - // inaccurate". Asserting on the raw field here would be asserting something - // the game does not do. - const ctx = withCtxDefaults({ seed0: fixture.seed0 }); - const fields = makeVulcanusCliffFields(ctx); - const placement = makeCliffPlacementFromFields(fields, { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, - }); - - // The blend the placement pass applies, recomputed independently here from - // the exported knot rule. - // Takes corner INDICES, not positions: the sample lattice is the bare - // `(i*4, j*4)`, while a cell's centre carries the prototype's `grid_offset` - // (2, 2.5). Those differ by 0.5 in y, so deriving one from the other is the - // exact mistake `CLIFF_CELL_CENTER_X` documents. - const smoothed = (i0: number, j0: number): number => { - const kx = smoothingKnots(i0); - const ky = smoothingKnots(j0); - const at = (i: number, j: number): number => fields.cliffElevation(i * 4, j * 4); - return ( - (1 - kx.t) * (1 - ky.t) * at(kx.lo, ky.lo) + - kx.t * (1 - ky.t) * at(kx.hi, ky.lo) + - (1 - kx.t) * ky.t * at(kx.lo, ky.hi) + - kx.t * ky.t * at(kx.hi, ky.hi) - ); - }; - - let sawBelowRaw = false; - for (const { x, y } of placement.placedCells(-256, -256, 256, 256)) { - // Centre (i*4 + 2, j*4 + 2.5) -> cell index -> the four corner INDICES. - const i0 = (x - 2) / 4; - const j0 = (y - 2.5) / 4; - const corners = [ - [i0, j0], - [i0 + 1, j0], - [i0, j0 + 1], - [i0 + 1, j0 + 1], - ]; - const highest = Math.max(...corners.map(([i, j]) => smoothed(i, j))); - expect(highest).toBeGreaterThanOrEqual(VULCANUS_CLIFF_ELEVATION_0); - if (Math.max(...corners.map(([i, j]) => fields.cliffElevation(i * 4, j * 4))) < 70) { - sawBelowRaw = true; - } - } - // Pin the consequence too, so a future change that quietly reverts to raw - // elevation fails here rather than only in the entity-count spec. - expect(sawBelowRaw).toBe(true); - }, 120000); }); diff --git a/test/vulcanusElevationLevels.spec.ts b/test/vulcanusElevationLevels.spec.ts deleted file mode 100644 index 04eabdeb..00000000 --- a/test/vulcanusElevationLevels.spec.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import fx from "./fixtures/oracle-vulcanus-elevation-levels.seed123456.json"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { - makeCliffinessBasic, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; -import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; - -const key = (x: number, y: number): string => `${String(x)},${String(y)}`; - -const ctx = withCtxDefaults({ seed0: fx.seed, startingPositions: [{ x: 0, y: 0 }] }); -const base = makeVulcanusCliffFields(ctx); -const fields = { - cliffElevation: base.cliffElevation, - // richness 4 in the capture, so cliffiness_basic saturates and its gate is open. - cliffiness: makeCliffinessBasic(fx.seed, 4), -}; - -/** - * The lava rejection, the same predicate `renderVulcanusCliffs` passes. Off by - * default here: this file's job is to invert the elevation FIELD, and the - * rejection brings the tile resolver - a different subsystem - into the answer. - * The last test turns it on deliberately, to attribute a residual to it. - */ -const tileAt = makeVulcanusTileResolver({ seed0: fx.seed, startingPositions: [{ x: 0, y: 0 }] }); -const lavaCollides = (x: number, y: number): boolean => - VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name); - -/** Per level: how many cells the game placed, how many we place, and the overlap. */ -const atLevel = ( - index: number, - reject = false, -): { level: number; game: number; ours: number; both: number } => { - const c = fx.cases[index]; - const r = fx.region; - const game = new Set(); - for (const p of c.cliffs.filter((q) => q.name === "cliff-vulcanus")) - game.add(key(Math.round((p.x - 2) / 4), Math.round((p.y - 2.5) / 4))); - const cells = makeCliffPlacementFromFields(fields, { - elevation0: c.elevation0, - interval: c.effective?.cliff_elevation_interval ?? 1000000, - smoothing: 0, - tileCollides: reject ? lavaCollides : undefined, - }).placedCells(r.x0, r.y0, r.x1, r.y1); - let both = 0; - const ours = new Set(); - for (const p of cells) { - const k = key(Math.round((p.x - 2) / 4), Math.round((p.y - 2.5) / 4)); - if (ours.has(k)) continue; - ours.add(k); - if (game.has(k)) both++; - } - return { level: c.elevation0, game: game.size, ours: ours.size, both }; -}; - -/** - * **The elevation LEVEL SET, which localises #18 to a single term of - * `vulcanus_elev`.** - * - * With the rule collapsed (`cliff_smoothing = 0`, one contour via - * `cliff_elevation_interval = 1e6`, the cliffiness gate held open by - * `richness = 4`) a cell carries a cliff exactly when its corner elevations - * straddle `cliff_elevation_0`. Sweeping that threshold therefore measures the - * elevation field **the generator itself reads** - something no expression - * sample can do, because `calculate_tile_properties` answers for a different - * channel and the open question was precisely whether the two agree. - * - * The answer has a sharp edge at **120**: - * - * | `cliff_elevation_0` | game | ours | ours/game | - * | --- | --- | --- | --- | - * | 20 - 110 | 658 -> 142 | 979 -> 200 | **1.20 - 1.49** | - * | 120 - 200 | 122 -> 97 | 126 -> 97 | **1.00 - 1.04** | - * - * Above 120 the port reproduces the game essentially exactly; below it we - * over-place by 20-50%. That edge is not arbitrary. `vulcanus_elev` is - * - * ``` - * vulcanus_elevation_offset - * + lerp(lerp(120 * vulcanus_basalt_lakes_multisample, - * 20 + vulcanus_mountains_func * vulcanus_mountains_elevation_multiplier, - * vulcanus_mountains_biome), - * vulcanus_ashlands_func, - * vulcanus_ashlands_biome) - * ``` - * - * and `vulcanus_basalt_lakes` is a `min(1, ...)`, so the basalt-lakes branch - * **saturates at exactly 120**. Elevations above 120 come from the mountains and - * ashlands branches, which contain no `multisample`; elevations below it are - * governed by `vulcanus_basalt_lakes_multisample`, the only `multisample` in the - * chain and the only term with no counterpart on Nauvis - which the port - * reproduces 334/334. - * - * So the residual is in that one term, and the mechanism to suspect is the one - * its own documentation describes: `multisample` evaluates "in a separate noise - * program with a larger grid" whose "sub-grids are copied to the main program". - * The cliff generator's program walks the 4-tile corner lattice; - * `calculate_tile_properties` - the channel - * `docs/noise/vulcanus-multisample-NOTES.md` measured the primitive through, and - * the channel every elevation fixture was captured through - does not. A `min()` - * of four samples is an erosion operator, so a coarser effective grid in the - * generator would smooth the field exactly the way this over-placement implies. - */ -describe("Vulcanus elevation, inverted through a cliff_elevation_0 sweep", () => { - const rows = fx.cases.map((_, i) => atLevel(i)); - - it("swept 19 levels and every override applied", () => { - expect(rows.map((r) => r.level)).toEqual(fx.cases.map((c) => c.elevation0)); - for (const c of fx.cases) { - expect(c.effective?.cliff_elevation_0).toBe(c.elevation0); - expect(c.effective?.cliff_smoothing).toBe(0); - expect(c.effective?.richness).toBe(4); - } - // Non-vacuity: every level placed a substantial number of cliffs, so no row - // below is comparing empty sets. - for (const r of rows) expect(r.game).toBeGreaterThan(90); - }); - - it("reproduces the game's whole cliff set at EVERY level - recall 1.000", () => { - // **The threshold this file was written to document is GONE, and that is the - // point.** Before the grid fix the ratio was 1.20-1.49 below an elevation of - // 120 and 1.00-1.04 above it - a clean edge exactly where - // `120 * vulcanus_basalt_lakes_multisample` saturates, which is what - // identified `multisample` as the cause (test/multisampleGrid.spec.ts). - // With its offsets scaled to the consuming program's grid, every level - // matches. - for (const r of rows) { - expect(r.both).toBe(r.game); - // Non-vacuity: every level compared a substantial set. - expect(r.game).toBeGreaterThan(90); - } - }); - - it("has no regime split left - the edge at 120 is gone", () => { - const high = rows.filter((r) => r.level >= 120).map((r) => r.ours / r.game); - const low = rows.filter((r) => r.level <= 110).map((r) => r.ours / r.game); - expect(high.length).toBe(9); - expect(low.length).toBe(10); - // Measured: every ratio now lies in 1.000 - 1.085, against 1.20 - 1.49 - // below the edge before. Asserting a single band across BOTH regimes is the - // inversion of the old test, which asserted a gap between them. - for (const v of [...high, ...low]) expect(v).toBeLessThanOrEqual(1.09); - // A small split does survive here - worst low ratio 1.085 against worst - // high 1.018, a gap of 0.067 where it used to be 0.16 - and #84 item 2 - // recorded it as a suspected second-order error in the same `multisample` - // term. **It is not. See the next test**, which attributes it. - const gap = Math.max(...low) - Math.max(...high); - expect(gap).toBeGreaterThan(0); - expect(gap).toBeLessThan(0.1); - }); - - /** - * **The surviving split is a MEASUREMENT artefact, not a second-order error - * in `multisample`** (measured 2026-08-01, closing #84 item 2). - * - * Everything above compares our placement, which does not run the lava - * rejection, against the game's, which always does. `tryToAddCliff` drops any - * cliff whose collision box touches a lava tile - and on Vulcanus the lava is - * the basalt lakes, i.e. exactly the low-elevation range where the excess sat. - * So the arm reading "we over-place below 120" was really reading "we do not - * delete what the game deletes, and there is more to delete down there." - * - * Running both sides with the rejection accounts for most of it: - * - * | `cliff_elevation_0` | ours/game, no rejection | with rejection | - * | --- | --- | --- | - * | 20 | 1.085 | 1.037 | - * | 40 | 1.048 | **1.042** | - * | 60 | 1.044 | 1.039 | - * | 90 - 130 | 1.008 - 1.018 | 1.008 - 1.018 | - * | 140 - 200 | 1.000 - 1.009 | 1.000 - 1.009 | - * - * Worst-low 1.042 against worst-high 1.018: the gap goes 0.067 -> **0.024**. - * - * **These numbers correct PR #86, which reported 0.018.** That measurement was - * taken while the collision box was the `rotbb` AABB rather than the rotated - * rectangle, so the rejection was over-aggressive - it deleted cells the game - * keeps, which flattered exactly this ratio. Fixing the box - * (`cliffBoxCoversTile`) removes that flattery and the honest residual is - * larger. A too-strong correction hides the thing it is correcting. - * - * **Recall is the strong signal here, and it is now ~1.000 everywhere**: every - * level from 30 to 200 reproduces the game's entire cliff set, and level 20 - * misses exactly 1 of 658. Before the box fix the rejection cost recall in - * precisely this regime - 0.951 at level 20 rising to 1.000 at 140 - which is - * what pointed at the box in the first place. The old reading of that deficit - * (a lava mask one tile too fat) was WRONG and is recorded in - * `docs/noise/vulcanus-cliffs-NOTES.md`: a dense 994-position capture at those - * exact boundaries found ZERO lava mismatches. - * - * What is left is pure over-placement concentrated below elevation 120, with - * no recall cost. That is a smaller and cleaner residual than #84 item 2 - * described, and it is still open. - */ - it("attributes the split to the lava rejection, not to the elevation field", () => { - const withRejection = fx.cases.map((_, i) => atLevel(i, true)); - // Non-vacuity: the rejection must actually remove cells, or "the split went - // away" and "the predicate never fired" are the same observation. - const removed = rows.reduce((n, r, i) => n + (r.ours - withRejection[i].ours), 0); - console.log(`levels: rejection removed ${String(removed)} cells across the sweep`); - expect(removed).toBeGreaterThan(25); - for (const [i, w] of withRejection.entries()) - console.log( - ` level ${String(w.level).padStart(3)} game=${String(w.game).padStart(4)} noRej=${(rows[i].ours / w.game).toFixed(4)} rej=${(w.ours / w.game).toFixed(4)} rec=${(w.both / w.game).toFixed(4)}`, - ); - - const ratios = (rs: typeof rows, pick: (level: number) => boolean): number[] => - rs.filter((r) => pick(r.level)).map((r) => r.ours / r.game); - const low = Math.max(...ratios(withRejection, (l) => l <= 110)); - const high = Math.max(...ratios(withRejection, (l) => l >= 120)); - // Measured 1.0415 (level 40) and 1.0177 (level 130). Both bounds are upper, - // so the port may improve without editing them. - expect(low).toBeLessThanOrEqual(1.05); - expect(high).toBeLessThanOrEqual(1.02); - // The gap is what #84 item 2 was about: 0.067 without the rejection, 0.024 - // with it. Guarded as an upper bound only - it may shrink to zero or invert. - expect(low - high).toBeLessThan(0.03); - - // **Recall, which is what the box shape buys.** Asserted per level rather - // than in aggregate so a regime-shaped regression cannot average itself - // away. The three box models measured on this sweep, worst level: - // - // | box | worst per-level recall | - // | --- | --- | - // | AABB of the rotated rect (until #88) | 0.951 | - // | 45-degree oriented rect (#88, WRONG) | 0.999 | - // | raw stored rect (disasm, current) | 0.977 | - // - // The middle row scored best and was wrong - it shrank the box past what - // the engine uses and so also absorbed the unrelated orientation residual. - // Guarding at the truth, not at the flattering number. - for (const w of withRejection) expect(w.both / w.game).toBeGreaterThan(0.97); - }, 120000); -}); diff --git a/test/vulcanusOreCliffSeparation.spec.ts b/test/vulcanusOreCliffSeparation.spec.ts deleted file mode 100644 index 23d310ec..00000000 --- a/test/vulcanusOreCliffSeparation.spec.ts +++ /dev/null @@ -1,622 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../src/noise/cliffs/vulcanusCliffFields"; -import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; -import cliffFix from "./fixtures/oracle-vulcanus-cliff-entities.seed123456.json"; -import cornerFix from "./fixtures/oracle-vulcanus-cliff-corner-fields.seed123456.json"; -import repFix from "./fixtures/oracle-vulcanus-ore-cliff-replication.seed123456.json"; -import resFix from "./fixtures/oracle-vulcanus-resource-entities.seed123456.json"; - -/** - * The game's cliff/ore separation on Vulcanus, measured entity-to-entity - * (issue #24). Both sides are the game's own output, captured over identical - * regions by `test/oracle/capture.ts vulcanus-cliff-entities` and - * `vulcanus-resource-entities`, so nothing here depends on our port being right. - * - * Why it is worth a committed spec rather than a one-off measurement: the - * separation is the strongest single constraint we have on a mechanism nobody - * has identified yet, and #24 has already been re-framed twice by re-measuring. - * Pinning the game's own numbers means the next re-frame starts from data - * instead of from the previous summary. - */ -const key = (x: number, y: number): string => `${String(x)},${String(y)}`; - -function oreTiles(index: number): Set { - return new Set(resFix.cases[index].resources.map((p) => key(Math.floor(p.x), Math.floor(p.y)))); -} - -function gameCliffs(index: number): { x: number; y: number }[] { - return cliffFix.cases[index].cliffs.filter((c) => c.name === "cliff-vulcanus"); -} - -/** The 4x4 tile block a cliff cell occupies: x in [cx-2, cx+2), y in [cy-2.5, cy+1.5). */ -function footprint(cx: number, cy: number): string[] { - const tiles: string[] = []; - const y0 = Math.floor(cy - 2.5); - for (let tx = cx - 2; tx < cx + 2; tx++) { - for (let ty = y0; ty < y0 + 4; ty++) tiles.push(key(tx, ty)); - } - return tiles; -} - -describe("Vulcanus: the game separates cliffs from ore", () => { - // READ THE TWO BLOCKS BELOW THIS ONE BEFORE TRUSTING THE INTERPRETATION HERE. - // The counts in this block are still exactly what the game produced, but two - // of its conclusions were overturned on 2026-07-29: - // - // * the "chance baseline" / "ratio" columns use tile independence, which does - // not hold for two fields that come in a handful of blobs. Under a - // shift null, regions 0 and 2 are NOT significant (P = 0.51 and 0.29); only - // the ore-rich regions are. See the replication block. - // * "there is no single mechanism" understated it: there is no mechanism at - // all on the cliff side. `generateCliffs()` reads no resource field, and - // substituting the game's own elevation and cliffiness into our placement - // does not move one cell - so the overlap our port paints is #18's RULE - // error, not a missing exclusion. See the FIELDS/RULE block. - // - // Measured 2026-07-28. Region 1's 8/3933 reproduces the figure issue #24 was - // opened with, from an independent capture - and it comes out identical under - // either candidate y-anchoring of the footprint, so the result does not rest - // on that choice. - // - // | region | dominant resource | on cliff | chance baseline | ratio | - // | --- | --- | --- | --- | --- | - // | 0 `[0,0]` | tungsten | 0 / 945 | 6.9% | 0.000 | - // | 1 `[1500,1500]` | calcite + geyser | 8 / 3933 | 21.6% | 0.009 | - // | 2 `[-1200,800]` | coal | 0 / 1047 | 9.8% | 0.000 | - // - // It is uniform across all four resource names, which rules out a *per-resource* - // biome dependency - but NOT terrain generally. **Do not read the uniformity as - // ruling out terrain**; a dependence the resources SHARE would look exactly like - // this, and measurement says one partly does. Sampling Vulcanus elevation at the - // game's own ore and cliff positions (2026-07-28): - // - // | region | ore elevation p5/p50/p95 | ore below 70 | cliff elevation p5/p50/p95 | - // | --- | --- | --- | --- | - // | 0 `[0,0]` | -25 / 39 / 86 | **81.3%** | 25 / 94 / 300 | - // | 1 `[1500,1500]` | 738 / 894 / 1169 | 0.0% | 94 / 653 / 949 | - // | 2 `[-1200,800]` | 300 / 300 / 300 | 0.0% | 27 / 155 / 263 | - // - // `cliff_elevation_0 = 70`, so no cliff can exist below 70 at all. In region 0 - // that alone accounts for most of the separation - 81% of the game's ore is - // under the threshold. In region 2 the ore sits at a flat 300 while every cliff - // is below 263, so they are disjoint by elevation there too. **Region 1 is not - // explained**: the ranges genuinely overlap (ore 738-1169, cliffs 94-949) and - // the separation is still near-total. - // - // Region 2 has a sharper version of the same thing: its ore sits on a - // perfectly FLAT plateau. Local elevation gradient at the game's own ore - // positions there is 0.00 at p10, p50 and p90, against 5.46/13.59/22.82 at its - // cliffs. A cliff needs a band crossing between adjacent corners, and a flat - // field has none, so no cliff can exist there at all. - // - // So there is no single mechanism. **Region 1 remains unexplained**, and four - // candidates have now been measured and FALSIFIED for it - recorded here so - // they do not get re-tested: - // - // 1. **Elevation range.** Only 56% of region 1's cliffs fall outside the band - // holding 98% of its ore ([712, 1204]). The other **387 cliffs share the - // ore's own elevation band** and still almost no ore sits on them. - // 2. **Flatness / local gradient.** Region 1's ore and cliffs have effectively - // IDENTICAL gradient distributions (p10/p50/p90 = 3.78/10.94/23.06 for ore - // vs 4.22/10.98/23.56 for cliffs). Whatever separates them, it is not that - // ore sits on flat ground - unlike region 2, where this is the whole story. - // 3. **The volcano-spot exclusion.** `vulcanus_mountains_resource_favorability` - // is `clamp(main_region - (mountain_volcano_spots > 0.78), 0, 1)`, so ore is - // genuinely barred from volcano spots - and the game's ore respects it (0.2% - // above the cutoff). But only **4.0%** of region 1's cliffs are on volcano - // spots, so it cannot account for a ~100x separation. - // 4. **Collision, on both paths** - see the map-gen mask grid note below. - const expected: [number, number, number][] = [ - // index, max on-cliff entities, max ratio-to-chance - [0, 0, 0.001], - [1, 8, 0.02], - [2, 0, 0.001], - ]; - - for (const [index, maxOnCliff, maxRatio] of expected) { - const r = cliffFix.cases[index].region; - it(`region ${String(index)} [${String(r.x0)},${String(r.y0)}]: the game's ore is ~never on the game's cliffs`, () => { - const ore = oreTiles(index); - const cliffs = gameCliffs(index); - const covered = new Set(); - for (const c of cliffs) for (const t of footprint(c.x, c.y)) covered.add(t); - - let onCliff = 0; - for (const t of ore) if (covered.has(t)) onCliff++; - - const span = (r.x1 - r.x0) * (r.y1 - r.y0); - const chance = covered.size / span; - const rate = onCliff / ore.size; - - expect(ore.size).toBeGreaterThan(500); - expect(onCliff).toBeLessThanOrEqual(maxOnCliff); - expect(rate / chance).toBeLessThan(maxRatio); - }); - } - - it("the separation at [0,0] is far wider than a collision footprint", () => { - // No ore anywhere within 6 tiles (chebyshev) of ANY of the 283 cliff cell - // centres. A footprint-scale rejection - the cliff's own 4x4 box - would - // leave ore free to sit 3 tiles away, so whatever separates them at [0,0] - // acts over a much larger distance than collision can. The elevation table - // above is the likely reason here: most of region 0's ore is below the - // elevation cliffs need to exist at all, so the gap is terrain, not a test. - // - // Region 1 behaves differently: calcite comes within 1 tile of a cliff - // centre and 8 entities land inside footprints. So the mechanism is NOT one - // uniform distance test, and any fix that models it as a fixed exclusion - // radius will be wrong on one region or the other. - // - // Collision is ruled out on BOTH paths, which is worth recording because the - // map-gen path is not the one #24 checked. `EntityMapGenerationTask` keeps - // its own per-tile collision-mask grid over a 96x96 working area (`this+0x90`, - // one u16 mask-table index per tile); `tryToAddCliff` writes into it and - // `tryToAddEntity` tests against it via - // `EntityMapGenerationTask::wouldCollide` (`0x101625468`). So cliffs really do - // get a chance to block entities at generation time - but the masks still do - // not intersect (cliff: item/meltable/object/player/water_tile/is_lower_object/ - // is_object/cliff; resource: resource), so this path cannot be what separates - // them either. - // - // Generation ORDER is settled though, and it only goes one way: - // `computeInternal` calls `generateCliffs()` then `generateEntities()`, and - // `apply` calls `applyCliffs()` then `applyEntities()`. Cliffs are committed - // before ore in both phases, so "ore suppressed by cliffs" is possible and - // "cliffs suppressed by ore" is not. - const ore = oreTiles(0); - let closest = Infinity; - for (const c of gameCliffs(0)) { - for (let tx = c.x - 8; tx <= c.x + 8; tx++) { - for (let ty = Math.floor(c.y) - 8; ty <= Math.floor(c.y) + 8; ty++) { - if (ore.has(key(tx, ty))) { - closest = Math.min(closest, Math.max(Math.abs(tx - c.x), Math.abs(ty - c.y))); - } - } - } - } - expect(closest).toBeGreaterThan(6); - }); - - it("our own over-placement is enriched on ore the game kept clear", () => { - // The asymmetry that says our residual error and this separation are - // related: cells we place that the game also places almost never touch ore, - // but cells we place that the game does NOT have touch ore an order of - // magnitude more often. - // - // | region | true positives on ore | false positives on ore | - // | --- | --- | --- | - // | 0 `[0,0]` | 0 / 223 = 0.0% | 8 / 103 = 7.8% | - // | 1 `[1500,1500]` | 3 / 757 = 0.4% | 20 / 298 = 6.7% | - // - // Note the size: 8 and 20 cells out of 103 and 298 false positives. Modelling - // the exclusion would remove those and move the over-placement ratio from - // 1.152 -> ~1.124 and 1.192 -> ~1.169. It is a real part of #18's residual - // but a small one - do not expect it to close the gap. - const ctx = withCtxDefaults({ seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }); - const fields = makeVulcanusCliffFields(ctx); - - for (const index of [0, 1]) { - const r = cliffFix.cases[index].region; - const ore = oreTiles(index); - const game = new Set(gameCliffs(index).map((c) => key(c.x, c.y))); - const ours = makeCliffPlacementFromFields(fields, { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, - }).placedCells(r.x0, r.y0, r.x1, r.y1); - - let truePos = 0; - let truePosOre = 0; - let falsePos = 0; - let falsePosOre = 0; - for (const c of ours) { - const touches = footprint(c.x, c.y).some((t) => ore.has(t)); - if (game.has(key(c.x, c.y))) { - truePos++; - if (touches) truePosOre++; - } else { - falsePos++; - if (touches) falsePosOre++; - } - } - // **The false-positive population has largely collapsed** since the - // `multisample` grid fix (test/multisampleGrid.spec.ts) took recall to - // 1.000 / 0.973 / 0.965. Region 0 is down from 103 false positives to 9, - // too few to measure an enrichment on, so the comparison now runs only - // where a population survives - region 1, still ~209. - expect(truePos).toBeGreaterThan(100); - // True positives never touch ore, and that half is unaffected and still - // worth pinning: it is the stronger of the two statements. - expect(truePosOre / truePos).toBeLessThan(0.02); - if (falsePos < 50) continue; - // Where enough over-placement remains, it is still enriched on ore the - // game kept clear - so the exclusion this file documents is real and is - // part of what is LEFT of #18, not part of what was fixed. - expect(falsePosOre / falsePos).toBeGreaterThan(0.04); - } - }, 120000); -}); - -/** - * Region, ore-tile set and cliff-cover set for any of the 11 captured regions, - * from whichever of the three entity fixtures holds it. - */ -interface RegionCase { - region: { x0: number; y0: number; x1: number; y1: number }; - ore: Set; - cover: Set; - cliffCells: { x: number; y: number }[]; - label: string; -} - -function allRegions(): RegionCase[] { - const out: RegionCase[] = []; - const push = ( - region: { x0: number; y0: number; x1: number; y1: number }, - resources: { x: number; y: number }[], - cliffs: { x: number; y: number; name: string }[], - ): void => { - const cliffCells = cliffs.filter((c) => c.name === "cliff-vulcanus"); - const cover = new Set(); - for (const c of cliffCells) for (const t of footprint(c.x, c.y)) cover.add(t); - out.push({ - region, - ore: new Set(resources.map((p) => key(Math.floor(p.x), Math.floor(p.y)))), - cover, - cliffCells, - label: `[${String(region.x0)},${String(region.y0)}]`, - }); - }; - for (let i = 0; i < cliffFix.cases.length; i++) - push(cliffFix.cases[i].region, resFix.cases[i].resources, cliffFix.cases[i].cliffs); - for (const c of repFix.cases) push(c.region, c.resources, c.cliffs); - return out; -} - -describe("Vulcanus: the separation replicates, but its published chance baseline does not", () => { - /** - * `#24`'s headline - "about 100x below chance" - divides the observed overlap - * by a **tile-independence** baseline (`ore tiles x cliff coverage / area`). - * That baseline assumes each ore tile is an independent trial. It is not: - * measured on the committed fixtures, region `[0,0]`'s 945 ore tiles are - * **2 connected blobs** and `[-1200,800]`'s 1047 are **2**, so those regions - * carry about one independent trial each. - * - * The right null keeps the blobs intact and moves them: shift the whole ore - * tile set by a random offset on the region torus and re-measure the overlap. - * 500 shifts per region, seeded, 2026-07-29: - * - * | region | ore tiles | blobs | cliff cover | overlap | tile-indep. expectation | shift-null median | P(shift <= observed) | - * | --- | --- | --- | --- | --- | --- | --- | --- | - * | `[0,0]` | 945 | 2 | 6.9% | 0 | 65 | 0 | **0.51** | - * | `[1500,1500]` | 3933 | 25 | 21.6% | 8 | 850 | 789 | **0.000** | - * | `[-1200,800]` | 1047 | 2 | 9.8% | 0 | 103 | 49 | **0.29** | - * | `[700,-1800]` | 404 | 9 | 15.2% | 0 | 61 | 55 | 0.02 | - * | `[-2400,-600]` | 597 | 3 | 12.9% | 1 | 77 | 58 | 0.19 | - * | `[1100,2600]` | 3045 | 20 | 21.2% | 9 | 645 | 611 | **0.000** | - * | `[-900,-2500]` | 904 | 4 | 15.9% | 0 | 144 | 110 | 0.18 | - * | `[-1700,1900]` | 714 | 2 | 22.7% | 0 | 162 | 156 | 0.10 | - * | `[300,3400]` | 944 | 8 | 2.0% | 0 | 19 | 0 | 0.73 | - * - * Two things follow, and they pull in opposite directions. - * - * **The effect is real and it replicates.** Pooled over the 9 regions that - * hold ore, 18 of 12,533 ore tiles sit under a cliff - 0.14% - and the two - * ore-rich regions each land outside 500 of 500 shifts. - * - * **But regions `[0,0]` and `[-1200,800]` were never evidence of it.** Half of - * all random placements of `[0,0]`'s ore blob also hit zero cliffs. The - * previous write-up called their ratio-to-chance "0.000" and read that as the - * strongest signal in the set; it is the weakest. Any future claim here needs - * the shift null, not the tile product. - */ - it("the shift null replicates the separation, and retires two regions as evidence", () => { - const regions = allRegions().filter((r) => r.ore.size > 0); - expect(regions).toHaveLength(9); - - let pooledOre = 0; - let pooledOverlap = 0; - const pValues = new Map(); - - for (const r of regions) { - const w = r.region.x1 - r.region.x0; - const h = r.region.y1 - r.region.y0; - // Flat cover grid: a string-keyed Set is far too slow for 500 shifts. - const grid = new Uint8Array(w * h); - for (const t of r.cover) { - const [tx, ty] = t.split(",").map(Number); - const gx = tx - r.region.x0; - const gy = ty - r.region.y0; - if (gx >= 0 && gx < w && gy >= 0 && gy < h) grid[gy * w + gx] = 1; - } - const oreX: number[] = []; - const oreY: number[] = []; - for (const t of r.ore) { - const [tx, ty] = t.split(",").map(Number); - oreX.push(tx - r.region.x0); - oreY.push(ty - r.region.y0); - } - const overlap = (dx: number, dy: number): number => { - let n = 0; - for (let i = 0; i < oreX.length; i++) { - const gx = (((oreX[i] + dx) % w) + w) % w; - const gy = (((oreY[i] + dy) % h) + h) % h; - n += grid[gy * w + gx]; - } - return n; - }; - const observed = overlap(0, 0); - pooledOre += r.ore.size; - pooledOverlap += observed; - - // Deterministic LCG so the p-values above are reproducible. - let seed = 777; - const rnd = (): number => { - seed = (seed * 1103515245 + 12345) & 0x7fffffff; - return seed / 0x7fffffff; - }; - const shifts = 500; - let atOrBelow = 0; - for (let s = 0; s < shifts; s++) { - if (overlap(Math.floor(rnd() * w), Math.floor(rnd() * h)) <= observed) atOrBelow++; - } - pValues.set(r.label, atOrBelow / shifts); - } - - // Replication: pooled 18 / 12533 = 0.14%. - expect(pooledOre).toBe(12533); - expect(pooledOverlap).toBe(18); - - // The two ore-rich regions are the only ones that carry real signal. - expect(pValues.get("[1500,1500]")).toBeLessThan(0.005); - expect(pValues.get("[1100,2600]")).toBeLessThan(0.005); - // And the two the previous write-up leaned on carry none. This asserts - // something about the NULL, not about our port - it uses only game data. - expect(pValues.get("[0,0]")).toBeGreaterThan(0.2); - expect(pValues.get("[-1200,800]")).toBeGreaterThan(0.15); - }, 60000); -}); - -/** The corner-field fixture, keyed for lookup by corner index. */ -function gameCornerFields(): { elevation: Map; cliffiness: Map } { - const elevation = new Map(); - const cliffiness = new Map(); - cornerFix.corners.forEach((k, i) => { - elevation.set(k, cornerFix.elevation[i]); - cliffiness.set(k, cornerFix.cliffiness[i]); - }); - return { elevation, cliffiness }; -} - -/** World position -> corner index, inverting how `cliffPlacement` samples. */ -const cornerIndex = (x: number, y: number): string => - key(x / cornerFix.grid, Math.round((y - cornerFix.cornerOffsetY) / cornerFix.grid)); - -describe("Vulcanus cliffs: the game's TILE-CHANNEL fields are not the cliff channel", () => { - /** - * The measurement `#18` never had, and the one that resolves `#24`. - * - * `EntityMapGenerationTask::generateCliffs()` (`0x1016229b4`, 2.1.12 arm64) - * calls exactly three things - `CliffGenerator::crossingsForChunk`, - * `CellCliffCrossing::toMaybeCliffOrientation` (inlined) and `tryToAddCliff` - - * and nothing else: no tile lookup, no entity lookup, no resource field. - * `tryToAddCliff`'s only rejection is `wouldCollide`, and that is gated behind - * `mode == 2` (`ldrb w8,[x0,#0x10]; cmp w8,#0x2; b.ne`). `computeInternal` - * calls `generateCliffs()` then `generateEntities()` (three times), and `apply` - * calls `applyCliffs()`, `applyDecoratives()`, `applyEntities()` - re-read - * directly here rather than taken from the previous write-up. So **cliff - * placement is a pure function of `cliff_elevation` and `cliffiness`**, and - * there is no ore/cliff exclusion in the engine to port. - * - * That makes the disagreement locatable. Substituting the GAME's own values - * for both fields at all 12,675 captured corners, over three - * calcite-dominated regions, and running our own - * `makeCliffPlacementFromFields` on them: - * - * | region | cells | TP | FP | FN | precision | recall | - * | --- | --- | --- | --- | --- | --- | --- | - * | `[1500,1500]` | 3844 | 706 | 290 | 90 | 0.709 | 0.887 | - * | `[1100,2600]` | 3844 | 720 | 199 | 86 | 0.783 | 0.893 | - * | `[-1700,1900]` | 3844 | 744 | 156 | 123 | 0.827 | 0.858 | - * - * and the placed cell set is **identical, cell for cell, to the one our own - * fields produce** - not one cell flips. That is what an accurate field - * predicts: a cell can only flip if a corner sits within the field error of a - * band boundary, and at ~5e-6 relative error against 120-wide bands the - * expected number of flips over ~14k corner reads is ~2e-4. - * - * **So #18's residual is not a field-accuracy problem.** 17-29% of the cliff - * cells the port places are wrong, and 11-14% of the game's are missed, with - * the game's own inputs. The error is in the rule as ported - - * `crossingsForChunk`'s sampling geometry, the `cliff_smoothing` knot model, - * `toMaybeCliffOrientation`, or `fixImpossibleCells`. - */ - const REGIONS = cornerFix.regions; - - function placementForRegion(index: number, source: "game" | "ours" | "game+3"): Set { - const r = REGIONS[index]; - const ctx = withCtxDefaults({ seed0: cornerFix.seed, startingPositions: [{ x: 0, y: 0 }] }); - const ours = makeVulcanusCliffFields(ctx); - const { elevation, cliffiness } = gameCornerFields(); - // Out-of-lattice corners fall back to our own field. The chunk-structured - // placement path rounds the query box out to whole 32-tile chunks, so it - // reads a fringe of corners outside the captured region; substituting a - // sentinel there would inject a fake result instead of measuring one. - const bias = source === "game+3" ? 3 : 0; - const fields = - source === "ours" - ? ours - : { - cliffElevation: (x: number, y: number): number => { - const v = elevation.get(cornerIndex(x, y)); - return v === undefined ? ours.cliffElevation(x, y) : v + bias; - }, - cliffiness: (x: number, y: number): number => - cliffiness.get(cornerIndex(x, y)) ?? ours.cliffiness(x, y), - }; - const cells = makeCliffPlacementFromFields(fields, { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, - }).placedCells(r.x0, r.y0, r.x1, r.y1); - return new Set(cells.map((c) => key(c.x, c.y))); - } - - it("substituting the game's TILE-CHANNEL fields now MOVES cells", () => { - // **Inverted 2026-08-01.** These fixtures sample `vulcanus_elevation` through - // `calculate_tile_properties`, whose noise program has a 1-tile grid, while - // the CLIFF generator's has a 4-tile one - and `multisample`'s offsets are in - // GRID UNITS, so `vulcanus_basalt_lakes_multisample`'s min-filter spans 4 - // tiles for cliffs and 1 tile here (test/multisampleGrid.spec.ts). The port - // now reads the cliff-channel field, so these values are the right numbers - // for the wrong consumer and must NOT reproduce our placement. - // - // This test agreeing for months is how the wrong channel stayed invisible: - // the fixture and the port were making the same mistake, so they agreed with - // each other rather than with the game. - for (let i = 0; i < REGIONS.length; i++) { - const game = placementForRegion(i, "game"); - const ours = placementForRegion(i, "ours"); - expect(game.size).toBeGreaterThan(500); - expect([...game].sort()).not.toEqual([...ours].sort()); - } - }, 180000); - - it("and the substitution really is live - a +3 elevation bias does move cells", () => { - // Guards the assertion above against passing vacuously (e.g. if every - // lookup silently fell through to our own field). 2026-07-29: the bias - // moves tens of cells per region. - for (let i = 0; i < REGIONS.length; i++) { - const game = placementForRegion(i, "game"); - const biased = placementForRegion(i, "game+3"); - const moved = [...game].filter((k) => !biased.has(k)).length; - expect(moved).toBeGreaterThan(5); - } - }, 180000); - - it("the game's own fields put cliffs inside ore patches where the game has none", () => { - /** - * The residual, localised. Cells whose whole 4x4 footprint is ore - * ("full-ore cells"), across the three calcite regions: - * - * | region | full-ore cells | our rule on the GAME's fields places | game actually placed | - * | --- | --- | --- | --- | - * | `[1500,1500]` | 172 | 8 | **0** | - * | `[1100,2600]` | 130 | 38 | **0** | - * | `[-1700,1900]` | 29 | 1 | **0** | - * - * A matched control rules out "the rule is just worse on volcano terrain": - * pairing each full-ore cell with up to three no-ore cells of the same mean - * elevation (+/-25) and mean cliffiness (+/-0.1) in the same region, the - * rule's precision on the controls is 0.79 / 1.02 / 2.00 (n = 443 / 387 / 69 - * controls). So the 47 predictions inside ore should have yielded ~47 real - * cliffs; they yielded 0. Poisson P(0 | 47) ~ 4e-21. - * - * Four candidate explanations for THAT, each measured and falsified - * 2026-07-29 - recorded so they are not re-tested: - * - * 1. **Elevation.** With the cliffiness gate forced open, the game's own - * corner elevations produce a band crossing in 40.5% / 34.3% / 50.0% of - * full-ore cells against 42.7% / 37.3% / 34.0% of random cells in the - * same regions. Ore sits on band-crossing terrain at the background rate. - * (For coal and tungsten it is 0.000 in four of five regions - ashlands - * elevation is `300 + 0.001 * ...`, effectively flat, and basalts tops - * out near 120 against `cliff_elevation_0 = 70`. Those resources - * genuinely cannot host cliffs; calcite is the hard case, and the earlier - * "ore below 70" reading only ever explained them.) - * 2. **Cliffiness.** The gate (`cliffinessAvg > 0.5`, and `cliffiness_basic` - * floors at exactly 0.5) is open at 14.9% of `[1500,1500]`'s full-ore - * cells against 60.0% of random - but at **85.8%** of `[1100,2600]`'s - * against 64.0%, and 10.0% at `[-1700,1900]`. It does not replicate, in - * either direction, which is what a coincidence looks like: - * `cliffiness_basic` is a `quick_multioctave_noise` at `seed1 = 123` with - * no dependence on any resource, biome or elevation field, so there is no - * path by which ore could correlate with it. - * 3. **`fixImpossibleCells`.** Running the placement with it on and off - * changes the full-ore predictions by 0 (measured on the interior-inset - * window: 8/35/1 both ways). - * 4. **Steep or aliased terrain.** Full-ore cells' max corner-to-corner - * elevation delta is p10/p50/p90 = 17/37/63 against 13/35/66 for no-ore - * cells - the same distribution - and the rule's precision is 0.58-0.84 - * across every delta bin, with no bin where it collapses. - * - * Collision is ruled out on both paths and both masks were re-read here - * rather than quoted: cliff = `{item, meltable, object, player, water_tile, - * is_lower_object, is_object, cliff}`, resource = `{resource}`. Nor can a - * TILE separate them: of the ~20 Vulcanus tiles only `lava` and `lava-hot` - * carry a layer the cliff mask holds (`water_tile`), and - * `tile_collision_masks.lava()` also carries `resource`, so lava excludes - * both. `volcanic-jagged-ground` - the tile the ore patches are painted, - * whose autoplace literally reads `vulcanus_calcite_region + 0.2` and which - * the Lua labels "CLIFF TILE" - is `tile_collision_masks.ground()`, which - * the cliff mask does not touch. - * - * So this is not an exclusion rule and there is nothing here to bolt on. It - * is #18's rule error, and this is the sharpest localisation of it anyone - * has: a 4x4 cell fully inside a calcite patch is where our rule is wrong - * ~100% of the time while being right ~78% of the time everywhere else. - * Whoever attacks #18 next should start from these cells. - */ - const oreByRegion = new Map>(); - const cliffsByRegion = new Map>(); - for (const r of allRegions()) { - oreByRegion.set(r.label, r.ore); - cliffsByRegion.set(r.label, new Set(r.cliffCells.map((c) => key(c.x, c.y)))); - } - - const perRegion: Record = {}; - let predicted = 0; - let actual = 0; - let fullOreCells = 0; - for (let i = 0; i < REGIONS.length; i++) { - const r = REGIONS[i]; - const label = `[${String(r.x0)},${String(r.y0)}]`; - const ore = oreByRegion.get(label); - const gameCliffCells = cliffsByRegion.get(label); - expect(ore).toBeDefined(); - expect(gameCliffCells).toBeDefined(); - if (ore === undefined || gameCliffCells === undefined) continue; - const placed = placementForRegion(i, "game"); - - const g = cornerFix.grid; - for (let cy = r.y0 / g; cy < r.y1 / g; cy++) { - for (let cx = r.x0 / g; cx < r.x1 / g; cx++) { - let n = 0; - for (let tx = cx * g; tx < cx * g + g; tx++) - for (let ty = cy * g; ty < cy * g + g; ty++) if (ore.has(key(tx, ty))) n++; - if (n < g * g) continue; - fullOreCells++; - const centre = key(cx * g + 2, cy * g + 2.5); - if (placed.has(centre)) predicted++; - if (gameCliffCells.has(centre)) actual++; - perRegion[label] = perRegion[label] ?? [0, 0, 0]; - perRegion[label][0]++; - if (placed.has(centre)) perRegion[label][1]++; - if (gameCliffCells.has(centre)) perRegion[label][2]++; - } - } - } - // Re-measured 2026-07-30 after the sample-lattice fix (fields are read at - // the bare (i*4, j*4); the prototype's grid_offset is a CENTRE offset). The - // per-region split shifted: [1500,1500] 8 -> 9, [1100,2600] 38 -> 37 and - // [-1700,1900] 1 -> 0, so the total we wrongly place inside all-calcite - // footprints goes 47 -> 46. The game still places ZERO in all of them, so - // the finding this test exists for is untouched: it is a rule error, and - // the sample-lattice fix does not touch it. - expect(perRegion).toEqual({ - "[1500,1500]": [172, 9, 0], - "[1100,2600]": [130, 37, 0], - "[-1700,1900]": [29, 0, 0], - }); - expect(fullOreCells).toBe(331); - expect(predicted).toBe(46); - expect(actual).toBe(0); - }, 180000); -}); diff --git a/test/vulcanusRender.spec.ts b/test/vulcanusRender.spec.ts deleted file mode 100644 index a878b861..00000000 --- a/test/vulcanusRender.spec.ts +++ /dev/null @@ -1,400 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { CLIFF_MAP_COLOR } from "../src/noise/cliffs/cliffCatalog"; -import { ROCK_MAP_COLOR } from "../src/noise/rocks/rockCatalog"; -import { - runRenderRequest, - type ElevationRenderRequest, -} from "../src/noise/preview/elevationRenderRequest"; -import { renderTerrain } from "../src/noise/preview/renderTerrain"; -import { renderVulcanusTerrain } from "../src/noise/preview/renderVulcanusTerrain"; -import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; - -const SEED = 123456; - -// Three tests here used to carry an explicit `}, 15000)`. They were the only -// annotations in the suite BELOW `vite.config.ts`'s 30s `testTimeout`, so they -// silently opted out of the margin that default exists to provide, and the -// slowest of them ("composites ... in view:'all'", 3.0s locally) timed out on a -// 4-core CI runner as soon as the suite grew. Removing them is the fix: the -// default is ~10x the measured cost and still fails a genuine hang well inside -// the job's cap. Do not re-add a per-test timeout under 30s here. - -describe("renderVulcanusTerrain", () => { - it("produces an ImageData of the requested size", () => { - const img = renderVulcanusTerrain({ seed0: SEED, width: 8, height: 6 }); - expect(img.width).toBe(8); - expect(img.height).toBe(6); - expect(img.data.length).toBe(8 * 6 * 4); - }); - - it("fully populates a 32x32 near-spawn region (no transparent/zero pixels)", () => { - // Origin-centered, 16 tiles/px so 32px covers a 512x512 world window - - // large enough to cross several biome/tile boundaries. Each pixel runs - // the full 19-tile Vulcanus argmax (no water fast-path applies here), so - // 32x32 (1024 points) is chosen over 128x128 to keep this test fast; an - // explicit timeout gives headroom under a loaded full-suite run. - const width = 32; - const height = 32; - const img = renderVulcanusTerrain({ - seed0: SEED, - width, - height, - originX: -256, - originY: -256, - tilesPerPixel: 16, - }); - let zeroAlpha = 0; - let blackTransparent = 0; - const seen = new Set(); - for (let i = 0; i < img.data.length; i += 4) { - const r = img.data[i]; - const g = img.data[i + 1]; - const b = img.data[i + 2]; - const a = img.data[i + 3]; - if (a === 0) zeroAlpha++; - if (r === 0 && g === 0 && b === 0 && a === 0) blackTransparent++; - seen.add(`${r},${g},${b}`); - } - // Vulcanus has no water/transparency, so every pixel must be fully - // opaque - a zero-alpha pixel means the buffer was never written (a - // real bug), not a legitimate "no tile here" result the way Nauvis - // water sometimes reads. - expect(zeroAlpha).toBe(0); - expect(blackTransparent).toBe(0); - // Sanity: the window actually spans more than one tile color - // (otherwise a renderer that always paints a single hardcoded color - // would pass the opacity checks above for the wrong reason). - expect(seen.size).toBeGreaterThan(1); - }); - - it("a near-spawn pixel matches the full Vulcanus tile resolver's color at the same world point", () => { - // World point (-320, -320), seed 123456 - a near-spawn point also sampled by - // the oracle fixture in vulcanusTiles.spec.ts (tile "volcanic-cracks" there). - const x = -320; - const y = -320; - const resolve = makeVulcanusTileResolver({ seed0: SEED }); - const expected = resolve(x, y).color; - - const img = renderVulcanusTerrain({ seed0: SEED, width: 1, height: 1, originX: x, originY: y }); - expect([img.data[0], img.data[1], img.data[2], img.data[3]]).toEqual([...expected, 255]); - }); -}); - -describe("runRenderRequest planet dispatch", () => { - const BASE: ElevationRenderRequest = { - id: 1, - seed0: SEED, - width: 16, - height: 16, - originX: -320, - originY: -320, - tilesPerPixel: 4, - waterLevel: 0, - segmentationMultiplier: 1, - startingPositions: [{ x: 0, y: 0 }], - view: "terrain", - }; - - it("planet 'vulcanus' + view 'terrain' matches a direct renderVulcanusTerrain call", () => { - const req: ElevationRenderRequest = { ...BASE, planet: "vulcanus" }; - const direct = renderVulcanusTerrain({ - seed0: req.seed0, - width: req.width, - height: req.height, - originX: req.originX, - originY: req.originY, - tilesPerPixel: req.tilesPerPixel, - ctx: { startingPositions: req.startingPositions }, - }); - const got = new Uint8ClampedArray(runRenderRequest(req).buffer); - expect(Array.from(got)).toEqual(Array.from(direct.data)); - }); - - it("planet 'vulcanus' differs from the Nauvis terrain render at the same point", () => { - const nauvis = new Uint8ClampedArray(runRenderRequest({ ...BASE, planet: "nauvis" }).buffer); - const vulcanus = new Uint8ClampedArray( - runRenderRequest({ ...BASE, planet: "vulcanus" }).buffer, - ); - expect(Array.from(vulcanus)).not.toEqual(Array.from(nauvis)); - }); - - it("omitting planet keeps the Nauvis render byte-identical to an explicit planet: 'nauvis'", () => { - const omitted = new Uint8ClampedArray(runRenderRequest(BASE).buffer); - const explicit = new Uint8ClampedArray(runRenderRequest({ ...BASE, planet: "nauvis" }).buffer); - expect(Array.from(omitted)).toEqual(Array.from(explicit)); - }); - - it("omitting planet reproduces a direct renderTerrain call unchanged (Nauvis path untouched)", () => { - const direct = renderTerrain({ - seed0: BASE.seed0, - width: BASE.width, - height: BASE.height, - originX: BASE.originX, - originY: BASE.originY, - tilesPerPixel: BASE.tilesPerPixel, - ctx: { - segmentationMultiplier: BASE.segmentationMultiplier, - startingPositions: BASE.startingPositions, - }, - }); - const got = new Uint8ClampedArray(runRenderRequest(BASE).buffer); - expect(Array.from(got)).toEqual(Array.from(direct.data)); - }); - - it("renders the Vulcanus resource overlay for view: resources", () => { - const common = { - id: 1, - seed0: 123456, - planet: "vulcanus" as const, - width: 48, - height: 48, - originX: -1600, - originY: -1600, - tilesPerPixel: 8, - waterLevel: 0, - segmentationMultiplier: 1, - startingPositions: [{ x: 0, y: 0 }], - }; - const terrain = runRenderRequest({ ...common, view: "terrain" }); - const withOre = runRenderRequest({ ...common, id: 2, view: "resources" }); - expect(Array.from(new Uint8ClampedArray(withOre.buffer))).not.toEqual( - Array.from(new Uint8ClampedArray(terrain.buffer)), - ); - }); - - it("leaves Vulcanus terrain alone for the Nauvis-only overlays", () => { - const common = { - id: 3, - seed0: 123456, - planet: "vulcanus" as const, - width: 32, - height: 32, - originX: -1600, - originY: -1600, - tilesPerPixel: 8, - waterLevel: 0, - segmentationMultiplier: 1, - startingPositions: [{ x: 0, y: 0 }], - }; - const terrain = runRenderRequest({ ...common, view: "terrain" }); - // "cliffs" and "rocks" are deliberately absent: V3 gave both a Vulcanus - // port, so neither is a no-op here. The two below still are. - for (const view of ["enemies", "trees"] as const) { - const other = runRenderRequest({ ...common, id: 4, view }); - expect(Array.from(new Uint8ClampedArray(other.buffer))).toEqual( - Array.from(new Uint8ClampedArray(terrain.buffer)), - ); - } - }); - - it("paints Vulcanus rocks for view:'rocks', in the shared ROCK_MAP_COLOR", () => { - const common = { - id: 7, - seed0: 123456, - planet: "vulcanus" as const, - width: 96, - height: 96, - originX: -128, - originY: -128, - tilesPerPixel: 1, - waterLevel: 0, - segmentationMultiplier: 1, - startingPositions: [{ x: 0, y: 0 }], - }; - const terrain = runRenderRequest({ ...common, view: "terrain" }); - const rocks = runRenderRequest({ ...common, id: 8, view: "rocks" }); - - // All four Vulcanus rock entities declare map_color {129, 105, 78}, the - // same as Nauvis's rocks, so ROCK_MAP_COLOR is shared not duplicated. - const before = new Uint8ClampedArray(terrain.buffer); - const after = new Uint8ClampedArray(rocks.buffer); - let changed = 0; - for (let o = 0; o < after.length; o += 4) { - if ( - after[o] === before[o] && - after[o + 1] === before[o + 1] && - after[o + 2] === before[o + 2] - ) - continue; - changed++; - expect([after[o], after[o + 1], after[o + 2]]).toEqual([...ROCK_MAP_COLOR]); - } - expect(changed).toBeGreaterThan(0); - }); - - it("rolls Vulcanus rocks rather than thresholding - coverage drops well below the 7% plateau", () => { - const common = { - id: 21, - seed0: 123456, - planet: "vulcanus" as const, - width: 256, - height: 256, - originX: -128, - originY: -128, - tilesPerPixel: 1, - waterLevel: 0, - segmentationMultiplier: 1, - startingPositions: [{ x: 0, y: 0 }], - }; - const terrain = new Uint8ClampedArray(runRenderRequest({ ...common, view: "terrain" }).buffer); - const rocks = new Uint8ClampedArray( - runRenderRequest({ ...common, id: 22, view: "rocks" }).buffer, - ); - let changed = 0; - for (let o = 0; o < rocks.length; o += 4) { - if ( - rocks[o] !== terrain[o] || - rocks[o + 1] !== terrain[o + 1] || - rocks[o + 2] !== terrain[o + 2] - ) - changed++; - } - const coverage = changed / (common.width * common.height); - // Directly measured for this exact window/seed under the old - // VULCANUS_ROCK_FOOTPRINT_THRESHOLD = 0.02 sweep: 7.56%, close to the 7.03% - // `docs/noise/vulcanus-rocks-NOTES.md` reports for `[-512, 512)^2`. - // - // **The bound used to be `< 0.01`, and that was wrong** - it was pinned just - // above the 0.78% a 1x1 mark produced, on the assumption that "well below the - // 7% plateau" was the goal. Comparing against the game's own - // `--generate-map-preview` output shows the game covers **5.17%** of an - // origin-centred 1024-tile window in rock colour, so 0.78% was 14x too - // little, not comfortably conservative (issue #22 item 6). Rocks now paint a - // 3x3 mark on both planets and this window measures ~4.5%. - // - // What this test can still honestly assert is that the render is a scattered - // roll and not the old plateau, so the bound sits between the two: above the - // game's own coverage with margin, and clearly under the 7.56% threshold - // sweep. It is NOT a fidelity check - `docs/noise/vulcanus-rocks-NOTES.md` - // holds the coverage-vs-game comparison, which is the thing that actually - // measures accuracy here. - expect(coverage).toBeLessThan(0.065); - expect(coverage).toBeGreaterThan(0.02); - expect(coverage).toBeGreaterThan(0); // and it must still paint SOMETHING - }); - - it("paints Vulcanus cliffs for view:'cliffs', in the shared CLIFF_MAP_COLOR", () => { - const common = { - id: 5, - seed0: 123456, - planet: "vulcanus" as const, - width: 128, - height: 128, - originX: -256, - originY: -256, - tilesPerPixel: 1, - waterLevel: 0, - segmentationMultiplier: 1, - startingPositions: [{ x: 0, y: 0 }], - }; - const terrain = runRenderRequest({ ...common, view: "terrain" }); - const cliffs = runRenderRequest({ ...common, id: 6, view: "cliffs" }); - expect(Array.from(new Uint8ClampedArray(cliffs.buffer))).not.toEqual( - Array.from(new Uint8ClampedArray(terrain.buffer)), - ); - - // Every changed pixel must be the cliff colour - `cliff-vulcanus` declares - // the same map_color {144, 119, 87} as Nauvis's `cliff`, so CLIFF_MAP_COLOR - // is shared rather than duplicated. - const before = new Uint8ClampedArray(terrain.buffer); - const after = new Uint8ClampedArray(cliffs.buffer); - let changed = 0; - for (let o = 0; o < after.length; o += 4) { - if ( - after[o] === before[o] && - after[o + 1] === before[o + 1] && - after[o + 2] === before[o + 2] - ) - continue; - changed++; - expect([after[o], after[o + 1], after[o + 2]]).toEqual([...CLIFF_MAP_COLOR]); - } - expect(changed).toBeGreaterThan(0); - }); - - // Vulcanus composites terrain -> resources -> rocks -> cliffs, so both - // obstruction overlays read on top of an ore patch rather than being buried - // by it. All three overlays paint opaquely, so the order is fully decided by - // who paints last on a contended pixel - which is what this pins. - // - // Window choice is not arbitrary: resources have to contend with BOTH other - // overlays for the two assertions to mean anything, and most windows give - // only one. This one (a 256x256-tile world window at 2 tiles/px) was probed - // to have 342 resource pixels cliffs also paint, and 67 rocks also paint - // under the old threshold render. T3 switched rocks to a per-tile placement - // roll (src/noise/placement/placementRoll.ts) painting a single pixel - // (matching Nauvis's renderRocks.ts, not the 3x3 mark other roll overlays - // use), which lowers the rock count sharply - re-measured at 4 here. The - // window still clears the ">0" bar below, so it was kept rather than - // re-probed; only this comment's count changed. - // Both counts are asserted below, so if placement ever shifts the test fails - // loudly rather than passing vacuously. It runs five full Vulcanus renders, - // hence the explicit timeout. - /** - * **Explicit 120s budget: this test has no headroom under the 30s global on a - * contended CI shard.** Measured 7990ms on a dev machine. That looks safe and is - * not: on PR #253 a 5952ms test in test/vulcanusCliffBands.spec.ts blew the - * 30000ms global on shard 3, so the - * real multiplier for a contended 4-core runner is above 5x rather than the - * ~3x a core-count comparison suggests - that shard's import time alone was - * 514s. 7990ms x 5 lands past 30s. - * - * It renders the same 'all' view four times over - once composited and once per - * overlay - so it is the heaviest test in this file by a wide margin. - * - * Not `retry` if it reddens anyway: nothing here is nondeterministic, these - * tests compare pixels against captured game output, so a retry would only - * hide a real slowdown. Read the duration the reporter prints first. - */ - it("composites Vulcanus rocks and cliffs ON TOP of resource patches in view:'all'", () => { - const common = { - id: 9, - seed0: 123456, - planet: "vulcanus" as const, - width: 128, - height: 128, - originX: -192, - originY: -192, - tilesPerPixel: 2, - waterLevel: 0, - segmentationMultiplier: 1, - startingPositions: [{ x: 0, y: 0 }], - }; - const terrain = new Uint8ClampedArray(runRenderRequest({ ...common, view: "terrain" }).buffer); - const allBuf = new Uint8ClampedArray(runRenderRequest({ ...common, view: "all" }).buffer); - const paintedBy = (view: "resources" | "rocks" | "cliffs"): Set => { - const buf = new Uint8ClampedArray(runRenderRequest({ ...common, view }).buffer); - const s = new Set(); - for (let o = 0; o < buf.length; o += 4) { - if (buf[o] !== terrain[o] || buf[o + 1] !== terrain[o + 1] || buf[o + 2] !== terrain[o + 2]) - s.add(o); - } - return s; - }; - const resources = paintedBy("resources"); - const rocks = paintedBy("rocks"); - const cliffs = paintedBy("cliffs"); - - let overCliffs = 0; - let overRocks = 0; - for (const o of resources) { - if (cliffs.has(o)) { - overCliffs++; - expect( - [allBuf[o], allBuf[o + 1], allBuf[o + 2]], - `pixel ${o}: cliffs must composite OVER resources`, - ).toEqual([...CLIFF_MAP_COLOR]); - } else if (rocks.has(o)) { - overRocks++; - expect( - [allBuf[o], allBuf[o + 1], allBuf[o + 2]], - `pixel ${o}: rocks must composite OVER resources`, - ).toEqual([...ROCK_MAP_COLOR]); - } - } - expect(overCliffs, "window must have pixels both cliffs and resources paint").toBeGreaterThan( - 0, - ); - expect(overRocks, "window must have pixels both rocks and resources paint").toBeGreaterThan(0); - }, 120000); -}); diff --git a/test/vulcanusResourceRender.spec.ts b/test/vulcanusResourceRender.spec.ts deleted file mode 100644 index c376d46f..00000000 --- a/test/vulcanusResourceRender.spec.ts +++ /dev/null @@ -1,358 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { withCtxDefaults } from "../src/noise/eval/ctx"; -import { makeVulcanusBiomes } from "../src/noise/expressions/vulcanusBiomes"; -import { makeVulcanusCracks } from "../src/noise/expressions/vulcanusCracks"; -import { makeVulcanusHelpers } from "../src/noise/expressions/vulcanusHelpers"; -import { makeVulcanusResources } from "../src/noise/expressions/vulcanusResources"; -import { makeVulcanusSpawn } from "../src/noise/expressions/vulcanusSpawn"; -import { renderVulcanusTerrain } from "../src/noise/preview/renderVulcanusTerrain"; -import { renderVulcanusResources } from "../src/noise/preview/renderVulcanusResources"; -import { VULCANUS_RESOURCE_CATALOG } from "../src/noise/resources/vulcanusResourceCatalog"; - -const SEED = 123456; - -describe("renderVulcanusResources", () => { - it("catalog carries the three solid ores plus the geyser, with their map_colors", () => { - expect(VULCANUS_RESOURCE_CATALOG.map((r) => r.name)).toEqual([ - "tungsten-ore", - "calcite", - "coal", - "sulfuric-acid-geyser", - ]); - expect(VULCANUS_RESOURCE_CATALOG.map((r) => r.controlName)).toEqual([ - "tungsten_ore", - "calcite", - "vulcanus_coal", - "sulfuric_acid_geyser", - ]); - // map_color = {98/256, 86/256, 150/256} -> Math.round(v * 255): {98, 86, 149} - expect(VULCANUS_RESOURCE_CATALOG[0].mapColor).toEqual([98, 86, 149]); - // map_color = {0.8, 0.7, 0.7} -> {204, 179, 179} - expect(VULCANUS_RESOURCE_CATALOG[1].mapColor).toEqual([204, 179, 179]); - // map_color = {0, 0, 0} - expect(VULCANUS_RESOURCE_CATALOG[2].mapColor).toEqual([0, 0, 0]); - // map_color = {0.78, 0.78, 0.1} -> {199, 199, 26} - expect(VULCANUS_RESOURCE_CATALOG[3].mapColor).toEqual([199, 199, 26]); - }); - - it("geyser probability is the game's expression over sulfuricAcidRegionPatchy", () => { - // `vulcanus_sulfuric_acid_geyser_probability`, - // `space-age/prototypes/planet/planet-vulcanus-map-gen.lua:849` (2.1.12): - // (control:sulfuric_acid_geyser:size > 0) - // * (0.025 * ((patchy > 0) + 2 * patchy)) - // The leading size factor is the renderer's `enabled` filter, so the entry's - // own `probability` carries only the second half. There is NO random_penalty - // wrapper - its calcite/coal/tungsten neighbours in the same file have one, - // and this expression does not. - const ctx = withCtxDefaults({ seed0: SEED, startingPositions: [{ x: 0, y: 0 }] }); - const helpers = makeVulcanusHelpers(ctx); - const spawn = makeVulcanusSpawn(ctx, helpers); - const cracks = makeVulcanusCracks(ctx, helpers); - const biomes = makeVulcanusBiomes(ctx, helpers, spawn, cracks); - const resources = makeVulcanusResources(ctx, helpers, spawn, biomes, cracks); - const entry = VULCANUS_RESOURCE_CATALOG.find((p) => p.name === "sulfuric-acid-geyser"); - expect(entry?.placement).toBe("roll"); - expect(VULCANUS_RESOURCE_CATALOG.filter((p) => p.placement === "threshold").length).toBe(3); - - const prob = entry?.probability?.(resources); - expect(prob).toBeDefined(); - let sawPositive = false; - let maxSeen = -Infinity; - for (let y = -512; y < 512; y += 37) { - for (let x = -512; x < 512; x += 37) { - const patchy = resources.sulfuricAcidRegionPatchy(x, y); - const expected = 0.025 * ((patchy > 0 ? 1 : 0) + 2 * patchy); - expect(prob!(x, y)).toBeCloseTo(expected, 12); - if (expected > 0) sawPositive = true; - maxSeen = Math.max(maxSeen, expected); - } - } - expect(sawPositive).toBe(true); - - // The cap matters because the catalog's ordering argument rests on it - // (calcite saturates to ~1 and must win a shared pixel). It is NOT 0.065 - - // that figure was a reasoned bound and it is wrong, because - // `vulcanus_sulfuric_acid_region` is a `max` against - // `vulcanus_starting_sulfur` and is not capped at 1. A +/-3000-tile sweep on - // a 7-tile grid, refined around its argmax, measures 0.0883 at - // (2481, -1985). This asserts the loose bound the ordering argument needs - // rather than the measured maximum, so a re-measure on another seed does not - // break it. - expect(maxSeen).toBeLessThan(0.2); - expect(prob!(2481, -1985)).toBeGreaterThan(0.08); - }); - - it("rolls the geyser instead of blobbing its whole patch extent", () => { - // The regression this task fixed: the geyser used to be thresholded, which - // painted the entire region where the game merely ROLLS for one. Compare the - // shipped render's geyser pixels against that old rule (`1000 * patchy >= - // 0.5`, i.e. `patchy >= 0.0005`) over a window chosen for having both. - // - // The window is measured, not guessed: sweeping +/-3000 in 200-tile steps - // for a 100x100 px window at this scale, origin (-600, 2800) is the ONLY one - // whose old footprint clears 500 px (525, with 135 painted). An earlier - // draft asserted `> 500` at origin (-320, 100), where the footprint is 260 - - // the guard was written before any window was measured. - const opts = { seed0: SEED, width: 100, height: 100, originX: -600, originY: 2800 }; - const tilesPerPixel = 2; - const base = renderVulcanusTerrain({ ...opts, tilesPerPixel }); - renderVulcanusResources(base, { ...opts, tilesPerPixel }); - - const ctx = withCtxDefaults({ seed0: SEED, startingPositions: [{ x: 0, y: 0 }] }); - const helpers = makeVulcanusHelpers(ctx); - const spawn = makeVulcanusSpawn(ctx, helpers); - const cracks = makeVulcanusCracks(ctx, helpers); - const biomes = makeVulcanusBiomes(ctx, helpers, spawn, cracks); - const resources = makeVulcanusResources(ctx, helpers, spawn, biomes, cracks); - const geyser = VULCANUS_RESOURCE_CATALOG.find((p) => p.name === "sulfuric-acid-geyser"); - - let painted = 0; - let oldFootprint = 0; - for (let py = 0; py < opts.height; py++) { - for (let px = 0; px < opts.width; px++) { - const x = opts.originX + px * tilesPerPixel; - const y = opts.originY + py * tilesPerPixel; - if (1000 * resources.sulfuricAcidRegionPatchy(x, y) >= 0.5) oldFootprint++; - const o = (py * opts.width + px) * 4; - const isGeyser = - base.data[o] === geyser?.mapColor[0] && - base.data[o + 1] === geyser.mapColor[1] && - base.data[o + 2] === geyser.mapColor[2]; - if (isGeyser) painted++; - } - } - // The window must contain a real patch, or "far fewer pixels" is vacuous. - // Measured 525. - expect(oldFootprint).toBeGreaterThan(400); - // ...and the roll must actually place something in it. Measured 135. - expect(painted).toBeGreaterThan(0); - // Measured ratio 0.257 (135 / 525). Inequalities rather than the counts, so - // a re-measure does not break this. - // - // **Do not read 0.257 as the area the geysers occupy.** These are painted - // MARK pixels: `PLACEMENT_MARK_RADIUS_PX` draws 3x3 per placement, so the - // 135 px here come from ~15 placements covering ~118 tiles of ground. The - // ratio happens to be scale-invariant (halving `tilesPerPixel` quarters the - // footprint samples and the roll hits alike), so tpp=1 measures 0.243 in a - // 256x256 window at (-568, 2888) - close to this by construction, not by - // coincidence. - // - // The honest entity-vs-blob figure, aggregated over +/-2000 tiles on a - // 2-tile grid rather than a footprint-selected window: 371 placements at the - // collision box's 2.8 x 2.8 = 7.84 tiles each against 12130 sampled - // footprint tiles, i.e. **0.240, a 4.2x overstatement**. Not the "more than - // an order of magnitude" this subsystem's notes claimed - that was another - // reasoned-not-measured number, corrected 2026-07-27. - expect(painted).toBeLessThan(oldFootprint / 2); - }); - - it("keeps the geyser last, so calcite wins the mountains-biome overlap", () => { - // Calcite and the geyser are the one pair that can be eligible at the same - // pixel (both gate on the mountains biome - see the catalog's module - // comment). The game arbitrates by maximum probability, and calcite's - // saturates to ~1 where the geyser's peaks below 0.09 (measured 0.0883, not - // the 0.065 this file used to quote), so calcite must win. The renderer - // reproduces that through paint order: geyser marks first, ores over them. - const names = VULCANUS_RESOURCE_CATALOG.map((r) => r.name); - expect(names.indexOf("sulfuric-acid-geyser")).toBe(names.length - 1); - expect(names.indexOf("calcite")).toBeLessThan(names.indexOf("sulfuric-acid-geyser")); - - // Structural order alone would still pass if the renderer stopped honouring - // it, so render a window where the two genuinely collide. World - // [200, 398] x [-2200, -2002] was found by sweeping +/-3000 in 200-tile - // steps for the largest calcite-and-geyser intersection: 116 of its 146 - // geyser pixels are also calcite. Every one of them must come out calcite. - const opts = { seed0: SEED, width: 100, height: 100, originX: 200, originY: -2200 }; - const tilesPerPixel = 2; - const base = renderVulcanusTerrain({ ...opts, tilesPerPixel }); - renderVulcanusResources(base, { ...opts, tilesPerPixel }); - - const ctx = withCtxDefaults({ seed0: SEED }); - const helpers = makeVulcanusHelpers(ctx); - const spawn = makeVulcanusSpawn(ctx, helpers); - const cracks = makeVulcanusCracks(ctx, helpers); - const biomes = makeVulcanusBiomes(ctx, helpers, spawn, cracks); - const resources = makeVulcanusResources(ctx, helpers, spawn, biomes, cracks); - const calcite = VULCANUS_RESOURCE_CATALOG.find((r) => r.name === "calcite"); - const geyser = VULCANUS_RESOURCE_CATALOG.find((r) => r.name === "sulfuric-acid-geyser"); - expect(calcite).toBeDefined(); - expect(geyser).toBeDefined(); - - let contested = 0; - for (let py = 0; py < opts.height; py++) { - for (let px = 0; px < opts.width; px++) { - const x = opts.originX + px * tilesPerPixel; - const y = opts.originY + py * tilesPerPixel; - const bothQualify = - 1000 * calcite!.region(resources)(x, y) >= 0.5 && - 1000 * geyser!.region(resources)(x, y) >= 0.5; - if (!bothQualify) continue; - contested++; - const o = (py * opts.width + px) * 4; - expect([base.data[o], base.data[o + 1], base.data[o + 2]]).toEqual(calcite!.mapColor); - } - } - // A window with no contested pixels would make the loop above vacuous. - expect(contested).toBeGreaterThan(100); - }); - - it("paints ore pixels and leaves the rest of the terrain untouched", () => { - const opts = { - seed0: SEED, - width: 64, - height: 64, - originX: -1600, - originY: -1600, - tilesPerPixel: 8, - }; - const base = renderVulcanusTerrain(opts); - const before = new Uint8ClampedArray(base.data); - renderVulcanusResources(base, { - seed0: SEED, - originX: opts.originX, - originY: opts.originY, - tilesPerPixel: opts.tilesPerPixel, - }); - - const colors = new Set(VULCANUS_RESOURCE_CATALOG.map((r) => r.mapColor.join(","))); - let changed = 0; - for (let o = 0; o < base.data.length; o += 4) { - const same = - base.data[o] === before[o] && - base.data[o + 1] === before[o + 1] && - base.data[o + 2] === before[o + 2]; - if (same) continue; - changed++; - // Every changed pixel must be exactly one of the three ore colors. - expect(colors.has(`${base.data[o]},${base.data[o + 1]},${base.data[o + 2]}`)).toBe(true); - } - // This window is world [-1600, -1096] x [-1600, -1096] (originX/Y=-1600, - // tilesPerPixel=8, 64px) - a 512x512-tile square well away from spawn (closest - // corner is ~1550 tiles out, far past VULCANUS_STARTING_AREA_RADIUS's farthest - // starting spot at ~236), so any pixels painted here are regular (non-starting) - // ore patches picked up by the spot-noise search, not starting patches. It must - // not be empty - an all-zero result means the overlay never fired. - expect(changed).toBeGreaterThan(0); - }); - - it("wires each ore to its own control - no lever is silently swapped", () => { - // Every other test uses all-default 1/1 sliders, so a catalog bug that wired - // (say) tungsten-ore's `levers` accessor to calcite's control would still pass - // them all (both sliders are on). This isolates one control at a time and - // checks only that ore's colour appears - genuinely exercising the - // controlName <-> region wiring in `VULCANUS_RESOURCE_CATALOG`. - // - // Window world [-650, -452] x [620, 818] (originX/Y=-650/620, tilesPerPixel=2, - // 100px) was picked by scanning for the smallest box containing a hit for all - // three ores' regions (see task-6 investigation) - unlike the window above, - // each ore paints a non-trivial number of pixels here on its own, so this - // actually fails if two levers are swapped (confirmed by temporarily swapping - // two `levers` accessors and watching this test fail). - // - // The geyser needs its own window. Its footprint is far sparser than the - // ores' - `patchy > 0` needs the spot cone deep enough that - // `(1 + region) * (0.5 + 0.5 * patches) > 1`, so it covers only the core of - // a sulfur spot - and it paints just 3 pixels in the ore window, which is - // too thin to be a meaningful assertion. World [-400, -202] x [600, 798] - // was found by the same sweep and holds 487 geyser pixels and none of the - // three ores, so it isolates the geyser's lever cleanly. - const oreWindow = { - seed0: SEED, - width: 100, - height: 100, - originX: -650, - originY: 620, - tilesPerPixel: 2, - }; - const windowByOre: Record = { - "sulfuric-acid-geyser": { ...oreWindow, originX: -400, originY: 600 }, - }; - const off = { frequency: 1, size: 0 }; - const on = { frequency: 1, size: 1 }; - const allControlsOff = { - tungstenOre: off, - vulcanusCoal: off, - calcite: off, - sulfuricAcidGeyser: off, - }; - // Deliberately hardcoded (not derived from VULCANUS_RESOURCE_CATALOG's own - // `levers` accessor) so this test exercises that mapping rather than trusting it. - const controlKeyByOre: Record = { - "tungsten-ore": "tungstenOre", - calcite: "calcite", - coal: "vulcanusCoal", - "sulfuric-acid-geyser": "sulfuricAcidGeyser", - }; - - for (const ore of VULCANUS_RESOURCE_CATALOG) { - const opts = windowByOre[ore.name] ?? oreWindow; - const base = renderVulcanusTerrain(opts); - const before = new Uint8ClampedArray(base.data); - renderVulcanusResources(base, { - seed0: SEED, - originX: opts.originX, - originY: opts.originY, - tilesPerPixel: opts.tilesPerPixel, - ctx: { - vulcanusResourceControls: { - ...allControlsOff, - [controlKeyByOre[ore.name]]: on, - }, - }, - }); - - const otherColors = VULCANUS_RESOURCE_CATALOG.filter((r) => r.name !== ore.name).map((r) => - r.mapColor.join(","), - ); - let paintedOwnColor = 0; - for (let o = 0; o < base.data.length; o += 4) { - const same = - base.data[o] === before[o] && - base.data[o + 1] === before[o + 1] && - base.data[o + 2] === before[o + 2]; - if (same) continue; - const key = `${base.data[o]},${base.data[o + 1]},${base.data[o + 2]}`; - if (key === ore.mapColor.join(",")) { - paintedOwnColor++; - } else { - // A changed pixel painted in another ore's colour means this ore's - // control leaked into (or was swapped with) another ore's region. - expect(otherColors.includes(key)).toBe(false); - } - } - // Confirms this ore's own control actually gates its own region - a - // trivially-empty result would make the check above vacuous. - expect(paintedOwnColor).toBeGreaterThan(0); - } - }); - - it("draws nothing when every resource's size slider is 0", () => { - const opts = { - seed0: SEED, - width: 32, - height: 32, - originX: -1600, - originY: -1600, - tilesPerPixel: 8, - }; - const base = renderVulcanusTerrain(opts); - const before = new Uint8ClampedArray(base.data); - const off = { frequency: 1, size: 0 }; - renderVulcanusResources(base, { - seed0: SEED, - originX: opts.originX, - originY: opts.originY, - tilesPerPixel: opts.tilesPerPixel, - ctx: { - vulcanusResourceControls: { - tungstenOre: off, - vulcanusCoal: off, - calcite: off, - sulfuricAcidGeyser: off, - }, - }, - }); - expect(Array.from(base.data)).toEqual(Array.from(before)); - }); -}); diff --git a/test/vulcanusRocks.spec.ts b/test/vulcanusRocks.spec.ts index 3a3544c3..2297d45c 100644 --- a/test/vulcanusRocks.spec.ts +++ b/test/vulcanusRocks.spec.ts @@ -1,83 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; import fixture from "./fixtures/oracle-vulcanus-rocks.seed123456.json"; -import { withCtxDefaults } from "../src/noise/eval/ctx"; -import { - DECORATIVE_KNOCKOUT_SEED1, - makeVulcanusDecorativeKnockout, - makeVulcanusRockFields, -} from "../src/noise/rocks/vulcanusRockField"; describe("Vulcanus rocks", () => { - const ctx = withCtxDefaults({ seed0: fixture.seed0 }); - const fields = makeVulcanusRockFields(ctx); - const positions = fixture.positions; const v = fixture.values; - const worst = (fn: (x: number, y: number) => number, want: number[]): number => { - let w = 0; - for (let i = 0; i < positions.length; i++) { - w = Math.max(w, Math.abs(fn(positions[i].x, positions[i].y) - want[i])); - } - return w; - }; - - it("vulcanus_decorative_knockout matches the oracle", () => { - const knockout = makeVulcanusDecorativeKnockout(fixture.seed0); - // `input_scale = 1/3` makes this the highest-frequency multioctave in the - // Vulcanus port, tied with sulfuricAcidPatches - and high frequency - // amplifies the game's f32 coordinate floor, because a coordinate error of - // e (which grows with |x|) becomes a phase error of e/3. The residual grows - // smoothly with distance rather than jumping, which is what distinguishes a - // precision floor from a porting error: - // - // r < 300 2.22e-5 (298 points) - // 300 <= r < 900 6.40e-5 (112 points) - // r >= 900 1.18e-4 (24 points) - // - // Bounds are the measured worst with headroom, not loosened tolerances. - expect(worst(knockout, v.vulcanus_decorative_knockout)).toBeLessThan(2e-4); - - // The near-field bound is the real regression guard - it is where the - // preview actually renders, and a structural error (wrong seed, octave - // count, persistence) could not agree to 2e-5 anywhere. - let nearWorst = 0; - for (let i = 0; i < positions.length; i++) { - const p = positions[i]; - if (Math.max(Math.abs(p.x), Math.abs(p.y)) >= 300) continue; - nearWorst = Math.max( - nearWorst, - Math.abs(knockout(p.x, p.y) - v.vulcanus_decorative_knockout[i]), - ); - } - expect(nearWorst).toBeLessThan(5e-5); - }); - - it("vulcanus_rock_huge matches the oracle", () => { - // Composed of aux, moisture, vulcanus_ashlands_biome, vulcanus_rock_noise - // and the knockout - each already oracle-validated on its own - so the bound - // is the worst of those compounded, dominated by the biome term (whose own - // spec carries 5e-4). - expect(worst(fields.rockHuge, v.vulcanus_rock_huge)).toBeLessThan(5e-4); - }); - - it("vulcanus_rock_big matches the oracle", () => { - expect(worst(fields.rockBig, v.vulcanus_rock_big)).toBeLessThan(5e-4); - }); - - it("density is the clamped max of the two, which is the game's own arbitration", () => { - // Per-tile arbitration is max probability, so max() here is exact rather - // than an approximation - see docs/noise/placement-roll-NOTES.md. - for (let i = 0; i < positions.length; i++) { - const { x, y } = positions[i]; - const want = Math.min( - 1, - Math.max(0, Math.max(v.vulcanus_rock_huge[i], v.vulcanus_rock_big[i])), - ); - expect(Math.abs(fields.density(x, y) - want)).toBeLessThan(5e-4); - } - }); - it("probabilities stay well under 1, so no threshold yields a solid footprint", () => { // Both expressions are capped at 0.2 * (1 - k * ashlands_biome), so the // overlay cannot use the ores' `>= 0.5` rule - it rolls per tile against @@ -93,8 +20,4 @@ describe("Vulcanus rocks", () => { // ...and the field is not trivially empty over the sample. expect(peak).toBeGreaterThan(0.05); }); - - it("pins the knockout seed", () => { - expect(DECORATIVE_KNOCKOUT_SEED1).toBe(1300000); - }); }); diff --git a/test/vulcanusTiles.spec.ts b/test/vulcanusTiles.spec.ts deleted file mode 100644 index 40f670c1..00000000 --- a/test/vulcanusTiles.spec.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import fixture from "./fixtures/oracle-vulcanus-tile-names.seed123456.json"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; -import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; - -/** - * Task 10: the Vulcanus tile argmax + map_color port, validated against the - * `get_tile` oracle (Space Age, real Vulcanus surface). `resolveVulcanusTile` - * evaluates every tile's `probability_expression` and paints the argmax's - * `map_color` - this asserts it names the SAME tile the game placed. - * - * Agreement is NOT expected to be 100%. As of V2 (Task 5) the three resource-coupling - * terms (`vulcanus_metal_tile`, `vulcanus_calcite_region`, - * `vulcanus_sulfuric_acid_region_patchy`) are fully restored in every `*_range` - * expression that references them - V1's no-resource-default stubs are gone. The - * remaining gap is the game's `random_penalty_between(0.9, 1, 1)` inside - * `vulcanus_metal_tile`, which this port approximates as `1` (an upper bound) because - * `random_penalty` is a whole-batch operation a per-pixel renderer cannot reproduce - * (see `src/noise/expressions/vulcanusResources.ts`); at a patch edge where the game - * rolled a low penalty, that can flip placement outright, so a residual sliver of - * disagreement right at ore-patch boundaries is expected, not a bug. - * - * Measured 98.16% (374/381), up from V1's 96.85% (369/381) - restoring the coupling - * terms fixed 5 of the original 12 mismatches. The 7 that remain are all far from any - * resource patch (`metalTile`/`calciteRegion`/`sulfuricAcidRegionPatchy` all read their - * no-patch floor there) and are ADJACENT-tile flips within one biome family - * (folds-flat/folds, smooth-stone/cracks-warm, ash-soil/pumice, ash-flats/ash-light, - * cracks-hot/cracks-warm), spread across radii 192-2079 - the same known far-field f32 - * coordinate floor in elevation/aux/moisture tipping a near-tie argmax across a range - * boundary that was already present in V1, now with the resource terms ruled out as a - * cause. No single range expression is systematically wrong (that would cluster many - * cells of one tile), so this is precision floor, not a bug. - */ -describe("makeVulcanusTileResolver vs get_tile oracle", () => { - const resolve = makeVulcanusTileResolver({ seed0: fixture.seed0 }); - const positions = fixture.positions; - const want = fixture.tileNames; - - it("agrees with the placed tile at a high fraction of positions", () => { - let agree = 0; - for (let i = 0; i < positions.length; i++) { - const p = positions[i]; - const got = resolve(p.x, p.y).name; - if (got === want[i]) agree++; - } - const agreement = agree / positions.length; - // Floor 0.978 (measured 0.9816, up from V1's 0.9685): high enough that a - // genuinely wrong range transcription fails it, with modest headroom for the - // remaining boundary-flip count. - expect(agreement).toBeGreaterThan(0.978); - }); - - /** - * The BINARY lava classification, separately - because that is the only thing - * the cliff collision rejection reads. `tryToAddCliff` asks "does this tile - * carry `water_tile`", never which tile it is, so the 19-way argmax above can - * confuse `volcanic-folds` for `volcanic-folds-flat` all day without moving a - * cliff. **It is exact**: measured 2026-07-30, all 49 `lava`/`lava-hot` - * positions and all 332 others land on the correct side, in both directions. - * - * Pinned at zero rather than at a fraction. Every one of the 7 name mismatches - * is a non-lava/non-lava confusion within a biome family, so nothing is being - * rounded away - and the standing hypothesis this falsifies was specific: the - * Vulcanus cliff rejection drops 10 real cliffs (issue #18), and - * `vulcanusCliffEntities.spec.ts` recorded the guess that the resolver is - * "plausibly worse at a lava boundary". It is not worse at a lava boundary; - * the 42 fixture positions that sit directly on one (Chebyshev distance 1 to a - * tile of the opposite lava-ness) are 42/42 correct even on the full name. - * - * **Know what this zero is worth before leaning on it.** Its sensitivity was - * measured by planting scale factors on `lava`'s probability (2026-07-30): - * `1.02` and `1.2` both still pass, `2`, `5` and `20` all fail. 381 sparse - * oracle positions do not sit close enough to a lava boundary in probability - * space to register a small shift, so this is a regression guard against a - * broken lava range expression, NOT a sub-tile boundary check. The boundary - * really is off by about a tile somewhere - the 10 dropped cliffs prove it - - * and this spec cannot see that. The thing that can is the negative-space - * oracle in `vulcanusCliffEntities.spec.ts`: a real cliff the game placed is a - * standing assertion that the game found no lava in its box. - */ - it("classifies lava exactly, which is what the cliff rejection reads", () => { - let ourLava = 0; - let gameLava = 0; - let mismatch = 0; - for (let i = 0; i < positions.length; i++) { - const p = positions[i]; - const ours = VULCANUS_CLIFF_BLOCKING_TILES.has(resolve(p.x, p.y).name); - const game = VULCANUS_CLIFF_BLOCKING_TILES.has(want[i]); - if (ours) ourLava++; - if (game) gameLava++; - if (ours !== game) mismatch++; - } - // Non-vacuity: the fixture really does carry lava, and plenty of it. Without - // this, a resolver that returned a constant non-lava tile would pass the - // mismatch assertion by agreeing with an empty set. - expect(gameLava).toBe(49); - expect(ourLava).toBe(49); - expect(mismatch).toBe(0); - }); -});