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
+
+
+
+
+
+
+
HikeMap Admin
+
You must be logged in as an admin to access this page.
+
Go to Login
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Name |
+ Key |
+ Level |
+ HP |
+ ATK |
+ DEF |
+ XP |
+ Enabled |
+ Actions |
+
+
+
+ | Loading... |
+
+
+
+
+
+
+
+
+
+
+ | Username |
+ Character |
+ Race/Class |
+ Level |
+ Stats |
+ Role |
+ Actions |
+
+
+
+ | Loading... |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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}
+
`;
@@ -9857,7 +9865,8 @@
entry.innerHTML = `
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 {