0xffd6563c…95fesent to0xbcd804be…650f·#25,465,653·view on Etherscan
/* species-params.js */
/* ============================================================
Seedling — per-species engine parameters (SEEDLING_ART_SPEC §3.1/§3.3)
Single source of truth for the growth/lifespan tuning constants.
Consumed by the simulation harness now, and by the species prototypes
as they are refined. Numbers are v1 starting points — the simulation
pass exists to retune them before lockLifespanParams().
Fields:
L expected lifespan, MONTHS (12–18, compressed band → ~1–1.5 yr base;
care f/c then pushes actual death to ~70–105% of L)
N structure saturation — entries for ~63% density (low = fast/bushy)
Hhalf vitality HALF-LIFE in days (high = hardy/slow-wilt)
repro maturity θ to first flower/fruit (juveniles don't reproduce)
mono monocarpic — flowering ends life (cause = MonocarpicBloom)
fatTail optional {p,lo,hi} — rare early death (bamboo gregarious bloom)
dormant {peak, display, hemi} — §6.1 warped dormancy (peak = yr-fraction,
display = fraction of year shown dormant; hemi flips calendar)
poll pollinator type library (§6.4); grasses get charm + motes
============================================================ */
const SPECIES = [
{ key:'sunflower', name:'Sunflower', L:12, N:10, Hhalf:14, repro:0.55, mono:true, dormant:null, poll:['bee'] },
{ key:'poppy', name:'Field Poppy', L:13, N:10, Hhalf:8, repro:0.45, mono:true, dormant:null, poll:['bee'] },
{ key:'aloe', name:'Aloe Vera', L:13, N:13, Hhalf:40, repro:0.60, dormant:{peak:0.55,display:0.10,hemi:'N'}, poll:['sunbird'] },
{ key:'lavender', name:'Lavender', L:13, N:12, Hhalf:35, repro:0.40, dormant:{peak:0.04,display:0.15,hemi:'N'}, poll:['bee','butterfly'] },
{ key:'bamboo', name:'Bamboo', L:15, N:11, Hhalf:35, repro:0.50, mono:true, dormant:{peak:0.04,display:0.08,hemi:'N'}, poll:['bird','mote'] },
{ key:'wattle', name:'Golden Wattle', L:14, N:12, Hhalf:32, repro:0.50, dormant:{peak:0.55,display:0.12,hemi:'S'}, poll:['bee'] },
{ key:'flytrap', name:'Venus Flytrap', L:14, N:10, Hhalf:9, repro:0.50, dormant:{peak:0.04,display:0.20,hemi:'N'}, poll:['bee'] },
{ key:'bird', name:'Bird of Paradise', L:15, N:9, Hhalf:16, repro:0.65, dormant:{peak:0.04,display:0.10,hemi:'S'}, poll:['sunbird'] },
{ key:'guava', name:'Chilean Guava', L:14, N:12, Hhalf:16, repro:0.50, dormant:{peak:0.04,display:0.12,hemi:'S'}, poll:['bee','butterfly'] },
{ key:'maple', name:'Japanese Maple', L:17, N:12, Hhalf:16, repro:0.60, deciduous:true, dormant:{peak:0.04,display:0.22,hemi:'N'}, poll:['bee'] },
{ key:'lotus', name:'Sacred Lotus', L:17, N:11, Hhalf:9, repro:0.55, deciduous:true, dormant:{peak:0.04,display:0.18,hemi:'N'}, poll:['bee','butterfly'] },
{ key:'protea', name:'King Protea', L:16, N:12, Hhalf:30, repro:0.60, dormant:{peak:0.55,display:0.10,hemi:'S'}, poll:['sunbird','butterfly'] },
{ key:'amla', name:'Amla', L:16, N:12, Hhalf:16, repro:0.60, deciduous:true, dormant:{peak:0.55,display:0.15,hemi:'N'}, poll:['bee'] },
{ key:'saguaro', name:'Saguaro', L:17, N:20, Hhalf:45, repro:0.75, dormant:{peak:0.55,display:0.06,hemi:'N'}, poll:['bee','bird'] },
{ key:'orchid', name:'Cattleya Orchid', L:17, N:9, Hhalf:9, repro:0.60, dormant:{peak:0.04,display:0.15,hemi:'S'}, poll:['butterfly'] },
{ key:'hairgrass', name:'Antarctic Hair Grass',L:17, N:10, Hhalf:45, repro:0.50, dormant:{peak:0.04,display:0.18,hemi:'S'}, poll:['bird','mote'] },
];
const MONTH_DAYS = 365 / 12;
const W_FULL_FRAC = 0.6; // watered weeks (as a fraction of L in weeks) to reach the c lifespan cap
// produce a core-shaped `spec` (LIFESPAN in days; H = exp time-constant from the half-life)
function toSpec(p){
return {
title: p.name, key: p.key,
LIFESPAN: Math.round(p.L * MONTH_DAYS), // days
N_DENS: p.N,
H: p.Hhalf / Math.LN2, // half-life → exp(-Δt/H) time-constant
W_FULL_FRAC,
REPRO_THETA: p.repro,
MONOCARPIC: !!p.mono,
fatTail: p.fatTail || null,
DORMANT: p.dormant || null,
POLLINATOR: p.poll,
senFrom: 0.80,
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = { SPECIES, toSpec, W_FULL_FRAC, MONTH_DAYS };
if (typeof window !== 'undefined') { window.SEEDLING_SPECIES = SPECIES; window.SEEDLING_toSpec = toSpec; }
;
/* seedling-core.js */
/* ============================================================
Seedling — shared engine core (v2)
Reusable machinery for every species. A species file only
defines its botany via mount({ ... draw(st, C, env) ... }).
Invariant here: glyph vocab, Symbol Scheme, occupancy grid +
z-order, fine grid, frame + 9 backdrops, growth = entries+age,
vitality/season, the panel/controller harness.
============================================================ */
// ---------- PRNG ----------
function hashStr(s){ let h=2166136261>>>0;
for(let i=0;i<s.length;i++){ h^=s.charCodeAt(i); h=Math.imul(h,16777619);} return h>>>0; }
function mulberry32(a){ return function(){ a|=0; a=a+0x6D2B79F5|0;
let t=Math.imul(a^a>>>15,1|a); t=t+Math.imul(t^t>>>7,61|t)^t;
return ((t^t>>>14)>>>0)/4294967296; };}
function rng(...p){ return mulberry32(hashStr(p.join('|'))); }
// ---------- math / color ----------
const clamp=(x,a,b)=>Math.max(a,Math.min(b,x));
const lerp=(a,b,t)=>a+(b-a)*t;
function smooth(e0,e1,x){const t=clamp((x-e0)/(e1-e0),0,1); return t*t*(3-2*t);}
function hex2rgb(h){h=h.replace('#','');return[parseInt(h.slice(0,2),16),parseInt(h.slice(2,4),16),parseInt(h.slice(4,6),16)];}
function rgb2hex(r){return'#'+r.map(v=>clamp(Math.round(v),0,255).toString(16).padStart(2,'0')).join('');}
function mix(c1,c2,t){const a=hex2rgb(c1),b=hex2rgb(c2);return rgb2hex([lerp(a[0],b[0],t),lerp(a[1],b[1],t),lerp(a[2],b[2],t)]);}
// HSL-space saturation boost — multiplies S by (1+f), keeps hue & lightness. Applied to every plant
// glyph in resolve() so the whole botany reads a touch more alive (the marks felt muted). Pollinators
// + soil bypass it on purpose (they stay desaturated/earthy).
const SAT_BOOST=0.10; // +10% per user; dial here if needed
function saturate(hex, f){ if(!f) return hex;
const c=hex2rgb(hex), r=c[0]/255, g=c[1]/255, b=c[2]/255;
const mx=Math.max(r,g,b), mn=Math.min(r,g,b), l=(mx+mn)/2;
if(mx===mn) return hex; // achromatic grey — no hue to enrich
const d=mx-mn; let s=l>0.5? d/(2-mx-mn) : d/(mx+mn);
let h; if(mx===r) h=((g-b)/d+(g<b?6:0))/6; else if(mx===g) h=((b-r)/d+2)/6; else h=((r-g)/d+4)/6;
s=clamp(s*(1+f),0,1);
const q=l<0.5? l*(1+s) : l+s-l*s, p=2*l-q;
const hue=(t)=>{ if(t<0)t+=1; if(t>1)t-=1; if(t<1/6)return p+(q-p)*6*t; if(t<0.5)return q;
if(t<2/3)return p+(q-p)*(2/3-t)*6; return p; };
return rgb2hex([hue(h+1/3)*255, hue(h)*255, hue(h-1/3)*255]); }
function shade(c,r){ const t=(r()*2-1)*0.13; return t>0?mix(c,'#ffffff',t):mix(c,'#000000',-t); }
const angTo=(a,b)=>Math.atan2(Math.sin(b-a),Math.cos(b-a)); // shortest turn a->b
const gauss=r=>(r()+r()+r()-1.5)/1.5; // bell-ish [-1,1]
const TAU=Math.PI*2, UP=-Math.PI/2, DOWN=Math.PI/2;
// ---------- grid / glyphs ----------
const W=720, H=980, BASEX=360, BASEY=905, GRID=5, GLY=GRID*0.95;
function L_(x1,y1,x2,y2,c,w){return `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="${c}" stroke-width="${w}" stroke-linecap="round"/>`;}
function glyph(sym,x,y,s,c,w){ const h=s*0.5; switch(sym){
case '|': return L_(x,y-h,x,y+h,c,w);
case '-': return L_(x-h,y,x+h,y,c,w);
case '/': return L_(x-h,y+h,x+h,y-h,c,w);
case '\\':return L_(x-h,y-h,x+h,y+h,c,w);
case 'X': return L_(x-h,y-h,x+h,y+h,c,w)+L_(x-h,y+h,x+h,y-h,c,w);
case '+': return L_(x,y-h,x,y+h,c,w)+L_(x-h,y,x+h,y,c,w);
case 'O': return `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${(h*0.74).toFixed(1)}" fill="none" stroke="${c}" stroke-width="${w}"/>`;
case '.': return `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${(w*1.15).toFixed(1)}" fill="${c}"/>`;
case '*': return `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${(h*0.9).toFixed(1)}" fill="${c}"/>`; // solid area-fill (not in any scheme; only via exempt/literal layers — e.g. maple palmate leaves)
case '[': return L_(x-h*0.3,y-h,x-h*0.3,y+h,c,w)+L_(x-h*0.3,y-h,x+h*0.3,y-h,c,w)+L_(x-h*0.3,y+h,x+h*0.3,y+h,c,w);
case ']': return L_(x+h*0.3,y-h,x+h*0.3,y+h,c,w)+L_(x+h*0.3,y-h,x-h*0.3,y-h,c,w)+L_(x+h*0.3,y+h,x-h*0.3,y+h,c,w);
} return ''; }
function dirSym(dx,dy){ const ax=Math.abs(dx),ay=Math.abs(dy);
if(ax<0.34*ay) return '|'; if(ay<0.34*ax) return '-'; return (dx*dy>=0)?'\\':'/'; }
// ---------- Symbol Scheme (Autoglyphs-style; the per-seed source of visual uniqueness) ----------
// Full glyph vocabulary: . O + - | / \ X [ ] (every symbol below has a case in glyph()).
// Mix of multi-, dual- and single-symbol schemes — single-symbol ones give the boldest, most
// distinct look (the whole plant rendered in one mark, à la Autoglyphs). Brackets [ ] stand in
// for the Autoglyphs "#"-class. Editing/reordering this list changes existing seeds' schemes.
const SCHEMES=[
// multi-symbol — legible, layered
['\\','|','/','-','.','O'], ['\\','|','-','/'], ['O','|','-'], ['+','-','|','O'], ['+','-','|'],
['X','/','\\'], ['/','\\'], ['O','\\','/'], ['.','O','+'], ['X','+','-','|'], ['+','X','.'], ['|','-'],
// single-symbol — bold, uniform
['O'], ['\\'], ['X'], ['+'],
// bracket schemes — the "#"-class adapted to [ ]
['[',']'], ['[',']','O'], ['[',']','O','-'], ['[',']','|','-','+'], ['[',']','+','\\','/','O']
];
function schemeFor(seed){ return SCHEMES[Math.floor(rng('scheme',seed)()*SCHEMES.length)]; }
const SchemeName=sc=>sc.join('');
function pickSym(ideal, sc, r){
if(sc.indexOf(ideal)>=0) return ideal;
// brackets are vertical-ish stand-ins; tried first so bracket-schemes actually render them
const fb={ '|':['[',']','+','X','-','O','.'], '-':['[',']','+','X','|','O','.'],
'/':['X','\\','+','O','[','.'], '\\':['X','/','+','O',']','.'],
'X':['+','/','\\','O'], '+':['X','O','-','|'],
'O':['.','+','X'], '.':['O','+'], '[':[']','|','O'], ']':['[','|','O'] };
const chain=fb[ideal]||['O']; for(const c of chain) if(sc.indexOf(c)>=0) return c;
return sc[Math.floor(r()*sc.length)];
}
// ---------- season ----------
function seasonOf(day){ const d=((day%365)+365)%365;
if(d<60)return'Winter'; if(d<152)return'Spring'; if(d<244)return'Summer'; if(d<335)return'Autumn'; return'Winter'; }
// ---------- 9 backdrops + white frame ----------
const BACKDROPS=['#D6E6F5','#F6EED5','#D7ECDD','#F7DEE4','#E8DEF2','#F4EBC4','#F7E0CB','#E2E6E9','#DBE1F3'];
const BACKDROP_NAMES=['Sky','Cream','Mint','Blush','Lilac','Butter','Peach','Ash','Periwinkle'];
function backdropFor(seed){ return Math.floor(rng('backdrop',seed)()*BACKDROPS.length); }
const FRAME=46;
function framed(bgc,inner){
return `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg">
<rect width="${W}" height="${H}" fill="#ffffff"/>
<rect x="${FRAME}" y="${FRAME}" width="${W-2*FRAME}" height="${H-2*FRAME}" fill="${bgc}"/>
<clipPath id="pan"><rect x="${FRAME}" y="${FRAME}" width="${W-2*FRAME}" height="${H-2*FRAME}"/></clipPath>
<g clip-path="url(#pan)">${inner}</g></svg>`;
}
// ---------- canvas: shared occupancy grid + z-order resolve ----------
const DEFLW={season:1.0, stem:1.15, leaves:1.0, flowers:1.7};
// per-layer glyph SIZE (× GLY): finer objects use smaller marks -> more detail. Systematic, central.
const DEFLS={season:1.0, stem:1.0, leaves:0.9, flowers:0.8};
function makeCanvas(){
const cells=new Map();
const snap=x=>Math.round(x/GRID)*GRID;
function push(x,y,z,ideal,col,layer){
const gx=snap(x),gy=snap(y);
if(gx<FRAME+4||gx>W-FRAME-4||gy<FRAME+4||gy>H-FRAME-4) return; // stay inside the frame
const k=gx+'_'+gy, ex=cells.get(k);
if(!ex||z>ex.z) cells.set(k,{z,ideal,col,layer,gx,gy});
}
function resolve(sc,ra,LW,exempt,LS){
const layer={season:'',stem:'',leaves:'',flowers:''};
for(const c of cells.values()){
const sym = exempt.indexOf(c.layer)>=0 ? c.ideal : pickSym(c.ideal,sc,ra);
layer[c.layer]+=glyph(sym,c.gx,c.gy,GLY*LS[c.layer],saturate(c.col,SAT_BOOST),LW[c.layer]);
}
// NOTE: prefixed ids — must NOT collide with panel element ids (e.g. <b id="season">),
// else getElementById('season') hits this <g> and refresh() wipes the layer's content.
return `<g id="lyr-season">${layer.season}</g><g id="lyr-stem">${layer.stem}</g><g id="lyr-leaves">${layer.leaves}</g><g id="lyr-flowers">${layer.flowers}</g>`;
}
return {push,snap,resolve,cells};
}
// ---------- growth env (§3.1 dual-clock, post-audit) ----------
// Four independent forces, per SEEDLING_ART_SPEC §3.1 / §3.2 (corrected):
// STATURE (m) — time + watered weeks (dual clock; entries cannot buy weeks)
// STRUCTURE — entryCount, CAPPED by stature (a sapling can't hold a full crown)
// REWARDS — vitality × consistency, gated by repro-maturity + season
// LIFESPAN — deathTime MONOTONIC in watered weeks (no retroactive death, §3.2)
const EPOCH=7, K_DEV=2; // 1 week; watered week ≈ K extra weeks
const fracDist=(a,b)=>{const d=Math.abs((a-b)%1);return d>0.5?1-d:d;}; // circular dist on [0,1)
function consistency(st){ const weeks=Math.max(1,Math.floor(st.now/EPOCH));
return clamp((st.wateredEpochs||0)/weeks,0,1); } // ρ "showed up" (rewards only, not death)
function maturity(spec, st){ // stature 0..1 (asymptotic, monotonic ↑)
const qnow=Math.floor(st.now/2)*2; // ambient ~2-day render tick
const D=qnow + K_DEV*(st.wateredEpochs||0)*EPOCH; // developmental time
return 1-Math.exp(-D/(0.7*spec.LIFESPAN)); }
function structureOf(spec, st){ return 1-Math.exp(-st.entryCount/(spec.N_DENS||12)); } // potential
function densityCap(spec, m){ return spec.densityCap?spec.densityCap(m):clamp(m*1.15,0,1); }
function densityOf(spec, st, m){ if(m==null)m=maturity(spec,st); // VISIBLE branches
return Math.min(structureOf(spec,st), densityCap(spec,m)); } // capped by stature (§3.1)
function deathTimeOf(spec, st){ // MONOTONIC in wateredEpochs (§3.2)
const r=rng('life',st.seed); const f=lerp(0.70,0.85,r()); const c=lerp(0.95,1.05,r());
// every species uses the same band — no fat-tail early-death (bamboo stays monocarpic only
// for the seal CAUSE via MONOCARPIC, matching the contract's cause 2).
const Wfull=Math.max(1,(spec.W_FULL_FRAC||0.6)*spec.LIFESPAN/EPOCH); // watered weeks to reach the c cap
const frac=clamp((st.wateredEpochs||0)/Wfull,0,1); // ↑ only as weeks accrue
return spec.LIFESPAN*(f+(c-f)*frac); } // plantedAt = 0 in proto clock
function vitalityOf(spec, st){ const H=spec.H||(1/(spec.DECAY||1/28));
return clamp(Math.exp(-(st.now-st.lastWatered)/H),0,1); }
function dormancyOf(spec, st){ // §6.1 warped: narrow display window, calendar-anchored
if(!spec.DORMANT) return 0; const {peak=0.04,display=0.15,hemi='N'}=spec.DORMANT;
const a=(((st.now/365)%1)+1)%1, pk=hemi==='S'?(peak+0.5)%1:peak;
const d=fracDist(a,pk), half=display/2;
return d<=half ? smooth(half,0,d) : 0; } // 1 at dormant peak → 0 at window edge
// ---------- pollinators (§6.3/6.4): public-attention ecosystem, never the plant ----------
// Drawn as a precise SVG layer (like soil — bypasses the glyph occupancy grid) so the tiny
// marks keep their geometry instead of snapping to the 5px grid. Still the same visual
// vocabulary — dots, circles, lines, short arcs — just placed exactly. Frozen mid-moment,
// no animation on a living diary ("life is stillness; death earns the motion," §7).
const POLL_MORPHS=['','','','','','pale','golden','night']; // mostly none; rare morphs (collectible)
function pollinatorsOf(spec, st, env){
const notes=st.notes||0;
if(notes<=10 || !env.reproOK || env.dormancy>0.5 || env.dead) return []; // earned + in-season only
const types=spec.POLLINATOR||['bee'];
const r=rng('poll',st.seed,Math.floor(notes/10)); // stable per ~10-note bucket
const n=1+Math.floor(r()*3); // 1..3 (3 = rarest)
const out=[];
for(let i=0;i<n;i++){
const side=r()<0.5?-1:1;
out.push({ type:types[Math.floor(r()*types.length)],
morph:POLL_MORPHS[Math.floor(r()*POLL_MORPHS.length)],
rx:clamp(0.5+side*(0.13+r()*0.30),0.06,0.94), // off the trunk's centre column…
ry:0.09+r()*0.26, // …in upper-canopy negative space (§6.4)
jit:r() });
}
return out; }
// Library (2026-06-17, post user review): five graceful, mostly-airborne marks — bee, butterfly,
// sunbird, bird, mote. The chunky moth/bat and the can't-float perched finch were dropped; the
// bee was kept but redrawn quiet (two hairline variants A/B, picked per-instance). Species are
// mapped onto these in species-params.js. desaturated vs the plant so focus stays on the diary;
// morphs recolour the whole visitor.
function pollPalette(type, morph){
if(morph==='pale') return {main:'#d7cfb6', accent:'#a89e80'}; // pale/albino — soft but still legible
if(morph==='golden') return {main:'#E2A92E', accent:'#A9771A'};
if(morph==='night') return {main:'#4a4660', accent:'#726d8a'};
switch(type){
case 'butterfly': return {main:'#a596a0', accent:'#8a7c87'}; // muted, quiet (was popping)
case 'sunbird': case 'bird': return {main:'#5f8076', accent:'#3c554d'};
case 'bee': return {main:'#9a895c', accent:'#6f6242'}; // soft amber, hairline
case 'mote': return {main:'#d8d1ba', accent:'#cfc7b0'};
default: return {main:'#7d8a82', accent:'#566057'};
} }
// Per-species placement (§6.4): after a species draws, anchor each visitor to the rewards it actually
// drew — the `flowers` layer cells (blooms/fruit). The visitor hovers just beside/above an upper bloom
// in clear negative space, never on the trunk. Fully automatic → covers all 16 species via their own
// bloom layouts; if a species drew no flower cells (e.g. wind-pollinated grasses), the generic
// upper-canopy rx/ry from pollinatorsOf is kept.
function placePollinators(C, polls, st){
const fc=[]; for(const c of C.cells.values()) if(c.layer==='flowers') fc.push(c);
if(fc.length<2) return; // nothing bloomed → keep generic fallback
fc.sort((a,b)=>a.gy-b.gy); // top blooms first (clear sky above them)
const top=fc.slice(0, Math.max(4, Math.ceil(fc.length*0.45)));
const r=rng('place', st.seed, st.notes||0);
polls.forEach((p)=>{
const cell=top[Math.floor(r()*top.length)];
const side=r()<0.5?-1:1, off=15+r()*13;
p.x=clamp(cell.gx+side*off, FRAME+12, W-FRAME-12); // beside the bloom…
p.y=clamp(cell.gy-off, FRAME+12, H-FRAME-12); // …and a touch above it (into open space)
});
}
function drawPollinator(p, env){ // returns an SVG string (small, STILL)
const x=(p.x!=null)? p.x : FRAME+p.rx*(W-2*FRAME); // p.x/p.y set by placePollinators; else generic
const y=(p.y!=null)? p.y : FRAME+p.ry*(H-2*FRAME);
const big=(p.type==='sunbird'||p.type==='bird');
const s=(11 + 4*((env&&env.m)||0.6)) * (big?1.12:1); // ≈3–5% of plant height; subordinate
const w=1.6, {main:c,accent:a}=pollPalette(p.type,p.morph), f=(p.jit||0)-0.5;
let op=0.9; // overall whisper; bee/butterfly even softer
const LN=(x1,y1,x2,y2,cc,ww)=>`<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="${cc}" stroke-width="${ww}" stroke-linecap="round"/>`;
const DOT=(cx,cy,r,cc)=>`<circle cx="${cx.toFixed(1)}" cy="${cy.toFixed(1)}" r="${r.toFixed(1)}" fill="${cc}"/>`;
const ARC=(d,cc,ww,fill)=>`<path d="${d}" fill="${fill||'none'}" stroke="${cc}" stroke-width="${ww}" stroke-linecap="round" stroke-linejoin="round"/>`;
const ELL=(cx,cy,rx,ry,ang,cc,ww,fill)=>`<ellipse cx="${cx.toFixed(1)}" cy="${cy.toFixed(1)}" rx="${rx.toFixed(1)}" ry="${ry.toFixed(1)}" transform="rotate(${ang.toFixed(0)} ${cx.toFixed(1)} ${cy.toFixed(1)})" fill="${fill||'none'}" stroke="${cc}" stroke-width="${ww}"/>`;
let g='';
switch(p.type){
case 'bee': { // quiet hairline bee — variant A or B per instance
op=0.7;
if((p.jit||0)<0.5){ // A · open oval + stripe + wing dashes
g+=ELL(x,y,s*0.74,s*0.44,-10,a,w*0.8);
g+=LN(x-s*0.04,y-s*0.36,x-s*0.04,y+s*0.36,a,w*0.65); // single stripe
g+=LN(x-s*0.26,y-s*0.58,x-s*0.06,y-s*0.46,a,w*0.6)+LN(x+s*0.30,y-s*0.56,x+s*0.10,y-s*0.46,a,w*0.6); // wing dashes
} else { // B · body dot + open V wings (most minimal)
g+=DOT(x,y,w*1.45,c);
g+=LN(x,y-s*0.10,x-s*0.70,y-s*0.72,a,w*0.7)+LN(x,y-s*0.10,x+s*0.70,y-s*0.72,a,w*0.7);
}
break; }
case 'butterfly': { // two mirrored wing-arcs + slender body (kept subtle)
const bw=w*0.78; // thinner strokes so it whispers, not pops
g+=ARC(`M ${x} ${y} C ${x-s*1.5} ${y-s*1.2}, ${x-s*1.4} ${y+s*1.0}, ${x} ${y+s*0.18}`,c,bw);
g+=ARC(`M ${x} ${y} C ${x+s*1.5} ${y-s*1.2}, ${x+s*1.4} ${y+s*1.0}, ${x} ${y+s*0.18}`,c,bw);
g+=LN(x,y-s*0.22,x,y+s*0.16,a,bw); // slender body
g+=LN(x,y-s*0.22,x-s*0.45,y-s*0.78,a,bw*0.85)+LN(x,y-s*0.22,x+s*0.45,y-s*0.78,a,bw*0.85); // antennae
op=0.72; // softest of the four
break; }
case 'sunbird': { // gull-stroke wings + body + long bill
g+=ARC(`M ${x-s*1.45} ${y} Q ${x-s*0.55} ${y-s*0.95} ${x} ${y-s*0.05} Q ${x+s*0.55} ${y-s*0.95} ${x+s*1.45} ${y}`,c,w);
g+=DOT(x,y-s*0.05,w*1.0,a);
g+=LN(x,y+s*0.05,x-s*1.15,y+s*0.95,a,w*0.8); // long sunbird bill, dipped to a flower
break; }
case 'bird': { // single gull-stroke + dot body
g+=ARC(`M ${x-s*1.5} ${y} Q ${x-s*0.6} ${y-s*0.9} ${x} ${y} Q ${x+s*0.6} ${y-s*0.9} ${x+s*1.5} ${y}`,c,w);
g+=DOT(x,y-s*0.02,w*0.9,a);
break; }
case 'mote': default: { // 2–3 pale drifting seed/pollen motes
g+=DOT(x,y,w*1.1,c)+DOT(x+s*0.9,y-s*0.6+f*s*0.3,w*0.9,c)+DOT(x-s*0.7,y+s*0.55-f*s*0.3,w*1.0,c);
break; }
}
return `<g opacity="${op}">${g}</g>`; }
function computeEnv(spec, st){
const ageF=clamp(st.now/spec.LIFESPAN,0,1);
const m=maturity(spec,st), density=densityOf(spec,st,m), rho=consistency(st);
const v=clamp(st.vitality!=null?st.vitality:vitalityOf(spec,st),0,1), dead=st.diedAt>0;
const sen=Math.max(smooth(spec.senFrom||0.80,1.0,ageF), dead?1:0);
const reproOK=m>=(spec.REPRO_THETA||0.4); // juveniles don't flower/fruit (§3.1)
const rewards=reproOK?clamp(v*(0.25+0.75*Math.sqrt(rho)),0,1):0;
const dormancy=dormancyOf(spec,st);
// per-seed off-centre placement: every plant rooted dead-centre fel