From 05eb2b807f4af6b60865149af5b40b04dc6fb58f Mon Sep 17 00:00:00 2001 From: Sukvii Date: Thu, 8 Oct 2026 20:12:14 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feedback7=20dober:=20the=20Final=20Assault?= =?UTF-8?q?=20and=20the=20Hidden=20Core=20pay=20a=20=E6=88=98=E6=9C=AF?= =?UTF-8?q?=E7=89=B9=E8=AE=AD=20card's=20perfect-battle=20coins,=20they=20?= =?UTF-8?q?paid=20kill=20coins=20only=20(PR=20#353=20by=20@Sukvii);=20gold?= =?UTF-8?q?en:=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seen: Match._settleBossBounties banked only the kill coins of the boss field (pp.coins). A perfect-payout bounty (a 战术特训 card, from the 杜宾 Art 教鞭) has its enemies spawned in the Final Assault / Hidden Core like any bounty with battles left, and used a battle there, but its coins were never paid — SETTLE pays them after every normal battle. Official: PRTS 杜宾 "<教鞭>:使用后为下场战斗添加额外敌人,若自身战斗完美作战可获得资金" and the cards' "若各自行动阶段就达成完美作战,获得N资金" have no boss-battle exception. The condition is [ASSUMED]: the team won (the first end the server registered), the result is a real one (not the synthetic stand-in of a field that never reported) and the player's own field let no counted enemy through; paid once per player, spent in the Hidden Core's prep with the kill coins. Now: _finishFinal reads the victory before the per-field loop and hands _settleBossBounties a `perfect` flag. Tests: clientCombat.test.js (the held 'cleared' result pays once, a duplicate never twice; six eligibility cases: team win / synthetic / own leak / uncounted leak / forced team defeat), followups.test.js (R14 -> R15 income and the Hidden Core; a boss win with an own leak keeps the kill coins only). Both fail on the parent. Golden: unchanged (matches: 18 scenarios match). --- docs/BALANCE.md | 2 +- docs/META.md | 6 +++- server/match/match/bossRounds.js | 26 ++++++++++++----- test/match/clientCombat.test.js | 40 ++++++++++++++++++++++++- test/match/followups.test.js | 50 ++++++++++++++++++++++++++++++++ 5 files changed, 113 insertions(+), 11 deletions(-) diff --git a/docs/BALANCE.md b/docs/BALANCE.md index 5edb42da..4fbac26d 100644 --- a/docs/BALANCE.md +++ b/docs/BALANCE.md @@ -393,7 +393,7 @@ heuristics CPU 36 / 88 → 35 / 86 ms and 16 / 40 → 16 / 40 ms. * Bounties with battles left (multi-round cards — "之后的每场作战" — last two battles since playtest #6, META §1.2) also spawn in the Final Assault / Hidden Core, on the owner's half (route to its goal), are shown in the boss-round - preview, and the boss battle uses up one of their battles (kill coins → pending funds for the Hidden Core prep). + preview, and the boss battle uses up one of their battles (kill coins, and a perfect-payout 战术特训 card's coins when the player's own field is perfect and the team wins → pending funds for the Hidden Core prep). * A 驰援 card is never offered when its bond has no chess left in the match's pool (every member banned). * 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 diff --git a/docs/META.md b/docs/META.md index 212ab116..d4031210 100644 --- a/docs/META.md +++ b/docs/META.md @@ -619,7 +619,11 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o `enemyScale[r]` × the tuning layer §3.1, bounties, boss templates, 联防 routing). Bounties with battles left (every one that lasts more than one battle; an official multi-round card lasts two, §1.2) also spawn in the Final Assault / Hidden Core, on the owner's half of the boss field (a route ending at its goal); the boss battle then uses up one of - the bounty's battles and its kill coins go to pending funds. + the bounty's battles and its kill coins go to pending funds — with, for every perfect-payout card (战术特训), its coins + as after a normal battle ("若各自行动阶段就达成完美作战,获得N资金"): once per player, when the team won (the first end the + server registered: the leader's pool empty), the result is a real one (not the stand-in of a field that never + reported) and the player's own field let no counted enemy through [ASSUMED: the text has no boss-battle exception, and + the card's enemies do come in the boss battle]. They are spent in the Hidden Core's prep like the kill coins. * **Combat time limit**: data `combatTimeLimit` (the level's `maxPlayTime`) counts REAL seconds of the forced 2× battle; the Battle / 联防 limit is `gd.combatTimeLimit(r)` = 2 × that in game seconds (`config.combatTimeScale`, default 2). Read as game seconds the rounds' own spawn schedules would not fit (R2's last flyer spawns at 43 s of diff --git a/server/match/match/bossRounds.js b/server/match/match/bossRounds.js index 74d659a1..5397d954 100644 --- a/server/match/match/bossRounds.js +++ b/server/match/match/bossRounds.js @@ -372,11 +372,15 @@ export class MatchBoss { } /** - * Bounties after a boss field: kill-bounty coins go to pending funds (spent in the Hidden Core's prep) and every - * bounty used one of its battles, exactly like SETTLE does for normal rounds. + * Bounties after a boss field: kill-bounty coins and — when `perfect` — the coins of every perfect-payout bounty (a + * 战术特训 card: PRTS "若各自行动阶段就达成完美作战,获得N资金", the same sum SETTLE pays after a normal battle) go to + * pending funds (spent in the Hidden Core's prep), and every bounty used one of its battles, exactly like SETTLE does + * for normal rounds. + * @param {boolean} perfect the player's own field was perfect and the team won (`_finishFinal`) */ - _settleBossBounties(ps, pp) { - const coins = Math.max(0, Math.trunc(Number(pp.coins) || 0)); + _settleBossBounties(ps, pp, perfect) { + let coins = Math.max(0, Math.trunc(Number(pp.coins) || 0)); + if (perfect) for (const b of ps.bounties) if (b.card.payout === 'perfect') coins += b.card.coin; if (coins > 0) { ps.pendingFunds += coins; ps.stats.fundsGained += coins; } if (!ps.bounties.length) return; for (const b of ps.bounties) b.roundsLeft--; @@ -471,6 +475,9 @@ export class MatchBoss { _finishFinal(hidden, resultOf) { if (this.phase !== (hidden ? PHASE.HIDDEN_CORE : PHASE.FINAL_ASSAULT)) return; this._stopClientCombat(); + // the end condition the server registered first decides (client-side combat: _endFinal — pool 0 → victory, team LP 0 + // → defeat); a boss field's final result may never turn a defeat into a victory (user playtest #6 item 5) + const victory = this._finalEnding ? this._finalEnding === 'cleared' : this.bossPool.hp <= 0; for (const f of this.fields) { const res = resultOf(f); this._collectSimErrors(f, res); @@ -485,13 +492,16 @@ export class MatchBoss { ps.dirty(); // m.private.stats this._charDamageTickers(ps, pp); } - if (pp && ps) this._settleBossBounties(ps, pp); + if (pp && ps) { + // the own battle counts as perfect when the team won, the result is a real one (not the stand-in of a field that + // never reported) and the player's own field let no counted enemy through [ASSUMED: the boss battle is a battle + // of the player's own — "下场作战" — so a perfect-payout card pays as after a normal one] + const perfect = victory && !res.synthetic && pp.perfect === true && !(pp.leaked || []).some((l) => l && l.counted !== false); + this._settleBossBounties(ps, pp, perfect); + } if (pp && ps) this.dispatch(ps, 'onBattleResult', { result: pp, lpLoss: 0, perfect: !!pp.perfect, boss: true }); } } - // the end condition the server registered first decides (client-side combat: _endFinal — pool 0 → victory, team LP 0 - // → defeat); a boss field's final result may never turn a defeat into a victory (user playtest #6 item 5) - const victory = this._finalEnding ? this._finalEnding === 'cleared' : this.bossPool.hp <= 0; this._syncTeamLp(); this.deadline = 0; this.overtimeAt = 0; diff --git a/test/match/clientCombat.test.js b/test/match/clientCombat.test.js index f314a7e4..dab1443b 100644 --- a/test/match/clientCombat.test.js +++ b/test/match/clientCombat.test.js @@ -9,7 +9,7 @@ import { PHASE } from '../../shared/constants.js'; import { validateC2S, isBattleResult } from '../../shared/protocol.js'; import { buildBattleSpec, createBattleFromSpec, resultDigest, compactResult, LocalBossPool, jsonClone } from '../../server/sim/spec.js'; import { DataSource } from '../../server/sim/simdata.js'; -import { validateClientResult, specBounds } from '../../server/match/fields.js'; +import { validateClientResult, specBounds, syntheticResult } from '../../server/match/fields.js'; import { CreditPool, SharedBossPool } from '../../server/match/finalAssault.js'; import { FakeBattle } from './fakeBattle.js'; import { DATA, makeMatch, checkInvariants, give, chessOfTier } from './harness.js'; @@ -509,6 +509,10 @@ test('Final Assault: an early boss b.result (forced at t≈0) does not end the p test('Final Assault: a 999-layer kill faster than the budget — the \'cleared\' b.result whose report covers the pool waits for the budget, no takeover (QA 6b)', () => { const { h, m, f } = faWithForger(9122); + const ps = h.ps('p_0'); + const training = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey)); + m.addBounty(ps, { ...training, rounds: 2, multiRound: false }); + const pending = ps.pendingFunds; const max = m.bossPool.maxHp; h.sched.advance(500); // 1 game s on the field clock: the budget credits ≤ 20 % of the pool const gt = m._fieldElapsed(f); @@ -537,10 +541,44 @@ test('Final Assault: a 999-layer kill faster than the budget — the \'cleared\' assert.equal(f.resultSource, 'client', 'the held client result completed the field'); assert.equal(m.verifyStats.takeovers, takeovers0); assert.equal(m.verifyStats.rejected, rejected0); + assert.equal(ps.pendingFunds, pending + training.coin, 'validated held client result earns the server card amount'); + assert.equal(ps.bounties[0].roundsLeft, 1); + assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: f.battleId, result: res }), { ok: true }); + assert.equal(ps.pendingFunds, pending + training.coin, 'duplicate accepted result does not pay twice'); + assert.equal(ps.bounties[0].roundsLeft, 1); for (const pid of f.players) assert.ok(Math.abs((f.bossBy[pid] || 0) - Math.min(by[pid], max)) <= max * 1e-9 + 5, `${pid} credited as reported (${f.bossBy[pid]} vs ${by[pid]})`); m.dispose(); }); +test('boss bounty eligibility: forced real results on a team victory qualify; synthetic, leaked and team defeat do not', () => { + for (const [ending, synthetic, perfect, leaked, eligible] of [ + ['cleared', false, true, [], true], + ['cleared', true, true, [], false], + ['cleared', false, false, [], false], + ['cleared', false, true, [{ counted: true }], false], + ['cleared', false, true, [{ counted: false }], true], + ['forced', false, true, [], false], + ]) { + const { m, f } = faWithForger(9122); + const ps = m.players.get(f.players[0]); + const card = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey)); + m.addBounty(ps, { ...card, rounds: 2, multiRound: false }); + m.addBounty(ps, { ...card, rounds: 2, multiRound: false }); + const res = syntheticResult(f.players); + if (!synthetic) delete res.synthetic; + res.reason = 'forced'; + Object.assign(res.perPlayer[ps.playerId], { perfect, leaked, coins: 3 }); + const pending = ps.pendingFunds, gained = ps.stats.fundsGained; + m._finalEnding = ending; + m.bossPool.hp = 0; // even a late clearing report cannot override an already-registered team defeat + m._finishFinal(false, () => res); + assert.equal(ps.pendingFunds, pending + 3 + (eligible ? 2 * card.coin : 0)); + assert.equal(ps.stats.fundsGained, gained + 3 + (eligible ? 2 * card.coin : 0)); + assert.deepEqual(ps.bounties.map((b) => b.roundsLeft), [1, 1]); + m.dispose(); + } +}); + test('Final Assault: a \'cleared\' b.result whose report does NOT cover the pool is still handed over (the partner takes the field)', () => { const { h, m, f } = faWithForger(9123); const max = m.bossPool.maxHp; diff --git a/test/match/followups.test.js b/test/match/followups.test.js index 37054329..7be1adf7 100644 --- a/test/match/followups.test.js +++ b/test/match/followups.test.js @@ -56,6 +56,56 @@ test('multi-round bounties spawn in the Final Assault on the owner\'s half, show m.dispose(); }); +// A 战术特训 card pays its coins when the player's own battle is perfect (PRTS "若各自行动阶段就达成完美作战,获得N资金"); +// the Final Assault and the Hidden Core are such battles — they pay it with the kill coins, at the next income. +test('perfect-payout bounties in the boss rounds: a 战术特训 card\'s coins join the kill coins at the next income (R14 → R15, then the Hidden Core)', () => { + const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 61, fake: true, + script: (b) => (b.kind === 'boss' || b.kind === 'hidden' ? { bossDps: 1e9, coins: { p_0: 3 } } : {}) }).start(); + const m = h.m; + h.toPrep(14); + const ps = h.ps('p_0'); + const [card, second] = DATA.choices.cards.bounty.filter((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey) && !m.gd.inactiveEnemies.has(c.enemyKey)); + m.addBounty(ps, card); + const pending = ps.pendingFunds, gained = ps.stats.fundsGained; + h.drive(() => m.phase === PHASE.FINAL_ASSAULT); + m.hiddenLayerSum = 1e6; // the Hidden Core opens + assert.ok(bossFields()[0].opts.spawns.some((s) => s.tag === 'bounty' && s.enemyKey === card.enemyKey && s.ownerPlayerId === ps.playerId), 'the card\'s enemies are in the boss battle'); + h.run(() => m.runner === null); + assert.equal(ps.pendingFunds, pending + 3 + card.coin, 'kill coins + one card amount, regardless of its enemy count'); + assert.equal(ps.stats.fundsGained, gained + 3 + card.coin); + const left = bountyBattles(card); + assert.equal(ps.bounties.length, left > 1 ? 1 : 0, 'the boss battle used one of its battles'); + if (left > 1) assert.equal(ps.bounties[0].roundsLeft, left - 1); + h.toPrep(15); + assert.equal(ps.pendingFunds, 0); + assert.equal(ps.funds, m.gd.income(15) + pending + 3 + card.coin, 'the base income is separate from the bounty funds'); + m.addBounty(ps, second); + const owed = ps.bounties.filter((b) => b.card.payout === 'perfect').reduce((n, b) => n + b.card.coin, 0); + h.drive(() => m.phase === PHASE.HIDDEN_CORE); + assert.ok(bossFields().at(-1).opts.spawns.some((s) => s.tag === 'bounty' && s.enemyKey === second.enemyKey), 'and in the Hidden Core'); + h.runToEnd(); + assert.equal(ps.pendingFunds, 3 + owed, 'the Hidden Core pays by the same rule'); + assert.equal(ps.bounties.length, 0, 'every bounty used its battles'); + checkInvariants(m); + m.dispose(); +}); + +test('boss victory without a perfect own result keeps the kill coins but gives no perfect-card funds', () => { + const h = makeMatch({ mode: 'coop', humans: 2, seed: 61, fake: true, + script: (b) => (b.kind === 'boss' ? { bossDps: 1e9, leaks: { p_0: 1 }, coins: { p_0: 3 } } : {}) }).start(); + h.toPrep(14); + const m = h.m, ps = h.ps('p_0'); + const card = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && !m.gd.inactiveEnemies.has(c.enemyKey)); + m.addBounty(ps, { ...card, rounds: 2, multiRound: false }); + const gained = ps.stats.fundsGained; + const result = h.drive(() => h.ended != null); + assert.ok(result && h.ended.victory); + assert.equal(ps.pendingFunds, 3); + assert.equal(ps.stats.fundsGained, gained + 3); + assert.equal(ps.bounties[0].roundsLeft, 1); + m.dispose(); +}); + test('solo Final Assault: the bounty spawns on the `_s` template; bountySpawns route choice by side', () => { const h = makeMatch({ mode: 'solo', difficulty: 'HARD', seed: 62, fake: true, script: (b) => (b.kind === 'boss' ? { bossDps: 1e9 } : {}) }).start(); const m = h.m; From 56a7b918a9a0feb09c37f4e517f391fddf95a2ca Mon Sep 17 00:00:00 2001 From: Sukvii Date: Thu, 8 Oct 2026 20:45:54 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feedback7=20dober:=20=E6=95=99=E9=9E=AD=20i?= =?UTF-8?q?s=20the=20official=20personal=20choice=20of=20three=20=E6=88=98?= =?UTF-8?q?=E6=9C=AF=E7=89=B9=E8=AE=AD=20cards=20again,=20not=20a=20random?= =?UTF-8?q?=20one=20(PR=20#353=20by=20@Sukvii,=20the=20owner's=20decision?= =?UTF-8?q?=20of=202026-10-08);=20golden:=20coop2-NORMAL-8-serverrun=20and?= =?UTF-8?q?=20coop2-ABYSS-10-boosted=20move?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- docs/BALANCE.md | 3 +- docs/META.md | 62 +++++++++---- docs/PLAYING.md | 2 +- docs/design/engine.md | 2 +- docs/design/match.md | 13 ++- docs/design/network.md | 5 +- docs/history/v2.5-playtest6.md | 2 + public/dev/game-mock.js | 25 ++++- public/i18n/en.json | 6 ++ public/i18n/ja.json | 6 ++ public/i18n/ko.json | 6 ++ public/i18n/zh-TW.json | 6 ++ public/js/screens/game.js | 40 +++++++- public/js/ui/choiceOverlay.js | 19 ++-- public/js/ui/gameActions.js | 2 +- public/js/ui/gameLogic.js | 2 +- public/js/ui/gameLogic/draft.js | 14 +++ public/js/ui/gameLogic/shop.js | 5 +- public/js/ui/hud.js | 15 +-- server/match/PlayerState.js | 7 ++ server/match/bot.js | 11 ++- server/match/builtinMeta.js | 3 +- server/match/effectsMeta.js | 1 + server/match/invariants.js | 8 ++ server/match/match/intents.js | 4 +- server/match/match/prep.js | 2 + server/match/match/spDraft.js | 62 ++++++++++++- server/match/player/prep.js | 1 + server/match/player/round.js | 1 + server/match/player/views.js | 10 +- server/sim/content/items/meta.js | 56 +++++++----- shared/protocol.js | 2 +- test/content/items.test.js | 69 +++++++++++++- test/golden/matches.json | 130 +++++++++++++------------- test/match/bot-smarter.test.js | 41 ++++++++- test/match/bot.test.js | 28 +++++- test/match/connection.test.js | 42 ++++++++- test/match/draft.test.js | 2 + test/match/economy.test.js | 27 ++++++ test/match/followups.test.js | 36 +++++++- test/match/hardening.test.js | 16 ++++ test/match/harness.js | 1 + test/match/unitStats.test.js | 5 + test/ui/gameLogic.test.js | 28 +++++- test/ui/mock.e2e.test.js | 141 ++++++++++++++++++++++++++++- test/ui/playtest2.real.e2e.test.js | 43 ++++++++- test/ui/playtest3.test.js | 26 +++++- test/ui/playtest4.test.js | 22 +++++ 48 files changed, 901 insertions(+), 159 deletions(-) diff --git a/docs/BALANCE.md b/docs/BALANCE.md index 4fbac26d..c88766a6 100644 --- a/docs/BALANCE.md +++ b/docs/BALANCE.md @@ -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. --- diff --git a/docs/META.md b/docs/META.md index d4031210..795cde6c 100644 --- a/docs/META.md +++ b/docs/META.md @@ -175,18 +175,18 @@ A `choice:` 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 diff --git a/docs/PLAYING.md b/docs/PLAYING.md index f357981a..48753eaf 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -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 个)时,**新栏位立刻刷出一名新干员**,已有的卡片不变 | | 其他收入 | 悬赏敌人、盟约「远见」「奇迹」、装备「骑士储蓄罐」、部分策略和特质 | diff --git a/docs/design/engine.md b/docs/design/engine.md index 9b90dee1..97f27166 100644 --- a/docs/design/engine.md +++ b/docs/design/engine.md @@ -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). --- diff --git a/docs/design/match.md b/docs/design/match.md index 9e4338b0..cb064e57 100644 --- a/docs/design/match.md +++ b/docs/design/match.md @@ -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). --- diff --git a/docs/design/network.md b/docs/design/network.md index 384b3c9c..0dcb3bf5 100644 --- a/docs/design/network.md +++ b/docs/design/network.md @@ -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 diff --git a/docs/history/v2.5-playtest6.md b/docs/history/v2.5-playtest6.md index 913b5a9a..c87f4a36 100644 --- a/docs/history/v2.5-playtest6.md +++ b/docs/history/v2.5-playtest6.md @@ -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. diff --git a/public/dev/game-mock.js b/public/dev/game-mock.js index 0c4f218b..edb6f9e4 100644 --- a/public/dev/game-mock.js +++ b/public/dev/game-mock.js @@ -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 {}; } diff --git a/public/i18n/en.json b/public/i18n/en.json index bbf29fcf..771aec9a 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -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", diff --git a/public/i18n/ja.json b/public/i18n/ja.json index 9986bbbe..fac957f3 100644 --- a/public/i18n/ja.json +++ b/public/i18n/ja.json @@ -385,6 +385,12 @@ "CONTINGENCY // 机变阶段": "CONTINGENCY // 調整段階", "选择一项": "1つ選択", "倒计时结束后仍未选定将自动分配": "カウントダウン終了までに選ばない場合、自動で割り当てられます", + "教鞭选择": "教鞭の選択", + "教鞭 · 战术特训": "教鞭 · 戦術訓練", + "请选择一项战术特训": "戦術訓練を1つ選択してください", + "请先完成教鞭选择": "先に教鞭の選択を完了してください", + "休整期结束时未选择将自动选定": "未選択の場合、整備フェーズ終了時に自動で選択されます", + "从候选中选择一项战术特训": "候補から戦術訓練を1つ選択", "选择一项(无时间限制)": "1つ選択(時間制限なし)", " · 点击卡牌选中,再次点击确认": " · カードをタップで選択、もう一度タップで確定", "已完成选择": "選択完了", diff --git a/public/i18n/ko.json b/public/i18n/ko.json index daf09d7f..7435d3f5 100644 --- a/public/i18n/ko.json +++ b/public/i18n/ko.json @@ -381,6 +381,12 @@ "CONTINGENCY // 机变阶段": "CONTINGENCY // 기회 단계", "选择一项": "하나를 선택하세요", "倒计时结束后仍未选定将自动分配": "카운트다운이 끝날 때까지 선택하지 않으면 자동으로 지정됩니다", + "教鞭选择": "교편 선택", + "教鞭 · 战术特训": "교편 · 전술 특훈", + "请选择一项战术特训": "전술 특훈 1개를 선택해 주세요", + "请先完成教鞭选择": "먼저 교편 선택을 완료해 주세요", + "休整期结束时未选择将自动选定": "선택하지 않으면 휴식 기간 종료 시 자동으로 선택됩니다", + "从候选中选择一项战术特训": "후보 중 전술 특훈 1개 선택", "选择一项(无时间限制)": "하나를 선택하세요 (시간 제한 없음)", " · 点击卡牌选中,再次点击确认": " · 카드를 탭해 선택하고 한 번 더 탭해 확인", "已完成选择": "선택 완료", diff --git a/public/i18n/zh-TW.json b/public/i18n/zh-TW.json index 24d9f4e9..13ff8596 100644 --- a/public/i18n/zh-TW.json +++ b/public/i18n/zh-TW.json @@ -383,6 +383,12 @@ "CONTINGENCY // 机变阶段": "CONTINGENCY // 機變階段", "选择一项": "選擇一項", "倒计时结束后仍未选定将自动分配": "倒計時結束後仍未選定將自動分配", + "教鞭选择": "教鞭選擇", + "教鞭 · 战术特训": "教鞭 · 戰術特訓", + "请选择一项战术特训": "請選擇一項戰術特訓", + "请先完成教鞭选择": "請先完成教鞭選擇", + "休整期结束时未选择将自动选定": "休整期結束時未選擇將自動選定", + "从候选中选择一项战术特训": "從候選中選擇一項戰術特訓", "选择一项(无时间限制)": "選擇一項(無時間限制)", " · 点击卡牌选中,再次点击确认": " · 點擊卡牌選中,再次點擊確認", "已完成选择": "已完成選擇", diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 52a79eb0..8eb5d490 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -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`
e.preventDefault()}>
${viewKind === 'loading' ? html`
<${Spinner} label="LOADING FIELD" />
` : 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} diff --git a/public/js/ui/choiceOverlay.js b/public/js/ui/choiceOverlay.js index 11207e84..e20d9ae3 100644 --- a/public/js/ui/choiceOverlay.js +++ b/public/js/ui/choiceOverlay.js @@ -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`