Files
deepseek 93b018e42b 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
2026-08-23 07:01:24 +00:00

98 lines
3.6 KiB
JavaScript

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