Files
ark-stronghold-protocol/test/content/egir-unite-carry.test.js
T
sganggs 221e6b8dd2 0.2.5-dev: community round 30 and follow-ups
- Stock: fixed trait gifts (缪尔赛思 / 变形者集群 变形同构体, 耶拉 不融冰, 蜜蜡 / 卡涅利安 迅捷作战粮, 诗怀雅 盟约之币) still occupy stock but are granted past the cap; an operator card already shown in the shop or a reward can be taken after the pool runs out and overdraws (GitHub #498, the owner's decision); item buys and item rewards keep the stock check. 洛洛 / 焰尾 sub-pools use the measured weights (PR #497 by @2321Robin; the occupancy idea of PR #496 by @2321Robin re-implemented differently).
- Boss pairs are drawn at random once per match and fixed to battle start; the Hidden Core reuses them; a leaver does not split a full pair (the owner's decision).
- 阿戈尔 in 联防 no longer re-applies the opening devour HP loss or a false revive; Touch's team medic stays for the match after its holder leaves (owner-approved, official lifetime unverified).
- Auto-skill ranges: 司霆惊蛰 S2, 斩业星熊 S3 against air, 予愿安洁莉娜 S3; 自在's post-rebirth invulnerability is also untargetable (#510); Horn / Clementia / catapult / 忍冬 first hit (PR #502 by @Xinglan233); 不融冰 / tear gas trigger on real damage.
- Bond layer-up sound in round start / draft / prep / battle (#482): one channel, a new cue replaces the old, no extra cooldown; a prep update that adds layers and activates a bond plays one cue.
- Client: multi-swing fix (#506), refresh single-flight (PR #507 by @robll-v1), LP tower keeps the settled value with a −N tick (PR #504 by @robll-v1), export with only 潜能 / 练度 changes (PR #503 by @Xinglan233), spectator rosters list DIY members (PR #505 by @Xinglan233), banned members beside bond tooltips (PR #494 by @YuriRestia), recovery action above the away overlay (PR #511 by @RainoutOvO), the /dev/sim-check.html consistency page (PR #492 by @Cloudnyco), Korean (PR #490 by @D-Roote) and English Victoria wording (PR #499 by @YuriRestia).
- golden: 45 of 287 scenarios move against 0.2.4 (roster 17, bonds 3, fields 6, matches 17, standins 2), each attributed in docs/history/0.2.5.md §30.9 / §30.16.
2026-10-12 14:07:08 +08:00

89 lines
5.4 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { makeMatch } from '../match/harness.js';
import { uniteBattleOpts } from '../../server/match/unite.js';
import { FORCED_EXIT } from '../../server/sim/constants.js';
const egir = (layers = 0, count = 5) => ({ count, active: true, tier: count >= 5 ? 2 : 1, layers });
const close = (a, b) => assert.ok(Math.abs(a - b) < 1e-8, `${a} != ${b}`);
const defs = { chess: Object.fromEntries([['A', 10000], ['B', 7000], ['C', 20000], ['D', 10000], ['E', 10000]]
.map(([id, maxHp]) => [id, chessRec({ id, bonds: ['egirShip'], stats: { atk: 100, def: 0, maxHp, blockCnt: 1 },
skill: { spCost: 30, initSp: 5, duration: 10 } })])) };
const units = [['A', 10, 3], ['B', 10, 4], ['C', 10, 5], ['D', 12, 3], ['E', 12, 5]]
.map(([chessId, row, col], i) => ({ uid: i + 1, kind: 'chess', chessId, row, col, dir: 'RIGHT' }));
function carryInput(t, own, lineup, bonds) {
const m = makeMatch({ mode: 'coop', humans: 3, fake: true }).m;
t.after(() => m.dispose());
const unitsEnd = own.result().perPlayer.p1.unitsEnd;
const ps = { playerId: 'p1', board: new Map(lineup.map(u => [u.uid, { uid: u.uid, kind: 'chess' }])),
battleInput: ({ side, colOffset, carry }) => ({ playerId: 'p1', seat: 0, side, colOffset, bonds,
units: lineup.map(u => ({ ...u, carryState: carry.get(u.uid) })) }) };
const { players } = uniteBattleOpts({ gd: m.gd, round: 3, lastResults: new Map([['p1', { unitsEnd }]]), dispatch() {} },
{ helpers: [ps], leaked: [] }, 60);
return { players, unitsEnd };
}
test('normal result → unite rebuilds Egir attributes but preserves every living HP ratio and SP', t => {
const own = makeBattle({ defs, units, bonds: { egirShip: egir() }, autoFinish: false });
own.step(2); own.b.forceEnd();
const { players, unitsEnd } = carryInput(t, own, units, { egirShip: egir(own.b.getPlayer('p1').bonds.egirShip.layers) });
const next = makeBattle({ kind: 'unite', defs, players, autoFinish: false }); next.b.start();
for (const old of unitsEnd) {
const u = next.unit(old.uid);
assert.equal(u.alive, old.alive);
close(u.hpRatio, old.hpPct); close(u.skill.spTotal, old.sp);
assert.deepEqual(u.findBuff('bond:egir:devour')?.mods, own.unit(old.uid).findBuff('bond:egir:devour')?.mods);
}
assert.equal(next.b.result().perPlayer.p1.deaths, 0);
assert.deepEqual(next.b.result().perPlayer.p1.layerGains, {});
checkInvariants(next.b);
});
test('a previous full-health survivor cannot be newly killed by opening devour during carry restoration', t => {
const weak = { chess: { ...defs.chess, B: { ...defs.chess.B, stats: { ...defs.chess.B.stats, maxHp: 2000 } } } };
const three = units.slice(0, 3).map(u => u.uid === 3 ? { ...u, row: 12, col: 7 } : u);
const own = makeBattle({ defs: weak, units: three, bonds: { egirShip: egir(0, 3) }, autoFinish: false, timeLimit: 120 });
own.run(80); own.b.forceEnd();
assert.equal(own.unit(2).hpRatio, 1, 'food redeployed before its old battle ended');
const { players } = carryInput(t, own, three, { egirShip: egir(own.b.getPlayer('p1').bonds.egirShip.layers, 3) });
const next = makeBattle({ kind: 'unite', defs: weak, players, autoFinish: false }); next.b.start();
assert.equal(next.unit(2).alive, true); assert.equal(next.unit(2).hpRatio, 1);
assert.equal(next.b.result().perPlayer.p1.deaths, 0);
close(next.unit(1).findBuff('bond:egir:devour').mods.atkFinal, 100);
});
test('forced-down carry still contributes devour attributes without consuming any of the three later revives', () => {
const lineup = units.map(u => ({ ...u, carryState: u.uid === 2 ? { down: true } : { hpPct: 0.4, sp: 17 } }));
const next = makeBattle({ kind: 'unite', defs, players: [{ playerId: 'p1', units: lineup, bonds: { egirShip: egir() } }], autoFinish: false });
next.b.start();
assert.equal(next.unit(2).alive, false); assert.equal(next.unit(2).removeReason, FORCED_EXIT);
close(next.unit(1).findBuff('bond:egir:devour').mods.atkFinal, 200);
const survived = [];
for (const uid of [4, 5, 1, 3]) { next.b.loseHp(next.unit(uid), 1e9); survived.push(next.unit(uid).alive); }
assert.deepEqual(survived, [true, true, true, false]);
checkInvariants(next.b);
});
test('carry preserves positive ratios below 1% and fractional HP when the new max HP is smaller', t => {
const lineup = [{ ...units[0], dir: 'UP' }];
const own = makeBattle({ defs, units: lineup, bonds: { egirShip: egir() }, autoFinish: false });
own.b.start(); own.unit(1).hp = 1; own.b.forceEnd();
const { players, unitsEnd } = carryInput(t, own, lineup, {});
close(players[0].units[0].carryState.hpPct, unitsEnd[0].hpPct);
const small = { chess: { A: { ...defs.chess.A, stats: { ...defs.chess.A.stats, maxHp: 1000 } } } };
const next = makeBattle({ kind: 'unite', defs: small, players, autoFinish: false }); next.b.start();
assert.ok(next.unit(1).alive && next.unit(1).hp > 0 && next.unit(1).hp < 1);
close(next.unit(1).hpRatio, unitsEnd[0].hpPct);
});
test('ordinary/boss devour and fresh unite units still receive opening devour damage', () => {
for (const kind of ['normal', 'unite', 'boss', 'hidden']) {
const h = makeBattle({ kind, defs, units, bonds: { egirShip: egir() }, autoFinish: false }); h.b.start();
assert.ok(h.unit(2).hpRatio < 0.5, kind);
assert.ok(h.unit(3).hpRatio < 0.7, kind);
checkInvariants(h.b);
}
});