openvibers/openvibe.live Commits
Legacy HoboQuest remnants are read-only in Live: viewing a chat profile no longer creates a game_players row. The profile card (GET /api/chat/user/:username/profile, and /internal/chat-context/users/profile that OpenVibe.Chat reads) called the old game engine's getPlayer(), which INSERTed a placeholder row for every profile opened (production 70, newest 2026-09-19); they now read db.getLegacyGameProfile(), which never writes and returns the same skills block for legacy players. The engine (server/game/game-engine.js), its item tables (items.js), the game schema and scripts/migrate-rs-companion.js (which wrote game_* tables) are retired, since OpenVibe.Games owns the game and imports Live's legacy rows. Chat tags move to the read-only server/chat/tags.js (catalog, tables, owned and equipped tags for chat lines, history and Chat's decor); the Tag Guardian fight, tag shop and grant/equip writers had no caller left and are gone, as is test/test-tags.js, which only exercised them. The global-chat route's require of the non-existent ../game/cosmetics (always caught, so cosmetics never attached) now uses the real cosmetics module. test/legacy-game-readonly.test.js
Changes
ddeleted file mode 100644@@@ -1,316 +0,0 @@--#!/usr/bin/env node--/**-- * OpenVibeGame — Migration Script-- * Ports player data from RS-Companion SQLite DB into OpenVibeGame tables-- *-- * RS-Companion users have TEXT user_ids (e.g. "12345" from RobotStreamer)-- * OpenVibe.Live users have INTEGER ids with usernames-- *-- * Strategy: Match by username (case-insensitive). For each matched user,-- * port XP, inventory, bank, recipes, battle stats, farm plots.-- *-- * Usage:-- * node scripts/migrate-rs-companion.js <path-to-rs-companion.db>-- *-- * Run AFTER the OpenVibeGame schema has been initialized (start the server once first).-- */----const Database = require('better-sqlite3');--const path = require('path');----// ── Paths ────────────────────────────────────────────────────--const rsDbPath = process.argv[2];--if (!rsDbPath) {-- console.error('Usage: node scripts/migrate-rs-companion.js <path-to-rs-companion.db>');-- process.exit(1);--}--const openvibeDbPath = path.resolve(__dirname, '../data/live.db');----console.log(`[Migration] RS-Companion DB: ${rsDbPath}`);--console.log(`[Migration] OpenVibe.Live DB: ${openvibeDbPath}`);----// ── Open databases ───────────────────────────────────────────--const rsDb = new Database(rsDbPath, { readonly: true });--const openvibeDb = new Database(openvibeDbPath);--openvibeDb.pragma('journal_mode = WAL');----// ── Ensure game tables exist ─────────────────────────────────--const fs = require('fs');--const schema = fs.readFileSync(path.resolve(__dirname, '../server/game/schema.sql'), 'utf8');--openvibeDb.exec(schema);----// ── Build username → openvibe user id map ────────────────────────--const openvibeUsers = openvibeDb.prepare('SELECT id, username, display_name FROM users').all();--const openvibeMap = new Map(); // lowercase username → openvibe user id--for (const u of openvibeUsers) {-- openvibeMap.set(u.username.toLowerCase(), u.id);-- if (u.display_name) openvibeMap.set(u.display_name.toLowerCase(), u.id);--}--console.log(`[Migration] ${openvibeUsers.length} OpenVibe.Live users loaded`);----// ── Fetch RS-Companion users ─────────────────────────────────--const rsUsers = rsDb.prepare('SELECT * FROM users').all();--console.log(`[Migration] ${rsUsers.length} RS-Companion users found`);----let matched = 0, skipped = 0;--const userMap = new Map(); // rs user_id → openvibe user id----for (const rsu of rsUsers) {-- const openvibeId = openvibeMap.get(rsu.username.toLowerCase());-- if (!openvibeId) { skipped++; continue; }-- userMap.set(rsu.user_id, openvibeId);-- matched++;--}--console.log(`[Migration] Matched ${matched} users, skipped ${skipped}`);----// ── Helper: upsert game_players ──────────────────────────────--const upsertPlayer = openvibeDb.prepare(`-- INSERT INTO game_players (user_id, display_name, mining_xp, fishing_xp, woodcut_xp, farming_xp, combat_xp, crafting_xp, total_coins_earned)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)-- ON CONFLICT(user_id) DO UPDATE SET-- mining_xp = MAX(game_players.mining_xp, excluded.mining_xp),-- fishing_xp = MAX(game_players.fishing_xp, excluded.fishing_xp),-- woodcut_xp = MAX(game_players.woodcut_xp, excluded.woodcut_xp),-- farming_xp = MAX(game_players.farming_xp, excluded.farming_xp),-- combat_xp = MAX(game_players.combat_xp, excluded.combat_xp),-- crafting_xp = MAX(game_players.crafting_xp, excluded.crafting_xp),-- total_coins_earned = MAX(game_players.total_coins_earned, excluded.total_coins_earned)--`);----// ── Helper: upsert inventory ─────────────────────────────────--const upsertInv = openvibeDb.prepare(`-- INSERT INTO game_inventory (user_id, item_id, quantity)-- VALUES (?, ?, ?)-- ON CONFLICT(user_id, item_id) DO UPDATE SET quantity = quantity + excluded.quantity--`);----// ── Helper: upsert bank ─────────────────────────────────────--const upsertBank = openvibeDb.prepare(`-- INSERT INTO game_bank (user_id, item_id, quantity)-- VALUES (?, ?, ?)-- ON CONFLICT(user_id, item_id) DO UPDATE SET quantity = quantity + excluded.quantity--`);----// ── Helper: upsert recipes ──────────────────────────────────--const upsertRecipe = openvibeDb.prepare(`-- INSERT OR IGNORE INTO game_recipes (user_id, recipe_id) VALUES (?, ?)--`);----// ── Helper: upsert battle stats ─────────────────────────────--const upsertBattle = openvibeDb.prepare(`-- INSERT INTO game_battle_stats (user_id, battles_won, battles_lost, total_stolen, total_lost, kill_streak, best_streak, kills, deaths)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)-- ON CONFLICT(user_id) DO UPDATE SET-- battles_won = MAX(game_battle_stats.battles_won, excluded.battles_won),-- battles_lost = MAX(game_battle_stats.battles_lost, excluded.battles_lost),-- total_stolen = MAX(game_battle_stats.total_stolen, excluded.total_stolen),-- total_lost = MAX(game_battle_stats.total_lost, excluded.total_lost),-- best_streak = MAX(game_battle_stats.best_streak, excluded.best_streak),-- kills = MAX(game_battle_stats.kills, excluded.kills),-- deaths = MAX(game_battle_stats.deaths, excluded.deaths)--`);----// ══════════════════════════════════════════════════════════════--// MIGRATE--// ══════════════════════════════════════════════════════════════----const migrateAll = openvibeDb.transaction(() => {-- let playerCount = 0, invCount = 0, bankCount = 0, recipeCount = 0, battleCount = 0;---- // ── 1. Players (XP from skill profiles) ──────────────────-- for (const [rsId, openvibeId] of userMap) {-- const rsu = rsDb.prepare('SELECT * FROM users WHERE user_id = ?').get(rsId);-- if (!rsu) continue;---- // Gather XP from profiles-- const fish = rsDb.prepare('SELECT fish_xp FROM fish_profile WHERE user_id = ?').get(rsId);-- const mine = rsDb.prepare('SELECT mine_xp FROM mine_profile WHERE user_id = ?').get(rsId);-- const wc = rsDb.prepare('SELECT wc_xp FROM woodcut_profile WHERE user_id = ?').get(rsId);---- // Combat XP from dungeon stats-- let combatXp = 0;-- try {-- const dung = rsDb.prepare('SELECT dungeon_xp FROM dungeon_stats WHERE user_id = ?').get(rsId);-- if (dung) combatXp = dung.dungeon_xp || 0;-- } catch {}---- // General XP → crafting XP (repurpose)-- const craftXp = Math.floor((rsu.xp || 0) * 0.3);---- upsertPlayer.run(-- openvibeId,-- rsu.username,-- mine?.mine_xp || 0,-- fish?.fish_xp || 0,-- wc?.wc_xp || 0,-- 0, // farming_xp (no direct equivalent)-- combatXp,-- craftXp,-- Math.floor(rsu.coins || 0)-- );-- playerCount++;-- }-- console.log(`[Migration] Ported ${playerCount} player profiles`);---- // ── 2. Inventory ─────────────────────────────────────────-- for (const [rsId, openvibeId] of userMap) {-- const items = rsDb.prepare('SELECT item_id, quantity FROM inventory WHERE user_id = ?').all(rsId);-- for (const item of items) {-- if (item.quantity > 0) {-- upsertInv.run(openvibeId, item.item_id, item.quantity);-- invCount++;-- }-- }-- }-- console.log(`[Migration] Ported ${invCount} inventory entries`);---- // ── 3. Bank ──────────────────────────────────────────────-- let hasBankItems = false;-- try { rsDb.prepare('SELECT 1 FROM bank_items LIMIT 1').get(); hasBankItems = true; } catch {}---- if (hasBankItems) {-- for (const [rsId, openvibeId] of userMap) {-- const items = rsDb.prepare('SELECT item_id, quantity FROM bank_items WHERE user_id = ?').all(rsId);-- for (const item of items) {-- if (item.quantity > 0) {-- upsertBank.run(openvibeId, item.item_id, item.quantity);-- bankCount++;-- }-- }-- }-- }-- console.log(`[Migration] Ported ${bankCount} bank entries`);---- // ── 4. Unlocked Recipes ──────────────────────────────────-- let hasRecipes = false;-- try { rsDb.prepare('SELECT 1 FROM unlocked_recipes LIMIT 1').get(); hasRecipes = true; } catch {}---- if (hasRecipes) {-- for (const [rsId, openvibeId] of userMap) {-- const recipes = rsDb.prepare('SELECT recipe_id FROM unlocked_recipes WHERE user_id = ?').all(rsId);-- for (const r of recipes) {-- upsertRecipe.run(openvibeId, r.recipe_id);-- recipeCount++;-- }-- }-- }-- console.log(`[Migration] Ported ${recipeCount} recipe unlocks`);---- // ── 5. Battle Stats ──────────────────────────────────────-- let hasBattle = false;-- try { rsDb.prepare('SELECT 1 FROM battle_stats LIMIT 1').get(); hasBattle = true; } catch {}---- if (hasBattle) {-- for (const [rsId, openvibeId] of userMap) {-- const bs = rsDb.prepare('SELECT * FROM battle_stats WHERE user_id = ?').get(rsId);-- if (!bs) continue;-- upsertBattle.run(-- openvibeId,-- bs.battles_won || 0, bs.battles_lost || 0,-- bs.total_stolen || 0, bs.total_lost || 0,-- bs.kill_streak || 0, bs.best_streak || 0,-- bs.kills || 0, bs.deaths || 0-- );-- battleCount++;-- }-- }-- console.log(`[Migration] Ported ${battleCount} battle stat entries`);---- // ── 6. Coins bonus (RS-Companion coins → OpenVibe.Live coins) ──-- let coinsMigrated = 0;-- for (const [rsId, openvibeId] of userMap) {-- const rsu = rsDb.prepare('SELECT coins FROM users WHERE user_id = ?').get(rsId);-- if (!rsu || !rsu.coins || rsu.coins <= 0) continue;-- const bonus = Math.floor(rsu.coins);-- if (bonus > 0) {-- openvibeDb.prepare('UPDATE users SET openvibe_coins_balance = openvibe_coins_balance + ? WHERE id = ?').run(bonus, openvibeId);-- coinsMigrated++;-- }-- }-- console.log(`[Migration] Awarded coin bonuses to ${coinsMigrated} users`);---- // ── 7. Cosmetics (activate fx_*/px_* from inventory) ─────-- let cosmeticCount = 0;-- for (const [rsId, openvibeId] of userMap) {-- const fxItems = openvibeDb.prepare(-- "SELECT item_id FROM game_inventory WHERE user_id = ? AND (item_id LIKE 'fx_%' OR item_id LIKE 'px_%') AND quantity > 0"-- ).all(openvibeId);-- for (const item of fxItems) {-- const cat = item.item_id.startsWith('fx_') ? 'name_effect' : 'particle';-- const r = openvibeDb.prepare('INSERT OR IGNORE INTO user_cosmetics (user_id, item_id, category) VALUES (?, ?, ?)').run(openvibeId, item.item_id, cat);-- if (r.changes > 0) cosmeticCount++;-- }-- }-- console.log(`[Migration] Activated ${cosmeticCount} cosmetics`);---- // ── 8. Unlocked voices → user_cosmetics ──────────────────-- let voiceCount = 0;-- let hasVoices = false;-- try { rsDb.prepare('SELECT 1 FROM user_voices LIMIT 1').get(); hasVoices = true; } catch {}---- if (hasVoices) {-- for (const [rsId, openvibeId] of userMap) {-- const voices = rsDb.prepare('SELECT voice_id FROM user_voices WHERE user_id = ?').all(rsId);-- for (const v of voices) {-- const r = openvibeDb.prepare('INSERT OR IGNORE INTO user_cosmetics (user_id, item_id, category) VALUES (?, ?, ?)').run(openvibeId, v.voice_id, 'voice');-- if (r.changes > 0) voiceCount++;-- }-- }-- }-- console.log(`[Migration] Ported ${voiceCount} voice unlocks`);---- // ── 9. Active effects → equipped cosmetics ───────────────-- let equipCount = 0;-- let hasEffects = false;-- try { rsDb.prepare('SELECT 1 FROM active_effects LIMIT 1').get(); hasEffects = true; } catch {}---- if (hasEffects) {-- for (const [rsId, openvibeId] of userMap) {-- const effects = rsDb.prepare('SELECT * FROM active_effects WHERE user_id = ?').all(rsId);-- for (const eff of effects) {-- try {-- const data = JSON.parse(eff.data || '{}');-- if (eff.effect_type === 'name_fx' && data.itemId) {-- openvibeDb.prepare('INSERT OR IGNORE INTO user_cosmetics (user_id, item_id, category) VALUES (?, ?, ?)').run(openvibeId, data.itemId, 'name_effect');-- openvibeDb.prepare('INSERT OR REPLACE INTO user_equipped (user_id, slot, item_id) VALUES (?, ?, ?)').run(openvibeId, 'name_effect', data.itemId);-- openvibeDb.prepare('UPDATE game_players SET name_effect = ? WHERE user_id = ?').run(data.itemId, openvibeId);-- equipCount++;-- } else if (eff.effect_type === 'particle_fx' && data.itemId) {-- openvibeDb.prepare('INSERT OR IGNORE INTO user_cosmetics (user_id, item_id, category) VALUES (?, ?, ?)').run(openvibeId, data.itemId, 'particle');-- openvibeDb.prepare('INSERT OR REPLACE INTO user_equipped (user_id, slot, item_id) VALUES (?, ?, ?)').run(openvibeId, 'particle', data.itemId);-- openvibeDb.prepare('UPDATE game_players SET particle_effect = ? WHERE user_id = ?').run(data.itemId, openvibeId);-- equipCount++;-- }-- } catch (e) { /* malformed JSON */ }-- }-- }-- }---- // ── 10. Selected voice → equipped ────────────────────────-- let hasVoiceSel = false;-- try { rsDb.prepare('SELECT 1 FROM user_voice_selection LIMIT 1').get(); hasVoiceSel = true; } catch {}---- if (hasVoiceSel) {-- for (const [rsId, openvibeId] of userMap) {-- const sel = rsDb.prepare('SELECT voice_id FROM user_voice_selection WHERE user_id = ?').get(rsId);-- if (sel) {-- openvibeDb.prepare('INSERT OR IGNORE INTO user_cosmetics (user_id, item_id, category) VALUES (?, ?, ?)').run(openvibeId, sel.voice_id, 'voice');-- openvibeDb.prepare('INSERT OR REPLACE INTO user_equipped (user_id, slot, item_id) VALUES (?, ?, ?)').run(openvibeId, 'voice', sel.voice_id);-- equipCount++;-- }-- }-- }-- console.log(`[Migration] Equipped ${equipCount} active cosmetics`);--});----// ── Run ──────────────────────────────────────────────────────--try {-- migrateAll();-- console.log('\n[Migration] ✅ Migration complete!');--} catch (err) {-- console.error('\n[Migration] ❌ Migration failed:', err.message);-- process.exit(1);--} finally {-- rsDb.close();-- openvibeDb.close();--}@@@ -862,7 +862,7 @@ class ChatServer {// Attach equipped tag for chat renderingtry {-- const tags = require('../game/tags');++ const tags = require('./tags');const tagProfile = tags.getTagProfile(client.user.id);if (tagProfile) chatMsg.tag = tagProfile;} catch { /* non-critical */ }@@@ -1061,7 +1061,7 @@ class ChatServer {} catch { /* non-critical */ }try {-- const tags = require('../game/tags');++ const tags = require('./tags');const tagProfile = tags.getTagProfile(client.user.id);if (tagProfile) gottiMsg.tag = tagProfile;} catch { /* non-critical */ }@@@ -1358,7 +1358,7 @@ class ChatServer {if (cp.hatFX) soundMsg.hatFX = cp.hatFX;} catch { /* non-critical */ }try {-- const tagProfile = require('../game/tags').getTagProfile(client.user.id);++ const tagProfile = require('./tags').getTagProfile(client.user.id);if (tagProfile) soundMsg.tag = tagProfile;} catch { /* non-critical */ }}@@@ -121,28 +121,9 @@ contextRouter.get('/users/profile', (req, res) => {delete profile.openvibe_bucks_balance;delete profile.last_seen;}-- try {-- const game = require('../game/game-engine');-- const player = game.getPlayer(user.id);-- if (player) {-- profile.game = {-- total_level: player.total_level,-- mining_level: player.mining_level,-- fishing_level: player.fishing_level,-- woodcut_level: player.woodcut_level,-- farming_level: player.farming_level,-- combat_level: player.combat_level,-- crafting_level: player.crafting_level,-- mining_xp: player.mining_xp,-- fishing_xp: player.fishing_xp,-- woodcut_xp: player.woodcut_xp,-- farming_xp: player.farming_xp,-- combat_xp: player.combat_xp,-- crafting_xp: player.crafting_xp,-- total_coins_earned: player.total_coins_earned || 0,-- };-- }-- } catch { /* game not initialized or player doesn't exist */ }++ // Legacy game skills, read-only (never creates a game_players row)++ const game = db.getLegacyGameProfile(user.id);++ if (game) profile.game = game;res.json(profile);} catch (err) {fail(res, 500, 'Failed to get profile');@@@ -216,7 +197,7 @@ contextRouter.post('/decor', (req, res) => {const ids = (Array.isArray(req.body?.user_ids) ? req.body.user_ids : []).map(Number).filter(Number.isInteger).slice(0, 500);let cosmetics = null, tags = null;try { cosmetics = require('../monetization/cosmetics'); } catch { /* */ }-- try { tags = require('../game/tags'); } catch { /* */ }++ try { tags = require('./tags'); } catch { /* */ }const decor = {};for (const id of ids) {let cosmetic = {}, tag = null;@@@ -24,7 +24,7 @@ const router = express.Router();function enrichMessagesWithCosmetics(messages) {let cosmetics = null, tags = null;try { cosmetics = require('../monetization/cosmetics'); } catch { /* */ }-- try { tags = require('../game/tags'); } catch { /* */ }++ try { tags = require('./tags'); } catch { /* */ }if ((!cosmetics && !tags) || !Array.isArray(messages)) return messages;const cache = new Map();for (const m of messages) {@@@ -270,7 +270,7 @@ router.post('/send', requireAuth, (req, res) => {// Attach cosmeticstry {-- const cosmetics = require('../game/cosmetics');++ const cosmetics = require('../monetization/cosmetics');const cosmeticProfile = cosmetics.getCosmeticProfile(req.user.id);if (cosmeticProfile.nameFX) chatMsg.nameFX = cosmeticProfile.nameFX;if (cosmeticProfile.particleFX) chatMsg.particleFX = cosmeticProfile.particleFX;@@@ -280,7 +280,7 @@ router.post('/send', requireAuth, (req, res) => {// Attach tagtry {-- const tags = require('../game/tags');++ const tags = require('./tags');const tagProfile = tags.getTagProfile(req.user.id);if (tagProfile) chatMsg.tag = tagProfile;} catch { /* non-critical */ }@@@ -375,29 +375,9 @@ router.get('/user/:username/profile', optionalAuth, (req, res) => {delete profile.last_seen;}-- // Add game stats if available-- try {-- const game = require('../game/game-engine');-- const player = game.getPlayer(user.id);-- if (player) {-- profile.game = {-- total_level: player.total_level,-- mining_level: player.mining_level,-- fishing_level: player.fishing_level,-- woodcut_level: player.woodcut_level,-- farming_level: player.farming_level,-- combat_level: player.combat_level,-- crafting_level: player.crafting_level,-- mining_xp: player.mining_xp,-- fishing_xp: player.fishing_xp,-- woodcut_xp: player.woodcut_xp,-- farming_xp: player.farming_xp,-- combat_xp: player.combat_xp,-- crafting_xp: player.crafting_xp,-- total_coins_earned: player.total_coins_earned || 0,-- };-- }-- } catch { /* game not initialized or player doesn't exist */ }++ // Legacy game skills, read-only (never creates a game_players row)++ const game = db.getLegacyGameProfile(user.id);++ if (game) profile.game = game;res.json(profile);} catch (err) {nnew file mode 100644@@@ -0,0 +1,98 @@++'use strict';++/**++ * Chat tags: the badge shown next to a username in chat (read-only).++ *++ * The tags came from the legacy HoboQuest game that ran inside Live (Tag Guardian fights, the Tag++ * Master shop, achievement grants). OpenVibe.Games owns that game now, so Live no longer writes any++ * of it: this module only reads what a user owns and has equipped, for chat lines, chat history++ * and OpenVibe.Chat's decor lookups. The tables stay, so existing equipped tags keep showing.++ */++const db = require('../db/database');++++// ── Tag catalog ──────────────────────────────────────────────++// category: 'special' (granted), 'shop' (was sold by the Tag Master NPC), 'achievement' (game milestone)++const TAGS = {++ // ── Special (granted, not purchasable) ───────────────────++ cfo: { name: 'CFO', emoji: '💼', color: '#f59e0b', bgColor: '#451a03', desc: 'Chief Financial Officer — legacy RS-Companion VIP', category: 'special', tier: 5 },++ founder: { name: 'Founder', emoji: '⭐', color: '#facc15', bgColor: '#422006', desc: 'Original OpenVibe.Live founder', category: 'special', tier: 5 },++ legacy: { name: 'Legacy', emoji: '🏛️', color: '#a78bfa', bgColor: '#1e1b4b', desc: 'Migrated from RS-Companion', category: 'special', tier: 3 },++ alpha_tester: { name: 'Alpha Tester', emoji: '🧪', color: '#34d399', bgColor: '#022c22', desc: 'Tested during alpha phase', category: 'special', tier: 3 },++ mod: { name: 'Mod', emoji: '🛡️', color: '#60a5fa', bgColor: '#172554', desc: 'Platform moderator', category: 'special', tier: 4 },++ admin: { name: 'Admin', emoji: '⚡', color: '#f87171', bgColor: '#450a0a', desc: 'Platform administrator', category: 'special', tier: 5 },++ streamer: { name: 'Streamer', emoji: '📡', color: '#c084fc', bgColor: '#3b0764', desc: 'Verified streamer', category: 'special', tier: 4 },++ vip: { name: 'VIP', emoji: '👑', color: '#fbbf24', bgColor: '#78350f', desc: 'Very Important Person', category: 'special', tier: 4 },++ developer: { name: 'Dev', emoji: '🔧', color: '#38bdf8', bgColor: '#0c4a6e', desc: 'Platform developer', category: 'special', tier: 5 },++++ // ── Shop Tags (purchasable from Tag Master NPC) ──────────++ noob: { name: 'Noob', emoji: '🐣', color: '#86efac', bgColor: '#052e16', desc: 'Everyone starts somewhere', category: 'shop', tier: 1, cost: 50 },++ chatterbox: { name: 'Chatterbox', emoji: '💬', color: '#93c5fd', bgColor: '#1e3a5f', desc: 'Professional yapper', category: 'shop', tier: 1, cost: 100 },++ warrior: { name: 'Warrior', emoji: '⚔️', color: '#fca5a5', bgColor: '#450a0a', desc: 'Battle-hardened fighter', category: 'shop', tier: 2, cost: 250 },++ miner: { name: 'Miner', emoji: '⛏️', color: '#d4d4d8', bgColor: '#27272a', desc: 'Deep rock delver', category: 'shop', tier: 2, cost: 250 },++ angler: { name: 'Angler', emoji: '🎣', color: '#7dd3fc', bgColor: '#0c4a6e', desc: 'Master of the rod', category: 'shop', tier: 2, cost: 250 },++ lumberjack: { name: 'Lumberjack', emoji: '🪓', color: '#a3e635', bgColor: '#1a2e05', desc: 'Timber specialist', category: 'shop', tier: 2, cost: 250 },++ farmer: { name: 'Farmer', emoji: '🌾', color: '#fde68a', bgColor: '#451a03', desc: 'Crop connoisseur', category: 'shop', tier: 2, cost: 250 },++ chef: { name: 'Chef', emoji: '👨🍳', color: '#fdba74', bgColor: '#431407', desc: 'Culinary master', category: 'shop', tier: 2, cost: 300 },++ merchant: { name: 'Merchant', emoji: '🏪', color: '#fcd34d', bgColor: '#78350f', desc: 'Shrewd trader', category: 'shop', tier: 3, cost: 500 },++ explorer: { name: 'Explorer', emoji: '🧭', color: '#6ee7b7', bgColor: '#022c22', desc: 'Seen it all', category: 'shop', tier: 3, cost: 500 },++ assassin: { name: 'Assassin', emoji: '🗡️', color: '#a78bfa', bgColor: '#1e1b4b', desc: 'Silent and deadly', category: 'shop', tier: 3, cost: 750 },++ overlord: { name: 'Overlord', emoji: '🔱', color: '#e879f9', bgColor: '#4a044e', desc: 'Rules with an iron fist', category: 'shop', tier: 4, cost: 1500 },++ legend: { name: 'Legend', emoji: '🏆', color: '#fbbf24', bgColor: '#451a03', desc: 'Etched in history', category: 'shop', tier: 5, cost: 5000 },++ void_walker: { name: 'Void Walker', emoji: '🕳️', color: '#818cf8', bgColor: '#0f0f23', desc: 'From beyond the abyss', category: 'shop', tier: 5, cost: 10000 },++++ // ── Achievement Tags (earned, not purchasable) ───────────++ dragon_slayer: { name: 'Dragonslayer', emoji: '🐉', color: '#ef4444', bgColor: '#450a0a', desc: 'Slew a dragon', category: 'achievement', tier: 4 },++ dungeon_master: { name: 'Dungeon Master',emoji: '🏰', color: '#c084fc', bgColor: '#3b0764', desc: 'Won 50 dungeon fights', category: 'achievement', tier: 4 },++ fish_lord: { name: 'Fish Lord', emoji: '🐟', color: '#22d3ee', bgColor: '#083344', desc: 'Caught every fish species', category: 'achievement', tier: 4 },++ big_spender: { name: 'Big Spender', emoji: '💰', color: '#fbbf24', bgColor: '#78350f', desc: 'Spent 50,000 gold total', category: 'achievement', tier: 3 },++ survivor: { name: 'Survivor', emoji: '💪', color: '#4ade80', bgColor: '#052e16', desc: 'Won 100 PvP battles', category: 'achievement', tier: 4 },++};++++function ensureTagTables() {++ db.getDb().exec(`++ CREATE TABLE IF NOT EXISTS user_tags (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ user_id INTEGER NOT NULL,++ tag_id TEXT NOT NULL,++ source TEXT DEFAULT 'shop',++ granted_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ UNIQUE(user_id, tag_id),++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE++ );++ CREATE TABLE IF NOT EXISTS user_equipped_tag (++ user_id INTEGER NOT NULL PRIMARY KEY,++ tag_id TEXT NOT NULL,++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE++ );++ `);++ try { db.getDb().exec('CREATE INDEX IF NOT EXISTS idx_user_tags_user ON user_tags(user_id)'); } catch { /* */ }++}++++/** Every tag a user owns. */++function getUserTags(userId) {++ const rows = db.getDb().prepare('SELECT tag_id, source, granted_at FROM user_tags WHERE user_id = ?').all(userId);++ return rows.map((r) => ({++ ...r,++ ...(TAGS[r.tag_id] || { name: r.tag_id, emoji: '🏷️', color: '#999', bgColor: '#333', desc: 'Unknown tag', category: 'special', tier: 0 }),++ tagId: r.tag_id,++ }));++}++++/** The user's equipped tag, or null. */++function getEquippedTag(userId) {++ const row = db.getDb().prepare('SELECT tag_id FROM user_equipped_tag WHERE user_id = ?').get(userId);++ if (!row) return null;++ const tag = TAGS[row.tag_id];++ return tag ? { tagId: row.tag_id, ...tag } : null;++}++++/** What a chat line carries: { tagId, name, emoji, color, bgColor, … } or null. */++function getTagProfile(userId) {++ return getEquippedTag(userId);++}++++/** The whole catalog. */++function getAllTags() {++ return Object.entries(TAGS).map(([id, t]) => ({ tagId: id, ...t }));++}++++module.exports = { TAGS, ensureTagTables, getUserTags, getEquippedTag, getTagProfile, getAllTags };@@@ -6580,6 +6580,33 @@ function getUserTotalGameLevel(userId) {}}++/**++ * The legacy HoboQuest skills a user earned while the game ran inside Live, for the chat profile++ * card: { total_level, <skill>_level, <skill>_xp, total_coins_earned } or null. Read-only: it never++ * creates a game_players row (the old game engine's getPlayer() inserted one for every profile++ * viewed). OpenVibe.Games owns the game now and imports these rows from here.++ */++function getLegacyGameProfile(userId) {++ if (!userId) return null;++ let p;++ try {++ p = get('SELECT * FROM game_players WHERE user_id = ?', [userId]);++ } catch {++ return null; // no game_players table (a fresh install)++ }++ if (!p) return null;++ const xpToLevel = (xp) => Math.floor(Math.sqrt((xp || 0) / 25)) + 1;++ const skills = ['mining', 'fishing', 'woodcut', 'farming', 'combat', 'crafting', 'smithing', 'agility'];++ const out = { total_level: 0, total_coins_earned: p.total_coins_earned || 0 };++ for (const s of skills) {++ const xp = p[`${s}_xp`] || 0;++ out[`${s}_xp`] = xp;++ out[`${s}_level`] = xpToLevel(xp);++ out.total_level += out[`${s}_level`];++ }++ return out;++}++// ── Anon IP Mapping ─────────────────────────────────/**@@@ -7673,7 +7700,7 @@ module.exports = {getChannelModerators, getChannelsByModerator,// Channel Moderation SettingsgetChannelModerationSettings, upsertChannelModerationSettings,-- getUserTotalGameLevel,++ getUserTotalGameLevel, getLegacyGameProfile,// Anon IP MappingsgetOrCreateAnonNum, getAnonFirstSeen, loadAnonMappings,// Stream first chats (welcome messages)ddeleted file mode 100644@@@ -1,2277 +0,0 @@--/**-- * OpenVibeGame — Server Game Engine-- * Open-world survival: gathering, building, PvP, crafting, economy-- */----const db = require('../db/database');--const fs = require('fs');--const path = require('path');--const cosmeticsSystem = require('../monetization/cosmetics');--const {-- ITEMS, RECIPES, STRUCTURES, CROPS, MONSTERS, RARITY_COLORS, BIOME_COLORS,-- FISH_TABLES, MINE_TABLES, WOOD_TABLES, WATER_ZONE_TABLES,-- FISH_SPECIES, FISH_JUNK, ROD_TIERS, buildFishTable,-- PICK_TIERS, AXE_TIERS, ORE_NODE_TYPES, getOreNodeType,-- MOB_TYPES,-- WEAPON_STATS, ARMOR_STATS, EQUIP_SKILL_MAP, FOOD_EFFECTS,-- MAP_W, MAP_H, TILE, OUTPOST_X, OUTPOST_Y, OUTPOST_RADIUS,-- NPCS, NPC_LIST, NPC_INTERACT_RANGE,-- xpToLevel, levelToXp, rollLoot,-- hashNoise, getBiomeAt, getResourceNodeAt, getDifficultyTier, isInSafeZone, getWaterZone,--} = require('./items');----let worldSeed = 0;----// ── Dodge i-frame tracking ───────────────────────────────────--const dodgeIframes = new Map(); // userId → expiresAt timestamp----// ── Combo tracking ───────────────────────────────────────────--const comboTrackers = new Map(); // userId → { count, lastHitTime }--const COMBO_WINDOW = 2500; // ms between hits to keep combo--const COMBO_MULTIPLIERS = [1.0, 1.0, 1.15, 1.3, 1.5, 1.75, 2.0]; // index = combo count----// ── Sprint stamina tracking ──────────────────────────────────--const sprintTimers = new Map(); // userId → lastDrainTime----// ── Agility tracking ────────────────────────────────────────--const sprintingPlayers = new Set(); // userIds currently sprinting--const playerDistAccum = new Map(); // userId → accumulated distance for agility XP--const swimmingPlayers = new Set(); // userIds currently in water----// ── Food buff tracking (in-memory for ATK/DEF mods) ─────────--const foodBuffs = new Map(); // userId → { atk, def, sprint_free, xp_mult, max_stamina, hp_regen, expiresAt }----// ══════════════════════════════════════════════════════════════--// ACHIEVEMENT DEFINITIONS--// ══════════════════════════════════════════════════════════════----const ACHIEVEMENTS = {-- // ── Combat ──-- first_blood: { name: 'First Blood', emoji: '🗡️', desc: 'Kill your first mob.', category: 'combat', reward: { coins: 25 } },-- mob_slayer_10: { name: 'Mob Slayer', emoji: '💀', desc: 'Kill 10 mobs.', category: 'combat', reward: { coins: 100 } },-- mob_slayer_50: { name: 'Monster Hunter', emoji: '🏹', desc: 'Kill 50 mobs.', category: 'combat', reward: { coins: 300, item: 'dungeon_key', qty: 3 } },-- mob_slayer_100: { name: 'Legendary Slayer', emoji: '⚔️', desc: 'Kill 100 mobs.', category: 'combat', reward: { coins: 750, item: 'hat_wizard', qty: 1 } },-- dragon_slayer: { name: 'Dragon Slayer', emoji: '🐉', desc: 'Kill a Dragon Whelp.', category: 'combat', reward: { coins: 500, item: 'dungeon_key', qty: 5 } },-- pvp_first: { name: 'Duelist', emoji: '🤺', desc: 'Win your first PvP kill.', category: 'combat', reward: { coins: 200 } },-- kill_streak_5: { name: 'Unstoppable', emoji: '🔥', desc: 'Reach a 5 kill streak.', category: 'combat', reward: { coins: 500 } },-- combo_master: { name: 'Combo Master', emoji: '💥', desc: 'Land a 5+ combo hit.', category: 'combat', reward: { coins: 200 } },-- // ── Gathering ──-- first_gather: { name: 'Gatherer', emoji: '🪓', desc: 'Gather your first resource.', category: 'gathering', reward: { coins: 10 } },-- gather_100: { name: 'Resourceful', emoji: '⛏️', desc: 'Gather 100 resources.', category: 'gathering', reward: { coins: 150 } },-- gather_500: { name: 'Strip Miner', emoji: '💎', desc: 'Gather 500 resources.', category: 'gathering', reward: { coins: 400, item: 'pick_iron', qty: 1 } },-- dragonite_found: { name: 'Draconic Discovery', emoji: '🐲', desc: 'Mine Dragonite ore.', category: 'gathering', reward: { coins: 300 } },-- crystal_wood: { name: 'Crystal Lumberjack', emoji: '✨', desc: 'Chop Crystal Wood.', category: 'gathering', reward: { coins: 250 } },-- // ── Fishing ──-- first_catch: { name: 'Gone Fishin\'', emoji: '🎣', desc: 'Catch your first fish.', category: 'fishing', reward: { coins: 15 } },-- fish_species_10: { name: 'Fish Hobbyist', emoji: '🐠', desc: 'Catch 10 different species.', category: 'fishing', reward: { coins: 200 } },-- fish_species_20: { name: 'Fish Collector', emoji: '🐡', desc: 'Catch 20 different species.', category: 'fishing', reward: { coins: 500 } },-- fish_species_all: { name: 'Master Angler', emoji: '🏆', desc: 'Catch every fish species.', category: 'fishing', reward: { coins: 2000, item: 'hat_crown', qty: 1 } },-- legendary_catch: { name: 'Legendary Catch', emoji: '🌟', desc: 'Catch a Legendary fish.', category: 'fishing', reward: { coins: 500 } },-- // ── Crafting ──-- first_craft: { name: 'Artisan', emoji: '🔨', desc: 'Craft your first item.', category: 'crafting', reward: { coins: 15 } },-- craft_25: { name: 'Craftsman', emoji: '🧰', desc: 'Craft 25 items.', category: 'crafting', reward: { coins: 200 } },-- craft_100: { name: 'Master Crafter', emoji: '⚒️', desc: 'Craft 100 items.', category: 'crafting', reward: { coins: 500 } },-- smelt_first: { name: 'Forge Lit', emoji: '🔥', desc: 'Smelt your first bar.', category: 'crafting', reward: { coins: 25 } },-- // ── Exploration ──-- explorer_1k: { name: 'Wanderer', emoji: '🧭', desc: 'Travel 1000 tiles.', category: 'exploration',reward: { coins: 100 } },-- explorer_10k: { name: 'Pathfinder', emoji: '🗺️', desc: 'Travel 10,000 tiles.', category: 'exploration',reward: { coins: 400 } },-- chest_open_1: { name: 'Treasure Hunter', emoji: '📦', desc: 'Open your first treasure chest.', category: 'exploration',reward: { coins: 50 } },-- chest_open_25: { name: 'Loot Goblin', emoji: '🧳', desc: 'Open 25 treasure chests.', category: 'exploration',reward: { coins: 300 } },-- dungeon_enter: { name: 'Delver', emoji: '🗝️', desc: 'Enter a dungeon.', category: 'exploration',reward: { coins: 100 } },-- dungeon_wins_10: { name: 'Dungeon Master', emoji: '🏰', desc: 'Win 10 dungeon fights.', category: 'exploration',reward: { coins: 500, item: 'hat_crown', qty: 1 } },-- // ── Economy / Milestones ──-- first_hat: { name: 'Dapper', emoji: '🎩', desc: 'Equip your first hat.', category: 'milestones', reward: { coins: 50 } },-- level_10_any: { name: 'Apprentice', emoji: '📈', desc: 'Reach level 10 in any skill.', category: 'milestones', reward: { coins: 200 } },-- level_25_any: { name: 'Journeyman', emoji: '📊', desc: 'Reach level 25 in any skill.', category: 'milestones', reward: { coins: 500 } },-- level_50_any: { name: 'Expert', emoji: '🏅', desc: 'Reach level 50 in any skill.', category: 'milestones', reward: { coins: 1500 } },-- total_level_50: { name: 'Well Rounded', emoji: '🌟', desc: 'Reach total level 50.', category: 'milestones', reward: { coins: 300 } },-- total_level_100: { name: 'OpenVibe Legend', emoji: '👑', desc: 'Reach total level 100.', category: 'milestones', reward: { coins: 1000, item: 'hat_halo', qty: 1 } },-- die_10: { name: 'Punching Bag', emoji: '😵', desc: 'Die 10 times.', category: 'milestones', reward: { coins: 50 } },-- build_10: { name: 'Home Builder', emoji: '🏠', desc: 'Build 10 structures.', category: 'milestones', reward: { coins: 150 } },--};----// Pending achievement notifications to send to players--const pendingAchievements = new Map(); // userId → [{ achievementId, ...ACHIEVEMENTS[id] }]----// ══════════════════════════════════════════════════════════════--// DAILY QUESTS--// ══════════════════════════════════════════════════════════════----const DAILY_QUEST_SLOTS = 3;--const DAILY_QUESTS = [-- { id: 'forage_run', pool: 'easy', stat: 'gather_count', target: 12, emoji: '🪓', name: 'Forage Run', desc: 'Gather 12 resources anywhere in the wild.', reward: { coins: 90, item: 'bait_worm', qty: 5 } },-- { id: 'pond_hopper', pool: 'easy', stat: 'fish_count', target: 3, emoji: '🎣', name: 'Pond Hopper', desc: 'Catch 3 fish today.', reward: { coins: 85, item: 'potion_stamina', qty: 1 } },-- { id: 'road_trip', pool: 'easy', stat: 'tiles_traveled', target: 180, emoji: '🧭', name: 'Road Trip', desc: 'Travel 180 tiles across the world.', reward: { coins: 95, item: 'potion_health', qty: 1 } },-- { id: 'bench_worker', pool: 'medium', stat: 'craft_count', target: 3, emoji: '🔨', name: 'Bench Worker', desc: 'Craft 3 items.', reward: { coins: 135, item: 'potion_stamina', qty: 2 } },-- { id: 'camp_clearout', pool: 'medium', stat: 'mob_kills', target: 5, emoji: '⚔️', name: 'Camp Clearout', desc: 'Defeat 5 mobs.', reward: { coins: 145, item: 'potion_health', qty: 2 } },-- { id: 'lockbox_luck', pool: 'medium', stat: 'chests_opened', target: 1, emoji: '📦', name: 'Lockbox Luck', desc: 'Open 1 treasure chest.', reward: { coins: 140, item: 'dungeon_key', qty: 1 } },-- { id: 'builder_brigade', pool: 'hard', stat: 'structures_built', target: 2, emoji: '🏗️', name: 'Builder Brigade', desc: 'Build 2 structures.', reward: { coins: 190, item: 'bar_iron', qty: 2 } },-- { id: 'long_haul', pool: 'hard', stat: 'tiles_traveled', target: 450, emoji: '🗺️', name: 'Long Haul', desc: 'Travel 450 tiles in one day.', reward: { coins: 210, item: 'food_fish_stew', qty: 1 } },-- { id: 'harvest_hustle', pool: 'hard', stat: 'gather_count', target: 30, emoji: '⛏️', name: 'Harvest Hustle', desc: 'Gather 30 resources today.', reward: { coins: 220, item: 'potion_stamina', qty: 3 } },--];----function getDailyQuestDate(date = new Date()) {-- return date.toISOString().slice(0, 10);--}----function getDailyQuestResetAt() {-- const next = new Date();-- next.setUTCHours(24, 0, 0, 0);-- return next.toISOString();--}----function hashString(input) {-- let hash = 2166136261;-- const str = String(input || '');-- for (let i = 0; i < str.length; i++) {-- hash ^= str.charCodeAt(i);-- hash = Math.imul(hash, 16777619);-- }-- return hash >>> 0;--}----function createSeededRng(seed) {-- let state = seed >>> 0;-- return () => {-- state = (state + 0x6D2B79F5) | 0;-- let t = Math.imul(state ^ (state >>> 15), 1 | state);-- t ^= t + Math.imul(t ^ (t >>> 7), 61 | t);-- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;-- };--}----function getDailyQuestTemplates(userId, questDate = getDailyQuestDate()) {-- const rng = createSeededRng(hashString(`${userId}:${questDate}:daily-quests`));-- const pools = ['easy', 'medium', 'hard'];-- const selected = [];-- for (const pool of pools) {-- const options = DAILY_QUESTS.filter(q => q.pool === pool);-- if (!options.length) continue;-- selected.push(options[Math.floor(rng() * options.length)]);-- }-- return selected;--}----function incrementDailyQuestStat(userId, statKey, amount = 1) {-- const value = Number(amount);-- if (!Number.isFinite(value) || value <= 0 || !statKey) return;-- const questDate = getDailyQuestDate();-- db.run(`INSERT INTO game_daily_quest_progress (user_id, quest_date, stat_key, value, updated_at)-- VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)-- ON CONFLICT(user_id, quest_date, stat_key)-- DO UPDATE SET value = value + excluded.value, updated_at = CURRENT_TIMESTAMP`,-- [userId, questDate, statKey, value]);--}----function getDailyQuestProgressMap(userId, questDate = getDailyQuestDate()) {-- const rows = db.all('SELECT stat_key, value FROM game_daily_quest_progress WHERE user_id = ? AND quest_date = ?', [userId, questDate]);-- return rows.reduce((acc, row) => {-- acc[row.stat_key] = Number(row.value) || 0;-- return acc;-- }, {});--}----function getDailyQuestClaimedSet(userId, questDate = getDailyQuestDate()) {-- const rows = db.all('SELECT quest_id FROM game_daily_quest_claims WHERE user_id = ? AND quest_date = ?', [userId, questDate]);-- return new Set(rows.map(row => row.quest_id));--}----function getDailyQuestStreak(userId, questDate = getDailyQuestDate()) {-- const rows = db.all(`SELECT quest_date, COUNT(*) as claimed-- FROM game_daily_quest_claims-- WHERE user_id = ?-- GROUP BY quest_date-- HAVING claimed >= ?-- ORDER BY quest_date DESC`, [userId, DAILY_QUEST_SLOTS]);-- const completedDays = new Set(rows.map(row => row.quest_date));-- let streak = 0;-- const cursor = new Date(`${questDate}T00:00:00.000Z`);-- while (completedDays.has(cursor.toISOString().slice(0, 10))) {-- streak++;-- cursor.setUTCDate(cursor.getUTCDate() - 1);-- }-- return streak;--}----function getDailyQuests(userId, questDate = getDailyQuestDate()) {-- const templates = getDailyQuestTemplates(userId, questDate);-- const progressMap = getDailyQuestProgressMap(userId, questDate);-- const claimedSet = getDailyQuestClaimedSet(userId, questDate);-- const quests = templates.map(template => {-- const progressRaw = progressMap[template.stat] || 0;-- const progress = Math.min(template.target, Math.floor(progressRaw));-- return {-- ...template,-- progress,-- completed: progressRaw >= template.target,-- claimed: claimedSet.has(template.id),-- };-- });-- return {-- date: questDate,-- nextResetAt: getDailyQuestResetAt(),-- streak: getDailyQuestStreak(userId, questDate),-- claimed: quests.filter(q => q.claimed).length,-- completed: quests.filter(q => q.completed).length,-- total: quests.length,-- quests,-- };--}----function claimDailyQuest(userId, questId) {-- const questDate = getDailyQuestDate();-- const daily = getDailyQuests(userId, questDate);-- const quest = daily.quests.find(entry => entry.id === questId);-- if (!quest) return { error: 'Quest not found.' };-- if (quest.claimed) return { error: 'Quest already claimed.' };-- if (!quest.completed) return { error: 'Quest is not complete yet.' };---- db.run('INSERT OR IGNORE INTO game_daily_quest_claims (user_id, quest_date, quest_id) VALUES (?, ?, ?)', [userId, questDate, questId]);-- if (quest.reward?.coins) db.addOpenCoins(userId, quest.reward.coins);-- if (quest.reward?.item) addItem(userId, quest.reward.item, quest.reward.qty || 1);---- return {-- success: true,-- quest: { ...quest, claimed: true },-- reward: quest.reward,-- daily: getDailyQuests(userId, questDate),-- };--}----// ══════════════════════════════════════════════════════════════--// WEATHER SYSTEM--// ══════════════════════════════════════════════════════════════----const WEATHER_TYPES = ['clear', 'rain', 'storm', 'fog', 'snow'];--const WEATHER_DURATIONS = { clear: [5,15], rain: [3,10], storm: [2,6], fog: [3,8], snow: [4,10] }; // min minutes--let currentWeather = 'clear';--let weatherChangesAt = 0;----function tickWeather() {-- if (Date.now() < weatherChangesAt) return false;-- // Pick new weather (weighted — clear most common)-- const weights = { clear: 40, rain: 25, fog: 15, storm: 10, snow: 10 };-- const total = Object.values(weights).reduce((s, w) => s + w, 0);-- let roll = Math.random() * total;-- for (const [w, weight] of Object.entries(weights)) {-- roll -= weight;-- if (roll <= 0) { currentWeather = w; break; }-- }-- const [minMins, maxMins] = WEATHER_DURATIONS[currentWeather];-- weatherChangesAt = Date.now() + (minMins + Math.random() * (maxMins - minMins)) * 60000;-- return true; // weather changed--}----function getWeather() { return { type: currentWeather, changesAt: weatherChangesAt }; }----// ══════════════════════════════════════════════════════════════--// TREASURE CHEST SYSTEM--// ══════════════════════════════════════════════════════════════----const liveChests = new Map(); // chestId → { id, tier, x, y, spawnedAt }--let chestIdCounter = 0;--const CHEST_TTL = 300000; // 5 min--const CHEST_MAX_NEARBY = 4;--const CHEST_TIERS = [-- { name: 'Wooden Chest', emoji: '📦', loot: [{ id: 'loot_coin_pouch', w: 30 }, { id: 'potion_health', w: 25 }, { id: 'bait_worm', w: 20 }, { id: 'seed_wheat', w: 15 }, { id: 'loot_feather', w: 10 }], coinRange: [5, 20] },-- { name: 'Silver Chest', emoji: '🪙', loot: [{ id: 'loot_coin_pouch', w: 20 }, { id: 'potion_health_big', w: 15 }, { id: 'dungeon_key', w: 12 }, { id: 'gem_ruby', w: 15 }, { id: 'loot_crown', w: 10 }, { id: 'food_fish_stew', w: 15 }, { id: 'bar_iron', w: 13 }], coinRange: [15, 50] },-- { name: 'Gold Chest', emoji: '💰', loot: [{ id: 'gem_emerald', w: 20 }, { id: 'gem_diamond', w: 12 }, { id: 'dungeon_key', w: 15 }, { id: 'loot_egg', w: 10 }, { id: 'bar_gold', w: 15 }, { id: 'food_pumpkin_pie', w: 10 }, { id: 'hat_cowboy', w: 8 }, { id: 'weapon_sword', w: 10 }], coinRange: [30, 100] },-- { name: 'Crystal Chest', emoji: '💎', loot: [{ id: 'gem_diamond', w: 18 }, { id: 'gem_star', w: 12 }, { id: 'ore_dragonite', w: 10 }, { id: 'loot_star', w: 8 }, { id: 'loot_void_heart', w: 4 }, { id: 'hat_wizard', w: 8 }, { id: 'dungeon_key', w: 15 }, { id: 'food_golden_feast', w: 10 }, { id: 'weapon_katana', w: 5 }, { id: 'bar_mithril', w: 10 }], coinRange: [50, 250] },--];----function spawnChests() {-- const players = getLivePlayers();-- for (const [uid, p] of Object.entries(players)) {-- const ptx = Math.floor(p.x / TILE), pty = Math.floor(p.y / TILE);-- if (isInSafeZone(ptx, pty)) continue;-- // Count nearby chests-- let nearby = 0;-- for (const ch of liveChests.values()) {-- if (Math.abs(ch.x - p.x) + Math.abs(ch.y - p.y) < 12 * TILE) nearby++;-- }-- if (nearby >= CHEST_MAX_NEARBY) continue;-- // 15% chance per spawn tick per player-- if (Math.random() > 0.15) continue;---- const angle = Math.random() * Math.PI * 2;-- const dist = (4 + Math.random() * 8) * TILE;-- const cx = p.x + Math.cos(angle) * dist;-- const cy = p.y + Math.sin(angle) * dist;-- const ctx_ = Math.floor(cx / TILE), cty = Math.floor(cy / TILE);-- if (ctx_ < 1 || ctx_ >= MAP_W - 1 || cty < 1 || cty >= MAP_H - 1) continue;-- const biome = getBiomeAt(ctx_, cty, worldSeed);-- if (biome === 'water' || biome === 'outpost') continue;---- const tier = Math.min(3, getDifficultyTier(ctx_, cty));-- const tierNames = ['wooden', 'silver', 'gold', 'crystal'];-- const id = ++chestIdCounter;-- liveChests.set(id, {-- id, tier: tierNames[tier], tierIdx: tier, x: cx, y: cy, spawnedAt: Date.now(),-- name: CHEST_TIERS[tier].name, emoji: CHEST_TIERS[tier].emoji,-- });-- }--}----function openChest(userId, chestId) {-- const chest = liveChests.get(chestId);-- if (!chest) return { error: 'Chest not found.' };-- const p = getPlayer(userId);-- const dist = Math.sqrt((p.x - chest.x) ** 2 + (p.y - chest.y) ** 2);-- if (dist > TILE * 3) return { error: 'Too far away!' };---- const tierData = CHEST_TIERS[chest.tierIdx];-- // Roll 1-3 loot items-- const numItems = 1 + Math.floor(Math.random() * (1 + chest.tier));-- const lootItems = [];-- for (let i = 0; i < numItems; i++) {-- const lootId = rollLoot(tierData.loot.map(l => ({ id: l.id, weight: l.w })));-- addItem(userId, lootId, 1);-- lootItems.push({ id: lootId, ...(ITEMS[lootId] || { name: lootId }) });-- }-- // Bonus coins-- const coins = tierData.coinRange[0] + Math.floor(Math.random() * (tierData.coinRange[1] - tierData.coinRange[0] + 1));-- db.addOpenCoins(userId, coins);---- liveChests.delete(chestId);---- // Track for achievements-- const totalChests = (db.get('SELECT total_chests_opened FROM game_players WHERE user_id = ?', [userId])?.total_chests_opened || 0) + 1;-- db.run('UPDATE game_players SET total_chests_opened = total_chests_opened + 1 WHERE user_id = ?', [userId]);-- incrementDailyQuestStat(userId, 'chests_opened', 1);-- checkAchievement(userId, 'chest_open_1', totalChests >= 1);-- checkAchievement(userId, 'chest_open_25', totalChests >= 25);---- return {-- success: true, chestId,-- tier: chest.tier, name: tierData.name, emoji: tierData.emoji,-- loot: lootItems.map(i => ({ id: i.id, name: i.name, emoji: i.emoji, qty: 1 })), coins,-- x: chest.x, y: chest.y,-- };--}----function cleanupChests() {-- const now = Date.now();-- for (const [id, ch] of liveChests) {-- if (now - ch.spawnedAt > CHEST_TTL) liveChests.delete(id);-- }--}----function getChestStates() {-- const now = Date.now();-- const result = {};-- for (const [id, ch] of liveChests) {-- if (now - ch.spawnedAt > CHEST_TTL) { liveChests.delete(id); continue; }-- result[id] = ch;-- }-- return result;--}----// NPC positions (tile coordinates)--function isNearNPC(userId, npcId) {-- const npc = NPCS[npcId];-- if (!npc) return false;-- const player = getPlayer(userId);-- if (!player) return false;-- const px = Math.floor(player.x / TILE), py = Math.floor(player.y / TILE);-- return Math.abs(px - npc.tileX) <= NPC_INTERACT_RANGE && Math.abs(py - npc.tileY) <= NPC_INTERACT_RANGE;--}----function isNearBankNPC(userId) { return isNearNPC(userId, 'banker'); }----function isNearAnyShopNPC(userId) {-- return NPC_LIST.filter(n => n.categories.length > 0).some(n => isNearNPC(userId, n.id));--}----function getNearestNPC(userId) {-- const player = getPlayer(userId);-- if (!player) return null;-- const px = Math.floor(player.x / TILE), py = Math.floor(player.y / TILE);-- let best = null, bestDist = Infinity;-- for (const npc of NPC_LIST) {-- const d = Math.abs(px - npc.tileX) + Math.abs(py - npc.tileY);-- if (d <= NPC_INTERACT_RANGE * 2 && d < bestDist) { best = npc; bestDist = d; }-- }-- return best;--}----function getShopItemsForNPC(npcId) {-- const npc = NPCS[npcId];-- if (!npc || !npc.categories.length) return [];-- return Object.entries(ITEMS)-- .filter(([_, i]) => (i.buyCost !== null || i.sellPrice !== null || i.coinPrice) && npc.categories.includes(i.category))-- .map(([id, i]) => ({ id, ...i }));--}----function getSellableItemsForNPC(npcId) {-- const npc = NPCS[npcId];-- if (!npc || !npc.categories.length) return [];-- return Object.entries(ITEMS)-- .filter(([_, i]) => i.sellPrice !== null && npc.categories.includes(i.category))-- .map(([id, i]) => ({ id, ...i }));--}----// ── Ground Items (dropped on death, scattered in world) ──────--const groundItems = new Map(); // groundItemId → { id, itemId, qty, x, y, droppedAt, name, emoji, rarity }--let groundItemIdCounter = 0;--const GROUND_ITEM_TTL = 300000; // 5 min----function dropGroundItem(itemId, qty, x, y, droppedBy = null) {-- const id = ++groundItemIdCounter;-- const item = ITEMS[itemId] || {};-- groundItems.set(id, {-- id, itemId, qty, x, y, droppedAt: Date.now(), droppedBy,-- name: item.name || itemId,-- emoji: item.emoji || '❓',-- rarity: item.rarity || 'Common',-- });-- return id;--}----function pickupGroundItem(userId, groundItemId) {-- const gi = groundItems.get(groundItemId);-- if (!gi) return { error: 'Item not found.' };-- const p = getPlayer(userId);-- const dist = Math.sqrt((p.x - gi.x) ** 2 + (p.y - gi.y) ** 2);-- if (dist > TILE * 2.5) return { error: 'Too far away!' };-- addItem(userId, gi.itemId, gi.qty);-- const result = { success: true, groundId: groundItemId, item: { id: gi.itemId, name: gi.name, emoji: gi.emoji, qty: gi.qty, rarity: gi.rarity } };-- groundItems.delete(groundItemId);-- return result;--}----function getGroundItemStates() {-- const now = Date.now();-- const result = {};-- for (const [id, gi] of groundItems) {-- if (now - gi.droppedAt > GROUND_ITEM_TTL) { groundItems.delete(id); continue; }-- result[id] = gi;-- }-- return result;--}----function cleanupGroundItems() {-- const now = Date.now();-- for (const [id, gi] of groundItems) {-- if (now - gi.droppedAt > GROUND_ITEM_TTL) groundItems.delete(id);-- }--}----// ── Station Proximity Check (furnace, workbench, campfire) ───--function isNearStation(userId, stationType) {-- const p = getPlayer(userId);-- const ptx = Math.floor(p.x / TILE), pty = Math.floor(p.y / TILE);-- const nearby = db.all(-- 'SELECT id FROM game_structures WHERE type = ? AND ABS(tile_x - ?) <= 3 AND ABS(tile_y - ?) <= 3',-- [stationType, ptx, pty]-- );-- return nearby.length > 0;--}----// ── Resource Node Depletion (in-memory) ──────────────────────--const depletedNodes = new Map(); // "x,y" → respawnTimestamp----function isNodeDepleted(tx, ty) {-- const key = `${tx},${ty}`;-- const t = depletedNodes.get(key);-- if (!t) return false;-- if (Date.now() >= t) { depletedNodes.delete(key); return false; }-- return true;--}----function depleteNode(tx, ty, cooldownMs) {-- depletedNodes.set(`${tx},${ty}`, Date.now() + cooldownMs);--}----function getDepletedNodes() {-- const now = Date.now();-- const result = [];-- for (const [key, t] of depletedNodes) {-- if (now < t) {-- const [x, y] = key.split(',').map(Number);-- result.push({ x, y, respawnAt: t });-- } else {-- depletedNodes.delete(key);-- }-- }-- return result;--}----// ── Init ─────────────────────────────────────────────────────--function initGameDb() {-- const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');-- db.getDb().exec(schema);---- // Migrations: add columns that may be missing from older schema versions-- const migrations = [-- 'ALTER TABLE game_players ADD COLUMN sleeping_bag_x REAL',-- 'ALTER TABLE game_players ADD COLUMN sleeping_bag_y REAL',-- 'ALTER TABLE game_players ADD COLUMN structures_built INTEGER DEFAULT 0',-- 'ALTER TABLE game_players ADD COLUMN resources_gathered INTEGER DEFAULT 0',-- 'ALTER TABLE game_players ADD COLUMN smithing_xp INTEGER DEFAULT 0',-- 'ALTER TABLE game_players ADD COLUMN agility_xp INTEGER DEFAULT 0',-- 'ALTER TABLE game_players ADD COLUMN total_chests_opened INTEGER DEFAULT 0',-- 'ALTER TABLE game_players ADD COLUMN total_tiles_traveled REAL DEFAULT 0',-- 'ALTER TABLE game_players ADD COLUMN total_dungeon_wins INTEGER DEFAULT 0',-- ];-- for (const sql of migrations) {-- try { db.getDb().exec(sql); } catch { /* column already exists */ }-- }---- // Fish collection table (Toontown-style)-- db.getDb().exec(`-- CREATE TABLE IF NOT EXISTS game_fish_collection (-- user_id INTEGER NOT NULL,-- fish_id TEXT NOT NULL,-- times_caught INTEGER DEFAULT 1,-- max_weight REAL DEFAULT 0,-- first_caught DATETIME DEFAULT CURRENT_TIMESTAMP,-- PRIMARY KEY (user_id, fish_id),-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE-- )-- `);-- try { db.getDb().exec('CREATE INDEX IF NOT EXISTS idx_fish_coll_user ON game_fish_collection(user_id)'); } catch { }---- // Achievement tracking table-- db.getDb().exec(`-- CREATE TABLE IF NOT EXISTS game_achievements (-- user_id INTEGER NOT NULL,-- achievement_id TEXT NOT NULL,-- completed_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- PRIMARY KEY (user_id, achievement_id),-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE-- )-- `);---- db.getDb().exec(`-- CREATE TABLE IF NOT EXISTS game_daily_quest_progress (-- user_id INTEGER NOT NULL,-- quest_date TEXT NOT NULL,-- stat_key TEXT NOT NULL,-- value REAL DEFAULT 0,-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- PRIMARY KEY (user_id, quest_date, stat_key),-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE-- )-- `);-- db.getDb().exec(`-- CREATE TABLE IF NOT EXISTS game_daily_quest_claims (-- user_id INTEGER NOT NULL,-- quest_date TEXT NOT NULL,-- quest_id TEXT NOT NULL,-- claimed_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- PRIMARY KEY (user_id, quest_date, quest_id),-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE-- )-- `);-- try { db.getDb().exec('CREATE INDEX IF NOT EXISTS idx_daily_quest_progress_user_date ON game_daily_quest_progress(user_id, quest_date)'); } catch { }-- try { db.getDb().exec('CREATE INDEX IF NOT EXISTS idx_daily_quest_claims_user_date ON game_daily_quest_claims(user_id, quest_date)'); } catch { }---- worldSeed = getWorldSeed();-- console.log(`[OpenVibeGame] Schema initialized, world seed: ${worldSeed}`);--}----function getWorldSeed() {-- let row = db.get("SELECT value FROM game_world_state WHERE key = 'world_seed'");-- if (!row) {-- const seed = Math.floor(Math.random() * 2147483647);-- db.run("INSERT INTO game_world_state (key, value) VALUES ('world_seed', ?)", [String(seed)]);-- return seed;-- }-- return parseInt(row.value, 10);--}----// ══════════════════════════════════════════════════════════════--// AGILITY — Passive bonus calculator--// ══════════════════════════════════════════════════════════════----function getAgilityBonuses(level) {-- return {-- maxStaminaBonus: level * 2, // +2 max stamina per level-- regenBonus: Math.floor(level * 0.4), // +0.4 regen per 3s tick per level-- sprintDrainMult: Math.max(0.50, 1.0 - level * 0.02), // -2% sprint drain / level (min 50%)-- dodgeCostReduct: Math.min(10, Math.floor(level * 0.5)), // -0.5 dodge cost / level (max -10)-- swimSpeedMult: 1.0 + level * 0.02, // +2% swim speed / level-- sprintSpeedMult: 1.0 + Math.floor(level / 5) * 0.02, // +2% sprint speed every 5 levels-- };--}----// ══════════════════════════════════════════════════════════════--// ACHIEVEMENT TRACKING--// ══════════════════════════════════════════════════════════════----function checkAchievement(userId, achievementId, condition) {-- if (!condition) return null;-- if (!ACHIEVEMENTS[achievementId]) return null;-- // Already completed?-- const existing = db.get('SELECT 1 FROM game_achievements WHERE user_id = ? AND achievement_id = ?', [userId, achievementId]);-- if (existing) return null;-- // Award it!-- db.run('INSERT OR IGNORE INTO game_achievements (user_id, achievement_id) VALUES (?, ?)', [userId, achievementId]);-- const ach = ACHIEVEMENTS[achievementId];-- // Grant reward-- if (ach.reward.coins) db.addOpenCoins(userId, ach.reward.coins);-- if (ach.reward.item) addItem(userId, ach.reward.item, ach.reward.qty || 1);-- // Queue notification-- if (!pendingAchievements.has(userId)) pendingAchievements.set(userId, []);-- pendingAchievements.get(userId).push({ id: achievementId, ...ach });-- return { id: achievementId, ...ach };--}----function getAchievements(userId) {-- const completed = db.all('SELECT achievement_id, completed_at FROM game_achievements WHERE user_id = ?', [userId]);-- const completedSet = new Set(completed.map(r => r.achievement_id));-- const all = Object.entries(ACHIEVEMENTS).map(([id, a]) => ({-- id, ...a,-- completed: completedSet.has(id),-- completedAt: completed.find(r => r.achievement_id === id)?.completed_at || null,-- }));-- return { achievements: all, completed: completed.length, total: Object.keys(ACHIEVEMENTS).length };--}----function flushPendingAchievements(userId) {-- const list = pendingAchievements.get(userId) || [];-- pendingAchievements.delete(userId);-- return list;--}----/** Check level-based achievements after any XP gain */--function checkLevelAchievements(userId) {-- const p = getPlayer(userId);-- const skills = ['mining', 'fishing', 'woodcut', 'farming', 'combat', 'crafting', 'smithing', 'agility'];-- const levels = skills.map(s => xpToLevel(p[`${s}_xp`] || 0));-- const maxLevel = Math.max(...levels);-- const totalLevel = levels.reduce((a, b) => a + b, 0);-- checkAchievement(userId, 'level_10_any', maxLevel >= 10);-- checkAchievement(userId, 'level_25_any', maxLevel >= 25);-- checkAchievement(userId, 'level_50_any', maxLevel >= 50);-- checkAchievement(userId, 'total_level_50', totalLevel >= 50);-- checkAchievement(userId, 'total_level_100', totalLevel >= 100);--}----// ══════════════════════════════════════════════════════════════--// PLAYER--// ══════════════════════════════════════════════════════════════----function getPlayer(userId) {-- let p = db.get('SELECT * FROM game_players WHERE user_id = ?', [userId]);-- if (!p) {-- // Spawn at outpost center-- const sx = OUTPOST_X * TILE + TILE / 2;-- const sy = OUTPOST_Y * TILE + TILE / 2;-- db.run('INSERT INTO game_players (user_id, x, y) VALUES (?, ?, ?)', [userId, sx, sy]);-- p = db.get('SELECT * FROM game_players WHERE user_id = ?', [userId]);-- }-- p.mining_level = xpToLevel(p.mining_xp);-- p.fishing_level = xpToLevel(p.fishing_xp);-- p.woodcut_level = xpToLevel(p.woodcut_xp);-- p.farming_level = xpToLevel(p.farming_xp);-- p.combat_level = xpToLevel(p.combat_xp);-- p.crafting_level = xpToLevel(p.crafting_xp);-- p.smithing_level = xpToLevel(p.smithing_xp || 0);-- p.agility_level = xpToLevel(p.agility_xp || 0);-- p.total_level = p.mining_level + p.fishing_level + p.woodcut_level +-- p.farming_level + p.combat_level + p.crafting_level +-- p.smithing_level + p.agility_level;-- // Apply agility max_stamina bonus: +2 per agility level-- const agilBonus = getAgilityBonuses(p.agility_level);-- p.max_stamina = 100 + agilBonus.maxStaminaBonus;-- return p;--}----function updatePlayerPosition(userId, x, y) {-- db.run('UPDATE game_players SET x = ?, y = ?, last_action = CURRENT_TIMESTAMP WHERE user_id = ?', [x, y, userId]);--}----function addXp(userId, skill, amount) {-- const col = `${skill}_xp`;-- const p = getPlayer(userId);-- const oldLevel = xpToLevel(p[col]);-- db.run(`UPDATE game_players SET ${col} = ${col} + ?, last_action = CURRENT_TIMESTAMP WHERE user_id = ?`, [amount, userId]);-- const newXp = p[col] + amount;-- const newLevel = xpToLevel(newXp);-- // Check level-based achievements on level up-- if (newLevel > oldLevel) checkLevelAchievements(userId);-- return { oldLevel, newLevel, leveledUp: newLevel > oldLevel, xp: newXp, totalXp: amount };--}----function regenStamina(userId) {-- const p = db.get('SELECT stamina, max_stamina, last_stamina_tick FROM game_players WHERE user_id = ?', [userId]);-- if (!p) return 0;-- const now = Date.now();-- let lastTick;-- try { lastTick = new Date(p.last_stamina_tick.replace(' ', 'T') + 'Z').getTime(); }-- catch { lastTick = now - 30000; }-- const regen = Math.floor((now - lastTick) / 1000 / 30);-- if (regen > 0 && p.stamina < p.max_stamina) {-- const newSta = Math.min(p.max_stamina, p.stamina + regen);-- db.run('UPDATE game_players SET stamina = ?, last_stamina_tick = CURRENT_TIMESTAMP WHERE user_id = ?', [newSta, userId]);-- return newSta;-- }-- return p.stamina;--}----/**-- * Continuous stamina regen tick — called every ~3s from server tick.-- * Returns an array of { userId, stamina, maxStamina } for players whose stamina changed.-- */--function regenStaminaTick() {-- const players = getLivePlayers();-- const updates = [];-- for (const [uid, lp] of Object.entries(players)) {-- const userId = parseInt(uid);-- const pd = getPlayer(userId);-- if (!pd) continue;---- const maxSta = pd.max_stamina;-- if (pd.stamina >= maxSta) continue; // Already full---- // Base regen: 3 per tick, boosted by agility-- const bonuses = getAgilityBonuses(pd.agility_level);-- let regen = 3 + bonuses.regenBonus;---- // Halve regen if currently sprinting-- if (sprintingPlayers.has(userId)) regen = Math.max(1, Math.floor(regen / 2));---- const newSta = Math.min(maxSta, pd.stamina + regen);-- if (newSta !== pd.stamina) {-- db.run('UPDATE game_players SET stamina = ?, last_stamina_tick = CURRENT_TIMESTAMP WHERE user_id = ?', [newSta, userId]);-- updates.push({ userId, stamina: newSta, maxStamina: maxSta });-- }-- }-- // Clear sprint tracking for this tick (will be re-set by next movement)-- sprintingPlayers.clear();-- return updates;--}----// ══════════════════════════════════════════════════════════════--// INVENTORY--// ══════════════════════════════════════════════════════════════----function getInventory(userId) {-- return db.all('SELECT item_id, quantity FROM game_inventory WHERE user_id = ? AND quantity > 0', [userId])-- .map(r => ({ ...r, ...(ITEMS[r.item_id] || { name: r.item_id, emoji: '❓', rarity: 'Common' }) }));--}----function getItemCount(userId, itemId) {-- const r = db.get('SELECT quantity FROM game_inventory WHERE user_id = ? AND item_id = ?', [userId, itemId]);-- return r ? r.quantity : 0;--}----function addItem(userId, itemId, qty = 1) {-- db.run(`INSERT INTO game_inventory (user_id, item_id, quantity) VALUES (?, ?, ?)-- ON CONFLICT(user_id, item_id) DO UPDATE SET quantity = quantity + ?`, [userId, itemId, qty, qty]);-- // Auto-unlock hats as global cosmetics so they show as chat badges-- const item = ITEMS[itemId];-- if (item?.category === 'hats') {-- try { cosmeticsSystem.unlockCosmetic(userId, itemId); } catch {}-- }--}----function removeItem(userId, itemId, qty = 1) {-- const cur = getItemCount(userId, itemId);-- if (cur < qty) return false;-- if (cur === qty) db.run('DELETE FROM game_inventory WHERE user_id = ? AND item_id = ?', [userId, itemId]);-- else db.run('UPDATE game_inventory SET quantity = quantity - ? WHERE user_id = ? AND item_id = ?', [qty, userId, itemId]);-- return true;--}----function hasItem(userId, itemId, qty = 1) { return getItemCount(userId, itemId) >= qty; }----// ══════════════════════════════════════════════════════════════--// BANK--// ══════════════════════════════════════════════════════════════----function getBank(userId) {-- return db.all('SELECT item_id, quantity FROM game_bank WHERE user_id = ? AND quantity > 0', [userId])-- .map(r => ({ ...r, ...(ITEMS[r.item_id] || { name: r.item_id, emoji: '❓', rarity: 'Common' }) }));--}----function bankDeposit(userId, itemId, qty = 1) {-- if (!removeItem(userId, itemId, qty)) return false;-- db.run(`INSERT INTO game_bank (user_id, item_id, quantity) VALUES (?, ?, ?)-- ON CONFLICT(user_id, item_id) DO UPDATE SET quantity = quantity + ?`, [userId, itemId, qty, qty]);-- return true;--}----function bankWithdraw(userId, itemId, qty = 1) {-- const r = db.get('SELECT quantity FROM game_bank WHERE user_id = ? AND item_id = ?', [userId, itemId]);-- if (!r || r.quantity < qty) return false;-- if (r.quantity === qty) db.run('DELETE FROM game_bank WHERE user_id = ? AND item_id = ?', [userId, itemId]);-- else db.run('UPDATE game_bank SET quantity = quantity - ? WHERE user_id = ? AND item_id = ?', [qty, userId, itemId]);-- addItem(userId, itemId, qty);-- return true;--}----// ══════════════════════════════════════════════════════════════--// SHOP--// ══════════════════════════════════════════════════════════════----function getShopItems() {-- return Object.entries(ITEMS).filter(([_, i]) => i.buyCost !== null).map(([id, i]) => ({ id, ...i }));--}----function buyItem(userId, itemId, qty = 1) {-- const item = ITEMS[itemId];-- if (!item || item.buyCost === null) return { error: 'Not for sale' };-- const cost = item.buyCost * qty;-- const user = db.getUserById(userId);-- if (!user || user.openvibe_coins_balance < cost) return { error: 'Not enough gold' };---- // Check level requirement before buying equipment-- const slot = EQUIPMENT_SLOTS[item.category];-- if (slot && item.levelReq) {-- const skillName = EQUIP_SKILL_MAP[item.category];-- if (skillName) {-- const p = getPlayer(userId);-- const playerLevel = xpToLevel(p[`${skillName}_xp`]);-- if (playerLevel < item.levelReq) {-- return { error: `Need ${skillName.charAt(0).toUpperCase() + skillName.slice(1)} level ${item.levelReq} to use this! (You're level ${playerLevel})` };-- }-- }-- }---- if (!db.deductOpenCoins(userId, cost)) return { error: 'Not enough gold' };-- addItem(userId, itemId, qty);---- // Auto-equip tools/equipment after buying (if it's equippable and better or slot is empty)-- let autoEquipped = false;-- if (slot && qty === 1) {-- const p = getPlayer(userId);-- const currentEquip = p[slot];-- // Auto-equip if the slot is empty or upgrading to a better item-- if (!currentEquip || isBetterEquipment(currentEquip, itemId, item.category)) {-- db.run(`UPDATE game_players SET ${slot} = ? WHERE user_id = ?`, [itemId, userId]);-- recalcCombatStats(userId);-- autoEquipped = true;-- }-- }---- return { success: true, item: { id: itemId, ...item }, qty, cost, autoEquipped, slot: autoEquipped ? item.category : null };--}----function sellItem(userId, itemId, qty = 1) {-- const item = ITEMS[itemId];-- if (!item || item.sellPrice === null) return { error: 'Cannot sell' };-- if (!removeItem(userId, itemId, qty)) return { error: 'Not enough items' };-- const earned = item.sellPrice * qty;-- db.addOpenCoins(userId, earned);-- return { success: true, item: { id: itemId, ...item }, qty, earned };--}----function buyWithCoins(userId, itemId) {-- const item = ITEMS[itemId];-- if (!item || !item.coinPrice) return { error: 'Not for coin sale' };-- const user = db.getUserById(userId);-- if (!user || user.openvibe_coins_balance < item.coinPrice) return { error: 'Not enough OpenCoins' };-- if (!db.deductOpenCoins(userId, item.coinPrice)) return { error: 'Not enough OpenCoins' };-- addItem(userId, itemId, 1);-- return { success: true, item: { id: itemId, ...item }, cost: item.coinPrice };--}----function getCoinShopItems() {-- return Object.entries(ITEMS).filter(([_, i]) => i.coinPrice).map(([id, i]) => ({ id, ...i }));--}----// ══════════════════════════════════════════════════════════════--// GATHERING (open-world resource nodes)--// ══════════════════════════════════════════════════════════════----function gather(userId, tileX, tileY) {-- const player = getPlayer(userId);-- regenStamina(userId);-- const sta = db.get('SELECT stamina FROM game_players WHERE user_id = ?', [userId]).stamina;---- // Proximity check (player tile vs target tile)-- const px = Math.floor(player.x / TILE), py = Math.floor(player.y / TILE);-- if (Math.abs(px - tileX) > 2 || Math.abs(py - tileY) > 2) return { error: 'Too far away!' };---- const node = getResourceNodeAt(tileX, tileY, worldSeed);-- if (!node) return { error: 'Nothing to gather here.' };-- if (isNodeDepleted(tileX, tileY)) return { error: 'Depleted. Come back soon.' };---- let skill, action;-- if (node.type === 'tree') { skill = 'woodcut'; action = 'chop'; }-- else if (node.type === 'rock') { skill = 'mining'; action = 'mine'; }-- else { return { error: 'Cannot gather this.' }; }---- // ── FIST PUNCHING (no tool) ──-- // Without proper tools, you can only punch stone rocks or basic trees for raw materials-- const equippedPick = player.equip_pickaxe;-- const equippedAxe = player.equip_axe;---- if (node.type === 'rock') {-- // Require pickaxe equipped to mine any rock-- if (!equippedPick) return { error: 'You need a pickaxe equipped to mine! ⛏️' };---- const oreType = node.ore || 'stone';-- const oreInfo = ORE_NODE_TYPES[oreType];-- const pickTier = equippedPick ? (PICK_TIERS[equippedPick]?.tier ?? -1) : -1;---- // Check mining level requirement-- if (oreInfo && oreInfo.minLevel && player.mining_level < oreInfo.minLevel) {-- return { error: `Need Mining level ${oreInfo.minLevel} to mine ${oreInfo.name}! (You're level ${player.mining_level})` };-- }---- if (oreInfo && pickTier < oreInfo.minPickTier) {-- const needed = Object.entries(PICK_TIERS).find(([_, v]) => v.tier >= oreInfo.minPickTier);-- return { error: `Need ${needed ? ITEMS[needed[0]]?.name || needed[1].name : 'a better'} pickaxe to mine ${oreInfo.name}!` };-- }-- }---- if (node.type === 'tree') {-- if (!equippedAxe) {-- // Fist punching trees: gives raw_stick, more stamina-- if (sta < 10) return { error: 'Not enough stamina!' };-- db.run('UPDATE game_players SET stamina = stamina - 10 WHERE user_id = ?', [userId]);-- addItem(userId, 'raw_stick', 1);-- depleteNode(tileX, tileY, 12000);-- const xpResult = addXp(userId, 'woodcut', 2);-- db.run('UPDATE game_players SET resources_gathered = resources_gathered + 1 WHERE user_id = ?', [userId]);-- incrementDailyQuestStat(userId, 'gather_count', 1);-- return {-- success: true, action: 'punch',-- loot: { id: 'raw_stick', ...(ITEMS['raw_stick'] || {}) },-- bonus: false, xp: xpResult, toolBroke: false,-- stamina: sta - 10, depletedUntil: Date.now() + 12000,-- tileX, tileY,-- };-- }-- }---- // ── NORMAL GATHERING (with tools) ──-- const staCost = node.type === 'rock' ? 8 : node.type === 'tree' ? 6 : 5;-- if (sta < staCost) return { error: 'Not enough stamina!' };---- db.run('UPDATE game_players SET stamina = stamina - ? WHERE user_id = ?', [staCost, userId]);---- const tier = getDifficultyTier(tileX, tileY);---- let lootId, item;-- if (node.type === 'rock') {-- // Ore-specific loot: pick from the appropriate ore table based on node subtype-- const oreType = node.ore || 'stone';-- const oreLootMap = {-- stone: ['ore_gravel', 'ore_coal', 'raw_stone'],-- tin: ['ore_tin'],-- copper: ['ore_copper'],-- coal: ['ore_coal'],-- iron: ['ore_iron'],-- gold: ['ore_gold'],-- mithril: ['ore_mithril'],-- titanium: ['ore_titanium'],-- platinum: ['ore_platinum'],-- dragonite:['ore_dragonite'],-- };-- const primaryOres = oreLootMap[oreType] || ['ore_gravel'];-- // Primary ore (70%), or tier-based bonus from MINE_TABLES (30%)-- if (Math.random() < 0.70 || !MINE_TABLES[Math.min(tier, 3)]) {-- lootId = primaryOres[Math.floor(Math.random() * primaryOres.length)];-- } else {-- lootId = rollLoot(MINE_TABLES[Math.min(tier, 3)]);-- }-- item = ITEMS[lootId];-- } else {-- const tables = { tree: WOOD_TABLES };-- const table = tables[node.type]?.[Math.min(tier, 3)] || tables[node.type]?.[0];-- if (!table) return { error: 'Cannot gather here.' };-- lootId = rollLoot(table);-- item = ITEMS[lootId];-- }---- addItem(userId, lootId, 1);---- // Bonus drop (12% chance for rocks)-- let bonus = false;-- if (node.type === 'rock' && Math.random() < 0.12) { addItem(userId, lootId, 1); bonus = true; }---- const baseXp = (item?.sellPrice || 5) + 5 + tier * 3;-- const xpResult = addXp(userId, skill, baseXp);---- // Tool break (3%) — only for non-starter tools-- let toolBroke = false;-- const toolSlots = { woodcut: 'equip_axe', mining: 'equip_pickaxe', fishing: 'equip_rod' };-- const slot = toolSlots[skill];-- if (slot && player[slot] && Math.random() < 0.03) {-- toolBroke = player[slot];-- db.run(`UPDATE game_players SET ${slot} = NULL WHERE user_id = ?`, [userId]);-- }---- const cooldown = node.type === 'tree' ? 25000 : node.type === 'rock' ? 40000 : 18000;-- depleteNode(tileX, tileY, cooldown);---- db.run('UPDATE game_players SET resources_gathered = resources_gathered + 1 WHERE user_id = ?', [userId]);-- incrementDailyQuestStat(userId, 'gather_count', 1);---- // Achievement checks-- const totalGathered = (db.get('SELECT resources_gathered FROM game_players WHERE user_id = ?', [userId])?.resources_gathered || 0);-- checkAchievement(userId, 'first_gather', totalGathered >= 1);-- checkAchievement(userId, 'gather_100', totalGathered >= 100);-- checkAchievement(userId, 'gather_500', totalGathered >= 500);-- if (lootId === 'ore_dragonite') checkAchievement(userId, 'dragonite_found', true);-- if (lootId === 'wood_crystal') checkAchievement(userId, 'crystal_wood', true);---- return {-- success: true, action,-- loot: { id: lootId, ...(item || {}) },-- bonus, xp: xpResult, toolBroke,-- stamina: sta - staCost,-- depletedUntil: Date.now() + cooldown,-- tileX, tileY,-- oreType: node.ore || undefined,-- };--}----// ══════════════════════════════════════════════════════════════--// FISHING (Toontown-style — cast targeting + reel mini-game)--// ══════════════════════════════════════════════════════════════----function fish(userId, tileX, tileY, reelScore) {-- const player = getPlayer(userId);-- regenStamina(userId);-- const sta = db.get('SELECT stamina FROM game_players WHERE user_id = ?', [userId]).stamina;---- // Proximity check-- const px = Math.floor(player.x / TILE), py = Math.floor(player.y / TILE);-- if (Math.abs(px - tileX) > 2 || Math.abs(py - tileY) > 2) return { error: 'Too far away!' };---- // Must be a water tile-- const biome = getBiomeAt(tileX, tileY, worldSeed);-- if (biome !== 'water') return { error: 'You need to cast into water!' };---- // Must have a fishing rod equipped-- if (!player.equip_rod) return { error: 'Equip a fishing rod first! (Buy one from the Tool Merchant)' };---- // Rod tier info-- const rodInfo = ROD_TIERS[player.equip_rod] || ROD_TIERS.rod_bamboo;-- const rodTier = rodInfo.tier;-- const staCost = rodInfo.staminaCost;-- if (sta < staCost) return { error: `Not enough stamina! (Need ${staCost})` };-- db.run('UPDATE game_players SET stamina = stamina - ? WHERE user_id = ?', [staCost, userId]);---- // Validate reel score (0 = miss, 1-3 = catch quality)-- const reel = Math.max(0, Math.min(3, Math.floor(Number(reelScore) || 0)));---- // Fish escaped (reel score 0 — missed all timing windows)-- if (reel === 0) {-- return {-- success: true, action: 'fish', zone: getWaterZone(tileX, tileY, worldSeed),-- escaped: true, message: 'The fish got away!',-- stamina: sta - staCost, tileX, tileY,-- };-- }---- // Determine water zone-- const zone = getWaterZone(tileX, tileY, worldSeed);---- // Build loot table filtered by rod tier + zone-- const table = buildFishTable(zone, rodTier);-- if (!table.length) return { error: 'No fish here!' };---- // Roll loot-- const lootId = rollLoot(table);-- const item = ITEMS[lootId];-- const spec = FISH_SPECIES[lootId];---- // Calculate weight (only for actual fish, not junk)-- let weight = 0;-- if (spec) {-- // Weight based on reel score: 1=low range, 2=mid range, 3=high range-- const range = spec.maxW - spec.minW;-- const reelFactor = (reel - 1) / 2; // 0, 0.5, 1-- const randomFactor = Math.random() * 0.3; // 0-30% extra variance-- weight = Math.round((spec.minW + range * (reelFactor * 0.7 + randomFactor)) * 10) / 10;-- weight = Math.max(spec.minW, Math.min(spec.maxW, weight));-- }---- addItem(userId, lootId, 1);---- // XP scales with fish value + weight bonus-- const baseXp = (item?.sellPrice || 5) + Math.floor(weight * 2);-- const xpResult = addXp(userId, 'fishing', baseXp);-- db.run('UPDATE game_players SET resources_gathered = resources_gathered + 1 WHERE user_id = ?', [userId]);-- incrementDailyQuestStat(userId, 'fish_count', 1);---- // Record in fish collection (only real fish, not junk)-- let newSpecies = false;-- let collectionCount = 0;-- let milestone = null;-- if (spec) {-- const caught = recordFishCatch(userId, lootId, weight);-- newSpecies = caught.newSpecies;-- collectionCount = caught.collectionCount;-- // Milestones at 5, 10, 15, 20, 25, 30-- const milestoneThresholds = [5, 10, 15, 20, 25, 30];-- for (const threshold of milestoneThresholds) {-- if (collectionCount === threshold) {-- // Bonus XP + max HP boost-- const bonusXp = threshold * 10;-- addXp(userId, 'fishing', bonusXp);-- db.run('UPDATE game_players SET max_hp = max_hp + 5, hp = MIN(hp + 5, max_hp + 5) WHERE user_id = ?', [userId]);-- milestone = { threshold, bonusXp, hpBoost: 5, total: collectionCount };-- break;-- }-- }-- // Fish achievements-- checkAchievement(userId, 'first_catch', true);-- checkAchievement(userId, 'fish_species_10', collectionCount >= 10);-- checkAchievement(userId, 'fish_species_20', collectionCount >= 20);-- checkAchievement(userId, 'fish_species_all', collectionCount >= Object.keys(FISH_SPECIES).length);-- if (spec.family === 'Legendary') checkAchievement(userId, 'legendary_catch', true);-- }---- // Rod break (2% — bamboo is unbreakable)-- let toolBroke = false;-- if (Math.random() < 0.02 && player.equip_rod !== 'rod_bamboo') {-- toolBroke = player.equip_rod;-- db.run('UPDATE game_players SET equip_rod = ? WHERE user_id = ?', ['rod_bamboo', userId]);-- }---- // Sell price bonus from weight-- const sellValue = (item?.sellPrice || 1) + Math.floor(weight * 2);---- return {-- success: true, action: 'fish', zone,-- loot: { id: lootId, ...(item || {}), weight, sellValue },-- reelScore: reel, newSpecies, collectionCount, milestone,-- xp: xpResult, toolBroke,-- stamina: sta - staCost, tileX, tileY,-- };--}----// Record a fish catch in the collection album--function recordFishCatch(userId, fishId, weight) {-- const existing = db.get(-- 'SELECT * FROM game_fish_collection WHERE user_id = ? AND fish_id = ?',-- [userId, fishId]-- );-- if (existing) {-- const newMax = Math.max(existing.max_weight, weight);-- db.run(-- 'UPDATE game_fish_collection SET times_caught = times_caught + 1, max_weight = ? WHERE user_id = ? AND fish_id = ?',-- [newMax, userId, fishId]-- );-- } else {-- db.run(-- 'INSERT INTO game_fish_collection (user_id, fish_id, times_caught, max_weight) VALUES (?, ?, 1, ?)',-- [userId, fishId, weight]-- );-- }-- const countRow = db.get('SELECT COUNT(*) as cnt FROM game_fish_collection WHERE user_id = ?', [userId]);-- return { newSpecies: !existing, collectionCount: countRow.cnt };--}----// Get a player's full fish collection for the album UI--function getFishCollection(userId) {-- const rows = db.all(-- 'SELECT fish_id, times_caught, max_weight, first_caught FROM game_fish_collection WHERE user_id = ? ORDER BY first_caught ASC',-- [userId]-- );-- const totalSpecies = Object.keys(FISH_SPECIES).length;-- return {-- collected: rows,-- totalSpecies,-- progress: rows.length,-- };--}----function useSonar(userId, tileX, tileY) {-- const player = getPlayer(userId);-- const px = Math.floor(player.x / TILE), py = Math.floor(player.y / TILE);-- if (Math.abs(px - tileX) > 3 || Math.abs(py - tileY) > 3) return { error: 'Too far away!' };---- if (!hasItem(userId, 'fish_sonar', 1)) return { error: 'You need a Fish Sonar! Craft one: 2x Copper Ore + 1x Ruby.' };-- if (!removeItem(userId, 'fish_sonar', 1)) return { error: 'No sonar in inventory!' };---- // Scan nearby water tiles for zones-- const zones = {};-- for (let dy = -4; dy <= 4; dy++) {-- for (let dx = -4; dx <= 4; dx++) {-- const cx = tileX + dx, cy = tileY + dy;-- const b = getBiomeAt(cx, cy, worldSeed);-- if (b === 'water') {-- const z = getWaterZone(cx, cy, worldSeed);-- if (!zones[z]) {-- const table = WATER_ZONE_TABLES[z] || WATER_ZONE_TABLES.shallow;-- zones[z] = {-- zone: z,-- fish: table.map(f => ({ id: f.id, name: ITEMS[f.id]?.name || f.id, emoji: ITEMS[f.id]?.emoji || '🐟', rarity: ITEMS[f.id]?.rarity || 'Common', weight: f.weight })),-- };-- }-- }-- }-- }-- return { success: true, zones: Object.values(zones) };--}----// ══════════════════════════════════════════════════════════════--// BUILDING--// ══════════════════════════════════════════════════════════════----function placeStructure(userId, structureType, tileX, tileY) {-- const struct = STRUCTURES[structureType];-- if (!struct) return { error: 'Unknown structure.' };---- const biome = getBiomeAt(tileX, tileY, worldSeed);-- if (biome === 'water') return { error: 'Cannot build on water.' };-- if (isInSafeZone(tileX, tileY)) return { error: 'Cannot build in Town.' };---- const existing = db.get('SELECT id FROM game_structures WHERE tile_x = ? AND tile_y = ?', [tileX, tileY]);-- if (existing) return { error: 'Tile occupied.' };---- const node = getResourceNodeAt(tileX, tileY, worldSeed);-- if (node && !isNodeDepleted(tileX, tileY)) return { error: 'Clear the resource first.' };---- // Check proximity-- const player = getPlayer(userId);-- const px = Math.floor(player.x / TILE), py = Math.floor(player.y / TILE);-- if (Math.abs(px - tileX) > 3 || Math.abs(py - tileY) > 3) return { error: 'Too far away!' };---- // Check materials-- for (const [itemId, qty] of Object.entries(struct.cost)) {-- if (!hasItem(userId, itemId, qty)) {-- const item = ITEMS[itemId] || { name: itemId };-- return { error: `Need ${qty}x ${item.name}` };-- }-- }-- for (const [itemId, qty] of Object.entries(struct.cost)) removeItem(userId, itemId, qty);---- db.run(`INSERT INTO game_structures (owner_id, type, tile_x, tile_y, hp, max_hp) VALUES (?, ?, ?, ?, ?, ?)`,-- [userId, structureType, tileX, tileY, struct.hp, struct.hp]);---- if (structureType === 'sleeping_bag') {-- db.run('UPDATE game_players SET sleeping_bag_x = ?, sleeping_bag_y = ? WHERE user_id = ?',-- [tileX * TILE + TILE / 2, tileY * TILE + TILE / 2, userId]);-- }---- db.run('UPDATE game_players SET structures_built = structures_built + 1 WHERE user_id = ?', [userId]);-- incrementDailyQuestStat(userId, 'structures_built', 1);-- const xpResult = addXp(userId, 'crafting', 15);---- // Achievement check-- const totalBuilt = db.get('SELECT structures_built FROM game_players WHERE user_id = ?', [userId])?.structures_built || 0;-- checkAchievement(userId, 'build_10', totalBuilt >= 10);---- const id = db.get('SELECT last_insert_rowid() as id').id;-- return {-- success: true,-- structure: { id, type: structureType, ...struct, tile_x: tileX, tile_y: tileY, owner_id: userId, hp: struct.hp, max_hp: struct.hp },-- xp: xpResult,-- };--}----function destroyStructure(userId, tileX, tileY) {-- const s = db.get('SELECT * FROM game_structures WHERE tile_x = ? AND tile_y = ?', [tileX, tileY]);-- if (!s) return { error: 'No structure here.' };-- if (s.owner_id !== userId) return { error: 'Not your structure.' };-- db.run('DELETE FROM game_structures WHERE id = ?', [s.id]);-- // Refund 50% materials-- const struct = STRUCTURES[s.type];-- if (struct) {-- for (const [itemId, qty] of Object.entries(struct.cost)) {-- addItem(userId, itemId, Math.ceil(qty / 2));-- }-- }-- return { success: true, tileX, tileY };--}----function getStructuresInArea(minX, minY, maxX, maxY) {-- return db.all('SELECT * FROM game_structures WHERE tile_x >= ? AND tile_x <= ? AND tile_y >= ? AND tile_y <= ?',-- [minX, maxX, minY, maxY]);--}----function getAllStructures() {-- return db.all('SELECT * FROM game_structures');--}----// Storage box operations--function storageDeposit(userId, structureId, itemId, qty) {-- const s = db.get('SELECT * FROM game_structures WHERE id = ? AND type = ?', [structureId, 'storage_box']);-- if (!s) return { error: 'Not a storage box.' };-- if (s.owner_id !== userId) return { error: 'Not yours.' };-- if (!removeItem(userId, itemId, qty)) return { error: 'Not enough items.' };-- const data = JSON.parse(s.data || '{}');-- if (!data.items) data.items = [];-- const ex = data.items.find(i => i.item_id === itemId);-- if (ex) ex.quantity += qty; else data.items.push({ item_id: itemId, quantity: qty });-- db.run('UPDATE game_structures SET data = ? WHERE id = ?', [JSON.stringify(data), structureId]);-- return { success: true, storage: data.items };--}----function storageWithdraw(userId, structureId, itemId, qty) {-- const s = db.get('SELECT * FROM game_structures WHERE id = ? AND type = ?', [structureId, 'storage_box']);-- if (!s) return { error: 'Not a storage box.' };-- if (s.owner_id !== userId) return { error: 'Not yours.' };-- const data = JSON.parse(s.data || '{}');-- const ex = (data.items || []).find(i => i.item_id === itemId);-- if (!ex || ex.quantity < qty) return { error: 'Not enough in storage.' };-- ex.quantity -= qty;-- if (ex.quantity <= 0) data.items = data.items.filter(i => i.item_id !== itemId);-- db.run('UPDATE game_structures SET data = ? WHERE id = ?', [JSON.stringify(data), structureId]);-- addItem(userId, itemId, qty);-- return { success: true, storage: data.items };--}----// ══════════════════════════════════════════════════════════════--// FARMING--// ══════════════════════════════════════════════════════════════----function getFarmPlots(userId) {-- const plots = db.all('SELECT * FROM game_farm_plots WHERE user_id = ? ORDER BY plot_index', [userId]);-- if (plots.length < 2) {-- for (let i = plots.length; i < 2; i++)-- db.run('INSERT OR IGNORE INTO game_farm_plots (user_id, plot_index) VALUES (?, ?)', [userId, i]);-- return db.all('SELECT * FROM game_farm_plots WHERE user_id = ? ORDER BY plot_index', [userId]);-- }-- return plots.map(p => {-- if (p.crop_id && !['empty', 'ripe', 'withered'].includes(p.stage)) p.stage = computeGrowthStage(p);-- return p;-- });--}----function computeGrowthStage(plot) {-- if (!plot.planted_at || !plot.crop_id) return 'empty';-- const cd = CROPS[plot.crop_id]; if (!cd) return 'empty';-- let plantedAt; try { plantedAt = new Date(plot.planted_at.replace(' ', 'T') + 'Z').getTime(); } catch { return 'empty'; }-- const elapsed = Date.now() - plantedAt;-- const wBonus = plot.watered_at ? 0.8 : 1.0;-- const t = cd.growTime * wBonus;-- if (elapsed >= t * 1.5) return 'withered';-- if (elapsed >= t) return 'ripe';-- if (elapsed >= t * 0.6) return 'growing';-- if (elapsed >= t * 0.3) return 'sprout';-- return 'seed';--}----function plant(userId, plotIndex, seedId) {-- if (!CROPS[seedId]) return { error: 'Invalid seed.' };-- if (!removeItem(userId, seedId, 1)) return { error: 'No seeds!' };-- const plot = db.get('SELECT * FROM game_farm_plots WHERE user_id = ? AND plot_index = ?', [userId, plotIndex]);-- if (!plot) return { error: 'Invalid plot.' };-- if (plot.stage !== 'empty' && plot.stage !== 'withered') return { error: 'Plot occupied!' };-- db.run(`UPDATE game_farm_plots SET crop_id = ?, planted_at = CURRENT_TIMESTAMP, watered_at = NULL, fertilized = 0, stage = 'seed' WHERE user_id = ? AND plot_index = ?`,-- [seedId, userId, plotIndex]);-- return { success: true, plotIndex, seed: seedId };--}----function water(userId, plotIndex) {-- const p = db.get('SELECT * FROM game_farm_plots WHERE user_id = ? AND plot_index = ?', [userId, plotIndex]);-- if (!p || !p.crop_id) return { error: 'Nothing planted!' };-- if (p.watered_at) return { error: 'Already watered!' };-- db.run('UPDATE game_farm_plots SET watered_at = CURRENT_TIMESTAMP WHERE user_id = ? AND plot_index = ?', [userId, plotIndex]);-- return { success: true, xp: addXp(userId, 'farming', 5) };--}----function harvest(userId, plotIndex) {-- const p = db.get('SELECT * FROM game_farm_plots WHERE user_id = ? AND plot_index = ?', [userId, plotIndex]);-- if (!p || !p.crop_id) return { error: 'Nothing planted!' };-- const stage = computeGrowthStage(p);-- if (stage !== 'ripe') return { error: `Crop is ${stage}.` };-- const cd = CROPS[p.crop_id];-- let qty = cd.yield[0] + Math.floor(Math.random() * (cd.yield[1] - cd.yield[0] + 1));-- if (p.fertilized) qty *= 2;-- addItem(userId, cd.output, qty);-- db.run(`UPDATE game_farm_plots SET crop_id = NULL, planted_at = NULL, watered_at = NULL, fertilized = 0, stage = 'empty' WHERE user_id = ? AND plot_index = ?`,-- [userId, plotIndex]);-- return { success: true, crop: cd.output, qty, xp: addXp(userId, 'farming', 15 + qty * 5) };--}----// ══════════════════════════════════════════════════════════════--// CRAFTING--// ══════════════════════════════════════════════════════════════----function getUnlockedRecipes(userId) {-- const unlocked = db.all('SELECT recipe_id FROM game_recipes WHERE user_id = ?', [userId]);-- const ids = new Set(unlocked.map(r => r.recipe_id));-- for (const [id, r] of Object.entries(RECIPES)) { if (r.rarity === 'Common') ids.add(id); }-- return Array.from(ids).map(id => {-- const recipe = RECIPES[id];-- if (!recipe) return null;-- const outputItem = ITEMS[recipe.output] || {};-- const inputDetails = {};-- for (const [itemId, qty] of Object.entries(recipe.inputs)) {-- const item = ITEMS[itemId] || {};-- inputDetails[itemId] = { name: item.name || itemId, emoji: item.emoji || '❓', qty };-- }-- return { id, ...recipe, outputEmoji: outputItem.emoji || '❓', outputCategory: outputItem.category || 'crafted', inputDetails };-- }).filter(Boolean);--}----function craft(userId, recipeId) {-- const recipe = RECIPES[recipeId];-- if (!recipe) return { error: 'Unknown recipe.' };-- const unlocked = db.get('SELECT id FROM game_recipes WHERE user_id = ? AND recipe_id = ?', [userId, recipeId]);-- if (!unlocked && recipe.rarity !== 'Common') return { error: 'Recipe not unlocked!' };---- // Station requirement check (furnace, workbench, campfire)-- if (recipe.station) {-- if (!isNearStation(userId, recipe.station)) {-- const stationNames = { furnace: 'Furnace 🔥', workbench: 'Workbench 🔨', campfire: 'Campfire 🔥' };-- return { error: `Need to be near a ${stationNames[recipe.station] || recipe.station}!` };-- }-- }---- // Smithing level check-- if (recipe.smithingLevel) {-- const p = getPlayer(userId);-- if (p.smithing_level < recipe.smithingLevel) {-- return { error: `Need Smithing level ${recipe.smithingLevel}! (You're level ${p.smithing_level})` };-- }-- }---- for (const [itemId, qty] of Object.entries(recipe.inputs)) {-- if (!hasItem(userId, itemId, qty)) return { error: `Need ${qty}x ${(ITEMS[itemId] || {}).name || itemId}` };-- }-- for (const [itemId, qty] of Object.entries(recipe.inputs)) removeItem(userId, itemId, qty);-- addItem(userId, recipe.output, recipe.qty);-- db.run('UPDATE game_players SET total_items_crafted = total_items_crafted + 1 WHERE user_id = ?', [userId]);-- incrementDailyQuestStat(userId, 'craft_count', 1);---- // Achievement checks-- const totalCrafted = db.get('SELECT total_items_crafted FROM game_players WHERE user_id = ?', [userId])?.total_items_crafted || 0;-- checkAchievement(userId, 'first_craft', totalCrafted >= 1);-- checkAchievement(userId, 'craft_25', totalCrafted >= 25);-- checkAchievement(userId, 'craft_100', totalCrafted >= 100);-- if (recipe.skill === 'smithing' && recipe.station === 'furnace') checkAchievement(userId, 'smelt_first', true);---- // Smithing recipes award smithing XP, others award crafting XP-- const xpSkill = recipe.skill === 'smithing' ? 'smithing' : 'crafting';-- return { success: true, output: { id: recipe.output, ...ITEMS[recipe.output] }, qty: recipe.qty, xp: addXp(userId, xpSkill, recipe.xp) };--}----function rollRecipeUnlock(userId) {-- const all = Object.entries(RECIPES).filter(([_, r]) => r.rarity !== 'Common');-- const have = new Set(db.all('SELECT recipe_id FROM game_recipes WHERE user_id = ?', [userId]).map(r => r.recipe_id));-- const avail = all.filter(([id]) => !have.has(id));-- if (!avail.length || Math.random() > 0.05) return null;-- const weights = { Uncommon: 40, Rare: 25, Epic: 10, Legendary: 3 };-- const table = avail.map(([id, r]) => ({ id, weight: weights[r.rarity] || 10 }));-- const recipeId = rollLoot(table);-- db.run('INSERT OR IGNORE INTO game_recipes (user_id, recipe_id) VALUES (?, ?)', [userId, recipeId]);-- return { id: recipeId, ...RECIPES[recipeId] };--}----// ══════════════════════════════════════════════════════════════--// COMBAT (Open-world PvP)--// ══════════════════════════════════════════════════════════════----function attackPlayer(attackerId, targetId) {-- const atk = getPlayer(attackerId);-- const def = getPlayer(targetId);-- if (!def) return { error: 'Target not found.' };---- // Weapon stats affect range-- const weaponInfo = getWeaponInfo(atk.equip_weapon);-- const attackRange = TILE * 3 * (weaponInfo.range || 1.0);-- const dist = Math.sqrt((atk.x - def.x) ** 2 + (atk.y - def.y) ** 2);-- if (dist > attackRange) return { error: 'Too far!' };---- const aTile = { x: Math.floor(atk.x / TILE), y: Math.floor(atk.y / TILE) };-- const dTile = { x: Math.floor(def.x / TILE), y: Math.floor(def.y / TILE) };-- if (isInSafeZone(aTile.x, aTile.y) || isInSafeZone(dTile.x, dTile.y)) return { error: 'No PvP in Town!' };---- // Check dodge i-frames-- if (isInvulnerable(targetId)) return { success: true, dodged: true, dmg: 0, targetHp: def.hp, targetMaxHp: def.max_hp, killed: false };---- // Combo system-- registerHit(attackerId);-- const combo = getComboMultiplier(attackerId);---- const isCrit = Math.random() < 0.15;-- let dmg = Math.max(1, Math.round(atk.attack * (0.8 + Math.random() * 0.4) - def.defense * 0.3));-- if (isCrit) dmg = Math.round(dmg * 1.5);-- dmg = Math.round(dmg * combo.mult);---- const newHp = Math.max(0, def.hp - dmg);-- db.run('UPDATE game_players SET hp = ? WHERE user_id = ?', [newHp, targetId]);---- const result = {-- success: true, dmg, isCrit, targetHp: newHp, targetMaxHp: def.max_hp, killed: false,-- combo: combo.count, comboMult: combo.mult,-- weaponSpeed: weaponInfo.speed,-- };---- if (newHp <= 0) {-- result.killed = true;-- result.deathData = handleDeath(targetId, attackerId);-- result.xp = addXp(attackerId, 'combat', 30 + atk.combat_level * 3);-- ensureBattleStats(attackerId);-- ensureBattleStats(targetId);-- db.run('UPDATE game_battle_stats SET battles_won = battles_won + 1, kill_streak = kill_streak + 1, best_streak = MAX(best_streak, kill_streak + 1), kills = kills + 1 WHERE user_id = ?', [attackerId]);-- db.run('UPDATE game_battle_stats SET battles_lost = battles_lost + 1, kill_streak = 0, deaths = deaths + 1 WHERE user_id = ?', [targetId]);-- comboTrackers.delete(attackerId);-- // PvP achievements-- checkAchievement(attackerId, 'pvp_first', true);-- const streak = db.get('SELECT kill_streak FROM game_battle_stats WHERE user_id = ?', [attackerId])?.kill_streak || 0;-- checkAchievement(attackerId, 'kill_streak_5', streak >= 5);-- }-- // Combo achievement-- if (combo.count >= 5) checkAchievement(attackerId, 'combo_master', true);-- return result;--}----function handleDeath(userId, killerId) {-- const inv = getInventory(userId);-- const dropped = [];-- const p = getPlayer(userId);-- const deathX = p.x, deathY = p.y;---- for (const item of inv) {-- if (item.item_id.startsWith('fx_') || item.item_id.startsWith('px_')) continue; // Keep cosmetics-- const dropQty = Math.floor(item.quantity / 2);-- if (dropQty > 0) {-- removeItem(userId, item.item_id, dropQty);-- // Scatter items as ground drops around the death position-- const angle = Math.random() * Math.PI * 2;-- const dist = 20 + Math.random() * 60;-- const gx = deathX + Math.cos(angle) * dist;-- const gy = deathY + Math.sin(angle) * dist;-- const gid = dropGroundItem(item.item_id, dropQty, gx, gy, userId);-- dropped.push({-- groundId: gid, id: item.item_id, qty: dropQty,-- name: item.name, emoji: item.emoji,-- x: gx, y: gy,-- });-- }-- }-- const spawn = db.get('SELECT sleeping_bag_x, sleeping_bag_y FROM game_players WHERE user_id = ?', [userId]);-- let sx = OUTPOST_X * TILE + TILE / 2, sy = OUTPOST_Y * TILE + TILE / 2;-- if (spawn?.sleeping_bag_x && spawn?.sleeping_bag_y) { sx = spawn.sleeping_bag_x; sy = spawn.sleeping_bag_y; }-- db.run('UPDATE game_players SET hp = max_hp, stamina = max_stamina, x = ?, y = ?, total_deaths = total_deaths + 1 WHERE user_id = ?', [sx, sy, userId]);-- addXp(userId, 'combat', 5);-- // Achievement: die 10 times-- const deathP = getPlayer(userId);-- if (deathP) checkAchievement(userId, 'die_10', deathP.total_deaths >= 10);-- return { dropped, spawnX: sx, spawnY: sy, deathX, deathY };--}----function ensureBattleStats(userId) { db.run('INSERT OR IGNORE INTO game_battle_stats (user_id) VALUES (?)', [userId]); }----function getBattleStats(userId) {-- ensureBattleStats(userId);-- return db.get('SELECT * FROM game_battle_stats WHERE user_id = ?', [userId]);--}----// ══════════════════════════════════════════════════════════════--// DUNGEON (PvE)--// ══════════════════════════════════════════════════════════════----function enterDungeon(userId) {-- const p = getPlayer(userId);-- if (p.combat_level < 5) return { error: 'Need Combat Lv 5.' };-- if (!removeItem(userId, 'dungeon_key', 1)) return { error: 'Need a Dungeon Key!' };-- const maxIdx = Math.min(MONSTERS.length - 1, Math.floor(p.combat_level / 5));-- const monster = { ...MONSTERS[Math.floor(Math.random() * (maxIdx + 1))] };-- const scale = 1 + (p.combat_level - 1) * 0.05;-- monster.hp = Math.round(monster.hp * scale);-- monster.atk = Math.round(monster.atk * scale);-- // Achievement: enter a dungeon-- checkAchievement(userId, 'dungeon_enter', true);-- return { success: true, monster, playerHp: p.hp, playerAtk: p.attack, playerDef: p.defense };--}----function fightMonster(userId, monster) {-- const p = getPlayer(userId);-- let playerHp = p.hp, monsterHp = monster.hp;-- const log = [];-- let round = 0;-- while (playerHp > 0 && monsterHp > 0 && round < 20) {-- round++;-- const pDmg = Math.max(1, Math.round(p.attack * (0.8 + Math.random() * 0.4) - (monster.def || 0) * 0.3));-- monsterHp -= pDmg;-- log.push({ type: 'player_attack', dmg: pDmg, monsterHp: Math.max(0, monsterHp) });-- if (monsterHp <= 0) break;-- const mDmg = Math.max(1, Math.round(monster.atk * (0.8 + Math.random() * 0.4) - p.defense * 0.3));-- playerHp -= mDmg;-- log.push({ type: 'monster_attack', dmg: mDmg, playerHp: Math.max(0, playerHp) });-- }-- db.run('UPDATE game_players SET hp = ? WHERE user_id = ?', [Math.max(0, playerHp), userId]);-- const won = monsterHp <= 0;-- if (won) {-- const xp = addXp(userId, 'combat', monster.xp);-- db.addOpenCoins(userId, monster.gold);-- db.run('UPDATE game_players SET total_monsters_killed = total_monsters_killed + 1, total_dungeon_wins = total_dungeon_wins + 1 WHERE user_id = ?', [userId]);-- let lootDrop = null;-- if (monster.loot?.length) {-- const lootId = rollLoot(monster.loot.map(l => ({ id: l.id, weight: l.w })));-- addItem(userId, lootId, 1);-- lootDrop = { id: lootId, ...ITEMS[lootId] };-- }-- // Achievement: dungeon wins-- const dwP = getPlayer(userId);-- if (dwP) checkAchievement(userId, 'dungeon_wins_10', dwP.total_dungeon_wins >= 10);-- return { success: true, won: true, log, xp, gold: monster.gold, loot: lootDrop };-- }-- db.run('UPDATE game_players SET total_deaths = total_deaths + 1, hp = max_hp WHERE user_id = ?', [userId]);-- addXp(userId, 'combat', Math.round(monster.xp * 0.2));-- return { success: true, won: false, log };--}----// ══════════════════════════════════════════════════════════════--// EQUIPMENT--// ══════════════════════════════════════════════════════════════----const EQUIPMENT_SLOTS = { rods: 'equip_rod', pickaxes: 'equip_pickaxe', axes: 'equip_axe', hats: 'equip_hat', weapons: 'equip_weapon', armor: 'equip_armor' };----/** Check if newItemId is a better equipment than currentItemId in the given category */--function isBetterEquipment(currentItemId, newItemId, category) {-- if (!currentItemId) return true;-- const curItem = ITEMS[currentItemId];-- const newItem = ITEMS[newItemId];-- if (!curItem || !newItem) return false;-- // For tools (pickaxes/axes/rods), compare by buyCost as tier proxy-- if (['pickaxes', 'axes', 'rods'].includes(category)) {-- const tierMaps = { pickaxes: PICK_TIERS, axes: AXE_TIERS };-- const tierMap = tierMaps[category];-- if (tierMap) {-- const curTier = tierMap[currentItemId]?.tier ?? -1;-- const newTier = tierMap[newItemId]?.tier ?? -1;-- return newTier > curTier;-- }-- return (newItem.buyCost || 0) > (curItem.buyCost || 0);-- }-- // For weapons/armor, compare by stats-- if (category === 'weapons') return (WEAPON_STATS[newItemId]?.atk || 0) > (WEAPON_STATS[currentItemId]?.atk || 0);-- if (category === 'armor') return (ARMOR_STATS[newItemId]?.def || 0) > (ARMOR_STATS[currentItemId]?.def || 0);-- return false;--}----function equipItem(userId, itemId) {-- const item = ITEMS[itemId];-- if (!item) return { error: 'Unknown item.' };-- if (!hasItem(userId, itemId, 1)) return { error: 'You don\'t have that.' };-- const slot = EQUIPMENT_SLOTS[item.category];-- if (!slot) return { error: 'Can\'t equip that.' };---- // Level requirement check (RuneScape-style)-- const skillName = EQUIP_SKILL_MAP[item.category];-- if (skillName && item.levelReq) {-- const p = getPlayer(userId);-- const playerLevel = xpToLevel(p[`${skillName}_xp`]);-- if (playerLevel < item.levelReq) {-- return { error: `Need ${skillName.charAt(0).toUpperCase() + skillName.slice(1)} level ${item.levelReq} to equip! (You're level ${playerLevel})` };-- }-- }---- db.run(`UPDATE game_players SET ${slot} = ? WHERE user_id = ?`, [itemId, userId]);-- recalcCombatStats(userId);-- // Achievement: first hat-- if (item.category === 'hats') checkAchievement(userId, 'first_hat', true);-- return { success: true, slot: item.category, item: { id: itemId, ...item } };--}----function unequipItem(userId, slotCategory) {-- const SLOT_MAP = { weapons: 'equip_weapon', armor: 'equip_armor', hats: 'equip_hat', pickaxes: 'equip_pickaxe', axes: 'equip_axe', rods: 'equip_rod' };-- const col = SLOT_MAP[slotCategory];-- if (!col) return { error: 'Invalid slot.' };-- const p = getPlayer(userId);-- if (!p[col]) return { error: 'Nothing equipped there.' };-- const removedId = p[col];-- db.run(`UPDATE game_players SET ${col} = NULL WHERE user_id = ?`, [userId]);-- recalcCombatStats(userId);-- const item = ITEMS[removedId];-- return { success: true, slot: slotCategory, item: item ? { id: removedId, ...item } : { id: removedId } };--}----function recalcCombatStats(userId) {-- const p = db.get('SELECT equip_weapon, equip_armor FROM game_players WHERE user_id = ?', [userId]);-- let atk = 10 + (WEAPON_STATS[p.equip_weapon]?.atk || 0);-- let def = 5 + (ARMOR_STATS[p.equip_armor]?.def || 0);-- // Apply food buffs-- const buff = getActiveFoodBuff(userId);-- if (buff) { atk += (buff.atk || 0); def += (buff.def || 0); }-- db.run('UPDATE game_players SET attack = ?, defense = ? WHERE user_id = ?', [atk, def, userId]);--}----// ══════════════════════════════════════════════════════════════--// DODGE ROLL--// ══════════════════════════════════════════════════════════════----function dodgeRoll(userId, dx, dy) {-- const p = getPlayer(userId);-- const sta = db.get('SELECT stamina FROM game_players WHERE user_id = ?', [userId])?.stamina || 0;-- // Agility reduces dodge cost (base 20, min 10)-- const bonuses = getAgilityBonuses(p.agility_level);-- const dodgeCost = Math.max(10, 20 - bonuses.dodgeCostReduct);-- if (sta < dodgeCost) return { error: `Not enough stamina to dodge! (Need ${dodgeCost})` };-- // Check cooldown (1.5s)-- const existing = dodgeIframes.get(userId);-- if (existing && Date.now() < existing + 1000) return { error: 'Dodge on cooldown!' };-- db.run('UPDATE game_players SET stamina = stamina - ? WHERE user_id = ?', [dodgeCost, userId]);-- // Grant 400ms invulnerability-- dodgeIframes.set(userId, Date.now() + 400);-- // Calculate dodge destination (3 tiles + agility bonus)-- const bonusDist = Math.floor(p.agility_level * 0.05 * TILE);-- const dist = TILE * 3 + bonusDist;-- const len = Math.sqrt(dx * dx + dy * dy) || 1;-- const newX = Math.max(TILE, Math.min((MAP_W - 1) * TILE, p.x + (dx / len) * dist));-- const newY = Math.max(TILE, Math.min((MAP_H - 1) * TILE, p.y + (dy / len) * dist));-- updatePlayerPosition(userId, newX, newY);-- // Grant agility XP for dodging-- const agilXp = addXp(userId, 'agility', 15);-- return { success: true, stamina: sta - dodgeCost, x: newX, y: newY, agilityXp: agilXp };--}----function isInvulnerable(userId) {-- const t = dodgeIframes.get(userId);-- if (!t) return false;-- if (Date.now() < t) return true;-- dodgeIframes.delete(userId);-- return false;--}----// ══════════════════════════════════════════════════════════════--// COMBO SYSTEM--// ══════════════════════════════════════════════════════════════----function getComboMultiplier(userId) {-- const c = comboTrackers.get(userId);-- if (!c) return { mult: 1.0, count: 0 };-- if (Date.now() - c.lastHitTime > COMBO_WINDOW) {-- comboTrackers.delete(userId);-- return { mult: 1.0, count: 0 };-- }-- const idx = Math.min(c.count, COMBO_MULTIPLIERS.length - 1);-- return { mult: COMBO_MULTIPLIERS[idx], count: c.count };--}----function registerHit(userId) {-- const c = comboTrackers.get(userId);-- const now = Date.now();-- if (c && now - c.lastHitTime < COMBO_WINDOW) {-- c.count++;-- c.lastHitTime = now;-- } else {-- comboTrackers.set(userId, { count: 1, lastHitTime: now });-- }--}----// ══════════════════════════════════════════════════════════════--// SPRINT STAMINA--// ══════════════════════════════════════════════════════════════----function drainSprintStamina(userId) {-- // Called every ~500ms while sprinting-- sprintingPlayers.add(userId); // Mark as sprinting for regen tick-- const buff = getActiveFoodBuff(userId);-- if (buff?.sprint_free) {-- // Still grant agility XP even with free sprint-- addXp(userId, 'agility', 1);-- return { ok: true };-- }-- const pd = getPlayer(userId);-- const p = db.get('SELECT stamina FROM game_players WHERE user_id = ?', [userId]);-- if (!p || p.stamina <= 0) return { ok: false, stamina: 0 };-- // Agility reduces sprint drain (base 1, reduced by sprintDrainMult)-- const bonuses = getAgilityBonuses(pd.agility_level);-- // Apply drain reduction: at high agility, skip some drain ticks-- if (bonuses.sprintDrainMult < 1.0 && Math.random() > bonuses.sprintDrainMult) {-- // Saved by agility — no drain this tick-- addXp(userId, 'agility', 1);-- return { ok: true, stamina: p.stamina };-- }-- db.run('UPDATE game_players SET stamina = MAX(0, stamina - 1) WHERE user_id = ?', [userId]);-- // Grant agility XP for sprinting-- addXp(userId, 'agility', 1);-- return { ok: true, stamina: p.stamina - 1 };--}----// ══════════════════════════════════════════════════════════════--// FOOD / BUFF SYSTEM--// ══════════════════════════════════════════════════════════════----function eatFood(userId, itemId) {-- const effect = FOOD_EFFECTS[itemId];-- if (!effect) return { error: 'Not food.' };-- if (!removeItem(userId, itemId, 1)) return { error: 'Don\'t have it.' };-- const result = { success: true, food: itemId, effects: [] };-- // Instant HP heal-- if (effect.hp) {-- db.run('UPDATE game_players SET hp = MIN(max_hp, hp + ?) WHERE user_id = ?', [effect.hp, userId]);-- result.effects.push({ type: 'heal', amount: effect.hp });-- }-- // Instant stamina restore-- if (effect.stamina) {-- db.run('UPDATE game_players SET stamina = MIN(max_stamina, stamina + ?) WHERE user_id = ?', [effect.stamina, userId]);-- result.effects.push({ type: 'stamina', amount: effect.stamina });-- }-- // Timed buff-- if (effect.buff) {-- const buff = { ...effect.buff, expiresAt: Date.now() + effect.buff.duration };-- foodBuffs.set(userId, buff);-- result.effects.push({ type: 'buff', buff: { ...effect.buff } });-- result.buffDuration = effect.buff.duration;-- // Recalc combat stats if buff has ATK/DEF-- if (effect.buff.atk || effect.buff.def) recalcCombatStats(userId);-- }-- // XP for cooking skill-- addXp(userId, 'crafting', 5);-- return result;--}----function getActiveFoodBuff(userId) {-- const buff = foodBuffs.get(userId);-- if (!buff) return null;-- if (Date.now() > buff.expiresAt) {-- foodBuffs.delete(userId);-- recalcCombatStats(userId); // Remove buff stats-- return null;-- }-- return buff;--}----function getWeaponInfo(weaponId) {-- return WEAPON_STATS[weaponId] || WEAPON_STATS.weapon_fist;--}----// ══════════════════════════════════════════════════════════════--// COSMETICS--// ══════════════════════════════════════════════════════════════----function equipCosmetic(userId, itemId) {-- const item = ITEMS[itemId];-- if (!item || !hasItem(userId, itemId, 1)) return { error: 'Don\'t have it.' };-- if (item.category === 'name_effects') { db.run('UPDATE game_players SET name_effect = ? WHERE user_id = ?', [itemId, userId]); return { success: true, type: 'name_effect' }; }-- if (item.category === 'particles') { db.run('UPDATE game_players SET particle_effect = ? WHERE user_id = ?', [itemId, userId]); return { success: true, type: 'particle_effect' }; }-- return { error: 'Not cosmetic.' };--}----function unequipCosmetic(userId, type) {-- if (type === 'name_effect') db.run('UPDATE game_players SET name_effect = NULL WHERE user_id = ?', [userId]);-- else if (type === 'particle_effect') db.run('UPDATE game_players SET particle_effect = NULL WHERE user_id = ?', [userId]);-- return { success: true };--}----// ══════════════════════════════════════════════════════════════--// CONSUMABLES / EFFECTS--// ══════════════════════════════════════════════════════════════----function useConsumable(userId, itemId) {-- if (!removeItem(userId, itemId, 1)) return { error: 'Don\'t have it.' };-- const effects = {-- craft_elixir: { type: 'coin_boost', duration: 600000, data: { multiplier: 2 } },-- craft_xp_potion: { type: 'xp_boost', duration: 600000, data: { multiplier: 2 } },-- craft_loot_magnet: { type: 'loot_magnet', charges: 5 },-- supp_stamina: { type: 'instant', effect: 'stamina', amount: 50 },-- potion_health: { type: 'instant', effect: 'hp', amount: 30 },-- potion_health_big: { type: 'instant', effect: 'hp', amount: 75 },-- potion_stamina: { type: 'instant', effect: 'stamina', amount: 30 },-- };-- const eff = effects[itemId];-- if (!eff) return { error: 'Not consumable.' };-- if (eff.type === 'instant') {-- if (eff.effect === 'hp') db.run('UPDATE game_players SET hp = MIN(max_hp, hp + ?) WHERE user_id = ?', [eff.amount, userId]);-- else db.run('UPDATE game_players SET stamina = MIN(max_stamina, stamina + ?) WHERE user_id = ?', [eff.amount, userId]);-- return { success: true, instant: true, effect: eff.effect, amount: eff.amount };-- }-- const expiresAt = eff.duration ? new Date(Date.now() + eff.duration).toISOString().replace('T', ' ').slice(0, 19) : null;-- db.run('INSERT INTO game_effects (user_id, effect_type, effect_id, expires_at, charges, data) VALUES (?, ?, ?, ?, ?, ?)',-- [userId, eff.type, itemId, expiresAt, eff.charges || null, JSON.stringify(eff.data || {})]);-- return { success: true, effectType: eff.type, duration: eff.duration, charges: eff.charges };--}----function getActiveEffects(userId) {-- db.run("DELETE FROM game_effects WHERE expires_at IS NOT NULL AND expires_at < CURRENT_TIMESTAMP AND user_id = ?", [userId]);-- db.run("DELETE FROM game_effects WHERE charges IS NOT NULL AND charges <= 0 AND user_id = ?", [userId]);-- return db.all('SELECT * FROM game_effects WHERE user_id = ?', [userId]);--}----// ══════════════════════════════════════════════════════════════--// LEADERBOARDS--// ══════════════════════════════════════════════════════════════----function getLeaderboard(boardType, limit = 20) {-- const cols = {-- mining: 'mining_xp', fishing: 'fishing_xp', woodcut: 'woodcut_xp',-- farming: 'farming_xp', combat: 'combat_xp', crafting: 'crafting_xp',-- smithing: 'smithing_xp', agility: 'COALESCE(agility_xp, 0)',-- total_level: 'mining_xp + fishing_xp + woodcut_xp + farming_xp + combat_xp + crafting_xp + COALESCE(smithing_xp, 0) + COALESCE(agility_xp, 0)',-- kills: 'total_monsters_killed', buildings: 'structures_built',-- };-- const col = cols[boardType];-- if (!col) return [];-- return db.all(`SELECT gp.user_id, gp.display_name, u.username, ${col} as score, gp.name_effect, gp.particle_effect-- FROM game_players gp JOIN users u ON gp.user_id = u.id ORDER BY ${col} DESC LIMIT ?`, [limit]);--}----// ══════════════════════════════════════════════════════════════--// MULTIPLAYER STATE--// ══════════════════════════════════════════════════════════════----const livePlayers = new Map();----function updateLivePlayer(userId, data) {-- livePlayers.set(userId, { ...data, userId, lastUpdate: Date.now() });--}----function removeLivePlayer(userId) { livePlayers.delete(userId); }----function getLivePlayers() {-- const now = Date.now();-- const result = {};-- for (const [uid, p] of livePlayers) {-- if (now - p.lastUpdate > 30000) { livePlayers.delete(uid); continue; }-- result[uid] = p;-- }-- return result;--}----// ══════════════════════════════════════════════════════════════--// WORLD MOBS — In-memory PvE system--// ══════════════════════════════════════════════════════════════----const liveMobs = new Map(); // mobId → mob object--let mobIdCounter = 0;--const MOB_LEASH = 8 * TILE;--const MOB_ATTACK_RANGE = 1.5 * TILE;--const MOB_ATTACK_CD = 2000; // ms between mob attacks--const MOB_MAX_NEARBY = 8; // max mobs within 10 tiles of a player----/** Spawn mobs near live players. Called every few seconds. */--function spawnMobs() {-- const players = getLivePlayers();-- for (const [uid, p] of Object.entries(players)) {-- const ptx = Math.floor(p.x / TILE), pty = Math.floor(p.y / TILE);-- if (isInSafeZone(ptx, pty)) continue;-- // Count existing mobs nearby-- let nearby = 0;-- for (const mob of liveMobs.values()) {-- if (Math.abs(mob.x - p.x) + Math.abs(mob.y - p.y) < 10 * TILE) nearby++;-- }-- if (nearby >= MOB_MAX_NEARBY) continue;---- const angle = Math.random() * Math.PI * 2;-- const dist = (5 + Math.random() * 6) * TILE;-- const mx = p.x + Math.cos(angle) * dist;-- const my = p.y + Math.sin(angle) * dist;-- const mtx = Math.floor(mx / TILE), mty = Math.floor(my / TILE);-- if (mtx < 1 || mtx >= MAP_W - 1 || mty < 1 || mty >= MAP_H - 1) continue;---- const biome = getBiomeAt(mtx, mty, worldSeed);-- if (biome === 'water' || biome === 'outpost') continue;---- const tier = getDifficultyTier(mtx, mty);-- const eligible = Object.entries(MOB_TYPES).filter(([_, m]) =>-- m.biomes.includes(biome) && m.minTier <= tier-- );-- if (!eligible.length) continue;---- const [typeName, def] = eligible[Math.floor(Math.random() * eligible.length)];-- const scale = 1 + tier * 0.15;-- const id = ++mobIdCounter;-- liveMobs.set(id, {-- id, type: typeName, name: def.name, emoji: def.emoji,-- x: mx, y: my, spawnX: mx, spawnY: my,-- hp: Math.round(def.hp * scale), maxHp: Math.round(def.hp * scale),-- atk: Math.round(def.atk * scale), def: Math.round(def.def * scale),-- speed: def.speed, aggroRange: def.aggroRange * TILE,-- state: 'idle', targetUserId: null, lastAttack: 0,-- patrolAngle: Math.random() * Math.PI * 2,-- xp: def.xp, gold: Math.round(def.gold * scale), loot: def.loot,-- _pendingAttack: null, _pendingKill: null,-- });-- }--}----/** Run one AI tick for every live mob. Returns arrays of pending attacks/kills for broadcast. */--function mobTick() {-- const now = Date.now();-- const players = getLivePlayers();-- const toRemove = [];-- const attacks = []; // { mobId, targetId, dmg, newHp, maxHp }-- const kills = []; // { mobId, targetId, deathData }---- for (const [id, mob] of liveMobs) {-- // Despawn if far from all players-- let nearDist = Infinity, nearP = null;-- for (const [uid, p] of Object.entries(players)) {-- const d = Math.abs(mob.x - p.x) + Math.abs(mob.y - p.y);-- if (d < nearDist) { nearDist = d; nearP = { uid, ...p }; }-- }-- if (nearDist > 16 * TILE) { toRemove.push(id); continue; }---- const spd = mob.speed * 1.6; // pixels per tick---- switch (mob.state) {-- case 'idle': {-- if (nearP && nearDist < mob.aggroRange) {-- const ptx = Math.floor(nearP.x / TILE), pty = Math.floor(nearP.y / TILE);-- if (!isInSafeZone(ptx, pty)) { mob.state = 'chase'; mob.targetUserId = nearP.uid; break; }-- }-- if (Math.random() < 0.02) { mob.state = 'patrol'; mob.patrolAngle = Math.random() * Math.PI * 2; }-- break;-- }-- case 'patrol': {-- mob.x += Math.cos(mob.patrolAngle) * spd * 0.5;-- mob.y += Math.sin(mob.patrolAngle) * spd * 0.5;-- if (Math.random() < 0.03) mob.state = 'idle';-- if (nearP && nearDist < mob.aggroRange) {-- const ptx = Math.floor(nearP.x / TILE), pty = Math.floor(nearP.y / TILE);-- if (!isInSafeZone(ptx, pty)) { mob.state = 'chase'; mob.targetUserId = nearP.uid; break; }-- }-- const ds = Math.sqrt((mob.x - mob.spawnX) ** 2 + (mob.y - mob.spawnY) ** 2);-- if (ds > MOB_LEASH) mob.state = 'returning';-- break;-- }-- case 'chase': {-- const tgt = players[mob.targetUserId];-- if (!tgt) { mob.state = 'idle'; mob.targetUserId = null; break; }-- const dx = tgt.x - mob.x, dy = tgt.y - mob.y;-- const d = Math.sqrt(dx * dx + dy * dy);-- const ttx = Math.floor(tgt.x / TILE), tty = Math.floor(tgt.y / TILE);-- if (isInSafeZone(ttx, tty) || d > mob.aggroRange * 1.6) {-- mob.state = 'returning'; mob.targetUserId = null; break;-- }-- if (d > MOB_ATTACK_RANGE) {-- mob.x += (dx / d) * spd;-- mob.y += (dy / d) * spd;-- } else if (now - mob.lastAttack >= MOB_ATTACK_CD) {-- mob.lastAttack = now;-- const targetUid = parseInt(mob.targetUserId);-- // Dodge i-frame check-- if (isInvulnerable(targetUid)) {-- attacks.push({ mobId: id, mobName: mob.name, targetId: mob.targetUserId, dmg: 0, dodged: true, newHp: -1, maxHp: -1 });-- } else {-- const pData = getPlayer(targetUid);-- if (pData) {-- const dmg = Math.max(1, Math.round(mob.atk * (0.8 + Math.random() * 0.4) - pData.defense * 0.3));-- const newHp = Math.max(0, pData.hp - dmg);-- db.run('UPDATE game_players SET hp = ? WHERE user_id = ?', [newHp, targetUid]);-- attacks.push({ mobId: id, mobName: mob.name, targetId: mob.targetUserId, dmg, newHp, maxHp: pData.max_hp });-- if (newHp <= 0) {-- const deathData = handleDeath(targetUid, null);-- kills.push({ mobId: id, mobName: mob.name, targetId: mob.targetUserId, deathData });-- mob.state = 'idle'; mob.targetUserId = null;-- }-- }-- }-- }-- const ds = Math.sqrt((mob.x - mob.spawnX) ** 2 + (mob.y - mob.spawnY) ** 2);-- if (ds > MOB_LEASH) { mob.state = 'returning'; mob.targetUserId = null; }-- break;-- }-- case 'returning': {-- const dx = mob.spawnX - mob.x, dy = mob.spawnY - mob.y;-- const d = Math.sqrt(dx * dx + dy * dy);-- if (d < TILE) { mob.state = 'idle'; mob.hp = mob.maxHp; }-- else { mob.x += (dx / d) * spd * 1.4; mob.y += (dy / d) * spd * 1.4; }-- break;-- }-- }---- // Clamp to world bounds-- mob.x = Math.max(TILE, Math.min((MAP_W - 1) * TILE, mob.x));-- mob.y = Math.max(TILE, Math.min((MAP_H - 1) * TILE, mob.y));-- }-- for (const mid of toRemove) liveMobs.delete(mid);-- return { attacks, kills };--}----/** Player attacks a world mob */--function attackMob(userId, mobId) {-- const mob = liveMobs.get(mobId);-- if (!mob) return { error: 'Target not found.' };-- const p = getPlayer(userId);-- if (!p) return { error: 'Player not found.' };---- // Weapon range affects attack distance-- const weaponInfo = getWeaponInfo(p.equip_weapon);-- const attackRange = TILE * 3 * (weaponInfo.range || 1.0);-- const dist = Math.sqrt((p.x - mob.x) ** 2 + (p.y - mob.y) ** 2);-- if (dist > attackRange) return { error: 'Too far!' };---- // Combo system-- registerHit(userId);-- const combo = getComboMultiplier(userId);---- const isCrit = Math.random() < 0.15;-- let dmg = Math.max(1, Math.round(p.attack * (0.8 + Math.random() * 0.4) - mob.def * 0.3));-- if (isCrit) dmg = Math.round(dmg * 1.5);-- dmg = Math.round(dmg * combo.mult);-- mob.hp -= dmg;---- // Aggro onto attacker-- if (mob.state !== 'chase' || mob.targetUserId !== String(userId)) {-- mob.state = 'chase';-- mob.targetUserId = String(userId);-- }---- if (mob.hp <= 0) {-- // XP multiplier from food buff-- const buff = getActiveFoodBuff(userId);-- const xpMult = buff?.xp_mult || 1;-- const xpResult = addXp(userId, 'combat', Math.round(mob.xp * xpMult));-- db.addOpenCoins(userId, mob.gold);-- db.run('UPDATE game_players SET total_monsters_killed = total_monsters_killed + 1 WHERE user_id = ?', [userId]);-- incrementDailyQuestStat(userId, 'mob_kills', 1);-- let lootDrop = null;-- if (mob.loot?.length) {-- const lootId = rollLoot(mob.loot.map(l => ({ id: l.id, weight: l.w })));-- if (lootId) { addItem(userId, lootId, 1); lootDrop = { id: lootId, ...(ITEMS[lootId] || { name: lootId }) }; }-- }-- liveMobs.delete(mobId);-- comboTrackers.delete(userId);-- // Achievement: mob kills-- const mkP = getPlayer(userId);-- if (mkP) {-- checkAchievement(userId, 'first_blood', true);-- checkAchievement(userId, 'mob_slayer_10', mkP.total_monsters_killed >= 10);-- checkAchievement(userId, 'mob_slayer_50', mkP.total_monsters_killed >= 50);-- checkAchievement(userId, 'mob_slayer_100', mkP.total_monsters_killed >= 100);-- if (mob.type === 'dragon') checkAchievement(userId, 'dragon_slayer', true);-- }-- return {-- success: true, dmg, isCrit, killed: true, mobName: mob.name, mobEmoji: mob.emoji,-- xp: xpResult, gold: mob.gold, loot: lootDrop, mobX: mob.x, mobY: mob.y,-- combo: combo.count, comboMult: combo.mult, weaponSpeed: weaponInfo.speed,-- };-- }-- return {-- success: true, dmg, isCrit, killed: false,-- mobHp: mob.hp, mobMaxHp: mob.maxHp, mobX: mob.x, mobY: mob.y,-- combo: combo.count, comboMult: combo.mult, weaponSpeed: weaponInfo.speed,-- };--}----/** Serialise live mobs for state broadcast */--function getMobStates() {-- const r = {};-- for (const [id, m] of liveMobs) {-- r[id] = { id: m.id, type: m.type, name: m.name, emoji: m.emoji, x: m.x, y: m.y, hp: m.hp, maxHp: m.maxHp, state: m.state };-- }-- return r;--}----/** Passive health regen for out-of-combat players (call from tick every ~3s) */--function regenHealth() {-- const players = getLivePlayers();-- for (const [uid, p] of Object.entries(players)) {-- const pd = getPlayer(parseInt(uid));-- if (!pd || pd.hp >= pd.max_hp) continue;-- // Check if any mob is targeting this player-- let inCombat = false;-- for (const mob of liveMobs.values()) {-- if (mob.targetUserId === uid && mob.state === 'chase') { inCombat = true; break; }-- }-- if (!inCombat) {-- // Food buff hp_regen bonus-- const buff = getActiveFoodBuff(parseInt(uid));-- const basePct = 0.03 + (buff?.hp_regen || 0);-- const regen = Math.max(1, Math.round(pd.max_hp * basePct));-- const newHp = Math.min(pd.max_hp, pd.hp + regen);-- if (newHp !== pd.hp) db.run('UPDATE game_players SET hp = ? WHERE user_id = ?', [newHp, parseInt(uid)]);-- }-- }--}----/**-- * Track player movement for agility XP (swimming, distance).-- * Called from _handleMove in game-server.js.-- */--function trackMovement(userId, x, y) {-- const tx = Math.floor(x / TILE), ty = Math.floor(y / TILE);-- const biome = getBiomeAt(tx, ty, worldSeed);---- // Swimming grants bonus agility XP-- if (biome === 'water') {-- if (!swimmingPlayers.has(userId)) {-- swimmingPlayers.add(userId);-- }-- // 2 XP per movement tick in water-- addXp(userId, 'agility', 2);-- } else {-- swimmingPlayers.delete(userId);-- }---- // Distance-based agility XP: 1 XP per ~10 tiles traveled-- const prev = playerDistAccum.get(userId) || { x, y, accum: 0 };-- const dist = Math.sqrt((x - prev.x) ** 2 + (y - prev.y) ** 2) / TILE;-- prev.accum += dist;-- prev.x = x; prev.y = y;-- if (prev.accum >= 10) {-- const chunks = Math.floor(prev.accum / 10);-- addXp(userId, 'agility', chunks);-- prev.accum -= chunks * 10;-- // Update total tiles traveled for achievement tracking-- db.run('UPDATE game_players SET total_tiles_traveled = total_tiles_traveled + ? WHERE user_id = ?', [chunks * 10, userId]);-- incrementDailyQuestStat(userId, 'tiles_traveled', chunks * 10);-- const ttP = getPlayer(userId);-- if (ttP) {-- checkAchievement(userId, 'explorer_1k', ttP.total_tiles_traveled >= 1000);-- checkAchievement(userId, 'explorer_10k', ttP.total_tiles_traveled >= 10000);-- }-- }-- playerDistAccum.set(userId, prev);--}----/**-- * Get agility stats for a player (for UI/API).-- */--function getAgilityStats(userId) {-- const p = getPlayer(userId);-- const bonuses = getAgilityBonuses(p.agility_level);-- return {-- level: p.agility_level,-- xp: p.agility_xp,-- nextLevelXp: p.agility_level * p.agility_level * 25,-- bonuses,-- };--}----module.exports = {-- initGameDb, getWorldSeed: () => worldSeed,-- ITEMS, RECIPES, STRUCTURES, RARITY_COLORS, BIOME_COLORS,-- MAP_W, MAP_H, TILE, OUTPOST_X, OUTPOST_Y, OUTPOST_RADIUS,-- WEAPON_STATS, ARMOR_STATS, EQUIP_SKILL_MAP, FOOD_EFFECTS,-- xpToLevel, levelToXp,-- // Player-- getPlayer, updatePlayerPosition, addXp, regenStamina,-- // Inventory-- getInventory, getItemCount, addItem, removeItem, hasItem,-- // Bank-- getBank, bankDeposit, bankWithdraw, isNearBankNPC,-- // Shop-- getShopItems, buyItem, sellItem, buyWithCoins, getCoinShopItems,-- // NPCs-- isNearNPC, isNearAnyShopNPC, getNearestNPC, getShopItemsForNPC, getSellableItemsForNPC,-- // Gathering-- gather, getDepletedNodes, isNodeDepleted, depleteNode,-- // Fishing-- fish, useSonar, getFishCollection,-- // Building-- placeStructure, destroyStructure, getStructuresInArea, getAllStructures,-- storageDeposit, storageWithdraw,-- // Farming-- getFarmPlots, plant, water, harvest,-- // Crafting-- getUnlockedRecipes, craft, rollRecipeUnlock, isNearStation,-- // Combat-- attackPlayer, handleDeath, getBattleStats,-- dodgeRoll, isInvulnerable, getWeaponInfo,-- enterDungeon, fightMonster,-- // Mobs-- spawnMobs, mobTick, attackMob, getMobStates, regenHealth,-- // Ground Items-- dropGroundItem, pickupGroundItem, getGroundItemStates, cleanupGroundItems,-- // Equipment-- equipItem, unequipItem, recalcCombatStats,-- // Cosmetics-- equipCosmetic, unequipCosmetic,-- // Effects-- useConsumable, getActiveEffects,-- // Food-- eatFood, getActiveFoodBuff, drainSprintStamina,-- // Agility & Stamina-- getAgilityBonuses, getAgilityStats, regenStaminaTick, trackMovement,-- // Leaderboards-- getLeaderboard,-- // Daily quests-- getDailyQuests, claimDailyQuest,-- // Multiplayer-- updateLivePlayer, removeLivePlayer, getLivePlayers,-- // Achievements-- getAchievements, flushPendingAchievements, ACHIEVEMENTS,-- // Weather-- tickWeather, getWeather,-- // Treasure Chests-- spawnChests, openChest, cleanupChests, getChestStates, CHEST_TIERS,--};ddeleted file mode 100644@@@ -1,998 +0,0 @@--/**-- * OpenVibeGame — Item Catalog, Structures, World Generation, Shared Utilities-- * Rust-style open-world survival game-- */----// ── Map Constants ────────────────────────────────────────────--const MAP_W = 512;--const MAP_H = 512;--const TILE = 32;--const OUTPOST_X = Math.floor(MAP_W / 2); // 256--const OUTPOST_Y = Math.floor(MAP_H / 2);--const OUTPOST_RADIUS = 14;----// ── Procedural Villages (3 NPC settlements on separate islands) ──--// Placed at offsets from center — each acts as a mini safe-zone with NPCs--const VILLAGES = [-- { id: 'fishing_village', name: 'Fishing Village', cx: OUTPOST_X - 80, cy: OUTPOST_Y - 70, radius: 8 },-- { id: 'mining_camp', name: 'Mining Camp', cx: OUTPOST_X + 85, cy: OUTPOST_Y - 60, radius: 8 },-- { id: 'forest_hamlet', name: 'Forest Hamlet', cx: OUTPOST_X - 50, cy: OUTPOST_Y + 90, radius: 8 },--];----const RARITY_COLORS = {-- Junk: '#888888', Common: '#b0b0b0', Uncommon: '#4fc94f',-- Rare: '#3b82f6', Epic: '#a855f7', Legendary: '#f59e0b', Mythic: '#ef4444',--};----const BIOME_COLORS = {-- water: '#1e40af', sand: '#d4a053', grass: '#22c55e', forest: '#166534',-- desert: '#92400e', hills: '#78716c', mountain: '#44403c', snow: '#cbd5e1',-- outpost: '#92702e',--};----// ── Noise Functions (shared client/server) ───────────────────--function hashNoise(x, y, seed) {-- let h = seed;-- h = Math.imul(h ^ (x * 374761393), 1103515245);-- h = Math.imul(h ^ (y * 668265263), 1103515245);-- h = (h ^ (h >> 13)) * 1103515245;-- return ((h ^ (h >> 16)) >>> 0) / 4294967296;--}----function smoothNoise(x, y, seed) {-- const ix = Math.floor(x), iy = Math.floor(y);-- const fx = x - ix, fy = y - iy;-- const sx = fx * fx * (3 - 2 * fx);-- const sy = fy * fy * (3 - 2 * fy);-- const n00 = hashNoise(ix, iy, seed), n10 = hashNoise(ix + 1, iy, seed);-- const n01 = hashNoise(ix, iy + 1, seed), n11 = hashNoise(ix + 1, iy + 1, seed);-- return (n00 + sx * (n10 - n00)) + sy * ((n01 + sx * (n11 - n01)) - (n00 + sx * (n10 - n00)));--}----function fbm(x, y, seed, octaves) {-- let v = 0, a = 1, f = 1, t = 0;-- for (let i = 0; i < octaves; i++) {-- v += smoothNoise(x * f, y * f, seed + i * 1000) * a;-- t += a; a *= 0.5; f *= 2;-- }-- return v / t;--}----function getBiome(elev, moist) {-- if (elev < 0.30) return 'water';-- if (elev < 0.35) return 'sand';-- if (elev > 0.82) return 'snow';-- if (elev > 0.68) return 'mountain';-- if (elev > 0.55) return 'hills';-- if (moist > 0.58) return 'forest';-- if (moist < 0.35) return 'desert';-- return 'grass';--}----// ── Road corridor segments (outpost ↔ each village) for land guarantee ──--const ROAD_SEGMENTS = VILLAGES.map(v => ({-- x1: OUTPOST_X, y1: OUTPOST_Y, x2: v.cx, y2: v.cy--}));----// Minimum distance from point (px,py) to any road corridor segment--function distToRoadCorridor(px, py) {-- let minD = Infinity;-- for (const seg of ROAD_SEGMENTS) {-- const dx = seg.x2 - seg.x1, dy = seg.y2 - seg.y1;-- const lenSq = dx * dx + dy * dy;-- if (lenSq === 0) { minD = Math.min(minD, Math.sqrt((px - seg.x1) ** 2 + (py - seg.y1) ** 2)); continue; }-- const t = Math.max(0, Math.min(1, ((px - seg.x1) * dx + (py - seg.y1) * dy) / lenSq));-- const projX = seg.x1 + t * dx, projY = seg.y1 + t * dy;-- const d = Math.sqrt((px - projX) ** 2 + (py - projY) ** 2);-- if (d < minD) minD = d;-- }-- return minD;--}----function getBiomeAt(tx, ty, seed) {-- // Main outpost override-- const dx = tx - OUTPOST_X, dy = ty - OUTPOST_Y;-- const dist = Math.sqrt(dx * dx + dy * dy);-- if (dist <= OUTPOST_RADIUS) return 'outpost';-- // Village overrides (3 procedural NPC villages)-- for (const v of VILLAGES) {-- const vdx = tx - v.cx, vdy = ty - v.cy;-- const vdist = Math.sqrt(vdx * vdx + vdy * vdy);-- if (vdist <= v.radius) return 'outpost';-- }-- // Water border (outer 3 tiles)-- if (tx < 3 || ty < 3 || tx >= MAP_W - 3 || ty >= MAP_H - 3) return 'water';-- let elev = fbm(tx / 40, ty / 40, seed, 6);-- const moist = fbm(tx / 35, ty / 35, seed + 5000, 4);-- // Guarantee land near outpost — boost elevation within 60 tiles-- if (dist < 60) {-- const t = (60 - dist) / (60 - OUTPOST_RADIUS);-- const minElev = 0.35 + t * 0.20;-- elev = Math.max(elev, minElev);-- }-- // Guarantee land near villages-- for (const v of VILLAGES) {-- const vdx = tx - v.cx, vdy = ty - v.cy;-- const vdist = Math.sqrt(vdx * vdx + vdy * vdy);-- if (vdist < 30) {-- const t = (30 - vdist) / (30 - v.radius);-- const minElev = 0.35 + t * 0.15;-- elev = Math.max(elev, minElev);-- }-- }-- // Guarantee land along inter-town road corridors (5-tile-wide strip)-- const roadDist = distToRoadCorridor(tx, ty);-- if (roadDist < 5) {-- const t = 1 - roadDist / 5;-- const minElev = 0.36 + t * 0.14; // center of road: 0.50, edge: 0.36-- elev = Math.max(elev, minElev);-- }-- return getBiome(elev, moist);--}----function getDifficultyTier(tx, ty) {-- const dx = tx - OUTPOST_X, dy = ty - OUTPOST_Y;-- const dist = Math.sqrt(dx * dx + dy * dy);-- // Also check distance to villages (villages lower local difficulty)-- let minVillageDist = Infinity;-- for (const v of VILLAGES) {-- const vd = Math.sqrt((tx - v.cx) ** 2 + (ty - v.cy) ** 2);-- if (vd < minVillageDist) minVillageDist = vd;-- }-- const effectiveDist = Math.min(dist, minVillageDist + 40);-- if (effectiveDist < 30) return 0;-- if (effectiveDist < 70) return 1;-- if (effectiveDist < 140) return 2;-- return 3;--}----// ── Pickaxe Tiers (determines what ores you can mine) ────────--const PICK_TIERS = {-- pick_wood: { tier: 0, name: 'Wooden' }, // stone, copper-- pick_stone: { tier: 1, name: 'Stone' }, // + iron-- pick_iron: { tier: 2, name: 'Iron' }, // + gold-- pick_gold: { tier: 3, name: 'Gold' }, // + mithril, titanium-- pick_diamond: { tier: 4, name: 'Diamond' }, // + platinum, dragonite--};----// ── Axe Tiers (determines what trees you can chop) ──────────--const AXE_TIERS = {-- axe_stone: { tier: 0, name: 'Stone' },-- axe_iron: { tier: 1, name: 'Iron' },-- axe_steel: { tier: 2, name: 'Steel' },-- axe_mythril: { tier: 3, name: 'Mythril' },--};----// ── Ore Node Types (affects what you mine) ───────────────────--// Each rock tile gets an ore subtype based on biome + a second noise hash--const ORE_NODE_TYPES = {-- stone: { emoji: '🪨', color: '#6b7280', minPickTier: -1, minLevel: 1, name: 'Stone' }, // fists OK-- tin: { emoji: '🔘', color: '#8a9a9a', minPickTier: 0, minLevel: 1, name: 'Tin' },-- copper: { emoji: '🟤', color: '#b87333', minPickTier: 0, minLevel: 1, name: 'Copper' },-- coal: { emoji: '⬛', color: '#374151', minPickTier: 0, minLevel: 5, name: 'Coal' },-- iron: { emoji: '⚙️', color: '#a8a8a8', minPickTier: 1, minLevel: 10, name: 'Iron' },-- gold: { emoji: '🥇', color: '#ffd700', minPickTier: 2, minLevel: 20, name: 'Gold' },-- mithril: { emoji: '💠', color: '#7dd3fc', minPickTier: 3, minLevel: 35, name: 'Mithril' },-- titanium: { emoji: '⬜', color: '#e2e8f0', minPickTier: 3, minLevel: 45, name: 'Titanium' },-- platinum: { emoji: '🪙', color: '#d4d4d8', minPickTier: 4, minLevel: 55, name: 'Platinum' },-- dragonite:{ emoji: '🐉', color: '#dc2626', minPickTier: 4, minLevel: 65, name: 'Dragonite' },--};----function getOreNodeType(tx, ty, seed, biome) {-- const h2 = hashNoise(tx, ty, seed + 77777); // second noise layer for ore type-- // Biome-specific ore distribution — tin & coal appear in starter zones-- if (biome === 'grass') {-- return h2 < 0.30 ? 'stone' : h2 < 0.50 ? 'tin' : h2 < 0.72 ? 'copper' : h2 < 0.88 ? 'coal' : 'iron';-- }-- if (biome === 'hills') {-- return h2 < 0.15 ? 'stone' : h2 < 0.28 ? 'tin' : h2 < 0.42 ? 'copper' : h2 < 0.55 ? 'coal' : h2 < 0.72 ? 'iron' : h2 < 0.88 ? 'gold' : 'mithril';-- }-- if (biome === 'mountain') {-- return h2 < 0.10 ? 'coal' : h2 < 0.25 ? 'iron' : h2 < 0.42 ? 'gold' : h2 < 0.58 ? 'mithril' : h2 < 0.74 ? 'titanium' : h2 < 0.90 ? 'platinum' : 'dragonite';-- }-- if (biome === 'snow') {-- return h2 < 0.15 ? 'coal' : h2 < 0.30 ? 'iron' : h2 < 0.50 ? 'gold' : h2 < 0.70 ? 'mithril' : h2 < 0.88 ? 'titanium' : 'platinum';-- }-- if (biome === 'desert') {-- return h2 < 0.25 ? 'stone' : h2 < 0.45 ? 'copper' : h2 < 0.60 ? 'coal' : h2 < 0.80 ? 'gold' : 'iron';-- }-- // Default (sand, forest fallback)-- return h2 < 0.35 ? 'stone' : h2 < 0.55 ? 'tin' : h2 < 0.78 ? 'copper' : 'coal';--}----function getResourceNodeAt(tx, ty, seed) {-- const biome = getBiomeAt(tx, ty, seed);-- if (biome === 'water' || biome === 'outpost') return null;-- const h = hashNoise(tx, ty, seed + 99999);-- // Forest: dense trees-- if (biome === 'forest' && h < 0.22) return { type: 'tree' };-- // Grass: scattered trees + occasional rocks-- if (biome === 'grass') {-- if (h < 0.08) return { type: 'tree' };-- if (h > 0.95) return { type: 'rock', ore: getOreNodeType(tx, ty, seed, biome) };-- }-- // Hills: rocks + rare trees-- if (biome === 'hills') {-- if (h < 0.12) return { type: 'rock', ore: getOreNodeType(tx, ty, seed, biome) };-- if (h > 0.93) return { type: 'tree' };-- }-- // Mountain: dense rocks-- if (biome === 'mountain' && h < 0.18) return { type: 'rock', ore: getOreNodeType(tx, ty, seed, biome) };-- // Sand: fish spots along the shore-- if (biome === 'sand') {-- if (h < 0.10) return { type: 'fish_spot' };-- if (h > 0.92) return { type: 'tree' };-- }-- // Snow: rocks + rare gems-- if (biome === 'snow' && h < 0.10) return { type: 'rock', ore: getOreNodeType(tx, ty, seed, biome) };-- // Desert: rocks + dead trees-- if (biome === 'desert') {-- if (h < 0.07) return { type: 'rock', ore: getOreNodeType(tx, ty, seed, biome) };-- if (h > 0.94) return { type: 'tree' };-- }-- return null;--}----function isInSafeZone(tx, ty) {-- const dx = tx - OUTPOST_X, dy = ty - OUTPOST_Y;-- if (Math.sqrt(dx * dx + dy * dy) <= OUTPOST_RADIUS) return true;-- for (const v of VILLAGES) {-- const vd = Math.sqrt((tx - v.cx) ** 2 + (ty - v.cy) ** 2);-- if (vd <= v.radius) return true;-- }-- return false;--}----// ── Items ────────────────────────────────────────────────────--const ITEMS = {-- // Seeds-- seed_wheat: { name: 'Wheat Seeds', emoji: '🌾', desc: 'Basic crop. 30 min.', buyCost: 5, sellPrice: null, category: 'seeds', rarity: 'Common' },-- seed_carrot: { name: 'Carrot Seeds', emoji: '🥕', desc: '45 min.', buyCost: 12, sellPrice: null, category: 'seeds', rarity: 'Common' },-- seed_tomato: { name: 'Tomato Seeds', emoji: '🍅', desc: '60 min.', buyCost: 20, sellPrice: null, category: 'seeds', rarity: 'Uncommon' },-- seed_corn: { name: 'Corn Seeds', emoji: '🌽', desc: '90 min.', buyCost: 30, sellPrice: null, category: 'seeds', rarity: 'Uncommon' },-- seed_pumpkin: { name: 'Pumpkin Seeds', emoji: '🎃', desc: '2 hours.', buyCost: 50, sellPrice: null, category: 'seeds', rarity: 'Rare' },-- seed_golden: { name: 'Golden Seeds', emoji: '✨', desc: '3 hours. Legendary.', buyCost: 150, sellPrice: null, category: 'seeds', rarity: 'Legendary' },-- // Crops-- crop_wheat: { name: 'Wheat', emoji: '🌾', desc: 'Harvested wheat.', buyCost: null, sellPrice: 18, category: 'crops', rarity: 'Common' },-- crop_carrot: { name: 'Carrots', emoji: '🥕', desc: 'Fresh carrots.', buyCost: null, sellPrice: 35, category: 'crops', rarity: 'Common' },-- crop_tomato: { name: 'Tomatoes', emoji: '🍅', desc: 'Ripe tomatoes.', buyCost: null, sellPrice: 60, category: 'crops', rarity: 'Uncommon' },-- crop_corn: { name: 'Corn', emoji: '🌽', desc: 'Golden corn.', buyCost: null, sellPrice: 90, category: 'crops', rarity: 'Uncommon' },-- crop_pumpkin: { name: 'Pumpkin', emoji: '🎃', desc: 'Thicc pumpkin.', buyCost: null, sellPrice: 160, category: 'crops', rarity: 'Rare' },-- crop_golden: { name: 'Golden Fruit', emoji: '✨', desc: 'Mythical produce.', buyCost: null, sellPrice: 550, category: 'crops', rarity: 'Legendary' },-- compost: { name: 'Compost', emoji: '🫘', desc: '+50% yield.', buyCost: null, sellPrice: 15, category: 'tools', rarity: 'Common' },-- // Fishing tools-- fish_sonar: { name: 'Fish Sonar', emoji: '📡', desc: 'Detects fish in water. One use.', buyCost: null, sellPrice: 20, category: 'tools', rarity: 'Uncommon' },-- // Bait-- bait_worm: { name: 'Worms', emoji: '🪱', desc: 'Basic bait.', buyCost: 3, sellPrice: 1, category: 'bait', rarity: 'Common' },-- bait_cricket: { name: 'Crickets', emoji: '🦗', desc: 'Better bait.', buyCost: 10, sellPrice: 4, category: 'bait', rarity: 'Common' },-- bait_shrimp: { name: 'Shrimp', emoji: '🦐', desc: 'Rare fish bait.', buyCost: 25, sellPrice: 10, category: 'bait', rarity: 'Uncommon' },-- bait_golden: { name: 'Golden Lure', emoji: '🌟', desc: 'Epic fish bait.', buyCost: 75, sellPrice: 30, category: 'bait', rarity: 'Rare' },-- // Rods (levelReq = fishing level)-- rod_bamboo: { name: 'Bamboo Rod', emoji: '🎋', desc: 'Basic rod.', buyCost: 15, sellPrice: 5, category: 'rods', rarity: 'Common', levelReq: 1 },-- rod_fiberglass: { name: 'Fiberglass Rod', emoji: '🥢', desc: 'Light rod.', buyCost: 50, sellPrice: 18, category: 'rods', rarity: 'Common', levelReq: 8 },-- rod_carbon: { name: 'Carbon Rod', emoji: '⚫', desc: 'Strong rod.', buyCost: 150, sellPrice: 55, category: 'rods', rarity: 'Uncommon', levelReq: 20 },-- rod_titanium: { name: 'Titanium Rod', emoji: '🔩', desc: 'Premium rod.', buyCost: 400, sellPrice: 150, category: 'rods', rarity: 'Rare', levelReq: 35 },-- rod_mythril: { name: 'Mythril Rod', emoji: '💜', desc: 'Top-tier rod.', buyCost: 1000, sellPrice: 375, category: 'rods', rarity: 'Epic', levelReq: 50 },-- // Fish — Minnow Family-- fish_minnow: { name: 'Minnow', emoji: '🐟', desc: 'Tiny but plentiful.', buyCost: null, sellPrice: 4, category: 'fish', rarity: 'Common' },-- fish_sunfish: { name: 'Sunfish', emoji: '🌻', desc: 'Warm water lover.', buyCost: null, sellPrice: 6, category: 'fish', rarity: 'Common' },-- // Fish — Bass Family-- fish_bass: { name: 'Bass', emoji: '🐟', desc: 'Solid catch.', buyCost: null, sellPrice: 10, category: 'fish', rarity: 'Common' },-- fish_perch: { name: 'Perch', emoji: '🐟', desc: 'Yellow-striped.', buyCost: null, sellPrice: 8, category: 'fish', rarity: 'Common' },-- // Fish — Trout Family-- fish_trout: { name: 'Trout', emoji: '🐠', desc: 'Speckled beauty.', buyCost: null, sellPrice: 12, category: 'fish', rarity: 'Common' },-- fish_clownfish: { name: 'Clownfish', emoji: '🤡', desc: 'Found Nemo.', buyCost: null, sellPrice: 14, category: 'fish', rarity: 'Common' },-- // Fish — Catfish Family (Tier 1)-- fish_catfish: { name: 'Catfish', emoji: '🐡', desc: 'Whiskered bottom-feeder.', buyCost: null, sellPrice: 18, category: 'fish', rarity: 'Uncommon' },-- fish_carp: { name: 'Carp', emoji: '🐟', desc: 'Golden scales.', buyCost: null, sellPrice: 16, category: 'fish', rarity: 'Common' },-- fish_pike: { name: 'Pike', emoji: '🐟', desc: 'Lurking predator.', buyCost: null, sellPrice: 22, category: 'fish', rarity: 'Uncommon' },-- // Fish — Salmon Family (Tier 1)-- fish_salmon: { name: 'Salmon', emoji: '🐠', desc: 'Fresh upstream salmon.', buyCost: null, sellPrice: 20, category: 'fish', rarity: 'Uncommon' },-- fish_rainbow_trout:{ name: 'Rainbow Trout', emoji: '🌈', desc: 'Shimmering colors.', buyCost: null, sellPrice: 24, category: 'fish', rarity: 'Uncommon' },-- fish_king_salmon: { name: 'King Salmon', emoji: '👑', desc: 'The royal catch.', buyCost: null, sellPrice: 30, category: 'fish', rarity: 'Rare' },-- // Fish — Puffer Family (Tier 1)-- fish_pufferfish: { name: 'Pufferfish', emoji: '🐡', desc: 'Don\'t poke it.', buyCost: null, sellPrice: 18, category: 'fish', rarity: 'Uncommon' },-- fish_jellyfish: { name: 'Jellyfish', emoji: '🪼', desc: 'Translucent drifter.', buyCost: null, sellPrice: 20, category: 'fish', rarity: 'Uncommon' },-- // Fish — Eel Family (Tier 2)-- fish_electric_eel: { name: 'Electric Eel', emoji: '⚡', desc: 'Shocking catch.', buyCost: null, sellPrice: 35, category: 'fish', rarity: 'Rare' },-- fish_stingray: { name: 'Stingray', emoji: '🦟', desc: 'Flat and dangerous.', buyCost: null, sellPrice: 28, category: 'fish', rarity: 'Uncommon' },-- // Fish — Swordfish Family (Tier 2)-- fish_swordfish: { name: 'Swordfish', emoji: '🗡️', desc: 'Razor-nosed speedster.', buyCost: null, sellPrice: 40, category: 'fish', rarity: 'Rare' },-- fish_marlin: { name: 'Marlin', emoji: '🏹', desc: 'Deep sea game fish.', buyCost: null, sellPrice: 45, category: 'fish', rarity: 'Rare' },-- fish_barracuda: { name: 'Barracuda', emoji: '🦷', desc: 'Silver torpedo.', buyCost: null, sellPrice: 38, category: 'fish', rarity: 'Rare' },-- // Fish — Deep Sea Family (Tier 2)-- fish_anglerfish: { name: 'Anglerfish', emoji: '🏮', desc: 'Lures with light.', buyCost: null, sellPrice: 42, category: 'fish', rarity: 'Rare' },-- fish_octopus: { name: 'Octopus', emoji: '🐙', desc: 'Eight-armed escape artist.',buyCost: null, sellPrice: 48, category: 'fish', rarity: 'Rare' },-- // Fish — Shark Family (Tier 3)-- fish_shark: { name: 'Shark', emoji: '🦈', desc: 'Jaws.', buyCost: null, sellPrice: 60, category: 'fish', rarity: 'Epic' },-- fish_hammerhead: { name: 'Hammerhead', emoji: '🔨', desc: 'Weird but fearsome.', buyCost: null, sellPrice: 75, category: 'fish', rarity: 'Epic' },-- fish_ghost_fish: { name: 'Ghost Fish', emoji: '👻', desc: 'Transparent and eerie.', buyCost: null, sellPrice: 70, category: 'fish', rarity: 'Epic' },-- // Fish — Whale Family (Tier 3)-- fish_whale: { name: 'Whale', emoji: '🐳', desc: 'Absolute unit.', buyCost: null, sellPrice: 90, category: 'fish', rarity: 'Epic' },-- fish_narwhal: { name: 'Narwhal', emoji: '🦄', desc: 'Unicorn of the sea.', buyCost: null, sellPrice: 95, category: 'fish', rarity: 'Epic' },-- // Fish — Legendary Family (Tier 4)-- fish_kraken: { name: 'Kraken Tentacle', emoji: '🦑', desc: 'Proof of bravery.', buyCost: null, sellPrice: 200, category: 'fish', rarity: 'Legendary' },-- fish_golden: { name: 'Golden Fish', emoji: '🏆', desc: 'The legendary one!', buyCost: null, sellPrice: 300, category: 'fish', rarity: 'Legendary' },-- fish_leviathan: { name: 'Leviathan Scale', emoji: '🐲', desc: 'From the abyss itself.', buyCost: null, sellPrice: 500, category: 'fish', rarity: 'Legendary' },-- // Junk (fishing)-- fish_boot: { name: 'Old Boot', emoji: '👢', desc: 'Not worth it.', buyCost: null, sellPrice: 1, category: 'junk', rarity: 'Junk' },-- fish_tin_can: { name: 'Tin Can', emoji: '🥫', desc: 'Soggy garbage.', buyCost: null, sellPrice: 1, category: 'junk', rarity: 'Junk' },-- // Raw materials-- raw_stone: { name: 'Stone', emoji: '🪨', desc: 'Basic rock.', buyCost: null, sellPrice: 1, category: 'materials', rarity: 'Junk' },-- raw_flint: { name: 'Flint', emoji: '🔥', desc: 'Sharp stone.', buyCost: null, sellPrice: 2, category: 'materials', rarity: 'Common' },-- raw_stick: { name: 'Stick', emoji: '🌿', desc: 'A plain stick.', buyCost: null, sellPrice: 1, category: 'materials', rarity: 'Junk' },-- // Ores & Gems-- ore_gravel: { name: 'Gravel', emoji: '🪨', desc: 'Boring rocks.', buyCost: null, sellPrice: 3, category: 'ores', rarity: 'Junk' },-- ore_tin: { name: 'Tin Ore', emoji: '🔘', desc: 'Soft tin.', buyCost: null, sellPrice: 6, category: 'ores', rarity: 'Common' },-- ore_coal: { name: 'Coal', emoji: '⬛', desc: 'Fuel source.', buyCost: null, sellPrice: 8, category: 'ores', rarity: 'Common' },-- ore_copper: { name: 'Copper Ore', emoji: '🟤', desc: 'Soft metal.', buyCost: null, sellPrice: 10, category: 'ores', rarity: 'Common' },-- ore_iron: { name: 'Iron Ore', emoji: '⚙️', desc: 'Solid iron.', buyCost: null, sellPrice: 15, category: 'ores', rarity: 'Common' },-- ore_gold: { name: 'Gold Nugget', emoji: '🥇', desc: 'Shiny gold.', buyCost: null, sellPrice: 35, category: 'ores', rarity: 'Uncommon' },-- ore_mithril: { name: 'Mithril Ore', emoji: '💠', desc: 'Enchanted metal.', buyCost: null, sellPrice: 55, category: 'ores', rarity: 'Rare' },-- ore_titanium: { name: 'Titanium Ore', emoji: '⬜', desc: 'Incredibly strong.', buyCost: null, sellPrice: 70, category: 'ores', rarity: 'Rare' },-- ore_platinum: { name: 'Platinum Ore', emoji: '🪙', desc: 'Rarer than gold.', buyCost: null, sellPrice: 90, category: 'ores', rarity: 'Epic' },-- ore_dragonite: { name: 'Dragonite Ore', emoji: '🐉', desc: 'Mythically rare.', buyCost: null, sellPrice: 250, category: 'ores', rarity: 'Legendary' },-- gem_ruby: { name: 'Ruby', emoji: '🔴', desc: 'Red gemstone.', buyCost: null, sellPrice: 45, category: 'gems', rarity: 'Uncommon' },-- gem_emerald: { name: 'Emerald', emoji: '💚', desc: 'Green gem.', buyCost: null, sellPrice: 65, category: 'gems', rarity: 'Rare' },-- gem_diamond: { name: 'Diamond', emoji: '💎', desc: 'The hardest gem.', buyCost: null, sellPrice: 100, category: 'gems', rarity: 'Rare' },-- gem_star: { name: 'Star Fragment', emoji: '⭐', desc: 'Fallen star shard.', buyCost: null, sellPrice: 180, category: 'gems', rarity: 'Epic' },-- mine_void_stone:{ name: 'Void Stone', emoji: '🕳️', desc: 'Absorbs light.', buyCost: null, sellPrice: 500, category: 'gems', rarity: 'Legendary' },-- // Bars-- bar_bronze: { name: 'Bronze Bar', emoji: '🟫', desc: 'Tin + Copper alloy.', buyCost: null, sellPrice: 16, category: 'bars', rarity: 'Common' },-- bar_copper: { name: 'Copper Bar', emoji: '🟤', desc: 'Pure copper.', buyCost: null, sellPrice: 18, category: 'bars', rarity: 'Common' },-- bar_iron: { name: 'Iron Bar', emoji: '🔩', desc: 'Crafting material.', buyCost: null, sellPrice: 25, category: 'bars', rarity: 'Common' },-- bar_steel: { name: 'Steel Bar', emoji: '🔗', desc: 'Iron + Coal alloy.', buyCost: null, sellPrice: 40, category: 'bars', rarity: 'Uncommon' },-- bar_gold: { name: 'Gold Bar', emoji: '🟡', desc: 'Valuable bar.', buyCost: null, sellPrice: 60, category: 'bars', rarity: 'Uncommon' },-- bar_mithril: { name: 'Mithril Bar', emoji: '💠', desc: 'Light and strong.', buyCost: null, sellPrice: 100, category: 'bars', rarity: 'Rare' },-- bar_titanium: { name: 'Titanium Bar', emoji: '⬜', desc: 'Incredibly durable.', buyCost: null, sellPrice: 130, category: 'bars', rarity: 'Rare' },-- bar_platinum: { name: 'Platinum Bar', emoji: '🪙', desc: 'Premium metal.', buyCost: null, sellPrice: 160, category: 'bars', rarity: 'Epic' },-- bar_dragonite: { name: 'Dragonite Bar', emoji: '🐉', desc: 'The finest bar.', buyCost: null, sellPrice: 450, category: 'bars', rarity: 'Legendary' },-- // Pickaxes (levelReq = mining level)-- pick_wood: { name: 'Wooden Pickaxe', emoji: '🪵', desc: 'Flimsy pick. Mines copper.', buyCost: 10, sellPrice: null, category: 'pickaxes', rarity: 'Common', levelReq: 1 },-- pick_stone: { name: 'Stone Pickaxe', emoji: '⛏️', desc: 'Mines iron.', buyCost: null, sellPrice: 8, category: 'pickaxes', rarity: 'Common', levelReq: 5 },-- pick_iron: { name: 'Iron Pickaxe', emoji: '⛏️', desc: 'Mines gold.', buyCost: 50, sellPrice: null, category: 'pickaxes', rarity: 'Uncommon', levelReq: 15 },-- pick_gold: { name: 'Gold Pickaxe', emoji: '✨', desc: 'Mines mithril.', buyCost: 150, sellPrice: null, category: 'pickaxes', rarity: 'Rare', levelReq: 30 },-- pick_diamond: { name: 'Diamond Pickaxe', emoji: '💎', desc: 'Mines everything.', buyCost: 400, sellPrice: null, category: 'pickaxes', rarity: 'Epic', levelReq: 45 },-- // Axes (levelReq = woodcut level)-- axe_stone: { name: 'Stone Axe', emoji: '🪓', desc: 'Basic axe.', buyCost: 8, sellPrice: null, category: 'axes', rarity: 'Common', levelReq: 1 },-- axe_iron: { name: 'Iron Axe', emoji: '⛏️', desc: 'Cuts hardwood.', buyCost: 40, sellPrice: null, category: 'axes', rarity: 'Uncommon', levelReq: 10 },-- axe_steel: { name: 'Steel Axe', emoji: '🔧', desc: 'Ancient trees.', buyCost: 120, sellPrice: null, category: 'axes', rarity: 'Rare', levelReq: 25 },-- axe_mythril: { name: 'Mythril Axe', emoji: '💠', desc: 'Cuts anything.', buyCost: 350, sellPrice: null, category: 'axes', rarity: 'Epic', levelReq: 40 },-- // Wood-- wood_twig: { name: 'Twig', emoji: '🌿', desc: 'A sad twig.', buyCost: null, sellPrice: 2, category: 'wood', rarity: 'Junk' },-- wood_oak: { name: 'Oak Log', emoji: '🪵', desc: 'Standard oak.', buyCost: null, sellPrice: 8, category: 'wood', rarity: 'Common' },-- wood_maple: { name: 'Maple Log', emoji: '🍁', desc: 'Beautiful maple.', buyCost: null, sellPrice: 16, category: 'wood', rarity: 'Common' },-- wood_birch: { name: 'Birch Log', emoji: '🌳', desc: 'Smooth birch.', buyCost: null, sellPrice: 24, category: 'wood', rarity: 'Uncommon' },-- wood_mahogany: { name: 'Mahogany Log', emoji: '🟤', desc: 'Dark hardwood.', buyCost: null, sellPrice: 40, category: 'wood', rarity: 'Uncommon' },-- wood_yew: { name: 'Yew Log', emoji: '🌲', desc: 'Ancient yew.', buyCost: null, sellPrice: 65, category: 'wood', rarity: 'Uncommon' },-- wood_elder: { name: 'Elderwood Log', emoji: '🧙', desc: 'Magical wood.', buyCost: null, sellPrice: 110, category: 'wood', rarity: 'Rare' },-- wood_crystal: { name: 'Crystal Wood', emoji: '💎', desc: 'Incredibly rare.', buyCost: null, sellPrice: 150, category: 'wood', rarity: 'Epic' },-- wood_sap: { name: 'Tree Sap', emoji: '🍯', desc: 'Sticky but useful.', buyCost: null, sellPrice: 12, category: 'wood', rarity: 'Common' },-- wood_spirit: { name: 'Tree Spirit Shard', emoji: '👻', desc: 'From an angry spirit.', buyCost: null, sellPrice: 350, category: 'wood', rarity: 'Legendary' },-- // Crafted-- craft_plank: { name: 'Wooden Plank', emoji: '🪵', desc: 'Building material.', buyCost: null, sellPrice: 15, category: 'crafted', rarity: 'Common' },-- craft_charcoal: { name: 'Charcoal', emoji: '⬛', desc: 'Refined fuel.', buyCost: null, sellPrice: 20, category: 'crafted', rarity: 'Uncommon' },-- craft_arrow: { name: 'Quiver of Arrows', emoji: '🏹', desc: 'Deadly arrows.', buyCost: null, sellPrice: 30, category: 'crafted', rarity: 'Uncommon' },-- craft_shield: { name: 'Wooden Shield', emoji: '🛡️', desc: 'Basic defense.', buyCost: null, sellPrice: 40, category: 'crafted', rarity: 'Uncommon' },-- craft_totem: { name: 'Spirit Totem', emoji: '🗿', desc: 'Carved totem.', buyCost: null, sellPrice: 100, category: 'crafted', rarity: 'Rare' },-- craft_wand: { name: 'Crystal Wand', emoji: '🪄', desc: 'Magical focus.', buyCost: null, sellPrice: 180, category: 'crafted', rarity: 'Epic' },-- craft_crown: { name: 'Crown of Thorns', emoji: '👑', desc: 'Painful but regal.', buyCost: null, sellPrice: 250, category: 'crafted', rarity: 'Epic' },-- craft_golem: { name: 'Iron Golem Core', emoji: '🤖', desc: 'Metal beast heart.', buyCost: null, sellPrice: 300, category: 'crafted', rarity: 'Epic' },-- // Consumables-- craft_elixir: { name: 'Elixir of Fortune', emoji: '🧪', desc: '2x coins 10 min.', buyCost: null, sellPrice: 60, category: 'consumable', rarity: 'Uncommon' },-- craft_xp_potion: { name: 'XP Boost Potion', emoji: '⚗️', desc: '2x XP 10 min.', buyCost: null, sellPrice: 50, category: 'consumable', rarity: 'Uncommon' },-- craft_loot_magnet: { name: 'Loot Magnet', emoji: '🧲', desc: 'Better loot 5 actions.', buyCost: null, sellPrice: 80, category: 'consumable', rarity: 'Uncommon' },-- supp_stamina: { name: 'Stamina Pill', emoji: '⚡', desc: '+50 stamina.', buyCost: 20, sellPrice: 8, category: 'consumable', rarity: 'Common' },-- farm_plot: { name: 'Farm Plot Deed', emoji: '📜', desc: 'Extra farm plot.', buyCost: 200, sellPrice: null, category: 'tools', rarity: 'Epic' },-- fertilizer: { name: 'Fertilizer', emoji: '💩', desc: '2x crop yield.', buyCost: 40, sellPrice: null, category: 'tools', rarity: 'Uncommon' },-- // Weapons & Armor (levelReq = combat level)-- weapon_fist: { name: 'Fists', emoji: '👊', desc: '+0 ATK. Punch stuff!', buyCost: null, sellPrice: null, category: 'weapons', rarity: 'Junk', levelReq: 1, weaponSpeed: 1.0, weaponRange: 1.0 },-- weapon_rock: { name: 'Sharp Rock', emoji: '🪨', desc: '+1 ATK. Fast but short.', buyCost: null, sellPrice: 1, category: 'weapons', rarity: 'Junk', levelReq: 1, weaponSpeed: 1.2, weaponRange: 0.8 },-- weapon_stick: { name: 'Wooden Stick', emoji: '🏏', desc: '+2 ATK. Long reach.', buyCost: 5, sellPrice: 2, category: 'weapons', rarity: 'Common', levelReq: 1, weaponSpeed: 1.3, weaponRange: 1.2 },-- weapon_sword: { name: 'Iron Sword', emoji: '⚔️', desc: '+8 ATK. Balanced.', buyCost: 80, sellPrice: 30, category: 'weapons', rarity: 'Uncommon', levelReq: 8, weaponSpeed: 1.0, weaponRange: 1.2 },-- weapon_axe: { name: 'Battle Axe', emoji: '🪓', desc: '+15 ATK. Slow & brutal.', buyCost: 250, sellPrice: 90, category: 'weapons', rarity: 'Rare', levelReq: 18, weaponSpeed: 0.7, weaponRange: 1.0 },-- weapon_katana: { name: 'Shadow Katana', emoji: '🗡️', desc: '+25 ATK. Lightning fast.', buyCost: 600, sellPrice: 220, category: 'weapons', rarity: 'Epic', levelReq: 30, weaponSpeed: 1.4, weaponRange: 1.3 },-- weapon_legendary: { name: 'Dragonslayer', emoji: '🐲', desc: '+40 ATK. The ultimate.', buyCost: null, sellPrice: 800, category: 'weapons', rarity: 'Legendary', levelReq: 45, weaponSpeed: 1.1, weaponRange: 1.4 },-- armor_cloth: { name: 'Cloth Armor', emoji: '👕', desc: '+3 DEF.', buyCost: 10, sellPrice: 4, category: 'armor', rarity: 'Common', levelReq: 1 },-- armor_leather: { name: 'Leather Armor', emoji: '🦺', desc: '+8 DEF.', buyCost: 60, sellPrice: 22, category: 'armor', rarity: 'Uncommon', levelReq: 5 },-- armor_chain: { name: 'Chainmail', emoji: '⛓️', desc: '+15 DEF.', buyCost: 200, sellPrice: 75, category: 'armor', rarity: 'Rare', levelReq: 15 },-- armor_plate: { name: 'Plate Armor', emoji: '🛡️', desc: '+25 DEF.', buyCost: 500, sellPrice: 185, category: 'armor', rarity: 'Epic', levelReq: 30 },-- armor_dragonscale: { name: 'Dragonscale Armor', emoji: '🐉', desc: '+40 DEF.', buyCost: null, sellPrice: 700, category: 'armor', rarity: 'Legendary', levelReq: 45 },-- // Hats-- hat_basic_cap: { name: 'Basic Cap', emoji: '🧢', desc: 'Simple cap.', buyCost: 50, sellPrice: 20, category: 'hats', rarity: 'Common' },-- hat_cowboy: { name: 'Cowboy Hat', emoji: '🤠', desc: 'Yeehaw.', buyCost: null, sellPrice: 100, category: 'hats', rarity: 'Uncommon' },-- hat_wizard: { name: 'Wizard Hat', emoji: '🧙', desc: 'Magical.', buyCost: null, sellPrice: 200, category: 'hats', rarity: 'Rare' },-- hat_crown: { name: 'Royal Crown', emoji: '👑', desc: 'Royalty.', buyCost: null, sellPrice: 500, category: 'hats', rarity: 'Epic' },-- hat_halo: { name: 'Halo', emoji: '😇', desc: 'Angelic.', buyCost: null, sellPrice: 800, category: 'hats', rarity: 'Legendary' },-- hat_void_crown: { name: 'Void Crown', emoji: '🕳️', desc: 'Dungeon only.', buyCost: null, sellPrice: 1500,category: 'hats', rarity: 'Mythic' },-- // Name & Particle Effects-- fx_rainbow: { name: 'Rainbow Name', emoji: '🌈', desc: 'Rainbow cycling!', buyCost: 1500, sellPrice: null, category: 'name_effects', rarity: 'Rare', coinPrice: 500 },-- fx_fire: { name: 'Fire Name', emoji: '🔥', desc: 'Blazing flames!', buyCost: 2000, sellPrice: null, category: 'name_effects', rarity: 'Rare', coinPrice: 650 },-- fx_ice: { name: 'Ice Name', emoji: '❄️', desc: 'Frosty glow!', buyCost: 2000, sellPrice: null, category: 'name_effects', rarity: 'Rare', coinPrice: 650 },-- fx_golden: { name: 'Golden Name', emoji: '👑', desc: 'Golden shine!', buyCost: 3500, sellPrice: null, category: 'name_effects', rarity: 'Epic', coinPrice: 1200 },-- fx_neon: { name: 'Neon Name', emoji: '💡', desc: 'Neon pulse!', buyCost: 3500, sellPrice: null, category: 'name_effects', rarity: 'Epic', coinPrice: 1200 },-- fx_galaxy: { name: 'Galaxy Name', emoji: '🌌', desc: 'Cosmic swirl!', buyCost: 5000, sellPrice: null, category: 'name_effects', rarity: 'Epic', coinPrice: 1800 },-- fx_void: { name: 'Void Name', emoji: '🕳️', desc: 'Warps space!', buyCost: 15000, sellPrice: null, category: 'name_effects', rarity: 'Legendary', coinPrice: 5000 },-- fx_toxic: { name: 'Toxic Name', emoji: '☠️', desc: 'Toxic drip!', buyCost: 2000, sellPrice: null, category: 'name_effects', rarity: 'Rare', coinPrice: 650 },-- fx_blood: { name: 'Blood Name', emoji: '🩸', desc: 'Dripping blood!', buyCost: 5000, sellPrice: null, category: 'name_effects', rarity: 'Epic', coinPrice: 1800 },-- fx_shadow: { name: 'Shadow Name', emoji: '🌑', desc: 'Dark shadow!', buyCost: 5000, sellPrice: null, category: 'name_effects', rarity: 'Epic', coinPrice: 1800 },-- fx_glitch: { name: 'Glitch Name', emoji: '📟', desc: 'Digital glitch!', buyCost: 5000, sellPrice: null, category: 'name_effects', rarity: 'Epic', coinPrice: 1800 },-- fx_hologram: { name: 'Hologram Name', emoji: '🔮', desc: 'Holographic shimmer!', buyCost: 10000, sellPrice: null, category: 'name_effects', rarity: 'Legendary', coinPrice: 3500 },-- fx_divine: { name: 'Divine Name', emoji: '✝️', desc: 'Divine radiance!', buyCost: 15000, sellPrice: null, category: 'name_effects', rarity: 'Legendary', coinPrice: 5000 },-- px_sparkle: { name: 'Sparkle Particles', emoji: '✨', desc: 'Sparkles!', buyCost: 1000, sellPrice: null, category: 'particles', rarity: 'Rare', coinPrice: 350 },-- px_hearts: { name: 'Heart Particles', emoji: '💖', desc: 'Hearts!', buyCost: 1500, sellPrice: null, category: 'particles', rarity: 'Rare', coinPrice: 500 },-- px_flames: { name: 'Flame Particles', emoji: '🔥', desc: 'Embers!', buyCost: 3000, sellPrice: null, category: 'particles', rarity: 'Epic', coinPrice: 1000 },-- px_stars: { name: 'Star Particles', emoji: '⭐', desc: 'Stars orbit!', buyCost: 3500, sellPrice: null, category: 'particles', rarity: 'Epic', coinPrice: 1200 },-- px_void: { name: 'Void Particles', emoji: '🕳️', desc: 'Dark matter!', buyCost: 8000, sellPrice: null, category: 'particles', rarity: 'Legendary', coinPrice: 2800 },-- // Voices (TTS cosmetics — activate to unlock globally)-- voice_deep: { name: 'Deep Voice', emoji: '🎵', desc: 'Low & rumbly TTS.', buyCost: 800, sellPrice: null, category: 'voices', rarity: 'Uncommon', coinPrice: 300 },-- voice_chipmunk: { name: 'Chipmunk Voice', emoji: '🐿️', desc: 'Squeaky & fast TTS.', buyCost: 800, sellPrice: null, category: 'voices', rarity: 'Uncommon', coinPrice: 300 },-- voice_robot: { name: 'Robot Voice', emoji: '🤖', desc: 'Monotone machine TTS.', buyCost: 2000, sellPrice: null, category: 'voices', rarity: 'Rare', coinPrice: 700 },-- voice_whisper: { name: 'Whisper Voice', emoji: '🤫', desc: 'Quiet & eerie TTS.', buyCost: 2000, sellPrice: null, category: 'voices', rarity: 'Rare', coinPrice: 700 },-- voice_demon: { name: 'Demon Voice', emoji: '😈', desc: 'From the underworld TTS.',buyCost: 5000, sellPrice: null, category: 'voices', rarity: 'Epic', coinPrice: 1800 },-- // Legacy voices (ported from RS-Companion)-- voice_gary: { name: 'Gary', emoji: '🔊', desc: 'Standard voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Common' },-- voice_brenda: { name: 'Brenda', emoji: '👩', desc: 'Friendly female voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Common' },-- voice_chadbot: { name: 'ChadBot', emoji: '💪', desc: 'Deep bro voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Uncommon' },-- voice_karen: { name: 'Karen', emoji: '💅', desc: 'Manager-seeking voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Uncommon' },-- voice_crackhead: { name: 'CrackheadCarl', emoji: '💊', desc: 'Manic energy voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Rare' },-- voice_squeakmaster:{ name: 'SqueakMaster', emoji: '🐭', desc: 'Ultra-squeaky voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Uncommon' },-- voice_bigchungus: { name: 'BigChungus', emoji: '🐰', desc: 'Absolute unit voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Uncommon' },-- voice_tweaker: { name: 'Tweaker', emoji: '⚡', desc: 'Fast & nervous voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Uncommon' },-- voice_grandpa: { name: 'Grandpa', emoji: '👴', desc: 'Old & wise voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Uncommon' },-- voice_ghostgirl: { name: 'GhostGirl', emoji: '👻', desc: 'Eerie whisper voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Rare' },-- voice_robotoverlord:{ name: 'RobotOverlord', emoji: '🤖', desc: 'Machine overlord voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Rare' },-- voice_sassybitch: { name: 'SassyBitch', emoji: '💁', desc: 'Sassy attitude voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Rare' },-- voice_helium: { name: 'Helium', emoji: '🎈', desc: 'Squeaky helium voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Uncommon' },-- voice_britbong: { name: 'BritBong', emoji: '🇬🇧', desc: 'British accent voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Uncommon' },-- voice_yeehaw: { name: 'YeeHaw', emoji: '🤠', desc: 'Southern drawl voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Uncommon' },-- voice_nyc: { name: 'NYC', emoji: '🗽', desc: 'New York accent voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Uncommon' },-- voice_french: { name: 'French', emoji: '🇫🇷', desc: 'French accent voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Uncommon' },-- voice_chatterbox: { name: 'Chatterbox', emoji: '💬', desc: 'Achievement voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Epic' },-- voice_fisherman: { name: 'Fisherman', emoji: '🎣', desc: 'Achievement voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Epic' },-- voice_gc_smooth: { name: 'Smooth Operator', emoji: '🎤', desc: 'Google Cloud TTS.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Epic' },-- voice_gc_sally: { name: 'Silicon Sally', emoji: '🎤', desc: 'Google Cloud TTS.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Epic' },-- voice_gc_butler: { name: 'British Butler', emoji: '🎤', desc: 'Google Cloud TTS.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Epic' },-- voice_gc_london: { name: 'Lady London', emoji: '🎤', desc: 'Google Cloud TTS.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Epic' },-- voice_gc_mumbai: { name: 'Mumbai Mike', emoji: '🎤', desc: 'Google Cloud TTS.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Epic' },-- voice_gc_downunder:{ name: 'Down Under', emoji: '🎤', desc: 'Google Cloud TTS.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Epic' },-- voice_gc_sheila: { name: 'Sheila', emoji: '🎤', desc: 'Google Cloud TTS.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Epic' },-- voice_gc_studio_f: { name: 'Studio Female', emoji: '🎙️', desc: 'Google Studio voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Legendary' },-- voice_gc_studio_m: { name: 'Studio Male', emoji: '🎙️', desc: 'Google Studio voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Legendary' },-- voice_pl_joanna: { name: 'Joanna', emoji: '🎤', desc: 'Amazon Polly voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Epic' },-- voice_pl_matthew: { name: 'Matthew', emoji: '🎤', desc: 'Amazon Polly voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Epic' },-- voice_pl_amy: { name: 'Amy', emoji: '🎤', desc: 'Amazon Polly voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Epic' },-- voice_pl_brian: { name: 'Brian', emoji: '🎤', desc: 'Amazon Polly voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Epic' },-- voice_pl_olivia: { name: 'Olivia', emoji: '🎤', desc: 'Amazon Polly voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Epic' },-- voice_pl_danielle: { name: 'Danielle', emoji: '🎤', desc: 'Amazon Polly Long voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Legendary' },-- voice_pl_gregory: { name: 'Gregory', emoji: '🎤', desc: 'Amazon Polly Long voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Legendary' },-- voice_pl_greg_n: { name: 'Gregory Neural', emoji: '🧠', desc: 'Amazon Polly Neural voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Legendary' },-- voice_pl_ruth: { name: 'Ruth', emoji: '🎤', desc: 'Amazon Polly Long voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Legendary' },-- voice_pl_arthur: { name: 'Arthur Neural', emoji: '🧠', desc: 'Amazon Polly Neural voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Mythic' },-- voice_dwarf_lord: { name: 'Dwarf Lord', emoji: '⛏️', desc: 'Skill mastery voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Mythic' },-- // Health-- potion_health: { name: 'Health Potion', emoji: '❤️', desc: 'Restores 30 HP.', buyCost: 15, sellPrice: 6, category: 'consumable', rarity: 'Common' },-- potion_health_big: { name: 'Greater Health Pot', emoji: '💖', desc: 'Restores 75 HP.', buyCost: 45, sellPrice: 18, category: 'consumable', rarity: 'Uncommon' },-- potion_stamina: { name: 'Stamina Potion', emoji: '⚡', desc: 'Restores 30 STA.', buyCost: 12, sellPrice: 5, category: 'consumable', rarity: 'Common' },-- food_campfire_meal:{ name: 'Burn Barrel Meal', emoji: '🍖', desc: 'HP over time.', buyCost: 25, sellPrice: 10, category: 'consumable', rarity: 'Uncommon' },-- // Cooked Food (grants buffs — cook at the burn barrel or craft)-- food_cooked_fish: { name: 'Cooked Fish', emoji: '🍽️', desc: '+20 HP instantly.', buyCost: null, sellPrice: 8, category: 'food', rarity: 'Common' },-- food_fish_stew: { name: 'Fish Stew', emoji: '🍲', desc: '+40 HP, +5 ATK 3 min.', buyCost: null, sellPrice: 25, category: 'food', rarity: 'Uncommon' },-- food_grilled_meat: { name: 'Grilled Meat', emoji: '🥩', desc: '+30 HP instantly.', buyCost: null, sellPrice: 15, category: 'food', rarity: 'Common' },-- food_veggie_soup: { name: 'Veggie Soup', emoji: '🥣', desc: '+25 HP, +5 DEF 3 min.', buyCost: null, sellPrice: 20, category: 'food', rarity: 'Uncommon' },-- food_golden_feast: { name: 'Golden Feast', emoji: '🍽️', desc: '+75 HP, +10 ATK/DEF 5 min.', buyCost: null, sellPrice: 100, category: 'food', rarity: 'Epic' },-- food_trail_mix: { name: 'Trail Mix', emoji: '🥜', desc: '+30 STA, free sprint 2 min.', buyCost: null, sellPrice: 12, category: 'food', rarity: 'Common' },-- food_pumpkin_pie: { name: 'Pumpkin Pie', emoji: '🥧', desc: '+50 HP, +2x XP 2 min.', buyCost: null, sellPrice: 50, category: 'food', rarity: 'Rare' },-- food_energy_bar: { name: 'Energy Bar', emoji: '🍫', desc: '+50 STA, +20 max STA 5 min.', buyCost: null, sellPrice: 30, category: 'food', rarity: 'Uncommon' },-- // Loot drops-- loot_lint: { name: 'Pocket Lint', emoji: '🧶', desc: 'Always there.', buyCost: null, sellPrice: 1, category: 'junk', rarity: 'Junk' },-- loot_coin_pouch: { name: 'Coin Pouch', emoji: '👛', desc: 'Some coins.', buyCost: null, sellPrice: 15, category: 'loot', rarity: 'Common' },-- loot_feather: { name: 'Phoenix Feather', emoji: '🪶', desc: 'Warm touch.', buyCost: null, sellPrice: 20, category: 'loot', rarity: 'Uncommon' },-- loot_crown: { name: 'Tiny Crown', emoji: '👑', desc: 'Hamster king.', buyCost: null, sellPrice: 60, category: 'loot', rarity: 'Rare' },-- loot_egg: { name: 'Dragon Egg', emoji: '🥚', desc: 'Is it moving??', buyCost: null, sellPrice: 200, category: 'loot', rarity: 'Epic' },-- loot_star: { name: 'Fallen Star', emoji: '🌟', desc: 'Piece of cosmos.', buyCost: null, sellPrice: 500, category: 'loot', rarity: 'Legendary' },-- loot_void_heart: { name: 'Void Heart', emoji: '🖤', desc: 'Dying universe.', buyCost: null, sellPrice: 1000, category: 'loot', rarity: 'Legendary' },-- // Dungeon keys-- dungeon_key: { name: 'Dungeon Key', emoji: '🗝️', desc: 'Opens dungeon.', buyCost: 100, sellPrice: 40, category: 'tools', rarity: 'Rare' },--};----// ── Structures (buildable in the world) ──────────────────────--const STRUCTURES = {-- wall_wood: { name: 'Wood Wall', emoji: '🟫', hp: 200, cost: { craft_plank: 3 }, category: 'wall' },-- wall_stone: { name: 'Stone Wall', emoji: '🧱', hp: 500, cost: { bar_iron: 2, ore_gravel: 5 }, category: 'wall' },-- door_wood: { name: 'Wood Door', emoji: '🚪', hp: 150, cost: { craft_plank: 2, bar_iron: 1 }, category: 'door' },-- floor_wood: { name: 'Wood Floor', emoji: '📋', hp: 150, cost: { craft_plank: 2 }, category: 'floor' },-- workbench: { name: 'Workbench', emoji: '🔨', hp: 200, cost: { craft_plank: 5, bar_iron: 1 }, category: 'station' },-- furnace: { name: 'Furnace', emoji: '🔥', hp: 250, cost: { ore_gravel: 10, bar_iron: 2 }, category: 'station' },-- storage_box: { name: 'Storage Box', emoji: '📦', hp: 100, cost: { craft_plank: 3 }, category: 'storage' },-- sleeping_bag: { name: 'Sleeping Bag', emoji: '🛏️', hp: 50, cost: { crop_wheat: 5 }, category: 'spawn' },-- campfire: { name: 'Burn Barrel', emoji: '🛢️', hp: 50, cost: { wood_oak: 5 }, category: 'comfort' },-- tool_cupboard: { name: 'Tool Cupboard', emoji: '🧰', hp: 300, cost: { craft_plank: 5, bar_iron: 2 }, category: 'auth' },--};----// ── Rod Tiers (Toontown-style weight limits) ─────────────────--const ROD_TIERS = {-- rod_bamboo: { tier: 0, maxWeight: 4, staminaCost: 3, name: 'Bamboo' },-- rod_fiberglass: { tier: 1, maxWeight: 8, staminaCost: 5, name: 'Fiberglass' },-- rod_carbon: { tier: 2, maxWeight: 12, staminaCost: 7, name: 'Carbon' },-- rod_titanium: { tier: 3, maxWeight: 16, staminaCost: 9, name: 'Titanium' },-- rod_mythril: { tier: 4, maxWeight: 20, staminaCost: 12, name: 'Mythril' },--};----// ── Fish Species Catalog (Toontown-inspired) ─────────────────--// Each species: family, weight range (lbs), min rod tier, which zones, rarity weight for loot tables--const FISH_SPECIES = {-- // — Minnow Family (Tier 0) —-- fish_minnow: { family: 'Minnow', minW: 0.5, maxW: 2, rodTier: 0, zones: ['shallow','river'], rarityWeight: 30 },-- fish_sunfish: { family: 'Minnow', minW: 1, maxW: 3, rodTier: 0, zones: ['shallow','river'], rarityWeight: 25 },-- // — Bass Family (Tier 0) —-- fish_bass: { family: 'Bass', minW: 1, maxW: 4, rodTier: 0, zones: ['shallow','river','deep'], rarityWeight: 28 },-- fish_perch: { family: 'Bass', minW: 1, maxW: 3, rodTier: 0, zones: ['shallow','river'], rarityWeight: 26 },-- // — Trout Family (Tier 0) —-- fish_trout: { family: 'Trout', minW: 1, maxW: 4, rodTier: 0, zones: ['shallow','river','arctic'],rarityWeight: 24 },-- fish_clownfish: { family: 'Trout', minW: 0.5, maxW: 2, rodTier: 0, zones: ['shallow'], rarityWeight: 20 },-- // — Catfish Family (Tier 1) —-- fish_catfish: { family: 'Catfish', minW: 3, maxW: 7, rodTier: 1, zones: ['river','deep'], rarityWeight: 18 },-- fish_carp: { family: 'Catfish', minW: 3, maxW: 6, rodTier: 1, zones: ['river'], rarityWeight: 20 },-- fish_pike: { family: 'Catfish', minW: 4, maxW: 8, rodTier: 1, zones: ['river','deep'], rarityWeight: 14 },-- // — Salmon Family (Tier 1) —-- fish_salmon: { family: 'Salmon', minW: 3, maxW: 7, rodTier: 1, zones: ['river','deep','arctic'], rarityWeight: 18 },-- fish_rainbow_trout:{ family: 'Salmon', minW: 4, maxW: 8, rodTier: 1, zones: ['river','arctic'], rarityWeight: 14 },-- fish_king_salmon: { family: 'Salmon', minW: 5, maxW: 8, rodTier: 1, zones: ['arctic'], rarityWeight: 8 },-- // — Puffer Family (Tier 1) —-- fish_pufferfish: { family: 'Puffer', minW: 2, maxW: 5, rodTier: 1, zones: ['shallow','deep'], rarityWeight: 16 },-- fish_jellyfish: { family: 'Puffer', minW: 2, maxW: 6, rodTier: 1, zones: ['deep'], rarityWeight: 14 },-- // — Eel Family (Tier 2) —-- fish_electric_eel: { family: 'Eel', minW: 5, maxW: 10, rodTier: 2, zones: ['river','deep'], rarityWeight: 10 },-- fish_stingray: { family: 'Eel', minW: 5, maxW: 9, rodTier: 2, zones: ['shallow','deep'], rarityWeight: 12 },-- // — Swordfish Family (Tier 2) —-- fish_swordfish: { family: 'Swordfish', minW: 6, maxW: 11, rodTier: 2, zones: ['deep'], rarityWeight: 10 },-- fish_marlin: { family: 'Swordfish', minW: 7, maxW: 12, rodTier: 2, zones: ['deep'], rarityWeight: 8 },-- fish_barracuda: { family: 'Swordfish', minW: 6, maxW: 10, rodTier: 2, zones: ['deep','arctic'], rarityWeight: 10 },-- // — Deep Sea Family (Tier 2) —-- fish_anglerfish: { family: 'DeepSea', minW: 5, maxW: 10, rodTier: 2, zones: ['deep'], rarityWeight: 9 },-- fish_octopus: { family: 'DeepSea', minW: 6, maxW: 12, rodTier: 2, zones: ['deep'], rarityWeight: 8 },-- // — Shark Family (Tier 3) —-- fish_shark: { family: 'Shark', minW: 10, maxW: 15, rodTier: 3, zones: ['deep'], rarityWeight: 5 },-- fish_hammerhead: { family: 'Shark', minW: 12, maxW: 16, rodTier: 3, zones: ['deep'], rarityWeight: 4 },-- fish_ghost_fish: { family: 'Shark', minW: 10, maxW: 14, rodTier: 3, zones: ['arctic'], rarityWeight: 5 },-- // — Whale Family (Tier 3) —-- fish_whale: { family: 'Whale', minW: 12, maxW: 16, rodTier: 3, zones: ['deep','arctic'], rarityWeight: 4 },-- fish_narwhal: { family: 'Whale', minW: 13, maxW: 16, rodTier: 3, zones: ['arctic'], rarityWeight: 3 },-- // — Legendary Family (Tier 4) —-- fish_kraken: { family: 'Legendary', minW: 15, maxW: 19, rodTier: 4, zones: ['deep'], rarityWeight: 2 },-- fish_golden: { family: 'Legendary', minW: 16, maxW: 20, rodTier: 4, zones: ['arctic'], rarityWeight: 2 },-- fish_leviathan: { family: 'Legendary', minW: 18, maxW: 20, rodTier: 4, zones: ['deep'], rarityWeight: 1 },--};----// Junk items in fishing (always available regardless of rod)--const FISH_JUNK = [-- { id: 'fish_boot', weight: 10 },-- { id: 'fish_tin_can', weight: 8 },--];----// Build zone loot tables dynamically from FISH_SPECIES + rod tier--function buildFishTable(zone, rodTier) {-- const table = [];-- for (const [fishId, spec] of Object.entries(FISH_SPECIES)) {-- if (spec.rodTier > rodTier) continue;-- if (!spec.zones.includes(zone)) continue;-- table.push({ id: fishId, weight: spec.rarityWeight });-- }-- // Add junk (reduced by rod tier)-- const junkMult = Math.max(0.2, 1 - rodTier * 0.18);-- for (const junk of FISH_JUNK) {-- table.push({ id: junk.id, weight: Math.max(1, Math.round(junk.weight * junkMult)) });-- }-- return table;--}----// ── Loot Tables by Difficulty Tier ───────────────────────────--const WOOD_TABLES = [-- // Tier 0: easy (near outpost)-- [{ id: 'wood_twig', weight: 20 }, { id: 'wood_oak', weight: 45 }, { id: 'wood_maple', weight: 25 }, { id: 'wood_sap', weight: 10 }],-- // Tier 1: medium-- [{ id: 'wood_oak', weight: 15 }, { id: 'wood_maple', weight: 25 }, { id: 'wood_birch', weight: 25 }, { id: 'wood_mahogany', weight: 20 }, { id: 'wood_sap', weight: 10 }, { id: 'wood_yew', weight: 5 }],-- // Tier 2: hard-- [{ id: 'wood_mahogany', weight: 15 }, { id: 'wood_yew', weight: 25 }, { id: 'wood_elder', weight: 25 }, { id: 'wood_sap', weight: 10 }, { id: 'wood_crystal', weight: 10 }, { id: 'loot_crown', weight: 3 }],-- // Tier 3: brutal (map edges)-- [{ id: 'wood_elder', weight: 25 }, { id: 'wood_crystal', weight: 25 }, { id: 'wood_spirit', weight: 8 }, { id: 'wood_yew', weight: 15 }, { id: 'loot_egg', weight: 2 }, { id: 'loot_star', weight: 1 }],--];----const MINE_TABLES = [-- [{ id: 'ore_gravel', weight: 30 }, { id: 'ore_coal', weight: 30 }, { id: 'ore_copper', weight: 20 }, { id: 'ore_iron', weight: 15 }, { id: 'gem_ruby', weight: 4 }, { id: 'loot_coin_pouch', weight: 1 }],-- [{ id: 'ore_coal', weight: 15 }, { id: 'ore_iron', weight: 25 }, { id: 'ore_gold', weight: 20 }, { id: 'gem_ruby', weight: 10 }, { id: 'gem_emerald', weight: 8 }, { id: 'ore_mithril', weight: 5 }, { id: 'gem_diamond', weight: 3 }],-- [{ id: 'ore_gold', weight: 15 }, { id: 'ore_mithril', weight: 20 }, { id: 'ore_titanium', weight: 15 }, { id: 'gem_emerald', weight: 12 }, { id: 'gem_diamond', weight: 10 }, { id: 'ore_platinum', weight: 8 }, { id: 'gem_star', weight: 5 }],-- [{ id: 'ore_titanium', weight: 15 }, { id: 'ore_platinum', weight: 20 }, { id: 'gem_diamond', weight: 15 }, { id: 'gem_star', weight: 12 }, { id: 'ore_dragonite', weight: 10 }, { id: 'mine_void_stone', weight: 5 }, { id: 'loot_void_heart', weight: 1 }],--];----const FISH_TABLES = [-- // Legacy tier tables — rebuilt from FISH_SPECIES for gather() compat-- buildFishTable('shallow', 0),-- buildFishTable('river', 1),-- buildFishTable('deep', 2),-- buildFishTable('deep', 4),--];----// Water zone fish tables — now dynamically built per rod tier at catch time--// These static tables are kept as the default (tier-0 rod) for backward compat--const WATER_ZONE_TABLES = {-- shallow: buildFishTable('shallow', 0),-- river: buildFishTable('river', 0),-- deep: buildFishTable('deep', 0),-- arctic: buildFishTable('arctic', 0),--};----function getWaterZone(tx, ty, seed) {-- // Check neighboring tiles to determine water zone type-- let hasSnow = false, hasSand = false, hasLand = false, waterCount = 0;-- for (let dx = -2; dx <= 2; dx++) {-- for (let dy = -2; dy <= 2; dy++) {-- if (dx === 0 && dy === 0) continue;-- const b = getBiomeAt(tx + dx, ty + dy, seed);-- if (b === 'snow') hasSnow = true;-- if (b === 'sand') hasSand = true;-- if (b !== 'water') hasLand = true;-- else waterCount++;-- }-- }-- if (hasSnow) return 'arctic';-- if (hasSand && hasLand) return 'shallow';-- if (hasLand && waterCount <= 16) return 'river';-- return 'deep';--}----// ── Crops ────────────────────────────────────────────────────--const CROPS = {-- seed_wheat: { growTime: 30 * 60000, output: 'crop_wheat', yield: [1, 3], mutation: null },-- seed_carrot: { growTime: 45 * 60000, output: 'crop_carrot', yield: [1, 3], mutation: null },-- seed_tomato: { growTime: 60 * 60000, output: 'crop_tomato', yield: [1, 2], mutation: null },-- seed_corn: { growTime: 90 * 60000, output: 'crop_corn', yield: [1, 2], mutation: null },-- seed_pumpkin: { growTime: 120 * 60000, output: 'crop_pumpkin', yield: [1, 2], mutation: null },-- seed_golden: { growTime: 180 * 60000, output: 'crop_golden', yield: [1, 1], mutation: null },--};----// ── Monsters ─────────────────────────────────────────────────--const MONSTERS = [-- { name: 'Rat', emoji: '🐀', hp: 20, atk: 3, def: 1, xp: 10, gold: 5, loot: [{ id: 'loot_lint', w: 80 }, { id: 'loot_coin_pouch', w: 20 }] },-- { name: 'Skeleton', emoji: '💀', hp: 40, atk: 8, def: 3, xp: 25, gold: 12, loot: [{ id: 'loot_coin_pouch', w: 50 }, { id: 'weapon_stick', w: 30 }, { id: 'loot_feather', w: 20 }] },-- { name: 'Spider', emoji: '🕷️', hp: 35, atk: 10, def: 2, xp: 20, gold: 10, loot: [{ id: 'bait_worm', w: 40 }, { id: 'loot_feather', w: 40 }, { id: 'loot_crown', w: 20 }] },-- { name: 'Goblin', emoji: '👺', hp: 50, atk: 12, def: 5, xp: 35, gold: 20, loot: [{ id: 'loot_coin_pouch', w: 40 }, { id: 'ore_gold', w: 30 }, { id: 'loot_crown', w: 30 }] },-- { name: 'Slime King', emoji: '🟢', hp: 80, atk: 6, def: 10, xp: 50, gold: 30, loot: [{ id: 'gem_emerald', w: 30 }, { id: 'loot_egg', w: 20 }, { id: 'hat_cowboy', w: 5 }] },-- { name: 'Dark Knight', emoji: '🗡️', hp: 100, atk: 18, def: 12, xp: 75, gold: 45, loot: [{ id: 'weapon_sword', w: 20 }, { id: 'armor_chain', w: 15 }, { id: 'gem_diamond', w: 25 }] },-- { name: 'Fire Elemental',emoji:'🔥', hp: 120, atk: 22, def: 8, xp: 90, gold: 55, loot: [{ id: 'gem_ruby', w: 25 }, { id: 'bar_gold', w: 20 }, { id: 'hat_wizard', w: 5 }] },-- { name: 'Dragon', emoji: '🐉', hp: 250, atk: 35, def: 25, xp: 200, gold: 150, loot: [{ id: 'ore_dragonite', w: 20 }, { id: 'weapon_katana', w: 10 }, { id: 'armor_dragonscale', w: 5 }, { id: 'weapon_legendary', w: 2 }] },-- { name: 'Void Wraith', emoji: '👻', hp: 300, atk: 40, def: 30, xp: 300, gold: 200, loot: [{ id: 'mine_void_stone', w: 15 }, { id: 'hat_void_crown', w: 3 }, { id: 'loot_void_heart', w: 5 }] },--];----// ── Recipes ──────────────────────────────────────────────────--const RECIPES = {-- // Starter tool crafting (fist → stone tier)-- pick_wood: { name: 'Wooden Pickaxe', inputs: { raw_stick: 3, raw_stone: 2 }, output: 'pick_wood', qty: 1, rarity: 'Common', xp: 5 },-- pick_stone: { name: 'Stone Pickaxe', inputs: { raw_stone: 5, raw_stick: 2, raw_flint: 1 }, output: 'pick_stone', qty: 1, rarity: 'Common', xp: 10 },-- axe_stone: { name: 'Stone Axe', inputs: { raw_stone: 3, raw_stick: 2 }, output: 'axe_stone', qty: 1, rarity: 'Common', xp: 5 },-- weapon_rock: { name: 'Sharp Rock', inputs: { raw_stone: 2, raw_flint: 1 }, output: 'weapon_rock', qty: 1, rarity: 'Junk', xp: 3 },-- rod_bamboo: { name: 'Bamboo Rod', inputs: { raw_stick: 3, bait_worm: 1 }, output: 'rod_bamboo', qty: 1, rarity: 'Common', xp: 5 },-- craft_plank: { name: 'Wooden Plank', inputs: { wood_oak: 2 }, output: 'craft_plank', qty: 3, rarity: 'Common', xp: 10 },-- craft_charcoal: { name: 'Charcoal', inputs: { wood_oak: 1, ore_coal: 1 }, output: 'craft_charcoal', qty: 2, rarity: 'Common', xp: 15 },-- craft_arrow: { name: 'Arrows', inputs: { craft_plank: 1, ore_iron: 1 }, output: 'craft_arrow', qty: 1, rarity: 'Uncommon', xp: 25 },-- craft_shield: { name: 'Wooden Shield', inputs: { craft_plank: 3, bar_iron: 1 }, output: 'craft_shield', qty: 1, rarity: 'Uncommon', xp: 30 },-- craft_totem: { name: 'Spirit Totem', inputs: { wood_elder: 2, gem_ruby: 1 }, output: 'craft_totem', qty: 1, rarity: 'Rare', xp: 60 },-- craft_wand: { name: 'Crystal Wand', inputs: { wood_crystal: 1, gem_diamond: 1 }, output: 'craft_wand', qty: 1, rarity: 'Epic', xp: 100 },-- craft_crown: { name: 'Crown of Thorns', inputs: { bar_gold: 2, gem_emerald: 1 }, output: 'craft_crown', qty: 1, rarity: 'Epic', xp: 120 },-- craft_golem: { name: 'Iron Golem Core', inputs: { bar_iron: 5, gem_ruby: 2 }, output: 'craft_golem', qty: 1, rarity: 'Epic', xp: 150 },-- // ── Smelting (requires furnace station nearby) ──-- bar_bronze: { name: 'Smelt Bronze', inputs: { ore_tin: 1, ore_copper: 1 }, output: 'bar_bronze', qty: 1, rarity: 'Common', xp: 12, skill: 'smithing', station: 'furnace' },-- bar_copper: { name: 'Smelt Copper', inputs: { ore_copper: 2 }, output: 'bar_copper', qty: 1, rarity: 'Common', xp: 10, skill: 'smithing', station: 'furnace' },-- bar_iron: { name: 'Smelt Iron', inputs: { ore_iron: 2, ore_coal: 1 }, output: 'bar_iron', qty: 1, rarity: 'Common', xp: 20, skill: 'smithing', station: 'furnace', smithingLevel: 5 },-- bar_steel: { name: 'Smelt Steel', inputs: { ore_iron: 1, ore_coal: 2 }, output: 'bar_steel', qty: 1, rarity: 'Uncommon', xp: 35, skill: 'smithing', station: 'furnace', smithingLevel: 15 },-- bar_gold: { name: 'Smelt Gold', inputs: { ore_gold: 2, craft_charcoal: 1 }, output: 'bar_gold', qty: 1, rarity: 'Uncommon', xp: 35, skill: 'smithing', station: 'furnace', smithingLevel: 10 },-- bar_mithril: { name: 'Smelt Mithril', inputs: { ore_mithril: 2, craft_charcoal: 2 },output: 'bar_mithril', qty: 1, rarity: 'Rare', xp: 60, skill: 'smithing', station: 'furnace', smithingLevel: 25 },-- bar_titanium: { name: 'Smelt Titanium', inputs: { ore_titanium: 3, craft_charcoal: 2 },output:'bar_titanium', qty: 1, rarity: 'Rare', xp: 80, skill: 'smithing', station: 'furnace', smithingLevel: 35 },-- bar_platinum: { name: 'Smelt Platinum', inputs: { ore_platinum: 3, craft_charcoal: 3 },output:'bar_platinum', qty: 1, rarity: 'Epic', xp: 100, skill: 'smithing', station: 'furnace', smithingLevel: 45 },-- bar_dragonite: { name: 'Smelt Dragonite', inputs: { ore_dragonite: 3, gem_star: 1 }, output: 'bar_dragonite', qty: 1, rarity: 'Legendary', xp: 200, skill: 'smithing', station: 'furnace', smithingLevel: 55 },-- // ── Smithing at Workbench (bars → tools/weapons) ──-- pick_iron: { name: 'Iron Pickaxe', inputs: { bar_iron: 3, raw_stick: 2 }, output: 'pick_iron', qty: 1, rarity: 'Uncommon', xp: 30, skill: 'smithing', station: 'workbench', smithingLevel: 8 },-- pick_gold: { name: 'Gold Pickaxe', inputs: { bar_gold: 3, wood_yew: 1 }, output: 'pick_gold', qty: 1, rarity: 'Rare', xp: 50, skill: 'smithing', station: 'workbench', smithingLevel: 20 },-- pick_diamond: { name: 'Diamond Pickaxe', inputs: { bar_titanium: 2, gem_diamond: 2 }, output: 'pick_diamond', qty: 1, rarity: 'Epic', xp: 100, skill: 'smithing', station: 'workbench', smithingLevel: 35 },-- axe_iron: { name: 'Iron Axe', inputs: { bar_iron: 3, raw_stick: 2 }, output: 'axe_iron', qty: 1, rarity: 'Uncommon', xp: 30, skill: 'smithing', station: 'workbench', smithingLevel: 8 },-- axe_steel: { name: 'Steel Axe', inputs: { bar_steel: 3, wood_mahogany: 1 }, output: 'axe_steel', qty: 1, rarity: 'Rare', xp: 50, skill: 'smithing', station: 'workbench', smithingLevel: 18 },-- axe_mythril: { name: 'Mythril Axe', inputs: { bar_mithril: 3, wood_elder: 1 }, output: 'axe_mythril', qty: 1, rarity: 'Epic', xp: 80, skill: 'smithing', station: 'workbench', smithingLevel: 30 },-- compost: { name: 'Compost', inputs: { crop_wheat: 2, wood_twig: 3 }, output: 'compost', qty: 2, rarity: 'Common', xp: 10 },-- craft_elixir: { name: 'Elixir of Fortune',inputs:{ gem_ruby: 1, crop_tomato: 2 }, output: 'craft_elixir', qty: 1, rarity: 'Uncommon', xp: 40 },-- craft_xp_potion:{ name: 'XP Boost Potion', inputs: { gem_emerald: 1, wood_sap: 2 }, output: 'craft_xp_potion',qty: 1, rarity: 'Uncommon', xp: 40 },-- fish_sonar: { name: 'Fish Sonar', inputs: { ore_copper: 2, gem_ruby: 1 }, output: 'fish_sonar', qty: 1, rarity: 'Uncommon', xp: 30 },-- hat_cowboy: { name: 'Cowboy Hat', inputs: { crop_wheat: 5, wood_oak: 2 }, output: 'hat_cowboy', qty: 1, rarity: 'Uncommon', xp: 35 },-- hat_wizard: { name: 'Wizard Hat', inputs: { wood_elder: 2, gem_ruby: 1 }, output: 'hat_wizard', qty: 1, rarity: 'Rare', xp: 75 },-- hat_crown: { name: 'Royal Crown', inputs: { bar_gold: 3, gem_diamond: 2 }, output: 'hat_crown', qty: 1, rarity: 'Epic', xp: 150 },-- potion_health: { name: 'Health Potion', inputs: { crop_tomato: 1, wood_sap: 1 }, output: 'potion_health', qty: 2, rarity: 'Common', xp: 15 },-- // ── Cooking (requires a burn barrel station nearby) ──-- food_cooked_fish: { name: 'Cooked Fish', inputs: { fish_bass: 1, wood_oak: 1 }, output: 'food_cooked_fish', qty: 2, rarity: 'Common', xp: 10, skill: 'cooking', station: 'campfire' },-- food_grilled_meat: { name: 'Grilled Meat', inputs: { fish_catfish: 1, raw_stick: 2 }, output: 'food_grilled_meat', qty: 1, rarity: 'Common', xp: 15, skill: 'cooking', station: 'campfire' },-- food_trail_mix: { name: 'Trail Mix', inputs: { crop_wheat: 2, crop_corn: 1, wood_sap: 1 },output: 'food_trail_mix', qty: 2, rarity: 'Common', xp: 15, skill: 'cooking' },-- food_veggie_soup: { name: 'Veggie Soup', inputs: { crop_carrot: 2, crop_tomato: 1, crop_wheat: 1 }, output: 'food_veggie_soup', qty: 1, rarity: 'Uncommon', xp: 25, skill: 'cooking', station: 'campfire' },-- food_fish_stew: { name: 'Fish Stew', inputs: { fish_salmon: 1, crop_tomato: 1, crop_carrot: 1 }, output: 'food_fish_stew', qty: 1, rarity: 'Uncommon', xp: 30, skill: 'cooking', station: 'campfire' },-- food_energy_bar: { name: 'Energy Bar', inputs: { crop_corn: 2, crop_wheat: 2, wood_sap: 1 },output: 'food_energy_bar', qty: 2, rarity: 'Uncommon', xp: 25, skill: 'cooking' },-- food_pumpkin_pie: { name: 'Pumpkin Pie', inputs: { crop_pumpkin: 1, crop_wheat: 2, crop_golden: 1 }, output: 'food_pumpkin_pie', qty: 1, rarity: 'Rare', xp: 60, skill: 'cooking', station: 'campfire' },-- food_golden_feast: { name: 'Golden Feast', inputs: { crop_golden: 1, fish_king_salmon: 1, bar_gold: 1 }, output: 'food_golden_feast', qty: 1, rarity: 'Epic', xp: 100, skill: 'cooking', station: 'campfire' },-- // Weapons & Armor (smithing at workbench)-- weapon_sword: { name: 'Iron Sword', inputs: { bar_iron: 3 }, output: 'weapon_sword', qty: 1, rarity: 'Uncommon', xp: 40, skill: 'smithing', station: 'workbench', smithingLevel: 10 },-- weapon_axe: { name: 'Battle Axe', inputs: { bar_steel: 3, wood_elder: 1 }, output: 'weapon_axe', qty: 1, rarity: 'Rare', xp: 80, skill: 'smithing', station: 'workbench', smithingLevel: 22 },-- weapon_katana: { name: 'Shadow Katana', inputs: { bar_titanium: 2, gem_diamond: 1 }, output: 'weapon_katana', qty: 1, rarity: 'Epic', xp: 150, skill: 'smithing', station: 'workbench', smithingLevel: 40 },-- armor_leather: { name: 'Leather Armor', inputs: { crop_wheat: 5, bar_iron: 1 }, output: 'armor_leather', qty: 1, rarity: 'Uncommon', xp: 35, station: 'workbench' },-- armor_chain: { name: 'Chainmail', inputs: { bar_iron: 4, ore_coal: 2 }, output: 'armor_chain', qty: 1, rarity: 'Rare', xp: 70, skill: 'smithing', station: 'workbench', smithingLevel: 15 },-- armor_plate: { name: 'Plate Armor', inputs: { bar_titanium: 2, bar_gold: 1 }, output: 'armor_plate', qty: 1, rarity: 'Epic', xp: 130, skill: 'smithing', station: 'workbench', smithingLevel: 35 },--};----// ── NPCs (Town Layout — buildings are 3w×4h, door at bottom-center) ──--// Center = OUTPOST_X,OUTPOST_Y. Town radius 14 tiles. Buildings spaced around a central square.--// Each NPC stands 1 tile inside their door (tileX/Y = door tile, which is the interaction point).--// buildTiles = full footprint (walls + floor), doorTile = entrance tile.----function makeBuildTiles(cx, cy) {-- // 3×4 building centered at cx, top row cy-2, bottom row cy+1-- const tiles = [];-- for (let dy = -2; dy <= 1; dy++) {-- for (let dx = -1; dx <= 1; dx++) {-- tiles.push([cx + dx, cy + dy]);-- }-- }-- return tiles;--}----const OX = OUTPOST_X, OY = OUTPOST_Y;--const NPCS = {-- banker: { id: 'banker', name: 'Banker', emoji: '🏦', tileX: OX, tileY: OY - 4, doorTile: [OX, OY - 3], buildTiles: makeBuildTiles(OX, OY - 4), roofColor: '#4a6741', categories: [] },-- weaponsmith: { id: 'weaponsmith', name: 'Weaponsmith', emoji: '⚔️', tileX: OX - 7, tileY: OY - 4, doorTile: [OX - 7, OY - 3], buildTiles: makeBuildTiles(OX - 7, OY - 4), roofColor: '#8b3a3a', categories: ['weapons', 'armor'] },-- toolshop: { id: 'toolshop', name: 'Tool Merchant', emoji: '🧰', tileX: OX - 7, tileY: OY + 1, doorTile: [OX - 7, OY + 2], buildTiles: makeBuildTiles(OX - 7, OY + 1), roofColor: '#5a4a3a', categories: ['pickaxes', 'axes', 'rods', 'tools'] },-- farmsupply: { id: 'farmsupply', name: 'Farm Supply', emoji: '🌱', tileX: OX + 7, tileY: OY - 4, doorTile: [OX + 7, OY - 3], buildTiles: makeBuildTiles(OX + 7, OY - 4), roofColor: '#5a7a3a', categories: ['seeds', 'bait', 'consumable'] },-- gemtrader: { id: 'gemtrader', name: 'Gem Trader', emoji: '💎', tileX: OX + 7, tileY: OY + 1, doorTile: [OX + 7, OY + 2], buildTiles: makeBuildTiles(OX + 7, OY + 1), roofColor: '#4a3a6a', categories: ['ores', 'gems', 'bars'] },-- cosmetics: { id: 'cosmetics', name: 'Cosmetics', emoji: '🎩', tileX: OX, tileY: OY + 4, doorTile: [OX, OY + 5], buildTiles: makeBuildTiles(OX, OY + 4), roofColor: '#8a5a8a', categories: ['hats', 'name_effects', 'particles', 'voices'] },-- cook: { id: 'cook', name: 'Camp Chef', emoji: '👨🍳', tileX: OX - 11,tileY: OY - 2, doorTile: [OX - 11, OY - 1], buildTiles: makeBuildTiles(OX - 11, OY - 2), roofColor: '#7a5a3a', categories: ['food', 'consumable'] },-- tagmaster: { id: 'tagmaster', name: 'Tag Master', emoji: '🏷️', tileX: OX + 11,tileY: OY - 2, doorTile: [OX + 11, OY - 1], buildTiles: makeBuildTiles(OX + 11, OY - 2), roofColor: '#4a5a7a', categories: ['tags'], guardedBy: 'tag_guardian' },--};----// Village NPC generation — each village gets 2 shopkeepers--(function generateVillageNPCs() {-- const villageDefs = [-- { village: VILLAGES[0], npcs: [-- { id: 'v_fishmonger', name: 'Fishmonger', emoji: '🐟', ox: -3, oy: -2, roofColor: '#3a6a7a', categories: ['rods', 'bait'] },-- { id: 'v_tavern', name: 'Tavern Keep', emoji: '🍺', ox: 3, oy: -2, roofColor: '#6a4a2a', categories: ['food', 'consumable'] },-- ]},-- { village: VILLAGES[1], npcs: [-- { id: 'v_minesmith', name: 'Mine Smith', emoji: '⛏️', ox: -3, oy: -2, roofColor: '#5a5a5a', categories: ['pickaxes', 'ores', 'bars'] },-- { id: 'v_armorsmith', name: 'Armorsmith', emoji: '🛡️', ox: 3, oy: -2, roofColor: '#7a3a3a', categories: ['armor', 'weapons'] },-- ]},-- { village: VILLAGES[2], npcs: [-- { id: 'v_herbalist', name: 'Herbalist', emoji: '🌿', ox: -3, oy: -2, roofColor: '#3a7a3a', categories: ['seeds', 'consumable'] },-- { id: 'v_woodsman', name: 'Woodsman', emoji: '🪵', ox: 3, oy: -2, roofColor: '#5a3a2a', categories: ['axes', 'tools'] },-- ]},-- ];-- for (const vd of villageDefs) {-- for (const n of vd.npcs) {-- const tx = vd.village.cx + n.ox;-- const ty = vd.village.cy + n.oy;-- NPCS[n.id] = {-- id: n.id, name: n.name, emoji: n.emoji,-- tileX: tx, tileY: ty,-- doorTile: [tx, ty + 1],-- buildTiles: makeBuildTiles(tx, ty),-- roofColor: n.roofColor,-- categories: n.categories,-- village: vd.village.id,-- };-- }-- }--})();----// ── Town Decorations (rendered on client, defined here for shared access) ──--const TOWN_DECO = [-- // ─ Main Town ─-- { type: 'fountain', tileX: OX, tileY: OY, emoji: '⛲' },-- // Lampposts along main paths-- { type: 'lamp', tileX: OX - 3, tileY: OY - 2 }, { type: 'lamp', tileX: OX + 3, tileY: OY - 2 },-- { type: 'lamp', tileX: OX - 3, tileY: OY + 2 }, { type: 'lamp', tileX: OX + 3, tileY: OY + 2 },-- { type: 'lamp', tileX: OX - 9, tileY: OY }, { type: 'lamp', tileX: OX + 9, tileY: OY },-- { type: 'lamp', tileX: OX, tileY: OY - 8 }, { type: 'lamp', tileX: OX, tileY: OY + 8 },-- // Benches near the square-- { type: 'bench', tileX: OX - 2, tileY: OY - 1 }, { type: 'bench', tileX: OX + 2, tileY: OY - 1 },-- { type: 'bench', tileX: OX - 2, tileY: OY + 1 }, { type: 'bench', tileX: OX + 2, tileY: OY + 1 },-- // Flower beds-- { type: 'flowers', tileX: OX - 1, tileY: OY - 1 }, { type: 'flowers', tileX: OX + 1, tileY: OY - 1 },-- { type: 'flowers', tileX: OX - 1, tileY: OY + 1 }, { type: 'flowers', tileX: OX + 1, tileY: OY + 1 },-- // Sign posts at town entrances-- { type: 'sign', tileX: OX, tileY: OY - OUTPOST_RADIUS + 3, text: 'Town' },-- { type: 'sign', tileX: OX, tileY: OY + OUTPOST_RADIUS - 3, text: 'Town' },-- { type: 'sign', tileX: OX - OUTPOST_RADIUS + 3, tileY: OY, text: 'Town' },-- { type: 'sign', tileX: OX + OUTPOST_RADIUS - 3, tileY: OY, text: 'Town' },-- // Decorative trees-- { type: 'town_tree', tileX: OX - 10, tileY: OY - 8 }, { type: 'town_tree', tileX: OX + 10, tileY: OY - 8 },-- { type: 'town_tree', tileX: OX - 10, tileY: OY + 8 }, { type: 'town_tree', tileX: OX + 10, tileY: OY + 8 },-- { type: 'town_tree', tileX: OX - 5, tileY: OY - 9 }, { type: 'town_tree', tileX: OX + 5, tileY: OY - 9 },-- { type: 'town_tree', tileX: OX - 5, tileY: OY + 9 }, { type: 'town_tree', tileX: OX + 5, tileY: OY + 9 },-- // Crates & barrels near shops-- { type: 'barrel', tileX: OX - 9, tileY: OY - 6 }, { type: 'barrel', tileX: OX - 5, tileY: OY - 6 },-- { type: 'crate', tileX: OX + 9, tileY: OY - 6 }, { type: 'crate', tileX: OX + 5, tileY: OY - 6 },-- { type: 'barrel', tileX: OX - 9, tileY: OY + 2 }, { type: 'crate', tileX: OX + 9, tileY: OY + 2 },-- { type: 'well', tileX: OX - 2, tileY: OY - 7 },-- // Tag Master building — guardian stands outside-- { type: 'lamp', tileX: OX + 9, tileY: OY },-- { type: 'sign', tileX: OX + 11, tileY: OY, text: 'Tag Master' },--];--// Add village decorations--for (const v of VILLAGES) {-- TOWN_DECO.push(-- { type: 'fountain', tileX: v.cx, tileY: v.cy, emoji: '⛲' },-- { type: 'lamp', tileX: v.cx - 3, tileY: v.cy - 1 },-- { type: 'lamp', tileX: v.cx + 3, tileY: v.cy - 1 },-- { type: 'lamp', tileX: v.cx - 3, tileY: v.cy + 1 },-- { type: 'lamp', tileX: v.cx + 3, tileY: v.cy + 1 },-- { type: 'bench', tileX: v.cx - 1, tileY: v.cy + 1 },-- { type: 'bench', tileX: v.cx + 1, tileY: v.cy + 1 },-- { type: 'sign', tileX: v.cx, tileY: v.cy + v.radius - 1, text: v.name },-- { type: 'sign', tileX: v.cx, tileY: v.cy - v.radius + 1, text: v.name },-- { type: 'town_tree', tileX: v.cx - 5, tileY: v.cy - 4 },-- { type: 'town_tree', tileX: v.cx + 5, tileY: v.cy - 4 },-- );--}----// ── Town Path Tiles — roads/cobblestone (set of "tileX,tileY" strings) ──--function generateTownPaths() {-- const paths = new Set();-- const cx = OX, cy = OY;---- // Collect all NPC building tiles to exclude from paths-- const buildingTiles = new Set();-- for (const npc of Object.values(NPCS)) {-- for (const [bx, by] of (npc.buildTiles || [])) buildingTiles.add(`${bx},${by}`);-- }-- const addPath = (x, y) => {-- const k = `${x},${y}`;-- if (!buildingTiles.has(k)) paths.add(k);-- };---- // ─ Main Town paths ─-- // North-south road (3 tiles wide)-- for (let y = cy - OUTPOST_RADIUS + 2; y <= cy + OUTPOST_RADIUS - 2; y++) {-- addPath(cx - 1, y); addPath(cx, y); addPath(cx + 1, y);-- }-- // East-west road (3 tiles wide)-- for (let x = cx - OUTPOST_RADIUS + 2; x <= cx + OUTPOST_RADIUS - 2; x++) {-- addPath(x, cy - 1); addPath(x, cy); addPath(x, cy + 1);-- }-- // Town square (5×5 around fountain)-- for (let x = cx - 2; x <= cx + 2; x++) {-- for (let y = cy - 2; y <= cy + 2; y++) {-- addPath(x, y);-- }-- }-- // Side paths to each building (2 tiles wide leading to doors)-- // NW weaponsmith-- for (let x = cx - 6; x <= cx - 2; x++) { addPath(x, cy - 3); addPath(x, cy - 2); }-- // NE farm supply-- for (let x = cx + 2; x <= cx + 6; x++) { addPath(x, cy - 3); addPath(x, cy - 2); }-- // SW tool shop-- for (let x = cx - 6; x <= cx - 2; x++) { addPath(x, cy + 2); addPath(x, cy + 3); }-- // SE gem trader-- for (let x = cx + 2; x <= cx + 6; x++) { addPath(x, cy + 2); addPath(x, cy + 3); }-- // S cosmetics-- for (let y = cy + 2; y <= cy + 6; y++) { addPath(cx - 1, y); addPath(cx, y); addPath(cx + 1, y); }-- // W cook-- for (let x = cx - OUTPOST_RADIUS + 2; x <= cx - 2; x++) { addPath(x, cy - 1); addPath(x, cy); }-- // E tag master-- for (let x = cx + 2; x <= cx + OUTPOST_RADIUS - 2; x++) { addPath(x, cy - 1); addPath(x, cy); }---- // ─ Village internal paths ─-- for (const v of VILLAGES) {-- // Small cross path within each village-- for (let y = v.cy - v.radius + 2; y <= v.cy + v.radius - 2; y++) {-- addPath(v.cx, y); addPath(v.cx - 1, y);-- }-- for (let x = v.cx - v.radius + 2; x <= v.cx + v.radius - 2; x++) {-- addPath(x, v.cy); addPath(x, v.cy - 1);-- }-- // Small square (3×3) around center-- for (let x = v.cx - 1; x <= v.cx + 1; x++) {-- for (let y = v.cy - 1; y <= v.cy + 1; y++) {-- addPath(x, y);-- }-- }-- }---- // ─ Inter-town roads (3-wide Bresenham path from main town to each village) ─-- for (const v of VILLAGES) {-- const steps = Math.max(Math.abs(v.cx - cx), Math.abs(v.cy - cy));-- for (let i = 0; i <= steps; i++) {-- const t = steps === 0 ? 0 : i / steps;-- const rx = Math.round(cx + (v.cx - cx) * t);-- const ry = Math.round(cy + (v.cy - cy) * t);-- // 3-wide road-- addPath(rx - 1, ry); addPath(rx, ry); addPath(rx + 1, ry);-- addPath(rx, ry - 1); addPath(rx, ry + 1);-- }-- }---- return paths;--}--const TOWN_PATHS = generateTownPaths();--const NPC_LIST = Object.values(NPCS);--const NPC_INTERACT_RANGE = 3;----// ── World Mob Types (roaming enemies in the open world) ─────--const MOB_TYPES = {-- rat: { name: 'Rat', emoji: '🐀', hp: 20, atk: 3, def: 0, xp: 10, gold: 5, speed: 0.8, aggroRange: 3, biomes: ['grass','forest','sand'], minTier: 0, loot: [{id:'loot_lint',w:50},{id:'bait_worm',w:30},{id:'loot_coin_pouch',w:20}] },-- wolf: { name: 'Wolf', emoji: '🐺', hp: 45, atk: 8, def: 2, xp: 25, gold: 12, speed: 1.4, aggroRange: 5, biomes: ['forest','hills','grass'], minTier: 0, loot: [{id:'loot_coin_pouch',w:40},{id:'potion_health',w:30},{id:'loot_feather',w:30}] },-- spider: { name: 'Cave Spider', emoji: '🕷️', hp: 35, atk: 7, def: 1, xp: 20, gold: 8, speed: 1.0, aggroRange: 4, biomes: ['hills','mountain','desert'], minTier: 1, loot: [{id:'bait_cricket',w:40},{id:'loot_coin_pouch',w:35},{id:'potion_health',w:25}] },-- skeleton: { name: 'Skeleton', emoji: '💀', hp: 60, atk: 12, def: 5, xp: 40, gold: 20, speed: 0.7, aggroRange: 5, biomes: ['mountain','desert','snow'], minTier: 1, loot: [{id:'dungeon_key',w:8},{id:'weapon_stick',w:22},{id:'loot_coin_pouch',w:40},{id:'loot_feather',w:30}] },-- golem: { name: 'Stone Golem', emoji: '🗿', hp: 100, atk: 18, def: 12, xp: 65, gold: 35, speed: 0.4, aggroRange: 4, biomes: ['mountain','hills'], minTier: 2, loot: [{id:'ore_iron',w:25},{id:'ore_gold',w:20},{id:'gem_ruby',w:15},{id:'bar_iron',w:25},{id:'loot_crown',w:15}] },-- wraith: { name: 'Wraith', emoji: '👻', hp: 80, atk: 22, def: 8, xp: 80, gold: 45, speed: 1.3, aggroRange: 6, biomes: ['snow','mountain'], minTier: 2, loot: [{id:'gem_emerald',w:25},{id:'mine_void_stone',w:5},{id:'loot_egg',w:15},{id:'dungeon_key',w:20},{id:'craft_xp_potion',w:35}] },-- dragon: { name: 'Dragon Whelp', emoji: '🐲', hp: 180, atk: 32, def: 18, xp: 150, gold: 80, speed: 1.0, aggroRange: 7, biomes: ['mountain','snow'], minTier: 3, loot: [{id:'ore_dragonite',w:15},{id:'gem_diamond',w:25},{id:'loot_egg',w:20},{id:'loot_star',w:10},{id:'dungeon_key',w:30}] },-- // Aggro village guards — patrol near villages, hostile to non-town players-- bandit: { name: 'Bandit', emoji: '🗡️', hp: 50, atk: 10, def: 3, xp: 30, gold: 15, speed: 1.1, aggroRange: 5, biomes: ['grass','forest','sand','hills','desert'], minTier: 0, loot: [{id:'loot_coin_pouch',w:40},{id:'weapon_stick',w:25},{id:'potion_health',w:20},{id:'loot_feather',w:15}], nearVillage: true },-- raider: { name: 'Raider', emoji: '⚔️', hp: 75, atk: 15, def: 6, xp: 45, gold: 25, speed: 1.0, aggroRange: 6, biomes: ['grass','forest','hills','mountain','desert'], minTier: 1, loot: [{id:'loot_coin_pouch',w:35},{id:'bar_iron',w:20},{id:'potion_health',w:25},{id:'dungeon_key',w:10},{id:'loot_crown',w:10}], nearVillage: true },--};----// ── Weapon & Armor Stats (shared with engine) ───────────────--const WEAPON_STATS = {-- weapon_fist: { atk: 0, speed: 1.0, range: 1.0 },-- weapon_rock: { atk: 1, speed: 1.2, range: 0.8 },-- weapon_stick: { atk: 2, speed: 1.3, range: 1.2 },-- weapon_sword: { atk: 8, speed: 1.0, range: 1.2 },-- weapon_axe: { atk: 15, speed: 0.7, range: 1.0 },-- weapon_katana: { atk: 25, speed: 1.4, range: 1.3 },-- weapon_legendary: { atk: 40, speed: 1.1, range: 1.4 },--};--const ARMOR_STATS = {-- armor_cloth: { def: 3 },-- armor_leather: { def: 8 },-- armor_chain: { def: 15 },-- armor_plate: { def: 25 },-- armor_dragonscale: { def: 40 },--};----// ── Level Requirements by Category ───────────────────────────--const EQUIP_SKILL_MAP = {-- pickaxes: 'mining', axes: 'woodcut', rods: 'fishing',-- weapons: 'combat', armor: 'combat',--};----// ── Food Effects ─────────────────────────────────────────────--const FOOD_EFFECTS = {-- food_cooked_fish: { hp: 20 },-- food_grilled_meat: { hp: 30 },-- food_fish_stew: { hp: 40, buff: { atk: 5, duration: 180000 } },-- food_veggie_soup: { hp: 25, buff: { def: 5, duration: 180000 } },-- food_trail_mix: { stamina: 30, buff: { sprint_free: true, duration: 120000 } },-- food_energy_bar: { stamina: 50, buff: { max_stamina: 20, duration: 300000 } },-- food_pumpkin_pie: { hp: 50, buff: { xp_mult: 2, duration: 120000 } },-- food_golden_feast: { hp: 75, buff: { atk: 10, def: 10, duration: 300000 } },-- food_campfire_meal:{ hp: 15, buff: { hp_regen: 3, duration: 120000 } },--};----// ── Utility ──────────────────────────────────────────────────--function xpToLevel(xp) { return Math.floor(Math.sqrt(xp / 25)) + 1; }--function levelToXp(level) { return (level - 1) * (level - 1) * 25; }----function rollLoot(table) {-- const total = table.reduce((s, e) => s + (e.weight || e.w || 0), 0);-- let roll = Math.random() * total;-- for (const entry of table) {-- roll -= (entry.weight || entry.w || 0);-- if (roll <= 0) return entry.id;-- }-- return table[table.length - 1].id;--}----module.exports = {-- ITEMS, RARITY_COLORS, RECIPES, STRUCTURES, BIOME_COLORS,-- FISH_TABLES, MINE_TABLES, WOOD_TABLES, WATER_ZONE_TABLES, CROPS, MONSTERS,-- FISH_SPECIES, FISH_JUNK, ROD_TIERS, buildFishTable,-- PICK_TIERS, AXE_TIERS, ORE_NODE_TYPES, getOreNodeType,-- MOB_TYPES, VILLAGES,-- WEAPON_STATS, ARMOR_STATS, EQUIP_SKILL_MAP, FOOD_EFFECTS,-- MAP_W, MAP_H, TILE, OUTPOST_X, OUTPOST_Y, OUTPOST_RADIUS,-- NPCS, NPC_LIST, NPC_INTERACT_RANGE, TOWN_DECO, TOWN_PATHS,-- xpToLevel, levelToXp, rollLoot,-- hashNoise, smoothNoise, fbm, getBiome, getBiomeAt, getWaterZone,-- getResourceNodeAt, getDifficultyTier, isInSafeZone,--};ddeleted file mode 100644@@@ -1,211 +0,0 @@----CREATE TABLE IF NOT EXISTS game_world_state (-- key TEXT PRIMARY KEY,-- value TEXT--);----CREATE TABLE IF NOT EXISTS game_players (-- user_id INTEGER PRIMARY KEY,-- display_name TEXT,-- x REAL DEFAULT 4096,-- y REAL DEFAULT 4096,-- -- XP-- mining_xp INTEGER DEFAULT 0,-- fishing_xp INTEGER DEFAULT 0,-- woodcut_xp INTEGER DEFAULT 0,-- farming_xp INTEGER DEFAULT 0,-- combat_xp INTEGER DEFAULT 0,-- crafting_xp INTEGER DEFAULT 0,-- agility_xp INTEGER DEFAULT 0,-- -- Combat-- hp INTEGER DEFAULT 100,-- max_hp INTEGER DEFAULT 100,-- attack INTEGER DEFAULT 10,-- defense INTEGER DEFAULT 5,-- -- Stamina-- stamina INTEGER DEFAULT 100,-- max_stamina INTEGER DEFAULT 100,-- last_stamina_tick DATETIME DEFAULT CURRENT_TIMESTAMP,-- -- Equipment slots (NULL = bare hands / no tool)-- equip_pickaxe TEXT DEFAULT NULL,-- equip_rod TEXT DEFAULT NULL,-- equip_axe TEXT DEFAULT NULL,-- equip_hat TEXT,-- equip_weapon TEXT,-- equip_armor TEXT,-- -- Spawn point (sleeping bag)-- sleeping_bag_x REAL,-- sleeping_bag_y REAL,-- -- Appearance-- sprite_skin INTEGER DEFAULT 0,-- name_effect TEXT,-- particle_effect TEXT,-- chat_color TEXT DEFAULT '#e8e6e3',-- -- Stats-- total_coins_earned INTEGER DEFAULT 0,-- total_items_crafted INTEGER DEFAULT 0,-- total_monsters_killed INTEGER DEFAULT 0,-- total_deaths INTEGER DEFAULT 0,-- battle_wins INTEGER DEFAULT 0,-- battle_losses INTEGER DEFAULT 0,-- structures_built INTEGER DEFAULT 0,-- resources_gathered INTEGER DEFAULT 0,-- -- Timestamps-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- last_action DATETIME DEFAULT CURRENT_TIMESTAMP,-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE--);----CREATE TABLE IF NOT EXISTS game_inventory (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- user_id INTEGER NOT NULL,-- item_id TEXT NOT NULL,-- quantity INTEGER DEFAULT 1,-- UNIQUE(user_id, item_id),-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE--);--CREATE INDEX IF NOT EXISTS idx_ginv_user ON game_inventory(user_id);----CREATE TABLE IF NOT EXISTS game_bank (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- user_id INTEGER NOT NULL,-- item_id TEXT NOT NULL,-- quantity INTEGER DEFAULT 1,-- UNIQUE(user_id, item_id),-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE--);----CREATE TABLE IF NOT EXISTS game_structures (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- owner_id INTEGER NOT NULL,-- type TEXT NOT NULL,-- tile_x INTEGER NOT NULL,-- tile_y INTEGER NOT NULL,-- hp INTEGER DEFAULT 100,-- max_hp INTEGER DEFAULT 100,-- data TEXT DEFAULT '{}',-- built_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- UNIQUE(tile_x, tile_y),-- FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE--);--CREATE INDEX IF NOT EXISTS idx_struct_tile ON game_structures(tile_x, tile_y);--CREATE INDEX IF NOT EXISTS idx_struct_owner ON game_structures(owner_id);----CREATE TABLE IF NOT EXISTS game_farm_plots (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- user_id INTEGER NOT NULL,-- plot_index INTEGER DEFAULT 0,-- crop_id TEXT,-- planted_at DATETIME,-- watered_at DATETIME,-- fertilized INTEGER DEFAULT 0,-- stage TEXT DEFAULT 'empty' CHECK(stage IN ('empty','seed','sprout','growing','ripe','withered')),-- UNIQUE(user_id, plot_index),-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE--);----CREATE TABLE IF NOT EXISTS game_recipes (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- user_id INTEGER NOT NULL,-- recipe_id TEXT NOT NULL,-- unlocked_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- UNIQUE(user_id, recipe_id),-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE--);----CREATE TABLE IF NOT EXISTS game_effects (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- user_id INTEGER NOT NULL,-- effect_type TEXT NOT NULL,-- effect_id TEXT,-- expires_at DATETIME,-- charges INTEGER,-- data TEXT DEFAULT '{}',-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE--);--CREATE INDEX IF NOT EXISTS idx_geffects_user ON game_effects(user_id);----CREATE TABLE IF NOT EXISTS game_battle_stats (-- user_id INTEGER PRIMARY KEY,-- battles_won INTEGER DEFAULT 0,-- battles_lost INTEGER DEFAULT 0,-- total_stolen INTEGER DEFAULT 0,-- total_lost INTEGER DEFAULT 0,-- kill_streak INTEGER DEFAULT 0,-- best_streak INTEGER DEFAULT 0,-- kills INTEGER DEFAULT 0,-- deaths INTEGER DEFAULT 0,-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE--);----CREATE TABLE IF NOT EXISTS game_dungeon_runs (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- leader_id INTEGER NOT NULL,-- floor_reached INTEGER DEFAULT 1,-- status TEXT DEFAULT 'active' CHECK(status IN ('active','completed','failed','abandoned')),-- party_data TEXT DEFAULT '[]',-- started_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- ended_at DATETIME,-- FOREIGN KEY (leader_id) REFERENCES users(id) ON DELETE CASCADE--);----CREATE TABLE IF NOT EXISTS game_leaderboard (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- user_id INTEGER NOT NULL,-- board_type TEXT NOT NULL,-- score INTEGER DEFAULT 0,-- rank INTEGER DEFAULT 0,-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- UNIQUE(user_id, board_type),-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE--);----CREATE TABLE IF NOT EXISTS game_fish_collection (-- user_id INTEGER NOT NULL,-- fish_id TEXT NOT NULL,-- times_caught INTEGER DEFAULT 1,-- max_weight REAL DEFAULT 0,-- first_caught DATETIME DEFAULT CURRENT_TIMESTAMP,-- PRIMARY KEY (user_id, fish_id),-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE--);--CREATE INDEX IF NOT EXISTS idx_fish_coll_user ON game_fish_collection(user_id);----CREATE TABLE IF NOT EXISTS game_daily_quest_progress (-- user_id INTEGER NOT NULL,-- quest_date TEXT NOT NULL,-- stat_key TEXT NOT NULL,-- value REAL DEFAULT 0,-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- PRIMARY KEY (user_id, quest_date, stat_key),-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE--);--CREATE INDEX IF NOT EXISTS idx_daily_quest_progress_user_date ON game_daily_quest_progress(user_id, quest_date);----CREATE TABLE IF NOT EXISTS game_daily_quest_claims (-- user_id INTEGER NOT NULL,-- quest_date TEXT NOT NULL,-- quest_id TEXT NOT NULL,-- claimed_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- PRIMARY KEY (user_id, quest_date, quest_id),-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE--);--CREATE INDEX IF NOT EXISTS idx_daily_quest_claims_user_date ON game_daily_quest_claims(user_id, quest_date);ddeleted file mode 100644@@@ -1,334 +0,0 @@--/**-- * OpenVibeGame — Tags System-- * Modular chat tags that display next to usernames.-- * Users can own multiple tags, equip one at a time, and buy new tags-- * from the Tag Master NPC after defeating the Tag Guardian.-- */--const db = require('../db/database');----// ── Tag Catalog ──────────────────────────────────────────────--// Tags are visual badges shown next to usernames in chat.--// category: 'special' = cannot be bought, only granted--// 'shop' = purchasable from Tag Master NPC--// 'achievement' = earned via game milestones--const TAGS = {-- // ── Special (granted, not purchasable) ───────────────────-- cfo: { name: 'CFO', emoji: '💼', color: '#f59e0b', bgColor: '#451a03', desc: 'Chief Financial Officer — legacy RS-Companion VIP', category: 'special', tier: 5 },-- founder: { name: 'Founder', emoji: '⭐', color: '#facc15', bgColor: '#422006', desc: 'Original OpenVibe.Live founder', category: 'special', tier: 5 },-- legacy: { name: 'Legacy', emoji: '🏛️', color: '#a78bfa', bgColor: '#1e1b4b', desc: 'Migrated from RS-Companion', category: 'special', tier: 3 },-- alpha_tester: { name: 'Alpha Tester', emoji: '🧪', color: '#34d399', bgColor: '#022c22', desc: 'Tested during alpha phase', category: 'special', tier: 3 },-- mod: { name: 'Mod', emoji: '🛡️', color: '#60a5fa', bgColor: '#172554', desc: 'Platform moderator', category: 'special', tier: 4 },-- admin: { name: 'Admin', emoji: '⚡', color: '#f87171', bgColor: '#450a0a', desc: 'Platform administrator', category: 'special', tier: 5 },-- streamer: { name: 'Streamer', emoji: '📡', color: '#c084fc', bgColor: '#3b0764', desc: 'Verified streamer', category: 'special', tier: 4 },-- vip: { name: 'VIP', emoji: '👑', color: '#fbbf24', bgColor: '#78350f', desc: 'Very Important Person', category: 'special', tier: 4 },-- developer: { name: 'Dev', emoji: '🔧', color: '#38bdf8', bgColor: '#0c4a6e', desc: 'Platform developer', category: 'special', tier: 5 },---- // ── Shop Tags (purchasable from Tag Master NPC) ──────────-- noob: { name: 'Noob', emoji: '🐣', color: '#86efac', bgColor: '#052e16', desc: 'Everyone starts somewhere', category: 'shop', tier: 1, cost: 50 },-- chatterbox: { name: 'Chatterbox', emoji: '💬', color: '#93c5fd', bgColor: '#1e3a5f', desc: 'Professional yapper', category: 'shop', tier: 1, cost: 100 },-- warrior: { name: 'Warrior', emoji: '⚔️', color: '#fca5a5', bgColor: '#450a0a', desc: 'Battle-hardened fighter', category: 'shop', tier: 2, cost: 250 },-- miner: { name: 'Miner', emoji: '⛏️', color: '#d4d4d8', bgColor: '#27272a', desc: 'Deep rock delver', category: 'shop', tier: 2, cost: 250 },-- angler: { name: 'Angler', emoji: '🎣', color: '#7dd3fc', bgColor: '#0c4a6e', desc: 'Master of the rod', category: 'shop', tier: 2, cost: 250 },-- lumberjack: { name: 'Lumberjack', emoji: '🪓', color: '#a3e635', bgColor: '#1a2e05', desc: 'Timber specialist', category: 'shop', tier: 2, cost: 250 },-- farmer: { name: 'Farmer', emoji: '🌾', color: '#fde68a', bgColor: '#451a03', desc: 'Crop connoisseur', category: 'shop', tier: 2, cost: 250 },-- chef: { name: 'Chef', emoji: '👨🍳', color: '#fdba74', bgColor: '#431407', desc: 'Culinary master', category: 'shop', tier: 2, cost: 300 },-- merchant: { name: 'Merchant', emoji: '🏪', color: '#fcd34d', bgColor: '#78350f', desc: 'Shrewd trader', category: 'shop', tier: 3, cost: 500 },-- explorer: { name: 'Explorer', emoji: '🧭', color: '#6ee7b7', bgColor: '#022c22', desc: 'Seen it all', category: 'shop', tier: 3, cost: 500 },-- assassin: { name: 'Assassin', emoji: '🗡️', color: '#a78bfa', bgColor: '#1e1b4b', desc: 'Silent and deadly', category: 'shop', tier: 3, cost: 750 },-- overlord: { name: 'Overlord', emoji: '🔱', color: '#e879f9', bgColor: '#4a044e', desc: 'Rules with an iron fist', category: 'shop', tier: 4, cost: 1500 },-- legend: { name: 'Legend', emoji: '🏆', color: '#fbbf24', bgColor: '#451a03', desc: 'Etched in history', category: 'shop', tier: 5, cost: 5000 },-- void_walker: { name: 'Void Walker', emoji: '🕳️', color: '#818cf8', bgColor: '#0f0f23', desc: 'From beyond the abyss', category: 'shop', tier: 5, cost: 10000 },---- // ── Achievement Tags (earned, not purchasable) ───────────-- dragon_slayer: { name: 'Dragonslayer', emoji: '🐉', color: '#ef4444', bgColor: '#450a0a', desc: 'Slew a dragon', category: 'achievement', tier: 4 },-- dungeon_master: { name: 'Dungeon Master',emoji: '🏰', color: '#c084fc', bgColor: '#3b0764', desc: 'Won 50 dungeon fights', category: 'achievement', tier: 4 },-- fish_lord: { name: 'Fish Lord', emoji: '🐟', color: '#22d3ee', bgColor: '#083344', desc: 'Caught every fish species', category: 'achievement', tier: 4 },-- big_spender: { name: 'Big Spender', emoji: '💰', color: '#fbbf24', bgColor: '#78350f', desc: 'Spent 50,000 gold total', category: 'achievement', tier: 3 },-- survivor: { name: 'Survivor', emoji: '💪', color: '#4ade80', bgColor: '#052e16', desc: 'Won 100 PvP battles', category: 'achievement', tier: 4 },--};----// ── Tag Guardian (NPC boss you must defeat to access the tag shop) ──--const TAG_GUARDIAN = {-- name: 'Tag Guardian',-- emoji: '🏷️',-- hp: 80,-- atk: 18,-- def: 8,-- xp: 50,-- gold: 30,-- desc: 'A mysterious warrior who guards the Tag Master\'s shop. Defeat them to prove your worth.',-- combatLevel: 3, // minimum combat level to challenge--};----// ── Ensure DB tables ─────────────────────────────────────────--function ensureTagTables() {-- const d = db.getDb();-- d.exec(`-- CREATE TABLE IF NOT EXISTS user_tags (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- user_id INTEGER NOT NULL,-- tag_id TEXT NOT NULL,-- source TEXT DEFAULT 'shop',-- granted_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- UNIQUE(user_id, tag_id),-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE-- );-- CREATE TABLE IF NOT EXISTS user_equipped_tag (-- user_id INTEGER NOT NULL PRIMARY KEY,-- tag_id TEXT NOT NULL,-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE-- );-- CREATE TABLE IF NOT EXISTS tag_guardian_defeats (-- user_id INTEGER NOT NULL PRIMARY KEY,-- defeated_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE-- );-- `);-- try { d.exec('CREATE INDEX IF NOT EXISTS idx_user_tags_user ON user_tags(user_id)'); } catch { }--}----// ── Tag Guardian Combat ──────────────────────────────────────----/**-- * Check if the user has already defeated the Tag Guardian.-- */--function hasDefeatedGuardian(userId) {-- const d = db.getDb();-- return !!d.prepare('SELECT 1 FROM tag_guardian_defeats WHERE user_id = ?').get(userId);--}----/**-- * Fight the Tag Guardian. Turn-based combat similar to dungeon monsters.-- * Returns { success, won, log, xp?, gold? }-- */--function fightGuardian(userId) {-- const d = db.getDb();---- if (hasDefeatedGuardian(userId)) {-- return { error: 'You have already defeated the Tag Guardian. The shop is open to you.' };-- }---- // Check combat level-- const player = d.prepare('SELECT hp, attack, defense, combat_xp FROM game_players WHERE user_id = ?').get(userId);-- if (!player) return { error: 'No game character found. Enter the game first.' };---- const combatLevel = xpToLevel(player.combat_xp);-- if (combatLevel < TAG_GUARDIAN.combatLevel) {-- return { error: `Need Combat Lv ${TAG_GUARDIAN.combatLevel} to challenge the Tag Guardian. (You're Lv ${combatLevel})` };-- }---- const guardian = { ...TAG_GUARDIAN };-- // Scale slightly with player level for fairness-- const scale = 1 + (combatLevel - 1) * 0.03;-- guardian.hp = Math.round(guardian.hp * scale);-- guardian.atk = Math.round(guardian.atk * scale);---- let playerHp = player.hp;-- let guardianHp = guardian.hp;-- const log = [];-- let round = 0;---- while (playerHp > 0 && guardianHp > 0 && round < 20) {-- round++;-- // Player attacks-- const pDmg = Math.max(1, Math.round(player.attack * (0.8 + Math.random() * 0.4) - guardian.def * 0.3));-- guardianHp -= pDmg;-- log.push({ type: 'player_attack', dmg: pDmg, guardianHp: Math.max(0, guardianHp) });-- if (guardianHp <= 0) break;-- // Guardian attacks-- const gDmg = Math.max(1, Math.round(guardian.atk * (0.8 + Math.random() * 0.4) - player.defense * 0.3));-- playerHp -= gDmg;-- log.push({ type: 'guardian_attack', dmg: gDmg, playerHp: Math.max(0, playerHp) });-- }---- // Update player HP-- d.prepare('UPDATE game_players SET hp = ? WHERE user_id = ?').run(Math.max(0, playerHp), userId);---- if (guardianHp <= 0) {-- // Victory — record the defeat and grant XP/gold-- d.prepare('INSERT OR IGNORE INTO tag_guardian_defeats (user_id) VALUES (?)').run(userId);-- // Grant XP via direct update (combat_xp)-- d.prepare('UPDATE game_players SET combat_xp = combat_xp + ? WHERE user_id = ?').run(guardian.xp, userId);-- // Gold is OpenCoins now — credit the network wallet (idempotent: one-time defeat).-- require('../monetization/wallet-client')-- .credit(userId, guardian.gold, 'Tag Guardian defeated', `live:guardian_defeat:${userId}`)-- .catch((e) => console.warn('[Tags] guardian gold credit failed:', e.message));---- return {-- success: true,-- won: true,-- log,-- xp: guardian.xp,-- gold: guardian.gold,-- message: 'The Tag Guardian falls! The Tag Master\'s shop is now open to you. 🏷️',-- };-- }---- // Defeat — player can try again after healing-- return {-- success: true,-- won: false,-- log,-- message: 'The Tag Guardian overwhelms you... heal up and try again.',-- };--}----// ── Tag Queries ──────────────────────────────────────────────----/**-- * Get all tags owned by a user.-- */--function getUserTags(userId) {-- const d = db.getDb();-- const rows = d.prepare('SELECT tag_id, source, granted_at FROM user_tags WHERE user_id = ?').all(userId);-- return rows.map(r => ({-- ...r,-- ...(TAGS[r.tag_id] || { name: r.tag_id, emoji: '🏷️', color: '#999', bgColor: '#333', desc: 'Unknown tag', category: 'special', tier: 0 }),-- tagId: r.tag_id,-- }));--}----/**-- * Get the user's currently equipped tag (or null).-- */--function getEquippedTag(userId) {-- const d = db.getDb();-- const row = d.prepare('SELECT tag_id FROM user_equipped_tag WHERE user_id = ?').get(userId);-- if (!row) return null;-- const tag = TAGS[row.tag_id];-- if (!tag) return null;-- return { tagId: row.tag_id, ...tag };--}----/**-- * Get tag profile for chat message attachment.-- * Returns { tagId, name, emoji, color, bgColor } or null.-- */--function getTagProfile(userId) {-- return getEquippedTag(userId);--}----/**-- * Grant a tag to a user (for special/achievement tags).-- */--function grantTag(userId, tagId, source = 'system') {-- const tag = TAGS[tagId];-- if (!tag) return { error: 'Unknown tag' };-- const d = db.getDb();-- try {-- d.prepare('INSERT OR IGNORE INTO user_tags (user_id, tag_id, source) VALUES (?, ?, ?)').run(userId, tagId, source);-- return { success: true, tag: { tagId, ...tag } };-- } catch (err) {-- return { error: err.message };-- }--}----/**-- * Revoke a tag from a user.-- */--function revokeTag(userId, tagId) {-- const d = db.getDb();-- // Unequip if currently equipped-- d.prepare('DELETE FROM user_equipped_tag WHERE user_id = ? AND tag_id = ?').run(userId, tagId);-- d.prepare('DELETE FROM user_tags WHERE user_id = ? AND tag_id = ?').run(userId, tagId);-- return { success: true };--}----/**-- * Buy a tag from the Tag Master shop.-- * Requires: guardian defeated, tag is shop category, enough gold, not already owned.-- */--async function buyTag(userId, tagId) {-- const tag = TAGS[tagId];-- if (!tag) return { error: 'Unknown tag' };-- if (tag.category !== 'shop') return { error: 'This tag cannot be purchased' };-- if (!tag.cost) return { error: 'This tag has no price' };---- if (!hasDefeatedGuardian(userId)) {-- return { error: 'You must defeat the Tag Guardian before shopping here!' };-- }---- const d = db.getDb();-- const existing = d.prepare('SELECT 1 FROM user_tags WHERE user_id = ? AND tag_id = ?').get(userId, tagId);-- if (existing) return { error: 'You already own this tag' };---- // Spend from the network OpenCoins wallet (idempotent per user+tag).-- try {-- const wallet = require('../monetization/wallet-client');-- const result = await wallet.debit(userId, tag.cost, `Tag purchase: ${tagId}`, `live:tag_purchase:${userId}:${tagId}`);-- if (!result) return { error: 'OpenCoins wallet unavailable — link your OpenVibe.Network account' };-- } catch (e) {-- if (e && e.status === 409) return { error: `Not enough OpenCoins. Need ${tag.cost}.` };-- return { error: 'Purchase failed — wallet unavailable' };-- }---- d.prepare('INSERT INTO user_tags (user_id, tag_id, source) VALUES (?, ?, ?)').run(userId, tagId, 'shop');-- return { success: true, tag: { tagId, ...tag }, cost: tag.cost };--}----/**-- * Equip a tag (must own it).-- */--function equipTag(userId, tagId) {-- const tag = TAGS[tagId];-- if (!tag) return { error: 'Unknown tag' };---- const d = db.getDb();-- const owned = d.prepare('SELECT 1 FROM user_tags WHERE user_id = ? AND tag_id = ?').get(userId, tagId);-- if (!owned) return { error: 'You don\'t own this tag' };---- d.prepare('INSERT OR REPLACE INTO user_equipped_tag (user_id, tag_id) VALUES (?, ?)').run(userId, tagId);-- return { success: true, tag: { tagId, ...tag } };--}----/**-- * Unequip the current tag.-- */--function unequipTag(userId) {-- const d = db.getDb();-- d.prepare('DELETE FROM user_equipped_tag WHERE user_id = ?').run(userId);-- return { success: true };--}----/**-- * Get the shop catalog (only shop-category tags).-- */--function getShopTags() {-- return Object.entries(TAGS)-- .filter(([_, t]) => t.category === 'shop')-- .map(([id, t]) => ({ tagId: id, ...t }))-- .sort((a, b) => a.cost - b.cost);--}----/**-- * Get all tags for the catalog/wiki view (all categories).-- */--function getAllTags() {-- return Object.entries(TAGS).map(([id, t]) => ({ tagId: id, ...t }));--}----// ── XP helper (duplicated locally to avoid circular dep) ─────--function xpToLevel(xp) {-- return Math.floor(Math.sqrt((xp || 0) / 25)) + 1;--}----module.exports = {-- TAGS,-- TAG_GUARDIAN,-- ensureTagTables,-- // Guardian-- hasDefeatedGuardian,-- fightGuardian,-- // Tag CRUD-- getUserTags,-- getEquippedTag,-- getTagProfile,-- grantTag,-- revokeTag,-- buyTag,-- equipTag,-- unequipTag,-- // Shop-- getShopTags,-- getAllTags,--};@@@ -1107,9 +1107,8 @@ async function start() {db.initDb();// Initialize cosmetics tablescosmeticsModule.ensureTables();-- // Initialize tags tables-- const tagsModule = require('./game/tags');-- tagsModule.ensureTagTables();++ // Chat tag tables (read-only tags, server/chat/tags.js)++ require('./chat/tags').ensureTagTables();// Initialize DM tablesconst dm = require('./chat/dm');dm.ensureTables();@@@ -1573,7 +1572,7 @@ async function start() {function startDrill() {db.initDb();cosmeticsModule.ensureTables();-- require('./game/tags').ensureTagTables();++ require('./chat/tags').ensureTagTables();require('./chat/dm').ensureTables();console.log(`[Drill] Database ready: ${paths.dbPath()}`);// Its port taken: stop (the process-wide handler would log EADDRINUSE and keep running unready).nnew file mode 100644@@@ -0,0 +1,112 @@++/**++ * Legacy HoboQuest remnants are read-only in Live.++ *++ * OpenVibe.Games owns the game (and imports Live's legacy game_* rows). Live's chat profile card++ * used the old game engine's getPlayer(), which INSERTed a placeholder game_players row for every++ * profile anyone opened (production had 70, the newest days old). The engine, its item tables and++ * the RS-Companion import script are gone; the profile card reads db.getLegacyGameProfile(), which++ * never writes, and chat tags come from the read-only server/chat/tags.js.++ *++ * node test/legacy-game-readonly.test.js++ */++'use strict';++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++++const ROOT = path.join(__dirname, '..');++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-legacy-game-'));++process.env.DB_PATH = path.join(tmp, 'live.db');++process.env.NODE_ENV = 'test';++const quiet = console.log;++console.log = () => {};++console.warn = () => {};++++const db = require('../server/db/database');++db.initDb();++const raw = db.getDb();++const auth = require('../server/auth/auth');++auth.optionalAuth = (req, res, next) => next();++auth.requireAuth = (req, res) => res.status(401).json({ error: 'Authentication required' });++++for (const [id, name] of [[1, 'veteran'], [2, 'newcomer']]) {++ raw.prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role) VALUES (?, ?, ?, ?, 'x', 'user')`).run(id, name, name, `${name}@x`);++}++++(async () => {++ // A fresh install has no game_players table at all: nothing to show, nothing created.++ assert.strictEqual(db.getLegacyGameProfile(2), null);++ assert.ok(!raw.prepare("SELECT 1 FROM sqlite_master WHERE name = 'game_players'").get(), 'Live never creates the game tables');++++ // Production has the legacy table (the shape the old schema created, without smithing_xp).++ raw.exec(`CREATE TABLE game_players (user_id INTEGER PRIMARY KEY, x REAL, y REAL, mining_xp INTEGER DEFAULT 0, fishing_xp INTEGER DEFAULT 0,++ woodcut_xp INTEGER DEFAULT 0, farming_xp INTEGER DEFAULT 0, combat_xp INTEGER DEFAULT 0, crafting_xp INTEGER DEFAULT 0,++ agility_xp INTEGER DEFAULT 0, total_coins_earned INTEGER DEFAULT 0)`);++ raw.prepare('INSERT INTO game_players (user_id, mining_xp, combat_xp, total_coins_earned) VALUES (1, 2500, 100, 42)').run();++ const count = () => raw.prepare('SELECT COUNT(*) AS n FROM game_players').get().n;++++ const g = db.getLegacyGameProfile(1);++ assert.strictEqual(g.mining_level, 11, 'levels use the game\'s formula (sqrt(xp/25)+1)');++ assert.strictEqual(g.combat_level, 3);++ assert.strictEqual(g.smithing_level, 1, 'a column the old table lacks counts as level 1');++ assert.strictEqual(g.total_level, 11 + 3 + 6, 'eight skills summed, like the old getPlayer()');++ assert.strictEqual(g.total_coins_earned, 42);++++ // The chat profile card (Live's route and the one OpenVibe.Chat reads) never adds a row.++ const express = require('express');++ const app = express();++ app.use('/api/chat', require('../server/chat/routes'));++ const server = http.createServer(app).listen(0);++ const get = (p) => new Promise((resolve, reject) => {++ http.get({ port: server.address().port, path: p }, (res) => {++ let text = '';++ res.on('data', (c) => { text += c; });++ res.on('end', () => resolve({ status: res.statusCode, json: JSON.parse(text || 'null') }));++ }).on('error', reject);++ });++ let r = await get('/api/chat/user/newcomer/profile');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.json.game, undefined, 'no legacy game block for someone who never played');++ assert.strictEqual(count(), 1, 'and viewing their profile created no game_players row');++ r = await get('/api/chat/user/veteran/profile');++ assert.strictEqual(r.json.game.total_level, 20, 'a legacy player still shows their skills');++ assert.strictEqual(count(), 1);++ server.close();++++ // Chat tags: read-only, and still shown.++ const tags = require('../server/chat/tags');++ tags.ensureTagTables();++ raw.prepare("INSERT INTO user_tags (user_id, tag_id, source) VALUES (1, 'legacy', 'migration')").run();++ raw.prepare("INSERT INTO user_equipped_tag (user_id, tag_id) VALUES (1, 'legacy')").run();++ assert.strictEqual(tags.getTagProfile(1).name, 'Legacy');++ assert.strictEqual(tags.getTagProfile(2), null);++ for (const writer of ['grantTag', 'revokeTag', 'buyTag', 'equipTag', 'unequipTag', 'fightGuardian']) {++ assert.strictEqual(tags[writer], undefined, `tags.${writer} is gone (the game owns tag grants now)`);++ }++++ // The engine is gone and nothing reaches for it.++ assert.ok(!fs.existsSync(path.join(ROOT, 'server/game')), 'server/game/ is retired');++ const offenders = [];++ const walk = (dir) => {++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {++ const p = path.join(dir, e.name);++ if (e.isDirectory()) { if (e.name !== 'node_modules') walk(p); } else if (e.name.endsWith('.js') && /require\(['"][./]*\/?game\/(game-engine|tags|items|cosmetics)['"]\)/.test(fs.readFileSync(p, 'utf8'))) offenders.push(path.relative(ROOT, p));++ }++ };++ walk(path.join(ROOT, 'server'));++ walk(path.join(ROOT, 'scripts'));++ assert.deepStrictEqual(offenders, []);++ const serverSrc = [];++ const collect = (dir) => { for (const e of fs.readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, e.name); if (e.isDirectory()) collect(p); else if (e.name.endsWith('.js')) serverSrc.push(fs.readFileSync(p, 'utf8')); } };++ collect(path.join(ROOT, 'server'));++ assert.ok(!serverSrc.some((s) => /INSERT[^;`'"]*INTO\s+game_players/i.test(s)), 'no server code inserts game_players rows');++++ fs.rmSync(tmp, { recursive: true, force: true });++ quiet('legacy-game-readonly: ok');++ process.exit(0);++})().catch((err) => {++ quiet(err);++ process.exit(1);++});ddeleted file mode 100644@@@ -1,339 +0,0 @@--/**-- * Tags System — Unit Tests-- *-- * Stand-alone test file using Node assert.-- * Creates a temporary SQLite DB in /tmp/ so no production data is touched.-- *-- * Run: node test/test-tags.js-- */----const assert = require('assert');--const path = require('path');--const fs = require('fs');----// ── Set up a temporary DB before requiring any modules ───────--const tmpDb = `/tmp/openvibe-tags-test-${Date.now()}.db`;--process.env.DB_PATH = tmpDb;----const Database = require('better-sqlite3');----// We need to bootstrap the minimum schema that the tags module expects:--// users table + game_players table--function setupTestDb() {-- const d = new Database(tmpDb);-- d.pragma('journal_mode = WAL');-- d.pragma('foreign_keys = ON');-- d.exec(`-- CREATE TABLE IF NOT EXISTS users (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- username TEXT UNIQUE NOT NULL,-- display_name TEXT,-- openvibe_coins_balance INTEGER DEFAULT 0,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP-- );-- CREATE TABLE IF NOT EXISTS game_players (-- user_id INTEGER PRIMARY KEY,-- display_name TEXT,-- hp INTEGER DEFAULT 100,-- attack INTEGER DEFAULT 10,-- defense INTEGER DEFAULT 5,-- combat_xp INTEGER DEFAULT 0,-- FOREIGN KEY (user_id) REFERENCES users(id)-- );-- `);-- d.close();--}----setupTestDb();----// Now require the modules (they'll use the test DB via DB_PATH env)--const db = require('../server/db/database');--db.initDb(); // runs schema.sql — adds full platform tables on top of our seed----const tags = require('../server/game/tags');--tags.ensureTagTables();----// ── Test Helpers ─────────────────────────────────────────────--let passed = 0;--let failed = 0;--const errors = [];----function test(name, fn) {-- try {-- fn();-- passed++;-- console.log(` ✅ ${name}`);-- } catch (e) {-- failed++;-- errors.push({ name, error: e });-- console.log(` ❌ ${name}: ${e.message}`);-- }--}----function describe(section, fn) {-- console.log(`\n─── ${section} ───`);-- fn();--}----// ── Create test users ────────────────────────────────────────--function createTestUser(username, coins = 0) {-- const d = db.getDb();-- d.prepare('INSERT INTO users (username, display_name, openvibe_coins_balance) VALUES (?, ?, ?)').run(username, username, coins);-- const user = d.prepare('SELECT id FROM users WHERE username = ?').get(username);-- d.prepare('INSERT OR IGNORE INTO game_players (user_id, display_name, combat_xp) VALUES (?, ?, ?)').run(user.id, username, 0);-- return user.id;--}----// ══════════════════════════════════════════════════════════════--// TESTS--// ══════════════════════════════════════════════════════════════----const userId1 = createTestUser('TestUser1', 10000);--const userId2 = createTestUser('TestUser2', 100);--const userId3 = createTestUser('PatrickTest', 5000);----describe('Tag Catalog', () => {-- test('TAGS object has expected entries', () => {-- assert.ok(tags.TAGS.cfo, 'CFO tag exists');-- assert.ok(tags.TAGS.noob, 'Noob tag exists');-- assert.ok(tags.TAGS.legend, 'Legend tag exists');-- assert.ok(tags.TAGS.legacy, 'Legacy tag exists');-- });---- test('getShopTags returns only shop-category tags', () => {-- const shopTags = tags.getShopTags();-- assert.ok(shopTags.length > 0, 'Some shop tags exist');-- for (const t of shopTags) {-- assert.strictEqual(t.category, 'shop', `${t.tagId} is shop category`);-- assert.ok(typeof t.cost === 'number' && t.cost > 0, `${t.tagId} has a price`);-- }-- });---- test('getAllTags returns all categories', () => {-- const all = tags.getAllTags();-- const categories = new Set(all.map(t => t.category));-- assert.ok(categories.has('special'), 'Has special tags');-- assert.ok(categories.has('shop'), 'Has shop tags');-- assert.ok(categories.has('achievement'), 'Has achievement tags');-- });---- test('shop tags are sorted by cost ascending', () => {-- const shopTags = tags.getShopTags();-- for (let i = 1; i < shopTags.length; i++) {-- assert.ok(shopTags[i].cost >= shopTags[i - 1].cost, 'Sorted by cost');-- }-- });--});----describe('Grant & Equip', () => {-- test('grantTag gives a tag to a user', () => {-- const result = tags.grantTag(userId1, 'cfo', 'test');-- assert.ok(result.success, 'Grant succeeded');-- assert.strictEqual(result.tag.tagId, 'cfo');-- });---- test('getUserTags returns the granted tag', () => {-- const owned = tags.getUserTags(userId1);-- assert.strictEqual(owned.length, 1);-- assert.strictEqual(owned[0].tagId, 'cfo');-- });---- test('grantTag is idempotent (INSERT OR IGNORE)', () => {-- const result = tags.grantTag(userId1, 'cfo', 'test');-- assert.ok(result.success);-- const owned = tags.getUserTags(userId1);-- assert.strictEqual(owned.length, 1, 'Still only 1 tag');-- });---- test('equipTag works', () => {-- const result = tags.equipTag(userId1, 'cfo');-- assert.ok(result.success);-- assert.strictEqual(result.tag.name, 'CFO');-- });---- test('getEquippedTag returns the equipped tag', () => {-- const eq = tags.getEquippedTag(userId1);-- assert.ok(eq, 'Tag is equipped');-- assert.strictEqual(eq.tagId, 'cfo');-- assert.strictEqual(eq.name, 'CFO');-- assert.strictEqual(eq.emoji, '💼');-- });---- test('getTagProfile returns the same as getEquippedTag', () => {-- const profile = tags.getTagProfile(userId1);-- assert.ok(profile);-- assert.strictEqual(profile.tagId, 'cfo');-- });---- test('equipTag fails if user doesn\'t own the tag', () => {-- const result = tags.equipTag(userId2, 'cfo');-- assert.ok(result.error, 'Should fail');-- assert.ok(result.error.includes("don't own"), result.error);-- });---- test('unequipTag removes equipped tag', () => {-- const result = tags.unequipTag(userId1);-- assert.ok(result.success);-- const eq = tags.getEquippedTag(userId1);-- assert.strictEqual(eq, null, 'No tag equipped');-- });---- test('revokeTag removes a tag', () => {-- tags.equipTag(userId1, 'cfo'); // re-equip first-- const result = tags.revokeTag(userId1, 'cfo');-- assert.ok(result.success);-- const owned = tags.getUserTags(userId1);-- assert.strictEqual(owned.length, 0, 'No tags owned');-- const eq = tags.getEquippedTag(userId1);-- assert.strictEqual(eq, null, 'Unequipped on revoke');-- });--});----describe('Tag Shop (Buy)', () => {-- test('buyTag fails without defeating guardian', () => {-- const result = tags.buyTag(userId1, 'noob');-- assert.ok(result.error);-- assert.ok(result.error.includes('Tag Guardian'), result.error);-- });---- test('manually mark guardian defeated, then buy works', () => {-- const d = db.getDb();-- d.prepare('INSERT OR IGNORE INTO tag_guardian_defeats (user_id) VALUES (?)').run(userId1);---- const result = tags.buyTag(userId1, 'noob');-- assert.ok(result.success, 'Buy succeeded');-- assert.strictEqual(result.tag.tagId, 'noob');-- assert.strictEqual(result.cost, 50);-- });---- test('buying deducts coins', () => {-- const user = db.getUserById(userId1);-- assert.strictEqual(user.openvibe_coins_balance, 10000 - 50);-- });---- test('buyTag fails if already owned', () => {-- const result = tags.buyTag(userId1, 'noob');-- assert.ok(result.error);-- assert.ok(result.error.includes('already own'), result.error);-- });---- test('buyTag fails for special category tags', () => {-- const d = db.getDb();-- d.prepare('INSERT OR IGNORE INTO tag_guardian_defeats (user_id) VALUES (?)').run(userId2);---- const result = tags.buyTag(userId2, 'cfo');-- assert.ok(result.error);-- assert.ok(result.error.includes('cannot be purchased'), result.error);-- });---- test('buyTag fails with insufficient gold', () => {-- const result = tags.buyTag(userId2, 'legend'); // costs 5000, user2 has 100-- assert.ok(result.error);-- assert.ok(result.error.includes('Not enough'), result.error);-- });---- test('buyTag fails for unknown tagId', () => {-- const result = tags.buyTag(userId1, 'nonexistent_tag');-- assert.ok(result.error);-- assert.ok(result.error.includes('Unknown'), result.error);-- });--});----describe('Tag Guardian Combat', () => {-- test('fightGuardian fails without game character', () => {-- // Create a user with no game_players row-- const d = db.getDb();-- d.prepare('INSERT INTO users (username, display_name, openvibe_coins_balance) VALUES (?, ?, ?)').run('NoGameChar', 'NoGameChar', 0);-- const noGameUser = d.prepare('SELECT id FROM users WHERE username = ?').get('NoGameChar');---- const result = tags.fightGuardian(noGameUser.id);-- assert.ok(result.error);-- assert.ok(result.error.includes('No game character'), result.error);-- });---- test('fightGuardian fails if combat level too low', () => {-- // userId2 has 0 combat_xp → level 1, needs level 3-- const result = tags.fightGuardian(userId2);-- assert.ok(result.error);-- assert.ok(result.error.includes('Combat Lv'), result.error);-- });---- test('fightGuardian works with sufficient combat level', () => {-- // Give userId3 enough combat XP for level 3: level = floor(sqrt(xp/25)) + 1-- // Level 3: floor(sqrt(xp/25)) = 2, sqrt(xp/25)>=2, xp/25>=4, xp>=100-- const d = db.getDb();-- d.prepare('UPDATE game_players SET combat_xp = 200, attack = 50, defense = 20 WHERE user_id = ?').run(userId3);---- const result = tags.fightGuardian(userId3);-- assert.ok(result.success, 'Fight initiated');-- assert.ok(result.log.length > 0, 'Has combat log');-- assert.ok(typeof result.won === 'boolean', 'Has won/lost result');-- });---- test('hasDefeatedGuardian returns correct status for user without defeat', () => {-- // userId2 never fought-- assert.strictEqual(tags.hasDefeatedGuardian(userId2), false);-- });---- test('fightGuardian returns already-defeated message if already won', () => {-- if (tags.hasDefeatedGuardian(userId3)) {-- const result = tags.fightGuardian(userId3);-- assert.ok(result.error);-- assert.ok(result.error.includes('already defeated'), result.error);-- }-- });--});----describe('Multiple Tags', () => {-- test('user can own multiple tags', () => {-- tags.grantTag(userId1, 'legacy', 'test');-- tags.grantTag(userId1, 'alpha_tester', 'test');-- const owned = tags.getUserTags(userId1);-- // Already owns noob from buy test-- assert.ok(owned.length >= 3, `Owns ${owned.length} tags`);-- });---- test('can switch between equipped tags', () => {-- tags.equipTag(userId1, 'noob');-- let eq = tags.getEquippedTag(userId1);-- assert.strictEqual(eq.tagId, 'noob');---- tags.equipTag(userId1, 'legacy');-- eq = tags.getEquippedTag(userId1);-- assert.strictEqual(eq.tagId, 'legacy');---- tags.equipTag(userId1, 'alpha_tester');-- eq = tags.getEquippedTag(userId1);-- assert.strictEqual(eq.tagId, 'alpha_tester');-- });--});----describe('TAG_GUARDIAN constant', () => {-- test('has required fields', () => {-- const g = tags.TAG_GUARDIAN;-- assert.ok(g.name, 'Has name');-- assert.ok(g.hp > 0, 'Has HP');-- assert.ok(g.atk > 0, 'Has attack');-- assert.ok(g.combatLevel > 0, 'Has combat level requirement');-- });--});----// ── Summary ──────────────────────────────────────────────────--console.log(`\n═══════════════════════════════════════════`);--console.log(` Tags Tests: ${passed} passed, ${failed} failed`);--console.log(`═══════════════════════════════════════════`);----if (errors.length > 0) {-- console.log('\nFailures:');-- for (const e of errors) {-- console.log(` ${e.name}:`);-- console.log(` ${e.error.stack || e.error.message}`);-- }--}----// Cleanup--try { fs.unlinkSync(tmpDb); } catch {}--try { fs.unlinkSync(tmpDb + '-wal'); } catch {}--try { fs.unlinkSync(tmpDb + '-shm'); } catch {}----process.exit(failed > 0 ? 1 : 0);
deleted file mode 100644
deleted file mode 100644
@@ -1,316 +0,0 @@
@@ -1,316 +0,0 @@
-#!/usr/bin/env node
-/**
- * OpenVibeGame — Migration Script
- * Ports player data from RS-Companion SQLite DB into OpenVibeGame tables
- *
- * RS-Companion users have TEXT user_ids (e.g. "12345" from RobotStreamer)
- * OpenVibe.Live users have INTEGER ids with usernames
- *
- * Strategy: Match by username (case-insensitive). For each matched user,
- * port XP, inventory, bank, recipes, battle stats, farm plots.
- *
- * Usage:
- * node scripts/migrate-rs-companion.js <path-to-rs-companion.db>
- *
- * Run AFTER the OpenVibeGame schema has been initialized (start the server once first).
- */
-
-const Database = require('better-sqlite3');
-const path = require('path');
-
-// ── Paths ────────────────────────────────────────────────────
-const rsDbPath = process.argv[2];
-if (!rsDbPath) {
- console.error('Usage: node scripts/migrate-rs-companion.js <path-to-rs-companion.db>');
- process.exit(1);
-}
-const openvibeDbPath = path.resolve(__dirname, '../data/live.db');
-
-console.log(`[Migration] RS-Companion DB: ${rsDbPath}`);
-console.log(`[Migration] OpenVibe.Live DB: ${openvibeDbPath}`);
-
-// ── Open databases ───────────────────────────────────────────
-const rsDb = new Database(rsDbPath, { readonly: true });
-const openvibeDb = new Database(openvibeDbPath);
-openvibeDb.pragma('journal_mode = WAL');
-
-// ── Ensure game tables exist ─────────────────────────────────
-const fs = require('fs');
-const schema = fs.readFileSync(path.resolve(__dirname, '../server/game/schema.sql'), 'utf8');
-openvibeDb.exec(schema);
-
-// ── Build username → openvibe user id map ────────────────────────
-const openvibeUsers = openvibeDb.prepare('SELECT id, username, display_name FROM users').all();
-const openvibeMap = new Map(); // lowercase username → openvibe user id
-for (const u of openvibeUsers) {
- openvibeMap.set(u.username.toLowerCase(), u.id);
- if (u.display_name) openvibeMap.set(u.display_name.toLowerCase(), u.id);
-}
-console.log(`[Migration] ${openvibeUsers.length} OpenVibe.Live users loaded`);
-
-// ── Fetch RS-Companion users ─────────────────────────────────
-const rsUsers = rsDb.prepare('SELECT * FROM users').all();
-console.log(`[Migration] ${rsUsers.length} RS-Companion users found`);
-
-let matched = 0, skipped = 0;
-const userMap = new Map(); // rs user_id → openvibe user id
-
-for (const rsu of rsUsers) {
- const openvibeId = openvibeMap.get(rsu.username.toLowerCase());
- if (!openvibeId) { skipped++; continue; }
- userMap.set(rsu.user_id, openvibeId);
- matched++;
-}
-console.log(`[Migration] Matched ${matched} users, skipped ${skipped}`);
-
-// ── Helper: upsert game_players ──────────────────────────────
-const upsertPlayer = openvibeDb.prepare(`
- INSERT INTO game_players (user_id, display_name, mining_xp, fishing_xp, woodcut_xp, farming_xp, combat_xp, crafting_xp, total_coins_earned)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
- ON CONFLICT(user_id) DO UPDATE SET
- mining_xp = MAX(game_players.mining_xp, excluded.mining_xp),
- fishing_xp = MAX(game_players.fishing_xp, excluded.fishing_xp),
- woodcut_xp = MAX(game_players.woodcut_xp, excluded.woodcut_xp),
- farming_xp = MAX(game_players.farming_xp, excluded.farming_xp),
- combat_xp = MAX(game_players.combat_xp, excluded.combat_xp),
- crafting_xp = MAX(game_players.crafting_xp, excluded.crafting_xp),
- total_coins_earned = MAX(game_players.total_coins_earned, excluded.total_coins_earned)
-`);
-
-// ── Helper: upsert inventory ─────────────────────────────────
-const upsertInv = openvibeDb.prepare(`
- INSERT INTO game_inventory (user_id, item_id, quantity)
- VALUES (?, ?, ?)
- ON CONFLICT(user_id, item_id) DO UPDATE SET quantity = quantity + excluded.quantity
-`);
-
-// ── Helper: upsert bank ─────────────────────────────────────
-const upsertBank = openvibeDb.prepare(`
- INSERT INTO game_bank (user_id, item_id, quantity)
- VALUES (?, ?, ?)
- ON CONFLICT(user_id, item_id) DO UPDATE SET quantity = quantity + excluded.quantity
-`);
-
-// ── Helper: upsert recipes ──────────────────────────────────
-const upsertRecipe = openvibeDb.prepare(`
- INSERT OR IGNORE INTO game_recipes (user_id, recipe_id) VALUES (?, ?)
-`);
-
-// ── Helper: upsert battle stats ─────────────────────────────
-const upsertBattle = openvibeDb.prepare(`
- INSERT INTO game_battle_stats (user_id, battles_won, battles_lost, total_stolen, total_lost, kill_streak, best_streak, kills, deaths)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
- ON CONFLICT(user_id) DO UPDATE SET
- battles_won = MAX(game_battle_stats.battles_won, excluded.battles_won),
- battles_lost = MAX(game_battle_stats.battles_lost, excluded.battles_lost),
- total_stolen = MAX(game_battle_stats.total_stolen, excluded.total_stolen),
- total_lost = MAX(game_battle_stats.total_lost, excluded.total_lost),
- best_streak = MAX(game_battle_stats.best_streak, excluded.best_streak),
- kills = MAX(game_battle_stats.kills, excluded.kills),
- deaths = MAX(game_battle_stats.deaths, excluded.deaths)
-`);
-
-// ══════════════════════════════════════════════════════════════
-// MIGRATE
-// ══════════════════════════════════════════════════════════════
-
-const migrateAll = openvibeDb.transaction(() => {
- let playerCount = 0, invCount = 0, bankCount = 0, recipeCount = 0, battleCount = 0;
-
- // ── 1. Players (XP from skill profiles) ──────────────────
- for (const [rsId, openvibeId] of userMap) {
- const rsu = rsDb.prepare('SELECT * FROM users WHERE user_id = ?').get(rsId);
- if (!rsu) continue;
-
- // Gather XP from profiles
- const fish = rsDb.prepare('SELECT fish_xp FROM fish_profile WHERE user_id = ?').get(rsId);
- const mine = rsDb.prepare('SELECT mine_xp FROM mine_profile WHERE user_id = ?').get(rsId);
- const wc = rsDb.prepare('SELECT wc_xp FROM woodcut_profile WHERE user_id = ?').get(rsId);
-
- // Combat XP from dungeon stats
- let combatXp = 0;
- try {
- const dung = rsDb.prepare('SELECT dungeon_xp FROM dungeon_stats WHERE user_id = ?').get(rsId);
- if (dung) combatXp = dung.dungeon_xp || 0;
- } catch {}
-
- // General XP → crafting XP (repurpose)
- const craftXp = Math.floor((rsu.xp || 0) * 0.3);
-
- upsertPlayer.run(
- openvibeId,
- rsu.username,
- mine?.mine_xp || 0,
- fish?.fish_xp || 0,
- wc?.wc_xp || 0,
- 0, // farming_xp (no direct equivalent)
- combatXp,
- craftXp,
- Math.floor(rsu.coins || 0)
- );
- playerCount++;
- }
- console.log(`[Migration] Ported ${playerCount} player profiles`);
-
- // ── 2. Inventory ─────────────────────────────────────────
- for (const [rsId, openvibeId] of userMap) {
- const items = rsDb.prepare('SELECT item_id, quantity FROM inventory WHERE user_id = ?').all(rsId);
- for (const item of items) {
- if (item.quantity > 0) {
- upsertInv.run(openvibeId, item.item_id, item.quantity);
- invCount++;
- }
- }
- }
- console.log(`[Migration] Ported ${invCount} inventory entries`);
-
- // ── 3. Bank ──────────────────────────────────────────────
- let hasBankItems = false;
- try { rsDb.prepare('SELECT 1 FROM bank_items LIMIT 1').get(); hasBankItems = true; } catch {}
-
- if (hasBankItems) {
- for (const [rsId, openvibeId] of userMap) {
- const items = rsDb.prepare('SELECT item_id, quantity FROM bank_items WHERE user_id = ?').all(rsId);
- for (const item of items) {
- if (item.quantity > 0) {
- upsertBank.run(openvibeId, item.item_id, item.quantity);
- bankCount++;
- }
- }
- }
- }
- console.log(`[Migration] Ported ${bankCount} bank entries`);
-
- // ── 4. Unlocked Recipes ──────────────────────────────────
- let hasRecipes = false;
- try { rsDb.prepare('SELECT 1 FROM unlocked_recipes LIMIT 1').get(); hasRecipes = true; } catch {}
-
- if (hasRecipes) {
- for (const [rsId, openvibeId] of userMap) {
- const recipes = rsDb.prepare('SELECT recipe_id FROM unlocked_recipes WHERE user_id = ?').all(rsId);
- for (const r of recipes) {
- upsertRecipe.run(openvibeId, r.recipe_id);
- recipeCount++;
- }
- }
- }
- console.log(`[Migration] Ported ${recipeCount} recipe unlocks`);
-
- // ── 5. Battle Stats ──────────────────────────────────────
- let hasBattle = false;
- try { rsDb.prepare('SELECT 1 FROM battle_stats LIMIT 1').get(); hasBattle = true; } catch {}
-
- if (hasBattle) {
- for (const [rsId, openvibeId] of userMap) {
- const bs = rsDb.prepare('SELECT * FROM battle_stats WHERE user_id = ?').get(rsId);
- if (!bs) continue;
- upsertBattle.run(
- openvibeId,
- bs.battles_won || 0, bs.battles_lost || 0,
- bs.total_stolen || 0, bs.total_lost || 0,
- bs.kill_streak || 0, bs.best_streak || 0,
- bs.kills || 0, bs.deaths || 0
- );
- battleCount++;
- }
- }
- console.log(`[Migration] Ported ${battleCount} battle stat entries`);
-
- // ── 6. Coins bonus (RS-Companion coins → OpenVibe.Live coins) ──
- let coinsMigrated = 0;
- for (const [rsId, openvibeId] of userMap) {
- const rsu = rsDb.prepare('SELECT coins FROM users WHERE user_id = ?').get(rsId);
- if (!rsu || !rsu.coins || rsu.coins <= 0) continue;
- const bonus = Math.floor(rsu.coins);
- if (bonus > 0) {
- openvibeDb.prepare('UPDATE users SET openvibe_coins_balance = openvibe_coins_balance + ? WHERE id = ?').run(bonus, openvibeId);
- coinsMigrated++;
- }
- }
- console.log(`[Migration] Awarded coin bonuses to ${coinsMigrated} users`);
-
- // ── 7. Cosmetics (activate fx_*/px_* from inventory) ─────
- let cosmeticCount = 0;
- for (const [rsId, openvibeId] of userMap) {
- const fxItems = openvibeDb.prepare(
- "SELECT item_id FROM game_inventory WHERE user_id = ? AND (item_id LIKE 'fx_%' OR item_id LIKE 'px_%') AND quantity > 0"
- ).all(openvibeId);
- for (const item of fxItems) {
- const cat = item.item_id.startsWith('fx_') ? 'name_effect' : 'particle';
- const r = openvibeDb.prepare('INSERT OR IGNORE INTO user_cosmetics (user_id, item_id, category) VALUES (?, ?, ?)').run(openvibeId, item.item_id, cat);
- if (r.changes > 0) cosmeticCount++;
- }
- }
- console.log(`[Migration] Activated ${cosmeticCount} cosmetics`);
-
- // ── 8. Unlocked voices → user_cosmetics ──────────────────
- let voiceCount = 0;
- let hasVoices = false;
- try { rsDb.prepare('SELECT 1 FROM user_voices LIMIT 1').get(); hasVoices = true; } catch {}
-
- if (hasVoices) {
- for (const [rsId, openvibeId] of userMap) {
- const voices = rsDb.prepare('SELECT voice_id FROM user_voices WHERE user_id = ?').all(rsId);
- for (const v of voices) {
- const r = openvibeDb.prepare('INSERT OR IGNORE INTO user_cosmetics (user_id, item_id, category) VALUES (?, ?, ?)').run(openvibeId, v.voice_id, 'voice');
- if (r.changes > 0) voiceCount++;
- }
- }
- }
- console.log(`[Migration] Ported ${voiceCount} voice unlocks`);
-
- // ── 9. Active effects → equipped cosmetics ───────────────
- let equipCount = 0;
- let hasEffects = false;
- try { rsDb.prepare('SELECT 1 FROM active_effects LIMIT 1').get(); hasEffects = true; } catch {}
-
- if (hasEffects) {
- for (const [rsId, openvibeId] of userMap) {
- const effects = rsDb.prepare('SELECT * FROM active_effects WHERE user_id = ?').all(rsId);
- for (const eff of effects) {
- try {
- const data = JSON.parse(eff.data || '{}');
- if (eff.effect_type === 'name_fx' && data.itemId) {
- openvibeDb.prepare('INSERT OR IGNORE INTO user_cosmetics (user_id, item_id, category) VALUES (?, ?, ?)').run(openvibeId, data.itemId, 'name_effect');
- openvibeDb.prepare('INSERT OR REPLACE INTO user_equipped (user_id, slot, item_id) VALUES (?, ?, ?)').run(openvibeId, 'name_effect', data.itemId);
- openvibeDb.prepare('UPDATE game_players SET name_effect = ? WHERE user_id = ?').run(data.itemId, openvibeId);
- equipCount++;
- } else if (eff.effect_type === 'particle_fx' && data.itemId) {
- openvibeDb.prepare('INSERT OR IGNORE INTO user_cosmetics (user_id, item_id, category) VALUES (?, ?, ?)').run(openvibeId, data.itemId, 'particle');
- openvibeDb.prepare('INSERT OR REPLACE INTO user_equipped (user_id, slot, item_id) VALUES (?, ?, ?)').run(openvibeId, 'particle', data.itemId);
- openvibeDb.prepare('UPDATE game_players SET particle_effect = ? WHERE user_id = ?').run(data.itemId, openvibeId);
- equipCount++;
- }
- } catch (e) { /* malformed JSON */ }
- }
- }
- }
-
- // ── 10. Selected voice → equipped ────────────────────────
- let hasVoiceSel = false;
- try { rsDb.prepare('SELECT 1 FROM user_voice_selection LIMIT 1').get(); hasVoiceSel = true; } catch {}
-
- if (hasVoiceSel) {
- for (const [rsId, openvibeId] of userMap) {
- const sel = rsDb.prepare('SELECT voice_id FROM user_voice_selection WHERE user_id = ?').get(rsId);
- if (sel) {
- openvibeDb.prepare('INSERT OR IGNORE INTO user_cosmetics (user_id, item_id, category) VALUES (?, ?, ?)').run(openvibeId, sel.voice_id, 'voice');
- openvibeDb.prepare('INSERT OR REPLACE INTO user_equipped (user_id, slot, item_id) VALUES (?, ?, ?)').run(openvibeId, 'voice', sel.voice_id);
- equipCount++;
- }
- }
- }
- console.log(`[Migration] Equipped ${equipCount} active cosmetics`);
-});
-
-// ── Run ──────────────────────────────────────────────────────
-try {
- migrateAll();
- console.log('\n[Migration] ✅ Migration complete!');
-} catch (err) {
- console.error('\n[Migration] ❌ Migration failed:', err.message);
- process.exit(1);
-} finally {
- rsDb.close();
- openvibeDb.close();
-}
@@ -862,7 +862,7 @@ class ChatServer {
@@ -862,7 +862,7 @@ class ChatServer {
// Attach equipped tag for chat rendering
// Attach equipped tag for chat rendering
try {
try {
- const tags = require('../game/tags');
+ const tags = require('./tags');
const tagProfile = tags.getTagProfile(client.user.id);
const tagProfile = tags.getTagProfile(client.user.id);
if (tagProfile) chatMsg.tag = tagProfile;
if (tagProfile) chatMsg.tag = tagProfile;
} catch { /* non-critical */ }
} catch { /* non-critical */ }
@@ -1061,7 +1061,7 @@ class ChatServer {
@@ -1061,7 +1061,7 @@ class ChatServer {
} catch { /* non-critical */ }
} catch { /* non-critical */ }
try {
try {
- const tags = require('../game/tags');
+ const tags = require('./tags');
const tagProfile = tags.getTagProfile(client.user.id);
const tagProfile = tags.getTagProfile(client.user.id);
if (tagProfile) gottiMsg.tag = tagProfile;
if (tagProfile) gottiMsg.tag = tagProfile;
} catch { /* non-critical */ }
} catch { /* non-critical */ }
@@ -1358,7 +1358,7 @@ class ChatServer {
@@ -1358,7 +1358,7 @@ class ChatServer {
if (cp.hatFX) soundMsg.hatFX = cp.hatFX;
if (cp.hatFX) soundMsg.hatFX = cp.hatFX;
} catch { /* non-critical */ }
} catch { /* non-critical */ }
try {
try {
- const tagProfile = require('../game/tags').getTagProfile(client.user.id);
+ const tagProfile = require('./tags').getTagProfile(client.user.id);
if (tagProfile) soundMsg.tag = tagProfile;
if (tagProfile) soundMsg.tag = tagProfile;
} catch { /* non-critical */ }
} catch { /* non-critical */ }
}
}
@@ -121,28 +121,9 @@ contextRouter.get('/users/profile', (req, res) => {
@@ -121,28 +121,9 @@ contextRouter.get('/users/profile', (req, res) => {
delete profile.openvibe_bucks_balance;
delete profile.openvibe_bucks_balance;
delete profile.last_seen;
delete profile.last_seen;
}
}
- try {
- const game = require('../game/game-engine');
- const player = game.getPlayer(user.id);
- if (player) {
- profile.game = {
- total_level: player.total_level,
- mining_level: player.mining_level,
- fishing_level: player.fishing_level,
- woodcut_level: player.woodcut_level,
- farming_level: player.farming_level,
- combat_level: player.combat_level,
- crafting_level: player.crafting_level,
- mining_xp: player.mining_xp,
- fishing_xp: player.fishing_xp,
- woodcut_xp: player.woodcut_xp,
- farming_xp: player.farming_xp,
- combat_xp: player.combat_xp,
- crafting_xp: player.crafting_xp,
- total_coins_earned: player.total_coins_earned || 0,
- };
- }
- } catch { /* game not initialized or player doesn't exist */ }
+ // Legacy game skills, read-only (never creates a game_players row)
+ const game = db.getLegacyGameProfile(user.id);
+ if (game) profile.game = game;
res.json(profile);
res.json(profile);
} catch (err) {
} catch (err) {
fail(res, 500, 'Failed to get profile');
fail(res, 500, 'Failed to get profile');
@@ -216,7 +197,7 @@ contextRouter.post('/decor', (req, res) => {
@@ -216,7 +197,7 @@ contextRouter.post('/decor', (req, res) => {
const ids = (Array.isArray(req.body?.user_ids) ? req.body.user_ids : []).map(Number).filter(Number.isInteger).slice(0, 500);
const ids = (Array.isArray(req.body?.user_ids) ? req.body.user_ids : []).map(Number).filter(Number.isInteger).slice(0, 500);
let cosmetics = null, tags = null;
let cosmetics = null, tags = null;
try { cosmetics = require('../monetization/cosmetics'); } catch { /* */ }
try { cosmetics = require('../monetization/cosmetics'); } catch { /* */ }
- try { tags = require('../game/tags'); } catch { /* */ }
+ try { tags = require('./tags'); } catch { /* */ }
const decor = {};
const decor = {};
for (const id of ids) {
for (const id of ids) {
let cosmetic = {}, tag = null;
let cosmetic = {}, tag = null;
@@ -24,7 +24,7 @@ const router = express.Router();
@@ -24,7 +24,7 @@ const router = express.Router();
function enrichMessagesWithCosmetics(messages) {
function enrichMessagesWithCosmetics(messages) {
let cosmetics = null, tags = null;
let cosmetics = null, tags = null;
try { cosmetics = require('../monetization/cosmetics'); } catch { /* */ }
try { cosmetics = require('../monetization/cosmetics'); } catch { /* */ }
- try { tags = require('../game/tags'); } catch { /* */ }
+ try { tags = require('./tags'); } catch { /* */ }
if ((!cosmetics && !tags) || !Array.isArray(messages)) return messages;
if ((!cosmetics && !tags) || !Array.isArray(messages)) return messages;
const cache = new Map();
const cache = new Map();
for (const m of messages) {
for (const m of messages) {
@@ -270,7 +270,7 @@ router.post('/send', requireAuth, (req, res) => {
@@ -270,7 +270,7 @@ router.post('/send', requireAuth, (req, res) => {
// Attach cosmetics
// Attach cosmetics
try {
try {
- const cosmetics = require('../game/cosmetics');
+ const cosmetics = require('../monetization/cosmetics');
const cosmeticProfile = cosmetics.getCosmeticProfile(req.user.id);
const cosmeticProfile = cosmetics.getCosmeticProfile(req.user.id);
if (cosmeticProfile.nameFX) chatMsg.nameFX = cosmeticProfile.nameFX;
if (cosmeticProfile.nameFX) chatMsg.nameFX = cosmeticProfile.nameFX;
if (cosmeticProfile.particleFX) chatMsg.particleFX = cosmeticProfile.particleFX;
if (cosmeticProfile.particleFX) chatMsg.particleFX = cosmeticProfile.particleFX;
@@ -280,7 +280,7 @@ router.post('/send', requireAuth, (req, res) => {
@@ -280,7 +280,7 @@ router.post('/send', requireAuth, (req, res) => {
// Attach tag
// Attach tag
try {
try {
- const tags = require('../game/tags');
+ const tags = require('./tags');
const tagProfile = tags.getTagProfile(req.user.id);
const tagProfile = tags.getTagProfile(req.user.id);
if (tagProfile) chatMsg.tag = tagProfile;
if (tagProfile) chatMsg.tag = tagProfile;
} catch { /* non-critical */ }
} catch { /* non-critical */ }
@@ -375,29 +375,9 @@ router.get('/user/:username/profile', optionalAuth, (req, res) => {
@@ -375,29 +375,9 @@ router.get('/user/:username/profile', optionalAuth, (req, res) => {
delete profile.last_seen;
delete profile.last_seen;
}
}
- // Add game stats if available
- try {
- const game = require('../game/game-engine');
- const player = game.getPlayer(user.id);
- if (player) {
- profile.game = {
- total_level: player.total_level,
- mining_level: player.mining_level,
- fishing_level: player.fishing_level,
- woodcut_level: player.woodcut_level,
- farming_level: player.farming_level,
- combat_level: player.combat_level,
- crafting_level: player.crafting_level,
- mining_xp: player.mining_xp,
- fishing_xp: player.fishing_xp,
- woodcut_xp: player.woodcut_xp,
- farming_xp: player.farming_xp,
- combat_xp: player.combat_xp,
- crafting_xp: player.crafting_xp,
- total_coins_earned: player.total_coins_earned || 0,
- };
- }
- } catch { /* game not initialized or player doesn't exist */ }
+ // Legacy game skills, read-only (never creates a game_players row)
+ const game = db.getLegacyGameProfile(user.id);
+ if (game) profile.game = game;
res.json(profile);
res.json(profile);
} catch (err) {
} catch (err) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,98 @@
@@ -0,0 +1,98 @@
+'use strict';
+/**
+ * Chat tags: the badge shown next to a username in chat (read-only).
+ *
+ * The tags came from the legacy HoboQuest game that ran inside Live (Tag Guardian fights, the Tag
+ * Master shop, achievement grants). OpenVibe.Games owns that game now, so Live no longer writes any
+ * of it: this module only reads what a user owns and has equipped, for chat lines, chat history
+ * and OpenVibe.Chat's decor lookups. The tables stay, so existing equipped tags keep showing.
+ */
+const db = require('../db/database');
+
+// ── Tag catalog ──────────────────────────────────────────────
+// category: 'special' (granted), 'shop' (was sold by the Tag Master NPC), 'achievement' (game milestone)
+const TAGS = {
+ // ── Special (granted, not purchasable) ───────────────────
+ cfo: { name: 'CFO', emoji: '💼', color: '#f59e0b', bgColor: '#451a03', desc: 'Chief Financial Officer — legacy RS-Companion VIP', category: 'special', tier: 5 },
+ founder: { name: 'Founder', emoji: '⭐', color: '#facc15', bgColor: '#422006', desc: 'Original OpenVibe.Live founder', category: 'special', tier: 5 },
+ legacy: { name: 'Legacy', emoji: '🏛️', color: '#a78bfa', bgColor: '#1e1b4b', desc: 'Migrated from RS-Companion', category: 'special', tier: 3 },
+ alpha_tester: { name: 'Alpha Tester', emoji: '🧪', color: '#34d399', bgColor: '#022c22', desc: 'Tested during alpha phase', category: 'special', tier: 3 },
+ mod: { name: 'Mod', emoji: '🛡️', color: '#60a5fa', bgColor: '#172554', desc: 'Platform moderator', category: 'special', tier: 4 },
+ admin: { name: 'Admin', emoji: '⚡', color: '#f87171', bgColor: '#450a0a', desc: 'Platform administrator', category: 'special', tier: 5 },
+ streamer: { name: 'Streamer', emoji: '📡', color: '#c084fc', bgColor: '#3b0764', desc: 'Verified streamer', category: 'special', tier: 4 },
+ vip: { name: 'VIP', emoji: '👑', color: '#fbbf24', bgColor: '#78350f', desc: 'Very Important Person', category: 'special', tier: 4 },
+ developer: { name: 'Dev', emoji: '🔧', color: '#38bdf8', bgColor: '#0c4a6e', desc: 'Platform developer', category: 'special', tier: 5 },
+
+ // ── Shop Tags (purchasable from Tag Master NPC) ──────────
+ noob: { name: 'Noob', emoji: '🐣', color: '#86efac', bgColor: '#052e16', desc: 'Everyone starts somewhere', category: 'shop', tier: 1, cost: 50 },
+ chatterbox: { name: 'Chatterbox', emoji: '💬', color: '#93c5fd', bgColor: '#1e3a5f', desc: 'Professional yapper', category: 'shop', tier: 1, cost: 100 },
+ warrior: { name: 'Warrior', emoji: '⚔️', color: '#fca5a5', bgColor: '#450a0a', desc: 'Battle-hardened fighter', category: 'shop', tier: 2, cost: 250 },
+ miner: { name: 'Miner', emoji: '⛏️', color: '#d4d4d8', bgColor: '#27272a', desc: 'Deep rock delver', category: 'shop', tier: 2, cost: 250 },
+ angler: { name: 'Angler', emoji: '🎣', color: '#7dd3fc', bgColor: '#0c4a6e', desc: 'Master of the rod', category: 'shop', tier: 2, cost: 250 },
+ lumberjack: { name: 'Lumberjack', emoji: '🪓', color: '#a3e635', bgColor: '#1a2e05', desc: 'Timber specialist', category: 'shop', tier: 2, cost: 250 },
+ farmer: { name: 'Farmer', emoji: '🌾', color: '#fde68a', bgColor: '#451a03', desc: 'Crop connoisseur', category: 'shop', tier: 2, cost: 250 },
+ chef: { name: 'Chef', emoji: '👨🍳', color: '#fdba74', bgColor: '#431407', desc: 'Culinary master', category: 'shop', tier: 2, cost: 300 },
+ merchant: { name: 'Merchant', emoji: '🏪', color: '#fcd34d', bgColor: '#78350f', desc: 'Shrewd trader', category: 'shop', tier: 3, cost: 500 },
+ explorer: { name: 'Explorer', emoji: '🧭', color: '#6ee7b7', bgColor: '#022c22', desc: 'Seen it all', category: 'shop', tier: 3, cost: 500 },
+ assassin: { name: 'Assassin', emoji: '🗡️', color: '#a78bfa', bgColor: '#1e1b4b', desc: 'Silent and deadly', category: 'shop', tier: 3, cost: 750 },
+ overlord: { name: 'Overlord', emoji: '🔱', color: '#e879f9', bgColor: '#4a044e', desc: 'Rules with an iron fist', category: 'shop', tier: 4, cost: 1500 },
+ legend: { name: 'Legend', emoji: '🏆', color: '#fbbf24', bgColor: '#451a03', desc: 'Etched in history', category: 'shop', tier: 5, cost: 5000 },
+ void_walker: { name: 'Void Walker', emoji: '🕳️', color: '#818cf8', bgColor: '#0f0f23', desc: 'From beyond the abyss', category: 'shop', tier: 5, cost: 10000 },
+
+ // ── Achievement Tags (earned, not purchasable) ───────────
+ dragon_slayer: { name: 'Dragonslayer', emoji: '🐉', color: '#ef4444', bgColor: '#450a0a', desc: 'Slew a dragon', category: 'achievement', tier: 4 },
+ dungeon_master: { name: 'Dungeon Master',emoji: '🏰', color: '#c084fc', bgColor: '#3b0764', desc: 'Won 50 dungeon fights', category: 'achievement', tier: 4 },
+ fish_lord: { name: 'Fish Lord', emoji: '🐟', color: '#22d3ee', bgColor: '#083344', desc: 'Caught every fish species', category: 'achievement', tier: 4 },
+ big_spender: { name: 'Big Spender', emoji: '💰', color: '#fbbf24', bgColor: '#78350f', desc: 'Spent 50,000 gold total', category: 'achievement', tier: 3 },
+ survivor: { name: 'Survivor', emoji: '💪', color: '#4ade80', bgColor: '#052e16', desc: 'Won 100 PvP battles', category: 'achievement', tier: 4 },
+};
+
+function ensureTagTables() {
+ db.getDb().exec(`
+ CREATE TABLE IF NOT EXISTS user_tags (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ tag_id TEXT NOT NULL,
+ source TEXT DEFAULT 'shop',
+ granted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(user_id, tag_id),
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+ );
+ CREATE TABLE IF NOT EXISTS user_equipped_tag (
+ user_id INTEGER NOT NULL PRIMARY KEY,
+ tag_id TEXT NOT NULL,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+ );
+ `);
+ try { db.getDb().exec('CREATE INDEX IF NOT EXISTS idx_user_tags_user ON user_tags(user_id)'); } catch { /* */ }
+}
+
+/** Every tag a user owns. */
+function getUserTags(userId) {
+ const rows = db.getDb().prepare('SELECT tag_id, source, granted_at FROM user_tags WHERE user_id = ?').all(userId);
+ return rows.map((r) => ({
+ ...r,
+ ...(TAGS[r.tag_id] || { name: r.tag_id, emoji: '🏷️', color: '#999', bgColor: '#333', desc: 'Unknown tag', category: 'special', tier: 0 }),
+ tagId: r.tag_id,
+ }));
+}
+
+/** The user's equipped tag, or null. */
+function getEquippedTag(userId) {
+ const row = db.getDb().prepare('SELECT tag_id FROM user_equipped_tag WHERE user_id = ?').get(userId);
+ if (!row) return null;
+ const tag = TAGS[row.tag_id];
+ return tag ? { tagId: row.tag_id, ...tag } : null;
+}
+
+/** What a chat line carries: { tagId, name, emoji, color, bgColor, … } or null. */
+function getTagProfile(userId) {
+ return getEquippedTag(userId);
+}
+
+/** The whole catalog. */
+function getAllTags() {
+ return Object.entries(TAGS).map(([id, t]) => ({ tagId: id, ...t }));
+}
+
+module.exports = { TAGS, ensureTagTables, getUserTags, getEquippedTag, getTagProfile, getAllTags };
@@ -6580,6 +6580,33 @@ function getUserTotalGameLevel(userId) {
@@ -6580,6 +6580,33 @@ function getUserTotalGameLevel(userId) {
}
}
}
}
+/**
+ * The legacy HoboQuest skills a user earned while the game ran inside Live, for the chat profile
+ * card: { total_level, <skill>_level, <skill>_xp, total_coins_earned } or null. Read-only: it never
+ * creates a game_players row (the old game engine's getPlayer() inserted one for every profile
+ * viewed). OpenVibe.Games owns the game now and imports these rows from here.
+ */
+function getLegacyGameProfile(userId) {
+ if (!userId) return null;
+ let p;
+ try {
+ p = get('SELECT * FROM game_players WHERE user_id = ?', [userId]);
+ } catch {
+ return null; // no game_players table (a fresh install)
+ }
+ if (!p) return null;
+ const xpToLevel = (xp) => Math.floor(Math.sqrt((xp || 0) / 25)) + 1;
+ const skills = ['mining', 'fishing', 'woodcut', 'farming', 'combat', 'crafting', 'smithing', 'agility'];
+ const out = { total_level: 0, total_coins_earned: p.total_coins_earned || 0 };
+ for (const s of skills) {
+ const xp = p[`${s}_xp`] || 0;
+ out[`${s}_xp`] = xp;
+ out[`${s}_level`] = xpToLevel(xp);
+ out.total_level += out[`${s}_level`];
+ }
+ return out;
+}
+
// ── Anon IP Mapping ─────────────────────────────────
// ── Anon IP Mapping ─────────────────────────────────
/**
/**
@@ -7673,7 +7700,7 @@ module.exports = {
@@ -7673,7 +7700,7 @@ module.exports = {
getChannelModerators, getChannelsByModerator,
getChannelModerators, getChannelsByModerator,
// Channel Moderation Settings
// Channel Moderation Settings
getChannelModerationSettings, upsertChannelModerationSettings,
getChannelModerationSettings, upsertChannelModerationSettings,
- getUserTotalGameLevel,
+ getUserTotalGameLevel, getLegacyGameProfile,
// Anon IP Mappings
// Anon IP Mappings
getOrCreateAnonNum, getAnonFirstSeen, loadAnonMappings,
getOrCreateAnonNum, getAnonFirstSeen, loadAnonMappings,
// Stream first chats (welcome messages)
// Stream first chats (welcome messages)
deleted file mode 100644
deleted file mode 100644
@@ -1,2277 +0,0 @@
@@ -1,2277 +0,0 @@
-/**
- * OpenVibeGame — Server Game Engine
- * Open-world survival: gathering, building, PvP, crafting, economy
- */
-
-const db = require('../db/database');
-const fs = require('fs');
-const path = require('path');
-const cosmeticsSystem = require('../monetization/cosmetics');
-const {
- ITEMS, RECIPES, STRUCTURES, CROPS, MONSTERS, RARITY_COLORS, BIOME_COLORS,
- FISH_TABLES, MINE_TABLES, WOOD_TABLES, WATER_ZONE_TABLES,
- FISH_SPECIES, FISH_JUNK, ROD_TIERS, buildFishTable,
- PICK_TIERS, AXE_TIERS, ORE_NODE_TYPES, getOreNodeType,
- MOB_TYPES,
- WEAPON_STATS, ARMOR_STATS, EQUIP_SKILL_MAP, FOOD_EFFECTS,
- MAP_W, MAP_H, TILE, OUTPOST_X, OUTPOST_Y, OUTPOST_RADIUS,
- NPCS, NPC_LIST, NPC_INTERACT_RANGE,
- xpToLevel, levelToXp, rollLoot,
- hashNoise, getBiomeAt, getResourceNodeAt, getDifficultyTier, isInSafeZone, getWaterZone,
-} = require('./items');
-
-let worldSeed = 0;
-
-// ── Dodge i-frame tracking ───────────────────────────────────
-const dodgeIframes = new Map(); // userId → expiresAt timestamp
-
-// ── Combo tracking ───────────────────────────────────────────
-const comboTrackers = new Map(); // userId → { count, lastHitTime }
-const COMBO_WINDOW = 2500; // ms between hits to keep combo
-const COMBO_MULTIPLIERS = [1.0, 1.0, 1.15, 1.3, 1.5, 1.75, 2.0]; // index = combo count
-
-// ── Sprint stamina tracking ──────────────────────────────────
-const sprintTimers = new Map(); // userId → lastDrainTime
-
-// ── Agility tracking ────────────────────────────────────────
-const sprintingPlayers = new Set(); // userIds currently sprinting
-const playerDistAccum = new Map(); // userId → accumulated distance for agility XP
-const swimmingPlayers = new Set(); // userIds currently in water
-
-// ── Food buff tracking (in-memory for ATK/DEF mods) ─────────
-const foodBuffs = new Map(); // userId → { atk, def, sprint_free, xp_mult, max_stamina, hp_regen, expiresAt }
-
-// ══════════════════════════════════════════════════════════════
-// ACHIEVEMENT DEFINITIONS
-// ══════════════════════════════════════════════════════════════
-
-const ACHIEVEMENTS = {
- // ── Combat ──
- first_blood: { name: 'First Blood', emoji: '🗡️', desc: 'Kill your first mob.', category: 'combat', reward: { coins: 25 } },
- mob_slayer_10: { name: 'Mob Slayer', emoji: '💀', desc: 'Kill 10 mobs.', category: 'combat', reward: { coins: 100 } },
- mob_slayer_50: { name: 'Monster Hunter', emoji: '🏹', desc: 'Kill 50 mobs.', category: 'combat', reward: { coins: 300, item: 'dungeon_key', qty: 3 } },
- mob_slayer_100: { name: 'Legendary Slayer', emoji: '⚔️', desc: 'Kill 100 mobs.', category: 'combat', reward: { coins: 750, item: 'hat_wizard', qty: 1 } },
- dragon_slayer: { name: 'Dragon Slayer', emoji: '🐉', desc: 'Kill a Dragon Whelp.', category: 'combat', reward: { coins: 500, item: 'dungeon_key', qty: 5 } },
- pvp_first: { name: 'Duelist', emoji: '🤺', desc: 'Win your first PvP kill.', category: 'combat', reward: { coins: 200 } },
- kill_streak_5: { name: 'Unstoppable', emoji: '🔥', desc: 'Reach a 5 kill streak.', category: 'combat', reward: { coins: 500 } },
- combo_master: { name: 'Combo Master', emoji: '💥', desc: 'Land a 5+ combo hit.', category: 'combat', reward: { coins: 200 } },
- // ── Gathering ──
- first_gather: { name: 'Gatherer', emoji: '🪓', desc: 'Gather your first resource.', category: 'gathering', reward: { coins: 10 } },
- gather_100: { name: 'Resourceful', emoji: '⛏️', desc: 'Gather 100 resources.', category: 'gathering', reward: { coins: 150 } },
- gather_500: { name: 'Strip Miner', emoji: '💎', desc: 'Gather 500 resources.', category: 'gathering', reward: { coins: 400, item: 'pick_iron', qty: 1 } },
- dragonite_found: { name: 'Draconic Discovery', emoji: '🐲', desc: 'Mine Dragonite ore.', category: 'gathering', reward: { coins: 300 } },
- crystal_wood: { name: 'Crystal Lumberjack', emoji: '✨', desc: 'Chop Crystal Wood.', category: 'gathering', reward: { coins: 250 } },
- // ── Fishing ──
- first_catch: { name: 'Gone Fishin\'', emoji: '🎣', desc: 'Catch your first fish.', category: 'fishing', reward: { coins: 15 } },
- fish_species_10: { name: 'Fish Hobbyist', emoji: '🐠', desc: 'Catch 10 different species.', category: 'fishing', reward: { coins: 200 } },
- fish_species_20: { name: 'Fish Collector', emoji: '🐡', desc: 'Catch 20 different species.', category: 'fishing', reward: { coins: 500 } },
- fish_species_all: { name: 'Master Angler', emoji: '🏆', desc: 'Catch every fish species.', category: 'fishing', reward: { coins: 2000, item: 'hat_crown', qty: 1 } },
- legendary_catch: { name: 'Legendary Catch', emoji: '🌟', desc: 'Catch a Legendary fish.', category: 'fishing', reward: { coins: 500 } },
- // ── Crafting ──
- first_craft: { name: 'Artisan', emoji: '🔨', desc: 'Craft your first item.', category: 'crafting', reward: { coins: 15 } },
- craft_25: { name: 'Craftsman', emoji: '🧰', desc: 'Craft 25 items.', category: 'crafting', reward: { coins: 200 } },
- craft_100: { name: 'Master Crafter', emoji: '⚒️', desc: 'Craft 100 items.', category: 'crafting', reward: { coins: 500 } },
- smelt_first: { name: 'Forge Lit', emoji: '🔥', desc: 'Smelt your first bar.', category: 'crafting', reward: { coins: 25 } },
- // ── Exploration ──
- explorer_1k: { name: 'Wanderer', emoji: '🧭', desc: 'Travel 1000 tiles.', category: 'exploration',reward: { coins: 100 } },
- explorer_10k: { name: 'Pathfinder', emoji: '🗺️', desc: 'Travel 10,000 tiles.', category: 'exploration',reward: { coins: 400 } },
- chest_open_1: { name: 'Treasure Hunter', emoji: '📦', desc: 'Open your first treasure chest.', category: 'exploration',reward: { coins: 50 } },
- chest_open_25: { name: 'Loot Goblin', emoji: '🧳', desc: 'Open 25 treasure chests.', category: 'exploration',reward: { coins: 300 } },
- dungeon_enter: { name: 'Delver', emoji: '🗝️', desc: 'Enter a dungeon.', category: 'exploration',reward: { coins: 100 } },
- dungeon_wins_10: { name: 'Dungeon Master', emoji: '🏰', desc: 'Win 10 dungeon fights.', category: 'exploration',reward: { coins: 500, item: 'hat_crown', qty: 1 } },
- // ── Economy / Milestones ──
- first_hat: { name: 'Dapper', emoji: '🎩', desc: 'Equip your first hat.', category: 'milestones', reward: { coins: 50 } },
- level_10_any: { name: 'Apprentice', emoji: '📈', desc: 'Reach level 10 in any skill.', category: 'milestones', reward: { coins: 200 } },
- level_25_any: { name: 'Journeyman', emoji: '📊', desc: 'Reach level 25 in any skill.', category: 'milestones', reward: { coins: 500 } },
- level_50_any: { name: 'Expert', emoji: '🏅', desc: 'Reach level 50 in any skill.', category: 'milestones', reward: { coins: 1500 } },
- total_level_50: { name: 'Well Rounded', emoji: '🌟', desc: 'Reach total level 50.', category: 'milestones', reward: { coins: 300 } },
- total_level_100: { name: 'OpenVibe Legend', emoji: '👑', desc: 'Reach total level 100.', category: 'milestones', reward: { coins: 1000, item: 'hat_halo', qty: 1 } },
- die_10: { name: 'Punching Bag', emoji: '😵', desc: 'Die 10 times.', category: 'milestones', reward: { coins: 50 } },
- build_10: { name: 'Home Builder', emoji: '🏠', desc: 'Build 10 structures.', category: 'milestones', reward: { coins: 150 } },
-};
-
-// Pending achievement notifications to send to players
-const pendingAchievements = new Map(); // userId → [{ achievementId, ...ACHIEVEMENTS[id] }]
-
-// ══════════════════════════════════════════════════════════════
-// DAILY QUESTS
-// ══════════════════════════════════════════════════════════════
-
-const DAILY_QUEST_SLOTS = 3;
-const DAILY_QUESTS = [
- { id: 'forage_run', pool: 'easy', stat: 'gather_count', target: 12, emoji: '🪓', name: 'Forage Run', desc: 'Gather 12 resources anywhere in the wild.', reward: { coins: 90, item: 'bait_worm', qty: 5 } },
- { id: 'pond_hopper', pool: 'easy', stat: 'fish_count', target: 3, emoji: '🎣', name: 'Pond Hopper', desc: 'Catch 3 fish today.', reward: { coins: 85, item: 'potion_stamina', qty: 1 } },
- { id: 'road_trip', pool: 'easy', stat: 'tiles_traveled', target: 180, emoji: '🧭', name: 'Road Trip', desc: 'Travel 180 tiles across the world.', reward: { coins: 95, item: 'potion_health', qty: 1 } },
- { id: 'bench_worker', pool: 'medium', stat: 'craft_count', target: 3, emoji: '🔨', name: 'Bench Worker', desc: 'Craft 3 items.', reward: { coins: 135, item: 'potion_stamina', qty: 2 } },
- { id: 'camp_clearout', pool: 'medium', stat: 'mob_kills', target: 5, emoji: '⚔️', name: 'Camp Clearout', desc: 'Defeat 5 mobs.', reward: { coins: 145, item: 'potion_health', qty: 2 } },
- { id: 'lockbox_luck', pool: 'medium', stat: 'chests_opened', target: 1, emoji: '📦', name: 'Lockbox Luck', desc: 'Open 1 treasure chest.', reward: { coins: 140, item: 'dungeon_key', qty: 1 } },
- { id: 'builder_brigade', pool: 'hard', stat: 'structures_built', target: 2, emoji: '🏗️', name: 'Builder Brigade', desc: 'Build 2 structures.', reward: { coins: 190, item: 'bar_iron', qty: 2 } },
- { id: 'long_haul', pool: 'hard', stat: 'tiles_traveled', target: 450, emoji: '🗺️', name: 'Long Haul', desc: 'Travel 450 tiles in one day.', reward: { coins: 210, item: 'food_fish_stew', qty: 1 } },
- { id: 'harvest_hustle', pool: 'hard', stat: 'gather_count', target: 30, emoji: '⛏️', name: 'Harvest Hustle', desc: 'Gather 30 resources today.', reward: { coins: 220, item: 'potion_stamina', qty: 3 } },
-];
-
-function getDailyQuestDate(date = new Date()) {
- return date.toISOString().slice(0, 10);
-}
-
-function getDailyQuestResetAt() {
- const next = new Date();
- next.setUTCHours(24, 0, 0, 0);
- return next.toISOString();
-}
-
-function hashString(input) {
- let hash = 2166136261;
- const str = String(input || '');
- for (let i = 0; i < str.length; i++) {
- hash ^= str.charCodeAt(i);
- hash = Math.imul(hash, 16777619);
- }
- return hash >>> 0;
-}
-
-function createSeededRng(seed) {
- let state = seed >>> 0;
- return () => {
- state = (state + 0x6D2B79F5) | 0;
- let t = Math.imul(state ^ (state >>> 15), 1 | state);
- t ^= t + Math.imul(t ^ (t >>> 7), 61 | t);
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
- };
-}
-
-function getDailyQuestTemplates(userId, questDate = getDailyQuestDate()) {
- const rng = createSeededRng(hashString(`${userId}:${questDate}:daily-quests`));
- const pools = ['easy', 'medium', 'hard'];
- const selected = [];
- for (const pool of pools) {
- const options = DAILY_QUESTS.filter(q => q.pool === pool);
- if (!options.length) continue;
- selected.push(options[Math.floor(rng() * options.length)]);
- }
- return selected;
-}
-
-function incrementDailyQuestStat(userId, statKey, amount = 1) {
- const value = Number(amount);
- if (!Number.isFinite(value) || value <= 0 || !statKey) return;
- const questDate = getDailyQuestDate();
- db.run(`INSERT INTO game_daily_quest_progress (user_id, quest_date, stat_key, value, updated_at)
- VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
- ON CONFLICT(user_id, quest_date, stat_key)
- DO UPDATE SET value = value + excluded.value, updated_at = CURRENT_TIMESTAMP`,
- [userId, questDate, statKey, value]);
-}
-
-function getDailyQuestProgressMap(userId, questDate = getDailyQuestDate()) {
- const rows = db.all('SELECT stat_key, value FROM game_daily_quest_progress WHERE user_id = ? AND quest_date = ?', [userId, questDate]);
- return rows.reduce((acc, row) => {
- acc[row.stat_key] = Number(row.value) || 0;
- return acc;
- }, {});
-}
-
-function getDailyQuestClaimedSet(userId, questDate = getDailyQuestDate()) {
- const rows = db.all('SELECT quest_id FROM game_daily_quest_claims WHERE user_id = ? AND quest_date = ?', [userId, questDate]);
- return new Set(rows.map(row => row.quest_id));
-}
-
-function getDailyQuestStreak(userId, questDate = getDailyQuestDate()) {
- const rows = db.all(`SELECT quest_date, COUNT(*) as claimed
- FROM game_daily_quest_claims
- WHERE user_id = ?
- GROUP BY quest_date
- HAVING claimed >= ?
- ORDER BY quest_date DESC`, [userId, DAILY_QUEST_SLOTS]);
- const completedDays = new Set(rows.map(row => row.quest_date));
- let streak = 0;
- const cursor = new Date(`${questDate}T00:00:00.000Z`);
- while (completedDays.has(cursor.toISOString().slice(0, 10))) {
- streak++;
- cursor.setUTCDate(cursor.getUTCDate() - 1);
- }
- return streak;
-}
-
-function getDailyQuests(userId, questDate = getDailyQuestDate()) {
- const templates = getDailyQuestTemplates(userId, questDate);
- const progressMap = getDailyQuestProgressMap(userId, questDate);
- const claimedSet = getDailyQuestClaimedSet(userId, questDate);
- const quests = templates.map(template => {
- const progressRaw = progressMap[template.stat] || 0;
- const progress = Math.min(template.target, Math.floor(progressRaw));
- return {
- ...template,
- progress,
- completed: progressRaw >= template.target,
- claimed: claimedSet.has(template.id),
- };
- });
- return {
- date: questDate,
- nextResetAt: getDailyQuestResetAt(),
- streak: getDailyQuestStreak(userId, questDate),
- claimed: quests.filter(q => q.claimed).length,
- completed: quests.filter(q => q.completed).length,
- total: quests.length,
- quests,
- };
-}
-
-function claimDailyQuest(userId, questId) {
- const questDate = getDailyQuestDate();
- const daily = getDailyQuests(userId, questDate);
- const quest = daily.quests.find(entry => entry.id === questId);
- if (!quest) return { error: 'Quest not found.' };
- if (quest.claimed) return { error: 'Quest already claimed.' };
- if (!quest.completed) return { error: 'Quest is not complete yet.' };
-
- db.run('INSERT OR IGNORE INTO game_daily_quest_claims (user_id, quest_date, quest_id) VALUES (?, ?, ?)', [userId, questDate, questId]);
- if (quest.reward?.coins) db.addOpenCoins(userId, quest.reward.coins);
- if (quest.reward?.item) addItem(userId, quest.reward.item, quest.reward.qty || 1);
-
- return {
- success: true,
- quest: { ...quest, claimed: true },
- reward: quest.reward,
- daily: getDailyQuests(userId, questDate),
- };
-}
-
-// ══════════════════════════════════════════════════════════════
-// WEATHER SYSTEM
-// ══════════════════════════════════════════════════════════════
-
-const WEATHER_TYPES = ['clear', 'rain', 'storm', 'fog', 'snow'];
-const WEATHER_DURATIONS = { clear: [5,15], rain: [3,10], storm: [2,6], fog: [3,8], snow: [4,10] }; // min minutes
-let currentWeather = 'clear';
-let weatherChangesAt = 0;
-
-function tickWeather() {
- if (Date.now() < weatherChangesAt) return false;
- // Pick new weather (weighted — clear most common)
- const weights = { clear: 40, rain: 25, fog: 15, storm: 10, snow: 10 };
- const total = Object.values(weights).reduce((s, w) => s + w, 0);
- let roll = Math.random() * total;
- for (const [w, weight] of Object.entries(weights)) {
- roll -= weight;
- if (roll <= 0) { currentWeather = w; break; }
- }
- const [minMins, maxMins] = WEATHER_DURATIONS[currentWeather];
- weatherChangesAt = Date.now() + (minMins + Math.random() * (maxMins - minMins)) * 60000;
- return true; // weather changed
-}
-
-function getWeather() { return { type: currentWeather, changesAt: weatherChangesAt }; }
-
-// ══════════════════════════════════════════════════════════════
-// TREASURE CHEST SYSTEM
-// ══════════════════════════════════════════════════════════════
-
-const liveChests = new Map(); // chestId → { id, tier, x, y, spawnedAt }
-let chestIdCounter = 0;
-const CHEST_TTL = 300000; // 5 min
-const CHEST_MAX_NEARBY = 4;
-const CHEST_TIERS = [
- { name: 'Wooden Chest', emoji: '📦', loot: [{ id: 'loot_coin_pouch', w: 30 }, { id: 'potion_health', w: 25 }, { id: 'bait_worm', w: 20 }, { id: 'seed_wheat', w: 15 }, { id: 'loot_feather', w: 10 }], coinRange: [5, 20] },
- { name: 'Silver Chest', emoji: '🪙', loot: [{ id: 'loot_coin_pouch', w: 20 }, { id: 'potion_health_big', w: 15 }, { id: 'dungeon_key', w: 12 }, { id: 'gem_ruby', w: 15 }, { id: 'loot_crown', w: 10 }, { id: 'food_fish_stew', w: 15 }, { id: 'bar_iron', w: 13 }], coinRange: [15, 50] },
- { name: 'Gold Chest', emoji: '💰', loot: [{ id: 'gem_emerald', w: 20 }, { id: 'gem_diamond', w: 12 }, { id: 'dungeon_key', w: 15 }, { id: 'loot_egg', w: 10 }, { id: 'bar_gold', w: 15 }, { id: 'food_pumpkin_pie', w: 10 }, { id: 'hat_cowboy', w: 8 }, { id: 'weapon_sword', w: 10 }], coinRange: [30, 100] },
- { name: 'Crystal Chest', emoji: '💎', loot: [{ id: 'gem_diamond', w: 18 }, { id: 'gem_star', w: 12 }, { id: 'ore_dragonite', w: 10 }, { id: 'loot_star', w: 8 }, { id: 'loot_void_heart', w: 4 }, { id: 'hat_wizard', w: 8 }, { id: 'dungeon_key', w: 15 }, { id: 'food_golden_feast', w: 10 }, { id: 'weapon_katana', w: 5 }, { id: 'bar_mithril', w: 10 }], coinRange: [50, 250] },
-];
-
-function spawnChests() {
- const players = getLivePlayers();
- for (const [uid, p] of Object.entries(players)) {
- const ptx = Math.floor(p.x / TILE), pty = Math.floor(p.y / TILE);
- if (isInSafeZone(ptx, pty)) continue;
- // Count nearby chests
- let nearby = 0;
- for (const ch of liveChests.values()) {
- if (Math.abs(ch.x - p.x) + Math.abs(ch.y - p.y) < 12 * TILE) nearby++;
- }
- if (nearby >= CHEST_MAX_NEARBY) continue;
- // 15% chance per spawn tick per player
- if (Math.random() > 0.15) continue;
-
- const angle = Math.random() * Math.PI * 2;
- const dist = (4 + Math.random() * 8) * TILE;
- const cx = p.x + Math.cos(angle) * dist;
- const cy = p.y + Math.sin(angle) * dist;
- const ctx_ = Math.floor(cx / TILE), cty = Math.floor(cy / TILE);
- if (ctx_ < 1 || ctx_ >= MAP_W - 1 || cty < 1 || cty >= MAP_H - 1) continue;
- const biome = getBiomeAt(ctx_, cty, worldSeed);
- if (biome === 'water' || biome === 'outpost') continue;
-
- const tier = Math.min(3, getDifficultyTier(ctx_, cty));
- const tierNames = ['wooden', 'silver', 'gold', 'crystal'];
- const id = ++chestIdCounter;
- liveChests.set(id, {
- id, tier: tierNames[tier], tierIdx: tier, x: cx, y: cy, spawnedAt: Date.now(),
- name: CHEST_TIERS[tier].name, emoji: CHEST_TIERS[tier].emoji,
- });
- }
-}
-
-function openChest(userId, chestId) {
- const chest = liveChests.get(chestId);
- if (!chest) return { error: 'Chest not found.' };
- const p = getPlayer(userId);
- const dist = Math.sqrt((p.x - chest.x) ** 2 + (p.y - chest.y) ** 2);
- if (dist > TILE * 3) return { error: 'Too far away!' };
-
- const tierData = CHEST_TIERS[chest.tierIdx];
- // Roll 1-3 loot items
- const numItems = 1 + Math.floor(Math.random() * (1 + chest.tier));
- const lootItems = [];
- for (let i = 0; i < numItems; i++) {
- const lootId = rollLoot(tierData.loot.map(l => ({ id: l.id, weight: l.w })));
- addItem(userId, lootId, 1);
- lootItems.push({ id: lootId, ...(ITEMS[lootId] || { name: lootId }) });
- }
- // Bonus coins
- const coins = tierData.coinRange[0] + Math.floor(Math.random() * (tierData.coinRange[1] - tierData.coinRange[0] + 1));
- db.addOpenCoins(userId, coins);
-
- liveChests.delete(chestId);
-
- // Track for achievements
- const totalChests = (db.get('SELECT total_chests_opened FROM game_players WHERE user_id = ?', [userId])?.total_chests_opened || 0) + 1;
- db.run('UPDATE game_players SET total_chests_opened = total_chests_opened + 1 WHERE user_id = ?', [userId]);
- incrementDailyQuestStat(userId, 'chests_opened', 1);
- checkAchievement(userId, 'chest_open_1', totalChests >= 1);
- checkAchievement(userId, 'chest_open_25', totalChests >= 25);
-
- return {
- success: true, chestId,
- tier: chest.tier, name: tierData.name, emoji: tierData.emoji,
- loot: lootItems.map(i => ({ id: i.id, name: i.name, emoji: i.emoji, qty: 1 })), coins,
- x: chest.x, y: chest.y,
- };
-}
-
-function cleanupChests() {
- const now = Date.now();
- for (const [id, ch] of liveChests) {
- if (now - ch.spawnedAt > CHEST_TTL) liveChests.delete(id);
- }
-}
-
-function getChestStates() {
- const now = Date.now();
- const result = {};
- for (const [id, ch] of liveChests) {
- if (now - ch.spawnedAt > CHEST_TTL) { liveChests.delete(id); continue; }
- result[id] = ch;
- }
- return result;
-}
-
-// NPC positions (tile coordinates)
-function isNearNPC(userId, npcId) {
- const npc = NPCS[npcId];
- if (!npc) return false;
- const player = getPlayer(userId);
- if (!player) return false;
- const px = Math.floor(player.x / TILE), py = Math.floor(player.y / TILE);
- return Math.abs(px - npc.tileX) <= NPC_INTERACT_RANGE && Math.abs(py - npc.tileY) <= NPC_INTERACT_RANGE;
-}
-
-function isNearBankNPC(userId) { return isNearNPC(userId, 'banker'); }
-
-function isNearAnyShopNPC(userId) {
- return NPC_LIST.filter(n => n.categories.length > 0).some(n => isNearNPC(userId, n.id));
-}
-
-function getNearestNPC(userId) {
- const player = getPlayer(userId);
- if (!player) return null;
- const px = Math.floor(player.x / TILE), py = Math.floor(player.y / TILE);
- let best = null, bestDist = Infinity;
- for (const npc of NPC_LIST) {
- const d = Math.abs(px - npc.tileX) + Math.abs(py - npc.tileY);
- if (d <= NPC_INTERACT_RANGE * 2 && d < bestDist) { best = npc; bestDist = d; }
- }
- return best;
-}
-
-function getShopItemsForNPC(npcId) {
- const npc = NPCS[npcId];
- if (!npc || !npc.categories.length) return [];
- return Object.entries(ITEMS)
- .filter(([_, i]) => (i.buyCost !== null || i.sellPrice !== null || i.coinPrice) && npc.categories.includes(i.category))
- .map(([id, i]) => ({ id, ...i }));
-}
-
-function getSellableItemsForNPC(npcId) {
- const npc = NPCS[npcId];
- if (!npc || !npc.categories.length) return [];
- return Object.entries(ITEMS)
- .filter(([_, i]) => i.sellPrice !== null && npc.categories.includes(i.category))
- .map(([id, i]) => ({ id, ...i }));
-}
-
-// ── Ground Items (dropped on death, scattered in world) ──────
-const groundItems = new Map(); // groundItemId → { id, itemId, qty, x, y, droppedAt, name, emoji, rarity }
-let groundItemIdCounter = 0;
-const GROUND_ITEM_TTL = 300000; // 5 min
-
-function dropGroundItem(itemId, qty, x, y, droppedBy = null) {
- const id = ++groundItemIdCounter;
- const item = ITEMS[itemId] || {};
- groundItems.set(id, {
- id, itemId, qty, x, y, droppedAt: Date.now(), droppedBy,
- name: item.name || itemId,
- emoji: item.emoji || '❓',
- rarity: item.rarity || 'Common',
- });
- return id;
-}
-
-function pickupGroundItem(userId, groundItemId) {
- const gi = groundItems.get(groundItemId);
- if (!gi) return { error: 'Item not found.' };
- const p = getPlayer(userId);
- const dist = Math.sqrt((p.x - gi.x) ** 2 + (p.y - gi.y) ** 2);
- if (dist > TILE * 2.5) return { error: 'Too far away!' };
- addItem(userId, gi.itemId, gi.qty);
- const result = { success: true, groundId: groundItemId, item: { id: gi.itemId, name: gi.name, emoji: gi.emoji, qty: gi.qty, rarity: gi.rarity } };
- groundItems.delete(groundItemId);
- return result;
-}
-
-function getGroundItemStates() {
- const now = Date.now();
- const result = {};
- for (const [id, gi] of groundItems) {
- if (now - gi.droppedAt > GROUND_ITEM_TTL) { groundItems.delete(id); continue; }
- result[id] = gi;
- }
- return result;
-}
-
-function cleanupGroundItems() {
- const now = Date.now();
- for (const [id, gi] of groundItems) {
- if (now - gi.droppedAt > GROUND_ITEM_TTL) groundItems.delete(id);
- }
-}
-
-// ── Station Proximity Check (furnace, workbench, campfire) ───
-function isNearStation(userId, stationType) {
- const p = getPlayer(userId);
- const ptx = Math.floor(p.x / TILE), pty = Math.floor(p.y / TILE);
- const nearby = db.all(
- 'SELECT id FROM game_structures WHERE type = ? AND ABS(tile_x - ?) <= 3 AND ABS(tile_y - ?) <= 3',
- [stationType, ptx, pty]
- );
- return nearby.length > 0;
-}
-
-// ── Resource Node Depletion (in-memory) ──────────────────────
-const depletedNodes = new Map(); // "x,y" → respawnTimestamp
-
-function isNodeDepleted(tx, ty) {
- const key = `${tx},${ty}`;
- const t = depletedNodes.get(key);
- if (!t) return false;
- if (Date.now() >= t) { depletedNodes.delete(key); return false; }
- return true;
-}
-
-function depleteNode(tx, ty, cooldownMs) {
- depletedNodes.set(`${tx},${ty}`, Date.now() + cooldownMs);
-}
-
-function getDepletedNodes() {
- const now = Date.now();
- const result = [];
- for (const [key, t] of depletedNodes) {
- if (now < t) {
- const [x, y] = key.split(',').map(Number);
- result.push({ x, y, respawnAt: t });
- } else {
- depletedNodes.delete(key);
- }
- }
- return result;
-}
-
-// ── Init ─────────────────────────────────────────────────────
-function initGameDb() {
- const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
- db.getDb().exec(schema);
-
- // Migrations: add columns that may be missing from older schema versions
- const migrations = [
- 'ALTER TABLE game_players ADD COLUMN sleeping_bag_x REAL',
- 'ALTER TABLE game_players ADD COLUMN sleeping_bag_y REAL',
- 'ALTER TABLE game_players ADD COLUMN structures_built INTEGER DEFAULT 0',
- 'ALTER TABLE game_players ADD COLUMN resources_gathered INTEGER DEFAULT 0',
- 'ALTER TABLE game_players ADD COLUMN smithing_xp INTEGER DEFAULT 0',
- 'ALTER TABLE game_players ADD COLUMN agility_xp INTEGER DEFAULT 0',
- 'ALTER TABLE game_players ADD COLUMN total_chests_opened INTEGER DEFAULT 0',
- 'ALTER TABLE game_players ADD COLUMN total_tiles_traveled REAL DEFAULT 0',
- 'ALTER TABLE game_players ADD COLUMN total_dungeon_wins INTEGER DEFAULT 0',
- ];
- for (const sql of migrations) {
- try { db.getDb().exec(sql); } catch { /* column already exists */ }
- }
-
- // Fish collection table (Toontown-style)
- db.getDb().exec(`
- CREATE TABLE IF NOT EXISTS game_fish_collection (
- user_id INTEGER NOT NULL,
- fish_id TEXT NOT NULL,
- times_caught INTEGER DEFAULT 1,
- max_weight REAL DEFAULT 0,
- first_caught DATETIME DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (user_id, fish_id),
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
- )
- `);
- try { db.getDb().exec('CREATE INDEX IF NOT EXISTS idx_fish_coll_user ON game_fish_collection(user_id)'); } catch { }
-
- // Achievement tracking table
- db.getDb().exec(`
- CREATE TABLE IF NOT EXISTS game_achievements (
- user_id INTEGER NOT NULL,
- achievement_id TEXT NOT NULL,
- completed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (user_id, achievement_id),
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
- )
- `);
-
- db.getDb().exec(`
- CREATE TABLE IF NOT EXISTS game_daily_quest_progress (
- user_id INTEGER NOT NULL,
- quest_date TEXT NOT NULL,
- stat_key TEXT NOT NULL,
- value REAL DEFAULT 0,
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (user_id, quest_date, stat_key),
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
- )
- `);
- db.getDb().exec(`
- CREATE TABLE IF NOT EXISTS game_daily_quest_claims (
- user_id INTEGER NOT NULL,
- quest_date TEXT NOT NULL,
- quest_id TEXT NOT NULL,
- claimed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (user_id, quest_date, quest_id),
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
- )
- `);
- try { db.getDb().exec('CREATE INDEX IF NOT EXISTS idx_daily_quest_progress_user_date ON game_daily_quest_progress(user_id, quest_date)'); } catch { }
- try { db.getDb().exec('CREATE INDEX IF NOT EXISTS idx_daily_quest_claims_user_date ON game_daily_quest_claims(user_id, quest_date)'); } catch { }
-
- worldSeed = getWorldSeed();
- console.log(`[OpenVibeGame] Schema initialized, world seed: ${worldSeed}`);
-}
-
-function getWorldSeed() {
- let row = db.get("SELECT value FROM game_world_state WHERE key = 'world_seed'");
- if (!row) {
- const seed = Math.floor(Math.random() * 2147483647);
- db.run("INSERT INTO game_world_state (key, value) VALUES ('world_seed', ?)", [String(seed)]);
- return seed;
- }
- return parseInt(row.value, 10);
-}
-
-// ══════════════════════════════════════════════════════════════
-// AGILITY — Passive bonus calculator
-// ══════════════════════════════════════════════════════════════
-
-function getAgilityBonuses(level) {
- return {
- maxStaminaBonus: level * 2, // +2 max stamina per level
- regenBonus: Math.floor(level * 0.4), // +0.4 regen per 3s tick per level
- sprintDrainMult: Math.max(0.50, 1.0 - level * 0.02), // -2% sprint drain / level (min 50%)
- dodgeCostReduct: Math.min(10, Math.floor(level * 0.5)), // -0.5 dodge cost / level (max -10)
- swimSpeedMult: 1.0 + level * 0.02, // +2% swim speed / level
- sprintSpeedMult: 1.0 + Math.floor(level / 5) * 0.02, // +2% sprint speed every 5 levels
- };
-}
-
-// ══════════════════════════════════════════════════════════════
-// ACHIEVEMENT TRACKING
-// ══════════════════════════════════════════════════════════════
-
-function checkAchievement(userId, achievementId, condition) {
- if (!condition) return null;
- if (!ACHIEVEMENTS[achievementId]) return null;
- // Already completed?
- const existing = db.get('SELECT 1 FROM game_achievements WHERE user_id = ? AND achievement_id = ?', [userId, achievementId]);
- if (existing) return null;
- // Award it!
- db.run('INSERT OR IGNORE INTO game_achievements (user_id, achievement_id) VALUES (?, ?)', [userId, achievementId]);
- const ach = ACHIEVEMENTS[achievementId];
- // Grant reward
- if (ach.reward.coins) db.addOpenCoins(userId, ach.reward.coins);
- if (ach.reward.item) addItem(userId, ach.reward.item, ach.reward.qty || 1);
- // Queue notification
- if (!pendingAchievements.has(userId)) pendingAchievements.set(userId, []);
- pendingAchievements.get(userId).push({ id: achievementId, ...ach });
- return { id: achievementId, ...ach };
-}
-
-function getAchievements(userId) {
- const completed = db.all('SELECT achievement_id, completed_at FROM game_achievements WHERE user_id = ?', [userId]);
- const completedSet = new Set(completed.map(r => r.achievement_id));
- const all = Object.entries(ACHIEVEMENTS).map(([id, a]) => ({
- id, ...a,
- completed: completedSet.has(id),
- completedAt: completed.find(r => r.achievement_id === id)?.completed_at || null,
- }));
- return { achievements: all, completed: completed.length, total: Object.keys(ACHIEVEMENTS).length };
-}
-
-function flushPendingAchievements(userId) {
- const list = pendingAchievements.get(userId) || [];
- pendingAchievements.delete(userId);
- return list;
-}
-
-/** Check level-based achievements after any XP gain */
-function checkLevelAchievements(userId) {
- const p = getPlayer(userId);
- const skills = ['mining', 'fishing', 'woodcut', 'farming', 'combat', 'crafting', 'smithing', 'agility'];
- const levels = skills.map(s => xpToLevel(p[`${s}_xp`] || 0));
- const maxLevel = Math.max(...levels);
- const totalLevel = levels.reduce((a, b) => a + b, 0);
- checkAchievement(userId, 'level_10_any', maxLevel >= 10);
- checkAchievement(userId, 'level_25_any', maxLevel >= 25);
- checkAchievement(userId, 'level_50_any', maxLevel >= 50);
- checkAchievement(userId, 'total_level_50', totalLevel >= 50);
- checkAchievement(userId, 'total_level_100', totalLevel >= 100);
-}
-
-// ══════════════════════════════════════════════════════════════
-// PLAYER
-// ══════════════════════════════════════════════════════════════
-
-function getPlayer(userId) {
- let p = db.get('SELECT * FROM game_players WHERE user_id = ?', [userId]);
- if (!p) {
- // Spawn at outpost center
- const sx = OUTPOST_X * TILE + TILE / 2;
- const sy = OUTPOST_Y * TILE + TILE / 2;
- db.run('INSERT INTO game_players (user_id, x, y) VALUES (?, ?, ?)', [userId, sx, sy]);
- p = db.get('SELECT * FROM game_players WHERE user_id = ?', [userId]);
- }
- p.mining_level = xpToLevel(p.mining_xp);
- p.fishing_level = xpToLevel(p.fishing_xp);
- p.woodcut_level = xpToLevel(p.woodcut_xp);
- p.farming_level = xpToLevel(p.farming_xp);
- p.combat_level = xpToLevel(p.combat_xp);
- p.crafting_level = xpToLevel(p.crafting_xp);
- p.smithing_level = xpToLevel(p.smithing_xp || 0);
- p.agility_level = xpToLevel(p.agility_xp || 0);
- p.total_level = p.mining_level + p.fishing_level + p.woodcut_level +
- p.farming_level + p.combat_level + p.crafting_level +
- p.smithing_level + p.agility_level;
- // Apply agility max_stamina bonus: +2 per agility level
- const agilBonus = getAgilityBonuses(p.agility_level);
- p.max_stamina = 100 + agilBonus.maxStaminaBonus;
- return p;
-}
-
-function updatePlayerPosition(userId, x, y) {
- db.run('UPDATE game_players SET x = ?, y = ?, last_action = CURRENT_TIMESTAMP WHERE user_id = ?', [x, y, userId]);
-}
-
-function addXp(userId, skill, amount) {
- const col = `${skill}_xp`;
- const p = getPlayer(userId);
- const oldLevel = xpToLevel(p[col]);
- db.run(`UPDATE game_players SET ${col} = ${col} + ?, last_action = CURRENT_TIMESTAMP WHERE user_id = ?`, [amount, userId]);
- const newXp = p[col] + amount;
- const newLevel = xpToLevel(newXp);
- // Check level-based achievements on level up
- if (newLevel > oldLevel) checkLevelAchievements(userId);
- return { oldLevel, newLevel, leveledUp: newLevel > oldLevel, xp: newXp, totalXp: amount };
-}
-
-function regenStamina(userId) {
- const p = db.get('SELECT stamina, max_stamina, last_stamina_tick FROM game_players WHERE user_id = ?', [userId]);
- if (!p) return 0;
- const now = Date.now();
- let lastTick;
- try { lastTick = new Date(p.last_stamina_tick.replace(' ', 'T') + 'Z').getTime(); }
- catch { lastTick = now - 30000; }
- const regen = Math.floor((now - lastTick) / 1000 / 30);
- if (regen > 0 && p.stamina < p.max_stamina) {
- const newSta = Math.min(p.max_stamina, p.stamina + regen);
- db.run('UPDATE game_players SET stamina = ?, last_stamina_tick = CURRENT_TIMESTAMP WHERE user_id = ?', [newSta, userId]);
- return newSta;
- }
- return p.stamina;
-}
-
-/**
- * Continuous stamina regen tick — called every ~3s from server tick.
- * Returns an array of { userId, stamina, maxStamina } for players whose stamina changed.
- */
-function regenStaminaTick() {
- const players = getLivePlayers();
- const updates = [];
- for (const [uid, lp] of Object.entries(players)) {
- const userId = parseInt(uid);
- const pd = getPlayer(userId);
- if (!pd) continue;
-
- const maxSta = pd.max_stamina;
- if (pd.stamina >= maxSta) continue; // Already full
-
- // Base regen: 3 per tick, boosted by agility
- const bonuses = getAgilityBonuses(pd.agility_level);
- let regen = 3 + bonuses.regenBonus;
-
- // Halve regen if currently sprinting
- if (sprintingPlayers.has(userId)) regen = Math.max(1, Math.floor(regen / 2));
-
- const newSta = Math.min(maxSta, pd.stamina + regen);
- if (newSta !== pd.stamina) {
- db.run('UPDATE game_players SET stamina = ?, last_stamina_tick = CURRENT_TIMESTAMP WHERE user_id = ?', [newSta, userId]);
- updates.push({ userId, stamina: newSta, maxStamina: maxSta });
- }
- }
- // Clear sprint tracking for this tick (will be re-set by next movement)
- sprintingPlayers.clear();
- return updates;
-}
-
-// ══════════════════════════════════════════════════════════════
-// INVENTORY
-// ══════════════════════════════════════════════════════════════
-
-function getInventory(userId) {
- return db.all('SELECT item_id, quantity FROM game_inventory WHERE user_id = ? AND quantity > 0', [userId])
- .map(r => ({ ...r, ...(ITEMS[r.item_id] || { name: r.item_id, emoji: '❓', rarity: 'Common' }) }));
-}
-
-function getItemCount(userId, itemId) {
- const r = db.get('SELECT quantity FROM game_inventory WHERE user_id = ? AND item_id = ?', [userId, itemId]);
- return r ? r.quantity : 0;
-}
-
-function addItem(userId, itemId, qty = 1) {
- db.run(`INSERT INTO game_inventory (user_id, item_id, quantity) VALUES (?, ?, ?)
- ON CONFLICT(user_id, item_id) DO UPDATE SET quantity = quantity + ?`, [userId, itemId, qty, qty]);
- // Auto-unlock hats as global cosmetics so they show as chat badges
- const item = ITEMS[itemId];
- if (item?.category === 'hats') {
- try { cosmeticsSystem.unlockCosmetic(userId, itemId); } catch {}
- }
-}
-
-function removeItem(userId, itemId, qty = 1) {
- const cur = getItemCount(userId, itemId);
- if (cur < qty) return false;
- if (cur === qty) db.run('DELETE FROM game_inventory WHERE user_id = ? AND item_id = ?', [userId, itemId]);
- else db.run('UPDATE game_inventory SET quantity = quantity - ? WHERE user_id = ? AND item_id = ?', [qty, userId, itemId]);
- return true;
-}
-
-function hasItem(userId, itemId, qty = 1) { return getItemCount(userId, itemId) >= qty; }
-
-// ══════════════════════════════════════════════════════════════
-// BANK
-// ══════════════════════════════════════════════════════════════
-
-function getBank(userId) {
- return db.all('SELECT item_id, quantity FROM game_bank WHERE user_id = ? AND quantity > 0', [userId])
- .map(r => ({ ...r, ...(ITEMS[r.item_id] || { name: r.item_id, emoji: '❓', rarity: 'Common' }) }));
-}
-
-function bankDeposit(userId, itemId, qty = 1) {
- if (!removeItem(userId, itemId, qty)) return false;
- db.run(`INSERT INTO game_bank (user_id, item_id, quantity) VALUES (?, ?, ?)
- ON CONFLICT(user_id, item_id) DO UPDATE SET quantity = quantity + ?`, [userId, itemId, qty, qty]);
- return true;
-}
-
-function bankWithdraw(userId, itemId, qty = 1) {
- const r = db.get('SELECT quantity FROM game_bank WHERE user_id = ? AND item_id = ?', [userId, itemId]);
- if (!r || r.quantity < qty) return false;
- if (r.quantity === qty) db.run('DELETE FROM game_bank WHERE user_id = ? AND item_id = ?', [userId, itemId]);
- else db.run('UPDATE game_bank SET quantity = quantity - ? WHERE user_id = ? AND item_id = ?', [qty, userId, itemId]);
- addItem(userId, itemId, qty);
- return true;
-}
-
-// ══════════════════════════════════════════════════════════════
-// SHOP
-// ══════════════════════════════════════════════════════════════
-
-function getShopItems() {
- return Object.entries(ITEMS).filter(([_, i]) => i.buyCost !== null).map(([id, i]) => ({ id, ...i }));
-}
-
-function buyItem(userId, itemId, qty = 1) {
- const item = ITEMS[itemId];
- if (!item || item.buyCost === null) return { error: 'Not for sale' };
- const cost = item.buyCost * qty;
- const user = db.getUserById(userId);
- if (!user || user.openvibe_coins_balance < cost) return { error: 'Not enough gold' };
-
- // Check level requirement before buying equipment
- const slot = EQUIPMENT_SLOTS[item.category];
- if (slot && item.levelReq) {
- const skillName = EQUIP_SKILL_MAP[item.category];
- if (skillName) {
- const p = getPlayer(userId);
- const playerLevel = xpToLevel(p[`${skillName}_xp`]);
- if (playerLevel < item.levelReq) {
- return { error: `Need ${skillName.charAt(0).toUpperCase() + skillName.slice(1)} level ${item.levelReq} to use this! (You're level ${playerLevel})` };
- }
- }
- }
-
- if (!db.deductOpenCoins(userId, cost)) return { error: 'Not enough gold' };
- addItem(userId, itemId, qty);
-
- // Auto-equip tools/equipment after buying (if it's equippable and better or slot is empty)
- let autoEquipped = false;
- if (slot && qty === 1) {
- const p = getPlayer(userId);
- const currentEquip = p[slot];
- // Auto-equip if the slot is empty or upgrading to a better item
- if (!currentEquip || isBetterEquipment(currentEquip, itemId, item.category)) {
- db.run(`UPDATE game_players SET ${slot} = ? WHERE user_id = ?`, [itemId, userId]);
- recalcCombatStats(userId);
- autoEquipped = true;
- }
- }
-
- return { success: true, item: { id: itemId, ...item }, qty, cost, autoEquipped, slot: autoEquipped ? item.category : null };
-}
-
-function sellItem(userId, itemId, qty = 1) {
- const item = ITEMS[itemId];
- if (!item || item.sellPrice === null) return { error: 'Cannot sell' };
- if (!removeItem(userId, itemId, qty)) return { error: 'Not enough items' };
- const earned = item.sellPrice * qty;
- db.addOpenCoins(userId, earned);
- return { success: true, item: { id: itemId, ...item }, qty, earned };
-}
-
-function buyWithCoins(userId, itemId) {
- const item = ITEMS[itemId];
- if (!item || !item.coinPrice) return { error: 'Not for coin sale' };
- const user = db.getUserById(userId);
- if (!user || user.openvibe_coins_balance < item.coinPrice) return { error: 'Not enough OpenCoins' };
- if (!db.deductOpenCoins(userId, item.coinPrice)) return { error: 'Not enough OpenCoins' };
- addItem(userId, itemId, 1);
- return { success: true, item: { id: itemId, ...item }, cost: item.coinPrice };
-}
-
-function getCoinShopItems() {
- return Object.entries(ITEMS).filter(([_, i]) => i.coinPrice).map(([id, i]) => ({ id, ...i }));
-}
-
-// ══════════════════════════════════════════════════════════════
-// GATHERING (open-world resource nodes)
-// ══════════════════════════════════════════════════════════════
-
-function gather(userId, tileX, tileY) {
- const player = getPlayer(userId);
- regenStamina(userId);
- const sta = db.get('SELECT stamina FROM game_players WHERE user_id = ?', [userId]).stamina;
-
- // Proximity check (player tile vs target tile)
- const px = Math.floor(player.x / TILE), py = Math.floor(player.y / TILE);
- if (Math.abs(px - tileX) > 2 || Math.abs(py - tileY) > 2) return { error: 'Too far away!' };
-
- const node = getResourceNodeAt(tileX, tileY, worldSeed);
- if (!node) return { error: 'Nothing to gather here.' };
- if (isNodeDepleted(tileX, tileY)) return { error: 'Depleted. Come back soon.' };
-
- let skill, action;
- if (node.type === 'tree') { skill = 'woodcut'; action = 'chop'; }
- else if (node.type === 'rock') { skill = 'mining'; action = 'mine'; }
- else { return { error: 'Cannot gather this.' }; }
-
- // ── FIST PUNCHING (no tool) ──
- // Without proper tools, you can only punch stone rocks or basic trees for raw materials
- const equippedPick = player.equip_pickaxe;
- const equippedAxe = player.equip_axe;
-
- if (node.type === 'rock') {
- // Require pickaxe equipped to mine any rock
- if (!equippedPick) return { error: 'You need a pickaxe equipped to mine! ⛏️' };
-
- const oreType = node.ore || 'stone';
- const oreInfo = ORE_NODE_TYPES[oreType];
- const pickTier = equippedPick ? (PICK_TIERS[equippedPick]?.tier ?? -1) : -1;
-
- // Check mining level requirement
- if (oreInfo && oreInfo.minLevel && player.mining_level < oreInfo.minLevel) {
- return { error: `Need Mining level ${oreInfo.minLevel} to mine ${oreInfo.name}! (You're level ${player.mining_level})` };
- }
-
- if (oreInfo && pickTier < oreInfo.minPickTier) {
- const needed = Object.entries(PICK_TIERS).find(([_, v]) => v.tier >= oreInfo.minPickTier);
- return { error: `Need ${needed ? ITEMS[needed[0]]?.name || needed[1].name : 'a better'} pickaxe to mine ${oreInfo.name}!` };
- }
- }
-
- if (node.type === 'tree') {
- if (!equippedAxe) {
- // Fist punching trees: gives raw_stick, more stamina
- if (sta < 10) return { error: 'Not enough stamina!' };
- db.run('UPDATE game_players SET stamina = stamina - 10 WHERE user_id = ?', [userId]);
- addItem(userId, 'raw_stick', 1);
- depleteNode(tileX, tileY, 12000);
- const xpResult = addXp(userId, 'woodcut', 2);
- db.run('UPDATE game_players SET resources_gathered = resources_gathered + 1 WHERE user_id = ?', [userId]);
- incrementDailyQuestStat(userId, 'gather_count', 1);
- return {
- success: true, action: 'punch',
- loot: { id: 'raw_stick', ...(ITEMS['raw_stick'] || {}) },
- bonus: false, xp: xpResult, toolBroke: false,
- stamina: sta - 10, depletedUntil: Date.now() + 12000,
- tileX, tileY,
- };
- }
- }
-
- // ── NORMAL GATHERING (with tools) ──
- const staCost = node.type === 'rock' ? 8 : node.type === 'tree' ? 6 : 5;
- if (sta < staCost) return { error: 'Not enough stamina!' };
-
- db.run('UPDATE game_players SET stamina = stamina - ? WHERE user_id = ?', [staCost, userId]);
-
- const tier = getDifficultyTier(tileX, tileY);
-
- let lootId, item;
- if (node.type === 'rock') {
- // Ore-specific loot: pick from the appropriate ore table based on node subtype
- const oreType = node.ore || 'stone';
- const oreLootMap = {
- stone: ['ore_gravel', 'ore_coal', 'raw_stone'],
- tin: ['ore_tin'],
- copper: ['ore_copper'],
- coal: ['ore_coal'],
- iron: ['ore_iron'],
- gold: ['ore_gold'],
- mithril: ['ore_mithril'],
- titanium: ['ore_titanium'],
- platinum: ['ore_platinum'],
- dragonite:['ore_dragonite'],
- };
- const primaryOres = oreLootMap[oreType] || ['ore_gravel'];
- // Primary ore (70%), or tier-based bonus from MINE_TABLES (30%)
- if (Math.random() < 0.70 || !MINE_TABLES[Math.min(tier, 3)]) {
- lootId = primaryOres[Math.floor(Math.random() * primaryOres.length)];
- } else {
- lootId = rollLoot(MINE_TABLES[Math.min(tier, 3)]);
- }
- item = ITEMS[lootId];
- } else {
- const tables = { tree: WOOD_TABLES };
- const table = tables[node.type]?.[Math.min(tier, 3)] || tables[node.type]?.[0];
- if (!table) return { error: 'Cannot gather here.' };
- lootId = rollLoot(table);
- item = ITEMS[lootId];
- }
-
- addItem(userId, lootId, 1);
-
- // Bonus drop (12% chance for rocks)
- let bonus = false;
- if (node.type === 'rock' && Math.random() < 0.12) { addItem(userId, lootId, 1); bonus = true; }
-
- const baseXp = (item?.sellPrice || 5) + 5 + tier * 3;
- const xpResult = addXp(userId, skill, baseXp);
-
- // Tool break (3%) — only for non-starter tools
- let toolBroke = false;
- const toolSlots = { woodcut: 'equip_axe', mining: 'equip_pickaxe', fishing: 'equip_rod' };
- const slot = toolSlots[skill];
- if (slot && player[slot] && Math.random() < 0.03) {
- toolBroke = player[slot];
- db.run(`UPDATE game_players SET ${slot} = NULL WHERE user_id = ?`, [userId]);
- }
-
- const cooldown = node.type === 'tree' ? 25000 : node.type === 'rock' ? 40000 : 18000;
- depleteNode(tileX, tileY, cooldown);
-
- db.run('UPDATE game_players SET resources_gathered = resources_gathered + 1 WHERE user_id = ?', [userId]);
- incrementDailyQuestStat(userId, 'gather_count', 1);
-
- // Achievement checks
- const totalGathered = (db.get('SELECT resources_gathered FROM game_players WHERE user_id = ?', [userId])?.resources_gathered || 0);
- checkAchievement(userId, 'first_gather', totalGathered >= 1);
- checkAchievement(userId, 'gather_100', totalGathered >= 100);
- checkAchievement(userId, 'gather_500', totalGathered >= 500);
- if (lootId === 'ore_dragonite') checkAchievement(userId, 'dragonite_found', true);
- if (lootId === 'wood_crystal') checkAchievement(userId, 'crystal_wood', true);
-
- return {
- success: true, action,
- loot: { id: lootId, ...(item || {}) },
- bonus, xp: xpResult, toolBroke,
- stamina: sta - staCost,
- depletedUntil: Date.now() + cooldown,
- tileX, tileY,
- oreType: node.ore || undefined,
- };
-}
-
-// ══════════════════════════════════════════════════════════════
-// FISHING (Toontown-style — cast targeting + reel mini-game)
-// ══════════════════════════════════════════════════════════════
-
-function fish(userId, tileX, tileY, reelScore) {
- const player = getPlayer(userId);
- regenStamina(userId);
- const sta = db.get('SELECT stamina FROM game_players WHERE user_id = ?', [userId]).stamina;
-
- // Proximity check
- const px = Math.floor(player.x / TILE), py = Math.floor(player.y / TILE);
- if (Math.abs(px - tileX) > 2 || Math.abs(py - tileY) > 2) return { error: 'Too far away!' };
-
- // Must be a water tile
- const biome = getBiomeAt(tileX, tileY, worldSeed);
- if (biome !== 'water') return { error: 'You need to cast into water!' };
-
- // Must have a fishing rod equipped
- if (!player.equip_rod) return { error: 'Equip a fishing rod first! (Buy one from the Tool Merchant)' };
-
- // Rod tier info
- const rodInfo = ROD_TIERS[player.equip_rod] || ROD_TIERS.rod_bamboo;
- const rodTier = rodInfo.tier;
- const staCost = rodInfo.staminaCost;
- if (sta < staCost) return { error: `Not enough stamina! (Need ${staCost})` };
- db.run('UPDATE game_players SET stamina = stamina - ? WHERE user_id = ?', [staCost, userId]);
-
- // Validate reel score (0 = miss, 1-3 = catch quality)
- const reel = Math.max(0, Math.min(3, Math.floor(Number(reelScore) || 0)));
-
- // Fish escaped (reel score 0 — missed all timing windows)
- if (reel === 0) {
- return {
- success: true, action: 'fish', zone: getWaterZone(tileX, tileY, worldSeed),
- escaped: true, message: 'The fish got away!',
- stamina: sta - staCost, tileX, tileY,
- };
- }
-
- // Determine water zone
- const zone = getWaterZone(tileX, tileY, worldSeed);
-
- // Build loot table filtered by rod tier + zone
- const table = buildFishTable(zone, rodTier);
- if (!table.length) return { error: 'No fish here!' };
-
- // Roll loot
- const lootId = rollLoot(table);
- const item = ITEMS[lootId];
- const spec = FISH_SPECIES[lootId];
-
- // Calculate weight (only for actual fish, not junk)
- let weight = 0;
- if (spec) {
- // Weight based on reel score: 1=low range, 2=mid range, 3=high range
- const range = spec.maxW - spec.minW;
- const reelFactor = (reel - 1) / 2; // 0, 0.5, 1
- const randomFactor = Math.random() * 0.3; // 0-30% extra variance
- weight = Math.round((spec.minW + range * (reelFactor * 0.7 + randomFactor)) * 10) / 10;
- weight = Math.max(spec.minW, Math.min(spec.maxW, weight));
- }
-
- addItem(userId, lootId, 1);
-
- // XP scales with fish value + weight bonus
- const baseXp = (item?.sellPrice || 5) + Math.floor(weight * 2);
- const xpResult = addXp(userId, 'fishing', baseXp);
- db.run('UPDATE game_players SET resources_gathered = resources_gathered + 1 WHERE user_id = ?', [userId]);
- incrementDailyQuestStat(userId, 'fish_count', 1);
-
- // Record in fish collection (only real fish, not junk)
- let newSpecies = false;
- let collectionCount = 0;
- let milestone = null;
- if (spec) {
- const caught = recordFishCatch(userId, lootId, weight);
- newSpecies = caught.newSpecies;
- collectionCount = caught.collectionCount;
- // Milestones at 5, 10, 15, 20, 25, 30
- const milestoneThresholds = [5, 10, 15, 20, 25, 30];
- for (const threshold of milestoneThresholds) {
- if (collectionCount === threshold) {
- // Bonus XP + max HP boost
- const bonusXp = threshold * 10;
- addXp(userId, 'fishing', bonusXp);
- db.run('UPDATE game_players SET max_hp = max_hp + 5, hp = MIN(hp + 5, max_hp + 5) WHERE user_id = ?', [userId]);
- milestone = { threshold, bonusXp, hpBoost: 5, total: collectionCount };
- break;
- }
- }
- // Fish achievements
- checkAchievement(userId, 'first_catch', true);
- checkAchievement(userId, 'fish_species_10', collectionCount >= 10);
- checkAchievement(userId, 'fish_species_20', collectionCount >= 20);
- checkAchievement(userId, 'fish_species_all', collectionCount >= Object.keys(FISH_SPECIES).length);
- if (spec.family === 'Legendary') checkAchievement(userId, 'legendary_catch', true);
- }
-
- // Rod break (2% — bamboo is unbreakable)
- let toolBroke = false;
- if (Math.random() < 0.02 && player.equip_rod !== 'rod_bamboo') {
- toolBroke = player.equip_rod;
- db.run('UPDATE game_players SET equip_rod = ? WHERE user_id = ?', ['rod_bamboo', userId]);
- }
-
- // Sell price bonus from weight
- const sellValue = (item?.sellPrice || 1) + Math.floor(weight * 2);
-
- return {
- success: true, action: 'fish', zone,
- loot: { id: lootId, ...(item || {}), weight, sellValue },
- reelScore: reel, newSpecies, collectionCount, milestone,
- xp: xpResult, toolBroke,
- stamina: sta - staCost, tileX, tileY,
- };
-}
-
-// Record a fish catch in the collection album
-function recordFishCatch(userId, fishId, weight) {
- const existing = db.get(
- 'SELECT * FROM game_fish_collection WHERE user_id = ? AND fish_id = ?',
- [userId, fishId]
- );
- if (existing) {
- const newMax = Math.max(existing.max_weight, weight);
- db.run(
- 'UPDATE game_fish_collection SET times_caught = times_caught + 1, max_weight = ? WHERE user_id = ? AND fish_id = ?',
- [newMax, userId, fishId]
- );
- } else {
- db.run(
- 'INSERT INTO game_fish_collection (user_id, fish_id, times_caught, max_weight) VALUES (?, ?, 1, ?)',
- [userId, fishId, weight]
- );
- }
- const countRow = db.get('SELECT COUNT(*) as cnt FROM game_fish_collection WHERE user_id = ?', [userId]);
- return { newSpecies: !existing, collectionCount: countRow.cnt };
-}
-
-// Get a player's full fish collection for the album UI
-function getFishCollection(userId) {
- const rows = db.all(
- 'SELECT fish_id, times_caught, max_weight, first_caught FROM game_fish_collection WHERE user_id = ? ORDER BY first_caught ASC',
- [userId]
- );
- const totalSpecies = Object.keys(FISH_SPECIES).length;
- return {
- collected: rows,
- totalSpecies,
- progress: rows.length,
- };
-}
-
-function useSonar(userId, tileX, tileY) {
- const player = getPlayer(userId);
- const px = Math.floor(player.x / TILE), py = Math.floor(player.y / TILE);
- if (Math.abs(px - tileX) > 3 || Math.abs(py - tileY) > 3) return { error: 'Too far away!' };
-
- if (!hasItem(userId, 'fish_sonar', 1)) return { error: 'You need a Fish Sonar! Craft one: 2x Copper Ore + 1x Ruby.' };
- if (!removeItem(userId, 'fish_sonar', 1)) return { error: 'No sonar in inventory!' };
-
- // Scan nearby water tiles for zones
- const zones = {};
- for (let dy = -4; dy <= 4; dy++) {
- for (let dx = -4; dx <= 4; dx++) {
- const cx = tileX + dx, cy = tileY + dy;
- const b = getBiomeAt(cx, cy, worldSeed);
- if (b === 'water') {
- const z = getWaterZone(cx, cy, worldSeed);
- if (!zones[z]) {
- const table = WATER_ZONE_TABLES[z] || WATER_ZONE_TABLES.shallow;
- zones[z] = {
- zone: z,
- fish: table.map(f => ({ id: f.id, name: ITEMS[f.id]?.name || f.id, emoji: ITEMS[f.id]?.emoji || '🐟', rarity: ITEMS[f.id]?.rarity || 'Common', weight: f.weight })),
- };
- }
- }
- }
- }
- return { success: true, zones: Object.values(zones) };
-}
-
-// ══════════════════════════════════════════════════════════════
-// BUILDING
-// ══════════════════════════════════════════════════════════════
-
-function placeStructure(userId, structureType, tileX, tileY) {
- const struct = STRUCTURES[structureType];
- if (!struct) return { error: 'Unknown structure.' };
-
- const biome = getBiomeAt(tileX, tileY, worldSeed);
- if (biome === 'water') return { error: 'Cannot build on water.' };
- if (isInSafeZone(tileX, tileY)) return { error: 'Cannot build in Town.' };
-
- const existing = db.get('SELECT id FROM game_structures WHERE tile_x = ? AND tile_y = ?', [tileX, tileY]);
- if (existing) return { error: 'Tile occupied.' };
-
- const node = getResourceNodeAt(tileX, tileY, worldSeed);
- if (node && !isNodeDepleted(tileX, tileY)) return { error: 'Clear the resource first.' };
-
- // Check proximity
- const player = getPlayer(userId);
- const px = Math.floor(player.x / TILE), py = Math.floor(player.y / TILE);
- if (Math.abs(px - tileX) > 3 || Math.abs(py - tileY) > 3) return { error: 'Too far away!' };
-
- // Check materials
- for (const [itemId, qty] of Object.entries(struct.cost)) {
- if (!hasItem(userId, itemId, qty)) {
- const item = ITEMS[itemId] || { name: itemId };
- return { error: `Need ${qty}x ${item.name}` };
- }
- }
- for (const [itemId, qty] of Object.entries(struct.cost)) removeItem(userId, itemId, qty);
-
- db.run(`INSERT INTO game_structures (owner_id, type, tile_x, tile_y, hp, max_hp) VALUES (?, ?, ?, ?, ?, ?)`,
- [userId, structureType, tileX, tileY, struct.hp, struct.hp]);
-
- if (structureType === 'sleeping_bag') {
- db.run('UPDATE game_players SET sleeping_bag_x = ?, sleeping_bag_y = ? WHERE user_id = ?',
- [tileX * TILE + TILE / 2, tileY * TILE + TILE / 2, userId]);
- }
-
- db.run('UPDATE game_players SET structures_built = structures_built + 1 WHERE user_id = ?', [userId]);
- incrementDailyQuestStat(userId, 'structures_built', 1);
- const xpResult = addXp(userId, 'crafting', 15);
-
- // Achievement check
- const totalBuilt = db.get('SELECT structures_built FROM game_players WHERE user_id = ?', [userId])?.structures_built || 0;
- checkAchievement(userId, 'build_10', totalBuilt >= 10);
-
- const id = db.get('SELECT last_insert_rowid() as id').id;
- return {
- success: true,
- structure: { id, type: structureType, ...struct, tile_x: tileX, tile_y: tileY, owner_id: userId, hp: struct.hp, max_hp: struct.hp },
- xp: xpResult,
- };
-}
-
-function destroyStructure(userId, tileX, tileY) {
- const s = db.get('SELECT * FROM game_structures WHERE tile_x = ? AND tile_y = ?', [tileX, tileY]);
- if (!s) return { error: 'No structure here.' };
- if (s.owner_id !== userId) return { error: 'Not your structure.' };
- db.run('DELETE FROM game_structures WHERE id = ?', [s.id]);
- // Refund 50% materials
- const struct = STRUCTURES[s.type];
- if (struct) {
- for (const [itemId, qty] of Object.entries(struct.cost)) {
- addItem(userId, itemId, Math.ceil(qty / 2));
- }
- }
- return { success: true, tileX, tileY };
-}
-
-function getStructuresInArea(minX, minY, maxX, maxY) {
- return db.all('SELECT * FROM game_structures WHERE tile_x >= ? AND tile_x <= ? AND tile_y >= ? AND tile_y <= ?',
- [minX, maxX, minY, maxY]);
-}
-
-function getAllStructures() {
- return db.all('SELECT * FROM game_structures');
-}
-
-// Storage box operations
-function storageDeposit(userId, structureId, itemId, qty) {
- const s = db.get('SELECT * FROM game_structures WHERE id = ? AND type = ?', [structureId, 'storage_box']);
- if (!s) return { error: 'Not a storage box.' };
- if (s.owner_id !== userId) return { error: 'Not yours.' };
- if (!removeItem(userId, itemId, qty)) return { error: 'Not enough items.' };
- const data = JSON.parse(s.data || '{}');
- if (!data.items) data.items = [];
- const ex = data.items.find(i => i.item_id === itemId);
- if (ex) ex.quantity += qty; else data.items.push({ item_id: itemId, quantity: qty });
- db.run('UPDATE game_structures SET data = ? WHERE id = ?', [JSON.stringify(data), structureId]);
- return { success: true, storage: data.items };
-}
-
-function storageWithdraw(userId, structureId, itemId, qty) {
- const s = db.get('SELECT * FROM game_structures WHERE id = ? AND type = ?', [structureId, 'storage_box']);
- if (!s) return { error: 'Not a storage box.' };
- if (s.owner_id !== userId) return { error: 'Not yours.' };
- const data = JSON.parse(s.data || '{}');
- const ex = (data.items || []).find(i => i.item_id === itemId);
- if (!ex || ex.quantity < qty) return { error: 'Not enough in storage.' };
- ex.quantity -= qty;
- if (ex.quantity <= 0) data.items = data.items.filter(i => i.item_id !== itemId);
- db.run('UPDATE game_structures SET data = ? WHERE id = ?', [JSON.stringify(data), structureId]);
- addItem(userId, itemId, qty);
- return { success: true, storage: data.items };
-}
-
-// ══════════════════════════════════════════════════════════════
-// FARMING
-// ══════════════════════════════════════════════════════════════
-
-function getFarmPlots(userId) {
- const plots = db.all('SELECT * FROM game_farm_plots WHERE user_id = ? ORDER BY plot_index', [userId]);
- if (plots.length < 2) {
- for (let i = plots.length; i < 2; i++)
- db.run('INSERT OR IGNORE INTO game_farm_plots (user_id, plot_index) VALUES (?, ?)', [userId, i]);
- return db.all('SELECT * FROM game_farm_plots WHERE user_id = ? ORDER BY plot_index', [userId]);
- }
- return plots.map(p => {
- if (p.crop_id && !['empty', 'ripe', 'withered'].includes(p.stage)) p.stage = computeGrowthStage(p);
- return p;
- });
-}
-
-function computeGrowthStage(plot) {
- if (!plot.planted_at || !plot.crop_id) return 'empty';
- const cd = CROPS[plot.crop_id]; if (!cd) return 'empty';
- let plantedAt; try { plantedAt = new Date(plot.planted_at.replace(' ', 'T') + 'Z').getTime(); } catch { return 'empty'; }
- const elapsed = Date.now() - plantedAt;
- const wBonus = plot.watered_at ? 0.8 : 1.0;
- const t = cd.growTime * wBonus;
- if (elapsed >= t * 1.5) return 'withered';
- if (elapsed >= t) return 'ripe';
- if (elapsed >= t * 0.6) return 'growing';
- if (elapsed >= t * 0.3) return 'sprout';
- return 'seed';
-}
-
-function plant(userId, plotIndex, seedId) {
- if (!CROPS[seedId]) return { error: 'Invalid seed.' };
- if (!removeItem(userId, seedId, 1)) return { error: 'No seeds!' };
- const plot = db.get('SELECT * FROM game_farm_plots WHERE user_id = ? AND plot_index = ?', [userId, plotIndex]);
- if (!plot) return { error: 'Invalid plot.' };
- if (plot.stage !== 'empty' && plot.stage !== 'withered') return { error: 'Plot occupied!' };
- db.run(`UPDATE game_farm_plots SET crop_id = ?, planted_at = CURRENT_TIMESTAMP, watered_at = NULL, fertilized = 0, stage = 'seed' WHERE user_id = ? AND plot_index = ?`,
- [seedId, userId, plotIndex]);
- return { success: true, plotIndex, seed: seedId };
-}
-
-function water(userId, plotIndex) {
- const p = db.get('SELECT * FROM game_farm_plots WHERE user_id = ? AND plot_index = ?', [userId, plotIndex]);
- if (!p || !p.crop_id) return { error: 'Nothing planted!' };
- if (p.watered_at) return { error: 'Already watered!' };
- db.run('UPDATE game_farm_plots SET watered_at = CURRENT_TIMESTAMP WHERE user_id = ? AND plot_index = ?', [userId, plotIndex]);
- return { success: true, xp: addXp(userId, 'farming', 5) };
-}
-
-function harvest(userId, plotIndex) {
- const p = db.get('SELECT * FROM game_farm_plots WHERE user_id = ? AND plot_index = ?', [userId, plotIndex]);
- if (!p || !p.crop_id) return { error: 'Nothing planted!' };
- const stage = computeGrowthStage(p);
- if (stage !== 'ripe') return { error: `Crop is ${stage}.` };
- const cd = CROPS[p.crop_id];
- let qty = cd.yield[0] + Math.floor(Math.random() * (cd.yield[1] - cd.yield[0] + 1));
- if (p.fertilized) qty *= 2;
- addItem(userId, cd.output, qty);
- db.run(`UPDATE game_farm_plots SET crop_id = NULL, planted_at = NULL, watered_at = NULL, fertilized = 0, stage = 'empty' WHERE user_id = ? AND plot_index = ?`,
- [userId, plotIndex]);
- return { success: true, crop: cd.output, qty, xp: addXp(userId, 'farming', 15 + qty * 5) };
-}
-
-// ══════════════════════════════════════════════════════════════
-// CRAFTING
-// ══════════════════════════════════════════════════════════════
-
-function getUnlockedRecipes(userId) {
- const unlocked = db.all('SELECT recipe_id FROM game_recipes WHERE user_id = ?', [userId]);
- const ids = new Set(unlocked.map(r => r.recipe_id));
- for (const [id, r] of Object.entries(RECIPES)) { if (r.rarity === 'Common') ids.add(id); }
- return Array.from(ids).map(id => {
- const recipe = RECIPES[id];
- if (!recipe) return null;
- const outputItem = ITEMS[recipe.output] || {};
- const inputDetails = {};
- for (const [itemId, qty] of Object.entries(recipe.inputs)) {
- const item = ITEMS[itemId] || {};
- inputDetails[itemId] = { name: item.name || itemId, emoji: item.emoji || '❓', qty };
- }
- return { id, ...recipe, outputEmoji: outputItem.emoji || '❓', outputCategory: outputItem.category || 'crafted', inputDetails };
- }).filter(Boolean);
-}
-
-function craft(userId, recipeId) {
- const recipe = RECIPES[recipeId];
- if (!recipe) return { error: 'Unknown recipe.' };
- const unlocked = db.get('SELECT id FROM game_recipes WHERE user_id = ? AND recipe_id = ?', [userId, recipeId]);
- if (!unlocked && recipe.rarity !== 'Common') return { error: 'Recipe not unlocked!' };
-
- // Station requirement check (furnace, workbench, campfire)
- if (recipe.station) {
- if (!isNearStation(userId, recipe.station)) {
- const stationNames = { furnace: 'Furnace 🔥', workbench: 'Workbench 🔨', campfire: 'Campfire 🔥' };
- return { error: `Need to be near a ${stationNames[recipe.station] || recipe.station}!` };
- }
- }
-
- // Smithing level check
- if (recipe.smithingLevel) {
- const p = getPlayer(userId);
- if (p.smithing_level < recipe.smithingLevel) {
- return { error: `Need Smithing level ${recipe.smithingLevel}! (You're level ${p.smithing_level})` };
- }
- }
-
- for (const [itemId, qty] of Object.entries(recipe.inputs)) {
- if (!hasItem(userId, itemId, qty)) return { error: `Need ${qty}x ${(ITEMS[itemId] || {}).name || itemId}` };
- }
- for (const [itemId, qty] of Object.entries(recipe.inputs)) removeItem(userId, itemId, qty);
- addItem(userId, recipe.output, recipe.qty);
- db.run('UPDATE game_players SET total_items_crafted = total_items_crafted + 1 WHERE user_id = ?', [userId]);
- incrementDailyQuestStat(userId, 'craft_count', 1);
-
- // Achievement checks
- const totalCrafted = db.get('SELECT total_items_crafted FROM game_players WHERE user_id = ?', [userId])?.total_items_crafted || 0;
- checkAchievement(userId, 'first_craft', totalCrafted >= 1);
- checkAchievement(userId, 'craft_25', totalCrafted >= 25);
- checkAchievement(userId, 'craft_100', totalCrafted >= 100);
- if (recipe.skill === 'smithing' && recipe.station === 'furnace') checkAchievement(userId, 'smelt_first', true);
-
- // Smithing recipes award smithing XP, others award crafting XP
- const xpSkill = recipe.skill === 'smithing' ? 'smithing' : 'crafting';
- return { success: true, output: { id: recipe.output, ...ITEMS[recipe.output] }, qty: recipe.qty, xp: addXp(userId, xpSkill, recipe.xp) };
-}
-
-function rollRecipeUnlock(userId) {
- const all = Object.entries(RECIPES).filter(([_, r]) => r.rarity !== 'Common');
- const have = new Set(db.all('SELECT recipe_id FROM game_recipes WHERE user_id = ?', [userId]).map(r => r.recipe_id));
- const avail = all.filter(([id]) => !have.has(id));
- if (!avail.length || Math.random() > 0.05) return null;
- const weights = { Uncommon: 40, Rare: 25, Epic: 10, Legendary: 3 };
- const table = avail.map(([id, r]) => ({ id, weight: weights[r.rarity] || 10 }));
- const recipeId = rollLoot(table);
- db.run('INSERT OR IGNORE INTO game_recipes (user_id, recipe_id) VALUES (?, ?)', [userId, recipeId]);
- return { id: recipeId, ...RECIPES[recipeId] };
-}
-
-// ══════════════════════════════════════════════════════════════
-// COMBAT (Open-world PvP)
-// ══════════════════════════════════════════════════════════════
-
-function attackPlayer(attackerId, targetId) {
- const atk = getPlayer(attackerId);
- const def = getPlayer(targetId);
- if (!def) return { error: 'Target not found.' };
-
- // Weapon stats affect range
- const weaponInfo = getWeaponInfo(atk.equip_weapon);
- const attackRange = TILE * 3 * (weaponInfo.range || 1.0);
- const dist = Math.sqrt((atk.x - def.x) ** 2 + (atk.y - def.y) ** 2);
- if (dist > attackRange) return { error: 'Too far!' };
-
- const aTile = { x: Math.floor(atk.x / TILE), y: Math.floor(atk.y / TILE) };
- const dTile = { x: Math.floor(def.x / TILE), y: Math.floor(def.y / TILE) };
- if (isInSafeZone(aTile.x, aTile.y) || isInSafeZone(dTile.x, dTile.y)) return { error: 'No PvP in Town!' };
-
- // Check dodge i-frames
- if (isInvulnerable(targetId)) return { success: true, dodged: true, dmg: 0, targetHp: def.hp, targetMaxHp: def.max_hp, killed: false };
-
- // Combo system
- registerHit(attackerId);
- const combo = getComboMultiplier(attackerId);
-
- const isCrit = Math.random() < 0.15;
- let dmg = Math.max(1, Math.round(atk.attack * (0.8 + Math.random() * 0.4) - def.defense * 0.3));
- if (isCrit) dmg = Math.round(dmg * 1.5);
- dmg = Math.round(dmg * combo.mult);
-
- const newHp = Math.max(0, def.hp - dmg);
- db.run('UPDATE game_players SET hp = ? WHERE user_id = ?', [newHp, targetId]);
-
- const result = {
- success: true, dmg, isCrit, targetHp: newHp, targetMaxHp: def.max_hp, killed: false,
- combo: combo.count, comboMult: combo.mult,
- weaponSpeed: weaponInfo.speed,
- };
-
- if (newHp <= 0) {
- result.killed = true;
- result.deathData = handleDeath(targetId, attackerId);
- result.xp = addXp(attackerId, 'combat', 30 + atk.combat_level * 3);
- ensureBattleStats(attackerId);
- ensureBattleStats(targetId);
- db.run('UPDATE game_battle_stats SET battles_won = battles_won + 1, kill_streak = kill_streak + 1, best_streak = MAX(best_streak, kill_streak + 1), kills = kills + 1 WHERE user_id = ?', [attackerId]);
- db.run('UPDATE game_battle_stats SET battles_lost = battles_lost + 1, kill_streak = 0, deaths = deaths + 1 WHERE user_id = ?', [targetId]);
- comboTrackers.delete(attackerId);
- // PvP achievements
- checkAchievement(attackerId, 'pvp_first', true);
- const streak = db.get('SELECT kill_streak FROM game_battle_stats WHERE user_id = ?', [attackerId])?.kill_streak || 0;
- checkAchievement(attackerId, 'kill_streak_5', streak >= 5);
- }
- // Combo achievement
- if (combo.count >= 5) checkAchievement(attackerId, 'combo_master', true);
- return result;
-}
-
-function handleDeath(userId, killerId) {
- const inv = getInventory(userId);
- const dropped = [];
- const p = getPlayer(userId);
- const deathX = p.x, deathY = p.y;
-
- for (const item of inv) {
- if (item.item_id.startsWith('fx_') || item.item_id.startsWith('px_')) continue; // Keep cosmetics
- const dropQty = Math.floor(item.quantity / 2);
- if (dropQty > 0) {
- removeItem(userId, item.item_id, dropQty);
- // Scatter items as ground drops around the death position
- const angle = Math.random() * Math.PI * 2;
- const dist = 20 + Math.random() * 60;
- const gx = deathX + Math.cos(angle) * dist;
- const gy = deathY + Math.sin(angle) * dist;
- const gid = dropGroundItem(item.item_id, dropQty, gx, gy, userId);
- dropped.push({
- groundId: gid, id: item.item_id, qty: dropQty,
- name: item.name, emoji: item.emoji,
- x: gx, y: gy,
- });
- }
- }
- const spawn = db.get('SELECT sleeping_bag_x, sleeping_bag_y FROM game_players WHERE user_id = ?', [userId]);
- let sx = OUTPOST_X * TILE + TILE / 2, sy = OUTPOST_Y * TILE + TILE / 2;
- if (spawn?.sleeping_bag_x && spawn?.sleeping_bag_y) { sx = spawn.sleeping_bag_x; sy = spawn.sleeping_bag_y; }
- db.run('UPDATE game_players SET hp = max_hp, stamina = max_stamina, x = ?, y = ?, total_deaths = total_deaths + 1 WHERE user_id = ?', [sx, sy, userId]);
- addXp(userId, 'combat', 5);
- // Achievement: die 10 times
- const deathP = getPlayer(userId);
- if (deathP) checkAchievement(userId, 'die_10', deathP.total_deaths >= 10);
- return { dropped, spawnX: sx, spawnY: sy, deathX, deathY };
-}
-
-function ensureBattleStats(userId) { db.run('INSERT OR IGNORE INTO game_battle_stats (user_id) VALUES (?)', [userId]); }
-
-function getBattleStats(userId) {
- ensureBattleStats(userId);
- return db.get('SELECT * FROM game_battle_stats WHERE user_id = ?', [userId]);
-}
-
-// ══════════════════════════════════════════════════════════════
-// DUNGEON (PvE)
-// ══════════════════════════════════════════════════════════════
-
-function enterDungeon(userId) {
- const p = getPlayer(userId);
- if (p.combat_level < 5) return { error: 'Need Combat Lv 5.' };
- if (!removeItem(userId, 'dungeon_key', 1)) return { error: 'Need a Dungeon Key!' };
- const maxIdx = Math.min(MONSTERS.length - 1, Math.floor(p.combat_level / 5));
- const monster = { ...MONSTERS[Math.floor(Math.random() * (maxIdx + 1))] };
- const scale = 1 + (p.combat_level - 1) * 0.05;
- monster.hp = Math.round(monster.hp * scale);
- monster.atk = Math.round(monster.atk * scale);
- // Achievement: enter a dungeon
- checkAchievement(userId, 'dungeon_enter', true);
- return { success: true, monster, playerHp: p.hp, playerAtk: p.attack, playerDef: p.defense };
-}
-
-function fightMonster(userId, monster) {
- const p = getPlayer(userId);
- let playerHp = p.hp, monsterHp = monster.hp;
- const log = [];
- let round = 0;
- while (playerHp > 0 && monsterHp > 0 && round < 20) {
- round++;
- const pDmg = Math.max(1, Math.round(p.attack * (0.8 + Math.random() * 0.4) - (monster.def || 0) * 0.3));
- monsterHp -= pDmg;
- log.push({ type: 'player_attack', dmg: pDmg, monsterHp: Math.max(0, monsterHp) });
- if (monsterHp <= 0) break;
- const mDmg = Math.max(1, Math.round(monster.atk * (0.8 + Math.random() * 0.4) - p.defense * 0.3));
- playerHp -= mDmg;
- log.push({ type: 'monster_attack', dmg: mDmg, playerHp: Math.max(0, playerHp) });
- }
- db.run('UPDATE game_players SET hp = ? WHERE user_id = ?', [Math.max(0, playerHp), userId]);
- const won = monsterHp <= 0;
- if (won) {
- const xp = addXp(userId, 'combat', monster.xp);
- db.addOpenCoins(userId, monster.gold);
- db.run('UPDATE game_players SET total_monsters_killed = total_monsters_killed + 1, total_dungeon_wins = total_dungeon_wins + 1 WHERE user_id = ?', [userId]);
- let lootDrop = null;
- if (monster.loot?.length) {
- const lootId = rollLoot(monster.loot.map(l => ({ id: l.id, weight: l.w })));
- addItem(userId, lootId, 1);
- lootDrop = { id: lootId, ...ITEMS[lootId] };
- }
- // Achievement: dungeon wins
- const dwP = getPlayer(userId);
- if (dwP) checkAchievement(userId, 'dungeon_wins_10', dwP.total_dungeon_wins >= 10);
- return { success: true, won: true, log, xp, gold: monster.gold, loot: lootDrop };
- }
- db.run('UPDATE game_players SET total_deaths = total_deaths + 1, hp = max_hp WHERE user_id = ?', [userId]);
- addXp(userId, 'combat', Math.round(monster.xp * 0.2));
- return { success: true, won: false, log };
-}
-
-// ══════════════════════════════════════════════════════════════
-// EQUIPMENT
-// ══════════════════════════════════════════════════════════════
-
-const EQUIPMENT_SLOTS = { rods: 'equip_rod', pickaxes: 'equip_pickaxe', axes: 'equip_axe', hats: 'equip_hat', weapons: 'equip_weapon', armor: 'equip_armor' };
-
-/** Check if newItemId is a better equipment than currentItemId in the given category */
-function isBetterEquipment(currentItemId, newItemId, category) {
- if (!currentItemId) return true;
- const curItem = ITEMS[currentItemId];
- const newItem = ITEMS[newItemId];
- if (!curItem || !newItem) return false;
- // For tools (pickaxes/axes/rods), compare by buyCost as tier proxy
- if (['pickaxes', 'axes', 'rods'].includes(category)) {
- const tierMaps = { pickaxes: PICK_TIERS, axes: AXE_TIERS };
- const tierMap = tierMaps[category];
- if (tierMap) {
- const curTier = tierMap[currentItemId]?.tier ?? -1;
- const newTier = tierMap[newItemId]?.tier ?? -1;
- return newTier > curTier;
- }
- return (newItem.buyCost || 0) > (curItem.buyCost || 0);
- }
- // For weapons/armor, compare by stats
- if (category === 'weapons') return (WEAPON_STATS[newItemId]?.atk || 0) > (WEAPON_STATS[currentItemId]?.atk || 0);
- if (category === 'armor') return (ARMOR_STATS[newItemId]?.def || 0) > (ARMOR_STATS[currentItemId]?.def || 0);
- return false;
-}
-
-function equipItem(userId, itemId) {
- const item = ITEMS[itemId];
- if (!item) return { error: 'Unknown item.' };
- if (!hasItem(userId, itemId, 1)) return { error: 'You don\'t have that.' };
- const slot = EQUIPMENT_SLOTS[item.category];
- if (!slot) return { error: 'Can\'t equip that.' };
-
- // Level requirement check (RuneScape-style)
- const skillName = EQUIP_SKILL_MAP[item.category];
- if (skillName && item.levelReq) {
- const p = getPlayer(userId);
- const playerLevel = xpToLevel(p[`${skillName}_xp`]);
- if (playerLevel < item.levelReq) {
- return { error: `Need ${skillName.charAt(0).toUpperCase() + skillName.slice(1)} level ${item.levelReq} to equip! (You're level ${playerLevel})` };
- }
- }
-
- db.run(`UPDATE game_players SET ${slot} = ? WHERE user_id = ?`, [itemId, userId]);
- recalcCombatStats(userId);
- // Achievement: first hat
- if (item.category === 'hats') checkAchievement(userId, 'first_hat', true);
- return { success: true, slot: item.category, item: { id: itemId, ...item } };
-}
-
-function unequipItem(userId, slotCategory) {
- const SLOT_MAP = { weapons: 'equip_weapon', armor: 'equip_armor', hats: 'equip_hat', pickaxes: 'equip_pickaxe', axes: 'equip_axe', rods: 'equip_rod' };
- const col = SLOT_MAP[slotCategory];
- if (!col) return { error: 'Invalid slot.' };
- const p = getPlayer(userId);
- if (!p[col]) return { error: 'Nothing equipped there.' };
- const removedId = p[col];
- db.run(`UPDATE game_players SET ${col} = NULL WHERE user_id = ?`, [userId]);
- recalcCombatStats(userId);
- const item = ITEMS[removedId];
- return { success: true, slot: slotCategory, item: item ? { id: removedId, ...item } : { id: removedId } };
-}
-
-function recalcCombatStats(userId) {
- const p = db.get('SELECT equip_weapon, equip_armor FROM game_players WHERE user_id = ?', [userId]);
- let atk = 10 + (WEAPON_STATS[p.equip_weapon]?.atk || 0);
- let def = 5 + (ARMOR_STATS[p.equip_armor]?.def || 0);
- // Apply food buffs
- const buff = getActiveFoodBuff(userId);
- if (buff) { atk += (buff.atk || 0); def += (buff.def || 0); }
- db.run('UPDATE game_players SET attack = ?, defense = ? WHERE user_id = ?', [atk, def, userId]);
-}
-
-// ══════════════════════════════════════════════════════════════
-// DODGE ROLL
-// ══════════════════════════════════════════════════════════════
-
-function dodgeRoll(userId, dx, dy) {
- const p = getPlayer(userId);
- const sta = db.get('SELECT stamina FROM game_players WHERE user_id = ?', [userId])?.stamina || 0;
- // Agility reduces dodge cost (base 20, min 10)
- const bonuses = getAgilityBonuses(p.agility_level);
- const dodgeCost = Math.max(10, 20 - bonuses.dodgeCostReduct);
- if (sta < dodgeCost) return { error: `Not enough stamina to dodge! (Need ${dodgeCost})` };
- // Check cooldown (1.5s)
- const existing = dodgeIframes.get(userId);
- if (existing && Date.now() < existing + 1000) return { error: 'Dodge on cooldown!' };
- db.run('UPDATE game_players SET stamina = stamina - ? WHERE user_id = ?', [dodgeCost, userId]);
- // Grant 400ms invulnerability
- dodgeIframes.set(userId, Date.now() + 400);
- // Calculate dodge destination (3 tiles + agility bonus)
- const bonusDist = Math.floor(p.agility_level * 0.05 * TILE);
- const dist = TILE * 3 + bonusDist;
- const len = Math.sqrt(dx * dx + dy * dy) || 1;
- const newX = Math.max(TILE, Math.min((MAP_W - 1) * TILE, p.x + (dx / len) * dist));
- const newY = Math.max(TILE, Math.min((MAP_H - 1) * TILE, p.y + (dy / len) * dist));
- updatePlayerPosition(userId, newX, newY);
- // Grant agility XP for dodging
- const agilXp = addXp(userId, 'agility', 15);
- return { success: true, stamina: sta - dodgeCost, x: newX, y: newY, agilityXp: agilXp };
-}
-
-function isInvulnerable(userId) {
- const t = dodgeIframes.get(userId);
- if (!t) return false;
- if (Date.now() < t) return true;
- dodgeIframes.delete(userId);
- return false;
-}
-
-// ══════════════════════════════════════════════════════════════
-// COMBO SYSTEM
-// ══════════════════════════════════════════════════════════════
-
-function getComboMultiplier(userId) {
- const c = comboTrackers.get(userId);
- if (!c) return { mult: 1.0, count: 0 };
- if (Date.now() - c.lastHitTime > COMBO_WINDOW) {
- comboTrackers.delete(userId);
- return { mult: 1.0, count: 0 };
- }
- const idx = Math.min(c.count, COMBO_MULTIPLIERS.length - 1);
- return { mult: COMBO_MULTIPLIERS[idx], count: c.count };
-}
-
-function registerHit(userId) {
- const c = comboTrackers.get(userId);
- const now = Date.now();
- if (c && now - c.lastHitTime < COMBO_WINDOW) {
- c.count++;
- c.lastHitTime = now;
- } else {
- comboTrackers.set(userId, { count: 1, lastHitTime: now });
- }
-}
-
-// ══════════════════════════════════════════════════════════════
-// SPRINT STAMINA
-// ══════════════════════════════════════════════════════════════
-
-function drainSprintStamina(userId) {
- // Called every ~500ms while sprinting
- sprintingPlayers.add(userId); // Mark as sprinting for regen tick
- const buff = getActiveFoodBuff(userId);
- if (buff?.sprint_free) {
- // Still grant agility XP even with free sprint
- addXp(userId, 'agility', 1);
- return { ok: true };
- }
- const pd = getPlayer(userId);
- const p = db.get('SELECT stamina FROM game_players WHERE user_id = ?', [userId]);
- if (!p || p.stamina <= 0) return { ok: false, stamina: 0 };
- // Agility reduces sprint drain (base 1, reduced by sprintDrainMult)
- const bonuses = getAgilityBonuses(pd.agility_level);
- // Apply drain reduction: at high agility, skip some drain ticks
- if (bonuses.sprintDrainMult < 1.0 && Math.random() > bonuses.sprintDrainMult) {
- // Saved by agility — no drain this tick
- addXp(userId, 'agility', 1);
- return { ok: true, stamina: p.stamina };
- }
- db.run('UPDATE game_players SET stamina = MAX(0, stamina - 1) WHERE user_id = ?', [userId]);
- // Grant agility XP for sprinting
- addXp(userId, 'agility', 1);
- return { ok: true, stamina: p.stamina - 1 };
-}
-
-// ══════════════════════════════════════════════════════════════
-// FOOD / BUFF SYSTEM
-// ══════════════════════════════════════════════════════════════
-
-function eatFood(userId, itemId) {
- const effect = FOOD_EFFECTS[itemId];
- if (!effect) return { error: 'Not food.' };
- if (!removeItem(userId, itemId, 1)) return { error: 'Don\'t have it.' };
- const result = { success: true, food: itemId, effects: [] };
- // Instant HP heal
- if (effect.hp) {
- db.run('UPDATE game_players SET hp = MIN(max_hp, hp + ?) WHERE user_id = ?', [effect.hp, userId]);
- result.effects.push({ type: 'heal', amount: effect.hp });
- }
- // Instant stamina restore
- if (effect.stamina) {
- db.run('UPDATE game_players SET stamina = MIN(max_stamina, stamina + ?) WHERE user_id = ?', [effect.stamina, userId]);
- result.effects.push({ type: 'stamina', amount: effect.stamina });
- }
- // Timed buff
- if (effect.buff) {
- const buff = { ...effect.buff, expiresAt: Date.now() + effect.buff.duration };
- foodBuffs.set(userId, buff);
- result.effects.push({ type: 'buff', buff: { ...effect.buff } });
- result.buffDuration = effect.buff.duration;
- // Recalc combat stats if buff has ATK/DEF
- if (effect.buff.atk || effect.buff.def) recalcCombatStats(userId);
- }
- // XP for cooking skill
- addXp(userId, 'crafting', 5);
- return result;
-}
-
-function getActiveFoodBuff(userId) {
- const buff = foodBuffs.get(userId);
- if (!buff) return null;
- if (Date.now() > buff.expiresAt) {
- foodBuffs.delete(userId);
- recalcCombatStats(userId); // Remove buff stats
- return null;
- }
- return buff;
-}
-
-function getWeaponInfo(weaponId) {
- return WEAPON_STATS[weaponId] || WEAPON_STATS.weapon_fist;
-}
-
-// ══════════════════════════════════════════════════════════════
-// COSMETICS
-// ══════════════════════════════════════════════════════════════
-
-function equipCosmetic(userId, itemId) {
- const item = ITEMS[itemId];
- if (!item || !hasItem(userId, itemId, 1)) return { error: 'Don\'t have it.' };
- if (item.category === 'name_effects') { db.run('UPDATE game_players SET name_effect = ? WHERE user_id = ?', [itemId, userId]); return { success: true, type: 'name_effect' }; }
- if (item.category === 'particles') { db.run('UPDATE game_players SET particle_effect = ? WHERE user_id = ?', [itemId, userId]); return { success: true, type: 'particle_effect' }; }
- return { error: 'Not cosmetic.' };
-}
-
-function unequipCosmetic(userId, type) {
- if (type === 'name_effect') db.run('UPDATE game_players SET name_effect = NULL WHERE user_id = ?', [userId]);
- else if (type === 'particle_effect') db.run('UPDATE game_players SET particle_effect = NULL WHERE user_id = ?', [userId]);
- return { success: true };
-}
-
-// ══════════════════════════════════════════════════════════════
-// CONSUMABLES / EFFECTS
-// ══════════════════════════════════════════════════════════════
-
-function useConsumable(userId, itemId) {
- if (!removeItem(userId, itemId, 1)) return { error: 'Don\'t have it.' };
- const effects = {
- craft_elixir: { type: 'coin_boost', duration: 600000, data: { multiplier: 2 } },
- craft_xp_potion: { type: 'xp_boost', duration: 600000, data: { multiplier: 2 } },
- craft_loot_magnet: { type: 'loot_magnet', charges: 5 },
- supp_stamina: { type: 'instant', effect: 'stamina', amount: 50 },
- potion_health: { type: 'instant', effect: 'hp', amount: 30 },
- potion_health_big: { type: 'instant', effect: 'hp', amount: 75 },
- potion_stamina: { type: 'instant', effect: 'stamina', amount: 30 },
- };
- const eff = effects[itemId];
- if (!eff) return { error: 'Not consumable.' };
- if (eff.type === 'instant') {
- if (eff.effect === 'hp') db.run('UPDATE game_players SET hp = MIN(max_hp, hp + ?) WHERE user_id = ?', [eff.amount, userId]);
- else db.run('UPDATE game_players SET stamina = MIN(max_stamina, stamina + ?) WHERE user_id = ?', [eff.amount, userId]);
- return { success: true, instant: true, effect: eff.effect, amount: eff.amount };
- }
- const expiresAt = eff.duration ? new Date(Date.now() + eff.duration).toISOString().replace('T', ' ').slice(0, 19) : null;
- db.run('INSERT INTO game_effects (user_id, effect_type, effect_id, expires_at, charges, data) VALUES (?, ?, ?, ?, ?, ?)',
- [userId, eff.type, itemId, expiresAt, eff.charges || null, JSON.stringify(eff.data || {})]);
- return { success: true, effectType: eff.type, duration: eff.duration, charges: eff.charges };
-}
-
-function getActiveEffects(userId) {
- db.run("DELETE FROM game_effects WHERE expires_at IS NOT NULL AND expires_at < CURRENT_TIMESTAMP AND user_id = ?", [userId]);
- db.run("DELETE FROM game_effects WHERE charges IS NOT NULL AND charges <= 0 AND user_id = ?", [userId]);
- return db.all('SELECT * FROM game_effects WHERE user_id = ?', [userId]);
-}
-
-// ══════════════════════════════════════════════════════════════
-// LEADERBOARDS
-// ══════════════════════════════════════════════════════════════
-
-function getLeaderboard(boardType, limit = 20) {
- const cols = {
- mining: 'mining_xp', fishing: 'fishing_xp', woodcut: 'woodcut_xp',
- farming: 'farming_xp', combat: 'combat_xp', crafting: 'crafting_xp',
- smithing: 'smithing_xp', agility: 'COALESCE(agility_xp, 0)',
- total_level: 'mining_xp + fishing_xp + woodcut_xp + farming_xp + combat_xp + crafting_xp + COALESCE(smithing_xp, 0) + COALESCE(agility_xp, 0)',
- kills: 'total_monsters_killed', buildings: 'structures_built',
- };
- const col = cols[boardType];
- if (!col) return [];
- return db.all(`SELECT gp.user_id, gp.display_name, u.username, ${col} as score, gp.name_effect, gp.particle_effect
- FROM game_players gp JOIN users u ON gp.user_id = u.id ORDER BY ${col} DESC LIMIT ?`, [limit]);
-}
-
-// ══════════════════════════════════════════════════════════════
-// MULTIPLAYER STATE
-// ══════════════════════════════════════════════════════════════
-
-const livePlayers = new Map();
-
-function updateLivePlayer(userId, data) {
- livePlayers.set(userId, { ...data, userId, lastUpdate: Date.now() });
-}
-
-function removeLivePlayer(userId) { livePlayers.delete(userId); }
-
-function getLivePlayers() {
- const now = Date.now();
- const result = {};
- for (const [uid, p] of livePlayers) {
- if (now - p.lastUpdate > 30000) { livePlayers.delete(uid); continue; }
- result[uid] = p;
- }
- return result;
-}
-
-// ══════════════════════════════════════════════════════════════
-// WORLD MOBS — In-memory PvE system
-// ══════════════════════════════════════════════════════════════
-
-const liveMobs = new Map(); // mobId → mob object
-let mobIdCounter = 0;
-const MOB_LEASH = 8 * TILE;
-const MOB_ATTACK_RANGE = 1.5 * TILE;
-const MOB_ATTACK_CD = 2000; // ms between mob attacks
-const MOB_MAX_NEARBY = 8; // max mobs within 10 tiles of a player
-
-/** Spawn mobs near live players. Called every few seconds. */
-function spawnMobs() {
- const players = getLivePlayers();
- for (const [uid, p] of Object.entries(players)) {
- const ptx = Math.floor(p.x / TILE), pty = Math.floor(p.y / TILE);
- if (isInSafeZone(ptx, pty)) continue;
- // Count existing mobs nearby
- let nearby = 0;
- for (const mob of liveMobs.values()) {
- if (Math.abs(mob.x - p.x) + Math.abs(mob.y - p.y) < 10 * TILE) nearby++;
- }
- if (nearby >= MOB_MAX_NEARBY) continue;
-
- const angle = Math.random() * Math.PI * 2;
- const dist = (5 + Math.random() * 6) * TILE;
- const mx = p.x + Math.cos(angle) * dist;
- const my = p.y + Math.sin(angle) * dist;
- const mtx = Math.floor(mx / TILE), mty = Math.floor(my / TILE);
- if (mtx < 1 || mtx >= MAP_W - 1 || mty < 1 || mty >= MAP_H - 1) continue;
-
- const biome = getBiomeAt(mtx, mty, worldSeed);
- if (biome === 'water' || biome === 'outpost') continue;
-
- const tier = getDifficultyTier(mtx, mty);
- const eligible = Object.entries(MOB_TYPES).filter(([_, m]) =>
- m.biomes.includes(biome) && m.minTier <= tier
- );
- if (!eligible.length) continue;
-
- const [typeName, def] = eligible[Math.floor(Math.random() * eligible.length)];
- const scale = 1 + tier * 0.15;
- const id = ++mobIdCounter;
- liveMobs.set(id, {
- id, type: typeName, name: def.name, emoji: def.emoji,
- x: mx, y: my, spawnX: mx, spawnY: my,
- hp: Math.round(def.hp * scale), maxHp: Math.round(def.hp * scale),
- atk: Math.round(def.atk * scale), def: Math.round(def.def * scale),
- speed: def.speed, aggroRange: def.aggroRange * TILE,
- state: 'idle', targetUserId: null, lastAttack: 0,
- patrolAngle: Math.random() * Math.PI * 2,
- xp: def.xp, gold: Math.round(def.gold * scale), loot: def.loot,
- _pendingAttack: null, _pendingKill: null,
- });
- }
-}
-
-/** Run one AI tick for every live mob. Returns arrays of pending attacks/kills for broadcast. */
-function mobTick() {
- const now = Date.now();
- const players = getLivePlayers();
- const toRemove = [];
- const attacks = []; // { mobId, targetId, dmg, newHp, maxHp }
- const kills = []; // { mobId, targetId, deathData }
-
- for (const [id, mob] of liveMobs) {
- // Despawn if far from all players
- let nearDist = Infinity, nearP = null;
- for (const [uid, p] of Object.entries(players)) {
- const d = Math.abs(mob.x - p.x) + Math.abs(mob.y - p.y);
- if (d < nearDist) { nearDist = d; nearP = { uid, ...p }; }
- }
- if (nearDist > 16 * TILE) { toRemove.push(id); continue; }
-
- const spd = mob.speed * 1.6; // pixels per tick
-
- switch (mob.state) {
- case 'idle': {
- if (nearP && nearDist < mob.aggroRange) {
- const ptx = Math.floor(nearP.x / TILE), pty = Math.floor(nearP.y / TILE);
- if (!isInSafeZone(ptx, pty)) { mob.state = 'chase'; mob.targetUserId = nearP.uid; break; }
- }
- if (Math.random() < 0.02) { mob.state = 'patrol'; mob.patrolAngle = Math.random() * Math.PI * 2; }
- break;
- }
- case 'patrol': {
- mob.x += Math.cos(mob.patrolAngle) * spd * 0.5;
- mob.y += Math.sin(mob.patrolAngle) * spd * 0.5;
- if (Math.random() < 0.03) mob.state = 'idle';
- if (nearP && nearDist < mob.aggroRange) {
- const ptx = Math.floor(nearP.x / TILE), pty = Math.floor(nearP.y / TILE);
- if (!isInSafeZone(ptx, pty)) { mob.state = 'chase'; mob.targetUserId = nearP.uid; break; }
- }
- const ds = Math.sqrt((mob.x - mob.spawnX) ** 2 + (mob.y - mob.spawnY) ** 2);
- if (ds > MOB_LEASH) mob.state = 'returning';
- break;
- }
- case 'chase': {
- const tgt = players[mob.targetUserId];
- if (!tgt) { mob.state = 'idle'; mob.targetUserId = null; break; }
- const dx = tgt.x - mob.x, dy = tgt.y - mob.y;
- const d = Math.sqrt(dx * dx + dy * dy);
- const ttx = Math.floor(tgt.x / TILE), tty = Math.floor(tgt.y / TILE);
- if (isInSafeZone(ttx, tty) || d > mob.aggroRange * 1.6) {
- mob.state = 'returning'; mob.targetUserId = null; break;
- }
- if (d > MOB_ATTACK_RANGE) {
- mob.x += (dx / d) * spd;
- mob.y += (dy / d) * spd;
- } else if (now - mob.lastAttack >= MOB_ATTACK_CD) {
- mob.lastAttack = now;
- const targetUid = parseInt(mob.targetUserId);
- // Dodge i-frame check
- if (isInvulnerable(targetUid)) {
- attacks.push({ mobId: id, mobName: mob.name, targetId: mob.targetUserId, dmg: 0, dodged: true, newHp: -1, maxHp: -1 });
- } else {
- const pData = getPlayer(targetUid);
- if (pData) {
- const dmg = Math.max(1, Math.round(mob.atk * (0.8 + Math.random() * 0.4) - pData.defense * 0.3));
- const newHp = Math.max(0, pData.hp - dmg);
- db.run('UPDATE game_players SET hp = ? WHERE user_id = ?', [newHp, targetUid]);
- attacks.push({ mobId: id, mobName: mob.name, targetId: mob.targetUserId, dmg, newHp, maxHp: pData.max_hp });
- if (newHp <= 0) {
- const deathData = handleDeath(targetUid, null);
- kills.push({ mobId: id, mobName: mob.name, targetId: mob.targetUserId, deathData });
- mob.state = 'idle'; mob.targetUserId = null;
- }
- }
- }
- }
- const ds = Math.sqrt((mob.x - mob.spawnX) ** 2 + (mob.y - mob.spawnY) ** 2);
- if (ds > MOB_LEASH) { mob.state = 'returning'; mob.targetUserId = null; }
- break;
- }
- case 'returning': {
- const dx = mob.spawnX - mob.x, dy = mob.spawnY - mob.y;
- const d = Math.sqrt(dx * dx + dy * dy);
- if (d < TILE) { mob.state = 'idle'; mob.hp = mob.maxHp; }
- else { mob.x += (dx / d) * spd * 1.4; mob.y += (dy / d) * spd * 1.4; }
- break;
- }
- }
-
- // Clamp to world bounds
- mob.x = Math.max(TILE, Math.min((MAP_W - 1) * TILE, mob.x));
- mob.y = Math.max(TILE, Math.min((MAP_H - 1) * TILE, mob.y));
- }
- for (const mid of toRemove) liveMobs.delete(mid);
- return { attacks, kills };
-}
-
-/** Player attacks a world mob */
-function attackMob(userId, mobId) {
- const mob = liveMobs.get(mobId);
- if (!mob) return { error: 'Target not found.' };
- const p = getPlayer(userId);
- if (!p) return { error: 'Player not found.' };
-
- // Weapon range affects attack distance
- const weaponInfo = getWeaponInfo(p.equip_weapon);
- const attackRange = TILE * 3 * (weaponInfo.range || 1.0);
- const dist = Math.sqrt((p.x - mob.x) ** 2 + (p.y - mob.y) ** 2);
- if (dist > attackRange) return { error: 'Too far!' };
-
- // Combo system
- registerHit(userId);
- const combo = getComboMultiplier(userId);
-
- const isCrit = Math.random() < 0.15;
- let dmg = Math.max(1, Math.round(p.attack * (0.8 + Math.random() * 0.4) - mob.def * 0.3));
- if (isCrit) dmg = Math.round(dmg * 1.5);
- dmg = Math.round(dmg * combo.mult);
- mob.hp -= dmg;
-
- // Aggro onto attacker
- if (mob.state !== 'chase' || mob.targetUserId !== String(userId)) {
- mob.state = 'chase';
- mob.targetUserId = String(userId);
- }
-
- if (mob.hp <= 0) {
- // XP multiplier from food buff
- const buff = getActiveFoodBuff(userId);
- const xpMult = buff?.xp_mult || 1;
- const xpResult = addXp(userId, 'combat', Math.round(mob.xp * xpMult));
- db.addOpenCoins(userId, mob.gold);
- db.run('UPDATE game_players SET total_monsters_killed = total_monsters_killed + 1 WHERE user_id = ?', [userId]);
- incrementDailyQuestStat(userId, 'mob_kills', 1);
- let lootDrop = null;
- if (mob.loot?.length) {
- const lootId = rollLoot(mob.loot.map(l => ({ id: l.id, weight: l.w })));
- if (lootId) { addItem(userId, lootId, 1); lootDrop = { id: lootId, ...(ITEMS[lootId] || { name: lootId }) }; }
- }
- liveMobs.delete(mobId);
- comboTrackers.delete(userId);
- // Achievement: mob kills
- const mkP = getPlayer(userId);
- if (mkP) {
- checkAchievement(userId, 'first_blood', true);
- checkAchievement(userId, 'mob_slayer_10', mkP.total_monsters_killed >= 10);
- checkAchievement(userId, 'mob_slayer_50', mkP.total_monsters_killed >= 50);
- checkAchievement(userId, 'mob_slayer_100', mkP.total_monsters_killed >= 100);
- if (mob.type === 'dragon') checkAchievement(userId, 'dragon_slayer', true);
- }
- return {
- success: true, dmg, isCrit, killed: true, mobName: mob.name, mobEmoji: mob.emoji,
- xp: xpResult, gold: mob.gold, loot: lootDrop, mobX: mob.x, mobY: mob.y,
- combo: combo.count, comboMult: combo.mult, weaponSpeed: weaponInfo.speed,
- };
- }
- return {
- success: true, dmg, isCrit, killed: false,
- mobHp: mob.hp, mobMaxHp: mob.maxHp, mobX: mob.x, mobY: mob.y,
- combo: combo.count, comboMult: combo.mult, weaponSpeed: weaponInfo.speed,
- };
-}
-
-/** Serialise live mobs for state broadcast */
-function getMobStates() {
- const r = {};
- for (const [id, m] of liveMobs) {
- r[id] = { id: m.id, type: m.type, name: m.name, emoji: m.emoji, x: m.x, y: m.y, hp: m.hp, maxHp: m.maxHp, state: m.state };
- }
- return r;
-}
-
-/** Passive health regen for out-of-combat players (call from tick every ~3s) */
-function regenHealth() {
- const players = getLivePlayers();
- for (const [uid, p] of Object.entries(players)) {
- const pd = getPlayer(parseInt(uid));
- if (!pd || pd.hp >= pd.max_hp) continue;
- // Check if any mob is targeting this player
- let inCombat = false;
- for (const mob of liveMobs.values()) {
- if (mob.targetUserId === uid && mob.state === 'chase') { inCombat = true; break; }
- }
- if (!inCombat) {
- // Food buff hp_regen bonus
- const buff = getActiveFoodBuff(parseInt(uid));
- const basePct = 0.03 + (buff?.hp_regen || 0);
- const regen = Math.max(1, Math.round(pd.max_hp * basePct));
- const newHp = Math.min(pd.max_hp, pd.hp + regen);
- if (newHp !== pd.hp) db.run('UPDATE game_players SET hp = ? WHERE user_id = ?', [newHp, parseInt(uid)]);
- }
- }
-}
-
-/**
- * Track player movement for agility XP (swimming, distance).
- * Called from _handleMove in game-server.js.
- */
-function trackMovement(userId, x, y) {
- const tx = Math.floor(x / TILE), ty = Math.floor(y / TILE);
- const biome = getBiomeAt(tx, ty, worldSeed);
-
- // Swimming grants bonus agility XP
- if (biome === 'water') {
- if (!swimmingPlayers.has(userId)) {
- swimmingPlayers.add(userId);
- }
- // 2 XP per movement tick in water
- addXp(userId, 'agility', 2);
- } else {
- swimmingPlayers.delete(userId);
- }
-
- // Distance-based agility XP: 1 XP per ~10 tiles traveled
- const prev = playerDistAccum.get(userId) || { x, y, accum: 0 };
- const dist = Math.sqrt((x - prev.x) ** 2 + (y - prev.y) ** 2) / TILE;
- prev.accum += dist;
- prev.x = x; prev.y = y;
- if (prev.accum >= 10) {
- const chunks = Math.floor(prev.accum / 10);
- addXp(userId, 'agility', chunks);
- prev.accum -= chunks * 10;
- // Update total tiles traveled for achievement tracking
- db.run('UPDATE game_players SET total_tiles_traveled = total_tiles_traveled + ? WHERE user_id = ?', [chunks * 10, userId]);
- incrementDailyQuestStat(userId, 'tiles_traveled', chunks * 10);
- const ttP = getPlayer(userId);
- if (ttP) {
- checkAchievement(userId, 'explorer_1k', ttP.total_tiles_traveled >= 1000);
- checkAchievement(userId, 'explorer_10k', ttP.total_tiles_traveled >= 10000);
- }
- }
- playerDistAccum.set(userId, prev);
-}
-
-/**
- * Get agility stats for a player (for UI/API).
- */
-function getAgilityStats(userId) {
- const p = getPlayer(userId);
- const bonuses = getAgilityBonuses(p.agility_level);
- return {
- level: p.agility_level,
- xp: p.agility_xp,
- nextLevelXp: p.agility_level * p.agility_level * 25,
- bonuses,
- };
-}
-
-module.exports = {
- initGameDb, getWorldSeed: () => worldSeed,
- ITEMS, RECIPES, STRUCTURES, RARITY_COLORS, BIOME_COLORS,
- MAP_W, MAP_H, TILE, OUTPOST_X, OUTPOST_Y, OUTPOST_RADIUS,
- WEAPON_STATS, ARMOR_STATS, EQUIP_SKILL_MAP, FOOD_EFFECTS,
- xpToLevel, levelToXp,
- // Player
- getPlayer, updatePlayerPosition, addXp, regenStamina,
- // Inventory
- getInventory, getItemCount, addItem, removeItem, hasItem,
- // Bank
- getBank, bankDeposit, bankWithdraw, isNearBankNPC,
- // Shop
- getShopItems, buyItem, sellItem, buyWithCoins, getCoinShopItems,
- // NPCs
- isNearNPC, isNearAnyShopNPC, getNearestNPC, getShopItemsForNPC, getSellableItemsForNPC,
- // Gathering
- gather, getDepletedNodes, isNodeDepleted, depleteNode,
- // Fishing
- fish, useSonar, getFishCollection,
- // Building
- placeStructure, destroyStructure, getStructuresInArea, getAllStructures,
- storageDeposit, storageWithdraw,
- // Farming
- getFarmPlots, plant, water, harvest,
- // Crafting
- getUnlockedRecipes, craft, rollRecipeUnlock, isNearStation,
- // Combat
- attackPlayer, handleDeath, getBattleStats,
- dodgeRoll, isInvulnerable, getWeaponInfo,
- enterDungeon, fightMonster,
- // Mobs
- spawnMobs, mobTick, attackMob, getMobStates, regenHealth,
- // Ground Items
- dropGroundItem, pickupGroundItem, getGroundItemStates, cleanupGroundItems,
- // Equipment
- equipItem, unequipItem, recalcCombatStats,
- // Cosmetics
- equipCosmetic, unequipCosmetic,
- // Effects
- useConsumable, getActiveEffects,
- // Food
- eatFood, getActiveFoodBuff, drainSprintStamina,
- // Agility & Stamina
- getAgilityBonuses, getAgilityStats, regenStaminaTick, trackMovement,
- // Leaderboards
- getLeaderboard,
- // Daily quests
- getDailyQuests, claimDailyQuest,
- // Multiplayer
- updateLivePlayer, removeLivePlayer, getLivePlayers,
- // Achievements
- getAchievements, flushPendingAchievements, ACHIEVEMENTS,
- // Weather
- tickWeather, getWeather,
- // Treasure Chests
- spawnChests, openChest, cleanupChests, getChestStates, CHEST_TIERS,
-};
deleted file mode 100644
deleted file mode 100644
@@ -1,998 +0,0 @@
@@ -1,998 +0,0 @@
-/**
- * OpenVibeGame — Item Catalog, Structures, World Generation, Shared Utilities
- * Rust-style open-world survival game
- */
-
-// ── Map Constants ────────────────────────────────────────────
-const MAP_W = 512;
-const MAP_H = 512;
-const TILE = 32;
-const OUTPOST_X = Math.floor(MAP_W / 2); // 256
-const OUTPOST_Y = Math.floor(MAP_H / 2);
-const OUTPOST_RADIUS = 14;
-
-// ── Procedural Villages (3 NPC settlements on separate islands) ──
-// Placed at offsets from center — each acts as a mini safe-zone with NPCs
-const VILLAGES = [
- { id: 'fishing_village', name: 'Fishing Village', cx: OUTPOST_X - 80, cy: OUTPOST_Y - 70, radius: 8 },
- { id: 'mining_camp', name: 'Mining Camp', cx: OUTPOST_X + 85, cy: OUTPOST_Y - 60, radius: 8 },
- { id: 'forest_hamlet', name: 'Forest Hamlet', cx: OUTPOST_X - 50, cy: OUTPOST_Y + 90, radius: 8 },
-];
-
-const RARITY_COLORS = {
- Junk: '#888888', Common: '#b0b0b0', Uncommon: '#4fc94f',
- Rare: '#3b82f6', Epic: '#a855f7', Legendary: '#f59e0b', Mythic: '#ef4444',
-};
-
-const BIOME_COLORS = {
- water: '#1e40af', sand: '#d4a053', grass: '#22c55e', forest: '#166534',
- desert: '#92400e', hills: '#78716c', mountain: '#44403c', snow: '#cbd5e1',
- outpost: '#92702e',
-};
-
-// ── Noise Functions (shared client/server) ───────────────────
-function hashNoise(x, y, seed) {
- let h = seed;
- h = Math.imul(h ^ (x * 374761393), 1103515245);
- h = Math.imul(h ^ (y * 668265263), 1103515245);
- h = (h ^ (h >> 13)) * 1103515245;
- return ((h ^ (h >> 16)) >>> 0) / 4294967296;
-}
-
-function smoothNoise(x, y, seed) {
- const ix = Math.floor(x), iy = Math.floor(y);
- const fx = x - ix, fy = y - iy;
- const sx = fx * fx * (3 - 2 * fx);
- const sy = fy * fy * (3 - 2 * fy);
- const n00 = hashNoise(ix, iy, seed), n10 = hashNoise(ix + 1, iy, seed);
- const n01 = hashNoise(ix, iy + 1, seed), n11 = hashNoise(ix + 1, iy + 1, seed);
- return (n00 + sx * (n10 - n00)) + sy * ((n01 + sx * (n11 - n01)) - (n00 + sx * (n10 - n00)));
-}
-
-function fbm(x, y, seed, octaves) {
- let v = 0, a = 1, f = 1, t = 0;
- for (let i = 0; i < octaves; i++) {
- v += smoothNoise(x * f, y * f, seed + i * 1000) * a;
- t += a; a *= 0.5; f *= 2;
- }
- return v / t;
-}
-
-function getBiome(elev, moist) {
- if (elev < 0.30) return 'water';
- if (elev < 0.35) return 'sand';
- if (elev > 0.82) return 'snow';
- if (elev > 0.68) return 'mountain';
- if (elev > 0.55) return 'hills';
- if (moist > 0.58) return 'forest';
- if (moist < 0.35) return 'desert';
- return 'grass';
-}
-
-// ── Road corridor segments (outpost ↔ each village) for land guarantee ──
-const ROAD_SEGMENTS = VILLAGES.map(v => ({
- x1: OUTPOST_X, y1: OUTPOST_Y, x2: v.cx, y2: v.cy
-}));
-
-// Minimum distance from point (px,py) to any road corridor segment
-function distToRoadCorridor(px, py) {
- let minD = Infinity;
- for (const seg of ROAD_SEGMENTS) {
- const dx = seg.x2 - seg.x1, dy = seg.y2 - seg.y1;
- const lenSq = dx * dx + dy * dy;
- if (lenSq === 0) { minD = Math.min(minD, Math.sqrt((px - seg.x1) ** 2 + (py - seg.y1) ** 2)); continue; }
- const t = Math.max(0, Math.min(1, ((px - seg.x1) * dx + (py - seg.y1) * dy) / lenSq));
- const projX = seg.x1 + t * dx, projY = seg.y1 + t * dy;
- const d = Math.sqrt((px - projX) ** 2 + (py - projY) ** 2);
- if (d < minD) minD = d;
- }
- return minD;
-}
-
-function getBiomeAt(tx, ty, seed) {
- // Main outpost override
- const dx = tx - OUTPOST_X, dy = ty - OUTPOST_Y;
- const dist = Math.sqrt(dx * dx + dy * dy);
- if (dist <= OUTPOST_RADIUS) return 'outpost';
- // Village overrides (3 procedural NPC villages)
- for (const v of VILLAGES) {
- const vdx = tx - v.cx, vdy = ty - v.cy;
- const vdist = Math.sqrt(vdx * vdx + vdy * vdy);
- if (vdist <= v.radius) return 'outpost';
- }
- // Water border (outer 3 tiles)
- if (tx < 3 || ty < 3 || tx >= MAP_W - 3 || ty >= MAP_H - 3) return 'water';
- let elev = fbm(tx / 40, ty / 40, seed, 6);
- const moist = fbm(tx / 35, ty / 35, seed + 5000, 4);
- // Guarantee land near outpost — boost elevation within 60 tiles
- if (dist < 60) {
- const t = (60 - dist) / (60 - OUTPOST_RADIUS);
- const minElev = 0.35 + t * 0.20;
- elev = Math.max(elev, minElev);
- }
- // Guarantee land near villages
- for (const v of VILLAGES) {
- const vdx = tx - v.cx, vdy = ty - v.cy;
- const vdist = Math.sqrt(vdx * vdx + vdy * vdy);
- if (vdist < 30) {
- const t = (30 - vdist) / (30 - v.radius);
- const minElev = 0.35 + t * 0.15;
- elev = Math.max(elev, minElev);
- }
- }
- // Guarantee land along inter-town road corridors (5-tile-wide strip)
- const roadDist = distToRoadCorridor(tx, ty);
- if (roadDist < 5) {
- const t = 1 - roadDist / 5;
- const minElev = 0.36 + t * 0.14; // center of road: 0.50, edge: 0.36
- elev = Math.max(elev, minElev);
- }
- return getBiome(elev, moist);
-}
-
-function getDifficultyTier(tx, ty) {
- const dx = tx - OUTPOST_X, dy = ty - OUTPOST_Y;
- const dist = Math.sqrt(dx * dx + dy * dy);
- // Also check distance to villages (villages lower local difficulty)
- let minVillageDist = Infinity;
- for (const v of VILLAGES) {
- const vd = Math.sqrt((tx - v.cx) ** 2 + (ty - v.cy) ** 2);
- if (vd < minVillageDist) minVillageDist = vd;
- }
- const effectiveDist = Math.min(dist, minVillageDist + 40);
- if (effectiveDist < 30) return 0;
- if (effectiveDist < 70) return 1;
- if (effectiveDist < 140) return 2;
- return 3;
-}
-
-// ── Pickaxe Tiers (determines what ores you can mine) ────────
-const PICK_TIERS = {
- pick_wood: { tier: 0, name: 'Wooden' }, // stone, copper
- pick_stone: { tier: 1, name: 'Stone' }, // + iron
- pick_iron: { tier: 2, name: 'Iron' }, // + gold
- pick_gold: { tier: 3, name: 'Gold' }, // + mithril, titanium
- pick_diamond: { tier: 4, name: 'Diamond' }, // + platinum, dragonite
-};
-
-// ── Axe Tiers (determines what trees you can chop) ──────────
-const AXE_TIERS = {
- axe_stone: { tier: 0, name: 'Stone' },
- axe_iron: { tier: 1, name: 'Iron' },
- axe_steel: { tier: 2, name: 'Steel' },
- axe_mythril: { tier: 3, name: 'Mythril' },
-};
-
-// ── Ore Node Types (affects what you mine) ───────────────────
-// Each rock tile gets an ore subtype based on biome + a second noise hash
-const ORE_NODE_TYPES = {
- stone: { emoji: '🪨', color: '#6b7280', minPickTier: -1, minLevel: 1, name: 'Stone' }, // fists OK
- tin: { emoji: '🔘', color: '#8a9a9a', minPickTier: 0, minLevel: 1, name: 'Tin' },
- copper: { emoji: '🟤', color: '#b87333', minPickTier: 0, minLevel: 1, name: 'Copper' },
- coal: { emoji: '⬛', color: '#374151', minPickTier: 0, minLevel: 5, name: 'Coal' },
- iron: { emoji: '⚙️', color: '#a8a8a8', minPickTier: 1, minLevel: 10, name: 'Iron' },
- gold: { emoji: '🥇', color: '#ffd700', minPickTier: 2, minLevel: 20, name: 'Gold' },
- mithril: { emoji: '💠', color: '#7dd3fc', minPickTier: 3, minLevel: 35, name: 'Mithril' },
- titanium: { emoji: '⬜', color: '#e2e8f0', minPickTier: 3, minLevel: 45, name: 'Titanium' },
- platinum: { emoji: '🪙', color: '#d4d4d8', minPickTier: 4, minLevel: 55, name: 'Platinum' },
- dragonite:{ emoji: '🐉', color: '#dc2626', minPickTier: 4, minLevel: 65, name: 'Dragonite' },
-};
-
-function getOreNodeType(tx, ty, seed, biome) {
- const h2 = hashNoise(tx, ty, seed + 77777); // second noise layer for ore type
- // Biome-specific ore distribution — tin & coal appear in starter zones
- if (biome === 'grass') {
- return h2 < 0.30 ? 'stone' : h2 < 0.50 ? 'tin' : h2 < 0.72 ? 'copper' : h2 < 0.88 ? 'coal' : 'iron';
- }
- if (biome === 'hills') {
- return h2 < 0.15 ? 'stone' : h2 < 0.28 ? 'tin' : h2 < 0.42 ? 'copper' : h2 < 0.55 ? 'coal' : h2 < 0.72 ? 'iron' : h2 < 0.88 ? 'gold' : 'mithril';
- }
- if (biome === 'mountain') {
- return h2 < 0.10 ? 'coal' : h2 < 0.25 ? 'iron' : h2 < 0.42 ? 'gold' : h2 < 0.58 ? 'mithril' : h2 < 0.74 ? 'titanium' : h2 < 0.90 ? 'platinum' : 'dragonite';
- }
- if (biome === 'snow') {
- return h2 < 0.15 ? 'coal' : h2 < 0.30 ? 'iron' : h2 < 0.50 ? 'gold' : h2 < 0.70 ? 'mithril' : h2 < 0.88 ? 'titanium' : 'platinum';
- }
- if (biome === 'desert') {
- return h2 < 0.25 ? 'stone' : h2 < 0.45 ? 'copper' : h2 < 0.60 ? 'coal' : h2 < 0.80 ? 'gold' : 'iron';
- }
- // Default (sand, forest fallback)
- return h2 < 0.35 ? 'stone' : h2 < 0.55 ? 'tin' : h2 < 0.78 ? 'copper' : 'coal';
-}
-
-function getResourceNodeAt(tx, ty, seed) {
- const biome = getBiomeAt(tx, ty, seed);
- if (biome === 'water' || biome === 'outpost') return null;
- const h = hashNoise(tx, ty, seed + 99999);
- // Forest: dense trees
- if (biome === 'forest' && h < 0.22) return { type: 'tree' };
- // Grass: scattered trees + occasional rocks
- if (biome === 'grass') {
- if (h < 0.08) return { type: 'tree' };
- if (h > 0.95) return { type: 'rock', ore: getOreNodeType(tx, ty, seed, biome) };
- }
- // Hills: rocks + rare trees
- if (biome === 'hills') {
- if (h < 0.12) return { type: 'rock', ore: getOreNodeType(tx, ty, seed, biome) };
- if (h > 0.93) return { type: 'tree' };
- }
- // Mountain: dense rocks
- if (biome === 'mountain' && h < 0.18) return { type: 'rock', ore: getOreNodeType(tx, ty, seed, biome) };
- // Sand: fish spots along the shore
- if (biome === 'sand') {
- if (h < 0.10) return { type: 'fish_spot' };
- if (h > 0.92) return { type: 'tree' };
- }
- // Snow: rocks + rare gems
- if (biome === 'snow' && h < 0.10) return { type: 'rock', ore: getOreNodeType(tx, ty, seed, biome) };
- // Desert: rocks + dead trees
- if (biome === 'desert') {
- if (h < 0.07) return { type: 'rock', ore: getOreNodeType(tx, ty, seed, biome) };
- if (h > 0.94) return { type: 'tree' };
- }
- return null;
-}
-
-function isInSafeZone(tx, ty) {
- const dx = tx - OUTPOST_X, dy = ty - OUTPOST_Y;
- if (Math.sqrt(dx * dx + dy * dy) <= OUTPOST_RADIUS) return true;
- for (const v of VILLAGES) {
- const vd = Math.sqrt((tx - v.cx) ** 2 + (ty - v.cy) ** 2);
- if (vd <= v.radius) return true;
- }
- return false;
-}
-
-// ── Items ────────────────────────────────────────────────────
-const ITEMS = {
- // Seeds
- seed_wheat: { name: 'Wheat Seeds', emoji: '🌾', desc: 'Basic crop. 30 min.', buyCost: 5, sellPrice: null, category: 'seeds', rarity: 'Common' },
- seed_carrot: { name: 'Carrot Seeds', emoji: '🥕', desc: '45 min.', buyCost: 12, sellPrice: null, category: 'seeds', rarity: 'Common' },
- seed_tomato: { name: 'Tomato Seeds', emoji: '🍅', desc: '60 min.', buyCost: 20, sellPrice: null, category: 'seeds', rarity: 'Uncommon' },
- seed_corn: { name: 'Corn Seeds', emoji: '🌽', desc: '90 min.', buyCost: 30, sellPrice: null, category: 'seeds', rarity: 'Uncommon' },
- seed_pumpkin: { name: 'Pumpkin Seeds', emoji: '🎃', desc: '2 hours.', buyCost: 50, sellPrice: null, category: 'seeds', rarity: 'Rare' },
- seed_golden: { name: 'Golden Seeds', emoji: '✨', desc: '3 hours. Legendary.', buyCost: 150, sellPrice: null, category: 'seeds', rarity: 'Legendary' },
- // Crops
- crop_wheat: { name: 'Wheat', emoji: '🌾', desc: 'Harvested wheat.', buyCost: null, sellPrice: 18, category: 'crops', rarity: 'Common' },
- crop_carrot: { name: 'Carrots', emoji: '🥕', desc: 'Fresh carrots.', buyCost: null, sellPrice: 35, category: 'crops', rarity: 'Common' },
- crop_tomato: { name: 'Tomatoes', emoji: '🍅', desc: 'Ripe tomatoes.', buyCost: null, sellPrice: 60, category: 'crops', rarity: 'Uncommon' },
- crop_corn: { name: 'Corn', emoji: '🌽', desc: 'Golden corn.', buyCost: null, sellPrice: 90, category: 'crops', rarity: 'Uncommon' },
- crop_pumpkin: { name: 'Pumpkin', emoji: '🎃', desc: 'Thicc pumpkin.', buyCost: null, sellPrice: 160, category: 'crops', rarity: 'Rare' },
- crop_golden: { name: 'Golden Fruit', emoji: '✨', desc: 'Mythical produce.', buyCost: null, sellPrice: 550, category: 'crops', rarity: 'Legendary' },
- compost: { name: 'Compost', emoji: '🫘', desc: '+50% yield.', buyCost: null, sellPrice: 15, category: 'tools', rarity: 'Common' },
- // Fishing tools
- fish_sonar: { name: 'Fish Sonar', emoji: '📡', desc: 'Detects fish in water. One use.', buyCost: null, sellPrice: 20, category: 'tools', rarity: 'Uncommon' },
- // Bait
- bait_worm: { name: 'Worms', emoji: '🪱', desc: 'Basic bait.', buyCost: 3, sellPrice: 1, category: 'bait', rarity: 'Common' },
- bait_cricket: { name: 'Crickets', emoji: '🦗', desc: 'Better bait.', buyCost: 10, sellPrice: 4, category: 'bait', rarity: 'Common' },
- bait_shrimp: { name: 'Shrimp', emoji: '🦐', desc: 'Rare fish bait.', buyCost: 25, sellPrice: 10, category: 'bait', rarity: 'Uncommon' },
- bait_golden: { name: 'Golden Lure', emoji: '🌟', desc: 'Epic fish bait.', buyCost: 75, sellPrice: 30, category: 'bait', rarity: 'Rare' },
- // Rods (levelReq = fishing level)
- rod_bamboo: { name: 'Bamboo Rod', emoji: '🎋', desc: 'Basic rod.', buyCost: 15, sellPrice: 5, category: 'rods', rarity: 'Common', levelReq: 1 },
- rod_fiberglass: { name: 'Fiberglass Rod', emoji: '🥢', desc: 'Light rod.', buyCost: 50, sellPrice: 18, category: 'rods', rarity: 'Common', levelReq: 8 },
- rod_carbon: { name: 'Carbon Rod', emoji: '⚫', desc: 'Strong rod.', buyCost: 150, sellPrice: 55, category: 'rods', rarity: 'Uncommon', levelReq: 20 },
- rod_titanium: { name: 'Titanium Rod', emoji: '🔩', desc: 'Premium rod.', buyCost: 400, sellPrice: 150, category: 'rods', rarity: 'Rare', levelReq: 35 },
- rod_mythril: { name: 'Mythril Rod', emoji: '💜', desc: 'Top-tier rod.', buyCost: 1000, sellPrice: 375, category: 'rods', rarity: 'Epic', levelReq: 50 },
- // Fish — Minnow Family
- fish_minnow: { name: 'Minnow', emoji: '🐟', desc: 'Tiny but plentiful.', buyCost: null, sellPrice: 4, category: 'fish', rarity: 'Common' },
- fish_sunfish: { name: 'Sunfish', emoji: '🌻', desc: 'Warm water lover.', buyCost: null, sellPrice: 6, category: 'fish', rarity: 'Common' },
- // Fish — Bass Family
- fish_bass: { name: 'Bass', emoji: '🐟', desc: 'Solid catch.', buyCost: null, sellPrice: 10, category: 'fish', rarity: 'Common' },
- fish_perch: { name: 'Perch', emoji: '🐟', desc: 'Yellow-striped.', buyCost: null, sellPrice: 8, category: 'fish', rarity: 'Common' },
- // Fish — Trout Family
- fish_trout: { name: 'Trout', emoji: '🐠', desc: 'Speckled beauty.', buyCost: null, sellPrice: 12, category: 'fish', rarity: 'Common' },
- fish_clownfish: { name: 'Clownfish', emoji: '🤡', desc: 'Found Nemo.', buyCost: null, sellPrice: 14, category: 'fish', rarity: 'Common' },
- // Fish — Catfish Family (Tier 1)
- fish_catfish: { name: 'Catfish', emoji: '🐡', desc: 'Whiskered bottom-feeder.', buyCost: null, sellPrice: 18, category: 'fish', rarity: 'Uncommon' },
- fish_carp: { name: 'Carp', emoji: '🐟', desc: 'Golden scales.', buyCost: null, sellPrice: 16, category: 'fish', rarity: 'Common' },
- fish_pike: { name: 'Pike', emoji: '🐟', desc: 'Lurking predator.', buyCost: null, sellPrice: 22, category: 'fish', rarity: 'Uncommon' },
- // Fish — Salmon Family (Tier 1)
- fish_salmon: { name: 'Salmon', emoji: '🐠', desc: 'Fresh upstream salmon.', buyCost: null, sellPrice: 20, category: 'fish', rarity: 'Uncommon' },
- fish_rainbow_trout:{ name: 'Rainbow Trout', emoji: '🌈', desc: 'Shimmering colors.', buyCost: null, sellPrice: 24, category: 'fish', rarity: 'Uncommon' },
- fish_king_salmon: { name: 'King Salmon', emoji: '👑', desc: 'The royal catch.', buyCost: null, sellPrice: 30, category: 'fish', rarity: 'Rare' },
- // Fish — Puffer Family (Tier 1)
- fish_pufferfish: { name: 'Pufferfish', emoji: '🐡', desc: 'Don\'t poke it.', buyCost: null, sellPrice: 18, category: 'fish', rarity: 'Uncommon' },
- fish_jellyfish: { name: 'Jellyfish', emoji: '🪼', desc: 'Translucent drifter.', buyCost: null, sellPrice: 20, category: 'fish', rarity: 'Uncommon' },
- // Fish — Eel Family (Tier 2)
- fish_electric_eel: { name: 'Electric Eel', emoji: '⚡', desc: 'Shocking catch.', buyCost: null, sellPrice: 35, category: 'fish', rarity: 'Rare' },
- fish_stingray: { name: 'Stingray', emoji: '🦟', desc: 'Flat and dangerous.', buyCost: null, sellPrice: 28, category: 'fish', rarity: 'Uncommon' },
- // Fish — Swordfish Family (Tier 2)
- fish_swordfish: { name: 'Swordfish', emoji: '🗡️', desc: 'Razor-nosed speedster.', buyCost: null, sellPrice: 40, category: 'fish', rarity: 'Rare' },
- fish_marlin: { name: 'Marlin', emoji: '🏹', desc: 'Deep sea game fish.', buyCost: null, sellPrice: 45, category: 'fish', rarity: 'Rare' },
- fish_barracuda: { name: 'Barracuda', emoji: '🦷', desc: 'Silver torpedo.', buyCost: null, sellPrice: 38, category: 'fish', rarity: 'Rare' },
- // Fish — Deep Sea Family (Tier 2)
- fish_anglerfish: { name: 'Anglerfish', emoji: '🏮', desc: 'Lures with light.', buyCost: null, sellPrice: 42, category: 'fish', rarity: 'Rare' },
- fish_octopus: { name: 'Octopus', emoji: '🐙', desc: 'Eight-armed escape artist.',buyCost: null, sellPrice: 48, category: 'fish', rarity: 'Rare' },
- // Fish — Shark Family (Tier 3)
- fish_shark: { name: 'Shark', emoji: '🦈', desc: 'Jaws.', buyCost: null, sellPrice: 60, category: 'fish', rarity: 'Epic' },
- fish_hammerhead: { name: 'Hammerhead', emoji: '🔨', desc: 'Weird but fearsome.', buyCost: null, sellPrice: 75, category: 'fish', rarity: 'Epic' },
- fish_ghost_fish: { name: 'Ghost Fish', emoji: '👻', desc: 'Transparent and eerie.', buyCost: null, sellPrice: 70, category: 'fish', rarity: 'Epic' },
- // Fish — Whale Family (Tier 3)
- fish_whale: { name: 'Whale', emoji: '🐳', desc: 'Absolute unit.', buyCost: null, sellPrice: 90, category: 'fish', rarity: 'Epic' },
- fish_narwhal: { name: 'Narwhal', emoji: '🦄', desc: 'Unicorn of the sea.', buyCost: null, sellPrice: 95, category: 'fish', rarity: 'Epic' },
- // Fish — Legendary Family (Tier 4)
- fish_kraken: { name: 'Kraken Tentacle', emoji: '🦑', desc: 'Proof of bravery.', buyCost: null, sellPrice: 200, category: 'fish', rarity: 'Legendary' },
- fish_golden: { name: 'Golden Fish', emoji: '🏆', desc: 'The legendary one!', buyCost: null, sellPrice: 300, category: 'fish', rarity: 'Legendary' },
- fish_leviathan: { name: 'Leviathan Scale', emoji: '🐲', desc: 'From the abyss itself.', buyCost: null, sellPrice: 500, category: 'fish', rarity: 'Legendary' },
- // Junk (fishing)
- fish_boot: { name: 'Old Boot', emoji: '👢', desc: 'Not worth it.', buyCost: null, sellPrice: 1, category: 'junk', rarity: 'Junk' },
- fish_tin_can: { name: 'Tin Can', emoji: '🥫', desc: 'Soggy garbage.', buyCost: null, sellPrice: 1, category: 'junk', rarity: 'Junk' },
- // Raw materials
- raw_stone: { name: 'Stone', emoji: '🪨', desc: 'Basic rock.', buyCost: null, sellPrice: 1, category: 'materials', rarity: 'Junk' },
- raw_flint: { name: 'Flint', emoji: '🔥', desc: 'Sharp stone.', buyCost: null, sellPrice: 2, category: 'materials', rarity: 'Common' },
- raw_stick: { name: 'Stick', emoji: '🌿', desc: 'A plain stick.', buyCost: null, sellPrice: 1, category: 'materials', rarity: 'Junk' },
- // Ores & Gems
- ore_gravel: { name: 'Gravel', emoji: '🪨', desc: 'Boring rocks.', buyCost: null, sellPrice: 3, category: 'ores', rarity: 'Junk' },
- ore_tin: { name: 'Tin Ore', emoji: '🔘', desc: 'Soft tin.', buyCost: null, sellPrice: 6, category: 'ores', rarity: 'Common' },
- ore_coal: { name: 'Coal', emoji: '⬛', desc: 'Fuel source.', buyCost: null, sellPrice: 8, category: 'ores', rarity: 'Common' },
- ore_copper: { name: 'Copper Ore', emoji: '🟤', desc: 'Soft metal.', buyCost: null, sellPrice: 10, category: 'ores', rarity: 'Common' },
- ore_iron: { name: 'Iron Ore', emoji: '⚙️', desc: 'Solid iron.', buyCost: null, sellPrice: 15, category: 'ores', rarity: 'Common' },
- ore_gold: { name: 'Gold Nugget', emoji: '🥇', desc: 'Shiny gold.', buyCost: null, sellPrice: 35, category: 'ores', rarity: 'Uncommon' },
- ore_mithril: { name: 'Mithril Ore', emoji: '💠', desc: 'Enchanted metal.', buyCost: null, sellPrice: 55, category: 'ores', rarity: 'Rare' },
- ore_titanium: { name: 'Titanium Ore', emoji: '⬜', desc: 'Incredibly strong.', buyCost: null, sellPrice: 70, category: 'ores', rarity: 'Rare' },
- ore_platinum: { name: 'Platinum Ore', emoji: '🪙', desc: 'Rarer than gold.', buyCost: null, sellPrice: 90, category: 'ores', rarity: 'Epic' },
- ore_dragonite: { name: 'Dragonite Ore', emoji: '🐉', desc: 'Mythically rare.', buyCost: null, sellPrice: 250, category: 'ores', rarity: 'Legendary' },
- gem_ruby: { name: 'Ruby', emoji: '🔴', desc: 'Red gemstone.', buyCost: null, sellPrice: 45, category: 'gems', rarity: 'Uncommon' },
- gem_emerald: { name: 'Emerald', emoji: '💚', desc: 'Green gem.', buyCost: null, sellPrice: 65, category: 'gems', rarity: 'Rare' },
- gem_diamond: { name: 'Diamond', emoji: '💎', desc: 'The hardest gem.', buyCost: null, sellPrice: 100, category: 'gems', rarity: 'Rare' },
- gem_star: { name: 'Star Fragment', emoji: '⭐', desc: 'Fallen star shard.', buyCost: null, sellPrice: 180, category: 'gems', rarity: 'Epic' },
- mine_void_stone:{ name: 'Void Stone', emoji: '🕳️', desc: 'Absorbs light.', buyCost: null, sellPrice: 500, category: 'gems', rarity: 'Legendary' },
- // Bars
- bar_bronze: { name: 'Bronze Bar', emoji: '🟫', desc: 'Tin + Copper alloy.', buyCost: null, sellPrice: 16, category: 'bars', rarity: 'Common' },
- bar_copper: { name: 'Copper Bar', emoji: '🟤', desc: 'Pure copper.', buyCost: null, sellPrice: 18, category: 'bars', rarity: 'Common' },
- bar_iron: { name: 'Iron Bar', emoji: '🔩', desc: 'Crafting material.', buyCost: null, sellPrice: 25, category: 'bars', rarity: 'Common' },
- bar_steel: { name: 'Steel Bar', emoji: '🔗', desc: 'Iron + Coal alloy.', buyCost: null, sellPrice: 40, category: 'bars', rarity: 'Uncommon' },
- bar_gold: { name: 'Gold Bar', emoji: '🟡', desc: 'Valuable bar.', buyCost: null, sellPrice: 60, category: 'bars', rarity: 'Uncommon' },
- bar_mithril: { name: 'Mithril Bar', emoji: '💠', desc: 'Light and strong.', buyCost: null, sellPrice: 100, category: 'bars', rarity: 'Rare' },
- bar_titanium: { name: 'Titanium Bar', emoji: '⬜', desc: 'Incredibly durable.', buyCost: null, sellPrice: 130, category: 'bars', rarity: 'Rare' },
- bar_platinum: { name: 'Platinum Bar', emoji: '🪙', desc: 'Premium metal.', buyCost: null, sellPrice: 160, category: 'bars', rarity: 'Epic' },
- bar_dragonite: { name: 'Dragonite Bar', emoji: '🐉', desc: 'The finest bar.', buyCost: null, sellPrice: 450, category: 'bars', rarity: 'Legendary' },
- // Pickaxes (levelReq = mining level)
- pick_wood: { name: 'Wooden Pickaxe', emoji: '🪵', desc: 'Flimsy pick. Mines copper.', buyCost: 10, sellPrice: null, category: 'pickaxes', rarity: 'Common', levelReq: 1 },
- pick_stone: { name: 'Stone Pickaxe', emoji: '⛏️', desc: 'Mines iron.', buyCost: null, sellPrice: 8, category: 'pickaxes', rarity: 'Common', levelReq: 5 },
- pick_iron: { name: 'Iron Pickaxe', emoji: '⛏️', desc: 'Mines gold.', buyCost: 50, sellPrice: null, category: 'pickaxes', rarity: 'Uncommon', levelReq: 15 },
- pick_gold: { name: 'Gold Pickaxe', emoji: '✨', desc: 'Mines mithril.', buyCost: 150, sellPrice: null, category: 'pickaxes', rarity: 'Rare', levelReq: 30 },
- pick_diamond: { name: 'Diamond Pickaxe', emoji: '💎', desc: 'Mines everything.', buyCost: 400, sellPrice: null, category: 'pickaxes', rarity: 'Epic', levelReq: 45 },
- // Axes (levelReq = woodcut level)
- axe_stone: { name: 'Stone Axe', emoji: '🪓', desc: 'Basic axe.', buyCost: 8, sellPrice: null, category: 'axes', rarity: 'Common', levelReq: 1 },
- axe_iron: { name: 'Iron Axe', emoji: '⛏️', desc: 'Cuts hardwood.', buyCost: 40, sellPrice: null, category: 'axes', rarity: 'Uncommon', levelReq: 10 },
- axe_steel: { name: 'Steel Axe', emoji: '🔧', desc: 'Ancient trees.', buyCost: 120, sellPrice: null, category: 'axes', rarity: 'Rare', levelReq: 25 },
- axe_mythril: { name: 'Mythril Axe', emoji: '💠', desc: 'Cuts anything.', buyCost: 350, sellPrice: null, category: 'axes', rarity: 'Epic', levelReq: 40 },
- // Wood
- wood_twig: { name: 'Twig', emoji: '🌿', desc: 'A sad twig.', buyCost: null, sellPrice: 2, category: 'wood', rarity: 'Junk' },
- wood_oak: { name: 'Oak Log', emoji: '🪵', desc: 'Standard oak.', buyCost: null, sellPrice: 8, category: 'wood', rarity: 'Common' },
- wood_maple: { name: 'Maple Log', emoji: '🍁', desc: 'Beautiful maple.', buyCost: null, sellPrice: 16, category: 'wood', rarity: 'Common' },
- wood_birch: { name: 'Birch Log', emoji: '🌳', desc: 'Smooth birch.', buyCost: null, sellPrice: 24, category: 'wood', rarity: 'Uncommon' },
- wood_mahogany: { name: 'Mahogany Log', emoji: '🟤', desc: 'Dark hardwood.', buyCost: null, sellPrice: 40, category: 'wood', rarity: 'Uncommon' },
- wood_yew: { name: 'Yew Log', emoji: '🌲', desc: 'Ancient yew.', buyCost: null, sellPrice: 65, category: 'wood', rarity: 'Uncommon' },
- wood_elder: { name: 'Elderwood Log', emoji: '🧙', desc: 'Magical wood.', buyCost: null, sellPrice: 110, category: 'wood', rarity: 'Rare' },
- wood_crystal: { name: 'Crystal Wood', emoji: '💎', desc: 'Incredibly rare.', buyCost: null, sellPrice: 150, category: 'wood', rarity: 'Epic' },
- wood_sap: { name: 'Tree Sap', emoji: '🍯', desc: 'Sticky but useful.', buyCost: null, sellPrice: 12, category: 'wood', rarity: 'Common' },
- wood_spirit: { name: 'Tree Spirit Shard', emoji: '👻', desc: 'From an angry spirit.', buyCost: null, sellPrice: 350, category: 'wood', rarity: 'Legendary' },
- // Crafted
- craft_plank: { name: 'Wooden Plank', emoji: '🪵', desc: 'Building material.', buyCost: null, sellPrice: 15, category: 'crafted', rarity: 'Common' },
- craft_charcoal: { name: 'Charcoal', emoji: '⬛', desc: 'Refined fuel.', buyCost: null, sellPrice: 20, category: 'crafted', rarity: 'Uncommon' },
- craft_arrow: { name: 'Quiver of Arrows', emoji: '🏹', desc: 'Deadly arrows.', buyCost: null, sellPrice: 30, category: 'crafted', rarity: 'Uncommon' },
- craft_shield: { name: 'Wooden Shield', emoji: '🛡️', desc: 'Basic defense.', buyCost: null, sellPrice: 40, category: 'crafted', rarity: 'Uncommon' },
- craft_totem: { name: 'Spirit Totem', emoji: '🗿', desc: 'Carved totem.', buyCost: null, sellPrice: 100, category: 'crafted', rarity: 'Rare' },
- craft_wand: { name: 'Crystal Wand', emoji: '🪄', desc: 'Magical focus.', buyCost: null, sellPrice: 180, category: 'crafted', rarity: 'Epic' },
- craft_crown: { name: 'Crown of Thorns', emoji: '👑', desc: 'Painful but regal.', buyCost: null, sellPrice: 250, category: 'crafted', rarity: 'Epic' },
- craft_golem: { name: 'Iron Golem Core', emoji: '🤖', desc: 'Metal beast heart.', buyCost: null, sellPrice: 300, category: 'crafted', rarity: 'Epic' },
- // Consumables
- craft_elixir: { name: 'Elixir of Fortune', emoji: '🧪', desc: '2x coins 10 min.', buyCost: null, sellPrice: 60, category: 'consumable', rarity: 'Uncommon' },
- craft_xp_potion: { name: 'XP Boost Potion', emoji: '⚗️', desc: '2x XP 10 min.', buyCost: null, sellPrice: 50, category: 'consumable', rarity: 'Uncommon' },
- craft_loot_magnet: { name: 'Loot Magnet', emoji: '🧲', desc: 'Better loot 5 actions.', buyCost: null, sellPrice: 80, category: 'consumable', rarity: 'Uncommon' },
- supp_stamina: { name: 'Stamina Pill', emoji: '⚡', desc: '+50 stamina.', buyCost: 20, sellPrice: 8, category: 'consumable', rarity: 'Common' },
- farm_plot: { name: 'Farm Plot Deed', emoji: '📜', desc: 'Extra farm plot.', buyCost: 200, sellPrice: null, category: 'tools', rarity: 'Epic' },
- fertilizer: { name: 'Fertilizer', emoji: '💩', desc: '2x crop yield.', buyCost: 40, sellPrice: null, category: 'tools', rarity: 'Uncommon' },
- // Weapons & Armor (levelReq = combat level)
- weapon_fist: { name: 'Fists', emoji: '👊', desc: '+0 ATK. Punch stuff!', buyCost: null, sellPrice: null, category: 'weapons', rarity: 'Junk', levelReq: 1, weaponSpeed: 1.0, weaponRange: 1.0 },
- weapon_rock: { name: 'Sharp Rock', emoji: '🪨', desc: '+1 ATK. Fast but short.', buyCost: null, sellPrice: 1, category: 'weapons', rarity: 'Junk', levelReq: 1, weaponSpeed: 1.2, weaponRange: 0.8 },
- weapon_stick: { name: 'Wooden Stick', emoji: '🏏', desc: '+2 ATK. Long reach.', buyCost: 5, sellPrice: 2, category: 'weapons', rarity: 'Common', levelReq: 1, weaponSpeed: 1.3, weaponRange: 1.2 },
- weapon_sword: { name: 'Iron Sword', emoji: '⚔️', desc: '+8 ATK. Balanced.', buyCost: 80, sellPrice: 30, category: 'weapons', rarity: 'Uncommon', levelReq: 8, weaponSpeed: 1.0, weaponRange: 1.2 },
- weapon_axe: { name: 'Battle Axe', emoji: '🪓', desc: '+15 ATK. Slow & brutal.', buyCost: 250, sellPrice: 90, category: 'weapons', rarity: 'Rare', levelReq: 18, weaponSpeed: 0.7, weaponRange: 1.0 },
- weapon_katana: { name: 'Shadow Katana', emoji: '🗡️', desc: '+25 ATK. Lightning fast.', buyCost: 600, sellPrice: 220, category: 'weapons', rarity: 'Epic', levelReq: 30, weaponSpeed: 1.4, weaponRange: 1.3 },
- weapon_legendary: { name: 'Dragonslayer', emoji: '🐲', desc: '+40 ATK. The ultimate.', buyCost: null, sellPrice: 800, category: 'weapons', rarity: 'Legendary', levelReq: 45, weaponSpeed: 1.1, weaponRange: 1.4 },
- armor_cloth: { name: 'Cloth Armor', emoji: '👕', desc: '+3 DEF.', buyCost: 10, sellPrice: 4, category: 'armor', rarity: 'Common', levelReq: 1 },
- armor_leather: { name: 'Leather Armor', emoji: '🦺', desc: '+8 DEF.', buyCost: 60, sellPrice: 22, category: 'armor', rarity: 'Uncommon', levelReq: 5 },
- armor_chain: { name: 'Chainmail', emoji: '⛓️', desc: '+15 DEF.', buyCost: 200, sellPrice: 75, category: 'armor', rarity: 'Rare', levelReq: 15 },
- armor_plate: { name: 'Plate Armor', emoji: '🛡️', desc: '+25 DEF.', buyCost: 500, sellPrice: 185, category: 'armor', rarity: 'Epic', levelReq: 30 },
- armor_dragonscale: { name: 'Dragonscale Armor', emoji: '🐉', desc: '+40 DEF.', buyCost: null, sellPrice: 700, category: 'armor', rarity: 'Legendary', levelReq: 45 },
- // Hats
- hat_basic_cap: { name: 'Basic Cap', emoji: '🧢', desc: 'Simple cap.', buyCost: 50, sellPrice: 20, category: 'hats', rarity: 'Common' },
- hat_cowboy: { name: 'Cowboy Hat', emoji: '🤠', desc: 'Yeehaw.', buyCost: null, sellPrice: 100, category: 'hats', rarity: 'Uncommon' },
- hat_wizard: { name: 'Wizard Hat', emoji: '🧙', desc: 'Magical.', buyCost: null, sellPrice: 200, category: 'hats', rarity: 'Rare' },
- hat_crown: { name: 'Royal Crown', emoji: '👑', desc: 'Royalty.', buyCost: null, sellPrice: 500, category: 'hats', rarity: 'Epic' },
- hat_halo: { name: 'Halo', emoji: '😇', desc: 'Angelic.', buyCost: null, sellPrice: 800, category: 'hats', rarity: 'Legendary' },
- hat_void_crown: { name: 'Void Crown', emoji: '🕳️', desc: 'Dungeon only.', buyCost: null, sellPrice: 1500,category: 'hats', rarity: 'Mythic' },
- // Name & Particle Effects
- fx_rainbow: { name: 'Rainbow Name', emoji: '🌈', desc: 'Rainbow cycling!', buyCost: 1500, sellPrice: null, category: 'name_effects', rarity: 'Rare', coinPrice: 500 },
- fx_fire: { name: 'Fire Name', emoji: '🔥', desc: 'Blazing flames!', buyCost: 2000, sellPrice: null, category: 'name_effects', rarity: 'Rare', coinPrice: 650 },
- fx_ice: { name: 'Ice Name', emoji: '❄️', desc: 'Frosty glow!', buyCost: 2000, sellPrice: null, category: 'name_effects', rarity: 'Rare', coinPrice: 650 },
- fx_golden: { name: 'Golden Name', emoji: '👑', desc: 'Golden shine!', buyCost: 3500, sellPrice: null, category: 'name_effects', rarity: 'Epic', coinPrice: 1200 },
- fx_neon: { name: 'Neon Name', emoji: '💡', desc: 'Neon pulse!', buyCost: 3500, sellPrice: null, category: 'name_effects', rarity: 'Epic', coinPrice: 1200 },
- fx_galaxy: { name: 'Galaxy Name', emoji: '🌌', desc: 'Cosmic swirl!', buyCost: 5000, sellPrice: null, category: 'name_effects', rarity: 'Epic', coinPrice: 1800 },
- fx_void: { name: 'Void Name', emoji: '🕳️', desc: 'Warps space!', buyCost: 15000, sellPrice: null, category: 'name_effects', rarity: 'Legendary', coinPrice: 5000 },
- fx_toxic: { name: 'Toxic Name', emoji: '☠️', desc: 'Toxic drip!', buyCost: 2000, sellPrice: null, category: 'name_effects', rarity: 'Rare', coinPrice: 650 },
- fx_blood: { name: 'Blood Name', emoji: '🩸', desc: 'Dripping blood!', buyCost: 5000, sellPrice: null, category: 'name_effects', rarity: 'Epic', coinPrice: 1800 },
- fx_shadow: { name: 'Shadow Name', emoji: '🌑', desc: 'Dark shadow!', buyCost: 5000, sellPrice: null, category: 'name_effects', rarity: 'Epic', coinPrice: 1800 },
- fx_glitch: { name: 'Glitch Name', emoji: '📟', desc: 'Digital glitch!', buyCost: 5000, sellPrice: null, category: 'name_effects', rarity: 'Epic', coinPrice: 1800 },
- fx_hologram: { name: 'Hologram Name', emoji: '🔮', desc: 'Holographic shimmer!', buyCost: 10000, sellPrice: null, category: 'name_effects', rarity: 'Legendary', coinPrice: 3500 },
- fx_divine: { name: 'Divine Name', emoji: '✝️', desc: 'Divine radiance!', buyCost: 15000, sellPrice: null, category: 'name_effects', rarity: 'Legendary', coinPrice: 5000 },
- px_sparkle: { name: 'Sparkle Particles', emoji: '✨', desc: 'Sparkles!', buyCost: 1000, sellPrice: null, category: 'particles', rarity: 'Rare', coinPrice: 350 },
- px_hearts: { name: 'Heart Particles', emoji: '💖', desc: 'Hearts!', buyCost: 1500, sellPrice: null, category: 'particles', rarity: 'Rare', coinPrice: 500 },
- px_flames: { name: 'Flame Particles', emoji: '🔥', desc: 'Embers!', buyCost: 3000, sellPrice: null, category: 'particles', rarity: 'Epic', coinPrice: 1000 },
- px_stars: { name: 'Star Particles', emoji: '⭐', desc: 'Stars orbit!', buyCost: 3500, sellPrice: null, category: 'particles', rarity: 'Epic', coinPrice: 1200 },
- px_void: { name: 'Void Particles', emoji: '🕳️', desc: 'Dark matter!', buyCost: 8000, sellPrice: null, category: 'particles', rarity: 'Legendary', coinPrice: 2800 },
- // Voices (TTS cosmetics — activate to unlock globally)
- voice_deep: { name: 'Deep Voice', emoji: '🎵', desc: 'Low & rumbly TTS.', buyCost: 800, sellPrice: null, category: 'voices', rarity: 'Uncommon', coinPrice: 300 },
- voice_chipmunk: { name: 'Chipmunk Voice', emoji: '🐿️', desc: 'Squeaky & fast TTS.', buyCost: 800, sellPrice: null, category: 'voices', rarity: 'Uncommon', coinPrice: 300 },
- voice_robot: { name: 'Robot Voice', emoji: '🤖', desc: 'Monotone machine TTS.', buyCost: 2000, sellPrice: null, category: 'voices', rarity: 'Rare', coinPrice: 700 },
- voice_whisper: { name: 'Whisper Voice', emoji: '🤫', desc: 'Quiet & eerie TTS.', buyCost: 2000, sellPrice: null, category: 'voices', rarity: 'Rare', coinPrice: 700 },
- voice_demon: { name: 'Demon Voice', emoji: '😈', desc: 'From the underworld TTS.',buyCost: 5000, sellPrice: null, category: 'voices', rarity: 'Epic', coinPrice: 1800 },
- // Legacy voices (ported from RS-Companion)
- voice_gary: { name: 'Gary', emoji: '🔊', desc: 'Standard voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Common' },
- voice_brenda: { name: 'Brenda', emoji: '👩', desc: 'Friendly female voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Common' },
- voice_chadbot: { name: 'ChadBot', emoji: '💪', desc: 'Deep bro voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Uncommon' },
- voice_karen: { name: 'Karen', emoji: '💅', desc: 'Manager-seeking voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Uncommon' },
- voice_crackhead: { name: 'CrackheadCarl', emoji: '💊', desc: 'Manic energy voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Rare' },
- voice_squeakmaster:{ name: 'SqueakMaster', emoji: '🐭', desc: 'Ultra-squeaky voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Uncommon' },
- voice_bigchungus: { name: 'BigChungus', emoji: '🐰', desc: 'Absolute unit voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Uncommon' },
- voice_tweaker: { name: 'Tweaker', emoji: '⚡', desc: 'Fast & nervous voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Uncommon' },
- voice_grandpa: { name: 'Grandpa', emoji: '👴', desc: 'Old & wise voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Uncommon' },
- voice_ghostgirl: { name: 'GhostGirl', emoji: '👻', desc: 'Eerie whisper voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Rare' },
- voice_robotoverlord:{ name: 'RobotOverlord', emoji: '🤖', desc: 'Machine overlord voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Rare' },
- voice_sassybitch: { name: 'SassyBitch', emoji: '💁', desc: 'Sassy attitude voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Rare' },
- voice_helium: { name: 'Helium', emoji: '🎈', desc: 'Squeaky helium voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Uncommon' },
- voice_britbong: { name: 'BritBong', emoji: '🇬🇧', desc: 'British accent voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Uncommon' },
- voice_yeehaw: { name: 'YeeHaw', emoji: '🤠', desc: 'Southern drawl voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Uncommon' },
- voice_nyc: { name: 'NYC', emoji: '🗽', desc: 'New York accent voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Uncommon' },
- voice_french: { name: 'French', emoji: '🇫🇷', desc: 'French accent voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Uncommon' },
- voice_chatterbox: { name: 'Chatterbox', emoji: '💬', desc: 'Achievement voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Epic' },
- voice_fisherman: { name: 'Fisherman', emoji: '🎣', desc: 'Achievement voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Epic' },
- voice_gc_smooth: { name: 'Smooth Operator', emoji: '🎤', desc: 'Google Cloud TTS.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Epic' },
- voice_gc_sally: { name: 'Silicon Sally', emoji: '🎤', desc: 'Google Cloud TTS.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Epic' },
- voice_gc_butler: { name: 'British Butler', emoji: '🎤', desc: 'Google Cloud TTS.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Epic' },
- voice_gc_london: { name: 'Lady London', emoji: '🎤', desc: 'Google Cloud TTS.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Epic' },
- voice_gc_mumbai: { name: 'Mumbai Mike', emoji: '🎤', desc: 'Google Cloud TTS.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Epic' },
- voice_gc_downunder:{ name: 'Down Under', emoji: '🎤', desc: 'Google Cloud TTS.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Epic' },
- voice_gc_sheila: { name: 'Sheila', emoji: '🎤', desc: 'Google Cloud TTS.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Epic' },
- voice_gc_studio_f: { name: 'Studio Female', emoji: '🎙️', desc: 'Google Studio voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Legendary' },
- voice_gc_studio_m: { name: 'Studio Male', emoji: '🎙️', desc: 'Google Studio voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Legendary' },
- voice_pl_joanna: { name: 'Joanna', emoji: '🎤', desc: 'Amazon Polly voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Epic' },
- voice_pl_matthew: { name: 'Matthew', emoji: '🎤', desc: 'Amazon Polly voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Epic' },
- voice_pl_amy: { name: 'Amy', emoji: '🎤', desc: 'Amazon Polly voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Epic' },
- voice_pl_brian: { name: 'Brian', emoji: '🎤', desc: 'Amazon Polly voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Epic' },
- voice_pl_olivia: { name: 'Olivia', emoji: '🎤', desc: 'Amazon Polly voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Epic' },
- voice_pl_danielle: { name: 'Danielle', emoji: '🎤', desc: 'Amazon Polly Long voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Legendary' },
- voice_pl_gregory: { name: 'Gregory', emoji: '🎤', desc: 'Amazon Polly Long voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Legendary' },
- voice_pl_greg_n: { name: 'Gregory Neural', emoji: '🧠', desc: 'Amazon Polly Neural voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Legendary' },
- voice_pl_ruth: { name: 'Ruth', emoji: '🎤', desc: 'Amazon Polly Long voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Legendary' },
- voice_pl_arthur: { name: 'Arthur Neural', emoji: '🧠', desc: 'Amazon Polly Neural voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Mythic' },
- voice_dwarf_lord: { name: 'Dwarf Lord', emoji: '⛏️', desc: 'Skill mastery voice.', buyCost: null, sellPrice: null, category: 'voices', rarity: 'Mythic' },
- // Health
- potion_health: { name: 'Health Potion', emoji: '❤️', desc: 'Restores 30 HP.', buyCost: 15, sellPrice: 6, category: 'consumable', rarity: 'Common' },
- potion_health_big: { name: 'Greater Health Pot', emoji: '💖', desc: 'Restores 75 HP.', buyCost: 45, sellPrice: 18, category: 'consumable', rarity: 'Uncommon' },
- potion_stamina: { name: 'Stamina Potion', emoji: '⚡', desc: 'Restores 30 STA.', buyCost: 12, sellPrice: 5, category: 'consumable', rarity: 'Common' },
- food_campfire_meal:{ name: 'Burn Barrel Meal', emoji: '🍖', desc: 'HP over time.', buyCost: 25, sellPrice: 10, category: 'consumable', rarity: 'Uncommon' },
- // Cooked Food (grants buffs — cook at the burn barrel or craft)
- food_cooked_fish: { name: 'Cooked Fish', emoji: '🍽️', desc: '+20 HP instantly.', buyCost: null, sellPrice: 8, category: 'food', rarity: 'Common' },
- food_fish_stew: { name: 'Fish Stew', emoji: '🍲', desc: '+40 HP, +5 ATK 3 min.', buyCost: null, sellPrice: 25, category: 'food', rarity: 'Uncommon' },
- food_grilled_meat: { name: 'Grilled Meat', emoji: '🥩', desc: '+30 HP instantly.', buyCost: null, sellPrice: 15, category: 'food', rarity: 'Common' },
- food_veggie_soup: { name: 'Veggie Soup', emoji: '🥣', desc: '+25 HP, +5 DEF 3 min.', buyCost: null, sellPrice: 20, category: 'food', rarity: 'Uncommon' },
- food_golden_feast: { name: 'Golden Feast', emoji: '🍽️', desc: '+75 HP, +10 ATK/DEF 5 min.', buyCost: null, sellPrice: 100, category: 'food', rarity: 'Epic' },
- food_trail_mix: { name: 'Trail Mix', emoji: '🥜', desc: '+30 STA, free sprint 2 min.', buyCost: null, sellPrice: 12, category: 'food', rarity: 'Common' },
- food_pumpkin_pie: { name: 'Pumpkin Pie', emoji: '🥧', desc: '+50 HP, +2x XP 2 min.', buyCost: null, sellPrice: 50, category: 'food', rarity: 'Rare' },
- food_energy_bar: { name: 'Energy Bar', emoji: '🍫', desc: '+50 STA, +20 max STA 5 min.', buyCost: null, sellPrice: 30, category: 'food', rarity: 'Uncommon' },
- // Loot drops
- loot_lint: { name: 'Pocket Lint', emoji: '🧶', desc: 'Always there.', buyCost: null, sellPrice: 1, category: 'junk', rarity: 'Junk' },
- loot_coin_pouch: { name: 'Coin Pouch', emoji: '👛', desc: 'Some coins.', buyCost: null, sellPrice: 15, category: 'loot', rarity: 'Common' },
- loot_feather: { name: 'Phoenix Feather', emoji: '🪶', desc: 'Warm touch.', buyCost: null, sellPrice: 20, category: 'loot', rarity: 'Uncommon' },
- loot_crown: { name: 'Tiny Crown', emoji: '👑', desc: 'Hamster king.', buyCost: null, sellPrice: 60, category: 'loot', rarity: 'Rare' },
- loot_egg: { name: 'Dragon Egg', emoji: '🥚', desc: 'Is it moving??', buyCost: null, sellPrice: 200, category: 'loot', rarity: 'Epic' },
- loot_star: { name: 'Fallen Star', emoji: '🌟', desc: 'Piece of cosmos.', buyCost: null, sellPrice: 500, category: 'loot', rarity: 'Legendary' },
- loot_void_heart: { name: 'Void Heart', emoji: '🖤', desc: 'Dying universe.', buyCost: null, sellPrice: 1000, category: 'loot', rarity: 'Legendary' },
- // Dungeon keys
- dungeon_key: { name: 'Dungeon Key', emoji: '🗝️', desc: 'Opens dungeon.', buyCost: 100, sellPrice: 40, category: 'tools', rarity: 'Rare' },
-};
-
-// ── Structures (buildable in the world) ──────────────────────
-const STRUCTURES = {
- wall_wood: { name: 'Wood Wall', emoji: '🟫', hp: 200, cost: { craft_plank: 3 }, category: 'wall' },
- wall_stone: { name: 'Stone Wall', emoji: '🧱', hp: 500, cost: { bar_iron: 2, ore_gravel: 5 }, category: 'wall' },
- door_wood: { name: 'Wood Door', emoji: '🚪', hp: 150, cost: { craft_plank: 2, bar_iron: 1 }, category: 'door' },
- floor_wood: { name: 'Wood Floor', emoji: '📋', hp: 150, cost: { craft_plank: 2 }, category: 'floor' },
- workbench: { name: 'Workbench', emoji: '🔨', hp: 200, cost: { craft_plank: 5, bar_iron: 1 }, category: 'station' },
- furnace: { name: 'Furnace', emoji: '🔥', hp: 250, cost: { ore_gravel: 10, bar_iron: 2 }, category: 'station' },
- storage_box: { name: 'Storage Box', emoji: '📦', hp: 100, cost: { craft_plank: 3 }, category: 'storage' },
- sleeping_bag: { name: 'Sleeping Bag', emoji: '🛏️', hp: 50, cost: { crop_wheat: 5 }, category: 'spawn' },
- campfire: { name: 'Burn Barrel', emoji: '🛢️', hp: 50, cost: { wood_oak: 5 }, category: 'comfort' },
- tool_cupboard: { name: 'Tool Cupboard', emoji: '🧰', hp: 300, cost: { craft_plank: 5, bar_iron: 2 }, category: 'auth' },
-};
-
-// ── Rod Tiers (Toontown-style weight limits) ─────────────────
-const ROD_TIERS = {
- rod_bamboo: { tier: 0, maxWeight: 4, staminaCost: 3, name: 'Bamboo' },
- rod_fiberglass: { tier: 1, maxWeight: 8, staminaCost: 5, name: 'Fiberglass' },
- rod_carbon: { tier: 2, maxWeight: 12, staminaCost: 7, name: 'Carbon' },
- rod_titanium: { tier: 3, maxWeight: 16, staminaCost: 9, name: 'Titanium' },
- rod_mythril: { tier: 4, maxWeight: 20, staminaCost: 12, name: 'Mythril' },
-};
-
-// ── Fish Species Catalog (Toontown-inspired) ─────────────────
-// Each species: family, weight range (lbs), min rod tier, which zones, rarity weight for loot tables
-const FISH_SPECIES = {
- // — Minnow Family (Tier 0) —
- fish_minnow: { family: 'Minnow', minW: 0.5, maxW: 2, rodTier: 0, zones: ['shallow','river'], rarityWeight: 30 },
- fish_sunfish: { family: 'Minnow', minW: 1, maxW: 3, rodTier: 0, zones: ['shallow','river'], rarityWeight: 25 },
- // — Bass Family (Tier 0) —
- fish_bass: { family: 'Bass', minW: 1, maxW: 4, rodTier: 0, zones: ['shallow','river','deep'], rarityWeight: 28 },
- fish_perch: { family: 'Bass', minW: 1, maxW: 3, rodTier: 0, zones: ['shallow','river'], rarityWeight: 26 },
- // — Trout Family (Tier 0) —
- fish_trout: { family: 'Trout', minW: 1, maxW: 4, rodTier: 0, zones: ['shallow','river','arctic'],rarityWeight: 24 },
- fish_clownfish: { family: 'Trout', minW: 0.5, maxW: 2, rodTier: 0, zones: ['shallow'], rarityWeight: 20 },
- // — Catfish Family (Tier 1) —
- fish_catfish: { family: 'Catfish', minW: 3, maxW: 7, rodTier: 1, zones: ['river','deep'], rarityWeight: 18 },
- fish_carp: { family: 'Catfish', minW: 3, maxW: 6, rodTier: 1, zones: ['river'], rarityWeight: 20 },
- fish_pike: { family: 'Catfish', minW: 4, maxW: 8, rodTier: 1, zones: ['river','deep'], rarityWeight: 14 },
- // — Salmon Family (Tier 1) —
- fish_salmon: { family: 'Salmon', minW: 3, maxW: 7, rodTier: 1, zones: ['river','deep','arctic'], rarityWeight: 18 },
- fish_rainbow_trout:{ family: 'Salmon', minW: 4, maxW: 8, rodTier: 1, zones: ['river','arctic'], rarityWeight: 14 },
- fish_king_salmon: { family: 'Salmon', minW: 5, maxW: 8, rodTier: 1, zones: ['arctic'], rarityWeight: 8 },
- // — Puffer Family (Tier 1) —
- fish_pufferfish: { family: 'Puffer', minW: 2, maxW: 5, rodTier: 1, zones: ['shallow','deep'], rarityWeight: 16 },
- fish_jellyfish: { family: 'Puffer', minW: 2, maxW: 6, rodTier: 1, zones: ['deep'], rarityWeight: 14 },
- // — Eel Family (Tier 2) —
- fish_electric_eel: { family: 'Eel', minW: 5, maxW: 10, rodTier: 2, zones: ['river','deep'], rarityWeight: 10 },
- fish_stingray: { family: 'Eel', minW: 5, maxW: 9, rodTier: 2, zones: ['shallow','deep'], rarityWeight: 12 },
- // — Swordfish Family (Tier 2) —
- fish_swordfish: { family: 'Swordfish', minW: 6, maxW: 11, rodTier: 2, zones: ['deep'], rarityWeight: 10 },
- fish_marlin: { family: 'Swordfish', minW: 7, maxW: 12, rodTier: 2, zones: ['deep'], rarityWeight: 8 },
- fish_barracuda: { family: 'Swordfish', minW: 6, maxW: 10, rodTier: 2, zones: ['deep','arctic'], rarityWeight: 10 },
- // — Deep Sea Family (Tier 2) —
- fish_anglerfish: { family: 'DeepSea', minW: 5, maxW: 10, rodTier: 2, zones: ['deep'], rarityWeight: 9 },
- fish_octopus: { family: 'DeepSea', minW: 6, maxW: 12, rodTier: 2, zones: ['deep'], rarityWeight: 8 },
- // — Shark Family (Tier 3) —
- fish_shark: { family: 'Shark', minW: 10, maxW: 15, rodTier: 3, zones: ['deep'], rarityWeight: 5 },
- fish_hammerhead: { family: 'Shark', minW: 12, maxW: 16, rodTier: 3, zones: ['deep'], rarityWeight: 4 },
- fish_ghost_fish: { family: 'Shark', minW: 10, maxW: 14, rodTier: 3, zones: ['arctic'], rarityWeight: 5 },
- // — Whale Family (Tier 3) —
- fish_whale: { family: 'Whale', minW: 12, maxW: 16, rodTier: 3, zones: ['deep','arctic'], rarityWeight: 4 },
- fish_narwhal: { family: 'Whale', minW: 13, maxW: 16, rodTier: 3, zones: ['arctic'], rarityWeight: 3 },
- // — Legendary Family (Tier 4) —
- fish_kraken: { family: 'Legendary', minW: 15, maxW: 19, rodTier: 4, zones: ['deep'], rarityWeight: 2 },
- fish_golden: { family: 'Legendary', minW: 16, maxW: 20, rodTier: 4, zones: ['arctic'], rarityWeight: 2 },
- fish_leviathan: { family: 'Legendary', minW: 18, maxW: 20, rodTier: 4, zones: ['deep'], rarityWeight: 1 },
-};
-
-// Junk items in fishing (always available regardless of rod)
-const FISH_JUNK = [
- { id: 'fish_boot', weight: 10 },
- { id: 'fish_tin_can', weight: 8 },
-];
-
-// Build zone loot tables dynamically from FISH_SPECIES + rod tier
-function buildFishTable(zone, rodTier) {
- const table = [];
- for (const [fishId, spec] of Object.entries(FISH_SPECIES)) {
- if (spec.rodTier > rodTier) continue;
- if (!spec.zones.includes(zone)) continue;
- table.push({ id: fishId, weight: spec.rarityWeight });
- }
- // Add junk (reduced by rod tier)
- const junkMult = Math.max(0.2, 1 - rodTier * 0.18);
- for (const junk of FISH_JUNK) {
- table.push({ id: junk.id, weight: Math.max(1, Math.round(junk.weight * junkMult)) });
- }
- return table;
-}
-
-// ── Loot Tables by Difficulty Tier ───────────────────────────
-const WOOD_TABLES = [
- // Tier 0: easy (near outpost)
- [{ id: 'wood_twig', weight: 20 }, { id: 'wood_oak', weight: 45 }, { id: 'wood_maple', weight: 25 }, { id: 'wood_sap', weight: 10 }],
- // Tier 1: medium
- [{ id: 'wood_oak', weight: 15 }, { id: 'wood_maple', weight: 25 }, { id: 'wood_birch', weight: 25 }, { id: 'wood_mahogany', weight: 20 }, { id: 'wood_sap', weight: 10 }, { id: 'wood_yew', weight: 5 }],
- // Tier 2: hard
- [{ id: 'wood_mahogany', weight: 15 }, { id: 'wood_yew', weight: 25 }, { id: 'wood_elder', weight: 25 }, { id: 'wood_sap', weight: 10 }, { id: 'wood_crystal', weight: 10 }, { id: 'loot_crown', weight: 3 }],
- // Tier 3: brutal (map edges)
- [{ id: 'wood_elder', weight: 25 }, { id: 'wood_crystal', weight: 25 }, { id: 'wood_spirit', weight: 8 }, { id: 'wood_yew', weight: 15 }, { id: 'loot_egg', weight: 2 }, { id: 'loot_star', weight: 1 }],
-];
-
-const MINE_TABLES = [
- [{ id: 'ore_gravel', weight: 30 }, { id: 'ore_coal', weight: 30 }, { id: 'ore_copper', weight: 20 }, { id: 'ore_iron', weight: 15 }, { id: 'gem_ruby', weight: 4 }, { id: 'loot_coin_pouch', weight: 1 }],
- [{ id: 'ore_coal', weight: 15 }, { id: 'ore_iron', weight: 25 }, { id: 'ore_gold', weight: 20 }, { id: 'gem_ruby', weight: 10 }, { id: 'gem_emerald', weight: 8 }, { id: 'ore_mithril', weight: 5 }, { id: 'gem_diamond', weight: 3 }],
- [{ id: 'ore_gold', weight: 15 }, { id: 'ore_mithril', weight: 20 }, { id: 'ore_titanium', weight: 15 }, { id: 'gem_emerald', weight: 12 }, { id: 'gem_diamond', weight: 10 }, { id: 'ore_platinum', weight: 8 }, { id: 'gem_star', weight: 5 }],
- [{ id: 'ore_titanium', weight: 15 }, { id: 'ore_platinum', weight: 20 }, { id: 'gem_diamond', weight: 15 }, { id: 'gem_star', weight: 12 }, { id: 'ore_dragonite', weight: 10 }, { id: 'mine_void_stone', weight: 5 }, { id: 'loot_void_heart', weight: 1 }],
-];
-
-const FISH_TABLES = [
- // Legacy tier tables — rebuilt from FISH_SPECIES for gather() compat
- buildFishTable('shallow', 0),
- buildFishTable('river', 1),
- buildFishTable('deep', 2),
- buildFishTable('deep', 4),
-];
-
-// Water zone fish tables — now dynamically built per rod tier at catch time
-// These static tables are kept as the default (tier-0 rod) for backward compat
-const WATER_ZONE_TABLES = {
- shallow: buildFishTable('shallow', 0),
- river: buildFishTable('river', 0),
- deep: buildFishTable('deep', 0),
- arctic: buildFishTable('arctic', 0),
-};
-
-function getWaterZone(tx, ty, seed) {
- // Check neighboring tiles to determine water zone type
- let hasSnow = false, hasSand = false, hasLand = false, waterCount = 0;
- for (let dx = -2; dx <= 2; dx++) {
- for (let dy = -2; dy <= 2; dy++) {
- if (dx === 0 && dy === 0) continue;
- const b = getBiomeAt(tx + dx, ty + dy, seed);
- if (b === 'snow') hasSnow = true;
- if (b === 'sand') hasSand = true;
- if (b !== 'water') hasLand = true;
- else waterCount++;
- }
- }
- if (hasSnow) return 'arctic';
- if (hasSand && hasLand) return 'shallow';
- if (hasLand && waterCount <= 16) return 'river';
- return 'deep';
-}
-
-// ── Crops ────────────────────────────────────────────────────
-const CROPS = {
- seed_wheat: { growTime: 30 * 60000, output: 'crop_wheat', yield: [1, 3], mutation: null },
- seed_carrot: { growTime: 45 * 60000, output: 'crop_carrot', yield: [1, 3], mutation: null },
- seed_tomato: { growTime: 60 * 60000, output: 'crop_tomato', yield: [1, 2], mutation: null },
- seed_corn: { growTime: 90 * 60000, output: 'crop_corn', yield: [1, 2], mutation: null },
- seed_pumpkin: { growTime: 120 * 60000, output: 'crop_pumpkin', yield: [1, 2], mutation: null },
- seed_golden: { growTime: 180 * 60000, output: 'crop_golden', yield: [1, 1], mutation: null },
-};
-
-// ── Monsters ─────────────────────────────────────────────────
-const MONSTERS = [
- { name: 'Rat', emoji: '🐀', hp: 20, atk: 3, def: 1, xp: 10, gold: 5, loot: [{ id: 'loot_lint', w: 80 }, { id: 'loot_coin_pouch', w: 20 }] },
- { name: 'Skeleton', emoji: '💀', hp: 40, atk: 8, def: 3, xp: 25, gold: 12, loot: [{ id: 'loot_coin_pouch', w: 50 }, { id: 'weapon_stick', w: 30 }, { id: 'loot_feather', w: 20 }] },
- { name: 'Spider', emoji: '🕷️', hp: 35, atk: 10, def: 2, xp: 20, gold: 10, loot: [{ id: 'bait_worm', w: 40 }, { id: 'loot_feather', w: 40 }, { id: 'loot_crown', w: 20 }] },
- { name: 'Goblin', emoji: '👺', hp: 50, atk: 12, def: 5, xp: 35, gold: 20, loot: [{ id: 'loot_coin_pouch', w: 40 }, { id: 'ore_gold', w: 30 }, { id: 'loot_crown', w: 30 }] },
- { name: 'Slime King', emoji: '🟢', hp: 80, atk: 6, def: 10, xp: 50, gold: 30, loot: [{ id: 'gem_emerald', w: 30 }, { id: 'loot_egg', w: 20 }, { id: 'hat_cowboy', w: 5 }] },
- { name: 'Dark Knight', emoji: '🗡️', hp: 100, atk: 18, def: 12, xp: 75, gold: 45, loot: [{ id: 'weapon_sword', w: 20 }, { id: 'armor_chain', w: 15 }, { id: 'gem_diamond', w: 25 }] },
- { name: 'Fire Elemental',emoji:'🔥', hp: 120, atk: 22, def: 8, xp: 90, gold: 55, loot: [{ id: 'gem_ruby', w: 25 }, { id: 'bar_gold', w: 20 }, { id: 'hat_wizard', w: 5 }] },
- { name: 'Dragon', emoji: '🐉', hp: 250, atk: 35, def: 25, xp: 200, gold: 150, loot: [{ id: 'ore_dragonite', w: 20 }, { id: 'weapon_katana', w: 10 }, { id: 'armor_dragonscale', w: 5 }, { id: 'weapon_legendary', w: 2 }] },
- { name: 'Void Wraith', emoji: '👻', hp: 300, atk: 40, def: 30, xp: 300, gold: 200, loot: [{ id: 'mine_void_stone', w: 15 }, { id: 'hat_void_crown', w: 3 }, { id: 'loot_void_heart', w: 5 }] },
-];
-
-// ── Recipes ──────────────────────────────────────────────────
-const RECIPES = {
- // Starter tool crafting (fist → stone tier)
- pick_wood: { name: 'Wooden Pickaxe', inputs: { raw_stick: 3, raw_stone: 2 }, output: 'pick_wood', qty: 1, rarity: 'Common', xp: 5 },
- pick_stone: { name: 'Stone Pickaxe', inputs: { raw_stone: 5, raw_stick: 2, raw_flint: 1 }, output: 'pick_stone', qty: 1, rarity: 'Common', xp: 10 },
- axe_stone: { name: 'Stone Axe', inputs: { raw_stone: 3, raw_stick: 2 }, output: 'axe_stone', qty: 1, rarity: 'Common', xp: 5 },
- weapon_rock: { name: 'Sharp Rock', inputs: { raw_stone: 2, raw_flint: 1 }, output: 'weapon_rock', qty: 1, rarity: 'Junk', xp: 3 },
- rod_bamboo: { name: 'Bamboo Rod', inputs: { raw_stick: 3, bait_worm: 1 }, output: 'rod_bamboo', qty: 1, rarity: 'Common', xp: 5 },
- craft_plank: { name: 'Wooden Plank', inputs: { wood_oak: 2 }, output: 'craft_plank', qty: 3, rarity: 'Common', xp: 10 },
- craft_charcoal: { name: 'Charcoal', inputs: { wood_oak: 1, ore_coal: 1 }, output: 'craft_charcoal', qty: 2, rarity: 'Common', xp: 15 },
- craft_arrow: { name: 'Arrows', inputs: { craft_plank: 1, ore_iron: 1 }, output: 'craft_arrow', qty: 1, rarity: 'Uncommon', xp: 25 },
- craft_shield: { name: 'Wooden Shield', inputs: { craft_plank: 3, bar_iron: 1 }, output: 'craft_shield', qty: 1, rarity: 'Uncommon', xp: 30 },
- craft_totem: { name: 'Spirit Totem', inputs: { wood_elder: 2, gem_ruby: 1 }, output: 'craft_totem', qty: 1, rarity: 'Rare', xp: 60 },
- craft_wand: { name: 'Crystal Wand', inputs: { wood_crystal: 1, gem_diamond: 1 }, output: 'craft_wand', qty: 1, rarity: 'Epic', xp: 100 },
- craft_crown: { name: 'Crown of Thorns', inputs: { bar_gold: 2, gem_emerald: 1 }, output: 'craft_crown', qty: 1, rarity: 'Epic', xp: 120 },
- craft_golem: { name: 'Iron Golem Core', inputs: { bar_iron: 5, gem_ruby: 2 }, output: 'craft_golem', qty: 1, rarity: 'Epic', xp: 150 },
- // ── Smelting (requires furnace station nearby) ──
- bar_bronze: { name: 'Smelt Bronze', inputs: { ore_tin: 1, ore_copper: 1 }, output: 'bar_bronze', qty: 1, rarity: 'Common', xp: 12, skill: 'smithing', station: 'furnace' },
- bar_copper: { name: 'Smelt Copper', inputs: { ore_copper: 2 }, output: 'bar_copper', qty: 1, rarity: 'Common', xp: 10, skill: 'smithing', station: 'furnace' },
- bar_iron: { name: 'Smelt Iron', inputs: { ore_iron: 2, ore_coal: 1 }, output: 'bar_iron', qty: 1, rarity: 'Common', xp: 20, skill: 'smithing', station: 'furnace', smithingLevel: 5 },
- bar_steel: { name: 'Smelt Steel', inputs: { ore_iron: 1, ore_coal: 2 }, output: 'bar_steel', qty: 1, rarity: 'Uncommon', xp: 35, skill: 'smithing', station: 'furnace', smithingLevel: 15 },
- bar_gold: { name: 'Smelt Gold', inputs: { ore_gold: 2, craft_charcoal: 1 }, output: 'bar_gold', qty: 1, rarity: 'Uncommon', xp: 35, skill: 'smithing', station: 'furnace', smithingLevel: 10 },
- bar_mithril: { name: 'Smelt Mithril', inputs: { ore_mithril: 2, craft_charcoal: 2 },output: 'bar_mithril', qty: 1, rarity: 'Rare', xp: 60, skill: 'smithing', station: 'furnace', smithingLevel: 25 },
- bar_titanium: { name: 'Smelt Titanium', inputs: { ore_titanium: 3, craft_charcoal: 2 },output:'bar_titanium', qty: 1, rarity: 'Rare', xp: 80, skill: 'smithing', station: 'furnace', smithingLevel: 35 },
- bar_platinum: { name: 'Smelt Platinum', inputs: { ore_platinum: 3, craft_charcoal: 3 },output:'bar_platinum', qty: 1, rarity: 'Epic', xp: 100, skill: 'smithing', station: 'furnace', smithingLevel: 45 },
- bar_dragonite: { name: 'Smelt Dragonite', inputs: { ore_dragonite: 3, gem_star: 1 }, output: 'bar_dragonite', qty: 1, rarity: 'Legendary', xp: 200, skill: 'smithing', station: 'furnace', smithingLevel: 55 },
- // ── Smithing at Workbench (bars → tools/weapons) ──
- pick_iron: { name: 'Iron Pickaxe', inputs: { bar_iron: 3, raw_stick: 2 }, output: 'pick_iron', qty: 1, rarity: 'Uncommon', xp: 30, skill: 'smithing', station: 'workbench', smithingLevel: 8 },
- pick_gold: { name: 'Gold Pickaxe', inputs: { bar_gold: 3, wood_yew: 1 }, output: 'pick_gold', qty: 1, rarity: 'Rare', xp: 50, skill: 'smithing', station: 'workbench', smithingLevel: 20 },
- pick_diamond: { name: 'Diamond Pickaxe', inputs: { bar_titanium: 2, gem_diamond: 2 }, output: 'pick_diamond', qty: 1, rarity: 'Epic', xp: 100, skill: 'smithing', station: 'workbench', smithingLevel: 35 },
- axe_iron: { name: 'Iron Axe', inputs: { bar_iron: 3, raw_stick: 2 }, output: 'axe_iron', qty: 1, rarity: 'Uncommon', xp: 30, skill: 'smithing', station: 'workbench', smithingLevel: 8 },
- axe_steel: { name: 'Steel Axe', inputs: { bar_steel: 3, wood_mahogany: 1 }, output: 'axe_steel', qty: 1, rarity: 'Rare', xp: 50, skill: 'smithing', station: 'workbench', smithingLevel: 18 },
- axe_mythril: { name: 'Mythril Axe', inputs: { bar_mithril: 3, wood_elder: 1 }, output: 'axe_mythril', qty: 1, rarity: 'Epic', xp: 80, skill: 'smithing', station: 'workbench', smithingLevel: 30 },
- compost: { name: 'Compost', inputs: { crop_wheat: 2, wood_twig: 3 }, output: 'compost', qty: 2, rarity: 'Common', xp: 10 },
- craft_elixir: { name: 'Elixir of Fortune',inputs:{ gem_ruby: 1, crop_tomato: 2 }, output: 'craft_elixir', qty: 1, rarity: 'Uncommon', xp: 40 },
- craft_xp_potion:{ name: 'XP Boost Potion', inputs: { gem_emerald: 1, wood_sap: 2 }, output: 'craft_xp_potion',qty: 1, rarity: 'Uncommon', xp: 40 },
- fish_sonar: { name: 'Fish Sonar', inputs: { ore_copper: 2, gem_ruby: 1 }, output: 'fish_sonar', qty: 1, rarity: 'Uncommon', xp: 30 },
- hat_cowboy: { name: 'Cowboy Hat', inputs: { crop_wheat: 5, wood_oak: 2 }, output: 'hat_cowboy', qty: 1, rarity: 'Uncommon', xp: 35 },
- hat_wizard: { name: 'Wizard Hat', inputs: { wood_elder: 2, gem_ruby: 1 }, output: 'hat_wizard', qty: 1, rarity: 'Rare', xp: 75 },
- hat_crown: { name: 'Royal Crown', inputs: { bar_gold: 3, gem_diamond: 2 }, output: 'hat_crown', qty: 1, rarity: 'Epic', xp: 150 },
- potion_health: { name: 'Health Potion', inputs: { crop_tomato: 1, wood_sap: 1 }, output: 'potion_health', qty: 2, rarity: 'Common', xp: 15 },
- // ── Cooking (requires a burn barrel station nearby) ──
- food_cooked_fish: { name: 'Cooked Fish', inputs: { fish_bass: 1, wood_oak: 1 }, output: 'food_cooked_fish', qty: 2, rarity: 'Common', xp: 10, skill: 'cooking', station: 'campfire' },
- food_grilled_meat: { name: 'Grilled Meat', inputs: { fish_catfish: 1, raw_stick: 2 }, output: 'food_grilled_meat', qty: 1, rarity: 'Common', xp: 15, skill: 'cooking', station: 'campfire' },
- food_trail_mix: { name: 'Trail Mix', inputs: { crop_wheat: 2, crop_corn: 1, wood_sap: 1 },output: 'food_trail_mix', qty: 2, rarity: 'Common', xp: 15, skill: 'cooking' },
- food_veggie_soup: { name: 'Veggie Soup', inputs: { crop_carrot: 2, crop_tomato: 1, crop_wheat: 1 }, output: 'food_veggie_soup', qty: 1, rarity: 'Uncommon', xp: 25, skill: 'cooking', station: 'campfire' },
- food_fish_stew: { name: 'Fish Stew', inputs: { fish_salmon: 1, crop_tomato: 1, crop_carrot: 1 }, output: 'food_fish_stew', qty: 1, rarity: 'Uncommon', xp: 30, skill: 'cooking', station: 'campfire' },
- food_energy_bar: { name: 'Energy Bar', inputs: { crop_corn: 2, crop_wheat: 2, wood_sap: 1 },output: 'food_energy_bar', qty: 2, rarity: 'Uncommon', xp: 25, skill: 'cooking' },
- food_pumpkin_pie: { name: 'Pumpkin Pie', inputs: { crop_pumpkin: 1, crop_wheat: 2, crop_golden: 1 }, output: 'food_pumpkin_pie', qty: 1, rarity: 'Rare', xp: 60, skill: 'cooking', station: 'campfire' },
- food_golden_feast: { name: 'Golden Feast', inputs: { crop_golden: 1, fish_king_salmon: 1, bar_gold: 1 }, output: 'food_golden_feast', qty: 1, rarity: 'Epic', xp: 100, skill: 'cooking', station: 'campfire' },
- // Weapons & Armor (smithing at workbench)
- weapon_sword: { name: 'Iron Sword', inputs: { bar_iron: 3 }, output: 'weapon_sword', qty: 1, rarity: 'Uncommon', xp: 40, skill: 'smithing', station: 'workbench', smithingLevel: 10 },
- weapon_axe: { name: 'Battle Axe', inputs: { bar_steel: 3, wood_elder: 1 }, output: 'weapon_axe', qty: 1, rarity: 'Rare', xp: 80, skill: 'smithing', station: 'workbench', smithingLevel: 22 },
- weapon_katana: { name: 'Shadow Katana', inputs: { bar_titanium: 2, gem_diamond: 1 }, output: 'weapon_katana', qty: 1, rarity: 'Epic', xp: 150, skill: 'smithing', station: 'workbench', smithingLevel: 40 },
- armor_leather: { name: 'Leather Armor', inputs: { crop_wheat: 5, bar_iron: 1 }, output: 'armor_leather', qty: 1, rarity: 'Uncommon', xp: 35, station: 'workbench' },
- armor_chain: { name: 'Chainmail', inputs: { bar_iron: 4, ore_coal: 2 }, output: 'armor_chain', qty: 1, rarity: 'Rare', xp: 70, skill: 'smithing', station: 'workbench', smithingLevel: 15 },
- armor_plate: { name: 'Plate Armor', inputs: { bar_titanium: 2, bar_gold: 1 }, output: 'armor_plate', qty: 1, rarity: 'Epic', xp: 130, skill: 'smithing', station: 'workbench', smithingLevel: 35 },
-};
-
-// ── NPCs (Town Layout — buildings are 3w×4h, door at bottom-center) ──
-// Center = OUTPOST_X,OUTPOST_Y. Town radius 14 tiles. Buildings spaced around a central square.
-// Each NPC stands 1 tile inside their door (tileX/Y = door tile, which is the interaction point).
-// buildTiles = full footprint (walls + floor), doorTile = entrance tile.
-
-function makeBuildTiles(cx, cy) {
- // 3×4 building centered at cx, top row cy-2, bottom row cy+1
- const tiles = [];
- for (let dy = -2; dy <= 1; dy++) {
- for (let dx = -1; dx <= 1; dx++) {
- tiles.push([cx + dx, cy + dy]);
- }
- }
- return tiles;
-}
-
-const OX = OUTPOST_X, OY = OUTPOST_Y;
-const NPCS = {
- banker: { id: 'banker', name: 'Banker', emoji: '🏦', tileX: OX, tileY: OY - 4, doorTile: [OX, OY - 3], buildTiles: makeBuildTiles(OX, OY - 4), roofColor: '#4a6741', categories: [] },
- weaponsmith: { id: 'weaponsmith', name: 'Weaponsmith', emoji: '⚔️', tileX: OX - 7, tileY: OY - 4, doorTile: [OX - 7, OY - 3], buildTiles: makeBuildTiles(OX - 7, OY - 4), roofColor: '#8b3a3a', categories: ['weapons', 'armor'] },
- toolshop: { id: 'toolshop', name: 'Tool Merchant', emoji: '🧰', tileX: OX - 7, tileY: OY + 1, doorTile: [OX - 7, OY + 2], buildTiles: makeBuildTiles(OX - 7, OY + 1), roofColor: '#5a4a3a', categories: ['pickaxes', 'axes', 'rods', 'tools'] },
- farmsupply: { id: 'farmsupply', name: 'Farm Supply', emoji: '🌱', tileX: OX + 7, tileY: OY - 4, doorTile: [OX + 7, OY - 3], buildTiles: makeBuildTiles(OX + 7, OY - 4), roofColor: '#5a7a3a', categories: ['seeds', 'bait', 'consumable'] },
- gemtrader: { id: 'gemtrader', name: 'Gem Trader', emoji: '💎', tileX: OX + 7, tileY: OY + 1, doorTile: [OX + 7, OY + 2], buildTiles: makeBuildTiles(OX + 7, OY + 1), roofColor: '#4a3a6a', categories: ['ores', 'gems', 'bars'] },
- cosmetics: { id: 'cosmetics', name: 'Cosmetics', emoji: '🎩', tileX: OX, tileY: OY + 4, doorTile: [OX, OY + 5], buildTiles: makeBuildTiles(OX, OY + 4), roofColor: '#8a5a8a', categories: ['hats', 'name_effects', 'particles', 'voices'] },
- cook: { id: 'cook', name: 'Camp Chef', emoji: '👨🍳', tileX: OX - 11,tileY: OY - 2, doorTile: [OX - 11, OY - 1], buildTiles: makeBuildTiles(OX - 11, OY - 2), roofColor: '#7a5a3a', categories: ['food', 'consumable'] },
- tagmaster: { id: 'tagmaster', name: 'Tag Master', emoji: '🏷️', tileX: OX + 11,tileY: OY - 2, doorTile: [OX + 11, OY - 1], buildTiles: makeBuildTiles(OX + 11, OY - 2), roofColor: '#4a5a7a', categories: ['tags'], guardedBy: 'tag_guardian' },
-};
-
-// Village NPC generation — each village gets 2 shopkeepers
-(function generateVillageNPCs() {
- const villageDefs = [
- { village: VILLAGES[0], npcs: [
- { id: 'v_fishmonger', name: 'Fishmonger', emoji: '🐟', ox: -3, oy: -2, roofColor: '#3a6a7a', categories: ['rods', 'bait'] },
- { id: 'v_tavern', name: 'Tavern Keep', emoji: '🍺', ox: 3, oy: -2, roofColor: '#6a4a2a', categories: ['food', 'consumable'] },
- ]},
- { village: VILLAGES[1], npcs: [
- { id: 'v_minesmith', name: 'Mine Smith', emoji: '⛏️', ox: -3, oy: -2, roofColor: '#5a5a5a', categories: ['pickaxes', 'ores', 'bars'] },
- { id: 'v_armorsmith', name: 'Armorsmith', emoji: '🛡️', ox: 3, oy: -2, roofColor: '#7a3a3a', categories: ['armor', 'weapons'] },
- ]},
- { village: VILLAGES[2], npcs: [
- { id: 'v_herbalist', name: 'Herbalist', emoji: '🌿', ox: -3, oy: -2, roofColor: '#3a7a3a', categories: ['seeds', 'consumable'] },
- { id: 'v_woodsman', name: 'Woodsman', emoji: '🪵', ox: 3, oy: -2, roofColor: '#5a3a2a', categories: ['axes', 'tools'] },
- ]},
- ];
- for (const vd of villageDefs) {
- for (const n of vd.npcs) {
- const tx = vd.village.cx + n.ox;
- const ty = vd.village.cy + n.oy;
- NPCS[n.id] = {
- id: n.id, name: n.name, emoji: n.emoji,
- tileX: tx, tileY: ty,
- doorTile: [tx, ty + 1],
- buildTiles: makeBuildTiles(tx, ty),
- roofColor: n.roofColor,
- categories: n.categories,
- village: vd.village.id,
- };
- }
- }
-})();
-
-// ── Town Decorations (rendered on client, defined here for shared access) ──
-const TOWN_DECO = [
- // ─ Main Town ─
- { type: 'fountain', tileX: OX, tileY: OY, emoji: '⛲' },
- // Lampposts along main paths
- { type: 'lamp', tileX: OX - 3, tileY: OY - 2 }, { type: 'lamp', tileX: OX + 3, tileY: OY - 2 },
- { type: 'lamp', tileX: OX - 3, tileY: OY + 2 }, { type: 'lamp', tileX: OX + 3, tileY: OY + 2 },
- { type: 'lamp', tileX: OX - 9, tileY: OY }, { type: 'lamp', tileX: OX + 9, tileY: OY },
- { type: 'lamp', tileX: OX, tileY: OY - 8 }, { type: 'lamp', tileX: OX, tileY: OY + 8 },
- // Benches near the square
- { type: 'bench', tileX: OX - 2, tileY: OY - 1 }, { type: 'bench', tileX: OX + 2, tileY: OY - 1 },
- { type: 'bench', tileX: OX - 2, tileY: OY + 1 }, { type: 'bench', tileX: OX + 2, tileY: OY + 1 },
- // Flower beds
- { type: 'flowers', tileX: OX - 1, tileY: OY - 1 }, { type: 'flowers', tileX: OX + 1, tileY: OY - 1 },
- { type: 'flowers', tileX: OX - 1, tileY: OY + 1 }, { type: 'flowers', tileX: OX + 1, tileY: OY + 1 },
- // Sign posts at town entrances
- { type: 'sign', tileX: OX, tileY: OY - OUTPOST_RADIUS + 3, text: 'Town' },
- { type: 'sign', tileX: OX, tileY: OY + OUTPOST_RADIUS - 3, text: 'Town' },
- { type: 'sign', tileX: OX - OUTPOST_RADIUS + 3, tileY: OY, text: 'Town' },
- { type: 'sign', tileX: OX + OUTPOST_RADIUS - 3, tileY: OY, text: 'Town' },
- // Decorative trees
- { type: 'town_tree', tileX: OX - 10, tileY: OY - 8 }, { type: 'town_tree', tileX: OX + 10, tileY: OY - 8 },
- { type: 'town_tree', tileX: OX - 10, tileY: OY + 8 }, { type: 'town_tree', tileX: OX + 10, tileY: OY + 8 },
- { type: 'town_tree', tileX: OX - 5, tileY: OY - 9 }, { type: 'town_tree', tileX: OX + 5, tileY: OY - 9 },
- { type: 'town_tree', tileX: OX - 5, tileY: OY + 9 }, { type: 'town_tree', tileX: OX + 5, tileY: OY + 9 },
- // Crates & barrels near shops
- { type: 'barrel', tileX: OX - 9, tileY: OY - 6 }, { type: 'barrel', tileX: OX - 5, tileY: OY - 6 },
- { type: 'crate', tileX: OX + 9, tileY: OY - 6 }, { type: 'crate', tileX: OX + 5, tileY: OY - 6 },
- { type: 'barrel', tileX: OX - 9, tileY: OY + 2 }, { type: 'crate', tileX: OX + 9, tileY: OY + 2 },
- { type: 'well', tileX: OX - 2, tileY: OY - 7 },
- // Tag Master building — guardian stands outside
- { type: 'lamp', tileX: OX + 9, tileY: OY },
- { type: 'sign', tileX: OX + 11, tileY: OY, text: 'Tag Master' },
-];
-// Add village decorations
-for (const v of VILLAGES) {
- TOWN_DECO.push(
- { type: 'fountain', tileX: v.cx, tileY: v.cy, emoji: '⛲' },
- { type: 'lamp', tileX: v.cx - 3, tileY: v.cy - 1 },
- { type: 'lamp', tileX: v.cx + 3, tileY: v.cy - 1 },
- { type: 'lamp', tileX: v.cx - 3, tileY: v.cy + 1 },
- { type: 'lamp', tileX: v.cx + 3, tileY: v.cy + 1 },
- { type: 'bench', tileX: v.cx - 1, tileY: v.cy + 1 },
- { type: 'bench', tileX: v.cx + 1, tileY: v.cy + 1 },
- { type: 'sign', tileX: v.cx, tileY: v.cy + v.radius - 1, text: v.name },
- { type: 'sign', tileX: v.cx, tileY: v.cy - v.radius + 1, text: v.name },
- { type: 'town_tree', tileX: v.cx - 5, tileY: v.cy - 4 },
- { type: 'town_tree', tileX: v.cx + 5, tileY: v.cy - 4 },
- );
-}
-
-// ── Town Path Tiles — roads/cobblestone (set of "tileX,tileY" strings) ──
-function generateTownPaths() {
- const paths = new Set();
- const cx = OX, cy = OY;
-
- // Collect all NPC building tiles to exclude from paths
- const buildingTiles = new Set();
- for (const npc of Object.values(NPCS)) {
- for (const [bx, by] of (npc.buildTiles || [])) buildingTiles.add(`${bx},${by}`);
- }
- const addPath = (x, y) => {
- const k = `${x},${y}`;
- if (!buildingTiles.has(k)) paths.add(k);
- };
-
- // ─ Main Town paths ─
- // North-south road (3 tiles wide)
- for (let y = cy - OUTPOST_RADIUS + 2; y <= cy + OUTPOST_RADIUS - 2; y++) {
- addPath(cx - 1, y); addPath(cx, y); addPath(cx + 1, y);
- }
- // East-west road (3 tiles wide)
- for (let x = cx - OUTPOST_RADIUS + 2; x <= cx + OUTPOST_RADIUS - 2; x++) {
- addPath(x, cy - 1); addPath(x, cy); addPath(x, cy + 1);
- }
- // Town square (5×5 around fountain)
- for (let x = cx - 2; x <= cx + 2; x++) {
- for (let y = cy - 2; y <= cy + 2; y++) {
- addPath(x, y);
- }
- }
- // Side paths to each building (2 tiles wide leading to doors)
- // NW weaponsmith
- for (let x = cx - 6; x <= cx - 2; x++) { addPath(x, cy - 3); addPath(x, cy - 2); }
- // NE farm supply
- for (let x = cx + 2; x <= cx + 6; x++) { addPath(x, cy - 3); addPath(x, cy - 2); }
- // SW tool shop
- for (let x = cx - 6; x <= cx - 2; x++) { addPath(x, cy + 2); addPath(x, cy + 3); }
- // SE gem trader
- for (let x = cx + 2; x <= cx + 6; x++) { addPath(x, cy + 2); addPath(x, cy + 3); }
- // S cosmetics
- for (let y = cy + 2; y <= cy + 6; y++) { addPath(cx - 1, y); addPath(cx, y); addPath(cx + 1, y); }
- // W cook
- for (let x = cx - OUTPOST_RADIUS + 2; x <= cx - 2; x++) { addPath(x, cy - 1); addPath(x, cy); }
- // E tag master
- for (let x = cx + 2; x <= cx + OUTPOST_RADIUS - 2; x++) { addPath(x, cy - 1); addPath(x, cy); }
-
- // ─ Village internal paths ─
- for (const v of VILLAGES) {
- // Small cross path within each village
- for (let y = v.cy - v.radius + 2; y <= v.cy + v.radius - 2; y++) {
- addPath(v.cx, y); addPath(v.cx - 1, y);
- }
- for (let x = v.cx - v.radius + 2; x <= v.cx + v.radius - 2; x++) {
- addPath(x, v.cy); addPath(x, v.cy - 1);
- }
- // Small square (3×3) around center
- for (let x = v.cx - 1; x <= v.cx + 1; x++) {
- for (let y = v.cy - 1; y <= v.cy + 1; y++) {
- addPath(x, y);
- }
- }
- }
-
- // ─ Inter-town roads (3-wide Bresenham path from main town to each village) ─
- for (const v of VILLAGES) {
- const steps = Math.max(Math.abs(v.cx - cx), Math.abs(v.cy - cy));
- for (let i = 0; i <= steps; i++) {
- const t = steps === 0 ? 0 : i / steps;
- const rx = Math.round(cx + (v.cx - cx) * t);
- const ry = Math.round(cy + (v.cy - cy) * t);
- // 3-wide road
- addPath(rx - 1, ry); addPath(rx, ry); addPath(rx + 1, ry);
- addPath(rx, ry - 1); addPath(rx, ry + 1);
- }
- }
-
- return paths;
-}
-const TOWN_PATHS = generateTownPaths();
-const NPC_LIST = Object.values(NPCS);
-const NPC_INTERACT_RANGE = 3;
-
-// ── World Mob Types (roaming enemies in the open world) ─────
-const MOB_TYPES = {
- rat: { name: 'Rat', emoji: '🐀', hp: 20, atk: 3, def: 0, xp: 10, gold: 5, speed: 0.8, aggroRange: 3, biomes: ['grass','forest','sand'], minTier: 0, loot: [{id:'loot_lint',w:50},{id:'bait_worm',w:30},{id:'loot_coin_pouch',w:20}] },
- wolf: { name: 'Wolf', emoji: '🐺', hp: 45, atk: 8, def: 2, xp: 25, gold: 12, speed: 1.4, aggroRange: 5, biomes: ['forest','hills','grass'], minTier: 0, loot: [{id:'loot_coin_pouch',w:40},{id:'potion_health',w:30},{id:'loot_feather',w:30}] },
- spider: { name: 'Cave Spider', emoji: '🕷️', hp: 35, atk: 7, def: 1, xp: 20, gold: 8, speed: 1.0, aggroRange: 4, biomes: ['hills','mountain','desert'], minTier: 1, loot: [{id:'bait_cricket',w:40},{id:'loot_coin_pouch',w:35},{id:'potion_health',w:25}] },
- skeleton: { name: 'Skeleton', emoji: '💀', hp: 60, atk: 12, def: 5, xp: 40, gold: 20, speed: 0.7, aggroRange: 5, biomes: ['mountain','desert','snow'], minTier: 1, loot: [{id:'dungeon_key',w:8},{id:'weapon_stick',w:22},{id:'loot_coin_pouch',w:40},{id:'loot_feather',w:30}] },
- golem: { name: 'Stone Golem', emoji: '🗿', hp: 100, atk: 18, def: 12, xp: 65, gold: 35, speed: 0.4, aggroRange: 4, biomes: ['mountain','hills'], minTier: 2, loot: [{id:'ore_iron',w:25},{id:'ore_gold',w:20},{id:'gem_ruby',w:15},{id:'bar_iron',w:25},{id:'loot_crown',w:15}] },
- wraith: { name: 'Wraith', emoji: '👻', hp: 80, atk: 22, def: 8, xp: 80, gold: 45, speed: 1.3, aggroRange: 6, biomes: ['snow','mountain'], minTier: 2, loot: [{id:'gem_emerald',w:25},{id:'mine_void_stone',w:5},{id:'loot_egg',w:15},{id:'dungeon_key',w:20},{id:'craft_xp_potion',w:35}] },
- dragon: { name: 'Dragon Whelp', emoji: '🐲', hp: 180, atk: 32, def: 18, xp: 150, gold: 80, speed: 1.0, aggroRange: 7, biomes: ['mountain','snow'], minTier: 3, loot: [{id:'ore_dragonite',w:15},{id:'gem_diamond',w:25},{id:'loot_egg',w:20},{id:'loot_star',w:10},{id:'dungeon_key',w:30}] },
- // Aggro village guards — patrol near villages, hostile to non-town players
- bandit: { name: 'Bandit', emoji: '🗡️', hp: 50, atk: 10, def: 3, xp: 30, gold: 15, speed: 1.1, aggroRange: 5, biomes: ['grass','forest','sand','hills','desert'], minTier: 0, loot: [{id:'loot_coin_pouch',w:40},{id:'weapon_stick',w:25},{id:'potion_health',w:20},{id:'loot_feather',w:15}], nearVillage: true },
- raider: { name: 'Raider', emoji: '⚔️', hp: 75, atk: 15, def: 6, xp: 45, gold: 25, speed: 1.0, aggroRange: 6, biomes: ['grass','forest','hills','mountain','desert'], minTier: 1, loot: [{id:'loot_coin_pouch',w:35},{id:'bar_iron',w:20},{id:'potion_health',w:25},{id:'dungeon_key',w:10},{id:'loot_crown',w:10}], nearVillage: true },
-};
-
-// ── Weapon & Armor Stats (shared with engine) ───────────────
-const WEAPON_STATS = {
- weapon_fist: { atk: 0, speed: 1.0, range: 1.0 },
- weapon_rock: { atk: 1, speed: 1.2, range: 0.8 },
- weapon_stick: { atk: 2, speed: 1.3, range: 1.2 },
- weapon_sword: { atk: 8, speed: 1.0, range: 1.2 },
- weapon_axe: { atk: 15, speed: 0.7, range: 1.0 },
- weapon_katana: { atk: 25, speed: 1.4, range: 1.3 },
- weapon_legendary: { atk: 40, speed: 1.1, range: 1.4 },
-};
-const ARMOR_STATS = {
- armor_cloth: { def: 3 },
- armor_leather: { def: 8 },
- armor_chain: { def: 15 },
- armor_plate: { def: 25 },
- armor_dragonscale: { def: 40 },
-};
-
-// ── Level Requirements by Category ───────────────────────────
-const EQUIP_SKILL_MAP = {
- pickaxes: 'mining', axes: 'woodcut', rods: 'fishing',
- weapons: 'combat', armor: 'combat',
-};
-
-// ── Food Effects ─────────────────────────────────────────────
-const FOOD_EFFECTS = {
- food_cooked_fish: { hp: 20 },
- food_grilled_meat: { hp: 30 },
- food_fish_stew: { hp: 40, buff: { atk: 5, duration: 180000 } },
- food_veggie_soup: { hp: 25, buff: { def: 5, duration: 180000 } },
- food_trail_mix: { stamina: 30, buff: { sprint_free: true, duration: 120000 } },
- food_energy_bar: { stamina: 50, buff: { max_stamina: 20, duration: 300000 } },
- food_pumpkin_pie: { hp: 50, buff: { xp_mult: 2, duration: 120000 } },
- food_golden_feast: { hp: 75, buff: { atk: 10, def: 10, duration: 300000 } },
- food_campfire_meal:{ hp: 15, buff: { hp_regen: 3, duration: 120000 } },
-};
-
-// ── Utility ──────────────────────────────────────────────────
-function xpToLevel(xp) { return Math.floor(Math.sqrt(xp / 25)) + 1; }
-function levelToXp(level) { return (level - 1) * (level - 1) * 25; }
-
-function rollLoot(table) {
- const total = table.reduce((s, e) => s + (e.weight || e.w || 0), 0);
- let roll = Math.random() * total;
- for (const entry of table) {
- roll -= (entry.weight || entry.w || 0);
- if (roll <= 0) return entry.id;
- }
- return table[table.length - 1].id;
-}
-
-module.exports = {
- ITEMS, RARITY_COLORS, RECIPES, STRUCTURES, BIOME_COLORS,
- FISH_TABLES, MINE_TABLES, WOOD_TABLES, WATER_ZONE_TABLES, CROPS, MONSTERS,
- FISH_SPECIES, FISH_JUNK, ROD_TIERS, buildFishTable,
- PICK_TIERS, AXE_TIERS, ORE_NODE_TYPES, getOreNodeType,
- MOB_TYPES, VILLAGES,
- WEAPON_STATS, ARMOR_STATS, EQUIP_SKILL_MAP, FOOD_EFFECTS,
- MAP_W, MAP_H, TILE, OUTPOST_X, OUTPOST_Y, OUTPOST_RADIUS,
- NPCS, NPC_LIST, NPC_INTERACT_RANGE, TOWN_DECO, TOWN_PATHS,
- xpToLevel, levelToXp, rollLoot,
- hashNoise, smoothNoise, fbm, getBiome, getBiomeAt, getWaterZone,
- getResourceNodeAt, getDifficultyTier, isInSafeZone,
-};
deleted file mode 100644
deleted file mode 100644
@@ -1,211 +0,0 @@
@@ -1,211 +0,0 @@
-
-CREATE TABLE IF NOT EXISTS game_world_state (
- key TEXT PRIMARY KEY,
- value TEXT
-);
-
-CREATE TABLE IF NOT EXISTS game_players (
- user_id INTEGER PRIMARY KEY,
- display_name TEXT,
- x REAL DEFAULT 4096,
- y REAL DEFAULT 4096,
- -- XP
- mining_xp INTEGER DEFAULT 0,
- fishing_xp INTEGER DEFAULT 0,
- woodcut_xp INTEGER DEFAULT 0,
- farming_xp INTEGER DEFAULT 0,
- combat_xp INTEGER DEFAULT 0,
- crafting_xp INTEGER DEFAULT 0,
- agility_xp INTEGER DEFAULT 0,
- -- Combat
- hp INTEGER DEFAULT 100,
- max_hp INTEGER DEFAULT 100,
- attack INTEGER DEFAULT 10,
- defense INTEGER DEFAULT 5,
- -- Stamina
- stamina INTEGER DEFAULT 100,
- max_stamina INTEGER DEFAULT 100,
- last_stamina_tick DATETIME DEFAULT CURRENT_TIMESTAMP,
- -- Equipment slots (NULL = bare hands / no tool)
- equip_pickaxe TEXT DEFAULT NULL,
- equip_rod TEXT DEFAULT NULL,
- equip_axe TEXT DEFAULT NULL,
- equip_hat TEXT,
- equip_weapon TEXT,
- equip_armor TEXT,
- -- Spawn point (sleeping bag)
- sleeping_bag_x REAL,
- sleeping_bag_y REAL,
- -- Appearance
- sprite_skin INTEGER DEFAULT 0,
- name_effect TEXT,
- particle_effect TEXT,
- chat_color TEXT DEFAULT '#e8e6e3',
- -- Stats
- total_coins_earned INTEGER DEFAULT 0,
- total_items_crafted INTEGER DEFAULT 0,
- total_monsters_killed INTEGER DEFAULT 0,
- total_deaths INTEGER DEFAULT 0,
- battle_wins INTEGER DEFAULT 0,
- battle_losses INTEGER DEFAULT 0,
- structures_built INTEGER DEFAULT 0,
- resources_gathered INTEGER DEFAULT 0,
- -- Timestamps
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- last_action DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
-);
-
-CREATE TABLE IF NOT EXISTS game_inventory (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER NOT NULL,
- item_id TEXT NOT NULL,
- quantity INTEGER DEFAULT 1,
- UNIQUE(user_id, item_id),
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
-);
-CREATE INDEX IF NOT EXISTS idx_ginv_user ON game_inventory(user_id);
-
-CREATE TABLE IF NOT EXISTS game_bank (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER NOT NULL,
- item_id TEXT NOT NULL,
- quantity INTEGER DEFAULT 1,
- UNIQUE(user_id, item_id),
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
-);
-
-CREATE TABLE IF NOT EXISTS game_structures (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- owner_id INTEGER NOT NULL,
- type TEXT NOT NULL,
- tile_x INTEGER NOT NULL,
- tile_y INTEGER NOT NULL,
- hp INTEGER DEFAULT 100,
- max_hp INTEGER DEFAULT 100,
- data TEXT DEFAULT '{}',
- built_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- UNIQUE(tile_x, tile_y),
- FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
-);
-CREATE INDEX IF NOT EXISTS idx_struct_tile ON game_structures(tile_x, tile_y);
-CREATE INDEX IF NOT EXISTS idx_struct_owner ON game_structures(owner_id);
-
-CREATE TABLE IF NOT EXISTS game_farm_plots (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER NOT NULL,
- plot_index INTEGER DEFAULT 0,
- crop_id TEXT,
- planted_at DATETIME,
- watered_at DATETIME,
- fertilized INTEGER DEFAULT 0,
- stage TEXT DEFAULT 'empty' CHECK(stage IN ('empty','seed','sprout','growing','ripe','withered')),
- UNIQUE(user_id, plot_index),
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
-);
-
-CREATE TABLE IF NOT EXISTS game_recipes (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER NOT NULL,
- recipe_id TEXT NOT NULL,
- unlocked_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- UNIQUE(user_id, recipe_id),
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
-);
-
-CREATE TABLE IF NOT EXISTS game_effects (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER NOT NULL,
- effect_type TEXT NOT NULL,
- effect_id TEXT,
- expires_at DATETIME,
- charges INTEGER,
- data TEXT DEFAULT '{}',
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
-);
-CREATE INDEX IF NOT EXISTS idx_geffects_user ON game_effects(user_id);
-
-CREATE TABLE IF NOT EXISTS game_battle_stats (
- user_id INTEGER PRIMARY KEY,
- battles_won INTEGER DEFAULT 0,
- battles_lost INTEGER DEFAULT 0,
- total_stolen INTEGER DEFAULT 0,
- total_lost INTEGER DEFAULT 0,
- kill_streak INTEGER DEFAULT 0,
- best_streak INTEGER DEFAULT 0,
- kills INTEGER DEFAULT 0,
- deaths INTEGER DEFAULT 0,
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
-);
-
-CREATE TABLE IF NOT EXISTS game_dungeon_runs (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- leader_id INTEGER NOT NULL,
- floor_reached INTEGER DEFAULT 1,
- status TEXT DEFAULT 'active' CHECK(status IN ('active','completed','failed','abandoned')),
- party_data TEXT DEFAULT '[]',
- started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- ended_at DATETIME,
- FOREIGN KEY (leader_id) REFERENCES users(id) ON DELETE CASCADE
-);
-
-CREATE TABLE IF NOT EXISTS game_leaderboard (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER NOT NULL,
- board_type TEXT NOT NULL,
- score INTEGER DEFAULT 0,
- rank INTEGER DEFAULT 0,
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- UNIQUE(user_id, board_type),
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
-);
-
-CREATE TABLE IF NOT EXISTS game_fish_collection (
- user_id INTEGER NOT NULL,
- fish_id TEXT NOT NULL,
- times_caught INTEGER DEFAULT 1,
- max_weight REAL DEFAULT 0,
- first_caught DATETIME DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (user_id, fish_id),
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
-);
-CREATE INDEX IF NOT EXISTS idx_fish_coll_user ON game_fish_collection(user_id);
-
-CREATE TABLE IF NOT EXISTS game_daily_quest_progress (
- user_id INTEGER NOT NULL,
- quest_date TEXT NOT NULL,
- stat_key TEXT NOT NULL,
- value REAL DEFAULT 0,
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (user_id, quest_date, stat_key),
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
-);
-CREATE INDEX IF NOT EXISTS idx_daily_quest_progress_user_date ON game_daily_quest_progress(user_id, quest_date);
-
-CREATE TABLE IF NOT EXISTS game_daily_quest_claims (
- user_id INTEGER NOT NULL,
- quest_date TEXT NOT NULL,
- quest_id TEXT NOT NULL,
- claimed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (user_id, quest_date, quest_id),
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
-);
-CREATE INDEX IF NOT EXISTS idx_daily_quest_claims_user_date ON game_daily_quest_claims(user_id, quest_date);
deleted file mode 100644
deleted file mode 100644
@@ -1,334 +0,0 @@
@@ -1,334 +0,0 @@
-/**
- * OpenVibeGame — Tags System
- * Modular chat tags that display next to usernames.
- * Users can own multiple tags, equip one at a time, and buy new tags
- * from the Tag Master NPC after defeating the Tag Guardian.
- */
-const db = require('../db/database');
-
-// ── Tag Catalog ──────────────────────────────────────────────
-// Tags are visual badges shown next to usernames in chat.
-// category: 'special' = cannot be bought, only granted
-// 'shop' = purchasable from Tag Master NPC
-// 'achievement' = earned via game milestones
-const TAGS = {
- // ── Special (granted, not purchasable) ───────────────────
- cfo: { name: 'CFO', emoji: '💼', color: '#f59e0b', bgColor: '#451a03', desc: 'Chief Financial Officer — legacy RS-Companion VIP', category: 'special', tier: 5 },
- founder: { name: 'Founder', emoji: '⭐', color: '#facc15', bgColor: '#422006', desc: 'Original OpenVibe.Live founder', category: 'special', tier: 5 },
- legacy: { name: 'Legacy', emoji: '🏛️', color: '#a78bfa', bgColor: '#1e1b4b', desc: 'Migrated from RS-Companion', category: 'special', tier: 3 },
- alpha_tester: { name: 'Alpha Tester', emoji: '🧪', color: '#34d399', bgColor: '#022c22', desc: 'Tested during alpha phase', category: 'special', tier: 3 },
- mod: { name: 'Mod', emoji: '🛡️', color: '#60a5fa', bgColor: '#172554', desc: 'Platform moderator', category: 'special', tier: 4 },
- admin: { name: 'Admin', emoji: '⚡', color: '#f87171', bgColor: '#450a0a', desc: 'Platform administrator', category: 'special', tier: 5 },
- streamer: { name: 'Streamer', emoji: '📡', color: '#c084fc', bgColor: '#3b0764', desc: 'Verified streamer', category: 'special', tier: 4 },
- vip: { name: 'VIP', emoji: '👑', color: '#fbbf24', bgColor: '#78350f', desc: 'Very Important Person', category: 'special', tier: 4 },
- developer: { name: 'Dev', emoji: '🔧', color: '#38bdf8', bgColor: '#0c4a6e', desc: 'Platform developer', category: 'special', tier: 5 },
-
- // ── Shop Tags (purchasable from Tag Master NPC) ──────────
- noob: { name: 'Noob', emoji: '🐣', color: '#86efac', bgColor: '#052e16', desc: 'Everyone starts somewhere', category: 'shop', tier: 1, cost: 50 },
- chatterbox: { name: 'Chatterbox', emoji: '💬', color: '#93c5fd', bgColor: '#1e3a5f', desc: 'Professional yapper', category: 'shop', tier: 1, cost: 100 },
- warrior: { name: 'Warrior', emoji: '⚔️', color: '#fca5a5', bgColor: '#450a0a', desc: 'Battle-hardened fighter', category: 'shop', tier: 2, cost: 250 },
- miner: { name: 'Miner', emoji: '⛏️', color: '#d4d4d8', bgColor: '#27272a', desc: 'Deep rock delver', category: 'shop', tier: 2, cost: 250 },
- angler: { name: 'Angler', emoji: '🎣', color: '#7dd3fc', bgColor: '#0c4a6e', desc: 'Master of the rod', category: 'shop', tier: 2, cost: 250 },
- lumberjack: { name: 'Lumberjack', emoji: '🪓', color: '#a3e635', bgColor: '#1a2e05', desc: 'Timber specialist', category: 'shop', tier: 2, cost: 250 },
- farmer: { name: 'Farmer', emoji: '🌾', color: '#fde68a', bgColor: '#451a03', desc: 'Crop connoisseur', category: 'shop', tier: 2, cost: 250 },
- chef: { name: 'Chef', emoji: '👨🍳', color: '#fdba74', bgColor: '#431407', desc: 'Culinary master', category: 'shop', tier: 2, cost: 300 },
- merchant: { name: 'Merchant', emoji: '🏪', color: '#fcd34d', bgColor: '#78350f', desc: 'Shrewd trader', category: 'shop', tier: 3, cost: 500 },
- explorer: { name: 'Explorer', emoji: '🧭', color: '#6ee7b7', bgColor: '#022c22', desc: 'Seen it all', category: 'shop', tier: 3, cost: 500 },
- assassin: { name: 'Assassin', emoji: '🗡️', color: '#a78bfa', bgColor: '#1e1b4b', desc: 'Silent and deadly', category: 'shop', tier: 3, cost: 750 },
- overlord: { name: 'Overlord', emoji: '🔱', color: '#e879f9', bgColor: '#4a044e', desc: 'Rules with an iron fist', category: 'shop', tier: 4, cost: 1500 },
- legend: { name: 'Legend', emoji: '🏆', color: '#fbbf24', bgColor: '#451a03', desc: 'Etched in history', category: 'shop', tier: 5, cost: 5000 },
- void_walker: { name: 'Void Walker', emoji: '🕳️', color: '#818cf8', bgColor: '#0f0f23', desc: 'From beyond the abyss', category: 'shop', tier: 5, cost: 10000 },
-
- // ── Achievement Tags (earned, not purchasable) ───────────
- dragon_slayer: { name: 'Dragonslayer', emoji: '🐉', color: '#ef4444', bgColor: '#450a0a', desc: 'Slew a dragon', category: 'achievement', tier: 4 },
- dungeon_master: { name: 'Dungeon Master',emoji: '🏰', color: '#c084fc', bgColor: '#3b0764', desc: 'Won 50 dungeon fights', category: 'achievement', tier: 4 },
- fish_lord: { name: 'Fish Lord', emoji: '🐟', color: '#22d3ee', bgColor: '#083344', desc: 'Caught every fish species', category: 'achievement', tier: 4 },
- big_spender: { name: 'Big Spender', emoji: '💰', color: '#fbbf24', bgColor: '#78350f', desc: 'Spent 50,000 gold total', category: 'achievement', tier: 3 },
- survivor: { name: 'Survivor', emoji: '💪', color: '#4ade80', bgColor: '#052e16', desc: 'Won 100 PvP battles', category: 'achievement', tier: 4 },
-};
-
-// ── Tag Guardian (NPC boss you must defeat to access the tag shop) ──
-const TAG_GUARDIAN = {
- name: 'Tag Guardian',
- emoji: '🏷️',
- hp: 80,
- atk: 18,
- def: 8,
- xp: 50,
- gold: 30,
- desc: 'A mysterious warrior who guards the Tag Master\'s shop. Defeat them to prove your worth.',
- combatLevel: 3, // minimum combat level to challenge
-};
-
-// ── Ensure DB tables ─────────────────────────────────────────
-function ensureTagTables() {
- const d = db.getDb();
- d.exec(`
- CREATE TABLE IF NOT EXISTS user_tags (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER NOT NULL,
- tag_id TEXT NOT NULL,
- source TEXT DEFAULT 'shop',
- granted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- UNIQUE(user_id, tag_id),
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
- );
- CREATE TABLE IF NOT EXISTS user_equipped_tag (
- user_id INTEGER NOT NULL PRIMARY KEY,
- tag_id TEXT NOT NULL,
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
- );
- CREATE TABLE IF NOT EXISTS tag_guardian_defeats (
- user_id INTEGER NOT NULL PRIMARY KEY,
- defeated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
- );
- `);
- try { d.exec('CREATE INDEX IF NOT EXISTS idx_user_tags_user ON user_tags(user_id)'); } catch { }
-}
-
-// ── Tag Guardian Combat ──────────────────────────────────────
-
-/**
- * Check if the user has already defeated the Tag Guardian.
- */
-function hasDefeatedGuardian(userId) {
- const d = db.getDb();
- return !!d.prepare('SELECT 1 FROM tag_guardian_defeats WHERE user_id = ?').get(userId);
-}
-
-/**
- * Fight the Tag Guardian. Turn-based combat similar to dungeon monsters.
- * Returns { success, won, log, xp?, gold? }
- */
-function fightGuardian(userId) {
- const d = db.getDb();
-
- if (hasDefeatedGuardian(userId)) {
- return { error: 'You have already defeated the Tag Guardian. The shop is open to you.' };
- }
-
- // Check combat level
- const player = d.prepare('SELECT hp, attack, defense, combat_xp FROM game_players WHERE user_id = ?').get(userId);
- if (!player) return { error: 'No game character found. Enter the game first.' };
-
- const combatLevel = xpToLevel(player.combat_xp);
- if (combatLevel < TAG_GUARDIAN.combatLevel) {
- return { error: `Need Combat Lv ${TAG_GUARDIAN.combatLevel} to challenge the Tag Guardian. (You're Lv ${combatLevel})` };
- }
-
- const guardian = { ...TAG_GUARDIAN };
- // Scale slightly with player level for fairness
- const scale = 1 + (combatLevel - 1) * 0.03;
- guardian.hp = Math.round(guardian.hp * scale);
- guardian.atk = Math.round(guardian.atk * scale);
-
- let playerHp = player.hp;
- let guardianHp = guardian.hp;
- const log = [];
- let round = 0;
-
- while (playerHp > 0 && guardianHp > 0 && round < 20) {
- round++;
- // Player attacks
- const pDmg = Math.max(1, Math.round(player.attack * (0.8 + Math.random() * 0.4) - guardian.def * 0.3));
- guardianHp -= pDmg;
- log.push({ type: 'player_attack', dmg: pDmg, guardianHp: Math.max(0, guardianHp) });
- if (guardianHp <= 0) break;
- // Guardian attacks
- const gDmg = Math.max(1, Math.round(guardian.atk * (0.8 + Math.random() * 0.4) - player.defense * 0.3));
- playerHp -= gDmg;
- log.push({ type: 'guardian_attack', dmg: gDmg, playerHp: Math.max(0, playerHp) });
- }
-
- // Update player HP
- d.prepare('UPDATE game_players SET hp = ? WHERE user_id = ?').run(Math.max(0, playerHp), userId);
-
- if (guardianHp <= 0) {
- // Victory — record the defeat and grant XP/gold
- d.prepare('INSERT OR IGNORE INTO tag_guardian_defeats (user_id) VALUES (?)').run(userId);
- // Grant XP via direct update (combat_xp)
- d.prepare('UPDATE game_players SET combat_xp = combat_xp + ? WHERE user_id = ?').run(guardian.xp, userId);
- // Gold is OpenCoins now — credit the network wallet (idempotent: one-time defeat).
- require('../monetization/wallet-client')
- .credit(userId, guardian.gold, 'Tag Guardian defeated', `live:guardian_defeat:${userId}`)
- .catch((e) => console.warn('[Tags] guardian gold credit failed:', e.message));
-
- return {
- success: true,
- won: true,
- log,
- xp: guardian.xp,
- gold: guardian.gold,
- message: 'The Tag Guardian falls! The Tag Master\'s shop is now open to you. 🏷️',
- };
- }
-
- // Defeat — player can try again after healing
- return {
- success: true,
- won: false,
- log,
- message: 'The Tag Guardian overwhelms you... heal up and try again.',
- };
-}
-
-// ── Tag Queries ──────────────────────────────────────────────
-
-/**
- * Get all tags owned by a user.
- */
-function getUserTags(userId) {
- const d = db.getDb();
- const rows = d.prepare('SELECT tag_id, source, granted_at FROM user_tags WHERE user_id = ?').all(userId);
- return rows.map(r => ({
- ...r,
- ...(TAGS[r.tag_id] || { name: r.tag_id, emoji: '🏷️', color: '#999', bgColor: '#333', desc: 'Unknown tag', category: 'special', tier: 0 }),
- tagId: r.tag_id,
- }));
-}
-
-/**
- * Get the user's currently equipped tag (or null).
- */
-function getEquippedTag(userId) {
- const d = db.getDb();
- const row = d.prepare('SELECT tag_id FROM user_equipped_tag WHERE user_id = ?').get(userId);
- if (!row) return null;
- const tag = TAGS[row.tag_id];
- if (!tag) return null;
- return { tagId: row.tag_id, ...tag };
-}
-
-/**
- * Get tag profile for chat message attachment.
- * Returns { tagId, name, emoji, color, bgColor } or null.
- */
-function getTagProfile(userId) {
- return getEquippedTag(userId);
-}
-
-/**
- * Grant a tag to a user (for special/achievement tags).
- */
-function grantTag(userId, tagId, source = 'system') {
- const tag = TAGS[tagId];
- if (!tag) return { error: 'Unknown tag' };
- const d = db.getDb();
- try {
- d.prepare('INSERT OR IGNORE INTO user_tags (user_id, tag_id, source) VALUES (?, ?, ?)').run(userId, tagId, source);
- return { success: true, tag: { tagId, ...tag } };
- } catch (err) {
- return { error: err.message };
- }
-}
-
-/**
- * Revoke a tag from a user.
- */
-function revokeTag(userId, tagId) {
- const d = db.getDb();
- // Unequip if currently equipped
- d.prepare('DELETE FROM user_equipped_tag WHERE user_id = ? AND tag_id = ?').run(userId, tagId);
- d.prepare('DELETE FROM user_tags WHERE user_id = ? AND tag_id = ?').run(userId, tagId);
- return { success: true };
-}
-
-/**
- * Buy a tag from the Tag Master shop.
- * Requires: guardian defeated, tag is shop category, enough gold, not already owned.
- */
-async function buyTag(userId, tagId) {
- const tag = TAGS[tagId];
- if (!tag) return { error: 'Unknown tag' };
- if (tag.category !== 'shop') return { error: 'This tag cannot be purchased' };
- if (!tag.cost) return { error: 'This tag has no price' };
-
- if (!hasDefeatedGuardian(userId)) {
- return { error: 'You must defeat the Tag Guardian before shopping here!' };
- }
-
- const d = db.getDb();
- const existing = d.prepare('SELECT 1 FROM user_tags WHERE user_id = ? AND tag_id = ?').get(userId, tagId);
- if (existing) return { error: 'You already own this tag' };
-
- // Spend from the network OpenCoins wallet (idempotent per user+tag).
- try {
- const wallet = require('../monetization/wallet-client');
- const result = await wallet.debit(userId, tag.cost, `Tag purchase: ${tagId}`, `live:tag_purchase:${userId}:${tagId}`);
- if (!result) return { error: 'OpenCoins wallet unavailable — link your OpenVibe.Network account' };
- } catch (e) {
- if (e && e.status === 409) return { error: `Not enough OpenCoins. Need ${tag.cost}.` };
- return { error: 'Purchase failed — wallet unavailable' };
- }
-
- d.prepare('INSERT INTO user_tags (user_id, tag_id, source) VALUES (?, ?, ?)').run(userId, tagId, 'shop');
- return { success: true, tag: { tagId, ...tag }, cost: tag.cost };
-}
-
-/**
- * Equip a tag (must own it).
- */
-function equipTag(userId, tagId) {
- const tag = TAGS[tagId];
- if (!tag) return { error: 'Unknown tag' };
-
- const d = db.getDb();
- const owned = d.prepare('SELECT 1 FROM user_tags WHERE user_id = ? AND tag_id = ?').get(userId, tagId);
- if (!owned) return { error: 'You don\'t own this tag' };
-
- d.prepare('INSERT OR REPLACE INTO user_equipped_tag (user_id, tag_id) VALUES (?, ?)').run(userId, tagId);
- return { success: true, tag: { tagId, ...tag } };
-}
-
-/**
- * Unequip the current tag.
- */
-function unequipTag(userId) {
- const d = db.getDb();
- d.prepare('DELETE FROM user_equipped_tag WHERE user_id = ?').run(userId);
- return { success: true };
-}
-
-/**
- * Get the shop catalog (only shop-category tags).
- */
-function getShopTags() {
- return Object.entries(TAGS)
- .filter(([_, t]) => t.category === 'shop')
- .map(([id, t]) => ({ tagId: id, ...t }))
- .sort((a, b) => a.cost - b.cost);
-}
-
-/**
- * Get all tags for the catalog/wiki view (all categories).
- */
-function getAllTags() {
- return Object.entries(TAGS).map(([id, t]) => ({ tagId: id, ...t }));
-}
-
-// ── XP helper (duplicated locally to avoid circular dep) ─────
-function xpToLevel(xp) {
- return Math.floor(Math.sqrt((xp || 0) / 25)) + 1;
-}
-
-module.exports = {
- TAGS,
- TAG_GUARDIAN,
- ensureTagTables,
- // Guardian
- hasDefeatedGuardian,
- fightGuardian,
- // Tag CRUD
- getUserTags,
- getEquippedTag,
- getTagProfile,
- grantTag,
- revokeTag,
- buyTag,
- equipTag,
- unequipTag,
- // Shop
- getShopTags,
- getAllTags,
-};
@@ -1107,9 +1107,8 @@ async function start() {
@@ -1107,9 +1107,8 @@ async function start() {
db.initDb();
db.initDb();
// Initialize cosmetics tables
// Initialize cosmetics tables
cosmeticsModule.ensureTables();
cosmeticsModule.ensureTables();
- // Initialize tags tables
- const tagsModule = require('./game/tags');
- tagsModule.ensureTagTables();
+ // Chat tag tables (read-only tags, server/chat/tags.js)
+ require('./chat/tags').ensureTagTables();
// Initialize DM tables
// Initialize DM tables
const dm = require('./chat/dm');
const dm = require('./chat/dm');
dm.ensureTables();
dm.ensureTables();
@@ -1573,7 +1572,7 @@ async function start() {
@@ -1573,7 +1572,7 @@ async function start() {
function startDrill() {
function startDrill() {
db.initDb();
db.initDb();
cosmeticsModule.ensureTables();
cosmeticsModule.ensureTables();
- require('./game/tags').ensureTagTables();
+ require('./chat/tags').ensureTagTables();
require('./chat/dm').ensureTables();
require('./chat/dm').ensureTables();
console.log(`[Drill] Database ready: ${paths.dbPath()}`);
console.log(`[Drill] Database ready: ${paths.dbPath()}`);
// Its port taken: stop (the process-wide handler would log EADDRINUSE and keep running unready).
// Its port taken: stop (the process-wide handler would log EADDRINUSE and keep running unready).
new file mode 100644
new file mode 100644
@@ -0,0 +1,112 @@
@@ -0,0 +1,112 @@
+/**
+ * Legacy HoboQuest remnants are read-only in Live.
+ *
+ * OpenVibe.Games owns the game (and imports Live's legacy game_* rows). Live's chat profile card
+ * used the old game engine's getPlayer(), which INSERTed a placeholder game_players row for every
+ * profile anyone opened (production had 70, the newest days old). The engine, its item tables and
+ * the RS-Companion import script are gone; the profile card reads db.getLegacyGameProfile(), which
+ * never writes, and chat tags come from the read-only server/chat/tags.js.
+ *
+ * node test/legacy-game-readonly.test.js
+ */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+const ROOT = path.join(__dirname, '..');
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-legacy-game-'));
+process.env.DB_PATH = path.join(tmp, 'live.db');
+process.env.NODE_ENV = 'test';
+const quiet = console.log;
+console.log = () => {};
+console.warn = () => {};
+
+const db = require('../server/db/database');
+db.initDb();
+const raw = db.getDb();
+const auth = require('../server/auth/auth');
+auth.optionalAuth = (req, res, next) => next();
+auth.requireAuth = (req, res) => res.status(401).json({ error: 'Authentication required' });
+
+for (const [id, name] of [[1, 'veteran'], [2, 'newcomer']]) {
+ raw.prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role) VALUES (?, ?, ?, ?, 'x', 'user')`).run(id, name, name, `${name}@x`);
+}
+
+(async () => {
+ // A fresh install has no game_players table at all: nothing to show, nothing created.
+ assert.strictEqual(db.getLegacyGameProfile(2), null);
+ assert.ok(!raw.prepare("SELECT 1 FROM sqlite_master WHERE name = 'game_players'").get(), 'Live never creates the game tables');
+
+ // Production has the legacy table (the shape the old schema created, without smithing_xp).
+ raw.exec(`CREATE TABLE game_players (user_id INTEGER PRIMARY KEY, x REAL, y REAL, mining_xp INTEGER DEFAULT 0, fishing_xp INTEGER DEFAULT 0,
+ woodcut_xp INTEGER DEFAULT 0, farming_xp INTEGER DEFAULT 0, combat_xp INTEGER DEFAULT 0, crafting_xp INTEGER DEFAULT 0,
+ agility_xp INTEGER DEFAULT 0, total_coins_earned INTEGER DEFAULT 0)`);
+ raw.prepare('INSERT INTO game_players (user_id, mining_xp, combat_xp, total_coins_earned) VALUES (1, 2500, 100, 42)').run();
+ const count = () => raw.prepare('SELECT COUNT(*) AS n FROM game_players').get().n;
+
+ const g = db.getLegacyGameProfile(1);
+ assert.strictEqual(g.mining_level, 11, 'levels use the game\'s formula (sqrt(xp/25)+1)');
+ assert.strictEqual(g.combat_level, 3);
+ assert.strictEqual(g.smithing_level, 1, 'a column the old table lacks counts as level 1');
+ assert.strictEqual(g.total_level, 11 + 3 + 6, 'eight skills summed, like the old getPlayer()');
+ assert.strictEqual(g.total_coins_earned, 42);
+
+ // The chat profile card (Live's route and the one OpenVibe.Chat reads) never adds a row.
+ const express = require('express');
+ const app = express();
+ app.use('/api/chat', require('../server/chat/routes'));
+ const server = http.createServer(app).listen(0);
+ const get = (p) => new Promise((resolve, reject) => {
+ http.get({ port: server.address().port, path: p }, (res) => {
+ let text = '';
+ res.on('data', (c) => { text += c; });
+ res.on('end', () => resolve({ status: res.statusCode, json: JSON.parse(text || 'null') }));
+ }).on('error', reject);
+ });
+ let r = await get('/api/chat/user/newcomer/profile');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.json.game, undefined, 'no legacy game block for someone who never played');
+ assert.strictEqual(count(), 1, 'and viewing their profile created no game_players row');
+ r = await get('/api/chat/user/veteran/profile');
+ assert.strictEqual(r.json.game.total_level, 20, 'a legacy player still shows their skills');
+ assert.strictEqual(count(), 1);
+ server.close();
+
+ // Chat tags: read-only, and still shown.
+ const tags = require('../server/chat/tags');
+ tags.ensureTagTables();
+ raw.prepare("INSERT INTO user_tags (user_id, tag_id, source) VALUES (1, 'legacy', 'migration')").run();
+ raw.prepare("INSERT INTO user_equipped_tag (user_id, tag_id) VALUES (1, 'legacy')").run();
+ assert.strictEqual(tags.getTagProfile(1).name, 'Legacy');
+ assert.strictEqual(tags.getTagProfile(2), null);
+ for (const writer of ['grantTag', 'revokeTag', 'buyTag', 'equipTag', 'unequipTag', 'fightGuardian']) {
+ assert.strictEqual(tags[writer], undefined, `tags.${writer} is gone (the game owns tag grants now)`);
+ }
+
+ // The engine is gone and nothing reaches for it.
+ assert.ok(!fs.existsSync(path.join(ROOT, 'server/game')), 'server/game/ is retired');
+ const offenders = [];
+ const walk = (dir) => {
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+ const p = path.join(dir, e.name);
+ if (e.isDirectory()) { if (e.name !== 'node_modules') walk(p); } else if (e.name.endsWith('.js') && /require\(['"][./]*\/?game\/(game-engine|tags|items|cosmetics)['"]\)/.test(fs.readFileSync(p, 'utf8'))) offenders.push(path.relative(ROOT, p));
+ }
+ };
+ walk(path.join(ROOT, 'server'));
+ walk(path.join(ROOT, 'scripts'));
+ assert.deepStrictEqual(offenders, []);
+ const serverSrc = [];
+ const collect = (dir) => { for (const e of fs.readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, e.name); if (e.isDirectory()) collect(p); else if (e.name.endsWith('.js')) serverSrc.push(fs.readFileSync(p, 'utf8')); } };
+ collect(path.join(ROOT, 'server'));
+ assert.ok(!serverSrc.some((s) => /INSERT[^;`'"]*INTO\s+game_players/i.test(s)), 'no server code inserts game_players rows');
+
+ fs.rmSync(tmp, { recursive: true, force: true });
+ quiet('legacy-game-readonly: ok');
+ process.exit(0);
+})().catch((err) => {
+ quiet(err);
+ process.exit(1);
+});
deleted file mode 100644
deleted file mode 100644
@@ -1,339 +0,0 @@
@@ -1,339 +0,0 @@
-/**
- * Tags System — Unit Tests
- *
- * Stand-alone test file using Node assert.
- * Creates a temporary SQLite DB in /tmp/ so no production data is touched.
- *
- * Run: node test/test-tags.js
- */
-
-const assert = require('assert');
-const path = require('path');
-const fs = require('fs');
-
-// ── Set up a temporary DB before requiring any modules ───────
-const tmpDb = `/tmp/openvibe-tags-test-${Date.now()}.db`;
-process.env.DB_PATH = tmpDb;
-
-const Database = require('better-sqlite3');
-
-// We need to bootstrap the minimum schema that the tags module expects:
-// users table + game_players table
-function setupTestDb() {
- const d = new Database(tmpDb);
- d.pragma('journal_mode = WAL');
- d.pragma('foreign_keys = ON');
- d.exec(`
- CREATE TABLE IF NOT EXISTS users (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- username TEXT UNIQUE NOT NULL,
- display_name TEXT,
- openvibe_coins_balance INTEGER DEFAULT 0,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
- );
- CREATE TABLE IF NOT EXISTS game_players (
- user_id INTEGER PRIMARY KEY,
- display_name TEXT,
- hp INTEGER DEFAULT 100,
- attack INTEGER DEFAULT 10,
- defense INTEGER DEFAULT 5,
- combat_xp INTEGER DEFAULT 0,
- FOREIGN KEY (user_id) REFERENCES users(id)
- );
- `);
- d.close();
-}
-
-setupTestDb();
-
-// Now require the modules (they'll use the test DB via DB_PATH env)
-const db = require('../server/db/database');
-db.initDb(); // runs schema.sql — adds full platform tables on top of our seed
-
-const tags = require('../server/game/tags');
-tags.ensureTagTables();
-
-// ── Test Helpers ─────────────────────────────────────────────
-let passed = 0;
-let failed = 0;
-const errors = [];
-
-function test(name, fn) {
- try {
- fn();
- passed++;
- console.log(` ✅ ${name}`);
- } catch (e) {
- failed++;
- errors.push({ name, error: e });
- console.log(` ❌ ${name}: ${e.message}`);
- }
-}
-
-function describe(section, fn) {
- console.log(`\n─── ${section} ───`);
- fn();
-}
-
-// ── Create test users ────────────────────────────────────────
-function createTestUser(username, coins = 0) {
- const d = db.getDb();
- d.prepare('INSERT INTO users (username, display_name, openvibe_coins_balance) VALUES (?, ?, ?)').run(username, username, coins);
- const user = d.prepare('SELECT id FROM users WHERE username = ?').get(username);
- d.prepare('INSERT OR IGNORE INTO game_players (user_id, display_name, combat_xp) VALUES (?, ?, ?)').run(user.id, username, 0);
- return user.id;
-}
-
-// ══════════════════════════════════════════════════════════════
-// TESTS
-// ══════════════════════════════════════════════════════════════
-
-const userId1 = createTestUser('TestUser1', 10000);
-const userId2 = createTestUser('TestUser2', 100);
-const userId3 = createTestUser('PatrickTest', 5000);
-
-describe('Tag Catalog', () => {
- test('TAGS object has expected entries', () => {
- assert.ok(tags.TAGS.cfo, 'CFO tag exists');
- assert.ok(tags.TAGS.noob, 'Noob tag exists');
- assert.ok(tags.TAGS.legend, 'Legend tag exists');
- assert.ok(tags.TAGS.legacy, 'Legacy tag exists');
- });
-
- test('getShopTags returns only shop-category tags', () => {
- const shopTags = tags.getShopTags();
- assert.ok(shopTags.length > 0, 'Some shop tags exist');
- for (const t of shopTags) {
- assert.strictEqual(t.category, 'shop', `${t.tagId} is shop category`);
- assert.ok(typeof t.cost === 'number' && t.cost > 0, `${t.tagId} has a price`);
- }
- });
-
- test('getAllTags returns all categories', () => {
- const all = tags.getAllTags();
- const categories = new Set(all.map(t => t.category));
- assert.ok(categories.has('special'), 'Has special tags');
- assert.ok(categories.has('shop'), 'Has shop tags');
- assert.ok(categories.has('achievement'), 'Has achievement tags');
- });
-
- test('shop tags are sorted by cost ascending', () => {
- const shopTags = tags.getShopTags();
- for (let i = 1; i < shopTags.length; i++) {
- assert.ok(shopTags[i].cost >= shopTags[i - 1].cost, 'Sorted by cost');
- }
- });
-});
-
-describe('Grant & Equip', () => {
- test('grantTag gives a tag to a user', () => {
- const result = tags.grantTag(userId1, 'cfo', 'test');
- assert.ok(result.success, 'Grant succeeded');
- assert.strictEqual(result.tag.tagId, 'cfo');
- });
-
- test('getUserTags returns the granted tag', () => {
- const owned = tags.getUserTags(userId1);
- assert.strictEqual(owned.length, 1);
- assert.strictEqual(owned[0].tagId, 'cfo');
- });
-
- test('grantTag is idempotent (INSERT OR IGNORE)', () => {
- const result = tags.grantTag(userId1, 'cfo', 'test');
- assert.ok(result.success);
- const owned = tags.getUserTags(userId1);
- assert.strictEqual(owned.length, 1, 'Still only 1 tag');
- });
-
- test('equipTag works', () => {
- const result = tags.equipTag(userId1, 'cfo');
- assert.ok(result.success);
- assert.strictEqual(result.tag.name, 'CFO');
- });
-
- test('getEquippedTag returns the equipped tag', () => {
- const eq = tags.getEquippedTag(userId1);
- assert.ok(eq, 'Tag is equipped');
- assert.strictEqual(eq.tagId, 'cfo');
- assert.strictEqual(eq.name, 'CFO');
- assert.strictEqual(eq.emoji, '💼');
- });
-
- test('getTagProfile returns the same as getEquippedTag', () => {
- const profile = tags.getTagProfile(userId1);
- assert.ok(profile);
- assert.strictEqual(profile.tagId, 'cfo');
- });
-
- test('equipTag fails if user doesn\'t own the tag', () => {
- const result = tags.equipTag(userId2, 'cfo');
- assert.ok(result.error, 'Should fail');
- assert.ok(result.error.includes("don't own"), result.error);
- });
-
- test('unequipTag removes equipped tag', () => {
- const result = tags.unequipTag(userId1);
- assert.ok(result.success);
- const eq = tags.getEquippedTag(userId1);
- assert.strictEqual(eq, null, 'No tag equipped');
- });
-
- test('revokeTag removes a tag', () => {
- tags.equipTag(userId1, 'cfo'); // re-equip first
- const result = tags.revokeTag(userId1, 'cfo');
- assert.ok(result.success);
- const owned = tags.getUserTags(userId1);
- assert.strictEqual(owned.length, 0, 'No tags owned');
- const eq = tags.getEquippedTag(userId1);
- assert.strictEqual(eq, null, 'Unequipped on revoke');
- });
-});
-
-describe('Tag Shop (Buy)', () => {
- test('buyTag fails without defeating guardian', () => {
- const result = tags.buyTag(userId1, 'noob');
- assert.ok(result.error);
- assert.ok(result.error.includes('Tag Guardian'), result.error);
- });
-
- test('manually mark guardian defeated, then buy works', () => {
- const d = db.getDb();
- d.prepare('INSERT OR IGNORE INTO tag_guardian_defeats (user_id) VALUES (?)').run(userId1);
-
- const result = tags.buyTag(userId1, 'noob');
- assert.ok(result.success, 'Buy succeeded');
- assert.strictEqual(result.tag.tagId, 'noob');
- assert.strictEqual(result.cost, 50);
- });
-
- test('buying deducts coins', () => {
- const user = db.getUserById(userId1);
- assert.strictEqual(user.openvibe_coins_balance, 10000 - 50);
- });
-
- test('buyTag fails if already owned', () => {
- const result = tags.buyTag(userId1, 'noob');
- assert.ok(result.error);
- assert.ok(result.error.includes('already own'), result.error);
- });
-
- test('buyTag fails for special category tags', () => {
- const d = db.getDb();
- d.prepare('INSERT OR IGNORE INTO tag_guardian_defeats (user_id) VALUES (?)').run(userId2);
-
- const result = tags.buyTag(userId2, 'cfo');
- assert.ok(result.error);
- assert.ok(result.error.includes('cannot be purchased'), result.error);
- });
-
- test('buyTag fails with insufficient gold', () => {
- const result = tags.buyTag(userId2, 'legend'); // costs 5000, user2 has 100
- assert.ok(result.error);
- assert.ok(result.error.includes('Not enough'), result.error);
- });
-
- test('buyTag fails for unknown tagId', () => {
- const result = tags.buyTag(userId1, 'nonexistent_tag');
- assert.ok(result.error);
- assert.ok(result.error.includes('Unknown'), result.error);
- });
-});
-
-describe('Tag Guardian Combat', () => {
- test('fightGuardian fails without game character', () => {
- // Create a user with no game_players row
- const d = db.getDb();
- d.prepare('INSERT INTO users (username, display_name, openvibe_coins_balance) VALUES (?, ?, ?)').run('NoGameChar', 'NoGameChar', 0);
- const noGameUser = d.prepare('SELECT id FROM users WHERE username = ?').get('NoGameChar');
-
- const result = tags.fightGuardian(noGameUser.id);
- assert.ok(result.error);
- assert.ok(result.error.includes('No game character'), result.error);
- });
-
- test('fightGuardian fails if combat level too low', () => {
- // userId2 has 0 combat_xp → level 1, needs level 3
- const result = tags.fightGuardian(userId2);
- assert.ok(result.error);
- assert.ok(result.error.includes('Combat Lv'), result.error);
- });
-
- test('fightGuardian works with sufficient combat level', () => {
- // Give userId3 enough combat XP for level 3: level = floor(sqrt(xp/25)) + 1
- // Level 3: floor(sqrt(xp/25)) = 2, sqrt(xp/25)>=2, xp/25>=4, xp>=100
- const d = db.getDb();
- d.prepare('UPDATE game_players SET combat_xp = 200, attack = 50, defense = 20 WHERE user_id = ?').run(userId3);
-
- const result = tags.fightGuardian(userId3);
- assert.ok(result.success, 'Fight initiated');
- assert.ok(result.log.length > 0, 'Has combat log');
- assert.ok(typeof result.won === 'boolean', 'Has won/lost result');
- });
-
- test('hasDefeatedGuardian returns correct status for user without defeat', () => {
- // userId2 never fought
- assert.strictEqual(tags.hasDefeatedGuardian(userId2), false);
- });
-
- test('fightGuardian returns already-defeated message if already won', () => {
- if (tags.hasDefeatedGuardian(userId3)) {
- const result = tags.fightGuardian(userId3);
- assert.ok(result.error);
- assert.ok(result.error.includes('already defeated'), result.error);
- }
- });
-});
-
-describe('Multiple Tags', () => {
- test('user can own multiple tags', () => {
- tags.grantTag(userId1, 'legacy', 'test');
- tags.grantTag(userId1, 'alpha_tester', 'test');
- const owned = tags.getUserTags(userId1);
- // Already owns noob from buy test
- assert.ok(owned.length >= 3, `Owns ${owned.length} tags`);
- });
-
- test('can switch between equipped tags', () => {
- tags.equipTag(userId1, 'noob');
- let eq = tags.getEquippedTag(userId1);
- assert.strictEqual(eq.tagId, 'noob');
-
- tags.equipTag(userId1, 'legacy');
- eq = tags.getEquippedTag(userId1);
- assert.strictEqual(eq.tagId, 'legacy');
-
- tags.equipTag(userId1, 'alpha_tester');
- eq = tags.getEquippedTag(userId1);
- assert.strictEqual(eq.tagId, 'alpha_tester');
- });
-});
-
-describe('TAG_GUARDIAN constant', () => {
- test('has required fields', () => {
- const g = tags.TAG_GUARDIAN;
- assert.ok(g.name, 'Has name');
- assert.ok(g.hp > 0, 'Has HP');
- assert.ok(g.atk > 0, 'Has attack');
- assert.ok(g.combatLevel > 0, 'Has combat level requirement');
- });
-});
-
-// ── Summary ──────────────────────────────────────────────────
-console.log(`\n═══════════════════════════════════════════`);
-console.log(` Tags Tests: ${passed} passed, ${failed} failed`);
-console.log(`═══════════════════════════════════════════`);
-
-if (errors.length > 0) {
- console.log('\nFailures:');
- for (const e of errors) {
- console.log(` ${e.name}:`);
- console.log(` ${e.error.stack || e.error.message}`);
- }
-}
-
-// Cleanup
-try { fs.unlinkSync(tmpDb); } catch {}
-try { fs.unlinkSync(tmpDb + '-wal'); } catch {}
-try { fs.unlinkSync(tmpDb + '-shm'); } catch {}
-
-process.exit(failed > 0 ? 1 : 0);