feedback1 WB: special-refresh offers drawn as what they hold (凯瑟琳's three items as item cards)

Player report #6: 凯瑟琳's 定向投放 offer after a level-up showed three nameless
operator cards (the raw item ids) under the promotion reward's 晋升奖励 header.
The shop bar now draws each offer slot by its kind (item slots as free item
cards) and names the offer: m.private shop.rewardOffer carries source
('merge' | 'special') and label (the strategy's effect name, the item's name
or the 特质's operator — effectsMeta.offerLabel); the reminder pill says
<label>待选择. Same path for 娜仁图亚, 寻呼模块, 信标 and 松果.
This commit is contained in:
sganggs committed 2026-10-03 01:48:29 +08:00
1 parent 5e6fe19a70
commit 49c0ebcf4d
10 files changed
+427 -39

No files matched your search

+142
View File
@@ -0,0 +1,142 @@
// Real-server browser E2E of player report #6 after 0.1.0: "凯瑟琳策略升级刷新的三个装备显示异常". A solo 险境 prep with the
// strategy 凯瑟琳 (【定向投放】"每次升级调度中心时,在调度中心刷新随机3件装备,可以选择并获得其中1件"; test/e2e/fastServer.mjs
// SP_START_ROUND 2: a starter kit + 20 funds) is driven with real input: the LEVEL card is tapped twice (确认升级) and the
// three free items replace the bar's operator cards — each drawn as an item card (icon, name, tier, description, FREE),
// under a 定向投放 header (not 晋升奖励 / PROMOTION), with the item's detail on the first tap; the second tap takes it into
// the hand and the bar shows the shop again. Desktop 1920×1080 and the phones 844×390 / 756×366 (touch). Zero console
// errors. Screenshots: test/e2e/out/fb1-offer-*.png.
//
// SP_E2E=1 node --test test/ui/feedback1-offers.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'));
async function soloToPrep(c, bandName) {
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', bandName);
await c.click('.draft-detail__btns .btn--primary', '确认选择');
await c.waitFor((x) => x.phase === 'PREP' && !x.ready, 'prep', 60000);
await sleep(1800);
}
/** The offer as drawn: header texts, the cards (kind, name, description, art, free tag) and their boxes. */
function offerDom(c) {
return c.page.evaluate(() => {
const box = (el) => { if (!el) return null; const r = el.getBoundingClientRect(); return { l: r.left, t: r.top, r: r.right, b: r.bottom, w: r.width, h: r.height }; };
const root = document.querySelector('.shopbar__reward');
if (!root) return null;
const cards = [...root.querySelectorAll('.scard')].map((el) => ({
item: el.classList.contains('scard--item'),
name: el.querySelector('.scard__name')?.textContent.trim() || '',
desc: el.querySelector('.scard__idesc')?.textContent.trim() || '',
icon: !!el.querySelector('.scard__itemart img'),
free: !!el.querySelector('.scard__free'),
tier: el.querySelector('.tierchip, [class*="tier"]')?.textContent.trim() || '',
box: box(el),
clipped: el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1,
}));
return {
title: root.querySelector('.rwtag__title')?.textContent.trim() || '',
micro: root.querySelector('.rwtag__micro')?.textContent.trim() || '',
sub: root.querySelector('.rwtag__sub')?.textContent.trim() || '',
cards, root: box(root), vw: innerWidth, vh: innerHeight,
};
});
}
const privOf = (c) => c.page.evaluate(() => globalThis.__SP__.store.get().match.private);
describe('player report #6: 凯瑟琳\'s three free items after a level-up (real server, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome + assets) to run' }, () => {
test('the level-up offer shows three item cards (desktop and phones); a pick puts the item into the hand', { timeout: 240000 }, async () => {
const puppeteer = (await import('puppeteer-core')).default;
const srv = await startRealServer({ fast: { timerScale: 0.5, combatSpeed: 4, startRound: 2 } });
const c = new Client(puppeteer, srv.base, 'cathy', { prefix: 'fb1-offer' });
try {
await soloToPrep(c, '凯瑟琳');
assert.equal((await privOf(c)).bandId, 'band_cathy', 'strategy 凯瑟琳');
// LEVEL: first tap arms 确认升级, the second upgrades
await c.click('.lvcard');
await sleep(250);
await c.click('.lvcard');
await c.page.waitForFunction(() => !!globalThis.__SP__.store.get().match.private?.shop?.rewardOffer, { timeout: 8000 });
await sleep(600);
const priv = await privOf(c);
const offer = priv.shop.rewardOffer;
assert.equal(offer.slots.length, 3, 'three items');
assert.ok(offer.slots.every((s) => s.kind === 'item' && s.price === 0), JSON.stringify(offer));
const names = await c.page.evaluate((ids) => ids.map((id) => globalThis.__SP__.data.lookup('items', id)?.name), offer.slots.map((s) => s.id));
await c.shot('desktop');
const dom = await offerDom(c);
assert.ok(dom, 'the offer is in the bar');
assert.equal(dom.title, '定向投放', `the header names the strategy (${dom.title} / ${dom.micro})`);
assert.match(dom.sub, /免费选择 1 件/);
assert.deepEqual(dom.cards.map((x) => x.name), names, 'the item names');
for (const card of dom.cards) {
assert.ok(card.item, `${card.name}: drawn as an item card`);
assert.ok(card.icon, `${card.name}: the item icon`);
assert.ok(card.desc.length > 2, `${card.name}: the description`);
assert.ok(card.free, `${card.name}: FREE`);
}
// phones (touch, landscape): the same cards inside the viewport, none overlapping another
const browserUa = await c.browser.userAgent();
for (const [w, h] of [[844, 390], [756, 366]]) {
await c.page.emulate({ viewport: { width: w, height: h, deviceScaleFactor: 2, isMobile: true, hasTouch: true, isLandscape: true }, userAgent: browserUa });
await c.page.waitForFunction(() => !!document.querySelector('.shopbar__reward .scard'), { timeout: 30000 });
await sleep(900);
await c.page.screenshot({ path: path.join(ROOT, 'test/e2e/out', `fb1-offer-${w}x${h}.png`) });
const d = await offerDom(c);
assert.ok(d && d.cards.length === 3, `${w}×${h}: three cards`);
for (const card of d.cards) {
assert.ok(card.item && card.icon, `${w}×${h} ${card.name}: an item card with its icon`);
assert.ok(card.box.l >= 0 && card.box.r <= w + 0.5 && card.box.t >= 0 && card.box.b <= h + 0.5, `${w}×${h} ${card.name}: inside the viewport ${JSON.stringify(card.box)}`);
}
for (let i = 1; i < d.cards.length; i++) assert.ok(d.cards[i].box.l >= d.cards[i - 1].box.r - 0.5, `${w}×${h}: cards ${i - 1} / ${i} do not overlap`);
}
await c.page.emulate({ viewport: { width: 1920, height: 1080, deviceScaleFactor: 1, isMobile: false, hasTouch: false }, userAgent: browserUa });
// switching touch emulation reloads the page: the session resumes with the offer still queued (re-hook the intents)
await c.page.waitForFunction(() => !!globalThis.__SP__?.store.get().match.private?.shop?.rewardOffer && !!document.querySelector('.shopbar__reward'), { timeout: 30000 });
await sleep(900);
await c.hookRequests();
// first tap: armed + the item's detail; second tap: taken
const want = names[1];
await c.click('.shopbar__reward .scard', want);
await sleep(400);
const armed = await c.page.evaluate(() => ({
armed: document.querySelector('.shopbar__reward .scard.is-armed .scard__name')?.textContent.trim() || null,
strip: document.querySelector('.shopbar__reward .scard.is-armed .scard__confirm')?.textContent.trim() || null,
detail: document.querySelector('.dpanel')?.textContent || '',
}));
assert.equal(armed.armed, want, 'the tapped card is armed');
assert.match(armed.strip || '', /确认选择/);
assert.ok(armed.detail.includes(want), 'the detail card shows the item');
await c.shot('armed');
await c.click('.shopbar__reward .scard.is-armed');
await c.page.waitForFunction(() => !globalThis.__SP__.store.get().match.private?.shop?.rewardOffer, { timeout: 8000 });
const after = await privOf(c);
const got = await c.page.evaluate((hand) => hand.filter((p) => p && p.kind === 'item').map((p) => globalThis.__SP__.data.lookup('items', p.id)?.name), after.hand);
assert.ok(got.includes(want), `the item is in the hand (${got.join(', ')})`);
assert.equal((await c.requests('g.reward')).length, 1, 'one g.reward');
await sleep(300);
assert.ok(await c.exists('.shopbar__cards'), 'the shop is back');
await c.shot('after');
assert.deepEqual(problemsOf([c]), []);
} finally {
await c.close();
await srv.stop();
}
});
});
+191
View File
@@ -0,0 +1,191 @@
// Player feedback after 0.1.0, report #6 (workstream WB): "凯瑟琳策略升级刷新的三个装备显示异常". The strategy 凯瑟琳
// (【定向投放】"每次升级调度中心时,在调度中心刷新随机3件装备,可以选择并获得其中1件") queues a free pick-one offer of three
// items; the shop bar drew every offer slot as an operator card (ChessCard on an item id: no art, the raw id as the name,
// tier I, no description) under the promotion reward's 晋升奖励 / PROMOTION header, and the reminder pill said
// 晋升奖励待选择. Now: m.private shop.rewardOffer says what made the offer (`source` 'merge' | 'special', `label` = the
// effect's name — effectsMeta.offerLabel), an item slot is drawn as an item card (FREE) and the header / pill name the
// offer (gameLogic.offerHeader). The same path serves 娜仁图亚 见者有份 (two items), 寻呼模块 / 信标 (operators, an item's
// name) and 松果's 特质. Real match paths for the server side; the bar's offer cards rendered as vnodes.
// The browser check (desktop + phones, real input) is test/ui/feedback1-offers.e2e.test.js (SP_E2E=1).
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)), '../..');
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 { offerHeader } = await import('../../public/js/ui/gameLogic.js');
const { RewardCards, ItemCard, ChessCard } = await import('../../public/js/ui/shopBar.js');
const { RewardOverlay } = await import('../../public/js/ui/rewardOverlay.js');
const { data } = await import('../../public/js/data.js');
const { DATA, makeMatch, give, giveItem, chessOfTier, legalTileFor } = await import('../match/harness.js');
const { offerLabel } = await import('../../server/match/effectsMeta.js');
await data.loadAll('chess', 'bonds', 'items', 'assets');
const OK = { ok: true };
const PAGER = 'chess_item_4_01_e_a'; // 寻呼模块
/** A solo prep with an empty board and hand, plenty of funds, the given strategy. */
function prep(band, seed = 61) {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed, fake: true }).start();
h.toPrep(1);
const m = h.m;
const ps = h.ps('p_0');
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
ps.offers.length = 0;
ps.funds = 60;
ps.bandId = band;
ps.recompute();
return { h, m, ps };
}
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);
}
describe('#6 the server names its offers (m.private shop.rewardOffer source / label)', () => {
test('凯瑟琳 (the players\' case): a level-up offers three different items, free, labelled 定向投放; a pick takes one into the hand', () => {
const { m, ps } = prep('band_cathy');
ps.shop.upgradePrice = 0;
assert.deepEqual(m.handle('p_0', { t: 'g.levelUp' }), OK);
const offer = ps.privateView().shop.rewardOffer;
assert.ok(offer, 'an offer after the level-up');
assert.equal(offer.source, 'special');
assert.equal(offer.label, '定向投放', 'the strategy\'s effect name');
assert.equal(offer.tier, null, 'items carry their own tiers');
assert.equal(offer.slots.length, 3);
assert.equal(new Set(offer.slots.map((s) => s.id)).size, 3, 'three different items');
for (const s of offer.slots) {
assert.equal(s.kind, 'item');
assert.equal(s.price, 0);
assert.ok(DATA.items[s.id] && DATA.items[s.id].itemType === 'EQUIP' && DATA.items[s.id].tier <= ps.shop.level, s.id);
}
const want = offer.slots[1].id;
const funds = ps.funds;
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 1 }), OK);
assert.equal(ps.funds, funds, 'free');
assert.ok([...ps.hand, ...ps.temp].some((p) => p && p.kind === 'item' && (p.id === want || p.id === DATA.items[want].goldenId)), 'the item is owned');
assert.equal(ps.privateView().shop.rewardOffer, null, 'the offer is gone');
m.dispose();
});
test('two level-ups in one prep queue two offers: the second shows after the first pick, never stale', () => {
const { m, ps } = prep('band_cathy', 62);
for (let i = 0; i < 2; i++) { ps.shop.upgradePrice = 0; assert.deepEqual(m.handle('p_0', { t: 'g.levelUp' }), OK); }
assert.equal(ps.offers.length, 2, 'two offers queued');
assert.equal(ps.privateView().shop.rewardOffer.slots.length, 3, 'the first one is shown');
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 0 }), OK);
const second = ps.privateView().shop.rewardOffer;
assert.ok(second && second.label === '定向投放' && second.slots.length === 3 && second.slots.every((s) => !s.sold), 'the second offer, all free to pick');
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 2 }), OK);
assert.equal(ps.privateView().shop.rewardOffer, null);
m.dispose();
});
test('娜仁图亚 见者有份 (two items), 寻呼模块 (operators of its bond) and the promotion reward', () => {
const { m, ps } = prep('band_narant', 63);
ps.shop.level = 3;
m.round = 2;
m.dispatch(ps, 'onRoundStart', { round: 2 });
let offer = ps.privateView().shop.rewardOffer;
assert.ok(offer && offer.source === 'special' && offer.label === '见者有份', JSON.stringify(offer));
assert.equal(offer.slots.length, 2);
assert.ok(offer.slots.every((s) => s.kind === 'item'));
ps.offers.length = 0;
// 寻呼模块: equipped on an operator — three operators sharing a bond, labelled with the item
const id = chessOfTier(1).find((x) => m.pool.has(x) && m.pool.left(x) > 0);
const op = give(m, ps, id, 'board', legalTileFor(m, ps, id));
const pager = giveItem(m, ps, PAGER);
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: pager.uid, targetUid: op.uid }), OK);
offer = ps.privateView().shop.rewardOffer;
assert.ok(offer && offer.source === 'special' && offer.label === '寻呼模块', JSON.stringify(offer));
assert.ok(offer.slots.length >= 1 && offer.slots.every((s) => s.kind === 'chess'));
ps.offers.length = 0;
// the promotion reward
ps.pushRewardOffer('merge');
offer = ps.privateView().shop.rewardOffer;
assert.equal(offer.source, 'merge');
assert.equal(offer.label, null);
assert.ok(Number.isInteger(offer.tier));
m.dispose();
});
test('offerLabel: a strategy → its effect name, an item → its name, a 特质 → its operator, anything else → null', () => {
const { m } = prep(null, 64);
const gd = m.gd;
assert.equal(offerLabel(gd, { kind: 'band', bandId: 'band_cathy' }), '定向投放');
assert.equal(offerLabel(gd, { kind: 'band', band: gd.band('band_narant') }), '见者有份');
assert.equal(offerLabel(gd, { kind: 'item', piece: { id: 'chess_item_5_04_e_a' } }), '信标');
const chess = chessOfTier(2)[0];
assert.equal(offerLabel(gd, { kind: 'garrison', piece: { id: chess } }), DATA.chess[chess].name);
assert.equal(offerLabel(gd, { kind: 'global', key: 'global:x' }), null);
assert.equal(offerLabel(gd, null), null);
m.dispose();
});
});
describe('#6 the shop bar draws the offer by its slots and names it', () => {
const ITEMS = Object.values(DATA.items).filter((it) => it.itemType === 'EQUIP' && !it.isGolden).slice(0, 3).map((it) => it.id);
const cathy = { tier: null, source: 'special', label: '定向投放', slots: ITEMS.map((id) => ({ kind: 'item', id, price: 0, sold: false })) };
const merge = { tier: 3, source: 'merge', label: null, slots: chessOfTier(3).slice(0, 3).map((id) => ({ kind: 'chess', id, price: 0, sold: false })) };
test('offerHeader: 晋升奖励 for the promotion reward, the label for a special refresh (items: 免费选择 1 件)', () => {
assert.deepEqual(offerHeader(merge), { title: '晋升奖励', micro: 'PROMOTION', sub: '免费选择 1 名', icon: 'crown', items: false, pill: '晋升奖励待选择' });
assert.deepEqual(offerHeader(cathy), { title: '定向投放', micro: 'SPECIAL', sub: '免费选择 1 件', icon: 'refresh', items: true, pill: '定向投放待选择' });
assert.equal(offerHeader({ ...merge, source: 'special', label: '寻呼模块' }).title, '寻呼模块');
assert.equal(offerHeader({ ...merge, source: 'special', label: '寻呼模块' }).sub, '免费选择 1 名');
assert.equal(offerHeader({ ...cathy, label: null }).title, '装备补给', 'an unnamed item offer');
assert.equal(offerHeader({ tier: 2, slots: merge.slots }).title, '晋升奖励', 'an offer view without source (older server) is the promotion reward');
});
test('RewardCards: item slots → ItemCard (FREE), chess slots → ChessCard; the header says 定向投放', () => {
const priv = { alive: true, funds: 0, hand: Array(10).fill(null), temp: Array(5).fill(null), board: [], shop: { rewardOffer: cathy } };
const v = RewardCards({ offer: cathy, priv, editable: true, onPick() {}, onDetail() {}, onLater() {}, armed: null, onTap() {} });
const nodes = [...walk(v)];
const cards = nodes.filter((n) => n.type === ItemCard || n.type === ChessCard);
assert.equal(cards.length, 3);
for (const c of cards) {
assert.equal(c.type, ItemCard, 'an item card');
assert.equal(c.props.free, true, 'FREE');
assert.equal(c.props.slot.price, 0);
assert.equal(c.props.reason, null, 'pickable with 0 funds');
}
const title = nodes.find((n) => typeof n.props?.class === 'string' && n.props.class.includes('rwtag__title'));
assert.equal(String(title.props.children), '定向投放');
// the item card itself: icon, name, description, FREE (no price hex)
const card = ItemCard({ ...cards[0].props });
const inner = [...walk(card.type === 'button' || card.props?.class?.includes?.('scard') ? card : card.props.children)];
const texts = inner.flatMap((n) => (typeof n.props?.children === 'string' ? [n.props.children] : []));
assert.ok(texts.includes(DATA.items[ITEMS[0]].name), `the name (${texts.join(' | ')})`);
assert.ok(inner.some((n) => typeof n.props?.class === 'string' && n.props.class.includes('scard__itemart')), 'the item art');
// the promotion reward still draws operator cards under 晋升奖励
const vm = RewardCards({ offer: merge, priv: { ...priv, shop: { rewardOffer: merge } }, editable: true, onPick() {}, onDetail() {}, onLater() {}, armed: null, onTap() {} });
const mcards = [...walk(vm)].filter((n) => n.type === ItemCard || n.type === ChessCard);
assert.ok(mcards.length === 3 && mcards.every((c) => c.type === ChessCard));
assert.equal(String([...walk(vm)].find((n) => typeof n.props?.class === 'string' && n.props.class.includes('rwtag__title')).props.children), '晋升奖励');
});
test('the reminder pill names the offer (定向投放待选择), with a tier chip only for operator offers', () => {
const pill = RewardOverlay({ priv: { shop: { rewardOffer: cathy } }, minimized: true, onMinimize() {} });
const texts = [...walk(pill)].flatMap((n) => (typeof n.props?.children === 'string' ? [n.props.children] : []));
assert.ok(texts.includes('定向投放待选择'), texts.join(' | '));
const mp = RewardOverlay({ priv: { shop: { rewardOffer: merge } }, minimized: true, onMinimize() {} });
assert.ok([...walk(mp)].flatMap((n) => (typeof n.props?.children === 'string' ? [n.props.children] : [])).includes('晋升奖励待选择'));
});
});