feat(platform/P1): 配对码跨设备 + 认领打磨 + 键盘语音双套资产(两 agent 并行 + 集成收口)

配对码与认领(B):
- POST /api/pair/start|join:6 位码 10 分钟单次有效、仅存内存零泄露面、5 连错 per-IP 封锁 10 分钟;join=新设备档案按桶 LWW 并入老档案+token 改绑(复用 claim-merge);日志脱敏
- 大厅:生成配对码(speechSynthesis 逐位朗读+aria-live+重听)/从其他设备转入(one-time-code 输入框)/从本机移除/401 过期自愈引导/aria 补全/玩家名可听化
- api-smoke 12/12(配对全流程/频控/封锁/过期自愈/移除/digitsToCn)+ CDP 真浏览器 E2E 22/22

键盘语音双套资产(A,路线 a):
- 164 变体句(8 游戏 grep 实测,超出调研预估 153:含轻触/点两下/按住变体)×2 性别=328 生成 656 文件 36.9 分钟;触屏主资产零字节改动(PCM 逐样本比对后还原)
- 引擎 _candidates 三跳路由 key→gender→平铺(inputMode 跟随);缓存键 mode|gender|id;engine-input 新增 K9 路由断言(72 条)
- check_all_assets 增 key 列(两性别一致且 ⊆ 主集)8 游戏全绿;voice_text.json 增 __key__ 表;live control.mjs key 会话对照键盘措辞验收(集成)
- voice-ids 文档:8 游戏键盘变体小节由 KEY_LINES 程序化生成(与台词表逐字断言一致);补齐 storyline/escape/simon 三个历史缺失文档;design README 增 new8 索引
- mock 适配:playtest persona 加 maxTouchPoints=1 保持触屏链断言语义零改动

