From a9776fdf35b84ee138a61f0d3a69a84a085946c3 Mon Sep 17 00:00:00 2001 From: HikeMap User Date: Thu, 8 Jan 2026 00:28:46 -0600 Subject: [PATCH] Implement OSM tag prefix system with kill tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features: - New admin page for OSM tags: manage prefixes, visibility, spawn radius - Global settings: base prefix chance (25%), double prefix chance (10%) - Tags auto-enabled when prefixes are added - Dynamic prefix spawning: monsters can get 0, 1, or 2 prefixes - Font size reduction for prefixed monster names - Kill tracking database for future quests/bestiary - Homebase discovery: auto-discover nearby POIs via Overpass API Database: - osm_tags table: tag config with multiple prefixes per tag - osm_tag_settings: global prefix settings - player_monster_kills: tracks all monster kills with full names API Endpoints: - /api/admin/osm-tags - CRUD for OSM tags - /api/admin/osm-tag-settings - global prefix settings - /api/osm-tags - public API for enabled tags - /api/user/monster-kill - record kills - /api/geocaches/discover-nearby - Overpass API integration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- admin.html | 265 ++++++++++++++++++++++++++++++++++++++++++++++++++ database.js | 237 +++++++++++++++++++++++++++++++++++++++++++++ index.html | 167 +++++++++++++++++++++++++++++--- server.js | 272 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 930 insertions(+), 11 deletions(-) diff --git a/admin.html b/admin.html index 37ff193..a3920bb 100644 --- a/admin.html +++ b/admin.html @@ -764,6 +764,9 @@ Settings + + 🌏 OSM Tags + Back to App @@ -1032,9 +1035,94 @@ + + +
+
+

OSM Tag Prefixes

+ +
+ + +
+

Prefix Settings

