This commit is contained in:
ookami125 2026-08-18 02:25:03 -04:00
parent 35f3810632
commit 089d28869b
13 changed files with 11741 additions and 27 deletions

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@
This repository is a sequence of small game-design experiments. It is not a single game yet. This repository is a sequence of small game-design experiments. It is not a single game yet.
The current experiment is **008 — Catalyst Ecology**. See [its README](experiments/008_catalyst_ecology/README.md) for the one-command launch instructions and playtest prompt. Play it before reading its private hypothesis notes. The current experiment is **009 — Catalyst Ascent**. See [its README](experiments/009_catalyst_ascent/README.md) for the one-command launch instructions and playtest prompt. Play it before reading its private hypothesis notes.
Earlier experiments are preserved in [`experiments/`](experiments/). Earlier experiments are preserved in [`experiments/`](experiments/).

View file

@ -2,19 +2,27 @@
"use strict"; "use strict";
const $ = s => document.querySelector(s), $$ = s => [...document.querySelectorAll(s)]; const $ = s => document.querySelector(s), $$ = s => [...document.querySelectorAll(s)];
const canvas = $("#field"), ctx = canvas.getContext("2d"); const canvas = $("#field"), ctx = canvas.getContext("2d");
const ASCENT = window.CATALYST_MODE === "ascent";
const CONFIG = ASCENT ? { experiment:"009_catalyst_ascent", revision:1, storage:"catalyst-ascent-last-jsonl", filename:"catalyst-ascent", choiceLimit:7 } : { experiment:"008_catalyst_ecology", revision:2, storage:"catalyst-ecology-last-jsonl", filename:"catalyst-ecology", choiceLimit:4 };
const COLORS = { mint: "#68e0b5", cyan: "#66d8f2", violet: "#b28bf5", amber: "#efb55e", red: "#ee6f72", rose: "#f18cba" }; const COLORS = { mint: "#68e0b5", cyan: "#66d8f2", violet: "#b28bf5", amber: "#efb55e", red: "#ee6f72", rose: "#f18cba" };
const KEYS = { KeyW:[0,-1],ArrowUp:[0,-1],KeyS:[0,1],ArrowDown:[0,1],KeyA:[-1,0],ArrowLeft:[-1,0],KeyD:[1,0],ArrowRight:[1,0] }; const KEYS = { KeyW:[0,-1],ArrowUp:[0,-1],KeyS:[0,1],ArrowDown:[0,1],KeyA:[-1,0],ArrowLeft:[-1,0],KeyD:[1,0],ArrowRight:[1,0] };
const ENEMIES = { const ENEMIES = {
mote:{name:"Motes",icon:"●",radius:.017,hp:2,speed:.068,color:COLORS.red}, mote:{name:"Motes",icon:"●",radius:.017,hp:2,speed:.068,color:COLORS.red},
husk:{name:"Husks",icon:"⬢",radius:.026,hp:7,speed:.044,color:COLORS.amber}, husk:{name:"Husks",icon:"⬢",radius:.026,hp:7,speed:.044,color:COLORS.amber},
titan:{name:"Titans",icon:"◆",radius:.036,hp:18,speed:.029,color:COLORS.violet}, titan:{name:"Titans",icon:"◆",radius:.036,hp:18,speed:.029,color:COLORS.violet},
brood:{name:"Broods",icon:"✹",radius:.032,hp:12,speed:.024,color:COLORS.rose} brood:{name:"Broods",icon:"✹",radius:.032,hp:12,speed:.024,color:COLORS.rose},
ward:{name:"Wards",icon:"⬡",radius:.032,hp:14,speed:.034,color:"#75bff2"},
renewal:{name:"Renewals",icon:"✚",radius:.038,hp:30,speed:.026,color:"#8fe28b"}
}; };
const EXPEDITIONS = { const ECOLOGY_EXPEDITIONS = {
shoal:{name:"Shoal",copy:"Small bodies arrive in increasingly dense currents.",waves:[{mote:7},{mote:12},{mote:18,husk:1},{mote:24,husk:2},{mote:32,husk:3},{mote:36,husk:4},{mote:40,husk:5,titan:1},{mote:44,husk:6,titan:2}]}, shoal:{name:"Shoal",copy:"Small bodies arrive in increasingly dense currents.",waves:[{mote:7},{mote:12},{mote:18,husk:1},{mote:24,husk:2},{mote:32,husk:3},{mote:36,husk:4},{mote:40,husk:5,titan:1},{mote:44,husk:6,titan:2}]},
bastion:{name:"Bastion",copy:"Durable bodies concentrate health into fewer targets.",waves:[{husk:3},{husk:4},{titan:2,husk:2,mote:4},{titan:3,husk:3,mote:5},{titan:4,husk:4,mote:6},{titan:5,husk:5,mote:8},{titan:5,husk:7,mote:12},{titan:6,husk:8,mote:16}]}, bastion:{name:"Bastion",copy:"Durable bodies concentrate health into fewer targets.",waves:[{husk:3},{husk:4},{titan:2,husk:2,mote:4},{titan:3,husk:3,mote:5},{titan:4,husk:4,mote:6},{titan:5,husk:5,mote:8},{titan:5,husk:7,mote:12},{titan:6,husk:8,mote:16}]},
brood:{name:"Brood",copy:"Brood bodies release fresh motes while mixed bodies close in.",waves:[{mote:7},{brood:1,mote:6},{brood:2,mote:8},{brood:3,husk:2,mote:8},{brood:3,titan:2,mote:10},{brood:4,titan:2,husk:3,mote:10},{brood:4,titan:3,husk:4,mote:12},{brood:5,titan:3,husk:5,mote:14}]} brood:{name:"Brood",copy:"Brood bodies release fresh motes while mixed bodies close in.",waves:[{mote:7},{brood:1,mote:6},{brood:2,mote:8},{brood:3,husk:2,mote:8},{brood:3,titan:2,mote:10},{brood:4,titan:2,husk:3,mote:10},{brood:4,titan:3,husk:4,mote:12},{brood:5,titan:3,husk:5,mote:14}]}
}; };
const ASCENT_EXPEDITIONS = {
ascent:{name:"Ascent",copy:"The population acquires re-forming wards, regeneration, and spawning as the run climbs.",waves:[{mote:7},{mote:12,husk:1},{brood:1,mote:8},{ward:1,mote:10},{ward:2,husk:3,mote:10},{renewal:1,ward:2,mote:12},{renewal:2,brood:2,ward:2,mote:12},{renewal:2,ward:3,titan:2,mote:16},{renewal:3,brood:3,ward:3,titan:2,mote:18},{renewal:4,brood:4,ward:4,titan:3,mote:22}]}
};
const EXPEDITIONS = ASCENT ? ASCENT_EXPEDITIONS : ECOLOGY_EXPEDITIONS;
const MODULES = { const MODULES = {
fork:{icon:"⋔",name:"Fork",short:"Fragments on primary hit",color:COLORS.cyan}, fork:{icon:"⋔",name:"Fork",short:"Fragments on primary hit",color:COLORS.cyan},
bloom:{icon:"✦",name:"Bloom",short:"Seeking sparks on any kill",color:COLORS.mint}, bloom:{icon:"✦",name:"Bloom",short:"Seeking sparks on any kill",color:COLORS.mint},
@ -26,16 +34,16 @@
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v)), round=v=>Math.round(v*1000)/1000; const clamp=(v,a,b)=>Math.max(a,Math.min(b,v)), round=v=>Math.round(v*1000)/1000;
const norm=(x,y)=>{const d=Math.hypot(x,y)||1;return[x/d,y/d]}, dist=(a,b)=>Math.hypot(a.x-b.x,a.y-b.y); const norm=(x,y)=>{const d=Math.hypot(x,y)||1;return[x/d,y/d]}, dist=(a,b)=>Math.hypot(a.x-b.x,a.y-b.y);
const session=crypto.randomUUID?crypto.randomUUID():`session-${Date.now()}`; const session=crypto.randomUUID?crypto.randomUUID():`session-${Date.now()}`;
const state={session,startedAt:Date.now(),logs:[],started:false,active:false,choosing:false,complete:false,expedition:"shoal",attempt:0,wave:0,waveAttempt:0,runTime:0,waveTime:0,choice:0,completed:new Set(), const state={session,startedAt:Date.now(),logs:[],started:false,active:false,choosing:false,complete:false,expedition:ASCENT?"ascent":"shoal",attempt:0,wave:0,waveAttempt:0,runTime:0,waveTime:0,choice:0,completed:new Set(),
upgrades:{fork:0,bloom:0,arc:0,focus:0,conduit:0,resonance:0},player:{x:.5,y:.5,vx:0,vy:0,health:6,inv:0,lastDamage:-99},keys:new Set(),pointer:{x:.75,y:.5,firing:false},fireCd:0, upgrades:{fork:0,bloom:0,arc:0,focus:0,conduit:0,resonance:0},player:{x:.5,y:.5,vx:0,vy:0,health:6,inv:0,lastDamage:-99},keys:new Set(),pointer:{x:.75,y:.5,firing:false},fireCd:0,
enemies:[],bullets:[],particles:[],effects:[],enemyId:0,bulletId:0,totalHitCounter:0,secondaryCounter:0,secondaryBudget:300,actions:null,lastFrame:performance.now(),snapshot:0,clearDelay:0,render:{size:1,ox:0,oy:0},bannerTimer:null,toastTimer:null}; enemies:[],bullets:[],particles:[],effects:[],enemyId:0,bulletId:0,totalHitCounter:0,secondaryCounter:0,secondaryBudget:300,actions:null,lastFrame:performance.now(),snapshot:0,clearDelay:0,render:{size:1,ox:0,oy:0},bannerTimer:null,toastTimer:null};
const freshActions=()=>({shots:0,primary_hits:0,fragment_hits:0,spark_hits:0,lance_hits:0,arc_triggers:0,focus_ruptures:0,conduit_lances:0,kills:0,spawned_motes:0,damage_taken:0}); state.actions=freshActions(); const freshActions=()=>({shots:0,primary_hits:0,fragment_hits:0,spark_hits:0,lance_hits:0,arc_triggers:0,focus_ruptures:0,conduit_lances:0,kills:0,spawned_motes:0,damage_taken:0}); state.actions=freshActions();
function log(type,data={}){const e={schema:1,experiment:"008_catalyst_ecology",prototype_revision:2,session_id:state.session,elapsed_ms:Date.now()-state.startedAt,expedition:state.expedition,run_attempt:state.attempt,wave:state.wave+1,wave_attempt:state.waveAttempt,run_seconds:round(state.runTime),type,...data};state.logs.push(JSON.stringify(e));try{localStorage.setItem("catalyst-ecology-last-jsonl",state.logs.join("\n")+"\n")}catch(_){}} function log(type,data={}){const e={schema:1,experiment:CONFIG.experiment,prototype_revision:CONFIG.revision,session_id:state.session,elapsed_ms:Date.now()-state.startedAt,expedition:state.expedition,run_attempt:state.attempt,wave:state.wave+1,wave_attempt:state.waveAttempt,run_seconds:round(state.runTime),type,...data};state.logs.push(JSON.stringify(e));try{localStorage.setItem(CONFIG.storage,state.logs.join("\n")+"\n")}catch(_){}}
const build=()=>Object.fromEntries(Object.entries(state.upgrades).filter(([,v])=>v)); const build=()=>Object.fromEntries(Object.entries(state.upgrades).filter(([,v])=>v));
const secondaryScale=()=>1+state.upgrades.resonance*.55; const secondaryScale=()=>1+state.upgrades.resonance*.55;
function populationText(spec){return Object.entries(spec).map(([k,v])=>`${v} ${ENEMIES[k].name}`).join(" · ")} function populationText(spec){return Object.entries(spec).map(([k,v])=>`${v} ${ENEMIES[k].name}`).join(" · ")}
function layout(spec){const entries=[];for(const[k,count]of Object.entries(spec)){for(let i=0;i<count;i++)entries.push(k)}return entries.map((kind,i)=>{const n=entries.length,a=i/n*Math.PI*2+state.wave*.39,ring=.34+((i*5+state.wave)%4)*.035;return{kind,x:.5+Math.cos(a)*ring,y:.5+Math.sin(a)*ring}})} function layout(spec){const entries=[];for(const[k,count]of Object.entries(spec)){for(let i=0;i<count;i++)entries.push(k)}return entries.map((kind,i)=>{const n=entries.length,a=i/n*Math.PI*2+state.wave*.39,ring=.34+((i*5+state.wave)%4)*.035;return{kind,x:.5+Math.cos(a)*ring,y:.5+Math.sin(a)*ring}})}
function spawnEnemy(kind,x,y,spawned=false){const d=ENEMIES[kind];state.enemies.push({id:++state.enemyId,kind,x,y,vx:0,vy:0,radius:d.radius,hp:d.hp,maxHp:d.hp,removed:false,flash:0,contact:0,focusHits:0,spawnCd:2+Math.random()*.7,spawnsLeft:kind==="brood"?2+Math.floor(state.wave/2):0});if(spawned)state.actions.spawned_motes++} function spawnEnemy(kind,x,y,spawned=false){const d=ENEMIES[kind];state.enemies.push({id:++state.enemyId,kind,x,y,vx:0,vy:0,radius:d.radius,hp:d.hp,maxHp:d.hp,removed:false,flash:0,contact:0,focusHits:0,spawnCd:2+Math.random()*.7,spawnsLeft:kind==="brood"?2+Math.floor(state.wave/2):0,ward:kind==="ward"?8:0,wardMax:kind==="ward"?8:0,wardWindow:0,wardCooldown:0,lastHit:-99,regenTotal:0});if(spawned)state.actions.spawned_motes++}
function spawnWave(reason){state.waveAttempt++;state.waveTime=0;state.snapshot=0;state.clearDelay=0;state.totalHitCounter=0;state.secondaryCounter=0;state.secondaryBudget=300;state.enemies=[];state.bullets=[];state.particles=[];state.effects=[];state.player={x:.5,y:.5,vx:0,vy:0,health:6,inv:1,lastDamage:-99};state.fireCd=0;state.pointer.firing=false;layout(EXPEDITIONS[state.expedition].waves[state.wave]).forEach(e=>spawnEnemy(e.kind,e.x,e.y));state.active=state.started;state.choosing=false;state.complete=false;$("#choice-overlay").classList.add("hidden");$("#complete-overlay").classList.add("hidden");updateUI();log("wave_started",{reason,population:EXPEDITIONS[state.expedition].waves[state.wave],enemy_count:state.enemies.length,build:build()});showBanner(`Field ${state.wave+1}`);canvas.focus({preventScroll:true})} function spawnWave(reason){state.waveAttempt++;state.waveTime=0;state.snapshot=0;state.clearDelay=0;state.totalHitCounter=0;state.secondaryCounter=0;state.secondaryBudget=300;state.enemies=[];state.bullets=[];state.particles=[];state.effects=[];state.player={x:.5,y:.5,vx:0,vy:0,health:6,inv:1,lastDamage:-99};state.fireCd=0;state.pointer.firing=false;layout(EXPEDITIONS[state.expedition].waves[state.wave]).forEach(e=>spawnEnemy(e.kind,e.x,e.y));state.active=state.started;state.choosing=false;state.complete=false;$("#choice-overlay").classList.add("hidden");$("#complete-overlay").classList.add("hidden");updateUI();log("wave_started",{reason,population:EXPEDITIONS[state.expedition].waves[state.wave],enemy_count:state.enemies.length,build:build()});showBanner(`Field ${state.wave+1}`);canvas.focus({preventScroll:true})}
function startRun(id,reason){if(state.started&&(state.active||state.choosing)&&!state.complete)log("run_abandoned",{reason,remaining:state.enemies.length,build:build(),actions:state.actions});state.expedition=id;state.attempt++;state.wave=0;state.waveAttempt=0;state.runTime=0;state.choice=0;state.upgrades={fork:0,bloom:0,arc:0,focus:0,conduit:0,resonance:0};state.actions=freshActions();state.complete=false;state.choosing=false;$$('.expedition').forEach(b=>b.classList.toggle("selected",b.dataset.expedition===id));log("run_started",{reason,expedition:id});spawnWave("run_started")} function startRun(id,reason){if(state.started&&(state.active||state.choosing)&&!state.complete)log("run_abandoned",{reason,remaining:state.enemies.length,build:build(),actions:state.actions});state.expedition=id;state.attempt++;state.wave=0;state.waveAttempt=0;state.runTime=0;state.choice=0;state.upgrades={fork:0,bloom:0,arc:0,focus:0,conduit:0,resonance:0};state.actions=freshActions();state.complete=false;state.choosing=false;$$('.expedition').forEach(b=>b.classList.toggle("selected",b.dataset.expedition===id));log("run_started",{reason,expedition:id});spawnWave("run_started")}
function begin(){if(state.started)return;state.started=true;$("#start-overlay").classList.add("hidden");log("session_started",{viewport:[innerWidth,innerHeight]});startRun(state.expedition,"session_started")} function begin(){if(state.started)return;state.started=true;$("#start-overlay").classList.add("hidden");log("session_started",{viewport:[innerWidth,innerHeight]});startRun(state.expedition,"session_started")}
@ -51,29 +59,29 @@
function triggerArc(origin){const l=state.upgrades.arc;if(!l)return;const threshold=Math.max(3,7-l);if(state.totalHitCounter%threshold!==0)return;const targets=nearest(origin,1+l,.32,new Set([origin.id]));state.actions.arc_triggers++;for(const t of targets){if(!spendBudget())break;lineEffect("arc",origin,t);damageEnemy(t,.78*secondaryScale(),"arc");secondaryHit(t,"arc")}log("module_triggered",{module:"arc",source_id:origin.id,target_ids:targets.map(t=>t.id),threshold})} function triggerArc(origin){const l=state.upgrades.arc;if(!l)return;const threshold=Math.max(3,7-l);if(state.totalHitCounter%threshold!==0)return;const targets=nearest(origin,1+l,.32,new Set([origin.id]));state.actions.arc_triggers++;for(const t of targets){if(!spendBudget())break;lineEffect("arc",origin,t);damageEnemy(t,.78*secondaryScale(),"arc");secondaryHit(t,"arc")}log("module_triggered",{module:"arc",source_id:origin.id,target_ids:targets.map(t=>t.id),threshold})}
function triggerFocus(enemy){const l=state.upgrades.focus;if(!l)return;enemy.focusHits++;const threshold=Math.max(2,6-l);if(enemy.focusHits<threshold)return;enemy.focusHits=0;if(!spendBudget())return;state.actions.focus_ruptures++;effect("focus",enemy.x,enemy.y);damageEnemy(enemy,(3+l*2)*secondaryScale(),"focus");secondaryHit(enemy,"focus");log("module_triggered",{module:"focus",source_id:enemy.id,threshold})} function triggerFocus(enemy){const l=state.upgrades.focus;if(!l)return;enemy.focusHits++;const threshold=Math.max(2,6-l);if(enemy.focusHits<threshold)return;enemy.focusHits=0;if(!spendBudget())return;state.actions.focus_ruptures++;effect("focus",enemy.x,enemy.y);damageEnemy(enemy,(3+l*2)*secondaryScale(),"focus");secondaryHit(enemy,"focus");log("module_triggered",{module:"focus",source_id:enemy.id,threshold})}
function hit(enemy,bullet){if(enemy.removed||bullet.removed)return;bullet.removed=true;if(bullet.kind==="primary")state.actions.primary_hits++;else if(bullet.kind==="fragment")state.actions.fragment_hits++;else if(bullet.kind==="spark")state.actions.spark_hits++;else if(bullet.kind==="lance")state.actions.lance_hits++;damageEnemy(enemy,bullet.damage,bullet.kind);if(bullet.kind==="primary"){triggerFocus(enemy);triggerFork(enemy,bullet)}else secondaryHit(enemy,bullet.kind);state.totalHitCounter++;triggerArc(enemy)} function hit(enemy,bullet){if(enemy.removed||bullet.removed)return;bullet.removed=true;if(bullet.kind==="primary")state.actions.primary_hits++;else if(bullet.kind==="fragment")state.actions.fragment_hits++;else if(bullet.kind==="spark")state.actions.spark_hits++;else if(bullet.kind==="lance")state.actions.lance_hits++;damageEnemy(enemy,bullet.damage,bullet.kind);if(bullet.kind==="primary"){triggerFocus(enemy);triggerFork(enemy,bullet)}else secondaryHit(enemy,bullet.kind);state.totalHitCounter++;triggerArc(enemy)}
function damageEnemy(enemy,amount,cause){if(enemy.removed)return;enemy.hp-=amount;enemy.flash=.1;burst(enemy.x,enemy.y,cause==="arc"?COLORS.violet:COLORS.mint,4);log("enemy_damaged",{enemy_id:enemy.id,enemy_kind:enemy.kind,amount:round(amount),cause,hp_after:round(Math.max(0,enemy.hp))});if(enemy.hp>0)return;enemy.removed=true;state.actions.kills++;burst(enemy.x,enemy.y,ENEMIES[enemy.kind].color,12);log("enemy_killed",{enemy_id:enemy.id,enemy_kind:enemy.kind,cause,remaining_after:state.enemies.filter(e=>!e.removed).length});triggerBloom(enemy)} function damageEnemy(enemy,amount,cause){if(enemy.removed)return;enemy.lastHit=state.waveTime;if(enemy.kind==="ward"&&enemy.ward>0){if(cause==="focus"||cause==="lance"){enemy.ward=0;enemy.wardWindow=0;enemy.wardCooldown=2.8;log("ward_broken",{enemy_id:enemy.id,cause,bypass:true})}else{if(enemy.ward===enemy.wardMax)enemy.wardWindow=1.55;enemy.ward--;enemy.flash=.1;burst(enemy.x,enemy.y,"#75bff2",3);log("ward_hit",{enemy_id:enemy.id,cause,ward_after:enemy.ward,window_seconds:round(enemy.wardWindow)});if(enemy.ward>0)return;enemy.wardWindow=0;enemy.wardCooldown=2.8;log("ward_broken",{enemy_id:enemy.id,cause,bypass:false});return}}enemy.hp-=amount;enemy.flash=.1;burst(enemy.x,enemy.y,cause==="arc"?COLORS.violet:COLORS.mint,4);log("enemy_damaged",{enemy_id:enemy.id,enemy_kind:enemy.kind,amount:round(amount),cause,hp_after:round(Math.max(0,enemy.hp))});if(enemy.hp>0)return;enemy.removed=true;state.actions.kills++;burst(enemy.x,enemy.y,ENEMIES[enemy.kind].color,12);log("enemy_killed",{enemy_id:enemy.id,enemy_kind:enemy.kind,cause,remaining_after:state.enemies.filter(e=>!e.removed).length});triggerBloom(enemy)}
function damagePlayer(enemy){if(state.player.inv>0||!state.active)return;state.player.health--;state.player.inv=.85;state.player.lastDamage=state.runTime;state.actions.damage_taken++;const[dx,dy]=norm(state.player.x-enemy.x,state.player.y-enemy.y);state.player.vx+=dx*.22;state.player.vy+=dy*.22;burst(state.player.x,state.player.y,COLORS.red,10);log("player_damaged",{enemy_id:enemy.id,enemy_kind:enemy.kind,health_after:state.player.health});if(state.player.health<=0)defeat()} function damagePlayer(enemy){if(state.player.inv>0||!state.active)return;state.player.health--;state.player.inv=.85;state.player.lastDamage=state.runTime;state.actions.damage_taken++;const[dx,dy]=norm(state.player.x-enemy.x,state.player.y-enemy.y);state.player.vx+=dx*.22;state.player.vy+=dy*.22;burst(state.player.x,state.player.y,COLORS.red,10);log("player_damaged",{enemy_id:enemy.id,enemy_kind:enemy.kind,health_after:state.player.health});if(state.player.health<=0)defeat()}
function defeat(){state.active=false;state.pointer.firing=false;state.keys.clear();log("player_defeated",{remaining:state.enemies.length,build:build(),actions:state.actions});showBanner("Field restoring");setTimeout(()=>{if(state.started&&!state.active&&!state.choosing&&!state.complete)spawnWave("defeat_restart")},800)} function defeat(){state.active=false;state.pointer.firing=false;state.keys.clear();log("player_defeated",{remaining:state.enemies.length,build:build(),actions:state.actions});showBanner("Field restoring");setTimeout(()=>{if(state.started&&!state.active&&!state.choosing&&!state.complete)spawnWave("defeat_restart")},800)}
function clearWave(){if(!state.active)return;state.active=false;state.pointer.firing=false;log("wave_completed",{duration_seconds:round(state.waveTime),health:state.player.health,build:build(),actions:state.actions});const waves=EXPEDITIONS[state.expedition].waves;if(state.wave===waves.length-1){completeRun();return}if(state.choice<4){showChoices();return}state.wave++;state.waveAttempt=0;const expectedAttempt=state.attempt,expectedExpedition=state.expedition;log("post_build_field_advanced",{next_population:waves[state.wave],build:build()});updateUI();showBanner(`Build retained · Field ${state.wave+1}`);setTimeout(()=>{if(state.attempt===expectedAttempt&&state.expedition===expectedExpedition&&!state.active&&!state.choosing&&!state.complete)spawnWave("post_build_advance")},650)} function clearWave(){if(!state.active)return;state.active=false;state.pointer.firing=false;log("wave_completed",{duration_seconds:round(state.waveTime),health:state.player.health,build:build(),actions:state.actions});const waves=EXPEDITIONS[state.expedition].waves;if(state.wave===waves.length-1){completeRun();return}if(state.choice<CONFIG.choiceLimit){showChoices();return}state.wave++;state.waveAttempt=0;const expectedAttempt=state.attempt,expectedExpedition=state.expedition;log("post_build_field_advanced",{next_population:waves[state.wave],build:build()});updateUI();showBanner(`Build retained · Field ${state.wave+1}`);setTimeout(()=>{if(state.attempt===expectedAttempt&&state.expedition===expectedExpedition&&!state.active&&!state.choosing&&!state.complete)spawnWave("post_build_advance")},650)}
function moduleDescription(key,next){if(key==="fork")return `${next*2} fragments leave every primary impact.`;if(key==="bloom")return `${2+next} seeking sparks leave every destroyed body.`;if(key==="arc")return `Every ${Math.max(3,7-next)} projectile hits chain into up to ${1+next} nearby bodies.`;if(key==="focus")return `${Math.max(2,6-next)} repeated primary hits rupture one body for ${3+next*2} damage.`;if(key==="conduit")return `Every ${Math.max(3,9-next*2)} secondary hits launch a ${Math.round((3+next*1.8)*10)/10}-damage lance at the healthiest body.`;return `All fragment, spark, Arc, Focus, and Conduit damage becomes ${(1+next*.55).toFixed(2)}×.`} function moduleDescription(key,next){if(key==="fork")return `${next*2} fragments leave every primary impact.`;if(key==="bloom")return `${2+next} seeking sparks leave every destroyed body.`;if(key==="arc")return `Every ${Math.max(3,7-next)} projectile hits chain into up to ${1+next} nearby bodies.`;if(key==="focus")return `${Math.max(2,6-next)} repeated primary hits rupture one body for ${3+next*2} damage.`;if(key==="conduit")return `Every ${Math.max(3,9-next*2)} secondary hits launch a ${Math.round((3+next*1.8)*10)/10}-damage lance at the healthiest body.`;return `All fragment, spark, Arc, Focus, and Conduit damage becomes ${(1+next*.55).toFixed(2)}×.`}
function showChoices(){state.choosing=true;state.choice++;const nextSpec=EXPEDITIONS[state.expedition].waves[state.wave+1];$("#next-forecast").textContent=`Next field: ${populationText(nextSpec)}.`;const box=$("#choices");box.replaceChildren();for(const[key,d]of Object.entries(MODULES)){const next=state.upgrades[key]+1,b=document.createElement("button");b.className="choice";b.dataset.module=key;b.innerHTML=`<i style="color:${d.color}">${d.icon}</i><b>${d.name}</b><small>${moduleDescription(key,next)}</small><em>${state.upgrades[key]?`Level ${state.upgrades[key]}${next}`:"Not selected"}</em>`;b.onclick=()=>choose(key);box.append(b)}$("#choice-overlay").classList.remove("hidden");log("choices_shown",{choice_number:state.choice,next_population:nextSpec,options:Object.keys(MODULES).map(k=>({key:k,current_level:state.upgrades[k]}))})} function showChoices(){state.choosing=true;state.choice++;const nextSpec=EXPEDITIONS[state.expedition].waves[state.wave+1];$("#next-forecast").textContent=`Next field: ${populationText(nextSpec)}.`;const box=$("#choices");box.replaceChildren();for(const[key,d]of Object.entries(MODULES)){const next=state.upgrades[key]+1,b=document.createElement("button");b.className="choice";b.dataset.module=key;b.innerHTML=`<i style="color:${d.color}">${d.icon}</i><b>${d.name}</b><small>${moduleDescription(key,next)}</small><em>${state.upgrades[key]?`Level ${state.upgrades[key]}${next}`:"Not selected"}</em>`;b.onclick=()=>choose(key);box.append(b)}$("#choice-overlay").classList.remove("hidden");log("choices_shown",{choice_number:state.choice,next_population:nextSpec,options:Object.keys(MODULES).map(k=>({key:k,current_level:state.upgrades[k]}))})}
function choose(key){if(!state.choosing||!MODULES[key])return;state.upgrades[key]++;state.choosing=false;log("module_chosen",{choice_number:state.choice,module:key,new_level:state.upgrades[key],build:build()});state.wave++;state.waveAttempt=0;spawnWave("module_chosen")} function choose(key){if(!state.choosing||!MODULES[key])return;state.upgrades[key]++;state.choosing=false;log("module_chosen",{choice_number:state.choice,module:key,new_level:state.upgrades[key],build:build()});state.wave++;state.waveAttempt=0;spawnWave("module_chosen")}
function completeRun(){state.complete=true;state.active=false;state.completed.add(state.expedition);log("run_completed",{duration_seconds:round(state.runTime),build:build(),actions:state.actions});document.querySelector(`[data-expedition="${state.expedition}"]`).classList.add("complete");const ids=Object.keys(EXPEDITIONS),next=ids[(ids.indexOf(state.expedition)+1)%ids.length];$("#complete-title").textContent=`${EXPEDITIONS[state.expedition].name} stabilized.`;$("#next-expedition").textContent=`Start ${EXPEDITIONS[next].name}`;$("#next-expedition").dataset.expedition=next;$("#complete-overlay").classList.remove("hidden");updateUI()} function completeRun(){state.complete=true;state.active=false;state.completed.add(state.expedition);log("run_completed",{duration_seconds:round(state.runTime),build:build(),actions:state.actions});document.querySelector(`[data-expedition="${state.expedition}"]`).classList.add("complete");const ids=Object.keys(EXPEDITIONS),next=ids[(ids.indexOf(state.expedition)+1)%ids.length];$("#complete-title").textContent=`${EXPEDITIONS[state.expedition].name} stabilized.`;$("#next-expedition").textContent=`Start ${EXPEDITIONS[next].name}`;$("#next-expedition").dataset.expedition=next;$("#complete-overlay").classList.remove("hidden");updateUI()}
function updatePlayer(dt){state.player.inv=Math.max(0,state.player.inv-dt);state.fireCd=Math.max(0,state.fireCd-dt);if(state.player.health<6&&state.runTime-state.player.lastDamage>3.5)state.player.health=Math.min(6,state.player.health+dt*.45);let x=0,y=0;for(const k of state.keys){const v=KEYS[k];if(v){x+=v[0];y+=v[1]}}if(x||y){[x,y]=norm(x,y);state.player.vx+=x*.92*dt;state.player.vy+=y*.92*dt}const drag=Math.pow(.025,dt);state.player.vx*=drag;state.player.vy*=drag;const s=Math.hypot(state.player.vx,state.player.vy);if(s>.29){state.player.vx*=.29/s;state.player.vy*=.29/s}state.player.x=clamp(state.player.x+state.player.vx*dt,.04,.96);state.player.y=clamp(state.player.y+state.player.vy*dt,.04,.96);if(state.pointer.firing)fire()} function updatePlayer(dt){state.player.inv=Math.max(0,state.player.inv-dt);state.fireCd=Math.max(0,state.fireCd-dt);if(state.player.health<6&&state.runTime-state.player.lastDamage>3.5)state.player.health=Math.min(6,state.player.health+dt*.45);let x=0,y=0;for(const k of state.keys){const v=KEYS[k];if(v){x+=v[0];y+=v[1]}}if(x||y){[x,y]=norm(x,y);state.player.vx+=x*.92*dt;state.player.vy+=y*.92*dt}const drag=Math.pow(.025,dt);state.player.vx*=drag;state.player.vy*=drag;const s=Math.hypot(state.player.vx,state.player.vy);if(s>.29){state.player.vx*=.29/s;state.player.vy*=.29/s}state.player.x=clamp(state.player.x+state.player.vx*dt,.04,.96);state.player.y=clamp(state.player.y+state.player.vy*dt,.04,.96);if(state.pointer.firing)fire()}
function updateEnemies(dt){for(const e of state.enemies){if(e.removed)continue;e.flash=Math.max(0,e.flash-dt);e.contact=Math.max(0,e.contact-dt);if(e.kind==="brood"&&e.spawnsLeft>0){e.spawnCd-=dt;if(e.spawnCd<=0){e.spawnCd=2.35;e.spawnsLeft--;const a=Math.random()*Math.PI*2;spawnEnemy("mote",clamp(e.x+Math.cos(a)*.055,.04,.96),clamp(e.y+Math.sin(a)*.055,.04,.96),true);log("brood_spawned",{brood_id:e.id,remaining_spawns:e.spawnsLeft})}}const[dx,dy]=norm(state.player.x-e.x,state.player.y-e.y),d=ENEMIES[e.kind];e.vx+=dx*d.speed*4.5*dt;e.vy+=dy*d.speed*4.5*dt;const drag=Math.pow(.12,dt);e.vx*=drag;e.vy*=drag;const s=Math.hypot(e.vx,e.vy);if(s>d.speed){e.vx*=d.speed/s;e.vy*=d.speed/s}e.x=clamp(e.x+e.vx*dt,e.radius,1-e.radius);e.y=clamp(e.y+e.vy*dt,e.radius,1-e.radius);if(dist(e,state.player)<e.radius+.022)damagePlayer(e)}for(let i=0;i<state.enemies.length;i++){const a=state.enemies[i];if(a.removed)continue;for(let j=i+1;j<state.enemies.length;j++){const b=state.enemies[j];if(b.removed)continue;const dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||.001,min=a.radius+b.radius;if(d>=min)continue;const o=(min-d)/2,nx=dx/d,ny=dy/d;a.x-=nx*o;a.y-=ny*o;b.x+=nx*o;b.y+=ny*o}}state.enemies=state.enemies.filter(e=>!e.removed)} function updateEnemies(dt){for(const e of state.enemies){if(e.removed)continue;e.flash=Math.max(0,e.flash-dt);e.contact=Math.max(0,e.contact-dt);if(e.kind==="ward"){if(e.ward>0&&e.ward<e.wardMax){e.wardWindow-=dt;if(e.wardWindow<=0){const before=e.ward;e.ward=e.wardMax;log("ward_reset",{enemy_id:e.id,ward_before:before})}}else if(e.ward===0){e.wardCooldown-=dt;if(e.wardCooldown<=0){e.ward=e.wardMax;log("ward_reformed",{enemy_id:e.id,hp:round(e.hp)})}}}if(e.kind==="renewal"&&e.hp<e.maxHp){const before=e.hp;e.hp=Math.min(e.maxHp,e.hp+3.6*dt);e.regenTotal+=e.hp-before;if(e.regenTotal>=3){log("renewal_regenerated",{enemy_id:e.id,amount:round(e.regenTotal),hp:round(e.hp)});e.regenTotal=0}}if(e.kind==="brood"&&e.spawnsLeft>0){e.spawnCd-=dt;if(e.spawnCd<=0){e.spawnCd=2.35;e.spawnsLeft--;const a=Math.random()*Math.PI*2;spawnEnemy("mote",clamp(e.x+Math.cos(a)*.055,.04,.96),clamp(e.y+Math.sin(a)*.055,.04,.96),true);log("brood_spawned",{brood_id:e.id,remaining_spawns:e.spawnsLeft})}}const[dx,dy]=norm(state.player.x-e.x,state.player.y-e.y),d=ENEMIES[e.kind];e.vx+=dx*d.speed*4.5*dt;e.vy+=dy*d.speed*4.5*dt;const drag=Math.pow(.12,dt);e.vx*=drag;e.vy*=drag;const s=Math.hypot(e.vx,e.vy);if(s>d.speed){e.vx*=d.speed/s;e.vy*=d.speed/s}e.x=clamp(e.x+e.vx*dt,e.radius,1-e.radius);e.y=clamp(e.y+e.vy*dt,e.radius,1-e.radius);if(dist(e,state.player)<e.radius+.022)damagePlayer(e)}for(let i=0;i<state.enemies.length;i++){const a=state.enemies[i];if(a.removed)continue;for(let j=i+1;j<state.enemies.length;j++){const b=state.enemies[j];if(b.removed)continue;const dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||.001,min=a.radius+b.radius;if(d>=min)continue;const o=(min-d)/2,nx=dx/d,ny=dy/d;a.x-=nx*o;a.y-=ny*o;b.x+=nx*o;b.y+=ny*o}}state.enemies=state.enemies.filter(e=>!e.removed)}
function updateBullets(dt){for(const b of state.bullets){if(b.removed)continue;b.life-=dt;if(b.targetId&&b.kind!=="primary"){let t=state.enemies.find(e=>e.id===b.targetId&&!e.removed);if(!t){t=b.kind==="lance"?healthiest():nearest(b,1,.8)[0];b.targetId=t?.id||null}if(t){const[dx,dy]=norm(t.x-b.x,t.y-b.y);b.vx+=dx*1.3*dt;b.vy+=dy*1.3*dt;const targetSpeed=b.kind==="lance"?.65:.48,s=Math.hypot(b.vx,b.vy)||1;b.vx=b.vx/s*targetSpeed;b.vy=b.vy/s*targetSpeed}}b.x+=b.vx*dt;b.y+=b.vy*dt;if(b.life<=0||b.x<-.05||b.x>1.05||b.y<-.05||b.y>1.05){b.removed=true;continue}for(const e of state.enemies)if(!e.removed&&dist(b,e)<=b.radius+e.radius){hit(e,b);break}}state.bullets=state.bullets.filter(b=>!b.removed)} function updateBullets(dt){for(const b of state.bullets){if(b.removed)continue;b.life-=dt;if(b.targetId&&b.kind!=="primary"){let t=state.enemies.find(e=>e.id===b.targetId&&!e.removed);if(!t){t=b.kind==="lance"?healthiest():nearest(b,1,.8)[0];b.targetId=t?.id||null}if(t){const[dx,dy]=norm(t.x-b.x,t.y-b.y);b.vx+=dx*1.3*dt;b.vy+=dy*1.3*dt;const targetSpeed=b.kind==="lance"?.65:.48,s=Math.hypot(b.vx,b.vy)||1;b.vx=b.vx/s*targetSpeed;b.vy=b.vy/s*targetSpeed}}b.x+=b.vx*dt;b.y+=b.vy*dt;if(b.life<=0||b.x<-.05||b.x>1.05||b.y<-.05||b.y>1.05){b.removed=true;continue}for(const e of state.enemies)if(!e.removed&&dist(b,e)<=b.radius+e.radius){hit(e,b);break}}state.bullets=state.bullets.filter(b=>!b.removed)}
function updateEffects(dt){for(const p of state.particles){p.life-=dt;p.x+=p.vx*dt;p.y+=p.vy*dt;p.vx*=Math.pow(.12,dt);p.vy*=Math.pow(.12,dt)}for(const e of state.effects)e.life-=dt;state.particles=state.particles.filter(p=>p.life>0);state.effects=state.effects.filter(e=>e.life>0)} function updateEffects(dt){for(const p of state.particles){p.life-=dt;p.x+=p.vx*dt;p.y+=p.vy*dt;p.vx*=Math.pow(.12,dt);p.vy*=Math.pow(.12,dt)}for(const e of state.effects)e.life-=dt;state.particles=state.particles.filter(p=>p.life>0);state.effects=state.effects.filter(e=>e.life>0)}
function update(dt){if(!state.active)return;state.runTime+=dt;state.waveTime+=dt;state.snapshot+=dt;updatePlayer(dt);updateEnemies(dt);updateBullets(dt);updateEffects(dt);if(!state.enemies.length){state.clearDelay+=dt;if(state.clearDelay>.6)clearWave()}if(state.snapshot>=5){state.snapshot=0;log("state_snapshot",{player:[round(state.player.x),round(state.player.y)],health:round(state.player.health),remaining:state.enemies.length,projectiles:state.bullets.length,secondary_budget:state.secondaryBudget,build:build(),actions:state.actions})}updateUI()} function update(dt){if(!state.active)return;state.runTime+=dt;state.waveTime+=dt;state.snapshot+=dt;updatePlayer(dt);updateEnemies(dt);updateBullets(dt);updateEffects(dt);if(!state.enemies.length){state.clearDelay+=dt;if(state.clearDelay>.6)clearWave()}if(state.snapshot>=5){state.snapshot=0;const capabilityState=state.enemies.filter(e=>e.kind==="ward"||e.kind==="renewal").map(e=>({id:e.id,kind:e.kind,hp:round(e.hp),ward:e.kind==="ward"?e.ward:undefined}));log("state_snapshot",{player:[round(state.player.x),round(state.player.y)],health:round(state.player.health),remaining:state.enemies.length,projectiles:state.bullets.length,secondary_budget:state.secondaryBudget,build:build(),capability_enemies:capabilityState,actions:state.actions})}updateUI()}
function burst(x,y,color,count){for(let i=0;i<count;i++){const a=Math.random()*Math.PI*2,s=.03+Math.random()*.1;state.particles.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,color,life:.18+Math.random()*.3,maxLife:.48,radius:.002+Math.random()*.003})}} function burst(x,y,color,count){for(let i=0;i<count;i++){const a=Math.random()*Math.PI*2,s=.03+Math.random()*.1;state.particles.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,color,life:.18+Math.random()*.3,maxLife:.48,radius:.002+Math.random()*.003})}}
function effect(kind,x,y){state.effects.push({kind,x,y,life:.22,maxLife:.22})}function lineEffect(kind,a,b){state.effects.push({kind,x:a.x,y:a.y,tx:b.x,ty:b.y,life:.16,maxLife:.16})} function effect(kind,x,y){state.effects.push({kind,x,y,life:.22,maxLife:.22})}function lineEffect(kind,a,b){state.effects.push({kind,x:a.x,y:a.y,tx:b.x,ty:b.y,life:.16,maxLife:.16})}
const screen=(x,y)=>[state.render.ox+x*state.render.size,state.render.oy+y*state.render.size]; const screen=(x,y)=>[state.render.ox+x*state.render.size,state.render.oy+y*state.render.size];
function draw(){const dpr=devicePixelRatio||1,w=canvas.clientWidth,h=canvas.clientHeight;if(canvas.width!==Math.round(w*dpr)||canvas.height!==Math.round(h*dpr)){canvas.width=Math.round(w*dpr);canvas.height=Math.round(h*dpr)}ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,w,h);const size=Math.max(100,Math.min(w-38,h-38));state.render={size,ox:(w-size)/2,oy:(h-size)/2};ctx.fillStyle="#081219";ctx.fillRect(state.render.ox,state.render.oy,size,size);ctx.strokeStyle="#182c34";ctx.lineWidth=1;for(let i=1;i<10;i++){const p=i/10*size;ctx.beginPath();ctx.moveTo(state.render.ox+p,state.render.oy);ctx.lineTo(state.render.ox+p,state.render.oy+size);ctx.stroke();ctx.beginPath();ctx.moveTo(state.render.ox,state.render.oy+p);ctx.lineTo(state.render.ox+size,state.render.oy+p);ctx.stroke()}ctx.strokeStyle="#36515b";ctx.lineWidth=2;ctx.strokeRect(state.render.ox,state.render.oy,size,size);for(const e of state.effects)drawEffect(e);for(const b of state.bullets){const[x,y]=screen(b.x,b.y);ctx.fillStyle=b.kind==="primary"?"#f5f8df":b.kind==="fragment"?COLORS.cyan:b.kind==="spark"?COLORS.mint:COLORS.rose;ctx.shadowColor=ctx.fillStyle;ctx.shadowBlur=9;ctx.beginPath();ctx.arc(x,y,Math.max(2.5,b.radius*size),0,Math.PI*2);ctx.fill();ctx.shadowBlur=0}for(const e of state.enemies)drawEnemy(e);for(const p of state.particles){const[x,y]=screen(p.x,p.y);ctx.globalAlpha=clamp(p.life/p.maxLife,0,1);ctx.fillStyle=p.color;ctx.beginPath();ctx.arc(x,y,p.radius*size,0,Math.PI*2);ctx.fill();ctx.globalAlpha=1}drawPlayer()} function draw(){const dpr=devicePixelRatio||1,w=canvas.clientWidth,h=canvas.clientHeight;if(canvas.width!==Math.round(w*dpr)||canvas.height!==Math.round(h*dpr)){canvas.width=Math.round(w*dpr);canvas.height=Math.round(h*dpr)}ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,w,h);const size=Math.max(100,Math.min(w-38,h-38));state.render={size,ox:(w-size)/2,oy:(h-size)/2};ctx.fillStyle="#081219";ctx.fillRect(state.render.ox,state.render.oy,size,size);ctx.strokeStyle="#182c34";ctx.lineWidth=1;for(let i=1;i<10;i++){const p=i/10*size;ctx.beginPath();ctx.moveTo(state.render.ox+p,state.render.oy);ctx.lineTo(state.render.ox+p,state.render.oy+size);ctx.stroke();ctx.beginPath();ctx.moveTo(state.render.ox,state.render.oy+p);ctx.lineTo(state.render.ox+size,state.render.oy+p);ctx.stroke()}ctx.strokeStyle="#36515b";ctx.lineWidth=2;ctx.strokeRect(state.render.ox,state.render.oy,size,size);for(const e of state.effects)drawEffect(e);for(const b of state.bullets){const[x,y]=screen(b.x,b.y);ctx.fillStyle=b.kind==="primary"?"#f5f8df":b.kind==="fragment"?COLORS.cyan:b.kind==="spark"?COLORS.mint:COLORS.rose;ctx.shadowColor=ctx.fillStyle;ctx.shadowBlur=9;ctx.beginPath();ctx.arc(x,y,Math.max(2.5,b.radius*size),0,Math.PI*2);ctx.fill();ctx.shadowBlur=0}for(const e of state.enemies)drawEnemy(e);for(const p of state.particles){const[x,y]=screen(p.x,p.y);ctx.globalAlpha=clamp(p.life/p.maxLife,0,1);ctx.fillStyle=p.color;ctx.beginPath();ctx.arc(x,y,p.radius*size,0,Math.PI*2);ctx.fill();ctx.globalAlpha=1}drawPlayer()}
function drawEnemy(e){const[x,y]=screen(e.x,e.y),r=e.radius*state.render.size;ctx.save();ctx.translate(x,y);ctx.fillStyle=e.flash?"#fff":ENEMIES[e.kind].color;ctx.shadowColor=ENEMIES[e.kind].color;ctx.shadowBlur=8;ctx.beginPath();const sides=e.kind==="mote"?0:e.kind==="husk"?6:e.kind==="titan"?4:8;if(!sides)ctx.arc(0,0,r,0,Math.PI*2);else for(let i=0;i<sides;i++){const a=i/sides*Math.PI*2,px=Math.cos(a)*r,py=Math.sin(a)*r;i?ctx.lineTo(px,py):ctx.moveTo(px,py)}ctx.closePath();ctx.fill();ctx.shadowBlur=0;ctx.strokeStyle="#081015";ctx.lineWidth=2;ctx.stroke();if(e.hp<e.maxHp){ctx.fillStyle="#1b2b30";ctx.fillRect(-r,r+5,r*2,3);ctx.fillStyle=COLORS.mint;ctx.fillRect(-r,r+5,r*2*clamp(e.hp/e.maxHp,0,1),3)}if(e.focusHits){ctx.strokeStyle=COLORS.amber;ctx.lineWidth=2;ctx.beginPath();ctx.arc(0,0,r+4,-Math.PI/2,-Math.PI/2+Math.PI*2*e.focusHits/Math.max(2,6-state.upgrades.focus));ctx.stroke()}ctx.restore()} function drawEnemy(e){const[x,y]=screen(e.x,e.y),r=e.radius*state.render.size;ctx.save();ctx.translate(x,y);ctx.fillStyle=e.flash?"#fff":ENEMIES[e.kind].color;ctx.shadowColor=ENEMIES[e.kind].color;ctx.shadowBlur=8;ctx.beginPath();const sides=e.kind==="mote"?0:e.kind==="husk"?6:e.kind==="titan"?4:8;if(!sides)ctx.arc(0,0,r,0,Math.PI*2);else for(let i=0;i<sides;i++){const a=i/sides*Math.PI*2,px=Math.cos(a)*r,py=Math.sin(a)*r;i?ctx.lineTo(px,py):ctx.moveTo(px,py)}ctx.closePath();ctx.fill();ctx.shadowBlur=0;ctx.strokeStyle="#081015";ctx.lineWidth=2;ctx.stroke();if(e.kind==="ward"&&e.ward>0){ctx.strokeStyle="#b8e6ff";ctx.lineWidth=2.5;ctx.setLineDash([3,2]);ctx.beginPath();ctx.arc(0,0,r+6,0,Math.PI*2*e.ward/e.wardMax);ctx.stroke();ctx.setLineDash([])}if(e.kind==="renewal"&&e.hp<e.maxHp){ctx.strokeStyle="#c8ffae";ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(-4,0);ctx.lineTo(4,0);ctx.moveTo(0,-4);ctx.lineTo(0,4);ctx.stroke()}if(e.hp<e.maxHp){ctx.fillStyle="#1b2b30";ctx.fillRect(-r,r+5,r*2,3);ctx.fillStyle=COLORS.mint;ctx.fillRect(-r,r+5,r*2*clamp(e.hp/e.maxHp,0,1),3)}if(e.focusHits){ctx.strokeStyle=COLORS.amber;ctx.lineWidth=2;ctx.beginPath();ctx.arc(0,0,r+4,-Math.PI/2,-Math.PI/2+Math.PI*2*e.focusHits/Math.max(2,6-state.upgrades.focus));ctx.stroke()}ctx.restore()}
function drawPlayer(){const[x,y]=screen(state.player.x,state.player.y),r=.021*state.render.size,[ax,ay]=norm(state.pointer.x-state.player.x,state.pointer.y-state.player.y);ctx.save();ctx.translate(x,y);ctx.rotate(Math.atan2(ay,ax));ctx.globalAlpha=state.player.inv&&Math.floor(state.player.inv*16)%2?.35:1;ctx.fillStyle=COLORS.mint;ctx.shadowColor=COLORS.mint;ctx.shadowBlur=12;ctx.beginPath();ctx.moveTo(r*1.35,0);ctx.lineTo(-r*.8,r*.8);ctx.lineTo(-r*.52,0);ctx.lineTo(-r*.8,-r*.8);ctx.closePath();ctx.fill();ctx.restore()} function drawPlayer(){const[x,y]=screen(state.player.x,state.player.y),r=.021*state.render.size,[ax,ay]=norm(state.pointer.x-state.player.x,state.pointer.y-state.player.y);ctx.save();ctx.translate(x,y);ctx.rotate(Math.atan2(ay,ax));ctx.globalAlpha=state.player.inv&&Math.floor(state.player.inv*16)%2?.35:1;ctx.fillStyle=COLORS.mint;ctx.shadowColor=COLORS.mint;ctx.shadowBlur=12;ctx.beginPath();ctx.moveTo(r*1.35,0);ctx.lineTo(-r*.8,r*.8);ctx.lineTo(-r*.52,0);ctx.lineTo(-r*.8,-r*.8);ctx.closePath();ctx.fill();ctx.restore()}
function drawEffect(e){const a=clamp(e.life/e.maxLife,0,1);ctx.save();ctx.globalAlpha=a;if(e.tx!==undefined){const[x,y]=screen(e.x,e.y),[tx,ty]=screen(e.tx,e.ty);ctx.strokeStyle=e.kind==="arc"?COLORS.violet:COLORS.rose;ctx.lineWidth=3;ctx.shadowColor=ctx.strokeStyle;ctx.shadowBlur=9;ctx.beginPath();ctx.moveTo(x,y);ctx.lineTo((x+tx)/2+(Math.random()-.5)*10,(y+ty)/2+(Math.random()-.5)*10);ctx.lineTo(tx,ty);ctx.stroke()}else{const[x,y]=screen(e.x,e.y);ctx.strokeStyle=e.kind==="bloom"?COLORS.mint:e.kind==="fork"?COLORS.cyan:e.kind==="focus"?COLORS.amber:COLORS.rose;ctx.lineWidth=2;ctx.beginPath();ctx.arc(x,y,(1-a)*.05*state.render.size+4,0,Math.PI*2);ctx.stroke()}ctx.restore()} function drawEffect(e){const a=clamp(e.life/e.maxLife,0,1);ctx.save();ctx.globalAlpha=a;if(e.tx!==undefined){const[x,y]=screen(e.x,e.y),[tx,ty]=screen(e.tx,e.ty);ctx.strokeStyle=e.kind==="arc"?COLORS.violet:COLORS.rose;ctx.lineWidth=3;ctx.shadowColor=ctx.strokeStyle;ctx.shadowBlur=9;ctx.beginPath();ctx.moveTo(x,y);ctx.lineTo((x+tx)/2+(Math.random()-.5)*10,(y+ty)/2+(Math.random()-.5)*10);ctx.lineTo(tx,ty);ctx.stroke()}else{const[x,y]=screen(e.x,e.y);ctx.strokeStyle=e.kind==="bloom"?COLORS.mint:e.kind==="fork"?COLORS.cyan:e.kind==="focus"?COLORS.amber:COLORS.rose;ctx.lineWidth=2;ctx.beginPath();ctx.arc(x,y,(1-a)*.05*state.render.size+4,0,Math.PI*2);ctx.stroke()}ctx.restore()}
function updateUI(){const ids=Object.keys(EXPEDITIONS),idx=ids.indexOf(state.expedition),ex=EXPEDITIONS[state.expedition],total=ex.waves.length;$("#expedition-kicker").textContent=`EXPEDITION ${String(idx+1).padStart(2,"0")} · FIELD ${state.wave+1} OF ${total}`;$("#expedition-name").textContent=ex.name;$("#expedition-copy").textContent=ex.copy;$("#wave").textContent=`${state.wave+1} / ${total}`;$("#remaining").textContent=String(state.enemies.length);$("#health").textContent=Array.from({length:6},(_,i)=>i<Math.ceil(state.player.health)?"●":"○").join(" ");const s=Math.floor(state.runTime);$("#run-time").textContent=`${Math.floor(s/60)}:${String(s%60).padStart(2,"0")}`;const forecast=$("#forecast-list");forecast.replaceChildren();for(const[k,v]of Object.entries(ex.waves[state.wave])){const d=ENEMIES[k],el=document.createElement("div");el.className="population";el.innerHTML=`<i style="color:${d.color}">${d.icon}</i><b>${v}</b> ${d.name}`;forecast.append(el)}const box=$("#build-list");box.replaceChildren();const selected=Object.entries(state.upgrades).filter(([,v])=>v);if(!selected.length){const p=document.createElement("p");p.className="empty";p.textContent="No catalysts selected.";box.append(p)}else for(const[k,v]of selected){const d=MODULES[k],el=document.createElement("div");el.className="build-item";el.innerHTML=`<i style="color:${d.color}">${d.icon}</i><span><b>${d.name}</b><small>${d.short}</small></span><em>LV ${v}</em>`;box.append(el)}} function updateUI(){const ids=Object.keys(EXPEDITIONS),idx=ids.indexOf(state.expedition),ex=EXPEDITIONS[state.expedition],total=ex.waves.length;$("#expedition-kicker").textContent=`EXPEDITION ${String(idx+1).padStart(2,"0")} · FIELD ${state.wave+1} OF ${total}`;$("#expedition-name").textContent=ex.name;$("#expedition-copy").textContent=ex.copy;$("#wave").textContent=`${state.wave+1} / ${total}`;$("#remaining").textContent=String(state.enemies.length);$("#health").textContent=Array.from({length:6},(_,i)=>i<Math.ceil(state.player.health)?"●":"○").join(" ");const s=Math.floor(state.runTime);$("#run-time").textContent=`${Math.floor(s/60)}:${String(s%60).padStart(2,"0")}`;const forecast=$("#forecast-list");forecast.replaceChildren();for(const[k,v]of Object.entries(ex.waves[state.wave])){const d=ENEMIES[k],el=document.createElement("div");el.className="population";el.innerHTML=`<i style="color:${d.color}">${d.icon}</i><b>${v}</b> ${d.name}`;forecast.append(el)}const box=$("#build-list");box.replaceChildren();const selected=Object.entries(state.upgrades).filter(([,v])=>v);if(!selected.length){const p=document.createElement("p");p.className="empty";p.textContent="No catalysts selected.";box.append(p)}else for(const[k,v]of selected){const d=MODULES[k],el=document.createElement("div");el.className="build-item";el.innerHTML=`<i style="color:${d.color}">${d.icon}</i><span><b>${d.name}</b><small>${d.short}</small></span><em>LV ${v}</em>`;box.append(el)}}
function showBanner(text){const b=$("#banner");b.textContent=text;b.classList.add("show");clearTimeout(state.bannerTimer);state.bannerTimer=setTimeout(()=>b.classList.remove("show"),1000)}function toast(text){const t=$("#toast");t.textContent=text;t.classList.add("show");clearTimeout(state.toastTimer);state.toastTimer=setTimeout(()=>t.classList.remove("show"),3300)} function showBanner(text){const b=$("#banner");b.textContent=text;b.classList.add("show");clearTimeout(state.bannerTimer);state.bannerTimer=setTimeout(()=>b.classList.remove("show"),1000)}function toast(text){const t=$("#toast");t.textContent=text;t.classList.add("show");clearTimeout(state.toastTimer);state.toastTimer=setTimeout(()=>t.classList.remove("show"),3300)}
async function save(){log("session_saved",{completed:[...state.completed],current_build:build(),actions:state.actions,event_count_before_save:state.logs.length});const body=state.logs.join("\n")+"\n",filename=`catalyst-ecology-${state.session}.jsonl`;try{const r=await fetch("/api/playtest-log",{method:"POST",headers:{"Content-Type":"application/x-ndjson","X-Playtest-Filename":filename},body});if(!r.ok)throw Error(r.status);const j=await r.json();toast(`Saved ${j.events} events to ${j.path}`)}catch(_){const blob=new Blob([body],{type:"application/x-ndjson"}),a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download=filename;a.click();URL.revokeObjectURL(a.href);toast(`Server unavailable; downloaded ${filename}`)}} async function save(){log("session_saved",{completed:[...state.completed],current_build:build(),actions:state.actions,event_count_before_save:state.logs.length});const body=state.logs.join("\n")+"\n",filename=`${CONFIG.filename}-${state.session}.jsonl`;try{const r=await fetch("/api/playtest-log",{method:"POST",headers:{"Content-Type":"application/x-ndjson","X-Playtest-Filename":filename},body});if(!r.ok)throw Error(r.status);const j=await r.json();toast(`Saved ${j.events} events to ${j.path}`)}catch(_){const blob=new Blob([body],{type:"application/x-ndjson"}),a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download=filename;a.click();URL.revokeObjectURL(a.href);toast(`Server unavailable; downloaded ${filename}`)}}
function pointer(e){const r=canvas.getBoundingClientRect();state.pointer.x=clamp((e.clientX-r.left-state.render.ox)/state.render.size,0,1);state.pointer.y=clamp((e.clientY-r.top-state.render.oy)/state.render.size,0,1)} function pointer(e){const r=canvas.getBoundingClientRect();state.pointer.x=clamp((e.clientX-r.left-state.render.ox)/state.render.size,0,1);state.pointer.y=clamp((e.clientY-r.top-state.render.oy)/state.render.size,0,1)}
canvas.onpointermove=pointer;canvas.onpointerdown=e=>{pointer(e);if(e.button===0){state.pointer.firing=true;fire()}e.preventDefault();canvas.focus({preventScroll:true})};addEventListener("pointerup",e=>{if(e.button===0)state.pointer.firing=false});canvas.oncontextmenu=e=>e.preventDefault();addEventListener("keydown",e=>{if(KEYS[e.code]){state.keys.add(e.code);e.preventDefault()}});addEventListener("keyup",e=>state.keys.delete(e.code));addEventListener("blur",()=>{state.keys.clear();state.pointer.firing=false});document.addEventListener("visibilitychange",()=>{if(state.started)log("visibility_changed",{hidden:document.hidden})}); canvas.onpointermove=pointer;canvas.onpointerdown=e=>{pointer(e);if(e.button===0){state.pointer.firing=true;fire()}e.preventDefault();canvas.focus({preventScroll:true})};addEventListener("pointerup",e=>{if(e.button===0)state.pointer.firing=false});canvas.oncontextmenu=e=>e.preventDefault();addEventListener("keydown",e=>{if(KEYS[e.code]){state.keys.add(e.code);e.preventDefault()}});addEventListener("keyup",e=>state.keys.delete(e.code));addEventListener("blur",()=>{state.keys.clear();state.pointer.firing=false});document.addEventListener("visibilitychange",()=>{if(state.started)log("visibility_changed",{hidden:document.hidden})});
$$('.expedition').forEach(b=>b.onclick=()=>selectExpedition(b.dataset.expedition));$("#begin").onclick=begin;$("#restart").onclick=()=>state.started?startRun(state.expedition,"restart_button"):begin();$("#save").onclick=save;$("#complete-save").onclick=save;$("#next-expedition").onclick=e=>startRun(e.currentTarget.dataset.expedition,"complete_next");$("#replay").onclick=()=>startRun(state.expedition,"complete_replay"); $$('.expedition').forEach(b=>b.onclick=()=>selectExpedition(b.dataset.expedition));$("#begin").onclick=begin;$("#restart").onclick=()=>state.started?startRun(state.expedition,"restart_button"):begin();$("#save").onclick=save;$("#complete-save").onclick=save;$("#next-expedition").onclick=e=>startRun(e.currentTarget.dataset.expedition,"complete_next");$("#replay").onclick=()=>startRun(state.expedition,"complete_replay");

