feedback1 integrate: WK bond popup and card chips include 变形同构体 wearers
# Conflicts: # docs/SIM.md # public/js/ui/bondStrip.js # public/js/ui/detailPanel.js # server/sim/snapshot.js
This commit is contained in:
commit
ff0f8d0211
15 files changed
+526
-45
No files matched your search
@@ -0,0 +1,165 @@
|
||||
// 变形同构体 and the bond member count on the real match paths (player report after 0.1.0: "缪尔赛思的变形同构体配上对应
|
||||
// 职业转职的装备能使装备的干员吃到对应盟约的加成,但是那个盟约的人口数量没有把转职之后的干员算进去").
|
||||
// Official rule: band 变形者集群 "与特定装备一同装备时装备者将视为特定盟约成员"; the item's talent (character_table
|
||||
// trap_1073_acarm073) lists the 14 pairings ("“维式重锤”系列装备→【维多利亚】盟约" …); 缪尔赛思's 特质 garrison_76
|
||||
// "<获得时>获得1个“变形同构体”". The wearer is a member: it counts for the bond's activation like any member — on the
|
||||
// board only for these BOARD bonds, once per operator (distinct members, normal and elite alike).
|
||||
// The server already counted it (prep, battle input, views, the battle); what the player saw was the client's bond
|
||||
// popup and card (test/ui/morph-bonds.test.js). This suite pins the count on every path, and the item ids the
|
||||
// client now gets with a teammate's units (prep scouting UnitInfo, the battle's UnitInfo).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeMatch, give, giveItem, DATA, legalTileFor } from './harness.js';
|
||||
import { unitBonds, isMember } from '../../server/sim/content/support/index.js';
|
||||
import { unitInfo } from '../../server/sim/snapshot.js';
|
||||
|
||||
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 GRANTS = Object.values(DATA.items).filter((r) => r.giveBondId).map((r) => r.id).sort();
|
||||
|
||||
/** A target chess outside `bond` (and not 调和: its +1 would blur the count). */
|
||||
const outsider = (bond) => Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier <= 2 && !c.bonds.includes(bond) && !c.bonds.includes('maniShip')).chessId;
|
||||
const members = (bond) => Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && c.bonds.includes(bond) && !c.bonds.includes('maniShip')).map((c) => c.chessId).sort();
|
||||
|
||||
/** PREP R1 of a real match (all 23 bonds: 绝境), the player's pieces cleared. */
|
||||
function prep({ mode = 'solo', humans = 1, fake = false, seed = 21 } = {}) {
|
||||
const h = makeMatch({ mode, difficulty: 'HARD', humans, seed, fake }).start();
|
||||
h.toPrep(1);
|
||||
for (const ps of h.m.players.values()) {
|
||||
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean), ...ps.temp.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
|
||||
ps.board.clear(); ps.hand.fill(null); ps.temp.fill(null); ps.offers.length = 0;
|
||||
ps.funds = 50; ps.bandId = null;
|
||||
ps.recompute();
|
||||
}
|
||||
return h;
|
||||
}
|
||||
const ok = (r) => assert.deepEqual(r, { ok: true });
|
||||
/** A chess with `items` (equipped through g.equip, in this order), from the hand, then onto the board when `board`. */
|
||||
function wearer(h, pid, chessId, itemIds, { board = true } = {}) {
|
||||
const { m } = h; const ps = h.ps(pid);
|
||||
const p = give(m, ps, chessId, 'hand');
|
||||
for (const id of itemIds) ok(m.handle(pid, { t: 'g.equip', itemUid: giveItem(m, ps, id).uid, targetUid: p.uid }));
|
||||
if (board) {
|
||||
const at = legalTileFor(m, ps, chessId);
|
||||
ok(m.handle(pid, { t: 'g.move', uid: p.uid, to: { area: 'board', row: at[0], col: at[1] } }));
|
||||
}
|
||||
return p;
|
||||
}
|
||||
const place = (h, pid, chessId) => {
|
||||
const { m } = h; const ps = h.ps(pid);
|
||||
const p = give(m, ps, chessId, 'hand');
|
||||
const at = legalTileFor(m, ps, chessId);
|
||||
ok(m.handle(pid, { t: 'g.move', uid: p.uid, to: { area: 'board', row: at[0], col: at[1] } }));
|
||||
return p;
|
||||
};
|
||||
const count = (ps, bond) => ps.bonds[bond]?.count ?? 0;
|
||||
|
||||
test('缪尔赛思 hands out 变形同构体 on purchase (garrison_76), the bought copy pairs with any bond item', () => {
|
||||
const h = prep();
|
||||
const { m } = h; const ps = h.ps('p_0');
|
||||
ps.shop.slots[0] = { kind: 'chess', id: 'chess_char_6_11_a', price: 6, basePrice: 6, sold: false, frozen: false };
|
||||
ok(m.handle('p_0', { t: 'g.buy', slot: 0 }));
|
||||
const iso = ps.hand.find((p) => p && p.id === ISO);
|
||||
assert.ok(iso, 'the 变形同构体 is in the hand');
|
||||
const t = give(m, ps, outsider('victoriaShip'), 'hand');
|
||||
ok(m.handle('p_0', { t: 'g.equip', itemUid: iso.uid, targetUid: t.uid }));
|
||||
ok(m.handle('p_0', { t: 'g.equip', itemUid: giveItem(m, ps, HAMMER).uid, targetUid: t.uid }));
|
||||
assert.equal(count(ps, 'victoriaShip'), 0, 'on the bench: BOARD bonds count the board only');
|
||||
const at = legalTileFor(m, ps, t.id);
|
||||
ok(m.handle('p_0', { t: 'g.move', uid: t.uid, to: { area: 'board', row: at[0], col: at[1] } }));
|
||||
assert.equal(count(ps, 'victoriaShip'), 1);
|
||||
h.invariants();
|
||||
});
|
||||
|
||||
test('every pairing (18 bond items × normal / golden, both 变形同构体): the wearer counts in prep, in the views, in the battle input and in the real battle', () => {
|
||||
const grants = Object.values(DATA.items).filter((r) => r.giveBondId);
|
||||
assert.equal(grants.length, 36);
|
||||
assert.equal(new Set(grants.map((r) => r.giveBondId)).size, 14, '14 bonds have a bond item (the official talent\'s list)');
|
||||
for (const r of grants) {
|
||||
const bond = r.giveBondId;
|
||||
const iso = r.isGolden ? ISO_B : ISO; // golden bond items with the golden 变形同构体, normal with normal
|
||||
const h = prep({ seed: 31 });
|
||||
const { m } = h; const ps = h.ps('p_0');
|
||||
const [a, b] = members(bond);
|
||||
place(h, 'p_0', a); place(h, 'p_0', b);
|
||||
assert.equal(count(ps, bond), 2, `${bond}: two members`);
|
||||
// the bond item first, then the 变形同构体 (the other order: the next test)
|
||||
const w = wearer(h, 'p_0', outsider(bond), [r.id, iso]);
|
||||
assert.equal(count(ps, bond), 3, `${r.id}: the wearer is the third member`);
|
||||
assert.equal(ps.bonds[bond].active, true, `${r.id}: the threshold of 3 (2 for add-on bonds) is reached with it`);
|
||||
assert.equal(ps.battleInput().bonds[bond].count, 3, `${r.id}: battle input`);
|
||||
assert.equal(ps.privateView().bonds.find((x) => x.bondId === bond).count, 3, `${r.id}: m.private`);
|
||||
let battle = null;
|
||||
const orig = m.newBattle.bind(m);
|
||||
m.newBattle = (o) => { const bt = orig(o); battle ??= bt; return bt; };
|
||||
assert.ok(h.drive(() => battle != null), 'combat starts');
|
||||
assert.equal(battle.getPlayer('p_0').bonds[bond].count, 3, `${r.id}: the battle's count`);
|
||||
const u = battle.allyUnits.find((x) => x.kind === 'op' && x.uid === w.uid);
|
||||
assert.ok(unitBonds(u).includes(bond) && isMember(battle, u, bond), `${r.id}: the battle's member`);
|
||||
}
|
||||
});
|
||||
|
||||
test('bench vs board, half pairs, two 变形同构体, the equip order, distinct members', () => {
|
||||
const h = prep({ fake: true });
|
||||
const { m } = h; const ps = h.ps('p_0');
|
||||
const bond = 'victoriaShip';
|
||||
const target = outsider(bond);
|
||||
// half pairs grant nothing
|
||||
wearer(h, 'p_0', target, [HAMMER]);
|
||||
assert.equal(count(ps, bond), 0, 'the bond item alone');
|
||||
const h2 = prep({ fake: true });
|
||||
wearer(h2, 'p_0', target, [ISO]);
|
||||
const other = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier <= 2 && !c.bonds.includes(bond) && c.chessId !== target).chessId;
|
||||
wearer(h2, 'p_0', other, [ISO, ISO_B]);
|
||||
assert.equal(count(h2.ps('p_0'), bond), 0, '变形同构体 alone / two of them');
|
||||
// order: 变形同构体 first
|
||||
const h3 = prep({ fake: true });
|
||||
const w = wearer(h3, 'p_0', target, [ISO, HAMMER], { board: false });
|
||||
assert.equal(count(h3.ps('p_0'), bond), 0, 'the pair on the bench');
|
||||
const at = legalTileFor(h3.m, h3.ps('p_0'), target);
|
||||
ok(h3.m.handle('p_0', { t: 'g.move', uid: w.uid, to: { area: 'board', row: at[0], col: at[1] } }));
|
||||
assert.equal(count(h3.ps('p_0'), bond), 1, 'deployed');
|
||||
ok(h3.m.handle('p_0', { t: 'g.move', uid: w.uid, to: { area: 'hand', idx: 9 } }));
|
||||
assert.equal(count(h3.ps('p_0'), bond), 0, 'back to the bench');
|
||||
// distinct members: two converted copies of one operator, or a converted copy beside a plain one, are one member
|
||||
const h4 = prep({ fake: true });
|
||||
const ps4 = h4.ps('p_0');
|
||||
wearer(h4, 'p_0', target, [ISO, HAMMER]);
|
||||
const plain = place(h4, 'p_0', target); // (a third copy would merge into the elite, the items back to the hand)
|
||||
assert.equal(count(ps4, bond), 1, 'converted + plain copy: one member');
|
||||
// (another 维式重锤 would merge with the equipped one into the golden hammer: the 战栗 one of the series)
|
||||
for (const id of [ISO_B, 'chess_item_2_03_e_a']) ok(h4.m.handle('p_0', { t: 'g.equip', itemUid: giveItem(h4.m, ps4, id).uid, targetUid: plain.uid }));
|
||||
assert.equal(plain.items.length, 2);
|
||||
assert.equal(count(ps4, bond), 1, 'two converted copies: one member');
|
||||
// and it never doubles a real member: a member wearing the pair still counts once
|
||||
const h5 = prep({ fake: true });
|
||||
wearer(h5, 'p_0', members(bond)[0], [ISO, HAMMER]);
|
||||
assert.equal(count(h5.ps('p_0'), bond), 1);
|
||||
for (const x of [h, h2, h3, h4, h5]) x.invariants();
|
||||
});
|
||||
|
||||
test('a teammate\'s views: m.public counts the wearer; the scouting UnitInfo and the battle\'s UnitInfo carry the items', () => {
|
||||
const h = prep({ mode: 'coop', humans: 2, seed: 41 });
|
||||
const { m } = h;
|
||||
const ps = h.ps('p_0');
|
||||
const bond = 'victoriaShip';
|
||||
const [a, b] = members(bond);
|
||||
place(h, 'p_0', a); place(h, 'p_0', b);
|
||||
const w = wearer(h, 'p_0', outsider(bond), [ISO, HAMMER]);
|
||||
const pub = m.publicView().players.find((p) => p.playerId === 'p_0');
|
||||
assert.equal(pub.bonds.find((x) => x.bondId === bond).count, 3, 'm.public players[].bonds');
|
||||
const meta = m.prepFieldMeta(ps);
|
||||
const u = meta.units.find((x) => x.uid === w.uid);
|
||||
assert.deepEqual(u.items, [ISO, HAMMER], 'prep scouting UnitInfo');
|
||||
assert.equal(meta.units.find((x) => x.defId === a).items, undefined, 'no items: no field');
|
||||
let battle = null;
|
||||
const orig = m.newBattle.bind(m);
|
||||
m.newBattle = (o) => { const bt = orig(o); if (o.players?.some((p) => p.playerId === 'p_0')) battle ??= bt; return bt; };
|
||||
assert.ok(h.drive(() => battle != null), 'combat starts');
|
||||
const su = battle.allyUnits.find((x) => x.kind === 'op' && x.uid === w.uid);
|
||||
assert.deepEqual(unitInfo(su).items, [ISO, HAMMER], 'the battle\'s UnitInfo (m.field / spawn events)');
|
||||
const plain = battle.allyUnits.find((x) => x.kind === 'op' && x.defId === a);
|
||||
assert.equal(unitInfo(plain).items, undefined, 'no items: no field');
|
||||
assert.equal(battle.enemies.every((e) => unitInfo(e).items === undefined), true, 'enemies: never');
|
||||
});
|
||||
@@ -444,6 +444,16 @@ test('live unit stats (user playtest #4 item 7): unitStats(id) reads the battle
|
||||
assert.equal(r.runner.unitIdOf(ally.uid, ally.ownerId, 'n:someone_else'), null, 'another field');
|
||||
assert.equal(r.runner.unitIdOf(null, ally.ownerId), null);
|
||||
assert.equal(r.runner.unitStats('x'), null);
|
||||
// a teammate's bond popup reads the owner's operators with their equipment (a 变形同构体 wearer is a member of the bond
|
||||
// it grants: test/ui/morph-bonds.test.js); an operator without items keeps the plain shape
|
||||
const saved = ally.items;
|
||||
ally.items = ['chess_item_6_09_e_a', 'chess_item_1_01_e_a'];
|
||||
const op = r.runner.ownerOps(ally.ownerId, start.fieldId).find((o) => o.defId === ally.defId);
|
||||
assert.deepEqual(op, { kind: 'op', ownerId: ally.ownerId, defId: ally.defId, items: ['chess_item_6_09_e_a', 'chess_item_1_01_e_a'] });
|
||||
ally.items = [];
|
||||
assert.deepEqual(r.runner.ownerOps(ally.ownerId, start.fieldId).find((o) => o.defId === ally.defId), { kind: 'op', ownerId: ally.ownerId, defId: ally.defId });
|
||||
ally.items = saved;
|
||||
assert.deepEqual(r.runner.ownerOps(ally.ownerId, 'n:someone_else'), [], 'another field');
|
||||
// enemies too, once one is out
|
||||
for (let i = 0; i < 60 && !e.battle.enemies.some((x) => x.alive); i++) r.advance(500, 50);
|
||||
const foe = e.battle.enemies.find((x) => x.alive);
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
// 变形同构体 on the client (player report after 0.1.0: "变形同构体配上对应装备能吃到盟约加成,但那个盟约的人口数量没有把
|
||||
// 转职之后的干员算进去"). The server counts the wearer (bondsMeta.pieceBonds; test/match/morph-bonds.test.js), so the
|
||||
// strip's "在场 n" was right — but the bond popup's member list (gameLogic bondMembers: "成员 x/y") and the wearer's own
|
||||
// card (detailPanel BondChips) only read the chess records' bonds, so the converted operator was missing from both.
|
||||
// Official rule: band 变形者集群 "与特定装备一同装备时装备者将视为特定盟约成员"; the item's talent (character_table
|
||||
// trap_1073_acarm073) lists the 14 pairings "“维式重锤”系列装备→【维多利亚】盟约 …".
|
||||
|
||||
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 { bondMembers, grantedBonds, pieceBondIds } from '../../public/js/ui/gameLogic.js';
|
||||
import { ownerBoard } from '../../public/js/ui/watchBonds.js';
|
||||
import { pieceBonds } from '../../server/match/bondsMeta.js';
|
||||
import { GameData } from '../../server/match/gamedata.js';
|
||||
import { getData } from '../../server/data.js';
|
||||
import { GEO } from '../../shared/constants.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const load = (f) => JSON.parse(readFileSync(path.join(ROOT, 'data', f), 'utf8'));
|
||||
const chess = load('chess.json');
|
||||
const items = load('items.json');
|
||||
const bonds = load('bonds.json');
|
||||
const getChess = (id) => chess[id] || null;
|
||||
const getItem = (id) => items[id] || null;
|
||||
|
||||
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 SHIELD = 'chess_item_1_02_e_a'; // 坚守盾牌 → 坚守
|
||||
const WEARER = 'chess_char_1_01_a'; // 隐现 (拉特兰 / 迅捷), not 维多利亚
|
||||
const VIC = Object.values(chess).filter((c) => c.visible && !c.isGolden && c.bonds.includes('victoriaShip')).map((c) => c.chessId).sort();
|
||||
|
||||
let uid = 0;
|
||||
const itemPiece = (id) => ({ uid: ++uid, id });
|
||||
const piece = (id, its = [], extra = {}) => ({ uid: ++uid, kind: 'chess', id, golden: !!chess[id]?.isGolden, tier: chess[id]?.tier ?? 1, items: its.map(itemPiece), ...extra });
|
||||
function privWith({ board = [], hand = [] } = {}) {
|
||||
const h = new Array(GEO.HAND_SIZE).fill(null);
|
||||
hand.forEach((p, i) => { h[i] = p; });
|
||||
return { board, hand: h, temp: new Array(GEO.TEMP_SIZE).fill(null) };
|
||||
}
|
||||
|
||||
describe('变形同构体 — the client rule', () => {
|
||||
test('grantedBonds: the other item\'s giveBondId while a 变形同构体 is worn; nothing otherwise', () => {
|
||||
assert.deepEqual(grantedBonds([itemPiece(ISO), itemPiece(HAMMER)], getItem), ['victoriaShip']);
|
||||
assert.deepEqual(grantedBonds([HAMMER, ISO_B], getItem), ['victoriaShip'], 'item ids, the golden 变形同构体, any order');
|
||||
assert.deepEqual(grantedBonds([itemPiece(HAMMER)], getItem), [], 'the bond item alone grants nothing');
|
||||
assert.deepEqual(grantedBonds([itemPiece(ISO)], getItem), []);
|
||||
assert.deepEqual(grantedBonds([ISO, ISO_B], getItem), [], 'two 变形同构体: no partner bond');
|
||||
assert.deepEqual(grantedBonds(null, getItem), []);
|
||||
assert.deepEqual(pieceBondIds(chess[WEARER], [ISO, SHIELD], getItem), ['lateranoShip', 'swiftShip', 'steadShip']);
|
||||
assert.deepEqual(pieceBondIds(chess[WEARER], [], getItem), ['lateranoShip', 'swiftShip']);
|
||||
});
|
||||
|
||||
test('the client rule equals the server\'s (bondsMeta.pieceBonds) for every bond item, normal and golden', () => {
|
||||
const gd = new GameData(getData({ log: { warn() {}, error() {}, info() {} } }), 'mode_multi_hard');
|
||||
const grants = Object.values(items).filter((r) => r.giveBondId);
|
||||
assert.equal(grants.length, 36, '18 bond items × normal / golden');
|
||||
for (const r of grants) {
|
||||
for (const iso of [ISO, ISO_B]) {
|
||||
const p = { id: WEARER, items: [{ id: r.id }, { id: iso }] };
|
||||
assert.deepEqual(pieceBondIds(chess[WEARER], p.items, getItem), pieceBonds(gd, p), `${r.id} + ${iso}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('变形同构体 — the bond popup\'s member list', () => {
|
||||
test('the wearer on the board is an on-board member of the granted bond (成员 x/y agrees with 在场)', () => {
|
||||
const b = bonds.victoriaShip;
|
||||
const priv = privWith({ board: [{ ...piece(VIC[0]), row: 9, col: 3 }, { ...piece(VIC[1]), row: 9, col: 4 }, { ...piece(WEARER, [ISO, HAMMER]), row: 9, col: 5 }] });
|
||||
const rows = bondMembers(b, priv, [], getChess, getItem);
|
||||
const on = rows.filter((r) => r.onBoard).map((r) => r.id).sort();
|
||||
assert.deepEqual(on, [VIC[0], VIC[1], WEARER].sort(), 'three in play, like the server\'s count');
|
||||
const w = rows.find((r) => r.id === WEARER);
|
||||
assert.equal(w.granted, true, 'marked as converted');
|
||||
assert.equal(w.owned, true);
|
||||
assert.equal(w.name, chess[WEARER].name);
|
||||
assert.equal(rows.length, b.visibleMembers.length + 1, 'the converted operator joins the list');
|
||||
assert.equal(rows.filter((r) => r.granted).length, 1);
|
||||
assert.deepEqual(w.items, [ISO, HAMMER], 'the row carries the wearer\'s item ids (its card shows the pair)');
|
||||
assert.ok(rows.filter((r) => !r.granted).every((r) => !('items' in r)), 'plain members carry none');
|
||||
// not a member of an unrelated bond, and not with the bond item alone
|
||||
assert.equal(bondMembers(bonds.steadShip, priv, [], getChess, getItem).some((r) => r.id === WEARER), false);
|
||||
const plain = privWith({ board: [{ ...piece(WEARER, [HAMMER]), row: 9, col: 5 }] });
|
||||
assert.equal(bondMembers(b, plain, [], getChess, getItem).some((r) => r.id === WEARER), false);
|
||||
});
|
||||
|
||||
test('a wearer on the bench is owned, not in play; copies of one operator are one member (distinct members)', () => {
|
||||
const b = bonds.victoriaShip;
|
||||
const bench = privWith({ hand: [piece(WEARER, [ISO, HAMMER])] });
|
||||
const w = bondMembers(b, bench, [], getChess, getItem).find((r) => r.id === WEARER);
|
||||
assert.ok(w && w.granted && w.owned && !w.onBoard, 'bench: owned only (BOARD bonds count the board)');
|
||||
const two = privWith({
|
||||
board: [{ ...piece(WEARER, [ISO, HAMMER]), row: 9, col: 5 }, { ...piece(chess[WEARER].goldenId, [ISO_B, HAMMER]), row: 9, col: 6 }],
|
||||
hand: [piece(WEARER, [ISO, HAMMER])],
|
||||
});
|
||||
const rows = bondMembers(b, two, [], getChess, getItem).filter((r) => r.id === WEARER);
|
||||
assert.equal(rows.length, 1, 'one entry per operator (normal and elite share it)');
|
||||
assert.equal(rows[0].onBoard, true);
|
||||
});
|
||||
|
||||
test('a teammate\'s operators on the field (ownerBoard) carry their items, so their popup lists the wearer too', () => {
|
||||
const field = { units: [
|
||||
{ kind: 'op', ownerId: 'p2', defId: WEARER, items: [ISO, HAMMER] },
|
||||
{ kind: 'op', ownerId: 'p2', defId: VIC[0] },
|
||||
{ kind: 'op', ownerId: 'p1', defId: VIC[1], items: [ISO, SHIELD] },
|
||||
] };
|
||||
const board = ownerBoard(field, 'p2');
|
||||
const on = bondMembers(bonds.victoriaShip, board, [], getChess, getItem).filter((r) => r.onBoard).map((r) => r.id).sort();
|
||||
assert.deepEqual(on, [VIC[0], WEARER].sort());
|
||||
});
|
||||
});
|
||||
|
||||
// ---- the components (htm vnodes walked like test/ui/watch-bonds.test.js) -------------------------------------------
|
||||
// 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');
|
||||
await data.loadAll('bonds', 'chess', 'items', 'assets', 'garrisons');
|
||||
const { BondPopup } = await import('../../public/js/ui/bondStrip.js');
|
||||
const { BondChips, ChessDetail, resolveDetail } = await import('../../public/js/ui/detailPanel.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 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('');
|
||||
return textOf(v.props?.children);
|
||||
};
|
||||
|
||||
describe('变形同构体 — what the popup and the card show', () => {
|
||||
test('the popup: 在场 3 and 成员 3/11 — the wearer lit, tagged 同构, titled 变形同构体', () => {
|
||||
const priv = privWith({ board: [{ ...piece(VIC[0]), row: 9, col: 3 }, { ...piece(VIC[1]), row: 9, col: 4 }, { ...piece(WEARER, [ISO, HAMMER]), row: 9, col: 5 }] });
|
||||
const entry = { bondId: 'victoriaShip', count: 3, active: true, tier: 1, layers: 0, thresholds: [3, 6], countsHand: false };
|
||||
const v = BondPopup({ bondId: 'victoriaShip', entry, priv, onClose() {} });
|
||||
const head = [...walk(v)].filter((x) => x.type === 'h4').map(textOf).find((t) => t.startsWith('成员'));
|
||||
assert.equal(head.replace(/\s+/g, ''), `成员3/${bonds.victoriaShip.visibleMembers.length + 1}`);
|
||||
assert.match(textOf([...walk(v)].find((x) => hasClass(x, 'bpop__facts'))), /在场\s*3/);
|
||||
const iso = [...walk(v)].filter((x) => hasClass(x, 'bpop__member') && hasClass(x, 'is-granted'));
|
||||
assert.equal(iso.length, 1);
|
||||
assert.ok(hasClass(iso[0], 'is-on'));
|
||||
assert.match(iso[0].props.title, /变形同构体/);
|
||||
assert.ok([...walk(iso[0])].some((x) => hasClass(x, 'bpop__iso') && textOf(x) === '同构'));
|
||||
});
|
||||
|
||||
test('the card\'s bond chips: the granted bond with its count, dashed and tagged; resolveDetail hands a teammate unit\'s items on', () => {
|
||||
const ids = pieceBondIds(chess[WEARER], [ISO, HAMMER], getItem);
|
||||
const mine = [{ bondId: 'victoriaShip', count: 3, active: true, tier: 1, layers: 0, thresholds: [3, 6] }];
|
||||
const v = BondChips({ bondIds: ids, bonds: mine, granted: ['victoriaShip'], onBond() {} });
|
||||
const chips = [...walk(v)].filter((x) => hasClass(x, 'dbond'));
|
||||
assert.deepEqual(chips.map((x) => x.props['data-bond']), ['lateranoShip', 'swiftShip', 'victoriaShip']);
|
||||
const vic = chips[2];
|
||||
assert.ok(hasClass(vic, 'is-granted') && hasClass(vic, 'is-active'));
|
||||
assert.match(vic.props.title, /维多利亚(变形同构体):在场 3\/6/);
|
||||
assert.ok(!hasClass(chips[0], 'is-granted'));
|
||||
const d = resolveDetail({ kind: 'unit', unit: { id: 7, side: 'ally', ownerId: 'p2', defId: WEARER, items: [ISO, HAMMER] } }, new Map());
|
||||
assert.deepEqual(d.unitItems, [ISO, HAMMER]);
|
||||
assert.equal(resolveDetail({ kind: 'unit', unit: { id: 8, side: 'ally', ownerId: 'p2', defId: WEARER } }, new Map()).unitItems, null);
|
||||
});
|
||||
|
||||
test('a 同构 row opens the wearer\'s card with its items: the pair (read-only 装备) and the granted chip', () => {
|
||||
const priv = privWith({ board: [{ ...piece(VIC[0]), row: 9, col: 3 }, { ...piece(WEARER, [ISO, HAMMER]), row: 9, col: 5 }] });
|
||||
const entry = { bondId: 'victoriaShip', count: 2, active: false, tier: 0, layers: 0, thresholds: [3, 6], countsHand: false };
|
||||
const calls = [];
|
||||
const v = BondPopup({ bondId: 'victoriaShip', entry, priv, onClose() {}, onMember: (...a) => calls.push(a) });
|
||||
const rows = [...walk(v)].filter((x) => hasClass(x, 'bpop__member'));
|
||||
rows.find((x) => hasClass(x, 'is-granted')).props.onClick();
|
||||
rows.find((x) => !hasClass(x, 'is-granted') && hasClass(x, 'is-on')).props.onClick();
|
||||
assert.deepEqual(calls, [[WEARER, [ISO, HAMMER]], [VIC[0], null]]);
|
||||
// the screen turns the click into { kind: 'chess', id, owner, items } (screens/game.js) → resolveDetail
|
||||
const d = resolveDetail({ kind: 'chess', id: WEARER, owner: 'p1', items: [ISO, HAMMER] }, new Map());
|
||||
assert.deepEqual(d.unitItems, [ISO, HAMMER]);
|
||||
assert.equal('unitItems' in resolveDetail({ kind: 'chess', id: WEARER, owner: 'p1', items: null }, new Map()), false, 'a plain member card: no items');
|
||||
const mine = [{ bondId: 'victoriaShip', count: 2, active: false, tier: 0, layers: 0, thresholds: [3, 6] }];
|
||||
const blocks = ChessDetail({ chess: d.chess, piece: null, editable: false, bonds: mine, loadout: null, unitItems: d.unitItems });
|
||||
const chips = [...walk(blocks)].find((x) => x.type === BondChips);
|
||||
assert.ok(chips.props.bondIds.includes('victoriaShip') && chips.props.granted.includes('victoriaShip'), 'the granted chip');
|
||||
const vic = [...walk(BondChips(chips.props))].find((x) => hasClass(x, 'dbond') && x.props['data-bond'] === 'victoriaShip');
|
||||
assert.ok(vic && hasClass(vic, 'is-granted'));
|
||||
const equip = blocks.find((b) => b?.key === 'equip');
|
||||
assert.ok(equip, 'a read-only 装备 section without an own piece');
|
||||
assert.deepEqual([...walk(equip)].filter((x) => x.props?.itemId).map((x) => x.props.itemId), [ISO, HAMMER]);
|
||||
assert.ok(![...walk(equip)].some((x) => hasClass(x, 'dempty')), 'no "drag to equip" hint on a read-only list');
|
||||
// a plain record card (shop, a plain member) keeps no 装备 section and no granted chip
|
||||
const bare = ChessDetail({ chess: d.chess, piece: null, editable: false, bonds: mine, loadout: null });
|
||||
assert.equal(bare.some((b) => b?.key === 'equip'), false);
|
||||
assert.deepEqual([...walk(bare)].find((x) => x.type === BondChips).props.granted, []);
|
||||
});
|
||||
});
|
||||
@@ -357,7 +357,8 @@ test('§20.15 wiring: the game screen feeds the strip, the popup and the detail
|
||||
assert.match(src, /onOpen=\$\{\(id\) => openBond\(id, strip\.ownerId, 'strip'\)\}/, 'the strip opens its owner\'s bond');
|
||||
assert.match(src, /const detailOwner = detailBondOwner\(detailTarget, \{ pub, myId, stripOwnerId: strip\.ownerId \}\)/);
|
||||
assert.match(src, /onBond=\$\{\(id\) => openBond\(id, detailOwner, 'detail'\)\}/, 'a card\'s chip opens the same owner\'s bond as its chips show');
|
||||
assert.match(src, /onMember=\$\{\(id\) => setDetail\(\{ kind: 'chess', id, owner: bondPop\.ownerId \}\)\}/, 'a member card keeps the popup\'s player');
|
||||
assert.match(src, /onMember=\$\{\(id, items\) => setDetail\(\{ kind: 'chess', id, owner: bondPop\.ownerId, items: items \|\| null \}\)\}/,
|
||||
'a member card keeps the popup\'s player (and a 变形同构体 row\'s items)');
|
||||
assert.match(src, /openId=\$\{bondPop && bondPop\.ownerId === strip\.ownerId \? bondPop\.bondId : null\}/, 'the strip marks only its own owner\'s popup');
|
||||
assert.match(src, /setBondOpen\(\(b\) => \(b && b\.from === 'strip' \? null : b\)\)/, 'a strip popup closes when the strip changes hands');
|
||||
assert.ok(!/stripBonds\.find\(\(b\) => b\.bondId === bondOpen\)/.test(src), 'no popup entry read from the strip regardless of its owner');
|
||||
|
||||
Reference in new issue
Block a user