diff --git a/Dockerfile b/Dockerfile index af553f8..5eeeabf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,7 @@ RUN npm install COPY server.js ./ COPY database.js ./ COPY index.html ./ +COPY admin.html ./ COPY manifest.json ./ COPY service-worker.js ./ @@ -24,6 +25,9 @@ COPY .env* ./ # Copy PWA icons COPY icon-*.png ./ +# Copy monster images +COPY mapgameimgs ./mapgameimgs + # Copy .well-known directory for app verification COPY .well-known ./.well-known diff --git a/admin.html b/admin.html new file mode 100644 index 0000000..b1ab10f --- /dev/null +++ b/admin.html @@ -0,0 +1,1199 @@ + + + + + + HikeMap Admin + + + + + + + + + + + + + + + + + + diff --git a/database.js b/database.js index 20f62b2..b59172b 100644 --- a/database.js +++ b/database.js @@ -124,12 +124,35 @@ class HikeMapDB { level_scale_hp INTEGER NOT NULL, level_scale_atk INTEGER NOT NULL, level_scale_def INTEGER NOT NULL, + min_level INTEGER DEFAULT 1, + max_level INTEGER DEFAULT 5, + spawn_weight INTEGER DEFAULT 100, dialogues TEXT NOT NULL, enabled BOOLEAN DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `); + // Add columns if they don't exist (migration for existing databases) + try { + this.db.exec(`ALTER TABLE monster_types ADD COLUMN min_level INTEGER DEFAULT 1`); + } catch (e) { /* Column already exists */ } + try { + this.db.exec(`ALTER TABLE monster_types ADD COLUMN max_level INTEGER DEFAULT 5`); + } catch (e) { /* Column already exists */ } + try { + this.db.exec(`ALTER TABLE monster_types ADD COLUMN spawn_weight INTEGER DEFAULT 100`); + } catch (e) { /* Column already exists */ } + + // Game settings table - key/value store for game configuration + this.db.exec(` + CREATE TABLE IF NOT EXISTS game_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + // Create indexes for performance this.db.exec(` CREATE INDEX IF NOT EXISTS idx_geocache_finds_user ON geocache_finds(user_id); @@ -526,21 +549,38 @@ class HikeMapDB { createMonsterType(monsterData) { const stmt = this.db.prepare(` - INSERT INTO monster_types (id, name, icon, base_hp, base_atk, base_def, xp_reward, level_scale_hp, level_scale_atk, level_scale_def, dialogues, enabled) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO monster_types (id, name, icon, base_hp, base_atk, base_def, xp_reward, + level_scale_hp, level_scale_atk, level_scale_def, min_level, max_level, spawn_weight, dialogues, enabled) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); + // Support both camelCase (legacy) and snake_case (new admin UI) field names + const baseHp = monsterData.baseHp || monsterData.base_hp; + const baseAtk = monsterData.baseAtk || monsterData.base_atk; + const baseDef = monsterData.baseDef || monsterData.base_def; + const xpReward = monsterData.xpReward || monsterData.base_xp; + const levelScale = monsterData.levelScale || { hp: 10, atk: 2, def: 1 }; + const minLevel = monsterData.minLevel || monsterData.min_level || 1; + const maxLevel = monsterData.maxLevel || monsterData.max_level || 5; + const spawnWeight = monsterData.spawnWeight || monsterData.spawn_weight || 100; + const dialogues = typeof monsterData.dialogues === 'string' + ? monsterData.dialogues + : JSON.stringify(monsterData.dialogues); + return stmt.run( - monsterData.id, + monsterData.id || monsterData.key, monsterData.name, - monsterData.icon, - monsterData.baseHp, - monsterData.baseAtk, - monsterData.baseDef, - monsterData.xpReward, - monsterData.levelScale.hp, - monsterData.levelScale.atk, - monsterData.levelScale.def, - JSON.stringify(monsterData.dialogues), + monsterData.icon || '🟢', + baseHp, + baseAtk, + baseDef, + xpReward, + levelScale.hp, + levelScale.atk, + levelScale.def, + minLevel, + maxLevel, + spawnWeight, + dialogues, monsterData.enabled !== false ? 1 : 0 ); } @@ -550,20 +590,36 @@ class HikeMapDB { UPDATE monster_types SET name = ?, icon = ?, base_hp = ?, base_atk = ?, base_def = ?, xp_reward = ?, level_scale_hp = ?, level_scale_atk = ?, level_scale_def = ?, - dialogues = ?, enabled = ? + min_level = ?, max_level = ?, spawn_weight = ?, dialogues = ?, enabled = ? WHERE id = ? `); + // Support both camelCase (legacy) and snake_case (new admin UI) field names + const baseHp = monsterData.baseHp || monsterData.base_hp; + const baseAtk = monsterData.baseAtk || monsterData.base_atk; + const baseDef = monsterData.baseDef || monsterData.base_def; + const xpReward = monsterData.xpReward || monsterData.base_xp; + const levelScale = monsterData.levelScale || { hp: 10, atk: 2, def: 1 }; + const minLevel = monsterData.minLevel || monsterData.min_level || 1; + const maxLevel = monsterData.maxLevel || monsterData.max_level || 5; + const spawnWeight = monsterData.spawnWeight || monsterData.spawn_weight || 100; + const dialogues = typeof monsterData.dialogues === 'string' + ? monsterData.dialogues + : JSON.stringify(monsterData.dialogues); + return stmt.run( monsterData.name, - monsterData.icon, - monsterData.baseHp, - monsterData.baseAtk, - monsterData.baseDef, - monsterData.xpReward, - monsterData.levelScale.hp, - monsterData.levelScale.atk, - monsterData.levelScale.def, - JSON.stringify(monsterData.dialogues), + monsterData.icon || '🟢', + baseHp, + baseAtk, + baseDef, + xpReward, + levelScale.hp, + levelScale.atk, + levelScale.def, + minLevel, + maxLevel, + spawnWeight, + dialogues, monsterData.enabled !== false ? 1 : 0, id ); @@ -631,6 +687,107 @@ class HikeMapDB { console.log('Seeded default monster: Moop'); } + // Admin: Get all users with their RPG stats + getAllUsers() { + const stmt = this.db.prepare(` + SELECT u.id, u.username, u.email, u.created_at, u.total_points, u.finds_count, + u.avatar_icon, u.avatar_color, u.is_admin, + r.character_name, r.race, r.class, r.level, r.xp, r.hp, r.max_hp, + r.mp, r.max_mp, r.atk, r.def, r.unlocked_skills + FROM users u + LEFT JOIN rpg_stats r ON u.id = r.user_id + ORDER BY u.created_at DESC + `); + return stmt.all(); + } + + // Admin: Update user RPG stats + updateUserRpgStats(userId, stats) { + const stmt = this.db.prepare(` + UPDATE rpg_stats SET + level = ?, xp = ?, hp = ?, max_hp = ?, mp = ?, max_mp = ?, + atk = ?, def = ?, unlocked_skills = ?, updated_at = datetime('now') + WHERE user_id = ? + `); + const unlockedSkillsJson = stats.unlockedSkills ? JSON.stringify(stats.unlockedSkills) : '["basic_attack"]'; + return stmt.run( + stats.level || 1, + stats.xp || 0, + stats.hp || 100, + stats.maxHp || 100, + stats.mp || 50, + stats.maxMp || 50, + stats.atk || 12, + stats.def || 8, + unlockedSkillsJson, + userId + ); + } + + // Admin: Reset user RPG progress + resetUserProgress(userId) { + const stmt = this.db.prepare(` + UPDATE rpg_stats SET + level = 1, xp = 0, hp = 100, max_hp = 100, mp = 50, max_mp = 50, + atk = 12, def = 8, unlocked_skills = '["basic_attack"]', + updated_at = datetime('now') + WHERE user_id = ? + `); + const result = stmt.run(userId); + + // Also clear their monster entourage + this.db.prepare(`DELETE FROM monster_entourage WHERE user_id = ?`).run(userId); + + return result; + } + + // Game settings methods + getSetting(key) { + const stmt = this.db.prepare(`SELECT value FROM game_settings WHERE key = ?`); + const row = stmt.get(key); + return row ? row.value : null; + } + + setSetting(key, value) { + const stmt = this.db.prepare(` + INSERT INTO game_settings (key, value, updated_at) + VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now') + `); + return stmt.run(key, value); + } + + getAllSettings() { + const stmt = this.db.prepare(`SELECT key, value FROM game_settings`); + const rows = stmt.all(); + const settings = {}; + rows.forEach(row => { + // Try to parse JSON values + try { + settings[row.key] = JSON.parse(row.value); + } catch { + settings[row.key] = row.value; + } + }); + return settings; + } + + seedDefaultSettings() { + const defaults = { + monsterSpawnInterval: 30000, + maxMonstersPerPlayer: 10, + xpMultiplier: 1.0, + combatEnabled: true + }; + + for (const [key, value] of Object.entries(defaults)) { + const existing = this.getSetting(key); + if (existing === null) { + this.setSetting(key, JSON.stringify(value)); + } + } + } + close() { if (this.db) { this.db.close(); diff --git a/index.html b/index.html index dd24c94..5fbd06e 100644 --- a/index.html +++ b/index.html @@ -447,28 +447,26 @@ } /* Geocache styles */ .geocache-marker { - background: transparent; - border: none; - font-size: 20px; cursor: pointer; - /* Larger tap target for mobile */ display: flex; align-items: center; justify-content: center; - width: 48px; - height: 48px; + width: 64px; + height: 64px; + /* DEBUG: visible halo showing tap zone - remove when confirmed working */ + background: rgba(255, 167, 38, 0.3); + border: 2px dashed rgba(255, 167, 38, 0.7); + border-radius: 50%; } .geocache-marker:hover { transform: scale(1.2); } .geocache-marker.in-range { - /* Removed pulse animation - was causing disappearing */ box-shadow: 0 0 20px rgba(255, 167, 38, 0.8); - border-radius: 50%; } - /* Increase tap area for geocache icon */ .geocache-marker i { - padding: 10px; + font-size: 36px; + pointer-events: none; /* Parent handles all touches */ } .geocache-dialog { position: fixed !important; @@ -1915,7 +1913,9 @@ background: radial-gradient(circle, rgba(255,100,100,0.4) 0%, rgba(255,100,100,0) 70%); } .monster-icon { - font-size: 44px; + width: 50px; + height: 50px; + object-fit: contain; filter: drop-shadow(2px 2px 4px rgba(0,0,0,0.6)); animation: monster-bob 2s ease-in-out infinite; } @@ -2261,7 +2261,9 @@ margin-bottom: 6px; } .monster-entry-icon { - font-size: 24px; + width: 32px; + height: 32px; + object-fit: contain; margin-right: 8px; } .monster-entry-name { @@ -4591,9 +4593,9 @@ const marker = L.marker([geocache.lat, geocache.lng], { icon: L.divIcon({ className: 'geocache-marker', - html: ``, - iconSize: [48, 48], - iconAnchor: [24, 40] + html: ``, + iconSize: [64, 64], + iconAnchor: [32, 32] // Centered for intuitive mobile tapping }), zIndexOffset: 1000 // Ensure geocaches appear above GPS markers }); @@ -6996,6 +6998,11 @@ // Fix for Chrome and PWA - use native addEventListener with passive: false mapContainer.addEventListener('touchstart', function(e) { if (navMode && e.touches.length === 1) { + // Check if touch target is a geocache marker - let those through to Leaflet + if (e.target.closest('.geocache-marker')) { + return; // Let Leaflet handle geocache taps + } + // ALWAYS prevent default in navMode to stop Leaflet from synthesizing dblclick // This fixes the 50/50 bug where both touchend and dblclick handlers race e.preventDefault(); @@ -9639,7 +9646,8 @@ const iconHtml = `
-
${monsterType.icon}
+ ${monsterType.name}
`; @@ -9857,7 +9865,8 @@ entry.innerHTML = `
${index === combatState.selectedTargetIndex ? 'â–¶' : ''} - ${monster.data.icon} + ${monster.data.name} ${monster.data.name} Lv.${monster.level}
diff --git a/mapgameimgs/default100.png b/mapgameimgs/default100.png new file mode 100755 index 0000000..d08e384 Binary files /dev/null and b/mapgameimgs/default100.png differ diff --git a/mapgameimgs/default50.png b/mapgameimgs/default50.png new file mode 100755 index 0000000..5283d19 Binary files /dev/null and b/mapgameimgs/default50.png differ diff --git a/server.js b/server.js index 4647e9b..51e002c 100644 --- a/server.js +++ b/server.js @@ -71,6 +71,9 @@ app.get('/default.kml', async (req, res) => { // Serve .well-known directory for app verification app.use('/.well-known', express.static(path.join(__dirname, '.well-known'))); +// Serve monster images +app.use('/mapgameimgs', express.static(path.join(__dirname, 'mapgameimgs'))); + // Serve other static files app.use(express.static(path.join(__dirname))); @@ -382,6 +385,17 @@ function optionalAuth(req, res, next) { next(); } +// Admin-only middleware - requires valid auth AND admin status +function adminOnly(req, res, next) { + authenticateToken(req, res, () => { + const user = db.getUserById(req.user.userId); + if (!user || !user.is_admin) { + return res.status(403).json({ error: 'Admin access required' }); + } + next(); + }); +} + // ============================================ // Authentication Endpoints // ============================================ @@ -906,6 +920,180 @@ app.delete('/api/user/monsters/:monsterId', authenticateToken, (req, res) => { } }); +// ============================================ +// Admin Endpoints +// ============================================ + +// Serve admin page +app.get('/admin', (req, res) => { + res.sendFile(path.join(__dirname, 'admin.html')); +}); + +// Get all monster types (admin - includes disabled) +app.get('/api/admin/monster-types', adminOnly, (req, res) => { + try { + const types = db.getAllMonsterTypes(false); // Include disabled + // Return with snake_case for frontend compatibility + const formatted = types.map(t => ({ + id: t.id, + key: t.id, // Use id as key for compatibility + name: t.name, + icon: t.icon, + min_level: t.min_level || 1, + max_level: t.max_level || 5, + base_hp: t.base_hp, + base_atk: t.base_atk, + base_def: t.base_def, + base_xp: t.xp_reward, + spawn_weight: t.spawn_weight || 100, + dialogues: t.dialogues, + enabled: !!t.enabled, + created_at: t.created_at + })); + res.json({ monsterTypes: formatted }); + } catch (err) { + console.error('Admin get monster types error:', err); + res.status(500).json({ error: 'Failed to get monster types' }); + } +}); + +// Create monster type +app.post('/api/admin/monster-types', adminOnly, (req, res) => { + try { + const data = req.body; + // Accept either 'id' or 'key' as the monster identifier + const monsterId = data.id || data.key; + if (!monsterId || !data.name) { + return res.status(400).json({ error: 'Missing required fields (key and name)' }); + } + // Ensure id is set for the database function + data.id = monsterId; + db.createMonsterType(data); + res.json({ success: true }); + } catch (err) { + console.error('Admin create monster type error:', err); + res.status(500).json({ error: 'Failed to create monster type' }); + } +}); + +// Update monster type +app.put('/api/admin/monster-types/:id', adminOnly, (req, res) => { + try { + const data = req.body; + db.updateMonsterType(req.params.id, data); + res.json({ success: true }); + } catch (err) { + console.error('Admin update monster type error:', err); + res.status(500).json({ error: 'Failed to update monster type' }); + } +}); + +// Delete monster type +app.delete('/api/admin/monster-types/:id', adminOnly, (req, res) => { + try { + db.deleteMonsterType(req.params.id); + res.json({ success: true }); + } catch (err) { + console.error('Admin delete monster type error:', err); + res.status(500).json({ error: 'Failed to delete monster type' }); + } +}); + +// Get all users +app.get('/api/admin/users', adminOnly, (req, res) => { + try { + const users = db.getAllUsers(); + // Return flat structure with snake_case for frontend compatibility + const formatted = users.map(u => ({ + id: u.id, + username: u.username, + email: u.email, + created_at: u.created_at, + total_points: u.total_points, + finds_count: u.finds_count, + avatar_icon: u.avatar_icon, + avatar_color: u.avatar_color, + is_admin: !!u.is_admin, + character_name: u.character_name, + race: u.race, + class: u.class, + level: u.level || 1, + xp: u.xp || 0, + hp: u.hp || 0, + max_hp: u.max_hp || 0, + mp: u.mp || 0, + max_mp: u.max_mp || 0, + atk: u.atk || 0, + def: u.def || 0, + unlocked_skills: u.unlocked_skills + })); + res.json({ users: formatted }); + } catch (err) { + console.error('Admin get users error:', err); + res.status(500).json({ error: 'Failed to get users' }); + } +}); + +// Update user RPG stats +app.put('/api/admin/users/:id', adminOnly, (req, res) => { + try { + const stats = req.body; + db.updateUserRpgStats(req.params.id, stats); + res.json({ success: true }); + } catch (err) { + console.error('Admin update user error:', err); + res.status(500).json({ error: 'Failed to update user' }); + } +}); + +// Toggle admin status +app.put('/api/admin/users/:id/admin', adminOnly, (req, res) => { + try { + const { isAdmin } = req.body; + db.setUserAdmin(req.params.id, isAdmin); + res.json({ success: true }); + } catch (err) { + console.error('Admin toggle admin error:', err); + res.status(500).json({ error: 'Failed to toggle admin status' }); + } +}); + +// Reset user progress +app.delete('/api/admin/users/:id/reset', adminOnly, (req, res) => { + try { + db.resetUserProgress(req.params.id); + res.json({ success: true }); + } catch (err) { + console.error('Admin reset user error:', err); + res.status(500).json({ error: 'Failed to reset user progress' }); + } +}); + +// Get game settings +app.get('/api/admin/settings', adminOnly, (req, res) => { + try { + const settings = db.getAllSettings(); + res.json(settings); + } catch (err) { + console.error('Admin get settings error:', err); + res.status(500).json({ error: 'Failed to get settings' }); + } +}); + +// Update game settings +app.put('/api/admin/settings', adminOnly, (req, res) => { + try { + const settings = req.body; + for (const [key, value] of Object.entries(settings)) { + db.setSetting(key, JSON.stringify(value)); + } + res.json({ success: true }); + } catch (err) { + console.error('Admin update settings error:', err); + res.status(500).json({ error: 'Failed to update settings' }); + } +}); + // Function to send push notification to all subscribers async function sendPushNotification(title, body, data = {}) { const notification = { @@ -1163,6 +1351,9 @@ server.listen(PORT, async () => { // Seed default monsters if they don't exist db.seedDefaultMonsters(); + // Seed default game settings if they don't exist + db.seedDefaultSettings(); + // Clean expired tokens periodically setInterval(() => { try {