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

+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;