1/**2 * Endless stream goldens — pin per-segment hashes for fixed seeds.3 *4 * The endless stream is BY DESIGN a pure function of the seed (culling is5 * generation-driven via KEEP_SEGMENTS, never marble-driven), so segment k of6 * seed X is fully deterministic. These pins make that promise enforceable:7 * a planner/WFC/island refactor that reshuffles the stream trips the golden8 * test loudly instead of silently changing every endless run in the wild.9 *10 * Regenerate (only when a reshuffle is INTENTIONAL):11 *12 * deno run --allow-read --allow-write tools/pin_endless.ts13 *14 * tests/endless_golden_test.ts imports the canonicalization from here — the15 * pins and the check are the same code path by construction.16 */17import type { Placement } from "../src/shared/types.ts";18import { cyrb128 } from "../src/gen/rng.ts";19import { genVersion } from "../src/gen/version.ts";20import type { DecorCatalog } from "../src/gen/decor.ts";21import { type EndlessSegment, EndlessWorld } from "../src/gen/endless/world.ts";2223/** Fixed probe seeds — same labels as the finite goldens ("one seed, one24 * theme, both modes"), plus one endless-only. */25export const GOLDEN_SEEDS = ["alpha", "zx9k2m4p1q", "dami", "marbles"];2627/** Pinned prefix per seed. Islands anchor every 14–38 cells; 12 segments28 * cover ~3 island cycles per seed (verified at pin time: every seed must29 * anchor at least 2 islands inside the pinned window). */30export const GOLDEN_SEGMENTS = 12;3132/** The real committed manifests — the catalog the client actually streams33 * with (MUST track src/client/assets.ts DECOR_PACKS: settings resolve34 * against the catalog, so a pack missing here silently blanks its biome's35 * decor and the goldens lie). A manifest edit that changes decor draws is36 * a real reshuffle too; regenerate the pins knowingly. */37export const GOLDEN_PACKS = [38 "nature-kit",39 "holiday-kit",40 "graveyard-kit",41 "pirate-kit",42 "survival-kit",43 "space-kit",44 "platformer-kit",45] as const;4647export function goldenCatalog(): DecorCatalog {48 return {49 packs: GOLDEN_PACKS.map((slug) => ({50 slug,51 ids: JSON.parse(Deno.readTextFileSync(52 new URL(`../assets/${slug}.manifest.json`, import.meta.url),53 )).pieces.map((p: { id: string }) => p.id),54 })),55 };56}5758/** Integer-domain canonical form of one placement — float formatting must59 * never affect the hash (positions ×4, scales/spins/boosts ×16, the60 * genVersion() idiom). Every semantically meaningful field rides along:61 * chapter/zone/bumper/goal are gameplay, not cosmetics. */62function canonPlacement(p: Placement): string {63 return [64 p.pieceId,65 p.pos.map((v) => Math.round(v * 4)).join(","),66 p.rotation,67 p.mirrored ? 1 : 0,68 p.role,69 p.scale !== undefined ? Math.round(p.scale * 16) : "",70 p.spin71 ? `${p.spin.axis.map((v) => Math.round(v * 16)).join(",")}~${72 Math.round(p.spin.radPerSec * 16)73 }`74 : "",75 p.boost !== undefined ? Math.round(p.boost * 16) : "",76 p.wind77 ? [78 p.wind.strength,79 p.wind.length,80 p.wind.radius,81 ...(p.wind.offset ?? []),82 p.wind.pulse?.period,83 p.wind.pulse?.duty,84 p.wind.pulse?.phase,85 p.wind.swirl ? 1 : undefined,86 ].map((v) => (v === undefined ? "" : Math.round(v * 16))).join(";")87 : "",88 p.float89 ? [90 p.float.amp,91 p.float.period,92 p.float.phase,93 ...(p.float.drift ?? []),94 ].map((v) => Math.round(v * 16)).join(";")95 : "",96 p.chapter ?? "",97 p.zone ?? "",98 p.bumper ? 1 : 0,99 p.goal ? 1 : 0,100 ].join("|");101}102103/** Canonical serialization of one endless segment: track placements (decor,104 * furniture and zone pads included), islands (meta + their placements +105 * mass cells), the window structure, and the plan's entry→exit contract. */106export function canonSegment(seg: EndlessSegment): string {107 const lines: string[] = [];108 lines.push(`seg:${seg.index}|startS:${Math.round(seg.startS * 4)}`);109 lines.push(`families:${seg.pieceFamilies.join(",")}`);110 lines.push(`mods:${seg.windowMods.map((m) => m ?? ".").join(",")}`);111 lines.push(112 `windows:${seg.windowRanges.map((w) => `${w.start}+${w.len}`).join(",")}`,113 );114 const { entry, exit } = seg.plan;115 lines.push(116 `entry:${entry.x},${entry.z},${Math.round(entry.y * 4)},${entry.dir}` +117 `|exit:${exit.x},${exit.z},${Math.round(exit.y * 4)},${exit.dir}`,118 );119 for (const p of seg.placements) lines.push(canonPlacement(p));120 for (const isle of seg.islands) {121 lines.push(122 `island:${isle.id}|${isle.biome}|${Math.round(isle.topY * 4)}` +123 `|cells:${isle.cells.join(";")}`,124 );125 for (const p of isle.placements) lines.push(canonPlacement(p));126 }127 // Void decor (M18b) — separate section, mirroring its separate field.128 for (const p of seg.voidDecor) lines.push(`void:${canonPlacement(p)}`);129 return lines.join("\n");130}131132/** 16-hex-char hash of one segment (cyrb128 — pure 32-bit integer ops,133 * bit-identical across JS engines). */134export function segmentHash(seg: EndlessSegment): string {135 const [a, b] = cyrb128(canonSegment(seg));136 return (a >>> 0).toString(16).padStart(8, "0") +137 (b >>> 0).toString(16).padStart(8, "0");138}139140/** Regenerate all pins — the test computes exactly this and compares. */141export function computeEndlessGoldens(): Record<string, string | string[]> {142 const catalog = goldenCatalog();143 const goldens: Record<string, string | string[]> = {144 __version: genVersion(),145 };146 for (const seed of GOLDEN_SEEDS) {147 const world = new EndlessWorld(seed, catalog);148 const hashes: string[] = [];149 let islands = 0;150 for (let i = 0; i < GOLDEN_SEGMENTS; i++) {151 const seg = world.get(i);152 islands += seg.islands.length;153 hashes.push(segmentHash(seg));154 }155 if (islands < 2) {156 throw new Error(157 `seed ${seed}: only ${islands} island(s) in ${GOLDEN_SEGMENTS} ` +158 `segments — pinned window too short to cover anchor cycles`,159 );160 }161 goldens[seed] = hashes;162 }163 return goldens;164}165166if (import.meta.main) {167 const goldens = computeEndlessGoldens();168 const file = new URL("../tests/golden_endless.json", import.meta.url);169 await Deno.writeTextFile(file, JSON.stringify(goldens, null, 2) + "\n");170 console.log(171 `tests/golden_endless.json written — ${GOLDEN_SEEDS.length} seeds × ` +172 `${GOLDEN_SEGMENTS} segments (version ${goldens.__version}). Commit it.`,173 );174}