View file

@ -1,6 +1,6 @@
# Experiment 008 Revision 2 Preliminary Analysis — Session 7b7c7a92 # Experiment 008 Revision 2 Preliminary Analysis — Session 7b7c7a92
Status: telemetry and brief initial report inspected; targeted follow-up pending. Status: complete.
Source: `JSONL/catalyst-ecology-7b7c7a92-42cb-4ab2-8a81-d1316ea972c5.jsonl` Source: `JSONL/catalyst-ecology-7b7c7a92-42cb-4ab2-8a81-d1316ea972c5.jsonl`
@ -77,3 +77,34 @@ No replay followed the clearer mature exposure. As always, this is ambiguous: th
2. During fields five through eight, did any interaction become surprising, satisfying, or fun rather than merely easier to read? When, if at all, did the extra fields become repetition? 2. During fields five through eight, did any interaction become surprising, satisfying, or fun rather than merely easier to read? When, if at all, did the extra fields become repetition?
3. Did the results suggest a specific alternative build worth replaying, and did the four-slot limit now feel productive or still premature/restrictive? 3. Did the results suggest a specific alternative build worth replaying, and did the four-slot limit now feel productive or still premature/restrictive?
## Player Report
Build selection combined remembered knowledge from the first 008 run with some intuitive experimentation. Fork was chosen across all three expeditions because it seemed like a generally decent way to put more projectiles on screen. The player also observed what appeared to be double or triple hits on large bodies, reducing the number of primary shots needed to kill them.
The additional mature fields permitted a small amount of tactical refinement. The player developed a consistent movement strategy in each expedition. They did not report a large strategic change or a new build interaction that demanded another run.
Power-up selection felt more meaningful than in revision 1, but not significantly so. The player believed essentially any four-module combination—and possibly no modules—would still complete the encounters. Because every choice was simply beneficial under permissive combat, the four-choice cap did not feel like either a productive tradeoff or a frustrating restriction. It was strategically inert.
## Revised Interpretation
Revision 2 repaired observation time but exposed a second measurement problem: the problem space did not discriminate among compositions. A component budget matters only when inclusion changes credible capabilities and exclusion creates a relevant limitation. Here baseline output appeared sufficient, no run failed, damage was negligible, and later population growth never forced a build-specific response.
This does not mean higher difficulty is automatically the answer. Raising health/count until weak builds fail could create compulsory throughput and repeat Experiment 002's pressure-driven persistence. The more useful conclusion is:
> Compositional choices become meaningful when their causal differences change what the player can effectively do—not merely how many additional effects appear while every route already succeeds.
Fork may be a reusable abstraction rather than a whole answer. It increased the hit surface available to different consumers and appeared to overlap larger targets. But it may also simply be overtuned generic throughput. The player's causal interpretation of multi-hits is report evidence; telemetry confirms many fragment hits but does not resolve whether multiple fragments from one impact hit the same large target as perceived.
The consistent movement strategies show that longer exposure supported execution learning. They do not establish fun: players can optimize serviceable controls while completing an experiment. One final report distinction should establish whether movement/build refinement itself was enjoyable or merely the clearest available way to finish.
## Final Enjoyment Distinction
The player describes the building as **enjoyable but meaningless**. This is the clearest summary of 008 and resolves the remaining ambiguity.
The positive activity was real: selecting causal components, seeing more projectiles and trigger chains, and learning how a completed build behaved had some intrinsic enjoyment. The negative was not implementation friction or lack of legibility after revision 2. The environment placed so little demand on capability that the chosen architecture did not determine success, access, recovery, or a valued payoff. Refinement therefore lacked weight.
This is stronger and more specific than saying the game was easy. Difficulty matters only insofar as it makes different capabilities consequential. More enemy health alone could preserve the same universal throughput problem. The next experiment should use enemy capabilities that outgrow baseline fire through several possible causal routes, while exact qualitative choices continue over one escalating run.
Experiment 008 is closed. Do not add more fields or tune the same expeditions again. Its contribution is:
> Building can be enjoyable for this player, but it becomes meaningless when the problem is insensitive to what was built.

