Back to Outbreak: Soycoon City
SOURCE / PINNED RELEASE

Made of little things.

Outbreak: Soycoon City

Release
db7750b1d009…
Author-recorded commit
3ee3f5fd27a6…
License
LICENSE
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-b87c04571a9

Archive hash verified: dbebdc772b142a1c…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

tests/audio.test.mjs
import test from 'node:test';
import assert from 'node:assert/strict';
import { testModules } from '../scripts/test-modules.mjs';
const { Game, GameAudio, createStage } = await testModules();
const idle = { forward:0, turn:0, run:false, aim:false };
const listener = { x:0,z:0,yaw:0,room:'evidence' };
const ids = game => game.drainSounds().map(s=>s.id);
const tick = () => new Promise(resolve=>setImmediate(resolve));
function game() { const g=new Game(createStage());g.paused=false;g.zombies.forEach(z=>z.groan=100);return g; }
test('shot/reload cues follow accepted simulation events and freeze during pause',()=>{
  const g=game();g.paused=true;g.shoot();g.reload();assert.deepEqual(ids(g),[]);
  g.paused=false;g.shoot();g.shoot();assert.deepEqual(g.drainSounds(),[{id:'shotgun_fire'},{id:'shotgun_pump',delaySeconds:.25}]);
  g.reload();g.reload();assert.deepEqual(ids(g),['reload_start']);
  g.paused=true;for(let i=0;i<40;i++)g.update(.05,idle);assert.deepEqual(ids(g),[]);assert.equal(g.reloadTime,1.5);
  g.paused=false;for(let i=0;i<31;i++)g.update(.05,idle);
  assert.deepEqual(ids(g),['reload_shell','reload_finish']);assert.equal(g.ammo,6);assert.equal(g.reserve,23);
  g.ammo=0;g.cooldown=0;g.shoot();assert.deepEqual(ids(g),[]);
});
test('footsteps require actual travel, with no sound for turning or pushing walls',()=>{
  const g=game();g.player.x=-1.8;g.player.z=2.2;g.player.yaw=Math.PI;
  for(let i=0;i<12;i++)g.update(.05,{...idle,forward:1});assert(ids(g).includes('footstep_stone'));
  for(let i=0;i<25;i++)g.update(.05,{...idle,turn:1});assert.deepEqual(ids(g),[]);
  g.player.x=4.7;g.player.z=2;g.player.yaw=Math.PI/2;
  for(let i=0;i<25;i++)g.update(.05,{...idle,forward:1});assert.deepEqual(ids(g),[]);
  g.room=g.stage.rooms.find(r=>r.id==='maintenance');g.player.x=-2;g.player.z=0;g.player.yaw=0;
  for(let i=0;i<12;i++)g.update(.05,{...idle,forward:1});assert(ids(g).includes('footstep_concrete'));
});
test('only living zombies in the current room emit positioned groans and bite cues',()=>{
  const g=game(),z=g.zombies[0];Object.assign(z,{x:g.player.x,z:g.player.z,groan:0});
  g.update(.05,idle);const sounds=g.drainSounds();assert.deepEqual(sounds.map(s=>s.id),['zombie_idle_groan','zombie_attack']);
  assert(sounds.every(s=>s.room===g.room.id));assert.equal(sounds[0].position.x,g.player.x);
  g.paused=true;g.update(.05,idle);assert.deepEqual(ids(g),[]);
  g.paused=false;z.hp=0;z.groan=0;g.update(.05,idle);assert.deepEqual(ids(g),[]);
});
const param=()=>({value:0,setValueAtTime(){},linearRampToValueAtTime(){}});
class Context {
  state='suspended';currentTime=0;destination={};onstatechange=null;sources=[];events=[];failDecode=false;
  node(extra={}) {return {connect(){},disconnect(){},...extra};}
  createGain(){return this.node({gain:param()});}
  createDynamicsCompressor(){return this.node({threshold:param(),ratio:param()});}
  createStereoPanner(){return this.node({pan:param()});}
  createBufferSource(){const s=this.node({buffer:null,loop:false,onended:null,playing:false,start(when){this.startTime=when;this.playing=true;},stop(){this.playing=false;}});this.sources.push(s);return s;}
  resume(){this.events.push('resume');this.state='running';return Promise.resolve();}
  decodeAudioData(){this.events.push('decode');return this.failDecode?Promise.reject(new Error('bad codec')):Promise.resolve({duration:1});}
  close(){this.state='closed';return Promise.resolve();}
}
const clip=(id,loop=false)=>({id,loop,url:'data:audio/mpeg;base64,AA==',gainDb:0,maxVoices:loop?1:2,fadeMs:10});
const active=context=>context.sources.filter(s=>s.playing);
test('activation resumes within the gesture, queues loading shots and bounds overlapping voices',async()=>{
  const ctx=new Context(),a=new GameAudio([clip('music_patrol',true),clip('shotgun_fire')],()=>{},()=>ctx);
  assert.equal(a.label,'Enable sound');assert.equal(ctx.events.length,0);
  a.activate();a.sync(true,'evidence');a.play({id:'shotgun_fire'},listener);await tick();
  assert.equal(ctx.events[0],'resume');assert.equal(a.label,'Sound on');assert.equal(active(ctx).length,2);
  for(let i=0;i<10;i++)a.play({id:'shotgun_fire'},listener);assert.equal(active(ctx).length,3);
  a.toggle();assert.equal(a.label,'Sound off');assert.equal(active(ctx).length,0);
  a.play({id:'shotgun_fire'},listener);a.toggle();assert.equal(active(ctx).length,1);a.dispose();assert.equal(ctx.state,'closed');
});
test('camera cuts keep music running; room ambience, pause, win and disposal stop the right sources',async()=>{
  const ctx=new Context(),a=new GameAudio([clip('music_patrol',true),clip('ambient_corridor',true),clip('zombie_attack')],()=>{},()=>ctx);
  a.activate();a.sync(true,'evidence');await tick();const music=active(ctx)[0];
  a.sync(true,'evidence');a.sync(true,'corridor');assert.equal(active(ctx)[0],music);assert.equal(active(ctx).length,2);
  a.sync(true,'evidence');assert.equal(active(ctx).length,1);assert.equal(active(ctx)[0],music);
  a.play({id:'zombie_attack',room:'corridor',position:{x:0,z:0}},listener);assert.equal(active(ctx).length,1);
  a.play({id:'zombie_attack',room:'evidence',position:{x:0,z:0}},listener);a.sync(true,'evidence',true);assert.equal(active(ctx).length,1);assert.equal(active(ctx)[0].loop,false);
  a.stop();assert.equal(active(ctx).length,0);a.sync(true,'evidence');assert.equal(active(ctx).length,1);
  ctx.state='suspended';ctx.onstatechange();assert.equal(active(ctx).length,0);assert.equal(a.label,'Enable sound');
  a.dispose();assert.equal(ctx.state,'closed');assert.equal(active(ctx).length,0);
});
test('the audio clock delays the pump after the blast and pause/mute cancel scheduled pumps',async()=>{
  const ctx=new Context(),a=new GameAudio([clip('shotgun_fire'),clip('shotgun_pump')],()=>{},()=>ctx);
  a.activate();a.sync(true,'evidence');await tick();ctx.currentTime=10;
  a.play({id:'shotgun_fire'},listener);a.play({id:'shotgun_pump',delaySeconds:.25},listener);
  assert.equal(ctx.sources[0].startTime,10);assert.equal(ctx.sources[1].startTime,10.25);
  a.sync(false,'evidence');assert.equal(active(ctx).length,0);
  a.sync(true,'evidence');a.play({id:'shotgun_pump',delaySeconds:.25},listener);
  a.toggle();assert.equal(active(ctx).length,0);
  a.toggle();a.sync(true,'evidence',true);a.play({id:'shotgun_pump',delaySeconds:.25},listener);
  assert.equal(active(ctx).length,1);a.dispose();assert.equal(active(ctx).length,0);
});
test('decode errors preserve gameplay with a retry, and late decoding cannot revive disposed audio',async()=>{
  const ctx=new Context();ctx.failDecode=true;const a=new GameAudio([clip('shotgun_fire')],()=>{},()=>ctx);
  a.activate();a.sync(true,'evidence');await tick();assert.equal(a.label,'Retry sound');assert.match(a.detail,/bad codec/);
  ctx.failDecode=false;a.toggle();await tick();a.play({id:'shotgun_fire'},listener);assert.equal(active(ctx).length,1);a.dispose();
  const slow=new Context();let finish;slow.decodeAudioData=()=>new Promise(resolve=>finish=resolve);
  const b=new GameAudio([clip('music_patrol',true)],()=>{},()=>slow);b.activate();b.sync(true,'evidence');b.dispose();finish({duration:1});await tick();assert.equal(active(slow).length,0);
});