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