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/sim.test.mjs
// Parity tests for the ported world simulation against the original
// BrowserQuest server values (server/js/{formulas,properties,worldserver}.js).
// Run: node --import ./tests/support/register.mjs --test tests/sim.test.mjs
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';

const mapData = JSON.parse(await readFile(new URL('../src/assets/maps/world_server.json', import.meta.url), 'utf8'));
const Formulas = await import('../src/shared/formulas.ts');
const Props = await import('../src/shared/properties.ts');
const { Entities, Messages } = await import('../src/shared/gametypes.ts');
const { check } = await import('../src/shared/format.ts');
const { World } = await import('../src/sim/world.ts');
const { takeSnapshot, restoreSnapshot } = await import('../src/sim/snapshot.ts');

test('hit points follow the original armor formula', () => {
  assert.equal(Formulas.hp(1), 80);
  assert.equal(Formulas.hp(2), 110);
  assert.equal(Formulas.hp(6), 230);
});

test('damage stays inside the original ranges', () => {
  for (let i = 0; i < 2000; i++) {
    const d = Formulas.dmg(7, 1); // golden sword vs rat armor
    assert.ok(d >= 0 && d <= 70, `dmg ${d}`);
    // sword1 vs boss armor: 1*(5..10) - 6*(1..3) is at most 4; non-positive results roll 0..3.
    const weak = Formulas.dmg(1, 6);
    assert.ok(weak >= 0 && weak <= 4, `weak ${weak}`);
  }
});

test('mob stats and drop tables match the original properties.js', () => {
  const expected = {
    rat: [25, 1, 1, { flask: 40, burger: 10, firepotion: 5 }],
    skeleton: [110, 2, 2, { flask: 40, mailarmor: 10, axe: 20, firepotion: 5 }],
    goblin: [90, 2, 1, { flask: 50, leatherarmor: 20, axe: 10, firepotion: 5 }],
    ogre: [200, 3, 2, { burger: 10, flask: 50, platearmor: 20, morningstar: 20, firepotion: 5 }],
    spectre: [250, 2, 4, { flask: 30, redarmor: 40, redsword: 30, firepotion: 5 }],
    deathknight: [250, 3, 3, { burger: 95, firepotion: 5 }],
    crab: [60, 2, 1, { flask: 50, axe: 20, leatherarmor: 10, firepotion: 5 }],
    snake: [150, 3, 2, { flask: 50, mailarmor: 10, morningstar: 10, firepotion: 5 }],
    skeleton2: [200, 3, 3, { flask: 60, platearmor: 15, bluesword: 15, firepotion: 5 }],
    eye: [200, 3, 3, { flask: 50, redarmor: 20, redsword: 10, firepotion: 5 }],
    bat: [80, 2, 1, { flask: 50, axe: 10, firepotion: 5 }],
    wizard: [100, 2, 6, { flask: 50, platearmor: 20, firepotion: 5 }],
    boss: [700, 6, 7, { goldensword: 100 }],
  };
  for (const [name, [hp, armor, weapon, drops]] of Object.entries(expected)) {
    const p = Props.Properties[name];
    assert.deepEqual([p.hp, p.armor, p.weapon], [hp, armor, weapon], name);
    assert.deepEqual(Object.entries(p.drops), Object.entries(drops), `${name} drop order`);
  }
  assert.equal(Props.getWeaponLevel(Entities.GOLDENSWORD), 7);
  assert.equal(Props.getArmorLevel(Entities.CLOTHARMOR), 1);
  assert.equal(Props.getArmorLevel(Entities.BOSS), 6);
});

test('message numbering is unchanged from gametypes.js', () => {
  assert.equal(Messages.HELLO, 0);
  assert.equal(Messages.SPAWN, 2);
  assert.equal(Messages.HIT, 8);
  assert.equal(Messages.CHECK, 26);
});

test('format checker accepts original messages and the optional HELLO pubkey', () => {
  assert.ok(check([Messages.HELLO, 'bob', 21, 60]));
  assert.ok(check([Messages.HELLO, 'bob', 21, 60, 'a'.repeat(64)]));
  assert.ok(check([Messages.WHO, 1, 2, 3]));
  assert.ok(!check([Messages.MOVE, 'x', 1]));
  assert.ok(!check([Messages.WHO]));
  assert.ok(!check([99]));
  assert.ok(!check('not an array'));
});

function connectClient(world) {
  const received = [];
  const closed = [];
  const session = world.connect({
    id: world.nextConnectionId(),
    send: (messages) => received.push(...messages),
    close: (reason) => closed.push(reason),
  });
  return { session, received, closed };
}

const wait = (ms) => new Promise((r) => setTimeout(r, ms));

test('a world spawns mobs, NPCs and items and welcomes a player', async () => {
  const world = new World(mapData);
  world.run();
  try {
    assert.ok(world.mobs.size > 100, `mobs ${world.mobs.size}`);
    assert.ok(world.npcs.size > 10, `npcs ${world.npcs.size}`);
    assert.ok(world.items.size > 5, `items ${world.items.size}`);

    const client = connectClient(world);
    client.session.receive([Messages.HELLO, 'Tester', Entities.CLOTHARMOR, Entities.SWORD1]);
    await wait(60);
    const welcome = client.received.find((m) => m[0] === Messages.WELCOME);
    assert.ok(welcome, 'WELCOME received');
    assert.equal(welcome[2], 'Tester');
    assert.equal(welcome[5], 80, 'cloth armor HP');
    assert.ok(client.received.some((m) => m[0] === Messages.POPULATION && m[1] === 1));
    assert.ok(client.received.some((m) => m[0] === Messages.LIST));

    // A second HELLO from a live player closes the connection, as the original server did.
    client.session.receive([Messages.HELLO, 'Again', Entities.CLOTHARMOR, Entities.SWORD1]);
    assert.deepEqual(client.closed, ['Cannot initiate handshake twice']);
  } finally {
    world.dispose();
  }
});

