diff --git a/index.html b/index.html
index 982f5e7..563f7b2 100644
--- a/index.html
+++ b/index.html
@@ -40,6 +40,7 @@
@@ -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 = $(`
`);
+ 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);
+ });
+});