diff --git a/static/v3/index.html b/static/v3/index.html index 1238951..36c41bc 100644 --- a/static/v3/index.html +++ b/static/v3/index.html @@ -859,6 +859,7 @@ registers the `progression` capability owner + window.v3Progression. --> + diff --git a/static/v3/notifications.js b/static/v3/notifications.js new file mode 100644 index 0000000..21b3c0e --- /dev/null +++ b/static/v3/notifications.js @@ -0,0 +1,160 @@ +/* + * fee[dB]ack v0.3.0 — achievement notifications (toasts). + * + * A small, reusable toast surface (window.fbNotify) plus the progression wiring + * that turns progression:* lifecycle events into fancy in-app notifications: + * + * quest-progressed → subtle "Quest advanced — N/M" + * quest-completed → celebratory "Quest Complete! +N dB" + * path-progressed → subtle "{Path}: challenge done — N/M to Level X" + * path-level-up → celebratory "{Path} reached Level X!" + * rank-changed (up) → celebratory "Mastery Rank X!" + * + * Vanilla JS, no framework (constitution P-II). Animation + accent colors are + * inline styles so the prebuilt Tailwind stylesheet needs no new utilities. + * Self-contained: it subscribes through window.slopsmith.on, degrading to a + * no-op when the bus or DOM isn't present (SSR/headless safety, P15). + */ +(function () { + 'use strict'; + + const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ( + { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + + function container() { + let host = document.getElementById('fb-notify-stack'); + if (!host) { + host = document.createElement('div'); + host.id = 'fb-notify-stack'; + // Bottom-right stack, newest on top; clicks pass through the gaps. + host.className = 'fixed bottom-4 right-4 z-[120] flex flex-col gap-2 items-end pointer-events-none'; + host.style.maxWidth = 'min(24rem, calc(100vw - 2rem))'; + document.body.appendChild(host); + } + return host; + } + + // opts: { title, message, icon, accent, reward, big, durationMs } + function show(opts) { + opts = opts || {}; + if (typeof document === 'undefined' || !document.body) return null; + const host = container(); + const accent = opts.accent || '#3B82F6'; + const big = !!opts.big; + + const card = document.createElement('div'); + card.className = 'pointer-events-auto bg-fb-card border border-fb-border/60 rounded-xl shadow-xl ' + + 'flex items-center gap-3 ' + (big ? 'px-4 py-3' : 'px-3 py-2'); + card.style.borderLeft = '4px solid ' + accent; + card.style.opacity = '0'; + card.style.transform = 'translateY(12px)'; + card.style.transition = 'transform .35s cubic-bezier(.2,.8,.2,1), opacity .35s'; + + const iconSize = big ? 'w-10 h-10 text-xl' : 'w-8 h-8 text-base'; + const icon = '' + esc(opts.icon || '⭐') + ''; + + const reward = (opts.reward != null && Number(opts.reward) > 0) + ? '+' + + Number(opts.reward).toLocaleString() + ' dB' + : ''; + const body = '' + + '' + esc(opts.title || '') + '' + + (opts.message ? '' + esc(opts.message) + '' : '') + + reward + ''; + + card.innerHTML = icon + body; + host.insertBefore(card, host.firstChild); // newest on top of the stack + + // Animate in on the next frame (double rAF so the initial style applies). + requestAnimationFrame(() => requestAnimationFrame(() => { + card.style.opacity = '1'; + card.style.transform = 'translateY(0)'; + })); + + const dur = opts.durationMs || (big ? 5200 : 3200); + const dismiss = () => { + if (card._done) return; + card._done = true; + clearTimeout(card._t); + card.style.opacity = '0'; + card.style.transform = 'translateY(8px)'; + setTimeout(() => { try { card.remove(); } catch (e) { /* already gone */ } }, 360); + }; + card.addEventListener('click', dismiss); + card._t = setTimeout(dismiss, dur); + return card; + } + + function clear() { + const host = document.getElementById('fb-notify-stack'); + if (host) host.innerHTML = ''; + } + + window.fbNotify = { show: show, clear: clear }; + + // ── Progression wiring ──────────────────────────────────────────────────── + const sm = window.slopsmith; + if (!sm || typeof sm.on !== 'function') return; // no bus → toasts API still usable + + const periodLabel = (p) => (p === 'weekly' ? 'Weekly Quest' : p === 'daily' ? 'Daily Quest' : 'Quest'); + const pathName = (id, fallback) => { + const prog = (window.v3Progression && window.v3Progression.get()) || null; + const hit = ((prog && prog.paths) || []).find((p) => p && p.id === id); + return (hit && hit.name) || fallback || 'Path'; + }; + + // The bus delivers a CustomEvent; the progression payload is e.detail + // (matches every other window.slopsmith.on consumer, e.g. progress.js). + sm.on('progression:quest-progressed', (e) => { + const q = e && e.detail; + if (!q) return; + fbNotify.show({ + icon: '🎯', accent: '#3B82F6', + title: periodLabel(q.period_type) + ' advanced', + message: (q.title ? q.title + ' — ' : '') + q.count + '/' + q.target, + }); + }); + + sm.on('progression:quest-completed', (e) => { + const q = e && e.detail; + if (!q) return; + fbNotify.show({ + big: true, icon: '🏆', accent: '#FACC15', + title: periodLabel(q.period_type) + ' complete!', + message: q.title || '', reward: q.reward_db, + }); + }); + + sm.on('progression:path-progressed', (e) => { + const p = e && e.detail; + if (!p) return; + fbNotify.show({ + icon: '🎸', accent: '#22C55E', + title: (p.name || 'Path') + ' progress', + message: 'Challenge done — ' + p.completed + '/' + p.required + ' to Level ' + p.next_level, + }); + }); + + sm.on('progression:path-level-up', (e) => { + const l = e && e.detail; + if (!l) return; + fbNotify.show({ + big: true, icon: '⭐', accent: '#F97316', + title: pathName(l.path_id) + ' — Level ' + l.new_level + '!', + message: 'Instrument path leveled up', + }); + }); + + sm.on('progression:rank-changed', (e) => { + const r = e && e.detail; + // Celebrate rank-UPs only (a per-source reset can lower it). + if (!r || !(Number(r.to) > Number(r.from))) return; + fbNotify.show({ + big: true, icon: '🏅', accent: '#A855F7', + title: 'Mastery Rank ' + r.to + '!', + message: 'Your overall rank went up', + }); + }); +})(); diff --git a/static/v3/progression-core.js b/static/v3/progression-core.js index 1c1319c..1a6fdf5 100644 --- a/static/v3/progression-core.js +++ b/static/v3/progression-core.js @@ -14,9 +14,11 @@ * * Lifecycle events are emitted on the capability surface and mirrored on * window.slopsmith as `progression:*` for non-capability consumers: - * challenge-completed, quest-completed, path-level-up, rank-changed, - * db-changed, calibration-completed, cosmetic-equipped (+ progression:updated - * whenever fresh state lands). + * challenge-completed, quest-completed, quest-progressed, path-level-up, + * path-progressed, rank-changed, db-changed, calibration-completed, + * cosmetic-equipped (+ progression:updated whenever fresh state lands). + * quest-progressed / path-progressed are the partial-advance counterparts to + * the *-completed / *-level-up events (the achievement-toast feed). * * Vanilla JS, no framework (constitution P-II). */ @@ -37,6 +39,19 @@ } } + // Index quests by "period:id" so a refresh can be diffed against the last + // state (period_type isn't on the per-quest payload, so carry it here). + function _questIndex(state) { + const out = {}; + const quests = (state && state.quests) || {}; + ['daily', 'weekly'].forEach((period) => { + (((quests[period] || {}).quests) || []).forEach((item) => { + if (item && item.id != null) out[period + ':' + item.id] = { period, item }; + }); + }); + return out; + } + function _diff(prev, next) { if (!prev || !next) return; if (prev.mastery_rank !== next.mastery_rank) { @@ -45,6 +60,38 @@ const before = (prev.wallet || {}).balance; const after = (next.wallet || {}).balance; if (before !== after) _emit('db-changed', { from: before, to: after, wallet: next.wallet }); + + // Quest "advance" — a still-incomplete quest whose count rose since the + // last state. The increment that COMPLETES a quest is intentionally left + // to quest-completed (emitted from notify()'s summary) so a finished + // quest surfaces once, not twice. A period rollover (count resets to 0, + // or a brand-new quest id) produces no event. + const prevQuests = _questIndex(prev); + const nextQuests = _questIndex(next); + Object.keys(nextQuests).forEach((key) => { + const pq = prevQuests[key]; + const nq = nextQuests[key]; + if (pq && !nq.item.completed && Number(nq.item.count) > Number(pq.item.count)) { + _emit('quest-progressed', Object.assign({ period_type: nq.period }, nq.item)); + } + }); + + // Path "progress" — a challenge toward the next level completed + // (next.completed rose) WITHOUT a level-up. The level-up itself is + // emitted as path-level-up from notify()'s summary, so it surfaces once. + const prevPaths = {}; + (prev.paths || []).forEach((p) => { if (p && p.id != null) prevPaths[p.id] = p; }); + (next.paths || []).forEach((np) => { + const pp = prevPaths[np && np.id]; + if (!pp || !np.next || !pp.next) return; + if (np.level === pp.level && Number(np.next.completed) > Number(pp.next.completed)) { + _emit('path-progressed', { + id: np.id, name: np.name, level: np.level, + next_level: np.next.level, + completed: np.next.completed, required: np.next.required, + }); + } + }); } async function refresh() { @@ -135,7 +182,8 @@ ownership: 'exclusive-owner', safety: 'safe', commands: ['inspect', 'record-event', 'list-shop', 'buy-item', 'equip-item'], - events: ['challenge-completed', 'quest-completed', 'path-level-up', 'rank-changed', + events: ['challenge-completed', 'quest-completed', 'quest-progressed', + 'path-level-up', 'path-progressed', 'rank-changed', 'db-changed', 'calibration-completed', 'cosmetic-equipped'], description: 'Owns player progression: mastery rank, instrument-path challenges, daily/weekly quests, the Decibels wallet, and the cosmetics shop.', handlers: { diff --git a/tests/js/progression_notifications.test.js b/tests/js/progression_notifications.test.js new file mode 100644 index 0000000..8c6a04e --- /dev/null +++ b/tests/js/progression_notifications.test.js @@ -0,0 +1,121 @@ +// Contract tests for notifications.js: the fbNotify toast surface and the +// progression:* → toast wiring (period labels, celebratory vs subtle, path +// name lookup, rank-up-only guard). + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { ROOT } = require('./capabilities_test_harness'); + +const SRC = fs.readFileSync(path.join(ROOT, 'static', 'v3', 'notifications.js'), 'utf8'); + +// Minimal DOM: enough for createElement/append/insertBefore/getElementById and +// the inline-style/innerHTML the toast sets. +function fakeDom() { + function mkEl(tag) { + return { + tagName: tag, id: '', className: '', innerHTML: '', style: {}, + children: [], get firstChild() { return this.children[0] || null; }, + appendChild(c) { this.children.push(c); c.parent = this; return c; }, + insertBefore(c, ref) { + const i = ref ? this.children.indexOf(ref) : -1; + if (i < 0) this.children.push(c); else this.children.splice(i, 0, c); + c.parent = this; return c; + }, + remove() { const p = this.parent; if (p) p.children = p.children.filter((x) => x !== this); }, + addEventListener(type, fn) { (this._h || (this._h = {}))[type] = fn; }, + _text() { return (this.innerHTML || '').replace(/<[^>]*>/g, ''); }, + }; + } + const body = mkEl('body'); + const byId = (node, id) => { + if (node.id === id) return node; + for (const c of node.children) { const hit = byId(c, id); if (hit) return hit; } + return null; + }; + return { + body, + createElement: mkEl, + getElementById: (id) => byId(body, id), + }; +} + +function load(progressionState) { + const handlers = {}; + const sandbox = { + console, + setTimeout: () => 0, clearTimeout: () => {}, + requestAnimationFrame: (fn) => fn(), // run animation callbacks synchronously + }; + sandbox.window = sandbox; + sandbox.document = fakeDom(); + // Deliver a CustomEvent-like wrapper ({detail}), exactly as the real bus + // does (capabilities.js: bus.on → addEventListener, fn gets a CustomEvent). + // Test call sites pass the raw payload; the handler must unwrap e.detail. + sandbox.window.slopsmith = { on: (name, fn) => { handlers[name] = (payload) => fn({ detail: payload }); } }; + sandbox.window.v3Progression = { get: () => progressionState }; + vm.createContext(sandbox); + vm.runInContext(SRC, sandbox); + const stack = () => sandbox.document.getElementById('fb-notify-stack'); + return { sandbox, handlers, stack }; +} + +test('fbNotify.show renders a card with the title and message', () => { + const { sandbox, stack } = load(null); + assert.equal(typeof sandbox.window.fbNotify.show, 'function'); + sandbox.window.fbNotify.show({ title: 'Hello', message: 'World' }); + const cards = stack().children; + assert.equal(cards.length, 1); + assert.match(cards[0]._text(), /Hello/); + assert.match(cards[0]._text(), /World/); +}); + +test('quest-completed makes a celebratory toast with the period label and reward', () => { + const { handlers, stack } = load(null); + handlers['progression:quest-completed']({ id: 'q1', title: 'Play 3 songs', period_type: 'weekly', reward_db: 200 }); + const card = stack().children[0]; + assert.match(card._text(), /Weekly Quest complete!/); + assert.match(card._text(), /Play 3 songs/); + assert.match(card._text(), /\+200 dB/); +}); + +test('quest-progressed makes a subtle toast showing N/M and the daily label', () => { + const { handlers, stack } = load(null); + handlers['progression:quest-progressed']({ id: 'q1', title: 'Play 3 songs', period_type: 'daily', count: 2, target: 3 }); + assert.match(stack().children[0]._text(), /Daily Quest advanced/); + assert.match(stack().children[0]._text(), /2\/3/); +}); + +test('path-level-up resolves the path name from progression state', () => { + const { handlers, stack } = load({ paths: [{ id: 'guitar', name: 'Lead Guitar' }] }); + handlers['progression:path-level-up']({ path_id: 'guitar', new_level: 4 }); + assert.match(stack().children[0]._text(), /Lead Guitar — Level 4!/); +}); + +test('path-progressed shows path name and challenge count toward the next level', () => { + const { handlers, stack } = load(null); + handlers['progression:path-progressed']({ id: 'bass', name: 'Bass', completed: 2, required: 3, next_level: 2 }); + assert.match(stack().children[0]._text(), /Bass progress/); + assert.match(stack().children[0]._text(), /2\/3 to Level 2/); +}); + +test('rank-changed toasts on a rank up but not a rank drop', () => { + const up = load(null); + up.handlers['progression:rank-changed']({ from: 2, to: 3 }); + assert.equal(up.stack().children.length, 1); + assert.match(up.stack().children[0]._text(), /Mastery Rank 3!/); + + const down = load(null); + down.handlers['progression:rank-changed']({ from: 3, to: 2 }); + assert.equal(down.stack() ? down.stack().children.length : 0, 0); // no toast on a drop +}); + +test('newest toast is inserted on top of the stack', () => { + const { sandbox, stack } = load(null); + sandbox.window.fbNotify.show({ title: 'first' }); + sandbox.window.fbNotify.show({ title: 'second' }); + assert.match(stack().children[0]._text(), /second/); + assert.match(stack().children[1]._text(), /first/); +}); diff --git a/tests/js/progression_progress_events.test.js b/tests/js/progression_progress_events.test.js new file mode 100644 index 0000000..f7f1dcc --- /dev/null +++ b/tests/js/progression_progress_events.test.js @@ -0,0 +1,115 @@ +// Contract tests for progression-core's _diff(): the quest-progressed / +// path-progressed "advance" events that feed the achievement toasts, plus the +// guards that keep a completion / level-up from also firing a progress event. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { ROOT } = require('./capabilities_test_harness'); + +const SRC = fs.readFileSync(path.join(ROOT, 'static', 'v3', 'progression-core.js'), 'utf8'); + +// Load progression-core.js in a sandbox whose fetch returns `states` in order. +// Boot consumes states[0] (prev=null → no diff); each later refresh() diffs +// against the previous state. +function load(states) { + const events = []; + let i = 0; + const sandbox = { + console, + setTimeout, clearTimeout, + fetch: async () => ({ ok: true, json: async () => states[Math.min(i++, states.length - 1)] }), + }; + sandbox.window = sandbox; + sandbox.window.slopsmith = { emit: (name, detail) => events.push({ name, detail }) }; + sandbox.document = { readyState: 'complete', addEventListener: () => {} }; + vm.createContext(sandbox); + vm.runInContext(SRC, sandbox); + return { sandbox, events }; +} + +const stateA = { + mastery_rank: 2, + wallet: { balance: 100 }, + quests: { + daily: { quests: [ + { id: 'q1', title: 'Play 3 songs', count: 1, target: 3, completed: false, reward_db: 50 }, + { id: 'q2', title: 'Finish one', count: 0, target: 1, completed: false, reward_db: 20 }, + ] }, + weekly: { quests: [ + { id: 'w1', title: 'Weekly grind', count: 2, target: 10, completed: false, reward_db: 200 }, + ] }, + }, + paths: [ + { id: 'guitar', name: 'Guitar', level: 1, max_level: 10, next: { level: 2, required: 3, completed: 1 } }, + { id: 'bass', name: 'Bass', level: 0, max_level: 10, next: { level: 1, required: 2, completed: 0 } }, + ], +}; + +const stateB = { + mastery_rank: 3, // rank up + wallet: { balance: 170 }, // dB changed + quests: { + daily: { quests: [ + { id: 'q1', title: 'Play 3 songs', count: 2, target: 3, completed: false, reward_db: 50 }, // advanced + { id: 'q2', title: 'Finish one', count: 1, target: 1, completed: true, reward_db: 20 }, // COMPLETED + ] }, + weekly: { quests: [ + { id: 'w1', title: 'Weekly grind', count: 3, target: 10, completed: false, reward_db: 200 }, // advanced + ] }, + }, + paths: [ + { id: 'guitar', name: 'Guitar', level: 1, max_level: 10, next: { level: 2, required: 3, completed: 2 } }, // progressed + { id: 'bass', name: 'Bass', level: 1, max_level: 10, next: { level: 2, required: 3, completed: 0 } }, // LEVELED UP + ], +}; + +async function diffEvents() { + const { sandbox, events } = load([stateA, stateB]); + await sandbox.window.v3Progression.refresh(); // coalesces with boot → state = A + events.length = 0; // drop boot's progression:updated + await sandbox.window.v3Progression.refresh(); // state = B → _diff(A, B) + return events.filter((e) => e.name !== 'progression:updated'); +} + +test('quest advance emits quest-progressed with period_type, completion does not', async () => { + const ev = await diffEvents(); + const progressed = ev.filter((e) => e.name === 'progression:quest-progressed'); + const ids = progressed.map((e) => e.detail.id).sort(); + assert.deepEqual(ids, ['q1', 'w1']); // q2 completed → not a progress event + const q1 = progressed.find((e) => e.detail.id === 'q1').detail; + assert.equal(q1.period_type, 'daily'); + assert.equal(q1.count, 2); + assert.equal(q1.target, 3); + const w1 = progressed.find((e) => e.detail.id === 'w1').detail; + assert.equal(w1.period_type, 'weekly'); +}); + +test('path challenge progress emits path-progressed; a level-up does not', async () => { + const ev = await diffEvents(); + const progressed = ev.filter((e) => e.name === 'progression:path-progressed'); + assert.equal(progressed.length, 1); + const g = progressed[0].detail; + assert.equal(g.id, 'guitar'); + assert.equal(g.name, 'Guitar'); + assert.equal(g.completed, 2); + assert.equal(g.required, 3); + assert.equal(g.next_level, 2); + // bass leveled up (level 0 → 1) → handled by path-level-up, not path-progressed. + assert.ok(!progressed.some((e) => e.detail.id === 'bass')); +}); + +test('rank-up and dB change still emit their events', async () => { + const ev = await diffEvents(); + const rank = ev.find((e) => e.name === 'progression:rank-changed'); + assert.ok(rank && rank.detail.from === 2 && rank.detail.to === 3); + assert.ok(ev.some((e) => e.name === 'progression:db-changed')); +}); + +test('no progress events fire on the very first state (prev=null)', async () => { + const { sandbox, events } = load([stateA]); + await sandbox.window.v3Progression.refresh(); + assert.ok(!events.some((e) => /progressed|changed/.test(e.name))); +});