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:
deepseek
2026-08-23 07:01:24 +00:00
commit 93b018e42b
35 changed files with 7785 additions and 0 deletions
+171
View File
@@ -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); });