@idle-screens/saver-metaquarium 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -232,6 +232,24 @@ textures, downloads, shadow maps or additional lights. Flora, shards, snow and
232
232
  bubbles scale with the existing device prop budget. `inspect().props.scenery`
233
233
  reports actual populations; prop draw-call/triangle totals include the world.
234
234
 
235
+ ## Reading a scene back
236
+
237
+ A SaverSpec explains itself; a metaquarium scene is `{id: "metaquarium"}` plus a control track. `describeMetaquarium` reads that back as parts, using the tank's own parsers and room presets, for a viewer's scene card, a channel's state, or an agent with no eyes:
238
+
239
+ ```ts
240
+ import { describeMetaquarium, metaquariumParamsFromTrack } from '@idle-screens/saver-metaquarium/manifest';
241
+
242
+ const { params, timed } = metaquariumParamsFromTrack(scene.track.deltas); // in force at mount, plus later choreography
243
+ const a = describeMetaquarium(params, timed);
244
+ a.summary; // '3 octopuses, a starfish, a crab and 4 baby fish in the reef; slow orbit.'
245
+ a.cast; // [{ slots: [0, 2], count: 3, breed: 'octopus', label: 'octopuses', motion: 'floor', … }, …]
246
+ a.sections; // [{ name: 'Room', items: ['Reef', 'dunes floor', 'water overhead'] }, { name: 'Camera', … }, …]
247
+ a.palette; // the author's colours, else the room's own
248
+ a.problems; // params set but doing nothing (validateMetaquariumParams)
249
+ ```
250
+
251
+ It reports only what the params say, and only what the author set (plus the room and camera, which every scene has).
252
+
235
253
  ## For agents (MCP)
236
254
 
237
255
  Everything an agent needs to author a scene is data, importable without three.js:
@@ -242,6 +260,7 @@ import { RECIPES, recipeTrack, PARAM_DOCS, GRAMMAR, validateMetaquariumParams }
242
260
  const scene = RECIPES.find((r) => r.id === 'stage-duet')!;
243
261
  publishScene({ spec: { id: 'metaquarium' }, track: recipeTrack(scene.params) });
244
262
  validateMetaquariumParams({ vignette: 'a >home1', geodeHomes: 0 }); // → [{ path: 'vignette', message: 'beat 1: no mark "home1" …' }]
