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