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:
deepseek
2026-08-23 07:01:24 +00:00
commit 93b018e42b
35 changed files with 7785 additions and 0 deletions
+387
View File
@@ -0,0 +1,387 @@
// ============================================================
// LifeTown Online — shared game data (single source of truth)
// Imported by both the Node server and the browser client.
// All content is original to LifeTown Online.
// ============================================================
export const GAME_NAME = 'LifeTown Online';
export const DISTRICT_NAME = 'Maple Court';
export const TIME_SCALE = 60; // 1 real second = 1 game minute -> full day = 24 min
export const DAY_MINUTES = 1440;
export const START_MONEY = 1500;
// ---------------- World layout (metres) ---------------------
export const WORLD = {
size: 150, // playable half-extent reference
roadX: { from: -70, to: 70, z: 0, width: 8 }, // main street along X
sidewalkZ: 6,
plaza: { x: 0, z: -26, r: 14 }, // central plaza + fountain
park: { x: 42, z: -30, w: 44, d: 40 }, // park with pond
spawn: { x: 0, z: 12 },
};
// Points of interest: where players/NPCs can do things.
// kind: sit | eat | work | exercise | shop | fish | stage | jobs | fountain | swim
export const POIS = [
{ id: 'fountain', name: 'Copper Fountain', x: 0, z: -26, kind: 'fountain', radius: 3 },
{ id: 'stage', name: 'Plaza Stage', x: -18, z: -34, kind: 'stage', radius: 4 },
{ id: 'bench_park', name: 'Park Bench', x: 36, z: -22, kind: 'sit', radius: 2 },
{ id: 'bench_2', name: 'Street Bench', x: -30, z: 9, kind: 'sit', radius: 2 },
{ id: 'cafe', name: 'Daily Grind Café', x: -48, z: -14, kind: 'eat', radius: 3, jobWorkspot: true },
{ id: 'cityhall', name: 'City Hall Desk', x: 24, z: -16, kind: 'jobs', radius: 3 },
{ id: 'gym', name: 'Flex Den Gym', x: 54, z: -14, kind: 'exercise', radius: 3, jobWorkspot: true },
{ id: 'store', name: 'MapleMart Home Store',x: -66, z: -16, kind: 'shop', radius: 3 },
{ id: 'foodtruck', name: 'Taco Comet Truck', x: 12, z: 10, kind: 'eat', radius: 3 },
{ id: 'pond', name: 'Willow Pond', x: 46, z: -40, kind: 'fish', radius: 4 },
];
// Buildings placed along the street (visual shells on the client).
export const BUILDINGS = [
{ id: 'cafe', name: 'Daily Grind Café', x: -48, z: -26, w: 16, d: 14, color: 0xf2b06b, roof: 0xd96c4f, sign: 'CAFÉ' },
{ id: 'cityhall', name: 'LifeTown City Hall', x: 24, z: -28, w: 22, d: 16, color: 0xe8e3d5, roof: 0x7fa88b, sign: 'CITY HALL' },
{ id: 'gym', name: 'Flex Den Gym', x: 54, z: -26, w: 16, d: 14, color: 0xc9d7f2, roof: 0x5b74a8, sign: 'GYM' },
{ id: 'store', name: 'MapleMart', x: -66, z: -28, w: 18, d: 14, color: 0xf7cdd3, roof: 0xb85c6e, sign: 'MAPLEMART' },
{ id: 'apt_a', name: 'Birch Apartments', x: -20, z: 22, w: 18, d: 14, color: 0xd9c9a8, roof: 0x8a6f56, sign: 'APARTMENTS' },
{ id: 'apt_b', name: 'Rowan Lofts', x: 6, z: 22, w: 18, d: 14, color: 0xcfd8c2, roof: 0x6f7f5a, sign: 'LOFTS' },
{ id: 'arcade', name: 'Pixel Porcupine Arcade', x: 38, z: 22, w: 16, d: 14, color: 0xd7c4ef, roof: 0x8a63b8, sign: 'ARCADE' },
];
// ---------------- Building doors & enterable venues ----------------
// Doors sit just outside each building's street-facing entrance.
export const DOORS = [
{ id: 'door_cafe', x: -48, z: -18.3, label: 'Enter Daily Grind Café', zone: 'venue:cafe' },
{ id: 'door_cityhall', x: 24, z: -19.2, label: 'Enter City Hall', zone: 'venue:cityhall' },
{ id: 'door_gym', x: 54, z: -18.3, label: 'Enter Flex Den Gym', zone: 'venue:gym' },
{ id: 'door_store', x: -66, z: -18.3, label: 'Enter MapleMart', zone: 'venue:store' },
{ id: 'door_arcade', x: 38, z: 13.9, label: 'Enter Pixel Porcupine Arcade', zone: 'venue:arcade' },
{ id: 'door_apt_a', x: -20, z: 13.9, label: 'Enter Your Apartment', zone: 'home:self' },
{ id: 'door_apt_b', x: 6, z: 13.9, label: 'Enter Your Apartment', zone: 'home:self' },
];
// Public venue interiors: shared rooms every resident can walk into.
export const VENUES = {
'venue:cafe': {
name: 'Daily Grind Café', size: [14, 10],
items: [
{ itemId: 'counter_kitchen', x: -2.5, z: -3.6, rot: 0 },
{ itemId: 'counter_kitchen', x: -0.5, z: -3.6, rot: 0 },
{ itemId: 'coffeemaker', x: 0.6, z: -3.7, rot: 0 },
{ itemId: 'microwave', x: -3.4, z: -3.7, rot: 0 },
{ itemId: 'table_dining', x: -4, z: 0.5, rot: 0 }, { itemId: 'stool', x: -4, z: 1.6, rot: 0 }, { itemId: 'stool', x: -4, z: -0.6, rot: 0 },
{ itemId: 'table_dining', x: 0.5, z: 0.5, rot: 0 }, { itemId: 'stool', x: 0.5, z: 1.6, rot: 0 }, { itemId: 'stool', x: 0.5, z: -0.6, rot: 0 },
{ itemId: 'loveseat', x: 5, z: 2.5, rot: Math.PI / 2 },
{ itemId: 'plant_monstera', x: 6, z: -3.5, rot: 0 },
{ itemId: 'paint_meadow', x: 0, z: -4.85, rot: 0 },
{ itemId: 'lamp_floor', x: 6.2, z: 3.8, rot: 0 },
{ itemId: 'rug_round', x: 0.25, z: 0.5, rot: 0 },
],
spots: [
{ x: -1.5, z: -2.6, label: 'Order a coffee ☕', act: 'eat' },
{ x: 5.5, z: 1.6, label: 'Lounge on the Loveseat', act: 'sit' },
],
},
'venue:cityhall': {
name: 'LifeTown City Hall', size: [16, 11],
items: [
{ itemId: 'table_desk', x: -3, z: -3, rot: 0 }, { itemId: 'chair_office', x: -3, z: -2.2, rot: Math.PI },
{ itemId: 'table_desk', x: 3, z: -3, rot: 0 }, { itemId: 'chair_office', x: 3, z: -2.2, rot: Math.PI },
{ itemId: 'shelf_books', x: -6.5, z: -4.6, rot: 0 },
{ itemId: 'shelf_cube', x: 6.5, z: -4.6, rot: 0 },
{ itemId: 'plant_tree', x: 0, z: -4.4, rot: 0 },
{ itemId: 'decor_clock', x: 0, z: -5.35, rot: 0 },
{ itemId: 'rug_long', x: 0, z: 1.5, rot: 0 },
{ itemId: 'lamp_arc', x: 0, z: 3.4, rot: 0 },
],
spots: [
{ x: -3, z: -1.6, label: 'Speak to the Career Desk 💼', act: 'jobs' },
{ x: 3, z: -1.6, label: 'Browse Property Records 🏡', act: 'property' },
],
},
'venue:gym': {
name: 'Flex Den Gym', size: [14, 10],
items: [
{ itemId: 'rug_shag', x: -4, z: -2.5, rot: 0 },
{ itemId: 'rug_shag', x: 4, z: -2.5, rot: 0 },
{ itemId: 'speaker', x: 0, z: -4.3, rot: 0 },
{ itemId: 'fridge_dual', x: -6.2, z: -4, rot: 0 },
{ itemId: 'plant_fern', x: 6.2, z: -4.2, rot: 0 },
{ itemId: 'tv_oled', x: 0, z: -4.75, rot: 0 },
{ itemId: 'sofa_modern', x: 5.5, z: 3.4, rot: Math.PI },
],
spots: [
{ x: -4, z: -1.4, label: 'Hit the Mats 🏋️', act: 'exercise' },
{ x: 4, z: -1.4, label: 'Cardio Corner 🏃', act: 'exercise' },
],
},
'venue:store': {
name: 'MapleMart Home Store', size: [16, 11],
items: [
{ itemId: 'shelf_cube', x: -5, z: -3.8, rot: 0 }, { itemId: 'shelf_cube', x: -3, z: -3.8, rot: 0 },
{ itemId: 'shelf_books', x: 5, z: -3.8, rot: 0 },
{ itemId: 'wardrobe_pine', x: 6.4, z: 0.5, rot: -Math.PI / 2 },
{ itemId: 'bed_cozy', x: -6, z: 1.5, rot: 0 },
{ itemId: 'table_coffee', x: 0.5, z: 0.8, rot: 0 },
{ itemId: 'sofa_basic', x: 2.5, z: 2.8, rot: Math.PI },
{ itemId: 'light_neon', x: 0, z: -5.2, rot: 0 },
{ itemId: 'plant_cactus', x: -6.8, z: -4.4, rot: 0 },
{ itemId: 'rug_round', x: 0.5, z: 0.8, rot: 0 },
],
spots: [
{ x: 0, z: -2.6, label: 'Browse the Catalogue 🛒', act: 'shop' },
],
},
'venue:arcade': {
name: 'Pixel Porcupine Arcade', size: [13, 10],
items: [
{ itemId: 'pc_tower', x: -4, z: -3.6, rot: 0 },
{ itemId: 'console', x: 0, z: -3.6, rot: 0 },
{ itemId: 'tv_flat', x: 0, z: -4.2, rot: 0 },
{ itemId: 'keyboard_elec', x: 4, z: -3.6, rot: 0 },
{ itemId: 'light_neon', x: -2.5, z: -5.05, rot: 0 },
{ itemId: 'light_neon', x: 2.5, z: -5.05, rot: 0 },
{ itemId: 'speaker', x: -6, z: -4.2, rot: 0 },
{ itemId: 'stool', x: -4, z: -2.6, rot: 0 }, { itemId: 'stool', x: 0, z: -2.6, rot: 0 }, { itemId: 'stool', x: 4, z: -2.6, rot: 0 },
{ itemId: 'rug_long', x: 0, z: 0.5, rot: 0 },
],
spots: [
{ x: 0, z: -2, label: 'Play the Cabinets 🕹️', act: 'arcade' },
{ x: 4.5, z: -2, label: 'Dance Machine 💃', act: 'stage' },
],
},
};
// ---------------- Personality traits ------------------------
export const TRAITS = ['Friendly','Romantic','Ambitious','Lazy','Creative','Athletic','Intelligent','Funny','Introverted','Extroverted','Generous','Competitive'];
// ---------------- Jobs / careers ----------------------------
// pay is TownCoins per work shift; xp accumulates toward promotion.
export const JOBS = [
{ id:'barista', title:'Barista', poi:'cafe', basePay: 55, hours:[6,14],
ranks:['Trainee Barista','Barista','Head Barista','Café Manager','Coffee Boss'] },
{ id:'chef', title:'Chef', poi:'foodtruck', basePay: 70, hours:[10,19],
ranks:['Kitchen Hand','Line Cook','Sous Chef','Head Chef','Star Chef'] },
{ id:'programmer', title:'Programmer', poi:'cityhall',basePay: 90, hours:[9,17],
ranks:['Junior Developer','Software Engineer','Senior Engineer','Lead Architect','Tech Executive'] },
{ id:'designer', title:'Designer', poi:'store', basePay: 75, hours:[10,18],
ranks:['Design Intern','Junior Designer','Product Designer','Art Director','Creative Director'] },
{ id:'doctor', title:'Doctor', poi:'gym', basePay: 110, hours:[8,16],
ranks:['Resident','Physician','Surgeon','Chief of Staff','Medical Director'] },
{ id:'teacher', title:'Teacher', poi:'cityhall',basePay: 65, hours:[8,15],
ranks:['Substitute','Teacher','Senior Teacher','Dean','Principal'] },
{ id:'police', title:'Police Officer', poi:'cityhall',basePay: 80, hours:[7,15],
ranks:['Cadet','Officer','Sergeant','Lieutenant','Commissioner'] },
{ id:'engineer', title:'Engineer', poi:'gym', basePay: 95, hours:[9,17],
ranks:['Assistant Engineer','Engineer','Project Engineer','Principal Engineer','Director of Works'] },
{ id:'musician', title:'Musician', poi:'stage', basePay: 50, hours:[17,23],
ranks:['Street Performer','Gig Musician','Band Leader','Recording Artist','Headliner'] },
{ id:'influencer', title:'Influencer', poi:'plaza', basePay: 45, hours:[11,19],
ranks:['Newcomer','Micro-Influencer','Trendsetter','Star Creator','Town Icon'] },
{ id:'athlete', title:'Athlete', poi:'gym', basePay: 60, hours:[7,13],
ranks:['Rookie','Amateur','Pro','Champion','Legend'] },
{ id:'artist', title:'Artist', poi:'pond', basePay: 40, hours:[10,16],
ranks:['Sketcher','Painter','Gallery Artist','Master Artist','Living Legend'] },
];
export const XP_PER_RANK = [0, 100, 260, 520, 900]; // cumulative xp needed to reach rank i
export const WORK_SECONDS = 6; // real seconds per shift
export const WORK_COOLDOWN = 25; // real seconds between shifts (server-enforced)
// ---------------- Needs ------------------------------------
export const NEEDS = ['hunger','energy','hygiene','fun','social','comfort']; // 0..100
export const NEED_DECAY_PER_GAME_HOUR = {
hunger: 4.2, energy: 3.6, hygiene: 2.4, fun: 3.0, social: 2.6, comfort: 2.0,
};
export const MOOD_NEEDS = [ // weight of each need into mood (-100..100 scale)
['hunger',1.0],['energy',0.8],['hygiene',0.5],['fun',1.0],['social',0.8],['comfort',0.6]
];
// ---------------- Furniture catalogue -----------------------
// fx: multipliers/capacities used by interactable objects at home.
// cat: bed sofa table chair tv pc fridge stove shower toilet sink wardrobe shelf plant decor light rug paint elec
export const CATALOG = [
// Beds
{ id:'bed_basic', cat:'bed', name:'Dreamer Single Bed', price:180, lvl:1, w:2, d:3, fx:{ energyRate:1.0 } },
{ id:'bed_cozy', cat:'bed', name:'Cloudline Double Bed', price:420, lvl:2, w:2.4, d:3, fx:{ energyRate:1.2 } },
{ id:'bed_lux', cat:'bed', name:'Aurora Velvet Bed', price:980, lvl:4, w:2.6, d:3.2, fx:{ energyRate:1.45 } },
{ id:'bed_kids', cat:'bed', name:'Rocket Bunk Bed', price:260, lvl:1, w:2.2, d:3, fx:{ energyRate:1.05 } },
// Sofas
{ id:'sofa_basic', cat:'sofa', name:'Comfi Couch', price:240, lvl:1, w:3, d:1.4, fx:{ comfortRate:1.1 } },
{ id:'sofa_modern',cat:'sofa', name:'Pebble Modular Sofa', price:520, lvl:3, w:3.4, d:1.6, fx:{ comfortRate:1.25 } },
{ id:'loveseat', cat:'sofa', name:'Sweetheart Loveseat', price:300, lvl:2, w:2.2, d:1.3, fx:{ comfortRate:1.15, romanceAura:true } },
{ id:'sofa_lux', cat:'sofa', name:'Velvet Cloud Sectional', price:1100, lvl:5, w:3.8, d:1.8, fx:{ comfortRate:1.4 } },
// Tables
{ id:'table_dining',cat:'table', name:'Oakmeet Dining Table', price:220, lvl:1, w:2.6, d:1.6, fx:{} },
{ id:'table_coffee',cat:'table',name:'Roundabout Coffee Table', price:130, lvl:1, w:1.4, d:1.4, fx:{ comfortRate:1.05 } },
{ id:'table_desk', cat:'table', name:'Focus Desk', price:190, lvl:1, w:2, d:1, fx:{ productivity:1.1 } },
// Chairs
{ id:'chair_wood', cat:'chair', name:'Simple Chair', price:60, lvl:1, w:0.9, d:0.9, fx:{} },
{ id:'chair_office',cat:'chair',name:'Ergo Glide Chair', price:170, lvl:2, w:1, d:1, fx:{ productivity:1.15 } },
{ id:'stool', cat:'chair', name:'Pop Stool', price:45, lvl:1, w:0.7, d:0.7, fx:{} },
// TV & electronics
{ id:'tv_flat', cat:'tv', name:'SlimView 40"', price:400, lvl:2, w:1.6, d:0.4, fx:{ funRate:1.2 } },
{ id:'tv_oled', cat:'tv', name:'CinemaWall 65"', price:1400, lvl:5, w:2.2, d:0.5, fx:{ funRate:1.5 } },
{ id:'pc_tower', cat:'pc', name:'Quanta PC Setup', price:800, lvl:3, w:1.8, d:0.8, fx:{ funRate:1.25, productivity:1.3 } },
{ id:'console', cat:'elec', name:'PlayBox Console', price:350, lvl:2, w:0.6, d:0.4, fx:{ funRate:1.3 } },
{ id:'speaker', cat:'elec', name:'BoomBerry Speaker', price:150, lvl:1, w:0.5, d:0.5, fx:{ funRate:1.1 } },
{ id:'fridge_retro',cat:'fridge',name:'Mint Fridge', price:320, lvl:1, w:1.2, d:1, fx:{ hungerRate:1.1 } },
{ id:'fridge_dual',cat:'fridge',name:'TwinFrost Deluxe', price:700, lvl:3, w:1.4, d:1, fx:{ hungerRate:1.25 } },
{ id:'stove_basic',cat:'stove',name: 'HeatWhirl Oven', price:290, lvl:1, w:1.2, d:0.9, fx:{ cookSpeed:1.0 } },
{ id:'stove_pro', cat:'stove',name:'ChefBlaze Range', price:650, lvl:3, w:1.4, d:0.9, fx:{ cookSpeed:1.35 } },
// Bathroom
{ id:'shower_std', cat:'shower',name:'RainRing Shower', price:260, lvl:1, w:1.2, d:1.2, fx:{ hygieneRate:1.0 } },
{ id:'shower_lux', cat:'shower',name:'Falls Spa Shower', price:600, lvl:4, w:1.4, d:1.4, fx:{ hygieneRate:1.35 } },
{ id:'toilet_std', cat:'toilet',name:'Porcelain Throne', price:140, lvl:1, w:0.9, d:1.1, fx:{} },
{ id:'sink_std', cat:'sink', name:'Bubble Basin', price:110, lvl:1, w:1, d:0.6, fx:{ hygieneRate:1.08 } },
{ id:'bath_tub', cat:'shower',name:'Soakstar Tub', price:480, lvl:3, w:1.8, d:1, fx:{ hygieneRate:1.2, comfortRate:1.05 } },
// Storage
{ id:'wardrobe_pine',cat:'wardrobe',name:'Pinewood Wardrobe', price:210, lvl:1, w:1.6, d:0.8, fx:{} },
{ id:'wardrobe_walk',cat:'wardrobe',name:'Walk-In Closet Set', price:850, lvl:5, w:2.6, d:1.2, fx:{ comfortRate:1.1 } },
{ id:'shelf_books',cat:'shelf', name:'Storyline Bookshelf', price:160, lvl:1, w:1.6, d:0.5, fx:{ funRate:1.05 } },
{ id:'shelf_cube', cat:'shelf', name:'CubeGrid Shelving', price:190, lvl:2, w:1.8, d:0.5, fx:{} },
{ id:'dresser', cat:'wardrobe',name:'Morningview Dresser', price:180, lvl:1, w:1.4, d:0.7, fx:{} },
// Plants & decor
{ id:'plant_fern', cat:'plant', name:'Feather Fern', price:40, lvl:1, w:0.8, d:0.8, fx:{ comfortRate:1.03 } },
{ id:'plant_monstera',cat:'plant',name:'Monstera Magnum', price:90, lvl:2, w:1, d:1, fx:{ comfortRate:1.06 } },
{ id:'plant_cactus',cat:'plant',name:'Spike Buddy Cactus', price:30, lvl:1, w:0.5, d:0.5, fx:{} },
{ id:'plant_tree', cat:'plant', name:'Indoor Fig Tree', price:140, lvl:2, w:1.2, d:1.2, fx:{ comfortRate:1.08 } },
{ id:'decor_vase', cat:'decor', name:'Sunset Vase', price:55, lvl:1, w:0.5, d:0.5, fx:{} },
{ id:'decor_clock',cat:'decor', name:'Ticktock Wall Clock', price:70, lvl:1, w:0.6, d:0.2, fx:{} },
{ id:'decor_mirror',cat:'decor',name:'Glimmer Mirror', price:120, lvl:2, w:1, d:0.2, fx:{ hygieneRate:1.05 } },
{ id:'decor_aquarium',cat:'decor',name:'Ripple Aquarium', price:380, lvl:3, w:1.2, d:0.6, fx:{ funRate:1.15, comfortRate:1.05 } },
{ id:'rug_round', cat:'rug', name:'Circle Breeze Rug', price:80, lvl:1, w:2.4, d:2.4, fx:{ comfortRate:1.04 } },
{ id:'rug_long', cat:'rug', name:'Hallway Runner', price:65, lvl:1, w:3, d:1.2, fx:{} },
{ id:'rug_shag', cat:'rug', name:'Cloudpile Shag Rug', price:150, lvl:2, w:2.8, d:2, fx:{ comfortRate:1.08 } },
// Lighting
{ id:'lamp_floor', cat:'light',name:'Halo Floor Lamp', price:95, lvl:1, w:0.7, d:0.7, fx:{ comfortRate:1.04 } },
{ id:'lamp_table', cat:'light',name:'Dusk Table Lamp', price:60, lvl:1, w:0.5, d:0.5, fx:{} },
{ id:'lamp_arc', cat:'light',name:'Arcline Lamp', price:230, lvl:3, w:1.2, d:0.6, fx:{ comfortRate:1.08 } },
{ id:'light_neon', cat:'light',name:'NeonWave Strip', price:180, lvl:2, w:2, d:0.2, fx:{ funRate:1.1 } },
// Paintings
{ id:'paint_meadow',cat:'paint',name:'Meadow Morning Print', price:85, lvl:1, w:1.2, d:0.1, fx:{ comfortRate:1.04 } },
{ id:'paint_abstract',cat:'paint',name:'Confetti Abstract', price:130, lvl:2, w:1.4, d:0.1, fx:{ funRate:1.05 } },
{ id:'paint_portrait',cat:'paint',name:'Ancestral Portrait', price:200, lvl:3, w:1.2, d:0.1, fx:{ comfortRate:1.06 } },
// Kitchen extras / misc
{ id:'counter_kitchen',cat:'table',name:'Prep Counter', price:240, lvl:2, w:1.8, d:0.9, fx:{ cookSpeed:1.1 } },
{ id:'microwave', cat:'elec', name:'ZapWave Microwave', price:130, lvl:1, w:0.8, d:0.6, fx:{ cookSpeed:1.08 } },
{ id:'coffeemaker',cat:'elec', name:'BrewBuddy Machine', price:110, lvl:1, w:0.6, d:0.5, fx:{ energyRate:1.05 } },
{ id:'guitar', cat:'decor',name:'Starwood Guitar', price:220, lvl:2, w:0.6, d:0.4, fx:{ funRate:1.15 } },
{ id:'keyboard_elec',cat:'elec',name:'MelodyKeys Synth', price:260, lvl:2, w:1.4, d:0.5, fx:{ funRate:1.15 } },
{ id:'easel', cat:'decor',name:'Vision Easel', price:150, lvl:2, w:1, d:0.8, fx:{ funRate:1.1 } },
{ id:'teddy', cat:'decor',name:'Mr. Snugglesworth', price:45, lvl:1, w:0.5, d:0.5, fx:{ comfortRate:1.05, romanceAura:true } },
];
export const CATALOG_BY_ID = Object.fromEntries(CATALOG.map(c => [c.id, c]));
// ---------------- Housing tiers -----------------------------
export const HOUSES = [
{ id:'studio', name:'Willow Studio Apartment', price:0, lvl:1, size:[11,9] },
{ id:'small', name:'Clover Small House', price:4500, lvl:3, size:[13,11] },
{ id:'medium', name:'Brookside Medium House', price:12000, lvl:6, size:[16,12] },
{ id:'large', name:'Hillcrest Large House', price:28000, lvl:10, size:[19,14] },
{ id:'luxury', name:'Lakeside Luxury Villa', price:60000, lvl:15, size:[22,16] },
{ id:'mansion',name:'Starfall Mansion', price:125000,lvl:22, size:[26,20] },
];
// ---------------- NPCs (all original) -----------------------
// schedule entries: [startHour, poiId] — walks to POIs through the day.
export const NPCS = [
{ id:'maya', name:'Maya Bloom', role:'Barista', color:0xe8875f, hairColor:0x3b2417, traits:['Friendly','Extroverted'],
schedule:[[6,'cafe'],[12,'plaza'],[14,'cafe'],[18,'stage'],[21,'fountain']], home:'cafe' },
{ id:'felix', name:'Felix Grant', role:'Programmer', color:0x5f86e8, hairColor:0x20180f, traits:['Intelligent','Introverted'],
schedule:[[8,'cityhall'],[12,'foodtruck'],[13,'cityhall'],[18,'arcade'],[22,'apt_b']], home:'apt_b' },
{ id:'rosa', name:'Rosa Vega', role:'Chef', color:0xd95f8a, hairColor:0x141414, traits:['Creative','Generous'],
schedule:[[9,'foodtruck'],[14,'foodtruck'],[18,'cafe'],[21,'plaza']], home:'apt_a' },
{ id:'kenji', name:'Kenji Sato', role:'Personal Coach', color:0x67b56a, hairColor:0x101010, traits:['Athletic','Competitive'],
schedule:[[6,'gym'],[12,'park'],[14,'gym'],[19,'stage'],[22,'apt_a']], home:'apt_a' },
{ id:'nadia', name:'Nadia Sol', role:'Artist', color:0xefcf62, hairColor:0x7a3d1e, traits:['Creative','Lazy'],
schedule:[[9,'pond'],[13,'park'],[16,'store'],[19,'plaza'],[22,'apt_b']], home:'apt_b' },
{ id:'theo', name:'Theo Marsh', role:'Shop Clerk', color:0x8fb7d9, hairColor:0x59391f, traits:['Funny','Generous'],
schedule:[[8,'store'],[13,'foodtruck'],[14,'store'],[19,'arcade'],[22,'apt_a']], home:'apt_a' },
{ id:'ivy', name:'Ivy Lane', role:'Musician', color:0xb58fd9, hairColor:0x2e1e47, traits:['Romantic','Creative'],
schedule:[[10,'stage'],[14,'cafe'],[17,'pond'],[20,'stage'],[23,'apt_b']], home:'apt_b' },
{ id:'bruno', name:'Bruno Steel', role:'Engineer', color:0xd97f4f, hairColor:0x2a2a2a, traits:['Ambitious','Competitive'],
schedule:[[7,'gym'],[9,'cityhall'],[12,'foodtruck'],[13,'cityhall'],[19,'arcade'],[22,'apt_a']], home:'apt_a' },
{ id:'cleo', name:'Cleo Brooks', role:'Reporter', color:0x7fd9c2, hairColor:0x8a2f2f, traits:['Extroverted','Funny'],
schedule:[[8,'plaza'],[11,'cafe'],[14,'cityhall'],[18,'stage'],[21,'fountain']], home:'apt_b' },
{ id:'oliver',name:'Oliver Finch', role:'Doctor', color:0xa8d97f, hairColor:0x4a3018, traits:['Intelligent','Generous'],
schedule:[[7,'gym'],[9,'cityhall'],[12,'cafe'],[13,'cityhall'],[19,'pond'],[22,'apt_a']], home:'apt_a' },
];
// POI anchor points for NPC schedule targets that are areas rather than POIs:
export const AREA_POINTS = { plaza:{x:0,z:-26}, park:{x:42,z:-26} };
// ---------------- Social interactions -----------------------
// req: minimum friendship(f)/romance(r) level. fx: relationship deltas & mood.
// anim: animation key both parties play. line templates may include {name}.
export const INTERACTIONS = [
{ id:'hello', group:'Friendly', label:'Say Hello', req:{f:0}, df:2, dr:0, anim:'wave',
lines:['Hi there, {name}!','Hello hello!','Hey {name}, good to see you!'] },
{ id:'chat', group:'Friendly', label:'Chat', req:{f:0}, df:4, dr:0, anim:'talk',
lines:['Did you see the plaza fountain? Gorgeous.','I heard the café has a new roast.','Weather has been lovely lately.'] },
{ id:'joke', group:'Friendly', label:'Tell Joke', req:{f:5}, df:5, dr:0, anim:'laugh', moodFx:{fun:+8},
lines:['Why did the scarecrow move to LifeTown? He heard the fields were outstanding!','I told my fridge a joke… it didn\'t get it, cold shoulder.'] },
{ id:'compliment',group:'Friendly',label:'Compliment', req:{f:5}, df:6, dr:1, anim:'talk', moodFx:{social:+6},
lines:['{name}, that outfit really suits you!','You always brighten my day, {name}.'] },
{ id:'highfive', group:'Friendly', label:'High Five', req:{f:10}, df:5, dr:0, anim:'highfive', moodFx:{fun:+5},
lines:['High five!','Up top!'] },
{ id:'story', group:'Friendly', label:'Tell Story', req:{f:15}, df:7, dr:0, anim:'talk', moodFx:{fun:+6},
lines:['So yesterday, a pigeon stole my sandwich…','Let me tell you about the great taco incident.'] },
{ id:'hug', group:'Friendly', label:'Hug', req:{f:25}, df:8, dr:1, anim:'hug', moodFx:{social:+10,comfort:+6},
lines:['Come here, you!','Group hug… well, duo hug.'] },
{ id:'flirt', group:'Romantic', label:'Flirt', req:{r:0}, df:1, dr:4, anim:'flirt',
lines:['Is your name Wi-Fi? Because I feel a connection.','Do you believe in love at first sight, {name}?'] },
{ id:'romantic_compliment',group:'Romantic',label:'Romantic Compliment',req:{r:10}, df:1, dr:6, anim:'flirt', moodFx:{social:+8},
lines:['Your smile could light up City Hall, {name}.','Every day with you feels like a festival.'] },
{ id:'gift_small',group:'Romantic',label:'Give Flowers', req:{r:10}, cost:40, df:2, dr:8, anim:'gift', moodFx:{social:+8},
lines:['These made me think of you!','A little something beautiful, for someone beautiful.'] },
{ id:'holdhands',group:'Romantic', label:'Hold Hands', req:{r:25}, df:2, dr:7, anim:'holdhands', moodFx:{social:+9,comfort:+5},
lines:['Shall we?','Just like this, {name}.'] },
{ id:'date', group:'Romantic', label:'Ask on Date', req:{r:35}, df:3, dr:9, anim:'celebrate', moodFx:{fun:+12,social:+12},
lines:['Dinner at the café, just us two?','It\'s a date, {name}!'] },
{ id:'kiss', group:'Romantic', label:'Kiss', req:{r:50}, df:2, dr:10, anim:'kiss', moodFx:{social:+14,happiness:10},
lines:['❤️','Mwah!'] },
{ id:'dance', group:'Funny', label:'Dance Together', req:{f:10}, df:4, dr:1, anim:'dance', moodFx:{fun:+14},
lines:['Show me your moves!','DJ, drop the beat!'] },
{ id:'funnyface',group:'Funny', label:'Make Funny Face', req:{f:5}, df:4, dr:0, anim:'laugh', moodFx:{fun:+8},
lines:['>:P','Boing! Look at my face!'] },
{ id:'insult', group:'Negative', label:'Insult', req:{f:0}, df:-12,dr:-6, anim:'argue', moodFx:{happiness:-10},
lines:['Nice going, clumsy!','Did you even try today?'] },
{ id:'argue', group:'Negative', label:'Argue', req:{f:10}, df:-15,dr:-10,anim:'argue', moodFx:{happiness:-14},
lines:['That\'s not what we agreed!','You always say that!'] },
{ id:'apologize',group:'Negative', label:'Apologize', req:{f:0}, df:10, dr:2, anim:'talk', moodFx:{happiness:6},
lines:['I\'m sorry, truly.','Can we start over, {name}?'] },
];
export const INTERACTION_BY_ID = Object.fromEntries(INTERACTIONS.map(i=>[i.id,i]));
// Friendship thresholds (spec): 0 stranger, 10 acquaintance, 25 friend, 50 close friend, 75 best friend.
export const FRIEND_TIERS = [
{ at:0, label:'Stranger' }, { at:10, label:'Acquaintance' }, { at:25, label:'Friend' },
{ at:50, label:'Close Friend' }, { at:75, label:'Best Friend' },
];
export const ROMANCE_PARTNER_AT = 100;
export const REL_MAX = 100;
// ---------------- Moods -------------------------------------
export const MOODS = ['Happy','Excited','Relaxed','Confident','Inspired','Romantic','Sad','Angry','Bored','Embarrassed','Lonely','Tired','Stressed'];
export const MOOD_EMOJI = { Happy:'😊', Excited:'🤩', Relaxed:'😌', Confident:'😎', Inspired:'💡', Romantic:'😍',
Sad:'😢', Angry:'😠', Bored:'😐', Embarrassed:'😳', Lonely:'🥺', Tired:'🥱', Stressed:'😖', Neutral:'🙂' };
// ---------------- Weather ----------------------------------
export const WEATHER_STATES = ['sunny','cloudy','rain','storm','fog'];
export const WEATHER_LABEL = { sunny:'Sunny', cloudy:'Cloudy', rain:'Rain', storm:'Storm', fog:'Fog' };
export const WEATHER_EMOJI = { sunny:'☀️', cloudy:'⛅', rain:'🌧️', storm:'⛈️', fog:'🌫️' };
// ---------------- Achievements ------------------------------
export const ACHIEVEMENTS = [
{ id:'first_home', name:'First Home', desc:'Buy your first house.' },
{ id:'social_butterfly', name:'Social Butterfly', desc:'Make 20 friends (friendship ≥ 25).' },
{ id:'millionaire', name:'Millionaire', desc:'Hold 1,000,000 TownCoins.' },
{ id:'heartbreaker', name:'Heartbreaker', desc:'Reach partner status 5 times.' },
{ id:'interior_designer', name:'Interior Designer', desc:'Place 25 furniture items.' },
{ id:'employed', name:'Day One', desc:'Get your first job.' },
{ id:'promoted', name:'Moving On Up', desc:'Earn any promotion.' },
{ id:'party_animal', name:'Party Animal', desc:'Dance at 3 events.' },
];
// ---------------- Emotes -----------------------------------
export const EMOTES = ['wave','dance','laugh','sad','angry','sleep','sit'];
// Chat moderation: basic length cap.
export const MAX_CHAT_LEN = 240;