feedback7 dober: 教鞭 is the official personal choice of three 战术特训 cards again, not a random one (PR #353 by @Sukvii, the owner's decision of 2026-10-08); golden: coop2-NORMAL-8-serverrun and coop2-ABYSS-10-boosted move

Seen: the Art 教鞭 (杜宾's strategy) drew three 战术特训 cards and added a random one — a documented simplification (docs/META.md
§2.5: a personal choice would need its own phase, protocol message, timer and AI takeover). Official: act2autochess
choice_event hunter_band_1 is a PERSONAL_CHOOSE; PRTS 下半 记录 §法术 教鞭 "使用后销毁,于3个战术特训的悬赏任务中选择一项". The
owner decided on 2026-10-08 to follow it. Taken from PR #353 (code and tests by @Sukvii), ported to the split 0.2.2 modules.
Now: ctx.offerBountyChoice -> Match.offerBountyChoice keeps up to three different mode-eligible cards (15 of 20 in 标准 /
险境, 20 in 绝境 / 终极; one meta-rng shuffle) in PlayerState.personalChoice, m.private only (same cards after a
reconnect); g.choice { idx, choiceId } confirms it (Match.pickPersonalChoice: owner, id, round, index; a request without
a choiceId is still the public draft, a stale id never falls through to it). No new phase or timer: Ready is refused while
it is open, the prep's deadline picks at random before the temp pieces and Ready, a bot or a 托管 seat picks with the bounty
scorer (botPickCard, the bots' seeded rng; at the prep's start, after the cast, before Ready; the scheduler's fallback
picks at random if a step fails). The client reuses ChoiceOverlay (personal: no order, no turns, the prep's countdown) over
a locked prep, the Ready reasons, the mock harness; six UI strings in en / ja / ko / zh-TW. An eliminated player's offer
goes with it; invariants.js flags an offer outside its owner's own open prep.
Not taken from the PR: its §25.24 in the released docs/history/0.2.0.md (the 0.2.2 section is the integrator's), its edits
of the Ready toggle beyond the personal-choice case, its tail placement of the i18n keys.
Golden: coop2-NORMAL-8-serverrun (ai_0 杜宾 casts 教鞭 from R2 on and now scores the three cards: teamLp 39 -> 34, virtualMs
121400 -> 118400, rng shop 511 -> 483 / bots 18063 -> 18070 / meta 112 -> 103) and coop2-ABYSS-10-boosted (ai_1 杜宾: virtualMs
121650 -> 124650, shop 590 -> 589 / bots 22075 -> 22096 / meta 141 -> 154); both first differ in round 2, every other scenario
and family is unchanged; node tools/golden.mjs --twice is identical.
Tests: items.test.js (three distinct cards, the tapped one applied once, nothing before the confirm, a refused cast keeps
the Art / count / rng / uid, an eliminated player's offer), economy.test.js (Ready refused, the deadline picks before
endPrep, solo prep untimed), connection.test.js (two players' offers, reconnect, foreign / duplicate / old ids, an offline
player's pick at the deadline), bot.test.js (托管 takeover incl. the failing-step fallback), bot-smarter.test.js (the scorer's
pick, deterministic per seed, no rng draw when nothing is pending), followups.test.js (R14 / R15), draft.test.js,
unitStats.test.js (protocol), hardening.test.js (invariant), gameLogic / playtest3 / playtest4 (view model, Ready reason,
ChoiceView, actions), mock.e2e.test.js and playtest2.real.e2e.test.js (browser, SP_E2E=1).
This commit is contained in:
Sukvii authored and sganggs committed 2026-10-08 20:45:54 +08:00
1 parent 05eb2b807f
commit 56a7b918a9
48 files changed
+901 -159

No files matched your search

+2 -1
View File
@@ -398,7 +398,8 @@ heuristics CPU 36 / 88 → 35 / 86 ms and 16 / 40 → 16 / 40 ms.
* docs/META.md §2.6 example guards `ctx.source.kind === 'choice'` (its EffectRef reuses the handler's key).
* 坚若磐石 = least LP lost (`titles.comment_3 { stat: 'lpLost', rule: 'min' }` in tuning.json; results.js supports
`rule: 'min'` among the players still alive).
* 教鞭 / “神秘顾客” stay a random bounty, documented in docs/META.md §2.5.
* 教鞭 offers a personal choice of three 战术特训 cards (a bot picks by the bounty scorer, the prep's deadline at random);
“神秘顾客” stays a random bounty — docs/META.md §2.5.
---
+45 -17
View File
@@ -175,18 +175,18 @@ A `choice:<effectId>` registry handler overrides the default application (§2.4)
### 1.3 Disconnects, AI takeover
* Disconnected human: the seat keeps playing its last lineup; drafts auto-resolve at their deadlines, prep auto-readies at
the deadline (the temp pieces due at that prep sold/destroyed). Nothing is bought for them. A battle the human was authority of goes to the server
the deadline (an open 教鞭 choice is picked at random first, §2.5; then the temp pieces due at that prep are sold/destroyed). Nothing is bought for them. A battle the human was authority of goes to the server
(normal / 联防: re-simulated from t = 0) or, on a boss field, to the partner's replica (DESIGN §14). The session stays
resumable for 10 min (net.js `reconnectWindowMs`); a **solo** run's for the official `constants.singleReconnectTime`
(86 400 s = 24 h, lobby.js `soloResumeWindowMs`) — nothing in a solo run is timed, so it simply waits. `onReconnect`
resends `m.public`, `m.private` and the `b.start` of the field the player is on / watching.
* `g.autoplay { on }` ("AI 托管"): the bot plays the seat (drafts, buying, placement, ready) until turned off.
* `g.autoplay { on }` ("AI 托管"): the bot plays the seat (drafts, buying, placement, an open 教鞭 choice, ready) until turned off.
* `onLeave` (quit / reconnect window expired): 中途退出 counts as elimination (research 00-INDEX §3, 01 §9, 06 §7 /
§10.3): every copy the seat holds returns to the shared pool at once; the seat leaves the round loop and the Final
Assault pairing (re-planned when it quits before the boss fight) and the boss pool (bloodPoint per player alive when
the fight starts — a departed seat no longer counts, DESIGN §25.13.4); its
running normal battle is force-ended; a pending band pick
becomes the default band and a 机变 turn passes on. Status `left`, LP 0, rounds passed = the rounds it had survived.
becomes the default band, a 机变 turn passes on and an open 教鞭 choice goes with the seat (`eliminate`). Status `left`, LP 0, rounds passed = the rounds it had survived.
When no human is left at all the match ends immediately (`reason: 'abandoned'`); when only eliminated spectators are
left it ends as `'eliminated'`.
@@ -263,7 +263,8 @@ like any owned unit — AI 托管 too; the buy loop's bench shed, which sells by
never sells a piece gained since the bot's last prep ended, `rememberOwned`), 身份牌 / 通讯机 / 寻呼模块 on a focus
member; 画卷 copies the most
valuable deployed operator; 教鞭 / “神秘顾客” are used after a perfect battle and otherwise kept in the hand — a bot drops
a kept one only when it needs the hand slot (“神秘顾客” then pays its fund), a human's seat under AI 托管 never. 机变: a
a kept one only when it needs the hand slot (“神秘顾客” then pays its fund), a human's seat under AI 托管 never; 教鞭's three
cards are scored like a 机变 bounty draft (§2.5). 机变: a
bounty card is scored by its expected payout minus its expected leaks × the value of an LP (2 + 20 / LP), the kill
chance from the exposure model below for that one enemy against the own board as it stands (its HP × the round's
multiplier, DEF / RES, speed, route kind) — an expected-value comparison: it prefers the card with the best payout
@@ -425,6 +426,7 @@ Writes (all validated, never throw on bad input, never make funds / pools negati
| `setDeviceActive(alias, on)` / `setTileOverride(r, c, 'melee'\|'ranged'\|'none')` | terrain changes of this player's board (legality + battle input `deviceOverrides`) |
| `addEffect(ref)` / `removeEffect(id)` / `setEffectCounter(id, v)` | EffectRefs `{ id, key?, name, desc, iconKind, iconId, counter?, battle=true, params?, data?, hidden? }` — shown in `m.private.effects`, passed to battles as `playerEffects` when `battle` |
| `addBounty(card)` | a bounty (choices.json cards.bounty shape) on the next battles |
| `offerBountyChoice(cards, sourceItemId)` | 教鞭 only: open a PERSONAL choice of up to three of `cards` (cards.bounty entries) for this player — shuffled once with the meta rng, kept in `m.private.personalChoice` until the owner confirms one (`g.choice { idx, choiceId }`) or the prep's deadline / an AI seat picks (§2.5); returns the intent result (`{ ok }`, or `{ error, detail }` for a phase that is not PREP, a Ready player, a choice already open or no card — before any rng draw or id, so the Art stays in the hand) |
| `toast(text, kind)` / `ticker(text)` / `giftTicker(fromName, chessId)` | messages |
| `teammates()` / `player(playerId)` | ctx objects of other alive players (team effects) |
@@ -458,20 +460,46 @@ bilibili BV1vzyVBuEN9 ≈ 8:24 / BV1Qkw1zMEoR ≈ 7:25, pointed out in PR #2 —
is not consumed: PRTS 下半 记录 备注 "生效时,原干员销毁,获得一名高一阶的随机初始干员(最高六阶)", PRTS 卫戍协议/帮助 "佩戴的
装备无法手动卸除,在失去该干员(干员出售、销毁、合并等)…时自动卸除", players re-inject it every round; player feedback after
0.1.0), 画卷 (copy the operator in range with its
items), 教鞭 / “神秘顾客” (a random bounty is added).
items), 教鞭 (a personal choice of three 战术特训 cards), “神秘顾客” (a random bounty is added).
**教鞭 / “神秘顾客” stay a random bounty (deliberate).** The official Arts open a personal 悬赏 choice
("选择一项(特殊)悬赏任务进行挑战", `choice_event hunter_band_1`). The server applies a random bounty instead: content
(server/sim/content/items/meta.js) draws 3 cards and takes one at random — 教鞭 from the 20 战术特训 cards (payout
`perfect`: extra enemies, the card's coins when the own phase is perfect; PRTS 下半 记录 §法术 教鞭 "于3个战术特训的悬赏
任务中选择一项", §机变阶段 "※以下悬赏任务仅由法术教鞭生成", 杜宾 加练! "若自身战斗完美作战可获得资金"; user playtest #6
item 4 review), “神秘顾客” (granted by nothing in act2) from the band-bounty family `enemyeffect_b_*` [ASSUMED] — each
limited to enemies the mode can field (the built-in fallback: a random tier ≤ II bounty of `cards.bounty`), and adds it
with `ctx.addBounty`. A personal choice overlay would need its own phase / protocol message outside SP_DRAFT (a
second, simultaneous draft in co-op, with timers and AI takeover) for a rarely used Art, while the random pick keeps the
risk / reward the item is about. The
bounty then behaves like any other (next battles, 联防 payouts, Final Assault spawns). 神秘顾客's destroy clause (+1
fund, the Art passes to the next alive player) is content too (`onDestroy`).
**教鞭 offers a personal choice of three 战术特训 cards (0.2.2); “神秘顾客” stays a random bounty.** The official Arts open a
personal 悬赏 choice ("选择一项(特殊)悬赏任务进行挑战", `choice_event hunter_band_1`, choiceType `PERSONAL_CHOOSE`). 教鞭
(PRTS 下半 记录 §法术 教鞭 "使用后销毁,于3个战术特训的悬赏任务中选择一项"; §机变阶段 "※以下悬赏任务仅由法术教鞭生成"; 杜宾
加练! "<教鞭>:使用后为下场战斗添加额外敌人,若自身战斗完美作战可获得资金"; user playtest #6 item 4 review) offers three of
the 20 战术特训 cards (payout `perfect`: extra enemies, the card's coins when the own phase is perfect), each limited to
enemies the mode can field. Until 0.2.1 the server took one of the three at random — a deliberate simplification: a
personal choice looked like it needed a phase of its own, a protocol message outside SP_DRAFT, a timer and an AI
takeover for a rarely used Art. The owner decided on 2026-10-08 to follow the official choice (PR #353 by @Sukvii), and
it fits in the existing parts:
* **Offer.** Content (server/sim/content/items/meta.js) calls `ctx.offerBountyChoice(cards, itemId)` (`Match.offerBountyChoice`,
match/spDraft.js): the eligible cards are shuffled once with the meta rng and up to three different ones are kept in
`ps.personalChoice { id, round, sourceItemId, cards }`. The Art is used up (and counts for the two per round) when the
offer is made; nothing is applied yet. Refused — the Art stays in the hand, no rng draw, no id — outside the player's
PREP, for a Ready player, while an offer is already open and when no card is left to offer.
* **Private.** Only the owner sees it: `m.private.personalChoice` (re-sent on a reconnect or a refresh, so the same cards
and id come back); never `m.public`, a scout's view or a spectator. Players have offers of their own, at the same time.
* **Confirm.** `g.choice { idx, choiceId }` (`Match.pickPersonalChoice`) validates the owner, the id, the round and the
index, then adds the card with `addBounty` and closes the offer; a request without a `choiceId` is the public 机变 draft
as ever, and a stale, foreign or repeated id never falls through to it (`BAD_TARGET`). The client shows the overlay of
the 机变 draft (`ChoiceOverlay`, `personal`: the same two taps, no order, no turns) over a locked prep.
* **No phase, no timer.** The offer lives inside PREP. Ready is refused while it is open (`setReady`: `BAD_TARGET`;
`m.private.canReady` false), and the prep's own deadline resolves it: in a timed co-op prep `prepDeadline` takes one of
the cards at random (the meta rng, like a 机变 turn that runs out) before the temp pieces and Ready; a solo or
single-human prep is untimed and has no timer for it, and a disconnected player waits for the deadline like for any
other prep action. A seat the engine plays — an AI teammate, a human under AI 托管, one that left — picks like the bots do.
* **The bots.** `bot.js` scores the three cards like a 机变 bounty draft (`botPickCard` → `bountyScore`: the expected
payout minus the expected leaks × the value of an LP, against the own board; the bots' seeded rng only breaks ties), so
the choice is deterministic for a seed: at the start of the bot's prep (an offer left by a human who turned 托管 on),
right after it cast the Art, and before Ready. When a step of the bot's prep fails, the scheduler's fallback readies the
seat with the random pick instead — no seat is ever left un-ready by an offer.
* The chosen bounty is a bounty like any other: next battles (a multi-round card lasts two, §1.2), 联防 payouts, the Final
Assault / Hidden Core spawns — and in the boss rounds its perfect-battle coins (§3 Waves).
“神秘顾客” (granted by nothing in act2, no PRTS list of its cards) remains random: three cards are drawn from the band-bounty
family `enemyeffect_b_*` [ASSUMED], limited to enemies the mode can field, and one is added with `ctx.addBounty` (the
built-in fallback: a random tier ≤ II bounty of `cards.bounty`). Its destroy clause (+1 fund, the Art passes to the next
alive player) is content too (`onDestroy`).
EffectRefs: `effect:builtin_round_coin`, `effect:builtin_gift`, `effect:builtin_next_buy_golden_item` (整备),
`effect:builtin_next_buy_elite` (升华). An eliminated player gets no dispatch, except an EffectRef whose handler sets
`afterElimination: true` (its `onRoundStart` still runs: `EffectDispatcher.dispatchEliminated`, called by
+1 -1
View File
@@ -46,7 +46,7 @@
| 招募价格(按阶级 I–VI) | 2 / 3 / 3 / 3 / 4 / 4 |
| 刷新 | 1 资金 |
| 出售干员 | 任何干员(含精锐)都只返还 **1** 资金 |
| 装备 / 法术 | 调度中心出售除特殊装备外的所有装备:4 把特殊维式重锤(战栗 / 坚固 / 加速 / 灼燃)只来自维多利亚盟约(每 25 层)和洛洛的定制品,突变细胞只来自昆图斯的策略。装备不能出售,整备区里的只能「销毁」(不返还)。已配发的装备锁定在干员身上:只能被第三件装备替换(弹出替换窗口,选择要替换的一件,它会被销毁;盟约之币、博士投影等装备即销毁的道具也一样,先销毁选中的那件再生效,干员留下一个空栏位,精锐干员不能使用博士投影,此时什么都不销毁),或在干员出售 / 合成精锐时退回整备区。**突变细胞**不会用完:战斗结束后装备者被销毁(部署在场上的话,原来的格子空出来,剩余可放置角色加 1),突变细胞和它的其他装备先退回整备区,可以再次配发给任意干员;随后获得一名高一阶(最高 VI)的随机普通干员(装备者是精锐也一样),和其他新获得的干员一样进入整备区(满了进入临时整备区),需要自己重新部署;如果它凑齐了合成所需的同名干员,照常合成精锐。**坚固维式重锤**首次受到致命伤害时锁血 8 秒,**每次部署一次**:被击倒再部署(或突袭跳跃再部署、被 M3茧甲 / 埃芒加德复活)后又能锁一次,娜仁图亚策略借来的锤子也一样;借用到期时正在锁血的会锁满 8 秒;站在阿戈尔干员身前被开场吞噬、自带源石溶剂掉血时也会用掉这一次 |
| 装备 / 法术 | 调度中心出售除特殊装备外的所有装备:4 把特殊维式重锤(战栗 / 坚固 / 加速 / 灼燃)只来自维多利亚盟约(每 25 层)和洛洛的定制品,突变细胞只来自昆图斯的策略。装备不能出售,整备区里的只能「销毁」(不返还)。已配发的装备锁定在干员身上:只能被第三件装备替换(弹出替换窗口,选择要替换的一件,它会被销毁;盟约之币、博士投影等装备即销毁的道具也一样,先销毁选中的那件再生效,干员留下一个空栏位,精锐干员不能使用博士投影,此时什么都不销毁),或在干员出售 / 合成精锐时退回整备区。**突变细胞**不会用完:战斗结束后装备者被销毁(部署在场上的话,原来的格子空出来,剩余可放置角色加 1),突变细胞和它的其他装备先退回整备区,可以再次配发给任意干员;随后获得一名高一阶(最高 VI)的随机普通干员(装备者是精锐也一样),和其他新获得的干员一样进入整备区(满了进入临时整备区),需要自己重新部署;如果它凑齐了合成所需的同名干员,照常合成精锐。**坚固维式重锤**首次受到致命伤害时锁血 8 秒,**每次部署一次**:被击倒再部署(或突袭跳跃再部署、被 M3茧甲 / 埃芒加德复活)后又能锁一次,娜仁图亚策略借来的锤子也一样;借用到期时正在锁血的会锁满 8 秒;站在阿戈尔干员身前被开场吞噬、自带源石溶剂掉血时也会用掉这一次。**教鞭**(杜宾的策略每 2 个回合给一枚)拖到场上使用后即销毁,休整期里弹出 3 张战术特训悬赏(只有你自己看得到,刷新页面或断线重连后还是这 3 张),点一张再点一次确认,选中的那张成为接下来的悬赏;选定之前不能点「准备就绪」。同盟模式的休整期倒计时结束时还没选,会随机选一张;独立模拟和只有一名玩家时休整期不限时,所以不会自动选。AI 队友和 AI 托管的座位按悬赏评分自己选。战术特训的赏金要「各自行动阶段就达成完美作战」才给(最终攻势、隐秘核心里是你这一侧没有漏怪且全队获胜);“神秘顾客”仍然随机给一个悬赏 |
| 冻结 | 免费,保留当前所有未买的格子到下一回合;冻结状态下仍可主动刷新 |
| 升级调度中心(1→2 … 5→6) | 险境及以上 5 / 8 / 11 / 12 / 13;同盟标准 5 / 8 / 10 / 11 / 11;独立标准 1 / 1 / 5 / 8 / 10。**每过一回合价格 −1**(最低 0),升级后按下一级的价格重新计算。升级到干员栏位更多的等级(险境及以上 2 级 4 个、4 级 5 个;标准 2 级 4 个、6 级 5 个)时,**新栏位立刻刷出一名新干员**,已有的卡片不变 |
| 其他收入 | 悬赏敌人、盟约「远见」「奇迹」、装备「骑士储蓄罐」、部分策略和特质 |
+1 -1
View File
@@ -177,6 +177,6 @@ Each domain file `server/sim/content/{tokens,bonds,garrisons,items,bands,enemies
- items: 56 equipment (+golden) + 3 Arts; 变形同构体 bond grant; VI-tier set/combos.
- bands: 40 strategies (meta + battle). 鸭爵 “神秘顾客” swaps 0–2 ground enemies (not the leader, its parts or bounties; on a pair boss field only the player's half — the boss / hidden `onBattleStart` event carries `routes` and `side`) in every teammate's normal / Final Assault / Hidden Core battle from R5 into the act2 鸭爵 / 高普尼克 / 流泪小子 / 圆仔 (lpr 1, 1 fund to the killer, 联防 included) (§21.5); the swaps carry the spawn tag `'duck'`, and the swapped 流泪小子 hides again 1 s after a block ends, not the official 0 s (the owner's decision of 2026-10-07, §25.22.6).
- enemies: special-type behaviours (FLY, TIMES, ELEMENT, DOT, INVISIBLE, REFLECTION, SPECIAL) and notable individual abilities from `data/enemies.json`; bosses 1–10 scripted — the huge leaders (假想敌:胄 ×2, 假想敌:管 ×2, 盐风主教昆图斯, 阿利斯泰尔, “萨米的意志”) are 自缚 + 无法被阻挡 (PRTS 天赋; `content/bosses.js SELF_BOUND`: a persistent noMove + unblockable buff from spawn) with their hit area (§19.4); 圆仔 and 鸭爵 are 无法被阻挡 (PRTS 天赋, §21.5); the knock-out forms follow PRTS — 深池逐火 余烬 (5 / 10 hits, 隐匿, blockable, back after 10 s), 转译基底·α (damage cancelled until its 2 s change into 寻仇者 / 特战术师 / 幽灵), the leaders' 重生, 守墓石像's statue and flight — and every 重生 clears what operators put on the enemy and restarts its skills from their initial cooldown (§21.4, §21.19); the mode's air units are 静态刚体, never moved (§21.9); leader parts (斩胄之剑 / 破胄之锤 / 碎铳之簧) pass every damage they take to their leader 1:1 as 无来源 HP loss (`PART_TRANSFER` 1, PRTS "等量"), a 假想敌:胄 drone that dies costs it 2 % of the pool (`DRONE_LINK_BASE 'pool'`) (§20.10, §20.13); devices/terrain.
- choices: 机变 families (悬赏决策 bounties, 道具补给, 机密商店, 战术决策 team/personal buffs). 悬赏决策 = the official structure of its round (choices.json `bountyDrafts`, 86 drafted cards: R3 a fixed set of six 两场 cards, R9 6 of a boss group's list, R11 6 of a seen 7-card list; §21.2); 机密商店 (R11 the official composition) and 战术决策 can offer the same card twice (`shopDraft` / `tacticDraft`); bounty cards carry `descRaw`; no draft offers a multi-round card, and 教鞭's lasts two battles (`choices.js MULTI_ROUND_BOUNTY_BATTLES`, §20.6). 教鞭 = a random 战术特训 card (3 drawn, 1 taken).
- choices: 机变 families (悬赏决策 bounties, 道具补给, 机密商店, 战术决策 team/personal buffs). 悬赏决策 = the official structure of its round (choices.json `bountyDrafts`, 86 drafted cards: R3 a fixed set of six 两场 cards, R9 6 of a boss group's list, R11 6 of a seen 7-card list; §21.2); 机密商店 (R11 the official composition) and 战术决策 can offer the same card twice (`shopDraft` / `tacticDraft`); bounty cards carry `descRaw`; no draft offers a multi-round card, and 教鞭's lasts two battles (`choices.js MULTI_ROUND_BOUNTY_BATTLES`, §20.6). Pointing Stick (教鞭) offers up to three private Tactical Training cards during PREP; the owner confirms one through `g.choice` with its `choiceId`, with Ready blocked until confirmation or the existing prep deadline's auto-pick. “神秘顾客” remains random (META §2.5).
---
+12 -1
View File
@@ -14,7 +14,8 @@ LOBBY(room) → INFO_CHECK (co-op 25 s; solo and any single-human match untimed
[SP_DRAFT] (if r ∈ spRounds: 机变 draft; co-op 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
PREP_END (<休整期结束时> effects; unfrozen shop cleared; Ready is blocked while a personal choice or temp overflow is pending;
at the deadline personal choices are auto-picked first, then
the temp pieces due at this prep are resolved (chess sold back, items destroyed, summon stacks removed — they come back at the next round start, §20.1) — pieces that arrived
after Ready or during the <休整期结束时> effects wait for the next prep (§6.2 tempDue))
COMBAT (one Battle per alive player, all in parallel, 2×; watchers get snapshots; solo: pausable, §14 Solo pause)
@@ -44,6 +45,7 @@ Solo FUNNY has 9 rounds (boss at R9); everything is read from `data/config.json
board: Map<'r,c', Piece>, // chess & token pieces on the normal field
bonds: { [bondId]: { count, active, tier, layers } }, // layers persist all match
effects: [ EffectRef ], // band/choice/team/garrison persistent effects & counters
personalChoice: null | { id, round, sourceItemId, cards: [ BountyCard ] }, // private PREP offer; raw cards on the server
counters: { refreshes, buys, sells, … },
stats: { … for results } }
Slot = { kind:'chess'|'item', id, price, sold:false, frozen }
@@ -62,6 +64,12 @@ Economy/shop/pool/merge/hand rules: exactly as research 00-INDEX §3–§4 (inco
**Temp overflow (临时整备区, row 8; user playtest #3 item 3).** Passive gains that find the hand full (merge elites, grants — the operator a 突变细胞 transformation gains included, §21.1 —, battle-result rewards, equipment returned by a merged / sold / transformed carrier) overflow into the 5 temp slots, and Ready is refused while any is occupied (official "直到溢出情况排除才可开始进行作战"). A temp piece is resolved — a chess sold back to the pool with its summons removed, items destroyed, a summon stack removed (it comes back at the next round start, PRTS "干员所属召唤物会于下一回合返还", §20.1) — only at the deadline of the **first prep in which its player could act on it** (`PlayerState.tempDue(piece)` vs `prepsEnded`; every write into a temp slot goes through `_putTemp`, which records it): a piece that arrived during a prep before Ready is due at that prep's end; one that arrived after Ready or during the `<休整期结束时>` effects, or outside PREP (COMBAT, 联防, SETTLE — battle-result grants, a SETTLE merge's elite, returned equipment —, the next ROUND_START, 机变) is kept, visible and usable (move to a free hand slot, place, equip, use, destroy, sell) through the **next** prep. Un-readying (`setReady(false)`) makes whatever arrived while ready due at the current prep. ROUND_START no longer wipes temp (it used to destroy what SETTLE had put there before the player ever saw it). A merge's elite whose merge consumed a deployed copy takes that copy's tile and facing (§20.11); otherwise it goes to the hand, overflowing into temp (also outside PREP). `server/match/audit.js` (`tools/matchrun.mjs --check`) tracks the arrivals itself and flags any temp piece that outlives its due prep. The UI frames the row on the board with the rule (§17.5).
Pointing Stick creates at most one pending offer per alive, non-Ready player in PREP. The owner confirms it with
`g.choice { idx, choiceId }`; confirmation validates its ID, round and index, adds one bounty and clears the offer.
Ready requires no pending offer as well as an empty temp area. The co-op prep deadline resolves the offer randomly
before resolving temp and setting Ready; untimed solo / single-human prep does not schedule a separate choice timer.
The offer is recipient-private, including after reconnect, and never enters `m.public.sp` or a teammate's scout view.
### 6.3 Bonds & layers (bondsMeta.js)
- `count` = number of **distinct base chess** on the board carrying the bond (`BOARD`); `BOARD_AND_DECK` bonds also count the hand; 绝技 counts elites on board; 调和 adds +1 to each core bond count that has a real member on the board (the views mark such an entry `harmony: 1`, §21.26), etc. (research 02).
@@ -81,6 +89,9 @@ Per match: pick stage (weighted among the mode's `config.modes[m].stages`, weigh
### 6.6 AI players (bot.js)
Pending Pointing Stick offers use the existing bounty scorer at bot prep entry, immediately after a successful cast,
and before Ready. The timer / disconnected-seat finalizer randomly resolves any remaining offer before temp and Ready.
Deterministic-ish heuristic player used for (a) AI teammates, (b) auto-play for disconnected humans (only auto-ready + auto-picks, not shopping, unless "AI 托管" is toggled), (c) tests. Strategy (§21.6): pick a band (weighted by starting LP; never one built around a bond the mode switches off — `gd.bandBondIds`, §21.26); each prep: take offers, sell dead bench weight, fill the board, complete affordable merges, level on the curve, then buy only merge progress / lineup upgrades worth more than the refreshes their price would buy (shop odds of completing held pairs), freeze for an unaffordable third copy (坎诺特 keeps 5 funds back); build around a committed focus core bond (a teammate's main bond on its strip counts against it; bonds the mode switches off never) + a second bond; value dealers by DPS vs the round's DEF / RES (an attack on every enemy in range counts double — 阵法术师 / 轰击术师), with the range the unit is deployed with; place by the exposure model + rehearsal on the server's deploy map (no 深水区; a tactician's 援军 within `PlayerState.summonRange`; a melee chess whose trait reads 「可以放置于远程位」 (钩索师 / 推击手) on a 高台 whose range covers the enemy road, else on the ground, counted as a blocker in the lineup either way — §3; `g.move` judges legality); items by effect (META §1.5: 突变细胞 on the least valuable normal single below 6阶, a cell left in temp gets a hand slot made for it, the operator its transformation gains is deployed by the next prep's lineup step like any owned unit — AI 托管 too; the buy loop's bench shed never sells a piece gained since the bot's last prep, §21.1); 机变 bounties by expected payout − expected LP loss over the offered cards; resolve temp — on a human seat under AI 托管 never by destroying an item while a bench operator can be sold for the slot —; ready. A bot's whole prep — shop decisions and layout planning (`botPrepBeginSteps`, 25–70 ms late in a 4-bot match), the layout rehearsal and `botPrepEndSteps` — runs in wall-clock slices of `botSliceMs` (8 ms) per scheduler callback on a real host (step generators that yield between whole actions, never with a transient board; the same actions in the same order, so decisions are unchanged; at once in virtual time). Under AI 托管 a human's own 自选 pieces score `DIY_PIECE_BONUS` (12) more in the shop / reward choice and the 调度中心 is levelled one round earlier for the step that opens a slotted tier (`levelUp`, `diyOpensAt`; 0.2.0 — bots field no 自选 piece).
---
+3 -2
View File
@@ -15,7 +15,7 @@ Platform additions (implemented): `welcome.{resumed, version}`, `welcome.diyKitt
**Spectator seats** (a remake feature — the official room has none; community report #26, owner's decision 2026-10-04; the spectator-seat subsection of §23): `room.spectate {code}` takes one of a co-op room's `MAX_SPECTATORS` (2) spectator seats, in its lobby or during its match; `room.removeSpectator {playerId}` (host) frees one (`room.closed {reason:'kicked'}` to it); `room.state.spectators: [{playerId, name, connected}]`. A spectator is never a player (not in `seats`, not counted for the start, never host, keeps no room alive — `room.closed {empty}` when the last human leaves), receives the room's broadcasts and watches like an eliminated player (`Match.addSpectator`: `b.start` watch / `m.field`, never `m.private`); it may only `g.watch`, leave, and store a `room.loadout` — anything else answers `error SPECTATOR`. Its seat is kept and given back on reconnect like a player's.
### 8.2 Match
C→S (all carry `rid`): `g.infoReady` · `g.band {bandId}` · `g.bandSkip` · `g.bandFocus {bandId?}` (the strategy highlighted in the draft screen — what a timed-out turn takes; absent / null clears it; `WRONG_PHASE` outside BAND_DRAFT, `ALREADY` after the pick, `BAD_TARGET` for a band not allowed; §18.2) · `g.buy {slot}` · `g.refresh` · `g.freeze` · `g.levelUp` · `g.sell {uid}` · `g.move {uid, to:{area:'board', row, col}|{area:'hand', idx}, dir?}` · `g.equip {itemUid, targetUid, replaceUid?}` (`replaceUid`: which equipped item a third one replaces, §6.2) · `g.art {itemUid, row, col, dir?}` (`dir` ∈ UP|RIGHT|DOWN|LEFT from the deploy wheel, §3; absent ⇒ `to.dir`, then RIGHT; `g.move` onto the piece's own tile re-orients it; board pieces in `m.private` / `m.field {prep:true}` carry `dir`) · `g.destroy {uid}` (hand / temp items only; equipped items are locked, §6.2) · `g.reward {idx}` · `g.choice {idx}` · `g.ready {ready}` · `g.emote {id}` · `g.watch {fieldId, playerId?}` (`playerId`: the player tapped — a shared 联防 / boss pair field shows two; what an eliminated viewer follows, §25) · `g.autoplay {on}` · `g.pause {on}` (solo battles only, §14 Solo pause) · `g.unitStats {seq?}` (ROUND_START / SP_DRAFT / PREP, else `WRONG_PHASE`; eliminated: `ELIMINATED`) → push `m.unitStats` (§18.5) · `g.leave`
C→S (all carry `rid`): `g.infoReady` · `g.band {bandId}` · `g.bandSkip` · `g.bandFocus {bandId?}` (the strategy highlighted in the draft screen — what a timed-out turn takes; absent / null clears it; `WRONG_PHASE` outside BAND_DRAFT, `ALREADY` after the pick, `BAD_TARGET` for a band not allowed; §18.2) · `g.buy {slot}` · `g.refresh` · `g.freeze` · `g.levelUp` · `g.sell {uid}` · `g.move {uid, to:{area:'board', row, col}|{area:'hand', idx}, dir?}` · `g.equip {itemUid, targetUid, replaceUid?}` (`replaceUid`: which equipped item a third one replaces, §6.2) · `g.art {itemUid, row, col, dir?}` (`dir` ∈ UP|RIGHT|DOWN|LEFT from the deploy wheel, §3; absent ⇒ `to.dir`, then RIGHT; `g.move` onto the piece's own tile re-orients it; board pieces in `m.private` / `m.field {prep:true}` carry `dir`) · `g.destroy {uid}` (hand / temp items only; equipped items are locked, §6.2) · `g.reward {idx}` · `g.choice {idx, choiceId?}` (with an ID: the recipient's PREP personal choice; without one: the public SP_DRAFT; stale or wrong IDs never fall through to the public draft) · `g.ready {ready}` · `g.emote {id}` · `g.watch {fieldId, playerId?}` (`playerId`: the player tapped — a shared 联防 / boss pair field shows two; what an eliminated viewer follows, §25) · `g.autoplay {on}` · `g.pause {on}` (solo battles only, §14 Solo pause) · `g.unitStats {seq?}` (ROUND_START / SP_DRAFT / PREP, else `WRONG_PHASE`; eliminated: `ELIMINATED`) → push `m.unitStats` (§18.5) · `g.leave`
S→C:
- `m.public` — full public state on every change (throttled ≤ 10/s): `{ phase, round, lastRound, deadline, serverNow, modeId, difficulty, stageId, factions, disabledBonds, bannedChess, bossId, hiddenBossId?, teamLp?, bossHp?:{hp,max}, overtimeAt? /* FA / HC: ms epoch the overtime drain starts, §4 */, paused /* solo pause, §14 */, draft?:{order,turn,picks,skipsLeft,turnDeadline,turnSeconds /* one turn's length, 0 untimed; deadline = turnDeadline (§18.2) */,untimed}, sp?:{family,cards /* bounty cards carry descRaw?, §20.6 */,turn,picks}, players:[{playerId, seat, name, isBot, connected, alive, lp, bandId, shopLevel, boardCount, ready, bonds:[{bondId,count,active,tier,layers,harmony? /* 调和's +1 is in count, §21.26 */}], fieldId, status, pendingLp? /* COMBAT / 联防 of a normal round: the LP the player's own battle will cost at settlement so far, min(lpCapPerRound, counted leaks) — in 联防 min(lpCapPerRound, uniteLeft) — omitted when 0 and in every other phase (§17.5 live LP) */, uniteLeft? /* 联防: the leaker's enemies still standing — not spawned yet + alive + counted leaks, uncapped, live (§20.6) */}] , fields:[{fieldId, kind, players:[playerId...]}] }` · `m.public.uniteResult` — SETTLE after a 联防 only, its outcome as data: `{ through /* the leakers' enemies that still got through, uncapped */, helpers:[playerId...], leakers:[playerId...], losses:{playerId: the LP settlement charged this round — every alive player} }`; absent when no 联防 resolved (the client's result box then reads the round's own battle; GitHub #235, PR #112)
- `m.private` — the recipient's full PlayerState view (§6.2 minus server-only data) on every change; in a boss round's prep also `bossMate { playerId, side, units }` — the pair partner's board on its half of the boss field (UnitInfo, mirrored on the right half), re-sent when the partner moves (0.2.0, community report item 51).
@@ -34,7 +34,8 @@ S→C:
`m.private`:
```js
{ playerId, seat, alive, lp, funds, bandId, ready, canReady /* false while temp non-empty */,
{ playerId, seat, alive, lp, funds, bandId, ready, canReady /* false while a personal choice or temp overflow is pending */,
personalChoice: null | { id, round, sourceItemId, cards: [ { kind: 'bounty', id, name, desc, descRaw, ... } ] }, // recipient only, including reconnect; absent from public / scout views
shop: { level, maxLevel: 6, upgradePrice, refreshPrice /* effective (0 when a free refresh is available) */, freeRefreshes,
frozen, slots: [ { kind: 'chess'|'item', id, price, basePrice, sold } | null ],
rewardOffer: null | { tier, source: 'merge'|'special', label, queued, slots: [ { kind: 'chess'|'item', id, price: 0, sold } ] } }, // §21.2
+2
View File
@@ -103,6 +103,8 @@ Eight workstreams were merged into `playtest6` (WH → WG → WD → WC → WB
- **#19**: PRTS 帮助 "临时刷新3名高于调度中心等级1阶(最多6阶)的干员"; the official 玩法介绍 image shows three different operators at price 0. v2.4.1 rolled three times independently (14–21 % of offers had a duplicate). **Now** three different chess, copy-weighted; a tier short of different chess tops up from the tier below [ASSUMED]; the offer reserves no copies. The integration QA found the same repeat in the other free pick-one chess offers — 信标 (same tier), 寻呼模块 (same bond ≤ shop level, content/items/meta.js) and the engine default `builtinMeta use_equip_reward_special_goods_char_chess` — they now draw different operators too (信标 tops up from the tier below, 寻呼模块 shows fewer cards when the pool has no other one), and `pushRewardOffer` drops a repeated id from any list it is given (`test/content/items.test.js`).
- Tests: `test/match/playtest6-matchflow.test.js` (the draft, two battles for a multi-round card end to end, 联防 counts, rewards), `test/match/followups.test.js`, `test/ui/playtest6-unite.test.js`, `test/ui/playtest6-bounty.test.js`, `test/content/items.test.js` (教鞭).
Superseded in 0.2.2: 教鞭 no longer takes one of its three 战术特训 cards at random — its owner chooses (docs/META.md §2.5).
### 20.7 机变 card text; the shop's confirming tap on phones (#6, #10) — `ui/choiceOverlay.js`, `css/screens/game-panels.css`, `ui/shopBar.js`, `css/screens/game-shop.css`, `css/devices.css`
- **#6 seen** (screenshot 2, Android 756×366 CSS px, 终极 co-op): the 道具补给 cards showed only icon, name and tier. **Cause**: the card was a centred column sized for 16:9; with the root font clamped at 40 px a 3×2 card was 105 px tall and showed 0.2 lines of text (217 of 223 texts cut), in all four families; at 1920×1080 nine long texts were cut at 4 lines. **Official** (research 06 §11.5; screenshots Mb2mtd9 / Wrkhtxv / SEbySVl): a framed icon at the top-left with the bold name beside it, the description below, numbers in blue, the tier chip on the icon's corner, the taker's round avatar alone at the top-right. **Now**: that layout; the text from `cardText` (the card's own `descRaw`, else the data `descRaw` while its plain form equals the server's `desc`, else the server text), clamped to 7 lines (8 at line height 1.3 on phones under 700 px wide: with Noto Sans SC — the page's web font, metrics of Android's CJK font — 海沟实验体 wraps to 8 lines at 640×360, residual QA); short screens get a tighter overlay header (no CONTINGENCY micro line) and .19rem text; the taker badge is the avatar alone (name in its tooltip and the card's aria-label) and the taken card's head reserves its width. All 223 texts (51 items, 129 bounty, 43 tactic) fit in full, taken or not, from 640×360 to 2560×1080, measured with Noto Sans SC.
+22 -3
View File
@@ -101,7 +101,7 @@ function refreshPrivate() {
const p = S.priv;
p.deployCount = p.board.filter((x) => x.kind === 'chess').length;
p.bonds = computeBonds(p);
p.canReady = !p.temp.some(Boolean);
p.canReady = !p.personalChoice && !p.temp.some(Boolean);
const upg = [5, 8, 11, 12, 13];
p.shop.maxLevel = 6;
if (p.shop.upgradePrice == null) p.shop.upgradePrice = upg[p.shop.level - 1] ?? 0;
@@ -178,7 +178,7 @@ function buildState() {
].sort((a, b) => a.t - b.t);
const priv = {
playerId: ME, seat: 0, alive: !VARIANTS.has('dead'), lp: 24, funds: 13, bandId: 'band_bldsk', ready: false, canReady: true,
playerId: ME, seat: 0, alive: !VARIANTS.has('dead'), lp: 24, funds: 13, bandId: 'band_bldsk', ready: false, canReady: true, personalChoice: null,
shop: { level, maxLevel: 6, upgradePrice: 9, refreshPrice: 1, freeRefreshes: 0, frozen: VARIANTS.has('frozen'), slots, rewardOffer: null },
hand, temp, board, deployCap: 8, deployCount: 0, bonds: [],
effects: [
@@ -661,7 +661,16 @@ async function mockRequest(t, f = {}) {
}
pushPublic(); return {};
}
case 'g.art': { prepOnly(); const it = findPiece(f.itemUid); if (!it) fail('BAD_TARGET'); removeAt(it); toast('奇术已生效', 'success'); refreshPrivate(); return {}; }
case 'g.art': {
prepOnly(); const it = findPiece(f.itemUid); if (!it) fail('BAD_TARGET');
if (it.piece.id === 'chess_item_6_03_m') {
if (p.personalChoice) fail('BAD_TARGET');
const cards = data.get('choices').cards.bounty.filter((c) => c.payout === 'perfect' && c.rounds < 90).slice(0, 3)
.map((c) => ({ ...c, kind: 'bounty', id: c.effectId, descRaw: data.lookup('effects', c.effectId)?.descRaw }));
p.personalChoice = { id: `mock.choice.${nextUid()}`, round: pub.round, sourceItemId: it.piece.id, cards };
}
removeAt(it); toast('奇术已生效', 'success'); refreshPrivate(); return {};
}
case 'g.reward': {
if (!p.shop.rewardOffer) fail('WRONG_PHASE');
const s = p.shop.rewardOffer.slots[f.idx]; if (!s) fail('BAD_TARGET');
@@ -669,6 +678,15 @@ async function mockRequest(t, f = {}) {
p.hand[idx] = chessPiece(data.lookup('chess', s.id)); p.shop.rewardOffer = null; refreshPrivate(); return {};
}
case 'g.choice': {
if (f.choiceId !== undefined) {
prepOnly();
const choice = p.personalChoice;
if (!choice || choice.id !== f.choiceId || choice.round !== pub.round || !choice.cards[f.idx]) fail('BAD_TARGET');
const card = choice.cards[f.idx];
p.effects.push({ id: choice.id, name: card.name, desc: card.descRaw || card.desc, iconKind: 'choice', iconId: card.id });
p.personalChoice = null;
refreshPrivate(); return {};
}
if (!pub.sp) fail('WRONG_PHASE');
const taken = Object.values(pub.sp.picks).includes(f.idx);
if (taken) fail('BAD_TARGET');
@@ -676,6 +694,7 @@ async function mockRequest(t, f = {}) {
}
case 'g.ready': {
if (pub.phase !== PHASE.PREP) fail('WRONG_PHASE');
if (f.ready && p.personalChoice) fail('BAD_TARGET');
if (f.ready && !p.canReady) fail('TEMP_NOT_EMPTY');
p.ready = !!f.ready; refreshPrivate(); return {};
}
+6
View File
@@ -382,6 +382,12 @@
"CONTINGENCY // 机变阶段": "CONTINGENCY // Improv Phase",
"选择一项": "Choose one",
"倒计时结束后仍未选定将自动分配": "If no choice is made before the countdown ends, one will be assigned automatically",
"教鞭选择": "Pointing Stick Choice",
"教鞭 · 战术特训": "Pointing Stick · Tactical Training",
"请选择一项战术特训": "Choose a Tactical Training bounty",
"请先完成教鞭选择": "Complete the Pointing Stick choice first",
"休整期结束时未选择将自动选定": "An unselected bounty will be picked automatically when the Rest Phase ends",
"从候选中选择一项战术特训": "Choose one Tactical Training bounty from the offered cards",
"选择一项(无时间限制)": "Choose one (no time limit)",
" · 点击卡牌选中,再次点击确认": " · Tap a card to select, tap again to confirm",
"已完成选择": "Choice made",
+6
View File
@@ -385,6 +385,12 @@
"CONTINGENCY // 机变阶段": "CONTINGENCY // 調整段階",
"选择一项": "1つ選択",
"倒计时结束后仍未选定将自动分配": "カウントダウン終了までに選ばない場合、自動で割り当てられます",
"教鞭选择": "教鞭の選択",
"教鞭 · 战术特训": "教鞭 · 戦術訓練",
"请选择一项战术特训": "戦術訓練を1つ選択してください",
"请先完成教鞭选择": "先に教鞭の選択を完了してください",
"休整期结束时未选择将自动选定": "未選択の場合、整備フェーズ終了時に自動で選択されます",
"从候选中选择一项战术特训": "候補から戦術訓練を1つ選択",
"选择一项(无时间限制)": "1つ選択(時間制限なし)",
" · 点击卡牌选中,再次点击确认": " · カードをタップで選択、もう一度タップで確定",
"已完成选择": "選択完了",
+6
View File
@@ -381,6 +381,12 @@
"CONTINGENCY // 机变阶段": "CONTINGENCY // 기회 단계",
"选择一项": "하나를 선택하세요",
"倒计时结束后仍未选定将自动分配": "카운트다운이 끝날 때까지 선택하지 않으면 자동으로 지정됩니다",
"教鞭选择": "교편 선택",
"教鞭 · 战术特训": "교편 · 전술 특훈",
"请选择一项战术特训": "전술 특훈 1개를 선택해 주세요",
"请先完成教鞭选择": "먼저 교편 선택을 완료해 주세요",
"休整期结束时未选择将自动选定": "선택하지 않으면 휴식 기간 종료 시 자동으로 선택됩니다",
"从候选中选择一项战术特训": "후보 중 전술 특훈 1개 선택",
"选择一项(无时间限制)": "하나를 선택하세요 (시간 제한 없음)",
" · 点击卡牌选中,再次点击确认": " · 카드를 탭해 선택하고 한 번 더 탭해 확인",
"已完成选择": "선택 완료",
+6
View File
@@ -383,6 +383,12 @@
"CONTINGENCY // 机变阶段": "CONTINGENCY // 機變階段",
"选择一项": "選擇一項",
"倒计时结束后仍未选定将自动分配": "倒計時結束後仍未選定將自動分配",
"教鞭选择": "教鞭選擇",
"教鞭 · 战术特训": "教鞭 · 戰術特訓",
"请选择一项战术特训": "請選擇一項戰術特訓",
"请先完成教鞭选择": "請先完成教鞭選擇",
"休整期结束时未选择将自动选定": "休整期結束時未選擇將自動選定",
"从候选中选择一项战术特训": "從候選中選擇一項戰術特訓",
"选择一项(无时间限制)": "選擇一項(無時間限制)",
" · 点击卡牌选中,再次点击确认": " · 點擊卡牌選中,再次點擊確認",
"已完成选择": "已完成選擇",
+35 -5
View File
@@ -95,7 +95,7 @@ import { pieceTile } from '../render/drag.js';
import {
phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent,
battleOverSfx, uniteResultBox, battleResultBox,
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers,
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, normalizePersonalChoice, sortedPlayers,
terrainInfo,
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord,
@@ -184,6 +184,7 @@ function MatchScreen() {
const [resultBox, setResultBox] = useState(null); // the round's result box (ResultDialog), shown at SETTLE
const [readyBusy, setReadyBusy] = useState(false);
const [spBusy, setSpBusy] = useState(null);
const [personalBusy, setPersonalBusy] = useState(null); // { choiceId, idx }: an older request cannot clear a newer pick
const [layer, setLayer] = useState('ALL'); // 联防 / 最终攻势 camera: 'L' | 'ALL' | 'R'
const [pen, setPen] = useState(false); // the camera shows the enemy preview pen (research 09 §2)
const [camKind, setCamKind] = useState('prep'); // kind of the last camera request (data-camera)
@@ -207,7 +208,9 @@ function MatchScreen() {
const scoutPid = watchingOther && field?.prep && typeof field.fieldId === 'string' && field.fieldId.startsWith('n:') ? field.fieldId.slice(2) : null;
const scoutBandId = scoutPid ? ownerBandId(pub, scoutPid) : null;
const scoutBandOwner = scoutPid ? (players.find((p) => p.playerId === scoutPid)?.name || null) : null;
const editable = phase === PHASE.PREP && !!priv && alive && !priv.ready && !watchingOther;
const personalChoice = !spectator && alive ? normalizePersonalChoice(pub, priv, myId) : null;
const hasPersonalChoice = !!personalChoice;
const editable = phase === PHASE.PREP && !!priv && alive && !priv.ready && !watchingOther && !hasPersonalChoice;
const showShop = !!priv && alive && (phase === PHASE.PREP || phase === PHASE.SP_DRAFT || phase === PHASE.ROUND_START) && !watchingOther;
const layersDisabled = phase === PHASE.UNITE || isBossPhase(phase);
const sp = phase === PHASE.SP_DRAFT ? normalizeSp(pub?.sp, players) : null;
@@ -271,7 +274,7 @@ function MatchScreen() {
getChess: (id) => { const c = gd.chess(id); return ownDiyRecord(c, priv, { chess: data.get('chess'), backups: data.get('backups') }) || c; },
getToken: gd.token, getItem: gd.item, getEffect: gd.effect, backups: gd.backups,
}), [priv, pub?.stageId, editable, gd.ready, deployField]);
live.current = { pub, priv, field, editable, placeCtx, watching, watchWho, home, myId, alive, spectator, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, selBusy, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused };
live.current = { pub, priv, field, editable, hasPersonalChoice, placeCtx, watching, watchWho, home, myId, alive, spectator, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, selBusy, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused };
// ---- camera: every request goes through setCam, which remembers it for the pen's way back -----------------------
// the own prep board: the normal board, or — in the prep of a boss round — the player's half of the boss field
@@ -637,6 +640,17 @@ function MatchScreen() {
setSpBusy(null);
}, [phaseKey]);
useEffect(() => {
setPersonalBusy((b) => b?.choiceId === personalChoice?.id ? b : null);
if (!personalChoice) return;
setSel(null);
setDetail(null);
setArmedCard(null);
setDrag(null);
view?.highlightTiles(null, null);
cancelFacingRef.current();
}, [personalChoice?.id, view]);
// a reload / reconnect while watching a teammate's battle after the own one (client-side combat): the server resends
// the watched field, the fresh screen adopts it as watched once per battle — the observing pill, 返回战场 and the own
// row work again, and the bond strip's "👁 name" matches the HUD (battle/observe.js resumedWatch)
@@ -1154,6 +1168,14 @@ function MatchScreen() {
const L = live.current;
// dialogs / the guide own the keyboard; behind the 本局信息 / 敌方情报 drawer only Esc (closing it) acts
if (shortcutBlocked(act, { modal: !!document.querySelector('.modal, .guide'), drawer: !!L.drawer })) return;
if (L.hasPersonalChoice) {
if (act !== 'escape') e.preventDefault();
if (act === 'ready') {
audio.sfx('error', { volume: 0.5 });
toast(shopBlockReason('ready', { priv: L.priv, editable: true }), 'warn');
}
return; // ChoiceOverlay handles Escape without cancelling the server's pending choice.
}
if (act === 'escape') {
if (L.emoteOpen) setEmoteOpen(false);
else if (L.pen && !L.detail) togglePenRef.current(false);
@@ -1256,7 +1278,7 @@ function MatchScreen() {
const temp = tempInfo(priv);
const tempNotice = temp.count > 0 && !!view && viewKind !== 'loading' && showPrep && alive && !pen && !sp;
// the ready button shows why it is refused under it (ui/hud.js ReadyToggle): the effects column moves down a line
const readyWhy = phase === PHASE.PREP && alive && !priv?.ready && temp.count > 0;
const readyWhy = phase === PHASE.PREP && alive && !priv?.ready && (hasPersonalChoice || temp.count > 0);
// the frame the panels are laid out in: the HUD layer (client px; inside the safe-area insets of a notched phone),
// and the root font size (1rem)
const panelFrame = () => {
@@ -1307,7 +1329,7 @@ function MatchScreen() {
// bonds this mode never activates (标准: 10 of 23, 奥术 among them) — shown 本局禁用 on cards, chips and the popup
const offBonds = modeOffBonds(getMode(pub?.modeId));
return html`<div class=${cx('screen', 'gm', `gm--${mode}`, drag && 'is-dragging', collapsed && 'is-collapsed', sp && 'has-sp', pen && 'is-pen', readyWhy && 'has-readywhy')}
return html`<div class=${cx('screen', 'gm', `gm--${mode}`, drag && 'is-dragging', collapsed && 'is-collapsed', (sp || hasPersonalChoice) && 'has-sp', pen && 'is-pen', readyWhy && 'has-readywhy')}
data-camera=${pen ? 'pen' : camKind}>
<div class="gm__field" ref=${hostRef} onContextMenu=${(e) => e.preventDefault()}></div>
${viewKind === 'loading' ? html`<div class="gm__loading"><${Spinner} label="LOADING FIELD" /></div>` : null}
@@ -1402,6 +1424,14 @@ function MatchScreen() {
${sp ? html`<${ChoiceOverlay} pub=${pub} sp=${sp} myId=${myId} solo=${solo} busyIdx=${spBusy} total=${total}
onPick=${async (i) => { setSpBusy(i); await actions.choice(i); setSpBusy(null); }} />` : null}
${personalChoice ? html`<${ChoiceOverlay} key=${personalChoice.id} pub=${pub} sp=${personalChoice} myId=${myId} solo=${solo} personal=${true}
busyIdx=${personalBusy?.choiceId === personalChoice.id ? personalBusy.idx : null} total=${total}
onPick=${async (i) => {
const choiceId = personalChoice.id;
setPersonalBusy({ choiceId, idx: i });
await actions.choice(i, choiceId);
setPersonalBusy((b) => b?.choiceId === choiceId ? null : b);
}} />` : null}
${banner ? html`<${PhaseBanner} key=${banner.key} mode="overlay" title=${banner.title} sub=${banner.sub} micro=${banner.micro}
tone=${banner.tone} duration=${banner.duration || 1500} onDone=${() => setBanner(null)} />` : null}
+10 -9
View File
@@ -127,7 +127,7 @@ export function armedCard(armed, sp, o) {
/**
* The overlay: keeps the two-tap selection and renders ChoiceView. `onPick(idx)` sends the confirmed card (g.choice).
* @param {{ pub:any, sp:any, myId:string, solo:boolean, onPick:(idx:number)=>void, busyIdx?:number|null, total?:number|null }} props
* @param {{ pub:any, sp:any, myId:string, solo:boolean, personal?:boolean, onPick:(idx:number)=>void, busyIdx?:number|null, total?:number|null }} props
*/
export function ChoiceOverlay(props) {
const { sp, myId, solo, busyIdx = null, onPick } = props;
@@ -154,10 +154,10 @@ export function ChoiceOverlay(props) {
/**
* The overlay's view (pure: no hooks — test/ui renders it as a function).
* @param {{ pub:any, sp:any, myId:string, solo:boolean, busyIdx?:number|null, total?:number|null, armed?:number|null,
* @param {{ pub:any, sp:any, myId:string, solo:boolean, personal?:boolean, busyIdx?:number|null, total?:number|null, armed?:number|null,
* onTap?:(idx:number)=>void, onConfirm?:()=>void, onDisarm?:()=>void }} props
*/
export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null, armed = null, onTap = () => {}, onConfirm = () => {}, onDisarm = () => {} }) {
export function ChoiceView({ pub, sp, myId, solo, personal = false, busyIdx = null, total = null, armed = null, onTap = () => {}, onConfirm = () => {}, onDisarm = () => {} }) {
if (!sp) return null;
const fam = data.get('choices')?.families?.[sp.family] || null;
const rawFam = data.getRaw('choices')?.families?.[sp.family] || null;
@@ -166,8 +166,8 @@ export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null,
const mine = sp.pickOf.get(myId);
const turnName = players.get(sp.turnPid)?.name || t('队友');
const special = /_s$/.test(String(sp.family || ''));
const order = solo ? [] : sp.order;
const timed = !solo && !sp.untimed;
const order = personal || solo ? [] : sp.order;
const timed = personal ? pub?.deadline > 0 : !solo && !sp.untimed;
const armedCardRec = armed != null ? sp.cards.find((c) => c && c.idx === armed) : null;
const armedName = armedCardRec ? resolveSpCard(armedCardRec, sp.family).name : null;
// a press anywhere but a card or the confirm button drops the selection
@@ -177,17 +177,18 @@ export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null,
if (t && typeof t.closest === 'function' && t.closest('.spcard, .spov__confirm')) return;
onDisarm();
};
return html`<div class=${cx('spov', armed != null && 'has-armed')} role="dialog" aria-label=${t('机变阶段')} onPointerDown=${onDown}>
return html`<div class=${cx('spov', armed != null && 'has-armed')} role="dialog" aria-label=${personal ? t('教鞭选择') : t('机变阶段')} onPointerDown=${onDown}>
<div class="spov__veil" aria-hidden="true"></div>
<div class="spov__inner">
<header class="spov__head">
<div class="spov__titles">
<${MicroLabel} tone="mint">${t('CONTINGENCY // 机变阶段')}</${MicroLabel}>
<${MicroLabel} tone="mint">${personal ? t('教鞭选择') : t('CONTINGENCY // 机变阶段')}</${MicroLabel}>
<h2 class=${cx('spov__title', special && 'is-special')}>${sentText(sp.name, rawFam?.name, fam?.name) || fam?.name || t('机变')}<span class="spov__bar">|</span><span class="spov__desc"><${RichText} text=${sentText(sp.desc, rawFam?.desc, fam?.desc) || fam?.desc || t('选择一项')} /></span></h2>
<p class="spov__sub">${timed ? t('倒计时结束后仍未选定将自动分配') : t('选择一项(无时间限制)')}${mine == null && myTurn ? t(' · 点击卡牌选中,再次点击确认') : ''}</p>
<p class="spov__sub">${timed ? (personal ? t('休整期结束时未选择将自动选定') : t('倒计时结束后仍未选定将自动分配')) : t('选择一项(无时间限制)')}${mine == null && myTurn ? t(' · 点击卡牌选中,再次点击确认') : ''}</p>
</div>
<div class="spov__turn">
${mine != null ? html`<span class="spov__turntxt is-done"><${Icon} name="check" />${t('已完成选择')}</span>`
${personal ? html`<span class="spov__turntxt is-mine">${t('从候选中选择一项战术特训')}</span>`
: mine != null ? html`<span class="spov__turntxt is-done"><${Icon} name="check" />${t('已完成选择')}</span>`
: myTurn ? html`<span class="spov__turntxt is-mine">${t('当前轮到你决策')}</span>`
: html`<span class="spov__turntxt">${t('{turnName} 正在决策…', { turnName })}<${Icon} name="hourglass" /></span>`}
${armed != null ? html`<${Button} variant="primary" size="lg" icon="check" class="spov__confirm" data-testid="sp-confirm"
+1 -1
View File
@@ -64,7 +64,7 @@ export const actions = {
art: (itemUid, row, col, dir) => act('g.art', dir ? { itemUid, row, col, dir } : { itemUid, row, col }),
destroy: (uid) => act('g.destroy', { uid }),
reward: (idx) => act('g.reward', { idx }),
choice: (idx) => act('g.choice', { idx }),
choice: (idx, choiceId) => act('g.choice', choiceId === undefined ? { idx } : { idx, choiceId }),
ready: (ready) => act('g.ready', { ready }, { sfx: ready ? 'ready' : 'back' }),
emote: (id) => act('g.emote', { id }, { quiet: true }),
// `playerId`: the player tapped in the team panel (a shared field shows two) — what an eliminated viewer follows
+1 -1
View File
@@ -36,7 +36,7 @@ export { sortBonds, bondTier, nextThreshold, grantedBonds, pieceBondIds, morphPa
export { priceTone, mergeProgress, mergeTarget, handFull, completesMerge, offerHeader, shopBlockReason, readyFundsPrompt } from './gameLogic/shop.js';
export { deploySets, stageOverrides, deployMap, effectiveStage, indexPieces, placementContext, piecePosition, tileAllows, summonRange, summonExcluded, canPlace, equipReplaces, equipMerges, boardTargets, dropIntent, dropFailureReason } from './gameLogic/placement.js';
export { terrainInfo } from './gameLogic/terrain.js';
export { normalizeDraft, normalizeSp } from './gameLogic/draft.js';
export { normalizeDraft, normalizeSp, normalizePersonalChoice } from './gameLogic/draft.js';
export { groupEnemies, PEN, penZoneTiles, penPlacement, previewEnemyKey, factionTypes } from './gameLogic/enemies.js';
export { snapHud, bossFrac, bossPctText, hasFlag, UF, attackInterval, fmtNum, rangeGridBox } from './gameLogic/format.js';
export { shortcutFor, closesOnFieldPress, shortcutBlocked, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, isBindableCode, hotkeyLabel, sanitizeHotkeys, rebindHotkey, isDefaultHotkeys, hotkeyOf, actionForKey, captureHotkey, facingSwallows } from './gameLogic/shortcuts.js';
+14
View File
@@ -1,6 +1,8 @@
// ui/gameLogic/draft.js — 机变 and band-draft normalisation. Re-exported from ../gameLogic.js.
import { isObj } from './shared.js';
import { PHASE } from '../../../../shared/constants.js';
import { t } from '../../../../shared/i18n.js';
// ---- 机变 / band draft normalisation ----------------------------------------------------------------------
@@ -73,3 +75,15 @@ export function normalizeSp(sp, players = []) {
cards, order, turnPid, pickOf, takenBy, pickedCount: pickOf.size,
};
}
/** Adapt only the recipient's current PREP choice; never write it into the public draft. */
export function normalizePersonalChoice(pub, priv, myId) {
const choice = priv?.personalChoice;
if (pub?.phase !== PHASE.PREP || !priv || priv.playerId !== myId || priv.alive === false
|| !choice || choice.round !== pub.round) return null;
return {
...normalizeSp({ family: 'bounty', name: t('教鞭 · 战术特训'), desc: t('请选择一项战术特训'),
cards: choice.cards, turn: myId, order: [myId], picks: {} }),
id: choice.id,
};
}
+4 -1
View File
@@ -139,7 +139,10 @@ export function offerHeader(offer) {
export function shopBlockReason(kind, { priv, editable, slot } = {}) {
if (!priv) return t('尚未就绪');
if (priv.alive === false) return t('你已被淘汰');
if (kind === 'ready') return priv.canReady === false ? t('临时整备区不为空,请先处理溢出的资源') : null;
if (kind === 'ready') {
if (priv.personalChoice) return t('请先完成教鞭选择');
return priv.canReady === false ? t('临时整备区不为空,请先处理溢出的资源') : null;
}
if (!editable) {
if (kind === 'reward') return t('当前无法选择');
return priv.ready ? t('已准备就绪,取消准备后才能操作') : t('当前阶段无法进行该操作');
+9 -6
View File
@@ -207,24 +207,27 @@ export function tempReadyReason(priv) {
}
/**
* Ready toggle (PREP only). Disabled while the temp row holds pieces — the reason shows under it (not only on hover):
* "临时整备区 N 个单位待处理" (user playtest #3 item 3).
* Ready toggle (PREP only). Disabled while the temp row holds pieces or a personal choice (教鞭) is open — the reason shows
* under it (not only on hover): "临时整备区 N 个单位待处理" (user playtest #3 item 3), "请先完成教鞭选择".
* @param {{ priv:any, onToggle:(ready:boolean)=>void, busy?:boolean, readyCount?:number, total?:number }} props
*/
export function ReadyToggle({ priv, onToggle, busy, readyCount, total }) {
const ready = !!priv?.ready;
const temp = tempInfo(priv);
const reason = !ready ? tempReadyReason(priv) || shopBlockReason('ready', { priv, editable: true }) : null;
const choice = !!priv?.personalChoice;
const reason = !ready ? (choice ? shopBlockReason('ready', { priv, editable: true }) : tempReadyReason(priv) || shopBlockReason('ready', { priv, editable: true })) : null;
const why = !ready && (choice || temp.count > 0);
const btn = html`<button type="button" class=${cx('readybtn', 'tapx', ready && 'is-on', busy && 'is-busy')} disabled=${!!reason || busy}
aria-pressed=${ready ? 'true' : 'false'} aria-describedby=${!ready && temp.count ? 'readywrap-why' : undefined} onClick=${() => onToggle(!ready)}>
aria-pressed=${ready ? 'true' : 'false'} aria-describedby=${why ? 'readywrap-why' : undefined} onClick=${() => onToggle(!ready)}>
<span class="readybtn__box">${ready ? html`<${Icon} name="check" />` : null}</span>
<span class="readybtn__label">${ready ? t('取消准备') : t('准备就绪')}</span>
<kbd class="readybtn__key">${hotkeyLabelOf('ready')}</kbd>
</button>`;
return html`<div class="readywrap">
${reason ? html`<${Tooltip} text=${reason} placement="bottom">${btn}<//>` : btn}
${!ready && temp.count ? html`<span class="readywrap__why" id="readywrap-why" role="status" data-testid="ready-why">
<${Icon} name="warn" /><span>${tParts('临时整备区 {n} 个单位待处理', { n: html`<b class="num">${temp.count}</b>`, count: temp.count })}</span></span>` : null}
${why ? html`<span class="readywrap__why" id="readywrap-why" role="status" data-testid="ready-why">
<${Icon} name="warn" />${choice ? html`<span>${reason}</span>`
: html`<span>${tParts('临时整备区 {n} 个单位待处理', { n: html`<b class="num">${temp.count}</b>`, count: temp.count })}</span>`}</span>` : null}
${Number.isFinite(total) && total > 1 ? html`<span class="readywrap__count">${t('已就绪')} <b class="num">${readyCount}</b>/<span class="num">${total}</span></span>` : null}
</div>`;
}
+7
View File
@@ -201,6 +201,13 @@ export class PlayerState {
this.effects = [];
/** active bounties: { id, card, roundsLeft, chooser } */
this.bounties = [];
/**
* The open personal choice of the Art 教鞭 (Match.offerBountyChoice): up to three cards.bounty entries the owner picks one
* of with `g.choice { idx, choiceId }` — m.private only; Ready is refused while it is open, the prep's deadline (or the
* bot) resolves it
* @type {null | { id: string, round: number, sourceItemId: string, cards: any[] }}
*/
this.personalChoice = null;
/** free-form counters for content (ctx.counter / setCounter) */
this.counters = {};
/** per-round counters (reset at round start) */
+8 -3
View File
@@ -1329,6 +1329,7 @@ export function botPrepBegin(m, ps) {
*/
export function* botPrepBeginSteps(m, ps) {
if (!ps.alive || ps.ready) return null;
m.autoPickPersonalChoice(ps, 'bot');
// 1. reward offers (free)
takeOffers(m, ps);
yield;
@@ -1366,6 +1367,7 @@ export function* botPrepEndSteps(m, ps, job = null) {
if (freeSlot(ps.hand) < 0) freeHandSlot(m, ps);
// what the next prep's bench shed may judge by value: what is owned now (later gains wait for a placement step)
rememberOwned(ps);
m.autoPickPersonalChoice(ps, 'bot');
tryDo(() => ps.setReady(true));
}
@@ -1807,8 +1809,8 @@ const isBountyArt = (gd, itemId) => { const rec = gd.item(itemId); return !!rec
/**
* Use an Art: 画卷 copies the operator on its tile (its range is the tile + the one in front) — the most valuable
* deployed operator, with a free hand slot for the copy; 教鞭 / “神秘顾客” add a bounty to the next battle (one the engine
* draws, not chosen) — only after a perfect battle with LP to spare, else the Art stays in the hand for a later round
* deployed operator, with a free hand slot for the copy; 教鞭 offers a scored personal bounty choice, “神秘顾客” adds a
* random bounty — only after a perfect battle with LP to spare, else the Art stays in the hand for a later round
* (destroying 教鞭 gives nothing; a full hand at the prep end: freeHandSlot).
*/
function useArt(m, ps, item, ctx) {
@@ -1825,7 +1827,10 @@ function useArt(m, ps, item, ctx) {
if (keys.includes('trap_create_self_choice')) {
if (!lastPerfect(m, ps) || ps.lp < 10) return; // kept
const [key] = [...ps.board.keys()];
if (key) { const [r, c] = parseKey(key); tryDo(() => ps.useArt(item.uid, r, c)); }
if (key) {
const [r, c] = parseKey(key);
if (tryDo(() => ps.useArt(item.uid, r, c))) m.autoPickPersonalChoice(ps, 'bot');
}
return;
}
const [key] = [...ps.board.keys()];
+1 -2
View File
@@ -25,8 +25,7 @@
// operator gained into the hand
// trap_copy_front_char 画卷 (Art) copy the chess on the tile / in front
// trap_create_self_choice {choice_event} 教鞭 / 神秘顾客 (Art) add a random bounty to your next battle
// [ASSUMED simplification, documented in docs/META.md: 教鞭/神秘顾客 pick the bounty for the player instead of opening
// a personal choice overlay.]
// This builtin picks a random bounty by default; content/items/meta.js overrides Pointing Stick with a private choice.
import { getData } from '../data.js';
import { itemKey } from './gamedata.js';
+1
View File
@@ -674,6 +674,7 @@ export function makeCtx(m, ps, source, hook, ev = null) {
// ---- bounties / choices
addBounty: (card) => m.addBounty(ps, card),
offerBountyChoice: (cards, sourceItemId) => m.offerBountyChoice(ps, cards, sourceItemId),
// ---- messaging
// a string or a shared/i18n.js msg(msgid, params)
+8
View File
@@ -18,6 +18,8 @@
// bonds ps.bonds equals a fresh computeBonds() (every mutation recomputed them); every bond's layers 0 … BOND_LAYER_CAP
// shop slot count follows the rolled layout; ids known; banned chess never offered by the shop / rewards
// elim. an eliminated player owns nothing (board, hand, temp, shop, offers, bounties, funds)
// choice a player's open 教鞭 choice (`personalChoice`) is one of an alive, not-Ready player in the PREP of its own round,
// with one to three different cards
// match phase known; teamLp / boss pool within range; combat fields match the alive players
import { PHASE, BOND_LAYER_CAP } from '../../shared/constants.js';
@@ -75,6 +77,12 @@ export function collectViolations(m, { limit = 25 } = {}) {
if (ps.shop.slots.length || ps.offers.length || ps.bounties.length) fail(`${id}: eliminated but keeps shop/offers/bounties`);
if (ps.funds || ps.pendingFunds) fail(`${id}: eliminated with funds ${ps.funds}+${ps.pendingFunds}`);
}
// 教鞭's personal choice (Match.offerBountyChoice): resolved before the prep ends — by its owner, the deadline or the bot
const pc = ps.personalChoice;
if (pc) {
if (!ps.alive || ps.ready || m.phase !== PHASE.PREP || pc.round !== m.round) fail(`${id}: a personal choice outside its own prep (${m.phase} R${m.round}, offered in R${pc.round})`);
if (!Array.isArray(pc.cards) || !pc.cards.length || pc.cards.length > 3 || new Set(pc.cards.map((c) => c && c.effectId)).size !== pc.cards.length) fail(`${id}: personal choice of ${pc.cards && pc.cards.length} cards`);
}
// pieces
const all = [...ps.board.values(), ...ps.hand.filter(Boolean), ...ps.temp.filter(Boolean)];
+3 -1
View File
@@ -31,7 +31,9 @@ export class MatchIntents {
case 'g.art': return ps.useArt(msg.itemUid, msg.row, msg.col, msg.dir);
case 'g.destroy': return ps.destroy(msg.uid);
case 'g.reward': return ps.pickReward(msg.idx);
case 'g.choice': return this.pickCard(ps, msg.idx);
case 'g.choice': return msg.choiceId !== undefined
? this.pickPersonalChoice(ps, msg.idx, msg.choiceId)
: this.pickCard(ps, msg.idx);
case 'g.ready': return ps.setReady(!!msg.ready);
case 'g.emote': return this.emote(ps, msg.id);
// playerId: the player tapped (a shared field names two) — the watch preference (item 56)
+2
View File
@@ -62,6 +62,7 @@ export class MatchPrep {
};
const ready = () => {
if (!ps.ready) {
this.autoPickPersonalChoice(ps, 'random');
ps.resolveTemp();
ps.setReady(true);
}
@@ -113,6 +114,7 @@ export class MatchPrep {
if (this.phase !== PHASE.PREP) return;
for (const ps of this.alivePlayers()) {
if (ps.ready) continue;
this.autoPickPersonalChoice(ps, 'random');
ps.resolveTemp();
ps.ready = true;
ps.dirty();
+59 -3
View File
@@ -1,14 +1,18 @@
// server/match/match/spDraft.js — Match methods: the 机变 draft (SP_DRAFT: turn order, timers, AI picks, the card applied
// by choices.js applyCard) and the rolls the meta effects ask for — bounties (addBounty), item ids (rollItemId) and
// choices.json pools (rollPool).
// by choices.js applyCard), the personal bounty choice of the Art 教鞭 (offerBountyChoice / pickPersonalChoice /
// autoPickPersonalChoice: held by the player inside PREP — no phase, no timer of its own) and the rolls the meta effects
// ask for — bounties (addBounty), item ids (rollItemId) and choices.json pools (rollPool).
// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match).
import { PHASE, ERR } from '../../../shared/constants.js';
import { generateDraft, applyCard, bountyBattles, isMultiRoundBounty } from '../choices.js';
import { generateDraft, applyCard, bountyBattles, isMultiRoundBounty, bountyCard } from '../choices.js';
import { weightedPick } from '../waves.js';
import { botPickCard } from '../bot.js';
import { OK, fail, DELAYS } from './common.js';
/** Cards of the personal choice (PRTS 卫戍协议:盟约 下半 §法术 教鞭: "于3个战术特训的悬赏任务中选择一项"). */
const PERSONAL_OFFER_SIZE = 3;
export class MatchSpDraft {
enterSpDraft() {
const draft = generateDraft(this.gd, this.rngDraft, this.round, { stageId: this.stageId, bondAvailable: (bondId) => this.bondLive(bondId) });
@@ -106,6 +110,58 @@ export class MatchSpDraft {
this.enterPrep();
}
/**
* 教鞭 (content/items/meta.js, ctx.offerBountyChoice): the Art opens a PERSONAL choice (act2autochess choice event
* `hunter_band_1`, choiceType PERSONAL_CHOOSE) between up to three of `candidates` — drawn once from the meta rng, kept in
* `ps.personalChoice` (m.private only) until the owner confirms one with `g.choice { idx, choiceId }`. It lives inside
* PREP: no phase of its own and no timer — the prep's deadline resolves it (prepDeadline), Ready is refused while it is
* pending (PlayerPrep.setReady). Refused — before an rng draw or an id is used, so the Art stays in the hand — when it
* is not the player's PREP, a choice is already open or no card is left to offer.
* @param {import('../PlayerState.js').PlayerState} ps @param {any[]} candidates cards.bounty entries
* @param {string} sourceItemId the Art
*/
offerBountyChoice(ps, candidates, sourceItemId) {
if (this.phase !== PHASE.PREP) return fail(ERR.WRONG_PHASE);
if (!ps.alive) return fail(ERR.ELIMINATED);
if (ps.ready) return fail(ERR.WRONG_PHASE, 'ready');
if (ps.personalChoice) return fail(ERR.BAD_TARGET, '请先完成当前教鞭选择'); // i18n-ignore: developer error detail
if (!candidates.length) return fail(ERR.BAD_TARGET, '当前没有可用的战术特训'); // i18n-ignore: developer error detail
const cards = this.rngMeta.shuffle(candidates.slice()).slice(0, PERSONAL_OFFER_SIZE);
ps.personalChoice = { id: `${this.battlePrefix}.choice.${this.nextUid()}`, round: this.round, sourceItemId, cards };
ps.dirty();
return OK;
}
/** g.choice { idx, choiceId }: the owner's pick of its open personal choice — the card becomes a bounty on its next battles. */
pickPersonalChoice(ps, idx, choiceId) {
if (this.phase !== PHASE.PREP) return fail(ERR.WRONG_PHASE);
if (!ps.alive) return fail(ERR.ELIMINATED);
if (ps.ready) return fail(ERR.WRONG_PHASE, 'ready');
const pending = ps.personalChoice;
if (!pending || pending.id !== choiceId || pending.round !== this.round) return fail(ERR.BAD_TARGET);
if (!Number.isInteger(idx) || idx < 0 || idx >= pending.cards.length) return fail(ERR.BAD_TARGET);
if (!this.addBounty(ps, pending.cards[idx])) return fail(ERR.BAD_TARGET);
ps.personalChoice = null;
ps.dirty();
return OK;
}
/**
* The pick made for a player who did not make it. 'bot': an AI seat or a human under AI 托管 — the bounty scorer of the
* 机变 draft (bot.js botPickCard: the expected pay minus the expected LP lost against the own board, the bots' seeded rng
* only breaks ties); 'random': the prep's deadline and a seat the engine finishes by other means — one of the cards at
* random from the meta rng, like a 机变 turn that runs out. Nothing pending: nothing happens, no rng draw.
*/
autoPickPersonalChoice(ps, mode) {
const pending = ps.personalChoice;
if (!pending) return undefined;
const indices = pending.cards.map((c, i) => i);
const idx = mode === 'bot'
? botPickCard(this, ps, pending.cards.map((c) => bountyCard(this.gd, c)), indices)
: this.rngMeta.pick(indices);
return this.pickPersonalChoice(ps, idx, pending.id);
}
addBounty(ps, card) {
if (!ps || !card || !this.gd.enemy(card.enemyKey)) return null;
// a multi-round card lasts MULTI_ROUND_BOUNTY_BATTLES battles (choices.js; the user's call after playtest #6)
+1
View File
@@ -56,6 +56,7 @@ export class PlayerPrep {
setReady(on) {
if (!this.alive) return fail(ERR.ELIMINATED);
if (this.m.phase !== PHASE.PREP) return fail(ERR.WRONG_PHASE);
if (on && this.personalChoice) return fail(ERR.BAD_TARGET, '请先完成教鞭选择'); // i18n-ignore: developer error detail
if (on && !this.tempEmpty) return fail(ERR.TEMP_NOT_EMPTY);
if (this.ready === !!on) return OK;
this.ready = !!on;
+1
View File
@@ -65,6 +65,7 @@ export class PlayerRound {
this._tempDue.clear();
this.offers = [];
this.bounties = [];
this.personalChoice = null;
this.shop.slots = [];
this.funds = 0;
this.pendingFunds = 0;
+8 -2
View File
@@ -6,7 +6,7 @@
import { PHASE } from '../../../shared/constants.js';
import { boardOrder, pieceDir } from '../board.js';
import { bondList, offBondCounts } from '../bondsMeta.js';
import { bountyText } from '../choices.js';
import { bountyText, bountyCard } from '../choices.js';
export class PlayerViews {
pieceView(p, rc = null) {
@@ -66,7 +66,13 @@ export class PlayerViews {
funds: this.funds,
bandId: this.bandId,
ready: this.ready,
canReady: this.alive && this.tempEmpty && this.m.phase === PHASE.PREP,
canReady: this.alive && this.tempEmpty && !this.personalChoice && this.m.phase === PHASE.PREP,
personalChoice: this.personalChoice ? {
id: this.personalChoice.id,
round: this.personalChoice.round,
sourceItemId: this.personalChoice.sourceItemId,
cards: this.personalChoice.cards.map((c) => bountyCard(this.gd, c)),
} : null,
shop: {
level: this.shop.level,
maxLevel: this.gd.maxShopLevel,
+32 -24
View File
@@ -13,17 +13,19 @@
// the target's items skipped the item after a merge)
// 紧急调度券 a shop operator leaves its slot only when it was actually granted (built-in cleared the slot first)
// 寻呼模块 the special refresh shows `refresh_cnt` DIFFERENT operators (fewer when the pool has no more)
// 教鞭 trap_create_self_choice {choice_event: hunter_band_1}: the bounty is a 战术特训 card (choices.json
// cards.bounty payout `perfect`, e.g. 战术特训·飞行I "若各自行动阶段就达成完美作战,获得1资金") — PRTS
// 卫戍协议:盟约 下半/PRTS盟约记录 §法术 教鞭 "于3个战术特训的悬赏任务中选择一项", §机变阶段 "※以下悬赏任务仅由
// 法术教鞭生成", and 杜宾 加练! "<教鞭>:使用后为下场战斗添加额外敌人,若自身战斗完美作战可获得资金" (user
// playtest #6 item 4 review; the built-in drew any tier ≤ II kill bounty, and research 04 §7 [ASSUMED]
// the band family `enemyeffect_b_*`).
// “神秘顾客” the same trap_create_self_choice ("选择一项特殊悬赏任务"; no band grants it in act2, research 04): a band
// bounty `enemyeffect_b_*` (research 04 §7 [ASSUMED]: "adds 1 enemy to your next battle, killer gets `coin`
// funds").
// Both: [ASSUMED simplification, engine: no PERSONAL_CHOOSE overlay] 3 cards whose enemy can appear in the
// mode are drawn and one of them is taken at random; the built-in runs when the family is empty.
// 教鞭 trap_create_self_choice {choice_event: hunter_band_1, choiceType PERSONAL_CHOOSE}: a personal choice of
// one of THREE 战术特训 cards (choices.json cards.bounty payout `perfect`, e.g. 战术特训·飞行I "若各自行动阶段就
// 达成完美作战,获得1资金") whose enemy the mode can field — PRTS 卫戍协议:盟约 下半/PRTS盟约记录 §法术 教鞭
// "使用后销毁,于3个战术特训的悬赏任务中选择一项", §机变阶段 "※以下悬赏任务仅由法术教鞭生成", and 杜宾 加练!
// "<教鞭>:使用后为下场战斗添加额外敌人,若自身战斗完美作战可获得资金". onArt → ctx.offerBountyChoice
// (Match.offerBountyChoice): the three cards go to the owner alone, who confirms one inside the PREP
// (g.choice with the offer's id); the prep's deadline or an AI seat resolves it (docs/META.md §2.5). Until
// 0.2.2 the engine took one of the three at random (user playtest #6 item 4 review; the built-in drew any
// tier ≤ II kill bounty, and research 04 §7 [ASSUMED] the band family `enemyeffect_b_*`).
// “神秘顾客” the same trap_create_self_choice ("选择一项特殊悬赏任务"; no band grants it in act2, research 04, and PRTS
// lists no pool): still [ASSUMED simplification] 3 band bounties `enemyeffect_b_*` (research 04 §7 [ASSUMED]:
// "adds 1 enemy to your next battle, killer gets `coin` funds") whose enemy can appear in the mode, one of them
// taken at random; the built-in runs when the family is empty.
// “神秘顾客” trap_disney_special: when actively destroyed, +count funds and the Art passes to the next alive player
// (seat order, cyclic)
// 天师古鼎 equip_with_another_gain_coin_when_gain_char: a 【炎】 carrier also holding 炎国短刀 (either quality)
@@ -48,7 +50,7 @@ function pieceIsMember(ctx, piece, bondId) {
return bonds.includes('maniShip') && isCoreBond(bondId) && ctx.bondActive('maniShip') && ctx.bondActive(bondId);
}
/** Offer size of the personal bounty choice (PRTS 法术 教鞭 "于3个战术特训的悬赏任务中选择一项"). */
/** Cards drawn before “神秘顾客” takes one at random. */
const OFFER_SIZE = 3;
/** cards.bounty entries matching `test` whose enemy can appear in this mode. */
function bountyCards(ctx, test) {
@@ -103,18 +105,24 @@ export function registerMeta(registry) {
},
}));
// 教鞭 / “神秘顾客” — trap_create_self_choice {choice_event}: 教鞭 a 战术特训 card, “神秘顾客” a band bounty (enemyeffect_b_*)
for (const [key, family] of [['chess_item_6_03_m', isTraining], ['chess_item_6_01_m', isBandBounty]]) {
wrap(registry, key, (base) => ({
onArt(ctx, ev) {
const cards = bountyCards(ctx, family);
if (!cards.length) { if (typeof base.onArt === 'function') base.onArt.call(base, ctx, ev); return; }
const offer = ctx.rng.shuffle(cards.slice()).slice(0, OFFER_SIZE);
const card = ctx.rng.pick(offer);
if (!card || !ctx.addBounty(card)) { ev.error = 'BAD_TARGET'; ev.detail = 'no bounty available'; }
},
}));
}
// 教鞭 — trap_create_self_choice {choice_event}: the server keeps the offered 战术特训 cards until confirmed.
wrap(registry, 'chess_item_6_03_m', () => ({
onArt(ctx, ev) {
const result = ctx.offerBountyChoice(bountyCards(ctx, isTraining), ev.item.id);
if (!result.ok) { ev.error = result.error; ev.detail = result.detail; }
},
}));
// “神秘顾客” — a random band bounty (enemyeffect_b_*).
wrap(registry, 'chess_item_6_01_m', (base) => ({
onArt(ctx, ev) {
const cards = bountyCards(ctx, isBandBounty);
if (!cards.length) { if (typeof base.onArt === 'function') base.onArt.call(base, ctx, ev); return; }
const offer = ctx.rng.shuffle(cards.slice()).slice(0, OFFER_SIZE);
const card = ctx.rng.pick(offer);
if (!card || !ctx.addBounty(card)) { ev.error = 'BAD_TARGET'; ev.detail = 'no bounty available'; }
},
}));
// 画卷 — trap_copy_front_char: copy the operator in range (elite status included) with its equipment. A copied
// normal item that completes a pair with an owned one merges at once and the golden stays in the hand (research 04
+1 -1
View File
@@ -365,7 +365,7 @@ export const C2S = {
'g.art': { itemUid: isUid, row: (v) => isInt(v, 0, GEO.ROWS - 1), col: (v) => isInt(v, 0, GEO.COLS - 1), dir: isDir, $optional: ['dir'] },
'g.destroy': { uid: isUid },
'g.reward': { idx: (v) => isInt(v, 0, 5) },
'g.choice': { idx: (v) => isInt(v, 0, 5) },
'g.choice': { idx: (v) => isInt(v, 0, 5), choiceId: isId, $optional: ['choiceId'] },
'g.ready': { ready: isBool },
'g.emote': { id: (v) => EMOTES.includes(v) },
// playerId: the player tapped in the team panel (a 联防 / boss pair field shows two) — what an eliminated viewer or a
+68 -1
View File
@@ -1139,7 +1139,23 @@ test('教鞭 (Art): a 战术特训 bounty (PRTS "于3个战术特训的悬赏任
const { m, ps } = setup({ seed: 60 + s });
for (let k = 0; k < 2; k++) {
const art = giveItem(m, ps, 'chess_item_6_03_m');
const deadline = m.deadline;
assert.deepEqual(m.handle('p_0', { t: 'g.art', itemUid: art.uid, row: 10, col: 5 }), OK);
const pending = ps.personalChoice;
assert.equal(ps.bounties.length, k, 'offering cards does not add a bounty');
assert.equal(pending.cards.length, 3);
assert.equal(new Set(pending.cards.map((c) => c.effectId)).size, 3);
assert.equal(pending.round, m.round);
assert.equal(pending.sourceItemId, art.id);
assert.equal(m.deadline, deadline, 'uses the existing PREP clock');
assert.equal(ps.find(art.uid), null);
assert.equal(ps.round.arts, k + 1, 'consumed when offered');
for (const c of pending.cards) assert.ok(training.has(c.effectId) && !m.gd.inactiveEnemies.has(c.enemyKey));
assert.deepEqual(m.handle('p_0', { t: 'g.choice', idx: 1, choiceId: pending.id }), OK);
assert.equal(ps.personalChoice, null);
assert.equal(ps.bounties[k].card.effectId, pending.cards[1].effectId, 'the submitted card is applied');
assert.equal(ps.round.arts, k + 1, 'picking does not consume another Art');
assert.equal(m.handle('p_0', { t: 'g.choice', idx: 1, choiceId: pending.id }).error, 'BAD_TARGET');
}
assert.equal(ps.bounties.length, 2);
for (const b of ps.bounties) {
@@ -1150,7 +1166,7 @@ test('教鞭 (Art): a 战术特训 bounty (PRTS "于3个战术特训的悬赏任
seen.add(b.card.effectId);
}
}
assert.ok(seen.size >= 5, 'random pick');
assert.ok(seen.size >= 5, 'different offered cards across seeds');
const { h, m } = setup({ mode: 'coop', humans: 2, seed: 7 });
const p0 = h.ps('p_0'), p1 = h.ps('p_1');
const art = giveItem(m, p0, 'chess_item_6_01_m');
@@ -1161,9 +1177,60 @@ test('教鞭 (Art): a 战术特训 bounty (PRTS "于3个战术特训的悬赏任
const passed = p1.hand.find((p) => p && p.id === 'chess_item_6_01_m');
assert.deepEqual(m.handle('p_1', { t: 'g.art', itemUid: passed.uid, row: 10, col: 5 }), OK);
assert.match(p1.bounties[0].card.effectId, /^enemyeffect_b_/);
assert.equal(p1.personalChoice, null, '神秘顾客 still applies its random bounty immediately');
cover('chess_item_6_03_m', 'chess_item_6_01_m');
});
test('教鞭: pending and empty-pool failures keep the Art, Arts count and RNG; short offers use actual cards', () => {
const { m, ps } = setup();
const first = giveItem(m, ps, 'chess_item_6_03_m');
const second = giveItem(m, ps, 'chess_item_6_03_m', 'temp');
assert.deepEqual(ps.useArt(first.uid, 10, 5), OK);
const pending = ps.personalChoice;
const rng = m.rngMeta.state(), uid = m.uidSeq;
assert.deepEqual(ps.useArt(second.uid, 10, 5), { error: 'BAD_TARGET', detail: '请先完成当前教鞭选择' });
assert.equal(ps.personalChoice, pending);
assert.ok(ps.find(second.uid));
assert.equal(ps.round.arts, 1);
assert.equal(m.rngMeta.state(), rng);
assert.equal(m.uidSeq, uid);
assert.equal(m.pickPersonalChoice(ps, -1, pending.id).error, 'BAD_TARGET');
assert.equal(m.pickPersonalChoice(ps, 0.5, pending.id).error, 'BAD_TARGET');
assert.equal(m.pickPersonalChoice(ps, 3, pending.id).error, 'BAD_TARGET');
assert.equal(ps.personalChoice, pending);
assert.deepEqual(m.pickPersonalChoice(ps, 0, pending.id), OK);
const training = DATA.choices.cards.bounty.filter((c) => c.payout === 'perfect');
m.gd.inactiveEnemies = new Set(training.map((c) => c.enemyKey));
assert.deepEqual(ps.useArt(second.uid, 10, 5), { error: 'BAD_TARGET', detail: '当前没有可用的战术特训' });
assert.ok(ps.find(second.uid));
assert.equal(ps.round.arts, 1);
assert.equal(ps.personalChoice, null);
assert.equal(m.rngMeta.state(), rng);
const allowed = training.find((c) => c.multiRound && m.gd.enemy(c.enemyKey));
m.gd.inactiveEnemies.delete(allowed.enemyKey);
assert.deepEqual(ps.useArt(second.uid, 10, 5), OK);
assert.ok(ps.personalChoice.cards.length > 0 && ps.personalChoice.cards.length < 3);
assert.equal(new Set(ps.personalChoice.cards.map((c) => c.effectId)).size, ps.personalChoice.cards.length);
assert.equal(ps.find(second.uid), null, 'Arts in temp are consumed too');
const view = ps.privateView().personalChoice;
assert.ok(view.cards.every((c) => c.kind === 'bounty' && c.rounds === 2));
assert.ok(view.cards.every((c) => !/每场/.test(c.descRaw || c.desc)), 'multi-round candidates use the same two-battle text as applied bounties');
m.dispose();
});
test('教鞭: an open choice goes with a player who is eliminated, and nothing of it is left in its views', () => {
const { h, m } = setup({ mode: 'coop', humans: 2, seed: 8 });
const a = h.ps('p_0');
const art = giveItem(m, a, 'chess_item_6_03_m');
assert.deepEqual(a.useArt(art.uid, 10, 5), { ok: true });
assert.equal(a.privateView().personalChoice.cards.length, 3);
a.eliminate(m.round);
assert.equal(a.personalChoice, null);
assert.equal(a.privateView().personalChoice, null);
assert.equal(a.privateView().canReady, false);
m.dispose();
});
test('merging: two identical normal items become the golden one (upgradeNum 2); upgradeNum-100 items never merge', () => {
const never = Object.values(DATA.items).filter((r) => r.itemType === 'EQUIP' && !r.isGolden && r.upgradeNum === 100).map((r) => r.name).sort();
assert.deepEqual(never, ['人事部文档', '信标', '变形同构体', '寻呼模块', '拟态物质', '突变细胞'].sort());
+65 -65
View File
@@ -1209,8 +1209,8 @@
"combat": "server",
"boost": null,
"setup": {"stageId":"act1autochess_m01","bossId":"boss_1","hiddenBossId":"boss_9","factions":["FLY","DOT","REFLECTION"]},
"end": {"victory":true,"roundsPassed":14,"hiddenReached":false,"hiddenCleared":false,"reason":"victory","teamLp":39,"bossPoolLeft":0,"virtualMs":121400},
"rng": {"setup":25,"shop":511,"waves":0,"draft":34,"bots":18063,"meta":112},
"end": {"victory":true,"roundsPassed":14,"hiddenReached":false,"hiddenCleared":false,"reason":"victory","teamLp":34,"bossPoolLeft":0,"virtualMs":118400},
"rng": {"setup":25,"shop":483,"waves":0,"draft":34,"bots":18070,"meta":103},
"errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0},
"rounds": [
{
@@ -1220,63 +1220,63 @@
},
{
"round": 2,
"ai_0": [1,29,5,0,0,1,4,"76d92108",2,"arcaneShip 0/2, preciShip 1/2, soloShip 1/0",10,11,1,9],
"ai_1": [1,28,5,0,0,1,4,"fca300a0",2,"arcaneShip 0/2, kjeragShip 0/2, preciShip 1/2, soloShip 1/0, steadShip 0/2",10,10,0,9]
"ai_0": [1,30,5,0,2,1,4,"4678f135",2,"arcaneShip 0/2, preciShip 1/2, soloShip 1/0",11,11,0,9],
"ai_1": [1,28,5,0,0,1,4,"a2f52619",2,"arcaneShip 0/2, kjeragShip 0/2, preciShip 1/2, soloShip 1/0, steadShip 0/2",10,10,0,9]
},
{
"round": 3,
"ai_0": [1,29,6,0,0,1,7,"b7a24935",5,"arcaneShip 0/2, preciShip 1/2, siracusaShip 0/3, soloShip 1/3",9,9,0,15],
"ai_1": [1,28,6,0,0,1,7,"bf48e672",6,"arcaneShip 0/2, kjeragShip 0/4, preciShip 1/2, soloShip 1/0, steadShip 1/4",9,9,0,15]
"ai_0": [1,30,8,0,0,1,7,"388a275b",5,"arcaneShip 0/2, preciShip 1/2, siracusaShip 0/3, soloShip 1/3",9,9,0,16],
"ai_1": [1,28,6,0,0,1,7,"f979585a",6,"arcaneShip 0/2, kjeragShip 0/4, preciShip 1/2, soloShip 1/0, steadShip 1/4",9,9,0,15]
},
{
"round": 4,
"ai_0": [1,29,7,0,1,1,8,"e64a4d06",13,"arcaneShip 0/2, kjeragShip 0/2, preciShip 1/2, siracusaShip 0/6, soloShip 1/9, steadShip 1/2",13,13,0,22],
"ai_1": [1,28,7,0,0,2,8,"ef6d85b4",6,"arcaneShip 0/2, kjeragShip 0/4, preciShip 1/2, soloShip 1/0, steadShip 1/4",12,12,0,22]
"ai_0": [1,30,7,0,2,1,8,"3158c50f",13,"arcaneShip 0/2, kjeragShip 0/2, preciShip 1/2, siracusaShip 0/6, soloShip 1/9, steadShip 1/2",13,13,0,23],
"ai_1": [1,28,7,0,0,2,8,"1db5c96f",6,"arcaneShip 0/2, kjeragShip 0/4, preciShip 1/2, soloShip 1/0, steadShip 1/4",12,12,0,22]
},
{
"round": 5,
"ai_0": [1,29,9,0,0,3,8,"c1f02bc4",13,"arcaneShip 0/2, kjeragShip 0/2, preciShip 1/2, siracusaShip 0/6, soloShip 1/9, steadShip 1/2",15,15,0,31],
"ai_0": [1,30,10,0,0,3,8,"e8ba0f59",13,"arcaneShip 0/2, kjeragShip 0/2, preciShip 1/2, siracusaShip 0/6, soloShip 1/9, steadShip 1/2",15,15,0,33],
"ai_1": [1,28,8,0,0,3,8,"a3c40192",6,"arcaneShip 0/2, kjeragShip 0/4, preciShip 1/2, soloShip 1/0, steadShip 1/4",15,15,0,29]
},
{
"round": 6,
"ai_0": [1,29,9,0,3,3,8,"41bbfcb2",32,"arcaneShip 0/2, deputShip 1/2, kjeragShip 0/2, preciShip 1/4, siracusaShip 1/12, soloShip 1/14, steadShip 0/2",25,25,0,39],
"ai_1": [1,28,9,0,1,3,8,"ee24046e",11,"arcaneShip 0/2, kjeragShip 0/9, preciShip 2/7, siracusaShip 1/0, soloShip 1/0, steadShip 1/4",20,22,2,37]
"ai_0": [1,30,9,0,3,3,8,"a4c58969",34,"arcaneShip 0/2, deputShip 1/2, kjeragShip 0/2, preciShip 1/4, siracusaShip 1/14, soloShip 1/14, steadShip 0/2",25,25,0,41],
"ai_1": [1,28,9,0,1,3,8,"14c226e1",11,"arcaneShip 0/2, kjeragShip 0/9, preciShip 2/7, siracusaShip 1/0, soloShip 1/0, steadShip 1/4",22,22,0,37]
},
{
"round": 7,
"ai_0": [1,29,13,0,2,4,8,"94f5b782",57,"arcaneShip 1/8, deputShip 2/6, kjeragShip 0/2, preciShip 0/4, siracusaShip 1/25, soloShip 1/18, steadShip 0/2",27,27,0,53],
"ai_1": [1,28,11,0,3,4,8,"c6add328",15,"arcaneShip 0/2, kjeragShip 0/13, preciShip 2/7, siracusaShip 1/0, soloShip 1/0, steadShip 1/8",27,27,0,49]
"ai_0": [1,30,13,0,2,4,8,"e989f361",57,"arcaneShip 1/8, deputShip 2/6, kjeragShip 0/2, preciShip 0/4, siracusaShip 1/25, soloShip 1/18, steadShip 0/2",27,27,0,55],
"ai_1": [1,28,11,0,3,4,8,"f88853b8",15,"arcaneShip 0/2, kjeragShip 0/13, preciShip 2/7, siracusaShip 1/0, soloShip 1/0, steadShip 1/8",26,27,1,49]
},
{
"round": 8,
"ai_0": [1,29,13,0,1,4,8,"d070c283",88,"arcaneShip 2/18, deputShip 2/10, kjeragShip 0/2, preciShip 0/4, siracusaShip 1/38, soloShip 1/22, steadShip 0/2",24,24,0,66],
"ai_1": [1,28,14,0,0,4,8,"3a8e9585",24,"arcaneShip 0/2, emptyShip 1/0, kjeragShip 1/13, preciShip 1/7, siracusaShip 1/4, soloShip 1/0, steadShip 0/8",13,23,10,63]
"ai_0": [1,30,13,0,1,4,8,"3b2db317",88,"arcaneShip 2/18, deputShip 2/10, kjeragShip 0/2, preciShip 0/4, siracusaShip 1/38, soloShip 1/22, steadShip 0/2",24,24,0,68],
"ai_1": [1,28,14,0,0,4,8,"5589bffd",26,"arcaneShip 0/4, emptyShip 1/0, kjeragShip 1/13, preciShip 1/9, siracusaShip 1/4, soloShip 1/0, steadShip 0/8",17,23,6,63]
},
{
"round": 9,
"ai_0": [1,29,13,0,0,4,8,"7ca04eb4",151,"arcaneShip 2/38, deputShip 1/12, indomShip 0/8, kjeragShip 0/2, miraShip 1/2, preciShip 0/4, siracusaShip 1/62, soloShip 1/24, steadShip 0/2, suntShip 1/2, victoriaShip 1/11, visiShip 0/8",15,15,0,79],
"ai_1": [1,28,12,0,0,4,8,"1575876e",76,"arcaneShip 0/2, indomShip 0/8, kjeragShip 1/28, preciShip 2/40, siracusaShip 1/8, soloShip 1/0, steadShip 0/8, suntShip 1/0, victoriaShip 0/8, visiShip 0/8",15,15,0,75]
"ai_0": [1,30,13,0,0,4,8,"e6a9c830",151,"arcaneShip 2/38, deputShip 1/12, indomShip 0/8, kjeragShip 0/2, miraShip 1/2, preciShip 0/4, siracusaShip 1/62, soloShip 1/24, steadShip 0/2, suntShip 1/2, victoriaShip 1/11, visiShip 0/8",15,15,0,81],
"ai_1": [1,28,12,0,0,4,8,"5097fa00",67,"arcaneShip 0/4, indomShip 0/8, kjeragShip 1/28, preciShip 2/31, siracusaShip 1/8, soloShip 1/0, steadShip 0/8, suntShip 1/0, victoriaShip 0/8, visiShip 0/8",15,15,0,75]
},
{
"round": 10,
"ai_0": [1,29,12,0,2,5,8,"42c3188f",176,"arcaneShip 2/42, deputShip 1/14, indomShip 0/8, kjeragShip 0/2, miraShip 1/4, preciShip 0/4, siracusaShip 1/72, soloShip 1/26, steadShip 0/2, suntShip 1/4, victoriaShip 1/14, visiShip 0/8",24,24,0,92],
"ai_1": [1,28,12,0,0,5,8,"d4d2685b",107,"arcaneShip 0/2, indomShip 0/8, kjeragShip 1/38, preciShip 2/61, siracusaShip 1/8, soloShip 1/0, steadShip 0/8, suntShip 1/0, victoriaShip 0/8, visiShip 0/8",23,23,0,86]
"ai_0": [1,30,12,0,2,5,8,"a4f0dde0",176,"arcaneShip 2/42, deputShip 1/14, indomShip 0/8, kjeragShip 0/2, miraShip 1/4, preciShip 0/4, siracusaShip 1/72, soloShip 1/26, steadShip 0/2, suntShip 1/4, victoriaShip 1/14, visiShip 0/8",24,24,0,94],
"ai_1": [1,28,12,0,0,5,8,"46894aa5",75,"arcaneShip 0/4, indomShip 0/8, kjeragShip 1/28, preciShip 2/39, siracusaShip 1/8, soloShip 1/0, steadShip 0/8, suntShip 1/0, victoriaShip 0/8, visiShip 0/8",23,23,0,86]
},
{
"round": 11,
"ai_0": [1,29,14,0,0,5,8,"bbafb340",217,"arcaneShip 2/50, deputShip 1/16, indomShip 0/8, kjeragShip 0/2, miraShip 1/6, preciShip 0/4, siracusaShip 1/94, soloShip 1/28, steadShip 0/2, suntShip 1/6, victoriaShip 1/17, visiShip 0/8",23,23,0,107],
"ai_1": [1,28,12,0,0,5,8,"164a05d7",130,"arcaneShip 0/2, indomShip 0/8, kjeragShip 1/38, preciShip 2/74, siracusaShip 1/18, soloShip 1/0, steadShip 0/8, suntShip 1/0, victoriaShip 0/8, visiShip 0/8",23,23,0,97]
"ai_0": [1,30,14,0,0,5,8,"95327720",211,"arcaneShip 2/50, deputShip 1/16, indomShip 0/8, kjeragShip 0/2, miraShip 0/4, preciShip 0/4, siracusaShip 1/94, soloShip 1/28, steadShip 0/2, suntShip 1/6, victoriaShip 1/17, visiShip 0/8",23,23,0,109],
"ai_1": [1,28,12,0,0,5,8,"d993478a",94,"arcaneShip 0/4, indomShip 0/8, kjeragShip 1/28, preciShip 2/40, siracusaShip 1/26, soloShip 1/0, steadShip 0/8, suntShip 1/0, victoriaShip 0/8, visiShip 0/8",23,23,0,98]
},
{
"round": 12,
"ai_0": [1,29,12,0,0,6,8,"68ce3885",234,"arcaneShip 2/52, deputShip 1/18, indomShip 0/8, kjeragShip 0/2, miraShip 1/8, preciShip 0/4, siracusaShip 1/98, soloShip 1/30, steadShip 0/2, suntShip 1/8, victoriaShip 1/20, visiShip 0/8",34,47,13,119],
"ai_1": [1,28,12,0,0,6,8,"94e71f96",174,"arcaneShip 0/2, indomShip 0/8, kjeragShip 1/38, preciShip 2/98, siracusaShip 1/38, soloShip 1/0, steadShip 0/8, suntShip 1/0, victoriaShip 0/8, visiShip 0/8",46,46,0,108]
"ai_0": [1,20,12,0,0,6,8,"90727574",226,"arcaneShip 2/52, deputShip 1/18, indomShip 0/8, kjeragShip 0/2, miraShip 0/4, preciShip 0/4, siracusaShip 1/98, soloShip 1/30, steadShip 0/2, suntShip 1/8, victoriaShip 1/20, visiShip 0/8",23,47,24,120],
"ai_1": [1,27,12,0,0,6,8,"1f6740ae",140,"arcaneShip 0/4, indomShip 0/8, kjeragShip 1/28, preciShip 2/64, siracusaShip 1/48, soloShip 1/0, steadShip 0/8, suntShip 1/0, victoriaShip 0/8, visiShip 0/8",45,46,1,109]
},
{
"round": 13,
"ai_0": [1,19,12,0,0,6,8,"e4c62459",238,"arcaneShip 2/60, deputShip 0/18, indomShip 0/8, kjeragShip 0/2, maniShip 1/0, miraShip 1/8, preciShip 0/4, siracusaShip 1/112, soloShip 1/30, steadShip 0/2, suntShip 1/8, victoriaShip 1/20, visiShip 0/8",22,39,17,129],
"ai_1": [1,20,12,0,0,6,8,"804960b4",215,"arcaneShip 0/4, indomShip 0/8, kjeragShip 1/38, preciShip 2/121, siracusaShip 1/56, soloShip 1/0, steadShip 0/8, suntShip 1/0, victoriaShip 0/8, visiShip 0/8",31,39,8,120]
"ai_0": [1,13,12,0,0,6,8,"61fd749a",264,"arcaneShip 2/58, deputShip 1/20, indomShip 0/8, kjeragShip 0/2, miraShip 1/10, preciShip 0/4, siracusaShip 1/102, soloShip 1/32, steadShip 0/2, suntShip 1/10, victoriaShip 1/32, visiShip 0/8",32,39,7,130],
"ai_1": [1,21,12,0,0,6,8,"9eb5c4f8",178,"arcaneShip 0/6, indomShip 0/8, kjeragShip 1/28, preciShip 2/80, siracusaShip 1/70, soloShip 1/0, steadShip 0/8, suntShip 1/0, victoriaShip 0/8, visiShip 0/8",33,39,6,122]
}
],
"players": {
@@ -1285,28 +1285,28 @@
"victory": true,
"roundsPassed": 14,
"eliminatedRound": null,
"lp": 19,
"lp": 13,
"bandId": "band_doberm",
"title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"},
"title": {"id":"comment_2","name":"不朽盟约","picId":"comment_icon_2","text":"激活的盟约层数总和最高"},
"trophies": 4,
"reward": 400,
"stats": {"activatedLayers":283,"bossDamage":779391,"dmgDealt":3811993,"gold":142,"itemsEquipped":20,"kills":276,"leaks":31,"lpLost":11,"merges":4,"perfectRounds":10,"refreshes":23},
"lineup": ["5_20_a@11,3[4_07_e_a+1_01_e_a]","5_12_a@11,6[6_09_e_a+3_08_e_b]","6_11_a@11,7","2_10_a@10,3[3_05_e_a+3_11_e_b]","4_10_a@10,7[4_09_e_a+1_05_e_a]","1_08_b@10,8[1_02_e_a+5_03_e_a]","4_11_b@9,8[4_08_e_a]","6_07_a@9,9[4_10_e_a+4_05_e_a]"],
"bonds": ["arcaneShip 2/74","deputShip 0/18","indomShip 0/8","kjeragShip 0/2","maniShip 1/0","miraShip 1/12","preciShip 0/4","siracusaShip 1/130","soloShip 1/30","steadShip 0/2","suntShip 1/8","victoriaShip 1/29","visiShip 0/8"]
"stats": {"activatedLayers":328,"bossDamage":362244,"dmgDealt":3468602,"gold":141,"itemsEquipped":23,"kills":264,"leaks":31,"lpLost":17,"merges":3,"perfectRounds":11,"refreshes":22},
"lineup": ["2_10_a@12,3[5_03_e_a+3_11_e_a]","5_20_a@11,3[4_07_e_a+1_01_e_b]","1_06_a@11,6[3_05_e_a+2_04_e_a]","5_12_a@11,7[3_10_e_a]","1_13_b@10,3","4_10_a@10,7[1_05_e_a+4_10_e_a]","1_08_b@10,8[3_09_e_a+4_05_e_b]","6_07_a@9,9[3_06_e_a+5_07_e_a]"],
"bonds": ["arcaneShip 2/73","deputShip 1/22","indomShip 0/8","kjeragShip 0/2","miraShip 1/16","preciShip 0/4","siracusaShip 1/127","soloShip 1/34","steadShip 0/2","suntShip 1/12","victoriaShip 1/44","visiShip 0/8"]
},
"ai_1": {
"alive": true,
"victory": true,
"roundsPassed": 14,
"eliminatedRound": null,
"lp": 20,
"lp": 21,
"bandId": "band_malkie",
"title": {"id":"comment_3","name":"坚若磐石","picId":"comment_icon_3","text":"目标生命值损失最少"},
"title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"},
"trophies": 4,
"reward": 400,
"stats": {"activatedLayers":215,"bossDamage":570609,"dmgDealt":4056057,"gold":132,"itemsEquipped":21,"kills":261,"leaks":20,"lpLost":8,"merges":3,"perfectRounds":10,"refreshes":22},
"lineup": ["3_20_a@12,3[4_03_e_a+5_01_e_a]","5_10_a@11,3[1_01_e_a+3_04_e_b]","5_02_a@11,6","1_17_a@11,7[5_07_e_a+1_02_e_b]","3_11_b@10,3[3_03_e_a+5_02_e_b]","1_08_b@10,8[3_11_e_a+3_06_e_a]","1_02_b@9,4[4_10_e_a+4_08_e_b]","6_18_a@9,9[5_02_e_b+4_11_e_a]"],
"bonds": ["arcaneShip 0/4","indomShip 0/8","kjeragShip 1/38","preciShip 2/121","siracusaShip 1/56","soloShip 1/0","steadShip 0/8","suntShip 1/0","victoriaShip 0/8","visiShip 0/8"]
"stats": {"activatedLayers":194,"bossDamage":987756,"dmgDealt":4195035,"gold":136,"itemsEquipped":29,"kills":254,"leaks":14,"lpLost":7,"merges":4,"perfectRounds":9,"refreshes":19},
"lineup": ["3_20_b@11,3[4_03_e_a+3_04_e_b]","5_02_a@11,6[5_07_e_a]","3_11_b@11,7[3_03_e_a+4_08_e_a]","6_18_a@10,3[5_03_e_a+5_02_e_b]","6_05_a@10,7[5_02_e_a+5_02_e_b]","1_08_b@10,8[4_08_e_b+5_02_e_b]","5_10_a@9,8[1_01_e_a+1_02_e_b]","6_19_a@9,9[4_11_e_a+4_10_e_a]"],
"bonds": ["arcaneShip 0/6","indomShip 0/8","kazimierzShip 0/8","kjeragShip 1/36","preciShip 2/80","siracusaShip 1/70","soloShip 1/0","steadShip 0/8","suntShip 1/0","swiftShip 1/8","victoriaShip 0/8","visiShip 0/8"]
}
}
},
@@ -1404,8 +1404,8 @@
"combat": "client",
"boost": {"lp":400,"layers":300},
"setup": {"stageId":"act2autochess_m04","bossId":"boss_5","hiddenBossId":"boss_9","factions":["FLY","ELEMENT","INVISIBLE"]},
"end": {"victory":true,"roundsPassed":15,"hiddenReached":true,"hiddenCleared":true,"reason":"victory","teamLp":800,"bossPoolLeft":0,"virtualMs":121650},
"rng": {"setup":25,"shop":590,"waves":0,"draft":61,"bots":22075,"meta":141},
"end": {"victory":true,"roundsPassed":15,"hiddenReached":true,"hiddenCleared":true,"reason":"victory","teamLp":800,"bossPoolLeft":0,"virtualMs":124650},
"rng": {"setup":25,"shop":589,"waves":0,"draft":61,"bots":22096,"meta":154},
"errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0},
"rounds": [
{
@@ -1416,62 +1416,62 @@
{
"round": 2,
"ai_0": [1,400,5,0,0,1,4,"f5818d14",302,"arcaneShip 0/300, deputShip 0/300, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/302, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 0/300, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 0/300, steadShip 1/302, suntShip 0/300, swiftShip 0/300, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",9,9,0,9],
"ai_1": [1,400,5,0,1,1,4,"e14f7c28",0,"arcaneShip 0/300, deputShip 0/300, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 0/300, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 0/300, steadShip 0/300, suntShip 0/300, swiftShip 0/300, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",10,10,0,9]
"ai_1": [1,400,5,0,1,1,4,"1bb10c7b",0,"arcaneShip 0/300, deputShip 0/300, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 0/300, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 0/300, steadShip 0/300, suntShip 0/300, swiftShip 0/300, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",10,10,0,9]
},
{
"round": 3,
"ai_0": [1,400,6,0,1,1,7,"6ff64423",603,"arcaneShip 0/300, deputShip 0/300, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/302, kjeragShip 0/302, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 0/300, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/300, steadShip 1/303, suntShip 0/300, swiftShip 0/302, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",11,11,0,15],
"ai_1": [1,400,7,0,2,1,7,"97624e1b",602,"arcaneShip 0/302, deputShip 0/300, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/302, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/300, steadShip 0/300, suntShip 0/300, swiftShip 0/300, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",11,11,0,16]
"ai_0": [1,400,6,0,1,1,7,"0d06d571",603,"arcaneShip 0/300, deputShip 0/300, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/302, kjeragShip 0/302, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 0/300, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/300, steadShip 1/303, suntShip 0/300, swiftShip 0/302, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",11,11,0,15],
"ai_1": [1,400,7,0,2,1,7,"f4115666",602,"arcaneShip 0/302, deputShip 0/300, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/302, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/300, steadShip 0/300, suntShip 0/300, swiftShip 0/300, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",11,11,0,16]
},
{
"round": 4,
"ai_0": [1,400,8,0,1,2,8,"6153ae71",906,"arcaneShip 0/302, deputShip 0/300, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/302, kjeragShip 0/302, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/302, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/300, steadShip 1/304, suntShip 0/300, swiftShip 0/302, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",21,21,0,23],
"ai_1": [1,400,9,0,5,2,8,"c56b1a00",909,"arcaneShip 0/302, deputShip 1/302, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/305, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/302, steadShip 0/300, suntShip 0/300, swiftShip 0/300, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",22,22,0,25]
"ai_1": [1,400,9,0,4,2,8,"d70518d7",917,"arcaneShip 0/302, deputShip 2/304, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/309, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/304, steadShip 0/300, suntShip 0/300, swiftShip 0/300, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",22,22,0,25]
},
{
"round": 5,
"ai_0": [1,400,9,0,0,3,8,"74d81bd7",1210,"arcaneShip 0/302, deputShip 0/300, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/302, kjeragShip 0/302, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/302, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 1/303, soloShip 1/300, steadShip 1/305, suntShip 0/300, swiftShip 0/302, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",24,24,0,32],
"ai_1": [1,400,13,0,0,3,8,"e04712f6",1523,"arcaneShip 1/304, deputShip 1/304, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/308, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/304, steadShip 0/300, suntShip 0/300, swiftShip 0/300, victoriaShip 1/303, visiShip 0/300, yanShip 0/300",24,24,0,38]
"ai_0": [1,400,9,0,0,3,8,"c3dfd8c8",1210,"arcaneShip 0/302, deputShip 0/300, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/302, kjeragShip 0/302, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/302, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 1/303, soloShip 1/300, steadShip 1/305, suntShip 0/300, swiftShip 0/302, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",24,24,0,32],
"ai_1": [1,400,12,0,0,3,8,"6db164c9",1534,"arcaneShip 1/304, deputShip 1/306, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/302, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/314, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/306, steadShip 1/304, suntShip 0/300, swiftShip 0/300, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",24,24,0,37]
},
{
"round": 6,
"ai_0": [1,400,9,0,0,3,8,"30588739",1220,"arcaneShip 0/302, deputShip 0/300, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/302, kjeragShip 0/307, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 2/308, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 1/306, soloShip 1/300, steadShip 1/306, suntShip 0/300, swiftShip 0/302, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",27,27,0,42],
"ai_1": [1,400,9,0,0,3,8,"50b45347",1535,"arcaneShip 1/306, deputShip 1/306, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/311, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/306, steadShip 0/300, suntShip 0/300, swiftShip 0/300, victoriaShip 1/306, visiShip 0/300, yanShip 0/300",27,27,0,45]
"ai_0": [1,400,9,0,0,3,8,"2455cf23",1227,"arcaneShip 0/302, deputShip 1/302, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/302, kjeragShip 0/302, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/304, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 1/308, soloShip 0/300, steadShip 1/313, suntShip 0/300, swiftShip 0/302, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",27,27,0,40],
"ai_1": [1,400,9,0,1,3,8,"a34aad2c",1858,"arcaneShip 1/306, deputShip 1/308, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/300, kjeragShip 1/309, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/321, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/308, steadShip 1/306, suntShip 0/300, swiftShip 0/300, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",30,30,0,47]
},
{
"round": 7,
"ai_0": [1,400,10,0,0,4,8,"8f4587cd",1228,"arcaneShip 0/302, deputShip 0/300, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/302, kjeragShip 0/307, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 2/311, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 1/309, soloShip 1/300, steadShip 1/308, suntShip 0/300, swiftShip 0/302, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",25,25,0,54],
"ai_1": [1,400,10,0,0,4,8,"2daf0b74",1547,"arcaneShip 1/308, deputShip 1/308, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/314, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/308, steadShip 0/300, suntShip 0/300, swiftShip 0/300, victoriaShip 1/309, visiShip 0/300, yanShip 0/300",25,25,0,54]
"ai_0": [1,400,10,0,0,4,8,"6d830af8",1244,"arcaneShip 0/302, deputShip 1/304, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/302, kjeragShip 0/302, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/309, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 1/313, soloShip 0/300, steadShip 1/318, suntShip 0/300, swiftShip 0/302, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",25,25,0,50],
"ai_1": [1,400,11,0,0,4,8,"ed7d0a62",1870,"arcaneShip 1/308, deputShip 1/310, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/300, kjeragShip 1/311, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/323, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/310, steadShip 1/308, suntShip 0/300, swiftShip 0/300, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",25,25,0,58]
},
{
"round": 8,
"ai_0": [1,400,11,0,0,4,8,"e8308b42",1534,"arcaneShip 1/306, deputShip 0/300, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/302, kjeragShip 1/307, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 2/311, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/309, soloShip 1/300, steadShip 1/310, suntShip 0/300, swiftShip 0/302, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",18,18,0,64],
"ai_1": [1,400,11,0,3,4,8,"cbdb5901",1561,"arcaneShip 1/310, deputShip 1/310, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/325, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 0/308, steadShip 1/304, suntShip 0/300, swiftShip 0/300, victoriaShip 1/312, visiShip 0/300, yanShip 0/300",19,19,0,63]
"ai_0": [1,400,11,0,2,4,8,"f4ce7cb6",1248,"arcaneShip 0/302, deputShip 1/306, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/302, kjeragShip 0/307, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 2/316, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/313, soloShip 1/302, steadShip 1/324, suntShip 0/300, swiftShip 0/302, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",18,18,0,61],
"ai_1": [1,400,11,0,2,4,8,"042290ea",2194,"arcaneShip 1/316, deputShip 1/312, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/300, kjeragShip 1/313, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/335, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/312, steadShip 0/308, suntShip 1/302, swiftShip 0/300, victoriaShip 1/304, visiShip 0/300, yanShip 0/300",19,19,0,69]
},
{
"round": 9,
"ai_0": [1,400,12,0,2,4,8,"c3934d4d",1864,"arcaneShip 1/314, deputShip 1/302, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/302, kjeragShip 1/309, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/313, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 1/311, soloShip 0/300, steadShip 1/315, suntShip 0/300, swiftShip 0/302, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",24,24,0,75],
"ai_1": [1,400,15,0,4,4,8,"eb426307",1574,"arcaneShip 1/310, deputShip 0/310, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/336, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/308, steadShip 1/307, suntShip 0/300, swiftShip 0/300, victoriaShip 1/313, visiShip 0/300, yanShip 0/300",24,24,0,79]
"ai_0": [1,400,14,0,0,4,8,"0659edb4",1539,"arcaneShip 1/312, deputShip 0/306, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/302, kjeragShip 1/307, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 2/318, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/313, soloShip 1/302, steadShip 0/324, suntShip 1/300, swiftShip 0/302, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",18,24,6,76],
"ai_1": [1,400,14,0,1,4,8,"99b9df21",2214,"arcaneShip 1/318, deputShip 1/314, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/300, kjeragShip 1/315, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/341, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/314, steadShip 0/308, suntShip 1/304, swiftShip 0/300, victoriaShip 1/308, visiShip 0/300, yanShip 0/300",24,24,0,84]
},
{
"round": 10,
"ai_0": [1,400,14,0,0,5,8,"3aef9421",2166,"arcaneShip 0/314, deputShip 1/304, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/302, kjeragShip 1/311, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/315, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 1/313, soloShip 1/302, steadShip 1/319, suntShip 1/302, swiftShip 0/302, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",12,12,0,89],
"ai_1": [1,400,16,0,3,5,8,"0d6049ac",1574,"arcaneShip 1/310, deputShip 0/310, egirShip 0/300, emptyShip 0/300, indomShip 1/300, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/342, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/308, steadShip 0/307, suntShip 0/300, swiftShip 0/300, victoriaShip 1/314, visiShip 0/300, yanShip 0/300",13,13,0,94]
"ai_0": [1,400,12,0,0,5,8,"37cd34cd",1563,"arcaneShip 1/314, deputShip 0/306, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/302, kjeragShip 1/310, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/323, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 1/316, soloShip 0/302, steadShip 0/324, suntShip 1/300, swiftShip 0/302, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",12,12,0,88],
"ai_1": [1,400,13,0,3,5,8,"49b505a4",2226,"arcaneShip 1/320, deputShip 1/316, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/300, kjeragShip 1/317, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/347, raidShip 0/300, sargonShip 0/300, siracusaShip 0/306, skillfulShip 0/300, soloShip 0/314, steadShip 0/308, suntShip 1/306, swiftShip 1/308, victoriaShip 1/312, visiShip 0/300, yanShip 0/300",13,13,0,96]
},
{
"round": 11,
"ai_0": [1,400,12,0,1,5,8,"0665e01c",2194,"arcaneShip 0/314, deputShip 1/306, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/302, kjeragShip 1/319, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/317, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 1/315, soloShip 1/304, steadShip 1/329, suntShip 1/304, swiftShip 0/302, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",34,34,0,101],
"ai_1": [1,400,15,0,3,5,8,"976cdb21",1906,"arcaneShip 1/310, deputShip 0/310, egirShip 0/300, emptyShip 0/300, indomShip 1/300, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/359, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/308, steadShip 1/314, suntShip 0/300, swiftShip 0/300, victoriaShip 1/315, visiShip 0/300, yanShip 0/300",34,34,0,108]
"ai_0": [1,400,12,0,1,5,8,"08857c7b",1899,"arcaneShip 1/328, deputShip 0/306, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/302, kjeragShip 1/315, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/323, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 1/331, soloShip 1/302, steadShip 0/324, suntShip 1/300, swiftShip 0/302, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",34,34,0,101],
"ai_1": [1,400,15,0,1,5,8,"3d501b69",2268,"arcaneShip 1/322, deputShip 1/318, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/300, kjeragShip 1/324, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 2/365, raidShip 0/300, sargonShip 0/300, siracusaShip 0/306, skillfulShip 0/300, soloShip 0/314, steadShip 0/308, suntShip 1/308, swiftShip 1/315, victoriaShip 1/316, visiShip 0/300, yanShip 0/300",34,34,0,112]
},
{
"round": 12,
"ai_0": [1,400,13,0,0,5,8,"6d98f0bb",2193,"arcaneShip 0/316, deputShip 1/308, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/302, kjeragShip 1/326, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/326, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 1/317, soloShip 1/306, steadShip 0/329, suntShip 1/306, swiftShip 1/304, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",14,14,0,113],
"ai_1": [1,400,15,0,2,6,8,"9c1759d4",2235,"arcaneShip 1/312, deputShip 1/312, egirShip 0/300, emptyShip 0/300, indomShip 1/302, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/374, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 1/302, soloShip 1/310, steadShip 1/323, suntShip 0/300, swiftShip 0/300, victoriaShip 0/315, visiShip 0/300, yanShip 0/300",16,16,0,122]
"ai_0": [1,400,13,0,0,6,8,"d89e40fc",2247,"arcaneShip 1/338, deputShip 0/306, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/302, kjeragShip 1/315, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/323, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 1/341, soloShip 1/302, steadShip 1/328, suntShip 1/300, swiftShip 0/302, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",12,14,2,115],
"ai_1": [1,400,13,0,3,6,8,"97f8bb50",2300,"arcaneShip 1/324, deputShip 1/320, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/300, kjeragShip 1/326, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 2/378, raidShip 0/300, sargonShip 0/300, siracusaShip 0/306, skillfulShip 0/300, soloShip 0/314, steadShip 0/308, suntShip 1/310, swiftShip 1/322, victoriaShip 1/320, visiShip 0/300, yanShip 0/300",15,15,0,126]
},
{
"round": 13,
"ai_0": [1,400,12,0,0,6,8,"1467bee8",2207,"arcaneShip 0/316, deputShip 1/310, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/302, kjeragShip 1/328, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/328, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 1/319, soloShip 1/308, steadShip 0/329, suntShip 1/308, swiftShip 1/306, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",41,41,0,125],
"ai_1": [1,400,14,0,4,6,8,"aa87d884",1963,"arcaneShip 1/319, deputShip 1/314, egirShip 0/300, emptyShip 0/300, indomShip 1/304, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/396, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 1/304, soloShip 0/310, steadShip 0/323, suntShip 0/300, swiftShip 0/300, victoriaShip 1/326, visiShip 0/300, yanShip 0/305",43,43,0,139]
"ai_0": [1,400,12,0,0,6,8,"2208b5c6",2293,"arcaneShip 1/350, deputShip 0/306, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/302, kjeragShip 1/325, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/328, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 1/356, soloShip 1/302, steadShip 1/332, suntShip 1/300, swiftShip 0/302, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",41,41,0,129],
"ai_1": [1,400,15,0,0,6,8,"65aff572",2352,"arcaneShip 1/326, deputShip 1/322, egirShip 0/300, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/300, kjeragShip 1/338, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 2/401, raidShip 0/300, sargonShip 0/300, siracusaShip 0/306, skillfulShip 0/300, soloShip 0/314, steadShip 0/308, suntShip 1/312, swiftShip 1/329, victoriaShip 1/324, visiShip 0/300, yanShip 0/300",41,41,0,141]
}
],
"players": {
@@ -1485,9 +1485,9 @@
"title": {"id":"comment_3","name":"坚若磐石","picId":"comment_icon_3","text":"目标生命值损失最少"},
"trophies": 8,
"reward": 425,
"stats": {"activatedLayers":2267,"bossDamage":6609998,"dmgDealt":23342655,"gold":149,"itemsEquipped":23,"kills":290,"leaks":0,"lpLost":0,"merges":6,"perfectRounds":13,"refreshes":16},
"lineup": ["3_16_b@12,8[3_01_e_a+1_01_e_b]","4_09_b@11,5","3_11_b@11,8[3_02_e_a+6_06_e_a]","1_02_b@10,4[1_02_e_a+4_02_e_a]","5_14_a@10,8[3_03_e_a+1_05_e_a]","6_20_a@9,6","3_20_a@9,8[3_07_e_a+3_08_e_a]","6_05_a@9,9[2_04_e_a+5_02_e_b]"],
"bonds": ["arcaneShip 0/316","deputShip 1/312","egirShip 0/300","emptyShip 0/300","indomShip 0/300","investShip 0/300","kazimierzShip 0/302","kjeragShip 1/343","lateranoShip 0/300","maniShip 0/300","miraShip 0/300","preciShip 1/343","raidShip 0/300","sargonShip 0/300","siracusaShip 0/300","skillfulShip 0/319","soloShip 1/310","steadShip 1/341","suntShip 1/310","swiftShip 1/308","victoriaShip 0/300","visiShip 0/300","yanShip 0/300"]
"stats": {"activatedLayers":2333,"bossDamage":3297740,"dmgDealt":11942829,"gold":151,"itemsEquipped":16,"kills":277,"leaks":8,"lpLost":0,"merges":3,"perfectRounds":11,"refreshes":21},
"lineup": ["2_17_a@12,8[4_12_e_a+4_08_e_b]","1_14_b@11,5","1_09_b@11,8","6_16_a@10,3[4_03_e_b+5_02_e_a]","1_02_a@10,4[3_02_e_a+1_01_e_b]","4_13_a@9,7[1_01_e_a+3_08_e_a]","3_11_a@9,8[3_03_e_b+3_07_e_a]","5_12_a@9,9[1_05_e_a+3_05_e_b]"],
"bonds": ["arcaneShip 1/364","deputShip 0/306","egirShip 0/300","emptyShip 0/300","indomShip 0/300","investShip 0/300","kazimierzShip 0/302","kjeragShip 1/325","lateranoShip 0/300","maniShip 0/300","miraShip 0/300","preciShip 1/328","raidShip 0/300","sargonShip 0/300","siracusaShip 0/300","skillfulShip 1/374","soloShip 1/302","steadShip 1/340","suntShip 1/300","swiftShip 0/302","victoriaShip 0/300","visiShip 0/300","yanShip 0/300"]
},
"ai_1": {
"alive": true,
@@ -1499,9 +1499,9 @@
"title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"},
"trophies": 8,
"reward": 425,
"stats": {"activatedLayers":2301,"bossDamage":7282442,"dmgDealt":25059114,"gold":165,"itemsEquipped":31,"kills":276,"leaks":0,"lpLost":0,"merges":5,"perfectRounds":13,"refreshes":38},
"lineup": ["2_10_a@11,5[2_03_e_b+3_09_e_a]","5_03_a@11,8[1_02_e_a+5_02_e_a]","1_06_a@10,3[1_05_e_a+3_02_e_a]","3_16_a@10,4[3_03_e_b+4_04_e_b]","5_06_a@10,8[3_09_e_b+3_09_e_b]","4_21_b@9,6[4_06_e_a+3_10_e_b]","5_02_a@9,8[4_09_e_a+3_06_e_b]","1_17_b@9,9[3_08_e_a+1_01_e_a]"],
"bonds": ["arcaneShip 1/327","deputShip 1/318","egirShip 0/300","emptyShip 0/300","indomShip 1/308","investShip 0/300","kazimierzShip 0/300","kjeragShip 0/300","lateranoShip 0/300","maniShip 0/300","miraShip 0/300","preciShip 1/404","raidShip 0/300","sargonShip 0/300","siracusaShip 0/300","skillfulShip 1/308","soloShip 0/310","steadShip 0/323","suntShip 1/304","swiftShip 0/300","victoriaShip 1/332","visiShip 0/300","yanShip 0/305"]
"stats": {"activatedLayers":2416,"bossDamage":10592658,"dmgDealt":34563753,"gold":166,"itemsEquipped":33,"kills":284,"leaks":0,"lpLost":0,"merges":5,"perfectRounds":13,"refreshes":34},
"lineup": ["5_08_a@11,5[4_06_e_a+3_10_e_b]","3_11_b@11,8[5_01_e_a+4_03_e_a]","2_10_b@10,3[1_01_e_a+3_10_e_a]","5_14_a@10,8[3_09_e_b+1_05_e_a]","3_20_b@9,6[4_09_e_a+3_06_e_b]","4_14_a@9,7[2_03_e_b+5_02_e_a]","1_06_b@9,8[2_03_e_b+3_03_e_a]","1_17_b@9,9[1_05_e_b+4_09_e_b]"],
"bonds": ["arcaneShip 1/330","deputShip 1/326","egirShip 0/300","emptyShip 0/300","indomShip 0/300","investShip 0/300","kazimierzShip 0/300","kjeragShip 1/342","lateranoShip 0/300","maniShip 0/300","miraShip 0/300","preciShip 2/427","raidShip 0/300","sargonShip 0/300","siracusaShip 0/306","skillfulShip 0/300","soloShip 0/314","steadShip 0/308","suntShip 2/316","swiftShip 1/343","victoriaShip 1/332","visiShip 0/300","yanShip 0/300"]
}
}
},
+40 -1
View File
@@ -38,9 +38,45 @@ test('bounty pick: the card the own board can beat, never one it cannot (even wh
assert.equal(botPickCard(m, ps, [hard, easy, huge].map((c, idx) => ({ ...c, idx })), [0, 1, 2]), 1);
ps.lp = 2;
assert.equal(botPickCard(m, ps, [hard, huge, easy].map((c, idx) => ({ ...c, idx })), [0, 1, 2]), 2, 'low LP: still the beatable one');
const raw = [hard, easy].map((c) => DATA.choices.cards.bounty.find((x) => x.effectId === c.id));
assert.deepEqual(m.offerBountyChoice(ps, raw, 'chess_item_6_03_m'), { ok: true });
assert.deepEqual(m.autoPickPersonalChoice(ps, 'bot'), { ok: true });
assert.equal(ps.personalChoice, null);
assert.equal(ps.bounties.at(-1).card.enemyKey, easy.enemyKey, 'personal cards use bounty scoring too');
const metaState = m.rngMeta.state(), botState = m.rngBots.state();
m.autoPickPersonalChoice(ps, 'bot');
m.autoPickPersonalChoice(ps, 'random');
assert.equal(m.rngMeta.state(), metaState);
assert.equal(m.rngBots.state(), botState, 'nothing pending: no RNG draws');
m.dispose();
});
test('a bot\'s 教鞭 pick is deterministic: one seed offers the same three cards and picks the same one, drawing only on the bots\' rng', () => {
const runs = [];
for (let k = 0; k < 2; k++) {
const h = soloBot({ seed: 5, difficulty: 'HARD' }).start();
const m = h.m;
const ps = m.order[0];
h.run(() => m.phase === PHASE.PREP && m.round === 1);
ps.lp = 999;
h.run(() => m.phase === PHASE.PREP && m.round === 5);
const art = giveItem(m, ps, 'chess_item_6_03_m');
assert.deepEqual(ps.useArt(art.uid, 10, 5), { ok: true });
const offered = ps.personalChoice.cards.map((c) => c.effectId);
assert.equal(offered.length, 3);
assert.equal(new Set(offered).size, 3, 'three different cards');
const meta = m.rngMeta.state(), bots = m.rngBots.state();
assert.deepEqual(m.autoPickPersonalChoice(ps, 'bot'), { ok: true });
assert.equal(m.rngMeta.state(), meta, 'the pick leaves the meta rng alone');
assert.notEqual(m.rngBots.state(), bots, 'one scoring draw per card from the bots\' rng');
assert.equal(ps.personalChoice, null);
runs.push({ offered, picked: ps.bounties.at(-1).card.effectId, bots: m.rngBots.state() });
assert.ok(offered.includes(runs[k].picked));
m.dispose();
}
assert.deepEqual(runs[0], runs[1]);
});
test('bounty pick in real drafts (绝境 R3 悬赏决策): a card the board likely beats (p ≥ 0.5) whenever a near-sure one (p ≥ 0.9) is offered', () => {
let drafts = 0;
for (const seed of [1, 2, 3, 4]) {
@@ -138,9 +174,12 @@ test('bounty Arts (教鞭): kept after a battle with leaks, used after a perfect
assert.ok(owns(ps, WHIP), 'kept after a battle with leaks (not destroyed)');
h.run(() => m.phase === PHASE.PREP && m.round === 4);
m.lastResults.set(ps.playerId, clean);
giveItem(m, ps, WHIP);
const bounties = ps.bounties.length;
h.run(() => m.phase === PHASE.COMBAT && m.round === 4);
assert.ok(!owns(ps, WHIP) && ps.bounties.length > bounties, 'used after a perfect battle');
assert.ok(!owns(ps, WHIP) && ps.bounties.length === bounties + 2, 'both Arts used and picked after a perfect battle');
assert.equal(ps.personalChoice, null);
assert.equal(ps.round.arts, 2);
assert.equal(m.errorCount, 0);
m.dispose();
}
+27 -1
View File
@@ -4,7 +4,7 @@ import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { fieldModel, planLayout, rehearse, rangeTiles, REHEARSAL_VARIANTS, LAYOUT_PARAMS, botPickCard, botPickBand } from '../../server/match/bot.js';
import { FIELD, canPlace, placeClass, parseKey } from '../../server/match/board.js';
import { makeMatch, checkInvariants, give, DATA } from './harness.js';
import { makeMatch, checkInvariants, give, giveItem, DATA } from './harness.js';
const soloBot = (o = {}) => makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }], ...o });
@@ -222,6 +222,32 @@ test('a sliced rehearsal is dropped when the prep ends first; the default layout
m.dispose();
});
test('AI takeover resolves a personal choice in untimed PREP, including generator-error Ready cleanup', () => {
for (const broken of [false, true]) {
const h = makeMatch({ mode: 'coop', humans: 1, seed: 6, fake: true, botSliceMs: 0 }).start();
h.toPrep(1);
const m = h.m, ps = h.ps('p_0');
assert.equal(m.deadline, 0, 'single-human co-op is untimed');
const art = giveItem(m, ps, 'chess_item_6_03_m');
assert.deepEqual(ps.useArt(art.uid, 10, 5), { ok: true });
const pick = m.autoPickPersonalChoice.bind(m), modes = [];
m.autoPickPersonalChoice = (p, mode) => {
if (p.personalChoice) modes.push(mode);
if (broken && mode === 'bot') throw new Error('scripted choice scoring failure');
return pick(p, mode);
};
assert.deepEqual(m.handle(ps.playerId, { t: 'g.autoplay', on: true }), { ok: true });
assert.ok(h.run(() => m.phase !== PHASE.PREP));
assert.equal(ps.personalChoice, null);
assert.equal(ps.bounties.length, 1);
assert.equal(ps.round.arts, 1);
assert.ok(ps.ready);
assert.ok(modes.includes(broken ? 'random' : 'bot'));
assert.equal(m.errorCount > 0, broken);
m.dispose();
}
});
test('economy: the bot fills the board first (8 units by round 4), levels on its curve and keeps a hand slot free', () => {
for (const seed of [5, 8]) {
const h = soloBot({ seed }).start();
+41 -1
View File
@@ -4,7 +4,7 @@ import assert from 'node:assert/strict';
import { PHASE, ERR } from '../../shared/constants.js';
import { Match } from '../../server/match/Match.js';
import { StubMatch } from '../../server/match/StubMatch.js';
import { DATA, makeMatch, checkInvariants, give, chessOfTier } from './harness.js';
import { DATA, makeMatch, checkInvariants, give, giveItem, chessOfTier } from './harness.js';
import { bossPoolHp } from '../../server/match/finalAssault.js';
const STATUSES = ['acting', 'ready', 'deciding', 'combat', 'done', 'helping', 'left', 'dead'];
@@ -51,6 +51,7 @@ test('m.private shape in PREP matches DESIGN §8.3 (pieces, slots, bonds, effect
assert.ok(s.kind === 'chess' || s.kind === 'item');
}
assert.equal(priv.shop.rewardOffer, null);
assert.equal(priv.personalChoice, null);
assert.equal(priv.deployCap, 8);
assert.ok(priv.effects.some((e) => e.iconKind === 'band' && e.name));
assert.ok(priv.nextEnemies.length > 0);
@@ -109,6 +110,45 @@ test('disconnect: the seat keeps playing; draft turns / prep auto-resolve at dea
m.dispose();
});
test('教鞭: simultaneous private choices survive reconnect; foreign, duplicate and old IDs cannot add bounties', () => {
const h = makeMatch({ mode: 'coop', humans: 2, spectators: ['watcher'], seed: 63, fake: true }).start();
h.toPrep(1);
const m = h.m, a = h.ps('p_0'), b = h.ps('p_1');
for (const ps of [a, b]) {
const art = giveItem(m, ps, 'chess_item_6_03_m');
assert.deepEqual(ps.useArt(art.uid, 10, 5), { ok: true });
}
const aChoice = a.privateView().personalChoice, bChoice = b.privateView().personalChoice;
assert.notEqual(aChoice.id, bChoice.id);
assert.equal(m.handle('p_0', { t: 'g.choice', idx: 0, choiceId: bChoice.id }).error, ERR.BAD_TARGET);
assert.equal(a.bounties.length + b.bounties.length, 0);
m.onDisconnect('p_0');
m.onReconnect('p_0');
assert.deepEqual(h.lastTo('p_0', 'm.private').personalChoice, aChoice);
m.flush(true);
assert.deepEqual(h.lastTo('p_1', 'm.private').personalChoice, bChoice);
assert.ok(!h.allTo('watcher', 'm.private').length);
assert.ok(!JSON.stringify(m.publicView()).includes(aChoice.id));
assert.ok(!JSON.stringify(m.prepFieldMeta(a)).includes(aChoice.id));
assert.deepEqual(m.handle('p_0', { t: 'g.choice', idx: 1, choiceId: aChoice.id }), { ok: true });
m.flush(true);
assert.equal(h.lastTo('p_0', 'm.private').personalChoice, null);
assert.equal(m.handle('p_0', { t: 'g.choice', idx: 1, choiceId: aChoice.id }).error, ERR.BAD_TARGET);
const next = giveItem(m, a, 'chess_item_6_03_m');
assert.deepEqual(a.useArt(next.uid, 10, 5), { ok: true });
const nextId = a.personalChoice.id;
assert.notEqual(nextId, aChoice.id);
assert.equal(m.handle('p_0', { t: 'g.choice', idx: 0, choiceId: aChoice.id }).error, ERR.BAD_TARGET);
assert.equal(a.personalChoice.id, nextId);
assert.equal(a.bounties.length, 1);
m.onDisconnect('p_1');
h.sched.advance(m.deadline - h.sched.now() + 1);
m.onReconnect('p_1');
assert.equal(h.lastTo('p_1', 'm.private').personalChoice, null);
assert.equal(b.bounties.length, 1, 'offline choice picked at the existing PREP deadline');
m.dispose();
});
test('g.autoplay lets the bot play a human seat (buys, places, readies); turning it off returns control', () => {
const h = makeMatch({ mode: 'solo', seed: 64, fake: true }).start();
const m = h.m;
+2
View File
@@ -150,6 +150,8 @@ test('机变 (co-op): 6 shared cards, random order, 30 s first / 16 s others, ti
assert.deepEqual(sp.order.slice().sort(), ['p_0', 'p_1', 'p_2']);
assert.equal(Math.round((m2.deadline - h2.sched.now()) / 1000), 30, 'first picker 30 s');
const [a, b, c] = sp.order;
assert.equal(m2.handle(a, { t: 'g.choice', idx: 2, choiceId: 'old.choice.1' }).error, ERR.WRONG_PHASE);
assert.deepEqual(m2.sp.picks, {}, 'personal IDs never fall through to the global draft');
assert.equal(m2.handle(b, { t: 'g.choice', idx: 0 }).error, ERR.NOT_YOUR_TURN);
assert.deepEqual(m2.handle(a, { t: 'g.choice', idx: 2 }), { ok: true });
assert.equal(m2.handle(a, { t: 'g.choice', idx: 3 }).error, ERR.ALREADY);
+27
View File
@@ -214,6 +214,15 @@ test('ready gating: temp must be empty (a freed hand slot pulls the temp piece i
assert.equal(a.hand[3], t);
assert.ok(a.tempEmpty);
assert.equal(a.privateView().canReady, true);
const art = giveItem(m, a, 'chess_item_6_03_m', 'temp');
assert.deepEqual(a.useArt(art.uid, 10, 5), { ok: true });
assert.ok(a.tempEmpty);
assert.equal(a.privateView().canReady, false);
assert.deepEqual(a.setReady(true), { error: ERR.BAD_TARGET, detail: '请先完成教鞭选择' });
assert.deepEqual(a.setReady(false), { ok: true });
assert.deepEqual(m.handle('p_0', { t: 'g.choice', idx: 0, choiceId: a.personalChoice.id }), { ok: true });
assert.equal(a.privateView().canReady, true);
assert.equal(a.round.arts, 1);
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { ok: true });
assert.deepEqual(m.handle('p_0', { t: 'g.refresh' }), { error: ERR.WRONG_PHASE, detail: 'ready' });
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: false }), { ok: true });
@@ -235,13 +244,26 @@ test('prep deadline (co-op): unready players are auto-readied and their temp is
const a = h.ps('p_0');
const id = chessOfTier(1).find((x) => m.pool.has(x));
const left = m.pool.left(id);
const art = giveItem(m, a, 'chess_item_6_03_m');
assert.deepEqual(a.useArt(art.uid, 10, 5), { ok: true });
fillHand(a);
give(m, a, id, 'temp');
assert.ok(!a.tempEmpty, 'a full hand: it waits in temp');
const endPrep = m.endPrep.bind(m);
let checked = false;
m.endPrep = () => {
assert.equal(a.personalChoice, null, 'deadline picks before endPrep builds the battle');
assert.equal(a.bounties.length, 1);
assert.equal(a.round.arts, 1);
assert.ok(a.ready && a.tempEmpty);
checked = true;
endPrep();
};
assert.ok(m.deadline > h.sched.now(), 'co-op prep is timed');
assert.equal(Math.round((m.deadline - h.sched.now()) / 1000), m.gd.prepTime(1));
h.sched.advance(m.deadline - h.sched.now() + 1);
assert.notEqual(m.phase, PHASE.PREP);
assert.ok(checked);
assert.ok(a.tempEmpty);
assert.equal(m.pool.left(id), left, 'temp chess sold back to the pool');
checkInvariants(m);
@@ -252,7 +274,12 @@ test('solo prep and 机变 are untimed', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 13 }).start();
h.toPrep(1);
assert.equal(h.m.deadline, 0);
const ps = h.ps('p_0');
const art = giveItem(h.m, ps, 'chess_item_6_03_m');
assert.deepEqual(ps.useArt(art.uid, 10, 5), { ok: true });
const id = ps.personalChoice.id;
h.sched.advance(10 * 60 * 1000);
assert.equal(h.m.phase, PHASE.PREP);
assert.equal(ps.personalChoice.id, id, 'no separate personal-choice timer');
h.m.dispose();
});
+35 -1
View File
@@ -11,7 +11,7 @@ import { fieldModel } from '../../server/match/bot.js';
import { FIELD, parseKey } from '../../server/match/board.js';
import { createRng } from '../../server/sim/rng.js';
import { FakeBattle } from './fakeBattle.js';
import { DATA, makeMatch, checkInvariants } from './harness.js';
import { DATA, makeMatch, checkInvariants, giveItem } from './harness.js';
const bossFields = () => FakeBattle.instances.filter((b) => b.kind === 'boss' || b.kind === 'hidden');
@@ -90,6 +90,40 @@ test('perfect-payout bounties in the boss rounds: a 战术特训 card\'s coins j
m.dispose();
});
test('教鞭 in the boss rounds (R14, R15): the card the owner chose spawns in the boss battle and pays at the next income, the Hidden Core takes another choice', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 61, fake: true,
script: (b) => (b.kind === 'boss' || b.kind === 'hidden' ? { bossDps: 1e9, coins: { p_0: 3 } } : {}) }).start();
const m = h.m;
h.toPrep(14);
const ps = h.ps('p_0');
const art = giveItem(m, ps, 'chess_item_6_03_m');
assert.deepEqual(ps.useArt(art.uid, 10, 5), { ok: true });
assert.equal(ps.bounties.length, 0, 'nothing is applied before the owner confirms');
assert.equal(ps.setReady(true).error, 'BAD_TARGET', 'Ready waits for the choice');
const card = ps.personalChoice.cards[2];
assert.deepEqual(m.handle(ps.playerId, { t: 'g.choice', idx: 2, choiceId: ps.personalChoice.id }), { ok: true });
assert.equal(ps.bounties[0].card.effectId, card.effectId, 'the card of the tapped slot');
const pending = ps.pendingFunds;
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
m.hiddenLayerSum = 1e6;
assert.ok(bossFields()[0].opts.spawns.some((s) => s.tag === 'bounty' && s.enemyKey === card.enemyKey && s.ownerPlayerId === ps.playerId), 'the chosen card\'s enemies come in the boss battle');
h.run(() => m.runner === null);
assert.equal(ps.pendingFunds, pending + 3 + card.coin, 'kill coins + the chosen card\'s coins');
h.toPrep(15);
assert.equal(ps.funds, m.gd.income(15) + pending + 3 + card.coin, 'paid at the R15 income, apart from the base income');
const hiddenArt = giveItem(m, ps, 'chess_item_6_03_m');
assert.deepEqual(ps.useArt(hiddenArt.uid, 10, 5), { ok: true });
const hiddenCard = ps.personalChoice.cards[0];
assert.deepEqual(m.handle(ps.playerId, { t: 'g.choice', idx: 0, choiceId: ps.personalChoice.id }), { ok: true });
const owed = ps.bounties.filter((b) => b.card.payout === 'perfect').reduce((n, b) => n + b.card.coin, 0);
h.drive(() => m.phase === PHASE.HIDDEN_CORE);
assert.ok(bossFields().at(-1).opts.spawns.some((s) => s.tag === 'bounty' && s.enemyKey === hiddenCard.enemyKey));
h.runToEnd();
assert.equal(ps.pendingFunds, 3 + owed, 'the Hidden Core pays by the same rule');
checkInvariants(m);
m.dispose();
});
test('boss victory without a perfect own result keeps the kill coins but gives no perfect-card funds', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 61, fake: true,
script: (b) => (b.kind === 'boss' ? { bossDps: 1e9, leaks: { p_0: 1 }, coins: { p_0: 3 } } : {}) }).start();
+16
View File
@@ -397,6 +397,22 @@ test('collectViolations reports corrupted state (funds, pool accounting, duplica
m.dispose();
});
test('collectViolations: a 教鞭 choice belongs to its owner\'s open prep only', () => {
const { m, ps } = prepSolo({ seed: 922 });
const art = giveItem(m, ps, 'chess_item_6_03_m');
assert.deepEqual(ps.useArt(art.uid, 10, 5), { ok: true });
assert.deepEqual(collectViolations(m), [], 'an open choice in its own prep is fine');
const pc = ps.personalChoice;
pc.round = m.round - 1;
assert.match(collectViolations(m).join('\n'), /personal choice outside its own prep/, 'a choice left from an earlier round');
pc.round = m.round;
pc.cards = [pc.cards[0], pc.cards[0]];
assert.match(collectViolations(m).join('\n'), /personal choice of 2 cards/, 'the same card twice');
ps.personalChoice = null;
assert.deepEqual(collectViolations(m), []);
m.dispose();
});
test('the rule auditor flags an LP loss that does not match the leaks (engine regression guard)', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 921, fake: true, script: (b) => (b.kind === 'normal' ? { leaks: Object.fromEntries(b.players.map((p) => [p, 2])) } : {}) }).start();
const m = h.m;
+1
View File
@@ -122,6 +122,7 @@ export function makeMatch(o = {}) {
if (idx != null) m.handle(ps.playerId, { t: 'g.choice', idx });
}
if (ready && m.phase === 'PREP' && ps.alive && !ps.ready) {
if (ps.personalChoice) m.handle(ps.playerId, { t: 'g.choice', idx: 0, choiceId: ps.personalChoice.id });
if (!ps.tempEmpty) ps.resolveTemp();
m.handle(ps.playerId, { t: 'g.ready', ready: true });
}
+5
View File
@@ -42,6 +42,11 @@ test('protocol: g.unitStats { seq? } and g.bandFocus { bandId? } validate; m.uni
assert.equal(validateC2S({ t: 'g.bandFocus', bandId: 'band_bldsk' }), null);
assert.notEqual(validateC2S({ t: 'g.bandFocus', bandId: 'a b' }), null);
assert.ok(S2C.includes('m.unitStats'));
assert.equal(validateC2S({ t: 'g.choice', idx: 0 }), null);
assert.equal(validateC2S({ t: 'g.choice', idx: 2, choiceId: 'seed-2.choice.12' }), null);
for (const choiceId of [null, '', 'a b', 12, 'a'.repeat(65)]) {
assert.notEqual(validateC2S({ t: 'g.choice', idx: 0, choiceId }), null);
}
});
test('unitStatsEntry: effective stats next to the base, rounded for display; the interval from bat / aspd when absent', () => {
+27 -1
View File
@@ -11,7 +11,7 @@ import {
phaseMode, phaseBanner, isCombatPhase, isBossPhase, countdownState, phaseTotalSeconds, sortBonds, bondTier, nextThreshold,
battleOverSfx, ownRoundLoss, uniteResultBox, battleResultBox, roundResultBox, RESULT_BOX_MS,
bondMembers, memberHeadCount, bannedPerBond, priceTone, mergeProgress, shopBlockReason, deploySets, indexPieces, placementContext, canPlace,
boardTargets, dropIntent, normalizeDraft, normalizeSp, groupEnemies, factionTypes, snapHud, bossFrac, attackInterval, fmtNum,
boardTargets, dropIntent, normalizeDraft, normalizeSp, normalizePersonalChoice, groupEnemies, factionTypes, snapHud, bossFrac, attackInterval, fmtNum,
rangeGridBox, shortcutFor, sanitizeSettings, DEFAULT_SETTINGS, normalizeResult, cycleField, fieldLabel, homeFieldId,
activeBubbles, sortedPlayers, tileKey, prepCapsuleLabel, prepCamera, dropFailureReason, terrainInfo,
} from '../../public/js/ui/gameLogic.js';
@@ -532,6 +532,32 @@ describe('drafts', () => {
assert.equal(normalizeSp(null), null);
assert.equal(normalizeSp({ cards: new Array(9).fill({}) }).cards.length, 6, 'at most 6 cards');
});
test('personal choice: only the living recipient in the current PREP, independent of the public draft', () => {
const pub = { phase: PHASE.PREP, round: 14, sp: { family: 'supply', cards: ['global'] } };
const priv = { playerId: 'a', alive: true, canReady: false, temp: [], personalChoice: {
id: 'seed.choice.3', round: 14, sourceItemId: 'chess_item_6_03_m',
cards: [{ id: 'e1', kind: 'bounty', name: '战术特训', coin: 2 }, { id: 'e2', kind: 'bounty' }],
} };
const before = JSON.stringify({ pub, priv });
const sp = normalizePersonalChoice(pub, priv, 'a');
assert.equal(sp.id, priv.personalChoice.id);
assert.equal(sp.family, 'bounty');
assert.equal(sp.name, '教鞭 · 战术特训');
assert.equal(sp.turnPid, 'a');
assert.equal(sp.pickOf.size, 0);
assert.deepEqual(sp.cards.map((c) => c.idx), [0, 1]);
assert.ok(sp.cards.every((c) => c.takenBy === null));
assert.equal(JSON.stringify({ pub, priv }), before);
assert.equal(shopBlockReason('ready', { priv, editable: true }), '请先完成教鞭选择');
assert.equal(shopBlockReason('ready', { priv: { ...priv, temp: [{}] }, editable: true }), '请先完成教鞭选择');
assert.match(shopBlockReason('ready', { priv: { ...priv, personalChoice: null }, editable: true }), /临时整备区/);
for (const phase of [PHASE.SP_DRAFT, PHASE.COMBAT, PHASE.ROUND_START]) assert.equal(normalizePersonalChoice({ ...pub, phase }, priv, 'a'), null);
assert.equal(normalizePersonalChoice(pub, null, 'a'), null, 'spectator has no private state');
assert.equal(normalizePersonalChoice(pub, priv, 'b'), null);
assert.equal(normalizePersonalChoice(pub, { ...priv, alive: false }, 'a'), null);
assert.equal(normalizePersonalChoice({ ...pub, round: 15 }, priv, 'a'), null);
assert.equal(normalizePersonalChoice(pub, { ...priv, personalChoice: null }, 'a'), null);
});
});
describe('fields, players, emotes', () => {
+140 -1
View File
@@ -15,7 +15,7 @@
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -71,6 +71,16 @@ describe('in-match UI (mock harness, headless Chrome)', { skip: !ENABLED && 'set
}
const mockState = (page) => page.evaluate(() => JSON.parse(JSON.stringify(globalThis.__MOCK__.S().priv)));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const useWhip = (page) => page.evaluate(async () => {
const { net } = await import('/js/net.js');
const uid = 900000 + globalThis.__MOCK__.S().requests.length;
globalThis.__MOCK__.mutate((S) => {
const idx = S.priv.hand.findIndex((p) => !p);
S.priv.hand[idx] = { uid, kind: 'item', id: 'chess_item_6_03_m', tier: 6 };
});
await net.request('g.art', { itemUid: uid, row: 10, col: 5 });
return globalThis.__MOCK__.S().priv.personalChoice;
});
/** Swipe the open direction wheel from its centre towards `dir` and release (research 09 §1.2). */
const swipeWheel = async (page, dir = 'RIGHT') => {
await page.waitForSelector('.fwheel__dia', { timeout: 4000 });
@@ -345,6 +355,135 @@ describe('in-match UI (mock harness, headless Chrome)', { skip: !ENABLED && 'set
await page.close();
});
test('教鞭: PREP cards confirm twice, use its timer, reset on a new ID; old replies cannot clear a new busy pick', async () => {
const { page, problems } = await open('phase=PREP', { render: 'fallback' });
await page.click('.gm__gear');
await page.waitForSelector('.modal .set-keys');
await page.click('.set-key[data-action="ready"]');
await page.waitForSelector('.set-key.is-waiting[data-action="ready"]');
await page.keyboard.press('KeyW');
await page.waitForFunction(() => document.querySelector('.set-key[data-action="ready"]')?.textContent.trim() === 'W');
await page.click('.modal__actions .btn--primary');
await page.waitForSelector('.modal', { hidden: true });
const deadline = await page.evaluate(() => globalThis.__MOCK__.S().pub.deadline);
const first = await useWhip(page);
await page.waitForSelector('.spov[aria-label="教鞭选择"]');
assert.equal(await page.evaluate(() => globalThis.__MOCK__.S().pub.deadline), deadline);
assert.equal(await page.$('.spov__order'), null);
assert.ok(await page.$('.spov .countdown'));
assert.doesNotMatch(await page.$eval('.spov', (el) => el.textContent), /机变阶段|当前轮到|正在决策/);
assert.equal(await page.$eval('.readybtn', (el) => el.disabled), true);
assert.match(await page.$eval('[data-testid="ready-why"]', (el) => el.textContent), /请先完成教鞭选择/);
await page.evaluate(async () => { await (await import('/js/ui/lang.js')).loadLangIndex(); });
for (const lang of ['en', 'ja', 'ko', 'zh-TW']) {
const messages = JSON.parse(readFileSync(path.join(ROOT, `public/i18n/${lang}.json`), 'utf8'));
await page.evaluate(async (code) => {
await (await import('/js/ui/lang.js')).switchLang(code);
globalThis.__MOCK__.pushPublic(); // this harness has no App/useLang root; render again with the active language
}, lang);
await page.waitForFunction((label) => document.querySelector('.spov')?.getAttribute('aria-label') === label, {}, messages['教鞭选择']);
assert.equal(await page.$eval('.spov__title', (el) => el.textContent), `${messages['教鞭 · 战术特训']}|${messages['请选择一项战术特训']}`);
assert.match(await page.$eval('.spov__sub', (el) => el.textContent), new RegExp(messages['休整期结束时未选择将自动选定']));
assert.equal(await page.$eval('[data-testid="ready-why"]', (el) => el.textContent), messages['请先完成教鞭选择']);
assert.equal((await mockState(page)).personalChoice.id, first.id, 'language switch keeps the same pending offer');
}
await page.evaluate(async () => { await (await import('/js/ui/lang.js')).switchLang('zh'); globalThis.__MOCK__.pushPublic(); });
await page.waitForSelector('.spov[aria-label="教鞭选择"]');
const requests = await page.evaluate(() => globalThis.__MOCK__.S().requests.length);
for (const key of ['KeyW', 'KeyR', 'KeyF', 'KeyD', 'KeyQ', 'KeyX']) await page.keyboard.press(key);
await sleep(150);
assert.equal(await page.evaluate(() => globalThis.__MOCK__.S().requests.length), requests, 'background shortcuts send no intents');
assert.match(await page.$$eval('.toast', (els) => els.map((el) => el.textContent).join('\n')), /请先完成教鞭选择/);
assert.equal(await page.$eval('.readybtn__key', (el) => el.textContent), 'W');
await page.click('.spcard.is-pickable');
await page.waitForSelector('.spcard.is-armed');
await sleep(200); // the overlay attaches its Escape listener in an effect, a frame after the highlight shows
await page.keyboard.press('Escape');
await page.waitForFunction(() => !document.querySelector('.spcard.is-armed'));
assert.equal((await mockState(page)).personalChoice.id, first.id, 'Escape only removes the highlight');
await page.screenshot({ path: path.join(OUT, 'ui-personal-choice-1920.png') });
await page.evaluate(async (id) => {
const { net } = await import('/js/net.js');
const request = net.request;
globalThis.__choiceReplies = {};
net.request = async (t, fields) => {
if (t !== 'g.choice' || fields.choiceId === undefined) return request(t, fields);
if (fields.choiceId === id) {
const result = await request(t, fields);
await new Promise((resolve) => { globalThis.__choiceReplies[fields.choiceId] = resolve; });
return result;
}
await new Promise((resolve) => { globalThis.__choiceReplies[fields.choiceId] = resolve; });
return request(t, fields);
};
}, first.id);
await page.click('.spcard.is-pickable');
assert.equal((await mockState(page)).personalChoice.id, first.id, 'one tap does not apply it');
await page.click('.spcard.is-armed');
await page.waitForFunction(() => !document.querySelector('.spov'));
const firstRequest = await page.evaluate(() => globalThis.__MOCK__.S().requests.filter(([t]) => t === 'g.choice').at(-1));
assert.deepEqual(firstRequest, ['g.choice', { idx: 0, choiceId: first.id }]);
const second = await useWhip(page);
await page.waitForSelector('.spov');
await page.click('.spcard.is-pickable');
await page.waitForSelector('.spcard.is-armed');
const thirdId = second.id + '.next';
await page.evaluate((id) => globalThis.__MOCK__.mutate((S) => { S.pub.deadline = 0; S.priv.personalChoice.id = id; }), thirdId);
await page.waitForFunction(() => !document.querySelector('.spcard.is-armed'));
assert.equal(await page.$('.spov .countdown'), null, 'co-op PREP without a deadline is untimed');
await page.click('.spcard.is-pickable');
await page.click('.spov__confirm');
await page.waitForSelector('.spcard.is-busy');
await page.waitForFunction((id) => !!globalThis.__choiceReplies[id], {}, first.id);
await page.evaluate((id) => globalThis.__choiceReplies[id](), first.id);
await sleep(150);
assert.ok(await page.$('.spcard.is-busy'), 'old reply leaves the newer choice busy');
await page.evaluate((id) => globalThis.__choiceReplies[id](), thirdId);
await page.waitForFunction(() => !document.querySelector('.spov'));
assert.equal((await mockState(page)).personalChoice, null);
assert.deepEqual(problems, []);
await page.close();
});
test('教鞭 appearing cancels the uncommitted direction wheel and active drag, releasing its held piece', async () => {
const { page, problems } = await open('phase=PREP', { render: 'fallback' });
const s = await mockState(page);
const hand = s.hand.find((p) => p && p.kind === 'chess');
const target = s.board.find((p) => p.kind === 'chess' && p.row === 9);
const selector = `.ff-piece[data-uid="${hand.uid}"]`;
const center = (sel) => page.$eval(sel, (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });
const homeStyle = await page.$eval(selector, (el) => el.style.transform);
const from = await center(selector), to = await center(`.ff-piece[data-uid="${target.uid}"]`);
await page.mouse.move(from.x, from.y);
await page.mouse.down();
await page.mouse.move(to.x, to.y, { steps: 12 });
await sleep(80);
await page.mouse.up();
await page.waitForSelector('.fwheel');
await useWhip(page);
await page.waitForSelector('.spov');
await page.waitForFunction(() => !document.querySelector('.fwheel'));
await page.waitForFunction((sel, transform) => document.querySelector(sel)?.style.transform === transform, {}, selector, homeStyle);
assert.equal(await page.$eval(selector, (el) => el.style.transform), homeStyle, 'cancelFacing released the held bench piece');
assert.equal(await page.$('.ff-piece.is-draggable'), null);
await page.click('.spcard.is-pickable');
await page.click('.spov__confirm');
await page.waitForFunction(() => !document.querySelector('.spov'));
const current = await center(selector);
await page.mouse.move(current.x, current.y);
await page.mouse.down();
await page.mouse.move(current.x + 90, current.y - 40, { steps: 8 });
await page.waitForSelector('.gm.is-dragging');
await useWhip(page);
await page.waitForSelector('.spov');
await page.waitForFunction(() => !document.querySelector('.gm.is-dragging, .ff-piece.is-lifted'));
await page.mouse.up();
assert.deepEqual((await mockState(page)).board, s.board);
assert.ok(!(await page.evaluate(() => globalThis.__MOCK__.S().requests.some(([t]) => t === 'g.move'))));
assert.deepEqual(problems, []);
await page.close();
});
// ---- round-1 UI fixes ----------------------------------------------------------------------------------------
const rectOf = (page, sel) => page.$eval(sel, (el) => { const r = el.getBoundingClientRect(); return { left: r.left, top: r.top, right: r.right, bottom: r.bottom }; });
const overlaps = (a, b) => !!a && !!b && a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
+42 -1
View File
@@ -345,7 +345,7 @@ describe('user playtest #2 item 10 — boss-round prep on the boss field (real s
});
test('co-op 险境 (R14): host on the left half, guest on the mirrored right half; scouting and 返回 keep the pen hidden', { skip: only('coop'), timeout: 10 * 60 * 1000 }, async () => {
const srv = await startRealServer({ fast: { timerScale: 0.5, combatSpeed: 8, startRound: 'boss' } });
const srv = await startRealServer({ fast: { timerScale: 1, combatSpeed: 8, startRound: 'boss', items: ['chess_item_6_03_m'] } });
const P = await pptr();
const host = new Client(P, srv.base, 'host', { prefix: 'fix' });
const guest = new Client(P, srv.base, 'guest', { prefix: 'fix', w: 1280, h: 720 });
@@ -412,6 +412,47 @@ describe('user playtest #2 item 10 — boss-round prep on the boss field (real s
assert.deepEqual(back.prepField, { kind: 'bossPrep', side: 'L', mirror: false }, '返回: the own boss half again');
}
await penRoundTrip(guest, 'guest');
// Real clients open their own 教鞭 candidates together; refreshing one tab restores that same private choice.
const choiceOf = (c) => c.page.evaluate(() => JSON.parse(JSON.stringify(globalThis.__SP__.store.get().match.private.personalChoice)));
const deadline = await host.page.evaluate(() => globalThis.__SP__.store.get().match.public.deadline);
await Promise.all([[host, hp], [guest, gp]].map(async ([c, placed]) => {
const art = (await c.handPieces('item')).find((p) => p.id === 'chess_item_6_03_m');
assert.ok(art, `${c.label}: starter 教鞭`);
await c.drag(await c.piecePoint(art.uid), await c.tilePoint(placed.row, placed.col));
await c.page.waitForSelector('.spov[aria-label="教鞭选择"]', { timeout: 6000 });
}));
const hostChoice = await choiceOf(host), guestChoice = await choiceOf(guest);
assert.notEqual(hostChoice.id, guestChoice.id);
for (const c of [host, guest]) {
assert.equal((await choiceOf(c)).cards.length, 3);
assert.equal((await c.st()).canReady, false);
assert.equal(await c.page.$('.spov__order'), null);
assert.equal(await c.page.evaluate(() => globalThis.__SP__.store.get().match.public.deadline), deadline);
}
await host.page.reload({ waitUntil: 'networkidle0' });
await host.page.waitForSelector('.spov[aria-label="教鞭选择"]', { timeout: 20000 });
assert.deepEqual(await choiceOf(host), hostChoice, 'refresh reuses the ID and cards');
assert.deepEqual(await choiceOf(guest), guestChoice, 'the teammate keeps its own candidates');
await host.page.evaluate(async () => { await (await import('/js/ui/lang.js')).switchLang('en'); });
await host.page.waitForSelector('.spov[aria-label="Pointing Stick Choice"]');
assert.match(await host.page.$eval('.spov__title', (el) => el.textContent), /Pointing Stick · Tactical Training/);
assert.equal(await host.page.$eval('[data-testid="ready-why"]', (el) => el.textContent), 'Complete the Pointing Stick choice first');
assert.equal(await guest.page.$eval('.spov', (el) => el.getAttribute('aria-label')), '教鞭选择', 'language stays per client');
assert.deepEqual(await choiceOf(host), hostChoice, 'language switch does not replace the offer');
await host.page.evaluate(async () => { await (await import('/js/ui/lang.js')).switchLang('zh'); });
await host.page.waitForSelector('.spov[aria-label="教鞭选择"]');
const refused = await host.page.evaluate(async (id) => {
try { await globalThis.__SP__.net.request('g.choice', { idx: 0, choiceId: id }); return null; }
catch (e) { return e.code; }
}, guestChoice.id);
assert.equal(refused, 'BAD_TARGET', 'a live client cannot submit the teammate\'s ID');
await host.shot('dobermann-private-reconnect');
for (const c of [host, guest]) {
await c.click('.spcard.is-pickable');
assert.ok(await choiceOf(c), 'first tap only highlights');
await c.click('.spov__confirm');
await c.page.waitForFunction(() => globalThis.__SP__.store.get().match.private.personalChoice === null, { timeout: 6000 });
}
for (const c of [host, guest]) await c.click('.readybtn');
await checkBossBattle(host, hp, 'L', 'host');
await checkBossBattle(guest, gp, 'R', 'guest');
+25 -1
View File
@@ -222,6 +222,18 @@ describe('3: the temp overflow row (临时整备区)', () => {
assert.deepEqual(tempInfo(null), { count: 0, items: 0 });
});
test('Ready shows the personal-choice reason and describes it even with no temp pieces', () => {
for (const temp of [[], [{ uid: 1, kind: 'item' }]]) {
const v = ReadyToggle({ priv: { ...priv(temp), canReady: false, personalChoice: { id: 'choice.1' } }, onToggle() {} });
const nodes = [...walk(v)];
const btn = nodes.find((n) => n.type === 'button');
assert.equal(btn.props.disabled, true);
assert.equal(btn.props['aria-describedby'], 'readywrap-why');
assert.equal(textOf(nodes.find((n) => hasClass(n, 'readywrap__why'))), '请先完成教鞭选择');
assert.equal(nodes.find((n) => n.type?.name === 'Tooltip').props.text, '请先完成教鞭选择');
}
});
test('the row frame: the outer corners of both end tiles, the label on the side with room (mirrored Final Assault prep too)', () => {
// tileScreen polys: [back-left, back-right, front-right, front-left] (render/app.js tileScreen corner order)
const tile = (cx, y, w = 100, d = 60) => ({ x: cx, y, s: w, poly: [[cx - w / 2 + 6, y - d / 2], [cx + w / 2 - 6, y - d / 2], [cx + w / 2, y + d / 2], [cx - w / 2, y + d / 2]] });
@@ -258,7 +270,7 @@ describe('3: the temp overflow row (临时整备区)', () => {
test('while the ready button shows its reason the effects column moves down a line (on phones the ready count ran into it)', () => {
const game = read('public/js/screens/game.js');
// the same condition as ReadyToggle's reason: PREP, alive, not ready, pieces in the row
assert.match(game, /const readyWhy = phase === PHASE\.PREP && alive && !priv\?\.ready && temp\.count > 0;/);
assert.match(game, /const readyWhy = phase === PHASE\.PREP && alive && !priv\?\.ready && \(hasPersonalChoice \|\| temp\.count > 0\);/);
assert.match(game, /readyWhy && 'has-readywhy'/);
const css = read('public/css/screens/game.css');
assert.match(css, /\.gm__effects \{ position: absolute; right: \.3rem; top: 2\.4rem; \}/);
@@ -389,6 +401,18 @@ describe('9: busy indicators and data loading', () => {
assert.ok(![...walk(landed)].some((n) => hasClass(n, 'spcard__busy')));
});
test('choice requests carry the personal ID while global requests keep their existing shape', async () => {
const { net } = await import('../../public/js/net.js');
const { actions } = await import('../../public/js/ui/gameActions.js');
const request = net.request, sent = [];
net.request = async (t, fields) => { sent.push([t, fields]); };
try {
assert.equal(await actions.choice(0), true);
assert.equal(await actions.choice(2, 'seed.choice.3'), true);
assert.deepEqual(sent, [['g.choice', { idx: 0 }], ['g.choice', { idx: 2, choiceId: 'seed.choice.3' }]]);
} finally { net.request = request; }
});
test('the "作战结束,等待队友完成作战" hourglass stands still', () => {
const css = read('public/css/screens/game.css');
assert.doesNotMatch(css, /hg-flip/);
+22
View File
@@ -130,6 +130,28 @@ describe('2: 机变 cards take two taps (select → confirm), like buying in the
assert.match(textOf(lone.find((n) => hasClass(n, 'spov__sub'))), /无时间限制/);
});
test('教鞭 ChoiceView: personal heading, no global order or turn; timing follows PREP deadline', () => {
const s = sp({ name: '教鞭 · 战术特训', desc: '请选择一项战术特训', cards: sp().cards.slice(0, 2) });
for (const [solo, deadline] of [[false, 0], [true, 123456]]) {
let confirmed = false;
const v = ChoiceView({ pub: { players: [{ playerId: 'me', name: 'Me' }, { playerId: 'p2', name: 'P2' }], deadline }, sp: s,
...me, solo, personal: true, armed: 0, total: 74, onConfirm: () => { confirmed = true; } });
const nodes = [...walk(v)];
assert.equal(v.props['aria-label'], '教鞭选择');
assert.ok(!nodes.some((n) => hasClass(n, 'spov__order')));
assert.doesNotMatch(textOf(v), /机变阶段|当前轮到|正在决策/);
const clock = nodes.find((n) => n.type?.name === 'Countdown');
assert.equal(!!clock, deadline > 0);
if (clock) { assert.equal(clock.props.deadline, deadline); assert.equal(clock.props.total, 74); }
assert.match(textOf(nodes.find((n) => hasClass(n, 'spov__sub'))), deadline ? /休整期结束时未选择将自动选定/ : /无时间限制/);
const btn = nodes.find((n) => n.type === Button);
btn.props.onClick();
assert.ok(confirmed);
assert.match(btn.props.title, /确认选择「A」/);
assert.match(nodes.find((n) => hasClass(n, 'spcard')).props.title, /^A\na$/);
}
});
test('the overlay keeps the selection itself and drops it on Esc / a tap elsewhere (source contract)', () => {
const src = read('public/js/ui/choiceOverlay.js');
assert.match(src, /export function ChoiceOverlay\(props\) \{[\s\S]*useState\(null\)[\s\S]*armedCard\(sel, sp/);