// DiveDex — React context + actions. // Owns the live appState. Every mutation funnels through here so persistence // is automatic and the action surface is the one place future Firebase wiring // will plug into. const DiveDexContext = React.createContext(null); // ── id helper ──────────────────────────────────────────────────────── function ddId(prefix) { return prefix + '-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6); } // Push state.settings audio prefs into the audio engine — used on boot and // after an import so the speakers reflect the just-restored state. function pushAudioPrefs(settings) { const A = window.DiveDexAudio; if (!A || !settings) return; if (typeof settings.soundEnabled === 'boolean') A.setSoundEnabled(settings.soundEnabled); if (typeof settings.musicEnabled === 'boolean') A.setMusicEnabled(settings.musicEnabled); if (settings.volume) A.setMasterVolume(settings.volume); } // Bridge helper: collect cosmetic item ids granted by a set of achievement ids, // minus anything already owned. Pure function over the bridge map. function itemsBridgedFromAchievements(achievementIds, alreadyOwned) { const map = (window.DD_STATE && window.DD_STATE.ACHIEVEMENT_TO_ITEMS) || {}; const out = []; achievementIds.forEach(aid => { (map[aid] || []).forEach(itemId => { if (out.indexOf(itemId) < 0 && alreadyOwned.indexOf(itemId) < 0) out.push(itemId); }); }); return out; } // ── scoring engine ─────────────────────────────────────────────────── // Pure function. Given a dive draft + sightings + the creature registry + // scoring config, returns the breakdown that drives Recap *and* the actual // XP/Pearl awards. // // dive: { site, op, buddy, maxDepth, time, temp, vis, gas, nitrox, // startBar, endBar, notes, mood, current, drift, photos, // buddyCheck, safeReserveBar } // sightings: [{ creatureId, count, firstSpotter }] // creatures: state.creatures // scoring: state-level SCORING_CONFIG // // Returns: { xpRows, totalXp, perUserXp, pearls, newDiscoveries, newLegendaries, // sharkFamilyCount, achievementsToUnlock } function computeDiveScoring(dive, sightings, creatures, scoring, alreadyUnlocked) { const xp = scoring.xp; const prl = scoring.pearls; const rows = []; rows.push({ label: 'Dive logged', value: xp.diveLogged }); const nitrox = Number(dive.nitrox) || (dive.gas === 'Nitrox' ? 32 : 21); if (nitrox > 21) rows.push({ label: `Nitrox ${nitrox}`, value: xp.nitroxDive }); const safeReserve = dive.safeReserveBar || 50; const airZen = Number(dive.endBar) >= safeReserve; if (airZen) rows.push({ label: 'Air Zen bonus', value: xp.airZenBonus, tone: 'foam' }); if (dive.drift || dive.current === 'strong' || dive.current === 'ripping') { rows.push({ label: 'Drift dive completed', value: xp.driftDive }); } if (dive.buddyCheck) rows.push({ label: 'Buddy check', value: xp.buddyCheck }); const photoCount = Number(dive.photos) || 0; if (photoCount > 0) rows.push({ label: `Photos added (${photoCount})`, value: xp.photosAdded }); // Sighting awards const rarityXp = { common: xp.commonSighting, uncommon: xp.uncommonSighting, rare: xp.rareSighting, epic: xp.epicSighting, legendary: xp.legendarySighting, mythic: xp.mythicSighting, }; const sharkFamilies = new Set(); let sharkTotalCount = 0; // sum of count across all shark sightings on this dive const newDiscoveries = []; // creatureIds newly discovered const newLegendaries = []; // for Pearl bonus const perUserXp = { rick: 0, siet: 0 }; let firstSpotterBonusGivenTo = null; sightings.forEach(s => { const cr = creatures.find(c => c.id === s.creatureId); if (!cr) return; const xpAward = rarityXp[cr.rarity] || xp.commonSighting; rows.push({ label: `${cr.commonName}${s.count > 1 ? ' ×' + s.count : ''}`, value: xpAward, tone: cr.rarity === 'legendary' || cr.rarity === 'mythic' ? 'gold' : null, }); // first-spotter attribution if (s.firstSpotter && perUserXp[s.firstSpotter] !== undefined) { perUserXp[s.firstSpotter] += xpAward; } if (s.firstSpotter && !cr.discovered && !firstSpotterBonusGivenTo) { firstSpotterBonusGivenTo = s.firstSpotter; } if (cr.familyType === 'shark') { sharkFamilies.add(cr.id); sharkTotalCount += (s.count || 1); } if (!cr.discovered) newDiscoveries.push(cr.id); if (cr.rarity === 'legendary' || cr.rarity === 'mythic') newLegendaries.push(cr.id); }); if (firstSpotterBonusGivenTo) { rows.push({ label: `${firstSpotterBonusGivenTo === 'siet' ? 'Siet' : 'Rick'} spotted it first`, value: xp.firstSpotterBonus }); perUserXp[firstSpotterBonusGivenTo] += xp.firstSpotterBonus; } const totalXp = rows.reduce((s, r) => s + r.value, 0); // Distribute the *unattributed* XP 50/50 between the two users. const attributed = perUserXp.rick + perUserXp.siet; const remainder = totalXp - attributed; perUserXp.rick += Math.floor(remainder / 2); perUserXp.siet += Math.ceil(remainder / 2); // Pearls let pearls = prl.diveLogged; pearls += newDiscoveries.length * prl.newCreature; pearls += newLegendaries.length * prl.legendaryEncounter; if (airZen) pearls += prl.airZenBonus; // Achievement auto-unlock conditions const achievementsToUnlock = []; const want = (id) => { if (alreadyUnlocked.indexOf(id) >= 0) return false; achievementsToUnlock.push(id); return true; }; // first-dive handled by caller (needs prior dive count) if (nitrox > 21) want('first-nitrox'); if (dive.endBar >= 50) want('fifty-bar-club'); if (dive.drift || dive.current === 'strong' || dive.current === 'ripping') want('drift-dive'); if (sightings.some(s => { const cr = creatures.find(c => c.id === s.creatureId); return cr && cr.familyType === 'shark'; })) want('first-shark'); if (sightings.some(s => s.creatureId === 'scalloped-hammerhead')) want('hammerhead-hunter'); if (sightings.some(s => s.creatureId === 'hammerhead-school')) want('hammerhead-school'); if (sightings.some(s => { const cr = creatures.find(c => c.id === s.creatureId); return cr && cr.rarity === 'legendary' && s.firstSpotter === 'siet'; })) want('siet-first'); if (airZen) want('air-zen-bonus'); // Sharknado: variety (≥3 distinct shark species) OR volume (≥10 sharks total). if (sharkFamilies.size >= 3 || sharkTotalCount >= 10) want('sharknado'); return { xpRows: rows, totalXp, perUserXp, pearls, newDiscoveries, newLegendaries, sharkFamilyCount: sharkFamilies.size, sharkTotalCount, airZen, achievementsToUnlock, }; } function DiveDexProvider({ children }) { const [state, setStateRaw] = React.useState(() => { return window.DD_STORAGE.loadAppState() || window.DD_STATE.makeInitialState(); }); React.useEffect(() => { window.DD_STORAGE.saveAppState(state); // On boot, push the persisted audio prefs into the audio engine so a // backup-restored state actually controls the speakers. (The engine keeps // its own LS keys for legacy reasons; state.settings is the backup-safe // mirror that survives export/import.) pushAudioPrefs(state.settings); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const setState = React.useCallback((updater) => { setStateRaw(prev => { const next = typeof updater === 'function' ? updater(prev) : updater; return window.DD_STORAGE.saveAppState(next); }); }, []); const actions = React.useMemo(() => ({ // ── settings & backup ────────────────────────────────────────────── updateSettings(patch) { setState(prev => ({ ...prev, settings: { ...prev.settings, ...patch } })); }, exportBackup() { window.DD_BACKUP.downloadBackup(state); setState(prev => ({ ...prev, lastBackupAt: new Date().toISOString() })); }, async importBackupFromFile(file) { const incoming = await window.DD_BACKUP.readBackupFile(file); setStateRaw(incoming); window.DD_STORAGE.saveAppState(incoming); // Restore audio prefs into the live engine pushAudioPrefs(incoming.settings); return incoming; }, resetDemoData() { const fresh = window.DD_STORAGE.resetAppState(); setStateRaw(fresh); pushAudioPrefs(fresh.settings); }, // ── auth (lightweight, single shared password) ──────────────────── // unlockApp validates the password if one is set, then records the // active user. Returns { ok, reason? } so the LockScreen can show // feedback without throwing. // unlockApp: validate-then-unlock. If `setNewPassword` is provided AND // there is no current password, this *also* sets the password atomically // — so first-run users can pick a profile and set a password in one tap. unlockApp(userId, password, opts) { if (!['rick', 'siet'].includes(userId)) { return { ok: false, reason: 'Pick who\'s diving.' }; } const setNewPassword = opts && typeof opts.setNewPassword === 'string' ? opts.setNewPassword : null; if (state.authPassword) { if (password !== state.authPassword) { return { ok: false, reason: 'Wrong password.' }; } } else if (setNewPassword) { // First-run: caller chose to add a password before continuing setState(prev => ({ ...prev, locked: false, activeUserId: userId, authPassword: setNewPassword })); return { ok: true }; } setState(prev => ({ ...prev, locked: false, activeUserId: userId })); return { ok: true }; }, lockApp() { setState(prev => ({ ...prev, locked: true })); }, setActiveUser(userId) { if (!['rick', 'siet'].includes(userId)) return { ok: false }; setState(prev => ({ ...prev, activeUserId: userId })); return { ok: true }; }, // Set or clear (empty string) the shared password. Plain-text storage — // this is casual access protection, not a real security boundary. updateAuthPassword(next) { setState(prev => ({ ...prev, authPassword: typeof next === 'string' ? next : '' })); return { ok: true }; }, // ── dive logging (the core loop) ────────────────────────────────── // dive: see computeDiveScoring above. sightings: same. // Returns the new dive id so the caller can navigate to its recap. logDive(diveInput, sightings) { const id = ddId('dive'); const isoNow = new Date().toISOString(); // Honor caller-provided dive time (from the date/time picker); fall back // to now. Sightings inherit the same timestamp so the Memories timeline // groups them on the dive's day, not the save day. const diveAt = (diveInput && diveInput.createdAt) || isoNow; let createdId = null; setState(prev => { const score = computeDiveScoring( diveInput, sightings || [], prev.creatures, window.DD_STATE.SCORING_CONFIG, prev.unlockedAchievementIds, ); // first-dive achievement — only if this is the very first dive const willBeFirst = prev.dives.length === 0 && prev.unlockedAchievementIds.indexOf('first-dive') < 0; if (willBeFirst) score.achievementsToUnlock.unshift('first-dive'); const diveRecord = { id, no: prev.dives.length + 1, ...diveInput, totalXp: score.totalXp, perUserXp: score.perUserXp, pearls: score.pearls, airZen: score.airZen, photoCount: Number(diveInput.photos) || 0, xpRows: score.xpRows, // freeze the breakdown so Recap is reproducible unlockedAchievementIds: score.achievementsToUnlock.slice(), newDiscoveryIds: score.newDiscoveries.slice(), createdAt: diveAt, savedAt: isoNow, status: 'logged', }; createdId = id; const sightingRecords = (sightings || []).map(s => ({ id: ddId('sgt'), diveId: id, creatureId: s.creatureId, count: s.count || 1, firstSpotter: s.firstSpotter || null, createdAt: diveAt, })); // Update creature discoveries const newDiscSet = new Set(score.newDiscoveries); const creatures = prev.creatures.map(c => { const sighting = (sightings || []).find(s => s.creatureId === c.id); if (!sighting) return c; const becomesDiscovered = newDiscSet.has(c.id); return { ...c, discovered: c.discovered || becomesDiscovered, sightingsCount: c.sightingsCount + (sighting.count || 1), firstSeenBy: c.firstSeenBy || sighting.firstSpotter || null, firstSeenAt: c.firstSeenAt || (becomesDiscovered ? isoNow : null), firstSeenDiveId: c.firstSeenDiveId || (becomesDiscovered ? id : null), }; }); const discoveredIds = Array.from(new Set([...prev.discoveredCreatureIds, ...score.newDiscoveries])); // Unlock achievements + accumulate their Pearl rewards let achievementPearls = 0; const achievements = prev.achievements.map(a => { if (score.achievementsToUnlock.indexOf(a.id) >= 0 && !a.unlocked) { achievementPearls += a.rewardPearls || 0; return { ...a, unlocked: true, unlockedAt: isoNow }; } return a; }); const unlockedIds = Array.from(new Set([...prev.unlockedAchievementIds, ...score.achievementsToUnlock])); // Bridge: grant cosmetics tied to the achievements that just unlocked. const bridgedItems = itemsBridgedFromAchievements(score.achievementsToUnlock, prev.inventory); diveRecord.bridgedItemIds = bridgedItems.slice(); return { ...prev, dives: [...prev.dives, diveRecord], sightings: [...prev.sightings, ...sightingRecords], creatures, discoveredCreatureIds: discoveredIds, achievements, unlockedAchievementIds: unlockedIds, inventory: [...prev.inventory, ...bridgedItems], currencies: { ...prev.currencies, pearls: prev.currencies.pearls + score.pearls + achievementPearls }, xp: { teamXp: prev.xp.teamXp + score.totalXp, userXp: { rick: prev.xp.userXp.rick + score.perUserXp.rick, siet: prev.xp.userXp.siet + score.perUserXp.siet, }, }, }; }); return createdId; }, // Delete a logged dive. Reverses XP and base Pearls (clamped at 0), // removes sightings, recomputes creature counts and first-seen attribution. // Achievements stay unlocked — too tangled to un-unlock cleanly, and the // user's trophies should be sticky once earned. deleteDive(diveId) { // Best-effort cleanup of any attached photos in IndexedDB. Fire-and-forget // so a slow IDB doesn't block the state mutation below. if (window.DD_PHOTOS) window.DD_PHOTOS.deleteDivePhotos(diveId).catch(() => {}); setState(prev => { const dive = prev.dives.find(d => d.id === diveId); if (!dive) return prev; // Drop the dive + its sightings const dives = prev.dives.filter(d => d.id !== diveId); const remainingSightings = prev.sightings.filter(s => s.diveId !== diveId); // Recompute per-creature count + reattribute first-seen when needed const countByCreature = remainingSightings.reduce((acc, s) => { acc[s.creatureId] = (acc[s.creatureId] || 0) + (s.count || 1); return acc; }, {}); // For each creature first-seen on the deleted dive, find the earliest // remaining sighting and reattribute. If none, mark undiscovered. const earliestSighting = {}; remainingSightings.forEach(s => { const existing = earliestSighting[s.creatureId]; if (!existing || s.createdAt < existing.createdAt) earliestSighting[s.creatureId] = s; }); const stillDiscovered = new Set(); const creatures = prev.creatures.map(c => { const newCount = countByCreature[c.id] || 0; const wasFirstSeenOnThisDive = c.firstSeenDiveId === diveId; if (wasFirstSeenOnThisDive) { const next = earliestSighting[c.id]; if (next) { // Reattribute first-seen to the next-earliest remaining sighting const nextDive = dives.find(d => d.id === next.diveId); stillDiscovered.add(c.id); return { ...c, sightingsCount: newCount, discovered: true, firstSeenBy: next.firstSpotter || null, firstSeenAt: next.createdAt, firstSeenDiveId: next.diveId, }; } // No other sightings — un-discover return { ...c, sightingsCount: 0, discovered: false, firstSeenBy: null, firstSeenAt: null, firstSeenDiveId: null, }; } // Was discovered on a different dive — keep state, just sync count if (c.discovered) stillDiscovered.add(c.id); return { ...c, sightingsCount: newCount }; }); const discoveredCreatureIds = Array.from(stillDiscovered); // Reverse XP — clamp at 0 so a stale state never goes negative const teamXp = Math.max(0, prev.xp.teamXp - (dive.totalXp || 0)); const rickXp = Math.max(0, prev.xp.userXp.rick - (dive.perUserXp ? dive.perUserXp.rick || 0 : 0)); const sietXp = Math.max(0, prev.xp.userXp.siet - (dive.perUserXp ? dive.perUserXp.siet || 0 : 0)); // Reverse base scoring Pearls. Achievement-reward Pearls stay // (the achievement is keeping its trophy, so its Pearl reward stays // counted as already-spent income). const pearls = Math.max(0, prev.currencies.pearls - (dive.pearls || 0)); // Renumber subsequent dives so dive.no stays contiguous const renumbered = dives.map((d, i) => ({ ...d, no: i + 1 })); return { ...prev, dives: renumbered, sightings: remainingSightings, creatures, discoveredCreatureIds, currencies: { ...prev.currencies, pearls }, xp: { teamXp, userXp: { rick: rickXp, siet: sietXp } }, }; }); return { ok: true }; }, // Edit a logged dive's metadata + sightings without changing scoring. // XP / Pearls / achievement unlocks stay frozen at the original log time — // edits are treated as "fix-ups" to the canonical record, not retroactive // re-scoring. Adding a sighting marks the creature discovered (so the Dex // reflects it) but does NOT grant retroactive XP/Pearl bonuses. updateDive(diveId, patch, sightings) { setState(prev => { const idx = prev.dives.findIndex(d => d.id === diveId); if (idx < 0) return prev; const old = prev.dives[idx]; const isoNow = new Date().toISOString(); // Merge metadata patch onto the existing dive, preserving immutable // scoring fields (totalXp, perUserXp, pearls, xpRows, etc.) const nextDive = { ...old, ...patch, // Re-derive a few view-friendly fields the form might change: airZen: typeof patch.endBar === 'number' ? (patch.endBar >= (prev.settings.safeReserveBar || 50)) : old.airZen, updatedAt: isoNow, }; const dives = prev.dives.slice(); dives[idx] = nextDive; // Replace the sighting set for this dive. We *keep* the old creature // discovery state (you can't un-discover), but new sightings can mark // additional creatures discovered. const otherSightings = prev.sightings.filter(s => s.diveId !== diveId); const newSightings = (sightings || []).map(s => ({ id: ddId('sgt'), diveId: diveId, creatureId: s.creatureId, count: s.count || 1, firstSpotter: s.firstSpotter || null, createdAt: isoNow, })); // Find creature ids that weren't previously discovered but are now // in this dive's sighting list — mark them discovered (no XP delta). const stillUndiscovered = new Set(prev.creatures.filter(c => !c.discovered).map(c => c.id)); const newlyDiscoveredHere = newSightings .filter(s => stillUndiscovered.has(s.creatureId)) .map(s => s.creatureId); // Recompute each affected creature's sighting count from scratch // (sum of `count` across all sightings for that creature, post-edit). const allSightings = [...otherSightings, ...newSightings]; const countByCreature = allSightings.reduce((acc, s) => { acc[s.creatureId] = (acc[s.creatureId] || 0) + (s.count || 1); return acc; }, {}); const newDiscSet = new Set(newlyDiscoveredHere); const creatures = prev.creatures.map(c => { // touch only creatures whose sighting count changed OR are newly discovered if (countByCreature[c.id] === undefined && !newDiscSet.has(c.id)) { // Was the old dive's only contribution to a now-removed sighting? const hadOldSighting = prev.sightings.some(s => s.diveId === diveId && s.creatureId === c.id); if (!hadOldSighting) return c; // Removed from this dive — recompute count, keep discovered state return { ...c, sightingsCount: countByCreature[c.id] || 0 }; } const newCount = countByCreature[c.id] || 0; const newlyDisc = newDiscSet.has(c.id); const firstSightingOnThisDive = newSightings.find(s => s.creatureId === c.id); return { ...c, sightingsCount: newCount, discovered: c.discovered || newlyDisc, firstSeenBy: c.firstSeenBy || (newlyDisc && firstSightingOnThisDive ? firstSightingOnThisDive.firstSpotter : c.firstSeenBy), firstSeenAt: c.firstSeenAt || (newlyDisc ? isoNow : c.firstSeenAt), firstSeenDiveId: c.firstSeenDiveId || (newlyDisc ? diveId : c.firstSeenDiveId), }; }); const discoveredCreatureIds = Array.from(new Set([...prev.discoveredCreatureIds, ...newlyDiscoveredHere])); return { ...prev, dives, sightings: allSightings, creatures, discoveredCreatureIds, }; }); return { ok: true }; }, unlockAchievement(achievementId) { setState(prev => { if (prev.unlockedAchievementIds.indexOf(achievementId) >= 0) return prev; const ach = prev.achievements.find(a => a.id === achievementId); if (!ach) return prev; const bridged = itemsBridgedFromAchievements([achievementId], prev.inventory); return { ...prev, achievements: prev.achievements.map(a => a.id === achievementId ? { ...a, unlocked: true, unlockedAt: new Date().toISOString() } : a), unlockedAchievementIds: [...prev.unlockedAchievementIds, achievementId], inventory: [...prev.inventory, ...bridged], currencies: { ...prev.currencies, pearls: prev.currencies.pearls + (ach.rewardPearls || 0) }, xp: { ...prev.xp, teamXp: prev.xp.teamXp + (ach.rewardXp || 0) }, }; }); }, completeSideQuest(questId) { setState(prev => { const q = prev.sideQuests.find(x => x.id === questId); if (!q || q.state === 'locked') return prev; // Non-repeatable quests can only be completed once. if (!q.repeatable && prev.completedSideQuestIds.indexOf(questId) >= 0) return prev; const now = new Date().toISOString(); const questXp = q.rewardXp || 0; const questPearls = q.rewardPearls || 0; const attribution = q.attribution || 'team'; // Attribute XP per the quest's setting: // 'rick' / 'siet' → all XP goes to that user + counted in teamXp // 'team' → split 50/50 between users + counted in teamXp let rickXpDelta = 0, sietXpDelta = 0; if (attribution === 'rick') rickXpDelta = questXp; else if (attribution === 'siet') sietXpDelta = questXp; else { rickXpDelta = Math.floor(questXp / 2); sietXpDelta = questXp - rickXpDelta; } let pearls = prev.currencies.pearls + questPearls; let teamXp = prev.xp.teamXp + questXp; let rickXp = prev.xp.userXp.rick + rickXpDelta; let sietXp = prev.xp.userXp.siet + sietXpDelta; let unlockedIds = prev.unlockedAchievementIds.slice(); let achievements = prev.achievements; let inventory = prev.inventory; // Bridge: a non-repeatable quest can trigger an achievement on first // completion. Repeatable quests skip the bridge (otherwise it'd // re-fire every tap). const bridgedAchievementId = !q.repeatable ? (window.DD_STATE.SIDEQUEST_TO_ACHIEVEMENT || {})[questId] : null; if (bridgedAchievementId && unlockedIds.indexOf(bridgedAchievementId) < 0) { const ach = achievements.find(a => a.id === bridgedAchievementId); if (ach) { achievements = achievements.map(a => a.id === bridgedAchievementId ? { ...a, unlocked: true, unlockedAt: now } : a); unlockedIds.push(bridgedAchievementId); pearls += ach.rewardPearls || 0; teamXp += ach.rewardXp || 0; // Achievement XP follows the same attribution as the quest that // triggered it — keeps the user-level math consistent. if (attribution === 'rick') rickXp += ach.rewardXp || 0; else if (attribution === 'siet') sietXp += ach.rewardXp || 0; else { const half = Math.floor((ach.rewardXp || 0) / 2); rickXp += half; sietXp += (ach.rewardXp || 0) - half; } const bridgedItems = itemsBridgedFromAchievements([bridgedAchievementId], inventory); inventory = [...inventory, ...bridgedItems]; } } // Log this completion (including repeats) so the UI can show // "done × N" and Memories can pull it in chronologically. const completionLog = [ ...(prev.sideQuestCompletions || []), { id: ddId('sqc'), questId, when: now, xp: questXp, pearls: questPearls, attribution }, ]; // Repeatable quests stay in Active (state unchanged). Non-repeatable // move to Completed and get added to completedSideQuestIds. const newSideQuests = q.repeatable ? prev.sideQuests : prev.sideQuests.map(x => x.id === questId ? { ...x, state: 'completed', completedAt: now } : x); const newCompletedIds = q.repeatable ? prev.completedSideQuestIds : [...prev.completedSideQuestIds, questId]; return { ...prev, sideQuests: newSideQuests, completedSideQuestIds: newCompletedIds, sideQuestCompletions: completionLog, achievements, unlockedAchievementIds: unlockedIds, inventory, currencies: { ...prev.currencies, pearls }, xp: { teamXp, userXp: { rick: rickXp, siet: sietXp } }, }; }); }, // ── boutique ────────────────────────────────────────────────────── // Returns { ok: bool, reason?: string } so screens can show feedback. buyShopItem(itemId) { const catalog = window.BOUTIQUE_ITEMS || []; const item = catalog.find(i => i.id === itemId); if (!item) return { ok: false, reason: 'Item not found.' }; if (item.state === 'locked' || item.state === 'achievement') { return { ok: false, reason: 'This item is unlock-only.' }; } if (state.inventory.indexOf(itemId) >= 0) return { ok: false, reason: 'Already owned.' }; const price = item.price || 0; if (state.currencies.pearls < price) { return { ok: false, reason: 'Not enough Pearls.' }; } setState(prev => ({ ...prev, currencies: { ...prev.currencies, pearls: prev.currencies.pearls - price }, inventory: [...prev.inventory, itemId], })); return { ok: true }; }, equipShopItem(itemId, userId) { const catalog = window.BOUTIQUE_ITEMS || []; const item = catalog.find(i => i.id === itemId); if (!item) return { ok: false, reason: 'Item not found.' }; if (state.inventory.indexOf(itemId) < 0) return { ok: false, reason: 'Not owned.' }; if (!['rick','siet'].includes(userId)) return { ok: false, reason: 'Unknown user.' }; const slot = (window.DD_STATE.CAT_TO_SLOT || {})[item.cat]; if (!slot) return { ok: false, reason: 'Nothing to equip for this item.' }; setState(prev => ({ ...prev, equippedItems: { ...prev.equippedItems, [userId]: { ...(prev.equippedItems[userId] || {}), [slot]: itemId }, }, })); return { ok: true }; }, unequipShopItem(itemId, userId) { setState(prev => { const u = prev.equippedItems[userId] || {}; const next = { ...u }; Object.keys(next).forEach(slot => { if (next[slot] === itemId) delete next[slot]; }); return { ...prev, equippedItems: { ...prev.equippedItems, [userId]: next } }; }); return { ok: true }; }, }), [state, setState]); React.useEffect(() => { window.__DD_STATE__ = state; }, [state]); return React.createElement( DiveDexContext.Provider, { value: { state, actions } }, children ); } function useDiveDex() { const v = React.useContext(DiveDexContext); if (!v) throw new Error('useDiveDex must be used within DiveDexProvider'); return v; } // Convenience selectors (pure) function selectLatestDive(state) { return state.dives.length ? state.dives[state.dives.length - 1] : null; } function selectDiveById(state, id) { return state.dives.find(d => d.id === id) || null; } // Resolve a dive's "hero" sighting + creature — the rarest species seen. // Used by Recap and Story Card so both surfaces highlight the same species. function selectDiveHero(state, dive) { if (!dive) return { sighting: null, creature: null }; const rank = { mythic: 6, legendary: 5, epic: 4, rare: 3, uncommon: 2, common: 1 }; const diveSightings = state.sightings.filter(s => s.diveId === dive.id); if (diveSightings.length === 0) return { sighting: null, creature: null }; const sorted = diveSightings.slice().sort((a, b) => { const ra = (state.creatures.find(c => c.id === a.creatureId) || {}).rarity || 'common'; const rb = (state.creatures.find(c => c.id === b.creatureId) || {}).rarity || 'common'; return (rank[rb] || 0) - (rank[ra] || 0); }); const sighting = sorted[0]; const creature = state.creatures.find(c => c.id === sighting.creatureId) || null; return { sighting, creature }; } // Effective per-item state, derived from catalog + inventory + equipped + pearls. // Returns one of: // 'equipped' — owned and equipped by at least one user // 'owned' — owned, not equipped // 'achievement' — catalog says unlock-only via achievement (not owned) // 'locked' — catalog locked (not owned) // 'not-enough' — buyable but pearls < price // 'buy' — buyable and affordable function selectBoutiqueItemState(state, catalogItem) { const id = catalogItem.id; const owned = state.inventory.indexOf(id) >= 0; if (owned) { const equipped = ['rick','siet'].some(u => Object.values(state.equippedItems[u] || {}).indexOf(id) >= 0 ); return equipped ? 'equipped' : 'owned'; } if (catalogItem.state === 'achievement') return 'achievement'; if (catalogItem.state === 'locked') return 'locked'; if (state.currencies.pearls < (catalogItem.price || 0)) return 'not-enough'; return 'buy'; } function selectItemEquippedBy(state, itemId) { return ['rick','siet'].filter(u => Object.values(state.equippedItems[u] || {}).indexOf(itemId) >= 0 ); } function selectEquippedForUser(state, userId, slot) { const ids = state.equippedItems[userId] || {}; if (slot) return ids[slot] || null; return ids; } Object.assign(window, { DiveDexProvider, useDiveDex, DiveDexContext, computeDiveScoring, selectLatestDive, selectDiveById, selectDiveHero, selectBoutiqueItemState, selectItemEquippedBy, selectEquippedForUser, });