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/hosted.mjs
import { createRequire } from 'node:module';
import { realpath, mkdir, readFile } from 'node:fs/promises';
import { execFileSync } from 'node:child_process';
import assert from 'node:assert/strict';
const url = process.env.SOYINDENT_PREVIEW_URL;
if (!url) throw new Error('Start soyli dev, then run SOYINDENT_PREVIEW_URL=<its printed URL> pnpm test:hosted');
const require = createRequire(await realpath(new URL('../node_modules/@napplet/conformance-cli/package.json', import.meta.url)));
const { chromium } = require('playwright');
const profile = JSON.parse(execFileSync('soyli', ['browser', 'path', '--json'], { encoding: 'utf8' }));
const executablePath = profile.executables.find(e => e.name === 'chromium-headless-shell' && e.installed)?.path;
const browser = await chromium.launch({ executablePath, headless: true, args: ['--enable-webgl', '--use-gl=angle', '--use-angle=swiftshader'] });
await mkdir('.napplet-space/testing', { recursive: true });
const errors = [];
const page = await browser.newPage({ viewport: { width: 1200, height: 900 } });
page.on('pageerror', e => errors.push(e.message));
try {
  await page.goto(url);
  const f = page.frameLocator('iframe');
  await f.locator('#scene canvas').waitFor();
  await f.locator('#startButton').click();
  await page.keyboard.press('Space');
  assert.equal(await f.locator('#ammoCount').innerText(), '05');
  await page.keyboard.press('KeyR');
  await f.locator('#ammoCount').filter({hasText:'06'}).waitFor();
  assert.equal(await f.locator('#ammoCount').innerText(), '06');
  assert.equal(await f.locator('#reserveCount').innerText(), '23');
  await page.keyboard.down('KeyW'); await f.locator('#cameraLabel').filter({hasText:'02'}).waitFor(); await page.keyboard.up('KeyW');
  assert.match(await f.locator('#cameraLabel').innerText(), /02/);
  await page.keyboard.press('Escape');
  console.log('PASS movement, camera cut, shotgun, reload, pause');
  await f.locator('#editorTab').click(); await f.locator('#saveMap').click();
  await f.locator('#status').filter({ hasText: 'Draft saved' }).waitFor();
  await f.locator('summary').filter({ hasText: 'Map JSON' }).click();
  await f.locator('#showJson').click();
  const stage = JSON.parse(await f.locator('#mapJson').inputValue());
  stage.rooms[0].spawn = { x: 4.3, z: 2.9 };
  await f.locator('#mapJson').fill(JSON.stringify(stage)); await f.locator('#applyJson').click();
  await f.locator('#testMap').click(); await page.keyboard.press('KeyE');
  assert.equal(await f.locator('#roomTitle').innerText(), 'Records Corridor');
  await page.keyboard.press('KeyE'); assert.equal(await f.locator('#roomTitle').innerText(), 'Evidence Room');
  console.log('PASS SDK draft save and both directions of connected doors');
  await f.locator('#editorTab').click(); const cameraCount=await f.locator('#cameraList button').count(); await f.locator('#addCamera').click();
  assert.equal(await f.locator('#cameraList button').count(), cameraCount+1);
  await f.locator('#deleteCamera').click(); assert.equal(await f.locator('#cameraList button').count(), cameraCount);
  await f.locator('#exportSchema').click(); await page.getByRole('button', { name: 'Save file', exact: true }).click();
  await f.locator('#status').filter({ hasText: 'Schema PNG exported' }).waitFor();
  const [schema] = await Promise.all([page.waitForEvent('download'), page.getByRole('link', { name: /evidence-cam-1-schema.png/ }).click()]);
  await schema.saveAs('.napplet-space/testing/exported-schema.png');
  const bytes = await readFile('.napplet-space/testing/exported-schema.png');
  assert.equal(bytes.readUInt32BE(16), 640); assert.equal(bytes.readUInt32BE(20), 480);
  console.log('PASS 640×480 schema PNG through the actual host file export');
  await f.locator('#importBackdrop').click();
  await page.getByLabel('Choose files for napplet').waitFor({ state: 'visible' });
  await page.getByLabel('Choose files for napplet').setInputFiles('.napplet-space/testing/exported-schema.png');
  await f.locator('#status').filter({ hasText: 'Background assigned' }).waitFor();
  assert.match(await f.locator('#artStatus').innerText(), /Custom art assigned/);
  await f.locator('#exportMap').click(); await page.getByRole('button', { name: 'Save file', exact: true }).click();
  await f.locator('#status').filter({ hasText: 'Map exported' }).waitFor();
  const [mapDownload] = await Promise.all([page.waitForEvent('download'), page.getByRole('link', { name: /soyindent-stage.json/ }).click()]);
  await mapDownload.saveAs('.napplet-space/testing/roundtrip-map.json');
  const map = JSON.parse(await readFile('.napplet-space/testing/roundtrip-map.json', 'utf8'));
  assert.match(map.rooms[0].cameras[0].backdrop, /^data:image\/png;base64,/);
  await f.locator('#importMap').click();
  await page.getByLabel('Choose files for napplet').waitFor({ state: 'visible' });
  await page.getByLabel('Choose files for napplet').setInputFiles('.napplet-space/testing/roundtrip-map.json');
  await f.locator('#status').filter({ hasText: 'Map imported' }).waitFor();
  assert.match(await f.locator('#artStatus').innerText(), /Custom art assigned/);
  await f.locator('#removeBackdrop').click();
  console.log('PASS background import and map JSON/art roundtrip');
  await f.locator('#loadMap').click();
  // Removing custom art leaves unsaved edits; the app must protect that draft.
  await f.locator('#status').filter({ hasText: 'Save or export' }).waitFor();
  await f.locator('#saveMap').click(); await f.locator('#status').filter({ hasText: 'Draft saved' }).waitFor();
  await f.locator('#loadMap').click(); await f.locator('#status').filter({ hasText: 'Draft loaded' }).waitFor();
  await f.locator('#playTab').click();
  for (const [w, h] of [[200, 160], [320, 560], [900, 600], [1600, 900]]) {
    await page.locator('iframe').evaluate((e, [w, h]) => { Object.assign(e.style, { width: `${w}px`, height: `${h}px`, minHeight: '0', flex: 'none' }); }, [w, h]);
    await page.waitForTimeout(120);
    const actual = await f.locator('body').evaluate(() => ({ w: innerWidth, h: innerHeight, scroll: document.body.scrollWidth, notice: getComputedStyle(document.querySelector('.minimum-notice')).display, stage: document.querySelector('#gameStage').getBoundingClientRect().toJSON() }));
    assert.equal(actual.w, w); assert.equal(actual.h, h); assert(actual.scroll <= w);
    if (w >= 280 && h >= 300) assert(Math.abs(actual.stage.width / actual.stage.height - 4 / 3) < .015);
    else assert.notEqual(actual.notice, 'none');
    await page.locator('iframe').screenshot({ path: `.napplet-space/testing/final-${w}x${h}.png` });
    if (w >= 280) { await f.locator('#editorTab').click(); assert((await f.locator('body').evaluate(() => document.body.scrollWidth)) <= w); await f.locator('#playTab').click(); }
    console.log(`PASS actual frame ${w}×${h}`);
  }
  for (const scheme of ['dark', 'light']) {
    await page.emulateMedia({ colorScheme: scheme });
    assert.equal(await f.locator('body').evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--bg')), '#111715');
  }
  for (const [w, h] of [[390, 844], [360, 640], [844, 390]]) {
    const context = await browser.newContext({ viewport: { width: w, height: h }, hasTouch: true, isMobile: true, deviceScaleFactor: 1 });
    try {
      const touchPage = await context.newPage(); touchPage.on('pageerror', e => errors.push(e.message));
      await touchPage.goto(url); const tf = touchPage.frameLocator('iframe');
      await tf.locator('#scene canvas').waitFor(); await tf.locator('#startButton').tap();
      await tf.locator('[data-action=shoot]').tap(); assert.equal(await tf.locator('#ammoCount').innerText(), '05');
      await tf.locator('[data-action=reload]').tap(); await tf.locator('#ammoCount').filter({hasText:'06'}).waitFor();
      assert.equal(await tf.locator('#ammoCount').innerText(), '06');
      const cdp = await context.newCDPSession(touchPage);
      await touchPage.locator('iframe').scrollIntoViewIfNeeded(); await tf.locator('[data-hold=forward]').scrollIntoViewIfNeeded();
      const a = await tf.locator('[data-hold=forward]').boundingBox(), b = await tf.locator('[data-hold=aim]').boundingBox();
      await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: a.x + a.width / 2, y: a.y + a.height / 2, id: 1 }, { x: b.x + b.width / 2, y: b.y + b.height / 2, id: 2 }] });
      await touchPage.waitForTimeout(200);
      assert.equal(await tf.locator('.held').count(), 2);
      await cdp.send('Input.dispatchTouchEvent', { type: 'touchCancel', touchPoints: [] });
      assert.equal(await tf.locator('.held').count(), 0);
      const frame = await tf.locator('body').evaluate(() => ({ w: innerWidth, h: innerHeight }));
      await touchPage.locator('iframe').screenshot({ path: `.napplet-space/testing/touch-${w}x${h}.png` });
      console.log(`PASS touch ${w}×${h}, actual frame ${frame.w}×${frame.h}, fire/reload, simultaneous movement+aim and cancellation`);
    } finally { await context.close(); }
  }
  await f.locator('body').evaluate(() => { delete window.napplet.theme; delete window.napplet.storage; delete window.napplet.fs; });
  await f.locator('#editorTab').click(); await f.locator('#saveMap').click();
  await f.locator('#status').filter({ hasText: 'Storage is unavailable' }).waitFor();
  await f.locator('#exportSchema').click(); await f.locator('#status').filter({ hasText: 'no file exports' }).waitFor();
  await f.locator('#playTab').click(); await f.locator('#startButton').click(); await page.keyboard.press('Space');
  assert.equal(await f.locator('#ammoCount').innerText(), '05');
  console.log('PASS app palette in both schemes and absent optional domains');
  assert.deepEqual(errors, []);
} finally { await browser.close(); }