+
+
+ + + Chance to spawn with any prefix when in a tag zone +
+
+ + + Chance for two prefixes when eligible +
+
+ +
+ + + + + + + + + + + + + + + + + +
Tag IDIconPrefixesVisibilitySpawn RadiusEnabledActions
Loading...
+
+ + +
@@ -14228,6 +14371,7 @@ // Check if this killed the monster if (currentTarget.hp <= 0) { monstersKilled++; + recordMonsterKill(currentTarget); // Track kill for bestiary // Play death animation animateMonsterAttack(targetIndex, 'death'); playSfx('monster_death'); @@ -14589,6 +14733,7 @@ // Check if this monster died if (currentTarget.hp <= 0) { monstersKilled++; + recordMonsterKill(currentTarget); // Track kill for bestiary playSfx('monster_death'); // Award XP immediately for this kill const xpReward = (currentTarget.data?.xpReward || 10) * currentTarget.level; @@ -15010,7 +15155,7 @@ // ========================================== // Load monster types, skills, classes, and spawn settings from database, then initialize auth - Promise.all([loadMonsterTypes(), loadSkillsFromDatabase(), loadClasses(), loadSpawnSettings()]).then(() => { + Promise.all([loadMonsterTypes(), loadSkillsFromDatabase(), loadClasses(), loadSpawnSettings(), loadOsmTags()]).then(() => { loadCurrentUser(); }); diff --git a/server.js b/server.js index 4167eb9..36b2483 100644 --- a/server.js +++ b/server.js @@ -2109,6 +2109,275 @@ app.delete('/api/admin/class-skills/:id', adminOnly, (req, res) => { } }); +// ===================== +// OSM TAGS ADMIN ENDPOINTS +// ===================== + +// Get all OSM tags (admin) +app.get('/api/admin/osm-tags', adminOnly, (req, res) => { + try { + const tags = db.getAllOsmTags(false); + res.json({ osmTags: tags }); + } catch (err) { + console.error('Admin get OSM tags error:', err); + res.status(500).json({ error: 'Failed to fetch OSM tags' }); + } +}); + +// Create OSM tag +app.post('/api/admin/osm-tags', adminOnly, (req, res) => { + try { + db.createOsmTag(req.body); + broadcastAdminChange('osm_tags'); + res.json({ success: true }); + } catch (err) { + console.error('Admin create OSM tag error:', err); + res.status(500).json({ error: 'Failed to create OSM tag' }); + } +}); + +// Update OSM tag +app.put('/api/admin/osm-tags/:id', adminOnly, (req, res) => { + try { + db.updateOsmTag(req.params.id, req.body); + broadcastAdminChange('osm_tags'); + res.json({ success: true }); + } catch (err) { + console.error('Admin update OSM tag error:', err); + res.status(500).json({ error: 'Failed to update OSM tag' }); + } +}); + +// Delete OSM tag +app.delete('/api/admin/osm-tags/:id', adminOnly, (req, res) => { + try { + db.deleteOsmTag(req.params.id); + broadcastAdminChange('osm_tags'); + res.json({ success: true }); + } catch (err) { + console.error('Admin delete OSM tag error:', err); + res.status(500).json({ error: 'Failed to delete OSM tag' }); + } +}); + +// Get OSM tag settings +app.get('/api/admin/osm-tag-settings', adminOnly, (req, res) => { + try { + const settings = db.getAllOsmTagSettings(); + res.json(settings); + } catch (err) { + console.error('Admin get OSM tag settings error:', err); + res.status(500).json({ error: 'Failed to fetch OSM tag settings' }); + } +}); + +// Update OSM tag settings +app.put('/api/admin/osm-tag-settings', adminOnly, (req, res) => { + try { + for (const [key, value] of Object.entries(req.body)) { + db.setOsmTagSetting(key, value); + } + broadcastAdminChange('osm_tag_settings'); + res.json({ success: true }); + } catch (err) { + console.error('Admin update OSM tag settings error:', err); + res.status(500).json({ error: 'Failed to update OSM tag settings' }); + } +}); + +// ===================== +// PUBLIC OSM TAGS ENDPOINTS (for client) +// ===================== + +// Get enabled OSM tags (public) +app.get('/api/osm-tags', (req, res) => { + try { + const tags = db.getAllOsmTags(true); + res.json(tags); + } catch (err) { + console.error('Get OSM tags error:', err); + res.status(500).json({ error: 'Failed to fetch OSM tags' }); + } +}); + +// Get OSM tag settings (public) +app.get('/api/osm-tag-settings', (req, res) => { + try { + const settings = db.getAllOsmTagSettings(); + res.json(settings); + } catch (err) { + console.error('Get OSM tag settings error:', err); + res.status(500).json({ error: 'Failed to fetch OSM tag settings' }); + } +}); + +// ===================== +// KILL TRACKING ENDPOINTS +// ===================== + +// Record a monster kill +app.post('/api/user/monster-kill', authenticateToken, (req, res) => { + try { + const { monsterName } = req.body; + if (!monsterName) { + return res.status(400).json({ error: 'Monster name required' }); + } + db.recordMonsterKill(req.user.id, monsterName); + res.json({ success: true }); + } catch (err) { + console.error('Record monster kill error:', err); + res.status(500).json({ error: 'Failed to record kill' }); + } +}); + +// Get user's kill stats +app.get('/api/user/kill-stats', authenticateToken, (req, res) => { + try { + const kills = db.getPlayerKillStats(req.user.id); + const total = db.getTotalPlayerKills(req.user.id); + res.json({ kills, total }); + } catch (err) { + console.error('Get kill stats error:', err); + res.status(500).json({ error: 'Failed to fetch kill stats' }); + } +}); + +// Get kill leaderboard +app.get('/api/leaderboard/kills', (req, res) => { + try { + const limit = parseInt(req.query.limit) || 50; + const topKillers = db.getTopKillers(limit); + res.json(topKillers); + } catch (err) { + console.error('Get kill leaderboard error:', err); + res.status(500).json({ error: 'Failed to fetch kill leaderboard' }); + } +}); + +// ===================== +// HOMEBASE DISCOVERY (Overpass API) +// ===================== + +// OSM tag to Overpass query mapping +const OSM_QUERY_MAP = { + 'grocery': 'shop=supermarket', + 'restaurant': 'amenity=restaurant', + 'fastfood': 'amenity=fast_food', + 'cafe': 'amenity=cafe', + 'bar': 'amenity=bar', + 'pharmacy': 'amenity=pharmacy', + 'bank': 'amenity=bank', + 'convenience': 'shop=convenience', + 'park': 'leisure=park', + 'gasstation': 'amenity=fuel' +}; + +// Discover nearby locations via Overpass API +app.post('/api/geocaches/discover-nearby', authenticateToken, async (req, res) => { + const { lat, lng, radiusMiles = 2 } = req.body; + if (!lat || !lng) { + return res.status(400).json({ error: 'Latitude and longitude required' }); + } + + const radiusMeters = radiusMiles * 1609.34; + + try { + // Get enabled OSM tags + const enabledTags = db.getAllOsmTags(true); + + // Load current geocaches + const geocachePath = await getGeocachePath(); + let geocachesData; + try { + const data = await fs.readFile(geocachePath, 'utf8'); + geocachesData = JSON.parse(data); + } catch (err) { + geocachesData = []; + } + + let discovered = 0; + let added = 0; + + const overpassUrl = 'https://overpass-api.de/api/interpreter'; + + for (const tag of enabledTags) { + const osmQuery = OSM_QUERY_MAP[tag.id]; + if (!osmQuery) continue; + + const query = ` + [out:json][timeout:25]; + ( + node[${osmQuery}](around:${radiusMeters},${lat},${lng}); + way[${osmQuery}](around:${radiusMeters},${lat},${lng}); + ); + out center; + `; + + try { + const response = await fetch(overpassUrl, { + method: 'POST', + body: `data=${encodeURIComponent(query)}`, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + }); + + if (response.ok) { + const data = await response.json(); + + for (const element of data.elements) { + const elemLat = element.lat || element.center?.lat; + const elemLng = element.lon || element.center?.lon; + if (!elemLat || !elemLng) continue; + + discovered++; + + // Check if already exists (within 10m) + const existing = geocachesData.find(gc => + Math.abs(gc.lat - elemLat) < 0.0001 && + Math.abs(gc.lng - elemLng) < 0.0001 + ); + + if (!existing) { + const newCache = { + id: `gc_osm_${element.id}`, + lat: elemLat, + lng: elemLng, + title: element.tags?.name || `${tag.id} location`, + icon: tag.icon || 'map-marker', + color: '#4CAF50', + tags: [tag.id], + messages: [], + createdAt: Date.now(), + autoDiscovered: true + }; + + geocachesData.push(newCache); + added++; + } + } + } + } catch (queryErr) { + console.error(`Overpass query failed for ${tag.id}:`, queryErr.message); + } + + // Small delay between queries to be nice to Overpass API + await new Promise(resolve => setTimeout(resolve, 500)); + } + + // Save if we added any + if (added > 0) { + await fs.writeFile(geocachePath, JSON.stringify(geocachesData, null, 2)); + // Update in-memory cache + geocaches = geocachesData; + console.log(`Discovery: Added ${added} new geocaches near (${lat}, ${lng})`); + } + + res.json({ discovered, added }); + } catch (err) { + console.error('Discovery error:', err); + res.status(500).json({ error: 'Failed to discover locations' }); + } +}); + // Function to send push notification to all subscribers async function sendPushNotification(title, body, data = {}) { const notification = { @@ -2459,6 +2728,9 @@ server.listen(PORT, async () => { // Seed default game settings if they don't exist db.seedDefaultSettings(); + // Seed default OSM tags if they don't exist + db.seedDefaultOsmTags(); + // Clean expired tokens periodically setInterval(() => { try {