|
| 1 | +import { describe, expect, it } from "vite-plus/test"; |
| 2 | + |
| 3 | +import ore from "./fixtures/oracle-vulcanus-cliff-ore-direction.seed123456.json"; |
| 4 | +import { CLIFF_ORIENTATION_NAMES } from "../src/noise/cliffs/cliffCatalog"; |
| 5 | +import { |
| 6 | + applyCliffConnections, |
| 7 | + cliffCodeForOrientation, |
| 8 | +} from "../src/noise/cliffs/cliffConnections"; |
| 9 | +import { |
| 10 | + VULCANUS_CLIFF_BASE_COLLISION_BOX, |
| 11 | + VULCANUS_GEYSER_COLLISION_HALF, |
| 12 | + VULCANUS_ORE_COLLISION_HALF, |
| 13 | +} from "../src/noise/cliffs/vulcanusOreRejection"; |
| 14 | + |
| 15 | +/** |
| 16 | + * **Four of the ten ore "run remainders" are the `Cliff::onDestroy` cascade, and |
| 17 | + * nobody had tested that because the cascade did not exist yet when they were |
| 18 | + * measured** (#84). |
| 19 | + * |
| 20 | + * `vulcanusOreRejection.ts` records that box overlap accounts for 21 of the 31 |
| 21 | + * cells the ore suppresses and that the other ten are "run remainders", with two |
| 22 | + * candidate explanations - "a cascade along cliff connections **or** a wider |
| 23 | + * box" - and it records the cascade half as REFUTED by `cliffOreCascade.spec.ts`. |
| 24 | + * |
| 25 | + * **That refutation is about a different cascade.** It tested #108's |
| 26 | + * CROSSING-stage mechanism: a rejection zeroes the cell's edge registers, a |
| 27 | + * neighbour's code changes, re-test to a fixpoint. The `applyCliffs` cascade - |
| 28 | + * `Cliff::onDestroy` taking the facing end of every connected neighbour, and |
| 29 | + * destroying a neighbour left with no end at all - was only read out of the |
| 30 | + * binary later, in #113. Nothing re-ran the remainder question against it. |
| 31 | + * |
| 32 | + * Running it closes four of the ten, at **zero** cost in precision. |
| 33 | + * |
| 34 | + * ## The experiment uses only the GAME's data on both sides |
| 35 | + * |
| 36 | + * This is what makes it worth trusting: nothing of the port's own field, ore |
| 37 | + * model or geyser roll appears anywhere in it. |
| 38 | + * |
| 39 | + * - **Start** from the game's `ALL resources OFF` cliff set - 892 cells with the |
| 40 | + * game's own orientations. |
| 41 | + * - **Destroy** the cells whose base collision box overlaps one of the game's |
| 42 | + * own resource entity positions, with the prototype half-extents the fixture |
| 43 | + * itself carries. |
| 44 | + * - **Compare** against the game's `resources ON` set - 861 cells. |
| 45 | + * |
| 46 | + * The only modelled things are the overlap rule and the ported cascade. |
| 47 | + * |
| 48 | + * | arm | matched | wrong | surplus | |
| 49 | + * | --- | --- | --- | --- | |
| 50 | + * | control - destroy the lever's own 31 | **861** | **0** | **0** | |
| 51 | + * | direct overlap only, no cascade | 856 | 5 | 10 | |
| 52 | + * | direct overlap + `onDestroy` cascade | **859** | **2** | **6** | |
| 53 | + * |
| 54 | + * The control is what validates the whole setup: the ore-off world minus those |
| 55 | + * 31 cells IS the ore-on world, orientations included. Getting that exactly is |
| 56 | + * also why the second `updateConnections` pass has to be suppressed - the game's |
| 57 | + * dumped set is POST-pipeline, so running the pass again double-applies it and |
| 58 | + * trims ends that legitimately survive (it scores `wrong = 13` if you forget). |
| 59 | + * |
| 60 | + * Recall on the lever's 31 goes **21/31 = 0.677 to 25/31 = 0.806** with no new |
| 61 | + * parameter and no wider box - which matters because #124 established that |
| 62 | + * widening the box would be fitting a shape to an effect the engine's collision |
| 63 | + * system provably does not produce. |
| 64 | + */ |
| 65 | + |
| 66 | +const nameToId = new Map(CLIFF_ORIENTATION_NAMES.map((n, i) => [n, i])); |
| 67 | +const K = (x: number, y: number): string => `${String(x)},${String(y)}`; |
| 68 | + |
| 69 | +interface Ent { |
| 70 | + x: number; |
| 71 | + y: number; |
| 72 | + name: string; |
| 73 | + orientation?: string; |
| 74 | +} |
| 75 | +interface Region { |
| 76 | + x0: number; |
| 77 | + y0: number; |
| 78 | + x1: number; |
| 79 | + y1: number; |
| 80 | +} |
| 81 | + |
| 82 | +const oreCases = ore.cases as unknown as { |
| 83 | + label: string; |
| 84 | + region: Region; |
| 85 | + cliffs: Ent[]; |
| 86 | + resources: Ent[]; |
| 87 | +}[]; |
| 88 | +const arm = (label: string): { label: string; region: Region; cliffs: Ent[]; resources: Ent[] } => { |
| 89 | + const c = oreCases.find((q) => q.label === label); |
| 90 | + if (c === undefined) throw new Error(`no arm ${label}`); |
| 91 | + return c; |
| 92 | +}; |
| 93 | +const cliffMap = (label: string): Map<string, number> => { |
| 94 | + const c = arm(label); |
| 95 | + const m = new Map<string, number>(); |
| 96 | + for (const e of c.cliffs) |
| 97 | + if ( |
| 98 | + e.name === "cliff-vulcanus" && |
| 99 | + e.x >= c.region.x0 && |
| 100 | + e.x < c.region.x1 && |
| 101 | + e.y >= c.region.y0 && |
| 102 | + e.y < c.region.y1 |
| 103 | + ) { |
| 104 | + const id = nameToId.get(e.orientation ?? ""); |
| 105 | + if (id !== undefined) m.set(K(e.x, e.y), id); |
| 106 | + } |
| 107 | + return m; |
| 108 | +}; |
| 109 | + |
| 110 | +const ON = cliffMap("entity region, resources ON"); |
| 111 | +const ALL_OFF = cliffMap("entity region, ALL resources OFF"); |
| 112 | +const REG = arm("entity region, resources ON").region; |
| 113 | +const inR = (p: { x: number; y: number }): boolean => |
| 114 | + p.x >= REG.x0 && p.x < REG.x1 && p.y >= REG.y0 && p.y < REG.y1; |
| 115 | +const RES = arm("entity region, resources ON").resources; |
| 116 | +const SUPPRESSED = new Set([...ALL_OFF.keys()].filter((k) => !ON.has(k))); |
| 117 | + |
| 118 | +/** The game's own ore-off cliffs, as an `applyCliffConnections` input. */ |
| 119 | +const START = [...ALL_OFF.entries()].map(([k, o]) => { |
| 120 | + const [xs, ys] = k.split(","); |
| 121 | + return { x: Number(xs), y: Number(ys), code: cliffCodeForOrientation(o) }; |
| 122 | +}); |
| 123 | + |
| 124 | +const [bl, bt, br, bb] = VULCANUS_CLIFF_BASE_COLLISION_BOX; |
| 125 | +const halfOf = (name: string): number => |
| 126 | + name === "sulfuric-acid-geyser" ? VULCANUS_GEYSER_COLLISION_HALF : VULCANUS_ORE_COLLISION_HALF; |
| 127 | + |
| 128 | +/** Bucketed so the overlap test is not 892 x 3933. */ |
| 129 | +const BUCKET = 8; |
| 130 | +const BUCKETS = new Map<string, Ent[]>(); |
| 131 | +for (const r of RES) { |
| 132 | + const k = K(Math.floor(r.x / BUCKET), Math.floor(r.y / BUCKET)); |
| 133 | + const a = BUCKETS.get(k); |
| 134 | + if (a === undefined) BUCKETS.set(k, [r]); |
| 135 | + else a.push(r); |
| 136 | +} |
| 137 | + |
| 138 | +/** Does the cell's base box overlap any of the GAME's resource entity boxes? */ |
| 139 | +const overlapsGameResource = (cx: number, cy: number): boolean => { |
| 140 | + const left = cx + bl; |
| 141 | + const top = cy + bt; |
| 142 | + const right = cx + br; |
| 143 | + const bottom = cy + bb; |
| 144 | + for (let gx = Math.floor((left - 2) / BUCKET); gx <= Math.floor((right + 2) / BUCKET); gx++) |
| 145 | + for (let gy = Math.floor((top - 2) / BUCKET); gy <= Math.floor((bottom + 2) / BUCKET); gy++) { |
| 146 | + const a = BUCKETS.get(K(gx, gy)); |
| 147 | + if (a === undefined) continue; |
| 148 | + for (const r of a) { |
| 149 | + const h = halfOf(r.name); |
| 150 | + if (r.x - h < right && left < r.x + h && r.y - h < bottom && top < r.y + h) return true; |
| 151 | + } |
| 152 | + } |
| 153 | + return false; |
| 154 | +}; |
| 155 | + |
| 156 | +const DIRECT = new Set(START.filter((c) => overlapsGameResource(c.x, c.y)).map((c) => K(c.x, c.y))); |
| 157 | + |
| 158 | +interface Score { |
| 159 | + matched: number; |
| 160 | + wrong: number; |
| 161 | + surplus: number; |
| 162 | + missing: number; |
| 163 | +} |
| 164 | +const run = (kill: Set<string>, noCascade: boolean): { score: Score; left: string[] } => { |
| 165 | + const out = applyCliffConnections(START, { |
| 166 | + collides: (_o, x, y) => kill.has(K(x, y)), |
| 167 | + noCascade, |
| 168 | + // The dumped set is POST-pipeline; running `updateConnections` again would |
| 169 | + // double-apply it. See the module comment. |
| 170 | + noUpdateConnections: true, |
| 171 | + }); |
| 172 | + const survivors = new Map(out.filter(inR).map((p) => [K(p.x, p.y), p.orientation] as const)); |
| 173 | + const score: Score = { matched: 0, wrong: 0, surplus: 0, missing: 0 }; |
| 174 | + for (const [k, id] of survivors) { |
| 175 | + const t = ON.get(k); |
| 176 | + if (t === undefined) score.surplus++; |
| 177 | + else if (t === id) score.matched++; |
| 178 | + else score.wrong++; |
| 179 | + } |
| 180 | + for (const k of ON.keys()) if (!survivors.has(k)) score.missing++; |
| 181 | + return { score, left: [...SUPPRESSED].filter((k) => survivors.has(k)) }; |
| 182 | +}; |
| 183 | + |
| 184 | +describe("the ore lever, replayed entirely on the game's own data", () => { |
| 185 | + /** |
| 186 | + * **The control that validates the harness.** The ore-off world minus the 31 |
| 187 | + * cells the lever attributes to the ore is exactly the ore-on world - |
| 188 | + * positions and orientations. If this ever stops being 861/0/0/0 the arms |
| 189 | + * below mean nothing. |
| 190 | + */ |
| 191 | + it("reproduces the resources-ON world exactly from the OFF world", () => { |
| 192 | + expect(ALL_OFF.size).toBe(892); |
| 193 | + expect(ON.size).toBe(861); |
| 194 | + expect(SUPPRESSED.size).toBe(31); |
| 195 | + expect(run(SUPPRESSED, false).score).toEqual({ |
| 196 | + matched: 861, |
| 197 | + wrong: 0, |
| 198 | + surplus: 0, |
| 199 | + missing: 0, |
| 200 | + }); |
| 201 | + }, 300000); |
| 202 | + |
| 203 | + /** |
| 204 | + * Box overlap against the game's own resource positions fires on 21 cells and |
| 205 | + * **every one of them is in the lever's set** - precision 1.000 with nothing |
| 206 | + * of ours in the measurement. That is the half of `vulcanusOreRejection.ts`'s |
| 207 | + * rule that was never in doubt, re-derived without the port's ore field or |
| 208 | + * geyser roll. |
| 209 | + */ |
| 210 | + it("finds 21 directly overlapped cells and no false positives", () => { |
| 211 | + expect(DIRECT.size).toBe(21); |
| 212 | + expect([...DIRECT].filter((k) => !SUPPRESSED.has(k))).toEqual([]); |
| 213 | + }, 300000); |
| 214 | +}); |
| 215 | + |
| 216 | +describe("the onDestroy cascade explains four of the ten remainders", () => { |
| 217 | + /** |
| 218 | + * The comparison that carries the finding. Destroying the same 21 cells |
| 219 | + * differs only in whether `Cliff::onDestroy` runs, and the cascade removes |
| 220 | + * four more - each one a cell left with no end at all once its neighbours' |
| 221 | + * facing ends went - plus three of the five orientation errors. |
| 222 | + * |
| 223 | + * **No cell the lever keeps is ever removed**, in either arm, so this costs no |
| 224 | + * precision. That is the property that distinguishes a mechanism from a wider |
| 225 | + * box: a box big enough to reach these four would also reach cells the game |
| 226 | + * kept. |
| 227 | + */ |
| 228 | + it("takes the remainder from 10 to 6 with no precision cost", () => { |
| 229 | + const withoutCascade = run(DIRECT, true); |
| 230 | + const withCascade = run(DIRECT, false); |
| 231 | + |
| 232 | + expect(withoutCascade.score).toEqual({ matched: 856, wrong: 5, surplus: 10, missing: 0 }); |
| 233 | + expect(withCascade.score).toEqual({ matched: 859, wrong: 2, surplus: 6, missing: 0 }); |
| 234 | + |
| 235 | + // Recall on the lever's own 31, before and after. |
| 236 | + expect((31 - withoutCascade.left.length) / 31).toBeCloseTo(0.677, 3); |
| 237 | + expect((31 - withCascade.left.length) / 31).toBeCloseTo(0.806, 3); |
| 238 | + |
| 239 | + // ...and neither arm removes a cell the game kept - `missing` is 0 in both. |
| 240 | + expect(withoutCascade.score.missing).toBe(0); |
| 241 | + expect(withCascade.score.missing).toBe(0); |
| 242 | + }, 300000); |
| 243 | + |
| 244 | + /** |
| 245 | + * The six that remain, pinned as the input to whatever comes next. Two of them |
| 246 | + * (`1546,1550.5`, `1546,1554.5`) are the pair that #123 attributed to the |
| 247 | + * geyser and #124 used for its n=1 destroy-stage proof - so the one cell in |
| 248 | + * this whole residual whose destruction is directly witnessed by the game's |
| 249 | + * own orientations is still unexplained by any rule. |
| 250 | + */ |
| 251 | + it("pins the six the cascade does not reach", () => { |
| 252 | + expect(run(DIRECT, false).left.sort((a, b) => a.localeCompare(b))).toEqual([ |
| 253 | + "1546,1550.5", |
| 254 | + "1546,1554.5", |
| 255 | + "1606,1590.5", |
| 256 | + "1606,1594.5", |
| 257 | + "1622,1614.5", |
| 258 | + "1626,1614.5", |
| 259 | + ]); |
| 260 | + }, 300000); |
| 261 | +}); |
0 commit comments