Files
SoundGame/web/profiles.js
T
xsl 4b39ae2e49 feat(new8/batch3-4): cipher/teahouse/keeper/bridge 全套交付——new8 八款全部可交付收官
四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 R1-R2 连续达标裁定可交付:
- cipher《暗码》223 断言:摩尔斯解码嵌叙事/四结局矩阵/暴露度逐NIGHT验算/三层兜底;R2 三修(链首标记音/预警震/结局计数句+4句补录)4/4 全满分档
- teahouse《深夜茶馆》205 断言:泡茶时机窗/经济逐晚对账/多晚弧线;连续 2 轮 4/4 愿意玩 P0=0
- keeper《守塔人》257 断言:时空签名教学-兑现/插叙触发回返/trust×truth 档位/4结局;R2 F1/F2 坐实(震型豁免/首播跳句锁)4/4
- bridge《星环舰长》336 断言:巡检节奏逐毫秒/危机×系统态矩阵/仲裁7场景/忠诚差分/评级验算;R1 当场修F1-F4、R2 F5 菜单顺延保护 4/4 上行

公共:index.html 四卡片+profiles.js 四桶+check_all_assets 登记(预接线)、00-program 八款进度账收账、
gen_voice_dual 四游戏段、README/HANDOVER 阵容更新(11 款)、voice_text.json 四游戏键、mureka 台账(累计$1.26/$2.00)

验证:四游戏 playtest 223/205/257/336 全绿 + guide 122/122 + 全项目 15 游戏资产终检绿
遗留:keeper 三代人声滤波人耳试听(最高优先,ASR 对滤波句失明)、bridge 音频 32MB 超软指引 2MB(压缩候选)
2026-09-14 05:48:13 +08:00

223 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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.<id> 各档案进度快照(原始 key → 原始字符串值;切走时回灌)
* soundgame.sync.<id> 各档案已知的服务器桶时间戳 {<game>: {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.hunt', game: 'hunt' },
{ key: 'soundgame.progress.cipher', game: 'cipher' },
{ key: 'soundgame.progress.teahouse', game: 'teahouse' },
{ key: 'soundgame.progress.keeper', game: 'keeper' },
{ key: 'soundgame.progress.bridge', game: 'bridge' },
{ 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';
}