Files
SoundGame/web/profiles.js
T
xsl 566241e05d feat(platform/P1): 配对码跨设备 + 认领打磨 + 键盘语音双套资产(两 agent 并行 + 集成收口)
配对码与认领(B):
- POST /api/pair/start|join:6 位码 10 分钟单次有效、仅存内存零泄露面、5 连错 per-IP 封锁 10 分钟;join=新设备档案按桶 LWW 并入老档案+token 改绑(复用 claim-merge);日志脱敏
- 大厅:生成配对码(speechSynthesis 逐位朗读+aria-live+重听)/从其他设备转入(one-time-code 输入框)/从本机移除/401 过期自愈引导/aria 补全/玩家名可听化
- api-smoke 12/12(配对全流程/频控/封锁/过期自愈/移除/digitsToCn)+ CDP 真浏览器 E2E 22/22

键盘语音双套资产(A,路线 a):
- 164 变体句(8 游戏 grep 实测,超出调研预估 153:含轻触/点两下/按住变体)×2 性别=328 生成 656 文件 36.9 分钟;触屏主资产零字节改动(PCM 逐样本比对后还原)
- 引擎 _candidates 三跳路由 key→gender→平铺(inputMode 跟随);缓存键 mode|gender|id;engine-input 新增 K9 路由断言(72 条)
- check_all_assets 增 key 列(两性别一致且 ⊆ 主集)8 游戏全绿;voice_text.json 增 __key__ 表;live control.mjs key 会话对照键盘措辞验收(集成)
- voice-ids 文档:8 游戏键盘变体小节由 KEY_LINES 程序化生成(与台词表逐字断言一致);补齐 storyline/escape/simon 三个历史缺失文档;design README 增 new8 索引
- mock 适配:playtest persona 加 maxTouchPoints=1 保持触屏链断言语义零改动

回归:12 套件全绿(8 playtest + guide 122 + engine-voice 19 + engine-input 72 + api-smoke 12/12)
同批入库:批次 2/3 已完稿设计文档(17-abyss/18-detective/19-cipher 全套,20-22 在途)
2026-09-10 00:43:23 +08:00

216 lines
8.3 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.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';
}