用户系统(B,游戏零改动): - POST /api/device 首访 0 交互静默建档(自动玩家一/二…,per-IP 频控);断网降级纯本地照玩 - 大厅档案切换器(切换=flush 上云→快照回灌→倒灌→服务器回拉,profileId 分桶修同设备串档;aria menu 编号可听) - claim 可选认领(命名+scrypt PIN);旧 users/sessions 单向迁移(PIN 明文即刻哈希,已真实数据验证);login 兼容+claim-merge 桶级 LWW;logout;/server/ 静态封禁 - PUT baseUpdatedAt 守卫(legacy 放行+警告);GET 无参全桶回拉(修 7/8 游戏跨设备只 PUT 不 GET) - web/profiles.js 分桶纯函数 + api-smoke.mjs 7/7(建档/LWW/claim-merge/迁移/频控/封禁/分桶 H1-H4) 键盘双端(A,游戏代码零改动): - gesture.js 键盘通道 +137 行:←↑↓→/WASD=滑动、空格=单击(走 300ms 双击窗与触摸共享虚拟指针)、回车=即时 doubletap、按住空格 ≥450ms=长按;repeat 抑制/表单守卫/方向键 preventDefault - settings.input(auto/touch/key)+ gesture.inputMode();遮罩键盘解锁与文案自适应(按空格开始/轻触开始) - 8 游戏遮罩改用自适应默认文案;大厅设置面板「操作方式」+页脚键位提示 - live control.mjs 键盘注入通道(CDP dispatchKeyEvent);engine-input.test 61 断言 回归:12 套件全绿(8 playtest + guide + engine-voice + engine-input + api-smoke 7/7);8001 服务器已重启加载新 API,/api/device 端到端验证通过 P1 待排:153 句×双性 TTS 键盘语音变体 / 6 位配对码跨设备 / 认领 UI 打磨(research/platform/00-synthesis.md §3)
186 lines
7.0 KiB
JavaScript
186 lines
7.0 KiB
JavaScript
/**
|
||
* profiles.js — 无感档案系统的大厅侧纯函数(P0-B)。
|
||
*
|
||
* 被 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)。
|
||
*
|
||
* 档案分桶模型(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);
|
||
}
|
||
|
||
// ---------------- 快照:回灌(切走)/ 倒灌(切入) ----------------
|
||
|
||
/** 回灌:把当前活动 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';
|
||
}
|