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

OP OpenVibers committed 24 Sept 2026 · ee70c40e62 · 13 files, +254 −4532


Changes

ddiff --git a/scripts/migrate-rs-companion.js b/scripts/migrate-rs-companion.js
ddeleted file mode 100644
iindex b34830f1..00000000
---- a/scripts/migrate-rs-companion.js
++++ /dev/null
@@@ -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();
--}
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex da22031c..ce485567 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -862,7 +862,7 @@ class ChatServer {
// Attach equipped tag for chat rendering
try {
-- 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 */ }
}
ddiff --git a/server/chat/live-context-routes.js b/server/chat/live-context-routes.js
iindex dbb6d79c..ed598ca1 100644
---- a/server/chat/live-context-routes.js
++++ b/server/chat/live-context-routes.js
@@@ -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;
ddiff --git a/server/chat/routes.js b/server/chat/routes.js
iindex 9effe0c0..fffd3120 100644
---- a/server/chat/routes.js
++++ b/server/chat/routes.js
@@@ -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 cosmetics
try {
-- 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 tag
try {
-- 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) {
ddiff --git a/server/chat/tags.js b/server/chat/tags.js
nnew file mode 100644
iindex 00000000..8c47ed07
---- /dev/null
++++ b/server/chat/tags.js
@@@ -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 };
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 1802cf5d..e0f817fb 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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 Settings
getChannelModerationSettings, upsertChannelModerationSettings,
-- getUserTotalGameLevel,
++ getUserTotalGameLevel, getLegacyGameProfile,
// Anon IP Mappings
getOrCreateAnonNum, getAnonFirstSeen, loadAnonMappings,
// Stream first chats (welcome messages)
ddiff --git a/server/game/game-engine.js b/server/game/game-engine.js
ddeleted file mode 100644
iindex 8ba12401..00000000
---- a/server/game/game-engine.js
++++ /dev/null
@@@ -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,
--};
ddiff --git a/server/game/items.js b/server/game/items.js
ddeleted file mode 100644
iindex 6d58fbc8..00000000
---- a/server/game/items.js
++++ /dev/null
@@@ -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,
--};
ddiff --git a/server/game/schema.sql b/server/game/schema.sql
ddeleted file mode 100644
iindex 5a1413c4..00000000
---- a/server/game/schema.sql
++++ /dev/null
@@@ -1,211 +0,0 @@
---- ═══════════════════════════════════════════════════════════════
---- OpenVibeGame — Rust-Style Survival Game Schema
---- Procedural world, base building, gathering, PvP, economy
---- ═══════════════════════════════════════════════════════════════
--
---- World state (seed, config)
--CREATE TABLE IF NOT EXISTS game_world_state (
-- key TEXT PRIMARY KEY,
-- value TEXT
--);
--
---- Player profile (1:1 with users)
--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
--);
--
---- Inventory
--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);
--
---- Bank storage (safe, no drop on death)
--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
--);
--
---- Structures (buildings in the world)
--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);
--
---- Farm plots (tile-based in game world)
--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
--);
--
---- Unlocked recipes
--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
--);
--
---- Active effects (buffs, cosmetics)
--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);
--
---- Battle stats
--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
--);
--
---- Dungeon runs
--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
--);
--
---- Leaderboard cache
--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
--);
--
---- Fish collection (Toontown-style album)
--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);
--
---- Daily quest progress and claims
--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);
ddiff --git a/server/game/tags.js b/server/game/tags.js
ddeleted file mode 100644
iindex 526a3eda..00000000
---- a/server/game/tags.js
++++ /dev/null
@@@ -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,
--};
ddiff --git a/server/index.js b/server/index.js
iindex 4ae36be2..fdbc941e 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -1107,9 +1107,8 @@ async function start() {
db.initDb();
// Initialize cosmetics tables
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
const 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).
ddiff --git a/test/legacy-game-readonly.test.js b/test/legacy-game-readonly.test.js
nnew file mode 100644
iindex 00000000..62faea5c
---- /dev/null
++++ b/test/legacy-game-readonly.test.js
@@@ -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);
++});
ddiff --git a/test/test-tags.js b/test/test-tags.js
ddeleted file mode 100644
iindex 4418f1c7..00000000
---- a/test/test-tags.js
++++ /dev/null
@@@ -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);