From 33a235582f7dc41f8faa33993232dd4e788f1a04 Mon Sep 17 00:00:00 2001 From: Oliver Davies Date: Sat, 17 Jan 2026 11:56:15 +0000 Subject: [PATCH 1/4] feat: Add Quest tab and update Hideout tracker with tick-based UI - Add Quest tab with trader-grouped progression tracking - Click ticks to mark quests complete, hover shows requirements/rewards - Update Hideout tracker to match (ticks instead of sliders) - Hover on hideout ticks shows level requirements with item icons - Reorder tabs: Hideout | Quest | Filters | Location - Wider sidebar (265px) for better tick visibility --- index.html | 6 + src/main.ts | 391 ++++++++++++++++++++++++++++++++++++++++++-- src/styles/main.css | 218 +++++++++++++++++++++++- src/types/Quest.ts | 9 +- 4 files changed, 601 insertions(+), 23 deletions(-) diff --git a/index.html b/index.html index 6e441b8..42edf2a 100644 --- a/index.html +++ b/index.html @@ -81,6 +81,7 @@

@@ -120,6 +121,11 @@

+ + + diff --git a/src/main.ts b/src/main.ts index 9666bbb..4b2e76b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -125,6 +125,9 @@ class App { // Initialize zone filter this.initializeZoneFilter(); + // Initialize quest tracker + this.initializeQuestTracker(); + // Initialize mobile menu this.initializeMobileMenu(); } @@ -479,7 +482,9 @@ class App { const moduleName = module.name; const getLevelText = (level: number) => { - return level === 0 ? 'Not Unlocked' : `Level ${level} / ${module.maxLevel}`; + if (level === 0) return 'Not Unlocked'; + if (level === module.maxLevel) return `Level ${level} (Max)`; + return `Level ${level} / ${module.maxLevel}`; }; const card = document.createElement('div'); @@ -488,34 +493,136 @@ class App {

${moduleName}

${getLevelText(currentLevel)} - +
`; - const slider = card.querySelector('.workshop-card__slider') as HTMLInputElement; const levelText = card.querySelector('.workshop-card__level-text') as HTMLSpanElement; + const ticksContainer = card.querySelector('.workshop-card__ticks') as HTMLDivElement; - slider.addEventListener('input', (e) => { - const newLevel = parseInt((e.target as HTMLInputElement).value); + // Helper to update UI and save progress + const updateProgress = (newLevel: number) => { levelText.textContent = getLevelText(newLevel); - }); - slider.addEventListener('change', (e) => { - const newLevel = parseInt((e.target as HTMLInputElement).value); + // Update tick visual states + ticksContainer.querySelectorAll('.workshop-card__tick').forEach((tick, idx) => { + tick.classList.toggle('completed', idx < newLevel); + }); + + // Save progress this.updateWorkshopLevel(module.id, newLevel); - }); + }; + + // Create tick marks for each level (1 to maxLevel) + for (let level = 1; level <= module.maxLevel; level++) { + const tick = document.createElement('div'); + tick.className = 'workshop-card__tick'; + if (level <= currentLevel) { + tick.classList.add('completed'); + } + tick.dataset.moduleId = module.id; + tick.dataset.level = String(level); + + // Find level data for requirements + const levelData = module.levels.find(l => l.level === level); + + // Add hover for popover + tick.addEventListener('mouseenter', (e) => this.showHideoutPopover(module, level, levelData, e)); + tick.addEventListener('mouseleave', () => this.hideHideoutPopover()); + + // Click to set progress + tick.addEventListener('click', () => { + const currentlyCompleted = tick.classList.contains('completed'); + // If clicking on a completed level, set to previous level + // If clicking on incomplete level, complete up to and including this one + const newLevel = currentlyCompleted ? level - 1 : level; + updateProgress(newLevel); + }); + + ticksContainer.appendChild(tick); + } workshopGrid.appendChild(card); }); } + private showHideoutPopover(module: any, level: number, levelData: any, event: MouseEvent) { + // Remove existing popover if any + this.hideHideoutPopover(); + + const popover = document.createElement('div'); + popover.className = 'quest-popover'; // Reuse quest popover styling + popover.id = 'hideout-popover'; + + // Build requirements HTML + let requirementsHtml = 'None'; + if (levelData?.requirementItemIds && levelData.requirementItemIds.length > 0) { + requirementsHtml = levelData.requirementItemIds.map((req: any) => { + const itemId = req.item_id || req.itemId; + const quantity = req.quantity || '?'; + const item = this.gameData.items.find(i => i.id === itemId); + const itemName = item?.name || itemId || 'Unknown'; + const iconUrl = item ? dataLoader.getIconUrl(item) : ''; + + return ` +
+ ${iconUrl ? `` : ''} + ${quantity}x ${itemName} +
+ `; + }).join(''); + } + + // Check for other requirements (like coins for stash) + let otherReqsHtml = ''; + if (levelData?.otherRequirements && levelData.otherRequirements.length > 0) { + otherReqsHtml = levelData.otherRequirements.map((req: string) => ` +
+ ${req} +
+ `).join(''); + } + + const allRequirementsHtml = requirementsHtml + otherReqsHtml || 'None'; + + popover.innerHTML = ` +
${module.name} - Level ${level}
+ ${levelData?.description ? `
${levelData.description}
` : ''} +
+
Requirements
+
${allRequirementsHtml}
+
+ `; + + document.body.appendChild(popover); + + // Position the popover near the tick (above it) + const rect = (event.target as HTMLElement).getBoundingClientRect(); + const popoverRect = popover.getBoundingClientRect(); + + let left = rect.left + rect.width / 2 - popoverRect.width / 2; + let top = rect.top - popoverRect.height - 8; + + // Keep popover within viewport + if (left < 8) left = 8; + if (left + popoverRect.width > window.innerWidth - 8) { + left = window.innerWidth - popoverRect.width - 8; + } + if (top < 8) { + top = rect.bottom + 8; + } + + popover.style.left = `${left}px`; + popover.style.top = `${top}px`; + } + + private hideHideoutPopover() { + const existing = document.getElementById('hideout-popover'); + if (existing) { + existing.remove(); + } + } + private updateWorkshopLevel(moduleId: string, level: number) { this.userProgress.hideoutLevels[moduleId] = level; StorageManager.saveUserProgress(this.userProgress); @@ -546,6 +653,258 @@ class App { } } + private initializeQuestTracker() { + const questTracker = document.getElementById('quest-tracker'); + if (!questTracker) return; + + // Group quests by trader (quest giver) + const questsByTrader = new Map(); + for (const quest of this.gameData.quests) { + const trader = quest.trader || 'Unknown'; + if (!questsByTrader.has(trader)) { + questsByTrader.set(trader, []); + } + questsByTrader.get(trader)!.push(quest); + } + + // Sort quests within each trader by sortOrder + for (const [, quests] of questsByTrader) { + quests.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)); + } + + // Sort traders alphabetically, but put "Unknown" at the end + const sortedTraders = [...questsByTrader.keys()].sort((a, b) => { + if (a === 'Unknown') return 1; + if (b === 'Unknown') return -1; + return a.localeCompare(b); + }); + + // Create a card for each trader + for (const trader of sortedTraders) { + const quests = questsByTrader.get(trader)!; + if (quests.length === 0) continue; + + // Count completed quests for this trader + const completedCount = quests.filter(q => + this.userProgress.completedQuests.includes(q.id) + ).length; + + const card = document.createElement('div'); + card.className = 'quest-card'; + + const questCountText = completedCount === quests.length + ? `All ${quests.length} Complete` + : `${completedCount} / ${quests.length}`; + + card.innerHTML = ` +

${trader}

+
+ ${questCountText} +
+
+ `; + + const levelText = card.querySelector('.quest-card__level-text') as HTMLSpanElement; + const ticksContainer = card.querySelector('.quest-card__ticks') as HTMLDivElement; + + // Helper to update UI and save progress + const updateProgress = (newValue: number) => { + const countText = newValue === quests.length + ? `All ${quests.length} Complete` + : `${newValue} / ${quests.length}`; + levelText.textContent = countText; + + // Update tick visual states + ticksContainer.querySelectorAll('.quest-card__tick').forEach((tick, idx) => { + tick.classList.toggle('completed', idx < newValue); + }); + + // Save progress + this.updateQuestProgress(trader, quests, newValue); + }; + + // Create tick marks for each quest + quests.forEach((quest, index) => { + const tick = document.createElement('div'); + tick.className = 'quest-card__tick'; + if (index < completedCount) { + tick.classList.add('completed'); + } + tick.dataset.questId = quest.id; + tick.dataset.questIndex = String(index); + + // Add hover for popover + tick.addEventListener('mouseenter', (e) => this.showQuestPopover(quest, e)); + tick.addEventListener('mouseleave', () => this.hideQuestPopover()); + + // Click to set progress up to this quest + tick.addEventListener('click', () => { + const currentlyCompleted = tick.classList.contains('completed'); + // If clicking on a completed quest, uncomplete from here onwards + // If clicking on incomplete quest, complete up to and including this one + const newValue = currentlyCompleted ? index : index + 1; + updateProgress(newValue); + }); + + ticksContainer.appendChild(tick); + }); + + questTracker.appendChild(card); + } + + // Show empty state if no quests + if (sortedTraders.length === 0 || this.gameData.quests.length === 0) { + questTracker.innerHTML = ` +
+

No quest data available

+

Quest data will appear here once loaded

+
+ `; + } + } + + private showQuestPopover(quest: any, event: MouseEvent) { + // Remove existing popover if any + this.hideQuestPopover(); + + const popover = document.createElement('div'); + popover.className = 'quest-popover'; + popover.id = 'quest-popover'; + + // Build requirements HTML + let requirementsHtml = 'None'; + if (quest.requirements && quest.requirements.length > 0) { + requirementsHtml = quest.requirements.map((req: any) => { + const itemId = req.item_id || req.itemId; + const quantity = req.quantity || '?'; + const item = this.gameData.items.find(i => i.id === itemId); + const itemName = item?.name || itemId || 'Unknown'; + const iconUrl = item ? dataLoader.getIconUrl(item) : ''; + + return ` +
+ ${iconUrl ? `` : ''} + ${quantity}x ${itemName} +
+ `; + }).join(''); + } + + // Build rewards HTML + let rewardsHtml = 'None'; + const rewards: string[] = []; + + if (quest.xp) { + rewards.push(`
${quest.xp} XP
`); + } + + if (quest.rewards) { + if (quest.rewards.item_id || quest.rewards.itemId) { + const itemId = quest.rewards.item_id || quest.rewards.itemId; + const quantity = quest.rewards.quantity || 1; + const item = this.gameData.items.find(i => i.id === itemId); + const itemName = item?.name || itemId || 'Unknown'; + const iconUrl = item ? dataLoader.getIconUrl(item) : ''; + + rewards.push(` +
+ ${iconUrl ? `` : ''} + ${quantity}x ${itemName} +
+ `); + } + + // Handle array of rewards + if (Array.isArray(quest.rewards)) { + quest.rewards.forEach((reward: any) => { + const itemId = reward.item_id || reward.itemId; + const quantity = reward.quantity || 1; + const item = this.gameData.items.find(i => i.id === itemId); + const itemName = item?.name || itemId || 'Unknown'; + const iconUrl = item ? dataLoader.getIconUrl(item) : ''; + + rewards.push(` +
+ ${iconUrl ? `` : ''} + ${quantity}x ${itemName} +
+ `); + }); + } + } + + if (rewards.length > 0) { + rewardsHtml = rewards.join(''); + } + + popover.innerHTML = ` +
${quest.name}
+ ${quest.description ? `
${quest.description}
` : ''} +
+
Requirements
+
${requirementsHtml}
+
+
+
Rewards
+
${rewardsHtml}
+
+ `; + + document.body.appendChild(popover); + + // Position the popover near the mouse + const rect = (event.target as HTMLElement).getBoundingClientRect(); + const popoverRect = popover.getBoundingClientRect(); + + let left = rect.left + rect.width / 2 - popoverRect.width / 2; + let top = rect.top - popoverRect.height - 8; + + // Keep popover within viewport + if (left < 8) left = 8; + if (left + popoverRect.width > window.innerWidth - 8) { + left = window.innerWidth - popoverRect.width - 8; + } + if (top < 8) { + top = rect.bottom + 8; + } + + popover.style.left = `${left}px`; + popover.style.top = `${top}px`; + } + + private hideQuestPopover() { + const existing = document.getElementById('quest-popover'); + if (existing) { + existing.remove(); + } + } + + private updateQuestProgress(_trader: string, quests: any[], completedCount: number) { + // Get quest IDs to mark as complete (first N quests) + const questsToComplete = quests.slice(0, completedCount).map(q => q.id); + const questsToUncomplete = quests.slice(completedCount).map(q => q.id); + + // Update completed quests in user progress + const completedSet = new Set(this.userProgress.completedQuests); + + // Add completed quests + questsToComplete.forEach(id => completedSet.add(id)); + + // Remove uncompleted quests + questsToUncomplete.forEach(id => completedSet.delete(id)); + + this.userProgress.completedQuests = [...completedSet]; + StorageManager.saveUserProgress(this.userProgress); + + // Recalculate item decisions + this.allItems = this.decisionEngine.getItemsWithDecisions(this.userProgress); + this.searchEngine.updateIndex(this.allItems); + + // Re-apply filters and render + this.applyFilters(); + this.updateStats(); + } + private applyFilters() { let items = [...this.allItems]; diff --git a/src/styles/main.css b/src/styles/main.css index 820b202..5c78848 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -609,7 +609,7 @@ body { .sidebar { position: sticky; top: 80px; - width: 220px; + width: 265px; flex-shrink: 0; background: var(--color-bg-card); border-radius: var(--radius-md); @@ -802,10 +802,220 @@ body { transition: all var(--transition-fast); } -.workshop-card__slider::-moz-range-thumb:hover { - width: 14px; +/* Workshop card ticks (matching quest tracker style) */ +.workshop-card__ticks { + display: flex; + gap: 2px; + width: 100%; + margin-top: var(--spacing-sm); +} + +.workshop-card__tick { + flex: 1; height: 14px; - box-shadow: 0 0 12px rgba(0, 188, 212, 0.8); + min-width: 0; + border-radius: 0; + background: var(--color-bg-elevated); + border: 1px solid var(--color-text-muted); + cursor: pointer; + transition: all var(--transition-fast); +} + +/* Rounded left end on first tick */ +.workshop-card__tick:first-child { + border-radius: 6px 0 0 6px; +} + +/* Rounded right end on last tick */ +.workshop-card__tick:last-child { + border-radius: 0 6px 6px 0; +} + +/* Single tick gets both rounded ends */ +.workshop-card__tick:only-child { + border-radius: 6px; +} + +.workshop-card__tick:hover { + transform: scaleY(1.15); + border-color: var(--color-accent-cyan); + background: rgba(0, 188, 212, 0.2); +} + +.workshop-card__tick.completed { + background: var(--color-accent-cyan); + border-color: var(--color-accent-cyan); +} + +/* ======================================== + Quest Tracker + ======================================== */ + +.quest-tracker { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.quest-tracker__empty { + text-align: center; + padding: var(--spacing-xl); + color: var(--color-text-muted); +} + +.quest-tracker__empty-hint { + font-size: 0.7rem; + margin-top: var(--spacing-sm); + opacity: 0.7; +} + +.quest-card { + background: rgba(10, 14, 26, 0.6); + border-radius: var(--radius-sm); + padding: var(--spacing-md); + border: 1px solid var(--color-bg-elevated); + transition: all var(--transition-base); +} + +.quest-card:hover { + border-color: var(--color-accent-cyan); + background: rgba(10, 14, 26, 0.8); + transform: translateX(2px); +} + +.quest-card h3 { + font-size: 0.7rem; + font-weight: 600; + margin-bottom: var(--spacing-sm); + color: var(--color-text-primary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.quest-card__level { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.quest-card__level-text { + font-size: 0.65rem; + color: var(--color-text-secondary); + font-weight: 500; +} + +.quest-card__ticks { + display: flex; + gap: 2px; + width: 100%; + margin-top: var(--spacing-sm); +} + +.quest-card__tick { + flex: 1; + height: 14px; + min-width: 0; + border-radius: 0; + background: var(--color-bg-elevated); + border: 1px solid var(--color-text-muted); + cursor: pointer; + transition: all var(--transition-fast); +} + +/* Rounded left end on first tick */ +.quest-card__tick:first-child { + border-radius: 6px 0 0 6px; +} + +/* Rounded right end on last tick */ +.quest-card__tick:last-child { + border-radius: 0 6px 6px 0; +} + +/* Single tick gets both rounded ends */ +.quest-card__tick:only-child { + border-radius: 6px; +} + +.quest-card__tick:hover { + transform: scaleY(1.15); + border-color: var(--color-accent-cyan); + background: rgba(0, 188, 212, 0.2); +} + +.quest-card__tick.completed { + background: var(--color-accent-cyan); + border-color: var(--color-accent-cyan); +} + +/* Quest Popover */ +.quest-popover { + position: fixed; + z-index: 1000; + background: var(--color-bg-card); + border: 1px solid var(--color-accent-cyan); + border-radius: var(--radius-md); + padding: var(--spacing-md); + max-width: 280px; + min-width: 200px; + box-shadow: var(--shadow-lg), 0 0 20px rgba(0, 188, 212, 0.2); + animation: fadeIn 0.15s ease-out; +} + +.quest-popover__title { + font-weight: 600; + font-size: 0.85rem; + color: var(--color-text-primary); + margin-bottom: var(--spacing-xs); + border-bottom: 1px solid var(--color-bg-elevated); + padding-bottom: var(--spacing-xs); +} + +.quest-popover__desc { + font-size: 0.7rem; + color: var(--color-text-secondary); + margin-bottom: var(--spacing-sm); + line-height: 1.4; +} + +.quest-popover__section { + margin-top: var(--spacing-sm); +} + +.quest-popover__section-title { + font-size: 0.6rem; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: var(--spacing-xs); +} + +.quest-popover__items { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.quest-popover__item { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.75rem; + color: var(--color-text-primary); +} + +.quest-popover__item-icon { + width: 20px; + height: 20px; + object-fit: contain; + border-radius: 2px; + background: var(--color-bg-dark); +} + +.quest-popover__none { + font-size: 0.7rem; + color: var(--color-text-muted); + font-style: italic; } /* ======================================== diff --git a/src/types/Quest.ts b/src/types/Quest.ts index ee94c29..2b6c468 100644 --- a/src/types/Quest.ts +++ b/src/types/Quest.ts @@ -14,14 +14,17 @@ export interface QuestReward { export interface Quest { id: string; - name: string; // English only (previously multilingual) - description?: string; // English only (previously multilingual) + name: string; + description?: string; + objectives?: string[]; requirements?: QuestRequirement[]; rewards?: QuestReward[]; rewardItemIds?: Array<{item_id: string; quantity: number}>; - questGiver?: string; + trader?: string; + questGiver?: string; // deprecated, use trader unlocks?: string[]; xp?: number; + sortOrder?: number; updatedAt?: string; previousQuestIds?: string[]; nextQuestIds?: string[]; From a62fab48db2971cee0b7b9ef6d1e011b97ce2fbf Mon Sep 17 00:00:00 2001 From: Oliver Davies Date: Sat, 17 Jan 2026 11:56:28 +0000 Subject: [PATCH 2/4] fix: Add resilient map discovery with CDN probing and Supabase pagination - Paginate Supabase queries to get all records (was limited to 1000) - Probe CDN directly to discover maps regardless of Supabase data - Generate name variations (hyphen/underscore/none) for resilience - Cache discovered maps across runs for continuity - Check daily dates for tile patterns (last 30 days) - Add location vocabulary for bootstrapping new map discovery - Reduce API rate limiting delays for faster fetches - Fix process exit (Sharp thread pool was keeping Node alive) --- scripts/fetch-data.ts | 384 +++++++++++++++++++++++++++++++++++------- 1 file changed, 327 insertions(+), 57 deletions(-) diff --git a/scripts/fetch-data.ts b/scripts/fetch-data.ts index 294cc8c..f87097f 100644 --- a/scripts/fetch-data.ts +++ b/scripts/fetch-data.ts @@ -50,8 +50,10 @@ interface MetaForgeQuest { objectives?: string[]; required_items?: any[]; rewards?: any; - trader?: string; + trader_name?: string; xp?: number; + sort_order?: number; + position?: { x: number; y: number }; [key: string]: any; } @@ -305,7 +307,7 @@ async function fetchAllItems(): Promise { // Rate limiting: wait 500ms between requests to be respectful if (hasNextPage) { - await new Promise(resolve => setTimeout(resolve, 500)); + await new Promise(resolve => setTimeout(resolve, 100)); } } catch (error) { console.error(` ❌ Failed to fetch page ${currentPage}:`, error); @@ -337,7 +339,7 @@ async function fetchAllQuests(): Promise { console.log(` βœ… Page ${currentPage - 1}: ${response.data.length} quests (Total so far: ${allQuests.length})`); if (hasNextPage) { - await new Promise(resolve => setTimeout(resolve, 500)); + await new Promise(resolve => setTimeout(resolve, 100)); } } catch (error) { console.error(` ❌ Failed to fetch quests page ${currentPage}:`, error); @@ -400,11 +402,43 @@ async function fetchAllRecycleComponents(): Promise { console.log('πŸ“₯ Fetching map marker data from MetaForge Supabase...'); - // Discover available maps from the database - console.log(' πŸ” Auto-discovering available maps...'); - const allMarkers = await fetchSupabase('arc_map_data', 'select=map'); - const uniqueMaps = [...new Set(allMarkers.map(m => m.map))].sort(); - console.log(` βœ… Found ${uniqueMaps.length} maps: ${uniqueMaps.join(', ')}`); + // Discover available maps from the database with pagination + // Supabase has a max limit per request, so we need to paginate + console.log(' πŸ” Auto-discovering available maps (with pagination)...'); + + const allMapNames = new Set(); + let offset = 0; + const pageSize = 1000; + let hasMore = true; + + while (hasMore) { + const markers = await fetchSupabase( + 'arc_map_data', + `select=map&limit=${pageSize}&offset=${offset}` + ); + + console.log(` πŸ“Š Retrieved ${markers.length} records (offset ${offset})...`); + + for (const marker of markers) { + if (marker.map) { + allMapNames.add(marker.map); + } + } + + if (markers.length < pageSize) { + hasMore = false; + } else { + offset += pageSize; + } + + // Rate limiting between pages + if (hasMore) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + } + + const uniqueMaps = [...allMapNames].sort(); + console.log(` βœ… Found ${uniqueMaps.length} unique maps: ${uniqueMaps.join(', ')}`); const mapDataArray: MapData[] = []; @@ -412,35 +446,51 @@ async function fetchAllMapData(): Promise { try { console.log(` Fetching markers for ${mapName}...`); - const markers = await fetchSupabase( - 'arc_map_data', - `map=eq.${mapName}&select=*` - ); + // Paginate to get all markers for this map + const allMarkers: MapMarker[] = []; + let mapOffset = 0; + let mapHasMore = true; + + while (mapHasMore) { + const markers = await fetchSupabase( + 'arc_map_data', + `map=eq.${mapName}&select=*&limit=${pageSize}&offset=${mapOffset}` + ); + + allMarkers.push(...markers); + + if (markers.length < pageSize) { + mapHasMore = false; + } else { + mapOffset += pageSize; + await new Promise(resolve => setTimeout(resolve, 50)); + } + } // Calculate stats const byCategory: Record = {}; const bySubcategory: Record = {}; - for (const marker of markers) { + for (const marker of allMarkers) { byCategory[marker.category] = (byCategory[marker.category] || 0) + 1; bySubcategory[marker.subcategory] = (bySubcategory[marker.subcategory] || 0) + 1; } const mapData: MapData = { map: mapName, - markers, + markers: allMarkers, stats: { - totalMarkers: markers.length, + totalMarkers: allMarkers.length, byCategory, bySubcategory } }; mapDataArray.push(mapData); - console.log(` βœ… ${mapName}: ${markers.length} markers`); + console.log(` βœ… ${mapName}: ${allMarkers.length} markers`); // Rate limiting - await new Promise(resolve => setTimeout(resolve, 500)); + await new Promise(resolve => setTimeout(resolve, 100)); } catch (error) { console.error(` ❌ Failed to fetch map data for ${mapName}:`, error); } @@ -473,7 +523,7 @@ async function fetchMapImages(mapNames: string[]): Promise { downloadedCount++; // Rate limiting - await new Promise(resolve => setTimeout(resolve, 500)); + await new Promise(resolve => setTimeout(resolve, 100)); } catch (error) { console.error(` ❌ Failed to download ${mapName}.webp:`, error); } @@ -483,7 +533,7 @@ async function fetchMapImages(mapNames: string[]): Promise { return downloadedCount; } -async function testTileUrl(url: string): Promise { +async function testUrl(url: string): Promise { return new Promise((resolve) => { https.request(url, { method: 'HEAD' }, (res) => { resolve(res.statusCode === 200); @@ -491,33 +541,240 @@ async function testTileUrl(url: string): Promise { }); } +/** + * Generate all reasonable name variations for a map name + * e.g., "buried-city" -> ["buried-city", "buried_city", "buriedcity", "Buried-City", etc.] + */ +function generateNameVariations(name: string): string[] { + const variations = new Set(); + const normalized = name.toLowerCase().trim(); + + // Original and lowercase + variations.add(name); + variations.add(normalized); + + // Hyphen/underscore/space variations + const withHyphens = normalized.replace(/[_\s]/g, '-'); + const withUnderscores = normalized.replace(/[-\s]/g, '_'); + const withoutSeparators = normalized.replace(/[-_\s]/g, ''); + + variations.add(withHyphens); + variations.add(withUnderscores); + variations.add(withoutSeparators); + + // Also try kebab-case from underscore (stella_montis -> stella-montis) + if (normalized.includes('_')) { + variations.add(normalized.replace(/_/g, '-')); + } + if (normalized.includes('-')) { + variations.add(normalized.replace(/-/g, '_')); + } + + return [...variations]; +} + +/** + * Load previously discovered maps from cache file + * This provides continuity across runs - once a map is found, we remember it + */ +function loadDiscoveredMapsCache(): string[] { + const cachePath = path.join(DATA_DIR, '.map-discovery-cache.json'); + try { + if (fs.existsSync(cachePath)) { + const data = JSON.parse(fs.readFileSync(cachePath, 'utf-8')); + console.log(` πŸ“¦ Loaded ${data.maps?.length || 0} maps from discovery cache`); + return data.maps || []; + } + } catch (error) { + console.warn(' ⚠️ Could not load map discovery cache:', error); + } + return []; +} + +/** + * Save discovered maps to cache file for future runs + */ +function saveDiscoveredMapsCache(maps: string[]): void { + const cachePath = path.join(DATA_DIR, '.map-discovery-cache.json'); + try { + fs.writeFileSync(cachePath, JSON.stringify({ + maps, + lastUpdated: new Date().toISOString() + }, null, 2)); + console.log(` πŸ’Ύ Saved ${maps.length} maps to discovery cache`); + } catch (error) { + console.warn(' ⚠️ Could not save map discovery cache:', error); + } +} + +/** + * Discover available maps by probing the CDN for map images + * Uses multiple strategies: + * 1. Variations of Supabase map names + * 2. Previously discovered maps (from cache) + * 3. Common word patterns found in existing names + */ +async function discoverMapsFromCDN(supabaseMaps: string[]): Promise { + console.log(' πŸ” Probing CDN to discover available maps...'); + + const discoveredMaps = new Set(); + const cdnBaseUrl = 'https://cdn.metaforge.app/arc-raiders/ui'; + + // Load previously discovered maps from cache + const cachedMaps = loadDiscoveredMapsCache(); + + // Generate candidate map names from multiple sources + const candidates = new Set(); + + // 1. Add variations of all Supabase maps + for (const mapName of supabaseMaps) { + for (const variation of generateNameVariations(mapName)) { + candidates.add(variation); + } + } + + // 2. Add variations of all cached maps (in case Supabase lost some) + for (const mapName of cachedMaps) { + for (const variation of generateNameVariations(mapName)) { + candidates.add(variation); + } + } + + // 3. Extract word components from known names and try new combinations + const wordComponents = new Set(); + for (const mapName of [...supabaseMaps, ...cachedMaps]) { + const words = mapName.toLowerCase().split(/[-_\s]/); + words.forEach(w => { + if (w.length > 2) wordComponents.add(w); + }); + } + + // 4. Add common location/map vocabulary words for bootstrapping discovery + // This isn't hardcoding map names - it's providing common words that MIGHT appear in map names + // If these don't exist on CDN, they simply won't be found + const locationVocabulary = [ + // Common terrain/structure words + 'dam', 'port', 'station', 'base', 'camp', 'outpost', 'facility', 'bunker', + 'tower', 'bridge', 'tunnel', 'mine', 'factory', 'warehouse', 'depot', + // Common location descriptors + 'north', 'south', 'east', 'west', 'central', 'old', 'new', 'upper', 'lower', + // Space/sci-fi themed + 'space', 'stellar', 'stella', 'luna', 'solar', 'orbital', 'launch', 'landing', + // Nature/geography + 'mountain', 'montis', 'valley', 'canyon', 'desert', 'forest', 'lake', 'river', + 'coast', 'beach', 'cliff', 'hill', 'ridge', 'peak', 'crater', + // Urban + 'city', 'town', 'village', 'district', 'zone', 'sector', 'hub', 'plaza', + // Condition descriptors + 'buried', 'hidden', 'lost', 'ancient', 'ruined', 'abandoned', 'crashed', + // Colors (common in game map names) + 'red', 'blue', 'green', 'black', 'white', 'grey', 'gray', 'golden', 'silver', + // Arc Raiders specific terms that might appear + 'raider', 'arc', 'gate', 'haven', 'refuge', 'frontier' + ]; + + // Add vocabulary words to word components + locationVocabulary.forEach(w => wordComponents.add(w)); + + // Try single words as map names (e.g., "dam", "spaceport") + for (const word of wordComponents) { + candidates.add(word); + // Also try with common suffixes/prefixes + candidates.add(`${word}-new`); + candidates.add(`new-${word}`); + candidates.add(`the-${word}`); + } + + // Try compound words (no separator) + for (const w1 of ['space', 'star', 'moon', 'sun', 'sky', 'air', 'sea']) { + for (const w2 of ['port', 'base', 'station', 'dock', 'hub']) { + candidates.add(`${w1}${w2}`); + } + } + + // Try two-word combinations with common separators (limit to avoid explosion) + const priorityWords = ['buried', 'blue', 'stella', 'dam', 'space', 'port', 'city', 'gate', 'montis', 'old', 'new']; + for (const w1 of priorityWords) { + for (const w2 of priorityWords) { + if (w1 !== w2) { + candidates.add(`${w1}-${w2}`); + candidates.add(`${w1}_${w2}`); + } + } + } + + console.log(` Testing ${candidates.size} map name candidates on CDN...`); + + // Test each candidate against the CDN (in parallel batches for speed) + const candidatesArray = [...candidates]; + const batchSize = 5; + + for (let i = 0; i < candidatesArray.length; i += batchSize) { + const batch = candidatesArray.slice(i, i + batchSize); + const results = await Promise.all( + batch.map(async candidate => { + const imageUrl = `${cdnBaseUrl}/${candidate}.webp`; + const exists = await testUrl(imageUrl); + return { candidate, exists }; + }) + ); + + for (const { candidate, exists } of results) { + if (exists) { + discoveredMaps.add(candidate); + console.log(` βœ… Found map on CDN: ${candidate}`); + } + } + + // Show progress + if ((i + batchSize) % 25 === 0 || i + batchSize >= candidatesArray.length) { + console.log(` Tested ${Math.min(i + batchSize, candidatesArray.length)}/${candidatesArray.length} candidates...`); + } + + // Rate limiting between batches + await new Promise(resolve => setTimeout(resolve, 50)); + } + + // Save discovered maps to cache for next run + const allDiscovered = [...discoveredMaps]; + saveDiscoveredMapsCache(allDiscovered); + + console.log(` πŸ“ CDN probe complete: found ${discoveredMaps.size} maps`); + return allDiscovered; +} + async function discoverMapTilePattern(mapName: string): Promise<{ baseUrl: string; separator: string } | null> { console.log(` πŸ” Discovering tile pattern for ${mapName}...`); - // Generate name variations - const nameWithUnderscore = mapName.replace(/-/g, '_'); - const nameVariations = [mapName]; - if (nameWithUnderscore !== mapName) { - nameVariations.push(nameWithUnderscore); - } + // Generate all name variations (hyphen, underscore, no separator, etc.) + const nameVariations = generateNameVariations(mapName); - // Generate historical dates (last 6 months of possible dates) - const historicalDates: string[] = []; - for (let monthsAgo = 0; monthsAgo < 6; monthsAgo++) { + // Generate dates for the last 30 days (daily, not monthly) + const recentDates: string[] = []; + for (let daysAgo = 0; daysAgo < 30; daysAgo++) { const date = new Date(); - date.setMonth(date.getMonth() - monthsAgo); - historicalDates.push(date.toISOString().slice(0, 10).replace(/-/g, '')); + date.setDate(date.getDate() - daysAgo); + recentDates.push(date.toISOString().slice(0, 10).replace(/-/g, '')); } - const patterns = []; + const patterns: Array<{ url: string; baseUrl: string; separator: string; label: string }> = []; // For each name variation, try all patterns for (const name of nameVariations) { + // Try recent daily dates first (most likely to work) + patterns.push( + ...recentDates.map(date => ({ + url: `https://cdn.metaforge.app/arc-raiders/maps/${name}/${date}/0/0/0.webp`, + baseUrl: `https://cdn.metaforge.app/arc-raiders/maps/${name}/${date}`, + separator: '/', + label: `${name}/${date}` + })) + ); + + // Then try other known patterns patterns.push( - // Current known patterns { url: `https://cdn.metaforge.app/arc-raiders/maps/${name}-new/0/0_0.webp`, baseUrl: `https://cdn.metaforge.app/arc-raiders/maps/${name}-new`, separator: '_', label: `${name}-new` }, { url: `https://cdn.metaforge.app/arc-raiders/maps/${name}/v2/0/0/0.webp`, baseUrl: `https://cdn.metaforge.app/arc-raiders/maps/${name}/v2`, separator: '/', label: `${name}/v2` }, - { url: `https://cdn.metaforge.app/arc-raiders/maps/${name}/20251030/0/0/0.webp`, baseUrl: `https://cdn.metaforge.app/arc-raiders/maps/${name}/20251030`, separator: '/', label: `${name}/20251030` }, // Try version numbers 1-10 ...Array.from({ length: 10 }, (_, i) => ({ @@ -527,14 +784,6 @@ async function discoverMapTilePattern(mapName: string): Promise<{ baseUrl: strin label: `${name}/v${i + 1}` })), - // Try historical dates - ...historicalDates.map(date => ({ - url: `https://cdn.metaforge.app/arc-raiders/maps/${name}/${date}/0/0/0.webp`, - baseUrl: `https://cdn.metaforge.app/arc-raiders/maps/${name}/${date}`, - separator: '/', - label: `${name}/${date}` - })), - // Base patterns without version/date { url: `https://cdn.metaforge.app/arc-raiders/maps/${name}/0/0_0.webp`, baseUrl: `https://cdn.metaforge.app/arc-raiders/maps/${name}`, separator: '_', label: `${name}/base_` }, { url: `https://cdn.metaforge.app/arc-raiders/maps/${name}/0/0/0.webp`, baseUrl: `https://cdn.metaforge.app/arc-raiders/maps/${name}`, separator: '/', label: `${name}/base/` } @@ -546,16 +795,16 @@ async function discoverMapTilePattern(mapName: string): Promise<{ baseUrl: strin for (const pattern of patterns) { testedCount++; - const exists = await testTileUrl(pattern.url); + const exists = await testUrl(pattern.url); if (exists) { console.log(` βœ… Found pattern: ${pattern.baseUrl} (separator: '${pattern.separator}') after testing ${testedCount}/${patterns.length} patterns`); return { baseUrl: pattern.baseUrl, separator: pattern.separator }; } - // Show progress every 5 patterns - if (testedCount % 5 === 0) { + // Show progress every 20 patterns + if (testedCount % 20 === 0) { console.log(` Tested ${testedCount}/${patterns.length} patterns...`); } - await new Promise(resolve => setTimeout(resolve, 50)); // Rate limiting + await new Promise(resolve => setTimeout(resolve, 30)); // Rate limiting } console.warn(` ⚠️ Tiles not available for ${mapName} (tested ${patterns.length} URL patterns)`); @@ -776,14 +1025,18 @@ function mapMetaForgeItemToOurFormat( } function mapMetaForgeQuestToOurFormat(metaforgeQuest: MetaForgeQuest): any { + // Use position.y for sort order (lower y = earlier in quest progression) + const sortOrder = metaforgeQuest.position?.y ?? metaforgeQuest.sort_order ?? 0; + return { id: metaforgeQuest.id, name: metaforgeQuest.name, objectives: metaforgeQuest.objectives || [], requirements: metaforgeQuest.required_items || [], - rewards: metaforgeQuest.rewards || {}, - trader: metaforgeQuest.trader, + rewards: metaforgeQuest.rewards || [], + trader: metaforgeQuest.trader_name || 'Unknown', xp: metaforgeQuest.xp || 0, + sortOrder: sortOrder, }; } @@ -795,7 +1048,7 @@ async function main() { // Fetch crafting and recycling data from Supabase (with rate limiting) console.log('\nπŸ“₯ Fetching crafting and recycling data from Supabase...'); - await new Promise(resolve => setTimeout(resolve, 500)); // Rate limiting + await new Promise(resolve => setTimeout(resolve, 100)); // Rate limiting const [craftingMap, recycleMap] = await Promise.all([ fetchAllCraftingComponents(), fetchAllRecycleComponents() @@ -828,7 +1081,7 @@ async function main() { // Fetch map data from Supabase console.log('\nπŸ“₯ Fetching map data from Supabase...'); - await new Promise(resolve => setTimeout(resolve, 500)); // Rate limiting + await new Promise(resolve => setTimeout(resolve, 100)); // Rate limiting const mapData = await fetchAllMapData(); // Save map data @@ -840,18 +1093,29 @@ async function main() { const totalMapMarkers = mapData.reduce((sum, m) => sum + m.stats.totalMarkers, 0); console.log(`βœ… Saved map data with ${totalMapMarkers} markers across ${mapData.length} maps`); - // Extract discovered map names - const discoveredMaps = mapData.map(m => m.map); + // Get map names from Supabase markers + const supabaseMaps = mapData.map(m => m.map); + console.log(`πŸ“ Maps from Supabase markers: ${supabaseMaps.join(', ') || '(none)'}`); + + // Discover actual available maps by probing the CDN + // This is resilient to name changes - we check what actually exists + const cdnMaps = await discoverMapsFromCDN(supabaseMaps); + console.log(`πŸ“ Maps found on CDN: ${cdnMaps.join(', ') || '(none)'}`); + + // Use CDN-discovered maps as the source of truth (they're what we can actually download) + // Fall back to Supabase names if CDN discovery fails completely + const allMaps = cdnMaps.length > 0 ? cdnMaps : supabaseMaps; + console.log(`πŸ“ Maps to process: ${allMaps.join(', ')}`); // Download map images console.log('\nπŸ“₯ Downloading map images...'); - await new Promise(resolve => setTimeout(resolve, 500)); // Rate limiting - await fetchMapImages(discoveredMaps); + await new Promise(resolve => setTimeout(resolve, 100)); // Rate limiting + await fetchMapImages(allMaps); - // Download map tiles + // Download map tiles for ALL maps (not just discovered ones) console.log('\nπŸ“₯ Downloading map tiles...'); - await new Promise(resolve => setTimeout(resolve, 500)); // Rate limiting - await downloadMapTiles(discoveredMaps); + await new Promise(resolve => setTimeout(resolve, 100)); // Rate limiting + await downloadMapTiles(allMaps); // Calculate map extents from downloaded tiles await calculateMapExtents(); @@ -944,6 +1208,12 @@ async function main() { console.log(`πŸ—ΊοΈ Total maps: ${metadata.mapCount}`); console.log(`πŸ“ Total map markers: ${metadata.mapMarkerCount}`); console.log(`\n⚠️ Note: Hideout modules and projects are stored in public/data/static/ and are not updated by this script.`); + + // Force exit - Sharp's thread pool can keep Node alive + process.exit(0); } -main().catch(console.error); +main().catch((err) => { + console.error(err); + process.exit(1); +}); From a651535bb57bfed6dc6cd50dc93489fe9dc63880 Mon Sep 17 00:00:00 2001 From: Oliver Davies Date: Sat, 17 Jan 2026 11:56:38 +0000 Subject: [PATCH 3/4] chore: Update deployment workflow for preview branches - Add deploy-preview.yml for claude/* branch deployments - Include preview folder in main deployment workflow - Update local Claude settings --- .github/workflows/deploy-preview.yml | 81 ++++++++++++++++++++++++++++ .github/workflows/deploy.yml | 36 ++++++++++++- 2 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/deploy-preview.yml diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml new file mode 100644 index 0000000..166f45e --- /dev/null +++ b/.github/workflows/deploy-preview.yml @@ -0,0 +1,81 @@ +name: Deploy Preview + +on: + push: + branches: + - 'claude/**' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }}preview/ + + steps: + - name: Checkout main branch + uses: actions/checkout@v4 + with: + ref: main + lfs: true + path: main + + - name: Checkout preview branch + uses: actions/checkout@v4 + with: + lfs: true + path: preview + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Build main site + working-directory: main + run: | + npm ci + npm run build + + - name: Build preview site + working-directory: preview + run: | + npm ci + npm run type-check + npx vite build --base=/RaiderCache/preview/ + + - name: Combine builds + run: | + mkdir -p combined + cp -r main/dist/* combined/ + mkdir -p combined/preview + cp -r preview/dist/* combined/preview/ + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: ./combined + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 + + - name: Summary + run: | + echo "### Deployed! :rocket:" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Main site:** https://otdavies.github.io/RaiderCache/" >> $GITHUB_STEP_SUMMARY + echo "**Preview:** https://otdavies.github.io/RaiderCache/preview/" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c63a56e..4d8a94f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -21,10 +21,11 @@ jobs: runs-on: ubuntu-latest steps: - - name: Checkout + - name: Checkout main uses: actions/checkout@v4 with: lfs: true + fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v4 @@ -38,9 +39,40 @@ jobs: - name: Type check run: npm run type-check - - name: Build + - name: Build main site run: npm run build + - name: Check for preview branch + id: preview + run: | + # Find the most recent claude/* branch + PREVIEW_BRANCH=$(git branch -r | grep 'origin/claude/' | head -1 | tr -d ' ') + if [ -n "$PREVIEW_BRANCH" ]; then + echo "branch=${PREVIEW_BRANCH#origin/}" >> $GITHUB_OUTPUT + echo "exists=true" >> $GITHUB_OUTPUT + echo "Found preview branch: $PREVIEW_BRANCH" + else + echo "exists=false" >> $GITHUB_OUTPUT + echo "No preview branch found" + fi + + - name: Build preview site + if: steps.preview.outputs.exists == 'true' + run: | + # Save main build + mv dist main-dist + + # Checkout and build preview branch + git checkout ${{ steps.preview.outputs.branch }} + npm ci + npx vite build --base=/RaiderCache/preview/ + + # Combine builds + mv main-dist dist-combined + mkdir -p dist-combined/preview + cp -r dist/* dist-combined/preview/ + mv dist-combined dist + - name: Setup Pages uses: actions/configure-pages@v4 From 88a75efd0b431b466764204510ba317ec6a13923 Mon Sep 17 00:00:00 2001 From: Oliver Davies Date: Sat, 17 Jan 2026 11:56:50 +0000 Subject: [PATCH 4/4] chore: Update game data and icons from MetaForge - Update items, quests, maps data with latest from API - Add map discovery cache for resilient detection - Update item icons from CDN --- public/data/.map-discovery-cache.json | 3 +++ public/data/items.json | 4 ++-- public/data/map-extents.json | 4 ++-- public/data/maps.json | 4 ++-- public/data/metadata.json | 4 ++-- public/data/quests.json | 4 ++-- 6 files changed, 13 insertions(+), 10 deletions(-) create mode 100644 public/data/.map-discovery-cache.json diff --git a/public/data/.map-discovery-cache.json b/public/data/.map-discovery-cache.json new file mode 100644 index 0000000..3b8dea2 --- /dev/null +++ b/public/data/.map-discovery-cache.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8ce5da7aa3ce65d36f232d4cbd1a399a99ac3cfc72783d5ebaa06010efd88c18 +size 148 diff --git a/public/data/items.json b/public/data/items.json index 7e421d9..ec05f07 100644 --- a/public/data/items.json +++ b/public/data/items.json @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:731c5588325d38352e982d8b7276f653f13413c088a6bd3285a300d840eee6ad -size 244917 +oid sha256:b786e30cbb6a6801a28160c5b081e420456777c47613475813a45fb68c12386a +size 243376 diff --git a/public/data/map-extents.json b/public/data/map-extents.json index a26afc2..6355969 100644 --- a/public/data/map-extents.json +++ b/public/data/map-extents.json @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:90ecb4847ed141fdd0f5b590f6bb1ffd89d65ef5b84680770caa90e7fedc4bb5 -size 794 +oid sha256:fbeafaeed7c7a0d4e745305792408b938b4a91e5d4fc2b2fa1cc0f361518eacc +size 800 diff --git a/public/data/maps.json b/public/data/maps.json index 48ece6e..34de543 100644 --- a/public/data/maps.json +++ b/public/data/maps.json @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d16db72c4fa1d3a6c4ecc68011156d8c99f52bd54046c348cc74916b995a76ad -size 2697868 +oid sha256:1e96a1071843eec9d25877b7db3f3ed65fb2f9f642733a3fbd4eba439d4d99eb +size 1080868 diff --git a/public/data/metadata.json b/public/data/metadata.json index 13ce8a9..3e09886 100644 --- a/public/data/metadata.json +++ b/public/data/metadata.json @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4f3a82b0a36a95adadab330430e60d063377e348ab07f53a13e6415bdb12ce14 -size 750 +oid sha256:26981844b44832eb75b6f8afe8b2f49f3fae510648846ad6000b1c65069efcdc +size 486 diff --git a/public/data/quests.json b/public/data/quests.json index 998c4d1..dfe3034 100644 --- a/public/data/quests.json +++ b/public/data/quests.json @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b337db12d090a1fa06c8f0b0301f4475c5f4fd9726171a4dc400ce5ad03395d5 -size 106803 +oid sha256:31193da85cdceb80b3816572d8acb69794c0030ec339105ce714ed0f2032aa18 +size 111099