diff --git a/docs/PLAYING.md b/docs/PLAYING.md index f357981a..b631b08b 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -17,8 +17,8 @@ | 阶段 | 说明 | |---|---| | 确认本局信息 | 本局地图(标准模拟固定为「战场#01」;险境从 8 张地图中随机,绝境 / 终极从不含战场#01 的 7 张中随机)、敌方派系(三种特训敌人类型,如折射、飞行:和官方一样,每种占全局 15 个回合位中的 3 个,第 14 回合领袖战和第 15 回合隐秘核心也各占一个位,所以险境 / 绝境 / 终极第 1–13 回合中有 7–9 波是这三种类型、每种最多 3 波,其余各波是「特异」类型;每波只有一种类型)、**被禁用的盟约及其干员**、领袖预告。所有人确认后继续;同盟模拟最多 25 秒;只有你一名玩家时(独立模拟,或同盟模拟只带 AI 队友)不限时。 | -| 选择策略 | 40 名策略(分队长)决定**初始目标生命值(20–45)**和一条永久规则。同盟:随机顺序轮流选,**每人 30 秒**,右上角和「决策中」显示同一个倒计时;AI 队友立即选完;队友已选的不能重复,每人可**跳过一次**(排到最后);超时自动选你**当前选中**的策略(已被队友选走则选华法琳)。独立:自由选择、不限时;同盟模拟只有你一名玩家时也不限时。选的时候可以点左侧决策顺序下方的「查看禁用盟约与干员」,再看一遍「确认本局信息」里的盟约(置灰、禁用人数)和本局禁用干员:只能查看,倒计时照常走,底部也显示轮到谁和剩余秒数;点「关闭」、点窗口外面或按 Esc 关闭,轮到下一位时会自动关闭。 | -| 机变 | 部分回合开始前出现:同盟共 6 张卡轮流各取 1 张(首位 30 秒,其余 16 秒;只有你一名玩家时不限时),独立 3 选 1。卡片包括装备、资金、干员、盟约层数、悬赏等;**悬赏决策**为你的作战添加悬赏敌人,卡上用蓝字标出持续多久:「下场作战」/「接下来两场作战」的只出现一场或两场,之后不再出现;官方的「多轮悬赏」(原文写「之后的每场作战」)在这里也按「接下来两场作战」处理,只出现两场(如教鞭的法术大师A2·多轮战术特训)。各回合的悬赏决策按官方对局截图来出:第 3 回合是 6 张「接下来两场作战」的悬赏(3 张 1 资金、2 张 2 资金、1 张 3 资金);第 9 回合是各首领的「下场作战」悬赏和「源石虫·特训」;绝境 / 终极的第 11 回合多半是悬赏决策(也可能是机密商店或战术决策),其中的悬赏通常有 1 只特异巨兽,其余是各阵营的「下场战斗」悬赏。击倒悬赏敌人的人(联防中的队友也算)获得资金;右侧效果栏会写「还剩 N 场作战」。**机密商店**的 6 件装备都免费,和官方一样可能出现两件相同的装备(各算一张卡,可以分别被选走);第 11 回合(绝境 / 终极)的机密商店按官方:2 件 VI 阶、至少 1 件 V 阶和 1 枚盟约之币,标准 / 险境较早回合的机密商店各阶装备都可能出现。**战术决策**也和官方一样可能出现两张相同的卡(官方截图里出现过两张「补给」;各算一张卡,可以分别被选走,每张只生效一次,两人各选一张全队卡则效果叠加);第 11 回合(绝境 / 终极)的战术决策按官方截图只出盟誓、驰援、列装 / 财富 / 补给 / 整备 / 升华等增益卡,不出削弱敌人的卡和战场演变卡。每张卡左上是图标和名称,下面写着完整效果(与详情卡相同的官方描述,手机上也显示);已被选走的卡变暗,右上角显示选走它的玩家头像(悬停可看名字)。选卡要**点两次**:第一次只选中(金框 +「确认选择 · 再次点击」),再点同一张或点右上的「确认选择」才生效;点别的卡换选,按 Esc 或点空白处取消。 | +| 选择策略 | 40 名策略(分队长)决定**初始目标生命值(20–45)**和一条永久规则。同盟:随机顺序轮流选,**每人 30 秒**,右上角和「决策中」显示同一个倒计时;AI 队友立即选完;队友已选的不能重复,每人可**跳过一次**(排到最后);创建者可以在同盟等待室打开「**AI 队友最后选择**」(默认关闭):选择策略和机变轮选时所有博士都排在 AI 队友前面(博士之间、AI 之间仍是随机顺序,AI 托管或掉线的博士也算博士),跳过时排到其他还没选的博士之后、AI 之前(没有其他博士时排到最后);超时自动选你**当前选中**的策略(已被队友选走则选华法琳)。独立:自由选择、不限时;同盟模拟只有你一名玩家时也不限时。选的时候可以点左侧决策顺序下方的「查看禁用盟约与干员」,再看一遍「确认本局信息」里的盟约(置灰、禁用人数)和本局禁用干员:只能查看,倒计时照常走,底部也显示轮到谁和剩余秒数;点「关闭」、点窗口外面或按 Esc 关闭,轮到下一位时会自动关闭。 | +| 机变 | 部分回合开始前出现:同盟共 6 张卡轮流各取 1 张(随机顺序,开启「AI 队友最后选择」时博士先选;首位 30 秒,其余 16 秒;只有你一名玩家时不限时),独立 3 选 1。卡片包括装备、资金、干员、盟约层数、悬赏等;**悬赏决策**为你的作战添加悬赏敌人,卡上用蓝字标出持续多久:「下场作战」/「接下来两场作战」的只出现一场或两场,之后不再出现;官方的「多轮悬赏」(原文写「之后的每场作战」)在这里也按「接下来两场作战」处理,只出现两场(如教鞭的法术大师A2·多轮战术特训)。各回合的悬赏决策按官方对局截图来出:第 3 回合是 6 张「接下来两场作战」的悬赏(3 张 1 资金、2 张 2 资金、1 张 3 资金);第 9 回合是各首领的「下场作战」悬赏和「源石虫·特训」;绝境 / 终极的第 11 回合多半是悬赏决策(也可能是机密商店或战术决策),其中的悬赏通常有 1 只特异巨兽,其余是各阵营的「下场战斗」悬赏。击倒悬赏敌人的人(联防中的队友也算)获得资金;右侧效果栏会写「还剩 N 场作战」。**机密商店**的 6 件装备都免费,和官方一样可能出现两件相同的装备(各算一张卡,可以分别被选走);第 11 回合(绝境 / 终极)的机密商店按官方:2 件 VI 阶、至少 1 件 V 阶和 1 枚盟约之币,标准 / 险境较早回合的机密商店各阶装备都可能出现。**战术决策**也和官方一样可能出现两张相同的卡(官方截图里出现过两张「补给」;各算一张卡,可以分别被选走,每张只生效一次,两人各选一张全队卡则效果叠加);第 11 回合(绝境 / 终极)的战术决策按官方截图只出盟誓、驰援、列装 / 财富 / 补给 / 整备 / 升华等增益卡,不出削弱敌人的卡和战场演变卡。每张卡左上是图标和名称,下面写着完整效果(与详情卡相同的官方描述,手机上也显示);已被选走的卡变暗,右上角显示选走它的玩家头像(悬停可看名字)。选卡要**点两次**:第一次只选中(金框 +「确认选择 · 再次点击」),再点同一张或点右上的「确认选择」才生效;点别的卡换选,按 Esc 或点空白处取消。 | | 休整期 | 购买、摆阵、配装备、冻结 / 刷新、升级调度中心;同盟有倒计时(65–215 秒,随回合增加);独立模拟、以及只有你一名玩家(只带 AI 队友)的同盟模拟不限时。所有人「准备就绪」即提前开战,全员就绪前可取消。 | - **特殊地形点一下就有说明**:战场上的**活性源石**、**沼泽**、**排气格栅**、**深水区**,以及**红门 / 蓝门 / 传送点**,点一下就会弹出它自己的说明卡——名称、机制,以及**这一关自己的数值**(比如战场#04 的活性源石:每秒 70 点真实伤害、攻击力 +20%、攻击速度 +20、持续 300 秒)。休整期和作战期都能点。普通地板 / 道路 / 高台点了不会弹东西,所以点一下仍然只是「取消选中、关掉卡片」。 | 作战 | 每人在自己的战场各自作战(战斗在你的浏览器里模拟,2 倍速)。干员按**逐列从左到右、每列从上到下**的顺序(官方「从上到下>从左到右」:最左一列最先)自动部署并消耗部署费用(初始 10,+1/秒)。时限 45–115 秒(实际秒数,即屏幕上的倒计时),时间到仍存活的敌人算漏怪。有敌人进入蓝门时,顶栏的目标生命值会**立即**显示扣除后的数值(红字 + 「−N」,每回合至多 10 点),结算时确定;联防阶段标注「联防中」,队友拦下的部分最后不扣。左侧队伍栏同样显示队友的待扣生命值。被击倒的干员**留在倒下的格子上**(倒地姿势、略微变灰),之后也在这一格再部署(与官方一样;倒在其他干员或召唤物初始位置上的,会回到自己的初始位置躺下,召唤物已经消失也一样;唯一的例外是乌尔比安:三技能把他移到船锚处期间被击倒时,他回到自己的部署位躺下、再部署——这是项目维护者 2026-10-07 应社区反馈有意做出的改动,与官方「原地倒下」不同);因自身效果强制退场的干员(史尔特尔「余烬」结束、耀骑士临光 2 技能结束等)同样倒在原地、在原地再部署;倒地干员所在的格子**不会被占用**,突袭盟约的再部署、援军、召唤物都会避开它。头上的圆圈是再部署倒计时:绿色圆环 + 秒数;变成黄色「DP」表示部署费用不够。血条下方的一行是**元素损伤**(与原版一致:元素图标 + 白条):干员为带图标的彩色圆盘,敌人为小号纯色圆盘,右侧白条是剩余元素值,像血条一样从右往左减少,白条耗尽的那一下就是爆条(被大额元素损伤一次打掉一截时白条也会一次少一截,例如掠海漂移体每击约为 25%–50%);爆条后进入爆发冷却(灼燃 / 神经 / 侵蚀 10 秒,敌方侵蚀 8 秒,凋亡 15 秒),这时白条变成**元素颜色**(神经青绿、侵蚀蓝、灼燃橙红、凋亡灰紫)并在冷却期间慢慢回满、图标闪烁——彩色条表示已经爆条、正在承受爆发效果,不是还剩的元素值;期间所有元素都不会累积也无法被治疗,结束后清零。元素爆发造成的是**无来源伤害**,不吃干员的增伤、穿透和装备加成(例如深巡打海嗣时灼燃爆发固定 7000),击杀仍记在爆条的干员名下;元素脆弱只提高元素伤害,不加快元素条;脆弱不影响元素伤害。已被击杀的敌人不会再被附加元素损伤。 | @@ -170,3 +170,7 @@ 刷新、冻结、升级、撤退、出售和准备只在休整期有效,撤退 / 出售要先点选一名干员。方向轮盘(方向键预览、`Enter` 确认、`Esc` 取消)、「玩法说明」和「干员调配」的翻页键是这些界面自己的按键,不能修改。 **修改快捷键**:标题画面右上角或对局左下角的「设置」→「快捷键」。点一下要改的按键(只用键盘时按 Tab 移到它上面再按 Enter),出现「按下新按键…」后按新的键;按 `Esc` 取消,设置窗口不会关闭。可以用字母、数字、空格、标点符号键和 Delete / Insert / Home / End / PageUp / PageDown;`Esc`、`Tab`、`Enter`、方向键和 F1–F12 留给界面使用,也不能搭配 Ctrl / Alt / ⌘。新按键已被另一个快捷键使用时两者互换,列表下方会写明(例如把「撤退选中干员」改成 `X`,「出售选中干员」就换成原来的 `Q`)。「恢复默认」改回上表的按键。修改立即生效,商店栏、准备按钮和单位底部「撤退 / 出售」按钮上显示的按键也跟着变;设置和音量等一起保存在当前浏览器里(换浏览器或清除网站数据后回到默认)。按键按键帽上印的字符识别(AZERTY 等键盘按印着的字母),俄文等非拉丁布局按键位识别。手机等纯触屏设备要连接键盘才能用快捷键,设置里的快捷键列表默认折叠。 + +## 12. 反馈问题 + +遇到报错、卡住或结果不对时,打开「设置」→「问题反馈」,点「复制诊断信息」,再到 [GitHub issue](https://github.com/sganggs/Stronghold-Protocol/issues) 里描述发生了什么并粘贴进去。诊断信息包括游戏版本、浏览器和设备、当前所在的阶段与回合,以及打开页面以来记录到的错误(最多保留最近 40 条)。战斗中复制时,默认附上这场战斗的数据,开发者可以用它重现这场战斗;不想附上可以关掉「附上本场战斗」。诊断信息只在你的浏览器里生成,不会自动上传;你和其他玩家的名字、房间号会被替换成 ``、``、`` 这样的占位符。剪贴板不可用时(部分 App 内置浏览器),内容会显示在下方的文本框里,手动全选复制即可。 diff --git a/docs/design/client.md b/docs/design/client.md index 5a3db6c4..0374a3da 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -37,7 +37,7 @@ Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highl Look & feel: research 06 §11–§12 (near-black green-grey panels, mint `#4ed8af` accents, gold prices, bracket frames, hexagon badges, bond discs, 7-segment countdown). Fonts: Noto Sans SC (Google Fonts with local fallback), Bender/Novecento self-hosted for numerals and Latin micro-labels. -Screens: **Title** (season art backdrop, 开始 → nickname), **Lobby** (独立模拟 / 同盟模拟 → create or join; recent rooms), **Room** (4 seats, invite code + copy link, difficulty picker (host), add/remove AI, ready, start), **Briefing** (INFO_CHECK: stage, factions, disabled bonds, banned chess, boss silhouette, ready x/4), **Band draft** (grid of 40 bands with icon/name/LP/effect; turn order list; skip; 查看禁用盟约与干员 under the order list opens the briefing's bond rows, legend and banned operators again in a read-only dialog — one component, `ui/matchInfo.js`, §21.28), **Game** (Pixi field + DOM HUD: top bar with round/phase capsule/LP (live during the own battle, §17.5)/timer/ready; bond strip + popups; left team panel with LP/status/emote bubbles, click to watch (a boss round frames the viewer's pair in green, §15); bottom shop bar (level card, 3–5 chess cards, item card, funds, refresh, freeze, collapse); detail panel on click/hover (header with bonds, then 特质 — the operator's own effect — right under it, 特性, stats (live: the battle's own numbers / the start-of-battle ones in prep, §18.5), skill, module, equipped items, talents: §17.5); enemy preview drawer (next round enemies with counts, factions; its 本局信息 tab lists the briefing's disabled bonds and banned operators, §21.28); 机变 overlay (the official card: framed icon + name, the effect text — data `descRaw` — under it, the taker's round avatar at the top-right; two taps, §18.2, §20.7); merge reward overlay (the promotion reward, or a special offer — 凯瑟琳's 定向投放 … — as item / chess cards under its source's label, with the count of offers queued behind it, §21.2); phase banners; the round's result box at SETTLE (the official 「作战结束」 + 「全员无伤!」 / 「生命值减少 −N」 for the viewer's own LP — after a 联防 the server's charge, `m.public.uniteResult.losses` — with the matching BATTLEOVER sound; none in the boss rounds or for a viewer not in the round; GitHub #235, PR #112); ticker (by broadcast priority, highest first; a leader-damage line plays only in its round and a player's newer one replaces the older, §21.10); toasts; emote wheel; settings (volume, quality, show damage numbers)), **Result** (victory/defeat, rounds, per-player lineup, stats, titles, back to room). +Screens: **Title** (season art backdrop, 开始 → nickname), **Lobby** (独立模拟 / 同盟模拟 → create or join; recent rooms), **Room** (4 seats, invite code + copy link, difficulty picker (host), the co-op 「AI 队友最后选择」 switch (host, off by default; read-only for the others), add/remove AI, ready, start), **Briefing** (INFO_CHECK: stage, factions, disabled bonds, banned chess, boss silhouette, ready x/4), **Band draft** (grid of 40 bands with icon/name/LP/effect; turn order list; skip; 查看禁用盟约与干员 under the order list opens the briefing's bond rows, legend and banned operators again in a read-only dialog — one component, `ui/matchInfo.js`, §21.28), **Game** (Pixi field + DOM HUD: top bar with round/phase capsule/LP (live during the own battle, §17.5)/timer/ready; bond strip + popups; left team panel with LP/status/emote bubbles, click to watch (a boss round frames the viewer's pair in green, §15); bottom shop bar (level card, 3–5 chess cards, item card, funds, refresh, freeze, collapse); detail panel on click/hover (header with bonds, then 特质 — the operator's own effect — right under it, 特性, stats (live: the battle's own numbers / the start-of-battle ones in prep, §18.5), skill, module, equipped items, talents: §17.5); enemy preview drawer (next round enemies with counts, factions; its 本局信息 tab lists the briefing's disabled bonds and banned operators, §21.28); 机变 overlay (the official card: framed icon + name, the effect text — data `descRaw` — under it, the taker's round avatar at the top-right; two taps, §18.2, §20.7); merge reward overlay (the promotion reward, or a special offer — 凯瑟琳's 定向投放 … — as item / chess cards under its source's label, with the count of offers queued behind it, §21.2); phase banners; the round's result box at SETTLE (the official 「作战结束」 + 「全员无伤!」 / 「生命值减少 −N」 for the viewer's own LP — after a 联防 the server's charge, `m.public.uniteResult.losses` — with the matching BATTLEOVER sound; none in the boss rounds or for a viewer not in the round; GitHub #235, PR #112); ticker (by broadcast priority, highest first; a leader-damage line plays only in its round and a player's newer one replaces the older, §21.10); toasts; emote wheel; settings (volume, quality, show damage numbers)), **Result** (victory/defeat, rounds, per-player lineup, stats, titles, back to room). 盟约栏支持「收起/展开盟约」(GitHub #142):收起时隐藏完整列表并关闭由列表打开的浮层,让原图标区域的指针事件到达棋盘,只保留至少 44×44 CSS px 的恢复按钮。折叠状态在本局阶段变化、观看队友时保留;展开后仍按当前观看对象显示最新盟约。按钮支持 Enter / Space,聚焦按钮时 Space 不触发准备或暂停。 diff --git a/docs/design/match.md b/docs/design/match.md index 9e4338b0..b05219fd 100644 --- a/docs/design/match.md +++ b/docs/design/match.md @@ -7,11 +7,11 @@ Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global). ### 6.1 State machine (Match.js) ``` -LOBBY(room) → INFO_CHECK (co-op 25 s; solo and any single-human match untimed (§18.2); all ready ⇒ skip; the operator loadout (§16) locks when it ends) → BAND_DRAFT (co-op: random order, ONE countdown — `BAND_TURN_SECONDS` 30 s per turn = m.public.deadline, AI seats pick at once, 1 skip each, a strategy a teammate already took is refused (队友已选), timeout ⇒ the strategy the player highlights (`g.bandFocus`) while free, else band_bldsk, else the first free one; solo: free pick, no timer, no skip; a single human: untimed) → BATTLE_CHECK (3 s) +LOBBY(room) → INFO_CHECK (co-op 25 s; solo and any single-human match untimed (§18.2); all ready ⇒ skip; the operator loadout (§16) locks when it ends) → BAND_DRAFT (co-op: random order — the room option 「AI 队友最后选择」 (host, off by default; `room.setAiPicksLast`) puts every human seat before every AI seat, each group keeping the drawn order, and draws no extra random number; a skipping human goes behind the other humans still to pick and ahead of the AI seats, or to the very end when no other human remains [ASSUMED] —, ONE countdown — `BAND_TURN_SECONDS` 30 s per turn = m.public.deadline, AI seats pick at once, 1 skip each, a strategy a teammate already took is refused (队友已选), timeout ⇒ the strategy the player highlights (`g.bandFocus`) while free, else band_bldsk, else the first free one; solo: free pick, no timer, no skip; a single human: untimed) → BATTLE_CHECK (3 s) → loop r = 1..lastRound: ROUND_START (income, upgrade price −1 (floor 0), temp NOT wiped — what overflowed after the last prep's deadline is shown in this prep (§6.2 temp overflow), unfrozen shop slots rerolled, frozen kept, <进入休整期时> effects) - [SP_DRAFT] (if r ∈ spRounds: 机变 draft; co-op 30 s first picker / 16 s others; solo 3 cards; solo / single human untimed; + [SP_DRAFT] (if r ∈ spRounds: 机变 draft; co-op: random order (「AI 队友最后选择」, off by default: humans first, the same partition as the strategy draft), 30 s first picker / 16 s others; solo 3 cards; solo / single human untimed; a card is picked with two taps, §18.2) PREP (co-op: timer from config, ends when all alive humans+bots ready; solo / single human: untimed, ends on ready) PREP_END (<休整期结束时> effects; unfrozen shop cleared; temp overflow: Ready is blocked while temp is non-empty; at the deadline diff --git a/docs/design/network.md b/docs/design/network.md index 384b3c9c..88db2a6b 100644 --- a/docs/design/network.md +++ b/docs/design/network.md @@ -7,8 +7,8 @@ Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global). Transport: one WebSocket per tab at `/ws`, JSON text frames `{ t: '', ...payload }`. Client requests may carry `rid` (request id); the server's direct reply echoes `rid` (`ok` or `error`). Server pushes have no `rid`. Max frame 64 KB inbound; rate limit 40 msgs/s per connection (excess dropped with `error RATE`). ### 8.1 Session & lobby -C→S: `hello {name, token?, version}` · `room.create {mode:'solo'|'coop', difficulty}` · `room.join {code}` · `room.leave` · `room.ready {ready}` · `room.setDifficulty {difficulty}` (host) · `room.addBot` / `room.removeBot {seat}` (host) · `room.kick {seat, playerId}` (host, before the match: another human leaves the room and gets `room.closed {reason:'kicked'}`; may join again; `playerId` = the player the host confirmed, a seat that changed hands meanwhile → `BAD_TARGET`) · `room.start` (host) · `room.loadout {entries}` (§16: any time in the lobby / room and during INFO_CHECK, then `WRONG_PHASE`; heavy rate-limit bucket; the match exposes it as `m.private.loadout`) · `room.ownership {notOwned}` (0.2.0 补位, the 干员持有 tab: the base chess ids marked as not owned — ≤ 160 ids, kept leniently: anything but a droppable NORMAL chess is dropped; stored on the session and the seat, a match takes the seat's list when it starts and never changes it — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes it as `m.private.standIns`) · `room.diy {picks}` (0.2.0 自选编队: `{ [slotBaseId]: { charId, skillIndex?, uniEquipId? } | null }` for the four DIY slots — ≤ 8 entries, kept leniently: shared/protocol.js `checkDiyPicks` drops an illegal pick (an operator without a kit, another tier's prototype, a prototype off its locked skill, a module of another game mode (集成战略 / 生息演算), an owned operator's second slot, the same operator twice in a tier, an unknown slot), only malformed picks are `BAD_MSG`; stored on the session and the seat like the not-owned list, a match takes the seat's picks when it starts — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes them as `m.private.diy`) · `ping {c}` -S→C: `welcome {playerId, token, name, serverNow}` · `room.state {code, hostId, mode, difficulty, seats:[{seat, playerId, name, isBot, ready, connected}|null ×4]}` · `room.closed {reason}` · `pong {c, s}` · `error {rid?, code, msg}` · `ok {rid}` +C→S: `hello {name, token?, version}` · `room.create {mode:'solo'|'coop', difficulty}` · `room.join {code}` · `room.leave` · `room.ready {ready}` · `room.setDifficulty {difficulty}` (host) · `room.setAiPicksLast {on}` (host, co-op, before the match — `ROOM_STARTED` during it, `BAD_TARGET` in a solo room; un-readies the other humans like a difficulty change; the match takes it as `opts.aiPicksLast`: humans draft before AI seats, off by default, no extra random draw) · `room.addBot` / `room.removeBot {seat}` (host) · `room.kick {seat, playerId}` (host, before the match: another human leaves the room and gets `room.closed {reason:'kicked'}`; may join again; `playerId` = the player the host confirmed, a seat that changed hands meanwhile → `BAD_TARGET`) · `room.start` (host) · `room.loadout {entries}` (§16: any time in the lobby / room and during INFO_CHECK, then `WRONG_PHASE`; heavy rate-limit bucket; the match exposes it as `m.private.loadout`) · `room.ownership {notOwned}` (0.2.0 补位, the 干员持有 tab: the base chess ids marked as not owned — ≤ 160 ids, kept leniently: anything but a droppable NORMAL chess is dropped; stored on the session and the seat, a match takes the seat's list when it starts and never changes it — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes it as `m.private.standIns`) · `room.diy {picks}` (0.2.0 自选编队: `{ [slotBaseId]: { charId, skillIndex?, uniEquipId? } | null }` for the four DIY slots — ≤ 8 entries, kept leniently: shared/protocol.js `checkDiyPicks` drops an illegal pick (an operator without a kit, another tier's prototype, a prototype off its locked skill, a module of another game mode (集成战略 / 生息演算), an owned operator's second slot, the same operator twice in a tier, an unknown slot), only malformed picks are `BAD_MSG`; stored on the session and the seat like the not-owned list, a match takes the seat's picks when it starts — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes them as `m.private.diy`) · `ping {c}` +S→C: `welcome {playerId, token, name, serverNow}` · `room.state {code, hostId, mode, difficulty, aiPicksLast, seats:[{seat, playerId, name, isBot, ready, connected}|null ×4]}` · `room.closed {reason}` · `pong {c, s}` · `error {rid?, code, msg}` · `ok {rid}` Platform additions (implemented): `welcome.{resumed, version}`, `welcome.diyKitted` (0.2.0: the operators a 自选 slot may field — server/sim/content/kits/index.js `KITTED_CHARS`, the picker's list), `room.state.inMatch`, `error.detail`; `room.closed.reason ∈ 'timeout'|'kicked'|'empty'|'shutdown'` (`kicked`: room.kick / room.removeSpectator; `empty`: a spectator whose room lost its last player); WebSocket close codes 4001 (session taken over by another socket — client must not auto-reconnect), 4002 (no hello in time), 1008 (flooding), 1001 (shutdown). The host's `room.start` counts as the host being ready. After any (re)`hello` the server pushes `room.state` and, if a match runs, the match resends `m.public`/`m.private` (+`m.field`, or the field's `b.start` under client-side combat). **Reconnect window:** a dropped session stays resumable for 10 min (`net.js NET_DEFAULTS.reconnectWindowMs`; co-op seats keep playing their last lineup meanwhile, §6.6); a session that drops while its **solo** room's match runs stays resumable for the official `config.constants.singleReconnectTime` (86 400 s = 24 h; lobby option `soloReconnectWindowMs`; research 01 §1 "24小时内随时返回") — the untimed solo run simply waits. Only when the window expires does the drop become `match.onLeave` (co-op: 中途退出 = elimination; solo: the run ends `'abandoned'`). The client keeps its token in sessionStorage plus a recent-token list in localStorage, so reopening the tab resumes. Everything lives in server memory: a server restart ends every room and run. The lobby⇄match interface (constructor opts, `start/handle/onDisconnect/onReconnect/onLeave/dispose`) is documented verbatim at the top of `server/match/Match.js` and is normative. `server/data.js` provides `getData()` + index getters (`getChess`, `getBond`, `getGarrison`, `getItem`, `getBand`, `getEffect`, `getEnemy`, `getWave`, `getStage`, `getBoss`, `getToken`, `getConfig`, `getMode`). Note: the skill trigger rule lives at `chess.skill.trigger.rule` in data. diff --git a/public/css/screens/game.css b/public/css/screens/game.css index be580299..d46c9089 100644 --- a/public/css/screens/game.css +++ b/public/css/screens/game.css @@ -543,6 +543,26 @@ .set-keys__more > summary { padding: .06rem 0; font-size: .15rem; color: var(--mint-400); cursor: pointer; } .set-keys__more[open] > summary { margin-bottom: .08rem; } @keyframes set-key-wait { to { box-shadow: 0 0 .12rem rgba(246, 163, 41, .45); } } +/* 问题反馈 (ui/settings.js DiagSection): laid out like 快捷键 — a head with its button, a hint, the result line; the + recorded-error count in the figure face of the slider values (amber once there is one); the report box only when the + clipboard refused it, styled like 干员调配's 导出 box (screens/loadout.css .lo-io__text) */ +.set-diag { display: flex; flex-direction: column; gap: .08rem; padding-top: .1rem; border-top: 1px solid var(--line); } +.set-diag__meta { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: .04rem .2rem; } +.set-diag__stat { display: inline-flex; align-items: baseline; gap: .1rem; font-size: .14rem; color: var(--text-lo); } +.set-diag__count { font-family: var(--font-num); font-size: .2rem; line-height: 1; color: var(--mint-400); } +.set-diag__count.is-warn { color: var(--amber); } +.set-diag__url { min-width: 0; font-size: .13rem; color: var(--text-dim); overflow-wrap: anywhere; -webkit-user-select: all; user-select: all; } +.set-diag__battle-hint { margin-top: -.06rem; } +.set-diag__text { + box-sizing: border-box; width: 100%; height: max(2rem, 140px); min-height: 120px; resize: vertical; + padding: max(.1rem, 8px) max(.12rem, 10px); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: max(.13rem, 11px); line-height: 1.5; + color: var(--text-hi); background: rgba(4, 10, 8, .55); border: 1px solid var(--line-2); border-radius: 2px; outline: none; + white-space: pre-wrap; overflow-wrap: anywhere; overflow-y: auto; +} +.set-diag__text:focus { border-color: var(--mint-700); } +/* touch: 16 px, below which iOS zooms into the focused box (as 干员调配's 导出 box and ui/clipboard.js) */ +.sp-coarse .set-diag__text { font-size: 16px; } @media (max-width: 767px) { .set-keys__list { grid-template-columns: minmax(0, 1fr); } } @media (max-height: 600px) and (pointer: coarse), (max-height: 600px) and (any-pointer: coarse) { .set-key { height: max(.38rem, 30px); } diff --git a/public/css/screens/room.css b/public/css/screens/room.css index fb84ee6f..950fe3ea 100644 --- a/public/css/screens/room.css +++ b/public/css/screens/room.css @@ -296,6 +296,13 @@ } .dpick__opt.is-active .icon { opacity: 1; filter: drop-shadow(0 0 5px var(--d-color)); } .dpick--ro { border: 0; background: none; gap: .18rem; align-items: center; font-size: .14rem; } +/* 「AI 队友最后选择」 (GitHub #338, co-op): one switch styled like a difficulty option, under the difficulty picker */ +.room-bar__opts { display: flex; flex-direction: column; align-items: flex-start; gap: .08rem; min-width: 0; } +.ailast { display: flex; align-items: center; gap: .12rem; font-size: .14rem; } +.ailast__opt { --d-color: var(--mint-400); height: .36rem; padding: 0 .14rem; border: 1px solid var(--line-2); background: rgba(0, 0, 0, .3); font-size: .15rem; letter-spacing: .04em; } +.ailast__opt:last-child { border-right: 1px solid var(--line-2); } +.ailast__opt .icon { opacity: .25; } +.ailast__opt .icon.is-on { opacity: 1; } .ready-count[hidden] { display: none; } .ready-count { display: flex; align-items: baseline; gap: .06rem; font-size: .16rem; } diff --git a/public/i18n/en.json b/public/i18n/en.json index bbf29fcf..ab7f0e25 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -104,6 +104,9 @@ "复制密钥": "Copy Key", "复制链接": "Copy Link", "由创建者选择": "Chosen by the Host", + "由创建者设置": "Set by the Host", + "AI 队友最后选择": "AI Teammates Pick Last", + "策略与机变轮选时,所有博士先于 AI 队友选择": "In the Strategy and Improv drafts, every Doctor picks before the AI teammates", "连接中断,请稍候重试": "Connection lost, please try again shortly", "离开同盟": "Leave Alliance", "你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?": "You are the Host of this Alliance. If you leave, host duties will be handed over or the Alliance will disband. Leave anyway?", @@ -808,6 +811,15 @@ "已恢复默认快捷键": "Default hotkeys restored", "快捷键需要实体键盘;触屏设备连接键盘后可用": "Hotkeys need a physical keyboard; connect one to use them on a touch device", "查看 / 更改快捷键": "Show / change hotkeys", + "问题反馈": "Report a problem", + "复制诊断信息": "Copy diagnostics", + "遇到问题时,复制诊断信息并粘贴到 GitHub issue 中,开发者就能看到出错时的情况。诊断信息只在本机生成,不会自动上传;玩家名和房间号会被替换。": "If something goes wrong, copy the diagnostics and paste them into a GitHub issue so the developers can see what happened. They are generated on this device and never uploaded; player names and the room code are replaced.", + "已记录的错误": "Errors recorded", + "附上本场战斗": "Attach this battle", + "开发者可以用附上的战斗数据重现这场战斗。": "Developers can replay this battle from the attached data.", + "已复制诊断信息,可以粘贴到 GitHub issue 中": "Diagnostics copied. Paste them into a GitHub issue.", + "无法写入剪贴板,请手动复制下面的内容": "Couldn't write to the clipboard. Copy the text below by hand.", + "诊断信息": "Diagnostics", "观战中无法进行该操作": "Spectators cannot do that", "标准": "Standard", "险境": "Perilous", diff --git a/public/i18n/ja.json b/public/i18n/ja.json index 9986bbbe..1beff69c 100644 --- a/public/i18n/ja.json +++ b/public/i18n/ja.json @@ -110,6 +110,9 @@ "复制密钥": "コードをコピー", "复制链接": "リンクをコピー", "由创建者选择": "ホストが選択", + "由创建者设置": "ホストが設定", + "AI 队友最后选择": "AI チームメイトは最後に選択", + "策略与机变轮选时,所有博士先于 AI 队友选择": "戦術と調整の順番選択では、すべてのドクターが AI チームメイトより先に選びます", "连接中断,请稍候重试": "接続が切断されました。しばらくしてから再試行してください", "离开同盟": "同盟から退出", "你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?": "あなたはこの同盟のホストです。退出するとホストが他のドクターに移るか、同盟が解散します。退出しますか?", @@ -808,6 +811,15 @@ "已恢复默认快捷键": "ショートカットキーをデフォルトに戻しました", "快捷键需要实体键盘;触屏设备连接键盘后可用": "ショートカットキーには物理キーボードが必要です。タッチ端末でもキーボードを接続すれば使えます", "查看 / 更改快捷键": "ショートカットキーの確認 / 変更", + "问题反馈": "問題の報告", + "复制诊断信息": "診断情報をコピー", + "遇到问题时,复制诊断信息并粘贴到 GitHub issue 中,开发者就能看到出错时的情况。诊断信息只在本机生成,不会自动上传;玩家名和房间号会被替换。": "問題が起きたときは、診断情報をコピーして GitHub の issue に貼り付けてください。開発者がエラー発生時の状況を確認できます。診断情報はこの端末内で作成され、自動でアップロードされることはありません。プレイヤー名とルーム番号は置き換えられます。", + "已记录的错误": "記録されたエラー", + "附上本场战斗": "この戦闘を添付", + "开发者可以用附上的战斗数据重现这场战斗。": "添付した戦闘データから、開発者がこの戦闘を再現できます。", + "已复制诊断信息,可以粘贴到 GitHub issue 中": "診断情報をコピーしました。GitHub の issue に貼り付けてください", + "无法写入剪贴板,请手动复制下面的内容": "クリップボードに書き込めませんでした。下の内容を手動でコピーしてください", + "诊断信息": "診断情報", "观战中无法进行该操作": "観戦中はこの操作はできません", "标准": "標準", "险境": "逆境", diff --git a/public/i18n/ko.json b/public/i18n/ko.json index daf09d7f..cb4fac70 100644 --- a/public/i18n/ko.json +++ b/public/i18n/ko.json @@ -106,6 +106,9 @@ "复制密钥": "코드 복사", "复制链接": "링크 복사", "由创建者选择": "방장이 선택", + "由创建者设置": "방장이 설정", + "AI 队友最后选择": "AI 팀원은 마지막에 선택", + "策略与机变轮选时,所有博士先于 AI 队友选择": "전략과 기회 순서 선택에서 모든 박사가 AI 팀원보다 먼저 선택합니다", "连接中断,请稍候重试": "연결이 끊어졌습니다. 잠시 후 다시 시도해 주세요.", "离开同盟": "동맹 나가기", "你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?": "이 동맹의 방장입니다. 나가면 방장 권한이 이양되거나 동맹이 해산됩니다. 정말 나가시겠습니까?", @@ -804,6 +807,15 @@ "已恢复默认快捷键": "기본 단축키를 복원했습니다", "快捷键需要实体键盘;触屏设备连接键盘后可用": "단축키는 실제 키보드가 필요합니다. 터치 기기에서는 키보드를 연결하면 사용할 수 있습니다.", "查看 / 更改快捷键": "단축키 보기 / 변경", + "问题反馈": "문제 신고", + "复制诊断信息": "진단 정보 복사", + "遇到问题时,复制诊断信息并粘贴到 GitHub issue 中,开发者就能看到出错时的情况。诊断信息只在本机生成,不会自动上传;玩家名和房间号会被替换。": "문제가 생기면 진단 정보를 복사해 GitHub issue에 붙여 넣어 주세요. 개발자가 오류가 났을 때의 상황을 확인할 수 있습니다. 진단 정보는 이 기기에서만 만들어지며 자동으로 업로드되지 않습니다. 플레이어 이름과 방 번호는 다른 값으로 바뀝니다.", + "已记录的错误": "기록된 오류", + "附上本场战斗": "이 전투 첨부", + "开发者可以用附上的战斗数据重现这场战斗。": "첨부한 전투 데이터로 개발자가 이 전투를 재현할 수 있습니다.", + "已复制诊断信息,可以粘贴到 GitHub issue 中": "진단 정보를 복사했습니다. GitHub issue에 붙여 넣어 주세요", + "无法写入剪贴板,请手动复制下面的内容": "클립보드에 쓸 수 없습니다. 아래 내용을 직접 복사해 주세요", + "诊断信息": "진단 정보", "观战中无法进行该操作": "관전 중에는 할 수 없습니다", "标准": "표준", "险境": "험지", diff --git a/public/i18n/zh-TW.json b/public/i18n/zh-TW.json index 24d9f4e9..36246ee3 100644 --- a/public/i18n/zh-TW.json +++ b/public/i18n/zh-TW.json @@ -108,6 +108,9 @@ "复制密钥": "複製金鑰", "复制链接": "複製連結", "由创建者选择": "由建立者選擇", + "由创建者设置": "由建立者設定", + "AI 队友最后选择": "AI 隊友最後選擇", + "策略与机变轮选时,所有博士先于 AI 队友选择": "策略與機變輪選時,所有博士先於 AI 隊友選擇", "连接中断,请稍候重试": "連線中斷,請稍候重試", "离开同盟": "離開同盟", "你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?": "你是同盟的建立者,離開後建立者身分將移交或同盟解散。確定離開嗎?", @@ -807,6 +810,15 @@ "已恢复默认快捷键": "已恢復預設快捷鍵", "快捷键需要实体键盘;触屏设备连接键盘后可用": "快捷鍵需要實體鍵盤;觸控裝置連接鍵盤後可用", "查看 / 更改快捷键": "查看 / 更改快捷鍵", + "问题反馈": "問題回報", + "复制诊断信息": "複製診斷資訊", + "遇到问题时,复制诊断信息并粘贴到 GitHub issue 中,开发者就能看到出错时的情况。诊断信息只在本机生成,不会自动上传;玩家名和房间号会被替换。": "遇到問題時,複製診斷資訊並貼到 GitHub issue 中,開發者就能看到出錯時的情況。診斷資訊只在本機產生,不會自動上傳;玩家名稱和房間號碼會被替換。", + "已记录的错误": "已記錄的錯誤", + "附上本场战斗": "附上本場戰鬥", + "开发者可以用附上的战斗数据重现这场战斗。": "開發者可以用附上的戰鬥資料重現這場戰鬥。", + "已复制诊断信息,可以粘贴到 GitHub issue 中": "已複製診斷資訊,可以貼到 GitHub issue 中", + "无法写入剪贴板,请手动复制下面的内容": "無法寫入剪貼簿,請手動複製下面的內容", + "诊断信息": "診斷資訊", "观战中无法进行该操作": "觀戰中無法進行該操作", "标准": "標準", "险境": "險境", diff --git a/public/js/battle/runner.js b/public/js/battle/runner.js index e8d25fbe..79ffbc1e 100644 --- a/public/js/battle/runner.js +++ b/public/js/battle/runner.js @@ -72,6 +72,7 @@ import { net as appNet } from '../net.js'; import { store as appStore } from '../store.js'; import { unitStatsEntry, fxForm } from '../../../shared/protocol.js'; import { spectateEffects } from './observe.js'; +import { recordError, setBattleSource } from '../diag.js'; const TICK = 1 / 30; /** Fast-forward budget per frame (ticks) when far behind. */ @@ -156,9 +157,12 @@ export async function loadBrowserSim({ base = '/sim/', dataBase = '/data/', fetc return { spec, ds: new simdata.DataSource(raw, null) }; } -/** Battle logger: content errors are isolated by the sim; report them as warnings (the server logs its own). */ +/** + * Battle logger: content errors are isolated by the sim; report them as warnings (the server logs its own) and keep + * them for the diagnostics a player copies (diag.js). + */ const SIM_LOGGER = Object.freeze({ - error: (...a) => console.warn('[sim]', ...a), + error: (...a) => { console.warn('[sim]', ...a); recordError('sim', a.length === 1 ? a[0] : a.map(String).join(' ')); }, warn: (...a) => console.warn('[sim]', ...a), info() {}, debug() {}, @@ -351,6 +355,7 @@ export function createBattleRunner(deps) { } catch (err) { stats.errors++; console.warn('[runner] battle step failed', err); + recordError('runner', err, 'battle step failed'); try { b.forceEnd('timeout'); } catch { /* ignore */ } } const dt = now() - t; @@ -393,7 +398,7 @@ export function createBattleRunner(deps) { const list = catchingUp ? s.ev.filter(keepsState) : s.ev; if (list.length) emit('ev', { t: 'b.ev', fieldId: e.fieldId, gt: s.gt, ev: list }); } - try { emit('snap', frameOf(e)); } catch (err) { console.warn('[runner] snapshot failed', err); } + try { emit('snap', frameOf(e)); } catch (err) { console.warn('[runner] snapshot failed', err); recordError('runner', err, 'snapshot failed'); } } /** @@ -457,7 +462,7 @@ export function createBattleRunner(deps) { result = e.sim.spec.compactResult(e.battle.result()); // an oversized frame would close the socket at the very end of the battle (64 KB inbound limit) if (typeof e.sim.spec.fitResult === 'function') result = e.sim.spec.fitResult(result, { bossLike: bossLike(e), battleId: e.battleId }); - } catch (err) { console.warn('[runner] result failed', err); } + } catch (err) { console.warn('[runner] result failed', err); recordError('runner', err, 'result failed'); } if (result) { e.result = result; // the view answers with the settlement voice of this battle (screens/game.js → audio.voice result*): the @@ -491,6 +496,7 @@ export function createBattleRunner(deps) { } else { e.delivery = 'delivered'; console.warn('[runner] b.result refused', code); + recordError('runner', code, 'b.result refused'); } return null; }); @@ -670,6 +676,7 @@ export function createBattleRunner(deps) { let sim; try { sim = await ensureSim(); } catch (err) { console.warn('[runner] simulation unavailable', err); + recordError('runner', err, 'simulation unavailable'); return; } if (!wanted()) return; @@ -678,6 +685,7 @@ export function createBattleRunner(deps) { battle = sim.spec.createBattleFromSpec(e.spec, sim.ds, { logger }); } catch (err) { console.warn('[runner] battle construction failed', err); + recordError('runner', err, 'battle construction failed'); return; } stats.battles++; @@ -806,6 +814,16 @@ export function createBattleRunner(deps) { return () => listeners.get(type)?.delete(fn); }, state, + /** + * The battle on screen as a report attaches it (diag.js): its b.start fields and its game time; null without one. + * @returns {{ battleId: string, fieldId: string, kind: string, spec: object, time: number|null }|null} + */ + currentBattle() { + const e = cur; + if (!e || !e.spec) return null; + const time = e.battle && Number.isFinite(e.battle.time) ? e.battle.time : null; + return { battleId: e.battleId, fieldId: e.fieldId, kind: e.kind, spec: e.spec, time }; + }, stats() { return { ...stats, avgTickMs: stats.ticks ? stats.stepMs / stats.ticks : 0, entries: entries.size, loadingSim: !!simP }; }, @@ -869,3 +887,4 @@ export const battleRunner = typeof window !== 'undefined' && typeof document !== ? createBattleRunner({ net: appNet, store: appStore }) : null; if (battleRunner) globalThis.__SP_RUNNER__ = battleRunner; // dev / E2E introspection +if (battleRunner) setBattleSource(() => battleRunner.currentBattle()); diff --git a/public/js/diag.js b/public/js/diag.js new file mode 100644 index 00000000..4a0c526c --- /dev/null +++ b/public/js/diag.js @@ -0,0 +1,269 @@ +// public/js/diag.js — the client's error log and the diagnostics a player copies into a bug report (设置 → 问题反馈). +// +// Nothing leaves the browser. The log is kept in memory (the last LOG_MAX entries, an identical error in a row counted +// on its entry); the report is plain text the player copies and pastes into a GitHub issue, with the player names, +// the room code and the reconnect token replaced and the page's host cut from every URL. A secret is a whole token in +// the prose and an exact string value in the attached battle JSON, so a name never rewrites a longer id. The battle +// on screen can be attached as its BattleSpec (the b.start a browser receives: player ids, no names), which the dev +// tools replay. +// +// recordError(source, err, detail?) source: 'error' (uncaught) | 'rejection' (unhandled promise) | 'sim' (a content +// error the battle isolated) | 'runner' (the local battle runner) | 'request' +// (a failed request or error push the player was shown) +// errorLog() / errorCount() / clearErrorLog() +// setBattleSource(fn) the battle runner registers how to read the battle on screen +// buildDiagnostics(input) the report text (pure: the state, the environment and the battle are passed in) +// diagnosticsText({ state, settings, attachBattle }) the report of this page (this browser, the battle on screen) +// +// Report keys are English on purpose: the text is read by developers and searched in issues, whatever the UI language. + +import { APP_VERSION } from '../../shared/constants.js'; + +export const LOG_MAX = 40; +export const STACK_LINES = 6; +const MESSAGE_MAX = 300; + +/** @type {{ source: string, message: string, stack: string|null, detail: string|null, at: number, lastAt: number, count: number }[]} */ +const log = []; +let total = 0; +let dropped = 0; +const pageStart = Date.now(); + +/** `name: message (code)` of an Error, a NetError, a {code, msg} push, a string or anything else. */ +export function messageOf(err) { + let s; + if (err == null) s = String(err); + else if (typeof err === 'string') s = err; + else if (typeof err === 'object') { + const name = typeof err.name === 'string' && err.name && err.name !== 'Error' ? `${err.name}: ` : ''; + const text = typeof err.message === 'string' && err.message ? err.message : typeof err.msg === 'string' ? err.msg : ''; + const code = typeof err.code === 'string' || typeof err.code === 'number' ? String(err.code) : ''; + if (text) s = `${name}${text}${code && !text.includes(code) ? ` (${code})` : ''}`; + else if (code) s = `${name}${code}`; + else { try { s = JSON.stringify(err); } catch { s = Object.prototype.toString.call(err); } } + } else s = String(err); + s = s.replace(/\s+/g, ' ').trim(); + return s.length > MESSAGE_MAX ? `${s.slice(0, MESSAGE_MAX - 1)}…` : s; +} + +/** The first STACK_LINES frames of `err.stack`, each without the page's scheme and host (`/js/ui/x.js:12:5`). */ +export function stackOf(err) { + const raw = err && typeof err === 'object' && typeof err.stack === 'string' ? err.stack : ''; + if (!raw) return null; + const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean); + // V8 starts with "Name: message"; Firefox / Safari list frames only + const frames = lines.filter((l, i) => !(i === 0 && !/^at |@|:\d+:\d+\)?$/.test(l))); + const out = frames.slice(0, STACK_LINES).map(stripHosts); + return out.length ? out.join('\n') : null; +} + +/** Cut `scheme://host[:port]` from every URL (a LAN server's address, a domain): the path is what locates the code. */ +export const stripHosts = (s) => String(s).replace(/\b[a-z][a-z0-9+.-]*:\/\/[^/\s)]+/gi, ''); + +/** + * Record an error. An entry identical to the newest one (same source, message and stack) raises its count instead. + * @param {string} source @param {any} err @param {string|null} [detail] @param {number} [at] epoch ms + */ +export function recordError(source, err, detail = null, at = Date.now()) { + const message = stripHosts(messageOf(err)); + const stack = stackOf(err); + const det = detail == null ? null : stripHosts(String(detail)).slice(0, MESSAGE_MAX); + total++; + const last = log[log.length - 1]; + if (last && last.source === source && last.message === message && last.stack === stack && last.detail === det) { + last.count++; + last.lastAt = at; + return last; + } + const e = { source: String(source), message, stack, detail: det, at, lastAt: at, count: 1 }; + log.push(e); + while (log.length > LOG_MAX) { log.shift(); dropped++; } + return e; +} + +/** The log, oldest first (copies). */ +export const errorLog = () => log.map((e) => ({ ...e })); +/** Errors recorded on this page (repeats included). */ +export const errorCount = () => total; +export function clearErrorLog() { log.length = 0; total = 0; dropped = 0; } + +let battleSource = null; +/** @param {() => ({ battleId: string, fieldId?: string, kind?: string, spec: object, time?: number }|null)} fn */ +export function setBattleSource(fn) { battleSource = typeof fn === 'function' ? fn : null; } +/** The battle on screen for a report, or null. */ +export function currentBattle() { + try { return battleSource ? battleSource() || null : null; } catch { return null; } +} + +/** Secrets worth replacing: non-empty strings, later pair winning when the same value is listed twice. */ +function secretMap(pairs) { + const map = new Map(); + for (const [v, ph] of pairs) { + if (typeof v === 'string' && v.trim()) map.set(v, ph); + } + return map; +} + +/** + * Replace each secret only as a whole token, longest first, in one pass (a placeholder is never scanned again). + * A token is bounded by the start or end of the text, or by anything that is not a letter, a digit or `_`, so + * "char" does not eat `chess_char_…`, "player" does not eat "players", and "W" does not eat "Windows". "W-W" is + * two tokens. The left boundary is a consumed character (or the start): a lookbehind is a SyntaxError in Safari + * before 16.4. + * @param {string} text @param {[string, string][]} pairs [value, placeholder] + */ +export function redact(text, pairs) { + const map = secretMap(pairs); + const list = [...map.keys()].sort((a, b) => b.length - a.length); + if (!list.length) return String(text); + const esc = (v) => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const word = '[\\p{L}\\p{N}_]'; + const re = new RegExp(`(?
^|(?!${word})[\\s\\S])(?${list.map(esc).join('|')})(?!${word})`, 'gu');
+  return String(text).replace(re, (...args) => {
+    const g = args[args.length - 1];
+    return `${g.pre}${map.get(g.hit)}`;
+  });
+}
+
+/**
+ * Copy `value`, replacing a string only when it is exactly a secret (object keys included). A longer string that
+ * merely contains the secret, such as a chess id or a note, is kept. This is what the attached battle JSON uses;
+ * token replacement would still rewrite `"charge the char"` when the name is `char`.
+ * @param {any} value @param {[string, string][]} pairs
+ */
+export function redactValues(value, pairs) {
+  const map = secretMap(pairs);
+  const walk = (v) => {
+    if (typeof v === 'string') return map.has(v) ? map.get(v) : v;
+    if (Array.isArray(v)) return v.map(walk);
+    if (v && typeof v === 'object') {
+      const out = {};
+      for (const [k, val] of Object.entries(v)) out[map.has(k) ? map.get(k) : k] = walk(val);
+      return out;
+    }
+    return v;
+  };
+  return walk(value);
+}
+
+/** [value, placeholder] pairs of what a report must not show: player names, the room code, the reconnect token. */
+export function secretsOf(state) {
+  const pairs = [];
+  const names = new Map();
+  const addName = (n) => {
+    if (typeof n !== 'string' || !n.trim() || names.has(n)) return;
+    names.set(n, n === state?.me?.name ? '' : ``);
+  };
+  addName(state?.me?.name);
+  for (const s of state?.room?.seats || []) addName(s?.name);
+  for (const s of state?.room?.spectators || []) addName(s?.name);
+  for (const p of state?.match?.public?.players || []) addName(p?.name);
+  for (const [n, ph] of names) pairs.push([n, ph]);
+  if (state?.room?.code) pairs.push([String(state.room.code), '']);
+  if (state?.me?.token) pairs.push([String(state.me.token), '']);
+  return pairs;
+}
+
+const pad2 = (n) => String(n).padStart(2, '0');
+const clockOf = (ms) => { const d = new Date(ms); return `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`; };
+const spanOf = (ms) => {
+  const s = Math.max(0, Math.round(ms / 1000));
+  return s >= 3600 ? `${Math.floor(s / 3600)}h ${pad2(Math.floor(s / 60) % 60)}m` : `${Math.floor(s / 60)}m ${pad2(s % 60)}s`;
+};
+const join = (...parts) => parts.filter((p) => p != null && p !== '' && p !== false).join(' | ');
+
+/**
+ * The report text: a ```text block (version, time, browser, screen, connection, where the player is, settings, the
+ * error log newest first) and, with `battle`, a ```json block of its spec. Secrets of `state` are whole tokens in the
+ * prose and exact string values in the JSON.
+ * @param {{ state: any, env?: any, settings?: any, battle?: any, entries?: any[], dropped?: number, total?: number,
+ *   now?: number, pageStart?: number, version?: string }} input
+ */
+export function buildDiagnostics(input) {
+  const { state = {}, env = {}, settings = null, battle = null } = input;
+  const entries = input.entries ?? errorLog();
+  const now = input.now ?? Date.now();
+  const lines = [];
+  lines.push('Stronghold-Protocol diagnostics');
+  lines.push(`version: ${input.version ?? APP_VERSION}`);
+  lines.push(`time: ${new Date(now).toISOString()} | page open ${spanOf(now - (input.pageStart ?? pageStart))}`);
+  if (env.ua) lines.push(`browser: ${env.ua}`);
+  if (env.gpu) lines.push(`gpu: ${env.gpu}`);
+  lines.push(`device: ${join(env.viewport, env.dpr ? `@${env.dpr}x` : null, env.touch ? 'touch' : null, env.cores ? `${env.cores} threads` : null,
+    env.memoryGB ? `${env.memoryGB} GB` : null, env.lang ? `lang ${env.lang}` : null, env.uiLang ? `ui ${env.uiLang}` : null) || 'unknown'}`);
+  const c = state.connection || {};
+  lines.push(`connection: ${join(c.status || 'unknown', Number.isFinite(c.ping) ? `ping ${Math.round(c.ping)} ms` : null,
+    c.attempt ? `retry ${c.attempt}` : null, c.lastError ? `last error ${messageOf(c.lastError)}` : null)}`);
+  const pub = state.match?.public || null;
+  const room = state.room || null;
+  lines.push(`where: ${join(pub?.phase ? `phase ${pub.phase}` : room ? 'room' : state.session?.entered ? 'lobby' : 'title',
+    Number.isFinite(pub?.round) ? `round ${pub.round}` : null, room?.mode ? `mode ${room.mode}` : null,
+    room?.difficulty ? `difficulty ${room.difficulty}` : null,
+    Array.isArray(pub?.players) ? `players ${pub.players.length}` : Array.isArray(room?.seats) ? `seats ${room.seats.filter(Boolean).length}` : null)}`);
+  const b = state.match?.battle || null;
+  if (b) {
+    lines.push(`battle: ${join(b.kind, b.fieldId ? `field ${b.fieldId}` : null, b.authoritative ? 'authoritative' : b.watch ? 'watching' : null,
+      b.done ? 'done' : b.loading ? 'loading' : 'running', b.paused ? 'paused' : null, Number.isFinite(b.speed) ? `${b.speed}x` : null)}`);
+  }
+  if (settings) {
+    lines.push(`settings: ${join(`quality ${settings.quality}`, settings.damageNumbers ? 'damage numbers' : null, settings.muted ? 'muted' : null)}`);
+  }
+  const shown = entries.slice().reverse();
+  const tot = input.total ?? total;
+  const drop = input.dropped ?? dropped;
+  lines.push('');
+  lines.push(`errors: ${tot ? `${tot} recorded${drop ? `, ${drop} oldest entries dropped` : ''}` : 'none recorded'}`);
+  shown.forEach((e, i) => {
+    lines.push(`#${i + 1} ${clockOf(e.at)}${e.count > 1 ? ` (x${e.count}, last ${clockOf(e.lastAt)})` : ''} ${e.source}: ${e.message}`);
+    if (e.detail) lines.push(`   ${e.detail}`);
+    if (e.stack) for (const l of e.stack.split('\n')) lines.push(`   ${l}`);
+  });
+  const pairs = secretsOf(state);
+  let text = redact(`\`\`\`text\n${lines.join('\n')}\n\`\`\``, pairs);
+  if (battle && battle.spec) {
+    const head = join(`battle ${battle.battleId}`, battle.kind, Number.isFinite(battle.spec.round) ? `round ${battle.spec.round}` : null,
+      Number.isFinite(battle.time) ? `copied at ${battle.time.toFixed(2)} s` : null);
+    const payload = redactValues({
+      t: 'b.start', battleId: battle.battleId, fieldId: battle.fieldId, kind: battle.kind, spec: battle.spec,
+    }, pairs);
+    text += `\n\n${redact(head, pairs)}\n\`\`\`json\n${JSON.stringify(payload)}\n\`\`\``;
+  }
+  return text;
+}
+
+let gpuName;
+/** What the report says about this browser and device (null fields stay out). */
+export function browserEnv() {
+  const g = globalThis;
+  if (gpuName === undefined) {
+    gpuName = null;
+    try {
+      const cv = g.document?.createElement('canvas');
+      const gl = cv && (cv.getContext('webgl2') || cv.getContext('webgl'));
+      const ext = gl && gl.getExtension('WEBGL_debug_renderer_info');
+      gpuName = gl ? String(ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER)) : 'no WebGL';
+    } catch { gpuName = null; }
+  }
+  let uiLang = null;
+  try { uiLang = g.document?.documentElement?.lang || null; } catch { /* ignore */ }
+  return {
+    ua: g.navigator?.userAgent ?? null,
+    gpu: gpuName,
+    viewport: Number.isFinite(g.innerWidth) ? `${g.innerWidth}x${g.innerHeight}` : null,
+    dpr: g.devicePixelRatio ?? null,
+    touch: !!(g.matchMedia && g.matchMedia('(pointer: coarse)').matches),
+    cores: g.navigator?.hardwareConcurrency ?? null,
+    memoryGB: g.navigator?.deviceMemory ?? null,
+    lang: g.navigator?.language ?? null,
+    uiLang,
+  };
+}
+
+/**
+ * The report of this page: the given store state and settings, this browser, the log and, with `attachBattle`, the
+ * battle on screen.
+ * @param {{ state: any, settings?: any, attachBattle?: boolean }} opts
+ */
+export function diagnosticsText({ state, settings = null, attachBattle = false }) {
+  return buildDiagnostics({ state, settings, env: browserEnv(), battle: attachBattle ? currentBattle() : null });
+}
diff --git a/public/js/main.js b/public/js/main.js
index 5b109e91..4e3a52f1 100644
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -53,6 +53,7 @@ import { installLoadoutSync, installOwnershipSync, installDiySync } from './ui/l
 import { startBuildGuard } from './ui/buildGuard.js';
 import { initLang, useLang, tickerText } from './ui/lang.js';
 import { t, N_, translateWire } from '../../shared/i18n.js';
