LifeTown Online — browser multiplayer life-sim MVP
- Server-authoritative Node/WS server: sessions, zones (town + instanced homes + public venue interiors), NPC routines, economy, relationships, persistence - TypeScript + Three.js client: character creator, Maple Court district, enterable venues (café, city hall, gym, store, arcade), build mode with 58-item furniture catalog, needs/mood systems, phone UI, day/night + weather, procedural audio - Verified by two-client e2e protocol suite and headless-browser walkthrough
This commit is contained in:
+171
@@ -0,0 +1,171 @@
|
||||
// LifeTown Online — end-to-end server verification with two simulated clients.
|
||||
import WebSocket from 'ws';
|
||||
import fs from 'node:fs';
|
||||
|
||||
// fresh fixtures: remove previous test saves so character creation is exercised
|
||||
for (const f of ['e2e_a', 'e2e_b']) {
|
||||
try { fs.unlinkSync(new globalThis.URL(`../saves/${f}.json`, import.meta.url)); } catch {}
|
||||
}
|
||||
|
||||
const WS_URL = 'ws://127.0.0.1:7788/ws';
|
||||
let failures = 0;
|
||||
function ok(name, cond, extra = '') {
|
||||
console.log(`${cond ? '✅' : '❌'} ${name}${extra ? ' — ' + extra : ''}`);
|
||||
if (!cond) failures++;
|
||||
}
|
||||
const wait = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
class Client {
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
this.inbox = [];
|
||||
this.ws = new WebSocket(WS_URL);
|
||||
this.ws.on('message', (d) => {
|
||||
const m = JSON.parse(d.toString());
|
||||
this.inbox.push(m);
|
||||
});
|
||||
this.opened = new Promise(res => this.ws.on('open', res));
|
||||
}
|
||||
async send(msg) { await this.opened; this.ws.send(JSON.stringify(msg)); }
|
||||
async until(pred, timeout = 4000, label = '') {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < timeout) {
|
||||
const hit = this.inbox.find(pred);
|
||||
if (hit) return hit;
|
||||
await wait(40);
|
||||
}
|
||||
throw new Error(`timeout waiting for ${label || pred}`);
|
||||
}
|
||||
clear() { this.inbox.length = 0; }
|
||||
}
|
||||
|
||||
const appearanceA = {
|
||||
bodyType: 'average', height: 1, skin: '#eab98f', hair: 'short', hairColor: '#4a2f1d',
|
||||
eyes: 'round', eyeColor: '#3a5a40', outfitTop: 'tee', topColor: '#4f8fd9', bottomColor: '#33415c',
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const a = new Client('E2E_A');
|
||||
const b = new Client('E2E_B');
|
||||
|
||||
// ---- create characters ----
|
||||
await a.send({ t: 'hello', name: 'E2E_A', appearance: appearanceA });
|
||||
const wa = await a.until(m => m.t === 'welcome' || m.t === 'error', 5000, 'welcome A');
|
||||
ok('A welcome', wa.t === 'welcome', `money=${wa.profile?.money} token=${!!wa.token}`);
|
||||
|
||||
await b.send({ t: 'hello', name: 'E2E_B', appearance: {} });
|
||||
const wb = await b.until(m => m.t === 'welcome' || m.t === 'error', 5000, 'welcome B');
|
||||
ok('B welcome', wb.t === 'welcome');
|
||||
|
||||
// join visibility: A was already in town -> B gets roster; B joining -> A gets playerJoin
|
||||
const rosterB = await b.until(m => m.t === 'roster' && m.players.some(p => p.id === wa.yourId), 4000, 'roster with A');
|
||||
ok('B received roster including A', !!rosterB);
|
||||
const joinA = await a.until(m => m.t === 'playerJoin' && m.id === wb.yourId, 4000, 'playerJoin B');
|
||||
ok('A notified of B joining (playerJoin)', !!joinA);
|
||||
|
||||
// duplicate name protection
|
||||
const c = new Client('E2E_A');
|
||||
await c.send({ t: 'hello', name: 'E2E_A' });
|
||||
const wc = await c.until(m => m.t === 'error', 4000, 'dup-name error');
|
||||
ok('duplicate name rejected', wc.code === 'taken');
|
||||
c.ws.close();
|
||||
|
||||
// ---- movement sync ----
|
||||
await wait(300);
|
||||
a.clear(); b.clear();
|
||||
await a.send({ t: 'move', x: 3.5, z: 11, ry: 1, anim: 'walk' });
|
||||
await wait(300);
|
||||
const snapB = b.inbox.filter(m => m.t === 'snapshot').find(s => s.players.some(p => p.id === wa.yourId));
|
||||
ok('B sees A moving in snapshot', !!snapB);
|
||||
|
||||
// ---- chat ----
|
||||
b.clear();
|
||||
await a.send({ t: 'chat', text: 'Hello from A!', channel: 'global' });
|
||||
const chatB = await b.until(m => m.t === 'chat' && m.from === 'E2E_A', 3000, 'chat relay');
|
||||
ok('global chat relayed', chatB.text.includes('Hello from A'));
|
||||
|
||||
// ---- player-player social ----
|
||||
a.clear();
|
||||
await a.send({ t: 'social', targetType: 'player', targetId: wb.yourId, action: 'hello' });
|
||||
const relA = await a.until(m => m.t === 'relUpdate', 3000, 'relUpdate A');
|
||||
const relAB = relA.relationships['p:e2e_b'];
|
||||
ok('social hello raises friendship', relAB && relAB.f >= 2, `f=${relAB?.f}`);
|
||||
|
||||
// locked interaction (flirt needs romance gates fine at r0? flirt req.r=0 so allowed; use holdhands req.r=25)
|
||||
a.clear();
|
||||
await a.send({ t: 'social', targetType: 'player', targetId: wb.yourId, action: 'holdhands' });
|
||||
const errLocked = await a.until(m => m.t === 'error', 3000, 'locked error');
|
||||
ok('gated interaction blocked', errLocked.code === 'locked', errLocked.message);
|
||||
|
||||
// ---- jobs & economy ----
|
||||
a.clear();
|
||||
await a.send({ t: 'job_select', jobId: 'barista' });
|
||||
const ju = await a.until(m => m.t === 'jobUpdate', 3000, 'jobUpdate');
|
||||
ok('job selected', ju.job?.id === 'barista');
|
||||
|
||||
a.clear();
|
||||
await a.send({ t: 'job_work' });
|
||||
const wr = await a.until(m => m.t === 'workResult', 3000, 'workResult');
|
||||
ok('work shift pays', wr.pay > 0, `pay=${wr.pay} money=${wr.money}`);
|
||||
|
||||
a.clear();
|
||||
await a.send({ t: 'job_work' });
|
||||
const cdErr = await a.until(m => m.t === 'error', 3000, 'cooldown error');
|
||||
ok('work cooldown enforced', cdErr.code === 'cooldown');
|
||||
|
||||
// ---- shopping ----
|
||||
a.clear();
|
||||
await a.send({ t: 'buy_item', itemId: 'bed_basic' });
|
||||
const inv = await a.until(m => m.t === 'inventory', 3000, 'inventory update');
|
||||
ok('item purchased into inventory', inv.inventory.some(i => i.itemId === 'bed_basic'));
|
||||
|
||||
// house purchase should fail on funds/level
|
||||
a.clear();
|
||||
await a.send({ t: 'buy_house', houseId: 'mansion' });
|
||||
const houseErr = await a.until(m => m.t === 'error', 3000, 'house locked');
|
||||
ok('mansion gated', houseErr.code === 'level' || houseErr.code === 'poor');
|
||||
|
||||
// ---- home layout persistence ----
|
||||
a.clear();
|
||||
await a.send({ t: 'place_home', layout: [{ itemId: 'bed_basic', x: -2, z: -2, rot: 0 }, { itemId: 'plant_fern', x: 2.5, z: 1.5, rot: 1 }] });
|
||||
const ls = await a.until(m => m.t === 'layoutSaved', 3000, 'layoutSaved');
|
||||
ok('home layout saved', ls.count === 2);
|
||||
|
||||
// ---- zone switching ----
|
||||
a.clear();
|
||||
await a.send({ t: 'zone', id: `home:e2e_a` });
|
||||
const zo = await a.until(m => m.t === 'zoneOk', 3000, 'zoneOk home');
|
||||
ok('entered own home', zo.zone === 'home:e2e_a');
|
||||
|
||||
// B cannot enter A's home uninvited
|
||||
b.clear();
|
||||
await b.send({ t: 'zone', id: 'home:e2e_a' });
|
||||
const lockHome = await b.until(m => m.t === 'error', 3000, 'home locked');
|
||||
ok('stranger blocked from home', lockHome.code === 'locked');
|
||||
|
||||
// invitation flow
|
||||
a.clear(); b.clear();
|
||||
await a.send({ t: 'invite', to: 'E2E_B' });
|
||||
const notifB = await b.until(m => m.t === 'notify' && m.action?.type === 'visit', 3000, 'invite notify');
|
||||
await b.send({ t: 'zone', id: notifB.action.home });
|
||||
const zoB = await b.until(m => m.t === 'zoneOk', 3000, 'zoneOk visit');
|
||||
ok('invited friend can visit home', zoB.zone === 'home:e2e_a');
|
||||
|
||||
// back to town
|
||||
b.clear();
|
||||
await b.send({ t: 'zone', id: 'town' });
|
||||
const zoT = await b.until(m => m.t === 'zoneOk', 3000, 'back to town');
|
||||
ok('returned to town', zoT.zone === 'town');
|
||||
|
||||
// time & weather broadcast
|
||||
const timeMsg = await a.until(m => m.t === 'time', 4000, 'time broadcast');
|
||||
ok('server clock ticking', typeof timeMsg.minutes === 'number', `${timeMsg.day} day, ${Math.round(timeMsg.minutes)} min`);
|
||||
|
||||
a.ws.close(); b.ws.close();
|
||||
await wait(200);
|
||||
|
||||
console.log(failures ? `\n${failures} FAILURES` : '\n🎉 ALL E2E CHECKS PASSED');
|
||||
process.exit(failures ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch(e => { console.error('E2E crashed:', e.message); process.exit(1); });
|
||||
Reference in New Issue
Block a user