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:
1 parent
05eb2b807f
commit
56a7b918a9
48 files changed
+901
-159
No files matched your search
+140
-1
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user