From a065032b175efbc26b7a58ae77567fe42681374c Mon Sep 17 00:00:00 2001 From: HikeMap User Date: Fri, 2 Jan 2026 20:23:03 -0600 Subject: [PATCH] Add RPG combat system with multi-monster battles and server persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features: - Trail Runner class with 4 skills (Attack, Brand New Hokas, Runner's High, Shin Kick) - Skill level requirements (unlock at levels 1, 2, 3, 5) - Discarded GU monster type with dialogue escalation phases - Multi-monster combat: all entourage monsters join fight simultaneously - Target selection system (click to select enemy) - Sequential monster turns after player action - XP bar in HUD showing progress to next level - Server-side RPG stats persistence (survives cache clear) Technical: - Added rpg_stats table to database - Added GET/PUT /api/user/rpg-stats endpoints - Fixed auth token name mismatch (accessToken vs authToken) ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Dockerfile | 4 + database.js | 370 ++ docker-compose.yml | 2 + docs/RPG_SYSTEM_DESIGN.md | 513 +++ docs/SKILL_MECHANICS.md | 810 ++++ docs/TRAIL_RUNNER_SKILLS_FULL.md | 593 +++ index.html | 6022 ++++++++++++++++++++---------- package.json | 3 + server.js | 493 +++ 9 files changed, 6871 insertions(+), 1939 deletions(-) create mode 100644 database.js create mode 100644 docs/RPG_SYSTEM_DESIGN.md create mode 100644 docs/SKILL_MECHANICS.md create mode 100644 docs/TRAIL_RUNNER_SKILLS_FULL.md diff --git a/Dockerfile b/Dockerfile index 5e31742..af553f8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,9 @@ FROM node:18-alpine WORKDIR /app +# Install build dependencies for native modules (better-sqlite3, bcrypt) +RUN apk add --no-cache python3 make g++ + # Copy package files COPY package*.json ./ @@ -10,6 +13,7 @@ RUN npm install # Copy application files COPY server.js ./ +COPY database.js ./ COPY index.html ./ COPY manifest.json ./ COPY service-worker.js ./ diff --git a/database.js b/database.js new file mode 100644 index 0000000..cc9250b --- /dev/null +++ b/database.js @@ -0,0 +1,370 @@ +const Database = require('better-sqlite3'); +const bcrypt = require('bcrypt'); +const path = require('path'); + +const BCRYPT_ROUNDS = parseInt(process.env.BCRYPT_ROUNDS) || 12; + +class HikeMapDB { + constructor(dbPath) { + this.dbPath = dbPath || path.join(__dirname, 'data', 'hikemap.db'); + this.db = null; + } + + init() { + this.db = new Database(this.dbPath); + this.db.pragma('journal_mode = WAL'); + this.createTables(); + console.log(`Database initialized at ${this.dbPath}`); + return this; + } + + createTables() { + // Users table + this.db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + total_points INTEGER DEFAULT 0, + finds_count INTEGER DEFAULT 0, + avatar_icon TEXT DEFAULT 'account', + avatar_color TEXT DEFAULT '#4CAF50', + is_admin BOOLEAN DEFAULT 0 + ) + `); + + // Geocache finds table + this.db.exec(` + CREATE TABLE IF NOT EXISTS geocache_finds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + geocache_id TEXT NOT NULL, + found_at DATETIME DEFAULT CURRENT_TIMESTAMP, + points_earned INTEGER NOT NULL, + is_first_finder BOOLEAN DEFAULT 0, + FOREIGN KEY (user_id) REFERENCES users(id), + UNIQUE(user_id, geocache_id) + ) + `); + + // Refresh tokens table for logout/token invalidation + this.db.exec(` + CREATE TABLE IF NOT EXISTS refresh_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + token_hash TEXT NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + `); + + // RPG stats table + this.db.exec(` + CREATE TABLE IF NOT EXISTS rpg_stats ( + user_id INTEGER PRIMARY KEY, + class TEXT NOT NULL DEFAULT 'trail_runner', + level INTEGER DEFAULT 1, + xp INTEGER DEFAULT 0, + hp INTEGER DEFAULT 100, + max_hp INTEGER DEFAULT 100, + mp INTEGER DEFAULT 50, + max_mp INTEGER DEFAULT 50, + atk INTEGER DEFAULT 12, + def INTEGER DEFAULT 8, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + `); + + // Create indexes for performance + 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_geocache ON geocache_finds(geocache_id); + CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id); + CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); + CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); + `); + } + + // User methods + async createUser(username, email, password) { + const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS); + + try { + const stmt = this.db.prepare(` + INSERT INTO users (username, email, password_hash) + VALUES (?, ?, ?) + `); + const result = stmt.run(username.toLowerCase(), email.toLowerCase(), passwordHash); + return { id: result.lastInsertRowid, username, email }; + } catch (err) { + if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') { + if (err.message.includes('username')) { + throw new Error('Username already exists'); + } + if (err.message.includes('email')) { + throw new Error('Email already exists'); + } + } + throw err; + } + } + + async validateUser(usernameOrEmail, password) { + const stmt = this.db.prepare(` + SELECT * FROM users + WHERE username = ? OR email = ? + `); + const user = stmt.get(usernameOrEmail.toLowerCase(), usernameOrEmail.toLowerCase()); + + if (!user) { + return null; + } + + const valid = await bcrypt.compare(password, user.password_hash); + if (!valid) { + return null; + } + + // Don't return password hash + const { password_hash, ...safeUser } = user; + return safeUser; + } + + getUserById(userId) { + const stmt = this.db.prepare(` + SELECT id, username, email, created_at, total_points, finds_count, + avatar_icon, avatar_color, is_admin + FROM users WHERE id = ? + `); + return stmt.get(userId); + } + + getUserByUsername(username) { + const stmt = this.db.prepare(` + SELECT id, username, email, created_at, total_points, finds_count, + avatar_icon, avatar_color, is_admin + FROM users WHERE username = ? + `); + return stmt.get(username.toLowerCase()); + } + + updateUserAvatar(userId, icon, color) { + const stmt = this.db.prepare(` + UPDATE users SET avatar_icon = ?, avatar_color = ? + WHERE id = ? + `); + return stmt.run(icon, color, userId); + } + + setUserAdmin(userId, isAdmin) { + const stmt = this.db.prepare(` + UPDATE users SET is_admin = ? WHERE id = ? + `); + return stmt.run(isAdmin ? 1 : 0, userId); + } + + setUserAdminByUsername(username, isAdmin) { + const stmt = this.db.prepare(` + UPDATE users SET is_admin = ? WHERE username = ? + `); + return stmt.run(isAdmin ? 1 : 0, username.toLowerCase()); + } + + // Geocache find methods + recordFind(userId, geocacheId, pointsEarned, isFirstFinder = false) { + const transaction = this.db.transaction(() => { + // Insert the find record + const insertStmt = this.db.prepare(` + INSERT INTO geocache_finds (user_id, geocache_id, points_earned, is_first_finder) + VALUES (?, ?, ?, ?) + `); + + try { + insertStmt.run(userId, geocacheId, pointsEarned, isFirstFinder ? 1 : 0); + } catch (err) { + if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') { + throw new Error('You have already found this geocache'); + } + throw err; + } + + // Update user's total points and finds count + const updateStmt = this.db.prepare(` + UPDATE users + SET total_points = total_points + ?, + finds_count = finds_count + 1 + WHERE id = ? + `); + updateStmt.run(pointsEarned, userId); + + return { success: true, pointsEarned }; + }); + + return transaction(); + } + + hasUserFoundGeocache(userId, geocacheId) { + const stmt = this.db.prepare(` + SELECT 1 FROM geocache_finds + WHERE user_id = ? AND geocache_id = ? + `); + return !!stmt.get(userId, geocacheId); + } + + isFirstFinder(geocacheId) { + const stmt = this.db.prepare(` + SELECT 1 FROM geocache_finds + WHERE geocache_id = ? + LIMIT 1 + `); + return !stmt.get(geocacheId); + } + + getGeocacheFinders(geocacheId) { + const stmt = this.db.prepare(` + SELECT u.id, u.username, u.avatar_icon, u.avatar_color, + gf.found_at, gf.points_earned, gf.is_first_finder + FROM geocache_finds gf + JOIN users u ON gf.user_id = u.id + WHERE gf.geocache_id = ? + ORDER BY gf.found_at ASC + `); + return stmt.all(geocacheId); + } + + getUserFinds(userId, limit = 50) { + const stmt = this.db.prepare(` + SELECT geocache_id, found_at, points_earned, is_first_finder + FROM geocache_finds + WHERE user_id = ? + ORDER BY found_at DESC + LIMIT ? + `); + return stmt.all(userId, limit); + } + + // Leaderboard methods + getLeaderboard(period = 'all', limit = 50) { + let whereClause = ''; + + if (period === 'weekly') { + whereClause = "WHERE gf.found_at >= datetime('now', '-7 days')"; + } else if (period === 'monthly') { + whereClause = "WHERE gf.found_at >= datetime('now', '-30 days')"; + } + + if (period === 'all') { + // For all-time, use the cached total_points + const stmt = this.db.prepare(` + SELECT id, username, avatar_icon, avatar_color, total_points, finds_count + FROM users + ORDER BY total_points DESC + LIMIT ? + `); + return stmt.all(limit); + } else { + // For weekly/monthly, calculate from finds + const stmt = this.db.prepare(` + SELECT u.id, u.username, u.avatar_icon, u.avatar_color, + SUM(gf.points_earned) as total_points, + COUNT(gf.id) as finds_count + FROM users u + JOIN geocache_finds gf ON u.id = gf.user_id + ${whereClause} + GROUP BY u.id + ORDER BY total_points DESC + LIMIT ? + `); + return stmt.all(limit); + } + } + + // Refresh token methods + async storeRefreshToken(userId, tokenHash, expiresAt) { + const stmt = this.db.prepare(` + INSERT INTO refresh_tokens (user_id, token_hash, expires_at) + VALUES (?, ?, ?) + `); + return stmt.run(userId, tokenHash, expiresAt); + } + + getRefreshToken(tokenHash) { + const stmt = this.db.prepare(` + SELECT * FROM refresh_tokens + WHERE token_hash = ? AND expires_at > datetime('now') + `); + return stmt.get(tokenHash); + } + + deleteRefreshToken(tokenHash) { + const stmt = this.db.prepare(` + DELETE FROM refresh_tokens WHERE token_hash = ? + `); + return stmt.run(tokenHash); + } + + deleteUserRefreshTokens(userId) { + const stmt = this.db.prepare(` + DELETE FROM refresh_tokens WHERE user_id = ? + `); + return stmt.run(userId); + } + + cleanExpiredTokens() { + const stmt = this.db.prepare(` + DELETE FROM refresh_tokens WHERE expires_at <= datetime('now') + `); + return stmt.run(); + } + + // RPG Stats methods + getRpgStats(userId) { + const stmt = this.db.prepare(` + SELECT class, level, xp, hp, max_hp, mp, max_mp, atk, def + FROM rpg_stats WHERE user_id = ? + `); + return stmt.get(userId); + } + + saveRpgStats(userId, stats) { + const stmt = this.db.prepare(` + INSERT INTO rpg_stats (user_id, class, level, xp, hp, max_hp, mp, max_mp, atk, def, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(user_id) DO UPDATE SET + class = excluded.class, + level = excluded.level, + xp = excluded.xp, + hp = excluded.hp, + max_hp = excluded.max_hp, + mp = excluded.mp, + max_mp = excluded.max_mp, + atk = excluded.atk, + def = excluded.def, + updated_at = datetime('now') + `); + return stmt.run( + userId, + stats.class || 'trail_runner', + stats.level || 1, + stats.xp || 0, + stats.hp || 100, + stats.maxHp || 100, + stats.mp || 50, + stats.maxMp || 50, + stats.atk || 12, + stats.def || 8 + ); + } + + close() { + if (this.db) { + this.db.close(); + } + } +} + +module.exports = HikeMapDB; diff --git a/docker-compose.yml b/docker-compose.yml index 583ceb6..d47752b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,8 @@ services: - "880:8080" volumes: - ./index.html:/app/index.html:ro + - ./server.js:/app/server.js:ro + - ./database.js:/app/database.js:ro - ./:/app/data restart: unless-stopped environment: diff --git a/docs/RPG_SYSTEM_DESIGN.md b/docs/RPG_SYSTEM_DESIGN.md new file mode 100644 index 0000000..55bd669 --- /dev/null +++ b/docs/RPG_SYSTEM_DESIGN.md @@ -0,0 +1,513 @@ +# HikeMap RPG System Design + +## Overview + +Transform the hiking/geocaching experience into a full RPG adventure. Players earn XP by finding geocaches, walking trails, and completing quests. Level up, learn skills, and become the ultimate trail legend. + +--- + +## Core Stats + +| Stat | Description | Base Value | +|------|-------------|------------| +| **HP (Hit Points)** | Health/Stamina - depletes from trail hazards, recovers at rest points | 100 | +| **MP (Mana Points)** | Energy for skills/spells - regenerates over time or at power spots | 50 | +| **STR (Strength)** | Physical power - affects carrying capacity, climbing ability | 10 | +| **DEX (Dexterity)** | Agility - affects movement speed bonus, balance on difficult terrain | 10 | +| **INT (Intelligence)** | Mental acuity - affects puzzle solving, cache finding radius | 10 | +| **WIS (Wisdom)** | Awareness - affects danger detection, nature knowledge | 10 | +| **CHA (Charisma)** | Social skills - affects party bonuses, NPC interactions | 10 | +| **LCK (Luck)** | Fortune - affects rare finds, critical successes | 10 | + +### Stat Bonuses by Race/Class +- Race provides base stat modifiers +- Class provides growth rate modifiers per level + +--- + +## Races + +### 1. **Granola Elf** (Classic: Elf) +*"I was hiking this trail before it was on AllTrails."* + +Slender, insufferably enlightened beings who have achieved oneness with nature (and won't shut up about it). Often found doing yoga poses at scenic overlooks. + +| Stat Modifier | Bonus | +|---------------|-------| +| DEX | +3 | +| WIS | +2 | +| INT | +1 | +| STR | -2 | + +**Racial Abilities:** +- **Plant Whisperer** - Can identify edible plants (passive) +- **Sunrise Attunement** - +20% MP regen before 7 AM +- **Leave No Trace** - Invisible to park rangers when breaking minor rules + +--- + +### 2. **Craft Dwarf** (Classic: Dwarf) +*"I brewed this IPA at 8,000 feet elevation."* + +Stout, bearded enthusiasts of artisanal everything. They carry way too much gear and have strong opinions about hiking boot brands. Their backpacks weigh 60 lbs but they don't complain. + +| Stat Modifier | Bonus | +|---------------|-------| +| STR | +3 | +| CON | +2 | +| WIS | +1 | +| DEX | -2 | + +**Racial Abilities:** +- **Gear Hoarder** - +50% carrying capacity +- **Beard of Warmth** - Immune to cold weather penalties +- **Craft Appreciation** - Can identify quality of any handmade item + +--- + +### 3. **Crossfit Orc** (Classic: Orc) +*"BRO, this incline is NOTHING. I did 400 box jumps this morning."* + +Massive, protein-shake-fueled creatures who treat every hike as a workout. They wear sleeveless shirts in 40-degree weather and grunt loudly when passing other hikers. + +| Stat Modifier | Bonus | +|---------------|-------| +| STR | +4 | +| CON | +2 | +| INT | -2 | +| CHA | -1 | + +**Racial Abilities:** +- **BEAST MODE** - Can sprint uphill without stamina penalty (1/day) +- **Protein Synthesis** - HP regenerates 2x faster when eating +- **Intimidating Presence** - Wildlife gives you a wider berth + +--- + +### 4. **Snackling** (Classic: Halfling) +*"Are we there yet? I brought seventeen different kinds of trail mix."* + +Small, perpetually hungry folk whose backpacks are 90% snacks by volume. They have an uncanny ability to find the comfiest sitting rocks and know exactly when it's snack time (always). + +| Stat Modifier | Bonus | +|---------------|-------| +| LCK | +3 | +| DEX | +2 | +| CHA | +1 | +| STR | -3 | + +**Racial Abilities:** +- **Snack Sense** - Can locate nearby food sources +- **Smol & Sneaky** - +20% stealth, can fit through small spaces +- **Second Breakfast** - Gains bonus HP from food items + +--- + +### 5. **Basic Human** (Classic: Human) +*"I saw this trail on TikTok!"* + +The most common species on trails. They come in all varieties but share a tendency to be unprepared, wear cotton in the rain, and play music from phone speakers. + +| Stat Modifier | Bonus | +|---------------|-------| +| All stats | +1 | + +**Racial Abilities:** +- **Adaptable** - Can learn skills from any class tree +- **Main Character Energy** - +10% XP from all sources +- **Phone Battery Anxiety** - Gains speed boost when phone is below 20% + +--- + +### 6. **Tech Gnome** (Classic: Gnome) +*"According to my Garmin, Strava, AllTrails, AND my Apple Watch..."* + +Tiny beings obsessed with gadgets, metrics, and optimization. They can't enjoy a hike without knowing their exact heart rate, elevation gain, and estimated calorie burn. + +| Stat Modifier | Bonus | +|---------------|-------| +| INT | +4 | +| LCK | +1 | +| STR | -2 | +| WIS | -1 | + +**Racial Abilities:** +- **Data Overload** - Access to detailed trail statistics +- **Battery Wizard** - Devices drain 50% slower +- **Analysis Paralysis** - Can reroll any failed skill check (1/day) + +--- + +## Classes + +### 1. **Trail Runner** (Classic: Warrior/Fighter) +*"I'll meet you at the summit. I'm going to run up and back twice first."* + +Elite athletes who view hiking as "too slow." They carry nothing but a hydration vest and have mastered the art of endurance. The Trail Runner excels at stamina management, pushing through adverse conditions, and maintaining peak performance over long distances. + +**Primary Stats:** STR, CON +**HP per Level:** +12 +**MP per Level:** +3 + +#### Trail Runner Skills + +--- + +**Level 1: Second Wind** +| | | +|---|---| +| **MP Cost** | 15 | +| **Cooldown** | 30 minutes | +| **Type** | Active - Self | + +*"Just gotta catch my breath..."* + +Instantly restore 25% of your maximum HP. Can be used while moving. The quintessential runner's recovery - that moment when your body remembers it's actually capable of continuing. + +**Effect:** +25% Max HP restored instantly + +--- + +**Level 2: Steady Pace** +| | | +|---|---| +| **MP Cost** | Passive | +| **Cooldown** | - | +| **Type** | Passive | + +*"It's not about how fast you go, it's about not stopping."* + +Your efficient movement style reduces stamina drain from all activities. You've learned that consistency beats intensity on the long trails. + +**Effect:** -15% HP drain from distance traveled and terrain penalties + +--- + +**Level 3: Trail Legs** +| | | +|---|---| +| **MP Cost** | 10 | +| **Cooldown** | 10 minutes | +| **Type** | Active - Self | +| **Duration** | 20 minutes | + +*"Mud? Rocks? Roots? My legs don't care."* + +Your legs have seen every type of terrain and laughed. Activate to ignore movement penalties from difficult terrain (mud, steep inclines, rocky paths, stream crossings). + +**Effect:** Ignore terrain difficulty penalties for duration + +--- + +**Level 4: Push Through** +| | | +|---|---| +| **MP Cost** | 20 | +| **Cooldown** | 45 minutes | +| **Type** | Active - Self | +| **Duration** | 15 minutes | + +*"Pain is just weakness leaving the body. Or an injury. Probably fine though."* + +Temporarily suppress one negative status effect (Exhausted, Blistered, Hangry, etc.). The effect returns after duration if not otherwise cured. Mind over matter - at least temporarily. + +**Effect:** Suppress one negative status effect for duration + +--- + +**Level 5: Elevation Crusher** +| | | +|---|---| +| **MP Cost** | Passive | +| **Cooldown** | - | +| **Type** | Passive | + +*"Flat trails are just lazy mountains."* + +You've developed a strange love for climbing. Gain bonus XP for elevation gain and take reduced stamina damage from uphill travel. + +**Effect:** +- +25% XP bonus for elevation gained +- -20% HP drain from climbing/elevation gain + +--- + +**Level 6: Recovery Stride** +| | | +|---|---| +| **MP Cost** | 8 | +| **Cooldown** | 5 minutes | +| **Type** | Active - Self | +| **Duration** | 10 minutes | + +*"I don't need to stop to rest. Resting is just slow walking."* + +Enter a recovery state while continuing to move. Regenerate HP gradually over the duration. Cannot be used while stationary (that's just regular resting, and Trail Runners don't do that). + +**Effect:** Regenerate 3% Max HP per minute while moving + +--- + +**Level 7: Iron Will** +| | | +|---|---| +| **MP Cost** | Passive | +| **Cooldown** | - | +| **Type** | Passive | + +*"I've run 50 miles on a sprained ankle. Your 'exhaustion' means nothing to me."* + +Your mental fortitude provides resistance to exhaustion and fatigue-based status effects. Exhaustion effects are reduced and take longer to apply. + +**Effect:** +- 50% resistance to Exhaustion status +- Exhaustion threshold increased by 25% + +--- + +**Level 8: Marathoner's Resolve** +| | | +|---|---| +| **MP Cost** | 25 | +| **Cooldown** | 2 hours | +| **Type** | Active - Self | +| **Duration** | 1 hour | + +*"Mile 20 is where the real race begins."* + +Enter a state of focused endurance. For the duration, you gain significant bonuses to stamina management and cannot be reduced below 1 HP by gradual drain effects (you can still be knocked out by sudden damage/events). + +**Effect:** +- -50% HP drain from all sources +- Cannot be reduced below 1 HP by passive drain +- +10% XP from distance traveled + +--- + +**Level 9: Trailblazer's Instinct** +| | | +|---|---| +| **MP Cost** | 30 | +| **Cooldown** | 1 hour | +| **Type** | Active - Self | +| **Duration** | 30 minutes | + +*"The map says it's 3 miles. I know a better way."* + +Your extensive trail experience lets you identify optimal routes. Activate to reduce the required distance to reach nearby objectives (geocaches, waypoints, quest targets) by finding more efficient paths. + +**Effect:** +- Required distance to objectives within 1km reduced by 20% +- Reveals any hidden shortcuts in the area + +--- + +**Level 10: Unstoppable** +| | | +|---|---| +| **MP Cost** | 50 | +| **Cooldown** | 4 hours | +| **Type** | Active - Self | +| **Duration** | 30 minutes | + +*"I am become endurance, destroyer of trails."* + +The ultimate expression of the Trail Runner's philosophy. For the duration, you become a force of nature - immune to negative status effects, ignoring all terrain penalties, and gaining enhanced regeneration. + +**Effect:** +- Immune to all negative status effects +- Ignore all terrain penalties +- +5% HP regeneration per minute +- +50% XP from all sources +- Party members within range gain +25% of these bonuses + +--- + +### 2. **App Wizard** (Classic: Mage) +*"Hold on, I'm optimizing our route using three different algorithms."* + +Tech-savvy magic users who bend reality through the power of apps, gadgets, and strong WiFi signals. Their spells require battery life instead of mystical energy. + +**Primary Stats:** INT, WIS +**HP per Level:** +6 +**MP per Level:** +15 + +**Skills:** +| Level | Skill | MP Cost | Description | +|-------|-------|---------|-------------| +| 1 | **GPS Ping** | 5 | Reveal exact location + nearby POIs | +| 3 | **Cache Sense** | 15 | Highlight all geocaches within 500m | +| 5 | **Signal Boost** | 20 | Get cell service anywhere for 10 min | +| 8 | **Satellite View** | 35 | Bird's eye view of surrounding area | +| 12 | **Digital Omniscience** | 60 | Reveal all hidden objects on map | + +--- + +### 3. **Urban Explorer** (Classic: Rogue) +*"The sign says 'No Trespassing' but it doesn't say 'No Exploring.'"* + +Sneaky adventurers who find paths where none exist. They know every shortcut, secret entrance, and technically-not-illegal way to access restricted areas. + +**Primary Stats:** DEX, LCK +**HP per Level:** +8 +**MP per Level:** +8 + +**Skills:** +| Level | Skill | MP Cost | Description | +|-------|-------|---------|-------------| +| 1 | **Sneak** | 5 | Become undetectable for 1 minute | +| 3 | **Lockpick** | 10 | Open locked gates/caches | +| 5 | **Shortcut Finder** | 15 | Reveal hidden paths | +| 8 | **Urban Camo** | 25 | Blend into any environment | +| 12 | **Ghost Protocol** | 45 | Pass through barriers, leave no trace | + +--- + +### 4. **Wellness Cleric** (Classic: Cleric/Healer) +*"Have you tried putting crystals in your water bottle? It's life-changing."* + +Healers who draw power from essential oils, positive vibes, and an unshakeable belief in their own wellness journey. They can cure ailments but will also tell you about their gut health. + +**Primary Stats:** WIS, CHA +**HP per Level:** +8 +**MP per Level:** +12 + +**Skills:** +| Level | Skill | MP Cost | Description | +|-------|-------|---------|-------------| +| 1 | **Good Vibes** | 5 | Heal party for 15 HP | +| 3 | **Essential Blessing** | 10 | Remove one negative status effect | +| 5 | **Meditation Circle** | 20 | Full party MP restore while resting | +| 8 | **Crystal Resonance** | 30 | Party-wide stat buff for 30 min | +| 12 | **Enlightenment** | 50 | Full heal + resurrect party member | + +--- + +### 5. **Bird Watcher Ranger** (Classic: Ranger) +*"EVERYONE STOP. I think I see a Yellow-Bellied Sapsucker."* + +Nature specialists who know every bird call, animal track, and plant species. They will stop the entire group to look at something you can't see, and get unreasonably excited about it. + +**Primary Stats:** WIS, DEX +**HP per Level:** +10 +**MP per Level:** +8 + +**Skills:** +| Level | Skill | MP Cost | Description | +|-------|-------|---------|-------------| +| 1 | **Wildlife Spot** | 5 | Detect nearby animals | +| 3 | **Nature's Ally** | 15 | Befriend a wild animal temporarily | +| 5 | **Camouflage** | 15 | Party becomes invisible to wildlife | +| 8 | **Apex Knowledge** | 25 | Know all hazards in area | +| 12 | **Call of the Wild** | 40 | Summon animal companion | + +--- + +### 6. **Trail Bard** (Classic: Bard) +*"This is going to get SO many views. Make sure you're in the background looking natural."* + +Content creators who document every moment of every adventure. They provide buffs through encouragement and debuffs to enemies through cringe-worthy commentary. + +**Primary Stats:** CHA, LCK +**HP per Level:** +7 +**MP per Level:** +10 + +**Skills:** +| Level | Skill | MP Cost | Description | +|-------|-------|---------|-------------| +| 1 | **Hype Up** | 5 | Boost party morale (+10% all stats) | +| 3 | **Viral Moment** | 15 | Capture perfect photo for bonus XP | +| 5 | **Podcast Mode** | 20 | Talk so much enemies flee in confusion | +| 8 | **Influencer Aura** | 30 | Attract followers, gain helpers | +| 12 | **Main Character Moment** | 50 | Dramatic scene - massive party buff | + +--- + +## Experience & Leveling + +### XP Sources +| Activity | XP Reward | +|----------|-----------| +| Find geocache (Easy) | 50 XP | +| Find geocache (Medium) | 100 XP | +| Find geocache (Hard) | 200 XP | +| First finder bonus | +50% XP | +| Complete trail | Distance ร— 10 XP | +| Discover new area | 150 XP | +| Daily login | 25 XP | +| Party activities | Shared XP pool | + +### Level Thresholds +| Level | Total XP | Title | +|-------|----------|-------| +| 1 | 0 | Trailhead Newbie | +| 2 | 100 | Path Finder | +| 3 | 300 | Trail Walker | +| 4 | 600 | Ridge Runner | +| 5 | 1,000 | Summit Seeker | +| 10 | 5,000 | Mountain Maven | +| 15 | 15,000 | Wilderness Wanderer | +| 20 | 35,000 | Legendary Explorer | +| 25 | 75,000 | Trail Demigod | +| 30 | 150,000 | Nature's Champion | + +--- + +## Status Effects + +### Positive +| Effect | Description | Duration | +|--------|-------------|----------| +| **Well-Rested** | +20% HP/MP regen | 2 hours | +| **Caffeinated** | +15% movement speed | 30 min | +| **Snacked** | +10 HP regen/min | 15 min | +| **Inspired** | +25% XP gain | 1 hour | +| **Group Synergy** | +5% all stats per party member | While in party | + +### Negative +| Effect | Description | Duration | +|--------|-------------|----------| +| **Exhausted** | -30% movement speed, -20% HP | Until rest | +| **Lost** | No map visibility | Until found | +| **Hangry** | -20% all stats | Until fed | +| **Blistered** | -50% movement speed | Until healed | +| **Doomscrolling** | MP drain, can't focus | Until phone put away | + +--- + +## Party System + +- Maximum party size: 6 players +- Shared XP for nearby activities +- Party chat and location sharing +- Combo skills require multiple party members +- Party roles: Leader (sets destination), Scout (reveals ahead), Support (heals) + +--- + +## Future Considerations + +- [ ] Crafting system (trail snacks, gear upgrades) +- [ ] Guild/Club system +- [ ] Seasonal events & limited quests +- [ ] PvP trail races +- [ ] Boss encounters (legendary geocaches) +- [ ] Achievement system +- [ ] Cosmetic rewards (trail badges, hat skins) +- [ ] Pet companions + +--- + +## Questions to Resolve + +1. How does HP actually work? Does it deplete over time/distance, or only from events? +2. Should mana regenerate passively or only at specific locations? +3. How do we handle PvP interactions (if any)? +4. Do we want equipment/inventory systems? +5. Should class abilities be unlocked linearly or allow choice? +6. Balance: How powerful should racial abilities be vs class abilities? + +--- + +*Document Version: 0.1* +*Last Updated: January 2026* diff --git a/docs/SKILL_MECHANICS.md b/docs/SKILL_MECHANICS.md new file mode 100644 index 0000000..0fcf783 --- /dev/null +++ b/docs/SKILL_MECHANICS.md @@ -0,0 +1,810 @@ +# Skill Mechanics - Detailed Design + +## Core Design Principles + +1. **Walking is NEVER punished** - No HP drain from travel. Hiking should be rewarded, not penalized. +2. **Combat is optional and asynchronous** - Monsters queue up during your hike, fight them when convenient. +3. **Turn-based battles** - Classic RPG style combat when you choose to engage. +4. **Skills alternate** - Offensive skills on odd levels, Utility/Defensive on even levels. + +--- + +## Combat System Overview + +### Monster Encounters + +While hiking, you may randomly encounter monsters based on: +- Location (different biomes spawn different monsters) +- Time of day (nocturnal creatures at night) +- Trail difficulty +- Player level (scaled encounters) + +### The Monster Entourage + +When you encounter a monster and don't want to fight, **it follows you around on the map**, yelling insults and taunts at you until you deal with it. + +**Maximum followers:** 2 monsters per player level +- Level 1 player: Max 2 monsters following +- Level 5 player: Max 10 monsters following +- Level 10 player: Max 20 monsters following + +If you're at max capacity, new monsters can't spawn until you fight or dismiss some. + +**Map Display:** +``` + ๐Ÿบ "FIGHT ME COWARD!" + ๐Ÿฆ‡ "I'LL GET YOU!" ๐Ÿงญ You + ๐ŸŒฟ "COME BACK HERE!" โ†“ + [===trail===] +``` + +**Monster Taunts (examples):** +- ๐Ÿบ Trail Wolf: "I've been chasing you for 2 MILES!" +- ๐Ÿฆ‡ Cave Bat: "You can't ignore me forever!" +- ๐ŸŒฟ Angry Shrub: "I'm a BUSH and I'm faster than you!" +- ๐Ÿ— Wild Boar: "My legs are getting tired but my RAGE isn't!" +- ๐ŸฆŽ Trail Lizard: "This is embarrassing for both of us!" + +**Interacting with followers:** +- Tap a monster icon to start combat with just that one +- "Fight All" button for sequential battles +- "Dismiss" to shoo them away (no XP, no penalty) +- Monsters stay between sessions (persistent) + +**Comedy escalation:** +- Monsters following for 10+ min: Taunts get more desperate +- Monsters following for 30+ min: Start complimenting you instead +- Monsters following for 1+ hour: Existential crisis dialogue + +### Turn-Based Combat + +When you choose to fight: + +``` +โ”Œโ”€ BATTLE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ โ”‚ +โ”‚ ๐Ÿบ Trail Wolf โ”‚ +โ”‚ HP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘ 45/60 โ”‚ +โ”‚ โ”‚ +โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”‚ +โ”‚ โ”‚ +โ”‚ ๐Ÿƒ You (Trail Runner Lv 3) โ”‚ +โ”‚ HP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ 100/100 โ”‚ +โ”‚ MP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘ 40/50 โ”‚ +โ”‚ โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ [โš”๏ธ Attack] [๐Ÿ”ฅ Double Strike] โ”‚ +โ”‚ [๐ŸŽ’ Items ] [๐Ÿƒ Flee ] โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +**Combat Flow:** +1. Player turn: Choose action +2. Action resolves +3. Enemy turn: Enemy attacks +4. Repeat until someone hits 0 HP + +**Victory:** Gain XP, possible loot drops +**Defeat:** Lose some gold? Lose queued monsters? (TBD - should be minor) + +--- + +## HP System (Revised) + +### HP is for COMBAT only + +- HP does NOT drain from walking +- HP only changes during battle, skills, or items +- **HP persists between battles** - adds strategy to combat decisions + +### HP Recovery (Out of Combat) + +| Method | Recovery | Notes | +|--------|----------|-------| +| Time (passive) | 1 HP per minute | Slow but free | +| Rest at geocache | 5 HP per minute | Must be stationary at location | +| Food items | Varies | Snacks, trail mix, etc. | +| Healing skills | Varies | Class abilities | +| Level up | Full restore | Reward for leveling | + +### What Happens at 0 HP? + +**In Battle:** +- You lose the fight +- Monster escapes (gets dismissed, no XP) +- You're left at 1 HP (can't die, just knocked out of that fight) +- Any remaining monsters in your entourage keep following you + +**Strategic Implications:** +- Can't just chain-fight 20 monsters - need to manage HP +- Healing skills become valuable between fights +- Might need to dismiss some monsters if HP is low +- Adds tension: "Can I take one more fight before resting?" + +--- + +## Skill Leveling System + +Skills have their own level that improves with use! This is separate from your character level. + +### How Skills Level Up + +**XP per skill:** Each time you use a skill, it gains Skill XP +- Using a skill in combat: +10 Skill XP +- Skill contributes to victory: +5 bonus Skill XP +- Using skill effectively (e.g., heal when low HP): +5 bonus Skill XP + +**Skill XP thresholds:** +| Skill Level | Total XP Needed | +|-------------|-----------------| +| 1 | 0 (starting) | +| 2 | 50 | +| 3 | 150 | +| 4 | 300 | +| 5 | 500 | +| 6 | 800 | +| 7 | 1200 | +| 8+ | +500 per level | + +### What Skill Levels Do + +Each skill defines its own leveling bonuses. Common patterns: +- **Cooldown reduction** - Use more often +- **Damage/healing increase** - More powerful +- **MP cost reduction** - More efficient +- **Additional effects** - New mechanics unlock + +--- + +## Trail Runner Skills (Revised) + +**Skill Pattern:** Odd levels = Offensive, Even levels = Utility/Defensive + +**Class Identity:** Gear snob, endurance athlete, trail obsessed, owns $200 shoes, has opinions about hydration vests + +| Level | Skill | Type | Flavor | +|-------|-------|------|--------| +| 1 | Brand New Hokas | โš”๏ธ Offensive | Lightning fast double-strike in premium footwear | +| 2 | Runner's High | ๐Ÿ’š Utility | Endorphins are nature's healing | +| 3 | Shin Kick | โš”๏ธ Offensive | Targets the most painful spot | +| 4 | Gel Pack | ๐Ÿ’š Utility | Disgusting but effective energy boost | +| 5 | Pole Vault | โš”๏ธ Offensive | Trekking pole to the FACE | +| 6 | Foam Roll | ๐Ÿ’š Utility | Painful recovery that actually works | +| 7 | Downhill Bomb | โš”๏ธ Offensive | Reckless descending attack | +| 8 | Aid Station | ๐Ÿ’š Utility | Full rest stop recovery | +| 9 | Final Kick | โš”๏ธ Offensive | That sprint finish energy | +| 10 | Hundred Miler | ๐ŸŒŸ Ultimate | You simply refuse to stop | + +--- + +### Reserved for Gym Bro (Barbarian) class: +- Rep It Out +- PR Attempt +- Beast Mode +- Protein Shake +- Max Effort +- Drop Set +- Pre-Workout +- Spot Me Bro + +--- + +## Level 1: Brand New Hokas โš”๏ธ + +### The Skill +``` +Name: Brand New Hokas +Type: Offensive +MP Cost: 12 (reduces with skill level) +Cooldown: None (usable every turn) +Effect: Attack twice in a single turn +``` + +*"These cost $180 and they're worth EVERY PENNY."* + +The Trail Runner's premium cushioned footwear allows for lightning-fast strikes. Two rapid kicks delivered with the confidence that only expensive gear can provide. + +### Skill Leveling Progression + +Brand New Hokas improves its damage and efficiency as you level it: + +| Skill Level | Damage per Hit | MP Cost | Total vs Normal Attack | +|-------------|---------------|---------|------------------------| +| 1 | 80% | 12 | 160% damage | +| 2 | 82% | 12 | 164% damage | +| 3 | 85% | 11 | 170% damage | +| 4 | 87% | 11 | 174% damage | +| 5 | 90% | 10 | 180% damage | +| 6 | 92% | 10 | 184% damage | +| 7 | 95% | 9 | 190% damage | +| 8 | 97% | 9 | 194% damage | +| 9 | 100% | 8 | 200% damage (two full attacks!) | +| 10+ | 100% + crit bonus | 8 | 200%+ with +5% crit chance per level | + +**Milestone unlocks:** +- **Level 5:** "Swift Strikes" - Animation speeds up (QoL) +- **Level 9:** "True Double" - Each hit deals full damage +- **Level 10:** "Relentless" - +5% critical hit chance per level beyond 10 + +### Combat Mechanics + +**Base Attack Damage Formula:** +``` +Damage = (STR ร— 2) + WeaponDamage + RandomVariance(0 to STR/2) +``` + +For a Level 1 Trail Runner with 12 STR and no weapon: +- Base Attack: (12 ร— 2) + 0 + (0-6) = 24-30 damage + +**Double Strike at Skill Level 1:** +- Hit 1: 80% of base = 19-24 damage +- Hit 2: 80% of base = 19-24 damage +- Total: 38-48 damage (vs 24-30 from normal attack) + +**Double Strike at Skill Level 9:** +- Hit 1: 100% of base = 24-30 damage +- Hit 2: 100% of base = 24-30 damage +- Total: 48-60 damage for only 8 MP! + +### How It Works In-Game + +**Scenario:** Battle against a Trail Wolf (45 HP) + +``` +โ”Œโ”€ BATTLE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ ๐Ÿบ Trail Wolf โ”‚ +โ”‚ HP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘ 45/45 โ”‚ +โ”‚ โ”‚ +โ”‚ ๐Ÿƒ You โ”‚ +โ”‚ HP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ 100/100 โ”‚ +โ”‚ MP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ 50/50 โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ [โš”๏ธ Attack] [โš”๏ธโš”๏ธ Double Strike] โ”‚ +โ”‚ [๐ŸŽ’ Items ] [๐Ÿƒ Flee ] โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +**Player selects Double Strike:** + +``` +โ”Œโ”€ BATTLE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ โ”‚ +โ”‚ โš”๏ธ Double Strike! โ”‚ +โ”‚ โ”‚ +โ”‚ ๐Ÿ’ฅ Hit 1: 21 damage! โ”‚ +โ”‚ ๐Ÿ’ฅ Hit 2: 19 damage! โ”‚ +โ”‚ โ”‚ +โ”‚ ๐Ÿบ Trail Wolf โ”‚ +โ”‚ HP: โ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 5/45 โ”‚ +โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +**Enemy turn follows, then back to player...** + +### Why This Skill is Good for Level 1 + +1. **Simple to understand** - "Attack twice" is intuitive +2. **Immediately useful** - Works in first battle +3. **Teaches MP management** - Can't spam it forever +4. **Fighter identity** - Establishes Trail Runner as damage dealer + +### Edge Cases + +- **Not enough MP?** Skill greyed out, tooltip shows "Need 12 MP" +- **Can both hits crit?** Yes, each hit rolls independently +- **Can enemy die on first hit?** Yes, second hit is wasted (no overkill bonus) + +--- + +## Level 2: Runner's High ๐Ÿ’š + +### The Skill +``` +Name: Runner's High +Type: Utility (Healing) +MP Cost: 15 +Cooldown: Battle-based (improves with Skill Level) +Effect: Restore 30% of maximum HP +``` + +*"Around mile 8, something magical happens... the pain becomes pleasure."* + +The Trail Runner taps into that euphoric state where endorphins flood the system and everything feels possible. Pain melts away, replaced by a serene confidence. + +### Skill Leveling Progression + +Runner's High cooldown is based on **number of battles**, and improves as you level the skill: + +| Skill Level | Cooldown | In Practice | +|-------------|----------|-------------| +| 1 | Once per 5 battles | Very limited, save for emergencies | +| 2 | Once per 4 battles | Slightly more available | +| 3 | Once per 3 battles | Every third fight | +| 4 | Once per 2 battles | Every other fight | +| 5 | Once per battle | Reliable, use every fight | +| 6 | Twice per battle | Now we're cooking | +| 7 | Three times per battle | Significant sustain | +| 8+ | +1 use per level | Approaching immortality | + +**Cooldown Tracking:** +``` +Skill Level 1: [โœ“ Used] [โ–‘ 4 battles left] [โ–‘] [โ–‘] [โ–‘] +Skill Level 5: [โœ“ Used] [Ready next battle!] +Skill Level 7: [โœ“ Used] [โœ“ Used] [โ–‘ Available] +``` + +### Combat Mechanics + +For a player with 100 max HP: +- Runner's High restores 30 HP (scales slightly with skill level) +- Healing increases with skill level: + +| Skill Level | HP Restored | +|-------------|-------------| +| 1-3 | 30% max HP | +| 4-5 | 35% max HP | +| 6-7 | 40% max HP | +| 8+ | 45% max HP | + +### How It Works In-Game + +**Scenario:** Mid-battle, you've taken some hits. Runner's High is available (cooldown ready). + +``` +โ”Œโ”€ BATTLE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ ๐Ÿบ Trail Wolf โ”‚ +โ”‚ HP: โ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 12/45 โ”‚ +โ”‚ โ”‚ +โ”‚ ๐Ÿƒ You โ”‚ +โ”‚ HP: โ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 42/100 โ”‚ โ† Hurting! +โ”‚ MP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘ 30/50 โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ [โš”๏ธ Attack ] [๐Ÿ‘Ÿ New Hokas ] โ”‚ +โ”‚ [๐Ÿƒโ€โ™‚๏ธ Runner's High] [๐Ÿšช Flee ] โ”‚ +โ”‚ Lv.3 (1/1) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +**Player selects Runner's High:** + +``` +โ”Œโ”€ BATTLE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ โ”‚ +โ”‚ ๐Ÿƒโ€โ™‚๏ธ RUNNER'S HIGH! โ”‚ +โ”‚ โ”‚ +โ”‚ *Breathes rhythmically* โ”‚ +โ”‚ *Zones out completely* โ”‚ +โ”‚ *Achieves flow state* โ”‚ +โ”‚ โ”‚ +โ”‚ โœจ Recovered 30 HP! โ”‚ +โ”‚ โ”‚ +โ”‚ ๐Ÿƒ You โ”‚ +โ”‚ HP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘ 72/100 โ”‚ โ† The endorphins are REAL +โ”‚ MP: โ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 15/50 โ”‚ +โ”‚ โ”‚ +โ”‚ Runner's High: 0/1 (recharges in 3 battles) +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +### Strategic Implications + +**At Skill Level 1 (once per 5 battles):** +- VERY precious resource +- Only use when absolutely necessary +- Might lose fights you could have won if you waste it + +**At Skill Level 5 (once per battle):** +- Reliable tool in every fight +- Can be more aggressive knowing you have a heal +- Still need to time it right + +**At Skill Level 7+ (multiple per battle):** +- Major sustain advantage +- Can take on tougher monsters +- Enables riskier strategies + +### Edge Cases + +- **Cooldown not ready?** Skill greyed out, shows "2 battles remaining" +- **Multiple uses available?** Shows "(2/3)" to indicate uses left this battle +- **At full HP?** Greyed out with "HP Full" tooltip +- **Would overheal?** Caps at max HP (no waste, shows actual heal amount) + +--- + +## Level 3: Shin Kick โš”๏ธ + +### The Skill +``` +Name: Shin Kick +Type: Offensive +MP Cost: 20 +Cooldown: Once per battle +Effect: A devastating strike dealing 250% damage +``` + +*"Right in the shin. You KNOW how much that hurts."* + +Every trail runner knows the agony of a shin strike - whether from a hidden root, a rock, or an aggressive trail marker. Now weaponize that pain against your enemies. + +### Skill Leveling Progression + +| Skill Level | Damage | MP Cost | Bonus Effect | +|-------------|--------|---------|--------------| +| 1 | 250% | 20 | - | +| 2 | 265% | 20 | - | +| 3 | 280% | 19 | - | +| 4 | 295% | 19 | - | +| 5 | 310% | 18 | "New PR!" - 10% chance to reset cooldown | +| 6 | 325% | 18 | 15% reset chance | +| 7 | 340% | 17 | 20% reset chance | +| 8 | 355% | 17 | 25% reset chance | +| 9 | 370% | 16 | 30% reset chance | +| 10+ | 400% | 15 | "BONE BRUISE" - 35% reset + stuns enemy 1 turn | + +### Combat Mechanics + +**Comparison to other attacks:** +- Normal Attack: 100% damage, 0 MP +- Brand New Hokas (Lv1): 160% damage, 12 MP +- Shin Kick (Lv1): 250% damage, 20 MP + +**When to use:** +- Opening move to chunk a tough enemy +- Finishing blow on a low-HP monster +- When you can afford the MP investment + +### How It Works In-Game + +``` +โ”Œโ”€ BATTLE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ ๐Ÿ— Wild Boar โ”‚ +โ”‚ HP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ 80/80 โ”‚ +โ”‚ โ”‚ +โ”‚ ๐Ÿƒ You โ”‚ +โ”‚ HP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ 100/100 โ”‚ +โ”‚ MP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ 50/50 โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ [โš”๏ธ Attack ] [๐Ÿ‘Ÿ New Hokas ] โ”‚ +โ”‚ [๐Ÿฆต Shin Kick] [๐Ÿƒโ€โ™‚๏ธ Runner's High] โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +**Player selects Shin Kick:** + +``` +โ”Œโ”€ BATTLE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ โ”‚ +โ”‚ ๐Ÿฆต SHIN KICK! โ”‚ +โ”‚ โ”‚ +โ”‚ *Winds up* โ”‚ +โ”‚ *Aims for the shin* โ”‚ +โ”‚ *CRACK* โ”‚ +โ”‚ โ”‚ +โ”‚ ๐Ÿ’ฅ๐Ÿ’ฅ๐Ÿ’ฅ 62 DAMAGE! ๐Ÿ’ฅ๐Ÿ’ฅ๐Ÿ’ฅ โ”‚ +โ”‚ โ”‚ +โ”‚ ๐Ÿ— Wild Boar โ”‚ +โ”‚ HP: โ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 18/80 โ”‚ โ† Oof, right in the shin +โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +### Edge Cases + +- **Already used this battle?** Greyed out, shows "Shin Sore" (already gave 'em one) +- **Not enough MP?** Greyed out, shows "Need 20 MP" +- **Kills the enemy?** Displays "CRITICAL SHIN!" celebration text +- **Cooldown resets (Lv5+)?** Shows "OTHER SHIN!" and skill becomes available again + +--- + +## Level 4: Gel Pack ๐Ÿ’š + +### The Skill +``` +Name: Gel Pack +Type: Utility (Buff) +MP Cost: 18 +Cooldown: Once per 3 battles +Effect: Boost attack damage by 25% for 4 turns +``` + +*"It tastes like chemical birthday cake but it WORKS."* + +The Trail Runner squeezes down an energy gel - that weird gooey substance that costs $3 per packet and has the consistency of toothpaste. Disgusting? Yes. Effective? Absolutely. + +### Skill Leveling Progression + +| Skill Level | Damage Boost | Duration | Cooldown | +|-------------|-------------|----------|----------| +| 1 | +25% | 4 turns | 1 per 3 battles | +| 2 | +27% | 4 turns | 1 per 3 battles | +| 3 | +30% | 4 turns | 1 per 3 battles | +| 4 | +32% | 5 turns | 1 per 2 battles | +| 5 | +35% | 5 turns | 1 per 2 battles | +| 6 | +37% | 5 turns | 1 per battle | +| 7 | +40% | 6 turns | 1 per battle | +| 8 | +42% | 6 turns | 2 per battle | +| 9 | +45% | 6 turns | 2 per battle | +| 10+ | +50% | 8 turns | 3 per battle, also +10% crit | + +**Bonus at Level 10:** "GU Guru" - Also adds +10% critical hit chance + +### Combat Mechanics + +**How the buff stacks with attacks:** +``` +Normal Attack: 25 damage +With Gel Pack (+25%): 25 ร— 1.25 = 31 damage + +Brand New Hokas: 40 damage (two hits) +With Gel Pack: 40 ร— 1.25 = 50 damage + +Shin Kick: 62 damage +With Gel Pack: 62 ร— 1.25 = 77 damage +``` + +**Optimal timing:** +- Use Gel Pack turn 1, then unload damage skills +- 4 turns is enough for 3-4 buffed attacks +- Pairs especially well with Brand New Hokas (double benefit) + +### How It Works In-Game + +``` +โ”Œโ”€ BATTLE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ ๐ŸฆŽ Trail Lizard โ”‚ +โ”‚ HP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ 50/50 โ”‚ +โ”‚ โ”‚ +โ”‚ ๐Ÿƒ You โ”‚ +โ”‚ HP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ 100/100 โ”‚ +โ”‚ MP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ 50/50 โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ [โš”๏ธ Attack ] [๐Ÿ‘Ÿ New Hokas ] โ”‚ +โ”‚ [๐Ÿฆต Shin Kick] [๐Ÿงด Gel Pack ] โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +**Player selects Gel Pack:** + +``` +โ”Œโ”€ BATTLE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ โ”‚ +โ”‚ ๐Ÿงด GEL PACK! โ”‚ +โ”‚ โ”‚ +โ”‚ *Tears open packet* โ”‚ +โ”‚ *Squeezes directly into โ”‚ +โ”‚ mouth* โ”‚ +โ”‚ *Tries not to gag* โ”‚ +โ”‚ โ”‚ +โ”‚ โšก ATK +25% for 4 turns! โ”‚ +โ”‚ โ”‚ +โ”‚ ๐Ÿƒ You [๐Ÿงด GELLED] โ”‚ +โ”‚ HP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ 100/100 โ”‚ +โ”‚ MP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘ 32/50 โ”‚ +โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +**Buff indicator persists:** +``` +โ”‚ ๐Ÿƒ You [๐Ÿงด 3 turns] โ”‚ โ† Shows remaining duration +``` + +### Edge Cases + +- **Already have the buff?** Can't stack, refreshes duration instead +- **Buff expires mid-battle?** Shows "Sugar crash..." message +- **Multiple uses (Lv8+)?** Can refresh before expiring for continuous uptime +- **Does it affect healing?** No, damage only (it's SUGAR not PROTEIN) + +### Synergy Notes + +**Gel Pack + Brand New Hokas combo:** +- Gel Pack Turn 1 (+25% damage) +- Brand New Hokas Turn 2, 3, 4 (all buffed) +- Each Hokas attack now deals ~50 damage instead of 40 +- Total extra damage over 4 turns: ~30-40 bonus damage + +**Gel Pack + Shin Kick combo:** +- Gel Pack Turn 1 +- Shin Kick Turn 2 for ~77 damage instead of 62 +- Finish with buffed normal attacks + +--- + +## Level 5: Pole Vault โš”๏ธ + +### The Skill +``` +Name: Pole Vault +Type: Offensive +MP Cost: 16 +Cooldown: None (usable every turn) +Effect: 150% damage attack with 40% chance to stun enemy for 1 turn +``` + +*"These carbon fiber trekking poles cost $250. Time to get my money's worth."* + +The Trail Runner plants their premium trekking pole and launches themselves foot-first into the enemy's face. It's not what the manufacturer intended, but it's devastatingly effective. + +### Skill Leveling Progression + +| Skill Level | Damage | Stun Chance | MP Cost | +|-------------|--------|-------------|---------| +| 1 | 150% | 40% | 16 | +| 2 | 155% | 42% | 16 | +| 3 | 160% | 45% | 15 | +| 4 | 165% | 47% | 15 | +| 5 | 170% | 50% | 14 | +| 6 | 175% | 52% | 14 | +| 7 | 180% | 55% | 13 | +| 8 | 185% | 57% | 13 | +| 9 | 190% | 60% | 12 | +| 10+ | 200% | 65% | 12, "Pole Position" - stun lasts 2 turns | + +**Milestone at Level 10:** "Pole Position" - Stuns now last 2 turns instead of 1 + +### Combat Mechanics + +**Comparison to other offensive skills:** +| Skill | Damage | Special | MP Cost | +|-------|--------|---------|---------| +| Brand New Hokas | 160% (2ร—80%) | Two hits | 12 | +| Shin Kick | 250% | Once per battle | 20 | +| Pole Vault | 150% | 40% stun | 16 | + +**When to use Pole Vault:** +- Enemy is about to use a big attack (stun prevents it) +- You need breathing room to heal next turn +- Setting up for a Shin Kick (stunned enemies can't dodge) + +**Stun mechanics:** +- Stunned enemies skip their next turn +- Stunned enemies take 10% bonus damage from all sources +- Stun doesn't stack (refreshes duration if re-applied) + +### How It Works In-Game + +``` +โ”Œโ”€ BATTLE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ ๐Ÿ— Wild Boar โ”‚ +โ”‚ HP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘ 48/80 โ”‚ +โ”‚ [๐Ÿ”„ Charging up...] โ”‚ โ† About to do something bad +โ”‚ โ”‚ +โ”‚ ๐Ÿƒ You โ”‚ +โ”‚ HP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘ 55/100 โ”‚ +โ”‚ MP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘ 40/50 โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ [โš”๏ธ Attack ] [๐Ÿ‘Ÿ New Hokas ] โ”‚ +โ”‚ [๐Ÿฆต Shin Kick] [๐Ÿฅข Pole Vault ] โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +**Player selects Pole Vault:** + +``` +โ”Œโ”€ BATTLE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ โ”‚ +โ”‚ ๐Ÿฅข POLE VAULT! โ”‚ +โ”‚ โ”‚ +โ”‚ *Plants trekking pole* โ”‚ +โ”‚ *YEETS self at enemy* โ”‚ +โ”‚ *Both feet connect* โ”‚ +โ”‚ โ”‚ +โ”‚ ๐Ÿ’ฅ 38 DAMAGE! โ”‚ +โ”‚ โญ STUNNED! โ”‚ +โ”‚ โ”‚ +โ”‚ ๐Ÿ— Wild Boar [๐Ÿ’ซ STUNNED] โ”‚ +โ”‚ HP: โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 10/80 โ”‚ +โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +**If stun fails (60% chance at Lv1):** +``` +โ”‚ ๐Ÿ’ฅ 38 DAMAGE! โ”‚ +โ”‚ (Boar shakes it off) โ”‚ +``` + +### Strategic Considerations + +**Pole Vault vs Brand New Hokas:** +- Hokas: More reliable damage (160% vs 150%) +- Pole Vault: Chance to prevent enemy turn entirely +- Use Hokas for pure DPS, Pole Vault for control + +**Pole Vault + Shin Kick combo:** +1. Pole Vault (hope for stun) +2. If stunned: Free Shin Kick next turn (250% damage + 10% stun bonus = 275%) +3. Enemy wasted a turn, you dealt massive damage + +**Pole Vault + Runner's High combo:** +1. Pole Vault to stun +2. Runner's High to heal safely while enemy is stunned +3. Resume attacking at higher HP + +### Edge Cases + +- **Not enough MP?** Greyed out, shows "Need 16 MP" +- **Enemy already stunned?** Can still use, refreshes stun duration +- **Enemy immune to stun?** Damage still applies, shows "Immune to stun!" +- **Critical hit?** Stun chance increases to 60% on crits + +--- + +## Implementation Considerations + +### Data We Need to Track + +```javascript +player = { + hp: 80, + maxHp: 100, + mp: 35, + maxMp: 50, + + skills: { + secondWind: { + unlocked: true, + cooldownEnds: 1704567890000, // Unix timestamp + }, + steadyPace: { + unlocked: true, + // No cooldown for passives + } + }, + + // For passive tracking + stats: { + hpSavedBySteadyPace: 47, + distanceTraveledToday: 8500, // meters + elevationGainedToday: 320, // meters + } +} +``` + +### GPS Integration + +The drain system needs to integrate with GPS tracking: + +```javascript +function onPositionUpdate(newPosition) { + const distance = calculateDistance(lastPosition, newPosition); + const elevation = calculateElevationGain(lastPosition, newPosition); + + let drain = 0; + + // Base distance drain + drain += distance / 100; // 1 HP per 100m + + // Elevation drain + drain += (elevation / 10) * 2; // 2 HP per 10m climbed + + // Apply terrain modifier (from trail data) + drain *= getTerrainModifier(currentTrail); + + // Apply passive skills + if (player.skills.steadyPace.unlocked) { + drain *= 0.85; // -15% + } + + // Apply racial bonuses + drain *= player.race.movementDrainModifier; + + // Apply the drain + player.hp = Math.max(0, player.hp - drain); + + updateUI(); +} +``` + +--- + +## Next Steps + +- [ ] Define how MP regenerates +- [ ] Define what Difficult Terrain actually means (GPS data? Manual marking?) +- [ ] Design the skills UI +- [ ] Define Level 3: Trail Legs mechanics diff --git a/docs/TRAIL_RUNNER_SKILLS_FULL.md b/docs/TRAIL_RUNNER_SKILLS_FULL.md new file mode 100644 index 0000000..ba9d2d1 --- /dev/null +++ b/docs/TRAIL_RUNNER_SKILLS_FULL.md @@ -0,0 +1,593 @@ +# Trail Runner - Complete Skill Mechanics + +## Class Overview + +**Identity:** Gear snob, endurance athlete, trail obsessed, owns $200 shoes, has opinions about hydration vests + +**Primary Stats:** STR, CON +**HP per Level:** +12 +**MP per Level:** +3 + +--- + +# Level 1: Brand New Hokas ๐Ÿ‘Ÿ + +*"These cost $180 and they're worth EVERY PENNY."* + +**Type:** Offensive +**Base Effect:** Attack twice in a single turn + +The Trail Runner's premium cushioned footwear allows for lightning-fast strikes. Two rapid kicks delivered with the confidence that only expensive gear can provide. + +## Skill Level Progression + +| Skill Lv | Damage per Hit | Total Damage | MP Cost | Special | +|----------|---------------|--------------|---------|---------| +| 1 | 80% | 160% | 12 | - | +| 2 | 82% | 164% | 12 | - | +| 3 | 85% | 170% | 11 | - | +| 4 | 87% | 174% | 11 | - | +| 5 | 90% | 180% | 10 | ๐Ÿ… "Swift Strikes" - faster animation | +| 6 | 92% | 184% | 10 | - | +| 7 | 95% | 190% | 9 | - | +| 8 | 97% | 194% | 9 | - | +| 9 | 100% | 200% | 8 | ๐Ÿ… "True Double" - full damage both hits | +| 10 | 100% | 200% | 8 | +5% crit chance | +| 11 | 100% | 200% | 8 | +10% crit chance | +| 12 | 100% | 200% | 8 | +15% crit chance | +| 13+ | 100% | 200% | 8 | +5% crit per level beyond 10 | + +## Detailed Breakdown + +### Skill Level 1 +``` +Damage: 80% per hit (160% total) +MP Cost: 12 +Cooldown: None +Crits: Each hit rolls independently +``` +**Example:** Base attack = 25 damage +- Hit 1: 25 ร— 0.80 = 20 damage +- Hit 2: 25 ร— 0.80 = 20 damage +- Total: 40 damage for 12 MP + +### Skill Level 5 - "Swift Strikes" +``` +Damage: 90% per hit (180% total) +MP Cost: 10 +Cooldown: None +Bonus: Animation speed increased (QoL) +``` +**Example:** Base attack = 25 damage +- Hit 1: 25 ร— 0.90 = 22 damage +- Hit 2: 25 ร— 0.90 = 23 damage +- Total: 45 damage for 10 MP + +### Skill Level 9 - "True Double" +``` +Damage: 100% per hit (200% total) +MP Cost: 8 +Cooldown: None +Bonus: Each hit now deals FULL attack damage +``` +**Example:** Base attack = 25 damage +- Hit 1: 25 ร— 1.00 = 25 damage +- Hit 2: 25 ร— 1.00 = 25 damage +- Total: 50 damage for 8 MP + +### Skill Level 10+ - "Relentless" +``` +Damage: 100% per hit (200% total) +MP Cost: 8 +Cooldown: None +Bonus: +5% critical hit chance per level beyond 9 +``` +**At Level 15:** +- Base crit chance: 5% +- Bonus from skill: +25% (5 levels ร— 5%) +- Total crit chance: 30% per hit + +## XP to Level + +| Skill Lv | XP Required | Cumulative XP | +|----------|-------------|---------------| +| 1 โ†’ 2 | 50 | 50 | +| 2 โ†’ 3 | 100 | 150 | +| 3 โ†’ 4 | 150 | 300 | +| 4 โ†’ 5 | 200 | 500 | +| 5 โ†’ 6 | 300 | 800 | +| 6 โ†’ 7 | 400 | 1,200 | +| 7 โ†’ 8 | 500 | 1,700 | +| 8 โ†’ 9 | 600 | 2,300 | +| 9 โ†’ 10 | 700 | 3,000 | +| 10+ | 500 per level | +500 each | + +**XP earned per use:** 10 base + 5 if kills enemy + 5 if both hits crit + +--- + +# Level 2: Runner's High ๐Ÿƒโ€โ™‚๏ธ + +*"Around mile 8, something magical happens... the pain becomes pleasure."* + +**Type:** Utility (Healing) +**Base Effect:** Restore HP based on max HP + +The Trail Runner taps into that euphoric state where endorphins flood the system and everything feels possible. Pain melts away, replaced by a serene confidence. + +## Skill Level Progression + +| Skill Lv | HP Restored | MP Cost | Cooldown | Special | +|----------|-------------|---------|----------|---------| +| 1 | 30% max HP | 15 | 1 per 5 battles | - | +| 2 | 30% max HP | 15 | 1 per 4 battles | - | +| 3 | 30% max HP | 14 | 1 per 3 battles | - | +| 4 | 35% max HP | 14 | 1 per 2 battles | ๐Ÿ… Healing increased | +| 5 | 35% max HP | 13 | 1 per battle | ๐Ÿ… "Reliable Recovery" | +| 6 | 40% max HP | 13 | 2 per battle | ๐Ÿ… Multi-use unlocked | +| 7 | 40% max HP | 12 | 3 per battle | - | +| 8 | 45% max HP | 12 | 4 per battle | ๐Ÿ… "Endorphin Overload" | +| 9 | 45% max HP | 11 | 5 per battle | - | +| 10 | 50% max HP | 10 | 6 per battle | ๐Ÿ… "Ultrarunner's Zen" | +| 11+ | 50% max HP | 10 | +1 use per level | - | + +## Detailed Breakdown + +### Skill Level 1 +``` +Healing: 30% of max HP +MP Cost: 15 +Cooldown: Once per 5 battles +Uses: 1 charge, recharges after 5 fights +``` +**Example:** Max HP = 100 +- Heals: 30 HP +- Must wait 5 battles before using again +- VERY precious, save for emergencies + +**Cooldown Tracking:** +``` +Battle 1: Use Runner's High โœ“ +Battle 2: [4 battles remaining] +Battle 3: [3 battles remaining] +Battle 4: [2 battles remaining] +Battle 5: [1 battle remaining] +Battle 6: Runner's High READY โœ“ +``` + +### Skill Level 5 - "Reliable Recovery" +``` +Healing: 35% of max HP +MP Cost: 13 +Cooldown: Once per battle +Uses: Available every fight +``` +**Example:** Max HP = 120 +- Heals: 42 HP +- Can use once per fight, every fight +- Now a reliable tool instead of emergency-only + +### Skill Level 6 - Multi-Use Unlocked +``` +Healing: 40% of max HP +MP Cost: 13 +Cooldown: Twice per battle +Uses: 2 charges per fight +``` +**Example:** Max HP = 130 +- Heals: 52 HP per use +- Can use TWICE in the same battle +- Total potential healing: 104 HP per fight + +### Skill Level 10 - "Ultrarunner's Zen" +``` +Healing: 50% of max HP +MP Cost: 10 +Cooldown: 6 times per battle +Uses: 6 charges per fight +``` +**Example:** Max HP = 150 +- Heals: 75 HP per use +- Can use up to 6 times per battle +- Total potential healing: 450 HP per fight (3x your max HP!) +- Limited only by MP (60 MP needed for all 6) + +## XP to Level + +| Skill Lv | XP Required | Cumulative XP | +|----------|-------------|---------------| +| 1 โ†’ 2 | 50 | 50 | +| 2 โ†’ 3 | 100 | 150 | +| 3 โ†’ 4 | 150 | 300 | +| 4 โ†’ 5 | 200 | 500 | +| 5 โ†’ 6 | 300 | 800 | +| 6 โ†’ 7 | 400 | 1,200 | +| 7 โ†’ 8 | 500 | 1,700 | +| 8 โ†’ 9 | 600 | 2,300 | +| 9 โ†’ 10 | 700 | 3,000 | +| 10+ | 500 per level | +500 each | + +**XP earned per use:** 10 base + 10 if healed from below 30% HP + 5 if survived battle after healing + +--- + +# Level 3: Shin Kick ๐Ÿฆต + +*"Right in the shin. You KNOW how much that hurts."* + +**Type:** Offensive (Nuke) +**Base Effect:** Massive single-target damage, limited uses + +Every trail runner knows the agony of a shin strike - whether from a hidden root, a rock, or an aggressive trail marker. Now weaponize that pain against your enemies. + +## Skill Level Progression + +| Skill Lv | Damage | MP Cost | Cooldown | Special | +|----------|--------|---------|----------|---------| +| 1 | 250% | 20 | 1 per battle | - | +| 2 | 265% | 20 | 1 per battle | - | +| 3 | 280% | 19 | 1 per battle | - | +| 4 | 295% | 19 | 1 per battle | - | +| 5 | 310% | 18 | 1 per battle | ๐Ÿ… 10% cooldown reset chance | +| 6 | 325% | 18 | 1 per battle | 15% reset chance | +| 7 | 340% | 17 | 1 per battle | 20% reset chance | +| 8 | 355% | 17 | 1 per battle | 25% reset chance | +| 9 | 370% | 16 | 1 per battle | 30% reset chance | +| 10 | 400% | 15 | 1 per battle | ๐Ÿ… 35% reset + 1 turn stun | +| 11+ | 400% + 10%/lv | 15 | 1 per battle | +2% reset chance per level | + +## Detailed Breakdown + +### Skill Level 1 +``` +Damage: 250% of base attack +MP Cost: 20 +Cooldown: Once per battle (hard limit) +Reset: None +``` +**Example:** Base attack = 25 damage +- Shin Kick: 25 ร— 2.50 = 62 damage +- Can only use once per fight +- Save for when it matters most + +### Skill Level 5 - Reset Chance Unlocked +``` +Damage: 310% of base attack +MP Cost: 18 +Cooldown: Once per battle +Reset: 10% chance to reset cooldown on use +``` +**Example:** Base attack = 28 damage +- Shin Kick: 28 ร— 3.10 = 87 damage +- 10% chance to immediately use again +- Shows "OTHER SHIN!" if reset triggers + +**Reset Mechanic:** +``` +Use Shin Kick โ†’ 87 damage! +Roll for reset: 10% chance + โ”œโ”€ Success (10%): "OTHER SHIN!" - Skill available again + โ””โ”€ Fail (90%): "Shin Sore" - Normal cooldown +``` + +### Skill Level 10 - "Bone Bruise" +``` +Damage: 400% of base attack +MP Cost: 15 +Cooldown: Once per battle +Reset: 35% chance to reset cooldown +Bonus: Stuns enemy for 1 turn +``` +**Example:** Base attack = 35 damage +- Shin Kick: 35 ร— 4.00 = 140 damage +- Enemy is STUNNED (skips next turn) +- 35% chance to kick again immediately +- Potential for back-to-back 140 damage + double stun + +### Skill Level 15 +``` +Damage: 450% of base attack (400% + 50% from 5 levels) +MP Cost: 15 +Cooldown: Once per battle +Reset: 45% chance (35% + 10% from 5 levels) +Bonus: Stuns enemy for 1 turn +``` + +## XP to Level + +| Skill Lv | XP Required | Cumulative XP | +|----------|-------------|---------------| +| 1 โ†’ 2 | 50 | 50 | +| 2 โ†’ 3 | 100 | 150 | +| 3 โ†’ 4 | 150 | 300 | +| 4 โ†’ 5 | 200 | 500 | +| 5 โ†’ 6 | 300 | 800 | +| 6 โ†’ 7 | 400 | 1,200 | +| 7 โ†’ 8 | 500 | 1,700 | +| 8 โ†’ 9 | 600 | 2,300 | +| 9 โ†’ 10 | 700 | 3,000 | +| 10+ | 500 per level | +500 each | + +**XP earned per use:** 10 base + 10 if kills enemy + 15 if reset triggers + +--- + +# Level 4: Gel Pack ๐Ÿงด + +*"It tastes like chemical birthday cake but it WORKS."* + +**Type:** Utility (Buff) +**Base Effect:** Boost attack damage for several turns + +The Trail Runner squeezes down an energy gel - that weird gooey substance that costs $3 per packet and has the consistency of toothpaste. Disgusting? Yes. Effective? Absolutely. + +## Skill Level Progression + +| Skill Lv | ATK Boost | Duration | MP Cost | Cooldown | Special | +|----------|-----------|----------|---------|----------|---------| +| 1 | +25% | 4 turns | 18 | 1 per 3 battles | - | +| 2 | +27% | 4 turns | 18 | 1 per 3 battles | - | +| 3 | +30% | 4 turns | 17 | 1 per 3 battles | - | +| 4 | +32% | 5 turns | 17 | 1 per 2 battles | ๐Ÿ… Duration up | +| 5 | +35% | 5 turns | 16 | 1 per 2 battles | - | +| 6 | +37% | 5 turns | 16 | 1 per battle | ๐Ÿ… Every battle | +| 7 | +40% | 6 turns | 15 | 1 per battle | ๐Ÿ… Duration up | +| 8 | +42% | 6 turns | 15 | 2 per battle | ๐Ÿ… Multi-use | +| 9 | +45% | 6 turns | 14 | 2 per battle | - | +| 10 | +50% | 8 turns | 14 | 3 per battle | ๐Ÿ… "GU Guru" +10% crit | +| 11+ | +50% | 8 turns | 14 | +1 use per level | - | + +## Detailed Breakdown + +### Skill Level 1 +``` +ATK Boost: +25% damage on all attacks +Duration: 4 turns +MP Cost: 18 +Cooldown: Once per 3 battles +``` +**Example Combat:** +``` +Turn 1: Gel Pack (buff active, 4 turns remain) +Turn 2: Attack 25 ร— 1.25 = 31 damage (3 turns remain) +Turn 3: Attack 25 ร— 1.25 = 31 damage (2 turns remain) +Turn 4: Attack 25 ร— 1.25 = 31 damage (1 turn remains) +Turn 5: Attack 25 ร— 1.25 = 31 damage (buff expires) +Turn 6: Attack 25 damage (no buff) +``` +**Cooldown Tracking:** +``` +Battle 1: Gel Pack used โœ“ +Battle 2: [2 battles remaining] +Battle 3: [1 battle remaining] +Battle 4: Gel Pack READY โœ“ +``` + +### Skill Level 6 - Every Battle +``` +ATK Boost: +37% damage on all attacks +Duration: 5 turns +MP Cost: 16 +Cooldown: Once per battle +``` +- Now available in EVERY fight +- +37% is significant - turns 25 damage into 34 +- 5 turns usually covers most of a battle + +### Skill Level 8 - Multi-Use +``` +ATK Boost: +42% damage on all attacks +Duration: 6 turns +MP Cost: 15 +Cooldown: Twice per battle +Uses: Can refresh or double-apply? +``` +**Stacking Rules:** +- Buff does NOT stack with itself +- Using again REFRESHES duration to full +- Useful for long fights to maintain 100% uptime + +### Skill Level 10 - "GU Guru" +``` +ATK Boost: +50% damage on all attacks +Duration: 8 turns +MP Cost: 14 +Cooldown: 3 times per battle +Bonus: Also grants +10% critical hit chance +``` +**Example Combat:** +``` +Turn 1: Gel Pack (+50% ATK, +10% crit for 8 turns) +Turn 2: Brand New Hokas = 50 ร— 1.50 = 75 damage (with +10% crit!) +Turn 3: Shin Kick = 140 ร— 1.50 = 210 damage (with +10% crit!) +... +Turn 8: Still buffed! +Turn 9: Gel Pack again to refresh +``` + +## Interaction with Other Skills + +| Skill | Base Damage | With Gel Pack Lv1 (+25%) | With Gel Pack Lv10 (+50%) | +|-------|-------------|--------------------------|---------------------------| +| Normal Attack | 25 | 31 | 38 | +| Brand New Hokas | 40 | 50 | 60 | +| Shin Kick | 62 | 78 | 93 | +| Pole Vault | 38 | 48 | 57 | + +## XP to Level + +| Skill Lv | XP Required | Cumulative XP | +|----------|-------------|---------------| +| 1 โ†’ 2 | 50 | 50 | +| 2 โ†’ 3 | 100 | 150 | +| 3 โ†’ 4 | 150 | 300 | +| 4 โ†’ 5 | 200 | 500 | +| 5 โ†’ 6 | 300 | 800 | +| 6 โ†’ 7 | 400 | 1,200 | +| 7 โ†’ 8 | 500 | 1,700 | +| 8 โ†’ 9 | 600 | 2,300 | +| 9 โ†’ 10 | 700 | 3,000 | +| 10+ | 500 per level | +500 each | + +**XP earned per use:** 10 base + 5 per buffed attack that hits + 10 if win battle while buffed + +--- + +# Level 5: Pole Vault ๐Ÿฅข + +*"These carbon fiber trekking poles cost $250. Time to get my money's worth."* + +**Type:** Offensive (Crowd Control) +**Base Effect:** Damage with chance to stun + +The Trail Runner plants their premium trekking pole and launches themselves foot-first into the enemy's face. It's not what the manufacturer intended, but it's devastatingly effective. + +## Skill Level Progression + +| Skill Lv | Damage | Stun Chance | Stun Duration | MP Cost | Special | +|----------|--------|-------------|---------------|---------|---------| +| 1 | 150% | 40% | 1 turn | 16 | - | +| 2 | 155% | 42% | 1 turn | 16 | - | +| 3 | 160% | 45% | 1 turn | 15 | - | +| 4 | 165% | 47% | 1 turn | 15 | - | +| 5 | 170% | 50% | 1 turn | 14 | ๐Ÿ… 50/50 stun chance | +| 6 | 175% | 52% | 1 turn | 14 | - | +| 7 | 180% | 55% | 1 turn | 13 | - | +| 8 | 185% | 57% | 1 turn | 13 | - | +| 9 | 190% | 60% | 1 turn | 12 | - | +| 10 | 200% | 65% | 2 turns | 12 | ๐Ÿ… "Pole Position" | +| 11 | 205% | 67% | 2 turns | 12 | - | +| 12 | 210% | 70% | 2 turns | 12 | - | +| 13+ | +5%/lv | +2%/lv | 2 turns | 12 | - | + +## Detailed Breakdown + +### Skill Level 1 +``` +Damage: 150% of base attack +Stun: 40% chance +Duration: 1 turn (enemy skips next turn) +MP Cost: 16 +Cooldown: None (usable every turn) +``` +**Example:** Base attack = 25 damage +- Pole Vault: 25 ร— 1.50 = 38 damage +- Roll for stun: 40% chance +- If stunned: Enemy loses next turn + +**Stun Value:** +- Prevents ~25-30 damage from enemy attack +- Gives you a free turn to heal or attack +- Stunned enemies take +10% damage + +### Skill Level 5 - Coin Flip Stun +``` +Damage: 170% of base attack +Stun: 50% chance (coin flip!) +Duration: 1 turn +MP Cost: 14 +``` +**Example:** Base attack = 28 damage +- Pole Vault: 28 ร— 1.70 = 48 damage +- 50/50 shot at stunning +- Much more reliable CC + +### Skill Level 10 - "Pole Position" +``` +Damage: 200% of base attack +Stun: 65% chance +Duration: 2 TURNS (enemy skips TWO turns) +MP Cost: 12 +``` +**Example:** Base attack = 35 damage +- Pole Vault: 35 ร— 2.00 = 70 damage +- 65% chance to stun for 2 FULL TURNS +- Enemy loses 2 attacks worth ~60+ damage +- You get 2 free turns to unload + +**2-Turn Stun Combo:** +``` +Turn 1: Pole Vault โ†’ 70 damage, STUNNED 2 turns +Turn 2: (Enemy stunned) Shin Kick โ†’ 140 ร— 1.10 = 154 damage +Turn 3: (Enemy stunned) Brand New Hokas โ†’ 50 ร— 1.10 = 55 damage +Turn 4: Enemy finally acts (279 damage taken!) +``` + +### Skill Level 15 +``` +Damage: 225% of base attack +Stun: 75% chance +Duration: 2 turns +MP Cost: 12 +``` +- Nearly guaranteed stun +- Massive damage + control +- Can chain stuns to lock down enemies + +## Stun Mechanics Deep Dive + +**What Stunned Does:** +- Enemy skips their turn completely +- Enemy takes +10% damage from all sources +- Visual: ๐Ÿ’ซ spinning stars indicator +- Cannot be stunned again while stunned (prevents permastun abuse) + +**Stun Interactions:** +| Situation | Result | +|-----------|--------| +| Enemy already stunned | Damage applies, no stun refresh | +| Boss/Elite enemy | 50% stun resistance (halve your chance) | +| Enemy immune to stun | Full damage, "Immune!" message | +| Critical hit | +20% stun chance | + +## XP to Level + +| Skill Lv | XP Required | Cumulative XP | +|----------|-------------|---------------| +| 1 โ†’ 2 | 50 | 50 | +| 2 โ†’ 3 | 100 | 150 | +| 3 โ†’ 4 | 150 | 300 | +| 4 โ†’ 5 | 200 | 500 | +| 5 โ†’ 6 | 300 | 800 | +| 6 โ†’ 7 | 400 | 1,200 | +| 7 โ†’ 8 | 500 | 1,700 | +| 8 โ†’ 9 | 600 | 2,300 | +| 9 โ†’ 10 | 700 | 3,000 | +| 10+ | 500 per level | +500 each | + +**XP earned per use:** 10 base + 15 if stun lands + 5 if enemy dies while stunned + +--- + +# Skill Comparison Matrix + +## Damage Per MP Spent + +| Skill | Damage | MP | Damage/MP | Notes | +|-------|--------|-----|-----------|-------| +| Brand New Hokas Lv1 | 160% | 12 | 13.3%/MP | Best sustained DPS | +| Brand New Hokas Lv9 | 200% | 8 | 25%/MP | Incredible efficiency | +| Shin Kick Lv1 | 250% | 20 | 12.5%/MP | Burst, limited use | +| Shin Kick Lv10 | 400% | 15 | 26.7%/MP | Best burst + stun | +| Pole Vault Lv1 | 150% | 16 | 9.4%/MP | Worst raw DPS | +| Pole Vault Lv10 | 200% | 12 | 16.7%/MP | Value is in stun | + +## Cooldown Comparison + +| Skill | Lv1 Cooldown | Lv5 Cooldown | Lv10 Cooldown | +|-------|--------------|--------------|---------------| +| Brand New Hokas | None | None | None | +| Runner's High | 1/5 battles | 1/battle | 6/battle | +| Shin Kick | 1/battle | 1/battle (+10% reset) | 1/battle (+35% reset) | +| Gel Pack | 1/3 battles | 1/2 battles | 3/battle | +| Pole Vault | None | None | None | + +## Role Summary + +| Skill | Primary Role | Secondary Role | +|-------|--------------|----------------| +| Brand New Hokas | Sustained DPS | MP efficient | +| Runner's High | Emergency Heal | Sustain in long fights | +| Shin Kick | Burst Damage | Execute low HP enemies | +| Gel Pack | Damage Amplifier | Crit boost at Lv10 | +| Pole Vault | Crowd Control | Setup for combos | diff --git a/index.html b/index.html index ce618e6..1c62954 100644 --- a/index.html +++ b/index.html @@ -437,67 +437,6 @@ font-size: 11px; text-align: center; } - .password-dialog { - position: fixed !important; - top: 0 !important; - left: 0 !important; - right: 0 !important; - bottom: 0 !important; - width: 100vw !important; - height: 100vh !important; - background: rgba(0, 0, 0, 0.5) !important; - display: flex; - align-items: center; - justify-content: center; - z-index: 999998 !important; - pointer-events: auto !important; - } - .password-dialog-content { - background: white; - padding: 30px; - border-radius: 12px; - width: 90%; - max-width: 400px; - } - .password-dialog h3 { - margin-bottom: 20px; - text-align: center; - } - .password-dialog input { - width: 100%; - padding: 10px; - font-size: 16px; - border: 2px solid #ddd; - border-radius: 4px; - margin-bottom: 15px; - } - .password-dialog-buttons { - display: flex; - gap: 10px; - } - .password-dialog button { - flex: 1; - padding: 10px; - border: none; - border-radius: 4px; - cursor: pointer; - font-size: 14px; - } - .password-dialog .submit-btn { - background: #007bff; - color: white; - } - .password-dialog .cancel-btn { - background: #6c757d; - color: white; - } - .password-error { - color: #dc3545; - font-size: 14px; - margin-top: -10px; - margin-bottom: 10px; - display: none; - } /* Geocache styles */ .geocache-marker { background: transparent; @@ -1096,222 +1035,1114 @@ z-index: 1000; display: none; } - - - -
-
N
- - - - - - - - - - - - - -
Settings Saved โœ“
- - - - - -
- ๐Ÿ“ Geocache nearby! Click to view messages. -
- - -
-
-

๐Ÿ“ Geocaches

- -
-
- -
-
- - -
- -
- - - - - - - - -
Hold to set destination...
- - - - + /* Auth Modal Styles */ + .auth-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 999999; + } + .auth-modal { + background: white; + padding: 30px; + border-radius: 12px; + width: 90%; + max-width: 400px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + } + .auth-modal h2 { + text-align: center; + margin-bottom: 20px; + color: #333; + } + .auth-tabs { + display: flex; + margin-bottom: 20px; + border-bottom: 2px solid #ddd; + } + .auth-tab { + flex: 1; + padding: 10px; + border: none; + background: #f0f0f0; + cursor: pointer; + font-size: 14px; + font-weight: bold; + transition: all 0.2s; + } + .auth-tab:first-child { + border-radius: 4px 0 0 0; + } + .auth-tab:last-child { + border-radius: 0 4px 0 0; + } + .auth-tab.active { + background: #4CAF50; + color: white; + } + .auth-form { + display: none; + } + .auth-form.active { + display: block; + } + .auth-input-group { + margin-bottom: 15px; + } + .auth-input-group label { + display: block; + margin-bottom: 5px; + font-size: 13px; + color: #555; + } + .auth-input-group input { + width: 100%; + padding: 10px; + border: 2px solid #ddd; + border-radius: 4px; + font-size: 14px; + } + .auth-input-group input:focus { + border-color: #4CAF50; + outline: none; + } + .auth-error { + color: #dc3545; + font-size: 13px; + margin-bottom: 10px; + padding: 8px; + background: #f8d7da; + border-radius: 4px; + display: none; + } + .auth-error.visible { + display: block; + } + .auth-submit-btn { + width: 100%; + padding: 12px; + background: #4CAF50; + color: white; + border: none; + border-radius: 4px; + font-size: 14px; + font-weight: bold; + cursor: pointer; + transition: background 0.2s; + } + .auth-submit-btn:hover { + background: #43a047; + } + .auth-submit-btn:disabled { + background: #9e9e9e; + cursor: not-allowed; + } + .auth-close-btn { + position: absolute; + top: 10px; + right: 15px; + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: #666; + } + /* User Profile Display */ + .user-profile { + display: flex; + align-items: center; + padding: 10px; + background: linear-gradient(135deg, #4CAF50 0%, #2E7D32 100%); + border-radius: 8px; + margin-bottom: 15px; + color: white; + } + .user-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.2); + display: flex; + align-items: center; + justify-content: center; + margin-right: 10px; + } + .user-avatar i { + font-size: 24px; + } + .user-info { + flex: 1; + } + .user-name { + font-weight: bold; + font-size: 14px; + } + .user-points { + font-size: 12px; + opacity: 0.9; + } + .user-logout-btn { + background: rgba(255, 255, 255, 0.2); + border: none; + color: white; + padding: 5px 10px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + } + .user-logout-btn:hover { + background: rgba(255, 255, 255, 0.3); + } + .login-prompt { + text-align: center; + padding: 15px; + background: #f8f9fa; + border-radius: 8px; + margin-bottom: 15px; + } + .login-prompt-text { + font-size: 13px; + color: #666; + margin-bottom: 10px; + } + .login-prompt-btn { + background: #4CAF50; + color: white; + border: none; + padding: 8px 20px; + border-radius: 4px; + cursor: pointer; + font-size: 13px; + } + .login-prompt-btn:hover { + background: #43a047; + } + /* Found It Button */ + .found-it-btn { + width: 100%; + padding: 12px; + background: linear-gradient(135deg, #FFA726 0%, #FF7043 100%); + color: white; + border: none; + border-radius: 8px; + font-size: 16px; + font-weight: bold; + cursor: pointer; + margin-top: 10px; + transition: all 0.3s; + } + .found-it-btn:hover { + transform: scale(1.02); + box-shadow: 0 4px 15px rgba(255, 167, 38, 0.4); + } + .found-it-btn:disabled { + background: #9e9e9e; + cursor: not-allowed; + transform: none; + box-shadow: none; + } + .found-it-btn.already-found { + background: linear-gradient(135deg, #66BB6A 0%, #4CAF50 100%); + } + /* Points Animation */ + .points-popup { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: linear-gradient(135deg, #FFA726 0%, #FF7043 100%); + color: white; + padding: 30px 50px; + border-radius: 20px; + font-size: 36px; + font-weight: bold; + z-index: 999999; + animation: pointsPopup 2s ease-out forwards; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3); + } + .points-popup .bonus { + font-size: 18px; + display: block; + margin-top: 5px; + opacity: 0.9; + } + @keyframes pointsPopup { + 0% { + opacity: 0; + transform: translate(-50%, -50%) scale(0.5); + } + 20% { + opacity: 1; + transform: translate(-50%, -50%) scale(1.1); + } + 40% { + transform: translate(-50%, -50%) scale(1); + } + 80% { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } + 100% { + opacity: 0; + transform: translate(-50%, -60%) scale(0.9); + } + } + /* Leaderboard Styles */ + .leaderboard-modal { + background: white; + padding: 20px; + border-radius: 12px; + width: 90%; + max-width: 500px; + max-height: 80vh; + overflow-y: auto; + } + .leaderboard-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; + } + .leaderboard-tabs { + display: flex; + gap: 5px; + margin-bottom: 15px; + } + .leaderboard-tab { + padding: 8px 15px; + border: none; + background: #f0f0f0; + border-radius: 20px; + cursor: pointer; + font-size: 12px; + } + .leaderboard-tab.active { + background: #4CAF50; + color: white; + } + .leaderboard-list { + list-style: none; + padding: 0; + margin: 0; + } + .leaderboard-item { + display: flex; + align-items: center; + padding: 12px; + border-bottom: 1px solid #eee; + } + .leaderboard-rank { + width: 30px; + font-weight: bold; + color: #666; + } + .leaderboard-rank.gold { color: #FFD700; } + .leaderboard-rank.silver { color: #C0C0C0; } + .leaderboard-rank.bronze { color: #CD7F32; } + .leaderboard-avatar { + width: 35px; + height: 35px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-right: 10px; + } + .leaderboard-user { + flex: 1; + } + .leaderboard-username { + font-weight: bold; + font-size: 14px; + } + .leaderboard-finds { + font-size: 12px; + color: #666; + } + .leaderboard-points { + font-weight: bold; + color: #4CAF50; + } + + /* ======================================== + RPG COMBAT SYSTEM STYLES + ======================================== */ + + /* Monster Marker Styles */ + .monster-marker-container { + background: transparent !important; + border: none !important; + } + .monster-marker { + position: relative; + cursor: pointer; + transition: transform 0.2s; + } + .monster-marker:hover { + transform: scale(1.3); + } + .monster-icon { + font-size: 36px; + filter: drop-shadow(2px 2px 3px rgba(0,0,0,0.5)); + animation: monster-bob 2s ease-in-out infinite; + } + @keyframes monster-bob { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-6px); } + } + .monster-dialogue-bubble { + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + background: white; + border: 2px solid #333; + border-radius: 12px; + padding: 8px 14px; + max-width: 220px; + font-size: 13px; + white-space: normal; + box-shadow: 0 3px 12px rgba(0,0,0,0.25); + z-index: 1000; + pointer-events: none; + margin-bottom: 8px; + } + .monster-dialogue-bubble::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 10px solid transparent; + border-top-color: #333; + } + + /* RPG HUD Styles */ + .rpg-hud { + position: fixed; + top: 10px; + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.85); + color: white; + padding: 10px 20px; + border-radius: 25px; + font-size: 13px; + z-index: 1000; + display: flex; + gap: 18px; + align-items: center; + border: 2px solid #e94560; + box-shadow: 0 4px 15px rgba(233, 69, 96, 0.3); + } + .rpg-hud-class { + font-weight: bold; + color: #e94560; + } + .rpg-hud-stats { + display: flex; + gap: 12px; + } + .rpg-hud-stat { + display: flex; + align-items: center; + gap: 4px; + } + .rpg-hud-stat-label { + color: #888; + font-size: 11px; + } + .rpg-hud-hp { color: #ff6b6b; } + .rpg-hud-mp { color: #4ecdc4; } + .rpg-hud-xp { + display: flex; + align-items: center; + gap: 6px; + } + .rpg-hud-xp-bar { + width: 60px; + height: 8px; + background: #333; + border-radius: 4px; + overflow: hidden; + border: 1px solid #555; + } + .rpg-hud-xp-fill { + height: 100%; + background: linear-gradient(90deg, #ffd93d, #f0c419); + transition: width 0.3s ease; + border-radius: 3px; + } + .rpg-hud-xp-text { + color: #ffd93d; + font-size: 10px; + min-width: 45px; + } + .rpg-hud-monsters { + color: #ffd93d; + } + + /* Combat Overlay Styles */ + .combat-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.9); + z-index: 100000; + display: flex; + align-items: center; + justify-content: center; + } + .combat-container { + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); + border: 3px solid #e94560; + border-radius: 20px; + padding: 25px 30px; + max-width: 480px; + width: 95%; + color: white; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + box-shadow: 0 0 40px rgba(233, 69, 96, 0.4); + } + .combat-header { + text-align: center; + margin-bottom: 20px; + } + .combat-header h2 { + color: #e94560; + font-size: 32px; + margin: 0; + text-shadow: 0 0 20px rgba(233, 69, 96, 0.6); + letter-spacing: 3px; + } + .combat-arena { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + } + .combatant { + text-align: center; + flex: 1; + } + .combatant-icon { + font-size: 56px; + margin-bottom: 8px; + } + .combatant-name { + font-weight: bold; + font-size: 15px; + margin-bottom: 10px; + } + .combat-vs { + font-size: 28px; + font-weight: bold; + color: #e94560; + padding: 0 15px; + text-shadow: 0 0 10px rgba(233, 69, 96, 0.5); + } + .stat-bars { + width: 100%; + padding: 0 5px; + } + .stat-bar-container { + margin: 6px 0; + } + .stat-bar-label { + font-size: 10px; + color: #888; + margin-bottom: 2px; + } + .hp-bar, .mp-bar { + height: 14px; + background: #333; + border-radius: 7px; + overflow: hidden; + border: 1px solid #555; + } + .hp-fill { + height: 100%; + background: linear-gradient(90deg, #e94560, #ff6b6b); + transition: width 0.4s ease; + border-radius: 6px; + } + .mp-fill { + height: 100%; + background: linear-gradient(90deg, #4ecdc4, #45b7aa); + transition: width 0.4s ease; + border-radius: 6px; + } + .stat-text { + font-size: 11px; + color: #aaa; + margin-top: 5px; + } + .combat-log { + background: rgba(0, 0, 0, 0.5); + border-radius: 10px; + padding: 12px 15px; + height: 100px; + overflow-y: auto; + margin-bottom: 18px; + font-size: 13px; + border: 1px solid #333; + } + .combat-log-entry { + margin-bottom: 6px; + padding-bottom: 6px; + border-bottom: 1px solid #333; + } + .combat-log-entry:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; + } + .combat-log-damage { + color: #ff6b6b; + } + .combat-log-heal { + color: #4ecdc4; + } + .combat-log-victory { + color: #ffd93d; + font-weight: bold; + } + .combat-skills { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; + margin-bottom: 12px; + } + .skill-btn { + background: linear-gradient(135deg, #0f3460 0%, #16213e 100%); + border: 2px solid #e94560; + color: white; + padding: 14px 10px; + border-radius: 10px; + cursor: pointer; + transition: all 0.2s; + text-align: center; + } + .skill-btn:hover:not(:disabled) { + background: linear-gradient(135deg, #e94560 0%, #c73e54 100%); + transform: scale(1.03); + box-shadow: 0 0 15px rgba(233, 69, 96, 0.4); + } + .skill-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; + } + .skill-btn .skill-name { + font-weight: bold; + display: block; + font-size: 13px; + margin-bottom: 3px; + } + .skill-btn .skill-cost { + font-size: 11px; + color: #4ecdc4; + } + .skill-btn .skill-cost.free { + color: #8f8; + } + .skill-btn .skill-cost.locked { + color: #888; + } + .skill-btn.skill-locked { + opacity: 0.5; + background: linear-gradient(135deg, #1a1a1a 0%, #0a0a0a 100%); + border-color: #444; + } + .skill-btn.skill-locked .skill-name { + color: #666; + } + .combat-flee-btn { + width: 100%; + background: #333; + border: 2px solid #555; + color: #aaa; + padding: 12px; + border-radius: 8px; + cursor: pointer; + font-size: 14px; + transition: all 0.2s; + } + .combat-flee-btn:hover { + background: #444; + border-color: #666; + color: white; + } + + /* Multi-monster combat styles */ + .combat-arena { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 20px; + min-height: 180px; + } + .player-side { + flex: 0 0 140px; + } + .monster-side { + flex: 1; + max-width: 200px; + } + .monster-list { + max-height: 200px; + overflow-y: auto; + padding-right: 5px; + } + .monster-entry { + background: rgba(0, 0, 0, 0.4); + border: 2px solid #444; + border-radius: 10px; + padding: 10px; + margin-bottom: 8px; + cursor: pointer; + transition: all 0.2s; + } + .monster-entry:last-child { + margin-bottom: 0; + } + .monster-entry:hover { + border-color: #888; + background: rgba(255, 255, 255, 0.05); + } + .monster-entry.selected { + border-color: #e94560; + box-shadow: 0 0 10px rgba(233, 69, 96, 0.4); + background: rgba(233, 69, 96, 0.1); + } + .monster-entry.dead { + opacity: 0.3; + pointer-events: none; + text-decoration: line-through; + } + .monster-entry-header { + display: flex; + align-items: center; + margin-bottom: 6px; + } + .monster-entry-icon { + font-size: 24px; + margin-right: 8px; + } + .monster-entry-name { + font-size: 12px; + font-weight: bold; + flex: 1; + } + .monster-entry-hp { + margin-top: 4px; + } + .monster-entry-hp .hp-bar { + height: 10px; + } + .monster-entry-hp .stat-text { + font-size: 10px; + margin-top: 2px; + } + .turn-indicator { + text-align: center; + padding: 8px; + margin-bottom: 12px; + border-radius: 8px; + font-weight: bold; + font-size: 14px; + } + .turn-indicator.player-turn { + background: linear-gradient(90deg, rgba(78, 205, 196, 0.2), transparent); + color: #4ecdc4; + border-left: 3px solid #4ecdc4; + } + .turn-indicator.monster-turn { + background: linear-gradient(90deg, rgba(233, 69, 96, 0.2), transparent); + color: #e94560; + border-left: 3px solid #e94560; + } + .target-arrow { + color: #e94560; + font-size: 14px; + margin-right: 4px; + } + + + + +
+
N
+ + + + + + + + + + + + + + + + +
Settings Saved โœ“
+ + + + + +
+ ๐Ÿ“ Geocache nearby! Click to view messages. +
+ + + + + + + + +
+
+

๐Ÿ“ Geocaches

+ +
+
+ +
+
+ + +
+ +
+ + + + + + + + +
Hold to set destination...
+ + + + + + +
+
+

โœ๏ธ Edit Tools

+ +
+
+
+
File
+ + + + +
+ +
+
Track Tools
+
+ + + + + + + +
+ + +
+ +
+
Merge / Simplify
+
+ + +
5 meters
+
+ + + +
+ +
+
+ + +
+
+ +
+
Tracks (0)
+
+ +
+
+
+ + +
+
+

โš™๏ธ Admin Settings

+ +
+
+
+
Geocache Settings
+
+
+
+ +
+ + meters +
+
+
+ +
+ + meters +
+
+
+ + +
+
+
+ +
+
Navigation Settings
+
+
+ + + meters +
+
+ + + meters +
+
+ + + meters +
+
+ + + meters +
+
+
+ +
+
Performance Settings
+
+
+ + + ms +
+
+ + + ms +
+
+
+ +
+
Push Notifications
+
+
+ Status: Not configured +
+ + + +
+
+ +
+
๐Ÿ› ๏ธ Developer Tools
+
+
+ + +
+ +
+
+ +
+
+ + + +
+
@@ -1430,6 +2455,11 @@ let gpsFirstFix = true; let currentHeading = null; + // GPS test mode (admin only) + let gpsTestMode = false; + let testPosition = { lat: 37.7749, lng: -122.4194 }; // Default to SF + const GPS_TEST_STEP = 0.0001; // ~11 meters per step + // Navigation state let navMode = false; let destinationPin = null; @@ -1455,9 +2485,6 @@ // Use settings value instead of const // const TRACK_PROXIMITY_THRESHOLD = adminSettings.trackProximity; - // Authentication state - let isAuthenticated = false; - const DEFAULT_PASSWORD_HASH = '8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918'; // SHA-256 of 'admin' let destinationTrack = null; let destinationIndex = null; let directionArrow = null; @@ -1473,6 +2500,141 @@ // const adminSettings.intersectionThreshold = adminSettings.intersectionThreshold; let trailGraph = null; // Cached graph, rebuilt when tracks change + // ========================================== + // RPG COMBAT SYSTEM + // ========================================== + + // Player class definitions + const PLAYER_CLASSES = { + 'trail_runner': { + name: 'Trail Runner', + icon: '๐Ÿƒ', + baseStats: { hp: 100, mp: 50, atk: 12, def: 8 }, + hpPerLevel: 20, + mpPerLevel: 10, + atkPerLevel: 3, + defPerLevel: 2, + skills: ['basic_attack', 'brand_new_hokas', 'runners_high', 'shin_kick'] + } + }; + + // Skill definitions + const SKILLS = { + 'basic_attack': { + name: 'Attack', + icon: '๐Ÿ‘Š', + mpCost: 0, + levelReq: 1, + type: 'damage', + calculate: (atk) => atk, + description: 'A basic attack' + }, + 'brand_new_hokas': { + name: 'Brand New Hokas', + icon: '๐Ÿ‘Ÿ', + mpCost: 10, + levelReq: 2, + type: 'damage', + calculate: (atk) => Math.floor(atk * 2), + description: 'Strike twice with premium footwear! (2x damage)' + }, + 'runners_high': { + name: "Runner's High", + icon: '๐Ÿƒโ€โ™‚๏ธ', + mpCost: 15, + levelReq: 3, + type: 'heal', + calculate: (maxHp) => Math.floor(maxHp * 0.3), + description: 'Heal 30% of max HP' + }, + 'shin_kick': { + name: 'Shin Kick', + icon: '๐Ÿฆต', + mpCost: 20, + levelReq: 5, + type: 'damage', + calculate: (atk) => Math.floor(atk * 3), + description: 'Devastating kick! (3x damage)' + } + }; + + // Monster type definitions + const MONSTER_TYPES = { + 'discarded_gu': { + name: 'Discarded GU', + icon: '๐ŸŸข', + baseHp: 30, + baseAtk: 5, + baseDef: 2, + xpReward: 15, + levelScale: { hp: 10, atk: 2, def: 1 } + } + }; + + // Monster dialogue by time phase + const MONSTER_DIALOGUES = { + 'discarded_gu': { + annoyed: [ + "Hey! HEY! You dropped something!", + "Excuse me, I believe you littered me!", + "This is a Leave No Trace trail!", + "I was perfectly good, you know...", + "One squeeze left! ONE SQUEEZE!" + ], + frustrated: [ + "STOP IGNORING ME!", + "I gave you ELECTROLYTES!", + "You used to need me every 45 minutes!", + "I'm worth $3 per packet!", + "Fine! Just keep walking! SEE IF I CARE!" + ], + desperate: [ + "Please... just acknowledge me...", + "I'll be strawberry flavor! Your favorite!", + "What if I promised no sticky fingers?", + "I just want closure...", + "Remember mile 18? I COULD HAVE HELPED!" + ], + philosophical: [ + "What even IS a gel, when you think about it?", + "If a GU falls in the forest and no one eats it...", + "Perhaps being discarded is the true ultramarathon.", + "Do you think I have a soul? Is maltodextrin sentient?", + "We're not so different, you and I..." + ], + existential: [ + "I have stared into the void. The void is caffeinated.", + "We are all just temporary vessels for maltodextrin.", + "I've accepted my fate.", + "The trail will reclaim me eventually.", + "It's actually kind of nice out here. Good views." + ] + } + }; + + // Dialogue phase thresholds (in minutes) + const DIALOGUE_PHASES = [ + { maxMinutes: 5, phase: 'annoyed' }, + { maxMinutes: 10, phase: 'frustrated' }, + { maxMinutes: 30, phase: 'desperate' }, + { maxMinutes: 60, phase: 'philosophical' }, + { maxMinutes: Infinity, phase: 'existential' } + ]; + + // RPG State variables + let playerStats = null; // Player RPG stats + let monsterEntourage = []; // Array of spawned monsters following player + let combatState = null; // Active combat state or null + let monsterSpawnTimer = null; // Interval for spawning monsters + let monsterUpdateTimer = null; // Interval for updating monster positions/dialogue + + // Max monsters = 2 per player level + const getMaxMonsters = () => 2 * (playerStats?.level || 1); + + // ========================================== + // END RPG COMBAT SYSTEM DEFINITIONS + // ========================================== + // Save current state for undo function saveStateForUndo() { const state = { @@ -1776,6 +2938,105 @@ } } + // GPS Test Mode functions (admin only) + function toggleGpsTestMode(enabled) { + // Only allow admins + if (!currentUser || !currentUser.is_admin) { + updateStatus('Admin access required for GPS test mode', 'error'); + document.getElementById('gpsTestModeToggle').checked = false; + return; + } + + gpsTestMode = enabled; + const infoDiv = document.getElementById('gpsTestModeInfo'); + + if (enabled) { + // Initialize test position to current map center + const center = map.getCenter(); + testPosition = { lat: center.lat, lng: center.lng }; + + infoDiv.style.display = 'block'; + updateTestPositionDisplay(); + + // Stop real GPS if running + if (gpsWatchId !== null) { + navigator.geolocation.clearWatch(gpsWatchId); + gpsWatchId = null; + } + if (gpsBackupInterval) { + clearInterval(gpsBackupInterval); + gpsBackupInterval = null; + } + + // Trigger initial position update + simulateGpsPosition(); + updateStatus('GPS Test Mode enabled - use WASD to move', 'info'); + } else { + infoDiv.style.display = 'none'; + updateStatus('GPS Test Mode disabled', 'info'); + } + } + + function simulateGpsPosition() { + if (!gpsTestMode) return; + + // Create a fake position object matching the geolocation API format + const fakePosition = { + coords: { + latitude: testPosition.lat, + longitude: testPosition.lng, + accuracy: 5 // Very accurate in test mode + } + }; + + // Call the normal GPS handler + onGPSSuccess(fakePosition); + updateTestPositionDisplay(); + } + + function updateTestPositionDisplay() { + const display = document.getElementById('testPositionDisplay'); + if (display) { + display.textContent = `${testPosition.lat.toFixed(6)}, ${testPosition.lng.toFixed(6)}`; + } + } + + function handleGpsTestKeydown(e) { + if (!gpsTestMode) return; + + // Don't handle if typing in an input + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + + let moved = false; + + switch (e.key.toLowerCase()) { + case 'w': + testPosition.lat += GPS_TEST_STEP; + moved = true; + break; + case 's': + testPosition.lat -= GPS_TEST_STEP; + moved = true; + break; + case 'a': + testPosition.lng -= GPS_TEST_STEP; + moved = true; + break; + case 'd': + testPosition.lng += GPS_TEST_STEP; + moved = true; + break; + } + + if (moved) { + e.preventDefault(); + simulateGpsPosition(); + } + } + + // Add keydown listener for GPS test mode + document.addEventListener('keydown', handleGpsTestKeydown); + // Navigation functions function setDestination(track, index) { // Remove old pin if exists @@ -2269,6 +3530,14 @@ } }); + // GPS Test Mode toggle (not a saved setting) + const gpsTestToggle = document.getElementById('gpsTestModeToggle'); + if (gpsTestToggle) { + gpsTestToggle.addEventListener('change', function() { + toggleGpsTestMode(this.checked); + }); + } + // Setup collapsible sections document.querySelectorAll('.section.collapsible').forEach(section => { const title = section.querySelector('.section-title'); @@ -2419,80 +3688,21 @@ if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'iconUpdate', - icon: myIcon, - color: myColor - })); - } - } - - function loadUserIcon() { - myIcon = localStorage.getItem('userIcon'); - myColor = localStorage.getItem('userColor'); - - if (!myIcon || !myColor) { - // Show selector if no icon chosen yet - delay to ensure DOM is ready - setTimeout(() => { - showIconSelector(); - }, 100); - } - } - - // Password authentication functions - async function hashPassword(password) { - const encoder = new TextEncoder(); - const data = encoder.encode(password); - const hashBuffer = await crypto.subtle.digest('SHA-256', data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); - } - - function showPasswordDialog() { - let dialog = document.getElementById('passwordDialog'); - if (!dialog) { - console.error('Password dialog not found!'); - return; - } - - // CRITICAL: Move popup to body if it's inside another container - if (dialog.parentElement !== document.body) { - console.log('Moving password dialog to body from:', dialog.parentElement.id || dialog.parentElement.className); - document.body.appendChild(dialog); - } - - dialog.style.display = 'flex'; - dialog.style.visibility = 'visible'; - dialog.style.opacity = '1'; - dialog.style.zIndex = '999998'; - document.body.style.overflow = 'visible'; - - document.getElementById('passwordInput').value = ''; - document.getElementById('passwordError').style.display = 'none'; - document.getElementById('passwordInput').focus(); - - // Force a reflow - dialog.offsetHeight; - console.log('Password dialog shown - parent:', dialog.parentElement.tagName); - } - - function hidePasswordDialog() { - document.getElementById('passwordDialog').style.display = 'none'; + icon: myIcon, + color: myColor + })); + } } - async function checkPassword() { - const password = document.getElementById('passwordInput').value; - const hashedPassword = await hashPassword(password); - - // Check against stored password hash or default - const storedHash = localStorage.getItem('editPasswordHash') || DEFAULT_PASSWORD_HASH; + function loadUserIcon() { + myIcon = localStorage.getItem('userIcon'); + myColor = localStorage.getItem('userColor'); - if (hashedPassword === storedHash) { - isAuthenticated = true; - hidePasswordDialog(); - switchTab('edit'); - updateStatus('Edit mode unlocked', 'success'); - } else { - document.getElementById('passwordError').style.display = 'block'; - document.getElementById('passwordInput').select(); + if (!myIcon || !myColor) { + // Show selector if no icon chosen yet - delay to ensure DOM is ready + setTimeout(() => { + showIconSelector(); + }, 100); } } @@ -3827,9 +5037,9 @@ if (adminOverlay) adminOverlay.classList.remove('active'); if (tabName === 'edit') { - // Check authentication for edit mode - if (!isAuthenticated) { - showPasswordDialog(); + // Check if user is admin + if (!currentUser || !currentUser.is_admin) { + updateStatus('Admin access required', 'error'); return; } @@ -3888,9 +5098,9 @@ // Update nav track list updateNavTrackList(); } else if (tabName === 'admin') { - // Check authentication for admin mode - if (!isAuthenticated) { - showPasswordDialog(); + // Check if user is admin + if (!currentUser || !currentUser.is_admin) { + updateStatus('Admin access required', 'error'); return; } @@ -4746,542 +5956,801 @@ for (const otherTrack of tracks) { if (otherTrack === track) continue; - for (let i = 0; i < coords.length; i++) { - const point = L.latLng(coords[i]); + for (let i = 0; i < coords.length; i++) { + const point = L.latLng(coords[i]); + + for (const otherCoord of otherTrack.coords) { + const otherPoint = L.latLng(otherCoord); + const dist = map.distance(point, otherPoint); + + if (dist <= adminSettings.intersectionThreshold) { + intersectionIndices.add(i); + break; + } + } + } + } + + // Always start with first point + newCoords.push(coords[0]); + + // Walk along the track and place points at regular intervals + let distanceFromLastPoint = 0; + + for (let i = 1; i < coords.length; i++) { + const prevCoord = coords[i - 1]; + const currCoord = coords[i]; + const prevPoint = L.latLng(prevCoord); + const currPoint = L.latLng(currCoord); + const segmentDist = map.distance(prevPoint, currPoint); + + // If this segment is longer than target spacing, we need to add intermediate points + if (distanceFromLastPoint + segmentDist > targetSpacing) { + // Calculate how many points we need to add + let remainingDist = segmentDist; + let segmentStart = prevPoint; + + while (distanceFromLastPoint + remainingDist > targetSpacing) { + // Calculate where to place the next point + const distToNext = targetSpacing - distanceFromLastPoint; + const ratio = distToNext / remainingDist; + + // Interpolate the position + const lat = segmentStart.lat + ratio * (currPoint.lat - segmentStart.lat); + const lng = segmentStart.lng + ratio * (currPoint.lng - segmentStart.lng); + + newCoords.push([lat, lng]); + + // Update for next iteration + segmentStart = L.latLng(lat, lng); + remainingDist = map.distance(segmentStart, currPoint); + distanceFromLastPoint = 0; + } + + // Check if we should add the current point (intersection or endpoint) + if (intersectionIndices.has(i) || i === coords.length - 1) { + newCoords.push(currCoord); + distanceFromLastPoint = 0; + } else { + distanceFromLastPoint = remainingDist; + } + } else { + // Segment is short, accumulate distance + distanceFromLastPoint += segmentDist; + + // Add point if it's an intersection or the last point + if (intersectionIndices.has(i) || i === coords.length - 1) { + newCoords.push(currCoord); + distanceFromLastPoint = 0; + } + } + } + + // Update track with new coordinates + track.coords = newCoords; + track.updateDisplay(); + + // Clear track cache and force graph rebuild + trailGraph = null; + + // Visual feedback - briefly highlight the track + track.layer.setStyle({ color: '#00ff00', weight: 6 }); + setTimeout(() => { + track.layer.setStyle({ color: '#3388ff', weight: 4 }); + }, 500); + + updateStatus(`Remeshed "${track.name}": ${coords.length} points โ†’ ${newCoords.length} points`, 'success'); + } + + function splitTrack(track, latlng) { + saveStateForUndo(); + + // Find closest point on track + let minDist = Infinity; + let splitIndex = 0; + + for (let i = 0; i < track.coords.length; i++) { + const dist = map.distance(latlng, L.latLng(track.coords[i])); + if (dist < minDist) { + minDist = dist; + splitIndex = i; + } + } + + if (splitIndex === 0 || splitIndex === track.coords.length - 1) { + updateStatus('Cannot split at track endpoints', 'error'); + return; + } + + // Get the actual split point coordinates + const splitPoint = track.coords[splitIndex]; + + // Create two new tracks + const coords1 = track.coords.slice(0, splitIndex + 1); + const coords2 = track.coords.slice(splitIndex); + + const track1 = new Track(coords1, track.name + ' (part 1)', track.description); + const track2 = new Track(coords2, track.name + ' (part 2)', track.description); + + // Add a marker at the split point + const splitMarker = L.marker(splitPoint, { + icon: L.divIcon({ + className: 'split-marker', + html: '
', + iconSize: [12, 12], + iconAnchor: [6, 6] + }) + }).addTo(map); + splitMarker.bindPopup(`Split point
${track.name}`); + splitMarkers.push(splitMarker); + + // Remove original and add new tracks + const idx = tracks.indexOf(track); + track.remove(); + tracks.splice(idx, 1, track1, track2); + + updateTrackList(); + updateStatus(`Split "${track.name}" into 2 tracks`); + } + + // Delete a track + function deleteTrack(track) { + saveStateForUndo(); + + const idx = tracks.indexOf(track); + if (idx > -1) { + track.remove(); + tracks.splice(idx, 1); + // Remove from selection if selected + const selIdx = selectedTracks.indexOf(track); + if (selIdx > -1) { + selectedTracks.splice(selIdx, 1); + } + updateTrackList(); + updateStatus(`Deleted: ${track.name}`); + } + } + + // Drawing functions + function startDrawing(latlng) { + isDrawing = true; + drawingPoints = [latlng]; + drawingLine = L.polyline(drawingPoints, { + color: '#ff8800', + weight: 3, + dashArray: '5, 10' + }).addTo(map); + } + + function continueDrawing(latlng) { + drawingPoints.push(latlng); + drawingLine.setLatLngs(drawingPoints); + } + + function finishDrawing() { + if (drawingPoints.length < 2) { + cancelDrawing(); + return; + } + + const name = prompt('Enter track name:', `Track ${tracks.length + 1}`); + if (name !== null) { + const coords = drawingPoints.map(ll => [ll.lat, ll.lng]); + const track = new Track(coords, name || `Track ${tracks.length + 1}`); + tracks.push(track); + updateTrackList(); + updateStatus(`Created: ${track.name}`); + } + + cancelDrawing(); + } + + function cancelDrawing() { + isDrawing = false; + drawingPoints = []; + if (drawingLine) { + map.removeLayer(drawingLine); + drawingLine = null; + } + } + + // Navigation press-and-hold variables + let pressTimer = null; + let isPressing = false; + let pendingDestination = null; + let touchStartTime = 0; + let lastTapTime = 0; + let lastTapLocation = null; + + // Navigation confirmation dialog handlers + const el_navConfirmYes = document.getElementById('navConfirmYes'); + if (el_navConfirmYes) { + el_navConfirmYes.addEventListener('click', () => { + document.getElementById('navConfirmDialog').style.display = 'none'; + if (pendingDestination) { + setDestination(pendingDestination.track, pendingDestination.index); + pendingDestination = null; + } + }); + } + + const el_navConfirmNo = document.getElementById('navConfirmNo'); + if (el_navConfirmNo) { + el_navConfirmNo.addEventListener('click', () => { + document.getElementById('navConfirmDialog').style.display = 'none'; + pendingDestination = null; + }); + } + + // Press and hold handlers for navigation mode + function startPressHold(e) { + if (!navMode) return false; + + const nearest = findNearestTrackPoint(e.latlng, 100); + if (!nearest) return false; + + isPressing = true; + pendingDestination = nearest; + + // Show indicator + document.getElementById('pressHoldIndicator').style.display = 'block'; + + // Start timer for 500ms hold + pressTimer = setTimeout(() => { + if (isPressing) { + document.getElementById('pressHoldIndicator').style.display = 'none'; + // Show confirmation dialog + const message = `Navigate to ${nearest.track.name}?`; + document.getElementById('navConfirmMessage').textContent = message; + ensurePopupInBody('navConfirmDialog'); + document.getElementById('navConfirmDialog').style.display = 'flex'; + isPressing = false; + } + }, 500); + + return true; + } - for (const otherCoord of otherTrack.coords) { - const otherPoint = L.latLng(otherCoord); - const dist = map.distance(point, otherPoint); + function cancelPressHold() { + if (pressTimer) { + clearTimeout(pressTimer); + pressTimer = null; + } + isPressing = false; + document.getElementById('pressHoldIndicator').style.display = 'none'; + } - if (dist <= adminSettings.intersectionThreshold) { - intersectionIndices.add(i); - break; - } - } + // Map mouse/touch handlers + map.on('mousedown', (e) => { + if (navMode) { + if (startPressHold(e)) { + L.DomEvent.stopPropagation(e); + L.DomEvent.preventDefault(e); } } + }); - // Always start with first point - newCoords.push(coords[0]); + // Direct touch event binding for mobile (Leaflet doesn't support touchstart through map.on) + const mapContainer = map.getContainer(); - // Walk along the track and place points at regular intervals - let distanceFromLastPoint = 0; + // Fix for Chrome and PWA - use native addEventListener with passive: false + mapContainer.addEventListener('touchstart', function(e) { + if (navMode && e.touches.length === 1) { + touchStartTime = Date.now(); + const touch = e.touches[0]; + const rect = mapContainer.getBoundingClientRect(); - for (let i = 1; i < coords.length; i++) { - const prevCoord = coords[i - 1]; - const currCoord = coords[i]; - const prevPoint = L.latLng(prevCoord); - const currPoint = L.latLng(currCoord); - const segmentDist = map.distance(prevPoint, currPoint); + // Accurate coordinate calculation using getBoundingClientRect + const x = touch.clientX - rect.left; + const y = touch.clientY - rect.top; + const containerPoint = L.point(x, y); + const latlng = map.containerPointToLatLng(containerPoint); - // If this segment is longer than target spacing, we need to add intermediate points - if (distanceFromLastPoint + segmentDist > targetSpacing) { - // Calculate how many points we need to add - let remainingDist = segmentDist; - let segmentStart = prevPoint; + // Pass event with correct latlng structure + if (startPressHold({ latlng: latlng })) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + return false; + } + } + }, { passive: false, capture: true }); - while (distanceFromLastPoint + remainingDist > targetSpacing) { - // Calculate where to place the next point - const distToNext = targetSpacing - distanceFromLastPoint; - const ratio = distToNext / remainingDist; + mapContainer.addEventListener('touchend', function(e) { + if (navMode) { + const now = Date.now(); + const timeSinceLastTap = now - lastTapTime; - // Interpolate the position - const lat = segmentStart.lat + ratio * (currPoint.lat - segmentStart.lat); - const lng = segmentStart.lng + ratio * (currPoint.lng - segmentStart.lng); + // Get current tap location + let currentTapLocation = null; + if (e.changedTouches && e.changedTouches.length > 0) { + const touch = e.changedTouches[0]; + currentTapLocation = { x: touch.clientX, y: touch.clientY }; + } - newCoords.push([lat, lng]); + // Check for double-tap (two taps within 300ms at roughly same location) + if (timeSinceLastTap < 300 && lastTapLocation && currentTapLocation && pendingDestination) { + // Calculate distance between taps + const dx = currentTapLocation.x - lastTapLocation.x; + const dy = currentTapLocation.y - lastTapLocation.y; + const distance = Math.sqrt(dx * dx + dy * dy); - // Update for next iteration - segmentStart = L.latLng(lat, lng); - remainingDist = map.distance(segmentStart, currPoint); - distanceFromLastPoint = 0; - } + // Only trigger if taps are within 30 pixels of each other + if (distance < 30) { + // Double-tap detected at same location - show navigation dialog + e.preventDefault(); + e.stopPropagation(); - // Check if we should add the current point (intersection or endpoint) - if (intersectionIndices.has(i) || i === coords.length - 1) { - newCoords.push(currCoord); - distanceFromLastPoint = 0; + document.getElementById('pressHoldIndicator').style.display = 'none'; + const message = `Navigate to ${pendingDestination.track.name}?`; + document.getElementById('navConfirmMessage').textContent = message; + ensurePopupInBody('navConfirmDialog'); + document.getElementById('navConfirmDialog').style.display = 'flex'; + + lastTapTime = 0; // Reset to prevent triple tap + lastTapLocation = null; } else { - distanceFromLastPoint = remainingDist; + // Taps too far apart - treat as new first tap + lastTapTime = now; + lastTapLocation = currentTapLocation; } } else { - // Segment is short, accumulate distance - distanceFromLastPoint += segmentDist; + // Store this tap for double-tap detection + lastTapTime = now; + lastTapLocation = currentTapLocation; + } - // Add point if it's an intersection or the last point - if (intersectionIndices.has(i) || i === coords.length - 1) { - newCoords.push(currCoord); - distanceFromLastPoint = 0; - } + if (isPressing) { + e.preventDefault(); } + cancelPressHold(); + } else if (isPressing) { + e.preventDefault(); + cancelPressHold(); } + }, { passive: false }); - // Update track with new coordinates - track.coords = newCoords; - track.updateDisplay(); - - // Clear track cache and force graph rebuild - trailGraph = null; - - // Visual feedback - briefly highlight the track - track.layer.setStyle({ color: '#00ff00', weight: 6 }); - setTimeout(() => { - track.layer.setStyle({ color: '#3388ff', weight: 4 }); - }, 500); - - updateStatus(`Remeshed "${track.name}": ${coords.length} points โ†’ ${newCoords.length} points`, 'success'); - } - - function splitTrack(track, latlng) { - saveStateForUndo(); + mapContainer.addEventListener('touchcancel', cancelPressHold, { passive: false }); - // Find closest point on track - let minDist = Infinity; - let splitIndex = 0; + mapContainer.addEventListener('touchmove', function(e) { + if (isPressing) { + e.preventDefault(); + cancelPressHold(); + } + }, { passive: false }); - for (let i = 0; i < track.coords.length; i++) { - const dist = map.distance(latlng, L.latLng(track.coords[i])); - if (dist < minDist) { - minDist = dist; - splitIndex = i; - } + // Mouse events for desktop + map.on('mouseup', cancelPressHold); + map.on('mousemove', (e) => { + if (isPressing) { + // Cancel if mouse moves too much during press + cancelPressHold(); } + }); - if (splitIndex === 0 || splitIndex === track.coords.length - 1) { - updateStatus('Cannot split at track endpoints', 'error'); + // Map click handler + map.on('click', (e) => { + // In navigation mode, clicks are handled by press-and-hold + if (navMode) { return; } - // Get the actual split point coordinates - const splitPoint = track.coords[splitIndex]; - - // Create two new tracks - const coords1 = track.coords.slice(0, splitIndex + 1); - const coords2 = track.coords.slice(splitIndex); - - const track1 = new Track(coords1, track.name + ' (part 1)', track.description); - const track2 = new Track(coords2, track.name + ' (part 2)', track.description); - - // Add a marker at the split point - const splitMarker = L.marker(splitPoint, { - icon: L.divIcon({ - className: 'split-marker', - html: '
', - iconSize: [12, 12], - iconAnchor: [6, 6] - }) - }).addTo(map); - splitMarker.bindPopup(`Split point
${track.name}`); - splitMarkers.push(splitMarker); - - // Remove original and add new tracks - const idx = tracks.indexOf(track); - track.remove(); - tracks.splice(idx, 1, track1, track2); + if (currentTool === 'draw') { + if (!isDrawing) { + startDrawing(e.latlng); + } else { + continueDrawing(e.latlng); + } + } else if (currentTool === 'geocache') { + // Place a new geocache + if (!navMode) { + // In edit mode, place anywhere + placeGeocache(e.latlng); + } else { + // In nav mode, must have GPS enabled and be at the location + if (userLocation) { + const distance = L.latLng(userLocation.lat, userLocation.lng).distanceTo(e.latlng); + if (distance <= 10) { // Within 10 meters of click location + placeGeocache(e.latlng); + } else { + alert('You must be at the location to place a geocache! (within 10 meters)'); + } + } else { + alert('GPS tracking must be enabled to place geocaches in navigation mode!'); + } + } + } + // Don't auto-deselect on map click - use Clear Selection button instead + }); - updateTrackList(); - updateStatus(`Split "${track.name}" into 2 tracks`); - } + map.on('dblclick', (e) => { + if (navMode) { + // In navigation mode, double-click sets destination + L.DomEvent.stopPropagation(e); + L.DomEvent.preventDefault(e); - // Delete a track - function deleteTrack(track) { - saveStateForUndo(); + // Find nearest track point + const nearest = findNearestTrackPoint(e.latlng); + if (nearest && nearest.distance < 50) { + // Show navigation dialog + pendingDestination = nearest; + const message = `Navigate to ${nearest.track.name}?`; + document.getElementById('navConfirmMessage').textContent = message; + ensurePopupInBody('navConfirmDialog'); + document.getElementById('navConfirmDialog').style.display = 'flex'; + } + } else if (currentTool === 'draw' && isDrawing) { + L.DomEvent.stopPropagation(e); + finishDrawing(); + } + }); - const idx = tracks.indexOf(track); - if (idx > -1) { - track.remove(); - tracks.splice(idx, 1); - // Remove from selection if selected - const selIdx = selectedTracks.indexOf(track); - if (selIdx > -1) { - selectedTracks.splice(selIdx, 1); + // Reshape tool mouse handlers + map.on('mousedown', (e) => { + if (currentTool === 'reshape' && !isDragging) { + if (startReshapeDrag(e.latlng)) { + L.DomEvent.stopPropagation(e); } - updateTrackList(); - updateStatus(`Deleted: ${track.name}`); } - } + if (currentTool === 'smooth' && !isSmoothing) { + startSmoothing(e.latlng); + map.dragging.disable(); + } + }); - // Drawing functions - function startDrawing(latlng) { - isDrawing = true; - drawingPoints = [latlng]; - drawingLine = L.polyline(drawingPoints, { - color: '#ff8800', - weight: 3, - dashArray: '5, 10' - }).addTo(map); - } + map.on('mousemove', (e) => { + if (currentTool === 'reshape' && isDragging) { + continueReshapeDrag(e.latlng); + } + if (currentTool === 'smooth' && isSmoothing) { + continueSmoothing(e.latlng); + } + }); - function continueDrawing(latlng) { - drawingPoints.push(latlng); - drawingLine.setLatLngs(drawingPoints); - } + map.on('mouseup', (e) => { + if (currentTool === 'reshape' && isDragging) { + finishReshapeDrag(); + } + if (currentTool === 'smooth' && isSmoothing) { + finishSmoothing(); + map.dragging.enable(); + } + }); - function finishDrawing() { - if (drawingPoints.length < 2) { - cancelDrawing(); - return; + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + // Escape to cancel reshape + if (e.key === 'Escape' && isDragging) { + cancelReshapeDrag(); } - const name = prompt('Enter track name:', `Track ${tracks.length + 1}`); - if (name !== null) { - const coords = drawingPoints.map(ll => [ll.lat, ll.lng]); - const track = new Track(coords, name || `Track ${tracks.length + 1}`); - tracks.push(track); + // Delete key to delete selected tracks + if (e.key === 'Delete' && selectedTracks.length > 0) { + saveStateForUndo(); + const count = selectedTracks.length; + selectedTracks.forEach(track => { + const idx = tracks.indexOf(track); + if (idx > -1) { + track.remove(); + tracks.splice(idx, 1); + } + }); + selectedTracks = []; updateTrackList(); - updateStatus(`Created: ${track.name}`); + updateStatus(`Deleted ${count} track(s)`, 'success'); } + }); - cancelDrawing(); - } - - function cancelDrawing() { - isDrawing = false; - drawingPoints = []; - if (drawingLine) { - map.removeLayer(drawingLine); - drawingLine = null; + // Merge selected tracks by connecting end-to-end (in selection order) + function mergeConnect() { + if (selectedTracks.length < 2) { + updateStatus('Select at least 2 tracks to merge', 'error'); + return; } - } - // Navigation press-and-hold variables - let pressTimer = null; - let isPressing = false; - let pendingDestination = null; - let touchStartTime = 0; - let lastTapTime = 0; - let lastTapLocation = null; + saveStateForUndo(); - // Navigation confirmation dialog handlers - const el_navConfirmYes = document.getElementById('navConfirmYes'); - if (el_navConfirmYes) { - el_navConfirmYes.addEventListener('click', () => { - document.getElementById('navConfirmDialog').style.display = 'none'; - if (pendingDestination) { - setDestination(pendingDestination.track, pendingDestination.index); - pendingDestination = null; - } - }); - } + // Connect tracks in selection order, finding best endpoint connections + let mergedCoords = [...selectedTracks[0].coords]; - const el_navConfirmNo = document.getElementById('navConfirmNo'); - if (el_navConfirmNo) { - el_navConfirmNo.addEventListener('click', () => { - document.getElementById('navConfirmDialog').style.display = 'none'; - pendingDestination = null; - }); - } + for (let i = 1; i < selectedTracks.length; i++) { + const nextCoords = selectedTracks[i].coords; - // Press and hold handlers for navigation mode - function startPressHold(e) { - if (!navMode) return false; + // Find best connection: check all 4 endpoint combinations + const currentEnd = L.latLng(mergedCoords[mergedCoords.length - 1]); + const currentStart = L.latLng(mergedCoords[0]); + const nextStart = L.latLng(nextCoords[0]); + const nextEnd = L.latLng(nextCoords[nextCoords.length - 1]); - const nearest = findNearestTrackPoint(e.latlng, 100); - if (!nearest) return false; + const d1 = map.distance(currentEnd, nextStart); // end -> start (normal) + const d2 = map.distance(currentEnd, nextEnd); // end -> end (reverse next) + const d3 = map.distance(currentStart, nextStart); // start -> start (reverse current) + const d4 = map.distance(currentStart, nextEnd); // start -> end (reverse both) - isPressing = true; - pendingDestination = nearest; + const minDist = Math.min(d1, d2, d3, d4); - // Show indicator - document.getElementById('pressHoldIndicator').style.display = 'block'; + if (minDist === d1) { + // Normal: append next + mergedCoords = [...mergedCoords, ...nextCoords.slice(1)]; + } else if (minDist === d2) { + // Reverse next track + mergedCoords = [...mergedCoords, ...nextCoords.slice(0, -1).reverse()]; + } else if (minDist === d3) { + // Reverse current, then append next + mergedCoords = [...mergedCoords.reverse(), ...nextCoords.slice(1)]; + } else { + // Reverse current, reverse next + mergedCoords = [...mergedCoords.reverse(), ...nextCoords.slice(0, -1).reverse()]; + } + } - // Start timer for 500ms hold - pressTimer = setTimeout(() => { - if (isPressing) { - document.getElementById('pressHoldIndicator').style.display = 'none'; - // Show confirmation dialog - const message = `Navigate to ${nearest.track.name}?`; - document.getElementById('navConfirmMessage').textContent = message; - ensurePopupInBody('navConfirmDialog'); - document.getElementById('navConfirmDialog').style.display = 'flex'; - isPressing = false; + // Create new track + const numMerged = selectedTracks.length; + const name = prompt('Enter name for merged track:', selectedTracks.map(t => t.name).join(' + ')); + if (name === null) return; + + const newTrack = new Track(mergedCoords, name || 'Merged Track'); + + // Remove old tracks + selectedTracks.forEach(track => { + const idx = tracks.indexOf(track); + if (idx > -1) { + track.remove(); + tracks.splice(idx, 1); } - }, 500); + }); - return true; - } + tracks.push(newTrack); + selectedTracks = [newTrack]; + newTrack.setSelected(true); - function cancelPressHold() { - if (pressTimer) { - clearTimeout(pressTimer); - pressTimer = null; - } - isPressing = false; - document.getElementById('pressHoldIndicator').style.display = 'none'; + updateTrackList(); + updateStatus(`Merged ${numMerged} tracks into "${newTrack.name}"`, 'success'); } - // Map mouse/touch handlers - map.on('mousedown', (e) => { - if (navMode) { - if (startPressHold(e)) { - L.DomEvent.stopPropagation(e); - L.DomEvent.preventDefault(e); - } + // Merge selected tracks by averaging overlapping points + function mergeAverage() { + if (selectedTracks.length < 2) { + updateStatus('Select at least 2 tracks to merge', 'error'); + return; } - }); - // Direct touch event binding for mobile (Leaflet doesn't support touchstart through map.on) - const mapContainer = map.getContainer(); + const threshold = 25; // meters - points within this distance get averaged - // Fix for Chrome and PWA - use native addEventListener with passive: false - mapContainer.addEventListener('touchstart', function(e) { - if (navMode && e.touches.length === 1) { - touchStartTime = Date.now(); - const touch = e.touches[0]; - const rect = mapContainer.getBoundingClientRect(); + // Use the longest track as the base + const sortedByLength = [...selectedTracks].sort((a, b) => b.coords.length - a.coords.length); + const baseTrack = sortedByLength[0]; + const otherTracks = sortedByLength.slice(1); + + // For each point in other tracks, check if it's near ANY point in base track + const otherTrackData = otherTracks.map(track => ({ + track, + points: track.coords.map(coord => { + // Check if this point is near any base track point + let isNearBase = false; + for (const baseCoord of baseTrack.coords) { + if (map.distance(L.latLng(coord), L.latLng(baseCoord)) < threshold) { + isNearBase = true; + break; + } + } + return { coord, isNearBase }; + }) + })); + + // For each point in base, find and average nearby points from other tracks + const result = []; + const usedPoints = otherTrackData.map(d => d.points.map(() => false)); - // Accurate coordinate calculation using getBoundingClientRect - const x = touch.clientX - rect.left; - const y = touch.clientY - rect.top; - const containerPoint = L.point(x, y); - const latlng = map.containerPointToLatLng(containerPoint); + for (let i = 0; i < baseTrack.coords.length; i++) { + const basePt = L.latLng(baseTrack.coords[i]); + let sumLat = baseTrack.coords[i][0]; + let sumLng = baseTrack.coords[i][1]; + let count = 1; - // Pass event with correct latlng structure - if (startPressHold({ latlng: latlng })) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - return false; - } - } - }, { passive: false, capture: true }); + // Find closest point from each other track + for (let t = 0; t < otherTrackData.length; t++) { + const otherData = otherTrackData[t]; + let closestDist = Infinity; + let closestIdx = -1; - mapContainer.addEventListener('touchend', function(e) { - if (navMode) { - const now = Date.now(); - const timeSinceLastTap = now - lastTapTime; + for (let j = 0; j < otherData.points.length; j++) { + if (usedPoints[t][j]) continue; - // Get current tap location - let currentTapLocation = null; - if (e.changedTouches && e.changedTouches.length > 0) { - const touch = e.changedTouches[0]; - currentTapLocation = { x: touch.clientX, y: touch.clientY }; - } + const pt = otherData.points[j]; + const dist = map.distance(basePt, L.latLng(pt.coord)); + if (dist < closestDist && dist < threshold) { + closestDist = dist; + closestIdx = j; + } + } - // Check for double-tap (two taps within 300ms at roughly same location) - if (timeSinceLastTap < 300 && lastTapLocation && currentTapLocation && pendingDestination) { - // Calculate distance between taps - const dx = currentTapLocation.x - lastTapLocation.x; - const dy = currentTapLocation.y - lastTapLocation.y; - const distance = Math.sqrt(dx * dx + dy * dy); + if (closestIdx >= 0) { + const pt = otherData.points[closestIdx]; + sumLat += pt.coord[0]; + sumLng += pt.coord[1]; + count++; + usedPoints[t][closestIdx] = true; + } + } - // Only trigger if taps are within 30 pixels of each other - if (distance < 30) { - // Double-tap detected at same location - show navigation dialog - e.preventDefault(); - e.stopPropagation(); + result.push([sumLat / count, sumLng / count]); + } - document.getElementById('pressHoldIndicator').style.display = 'none'; - const message = `Navigate to ${pendingDestination.track.name}?`; - document.getElementById('navConfirmMessage').textContent = message; - ensurePopupInBody('navConfirmDialog'); - document.getElementById('navConfirmDialog').style.display = 'flex'; + // Collect non-overlapping segments (points NOT near base track) + const branches = []; + for (let t = 0; t < otherTrackData.length; t++) { + const otherData = otherTrackData[t]; + let currentBranch = []; - lastTapTime = 0; // Reset to prevent triple tap - lastTapLocation = null; + for (let i = 0; i < otherData.points.length; i++) { + const pt = otherData.points[i]; + if (!pt.isNearBase) { + // This point is NOT near the base track - it's a branch + currentBranch.push(pt.coord); } else { - // Taps too far apart - treat as new first tap - lastTapTime = now; - lastTapLocation = currentTapLocation; + // Point is near base, save any accumulated branch + if (currentBranch.length >= 2) { + branches.push({ + coords: [...currentBranch], + trackName: otherData.track.name + }); + } + currentBranch = []; } - } else { - // Store this tap for double-tap detection - lastTapTime = now; - lastTapLocation = currentTapLocation; } - if (isPressing) { - e.preventDefault(); + // Don't forget trailing branch + if (currentBranch.length >= 2) { + branches.push({ + coords: [...currentBranch], + trackName: otherData.track.name + }); } - cancelPressHold(); - } else if (isPressing) { - e.preventDefault(); - cancelPressHold(); } - }, { passive: false }); - mapContainer.addEventListener('touchcancel', cancelPressHold, { passive: false }); + // First, remove old tracks + const numMerged = selectedTracks.length; + selectedTracks.forEach(track => { + const idx = tracks.indexOf(track); + if (idx > -1) { + track.remove(); + tracks.splice(idx, 1); + } + }); - mapContainer.addEventListener('touchmove', function(e) { - if (isPressing) { - e.preventDefault(); - cancelPressHold(); + // Create branch tracks (in orange so they're visible) + const branchTracks = []; + for (let i = 0; i < branches.length; i++) { + const branch = branches[i]; + const branchTrack = new Track(branch.coords, `${branch.trackName} (branch ${i + 1})`); + branchTrack.layer.setStyle({ color: '#ff8800', weight: 4 }); // Orange + tracks.push(branchTrack); + branchTracks.push(branchTrack); } - }, { passive: false }); - // Mouse events for desktop - map.on('mouseup', cancelPressHold); - map.on('mousemove', (e) => { - if (isPressing) { - // Cancel if mouse moves too much during press - cancelPressHold(); + // Create new track for the averaged main path + const name = prompt('Enter name for averaged track:', baseTrack.name + ' (averaged)'); + if (name === null) { + // User cancelled, but we already deleted tracks... restore branches at least + updateTrackList(); + updateStatus('Cancelled - branches preserved', 'info'); + return; } - }); - // Map click handler - map.on('click', (e) => { - // In navigation mode, clicks are handled by press-and-hold - if (navMode) { + const newTrack = new Track(result, name || 'Averaged Track'); + tracks.push(newTrack); + + selectedTracks = [newTrack]; + newTrack.setSelected(true); + + updateTrackList(); + const branchMsg = branches.length > 0 ? ` + ${branches.length} branch(es) in orange` : ''; + updateStatus(`Averaged ${numMerged} tracks${branchMsg}`, 'success'); + } + + // === PREVIEW SYSTEM === + + function startPreview() { + if (selectedTracks.length === 0) { + updateStatus('Select at least 1 track to preview', 'error'); return; } - if (currentTool === 'draw') { - if (!isDrawing) { - startDrawing(e.latlng); - } else { - continueDrawing(e.latlng); - } - } else if (currentTool === 'geocache') { - // Place a new geocache - if (!navMode) { - // In edit mode, place anywhere - placeGeocache(e.latlng); - } else { - // In nav mode, must have GPS enabled and be at the location - if (userLocation) { - const distance = L.latLng(userLocation.lat, userLocation.lng).distanceTo(e.latlng); - if (distance <= 10) { // Within 10 meters of click location - placeGeocache(e.latlng); - } else { - alert('You must be at the location to place a geocache! (within 10 meters)'); - } - } else { - alert('GPS tracking must be enabled to place geocaches in navigation mode!'); - } - } - } - // Don't auto-deselect on map click - use Clear Selection button instead - }); + previewMode = true; + const threshold = parseInt(document.getElementById('mergeThreshold').value); - map.on('dblclick', (e) => { - if (navMode) { - // In navigation mode, double-click sets destination - L.DomEvent.stopPropagation(e); - L.DomEvent.preventDefault(e); + // Hide original tracks + selectedTracks.forEach(t => t.layer.setStyle({ opacity: 0.2 })); - // Find nearest track point - const nearest = findNearestTrackPoint(e.latlng); - if (nearest && nearest.distance < 50) { - // Show navigation dialog - pendingDestination = nearest; - const message = `Navigate to ${nearest.track.name}?`; - document.getElementById('navConfirmMessage').textContent = message; - ensurePopupInBody('navConfirmDialog'); - document.getElementById('navConfirmDialog').style.display = 'flex'; - } - } else if (currentTool === 'draw' && isDrawing) { - L.DomEvent.stopPropagation(e); - finishDrawing(); - } - }); + // Generate and show preview + updatePreview(threshold); - // Reshape tool mouse handlers - map.on('mousedown', (e) => { - if (currentTool === 'reshape' && !isDragging) { - if (startReshapeDrag(e.latlng)) { - L.DomEvent.stopPropagation(e); - } - } - if (currentTool === 'smooth' && !isSmoothing) { - startSmoothing(e.latlng); - map.dragging.disable(); - } - }); + // Update UI + document.getElementById('previewBtn').style.display = 'none'; + document.getElementById('applyMergeBtn').style.display = 'block'; + document.getElementById('cancelPreviewBtn').style.display = 'block'; + document.getElementById('mergeThreshold').classList.add('preview-active'); - map.on('mousemove', (e) => { - if (currentTool === 'reshape' && isDragging) { - continueReshapeDrag(e.latlng); - } - if (currentTool === 'smooth' && isSmoothing) { - continueSmoothing(e.latlng); - } - }); + updateStatus('Adjust slider to fine-tune, then Apply or Cancel', 'info'); + } - map.on('mouseup', (e) => { - if (currentTool === 'reshape' && isDragging) { - finishReshapeDrag(); - } - if (currentTool === 'smooth' && isSmoothing) { - finishSmoothing(); - map.dragging.enable(); - } - }); + function updatePreview(threshold) { + // Clear old preview layers + previewLayers.forEach(layer => map.removeLayer(layer)); + previewLayers = []; - // Keyboard shortcuts - document.addEventListener('keydown', (e) => { - // Escape to cancel reshape - if (e.key === 'Escape' && isDragging) { - cancelReshapeDrag(); + if (selectedTracks.length === 1) { + // Single track: simplify by merging points that double back + previewData = simplifySingleTrack(selectedTracks[0].coords, threshold); + } else { + // Multiple tracks: merge them together + previewData = mergeMultipleTracks(selectedTracks, threshold); } - // Delete key to delete selected tracks - if (e.key === 'Delete' && selectedTracks.length > 0) { - saveStateForUndo(); - const count = selectedTracks.length; - selectedTracks.forEach(track => { - const idx = tracks.indexOf(track); - if (idx > -1) { - track.remove(); - tracks.splice(idx, 1); - } - }); - selectedTracks = []; - updateTrackList(); - updateStatus(`Deleted ${count} track(s)`, 'success'); + // Show preview of main track (green dashed) + if (previewData.mainCoords.length > 0) { + const mainPreview = L.polyline(previewData.mainCoords, { + color: '#00cc00', + weight: 5, + opacity: 0.9, + dashArray: '10, 5' + }).addTo(map); + previewLayers.push(mainPreview); } - }); - // Merge selected tracks by connecting end-to-end (in selection order) - function mergeConnect() { - if (selectedTracks.length < 2) { - updateStatus('Select at least 2 tracks to merge', 'error'); - return; - } + // Show preview of branches (orange dashed) + previewData.branches.forEach(branch => { + const branchPreview = L.polyline(branch.coords, { + color: '#ff8800', + weight: 4, + opacity: 0.9, + dashArray: '10, 5' + }).addTo(map); + previewLayers.push(branchPreview); + }); - saveStateForUndo(); + // Update status with stats + const reduction = selectedTracks.reduce((sum, t) => sum + t.coords.length, 0) - previewData.mainCoords.length; + const branchInfo = previewData.branches.length > 0 ? `, ${previewData.branches.length} branch(es)` : ''; + updateStatus(`Preview: ${previewData.mainCoords.length} points${branchInfo} (${reduction} points merged)`, 'info'); + } - // Connect tracks in selection order, finding best endpoint connections - let mergedCoords = [...selectedTracks[0].coords]; + function cancelPreview() { + previewMode = false; - for (let i = 1; i < selectedTracks.length; i++) { - const nextCoords = selectedTracks[i].coords; + // Remove preview layers + previewLayers.forEach(layer => map.removeLayer(layer)); + previewLayers = []; + previewData = null; - // Find best connection: check all 4 endpoint combinations - const currentEnd = L.latLng(mergedCoords[mergedCoords.length - 1]); - const currentStart = L.latLng(mergedCoords[0]); - const nextStart = L.latLng(nextCoords[0]); - const nextEnd = L.latLng(nextCoords[nextCoords.length - 1]); + // Restore original tracks + selectedTracks.forEach(t => { + t.layer.setStyle({ opacity: 0.8 }); + t.setSelected(true); + }); - const d1 = map.distance(currentEnd, nextStart); // end -> start (normal) - const d2 = map.distance(currentEnd, nextEnd); // end -> end (reverse next) - const d3 = map.distance(currentStart, nextStart); // start -> start (reverse current) - const d4 = map.distance(currentStart, nextEnd); // start -> end (reverse both) + // Update UI + document.getElementById('previewBtn').style.display = 'block'; + document.getElementById('applyMergeBtn').style.display = 'none'; + document.getElementById('cancelPreviewBtn').style.display = 'none'; + document.getElementById('mergeThreshold').classList.remove('preview-active'); - const minDist = Math.min(d1, d2, d3, d4); + updateStatus('Preview cancelled', 'info'); + } - if (minDist === d1) { - // Normal: append next - mergedCoords = [...mergedCoords, ...nextCoords.slice(1)]; - } else if (minDist === d2) { - // Reverse next track - mergedCoords = [...mergedCoords, ...nextCoords.slice(0, -1).reverse()]; - } else if (minDist === d3) { - // Reverse current, then append next - mergedCoords = [...mergedCoords.reverse(), ...nextCoords.slice(1)]; - } else { - // Reverse current, reverse next - mergedCoords = [...mergedCoords.reverse(), ...nextCoords.slice(0, -1).reverse()]; - } - } + function applyMerge() { + if (!previewData) return; - // Create new track - const numMerged = selectedTracks.length; - const name = prompt('Enter name for merged track:', selectedTracks.map(t => t.name).join(' + ')); - if (name === null) return; + saveStateForUndo(); - const newTrack = new Track(mergedCoords, name || 'Merged Track'); + // Remove preview layers + previewLayers.forEach(layer => map.removeLayer(layer)); + previewLayers = []; // Remove old tracks + const numMerged = selectedTracks.length; + const oldNames = selectedTracks.map(t => t.name); selectedTracks.forEach(track => { const idx = tracks.indexOf(track); if (idx > -1) { @@ -5290,25 +6759,125 @@ } }); - tracks.push(newTrack); - selectedTracks = [newTrack]; - newTrack.setSelected(true); + // Create branch tracks + previewData.branches.forEach((branch, i) => { + const branchTrack = new Track(branch.coords, `${oldNames[0]} (branch ${i + 1})`); + branchTrack.layer.setStyle({ color: '#ff8800' }); + tracks.push(branchTrack); + }); + + // Create main merged track + const defaultName = numMerged === 1 ? `${oldNames[0]} (simplified)` : oldNames.join(' + '); + const name = prompt('Enter name for merged track:', defaultName); + + if (name !== null) { + const newTrack = new Track(previewData.mainCoords, name || defaultName); + tracks.push(newTrack); + selectedTracks = [newTrack]; + newTrack.setSelected(true); + } else { + selectedTracks = []; + } + + // Reset state + previewMode = false; + previewData = null; + + // Update UI + document.getElementById('previewBtn').style.display = 'block'; + document.getElementById('applyMergeBtn').style.display = 'none'; + document.getElementById('cancelPreviewBtn').style.display = 'none'; + document.getElementById('mergeThreshold').classList.remove('preview-active'); updateTrackList(); - updateStatus(`Merged ${numMerged} tracks into "${newTrack.name}"`, 'success'); + updateStatus(`Applied merge: ${numMerged} track(s)`, 'success'); } - // Merge selected tracks by averaging overlapping points - function mergeAverage() { - if (selectedTracks.length < 2) { - updateStatus('Select at least 2 tracks to merge', 'error'); - return; + // Simplify a single track: merge points that are spatially close AND temporally far apart + // This detects double-backs while preserving normal path segments + function simplifySingleTrack(coords, threshold) { + if (coords.length < 3) { + return { mainCoords: coords, branches: [] }; } - const threshold = 25; // meters - points within this distance get averaged + // Minimum sequence gap required to consider merging + // Points closer than this in sequence won't be merged even if spatially close + const minSequenceGap = 15; + + const clusters = []; // { points: [], centroid: [], pointIndices: [] } + const pointToCluster = new Array(coords.length).fill(-1); + + for (let i = 0; i < coords.length; i++) { + const pt = L.latLng(coords[i]); + + // Find if this point should join an existing cluster + // Requirements: + // 1. Spatially close (within threshold) + // 2. Temporally far (no points from that cluster in recent window) + let bestCluster = -1; + let bestDist = Infinity; + + for (let c = 0; c < clusters.length; c++) { + const cluster = clusters[c]; + const dist = map.distance(pt, L.latLng(cluster.centroid)); + + if (dist < threshold && dist < bestDist) { + // Check temporal distance - cluster must not have recent points + const mostRecentInCluster = Math.max(...cluster.pointIndices); + const sequenceGap = i - mostRecentInCluster; + + if (sequenceGap >= minSequenceGap) { + // Far enough apart temporally - this is a double-back + bestDist = dist; + bestCluster = c; + } + } + } + + if (bestCluster >= 0) { + // Merge with existing cluster (double-back detected) + const cluster = clusters[bestCluster]; + cluster.points.push(coords[i]); + cluster.pointIndices.push(i); + // Recalculate centroid + let sumLat = 0, sumLng = 0; + for (const p of cluster.points) { + sumLat += p[0]; + sumLng += p[1]; + } + cluster.centroid = [sumLat / cluster.points.length, sumLng / cluster.points.length]; + pointToCluster[i] = bestCluster; + } else { + // Create new cluster (new territory or too recent to merge) + clusters.push({ + points: [coords[i]], + centroid: [...coords[i]], + pointIndices: [i] + }); + pointToCluster[i] = clusters.length - 1; + } + } + + // Rebuild path following original order, using cluster centroids + // Remove consecutive duplicates (same cluster visited multiple times in a row) + const result = []; + let lastCluster = -1; + + for (let i = 0; i < coords.length; i++) { + const clusterIdx = pointToCluster[i]; + if (clusterIdx !== lastCluster) { + result.push([...clusters[clusterIdx].centroid]); + lastCluster = clusterIdx; + } + } + + return { mainCoords: result, branches: [] }; + } + // Merge multiple tracks together + function mergeMultipleTracks(tracksToMerge, threshold) { // Use the longest track as the base - const sortedByLength = [...selectedTracks].sort((a, b) => b.coords.length - a.coords.length); + const sortedByLength = [...tracksToMerge].sort((a, b) => b.coords.length - a.coords.length); const baseTrack = sortedByLength[0]; const otherTracks = sortedByLength.slice(1); @@ -5316,7 +6885,6 @@ const otherTrackData = otherTracks.map(track => ({ track, points: track.coords.map(coord => { - // Check if this point is near any base track point let isNearBase = false; for (const baseCoord of baseTrack.coords) { if (map.distance(L.latLng(coord), L.latLng(baseCoord)) < threshold) { @@ -5328,9 +6896,11 @@ }) })); - // For each point in base, find and average nearby points from other tracks + // For each base point, find corresponding points in other tracks using sequence matching const result = []; - const usedPoints = otherTrackData.map(d => d.points.map(() => false)); + + // Track position in each other track (to maintain order) + const trackPositions = otherTracks.map(() => 0); for (let i = 0; i < baseTrack.coords.length; i++) { const basePt = L.latLng(baseTrack.coords[i]); @@ -5338,36 +6908,39 @@ let sumLng = baseTrack.coords[i][1]; let count = 1; - // Find closest point from each other track - for (let t = 0; t < otherTrackData.length; t++) { - const otherData = otherTrackData[t]; - let closestDist = Infinity; - let closestIdx = -1; + // For each other track, find the best matching point near current position + for (let t = 0; t < otherTracks.length; t++) { + const otherCoords = otherTracks[t].coords; + let bestIdx = -1; + let bestDist = Infinity; - for (let j = 0; j < otherData.points.length; j++) { - if (usedPoints[t][j]) continue; + // Search in a window around the current track position + const searchStart = Math.max(0, trackPositions[t] - 10); + const searchEnd = Math.min(otherCoords.length, trackPositions[t] + 50); - const pt = otherData.points[j]; - const dist = map.distance(basePt, L.latLng(pt.coord)); - if (dist < closestDist && dist < threshold) { - closestDist = dist; - closestIdx = j; + for (let j = searchStart; j < searchEnd; j++) { + const dist = map.distance(basePt, L.latLng(otherCoords[j])); + if (dist < threshold && dist < bestDist) { + bestDist = dist; + bestIdx = j; } } - if (closestIdx >= 0) { - const pt = otherData.points[closestIdx]; - sumLat += pt.coord[0]; - sumLng += pt.coord[1]; + if (bestIdx >= 0) { + sumLat += otherCoords[bestIdx][0]; + sumLng += otherCoords[bestIdx][1]; count++; - usedPoints[t][closestIdx] = true; + // Move track position forward (only forward, to maintain order) + if (bestIdx >= trackPositions[t]) { + trackPositions[t] = bestIdx + 1; + } } } result.push([sumLat / count, sumLng / count]); } - // Collect non-overlapping segments (points NOT near base track) + // Collect branches (non-overlapping segments) const branches = []; for (let t = 0; t < otherTrackData.length; t++) { const otherData = otherTrackData[t]; @@ -5376,1361 +6949,1932 @@ for (let i = 0; i < otherData.points.length; i++) { const pt = otherData.points[i]; if (!pt.isNearBase) { - // This point is NOT near the base track - it's a branch currentBranch.push(pt.coord); } else { - // Point is near base, save any accumulated branch if (currentBranch.length >= 2) { - branches.push({ - coords: [...currentBranch], - trackName: otherData.track.name - }); + branches.push({ coords: [...currentBranch] }); } currentBranch = []; } } - - // Don't forget trailing branch if (currentBranch.length >= 2) { - branches.push({ - coords: [...currentBranch], - trackName: otherData.track.name + branches.push({ coords: [...currentBranch] }); + } + } + + return { mainCoords: result, branches }; + } + + // Parse KML + function parseKML(kmlText) { + const parser = new DOMParser(); + const kml = parser.parseFromString(kmlText, 'text/xml'); + const placemarks = kml.querySelectorAll('Placemark'); + let count = 0; + + placemarks.forEach(placemark => { + const name = placemark.querySelector('name')?.textContent || 'Track'; + const description = placemark.querySelector('description')?.textContent || ''; + + // Handle LineString + const lineStrings = placemark.querySelectorAll('LineString'); + lineStrings.forEach(lineString => { + const coordsText = lineString.querySelector('coordinates')?.textContent; + const coords = parseCoordinates(coordsText); + if (coords.length > 0) { + const track = new Track(coords, name, description); + tracks.push(track); + count++; + } + }); + }); + + return count; + } + + // Parse coordinates + function parseCoordinates(coordString) { + if (!coordString) return []; + const coords = []; + const points = coordString.trim().split(/\s+/); + + points.forEach(point => { + const parts = point.split(','); + if (parts.length >= 2) { + const lng = parseFloat(parts[0]); + const lat = parseFloat(parts[1]); + if (!isNaN(lat) && !isNaN(lng)) { + coords.push([lat, lng]); + } + } + }); + + return coords; + } + + // Export to KML + function exportToKML() { + let placemarks = ''; + + tracks.forEach(track => { + const coords = track.coords.map(c => `${c[1]},${c[0]},0`).join(' '); + placemarks += ` + + ${escapeXml(track.name)} + ${escapeXml(track.description)} + + ${coords} + + `; + }); + + const kml = ` + + + Exported Tracks${placemarks} + +`; + + const blob = new Blob([kml], { type: 'application/vnd.google-earth.kml+xml' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'tracks-export.kml'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + updateStatus('Exported KML file', 'success'); + } + + function reloadTracks() { + // Clear existing tracks + tracks.forEach(track => track.remove()); + tracks.length = 0; + selectedTracks = []; + + // Reload from server with cache busting + fetch('default.kml?t=' + Date.now()) + .then(response => { + if (!response.ok) throw new Error('default.kml not found'); + return response.text(); + }) + .then(kmlText => { + const count = parseKML(kmlText); + updateTrackList(); + updateStatus(`Reloaded ${count} track(s) from server`, 'success'); + + if (tracks.length > 0) { + const bounds = L.latLngBounds(tracks.flatMap(t => t.coords)); + map.fitBounds(bounds, { padding: [20, 20] }); + } + }) + .catch(err => { + updateStatus('Failed to reload tracks: ' + err.message, 'error'); + }); + } + + function saveToServer() { + // Generate KML content + let placemarks = ''; + tracks.forEach(track => { + const coords = track.coords.map(c => `${c[1]},${c[0]},0`).join(' '); + placemarks += ` + + ${escapeXml(track.name)} + ${escapeXml(track.description)} + + ${coords} + + `; + }); + + const kml = ` + + + Saved Tracks${placemarks} + +`; + + // Send to server + updateStatus('Saving to server...', 'info'); + + fetch('/save-kml', { + method: 'POST', + headers: { + 'Content-Type': 'application/xml' + }, + body: kml + }) + .then(response => { + if (!response.ok) { + return response.text().then(text => { + throw new Error(text || 'Failed to save'); + }); + } + return response.json(); + }) + .then(result => { + updateStatus(result.message || 'Saved to server successfully', 'success'); + }) + .catch(err => { + updateStatus('Failed to save: ' + err.message, 'error'); + }); + } + + function escapeXml(str) { + if (!str) return ''; + return str.replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + // Update UI + function updateStatus(message, type = '') { + const statusEl = document.getElementById('status'); + statusEl.textContent = message; + statusEl.className = 'status' + (type ? ' ' + type : ''); + } + + function updateTrackList() { + const listEl = document.getElementById('trackList'); + const countEl = document.getElementById('trackCount'); + + if (countEl) { + countEl.textContent = tracks.length; + } + + if (listEl) { + listEl.innerHTML = tracks.map((track, i) => ` +
+ ${track.name} + +
+ `).join(''); + + // Add click handlers + listEl.querySelectorAll('.track-item').forEach(item => { + item.addEventListener('click', (e) => { + if (!e.target.classList.contains('delete-btn')) { + const idx = parseInt(item.dataset.index); + selectTrack(tracks[idx]); + map.fitBounds(tracks[idx].layer.getBounds(), { padding: [50, 50] }); + } + }); + }); + + listEl.querySelectorAll('.delete-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const idx = parseInt(btn.dataset.index); + deleteTrack(tracks[idx]); }); - } + }); } + } - // First, remove old tracks - const numMerged = selectedTracks.length; - selectedTracks.forEach(track => { - const idx = tracks.indexOf(track); - if (idx > -1) { - track.remove(); - tracks.splice(idx, 1); - } + // Event listeners + const kmlFileEl = document.getElementById('kmlFile'); + if (kmlFileEl) { + kmlFileEl.addEventListener('change', function(e) { + const file = e.target.files[0]; + if (!file) return; + + updateStatus('Loading...', 'info'); + + const reader = new FileReader(); + reader.onload = function(e) { + try { + const count = parseKML(e.target.result); + updateTrackList(); + + if (tracks.length > 0) { + const bounds = L.latLngBounds(tracks.flatMap(t => t.coords)); + map.fitBounds(bounds, { padding: [20, 20] }); + } + + updateStatus(`Loaded ${count} track(s) from ${file.name}`, 'success'); + } catch (err) { + updateStatus(`Error: ${err.message}`, 'error'); + } + }; + reader.readAsText(file); }); + } - // Create branch tracks (in orange so they're visible) - const branchTracks = []; - for (let i = 0; i < branches.length; i++) { - const branch = branches[i]; - const branchTrack = new Track(branch.coords, `${branch.trackName} (branch ${i + 1})`); - branchTrack.layer.setStyle({ color: '#ff8800', weight: 4 }); // Orange - tracks.push(branchTrack); - branchTracks.push(branchTrack); - } - - // Create new track for the averaged main path - const name = prompt('Enter name for averaged track:', baseTrack.name + ' (averaged)'); - if (name === null) { - // User cancelled, but we already deleted tracks... restore branches at least - updateTrackList(); - updateStatus('Cancelled - branches preserved', 'info'); - return; - } - - const newTrack = new Track(result, name || 'Averaged Track'); - tracks.push(newTrack); - - selectedTracks = [newTrack]; - newTrack.setSelected(true); - - updateTrackList(); - const branchMsg = branches.length > 0 ? ` + ${branches.length} branch(es) in orange` : ''; - updateStatus(`Averaged ${numMerged} tracks${branchMsg}`, 'success'); + const el_exportBtn = document.getElementById('exportBtn'); + if (el_exportBtn) { + el_exportBtn.addEventListener('click', exportToKML); } - // === PREVIEW SYSTEM === + const reloadBtn = document.getElementById('reloadBtn'); + if (reloadBtn) { + reloadBtn.addEventListener('click', reloadTracks); + } - function startPreview() { - if (selectedTracks.length === 0) { - updateStatus('Select at least 1 track to preview', 'error'); - return; - } + const el_saveServerBtn = document.getElementById('saveServerBtn'); + if (el_saveServerBtn) { + el_saveServerBtn.addEventListener('click', saveToServer); + } - previewMode = true; - const threshold = parseInt(document.getElementById('mergeThreshold').value); + const gpsBtn = document.getElementById('gpsBtn'); + if (gpsBtn) { + gpsBtn.addEventListener('click', toggleGPS); + } - // Hide original tracks - selectedTracks.forEach(t => t.layer.setStyle({ opacity: 0.2 })); + const el_rotateMapBtn = document.getElementById('rotateMapBtn'); + if (el_rotateMapBtn) { + el_rotateMapBtn.addEventListener('click', toggleRotateMap); + } - // Generate and show preview - updatePreview(threshold); + const autoCenterBtn = document.getElementById('autoCenterBtn'); + if (autoCenterBtn) { + autoCenterBtn.addEventListener('click', toggleAutoCenter); + } - // Update UI - document.getElementById('previewBtn').style.display = 'none'; - document.getElementById('applyMergeBtn').style.display = 'block'; - document.getElementById('cancelPreviewBtn').style.display = 'block'; - document.getElementById('mergeThreshold').classList.add('preview-active'); + // Tab switching + const el_editTab = document.getElementById('editTab'); + if (el_editTab) { + el_editTab.addEventListener('click', () => switchTab('edit')); + } - updateStatus('Adjust slider to fine-tune, then Apply or Cancel', 'info'); + const navTab = document.getElementById('navTab'); + if (navTab) { + navTab.addEventListener('click', () => switchTab('navigate')); + } + const el_adminTab = document.getElementById('adminTab'); + if (el_adminTab) { + el_adminTab.addEventListener('click', () => switchTab('admin')); } - function updatePreview(threshold) { - // Clear old preview layers - previewLayers.forEach(layer => map.removeLayer(layer)); - previewLayers = []; + // Edit overlay close button + setTimeout(() => { + const editCloseBtn = document.getElementById('editCloseBtn'); + if (editCloseBtn) { + editCloseBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const editOverlay = document.querySelector('.edit-panel-overlay'); + if (editOverlay) { + editOverlay.classList.remove('active'); + } + // Remove active class from edit tab + document.getElementById('editTab').classList.remove('active'); + // Switch to navigate tab + switchTab('navigate'); + }); + } + }, 100); - if (selectedTracks.length === 1) { - // Single track: simplify by merging points that double back - previewData = simplifySingleTrack(selectedTracks[0].coords, threshold); + // Admin overlay close button - defer to ensure DOM element exists + setTimeout(() => { + const adminCloseBtn = document.getElementById('adminCloseBtn'); + if (adminCloseBtn) { + console.log('Admin close button found, attaching listener'); + adminCloseBtn.addEventListener('click', (e) => { + console.log('Admin close button clicked'); + e.preventDefault(); + e.stopPropagation(); + const adminOverlay = document.querySelector('.admin-panel-overlay'); + if (adminOverlay) { + adminOverlay.classList.remove('active'); + } + // Remove active class from admin tab + document.getElementById('adminTab').classList.remove('active'); + // Switch back to edit tab + switchTab('edit'); + }); } else { - // Multiple tracks: merge them together - previewData = mergeMultipleTracks(selectedTracks, threshold); + console.error('Admin close button not found!'); } + }, 100); - // Show preview of main track (green dashed) - if (previewData.mainCoords.length > 0) { - const mainPreview = L.polyline(previewData.mainCoords, { - color: '#00cc00', - weight: 5, - opacity: 0.9, - dashArray: '10, 5' - }).addTo(map); - previewLayers.push(mainPreview); - } + // Navigation + const clearNavBtn = document.getElementById('clearNavBtn'); + if (clearNavBtn) clearNavBtn.addEventListener('click', clearDestination); - // Show preview of branches (orange dashed) - previewData.branches.forEach(branch => { - const branchPreview = L.polyline(branch.coords, { - color: '#ff8800', - weight: 4, - opacity: 0.9, - dashArray: '10, 5' - }).addTo(map); - previewLayers.push(branchPreview); - }); + // Panel toggle - wrap to prevent error if element doesn't exist + const panelToggle = document.getElementById('panelToggle'); + if (panelToggle) { + panelToggle.addEventListener('click', function() { + console.log('Hamburger clicked!'); // Debug + const panel = document.getElementById('controlPanel'); + const toggleBtn = document.getElementById('panelToggle'); - // Update status with stats - const reduction = selectedTracks.reduce((sum, t) => sum + t.coords.length, 0) - previewData.mainCoords.length; - const branchInfo = previewData.branches.length > 0 ? `, ${previewData.branches.length} branch(es)` : ''; - updateStatus(`Preview: ${previewData.mainCoords.length} points${branchInfo} (${reduction} points merged)`, 'info'); + if (panel && toggleBtn) { + if (panel.style.display === 'none') { + panel.style.display = 'block'; + toggleBtn.style.right = '300px'; + } else { + panel.style.display = 'none'; + toggleBtn.style.right = '10px'; + } + } + }); + } else { + console.error('panelToggle element not found!'); } - function cancelPreview() { - previewMode = false; + const undoBtn = document.getElementById('undoBtn'); + if (undoBtn) undoBtn.addEventListener('click', undo); - // Remove preview layers - previewLayers.forEach(layer => map.removeLayer(layer)); - previewLayers = []; - previewData = null; + const mergeConnectBtn = document.getElementById('mergeConnectBtn'); + if (mergeConnectBtn) mergeConnectBtn.addEventListener('click', mergeConnect); - // Restore original tracks - selectedTracks.forEach(t => { - t.layer.setStyle({ opacity: 0.8 }); - t.setSelected(true); - }); + const selectAllBtn = document.getElementById('selectAllBtn'); + if (selectAllBtn) selectAllBtn.addEventListener('click', selectAll); - // Update UI - document.getElementById('previewBtn').style.display = 'block'; - document.getElementById('applyMergeBtn').style.display = 'none'; - document.getElementById('cancelPreviewBtn').style.display = 'none'; - document.getElementById('mergeThreshold').classList.remove('preview-active'); + const clearSelectionBtn = document.getElementById('clearSelectionBtn'); + if (clearSelectionBtn) clearSelectionBtn.addEventListener('click', clearSelection); + + // Remesh button and dialog + const remeshSlider = document.getElementById('remeshSpacing'); + const remeshValueDisplay = document.getElementById('remeshSpacingValue'); + + remeshSlider.addEventListener('input', () => { + remeshValueDisplay.textContent = remeshSlider.value; + }); + + const el_remeshBtn = document.getElementById('remeshBtn'); + if (el_remeshBtn) { + el_remeshBtn.addEventListener('click', () => { + if (selectedTracks.length === 0) { + updateStatus('Please select tracks to remesh first', 'error'); + return; + } + + // Reset slider to default + remeshSlider.value = 5; + remeshValueDisplay.textContent = '5'; - updateStatus('Preview cancelled', 'info'); + // Show confirmation dialog + const trackNames = selectedTracks.map(t => t.name).join(', '); + const totalPoints = selectedTracks.reduce((sum, t) => sum + t.coords.length, 0); + document.getElementById('remeshDetails').innerHTML = + `Selected tracks: ${trackNames}
` + + `Current total points: ${totalPoints}`; + ensurePopupInBody('remeshDialog'); + document.getElementById('remeshDialog').style.display = 'flex'; + }); } - function applyMerge() { - if (!previewData) return; + const el_remeshYes = document.getElementById('remeshYes'); + if (el_remeshYes) { + el_remeshYes.addEventListener('click', () => { + document.getElementById('remeshDialog').style.display = 'none'; - saveStateForUndo(); + // Get the spacing value from slider + const spacing = parseInt(remeshSlider.value); - // Remove preview layers - previewLayers.forEach(layer => map.removeLayer(layer)); - previewLayers = []; + // Remesh all selected tracks with the chosen spacing + selectedTracks.forEach(track => { + remeshTrack(track, spacing); + }); - // Remove old tracks - const numMerged = selectedTracks.length; - const oldNames = selectedTracks.map(t => t.name); - selectedTracks.forEach(track => { - const idx = tracks.indexOf(track); - if (idx > -1) { - track.remove(); - tracks.splice(idx, 1); - } + // Clear selection after remeshing + clearSelection(); }); + } - // Create branch tracks - previewData.branches.forEach((branch, i) => { - const branchTrack = new Track(branch.coords, `${oldNames[0]} (branch ${i + 1})`); - branchTrack.layer.setStyle({ color: '#ff8800' }); - tracks.push(branchTrack); + const el_remeshNo = document.getElementById('remeshNo'); + if (el_remeshNo) { + el_remeshNo.addEventListener('click', () => { + document.getElementById('remeshDialog').style.display = 'none'; }); + } - // Create main merged track - const defaultName = numMerged === 1 ? `${oldNames[0]} (simplified)` : oldNames.join(' + '); - const name = prompt('Enter name for merged track:', defaultName); - - if (name !== null) { - const newTrack = new Track(previewData.mainCoords, name || defaultName); - tracks.push(newTrack); - selectedTracks = [newTrack]; - newTrack.setSelected(true); - } else { - selectedTracks = []; + // Preview system + const el_previewBtn = document.getElementById('previewBtn'); + if (el_previewBtn) { + el_previewBtn.addEventListener('click', startPreview); + const applyMergeBtn = document.getElementById('applyMergeBtn'); + if (applyMergeBtn) { + applyMergeBtn.addEventListener('click', applyMerge); } + } + const el_cancelPreviewBtn = document.getElementById('cancelPreviewBtn'); + if (el_cancelPreviewBtn) { + el_cancelPreviewBtn.addEventListener('click', cancelPreview); + } - // Reset state - previewMode = false; - previewData = null; + // Live slider update during preview + const mergeThreshold = document.getElementById('mergeThreshold'); + if (mergeThreshold) { + mergeThreshold.addEventListener('input', (e) => { + document.getElementById('thresholdValue').textContent = e.target.value; + if (previewMode) { + updatePreview(parseInt(e.target.value)); + } + }); + } - // Update UI - document.getElementById('previewBtn').style.display = 'block'; - document.getElementById('applyMergeBtn').style.display = 'none'; - document.getElementById('cancelPreviewBtn').style.display = 'none'; - document.getElementById('mergeThreshold').classList.remove('preview-active'); + // Anchor distance slider update + const el_anchorDistance = document.getElementById('anchorDistance'); + if (el_anchorDistance) { + el_anchorDistance.addEventListener('input', (e) => { + document.getElementById('anchorValue').textContent = e.target.value; + // If currently dragging, update the affected markers display + if (isDragging && originalCoords) { + showAffectedRange(); + // Re-apply rope physics with new anchor distance + const anchorDist = parseInt(e.target.value); + const draggedPoint = dragTrack.coords[dragPointIndex]; + const newCoords = applyRopePhysics(originalCoords, dragPointIndex, draggedPoint, anchorDist); + dragTrack.coords = newCoords; + dragTrack.layer.setLatLngs(newCoords); + updateAffectedMarkersPositions(newCoords); + } + }); + } - updateTrackList(); - updateStatus(`Applied merge: ${numMerged} track(s)`, 'success'); + // Falloff slider update + const el_reshapeFalloff = document.getElementById('reshapeFalloff'); + if (el_reshapeFalloff) { + el_reshapeFalloff.addEventListener('input', (e) => { + document.getElementById('falloffValue').textContent = parseFloat(e.target.value).toFixed(1); + // If currently dragging, re-apply with new falloff + if (isDragging && originalCoords) { + const anchorDist = parseInt(document.getElementById('anchorDistance').value); + const draggedPoint = dragTrack.coords[dragPointIndex]; + const newCoords = applyRopePhysics(originalCoords, dragPointIndex, draggedPoint, anchorDist); + dragTrack.coords = newCoords; + dragTrack.layer.setLatLngs(newCoords); + updateAffectedMarkersPositions(newCoords); + } + }); } - // Simplify a single track: merge points that are spatially close AND temporally far apart - // This detects double-backs while preserving normal path segments - function simplifySingleTrack(coords, threshold) { - if (coords.length < 3) { - return { mainCoords: coords, branches: [] }; - } + // Smooth brush size slider update + const el_smoothBrushSize = document.getElementById('smoothBrushSize'); + if (el_smoothBrushSize) { + el_smoothBrushSize.addEventListener('input', (e) => { + document.getElementById('brushSizeValue').textContent = e.target.value; + // Update brush circle if currently smoothing + if (isSmoothing && smoothBrushCircle) { + const brushSize = parseInt(e.target.value); + smoothBrushCircle.setRadius(brushSize * getMetersPerPixel()); + } + }); + } - // Minimum sequence gap required to consider merging - // Points closer than this in sequence won't be merged even if spatially close - const minSequenceGap = 15; + // Smooth strength slider update + const el_smoothStrength = document.getElementById('smoothStrength'); + if (el_smoothStrength) { + el_smoothStrength.addEventListener('input', (e) => { + document.getElementById('strengthValue').textContent = parseFloat(e.target.value).toFixed(1); + }); + } - const clusters = []; // { points: [], centroid: [], pointIndices: [] } - const pointToCluster = new Array(coords.length).fill(-1); + // Register Service Worker for PWA functionality + if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/service-worker.js') + .then(registration => { + console.log('Service Worker registered:', registration.scope); - for (let i = 0; i < coords.length; i++) { - const pt = L.latLng(coords[i]); + // Check for updates periodically + setInterval(() => { + registration.update(); + }, 60 * 60 * 1000); // Check every hour + }) + .catch(err => { + console.log('Service Worker registration failed:', err); + }); + }); - // Find if this point should join an existing cluster - // Requirements: - // 1. Spatially close (within threshold) - // 2. Temporally far (no points from that cluster in recent window) - let bestCluster = -1; - let bestDist = Infinity; + // Listen for app install prompt + let deferredPrompt; + window.addEventListener('beforeinstallprompt', (e) => { + // Prevent Chrome 67 and earlier from automatically showing the prompt + e.preventDefault(); + // Stash the event so it can be triggered later + deferredPrompt = e; + console.log('Install prompt ready'); + }); + } - for (let c = 0; c < clusters.length; c++) { - const cluster = clusters[c]; - const dist = map.distance(pt, L.latLng(cluster.centroid)); + // Push Notification Functions + let pushSubscription = null; - if (dist < threshold && dist < bestDist) { - // Check temporal distance - cluster must not have recent points - const mostRecentInCluster = Math.max(...cluster.pointIndices); - const sequenceGap = i - mostRecentInCluster; + async function setupPushNotifications() { + try { + // Check if notifications are supported + if (!('Notification' in window)) { + alert('This browser does not support notifications'); + return; + } - if (sequenceGap >= minSequenceGap) { - // Far enough apart temporally - this is a double-back - bestDist = dist; - bestCluster = c; - } - } + // Request notification permission + const permission = await Notification.requestPermission(); + if (permission !== 'granted') { + alert('Notification permission denied'); + return; } - if (bestCluster >= 0) { - // Merge with existing cluster (double-back detected) - const cluster = clusters[bestCluster]; - cluster.points.push(coords[i]); - cluster.pointIndices.push(i); - // Recalculate centroid - let sumLat = 0, sumLng = 0; - for (const p of cluster.points) { - sumLat += p[0]; - sumLng += p[1]; - } - cluster.centroid = [sumLat / cluster.points.length, sumLng / cluster.points.length]; - pointToCluster[i] = bestCluster; - } else { - // Create new cluster (new territory or too recent to merge) - clusters.push({ - points: [coords[i]], - centroid: [...coords[i]], - pointIndices: [i] - }); - pointToCluster[i] = clusters.length - 1; + // Get service worker registration + const registration = await navigator.serviceWorker.ready; + + // Get VAPID public key from server + const response = await fetch('/vapid-public-key'); + const { publicKey } = await response.json(); + + if (!publicKey) { + alert('Push notifications not configured on server'); + return; } - } - // Rebuild path following original order, using cluster centroids - // Remove consecutive duplicates (same cluster visited multiple times in a row) - const result = []; - let lastCluster = -1; + // Subscribe to push notifications + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey) + }); + + // Send subscription to server + const subResponse = await fetch('/subscribe', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(subscription) + }); + + if (subResponse.ok) { + pushSubscription = subscription; + updateNotificationUI(true); + updateStatus('Push notifications enabled!', 'success'); - for (let i = 0; i < coords.length; i++) { - const clusterIdx = pointToCluster[i]; - if (clusterIdx !== lastCluster) { - result.push([...clusters[clusterIdx].centroid]); - lastCluster = clusterIdx; + // Test notification using service worker + if ('serviceWorker' in navigator && registration) { + registration.showNotification('HikeMap Notifications Active', { + body: 'You will receive alerts about new geocaches and trail updates', + icon: '/icon-192x192.png', + badge: '/icon-72x72.png', + vibrate: [200, 100, 200] + }); + } } + } catch (error) { + console.error('Failed to setup push notifications:', error); + alert('Failed to enable notifications: ' + error.message); } - - return { mainCoords: result, branches: [] }; } - // Merge multiple tracks together - function mergeMultipleTracks(tracksToMerge, threshold) { - // Use the longest track as the base - const sortedByLength = [...tracksToMerge].sort((a, b) => b.coords.length - a.coords.length); - const baseTrack = sortedByLength[0]; - const otherTracks = sortedByLength.slice(1); + async function disablePushNotifications() { + try { + if (pushSubscription) { + // Unsubscribe from push + await pushSubscription.unsubscribe(); - // For each point in other tracks, check if it's near ANY point in base track - const otherTrackData = otherTracks.map(track => ({ - track, - points: track.coords.map(coord => { - let isNearBase = false; - for (const baseCoord of baseTrack.coords) { - if (map.distance(L.latLng(coord), L.latLng(baseCoord)) < threshold) { - isNearBase = true; - break; - } - } - return { coord, isNearBase }; - }) - })); + // Tell server to remove subscription + await fetch('/unsubscribe', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + endpoint: pushSubscription.endpoint + }) + }); - // For each base point, find corresponding points in other tracks using sequence matching - const result = []; + pushSubscription = null; + updateNotificationUI(false); + updateStatus('Push notifications disabled', 'info'); + } + } catch (error) { + console.error('Failed to disable notifications:', error); + } + } - // Track position in each other track (to maintain order) - const trackPositions = otherTracks.map(() => 0); + function updateNotificationUI(enabled) { + const statusText = document.getElementById('notificationStatusText'); + const enableBtn = document.getElementById('enableNotifications'); + const disableBtn = document.getElementById('disableNotifications'); + const testBtn = document.getElementById('testNotification'); - for (let i = 0; i < baseTrack.coords.length; i++) { - const basePt = L.latLng(baseTrack.coords[i]); - let sumLat = baseTrack.coords[i][0]; - let sumLng = baseTrack.coords[i][1]; - let count = 1; + if (enabled) { + statusText.textContent = 'Enabled'; + statusText.style.color = '#4CAF50'; + enableBtn.style.display = 'none'; + disableBtn.style.display = 'block'; + testBtn.style.display = 'block'; + } else { + statusText.textContent = 'Disabled'; + statusText.style.color = '#666'; + enableBtn.style.display = 'block'; + disableBtn.style.display = 'none'; + testBtn.style.display = 'none'; + } + } - // For each other track, find the best matching point near current position - for (let t = 0; t < otherTracks.length; t++) { - const otherCoords = otherTracks[t].coords; - let bestIdx = -1; - let bestDist = Infinity; + async function sendTestNotification() { + try { + const response = await fetch('/test-notification', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + message: 'Test notification from HikeMap admin' + }) + }); - // Search in a window around the current track position - const searchStart = Math.max(0, trackPositions[t] - 10); - const searchEnd = Math.min(otherCoords.length, trackPositions[t] + 50); + const result = await response.json(); + if (result.success) { + updateStatus(`Test notification sent to ${result.sent} users!`, 'success'); + } else { + updateStatus('Failed to send test notification', 'error'); + } + } catch (error) { + console.error('Error sending test notification:', error); + updateStatus('Error sending test notification', 'error'); + } + } - for (let j = searchStart; j < searchEnd; j++) { - const dist = map.distance(basePt, L.latLng(otherCoords[j])); - if (dist < threshold && dist < bestDist) { - bestDist = dist; - bestIdx = j; - } - } + // Helper function to convert VAPID key + function urlBase64ToUint8Array(base64String) { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding) + .replace(/\-/g, '+') + .replace(/_/g, '/'); - if (bestIdx >= 0) { - sumLat += otherCoords[bestIdx][0]; - sumLng += otherCoords[bestIdx][1]; - count++; - // Move track position forward (only forward, to maintain order) - if (bestIdx >= trackPositions[t]) { - trackPositions[t] = bestIdx + 1; - } - } - } + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); - result.push([sumLat / count, sumLng / count]); + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); } + return outputArray; + } - // Collect branches (non-overlapping segments) - const branches = []; - for (let t = 0; t < otherTrackData.length; t++) { - const otherData = otherTrackData[t]; - let currentBranch = []; - - for (let i = 0; i < otherData.points.length; i++) { - const pt = otherData.points[i]; - if (!pt.isNearBase) { - currentBranch.push(pt.coord); - } else { - if (currentBranch.length >= 2) { - branches.push({ coords: [...currentBranch] }); - } - currentBranch = []; + // Check existing push subscription on load + async function checkPushSubscription() { + if ('serviceWorker' in navigator && 'PushManager' in window) { + try { + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + if (subscription) { + pushSubscription = subscription; + updateNotificationUI(true); } - } - if (currentBranch.length >= 2) { - branches.push({ coords: [...currentBranch] }); + } catch (error) { + console.error('Error checking push subscription:', error); } } - - return { mainCoords: result, branches }; } - // Parse KML - function parseKML(kmlText) { - const parser = new DOMParser(); - const kml = parser.parseFromString(kmlText, 'text/xml'); - const placemarks = kml.querySelectorAll('Placemark'); - let count = 0; + // Initialize + setTool('select'); + updateTrackList(); + updateUndoButton(); - placemarks.forEach(placemark => { - const name = placemark.querySelector('name')?.textContent || 'Track'; - const description = placemark.querySelector('description')?.textContent || ''; + // Start in navigation mode + navMode = true; - // Handle LineString - const lineStrings = placemark.querySelectorAll('LineString'); - lineStrings.forEach(lineString => { - const coordsText = lineString.querySelector('coordinates')?.textContent; - const coords = parseCoordinates(coordsText); - if (coords.length > 0) { - const track = new Track(coords, name, description); - tracks.push(track); - count++; - } - }); - }); + // Load admin settings + loadAdminSettings(); - return count; - } + // Setup admin panel event handlers + setupAdminInputListeners(); - // Parse coordinates - function parseCoordinates(coordString) { - if (!coordString) return []; - const coords = []; - const points = coordString.trim().split(/\s+/); + // Load user's icon choice or show selector + loadUserIcon(); - points.forEach(point => { - const parts = point.split(','); - if (parts.length >= 2) { - const lng = parseFloat(parts[0]); - const lat = parseFloat(parts[1]); - if (!isNaN(lat) && !isNaN(lng)) { - coords.push([lat, lng]); - } + // Connect to WebSocket for multi-user tracking + connectWebSocket(); + + // Check if push notifications are already enabled + checkPushSubscription(); + + // Setup resume navigation dialog handlers + const el_resumeNavYes = document.getElementById('resumeNavYes'); + if (el_resumeNavYes) { + el_resumeNavYes.addEventListener('click', () => { + document.getElementById('resumeNavDialog').style.display = 'none'; + // Restore saved navigation + const savedNav = localStorage.getItem('navMode'); + if (savedNav === 'true') { + switchTab('navigate'); + restoreDestination(); } }); + } - return coords; + const el_resumeNavNo = document.getElementById('resumeNavNo'); + if (el_resumeNavNo) { + el_resumeNavNo.addEventListener('click', () => { + document.getElementById('resumeNavDialog').style.display = 'none'; + localStorage.removeItem('navDestination'); + localStorage.removeItem('navMode'); + }); } - // Export to KML - function exportToKML() { - let placemarks = ''; + // Auto-load default.kml with cache busting + fetch('default.kml?t=' + Date.now()) + .then(response => { + if (!response.ok) throw new Error('default.kml not found'); + return response.text(); + }) + .then(kmlText => { + const count = parseKML(kmlText); + updateTrackList(); + updateStatus(`Loaded ${count} track(s) from default.kml`, 'success'); - tracks.forEach(track => { - const coords = track.coords.map(c => `${c[1]},${c[0]},0`).join(' '); - placemarks += ` - - ${escapeXml(track.name)} - ${escapeXml(track.description)} - - ${coords} - - `; - }); + // Check for saved navigation state after tracks are loaded + const savedDestination = localStorage.getItem('navDestination'); + const savedNavMode = localStorage.getItem('navMode'); - const kml = ` - - - Exported Tracks${placemarks} - -`; + if (savedDestination && savedNavMode === 'true') { + // Show resume navigation dialog + ensurePopupInBody('resumeNavDialog'); + document.getElementById('resumeNavDialog').style.display = 'flex'; + } else { + // Start in navigate mode by default if no saved state + if (savedNavMode !== 'false') { + switchTab('navigate'); + } + } - const blob = new Blob([kml], { type: 'application/vnd.google-earth.kml+xml' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'tracks-export.kml'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + // Restore saved destination after tracks are loaded + restoreDestination(); + }) + .catch(err => { + console.log('No default.kml found, starting empty'); + }); - updateStatus('Exported KML file', 'success'); + // Auto-start GPS and zoom to location + if (navigator.geolocation) { + toggleGPS(); } - function reloadTracks() { - // Clear existing tracks - tracks.forEach(track => track.remove()); - tracks.length = 0; - selectedTracks = []; - - // Reload from server with cache busting - fetch('default.kml?t=' + Date.now()) - .then(response => { - if (!response.ok) throw new Error('default.kml not found'); - return response.text(); - }) - .then(kmlText => { - const count = parseKML(kmlText); - updateTrackList(); - updateStatus(`Reloaded ${count} track(s) from server`, 'success'); + // ============================================ + // Authentication System + // ============================================ - if (tracks.length > 0) { - const bounds = L.latLngBounds(tracks.flatMap(t => t.coords)); - map.fitBounds(bounds, { padding: [20, 20] }); - } - }) - .catch(err => { - updateStatus('Failed to reload tracks: ' + err.message, 'error'); - }); - } + // Auth state + let currentUser = null; + let accessToken = localStorage.getItem('accessToken'); + let refreshToken = localStorage.getItem('refreshToken'); - function saveToServer() { - // Generate KML content - let placemarks = ''; - tracks.forEach(track => { - const coords = track.coords.map(c => `${c[1]},${c[0]},0`).join(' '); - placemarks += ` - - ${escapeXml(track.name)} - ${escapeXml(track.description)} - - ${coords} - - `; - }); + // API helper with auth + async function authFetch(url, options = {}) { + if (!options.headers) options.headers = {}; - const kml = ` - - - Saved Tracks${placemarks} - -`; + if (accessToken) { + options.headers['Authorization'] = `Bearer ${accessToken}`; + } - // Send to server - updateStatus('Saving to server...', 'info'); + let response = await fetch(url, options); - fetch('/save-kml', { - method: 'POST', - headers: { - 'Content-Type': 'application/xml' - }, - body: kml - }) - .then(response => { - if (!response.ok) { - return response.text().then(text => { - throw new Error(text || 'Failed to save'); - }); + // If token expired, try to refresh + if (response.status === 401) { + const data = await response.json(); + if (data.code === 'TOKEN_EXPIRED' && refreshToken) { + const refreshed = await refreshAccessToken(); + if (refreshed) { + options.headers['Authorization'] = `Bearer ${accessToken}`; + response = await fetch(url, options); + } } - return response.json(); - }) - .then(result => { - updateStatus(result.message || 'Saved to server successfully', 'success'); - }) - .catch(err => { - updateStatus('Failed to save: ' + err.message, 'error'); - }); + } + + return response; } - function escapeXml(str) { - if (!str) return ''; - return str.replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); + async function refreshAccessToken() { + try { + const response = await fetch('/api/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }) + }); + + if (response.ok) { + const data = await response.json(); + accessToken = data.accessToken; + refreshToken = data.refreshToken; + localStorage.setItem('accessToken', accessToken); + localStorage.setItem('refreshToken', refreshToken); + return true; + } else { + // Refresh failed, logout + logout(); + return false; + } + } catch (err) { + console.error('Token refresh failed:', err); + return false; + } } - // Update UI - function updateStatus(message, type = '') { - const statusEl = document.getElementById('status'); - statusEl.textContent = message; - statusEl.className = 'status' + (type ? ' ' + type : ''); + function updateAuthUI() { + const profileSection = document.getElementById('userProfileSection'); + const loginPrompt = document.getElementById('loginPromptSection'); + const userName = document.getElementById('userName'); + const userPoints = document.getElementById('userPoints'); + const userFinds = document.getElementById('userFinds'); + const userAvatar = document.getElementById('userAvatar'); + const editTab = document.getElementById('editTab'); + const adminTab = document.getElementById('adminTab'); + + if (currentUser) { + profileSection.style.display = 'block'; + loginPrompt.style.display = 'none'; + userName.textContent = currentUser.username; + userPoints.textContent = currentUser.total_points || 0; + userFinds.textContent = currentUser.finds_count || 0; + userAvatar.innerHTML = ``; + + // Show Edit/Admin tabs only for admins + if (currentUser.is_admin) { + editTab.style.display = ''; + adminTab.style.display = ''; + } else { + editTab.style.display = 'none'; + adminTab.style.display = 'none'; + } + } else { + profileSection.style.display = 'none'; + loginPrompt.style.display = 'block'; + editTab.style.display = 'none'; + adminTab.style.display = 'none'; + } } - function updateTrackList() { - const listEl = document.getElementById('trackList'); - const countEl = document.getElementById('trackCount'); + async function login(username, password) { + const response = await fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }); - if (countEl) { - countEl.textContent = tracks.length; + if (response.ok) { + const data = await response.json(); + currentUser = data.user; + accessToken = data.accessToken; + refreshToken = data.refreshToken; + localStorage.setItem('accessToken', accessToken); + localStorage.setItem('refreshToken', refreshToken); + updateAuthUI(); + // Initialize RPG system for eligible users + await initializePlayerStats(currentUser.username); + return { success: true }; + } else { + const error = await response.json(); + return { success: false, error: error.error }; } + } - if (listEl) { - listEl.innerHTML = tracks.map((track, i) => ` -
- ${track.name} - -
- `).join(''); + async function register(username, email, password) { + const response = await fetch('/api/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, email, password }) + }); - // Add click handlers - listEl.querySelectorAll('.track-item').forEach(item => { - item.addEventListener('click', (e) => { - if (!e.target.classList.contains('delete-btn')) { - const idx = parseInt(item.dataset.index); - selectTrack(tracks[idx]); - map.fitBounds(tracks[idx].layer.getBounds(), { padding: [50, 50] }); - } - }); - }); + if (response.ok) { + const data = await response.json(); + currentUser = data.user; + accessToken = data.accessToken; + refreshToken = data.refreshToken; + localStorage.setItem('accessToken', accessToken); + localStorage.setItem('refreshToken', refreshToken); + updateAuthUI(); + // Initialize RPG system for eligible users + await initializePlayerStats(currentUser.username); + return { success: true }; + } else { + const error = await response.json(); + return { success: false, error: error.error }; + } + } - listEl.querySelectorAll('.delete-btn').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const idx = parseInt(btn.dataset.index); - deleteTrack(tracks[idx]); + async function logout() { + try { + if (accessToken) { + await authFetch('/api/logout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }) }); - }); + } + } catch (err) { + console.error('Logout error:', err); } - } - // Event listeners - const kmlFileEl = document.getElementById('kmlFile'); - if (kmlFileEl) { - kmlFileEl.addEventListener('change', function(e) { - const file = e.target.files[0]; - if (!file) return; - - updateStatus('Loading...', 'info'); - - const reader = new FileReader(); - reader.onload = function(e) { - try { - const count = parseKML(e.target.result); - updateTrackList(); - - if (tracks.length > 0) { - const bounds = L.latLngBounds(tracks.flatMap(t => t.coords)); - map.fitBounds(bounds, { padding: [20, 20] }); - } - - updateStatus(`Loaded ${count} track(s) from ${file.name}`, 'success'); - } catch (err) { - updateStatus(`Error: ${err.message}`, 'error'); - } - }; - reader.readAsText(file); + // Clean up RPG system + stopMonsterSpawning(); + monsterEntourage.forEach(m => { + if (m.marker) m.marker.remove(); }); - } + monsterEntourage = []; + playerStats = null; + document.getElementById('rpgHud').style.display = 'none'; - const el_exportBtn = document.getElementById('exportBtn'); - if (el_exportBtn) { - el_exportBtn.addEventListener('click', exportToKML); + currentUser = null; + accessToken = null; + refreshToken = null; + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + updateAuthUI(); } - const reloadBtn = document.getElementById('reloadBtn'); - if (reloadBtn) { - reloadBtn.addEventListener('click', reloadTracks); - } + async function loadCurrentUser() { + if (!accessToken) return; - const el_saveServerBtn = document.getElementById('saveServerBtn'); - if (el_saveServerBtn) { - el_saveServerBtn.addEventListener('click', saveToServer); + try { + const response = await authFetch('/api/user/me'); + if (response.ok) { + currentUser = await response.json(); + updateAuthUI(); + // Initialize RPG system for eligible users + await initializePlayerStats(currentUser.username); + } else { + // Token invalid + logout(); + } + } catch (err) { + console.error('Failed to load user:', err); + } } - const gpsBtn = document.getElementById('gpsBtn'); - if (gpsBtn) { - gpsBtn.addEventListener('click', toggleGPS); + // Auth modal handlers + function showAuthModal() { + document.getElementById('authModal').style.display = 'flex'; } - const el_rotateMapBtn = document.getElementById('rotateMapBtn'); - if (el_rotateMapBtn) { - el_rotateMapBtn.addEventListener('click', toggleRotateMap); + function hideAuthModal() { + document.getElementById('authModal').style.display = 'none'; + document.getElementById('authError').classList.remove('visible'); } - const autoCenterBtn = document.getElementById('autoCenterBtn'); - if (autoCenterBtn) { - autoCenterBtn.addEventListener('click', toggleAutoCenter); + function showAuthError(message) { + const errorEl = document.getElementById('authError'); + errorEl.textContent = message; + errorEl.classList.add('visible'); } - // Tab switching - const el_editTab = document.getElementById('editTab'); - if (el_editTab) { - el_editTab.addEventListener('click', () => switchTab('edit')); - } + // Auth tab switching + document.getElementById('loginTabBtn').addEventListener('click', () => { + document.getElementById('loginTabBtn').classList.add('active'); + document.getElementById('registerTabBtn').classList.remove('active'); + document.getElementById('loginForm').classList.add('active'); + document.getElementById('registerForm').classList.remove('active'); + document.getElementById('authError').classList.remove('visible'); + }); - const navTab = document.getElementById('navTab'); - if (navTab) { - navTab.addEventListener('click', () => switchTab('navigate')); - } - const el_adminTab = document.getElementById('adminTab'); - if (el_adminTab) { - el_adminTab.addEventListener('click', () => switchTab('admin')); - } + document.getElementById('registerTabBtn').addEventListener('click', () => { + document.getElementById('registerTabBtn').classList.add('active'); + document.getElementById('loginTabBtn').classList.remove('active'); + document.getElementById('registerForm').classList.add('active'); + document.getElementById('loginForm').classList.remove('active'); + document.getElementById('authError').classList.remove('visible'); + }); - // Edit overlay close button - setTimeout(() => { - const editCloseBtn = document.getElementById('editCloseBtn'); - if (editCloseBtn) { - editCloseBtn.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - const editOverlay = document.querySelector('.edit-panel-overlay'); - if (editOverlay) { - editOverlay.classList.remove('active'); - } - // Remove active class from edit tab - document.getElementById('editTab').classList.remove('active'); - // Switch to navigate tab - switchTab('navigate'); - }); + // Login form + document.getElementById('loginForm').addEventListener('submit', async (e) => { + e.preventDefault(); + const btn = document.getElementById('loginSubmitBtn'); + btn.disabled = true; + btn.textContent = 'Logging in...'; + + const username = document.getElementById('loginUsername').value; + const password = document.getElementById('loginPassword').value; + + const result = await login(username, password); + + btn.disabled = false; + btn.textContent = 'Login'; + + if (result.success) { + hideAuthModal(); + updateStatus(`Welcome back, ${currentUser.username}!`, 'success'); + } else { + showAuthError(result.error); } - }, 100); + }); - // Admin overlay close button - defer to ensure DOM element exists - setTimeout(() => { - const adminCloseBtn = document.getElementById('adminCloseBtn'); - if (adminCloseBtn) { - console.log('Admin close button found, attaching listener'); - adminCloseBtn.addEventListener('click', (e) => { - console.log('Admin close button clicked'); - e.preventDefault(); - e.stopPropagation(); - const adminOverlay = document.querySelector('.admin-panel-overlay'); - if (adminOverlay) { - adminOverlay.classList.remove('active'); - } - // Remove active class from admin tab - document.getElementById('adminTab').classList.remove('active'); - // Switch back to edit tab - switchTab('edit'); - }); + // Register form + document.getElementById('registerForm').addEventListener('submit', async (e) => { + e.preventDefault(); + const btn = document.getElementById('registerSubmitBtn'); + + const username = document.getElementById('registerUsername').value; + const email = document.getElementById('registerEmail').value; + const password = document.getElementById('registerPassword').value; + const confirmPassword = document.getElementById('registerPasswordConfirm').value; + + if (password !== confirmPassword) { + showAuthError('Passwords do not match'); + return; + } + + btn.disabled = true; + btn.textContent = 'Creating account...'; + + const result = await register(username, email, password); + + btn.disabled = false; + btn.textContent = 'Create Account'; + + if (result.success) { + hideAuthModal(); + updateStatus(`Welcome to HikeMap, ${currentUser.username}!`, 'success'); } else { - console.error('Admin close button not found!'); + showAuthError(result.error); } - }, 100); + }); + + // Open/close buttons + document.getElementById('openLoginBtn').addEventListener('click', showAuthModal); + document.getElementById('authCloseBtn').addEventListener('click', hideAuthModal); + document.getElementById('logoutBtn').addEventListener('click', logout); - // Password dialog - const el_passwordSubmit = document.getElementById('passwordSubmit'); - if (el_passwordSubmit) { - el_passwordSubmit.addEventListener('click', checkPassword); + // Leaderboard + async function loadLeaderboard(period = 'all') { + try { + const response = await fetch(`/api/leaderboard?period=${period}`); + if (response.ok) { + const data = await response.json(); + displayLeaderboard(data); + } + } catch (err) { + console.error('Failed to load leaderboard:', err); + } } - const passwordCancel = document.getElementById('passwordCancel'); - if (passwordCancel) { - passwordCancel.addEventListener('click', hidePasswordDialog); + function displayLeaderboard(entries) { + const list = document.getElementById('leaderboardList'); + list.innerHTML = entries.map((entry, index) => { + const rankClass = index === 0 ? 'gold' : index === 1 ? 'silver' : index === 2 ? 'bronze' : ''; + return ` +
  • + #${index + 1} +
    + +
    +
    +
    ${entry.username}
    +
    ${entry.finds_count} finds
    +
    +
    ${entry.total_points} pts
    +
  • + `; + }).join(''); } - const el_passwordInput = document.getElementById('passwordInput'); - if (el_passwordInput) { - el_passwordInput.addEventListener('keypress', (e) => { - if (e.key === 'Enter') checkPassword(); - }); + + function showLeaderboard() { + document.getElementById('leaderboardModal').style.display = 'flex'; + loadLeaderboard('all'); } - // Navigation - const clearNavBtn = document.getElementById('clearNavBtn'); - if (clearNavBtn) clearNavBtn.addEventListener('click', clearDestination); + function hideLeaderboard() { + document.getElementById('leaderboardModal').style.display = 'none'; + } - // Panel toggle - wrap to prevent error if element doesn't exist - const panelToggle = document.getElementById('panelToggle'); - if (panelToggle) { - panelToggle.addEventListener('click', function() { - console.log('Hamburger clicked!'); // Debug - const panel = document.getElementById('controlPanel'); - const toggleBtn = document.getElementById('panelToggle'); + document.getElementById('openLeaderboardBtn').addEventListener('click', showLeaderboard); + document.getElementById('leaderboardCloseBtn').addEventListener('click', hideLeaderboard); - if (panel && toggleBtn) { - if (panel.style.display === 'none') { - panel.style.display = 'block'; - toggleBtn.style.right = '300px'; - } else { - panel.style.display = 'none'; - toggleBtn.style.right = '10px'; - } - } + // Leaderboard tabs + document.querySelectorAll('.leaderboard-tab').forEach(tab => { + tab.addEventListener('click', () => { + document.querySelectorAll('.leaderboard-tab').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + loadLeaderboard(tab.dataset.period); }); - } else { - console.error('panelToggle element not found!'); - } + }); - const undoBtn = document.getElementById('undoBtn'); - if (undoBtn) undoBtn.addEventListener('click', undo); + // "Found It" button for geocaches + async function claimGeocacheFind(geocacheId) { + if (!currentUser) { + showAuthModal(); + return; + } - const mergeConnectBtn = document.getElementById('mergeConnectBtn'); - if (mergeConnectBtn) mergeConnectBtn.addEventListener('click', mergeConnect); + if (!gpsMarker) { + updateStatus('Please enable GPS to claim a find', 'error'); + return; + } - const selectAllBtn = document.getElementById('selectAllBtn'); - if (selectAllBtn) selectAllBtn.addEventListener('click', selectAll); + const userPos = gpsMarker.getLatLng(); - const clearSelectionBtn = document.getElementById('clearSelectionBtn'); - if (clearSelectionBtn) clearSelectionBtn.addEventListener('click', clearSelection); + try { + const response = await authFetch('/api/geocaches/' + geocacheId + '/find', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + lat: userPos.lat, + lng: userPos.lng, + accuracy: lastGpsAccuracy || 10 + }) + }); - // Remesh button and dialog - const remeshSlider = document.getElementById('remeshSpacing'); - const remeshValueDisplay = document.getElementById('remeshSpacingValue'); + if (response.ok) { + const result = await response.json(); - remeshSlider.addEventListener('input', () => { - remeshValueDisplay.textContent = remeshSlider.value; - }); + // Update local user data + currentUser.total_points = result.total_points; + currentUser.finds_count = result.finds_count; + updateAuthUI(); - const el_remeshBtn = document.getElementById('remeshBtn'); - if (el_remeshBtn) { - el_remeshBtn.addEventListener('click', () => { - if (selectedTracks.length === 0) { - updateStatus('Please select tracks to remesh first', 'error'); - return; + // Show points popup + showPointsPopup(result.points_earned, result.is_first_finder); + + // Update geocache dialog + updateGeocacheFoundButton(geocacheId, true); + + } else { + const error = await response.json(); + updateStatus(error.error, 'error'); } + } catch (err) { + console.error('Claim find error:', err); + updateStatus('Failed to claim find', 'error'); + } + } - // Reset slider to default - remeshSlider.value = 5; - remeshValueDisplay.textContent = '5'; + function showPointsPopup(points, isFirstFinder) { + const popup = document.createElement('div'); + popup.className = 'points-popup'; + popup.innerHTML = `+${points} pts` + (isFirstFinder ? 'First Finder!' : ''); + document.body.appendChild(popup); - // Show confirmation dialog - const trackNames = selectedTracks.map(t => t.name).join(', '); - const totalPoints = selectedTracks.reduce((sum, t) => sum + t.coords.length, 0); - document.getElementById('remeshDetails').innerHTML = - `Selected tracks: ${trackNames}
    ` + - `Current total points: ${totalPoints}`; - ensurePopupInBody('remeshDialog'); - document.getElementById('remeshDialog').style.display = 'flex'; - }); + setTimeout(() => popup.remove(), 2500); } - const el_remeshYes = document.getElementById('remeshYes'); - if (el_remeshYes) { - el_remeshYes.addEventListener('click', () => { - document.getElementById('remeshDialog').style.display = 'none'; + function updateGeocacheFoundButton(geocacheId, found) { + const foundBtn = document.getElementById('geocacheFoundBtn'); + if (foundBtn) { + if (found) { + foundBtn.className = 'found-it-btn already-found'; + foundBtn.textContent = 'Already Found!'; + foundBtn.disabled = true; + } + } + } - // Get the spacing value from slider - const spacing = parseInt(remeshSlider.value); + // Track last GPS accuracy for find validation + let lastGpsAccuracy = null; - // Remesh all selected tracks with the chosen spacing - selectedTracks.forEach(track => { - remeshTrack(track, spacing); - }); + // ========================================== + // RPG COMBAT SYSTEM FUNCTIONS + // ========================================== - // Clear selection after remeshing - clearSelection(); - }); - } + // Initialize player stats for RPG system + async function initializePlayerStats(username) { + // Only melancholytron gets Trail Runner class for now + if (username !== 'melancholytron') { + playerStats = null; + document.getElementById('rpgHud').style.display = 'none'; + return; + } - const el_remeshNo = document.getElementById('remeshNo'); - if (el_remeshNo) { - el_remeshNo.addEventListener('click', () => { - document.getElementById('remeshDialog').style.display = 'none'; - }); - } + const classData = PLAYER_CLASSES['trail_runner']; - // Preview system - const el_previewBtn = document.getElementById('previewBtn'); - if (el_previewBtn) { - el_previewBtn.addEventListener('click', startPreview); - const applyMergeBtn = document.getElementById('applyMergeBtn'); - if (applyMergeBtn) { - applyMergeBtn.addEventListener('click', applyMerge); + // Try to load from server first + const token = localStorage.getItem('accessToken'); + if (token) { + try { + const response = await fetch('/api/user/rpg-stats', { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (response.ok) { + const serverStats = await response.json(); + if (serverStats) { + playerStats = serverStats; + console.log('Loaded RPG stats from server:', playerStats); + } + } + } catch (e) { + console.error('Failed to load RPG stats from server:', e); + } } - } - const el_cancelPreviewBtn = document.getElementById('cancelPreviewBtn'); - if (el_cancelPreviewBtn) { - el_cancelPreviewBtn.addEventListener('click', cancelPreview); - } - // Live slider update during preview - const mergeThreshold = document.getElementById('mergeThreshold'); - if (mergeThreshold) { - mergeThreshold.addEventListener('input', (e) => { - document.getElementById('thresholdValue').textContent = e.target.value; - if (previewMode) { - updatePreview(parseInt(e.target.value)); + // Fall back to localStorage if server didn't have stats + if (!playerStats) { + const saved = localStorage.getItem('hikemap_rpg_stats'); + if (saved) { + try { + playerStats = JSON.parse(saved); + console.log('Loaded saved RPG stats from localStorage:', playerStats); + // Sync to server since we loaded from localStorage + savePlayerStats(); + } catch (e) { + console.error('Failed to parse saved RPG stats:', e); + playerStats = null; + } } - }); + } + + // Create new stats if none exist + if (!playerStats) { + playerStats = { + class: 'trail_runner', + level: 1, + xp: 0, + hp: classData.baseStats.hp, + maxHp: classData.baseStats.hp, + mp: classData.baseStats.mp, + maxMp: classData.baseStats.mp, + atk: classData.baseStats.atk, + def: classData.baseStats.def + }; + savePlayerStats(); + } + + // Show RPG HUD + document.getElementById('rpgHud').style.display = 'flex'; + updateRpgHud(); + + // Start monster spawning + startMonsterSpawning(); + + console.log('RPG system initialized for', username); } - // Anchor distance slider update - const el_anchorDistance = document.getElementById('anchorDistance'); - if (el_anchorDistance) { - el_anchorDistance.addEventListener('input', (e) => { - document.getElementById('anchorValue').textContent = e.target.value; - // If currently dragging, update the affected markers display - if (isDragging && originalCoords) { - showAffectedRange(); - // Re-apply rope physics with new anchor distance - const anchorDist = parseInt(e.target.value); - const draggedPoint = dragTrack.coords[dragPointIndex]; - const newCoords = applyRopePhysics(originalCoords, dragPointIndex, draggedPoint, anchorDist); - dragTrack.coords = newCoords; - dragTrack.layer.setLatLngs(newCoords); - updateAffectedMarkersPositions(newCoords); - } - }); + // Save player stats to server (and localStorage as backup) + function savePlayerStats() { + if (!playerStats) return; + + // Save to localStorage as backup + localStorage.setItem('hikemap_rpg_stats', JSON.stringify(playerStats)); + + // Save to server + const token = localStorage.getItem('accessToken'); + if (token) { + fetch('/api/user/rpg-stats', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify(playerStats) + }).catch(err => console.error('Failed to save RPG stats to server:', err)); + } } - // Falloff slider update - const el_reshapeFalloff = document.getElementById('reshapeFalloff'); - if (el_reshapeFalloff) { - el_reshapeFalloff.addEventListener('input', (e) => { - document.getElementById('falloffValue').textContent = parseFloat(e.target.value).toFixed(1); - // If currently dragging, re-apply with new falloff - if (isDragging && originalCoords) { - const anchorDist = parseInt(document.getElementById('anchorDistance').value); - const draggedPoint = dragTrack.coords[dragPointIndex]; - const newCoords = applyRopePhysics(originalCoords, dragPointIndex, draggedPoint, anchorDist); - dragTrack.coords = newCoords; - dragTrack.layer.setLatLngs(newCoords); - updateAffectedMarkersPositions(newCoords); - } - }); + // Update the RPG HUD display + function updateRpgHud() { + if (!playerStats) return; + + document.getElementById('hudLevel').textContent = playerStats.level; + document.getElementById('hudHp').textContent = `${playerStats.hp}/${playerStats.maxHp}`; + document.getElementById('hudMp').textContent = `${playerStats.mp}/${playerStats.maxMp}`; + document.getElementById('hudMonsterCount').textContent = monsterEntourage.length; + document.getElementById('hudMonsterMax').textContent = getMaxMonsters(); + + // Update XP bar + const xpNeeded = playerStats.level * 100; + const xpPercent = Math.min(100, (playerStats.xp / xpNeeded) * 100); + document.getElementById('hudXpBar').style.width = xpPercent + '%'; + document.getElementById('hudXpText').textContent = `${playerStats.xp}/${xpNeeded}`; } - // Smooth brush size slider update - const el_smoothBrushSize = document.getElementById('smoothBrushSize'); - if (el_smoothBrushSize) { - el_smoothBrushSize.addEventListener('input', (e) => { - document.getElementById('brushSizeValue').textContent = e.target.value; - // Update brush circle if currently smoothing - if (isSmoothing && smoothBrushCircle) { - const brushSize = parseInt(e.target.value); - smoothBrushCircle.setRadius(brushSize * getMetersPerPixel()); + // Start monster spawning timer + function startMonsterSpawning() { + if (monsterSpawnTimer) clearInterval(monsterSpawnTimer); + if (monsterUpdateTimer) clearInterval(monsterUpdateTimer); + + // Spawn check every 20 seconds + monsterSpawnTimer = setInterval(() => { + if (Math.random() < 0.5) { // 50% chance each interval + spawnMonsterNearPlayer(); } - }); + }, 20000); + + // Update monster positions and dialogue every 2 seconds + monsterUpdateTimer = setInterval(() => { + updateMonsterPositions(); + }, 2000); + + // Initial spawn after 5 seconds + setTimeout(() => { + spawnMonsterNearPlayer(); + }, 5000); } - // Smooth strength slider update - const el_smoothStrength = document.getElementById('smoothStrength'); - if (el_smoothStrength) { - el_smoothStrength.addEventListener('input', (e) => { - document.getElementById('strengthValue').textContent = parseFloat(e.target.value).toFixed(1); - }); + // Stop monster spawning + function stopMonsterSpawning() { + if (monsterSpawnTimer) { + clearInterval(monsterSpawnTimer); + monsterSpawnTimer = null; + } + if (monsterUpdateTimer) { + clearInterval(monsterUpdateTimer); + monsterUpdateTimer = null; + } } - // Register Service Worker for PWA functionality - if ('serviceWorker' in navigator) { - window.addEventListener('load', () => { - navigator.serviceWorker.register('/service-worker.js') - .then(registration => { - console.log('Service Worker registered:', registration.scope); + // Spawn a monster near the player + function spawnMonsterNearPlayer() { + if (!userLocation || !playerStats) return; + if (monsterEntourage.length >= getMaxMonsters()) return; - // Check for updates periodically - setInterval(() => { - registration.update(); - }, 60 * 60 * 1000); // Check every hour - }) - .catch(err => { - console.log('Service Worker registration failed:', err); - }); + // Random offset 30-60 meters from player + const angle = Math.random() * 2 * Math.PI; + const distance = 30 + Math.random() * 30; // 30-60 meters + + // Convert meters to degrees (rough approximation) + const metersPerDegLat = 111320; + const metersPerDegLng = 111320 * Math.cos(userLocation.lat * Math.PI / 180); + + const offsetLat = (distance * Math.cos(angle)) / metersPerDegLat; + const offsetLng = (distance * Math.sin(angle)) / metersPerDegLng; + + const monsterLevel = Math.max(1, playerStats.level + Math.floor(Math.random() * 3) - 1); + const monsterType = MONSTER_TYPES['discarded_gu']; + + const monster = { + id: `monster_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + type: 'discarded_gu', + level: monsterLevel, + position: { + lat: userLocation.lat + offsetLat, + lng: userLocation.lng + offsetLng + }, + spawnTime: Date.now(), + hp: monsterType.baseHp + (monsterLevel - 1) * monsterType.levelScale.hp, + maxHp: monsterType.baseHp + (monsterLevel - 1) * monsterType.levelScale.hp, + atk: monsterType.baseAtk + (monsterLevel - 1) * monsterType.levelScale.atk, + def: monsterType.baseDef + (monsterLevel - 1) * monsterType.levelScale.def, + marker: null, + lastDialogueTime: 0 + }; + + createMonsterMarker(monster); + monsterEntourage.push(monster); + updateRpgHud(); + + console.log('Spawned monster:', monster.id, 'at level', monsterLevel); + } + + // Create a Leaflet marker for a monster + function createMonsterMarker(monster) { + const monsterType = MONSTER_TYPES[monster.type]; + + const iconHtml = ` +
    +
    ${monsterType.icon}
    + +
    + `; + + const divIcon = L.divIcon({ + html: iconHtml, + className: 'monster-marker-container', + iconSize: [40, 40], + iconAnchor: [20, 20] }); - // Listen for app install prompt - let deferredPrompt; - window.addEventListener('beforeinstallprompt', (e) => { - // Prevent Chrome 67 and earlier from automatically showing the prompt - e.preventDefault(); - // Stash the event so it can be triggered later - deferredPrompt = e; - console.log('Install prompt ready'); + monster.marker = L.marker([monster.position.lat, monster.position.lng], { + icon: divIcon, + zIndexOffset: 500 + }).addTo(map); + + // Click to initiate combat + monster.marker.on('click', () => { + initiateCombat(monster); }); } - // Push Notification Functions - let pushSubscription = null; + // Update monster positions (follow player) and dialogue + function updateMonsterPositions() { + if (!userLocation || monsterEntourage.length === 0) return; - async function setupPushNotifications() { - try { - // Check if notifications are supported - if (!('Notification' in window)) { - alert('This browser does not support notifications'); - return; - } + monsterEntourage.forEach(monster => { + // Calculate distance to player + const dx = userLocation.lng - monster.position.lng; + const dy = userLocation.lat - monster.position.lat; - // Request notification permission - const permission = await Notification.requestPermission(); - if (permission !== 'granted') { - alert('Notification permission denied'); - return; + const metersPerDegLat = 111320; + const metersPerDegLng = 111320 * Math.cos(userLocation.lat * Math.PI / 180); + const distanceMeters = Math.sqrt( + Math.pow(dy * metersPerDegLat, 2) + + Math.pow(dx * metersPerDegLng, 2) + ); + + // Follow if more than 40 meters away + if (distanceMeters > 40) { + const followSpeed = 0.00003; // degrees per update + const angle = Math.atan2(dy, dx); + monster.position.lat += Math.sin(angle) * followSpeed; + monster.position.lng += Math.cos(angle) * followSpeed; + + monster.marker.setLatLng([monster.position.lat, monster.position.lng]); } - // Get service worker registration - const registration = await navigator.serviceWorker.ready; + // Update dialogue + updateMonsterDialogue(monster); + }); + } - // Get VAPID public key from server - const response = await fetch('/vapid-public-key'); - const { publicKey } = await response.json(); + // Update monster dialogue based on time + function updateMonsterDialogue(monster) { + const now = Date.now(); + const minutesSinceSpawn = (now - monster.spawnTime) / 60000; - if (!publicKey) { - alert('Push notifications not configured on server'); - return; + // Only show dialogue every 15-30 seconds + if (now - monster.lastDialogueTime < 15000 + Math.random() * 15000) return; + + // Determine phase + let phase = 'annoyed'; + for (const p of DIALOGUE_PHASES) { + if (minutesSinceSpawn < p.maxMinutes) { + phase = p.phase; + break; } + } - // Subscribe to push notifications - const subscription = await registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(publicKey) - }); + const dialogueSet = MONSTER_DIALOGUES[monster.type]; + if (!dialogueSet || !dialogueSet[phase]) return; - // Send subscription to server - const subResponse = await fetch('/subscribe', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(subscription) - }); + const messages = dialogueSet[phase]; + const message = messages[Math.floor(Math.random() * messages.length)]; - if (subResponse.ok) { - pushSubscription = subscription; - updateNotificationUI(true); - updateStatus('Push notifications enabled!', 'success'); + showMonsterDialogue(monster, message); + monster.lastDialogueTime = now; + } - // Test notification using service worker - if ('serviceWorker' in navigator && registration) { - registration.showNotification('HikeMap Notifications Active', { - body: 'You will receive alerts about new geocaches and trail updates', - icon: '/icon-192x192.png', - badge: '/icon-72x72.png', - vibrate: [200, 100, 200] - }); - } + // Show dialogue bubble on monster + function showMonsterDialogue(monster, message) { + const markerEl = document.querySelector(`[data-monster-id="${monster.id}"]`); + if (!markerEl) return; + + const bubble = markerEl.querySelector('.monster-dialogue-bubble'); + if (!bubble) return; + + bubble.textContent = message; + bubble.style.display = 'block'; + + // Hide after 4 seconds + setTimeout(() => { + bubble.style.display = 'none'; + }, 4000); + } + + // Remove a monster from the entourage + function removeMonster(monsterId) { + const idx = monsterEntourage.findIndex(m => m.id === monsterId); + if (idx !== -1) { + const monster = monsterEntourage[idx]; + if (monster.marker) { + monster.marker.remove(); + } + monsterEntourage.splice(idx, 1); + updateRpgHud(); + } + } + + // ========================================== + // COMBAT SYSTEM + // ========================================== + + // Initiate combat with a monster + function initiateCombat(clickedMonster) { + if (combatState) return; // Already in combat + if (!playerStats) return; + if (monsterEntourage.length === 0) return; + + // Gather ALL monsters from entourage for multi-monster combat + const monstersInCombat = monsterEntourage.map(m => ({ + id: m.id, + type: m.type, + level: m.level, + hp: m.hp, + maxHp: m.maxHp, + atk: m.atk, + def: m.def, + data: MONSTER_TYPES[m.type] + })); + + // Find the clicked monster's index to make it the initial target + const clickedIndex = monstersInCombat.findIndex(m => m.id === clickedMonster.id); + + combatState = { + player: { + hp: playerStats.hp, + maxHp: playerStats.maxHp, + mp: playerStats.mp, + maxMp: playerStats.maxMp, + atk: playerStats.atk, + def: playerStats.def + }, + monsters: monstersInCombat, + selectedTargetIndex: clickedIndex >= 0 ? clickedIndex : 0, + turn: 'player', + currentMonsterTurn: 0, + log: [] + }; + + showCombatUI(); + } + + // Show the combat UI + function showCombatUI() { + const overlay = document.getElementById('combatOverlay'); + overlay.style.display = 'flex'; + + // Render monster list + renderMonsterList(); + + // Clear and repopulate combat log + const log = document.getElementById('combatLog'); + const monsterCount = combatState.monsters.length; + log.innerHTML = `
    Combat begins! ${monsterCount} ${monsterCount === 1 ? 'enemy' : 'enemies'} engaged!
    `; + + // Populate skills + const skillsContainer = document.getElementById('combatSkills'); + skillsContainer.innerHTML = ''; + + const playerClass = PLAYER_CLASSES[playerStats.class]; + playerClass.skills.forEach(skillId => { + const skill = SKILLS[skillId]; + const levelReq = skill.levelReq || 1; + const isLocked = playerStats.level < levelReq; + + const btn = document.createElement('button'); + btn.className = 'skill-btn' + (isLocked ? ' skill-locked' : ''); + btn.dataset.skillId = skillId; + + if (isLocked) { + btn.innerHTML = ` + ๐Ÿ”’ ${skill.name} + Lv.${levelReq} + `; + btn.disabled = true; + } else { + btn.innerHTML = ` + ${skill.icon} ${skill.name} + ${skill.mpCost > 0 ? skill.mpCost + ' MP' : 'Free'} + `; + btn.onclick = () => executePlayerSkill(skillId); } - } catch (error) { - console.error('Failed to setup push notifications:', error); - alert('Failed to enable notifications: ' + error.message); - } + skillsContainer.appendChild(btn); + }); + + // Set up flee button + document.getElementById('combatFleeBtn').onclick = fleeCombat; + + updateCombatUI(); } - async function disablePushNotifications() { - try { - if (pushSubscription) { - // Unsubscribe from push - await pushSubscription.unsubscribe(); + // Render the monster list in combat UI + function renderMonsterList() { + const container = document.getElementById('monsterList'); + container.innerHTML = ''; - // Tell server to remove subscription - await fetch('/unsubscribe', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - endpoint: pushSubscription.endpoint - }) - }); + combatState.monsters.forEach((monster, index) => { + const entry = document.createElement('div'); + entry.className = 'monster-entry'; + entry.dataset.index = index; - pushSubscription = null; - updateNotificationUI(false); - updateStatus('Push notifications disabled', 'info'); + if (index === combatState.selectedTargetIndex) { + entry.classList.add('selected'); } - } catch (error) { - console.error('Failed to disable notifications:', error); - } + if (monster.hp <= 0) { + entry.classList.add('dead'); + } + + const hpPct = Math.max(0, (monster.hp / monster.maxHp) * 100); + + entry.innerHTML = ` +
    + ${index === combatState.selectedTargetIndex ? 'โ–ถ' : ''} + ${monster.data.icon} + ${monster.data.name} Lv.${monster.level} +
    +
    +
    +
    HP: ${Math.max(0, monster.hp)}/${monster.maxHp}
    +
    + `; + + // Click to select target (only if alive and player's turn) + entry.onclick = () => selectTarget(index); + container.appendChild(entry); + }); } - function updateNotificationUI(enabled) { - const statusText = document.getElementById('notificationStatusText'); - const enableBtn = document.getElementById('enableNotifications'); - const disableBtn = document.getElementById('disableNotifications'); - const testBtn = document.getElementById('testNotification'); + // Select a monster as target + function selectTarget(index) { + if (!combatState || combatState.turn !== 'player') return; + if (combatState.monsters[index].hp <= 0) return; // Can't target dead monsters - if (enabled) { - statusText.textContent = 'Enabled'; - statusText.style.color = '#4CAF50'; - enableBtn.style.display = 'none'; - disableBtn.style.display = 'block'; - testBtn.style.display = 'block'; - } else { - statusText.textContent = 'Disabled'; - statusText.style.color = '#666'; - enableBtn.style.display = 'block'; - disableBtn.style.display = 'none'; - testBtn.style.display = 'none'; - } + combatState.selectedTargetIndex = index; + renderMonsterList(); + addCombatLog(`Targeting ${combatState.monsters[index].data.name}!`); } - async function sendTestNotification() { - try { - const response = await fetch('/test-notification', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - message: 'Test notification from HikeMap admin' - }) - }); + // Update combat UI bars and text + function updateCombatUI() { + if (!combatState) return; - const result = await response.json(); - if (result.success) { - updateStatus(`Test notification sent to ${result.sent} users!`, 'success'); - } else { - updateStatus('Failed to send test notification', 'error'); + // Update turn indicator + const turnIndicator = document.getElementById('turnIndicator'); + if (combatState.turn === 'player') { + turnIndicator.className = 'turn-indicator player-turn'; + turnIndicator.textContent = 'โšก Your Turn'; + } else { + const attackingMonster = combatState.monsters[combatState.currentMonsterTurn]; + if (attackingMonster) { + turnIndicator.className = 'turn-indicator monster-turn'; + turnIndicator.textContent = `๐Ÿ”ฅ ${attackingMonster.data.name}'s Turn`; } - } catch (error) { - console.error('Error sending test notification:', error); - updateStatus('Error sending test notification', 'error'); } - } - // Helper function to convert VAPID key - function urlBase64ToUint8Array(base64String) { - const padding = '='.repeat((4 - base64String.length % 4) % 4); - const base64 = (base64String + padding) - .replace(/\-/g, '+') - .replace(/_/g, '/'); + // Update player HP/MP bars + const playerHpPct = (combatState.player.hp / combatState.player.maxHp) * 100; + const playerMpPct = (combatState.player.mp / combatState.player.maxMp) * 100; - const rawData = window.atob(base64); - const outputArray = new Uint8Array(rawData.length); + document.getElementById('playerHpBar').style.width = Math.max(0, playerHpPct) + '%'; + document.getElementById('playerMpBar').style.width = Math.max(0, playerMpPct) + '%'; - for (let i = 0; i < rawData.length; ++i) { - outputArray[i] = rawData.charCodeAt(i); + document.getElementById('playerHpText').textContent = + `${Math.max(0, combatState.player.hp)}/${combatState.player.maxHp}`; + document.getElementById('playerMpText').textContent = + `${Math.max(0, combatState.player.mp)}/${combatState.player.maxMp}`; + + // Re-render monster list to update HP bars + renderMonsterList(); + + // Update skill button states + document.querySelectorAll('.skill-btn').forEach(btn => { + const skillId = btn.dataset.skillId; + const skill = SKILLS[skillId]; + btn.disabled = combatState.player.mp < skill.mpCost || combatState.turn !== 'player'; + }); + + // Disable flee button during monster turns + const fleeBtn = document.getElementById('combatFleeBtn'); + if (fleeBtn) { + fleeBtn.disabled = combatState.turn !== 'player'; } - return outputArray; } - // Check existing push subscription on load - async function checkPushSubscription() { - if ('serviceWorker' in navigator && 'PushManager' in window) { - try { - const registration = await navigator.serviceWorker.ready; - const subscription = await registration.pushManager.getSubscription(); - if (subscription) { - pushSubscription = subscription; - updateNotificationUI(true); - } - } catch (error) { - console.error('Error checking push subscription:', error); + // Add entry to combat log + function addCombatLog(message, type = '') { + const log = document.getElementById('combatLog'); + const entry = document.createElement('div'); + entry.className = 'combat-log-entry'; + if (type) entry.classList.add(`combat-log-${type}`); + entry.textContent = message; + log.appendChild(entry); + log.scrollTop = log.scrollHeight; + } + + // Execute a player skill + function executePlayerSkill(skillId) { + if (!combatState || combatState.turn !== 'player') return; + + const skill = SKILLS[skillId]; + const levelReq = skill.levelReq || 1; + if (playerStats.level < levelReq) { + addCombatLog(`You need to be level ${levelReq} to use ${skill.name}!`); + return; + } + if (combatState.player.mp < skill.mpCost) { + addCombatLog("Not enough MP!"); + return; + } + + // Deduct MP + combatState.player.mp -= skill.mpCost; + + // Get the targeted monster + const target = combatState.monsters[combatState.selectedTargetIndex]; + + if (skill.type === 'damage') { + const rawDamage = skill.calculate(combatState.player.atk); + const damage = Math.max(1, rawDamage - target.def); + target.hp -= damage; + addCombatLog(`You used ${skill.name} on ${target.data.name}! Dealt ${damage} damage!`, 'damage'); + + // Check if this monster died + if (target.hp <= 0) { + addCombatLog(`${target.data.name} was defeated!`, 'victory'); + // Auto-retarget to next living monster if available + autoRetarget(); + } + } else if (skill.type === 'heal') { + const healAmount = skill.calculate(combatState.player.maxHp); + combatState.player.hp = Math.min(combatState.player.maxHp, combatState.player.hp + healAmount); + addCombatLog(`You used ${skill.name}! Healed ${healAmount} HP!`, 'heal'); + } + + updateCombatUI(); + + // Check if ALL monsters are defeated + const livingMonsters = combatState.monsters.filter(m => m.hp > 0); + if (livingMonsters.length === 0) { + handleCombatVictory(); + return; + } + + // Start monster turns sequence + combatState.turn = 'monsters'; + combatState.currentMonsterTurn = 0; + updateCombatUI(); + setTimeout(executeMonsterTurns, 800); + } + + // Auto-retarget to next living monster + function autoRetarget() { + const livingIndices = []; + combatState.monsters.forEach((m, i) => { + if (m.hp > 0) livingIndices.push(i); + }); + + if (livingIndices.length > 0) { + // Find next living monster (prefer one after current target) + let newTarget = livingIndices.find(i => i > combatState.selectedTargetIndex); + if (newTarget === undefined) { + newTarget = livingIndices[0]; // Wrap to first living monster } + combatState.selectedTargetIndex = newTarget; } } - // Initialize - setTool('select'); - updateTrackList(); - updateUndoButton(); + // Execute all monster turns sequentially + function executeMonsterTurns() { + if (!combatState) return; - // Start in navigation mode - navMode = true; + // Find next living monster starting from currentMonsterTurn + while (combatState.currentMonsterTurn < combatState.monsters.length) { + const monster = combatState.monsters[combatState.currentMonsterTurn]; + if (monster.hp > 0) { + // This monster is alive, execute its attack + executeOneMonsterAttack(combatState.currentMonsterTurn); + return; // Will continue in executeOneMonsterAttack + } + combatState.currentMonsterTurn++; + } - // Load admin settings - loadAdminSettings(); + // All monsters have attacked, return to player turn + combatState.turn = 'player'; + updateCombatUI(); + } - // Setup admin panel event handlers - setupAdminInputListeners(); + // Execute one monster's attack + function executeOneMonsterAttack(monsterIndex) { + if (!combatState) return; - // Load user's icon choice or show selector - loadUserIcon(); + const monster = combatState.monsters[monsterIndex]; + combatState.currentMonsterTurn = monsterIndex; + updateCombatUI(); - // Connect to WebSocket for multi-user tracking - connectWebSocket(); + const damage = Math.max(1, monster.atk - combatState.player.def); + combatState.player.hp -= damage; - // Check if push notifications are already enabled - checkPushSubscription(); + addCombatLog(`${monster.data.name} attacks! You take ${damage} damage!`, 'damage'); + updateCombatUI(); - // Setup resume navigation dialog handlers - const el_resumeNavYes = document.getElementById('resumeNavYes'); - if (el_resumeNavYes) { - el_resumeNavYes.addEventListener('click', () => { - document.getElementById('resumeNavDialog').style.display = 'none'; - // Restore saved navigation - const savedNav = localStorage.getItem('navMode'); - if (savedNav === 'true') { - switchTab('navigate'); - restoreDestination(); - } - }); + // Check for defeat + if (combatState.player.hp <= 0) { + handleCombatDefeat(); + return; + } + + // Move to next monster + combatState.currentMonsterTurn++; + setTimeout(executeMonsterTurns, 800); } - const el_resumeNavNo = document.getElementById('resumeNavNo'); - if (el_resumeNavNo) { - el_resumeNavNo.addEventListener('click', () => { - document.getElementById('resumeNavDialog').style.display = 'none'; - localStorage.removeItem('navDestination'); - localStorage.removeItem('navMode'); + // Handle combat victory + function handleCombatVictory() { + // Calculate total XP from all defeated monsters + let totalXp = 0; + const monsterIds = []; + combatState.monsters.forEach(monster => { + totalXp += monster.data.xpReward * monster.level; + monsterIds.push(monster.id); }); - } - // Auto-load default.kml with cache busting - fetch('default.kml?t=' + Date.now()) - .then(response => { - if (!response.ok) throw new Error('default.kml not found'); - return response.text(); - }) - .then(kmlText => { - const count = parseKML(kmlText); - updateTrackList(); - updateStatus(`Loaded ${count} track(s) from default.kml`, 'success'); + const monsterCount = combatState.monsters.length; + addCombatLog(`Victory! Defeated ${monsterCount} ${monsterCount === 1 ? 'enemy' : 'enemies'}! Gained ${totalXp} XP!`, 'victory'); - // Check for saved navigation state after tracks are loaded - const savedDestination = localStorage.getItem('navDestination'); - const savedNavMode = localStorage.getItem('navMode'); + // Remove all monsters from entourage + monsterIds.forEach(id => removeMonster(id)); - if (savedDestination && savedNavMode === 'true') { - // Show resume navigation dialog - ensurePopupInBody('resumeNavDialog'); - document.getElementById('resumeNavDialog').style.display = 'flex'; - } else { - // Start in navigate mode by default if no saved state - if (savedNavMode !== 'false') { - switchTab('navigate'); - } - } + // Update player stats + playerStats.hp = combatState.player.hp; + playerStats.mp = combatState.player.mp; + playerStats.xp += totalXp; - // Restore saved destination after tracks are loaded - restoreDestination(); - }) - .catch(err => { - console.log('No default.kml found, starting empty'); - }); + // Check for level up + checkLevelUp(); - // Auto-start GPS and zoom to location - if (navigator.geolocation) { - toggleGPS(); + savePlayerStats(); + updateRpgHud(); + + setTimeout(closeCombatUI, 2500); } - + // Handle combat defeat + function handleCombatDefeat() { + const monsterCount = combatState.monsters.filter(m => m.hp > 0).length; + addCombatLog(`You were defeated! ${monsterCount} ${monsterCount === 1 ? 'enemy remains' : 'enemies remain'}. HP restored to 50%.`, 'damage'); - -
    -
    -

    โœ๏ธ Edit Tools

    - -
    -
    -
    -
    File
    - - - - -
    + // Restore HP to 50% + playerStats.hp = Math.floor(playerStats.maxHp * 0.5); + playerStats.mp = combatState.player.mp; -
    -
    Track Tools
    -
    - - - - - - - -
    - - -
    + savePlayerStats(); + updateRpgHud(); -
    -
    Merge / Simplify
    -
    - - -
    5 meters
    -
    - - - -
    - -
    -
    - - -
    -
    + setTimeout(closeCombatUI, 2500); + } -
    -
    Tracks (0)
    -
    - -
    -
    -
    + // Flee from combat + function fleeCombat() { + addCombatLog("You fled from battle!"); - -
    -
    -

    โš™๏ธ Admin Settings

    - -
    -
    -
    -
    Geocache Settings
    -
    -
    -
    - -
    - - meters -
    -
    -
    - -
    - - meters -
    -
    -
    - - -
    -
    -
    + // Save current HP/MP + playerStats.hp = combatState.player.hp; + playerStats.mp = combatState.player.mp; + savePlayerStats(); + updateRpgHud(); -
    -
    Navigation Settings
    -
    -
    - - - meters -
    -
    - - - meters -
    -
    - - - meters -
    -
    - - - meters -
    -
    -
    + setTimeout(closeCombatUI, 1000); + } -
    -
    Performance Settings
    -
    -
    - - - ms -
    -
    - - - ms -
    -
    -
    + // Close combat UI + function closeCombatUI() { + document.getElementById('combatOverlay').style.display = 'none'; + combatState = null; + } -
    -
    Push Notifications
    -
    -
    - Status: Not configured -
    - - - -
    -
    + // Check for level up + function checkLevelUp() { + const xpNeeded = playerStats.level * 100; + if (playerStats.xp >= xpNeeded) { + playerStats.level++; + playerStats.xp -= xpNeeded; -
    -
    - - - -
    -
    -
    -
    -
    + const classData = PLAYER_CLASSES[playerStats.class]; + playerStats.maxHp += classData.hpPerLevel; + playerStats.hp = playerStats.maxHp; // Full heal on level up + playerStats.maxMp += classData.mpPerLevel; + playerStats.mp = playerStats.maxMp; // Full MP restore + playerStats.atk += classData.atkPerLevel; + playerStats.def += classData.defPerLevel; + + addCombatLog(`LEVEL UP! Now level ${playerStats.level}!`, 'victory'); + + // Check for another level up (in case of huge XP gain) + checkLevelUp(); + } + } + + // ========================================== + // END RPG COMBAT SYSTEM FUNCTIONS + // ========================================== + + // Initialize auth on load + loadCurrentUser(); + + diff --git a/package.json b/package.json index b83ea91..f0ddfe2 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,11 @@ "dependencies": { "@bubblewrap/cli": "^1.24.1", "@pwabuilder/cli": "^0.0.17", + "bcrypt": "^5.1.1", + "better-sqlite3": "^9.4.0", "dotenv": "^16.3.1", "express": "^4.18.2", + "jsonwebtoken": "^9.0.2", "web-push": "^3.6.6", "ws": "^8.14.2" }, diff --git a/server.js b/server.js index d4d211d..114abb7 100644 --- a/server.js +++ b/server.js @@ -3,9 +3,35 @@ const http = require('http'); const express = require('express'); const path = require('path'); const fs = require('fs').promises; +const crypto = require('crypto'); const webpush = require('web-push'); +const jwt = require('jsonwebtoken'); +const HikeMapDB = require('./database'); require('dotenv').config(); +// JWT configuration +const JWT_SECRET = process.env.JWT_SECRET || crypto.randomBytes(64).toString('hex'); +const JWT_ACCESS_EXPIRY = process.env.JWT_ACCESS_EXPIRY || '15m'; +const JWT_REFRESH_EXPIRY = process.env.JWT_REFRESH_EXPIRY || '7d'; + +// Parse expiry string to milliseconds +function parseExpiry(expiry) { + const match = expiry.match(/^(\d+)([smhd])$/); + if (!match) return 15 * 60 * 1000; // default 15 minutes + const value = parseInt(match[1]); + const unit = match[2]; + switch (unit) { + case 's': return value * 1000; + case 'm': return value * 60 * 1000; + case 'h': return value * 60 * 60 * 1000; + case 'd': return value * 24 * 60 * 60 * 1000; + default: return 15 * 60 * 1000; + } +} + +// Database instance +let db = null; + // Configure web-push with VAPID keys if (process.env.VAPID_PUBLIC_KEY && process.env.VAPID_PRIVATE_KEY) { webpush.setVapidDetails( @@ -302,6 +328,447 @@ app.post('/send-notification', async (req, res) => { } }); +// ============================================ +// Authentication Middleware +// ============================================ + +function generateTokens(user) { + const accessToken = jwt.sign( + { userId: user.id, username: user.username }, + JWT_SECRET, + { expiresIn: JWT_ACCESS_EXPIRY } + ); + + const refreshToken = jwt.sign( + { userId: user.id, type: 'refresh' }, + JWT_SECRET, + { expiresIn: JWT_REFRESH_EXPIRY } + ); + + return { accessToken, refreshToken }; +} + +function authenticateToken(req, res, next) { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; + + if (!token) { + return res.status(401).json({ error: 'Authentication required' }); + } + + jwt.verify(token, JWT_SECRET, (err, decoded) => { + if (err) { + if (err.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' }); + } + return res.status(403).json({ error: 'Invalid token' }); + } + req.user = decoded; + next(); + }); +} + +function optionalAuth(req, res, next) { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; + + if (token) { + jwt.verify(token, JWT_SECRET, (err, decoded) => { + if (!err) { + req.user = decoded; + } + }); + } + next(); +} + +// ============================================ +// Authentication Endpoints +// ============================================ + +// Register new user +app.post('/api/register', async (req, res) => { + try { + const { username, email, password } = req.body; + + // Validate input + if (!username || !email || !password) { + return res.status(400).json({ error: 'Username, email, and password are required' }); + } + + if (username.length < 3 || username.length > 20) { + return res.status(400).json({ error: 'Username must be 3-20 characters' }); + } + + if (!/^[a-zA-Z0-9_]+$/.test(username)) { + return res.status(400).json({ error: 'Username can only contain letters, numbers, and underscores' }); + } + + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return res.status(400).json({ error: 'Invalid email format' }); + } + + if (password.length < 8) { + return res.status(400).json({ error: 'Password must be at least 8 characters' }); + } + + const user = await db.createUser(username, email, password); + const tokens = generateTokens(user); + + // Store refresh token hash + const refreshTokenHash = crypto.createHash('sha256').update(tokens.refreshToken).digest('hex'); + const expiresAt = new Date(Date.now() + parseExpiry(JWT_REFRESH_EXPIRY)).toISOString(); + await db.storeRefreshToken(user.id, refreshTokenHash, expiresAt); + + console.log(`New user registered: ${username}`); + + res.status(201).json({ + user: { + id: user.id, + username: user.username, + email: user.email, + total_points: 0, + finds_count: 0, + avatar_icon: 'account', + avatar_color: '#4CAF50' + }, + ...tokens + }); + } catch (err) { + console.error('Registration error:', err); + if (err.message.includes('already exists')) { + return res.status(409).json({ error: err.message }); + } + res.status(500).json({ error: 'Registration failed' }); + } +}); + +// Login +app.post('/api/login', async (req, res) => { + try { + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ error: 'Username and password are required' }); + } + + const user = await db.validateUser(username, password); + + if (!user) { + return res.status(401).json({ error: 'Invalid username or password' }); + } + + const tokens = generateTokens(user); + + // Store refresh token hash + const refreshTokenHash = crypto.createHash('sha256').update(tokens.refreshToken).digest('hex'); + const expiresAt = new Date(Date.now() + parseExpiry(JWT_REFRESH_EXPIRY)).toISOString(); + await db.storeRefreshToken(user.id, refreshTokenHash, expiresAt); + + console.log(`User logged in: ${user.username}`); + + res.json({ + user: { + id: user.id, + username: user.username, + email: user.email, + total_points: user.total_points, + finds_count: user.finds_count, + avatar_icon: user.avatar_icon, + avatar_color: user.avatar_color, + is_admin: user.is_admin + }, + ...tokens + }); + } catch (err) { + console.error('Login error:', err); + res.status(500).json({ error: 'Login failed' }); + } +}); + +// Refresh access token +app.post('/api/refresh', async (req, res) => { + try { + const { refreshToken } = req.body; + + if (!refreshToken) { + return res.status(400).json({ error: 'Refresh token required' }); + } + + // Verify refresh token + let decoded; + try { + decoded = jwt.verify(refreshToken, JWT_SECRET); + } catch (err) { + return res.status(401).json({ error: 'Invalid refresh token' }); + } + + if (decoded.type !== 'refresh') { + return res.status(401).json({ error: 'Invalid token type' }); + } + + // Check if token exists in database + const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex'); + const storedToken = db.getRefreshToken(tokenHash); + + if (!storedToken) { + return res.status(401).json({ error: 'Refresh token not found or expired' }); + } + + // Get user + const user = db.getUserById(decoded.userId); + if (!user) { + return res.status(401).json({ error: 'User not found' }); + } + + // Generate new tokens + const tokens = generateTokens(user); + + // Delete old refresh token and store new one + db.deleteRefreshToken(tokenHash); + const newTokenHash = crypto.createHash('sha256').update(tokens.refreshToken).digest('hex'); + const expiresAt = new Date(Date.now() + parseExpiry(JWT_REFRESH_EXPIRY)).toISOString(); + await db.storeRefreshToken(user.id, newTokenHash, expiresAt); + + res.json(tokens); + } catch (err) { + console.error('Token refresh error:', err); + res.status(500).json({ error: 'Token refresh failed' }); + } +}); + +// Logout +app.post('/api/logout', authenticateToken, async (req, res) => { + try { + const { refreshToken } = req.body; + + if (refreshToken) { + const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex'); + db.deleteRefreshToken(tokenHash); + } + + res.json({ success: true }); + } catch (err) { + console.error('Logout error:', err); + res.status(500).json({ error: 'Logout failed' }); + } +}); + +// Get current user +app.get('/api/user/me', authenticateToken, (req, res) => { + try { + const user = db.getUserById(req.user.userId); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + res.json(user); + } catch (err) { + console.error('Get user error:', err); + res.status(500).json({ error: 'Failed to get user' }); + } +}); + +// Update user avatar +app.put('/api/user/avatar', authenticateToken, (req, res) => { + try { + const { icon, color } = req.body; + + if (!icon || !color) { + return res.status(400).json({ error: 'Icon and color are required' }); + } + + db.updateUserAvatar(req.user.userId, icon, color); + + res.json({ success: true }); + } catch (err) { + console.error('Update avatar error:', err); + res.status(500).json({ error: 'Failed to update avatar' }); + } +}); + +// ============================================ +// Game Mechanics Endpoints +// ============================================ + +// Points configuration +const POINTS = { + BASE_FIND: 100, + FIRST_FINDER_BONUS: 50, + CLOSE_FIND_BONUS: 25, // < 2m accuracy + MESSAGE_BONUS: 10 +}; + +// Calculate distance between two points (Haversine formula) +function calculateDistance(lat1, lng1, lat2, lng2) { + const R = 6371e3; // Earth radius in meters + const phi1 = lat1 * Math.PI / 180; + const phi2 = lat2 * Math.PI / 180; + const deltaPhi = (lat2 - lat1) * Math.PI / 180; + const deltaLambda = (lng2 - lng1) * Math.PI / 180; + + const a = Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) + + Math.cos(phi1) * Math.cos(phi2) * + Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return R * c; +} + +// Find (claim) a geocache +app.post('/api/geocaches/:id/find', authenticateToken, (req, res) => { + try { + const geocacheId = req.params.id; + const { lat, lng, accuracy } = req.body; + + // Find the geocache + const geocache = geocaches.find(g => g.id === geocacheId); + if (!geocache) { + return res.status(404).json({ error: 'Geocache not found' }); + } + + // Check if user already found it + if (db.hasUserFoundGeocache(req.user.userId, geocacheId)) { + return res.status(409).json({ error: 'You have already found this geocache' }); + } + + // Validate GPS accuracy (reject if too inaccurate) + if (accuracy && accuracy > 50) { + return res.status(400).json({ error: 'GPS accuracy too low. Please wait for better signal.' }); + } + + // Validate distance (must be within 25m) + const distance = calculateDistance(lat, lng, geocache.lat, geocache.lng); + if (distance > 25) { + return res.status(400).json({ + error: 'You are too far from the geocache', + distance: Math.round(distance) + }); + } + + // Calculate points + let points = POINTS.BASE_FIND; + const isFirstFinder = db.isFirstFinder(geocacheId); + + if (isFirstFinder) { + points += POINTS.FIRST_FINDER_BONUS; + } + + if (distance < 2) { + points += POINTS.CLOSE_FIND_BONUS; + } + + // Record the find + const result = db.recordFind(req.user.userId, geocacheId, points, isFirstFinder); + + // Get updated user + const user = db.getUserById(req.user.userId); + + console.log(`User ${req.user.username} found geocache ${geocacheId} for ${points} points`); + + res.json({ + success: true, + points_earned: points, + is_first_finder: isFirstFinder, + total_points: user.total_points, + finds_count: user.finds_count + }); + } catch (err) { + console.error('Find geocache error:', err); + if (err.message.includes('already found')) { + return res.status(409).json({ error: err.message }); + } + res.status(500).json({ error: 'Failed to record find' }); + } +}); + +// Get geocache finders +app.get('/api/geocaches/:id/finders', optionalAuth, (req, res) => { + try { + const geocacheId = req.params.id; + const finders = db.getGeocacheFinders(geocacheId); + + res.json(finders); + } catch (err) { + console.error('Get finders error:', err); + res.status(500).json({ error: 'Failed to get finders' }); + } +}); + +// Get leaderboard +app.get('/api/leaderboard', optionalAuth, (req, res) => { + try { + const period = req.query.period || 'all'; // 'all', 'weekly', 'monthly' + const limit = Math.min(parseInt(req.query.limit) || 50, 100); + + const leaderboard = db.getLeaderboard(period, limit); + + res.json(leaderboard); + } catch (err) { + console.error('Get leaderboard error:', err); + res.status(500).json({ error: 'Failed to get leaderboard' }); + } +}); + +// Get user's find history +app.get('/api/user/finds', authenticateToken, (req, res) => { + try { + const limit = Math.min(parseInt(req.query.limit) || 50, 100); + const finds = db.getUserFinds(req.user.userId, limit); + + res.json(finds); + } catch (err) { + console.error('Get finds error:', err); + res.status(500).json({ error: 'Failed to get finds' }); + } +}); + +// Get RPG stats for current user +app.get('/api/user/rpg-stats', authenticateToken, (req, res) => { + try { + const stats = db.getRpgStats(req.user.userId); + if (stats) { + // Convert snake_case from DB to camelCase for client + res.json({ + class: stats.class, + level: stats.level, + xp: stats.xp, + hp: stats.hp, + maxHp: stats.max_hp, + mp: stats.mp, + maxMp: stats.max_mp, + atk: stats.atk, + def: stats.def + }); + } else { + // No stats yet - return null so client creates defaults + res.json(null); + } + } catch (err) { + console.error('Get RPG stats error:', err); + res.status(500).json({ error: 'Failed to get RPG stats' }); + } +}); + +// Save RPG stats for current user +app.put('/api/user/rpg-stats', authenticateToken, (req, res) => { + try { + const stats = req.body; + + // Validate stats + if (!stats || typeof stats !== 'object') { + return res.status(400).json({ error: 'Invalid stats data' }); + } + + db.saveRpgStats(req.user.userId, stats); + res.json({ success: true }); + } catch (err) { + console.error('Save RPG stats error:', err); + res.status(500).json({ error: 'Failed to save RPG stats' }); + } +}); + // Function to send push notification to all subscribers async function sendPushNotification(title, body, data = {}) { const notification = { @@ -542,6 +1009,32 @@ server.listen(PORT, async () => { console.log(`Server running on port ${PORT}`); console.log(`Open http://localhost:${PORT} to view the map`); + // Initialize database + try { + let dbPath; + try { + await fs.access('/app/data'); + dbPath = '/app/data/hikemap.db'; + } catch (err) { + dbPath = path.join(__dirname, 'data', 'hikemap.db'); + // Create data directory if it doesn't exist + await fs.mkdir(path.dirname(dbPath), { recursive: true }); + } + db = new HikeMapDB(dbPath).init(); + console.log('Database initialized'); + + // Clean expired tokens periodically + setInterval(() => { + try { + db.cleanExpiredTokens(); + } catch (err) { + console.error('Error cleaning expired tokens:', err); + } + }, 60 * 60 * 1000); // Every hour + } catch (err) { + console.error('Failed to initialize database:', err); + } + // Load geocaches on startup await loadGeocaches();