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