1/**2 * Decor-as-world-field invariants (M2a). The field must be a pure function of3 * (seed, cell, oracle answer) — realizing it region-by-region has to4 * reproduce the single-region result exactly, or M5's chunk streaming would5 * pop decor at chunk seams.6 */7import { assert, assertEquals } from "@std/assert";8import type { Placement } from "../src/shared/types.ts";9import { rngFamily } from "../src/gen/rng.ts";10import {11 type CellRegion,12 cellSetOracle,13 pickSetting,14 placeDecor,15 regionAround,16 type Setting,17 settingByName,18} from "../src/gen/decor.ts";19import { goldenCatalog } from "../tools/pin_endless.ts";20import { pickStyle, type StyleField } from "../src/gen/style.ts";2122const CATALOG = {23 packs: [{24 slug: "nature-kit",25 ids: [26 "tree_a",27 "tree_dead",28 "rock_a",29 "stone_b",30 "grass_a",31 "plant_a",32 "cliff_a",33 ],34 }],35};3637/** Small square ring of track cells. */38const RING = new Set(39 ["0,0", "1,0", "2,0", "2,1", "2,2", "1,2", "0,2", "0,1"],40);4142function fixture(seed: string): { setting: Setting; style: StyleField } {43 return {44 setting: pickSetting(rngFamily(seed, "setting"), CATALOG),45 style: pickStyle(rngFamily(seed, "style")),46 };47}4849function field(seed: string, region?: CellRegion): Placement[] {50 const { setting, style } = fixture(seed);51 return placeDecor(52 rngFamily(seed, "decor"),53 setting,54 style,55 region ?? regionAround(RING, 13),56 cellSetOracle(RING),57 CATALOG,58 );59}6061function distToRing(p: Placement): number {62 const x = Math.round(p.pos[0]);63 const z = Math.round(p.pos[2]);64 let best = Infinity;65 for (const cell of RING) {66 const [cx, cz] = cell.split(",").map(Number);67 best = Math.min(best, Math.hypot(x - cx, z - cz));68 }69 return best;70}7172function canonical(placements: Placement[]): string {73 return placements.map((p) => JSON.stringify(p)).sort().join("\n");74}7576Deno.test("field tiles: quadrant regions reproduce the single-region field", () => {77 for (const seed of ["tile-a", "tile-b", "tile-c"]) {78 const whole = field(seed);79 const full = regionAround(RING, 13);80 const midX = Math.floor((full.minX + full.maxX) / 2);81 const midZ = Math.floor((full.minZ + full.maxZ) / 2);82 const quadrants: CellRegion[] = [83 { minX: full.minX, maxX: midX, minZ: full.minZ, maxZ: midZ },84 { minX: midX + 1, maxX: full.maxX, minZ: full.minZ, maxZ: midZ },85 { minX: full.minX, maxX: midX, minZ: midZ + 1, maxZ: full.maxZ },86 { minX: midX + 1, maxX: full.maxX, minZ: midZ + 1, maxZ: full.maxZ },87 ];88 const tiled = quadrants.flatMap((q) => field(seed, q));89 assertEquals(canonical(tiled), canonical(whole));90 }91});9293Deno.test("ambient band: decor continues beyond the old far cutoff (5.5)", () => {94 let ambient = 0;95 for (let i = 0; i < 10; i++) {96 ambient += field(`ambient-${i}`).filter((p) => distToRing(p) > 5.5).length;97 }98 assert(ambient > 0, "expected some ambient-band decor beyond dist 5.5");99});100101Deno.test("ambient falloff: the field thins with distance (graded, not flat)", () => {102 // M13: ambient density grades from 0.3 at the far edge to the floor at103 // the grade horizon. Aggregate per-cell hit rates over two annuli —104 // the near ambient ring must be denser than the deep ring.105 const full = regionAround(RING, 13);106 let nearCells = 0;107 let deepCells = 0;108 for (let x = full.minX; x <= full.maxX; x++) {109 for (let z = full.minZ; z <= full.maxZ; z++) {110 let d = Infinity;111 for (const cell of RING) {112 const [cx, cz] = cell.split(",").map(Number);113 d = Math.min(d, Math.hypot(x - cx, z - cz));114 }115 if (d > 5.5 && d <= 8) nearCells++;116 else if (d > 10.5) deepCells++;117 }118 }119 let nearHits = 0;120 let deepHits = 0;121 for (let i = 0; i < 40; i++) {122 for (const p of field(`grade-${i}`)) {123 const d = distToRing(p);124 if (d > 5.5 && d <= 8) nearHits++;125 else if (d > 10.5) deepHits++;126 }127 }128 const nearRate = nearHits / (nearCells * 40);129 const deepRate = deepHits / (deepCells * 40);130 assert(nearHits > 0, "no near-ambient decor at all");131 assert(132 nearRate > deepRate * 1.8,133 `falloff too flat: near ${nearRate.toFixed(4)} vs deep ${134 deepRate.toFixed(4)135 }`,136 );137});138139Deno.test("no decor lands on or within 1 cell of the track", () => {140 for (let i = 0; i < 10; i++) {141 for (const p of field(`clip-${i}`)) {142 assert(143 distToRing(p) >= 1,144 `decor ${p.pieceId} at dist ${distToRing(p)} (seed clip-${i})`,145 );146 }147 }148});149150// ---------------------------------------------------------------------------151// M18a settings generalization: themed rows + the finite guard.152// ---------------------------------------------------------------------------153154Deno.test("settings: finite draw is exactly the historical nature trio", () => {155 // The `finite` flag is the genVersion guard: pickSetting must only ever156 // land on forest/rocky/meadow, in that order, for any draw value —157 // adding themed rows must not touch the finite stream.158 const names = new Set<string>();159 for (let i = 0; i < 200; i++) {160 const s = pickSetting(rngFamily(`finite-${i}`, "setting"), goldenCatalog());161 names.add(s.name);162 assertEquals(s.packSlug, "nature-kit");163 }164 assertEquals([...names].sort(), ["forest", "meadow", "rocky"]);165});166167Deno.test("settings: every row resolves against its real pack manifest", () => {168 const catalog = goldenCatalog();169 for (170 const name of [171 "forest",172 "rocky",173 "meadow",174 "winter",175 "spooky",176 "tropical",177 "autumn",178 ]179 ) {180 const s = settingByName(name, catalog);181 assert(s.packSlug !== null, `${name}: no pack resolved`);182 assert(s.groups.length >= 3, `${name}: only ${s.groups.length} groups match`);183 }184 // Unknown names resolve bare, never throw.185 assertEquals(settingByName("volcano", catalog).packSlug, null);186});