- 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
98 lines
3.6 KiB
JavaScript
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 };
|