Files
ark-stronghold-protocol/test/ui/morph-bonds.test.js
T
sganggs 0c898bb524 feedback1c WK: 变形同构体 wearers in the bond popup's members and on their card
Player report: with 变形同构体 and the matching bond item the wearer gets the
bond's effect, but the bond's member count leaves the converted operator out.

The count that activates the bond already included the wearer on every server
path (bondsMeta.pieceBonds: prep count, views, battle input; support.unitBonds:
the battle). The client did not: the bond popup's member list (成员 x/y) and the
wearer's card read only the chess records' bonds, so the popup said 在场 3 but
成员 2/10 without the wearer, and the card had no chip for the bond.

- gameLogic.grantedBonds / pieceBondIds: the server's 变形同构体 rule on the
  client; bondMembers lists wearers (tagged 同构, one row per operator, on the
  board or owned), so 成员 x/y agrees with 在场.
- detailPanel: the card's chips include the granted bond (dashed, 同构).
- Teammates: UnitInfo carries an ally operator's item ids (sim unitInfo,
  Match.prepFieldMeta); ownerBoard / runner.ownerOps keep them.
- Tests: every pairing on real matches (prep, views, battle input, the real
  battle), bench vs board, half pairs, distinct members; the client rule
  against the server's; popup / card rendering.
2026-10-03 02:29:28 +08:00

174 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 变形同构体 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);
// 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');
const { BondPopup } = await import('../../public/js/ui/bondStrip.js');
const { BondChips, 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);
});
});