feedback7 G-SMALL: co-op rooms can make AI teammates draft last (PR #368 by @anAtheist987)
Applied the host-only room option 「AI 队友最后选择」, default off. Off, the draft order and the random streams stay as they are. On, humans draft before AI seats after the existing shuffle, and a skip stays ahead of the AI seats. Dropped docs/history/0.2.2.md and every §27 citation. The player-facing sentence is in docs/PLAYING.md. The room-option rule is in docs/design/match.md, docs/design/client.md and docs/design/network.md. The #338 per-bot faction and ban presets are not included. Tests: node --test test/lobby-ai-last.test.js test/match/ai-picks-last.test.js test/ui/ai-picks-last.test.js — 18 pass. node --test test/lobby.test.js test/client-static.test.js — 378 pass. node tools/i18n.mjs check --all --strict — 1032/1032 in en, ja, ko and zh-TW, 0 errors.
This commit is contained in:
1 parent
ae22335cfe
commit
fd791a66cb
19 files changed
+527
-21
No files matched your search
+2
-2
@@ -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),击杀仍记在爆条的干员名下;元素脆弱只提高元素伤害,不加快元素条;脆弱不影响元素伤害。已被击杀的敌人不会再被附加元素损伤。 |
|
||||
|
||||
@@ -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 不触发准备或暂停。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '<type>', ...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.
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -110,6 +110,9 @@
|
||||
"复制密钥": "コードをコピー",
|
||||
"复制链接": "リンクをコピー",
|
||||
"由创建者选择": "ホストが選択",
|
||||
"由创建者设置": "ホストが設定",
|
||||
"AI 队友最后选择": "AI チームメイトは最後に選択",
|
||||
"策略与机变轮选时,所有博士先于 AI 队友选择": "戦術と調整の順番選択では、すべてのドクターが AI チームメイトより先に選びます",
|
||||
"连接中断,请稍候重试": "接続が切断されました。しばらくしてから再試行してください",
|
||||
"离开同盟": "同盟から退出",
|
||||
"你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?": "あなたはこの同盟のホストです。退出するとホストが他のドクターに移るか、同盟が解散します。退出しますか?",
|
||||
|
||||
@@ -106,6 +106,9 @@
|
||||
"复制密钥": "코드 복사",
|
||||
"复制链接": "링크 복사",
|
||||
"由创建者选择": "방장이 선택",
|
||||
"由创建者设置": "방장이 설정",
|
||||
"AI 队友最后选择": "AI 팀원은 마지막에 선택",
|
||||
"策略与机变轮选时,所有博士先于 AI 队友选择": "전략과 기회 순서 선택에서 모든 박사가 AI 팀원보다 먼저 선택합니다",
|
||||
"连接中断,请稍候重试": "연결이 끊어졌습니다. 잠시 후 다시 시도해 주세요.",
|
||||
"离开同盟": "동맹 나가기",
|
||||
"你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?": "이 동맹의 방장입니다. 나가면 방장 권한이 이양되거나 동맹이 해산됩니다. 정말 나가시겠습니까?",
|
||||
|
||||
@@ -108,6 +108,9 @@
|
||||
"复制密钥": "複製金鑰",
|
||||
"复制链接": "複製連結",
|
||||
"由创建者选择": "由建立者選擇",
|
||||
"由创建者设置": "由建立者設定",
|
||||
"AI 队友最后选择": "AI 隊友最後選擇",
|
||||
"策略与机变轮选时,所有博士先于 AI 队友选择": "策略與機變輪選時,所有博士先於 AI 隊友選擇",
|
||||
"连接中断,请稍候重试": "連線中斷,請稍候重試",
|
||||
"离开同盟": "離開同盟",
|
||||
"你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?": "你是同盟的建立者,離開後建立者身分將移交或同盟解散。確定離開嗎?",
|
||||
|
||||
@@ -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 }) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** 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`<div class="ailast">
|
||||
<${Tooltip} text=${t('策略与机变轮选时,所有博士先于 AI 队友选择')}>
|
||||
<button type="button" role="switch" aria-checked=${option.on ? 'true' : 'false'}
|
||||
class=${`dpick__opt ailast__opt${option.on ? ' is-active' : ''}`} disabled=${!option.editable || !!busy}
|
||||
onClick=${() => option.editable && onToggle(!option.on)}>
|
||||
<${Icon} name="check" class=${option.on ? 'is-on' : ''} />${t('AI 队友最后选择')}
|
||||
</button>
|
||||
<//>
|
||||
${option.editable ? null : html`<span class="t-dim">${t('由创建者设置')}</span>`}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** 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() {
|
||||
<footer class="room-bar">
|
||||
<div class="room-bar__left">
|
||||
<span class="room-bar__label">${t('模拟难度')}<${MicroLabel}>DIFFICULTY<//></span>
|
||||
<${DifficultyPicker} room=${room} isHost=${facts.isHost} busy=${busy} onPick=${setDifficulty} />
|
||||
<div class="room-bar__opts">
|
||||
<${DifficultyPicker} room=${room} isHost=${facts.isHost} busy=${busy} onPick=${setDifficulty} />
|
||||
<${AiLastToggle} option=${aiLastOption(room, me.playerId)} busy=${busy} onToggle=${setAiLast} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="room-bar__center">
|
||||
<div class="ready-count" hidden=${!coop}>
|
||||
|
||||
+33
-7
@@ -8,9 +8,10 @@
|
||||
// Humans and bots take the lowest free seat index; seat indexes never compact.
|
||||
// * ▸ Being in a LOBBY room and sending room.create / room.join implicitly leaves it. While your room is
|
||||
// in a match, create/join of another room fails with ROOM_STARTED (send g.leave or room.leave first).
|
||||
// * Host-only: room.setDifficulty, room.addBot, room.removeBot, room.kick, room.start. ▸ Changing the difficulty
|
||||
// un-readies the other humans. ▸ room.start requires every other human to be connected and ready;
|
||||
// the host's start counts as the host's ready (the host may still toggle room.ready for display).
|
||||
// * Host-only: room.setDifficulty, room.setAiPicksLast, room.addBot, room.removeBot, room.kick, room.start.
|
||||
// ▸ Changing the difficulty or the AI-picks-last option (co-op only) un-readies the other humans.
|
||||
// ▸ room.start requires every other human to be connected and ready; the host's start counts as the host's
|
||||
// ready (the host may still toggle room.ready for display).
|
||||
// * room.kick {seat, playerId} (community report #17, owner approved): before the match only, the host removes another
|
||||
// human like an AI seat (an AI seat stays room.removeBot's; never the host itself). `playerId` names the player the
|
||||
// host confirmed: a seat that changed hands meanwhile (left, someone else joined) is refused with BAD_TARGET. The
|
||||
@@ -37,10 +38,10 @@
|
||||
// that window coalesce into one deferred resync, so hello spam cannot amplify into ~15 KB per request.
|
||||
// * Result replay: the match's final m.public and each human's m.result are kept after the match ends. A
|
||||
// human who resyncs (resume after a drop, a reloaded tab, a repeated hello) while the room is back in LOBBY
|
||||
// gets room.state followed by those two frames again, until they act in the room (ready, difficulty, AI
|
||||
// seats, start), leave it, or a new match starts. A human removed by the lobby grace gets them right after
|
||||
// `room.closed {timeout}` on their next resume (Match.onReconnect cannot do this: the lobby drops the
|
||||
// match reference at onEnd and disposes it on the next macrotask).
|
||||
// gets room.state followed by those two frames again, until they act in the room (ready, difficulty, the
|
||||
// AI-picks-last option, AI seats, start), leave it, or a new match starts. A human removed by the lobby grace
|
||||
// gets them right after `room.closed {timeout}` on their next resume (Match.onReconnect cannot do this: the
|
||||
// lobby drops the match reference at onEnd and disposes it on the next macrotask).
|
||||
// * Per-network limits (internet clients only, see net.js clientAddress): at most `maxRoomsPerAddr` rooms
|
||||
// created from one network may exist at once and at most `maxMatchesPerAddr` matches started from one
|
||||
// network may run at once (room.create / room.start → ERR.RATE). Without them a socket loop could fill
|
||||
@@ -153,6 +154,11 @@ export class Room {
|
||||
this.code = code;
|
||||
this.mode = mode;
|
||||
this.difficulty = difficulty;
|
||||
/**
|
||||
* 「AI 队友最后选择」 (room.setAiPicksLast, GitHub #338; co-op only, off by default): the match's strategy and 机变 drafts
|
||||
* put every human seat before every AI seat (Match opts.aiPicksLast). Kept across the room's matches.
|
||||
*/
|
||||
this.aiPicksLast = false;
|
||||
/** @type {string | null} */
|
||||
this.hostId = null;
|
||||
/** @type {(Seat | null)[]} */
|
||||
@@ -202,6 +208,7 @@ export class Room {
|
||||
hostId: this.hostId,
|
||||
mode: this.mode,
|
||||
difficulty: this.difficulty,
|
||||
aiPicksLast: this.aiPicksLast,
|
||||
inMatch: !!this.match,
|
||||
seats: this.seats.map((s) => (s
|
||||
? { seat: s.seat, playerId: s.playerId, name: s.name, isBot: s.isBot, ready: s.ready, connected: s.connected && !s.left }
|
||||
@@ -311,6 +318,7 @@ export class Lobby {
|
||||
case 'room.leave': return this.leave(session);
|
||||
case 'room.ready': return this.ready(session, msg);
|
||||
case 'room.setDifficulty': return this.setDifficulty(session, msg);
|
||||
case 'room.setAiPicksLast': return this.setAiPicksLast(session, msg);
|
||||
case 'room.addBot': return this.addBot(session);
|
||||
case 'room.removeBot': return this.removeBot(session, msg);
|
||||
case 'room.kick': return this.kick(session, msg);
|
||||
@@ -499,6 +507,22 @@ export class Lobby {
|
||||
return OK;
|
||||
}
|
||||
|
||||
/** 「AI 队友最后选择」 (GitHub #338): host-only, before the match, co-op rooms only (a solo room has no AI seat). */
|
||||
setAiPicksLast(session, { on }) {
|
||||
const room = this.roomOf(session);
|
||||
if (!room) return fail(ERR.NOT_IN_ROOM);
|
||||
if (room.hostId !== session.playerId) return fail(ERR.NOT_HOST);
|
||||
if (room.match) return fail(ERR.ROOM_STARTED);
|
||||
if (room.mode === 'solo') return fail(ERR.BAD_TARGET, 'solo rooms have no AI teammates');
|
||||
this.dropReplay(room, session.playerId);
|
||||
if (room.aiPicksLast !== on) {
|
||||
room.aiPicksLast = on;
|
||||
for (const s of room.seats) if (s && !s.isBot && s.playerId !== room.hostId) s.ready = false;
|
||||
this.broadcastState(room);
|
||||
}
|
||||
return OK;
|
||||
}
|
||||
|
||||
addBot(session) {
|
||||
const room = this.roomOf(session);
|
||||
if (!room) return fail(ERR.NOT_IN_ROOM);
|
||||
@@ -672,6 +696,8 @@ export class Lobby {
|
||||
mode: room.mode,
|
||||
difficulty: room.difficulty,
|
||||
modeId: modeIdFor(room.mode, room.difficulty),
|
||||
// 「AI 队友最后选择」 (GitHub #338): fixed for the match
|
||||
aiPicksLast: room.mode !== 'solo' && room.aiPicksLast === true,
|
||||
seats,
|
||||
// the spectator seats (header): watched like eliminated players, never players
|
||||
spectators: room.spectators.map((s) => s.playerId),
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
// opts.mode 'solo' | 'coop'
|
||||
// opts.difficulty 'FUNNY'|'NORMAL'|'HARD'|'ABYSS'
|
||||
// opts.modeId string modeIdFor(mode, difficulty), e.g. 'mode_multi_hard'
|
||||
// opts.aiPicksLast boolean (optional) the co-op room option 「AI 队友最后选择」 (room.setAiPicksLast, GitHub #338):
|
||||
// the strategy and 机变 drafts put every human seat before every AI seat
|
||||
// (MatchPhases.humansFirst); only `true` turns it on; ignored in solo
|
||||
// opts.seats Array<{ seat: 0..3, playerId: string, name: string, isBot: boolean, connected: boolean,
|
||||
// loadout?: { [baseChessId]: { skill: index, module: uniEquipId|'none'|null } } | null,
|
||||
// notOwned?: string[] | null,
|
||||
@@ -236,6 +239,8 @@ export class Match {
|
||||
this.gd = new GameData(this.data, this.modeId);
|
||||
if (!this.difficulty) this.difficulty = this.gd.difficulty;
|
||||
this.isSolo = this.mode === 'solo' || this.gd.isSolo;
|
||||
/** 「AI 队友最后选择」 (opts.aiPicksLast, GitHub #338): humans draft before AI seats (MatchPhases.humansFirst) */
|
||||
this.aiPicksLast = !this.isSolo && opts.aiPicksLast === true;
|
||||
this.ownsScheduler = !opts.scheduler;
|
||||
this.sched = opts.scheduler || new RealScheduler({ now: opts.now || Date.now, onError: (e) => this.reportError('timer', e) });
|
||||
this.registry = opts.registry || getDefaultRegistry();
|
||||
|
||||
@@ -40,8 +40,9 @@ export class MatchPhases {
|
||||
enterBandDraft() {
|
||||
if (this.phase !== PHASE.INFO_CHECK) return;
|
||||
this.phase = PHASE.BAND_DRAFT;
|
||||
const order = this.order.map((p) => p.playerId);
|
||||
let order = this.order.map((p) => p.playerId);
|
||||
if (!this.isSolo) this.rngDraft.shuffle(order);
|
||||
order = this.humansFirst(order);
|
||||
const skips = this.isSolo ? 0 : this.gd.bandDraft.skipsPerPlayer;
|
||||
const untimed = this.soloUntimed;
|
||||
this.draft = {
|
||||
@@ -54,6 +55,22 @@ export class MatchPhases {
|
||||
this.markPublic();
|
||||
}
|
||||
|
||||
/**
|
||||
* The co-op room option 「AI 队友最后选择」 (this.aiPicksLast, GitHub #338): every human seat before every AI seat, each
|
||||
* group in the order the draft drew (a stable partition applied AFTER the shuffle — no extra random draw: the order
|
||||
* with the option off is unchanged, and with it on every random stream stands where it would without it; only the
|
||||
* picks made in the new order can differ). A human is any seat that is not an AI seat (room.addBot): under AI 托管,
|
||||
* disconnected or departed it still counts as a human. Used by the strategy draft and the 机变 draft
|
||||
* (MatchSpDraft.enterSpDraft).
|
||||
* @param {string[]} order playerIds in drawn order
|
||||
* @returns {string[]}
|
||||
*/
|
||||
humansFirst(order) {
|
||||
if (!this.aiPicksLast) return order;
|
||||
const bot = (pid) => !!this.players.get(pid)?.isBot;
|
||||
return [...order.filter((pid) => !bot(pid)), ...order.filter(bot)];
|
||||
}
|
||||
|
||||
draftTurn() {
|
||||
const d = this.draft;
|
||||
if (!d) return null;
|
||||
@@ -194,7 +211,16 @@ export class MatchPhases {
|
||||
if (d.order.length - d.idx <= 1) return fail(ERR.BAD_TARGET, 'nobody to pass to');
|
||||
d.skipsLeft[ps.playerId]--;
|
||||
d.order.splice(d.idx, 1);
|
||||
d.order.push(ps.playerId);
|
||||
// the skipper goes to the end; with 「AI 队友最后选择」 to the end of the humans still to pick — behind them, ahead of
|
||||
// the AI seats — and to the very end only when no other human is left to pass to [ASSUMED: the option's intent,
|
||||
// humans before AI, kept through a skip; no source, a remake option]
|
||||
let at = d.order.length;
|
||||
if (this.aiPicksLast) {
|
||||
for (let j = d.order.length - 1; j >= d.idx; j--) {
|
||||
if (!this.players.get(d.order[j])?.isBot) { at = j + 1; break; }
|
||||
}
|
||||
}
|
||||
d.order.splice(at, 0, ps.playerId);
|
||||
this.startDraftTurn();
|
||||
return OK;
|
||||
}
|
||||
|
||||
@@ -15,8 +15,10 @@ export class MatchSpDraft {
|
||||
const alive = this.alivePlayers();
|
||||
if (!draft || !alive.length) { this.enterPrep(); return; }
|
||||
this.phase = PHASE.SP_DRAFT;
|
||||
const order = alive.map((p) => p.playerId);
|
||||
let order = alive.map((p) => p.playerId);
|
||||
if (!this.isSolo) this.rngDraft.shuffle(order);
|
||||
// 「AI 队友最后选择」 (GitHub #338): humans before AI seats, after the same shuffle (MatchPhases.humansFirst)
|
||||
order = this.humansFirst(order);
|
||||
// untimed: solo and any single-human match (soloUntimed); the co-op order / 6 cards stay
|
||||
const untimed = this.soloUntimed;
|
||||
this.sp = { ...draft, order, idx: 0, picks: {}, taken: {}, untimed, turnDeadline: 0 };
|
||||
|
||||
@@ -325,6 +325,9 @@ export const C2S = {
|
||||
'room.leave': {},
|
||||
'room.ready': { ready: isBool },
|
||||
'room.setDifficulty': { difficulty: (v) => DIFFICULTIES.includes(v) },
|
||||
// the co-op room option 「AI 队友最后选择」 (GitHub #338; host, before the match): the strategy and 机变 drafts order every
|
||||
// human seat before every AI seat (server/match/match/phases.js humansFirst); room.state.aiPicksLast
|
||||
'room.setAiPicksLast': { on: isBool },
|
||||
'room.addBot': {},
|
||||
'room.removeBot': { seat: (v) => isInt(v, 0, MAX_SEATS - 1) },
|
||||
// the host removes another human before the match (server/lobby.js kick; community report #17); playerId = the one the
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// test/lobby-ai-last.test.js — the co-op room option 「AI 队友最后选择」 (room.setAiPicksLast {on}; GitHub #338): host-only,
|
||||
// before the match only, co-op rooms only, off by default; room.state.aiPicksLast shows it to everybody; the match gets
|
||||
// it as opts.aiPicksLast when it starts (server/match/Match.js orders the drafts with it — test/match/ai-picks-last.test.js).
|
||||
import { describe, test, before, after, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { startServer } from '../server/index.js';
|
||||
import { StubMatch } from '../server/match/StubMatch.js';
|
||||
import { TestClient } from './helpers/wsClient.js';
|
||||
import { ERR } from '../shared/constants.js';
|
||||
import { validateC2S } from '../shared/protocol.js';
|
||||
|
||||
function clientPool(getUrl) {
|
||||
const open = new Set();
|
||||
return {
|
||||
async connect() { const c = await TestClient.connect(getUrl()); open.add(c); return c; },
|
||||
async player(name, token) {
|
||||
const c = await this.connect();
|
||||
const w = await c.hello(name, token);
|
||||
c.id = w.playerId;
|
||||
c.token = w.token;
|
||||
return c;
|
||||
},
|
||||
async closeAll() {
|
||||
await Promise.all([...open].map((c) => c.terminate().catch(() => {})));
|
||||
open.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
const quietLog = () => {
|
||||
const errors = [];
|
||||
return { errors, log: { info() {}, warn() {}, debug() {}, error: (...a) => errors.push(a.map(String).join(' ')) } };
|
||||
};
|
||||
const ok = async (c, msg) => { const r = await c.request(msg); assert.equal(r.t, 'ok', `${msg.t}: ${JSON.stringify(r)}`); return r; };
|
||||
const err = async (c, msg, code) => { const r = await c.request(msg); assert.equal(r.t, 'error', JSON.stringify(r)); assert.equal(r.code, code, JSON.stringify(r)); return r; };
|
||||
const seatOf = (state, id) => state.seats.find((s) => s && s.playerId === id) || null;
|
||||
async function createRoom(c, mode = 'coop') {
|
||||
await ok(c, { t: 'room.create', mode, difficulty: 'NORMAL' });
|
||||
return c.waitFor('room.state', (s) => s.hostId === c.id && s.mode === mode);
|
||||
}
|
||||
async function joinRoom(c, code) {
|
||||
await ok(c, { t: 'room.join', code });
|
||||
return c.waitFor('room.state', (s) => s.code === code && !!seatOf(s, c.id));
|
||||
}
|
||||
|
||||
/** StubMatch that records the options each match was started with. */
|
||||
const started = [];
|
||||
class RecordingMatch extends StubMatch {
|
||||
constructor(opts) {
|
||||
super(opts);
|
||||
started.push(opts);
|
||||
}
|
||||
}
|
||||
|
||||
test('protocol: room.setAiPicksLast takes exactly a boolean `on`', () => {
|
||||
assert.equal(validateC2S({ t: 'room.setAiPicksLast', on: true }), null);
|
||||
assert.equal(validateC2S({ t: 'room.setAiPicksLast', on: false }), null);
|
||||
for (const on of [undefined, null, 1, 0, 'true', 'yes', {}, []]) {
|
||||
assert.notEqual(validateC2S({ t: 'room.setAiPicksLast', on }), null, JSON.stringify(on));
|
||||
}
|
||||
});
|
||||
|
||||
describe('room.setAiPicksLast (lobby)', () => {
|
||||
let srv;
|
||||
let pool;
|
||||
const cap = quietLog();
|
||||
before(async () => {
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', log: cap.log, MatchClass: RecordingMatch, lobbyGraceMs: 60_000 });
|
||||
pool = clientPool(() => `ws://127.0.0.1:${srv.port}/ws`);
|
||||
});
|
||||
afterEach(async () => { await pool.closeAll(); });
|
||||
after(async () => {
|
||||
await srv?.close();
|
||||
assert.deepEqual(cap.errors, [], 'no server errors logged');
|
||||
});
|
||||
|
||||
test('off by default; host-only; every seat sees it in room.state; a change un-readies the guests like the difficulty', async () => {
|
||||
const host = await pool.player('Host');
|
||||
const st = await createRoom(host);
|
||||
assert.equal(st.aiPicksLast, false);
|
||||
const guest = await pool.player('Guest');
|
||||
const js = await joinRoom(guest, st.code);
|
||||
assert.equal(js.aiPicksLast, false);
|
||||
await err(guest, { t: 'room.setAiPicksLast', on: true }, ERR.NOT_HOST);
|
||||
await ok(guest, { t: 'room.ready', ready: true });
|
||||
await host.waitFor('room.state', (s) => seatOf(s, guest.id)?.ready);
|
||||
await ok(host, { t: 'room.setAiPicksLast', on: true });
|
||||
const on = await guest.waitFor('room.state', (s) => s.aiPicksLast === true);
|
||||
assert.equal(seatOf(on, guest.id).ready, false, 'the rules changed: the guest confirms again');
|
||||
await host.waitFor('room.state', (s) => s.aiPicksLast === true);
|
||||
// the same value again changes nothing (no broadcast needed, still ok)
|
||||
await ok(host, { t: 'room.setAiPicksLast', on: true });
|
||||
await ok(host, { t: 'room.setAiPicksLast', on: false });
|
||||
await guest.waitFor('room.state', (s) => s.aiPicksLast === false);
|
||||
// a malformed value never reaches the lobby
|
||||
await err(host, { t: 'room.setAiPicksLast', on: 'yes' }, ERR.BAD_MSG);
|
||||
});
|
||||
|
||||
test('solo rooms have no AI teammates: refused, the state stays off', async () => {
|
||||
const host = await pool.player('Solo');
|
||||
const st = await createRoom(host, 'solo');
|
||||
assert.equal(st.aiPicksLast, false);
|
||||
await err(host, { t: 'room.setAiPicksLast', on: true }, ERR.BAD_TARGET);
|
||||
});
|
||||
|
||||
test('the match gets it at the start; it cannot change during the match; the room keeps it for the next match and a new host', async () => {
|
||||
const host = await pool.player('Host');
|
||||
const st = await createRoom(host);
|
||||
const guest = await pool.player('Guest');
|
||||
await joinRoom(guest, st.code);
|
||||
await ok(host, { t: 'room.addBot' });
|
||||
await ok(host, { t: 'room.setAiPicksLast', on: true });
|
||||
await ok(guest, { t: 'room.ready', ready: true });
|
||||
await host.waitFor('room.state', (s) => s.aiPicksLast && seatOf(s, guest.id)?.ready);
|
||||
const n = started.length;
|
||||
await ok(host, { t: 'room.start' });
|
||||
await guest.waitFor('room.state', (s) => s.inMatch && s.aiPicksLast === true);
|
||||
assert.equal(started.length, n + 1);
|
||||
assert.equal(started[n].aiPicksLast, true, 'opts.aiPicksLast');
|
||||
await err(host, { t: 'room.setAiPicksLast', on: false }, ERR.ROOM_STARTED);
|
||||
await err(guest, { t: 'room.setAiPicksLast', on: false }, ERR.NOT_HOST);
|
||||
// the stub match ends once every human confirmed the briefing → back in the lobby with the option kept
|
||||
guest.clearInbox();
|
||||
await ok(host, { t: 'g.infoReady' });
|
||||
await ok(guest, { t: 'g.infoReady' });
|
||||
const back = await guest.waitFor('room.state', (s) => !s.inMatch);
|
||||
assert.equal(back.aiPicksLast, true);
|
||||
// host migration keeps the room's settings
|
||||
await ok(host, { t: 'room.leave' });
|
||||
const migrated = await guest.waitFor('room.state', (s) => s.hostId === guest.id);
|
||||
assert.equal(migrated.aiPicksLast, true);
|
||||
await ok(guest, { t: 'room.setAiPicksLast', on: false });
|
||||
await guest.waitFor('room.state', (s) => s.aiPicksLast === false);
|
||||
});
|
||||
|
||||
test('without the option the match gets aiPicksLast false', async () => {
|
||||
const host = await pool.player('Host');
|
||||
await createRoom(host);
|
||||
await ok(host, { t: 'room.addBot' });
|
||||
const n = started.length;
|
||||
await ok(host, { t: 'room.start' });
|
||||
await host.waitFor('room.state', (s) => s.inMatch);
|
||||
assert.equal(started[n].aiPicksLast, false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
// The co-op room option 「AI 队友最后选择」 (opts.aiPicksLast; GitHub #338): in the strategy draft (BAND_DRAFT) and the
|
||||
// 机变 draft (SP_DRAFT) every human seat picks before every AI seat. The drawn order is kept inside each group (a stable
|
||||
// partition AFTER the usual shuffle): no extra random draw, so the option off is today's order and the option on moves
|
||||
// no random stream. A human under AI 托管, disconnected or departed is still a human (only room.addBot seats are AI).
|
||||
// A human's skip passes the turn to the other humans still to pick, ahead of the AI seats [ASSUMED]; with no other
|
||||
// human left it goes to the end as without the option.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
import { makeMatch } from './harness.js';
|
||||
|
||||
const isAi = (pid) => pid.startsWith('ai_');
|
||||
/** the stable humans-first partition of a drawn order */
|
||||
const humansFirst = (order) => [...order.filter((p) => !isAi(p)), ...order.filter(isAi)];
|
||||
/** humans first, then AI seats, each in seat order — 1 host + 3 AI by default */
|
||||
const seatsOf = (ids) => ids.map((playerId, seat) => ({ seat, playerId, name: playerId, isBot: isAi(playerId), connected: true }));
|
||||
|
||||
/** A match driven to BAND_DRAFT (every human confirms the briefing); `before(m)` runs while still in INFO_CHECK. */
|
||||
function toBandDraft(o, before) {
|
||||
const h = makeMatch({ mode: 'coop', ...o }).start();
|
||||
if (before) before(h.m);
|
||||
for (const ps of h.m.players.values()) if (!ps.isBot && !ps.left) h.m.handle(ps.playerId, { t: 'g.infoReady' });
|
||||
h.run(() => h.m.phase !== PHASE.INFO_CHECK);
|
||||
assert.equal(h.m.phase, PHASE.BAND_DRAFT);
|
||||
return h;
|
||||
}
|
||||
|
||||
/** The band-draft order (taken at its start, before any skip) and the next draws of the random streams after it. */
|
||||
function bandOrder(o, before) {
|
||||
const h = toBandDraft(o, before);
|
||||
const order = h.m.draft.order.slice();
|
||||
const draws = { draft: h.m.rngDraft(), shop: h.m.rngShop(), bots: h.m.rngBots(), meta: h.m.rngMeta(), waves: h.m.rngWaves() };
|
||||
h.m.dispose();
|
||||
return { order, draws };
|
||||
}
|
||||
|
||||
test('the option: off by default, on only when opts.aiPicksLast === true, never in solo', () => {
|
||||
const mk = (o) => { const h = makeMatch(o); const v = h.m.aiPicksLast; h.m.dispose(); return v; };
|
||||
assert.equal(mk({ mode: 'coop', humans: 1, bots: 3 }), false);
|
||||
assert.equal(mk({ mode: 'coop', humans: 1, bots: 3, aiPicksLast: true }), true);
|
||||
assert.equal(mk({ mode: 'coop', humans: 1, bots: 3, aiPicksLast: 'yes' }), false, 'only a real boolean');
|
||||
assert.equal(mk({ mode: 'solo', humans: 1, aiPicksLast: true }), false, 'solo: nothing to order');
|
||||
});
|
||||
|
||||
test('band draft, option off: the order is exactly today\'s for the same seed (no option = off)', () => {
|
||||
let humanNotFirst = 0;
|
||||
for (let seed = 1; seed <= 12; seed++) {
|
||||
const o = { seats: seatsOf(['p_0', 'ai_0', 'ai_1', 'ai_2']), seed };
|
||||
const def = bandOrder(o);
|
||||
const off = bandOrder({ ...o, aiPicksLast: false });
|
||||
assert.deepEqual(off, def, `seed ${seed}`);
|
||||
if (def.order[0] !== 'p_0') humanNotFirst++;
|
||||
}
|
||||
assert.ok(humanNotFirst > 0, 'the shuffle does put an AI first without the option (so the next test means something)');
|
||||
});
|
||||
|
||||
test('band draft, option on, 1 human + 3 AI: the human always picks first; the same shuffle, every random stream unchanged', () => {
|
||||
for (let seed = 1; seed <= 12; seed++) {
|
||||
const o = { seats: seatsOf(['p_0', 'ai_0', 'ai_1', 'ai_2']), seed };
|
||||
const off = bandOrder(o);
|
||||
const on = bandOrder({ ...o, aiPicksLast: true });
|
||||
assert.equal(on.order[0], 'p_0', `seed ${seed}`);
|
||||
assert.deepEqual(on.order, humansFirst(off.order), `seed ${seed}: the AI seats keep their drawn order`);
|
||||
assert.deepEqual(on.draws, off.draws, `seed ${seed}: no extra random draw`);
|
||||
}
|
||||
});
|
||||
|
||||
test('band draft, option on, 2 humans + 2 AI on interleaved seats: both humans first, in their drawn order', () => {
|
||||
const ids = ['p_0', 'ai_0', 'p_1', 'ai_1'];
|
||||
const seen = new Set();
|
||||
for (let seed = 1; seed <= 12; seed++) {
|
||||
const o = { seats: seatsOf(ids), seed };
|
||||
const off = bandOrder(o);
|
||||
const on = bandOrder({ ...o, aiPicksLast: true });
|
||||
assert.deepEqual(on.order, humansFirst(off.order), `seed ${seed}`);
|
||||
assert.deepEqual(on.order.slice(0, 2).sort(), ['p_0', 'p_1']);
|
||||
seen.add(on.order[0]);
|
||||
}
|
||||
assert.equal(seen.size, 2, 'the humans\' own order is still drawn (either may lead)');
|
||||
});
|
||||
|
||||
test('band draft, option on: all humans, or all AI seats (tools/matchrun), keep today\'s order', () => {
|
||||
for (const ids of [['p_0', 'p_1', 'p_2', 'p_3'], ['ai_0', 'ai_1', 'ai_2', 'ai_3']]) {
|
||||
for (let seed = 1; seed <= 4; seed++) {
|
||||
const o = { seats: seatsOf(ids), seed };
|
||||
if (ids[0] === 'ai_0') {
|
||||
// no human: INFO_CHECK ends by itself
|
||||
const run = (x) => { const h = makeMatch({ mode: 'coop', ...x }).start(); h.run(() => h.m.phase !== PHASE.INFO_CHECK); const r = h.m.draft.order.slice(); h.m.dispose(); return r; };
|
||||
assert.deepEqual(run({ ...o, aiPicksLast: true }), run(o), `all AI, seed ${seed}`);
|
||||
} else {
|
||||
assert.deepEqual(bandOrder({ ...o, aiPicksLast: true }), bandOrder(o), `all humans, seed ${seed}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('band draft, option on: a human under AI 托管, disconnected or departed is still ordered as a human', () => {
|
||||
for (let seed = 1; seed <= 8; seed++) {
|
||||
const o = { seats: seatsOf(['p_0', 'p_1', 'p_2', 'ai_0']), seed, aiPicksLast: true };
|
||||
const before = (m) => {
|
||||
m.handle('p_0', { t: 'g.autoplay', on: true });
|
||||
m.onDisconnect('p_1');
|
||||
m.onLeave('p_2');
|
||||
};
|
||||
const h = toBandDraft(o, before);
|
||||
assert.equal(h.m.draft.order[3], 'ai_0', `seed ${seed}: ${h.m.draft.order}`);
|
||||
h.m.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('band draft, option on: a skip passes the turn to the other humans, ahead of the AI seats; the last human\'s skip goes to the end [ASSUMED]', () => {
|
||||
const h = toBandDraft({ seats: seatsOf(['p_0', 'ai_0', 'p_1', 'ai_1']), seed: 3, aiPicksLast: true });
|
||||
const m = h.m;
|
||||
const [h1, h2, a1, a2] = m.draft.order;
|
||||
assert.ok(!isAi(h1) && !isAi(h2) && isAi(a1) && isAi(a2), String(m.draft.order));
|
||||
// the first human passes: behind the other human, still before the AI seats
|
||||
assert.deepEqual(m.handle(h1, { t: 'g.bandSkip' }), { ok: true });
|
||||
assert.deepEqual(m.draft.order, [h2, h1, a1, a2]);
|
||||
assert.equal(m.draftTurn(), h2);
|
||||
// the second human passes: behind the first again
|
||||
assert.deepEqual(m.handle(h2, { t: 'g.bandSkip' }), { ok: true });
|
||||
assert.deepEqual(m.draft.order, [h1, h2, a1, a2]);
|
||||
assert.deepEqual(m.handle(h1, { t: 'g.band', bandId: 'band_amiya' }), { ok: true });
|
||||
assert.equal(m.draftTurn(), h2);
|
||||
m.dispose();
|
||||
|
||||
// a lone human (host + AI): no other human to pass to — the skip moves it to the end as without the option
|
||||
const h3 = toBandDraft({ seats: seatsOf(['p_0', 'ai_0', 'ai_1', 'ai_2']), seed: 3, aiPicksLast: true });
|
||||
const order = h3.m.draft.order.slice();
|
||||
assert.equal(order[0], 'p_0');
|
||||
assert.deepEqual(h3.m.handle('p_0', { t: 'g.bandSkip' }), { ok: true });
|
||||
assert.deepEqual(h3.m.draft.order, [...order.slice(1), 'p_0']);
|
||||
h3.m.dispose();
|
||||
|
||||
// option off: the skip still goes to the very end (today's rule)
|
||||
const h4 = toBandDraft({ seats: seatsOf(['p_0', 'ai_0', 'p_1', 'ai_1']), seed: 3 });
|
||||
h4.run(() => !isAi(h4.m.draftTurn() || 'ai_'));
|
||||
const o4 = h4.m.draft.order.slice();
|
||||
const at = h4.m.draft.idx;
|
||||
const cur = h4.m.draftTurn();
|
||||
assert.deepEqual(h4.m.handle(cur, { t: 'g.bandSkip' }), { ok: true });
|
||||
assert.deepEqual(h4.m.draft.order, [...o4.slice(0, at), ...o4.slice(at + 1), cur]);
|
||||
h4.m.dispose();
|
||||
});
|
||||
|
||||
/** Drive a fake-battle match into the SP draft of round `r`; `flip(m)` runs just before it is entered. */
|
||||
function toSpDraft(o, r, flip) {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', fake: true, ...o }).start();
|
||||
const m = h.m;
|
||||
h.drive(() => m.phase === PHASE.ROUND_START && m.round === r);
|
||||
assert.equal(m.round, r);
|
||||
if (flip) flip(m);
|
||||
h.run(() => m.phase === PHASE.SP_DRAFT);
|
||||
assert.equal(m.phase, PHASE.SP_DRAFT);
|
||||
return h;
|
||||
}
|
||||
|
||||
test('机变 draft, option off vs on from the same state: on = the humans-first partition of the same shuffle; no random stream moves', () => {
|
||||
for (const [ids, seed] of [[['p_0', 'ai_0', 'ai_1', 'ai_2'], 14], [['p_0', 'ai_0', 'p_1', 'ai_1'], 5], [['p_0', 'ai_0', 'ai_1', 'ai_2'], 2]]) {
|
||||
const o = { seats: seatsOf(ids), seed };
|
||||
const off = toSpDraft(o, 3);
|
||||
const on = toSpDraft(o, 3, (m) => { m.aiPicksLast = true; });
|
||||
assert.deepEqual(on.m.sp.order, humansFirst(off.m.sp.order), `seed ${seed}`);
|
||||
assert.deepEqual(on.m.sp.cards, off.m.sp.cards, 'the same cards');
|
||||
for (const k of ['rngDraft', 'rngShop', 'rngBots', 'rngMeta', 'rngWaves']) assert.equal(on.m[k](), off.m[k](), `${k} unchanged (seed ${seed})`);
|
||||
off.m.dispose();
|
||||
on.m.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('机变 draft, option on, 1 human + 3 AI: the human picks first in every 机变 round of the match', () => {
|
||||
for (const seed of [1, 14]) {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', fake: true, seats: seatsOf(['p_0', 'ai_0', 'ai_1', 'ai_2']), seed, aiPicksLast: true }).start();
|
||||
const m = h.m;
|
||||
const firsts = {};
|
||||
const enter = m.enterSpDraft;
|
||||
m.enterSpDraft = function () {
|
||||
enter.call(this);
|
||||
if (this.sp) firsts[this.round] = this.sp.order[0];
|
||||
};
|
||||
h.drive(() => h.ended != null || m.round > 9);
|
||||
const rounds = m.gd.spRounds().filter((r) => r <= 9);
|
||||
assert.deepEqual(rounds, [3, 6, 9]);
|
||||
for (const r of rounds) if (firsts[r] != null) assert.equal(firsts[r], 'p_0', `seed ${seed} R${r}`);
|
||||
assert.ok(firsts[3] === 'p_0', 'round 3 is always reached');
|
||||
m.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('机变 draft, option on, 2 humans + 2 AI interleaved, one under AI 托管: both humans ahead of the AI seats', () => {
|
||||
const o = { seats: seatsOf(['p_0', 'ai_0', 'p_1', 'ai_1']), seed: 5 };
|
||||
const h = toSpDraft({ ...o, aiPicksLast: true }, 3, (m) => { m.handle('p_1', { t: 'g.autoplay', on: true }); });
|
||||
assert.deepEqual(h.m.sp.order.slice(0, 2).sort(), ['p_0', 'p_1']);
|
||||
assert.ok(h.m.sp.order.slice(2).every(isAi));
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
test('solo: the option changes nothing (seat order, no skip)', () => {
|
||||
const run = (aiPicksLast) => {
|
||||
const h = makeMatch({ mode: 'solo', humans: 1, seed: 7, aiPicksLast }).start();
|
||||
h.m.handle('p_0', { t: 'g.infoReady' });
|
||||
h.run(() => h.m.phase !== PHASE.INFO_CHECK);
|
||||
const r = { order: h.m.draft.order.slice(), next: h.m.rngDraft() };
|
||||
h.m.dispose();
|
||||
return r;
|
||||
};
|
||||
assert.deepEqual(run(true), run(undefined));
|
||||
});
|
||||
@@ -7,7 +7,8 @@
|
||||
// seed, matchNo (the room's match number: part of the battleId prefix), data (default: real data/*.json),
|
||||
// fake (true → test/match/fakeBattle.js as BattleClass), script (FakeBattle.script), registry, instant (virtual
|
||||
// scheduler runs battles synchronously; default true), timerScale, battleContent, botRehearsal (default 0),
|
||||
// botSliceMs (bot rehearsal slice budget; default: unbounded in virtual time).
|
||||
// botSliceMs (bot rehearsal slice budget; default: unbounded in virtual time), aiPicksLast (the room option
|
||||
// AI 队友最后选择: the drafts order every human seat before every AI seat).
|
||||
// Combat mode: clientCombat (default false here: the legacy server-run mode most suites were written for; production
|
||||
// defaults to client-side combat, DESIGN §14). With clientCombat: true every human gets a scripted browser
|
||||
// (test/match/simClient.js SimClient: h.clients) unless clients: false; pace 'instant' | 'paced', perPlayer
|
||||
@@ -77,6 +78,7 @@ export function makeMatch(o = {}) {
|
||||
clientCombat: o.clientCombat ?? false,
|
||||
verify: o.verify ?? 'off',
|
||||
headlessSliceMs: o.headlessSliceMs,
|
||||
aiPicksLast: o.aiPicksLast,
|
||||
});
|
||||
const m = h.m;
|
||||
if (m.clientCombat && o.clients !== false) {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// The room screen's 「AI 队友最后选择」 switch (GitHub #338; server: room.setAiPicksLast, room.state.aiPicksLast): co-op
|
||||
// rooms only, the host toggles it, everybody else sees its state read-only; the texts go through t() with English in
|
||||
// public/i18n/en.json.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const SRC = readFileSync(path.join(ROOT, 'public/js/screens/room.js'), 'utf8');
|
||||
const EN = JSON.parse(readFileSync(path.join(ROOT, 'public/i18n/en.json'), 'utf8'));
|
||||
const mod = () => import(pathToFileURL(path.join(ROOT, 'public/js/screens/room.js')).href);
|
||||
|
||||
const room = (o = {}) => ({
|
||||
code: 'ABCD', hostId: 'h', mode: 'coop', difficulty: 'NORMAL', aiPicksLast: false,
|
||||
seats: [{ seat: 0, playerId: 'h', name: 'Host', isBot: false, ready: false, connected: true }, { seat: 1, playerId: 'g', name: 'G', isBot: false, ready: false, connected: true }, null, null],
|
||||
...o,
|
||||
});
|
||||
|
||||
test('aiLastOption: hidden in solo, editable by the host only, the state from room.state (off when absent)', async () => {
|
||||
const { aiLastOption } = await mod();
|
||||
assert.equal(aiLastOption(room({ mode: 'solo' }), 'h'), null, 'solo: no AI teammates');
|
||||
assert.deepEqual(aiLastOption(room(), 'h'), { on: false, editable: true });
|
||||
assert.deepEqual(aiLastOption(room({ aiPicksLast: true }), 'h'), { on: true, editable: true });
|
||||
assert.deepEqual(aiLastOption(room({ aiPicksLast: true }), 'g'), { on: true, editable: false });
|
||||
assert.deepEqual(aiLastOption(room({ aiPicksLast: undefined }), 'g'), { on: false, editable: false }, 'an older server');
|
||||
assert.equal(aiLastOption(null, 'h'), null);
|
||||
});
|
||||
|
||||
test('room.js sends room.setAiPicksLast { on } and shows the switch through t(); en.json has the English', () => {
|
||||
assert.match(SRC, /net\.request\('room\.setAiPicksLast', \{ on \}\)/);
|
||||
for (const zh of ['AI 队友最后选择', '策略与机变轮选时,所有博士先于 AI 队友选择', '由创建者设置']) {
|
||||
assert.ok(SRC.includes(`t('${zh}')`), zh);
|
||||
assert.equal(typeof EN[zh], 'string', `en.json: ${zh}`);
|
||||
assert.ok(EN[zh].length > 0 && !/[一-鿿]/.test(EN[zh]), `English for ${zh}`);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user