Browse Source

Implement OSM tag prefix system with kill tracking

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 <noreply@anthropic.com>
master
HikeMap User 1 month ago
parent
commit
a9776fdf35
  1. 265
      admin.html
  2. 237
      database.js
  3. 167
      index.html
  4. 272
      server.js

265
admin.html

@ -764,6 +764,9 @@
<a class="nav-item" data-section="settings"> <a class="nav-item" data-section="settings">
<span class="icon">&#9881;</span> Settings <span class="icon">&#9881;</span> Settings
</a> </a>
<a class="nav-item" data-section="osm-tags">
<span class="icon">&#127759;</span> OSM Tags
</a>
<div class="nav-spacer"></div> <div class="nav-spacer"></div>
<a class="nav-item" href="/"> <a class="nav-item" href="/">
<span class="icon">&#8592;</span> Back to App <span class="icon">&#8592;</span> Back to App
@ -1032,9 +1035,94 @@
<button class="btn btn-primary" id="saveSettingsBtn">Save Settings</button> <button class="btn btn-primary" id="saveSettingsBtn">Save Settings</button>
</div> </div>
</section> </section>
<!-- OSM Tags Section -->
<section id="osm-tags-section" class="section">
<div class="section-header">
<h2>OSM Tag Prefixes</h2>
<button class="btn btn-primary" id="addOsmTagBtn">+ Add Tag</button>
</div>
<!-- Global Prefix Settings -->
<div class="settings-card" style="margin-bottom: 30px;">
<h3>Prefix Settings</h3>
<div class="form-row">
<div class="form-group">
<label>Base Prefix Chance (%)</label>
<input type="number" id="osm-basePrefixChance" min="0" max="100" value="25">
<small style="color: #888; font-size: 11px;">Chance to spawn with any prefix when in a tag zone</small>
</div>
<div class="form-group">
<label>Double Prefix Chance (%)</label>
<input type="number" id="osm-doublePrefixChance" min="0" max="100" value="10">
<small style="color: #888; font-size: 11px;">Chance for two prefixes when eligible</small>
</div>
</div>
<button class="btn btn-primary" id="saveOsmSettingsBtn">Save Prefix Settings</button>
</div>
<!-- Tags Table -->
<table class="data-table" id="osmTagTable">
<thead>
<tr>
<th>Tag ID</th>
<th>Icon</th>
<th>Prefixes</th>
<th>Visibility</th>
<th>Spawn Radius</th>
<th>Enabled</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="osmTagTableBody">
<tr><td colspan="7" class="loading">Loading...</td></tr>
</tbody>
</table>
</section>
</main> </main>
</div> </div>
<!-- OSM Tag Edit Modal -->
<div class="modal-overlay" id="osmTagModal">
<div class="modal">
<div class="modal-header">
<h3 id="osmTagModalTitle">Edit OSM Tag</h3>
<button class="modal-close" onclick="closeOsmTagModal()">&times;</button>
</div>
<form id="osmTagForm">
<input type="hidden" id="osmTagIdField">
<div class="form-group">
<label>Tag ID</label>
<input type="text" id="osmTagIdInput" required placeholder="e.g., grocery, restaurant">
<small style="color: #888; font-size: 11px;">Matches geocache tags array values</small>
</div>
<div class="form-group">
<label>Icon (MDI name)</label>
<input type="text" id="osmTagIcon" value="map-marker" placeholder="e.g., cart, silverware-fork-knife">
</div>
<div class="form-row">
<div class="form-group">
<label>Visibility Distance (m)</label>
<input type="number" id="osmTagVisibility" value="400" min="50">
</div>
<div class="form-group">
<label>Spawn Radius (m)</label>
<input type="number" id="osmTagSpawnRadius" value="400" min="50">
</div>
</div>
<div class="form-group">
<label>Prefixes (one per line)</label>
<textarea id="osmTagPrefixes" rows="6" placeholder="Cart Wranglin'&#10;Stock-Boy&#10;Aisle Runner"></textarea>
<small style="color: #888; font-size: 11px;">Each prefix on its own line. Tag is enabled when at least one prefix exists.</small>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeOsmTagModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
<!-- Monster Edit Modal --> <!-- Monster Edit Modal -->
<div class="modal-overlay" id="monsterModal"> <div class="modal-overlay" id="monsterModal">
<div class="modal"> <div class="modal">
@ -3086,6 +3174,181 @@
} }
}); });
// ============= OSM TAGS =============
let osmTags = [];
let osmTagSettings = { basePrefixChance: 25, doublePrefixChance: 10 };
async function loadOsmTags() {
try {
const response = await api('/api/admin/osm-tags');
osmTags = response.osmTags || [];
renderOsmTagTable();
} catch (e) {
console.error('Failed to load OSM tags:', e);
showToast('Failed to load OSM tags', 'error');
}
}
async function loadOsmTagSettings() {
try {
const response = await api('/api/admin/osm-tag-settings');
osmTagSettings = response;
document.getElementById('osm-basePrefixChance').value = osmTagSettings.basePrefixChance || 25;
document.getElementById('osm-doublePrefixChance').value = osmTagSettings.doublePrefixChance || 10;
} catch (e) {
console.error('Failed to load OSM tag settings:', e);
}
}
function renderOsmTagTable() {
const tbody = document.getElementById('osmTagTableBody');
if (osmTags.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center; color:#888;">No OSM tags configured</td></tr>';
return;
}
tbody.innerHTML = osmTags.map(tag => {
const prefixes = typeof tag.prefixes === 'string' ? JSON.parse(tag.prefixes || '[]') : (tag.prefixes || []);
const prefixCount = prefixes.length;
const prefixPreview = prefixes.slice(0, 2).join(', ') + (prefixes.length > 2 ? '...' : '');
return `
<tr>
<td><strong>${escapeHtml(tag.id)}</strong></td>
<td>${escapeHtml(tag.icon)}</td>
<td title="${escapeHtml(prefixes.join(', '))}">
${prefixCount > 0 ? `<span style="color:#4CAF50">${prefixCount} prefix${prefixCount > 1 ? 'es' : ''}</span>` : '<span style="color:#888">None</span>'}
${prefixPreview ? `<br><small style="color:#666">${escapeHtml(prefixPreview)}</small>` : ''}
</td>
<td>${tag.visibility_distance}m</td>
<td>${tag.spawn_radius}m</td>
<td>
<span class="status ${tag.enabled ? 'enabled' : 'disabled'}">
${tag.enabled ? 'Yes' : 'No'}
</span>
</td>
<td class="actions">
<button class="btn btn-small" onclick="editOsmTag('${escapeHtml(tag.id)}')">Edit</button>
<button class="btn btn-small btn-danger" onclick="deleteOsmTag('${escapeHtml(tag.id)}')">Delete</button>
</td>
</tr>
`;
}).join('');
}
function openOsmTagModal(tag = null) {
const modal = document.getElementById('osmTagModal');
const title = document.getElementById('osmTagModalTitle');
const form = document.getElementById('osmTagForm');
const idInput = document.getElementById('osmTagIdInput');
form.reset();
if (tag) {
title.textContent = 'Edit OSM Tag';
document.getElementById('osmTagIdField').value = tag.id;
idInput.value = tag.id;
idInput.readOnly = true;
document.getElementById('osmTagIcon').value = tag.icon || 'map-marker';
document.getElementById('osmTagVisibility').value = tag.visibility_distance || 400;
document.getElementById('osmTagSpawnRadius').value = tag.spawn_radius || 400;
const prefixes = typeof tag.prefixes === 'string' ? JSON.parse(tag.prefixes || '[]') : (tag.prefixes || []);
document.getElementById('osmTagPrefixes').value = prefixes.join('\n');
} else {
title.textContent = 'Add OSM Tag';
document.getElementById('osmTagIdField').value = '';
idInput.readOnly = false;
document.getElementById('osmTagIcon').value = 'map-marker';
document.getElementById('osmTagVisibility').value = 400;
document.getElementById('osmTagSpawnRadius').value = 400;
document.getElementById('osmTagPrefixes').value = '';
}
modal.classList.add('active');
}
function closeOsmTagModal() {
document.getElementById('osmTagModal').classList.remove('active');
}
function editOsmTag(id) {
const tag = osmTags.find(t => t.id === id);
if (tag) {
openOsmTagModal(tag);
}
}
async function deleteOsmTag(id) {
if (!confirm(`Are you sure you want to delete the OSM tag "${id}"?`)) return;
try {
await api(`/api/admin/osm-tags/${id}`, { method: 'DELETE' });
osmTags = osmTags.filter(t => t.id !== id);
renderOsmTagTable();
showToast('OSM tag deleted');
} catch (e) {
showToast('Failed to delete OSM tag: ' + e.message, 'error');
}
}
document.getElementById('addOsmTagBtn').addEventListener('click', () => {
openOsmTagModal(null);
});
document.getElementById('osmTagForm').addEventListener('submit', async (e) => {
e.preventDefault();
const existingId = document.getElementById('osmTagIdField').value;
const newId = document.getElementById('osmTagIdInput').value.trim().toLowerCase();
const prefixesText = document.getElementById('osmTagPrefixes').value;
const prefixes = prefixesText.split('\n').map(p => p.trim()).filter(p => p.length > 0);
const data = {
id: newId,
icon: document.getElementById('osmTagIcon').value.trim() || 'map-marker',
visibility_distance: parseInt(document.getElementById('osmTagVisibility').value) || 400,
spawn_radius: parseInt(document.getElementById('osmTagSpawnRadius').value) || 400,
prefixes: prefixes
};
try {
if (existingId) {
await api(`/api/admin/osm-tags/${existingId}`, {
method: 'PUT',
body: JSON.stringify(data)
});
showToast('OSM tag updated');
} else {
await api('/api/admin/osm-tags', {
method: 'POST',
body: JSON.stringify(data)
});
showToast('OSM tag created');
}
closeOsmTagModal();
loadOsmTags();
} catch (e) {
showToast('Failed to save OSM tag: ' + e.message, 'error');
}
});
document.getElementById('saveOsmSettingsBtn').addEventListener('click', async () => {
const settings = {
basePrefixChance: parseInt(document.getElementById('osm-basePrefixChance').value) || 25,
doublePrefixChance: parseInt(document.getElementById('osm-doublePrefixChance').value) || 10
};
try {
await api('/api/admin/osm-tag-settings', {
method: 'PUT',
body: JSON.stringify(settings)
});
osmTagSettings = settings;
showToast('Prefix settings saved');
} catch (e) {
showToast('Failed to save prefix settings: ' + e.message, 'error');
}
});
// ============= UTILITIES ============= // ============= UTILITIES =============
function escapeHtml(str) { function escapeHtml(str) {
if (!str) return ''; if (!str) return '';
@ -3098,6 +3361,8 @@
// Initialize // Initialize
checkAuth(); checkAuth();
loadOsmTags();
loadOsmTagSettings();
</script> </script>
</body> </body>
</html> </html>

237
database.js

@ -324,6 +324,42 @@ class HikeMapDB {
) )
`); `);
// OSM Tags table - configuration for location-based prefixes
this.db.exec(`
CREATE TABLE IF NOT EXISTS osm_tags (
id TEXT PRIMARY KEY,
prefixes TEXT NOT NULL DEFAULT '[]',
icon TEXT DEFAULT 'map-marker',
visibility_distance INTEGER DEFAULT 400,
spawn_radius INTEGER DEFAULT 400,
enabled BOOLEAN DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// OSM Tag settings - global prefix configuration
this.db.exec(`
CREATE TABLE IF NOT EXISTS osm_tag_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Player monster kills - tracking for quests/bestiary
this.db.exec(`
CREATE TABLE IF NOT EXISTS player_monster_kills (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player_id INTEGER NOT NULL,
monster_name TEXT NOT NULL,
kill_count INTEGER DEFAULT 1,
first_kill DATETIME DEFAULT CURRENT_TIMESTAMP,
last_kill DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (player_id) REFERENCES users(id),
UNIQUE(player_id, monster_name)
)
`);
// Create indexes for performance // Create indexes for performance
this.db.exec(` this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_geocache_finds_user ON geocache_finds(user_id); CREATE INDEX IF NOT EXISTS idx_geocache_finds_user ON geocache_finds(user_id);
@ -339,6 +375,7 @@ class HikeMapDB {
CREATE INDEX IF NOT EXISTS idx_class_skills_class ON class_skills(class_id); CREATE INDEX IF NOT EXISTS idx_class_skills_class ON class_skills(class_id);
CREATE INDEX IF NOT EXISTS idx_class_skills_skill ON class_skills(skill_id); CREATE INDEX IF NOT EXISTS idx_class_skills_skill ON class_skills(skill_id);
CREATE INDEX IF NOT EXISTS idx_player_buffs_player ON player_buffs(player_id); CREATE INDEX IF NOT EXISTS idx_player_buffs_player ON player_buffs(player_id);
CREATE INDEX IF NOT EXISTS idx_player_monster_kills_player ON player_monster_kills(player_id);
`); `);
} }
@ -2210,6 +2247,206 @@ class HikeMapDB {
return stmt.run(threshold, threshold); return stmt.run(threshold, threshold);
} }
// =====================
// OSM TAGS METHODS
// =====================
getAllOsmTags(enabledOnly = false) {
const stmt = enabledOnly
? this.db.prepare(`SELECT * FROM osm_tags WHERE enabled = 1`)
: this.db.prepare(`SELECT * FROM osm_tags`);
return stmt.all();
}
getOsmTag(id) {
const stmt = this.db.prepare(`SELECT * FROM osm_tags WHERE id = ?`);
return stmt.get(id);
}
createOsmTag(tagData) {
const stmt = this.db.prepare(`
INSERT INTO osm_tags (id, prefixes, icon, visibility_distance, spawn_radius, enabled)
VALUES (?, ?, ?, ?, ?, ?)
`);
const prefixes = Array.isArray(tagData.prefixes)
? JSON.stringify(tagData.prefixes)
: (tagData.prefixes || '[]');
// Auto-enable if prefixes exist
const parsedPrefixes = typeof prefixes === 'string' ? JSON.parse(prefixes) : prefixes;
const enabled = parsedPrefixes.length > 0 ? 1 : 0;
return stmt.run(
tagData.id,
prefixes,
tagData.icon || 'map-marker',
tagData.visibility_distance || tagData.visibilityDistance || 400,
tagData.spawn_radius || tagData.spawnRadius || 400,
enabled
);
}
updateOsmTag(id, tagData) {
const stmt = this.db.prepare(`
UPDATE osm_tags SET
prefixes = ?, icon = ?, visibility_distance = ?, spawn_radius = ?, enabled = ?
WHERE id = ?
`);
const prefixes = Array.isArray(tagData.prefixes)
? JSON.stringify(tagData.prefixes)
: (tagData.prefixes || '[]');
// Auto-enable if prefixes exist
const parsedPrefixes = typeof prefixes === 'string' ? JSON.parse(prefixes) : prefixes;
const enabled = parsedPrefixes.length > 0 ? 1 : 0;
return stmt.run(
prefixes,
tagData.icon || 'map-marker',
tagData.visibility_distance || tagData.visibilityDistance || 400,
tagData.spawn_radius || tagData.spawnRadius || 400,
enabled,
id
);
}
deleteOsmTag(id) {
const stmt = this.db.prepare(`DELETE FROM osm_tags WHERE id = ?`);
return stmt.run(id);
}
// =====================
// OSM TAG SETTINGS METHODS
// =====================
getOsmTagSetting(key) {
const stmt = this.db.prepare(`SELECT value FROM osm_tag_settings WHERE key = ?`);
const row = stmt.get(key);
if (!row) return null;
try {
return JSON.parse(row.value);
} catch {
return row.value;
}
}
setOsmTagSetting(key, value) {
const stmt = this.db.prepare(`
INSERT INTO osm_tag_settings (key, value, updated_at)
VALUES (?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')
`);
const valueStr = typeof value === 'string' ? value : JSON.stringify(value);
return stmt.run(key, valueStr);
}
getAllOsmTagSettings() {
const stmt = this.db.prepare(`SELECT key, value FROM osm_tag_settings`);
const rows = stmt.all();
const settings = {};
rows.forEach(row => {
try {
settings[row.key] = JSON.parse(row.value);
} catch {
settings[row.key] = row.value;
}
});
return settings;
}
seedDefaultOsmTagSettings() {
const defaults = {
basePrefixChance: 25,
doublePrefixChance: 10
};
for (const [key, value] of Object.entries(defaults)) {
const existing = this.getOsmTagSetting(key);
if (existing === null) {
this.setOsmTagSetting(key, value);
console.log(` Seeded OSM tag setting: ${key} = ${value}`);
}
}
}
seedDefaultOsmTags() {
console.log('Checking/seeding default OSM tags...');
const defaultTags = [
{ id: 'grocery', icon: 'cart', prefixes: [] },
{ id: 'restaurant', icon: 'silverware-fork-knife', prefixes: [] },
{ id: 'fastfood', icon: 'food', prefixes: [] },
{ id: 'cafe', icon: 'coffee', prefixes: [] },
{ id: 'bar', icon: 'glass-mug-variant', prefixes: [] },
{ id: 'pharmacy', icon: 'pharmacy', prefixes: [] },
{ id: 'bank', icon: 'bank', prefixes: [] },
{ id: 'convenience', icon: 'store', prefixes: [] },
{ id: 'park', icon: 'tree', prefixes: [] },
{ id: 'gasstation', icon: 'gas-station', prefixes: [] }
];
for (const tag of defaultTags) {
const existing = this.getOsmTag(tag.id);
if (!existing) {
this.createOsmTag(tag);
console.log(` Seeded OSM tag: ${tag.id}`);
}
}
// Also seed default settings
this.seedDefaultOsmTagSettings();
}
// =====================
// PLAYER MONSTER KILLS METHODS
// =====================
recordMonsterKill(playerId, monsterName) {
const stmt = this.db.prepare(`
INSERT INTO player_monster_kills (player_id, monster_name, kill_count, first_kill, last_kill)
VALUES (?, ?, 1, datetime('now'), datetime('now'))
ON CONFLICT(player_id, monster_name) DO UPDATE SET
kill_count = kill_count + 1,
last_kill = datetime('now')
`);
return stmt.run(playerId, monsterName);
}
getPlayerKillStats(playerId) {
const stmt = this.db.prepare(`
SELECT monster_name, kill_count, first_kill, last_kill
FROM player_monster_kills
WHERE player_id = ?
ORDER BY kill_count DESC
`);
return stmt.all(playerId);
}
getPlayerKillsForMonster(playerId, monsterName) {
const stmt = this.db.prepare(`
SELECT * FROM player_monster_kills
WHERE player_id = ? AND monster_name = ?
`);
return stmt.get(playerId, monsterName);
}
getTotalPlayerKills(playerId) {
const stmt = this.db.prepare(`
SELECT SUM(kill_count) as total FROM player_monster_kills WHERE player_id = ?
`);
const result = stmt.get(playerId);
return result ? result.total || 0 : 0;
}
getTopKillers(limit = 50) {
const stmt = this.db.prepare(`
SELECT u.id, u.username, u.avatar_icon, u.avatar_color,
SUM(k.kill_count) as total_kills,
COUNT(DISTINCT k.monster_name) as unique_monsters
FROM users u
JOIN player_monster_kills k ON u.id = k.player_id
GROUP BY u.id
ORDER BY total_kills DESC
LIMIT ?
`);
return stmt.all(limit);
}
close() { close() {
if (this.db) { if (this.db) {
this.db.close(); this.db.close();

167
index.html

@ -2649,6 +2649,12 @@
font-weight: bold; font-weight: bold;
flex: 1; flex: 1;
} }
.monster-entry-name.prefix-1 {
font-size: 11px;
}
.monster-entry-name.prefix-2 {
font-size: 10px;
}
.monster-entry-hp { .monster-entry-hp {
margin-top: 4px; margin-top: 4px;
} }
@ -4424,6 +4430,104 @@
let MONSTER_SKILLS = {}; // Skills assigned to each monster type let MONSTER_SKILLS = {}; // Skills assigned to each monster type
let skillsLoaded = false; let skillsLoaded = false;
// OSM Tags for location-based prefixes
let OSM_TAGS = {};
let OSM_TAG_SETTINGS = { basePrefixChance: 25, doublePrefixChance: 10 };
let osmTagsLoaded = false;
// Load OSM tags from the database
async function loadOsmTags() {
try {
const response = await fetch('/api/osm-tags');
if (response.ok) {
const tags = await response.json();
OSM_TAGS = {};
tags.forEach(t => {
const prefixes = typeof t.prefixes === 'string' ? JSON.parse(t.prefixes || '[]') : (t.prefixes || []);
OSM_TAGS[t.id] = {
prefixes: prefixes,
icon: t.icon,
visibilityDistance: t.visibility_distance,
spawnRadius: t.spawn_radius,
enabled: t.enabled
};
});
osmTagsLoaded = true;
console.log(`Loaded ${Object.keys(OSM_TAGS).length} OSM tags`);
}
// Also load settings
const settingsResponse = await fetch('/api/osm-tag-settings');
if (settingsResponse.ok) {
const settings = await settingsResponse.json();
if (settings.basePrefixChance !== undefined) {
OSM_TAG_SETTINGS.basePrefixChance = settings.basePrefixChance;
}
if (settings.doublePrefixChance !== undefined) {
OSM_TAG_SETTINGS.doublePrefixChance = settings.doublePrefixChance;
}
console.log('Loaded OSM tag settings:', OSM_TAG_SETTINGS);
}
} catch (err) {
console.error('Failed to load OSM tags:', err);
}
}
// Record a monster kill for tracking (quests/bestiary)
async function recordMonsterKill(monster) {
const fullName = (monster.namePrefix || '') + monster.data.name;
const token = localStorage.getItem('accessToken');
if (!token) return;
try {
await fetch('/api/user/monster-kill', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ monsterName: fullName })
});
} catch (err) {
console.error('Failed to record kill:', err);
}
}
// Discover nearby locations when homebase is set
async function discoverNearbyLocations(lat, lng) {
const token = localStorage.getItem('accessToken');
if (!token) return;
try {
updateStatus('Discovering nearby locations...', 'info');
const response = await fetch('/api/geocaches/discover-nearby', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ lat, lng, radiusMiles: 2 })
});
if (response.ok) {
const { discovered, added } = await response.json();
if (added > 0) {
updateStatus(`Discovered ${added} new locations near your home base!`, 'success');
// Reload geocaches to include new ones
await loadGeocaches();
renderGeocaches();
} else if (discovered > 0) {
updateStatus(`Found ${discovered} locations already in database.`, 'info');
} else {
updateStatus('Home base set!', 'success');
}
}
} catch (err) {
console.error('Failed to discover locations:', err);
}
}
// Load monster types from the database // Load monster types from the database
async function loadMonsterTypes() { async function loadMonsterTypes() {
try { try {
@ -12890,6 +12994,9 @@
playerStats.homeBaseLng = data.homeBaseLng; playerStats.homeBaseLng = data.homeBaseLng;
updateHomeBaseMarker(); updateHomeBaseMarker();
console.log('Home base set at:', lat, lng); console.log('Home base set at:', lat, lng);
// Discover nearby locations via Overpass API
discoverNearbyLocations(lat, lng);
} else { } else {
const error = await response.json(); const error = await response.json();
alert(error.error || 'Failed to set home base'); alert(error.error || 'Failed to set home base');
@ -13603,23 +13710,58 @@
def: monsterType.baseDef + (monsterLevel - 1) * monsterType.levelScale.def, def: monsterType.baseDef + (monsterLevel - 1) * monsterType.levelScale.def,
marker: null, marker: null,
lastDialogueTime: 0, lastDialogueTime: 0,
namePrefix: '' // Will be set below based on location
namePrefix: '', // Will be set below based on location
prefixes: [] // Array of prefix strings for font sizing
}; };
// Check if spawning near a special location and set name prefix // Check if spawning near a special location and set name prefix
// Only apply prefix to "anywhere" monsters - location-specific monsters don't need it // Only apply prefix to "anywhere" monsters - location-specific monsters don't need it
const monsterSpawnLoc = monsterType.spawnLocation || 'anywhere'; const monsterSpawnLoc = monsterType.spawnLocation || 'anywhere';
if (monsterSpawnLoc === 'anywhere') {
if (monsterSpawnLoc === 'anywhere' && osmTagsLoaded) {
const spawnPos = L.latLng(monster.position.lat, monster.position.lng); const spawnPos = L.latLng(monster.position.lat, monster.position.lng);
// Collect all eligible prefixes from nearby tagged locations
const eligiblePrefixes = [];
for (const cache of geocaches) { for (const cache of geocaches) {
// Use tags if available, fall back to icon check for backwards compatibility
const isGrocery = (cache.tags && cache.tags.includes('grocery')) || cache.icon === 'cart';
if (isGrocery) {
if (!cache.tags || cache.tags.length === 0) continue;
for (const tagId of cache.tags) {
const tagConfig = OSM_TAGS[tagId];
if (!tagConfig || !tagConfig.enabled) continue;
if (!tagConfig.prefixes || tagConfig.prefixes.length === 0) continue;
const dist = spawnPos.distanceTo(L.latLng(cache.lat, cache.lng)); const dist = spawnPos.distanceTo(L.latLng(cache.lat, cache.lng));
if (dist <= 400) {
monster.namePrefix = "Cart Wranglin' ";
break;
if (dist <= tagConfig.spawnRadius) {
// Add all prefixes from this tag (avoiding duplicates)
tagConfig.prefixes.forEach(prefix => {
if (!eligiblePrefixes.find(p => p.prefix === prefix)) {
eligiblePrefixes.push({ prefix, tagId });
}
});
}
}
}
if (eligiblePrefixes.length > 0) {
// Roll for prefix chance
if (Math.random() * 100 < OSM_TAG_SETTINGS.basePrefixChance) {
// Pick first prefix
const firstPick = eligiblePrefixes[Math.floor(Math.random() * eligiblePrefixes.length)];
monster.prefixes.push(firstPick.prefix);
// Check for double prefix (need at least 2 different prefixes available)
if (eligiblePrefixes.length >= 2 && Math.random() * 100 < OSM_TAG_SETTINGS.doublePrefixChance) {
const remainingPrefixes = eligiblePrefixes.filter(p => p.prefix !== firstPick.prefix);
if (remainingPrefixes.length > 0) {
const secondPick = remainingPrefixes[Math.floor(Math.random() * remainingPrefixes.length)];
monster.prefixes.push(secondPick.prefix);
}
} }
// Randomize order and build display string
monster.prefixes.sort(() => Math.random() - 0.5);
monster.namePrefix = monster.prefixes.map(p => p + ' ').join('');
} }
} }
} }
@ -13794,7 +13936,8 @@
accuracy: monsterType?.accuracy || 85, accuracy: monsterType?.accuracy || 85,
dodge: monsterType?.dodge || 5, dodge: monsterType?.dodge || 5,
data: monsterType, data: monsterType,
namePrefix: m.namePrefix || '' // Location-based name prefix
namePrefix: m.namePrefix || '', // Location-based name prefix
prefixes: m.prefixes || [] // Array of prefix strings for font sizing
}; };
}); });
@ -14090,7 +14233,7 @@
onerror="this.src='/mapgameimgs/monsters/default100.png'" alt="${monster.namePrefix || ''}${monster.data.name}"> onerror="this.src='/mapgameimgs/monsters/default100.png'" alt="${monster.namePrefix || ''}${monster.data.name}">
<div class="status-overlay">${monsterOverlayHtml}</div> <div class="status-overlay">${monsterOverlayHtml}</div>
</div> </div>
<span class="monster-entry-name">${monster.namePrefix || ''}${monster.data.name} Lv.${monster.level}</span>
<span class="monster-entry-name${monster.prefixes?.length >= 2 ? ' prefix-2' : (monster.prefixes?.length === 1 || monster.namePrefix ? ' prefix-1' : '')}">${monster.namePrefix || ''}${monster.data.name} Lv.${monster.level}</span>
</div> </div>
<div class="monster-entry-hp"> <div class="monster-entry-hp">
<div class="hp-bar"><div class="hp-fill" style="width: ${hpPct}%;"></div></div> <div class="hp-bar"><div class="hp-fill" style="width: ${hpPct}%;"></div></div>
@ -14228,6 +14371,7 @@
// Check if this killed the monster // Check if this killed the monster
if (currentTarget.hp <= 0) { if (currentTarget.hp <= 0) {
monstersKilled++; monstersKilled++;
recordMonsterKill(currentTarget); // Track kill for bestiary
// Play death animation // Play death animation
animateMonsterAttack(targetIndex, 'death'); animateMonsterAttack(targetIndex, 'death');
playSfx('monster_death'); playSfx('monster_death');
@ -14589,6 +14733,7 @@
// Check if this monster died // Check if this monster died
if (currentTarget.hp <= 0) { if (currentTarget.hp <= 0) {
monstersKilled++; monstersKilled++;
recordMonsterKill(currentTarget); // Track kill for bestiary
playSfx('monster_death'); playSfx('monster_death');
// Award XP immediately for this kill // Award XP immediately for this kill
const xpReward = (currentTarget.data?.xpReward || 10) * currentTarget.level; 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 // 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(); loadCurrentUser();
}); });

272
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 // Function to send push notification to all subscribers
async function sendPushNotification(title, body, data = {}) { async function sendPushNotification(title, body, data = {}) {
const notification = { const notification = {
@ -2459,6 +2728,9 @@ server.listen(PORT, async () => {
// Seed default game settings if they don't exist // Seed default game settings if they don't exist
db.seedDefaultSettings(); db.seedDefaultSettings();
// Seed default OSM tags if they don't exist
db.seedDefaultOsmTags();
// Clean expired tokens periodically // Clean expired tokens periodically
setInterval(() => { setInterval(() => {
try { try {

Loading…
Cancel
Save