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:
+636
@@ -0,0 +1,636 @@
|
||||
// LifeTown Online — authoritative game server: sessions, zones, snapshots,
|
||||
// NPC simulation, world clock/weather, economy, relationships, persistence.
|
||||
import { WebSocket } from 'ws';
|
||||
import {
|
||||
TIME_SCALE, DAY_MINUTES, JOBS, XP_PER_RANK, WORK_SECONDS, WORK_COOLDOWN,
|
||||
NPCS, AREA_POINTS, INTERACTION_BY_ID, REL_MAX, ROMANCE_PARTNER_AT,
|
||||
CATALOG_BY_ID, HOUSES, MAX_CHAT_LEN, WEATHER_STATES,
|
||||
FRIEND_TIERS, NEEDS, VENUES,
|
||||
} from '../shared/data.mjs';
|
||||
import { loadProfile, saveProfile, newProfile, findProfileByToken, listProfileNames, sanitizeName } from './persist.mjs';
|
||||
|
||||
const TICK_MS = 1000 / 12; // snapshot rate
|
||||
const MOVE_RATE_MS = 66; // expected client movement packet interval
|
||||
const MAX_SPEED = 6.5; // metres/sec sanity cap
|
||||
|
||||
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
||||
const dist2d = (a, b) => Math.hypot(a.x - b.x, a.z - b.z);
|
||||
|
||||
function relKeyOf(targetType, id) { return targetType === 'npc' ? `npc:${id}` : `p:${String(id).toLowerCase()}`; }
|
||||
|
||||
function friendLabel(f) {
|
||||
let label = FRIEND_TIERS[0].label;
|
||||
for (const t of FRIEND_TIERS) if (f >= t.at) label = t.label;
|
||||
return label;
|
||||
}
|
||||
|
||||
export class GameServer {
|
||||
constructor(wss) {
|
||||
this.wss = wss;
|
||||
this.sessions = new Set(); // all live sessions
|
||||
this.byZone = new Map(); // zoneId -> Set<session>
|
||||
this.npcs = NPCS.map(def => ({
|
||||
id: def.id, name: def.name, role: def.role, color: def.color,
|
||||
x: 0, z: 12, ry: 0, anim: 'idle',
|
||||
target: null, waitUntil: 0, lineAt: Date.now() + 8000 + Math.random() * 20000,
|
||||
}));
|
||||
// start the world at 07:30 day 1 (1 real second = TIME_SCALE game-seconds)
|
||||
const startGameMin = 7 * 60 + 30;
|
||||
const elapsedRealSec = startGameMin * 60 / TIME_SCALE;
|
||||
this.t0 = Date.now() - elapsedRealSec * 1000;
|
||||
this.day = 1;
|
||||
this.weather = 'sunny';
|
||||
this.nextWeatherRoll = Date.now() + 30000;
|
||||
|
||||
this.interval = setInterval(() => this.tick(), TICK_MS);
|
||||
this.slowInterval = setInterval(() => this.slowTick(), 2000);
|
||||
console.log(`[LifeTown] world initialised — ${this.npcs.length} townsfolk ready`);
|
||||
}
|
||||
|
||||
// ---------------- world clock ---------------------------
|
||||
gameTime() {
|
||||
const totalGameSec = Math.floor((Date.now() - this.t0) / 1000) * TIME_SCALE;
|
||||
const totalMin = Math.floor(totalGameSec / 60);
|
||||
return { day: Math.floor(totalMin / DAY_MINUTES) + 1, minutes: totalMin % DAY_MINUTES };
|
||||
}
|
||||
|
||||
// ---------------- connection lifecycle ------------------
|
||||
onConnection(ws) {
|
||||
ws.on('message', (data) => this.onMessage(ws, data));
|
||||
ws.on('close', () => this.onClose(ws));
|
||||
ws.on('error', () => {});
|
||||
ws.isAlive = true;
|
||||
}
|
||||
|
||||
onClose(ws) {
|
||||
const s = ws.__session;
|
||||
if (!s) return;
|
||||
this.sessions.delete(s);
|
||||
this.leaveZone(s);
|
||||
try { saveProfile(s.profile); } catch {}
|
||||
this.broadcastToZone(s.zone, 'playerLeave', { id: s.id }, s);
|
||||
console.log(`[LifeTown] ${s.profile.name} left the town`);
|
||||
}
|
||||
|
||||
leaveZone(s) {
|
||||
const set = this.byZone.get(s.zone);
|
||||
if (set) { set.delete(s); if (!set.size) this.byZone.delete(s.zone); }
|
||||
}
|
||||
joinZone(s, zoneId) {
|
||||
this.leaveZone(s);
|
||||
s.zone = zoneId;
|
||||
if (!this.byZone.has(zoneId)) this.byZone.set(zoneId, new Set());
|
||||
this.byZone.get(zoneId).add(s);
|
||||
// tell zone about the newcomer (full appearance so clients build the avatar)
|
||||
this.broadcastToZone(zoneId, 'playerJoin', { id: s.id, name: s.profile.name, appearance: s.publicAppearance(), zone: zoneId }, s);
|
||||
// roster reply to mover: everyone currently here
|
||||
const roster = [...(this.byZone.get(zoneId) || [])].filter(o => o !== s)
|
||||
.map(o => ({ id: o.id, name: o.profile.name, appearance: o.publicAppearance() }));
|
||||
this.send(s.ws, 'roster', { zone: zoneId, players: roster });
|
||||
}
|
||||
|
||||
// ---------------- messaging primitives -------------------
|
||||
send(ws, t, data) { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ t, ...data })); }
|
||||
sendSession(s, t, data) { this.send(s.ws, t, data); }
|
||||
broadcast(t, data) { for (const s of this.sessions) this.sendSession(s, t, data); }
|
||||
broadcastToZone(zoneId, t, data, exclude) {
|
||||
const set = this.byZone.get(zoneId);
|
||||
if (!set) return;
|
||||
for (const s of set) if (s !== exclude) this.sendSession(s, t, data);
|
||||
}
|
||||
|
||||
onMessage(ws, raw) {
|
||||
let msg;
|
||||
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
||||
if (!msg || typeof msg.t !== 'string') return;
|
||||
const s = ws.__session;
|
||||
|
||||
switch (msg.t) {
|
||||
case 'hello': return this.hello(ws, msg);
|
||||
case 'ping': return this.send(ws, 'pong', {});
|
||||
}
|
||||
if (!s) return;
|
||||
switch (msg.t) {
|
||||
case 'move': return this.move(s, msg);
|
||||
case 'chat': return this.chat(s, msg);
|
||||
case 'emote': return this.emote(s, msg);
|
||||
case 'social': return this.social(s, msg);
|
||||
case 'zone': return this.switchZone(s, msg);
|
||||
case 'invite': return this.invite(s, msg);
|
||||
case 'job_select': return this.jobSelect(s, msg);
|
||||
case 'job_work': return this.jobWork(s, msg);
|
||||
case 'buy_item': return this.buyItem(s, msg);
|
||||
case 'buy_house': return this.buyHouse(s, msg);
|
||||
case 'place_home': return this.placeHome(s, msg);
|
||||
case 'sell_item': return this.sellItem(s, msg);
|
||||
case 'sell_placed': return this.sellPlaced(s, msg);
|
||||
case 'needs_sync': return this.needsSync(s, msg);
|
||||
case 'gift_player': return this.giftPlayer(s, msg);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- hello / character creation -------------
|
||||
hello(ws, msg) {
|
||||
const name = sanitizeName(msg.name);
|
||||
if (name.length < 2) return this.send(ws, 'error', { code: 'name', message: 'Name must be 2-20 letters.' });
|
||||
|
||||
let profile = null;
|
||||
if (msg.token) profile = findProfileByToken(msg.token);
|
||||
if (!profile) {
|
||||
profile = loadProfile(name);
|
||||
if (profile && (!msg.token || profile.token !== msg.token)) {
|
||||
// name belongs to another save; refuse politely
|
||||
return this.send(ws, 'error', { code: 'taken', message: `The name "${name}" already lives in LifeTown. Pick another.` });
|
||||
}
|
||||
if (!profile) {
|
||||
profile = newProfile(name, msg.appearance || {});
|
||||
saveProfile(profile);
|
||||
}
|
||||
}
|
||||
if (this.sessions.size >= 64) return this.send(ws, 'error', { code: 'full', message: 'Town is full right now (64 residents online).' });
|
||||
|
||||
const s = {
|
||||
id: `${profile.name.toLowerCase()}#${Math.random().toString(36).slice(2, 7)}`,
|
||||
ws, profile,
|
||||
zone: 'town', x: 0, z: 12, ry: 0, anim: 'idle',
|
||||
lastMoveAt: Date.now(), socialCooldownAt: {}, lastNeedsSync: 0,
|
||||
publicAppearance: () => ({
|
||||
bodyType: profile.appearance?.bodyType ?? 'average',
|
||||
height: profile.appearance?.height ?? 1,
|
||||
skin: profile.appearance?.skin ?? '#eab98f',
|
||||
hair: profile.appearance?.hair ?? 'short',
|
||||
hairColor: profile.appearance?.hairColor ?? '#4a2f1d',
|
||||
eyeColor: profile.appearance?.eyeColor ?? '#3a5a40',
|
||||
topColor: profile.appearance?.outfit?.topColor ?? '#4f8fd9',
|
||||
bottomColor: profile.appearance?.outfit?.bottomColor ?? '#33415c',
|
||||
outfitTop: profile.appearance?.outfit?.top ?? 'tee',
|
||||
outfitBottom: profile.appearance?.outfit?.bottom ?? 'jeans',
|
||||
dress: profile.appearance?.outfit?.dress ?? null,
|
||||
shoes: profile.appearance?.outfit?.shoes ?? 'sneakers',
|
||||
accessory: profile.appearance?.outfit?.accessory ?? 'none',
|
||||
name: profile.name,
|
||||
}),
|
||||
};
|
||||
ws.__session = s;
|
||||
this.sessions.add(s);
|
||||
|
||||
const time = this.gameTime();
|
||||
this.joinZone(s, 'town');
|
||||
this.send(ws, 'welcome', {
|
||||
yourId: s.id, token: profile.token, zone: 'town',
|
||||
profile: this.clientProfile(profile),
|
||||
time: { ...time, scale: TIME_SCALE },
|
||||
weather: this.weather,
|
||||
spawn: { x: 0, z: 12 },
|
||||
online: [...this.sessions].filter(o => o !== s).map(o => ({ id: o.id, name: o.profile.name })),
|
||||
residentsOnline: this.sessions.size,
|
||||
});
|
||||
this.broadcastToZone('town', 'notify', { title: 'Welcome!', text: `${profile.name} just arrived in LifeTown.`, icon: '👋' }, s);
|
||||
console.log(`[LifeTown] ${profile.name} joined (${this.sessions.size} online)`);
|
||||
}
|
||||
|
||||
clientProfile(p) {
|
||||
return {
|
||||
name: p.name, money: p.money, level: p.level, xp: p.xp,
|
||||
job: p.job, houseTier: p.houseTier, housesOwned: p.housesOwned,
|
||||
homeLayout: p.homeLayout, inventory: p.inventory,
|
||||
relationships: p.relationships, achievements: p.achievements,
|
||||
stats: p.stats, appearance: p.appearance, needs: p.needs, moodlets: p.moodlets || [],
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------- movement -------------------------------
|
||||
move(s, msg) {
|
||||
const now = Date.now();
|
||||
const dt = Math.min((now - s.lastMoveAt) / 1000, 1);
|
||||
const nx = Number(msg.x), nz = Number(msg.z);
|
||||
if (!Number.isFinite(nx) || !Number.isFinite(nz)) return;
|
||||
const d = Math.hypot(nx - s.x, nz - s.z);
|
||||
if (d > MAX_SPEED * dt + 2.5) {
|
||||
// implausible jump: clamp toward previous position but stay responsive
|
||||
const k = (MAX_SPEED * dt + 2.5) / d;
|
||||
s.x += (nx - s.x) * k; s.z += (nz - s.z) * k;
|
||||
} else { s.x = clamp(nx, -72, 72); s.z = clamp(nz, -46, 46); }
|
||||
s.ry = Number(msg.ry) || s.ry;
|
||||
s.anim = typeof msg.anim === 'string' ? String(msg.anim).slice(0, 16) : s.anim;
|
||||
s.lastMoveAt = now;
|
||||
}
|
||||
|
||||
// ---------------- chat & emotes --------------------------
|
||||
chat(s, msg) {
|
||||
const text = String(msg.text || '').slice(0, MAX_CHAT_LEN).trim();
|
||||
if (!text) return;
|
||||
if (msg.channel === 'global') {
|
||||
this.broadcast('chat', { from: s.profile.name, text, channel: 'global', ts: Date.now() });
|
||||
} else {
|
||||
this.broadcastToZone(s.zone, 'chat', { from: s.profile.name, text, channel: 'say', ts: Date.now() });
|
||||
this.broadcastToZone(s.zone, 'bubble', { id: s.id, text });
|
||||
}
|
||||
}
|
||||
|
||||
emote(s, msg) {
|
||||
const id = String(msg.id || '').slice(0, 16);
|
||||
this.broadcastToZone(s.zone, 'emote', { id: s.id, emote: id });
|
||||
if (id === 'dance') {
|
||||
s.profile.stats.dances = (s.profile.stats.dances || 0) + 1;
|
||||
this.checkAchievements(s);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- zones ---------------------------------
|
||||
switchZone(s, msg) {
|
||||
const want = String(msg.id || '');
|
||||
if (want === 'town') {
|
||||
s.x = 0; s.z = 12; s.anim = 'idle';
|
||||
this.joinZone(s, 'town');
|
||||
this.sendSession(s, 'zoneOk', { zone: 'town', spawn: { x: 0, z: 12 } });
|
||||
return;
|
||||
}
|
||||
if (want.startsWith('venue:')) {
|
||||
if (!VENUES[want]) return this.sendSession(s, 'error', { code: 'nozone', message: 'That place does not exist.' });
|
||||
s.x = 0; s.z = 4; s.anim = 'idle';
|
||||
this.joinZone(s, want);
|
||||
this.sendSession(s, 'zoneOk', { zone: want, spawn: { x: 0, z: 4 } });
|
||||
return;
|
||||
}
|
||||
if (want.startsWith('home:')) {
|
||||
const owner = want.slice(5);
|
||||
const isSelf = owner === s.profile.name.toLowerCase();
|
||||
const rel = s.profile.relationships[`p:${owner}`];
|
||||
const allowed = isSelf || (rel && rel.f >= 25) || (s.invitedTo === want);
|
||||
if (!allowed) return this.sendSession(s, 'error', { code: 'locked', message: 'You need an invitation (or friendship ≥ Friend) to visit that home.' });
|
||||
s.x = 0; s.z = 6; s.anim = 'idle';
|
||||
this.joinZone(s, want);
|
||||
const ownerProf = loadProfile(owner);
|
||||
this.sendSession(s, 'zoneOk', {
|
||||
zone: want, spawn: { x: 0, z: 6 },
|
||||
tier: ownerProf?.houseTier || 'studio',
|
||||
layout: ownerProf?.homeLayout || [],
|
||||
own: isSelf,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
invite(s, msg) {
|
||||
const targetName = sanitizeName(msg.to).toLowerCase();
|
||||
const target = [...this.sessions].find(o => o.profile.name.toLowerCase() === targetName);
|
||||
if (!target) return this.sendSession(s, 'error', { code: 'offline', message: 'That resident is not online.' });
|
||||
target.invitedTo = `home:${s.profile.name.toLowerCase()}`;
|
||||
this.sendSession(target, 'notify', { title: 'Invitation', text: `${s.profile.name} invited you to their home!`, icon: '🏠', action: { type: 'visit', home: `home:${s.profile.name.toLowerCase()}` } });
|
||||
this.sendSession(s, 'notify', { title: 'Invite sent', text: `${target.profile.name} can now visit your home.`, icon: '📨' });
|
||||
}
|
||||
|
||||
giftPlayer(s, msg) {
|
||||
const targetName = sanitizeName(msg.to).toLowerCase();
|
||||
const target = [...this.sessions].find(o => o.profile.name.toLowerCase() === targetName);
|
||||
if (!target || target.zone !== s.zone) return this.sendSession(s, 'error', { code: 'range', message: 'They are not nearby.' });
|
||||
if (dist2d(s, target) > 6) return this.sendSession(s, 'error', { code: 'range', message: 'Walk closer to hand over the gift.' });
|
||||
const cost = 40;
|
||||
if (s.profile.money < cost) return this.sendSession(s, 'error', { code: 'poor', message: 'Not enough TownCoins (need 40).' });
|
||||
s.profile.money -= cost;
|
||||
s.profile.stats.giftsSent++;
|
||||
this.applyRelDelta(s.profile, 'p', target.profile.name, { df: 2, dr: 6 });
|
||||
this.applyRelDelta(target.profile, 'p', s.profile.name, { df: 3, dr: 4 });
|
||||
target.profile.money += 20; // small thank-you kickback keeps economy friendly
|
||||
this.broadcastToZone(s.zone, 'socialFx', {
|
||||
from: s.id, to: target.id, anim: 'gift', emoji: '🎁',
|
||||
lines: [`${s.profile.name} gives ${target.profile.name} a present!`],
|
||||
});
|
||||
this.sendSession(s, 'money', { amount: s.profile.money });
|
||||
this.sendSession(target, 'money', { amount: target.profile.money });
|
||||
this.pushRelUpdate(s); this.pushRelUpdate(target);
|
||||
this.checkAchievements(s);
|
||||
}
|
||||
|
||||
// ---------------- social interactions --------------------
|
||||
social(s, msg) {
|
||||
const action = INTERACTION_BY_ID[String(msg.action || '')];
|
||||
if (!action) return;
|
||||
const type = msg.targetType === 'player' ? 'p' : 'npc';
|
||||
const now = Date.now();
|
||||
if ((s.socialCooldownAt[action.id] || 0) > now) return this.sendSession(s, 'error', { code: 'cooldown', message: 'Give it a moment…' });
|
||||
|
||||
// locate target
|
||||
let target = null, targetName = '', targetWs = null;
|
||||
if (type === 'npc') {
|
||||
const zset = this.byZone.get('town');
|
||||
if (s.zone !== 'town') return this.sendSession(s, 'error', { code: 'range', message: 'Townsfolk are out in the district.' });
|
||||
target = this.npcs.find(n => n.id === msg.targetId);
|
||||
targetName = target ? target.name : '';
|
||||
if (!zset || !zset.has(s)) return;
|
||||
} else {
|
||||
target = [...this.sessions].find(o => o.id === msg.targetId && o.zone === s.zone);
|
||||
if (!target) return this.sendSession(s, 'error', { code: 'range', message: 'They are not here anymore.' });
|
||||
targetName = target.profile.name; targetWs = target;
|
||||
}
|
||||
if (!target) return;
|
||||
if (dist2d(s, target) > 4.5) return this.sendSession(s, 'error', { code: 'range', message: 'Too far away — walk closer first.' });
|
||||
|
||||
const rel = s.profile.relationships[relKeyOf(type, type === 'npc' ? msg.targetId : targetName)] || { f: 0, r: 0 };
|
||||
if (rel.f < (action.req.f || 0) || rel.r < (action.req.r || 0))
|
||||
return this.sendSession(s, 'error', { code: 'locked', message: `Your bond isn't strong enough yet (need ${Math.max(action.req.f || 0, action.req.r || 0)}).` });
|
||||
|
||||
if (action.cost) {
|
||||
if (s.profile.money < action.cost) return this.sendSession(s, 'error', { code: 'poor', message: `Costs ${action.cost} TC.` });
|
||||
s.profile.money -= action.cost;
|
||||
this.sendSession(s, 'money', { amount: s.profile.money });
|
||||
}
|
||||
|
||||
// apply relationship deltas (initiator gains slightly less romance than target loses none — both grow)
|
||||
const df = action.df | 0, dr = action.dr | 0;
|
||||
const relA = this.applyRelDelta(s.profile, type, type === 'npc' ? msg.targetId : targetName, { df, dr });
|
||||
let relB = null;
|
||||
if (type === 'p') relB = this.applyRelDelta(target.profile, 'p', s.profile.name, { df: Math.ceil(df * 0.8), dr });
|
||||
|
||||
s.socialCooldownAt[action.id] = now + 1500;
|
||||
|
||||
// visible outcome
|
||||
const line = (action.lines && action.lines.length)
|
||||
? action.lines[Math.floor(Math.random() * action.lines.length)].replace('{name}', targetName)
|
||||
: '';
|
||||
this.broadcastToZone(s.zone, 'socialFx', {
|
||||
from: s.id, to: target.id ?? msg.targetId, anim: action.anim, group: action.group, emoji: action.group === 'Negative' ? '💢' : '💬',
|
||||
lines: line ? [`${s.profile.name}: ${line}`] : [],
|
||||
});
|
||||
if (action.anim === 'dance') { s.profile.stats.dances++; }
|
||||
|
||||
// NPC reaction: chance to answer with a friendly/negative line based on resulting friendship
|
||||
if (type === 'npc') {
|
||||
const willRespond = Math.random() < 0.8;
|
||||
if (willRespond) {
|
||||
const good = (df + dr) >= 0 && relA.f >= 10;
|
||||
const replies = good
|
||||
? ['That made my day!', 'You are sweet, you know that?', 'LifeTown suits you.', 'Ha! Good one.', 'Same time tomorrow?', 'You always know what to say.']
|
||||
: ['Wow. Just… wow.', 'I did NOT need that today.', 'Please be kinder.'];
|
||||
const delay = 900 + Math.random() * 1200;
|
||||
setTimeout(() => {
|
||||
if (target) this.broadcastToZone('town', 'bubble', { id: target.id, text: replies[Math.floor(Math.random() * replies.length)] });
|
||||
}, delay);
|
||||
}
|
||||
} else if (targetWs) {
|
||||
this.pushRelUpdate(target);
|
||||
}
|
||||
|
||||
this.pushRelUpdate(s);
|
||||
this.checkAchievements(s);
|
||||
}
|
||||
|
||||
applyRelDelta(profile, type, id, { df, dr }) {
|
||||
const key = relKeyOf(type, id);
|
||||
const rel = profile.relationships[key] || { f: 0, r: 0 };
|
||||
rel.f = clamp(rel.f + df, 0, REL_MAX);
|
||||
rel.r = clamp(rel.r + dr, 0, REL_MAX);
|
||||
const wasPartner = !!rel.partner;
|
||||
if (!wasPartner && rel.r >= ROMANCE_PARTNER_AT) {
|
||||
rel.partner = true;
|
||||
profile.stats.partners = (profile.stats.partners || 0) + 1;
|
||||
this.send(this.wsOf(profile), 'notify', { title: 'It\'s official!', text: `You and ${id} are partners now! 💞`, icon: '💞' });
|
||||
}
|
||||
if (wasPartner && rel.r < ROMANCE_PARTNER_AT - 15) rel.partner = false; // breakup by decay
|
||||
profile.relationships[key] = rel;
|
||||
return rel;
|
||||
}
|
||||
|
||||
pushRelUpdate(s) {
|
||||
this.sendSession(s, 'relUpdate', { relationships: s.profile.relationships });
|
||||
}
|
||||
|
||||
wsOf(profile) {
|
||||
const found = [...this.sessions].find(x => x.profile === profile);
|
||||
return found ? found.ws : null;
|
||||
}
|
||||
|
||||
// ---------------- jobs & economy -------------------------
|
||||
jobSelect(s, msg) {
|
||||
const job = JOBS.find(j => j.id === msg.jobId);
|
||||
if (!job) return;
|
||||
if (s.profile.job && s.profile.job.id === job.id) return;
|
||||
const wasEmployed = !!s.profile.job;
|
||||
s.profile.job = { id: job.id, rank: wasEmployed ? 0 : 0, xp: 0 };
|
||||
this.sendSession(s, 'jobUpdate', { job: s.profile.job });
|
||||
this.sendSession(s, 'notify', { title: 'New career!', text: `You are now a ${job.ranks[0]} at ${job.title}.`, icon: '💼' });
|
||||
this.checkAchievements(s);
|
||||
saveProfile(s.profile);
|
||||
}
|
||||
|
||||
jobWork(s, msg) {
|
||||
const p = s.profile;
|
||||
if (!p.job) return this.sendSession(s, 'error', { code: 'nojob', message: 'Get a job at City Hall first!' });
|
||||
const now = Date.now();
|
||||
if (now - (p.workLastAt || 0) < WORK_COOLDOWN * 1000) {
|
||||
const wait = Math.ceil((WORK_COOLDOWN * 1000 - (now - p.workLastAt)) / 1000);
|
||||
return this.sendSession(s, 'error', { code: 'cooldown', message: `Next shift available in ${wait}s.` });
|
||||
}
|
||||
p.workLastAt = now;
|
||||
const job = JOBS.find(j => j.id === p.job.id);
|
||||
const rank = clamp(p.job.rank, 0, job.ranks.length - 1);
|
||||
const pay = Math.round(job.basePay * (1 + rank * 0.55) * (0.95 + Math.random() * 0.1));
|
||||
const xpGain = 12 + rank * 4;
|
||||
p.job.xp += xpGain;
|
||||
p.money += pay;
|
||||
p.stats.shifts++;
|
||||
let promoted = false;
|
||||
while (p.job.rank < job.ranks.length - 1 && p.job.xp >= XP_PER_RANK[p.job.rank + 1]) {
|
||||
p.job.rank++; promoted = true;
|
||||
}
|
||||
this.sendSession(s, 'workResult', { pay, xp: xpGain, promoted, rank: p.job.rank, ranks: job.ranks, money: p.money });
|
||||
this.sendSession(s, 'money', { amount: p.money });
|
||||
if (promoted) {
|
||||
this.sendSession(s, 'notify', { title: 'Promotion!', text: `You are now ${job.ranks[p.job.rank]}!`, icon: '📈' });
|
||||
p.level = Math.min(40, p.level + 1);
|
||||
}
|
||||
this.checkAchievements(s);
|
||||
saveProfile(p);
|
||||
}
|
||||
|
||||
buyItem(s, msg) {
|
||||
const item = CATALOG_BY_ID[msg.itemId];
|
||||
const p = s.profile;
|
||||
if (!item) return;
|
||||
if (p.level < item.lvl) return this.sendSession(s, 'error', { code: 'level', message: `Unlocks at level ${item.lvl}.` });
|
||||
if (p.money < item.price) return this.sendSession(s, 'error', { code: 'poor', message: 'Not enough TownCoins.' });
|
||||
p.money -= item.price;
|
||||
p.inventory.push({ itemId: item.id });
|
||||
this.sendSession(s, 'inventory', { inventory: p.inventory });
|
||||
this.sendSession(s, 'money', { amount: p.money });
|
||||
this.sendSession(s, 'notify', { title: 'Purchased', text: `${item.name} added to your inventory.`, icon: '🛒' });
|
||||
saveProfile(p);
|
||||
}
|
||||
|
||||
sellItem(s, msg) {
|
||||
const p = s.profile;
|
||||
const idx = p.inventory.findIndex(it => it.itemId === msg.itemId);
|
||||
if (idx === -1) return;
|
||||
const item = CATALOG_BY_ID[msg.itemId];
|
||||
const refund = Math.round(item.price * 0.6);
|
||||
p.inventory.splice(idx, 1);
|
||||
p.money += refund;
|
||||
this.sendSession(s, 'inventory', { inventory: p.inventory });
|
||||
this.sendSession(s, 'money', { amount: p.money });
|
||||
this.sendSession(s, 'notify', { title: 'Sold', text: `Sold ${item.name} for ${refund} TC.`, icon: '💰' });
|
||||
saveProfile(p);
|
||||
}
|
||||
|
||||
sellPlaced(s, msg) {
|
||||
const item = CATALOG_BY_ID[msg.itemId];
|
||||
if (!item) return;
|
||||
const refund = Math.round(item.price * 0.6);
|
||||
s.profile.money += refund;
|
||||
this.sendSession(s, 'money', { amount: s.profile.money });
|
||||
this.sendSession(s, 'notify', { title: 'Sold furniture', text: `Removed ${item.name} — refunded ${refund} TC.`, icon: '🗑' });
|
||||
this.saveLayoutDebounced(s);
|
||||
}
|
||||
|
||||
buyHouse(s, msg) {
|
||||
const house = HOUSES.find(h => h.id === msg.houseId);
|
||||
const p = s.profile;
|
||||
if (!house) return;
|
||||
if (p.housesOwned.includes(house.id)) return;
|
||||
if (p.level < house.lvl) return this.sendSession(s, 'error', { code: 'level', message: `Unlocks at level ${house.lvl}.` });
|
||||
if (p.money < house.price) return this.sendSession(s, 'error', { code: 'poor', message: 'Not enough TownCoins.' });
|
||||
p.money -= house.price;
|
||||
p.housesOwned.push(house.id);
|
||||
p.houseTier = house.id;
|
||||
if (!p.achievements.includes('first_home')) p.achievements.push('first_home');
|
||||
this.sendSession(s, 'profile', { profile: this.clientProfile(p) });
|
||||
this.sendSession(s, 'money', { amount: p.money });
|
||||
this.sendSession(s, 'notify', { title: 'New home!', text: `You bought the ${house.name}! 🏡`, icon: '🏡' });
|
||||
this.checkAchievements(s);
|
||||
saveProfile(p);
|
||||
}
|
||||
|
||||
placeHome(s, msg) {
|
||||
const p = s.profile;
|
||||
if (!Array.isArray(msg.layout)) return;
|
||||
// validate: each placed item must exist in catalog; cap count; positions sane
|
||||
const layout = msg.layout.slice(0, 120).filter(it =>
|
||||
CATALOG_BY_ID[it?.itemId] &&
|
||||
Number.isFinite(it.x) && Number.isFinite(it.z) &&
|
||||
Math.abs(it.x) < 30 && Math.abs(it.z) < 30
|
||||
).map(it => ({ itemId: it.itemId, x: +it.x.toFixed(2), z: +it.z.toFixed(2), rot: (+it.rot || 0) }));
|
||||
p.homeLayout = layout;
|
||||
this.sendSession(s, 'layoutSaved', { count: layout.length });
|
||||
this.saveLayoutDebounced(s);
|
||||
}
|
||||
|
||||
saveLayoutDebounced(s) {
|
||||
clearTimeout(s._layoutSaveTimer);
|
||||
s._layoutSaveTimer = setTimeout(() => { try { saveProfile(s.profile); } catch {} }, 2000);
|
||||
this.checkAchievements(s);
|
||||
}
|
||||
|
||||
needsSync(s, msg) {
|
||||
const p = s.profile;
|
||||
if (msg.needs && typeof msg.needs === 'object') {
|
||||
p.needs = Object.fromEntries(NEEDS.map(k => [k, clamp(Number(msg.needs[k]) || 50, 0, 100)]));
|
||||
}
|
||||
if (Array.isArray(msg.moodlets)) p.moodlets = msg.moodlets.slice(0, 12);
|
||||
if (Date.now() - s.lastNeedsSync > 25000) { s.lastNeedsSync = Date.now(); saveProfile(p); }
|
||||
}
|
||||
|
||||
// ---------------- achievements ---------------------------
|
||||
checkAchievements(s) {
|
||||
const p = s.profile;
|
||||
const grant = (id, title, text) => {
|
||||
if (p.achievements.includes(id)) return;
|
||||
p.achievements.push(id);
|
||||
this.sendSession(s, 'achievement', { id, name: title, desc: text });
|
||||
this.sendSession(s, 'profile', { profile: this.clientProfile(p) });
|
||||
};
|
||||
const defs = {
|
||||
first_home: ['First Home', 'Buy your first house.'],
|
||||
social_butterfly: ['Social Butterfly', 'Make 20 friends.'],
|
||||
millionaire: ['Millionaire', 'Hold 1,000,000 TownCoins.'],
|
||||
heartbreaker: ['Heartbreaker', 'Have 5 partners.'],
|
||||
interior_designer: ['Interior Designer', 'Place 25 furniture items.'],
|
||||
employed: ['Day One', 'Get your first job.'],
|
||||
promoted: ['Moving On Up', 'Earn any promotion.'],
|
||||
party_animal: ['Party Animal', 'Dance at 3 events.'],
|
||||
};
|
||||
if (p.housesOwned.length >= 1 && p.houseTier !== 'studio') grant('first_home', ...defs.first_home);
|
||||
if (Object.values(p.relationships).filter(r => r.f >= 25).length >= 20) grant('social_butterfly', ...defs.social_butterfly);
|
||||
if (p.money >= 1000000) grant('millionaire', ...defs.millionaire);
|
||||
if ((p.stats.partners || 0) >= 5) grant('heartbreaker', ...defs.heartbreaker);
|
||||
if ((p.homeLayout || []).length >= 25) grant('interior_designer', ...defs.interior_designer);
|
||||
if (!!p.job) grant('employed', ...defs.employed);
|
||||
if (p.job && p.job.rank >= 1) grant('promoted', ...defs.promoted);
|
||||
if ((p.stats.dances || 0) >= 3) grant('party_animal', ...defs.party_animal);
|
||||
}
|
||||
|
||||
// ---------------- main loops -----------------------------
|
||||
tick() {
|
||||
// NPC brains
|
||||
const now = Date.now();
|
||||
const { minutes } = this.gameTime();
|
||||
const hour = minutes / 60;
|
||||
for (const n of this.npcs) {
|
||||
const def = NPCS.find(d => d.id === n.id);
|
||||
// choose schedule stop for current hour
|
||||
let stopId = def.schedule[0][1];
|
||||
for (const [startH, poi] of def.schedule) if (hour >= startH) stopId = poi;
|
||||
const anchor = AREA_POINTS[stopId] || { x: 0, z: -26 };
|
||||
// deterministic per-npc offset so they don't stack
|
||||
const seed = (n.id.charCodeAt(0) * 7 + n.id.length * 13) % 100 / 100;
|
||||
const ox = Math.sin(now / 9000 + seed * 6.28) * 3.2;
|
||||
const oz = Math.cos(now / 11000 + seed * 6.28) * 3.2;
|
||||
const tx = anchor.x + ox, tz = anchor.z + oz;
|
||||
|
||||
if (!n.target || Math.hypot(n.tx - tx, n.tz - tz) > 2) { n.tx = tx; n.tz = tz; n.target = true; }
|
||||
const dx = n.tx - n.x, dz = n.tz - n.z;
|
||||
const d = Math.hypot(dx, dz);
|
||||
if (d > 0.35) {
|
||||
const sp = 1.9;
|
||||
n.x += dx / d * sp * (TICK_MS / 1000);
|
||||
n.z += dz / d * sp * (TICK_MS / 1000);
|
||||
n.ry = Math.atan2(dx, dz);
|
||||
n.anim = 'walk';
|
||||
} else {
|
||||
n.anim = now > n.waitUntil ? (Math.random() < 0.25 ? 'talk' : 'idle') : n.anim;
|
||||
if (now > n.waitUntil) n.waitUntil = now + 4000 + Math.random() * 6000;
|
||||
}
|
||||
// ambient chatter
|
||||
if (now > n.lineAt && n.anim !== 'walk') {
|
||||
n.lineAt = now + 24000 + Math.random() * 26000;
|
||||
const lines = [
|
||||
'Lovely day in LifeTown!', 'Have you tried the taco truck?', 'I should redecorate my place…',
|
||||
'The fountain sparkles at night.', 'Anyone up for dancing later?', 'Work, work, work…',
|
||||
'I love the smell of café roast.', 'Rain soon, I can feel it.',
|
||||
];
|
||||
this.broadcastToZone('town', 'bubble', { id: n.id, text: lines[Math.floor(Math.random() * lines.length)] });
|
||||
}
|
||||
}
|
||||
|
||||
// snapshots per zone
|
||||
const npcSnap = this.npcs.map(n => ({ id: n.id, x: +n.x.toFixed(2), z: +n.z.toFixed(2), ry: +n.ry.toFixed(2), anim: n.anim }));
|
||||
for (const [zoneId, set] of this.byZone) {
|
||||
const players = [...set].map(pl => ({ id: pl.id, x: +pl.x.toFixed(2), z: +pl.z.toFixed(2), ry: +(pl.ry || 0).toFixed(2), anim: pl.anim }));
|
||||
const payload = JSON.stringify({
|
||||
t: 'snapshot', zone: zoneId,
|
||||
players,
|
||||
npcs: zoneId === 'town' ? npcSnap : [],
|
||||
});
|
||||
for (const pl of set) if (pl.ws.readyState === WebSocket.OPEN) pl.ws.send(payload);
|
||||
}
|
||||
}
|
||||
|
||||
slowTick() {
|
||||
const time = this.gameTime();
|
||||
// weather machine
|
||||
if (Date.now() > this.nextWeatherRoll) {
|
||||
this.nextWeatherRoll = Date.now() + 45000 + Math.random() * 75000;
|
||||
if (Math.random() < 0.55) {
|
||||
const weights = { sunny: 0.42, cloudy: 0.24, rain: 0.16, storm: 0.06, fog: 0.12 };
|
||||
const roll = Math.random();
|
||||
let acc = 0, next = this.weather;
|
||||
for (const w of WEATHER_STATES) { acc += weights[w]; if (roll <= acc) { next = w; break; } }
|
||||
if (next !== this.weather) {
|
||||
this.weather = next;
|
||||
this.broadcast('weather', { weather: next });
|
||||
const labels = { sunny: 'The sun breaks through over LifeTown!', cloudy: 'Clouds drift in…', rain: 'Rain patters across Maple Court ☔', storm: 'A storm rolls in — stay cozy!', fog: 'A soft fog wraps the town.' };
|
||||
this.broadcast('notify', { title: 'Weather', text: labels[next], icon: '🌤️' });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const s of this.sessions) this.sendSession(s, 'time', { ...time, weather: this.weather });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// LifeTown Online — entry point: static file server + WebSocket upgrade.
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { GameServer } from './game.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DIST = path.join(__dirname, '..', 'client', 'dist');
|
||||
const PORT = Number(process.env.GAME_PORT || 7788);
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.mjs': 'text/javascript',
|
||||
'.css': 'text/css', '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg',
|
||||
'.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.wasm': 'application/wasm',
|
||||
'.woff2': 'font/woff2', '.mp3': 'audio/mpeg', '.ogg': 'audio/ogg', '.webp': 'image/webp',
|
||||
};
|
||||
|
||||
function serveStatic(req, res) {
|
||||
let urlPath = decodeURIComponent(new URL(req.url, 'http://x').pathname);
|
||||
if (urlPath === '/') urlPath = '/index.html';
|
||||
let filePath = path.normalize(path.join(DIST, urlPath));
|
||||
if (!filePath.startsWith(DIST)) { res.writeHead(403); res.end('Forbidden'); return; }
|
||||
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
||||
// SPA fallback
|
||||
filePath = path.join(DIST, 'index.html');
|
||||
if (!fs.existsSync(filePath)) { res.writeHead(503); res.end('LifeTown client is not built yet. Run: npm run build'); return; }
|
||||
}
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
res.writeHead(200, {
|
||||
'Content-Type': MIME[ext] || 'application/octet-stream',
|
||||
'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=3600',
|
||||
});
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
}
|
||||
|
||||
const server = http.createServer(serveStatic);
|
||||
|
||||
// Single WebSocket endpoint: /ws
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
const { pathname } = new URL(req.url, 'http://x');
|
||||
if (pathname === '/ws') {
|
||||
wss.handleUpgrade(req, socket, head, (ws) => game.onConnection(ws));
|
||||
} else {
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
const game = new GameServer(wss);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[LifeTown] server listening on http://127.0.0.1:${PORT}`);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
// LifeTown Online — JSON-file persistence for player profiles.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { START_MONEY, HOUSES } from '../shared/data.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SAVE_DIR = path.join(__dirname, '..', 'saves');
|
||||
fs.mkdirSync(SAVE_DIR, { recursive: true });
|
||||
|
||||
function sanitizeName(name) {
|
||||
return String(name || '').trim().replace(/[^A-Za-z0-9_\- ]/g, '').slice(0, 20);
|
||||
}
|
||||
|
||||
export function newProfile(name, appearance) {
|
||||
const safe = sanitizeName(name);
|
||||
return {
|
||||
name: safe,
|
||||
token: crypto.randomBytes(16).toString('hex'),
|
||||
createdAt: Date.now(),
|
||||
appearance, // character creator output (client-defined schema)
|
||||
money: START_MONEY,
|
||||
level: 1, xp: 0, // player level
|
||||
job: null, // { id, rank, xp }
|
||||
workLastAt: 0,
|
||||
houseTier: 'studio', // owned house tier id
|
||||
housesOwned: ['studio'],
|
||||
homeLayout: [], // [{itemId,x,z,rot}]
|
||||
inventory: [], // [{itemId}] purchased, not yet placed
|
||||
relationships: {}, // npcId|playerName -> {f,r,status}
|
||||
achievements: [],
|
||||
stats: { shifts: 0, dances: 0, giftsSent: 0 },
|
||||
needs: null, // client-simulated; stored for continuity
|
||||
moodlets: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function loadProfile(name) {
|
||||
const safe = sanitizeName(name);
|
||||
const file = path.join(SAVE_DIR, `${safe.toLowerCase()}.json`);
|
||||
try {
|
||||
if (!fs.existsSync(file)) return null;
|
||||
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
if (!raw || raw.name !== safe) return null;
|
||||
// migrate defaults
|
||||
const base = newProfile(safe, raw.appearance || defaultAppearance());
|
||||
const merged = { ...base, ...raw, name: safe };
|
||||
if (!Array.isArray(merged.homeLayout)) merged.homeLayout = [];
|
||||
if (!Array.isArray(merged.inventory)) merged.inventory = [];
|
||||
if (!Array.isArray(merged.achievements)) merged.achievements = [];
|
||||
if (typeof merged.relationships !== 'object' || !merged.relationships) merged.relationships = {};
|
||||
if (typeof merged.stats !== 'object' || !merged.stats) merged.stats = { shifts: 0, dances: 0, giftsSent: 0 };
|
||||
return merged;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveProfile(profile) {
|
||||
const file = path.join(SAVE_DIR, `${profile.name.toLowerCase()}.json`);
|
||||
const tmp = file + '.tmp';
|
||||
fs.writeFileSync(tmp, JSON.stringify(profile));
|
||||
fs.renameSync(tmp, file);
|
||||
}
|
||||
|
||||
export function defaultAppearance() {
|
||||
return {
|
||||
bodyType: 'average', height: 1, skin: '#eab98f', faceShape: 'round',
|
||||
hair: 'short', hairColor: '#4a2f1d', eyes: 'round', eyeColor: '#3a5a40',
|
||||
brows: 'natural', nose: 'button', mouth: 'smile',
|
||||
outfit: { top: 'tee', bottom: 'jeans', dress: null, shoes: 'sneakers', accessory: 'none', topColor: '#4f8fd9', bottomColor: '#33415c' },
|
||||
personality: ['Friendly'], voice: 'warm', age: 25,
|
||||
};
|
||||
}
|
||||
|
||||
export function findProfileByToken(token) {
|
||||
if (!token || typeof token !== 'string') return null;
|
||||
for (const f of fs.readdirSync(SAVE_DIR)) {
|
||||
if (!f.endsWith('.json')) continue;
|
||||
try {
|
||||
const p = JSON.parse(fs.readFileSync(path.join(SAVE_DIR, f), 'utf8'));
|
||||
if (p.token === token) return p;
|
||||
} catch { /* skip corrupt */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function listProfileNames() {
|
||||
try {
|
||||
return fs.readdirSync(SAVE_DIR).filter(f => f.endsWith('.json')).map(f => {
|
||||
try { return JSON.parse(fs.readFileSync(path.join(SAVE_DIR, f), 'utf8')).name; } catch { return null; }
|
||||
}).filter(Boolean);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export { sanitizeName };
|
||||
Reference in New Issue
Block a user