diff --git a/index.html b/index.html index 982f5e7..563f7b2 100644 --- a/index.html +++ b/index.html @@ -40,6 +40,7 @@

影伴

+
diff --git a/src/app.css b/src/app.css index fe2bdbd..2e9fc47 100644 --- a/src/app.css +++ b/src/app.css @@ -313,6 +313,22 @@ .growth-history-list .pts{font-weight:800;flex:0 0 auto;} .growth-history-list .pts.pos{color:var(--green-deep);} .growth-history-list .pts.neg{color:#d9534f;} + .reward-fulfill{width:auto;padding:8px 10px;font-size:12px;background:#ffd25e;color:#5b3e00;} + .reward-pill{flex:0 0 auto;} + .sound-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 0;border-bottom:1px solid var(--line);} + .sound-row:last-child{border-bottom:none;} + .sound-label{font-size:13.5px;font-weight:800;color:var(--ink);} + .sound-switch{min-width:52px;padding:7px 12px;border-radius:999px;border:1px solid var(--line);background:#e8ece8;color:var(--ink-soft);font-family:inherit;font-size:13px;font-weight:800;cursor:pointer;transition:background-color .15s ease,color .15s ease,transform .15s ease;} + .sound-switch:active{transform:scale(.96);} + .sound-switch.on{background:var(--green);color:#fff;border-color:var(--green);} + .sound-range{flex:1;max-width:220px;accent-color:var(--green);} + .sound-events{display:grid;gap:10px;} + .sound-event{border:1px solid var(--line);border-radius:14px;padding:10px 12px;display:grid;gap:8px;} + .sound-event-head{display:flex;align-items:center;justify-content:space-between;gap:10px;} + .sound-event-controls{display:flex;align-items:center;gap:8px;} + .sound-event-controls select{flex:1;min-width:0;border:1px solid var(--line);border-radius:10px;background:var(--card);color:var(--ink);padding:7px 8px;font:inherit;font-size:13px;} + .sound-event-controls select:disabled{opacity:.5;} + .sound-preview{width:auto;padding:7px 12px;font-size:12px;flex:0 0 auto;} @media (max-width:360px){ .growth-form{grid-template-columns:1fr;} .growth-form .checkin{width:100%;} diff --git a/src/app.js b/src/app.js index 0b5ce4e..004d476 100644 --- a/src/app.js +++ b/src/app.js @@ -31,6 +31,7 @@ import { createIndexedDbLearningDb } from "./learning-local-db.js"; import { createGrowthLoopController } from "./learning-growth-loop-controller.js"; import { ACTIVITY_EVENT_TYPES, activityEventIdFor } from "./learning-analytics.js"; import { buildLegacyPointEntries, getActivePointAction, getBalance, getLegacyPeriodTotal, getLegacyPointsImport, getOpeningBalance, getPointDayTotal, getPointPeriodTotal } from "./learning-growth-loop.js"; +import { createSoundEngine, SOUND_EVENTS, SOUND_EVENT_KEYS } from "./learning-sounds.js"; inject(); installRapidActionGuard(document); @@ -141,7 +142,14 @@ const PEANUT_BOOKS = [ const STORE_KEY = "shadow_mate_workbench_v1"; const growthLoopDb = createIndexedDbLearningDb(); -const growthLoopController = createGrowthLoopController({ db: growthLoopDb }); +const soundEffects = createSoundEngine(); +window.soundEffects = soundEffects; +const growthLoopController = createGrowthLoopController({ + db: growthLoopDb, + onRewardFulfilled: ({ redemption }) => { + if (redemption?.status === "fulfilled") soundEffects.play("reward_fulfilled"); + }, +}); let growthLoopSnapshot = growthLoopController.getSnapshot(); let CURRENT_MOD = "home"; window.growthLoop = growthLoopController; @@ -271,6 +279,7 @@ function toggleCheckin(mod){ key: mod, }); save(); + if (isChecked(mod)) soundEffects.play("action_completed"); void queueGrowthActivity( ACTIVITY_EVENT_TYPES.GROWTH_ACTIVITY_RECORDED, { source: "checkin", entry_type: "manual" }, @@ -325,6 +334,8 @@ function togglePoint(itemId, day){ const item = pointItemAt(itemId); if(!item) return; const requestId = clientRequestId("point"); + const wasOn = pointOn(itemId, day); + const delta = Number(item.default_points ?? item.pts); void window.growthLoop.recordPoint({ item, occurred_on: dateKeyForDay(day), request_id: requestId }).then(() => { void queueGrowthActivity( ACTIVITY_EVENT_TYPES.GROWTH_ACTIVITY_RECORDED, @@ -334,6 +345,13 @@ function togglePoint(itemId, day){ void queueGrowthActivity(ACTIVITY_EVENT_TYPES.CORE_ACTIVATION, { source: "point_item" }, "once"); window.cloudSync?.scheduleGrowthLoop?.(); renderPoints(); + if (wasOn) { + soundEffects.play("try_again"); + } else if (delta < 0) { + soundEffects.play("points_deducted"); + } else { + soundEffects.play("points_earned"); + } }).catch((error) => { console.error("Growth Loop local point write failed:", error); alert("本机记录没有保存成功,请稍后重试。"); @@ -437,6 +455,7 @@ function stopActivePlayback() { } async function speak(t, button){ + soundEffects.setTtsActive(true); const originalLabel = button?.dataset.label || "听发音"; const voiceHelp = "请在系统设置中安装英语语音包,然后重试"; const showSpeechGuide = () => { @@ -453,6 +472,7 @@ async function speak(t, button){ button.after(guideLink); }; const restore = () => { + soundEffects.setTtsActive(false); clearSystemTimer(); if (!button) return; button.innerHTML = buttonContent("volume", originalLabel); @@ -892,8 +912,8 @@ function renderMath(){ const v = el("qa").value; const f = el("qf"); if(v===""){ f.textContent="请先写出答案哦"; f.className="feedback no"; return; } - if(+v===mathAns){ f.innerHTML=`${icon("party")} 答对啦,真棒!`; f.className="feedback ok"; } - else { f.textContent=`再想想~正确答案是 ${mathAns}`; f.className="feedback no"; } + if(+v===mathAns){ f.innerHTML=`${icon("party")} 答对啦,真棒!`; f.className="feedback ok"; soundEffects.play("action_completed"); } + else { f.textContent=`再想想~正确答案是 ${mathAns}`; f.className="feedback no"; soundEffects.play("try_again"); } }; // 数感:数字填写 1-100 找缺失 @@ -1270,11 +1290,19 @@ function renderGrow(){ .filter((item) => item.reward_id === reward.id) .sort((left, right) => String(right.created_at || "").localeCompare(String(left.created_at || "")))[0]; const status = latest?.status === "pending" ? "待联网确认" : latest?.status === "fulfilled" ? "已兑现" : ""; + const canFulfill = latest?.status === "pending" && latest?.confirmed && !latest?.fulfill_requested; + const fulfillRequested = latest?.status === "pending" && latest?.confirmed && latest?.fulfill_requested; + const fulfillAction = canFulfill + ? `` + : fulfillRequested + ? `兑现已请求` + : ""; return `
${icon(reward.icon_key || "gift")}
${escapeHtml(reward.name)}${escapeHtml(reward.description || "家长和孩子一起约定")}
${cost}分 + ${fulfillAction}
`; }).join(""); const rewardCard = $(`
@@ -1328,6 +1356,19 @@ function renderGrow(){ renderGrow(); }; }); + rewardCard.querySelectorAll("[data-fulfill-id]").forEach((button) => { + button.onclick = async () => { + button.disabled = true; + const result = await window.growthLoop.fulfillRedemption({ redemption_id: button.dataset.fulfillId }); + if (result.error) { + button.disabled = false; + alert("这个奖励暂时不能兑现,请稍后重试。"); + return; + } + window.cloudSync?.scheduleGrowthLoop?.(); + renderGrow(); + }; + }); const historyEntries = growthLoopSnapshot.ledger .filter((entry) => !["rejected", "conflict"].includes(entry.status)) @@ -1711,6 +1752,82 @@ function checkinBtn(mod,label){ return ``; } +/* ========================================================= + 渲染:音效设置(设备级,仅本机) + ========================================================= */ +function renderSettings(){ + const main = el("main"); main.innerHTML=""; + main.appendChild(modTitle("settings","音效设置")); + const settings = soundEffects.getSettings(); + main.appendChild($(` +
+

${icon("volume")} 音效总开关与音量

+
+ 启用界面音效 + +
+
+ 总音量 ${Math.round(settings.volume*100)}% + +
+
+ `)); + const eventsCard = $(`

${icon("list")} 事件音效

`); + main.appendChild(eventsCard); + const list = eventsCard.querySelector(".sound-events"); + for (const key of SOUND_EVENT_KEYS) { + const def = SOUND_EVENTS[key]; + const eventSettings = settings.events[key]; + const variantOptions = Object.entries(def.variants).map(([variantKey, variant]) => + `` + ).join(""); + list.appendChild($(` +
+
+ ${def.label} + +
+
+ + +
+
+ `)); + } + main.appendChild($(` +
+ +
恢复为默认的总开关、音量与每个事件的变体选择。
+
+ `)); + main.appendChild($(``)); + + el("snd-master").onclick = () => { + soundEffects.setEnabled(!soundEffects.getSettings().enabled); + renderSettings(); + }; + el("snd-volume").oninput = (event) => { + soundEffects.setVolume(Number(event.target.value) / 100); + const label = el("snd-volume-label"); + if (label) label.textContent = `总音量 ${Math.round(soundEffects.getSettings().volume*100)}%`; + }; + el("snd-reset").onclick = () => { + soundEffects.resetDefaults(); + renderSettings(); + }; + main.querySelectorAll("[data-event-enable]").forEach((button) => button.onclick = () => { + soundEffects.setEventEnabled(button.dataset.eventEnable, !soundEffects.getSettings().events[button.dataset.eventEnable].enabled); + renderSettings(); + }); + main.querySelectorAll("[data-event-variant]").forEach((select) => select.onchange = () => { + soundEffects.setEventVariant(select.dataset.eventVariant, select.value); + renderSettings(); + }); + main.querySelectorAll("[data-event-preview]").forEach((button) => button.onclick = () => { + soundEffects.preview(button.dataset.eventPreview); + }); +} + /* ========================================================= 导航切换 ========================================================= */ @@ -1731,6 +1848,7 @@ function switchMod(mod){ else if(mod==="points") renderPoints(); else if(mod==="grow") renderGrow(); else if(mod==="guide") renderGuide(); + else if(mod==="settings") renderSettings(); // 绑定打卡按钮 el("main").querySelectorAll("[data-cmod]").forEach(btn=>{ btn.onclick=()=>{ diff --git a/src/icons.js b/src/icons.js index 9f5abc7..5944294 100644 --- a/src/icons.js +++ b/src/icons.js @@ -38,6 +38,7 @@ import { Plus, RefreshCw, RotateCcw, + Settings, Sprout, Star, Trash2, @@ -88,6 +89,7 @@ const ICONS = { rotate: RotateCcw, sprout: Sprout, star: Star, + settings: Settings, trash: Trash2, trophy: Trophy, learner: UserRound, diff --git a/src/learning-growth-loop-controller.js b/src/learning-growth-loop-controller.js index 5655892..5c5167c 100644 --- a/src/learning-growth-loop-controller.js +++ b/src/learning-growth-loop-controller.js @@ -65,7 +65,7 @@ function rebindSnapshotScope(input, scope) { return next; } -export function createGrowthLoopController({ db } = {}) { +export function createGrowthLoopController({ db, onRewardFulfilled = null } = {}) { if (!db) throw new Error("growth_loop_local_db_required"); let scope = { household_id: null, profile_id: null }; let scopeKey = scopeKeyForGrowthLoop(scope); @@ -220,6 +220,30 @@ export function createGrowthLoopController({ db } = {}) { return clone(snapshot); } + async function fulfillRedemption({ redemption_id, request_id = createId() } = {}) { + const redemption = snapshot.redemptions.find((entry) => entry.id === redemption_id); + if (!redemption) return { ...clone(snapshot), error: "redemption_not_found" }; + if (redemption.status !== "pending" || redemption.fulfill_requested) { + return { ...clone(snapshot), error: "redemption_not_pending" }; + } + const next = normalizeGrowthLoopState(snapshot, scope); + const row = next.redemptions.find((entry) => entry.id === redemption_id); + row.fulfill_requested = true; + await db.appendOutbox({ + event_id: createId(), + request_id, + scope_key: scopeKey, + household_id: scope.household_id, + profile_id: scope.profile_id, + type: "redemption_fulfill", + payload: { redemption_id }, + }); + await db.putSnapshot(scopeKey, next); + snapshot = next; + notify(); + return clone(snapshot); + } + async function queueActivity({ event_type, payload = {}, occurred_at, client_version, timezone, event_id }) { const event = buildActivityEvent({ event_type, @@ -283,9 +307,19 @@ export function createGrowthLoopController({ db } = {}) { const redemption = next.redemptions.find((entry) => entry.request_id === event.request_id); if (redemption) { Object.assign(redemption, remote || {}, { status: remote?.status || "pending" }); + if (remote?.id) redemption.confirmed = true; const debit = next.ledger.find((entry) => entry.redemption_id === redemption.id); if (debit && remote?.id) debit.redemption_id = remote.id; } + } else if (event.type === "redemption_fulfill") { + const redemption = next.redemptions.find((entry) => entry.id === event.payload.redemption_id); + if (redemption && remote?.status === "fulfilled") { + const alreadyFulfilled = redemption.status === "fulfilled"; + Object.assign(redemption, remote, { status: "fulfilled", confirmed: true }); + if (!alreadyFulfilled && typeof onRewardFulfilled === "function") { + onRewardFulfilled({ redemption: clone(redemption) }); + } + } } await db.putSnapshot(scopeKey, next); snapshot = next; @@ -394,6 +428,7 @@ export function createGrowthLoopController({ db } = {}) { closePeriod, createReward, redeemReward, + fulfillRedemption, queueActivity, mergeRemote, sync, diff --git a/src/learning-sounds.js b/src/learning-sounds.js new file mode 100644 index 0000000..2612cd4 --- /dev/null +++ b/src/learning-sounds.js @@ -0,0 +1,465 @@ +/* 影伴品牌音效引擎:Web Audio 实时合成 + 设备级设置 + * + * - 五类事件固定配方(不随机生成),每类 2~3 个内置变体。 + * - 设置仅保存在当前设备(localStorage),不同步云端。 + * - 播放边界:同操作只播最高优先级、不叠加节流、TTS 互斥、 + * 浏览器阻止/音频不可用时静默降级,绝不影响业务写入。 + * - 默认音量克制;"再试一次"与"扣除积分"不使用强烈负向声音。 + */ + +const STORAGE_KEY = "shadow_mate_sound_settings_v1"; +const REPEAT_THROTTLE_MS = 300; +const PLAY_GAP_MS = 60; + +export const SOUND_EVENT_KEYS = [ + "action_completed", + "points_earned", + "try_again", + "points_deducted", + "reward_fulfilled", +]; + +export const SOUND_EVENTS = { + action_completed: { + label: "完成行动", + priority: 3, + defaultVariant: "block_click", + variants: { + block_click: { + name: "积木咔嗒", + recipe: { + maxDurationMs: 220, + notes: [ + { t: 0, dur: 80, freq: 520, wave: "triangle", gain: 0.5, filter: { type: "bandpass", freq: 2000, q: 1.1 } }, + { t: 70, dur: 120, freq: 700, wave: "triangle", gain: 0.55, filter: { type: "bandpass", freq: 2200, q: 1.1 } }, + ], + }, + }, + little_rise: { + name: "小小上扬", + recipe: { + maxDurationMs: 180, + notes: [ + { t: 0, dur: 70, freq: 660, wave: "sine", gain: 0.4 }, + { t: 55, dur: 100, freq: 880, wave: "sine", gain: 0.45 }, + ], + }, + }, + bubble_done: { + name: "泡泡完成", + recipe: { + maxDurationMs: 230, + notes: [ + { t: 0, dur: 90, freq: 587.33, wave: "sine", gain: 0.42 }, + { t: 90, dur: 120, freq: 783.99, wave: "sine", gain: 0.5 }, + ], + }, + }, + }, + }, + points_earned: { + label: "获得积分", + priority: 4, + defaultVariant: "star_collect", + variants: { + star_collect: { + name: "星星收集", + recipe: { + maxDurationMs: 330, + notes: [ + { t: 0, dur: 90, freq: 523.25, wave: "triangle", gain: 0.55 }, + { t: 90, dur: 90, freq: 659.25, wave: "triangle", gain: 0.6 }, + { t: 180, dur: 130, freq: 783.99, wave: "triangle", gain: 0.65 }, + ], + }, + }, + flash_twin: { + name: "闪光两连", + recipe: { + maxDurationMs: 280, + notes: [ + { t: 0, dur: 110, freq: 880, wave: "triangle", gain: 0.55 }, + { t: 100, dur: 150, freq: 1318.51, wave: "triangle", gain: 0.62 }, + ], + }, + }, + star_triple: { + name: "星光三连", + recipe: { + maxDurationMs: 300, + notes: [ + { t: 0, dur: 80, freq: 659.25, wave: "triangle", gain: 0.5 }, + { t: 80, dur: 80, freq: 880, wave: "triangle", gain: 0.55 }, + { t: 160, dur: 120, freq: 1174.66, wave: "triangle", gain: 0.6 }, + ], + }, + }, + }, + }, + try_again: { + label: "再试一次", + priority: 2, + defaultVariant: "bubble_bounce", + variants: { + bubble_bounce: { + name: "泡泡回弹", + recipe: { + maxDurationMs: 220, + notes: [ + { t: 0, dur: 130, freq: [392, 500], wave: "sine", gain: 0.4 }, + { t: 110, dur: 90, freq: 440, wave: "sine", gain: 0.25 }, + ], + }, + }, + gentle_nudge: { + name: "轻轻提醒", + recipe: { + maxDurationMs: 200, + notes: [ + { t: 0, dur: 80, freq: 523.25, wave: "sine", gain: 0.32 }, + { t: 90, dur: 100, freq: 587.33, wave: "sine", gain: 0.3 }, + ], + }, + }, + small_step: { + name: "小步再来", + recipe: { + maxDurationMs: 240, + notes: [ + { t: 0, dur: 70, freq: 392, wave: "sine", gain: 0.34 }, + { t: 70, dur: 80, freq: 466.16, wave: "sine", gain: 0.34 }, + { t: 140, dur: 90, freq: 523.25, wave: "sine", gain: 0.3 }, + ], + }, + }, + }, + }, + points_deducted: { + label: "扣除积分", + priority: 1, + defaultVariant: "soft_reminder", + variants: { + soft_reminder: { + name: "柔和提醒", + recipe: { + maxDurationMs: 220, + notes: [ + { t: 0, dur: 90, freq: 329.63, wave: "sine", gain: 0.3 }, + { t: 90, dur: 120, freq: 293.66, wave: "sine", gain: 0.28 }, + ], + }, + }, + slow_down: { + name: "慢一点", + recipe: { + maxDurationMs: 270, + notes: [ + { t: 0, dur: 110, freq: 349.23, wave: "sine", gain: 0.3 }, + { t: 120, dur: 140, freq: 311.13, wave: "sine", gain: 0.28 }, + ], + }, + }, + soft_wooden: { + name: "轻声木鱼", + recipe: { + maxDurationMs: 170, + notes: [ + { t: 0, dur: 60, freq: 660, wave: "triangle", gain: 0.3, filter: { type: "bandpass", freq: 1800, q: 1.3 } }, + { t: 90, dur: 60, freq: 660, wave: "triangle", gain: 0.26, filter: { type: "bandpass", freq: 1800, q: 1.3 } }, + ], + }, + }, + }, + }, + reward_fulfilled: { + label: "奖励已兑现", + priority: 5, + defaultVariant: "squad_cheer", + variants: { + squad_cheer: { + name: "小队庆祝", + recipe: { + maxDurationMs: 680, + notes: [ + { t: 0, dur: 110, freq: 523.25, wave: "triangle", gain: 0.5 }, + { t: 100, dur: 110, freq: 659.25, wave: "triangle", gain: 0.52 }, + { t: 200, dur: 110, freq: 783.99, wave: "triangle", gain: 0.55 }, + { t: 300, dur: 130, freq: 1046.5, wave: "triangle", gain: 0.6 }, + { t: 480, dur: 180, freq: 523.25, wave: "sine", gain: 0.28 }, + { t: 480, dur: 180, freq: 783.99, wave: "sine", gain: 0.28 }, + { t: 480, dur: 180, freq: 1046.5, wave: "sine", gain: 0.28 }, + ], + }, + }, + chest_open: { + name: "宝箱打开", + recipe: { + maxDurationMs: 560, + notes: [ + { t: 0, dur: 110, freq: 392, wave: "triangle", gain: 0.5 }, + { t: 100, dur: 110, freq: 523.25, wave: "triangle", gain: 0.55 }, + { t: 200, dur: 120, freq: 659.25, wave: "triangle", gain: 0.58 }, + { t: 320, dur: 220, freq: 523.25, wave: "sine", gain: 0.26 }, + { t: 320, dur: 220, freq: 659.25, wave: "sine", gain: 0.26 }, + { t: 320, dur: 220, freq: 783.99, wave: "sine", gain: 0.26 }, + ], + }, + }, + finish_chord: { + name: "完成和弦", + recipe: { + maxDurationMs: 500, + notes: [ + { t: 0, dur: 420, freq: 523.25, wave: "sine", gain: 0.26 }, + { t: 0, dur: 420, freq: 659.25, wave: "sine", gain: 0.26 }, + { t: 0, dur: 420, freq: 783.99, wave: "sine", gain: 0.26 }, + { t: 180, dur: 200, freq: 1046.5, wave: "triangle", gain: 0.3 }, + ], + }, + }, + }, + }, +}; + +export function normalizeSettings(input = {}) { + const events = {}; + for (const key of SOUND_EVENT_KEYS) { + const def = SOUND_EVENTS[key]; + const raw = input?.events?.[key] || {}; + events[key] = { + enabled: raw.enabled !== false, + variant: def.variants[raw.variant] ? raw.variant : def.defaultVariant, + }; + } + const rawVolume = Number(input?.volume); + return { + schema_version: 1, + enabled: input?.enabled !== false, + volume: Number.isFinite(rawVolume) ? Math.max(0, Math.min(1, rawVolume)) : 0.6, + events, + }; +} + +export const DEFAULT_SETTINGS = normalizeSettings({}); + +function cloneSettings(settings) { + return structuredClone(settings); +} + +function loadSettings(store) { + if (!store) return cloneSettings(DEFAULT_SETTINGS); + try { + const raw = JSON.parse(store.getItem(STORAGE_KEY) || "null"); + return normalizeSettings(raw); + } catch (_) { + return cloneSettings(DEFAULT_SETTINGS); + } +} + +function saveSettings(store, settings) { + if (!store) return; + try { + store.setItem(STORAGE_KEY, JSON.stringify(settings)); + } catch (_) { + // 存储不可用(隐私模式/配额)时静默降级为仅本次会话有效。 + } +} + +let sharedContext = null; + +function defaultGetAudioContext() { + if (typeof window === "undefined") return null; + const Ctor = window.AudioContext || window.webkitAudioContext; + if (typeof Ctor !== "function") return null; + if (!sharedContext || sharedContext.state === "closed") { + try { + sharedContext = new Ctor(); + } catch (_) { + sharedContext = null; + } + } + return sharedContext; +} + +export function renderRecipe(recipe, { volume = 1, getAudioContext = defaultGetAudioContext } = {}) { + const ctx = getAudioContext(); + if (!ctx) return null; + try { + if (typeof ctx.resume === "function" && ctx.state === "suspended") { + try { + void ctx.resume(); + } catch (_) { + // 用户手势之外被暂停时,等待下次手势即可。 + } + } + const master = ctx.createGain(); + master.gain.value = Math.max(0, Math.min(1, volume)); + master.connect(ctx.destination); + for (const note of recipe.notes || []) { + const start = ctx.currentTime + (note.t || 0) / 1000; + const duration = Math.max(0.03, (note.dur || 100) / 1000); + const osc = ctx.createOscillator(); + osc.type = note.wave || "sine"; + const freq = note.freq; + osc.frequency.setValueAtTime(Array.isArray(freq) ? freq[0] : freq, start); + if (Array.isArray(freq) && freq.length === 2) { + osc.frequency.exponentialRampToValueAtTime(Math.max(1, freq[1]), start + duration); + } + const envelope = ctx.createGain(); + const peak = Math.max(0, Math.min(1, note.gain || 0.3)); + const attack = Math.max(0.001, (note.attack || 5) / 1000); + envelope.gain.setValueAtTime(0.0001, start); + envelope.gain.linearRampToValueAtTime(peak, start + attack); + envelope.gain.exponentialRampToValueAtTime(0.0001, start + duration); + let output = envelope; + if (note.filter) { + const filter = ctx.createBiquadFilter(); + filter.type = note.filter.type || "bandpass"; + filter.frequency.value = note.filter.freq || 1200; + filter.Q.value = note.filter.q ?? 1; + envelope.connect(filter); + output = filter; + } + osc.connect(envelope); + output.connect(master); + osc.start(start); + osc.stop(start + duration + 0.05); + } + return Number(recipe.maxDurationMs) || 0; + } catch (_) { + // 音频上下文异常/浏览器阻止播放:静默降级,不向业务抛出。 + return null; + } +} + +export function createSoundEngine({ + storage = null, + now = () => Date.now(), + getAudioContext = defaultGetAudioContext, + render = renderRecipe, +} = {}) { + const store = storage || (typeof window !== "undefined" ? window.localStorage : null); + let settings = loadSettings(store); + let ttsActive = false; + let activeUntil = 0; + const lastPlayedAt = Object.fromEntries(SOUND_EVENT_KEYS.map((key) => [key, -Infinity])); + + function persist() { + saveSettings(store, settings); + } + + function getSettings() { + return cloneSettings(settings); + } + + function setEnabled(value) { + settings = { ...settings, enabled: Boolean(value) }; + persist(); + } + + function setVolume(value) { + const volume = Number(value); + settings = { ...settings, volume: Number.isFinite(volume) ? Math.max(0, Math.min(1, volume)) : settings.volume }; + persist(); + } + + function setEventEnabled(event, value) { + if (!SOUND_EVENTS[event]) return; + settings = { + ...settings, + events: { ...settings.events, [event]: { ...settings.events[event], enabled: Boolean(value) } }, + }; + persist(); + } + + function setEventVariant(event, variant) { + if (!SOUND_EVENTS[event] || !SOUND_EVENTS[event].variants[variant]) return; + settings = { + ...settings, + events: { ...settings.events, [event]: { ...settings.events[event], variant } }, + }; + persist(); + } + + function resetDefaults() { + settings = cloneSettings(DEFAULT_SETTINGS); + persist(); + } + + function setTtsActive(value) { + ttsActive = Boolean(value); + } + + function isTtsActive() { + return ttsActive; + } + + function resolveRecipe(event, variantOverride = null) { + const def = SOUND_EVENTS[event]; + if (!def) return null; + const variantKey = variantOverride || settings.events[event]?.variant || def.defaultVariant; + const variant = def.variants[variantKey] || def.variants[def.defaultVariant]; + return { recipe: variant.recipe, variantKey }; + } + + function play(event, { force = false } = {}) { + const def = SOUND_EVENTS[event]; + if (!def) return { played: false, reason: "unknown_event" }; + const eventSettings = settings.events[event] || DEFAULT_SETTINGS.events[event]; + if (!force) { + if (!settings.enabled) return { played: false, reason: "master_disabled" }; + if (!eventSettings.enabled) return { played: false, reason: "event_disabled" }; + if (ttsActive) return { played: false, reason: "tts_active" }; + const time = now(); + if (time - lastPlayedAt[event] < REPEAT_THROTTLE_MS) return { played: false, reason: "throttled" }; + if (time < activeUntil) return { played: false, reason: "busy" }; + } + const resolved = resolveRecipe(event); + if (!resolved) return { played: false, reason: "no_recipe" }; + const duration = render(resolved.recipe, { volume: settings.volume, getAudioContext }); + if (duration === null) return { played: false, reason: "unavailable" }; + if (!force) { + const time = now(); + lastPlayedAt[event] = time; + activeUntil = time + duration + PLAY_GAP_MS; + } + return { played: true, duration, event }; + } + + function preview(event, { variant = null, force = false } = {}) { + const def = SOUND_EVENTS[event]; + if (!def) return { played: false, reason: "unknown_event" }; + if (!force && ttsActive) return { played: false, reason: "tts_active" }; + const resolved = resolveRecipe(event, variant); + if (!resolved) return { played: false, reason: "no_recipe" }; + const duration = render(resolved.recipe, { volume: settings.volume, getAudioContext }); + if (duration === null) return { played: false, reason: "unavailable" }; + return { played: true, duration, event, variant: resolved.variantKey }; + } + + function playPriority(events, options = {}) { + const candidates = [...events].filter((event) => SOUND_EVENTS[event]); + if (!candidates.length) return { played: false, reason: "unknown_event" }; + const ranked = candidates.slice().sort((a, b) => SOUND_EVENTS[b].priority - SOUND_EVENTS[a].priority); + for (const event of ranked) { + const result = play(event, options); + if (result.played) return result; + if (result.reason === "unavailable" || result.reason === "unknown_event") break; + } + return { played: false, reason: "none_playable" }; + } + + return { + getSettings, + setEnabled, + setVolume, + setEventEnabled, + setEventVariant, + resetDefaults, + setTtsActive, + isTtsActive, + play, + preview, + playPriority, + }; +} diff --git a/tests/e2e/sounds.spec.js b/tests/e2e/sounds.spec.js new file mode 100644 index 0000000..e9a87de --- /dev/null +++ b/tests/e2e/sounds.spec.js @@ -0,0 +1,146 @@ +import { test, expect } from "@playwright/test"; + +const STORAGE_KEY = "shadow_mate_sound_settings_v1"; + +test.describe("Sound effects settings and playback", () => { + test.use({ serviceWorkers: "block" }); + + // 在真实引擎上包一层记录器:既保留真实播放行为,又记录调用与结果。 + async function installPlaySpies(page) { + await page.evaluate(() => { + window.__soundCalls = []; + const engine = window.soundEffects; + for (const method of ["play", "preview", "playPriority"]) { + const original = engine[method].bind(engine); + engine[method] = (...args) => { + const result = original(...args); + window.__soundCalls.push({ method, args, result }); + return result; + }; + } + }); + } + + function playedEvents(page, method = "play") { + return page.evaluate(({ method }) => ( + (window.__soundCalls || []) + .filter((call) => call.method === method && call.result?.played) + .map((call) => call.args[0]) + ), { method }); + } + + async function storedSettings(page) { + return page.evaluate((key) => JSON.parse(localStorage.getItem(key)), STORAGE_KEY); + } + + test("renders the sound settings page with the five fixed events", async ({ page }) => { + await page.goto("/"); + await page.click('[data-mod="settings"]'); + await expect(page.locator("#snd-master")).toBeVisible(); + await expect(page.locator("#snd-volume")).toBeVisible(); + await expect(page.locator("#snd-reset")).toBeVisible(); + await expect(page.locator("[data-event]")).toHaveCount(5); + for (const key of ["action_completed", "points_earned", "try_again", "points_deducted", "reward_fulfilled"]) { + await expect(page.locator(`[data-event-preview="${key}"]`)).toBeVisible(); + } + }); + + test("previews each of the five events", async ({ page }) => { + await page.goto("/"); + await page.click('[data-mod="settings"]'); + await installPlaySpies(page); + for (const key of ["action_completed", "points_earned", "try_again", "points_deducted", "reward_fulfilled"]) { + await page.locator(`[data-event-preview="${key}"]`).click(); + } + const previewed = await page.evaluate(() => ( + (window.__soundCalls || []).filter((call) => call.method === "preview").map((call) => call.args[0]) + )); + expect(previewed).toEqual(["action_completed", "points_earned", "try_again", "points_deducted", "reward_fulfilled"]); + }); + + test("master switch and volume persist on this device only", async ({ page }) => { + await page.goto("/"); + await page.click('[data-mod="settings"]'); + await page.locator("#snd-master").click(); + expect((await storedSettings(page)).enabled).toBe(false); + + await page.locator("#snd-master").click(); + await page.locator("#snd-volume").evaluate((input) => { + input.value = "30"; + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + const saved = await storedSettings(page); + expect(saved.enabled).toBe(true); + expect(saved.volume).toBe(0.3); + await expect(page.locator("#snd-volume-label")).toHaveText("总音量 30%"); + }); + + test("event toggle and variant selection persist", async ({ page }) => { + await page.goto("/"); + await page.click('[data-mod="settings"]'); + await page.locator('[data-event-enable="points_earned"]').click(); + expect((await storedSettings(page)).events.points_earned.enabled).toBe(false); + + await page.locator('[data-event-enable="points_earned"]').click(); + await page.locator('[data-event-variant="points_earned"]').selectOption("star_triple"); + const saved = await storedSettings(page); + expect(saved.events.points_earned.enabled).toBe(true); + expect(saved.events.points_earned.variant).toBe("star_triple"); + }); + + test("resets all sound settings to defaults", async ({ page }) => { + await page.goto("/"); + await page.click('[data-mod="settings"]'); + await page.locator("#snd-volume").evaluate((input) => { + input.value = "20"; + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await page.locator("#snd-master").click(); + await page.locator("#snd-reset").click(); + const settings = await page.evaluate(() => window.soundEffects.getSettings()); + expect(settings.enabled).toBe(true); + expect(settings.volume).toBe(0.6); + expect(settings.events.points_earned.variant).toBe("star_collect"); + }); + + test("plays positive, deducted and retry sounds for point actions", async ({ page }) => { + await page.goto("/"); + await installPlaySpies(page); + await page.click('[data-mod="points"]'); + + // 创建正分项并加分 → points_earned + await page.fill('#pointItemForm input[name="name"]', "自己刷牙"); + await page.fill('#pointItemForm input[name="points"]', "2"); + await page.click('#pointItemForm button[type="submit"]'); + let card = page.locator(".pts-card").filter({ hasText: "自己刷牙" }); + await expect(card).toBeVisible(); + await card.locator(".pts-toggle").click(); + await expect(card).toHaveClass(/done/); + await expect.poll(async () => (await playedEvents(page, "play"))).toContain("points_earned"); + + // 撤销上一条记录 → try_again(等待上一音效结束,避免防叠加门控) + await page.waitForTimeout(600); + card = page.locator(".pts-card").filter({ hasText: "自己刷牙" }); + await card.locator(".pts-toggle").click(); + await expect.poll(async () => (await playedEvents(page, "play"))).toContain("try_again"); + + // 创建减分项并扣分 → points_deducted + await page.waitForTimeout(600); + await page.fill('#pointItemForm input[name="name"]', "不收玩具"); + await page.fill('#pointItemForm input[name="points"]', "-1"); + await page.click('#pointItemForm button[type="submit"]'); + card = page.locator(".pts-card").filter({ hasText: "不收玩具" }); + await expect(card).toBeVisible(); + await card.locator(".pts-toggle").click(); + await expect.poll(async () => (await playedEvents(page, "play"))).toContain("points_deducted"); + }); + + test("plays a completion sound when checking in", async ({ page }) => { + await page.goto("/"); + await installPlaySpies(page); + await page.click('[data-mod="learning"]'); + await page.click('[data-go="chinese"]'); + await page.locator('[data-cmod="chinese-literacy"]').click(); + expect(await playedEvents(page, "play")).toContain("action_completed"); + }); +}); diff --git a/tests/unit/learning-growth-loop-controller.test.js b/tests/unit/learning-growth-loop-controller.test.js index 16a4e2f..98c2fbc 100644 --- a/tests/unit/learning-growth-loop-controller.test.js +++ b/tests/unit/learning-growth-loop-controller.test.js @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createMemoryLearningDb } from "../../src/learning-local-db.js"; import { createGrowthLoopController } from "../../src/learning-growth-loop-controller.js"; @@ -115,3 +115,95 @@ describe("Growth Loop controller legacy points import", () => { })); }); }); + +describe("Growth Loop controller reward fulfillment", () => { + async function setupController({ onRewardFulfilled } = {}) { + const db = createMemoryLearningDb(); + const controller = createGrowthLoopController({ db, onRewardFulfilled }); + await controller.loadScope({ household_id: "h-1", profile_id: "p-1" }); + await controller.recordPoint({ + item: { id: "item-1", name: "整理玩具", default_points: 10 }, + occurred_on: "2026-08-14", + request_id: "point-1", + }); + await controller.createReward({ + request_id: "reward-1", + reward: { id: "reward-1", name: "周末去公园", cost_points: 5, category: "family", icon_key: "gift" }, + }); + await controller.redeemReward({ reward_id: "reward-1", request_id: "redeem-1" }); + return { db, controller }; + } + + async function confirmRedeem(controller) { + const transport = { + send: vi.fn(async (event) => event.type === "reward_redeem" + ? { status: "confirmed", data: { id: "server-redemption-1", status: "pending", reward_id: "reward-1" } } + : { status: "confirmed", data: { id: `server-${event.event_id}` } }), + }; + await controller.sync({ transport }); + } + + it("enqueues a redemption_fulfill only for a confirmed pending redemption", async () => { + const onRewardFulfilled = vi.fn(); + const { db, controller } = await setupController({ onRewardFulfilled }); + await confirmRedeem(controller); + expect(onRewardFulfilled).not.toHaveBeenCalled(); + + const row = controller.getSnapshot().redemptions.find((item) => item.id === "server-redemption-1"); + expect(row).toEqual(expect.objectContaining({ status: "pending", confirmed: true })); + + const result = await controller.fulfillRedemption({ redemption_id: "server-redemption-1" }); + expect(result.error).toBeUndefined(); + expect(controller.getSnapshot().redemptions.find((item) => item.id === "server-redemption-1").fulfill_requested).toBe(true); + + const pending = await controller.pendingOutbox(); + const fulfillEvent = pending.find((event) => event.type === "redemption_fulfill"); + expect(fulfillEvent).toEqual(expect.objectContaining({ payload: { redemption_id: "server-redemption-1" } })); + + // 已请求兑现后再次请求被拒绝,避免重复入队。 + const again = await controller.fulfillRedemption({ redemption_id: "server-redemption-1" }); + expect(again.error).toBe("redemption_not_pending"); + }); + + it("fires onRewardFulfilled only once when the server confirms fulfillment", async () => { + const onRewardFulfilled = vi.fn(); + const { db, controller } = await setupController({ onRewardFulfilled }); + await confirmRedeem(controller); + await controller.fulfillRedemption({ redemption_id: "server-redemption-1" }); + + const fulfillTransport = { + send: vi.fn(async (event) => event.type === "redemption_fulfill" + ? { status: "confirmed", data: { id: "server-redemption-1", status: "fulfilled", fulfilled_at: "2026-08-16T00:00:00Z" } } + : { status: "confirmed", data: { id: `server-${event.event_id}` } }), + }; + await controller.sync({ transport: fulfillTransport }); + + expect(onRewardFulfilled).toHaveBeenCalledTimes(1); + expect(onRewardFulfilled).toHaveBeenCalledWith(expect.objectContaining({ + redemption: expect.objectContaining({ id: "server-redemption-1", status: "fulfilled" }), + })); + expect(controller.getSnapshot().redemptions.find((item) => item.id === "server-redemption-1")).toEqual( + expect.objectContaining({ status: "fulfilled" }), + ); + + // 重复的确认事件(幂等重放)不会再次触发。 + await db.appendOutbox({ + event_id: "duplicate-fulfill", + request_id: "duplicate-1", + scope_key: "h-1:p-1", + household_id: "h-1", + profile_id: "p-1", + type: "redemption_fulfill", + payload: { redemption_id: "server-redemption-1" }, + }); + await controller.sync({ transport: fulfillTransport }); + expect(onRewardFulfilled).toHaveBeenCalledTimes(1); + }); + + it("rejects fulfillment for an unknown redemption", async () => { + const onRewardFulfilled = vi.fn(); + const { controller } = await setupController({ onRewardFulfilled }); + expect((await controller.fulfillRedemption({ redemption_id: "missing" })).error).toBe("redemption_not_found"); + expect(onRewardFulfilled).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/learning-sounds.test.js b/tests/unit/learning-sounds.test.js new file mode 100644 index 0000000..ff8237a --- /dev/null +++ b/tests/unit/learning-sounds.test.js @@ -0,0 +1,306 @@ +import { describe, expect, it, beforeEach, vi } from "vitest"; +import { + createSoundEngine, + DEFAULT_SETTINGS, + normalizeSettings, + renderRecipe, + SOUND_EVENT_KEYS, + SOUND_EVENTS, +} from "../../src/learning-sounds.js"; + +function createFakeAudioContext() { + const oscillators = []; + const ctx = { + currentTime: 100, + state: "running", + destination: { label: "destination" }, + resume: vi.fn(() => Promise.resolve()), + createGain: () => ({ + gain: { + value: 0, + setValueAtTime: vi.fn(), + linearRampToValueAtTime: vi.fn(), + exponentialRampToValueAtTime: vi.fn(), + }, + connect: vi.fn(), + }), + createBiquadFilter: () => ({ + type: "bandpass", + frequency: { value: 0 }, + Q: { value: 1 }, + connect: vi.fn(), + }), + createOscillator: () => { + const oscillator = { + type: "sine", + frequency: { value: 0, setValueAtTime: vi.fn(), exponentialRampToValueAtTime: vi.fn() }, + connect: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + }; + oscillators.push(oscillator); + return oscillator; + }, + }; + return { ctx, oscillators }; +} + +function createMemoryStorage() { + const map = new Map(); + return { + getItem: (key) => (map.has(key) ? map.get(key) : null), + setItem: (key, value) => { map.set(key, String(value)); }, + removeItem: (key) => { map.delete(key); }, + clear: () => { map.clear(); }, + raw: (key) => map.get(key), + }; +} + +function makeEngine({ settings, now } = {}) { + let current = now || 1000; + const clock = () => current; + const storage = createMemoryStorage(); + if (settings) { + storage.setItem("shadow_mate_sound_settings_v1", JSON.stringify(settings)); + } + const engine = createSoundEngine({ + storage, + now: clock, + getAudioContext: () => createFakeAudioContext().ctx, + }); + return { engine, storage, advance: (ms) => { current += ms; } }; +} + +let storage; // 每个用例的独立内存存储。 + +beforeEach(() => { + storage = createMemoryStorage(); +}); + +describe("fixed event recipes", () => { + it("defines exactly the five stable events with the expected priority order", () => { + expect(SOUND_EVENT_KEYS).toEqual([ + "action_completed", + "points_earned", + "try_again", + "points_deducted", + "reward_fulfilled", + ]); + expect(SOUND_EVENTS.reward_fulfilled.priority).toBeGreaterThan(SOUND_EVENTS.points_earned.priority); + expect(SOUND_EVENTS.points_earned.priority).toBeGreaterThan(SOUND_EVENTS.action_completed.priority); + }); + + it("keeps two to three fixed variants per event and never randomizes recipes", () => { + for (const key of SOUND_EVENT_KEYS) { + const variants = Object.values(SOUND_EVENTS[key].variants); + expect(variants.length).toBeGreaterThanOrEqual(2); + expect(variants.length).toBeLessThanOrEqual(3); + for (const variant of variants) { + expect(variant.recipe.maxDurationMs).toBeGreaterThan(0); + expect(Array.isArray(variant.recipe.notes)).toBe(true); + expect(variant.recipe.notes.length).toBeGreaterThan(0); + for (const note of variant.recipe.notes) { + expect(Number.isFinite(note.freq) || (Array.isArray(note.freq) && note.freq.length === 2)).toBe(true); + expect(Number.isFinite(note.gain)).toBe(true); + expect(note.gain).toBeGreaterThan(0); + } + // 配方是纯数据(可 JSON 往返),保证固定不随机。 + expect(JSON.parse(JSON.stringify(variant.recipe))).toEqual(variant.recipe); + } + } + }); + + it("keeps negative events soft and non-alarming", () => { + const tryAgain = Object.values(SOUND_EVENTS.try_again.variants).map((v) => v.recipe); + const deducted = Object.values(SOUND_EVENTS.points_deducted.variants).map((v) => v.recipe); + const negativePeaks = [...tryAgain, ...deducted].flatMap((recipe) => recipe.notes.map((note) => note.gain)); + expect(Math.max(...negativePeaks)).toBeLessThan(0.45); + expect(tryAgain.every((recipe) => recipe.notes.every((note) => note.wave === "sine"))).toBe(true); + }); +}); + +describe("settings normalization and persistence", () => { + it("defaults to master on, 60% volume, all events on, default variants", () => { + const settings = DEFAULT_SETTINGS; + expect(settings.enabled).toBe(true); + expect(settings.volume).toBe(0.6); + for (const key of SOUND_EVENT_KEYS) { + expect(settings.events[key].enabled).toBe(true); + expect(settings.events[key].variant).toBe(SOUND_EVENTS[key].defaultVariant); + } + }); + + it("sanitizes invalid stored values back to defaults", () => { + const normalized = normalizeSettings({ + enabled: "no", + volume: 99, + events: { action_completed: { enabled: false, variant: "not_a_variant" } }, + }); + expect(normalized.enabled).toBe(true); + expect(normalized.volume).toBe(1); + expect(normalized.events.action_completed.enabled).toBe(false); + expect(normalized.events.action_completed.variant).toBe("block_click"); + }); + + it("persists changes to device-local storage and loads them back", () => { + const { engine, storage } = makeEngine(); + engine.setEnabled(false); + engine.setVolume(0.4); + engine.setEventEnabled("points_earned", false); + engine.setEventVariant("reward_fulfilled", "chest_open"); + const saved = JSON.parse(storage.raw("shadow_mate_sound_settings_v1")); + expect(saved.enabled).toBe(false); + expect(saved.volume).toBe(0.4); + expect(saved.events.points_earned.enabled).toBe(false); + expect(saved.events.reward_fulfilled.variant).toBe("chest_open"); + + const reloaded = createSoundEngine({ storage, now: () => 0 }).getSettings(); + expect(reloaded.enabled).toBe(false); + expect(reloaded.volume).toBe(0.4); + expect(reloaded.events.points_earned.enabled).toBe(false); + expect(reloaded.events.reward_fulfilled.variant).toBe("chest_open"); + }); + + it("restores defaults on demand", () => { + const { engine } = makeEngine(); + engine.setEnabled(false); + engine.setVolume(0.1); + engine.setEventVariant("points_earned", "star_triple"); + engine.resetDefaults(); + expect(engine.getSettings()).toEqual(DEFAULT_SETTINGS); + }); +}); + +describe("play gating and boundaries", () => { + it("silently drops plays when master or the event is disabled", () => { + const { engine } = makeEngine(); + engine.setEnabled(false); + expect(engine.play("action_completed")).toEqual({ played: false, reason: "master_disabled" }); + engine.setEnabled(true); + engine.setEventEnabled("points_earned", false); + expect(engine.play("points_earned")).toEqual({ played: false, reason: "event_disabled" }); + expect(engine.play("action_completed")).toEqual({ played: true, duration: 220, event: "action_completed" }); + }); + + it("skips UI sounds while TTS is playing and resumes after it finishes", () => { + const { engine, advance } = makeEngine(); + engine.setTtsActive(true); + expect(engine.play("action_completed")).toEqual({ played: false, reason: "tts_active" }); + expect(engine.preview("points_earned")).toEqual({ played: false, reason: "tts_active" }); + engine.setTtsActive(false); + advance(1000); + expect(engine.play("action_completed").played).toBe(true); + }); + + it("throttles rapid repeated plays and does not overlap sounds", () => { + const { engine, advance } = makeEngine(); + expect(engine.play("action_completed").played).toBe(true); + // 同一个事件在防重窗口内被丢弃。 + advance(50); + expect(engine.play("action_completed").reason).toBe("throttled"); + // 上一个音效仍在播放期内时,其它事件也被节流,保证不叠加。 + expect(engine.play("points_earned").reason).toBe("busy"); + // 上一音效结束后,其它事件可以播放;正在播放的事件仍被节流。 + advance(500); + expect(engine.play("points_earned").played).toBe(true); + expect(engine.play("action_completed").reason).toBe("busy"); + // 完全结束后可以再次播放。 + advance(500); + expect(engine.play("action_completed").played).toBe(true); + }); + + it("drops an unknown event without throwing", () => { + const { engine } = makeEngine(); + expect(engine.play("not_an_event")).toEqual({ played: false, reason: "unknown_event" }); + }); + + it("degrades silently when the audio context is unavailable", () => { + const engine = createSoundEngine({ storage, now: () => 0, getAudioContext: () => null }); + expect(engine.play("points_earned")).toEqual({ played: false, reason: "unavailable" }); + }); + + it("never lets a failing render affect business state", () => { + const render = vi.fn(() => null); + const engine = createSoundEngine({ + storage, + now: () => 0, + render, + getAudioContext: () => createFakeAudioContext().ctx, + }); + const result = engine.play("action_completed"); + expect(result).toEqual({ played: false, reason: "unavailable" }); + expect(() => engine.getSettings()).not.toThrow(); + }); +}); + +describe("priority selection", () => { + it("plays only the highest-priority event for one operation", () => { + const { engine, advance } = makeEngine(); + const result = engine.playPriority(["action_completed", "points_earned"]); + expect(result.event).toBe("points_earned"); + // 上一音效结束后,再次从同一操作的多事件中选择最高优先级。 + advance(1000); + const top = engine.playPriority(["action_completed", "points_earned", "reward_fulfilled"]); + expect(top.event).toBe("reward_fulfilled"); + }); + + it("falls through to the next enabled event when the top one is disabled", () => { + const { engine } = makeEngine(); + engine.setEventEnabled("points_earned", false); + const result = engine.playPriority(["action_completed", "points_earned"]); + expect(result.event).toBe("action_completed"); + }); + + it("plays nothing when no candidate is playable", () => { + const { engine } = makeEngine(); + engine.setEnabled(false); + expect(engine.playPriority(["action_completed", "points_earned"])).toEqual({ played: false, reason: "none_playable" }); + }); +}); + +describe("preview and variant selection", () => { + it("previews the selected variant even when the event is disabled", () => { + const { engine } = makeEngine(); + engine.setEventEnabled("points_earned", false); + engine.setEventVariant("points_earned", "star_triple"); + const result = engine.preview("points_earned"); + expect(result.played).toBe(true); + expect(result.variant).toBe("star_triple"); + }); + + it("previews a specific variant override", () => { + const { engine } = makeEngine(); + const result = engine.preview("reward_fulfilled", { variant: "finish_chord" }); + expect(result.played).toBe(true); + expect(result.variant).toBe("finish_chord"); + }); +}); + +describe("web audio rendering", () => { + it("schedules one oscillator per recipe note through a live context", () => { + const { ctx, oscillators } = createFakeAudioContext(); + const duration = renderRecipe(SOUND_EVENTS.action_completed.variants.block_click.recipe, { + volume: 0.6, + getAudioContext: () => ctx, + }); + expect(duration).toBe(220); + expect(oscillators.length).toBe(2); + expect(oscillators[0].start).toHaveBeenCalled(); + expect(oscillators[0].stop).toHaveBeenCalled(); + }); + + it("returns null and never throws when there is no audio context", () => { + expect(renderRecipe(SOUND_EVENTS.reward_fulfilled.variants.squad_cheer.recipe, { getAudioContext: () => null })).toBe(null); + }); + + it("returns null and never throws when a context throws mid-render", () => { + const broken = { + currentTime: 0, + state: "running", + destination: {}, + createGain: () => { throw new Error("audio blocked"); }, + }; + expect(() => renderRecipe(SOUND_EVENTS.points_earned.variants.star_collect.recipe, { getAudioContext: () => broken })).not.toThrow(); + expect(renderRecipe(SOUND_EVENTS.points_earned.variants.star_collect.recipe, { getAudioContext: () => broken })).toBe(null); + }); +});