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); });
+99
View File
@@ -0,0 +1,99 @@
// LifeTown Online - headless verification: one short browser session per screen
import { chromium } from 'playwright-core';
import fs from 'node:fs';
const EXE = '/root/.cache/ms-playwright/chromium_headless_shell-1148/chrome-linux/headless_shell';
const URL = 'http://127.0.0.1:7788';
const OUT = '/tmp/lifetown-shots';
fs.mkdirSync(OUT, { recursive: true });
const ARGS = ['--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu-sandbox'];
const allErrors = [];
async function withPage(fn, label) {
let browser;
try {
browser = await chromium.launch({ executablePath: EXE, args: ARGS, timeout: 30000 });
const page = await browser.newPage({ viewport: { width: 960, height: 600 } });
page.on('pageerror', (e) => allErrors.push(`[${label}] ${String(e).slice(0, 250)}`));
page.on('console', (m) => { if (m.type() === 'error') allErrors.push(`[${label}] CONSOLE ${m.text().slice(0, 200)}`); });
await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 20000 });
await fn(page);
console.log(`[shot] OK ${label}`);
} catch (e) {
console.log(`[shot] FAIL ${label}: ${String(e).slice(0, 140)}`);
} finally {
try { await browser?.close(); } catch {}
}
}
async function enterGame(page) {
const uname = 'Sunny' + Math.floor(Math.random() * 900000 + 100000);
await page.waitForTimeout(700);
await page.click('#btn-newchar', { force: true });
await page.fill('#cr-name', uname);
await page.click('#btn-startlife', { force: true });
await page.waitForTimeout(4200);
}
(async () => {
await withPage(async (page) => {
await page.waitForTimeout(1100);
await page.screenshot({ path: `${OUT}/1-title.png` });
}, 'title');
await withPage(async (page) => {
await page.waitForTimeout(700);
await page.click('#btn-newchar', { force: true });
await page.fill('#cr-name', 'Sunny' + Math.floor(Math.random() * 900000 + 100000));
await page.waitForTimeout(1200);
await page.screenshot({ path: `${OUT}/2-creator.png` });
}, 'creator');
await withPage(async (page) => {
await enterGame(page);
await page.screenshot({ path: `${OUT}/3-town.png` });
await page.keyboard.down('KeyW');
await page.waitForTimeout(1200);
await page.keyboard.up('KeyW');
await page.waitForTimeout(400);
await page.screenshot({ path: `${OUT}/4-walked.png` });
}, 'town');
await withPage(async (page) => {
await enterGame(page);
await page.click('[data-act="phone"]', { force: true });
await page.waitForTimeout(900);
await page.screenshot({ path: `${OUT}/5-phone.png` });
}, 'phone');
await withPage(async (page) => {
await enterGame(page);
await page.click('[data-act="home"]', { force: true });
await page.waitForTimeout(600);
await page.screenshot({ path: `${OUT}/6-homemenu.png` });
const btns = await page.$$('#dialog-body button');
if (btns[1]) await btns[1].click({ force: true });
await page.waitForTimeout(3200);
await page.screenshot({ path: `${OUT}/7-home.png` });
}, 'home');
await withPage(async (page) => {
await enterGame(page);
await page.click('[data-act="home"]', { force: true });
await page.waitForTimeout(500);
const btns = await page.$$('#dialog-body button');
if (btns[1]) await btns[1].click({ force: true });
await page.waitForTimeout(3000);
await page.click('[data-act="build"]', { force: true });
await page.waitForTimeout(700);
await page.click('#btn-shop', { force: true });
await page.waitForTimeout(900);
await page.screenshot({ path: `${OUT}/8-catalog.png` });
await page.click('#catalog-close', { force: true });
await page.waitForTimeout(400);
await page.screenshot({ path: `${OUT}/9-buildmode.png` });
}, 'build');
console.log(allErrors.length ? `PAGE ERRORS (${allErrors.length}):\n` + allErrors.slice(0, 10).join('\n') : 'NO PAGE ERRORS');
})();