回归:12 套件全绿(8 playtest + guide 122 + engine-voice 19 + engine-input 72 + api-smoke 12/12)
同批入库:批次 2/3 已完稿设计文档(17-abyss/18-detective/19-cipher 全套,20-22 在途)
This commit is contained in:
xsl
2026-09-10 00:43:23 +08:00
parent ede522dda1
commit 566241e05d
694 changed files with 4242 additions and 168 deletions
+277 -39
View File
@@ -86,6 +86,16 @@
font-size: 14px;
cursor: pointer;
}
#pair-code-display {
align-self: center;
min-width: 150px;
padding: 6px 10px;
font-size: 22px;
letter-spacing: 0.3em;
text-align: center;
color: #7ec699;
}
#remove-form .manage-row button { background: #3c4043; }
.manage-note { font-size: 12px; color: #5f6368; margin: 4px 0 0; }
/* 设置区 */
#settings-row { display: flex; flex-wrap: wrap; gap: 16px; }
@@ -167,7 +177,7 @@
<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">
<input id="pin-input" inputmode="numeric" maxlength="6" autocomplete="off" aria-describedby="pin-hint">
<button id="pin-btn">设置 PIN</button>
</div>
<p class="manage-note" id="pin-hint">PIN 是可选项,只为防家人恶作剧;不设也完整可玩。</p>
@@ -176,11 +186,36 @@
<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">
<input id="login-pin-input" inputmode="numeric" maxlength="6" autocomplete="off"
aria-label="登录 PIN,4 到 6 位数字">
<button id="login-btn">登录</button>
</div>
</div>
<p class="manage-note" id="offline-note" hidden>当前为离线档案(无本地服务器):可改名、可切换,PIN 与登录需要家庭服务器。</p>
<div class="manage-form" id="pair-out-form">
<label for="pair-code-display">转移到新设备:在本机生成 6 位配对码,新设备输入后即可转入本档案</label>
<div class="manage-row">
<output id="pair-code-display" aria-label="配对码未生成">------</output>
<button id="pair-start-btn">生成配对码</button>
<button id="pair-replay-btn" hidden aria-label="重听配对码,逐位朗读">重听</button>
</div>
<p class="manage-note" id="pair-out-note">配对码逐位朗读,10 分钟内单次有效;重新生成会使旧码立即作废。</p>
</div>
<div class="manage-form" id="pair-in-form">
<label for="pair-code-input">从其他设备转入:输入老设备上显示的 6 位配对码,本机当前进度会合并过去</label>
<div class="manage-row">
<input id="pair-code-input" inputmode="numeric" maxlength="6" autocomplete="one-time-code"
aria-describedby="pair-in-hint">
<button id="pair-join-btn">转入</button>
</div>
<p class="manage-note" id="pair-in-hint">在老设备的「档案管理 → 转移到新设备」处获取配对码;连错 5 次需等待 10 分钟。</p>
</div>
<div class="manage-form" id="remove-form">
<div class="manage-row">
<button id="remove-btn" aria-describedby="remove-hint">从本机移除(不删云端)</button>
</div>
<p class="manage-note" id="remove-hint">清掉本机的档案快照与登录凭证并回到匿名新档案;云端进度保留,可随时用「名字 + PIN」或新配对码找回。</p>
</div>
<p class="manage-note" id="offline-note" hidden>当前为离线档案(无本地服务器):可改名、可切换,配对与登录需要家庭服务器。</p>
</div>
<p id="user-msg" role="status" aria-live="polite"></p>
</section>
@@ -291,8 +326,8 @@
import { settings } from './engine/settings.js';
import {
BUCKETS, SETTINGS_KEY,
applyAuth, decideBucket, getCurrentProfile, migrateLegacyAuth,
nextLocalProfileName, readRegistry, readStorageJSON, restoreSnapshot,
applyAuth, cnNum, decideBucket, digitsToCn, getCurrentProfile, migrateLegacyAuth,
nextLocalProfileName, readRegistry, readStorageJSON, removeProfile, restoreSnapshot,
setCurrentProfile, snapshotKeyOf, stashSnapshot, syncKeyOf,
writeRegistry, writeStorageJSON,
} from './profiles.js';
@@ -311,9 +346,13 @@
// ---------- 服务器 API(不可达时静默降级为纯本地档案) ----------
/**
* tokenOverride === null → 显式匿名(不带 Authorization,如配对转入、名+PIN 换发登录);
* 缺省 → 当前档案 token。
*/
async function api(path, opts = {}, tokenOverride) {
const cur = getCurrentProfile(localStorage);
const token = tokenOverride || (cur && cur.token) || null;
const token = tokenOverride !== undefined ? tokenOverride : ((cur && cur.token) || null);
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token;
const resp = await fetch('/api/' + path, { ...opts, headers });
@@ -343,9 +382,76 @@
const loginPinInput = document.getElementById('login-pin-input');
const loginBtn = document.getElementById('login-btn');
const offlineNote = document.getElementById('offline-note');
const pairOutForm = document.getElementById('pair-out-form');
const pairCodeDisplay = document.getElementById('pair-code-display');
const pairStartBtn = document.getElementById('pair-start-btn');
const pairReplayBtn = document.getElementById('pair-replay-btn');
const pairInForm = document.getElementById('pair-in-form');
const pairCodeInput = document.getElementById('pair-code-input');
const pairJoinBtn = document.getElementById('pair-join-btn');
const removeBtn = document.getElementById('remove-btn');
function announce(text) { userMsg.textContent = text || ''; }
// ---------- 可听化(P1 裁定:配置类数字与自定义玩家名不走 TTS 资产,用浏览器 ----------
// ---------- speechSynthesis;中文 voice 优先;失败静默降级,aria-live 文本兜底) ----------
let zhVoice = null;
function pickZhVoice() {
try {
const voices = window.speechSynthesis ? window.speechSynthesis.getVoices() : [];
zhVoice = voices.find((v) => /^zh([-_]|$)/i.test(v.lang) && /cN|Chinese|中文|普通话/i.test(v.name))
|| voices.find((v) => /^zh([-_]|$)/i.test(v.lang))
|| null;
} catch (_) { zhVoice = null; }
}
if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
pickZhVoice();
try { window.speechSynthesis.addEventListener('voiceschanged', pickZhVoice); } catch (_) { /* 老浏览器 */ }
}
/** 返回是否成功入队(不可用/异常 → false,调用方自行降级)。 */
function speak(text, rate = 1) {
try {
if (!('speechSynthesis' in window)) return false;
const u = new SpeechSynthesisUtterance(text);
u.lang = 'zh-CN';
u.rate = rate;
if (zhVoice) u.voice = zhVoice;
window.speechSynthesis.cancel(); // 顶掉上一句(重听配对码场景)
window.speechSynthesis.speak(u);
return true;
} catch (_) { return false; }
}
/** 配对码逐位朗读:'048920' → 「配对码,零,四,八,九,二,零」。 */
function speakPairCode(code) {
return speak(`配对码,${digitsToCn(code).split(' ').join(',')}。`, 0.9);
}
function pairCodeSpeech(code) { return digitsToCn(code).split(' ').join(','); }
/** 玩家名可听化 P1 版:speechSynthesis 念名(中文 voice 优先);不可用则静默降级为「玩家 N」。 */
function speakProfileName(profile) {
if (!profile) return;
if (speak(profile.name)) return;
const list = readRegistry(localStorage);
const idx = list.findIndex((p) => p.id === profile.id);
speak('玩家' + cnNum(idx >= 0 ? idx + 1 : 1));
}
// ---------- token 过期(180 天滑动到期)引导(01 遗留清单) ----------
function isAuthExpired(e) {
return !!e && e.status === 401 && String(e.message || '').includes('未授权');
}
const AUTH_EXPIRED_HINT = '档案已过期:请展开档案管理,用「名字 + PIN」重新登录、从其他设备转入,或新建玩家。';
function guideAuthExpired() {
announce(AUTH_EXPIRED_HINT);
profileManage.hidden = false;
loginNameInput.focus();
}
// ---------- 同步映射表(各档案已知的服务器桶 updatedAt/rev) ----------
function getSyncMap(id) { return readStorageJSON(localStorage, syncKeyOf(id), {}) || {}; }
@@ -466,12 +572,14 @@
try {
await syncProfile(cur); // 回拉:服务器进度倒灌 + 本地新进度上云
} catch (e) {
announce(`已切换到${cur.name}(部分云端进度拉取失败:${e.message})`);
reflectSettings(); renderUserArea(); refreshCards();
if (isAuthExpired(e)) { guideAuthExpired(); return true; } // token 180 天滑动到期
announce(`已切换到${cur.name}(部分云端进度拉取失败:${e.message})`);
return true;
}
reflectSettings(); renderUserArea(); refreshCards();
announce(`已切换到${cur.name}`);
speakProfileName(cur);
return true;
}
@@ -499,7 +607,7 @@
};
}
async function createNewProfile() {
async function createNewProfile({ quiet = false } = {}) {
closeMenu();
let p;
if (PUBLIC_STATIC) {
@@ -516,7 +624,11 @@
list.push(p);
writeRegistry(localStorage, list);
const ok = await switchTo(p.id);
announce(ok === false ? '已取消创建' : `已创建玩家档案:${p.name}`);
if (!quiet) {
announce(ok === false ? '已取消创建' : `已创建玩家档案:${p.name}`);
speakProfileName(p);
}
return p;
}
// ---------- 首次初始化:0 输入 0 点击静默建档 ----------
@@ -550,9 +662,9 @@
setCurrentProfile(localStorage, first.id);
applyAuth(localStorage, first);
renderUserArea(); refreshCards();
if (created) announce(`已创建玩家档案:${first.name}`);
if (created) { announce(`已创建玩家档案:${first.name}`); speakProfileName(first); }
// 既有本地进度「认领」进首个档案并上云(游客数据原地升级,不丢——01 §4.4)
try { await syncProfile(first); refreshCards(); } catch (_) { /* 离线:只留本地 */ }
try { await syncProfile(first); refreshCards(); } catch (e) { if (isAuthExpired(e)) guideAuthExpired(); }
return;
}
let cur = getCurrentProfile(localStorage) || readRegistry(localStorage)[0];
@@ -568,7 +680,9 @@
try {
await syncProfile(cur);
refreshCards();
} catch (_) { /* 离线:本地主存,游戏照玩 */ }
} catch (e) {
if (isAuthExpired(e)) guideAuthExpired(); // token 180 天滑动到期 → 重新登录或新建
}
}
// ---------- 玩家区 UI ----------
@@ -580,7 +694,10 @@
const online = !!(cur && cur.token);
pinForm.hidden = !online;
loginForm.hidden = !online;
pairOutForm.hidden = !online;
pairInForm.hidden = !online;
offlineNote.hidden = online || !cur;
resetPairCodeUI(); // 显示中的配对码属于切换前的档案,一并复位
closeMenu();
}
@@ -614,7 +731,7 @@
const newBtn = menuButton('+ 新玩家', '新建玩家档案', createNewProfile);
newBtn.setAttribute('role', 'menuitem');
profileMenu.append(newBtn);
const manageBtn = menuButton('档案管理(改名 / 设 PIN / 登录)', '打开档案管理', () => {
const manageBtn = menuButton('档案管理(改名 / 设 PIN / 转移设备 / 登录)', '打开档案管理', () => {
closeMenu();
profileManage.hidden = !profileManage.hidden;
if (!profileManage.hidden) renameInput.focus();
@@ -681,41 +798,162 @@
announce(`已为${cur.name}设置 PIN,可在其他设备用「${cur.name} + PIN」登录`);
};
/**
* join(配对转入)/ login(名+PIN 换发)成功后,把本机视图换绑到服务器档案:
* 快照/同步表搬 id、注册表原位替换(目标档案若已在本机列表则去重)、当前档案切换,
* 再 syncProfile 让本地较新的桶上云、云端合并结果倒灌。
*/
async function adoptBoundProfile(cur, r) {
const token = r.token || (cur && cur.token);
const entry = { id: r.profileId, name: r.name, token, claimed: !!r.claimed };
if (cur && r.profileId !== cur.id) {
moveStorageKey(snapshotKeyOf(cur.id), snapshotKeyOf(r.profileId));
moveStorageKey(syncKeyOf(cur.id), syncKeyOf(r.profileId));
}
const list = readRegistry(localStorage).filter((p) => p.id !== r.profileId);
const idx = cur ? list.findIndex((p) => p.id === cur.id) : -1;
if (idx >= 0) list[idx] = entry; else list.push(entry);
writeRegistry(localStorage, list);
setCurrentProfile(localStorage, r.profileId);
applyAuth(localStorage, entry);
try { await syncProfile(entry); } catch (_) { /* 合并结果稍后再拉 */ }
renderUserArea(); refreshCards(); reflectSettings();
return entry;
}
loginBtn.onclick = async () => {
const cur = getCurrentProfile(localStorage);
if (!cur || !cur.token) { announce('离线档案不支持登录(需要家庭服务器)'); return; }
if (!cur) 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);
if (PUBLIC_STATIC) { announce('纯静态形态不支持登录(需要家庭服务器)'); return; }
let r = null;
// 无 token(纯本地档案遇服务器可用)或 token 已过期:claim 走不通,直接 login 换发,
// 本地进度由客户端 syncProfile 按桶 LWW 推上去(与配对转入的无 token 分支同语义)
let deadToken = !cur.token;
if (!deadToken) {
// 先把本机当前进度全部 flush 上去,确保服务器端合并时两边的桶都最新
try {
await syncProfile(cur);
} catch (e) {
if (!isAuthExpired(e)) { announce(`同步失败:${e.message}`); return; }
deadToken = true;
}
}
if (!deadToken) {
try {
r = await api('claim', { method: 'POST', body: JSON.stringify({ name, pin }) }, cur.token);
} catch (e) {
if (!isAuthExpired(e)) { announce(e.message); return; }
deadToken = true;
}
}
if (deadToken) {
try {
r = await api('login', { method: 'POST', body: JSON.stringify({ username: name, pin }) }, null);
} catch (e) { announce(e.message); return; }
}
const now = getCurrentProfile(localStorage);
try { await syncProfile(now); } catch (_) { /* 合并结果稍后再拉 */ }
loginNameInput.value = '';
loginPinInput.value = '';
renderUserArea(); refreshCards(); reflectSettings();
announce(r.merged ? `欢迎回来,${now.name}(两台设备进度已合并)` : `已登录:${now.name}`);
if (!r.merged && r.profileId === cur.id && !deadToken) {
// 原地认领(名字+PIN 设到当前档案):只补 claimed 标记
const list = readRegistry(localStorage).map((p) => (p.id === cur.id ? { ...p, claimed: true } : p));
writeRegistry(localStorage, list);
renderUserArea();
announce(`已登录:${cur.name}`);
return;
}
const entry = await adoptBoundProfile(cur, r);
announce(r.merged ? `欢迎回来,${entry.name}(两台设备进度已合并)` : `已登录:${entry.name}`);
speakProfileName(entry);
};
// ---------- 档案管理:配对码(转移到新设备 / 从其他设备转入) + 从本机移除 ----------
let pairCode = null;
function resetPairCodeUI() {
pairCode = null;
pairCodeDisplay.textContent = '------';
pairCodeDisplay.setAttribute('aria-label', '配对码未生成');
pairReplayBtn.hidden = true;
}
pairStartBtn.onclick = async () => {
const cur = getCurrentProfile(localStorage);
if (!cur || !cur.token) { announce('离线档案不能生成配对码(需要家庭服务器)'); return; }
try {
const r = await api('pair/start', { method: 'POST' }, cur.token);
pairCode = r.code;
const until = new Date(r.expiresAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
pairCodeDisplay.textContent = r.code;
pairCodeDisplay.setAttribute('aria-label', `配对码 ${pairCodeSpeech(r.code)},${until} 前有效`);
pairReplayBtn.hidden = false;
announce(`配对码:${pairCodeSpeech(r.code)}。${until} 前单次有效,请在 10 分钟内于新设备输入`);
speakPairCode(r.code);
} catch (e) { announce(`配对码生成失败:${e.message}`); }
};
pairReplayBtn.onclick = () => {
if (!pairCode) return;
announce(`配对码:${pairCodeSpeech(pairCode)}`);
speakPairCode(pairCode);
};
pairJoinBtn.onclick = async () => {
const code = pairCodeInput.value.trim();
if (!/^\d{6}$/.test(code)) { announce('配对码需为 6 位数字'); return; }
const cur = getCurrentProfile(localStorage);
if (!cur) return;
// 先 flush 本机进度:join 在服务器端按桶 LWW 合并,两边都最新
if (cur.token) {
try {
await syncProfile(cur);
} catch (e) {
if (!isAuthExpired(e)) { announce(`同步失败:${e.message}`); return; }
// token 已过期:join 会把它视作无档案设备直接签发老档案的新 token,
// 本地进度随后由 syncProfile 客户端侧 LWW 推上去
}
}
let r;
try {
r = await api('pair/join', { method: 'POST', body: JSON.stringify({ code }) }, (cur.token || null));
} catch (e) {
announce(e.status === 404 ? '配对码不存在或已失效,请在老设备重新生成'
: e.status === 429 ? `尝试过多:${e.message}`
: e.status === 409 ? `${e.message}`
: `转入失败:${e.message}`);
return;
}
pairCodeInput.value = '';
const entry = await adoptBoundProfile(cur, r);
announce(r.merged
? `欢迎回来,${entry.name}(本机进度已按游戏合并,共 ${r.mergedGames} 项)`
: `欢迎回来,${entry.name}`);
speakProfileName(entry);
};
removeBtn.onclick = async () => {
const cur = getCurrentProfile(localStorage);
if (!cur) return;
const online = !!cur.token;
const ok = window.confirm(online
? `从本机移除「${cur.name}」?云端进度保留,可随时用「名字 + PIN」或新配对码找回。`
: `「${cur.name}」仅存本机(无云端副本),移除后本机进度将丢失。确定移除?`);
if (!ok) return;
if (online) {
try { await syncProfile(cur); } catch (_) { /* 尽力冲云:云端至少保留最后已知副本 */ }
try { await api('logout', { method: 'POST' }, cur.token); } catch (_) { /* 吊销失败:token 自然过期 */ }
}
const name = cur.name;
removeProfile(localStorage, cur.id); // 清快照/同步表/注册表项/CURRENT/AUTH(云端不动)
resetPairCodeUI();
await createNewProfile({ quiet: true }); // 回匿名新建(switchTo 倒灌前先清空活动 key)
const fresh = getCurrentProfile(localStorage);
announce(`已从本机移除${name}(云端保留),现在是新档案:${fresh ? fresh.name : '(无)'}`);
speakProfileName(fresh);
};
// ---------- 设置区(含「操作方式」,settings.input 由引擎侧消费) ----------