1/**2 * Setting + Decor systems.3 *4 * Setting: its own RNG family picks a setting theme — a decor pack plus rule5 * table (piece-name patterns × distance bands). Data-driven: new packs are a6 * manifest + a rule table away.7 *8 * Decor: a WORLD FIELD. The caller supplies a cell region and a track-distance9 * oracle; placeDecor realizes the field for exactly that region. Every cell's10 * outcome is keyed by its own RNG child (`cell/${x}/${z}`) and the oracle's11 * answer — never by iteration order or region bounds — so any tiling of12 * regions produces the same world. Single-track mode passes bounds+margin and13 * an oracle over its own cell set; M5's chunk streamer passes chunk regions14 * and an oracle backed by "this chunk + neighbors".15 */16import type { Placement, Vec3 } from "../shared/types.ts";17import type { Rng } from "./rng.ts";18import type { StyleField } from "./style.ts";1920/** What the generator needs to know about available decor pieces. */21export interface DecorCatalog {22 packs: { slug: string; ids: readonly string[] }[];23}2425type Band = "near" | "mid" | "far" | "ambient";2627interface DecorGroup {28 pattern: RegExp;29 weight: number;30 band: Band;31 scale: readonly [number, number];32}3334export interface Setting {35 name: string;36 packSlug: string | null;37 groups: DecorGroup[];38}3940/** Setting rule rows. `pack` names the decor pack the patterns are scoped41 * to (per-pack scoping makes cross-pack regex collisions impossible);42 * `finite` marks the rows the LOOP mode's setting draw picks from — that43 * list must stay exactly the historical three, in order, or every finite44 * seed's `setting` stream reshuffles (genVersion moves). Endless biomes45 * reach any row by name via settingByName. */46type SettingSpec = Omit<Setting, "packSlug"> & {47 pack: string;48 finite?: boolean;49};5051const SETTINGS: SettingSpec[] = [52 {53 name: "forest",54 pack: "nature-kit",55 finite: true,56 groups: [57 {58 pattern: /^tree[-_]?(?!.*dead)/,59 weight: 6,60 band: "mid",61 scale: [0.8, 1.3],62 },63 { pattern: /^tree/, weight: 3, band: "far", scale: [1.0, 1.6] },64 { pattern: /^(rock|stone)/, weight: 2, band: "near", scale: [0.6, 1.1] },65 {66 pattern: /^(grass|plant|flower|mushroom)/,67 weight: 4,68 band: "near",69 scale: [0.8, 1.2],70 },71 { pattern: /^stump/, weight: 1, band: "mid", scale: [0.8, 1.2] },72 ],73 },74 {75 name: "rocky",76 pack: "nature-kit",77 finite: true,78 groups: [79 {80 pattern: /^(rock|stone|cliff)/,81 weight: 6,82 band: "mid",83 scale: [0.8, 1.5],84 },85 { pattern: /^(rock|stone)/, weight: 4, band: "near", scale: [0.5, 0.9] },86 {87 pattern: /^tree.*dead|^log/,88 weight: 2,89 band: "mid",90 scale: [0.9, 1.2],91 },92 { pattern: /^(grass|plant)/, weight: 2, band: "near", scale: [0.7, 1.0] },93 {94 pattern: /^cliff|^rock[-_]?large/,95 weight: 3,96 band: "far",97 scale: [1.2, 2.0],98 },99 ],100 },101 {102 name: "meadow",103 pack: "nature-kit",104 finite: true,105 groups: [106 {107 pattern: /^(grass|flower|plant)/,108 weight: 6,109 band: "near",110 scale: [0.8, 1.3],111 },112 { pattern: /^(fence|path)/, weight: 1.5, band: "near", scale: [1, 1] },113 { pattern: /^tree/, weight: 2, band: "far", scale: [1.0, 1.5] },114 {115 pattern: /^(crop|pumpkin|bed)/,116 weight: 2,117 band: "mid",118 scale: [0.9, 1.1],119 },120 ],121 },122 // M18a themed rows (endless biomes only — never in the finite draw).123 // Scale ranges are measured against each pack's authored size (pirate124 // pieces are ~2.5-3x nature scale; survival props run tiny).125 {126 name: "winter",127 pack: "holiday-kit",128 groups: [129 { pattern: /^tree-snow/, weight: 6, band: "mid", scale: [0.7, 1.0] },130 { pattern: /^tree-snow/, weight: 3, band: "far", scale: [0.9, 1.2] },131 { pattern: /^rocks-(small|medium)/, weight: 2, band: "near", scale: [0.5, 0.8] },132 { pattern: /^rocks-large/, weight: 2, band: "far", scale: [0.4, 0.6] },133 { pattern: /^snow-pile/, weight: 3, band: "near", scale: [0.8, 1.2] },134 { pattern: /^(present-|candy-cane)/, weight: 1.5, band: "near", scale: [0.8, 1.2] },135 { pattern: /^lantern$/, weight: 1, band: "near", scale: [0.5, 0.7] },136 { pattern: /^tree-decorated$/, weight: 0.8, band: "mid", scale: [0.8, 0.95] },137 ],138 },139 {140 name: "spooky",141 pack: "graveyard-kit",142 groups: [143 { pattern: /^pine/, weight: 5, band: "mid", scale: [0.6, 0.9] },144 { pattern: /^pine/, weight: 3, band: "far", scale: [0.8, 1.1] },145 { pattern: /^gravestone/, weight: 5, band: "near", scale: [0.9, 1.3] },146 { pattern: /^iron-fence$/, weight: 2, band: "near", scale: [0.9, 1.1] },147 { pattern: /^pumpkin/, weight: 2, band: "near", scale: [0.9, 1.3] },148 { pattern: /^(lantern-candle|candle)/, weight: 1.5, band: "near", scale: [1.0, 1.3] },149 { pattern: /^(debris|rocks|trunk)/, weight: 2, band: "mid", scale: [0.7, 1.1] },150 { pattern: /^cross(-wood)?$/, weight: 1.5, band: "mid", scale: [1.0, 1.3] },151 ],152 },153 {154 name: "tropical",155 pack: "pirate-kit",156 groups: [157 { pattern: /^palm-(bend|straight)/, weight: 6, band: "mid", scale: [0.3, 0.42] },158 { pattern: /^palm-detailed/, weight: 3, band: "far", scale: [0.35, 0.5] },159 { pattern: /^rocks-sand/, weight: 3, band: "near", scale: [0.15, 0.3] },160 { pattern: /^grass$/, weight: 3, band: "near", scale: [0.8, 1.2] },161 { pattern: /^grass-plant$/, weight: 2, band: "near", scale: [0.5, 0.8] },162 { pattern: /^(barrel|crate|chest)$/, weight: 1.5, band: "near", scale: [0.5, 0.7] },163 { pattern: /^flag$/, weight: 0.8, band: "mid", scale: [0.5, 0.7] },164 ],165 },166 {167 name: "autumn",168 pack: "survival-kit",169 groups: [170 { pattern: /^tree-autumn/, weight: 6, band: "mid", scale: [0.9, 1.3] },171 { pattern: /^tree-autumn-tall/, weight: 3, band: "far", scale: [1.1, 1.5] },172 { pattern: /^rock-(a|b|c|flat)/, weight: 2.5, band: "near", scale: [0.8, 1.2] },173 { pattern: /^rock-sand/, weight: 2, band: "mid", scale: [0.7, 1.1] },174 { pattern: /^grass/, weight: 3, band: "near", scale: [1.0, 1.4] },175 { pattern: /^(tent|campfire)/, weight: 1.5, band: "mid", scale: [1.3, 1.7] },176 { pattern: /^signpost/, weight: 0.8, band: "near", scale: [1.2, 1.5] },177 { pattern: /^tree-log/, weight: 1.5, band: "near", scale: [1.0, 1.4] },178 ],179 },180];181182/** Resolve one settings row against its own pack in the catalog: groups183 * whose pattern matches nothing in the pack are dropped (pack versions184 * drift; the field must never place a missing id). */185function resolveSetting(186 spec: SettingSpec,187 catalog: DecorCatalog,188): Setting {189 const pack = catalog.packs.find((p) => p.slug === spec.pack);190 if (!pack) return { name: "bare", packSlug: null, groups: [] };191 return {192 name: spec.name,193 packSlug: spec.pack,194 groups: spec.groups.filter((g) => pack.ids.some((id) => g.pattern.test(id))),195 };196}197198/**199 * Setting by name — island biomes (endless mode) reach any rule row200 * directly (nature rows and the M18a themed rows alike) instead of201 * drawing a setting per world.202 */203export function settingByName(name: string, catalog: DecorCatalog): Setting {204 const spec = SETTINGS.find((s) => s.name === name);205 if (!spec) return { name: "bare", packSlug: null, groups: [] };206 return resolveSetting(spec, catalog);207}208209export function pickSetting(rng: Rng, catalog: DecorCatalog): Setting {210 // LOOP mode's per-world draw: exactly the historical rows, in order —211 // the `finite` flag is the genVersion guard (see SETTINGS).212 const spec = rng.pick(SETTINGS.filter((s) => s.finite));213 return resolveSetting(spec, catalog);214}215216/** Marble Kit decor sprinkled independently of the setting pack. */217const MARBLE_DECOR: {218 id: string;219 weight: number;220 band: Band;221 spin?: boolean;222}[] = [223 { id: "tree", weight: 2, band: "mid" },224 { id: "tree-tall", weight: 2, band: "mid" },225 { id: "tree-large", weight: 1, band: "far" },226 { id: "tree-tall-large", weight: 1, band: "far" },227 // Banners are WIDE (posts at ±0.425, plus placement jitter ±0.3): in the228 // near band they can reach across into an adjacent track cell (caught by229 // validate at 1/1000 seeds). Mid band keeps them clear.230 { id: "banner", weight: 1, band: "mid" },231 { id: "banner-high", weight: 0.7, band: "mid" },232 { id: "banner-double", weight: 0.5, band: "mid" },233 { id: "banner-double-high", weight: 0.4, band: "mid" },234 { id: "banner-hanging", weight: 0.5, band: "mid" },235 { id: "banner-hanging-high", weight: 0.4, band: "mid" },236 { id: "banner-hanging-double", weight: 0.3, band: "mid" },237 { id: "banner-hanging-double-high", weight: 0.3, band: "mid" },238 // Fans are TRACK ELEMENTS now (fans.ts, M4): deliberately placed against239 // the channel with a marble boost — no longer sprinkled as decor.240];241242/** Inclusive cell bounds the decor field is realized for. */243export interface CellRegion {244 minX: number;245 maxX: number;246 minZ: number;247 maxZ: number;248}249250/**251 * What the field knows about the track at a cell: distance to the nearest252 * track cell and the grid direction toward it (as a piece rotation, so facade253 * pieces like fans can face the track). `dist` is true out to254 * ORACLE_GRADE_HORIZON (the graded ambient falloff needs it) and Infinity255 * beyond; everything past 5.5 is the ambient band.256 */257export interface TrackProbe {258 dist: number;259 dir: Placement["rotation"];260}261262/**263 * Cell → nearest-track info. A chunked caller (M5) backs this with264 * "this chunk + neighbors"; single-track mode wraps its one cell set.265 * Implementations must answer from bounded local context only.266 */267export type TrackOracle = (x: number, z: number) => TrackProbe;268269/**270 * How far (in cells) an oracle must see: the banded rules stop at 5.5, so any271 * track farther than this cannot change a cell's BAND. Chunked backers272 * need neighbor context of exactly ORACLE_GRADE_HORIZON.273 */274export const ORACLE_HORIZON = 6;275276/**277 * Widened oracle contract (M13, the promised distance-graded ambient):278 * oracles return TRUE distances out to this radius (matching the279 * single-track margin) and saturate to Infinity beyond — so the ambient280 * band can thin with distance instead of a flat factor. This widens the281 * CONTRACT, not the field rules: the graded density is the only consumer.282 */283export const ORACLE_GRADE_HORIZON = 13;284285/** Track bounds + margin — the single-track region. */286export function regionAround(287 cells: ReadonlySet<string>,288 margin: number,289): CellRegion {290 let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;291 for (const cell of cells) {292 const [x, z] = cell.split(",").map(Number);293 minX = Math.min(minX, x);294 maxX = Math.max(maxX, x);295 minZ = Math.min(minZ, z);296 maxZ = Math.max(maxZ, z);297 }298 return {299 minX: minX - margin,300 maxX: maxX + margin,301 minZ: minZ - margin,302 maxZ: maxZ + margin,303 };304}305306/** Oracle over a plain cell set (single-track mode). */307export function cellSetOracle(cells: ReadonlySet<string>): TrackOracle {308 return (x, z) => {309 let best = Infinity;310 let bx = 0, bz = 1;311 for (let dx = -ORACLE_GRADE_HORIZON; dx <= ORACLE_GRADE_HORIZON; dx++) {312 for (let dz = -ORACLE_GRADE_HORIZON; dz <= ORACLE_GRADE_HORIZON; dz++) {313 if (cells.has(`${x + dx},${z + dz}`)) {314 const d = Math.hypot(dx, dz);315 if (d < best) {316 best = d;317 bx = dx;318 bz = dz;319 }320 }321 }322 }323 // Dominant axis → Dir (0=+x, 1=+z, 2=−x, 3=−z).324 const wantDir = Math.abs(bx) >= Math.abs(bz)325 ? (bx >= 0 ? 0 : 2)326 : (bz >= 0 ? 1 : 3);327 // Rotation r maps local +z (dir 1) to world (1 − r + 4) % 4.328 return {329 dist: best,330 dir: ((1 - wantDir + 4) % 4) as Placement["rotation"],331 };332 };333}334335/** Ambient band thins the field out toward the fog, not a hard cliff. */336const AMBIENT_DENSITY = 0.3;337/** Graded falloff floor at/beyond the grade horizon — the last stragglers338 * the fog eats. */339const AMBIENT_FLOOR = 0.05;340341/** Distance-graded ambient density (M13): 0.3 at the far-band edge342 * thinning linearly to the floor at ORACLE_GRADE_HORIZON. Saturated343 * distances (Infinity) sit at the floor. */344export function ambientDensity(dist: number): number {345 const span = ORACLE_GRADE_HORIZON - 5.5;346 const t = Math.max(347 0,348 Math.min(1, (Math.min(dist, ORACLE_GRADE_HORIZON) - 5.5) / span),349 );350 return AMBIENT_DENSITY + (AMBIENT_FLOOR - AMBIENT_DENSITY) * t;351}352353export function placeDecor(354 rng: Rng,355 setting: Setting,356 style: StyleField,357 region: CellRegion,358 oracle: TrackOracle,359 catalog: DecorCatalog,360): Placement[] {361 const placements: Placement[] = [];362 const nature = setting.packSlug363 ? catalog.packs.find((p) => p.slug === setting.packSlug)364 : undefined;365366 const bandOf = (d: number): Band | null => {367 if (d < 1) return null; // on/too close to the track — would clip it368 if (d <= 2) return "near";369 if (d <= 3.5) return "mid";370 if (d <= 5.5) return "far";371 return "ambient"; // sparse continuation out to the region edge372 };373374 const idsFor = (group: DecorGroup): string[] =>375 nature ? nature.ids.filter((id) => group.pattern.test(id)) : [];376377 for (let x = region.minX; x <= region.maxX; x++) {378 for (let z = region.minZ; z <= region.maxZ; z++) {379 const cellRng = rng.child(`cell/${x}/${z}`);380 if (!cellRng.chance(style.decorDensity * 0.45)) continue;381 const probe = oracle(x, z);382 const band = bandOf(probe.dist);383 if (band === null) continue;384 // The ambient band reuses the far-band rules, thinned: horizon fill of385 // the same trees/rocks/cliffs, sparse enough that the fog does the rest.386 const ruleBand: Band = band === "ambient" ? "far" : band;387 if (band === "ambient" && !cellRng.chance(ambientDensity(probe.dist))) {388 continue;389 }390391 // Weighted union of setting groups and marble-kit decor for this band.392 const options: (readonly [() => Placement | null, number])[] = [];393 for (const group of setting.groups) {394 if (group.band !== ruleBand) continue;395 const ids = idsFor(group);396 if (ids.length === 0) continue;397 options.push(398 [399 () =>400 makeNaturePlacement(cellRng, setting.packSlug!, ids, group, x, z),401 group.weight,402 ] as const,403 );404 }405 for (const d of MARBLE_DECOR) {406 if (d.band !== ruleBand) continue;407 // Fans stay banded: a fan spinning alone in the ambient void reads408 // as debris, not scenery.409 if (band === "ambient" && d.spin) continue;410 options.push(411 [412 () => makeMarblePlacement(cellRng, d, x, z, probe.dir),413 d.weight * 0.5,414 ] as const,415 );416 }417 if (options.length === 0) continue;418 const make = cellRng.weighted(options);419 const placement = make();420 if (placement) placements.push(placement);421 }422 }423 return placements;424}425426function jitteredPos(rng: Rng, x: number, z: number): Vec3 {427 return [x + (rng.float() - 0.5) * 0.6, 0, z + (rng.float() - 0.5) * 0.6];428}429430function makeNaturePlacement(431 rng: Rng,432 packSlug: string,433 ids: string[],434 group: DecorGroup,435 x: number,436 z: number,437): Placement {438 const id = rng.pick(ids);439 const scale = group.scale[0] +440 rng.float() * (group.scale[1] - group.scale[0]);441 return {442 pieceId: `${packSlug}:${id}`,443 pos: jitteredPos(rng, x, z),444 rotation: rng.int(0, 4) as Placement["rotation"],445 role: "decor",446 scale,447 };448}449450function makeMarblePlacement(451 rng: Rng,452 d: { id: string; spin?: boolean },453 x: number,454 z: number,455 faceTrack: Placement["rotation"],456): Placement {457 const pos = jitteredPos(rng, x, z);458 // Spinning pieces (fans) face the track — their local +z is the blade459 // side, and the housing back reads as a blank deck-colored cube.460 const rotation = d.spin ? faceTrack : rng.int(0, 4) as Placement["rotation"];461 return {462 pieceId: d.id,463 pos,464 rotation,465 role: "decor",466 spin: d.spin467 ? { axis: [0, 0, 1], radPerSec: 1.5 + rng.float() * 2.5 }468 : undefined,469 };470}