+import { recordError } from './diag.js';
 
 const RESTORE_GRACE_MS = 1500;
 const JOIN_DELAY_MS = 350;
@@ -311,12 +312,14 @@ function installGlobalErrorHandlers() {
     // expected browser behaviour, not app errors: log quietly, never toast.
     if (err && (err.name === 'NotAllowedError' || err.name === 'AbortError')) { console.warn('[app] ignored rejection', err.name); return; }
     console.error('[app] unhandled rejection', err);
+    recordError('rejection', err);
     if (err instanceof NetError) toastError(err);
     else toast(t('发生意外错误:{error}', { error: describeError(err) }).slice(0, 120), 'error');
   });
   window.addEventListener('error', (ev) => {
     if (!(ev instanceof ErrorEvent)) return; // resource load errors are not script errors
     console.error('[app] uncaught error', ev.error || ev.message);
+    recordError('error', ev.error || ev.message, ev.error ? null : `${ev.filename || '?'}:${ev.lineno || 0}:${ev.colno || 0}`);
   });
 }
 
diff --git a/public/js/screens/room.js b/public/js/screens/room.js
index 26fe4a2a..e636211d 100644
--- a/public/js/screens/room.js
+++ b/public/js/screens/room.js
@@ -1,6 +1,6 @@
 // Room screen (同盟等待室): 4 seat cards (avatar frame, name, ready state, AI badge, host crown),
