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

View file

@ -2,19 +2,27 @@
"use strict";
const $ = s => document.querySelector(s), $$ = s => [...document.querySelectorAll(s)];
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 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 = {
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},
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}]},
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}]}
};
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 = {
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},
@ -26,16 +34,16 @@
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 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,
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();
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 secondaryScale=()=>1+state.upgrades.resonance*.55;
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 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 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")}
@ -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 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 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 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 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 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 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 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 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];
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 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 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)}
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");

View file

@ -1,6 +1,6 @@
# 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`
@ -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?
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.