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

+41 -1
View File
@@ -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;