Back to Powder Tool V600Billion
SOURCE / PINNED RELEASE

Made of little things.

Powder Tool V600Billion

Release
142767edcab8…
Author-recorded commit
6d92971effd0…
License
LICENSE
Author’s source reference
nostr://npub1fllw8kw0thjj55wds0uugcnp5kej2nfxd36eruq39d56wwz8r44q5q78wj/wss%3A%2F%2Fgit.napplet.soy%2F/powder-toy

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

source/scripts/e2e-online.mjs
/**
 * The online features through the game's own UI, in a real browser.
 *
 *   npm run e2e [-- hangar|soy]   (PLAYWRIGHT_CHROMIUM_EXECUTABLE=... for a Chromium of your own)
 *
 * alice (signed in through the host) builds something and saves it to the
 * server with Publish checked: a kind 30078 event must go out, signed by her.
 * The napplet then reloads as bob: the online browser lists alice's save with
 * its thumbnail, bob opens the preview, comments (kind 1111) and, with the
 * save loaded, votes it up (kind 7), then favourites it from the browser's
 * selection bar. Back as alice: she saves her loaded save again in place,
 * unpublishes it, publishes it again and deletes it, each from the selection
 * bar. Those are the game's request tasks, which without threads run as
 * request steps on the main thread (patches/the-powder-toy-single-thread.patch).
 *
 * In napplet.soy's player the host signs only its own app-data records, so
 * there the check is what a player gets instead: publishing is refused with
 * a message that says why, saving without Publish keeps the save on the
 * device, and a save shared elsewhere shows up in the online browser.
 * Screenshots of every step land in qa/online-<player>-*.png.
 */
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { mkdirSync, readFileSync } from 'node:fs';

import { PUBKEYS, QA_DIR as output, signAs, startHarness } from './harness.mjs';
import { D_PREFIX, saveTemplate } from '../src/server/events.js';
import { litShare, readPng } from '../test/lib/png.mjs';

mkdirSync(output, { recursive: true });