263
+ validateMetaquariumParams({ fishMix: 'dori:3,blowfish:2', fishCount: 14 }); // → [{ path: 'fishCount', also: ['fishMix'], message: 'ignored: a non-empty fishMix sets the cast (5 fish) …' }]
245
264
  ```
246
265
 
247
- `PARAM_DOCS` has one line per param (a test keeps it complete), `GRAMMAR` documents the five small DSLs and the marks a vignette can name, and the playground mounts every recipe unchanged on its "recipes" shelf.
266
+ `PARAM_DOCS` has one line per param (a test keeps it complete), `GRAMMAR` documents the five small DSLs and the marks a vignette can name, and the playground mounts every recipe unchanged on its "recipes" shelf. `validateMetaquariumParams` also names the params that would do nothing: one another param overrides (`fishCount` beside a `fishMix`), a slot past the end of the cast, or a dial on something switched off (`shoalKind` with no shoal); a problem that involves other params lists them in `also`.
@@ -1898,7 +1898,7 @@ function parseFloraMix(src) {
1898
1898
  for (const raw of src.split(",")) {
1899
1899
  const part = raw.trim();
1900
1900
  if (!part) continue;
1901
- const m = /^([a-z]+)\s*(?::\s*([0-9]+(?:\.[0-9]+)?|\.[0-9]+))?$/i.exec(part);
1901
+ const m = /^([a-z]+)\s*(?::\s*([0-9]*\.?[0-9]+))?$/i.exec(part);
1902
1902
  const name = m?.[1]?.toLowerCase();
1903
1903
  if (!m || !name || !FLORA_SPECIES.includes(name)) {
1904
1904
  problems.push(`"${part}": expected species[:weight] with species one of ${FLORA_SPECIES.join(", ")}`);
@@ -1946,7 +1946,7 @@ function parseGeodeMix(src) {
1946
1946
  for (const raw of src.split(",")) {
1947
1947
  const part = raw.trim();
1948
1948
  if (!part) continue;
1949
- const m = /^([a-z]+)\s*(?::\s*([0-9]+(?:\.[0-9]+)?|\.[0-9]+))?$/i.exec(part);
1949
+ const m = /^([a-z]+)\s*(?::\s*([0-9]*\.?[0-9]+))?$/i.exec(part);
1950
1950
  const name = m?.[1]?.toLowerCase();
1951
1951
  if (!m || !name || !GEODE_KINDS.includes(name)) {
1952
1952
  problems.push(`"${part}": expected kind[:weight] with kind one of ${GEODE_KINDS.join(", ")}`);
@@ -2390,6 +2390,7 @@ var PARAM_DOCS = {
2390
2390
  danceTempo: "Beats per minute for the starfish dance (60\u2013180, default 128). Glides on the beat.",
2391
2391
  fishLook: "`neon` dresses the bundled creatures (crab, glowfish, shark\u2026) for blacklight: near-black coats, eyes and a crab's mouth glowing a neon of their own. Best in a dark room (abyss, void) with `finish`. `natural` is the default.",
2392
2392
  swimWave: "Fish bend as they swim: a wave runs nose to tail and the body curls into turns (0 = rigid wiggle). Skips the turtle, crab and fish that already have a skeleton; the seahorse gets its own motion instead \u2014 fin, tail and nod.",
2393
+ fishAvoid: "Fish make way for each other instead of swimming through one another: a pair about to meet parts a second ahead, the smaller fish giving more, over or under before round. 1 (default) is on, 0 is the old pass-through. `inspect().crowding` counts the pairs still touching.",
2393
2394
  eyeLife: "Eyes blink, look, and emote (0 = painted on). Redraws each token's own pixel-grid eye; black and white only.",
2394
2395
  // water and room
2395
2396
  fogColor: "The water colour; also the background. Dark blues and purples make light sources read.",
@@ -2709,6 +2710,30 @@ var RECIPES = [
2709
2710
  cameraAzimuth: 0,
2710
2711
  autoRotate: 1
2711
2712
  }
2713
+ },
2714
+ {
2715
+ id: "reef-characters",
2716
+ label: "Reef characters",
2717
+ what: "The bundled characters on a coral reef: tangs that look back at you, blowfish that puff up and flirt, an octopus that changes colour and inks, a crab on the floor. A slow orbit.",
2718
+ params: {
2719
+ ...ALIVE,
2720
+ environment: "reef",
2721
+ fishMix: "dori:3,blowfish:2,octopus:1,crab:1",
2722
+ floraDensity: 0.6,
2723
+ marineSnow: 0.3,
2724
+ finish: 0.5,
2725
+ swimSpeed: 0.7,
2726
+ cameraDistance: 160,
2727
+ cameraElevation: 13,
2728
+ cameraAzimuth: 20,
2729
+ autoRotate: 0.6
2730
+ }
2731
+ },
2732
+ {
2733
+ id: "starfish-class",
2734
+ label: "Starfish aerobics",
2735
+ what: "Seven starfish stand up on a pale ice floor, face the camera and dance an aerobics routine in unison, an instructor in front.",
2736
+ params: { environment: "ice", fishMix: "starfish:7", starfishDance: "aerobics", danceTempo: 120, shot: "front", autoRotate: 0 }
2712
2737
  }
2713
2738
  ];
2714
2739
  var recipe = (id) => RECIPES.find((r) => r.id === id);
@@ -2748,8 +2773,238 @@ function validateMetaquariumParams(params) {
2748
2773
  if (params.fountain === "vent" || params.fountain === "geode") world.fountain = at;
2749
2774
  push("vignette", parseVignette(resolveVignette(str("vignette")), indoors ? INTERIOR_MARKS : { ...OPEN_MARKS, ...world }).problems);
2750
2775
  }
2776
+ out.push(...dependencyProblems(params));
2751
2777
  return out;
2752
2778
  }
2779
+ var CAST_CAP = 24;
2780
+ var ORBIT_PARAMS = ["cameraElevation", "cameraDistance"];
2781
+ function dependencyProblems(params) {
2782
+ const out = [];
2783
+ const set = (k) => params[k] !== void 0 && params[k] !== null;
2784
+ const num = (k, fallback) => {
2785
+ const v = Number(params[k]);
2786
+ return set(k) && Number.isFinite(v) ? v : fallback;
2787
+ };
2788
+ const str = (k, fallback = "") => typeof params[k] === "string" ? params[k].trim() : fallback;
2789
+ const mix = str("fishMix") ? parseFishMix(str("fishMix")).entries : [];
2790
+ const mixTotal = mix.reduce((n, e) => n + e.count, 0);
2791
+ const cast = mix.length ? Math.min(mixTotal, CAST_CAP) : Math.min(Math.max(1, Math.round(num("fishCount", 1))), CAST_CAP);
2792
+ const castWhy = mix.length ? `fishMix casts ${cast}` : `the cast is fishCount ${cast}`;
2793
+ const castParams = mix.length ? ["fishMix"] : ["fishCount"];
2794
+ if (mix.length) {
2795
+ for (const k of ["fishCount", "fishUrl"]) {
2796
+ if (set(k)) out.push({ path: k, also: ["fishMix"], message: `ignored: a non-empty fishMix sets the cast (${mixTotal} fish) \u2014 put the counts in fishMix` });
2797
+ }
2798
+ if (mixTotal > CAST_CAP) out.push({ path: "fishMix", message: `casts ${mixTotal} fish, but at most ${CAST_CAP} swim (fewer on smaller devices): slots ${CAST_CAP}+ never appear` });
2799
+ }
2800
+ const noFish = (slot) => `slot ${slot} has no fish \u2014 ${castWhy} (slots 0\u2013${cast - 1})`;
2801
+ const follow = Math.round(num("cameraFollow", -1));
2802
+ if (follow >= cast) out.push({ path: "cameraFollow", also: castParams, message: noFish(follow) });
2803
+ const rig = parseSpotRig(str("spotRig")).spots;
2804
+ const spot2 = Math.round(num("followSpot", -1));
2805
+ if (rig.length && set("followSpot")) out.push({ path: "followSpot", also: ["spotRig"], message: "ignored: a spotRig replaces the single followSpot" });
2806
+ else if (spot2 >= cast) out.push({ path: "followSpot", also: castParams, message: noFish(spot2) });
2807
+ for (const s of rig) if (s.slot >= cast) out.push({ path: "spotRig", also: castParams, message: noFish(s.slot) });
2808
+ if (str("vignette")) {
2809
+ const { actors } = parseVignette(resolveVignette(str("vignette")), { ...OPEN_MARKS, ...INTERIOR_MARKS });
2810
+ if (actors > cast) out.push({ path: "vignette", also: castParams, message: `needs ${actors} fish (actors a\u2013${"abc"[actors - 1]} are slots 0\u2013${actors - 1}), but ${castWhy}` });
2811
+ }
2812
+ const dance = str("starfishDance", "off") || "off";
2813
+ const starfishSwims = mix.some((e, i) => e.breed === "starfish" && mix.slice(0, i).reduce((n, p) => n + p.count, 0) < CAST_CAP);
2814
+ if (dance !== "off" && !starfishSwims) {
2815
+ out.push({ path: "starfishDance", also: ["fishMix"], message: "no starfish in the cast to dance \u2014 add e.g. starfish:7 (slots past the cap never swim)" });
2816
+ }
2817
+ if (set("danceTempo") && dance === "off") out.push({ path: "danceTempo", also: ["starfishDance"], message: "does nothing while starfishDance is off" });
2818
+ if (follow < 0) {
2819
+ for (const k of ["followDistance", "followAngle"]) if (set(k)) out.push({ path: k, also: ["cameraFollow"], message: "does nothing while cameraFollow is -1 (the orbit camera)" });
2820
+ } else if (follow < cast) {
2821
+ for (const k of ORBIT_PARAMS) if (set(k)) out.push({ path: k, also: ["cameraFollow"], message: `ignored while cameraFollow rides with slot ${follow} \u2014 they return when it is -1` });
2822
+ }
2823
+ const spotOn = rig.length > 0 || spot2 >= 0;
2824
+ if (!spotOn) {
2825
+ for (const k of ["spotStrength", "spotShadow", "spotColor"]) if (set(k)) out.push({ path: k, also: ["followSpot", "spotRig"], message: "does nothing with no spot on (followSpot is -1 and spotRig is empty)" });
2826
+ } else if (rig.length && set("spotColor")) {
2827
+ out.push({ path: "spotColor", also: ["spotRig"], message: "colours the single followSpot; a spotRig gives each of its spots its own colour (slot/#rrggbb)" });
2828
+ }
2829
+ if (Math.round(num("shoal", 0) * CAST_CAP * 2.5) < 3) {
2830
+ for (const k of ["shoalKind", "shoalSpeed"]) if (set(k)) out.push({ path: k, also: ["shoal"], message: "does nothing while shoal is too low to make a school (under 3 fish; 0 is off)" });
2831
+ }
2832
+ if (num("floraDensity", 0) <= 0) {
2833
+ for (const k of ["floraMix", "floraPalette", "floraLayout"]) if (set(k)) out.push({ path: k, also: ["floraDensity"], message: "does nothing while floraDensity is 0 (no plants)" });
2834
+ }
2835
+ return out;
2836
+ }
2837
+
2838
+ // src/anatomy.ts
2839
+ var CAST_CAP2 = 24;
2840
+ var FLOOR_BREEDS = /* @__PURE__ */ new Set(["crab", "starfish", "octopus"]);
2841
+ var BREED_NOUN = {
2842
+ dori: ["tang", "tangs"],
2843
+ babyfish: ["baby fish", "baby fish"],
2844
+ blowfish: ["blowfish", "blowfish"],
2845
+ octopus: ["octopus", "octopuses"],
2846
+ starfish: ["starfish", "starfish"],
2847
+ crab: ["crab", "crabs"],
2848
+ shark: ["shark", "sharks"],
2849
+ glowfish: ["glowfish", "glowfish"],
2850
+ hackerfish: ["hackerfish", "hackerfish"],
2851
+ betafish: ["betafish", "betafish"],
2852
+ angelfish: ["angelfish", "angelfish"],
2853
+ seahorse: ["seahorse", "seahorses"],
2854
+ seaturtle: ["sea turtle", "sea turtles"]
2855
+ };
2856
+ var noun = (breed, n) => (BREED_NOUN[breed] ?? [breed, `${breed}s`])[n === 1 ? 0 : 1];
2857
+ function metaquariumParamsFromTrack(deltas) {
2858
+ const params = {};
2859
+ const timed = [];
2860
+ for (const d of deltas ?? []) {
2861
+ if (!d || typeof d.path !== "string") continue;
2862
+ const t = typeof d.t === "number" && Number.isFinite(d.t) ? d.t : 0;
2863
+ if (t <= 0 || typeof d.liveAt === "number") params[d.path] = d.value;
2864
+ else timed.push({ atMs: t, path: d.path, value: d.value });
2865
+ }
2866
+ timed.sort((a, b) => a.atMs - b.atMs);
2867
+ return { params, timed };
2868
+ }
2869
+ var defaultOf = (k) => METAQUARIUM_PARAMS[k]?.default;
2870
+ function describeMetaquarium(params, timed = []) {
2871
+ const has = (k) => params[k] !== void 0 && params[k] !== null && params[k] !== "";
2872
+ const val = (k) => has(k) ? params[k] : defaultOf(k);
2873
+ const num = (k) => {
2874
+ const n = Number(val(k));
2875
+ return Number.isFinite(n) ? n : 0;
2876
+ };
2877
+ const str = (k) => String(val(k) ?? "").trim();
2878
+ const pct = (k) => `${Math.round(num(k) * 100)}%`;
2879
+ const cast = [];
2880
+ const swimStyle = str("swimStyle");
2881
+ const motionOf = (breed, style) => FLOOR_BREEDS.has(breed) ? "floor" : style ?? (has("swimStyle") ? swimStyle : "swims");
2882
+ const mix = has("fishMix") ? parseFishMix(str("fishMix")).entries : [];
2883
+ if (mix.length) {
2884
+ const slots = expandFishMixSlots(mix, Number.MAX_SAFE_INTEGER);
2885
+ let at = 0;
2886
+ for (const e of mix) {
2887
+ const minted = isMintedId(e.id);
2888
+ cast.push({
2889
+ slots: [at, at + e.count - 1],
2890
+ count: e.count,
2891
+ visible: Math.max(0, Math.min(e.count, CAST_CAP2 - at)),
2892
+ breed: e.breed,
2893
+ label: minted ? e.count > 1 ? `${e.breed} from #${e.id}` : `${e.breed} #${e.id}` : noun(e.breed, e.count),
2894
+ motion: motionOf(e.breed, e.style),
2895
+ size: slots[at]?.size ?? null,
2896
+ offstage: at >= CAST_CAP2
2897
+ });
2898
+ at += e.count;
2899
+ }
2900
+ } else {
2901
+ const n = Math.min(Math.max(1, Math.round(num("fishCount"))), CAST_CAP2);
2902
+ const id = Number(/fish_(\d+)_/.exec(str("fishUrl"))?.[1] ?? NaN);
2903
+ const breed = Number.isFinite(id) && breedOf(id) || "fish";
2904
+ cast.push({ slots: [0, n - 1], count: n, visible: n, breed, label: Number.isFinite(id) ? `${breed} #${id}` : breed, motion: motionOf(breed), size: null, offstage: false });
2905
+ }
2906
+ const castTotal = Math.min(cast.reduce((n, r) => n + r.count, 0), CAST_CAP2);
2907
+ const slotName = (slot) => {
2908
+ const row = cast.find((r) => slot >= r.slots[0] && slot <= r.slots[1]);
2909
+ return row ? `slot ${slot} (${row.breed === "fish" ? "fish" : noun(row.breed, 1)})` : `slot ${slot} (empty)`;
2910
+ };
2911
+ const env = environmentOf(str("environment"));
2912
+ const room = [env.label];
2913
+ if (has("floorKind") && str("floorKind") !== "auto") room.push(`${str("floorKind")} floor`);
2914
+ else room.push(`${env.floor} floor`);
2915
+ if (env.water) room.push("water overhead");
2916
+ if (num("water") > 0) room.push(`water ${pct("water")}${has("waterClarity") ? `, clarity ${pct("waterClarity")}` : ""}`);
2917
+ if (num("caustics") > 0) room.push(`caustics ${pct("caustics")}`);
2918
+ if (num("surfaceMirror") > 0) room.push(`surface mirror ${pct("surfaceMirror")}`);
2919
+ const rays = has("rayStrength") && num("rayStrength") >= 0 ? num("rayStrength") : env.rays?.strength ?? null;
2920
+ if (rays !== null) room.push(rays === 0 ? "no light shafts" : `light shafts ${Math.round(rays * 100)}%`);
2921
+ if (has("fogNear") || has("fogFar")) room.push(`fog ${num("fogNear")}\u2013${num("fogFar")}`);
2922
+ if (str("interior") === "geode") room.push("inside a geode home");
2923
+ const world = [];
2924
+ if (str("landmark") !== "none") world.push(`the ${str("landmark")}`);
2925
+ if (num("geodeHomes") > 0) world.push(`${num("geodeHomes")} geode home${num("geodeHomes") === 1 ? "" : "s"}`);
2926
+ if (num("paths") > 0) world.push(`paths${has("pathMaterial") && str("pathMaterial") !== "auto" ? ` (${str("pathMaterial")})` : ""}`);
2927
+ if (str("fountain") !== "none") world.push(`${str("fountain")} fountain`);
2928
+ if (num("streetLamps") > 0) world.push("street lamps");
2929
+ if (num("floraDensity") > 0) world.push(`plants ${pct("floraDensity")}${has("floraMix") ? `: ${str("floraMix")}` : ""}`);
2930
+ if (num("rockDensity") > 0) world.push(`rocks ${pct("rockDensity")}`);
2931
+ if (num("geodes") > 0) world.push(`wild geodes ${pct("geodes")}`);
2932
+ if (has("propMix")) {
2933
+ const props = parsePropMix(str("propMix")).entries;
2934
+ const n = props.reduce((k, e) => k + e.count, 0);
2935
+ if (n) world.push(`${n} crystal${n === 1 ? "" : "s"}: ${props.map((e) => `${e.count > 1 ? `${e.count} ` : ""}${e.palette === "env" ? "" : `${e.palette} `}${e.habit}${e.size > 1 ? ` \xD7${e.size}` : ""}`).join(", ")}`);
2936
+ }
2937
+ if (num("skyLanterns") > 0) world.push("jellyfish lanterns overhead");
2938
+ if (num("horizon") > 0) world.push("a far horizon");
2939
+ if (num("bubbleVents") > 0) world.push("bubble vents");
2940
+ if (num("marineSnow") > 0) world.push("marine snow");
2941
+ if (num("co2Mist") > 0) world.push("CO\u2082 mist");
2942
+ if (num("pearling") > 0) world.push("pearling plants");
2943
+ if (Math.round(num("shoal") * CAST_CAP2 * 2.5) >= 3) world.push(`a school of ${str("shoalKind")}`);
2944
+ const stage = [];
2945
+ const rig = parseSpotRig(str("spotRig")).spots;
2946
+ if (rig.length) stage.push(`${rig.length} spotlight${rig.length === 1 ? "" : "s"} on ${rig.map((s) => slotName(s.slot)).join(", ")}`);
2947
+ else if (num("followSpot") >= 0) stage.push(`follow-spot on ${slotName(num("followSpot"))}`);
2948
+ if (has("spotCues") && (rig.length || num("followSpot") >= 0)) {
2949
+ const sheet = parseSpotCues(str("spotCues"), Math.max(1, rig.length));
2950
+ stage.push(`${sheet.cues.length} light cues, ${Math.round(sheet.duration)}s loop`);
2951
+ }
2952
+ if (has("vignette")) {
2953
+ const v = str("vignette");
2954
+ stage.push(VIGNETTES[v] ? `vignette: ${v}` : `vignette: ${v.split(/[|\n]/).filter((b) => b.trim()).length} beats`);
2955
+ }
2956
+ if (str("starfishDance") !== "off") stage.push(`starfish ${str("starfishDance")}${has("danceTempo") ? ` at ${num("danceTempo")} bpm` : ""}`);
2957
+ const camera = [];
2958
+ const follow = Math.round(num("cameraFollow"));
2959
+ if (follow >= 0) {
2960
+ camera.push(`rides with ${slotName(follow)}`);
2961
+ if (has("followDistance")) camera.push(`${num("followDistance")} behind`);
2962
+ if (has("followAngle") && num("followAngle") !== 0) camera.push(`${num("followAngle")}\xB0 round`);
2963
+ } else {
2964
+ if (str("shot") !== "orbit") camera.push(`${str("shot")} shot`);
2965
+ camera.push(`distance ${num("cameraDistance")}`, `elevation ${num("cameraElevation")}\xB0`);
2966
+ const spin = num("autoRotate");
2967
+ camera.push(spin > 0 ? `orbits ${spin}\xB0/s` : "holds still");
2968
+ }
2969
+ const motion = [];
2970
+ if (has("swimSpeed")) motion.push(`speed \xD7${num("swimSpeed")}`);
2971
+ if (has("pathShape")) motion.push(`${str("pathShape")} paths`);
2972
+ if (has("formationShape")) motion.push(`${str("formationShape")} formation`);
2973
+ if (str("maneuver") !== "none") motion.push(`${str("maneuver")} maneuvers`);
2974
+ if (num("bodyWiggle") > 0) motion.push(`wiggle ${pct("bodyWiggle")}`);
2975
+ if (has("fishSize") && num("fishSize") !== 1) motion.push(`fish \xD7${num("fishSize")} size`);
2976
+ const look = [];
2977
+ if (str("fishLighting") === "flat") look.push("flat-lit fish");
2978
+ if (str("fishLook") === "neon") look.push("neon (blacklight) coats");
2979
+ if (num("eyeLife") > 0) look.push("living eyes");
2980
+ if (num("finish") > 0) look.push(`finish ${pct("finish")}`);
2981
+ if (has("fishGlow")) look.push(`glow ${pct("fishGlow")}`);
2982
+ if (num("fishAmbient") > 0) look.push(`fish ambient ${pct("fishAmbient")}`);
2983
+ if (str("dither") === "on") look.push("dither");
2984
+ const sections = [["Room", room], ["World", world], ["Stage", stage], ["Camera", camera], ["Motion", motion], ["Look", look]].filter(([, items]) => items.length > 0).map(([name, items]) => ({ name, items: [...items] }));
2985
+ const palette = [];
2986
+ const add = (c) => {
2987
+ if (typeof c === "string" && /^#[0-9a-f]{3}(?:[0-9a-f]{3})?$/i.test(c) && !palette.includes(c.toLowerCase())) palette.push(c.toLowerCase());
2988
+ };
2989
+ const roomOr = (k, room2) => add(has(k) ? params[k] : room2 ?? defaultOf(k));
2990
+ roomOr("fogColor", env.palette?.fog);
2991
+ roomOr("floorColor", env.palette?.floor);
2992
+ if (num("moteDensity") > 0 || num("marineSnow") > 0 || env.palette) roomOr("moteColor", env.palette?.mote);
2993
+ if (num("water") > 0) roomOr("waterTint", env.palette?.tint);
2994
+ for (const s of rig) add(s.color);
2995
+ if (!rig.length && num("followSpot") >= 0) add(str("spotColor"));
2996
+ if (has("floraPalette")) for (const c of str("floraPalette").split(",")) add(c.trim());
2997
+ const swimming = cast.filter((r) => r.visible > 0);
2998
+ const nameOf = (r) => r.visible < r.count && !r.label.includes("#") ? noun(r.breed, r.visible) : r.label;
2999
+ const names = swimming.map((r) => r.visible === 1 ? `${/^[aeiou]/.test(nameOf(r)) ? "an" : "a"} ${nameOf(r)}` : `${r.visible} ${nameOf(r)}`);
3000
+ const list = names.length > 1 ? `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}` : names[0] ?? "no fish";
3001
+ const where = str("interior") === "geode" ? "inside a geode home" : `in the ${env.label.toLowerCase()}`;
3002
+ const act = stage.find((x) => x.startsWith("starfish ")) ?? stage.find((x) => x.startsWith("vignette")) ?? stage[0];
3003
+ const extra = act ?? (str("landmark") !== "none" ? `around the ${str("landmark")}` : num("geodeHomes") > 0 ? "in a village" : null);
3004
+ const cam = follow >= 0 ? `following ${slotName(follow)}` : num("autoRotate") > 0 ? "slow orbit" : "still camera";
3005
+ const summary = `${list} ${where}${extra ? `, ${extra}` : ""}; ${cam}.`;
3006
+ return { summary, cast, castTotal, sections, palette, timed, problems: validateMetaquariumParams(params) };
3007
+ }
2753
3008
 
