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