四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 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(压缩候选)
223 lines
8.7 KiB
JavaScript
223 lines
8.7 KiB
JavaScript
/**
|
||
* 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';
|
||
}
|