Playtest #6 fixes: summons placed by hand, boss pool stall, bounties, 联防 leak counter, distinct offers, 机变 card text, phone buy tap, skill triggers, blocking, push force, 孤立, enemies, element bar

- #1 #2 skill / device summons (赫默's drone, 巫恋's doll, 凯瑟琳's devices) are hand cards placed by the player;
  placed summons deploy once at battle start, then a skill summon re-appears at its tile on each cast (the user's call)
- #3 叙拉古 隐匿 ends on time (32 + 0.4 × layers s); 忍冬 S3 迷彩 lasts until her next cast (official), kept apart from 隐匿
- #4 multi-round bounty cards last two battles (the user's call; MULTI_ROUND_BOUNTY_BATTLES), blue / red card text
- #5 the shared boss pool's float dust (≈3.6e-12 HP) stalled Final Assault leaders at 0 %; < 1 HP now clears the pool,
  and the team LP reaching 0 first is a defeat (PRTS)
- #6 #10 机变 cards show their official effect text; an invisible 详情 button no longer swallows the confirming tap on phones
- #7 live ×N counter of a leaker's enemies still on the 联防 field (LP loss stays capped at 10)
- #8 #15 #16 skill triggers per 技能策略: skill-range triggers, injured-ally heals (古米, 塞雷娅), tank take-damage
  triggers with a 3 s cooldown; 新约能天使 S2 shields the highest-aggro operator in range
- #9 official per-enemy model scales (威龙)
- #11 element bar under the unit's HP bar (white = 元素值 left, element-coloured refill after a burst), floor rounding
- #12 枯朽战车 kit per PRTS; every ranged enemy reaches its target's 0.25 collider
- #13 恐惧 wander (萨科塔之翼 at half HP, operator fears make enemies flee) with re-planning after pushes
- #14 push / pull distance by 力度 − 重量 (PRTS table); #17 every blocker attacks what it blocks; ASPD floor 20
- #18 炎佑 is 孤立: no heals or ally buffs
- #19 every free pick-one chess offer (promotion reward, 信标, 寻呼模块) shows different operators
- DESIGN §20 and the normative lines, SIM / META / DATA / PLAYING updated; 2747 unit tests and all 27 browser suites
  green; dev recordings regenerated
This commit is contained in:
sganggs committed 2026-10-01 19:08:23 +08:00
1 parent e6628f0cfe
commit 6464e4b46a
138 files changed
+7946 -1379

No files matched your search

+2 -2
View File
@@ -133,8 +133,8 @@ describe('in-match UI (mock harness, headless Chrome)', { skip: !ENABLED && 'set
await page.keyboard.press('Space');
await sleep(300);
assert.equal((await mockState(page)).ready, false);
// detail + Esc
await page.click('.scard .scard__detail');
// detail + Esc: the first tap on a card opens its detail (there is no ⓘ corner — user playtest #6 item 10)
await page.click('.shopbar__cards .scard:not(.scard--sold)');
await page.waitForSelector('.dpanel');
await page.keyboard.press('Escape');
await sleep(200);
+10 -10
View File
@@ -130,7 +130,7 @@ describe('2: the own LP drops live while the battle runs', () => {
const r = liveLp(null, { phase, round: 3, lp: 40, leaks: 9 });
assert.deepEqual([r.pending, r.shown, r.base], [0, 40, null], phase);
}
assert.deepEqual(liveLp(null, { phase: PHASE.COMBAT, round: 3, lp: undefined, leaks: 2 }), { base: null, pending: 0, shown: null, unite: false });
assert.deepEqual(liveLp(null, { phase: PHASE.COMBAT, round: 3, lp: undefined, leaks: 2 }), { base: null, pending: 0, shown: null, unite: false, left: null });
assert.match(pendingTip(30, 3), /结算时扣除 3 点/);
assert.match(pendingTip(30, 10, { unite: true }), /联防中/);
assert.equal(pendingTip(30, 0), null);
@@ -138,16 +138,16 @@ describe('2: the own LP drops live while the battle runs', () => {
test('team panel rows: the own row takes the top bar\'s live value, a teammate\'s m.public players[].pendingLp; only in COMBAT / 联防', () => {
const pub = { phase: PHASE.COMBAT };
assert.deepEqual(rowLp({ lp: 30, pendingLp: 3 }, pub), { lp: 30, pending: 3, unite: false });
assert.deepEqual(rowLp({ lp: 2, pendingLp: 3 }, pub), { lp: 2, pending: 2, unite: false }, 'clamped at the LP left');
assert.deepEqual(rowLp({ lp: 30 }, pub), { lp: 30, pending: 0, unite: false });
assert.deepEqual(rowLp({ lp: 30, pendingLp: 4 }, { phase: PHASE.UNITE }), { lp: 30, pending: 4, unite: true });
assert.deepEqual(rowLp({ lp: 30, pendingLp: 4 }, { phase: PHASE.SETTLE }), { lp: 30, pending: 0, unite: false });
assert.deepEqual(rowLp({ lp: 30, pendingLp: 4, alive: false }, pub), { lp: 30, pending: 0, unite: false });
assert.deepEqual(rowLp({ lp: 30, pendingLp: 3 }, pub), { lp: 30, pending: 3, unite: false, left: null });
assert.deepEqual(rowLp({ lp: 2, pendingLp: 3 }, pub), { lp: 2, pending: 2, unite: false, left: null }, 'clamped at the LP left');
assert.deepEqual(rowLp({ lp: 30 }, pub), { lp: 30, pending: 0, unite: false, left: null });
assert.deepEqual(rowLp({ lp: 30, pendingLp: 4 }, { phase: PHASE.UNITE }), { lp: 30, pending: 4, unite: true, left: null });
assert.deepEqual(rowLp({ lp: 30, pendingLp: 4 }, { phase: PHASE.SETTLE }), { lp: 30, pending: 0, unite: false, left: null });
assert.deepEqual(rowLp({ lp: 30, pendingLp: 4, alive: false }, pub), { lp: 30, pending: 0, unite: false, left: null });
// the own row: the top bar's numbers (m.private lp — m.public may lag behind it by a throttle interval)
assert.deepEqual(rowLp({ lp: 30, pendingLp: 1 }, pub, { lp: 30, pending: 5, unite: false }), { lp: 30, pending: 5, unite: false });
assert.deepEqual(rowLp({ lp: 30 }, pub, { lp: 25, pending: 0, unite: false }), { lp: 25, pending: 0, unite: false }, 'settled m.private first');
assert.deepEqual(rowLp({ lp: 30 }, { phase: PHASE.FINAL_ASSAULT }, { lp: 99, pending: 5 }), { lp: 30, pending: 0, unite: false }, 'boss rounds: the row keeps its own share');
assert.deepEqual(rowLp({ lp: 30, pendingLp: 1 }, pub, { lp: 30, pending: 5, unite: false }), { lp: 30, pending: 5, unite: false, left: null });
assert.deepEqual(rowLp({ lp: 30 }, pub, { lp: 25, pending: 0, unite: false }), { lp: 25, pending: 0, unite: false, left: null }, 'settled m.private first');
assert.deepEqual(rowLp({ lp: 30 }, { phase: PHASE.FINAL_ASSAULT }, { lp: 99, pending: 5 }), { lp: 30, pending: 0, unite: false, left: null }, 'boss rounds: the row keeps its own share');
});
test('LP tower: lp − pending in red with a −N tick keyed by the pending value (it pops again on every leak), the 联防中 tag', () => {
+57
View File
@@ -0,0 +1,57 @@
// User playtest #6 item 5 (HUD part) — the boss bar read "0.0%" while the leader kept fighting. The leader's death is a
// sim / match fix (test/match/playtest6-bosspool.test.js: a pool never holds less than 1 HP, so 0 on the HUD means the
// leader is down); the bar itself now (ui/gameLogic.js bossPctText, ui/hud.js PhaseCapsule)
// * never reads 0.0 % while the leader has HP left (a sliver reads "<0.1%"),
// * shows the live pool of the battle on screen (its snapshot: the local simulation's own damage on top of b.pool)
// before the ~1 Hz m.public.bossHp, which lagged the leader's death.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
const { bossPctText, bossFrac } = await import('../../public/js/ui/gameLogic.js');
const { PhaseCapsule } = await import('../../public/js/ui/hud.js');
/** The text of a vnode tree in document order. */
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('');
return typeof v === 'object' ? textOf(v.props?.children) : '';
};
function* walk(v) {
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
if (!v || typeof v !== 'object') return;
yield v;
yield* walk(v.props?.children);
}
const barText = (vnode) => {
for (const v of walk(vnode)) if (typeof v.props?.class === 'string' && v.props.class.includes('bossbar__txt')) return textOf(v);
return null;
};
test('bossPctText: whole percents from 10 %, one decimal below, never 0.0 % for a living leader', () => {
assert.equal(bossPctText(1), '100%');
assert.equal(bossPctText(0.456), '46%');
assert.equal(bossPctText(0.1), '10%');
assert.equal(bossPctText(0.0934), '9.3%');
assert.equal(bossPctText(0.001), '0.1%');
assert.equal(bossPctText(800 / 1800000), '<0.1%', 'the user\'s screenshot: a leader a few hundred HP from death');
assert.equal(bossPctText(1 / 3600000), '<0.1%', 'the last HP point of the largest pool');
assert.equal(bossPctText(0), '0.0%', 'only a leader that is down reads 0.0 %');
assert.equal(bossPctText(null), null);
assert.equal(bossPctText(NaN), null);
assert.equal(bossPctText(bossFrac({ hp: 1, max: 1800000 })), '<0.1%');
});
test('PhaseCapsule: the live pool of the battle on screen wins over the ~1 Hz m.public value', () => {
const pub = { phase: PHASE.FINAL_ASSAULT, bossHp: { hp: 900, max: 1800000 } };
// the local battle already saw the leader die (its snapshot pool is 0): the bar says so at once
assert.equal(barText(PhaseCapsule({ pub, hud: { killed: 15, total: 20, boss: { hp: 0, max: 1800000 } } })), '0.0%');
// a living leader never reads 0.0 %
assert.equal(barText(PhaseCapsule({ pub, hud: { killed: 15, total: 20, boss: { hp: 700, max: 1800000 } } })), '<0.1%');
// no snapshot yet (the battle is loading): m.public
assert.equal(barText(PhaseCapsule({ pub, hud: null })), '0.1%', '900 of 1 800 000 = 0.05 %');
assert.equal(barText(PhaseCapsule({ pub: { phase: PHASE.FINAL_ASSAULT, bossHp: { hp: 900000, max: 1800000 } }, hud: { killed: 1, total: 20, boss: null } })), '50%');
assert.equal(barText(PhaseCapsule({ pub: { phase: PHASE.FINAL_ASSAULT }, hud: null })), '敌方领袖');
});
+55
View File
@@ -0,0 +1,55 @@
// User playtest #6 item 4 (workstream WC), the client side: a 悬赏决策 card shows how many battles its enemies come for
// the way the official card does — "下场作战" / "两场作战" in blue, a multi-round card's "每场" in red (activity_table
// effectDesc rich text) — instead of plain text. A real draft (server/match/choices.js generateDraft → cardView), through
// m.public's normalisation (ui/gameLogic.js normalizeSp), resolved by the overlay (ui/choiceOverlay.js resolveSpCard).
// The server side is in test/match/playtest6-matchflow.test.js.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { DATA } from '../match/harness.js';
import { GameData } from '../../server/match/gamedata.js';
import { generateDraft, cardView } from '../../server/match/choices.js';
import { createRng } from '../../server/sim/rng.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
// the browser data store reads the real data files from disk
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 { data } = await import('../../public/js/data.js');
const { resolveSpCard } = await import('../../public/js/ui/choiceOverlay.js');
const { normalizeSp } = await import('../../public/js/ui/gameLogic.js');
const { parseRichText } = await import('../../public/js/ui/richText.js');
test('#4 the draft overlay shows each bounty card\'s battles in the official colours (blue 下场 / 两场; a multi-round card reads 两场 too)', async () => {
await data.loadAll('choices', 'effects', 'items', 'assets', 'enemies');
const gd = new GameData(DATA, 'mode_multi_hard');
// the official multi-round cards: two battles, in the two-battle cards' blue (the user's call after playtest #6)
const MULTI = new Set(DATA.choices.cards.bounty.filter((c) => c.multiRound).map((c) => c.effectId));
const seen = new Set();
for (let seed = 1; seed <= 40 && seen.size < 3; seed++) {
const d = generateDraft(gd, createRng(seed * 977 + 3), 3, { stageId: 'act2autochess_m02' });
assert.equal(d.family, 'bounty', 'co-op 绝境 R3 is a bounty draft');
const sp = normalizeSp({ family: d.family, cards: d.cards.map(cardView), order: ['p_0'], turn: 'p_0', picks: {}, taken: {} }, [{ playerId: 'p_0' }]);
for (const card of sp.cards) {
const view = resolveSpCard(card, sp.family);
const segs = parseRichText(view.desc).filter((s) => s.text);
const styled = (cls) => segs.filter((s) => s.cls.includes(cls)).map((s) => s.text).join('|');
assert.ok(!styled('ba.vdown').includes('每场'), `${card.id} ${view.name}: no red 每场 (${view.desc})`);
if (card.rounds === 2) assert.ok(styled('ba.vup').includes('两场作战'), `${card.id} ${view.name}: blue 两场作战 (${view.desc})`);
else assert.match(styled('ba.vup'), /下场(作战|战斗)/, `${card.id} ${view.name}: blue 下场 (${view.desc})`);
seen.add(MULTI.has(card.id) ? 'multi' : card.rounds);
}
}
assert.deepEqual([...seen].map(String).sort(), ['1', '2', 'multi'], 'drafts with 1-, 2-battle and multi-round cards were checked');
});
+136
View File
@@ -0,0 +1,136 @@
// Real-server browser E2E of user playtest #6 items 1 and 2 (from a friend): 凯瑟琳's device and 赫默's drone could not be
// placed by hand. A solo prep (test/e2e/fastServer.mjs hook SP_START_ROUND 2 + SP_START_CHESS: 古米, 凯瑟琳, 赫默 in the
// hand) is driven with real mouse drags: the three operators go onto the board through the direction wheel, their
// summon cards appear in the hand (赫默's 医疗探机 ×1, 凯瑟琳's 支援装置 ×2), the drone is dropped on a free tile and a
// device next to 古米 facing it — then the battle starts with both on the field: the device fights from the start and
// the placed drone deploys once at the battle start (PRTS 卫戍协议/帮助 §作战阶段; settled by the user after playtest #6 —
// shared/constants.js SKILL_SUMMON_START_DEPLOY), then comes back with each S2 (sim/content/tokens.js). Zero console
// errors.
//
// SP_E2E=1 node --test test/ui/playtest6-summons.e2e.test.js
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf } from '../e2e/client.mjs';
const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets'));
const GUMMY = 'chess_char_1_10_a', CATHY = 'chess_char_4_11_a', SILENCE = 'chess_char_2_02_a';
const DRONE = 'token_10000_silent_healrb', DEVICE = 'token_10041_cathy_catsld';
async function soloToPrep(c) {
await c.open();
await c.enter('煌');
await c.click('.mode-card', '独立模拟');
await c.click('.diff-card', '险境');
await c.click('.create-box button', '开始独立模拟');
await c.waitFor((s) => !!s.room, 'solo room');
if (!(await c.st()).phase) await c.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
await c.click('.brief__foot .btn--primary', '准备就绪');
await c.waitFor((s) => s.phase === 'BAND_DRAFT', 'band draft', 30000);
await c.click('.dband', null, { nth: 1 });
await c.click('.draft-detail__btns .btn--primary', '确认选择');
await c.waitFor((x) => x.phase === 'PREP' && !x.ready, 'prep', 60000);
await sleep(1800);
}
/** Drag hand piece `uid` onto (row, col) and pick `dir` on the wheel; returns the board piece. */
async function place(c, uid, tile, dir) {
const from = await c.piecePoint(uid);
const to = await c.tilePoint(tile.row, tile.col);
assert.ok(from && to, `piece ${uid} and tile ${tile.row},${tile.col} on screen`);
await c.drag(from, to);
await c.page.waitForSelector('.fwheel__dia', { timeout: 4000 });
await c.swipe(dir);
await sleep(500);
}
const handOf = (c, id) => c.page.evaluate((id) => (globalThis.__SP__.store.get().match.private?.hand || []).find((p) => p && p.id === id) || null, id);
const boardOf = (c, id) => c.page.evaluate((id) => (globalThis.__SP__.store.get().match.private?.board || []).filter((p) => p.id === id), id);
/**
* A free deploy tile orthogonally next to one of the board's operators and the direction that faces it from there
* (board coordinates): { row, col, dir } or null.
*/
function spotNextToOperator(c) {
return c.page.evaluate(() => {
const { store, data } = globalThis.__SP__;
const s = store.get();
const st = data.lookup('stages', s.match.public.stageId);
const dt = st.deployTiles.normal;
const ok = new Set([...dt.rangedOnly, ...dt.melee].map(([a, b]) => `${a},${b}`));
const used = new Set(s.match.private.board.map((p) => `${p.row},${p.col}`));
for (const op of s.match.private.board.filter((p) => p.kind === 'chess')) {
for (const [dr, dc, dir] of [[0, -1, 'RIGHT'], [1, 0, 'DOWN'], [-1, 0, 'UP'], [0, 1, 'LEFT']]) {
const k = `${op.row + dr},${op.col + dc}`;
if (ok.has(k) && !used.has(k)) return { row: op.row + dr, col: op.col + dc, dir };
}
}
return null;
});
}
describe('user playtest #6: summons placed by hand (real server, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome + assets) to run' }, () => {
let puppeteer = null;
test('赫默\'s drone and 凯瑟琳\'s devices are hand cards, placed with the wheel; both are on the field at the battle start', { timeout: 240000 }, async () => {
puppeteer = puppeteer || (await import('puppeteer-core')).default;
const srv = await startRealServer({ fast: { timerScale: 0.5, combatSpeed: 4, startRound: 2, chess: [GUMMY, CATHY, SILENCE] } });
const c = new Client(puppeteer, srv.base, 'summons', { prefix: 'pt6' });
try {
await soloToPrep(c);
await c.hookRequests();
// the three operators onto the board (melee first: 古米 and 凯瑟琳 take melee tiles)
for (const id of [GUMMY, CATHY, SILENCE]) {
const p = await handOf(c, id);
assert.ok(p, `${id} in the starter kit`);
const tile = await c.freeTileFor(p.uid);
await place(c, p.uid, tile, 'RIGHT');
assert.equal((await boardOf(c, id)).length, 1, `${id} placed`);
}
// their summon cards
const drone = await handOf(c, DRONE);
const dev = await handOf(c, DEVICE);
assert.ok(drone, '赫默\'s 医疗探机 card is in the hand');
assert.ok(dev && dev.count === 2, `凯瑟琳's 支援装置 card ×2 (${dev && dev.count})`);
await c.shot('hand-cards');
// the drone on a free tile, facing UP
const dt = await c.freeTileFor(drone.uid, { prefer: 'back' });
await place(c, drone.uid, dt, 'UP');
const [dp] = await boardOf(c, DRONE);
assert.ok(dp && dp.row === dt.row && dp.col === dt.col && dp.dir === 'UP', `drone placed (${JSON.stringify(dp)})`);
// a device next to an operator, facing it
const spot = await spotNextToOperator(c);
assert.ok(spot, 'a free tile next to an operator');
await place(c, dev.uid, spot, spot.dir);
const devs = await boardOf(c, DEVICE);
assert.equal(devs.length, 1);
assert.deepEqual([devs[0].row, devs[0].col, devs[0].dir], [spot.row, spot.col, spot.dir], 'device placed facing the operator');
assert.equal((await handOf(c, DEVICE))?.count, 1, 'one device left on the card');
assert.ok((await c.requests('g.move')).length >= 5, 'every placement went through g.move');
// the rest of the stack (one device) is drawn back on its bench slot at once (not left over the placed copy)
await sleep(450);
const slot = await c.page.evaluate((id) => (globalThis.__SP__.store.get().match.private?.hand || []).findIndex((p) => p && p.id === id), DEVICE);
const home = await c.tilePoint(7, slot);
const at = await c.piecePoint(dev.uid, 0.5);
assert.ok(home && at && Math.hypot(at.x - home.x, at.y - home.y) < 90, `the device card is back on its bench slot (${JSON.stringify({ home, at })})`);
await c.shot('placed');
// the battle: the device deploys with the board, and so does the placed drone, once (then with each S2)
await c.click('.readybtn');
await c.waitFor((s) => s.phase === 'COMBAT', 'combat', 30000);
// the battle views (render/app.js: unit id → view with its UnitInfo) once the board has deployed
const onField = () => c.page.evaluate(() => [...(globalThis.__SP_VIEW__?.raw?.debug?.views?.values() || [])].filter((v) => v?.info && v.alive !== false).map((v) => v.info.defId));
await c.page.waitForFunction((id) => [...(globalThis.__SP_VIEW__?.raw?.debug?.views?.values() || [])].some((v) => v?.info?.defId === id), { timeout: 30000 }, GUMMY);
await sleep(600);
const start = await onField();
assert.ok(start.includes(DEVICE), `the device is on the field from the start (${start.join(', ')})`);
assert.ok(start.includes(DRONE), `the placed drone deploys once at the battle start (${start.join(', ')})`);
await c.shot('battle');
assert.deepEqual(problemsOf([c]), []);
} finally {
await c.close();
await srv.stop();
}
});
});
+233
View File
@@ -0,0 +1,233 @@
// Browser regressions of user playtest #6, UI items 6 / 10, through the mock harness (public/dev/game-mock.html) in
// headless Chrome (puppeteer-core + system Chrome). Opt-in: SP_E2E=1.
//
// SP_E2E=1 node --test test/ui/playtest6-ui.e2e.test.js
//
// 6 every 机变 card shows its whole effect text inside the card: all 51 shop items (道具补给 / 机密商店), all 129
// bounty and all 43 tactic cards, at the phone landscape viewports (the smallest supported 640×360, 667×375,
// 720×360, the user's Android 756×366, 800×360, 844×390, 932×430) and at 1280×720 / 1920×1080 — the text is not
// clamped, ends inside the card's padding, and is at least 7.5 px (phones) / .19rem (16:9); every card is measured
// once taken (the taker's round avatar at its top-right) and once not, and a taken card's name and tags never run
// under the badge — measured with the page's web font Noto Sans SC when Google Fonts can be reached (QA: with it
// 海沟实验体 took 8 lines at 640×360). v2.4.1 at 756×366: 0.2 lines of it were visible (screenshot 2).
// 10 a tap anywhere on the armed shop card (operator and item) buys, the 确认购买 strip included, on touch at
// 640×360 / 756×366 / 800×360 / 844×390 / 932×430 and with the mouse at 1920×1080: every point of a 12 × 12 grid over the
// armed card hit-tests to the card, and real taps on the strip's centre / right end and the card's bottom-right
// buy. v2.4.1: 120 of 144 points (the ⓘ corner's hidden 44 px touch area over the bottom-right quarter) — the strip's
// centre did not buy; at 640×360 the left-docked detail card opened by the first tap also covered the top quarter of
// the first cards (84 / 96 of 144).
// Screenshots: test/e2e/out/p6-*.png.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** name, width, height, touch */
const PHONES = [['756x366', 756, 366, true], ['800x360', 800, 360, true], ['844x390', 844, 390, true], ['932x430', 932, 430, true]];
/** narrower phones (text only): the smallest supported phone (DESIGN §10, devices.e2e) and the iPhone SE / 8 */
const NARROW = [['640x360', 640, 360, true], ['667x375', 667, 375, true], ['720x360', 720, 360, true]];
/** two pick maps over the 4 mock players (p1 = me): every one of 6 cards is taken in one pass and free in the other */
const TAKEN = [{ p4: 0, p3: 1, ai_2: 2, p1: 3 }, { p4: 4, p3: 5 }];
const DESKTOP = [['1280x720', 1280, 720, false], ['1920x1080', 1920, 1080, false]];
/** public/index.html's Google Fonts stylesheet */
const WEB_FONTS = 'https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700;900&family=Oxanium:wght@400;500;600;700&family=Rajdhani:wght@500;600;700&display=swap';
describe('user playtest #6 — UI items 6 / 10 (mock harness, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
let srv;
let browser;
let base;
before(async () => {
const { startServer } = await import('../../server/index.js');
const puppeteer = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
base = `http://127.0.0.1:${srv.port}`;
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-sandbox'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
async function open(query, { w, h, touch, webFont = false }) {
const page = await browser.newPage();
await page.setViewport({ width: w, height: h, deviceScaleFactor: 1, isMobile: touch, hasTouch: touch });
const problems = [];
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
await page.goto(`${base}/dev/game-mock.html?shot=1&${query}`, { waitUntil: 'networkidle0' });
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)'), { timeout: 20000 });
// the real page's web font (index.html loads it from Google Fonts; Android's CJK font has its metrics): the mock has
// no <link> for it, and the system fallback wraps differently (QA: 海沟实验体 took 8 lines at 640×360 with it) —
// measured with it whenever it can be fetched
const noto = webFont && await Promise.race([
page.addStyleTag({ url: WEB_FONTS }).then(() => page.evaluate(async () => {
await Promise.all(['400', '700', '900'].map((wt) => document.fonts.load(`${wt} 12px "Noto Sans SC"`, '获得伤害减免')));
return document.fonts.check('12px "Noto Sans SC"', '获得');
})).catch(() => false),
sleep(8000).then(() => false),
]);
await page.evaluate(() => document.fonts.ready);
await sleep(900);
return { page, problems, noto };
}
/** Each 机变 card's text: clamped?, bottom beyond the card's padding (px), font size; taken?, name / tags under the badge? */
const cardTexts = (page) => page.evaluate(() => [...document.querySelectorAll('.spcard')].map((c) => {
const r = c.getBoundingClientRect();
const d = c.querySelector('.spcard__desc');
if (!d) return { name: c.querySelector('.spcard__name')?.textContent, missing: true };
const dr = d.getBoundingClientRect();
const cs = getComputedStyle(d);
const badge = c.querySelector('.spcard__taker')?.getBoundingClientRect();
let under = false;
for (const el of badge ? c.querySelectorAll('.spcard__name, .spcard__tag') : []) {
const range = document.createRange();
range.selectNodeContents(el);
for (const a of [...range.getClientRects(), el.getBoundingClientRect()]) {
if (a.right > badge.left + 0.5 && a.left < badge.right - 0.5 && a.bottom > badge.top + 0.5 && a.top < badge.bottom - 0.5) under = true;
}
}
return { name: c.querySelector('.spcard__name')?.textContent, text: d.textContent.length, clamped: d.scrollHeight > d.clientHeight + 1,
beyond: dr.bottom - (r.bottom - parseFloat(getComputedStyle(c).paddingBottom)), font: parseFloat(cs.fontSize), rem: parseFloat(getComputedStyle(document.documentElement).fontSize),
taken: !!badge, under };
}));
test('6: every 机变 card shows its whole effect text inside the card, taken or not, on phones and on desktop', { timeout: 15 * 60 * 1000 }, async () => {
const report = [];
for (const [name, w, h, touch] of [...NARROW, ...PHONES, ...DESKTOP]) {
const { page, problems, noto } = await open('phase=SP_DRAFT&variant=supply', { w, h, touch, webFont: true });
if (name === '756x366' || name === '640x360') await page.screenshot({ path: path.join(OUT, `p6-supply-${name}.png`) });
const sets = await page.evaluate(async () => {
const { data } = await import('/js/data.js');
const ch = data.get('choices');
return {
supply: data.list('items').filter((i) => !i.isGolden && i.itemType === 'EQUIP' && !i.shopExcluded).map((i) => ({ itemId: i.id })),
bounty: ch.cards.bounty.map((b) => ({ effectId: b.effectId })),
tactic: ch.cards.tactic.map((t) => ({ effectId: t.effectId })),
};
});
for (const [family, list] of Object.entries(sets)) {
let n = 0;
let taken = 0;
for (let i = 0; i < list.length; i += 6) {
for (const picks of TAKEN) {
await page.evaluate((cards, family, picks) => {
const S = globalThis.__MOCK__.S();
S.pub.sp = { ...S.pub.sp, family, cards, picks };
globalThis.__MOCK__.pushPublic();
}, list.slice(i, i + 6), family, picks);
await sleep(120);
for (const t of await cardTexts(page)) {
n++;
if (t.taken) taken++;
const tag = `${name} ${family} ${t.name}${t.taken ? ' (taken)' : ''}`;
assert.ok(!t.missing && t.text > 0, `${tag}: an effect text`);
assert.ok(!t.clamped, `${tag}: the whole text (not clamped)`);
assert.ok(t.beyond <= 0.5, `${tag}: inside the card (${t.beyond.toFixed(1)} px beyond its padding)`);
assert.ok(touch ? t.font >= 7.5 : t.font >= 0.19 * t.rem - 0.01, `${tag}: ${t.font} px`);
assert.ok(!t.under, `${tag}: the name and tags keep clear of the taker's badge`);
}
}
}
assert.equal(n, list.length * TAKEN.length, `${name} ${family}: every card rendered`);
assert.equal(taken, list.length, `${name} ${family}: every card measured taken once`);
}
report.push(`${name}: ${Object.values(sets).reduce((a, l) => a + l.length, 0)} texts in full, taken or not (${noto ? 'Noto Sans SC' : 'system font — Google Fonts unreachable'})`);
assert.deepEqual(noto ? problems : problems.filter((x) => !/Failed to load resource/.test(x)), [], name);
await page.close();
}
console.log(report.join('\n'));
});
test('6: the taken-by badge and the two-tap strip stay on the new card (756×366)', async () => {
const { page, problems } = await open('phase=SP_DRAFT&variant=supply', { w: 756, h: 366, touch: true });
assert.ok(await page.$('.spcard.is-taken .spcard__taker'), 'the taker\'s badge');
const tap = async (sel) => {
const r = await page.$eval(sel, (el) => { const b = el.getBoundingClientRect(); return { x: b.left + b.width / 2, y: b.bottom - 4 }; });
await page.touchscreen.tap(r.x, r.y);
await sleep(350);
};
await tap('.spcard.is-pickable');
assert.ok(await page.$('.spcard.is-armed .spcard__confirm'), 'first tap: selected, 确认选择 strip');
await page.screenshot({ path: path.join(OUT, 'p6-supply-armed-756x366.png') });
await tap('.spcard.is-armed'); // on the strip
await page.waitForSelector('.spcard.is-mine', { timeout: 3000 });
assert.deepEqual(problems, []);
await page.close();
});
/** Arm the first buyable card matching `sel`, then hit-test a 12 × 12 grid over it: a point counts when the tap target
* there (the nearest button / role=button) is the armed card itself — not an inner control such as the old ⓘ, and not
* the detail card its first tap opened. */
async function armAndProbe(page, sel, touch) {
const tapAt = async (x, y) => { if (touch) await page.touchscreen.tap(x, y); else await page.mouse.click(x, y); await sleep(400); };
const b = await page.$eval(sel, (el) => { const r = el.getBoundingClientRect(); return { x: r.left, y: r.top, w: r.width, h: r.height }; });
await tapAt(b.x + b.w * 0.85, b.y + b.h * 0.85); // the old ⓘ corner: arms like the rest of the card
return page.evaluate(() => {
const a = document.querySelector('.scard.is-armed');
if (!a) return null;
const r = a.getBoundingClientRect();
const N = 12;
let hit = 0;
for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) {
const el = document.elementFromPoint(r.left + (i + 0.5) * r.width / N, r.top + (j + 0.5) * r.height / N);
if (el && el.closest('button, [role="button"], a, input') === a) hit++;
}
const s = a.querySelector('.scard__confirm')?.getBoundingClientRect();
return { hit, n: N * N, box: { x: r.left, y: r.top, w: r.width, h: r.height }, strip: s && { x: s.left, y: s.top, w: s.width, h: s.height } };
});
}
test('10: a tap anywhere on the armed shop card buys — the 确认购买 strip included (touch on phones, mouse on desktop)', { timeout: 10 * 60 * 1000 }, async () => {
const report = [];
const SPOTS = ['strip centre', 'strip right', 'card bottom-right'];
for (const [name, w, h, touch] of [NARROW[0], ...PHONES, DESKTOP[1]]) {
const { page, problems } = await open('phase=PREP', { w, h, touch });
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => { S.priv.funds = 99; S.priv.hand = S.priv.hand.map(() => null); }));
await sleep(200);
for (const [kind, sel] of [['operator', '.shopbar__cards .scard:not(.scard--sold):not(.is-disabled)'], ['item', '.shopbar__item .scard:not(.scard--sold):not(.is-disabled)']]) {
for (const spot of SPOTS) {
// a fresh card for every spot: the next operator card; the item slot comes back with a refresh (R)
if (!(await page.$(sel))) { await page.keyboard.press('KeyR'); await sleep(350); }
const slotIdx = await page.evaluate((s) => {
const el = document.querySelector(s);
const S = globalThis.__MOCK__.S();
const isItem = !!el.closest('.shopbar__item');
const cards = [...document.querySelectorAll(isItem ? '.shopbar__item .scard' : '.shopbar__cards .scard')];
const slots = S.priv.shop.slots.map((x, i) => ({ x, i })).filter(({ x }) => (isItem ? x && x.kind === 'item' : !x || x.kind !== 'item'));
return slots[cards.indexOf(el)]?.i ?? null;
}, sel);
assert.ok(Number.isInteger(slotIdx), `${name} ${kind}: a card to buy`);
const probe = await armAndProbe(page, sel, touch);
assert.ok(probe, `${name} ${kind}: the card is armed by a tap on its bottom-right`);
assert.equal(probe.hit, probe.n, `${name} ${kind}: ${probe.hit}/${probe.n} points of the armed card are the card`);
const funds0 = await page.evaluate(() => globalThis.__MOCK__.S().priv.funds);
const { box, strip } = probe;
const at = spot === 'strip centre' ? [strip.x + strip.w / 2, strip.y + strip.h / 2]
: spot === 'strip right' ? [strip.x + strip.w * 0.9, strip.y + strip.h * 0.6] : [box.x + box.w * 0.94, box.y + box.h * 0.94];
if (touch) await page.touchscreen.tap(at[0], at[1]); else await page.mouse.click(at[0], at[1]);
await sleep(450);
const after = await page.evaluate((i) => ({ funds: globalThis.__MOCK__.S().priv.funds, sold: !!globalThis.__MOCK__.S().priv.shop.slots[i]?.sold }), slotIdx);
assert.ok(after.sold && after.funds < funds0, `${name} ${kind}: a ${touch ? 'tap' : 'click'} on the ${spot} buys`);
if (spot === 'strip centre' && kind === 'operator') report.push(`${name}: ${probe.hit}/${probe.n}`);
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => { S.priv.funds = 99; S.priv.hand = S.priv.hand.map(() => null); }));
await sleep(150);
}
}
assert.deepEqual(problems, [], name);
await page.close();
}
console.log(report.join('\n'));
});
});
+207
View File
@@ -0,0 +1,207 @@
// Regression tests (Node) of user playtest #6, client-UI items (workstream WD):
// 6 the 机变 道具补给 overlay showed only icon, name and tier on each card — no effect text (the user's Android,
// 756×366 CSS px: the description sat below the fold of a 105 px card and was clipped). The cards now use the
// official layout (research 06 §11.5 / the official 机变 screenshots): a framed icon with the name beside it, the
// description across the card under both, left-aligned; the description is the item's / effect's official rich text
// (the same text as the detail card: items.json / effects.json `descRaw`), even when the server card carries the
// plain `desc`. The taker's badge is the round avatar alone (official) and a taken card's name keeps clear of it.
// The on-screen check (every card's text inside its card, taken or not, no name under the badge, at the phone
// viewports down to 640×360 and on desktop) is the browser test test/ui/playtest6-ui.e2e.test.js.
// 10 on phones the second tap on the armed shop card's 确认购买 strip did nothing: the card's ⓘ corner (invisible on
// touch, opacity 0 without hover) had a 44 px touch hit area (css/devices.css — its 30 px override lost on
// specificity) over the bottom-right quarter of the 67 × 97 px card, right half of the strip included; a tap there
// re-opened the detail (stopPropagation) instead of buying. The ⓘ is gone (official cards have none: the first tap
// selects and shows the detail, the second confirms); a tap anywhere on a card is the card's tap, and a card that
// cannot be bought now (bar not editable) opens its detail instead.
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
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, resolveSpCard, cardText } = await import('../../public/js/ui/choiceOverlay.js');
const { ChessCard, ItemCard } = await import('../../public/js/ui/shopBar.js');
const { normalizeSp } = await import('../../public/js/ui/gameLogic.js');
const { richTextPlain } = await import('../../public/js/ui/richText.js');
const { cardView } = await import('../../server/match/choices.js');
const { data } = await import('../../public/js/data.js');
/** Every vnode of a preact tree (htm output), depth first. */
function* walk(v) {
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
if (!v || typeof v !== 'object') return;
yield v;
yield* walk(v.props?.children);
}
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
const children = (v) => [v.props?.children].flat(Infinity).filter((x) => x && typeof x === 'object');
await data.loadAll('items', 'effects', 'choices', 'assets', 'chess', 'bonds');
// ---- 6: 机变 cards show their effect text ----------------------------------------------------------------------------
describe('6: 机变 cards (道具补给 / 机密商店 / 悬赏 / 战术) show the effect text in the official layout', () => {
const players = [{ playerId: 'me', seat: 0, name: 'Me' }, { playerId: 'p2', seat: 1, name: 'P2' }];
// server-shaped public cards (server/match/choices.js itemCard → cardView): the plain `desc` of the item
const shopItems = data.list('items').filter((i) => i.itemType === 'EQUIP' && !i.isGolden && !i.shopExcluded);
const longest = [...shopItems].sort((a, b) => b.descRaw.length - a.descRaw.length).slice(0, 6);
const serverSp = (family, cards) => normalizeSp({ family, cards: cards.map((c, idx) => cardView({ ...c, idx })), order: ['me', 'p2'], turn: 'me', picks: { p2: 5 } }, players);
const itemCards = longest.map((it) => ({ kind: 'item', id: it.id, name: it.name, desc: it.desc, tier: it.tier }));
for (const family of ['supply', 'shop']) {
test(`${family}: every item card carries the item's official rich description (the detail card's text), beside nothing`, () => {
const sp = serverSp(family, itemCards);
const nodes = [...walk(ChoiceView({ pub: { players, deadline: 0 }, sp, myId: 'me', solo: false }))];
const cards = nodes.filter((n) => hasClass(n, 'spcard'));
assert.equal(cards.length, 6);
cards.forEach((card, i) => {
const it = longest[i];
const desc = [...walk(card)].find((n) => hasClass(n, 'spcard__desc'));
assert.ok(desc, `${it.name}: a description`);
// RichText renders descRaw (ba.vup numbers, ba.acrem set bonus, line breaks) — the plain server text lost them
assert.equal(desc.props.text, it.descRaw, `${it.name}: the rich text, as ItemDetail shows it`);
assert.equal(richTextPlain(desc.props.text).replace(/\s/g, ''), it.desc.replace(/\s/g, ''));
});
});
}
test('bounty / tactic cards: the effect\'s rich text too (numbers and 下场作战 highlighted like the official cards)', () => {
const ch = data.get('choices');
for (const [family, list] of [['bounty', ch.cards.bounty], ['tactic', ch.cards.tactic]]) {
const src = list.slice(0, 6);
const sp = serverSp(family, src.map((c) => ({ kind: family, id: c.effectId, name: c.name, desc: c.desc, tier: c.tier ?? null, team: c.team, tacticKind: c.kind, enemyKey: c.enemyKey, coin: c.coin })));
sp.cards.forEach((card, i) => {
const r = resolveSpCard(card, family);
const eff = data.lookup('effects', src[i].effectId);
assert.equal(r.desc, eff.descRaw, `${family} ${src[i].name}`);
assert.match(r.desc, /<@ba\.v(up|down)>/, `${family} ${src[i].name}: highlighted parts`);
});
}
});
test('cardText: the data\'s rich text only while it says what the server says; a server-side wording wins', () => {
const rich = '为自身<@ba.vup>下场作战</>添加1只A,将其击倒者获得<@ba.vup>2</>资金';
assert.equal(cardText({ desc: '为自身下场作战添加1只A,将其击倒者获得2资金' }, rich), rich);
assert.equal(cardText({}, rich), rich, 'no server text (the mock harness): the data');
const changed = '为自身之后2场作战添加1只A,将其击倒者获得2资金';
assert.equal(cardText({ desc: changed }, rich), changed, 'a different server wording is shown as sent');
assert.equal(cardText({ descRaw: '<@ba.vup>x</>', desc: 'x' }, rich), '<@ba.vup>x</>', 'the card\'s own rich text first');
assert.equal(cardText({ desc: 'plain' }, ''), 'plain');
});
test('layout: a head row (framed icon + name, tags under the name), the description under it across the card', () => {
const sp = serverSp('supply', itemCards);
const v = ChoiceView({ pub: { players, deadline: 0 }, sp, myId: 'me', solo: false });
for (const card of [...walk(v)].filter((n) => hasClass(n, 'spcard'))) {
const kids = children(card);
const head = kids.find((n) => hasClass(n, 'spcard__head'));
assert.ok(head, 'a head row is a direct child of the card');
const headNodes = [...walk(head)];
assert.ok(headNodes.some((n) => hasClass(n, 'spcard__icon')) && headNodes.some((n) => hasClass(n, 'spcard__name')), 'icon + name in the head');
const desc = kids.find((n) => hasClass(n, 'spcard__desc'));
assert.ok(desc, 'the description is a direct child of the card');
assert.ok(kids.indexOf(desc) > kids.indexOf(head), 'under the head');
}
// the taker's badge and the two-tap strip stay (DESIGN §18.2); the badge is the round avatar alone, as in the
// official screenshots — the taker's name is the badge's tooltip and part of the card's accessible name (the
// avatar + name pill ran under long card names once the name moved into the top row)
const taken = [...walk(v)].filter((n) => hasClass(n, 'spcard') && hasClass(n, 'is-taken'));
assert.equal(taken.length, 1);
const badge = [...walk(taken[0])].find((n) => hasClass(n, 'spcard__taker'));
assert.ok(badge, 'the taker\'s badge');
assert.equal(children(badge).length, 1, 'avatar only');
assert.equal(children(badge)[0].type?.name, 'PlayerAvatar');
assert.equal(badge.props.title, 'P2 已选择');
assert.match(taken[0].props['aria-label'], /,P2已选择$/);
const armed = [...walk(ChoiceView({ pub: { players, deadline: 0 }, sp, myId: 'me', solo: false, armed: 0 }))];
assert.ok(armed.some((n) => hasClass(n, 'spcard__confirm')));
});
test('CSS: left-aligned official card; the description is clamped by lines, larger on desktop, ≥ .18rem on short phones', () => {
const css = read('public/css/screens/game-panels.css');
const rule = (sel, src = css) => {
const m = src.match(new RegExp(`(?:^|\\n)\\s*${sel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} \\{([^}]*)\\}`));
return m ? m[1] : null;
};
assert.match(rule('.spcard'), /text-align: left/);
assert.match(rule('.spcard__head'), /display: flex/);
const desc = rule('.spcard__desc');
assert.match(desc, /-webkit-line-clamp: \d/);
const size = (r) => Number(r.match(/font-size: (\.\d+)rem/)[1]);
assert.ok(size(desc) >= 0.19, `desktop description ${size(desc)}rem`);
// short landscape phones (height < 10.8rem because the root size is clamped at 40 px)
const phone = css.match(/@media \(max-height: 431\.98px\) \{([\s\S]*?)\n\}/);
assert.ok(phone, 'a short-screen block');
const pdesc = rule('.spcard__desc', phone[1]);
assert.ok(pdesc && size(pdesc) >= 0.18, 'phones keep a readable description');
assert.doesNotMatch(pdesc, /line-clamp/, 'phones show as many lines as 16:9 (信标 takes 7 below ≈ 740 px wide)');
assert.ok(rule('.spcard__icon', phone[1]), 'phones use a smaller icon');
assert.match(rule('.spov__inner', phone[1]) || '', /top: \.\d+rem/, 'phones: a tighter header gives the grid more height');
// a taken card's head keeps clear of the taker's badge (top-right corner), on phones too
assert.match(rule('.spcard.is-taken .spcard__head'), /padding-right: calc\(var\(--tk\) \+ var\(--tk-at\) - var\(--sp-px\)/);
assert.match(rule('.spcard__taker'), /right: var\(--tk-at\); top: var\(--tk-at\)/);
assert.match(rule('.spcard', phone[1]), /--sp-px: [^;]+; --tk: [^;]+; --tk-at:/);
});
});
// ---- 10: the armed shop card confirms wherever it is tapped --------------------------------------------------------
describe('10: a tap anywhere on the armed shop card buys (no inner target swallows the confirm)', () => {
const chess = data.list('chess').find((c) => c.visible && !c.isGolden && c.tier === 3);
const item = data.list('items').find((i) => i.itemType === 'EQUIP' && !i.isGolden && !i.shopExcluded);
const priv = { funds: 20, hand: [], board: [], temp: [], loadout: {} };
const clickables = (v) => [...walk(v)].filter((n) => typeof n.props?.onClick === 'function');
for (const [kind, Card, slot] of [['operator', ChessCard, { kind: 'chess', id: chess.chessId, price: 3, basePrice: 3 }], ['item', ItemCard, { kind: 'item', id: item.id, price: 2, basePrice: 2 }]]) {
test(`${kind} card: armed, the card is the only tap target in it (no ⓘ corner) and its tap confirms`, () => {
const taps = [];
const v = Card({ slot, idx: 2, priv, armed: true, onTap: (i) => taps.push(['tap', i]), onBuy: (i) => taps.push(['buy', i]), onDetail: (id) => taps.push(['detail', id]) });
const nodes = [...walk(v)];
assert.ok(!nodes.some((n) => hasClass(n, 'scard__detail')), 'no ⓘ corner on the card');
const cl = clickables(v);
assert.equal(cl.length, 1, 'one click handler: the card itself');
assert.ok(hasClass(cl[0], 'scard') && hasClass(cl[0], 'is-armed'));
assert.ok(nodes.some((n) => n.type?.name === 'ArmedTag'), 'the 确认购买 strip');
cl[0].props.onClick();
assert.deepEqual(taps, [['tap', 2]], 'the second tap goes to the two-tap handler (ShopBar buys)');
});
test(`${kind} card: not armed, no inner target either; a card that cannot be bought now opens its detail`, () => {
const taps = [];
const idle = Card({ slot, idx: 1, priv, onTap: (i) => taps.push(['tap', i]), onBuy: (i) => taps.push(['buy', i]), onDetail: (id) => taps.push(['detail', id]) });
assert.equal(clickables(idle).length, 1);
clickables(idle)[0].props.onClick();
// not editable (ready / round start): no two-tap, a reason — the tap shows the detail (the ⓘ did before)
const locked = Card({ slot, idx: 1, priv, reason: '已准备就绪,取消准备后才能操作', onBuy: (i) => taps.push(['buy', i]), onDetail: (id) => taps.push(['detail', id]) });
const lockedNodes = [...walk(locked)];
assert.ok(!lockedNodes.some((n) => hasClass(n, 'scard__detail')));
const btn = lockedNodes.find((n) => hasClass(n, 'scard'));
assert.notEqual(btn.props['aria-disabled'], 'true', 'its tap does something: not announced as disabled');
btn.props.onClick();
assert.deepEqual(taps, [['tap', 1], ['detail', slot.id]]);
});
}
test('CSS: no hit area of an ⓘ corner is left (it covered the bottom-right quarter of a phone card)', () => {
assert.doesNotMatch(read('public/css/devices.css'), /scard__detail/);
assert.doesNotMatch(read('public/css/screens/game-shop.css'), /scard__detail/);
assert.doesNotMatch(read('public/js/ui/shopBar.js'), /scard__detail/);
// a locked bar's card opens its detail: a help cursor, not "not allowed"
assert.match(read('public/css/screens/game-shop.css'), /\.shopbar\.is-locked \.scard:not\(\.scard--sold\) \{ cursor: help; \}/);
});
});
+237
View File
@@ -0,0 +1,237 @@
// User playtest #6 item 7 (workstream WC), the client side: during 联防 a leaker's count of its enemies still standing
// on the 联防 field falls live as the helpers kill them — uncapped in the official runner tag ×N (phase capsule, team
// rows), while the LP loss stays min(10, N). Pure HUD helpers (ui/hud.js, ui/teamPanel.js) and the browser runner
// (public/js/battle/runner.js state().uniteLeft / b.progress `left`) on a real 联防 spec. The server side is in
// test/match/playtest6-matchflow.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';
import { PHASE } from '../../shared/constants.js';
import { createBattleRunner } from '../../public/js/battle/runner.js';
import { createStore, initialState } from '../../public/js/store.js';
import * as specMod from '../../server/sim/spec.js';
import { DataSource } from '../../server/sim/simdata.js';
import { Battle } from '../../server/sim/Battle.js';
import { validateC2S } from '../../shared/protocol.js';
import { DATA, makeMatch } from '../match/harness.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
const { liveLp, uniteRemaining, pendingTip, PhaseCapsule, MissTag, missTip } = await import('../../public/js/ui/hud.js');
const { rowLp, rowLpTip } = await import('../../public/js/ui/teamPanel.js');
function* walk(v) {
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
if (!v || typeof v !== 'object') return;
yield v;
yield* walk(v.props?.children);
}
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('');
return typeof v === 'object' ? textOf(v.props?.children) : '';
};
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
describe('#7 the 联防 leak counter (HUD)', () => {
test('uniteRemaining: the local 联防 replica\'s count while it runs (the field on screen), else the server\'s; null when unknown', () => {
assert.equal(uniteRemaining(12, 14), 12, 'the local replica is ahead of the ~1 Hz report');
assert.equal(uniteRemaining(15, 13), 15, 'a split raised it on the field before the next report: the replica shows it');
assert.equal(uniteRemaining(undefined, 13), 13, 'no local replica yet (loading / not on screen)');
assert.equal(uniteRemaining(0, 3), 0);
for (const [a, b] of [[undefined, undefined], [null, NaN], [-1, 'x']]) assert.equal(uniteRemaining(a, b), null);
});
test('liveLp in 联防: the count replaces the own battle\'s leaks — uncapped in `left`, the LP loss capped at 10, falling live', () => {
let base = null;
const step = (o) => { const r = liveLp(base, { phase: PHASE.UNITE, round: 7, lp: 30, statsLeaks: 2, leaks: 14, cap: 10, ...o }); base = r.base; return r; };
const seq = [15, 14, 12, 10, 9, 4, 0].map((n) => step({ uniteLeft: n }));
assert.deepEqual(seq.map((r) => r.left), [15, 14, 12, 10, 9, 4, 0]);
assert.deepEqual(seq.map((r) => r.pending), [10, 10, 10, 10, 9, 4, 0], 'min(10, left)');
assert.deepEqual(seq.map((r) => r.shown), [20, 20, 20, 20, 21, 26, 30]);
assert.ok(seq.every((r) => r.unite), '联防中 while the player is a leaker (also at 0: the tag shows ×0)');
// the settlement lands (m.private lp 26): nothing pending any more, no tag
const settled = step({ uniteLeft: 4, lp: 26, statsLeaks: 16 });
assert.deepEqual([settled.pending, settled.left, settled.shown], [0, null, 26]);
// not a leaker / no count: the own battle's count (the old behaviour)
const helper = liveLp(null, { phase: PHASE.UNITE, round: 7, lp: 30, leaks: 0 });
assert.deepEqual([helper.pending, helper.left, helper.unite], [0, null, false]);
const legacy = liveLp(null, { phase: PHASE.UNITE, round: 7, lp: 30, leaks: 14 });
assert.deepEqual([legacy.pending, legacy.left], [10, null]);
// COMBAT ignores it
assert.equal(liveLp(null, { phase: PHASE.COMBAT, round: 7, lp: 30, leaks: 2, uniteLeft: 9 }).left, null);
});
test('tooltips name the true count and the capped loss', () => {
const over = pendingTip(30, 10, { unite: true, left: 15 });
assert.match(over, /还剩 15 个.*剩余不足 10 个后,队友每击倒一个少扣 1 点.*扣除 10 点(每回合至多 10 点)/);
assert.doesNotMatch(over, /队友每击倒一个就少扣/, 'above the cap a kill does not lower the loss');
assert.match(pendingTip(30, 4, { unite: true, left: 4 }), /还剩 4 个,队友每击倒一个就少扣 1 点/);
assert.match(pendingTip(30, 8, { unite: true, left: 9, cap: 8 }), /剩余不足 8 个后.*每回合至多 8 点/);
assert.equal(rowLpTip({ lp: 30, pending: 10, unite: true, left: 13 }), '目标生命值 30,联防中:漏过的敌人还剩 13 个,按现在结算扣除 10 点(每回合至多 10 点)');
assert.match(rowLpTip({ lp: 30, pending: 8, unite: true, left: 13 }, 8), /每回合至多 8 点/, 'the configured cap');
assert.equal(rowLpTip({ lp: 30, pending: 0, unite: true, left: 0 }), null);
});
test('team rows: a teammate\'s m.public players[].uniteLeft (uncapped) next to lp − pendingLp; the own row the top bar\'s value', () => {
const pub = { phase: PHASE.UNITE };
assert.deepEqual(rowLp({ lp: 30, pendingLp: 10, uniteLeft: 13 }, pub), { lp: 30, pending: 10, unite: true, left: 13 });
assert.deepEqual(rowLp({ lp: 30, uniteLeft: 0 }, pub), { lp: 30, pending: 0, unite: true, left: 0 }, 'all struck down: ×0');
assert.deepEqual(rowLp({ lp: 30 }, pub), { lp: 30, pending: 0, unite: false, left: null }, 'a helper');
assert.deepEqual(rowLp({ lp: 30, pendingLp: 2, uniteLeft: 2 }, { phase: PHASE.COMBAT }), { lp: 30, pending: 2, unite: false, left: null }, 'COMBAT: no tag');
assert.deepEqual(rowLp({ lp: 30, uniteLeft: 12 }, pub, { lp: 30, pending: 10, unite: true, left: 11 }), { lp: 30, pending: 10, unite: true, left: 11 });
});
test('team rows of a helper watching the 联防: a teammate\'s count comes from the local replica on screen (live with the kills), else m.public', () => {
const pub = { phase: PHASE.UNITE, unite: { helpers: ['me'], leakers: ['b', 'c'] } };
const b = { playerId: 'b', lp: 30, pendingLp: 10, uniteLeft: 13 }; // the authority's last report (~1 Hz, a kill behind)
const c = { playerId: 'c', lp: 4, pendingLp: 4, uniteLeft: 6 };
const local = { b: 12, c: 9 };
assert.deepEqual(rowLp(b, pub, null, { uniteLocal: local }), { lp: 30, pending: 10, unite: true, left: 12 }, 'the replica wins');
assert.deepEqual(rowLp(b, pub, null, { uniteLocal: { b: 7 } }), { lp: 30, pending: 7, unite: true, left: 7 }, 'loss = min(10, left) from the same count');
assert.deepEqual(rowLp(b, pub, null, { uniteLocal: { b: 7 }, cap: 5 }), { lp: 30, pending: 5, unite: true, left: 7 }, 'the configured cap');
assert.deepEqual(rowLp(c, pub, null, { uniteLocal: local }), { lp: 4, pending: 4, unite: true, left: 9 }, 'never more than the LP left');
assert.deepEqual(rowLp(b, pub, null, { uniteLocal: {} }), { lp: 30, pending: 0, unite: true, left: 0 }, 'absent from the replica = all struck down');
assert.deepEqual(rowLp(b, pub, null, { uniteLocal: null }), { lp: 30, pending: 10, unite: true, left: 13 }, 'no replica on screen: m.public');
assert.deepEqual(rowLp({ playerId: 'me', lp: 30 }, pub, null, { uniteLocal: local }), { lp: 30, pending: 0, unite: false, left: null }, 'a helper row gets no tag');
assert.deepEqual(rowLp(b, { ...pub, phase: PHASE.COMBAT }, null, { uniteLocal: local }), { lp: 30, pending: 10, unite: false, left: null }, 'COMBAT ignores it');
const own = { lp: 30, pending: 10, unite: true, left: 11 };
assert.deepEqual(rowLp(b, pub, own, { uniteLocal: { b: 2 } }), own, 'the own row keeps the top bar\'s value');
// wiring: the game screen hands the runner's per-leaker counts to the team panel, which passes them to rowLp
assert.match(read('public/js/screens/game.js'), /const uniteLocal = phase === PHASE\.UNITE && battleState && battleState\.uniteLeft \? battleState\.uniteLeft : null;/);
assert.match(read('public/js/screens/game.js'), /<\$\{TeamPanel\}[^\n]*uniteLocal=\$\{uniteLocal\}/);
assert.match(read('public/js/ui/teamPanel.js'), /rowLp\(p, pub, self \? selfLive : null, \{ uniteLocal, cap \}\)/);
});
test('the tag\'s tooltip speaks to the leaker on the own tag, names the teammate on a teammate\'s row', () => {
assert.equal(missTip(5), '你漏过的敌人还剩 5 个(队友正在迎战)');
assert.equal(missTip(0), '你漏过的敌人已全部被击倒');
assert.equal(missTip(5, 'Doctor·B'), 'Doctor·B 漏过的敌人还剩 5 个(联防中)');
assert.equal(missTip(0, 'Doctor·B'), 'Doctor·B 漏过的敌人已全部被击倒');
assert.equal(MissTag({ n: 5, name: 'Doctor·B' }).props.title, 'Doctor·B 漏过的敌人还剩 5 个(联防中)');
assert.equal(MissTag({ n: 5 }).props.title, '你漏过的敌人还剩 5 个(队友正在迎战)');
assert.match(read('public/js/ui/teamPanel.js'), /<\$\{MissTag\} n=\$\{lp\.left\} name=\$\{self \? null : p\.name \|\| '博士'\} \/>/);
});
test('the phase capsule carries the official runner tag ×N in 联防 only; the tag reads the uncapped number', () => {
const tagOf = (v) => [...walk(v)].find((n) => n.type === MissTag);
const unite = PhaseCapsule({ pub: { phase: PHASE.UNITE }, hud: { killed: 3, total: 17 }, miss: 14 });
assert.equal(tagOf(unite)?.props.n, 14);
assert.equal(tagOf(PhaseCapsule({ pub: { phase: PHASE.UNITE }, hud: { killed: 3, total: 17 }, miss: null })), undefined, 'a helper / observer');
assert.equal(tagOf(PhaseCapsule({ pub: { phase: PHASE.COMBAT }, hud: { killed: 3, total: 17 }, miss: 5 })), undefined);
const tag = MissTag({ n: 14 });
assert.ok(hasClass(tag, 'misstag'));
assert.match(textOf(tag), /14$/);
assert.match(tag.props.title, /还剩 14 个/);
assert.ok(hasClass(MissTag({ n: 0 }), 'is-clear'));
// wiring: the top bar hands the live value to the capsule; the game screen feeds liveLp with the leaker's count
assert.match(read('public/js/ui/hud.js'), /PhaseCapsule} pub=\$\{pub\} hud=\$\{hud\} miss=/);
assert.match(read('public/js/screens/game.js'), /uniteLeft: leaker \? uniteRemaining\(localLeft, meP\?\.uniteLeft\) : null/);
assert.match(read('public/js/ui/teamPanel.js'), /<\$\{MissTag\} n=\$\{lp\.left\} name=/);
});
});
// ---- the browser runner on a real 联防 spec --------------------------------------------------------------------------
const DS = new DataSource(DATA, null);
const LEAKS = 14;
/** Real battles whose 联防 helpers strike down a leaked enemy every 6 game seconds (from 4 s on). */
class SlowHelpers extends Battle {
constructor(opts) {
super(opts);
if (this.kind !== 'unite') return;
let next = 4;
this.on('tick', () => {
if (this.time < next) return;
const e = this.enemies.find((x) => x.alive && x.counted);
if (!e) return;
this.kill(e, null);
next += 6;
});
}
}
/** The b.start frames of a 联防 where p_1 let LEAKS 源石虫 through and p_0 helps (client-side combat, no clients). */
function uniteStarts() {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 710, captureFrames: false, clientCombat: true, clients: false });
h.m.start();
h.drive(() => h.m.phase === PHASE.PREP && h.m.round === 1, { ready: false });
const m = h.m;
m.phase = PHASE.COMBAT;
m.fields = [];
m.lastResults = new Map([
['p_0', { leaked: [], perfect: true, coins: 0, layerGains: {}, killed: 5, total: 5, unitsEnd: [] }],
['p_1', { leaked: Array.from({ length: LEAKS }, () => ({ enemyKey: 'enemy_1007_slime', mods: null, lpr: 1, sourcePlayerId: 'p_1', tag: null, counted: true })), perfect: false, coins: 0, layerGains: {}, killed: 0, total: LEAKS, unitsEnd: [] }],
]);
m._afterCombat();
assert.equal(m.phase, PHASE.UNITE);
const out = { helper: h.lastTo('p_0', 'b.start'), leaker: h.lastTo('p_1', 'b.start') };
m.dispose();
return out;
}
function rig() {
let t = 1000;
const frames = [];
const sent = [];
const handlers = new Map();
const net = {
on(ty, fn) { if (!handlers.has(ty)) handlers.set(ty, new Set()); handlers.get(ty).add(fn); return () => handlers.get(ty).delete(fn); },
emit(ty, msg) { for (const fn of handlers.get(ty) || []) fn({ t: ty, ...msg }); },
send(ty, fields) { const msg = { ...fields, t: ty }; assert.equal(validateC2S(msg), null, `invalid ${ty}`); sent.push(msg); return true; },
request(ty, fields) { const msg = { ...fields, t: ty, rid: 1 }; assert.equal(validateC2S(msg), null, `invalid ${ty}`); sent.push(msg); return Promise.resolve({ t: 'ok' }); },
};
const store = createStore(initialState);
const spec = { ...specMod, createBattleFromSpec: (s, ds, o = {}) => specMod.createBattleFromSpec(s, ds, { ...o, BattleClass: SlowHelpers }) };
const runner = createBattleRunner({
net, store, doc: { hidden: false, addEventListener() {} }, now: () => t,
raf: (fn) => { frames.push(fn); return frames.length; }, caf: () => {},
setInterval: () => 1, clearInterval: () => {},
loadSim: async () => ({ spec, ds: DS }),
logger: { error() {}, warn() {}, info() {}, debug() {} },
});
return {
runner, net, store, sent,
advance(ms, step = 1000 / 60) { const end = t + ms; while (t < end) { t = Math.min(end, t + step); for (const fn of frames.splice(0)) fn(t); } },
async settle() { for (let i = 0; i < 50; i++) { await new Promise((res) => setImmediate(res)); for (const fn of frames.splice(0)) fn(t); } },
};
}
test('#7 runner: a 联防 replica publishes state().uniteLeft as it falls; the authority reports b.progress `left`', async () => {
const { helper, leaker } = uniteStarts();
assert.equal(helper.kind, 'unite');
assert.equal(helper.authoritative, true, 'the helper runs the 联防');
assert.equal(leaker.authoritative, false, 'the leaker watches a display replica');
// the leaker's browser: a display replica — its top bar reads state().uniteLeft (store.match.battle)
const L = rig();
L.net.emit('b.start', leaker);
await L.settle();
const seen = [];
for (let i = 0; i < 160; i++) {
L.advance(500);
const s = L.store.get().match.battle;
if (s && s.uniteLeft) seen.push(s.uniteLeft.p_1 ?? 0);
}
assert.equal(seen[0], LEAKS, 'starts at every enemy that got through');
for (let i = 1; i < seen.length; i++) assert.ok(seen[i] <= seen[i - 1], '源石虫 never split: it only falls here');
assert.ok(new Set(seen).size >= 4 && seen[seen.length - 1] < 10, `falls live: ${[...new Set(seen)].join(' → ')}`);
assert.equal(L.sent.filter((x) => x.t === 'b.progress').length, 0, 'a replica never reports');
L.runner.dispose();
// the helper's browser: the authority's reports carry the counts
const H = rig();
H.net.emit('b.start', helper);
await H.settle();
H.advance(20_000);
const prog = H.sent.filter((x) => x.t === 'b.progress');
assert.ok(prog.length >= 15, `${prog.length} reports`);
assert.ok(prog.every((x) => x.left && Object.keys(x.left).every((k) => k === 'p_1')), 'left: the leaker only');
assert.ok(prog[prog.length - 1].left.p_1 < prog[0].left.p_1, 'the reported count falls');
H.runner.dispose();
});
+117
View File
@@ -0,0 +1,117 @@
// User playtest #6 items 1 and 2, the UI side: a summon piece in the hand / on the board says how it takes the field
// (ui/detailPanel.js summonDeployHint / TokenDetail) — 赫默's 医疗探机 once at the battle start, then each time her skill
// fires; 凯瑟琳's 支援装置 with the board — and shows what it does (the device's talent 定向支援信号, the doll's token
// skill), with the numbers of the owner's own variant (a golden owner's summon is stronger: resolveDetail finds the
// owner by `ownerUid`). One switch, shared/constants.js SKILL_SUMMON_START_DEPLOY (the PRTS start-of-battle deploy,
// settled by the user after playtest #6), drives the sim and the hint; the guide (docs/PLAYING.md §4) and docs/SIM.md
// must say the same. The rules themselves:
// test/match/playtest6_summons.test.js (prep) and test/content/playtest6_summons.test.js (battle).
import { test } 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)), '../..');
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 { TokenDetail, summonDeployHint, resolveDetail, tokenVariantFor } = await import('../../public/js/ui/detailPanel.js');
const { indexPieces } = await import('../../public/js/ui/gameLogic.js');
const { SKILL_SUMMON_START_DEPLOY } = await import('../../shared/constants.js');
const simTokens = await import('../../server/sim/content/tokens.js');
const { data } = await import('../../public/js/data.js');
const TOKENS = JSON.parse(readFileSync(path.join(ROOT, 'data', 'tokens.json'), 'utf8'));
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' && ['Section', 'Stat', 'LiveTag'].includes(v.type.name)) return textOf(v.type(v.props));
return typeof v === 'object' ? textOf(v.props?.children) : '';
};
test('summonDeployHint: skill summons at the start and with the skill, talent summons with the board, other tokens say nothing', () => {
assert.equal(SKILL_SUMMON_START_DEPLOY, true, 'the user\'s answer after playtest #6: the PRTS start deploy');
assert.match(summonDeployHint(TOKENS.token_10000_silent_healrb), /^作战开始时在摆放的位置部署一次.*发动技能时/);
assert.match(summonDeployHint(TOKENS.token_10006_vodfox_doll), /^作战开始时在摆放的位置部署一次.*发动技能时/);
for (const id of ['token_10041_cathy_catsld', 'token_10028_vigil_wolf', 'token_10017_skadi2_dedant', 'token_10030_mlyss_wtrman']) {
assert.match(summonDeployHint(TOKENS[id]), /作战开始时/, id);
}
assert.equal(summonDeployHint(TOKENS.token_10056_angel2_target), null, '投递坐标 is no hand piece');
assert.equal(summonDeployHint(TOKENS.enemy_9012_acloon), null);
});
test('one switch for the start-of-battle deploy: the sim and the hint read shared/constants.js SKILL_SUMMON_START_DEPLOY', () => {
assert.equal(simTokens.SKILL_SUMMON_START_DEPLOY, SKILL_SUMMON_START_DEPLOY, 'the sim re-exports the shared switch');
const drone = TOKENS.token_10000_silent_healrb;
assert.equal(summonDeployHint(drone), summonDeployHint(drone, SKILL_SUMMON_START_DEPLOY), 'the hint defaults to it');
// off (the playtest #4 reading): only with the skill; on (PRTS, the default): once at the start, then with each skill
assert.match(summonDeployHint(drone, false), /^所属干员发动技能时才在摆放的位置出现/);
assert.match(summonDeployHint(drone, true), /^作战开始时在摆放的位置部署一次,之后所属干员每次发动技能时再次出现/);
assert.match(summonDeployHint(TOKENS.token_10006_vodfox_doll, true), /作战开始时.*部署一次/);
// talent summons deploy with the board either way
assert.equal(summonDeployHint(TOKENS.token_10041_cathy_catsld, true), summonDeployHint(TOKENS.token_10041_cathy_catsld, false));
});
test('the guide and SIM.md state the start-of-battle rule the switch selects (flip it ⇒ update both)', () => {
const playing = readFileSync(path.join(ROOT, 'docs', 'PLAYING.md'), 'utf8');
const sim = readFileSync(path.join(ROOT, 'docs', 'SIM.md'), 'utf8');
if (SKILL_SUMMON_START_DEPLOY) {
assert.match(playing, /医疗探机、诅咒娃娃[^\n]*开战时[^\n]*部署一次/, 'PLAYING.md §4: the drone / doll also deploy once at the start');
assert.match(sim, /deploys once at the battle start/, 'SIM.md token pieces: the PRTS start deploy');
} else {
assert.match(playing, /医疗探机、诅咒娃娃要等所属干员发动技能时才在摆放的位置出现/, 'PLAYING.md §4: only with the skill');
assert.match(sim, /a skill's summon[^.]*waits on its tile and takes the field there\s+each time the skill gives one/, 'SIM.md token pieces');
}
// the exception for unplaced summons is stated for the summons it holds for (test/content/playtest6_summons)
assert.match(playing, /狼群、流形(战术家的援军)没有摆放时会在战术点出现/);
assert.ok(!/没有摆放的召唤物不会出现/.test(playing), 'no blanket "unplaced summons never appear"');
});
test('the summon card: 凯瑟琳\'s device shows the hint and its shield talent; 巫恋\'s doll its token skill', async () => {
await data.loadAll('tokens', 'assets');
const dev = textOf(TokenDetail({ token: TOKENS.token_10041_cathy_catsld, piece: { kind: 'token', id: 'token_10041_cathy_catsld', count: 2 } }));
assert.match(dev, /作战开始时在摆放的位置部署/);
assert.match(dev, /定向支援信号/);
assert.match(dev, /屏障/);
const drone = textOf(TokenDetail({ token: TOKENS.token_10000_silent_healrb, piece: { kind: 'token', id: 'token_10000_silent_healrb', count: 1 } }));
assert.match(drone, /作战开始时在摆放的位置部署一次,之后所属干员每次发动技能时再次出现/);
assert.ok(!/自我销毁/.test(drone), 'the withdraw skill is not listed');
const doll = textOf(TokenDetail({ token: TOKENS.token_10006_vodfox_doll, piece: { kind: 'token', id: 'token_10006_vodfox_doll', count: 1 } }));
assert.match(doll, /攻击力和防御力-25%/);
// a summon on the battlefield (no prep piece) shows no prep hint
assert.ok(!/摆放的位置/.test(textOf(TokenDetail({ token: TOKENS.token_10041_cathy_catsld, piece: null }))));
});
test('a golden owner\'s summon card shows the golden variant: 精锐 巫恋\'s doll −30%, 精锐 赫默\'s drone ATK 114', async () => {
await data.loadAll('tokens', 'assets');
assert.equal(tokenVariantFor(TOKENS.token_10000_silent_healrb, 'chess_char_2_02_b').stats.atk, 114);
assert.equal(tokenVariantFor(TOKENS.token_10000_silent_healrb, 'chess_char_2_02_a').stats.atk, 85);
assert.equal(tokenVariantFor(TOKENS.token_10000_silent_healrb, null).stats.atk, 85, 'unknown owner: the first variant');
// the private view: golden 巫恋 on the board, her doll card in the hand (resolveDetail reads the owner by ownerUid)
const priv = {
board: [{ uid: 7, kind: 'chess', id: 'chess_char_3_15_b', row: 10, col: 4 }, { uid: 9, kind: 'chess', id: 'chess_char_2_02_b', row: 10, col: 5 }],
hand: [{ uid: 8, kind: 'token', id: 'token_10006_vodfox_doll', ownerUid: 7, count: 1 }, { uid: 10, kind: 'token', id: 'token_10000_silent_healrb', ownerUid: 9, count: 1 }],
temp: [],
};
const pieces = indexPieces(priv);
const doll = resolveDetail({ kind: 'piece', uid: 8 }, pieces);
assert.equal(doll.ownerId, 'chess_char_3_15_b');
const dollText = textOf(TokenDetail(doll));
assert.match(dollText, /攻击力和防御力-30%/);
assert.ok(!/-25%/.test(dollText));
const drone = resolveDetail({ kind: 'piece', uid: 10 }, pieces);
assert.match(textOf(TokenDetail(drone)), /114/);
// in battle the own summon unit carries its piece uid: the same owner variant
const unit = resolveDetail({ kind: 'unit', unit: { id: 3, side: 'ally', defId: 'token_10006_vodfox_doll', uid: 8 } }, pieces);
assert.equal(unit.type, 'token');
assert.equal(unit.ownerId, 'chess_char_3_15_b');
});