Back to Rail Game
SOURCE / PINNED RELEASE

Made of little things.

Rail Game

Release
ccdd5b913413…
Author-recorded commit
ddf0122b6acc…
License
LICENSE
Author’s source reference
nostr://npub1n8ga89w8h6tvwamxusfyzexw8gjy84yxu9rxgnmk955cxtml4ujswzxydd/wss%3A%2F%2Fgit.napplet.soy%2F/n-91ec90c6fdf

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

tests/capture-preview.mjs
// Capture real gameplay in the soyLI iframe; no game-state injection.
// node tests/capture-preview.mjs http://127.0.0.1:PORT/
import assert from 'node:assert/strict';
import { mkdir, readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const { chromium } = createRequire(require.resolve('@napplet/conformance-cli/package.json'))('playwright');
const ts = require('typescript');
const compiled = ts.transpileModule(await readFile(new URL('../src/game.ts', import.meta.url),'utf8'), {compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText;
const {RailGame,MAX_SQUAD} = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`);
const url = process.argv[2];
if (!url) throw new Error('Pass the running soyli dev URL.');
await mkdir('.napplet-space/promotion',{recursive:true});
const browser = await chromium.launch({headless:true});
try {
  const page = await browser.newPage({viewport:{width:900,height:600}});
  await page.goto(url);
  const frame = await (await page.locator('iframe').elementHandle()).contentFrame();
  await frame.locator('#primaryButton').waitFor();
  await page.locator('iframe').evaluate(f => Object.assign(f.style,{position:'fixed',inset:'0',width:'100vw',height:'100vh',minHeight:'0',border:'0',zIndex:'1000'}));
  await page.evaluate(() => document.querySelector('iframe').contentWindow.postMessage({type:'theme.changed',theme:{colors:{background:'#101d25',text:'#e9f2f2',primary:'#78edbb'}}},'*'));
  await page.clock.install(); await page.clock.pauseAt(new Date(Date.now()+1000));
  await frame.locator('#primaryButton').click();
  await page.mouse.move(450,400); await page.mouse.down();
  const layout = new RailGame().entities;
  let x = 0, reached = false;
  for (let tick=0; tick<120; tick++) {
    const distance = 900-parseInt(await frame.locator('#routeLabel').innerText());
    const upcoming = layout.filter(e => !['enemy','boss'].includes(e.kind) && e.z>distance+.5 && e.z<distance+36).sort((a,b)=>a.z-b.z);
    if (upcoming.length) {
      const row = upcoming.filter(e=>Math.abs(e.z-upcoming[0].z)<1);
      const pickup = row.find(e=>e.kind==='multiply') ?? row.find(e=>e.kind==='damage') ?? row.find(e=>['recruit','rapid','shield'].includes(e.kind));
      if(pickup) x=pickup.x;
      else if(row.some(e=>e.kind==='barrier'&&Math.abs(e.x-x)<1.1)) x=[-2,0,2].find(lane=>row.every(e=>Math.abs(e.x-lane)>1))??0;
    }
    await page.mouse.move(450+x*112.5,400); await page.clock.runFor(500);
    if(Number(await frame.locator('#squad').innerText())===MAX_SQUAD){reached=true;break;}
  }
  assert.equal(reached,true); await page.mouse.up();
  for (const [width,height,name] of [[1200,750,'landscape'],[390,844,'portrait']]) {
    await page.setViewportSize({width,height});
    // Canvas resize clears its pixels. Virtual time must run a new animation frame.
    await page.clock.runFor(100);
    const painted = await frame.evaluate(() => {
      const canvas=document.getElementById('game');
      const pixels=canvas.getContext('2d').getImageData(0,0,canvas.width,canvas.height).data;
      let opaque=0; for(let i=3;i<pixels.length;i+=400) if(pixels[i]===255) opaque++;
      return {width:innerWidth,height:innerHeight,opaque};
    });
    assert.equal(painted.width,width); assert.equal(painted.height,height); assert.ok(painted.opaque>100);
    await page.locator('iframe').screenshot({path:`.napplet-space/promotion/release-${name}.png`});
    console.log('Captured',name,JSON.stringify(painted));
  }
} finally { await browser.close(); }