2754
3009
  // src/manifest.ts
2755
3010
  var METAQUARIUM_PARAMS = {
@@ -3003,6 +3258,11 @@ var METAQUARIUM_PARAMS = {
3003
3258
  /** A body wave from nose to tail, beating with distance swum and curling
3004
3259
  * into turns. 0 keeps the legacy rigid wiggle and the stock programs. */
3005
3260
  swimWave: { type: "number", default: 0, min: 0, max: 1, ease: "smooth" },
3261
+ /** Elbow room, 0..1: fish see each other coming and make way — the smaller
3262
+ * gives more, over or under before round. Pure in t, like every motion here.
3263
+ * On by default, the one exception to "defaults keep the old look":
3264
+ * fish swimming through fish was a bug, not a look. 0 is the old tank. */
3265
+ fishAvoid: { type: "number", default: 1, min: 0, max: 1, ease: "smooth" },
3006
3266
  /** Independent mineral-world layers. Zero preserves legacy scenes; counts
3007
3267
  * are reduced by the device's existing prop budget. All motion is analytic. */
3008
3268
  rockDensity: { type: "number", default: 0, min: 0, max: 1, ease: "step" },
@@ -3180,7 +3440,7 @@ var BLOOM_COLORS = [
3180
3440
  var metaquariumManifest = {
3181
3441
  id: "metaquarium",
3182
3442
  label: "Metaquarium",
3183
- description: "A living fish tank: skinned GLB fish swim seeded Catmull-Rom paths through a dark, fogged aquarium.",
3443
+ description: "A voxel fish tank you configure by params: a cast of fish and characters (tangs, blowfish, an octopus, crabs, starfish, minted fish) in rooms, gardens, villages and castles, with stage spotlights and small scripted scenes.",
3184
3444
  minBackend: "webgl2",
3185
3445
  costTier: "medium",
3186
3446
  motionIntensity: "calm",
@@ -3325,6 +3585,8 @@ export {
3325
3585
  recipe,
3326
3586
  recipeTrack,
3327
3587
  validateMetaquariumParams,
3588
+ metaquariumParamsFromTrack,
3589
+ describeMetaquarium,
3328
3590
  METAQUARIUM_PARAMS,
3329
3591
  MIAMI_VICE_COLORS,
3330
3592
  BLOOM_COLORS,
@@ -3333,4 +3595,4 @@ export {
3333
3595
  paramSpaceWith,
3334
3596
  coerceNum
3335
3597
  };
3336
- //# sourceMappingURL=chunk-XLPOK4TE.js.map
3598
+ //# sourceMappingURL=chunk-XPNWX4OU.js.map