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)
This commit is contained in:
xsl
2026-09-09 23:39:23 +08:00
parent 3704cd0792
commit ede522dda1
22 changed files with 2370 additions and 275 deletions
+548 -140
View File
@@ -26,9 +26,48 @@
padding: 16px 20px;
}
.panel h3 { margin: 0 0 12px; font-size: 14px; color: #9aa0a6; font-weight: normal; letter-spacing: 0.1em; }
/* 用户区 */
/* 玩家区(档案切换器) */
#user-row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
#user-row input {
#profile-btn {
background: #4f8cff;
border: none;
border-radius: 8px;
color: #fff;
padding: 10px 18px;
font-size: 15px;
cursor: pointer;
}
#profile-btn[aria-expanded="true"] { background: #3c74e0; }
#user-msg { font-size: 13px; color: #9aa0a6; width: 100%; margin: 8px 0 0; min-height: 1em; }
#profile-menu {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 12px;
padding: 10px;
background: #0b0d10;
border: 1px solid #262c36;
border-radius: 10px;
}
#profile-menu button {
background: #161a20;
border: 1px solid #262c36;
border-radius: 8px;
color: #e8eaed;
padding: 10px 14px;
font-size: 15px;
text-align: left;
cursor: pointer;
}
#profile-menu button:hover { border-color: #4f8cff; }
#profile-menu button[aria-checked="true"] { color: #4f8cff; }
/* 档案管理 */
#profile-manage { margin-top: 12px; padding-top: 12px; border-top: 1px solid #262c36; }
#profile-manage h4 { margin: 0 0 10px; font-size: 13px; color: #9aa0a6; font-weight: normal; }
.manage-form { margin-bottom: 12px; }
.manage-form label { display: block; font-size: 13px; color: #9aa0a6; margin-bottom: 6px; }
.manage-row { display: flex; flex-wrap: wrap; gap: 8px; }
.manage-row input {
background: #0b0d10;
border: 1px solid #262c36;
border-radius: 8px;
@@ -37,7 +76,8 @@
font-size: 14px;
width: 140px;
}
#user-row button {
.manage-row input[inputmode="numeric"] { width: 110px; }
.manage-row button {
background: #4f8cff;
border: none;
border-radius: 8px;
@@ -46,9 +86,7 @@
font-size: 14px;
cursor: pointer;
}
#user-row button.secondary { background: #262c36; }
#user-msg { font-size: 13px; color: #e57373; width: 100%; margin: 0; min-height: 1em; }
#user-name { color: #4f8cff; font-weight: bold; }
.manage-note { font-size: 12px; color: #5f6368; margin: 4px 0 0; }
/* 设置区 */
#settings-row { display: flex; flex-wrap: wrap; gap: 16px; }
.setting-item { display: flex; flex-direction: column; gap: 6px; }
@@ -110,13 +148,44 @@
<p>纯音频游戏 · 一副耳机,六种手势,无需眼看</p>
</header>
<section class="panel" id="user-panel">
<h3>用户</h3>
<div id="user-row"></div>
<p id="user-msg"></p>
<!-- 玩家区:匿名档案切换器(首访 0 输入 0 点击自动建档,research/platform/01-identity.md 方案 B) -->
<section class="panel" id="user-panel" aria-label="玩家">
<h3>玩家</h3>
<div id="user-row">
<button id="profile-btn" aria-haspopup="menu" aria-expanded="false">玩家:…</button>
</div>
<div id="profile-menu" role="menu" aria-label="切换玩家" hidden></div>
<div id="profile-manage" role="group" aria-labelledby="manage-title" hidden>
<h4 id="manage-title">档案管理</h4>
<div class="manage-form">
<label for="rename-input">新名字(1~24 位,可用中文;玩家一这类自动名可听,自定义名仅显示)</label>
<div class="manage-row">
<input id="rename-input" maxlength="24">
<button id="rename-btn">改名</button>
</div>
</div>
<div class="manage-form" id="pin-form">
<label for="pin-input">设置 PIN(4~6 位数字;设置后可用「名字 + PIN」在其他设备登录本档案)</label>
<div class="manage-row">
<input id="pin-input" inputmode="numeric" maxlength="6" aria-describedby="pin-hint">
<button id="pin-btn">设置 PIN</button>
</div>
<p class="manage-note" id="pin-hint">PIN 是可选项,只为防家人恶作剧;不设也完整可玩。</p>
</div>
<div class="manage-form" id="login-form">
<label for="login-name-input">已有档案?用名字 + PIN 登录(本机当前进度会按游戏合并上去)</label>
<div class="manage-row">
<input id="login-name-input" maxlength="24">
<input id="login-pin-input" inputmode="numeric" maxlength="6" aria-label="PIN">
<button id="login-btn">登录</button>
</div>
</div>
<p class="manage-note" id="offline-note" hidden>当前为离线档案(无本地服务器):可改名、可切换,PIN 与登录需要家庭服务器。</p>
</div>
<p id="user-msg" role="status" aria-live="polite"></p>
</section>
<section class="panel" id="settings-panel">
<section class="panel" id="settings-panel" aria-label="设置">
<h3>设置</h3>
<div id="settings-row">
<div class="setting-item">
@@ -151,6 +220,14 @@
<option value="0">关闭</option>
</select>
</div>
<div class="setting-item">
<label for="sel-input">操作方式</label>
<select id="sel-input">
<option value="auto">自动</option>
<option value="touch">触屏</option>
<option value="key">键盘</option>
</select>
</div>
</div>
</section>
@@ -206,105 +283,450 @@
</main>
<footer>
建议佩戴耳机游玩 · 操作方式:上 / 下 / 左 / 右滑动、单击、双击<br>
建议佩戴耳机游玩 · 操作方式:上 / 下 / 左 / 右滑动、单击、双击(PC 键盘:方向键 / WASD、空格、回车)<br>
单击判定有约 300ms 延迟(双击识别所需),属于正常设计
</footer>
<script type="module">
import { settings } from './engine/settings.js';
import {
BUCKETS, SETTINGS_KEY,
applyAuth, decideBucket, getCurrentProfile, migrateLegacyAuth,
nextLocalProfileName, readRegistry, readStorageJSON, restoreSnapshot,
setCurrentProfile, snapshotKeyOf, stashSnapshot, syncKeyOf,
writeRegistry, writeStorageJSON,
} from './profiles.js';
const AUTH_KEY = 'soundgame.auth';
const GAMES = ['radio', 'dungeon', 'rpg', 'storyline', 'escape', 'simon', 'rhythm', 'space'];
// 公网站点只托管静态文件、无用户 API(docs/deploy.md):档案系统自动降级为
// 纯本地档案(research/platform/01-identity.md 方案 A = 方案 B 的本地后端),
// 不再整体隐藏玩家面板。本地 ./web/serve.sh 与服务器档案同一套代码。
const PUBLIC_STATIC =
location.protocol === 'https:' && location.hostname.endsWith('xiangsilian.com');
// ---------- 认证状态 ----------
function getAuth() {
try { return JSON.parse(localStorage.getItem(AUTH_KEY)) || null; }
catch (_) { return null; }
}
function setAuth(a) {
if (a) localStorage.setItem(AUTH_KEY, JSON.stringify(a));
else localStorage.removeItem(AUTH_KEY);
}
// settings.input 白名单值(engine/settings.js VALID.input:'auto' | 'touch' | 'key',
// 引擎侧手势分发由键盘适配工作线实现;此处只写入设置)
const INPUT_MODES = ['auto', 'touch', 'key'];
const NAME_RE = /^[A-Za-z0-9_\-一-龥]{1,24}$/;
const PIN_RE = /^\d{4,6}$/;
// 服务器不可达(如纯静态托管)时静默降级为仅本地模式
async function api(path, opts = {}) {
const auth = getAuth();
// ---------- 服务器 API(不可达时静默降级为纯本地档案) ----------
async function api(path, opts = {}, tokenOverride) {
const cur = getCurrentProfile(localStorage);
const token = tokenOverride || (cur && cur.token) || null;
const headers = { 'Content-Type': 'application/json' };
if (auth) headers['Authorization'] = 'Bearer ' + auth.token;
if (token) headers['Authorization'] = 'Bearer ' + token;
const resp = await fetch('/api/' + path, { ...opts, headers });
const body = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(body.error || ('HTTP ' + resp.status));
if (!resp.ok) {
const err = new Error(body.error || ('HTTP ' + resp.status));
err.status = resp.status;
err.body = body;
throw err;
}
return body;
}
// ---------- 用户区 UI ----------
const userRow = document.getElementById('user-row');
// ---------- UI 元素 / 读屏播报 ----------
const profileBtn = document.getElementById('profile-btn');
const profileMenu = document.getElementById('profile-menu');
const profileManage = document.getElementById('profile-manage');
const userMsg = document.getElementById('user-msg');
const renameInput = document.getElementById('rename-input');
const renameBtn = document.getElementById('rename-btn');
const pinForm = document.getElementById('pin-form');
const pinInput = document.getElementById('pin-input');
const pinBtn = document.getElementById('pin-btn');
const loginForm = document.getElementById('login-form');
const loginNameInput = document.getElementById('login-name-input');
const loginPinInput = document.getElementById('login-pin-input');
const loginBtn = document.getElementById('login-btn');
const offlineNote = document.getElementById('offline-note');
function showMsg(text) { userMsg.textContent = text || ''; }
function announce(text) { userMsg.textContent = text || ''; }
function renderUserArea() {
const auth = getAuth();
userRow.innerHTML = '';
if (auth) {
const span = document.createElement('span');
span.innerHTML = '已登录:<span id="user-name"></span>';
span.querySelector('#user-name').textContent = auth.username;
const logout = document.createElement('button');
logout.className = 'secondary';
logout.textContent = '退出';
logout.onclick = () => {
setAuth(null);
renderUserArea();
clearProgress();
showMsg('');
};
userRow.append(span, logout);
} else {
const nameInput = document.createElement('input');
nameInput.placeholder = '用户名';
nameInput.maxLength = 24;
const pinInput = document.createElement('input');
pinInput.placeholder = 'PIN(4~6 位数字)';
pinInput.inputMode = 'numeric';
pinInput.maxLength = 6;
const loginBtn = document.createElement('button');
loginBtn.textContent = '登录';
const regBtn = document.createElement('button');
regBtn.className = 'secondary';
regBtn.textContent = '注册';
const submit = async (kind) => {
showMsg('');
try {
if (kind === 'register') {
await api('register', {
method: 'POST',
body: JSON.stringify({ username: nameInput.value.trim(), pin: pinInput.value.trim() }),
});
}
const r = await api('login', {
method: 'POST',
body: JSON.stringify({ username: nameInput.value.trim(), pin: pinInput.value.trim() }),
});
setAuth({ username: r.username, token: r.token });
renderUserArea();
await pullSettings();
await loadProgress();
} catch (e) {
showMsg(e.message);
}
};
loginBtn.onclick = () => submit('login');
regBtn.onclick = () => submit('register');
userRow.append(nameInput, pinInput, loginBtn, regBtn);
// ---------- 同步映射表(各档案已知的服务器桶 updatedAt/rev) ----------
function getSyncMap(id) { return readStorageJSON(localStorage, syncKeyOf(id), {}) || {}; }
function setSyncBucket(id, game, updatedAt, rev) {
const m = getSyncMap(id);
m[game] = { updatedAt, rev };
writeStorageJSON(localStorage, syncKeyOf(id), m);
}
function moveStorageKey(from, to) {
const v = localStorage.getItem(from);
if (v !== null) {
localStorage.setItem(to, v);
localStorage.removeItem(from);
}
}
// ---------- 设置区 ----------
// ---------- 桶级 LWW:倒灌 / 上传 / 409 处理(大厅统一做,游戏零改动) ----------
function refreshCard(game) {
const bucket = BUCKETS.find((b) => b.game === game);
const el = document.querySelector(`a.card[data-game="${game}"] .progress`);
if (!el || !bucket) return;
const data = readStorageJSON(localStorage, bucket.key, null);
el.textContent = data && typeof data === 'object' && typeof data.summary === 'string'
? data.summary
: '';
}
function refreshCards() {
for (const { game } of BUCKETS) refreshCard(game);
}
function pourServerBucket(bucket, sv) {
localStorage.setItem(bucket.key, JSON.stringify(sv.data));
if (bucket.key === SETTINGS_KEY) applyServerSettings(sv.data);
refreshCard(bucket.game);
}
/**
* 上传一个桶。带 baseUpdatedAt 守卫;409(别处更新过)时按 data.updatedAt
* 桶级 LWW 择优:本地确较新 → 用服务器新基线重放;否则采纳服务器副本倒灌。
*/
async function putBucket(profile, game, raw, baseUpdatedAt) {
const data = JSON.parse(raw);
const body = { game, data };
if (baseUpdatedAt) body.baseUpdatedAt = baseUpdatedAt;
try {
const r = await api('progress', { method: 'PUT', body: JSON.stringify(body) }, profile.token);
setSyncBucket(profile.id, game, r.updatedAt, r.rev);
} catch (e) {
if (e.status !== 409 || !e.body || !e.body.server) throw e;
const sv = e.body.server;
const bucket = BUCKETS.find((b) => b.game === game);
const localRaw = localStorage.getItem(bucket.key);
const decision = decideBucket(localRaw, sv);
if (decision === 'push-local') {
return putBucket(profile, game, localRaw, sv.updatedAt); // 本地确较新,用新基线重放一次
}
if (decision === 'take-server') {
pourServerBucket(bucket, sv);
setSyncBucket(profile.id, game, sv.updatedAt, sv.rev);
}
}
}
/**
* 档案同步:GET 全桶 → 逐桶 LWW(decideBucket)——本地缺/旧 → 服务器倒灌回
* 游戏 original key(修「7/8 游戏只 PUT 不 GET」存量 bug);服务器缺 → 本地
* 上云。游戏代码继续只读本地,零改动。
*/
async function syncProfile(profile) {
if (!profile || !profile.token) return; // 纯本地档案(公网静态 / 离线建档)
const r = await api('progress', { method: 'GET' }, profile.token);
const serverGames = (r && r.games) || {};
for (const bucket of BUCKETS) {
const sv = serverGames[bucket.game] || null;
const localRaw = localStorage.getItem(bucket.key);
switch (decideBucket(localRaw, sv)) {
case 'take-server':
pourServerBucket(bucket, sv);
setSyncBucket(profile.id, bucket.game, sv.updatedAt, sv.rev);
break;
case 'push-local':
try {
await putBucket(profile, bucket.game, localRaw, sv ? sv.updatedAt : undefined);
} catch (_) { /* 单桶失败不阻塞其余桶 */ }
break;
default:
if (sv) setSyncBucket(profile.id, bucket.game, sv.updatedAt, sv.rev);
}
}
}
// ---------- 档案切换(先全部 flush 完成,再回灌/倒灌,失败即中止——01 §4.6) ----------
async function switchTo(targetId) {
const list = readRegistry(localStorage);
const from = getCurrentProfile(localStorage);
const target = list.find((p) => p.id === targetId);
if (!target || (from && from.id === targetId)) return false;
if (from && from.token) {
try {
await syncProfile(from); // flush:当前档案进度全部上云
} catch (e) {
announce(`同步失败,已取消切换:${e.message}`);
return false;
}
}
if (from) stashSnapshot(localStorage, from.id); // 回灌:活动 key → 旧档案快照
restoreSnapshot(localStorage, target.id); // 倒灌:新档案快照 → 活动 key(先清空防串档)
setCurrentProfile(localStorage, target.id);
applyAuth(localStorage, target); // 游戏侧虚拟视图:soundgame.auth
let cur = getCurrentProfile(localStorage);
if (!cur.token && !PUBLIC_STATIC) {
// 纯本地档案遇到服务器可用:升级为服务器档案(首访时服务器短暂不可达的自愈)
try { await bindLocalProfile(cur); cur = getCurrentProfile(localStorage); } catch (_) {}
}
try {
await syncProfile(cur); // 回拉:服务器进度倒灌 + 本地新进度上云
} catch (e) {
announce(`已切换到${cur.name}(部分云端进度拉取失败:${e.message})`);
reflectSettings(); renderUserArea(); refreshCards();
return true;
}
reflectSettings(); renderUserArea(); refreshCards();
announce(`已切换到${cur.name}`);
return true;
}
/** 纯本地档案升级为服务器档案:快照/同步表换 id,注册表原位替换,名字保留。 */
async function bindLocalProfile(local) {
const r = await api('device', { method: 'POST', body: '{}' });
moveStorageKey(snapshotKeyOf(local.id), snapshotKeyOf(r.profileId));
moveStorageKey(syncKeyOf(local.id), syncKeyOf(r.profileId));
const list = readRegistry(localStorage).map((p) =>
p.id === local.id ? { ...p, id: r.profileId, token: r.token } : p);
writeRegistry(localStorage, list);
setCurrentProfile(localStorage, r.profileId);
if (r.name !== local.name) {
await api('profile', { method: 'PUT', body: JSON.stringify({ name: local.name }) }, r.token)
.catch(() => { /* 服务器命名失败不影响本地使用 */ });
}
applyAuth(localStorage, getCurrentProfile(localStorage));
}
function newLocalProfile() {
return {
id: 'L' + Math.random().toString(36).slice(2, 10),
name: nextLocalProfileName(readRegistry(localStorage)),
token: null,
};
}
async function createNewProfile() {
closeMenu();
let p;
if (PUBLIC_STATIC) {
p = newLocalProfile();
} else {
try {
const r = await api('device', { method: 'POST', body: '{}' });
p = { id: r.profileId, name: r.name, token: r.token };
} catch (_) {
p = newLocalProfile(); // 服务器不可达:纯本地档案,之后自动补绑
}
}
const list = readRegistry(localStorage);
list.push(p);
writeRegistry(localStorage, list);
const ok = await switchTo(p.id);
announce(ok === false ? '已取消创建' : `已创建玩家档案:${p.name}`);
}
// ---------- 首次初始化:0 输入 0 点击静默建档 ----------
async function init() {
if (readRegistry(localStorage).length === 0) {
let first = null;
let created = false;
// v1 旧登录态({username, token})收养为首个档案(01 §4.4)
const legacy = migrateLegacyAuth(localStorage);
if (legacy && legacy.token && !PUBLIC_STATIC) {
try {
const r = await api('profile', { method: 'GET' }, legacy.token);
first = { id: r.profileId, name: r.name, token: legacy.token, claimed: !!r.claimed };
} catch (_) { first = null; /* token 失效:走匿名建档,旧档可稍后名字+PIN 找回 */ }
}
if (!first) {
created = true;
if (PUBLIC_STATIC) {
first = newLocalProfile();
} else {
try {
const r = await api('device', { method: 'POST', body: '{}' });
first = { id: r.profileId, name: r.name, token: r.token };
} catch (_) {
first = newLocalProfile();
}
}
}
writeRegistry(localStorage, [first]);
setCurrentProfile(localStorage, first.id);
applyAuth(localStorage, first);
renderUserArea(); refreshCards();
if (created) announce(`已创建玩家档案:${first.name}`);
// 既有本地进度「认领」进首个档案并上云(游客数据原地升级,不丢——01 §4.4)
try { await syncProfile(first); refreshCards(); } catch (_) { /* 离线:只留本地 */ }
return;
}
let cur = getCurrentProfile(localStorage) || readRegistry(localStorage)[0];
setCurrentProfile(localStorage, cur.id);
if (!cur.token && !PUBLIC_STATIC) {
try {
await bindLocalProfile(cur);
cur = getCurrentProfile(localStorage);
} catch (_) { /* 服务器仍不可达:保持纯本地档案 */ }
}
applyAuth(localStorage, cur);
renderUserArea(); refreshCards();
try {
await syncProfile(cur);
refreshCards();
} catch (_) { /* 离线:本地主存,游戏照玩 */ }
}
// ---------- 玩家区 UI ----------
function renderUserArea() {
const cur = getCurrentProfile(localStorage);
profileBtn.textContent = cur ? `玩家:${cur.name}` : '玩家:…';
profileBtn.setAttribute('aria-label', cur ? `切换玩家,当前玩家:${cur.name}` : '切换玩家');
const online = !!(cur && cur.token);
pinForm.hidden = !online;
loginForm.hidden = !online;
offlineNote.hidden = online || !cur;
closeMenu();
}
function menuButton(text, label, onclick) {
const b = document.createElement('button');
b.textContent = text;
if (label) b.setAttribute('aria-label', label);
b.onclick = onclick;
return b;
}
function openMenu() {
const list = readRegistry(localStorage);
const cur = getCurrentProfile(localStorage);
profileMenu.innerHTML = '';
for (const p of list) {
const isCur = !!cur && p.id === cur.id;
const b = menuButton(
(isCur ? '✓ ' : '') + p.name + (p.claimed ? '(已设PIN)' : ''),
isCur ? `当前玩家:${p.name}` : `切换到玩家:${p.name}`,
() => {
closeMenu();
if (isCur) { announce(`当前就是${p.name}`); return; }
switchTo(p.id);
},
);
b.setAttribute('role', 'menuitemradio');
b.setAttribute('aria-checked', String(isCur));
profileMenu.append(b);
}
const newBtn = menuButton('+ 新玩家', '新建玩家档案', createNewProfile);
newBtn.setAttribute('role', 'menuitem');
profileMenu.append(newBtn);
const manageBtn = menuButton('档案管理(改名 / 设 PIN / 登录)', '打开档案管理', () => {
closeMenu();
profileManage.hidden = !profileManage.hidden;
if (!profileManage.hidden) renameInput.focus();
});
manageBtn.setAttribute('role', 'menuitem');
profileManage.hidden = true;
profileMenu.append(manageBtn);
profileMenu.hidden = false;
profileBtn.setAttribute('aria-expanded', 'true');
const first = profileMenu.querySelector('button');
if (first) first.focus();
}
function closeMenu() {
profileMenu.hidden = true;
profileMenu.innerHTML = '';
profileBtn.setAttribute('aria-expanded', 'false');
}
profileBtn.onclick = () => {
if (profileMenu.hidden) openMenu();
else closeMenu();
};
document.addEventListener('click', (e) => {
if (!profileMenu.hidden && !document.getElementById('user-panel').contains(e.target)) closeMenu();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (!profileMenu.hidden) { closeMenu(); profileBtn.focus(); }
else if (!profileManage.hidden) profileManage.hidden = true;
}
});
// ---------- 档案管理:改名 / 设 PIN / 名字+PIN 登录 ----------
renameBtn.onclick = async () => {
const cur = getCurrentProfile(localStorage);
if (!cur) return;
const name = renameInput.value.trim();
if (!NAME_RE.test(name)) { announce('名字需为 1~24 位字母/数字/中文/_/-'); return; }
try {
if (cur.token) await api('profile', { method: 'PUT', body: JSON.stringify({ name }) }, cur.token);
} catch (e) { announce(e.message); return; }
const list = readRegistry(localStorage).map((p) => (p.id === cur.id ? { ...p, name } : p));
writeRegistry(localStorage, list);
applyAuth(localStorage, getCurrentProfile(localStorage)); // soundgame.auth.username 同步
renameInput.value = '';
renderUserArea();
announce(`已改名为${name}`);
};
pinBtn.onclick = async () => {
const cur = getCurrentProfile(localStorage);
if (!cur || !cur.token) { announce('离线档案不支持 PIN(需要家庭服务器)'); return; }
const pin = pinInput.value.trim();
if (!PIN_RE.test(pin)) { announce('PIN 需为 4~6 位数字'); return; }
try {
await api('claim', { method: 'POST', body: JSON.stringify({ pin }) }, cur.token);
} catch (e) { announce(e.message); return; }
const list = readRegistry(localStorage).map((p) => (p.id === cur.id ? { ...p, claimed: true } : p));
writeRegistry(localStorage, list);
pinInput.value = '';
renderUserArea();
announce(`已为${cur.name}设置 PIN,可在其他设备用「${cur.name} + PIN」登录`);
};
loginBtn.onclick = async () => {
const cur = getCurrentProfile(localStorage);
if (!cur || !cur.token) { announce('离线档案不支持登录(需要家庭服务器)'); return; }
const name = loginNameInput.value.trim();
const pin = loginPinInput.value.trim();
if (!NAME_RE.test(name)) { announce('请输入要登录的名字'); return; }
if (!PIN_RE.test(pin)) { announce('PIN 需为 4~6 位数字'); return; }
// 先把本机当前进度全部 flush 上去,确保服务器端合并时两边的桶都最新
try {
await syncProfile(cur);
} catch (e) { announce(`同步失败:${e.message}`); return; }
let r;
try {
r = await api('claim', { method: 'POST', body: JSON.stringify({ name, pin }) }, cur.token);
} catch (e) { announce(e.message); return; }
if (r.profileId && r.profileId !== cur.id) {
// 服务器已把本机 token 改绑目标档案:搬运快照/同步表,注册表原位替换
moveStorageKey(snapshotKeyOf(cur.id), snapshotKeyOf(r.profileId));
moveStorageKey(syncKeyOf(cur.id), syncKeyOf(r.profileId));
const list = readRegistry(localStorage).map((p) =>
p.id === cur.id ? { id: r.profileId, name: r.name, token: cur.token, claimed: true } : p);
writeRegistry(localStorage, list);
setCurrentProfile(localStorage, r.profileId);
applyAuth(localStorage, getCurrentProfile(localStorage));
} else {
const list = readRegistry(localStorage).map((p) =>
p.id === cur.id ? { ...p, claimed: true } : p);
writeRegistry(localStorage, list);
}
const now = getCurrentProfile(localStorage);
try { await syncProfile(now); } catch (_) { /* 合并结果稍后再拉 */ }
loginNameInput.value = '';
loginPinInput.value = '';
renderUserArea(); refreshCards(); reflectSettings();
announce(r.merged ? `欢迎回来,${now.name}(两台设备进度已合并)` : `已登录:${now.name}`);
};
// ---------- 设置区(含「操作方式」,settings.input 由引擎侧消费) ----------
const selVoice = document.getElementById('sel-voice');
const selSpeed = document.getElementById('sel-speed');
const selBgm = document.getElementById('sel-bgm');
const selGuide = document.getElementById('sel-guide');
const selInput = document.getElementById('sel-input');
function readRawSettings() { return readStorageJSON(localStorage, SETTINGS_KEY, {}) || {}; }
function reflectSettings() {
const s = settings.get();
@@ -312,76 +734,62 @@
selSpeed.value = String(s.speed);
selBgm.value = String(s.bgm);
selGuide.value = s.guide;
const inp = readRawSettings().input;
selInput.value = INPUT_MODES.includes(inp) ? inp : 'auto';
}
/** 服务器 _settings 桶倒灌:走 settings.set 白名单;input 再并入原始 JSON 垫底。 */
function applyServerSettings(data) {
if (!data || typeof data !== 'object') return;
const { input, ...rest } = data;
const patch = { ...rest };
if (INPUT_MODES.includes(input)) patch.input = input; // 白名单外键不进 set(避免引擎告警噪音)
settings.set(patch);
try {
const raw = readRawSettings();
if (INPUT_MODES.includes(input)) raw.input = input;
localStorage.setItem(SETTINGS_KEY, JSON.stringify(raw));
} catch (_) { /* 隐私模式 */ }
reflectSettings();
}
async function pushSettingsBucket() {
const cur = getCurrentProfile(localStorage);
if (!cur || !cur.token) return;
const data = { ...settings.get(), input: readRawSettings().input || 'auto' };
const base = getSyncMap(cur.id)._settings && getSyncMap(cur.id)._settings.updatedAt;
putBucket(cur, '_settings', JSON.stringify(data), base).catch(() => { /* 离线只留本地 */ });
}
function onSettingChange() {
const patch = {
settings.set({
voice: selVoice.value,
speed: Number(selSpeed.value),
bgm: Number(selBgm.value),
guide: selGuide.value,
};
settings.set(patch); // 始终写 localStorage
// 已登录时同步到服务器(game 键 '_settings')
if (getAuth()) {
api('progress', {
method: 'PUT',
body: JSON.stringify({ game: '_settings', data: settings.get() }),
}).catch(() => { /* 离线时只留本地 */ });
}
input: selInput.value,
});
// 兼容垫底:若引擎侧 input 白名单尚未合入(settings.set 会忽略并警告),
// 直接并入持久化 JSON,引擎支持后自动生效。
try {
const raw = readRawSettings();
raw.input = selInput.value;
localStorage.setItem(SETTINGS_KEY, JSON.stringify(raw));
} catch (_) { /* 隐私模式 */ }
pushSettingsBucket();
}
selVoice.onchange = onSettingChange;
selSpeed.onchange = onSettingChange;
selBgm.onchange = onSettingChange;
selGuide.onchange = onSettingChange;
// 登录后从服务器拉回设置(服务器为准)
async function pullSettings() {
try {
const r = await api('progress?game=_settings');
if (r.data && typeof r.data === 'object') {
settings.set(r.data);
reflectSettings();
}
} catch (_) { /* 无服务器或尚未保存过设置 */ }
}
// ---------- 游戏进度摘要 ----------
function clearProgress() {
document.querySelectorAll('a.card .progress').forEach((el) => { el.textContent = ''; });
}
async function loadProgress() {
clearProgress();
if (!getAuth()) return;
for (const game of GAMES) {
try {
const r = await api('progress?game=' + encodeURIComponent(game));
if (r.data && typeof r.data.summary === 'string' && r.data.summary) {
const el = document.querySelector(`a.card[data-game="${game}"] .progress`);
if (el) el.textContent = r.data.summary;
}
} catch (_) { /* 单个游戏拉取失败不影响其他 */ }
}
}
selInput.onchange = onSettingChange;
// ---------- 启动 ----------
// 公网站点只托管静态文件,不跑用户 API(PIN 明文,不宜暴露)。
// 本地 ./web/serve.sh(http + 非 443)仍显示登录。
const PUBLIC_STATIC =
location.protocol === 'https:' && location.hostname.endsWith('xiangsilian.com');
reflectSettings();
if (PUBLIC_STATIC) {
const userPanel = document.getElementById('user-panel');
if (userPanel) userPanel.hidden = true;
} else {
renderUserArea();
if (getAuth()) {
pullSettings();
loadProgress();
}
}
renderUserArea();
refreshCards();
init(); // 网络部分全部静默失败降级:不登录(无服务器)也完整可玩
</script>
</body>
</html>