Files
SoundGame/web/profiles.js
T
xsl ede522dda1 feat(platform/P0): 无感用户系统 + PC 键盘双端一致(两 agent 并行 + 集成收口)
用户系统(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)
2026-09-09 23:39:23 +08:00

186 lines
7.0 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)。
*
* 被 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';
}