for (const player of process.argv[2] ? [process.argv[2]] : ['hangar', 'soy']) {
const step = [`${player}:`];
const harness = await startHarness({ player, identity: 'alice', profile: { name: 'alice' } });
const { tab } = harness;

async function box() {
  return harness.frame().evaluate(() => {
    const canvas = document.getElementById('canvas');
    const rect = canvas.getBoundingClientRect();
    return { x: rect.left, y: rect.top, scale: rect.width / canvas.width };
  });
}
/** Click at a point in the game's own 629 x 424 coordinates. */
async function click(x, y) {
  const b = await box();
  await tab.mouse.click(b.x + x * b.scale, b.y + y * b.scale);
  await tab.waitForTimeout(300);
}
/** Press one of the page's own buttons; those for the game's dialogs click the game's hidden bar. */
async function press(selector) {
  await harness.frame().click(selector);
  await tab.waitForTimeout(300);
}
/** An entry of the page's "More" menu. */
async function menu(action) {
  await harness.frame().click('#more');
  await harness.frame().click(`#more-menu [data-action="${action}"]`);
  await tab.waitForTimeout(300);
}
/**
 * Open the online browser with the page's Open button and make sure it is
 * open: a click that lands while a dialog is still closing is swallowed by
 * the game, so look for the browser's search box (its top border, a lit line
 * where the game's own screen is dark) and click again if it is not there.
 */
async function openBrowser() {
  for (let attempt = 0; attempt < 3; attempt++) {
    await harness.frame().waitForFunction(() => !document.body.classList.contains('game-dialog'), null, { timeout: 5000 });
    await press('#open');
    for (const started = Date.now(); Date.now() - started < 2000;) {
      const b = await box();
      const strip = readPng(await tab.screenshot({ clip: { x: b.x + 150 * b.scale, y: b.y + 8 * b.scale, width: 200 * b.scale, height: 5 * b.scale } }));
      if (litShare(strip, { x: 0, y: 0, w: strip.width, h: strip.height }) > 0.05) return;
      await tab.waitForTimeout(250);
    }
  }
  throw new Error('the online browser did not open');
}
/** A middle click selects a save in the online browser (upstream SaveButton::OnMouseDown). */
async function select(x, y) {
  const b = await box();
  await tab.mouse.click(b.x + x * b.scale, b.y + y * b.scale, { button: 'middle' });
  await tab.waitForTimeout(300);
}
async function shot(name) {
  await tab.screenshot({ path: `${output}online-${player}-${name}.png` });
}
/** Wait until the page has published one more event of this kind than it had. */
async function onePublished(kind, before, timeout = 15000) {
  await tab.waitForFunction(([k, n]) => window.__host.published.filter((event) => event.kind === k).length > n, [kind, before], { timeout });
  return (await published(kind)).at(-1);
}
async function signInAs(name) {
  await harness.host(([pubkey, profile]) => { window.__host.pubkey = pubkey; window.__host.profile = profile; window.__host.reload(); }, [PUBKEYS[name], { name }]);
  await harness.waitForGame();
  await click(300, 200);
}
async function lua(line) {
  await tab.keyboard.press('Backquote');
  await tab.waitForTimeout(200);
  await tab.keyboard.type(line, { delay: 1 });
  await tab.keyboard.press('Enter');
  await tab.waitForTimeout(300);
  await tab.keyboard.press('Backquote');
  await tab.waitForTimeout(200);
}
const published = (kind) => harness.host((k) => window.__host.published.filter((event) => event.kind === k), kind);

try {
  await harness.waitForGame();
  const username = await harness.frame().evaluate(() => document.querySelector("#PowderSessionInfo [name='Username']")?.value);
  assert.equal(username, `alice_${PUBKEYS.alice.slice(0, 4)}`);
  step.push(`ok   alice is signed in to the game as ${username}`);

  if (player === 'soy') {
    // The page picks the message of the day that says so (its wiring: test/server.test.mjs).
    assert.equal(await harness.frame().evaluate(() => document.documentElement.dataset.nappletPublish), 'host-own-format-only');
    step.push('ok   the game read the host\'s appData hint and says up front that it cannot publish here');
    await click(300, 200);
    await lua('sim.clearSim() for x=200,400 do sim.partCreate(-1,x,300,elem.DEFAULT_PT_METL) end');
    await press('#save');
    await tab.waitForTimeout(500);
    await tab.keyboard.type('Soy Tower', { delay: 5 });
    await click(94 + 15, 112 + 53); // Publish
    await tab.keyboard.press('Enter');
    await tab.waitForTimeout(2000);
    await shot('1-refused');
    assert.equal(await harness.host(() => window.__host.log.some((type) => type === 'outbox.publish' || type === 'relay.publish')), false, 'the host was not asked to sign');
    step.push('ok   Publish is refused in the page with an explanation; the host is not asked');
    await tab.keyboard.press('Enter'); // close the error; the save dialog is still open
    await tab.waitForTimeout(400);
    await click(94 + 15, 112 + 53); // Publish off
    await tab.keyboard.press('Enter');
    await tab.waitForFunction(() => [...window.__host.storage.keys()].some((key) => key.startsWith('priv:')), null, { timeout: 15000 });
    step.push('ok   without Publish the save is kept on the device');
    await tab.waitForTimeout(800);
    await tab.keyboard.press('Enter');

    // A save bob shared from a host that publishes the game's events.
    const bytes = readFileSync(new URL('../test/fixtures/stamp-small.cps', import.meta.url));
    const now = Math.floor(Date.now() / 1000);
    const d = D_PREFIX + createHash('sha256').update('bridge').digest('hex').slice(0, 16);
    await harness.host((event) => window.__host.inject(event), signAs('bob', saveTemplate({
      d, name: 'Bridge', description: 'shared elsewhere', data: bytes.toString('base64'), size: bytes.length, tags: [],
      publishedAt: now, sha256: createHash('sha256').update(bytes).digest('hex'), now,
    })));
    await openBrowser();
    // The tile appears once the list and the picture are in; wait for it rather than a fixed time.
    let share = 0;
    for (const started = Date.now(); Date.now() - started < 12000 && share <= 0.005;) {
      await tab.waitForTimeout(500);
      const b = await box();
      const tile = readPng(await tab.screenshot({ path: `${output}online-${player}-2-browser.png`, clip: {
        x: b.x + 8 * b.scale, y: b.y + 126 * b.scale, width: 95 * b.scale, height: 58 * b.scale,
      } }));
      share = litShare(tile, { x: 0, y: 0, w: tile.width, h: tile.height });
    }
    assert.ok(share > 0.005 && share < 0.6, `the first tile shows bob's save (lit share ${share.toFixed(3)})`);
    await shot('3-browser');
    step.push('ok   a save shared elsewhere shows up in the online browser, with its picture');
    assert.deepEqual(harness.errors, [], 'no uncaught errors');
    continue;
  }

  await click(300, 200); // focus
  await lua('sim.clearSim() for x=200,400 do sim.partCreate(-1,x,300,elem.DEFAULT_PT_METL) end for y=200,299 do sim.partCreate(-1,300,y,elem.DEFAULT_PT_WOOD) end');
  await press('#save'); // the game's save button, right of its split: save to server
  await tab.waitForTimeout(500);
  await tab.keyboard.type('E2E Tower', { delay: 5 });
  await click(94 + 15, 112 + 53); // Publish checkbox
  await shot('1-save-dialog');
  await tab.keyboard.press('Enter');
  await tab.waitForFunction(() => window.__host.published.some((event) => event.kind === 30078), null, { timeout: 15000 });
  const [save] = await published(30078);
  assert.equal(save.pubkey, PUBKEYS.alice);
  assert.equal(save.tags.find((tag) => tag[0] === 'title')[1], 'E2E Tower');
  assert.equal(Buffer.from(save.content, 'base64').subarray(0, 4).toString(), 'OPS1');
  step.push(`ok   the game published the save as kind 30078 (${Buffer.from(save.content, 'base64').length} bytes), signed by alice`);
  await tab.waitForTimeout(800);
  await shot('2-uploaded');
  await tab.keyboard.press('Enter'); // close "save uploaded"

  // The same browser, now as bob.
  await harness.host((pubkey) => { window.__host.pubkey = pubkey; window.__host.profile = { name: 'bob' }; window.__host.reload(); }, PUBKEYS.bob);
  await harness.waitForGame();
  assert.equal(await harness.frame().evaluate(() => document.querySelector("#PowderSessionInfo [name='Username']")?.value), `bob_${PUBKEYS.bob.slice(0, 4)}`);
  await click(300, 200);
  await openBrowser(); // the online browser
  await tab.waitForTimeout(3500);
  await shot('3-browser');
  await click(63, 167); // first save on the front page (tags row above it)
  await tab.waitForTimeout(3000);
  await shot('4-preview');
  await click(56 + 310 + 60, 41 + 331); // comment box
  await tab.keyboard.type('Stands tall', { delay: 5 });
  await click(56 + 496, 41 + 332); // submit
  await tab.waitForFunction(() => window.__host.published.some((event) => event.kind === 1111), null, { timeout: 15000 });
  const [comment] = await published(1111);
  assert.equal(comment.pubkey, PUBKEYS.bob);
  assert.equal(comment.content, 'Stands tall');
  assert.ok(comment.tags.some((tag) => tag[0] === 'A' && tag[1].startsWith(`30078:${PUBKEYS.alice}:powder-toy/save/`)));
  step.push('ok   bob commented from the preview (kind 1111 on the save)');
  await tab.waitForTimeout(1500);
  await shot('5-commented');
  await click(56 + 25, 41 + 332); // open the save
  await tab.waitForTimeout(1500);
  await shot('6-opened');
  await menu('vote-up');
  await tab.waitForFunction(() => window.__host.published.some((event) => event.kind === 7), null, { timeout: 15000 });
  const [vote] = await published(7);
  assert.deepEqual([vote.pubkey, vote.content], [PUBKEYS.bob, '+']);
  step.push('ok   bob voted the loaded save up (kind 7 "+")');
  await tab.waitForTimeout(800);
  await shot('7-voted');

  // Favourite from the browser's selection bar: a task of one request per selected save.
  await openBrowser();
  await tab.waitForTimeout(3000);
  await select(63, 167);
  await shot('8-selected');
  await click(367, 414); // Favourite
  await tab.waitForFunction(() => JSON.parse(window.__host.storage.get('favourites') ?? '[]').length === 1, null, { timeout: 15000 });
  step.push('ok   bob favourited the save from the selection bar');

  // alice saves her loaded save again in place: ServerSaveActivity's upload task.
  await signInAs('alice');
  await openBrowser();
  await tab.waitForTimeout(3000);
  await click(63, 167); // the save
  await tab.waitForTimeout(2500);
  await click(56 + 25, 41 + 332); // open it
  await tab.waitForTimeout(1500);
  await lua('for x=250,350 do sim.partCreate(-1,x,190,elem.DEFAULT_PT_GLAS) end');
  const saves = (await published(30078)).length;
  await menu('quick-save'); // the game's save button, left of its split: save the loaded save again
  const again = await onePublished(30078, saves);
  assert.deepEqual([again.pubkey, again.tags.find((tag) => tag[0] === 'd')[1]], [PUBKEYS.alice, save.tags.find((tag) => tag[0] === 'd')[1]]);
  step.push('ok   alice saved her loaded save again in place (a new version under the same d tag)');
  await tab.waitForTimeout(1000);
  await shot('9-saved-again');

  // Unpublish, publish, delete: her own saves, from the selection bar.
  await openBrowser();
  await tab.waitForTimeout(2500);
  await click(589, 18); // My Own
  await tab.waitForTimeout(2500);
  await shot('10-own');
  const deletions = (await published(5)).length;
  await select(63, 75);
  await click(262, 414); // Unpublish
  await tab.waitForTimeout(400);
  await tab.keyboard.press('Enter'); // confirm
  const unpublished = await onePublished(5, deletions);
  assert.ok(unpublished.tags.some((tag) => tag[0] === 'a' && tag[1] === `30078:${PUBKEYS.alice}:${save.tags.find((t) => t[0] === 'd')[1]}`));
  await tab.waitForFunction(() => [...window.__host.storage.keys()].some((key) => key.startsWith('priv:')), null, { timeout: 5000 });
  step.push('ok   alice unpublished it (kind 5; the save stays with her, unpublished)');
  await tab.waitForTimeout(2500);
  await shot('11-unpublished');

  const before = (await published(30078)).length;
  await select(63, 75);
  await click(262, 414); // now "Publish"
  await tab.waitForTimeout(400);
  await tab.keyboard.press('Enter');
  await onePublished(30078, before);
  step.push('ok   alice published it again (kind 30078)');
  await tab.waitForTimeout(2500);

  await select(63, 75);
  await click(157, 414); // Delete
  await tab.waitForTimeout(400);
  await tab.keyboard.press('Enter');
  await onePublished(5, deletions + 1);
  step.push('ok   alice deleted it (kind 5)');
  await tab.waitForTimeout(2500);
  await shot('12-deleted');
  assert.deepEqual(harness.errors, [], 'no uncaught errors');
} catch (error) {
  await shot('failure').catch(() => {});
  step.push(`FAIL ${error.message}`);
  process.exitCode = 1;
} finally {
  await harness.close();
  console.log(step.join('\n'));
}
}