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.
166 lines
10 KiB
JavaScript
166 lines
10 KiB
JavaScript
// 变形同构体 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');
|
||
});
|