Back to Browser Side Quest
SOURCE / PINNED RELEASE

Made of little things.

Browser Side Quest

Release
b9d42f5768e4…
Author-recorded commit
c267690145fa…
License
LICENSE
Author’s source reference
nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.shakespeare.diy%2F/browser-side-quest

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

tests/multiplayer.mjs
// BrowserQuest shard multiplayer scenario.
// Run with: soyli multiplayer tests/multiplayer.mjs --latency 50 --jitter 15
// Trusted local test code. Observes rendered state through the read-only probe
// the game writes on #foreground (data-local-x/y, data-peers).
export default async ({ players, check, measure, diagnostics }) => {
  const [host, guest] = players;

  const enter = async (player, name) => {
    const input = player.frame.locator('#nameinput');
    await input.waitFor();
    await input.fill(name);
    await player.frame.locator('#createcharacter .play').click();
    await player.frame.locator('#app.game').waitFor({ timeout: 20000 });
    // Dismiss the first-play instructions.
    await player.frame.locator('#instructions.active').click({ timeout: 5000 }).catch(() => undefined);
  };

  // The first player becomes the shard host; the second joins it.
  await enter(host, 'Hosty');
  await host.frame.waitForTimeout(2500);
  await enter(guest, 'Guesty');

  const peersOf = (player) => player.frame.locator('#foreground').getAttribute('data-peers');
  const waitForPeer = async (player, name, timeout = 20000) => {
    const start = Date.now();
    while (Date.now() - start < timeout) {
      if (((await peersOf(player)) ?? '').includes(`${name}@`)) return true;
      await player.frame.waitForTimeout(200);
    }
    return false;
  };

  const worldName = async (player) => (await player.frame.locator('#world-name').textContent())?.trim();
  check('host runs the world', /host/.test((await worldName(host)) ?? ''));
  check('guest joined as a guest', /guest/.test((await worldName(guest)) ?? ''));
  check('every player has a connected peer', (await diagnostics()).every((rows) => rows.some((p) => p.state === 'connected')));

  // Starting areas are spread over the village and the original game only shows
  // entities in adjacent 28x12 zone groups, so walk the guest to the host, steering
  // by rendered positions (and sidestepping when blocked).
  const gridOf = async (player) => {
    const c = player.frame.locator('#foreground');
    return {
      x: Math.round(Number(await c.getAttribute('data-local-x')) / 16),
      y: Math.round(Number(await c.getAttribute('data-local-y')) / 16),
    };
  };
  const hold = async (player, code, ms) => {
    await player.frame.evaluate((k) => window.dispatchEvent(new KeyboardEvent('keydown', { code: k, key: k, bubbles: true })), code);
    await player.frame.waitForTimeout(ms);
    await player.frame.evaluate((k) => window.dispatchEvent(new KeyboardEvent('keyup', { code: k, key: k, bubbles: true })), code);
  };
  const walkTo = async (player, target, budgetMs) => {
    const start = Date.now();
    let last = null;
    let detour = 0;
    while (Date.now() - start < budgetMs) {
      const t = await target();
      const me = await gridOf(player);
      const dx = t.x - me.x;
      const dy = t.y - me.y;
      if (Math.abs(dx) + Math.abs(dy) <= 2) return true;
      const stuck = last && last.x === me.x && last.y === me.y;
      if (stuck) detour = 3;
      let code;
      if (detour > 0) {
        detour -= 1;
        code = Math.abs(dx) >= Math.abs(dy) ? (Math.random() < 0.5 ? 'ArrowUp' : 'ArrowDown') : Math.random() < 0.5 ? 'ArrowLeft' : 'ArrowRight';
      } else {
        code = Math.abs(dx) >= Math.abs(dy) ? (dx > 0 ? 'ArrowRight' : 'ArrowLeft') : dy > 0 ? 'ArrowDown' : 'ArrowUp';
      }
      last = me;
      await hold(player, code, 380);
    }
    return false;
  };
  check('guest walks to the host', await walkTo(guest, () => gridOf(host), 45000));
  check('guest sees the host warrior', await waitForPeer(guest, 'Hosty'));
  check('host sees the guest warrior', await waitForPeer(host, 'Guesty'));

  const count = await guest.frame.locator('#playercount .count').textContent();
  check('population shows two players', count?.trim() === '2');

  // Guest input to visible local movement (the local player is client-predicted).
  // The walk can end on a screen edge; let that screen flip (a 500 ms camera
  // transition that ignores input, as in the original) finish before measuring.
  await guest.frame.waitForTimeout(1000);
  const ms = await guest.frame.evaluate(async () => {
    const canvas = document.querySelector('#foreground');
    const before = `${canvas.dataset.localX},${canvas.dataset.localY}`;
    const start = performance.now();
    const keys = ['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp'];
    try {
      for (const code of keys) {
        window.dispatchEvent(new KeyboardEvent('keydown', { code, key: code, bubbles: true }));
        const moved = await new Promise((resolve) => {
          const poll = () => {
            if (`${canvas.dataset.localX},${canvas.dataset.localY}` !== before) resolve(true);
            else if (performance.now() - start > 1500) resolve(false);
            else requestAnimationFrame(poll);
          };
          requestAnimationFrame(poll);
        });
        window.dispatchEvent(new KeyboardEvent('keyup', { code, key: code, bubbles: true }));
        if (moved) return performance.now() - start;
      }
      throw new Error('Local player did not move');
    } finally {
      for (const code of keys) window.dispatchEvent(new KeyboardEvent('keyup', { code, key: code, bubbles: true }));
    }
  });
  measure('guest input to visible movement', ms, 50);

  // The host must see the guest's new position.
  const guestPosOnHost = async () => ((await peersOf(host)) ?? '').match(/Guesty@(\d+),(\d+)/)?.slice(1).join(',');
  const before = await guestPosOnHost();
  await guest.frame.evaluate(() => {
    window.dispatchEvent(new KeyboardEvent('keydown', { code: 'ArrowDown', key: 'ArrowDown', bubbles: true }));
  });
  await guest.frame.waitForTimeout(900);
  await guest.frame.evaluate(() => {
    window.dispatchEvent(new KeyboardEvent('keyup', { code: 'ArrowDown', key: 'ArrowDown', bubbles: true }));
  });
  let moved = false;
  for (let i = 0; i < 30 && !moved; i++) {
    await host.frame.waitForTimeout(200);
    moved = (await guestPosOnHost()) !== before;
  }
  check('host sees the guest walk', moved);

  // Chat: the guest speaks, the host renders the bubble.
  await guest.frame.locator('#foreground').focus();
  await guest.frame.evaluate(() => window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })));
  await guest.frame.locator('#chatinput').fill('hello from the guest');
  await guest.frame.locator('#chatinput').press('Enter');
  const bubble = host.frame.locator('.bubble', { hasText: 'hello from the guest' });
  check('host sees guest chat', await bubble.waitFor({ timeout: 8000 }).then(() => true, () => false));

  // Host leaves: the guest takes over the world and keeps playing.
  await host.page.close();
  let migrated = false;
  for (let i = 0; i < 60 && !migrated; i++) {
    await guest.frame.waitForTimeout(250);
    migrated = /host/.test((await worldName(guest)) ?? '') && !!(await guest.frame.locator('#app.game:not(.overlay)').count());
  }
  check('guest becomes host after the host leaves', migrated);
};