/** * profiles.js — 无感档案系统的大厅侧纯函数(P0-B + P1 配对码)。 * * 被 web/index.html(UI / 网络编排)与 web/server/test/api-smoke.mjs(node 断言) * 共用:只做 localStorage 形状计算,不碰 DOM / fetch——测试里传一个 Map 版 * storage({getItem,setItem,removeItem})即可。自动名汉字数字与 server.js 的 * cnNum 保持同一词表(玩家一~九可听,见 research/platform/01-identity.md §4.2); * digitsToCn / removeProfile 为 P1 配对码与「从本机移除」的纯函数地基。 * * 档案分桶模型(research/platform/03-data-sync.md §4.4): * soundgame.profiles 本机档案表 [{id, name, token|null, claimed?}] * soundgame.profile.current 当前档案 id * soundgame.p. 各档案进度快照(原始 key → 原始字符串值;切走时回灌) * soundgame.sync. 各档案已知的服务器桶时间戳 {: {updatedAt, rev}} * soundgame.auth 虚拟视图:当前档案 token(游戏侧只读它,key 名不变) * * 游戏继续读写原始 key(soundgame.progress.rpg 等),一个文件都不用改—— * 切换档案 = 大厅先把活动 key 回灌进旧档案快照,再把新档案快照倒灌回活动 key。 * 一次性教学标记(tutIntroSeen / guideDone / drillDone)不入桶:跨档案共享是 * 已裁定的可接受行为(01 §4.6)。 */ export const REGISTRY_KEY = 'soundgame.profiles'; export const CURRENT_KEY = 'soundgame.profile.current'; export const AUTH_KEY = 'soundgame.auth'; export const SETTINGS_KEY = 'soundgame.settings'; /** 受管桶:localStorage 原始 key ↔ 服务器 game 桶名。 */ export const BUCKETS = [ { key: 'soundgame.progress.rpg', game: 'rpg' }, { key: 'soundgame.progress.dungeon', game: 'dungeon' }, { key: 'soundgame.progress.radio', game: 'radio' }, { key: 'soundgame.progress.abyss', game: 'abyss' }, { key: 'soundgame.progress.detective', game: 'detective' }, { key: 'soundgame.rhythm.progress', game: 'rhythm' }, { key: 'soundgame.simon.best', game: 'simon' }, { key: 'soundgame.space.best', game: 'space' }, { key: 'soundgame.escape.stats', game: 'escape' }, { key: SETTINGS_KEY, game: '_settings' }, ]; export function snapshotKeyOf(id) { return `soundgame.p.${id}`; } export function syncKeyOf(id) { return `soundgame.sync.${id}`; } // ---------------- storage JSON 小工具 ---------------- export function readStorageJSON(storage, key, fallback = null) { try { const v = JSON.parse(storage.getItem(key)); return v === null || v === undefined ? fallback : v; } catch (_) { return fallback; } } export function writeStorageJSON(storage, key, value) { try { storage.setItem(key, JSON.stringify(value)); } catch (_) { /* 隐私模式:写不进就只留内存态(调用方自行兜底) */ } } // ---------------- 本机档案表 ---------------- export function readRegistry(storage) { const list = readStorageJSON(storage, REGISTRY_KEY, []); return Array.isArray(list) ? list.filter((p) => p && typeof p.id === 'string' && typeof p.name === 'string') : []; } export function writeRegistry(storage, list) { writeStorageJSON(storage, REGISTRY_KEY, list); } export function getCurrentProfile(storage) { const id = storage.getItem(CURRENT_KEY); return readRegistry(storage).find((p) => p.id === id) || null; } export function setCurrentProfile(storage, id) { try { storage.setItem(CURRENT_KEY, id); } catch (_) { /* 隐私模式 */ } } /** 游戏侧兼容视图:soundgame.auth = {username, token}(8 个游戏只读 .token)。 */ export function applyAuth(storage, profile) { try { if (profile && profile.token) { storage.setItem(AUTH_KEY, JSON.stringify({ username: profile.name, token: profile.token })); } else { storage.removeItem(AUTH_KEY); } } catch (_) { /* 隐私模式 */ } } /** v1 旧登录态识别({username, token},2026-09 之前的大厅写入)。 */ export function migrateLegacyAuth(storage) { const a = readStorageJSON(storage, AUTH_KEY, null); return a && typeof a === 'object' && typeof a.token === 'string' && a.token ? a : null; } // ---------------- 自动名 ---------------- export function cnNum(n) { const d = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九']; if (!Number.isInteger(n) || n <= 0 || n > 99) return String(n); if (n < 10) return d[n]; const t = Math.floor(n / 10); const u = n % 10; return (t > 1 ? d[t] : '') + '十' + (u ? d[u] : ''); } /** 纯本地档案(无服务器 / 公网静态形态)的自动名。 */ export function nextLocalProfileName(registry) { return '玩家' + cnNum(registry.length + 1); } /** * 配对码/数字串的逐位中文读法(P1 裁定:配置类数字不走 TTS 资产,交给 * 浏览器 speechSynthesis;0 必须读「零」而非字母 O,故自行映射)。 * '048920' → '零 四 八 九 二 零'(空格分隔,朗读端再替换为停顿)。 */ export function digitsToCn(str) { const d = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九']; return String(str).split('').map((ch) => (ch >= '0' && ch <= '9' ? d[Number(ch)] : ch)).join(' '); } /** * 「从本机移除(不删云端)」:清本机档案快照与同步表并出注册表; * 若是当前档案则同时清 CURRENT(调用方负责善后:新建匿名或切换)。 * 服务器侧数据不动;token 是否吊销由调用方决定(UI 会 best-effort logout)。 */ export function removeProfile(storage, id) { const list = readRegistry(storage).filter((p) => p.id !== id); writeRegistry(storage, list); try { storage.removeItem(snapshotKeyOf(id)); storage.removeItem(syncKeyOf(id)); if (storage.getItem(CURRENT_KEY) === id) { storage.removeItem(CURRENT_KEY); storage.removeItem(AUTH_KEY); // 是当前档案:游戏侧虚拟视图一并清掉(调用方重建) } } catch (_) { /* 隐私模式 */ } return list; } // ---------------- 快照:回灌(切走)/ 倒灌(切入) ---------------- /** 回灌:把当前活动 key 原样存进档案快照。 */ export function stashSnapshot(storage, id) { const snap = {}; for (const b of BUCKETS) { const v = storage.getItem(b.key); if (v !== null) snap[b.key] = v; } writeStorageJSON(storage, snapshotKeyOf(id), snap); } /** 倒灌:先清空活动 key(防上一个档案的残留串档),再从快照恢复。 */ export function restoreSnapshot(storage, id) { const snap = readStorageJSON(storage, snapshotKeyOf(id), {}) || {}; for (const b of BUCKETS) { try { storage.removeItem(b.key); } catch (_) { /* 隐私模式 */ } } for (const [k, v] of Object.entries(snap)) { if (typeof v === 'string') { try { storage.setItem(k, v); } catch (_) { /* 隐私模式 */ } } } } // ---------------- 桶级 LWW 裁决(纯函数) ---------------- /** * 决定本地/服务器桶谁生效。时间戳取 data.updatedAt(游戏写入的客户端时间, * 存在于 rpg/dungeon/radio/rhythm 等存档体内);服务器桶自身的 updatedAt 是 * 「接收时间」,不参与内容比较(不信任两端时钟对齐,03 §2.4)。 * 无时间戳可比较(如 _settings、simon 最高分)→ 内容相同 keep、否则服务器胜 * ——与旧「登录即拉设置」行为一致,同时修掉 local-always-wins 分叉 bug(03 §1.2)。 * * @param {string|null} localRaw localStorage 原始字符串(null = 本地无档) * @param {{updatedAt: string, rev: number, data: any}|null} serverBucket * @returns {'keep'|'take-server'|'push-local'} */ export function decideBucket(localRaw, serverBucket) { const hasServer = !!(serverBucket && serverBucket.data !== null && serverBucket.data !== undefined); const hasLocal = localRaw !== null && localRaw !== undefined; if (!hasServer) return hasLocal ? 'push-local' : 'keep'; if (!hasLocal) return 'take-server'; let local; try { local = JSON.parse(localRaw); } catch (_) { return 'take-server'; // 本地损坏 → 服务器副本兜底 } const sd = serverBucket.data; if ( local && typeof local === 'object' && typeof local.updatedAt === 'string' && sd && typeof sd === 'object' && typeof sd.updatedAt === 'string' ) { if (local.updatedAt === sd.updatedAt) return 'keep'; return local.updatedAt > sd.updatedAt ? 'push-local' : 'take-server'; } return localRaw === JSON.stringify(sd) ? 'keep' : 'take-server'; }