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.
/**
* 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'));
}
}
