Files
ark-stronghold-protocol/test/ui/playtest4.test.js
T
Sukvii 56a7b918a9 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).
2026-10-08 20:45:54 +08:00

276 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Regression tests (Node) of user playtest #4, UI-flow items:
// 2 机变 picks take two taps like the shop (ui/choiceOverlay.js): the first tap selects a card (gold frame, 确认选择 ·
// 再次点击 strip, the header's 确认选择), the second — or 确认选择 — sends it; another card moves the selection; a card
// that cannot be picked (not my turn, taken, a pick in flight) is never selected; the selection drops with it
// 3 untimed phases show no timer at all: Countdown renders nothing without a deadline; the 机变 overlay of an untimed
// draft (solo, a single human) shows no clock and says so
// 7 the detail card's live stats (ui/detailPanel.js): battle / prep values coloured against the unit's base (green
// up, red down, a shorter attack interval is up), the difference beside them, a 实时 / 开战时 tag; the battle HP
// bar follows the live HP; the game screen asks g.unitStats for own board units in prep and reads the local sim in
// battle
// 1 (layout) on a notched phone the shop bar keeps its place at the bottom edge instead of rising by the bottom inset
// over the bench pads — a bench piece is pressed / dropped on by its tile (css/devices.css)
// 5 an effect-only item's card (the special 维式重锤, 突变细胞: items.json shopExcluded) says the shop never sells it
// and where it comes from
// (server side: test/match/unitStats.test.js, test/match/draft.test.js, test/ui/bandDraft.test.js; the runner:
// test/match/runner.test.js)
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
// the browser data store reads the real data files from disk (the card renders below)
globalThis.fetch = async (url) => {
const name = String(url).split('/').pop();
try {
const body = readFileSync(path.join(ROOT, 'data', name), 'utf8');
return { ok: true, status: 200, json: async () => JSON.parse(body) };
} catch {
return { ok: false, status: 404, json: async () => ({}) };
}
};
const { ChoiceView, spTap, armedCard, cardPickable } = await import('../../public/js/ui/choiceOverlay.js');
const { ChessDetail, ItemDetail, statTone, liveStat } = await import('../../public/js/ui/detailPanel.js');
const { Button } = await import('../../public/js/ui/components.js');
const { data } = await import('../../public/js/data.js');
/** Every vnode of a preact tree (htm output), depth first; `expand` names components rendered in place (hook-free). */
function* walk(v, expand = new Set()) {
if (Array.isArray(v)) { for (const x of v) yield* walk(x, expand); return; }
if (!v || typeof v !== 'object') return;
yield v;
if (typeof v.type === 'function' && expand.has(v.type.name)) { yield* walk(v.type(v.props), expand); return; }
yield* walk(v.props?.children, expand);
}
const textOf = (v) => {
if (v == null || typeof v === 'boolean') return '';
if (typeof v === 'string' || typeof v === 'number') return String(v);
if (Array.isArray(v)) return v.map(textOf).join('');
if (typeof v === 'object' && typeof v.type === 'function' && ['Stat', 'LiveTag'].includes(v.type.name)) return textOf(v.type(v.props));
return typeof v === 'object' ? textOf(v.props?.children) : '';
};
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
// ---- 2: two-tap 机变 picks ----------------------------------------------------------------------------------------
describe('2: 机变 cards take two taps (select → confirm), like buying in the shop', () => {
const sp = (o = {}) => ({
family: 'bounty', name: '悬赏决策', desc: '', untimed: false, order: ['me', 'p2'], turnPid: 'me', pickOf: new Map(),
cards: [{ idx: 0, name: 'A', desc: 'a', takenBy: null }, { idx: 1, name: 'B', desc: 'b', takenBy: null }, { idx: 2, name: 'C', desc: 'c', takenBy: 'p2' }],
...o,
});
const me = { myId: 'me', solo: false };
test('spTap: the first tap selects, the second on the same card confirms, another card moves the selection', () => {
assert.deepEqual(spTap(null, 0, true), { armed: 0, pick: null }, 'first tap: selected, nothing sent');
assert.deepEqual(spTap(0, 0, true), { armed: null, pick: 0 }, 'second tap on it: sent');
assert.deepEqual(spTap(0, 1, true), { armed: 1, pick: null }, 'another card: the selection moves');
assert.deepEqual(spTap(1, 0, false), { armed: 1, pick: null }, 'a card that cannot be picked changes nothing');
assert.deepEqual(spTap(null, 'x', true), { armed: null, pick: null });
});
test('cardPickable / armedCard: only my turn, before my pick, a free card, nothing in flight — the selection drops otherwise', () => {
const s = sp();
assert.equal(cardPickable(s, s.cards[0], me), true);
assert.equal(cardPickable(s, s.cards[2], me), false, 'taken');
assert.equal(cardPickable(sp({ turnPid: 'p2' }), s.cards[0], me), false, 'not my turn');
assert.equal(cardPickable(sp({ turnPid: 'p2' }), s.cards[0], { myId: 'me', solo: true }), true, 'solo: always my turn');
assert.equal(cardPickable(sp({ pickOf: new Map([['me', 1]]) }), s.cards[0], me), false, 'after my pick');
assert.equal(cardPickable(s, s.cards[0], { ...me, busyIdx: 1 }), false, 'a pick in flight');
assert.equal(armedCard(0, s, me), 0);
assert.equal(armedCard(2, s, me), null, 'a taken card is never selected');
assert.equal(armedCard(0, sp({ turnPid: 'p2' }), me), null, 'the turn moved on (timeout)');
assert.equal(armedCard(0, s, { ...me, busyIdx: 0 }), null);
assert.equal(armedCard(7, s, me), null);
assert.equal(armedCard(null, s, me), null);
});
test('ChoiceView: the selected card lifts with a 确认选择 · 再次点击 strip, the header offers 确认选择; taps go through onTap', () => {
const s = sp();
const taps = [];
let confirmed = 0;
const v = ChoiceView({ pub: { players: [{ playerId: 'me', seat: 0, name: 'Me' }, { playerId: 'p2', seat: 1, name: 'P2' }], deadline: 0 }, sp: s, ...me,
armed: 1, onTap: (i) => taps.push(i), onConfirm: () => { confirmed++; } });
assert.ok(hasClass(v, 'has-armed'));
const nodes = [...walk(v)];
const cards = nodes.filter((n) => hasClass(n, 'spcard'));
assert.equal(cards.length, 3);
assert.deepEqual(cards.map((c) => hasClass(c, 'is-armed')), [false, true, false]);
assert.equal(cards[1].props['aria-pressed'], 'true');
assert.match(cards[1].props['aria-label'], /已选中,再次点击确认/);
assert.equal(textOf(nodes.find((n) => hasClass(n, 'spcard__confirm'))), '确认选择再次点击');
const btn = nodes.find((n) => n.type === Button);
assert.ok(btn && textOf(btn.props.children).includes('确认选择'), 'the header confirm');
btn.props.onClick();
assert.equal(confirmed, 1);
cards[0].props.onClick();
cards[1].props.onClick();
cards[2].props.onClick(); // taken: disabled, never tapped
assert.deepEqual(taps, [0, 1]);
assert.equal(cards[2].props.disabled, true);
// nothing selected: no strip, no confirm button, the hint says how it works
const idle = [...walk(ChoiceView({ pub: { players: [] }, sp: s, ...me }))];
assert.ok(!idle.some((n) => hasClass(n, 'spcard__confirm')));
assert.ok(!idle.some((n) => n.type === Button));
assert.match(textOf(idle.find((n) => hasClass(n, 'spov__sub'))), /点击卡牌选中,再次点击确认/);
});
test('3: an untimed 机变 (solo, a single human) shows no clock and says so; a timed one keeps its countdown', () => {
const timed = [...walk(ChoiceView({ pub: { players: [], deadline: Date.now() + 9000 }, sp: sp(), ...me }))];
assert.ok(timed.some((n) => n.type?.name === 'Countdown'));
assert.match(textOf(timed.find((n) => hasClass(n, 'spov__sub'))), /倒计时结束后仍未选定将自动分配/);
const lone = [...walk(ChoiceView({ pub: { players: [], deadline: 0 }, sp: sp({ untimed: true }), ...me }))];
assert.ok(!lone.some((n) => n.type?.name === 'Countdown'), 'a single human\'s co-op draft: untimed');
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/);
assert.match(src, /e\.key === 'Escape'/);
assert.match(src, /closest\('\.spcard, \.spov__confirm'\)/);
const css = read('public/css/screens/game-panels.css');
assert.match(css, /\.spcard\.is-armed/);
assert.match(css, /\.spcard__confirm \{/);
});
});
// ---- 3: no timer without a deadline --------------------------------------------------------------------------------
describe('3: untimed phases show no timer', () => {
test('Countdown renders nothing without a deadline (the "--" placeholder read as a timer)', () => {
const comp = read('public/js/ui/components.js');
const fn = comp.slice(comp.indexOf('export function Countdown'), comp.indexOf('// ---- Modal'));
assert.match(fn, /if \(remain == null\) return null;/);
// after the hooks (a component may toggle between timed and untimed)
assert.ok(fn.indexOf('useEffect(') < fn.indexOf('if (remain == null) return null;'));
});
});
// ---- 7: live stats -------------------------------------------------------------------------------------------------
describe('7: the detail card shows live stats against the base', () => {
test('statTone: higher is up, a shorter attack interval is up, rounding noise is equal', () => {
assert.equal(statTone('atk', 1042, 501), 'up');
assert.equal(statTone('atk', 400, 501), 'down');
assert.equal(statTone('atk', 501.3, 501), null);
assert.equal(statTone('interval', 0.9, 1.05), 'up', 'faster attacks help');
assert.equal(statTone('interval', 1.3, 1.05), 'down');
assert.equal(statTone('interval', 1.052, 1.05), null);
assert.equal(statTone('res', 12.3, 10), 'up');
assert.equal(statTone('res', 10.02, 10), null);
assert.equal(statTone('blockCnt', 2, 3), 'down');
assert.equal(statTone('atk', null, 3), null);
});
test('liveStat: the live value with its difference and the base in the title; the record value without live stats', () => {
const live = { atk: 1042, interval: 0.95, res: 12.5, base: { atk: 501, interval: 1.05, res: 10 } };
assert.deepEqual(liveStat(live, 'atk', 501), { v: '1,042', tone: 'up', sub: '+541', title: '基础 501' });
const iv = liveStat(live, 'interval', 1.05, (v) => `${v.toFixed(2)}s`);
assert.deepEqual(iv, { v: '0.95s', tone: 'up', sub: '−0.10', title: '基础 1.05s' });
assert.equal(liveStat(live, 'res', 10, String).sub, '+2.5');
assert.deepEqual(liveStat(null, 'atk', 501), { v: '501', tone: null, sub: null, title: undefined });
assert.equal(liveStat(null, 'atk', null).v, '—');
assert.deepEqual(liveStat({ atk: 501, base: { atk: 501 } }, 'atk', 501), { v: '501', tone: null, sub: null, title: '基础 501' });
});
test('a rendered card: live values coloured, the 实时 / 开战时 tag, the battle HP bar from the live HP', async () => {
await data.loadAll('chess', 'garrisons', 'assets', 'bonds', 'items');
const c = data.lookup('chess', 'chess_char_1_01_a');
assert.ok(c);
const s = c.stats;
const live = {
src: 'battle', hp: 300, maxHp: Math.round(s.maxHp * 1.2), atk: Math.round(s.atk * 0.8), def: s.def, res: s.res ?? 0,
interval: 0.5, blockCnt: s.blockCnt,
base: { maxHp: s.maxHp, atk: s.atk, def: s.def, res: s.res ?? 0, interval: 1, blockCnt: s.blockCnt },
};
const blocks = ChessDetail({ chess: c, piece: null, editable: false, bonds: [], loadout: null, live });
const stats = blocks.find((b) => b.key === 'stats');
assert.ok(hasClass(stats, 'is-live'));
assert.equal(stats.props['data-live'], 'battle');
const cells = [...walk(stats, new Set(['Stat', 'LiveTag']))].filter((n) => hasClass(n, 'dstat'));
const cell = (k) => cells.find((n) => textOf(n).startsWith(k));
assert.ok(hasClass(cell('生命上限'), 'is-up'));
assert.ok(hasClass(cell('攻击'), 'is-down') && !hasClass(cell('攻击间隔'), 'is-down'));
assert.ok(hasClass(cell('攻击间隔'), 'is-up'), 'a shorter interval is a buff');
assert.ok(!hasClass(cell('防御'), 'is-up') && !hasClass(cell('防御'), 'is-down'), 'unchanged');
assert.ok(textOf(stats).includes('实时'));
const head = blocks.find((b) => b.key === 'head');
const hp = [...walk(head)].find((n) => hasClass(n, 'dhp'));
assert.ok(textOf(hp).includes(`300 / ${live.maxHp.toLocaleString('en-US')}`), 'the live HP');
// the prep preview: 开战时, no HP bar
const prep = ChessDetail({ chess: c, piece: null, editable: false, bonds: [], loadout: null, live: { ...live, src: 'prep' } });
assert.ok(textOf(prep.find((b) => b.key === 'stats')).includes('开战时'));
assert.ok(![...walk(prep.find((b) => b.key === 'head'))].some((n) => hasClass(n, 'dhp')));
// no live stats: the record's numbers, no tag
const plain = ChessDetail({ chess: c, piece: null, editable: false, bonds: [], loadout: null });
const pstats = plain.find((b) => b.key === 'stats');
assert.ok(!hasClass(pstats, 'is-live'));
assert.ok(!textOf(pstats).includes('实时') && !textOf(pstats).includes('开战时'));
});
test('the game screen feeds it: g.unitStats for an own board unit in prep (newest answer only), the local sim in battle', () => {
const src = read('public/js/screens/game.js');
assert.match(src, /net\.on\('m\.unitStats'/);
assert.match(src, /msg\.seq !== statsSeqRef\.current/, 'only the newest request\'s answer');
assert.match(src, /net\.request\('g\.unitStats', \{ seq: statsSeqRef\.current \}\)/);
assert.match(src, /battleRunner\.unitStats\(uid, fid\)/);
assert.match(src, /live=\$\{liveStats\}/);
const panel = read('public/js/ui/detailPanel.js');
assert.match(panel, /useTicker\(detail && getter \? 250 : 0\)/, 'a getter is re-read 4× a second');
// an own board piece's card opened in prep and left open into the battle turns live (its unit found by uid)
assert.match(src, /battleRunner\.unitIdOf\(pieceUid, myId, fid\)/);
});
});
// ---- 1 (layout) / 5 ------------------------------------------------------------------------------------------------
describe('1 / 5: the bench stays reachable on a notched phone; effect-only items say where they come from', () => {
test('devices.css: the shop bar steps back over the HUD\'s bottom inset (it covered ~3/4 of bench pads 2–9)', () => {
const css = read('public/css/devices.css');
assert.match(css, /\.gm__hud > \.shopbar \{ bottom: calc\(\.2rem - var\(--sa-b\)\); \}/);
// the shop's own rule stays the desktop place (.2rem from the bottom): the inset rule only cancels the HUD inset
assert.match(read('public/css/screens/game-shop.css'), /\.shopbar \{\n {2}position: absolute; right: \.26rem; bottom: \.2rem;/);
});
test('ItemDetail: 调度中心不出售 + the source for the special 维式重锤 / 突变细胞, nothing for a shop item', async () => {
await data.loadAll('items', 'assets');
const special = data.lookup('items', 'chess_item_4_09_e_a'); // 灼燃维式重锤
const cell = data.lookup('items', 'chess_item_5_08_e_a'); // 突变细胞
assert.ok(special?.shopExcluded && cell?.shopExcluded, 'items.json marks them');
const plain = data.list('items').find((i) => i.itemType === 'EQUIP' && !i.shopExcluded && !i.isGolden);
const note = (item) => [...walk(ItemDetail({ item, piece: null, editable: false }))].find((n) => hasClass(n, 'dhint--source'));
assert.match(textOf(note(special)), /调度中心不出售 · 获取途径:维多利亚盟约每25层 \/ 洛洛的定制品/);
assert.match(textOf(note(cell)), /昆图斯/);
assert.equal(note(plain), undefined, 'a shop item has no such line');
});
});