feedback7: merge fb7-dobermann — 教鞭 is the official three-choice (PR #353 by @Sukvii, the owner's decision), the Final Assault / Hidden Core pay a 战术特训 card's perfect coins (guarded by b.progress leaksBy), the bots put zero-range heavy guards on the road first (PR #339 by @IceCodeNew, placement only)

- Conflicts (the branch started at ae22335c): spec.js / protocol.js b.progress (resolved from fb7-ui-backlog + leaksBy),
  gameLogic.js / game.js exports (device tips + normalizePersonalChoice), docs/PLAYING.md / design/network.md lines
  merged against the base, META.md (画卷 note + 教鞭), test/docs-consistency.test.js pin (both fields).
- Grok review: MERGE AFTER FIXES — the HIGH (a forged boss result paid the cards) fixed on the branch (66a228c6), the
  META wording (1ab12763).
- Golden: matches 10 (the 杜宾 bot picks and the guard placement); union: coop3-ABYSS-5 differs from HEAD only in ai_2's
  dmgDealt (1238011 → 1186269), no outcome change — HEAD's side had moved it, the new placement applies on that path.
- Broad node --test 5623 (5621 pass, 0 fail, 2 skipped); lint 0 errors; typecheck; i18n strict clean.
This commit is contained in:
sganggs committed 2026-10-08 22:11:49 +08:00
commit 2de2feee3c
57 files changed
+1339 -276

No files matched your search

+3 -2
View File
@@ -393,12 +393,13 @@ heuristics CPU 36 / 88 → 35 / 86 ms and 16 / 40 → 16 / 40 ms.
* Bounties with battles left (multi-round cards — "之后的每场作战" — last two battles since playtest #6, META §1.2) * Bounties with battles left (multi-round cards — "之后的每场作战" — last two battles since playtest #6, META §1.2)
also spawn in the Final Assault / Hidden Core, on the owner's half (route to its goal), are shown in the boss-round also spawn in the Final Assault / Hidden Core, on the owner's half (route to its goal), are shown in the boss-round
preview, and the boss battle uses up one of their battles (kill coins → pending funds for the Hidden Core prep). preview, and the boss battle uses up one of their battles (kill coins, and a perfect-payout 战术特训 card's coins when the player's own field is perfect and the team wins → pending funds for the Hidden Core prep).
* A 驰援 card is never offered when its bond has no chess left in the match's pool (every member banned). * A 驰援 card is never offered when its bond has no chess left in the match's pool (every member banned).
* docs/META.md §2.6 example guards `ctx.source.kind === 'choice'` (its EffectRef reuses the handler's key). * 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 * 坚若磐石 = least LP lost (`titles.comment_3 { stat: 'lpLost', rule: 'min' }` in tuning.json; results.js supports
`rule: 'min'` among the players still alive). `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.
--- ---
+59 -19
View File
@@ -175,18 +175,18 @@ A `choice:<effectId>` registry handler overrides the default application (§2.4)
### 1.3 Disconnects, AI takeover ### 1.3 Disconnects, AI takeover
* Disconnected human: the seat keeps playing its last lineup; drafts auto-resolve at their deadlines, prep auto-readies at * 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 (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` 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` (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. 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 / * `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 §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 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 the fight starts — a departed seat no longer counts, DESIGN §25.13.4); its
running normal battle is force-ended; a pending band pick 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 When no human is left at all the match ends immediately (`reason: 'abandoned'`); when only eliminated spectators are
left it ends as `'eliminated'`. 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 never sells a piece gained since the bot's last prep ended, `rememberOwned`), 身份牌 / 通讯机 / 寻呼模块 on a focus
member; 画卷 copies the most member; 画卷 copies the most
valuable deployed operator; 教鞭 / “神秘顾客” are used after a perfect battle and otherwise kept in the hand — a bot drops 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 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 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 multiplier, DEF / RES, speed, route kind) — an expected-value comparison: it prefers the card with the best payout
@@ -275,7 +276,9 @@ its enemies; an exposure model (time on each tile × DPS against the round's arm
unit's cover is the range it is deployed with under the player's loadout, `rangeRec` = `attackRangeGrid`, the grid the unit's cover is the range it is deployed with under the player's loadout, `rangeRec` = `attackRangeGrid`, the grid the
server's `summonRange` and the card use —, blocker hold time, anti-air only on flying routes) is maximized greedily server's `summonRange` and the card use —, blocker hold time, anti-air only on flying routes) is maximized greedily
(blockers, then damage dealers, then healers) over the server's deploy map (no 深水区, PlayerState.deployMap); a (blockers, then damage dealers, then healers) over the server's deploy map (no 深水区, PlayerState.deployMap); a
tactician's 援军 goes on a tile of the tactician's attack range (`PlayerState.summonRange`, the server's own rule; player blocker whose range is its own tile only (range 0-1, the one grid (0,0): 角峰, 古米, 泡泡, 折桠, 菲莱, 蛇屠箱, 塞雷娅, 余) takes a free
tile of the enemy road first — it blocks and hits nothing off it — and only when the road has none free any legal tile (0.2.2, PR #339 by
@IceCodeNew); a tactician's 援军 goes on a tile of the tactician's attack range (`PlayerState.summonRange`, the server's own rule; player
report #9 after 0.1.0), and a tile the server's `g.move` refuses is skipped for the next best one (the planner reads report #9 after 0.1.0), and a tile the server's `g.move` refuses is skipped for the next best one (the planner reads
`board.js legalTiles` on the deploy map; `g.move` is the judge). Boss `board.js legalTiles` on the deploy map; `g.move` is the judge). Boss
rounds: the player's boss-field template (`Match.bossWaves`) is mapped onto the own board (rows −7, the right player rounds: the player's boss-field template (`Match.bossWaves`) is mapped onto the own board (rows −7, the right player
@@ -425,6 +428,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`) | | `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` | | `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 | | `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 | | `toast(text, kind)` / `ticker(text)` / `giftTicker(fromName, chessId)` | messages |
| `teammates()` / `player(playerId)` | ctx objects of other alive players (team effects) | | `teammates()` / `player(playerId)` | ctx objects of other alive players (team effects) |
@@ -458,20 +462,47 @@ bilibili BV1vzyVBuEN9 ≈ 8:24 / BV1Qkw1zMEoR ≈ 7:25, pointed out in PR #2 —
is not consumed: PRTS 下半 记录 备注 "生效时,原干员销毁,获得一名高一阶的随机初始干员(最高六阶)", PRTS 卫戍协议/帮助 "佩戴的 is not consumed: PRTS 下半 记录 备注 "生效时,原干员销毁,获得一名高一阶的随机初始干员(最高六阶)", PRTS 卫戍协议/帮助 "佩戴的
装备无法手动卸除,在失去该干员(干员出售、销毁、合并等)…时自动卸除", players re-inject it every round; player feedback after 装备无法手动卸除,在失去该干员(干员出售、销毁、合并等)…时自动卸除", players re-inject it every round; player feedback after
0.1.0), 画卷 (copy the operator in range with its 0.1.0), 画卷 (copy the operator in range with its
items — the copied items arrive unequipped in the hand, PRTS 画卷 备注 "获得的装备为未装备状态"), 教鞭 / “神秘顾客” (a random bounty is added). items — the copied items arrive unequipped in the hand, PRTS 画卷 备注 "获得的装备为未装备状态"), 教鞭 (a personal choice of three 战术特训 cards), “神秘顾客” (a random bounty is added).
**教鞭 / “神秘顾客” stay a random bounty (deliberate).** The official Arts open a personal 悬赏 choice **教鞭 offers a personal choice of three 战术特训 cards (0.2.2); “神秘顾客” stays a random bounty.** The official Arts open a
("选择一项(特殊)悬赏任务进行挑战", `choice_event hunter_band_1`). The server applies a random bounty instead: content personal 悬赏 choice ("选择一项(特殊)悬赏任务进行挑战", `choice_event hunter_band_1`, choiceType `PERSONAL_CHOOSE`). 教鞭
(server/sim/content/items/meta.js) draws 3 cards and takes one at random — 教鞭 from the 20 战术特训 cards (payout (PRTS 下半 记录 §法术 教鞭 "使用后销毁,于3个战术特训的悬赏任务中选择一项"; §机变阶段 "※以下悬赏任务仅由法术教鞭生成"; 杜宾
`perfect`: extra enemies, the card's coins when the own phase is perfect; PRTS 下半 记录 §法术 教鞭 "于3个战术特训的悬赏 加练! "<教鞭>:使用后为下场战斗添加额外敌人,若自身战斗完美作战可获得资金"; user playtest #6 item 4 review) offers three of
任务中选择一项", §机变阶段 "※以下悬赏任务仅由法术教鞭生成", 杜宾 加练! "若自身战斗完美作战可获得资金"; user playtest #6 the 20 战术特训 cards (payout `perfect`: extra enemies, the card's coins when the own phase is perfect), each limited to
item 4 review), “神秘顾客” (granted by nothing in act2) from the band-bounty family `enemyeffect_b_*` [ASSUMED] — each enemies the mode can field. Until 0.2.1 the server took one of the three at random — a deliberate simplification: a
limited to enemies the mode can field (the built-in fallback: a random tier ≤ II bounty of `cards.bounty`), and adds it personal choice looked like it needed a phase of its own, a protocol message outside SP_DRAFT, a timer and an AI
with `ctx.addBounty`. A personal choice overlay would need its own phase / protocol message outside SP_DRAFT (a takeover for a rarely used Art. The owner decided on 2026-10-08 to follow the official choice (PR #353 by @Sukvii), and
second, simultaneous draft in co-op, with timers and AI takeover) for a rarely used Art, while the random pick keeps the it fits in the existing parts:
risk / reward the item is about. The
bounty then behaves like any other (next battles, 联防 payouts, Final Assault spawns). 神秘顾客's destroy clause (+1 * **Offer.** Content (server/sim/content/items/meta.js) calls `ctx.offerBountyChoice(cards, itemId)` (`Match.offerBountyChoice`,
fund, the Art passes to the next alive player) is content too (`onDestroy`). 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 or a human under AI 托管 — picks like the bots do; a seat that
left is eliminated (`onLeave` → `eliminate`), which drops its open offer: nobody picks for it.
* **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` (整备), 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 `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 `afterElimination: true` (its `onRoundStart` still runs: `EffectDispatcher.dispatchEliminated`, called by
@@ -619,7 +650,16 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
`enemyScale[r]` × the tuning layer §3.1, bounties, boss templates, 联防 routing). Bounties with battles left (every one `enemyScale[r]` × the tuning layer §3.1, bounties, boss templates, 联防 routing). Bounties with battles left (every one
that lasts more than one battle; an official multi-round card lasts two, §1.2) also spawn in the Final Assault / that lasts more than one battle; an official multi-round card lasts two, §1.2) also spawn in the Final Assault /
Hidden Core, on the owner's half of the boss field (a route ending at its goal); the boss battle then uses up one of Hidden Core, on the owner's half of the boss field (a route ending at its goal); the boss battle then uses up one of
the bounty's battles and its kill coins go to pending funds. the bounty's battles and its kill coins go to pending funds — with, for every perfect-payout card (战术特训), its coins
as after a normal battle ("若各自行动阶段就达成完美作战,获得N资金"): once per player, when the team won (the first end the
server registered: the leader's pool empty), the result is a real one (not the stand-in of a field that never
reported) and the player's own field let no counted enemy through [ASSUMED: the text has no boss-battle exception, and
the card's enemies do come in the boss battle] — and, for a field a client reported (the boss path never re-simulates
it), the result agrees with what its authority reported while it fought: LP it asked the team to pay must have been
split in `b.progress.leaksBy` (per player, the enemy leaks; the rest is the leader's own "扣除目标生命" effects), and a
player's leaked list must show at least its reported leak LP (`Match._bossLeaksAgree`), so a result cannot hide a charged
leak or hand it to the other seat; a server-run field is the truth itself. They are spent in the Hidden Core's prep like
the kill coins.
* **Combat time limit**: data `combatTimeLimit` (the level's `maxPlayTime`) counts REAL seconds of the forced 2× * **Combat time limit**: data `combatTimeLimit` (the level's `maxPlayTime`) counts REAL seconds of the forced 2×
battle; the Battle / 联防 limit is `gd.combatTimeLimit(r)` = 2 × that in game seconds (`config.combatTimeScale`, battle; the Battle / 联防 limit is `gd.combatTimeLimit(r)` = 2 × that in game seconds (`config.combatTimeScale`,
default 2). Read as game seconds the rounds' own spawn schedules would not fit (R2's last flyer spawns at 43 s of default 2). Read as game seconds the rounds' own spawn schedules would not fit (R2's last flyer spawns at 43 s of
+1 -1
View File
@@ -49,7 +49,7 @@
| 招募价格(按阶级 I–VI) | 2 / 3 / 3 / 3 / 4 / 4 | | 招募价格(按阶级 I–VI) | 2 / 3 / 3 / 3 / 4 / 4 |
| 刷新 | 1 资金 | | 刷新 | 1 资金 |
| 出售干员 | 任何干员(含精锐)都只返还 **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→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. - 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). - 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. - 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).
--- ---
+13 -2
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: random order (「AI 队友最后选择」, off by default: humans first, the same partition as the strategy draft), 30 s first picker / 16 s others; solo 3 cards; solo / single human untimed; [SP_DRAFT] (if r ∈ spRounds: 机变 draft; co-op: random order (「AI 队友最后选择」, off by default: humans first, the same partition as the strategy draft), 30 s first picker / 16 s others; solo 3 cards; solo / single human untimed;
a card is picked with two taps, §18.2) 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 (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 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)) 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) 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 board: Map<'r,c', Piece>, // chess & token pieces on the normal field
bonds: { [bondId]: { count, active, tier, layers } }, // layers persist all match bonds: { [bondId]: { count, active, tier, layers } }, // layers persist all match
effects: [ EffectRef ], // band/choice/team/garrison persistent effects & counters 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, … }, counters: { refreshes, buys, sells, … },
stats: { … for results } } stats: { … for results } }
Slot = { kind:'chess'|'item', id, price, sold:false, frozen } 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). **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) ### 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). - `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,7 +89,10 @@ Per match: pick stage (weighted among the mode's `config.modes[m].stages`, weigh
### 6.6 AI players (bot.js) ### 6.6 AI players (bot.js)
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). Pending Pointing Stick offers use the existing bounty scorer at bot prep entry, immediately after a successful cast,
and before Ready. The prep's deadline — and the scheduler's fallback when a step of a bot's prep fails — randomly resolves any remaining offer before temp and Ready; a seat that left is eliminated and its offer dropped.
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 blocker whose range is its own tile only — range 0-1 — takes a free tile of the enemy road first; 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).
--- ---
+4 -3
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, at any time — the room screen's strip and, once the match runs, the game screen's 观战席 capsule in the top bar, GitHub #120) frees one (`room.closed {reason:'kicked'}` to it, no match frame after it); `room.state.spectators: [{playerId, name, connected}]` (still pushed while the match runs). 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. **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, at any time — the room screen's strip and, once the match runs, the game screen's 观战席 capsule in the top bar, GitHub #120) frees one (`room.closed {reason:'kicked'}` to it, no match frame after it); `room.state.spectators: [{playerId, name, connected}]` (still pushed while the match runs). 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 ### 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: 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...], progress?:{killed, resolved, total, done} /* §14 顶栏胶囊: resolved = the field's own scheduled enemies knocked down or leaked, null while unknown */}] }` · `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.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...], progress?:{killed, resolved, total, done} /* §14 顶栏胶囊: resolved = the field's own scheduled enemies knocked down or leaked, null while unknown */}] }` · `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). - `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).
@@ -41,7 +41,8 @@ S→C:
`m.private`: `m.private`:
```js ```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, shop: { level, maxLevel: 6, upgradePrice, refreshPrice /* effective (0 when a free refresh is available) */, freeRefreshes,
frozen, slots: [ { kind: 'chess'|'item', id, price, basePrice, sold, frozen } | null ], // slot.frozen: the 冻结 toggle copies onto every unsold slot, and 梓兰's 猎头顾问 freezes ONE card of an active refresh with the toggle off — every card draws its own flag (GitHub #354) frozen, slots: [ { kind: 'chess'|'item', id, price, basePrice, sold, frozen } | null ], // slot.frozen: the 冻结 toggle copies onto every unsold slot, and 梓兰's 猎头顾问 freezes ONE card of an active refresh with the toggle off — every card draws its own flag (GitHub #354)
rewardOffer: null | { tier, source: 'merge'|'special', label, queued, slots: [ { kind: 'chess'|'item', id, price: 0, sold } ] } }, // §21.2 rewardOffer: null | { tier, source: 'merge'|'special', label, queued, slots: [ { kind: 'chess'|'item', id, price: 0, sold } ] } }, // §21.2
@@ -83,7 +84,7 @@ Piece = { uid, kind: 'chess'|'item'|'token', id, golden: bool, tier, items: [ {
**Protocol (additions; `shared/protocol.js` is normative).** **Protocol (additions; `shared/protocol.js` is normative).**
- `battleId` = `<prefix>.<round>.<seq>.<fieldId>` (`Match._ccField`; `<prefix>` = the match seed in base 36 plus `-<matchNo>`, the room's match number the lobby passes as `opts.matchNo`; `<seq>` counts every battle of the match; the informational `.<fieldId>` is dropped when the id would exceed the protocol's 64 chars): unique for the whole match although field ids repeat (`n:<pid>` every round, `u`, `b1`/`b2` in both boss rounds), and across the matches of one room. The client runner keys its local battles by it (the same id again = the same battle, no rebuild) and the server matches reports by it, so a late `b.progress` / `b.result` of a finished battle — or one that crossed into the room's next match on the same socket — is ignored instead of being credited to another battle. - `battleId` = `<prefix>.<round>.<seq>.<fieldId>` (`Match._ccField`; `<prefix>` = the match seed in base 36 plus `-<matchNo>`, the room's match number the lobby passes as `opts.matchNo`; `<seq>` counts every battle of the match; the informational `.<fieldId>` is dropped when the id would exceed the protocol's 64 chars): unique for the whole match although field ids repeat (`n:<pid>` every round, `u`, `b1`/`b2` in both boss rounds), and across the matches of one room. The client runner keys its local battles by it (the same id again = the same battle, no rebuild) and the server matches reports by it, so a late `b.progress` / `b.result` of a finished battle — or one that crossed into the room's next match on the same socket — is ignored instead of being credited to another battle.
- S→C `b.start { battleId, fieldId, kind, spec, authoritative, startAt, serverNow, elapsed, speed, watch, done }` — sent at COMBAT/UNITE/FINAL_ASSAULT/HIDDEN start to every human in the field (联防: to every human; COMBAT and boss rounds: eliminated humans and spectator seats get the field of the player they follow as a display replica — the one they last watched with `g.watch`, else the first player still in; research 09 "keep-watching auto-observes the first available field", 0.2.0 item 56), on `g.watch`, and on reconnect/resync. `elapsed` = game seconds on the field clock (a finished field: its end time); the client fast-forwards to it. `speed` = game seconds per real second (2; tests/tools may speed up). - S→C `b.start { battleId, fieldId, kind, spec, authoritative, startAt, serverNow, elapsed, speed, watch, done }` — sent at COMBAT/UNITE/FINAL_ASSAULT/HIDDEN start to every human in the field (联防: to every human; COMBAT and boss rounds: eliminated humans and spectator seats get the field of the player they follow as a display replica — the one they last watched with `g.watch`, else the first player still in; research 09 "keep-watching auto-observes the first available field", 0.2.0 item 56), on `g.watch`, and on reconnect/resync. `elapsed` = game seconds on the field clock (a finished field: its end time); the client fast-forwards to it. `speed` = game seconds per real second (2; tests/tools may speed up).
- C→S `b.progress { battleId, gt, killed, total, resolved?, leaks?, left?, bossDmg?, by?, done? }` — authoritative clients, ~1 Hz (boss fields 4 Hz). `resolved` = the HUD capsule's numerator of this field (below; omitted by a client that cannot count it — the teammate UI then falls back to `killed`); `killed` / `total` are the field's own counters: `killed` counts every `counted` knock-out (a runtime split / summon included) and may exceed `total`, which counts only the enemies the round scheduled. Normal/联防: `leaks` = counted leaks so far; 联防: `left` = per leaker its enemies still standing (`spec.js uniteLeft`, clamped by the server to `fields.js uniteBillBounds`; §20.6). Boss: `bossDmg` = cumulative damage of this field to the shared pool, `by` = the same per player, `leaks` = the field's cumulative LP cost (leaks × `lpr` + leader "扣除目标生命" effects; `spec.js attachLpMeter`). The server applies deltas (never twice). Stale reports (unknown battle, not the authority, after the match ended) are ignored, never answered with an error; the platform never answers a rid-less `b.progress` with an error frame (it would surface as a toast). - C→S `b.progress { battleId, gt, killed, total, resolved?, leaks?, left?, bossDmg?, by?, leaksBy?, done? }` — authoritative clients, ~1 Hz (boss fields 4 Hz). `resolved` = the HUD capsule's numerator of this field (below; omitted by a client that cannot count it — the teammate UI then falls back to `killed`); `killed` / `total` are the field's own counters: `killed` counts every `counted` knock-out (a runtime split / summon included) and may exceed `total`, which counts only the enemies the round scheduled. Normal/联防: `leaks` = counted leaks so far; 联防: `left` = per leaker its enemies still standing (`spec.js uniteLeft`, clamped by the server to `fields.js uniteBillBounds`; §20.6). Boss: `bossDmg` = cumulative damage of this field to the shared pool, `by` = the same per player, `leaks` = the field's cumulative LP cost (leaks × `lpr` + leader "扣除目标生命" effects; `spec.js attachLpMeter`), `leaksBy` = its split per player (the LP the enemies that reached that player's goal cost: every leak entry the result will show × its `lpr`; `leaks` minus its sum is the leader's effects; `spec.js battleProgress`) — the server keeps the highest value per player and, before a perfect-payout bounty pays in a client-reported field, requires the result's leaked list of that player to show at least that much and the LP asked for to have been split at all (`Match._bossLeaksAgree`, the boss path does not re-simulate). The server applies deltas (never twice). Stale reports (unknown battle, not the authority, after the match ended) are ignored, never answered with an error; the platform never answers a rid-less `b.progress` with an error frame (it would surface as a toast).
- C→S `b.result { battleId, result }` — authoritative client at battle end; `result` = `compactResult(BattleResult)` (the fields the match consumes; ≤ 4 players, ≤ 400 leaks, ≤ 64 unitsEnd, ≤ 160 unitStats, ≤ 40 layer gains; `fitResult` keeps the frame ≤ 60 KB: unit statistics, then leak mods, then — boss only — leak entries go first). Structural checks in `isBattleResult`; `server/net.js` routes `b.*` frames to the match (lobby `routeGame`). - C→S `b.result { battleId, result }` — authoritative client at battle end; `result` = `compactResult(BattleResult)` (the fields the match consumes; ≤ 4 players, ≤ 400 leaks, ≤ 64 unitsEnd, ≤ 160 unitStats, ≤ 40 layer gains; `fitResult` keeps the frame ≤ 60 KB: unit statistics, then leak mods, then — boss only — leak entries go first). Structural checks in `isBattleResult`; `server/net.js` routes `b.*` frames to the match (lobby `routeGame`).
- S→C `b.pool { hp, max, teamLp, acked: { [fieldId]: cumulative damage counted } }` (boss rounds, ≤ 4 Hz; exact numbers, never rounded) — clients reconcile their `LocalBossPool`: displayed pool = server hp − (local cumulative damage − acked). Pools never hold less than 1 HP (`BOSS_POOL_MIN_HP`: `SharedBossPool` and `LocalBossPool`; a browser pool reads any finite rest below 1 as 0 — §20.5); the 'cleared'-result dust rule (a boss field whose client result is `'cleared'` while < 1 HP is left on the server empties the pool) only catches noise at that boundary. - S→C `b.pool { hp, max, teamLp, acked: { [fieldId]: cumulative damage counted } }` (boss rounds, ≤ 4 Hz; exact numbers, never rounded) — clients reconcile their `LocalBossPool`: displayed pool = server hp − (local cumulative damage − acked). Pools never hold less than 1 HP (`BOSS_POOL_MIN_HP`: `SharedBossPool` and `LocalBossPool`; a browser pool reads any finite rest below 1 as 0 — §20.5); the 'cleared'-result dust rule (a boss field whose client result is `'cleared'` while < 1 HP is left on the server empties the pool) only catches noise at that boundary.
- S→C `b.end { battleId, fieldId, reason }` — `'cleared'` (pool 0) / `'forced'` (team LP 0): the client calls `battle.forceEnd` and reports; `'takeover'`: stop reporting, keep displaying. Both also reach a battle the client is still preparing (the sim loading, the silent catch-up): Client runner below. - S→C `b.end { battleId, fieldId, reason }` — `'cleared'` (pool 0) / `'forced'` (team LP 0): the client calls `battle.forceEnd` and reports; `'takeover'`: stop reporting, keep displaying. Both also reach a battle the client is still preparing (the sim loading, the silent catch-up): Client runner below.
+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`). - **#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` (教鞭). - 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` ### 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. - **#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
@@ -104,7 +104,7 @@ function refreshPrivate() {
const p = S.priv; const p = S.priv;
p.deployCount = p.board.filter((x) => x.kind === 'chess').length; p.deployCount = p.board.filter((x) => x.kind === 'chess').length;
p.bonds = computeBonds(p); 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]; const upg = [5, 8, 11, 12, 13];
p.shop.maxLevel = 6; p.shop.maxLevel = 6;
if (p.shop.upgradePrice == null) p.shop.upgradePrice = upg[p.shop.level - 1] ?? 0; if (p.shop.upgradePrice == null) p.shop.upgradePrice = upg[p.shop.level - 1] ?? 0;
@@ -181,7 +181,7 @@ function buildState() {
].sort((a, b) => a.t - b.t); ].sort((a, b) => a.t - b.t);
const priv = { 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 }, 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: [], hand, temp, board, deployCap: 8, deployCount: 0, bonds: [],
effects: [ effects: [
@@ -680,7 +680,16 @@ async function mockRequest(t, f = {}) {
} }
pushPublic(); return {}; 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': { case 'g.reward': {
if (!p.shop.rewardOffer) fail('WRONG_PHASE'); if (!p.shop.rewardOffer) fail('WRONG_PHASE');
const s = p.shop.rewardOffer.slots[f.idx]; if (!s) fail('BAD_TARGET'); const s = p.shop.rewardOffer.slots[f.idx]; if (!s) fail('BAD_TARGET');
@@ -688,6 +697,15 @@ async function mockRequest(t, f = {}) {
p.hand[idx] = chessPiece(data.lookup('chess', s.id)); p.shop.rewardOffer = null; refreshPrivate(); return {}; p.hand[idx] = chessPiece(data.lookup('chess', s.id)); p.shop.rewardOffer = null; refreshPrivate(); return {};
} }
case 'g.choice': { 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'); if (!pub.sp) fail('WRONG_PHASE');
const taken = Object.values(pub.sp.picks).includes(f.idx); const taken = Object.values(pub.sp.picks).includes(f.idx);
if (taken) fail('BAD_TARGET'); if (taken) fail('BAD_TARGET');
@@ -695,6 +713,7 @@ async function mockRequest(t, f = {}) {
} }
case 'g.ready': { case 'g.ready': {
if (pub.phase !== PHASE.PREP) fail('WRONG_PHASE'); 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'); if (f.ready && !p.canReady) fail('TEMP_NOT_EMPTY');
p.ready = !!f.ready; refreshPrivate(); return {}; p.ready = !!f.ready; refreshPrivate(); return {};
} }
+6
View File
@@ -386,6 +386,12 @@
"CONTINGENCY // 机变阶段": "CONTINGENCY // Improv Phase", "CONTINGENCY // 机变阶段": "CONTINGENCY // Improv Phase",
"选择一项": "Choose one", "选择一项": "Choose one",
"倒计时结束后仍未选定将自动分配": "If no choice is made before the countdown ends, one will be assigned automatically", "倒计时结束后仍未选定将自动分配": "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)", "选择一项(无时间限制)": "Choose one (no time limit)",
" · 点击卡牌选中,再次点击确认": " · Tap a card to select, tap again to confirm", " · 点击卡牌选中,再次点击确认": " · Tap a card to select, tap again to confirm",
"已完成选择": "Choice made", "已完成选择": "Choice made",
+6
View File
@@ -389,6 +389,12 @@
"CONTINGENCY // 机变阶段": "CONTINGENCY // 調整段階", "CONTINGENCY // 机变阶段": "CONTINGENCY // 調整段階",
"选择一项": "1つ選択", "选择一项": "1つ選択",
"倒计时结束后仍未选定将自动分配": "カウントダウン終了までに選ばない場合、自動で割り当てられます", "倒计时结束后仍未选定将自动分配": "カウントダウン終了までに選ばない場合、自動で割り当てられます",
"教鞭选择": "教鞭の選択",
"教鞭 · 战术特训": "教鞭 · 戦術訓練",
"请选择一项战术特训": "戦術訓練を1つ選択してください",
"请先完成教鞭选择": "先に教鞭の選択を完了してください",
"休整期结束时未选择将自动选定": "未選択の場合、整備フェーズ終了時に自動で選択されます",
"从候选中选择一项战术特训": "候補から戦術訓練を1つ選択",
"选择一项(无时间限制)": "1つ選択(時間制限なし)", "选择一项(无时间限制)": "1つ選択(時間制限なし)",
" · 点击卡牌选中,再次点击确认": " · カードをタップで選択、もう一度タップで確定", " · 点击卡牌选中,再次点击确认": " · カードをタップで選択、もう一度タップで確定",
"已完成选择": "選択完了", "已完成选择": "選択完了",
+6
View File
@@ -385,6 +385,12 @@
"CONTINGENCY // 机变阶段": "CONTINGENCY // 기회 단계", "CONTINGENCY // 机变阶段": "CONTINGENCY // 기회 단계",
"选择一项": "하나를 선택하세요", "选择一项": "하나를 선택하세요",
"倒计时结束后仍未选定将自动分配": "카운트다운이 끝날 때까지 선택하지 않으면 자동으로 지정됩니다", "倒计时结束后仍未选定将自动分配": "카운트다운이 끝날 때까지 선택하지 않으면 자동으로 지정됩니다",
"教鞭选择": "교편 선택",
"教鞭 · 战术特训": "교편 · 전술 특훈",
"请选择一项战术特训": "전술 특훈 1개를 선택해 주세요",
"请先完成教鞭选择": "먼저 교편 선택을 완료해 주세요",
"休整期结束时未选择将自动选定": "선택하지 않으면 휴식 기간 종료 시 자동으로 선택됩니다",
"从候选中选择一项战术特训": "후보 중 전술 특훈 1개 선택",
"选择一项(无时间限制)": "하나를 선택하세요 (시간 제한 없음)", "选择一项(无时间限制)": "하나를 선택하세요 (시간 제한 없음)",
" · 点击卡牌选中,再次点击确认": " · 카드를 탭해 선택하고 한 번 더 탭해 확인", " · 点击卡牌选中,再次点击确认": " · 카드를 탭해 선택하고 한 번 더 탭해 확인",
"已完成选择": "선택 완료", "已完成选择": "선택 완료",
+6
View File
@@ -387,6 +387,12 @@
"CONTINGENCY // 机变阶段": "CONTINGENCY // 機變階段", "CONTINGENCY // 机变阶段": "CONTINGENCY // 機變階段",
"选择一项": "選擇一項", "选择一项": "選擇一項",
"倒计时结束后仍未选定将自动分配": "倒計時結束後仍未選定將自動分配", "倒计时结束后仍未选定将自动分配": "倒計時結束後仍未選定將自動分配",
"教鞭选择": "教鞭選擇",
"教鞭 · 战术特训": "教鞭 · 戰術特訓",
"请选择一项战术特训": "請選擇一項戰術特訓",
"请先完成教鞭选择": "請先完成教鞭選擇",
"休整期结束时未选择将自动选定": "休整期結束時未選擇將自動選定",
"从候选中选择一项战术特训": "從候選中選擇一項戰術特訓",
"选择一项(无时间限制)": "選擇一項(無時間限制)", "选择一项(无时间限制)": "選擇一項(無時間限制)",
" · 点击卡牌选中,再次点击确认": " · 點擊卡牌選中,再次點擊確認", " · 点击卡牌选中,再次点击确认": " · 點擊卡牌選中,再次點擊確認",
"已完成选择": "已完成選擇", "已完成选择": "已完成選擇",
+3
View File
@@ -448,6 +448,9 @@ export function createBattleRunner(deps) {
if (bossLike(e)) { if (bossLike(e)) {
const pool = e.battle.sharedBoss; const pool = e.battle.sharedBoss;
msg.leaks = Math.min(1e6, e.meter.lp); msg.leaks = Math.min(1e6, e.meter.lp);
// the split of that LP between the players' own leaks (the rest is the leader's effects): the server holds the result's
// leaked lists to it before a perfect-payout bounty pays (Match._bossLeaksAgree)
if (p.leaksBy) msg.leaksBy = p.leaksBy;
msg.bossDmg = pool && Number.isFinite(pool.cum) ? pool.cum : 0; msg.bossDmg = pool && Number.isFinite(pool.cum) ? pool.cum : 0;
if (pool && pool.byPlayer) { if (pool && pool.byPlayer) {
const by = {}; const by = {};
+35 -5
View File
@@ -95,7 +95,7 @@ import { pieceTile } from '../render/drag.js';
import { import {
phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent, phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent,
battleOverSfx, uniteResultBox, battleResultBox, 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, deviceInfo, deviceTipAt, noteDeviceUnits, terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits,
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason, countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord, previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord,
@@ -197,6 +197,7 @@ function MatchScreen() {
const [resultBox, setResultBox] = useState(null); // the round's result box (ResultDialog), shown at SETTLE const [resultBox, setResultBox] = useState(null); // the round's result box (ResultDialog), shown at SETTLE
const [readyBusy, setReadyBusy] = useState(false); const [readyBusy, setReadyBusy] = useState(false);
const [spBusy, setSpBusy] = useState(null); 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 [layer, setLayer] = useState('ALL'); // 联防 / 最终攻势 camera: 'L' | 'ALL' | 'R'
const [pen, setPen] = useState(false); // the camera shows the enemy preview pen (research 09 §2) 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) const [camKind, setCamKind] = useState('prep'); // kind of the last camera request (data-camera)
@@ -220,7 +221,9 @@ function MatchScreen() {
const scoutPid = watchingOther && field?.prep && typeof field.fieldId === 'string' && field.fieldId.startsWith('n:') ? field.fieldId.slice(2) : null; 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 scoutBandId = scoutPid ? ownerBandId(pub, scoutPid) : null;
const scoutBandOwner = scoutPid ? (players.find((p) => p.playerId === scoutPid)?.name || null) : 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 showShop = !!priv && alive && (phase === PHASE.PREP || phase === PHASE.SP_DRAFT || phase === PHASE.ROUND_START) && !watchingOther;
const layersDisabled = phase === PHASE.UNITE || isBossPhase(phase); const layersDisabled = phase === PHASE.UNITE || isBossPhase(phase);
const sp = phase === PHASE.SP_DRAFT ? normalizeSp(pub?.sp, players) : null; const sp = phase === PHASE.SP_DRAFT ? normalizeSp(pub?.sp, players) : null;
@@ -284,7 +287,7 @@ function MatchScreen() {
getChess: (id) => { const c = gd.chess(id); return ownDiyRecord(c, priv, { chess: data.get('chess'), backups: data.get('backups') }) || c; }, 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, getToken: gd.token, getItem: gd.item, getEffect: gd.effect, backups: gd.backups,
}), [priv, pub?.stageId, editable, gd.ready, deployField]); }), [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 ----------------------- // ---- 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 // the own prep board: the normal board, or — in the prep of a boss round — the player's half of the boss field
@@ -673,6 +676,17 @@ function MatchScreen() {
setSpBusy(null); setSpBusy(null);
}, [phaseKey]); }, [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 // 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 // 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) // row work again, and the bond strip's "👁 name" matches the HUD (battle/observe.js resumedWatch)
@@ -1199,6 +1213,14 @@ function MatchScreen() {
const L = live.current; const L = live.current;
// dialogs / the guide own the keyboard; behind the 本局信息 / 敌方情报 drawer only Esc (closing it) acts // 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 (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 (act === 'escape') {
if (L.emoteOpen) setEmoteOpen(false); if (L.emoteOpen) setEmoteOpen(false);
else if (L.pen && !L.detail) togglePenRef.current(false); else if (L.pen && !L.detail) togglePenRef.current(false);
@@ -1301,7 +1323,7 @@ function MatchScreen() {
const temp = tempInfo(priv); const temp = tempInfo(priv);
const tempNotice = temp.count > 0 && !!view && viewKind !== 'loading' && showPrep && alive && !pen && !sp; 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 // 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), // 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) // and the root font size (1rem)
const panelFrame = () => { const panelFrame = () => {
@@ -1369,7 +1391,7 @@ function MatchScreen() {
// bonds this mode never activates (标准: 10 of 23, 奥术 among them) — shown 本局禁用 on cards, chips and the popup // bonds this mode never activates (标准: 10 of 23, 奥术 among them) — shown 本局禁用 on cards, chips and the popup
const offBonds = modeOffBonds(getMode(pub?.modeId)); 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}> data-camera=${pen ? 'pen' : camKind}>
<div class="gm__field" ref=${hostRef} onContextMenu=${(e) => e.preventDefault()}></div> <div class="gm__field" ref=${hostRef} onContextMenu=${(e) => e.preventDefault()}></div>
${viewKind === 'loading' ? html`<div class="gm__loading"><${Spinner} label="LOADING FIELD" /></div>` : null} ${viewKind === 'loading' ? html`<div class="gm__loading"><${Spinner} label="LOADING FIELD" /></div>` : null}
@@ -1465,6 +1487,14 @@ function MatchScreen() {
${sp ? html`<${ChoiceOverlay} pub=${pub} sp=${sp} myId=${myId} solo=${solo} busyIdx=${spBusy} total=${total} ${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} 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} ${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} 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). * 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) { export function ChoiceOverlay(props) {
const { sp, myId, solo, busyIdx = null, onPick } = 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). * 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 * 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; if (!sp) return null;
const fam = data.get('choices')?.families?.[sp.family] || null; const fam = data.get('choices')?.families?.[sp.family] || null;
const rawFam = data.getRaw('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 mine = sp.pickOf.get(myId);
const turnName = players.get(sp.turnPid)?.name || t('队友'); const turnName = players.get(sp.turnPid)?.name || t('队友');
const special = /_s$/.test(String(sp.family || '')); const special = /_s$/.test(String(sp.family || ''));
const order = solo ? [] : sp.order; const order = personal || solo ? [] : sp.order;
const timed = !solo && !sp.untimed; const timed = personal ? pub?.deadline > 0 : !solo && !sp.untimed;
const armedCardRec = armed != null ? sp.cards.find((c) => c && c.idx === armed) : null; const armedCardRec = armed != null ? sp.cards.find((c) => c && c.idx === armed) : null;
const armedName = armedCardRec ? resolveSpCard(armedCardRec, sp.family).name : null; const armedName = armedCardRec ? resolveSpCard(armedCardRec, sp.family).name : null;
// a press anywhere but a card or the confirm button drops the selection // 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; if (t && typeof t.closest === 'function' && t.closest('.spcard, .spov__confirm')) return;
onDisarm(); 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__veil" aria-hidden="true"></div>
<div class="spov__inner"> <div class="spov__inner">
<header class="spov__head"> <header class="spov__head">
<div class="spov__titles"> <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> <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>
<div class="spov__turn"> <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>` : myTurn ? html`<span class="spov__turntxt is-mine">${t('当前轮到你决策')}</span>`
: html`<span class="spov__turntxt">${t('{turnName} 正在决策…', { turnName })}<${Icon} name="hourglass" /></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" ${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 }), art: (itemUid, row, col, dir) => act('g.art', dir ? { itemUid, row, col, dir } : { itemUid, row, col }),
destroy: (uid) => act('g.destroy', { uid }), destroy: (uid) => act('g.destroy', { uid }),
reward: (idx) => act('g.reward', { idx }), 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' }), ready: (ready) => act('g.ready', { ready }, { sfx: ready ? 'ready' : 'back' }),
emote: (id) => act('g.emote', { id }, { quiet: true }), 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 // `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, readyShopFold } from './gameLogic/shop.js'; export { priceTone, mergeProgress, mergeTarget, handFull, completesMerge, offerHeader, shopBlockReason, readyFundsPrompt, readyShopFold } 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 { deploySets, stageOverrides, deployMap, effectiveStage, indexPieces, placementContext, piecePosition, tileAllows, summonRange, summonExcluded, canPlace, equipReplaces, equipMerges, boardTargets, dropIntent, dropFailureReason } from './gameLogic/placement.js';
export { terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits } from './gameLogic/terrain.js'; export { terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits } 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 { groupEnemies, PEN, penZoneTiles, penPlacement, previewEnemyKey, factionTypes } from './gameLogic/enemies.js';
export { snapHud, bossFrac, bossPctText, hasFlag, UF, attackInterval, fmtNum, rangeGridBox } from './gameLogic/format.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, facingEnter } from './gameLogic/shortcuts.js'; export { shortcutFor, closesOnFieldPress, shortcutBlocked, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, isBindableCode, hotkeyLabel, sanitizeHotkeys, rebindHotkey, isDefaultHotkeys, hotkeyOf, actionForKey, captureHotkey, facingSwallows, facingEnter } from './gameLogic/shortcuts.js';
+14
View File
@@ -1,6 +1,8 @@
// ui/gameLogic/draft.js — 机变 and band-draft normalisation. Re-exported from ../gameLogic.js. // ui/gameLogic/draft.js — 机变 and band-draft normalisation. Re-exported from ../gameLogic.js.
import { isObj } from './shared.js'; import { isObj } from './shared.js';
import { PHASE } from '../../../../shared/constants.js';
import { t } from '../../../../shared/i18n.js';
// ---- 机变 / band draft normalisation ---------------------------------------------------------------------- // ---- 机变 / band draft normalisation ----------------------------------------------------------------------
@@ -73,3 +75,15 @@ export function normalizeSp(sp, players = []) {
cards, order, turnPid, pickOf, takenBy, pickedCount: pickOf.size, 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
@@ -140,7 +140,10 @@ export function offerHeader(offer) {
export function shopBlockReason(kind, { priv, editable, slot } = {}) { export function shopBlockReason(kind, { priv, editable, slot } = {}) {
if (!priv) return t('尚未就绪'); if (!priv) return t('尚未就绪');
if (priv.alive === false) 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 (!editable) {
if (kind === 'reward') return t('当前无法选择'); if (kind === 'reward') return t('当前无法选择');
return priv.ready ? t('已准备就绪,取消准备后才能操作') : t('当前阶段无法进行该操作'); return priv.ready ? t('已准备就绪,取消准备后才能操作') : t('当前阶段无法进行该操作');
+9 -6
View File
@@ -246,24 +246,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): * Ready toggle (PREP only). Disabled while the temp row holds pieces or a personal choice (教鞭) is open — the reason shows
* "临时整备区 N 个单位待处理" (user playtest #3 item 3). * 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 * @param {{ priv:any, onToggle:(ready:boolean)=>void, busy?:boolean, readyCount?:number, total?:number }} props
*/ */
export function ReadyToggle({ priv, onToggle, busy, readyCount, total }) { export function ReadyToggle({ priv, onToggle, busy, readyCount, total }) {
const ready = !!priv?.ready; const ready = !!priv?.ready;
const temp = tempInfo(priv); 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} 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__box">${ready ? html`<${Icon} name="check" />` : null}</span>
<span class="readybtn__label">${ready ? t('取消准备') : t('准备就绪')}</span> <span class="readybtn__label">${ready ? t('取消准备') : t('准备就绪')}</span>
<kbd class="readybtn__key">${hotkeyLabelOf('ready')}</kbd> <kbd class="readybtn__key">${hotkeyLabelOf('ready')}</kbd>
</button>`; </button>`;
return html`<div class="readywrap"> return html`<div class="readywrap">
${reason ? html`<${Tooltip} text=${reason} placement="bottom">${btn}<//>` : btn} ${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"> ${why ? 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} <${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} ${Number.isFinite(total) && total > 1 ? html`<span class="readywrap__count">${t('已就绪')} <b class="num">${readyCount}</b>/<span class="num">${total}</span></span>` : null}
</div>`; </div>`;
} }
+7
View File
@@ -201,6 +201,13 @@ export class PlayerState {
this.effects = []; this.effects = [];
/** active bounties: { id, card, roundsLeft, chooser } */ /** active bounties: { id, card, roundsLeft, chooser } */
this.bounties = []; 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) */ /** free-form counters for content (ctx.counter / setCounter) */
this.counters = {}; this.counters = {};
/** per-round counters (reset at round start) */ /** per-round counters (reset at round start) */
+19 -5
View File
@@ -37,7 +37,8 @@
// tiles, where it blocks — except one whose trait reads 「可以放置于远程位」 (歌蕾蒂娅, 崖心, 见行者, any module: // tiles, where it blocks — except one whose trait reads 「可以放置于远程位」 (歌蕾蒂娅, 崖心, 见行者, any module:
// placeClass 'all', shared/highGround.js), which takes a 高台 whose range covers the enemy road, else the ground // placeClass 'all', shared/highGround.js), which takes a 高台 whose range covers the enemy road, else the ground
// (owner 2026-10-04 for the 高台 preference, 2026-10-05 for who may use one). It stays a blocker in the lineup // (owner 2026-10-04 for the 高台 preference, 2026-10-05 for who may use one). It stays a blocker in the lineup
// (basePositionClass). Each unit's range grid — the one it is // (basePositionClass). A blocker whose range grid is its own tile only (range 0-1: 角峰, 古米, 泡泡, 折桠, 菲莱, 蛇屠箱,
// 塞雷娅, 余) is planned on a free tile of the enemy road first: off it nothing is blocked or hit. Each unit's range grid — the one it is
// deployed with, rangeRec (loadoutRecord attackRangeGrid) — is rotated per direction (DESIGN §3; RIGHT is tried first // deployed with, rangeRec (loadoutRecord attackRangeGrid) — is rotated per direction (DESIGN §3; RIGHT is tried first
// and kept on ties, so symmetric ranges and melee units whose front adds nothing stay facing the gates), so // and kept on ties, so symmetric ranges and melee units whose front adds nothing stay facing the gates), so
// ranged units turn toward the enemy path tiles they cover best and blockers toward the road; on 气流 tiles // ranged units turn toward the enemy path tiles they cover best and blockers toward the road; on 气流 tiles
@@ -978,6 +979,12 @@ export function* planLayoutSteps(m, ps, pieces, params = LAYOUT_PARAMS, { occupi
const preferHigh = cls === 'all' && basePositionClass(r0) === 'melee'; const preferHigh = cls === 'all' && basePositionClass(r0) === 'melee';
let bestHigh = null; let bestHigh = null;
let bestHighV = -Infinity; let bestHighV = -Infinity;
// a blocker whose range is its own tile only (range 0-1: 角峰, 古米, 泡泡, 折桠, 菲莱, 蛇屠箱, 塞雷娅, 余) neither blocks nor
// hits anything off the enemy road — the official range "0-1" (excel range_table) is the one grid (0,0) — so a free road tile
// comes first (the kill estimate saturates in a full layout and then cannot tell the tiles apart)
const preferRoad = isBlocker(r0) && r0.rangeGrid?.length === 1 && r0.rangeGrid[0].every((v) => v === 0);
let bestRoad = null;
let bestRoadV = -Infinity;
for (const [r, c] of legalTiles(map, cls)) { for (const [r, c] of legalTiles(map, cls)) {
const k = tileKey(r, c); const k = tileKey(r, c);
if (taken.has(k) || (within && !within.has(k)) || (without && without.has(k))) continue; if (taken.has(k) || (within && !within.has(k)) || (without && without.has(k))) continue;
@@ -993,13 +1000,15 @@ export function* planLayoutSteps(m, ps, pieces, params = LAYOUT_PARAMS, { occupi
const v = layout.value() + noise; const v = layout.value() + noise;
layout.units.pop(); layout.units.pop();
if (v > bestV) { bestV = v; best = [k, r, c, dir]; } if (v > bestV) { bestV = v; best = [k, r, c, dir]; }
// (the best road tile by the same value)
if (preferRoad && model.ground.has(k) && v > bestRoadV) { bestRoadV = v; bestRoad = [k, r, c, dir]; }
if (preferHigh && map.get(k) === 'ranged' && [...u.cover].some((ck) => model.ground.has(ck)) && v > bestHighV) { if (preferHigh && map.get(k) === 'ranged' && [...u.cover].some((ck) => model.ground.has(ck)) && v > bestHighV) {
bestHighV = v; bestHighV = v;
bestHigh = [k, r, c, dir]; bestHigh = [k, r, c, dir];
} }
} }
} }
const pick = bestHigh || best; const pick = bestHigh || bestRoad || best;
if (!pick) continue; if (!pick) continue;
taken.add(pick[0]); taken.add(pick[0]);
layout.units.push(unitOf(r0, pick[0], pick[1], pick[2], pick[3], model)); layout.units.push(unitOf(r0, pick[0], pick[1], pick[2], pick[3], model));
@@ -1329,6 +1338,7 @@ export function botPrepBegin(m, ps) {
*/ */
export function* botPrepBeginSteps(m, ps) { export function* botPrepBeginSteps(m, ps) {
if (!ps.alive || ps.ready) return null; if (!ps.alive || ps.ready) return null;
m.autoPickPersonalChoice(ps, 'bot');
// 1. reward offers (free) // 1. reward offers (free)
takeOffers(m, ps); takeOffers(m, ps);
yield; yield;
@@ -1366,6 +1376,7 @@ export function* botPrepEndSteps(m, ps, job = null) {
if (freeSlot(ps.hand) < 0) freeHandSlot(m, ps); 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) // what the next prep's bench shed may judge by value: what is owned now (later gains wait for a placement step)
rememberOwned(ps); rememberOwned(ps);
m.autoPickPersonalChoice(ps, 'bot');
tryDo(() => ps.setReady(true)); tryDo(() => ps.setReady(true));
} }
@@ -1807,8 +1818,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 * 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 * deployed operator, with a free hand slot for the copy; 教鞭 offers a scored personal bounty choice, “神秘顾客” adds a
* draws, not chosen) — only after a perfect battle with LP to spare, else the Art stays in the hand for a later round * 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). * (destroying 教鞭 gives nothing; a full hand at the prep end: freeHandSlot).
*/ */
function useArt(m, ps, item, ctx) { function useArt(m, ps, item, ctx) {
@@ -1825,7 +1836,10 @@ function useArt(m, ps, item, ctx) {
if (keys.includes('trap_create_self_choice')) { if (keys.includes('trap_create_self_choice')) {
if (!lastPerfect(m, ps) || ps.lp < 10) return; // kept if (!lastPerfect(m, ps) || ps.lp < 10) return; // kept
const [key] = [...ps.board.keys()]; 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; return;
} }
const [key] = [...ps.board.keys()]; const [key] = [...ps.board.keys()];
+1 -2
View File
@@ -25,8 +25,7 @@
// operator gained into the hand // operator gained into the hand
// trap_copy_front_char 画卷 (Art) copy the chess on the tile / in front // 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 // 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 // This builtin picks a random bounty by default; content/items/meta.js overrides Pointing Stick with a private choice.
// a personal choice overlay.]
import { getData } from '../data.js'; import { getData } from '../data.js';
import { itemKey } from './gamedata.js'; import { itemKey } from './gamedata.js';
+1
View File
@@ -674,6 +674,7 @@ export function makeCtx(m, ps, source, hook, ev = null) {
// ---- bounties / choices // ---- bounties / choices
addBounty: (card) => m.addBounty(ps, card), addBounty: (card) => m.addBounty(ps, card),
offerBountyChoice: (cards, sourceItemId) => m.offerBountyChoice(ps, cards, sourceItemId),
// ---- messaging // ---- messaging
// a string or a shared/i18n.js msg(msgid, params) // 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 // 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 // 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) // 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 // match phase known; teamLp / boss pool within range; combat fields match the alive players
import { PHASE, BOND_LAYER_CAP } from '../../shared/constants.js'; 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.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}`); 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 // pieces
const all = [...ps.board.values(), ...ps.hand.filter(Boolean), ...ps.temp.filter(Boolean)]; const all = [...ps.board.values(), ...ps.hand.filter(Boolean), ...ps.temp.filter(Boolean)];
+48 -8
View File
@@ -231,6 +231,35 @@ export class MatchBoss {
if (credit > 0) this._teamLpLoss(credit); if (credit > 0) this._teamLpLoss(credit);
} }
/**
* The authority's per-player split of the LP its field's enemy leaks cost (b.progress `leaksBy`: cumulative, per player,
* the leader's own LP effects not in it) — the highest value seen per player. The field's perfect-payout bounties are
* paid only where the final result agrees with it (`_bossLeaksAgree`).
*/
_noteLeaksBy(f, by) {
if (!by || typeof by !== 'object') return;
if (!f.leaksBy) f.leaksBy = {};
for (const pid of f.players) {
const v = Number(by[pid]);
if (Number.isFinite(v) && v > (f.leaksBy[pid] || 0)) f.leaksBy[pid] = v;
}
}
/**
* Whether a boss field's b.result may be believed about one player's leaks before a perfect-payout bounty pays: the
* result comes from a client (a server run is the truth itself) and does not contradict what that client reported while
* it fought — LP it asked the team to pay must have been split into the players' own leaks and the leader's effects
* (b.progress `leaksBy`; reports without the split explain nothing), and the player's leaked list must show at least the
* leak LP reported for it (a leak is never moved to the other seat or dropped). A modified authority can still lie in
* both; what the server cannot see it cannot check (the boss path never re-simulates).
*/
_bossLeaksAgree(f, res, pid) {
if (!f.cc || f.resultSource !== 'client') return true;
if (f.lpReported > 1e-9 && !f.leaksBy) return false;
const shown = ((res.perPlayer[pid] && res.perPlayer[pid].leaked) || []).reduce((n, l) => n + (Number.isFinite(l.lpr) && l.lpr >= 0 ? l.lpr : 1), 0);
return shown + 1e-6 >= ((f.leaksBy && f.leaksBy[pid]) || 0);
}
/** The server runs a boss field in real time (no client left): credits only what exceeds the client's reports. */ /** The server runs a boss field in real time (no client left): credits only what exceeds the client's reports. */
_bossServerRun(f) { _bossServerRun(f) {
const credit = new CreditPool(this.bossPool, { acked: f.bossAcked, ackedBy: f.bossBy }); const credit = new CreditPool(this.bossPool, { acked: f.bossAcked, ackedBy: f.bossBy });
@@ -372,11 +401,15 @@ export class MatchBoss {
} }
/** /**
* Bounties after a boss field: kill-bounty coins go to pending funds (spent in the Hidden Core's prep) and every * Bounties after a boss field: kill-bounty coins and — when `perfect` — the coins of every perfect-payout bounty (a
* bounty used one of its battles, exactly like SETTLE does for normal rounds. * 战术特训 card: PRTS "若各自行动阶段就达成完美作战,获得N资金", the same sum SETTLE pays after a normal battle) go to
* pending funds (spent in the Hidden Core's prep), and every bounty used one of its battles, exactly like SETTLE does
* for normal rounds.
* @param {boolean} perfect the player's own field was perfect and the team won (`_finishFinal`)
*/ */
_settleBossBounties(ps, pp) { _settleBossBounties(ps, pp, perfect) {
const coins = Math.max(0, Math.trunc(Number(pp.coins) || 0)); let coins = Math.max(0, Math.trunc(Number(pp.coins) || 0));
if (perfect) for (const b of ps.bounties) if (b.card.payout === 'perfect') coins += b.card.coin;
if (coins > 0) { ps.pendingFunds += coins; ps.stats.fundsGained += coins; } if (coins > 0) { ps.pendingFunds += coins; ps.stats.fundsGained += coins; }
if (!ps.bounties.length) return; if (!ps.bounties.length) return;
for (const b of ps.bounties) b.roundsLeft--; for (const b of ps.bounties) b.roundsLeft--;
@@ -471,6 +504,9 @@ export class MatchBoss {
_finishFinal(hidden, resultOf) { _finishFinal(hidden, resultOf) {
if (this.phase !== (hidden ? PHASE.HIDDEN_CORE : PHASE.FINAL_ASSAULT)) return; if (this.phase !== (hidden ? PHASE.HIDDEN_CORE : PHASE.FINAL_ASSAULT)) return;
this._stopClientCombat(); this._stopClientCombat();
// the end condition the server registered first decides (client-side combat: _endFinal — pool 0 → victory, team LP 0
// → defeat); a boss field's final result may never turn a defeat into a victory (user playtest #6 item 5)
const victory = this._finalEnding ? this._finalEnding === 'cleared' : this.bossPool.hp <= 0;
for (const f of this.fields) { for (const f of this.fields) {
const res = resultOf(f); const res = resultOf(f);
this._collectSimErrors(f, res); this._collectSimErrors(f, res);
@@ -485,13 +521,17 @@ export class MatchBoss {
ps.dirty(); // m.private.stats ps.dirty(); // m.private.stats
this._charDamageTickers(ps, pp); this._charDamageTickers(ps, pp);
} }
if (pp && ps) this._settleBossBounties(ps, pp); if (pp && ps) {
// the own battle counts as perfect when the team won, the result is a real one (not the stand-in of a field that
// never reported) and the player's own field let no counted enemy through [ASSUMED: the boss battle is a battle
// of the player's own — "下场作战" — so a perfect-payout card pays as after a normal one]
const perfect = victory && !res.synthetic && pp.perfect === true && !(pp.leaked || []).some((l) => l && l.counted !== false)
&& this._bossLeaksAgree(f, res, pid);
this._settleBossBounties(ps, pp, perfect);
}
if (pp && ps) this.dispatch(ps, 'onBattleResult', { result: pp, lpLoss: 0, perfect: !!pp.perfect, boss: true }); if (pp && ps) this.dispatch(ps, 'onBattleResult', { result: pp, lpLoss: 0, perfect: !!pp.perfect, boss: true });
} }
} }
// the end condition the server registered first decides (client-side combat: _endFinal — pool 0 → victory, team LP 0
// → defeat); a boss field's final result may never turn a defeat into a victory (user playtest #6 item 5)
const victory = this._finalEnding ? this._finalEnding === 'cleared' : this.bossPool.hp <= 0;
this._syncTeamLp(); this._syncTeamLp();
this.deadline = 0; this.deadline = 0;
this.overtimeAt = 0; this.overtimeAt = 0;
+4 -2
View File
@@ -48,8 +48,10 @@ export class MatchClientCombat {
bossAcked: 0, bossBy: {}, lpAcked: 0, lpCum: 0, deadlineTimer: null, doneTimer: null, waitTimer: null, bossAcked: 0, bossBy: {}, lpAcked: 0, lpCum: 0, deadlineTimer: null, doneTimer: null, waitTimer: null,
// boss fields: the latest client reports (re-credited as the plausibility budget grows), the server run's // boss fields: the latest client reports (re-credited as the plausibility budget grows), the server run's
// CreditPool, humans demoted for an implausible result (never the authority of this field again), a 'cleared' // CreditPool, humans demoted for an implausible result (never the authority of this field again), a 'cleared'
// b.result waiting for the budget to credit the pool it emptied (`heldResult`, _onResult) // b.result waiting for the budget to credit the pool it emptied (`heldResult`, _onResult); `leaksBy`: the authority's
bossReported: null, lpReported: 0, credit: null, demoted: new Set(), heldResult: null, // per-player split of the LP its enemy leaks cost (b.progress, the highest value seen; null until one carried it),
// which a result must not contradict before a perfect-payout bounty pays (_bossLeaksAgree)
bossReported: null, lpReported: 0, credit: null, demoted: new Set(), heldResult: null, leaksBy: null,
}; };
} }
+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.art': return ps.useArt(msg.itemUid, msg.row, msg.col, msg.dir);
case 'g.destroy': return ps.destroy(msg.uid); case 'g.destroy': return ps.destroy(msg.uid);
case 'g.reward': return ps.pickReward(msg.idx); 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.ready': return ps.setReady(!!msg.ready);
case 'g.emote': return this.emote(ps, msg.id); case 'g.emote': return this.emote(ps, msg.id);
// playerId: the player tapped (a shared field names two) — the watch preference (item 56) // 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 = () => { const ready = () => {
if (!ps.ready) { if (!ps.ready) {
this.autoPickPersonalChoice(ps, 'random');
ps.resolveTemp(); ps.resolveTemp();
ps.setReady(true); ps.setReady(true);
} }
@@ -113,6 +114,7 @@ export class MatchPrep {
if (this.phase !== PHASE.PREP) return; if (this.phase !== PHASE.PREP) return;
for (const ps of this.alivePlayers()) { for (const ps of this.alivePlayers()) {
if (ps.ready) continue; if (ps.ready) continue;
this.autoPickPersonalChoice(ps, 'random');
ps.resolveTemp(); ps.resolveTemp();
ps.ready = true; ps.ready = true;
ps.dirty(); ps.dirty();
+1
View File
@@ -34,6 +34,7 @@ export class MatchReports {
if (f.kind === 'boss' || f.kind === 'hidden') { if (f.kind === 'boss' || f.kind === 'hidden') {
this._creditBoss(f, msg.bossDmg, msg.by); this._creditBoss(f, msg.bossDmg, msg.by);
this._creditLp(f, msg.leaks, true); this._creditLp(f, msg.leaks, true);
this._noteLeaksBy(f, msg.leaksBy);
this._checkFinalEnd(); this._checkFinalEnd();
this._broadcastPool(false); this._broadcastPool(false);
// 4 Hz per field: b.pool carries the exact pool / team LP; m.public (boss HP, LP, progress) follows at ~1 Hz // 4 Hz per field: b.pool carries the exact pool / team LP; m.public (boss HP, LP, progress) follows at ~1 Hz
+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 // 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 // by choices.js applyCard), the personal bounty choice of the Art 教鞭 (offerBountyChoice / pickPersonalChoice /
// choices.json pools (rollPool). // 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). // 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 { 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 { weightedPick } from '../waves.js';
import { botPickCard } from '../bot.js'; import { botPickCard } from '../bot.js';
import { OK, fail, DELAYS } from './common.js'; import { OK, fail, DELAYS } from './common.js';
/** Cards of the personal choice (PRTS 卫戍协议:盟约 下半 §法术 教鞭: "于3个战术特训的悬赏任务中选择一项"). */
const PERSONAL_OFFER_SIZE = 3;
export class MatchSpDraft { export class MatchSpDraft {
enterSpDraft() { enterSpDraft() {
const draft = generateDraft(this.gd, this.rngDraft, this.round, { stageId: this.stageId, bondAvailable: (bondId) => this.bondLive(bondId) }); const draft = generateDraft(this.gd, this.rngDraft, this.round, { stageId: this.stageId, bondAvailable: (bondId) => this.bondLive(bondId) });
@@ -108,6 +112,58 @@ export class MatchSpDraft {
this.enterPrep(); 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) { addBounty(ps, card) {
if (!ps || !card || !this.gd.enemy(card.enemyKey)) return null; 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) // 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) { setReady(on) {
if (!this.alive) return fail(ERR.ELIMINATED); if (!this.alive) return fail(ERR.ELIMINATED);
if (this.m.phase !== PHASE.PREP) return fail(ERR.WRONG_PHASE); 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 (on && !this.tempEmpty) return fail(ERR.TEMP_NOT_EMPTY);
if (this.ready === !!on) return OK; if (this.ready === !!on) return OK;
this.ready = !!on; this.ready = !!on;
+1
View File
@@ -65,6 +65,7 @@ export class PlayerRound {
this._tempDue.clear(); this._tempDue.clear();
this.offers = []; this.offers = [];
this.bounties = []; this.bounties = [];
this.personalChoice = null;
this.shop.slots = []; this.shop.slots = [];
this.funds = 0; this.funds = 0;
this.pendingFunds = 0; this.pendingFunds = 0;
+8 -2
View File
@@ -6,7 +6,7 @@
import { PHASE } from '../../../shared/constants.js'; import { PHASE } from '../../../shared/constants.js';
import { boardOrder, pieceDir } from '../board.js'; import { boardOrder, pieceDir } from '../board.js';
import { bondList, offBondCounts } from '../bondsMeta.js'; import { bondList, offBondCounts } from '../bondsMeta.js';
import { bountyText } from '../choices.js'; import { bountyText, bountyCard } from '../choices.js';
export class PlayerViews { export class PlayerViews {
pieceView(p, rc = null) { pieceView(p, rc = null) {
@@ -66,7 +66,13 @@ export class PlayerViews {
funds: this.funds, funds: this.funds,
bandId: this.bandId, bandId: this.bandId,
ready: this.ready, 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: { shop: {
level: this.shop.level, level: this.shop.level,
maxLevel: this.gd.maxShopLevel, maxLevel: this.gd.maxShopLevel,
+32 -24
View File
@@ -13,17 +13,19 @@
// the target's items skipped the item after a merge) // 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) // 紧急调度券 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) // 寻呼模块 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 // 教鞭 trap_create_self_choice {choice_event: hunter_band_1, choiceType PERSONAL_CHOOSE}: a personal choice of
// cards.bounty payout `perfect`, e.g. 战术特训·飞行I "若各自行动阶段就达成完美作战,获得1资金") — PRTS // one of THREE 战术特训 cards (choices.json cards.bounty payout `perfect`, e.g. 战术特训·飞行I "若各自行动阶段就
// 卫戍协议:盟约 下半/PRTS盟约记录 §法术 教鞭 "于3个战术特训的悬赏任务中选择一项", §机变阶段 "※以下悬赏任务仅由 // 达成完美作战,获得1资金") whose enemy the mode can field — PRTS 卫戍协议:盟约 下半/PRTS盟约记录 §法术 教鞭
// 法术教鞭生成", and 杜宾 加练! "<教鞭>:使用后为下场战斗添加额外敌人,若自身战斗完美作战可获得资金" (user // "使用后销毁,于3个战术特训的悬赏任务中选择一项", §机变阶段 "※以下悬赏任务仅由法术教鞭生成", and 杜宾 加练!
// playtest #6 item 4 review; the built-in drew any tier ≤ II kill bounty, and research 04 §7 [ASSUMED] // "<教鞭>:使用后为下场战斗添加额外敌人,若自身战斗完美作战可获得资金". onArt → ctx.offerBountyChoice
// the band family `enemyeffect_b_*`). // (Match.offerBountyChoice): the three cards go to the owner alone, who confirms one inside the PREP
// “神秘顾客” the same trap_create_self_choice ("选择一项特殊悬赏任务"; no band grants it in act2, research 04): a band // (g.choice with the offer's id); the prep's deadline or an AI seat resolves it (docs/META.md §2.5). Until
// bounty `enemyeffect_b_*` (research 04 §7 [ASSUMED]: "adds 1 enemy to your next battle, killer gets `coin` // 0.2.2 the engine took one of the three at random (user playtest #6 item 4 review; the built-in drew any
// funds"). // tier ≤ II kill bounty, and research 04 §7 [ASSUMED] the band family `enemyeffect_b_*`).
// Both: [ASSUMED simplification, engine: no PERSONAL_CHOOSE overlay] 3 cards whose enemy can appear in the // “神秘顾客” the same trap_create_self_choice ("选择一项特殊悬赏任务"; no band grants it in act2, research 04, and PRTS
// mode are drawn and one of them is taken at random; the built-in runs when the family is empty. // 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 // “神秘顾客” trap_disney_special: when actively destroyed, +count funds and the Art passes to the next alive player
// (seat order, cyclic) // (seat order, cyclic)
// 天师古鼎 equip_with_another_gain_coin_when_gain_char: a 【炎】 carrier also holding 炎国短刀 (either quality) // 天师古鼎 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); 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; const OFFER_SIZE = 3;
/** cards.bounty entries matching `test` whose enemy can appear in this mode. */ /** cards.bounty entries matching `test` whose enemy can appear in this mode. */
function bountyCards(ctx, test) { 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_*) // 教鞭 — trap_create_self_choice {choice_event}: the server keeps the offered 战术特训 cards until confirmed.
for (const [key, family] of [['chess_item_6_03_m', isTraining], ['chess_item_6_01_m', isBandBounty]]) { wrap(registry, 'chess_item_6_03_m', () => ({
wrap(registry, key, (base) => ({ onArt(ctx, ev) {
onArt(ctx, ev) { const result = ctx.offerBountyChoice(bountyCards(ctx, isTraining), ev.item.id);
const cards = bountyCards(ctx, family); if (!result.ok) { ev.error = result.error; ev.detail = result.detail; }
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'; } // “神秘顾客” — 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) and its equipment. The copied items // 画卷 — trap_copy_front_char: copy the operator in range (elite status included) and its equipment. The copied items
// are gained unequipped, like any gained item — the hand, overflow temp, destroyed with the usual toast when both are // are gained unequipped, like any gained item — the hand, overflow temp, destroyed with the usual toast when both are
+14 -3
View File
@@ -264,7 +264,9 @@ export function uniteLeft(battle) {
/** /**
* Progress numbers of a battle for b.progress / the teammates' waiting UI: game time, kills, total, the capsule's * Progress numbers of a battle for b.progress / the teammates' waiting UI: game time, kills, total, the capsule's
* `resolved` (this field's own scheduled enemies knocked out or leaked — Battle.resolved), counted leaks * `resolved` (this field's own scheduled enemies knocked out or leaked — Battle.resolved), counted leaks
* (normal / unite), the boss pool damage of this field and — unite fields — `left` (uniteLeft: each leaker's enemies * (normal / unite), the boss pool damage of this field, `leaksBy` of a boss / hidden field — per player, the LP the
* enemies that reached its goal cost (every leak entry the result will show, × its `lpr`; the field's LP meter minus
* their sum is the leader's own "扣除目标生命" effects) — and, unite fields, `left` (uniteLeft: each leaker's enemies
* still standing). * still standing).
*/ */
export function battleProgress(battle) { export function battleProgress(battle) {
@@ -290,6 +292,11 @@ export function battleProgress(battle) {
const left = uniteLeft(battle); const left = uniteLeft(battle);
if (left) out.left = left; if (left) out.left = left;
} }
if (battle && (battle.kind === 'boss' || battle.kind === 'hidden')) {
const leaksBy = {};
for (const pid of Object.keys(pp).slice(0, 4)) leaksBy[pid] = Math.min(1e6, leakEntries(pp[pid]).reduce((n, l) => n + leakLpr(l), 0));
out.leaksBy = leaksBy;
}
return out; return out;
} }
@@ -335,6 +342,10 @@ const cap = (list, n) => (Array.isArray(list) ? list.slice(0, n) : []);
const isKey = (v) => typeof v === 'string' && v.length > 0 && v.length <= 64 && /^[A-Za-z0-9_\-.:]+$/.test(v); const isKey = (v) => typeof v === 'string' && v.length > 0 && v.length <= 64 && /^[A-Za-z0-9_\-.:]+$/.test(v);
const uidOr = (v) => (Number.isInteger(v) && v >= 1 && v <= 0x80000000 ? v : null); const uidOr = (v) => (Number.isInteger(v) && v >= 1 && v <= 0x80000000 ? v : null);
const keyOr = (v) => (isKey(v) ? v : null); const keyOr = (v) => (isKey(v) ? v : null);
/** A player's leak entries as the b.result carries them (compactResult: a valid enemy key, at most 400). */
const leakEntries = (p) => cap(p && p.leaked, 400).filter((l) => l && isKey(l.enemyKey));
/** What one leak entry costs the team on the wire: its `lpr` (0 … 1000), 1 when absent. */
const leakLpr = (l) => Math.max(0, Math.min(1000, fnum(l && l.lpr, 1)));
function compactMods(m) { function compactMods(m) {
if (!m || typeof m !== 'object') return null; if (!m || typeof m !== 'object') return null;
@@ -365,8 +376,8 @@ export function compactResult(res) {
// NOT clamped to `total`: the denominator counts only the round's own scheduled enemies (Battle.resolved) // NOT clamped to `total`: the denominator counts only the round's own scheduled enemies (Battle.resolved)
killed: Math.max(0, Math.trunc(fnum(p.killed))), killed: Math.max(0, Math.trunc(fnum(p.killed))),
total, total,
leaked: cap(p.leaked, 400).filter((l) => l && isKey(l.enemyKey)).map((l) => { leaked: leakEntries(p).map((l) => {
const o = { enemyKey: l.enemyKey, mods: compactMods(l.mods), lpr: Math.max(0, Math.min(1000, fnum(l.lpr, 1))), sourcePlayerId: keyOr(l.sourcePlayerId), tag: typeof l.tag === 'string' && l.tag.length <= 16 ? l.tag : null, counted: l.counted !== false }; const o = { enemyKey: l.enemyKey, mods: compactMods(l.mods), lpr: leakLpr(l), sourcePlayerId: keyOr(l.sourcePlayerId), tag: typeof l.tag === 'string' && l.tag.length <= 16 ? l.tag : null, counted: l.counted !== false };
if (l.boss) o.boss = true; if (l.boss) o.boss = true;
if (l.spawned) o.spawned = true; if (l.spawned) o.spawned = true;
return o; return o;
+6 -3
View File
@@ -376,7 +376,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.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.destroy': { uid: isUid },
'g.reward': { idx: (v) => isInt(v, 0, 5) }, '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.ready': { ready: isBool },
'g.emote': { id: (v) => EMOTES.includes(v) }, '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 // playerId: the player tapped in the team panel (a 联防 / boss pair field shows two) — what an eliminated viewer or a
@@ -395,14 +395,17 @@ export const C2S = {
// `left` = { [leakerId]: its enemies still standing (unspawned, alive, or through again) } (server/sim/spec.js // `left` = { [leakerId]: its enemies still standing (unspawned, alive, or through again) } (server/sim/spec.js
// uniteLeft; user playtest #6 item 7 — the leakers' live counter). `resolved` = the HUD capsule's numerator: the // uniteLeft; user playtest #6 item 7 — the leakers' live counter). `resolved` = the HUD capsule's numerator: the
// field's own scheduled enemies that were knocked out **or leaked** (official: 漏一个 1/3, 打死一个 2/3, // field's own scheduled enemies that were knocked out **or leaked** (official: 漏一个 1/3, 打死一个 2/3,
// 打死会分裂的 3/3 — Battle.leakedInTotal; runtime splits / summons stay out of both parts of the capsule). // 打死会分裂的 3/3 — Battle.leakedInTotal; runtime splits / summons stay out of both parts of the capsule). A boss / hidden field adds
// `leaksBy` = { [playerId]: the cumulative LP the enemies that reached that player's goal cost } — `leaks` minus its
// sum is the leader's own "扣除目标生命" effects; the server holds the b.result's leaked lists to it (Match._bossLeaksAgree)
'b.progress': { 'b.progress': {
battleId: isId, gt: (v) => isNum(v, 0, 1e5), killed: (v) => isInt(v, 0, 1e5), total: (v) => isInt(v, 0, 1e5), battleId: isId, gt: (v) => isNum(v, 0, 1e5), killed: (v) => isInt(v, 0, 1e5), total: (v) => isInt(v, 0, 1e5),
resolved: (v) => isInt(v, 0, 1e5), resolved: (v) => isInt(v, 0, 1e5),
leaks: (v) => isNum(v, 0, 1e6), bossDmg: (v) => isNum(v, 0, BIG), leaks: (v) => isNum(v, 0, 1e6), bossDmg: (v) => isNum(v, 0, BIG),
by: (v) => isMap(v, RESULT_LIMITS.players, isId, (x) => isNum(x, 0, BIG)), done: isBool, by: (v) => isMap(v, RESULT_LIMITS.players, isId, (x) => isNum(x, 0, BIG)), done: isBool,
left: (v) => isMap(v, RESULT_LIMITS.players, isId, (x) => isInt(x, 0, 1e5)), left: (v) => isMap(v, RESULT_LIMITS.players, isId, (x) => isInt(x, 0, 1e5)),
$optional: ['resolved', 'leaks', 'bossDmg', 'by', 'done', 'left'], leaksBy: (v) => isMap(v, RESULT_LIMITS.players, isId, (x) => isNum(x, 0, 1e6)),
$optional: ['resolved', 'leaks', 'bossDmg', 'by', 'done', 'left', 'leaksBy'],
}, },
'b.result': { battleId: isId, result: isBattleResult }, 'b.result': { battleId: isId, result: isBattleResult },
}; };
+68 -1
View File
@@ -1140,7 +1140,23 @@ test('教鞭 (Art): a 战术特训 bounty (PRTS "于3个战术特训的悬赏任
const { m, ps } = setup({ seed: 60 + s }); const { m, ps } = setup({ seed: 60 + s });
for (let k = 0; k < 2; k++) { for (let k = 0; k < 2; k++) {
const art = giveItem(m, ps, 'chess_item_6_03_m'); 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); 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); assert.equal(ps.bounties.length, 2);
for (const b of ps.bounties) { for (const b of ps.bounties) {
@@ -1151,7 +1167,7 @@ test('教鞭 (Art): a 战术特训 bounty (PRTS "于3个战术特训的悬赏任
seen.add(b.card.effectId); 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 { h, m } = setup({ mode: 'coop', humans: 2, seed: 7 });
const p0 = h.ps('p_0'), p1 = h.ps('p_1'); const p0 = h.ps('p_0'), p1 = h.ps('p_1');
const art = giveItem(m, p0, 'chess_item_6_01_m'); const art = giveItem(m, p0, 'chess_item_6_01_m');
@@ -1162,9 +1178,60 @@ test('教鞭 (Art): a 战术特训 bounty (PRTS "于3个战术特训的悬赏任
const passed = p1.hand.find((p) => p && p.id === 'chess_item_6_01_m'); 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.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.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'); 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', () => { 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(); 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()); assert.deepEqual(never, ['人事部文档', '信标', '变形同构体', '寻呼模块', '拟态物质', '突变细胞'].sort());
+2 -1
View File
@@ -490,7 +490,8 @@ test('user playtest #6 (DESIGN §20): summons, skill triggers, blocking, push fo
assert.match(sec(17), /\*\*revised by §20\.6\*\*/); assert.match(sec(17), /\*\*revised by §20\.6\*\*/);
assert.ok(!/the own battle's count stays on show as an upper bound tagged 联防中/.test(DESIGN), '§17.5: the frozen 联防 count is gone'); assert.ok(!/the own battle's count stays on show as an upper bound tagged 联防中/.test(DESIGN), '§17.5: the frozen 联防 count is gone');
assert.match(sec(8), /uniteLeft\? \/\* 联防: the leaker's enemies still standing/); assert.match(sec(8), /uniteLeft\? \/\* 联防: the leaker's enemies still standing/);
assert.match(sec(14), /`b\.progress \{ battleId, gt, killed, total, resolved\?, leaks\?, left\?, bossDmg\?, by\?, done\? \}`/); assert.match(sec(14), /`b\.progress \{ battleId, gt, killed, total, resolved\?, leaks\?, left\?, bossDmg\?, by\?, leaksBy\?, done\? \}`/);
assert.match(doc('shared/protocol.js'), /leaksBy: \(v\) => isMap\(v, RESULT_LIMITS\.players, isId, \(x\) => isNum\(x, 0, 1e6\)\)/);
assert.match(sec(6), /3 \*\*different\*\* free chess of tier `min\(level\+1, 6\)`/); assert.match(sec(6), /3 \*\*different\*\* free chess of tier `min\(level\+1, 6\)`/);
assert.match(S20, /105 cards: 56 next-battle incl\. 源石虫·特训, 42 two-battle, 7 multi-round/); assert.match(S20, /105 cards: 56 next-battle incl\. 源石虫·特训, 42 two-battle, 7 multi-round/);
// #11 / #9 / #13: the gauge look (§18.3 / §19.5 superseded, §8.2 fill), model scale and fear (§9, §5.3, §2) // #11 / #9 / #13: the gauge look (§18.3 / §19.5 superseded, §8.2 fill), model scale and fear (§9, §5.3, §2)
+153 -157
View File
@@ -23,7 +23,7 @@
{"round":1,"ai_0":[1,27,4,0,0,1,2,"20c00c02",0,"preciShip 0/2, sargonShip 0/2",4,4,0,4]}, {"round":1,"ai_0":[1,27,4,0,0,1,2,"20c00c02",0,"preciShip 0/2, sargonShip 0/2",4,4,0,4]},
{"round":2,"ai_0":[1,27,5,0,0,2,4,"8cdbb4d3",0,"preciShip 0/2, sargonShip 0/4",8,8,0,9]}, {"round":2,"ai_0":[1,27,5,0,0,2,4,"8cdbb4d3",0,"preciShip 0/2, sargonShip 0/4",8,8,0,9]},
{"round":3,"ai_0":[1,27,6,0,0,3,6,"24c9b010",1,"indomShip 1/1, preciShip 0/2, sargonShip 0/4",7,7,0,15]}, {"round":3,"ai_0":[1,27,6,0,0,3,6,"24c9b010",1,"indomShip 1/1, preciShip 0/2, sargonShip 0/4",7,7,0,15]},
{"round":4,"ai_0":[1,27,7,0,0,3,8,"2f6d1395",16,"deputShip 1/2, indomShip 1/4, preciShip 0/2, sargonShip 1/10",10,10,0,22]}, {"round":4,"ai_0":[1,27,7,0,0,3,8,"e0ec421c",16,"deputShip 1/2, indomShip 1/4, preciShip 0/2, sargonShip 1/10",10,10,0,22]},
{"round":5,"ai_0":[1,27,8,0,0,3,8,"beffa9be",29,"deputShip 1/4, indomShip 1/7, preciShip 0/2, sargonShip 1/12, steadShip 1/6",53,16,0,29]}, {"round":5,"ai_0":[1,27,8,0,0,3,8,"beffa9be",29,"deputShip 1/4, indomShip 1/7, preciShip 0/2, sargonShip 1/12, steadShip 1/6",53,16,0,29]},
{"round":6,"ai_0":[1,27,9,0,0,4,8,"522b51a4",40,"deputShip 2/8, indomShip 0/7, preciShip 0/2, sargonShip 1/18, steadShip 2/14",28,28,0,40]}, {"round":6,"ai_0":[1,27,9,0,0,4,8,"522b51a4",40,"deputShip 2/8, indomShip 0/7, preciShip 0/2, sargonShip 1/18, steadShip 2/14",28,28,0,40]},
{"round":7,"ai_0":[1,27,10,0,0,4,8,"84e59f1d",58,"deputShip 2/12, indomShip 0/7, preciShip 0/2, sargonShip 1/24, steadShip 2/22",27,27,0,51]}, {"round":7,"ai_0":[1,27,10,0,0,4,8,"84e59f1d",58,"deputShip 2/12, indomShip 0/7, preciShip 0/2, sargonShip 1/24, steadShip 2/22",27,27,0,51]},
@@ -40,7 +40,7 @@
"title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"}, "title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"},
"trophies": 0, "trophies": 0,
"reward": 113, "reward": 113,
"stats": {"activatedLayers":78,"bossDamage":200000,"dmgDealt":776675,"gold":72,"itemsEquipped":10,"kills":173,"leaks":0,"lpLost":0,"merges":2,"perfectRounds":8,"refreshes":14}, "stats": {"activatedLayers":78,"bossDamage":200000,"dmgDealt":775946,"gold":72,"itemsEquipped":10,"kills":173,"leaks":0,"lpLost":0,"merges":2,"perfectRounds":8,"refreshes":14},
"lineup": ["4_14_a@12,7","2_08_a@12,8[4_05_e_a+2_04_e_b]","2_14_a@11,8","3_10_a@10,3","2_17_a@10,4[1_01_e_b+4_02_e_a]","4_01_b@10,7","1_12_b@10,8[3_05_e_a+5_02_e_a]","5_02_a@9,8[4_08_e_a]"], "lineup": ["4_14_a@12,7","2_08_a@12,8[4_05_e_a+2_04_e_b]","2_14_a@11,8","3_10_a@10,3","2_17_a@10,4[1_01_e_b+4_02_e_a]","4_01_b@10,7","1_12_b@10,8[3_05_e_a+5_02_e_a]","5_02_a@9,8[4_08_e_a]"],
"bonds": ["deputShip 0/16","emptyShip 1/0","indomShip 0/7","preciShip 1/7","sargonShip 1/28","steadShip 2/38","swiftShip 1/5"] "bonds": ["deputShip 0/16","emptyShip 1/0","indomShip 0/7","preciShip 1/7","sargonShip 1/28","steadShip 2/38","swiftShip 1/5"]
} }
@@ -106,8 +106,8 @@
{"round":1,"ai_0":[1,27,4,0,0,1,2,"cc1bac8f",0,"sargonShip 0/2, soloShip 1/0",5,5,0,4]}, {"round":1,"ai_0":[1,27,4,0,0,1,2,"cc1bac8f",0,"sargonShip 0/2, soloShip 1/0",5,5,0,4]},
{"round":2,"ai_0":[1,27,5,0,0,1,4,"eee78fe4",0,"kazimierzShip 0/2, sargonShip 0/4, soloShip 1/0, swiftShip 0/2",10,10,0,9]}, {"round":2,"ai_0":[1,27,5,0,0,1,4,"eee78fe4",0,"kazimierzShip 0/2, sargonShip 0/4, soloShip 1/0, swiftShip 0/2",10,10,0,9]},
{"round":3,"ai_0":[1,27,6,0,0,1,6,"a5dafe29",1,"kazimierzShip 0/2, sargonShip 0/8, soloShip 1/1, swiftShip 0/2",8,8,0,15]}, {"round":3,"ai_0":[1,27,6,0,0,1,6,"a5dafe29",1,"kazimierzShip 0/2, sargonShip 0/8, soloShip 1/1, swiftShip 0/2",8,8,0,15]},
{"round":4,"ai_0":[1,27,7,0,0,2,8,"3fbb6d23",2,"kazimierzShip 0/4, kjeragShip 0/2, sargonShip 0/8, soloShip 1/2, steadShip 0/2, swiftShip 0/4",8,8,0,22]}, {"round":4,"ai_0":[1,27,7,0,0,2,8,"71c18729",2,"kazimierzShip 0/4, kjeragShip 0/2, sargonShip 0/8, soloShip 1/2, steadShip 0/2, swiftShip 0/4",8,8,0,22]},
{"round":5,"ai_0":[1,27,8,0,0,3,8,"e6160244",3,"kazimierzShip 0/4, kjeragShip 0/2, sargonShip 0/8, soloShip 1/3, steadShip 0/2, swiftShip 0/4",21,21,0,30]}, {"round":5,"ai_0":[1,27,8,0,0,3,8,"b2a814f4",3,"kazimierzShip 0/4, kjeragShip 0/2, sargonShip 0/8, soloShip 1/3, steadShip 0/2, swiftShip 0/4",21,21,0,30]},
{ {
"round": 6, "round": 6,
"ai_0": [1,27,9,0,0,3,8,"83353941",23,"deputShip 1/2, kazimierzShip 0/4, kjeragShip 0/2, sargonShip 0/8, siracusaShip 1/8, soloShip 1/9, steadShip 1/4, swiftShip 0/4",64,19,0,39] "ai_0": [1,27,9,0,0,3,8,"83353941",23,"deputShip 1/2, kazimierzShip 0/4, kjeragShip 0/2, sargonShip 0/8, siracusaShip 1/8, soloShip 1/9, steadShip 1/4, swiftShip 0/4",64,19,0,39]
@@ -250,7 +250,7 @@
{"round":1,"ai_0":[1,27,4,0,0,1,2,"cc1bac8f",0,"sargonShip 0/2, soloShip 1/0",5,5,0,4]}, {"round":1,"ai_0":[1,27,4,0,0,1,2,"cc1bac8f",0,"sargonShip 0/2, soloShip 1/0",5,5,0,4]},
{"round":2,"ai_0":[1,27,5,0,0,1,4,"eee78fe4",0,"kazimierzShip 0/2, sargonShip 0/4, soloShip 1/0, swiftShip 0/2",10,10,0,9]}, {"round":2,"ai_0":[1,27,5,0,0,1,4,"eee78fe4",0,"kazimierzShip 0/2, sargonShip 0/4, soloShip 1/0, swiftShip 0/2",10,10,0,9]},
{"round":3,"ai_0":[1,27,6,0,1,1,6,"d5f9d956",1,"kazimierzShip 0/2, sargonShip 0/8, soloShip 1/1, swiftShip 0/2",11,9,0,15]}, {"round":3,"ai_0":[1,27,6,0,1,1,6,"d5f9d956",1,"kazimierzShip 0/2, sargonShip 0/8, soloShip 1/1, swiftShip 0/2",11,9,0,15]},
{"round":4,"ai_0":[1,27,8,0,1,2,8,"247bff16",4,"kazimierzShip 0/4, kjeragShip 0/2, sargonShip 0/8, soloShip 1/1, steadShip 1/3, swiftShip 0/4",11,9,0,23]}, {"round":4,"ai_0":[1,27,8,0,1,2,8,"9e01b865",4,"kazimierzShip 0/4, kjeragShip 0/2, sargonShip 0/8, soloShip 1/1, steadShip 1/3, swiftShip 0/4",11,9,0,23]},
{"round":5,"ai_0":[1,27,9,0,0,3,8,"8f1605f3",5,"kazimierzShip 0/4, kjeragShip 0/2, sargonShip 0/8, soloShip 1/1, steadShip 1/4, swiftShip 0/4",21,21,0,31]}, {"round":5,"ai_0":[1,27,9,0,0,3,8,"8f1605f3",5,"kazimierzShip 0/4, kjeragShip 0/2, sargonShip 0/8, soloShip 1/1, steadShip 1/4, swiftShip 0/4",21,21,0,31]},
{ {
"round": 6, "round": 6,
@@ -387,58 +387,54 @@
"combat": "client", "combat": "client",
"boost": null, "boost": null,
"setup": {"stageId":"act1autochess_m03","bossId":"boss_5","hiddenBossId":"boss_8","factions":["TIMES","DOT","INVISIBLE"]}, "setup": {"stageId":"act1autochess_m03","bossId":"boss_5","hiddenBossId":"boss_8","factions":["TIMES","DOT","INVISIBLE"]},
"end": {"victory":false,"roundsPassed":11,"hiddenReached":false,"hiddenCleared":false,"reason":"eliminated","teamLp":null,"bossPoolLeft":null,"virtualMs":94500}, "end": {"victory":false,"roundsPassed":10,"hiddenReached":false,"hiddenCleared":false,"reason":"eliminated","teamLp":null,"bossPoolLeft":null,"virtualMs":87100},
"rng": {"setup":25,"shop":121,"waves":0,"draft":42,"bots":7368,"meta":2}, "rng": {"setup":25,"shop":114,"waves":0,"draft":42,"bots":6638,"meta":1},
"errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0}, "errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0},
"rounds": [ "rounds": [
{"round":1,"ai_0":[1,27,4,0,0,1,2,"cc1bac8f",0,"sargonShip 0/2, soloShip 1/0",5,5,0,4]}, {"round":1,"ai_0":[1,27,4,0,0,1,2,"cc1bac8f",0,"sargonShip 0/2, soloShip 1/0",5,5,0,4]},
{"round":2,"ai_0":[1,27,5,0,0,1,4,"eee78fe4",0,"kazimierzShip 0/2, sargonShip 0/4, soloShip 1/0, swiftShip 0/2",10,10,0,9]}, {"round":2,"ai_0":[1,27,5,0,0,1,4,"eee78fe4",0,"kazimierzShip 0/2, sargonShip 0/4, soloShip 1/0, swiftShip 0/2",10,10,0,9]},
{"round":3,"ai_0":[1,27,6,0,1,1,6,"d5f9d956",1,"kazimierzShip 0/2, sargonShip 0/8, soloShip 1/1, swiftShip 0/2",11,9,0,15]}, {"round":3,"ai_0":[1,27,6,0,1,1,6,"d5f9d956",1,"kazimierzShip 0/2, sargonShip 0/8, soloShip 1/1, swiftShip 0/2",11,9,0,15]},
{"round":4,"ai_0":[1,27,8,0,1,2,8,"247bff16",4,"kazimierzShip 0/4, kjeragShip 0/2, sargonShip 0/8, soloShip 1/1, steadShip 1/3, swiftShip 0/4",11,9,0,23]}, {"round":4,"ai_0":[1,24,8,0,1,2,8,"dd7803fd",4,"kazimierzShip 0/4, kjeragShip 0/2, sargonShip 0/8, soloShip 1/1, steadShip 1/3, swiftShip 0/4",8,9,3,23]},
{"round":5,"ai_0":[1,27,9,0,0,3,8,"5fdaa5e6",5,"kazimierzShip 0/4, kjeragShip 0/2, sargonShip 0/8, soloShip 1/1, steadShip 1/4, swiftShip 0/4",21,21,0,31]}, {"round":5,"ai_0":[1,24,9,0,0,3,8,"5fdaa5e6",5,"kazimierzShip 0/4, kjeragShip 0/2, sargonShip 0/8, soloShip 1/1, steadShip 1/4, swiftShip 0/4",21,21,0,31]},
{ {
"round": 6, "round": 6,
"ai_0": [1,27,9,0,0,3,8,"3bdfd652",23,"deputShip 1/2, kazimierzShip 0/4, kjeragShip 0/2, sargonShip 0/8, siracusaShip 1/8, soloShip 1/3, steadShip 1/10, swiftShip 0/4",56,17,0,40] "ai_0": [1,24,9,0,0,3,8,"3bdfd652",23,"deputShip 1/2, kazimierzShip 0/4, kjeragShip 0/2, sargonShip 0/8, siracusaShip 1/8, soloShip 1/3, steadShip 1/10, swiftShip 0/4",56,17,0,40]
}, },
{ {
"round": 7, "round": 7,
"ai_0": [1,27,10,0,0,4,8,"1e743cfe",38,"deputShip 1/4, kazimierzShip 0/4, kjeragShip 0/2, sargonShip 0/8, siracusaShip 1/16, soloShip 1/5, steadShip 1/13, swiftShip 0/4",28,28,0,50] "ai_0": [1,24,10,0,0,4,8,"1e743cfe",38,"deputShip 1/4, kazimierzShip 0/4, kjeragShip 0/2, sargonShip 0/8, siracusaShip 1/16, soloShip 1/5, steadShip 1/13, swiftShip 0/4",28,28,0,50]
}, },
{ {
"round": 8, "round": 8,
"ai_0": [1,22,11,0,0,4,8,"0ccee9fb",70,"deputShip 1/6, emptyShip 1/2, kazimierzShip 0/8, kjeragShip 1/9, preciShip 0/5, sargonShip 0/8, siracusaShip 1/31, soloShip 1/7, steadShip 1/15, swiftShip 0/8",14,19,5,61] "ai_0": [1,19,11,0,0,4,8,"0ccee9fb",70,"deputShip 1/6, emptyShip 1/2, kazimierzShip 0/8, kjeragShip 1/9, preciShip 0/5, sargonShip 0/8, siracusaShip 1/31, soloShip 1/7, steadShip 1/15, swiftShip 0/8",14,19,5,61]
}, },
{ {
"round": 9, "round": 9,
"ai_0": [1,22,12,0,0,4,8,"8f39e18f",106,"arcaneShip 1/10, deputShip 1/8, emptyShip 1/4, kazimierzShip 0/8, kjeragShip 1/16, preciShip 1/14, sargonShip 0/8, siracusaShip 1/45, soloShip 1/9, steadShip 0/15, swiftShip 0/8",34,34,0,75] "ai_0": [1,19,12,0,0,4,8,"8f39e18f",106,"arcaneShip 1/10, deputShip 1/8, emptyShip 1/4, kazimierzShip 0/8, kjeragShip 1/16, preciShip 1/14, sargonShip 0/8, siracusaShip 1/45, soloShip 1/9, steadShip 0/15, swiftShip 0/8",34,34,0,75]
}, },
{ {
"round": 10, "round": 10,
"ai_0": [1,12,12,0,0,5,8,"2c008a00",122,"arcaneShip 1/12, deputShip 1/10, emptyShip 1/6, kazimierzShip 0/8, kjeragShip 1/18, preciShip 1/16, sargonShip 0/8, siracusaShip 1/49, soloShip 1/11, steadShip 0/15, swiftShip 0/8",14,24,10,86] "ai_0": [1,9,12,0,0,5,8,"2c008a00",122,"arcaneShip 1/12, deputShip 1/10, emptyShip 1/6, kazimierzShip 0/8, kjeragShip 1/18, preciShip 1/16, sargonShip 0/8, siracusaShip 1/49, soloShip 1/11, steadShip 0/15, swiftShip 0/8",14,24,10,86]
}, },
{ {
"round": 11, "round": 11,
"ai_0": [1,2,12,0,1,5,8,"662804e8",152,"arcaneShip 1/18, deputShip 1/12, emptyShip 1/8, kazimierzShip 0/8, kjeragShip 1/22, preciShip 1/18, sargonShip 0/8, siracusaShip 1/61, soloShip 1/13, steadShip 0/17, swiftShip 0/8",22,34,12,98] "ai_0": [0,0,12,0,0,5,0,"811c9dc5",0,"arcaneShip 0/18, deputShip 0/12, emptyShip 0/8, kazimierzShip 0/8, kjeragShip 0/22, preciShip 0/18, sargonShip 0/8, siracusaShip 0/61, soloShip 0/13, steadShip 0/17, swiftShip 0/8",22,34,12,98]
},
{
"round": 12,
"ai_0": [0,0,13,0,0,6,0,"811c9dc5",0,"arcaneShip 0/18, deputShip 0/14, emptyShip 0/10, kazimierzShip 0/8, kjeragShip 0/27, preciShip 0/23, sargonShip 0/8, siracusaShip 0/69, soloShip 0/15, steadShip 0/17, swiftShip 0/8",9,31,22,111]
} }
], ],
"players": { "players": {
"ai_0": { "ai_0": {
"alive": false, "alive": false,
"victory": false, "victory": false,
"roundsPassed": 11, "roundsPassed": 10,
"eliminatedRound": 12, "eliminatedRound": 11,
"lp": 0, "lp": 0,
"bandId": "band_ermengard", "bandId": "band_ermengard",
"title": {"id":"comment_4","name":"精英云集","picId":"comment_icon_4","text":"晋升精锐次数最多"}, "title": {"id":"comment_4","name":"精英云集","picId":"comment_icon_4","text":"晋升精锐次数最多"},
"trophies": 0, "trophies": 0,
"reward": 255, "reward": 213,
"stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":1683862,"gold":111,"itemsEquipped":13,"kills":235,"leaks":49,"lpLost":35,"merges":2,"perfectRounds":8,"refreshes":6}, "stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":1352800,"gold":98,"itemsEquipped":12,"kills":223,"leaks":30,"lpLost":28,"merges":2,"perfectRounds":7,"refreshes":6},
"lineup": [], "lineup": [],
"bonds": ["arcaneShip 0/18","deputShip 0/14","emptyShip 0/10","kazimierzShip 0/8","kjeragShip 0/27","preciShip 0/23","sargonShip 0/8","siracusaShip 0/69","soloShip 0/15","steadShip 0/17","swiftShip 0/8"] "bonds": ["arcaneShip 0/18","deputShip 0/12","emptyShip 0/8","kazimierzShip 0/8","kjeragShip 0/22","preciShip 0/18","sargonShip 0/8","siracusaShip 0/61","soloShip 0/13","steadShip 0/17","swiftShip 0/8"]
} }
} }
}, },
@@ -651,48 +647,48 @@
}, },
{ {
"round": 4, "round": 4,
"ai_0": [1,25,8,0,1,2,8,"0c0d931c",5,"kazimierzShip 0/2, kjeragShip 0/2, soloShip 1/3, steadShip 2/2, swiftShip 0/2",12,12,0,23], "ai_0": [1,25,8,0,1,2,8,"30629777",5,"kazimierzShip 0/2, kjeragShip 0/2, soloShip 1/3, steadShip 2/2, swiftShip 0/2",12,12,0,23],
"ai_1": [1,26,8,0,1,2,8,"81a34b39",0,"arcaneShip 0/2, kazimierzShip 0/2, preciShip 0/2, sargonShip 0/4, soloShip 1/0, swiftShip 0/2",11,12,1,23] "ai_1": [1,25,8,0,1,2,8,"81a34b39",0,"arcaneShip 0/2, kazimierzShip 0/2, preciShip 0/2, sargonShip 0/4, soloShip 1/0, swiftShip 0/2",11,12,1,23]
}, },
{ {
"round": 5, "round": 5,
"ai_0": [1,25,9,0,0,3,8,"9d0d6b64",7,"kazimierzShip 0/2, kjeragShip 0/2, soloShip 1/3, steadShip 2/2, swiftShip 1/2",18,18,0,32], "ai_0": [1,25,9,0,0,3,8,"9d0d6b64",7,"kazimierzShip 0/2, kjeragShip 0/2, soloShip 1/3, steadShip 2/2, swiftShip 1/2",18,18,0,32],
"ai_1": [1,26,9,0,0,3,8,"7a9c7584",0,"arcaneShip 0/2, kazimierzShip 0/2, preciShip 0/2, sargonShip 0/4, skillfulShip 1/0, soloShip 1/0, swiftShip 0/2",18,18,0,32] "ai_1": [1,25,9,0,0,3,8,"7a9c7584",0,"arcaneShip 0/2, kazimierzShip 0/2, preciShip 0/2, sargonShip 0/4, skillfulShip 1/0, soloShip 1/0, swiftShip 0/2",18,18,0,32]
}, },
{ {
"round": 6, "round": 6,
"ai_0": [1,25,9,0,0,3,8,"a0e6e026",9,"kazimierzShip 1/2, kjeragShip 0/2, skillfulShip 1/0, soloShip 1/3, steadShip 2/2, swiftShip 1/2",24,24,0,41], "ai_0": [1,25,9,0,0,3,8,"a0e6e026",9,"kazimierzShip 1/2, kjeragShip 0/2, skillfulShip 1/0, soloShip 1/3, steadShip 2/2, swiftShip 1/2",24,24,0,41],
"ai_1": [1,26,9,0,0,3,8,"e1a93e5c",2,"arcaneShip 0/2, kazimierzShip 0/2, preciShip 0/2, sargonShip 0/4, skillfulShip 1/0, soloShip 1/0, swiftShip 1/2",24,24,0,43] "ai_1": [1,25,9,0,0,3,8,"e1a93e5c",2,"arcaneShip 0/2, kazimierzShip 0/2, preciShip 0/2, sargonShip 0/4, skillfulShip 1/0, soloShip 1/0, swiftShip 1/2",24,24,0,43]
}, },
{ {
"round": 7, "round": 7,
"ai_0": [1,25,10,0,0,4,8,"7dbd16ef",20,"kazimierzShip 1/2, kjeragShip 0/2, raidShip 1/4, skillfulShip 1/3, soloShip 1/3, steadShip 1/6, swiftShip 1/2",28,28,0,51], "ai_0": [1,25,10,0,0,4,8,"7dbd16ef",20,"kazimierzShip 1/2, kjeragShip 0/2, raidShip 1/4, skillfulShip 1/3, soloShip 1/3, steadShip 1/6, swiftShip 1/2",28,28,0,51],
"ai_1": [1,26,10,0,2,4,8,"6a9b4c34",2,"arcaneShip 0/2, kazimierzShip 0/2, preciShip 0/2, sargonShip 0/4, skillfulShip 1/0, soloShip 1/0, swiftShip 1/2",28,28,0,53] "ai_1": [1,25,10,0,2,4,8,"6a9b4c34",2,"arcaneShip 0/2, kazimierzShip 0/2, preciShip 0/2, sargonShip 0/4, skillfulShip 1/0, soloShip 1/0, swiftShip 1/2",28,28,0,53]
}, },
{ {
"round": 8, "round": 8,
"ai_0": [1,25,11,0,0,4,8,"50b7d162",57,"kazimierzShip 1/16, kjeragShip 0/2, raidShip 1/21, skillfulShip 1/6, soloShip 1/3, steadShip 2/11, swiftShip 0/4",9,9,0,61], "ai_0": [1,25,11,0,0,4,8,"50b7d162",57,"kazimierzShip 1/16, kjeragShip 0/2, raidShip 1/21, skillfulShip 1/6, soloShip 1/3, steadShip 2/11, swiftShip 0/4",9,9,0,61],
"ai_1": [1,26,13,0,0,4,8,"bdd566ef",15,"arcaneShip 0/2, kazimierzShip 0/2, preciShip 2/15, sargonShip 0/4, siracusaShip 1/0, skillfulShip 1/0, soloShip 1/0, swiftShip 0/2",6,9,3,66] "ai_1": [1,25,13,0,0,4,8,"bdd566ef",15,"arcaneShip 0/2, kazimierzShip 0/2, preciShip 2/15, sargonShip 0/4, siracusaShip 1/0, skillfulShip 1/0, soloShip 1/0, swiftShip 0/2",6,9,3,66]
}, },
{ {
"round": 9, "round": 9,
"ai_0": [1,25,12,0,2,4,8,"d4f6e277",74,"kazimierzShip 1/19, kjeragShip 0/2, raidShip 1/32, skillfulShip 0/9, soloShip 1/3, steadShip 2/12, suntShip 1/0, swiftShip 1/8",23,23,0,73], "ai_0": [1,25,12,0,2,4,8,"d4f6e277",74,"kazimierzShip 1/19, kjeragShip 0/2, raidShip 1/32, skillfulShip 0/9, soloShip 1/3, steadShip 2/12, suntShip 1/0, swiftShip 1/8",23,23,0,73],
"ai_1": [1,25,12,0,1,4,8,"f01fb873",39,"arcaneShip 0/2, kazimierzShip 0/2, preciShip 1/28, sargonShip 0/4, siracusaShip 1/4, skillfulShip 1/0, soloShip 1/0, swiftShip 1/7",17,23,6,78] "ai_1": [1,24,12,0,1,4,8,"f01fb873",39,"arcaneShip 0/2, kazimierzShip 0/2, preciShip 1/28, sargonShip 0/4, siracusaShip 1/4, skillfulShip 1/0, soloShip 1/0, swiftShip 1/7",17,23,6,78]
}, },
{ {
"round": 10, "round": 10,
"ai_0": [1,23,14,0,1,5,8,"eb530a8d",86,"kazimierzShip 1/25, kjeragShip 0/2, raidShip 1/32, skillfulShip 0/9, soloShip 1/3, steadShip 2/12, suntShip 1/0, swiftShip 1/14",6,8,2,88], "ai_0": [1,23,14,0,1,5,8,"eb530a8d",86,"kazimierzShip 1/25, kjeragShip 0/2, raidShip 1/32, skillfulShip 0/9, soloShip 1/3, steadShip 2/12, suntShip 1/0, swiftShip 1/14",6,8,2,88],
"ai_1": [1,22,13,0,0,5,8,"b403dc5e",61,"arcaneShip 0/2, kazimierzShip 0/2, preciShip 1/41, sargonShip 0/4, siracusaShip 1/4, skillfulShip 1/2, soloShip 1/0, suntShip 1/0, swiftShip 1/14",5,8,3,91] "ai_1": [1,21,13,0,0,5,8,"b403dc5e",61,"arcaneShip 0/2, kazimierzShip 0/2, preciShip 1/41, sargonShip 0/4, siracusaShip 1/4, skillfulShip 1/2, soloShip 1/0, suntShip 1/0, swiftShip 1/14",5,8,3,91]
}, },
{ {
"round": 11, "round": 11,
"ai_0": [1,13,13,0,1,5,8,"5c79cc63",105,"kazimierzShip 1/29, kjeragShip 0/2, raidShip 1/38, skillfulShip 0/9, soloShip 1/3, steadShip 2/13, suntShip 1/0, swiftShip 1/22",13,27,14,100], "ai_0": [1,13,13,0,1,5,8,"5c79cc63",105,"kazimierzShip 1/29, kjeragShip 0/2, raidShip 1/38, skillfulShip 0/9, soloShip 1/3, steadShip 2/13, suntShip 1/0, swiftShip 1/22",13,27,14,100],
"ai_1": [1,12,12,0,1,5,8,"33d685b9",87,"arcaneShip 0/2, kazimierzShip 0/2, preciShip 1/54, sargonShip 0/4, siracusaShip 1/8, skillfulShip 1/4, soloShip 1/0, suntShip 1/0, swiftShip 1/21",8,27,19,102] "ai_1": [1,11,12,0,1,5,8,"33d685b9",87,"arcaneShip 0/2, kazimierzShip 0/2, preciShip 1/54, sargonShip 0/4, siracusaShip 1/8, skillfulShip 1/4, soloShip 1/0, suntShip 1/0, swiftShip 1/21",8,27,19,102]
}, },
{ {
"round": 12, "round": 12,
"ai_0": [1,3,13,0,0,6,8,"a701cd86",125,"kazimierzShip 1/29, kjeragShip 0/2, raidShip 1/49, skillfulShip 0/9, soloShip 1/3, steadShip 2/18, suntShip 1/0, swiftShip 1/26, visiShip 1/0",31,50,19,114], "ai_0": [1,3,13,0,0,6,8,"a701cd86",125,"kazimierzShip 1/29, kjeragShip 0/2, raidShip 1/49, skillfulShip 0/9, soloShip 1/3, steadShip 2/18, suntShip 1/0, swiftShip 1/26, visiShip 1/0",31,50,19,114],
"ai_1": [1,2,13,0,0,6,8,"62b855c3",71,"arcaneShip 0/2, kazimierzShip 0/2, preciShip 1/63, sargonShip 0/4, siracusaShip 2/8, skillfulShip 0/6, soloShip 1/0, suntShip 1/0, swiftShip 0/23",23,50,27,114] "ai_1": [1,1,13,0,0,6,8,"62b855c3",71,"arcaneShip 0/2, kazimierzShip 0/2, preciShip 1/63, sargonShip 0/4, siracusaShip 2/8, skillfulShip 0/6, soloShip 1/0, suntShip 1/0, swiftShip 0/23",23,50,27,114]
}, },
{ {
"round": 13, "round": 13,
@@ -711,7 +707,7 @@
"title": {"id":"comment_4","name":"精英云集","picId":"comment_icon_4","text":"晋升精锐次数最多"}, "title": {"id":"comment_4","name":"精英云集","picId":"comment_icon_4","text":"晋升精锐次数最多"},
"trophies": 3, "trophies": 3,
"reward": 298, "reward": 298,
"stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":2920409,"gold":127,"itemsEquipped":9,"kills":226,"leaks":65,"lpLost":32,"merges":4,"perfectRounds":9,"refreshes":12}, "stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":2915613,"gold":127,"itemsEquipped":9,"kills":225,"leaks":65,"lpLost":32,"merges":4,"perfectRounds":9,"refreshes":12},
"lineup": [], "lineup": [],
"bonds": ["kazimierzShip 0/45","kjeragShip 0/10","miraShip 0/7","raidShip 0/49","skillfulShip 0/9","soloShip 0/3","steadShip 0/18","swiftShip 0/46","yanShip 0/6"] "bonds": ["kazimierzShip 0/45","kjeragShip 0/10","miraShip 0/7","raidShip 0/49","skillfulShip 0/9","soloShip 0/3","steadShip 0/18","swiftShip 0/46","yanShip 0/6"]
}, },
@@ -725,7 +721,7 @@
"title": {"id":"comment_5","name":"万事俱备","picId":"comment_icon_5","text":"装备配发数量最多"}, "title": {"id":"comment_5","name":"万事俱备","picId":"comment_icon_5","text":"装备配发数量最多"},
"trophies": 3, "trophies": 3,
"reward": 298, "reward": 298,
"stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":2271756,"gold":126,"itemsEquipped":11,"kills":194,"leaks":88,"lpLost":34,"merges":3,"perfectRounds":6,"refreshes":23}, "stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":2271756,"gold":126,"itemsEquipped":11,"kills":194,"leaks":88,"lpLost":35,"merges":3,"perfectRounds":6,"refreshes":23},
"lineup": [], "lineup": [],
"bonds": ["arcaneShip 0/2","kazimierzShip 0/2","preciShip 0/63","sargonShip 0/4","siracusaShip 0/24","skillfulShip 0/14","swiftShip 0/28"] "bonds": ["arcaneShip 0/2","kazimierzShip 0/2","preciShip 0/63","sargonShip 0/4","siracusaShip 0/24","skillfulShip 0/14","swiftShip 0/28"]
} }
@@ -857,7 +853,7 @@
"title": {"id":"comment_5","name":"万事俱备","picId":"comment_icon_5","text":"装备配发数量最多"}, "title": {"id":"comment_5","name":"万事俱备","picId":"comment_icon_5","text":"装备配发数量最多"},
"trophies": 2, "trophies": 2,
"reward": 191, "reward": 191,
"stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":1238011,"gold":83,"itemsEquipped":7,"kills":160,"leaks":67,"lpLost":30,"merges":2,"perfectRounds":2,"refreshes":14}, "stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":1186269,"gold":83,"itemsEquipped":7,"kills":160,"leaks":67,"lpLost":30,"merges":2,"perfectRounds":2,"refreshes":14},
"lineup": [], "lineup": [],
"bonds": ["indomShip 0/2","siracusaShip 0/80","soloShip 0/4","swiftShip 0/6","yanShip 0/1"] "bonds": ["indomShip 0/2","siracusaShip 0/80","soloShip 0/4","swiftShip 0/6","yanShip 0/1"]
} }
@@ -874,7 +870,7 @@
"boost": null, "boost": null,
"setup": {"stageId":"act1autochess_m01","bossId":"boss_3","hiddenBossId":null,"factions":["TIMES","ELEMENT","REFLECTION"]}, "setup": {"stageId":"act1autochess_m01","bossId":"boss_3","hiddenBossId":null,"factions":["TIMES","ELEMENT","REFLECTION"]},
"end": {"victory":true,"roundsPassed":14,"hiddenReached":false,"hiddenCleared":false,"reason":"victory","teamLp":130,"bossPoolLeft":0,"virtualMs":130000}, "end": {"victory":true,"roundsPassed":14,"hiddenReached":false,"hiddenCleared":false,"reason":"victory","teamLp":130,"bossPoolLeft":0,"virtualMs":130000},
"rng": {"setup":16,"shop":910,"waves":0,"draft":25,"bots":35892,"meta":30}, "rng": {"setup":16,"shop":832,"waves":0,"draft":25,"bots":35892,"meta":31},
"errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0}, "errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0},
"rounds": [ "rounds": [
{ {
@@ -893,7 +889,7 @@
}, },
{ {
"round": 3, "round": 3,
"ai_0": [1,26,6,0,0,1,6,"762d2060",4,"deputShip 1/4, kjeragShip 0/2, sargonShip 0/13, steadShip 0/2",6,6,0,15], "ai_0": [1,26,6,0,0,1,6,"21f68a64",4,"deputShip 1/4, kjeragShip 0/2, sargonShip 0/13, steadShip 0/2",6,6,0,15],
"ai_1": [1,45,6,0,0,1,7,"24a9876d",0,"preciShip 0/2",6,6,0,15], "ai_1": [1,45,6,0,0,1,7,"24a9876d",0,"preciShip 0/2",6,6,0,15],
"ai_2": [1,28,6,0,0,1,6,"683d4946",2,"sargonShip 0/8, siracusaShip 1/2",6,6,0,15], "ai_2": [1,28,6,0,0,1,6,"683d4946",2,"sargonShip 0/8, siracusaShip 1/2",6,6,0,15],
"ai_3": [1,31,6,0,0,1,6,"3694ec95",4,"preciShip 1/4, siracusaShip 0/3, yanShip 0/1",6,6,0,15] "ai_3": [1,31,6,0,0,1,6,"3694ec95",4,"preciShip 1/4, siracusaShip 0/3, yanShip 0/1",6,6,0,15]
@@ -921,52 +917,52 @@
}, },
{ {
"round": 7, "round": 7,
"ai_0": [1,26,10,0,0,4,8,"caacae15",53,"deputShip 2/34, kjeragShip 0/2, sargonShip 0/15, steadShip 2/19",29,29,0,52], "ai_0": [1,26,10,0,0,4,8,"58c5f5d4",52,"deputShip 2/34, kjeragShip 0/2, sargonShip 0/15, steadShip 2/18",29,29,0,52],
"ai_1": [1,45,10,0,0,4,8,"ec27f6eb",45,"deputShip 2/30, preciShip 0/4, sargonShip 0/2, siracusaShip 1/15",29,29,0,49], "ai_1": [1,45,10,0,0,4,8,"ec27f6eb",45,"deputShip 2/30, preciShip 0/4, sargonShip 0/2, siracusaShip 1/15",29,29,0,49],
"ai_2": [1,28,10,0,0,4,8,"15280eab",16,"sargonShip 0/8, siracusaShip 1/16, swiftShip 0/6, visiShip 1/0, yanShip 0/1",29,29,0,50], "ai_2": [1,28,10,0,0,4,8,"15280eab",16,"sargonShip 0/8, siracusaShip 1/16, swiftShip 0/6, visiShip 1/0, yanShip 0/1",29,29,0,50],
"ai_3": [1,31,10,0,1,4,8,"53fd121b",82,"deputShip 2/12, emptyShip 1/8, kjeragShip 0/20, preciShip 1/53, siracusaShip 0/3, swiftShip 1/9, yanShip 0/1",29,29,0,50] "ai_3": [1,31,10,0,1,4,8,"53fd121b",82,"deputShip 2/12, emptyShip 1/8, kjeragShip 0/20, preciShip 1/53, siracusaShip 0/3, swiftShip 1/9, yanShip 0/1",29,29,0,50]
}, },
{ {
"round": 8, "round": 8,
"ai_0": [1,26,11,0,0,4,8,"eba0dfca",119,"deputShip 1/41, kjeragShip 0/2, preciShip 2/20, sargonShip 1/29, steadShip 1/29",15,15,0,61], "ai_0": [1,26,11,0,0,4,8,"eba0dfca",118,"deputShip 1/41, kjeragShip 0/2, preciShip 2/20, sargonShip 1/29, steadShip 1/28",15,15,0,61],
"ai_1": [1,45,11,0,0,4,8,"528da1bc",65,"deputShip 2/38, preciShip 0/4, sargonShip 0/2, siracusaShip 1/23, steadShip 1/4",15,15,0,60], "ai_1": [1,45,11,0,0,4,8,"528da1bc",65,"deputShip 2/38, preciShip 0/4, sargonShip 0/2, siracusaShip 1/23, steadShip 1/4",15,15,0,60],
"ai_2": [1,28,11,0,0,4,8,"dfed3d0a",48,"sargonShip 0/8, siracusaShip 1/36, swiftShip 1/12, visiShip 1/0, yanShip 0/1",15,15,0,59], "ai_2": [1,28,11,0,0,4,8,"dfed3d0a",48,"sargonShip 0/8, siracusaShip 1/36, swiftShip 1/12, visiShip 1/0, yanShip 0/1",15,15,0,59],
"ai_3": [1,31,12,0,1,4,7,"363e51cd",130,"deputShip 1/14, emptyShip 1/10, kjeragShip 1/27, preciShip 1/63, siracusaShip 0/3, swiftShip 1/16, yanShip 0/1",15,15,0,63] "ai_3": [1,31,12,0,1,4,7,"363e51cd",130,"deputShip 1/14, emptyShip 1/10, kjeragShip 1/27, preciShip 1/63, siracusaShip 0/3, swiftShip 1/16, yanShip 0/1",15,15,0,63]
}, },
{ {
"round": 9, "round": 9,
"ai_0": [1,26,12,0,0,4,8,"fc454676",165,"deputShip 1/47, kjeragShip 0/2, preciShip 2/38, sargonShip 1/41, steadShip 1/39",30,30,0,74], "ai_0": [1,26,12,0,0,4,8,"fc454676",164,"deputShip 1/47, kjeragShip 0/2, preciShip 2/38, sargonShip 1/41, steadShip 1/38",30,30,0,74],
"ai_1": [1,45,12,0,0,4,8,"33bd98dc",83,"deputShip 2/48, emptyShip 1/4, preciShip 0/4, sargonShip 0/2, siracusaShip 1/31, steadShip 0/4",30,30,0,70], "ai_1": [1,45,12,0,0,4,8,"33bd98dc",83,"deputShip 2/48, emptyShip 1/4, preciShip 0/4, sargonShip 0/2, siracusaShip 1/31, steadShip 0/4",30,30,0,70],
"ai_2": [1,28,12,0,0,4,8,"6965081a",70,"sargonShip 0/8, siracusaShip 1/58, swiftShip 1/12, visiShip 1/0, yanShip 0/1",30,30,0,72], "ai_2": [1,28,12,0,0,4,8,"6965081a",70,"sargonShip 0/8, siracusaShip 1/58, swiftShip 1/12, visiShip 1/0, yanShip 0/1",30,30,0,72],
"ai_3": [1,31,13,0,0,4,8,"9a60eaff",155,"deputShip 1/16, emptyShip 1/12, kjeragShip 1/32, preciShip 1/72, siracusaShip 0/3, swiftShip 1/23, yanShip 0/1",30,30,0,76] "ai_3": [1,31,13,0,0,4,8,"9a60eaff",155,"deputShip 1/16, emptyShip 1/12, kjeragShip 1/32, preciShip 1/72, siracusaShip 0/3, swiftShip 1/23, yanShip 0/1",30,30,0,76]
}, },
{ {
"round": 10, "round": 10,
"ai_0": [1,26,12,0,0,5,8,"2b6a123a",239,"deputShip 1/49, kjeragShip 0/2, preciShip 2/70, sargonShip 1/67, steadShip 1/53",29,29,0,86], "ai_0": [1,26,12,0,0,5,8,"1f68e81b",230,"deputShip 1/53, kjeragShip 0/2, preciShip 2/66, sargonShip 1/63, steadShip 1/48",29,29,0,86],
"ai_1": [1,45,12,0,0,5,8,"d8c7d895",101,"deputShip 2/58, emptyShip 1/8, preciShip 0/4, sargonShip 0/2, siracusaShip 1/35, steadShip 0/4",29,29,0,82], "ai_1": [1,45,12,0,0,5,8,"d8c7d895",101,"deputShip 2/58, emptyShip 1/8, preciShip 0/4, sargonShip 0/2, siracusaShip 1/35, steadShip 0/4",29,29,0,82],
"ai_2": [1,28,12,0,0,5,8,"fe139ef2",72,"sargonShip 0/8, siracusaShip 2/60, swiftShip 1/12, visiShip 1/0, yanShip 0/1",29,29,0,83], "ai_2": [1,28,12,0,0,5,8,"fe139ef2",72,"sargonShip 0/8, siracusaShip 2/60, swiftShip 1/12, visiShip 1/0, yanShip 0/1",29,29,0,83],
"ai_3": [1,31,12,0,0,5,7,"957d85d7",169,"deputShip 1/18, emptyShip 0/14, kjeragShip 1/40, preciShip 1/81, siracusaShip 0/3, swiftShip 1/30, yanShip 0/1",29,29,0,88] "ai_3": [1,31,12,0,0,5,7,"957d85d7",169,"deputShip 1/18, emptyShip 0/14, kjeragShip 1/40, preciShip 1/81, siracusaShip 0/3, swiftShip 1/30, yanShip 0/1",29,29,0,88]
}, },
{ {
"round": 11, "round": 11,
"ai_0": [1,26,12,0,0,5,8,"a6455ca9",219,"deputShip 0/49, kjeragShip 0/2, preciShip 2/85, sargonShip 1/73, steadShip 2/61",28,28,0,98], "ai_0": [1,26,12,0,0,5,8,"a6455ca9",206,"deputShip 0/53, kjeragShip 0/2, preciShip 2/81, sargonShip 1/69, steadShip 2/56",28,28,0,98],
"ai_1": [1,45,12,0,0,5,8,"8d2d3b28",139,"deputShip 2/68, emptyShip 1/15, indomShip 1/6, preciShip 0/4, sargonShip 0/2, siracusaShip 1/50, steadShip 0/4",13,28,15,95], "ai_1": [1,45,12,0,0,5,8,"8d2d3b28",139,"deputShip 2/68, emptyShip 1/15, indomShip 1/6, preciShip 0/4, sargonShip 0/2, siracusaShip 1/50, steadShip 0/4",13,28,15,95],
"ai_2": [1,28,12,0,0,5,8,"11fc8c7b",106,"emptyShip 1/0, sargonShip 0/8, siracusaShip 2/94, swiftShip 1/12, yanShip 0/1",27,28,1,97], "ai_2": [1,28,12,0,0,5,8,"98a908c4",138,"emptyShip 1/0, sargonShip 0/8, siracusaShip 2/114, swiftShip 1/24, yanShip 0/1",28,28,0,95],
"ai_3": [1,31,12,0,0,5,7,"b2ef4516",206,"deputShip 1/20, emptyShip 0/14, kjeragShip 1/55, preciShip 1/94, siracusaShip 0/3, swiftShip 1/37, yanShip 0/1",28,28,0,100] "ai_3": [1,31,12,0,0,5,7,"e35238bd",203,"deputShip 1/20, emptyShip 0/14, kjeragShip 1/52, preciShip 1/94, siracusaShip 0/3, swiftShip 1/37, yanShip 0/1",28,28,0,100]
}, },
{ {
"round": 12, "round": 12,
"ai_0": [1,26,12,0,0,6,8,"3fc98b94",258,"deputShip 0/49, kjeragShip 0/2, preciShip 2/106, sargonShip 1/83, steadShip 2/69",46,46,0,112], "ai_0": [1,26,12,0,0,6,8,"6982f097",241,"deputShip 0/53, kjeragShip 0/2, preciShip 2/100, sargonShip 1/77, steadShip 2/64",46,46,0,113],
"ai_1": [1,45,12,0,0,6,8,"c13985aa",151,"deputShip 2/78, emptyShip 1/19, indomShip 0/6, preciShip 0/4, sargonShip 0/2, siracusaShip 1/54, steadShip 0/4",24,46,22,107], "ai_1": [1,45,12,0,0,6,8,"9409d711",157,"deputShip 2/78, emptyShip 1/19, indomShip 0/6, preciShip 0/4, sargonShip 0/2, siracusaShip 1/60, steadShip 0/4",26,46,20,107],
"ai_2": [1,28,12,0,0,6,8,"cbd1cd4c",124,"emptyShip 1/0, sargonShip 0/8, siracusaShip 2/112, swiftShip 1/12, yanShip 0/1",46,46,0,107], "ai_2": [1,28,12,0,0,6,8,"a00d83bb",204,"emptyShip 1/0, sargonShip 0/8, siracusaShip 2/180, swiftShip 1/24, yanShip 0/1",46,46,0,107],
"ai_3": [1,31,12,0,0,6,7,"960125ca",269,"deputShip 1/24, emptyShip 0/14, kjeragShip 1/90, preciShip 1/111, siracusaShip 0/3, swiftShip 1/44, yanShip 0/1",46,46,0,112] "ai_3": [1,31,12,0,0,6,7,"0adf3859",250,"deputShip 1/22, emptyShip 0/14, kjeragShip 1/77, preciShip 1/107, siracusaShip 0/3, swiftShip 1/44, yanShip 0/1",46,46,0,111]
}, },
{ {
"round": 13, "round": 13,
"ai_0": [1,26,12,0,0,6,8,"c7cc20e6",270,"deputShip 0/49, kjeragShip 0/2, preciShip 2/110, sargonShip 1/83, steadShip 2/77, victoriaShip 0/5, yanShip 0/5",32,32,0,124], "ai_0": [1,26,12,0,0,6,8,"7e6a7c23",277,"deputShip 0/53, kjeragShip 0/2, preciShip 2/120, sargonShip 1/85, steadShip 2/72, victoriaShip 0/5, yanShip 0/5",32,32,0,125],
"ai_1": [1,45,12,0,0,6,8,"b8ae2ad4",181,"deputShip 1/86, emptyShip 1/21, indomShip 2/10, preciShip 0/4, sargonShip 0/2, siracusaShip 1/64, steadShip 0/4",32,32,0,118], "ai_1": [1,45,12,0,0,6,8,"8ffb520b",201,"deputShip 2/90, emptyShip 1/23, indomShip 0/6, preciShip 1/10, sargonShip 0/2, siracusaShip 1/78, steadShip 0/4",32,32,0,122],
"ai_2": [1,28,12,0,0,6,8,"690ad961",210,"emptyShip 1/0, sargonShip 0/8, siracusaShip 2/186, swiftShip 1/24, yanShip 0/1",32,32,0,119], "ai_2": [1,28,12,0,0,6,8,"417dbf48",256,"emptyShip 1/0, sargonShip 0/8, siracusaShip 2/232, swiftShip 1/24, yanShip 0/1",32,32,0,119],
"ai_3": [1,31,12,0,0,6,8,"7229fc68",300,"deputShip 1/26, emptyShip 0/14, kjeragShip 1/99, preciShip 2/124, siracusaShip 0/3, swiftShip 1/51, yanShip 0/1",32,32,0,125] "ai_3": [1,31,12,0,2,6,7,"c443f903",203,"deputShip 0/22, emptyShip 0/14, kjeragShip 1/91, preciShip 2/112, siracusaShip 0/3, swiftShip 0/49, yanShip 0/1",32,32,0,122]
} }
], ],
"players": { "players": {
@@ -980,9 +976,9 @@
"title": {"id":"comment_2","name":"不朽盟约","picId":"comment_icon_2","text":"激活的盟约层数总和最高"}, "title": {"id":"comment_2","name":"不朽盟约","picId":"comment_icon_2","text":"激活的盟约层数总和最高"},
"trophies": 3, "trophies": 3,
"reward": 250, "reward": 250,
"stats": {"activatedLayers":354,"bossDamage":141377,"dmgDealt":1741970,"gold":138,"itemsEquipped":19,"kills":302,"leaks":0,"lpLost":0,"merges":6,"perfectRounds":13,"refreshes":20}, "stats": {"activatedLayers":281,"bossDamage":147152,"dmgDealt":1866747,"gold":139,"itemsEquipped":20,"kills":323,"leaks":0,"lpLost":0,"merges":6,"perfectRounds":13,"refreshes":18},
"lineup": ["2_08_a@12,8[4_04_e_a+4_02_e_b]","2_14_b@11,6","6_12_a@11,7[4_07_e_a+4_03_e_b]","6_05_a@11,8[1_05_e_a+5_02_e_b]","6_14_a@10,3","6_03_a@9,4","3_20_b@9,8[3_02_e_a]","5_02_b@9,9[3_06_e_a+5_02_e_b]"], "lineup": ["6_03_a@12,4","5_02_b@11,6[3_06_e_a+5_02_e_b]","4_20_b@11,7","2_08_b@10,4[4_04_e_a+4_02_e_a]","1_12_b@10,8[3_02_e_a+4_05_e_a]","4_22_b@9,4","6_08_a@9,8[5_01_e_a+4_07_e_a]","6_05_a@9,9[5_09_e_a+3_04_e_a]"],
"bonds": ["deputShip 2/53","emptyShip 1/4","kjeragShip 0/7","preciShip 2/125","sargonShip 1/91","steadShip 1/81","victoriaShip 0/5","yanShip 0/5"] "bonds": ["deputShip 0/53","kjeragShip 0/2","preciShip 2/124","sargonShip 1/85","steadShip 1/72","victoriaShip 0/5","yanShip 0/5"]
}, },
"ai_1": { "ai_1": {
"alive": true, "alive": true,
@@ -994,9 +990,9 @@
"title": {"id":"comment_3","name":"坚若磐石","picId":"comment_icon_3","text":"目标生命值损失最少"}, "title": {"id":"comment_3","name":"坚若磐石","picId":"comment_icon_3","text":"目标生命值损失最少"},
"trophies": 3, "trophies": 3,
"reward": 250, "reward": 250,
"stats": {"activatedLayers":213,"bossDamage":49702,"dmgDealt":1353781,"gold":130,"itemsEquipped":17,"kills":246,"leaks":37,"lpLost":0,"merges":5,"perfectRounds":11,"refreshes":20}, "stats": {"activatedLayers":237,"bossDamage":75279,"dmgDealt":1384510,"gold":135,"itemsEquipped":22,"kills":248,"leaks":35,"lpLost":0,"merges":5,"perfectRounds":11,"refreshes":17},
"lineup": ["2_14_b@12,7","5_10_a@11,6[4_08_e_a+3_01_e_b]","5_20_a@11,7[3_07_e_a+5_02_e_b]","4_16_a@11,8[4_04_e_a+4_11_e_a]","1_20_b@10,7","6_06_a@10,8[4_05_e_b+2_04_e_a]","1_13_b@9,8","6_06_a@9,9"], "lineup": ["2_14_b@12,3","5_10_a@11,6[4_08_e_a+3_01_e_a]","6_12_a@11,7[1_01_e_a+4_05_e_b]","5_20_a@11,8[2_01_e_a+1_05_e_a]","6_14_a@10,3","2_16_b@10,8[4_03_e_a+4_06_e_a]","1_13_b@9,8[5_02_e_b+5_02_e_b]","4_16_a@9,9[4_04_e_a+4_11_e_a]"],
"bonds": ["deputShip 1/94","emptyShip 1/23","indomShip 1/14","preciShip 0/4","sargonShip 0/2","siracusaShip 1/82","steadShip 0/4"] "bonds": ["deputShip 2/102","emptyShip 1/27","indomShip 0/6","preciShip 0/12","sargonShip 0/2","siracusaShip 1/108","steadShip 0/4"]
}, },
"ai_2": { "ai_2": {
"alive": true, "alive": true,
@@ -1005,12 +1001,12 @@
"eliminatedRound": null, "eliminatedRound": null,
"lp": 28, "lp": 28,
"bandId": "band_mlyss", "bandId": "band_mlyss",
"title": {"id":"comment_5","name":"万事俱备","picId":"comment_icon_5","text":"装备配发数量最多"}, "title": {"id":"comment_4","name":"精英云集","picId":"comment_icon_4","text":"晋升精锐次数最多"},
"trophies": 3, "trophies": 3,
"reward": 250, "reward": 250,
"stats": {"activatedLayers":240,"bossDamage":315493,"dmgDealt":1782088,"gold":131,"itemsEquipped":20,"kills":287,"leaks":1,"lpLost":0,"merges":4,"perfectRounds":12,"refreshes":21}, "stats": {"activatedLayers":280,"bossDamage":320516,"dmgDealt":1866796,"gold":129,"itemsEquipped":20,"kills":288,"leaks":0,"lpLost":0,"merges":5,"perfectRounds":13,"refreshes":16},
"lineup": ["6_14_a@12,3","6_18_a@11,3[3_01_e_a+1_02_e_a]","1_07_b@11,7[1_01_e_a+1_05_e_a]","5_10_a@11,8[4_03_e_b+5_01_e_b]","3_10_a@10,3","3_18_b@9,4[2_01_e_b+4_02_e_a]","5_20_a@9,8[3_02_e_a+2_01_e_b]","2_16_b@9,9[3_07_e_a]"], "lineup": ["6_14_a@11,6","3_10_b@11,7","5_10_a@11,8[4_03_e_b+4_12_e_a]","3_18_b@10,4[3_07_e_a+4_02_e_a]","2_16_b@10,8[5_01_e_a+2_01_e_a]","4_16_b@9,4","6_18_a@9,8[4_03_e_a+5_02_e_a]","5_20_a@9,9[3_02_e_a+2_01_e_b]"],
"bonds": ["emptyShip 1/0","sargonShip 0/8","siracusaShip 2/216","swiftShip 1/24","yanShip 0/1"] "bonds": ["emptyShip 1/2","sargonShip 0/8","siracusaShip 2/254","swiftShip 1/24","yanShip 0/1"]
}, },
"ai_3": { "ai_3": {
"alive": true, "alive": true,
@@ -1022,9 +1018,9 @@
"title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"}, "title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"},
"trophies": 3, "trophies": 3,
"reward": 250, "reward": 250,
"stats": {"activatedLayers":320,"bossDamage":633429,"dmgDealt":2257252,"gold":136,"itemsEquipped":37,"kills":305,"leaks":0,"lpLost":0,"merges":4,"perfectRounds":13,"refreshes":23}, "stats": {"activatedLayers":245,"bossDamage":597053,"dmgDealt":2088676,"gold":134,"itemsEquipped":38,"kills":283,"leaks":0,"lpLost":0,"merges":3,"perfectRounds":13,"refreshes":20},
"lineup": ["3_20_b@12,8[5_02_e_b+6_03_e_a]","6_14_a@11,3[4_03_e_a+4_05_e_a]","4_13_b@11,6[3_11_e_a+5_02_e_b]","6_01_a@11,7[4_04_e_b+1_01_e_b]","4_14_a@11,8[2_01_e_b+5_01_e_a]","4_22_a@10,8[4_02_e_a+3_08_e_a]","3_11_b@9,8[4_09_e_a+3_07_e_a]","5_14_a@9,9[4_06_e_b+4_10_e_a]"], "lineup": ["4_21_a@12,3","6_08_a@11,6[4_11_e_a]","6_01_a@11,7","4_13_b@11,8[2_01_e_b+5_02_e_b]","3_20_b@10,3[5_02_e_b+6_03_e_a]","4_22_a@10,8[4_04_e_a+4_02_e_a]","3_11_b@9,8[3_07_e_b+3_06_e_a]","5_14_a@9,9[4_06_e_b+4_04_e_b]"],
"bonds": ["deputShip 1/28","emptyShip 0/14","kjeragShip 1/101","preciShip 2/133","siracusaShip 0/3","swiftShip 1/58","yanShip 0/1"] "bonds": ["deputShip 1/24","emptyShip 0/14","kjeragShip 1/104","preciShip 2/117","siracusaShip 0/3","swiftShip 0/57","yanShip 0/1"]
} }
} }
}, },
@@ -1203,8 +1199,8 @@
"combat": "server", "combat": "server",
"boost": null, "boost": null,
"setup": {"stageId":"act1autochess_m01","bossId":"boss_1","hiddenBossId":"boss_9","factions":["FLY","DOT","REFLECTION"]}, "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":42,"bossPoolLeft":0,"virtualMs":122900}, "end": {"victory":true,"roundsPassed":14,"hiddenReached":false,"hiddenCleared":false,"reason":"victory","teamLp":46,"bossPoolLeft":0,"virtualMs":116900},
"rng": {"setup":25,"shop":550,"waves":0,"draft":34,"bots":18090,"meta":100}, "rng": {"setup":25,"shop":485,"waves":0,"draft":34,"bots":18112,"meta":106},
"errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0}, "errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0},
"rounds": [ "rounds": [
{ {
@@ -1214,63 +1210,63 @@
}, },
{ {
"round": 2, "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_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,"fca300a0",2,"arcaneShip 0/2, kjeragShip 0/2, preciShip 1/2, soloShip 1/0, steadShip 0/2",10,10,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, "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_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,"bf48e672",6,"arcaneShip 0/2, kjeragShip 0/4, preciShip 1/2, soloShip 1/0, steadShip 1/4",9,9,0,15] "ai_1": [1,28,6,0,0,1,7,"d5a926f3",6,"arcaneShip 0/2, kjeragShip 0/4, preciShip 1/2, soloShip 1/0, steadShip 1/4",9,9,0,15]
}, },
{ {
"round": 4, "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_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,"ef6d85b4",6,"arcaneShip 0/2, kjeragShip 0/4, preciShip 1/2, soloShip 1/0, steadShip 1/4",12,12,0,22] "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, "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,2,3,8,"a3c40192",6,"arcaneShip 0/2, kjeragShip 0/4, preciShip 1/2, soloShip 1/0, steadShip 1/4",15,15,0,30] "ai_1": [1,28,8,0,2,3,8,"a3c40192",6,"arcaneShip 0/2, kjeragShip 0/4, preciShip 1/2, soloShip 1/0, steadShip 1/4",15,15,0,30]
}, },
{ {
"round": 6, "round": 6,
"ai_0": [1,29,9,0,3,3,8,"5840167c",27,"arcaneShip 0/2, kjeragShip 0/2, preciShip 1/2, siracusaShip 1/12, soloShip 1/11, steadShip 2/2",25,23,0,39], "ai_0": [1,30,9,0,3,3,8,"64e268da",27,"arcaneShip 0/2, kjeragShip 0/2, preciShip 1/2, siracusaShip 1/12, soloShip 1/11, steadShip 2/2",23,23,0,41],
"ai_1": [1,28,11,0,1,3,8,"c883cfec",11,"arcaneShip 0/2, kjeragShip 0/9, preciShip 2/7, siracusaShip 1/0, soloShip 1/0, steadShip 1/4",22,22,0,41] "ai_1": [1,28,11,0,1,3,8,"9fbb73ca",11,"arcaneShip 0/2, kjeragShip 0/9, preciShip 2/7, siracusaShip 1/0, soloShip 1/0, steadShip 1/4",22,22,0,41]
}, },
{ {
"round": 7, "round": 7,
"ai_0": [1,19,13,0,2,4,8,"2261a840",41,"arcaneShip 0/2, deputShip 1/2, kjeragShip 0/2, preciShip 1/4, siracusaShip 1/22, soloShip 1/13, steadShip 0/2",17,27,10,51], "ai_0": [1,20,13,0,2,4,8,"e485391c",41,"arcaneShip 0/2, deputShip 1/2, kjeragShip 0/2, preciShip 1/4, siracusaShip 1/22, soloShip 1/13, steadShip 0/2",16,27,11,53],
"ai_1": [1,23,11,0,1,4,8,"a0f53595",17,"arcaneShip 0/2, kjeragShip 0/9, preciShip 2/7, siracusaShip 1/6, soloShip 1/0, steadShip 1/4, swiftShip 0/6",22,27,5,53] "ai_1": [1,26,11,0,1,4,8,"ba8ea888",17,"arcaneShip 0/2, kjeragShip 0/9, preciShip 2/7, siracusaShip 1/6, soloShip 1/0, steadShip 1/4, swiftShip 0/6",25,27,2,53]
}, },
{ {
"round": 8, "round": 8,
"ai_0": [1,19,13,0,0,4,8,"876b6843",81,"arcaneShip 1/12, deputShip 2/6, kjeragShip 0/2, preciShip 1/12, siracusaShip 1/34, soloShip 1/17, steadShip 0/2",23,23,0,66], "ai_0": [1,20,13,0,0,4,8,"07dcbf6d",81,"arcaneShip 1/12, deputShip 2/6, kjeragShip 0/2, preciShip 1/12, siracusaShip 1/34, soloShip 1/17, steadShip 0/2",23,23,0,68],
"ai_1": [1,23,12,0,0,4,8,"170e7347",43,"arcaneShip 1/8, kjeragShip 0/13, preciShip 1/7, siracusaShip 2/28, soloShip 1/0, steadShip 0/8, swiftShip 0/6",21,23,2,63] "ai_1": [1,26,12,0,0,4,8,"c6ebef9d",43,"arcaneShip 1/8, kjeragShip 0/13, preciShip 1/7, siracusaShip 2/28, soloShip 1/0, steadShip 0/8, swiftShip 0/6",23,23,0,63]
}, },
{ {
"round": 9, "round": 9,
"ai_0": [1,19,12,0,1,4,8,"1adfa3ff",119,"arcaneShip 1/23, deputShip 1/8, emptyShip 1/2, indomShip 0/8, kjeragShip 0/2, preciShip 1/18, siracusaShip 1/47, soloShip 1/19, steadShip 0/2, suntShip 1/2, victoriaShip 0/8, visiShip 0/8",16,16,0,76], "ai_0": [1,20,12,0,2,4,8,"4bdb7882",113,"arcaneShip 1/20, deputShip 1/8, emptyShip 1/2, indomShip 0/8, kjeragShip 0/2, preciShip 1/18, siracusaShip 1/44, soloShip 1/19, steadShip 0/2, suntShip 1/2, victoriaShip 0/8, visiShip 0/8",16,16,0,80],
"ai_1": [1,23,12,0,0,4,8,"2376d356",71,"arcaneShip 1/14, indomShip 0/8, kjeragShip 0/13, miraShip 1/0, preciShip 1/7, siracusaShip 2/50, soloShip 1/0, steadShip 0/8, swiftShip 0/6, victoriaShip 0/8, visiShip 0/8",15,15,0,74] "ai_1": [1,26,12,0,0,4,8,"f7fe62df",84,"arcaneShip 1/16, indomShip 0/8, kjeragShip 0/13, miraShip 1/3, preciShip 1/9, siracusaShip 2/56, soloShip 1/0, steadShip 0/8, swiftShip 0/6, victoriaShip 0/8, visiShip 0/8",15,15,0,74]
}, },
{ {
"round": 10, "round": 10,
"ai_0": [1,19,13,0,4,5,8,"d7f57e17",144,"arcaneShip 1/27, deputShip 1/10, emptyShip 1/4, indomShip 0/8, kjeragShip 0/2, preciShip 1/25, siracusaShip 1/53, soloShip 1/21, steadShip 0/2, suntShip 1/4, victoriaShip 0/8, visiShip 0/8",24,24,0,89], "ai_0": [1,20,14,0,4,5,8,"1feec931",138,"arcaneShip 1/24, deputShip 1/10, emptyShip 1/4, indomShip 0/8, kjeragShip 0/2, preciShip 1/25, siracusaShip 1/50, soloShip 1/21, steadShip 0/2, suntShip 1/4, victoriaShip 0/8, visiShip 0/8",24,24,0,93],
"ai_1": [1,23,12,0,0,5,8,"c9414faf",92,"arcaneShip 1/16, indomShip 0/8, kjeragShip 0/13, miraShip 1/3, preciShip 1/7, siracusaShip 2/66, soloShip 1/0, steadShip 0/8, swiftShip 0/6, victoriaShip 0/8, visiShip 0/8",23,23,0,86] "ai_1": [1,26,12,0,0,5,8,"7b71e64a",105,"arcaneShip 1/18, indomShip 0/8, kjeragShip 0/13, miraShip 1/6, preciShip 1/9, siracusaShip 2/72, soloShip 1/0, steadShip 0/8, swiftShip 0/6, victoriaShip 0/8, visiShip 0/8",23,23,0,86]
}, },
{ {
"round": 11, "round": 11,
"ai_0": [1,19,16,0,0,5,8,"d9d7baf5",196,"arcaneShip 1/35, deputShip 1/12, emptyShip 1/6, indomShip 0/8, kjeragShip 0/2, preciShip 1/32, siracusaShip 1/69, soloShip 1/23, steadShip 0/2, suntShip 1/6, swiftShip 1/13, victoriaShip 0/8, visiShip 0/8",21,23,2,106], "ai_0": [1,20,16,0,0,5,8,"65d57ecf",151,"arcaneShip 1/32, deputShip 0/10, emptyShip 1/4, indomShip 0/8, kjeragShip 0/2, preciShip 2/32, raidShip 1/0, siracusaShip 1/58, soloShip 1/21, steadShip 0/2, suntShip 1/4, victoriaShip 0/8, visiShip 0/8",23,23,0,110],
"ai_1": [1,23,12,0,0,5,8,"b94df622",144,"arcaneShip 1/22, indomShip 0/8, kjeragShip 0/13, miraShip 1/5, preciShip 1/7, siracusaShip 2/110, soloShip 1/0, steadShip 0/8, swiftShip 0/6, victoriaShip 0/8, visiShip 0/8",23,23,0,98] "ai_1": [1,26,12,0,0,5,8,"6412e274",145,"arcaneShip 2/30, indomShip 0/8, kjeragShip 0/13, miraShip 1/9, preciShip 0/9, siracusaShip 2/106, soloShip 1/0, steadShip 0/8, swiftShip 0/6, victoriaShip 0/8, visiShip 0/8",23,23,0,98]
}, },
{ {
"round": 12, "round": 12,
"ai_0": [1,19,12,0,0,6,8,"9673c983",228,"arcaneShip 1/39, deputShip 1/14, emptyShip 1/8, indomShip 0/8, kjeragShip 0/2, preciShip 1/41, siracusaShip 1/73, soloShip 1/25, steadShip 0/2, suntShip 1/8, swiftShip 1/20, victoriaShip 0/8, visiShip 0/8",36,46,10,118], "ai_0": [1,20,12,0,1,6,8,"d8b6db14",156,"arcaneShip 1/32, deputShip 0/10, emptyShip 1/4, indomShip 0/8, kjeragShip 0/2, preciShip 2/37, raidShip 1/0, siracusaShip 1/58, soloShip 1/21, steadShip 0/2, suntShip 1/4, victoriaShip 0/8, visiShip 0/8",47,47,0,123],
"ai_1": [1,23,12,0,2,5,8,"7af40c48",221,"arcaneShip 1/30, indomShip 0/8, kjeragShip 0/13, miraShip 1/8, preciShip 1/9, siracusaShip 2/168, soloShip 1/0, steadShip 0/8, swiftShip 1/6, victoriaShip 0/8, visiShip 0/8",46,46,0,112] "ai_1": [1,26,12,0,0,6,8,"6e6d725d",194,"arcaneShip 2/30, indomShip 0/8, kjeragShip 0/13, miraShip 1/12, preciShip 0/9, siracusaShip 2/152, soloShip 1/0, steadShip 0/8, swiftShip 0/6, victoriaShip 0/8, visiShip 0/8",46,46,0,111]
}, },
{ {
"round": 13, "round": 13,
"ai_0": [1,19,12,0,0,6,8,"6b48239d",268,"arcaneShip 1/47, deputShip 1/16, emptyShip 1/10, indomShip 0/8, kjeragShip 0/2, preciShip 1/48, siracusaShip 1/83, soloShip 1/27, steadShip 0/2, suntShip 1/10, swiftShip 1/27, victoriaShip 0/8, visiShip 0/8",28,39,11,128], "ai_0": [1,20,13,0,0,6,8,"dfdf661e",164,"arcaneShip 1/36, deputShip 0/10, emptyShip 1/4, indomShip 0/8, kjeragShip 0/2, preciShip 2/37, raidShip 1/0, siracusaShip 1/62, soloShip 1/21, steadShip 0/2, suntShip 1/4, victoriaShip 0/8, visiShip 0/8",38,39,1,135],
"ai_1": [1,23,14,0,0,6,8,"03a221b4",270,"arcaneShip 1/32, indomShip 0/8, kjeragShip 0/13, miraShip 1/11, preciShip 1/9, siracusaShip 2/212, soloShip 1/0, steadShip 0/8, swiftShip 1/6, victoriaShip 0/8, visiShip 0/8",39,39,0,126] "ai_1": [1,26,12,0,0,6,8,"61848044",279,"arcaneShip 2/40, indomShip 0/8, kjeragShip 0/13, miraShip 1/15, preciShip 0/9, siracusaShip 2/224, soloShip 1/0, steadShip 0/8, suntShip 1/0, swiftShip 0/6, victoriaShip 0/8, visiShip 0/8",39,39,0,121]
} }
], ],
"players": { "players": {
@@ -1279,28 +1275,28 @@
"victory": true, "victory": true,
"roundsPassed": 14, "roundsPassed": 14,
"eliminatedRound": null, "eliminatedRound": null,
"lp": 19, "lp": 20,
"bandId": "band_doberm", "bandId": "band_doberm",
"title": {"id":"comment_2","name":"不朽盟约","picId":"comment_icon_2","text":"激活的盟约层数总和最高"}, "title": {"id":"comment_5","name":"万事俱备","picId":"comment_icon_5","text":"装备配发数量最多"},
"trophies": 4, "trophies": 4,
"reward": 400, "reward": 400,
"stats": {"activatedLayers":312,"bossDamage":190349,"dmgDealt":3265362,"gold":143,"itemsEquipped":17,"kills":253,"leaks":34,"lpLost":11,"merges":3,"perfectRounds":8,"refreshes":26}, "stats": {"activatedLayers":176,"bossDamage":279994,"dmgDealt":3560951,"gold":149,"itemsEquipped":22,"kills":273,"leaks":12,"lpLost":10,"merges":4,"perfectRounds":11,"refreshes":20},
"lineup": ["2_14_a@12,3","5_10_a@11,3[1_01_e_a+4_08_e_a]","1_17_a@11,6[1_02_e_a+1_05_e_a]","1_06_b@11,7","4_14_a@10,3[4_06_e_a+4_02_e_a]","4_10_a@10,7[3_11_e_a+6_01_e_a]","1_08_b@10,8[5_02_e_a+4_03_e_b]","3_18_a@9,9[3_04_e_a+4_05_e_a]"], "lineup": ["5_10_a@11,3[6_10_e_a+3_03_e_b]","6_12_a@11,6[2_01_e_a+6_09_e_a]","6_11_a@11,7","1_17_a@10,3[1_05_e_a+1_02_e_b]","4_10_a@10,7[3_11_e_a+4_03_e_a]","4_16_b@10,8[3_05_e_b+5_01_e_a]","6_08_a@9,8[6_01_e_a+4_02_e_a]","1_08_b@9,9[5_02_e_a+1_01_e_b]"],
"bonds": ["arcaneShip 1/57","deputShip 1/18","emptyShip 1/12","indomShip 0/8","kjeragShip 0/2","preciShip 1/57","siracusaShip 1/93","soloShip 1/29","steadShip 0/2","suntShip 1/12","swiftShip 1/34","victoriaShip 0/8","visiShip 0/8"] "bonds": ["arcaneShip 1/42","deputShip 0/10","emptyShip 1/4","indomShip 0/8","kjeragShip 0/2","maniShip 1/0","preciShip 2/37","siracusaShip 1/68","soloShip 1/21","steadShip 0/2","suntShip 1/4","victoriaShip 0/8","visiShip 0/8"]
}, },
"ai_1": { "ai_1": {
"alive": true, "alive": true,
"victory": true, "victory": true,
"roundsPassed": 14, "roundsPassed": 14,
"eliminatedRound": null, "eliminatedRound": null,
"lp": 23, "lp": 26,
"bandId": "band_malkie", "bandId": "band_malkie",
"title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"}, "title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"},
"trophies": 4, "trophies": 4,
"reward": 400, "reward": 400,
"stats": {"activatedLayers":294,"bossDamage":1159651,"dmgDealt":5116628,"gold":136,"itemsEquipped":19,"kills":284,"leaks":7,"lpLost":5,"merges":4,"perfectRounds":11,"refreshes":26}, "stats": {"activatedLayers":321,"bossDamage":1070006,"dmgDealt":4448912,"gold":137,"itemsEquipped":21,"kills":267,"leaks":2,"lpLost":2,"merges":5,"perfectRounds":12,"refreshes":21},
"lineup": ["5_20_a@11,3[3_11_e_a+5_02_e_a]","1_17_a@11,6[5_07_e_a+1_02_e_b]","3_19_a@11,7","6_18_a@10,3[3_06_e_b]","4_10_a@10,7[2_01_e_b+3_04_e_a]","1_08_b@10,8","6_05_a@9,8[1_01_e_b+4_07_e_a]","3_18_a@9,9[4_05_e_a+4_06_e_b]"], "lineup": ["6_18_a@11,3[3_11_e_a+5_07_e_a]","3_19_a@11,6[4_04_e_a+3_04_e_a]","1_17_b@11,7[1_02_e_a]","5_20_a@10,3[4_07_e_a+4_03_e_a]","4_10_a@10,7[2_01_e_b+1_01_e_a]","2_16_b@10,8[4_06_e_a+1_02_e_b]","5_12_a@9,8","1_08_b@9,9[4_10_e_a+4_05_e_a]"],
"bonds": ["arcaneShip 1/38","indomShip 0/8","kjeragShip 0/13","miraShip 1/11","preciShip 1/9","siracusaShip 2/230","soloShip 1/0","steadShip 0/8","swiftShip 1/6","victoriaShip 0/8","visiShip 0/8"] "bonds": ["arcaneShip 2/56","indomShip 0/8","kjeragShip 0/13","miraShip 1/15","preciShip 0/13","siracusaShip 2/250","soloShip 1/0","steadShip 0/8","suntShip 1/0","swiftShip 0/6","victoriaShip 0/8","visiShip 0/8"]
} }
} }
}, },
@@ -1398,8 +1394,8 @@
"combat": "client", "combat": "client",
"boost": {"lp":400,"layers":300}, "boost": {"lp":400,"layers":300},
"setup": {"stageId":"act2autochess_m04","bossId":"boss_5","hiddenBossId":"boss_9","factions":["FLY","ELEMENT","INVISIBLE"]}, "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":795,"bossPoolLeft":0,"virtualMs":124650}, "end": {"victory":true,"roundsPassed":15,"hiddenReached":true,"hiddenCleared":true,"reason":"victory","teamLp":543,"bossPoolLeft":0,"virtualMs":123150},
"rng": {"setup":25,"shop":750,"waves":0,"draft":61,"bots":22165,"meta":198}, "rng": {"setup":25,"shop":591,"waves":0,"draft":61,"bots":22093,"meta":138},
"errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0}, "errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0},
"rounds": [ "rounds": [
{ {
@@ -1410,62 +1406,62 @@
{ {
"round": 2, "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_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, "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_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,"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_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, "round": 4,
"ai_0": [1,400,8,0,1,2,8,"f2591697",1206,"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/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_0": [1,400,8,0,1,2,8,"f2591697",1206,"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/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,4,2,8,"a5d7e73a",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",24,24,0,24] "ai_1": [1,400,9,0,4,2,8,"bb20c536",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,24]
}, },
{ {
"round": 5, "round": 5,
"ai_0": [1,400,9,0,0,3,8,"e81795ce",1207,"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/300, soloShip 1/300, steadShip 1/305, suntShip 0/300, swiftShip 0/302, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",24,24,0,31], "ai_0": [1,400,9,0,0,3,8,"1ba198e4",1207,"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/300, soloShip 1/300, steadShip 1/305, suntShip 0/300, swiftShip 0/302, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",24,24,0,31],
"ai_1": [1,400,12,0,0,3,8,"9203fcff",1219,"arcaneShip 0/302, 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/309, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/304, steadShip 1/302, suntShip 0/300, swiftShip 0/300, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",24,24,0,36] "ai_1": [1,400,12,0,0,3,8,"db4f0eb6",1219,"arcaneShip 0/302, 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/309, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/304, steadShip 1/302, suntShip 0/300, swiftShip 0/300, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",24,24,0,36]
}, },
{ {
"round": 6, "round": 6,
"ai_0": [1,400,9,0,2,3,8,"765371f3",1528,"arcaneShip 1/307, 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 0/300, soloShip 1/302, steadShip 1/313, suntShip 0/300, swiftShip 0/302, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",27,27,0,41], "ai_0": [1,400,9,0,2,3,8,"765371f3",1528,"arcaneShip 1/307, 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 0/300, soloShip 1/302, steadShip 1/313, suntShip 0/300, swiftShip 0/302, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",27,27,0,41],
"ai_1": [1,400,9,0,3,3,8,"2eaa7d4c",919,"arcaneShip 0/302, 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 0/309, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/306, steadShip 2/305, suntShip 0/300, swiftShip 0/300, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",28,28,0,44] "ai_1": [1,400,9,0,2,3,8,"12bd0f90",921,"arcaneShip 0/302, deputShip 1/307, 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/309, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/306, steadShip 2/308, suntShip 0/300, swiftShip 0/300, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",28,28,0,44]
}, },
{ {
"round": 7, "round": 7,
"ai_0": [1,400,12,0,1,4,8,"d26ea2d9",1544,"arcaneShip 1/312, 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/306, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/304, steadShip 1/318, suntShip 0/300, swiftShip 0/302, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",25,25,0,54], "ai_0": [1,400,12,0,1,4,8,"22644261",1545,"arcaneShip 1/312, 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/306, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/304, steadShip 1/319, suntShip 0/300, swiftShip 0/302, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",25,25,0,54],
"ai_1": [1,400,13,0,0,4,8,"b18267fd",1242,"arcaneShip 0/302, deputShip 2/316, egirShip 0/300, emptyShip 1/304, indomShip 0/300, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 0/309, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/310, steadShip 2/312, suntShip 0/300, swiftShip 0/300, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",24,25,1,59] "ai_1": [1,400,12,0,0,4,8,"2dd93a93",938,"arcaneShip 0/302, deputShip 2/311, 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/309, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/310, steadShip 2/317, suntShip 0/300, swiftShip 0/300, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",25,25,0,57]
}, },
{ {
"round": 8, "round": 8,
"ai_0": [1,400,12,0,0,4,8,"d692537c",1558,"arcaneShip 1/315, deputShip 0/304, egirShip 1/307, 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 2/310, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/304, steadShip 2/322, suntShip 0/300, swiftShip 0/302, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",18,18,0,64], "ai_0": [1,400,12,0,0,4,8,"61230d1b",1552,"arcaneShip 1/312, deputShip 0/304, egirShip 1/303, 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 2/310, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/304, steadShip 2/323, 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,0,4,8,"b2496d66",1574,"arcaneShip 0/302, deputShip 2/324, egirShip 0/300, emptyShip 1/308, indomShip 0/300, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 0/309, raidShip 0/300, sargonShip 0/300, siracusaShip 1/318, skillfulShip 0/300, soloShip 1/314, steadShip 0/312, suntShip 0/300, swiftShip 1/310, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",18,18,0,70] "ai_1": [1,400,11,0,1,4,8,"770dde40",1261,"arcaneShip 0/302, deputShip 2/315, 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 0/309, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/314, steadShip 1/328, suntShip 0/300, swiftShip 0/300, victoriaShip 0/305, visiShip 0/300, yanShip 0/305",19,19,0,68]
}, },
{ {
"round": 9, "round": 9,
"ai_0": [1,400,12,0,0,4,8,"cdec5dfa",2231,"arcaneShip 2/318, deputShip 0/304, egirShip 1/319, 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/310, raidShip 1/353, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/304, steadShip 1/327, suntShip 1/300, swiftShip 0/302, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",24,24,0,77], "ai_0": [1,400,12,0,0,4,8,"b75b1b8c",2237,"arcaneShip 2/312, deputShip 0/304, egirShip 1/314, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 1/310, kjeragShip 0/302, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/314, raidShip 1/353, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/304, steadShip 1/330, suntShip 0/300, swiftShip 0/306, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",24,24,0,77],
"ai_1": [1,400,12,0,3,6,8,"36a202d9",2210,"arcaneShip 0/302, deputShip 1/326, egirShip 0/300, emptyShip 0/308, indomShip 0/300, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 1/306, preciShip 0/309, raidShip 0/300, sargonShip 0/300, siracusaShip 1/342, skillfulShip 1/302, soloShip 1/316, steadShip 0/312, suntShip 1/302, swiftShip 1/316, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",29,25,0,114] "ai_1": [1,400,13,0,3,4,8,"d94eb18f",1569,"arcaneShip 0/302, deputShip 1/317, egirShip 0/300, emptyShip 0/300, indomShip 1/306, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 0/309, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 1/302, soloShip 0/314, steadShip 1/336, suntShip 0/300, swiftShip 0/300, victoriaShip 1/308, visiShip 0/300, yanShip 0/305",24,24,0,80]
}, },
{ {
"round": 10, "round": 10,
"ai_0": [1,400,12,0,0,5,8,"582856d0",2251,"arcaneShip 2/318, deputShip 0/304, egirShip 1/327, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/302, kjeragShip 0/304, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/310, raidShip 1/363, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/304, steadShip 1/329, suntShip 1/300, swiftShip 0/302, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",12,12,0,89], "ai_0": [1,400,12,0,0,5,8,"6e225676",2268,"arcaneShip 2/312, deputShip 0/304, egirShip 1/319, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 1/318, kjeragShip 0/302, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/322, raidShip 1/363, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/304, steadShip 1/330, suntShip 0/300, swiftShip 0/306, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",12,12,0,92],
"ai_1": [1,400,15,0,2,6,8,"3a43d0ba",2260,"arcaneShip 0/302, deputShip 1/328, egirShip 0/300, emptyShip 0/308, indomShip 0/300, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 1/314, preciShip 0/309, raidShip 0/300, sargonShip 0/300, siracusaShip 1/370, skillfulShip 1/306, soloShip 1/318, steadShip 0/312, suntShip 1/304, swiftShip 1/320, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",13,13,0,130] "ai_1": [1,400,15,0,0,5,8,"a3501bfb",1588,"arcaneShip 0/302, deputShip 1/319, egirShip 0/300, emptyShip 0/300, indomShip 0/306, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 0/309, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 1/314, soloShip 0/314, steadShip 1/342, suntShip 1/302, swiftShip 0/300, victoriaShip 1/311, visiShip 0/300, yanShip 0/305",12,12,0,97]
}, },
{ {
"round": 11, "round": 11,
"ai_0": [1,400,12,0,1,5,8,"9ac6e043",2281,"arcaneShip 2/332, deputShip 0/304, egirShip 1/340, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/302, kjeragShip 0/308, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/315, raidShip 1/382, sargonShip 0/300, siracusaShip 0/300, skillfulShip 1/308, soloShip 1/304, steadShip 0/333, suntShip 1/300, swiftShip 0/302, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",38,34,0,102], "ai_0": [1,400,12,0,2,5,8,"3fe9e60e",2646,"arcaneShip 1/315, deputShip 0/304, egirShip 1/338, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 1/326, kjeragShip 0/302, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/335, raidShip 1/384, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/304, steadShip 1/333, suntShip 0/300, swiftShip 1/311, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",34,34,0,102],
"ai_1": [1,400,14,0,1,6,8,"120b46d6",2628,"arcaneShip 1/318, deputShip 1/330, egirShip 0/300, emptyShip 0/308, indomShip 0/300, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 1/322, preciShip 0/311, raidShip 0/300, sargonShip 0/300, siracusaShip 1/390, skillfulShip 1/318, soloShip 1/320, steadShip 0/312, suntShip 1/306, swiftShip 1/324, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",34,34,0,144] "ai_1": [1,400,12,0,1,5,8,"44ef6174",1936,"arcaneShip 0/302, deputShip 1/321, egirShip 0/300, emptyShip 0/300, indomShip 0/306, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 0/309, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 1/327, soloShip 1/316, steadShip 1/351, suntShip 1/304, swiftShip 0/300, victoriaShip 1/317, visiShip 0/300, yanShip 0/305",34,34,0,110]
}, },
{ {
"round": 12, "round": 12,
"ai_0": [1,400,13,0,0,6,8,"afebef50",2325,"arcaneShip 2/340, deputShip 0/304, egirShip 1/345, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/302, kjeragShip 0/308, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/320, raidShip 1/400, sargonShip 0/300, siracusaShip 0/300, skillfulShip 1/316, soloShip 1/304, steadShip 0/333, suntShip 1/300, swiftShip 0/302, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",6,14,8,115], "ai_0": [1,400,14,0,0,6,8,"2a0b76e3",2717,"arcaneShip 1/318, deputShip 0/304, egirShip 1/351, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 1/342, kjeragShip 0/302, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/356, raidShip 1/397, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/304, steadShip 1/333, suntShip 0/300, swiftShip 1/316, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",6,14,8,116],
"ai_1": [1,400,13,0,5,6,8,"9e462d87",2688,"arcaneShip 1/333, deputShip 1/332, egirShip 0/300, emptyShip 0/308, indomShip 0/300, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 1/328, preciShip 0/311, raidShip 0/300, sargonShip 0/300, siracusaShip 1/411, skillfulShip 1/326, soloShip 1/322, steadShip 0/312, suntShip 1/308, swiftShip 1/328, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",15,15,0,152] "ai_1": [1,400,13,0,3,6,8,"224928d1",1961,"arcaneShip 0/302, deputShip 1/323, egirShip 0/300, emptyShip 0/300, indomShip 0/306, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 0/309, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 1/335, soloShip 1/318, steadShip 1/359, suntShip 1/306, swiftShip 0/300, victoriaShip 1/320, visiShip 0/300, yanShip 0/305",15,15,0,121]
}, },
{ {
"round": 13, "round": 13,
"ai_0": [1,400,12,0,2,6,8,"97689296",2380,"arcaneShip 2/349, deputShip 0/304, egirShip 1/359, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 0/302, kjeragShip 0/308, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/325, raidShip 1/421, sargonShip 0/300, siracusaShip 0/300, skillfulShip 1/322, soloShip 1/304, steadShip 0/333, suntShip 1/300, swiftShip 0/302, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",41,41,0,126], "ai_0": [1,400,12,0,2,6,8,"06928963",3079,"arcaneShip 1/321, deputShip 0/304, egirShip 1/358, emptyShip 0/300, indomShip 0/300, investShip 0/300, kazimierzShip 1/354, kjeragShip 0/302, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 1/369, raidShip 1/415, sargonShip 0/300, siracusaShip 0/300, skillfulShip 0/300, soloShip 1/304, steadShip 1/333, suntShip 1/300, swiftShip 1/325, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",41,41,0,129],
"ai_1": [1,400,17,0,0,6,8,"36a5d772",2717,"arcaneShip 1/345, deputShip 0/332, egirShip 0/300, emptyShip 0/308, indomShip 0/300, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 1/334, preciShip 1/311, raidShip 0/300, sargonShip 0/300, siracusaShip 1/429, skillfulShip 1/338, soloShip 1/322, steadShip 0/312, suntShip 1/308, swiftShip 1/330, victoriaShip 0/300, visiShip 0/300, yanShip 0/300",41,41,0,168] "ai_1": [1,400,15,0,0,6,8,"b067967e",1988,"arcaneShip 0/302, deputShip 1/325, egirShip 0/300, emptyShip 0/300, indomShip 0/306, investShip 0/300, kazimierzShip 0/300, kjeragShip 0/300, lateranoShip 0/300, maniShip 0/300, miraShip 0/300, preciShip 0/309, raidShip 0/300, sargonShip 0/300, siracusaShip 0/300, skillfulShip 1/345, soloShip 1/320, steadShip 1/367, suntShip 1/308, swiftShip 0/300, victoriaShip 1/323, visiShip 0/300, yanShip 0/305",41,41,0,134]
} }
], ],
"players": { "players": {
@@ -1474,28 +1470,28 @@
"victory": true, "victory": true,
"roundsPassed": 15, "roundsPassed": 15,
"eliminatedRound": null, "eliminatedRound": null,
"lp": 398, "lp": 272,
"bandId": "band_sarkazb", "bandId": "band_sarkazb",
"title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"}, "title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"},
"trophies": 8, "trophies": 8,
"reward": 425, "reward": 425,
"stats": {"activatedLayers":3362,"bossDamage":7735924,"dmgDealt":20519044,"gold":154,"itemsEquipped":21,"kills":287,"leaks":8,"lpLost":0,"merges":7,"perfectRounds":12,"refreshes":21}, "stats": {"activatedLayers":2790,"bossDamage":7592235,"dmgDealt":21017016,"gold":156,"itemsEquipped":19,"kills":331,"leaks":8,"lpLost":0,"merges":8,"perfectRounds":12,"refreshes":31},
"lineup": ["4_09_b@11,5[3_11_e_a+3_11_e_b]","6_11_a@11,8","3_13_b@10,3","3_05_b@10,8[1_01_e_a+6_09_e_a]","6_16_a@9,6","6_05_a@9,7[3_02_e_a]","4_14_b@9,8[2_01_e_a+1_02_e_b]","5_07_a@9,9[4_10_e_b]"], "lineup": ["2_18_b@11,5[1_05_e_b+1_01_e_a]","4_14_a@11,8[4_03_e_a]","4_09_a@10,3","1_19_b@10,4","6_17_b@10,8[2_01_e_a+1_02_e_a]","3_09_a@9,7","4_20_b@9,8[1_01_e_b+3_11_e_a]","3_05_b@9,9"],
"bonds": ["arcaneShip 1/365","deputShip 0/304","egirShip 1/362","emptyShip 0/300","indomShip 0/300","investShip 0/300","kazimierzShip 0/302","kjeragShip 0/308","lateranoShip 0/300","maniShip 1/300","miraShip 0/300","preciShip 1/345","raidShip 1/423","sargonShip 1/300","siracusaShip 0/300","skillfulShip 1/338","soloShip 1/307","steadShip 0/333","suntShip 1/300","swiftShip 1/322","victoriaShip 0/300","visiShip 0/300","yanShip 0/300"] "bonds": ["arcaneShip 0/321","deputShip 0/304","egirShip 1/358","emptyShip 0/300","indomShip 0/300","investShip 0/300","kazimierzShip 1/357","kjeragShip 0/302","lateranoShip 0/300","maniShip 0/300","miraShip 0/300","preciShip 1/379","raidShip 1/424","sargonShip 0/300","siracusaShip 0/300","skillfulShip 0/300","soloShip 1/304","steadShip 1/333","suntShip 2/300","swiftShip 1/335","victoriaShip 0/300","visiShip 0/300","yanShip 0/300"]
}, },
"ai_1": { "ai_1": {
"alive": true, "alive": true,
"victory": true, "victory": true,
"roundsPassed": 15, "roundsPassed": 15,
"eliminatedRound": null, "eliminatedRound": null,
"lp": 397, "lp": 271,
"bandId": "band_doberm", "bandId": "band_doberm",
"title": {"id":"comment_3","name":"坚若磐石","picId":"comment_icon_3","text":"目标生命值损失最少"}, "title": {"id":"comment_3","name":"坚若磐石","picId":"comment_icon_3","text":"目标生命值损失最少"},
"trophies": 8, "trophies": 8,
"reward": 425, "reward": 425,
"stats": {"activatedLayers":2801,"bossDamage":6156699,"dmgDealt":23996575,"gold":191,"itemsEquipped":32,"kills":287,"leaks":1,"lpLost":0,"merges":5,"perfectRounds":12,"refreshes":55}, "stats": {"activatedLayers":2377,"bossDamage":6286125,"dmgDealt":26191649,"gold":159,"itemsEquipped":34,"kills":281,"leaks":0,"lpLost":0,"merges":3,"perfectRounds":13,"refreshes":23},
"lineup": ["4_10_a@11,5[3_05_e_a+4_03_e_a]","6_05_a@11,8[4_08_e_a+6_09_e_a]","6_16_a@10,3[3_11_e_a+1_05_e_b]","2_13_a@10,7[4_04_e_a+4_05_e_a]","1_08_b@10,8[4_07_e_b+5_07_e_a]","6_08_a@9,6[2_01_e_b+3_01_e_b]","5_20_a@9,7[1_05_e_a+3_04_e_a]","6_08_a@9,8[3_03_e_a+1_01_e_a]","3_19_b@9,9[2_04_e_a+3_07_e_b]"], "lineup": ["2_17_a@12,8[4_02_e_a+3_03_e_a]","1_13_b@11,5[4_07_e_a+3_11_e_a]","5_03_a@11,8[3_02_e_a+2_03_e_b]","3_16_a@10,4[1_05_e_b+2_01_e_b]","4_11_a@10,8[4_11_e_a+4_09_e_a]","6_16_a@9,7[2_03_e_b+4_09_e_b]","1_06_b@9,8[3_06_e_a+3_08_e_b]","5_12_a@9,9[5_03_e_a+1_01_e_b]"],
"bonds": ["arcaneShip 1/371","deputShip 0/332","egirShip 0/300","emptyShip 0/308","indomShip 0/300","investShip 0/300","kazimierzShip 0/300","kjeragShip 0/300","lateranoShip 0/300","maniShip 0/300","miraShip 1/334","preciShip 1/311","raidShip 0/300","sargonShip 0/300","siracusaShip 1/465","skillfulShip 1/356","soloShip 1/322","steadShip 0/312","suntShip 1/308","swiftShip 1/334","victoriaShip 0/300","visiShip 0/300","yanShip 0/300"] "bonds": ["arcaneShip 1/332","deputShip 1/329","egirShip 0/300","emptyShip 0/300","indomShip 0/306","investShip 0/300","kazimierzShip 0/300","kjeragShip 0/300","lateranoShip 0/300","maniShip 0/300","miraShip 0/300","preciShip 0/309","raidShip 0/300","sargonShip 0/300","siracusaShip 0/300","skillfulShip 1/370","soloShip 1/324","steadShip 1/377","suntShip 1/312","swiftShip 0/300","victoriaShip 1/333","visiShip 0/300","yanShip 0/305"]
} }
} }
}, },
@@ -1638,8 +1634,8 @@
"combat": "client", "combat": "client",
"boost": null, "boost": null,
"setup": {"stageId":"act1autochess_m03","bossId":"boss_7","hiddenBossId":"boss_10","factions":["ELEMENT","INVISIBLE","REFLECTION"]}, "setup": {"stageId":"act1autochess_m03","bossId":"boss_7","hiddenBossId":"boss_10","factions":["ELEMENT","INVISIBLE","REFLECTION"]},
"end": {"victory":true,"roundsPassed":14,"hiddenReached":false,"hiddenCleared":false,"reason":"victory","teamLp":55,"bossPoolLeft":0,"virtualMs":119900}, "end": {"victory":true,"roundsPassed":14,"hiddenReached":false,"hiddenCleared":false,"reason":"victory","teamLp":55,"bossPoolLeft":0,"virtualMs":122900},
"rng": {"setup":25,"shop":477,"waves":0,"draft":34,"bots":16274,"meta":23}, "rng": {"setup":25,"shop":512,"waves":0,"draft":34,"bots":16483,"meta":18},
"errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0}, "errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0},
"rounds": [ "rounds": [
{"round":1,"h_0":[1,27,4,0,0,5,1,"3e4697a0",0,"",5,5,0,3],"ai_0":[1,28,4,0,0,1,2,"a1734bc6",0,"sargonShip 0/2, soloShip 1/0",5,5,0,4]}, {"round":1,"h_0":[1,27,4,0,0,5,1,"3e4697a0",0,"",5,5,0,3],"ai_0":[1,28,4,0,0,1,2,"a1734bc6",0,"sargonShip 0/2, soloShip 1/0",5,5,0,4]},
@@ -1652,48 +1648,48 @@
}, },
{ {
"round": 5, "round": 5,
"h_0": [1,27,8,0,0,5,8,"1a62176b",30,"indomShip 1/0, preciShip 0/2, sargonShip 1/28, steadShip 1/1, victoriaShip 1/1",27,27,0,28], "h_0": [1,27,8,0,0,5,8,"89fff9d8",29,"indomShip 1/0, preciShip 0/2, sargonShip 1/26, steadShip 1/2, victoriaShip 1/1",27,27,0,28],
"ai_0": [1,28,8,0,1,2,8,"e0bfc937",0,"sargonShip 0/2, skillfulShip 1/0, soloShip 1/0, suntShip 1/0, yanShip 0/6",27,27,0,29] "ai_0": [1,28,8,0,1,2,8,"e0bfc937",0,"sargonShip 0/2, skillfulShip 1/0, soloShip 1/0, suntShip 1/0, yanShip 0/6",27,27,0,29]
}, },
{ {
"round": 6, "round": 6,
"h_0": [1,27,9,0,1,5,8,"7f0036b7",93,"arcaneShip 0/2, indomShip 1/6, preciShip 1/17, sargonShip 1/50, steadShip 1/6, victoriaShip 1/8, visiShip 1/6",24,22,0,38], "h_0": [1,27,9,0,1,5,8,"7f0036b7",92,"arcaneShip 0/2, indomShip 1/6, preciShip 1/17, sargonShip 1/48, steadShip 1/7, victoriaShip 1/8, visiShip 1/6",24,22,0,38],
"ai_0": [1,28,10,0,2,3,8,"ea720e1b",0,"sargonShip 0/4, skillfulShip 1/0, soloShip 1/0, suntShip 1/0, yanShip 0/6",22,22,0,39] "ai_0": [1,28,10,0,2,3,8,"ea720e1b",0,"sargonShip 0/4, skillfulShip 1/0, soloShip 1/0, suntShip 1/0, yanShip 0/6",22,22,0,39]
}, },
{ {
"round": 7, "round": 7,
"h_0": [1,27,11,0,1,5,8,"f502b39b",129,"arcaneShip 0/2, indomShip 1/10, preciShip 1/24, sargonShip 1/71, steadShip 1/11, victoriaShip 1/13, visiShip 0/6",28,26,0,50], "h_0": [1,27,11,0,1,5,8,"5702ef65",134,"arcaneShip 0/2, indomShip 1/6, preciShip 1/40, sargonShip 1/67, steadShip 1/12, victoriaShip 1/9, visiShip 0/6",27,26,1,50],
"ai_0": [1,28,12,0,2,4,8,"6aec478c",0,"sargonShip 0/4, skillfulShip 1/0, soloShip 1/0, suntShip 1/0, yanShip 0/6",26,26,0,51] "ai_0": [1,28,12,0,2,4,8,"6aec478c",0,"sargonShip 0/4, skillfulShip 1/0, soloShip 1/0, suntShip 1/0, yanShip 0/6",26,26,0,51]
}, },
{ {
"round": 8, "round": 8,
"h_0": [1,27,12,0,0,5,8,"97941a60",215,"arcaneShip 0/2, indomShip 1/22, preciShip 2/57, sargonShip 1/104, soloShip 1/0, steadShip 2/32, victoriaShip 0/25, visiShip 0/6",18,18,0,65], "h_0": [1,27,12,0,0,5,8,"b6d1cc82",224,"arcaneShip 0/2, indomShip 1/18, preciShip 2/73, sargonShip 1/100, soloShip 1/0, steadShip 2/33, victoriaShip 0/21, visiShip 0/6",18,18,0,63],
"ai_0": [1,28,13,0,1,4,8,"8d499ef9",16,"miraShip 1/0, sargonShip 0/4, skillfulShip 1/3, soloShip 1/0, steadShip 1/0, suntShip 1/0, yanShip 1/13",17,18,1,63] "ai_0": [1,28,13,0,1,4,8,"8d499ef9",16,"miraShip 1/0, sargonShip 0/4, skillfulShip 1/3, soloShip 1/0, steadShip 1/0, suntShip 1/0, yanShip 1/13",17,18,1,63]
}, },
{ {
"round": 9, "round": 9,
"h_0": [1,27,12,0,4,5,8,"96baa6b8",260,"arcaneShip 0/2, indomShip 1/22, preciShip 1/66, sargonShip 1/117, soloShip 1/0, steadShip 2/40, swiftShip 1/15, victoriaShip 0/25, visiShip 0/6",23,23,0,76], "h_0": [1,27,12,0,4,5,8,"e8c67194",264,"arcaneShip 0/2, indomShip 1/18, preciShip 2/94, sargonShip 1/108, steadShip 1/34, victoriaShip 0/21, visiShip 1/10",22,23,1,77],
"ai_0": [1,28,13,0,0,4,8,"a5033a5f",77,"miraShip 1/0, preciShip 1/29, sargonShip 1/28, skillfulShip 1/3, soloShip 1/0, suntShip 1/0, yanShip 1/17",20,23,3,76] "ai_0": [1,28,13,0,2,4,8,"f213e56e",30,"sargonShip 0/4, siracusaShip 1/8, skillfulShip 1/3, soloShip 1/0, suntShip 1/0, yanShip 1/19",23,23,0,76]
}, },
{ {
"round": 10, "round": 10,
"h_0": [1,27,16,0,2,5,8,"831f33bf",270,"arcaneShip 0/2, indomShip 1/22, kazimierzShip 0/8, kjeragShip 0/8, preciShip 1/80, sargonShip 1/123, soloShip 1/0, steadShip 2/45, suntShip 1/0, swiftShip 0/23, victoriaShip 0/25, visiShip 0/6",31,31,0,90], "h_0": [1,27,16,0,2,5,8,"9e1684a3",309,"arcaneShip 0/2, indomShip 1/18, preciShip 2/110, sargonShip 1/116, soloShip 1/0, steadShip 2/43, suntShip 1/0, victoriaShip 0/21, visiShip 1/22",31,31,0,91],
"ai_0": [1,28,12,0,0,5,8,"276b0c45",128,"miraShip 1/0, preciShip 1/56, sargonShip 1/50, skillfulShip 1/3, soloShip 1/0, suntShip 1/0, visiShip 1/0, yanShip 1/19",31,31,0,88] "ai_0": [1,28,14,0,0,5,8,"dcc3d5bd",35,"sargonShip 0/4, siracusaShip 1/8, skillfulShip 1/6, soloShip 1/0, suntShip 2/0, yanShip 1/21",31,31,0,89]
}, },
{ {
"round": 11, "round": 11,
"h_0": [1,27,14,0,0,6,8,"e9f0f2fe",363,"arcaneShip 0/2, indomShip 1/22, kazimierzShip 0/8, kjeragShip 0/8, preciShip 2/99, sargonShip 1/147, steadShip 1/49, suntShip 1/0, swiftShip 1/40, victoriaShip 0/25, visiShip 1/6",19,19,0,102], "h_0": [1,27,16,0,0,6,8,"92c1a6c0",354,"arcaneShip 0/2, indomShip 1/18, preciShip 2/127, sargonShip 1/120, steadShip 1/46, suntShip 1/0, swiftShip 1/5, victoriaShip 0/21, visiShip 1/38",19,19,0,106],
"ai_0": [1,28,12,0,0,5,8,"bd0a32a3",183,"preciShip 1/76, sargonShip 1/74, skillfulShip 1/3, soloShip 1/0, suntShip 1/0, victoriaShip 0/5, visiShip 1/0, yanShip 1/30",19,19,0,102] "ai_0": [1,28,12,0,0,5,8,"57021233",53,"sargonShip 0/4, siracusaShip 1/12, skillfulShip 1/12, soloShip 1/0, suntShip 2/0, visiShip 1/0, yanShip 1/29",19,19,0,101]
}, },
{ {
"round": 12, "round": 12,
"h_0": [1,27,12,0,0,6,8,"efd0ac13",413,"arcaneShip 0/2, indomShip 1/26, kazimierzShip 0/8, kjeragShip 0/8, maniShip 1/0, preciShip 1/129, sargonShip 1/169, steadShip 2/54, suntShip 1/0, swiftShip 0/40, victoriaShip 1/35, visiShip 0/6",33,33,0,117], "h_0": [1,27,14,0,2,6,8,"7d4a4b17",428,"arcaneShip 0/2, indomShip 1/18, preciShip 2/159, sargonShip 1/139, steadShip 1/48, suntShip 1/0, swiftShip 1/10, victoriaShip 0/21, visiShip 1/54",33,33,0,124],
"ai_0": [1,28,12,0,0,6,8,"931d261f",245,"preciShip 1/105, sargonShip 1/107, skillfulShip 1/3, soloShip 1/0, suntShip 1/0, victoriaShip 0/5, visiShip 1/0, yanShip 1/30",27,33,6,113] "ai_0": [1,28,12,0,0,6,8,"672ecfc2",63,"sargonShip 0/8, siracusaShip 1/12, skillfulShip 1/18, soloShip 1/0, suntShip 2/0, yanShip 1/33",22,33,11,114]
}, },
{ {
"round": 13, "round": 13,
"h_0": [1,27,12,0,0,6,8,"e1220022",484,"arcaneShip 0/2, indomShip 1/28, kazimierzShip 0/8, kjeragShip 0/8, maniShip 1/0, preciShip 1/161, sargonShip 1/193, steadShip 2/59, suntShip 1/0, swiftShip 0/40, victoriaShip 1/43, visiShip 0/6",50,50,0,130], "h_0": [1,27,16,0,2,6,8,"dc6467d8",466,"arcaneShip 0/2, indomShip 0/18, preciShip 2/186, sargonShip 1/189, siracusaShip 0/6, steadShip 0/48, suntShip 1/0, swiftShip 1/21, victoriaShip 0/21, visiShip 1/70",50,50,0,140],
"ai_0": [1,28,12,0,0,6,8,"a2f4a969",302,"preciShip 1/134, sargonShip 1/131, skillfulShip 1/3, soloShip 1/0, suntShip 1/0, victoriaShip 0/5, visiShip 1/0, yanShip 1/34",50,50,0,127] "ai_0": [1,28,12,0,0,6,8,"a47a6f63",87,"kazimierzShip 0/2, preciShip 2/24, sargonShip 0/8, siracusaShip 0/12, skillfulShip 1/24, soloShip 1/0, suntShip 2/0, swiftShip 0/2, yanShip 1/39",44,50,6,126]
} }
], ],
"players": { "players": {
@@ -1704,12 +1700,12 @@
"eliminatedRound": null, "eliminatedRound": null,
"lp": 27, "lp": 27,
"bandId": "band_yu", "bandId": "band_yu",
"title": {"id":"comment_2","name":"不朽盟约","picId":"comment_icon_2","text":"激活的盟约层数总和最高"}, "title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"},
"trophies": 4, "trophies": 4,
"reward": 400, "reward": 400,
"stats": {"activatedLayers":496,"bossDamage":678256,"dmgDealt":4102418,"gold":141,"itemsEquipped":24,"kills":319,"leaks":0,"lpLost":0,"merges":2,"perfectRounds":13,"refreshes":19}, "stats": {"activatedLayers":492,"bossDamage":1089764,"dmgDealt":4817588,"gold":156,"itemsEquipped":24,"kills":325,"leaks":2,"lpLost":0,"merges":4,"perfectRounds":11,"refreshes":32},
"lineup": ["4_20_b@11,8[3_05_e_a+5_03_e_a]","6_11_a@10,4[6_05_e_a+4_06_e_a]","5_02_a@10,6[5_01_e_a+6_01_e_a]","6_07_a@10,8[2_04_e_a+6_09_e_a]","3_16_a@9,4[4_05_e_a+3_09_e_a]","2_08_b@9,5[3_11_e_a+3_07_e_a]","6_03_a@9,6[4_04_e_b+2_01_e_a]","4_07_a@9,9[4_11_e_a+4_02_e_a]"], "lineup": ["6_05_a@12,8[3_04_e_a+6_03_e_a]","4_14_a@11,6","5_21_b@11,7[4_03_e_a+5_01_e_b]","4_20_b@11,8[1_01_e_b]","6_05_a@10,6[4_08_e_a+2_01_e_b]","5_02_a@9,7[6_01_e_a+3_11_e_a]","4_07_b@9,8[4_04_e_b+4_11_e_b]","4_23_b@9,9[4_06_e_a+4_04_e_a]"],
"bonds": ["arcaneShip 0/2","indomShip 1/28","kazimierzShip 0/8","kjeragShip 0/8","maniShip 1/0","preciShip 1/161","sargonShip 1/195","steadShip 2/63","suntShip 1/0","swiftShip 0/40","victoriaShip 1/49","visiShip 0/6"] "bonds": ["arcaneShip 0/2","indomShip 0/18","preciShip 2/191","sargonShip 1/189","siracusaShip 0/6","steadShip 0/48","suntShip 1/0","swiftShip 1/26","victoriaShip 0/21","visiShip 1/86"]
}, },
"ai_0": { "ai_0": {
"alive": true, "alive": true,
@@ -1718,12 +1714,12 @@
"eliminatedRound": null, "eliminatedRound": null,
"lp": 28, "lp": 28,
"bandId": "band_mlyss", "bandId": "band_mlyss",
"title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"}, "title": {"id":"comment_3","name":"坚若磐石","picId":"comment_icon_3","text":"目标生命值损失最少"},
"trophies": 4, "trophies": 4,
"reward": 400, "reward": 400,
"stats": {"activatedLayers":316,"bossDamage":896744,"dmgDealt":3970823,"gold":140,"itemsEquipped":19,"kills":279,"leaks":10,"lpLost":0,"merges":4,"perfectRounds":10,"refreshes":21}, "stats": {"activatedLayers":115,"bossDamage":485236,"dmgDealt":3388651,"gold":140,"itemsEquipped":20,"kills":273,"leaks":18,"lpLost":0,"merges":6,"perfectRounds":10,"refreshes":11},
"lineup": ["5_02_a@12,8[3_01_e_a+5_01_e_a]","2_04_b@11,7[2_04_e_a+3_08_e_a]","5_03_a@11,8","3_13_a@10,5","6_08_a@10,6","4_04_b@10,8[4_02_e_a+4_08_e_a]","3_03_a@9,8[4_06_e_b]","6_06_a@9,9[4_10_e_a+4_07_e_a]"], "lineup": ["4_14_a@12,8","2_04_b@11,7[5_03_e_a]","1_03_b@11,8","5_02_a@10,5","6_05_a@10,6[3_03_e_a]","3_03_b@10,8[3_11_e_b+2_04_e_b]","1_08_b@9,8[1_02_e_a+4_06_e_a]","3_02_b@9,9"],
"bonds": ["preciShip 1/134","sargonShip 1/131","skillfulShip 1/3","suntShip 1/0","victoriaShip 0/5","visiShip 1/10","yanShip 1/38"] "bonds": ["kazimierzShip 0/2","preciShip 2/29","sargonShip 0/8","siracusaShip 0/12","skillfulShip 1/33","soloShip 1/0","suntShip 2/0","swiftShip 1/7","yanShip 1/46"]
} }
}, },
"diy": { "diy": {
@@ -1749,7 +1745,7 @@
[12,"",""], [12,"",""],
[13,"",""] [13,"",""]
], ],
"stock": {"chess_char_5_diy1_a":7,"chess_char_5_diy2_a":8,"chess_char_6_diy1_a":5,"chess_char_6_diy2_a":5} "stock": {"chess_char_5_diy1_a":8,"chess_char_5_diy2_a":8,"chess_char_6_diy1_a":5,"chess_char_6_diy2_a":5}
} }
} }
} }
+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); assert.equal(botPickCard(m, ps, [hard, easy, huge].map((c, idx) => ({ ...c, idx })), [0, 1, 2]), 1);
ps.lp = 2; 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'); 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(); 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', () => { 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; let drafts = 0;
for (const seed of [1, 2, 3, 4]) { 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)'); assert.ok(owns(ps, WHIP), 'kept after a battle with leaks (not destroyed)');
h.run(() => m.phase === PHASE.PREP && m.round === 4); h.run(() => m.phase === PHASE.PREP && m.round === 4);
m.lastResults.set(ps.playerId, clean); m.lastResults.set(ps.playerId, clean);
giveItem(m, ps, WHIP);
const bounties = ps.bounties.length; const bounties = ps.bounties.length;
h.run(() => m.phase === PHASE.COMBAT && m.round === 4); 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); assert.equal(m.errorCount, 0);
m.dispose(); m.dispose();
} }
+79 -2
View File
@@ -3,11 +3,62 @@ import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js'; import { PHASE } from '../../shared/constants.js';
import { fieldModel, planLayout, rehearse, rangeTiles, REHEARSAL_VARIANTS, LAYOUT_PARAMS, botPickCard, botPickBand } from '../../server/match/bot.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 { FIELD, canPlace, placeClass, parseKey, legalTiles, tileKey } from '../../server/match/board.js';
import { makeMatch, checkInvariants, give, DATA } from './harness.js'; import { makeMatch, checkInvariants, give, giveItem, DATA } from './harness.js';
import { makeBattle } from '../helpers/battleHarness.js';
const soloBot = (o = {}) => makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }], ...o }); const soloBot = (o = {}) => makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }], ...o });
test('user: Given a saturated layout, When placing a self-range defender, Then prefer a free enemy path tile', () => {
const h = makeMatch({ mode: 'solo', seed: 11 }).start().toPrep();
const m = h.m;
const ps = m.order[0];
try {
const pieces = ['chess_char_6_03_a', 'chess_char_5_11_a', 'chess_char_1_02_a',
'chess_char_1_10_a', 'chess_char_2_08_a', 'chess_char_3_16_a'].map((id) => give(m, ps, id, 'hand'));
const plan = planLayout(m, ps, pieces);
// act2autochess_m04, first wave: the ground route crosses these deployable tiles.
const road = new Set(['9,9', '9,8', '9,7', '9,6', '11,5', '11,4', '10,4', '9,4', '9,3']);
assert.equal(m.stageId, 'act2autochess_m04');
assert.ok(road.has(plan.get(pieces.at(-1).uid)), `Cuora must block the route, got ${plan.get(pieces.at(-1).uid)}`);
for (const [key, expectedToBlock] of [[plan.get(pieces.at(-1).uid), true], ['12,5', false]]) {
const [row, col] = parseKey(key);
const battle = makeBattle({ stageId: m.stageId, waveTemplate: 'act1autochess_01',
units: [{ chessId: 'chess_char_3_16_a', row, col }] });
battle.runToEnd();
assert.equal(battle.hooksOf('blocked').length > 0, expectedToBlock, `blocking at ${key}`);
assert.equal(battle.result().perPlayer.p1.damageDealt > 0, expectedToBlock, `damage at ${key}`);
}
// When every path position is occupied, a legal fallback remains available.
const fallback = planLayout(m, ps, [pieces.at(-1)], undefined, { occupied: road });
const key = fallback.get(pieces.at(-1).uid);
assert.ok(key && !road.has(key));
assert.equal(ps.deployMap().get(key), 'melee');
} finally { m.dispose(); }
});
test('self-range blockers (range 0-1, only their own tile) fill the free enemy-road tiles before any other tile, on every stage and seed', () => {
// official range_table "0-1": the one grid (0,0) — 角峰 古米 泡泡 折桠 菲莱 蛇屠箱 塞雷娅 余
const guards = Object.values(DATA.chess).filter((c) => c && !c.isGolden && c.rangeGrid && c.rangeGrid.length === 1 && c.rangeGrid[0].every((v) => v === 0));
assert.equal(guards.length, 8);
const stages = new Set();
for (let seed = 1; seed <= 40; seed++) {
const h = makeMatch({ mode: 'solo', seed }).start().toPrep();
const m = h.m;
const ps = m.order[0];
try {
stages.add(m.stageId);
const pieces = guards.map((c) => give(m, ps, c.chessId, 'hand'));
const plan = planLayout(m, ps, pieces);
const model = fieldModel(m, ps);
const road = legalTiles(ps.deployMap(), 'melee').filter(([r, c]) => model.ground.has(tileKey(r, c)));
const onRoad = pieces.filter((p) => model.ground.has(plan.get(p.uid))).length;
assert.equal(onRoad, Math.min(pieces.length, road.length), `seed ${seed}, ${m.stageId}: ${onRoad} of ${pieces.length} guards on the ${road.length} road tiles`);
} finally { m.dispose(); }
}
assert.equal(stages.size, 8, 'every stage was tried');
});
test('field model: the round\'s routes traced over the own board, weighted by the enemies that use them', () => { test('field model: the round\'s routes traced over the own board, weighted by the enemies that use them', () => {
const h = soloBot({ seed: 2 }).start(); const h = soloBot({ seed: 2 }).start();
const m = h.m; const m = h.m;
@@ -222,6 +273,32 @@ test('a sliced rehearsal is dropped when the prep ends first; the default layout
m.dispose(); 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', () => { 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]) { for (const seed of [5, 8]) {
const h = soloBot({ seed }).start(); const h = soloBot({ seed }).start();
+136 -1
View File
@@ -9,7 +9,7 @@ import { PHASE } from '../../shared/constants.js';
import { validateC2S, isBattleResult } from '../../shared/protocol.js'; import { validateC2S, isBattleResult } from '../../shared/protocol.js';
import { buildBattleSpec, createBattleFromSpec, resultDigest, compactResult, LocalBossPool, jsonClone } from '../../server/sim/spec.js'; import { buildBattleSpec, createBattleFromSpec, resultDigest, compactResult, LocalBossPool, jsonClone } from '../../server/sim/spec.js';
import { DataSource } from '../../server/sim/simdata.js'; import { DataSource } from '../../server/sim/simdata.js';
import { validateClientResult, specBounds } from '../../server/match/fields.js'; import { validateClientResult, specBounds, syntheticResult } from '../../server/match/fields.js';
import { CreditPool, SharedBossPool } from '../../server/match/finalAssault.js'; import { CreditPool, SharedBossPool } from '../../server/match/finalAssault.js';
import { FakeBattle } from './fakeBattle.js'; import { FakeBattle } from './fakeBattle.js';
import { DATA, makeMatch, checkInvariants, give, chessOfTier } from './harness.js'; import { DATA, makeMatch, checkInvariants, give, chessOfTier } from './harness.js';
@@ -516,6 +516,10 @@ test('Final Assault: an early boss b.result (forced at t≈0) does not end the p
test('Final Assault: a 999-layer kill faster than the budget — the \'cleared\' b.result whose report covers the pool waits for the budget, no takeover (QA 6b)', () => { test('Final Assault: a 999-layer kill faster than the budget — the \'cleared\' b.result whose report covers the pool waits for the budget, no takeover (QA 6b)', () => {
const { h, m, f } = faWithForger(9122); const { h, m, f } = faWithForger(9122);
const ps = h.ps('p_0');
const training = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey));
m.addBounty(ps, { ...training, rounds: 2, multiRound: false });
const pending = ps.pendingFunds;
const max = m.bossPool.maxHp; const max = m.bossPool.maxHp;
h.sched.advance(500); // 1 game s on the field clock: the budget credits ≤ 20 % of the pool h.sched.advance(500); // 1 game s on the field clock: the budget credits ≤ 20 % of the pool
const gt = m._fieldElapsed(f); const gt = m._fieldElapsed(f);
@@ -544,10 +548,141 @@ test('Final Assault: a 999-layer kill faster than the budget — the \'cleared\'
assert.equal(f.resultSource, 'client', 'the held client result completed the field'); assert.equal(f.resultSource, 'client', 'the held client result completed the field');
assert.equal(m.verifyStats.takeovers, takeovers0); assert.equal(m.verifyStats.takeovers, takeovers0);
assert.equal(m.verifyStats.rejected, rejected0); assert.equal(m.verifyStats.rejected, rejected0);
assert.equal(ps.pendingFunds, pending + training.coin, 'validated held client result earns the server card amount');
assert.equal(ps.bounties[0].roundsLeft, 1);
assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: f.battleId, result: res }), { ok: true });
assert.equal(ps.pendingFunds, pending + training.coin, 'duplicate accepted result does not pay twice');
assert.equal(ps.bounties[0].roundsLeft, 1);
for (const pid of f.players) assert.ok(Math.abs((f.bossBy[pid] || 0) - Math.min(by[pid], max)) <= max * 1e-9 + 5, `${pid} credited as reported (${f.bossBy[pid]} vs ${by[pid]})`); for (const pid of f.players) assert.ok(Math.abs((f.bossBy[pid] || 0) - Math.min(by[pid], max)) <= max * 1e-9 + 5, `${pid} credited as reported (${f.bossBy[pid]} vs ${by[pid]})`);
m.dispose(); m.dispose();
}); });
test('boss bounty eligibility: forced real results on a team victory qualify; synthetic, leaked and team defeat do not', () => {
for (const [ending, synthetic, perfect, leaked, eligible] of [
['cleared', false, true, [], true],
['cleared', true, true, [], false],
['cleared', false, false, [], false],
['cleared', false, true, [{ counted: true }], false],
['cleared', false, true, [{ counted: false }], true],
['forced', false, true, [], false],
]) {
const { m, f } = faWithForger(9122);
const ps = m.players.get(f.players[0]);
const card = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey));
m.addBounty(ps, { ...card, rounds: 2, multiRound: false });
m.addBounty(ps, { ...card, rounds: 2, multiRound: false });
const res = syntheticResult(f.players);
if (!synthetic) delete res.synthetic;
res.reason = 'forced';
Object.assign(res.perPlayer[ps.playerId], { perfect, leaked, coins: 3 });
const pending = ps.pendingFunds, gained = ps.stats.fundsGained;
m._finalEnding = ending;
m.bossPool.hp = 0; // even a late clearing report cannot override an already-registered team defeat
m._finishFinal(false, () => res);
assert.equal(ps.pendingFunds, pending + 3 + (eligible ? 2 * card.coin : 0));
assert.equal(ps.stats.fundsGained, gained + 3 + (eligible ? 2 * card.coin : 0));
assert.deepEqual(ps.bounties.map((b) => b.roundsLeft), [1, 1]);
m.dispose();
}
});
// ---- a perfect-payout bounty and what the authority reported (a modified client could tell the two halves apart) -------
/**
* A Final Assault pair field played by hand (the SimClient of p_0, the authority, is muted): both players hold the same
* perfect-payout card; the authority sends one b.progress — `leaks` (the field's LP cost) and `leaksBy` (its per-player
* split, when given) — then a 'cleared' b.result whose leaked lists hold `leaked[pid]` counted leaks (lpr 1); the fight
* is driven to its end. Returns the funds each player was paid (kill coins: none) and what the server charged.
*/
function bossPaid(seed, { leaks = 0, leaksBy = null, leaked = {} }) {
const { h, m, f } = faWithForger(seed);
const card = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey));
const ps = f.players.map((pid) => h.ps(pid));
for (const p of ps) m.addBounty(p, { ...card, rounds: 2, multiRound: false });
const before = ps.map((p) => p.pendingFunds);
const lp0 = m.teamLp;
const max = m.bossPool.maxHp;
h.sched.advance(500);
const gt = m._fieldElapsed(f);
const by = { [f.players[0]]: max * 0.6, [f.players[1]]: max * 0.4 + 5 };
const progress = { t: 'b.progress', battleId: f.battleId, gt, killed: 0, total: 0, bossDmg: max + 5, by, leaks, ...(leaksBy ? { leaksBy } : {}) };
assert.equal(validateC2S(progress), null);
assert.deepEqual(m.handle('p_0', progress), { ok: true });
const entry = (pid) => ({ enemyKey: card.enemyKey, mods: null, lpr: 1, sourcePlayerId: pid, tag: null, counted: true, spawned: true });
const res = { reason: 'cleared', time: gt, killed: 0, total: 0, errors: 0,
perPlayer: Object.fromEntries(f.players.map((pid) => {
const list = Array.from({ length: leaked[pid] || 0 }, () => entry(pid));
return [pid, { killed: 0, total: 0, leaked: list, perfect: list.length === 0, layerGains: {}, coins: 0, damageDealt: by[pid], bossDamage: by[pid], healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [] }];
})) };
assert.equal(isBattleResult(res), true);
assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: f.battleId, result: res }), { ok: true });
h.drive(() => m._finalEnding != null || m.phase !== PHASE.FINAL_ASSAULT, { maxSteps: 2e5 });
const out = { ending: m._finalEnding, source: f.resultSource, coin: card.coin, charged: lp0 - m.teamLp, paid: ps.map((p, i) => p.pendingFunds - before[i]), left: ps.map((p) => p.bounties.map((b) => b.roundsLeft)) };
m.dispose();
return out;
}
test('Final Assault, a modified authority: LP charged by b.progress while both leaked lists are empty pays no perfect-payout card', () => {
const r = bossPaid(9124, { leaks: 1, leaked: {} });
assert.equal(r.ending, 'cleared');
assert.equal(r.source, 'client');
assert.equal(r.charged, 1, 'the server charged the team leak');
assert.deepEqual(r.paid, [0, 0], 'a result that shows no leak after a charged one is not believed');
assert.deepEqual(r.left, [[1], [1]], 'the boss battle still used one of the cards\' battles');
});
test('Final Assault, a modified authority: one seat\'s reported leak cannot be moved to the other — nobody is paid', () => {
// progress: the leak is p_0's; the result pins it on p_1 so that p_0 reads perfect (and p_1 cannot be paid anyway)
const r = bossPaid(9125, { leaks: 1, leaksBy: { p_0: 1, p_1: 0 }, leaked: { p_1: 1 } });
assert.equal(r.ending, 'cleared');
assert.equal(r.charged, 1);
assert.deepEqual(r.paid, [0, 0]);
// …and with no split at all the result decides nothing either: the same leak pinned on p_1
assert.deepEqual(bossPaid(9126, { leaks: 1, leaked: { p_1: 1 } }).paid, [0, 0]);
});
test('Final Assault, honest authority: the seat whose half let nothing through is paid, the leaker is not; a leader\'s own LP effect costs nobody the card', () => {
const leak = bossPaid(9127, { leaks: 1, leaksBy: { p_0: 0, p_1: 1 }, leaked: { p_1: 1 } });
assert.equal(leak.charged, 1);
assert.deepEqual(leak.paid, [leak.coin, 0]);
// 2 LP of leader "扣除目标生命" effects (no leaked enemy): the report says so, the result agrees — both halves are perfect
const fx = bossPaid(9128, { leaks: 2, leaksBy: { p_0: 0, p_1: 0 }, leaked: {} });
assert.equal(fx.charged, 2);
assert.deepEqual(fx.paid, [fx.coin, fx.coin]);
// nothing charged, nothing leaked: as before
assert.deepEqual(bossPaid(9129, { leaks: 0, leaked: {} }).paid.map((n, i, a) => n === a[0]), [true, true]);
assert.ok(bossPaid(9130, { leaks: 0, leaked: {} }).paid.every((n) => n > 0));
});
test('Final Assault, honest clients (client-side combat): a leak on one half costs that seat its perfect-payout card, not its partner; the leader\'s own LP effect costs nobody', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 9132, fake: true, clientCombat: true, instant: false,
script: (b) => (b.kind === 'boss' ? { bossDps: 20000, leaks: { p_1: 1 }, leakEvents: [{ at: 1, lpr: 1 }], lpLossEvents: [{ at: 1.5, amount: 2 }] } : { duration: 2 }) }).start();
const m = h.m;
h.autoHumans();
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
const card = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey));
const [p0, p1] = [h.ps('p_0'), h.ps('p_1')];
for (const p of [p0, p1]) m.addBounty(p, { ...card, rounds: 2, multiRound: false });
const [a, b] = [p0.pendingFunds, p1.pendingFunds];
const end = h.runToEnd();
assert.equal(end.victory, true);
const f = fields(h)[0];
assert.equal(f.resultSource, 'client');
assert.equal(f.lpReported, 3, 'leak lpr 1 + leader effect 2');
assert.deepEqual(f.leaksBy, { p_1: 1 }, 'the authority split it: p_1\'s one leak (the other 2 LP are the leader\'s effect), nothing on p_0');
assert.equal(p0.pendingFunds - a, card.coin, 'the seat that let nothing through is paid');
assert.equal(p1.pendingFunds - b, 0, 'the seat that leaked is not');
m.dispose();
});
test('b.progress carries `leaksBy`, the per-player split of a boss field\'s leak LP (shared/protocol.js)', () => {
const base = { t: 'b.progress', battleId: 'a.1.1.b1', gt: 3, killed: 0, total: 0, leaks: 2, bossDmg: 10, by: { p_0: 10 } };
assert.equal(validateC2S({ ...base, leaksBy: { p_0: 1, p_1: 0.5 } }), null);
assert.equal(validateC2S({ ...base, leaksBy: {} }), null);
assert.equal(validateC2S(base), null, 'optional: a client that never sends it still validates');
for (const bad of [{ p_0: -1 }, { p_0: 'x' }, { p_0: 1e7 }, [1, 2], 'p_0', null, { 'a b': 1 }]) assert.notEqual(validateC2S({ ...base, leaksBy: bad }), null, JSON.stringify(bad));
});
test('Final Assault: a \'cleared\' b.result whose report does NOT cover the pool is still handed over (the partner takes the field)', () => { test('Final Assault: a \'cleared\' b.result whose report does NOT cover the pool is still handed over (the partner takes the field)', () => {
const { h, m, f } = faWithForger(9123); const { h, m, f } = faWithForger(9123);
const max = m.bossPool.maxHp; const max = m.bossPool.maxHp;
+41 -1
View File
@@ -4,7 +4,7 @@ import assert from 'node:assert/strict';
import { PHASE, ERR } from '../../shared/constants.js'; import { PHASE, ERR } from '../../shared/constants.js';
import { Match } from '../../server/match/Match.js'; import { Match } from '../../server/match/Match.js';
import { StubMatch } from '../../server/match/StubMatch.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'; import { bossPoolHp } from '../../server/match/finalAssault.js';
const STATUSES = ['acting', 'ready', 'deciding', 'combat', 'done', 'helping', 'left', 'dead']; 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.ok(s.kind === 'chess' || s.kind === 'item');
} }
assert.equal(priv.shop.rewardOffer, null); assert.equal(priv.shop.rewardOffer, null);
assert.equal(priv.personalChoice, null);
assert.equal(priv.deployCap, 8); assert.equal(priv.deployCap, 8);
assert.ok(priv.effects.some((e) => e.iconKind === 'band' && e.name)); assert.ok(priv.effects.some((e) => e.iconKind === 'band' && e.name));
assert.ok(priv.nextEnemies.length > 0); 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(); 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', () => { 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 h = makeMatch({ mode: 'solo', seed: 64, fake: true }).start();
const m = h.m; 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.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'); assert.equal(Math.round((m2.deadline - h2.sched.now()) / 1000), 30, 'first picker 30 s');
const [a, b, c] = sp.order; 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.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.deepEqual(m2.handle(a, { t: 'g.choice', idx: 2 }), { ok: true });
assert.equal(m2.handle(a, { t: 'g.choice', idx: 3 }).error, ERR.ALREADY); 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.equal(a.hand[3], t);
assert.ok(a.tempEmpty); assert.ok(a.tempEmpty);
assert.equal(a.privateView().canReady, true); 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.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.refresh' }), { error: ERR.WRONG_PHASE, detail: 'ready' });
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: false }), { ok: true }); 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 a = h.ps('p_0');
const id = chessOfTier(1).find((x) => m.pool.has(x)); const id = chessOfTier(1).find((x) => m.pool.has(x));
const left = m.pool.left(id); 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); fillHand(a);
give(m, a, id, 'temp'); give(m, a, id, 'temp');
assert.ok(!a.tempEmpty, 'a full hand: it waits in 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.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)); assert.equal(Math.round((m.deadline - h.sched.now()) / 1000), m.gd.prepTime(1));
h.sched.advance(m.deadline - h.sched.now() + 1); h.sched.advance(m.deadline - h.sched.now() + 1);
assert.notEqual(m.phase, PHASE.PREP); assert.notEqual(m.phase, PHASE.PREP);
assert.ok(checked);
assert.ok(a.tempEmpty); assert.ok(a.tempEmpty);
assert.equal(m.pool.left(id), left, 'temp chess sold back to the pool'); assert.equal(m.pool.left(id), left, 'temp chess sold back to the pool');
checkInvariants(m); checkInvariants(m);
@@ -252,7 +274,12 @@ test('solo prep and 机变 are untimed', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 13 }).start(); const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 13 }).start();
h.toPrep(1); h.toPrep(1);
assert.equal(h.m.deadline, 0); 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); h.sched.advance(10 * 60 * 1000);
assert.equal(h.m.phase, PHASE.PREP); assert.equal(h.m.phase, PHASE.PREP);
assert.equal(ps.personalChoice.id, id, 'no separate personal-choice timer');
h.m.dispose(); h.m.dispose();
}); });
+85 -1
View File
@@ -11,7 +11,7 @@ import { fieldModel } from '../../server/match/bot.js';
import { FIELD, parseKey } from '../../server/match/board.js'; import { FIELD, parseKey } from '../../server/match/board.js';
import { createRng } from '../../server/sim/rng.js'; import { createRng } from '../../server/sim/rng.js';
import { FakeBattle } from './fakeBattle.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'); const bossFields = () => FakeBattle.instances.filter((b) => b.kind === 'boss' || b.kind === 'hidden');
@@ -56,6 +56,90 @@ test('multi-round bounties spawn in the Final Assault on the owner\'s half, show
m.dispose(); m.dispose();
}); });
// A 战术特训 card pays its coins when the player's own battle is perfect (PRTS "若各自行动阶段就达成完美作战,获得N资金");
// the Final Assault and the Hidden Core are such battles — they pay it with the kill coins, at the next income.
test('perfect-payout bounties in the boss rounds: a 战术特训 card\'s coins join the kill coins at the next income (R14 → R15, then the Hidden Core)', () => {
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 [card, second] = DATA.choices.cards.bounty.filter((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey) && !m.gd.inactiveEnemies.has(c.enemyKey));
m.addBounty(ps, card);
const pending = ps.pendingFunds, gained = ps.stats.fundsGained;
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
m.hiddenLayerSum = 1e6; // the Hidden Core opens
assert.ok(bossFields()[0].opts.spawns.some((s) => s.tag === 'bounty' && s.enemyKey === card.enemyKey && s.ownerPlayerId === ps.playerId), 'the card\'s enemies are in the boss battle');
h.run(() => m.runner === null);
assert.equal(ps.pendingFunds, pending + 3 + card.coin, 'kill coins + one card amount, regardless of its enemy count');
assert.equal(ps.stats.fundsGained, gained + 3 + card.coin);
const left = bountyBattles(card);
assert.equal(ps.bounties.length, left > 1 ? 1 : 0, 'the boss battle used one of its battles');
if (left > 1) assert.equal(ps.bounties[0].roundsLeft, left - 1);
h.toPrep(15);
assert.equal(ps.pendingFunds, 0);
assert.equal(ps.funds, m.gd.income(15) + pending + 3 + card.coin, 'the base income is separate from the bounty funds');
m.addBounty(ps, second);
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 === second.enemyKey), 'and in the Hidden Core');
h.runToEnd();
assert.equal(ps.pendingFunds, 3 + owed, 'the Hidden Core pays by the same rule');
assert.equal(ps.bounties.length, 0, 'every bounty used its battles');
checkInvariants(m);
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();
h.toPrep(14);
const m = h.m, ps = h.ps('p_0');
const card = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && !m.gd.inactiveEnemies.has(c.enemyKey));
m.addBounty(ps, { ...card, rounds: 2, multiRound: false });
const gained = ps.stats.fundsGained;
const result = h.drive(() => h.ended != null);
assert.ok(result && h.ended.victory);
assert.equal(ps.pendingFunds, 3);
assert.equal(ps.stats.fundsGained, gained + 3);
assert.equal(ps.bounties[0].roundsLeft, 1);
m.dispose();
});
test('solo Final Assault: the bounty spawns on the `_s` template; bountySpawns route choice by side', () => { test('solo Final Assault: the bounty spawns on the `_s` template; bountySpawns route choice by side', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'HARD', seed: 62, fake: true, script: (b) => (b.kind === 'boss' ? { bossDps: 1e9 } : {}) }).start(); const h = makeMatch({ mode: 'solo', difficulty: 'HARD', seed: 62, fake: true, script: (b) => (b.kind === 'boss' ? { bossDps: 1e9 } : {}) }).start();
const m = h.m; const m = h.m;
+16
View File
@@ -397,6 +397,22 @@ test('collectViolations reports corrupted state (funds, pool accounting, duplica
m.dispose(); 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)', () => { 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 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; const m = h.m;
+1
View File
@@ -124,6 +124,7 @@ export function makeMatch(o = {}) {
if (idx != null) m.handle(ps.playerId, { t: 'g.choice', idx }); if (idx != null) m.handle(ps.playerId, { t: 'g.choice', idx });
} }
if (ready && m.phase === 'PREP' && ps.alive && !ps.ready) { 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(); if (!ps.tempEmpty) ps.resolveTemp();
m.handle(ps.playerId, { t: 'g.ready', ready: true }); m.handle(ps.playerId, { t: 'g.ready', ready: true });
} }
+6
View File
@@ -217,6 +217,12 @@ test('boss field: the local pool follows b.pool (server hp − unacknowledged lo
assert.ok(last.bossDmg <= pool.cum); assert.ok(last.bossDmg <= pool.cum);
assert.ok(last.by && typeof last.by === 'object'); assert.ok(last.by && typeof last.by === 'object');
assert.equal(typeof last.leaks, 'number'); assert.equal(typeof last.leaks, 'number');
// the per-player split of the leak LP (the server holds the result's leaked lists to it, Match._bossLeaksAgree): the
// leaks of every player of the field, never more than the field's LP cost (leader effects make up the rest)
assert.ok(last.leaksBy && typeof last.leaksBy === 'object', 'boss progress carries leaksBy');
assert.deepEqual(Object.keys(last.leaksBy).sort(), start.spec.players.map((p) => p.playerId).sort());
assert.ok(Object.values(last.leaksBy).every((n) => Number.isFinite(n) && n >= 0));
assert.ok(Object.values(last.leaksBy).reduce((a, b) => a + b, 0) <= last.leaks + 1e-9);
r.net.emit('b.pool', { hp: pool.maxHp * 0.5, max: pool.maxHp, teamLp: 20, acked: { [start.fieldId]: pool.cum } }); r.net.emit('b.pool', { hp: pool.maxHp * 0.5, max: pool.maxHp, teamLp: 20, acked: { [start.fieldId]: pool.cum } });
assert.ok(Math.abs(pool.hp - pool.maxHp * 0.5) < 1e-6, 'server hp when everything is acknowledged'); assert.ok(Math.abs(pool.hp - pool.maxHp * 0.5) < 1e-6, 'server hp when everything is acknowledged');
r.runner.dispose(); r.runner.dispose();
+1
View File
@@ -135,6 +135,7 @@ export class SimClient {
if (bossLike) { if (bossLike) {
const pool = e.battle.sharedBoss; const pool = e.battle.sharedBoss;
msg.leaks = e.meter.lp; msg.leaks = e.meter.lp;
if (p.leaksBy) msg.leaksBy = p.leaksBy; // like public/js/battle/runner.js
msg.bossDmg = pool && Number.isFinite(pool.cum) ? pool.cum : 0; msg.bossDmg = pool && Number.isFinite(pool.cum) ? pool.cum : 0;
if (pool && pool.byPlayer) msg.by = { ...pool.byPlayer }; if (pool && pool.byPlayer) msg.by = { ...pool.byPlayer };
} else { } else {
+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.equal(validateC2S({ t: 'g.bandFocus', bandId: 'band_bldsk' }), null);
assert.notEqual(validateC2S({ t: 'g.bandFocus', bandId: 'a b' }), null); assert.notEqual(validateC2S({ t: 'g.bandFocus', bandId: 'a b' }), null);
assert.ok(S2C.includes('m.unitStats')); 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', () => { 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, phaseMode, phaseBanner, isCombatPhase, isBossPhase, countdownState, phaseTotalSeconds, sortBonds, bondTier, nextThreshold,
battleOverSfx, ownRoundLoss, uniteResultBox, battleResultBox, roundResultBox, RESULT_BOX_MS, battleOverSfx, ownRoundLoss, uniteResultBox, battleResultBox, roundResultBox, RESULT_BOX_MS,
bondMembers, memberHeadCount, bannedPerBond, priceTone, mergeProgress, shopBlockReason, deploySets, indexPieces, placementContext, canPlace, 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, rangeGridBox, shortcutFor, sanitizeSettings, DEFAULT_SETTINGS, normalizeResult, cycleField, fieldLabel, homeFieldId,
activeBubbles, sortedPlayers, tileKey, prepCapsuleLabel, prepCamera, dropFailureReason, terrainInfo, activeBubbles, sortedPlayers, tileKey, prepCapsuleLabel, prepCamera, dropFailureReason, terrainInfo,
} from '../../public/js/ui/gameLogic.js'; } from '../../public/js/ui/gameLogic.js';
@@ -584,6 +584,32 @@ describe('drafts', () => {
assert.equal(normalizeSp(null), null); assert.equal(normalizeSp(null), null);
assert.equal(normalizeSp({ cards: new Array(9).fill({}) }).cards.length, 6, 'at most 6 cards'); 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', () => { describe('fields, players, emotes', () => {
+140 -1
View File
@@ -15,7 +15,7 @@
import { test, describe, before, after } from 'node:test'; import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict'; 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 path from 'node:path';
import { fileURLToPath } from 'node:url'; 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 mockState = (page) => page.evaluate(() => JSON.parse(JSON.stringify(globalThis.__MOCK__.S().priv)));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); 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). */ /** Swipe the open direction wheel from its centre towards `dir` and release (research 09 §1.2). */
const swipeWheel = async (page, dir = 'RIGHT') => { const swipeWheel = async (page, dir = 'RIGHT') => {
await page.waitForSelector('.fwheel__dia', { timeout: 4000 }); await page.waitForSelector('.fwheel__dia', { timeout: 4000 });
@@ -480,6 +490,135 @@ describe('in-match UI (mock harness, headless Chrome)', { skip: !ENABLED && 'set
await page.close(); 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 ---------------------------------------------------------------------------------------- // ---- 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 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; 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 () => { 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 P = await pptr();
const host = new Client(P, srv.base, 'host', { prefix: 'fix' }); const host = new Client(P, srv.base, 'host', { prefix: 'fix' });
const guest = new Client(P, srv.base, 'guest', { prefix: 'fix', w: 1280, h: 720 }); 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'); assert.deepEqual(back.prepField, { kind: 'bossPrep', side: 'L', mirror: false }, '返回: the own boss half again');
} }
await penRoundTrip(guest, 'guest'); 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'); for (const c of [host, guest]) await c.click('.readybtn');
await checkBossBattle(host, hp, 'L', 'host'); await checkBossBattle(host, hp, 'L', 'host');
await checkBossBattle(guest, gp, 'R', 'guest'); 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 }); 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)', () => { 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) // 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]] }); 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)', () => { 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'); const game = read('public/js/screens/game.js');
// the same condition as ReadyToggle's reason: PREP, alive, not ready, pieces in the row // 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'/); assert.match(game, /readyWhy && 'has-readywhy'/);
const css = read('public/css/screens/game.css'); const css = read('public/css/screens/game.css');
assert.match(css, /\.gm__effects \{ position: absolute; right: \.3rem; top: 2\.4rem; \}/); 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'))); 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', () => { test('the "作战结束,等待队友完成作战" hourglass stands still', () => {
const css = read('public/css/screens/game.css'); const css = read('public/css/screens/game.css');
assert.doesNotMatch(css, /hg-flip/); 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'))), /无时间限制/); 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)', () => { 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'); const src = read('public/js/ui/choiceOverlay.js');
assert.match(src, /export function ChoiceOverlay\(props\) \{[\s\S]*useState\(null\)[\s\S]*armedCard\(sel, sp/); assert.match(src, /export function ChoiceOverlay\(props\) \{[\s\S]*useState\(null\)[\s\S]*armedCard\(sel, sp/);