test('hitting a rat until it dies yields DAMAGE, KILL and a despawn', async () => {
  const world = new World(mapData);
  world.run();
  try {
    const client = connectClient(world);
    client.session.receive([Messages.HELLO, 'Slayer', Entities.GOLDENARMOR, Entities.GOLDENSWORD]);
    const rat = [...world.mobs.values()].find((m) => m.kind === Entities.RAT);
    assert.ok(rat);
    for (let i = 0; i < 40 && world.mobs.has(rat.id); i++) {
      client.session.receive([Messages.HIT, rat.id]);
    }
    await wait(60);
    assert.ok(!world.mobs.has(rat.id), 'rat removed from the world');
    assert.ok(client.received.some((m) => m[0] === Messages.DAMAGE && m[1] === rat.id));
    assert.ok(client.received.some((m) => m[0] === Messages.KILL && m[1] === Entities.RAT));
  } finally {
    world.dispose();
  }
});

test('a snapshot carries dead mobs and hit points to a new host', () => {
  const a = new World(mapData);
  a.run();
  const b = new World(mapData);
  b.run();
  try {
    const mobs = [...a.mobs.values()];
    const wounded = mobs[0];
    wounded.hitPoints = 1;
    const dead = mobs[1];
    a.removeEntity(dead);

    restoreSnapshot(b, takeSnapshot(a));
    assert.equal(b.mobs.get(wounded.id)?.hitPoints, 1);
    assert.ok(!b.mobs.has(dead.id), 'dead mob stays dead on the new host');
  } finally {
    a.dispose();
    b.dispose();
  }
});

test('chat reaches players in the same zone group without a ZONE message', async () => {
  // The port's camera is fitted to the frame, so clients don't send ZONE on the
  // original 28x12 group boundaries; membership must follow MOVE.
  const world = new World(mapData);
  world.run();
  try {
    const valid = (x, y) => world.isValidPosition(x, y);
    // A spot for A, and a far-away spot (a non-adjacent group) for B to start from.
    let a = null;
    for (let y = 200; y < 260 && !a; y++) for (let x = 2; x < 80 && !a; x++) if (valid(x, y) && valid(x + 1, y)) a = { x, y };
    let far = null;
    for (let y = 20; y < 100 && !far; y++) for (let x = 2; x < 80 && !far; x++) if (valid(x, y)) far = { x, y };
    assert.ok(a && far, 'found positions');
    assert.notEqual(world.map.getGroupIdFromPosition(a.x, a.y), world.map.getGroupIdFromPosition(far.x, far.y));

    const alice = connectClient(world);
    alice.session.receive([Messages.HELLO, 'Alice', Entities.CLOTHARMOR, Entities.SWORD1]);
    const bob = connectClient(world);
    bob.session.receive([Messages.HELLO, 'Bob', Entities.CLOTHARMOR, Entities.SWORD1]);
    alice.session.receive([Messages.MOVE, a.x, a.y]);
    bob.session.receive([Messages.MOVE, far.x, far.y]);
    await wait(60);
    bob.session.receive([Messages.MOVE, a.x + 1, a.y]); // into Alice's group, no ZONE
    await wait(60);
    alice.received.length = 0;
    bob.session.receive([Messages.CHAT, 'hello']);
    await wait(60);
    assert.ok(alice.received.some((m) => m[0] === Messages.CHAT && m[2] === 'hello'), 'Alice hears Bob');
  } finally {
    world.dispose();
  }
});

test('chat reaches a visible neighbour across a zone-group boundary', async () => {
  const world = new World(mapData);
  world.run();
  try {
    const valid = (x, y) => world.isValidPosition(x, y);
    const group = (x, y) => world.map.getGroupIdFromPosition(x, y);
    // Two walkable tiles side by side that straddle a group boundary.
    let pair = null;
    for (let y = 200; y < 300 && !pair; y++) {
      for (let x = 1; x < 160 && !pair; x++) {
        if (valid(x, y) && valid(x + 1, y) && group(x, y) !== group(x + 1, y)) pair = [{ x, y }, { x: x + 1, y }];
      }
    }
    assert.ok(pair, 'found a boundary');
    const alice = connectClient(world);
    alice.session.receive([Messages.HELLO, 'Alice', Entities.CLOTHARMOR, Entities.SWORD1]);
    const bob = connectClient(world);
    bob.session.receive([Messages.HELLO, 'Bob', Entities.CLOTHARMOR, Entities.SWORD1]);
    alice.session.receive([Messages.MOVE, pair[0].x, pair[0].y]);
    bob.session.receive([Messages.MOVE, pair[1].x, pair[1].y]);
    await wait(60);
    alice.received.length = 0;
    bob.received.length = 0;
    bob.session.receive([Messages.CHAT, 'over here']);
    await wait(60);
    assert.ok(alice.received.some((m) => m[0] === Messages.CHAT && m[2] === 'over here'), 'Alice hears Bob');
    assert.ok(bob.received.some((m) => m[0] === Messages.CHAT && m[2] === 'over here'), 'Bob sees his own bubble');
  } finally {
    world.dispose();
  }
});