Files
SoundGame/web/profiles.js
T
xsl 005851d01a feat(hunt): 《围猎》2-4人实时联机纯音频协作守卫——纯音频MMORPG第一阶段切片
- 设计:docs/design/23-hunt.md + voice-ids(84 id,166条)+ 13号断言登记,§15记录10+1条设计裁决
- 服务器:零依赖WebSocket(ws.js)+ 房间管理/服务端权威战斗/bot AI(hunt-room.js),断线30s宽限resume回槽、超时转bot;修复package.json模块类型误判的既有破坏
- 客户端:web/hunt/ 全套(教学T0-T4/听觉视野裁剪R1-R8/结算排行榜/guide接入),playtest 211断言
- 引擎:audio.js 后向兼容 opts.pan(StereoPannerNode),web-engine-api.md 同步
- 资产:166条TTS语音(QC无离群)+ 13件合成sfx(真峰值≤-3dBFS)+ 2首Mureka BGM,终检全绿
- 平衡:bot调参至挂机局71%失守/C、3bot局71% A/B(hunt-balance-scan.mjs 24种子扫描)
- 接线:大厅卡片、profiles.js hunt桶、README/roadmap MMO路线条目

验证:hunt-sim 16/16 + api-smoke 12/12 + hunt playtest 211/211 + 既有10游戏与引擎回归零回退 + 真服务器WS端到端冒烟通过
2026-09-13 11:08:01 +08:00

219 lines
8.5 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.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';
}