feedback1 gaps: tests for the pairings, the 调和 row and the band pick
test/ui/feedback1-gaps.test.js: the 14-bond list from the data (checked against the official talent when the cache is there), 本局禁用 in 标准, the wearer highlight, the cards, the 调和 row in the own and a teammate's popup. test/match/feedback1-gaps.test.js: the harmony mark in the states and views, bandBondIds, botPickBand over seeds 1-200 solo and co-op in 标准 and 绝境, the fallback, the docs. test/ui/feedback1-gaps.e2e.test.js (SP_E2E=1): the cards and the popup in headless Chrome at 1920x1080, 844x390 and 756x366.
This commit is contained in:
1 parent
0fb1a17e7e
commit
c783fe424e
4 files changed
+673
-2
No files matched your search
@@ -0,0 +1,228 @@
|
||||
// Gaps found while triaging GitHub issues #1 / #8 (DESIGN §21.26) — the server side.
|
||||
// 2. 调和's +1: computeBonds marks a core bond's state whose count holds 调和's +1 (`harmony: 1`); the views send it
|
||||
// (m.private bonds, m.public players[].bonds — a teammate's strip and popup), the battle input does not. The popup
|
||||
// that reads it: test/ui/feedback1-gaps.test.js.
|
||||
// 3. Bots never pick a strategy built around a bond the mode switches off: GameData.bandBondIds reads the tie from the
|
||||
// band's own data (its <bond> names, its blackboards' bond ids and bond pools); botPickBand gives such a band weight 0
|
||||
// — in 标准 潘格尼尼 (<拉特兰>, pool_char_later), 克莱门莎 (<阿戈尔>), 玛恩纳 (<卡西米尔>). Before: 5 of 200 solo picks and
|
||||
// 23 of 800 co-op picks (seeds 1–200) were one of them; the other modes keep exactly the earlier picks.
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
import { GameData } from '../../server/match/gamedata.js';
|
||||
import { computeBonds, bondList, bondSnapshot, bondsWithGains, HARMONY_BOND } from '../../server/match/bondsMeta.js';
|
||||
import { botPickBand } from '../../server/match/bot.js';
|
||||
import { tileKey } from '../../server/match/board.js';
|
||||
import { DATA, makeMatch, give, legalTileFor, checkInvariants } from './harness.js';
|
||||
|
||||
const MLYSS = 'chess_char_6_11_a'; // 缪尔赛思 (调和)
|
||||
const members = (bond, n) => Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && c.bonds.includes(bond) && !c.bonds.includes(HARMONY_BOND)).map((c) => c.chessId).sort().slice(0, n);
|
||||
let uid = 1;
|
||||
const piece = (id) => ({ uid: uid++, kind: 'chess', id, items: [] });
|
||||
const state = (board, hand = []) => {
|
||||
const b = new Map();
|
||||
board.forEach((p, i) => b.set(`${9 + (i % 4)},${2 + Math.floor(i / 4)}`, p));
|
||||
const h = new Array(10).fill(null);
|
||||
hand.forEach((p, i) => { h[i] = p; });
|
||||
return { board: b, hand: h, layers: {} };
|
||||
};
|
||||
|
||||
describe('§21.26 2 — 调和\'s +1 in the bond states and views', () => {
|
||||
const gd = new GameData(DATA, 'mode_multi_normal');
|
||||
|
||||
test('computeBonds: `harmony: 1` exactly on the core bonds whose count holds the +1 — never an add-on bond, a core bond without a member, or 调和 itself', () => {
|
||||
const yan = members('yanShip', 2);
|
||||
const preci = members('preciShip', 1);
|
||||
const s = computeBonds(gd, state([piece(MLYSS), ...yan.map(piece), ...preci.map(piece)]));
|
||||
assert.equal(s[HARMONY_BOND].active, true);
|
||||
assert.deepEqual(s.yanShip, { count: 3, active: true, tier: 1, layers: 0, harmony: 1 });
|
||||
for (const [id, b] of Object.entries(s)) {
|
||||
if (id === 'yanShip') continue;
|
||||
assert.ok(!('harmony' in b), `${id}: no harmony (core with members: ${DATA.bonds[id].isCore && b.count > 0})`);
|
||||
}
|
||||
// the same board without 调和: no mark anywhere, the count is the real one
|
||||
const bare = computeBonds(gd, state([...yan.map(piece), ...preci.map(piece)]));
|
||||
assert.deepEqual(bare.yanShip, { count: 2, active: false, tier: 0, layers: 0 });
|
||||
assert.ok(Object.values(bare).every((b) => !('harmony' in b)));
|
||||
// 调和 on the bench does nothing (BOARD)
|
||||
const bench = computeBonds(gd, state(yan.map(piece), [piece(MLYSS)]));
|
||||
assert.equal(bench.yanShip.count, 2);
|
||||
assert.ok(!('harmony' in bench.yanShip));
|
||||
});
|
||||
|
||||
test('bondList carries it (m.private and m.public), bondsWithGains keeps it, bondSnapshot (the battle input) leaves it out', () => {
|
||||
const s = computeBonds(gd, state([piece(MLYSS), ...members('yanShip', 2).map(piece)]));
|
||||
const full = bondList(gd, s, { full: true }).find((b) => b.bondId === 'yanShip');
|
||||
assert.deepEqual(full, { bondId: 'yanShip', count: 3, active: true, tier: 1, layers: 0, harmony: 1, thresholds: [3, 6, 9], countsHand: false });
|
||||
const pub = bondList(gd, s).find((b) => b.bondId === 'yanShip');
|
||||
assert.deepEqual(pub, { bondId: 'yanShip', count: 3, active: true, tier: 1, layers: 0, harmony: 1 });
|
||||
assert.ok(bondList(gd, s).filter((b) => b.bondId !== 'yanShip').every((b) => !('harmony' in b)));
|
||||
assert.equal(bondsWithGains(s, { yanShip: 4 }).yanShip.harmony, 1, 'the battle\'s gains keep the mark');
|
||||
assert.deepEqual(bondSnapshot(s).yanShip, { count: 3, active: true, tier: 1, layers: 0 }, 'the battle reads count / active / tier / layers only');
|
||||
});
|
||||
|
||||
test('a real co-op match: the deploying player\'s m.private and their m.public row (a teammate\'s strip) say the count holds the +1', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, seed: 2612, fake: true }).start();
|
||||
h.toPrep(1);
|
||||
const m = h.m;
|
||||
const ps = h.ps('p_1');
|
||||
ps.board.clear();
|
||||
const used = new Set();
|
||||
for (const id of [MLYSS, ...members('yanShip', 2)]) {
|
||||
const t = legalTileFor(m, ps, id, used);
|
||||
used.add(tileKey(t[0], t[1]));
|
||||
give(m, ps, id, 'board', t);
|
||||
}
|
||||
ps.recompute();
|
||||
const own = ps.privateView().bonds.find((b) => b.bondId === 'yanShip');
|
||||
assert.equal(own.count, 3);
|
||||
assert.equal(own.harmony, 1);
|
||||
const row = m.publicView().players.find((p) => p.playerId === 'p_1').bonds.find((b) => b.bondId === 'yanShip');
|
||||
assert.deepEqual(row, { bondId: 'yanShip', count: 3, active: true, tier: 1, layers: 0, harmony: 1 }, 'what p_0 reads when watching p_1');
|
||||
assert.ok(m.publicView().players.find((p) => p.playerId === 'p_0').bonds.every((b) => !('harmony' in b)), 'p_0 has no 调和');
|
||||
// 缪尔赛思 back to the bench: the mark goes with the +1
|
||||
const k = [...ps.board].find(([, p]) => p.id === MLYSS)[0];
|
||||
assert.deepEqual(m.handle('p_1', { t: 'g.move', uid: ps.board.get(k).uid, to: { area: 'hand', idx: ps.hand.findIndex((x) => x == null) } }), { ok: true });
|
||||
const after = ps.privateView().bonds.find((b) => b.bondId === 'yanShip');
|
||||
assert.equal(after.count, 2);
|
||||
assert.ok(!('harmony' in after));
|
||||
checkInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('§21.26 3 — strategies tied to a bond the mode switches off', () => {
|
||||
const funny = new GameData(DATA, 'mode_multi_funny');
|
||||
|
||||
test('GameData.bandBondIds reads the tie from the band\'s data: its <bond> names, bond ids and bond pools in its blackboards', () => {
|
||||
const tied = Object.fromEntries(funny.bandIds().map((id) => [DATA.bands[id].name, funny.bandBondIds(id)]).filter(([, b]) => b.length));
|
||||
assert.deepEqual(tied, {
|
||||
杜遥夜: ['yanShip'], 佩佩: ['sargonShip'], 哈洛德: ['victoriaShip'], 休露丝: ['kjeragShip'], 潘格尼尼: ['lateranoShip'],
|
||||
克莱门莎: ['egirShip'], 玛恩纳: ['kazimierzShip'], 贾维: ['siracusaShip'], 娜仁图亚: ['sargonShip'],
|
||||
});
|
||||
// every tied band carries the official note "在<X>部分干员缺席时体验可能不完整" naming that bond
|
||||
for (const id of funny.bandIds()) {
|
||||
const b = DATA.bands[id];
|
||||
const note = /在<([^<>]+)>部分干员缺席时体验可能不完整/.exec(b.desc);
|
||||
if (funny.bandBondIds(id).length) assert.ok(note && funny.bandBondIds(id).some((x) => DATA.bonds[x].name === note[1]), b.name);
|
||||
}
|
||||
// the three 标准 switches off; a bracketed item (<寻呼模块>, <画卷>) is no bond; unknown bands: []
|
||||
assert.deepEqual(funny.bandIds().filter((id) => funny.bandBondIds(id).some((b) => funny.modeInactiveBonds.has(b))), ['band_paganini', 'band_clementia', 'band_mlynar']);
|
||||
assert.deepEqual(funny.bandBondIds('band_jesica'), []);
|
||||
assert.deepEqual(funny.bandBondIds('band_dusk'), []);
|
||||
assert.deepEqual(funny.bandBondIds('nope'), []);
|
||||
});
|
||||
|
||||
test('a band tied only through a pool or a blackboard bond id is caught too (潘格尼尼 without its text)', () => {
|
||||
const data = { ...DATA, bands: {
|
||||
band_pool: { ...DATA.bands.band_paganini, bandId: 'band_pool', desc: '【定制铳械】累计花费55资金后,获得1名精锐干员' },
|
||||
band_bb: { ...DATA.bands.band_clementia, bandId: 'band_bb', desc: '【崇高牺牲】' },
|
||||
band_free: { ...DATA.bands.band_bldsk, bandId: 'band_free' },
|
||||
} };
|
||||
const gd = new GameData(data, 'mode_single_funny');
|
||||
assert.deepEqual(gd.bandBondIds('band_pool'), ['lateranoShip'], 'choices.json pools.pool_char_later.bond');
|
||||
assert.deepEqual(gd.bandBondIds('band_bb'), ['egirShip'], 'bbStr bond_id');
|
||||
assert.deepEqual(gd.bandBondIds('band_free'), []);
|
||||
});
|
||||
|
||||
/** Bands the bots took over seeds 1–200: solo (one AI's pick) and co-op (the real draft, 4 AI seats). */
|
||||
function picks(difficulty) {
|
||||
const solo = [];
|
||||
const coop = [];
|
||||
for (let seed = 1; seed <= 200; seed++) {
|
||||
const s = makeMatch({ mode: 'solo', difficulty, seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }], seed, fake: true });
|
||||
solo.push(botPickBand(s.m, s.m.order[0]));
|
||||
s.m.dispose();
|
||||
const c = makeMatch({ mode: 'coop', difficulty, humans: 0, bots: 4, seed, fake: true }).start();
|
||||
c.run(() => c.m.phase !== PHASE.INFO_CHECK && c.m.phase !== PHASE.BAND_DRAFT, { maxSteps: 1e5 });
|
||||
assert.equal(c.m.phase, PHASE.BATTLE_CHECK, `seed ${seed}: the draft ended`);
|
||||
for (const ps of c.m.order) coop.push(ps.bandId);
|
||||
assert.equal(new Set(c.m.order.map((ps) => ps.bandId)).size, 4, 'no strategy twice');
|
||||
c.m.dispose();
|
||||
}
|
||||
return { solo, coop };
|
||||
}
|
||||
const TIED = new Set(['band_paganini', 'band_clementia', 'band_mlynar']);
|
||||
|
||||
test('标准: over seeds 1–200 no bot picks 潘格尼尼 / 克莱门莎 / 玛恩纳 — solo (200) or in the co-op draft (800); every pick is offered', () => {
|
||||
const { solo, coop } = picks('FUNNY');
|
||||
assert.equal(solo.filter((id) => TIED.has(id)).length, 0, 'solo');
|
||||
assert.equal(coop.filter((id) => TIED.has(id)).length, 0, 'co-op');
|
||||
const offered = new Set(funny.bandIds());
|
||||
assert.ok([...solo, ...coop].every((id) => offered.has(id)));
|
||||
assert.ok(new Set(solo).size >= 15 && new Set(coop).size >= 25, 'the other strategies are still spread out');
|
||||
});
|
||||
|
||||
test('绝境: the same seeds still give those strategies (nothing switched off), picks unchanged from the LP-only weighting', () => {
|
||||
const { solo, coop } = picks('HARD');
|
||||
assert.ok(solo.filter((id) => TIED.has(id)).length > 0, `solo ${solo.filter((id) => TIED.has(id)).length}/200`);
|
||||
assert.ok(coop.filter((id) => TIED.has(id)).length > 0, `co-op ${coop.filter((id) => TIED.has(id)).length}/800`);
|
||||
// the pre-0.1.1 pick (starting LP only), replayed on the same rng: identical where no bond is off
|
||||
const before = (m) => {
|
||||
const ids = m.gd.bandIds();
|
||||
const lateFunds = (id) => /暂存/.test(String(m.gd.band(id)?.desc || ''));
|
||||
const pairs = ids.map((id) => [id, Math.max(1, (m.gd.startLp(id) - 18) ** 2) * (m.isSolo && lateFunds(id) ? 0.02 : 1)]);
|
||||
let r = m.rngBots() * pairs.reduce((a, [, w]) => a + w, 0);
|
||||
for (const [id, w] of pairs) { r -= w; if (r < 0) return id; }
|
||||
return pairs[pairs.length - 1][0];
|
||||
};
|
||||
for (const difficulty of ['NORMAL', 'HARD', 'ABYSS']) {
|
||||
for (let seed = 1; seed <= 200; seed++) {
|
||||
const seats = [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }];
|
||||
const a = makeMatch({ mode: 'solo', difficulty, seats, seed, fake: true });
|
||||
const b = makeMatch({ mode: 'solo', difficulty, seats, seed, fake: true });
|
||||
assert.equal(botPickBand(a.m, a.m.order[0]), before(b.m), `${difficulty} seed ${seed}`);
|
||||
a.m.dispose();
|
||||
b.m.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('deterministic per seed; one rng draw per pick; with every offered band excluded, the default band', () => {
|
||||
const seats = [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }];
|
||||
for (const seed of [3, 77, 140]) {
|
||||
const a = makeMatch({ mode: 'solo', difficulty: 'FUNNY', seats, seed, fake: true });
|
||||
const b = makeMatch({ mode: 'solo', difficulty: 'FUNNY', seats, seed, fake: true });
|
||||
assert.equal(botPickBand(a.m, a.m.order[0]), botPickBand(b.m, b.m.order[0]));
|
||||
a.m.dispose();
|
||||
b.m.dispose();
|
||||
}
|
||||
let draws = 0;
|
||||
const fake = (gd, r = 0.5) => ({ gd, isSolo: true, rngBots: () => { draws++; return r; } });
|
||||
botPickBand(fake(funny), null);
|
||||
assert.equal(draws, 1);
|
||||
// every offered band tied to a bond 标准 switches off (the default band 华法琳 not offered here): the default band
|
||||
const data = { ...DATA, bands: {
|
||||
band_bldsk: { ...DATA.bands.band_bldsk, modeTypeList: ['MULTI'] },
|
||||
band_paganini: DATA.bands.band_paganini, band_clementia: DATA.bands.band_clementia, band_mlynar: DATA.bands.band_mlynar,
|
||||
} };
|
||||
const gd = new GameData(data, 'mode_single_funny');
|
||||
assert.deepEqual(gd.bandIds(), ['band_paganini', 'band_clementia', 'band_mlynar']);
|
||||
for (const r of [0, 0.3, 0.999]) assert.equal(botPickBand(fake(gd, r), null), gd.defaultBandId);
|
||||
// one free band left: always it, whatever the draw (also at the top of the range)
|
||||
const one = new GameData({ ...data, bands: { ...data.bands, band_amiya: DATA.bands.band_amiya } }, 'mode_single_funny');
|
||||
for (const r of [0, 0.5, 0.9999999]) assert.equal(botPickBand(fake(one, r), null), 'band_amiya');
|
||||
});
|
||||
});
|
||||
|
||||
test('§21.26 docs: DESIGN (the subsection and the normative lines), META, PLAYING and the CHANGELOG say what the code does', () => {
|
||||
const doc = (p) => readFileSync(new URL(`../../${p}`, import.meta.url), 'utf8');
|
||||
const DESIGN = doc('docs/DESIGN.md');
|
||||
const at = DESIGN.indexOf('### 21.26 Gaps found while triaging GitHub issues #1 / #8 (v0.1.1)');
|
||||
assert.ok(at > 0, 'the subsection');
|
||||
const s = DESIGN.slice(at);
|
||||
for (const k of ['morphPairings', 'harmonyMembers', '`harmony: 1`', 'bandBondIds', 'botPickBand', '0 of 200 and 0 of 800', '5 of 200 solo and 23 of 800 co-op']) assert.ok(s.includes(k), k);
|
||||
assert.match(DESIGN, /bonds:\[\{bondId,count,active,tier,layers,harmony\? \/\* 调和's \+1 is in count, §21\.26 \*\/\}\]/, '§8.2');
|
||||
assert.match(DESIGN, /bonds: \[ \{ bondId, count, active, tier, layers, harmony\? \/\* 调和's \+1 is in count, §21\.26 \*\/, thresholds, countsHand \} \]/, '§8.3');
|
||||
assert.match(DESIGN, /Strategy \(§21\.6\): pick a band \(weighted by starting LP; never one built around a bond the mode switches off — `gd\.bandBondIds`, §21\.26\)/, '§6.6');
|
||||
const META = doc('docs/META.md');
|
||||
assert.match(META, /\*\*Strategy\*\* \(`botPickBand`\)/);
|
||||
assert.match(META, /carries `harmony: 1` in both lists/);
|
||||
assert.match(doc('docs/PLAYING.md'), /盟约详情会写「含调和 \+1」/);
|
||||
const log = doc('CHANGELOG.md');
|
||||
const v011 = log.slice(log.indexOf('## 0.1.1'), log.indexOf('## 0.1.0'));
|
||||
assert.match(v011, /变形同构体的详情卡列出天赋栏里的对应关系/);
|
||||
assert.match(v011, /盟约详情写明「含调和 \+1」/);
|
||||
assert.match(v011, /人机不再选择围绕本局禁用盟约的策略(标准模拟下的潘格尼尼、克莱门莎、玛恩纳)/);
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
// The data behind the watched player's bond strip (DESIGN §20.15, user report after playtest #6): what the server
|
||||
// sends of every player's bonds and what the browser's battle runner adds live.
|
||||
// * m.public players[].bonds = every bond with members, layers or an active tier — the player's own m.private list
|
||||
// without the per-bond extras the client reads from bonds.json (thresholds / countsHand); nothing else — and nothing
|
||||
// at all for an eliminated player (nobody can watch them).
|
||||
// without the per-bond extras the client reads from bonds.json (thresholds / countsHand); nothing else (but `harmony`
|
||||
// on an entry whose count holds 调和's +1, DESIGN §21.26: test/match/feedback1-gaps.test.js) — and nothing at all for
|
||||
// an eliminated player (nobody can watch them).
|
||||
// * From the end of COMBAT until the settlement the views (m.private and m.public) carry the finished normal battle's
|
||||
// in-battle gains (PlayerState.bondsView / bondsMeta.bondsWithGains, capped at 999): the strip of a player — and of a
|
||||
// teammate watching him in the 联防 — keeps the layers his battle reached. The persistent state is untouched and the
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// The 0.1.1 gaps on screen (DESIGN §21.26), in headless Chrome (puppeteer-core + system Chrome) through the mock harness
|
||||
// (public/dev/game-mock.html ?phase=PREP&variant=funny,morph,harmony: a 标准 match, a 变形同构体 in the shop's item slot, the
|
||||
// first board operator wearing 变形同构体 + 维式重锤, 缪尔赛思 on the board). Opt-in: SP_E2E=1.
|
||||
//
|
||||
// SP_E2E=1 node --test test/ui/feedback1-gaps.e2e.test.js
|
||||
// SP_E2E=1 SP_E2E_OUT=<dir> node --test test/ui/feedback1-gaps.e2e.test.js # screenshots elsewhere
|
||||
//
|
||||
// At 1920×1080 and on phones (844×390, 756×366, touch): the 变形同构体 card from the shop lists its 14 pairings (5 marked 本局禁用),
|
||||
// the wearer's card highlights 维多利亚 (生效中), and the popup of a core bond holding 调和's +1 says 在场 n(含调和 +1) with the
|
||||
// 调和 row naming 缪尔赛思 — every line inside its panel (the detail card without sideways scrolling), text ≥ 9 px on the
|
||||
// phones. Screenshots:
|
||||
// fb1-gaps-{morph-shop,morph-wearer,harmony}-<w>x<h>.png. Unit counterparts: test/ui/feedback1-gaps.test.js.
|
||||
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 = process.env.SP_E2E_OUT ? path.resolve(process.env.SP_E2E_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));
|
||||
|
||||
/** Geometry and text of the rows matching `sel` inside the scroller `box`: every row inside it, font size, overflow. */
|
||||
const measure = (page, box, sel) => page.evaluate((box, sel) => {
|
||||
const b = document.querySelector(box);
|
||||
if (!b) return null;
|
||||
const r = b.getBoundingClientRect();
|
||||
const rows = [...b.querySelectorAll(sel)].map((el) => {
|
||||
const q = el.getBoundingClientRect();
|
||||
return { text: el.textContent.trim(), off: el.classList.contains('is-off'), worn: el.classList.contains('is-worn'), bond: el.getAttribute('data-bond'),
|
||||
inside: q.left >= r.left - 0.5 && q.right <= r.right + 0.5, font: parseFloat(getComputedStyle(el).fontSize) };
|
||||
});
|
||||
return { rows, sideways: b.scrollWidth > b.clientWidth + 1 };
|
||||
}, box, sel);
|
||||
|
||||
describe('0.1.1 gaps: 变形同构体 pairings and 调和\'s +1 (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: true, args: ['--no-sandbox'] });
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
await srv?.close();
|
||||
});
|
||||
|
||||
for (const [name, w, h, touch] of [['1920x1080', 1920, 1080, false], ['844x390', 844, 390, true], ['756x366', 756, 366, true]]) {
|
||||
test(`${name}: the 变形同构体 card, its wearer's card and the 调和 row of the bond popup`, { timeout: 120000 }, async () => {
|
||||
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&render=fallback&phase=PREP&variant=funny,morph,harmony`, { waitUntil: 'networkidle0' });
|
||||
await page.waitForSelector('.shopbar__item .scard', { timeout: 20000 });
|
||||
await sleep(600);
|
||||
const minFont = touch ? 9 : 13.5;
|
||||
const tap = async (sel) => { const el = await page.$(sel); assert.ok(el, sel); if (touch) await el.tap(); else await el.click(); await sleep(300); };
|
||||
|
||||
// 1. the shop's 变形同构体 card: the first tap opens its detail with the 天赋 list
|
||||
await tap('.shopbar__item .scard');
|
||||
await page.waitForSelector('.dpanel .dmorph__row', { timeout: 5000 });
|
||||
await page.screenshot({ path: path.join(OUT, `fb1-gaps-morph-shop-${name}.png`) });
|
||||
const shop = await measure(page, '.dpanel__scroll', '.dmorph__row');
|
||||
assert.equal(shop.rows.length, 14, 'one line per bond');
|
||||
assert.deepEqual(shop.rows.filter((r) => r.off).map((r) => r.bond), ['lateranoShip', 'egirShip', 'kazimierzShip', 'arcaneShip', 'raidShip'], '标准: 本局禁用');
|
||||
assert.ok(shop.rows.filter((r) => r.off).every((r) => /本局禁用/.test(r.text)));
|
||||
assert.match(shop.rows.find((r) => r.bond === 'victoriaShip').text, /^【维多利亚】维式重锤、战栗维式重锤、坚固维式重锤、加速维式重锤、灼燃维式重锤$/);
|
||||
assert.equal(shop.rows.filter((r) => r.worn).length, 0, 'no wearer: no highlight');
|
||||
assert.ok(shop.rows.every((r) => r.inside), 'every line inside the card');
|
||||
assert.equal(shop.sideways, false, 'no sideways scrolling');
|
||||
assert.ok(shop.rows.every((r) => r.font >= minFont), `font ${shop.rows[0].font} px`);
|
||||
assert.match(await page.$eval('.dpanel .dmorph__lead', (el) => el.textContent), /搭配以下装备时,携带者视为对应盟约的成员:/);
|
||||
await page.evaluate(() => document.querySelector('.dpanel .dmorph__list')?.scrollIntoView({ block: 'start' }));
|
||||
await sleep(150);
|
||||
await page.screenshot({ path: path.join(OUT, `fb1-gaps-morph-shop-${name}-list.png`) });
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(300);
|
||||
|
||||
// 2. the wearer's card (long-press / right-click on the board piece: contextmenu on the DOM board)
|
||||
const uid = await page.evaluate(() => globalThis.__MOCK__.S().priv.board[0].uid);
|
||||
await page.evaluate((uid) => document.querySelector(`.ff-piece[data-uid="${uid}"]`)?.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 10, clientY: 10 })), uid);
|
||||
await page.waitForSelector('.dpanel .dsec--equip .dmorph__row', { timeout: 5000 });
|
||||
await page.evaluate(() => document.querySelector('.dpanel .dmorph__row.is-worn')?.scrollIntoView({ block: 'center' }));
|
||||
await sleep(200);
|
||||
await page.screenshot({ path: path.join(OUT, `fb1-gaps-morph-wearer-${name}.png`) });
|
||||
const wearer = await measure(page, '.dpanel__scroll', '.dmorph__row');
|
||||
const worn = wearer.rows.filter((r) => r.worn);
|
||||
assert.deepEqual(worn.map((r) => r.bond), ['victoriaShip'], 'the pairing it wears');
|
||||
assert.match(worn[0].text, /生效中$/);
|
||||
assert.ok(wearer.rows.every((r) => r.inside) && !wearer.sideways);
|
||||
assert.match(await page.$eval('.dpanel .dhint--morph', (el) => el.textContent.trim()), /与变形同构体一同装备时,携带者视为【维多利亚】成员生效中/);
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(300);
|
||||
|
||||
// 3. the popup of a core bond whose count holds 调和's +1 (the strip disc)
|
||||
const bond = await page.evaluate(() => (globalThis.__MOCK__.store.get().match.private.bonds.find((b) => b.harmony > 0) || {}).bondId);
|
||||
assert.ok(bond, 'a bond entry with the +1');
|
||||
await tap(`.bslot[data-bond="${bond}"] .bond`);
|
||||
await page.waitForSelector('.bpop .bpop__harmony', { timeout: 5000 });
|
||||
await page.screenshot({ path: path.join(OUT, `fb1-gaps-harmony-${name}.png`) });
|
||||
const facts = await page.$eval('.bpop .bpop__facts', (el) => el.textContent);
|
||||
assert.match(facts, /在场\s*\d+\/\d+(含调和 \+1)/);
|
||||
const note = await page.$eval('.bpop .bpop__hnote', (el) => parseFloat(getComputedStyle(el).fontSize));
|
||||
assert.ok(note >= (touch ? 8 : 11.5), `the note at ${note} px`);
|
||||
await page.evaluate(() => document.querySelector('.bpop .bpop__harmony')?.scrollIntoView({ block: 'center' }));
|
||||
await sleep(150);
|
||||
await page.screenshot({ path: path.join(OUT, `fb1-gaps-harmony-${name}-row.png`) });
|
||||
const row = await measure(page, '.bpop', '.bpop__harmony');
|
||||
assert.equal(row.rows.length, 1);
|
||||
assert.match(row.rows[0].text, /^调和 \+1缪尔赛思 在场:核心盟约激活人数 \+1$/);
|
||||
// (inside the popup's box; the popup's own few px of sideways overflow on phones predate this row)
|
||||
assert.ok(row.rows[0].inside, 'inside the popup');
|
||||
assert.ok(row.rows[0].font >= minFont, `font ${row.rows[0].font} px`);
|
||||
// the 调和 row opens 缪尔赛思's card
|
||||
await tap('.bpop .bpop__harmony');
|
||||
await page.waitForFunction(() => /缪尔赛思/.test(document.querySelector('.dpanel .dhead__name')?.textContent || ''), { timeout: 5000 });
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,310 @@
|
||||
// Gaps found while triaging GitHub issues #1 / #8 (DESIGN §21.26) — the client side.
|
||||
// 1. 变形同构体 shows its pairings ("转职球" read as doing nothing: its text says "具体对应关系可在模拟中查看本装备天赋栏", and
|
||||
// no screen showed that 天赋栏). gameLogic morphPairings builds the list from the items' `giveBondId` — the rule the
|
||||
// server counts with — grouped by bond in bonds.json order; detailPanel MorphPairings draws it on the item card (shop,
|
||||
// reward, hand) and on a wearer's card (装备), 本局禁用 marked (标准: 5 of the 14 bonds), the pairing a wearer wears
|
||||
// highlighted; a bond item says what it gives a 变形同构体 wearer (MorphGrantLine).
|
||||
// 2. 调和's +1 in the bond popup ("一些不考虑整备区的盟约计算人数时也会把整备区的干员算进去"): the server marks a count that holds
|
||||
// 调和's +1 (`harmony` in m.private bonds / m.public players[].bonds — test/match/feedback1-gaps.test.js); the popup
|
||||
// says 在场 n(含调和 +1) and a 调和 row heads the member list naming the 调和 operators on that board — own and a
|
||||
// teammate's (ui/watchBonds.js popupView over the scouted field), the chip's and the strip's titles too.
|
||||
// On screen (headless Chrome, opt-in): test/ui/feedback1-gaps.e2e.test.js.
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const load = (f) => JSON.parse(readFileSync(path.join(ROOT, 'data', f), '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 { morphPairings, modeOffBonds, grantedBonds, harmonyMembers, HARMONY_BOND } = await import('../../public/js/ui/gameLogic.js');
|
||||
const { data, getMode } = await import('../../public/js/data.js');
|
||||
await data.loadAll('bonds', 'chess', 'items', 'assets', 'garrisons', 'config');
|
||||
const { ItemDetail, ChessDetail, MorphPairings, MorphGrantLine, BondChips } = await import('../../public/js/ui/detailPanel.js');
|
||||
const { BondPopup, BondStrip } = await import('../../public/js/ui/bondStrip.js');
|
||||
const { popupView } = await import('../../public/js/ui/watchBonds.js');
|
||||
const { HARMONY_BOND: SERVER_HARMONY_BOND } = await import('../../server/match/bondsMeta.js');
|
||||
const { makeMatch, give, giveItem, legalTileFor } = await import('../match/harness.js');
|
||||
const { tileKey } = await import('../../server/match/board.js');
|
||||
|
||||
const items = load('items.json');
|
||||
const bonds = load('bonds.json');
|
||||
const chess = load('chess.json');
|
||||
const config = load('config.json');
|
||||
const ITEMS = Object.values(items);
|
||||
const BONDS = Object.values(bonds);
|
||||
|
||||
const ISO = 'chess_item_6_09_e_a'; // 变形同构体
|
||||
const ISO_B = 'chess_item_6_09_e_b';
|
||||
const HAMMER = 'chess_item_1_01_e_a'; // 维式重锤 → 维多利亚
|
||||
const HAMMER_SHUDDER = 'chess_item_2_03_e_a'; // 战栗维式重锤 → 维多利亚 (an effect-only hammer)
|
||||
const LATERANO_CLIP = 'chess_item_4_08_e_a'; // 拉特兰桥夹 → 拉特兰 (off in 标准)
|
||||
const WEARER = 'chess_char_1_01_a'; // 隐现 (拉特兰 / 迅捷)
|
||||
const MLYSS = 'chess_char_6_11_a'; // 缪尔赛思 (调和)
|
||||
const YAN = Object.values(chess).filter((c) => c.visible && !c.isGolden && c.bonds.includes('yanShip') && !c.bonds.includes(HARMONY_BOND)).map((c) => c.chessId).sort();
|
||||
|
||||
/** Every vnode of a tree; the panel's own hook-free components are expanded (RichText & co. stay unexpanded). */
|
||||
const EXPAND = new Set(['Section', 'ItemRow', 'MorphPairings', 'MorphGrantLine']);
|
||||
function* walk(v) {
|
||||
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
|
||||
if (!v || typeof v !== 'object') return;
|
||||
yield v;
|
||||
if (typeof v.type === 'function' && EXPAND.has(v.type.name)) { yield* walk(v.type(v.props)); return; }
|
||||
yield* walk(v.props?.children);
|
||||
}
|
||||
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
|
||||
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.type === 'function' && EXPAND.has(v.type.name)) return textOf(v.type(v.props));
|
||||
if (typeof v.type === 'function' && v.type.name === 'RichText') return String(v.props.text || '');
|
||||
return textOf(v.props?.children);
|
||||
};
|
||||
const byClass = (tree, c) => [...walk(tree)].filter((v) => hasClass(v, c));
|
||||
|
||||
/** The expected list straight from the data: bond → the distinct names of the items whose giveBondId is it. */
|
||||
function expectedPairings() {
|
||||
const out = new Map();
|
||||
for (const r of ITEMS) {
|
||||
if (r.canGiveBond || !r.giveBondId) continue;
|
||||
if (!out.has(r.giveBondId)) out.set(r.giveBondId, new Set());
|
||||
out.get(r.giveBondId).add(r.name);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('§21.26 1 — 变形同构体: the pairing list (gameLogic morphPairings)', () => {
|
||||
test('from the data: 14 bonds in bonds.json order, 18 items, the same giveBondId the server counts with', () => {
|
||||
const rows = morphPairings(ITEMS, BONDS);
|
||||
const want = expectedPairings();
|
||||
assert.equal(rows.length, 14);
|
||||
assert.equal(want.size, 14);
|
||||
assert.deepEqual(rows.map((r) => r.bondId), BONDS.map((b) => b.bondId).filter((id) => want.has(id)), 'bonds.json order');
|
||||
for (const r of rows) {
|
||||
assert.equal(r.name, bonds[r.bondId].name);
|
||||
assert.deepEqual(new Set(r.items.map((it) => it.name)), want.get(r.bondId), `${r.name}: its items`);
|
||||
assert.equal(new Set(r.items.map((it) => it.id)).size, r.items.length, 'one entry per item family (normal / 进阶 share it)');
|
||||
assert.equal(r.off, false);
|
||||
assert.equal(r.worn, false);
|
||||
}
|
||||
assert.equal(rows.reduce((n, r) => n + r.items.length, 0), 18, '18 bond items');
|
||||
const vic = rows.find((r) => r.bondId === 'victoriaShip');
|
||||
assert.deepEqual(vic.items.map((it) => it.name), ['维式重锤', '战栗维式重锤', '坚固维式重锤', '加速维式重锤', '灼燃维式重锤'], 'by tier: the hammer series');
|
||||
assert.ok(!rows.some((r) => r.items.some((it) => items[it.id].canGiveBond)), '变形同构体 itself is no partner');
|
||||
// every listed item really grants its bond to a wearer (the client rule = the server's: test/ui/morph-bonds.test.js)
|
||||
for (const r of rows) for (const it of r.items) assert.deepEqual(grantedBonds([ISO, it.id], (id) => items[id]), [r.bondId], it.name);
|
||||
});
|
||||
|
||||
test('the official 天赋栏 (character_table trap_1073_acarm073) lists the same 14 pairings', { skip: !existsSync(path.join(ROOT, '.cache/gamedata/excel/character_table.json')) && 'no official cache' }, () => {
|
||||
const ct = JSON.parse(readFileSync(path.join(ROOT, '.cache/gamedata/excel/character_table.json'), 'utf8'));
|
||||
const talent = ct[items[ISO].trapId].talents[0].candidates.at(-1).description.replace(/\\n/g, '\n');
|
||||
const pairs = [...talent.matchAll(/“([^”]+)”(?:系列装备)?→【([^】]+)】盟约/g)].map(([, item, bond]) => ({ item, bond }));
|
||||
assert.equal(pairs.length, 14, talent);
|
||||
const rows = morphPairings(ITEMS, BONDS);
|
||||
assert.deepEqual(pairs.map((p) => p.bond).sort(), rows.map((r) => r.name).sort(), 'the same 14 bonds');
|
||||
for (const p of pairs) {
|
||||
const r = rows.find((x) => x.name === p.bond);
|
||||
assert.ok(r.items.some((it) => it.name.includes(p.item)), `“${p.item}”→【${p.bond}】`);
|
||||
}
|
||||
});
|
||||
|
||||
test('本局禁用: 标准 marks the 5 bonds it switches off (拉特兰 阿戈尔 卡西米尔 奥术 突袭), the other modes none', () => {
|
||||
for (const id of ['mode_single_funny', 'mode_multi_funny']) {
|
||||
const off = modeOffBonds(getMode(id));
|
||||
const rows = morphPairings(ITEMS, BONDS, { off });
|
||||
const marked = rows.filter((r) => r.off).map((r) => r.bondId);
|
||||
assert.deepEqual(marked, rows.map((r) => r.bondId).filter((b) => config.modes[id].inactiveBondIds.includes(b)), id);
|
||||
assert.deepEqual(marked.map((b) => bonds[b].name), ['拉特兰', '阿戈尔', '卡西米尔', '奥术', '突袭']);
|
||||
}
|
||||
for (const id of ['mode_single_normal', 'mode_multi_hard', 'mode_multi_abyss']) {
|
||||
assert.equal(morphPairings(ITEMS, BONDS, { off: modeOffBonds(getMode(id)) }).filter((r) => r.off).length, 0, id);
|
||||
}
|
||||
});
|
||||
|
||||
test('on a wearer: the pairing it wears is highlighted (bond and item), with a 进阶 变形同构体 too; nothing without a pair', () => {
|
||||
const worn = (carried, off = null) => morphPairings(ITEMS, BONDS, { carried, off }).filter((r) => r.worn);
|
||||
const vic = worn([ISO, HAMMER_SHUDDER]);
|
||||
assert.deepEqual(vic.map((r) => r.bondId), ['victoriaShip']);
|
||||
assert.deepEqual(vic[0].items.filter((it) => it.worn).map((it) => it.name), ['战栗维式重锤'], 'only the item it carries');
|
||||
assert.deepEqual(worn([{ uid: 1, id: HAMMER }, { uid: 2, id: ISO_B }]).map((r) => r.bondId), ['victoriaShip'], 'pieces, golden 变形同构体');
|
||||
const lat = worn([ISO, LATERANO_CLIP], modeOffBonds(getMode('mode_multi_funny')));
|
||||
assert.ok(lat.length === 1 && lat[0].bondId === 'lateranoShip' && lat[0].off, 'worn, but 本局禁用 in 标准');
|
||||
assert.deepEqual(worn([ISO]), [], 'the 变形同构体 alone');
|
||||
assert.deepEqual(worn([HAMMER]), [], 'the bond item alone');
|
||||
assert.deepEqual(worn([ISO, 'chess_item_1_03_e_a']), [], 'a partner with no bond');
|
||||
assert.deepEqual(worn(null), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('§21.26 1 — 变形同构体 on the cards', () => {
|
||||
const funny = modeOffBonds(getMode('mode_multi_funny'));
|
||||
|
||||
test('the item card (shop / reward / hand): a 天赋 section with the 14 lines, 本局禁用 struck through, no highlight', () => {
|
||||
const v = ItemDetail({ item: items[ISO], piece: null, editable: false, onDestroy() {}, offBonds: funny });
|
||||
const sec = [...walk(v)].find((x) => x.type?.name === 'Section' && x.props.title === '天赋');
|
||||
assert.ok(sec, 'a 天赋 section (the item text points at its 天赋栏)');
|
||||
assert.match(textOf(v), /搭配以下装备时,携带者视为对应盟约的成员:/);
|
||||
const rows = byClass(v, 'dmorph__row');
|
||||
assert.equal(rows.length, 14);
|
||||
const off = rows.filter((r) => hasClass(r, 'is-off'));
|
||||
assert.deepEqual(off.map((r) => r.props['data-bond']), ['lateranoShip', 'egirShip', 'kazimierzShip', 'arcaneShip', 'raidShip']);
|
||||
for (const r of off) assert.match(textOf(r), /本局禁用/);
|
||||
assert.equal(rows.filter((r) => !hasClass(r, 'is-off') && /本局禁用/.test(textOf(r))).length, 0);
|
||||
assert.equal(byClass(v, 'is-worn').length, 0, 'no wearer: nothing highlighted');
|
||||
assert.match(textOf(rows.find((r) => r.props['data-bond'] === 'victoriaShip')), /【维多利亚】维式重锤、战栗维式重锤、坚固维式重锤、加速维式重锤、灼燃维式重锤/);
|
||||
assert.equal(byClass(v, 'dmorph__none').length, 0);
|
||||
// other modes: no mark
|
||||
assert.equal(byClass(ItemDetail({ item: items[ISO_B], piece: null, editable: false, onDestroy() {}, offBonds: new Set() }), 'is-off').length, 0, '进阶 copy, 绝境');
|
||||
});
|
||||
|
||||
test('a wearer\'s card (装备): the 变形同构体 row lists the pairings with the worn one highlighted (生效中); the bond item\'s row says it', () => {
|
||||
const piece = { uid: 1, kind: 'chess', id: WEARER, golden: false, tier: 1, items: [{ uid: 2, id: ISO }, { uid: 3, id: HAMMER }] };
|
||||
const blocks = ChessDetail({ chess: chess[WEARER], piece, editable: false, bonds: [], offBonds: funny, loadout: null });
|
||||
const equip = blocks.find((b) => b?.key === 'equip');
|
||||
const rows = byClass(equip, 'dmorph__row');
|
||||
assert.equal(rows.length, 14);
|
||||
const worn = rows.filter((r) => hasClass(r, 'is-worn'));
|
||||
assert.deepEqual(worn.map((r) => r.props['data-bond']), ['victoriaShip']);
|
||||
assert.match(textOf(worn[0]), /生效中/);
|
||||
assert.deepEqual(byClass(worn[0], 'dmorph__item').filter((x) => hasClass(x, 'is-worn')).map(textOf), ['维式重锤']);
|
||||
const grant = byClass(equip, 'dhint--morph');
|
||||
assert.equal(grant.length, 1, 'the 维式重锤 row');
|
||||
assert.ok(hasClass(grant[0], 'is-worn'));
|
||||
assert.match(textOf(grant[0]), /与变形同构体一同装备时,携带者视为【维多利亚】成员.*生效中/);
|
||||
// worn with a bond the mode switches off: 已搭配 + 本局禁用, never 生效中
|
||||
const lat = ChessDetail({ chess: chess[WEARER], piece: { ...piece, items: [{ uid: 2, id: ISO }, { uid: 4, id: LATERANO_CLIP }] }, editable: false, bonds: [], offBonds: funny, loadout: null });
|
||||
const latRow = byClass(lat.find((b) => b?.key === 'equip'), 'dmorph__row').find((r) => hasClass(r, 'is-worn'));
|
||||
assert.equal(latRow.props['data-bond'], 'lateranoShip');
|
||||
assert.match(textOf(latRow), /已搭配/);
|
||||
assert.match(textOf(latRow), /本局禁用/);
|
||||
assert.doesNotMatch(textOf(latRow), /生效中/);
|
||||
// a teammate's unit / a popup's 同构 row (no own piece): the same, from the item ids it carries
|
||||
const ro = ChessDetail({ chess: chess[WEARER], piece: null, editable: false, bonds: [], offBonds: null, loadout: null, unitItems: [HAMMER_SHUDDER, ISO_B] });
|
||||
assert.deepEqual(byClass(ro.find((b) => b?.key === 'equip'), 'dmorph__row').filter((r) => hasClass(r, 'is-worn')).map((r) => r.props['data-bond']), ['victoriaShip']);
|
||||
});
|
||||
|
||||
test('a wearer without a pair reads 暂未生效; a bond item alone says what it would give', () => {
|
||||
const lone = MorphPairings({ off: null, carried: [ISO] });
|
||||
assert.equal(byClass(lone, 'is-worn').length, 0);
|
||||
assert.match(textOf(byClass(lone, 'dmorph__none')[0]), /暂未生效/);
|
||||
const card = ItemDetail({ item: items[HAMMER], piece: null, editable: false, onDestroy() {}, offBonds: funny });
|
||||
const line = byClass(card, 'dhint--morph');
|
||||
assert.equal(line.length, 1);
|
||||
assert.match(textOf(line[0]), /^与变形同构体一同装备时,携带者视为【维多利亚】成员$/);
|
||||
assert.ok(!hasClass(line[0], 'is-worn'));
|
||||
const clip = byClass(ItemDetail({ item: items[LATERANO_CLIP], piece: null, editable: false, onDestroy() {}, offBonds: funny }), 'dhint--morph');
|
||||
assert.ok(hasClass(clip[0], 'is-off'));
|
||||
assert.match(textOf(clip[0]), /【拉特兰】成员本局禁用/);
|
||||
assert.equal(MorphGrantLine({ item: items[ISO], off: null }), null, 'not on the 变形同构体 itself');
|
||||
assert.equal(MorphGrantLine({ item: items['chess_item_1_03_e_a'], off: null }), null, 'not on an item without a bond');
|
||||
});
|
||||
});
|
||||
|
||||
describe('§21.26 2 — 调和\'s +1 in the bond popup', () => {
|
||||
test('the client names 调和 by the server\'s id; harmonyMembers lists the 调和 operators on a board once each', () => {
|
||||
assert.equal(HARMONY_BOND, SERVER_HARMONY_BOND);
|
||||
const getChess = (id) => chess[id] || null;
|
||||
const priv = { board: [{ kind: 'chess', id: MLYSS }, { kind: 'chess', id: chess[MLYSS].goldenId }, { kind: 'chess', id: YAN[0] }, { kind: 'token', id: 'x' }], hand: [{ kind: 'chess', id: 'chess_char_1_15_a' }] };
|
||||
assert.deepEqual(harmonyMembers(priv, getChess), [{ id: MLYSS, name: '缪尔赛思' }], 'board only, one per operator');
|
||||
assert.deepEqual(harmonyMembers({ board: [{ kind: 'chess', id: 'chess_char_1_15_a' }] }, getChess).map((x) => x.name), ['盟约·辅助干员'], 'the Pith strategy\'s hidden one');
|
||||
assert.deepEqual(harmonyMembers(null, getChess), []);
|
||||
});
|
||||
|
||||
/** A co-op match: p_1 deploys 缪尔赛思 + two 炎 operators (调和 makes 炎 3 = active), p_0 has nothing of it. */
|
||||
function harmonyMatch() {
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, seed: 2611, fake: true }).start();
|
||||
h.toPrep(1);
|
||||
const m = h.m;
|
||||
const ps = h.ps('p_1');
|
||||
ps.board.clear();
|
||||
const used = new Set();
|
||||
const pieces = {};
|
||||
for (const id of [MLYSS, YAN[0], YAN[1]]) {
|
||||
const t = legalTileFor(m, ps, id, used);
|
||||
used.add(tileKey(t[0], t[1]));
|
||||
pieces[id] = give(m, ps, id, 'board', t);
|
||||
}
|
||||
ps.recompute();
|
||||
return { h, m, ps, pieces };
|
||||
}
|
||||
|
||||
test('own popup: 在场 3(含调和 +1), the 调和 row names 缪尔赛思 and opens her card; the member list stays 2 in play', () => {
|
||||
const { h, m, ps } = harmonyMatch();
|
||||
const priv = ps.privateView();
|
||||
const view = popupView({ open: { id: 'yanShip', ownerId: 'p_1' }, pub: m.publicView(), priv, myId: 'p_1', field: null });
|
||||
assert.equal(view.entry.harmony, 1, 'the server\'s entry says the count holds 调和\'s +1');
|
||||
assert.equal(view.entry.count, 3);
|
||||
const calls = [];
|
||||
const v = BondPopup({ bondId: 'yanShip', entry: view.entry, priv: view.priv, onClose() {}, onMember: (...a) => calls.push(a) });
|
||||
assert.match(textOf(byClass(v, 'bpop__facts')[0]).replace(/\s+/g, ''), /在场3\/6(含调和\+1)/);
|
||||
const row = byClass(v, 'bpop__harmony');
|
||||
assert.equal(row.length, 1);
|
||||
assert.equal(row[0].type, 'button');
|
||||
assert.match(textOf(row[0]), /调和 \+1/);
|
||||
assert.match(textOf(row[0]), /缪尔赛思 在场:核心盟约激活人数 \+1/);
|
||||
row[0].props.onClick();
|
||||
assert.deepEqual(calls, [[MLYSS, null]], 'opens 缪尔赛思\'s card');
|
||||
const head = [...walk(v)].filter((x) => x.type === 'h4').map(textOf).find((t) => t.startsWith('成员'));
|
||||
assert.match(head.replace(/\s+/g, ''), /^成员2\//, 'the real members: 2 in play + the 调和 row = 在场 3');
|
||||
// the popup of 调和 itself (an add-on bond) and of a bond without the +1: no row
|
||||
const mani = popupView({ open: { id: HARMONY_BOND, ownerId: 'p_1' }, pub: m.publicView(), priv, myId: 'p_1' });
|
||||
assert.equal(byClass(BondPopup({ bondId: HARMONY_BOND, entry: mani.entry, priv, onClose() {} }), 'bpop__harmony').length, 0);
|
||||
m.dispose();
|
||||
void h;
|
||||
});
|
||||
|
||||
test('a teammate\'s popup (scouting their board): the entry from m.public carries the +1, the row names their 缪尔赛思', () => {
|
||||
const { m, ps } = harmonyMatch();
|
||||
const pub = m.publicView();
|
||||
const field = m.prepFieldMeta(ps); // what 前往查看 shows p_0 (m.field)
|
||||
const view = popupView({ open: { id: 'yanShip', ownerId: 'p_1' }, pub, priv: m.players.get('p_0').privateView(), myId: 'p_0', field });
|
||||
assert.equal(view.name, ps.name);
|
||||
assert.equal(view.entry.harmony, 1);
|
||||
const v = BondPopup({ bondId: 'yanShip', entry: view.entry, priv: view.priv, owner: view.name, onClose() {} });
|
||||
assert.match(textOf(byClass(v, 'bpop__facts')[0]), /含调和 \+1/);
|
||||
assert.match(textOf(byClass(v, 'bpop__harmony')[0]), /缪尔赛思 在场/);
|
||||
// their board not on screen (no field): the row still explains the +1, without names
|
||||
const blind = popupView({ open: { id: 'yanShip', ownerId: 'p_1' }, pub, priv: null, myId: 'p_0', field: null });
|
||||
const row = byClass(BondPopup({ bondId: 'yanShip', entry: blind.entry, priv: blind.priv, owner: view.name, onClose() {} }), 'bpop__harmony');
|
||||
assert.equal(row[0].type, 'div');
|
||||
assert.match(textOf(row[0]), /调和已激活:核心盟约激活人数 \+1/);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('without the server\'s +1 nothing changes; a 本局禁用 popup never shows it', () => {
|
||||
const plain = { bondId: 'yanShip', count: 3, active: true, tier: 1, layers: 0, thresholds: [3, 6, 9] };
|
||||
const priv = { board: [{ kind: 'chess', id: MLYSS }, { kind: 'chess', id: YAN[0] }], hand: [], temp: [] };
|
||||
const v = BondPopup({ bondId: 'yanShip', entry: plain, priv, onClose() {} });
|
||||
assert.equal(byClass(v, 'bpop__harmony').length, 0, 'the client does not re-derive the rule');
|
||||
assert.doesNotMatch(textOf(byClass(v, 'bpop__facts')[0]), /调和/);
|
||||
const off = BondPopup({ bondId: 'lateranoShip', entry: { bondId: 'lateranoShip', count: 2, harmony: 1 }, priv, onClose() {}, off: true });
|
||||
assert.equal(byClass(off, 'bpop__harmony').length, 0);
|
||||
});
|
||||
|
||||
test('the strip disc\'s and the card chip\'s titles say the count holds the +1', () => {
|
||||
const entry = { bondId: 'yanShip', count: 3, active: true, tier: 1, layers: 5, harmony: 1, thresholds: [3, 6, 9] };
|
||||
const strip = BondStrip({ bonds: [entry], onOpen() {} });
|
||||
const slot = [...walk(strip)].find((x) => x.props?.['data-bond'] === 'yanShip');
|
||||
assert.equal(slot.props['data-harmony'], 1);
|
||||
const disc = [...walk(slot)].find((x) => typeof x.props?.title === 'string' && x.props.title.startsWith('炎'));
|
||||
assert.equal(disc.props.title, '炎 3/6(含调和 +1)');
|
||||
const chips = BondChips({ bondIds: ['yanShip'], bonds: [entry], onBond() {} });
|
||||
const chip = [...walk(chips)].find((x) => x.props?.['data-bond'] === 'yanShip');
|
||||
assert.equal(chip.props.title, '炎:在场 3/6(含调和 +1),已激活 1 阶');
|
||||
const noPlus = [...walk(BondChips({ bondIds: ['yanShip'], bonds: [{ ...entry, harmony: undefined }] }))].find((x) => x.props?.['data-bond'] === 'yanShip');
|
||||
assert.equal(noPlus.props.title, '炎:在场 3/6,已激活 1 阶');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user