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

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

No files matched your search

+35 -5
View File
@@ -95,7 +95,7 @@ import { pieceTile } from '../render/drag.js';
import {
phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent,
battleOverSfx, uniteResultBox, battleResultBox,
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers,
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, normalizePersonalChoice, sortedPlayers,
terrainInfo,
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord,
@@ -184,6 +184,7 @@ function MatchScreen() {
const [resultBox, setResultBox] = useState(null); // the round's result box (ResultDialog), shown at SETTLE
const [readyBusy, setReadyBusy] = useState(false);
const [spBusy, setSpBusy] = useState(null);
const [personalBusy, setPersonalBusy] = useState(null); // { choiceId, idx }: an older request cannot clear a newer pick
const [layer, setLayer] = useState('ALL'); // 联防 / 最终攻势 camera: 'L' | 'ALL' | 'R'
const [pen, setPen] = useState(false); // the camera shows the enemy preview pen (research 09 §2)
const [camKind, setCamKind] = useState('prep'); // kind of the last camera request (data-camera)
@@ -207,7 +208,9 @@ function MatchScreen() {
const scoutPid = watchingOther && field?.prep && typeof field.fieldId === 'string' && field.fieldId.startsWith('n:') ? field.fieldId.slice(2) : null;
const scoutBandId = scoutPid ? ownerBandId(pub, scoutPid) : null;
const scoutBandOwner = scoutPid ? (players.find((p) => p.playerId === scoutPid)?.name || null) : null;
const editable = phase === PHASE.PREP && !!priv && alive && !priv.ready && !watchingOther;
const personalChoice = !spectator && alive ? normalizePersonalChoice(pub, priv, myId) : null;
const hasPersonalChoice = !!personalChoice;
const editable = phase === PHASE.PREP && !!priv && alive && !priv.ready && !watchingOther && !hasPersonalChoice;
const showShop = !!priv && alive && (phase === PHASE.PREP || phase === PHASE.SP_DRAFT || phase === PHASE.ROUND_START) && !watchingOther;
const layersDisabled = phase === PHASE.UNITE || isBossPhase(phase);
const sp = phase === PHASE.SP_DRAFT ? normalizeSp(pub?.sp, players) : null;
@@ -271,7 +274,7 @@ function MatchScreen() {
getChess: (id) => { const c = gd.chess(id); return ownDiyRecord(c, priv, { chess: data.get('chess'), backups: data.get('backups') }) || c; },
getToken: gd.token, getItem: gd.item, getEffect: gd.effect, backups: gd.backups,
}), [priv, pub?.stageId, editable, gd.ready, deployField]);
live.current = { pub, priv, field, editable, placeCtx, watching, watchWho, home, myId, alive, spectator, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, selBusy, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused };
live.current = { pub, priv, field, editable, hasPersonalChoice, placeCtx, watching, watchWho, home, myId, alive, spectator, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, selBusy, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused };
// ---- camera: every request goes through setCam, which remembers it for the pen's way back -----------------------
// the own prep board: the normal board, or — in the prep of a boss round — the player's half of the boss field
@@ -637,6 +640,17 @@ function MatchScreen() {
setSpBusy(null);
}, [phaseKey]);
useEffect(() => {
setPersonalBusy((b) => b?.choiceId === personalChoice?.id ? b : null);
if (!personalChoice) return;
setSel(null);
setDetail(null);
setArmedCard(null);
setDrag(null);
view?.highlightTiles(null, null);
cancelFacingRef.current();
}, [personalChoice?.id, view]);
// a reload / reconnect while watching a teammate's battle after the own one (client-side combat): the server resends
// the watched field, the fresh screen adopts it as watched once per battle — the observing pill, 返回战场 and the own
// row work again, and the bond strip's "👁 name" matches the HUD (battle/observe.js resumedWatch)
@@ -1154,6 +1168,14 @@ function MatchScreen() {
const L = live.current;
// dialogs / the guide own the keyboard; behind the 本局信息 / 敌方情报 drawer only Esc (closing it) acts
if (shortcutBlocked(act, { modal: !!document.querySelector('.modal, .guide'), drawer: !!L.drawer })) return;
if (L.hasPersonalChoice) {
if (act !== 'escape') e.preventDefault();
if (act === 'ready') {
audio.sfx('error', { volume: 0.5 });
toast(shopBlockReason('ready', { priv: L.priv, editable: true }), 'warn');
}
return; // ChoiceOverlay handles Escape without cancelling the server's pending choice.
}
if (act === 'escape') {
if (L.emoteOpen) setEmoteOpen(false);
else if (L.pen && !L.detail) togglePenRef.current(false);
@@ -1256,7 +1278,7 @@ function MatchScreen() {
const temp = tempInfo(priv);
const tempNotice = temp.count > 0 && !!view && viewKind !== 'loading' && showPrep && alive && !pen && !sp;
// the ready button shows why it is refused under it (ui/hud.js ReadyToggle): the effects column moves down a line
const readyWhy = phase === PHASE.PREP && alive && !priv?.ready && temp.count > 0;
const readyWhy = phase === PHASE.PREP && alive && !priv?.ready && (hasPersonalChoice || temp.count > 0);
// the frame the panels are laid out in: the HUD layer (client px; inside the safe-area insets of a notched phone),
// and the root font size (1rem)
const panelFrame = () => {
@@ -1307,7 +1329,7 @@ function MatchScreen() {
// bonds this mode never activates (标准: 10 of 23, 奥术 among them) — shown 本局禁用 on cards, chips and the popup
const offBonds = modeOffBonds(getMode(pub?.modeId));
return html`<div class=${cx('screen', 'gm', `gm--${mode}`, drag && 'is-dragging', collapsed && 'is-collapsed', sp && 'has-sp', pen && 'is-pen', readyWhy && 'has-readywhy')}
return html`<div class=${cx('screen', 'gm', `gm--${mode}`, drag && 'is-dragging', collapsed && 'is-collapsed', (sp || hasPersonalChoice) && 'has-sp', pen && 'is-pen', readyWhy && 'has-readywhy')}
data-camera=${pen ? 'pen' : camKind}>
<div class="gm__field" ref=${hostRef} onContextMenu=${(e) => e.preventDefault()}></div>
${viewKind === 'loading' ? html`<div class="gm__loading"><${Spinner} label="LOADING FIELD" /></div>` : null}
@@ -1402,6 +1424,14 @@ function MatchScreen() {
${sp ? html`<${ChoiceOverlay} pub=${pub} sp=${sp} myId=${myId} solo=${solo} busyIdx=${spBusy} total=${total}
onPick=${async (i) => { setSpBusy(i); await actions.choice(i); setSpBusy(null); }} />` : null}
${personalChoice ? html`<${ChoiceOverlay} key=${personalChoice.id} pub=${pub} sp=${personalChoice} myId=${myId} solo=${solo} personal=${true}
busyIdx=${personalBusy?.choiceId === personalChoice.id ? personalBusy.idx : null} total=${total}
onPick=${async (i) => {
const choiceId = personalChoice.id;
setPersonalBusy({ choiceId, idx: i });
await actions.choice(i, choiceId);
setPersonalBusy((b) => b?.choiceId === choiceId ? null : b);
}} />` : null}
${banner ? html`<${PhaseBanner} key=${banner.key} mode="overlay" title=${banner.title} sub=${banner.sub} micro=${banner.micro}
tone=${banner.tone} duration=${banner.duration || 1500} onDone=${() => setBanner(null)} />` : null}