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:
@@ -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