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

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

No files matched your search

+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, deviceInfo, deviceTipAt, noteDeviceUnits,
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord,
@@ -197,6 +197,7 @@ function MatchScreen() {
const [resultBox, setResultBox] = useState(null); // the round's result box (ResultDialog), shown at SETTLE
const [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)
@@ -220,7 +221,9 @@ function MatchScreen() {
const scoutPid = watchingOther && field?.prep && typeof field.fieldId === 'string' && field.fieldId.startsWith('n:') ? field.fieldId.slice(2) : null;
const 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;
@@ -284,7 +287,7 @@ function MatchScreen() {
getChess: (id) => { const c = gd.chess(id); return ownDiyRecord(c, priv, { chess: data.get('chess'), backups: data.get('backups') }) || c; },
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
@@ -673,6 +676,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)
@@ -1199,6 +1213,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);
@@ -1301,7 +1323,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 = () => {
@@ -1369,7 +1391,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}
@@ -1465,6 +1487,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}