View file

@ -0,0 +1,22 @@
# Experiment 009 — Catalyst Ascent
Build one emitter through a ten-field ascent. The first seven fields each end with an exact catalyst choice; the complete build remains active for the last three. Later populations acquire capabilities that change what sustained fire alone can accomplish.
Run from the repository root:
```bash
./experiments/009_catalyst_ascent/run.sh
```
Then open <http://127.0.0.1:8000/experiments/009_catalyst_ascent/prototype/>.
Controls:
- Move with `WASD` or the arrow keys.
- Aim with the mouse.
- Hold the left mouse button to fire.
- Choose one catalyst after each of the first seven fields.
A failed field restores with the current build. Play until you feel ready to stop, then click **Save JSONL**. Replaying with another build is optional.
Please play before reading [`hypothesis.md`](hypothesis.md).

View file

@ -0,0 +1,72 @@
# Experiment 009 Hypothesis — Private Until After Play
## Question
Does an enjoyable qualitative build become meaningful when later problems are sensitive to the capabilities it creates, or does added resistance merely turn a pleasant toy into compulsory throughput work?
## Why This Experiment
Experiment 008 separated two judgments that earlier tests often blurred: the player enjoyed building, but the build felt meaningless. Longer mature fields made the effects and modest movement refinements easier to see, yet the player believed nearly any selection—or no selection—could clear every population. The four-choice cap therefore excluded options on paper without producing a felt tradeoff.
The likely issue is not simply low difficulty. More enemy health could make upgrades mandatory while leaving every build interchangeable. Catalyst Ascent instead retains the exact six-component causal vocabulary and introduces qualitative enemy capabilities over one continuous run:
- **Wards** absorb eight hits inside a short opening window, reset partial progress, and later re-form. Focus ruptures and Conduit lances can breach them directly, while sufficiently dense products can strip them.
- **Renewals** continuously regenerate, rewarding concentrated damage, chained effects, or amplified secondary output rather than intermittent baseline fire.
- **Broods** keep creating small bodies, potentially feeding kill-triggered Bloom while increasing pressure if ignored.
The player chooses after fields one through seven, then carries the complete build through fields eight through ten. This preserves the positive authorship and visible cascade from 007/008 while allowing the environment to outgrow baseline capabilities. Failures restore the current field and build so execution mistakes do not erase the construction history.
## Competing Interpretations
1. Capability-sensitive enemies make construction both enjoyable and instrumentally meaningful; the player anticipates what the build can handle, notices a limitation, and adapts through one of several causal routes.
2. Escalation compels continued play but creates no additional curiosity, repeating Experiment 002's distinction between persistence and reasoning.
3. Ward, Renewal, and Brood labels prescribe obvious counters, reducing choices to loadout replacement as in Experiment 003.
4. The exact qualitative effects and visible power trajectory sustain interest, but enemy capabilities add little; spectacle and composition remain sufficient without consequential exclusion.
5. One universally dense effect network defeats every capability, so the experiment again measures throughput rather than meaningful construction.
6. The environment finally makes a choice consequential, but a mistaken build creates a soft lock or feels punitive rather than recoverable.
7. The player enjoys selecting and watching a build but still does not care whether it succeeds; capability relevance alone does not supply valued stakes.
## Evidence Priorities
Strong evidence:
- anticipating a later capability while choosing, then confirming or revising that prediction;
- noticing that the current build changes which target, firing pattern, or movement strategy is viable;
- identifying two causally different routes through the same enemy capability;
- wanting to replay specifically to test a different route, rather than merely to finish or maximize a number;
- a limitation generating a new build question without making the current run feel wasted;
- the complete build continuing to reveal consequences in fields eight through ten.
Ambiguous evidence:
- completing all ten fields;
- dying, clearing faster, or selecting seven upgrades;
- using the textual Ward/Focus or Ward/Conduit relationship;
- long play caused by durable enemies;
- large effect counts or spectacular screen clearing without a reported or behavioral question.
Failure evidence:
- one obvious package answers every field;
- enemies merely take longer to kill;
- a capability is unreadable or is confused with ordinary health;
- the player feels forced to take a named counter with no meaningful alternative;
- a mistaken build makes progress impossible and recovery requires restarting the whole run;
- building remains enjoyable but still feels unrelated to anything worth accomplishing.
## Analysis Guardrails
- Treat the player's “meaningless” report as evidence, not a final diagnosis. Test whether consequence sensitivity is the missing cause rather than assuming difficulty is the answer.
- Do not infer meaning from necessity alone. A compulsory upgrade can be strategically empty.
- Do not use clear rate, deaths, duration, or upgrade count as stand-alone proxies for fun.
- Separate anticipation, observed causal attribution, revised understanding, and post-hoc explanation in both telemetry and questioning.
- A selected counter is only meaningful evidence if the player perceived alternatives or learned why it worked.
- Preserve the possibility that building is intrinsically enjoyable while the shooter objective remains emotionally irrelevant.
## Feedback Questions
Ask only after reviewing telemetry:
1. When, if ever, did the build start to feel like it enabled something baseline fire could not? What did they believe caused that difference?
2. Did any enemy capability change a choice or tactic, and did it feel like a problem with multiple possible answers or a prescribed counter?
3. Did a limitation create another build they wanted to try? At what point were they ready to stop?

