1/**2 * Style system: picks a theme (piece-family weight biases), palette variant,3 * solid-vs-open policy, support flavor, and decor density. Emits weights and4 * predicates — never placements.5 */6import type { Rng } from "./rng.ts";7import type { PieceDef } from "./registry.ts";89/** Seed TEMPERAMENT (M24): a drawn identity that leans the ENDLESS10 * planner's hazard appetite and biome draw — worlds develop coherent11 * characters instead of uniform sampling. Finite mode never reads it12 * (genVersion stays put); the draws are APPENDED to the style stream. */13export interface Temperament {14 name: string;15 /** Endless clause-weight multipliers (path.ts combines with the16 * climate bias). */17 clauseBias: Record<string, number>;18 /** Island biome-weight multipliers (islands.ts). */19 biomeBias: Record<string, number>;20}2122export const TEMPERAMENTS: readonly (Temperament & { weight: number })[] = [23 { name: "balanced", weight: 3, clauseBias: {}, biomeBias: {} },24 {25 name: "treacherous",26 weight: 1.5,27 clauseBias: { icy: 1.8, gravity: 1.5, spurDrop: 1.3 },28 biomeBias: { spooky: 2, barren: 1.5, winter: 1.4 },29 },30 {31 name: "soaring",32 weight: 1.5,33 clauseBias: { jump: 1.8, sprint: 1.4, helixDive: 1.3 },34 biomeBias: { tropical: 1.6, meadow: 1.3 },35 },36 {37 name: "tangled",38 weight: 1.2,39 clauseBias: { braid: 1.8, spurDrop: 1.4, reorient: 1.2 },40 biomeBias: { abstract: 1.6, grove: 1.4 },41 },42 {43 name: "rolling",44 weight: 1.2,45 clauseBias: { slope: 1.5, cornerDrop: 1.5, helixDive: 1.2 },46 biomeBias: { rocky: 1.5, autumn: 1.4 },47 },48 {49 name: "serene",50 weight: 1,51 clauseBias: { icy: 0.6, gravity: 0.6, jump: 0.8, braid: 0.8 },52 biomeBias: { meadow: 2, forest: 1.5, grove: 1.5 },53 },54];5556export interface StyleField {57 theme: string;58 paletteIndex: 0 | 1 | 2;59 /** Multiplicative bias applied to a def's base weight at a ring index. */60 weightFor(def: PieceDef, ringIndex: number): number;61 /** Whether a track piece at this base height should use its solid variant. */62 solidAt(baseY: number): boolean;63 supportKind: "single" | "two" | "four";64 supportColor: boolean;65 /** 0..1 — decor placement probability scale. */66 decorDensity: number;67 /** 0..1 — the feature planner's appetite for wide-channel runs. */68 wideAffinity: number;69 /** Seed identity (M24) — endless-only hazard/biome leans. */70 temperament: Temperament;71}7273interface ThemeSpec {74 name: string;75 weight: number;76 bias: Record<string, number>; // by def id or family77 solidGround: number; // chance ground-level pieces go solid78 decorDensity: number;79 wideAffinity: number;80}8182const THEMES: ThemeSpec[] = [83 {84 name: "classic",85 weight: 4,86 bias: {},87 solidGround: 0.55,88 decorDensity: 0.5,89 wideAffinity: 0.3,90 },91 {92 name: "sweeping",93 weight: 3,94 bias: {95 "curve-large": 5,96 curve: 2,97 "s-curve": 2,98 corner: 0.25,99 tunnel: 0.5,100 },101 solidGround: 0.4,102 decorDensity: 0.45,103 wideAffinity: 0.35,104 },105 {106 name: "tight",107 weight: 3,108 bias: {109 corner: 3.5,110 bend: 3,111 curve: 0.6,112 "curve-large": 0.05,113 straight: 0.8,114 },115 solidGround: 0.5,116 decorDensity: 0.55,117 wideAffinity: 0.05,118 },119 {120 name: "canyon",121 weight: 2,122 bias: { tunnel: 5, slant: 1.6, ramp: 1.6, bump: 2 },123 solidGround: 0.95,124 decorDensity: 0.8,125 wideAffinity: 0.1,126 },127 // M2 themes: exploit the flow vocabulary.128 {129 name: "serpentine",130 weight: 3,131 bias: {132 bend: 6,133 "s-curve": 5,134 wave: 4,135 bump: 2.5,136 corner: 0.7,137 straight: 0.7,138 "curve-large": 0.4,139 },140 solidGround: 0.45,141 decorDensity: 0.5,142 wideAffinity: 0.15,143 },144 {145 name: "boulevard",146 weight: 2.5,147 bias: {148 "curve-large": 3,149 curve: 1.5,150 straight: 1.4,151 wave: 0.5,152 corner: 0.3,153 tunnel: 0.3,154 },155 solidGround: 0.6,156 decorDensity: 0.65,157 wideAffinity: 0.9,158 },159];160161export function pickStyle(rng: Rng): StyleField {162 const spec = rng.weighted(THEMES.map((t) => [t, t.weight] as const));163 const paletteIndex = rng.int(0, 3) as 0 | 1 | 2;164 const supportKind = rng.weighted(165 [["single", 5], ["two", 3], ["four", 1.5]] as const,166 ) as "single" | "two" | "four";167 const supportColor = rng.chance(0.3);168 const solidGround = rng.chance(spec.solidGround);169170 // Per-track zone offset makes the same theme vary between seeds: weights171 // gently oscillate around the ring in 2–4 zones.172 const zones = rng.int(2, 5);173 const zonePhase = rng.float() * Math.PI * 2;174 const zoneAmp = 0.25 + rng.float() * 0.35;175176 // M24 temperament — APPENDED draw (finite streams byte-identical; the177 // endless planner and biome draw read it).178 const temperament = rng.weighted(179 TEMPERAMENTS.map((t) => [t, t.weight] as const),180 );181182 return {183 theme: spec.name,184 paletteIndex,185 temperament,186 supportKind,187 supportColor,188 decorDensity: spec.decorDensity,189 wideAffinity: spec.wideAffinity,190 solidAt(baseY: number): boolean {191 // Elevated track reads better open (supports visible); ground track192 // follows the theme's solidness.193 return baseY <= 0 ? solidGround : false;194 },195 weightFor(def: PieceDef, ringIndex: number): number {196 const bias = spec.bias[def.id] ?? spec.bias[def.family] ?? 1;197 const zone = 1 + zoneAmp * Math.sin(zonePhase + ringIndex * zones * 0.05);198 return bias * zone;199 },200 };201}