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

+27 -1
View File
@@ -11,7 +11,7 @@ import {
phaseMode, phaseBanner, isCombatPhase, isBossPhase, countdownState, phaseTotalSeconds, sortBonds, bondTier, nextThreshold,
battleOverSfx, ownRoundLoss, uniteResultBox, battleResultBox, roundResultBox, RESULT_BOX_MS,
bondMembers, memberHeadCount, bannedPerBond, priceTone, mergeProgress, shopBlockReason, deploySets, indexPieces, placementContext, canPlace,
boardTargets, dropIntent, normalizeDraft, normalizeSp, groupEnemies, factionTypes, snapHud, bossFrac, attackInterval, fmtNum,
boardTargets, dropIntent, normalizeDraft, normalizeSp, normalizePersonalChoice, groupEnemies, factionTypes, snapHud, bossFrac, attackInterval, fmtNum,
rangeGridBox, shortcutFor, sanitizeSettings, DEFAULT_SETTINGS, normalizeResult, cycleField, fieldLabel, homeFieldId,
activeBubbles, sortedPlayers, tileKey, prepCapsuleLabel, prepCamera, dropFailureReason, terrainInfo,
} from '../../public/js/ui/gameLogic.js';
@@ -532,6 +532,32 @@ describe('drafts', () => {
assert.equal(normalizeSp(null), null);
assert.equal(normalizeSp({ cards: new Array(9).fill({}) }).cards.length, 6, 'at most 6 cards');
});
test('personal choice: only the living recipient in the current PREP, independent of the public draft', () => {
const pub = { phase: PHASE.PREP, round: 14, sp: { family: 'supply', cards: ['global'] } };
const priv = { playerId: 'a', alive: true, canReady: false, temp: [], personalChoice: {
id: 'seed.choice.3', round: 14, sourceItemId: 'chess_item_6_03_m',
cards: [{ id: 'e1', kind: 'bounty', name: '战术特训', coin: 2 }, { id: 'e2', kind: 'bounty' }],
} };
const before = JSON.stringify({ pub, priv });
const sp = normalizePersonalChoice(pub, priv, 'a');
assert.equal(sp.id, priv.personalChoice.id);
assert.equal(sp.family, 'bounty');
assert.equal(sp.name, '教鞭 · 战术特训');
assert.equal(sp.turnPid, 'a');
assert.equal(sp.pickOf.size, 0);
assert.deepEqual(sp.cards.map((c) => c.idx), [0, 1]);
assert.ok(sp.cards.every((c) => c.takenBy === null));
assert.equal(JSON.stringify({ pub, priv }), before);
assert.equal(shopBlockReason('ready', { priv, editable: true }), '请先完成教鞭选择');
assert.equal(shopBlockReason('ready', { priv: { ...priv, temp: [{}] }, editable: true }), '请先完成教鞭选择');
assert.match(shopBlockReason('ready', { priv: { ...priv, personalChoice: null }, editable: true }), /临时整备区/);
for (const phase of [PHASE.SP_DRAFT, PHASE.COMBAT, PHASE.ROUND_START]) assert.equal(normalizePersonalChoice({ ...pub, phase }, priv, 'a'), null);
assert.equal(normalizePersonalChoice(pub, null, 'a'), null, 'spectator has no private state');
assert.equal(normalizePersonalChoice(pub, priv, 'b'), null);
assert.equal(normalizePersonalChoice(pub, { ...priv, alive: false }, 'a'), null);
assert.equal(normalizePersonalChoice({ ...pub, round: 15 }, priv, 'a'), null);
assert.equal(normalizePersonalChoice(pub, { ...priv, personalChoice: null }, 'a'), null);
});
});
describe('fields, players, emotes', () => {
+140 -1
View File
@@ -15,7 +15,7 @@
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -71,6 +71,16 @@ describe('in-match UI (mock harness, headless Chrome)', { skip: !ENABLED && 'set
}
const mockState = (page) => page.evaluate(() => JSON.parse(JSON.stringify(globalThis.__MOCK__.S().priv)));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const useWhip = (page) => page.evaluate(async () => {
const { net } = await import('/js/net.js');
const uid = 900000 + globalThis.__MOCK__.S().requests.length;
globalThis.__MOCK__.mutate((S) => {
const idx = S.priv.hand.findIndex((p) => !p);
S.priv.hand[idx] = { uid, kind: 'item', id: 'chess_item_6_03_m', tier: 6 };
});
await net.request('g.art', { itemUid: uid, row: 10, col: 5 });
return globalThis.__MOCK__.S().priv.personalChoice;
});
/** Swipe the open direction wheel from its centre towards `dir` and release (research 09 §1.2). */
const swipeWheel = async (page, dir = 'RIGHT') => {
await page.waitForSelector('.fwheel__dia', { timeout: 4000 });
@@ -345,6 +355,135 @@ describe('in-match UI (mock harness, headless Chrome)', { skip: !ENABLED && 'set
await page.close();
});
test('教鞭: PREP cards confirm twice, use its timer, reset on a new ID; old replies cannot clear a new busy pick', async () => {
const { page, problems } = await open('phase=PREP', { render: 'fallback' });
await page.click('.gm__gear');
await page.waitForSelector('.modal .set-keys');
await page.click('.set-key[data-action="ready"]');
await page.waitForSelector('.set-key.is-waiting[data-action="ready"]');
await page.keyboard.press('KeyW');
await page.waitForFunction(() => document.querySelector('.set-key[data-action="ready"]')?.textContent.trim() === 'W');
await page.click('.modal__actions .btn--primary');
await page.waitForSelector('.modal', { hidden: true });
const deadline = await page.evaluate(() => globalThis.__MOCK__.S().pub.deadline);
const first = await useWhip(page);
await page.waitForSelector('.spov[aria-label="教鞭选择"]');
assert.equal(await page.evaluate(() => globalThis.__MOCK__.S().pub.deadline), deadline);
assert.equal(await page.$('.spov__order'), null);
assert.ok(await page.$('.spov .countdown'));
assert.doesNotMatch(await page.$eval('.spov', (el) => el.textContent), /机变阶段|当前轮到|正在决策/);
assert.equal(await page.$eval('.readybtn', (el) => el.disabled), true);
assert.match(await page.$eval('[data-testid="ready-why"]', (el) => el.textContent), /请先完成教鞭选择/);
await page.evaluate(async () => { await (await import('/js/ui/lang.js')).loadLangIndex(); });
for (const lang of ['en', 'ja', 'ko', 'zh-TW']) {
const messages = JSON.parse(readFileSync(path.join(ROOT, `public/i18n/${lang}.json`), 'utf8'));
await page.evaluate(async (code) => {
await (await import('/js/ui/lang.js')).switchLang(code);
globalThis.__MOCK__.pushPublic(); // this harness has no App/useLang root; render again with the active language
}, lang);
await page.waitForFunction((label) => document.querySelector('.spov')?.getAttribute('aria-label') === label, {}, messages['教鞭选择']);
assert.equal(await page.$eval('.spov__title', (el) => el.textContent), `${messages['教鞭 · 战术特训']}|${messages['请选择一项战术特训']}`);
assert.match(await page.$eval('.spov__sub', (el) => el.textContent), new RegExp(messages['休整期结束时未选择将自动选定']));
assert.equal(await page.$eval('[data-testid="ready-why"]', (el) => el.textContent), messages['请先完成教鞭选择']);
assert.equal((await mockState(page)).personalChoice.id, first.id, 'language switch keeps the same pending offer');
}
await page.evaluate(async () => { await (await import('/js/ui/lang.js')).switchLang('zh'); globalThis.__MOCK__.pushPublic(); });
await page.waitForSelector('.spov[aria-label="教鞭选择"]');
const requests = await page.evaluate(() => globalThis.__MOCK__.S().requests.length);
for (const key of ['KeyW', 'KeyR', 'KeyF', 'KeyD', 'KeyQ', 'KeyX']) await page.keyboard.press(key);
await sleep(150);
assert.equal(await page.evaluate(() => globalThis.__MOCK__.S().requests.length), requests, 'background shortcuts send no intents');
assert.match(await page.$$eval('.toast', (els) => els.map((el) => el.textContent).join('\n')), /请先完成教鞭选择/);
assert.equal(await page.$eval('.readybtn__key', (el) => el.textContent), 'W');
await page.click('.spcard.is-pickable');
await page.waitForSelector('.spcard.is-armed');
await sleep(200); // the overlay attaches its Escape listener in an effect, a frame after the highlight shows
await page.keyboard.press('Escape');
await page.waitForFunction(() => !document.querySelector('.spcard.is-armed'));
assert.equal((await mockState(page)).personalChoice.id, first.id, 'Escape only removes the highlight');
await page.screenshot({ path: path.join(OUT, 'ui-personal-choice-1920.png') });
await page.evaluate(async (id) => {
const { net } = await import('/js/net.js');
const request = net.request;
globalThis.__choiceReplies = {};
net.request = async (t, fields) => {
if (t !== 'g.choice' || fields.choiceId === undefined) return request(t, fields);
if (fields.choiceId === id) {
const result = await request(t, fields);
await new Promise((resolve) => { globalThis.__choiceReplies[fields.choiceId] = resolve; });
return result;
}
await new Promise((resolve) => { globalThis.__choiceReplies[fields.choiceId] = resolve; });
return request(t, fields);
};
}, first.id);
await page.click('.spcard.is-pickable');
assert.equal((await mockState(page)).personalChoice.id, first.id, 'one tap does not apply it');
await page.click('.spcard.is-armed');
await page.waitForFunction(() => !document.querySelector('.spov'));
const firstRequest = await page.evaluate(() => globalThis.__MOCK__.S().requests.filter(([t]) => t === 'g.choice').at(-1));
assert.deepEqual(firstRequest, ['g.choice', { idx: 0, choiceId: first.id }]);
const second = await useWhip(page);
await page.waitForSelector('.spov');
await page.click('.spcard.is-pickable');
await page.waitForSelector('.spcard.is-armed');
const thirdId = second.id + '.next';
await page.evaluate((id) => globalThis.__MOCK__.mutate((S) => { S.pub.deadline = 0; S.priv.personalChoice.id = id; }), thirdId);
await page.waitForFunction(() => !document.querySelector('.spcard.is-armed'));
assert.equal(await page.$('.spov .countdown'), null, 'co-op PREP without a deadline is untimed');
await page.click('.spcard.is-pickable');
await page.click('.spov__confirm');
await page.waitForSelector('.spcard.is-busy');
await page.waitForFunction((id) => !!globalThis.__choiceReplies[id], {}, first.id);
await page.evaluate((id) => globalThis.__choiceReplies[id](), first.id);
await sleep(150);
assert.ok(await page.$('.spcard.is-busy'), 'old reply leaves the newer choice busy');
await page.evaluate((id) => globalThis.__choiceReplies[id](), thirdId);
await page.waitForFunction(() => !document.querySelector('.spov'));
assert.equal((await mockState(page)).personalChoice, null);
assert.deepEqual(problems, []);
await page.close();
});
test('教鞭 appearing cancels the uncommitted direction wheel and active drag, releasing its held piece', async () => {
const { page, problems } = await open('phase=PREP', { render: 'fallback' });
const s = await mockState(page);
const hand = s.hand.find((p) => p && p.kind === 'chess');
const target = s.board.find((p) => p.kind === 'chess' && p.row === 9);
const selector = `.ff-piece[data-uid="${hand.uid}"]`;
const center = (sel) => page.$eval(sel, (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });
const homeStyle = await page.$eval(selector, (el) => el.style.transform);
const from = await center(selector), to = await center(`.ff-piece[data-uid="${target.uid}"]`);
await page.mouse.move(from.x, from.y);
await page.mouse.down();
await page.mouse.move(to.x, to.y, { steps: 12 });
await sleep(80);
await page.mouse.up();
await page.waitForSelector('.fwheel');
await useWhip(page);
await page.waitForSelector('.spov');
await page.waitForFunction(() => !document.querySelector('.fwheel'));
await page.waitForFunction((sel, transform) => document.querySelector(sel)?.style.transform === transform, {}, selector, homeStyle);
assert.equal(await page.$eval(selector, (el) => el.style.transform), homeStyle, 'cancelFacing released the held bench piece');
assert.equal(await page.$('.ff-piece.is-draggable'), null);
await page.click('.spcard.is-pickable');
await page.click('.spov__confirm');
await page.waitForFunction(() => !document.querySelector('.spov'));
const current = await center(selector);
await page.mouse.move(current.x, current.y);
await page.mouse.down();
await page.mouse.move(current.x + 90, current.y - 40, { steps: 8 });
await page.waitForSelector('.gm.is-dragging');
await useWhip(page);
await page.waitForSelector('.spov');
await page.waitForFunction(() => !document.querySelector('.gm.is-dragging, .ff-piece.is-lifted'));
await page.mouse.up();
assert.deepEqual((await mockState(page)).board, s.board);
assert.ok(!(await page.evaluate(() => globalThis.__MOCK__.S().requests.some(([t]) => t === 'g.move'))));
assert.deepEqual(problems, []);
await page.close();
});
// ---- round-1 UI fixes ----------------------------------------------------------------------------------------
const rectOf = (page, sel) => page.$eval(sel, (el) => { const r = el.getBoundingClientRect(); return { left: r.left, top: r.top, right: r.right, bottom: r.bottom }; });
const overlaps = (a, b) => !!a && !!b && a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
+42 -1
View File
@@ -345,7 +345,7 @@ describe('user playtest #2 item 10 — boss-round prep on the boss field (real s
});
test('co-op 险境 (R14): host on the left half, guest on the mirrored right half; scouting and 返回 keep the pen hidden', { skip: only('coop'), timeout: 10 * 60 * 1000 }, async () => {
const srv = await startRealServer({ fast: { timerScale: 0.5, combatSpeed: 8, startRound: 'boss' } });
const srv = await startRealServer({ fast: { timerScale: 1, combatSpeed: 8, startRound: 'boss', items: ['chess_item_6_03_m'] } });
const P = await pptr();
const host = new Client(P, srv.base, 'host', { prefix: 'fix' });
const guest = new Client(P, srv.base, 'guest', { prefix: 'fix', w: 1280, h: 720 });
@@ -412,6 +412,47 @@ describe('user playtest #2 item 10 — boss-round prep on the boss field (real s
assert.deepEqual(back.prepField, { kind: 'bossPrep', side: 'L', mirror: false }, '返回: the own boss half again');
}
await penRoundTrip(guest, 'guest');
// Real clients open their own 教鞭 candidates together; refreshing one tab restores that same private choice.
const choiceOf = (c) => c.page.evaluate(() => JSON.parse(JSON.stringify(globalThis.__SP__.store.get().match.private.personalChoice)));
const deadline = await host.page.evaluate(() => globalThis.__SP__.store.get().match.public.deadline);
await Promise.all([[host, hp], [guest, gp]].map(async ([c, placed]) => {
const art = (await c.handPieces('item')).find((p) => p.id === 'chess_item_6_03_m');
assert.ok(art, `${c.label}: starter 教鞭`);
await c.drag(await c.piecePoint(art.uid), await c.tilePoint(placed.row, placed.col));
await c.page.waitForSelector('.spov[aria-label="教鞭选择"]', { timeout: 6000 });
}));
const hostChoice = await choiceOf(host), guestChoice = await choiceOf(guest);
assert.notEqual(hostChoice.id, guestChoice.id);
for (const c of [host, guest]) {
assert.equal((await choiceOf(c)).cards.length, 3);
assert.equal((await c.st()).canReady, false);
assert.equal(await c.page.$('.spov__order'), null);
assert.equal(await c.page.evaluate(() => globalThis.__SP__.store.get().match.public.deadline), deadline);
}
await host.page.reload({ waitUntil: 'networkidle0' });
await host.page.waitForSelector('.spov[aria-label="教鞭选择"]', { timeout: 20000 });
assert.deepEqual(await choiceOf(host), hostChoice, 'refresh reuses the ID and cards');
assert.deepEqual(await choiceOf(guest), guestChoice, 'the teammate keeps its own candidates');
await host.page.evaluate(async () => { await (await import('/js/ui/lang.js')).switchLang('en'); });
await host.page.waitForSelector('.spov[aria-label="Pointing Stick Choice"]');
assert.match(await host.page.$eval('.spov__title', (el) => el.textContent), /Pointing Stick · Tactical Training/);
assert.equal(await host.page.$eval('[data-testid="ready-why"]', (el) => el.textContent), 'Complete the Pointing Stick choice first');
assert.equal(await guest.page.$eval('.spov', (el) => el.getAttribute('aria-label')), '教鞭选择', 'language stays per client');
assert.deepEqual(await choiceOf(host), hostChoice, 'language switch does not replace the offer');
await host.page.evaluate(async () => { await (await import('/js/ui/lang.js')).switchLang('zh'); });
await host.page.waitForSelector('.spov[aria-label="教鞭选择"]');
const refused = await host.page.evaluate(async (id) => {
try { await globalThis.__SP__.net.request('g.choice', { idx: 0, choiceId: id }); return null; }
catch (e) { return e.code; }
}, guestChoice.id);
assert.equal(refused, 'BAD_TARGET', 'a live client cannot submit the teammate\'s ID');
await host.shot('dobermann-private-reconnect');
for (const c of [host, guest]) {
await c.click('.spcard.is-pickable');
assert.ok(await choiceOf(c), 'first tap only highlights');
await c.click('.spov__confirm');
await c.page.waitForFunction(() => globalThis.__SP__.store.get().match.private.personalChoice === null, { timeout: 6000 });
}
for (const c of [host, guest]) await c.click('.readybtn');
await checkBossBattle(host, hp, 'L', 'host');
await checkBossBattle(guest, gp, 'R', 'guest');
+25 -1
View File
@@ -222,6 +222,18 @@ describe('3: the temp overflow row (临时整备区)', () => {
assert.deepEqual(tempInfo(null), { count: 0, items: 0 });
});
test('Ready shows the personal-choice reason and describes it even with no temp pieces', () => {
for (const temp of [[], [{ uid: 1, kind: 'item' }]]) {
const v = ReadyToggle({ priv: { ...priv(temp), canReady: false, personalChoice: { id: 'choice.1' } }, onToggle() {} });
const nodes = [...walk(v)];
const btn = nodes.find((n) => n.type === 'button');
assert.equal(btn.props.disabled, true);
assert.equal(btn.props['aria-describedby'], 'readywrap-why');
assert.equal(textOf(nodes.find((n) => hasClass(n, 'readywrap__why'))), '请先完成教鞭选择');
assert.equal(nodes.find((n) => n.type?.name === 'Tooltip').props.text, '请先完成教鞭选择');
}
});
test('the row frame: the outer corners of both end tiles, the label on the side with room (mirrored Final Assault prep too)', () => {
// tileScreen polys: [back-left, back-right, front-right, front-left] (render/app.js tileScreen corner order)
const tile = (cx, y, w = 100, d = 60) => ({ x: cx, y, s: w, poly: [[cx - w / 2 + 6, y - d / 2], [cx + w / 2 - 6, y - d / 2], [cx + w / 2, y + d / 2], [cx - w / 2, y + d / 2]] });
@@ -258,7 +270,7 @@ describe('3: the temp overflow row (临时整备区)', () => {
test('while the ready button shows its reason the effects column moves down a line (on phones the ready count ran into it)', () => {
const game = read('public/js/screens/game.js');
// the same condition as ReadyToggle's reason: PREP, alive, not ready, pieces in the row
assert.match(game, /const readyWhy = phase === PHASE\.PREP && alive && !priv\?\.ready && temp\.count > 0;/);
assert.match(game, /const readyWhy = phase === PHASE\.PREP && alive && !priv\?\.ready && \(hasPersonalChoice \|\| temp\.count > 0\);/);
assert.match(game, /readyWhy && 'has-readywhy'/);
const css = read('public/css/screens/game.css');
assert.match(css, /\.gm__effects \{ position: absolute; right: \.3rem; top: 2\.4rem; \}/);
@@ -389,6 +401,18 @@ describe('9: busy indicators and data loading', () => {
assert.ok(![...walk(landed)].some((n) => hasClass(n, 'spcard__busy')));
});
test('choice requests carry the personal ID while global requests keep their existing shape', async () => {
const { net } = await import('../../public/js/net.js');
const { actions } = await import('../../public/js/ui/gameActions.js');
const request = net.request, sent = [];
net.request = async (t, fields) => { sent.push([t, fields]); };
try {
assert.equal(await actions.choice(0), true);
assert.equal(await actions.choice(2, 'seed.choice.3'), true);
assert.deepEqual(sent, [['g.choice', { idx: 0 }], ['g.choice', { idx: 2, choiceId: 'seed.choice.3' }]]);
} finally { net.request = request; }
});
test('the "作战结束,等待队友完成作战" hourglass stands still', () => {
const css = read('public/css/screens/game.css');
assert.doesNotMatch(css, /hg-flip/);
+22
View File
@@ -130,6 +130,28 @@ describe('2: 机变 cards take two taps (select → confirm), like buying in the
assert.match(textOf(lone.find((n) => hasClass(n, 'spov__sub'))), /无时间限制/);
});
test('教鞭 ChoiceView: personal heading, no global order or turn; timing follows PREP deadline', () => {
const s = sp({ name: '教鞭 · 战术特训', desc: '请选择一项战术特训', cards: sp().cards.slice(0, 2) });
for (const [solo, deadline] of [[false, 0], [true, 123456]]) {
let confirmed = false;
const v = ChoiceView({ pub: { players: [{ playerId: 'me', name: 'Me' }, { playerId: 'p2', name: 'P2' }], deadline }, sp: s,
...me, solo, personal: true, armed: 0, total: 74, onConfirm: () => { confirmed = true; } });
const nodes = [...walk(v)];
assert.equal(v.props['aria-label'], '教鞭选择');
assert.ok(!nodes.some((n) => hasClass(n, 'spov__order')));
assert.doesNotMatch(textOf(v), /机变阶段|当前轮到|正在决策/);
const clock = nodes.find((n) => n.type?.name === 'Countdown');
assert.equal(!!clock, deadline > 0);
if (clock) { assert.equal(clock.props.deadline, deadline); assert.equal(clock.props.total, 74); }
assert.match(textOf(nodes.find((n) => hasClass(n, 'spov__sub'))), deadline ? /休整期结束时未选择将自动选定/ : /无时间限制/);
const btn = nodes.find((n) => n.type === Button);
btn.props.onClick();
assert.ok(confirmed);
assert.match(btn.props.title, /确认选择「A」/);
assert.match(nodes.find((n) => hasClass(n, 'spcard')).props.title, /^A\na$/);
}
});
test('the overlay keeps the selection itself and drops it on Esc / a tap elsewhere (source contract)', () => {
const src = read('public/js/ui/choiceOverlay.js');
assert.match(src, /export function ChoiceOverlay\(props\) \{[\s\S]*useState\(null\)[\s\S]*armedCard\(sel, sp/);