View file

@ -0,0 +1,39 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Catalyst Ascent — Experiment 009</title>
<link rel="stylesheet" href="/experiments/008_catalyst_ecology/prototype/style.css">
</head>
<body>
<header>
<div class="title"><span>EXPERIMENT 009</span><h1>Catalyst Ascent</h1></div>
<nav aria-label="Run selection">
<button class="expedition selected" data-expedition="ascent"><span>01</span> Ascent</button>
</nav>
<div class="top-actions"><button id="restart">Restart run</button><button id="save">Save JSONL</button></div>
</header>
<main>
<aside>
<section class="brief"><span id="expedition-kicker">EXPEDITION 01 · FIELD 1 OF 10</span><h2 id="expedition-name">Ascent</h2><p id="expedition-copy"></p></section>
<section class="forecast"><h3>Current population</h3><div id="forecast-list"></div></section>
<section class="controls"><h3>Controls</h3><p><kbd>WASD</kbd><span>Move.</span></p><p><kbd>HOLD LEFT</kbd><span>Fire toward cursor.</span></p></section>
<section class="forecast"><h3>Developing capabilities</h3><div class="population"><i style="color:#75bff2"></i><b>Ward</b> Strip its ring in one burst; rupture and lance breach it.</div><div class="population"><i style="color:#8fe28b"></i><b>Renewal</b> Continuously regenerates health.</div><div class="population"><i style="color:#f18cba"></i><b>Brood</b> Releases fresh motes while alive.</div></section>
<section class="build"><h3>Seven-choice build</h3><div id="build-list"><p class="empty">No catalysts selected.</p></div></section>
<section class="readouts">
<div><span>INTEGRITY</span><b id="health">● ● ● ● ● ●</b></div><div><span>FIELD</span><b id="wave">1 / 10</b></div>
<div><span>REMAINING</span><b id="remaining">0</b></div><div><span>RUN TIME</span><b id="run-time">0:00</b></div>
</section>
<section class="note">All six catalysts remain available at every choice. Repeats stack. A failed field restores locally with the same build.</section>
</aside>
<section class="playfield"><canvas id="field" tabindex="0"></canvas><div id="banner" class="banner"></div><div class="canvas-help">WASD move · aim with mouse · hold left to fire</div></section>
</main>
<div id="start-overlay" class="overlay"><div><span>ONE ESCALATING RUN</span><b>Build into changing resistance.</b><p>Ten fields. Choose after each of the first seven, then use the complete build through three more. Later populations acquire new capabilities. Stop whenever you want.</p><button id="begin">Begin Ascent</button></div></div>
<div id="choice-overlay" class="overlay hidden"><div class="wide"><span>FIELD STABLE</span><b>Choose one catalyst</b><p id="next-forecast"></p><div id="choices"></div></div></div>
<div id="complete-overlay" class="overlay hidden"><div><span>ASCENT COMPLETE</span><b id="complete-title"></b><p>Replay with another build, save, or stop.</p><div class="complete-actions"><button id="next-expedition"></button><button id="replay">Replay this ascent</button><button id="complete-save">Save JSONL</button></div></div></div>
<div id="toast"></div>
<script>window.CATALYST_MODE = "ascent";</script>
<script src="/experiments/008_catalyst_ecology/prototype/app.js"></script>
</body>
</html>