-// host controls (difficulty picker, add/remove AI in co-op, start), invite code with copy code /
-// copy link, ready toggle and leave.
+// host controls (difficulty picker, add/remove AI and the 「AI 队友最后选择」 switch in co-op, start), invite code with
+// copy code / copy link, ready toggle and leave.
 //
 // Start rule (server/lobby.js): room.start needs every *other* human connected and ready; the
 // host's start counts as the host's ready. So 开始模拟 is enabled exactly then and sends room.start
@@ -69,6 +69,19 @@ export function roomFacts(room, myId) {
   };
 }
 
+/**
+ * The co-op room option 「AI 队友最后选择」 (GitHub #338; room.setAiPicksLast, room.state.aiPicksLast): in the strategy and
+ * 机变 drafts every human picks before the AI teammates. null in a solo room (no AI teammates); otherwise its state (off
+ * when the server sends none) and whether this player may switch it — the host only, the others see it read-only.
+ * @param {any} room room.state payload
+ * @param {any} myId
+ * @returns {{ on: boolean, editable: boolean } | null}
+ */
+export function aiLastOption(room, myId) {
+  if (!room || room.mode === 'solo') return null;
+  return { on: room.aiPicksLast === true, editable: room.hostId != null && room.hostId === myId };
+}
+
 /** Invite link for a room code (current page URL with ?room=CODE). */
 export function inviteLink(code) {
   const loc = globalThis.location;
@@ -189,6 +202,21 @@ function DifficultyPicker({ room, isHost, busy, onPick }) {
   `;
 }
 
+/** The 「AI 队友最后选择」 switch (co-op): the host toggles it, everybody else sees its state. */
+function AiLastToggle({ option, busy, onToggle }) {
+  if (!option) return null;
+  return html`
+ <${Tooltip} text=${t('策略与机变轮选时,所有博士先于 AI 队友选择')}> + + + ${option.editable ? null : html`${t('由创建者设置')}`} +
`; +} + /** Room screen component. */ export function RoomScreen() { const room = useStore((s) => s.room); @@ -229,6 +257,7 @@ export function RoomScreen() { if (ok) run(`kick${seat}`, () => net.request('room.kick', { seat, playerId })); }; const setDifficulty = (difficulty) => run('diff', () => net.request('room.setDifficulty', { difficulty })); + const setAiLast = (on) => run('ailast', () => net.request('room.setAiPicksLast', { on })); // spectator seats: the host frees one; a spectator takes a free player seat with room.join of this room const removeSpectator = (playerId) => run(`rs${playerId}`, () => net.request('room.removeSpectator', { playerId })); const sit = () => run('sit', () => net.request('room.join', { code: room.code })); @@ -307,7 +336,10 @@ export function RoomScreen() {