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

+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; \}/);
});
});