Files
ark-stronghold-protocol/test/match/morph-bonds.test.js
T
sganggs 6bfc08270c 0.2.4-dev: round-29 community fixes and the official shared card pool
- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap.
- 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope).
- 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki).
- Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video).
- 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane).
- AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons.
- golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
2026-10-11 01:58:52 +08:00

169 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.
// 变形同构体 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');
// A normal plus its golden occupy 3 of the tier-VI cap. The lone copy is a separate match so the pair can sit at that cap.
const alone = prep({ fake: true });
wearer(alone, 'p_0', target, [ISO]);
assert.equal(count(alone.ps('p_0'), bond), 0, '变形同构体 alone');
const h2 = prep({ fake: true });
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, '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, alone, 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');
});