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