// Achievements engine — subscribes to bus events, unlocks achievements. // On unlock: pushes id into State, emits 'achievement:unlocked' for the widget. import Bus from '../core/bus.js'; import State from '../core/state.js'; import catalog from './catalog.js'; // In-memory counters per achievement id const counters = {}; // For uniqueField tracking: Set of seen values per achievement id const uniqueSets = {}; // For speedrun: map of windowId -> open timestamp const openTimestamps = {}; let initialized = false; function hasUnlocked(id) { const unlocked = State.get('achievements'); return unlocked && unlocked.includes(id); } function unlock(ach) { if (hasUnlocked(ach.id)) return; State.addAchievement(ach.id); Bus.emit('achievement:unlocked', { id: ach.id, title: ach.title, desc: ach.desc, game: ach.game }); } function handleEvent(ach, data) { const trigger = ach.trigger; // Event+id specific trigger (easter egg matches) if (trigger.id) { if (data && data.id === trigger.id) { unlock(ach); } return; } // Simple event trigger (no count, no accumulate) — unlock on first fire if (!trigger.count && !trigger.accumulate && !trigger.uniqueField) { unlock(ach); return; } // Count-based trigger if (trigger.count) { counters[ach.id] = (counters[ach.id] || 0) + 1; if (counters[ach.id] >= trigger.count) { unlock(ach); } return; } // UniqueField trigger if (trigger.uniqueField) { if (!uniqueSets[ach.id]) { uniqueSets[ach.id] = new Set(); } const val = data && data[trigger.uniqueField]; if (val) { uniqueSets[ach.id].add(val); if (uniqueSets[ach.id].size >= trigger.count) { unlock(ach); } } return; } // Accumulate trigger if (trigger.accumulate) { const key = trigger.accumulate; const value = data && data[key]; if (typeof value === 'number') { counters[ach.id] = (counters[ach.id] || 0) + value; if (counters[ach.id] >= trigger.threshold) { unlock(ach); } } return; } } function init() { if (initialized) return; initialized = true; // Pre-initialize counters for all achievements catalog.forEach(ach => { counters[ach.id] = 0; uniqueSets[ach.id] = ach.trigger.uniqueField ? new Set() : undefined; }); // Subscribe to each achievement's trigger event catalog.forEach(ach => { const trigger = ach.trigger; if (trigger.custom === 'speedrun') { // Speedrun: listen to app:open and app:close on wm.js Bus.on('app:open', (data) => { if (data && data.id) { openTimestamps[data.id] = Date.now(); } }); Bus.on('app:close', (data) => { if (data && data.id && openTimestamps[data.id]) { const delta = Date.now() - openTimestamps[data.id]; if (delta < 2000) { unlock(ach); } delete openTimestamps[data.id]; } }); return; } if (trigger.event) { Bus.on(trigger.event, (data) => { handleEvent(ach, data); }); } }); } export default { init };