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.

src/levels-view.ts
import { identity, storage } from '@napplet/sdk';
import type { AppRecord } from '../docs/examples/app-data.js';
import { levelCollection, type LevelCollection } from './sharing-host.js';
import { runtimeHasDomain } from './domain-availability.js';
import { type LevelRecordData } from './level-format.js';
import { LevelFiles } from './level-files.js';
import { parseStage, type Stage } from './world.js';
import { repositoryLevels } from './assets.js';
const el=<T extends HTMLElement>(id:string)=>document.getElementById(id) as T;
type Record = AppRecord<LevelRecordData>;
type Hooks={stage():Stage;dirty():boolean;open(stage:Stage,edit:boolean):void};
export class LevelsView {
  collection:LevelCollection|null=null;records:Record[]=[];selected:Record|null=null;viewer='';busy=false;
  pending:Awaited<ReturnType<LevelCollection['prepare']>>|null=null;
  identitySubscription:{close():void}|undefined;
  constructor(private files:LevelFiles,private hooks:Hooks) {
    for(const [i,stage] of repositoryLevels.entries()){const option=document.createElement('option');option.value=String(i);option.textContent=stage.name;el('repositoryLevel').append(option);}
    el('openRepositoryLevel').addEventListener('click',()=>this.run(async()=>{
      if(this.hooks.dirty())throw new Error('Save or export your current edits before loading another level.');
      const stage=repositoryLevels[Number(el<HTMLSelectElement>('repositoryLevel').value)];
      if(!stage)throw new Error('Choose a repository level.');
      this.selected=null;this.pending=null;this.hooks.open(parseStage(JSON.stringify(stage)),false);
    }));
    el('lookupLevel').addEventListener('click',()=>this.run(async()=>{const a=JSON.parse(el<HTMLTextAreaElement>('sharedLevelAddress').value);const result=await(await this.client()).get(a.author,a.id,a.scope);if(result.incomplete)throw new Error('Lookup is incomplete. Retry before opening.');if(!result.record||result.record.deleted)throw new Error('Level address unavailable.');this.records=[result.record,...this.records.filter(r=>r.revision!==result.record!.revision)];this.message('Shared level found. Choose Play or Remix / edit.');}));
    el('refreshLevels').addEventListener('click',()=>this.run(()=>this.refresh()));
    el('publishLevel').addEventListener('click',()=>this.run(()=>this.publish(false)));
    el('updateLevel').addEventListener('click',()=>this.run(()=>this.publish(true)));
    el('retryLevel').addEventListener('click',()=>this.run(async()=>{if(!this.pending)throw new Error('Prepare a publication first.');await this.finish();}));
    el('unpublishLevel').addEventListener('click',()=>this.run(()=>this.unpublish()));
    if(runtimeHasDomain('identity'))try{this.identitySubscription=identity.onChanged(()=>{this.pending=null;void this.readViewer().catch(()=>{});});}catch{}
  }
  message(text:string):void{el('levelStatus').textContent=text;}
  enter():void {el<HTMLInputElement>('levelTitle').value=this.hooks.stage().name;this.render();}
  run(fn:()=>Promise<void>):void {
    if(this.busy)return;this.busy=true;this.render();
    void fn().catch(e=>this.message(e instanceof Error?e.message:String(e))).finally(()=>{this.busy=false;this.render();});
  }
  async client():Promise<LevelCollection>{return this.collection??=await levelCollection();}
  async readViewer():Promise<void>{this.viewer=runtimeHasDomain('identity')?await identity.getPublicKey():'';this.render();}
  async refresh():Promise<void>{
    this.message('Loading recent levels…');const collection=await this.client();await this.readViewer();const result=await collection.list();this.records=result.records;
    this.message(`${this.records.length} recent levels.${result.incomplete?' Some relays did not finish; retry before treating this as complete.':''}${result.invalid?` ${result.invalid} incompatible or invalid records skipped.`:''}${result.nextUntil?' This is a bounded recent view; older records may exist.':''}`);this.render();
  }
  async publish(update:boolean):Promise<void>{
    const collection=await this.client();await this.readViewer();const owner=this.viewer;
    if(!/^[a-f0-9]{64}$/.test(owner))throw new Error('Connect your Nostr identity in the host to publish. Your editor draft is preserved.');
    if(update&&(!this.selected||this.selected.author!==owner||this.selected.scope!==collection.policy.scope))throw new Error('Choose one of your published levels in this napplet first.');
    const snapshot=parseStage(JSON.stringify(this.hooks.stage())),title=el<HTMLInputElement>('levelTitle').value.trim(),description=el<HTMLTextAreaElement>('levelDescription').value.trim();
    if(!title||title.length>160||description.length>2000)throw new Error('Use a title of 1–160 characters and a description under 2000 characters.');
    this.pending=null;
    if(runtimeHasDomain('storage')){
      const draft=structuredClone(snapshot);for(const r of draft.rooms)for(const c of r.cameras){delete c.backdrop;delete c.artSignature;}
      try{await storage.setItem('soyindent-stage-v1',JSON.stringify(draft));}catch{this.message('Host draft save failed; your map remains in this editor. Preparing the publication…');}
    }
    const guard=async()=>{if(await identity.getPublicKey()!==owner)throw new Error('The selected identity changed. Prepare this publication again.');};
    const ref=await this.files.prepare(snapshot,m=>this.message(m),guard);await guard();
    const source=!update?this.selected:null;
    const data:LevelRecordData={package:ref,description,rooms:snapshot.rooms.length,zombies:snapshot.rooms.reduce((n,r)=>n+r.zombies.length,0),...(source?{remix:{author:source.author,id:source.id,scope:source.scope,revision:source.revision}}:{})};
    this.pending=await collection.prepare({id:update?this.selected!.id:crypto.randomUUID(),title,data,base:update?this.selected:null});
    this.message('Level files are ready. Confirm the Nostr publication in the host.');await this.finish();
  }
  async finish():Promise<void>{
    const saved=await this.pending!.publish();this.pending=null;
    this.records=this.records.filter(r=>r.author!==saved.author||r.id!==saved.id);
    if(saved.deleted){this.selected=null;this.message('Unpublished with a public tombstone. Existing copies and uploaded files can remain available.');}
    else {this.selected=saved;this.records.unshift(saved);this.message('Level published. Its current Nostr address and immutable package link are shown below.');}
  }
  async unpublish():Promise<void>{
    if(!this.selected)throw new Error('Choose one of your levels first.');
    this.pending=await(await this.client()).prepare({id:this.selected.id,title:this.selected.title,base:this.selected,deleted:true});
    await this.finish();
  }
  async open(record:Record,edit:boolean):Promise<void>{
    if(this.hooks.dirty())throw new Error('Save or export your current edits before loading another level.');
    const before=JSON.stringify(this.hooks.stage());this.message('Loading and verifying level files…');
    const lookup=await(await this.client()).get(record.author,record.id,record.scope);
    if(lookup.incomplete)throw new Error('The current revision lookup is incomplete. Retry before loading.');
    if(!lookup.record||lookup.record.deleted||!lookup.record.data)throw new Error('This level is no longer available.');
    const current=lookup.record,stage=await this.files.load(current.data!.package);
    if(stage.rooms.length!==current.data!.rooms||stage.rooms.reduce((n,r)=>n+r.zombies.length,0)!==current.data!.zombies)throw new Error('The level package does not match its published room/enemy counts.');
    if(before!==JSON.stringify(this.hooks.stage())||this.hooks.dirty())throw new Error('Your editor changed while loading. Save or export it, then retry.');
    this.selected=current;this.hooks.open(stage,edit);this.message(edit?'Level copied into your editor. Publish as new to make a remix.':'Level loaded. Clear the precinct.');this.render();
  }
  render():void{
    const own=!!this.selected&&this.selected.author===this.viewer&&this.selected.scope===this.collection?.policy.scope;
    el<HTMLButtonElement>('publishLevel').disabled=this.busy;
    el<HTMLButtonElement>('updateLevel').disabled=this.busy||!own;
    el<HTMLButtonElement>('unpublishLevel').disabled=this.busy||!own;
    el<HTMLButtonElement>('retryLevel').disabled=this.busy||!this.pending;
    el<HTMLButtonElement>('lookupLevel').disabled=this.busy;
    el<HTMLButtonElement>('refreshLevels').disabled=this.busy;
    el<HTMLButtonElement>('openRepositoryLevel').disabled=this.busy;
    el('levelIdentity').textContent=this.viewer?`Publishing as ${this.viewer.slice(0,12)}…`:'Browse anonymously; connect your Nostr identity in the host to publish.';
    el('levelSelection').textContent=this.selected?`${this.selected.title} · ${own?'your published level':'remix source'}`:'Your current editor map will be published as a new level.';
    const list=el('levelList');list.replaceChildren();
    if(!this.records.length){const p=document.createElement('p');p.className='muted';p.textContent='Refresh to discover recent player levels.';list.append(p);}
    for(const record of this.records){
      const article=document.createElement('article');article.className='level-card';const title=document.createElement('h3');title.textContent=record.title;
      const info=document.createElement('p');info.className='muted';info.textContent=`${record.data?.rooms} rooms · ${record.data?.zombies} zombies · ${record.author.slice(0,12)}…`;
      const desc=document.createElement('p');desc.textContent=record.data?.description??'';
      const actions=document.createElement('div');actions.className='level-card-actions';
      for(const [label,edit] of [['Play',false],['Remix / edit',true]] as const){const b=document.createElement('button');b.textContent=label;b.disabled=this.busy;b.addEventListener('click',()=>this.run(()=>this.open(record,edit)));actions.append(b);}
      if(record.author===this.viewer){const b=document.createElement('button');b.textContent='Select for update';b.disabled=this.busy;b.addEventListener('click',()=>{this.selected=record;this.render();});actions.append(b);}
      article.append(title,info,desc,actions);list.append(article);
    }
    const address=el<HTMLTextAreaElement>('levelAddress');address.value=this.selected?JSON.stringify({author:this.selected.author,scope:this.selected.scope,collection:'levels',id:this.selected.id,revision:this.selected.revision,relays:this.collection?.relays,package:this.selected.data?.package},null,2):'';
  }
  dispose():void{this.identitySubscription?.close();}
}