View file

@ -0,0 +1,3 @@
# Experiment 009 Results
No playtest has been analyzed yet.

View file

@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
EXPERIMENT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$EXPERIMENT_DIR/../.." && pwd)"
exec python3 "$REPO_ROOT/tools/playtest_server.py" \
--directory "$REPO_ROOT" \
--log-directory "$REPO_ROOT/JSONL" \
--port 8000

View file

@ -1,22 +1,22 @@
# Agent Handoff — Private Research Notes # Agent Handoff — Private Research Notes
Last updated: 2026-08-17, after the successful Experiment 007 result and validated Experiment 008 implementation. Last updated: 2026-08-18, after the completed Experiment 008 interpretation and validated Experiment 009 implementation.
This file is written so another agent can continue the research program without reconstructing the reasoning. The player intends not to read it before playtesting, to avoid expectation effects. It contains design hypotheses, likely failure interpretations, and things to watch for. This file is written so another agent can continue the research program without reconstructing the reasoning. The player intends not to read it before playtesting, to avoid expectation effects. It contains design hypotheses, likely failure interpretations, and things to watch for.
## Current Handoff Snapshot — Read This First ## Current Handoff Snapshot — Read This First
Date: 2026-08-17. Date: 2026-08-18.
The current playable is **Experiment 008 — Catalyst Ecology revision 2** in `experiments/008_catalyst_ecology/`. Corrective telemetry is inspected and the detailed player report is pending. Experiment 007 is complete and is the first successful probe. The current playable is **Experiment 009 — Catalyst Ascent revision 1** in `experiments/009_catalyst_ascent/`. Experiment 008 is closed: the player enjoyed building but found it meaningless because nearly any build or baseline fire seemed viable. Experiment 009 tests the designer interpretation that construction needs capability-sensitive consequence; it does not assume that more difficulty is the solution.
Run it with: Run it with:
```bash ```bash
./experiments/008_catalyst_ecology/run.sh ./experiments/009_catalyst_ascent/run.sh
``` ```
Then open `http://127.0.0.1:8000`. The custom local server writes validated logs directly to repository `JSONL/` when the player presses **Save JSONL**. Port 8000 was free and all Codex validation processes were stopped at handoff. Then open `http://127.0.0.1:8000/experiments/009_catalyst_ascent/prototype/`. The custom local server writes validated logs directly to repository `JSONL/` when the player presses **Save JSONL**. Validation artifacts were moved out of `JSONL/`; only player logs should remain there. The validation server and Chromium process were stopped at handoff.
### The actual research objective ### The actual research objective
@ -45,9 +45,9 @@ The strongest current inference is not “the player dislikes systems,” automa
The current compact theory is: The current compact theory is:
> A systemic question is more likely to matter when its answer increases agency inside an activity the player already values. Coupling is useful only while it creates selective leverage; indiscriminate consequences can erase good actions rather than create emergence. > Fun can arise from building a causal capability, discovering how components amplify one another, and expressing that understanding as visible power. For the activity to remain meaningful, problems must distinguish capabilities through consequences while still admitting multiple causal routes.
This remains a hypothesis. Experiment 006 deliberately tests the lower layer first: whether immediate, assertive, targeted action has any intrinsic value before adding engineering, progression, world attachment, or multiplayer context. This remains a hypothesis. Experiment 007 supplied the first successful curiosity chain and Experiment 008 isolated enjoyable building from meaningful consequence. Experiment 009 now tests whether capability-sensitive problems join those two qualities, while guarding against the alternate explanation that added resistance merely makes a serviceable shooter compulsory.
### Experiment history in one page ### Experiment history in one page
@ -269,7 +269,7 @@ Revision 2 validation completed three full synthetic runs using the first-sessio
After the corrective playtest, compare mature fields five through eight within each expedition. Ask whether interactions became understandable, whether any full build developed or flattened over those fields, whether a specific alternative build arose, and when the extra exposure shifted from useful observation to repetition. Do not compare total duration directly to revision 1 as enjoyment evidence; revision 2 deliberately contains more fields. After the corrective playtest, compare mature fields five through eight within each expedition. Ask whether interactions became understandable, whether any full build developed or flattened over those fields, whether a specific alternative build arose, and when the extra exposure shifted from useful observation to repetition. Do not compare total duration directly to revision 1 as enjoyment evidence; revision 2 deliberately contains more fields.
### Experiment 008 revision 2 telemetry; follow-up pending ### Experiment 008 revision 2 complete report
The player saved `JSONL/catalyst-ecology-7b7c7a92-42cb-4ab2-8a81-d1316ea972c5.jsonl`; analysis is in `experiments/008_catalyst_ecology/results/7b7c7a92-revision-2-preliminary-analysis.md`. The player saved `JSONL/catalyst-ecology-7b7c7a92-42cb-4ab2-8a81-d1316ea972c5.jsonl`; analysis is in `experiments/008_catalyst_ecology/results/7b7c7a92-revision-2-preliminary-analysis.md`.
@ -281,7 +281,48 @@ All three eight-field expeditions completed first attempt without replay. Builds
Bloom+Arc was no longer universal. Fork appeared in every build but fed different consumers. Mature-build exposure increased to approximately 41 seconds in Shoal, 76 in Bastion, and 56 in Brood. The player says it was “a bit easier to see how my choices impacted my play,” confirming the length correction improved visibility. Bloom+Arc was no longer universal. Fork appeared in every build but fed different consumers. Mature-build exposure increased to approximately 41 seconds in Shoal, 76 in Bastion, and 56 in Brood. The player says it was “a bit easier to see how my choices impacted my play,” confirming the length correction improved visibility.
Telemetry alone cannot distinguish population-aware composition from deliberate experiment coverage. Ask why each build differed, what role Fork played, whether any mature interaction was satisfying/surprising rather than only readable, when extra fields became repetitive, whether a specific alternative build arose, and how the four-slot limit felt after sustained exposure. The player combined memory from revision 1 with intuition rather than following fully planned ecology builds. Fork appeared everywhere because it was a decent general projectile generator and seemed to double/triple-hit large bodies. Mature fields allowed slight refinement into consistent movement strategies.
Power-up choice was only a little more meaningful, not significantly so. The player believed essentially any combination or even no power-ups would remain viable. Because every module was a free benefit and baseline combat was permissive, the four-slot cap created no felt tradeoff. Thus different builds do not strongly validate H23.
The final distinction is: **building was enjoyable but meaningless**. This confirms construction/composition itself had value, while the permissive environment made architecture irrelevant to success. Close 008. Do not add more fields or tune the same expeditions again.
The next higher-information probe should use a single escalating qualitative-build run where baseline output eventually becomes insufficient through capability-sensitive enemies, exact choices continue, and causal combinations can express dramatic power. Prefer several causal routes—hit generation, burst, kill chains, secondary conversion—over labeled one-module counters. Use local wave restart. Guard against mistaking survival-driven continuation for enjoyment, as in 002.
### Experiment 009 implementation and validation
Experiment 009 is implemented as **Catalyst Ascent**. It deliberately reuses the 008 engine and the same six catalysts so the independent change is closer to consequence sensitivity than content novelty. The new page lives in `experiments/009_catalyst_ascent/prototype/`, sets `window.CATALYST_MODE = "ascent"`, and loads the shared 008 stylesheet and application. The shared application defaults to unmodified 008 behavior when that flag is absent.
The run has ten fields. Exact choices occur after fields one through seven, and the completed seven-choice build persists through fields eight through ten. The first three fields establish Motes, Husks, and Broods. Later mixtures introduce:
- **Wards:** eight shield segments must be stripped within a 1.55-second window or partial progress resets. A broken shield reforms after 2.8 seconds if the body remains alive. Focus rupture and Conduit lance bypass the shield and damage the body on the same event. Baseline fire can just barely strip a shield with uninterrupted accurate fire; Fork fragments, Bloom sparks, Arc damage, Focus, and Conduit provide different routes.
- **Renewals:** 30 health and continuous 3.6 health/second regeneration. This favors concentrated output without naming one required component.
- **Broods:** retain the existing bounded Mote spawning, providing both accumulating pressure and possible fuel for kill-triggered Bloom.
Later fields mix those capabilities with Motes, Titans, and one another. This is intended to distinguish hit density, single-target rupture, secondary-hit conversion, kill chains, and secondary amplification. It may instead produce one universal dense-effect network or obvious textual counters; preserve those as live failure interpretations.
Failure restores only the current field with the current build. There is still a possible build-quality recovery limitation: a player who chooses seven levels of a non-producing modifier could make progress extremely difficult and would need **Restart run**. The design mitigates ordinary cases by making baseline shield stripping technically possible and offering all exact options every time, but this has not been playtested for feel. Do not silently reinterpret a hard or tedious field as meaningfulness.
Validation completed on 2026-08-18:
- JavaScript and shell syntax pass.
- The 1672×976 layout was visually inspected. The full field, instructions, capability descriptions, build, and start overlay fit without page scrolling.
- Deterministic Ward validation reduced a shield from 8 to 5, observed it reset to 8 after the opening window, then used Focus to breach the shield and reduce body health from 14 to 9.
- Deterministic Renewal validation damaged one from 30 to 20 and observed it regenerate to approximately 24.32 over 1.2 seconds; `renewal_regenerated` telemetry fired.
- A full synthetic Ascent recorded exactly ten `wave_started`, ten `wave_completed`, seven `choices_shown`, seven `module_chosen`, two `post_build_field_advanced`, and one `run_completed` event. The final test build was Fork 2 / Bloom 1 / Arc 1 / Focus 1 / Conduit 1 / Resonance 1.
- Direct server upload wrote 738 valid revision-1 events to `JSONL/catalyst-ascent-<session>.jsonl`; the validation file was then moved to `/tmp`.
- Regression validation reloaded Experiment 008 without the mode flag and confirmed Shoal, three expedition buttons, eight fields, four choices, three post-build transitions, completion, and `experiment: 008_catalyst_ecology` telemetry.
Expected player logs are `JSONL/catalyst-ascent-<session>.jsonl`.
After the player saves:
1. Inspect run attempts, wave attempts, choice order and deliberation, build at every field, Ward hit/reset/break/reform causes, Renewal regeneration, Brood spawning, module triggers, target kill causes, damage/defeats, restarts, completion, replay, and save.
2. Write a preliminary result under `experiments/009_catalyst_ascent/results/` before asking follow-ups.
3. Separate what the player anticipated when choosing from what they noticed during play and what they inferred only afterward.
4. Ask when the build first enabled something baseline fire did not, whether a capability felt multiply solvable or prescribed, what alternate build—if any—they wanted to try, and when they were ready to stop.
5. Do not infer meaning from necessity alone. A forced counter can be empty; long play can be attrition; completion can be compliance.
6. Preserve the possibility that building remains enjoyable while the combat objective itself remains something the player does not care about.
### Historical Experiment 006 interpretation branches ### Historical Experiment 006 interpretation branches
@ -315,7 +356,7 @@ If port 8000 is occupied after agent validation, inspect with `ss -ltnp 'sport =
- `codex_game_design_research_plan.md` — original research mandate and preference priors. - `codex_game_design_research_plan.md` — original research mandate and preference priors.
- `research/current_model.md` — synthesized current preference model. - `research/current_model.md` — synthesized current preference model.
- `research/hypotheses.md` — H01H23 with evidence and confidence. - `research/hypotheses.md` — H01H24 with evidence and confidence.
- `research/experiment_index.md` — compact experiment history. - `research/experiment_index.md` — compact experiment history.
- `research/agent_handoff.md` — this private operational record. - `research/agent_handoff.md` — this private operational record.
- `experiments/*/hypothesis.md` — per-experiment private intent. - `experiments/*/hypothesis.md` — per-experiment private intent.

View file

@ -1,6 +1,6 @@
# Current Preference Model # Current Preference Model
Status: **updated after Experiment 008's under-length first run; corrective revision 2 is validated and ready** Status: **updated after completed Experiment 008; Experiment 009 is ready to test capability-sensitive consequence**
The leading long-term theory remains that fun may come from learning a compact set of consistent laws, constructing a system from them, and discovering consequences that create further self-directed questions. Experiment 000 did not provide positive evidence: its permissive abstract observations were solved in about three minutes, produced no voluntary experimentation, and did not make phase, timing, or cyclic behavior perceptible or necessary. The leading long-term theory remains that fun may come from learning a compact set of consistent laws, constructing a system from them, and discovering consequences that create further self-directed questions. Experiment 000 did not provide positive evidence: its permissive abstract observations were solved in about three minutes, produced no voluntary experimentation, and did not make phase, timing, or cyclic behavior perceptible or necessary.
@ -100,7 +100,25 @@ The intended test is not whether more modules or enemy types are more entertaini
The first 008 run did not provide enough mature-build exposure to answer that question. The player selected three different builds but reports little planning; Conduit-first was a misunderstanding. More importantly, each fourth selection was followed by only one field. Complete builds existed for about 7 seconds in Shoal, 20 seconds in Bastion, and 22 seconds in Brood. The player repeatedly began to see something cool emerge just as the expedition ended. The first 008 run did not provide enough mature-build exposure to answer that question. The player selected three different builds but reports little planning; Conduit-first was a misunderstanding. More importantly, each fourth selection was followed by only one field. Complete builds existed for about 7 seconds in Shoal, 20 seconds in Bastion, and 22 seconds in Brood. The player repeatedly began to see something cool emerge just as the expedition ended.
This is a structural measurement failure, not evidence against the compositional model. Compared with 007, the module vocabulary and populations grew more complex while mature-build observation time did not. Corrective revision 2 adds three fields to every expedition without changing the four-choice budget or module mechanics. The four selections still occur after fields one through four, and the completed build now persists through fields five through eight. Only after this sustained observation can replay, adaptation, universal cores, or obvious counter-loadouts be interpreted. The first run's structural measurement failure was corrected in revision 2. Mature fields made choices easier to evaluate, produced distinct full builds, and allowed slight movement-strategy refinement. However, the player believed nearly any combination—or even no catalysts—could complete the expeditions. Baseline sufficiency meant every choice was a benefit but no exclusion mattered, so the four-slot limit created no felt tradeoff.
The updated model is:
> Causal composition creates curiosity and power expression, but strategic choice requires the environment to distinguish capabilities. A slot limit alone is meaningless when every included effect helps and every omitted effect is unnecessary.
Do not respond by merely inflating enemy health in the same three expeditions. That could make throughput compulsory without creating new reasoning. The next high-information test should combine 007's exact qualitative growth with a single escalating run whose changing capabilities eventually exceed baseline fire. Continued choices should let the player author a response and reach spectacular power, while telemetry/report distinguish valued build anticipation from survival-driven persistence.
The final 008 report sharpens this further: the player explicitly called the building enjoyable but meaningless. This establishes the first positive statement about construction itself in the project. It also shows why prior quality gradients and slot limits failed: a choice has no weight if the environment is insensitive to its omission.
The leading model is now:
> Fun can arise from building a causal capability, discovering how components amplify one another, and expressing that understanding as visible power. For the activity to remain meaningful, problems must distinguish capabilities through consequences, while still admitting multiple causal routes.
The next probe should be one escalating run rather than three labeled ecology tests. Exact choices should continue so a build can mature and specialize. Later enemies should introduce capabilities—such as re-forming wards, regeneration, and spawning—that baseline fire cannot comfortably answer, but which several combinations can address through hit generation, burst, kill chains, or secondary-hit conversion. Local wave restart should make failure informative rather than erase the run.
Experiment 009 implements this as Catalyst Ascent: one ten-field run, with exact choices after the first seven fields and the mature build retained through the final three. It reuses the six known catalysts so novelty comes from consequence rather than a larger menu. Wards reset partial shield damage unless eight hits land within a short window, while Focus ruptures and Conduit lances breach them; Renewal bodies continuously regenerate; Broods continue to produce Motes. Mixed later fields allow hit density, focused rupture, secondary conversion, kill chains, and amplification to overlap rather than assigning one named counter.
This is deliberately not a generic difficulty test. The key result is whether the player anticipates, notices, and revises a capability relationship—and whether doing so makes the already-enjoyable building feel consequential. Longer play, deaths, clearing all fields, or selecting the textual counter do not answer that question. A possible failure is that pressure merely compels throughput as in 002; another is that the shooter objective remains emotionally meaningless even when construction affects success.
## Methodological Guardrail: Reports Are Evidence, Not Ground Truth ## Methodological Guardrail: Reports Are Evidence, Not Ground Truth

View file

@ -10,4 +10,5 @@
| [005 — Sanctuary Wake](../experiments/005_sanctuary_wake/README.md) | Does a coupled physical tool become enjoyable in a changing, recoverable rescue crisis without stat growth? | Wanted to stop immediately; readable chaos became unmanageable, charge reduced agency, and rescue outcomes inspired no care | H08/H19 down as sufficient; indiscriminate coupling identified as harmful | Is assertive, selective action intrinsically more valuable than custodial management? | | [005 — Sanctuary Wake](../experiments/005_sanctuary_wake/README.md) | Does a coupled physical tool become enjoyable in a changing, recoverable rescue crisis without stat growth? | Wanted to stop immediately; readable chaos became unmanageable, charge reduced agency, and rescue outcomes inspired no care | H08/H19 down as sufficient; indiscriminate coupling identified as harmful | Is assertive, selective action intrinsically more valuable than custodial management? |
| [006 — Breakline](../experiments/006_breakline/README.md) | Are targeted combat/movement verbs enjoyable without progression, timers, or accumulating failure? | No; all sets were cleared, but strike dominated, tether hurt positioning, dash was forgotten, and reflection was interesting to inspect but attention-splitting rather than fun | H20 down as a sufficient cause; voluntary replay again separated from enjoyment | Does rapid, deliberately chosen qualitative transformation make otherwise serviceable action worth continuing? | | [006 — Breakline](../experiments/006_breakline/README.md) | Are targeted combat/movement verbs enjoyable without progression, timers, or accumulating failure? | No; all sets were cleared, but strike dominated, tether hurt positioning, dash was forgotten, and reflection was interesting to inspect but attention-splitting rather than fun | H20 down as a sufficient cause; voluntary replay again separated from enjoyment | Does rapid, deliberately chosen qualitative transformation make otherwise serviceable action worth continuing? |
| [007 — Catalyst Trials](../experiments/007_catalyst_trials/README.md) | Does rapid qualitative chosen growth create value beyond matched numerical amplification? | Successful: mutation prompted at least four repeat/comparison runs, wrong predictions, revised build knowledge, conditional small/big-enemy mapping, and a qualified fun report; numerical choices were boring | H21/H22 strongly up; H23 added; first clear curiosity chain | Can changing enemy ecologies and limited composition preserve reasoning without prescribing counter-loadouts? | | [007 — Catalyst Trials](../experiments/007_catalyst_trials/README.md) | Does rapid qualitative chosen growth create value beyond matched numerical amplification? | Successful: mutation prompted at least four repeat/comparison runs, wrong predictions, revised build knowledge, conditional small/big-enemy mapping, and a qualified fun report; numerical choices were boring | H21/H22 strongly up; H23 added; first clear curiosity chain | Can changing enemy ecologies and limited composition preserve reasoning without prescribing counter-loadouts? |
| [008 — Catalyst Ecology](../experiments/008_catalyst_ecology/README.md) | Can a four-choice compositional system remain interesting across small, durable, and spawning populations? | Revision 2 completed; three distinct mature builds were clearer in use, Bloom+Arc ceased being universal, but no replay occurred; report pending | H23 receives behavioral support but intention/enjoyment remain unresolved | Were builds ecological reasoning or experiment coverage, and did mature exposure create fun or only legibility? | | [008 — Catalyst Ecology](../experiments/008_catalyst_ecology/README.md) | Can a four-choice compositional system remain interesting across small, durable, and spawning populations? | Building was enjoyable but meaningless: mature effects became clear, yet almost any build/baseline seemed sufficient and the cap carried no felt tradeoff | H23 down as implemented; H24 added with strong support | Can capability-sensitive escalation give qualitative building weight without prescribing one answer? |
| [009 — Catalyst Ascent](../experiments/009_catalyst_ascent/README.md) | Does capability-sensitive escalation make an enjoyable build meaningful through several causal routes? | Ready for playtest | Tests H24 while separating consequence from raw health inflation | Does a limitation create anticipation, tactical expression, and another build question—or only compulsory throughput? |

View file

@ -180,9 +180,19 @@ Confidence is deliberately qualitative until there is playtest evidence.
## H23 — Changing enemy ecology can preserve compositional reasoning ## H23 — Changing enemy ecology can preserve compositional reasoning
- **Confidence:** unresolved; first 008 run was too short at build maturity - **Confidence:** low as implemented; ecology was legible but not consequential
- **Evidence for:** Without prompting, the player concluded that Bloom excelled against small enemies while Arc performed better against large enemies. Conditional target profiles can make component knowledge transfer while preventing one exact build from answering every field. - **Evidence for:** Without prompting, the player concluded that Bloom excelled against small enemies while Arc performed better against large enemies. Conditional target profiles can make component knowledge transfer while preventing one exact build from answering every field.
- **Evidence against:** Experiment 003's changing requirements collapsed into obvious prescribed counter-parts. In 007, taking all mutation families together may dominate, and all-Bloom's aggressive scaling may erase ecology distinctions through raw power. - **Evidence against:** Experiment 003's changing requirements collapsed into obvious prescribed counter-parts. In 007, taking all mutation families together may dominate, and all-Bloom's aggressive scaling may erase ecology distinctions through raw power.
- **Experiments:** 003 is negative adjacent evidence; 007 generated the hypothesis; 008 first run supplied only one short field after the fourth choice and could not isolate it. - **Experiments:** 003 is negative adjacent evidence; 007 generated the hypothesis; 008 first run supplied only one short field after the fourth choice and could not isolate it.
- **Evidence update:** In 008 the player used three different builds and believed multiple approaches may exist, but reported that each expedition ended just as something cool began emerging. The fourth module was active for only 722 seconds. Conduit-first was a misunderstanding, not planned interface composition. - **Evidence update:** In 008 the player used three different builds and believed multiple approaches may exist, but reported that each expedition ended just as something cool began emerging. The fourth module was active for only 722 seconds. Conduit-first was a misunderstanding, not planned interface composition.
- **Revision 2 evidence:** Added mature fields made choice effects easier to see and supported small movement-strategy refinements. Builds differentiated and Bloom+Arc stopped being universal. However, the player believed almost any build or even baseline fire could complete every expedition; no exclusion created a relevant limitation, so the cap generated little meaningful tradeoff.
- **Interpretation:** Population variety cannot preserve reasoning when success is insensitive to the composition. Simply raising difficulty remains an untested and potentially confounded correction.
## H24 — Enjoyable building needs capability-sensitive consequence
- **Confidence:** high as a requirement; the suitable consequence remains unresolved
- **Evidence for:** In 008 revision 2, longer exposure made three causal builds legible and building was explicitly reported enjoyable. It simultaneously felt meaningless because nearly any build or baseline fire appeared sufficient. Earlier 000/001/003 failures also lacked consequences sensitive to deeper construction, while 002's survival/wave stakes gave upgrades instrumental value before one strategy dominated.
- **Evidence against:** Pure sandbox building can be enjoyable without external failure when the output is expressive enough. Experiment 007 generated repeated build tests from surprise and spectacle alone, so hard challenge is not always required.
- **Experiments:** 000, 001, 002, 003, 007, 008
- **Unresolved:** Can capability-sensitive enemies make exact qualitative building matter through multiple causal routes without collapsing into obvious counters or raw throughput pressure?
- **Unresolved:** Can mixed/behavioral ecologies support several viable causal compositions, or does adaptation become “equip the labeled counter”? - **Unresolved:** Can mixed/behavioral ecologies support several viable causal compositions, or does adaptation become “equip the labeled counter”?