- simdata: DataSource keeps `backups` (rawBackups follows the fallback chain); getStandIn = shared/standIn.js standInRecord normalised (the chess's ids / bonds / 特质 / tier, the stand-in's body, def.standInFor); a PRESET / DIY chess or a source without backups fields itself; the per-battle view maps id-only lookups. - spec.js keeps `standIn` only when exactly true (never on a token); Battle passes it to getChess; syncUnitLoadout carries it; UnitInfo `standInFor`. - content/index.js kitOf: a stand-in's kit is KITS[def.charId] only; no default skill (a stand-in kit writes `skills`); without a kit the generic kit plus genericTalents. kits/index.js STANDIN_KIT_FILES / STANDIN_KITS, merged after the tier groups (old keys keep their order; golden unchanged). - server/data.js and tools/setup.mjs expect backups.json. - test/content/standin.test.js: the plumbing and the eight 预备干员 on all 28 chess records they replace.
358 lines
22 KiB
JavaScript
358 lines
22 KiB
JavaScript
// test/content/standin.test.js — 补位 stand-ins in the battle simulation (DATA.md §18; kits/README.md "Stand-in kits").
|
||
//
|
||
// The data layer: simdata getChess(id, { standIn: true }) / getStandIn — the chess's identity (ids, bonds, 特质, tier)
|
||
// with the stand-in's body, skill and module (shared/standIn.js standInRecord over data/backups.json) —, the per-battle
|
||
// view, the BattleSpec field `standIn`; the kit lookup by the stand-in's charId, never the replaced operator's kit
|
||
// (content/index.js kitOf); the harness option `units[].standIn`; and the eight 预备干员 (char_600–607) on the generic
|
||
// path, on every chess they replace (normal + elite): the stand-in body, the skill firing with its blackboard numbers,
|
||
// the unconditional stat talent (generic.js genericTalents), 一击即退 lasting its 10 s from every deployment (PRTS
|
||
// 预备干员-特种: 被动, 持续 10), 冲锋号令's DP at full SP (as 德克萨斯's kit casts the same skill) and 预备干员-重装 casting
|
||
// with an enemy in range (the owner's 重装 exception for the stand-ins, 2026-10-05; tools/build-data.mjs
|
||
// STANDIN_TRIGGER_DEVIATIONS).
|
||
// Run: node --test test/content/standin.test.js
|
||
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { readFileSync } from 'node:fs';
|
||
import { makeBattle, enemyRec, standInRec, checkInvariants } from '../helpers/battleHarness.js';
|
||
import { DataSource, getDefaultSource, withUnitLoadouts } from '../../server/sim/simdata.js';
|
||
import { buildBattleSpec, createBattleFromSpec, sanitizeUnitLoadout } from '../../server/sim/spec.js';
|
||
import { KITS, kitOf, isStandInDef, skillSpecSource } from '../../server/sim/content/index.js';
|
||
import { statTalentMods } from '../../server/sim/content/generic.js';
|
||
import { unitInfo } from '../../server/sim/snapshot.js';
|
||
import { standInRecord, unitForm } from '../../shared/standIn.js';
|
||
import { bodyInKeys } from '../../server/sim/body.js';
|
||
|
||
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
|
||
const CHESS = load('chess');
|
||
const BACKUPS = load('backups');
|
||
const ds = getDefaultSource();
|
||
const RESERVES = ['char_600_cpione', 'char_601_cguard', 'char_602_cdfend', 'char_603_csnipe', 'char_604_ccast', 'char_605_cmedic', 'char_606_csuppo', 'char_607_cspec'];
|
||
/** Every chess record (normal + elite) a 预备干员 stands in for. */
|
||
const RESERVE_RECORDS = Object.values(CHESS).filter((c) => c.chessType === 'NORMAL' && RESERVES.includes(c.backup.charId))
|
||
.sort((a, b) => a.chessId.localeCompare(b.chessId));
|
||
const DUMMY = { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e9, atk: 0, speed: 1 }) };
|
||
const close = (a, b, msg) => assert.ok(Math.abs(a - b) < 1e-6, `${msg}: ${a} vs ${b}`);
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// data layer
|
||
|
||
test('data: every NORMAL chess record resolves to its stand-in def — the chess identity, the stand-in body and backup selection — cached and frozen; PRESET / DIY keep their own def', () => {
|
||
let n = 0;
|
||
for (const c of Object.values(CHESS)) {
|
||
const d = ds.getChess(c.chessId, { standIn: true });
|
||
if (c.chessType !== 'NORMAL') {
|
||
assert.equal(d, ds.getChess(c.chessId), `${c.chessId}: a ${c.chessType} chess fights as itself`);
|
||
assert.equal(d?.standInFor, undefined);
|
||
continue;
|
||
}
|
||
const r = standInRecord(c, BACKUPS);
|
||
const where = `${c.chessId} → ${c.backup.charId}`;
|
||
assert.deepEqual([d.id, d.baseId, d.golden, d.tier, d.charId, d.standInFor, d.name], [c.chessId, c.baseId, !!c.isGolden, c.tier, c.backup.charId, c.charId, r.name], where);
|
||
assert.deepEqual([d.bonds, d.raw.garrisonIds], [c.bonds, c.garrisonIds], `${where}: bonds / 特质 of the chess`);
|
||
for (const k of ['maxHp', 'atk', 'def', 'res', 'blockCnt', 'cost', 'respawnTime']) assert.equal(d.stats[k], r.stats[k], `${where}: ${k} of the stand-in`);
|
||
assert.deepEqual([d.profession, d.subProf, d.rangeGrid], [r.profession, r.subProfessionId, r.rangeGrid], `${where}: class and range of the stand-in`);
|
||
assert.deepEqual([d.skill.id, d.skill.index, d.skill.bb], [r.skill.skillId, c.backup.skillIndex, r.skill.bb], `${where}: the chess's backup skill`);
|
||
assert.equal(d.loadout.standIn, true);
|
||
assert.equal(d.loadout.moduleId ?? null, c.status.equipLevel > 0 ? (c.backup.uniEquipId ?? 'none') : null, `${where}: the backup module`);
|
||
assert.equal(!!d.raw.module?.active, !!(c.backup.uniEquipId && c.status.equipLevel > 0), `${where}: module active`);
|
||
assert.ok(Object.isFrozen(d) && Object.isFrozen(d.skill.bb), `${where}: frozen`);
|
||
assert.equal(ds.getChess(c.chessId, { standIn: true, skillIndex: 0, moduleId: 'none' }), d, `${where}: a stand-in's skill / module cannot be changed`);
|
||
const own = ds.getChess(c.chessId);
|
||
assert.ok(own !== d && own.charId === c.charId && own.standInFor === undefined, `${where}: the chess's own def is untouched`);
|
||
assert.ok(isStandInDef(d) && !isStandInDef(own));
|
||
n++;
|
||
}
|
||
assert.equal(n, 110);
|
||
});
|
||
|
||
test('data: a source without backups.json fields the chess itself (a browser that did not fetch it); with it, the stand-in', () => {
|
||
const rec = CHESS.chess_char_4_04_a;
|
||
const bare = new DataSource({ chess: { [rec.chessId]: rec } }, null);
|
||
assert.equal(bare.rawBackups(), null);
|
||
const d = bare.getChess(rec.chessId, { standIn: true });
|
||
assert.deepEqual([d.charId, d.standInFor], [rec.charId, undefined]);
|
||
const full = new DataSource({ chess: { [rec.chessId]: rec }, backups: BACKUPS }, null);
|
||
assert.deepEqual([full.getChess(rec.chessId, { standIn: true }).charId, full.getChess(rec.chessId, { standIn: true }).standInFor], ['char_607_cspec', rec.charId]);
|
||
// a fallback chain finds the default source's backups (test DataSources over getDefaultSource)
|
||
assert.equal(new DataSource({}, ds).rawBackups(), ds.rawBackups());
|
||
});
|
||
|
||
test('spec and view: buildBattleSpec keeps standIn only when exactly true (never on a token); createBattleFromSpec fields the stand-in; the view maps id-only lookups', () => {
|
||
assert.deepEqual(sanitizeUnitLoadout({ chessId: 'x', standIn: true }), { chessId: 'x', standIn: true });
|
||
assert.deepEqual(sanitizeUnitLoadout({ chessId: 'x', standIn: 'true' }), { chessId: 'x' });
|
||
assert.deepEqual(sanitizeUnitLoadout({ chessId: 'x', standIn: 1 }), { chessId: 'x' });
|
||
assert.deepEqual(sanitizeUnitLoadout({ kind: 'token', tokenId: 't', standIn: true }), { kind: 'token', tokenId: 't' });
|
||
const spec = buildBattleSpec({
|
||
battleId: 'standin', fieldId: 'f', kind: 'normal', seed: 9, modeId: 'mode_multi_normal', round: 3, stageId: 'act2autochess_m01', timeLimit: 30,
|
||
players: [{
|
||
playerId: 'p1', seat: 0, side: 'L', colOffset: 0, bonds: {}, bandId: null, playerEffects: [],
|
||
units: [
|
||
{ uid: 1, kind: 'chess', chessId: 'chess_char_4_04_a', row: 10, col: 4, standIn: true },
|
||
{ uid: 2, kind: 'chess', chessId: 'chess_char_4_07_a', row: 11, col: 4, standIn: 'yes' },
|
||
],
|
||
}],
|
||
spawns: [], routes: [],
|
||
});
|
||
assert.deepEqual(spec.players[0].units.map((u) => u.standIn), [true, undefined]);
|
||
const b = createBattleFromSpec(spec, ds);
|
||
b.step();
|
||
const [u1, u2] = [1, 2].map((uid) => b.allyUnits.find((u) => u.uid === uid));
|
||
assert.deepEqual([u1.def.charId, u1.def.standInFor, u1.defId], ['char_607_cspec', CHESS.chess_char_4_04_a.charId, 'chess_char_4_04_a']);
|
||
assert.deepEqual([u2.def.charId, u2.def.standInFor], [CHESS.chess_char_4_07_a.charId, undefined]);
|
||
assert.equal(b.data.getChess('chess_char_4_04_a'), u1.def, 'the per-battle view: an id-only lookup is the stand-in');
|
||
assert.deepEqual(b.data.loadoutConflicts, []);
|
||
assert.equal(b.errors.length, 0);
|
||
// two players of one field: one fields the chess as its stand-in, the other as itself — the first wins id-only lookups
|
||
const view = withUnitLoadouts(ds, [
|
||
{ units: [{ chessId: 'chess_char_4_04_a', standIn: true }] },
|
||
{ units: [{ chessId: 'chess_char_4_04_a' }] },
|
||
]);
|
||
assert.deepEqual(view.loadoutConflicts, ['chess_char_4_04_a']);
|
||
assert.equal(view.getChess('chess_char_4_04_a').charId, 'char_607_cspec');
|
||
assert.equal(view.getChess('chess_char_4_04_a', {}).charId, CHESS.chess_char_4_04_a.charId, 'an explicit loadout is exact');
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// kit lookup
|
||
|
||
test('kits: a stand-in finds its kit by its charId only — never the kit of the operator it replaces (kitOf, skillSpecSource)', () => {
|
||
const sd = ds.getChess('chess_char_4_04_a', { standIn: true });
|
||
const od = ds.getChess('chess_char_4_04_a');
|
||
const fChess = () => ({}), fStand = () => ({});
|
||
assert.equal(typeof KITS.chess_char_4_04_a, 'function', '伊内丝 has a kit of her own');
|
||
assert.equal(kitOf(sd, { chess_char_4_04_a: fChess, chess_char_4_04: fChess }), undefined);
|
||
assert.equal(kitOf(sd, { chess_char_4_04_a: fChess, char_607_cspec: fStand }), fStand);
|
||
assert.equal(kitOf(od, { chess_char_4_04_a: fChess, char_607_cspec: fStand }), fChess);
|
||
assert.equal(kitOf(ds.getChess('chess_char_4_04_b'), { chess_char_4_04_a: fChess }), fChess, 'the elite finds the base kit as before');
|
||
assert.equal(kitOf(sd, KITS), undefined, 'no stand-in kit is registered for 预备干员-特种');
|
||
assert.equal(skillSpecSource(sd), 'none');
|
||
assert.equal(skillSpecSource(od), 'kit');
|
||
assert.equal(skillSpecSource(sd, { char_607_cspec: () => ({ skills: { 'skcom_born_up[2]': { kind: 'passive' } } }) }), 'skills');
|
||
assert.equal(skillSpecSource(sd, { char_607_cspec: () => ({ skill: { kind: 'passive' } }) }), 'generic', 'a stand-in has no default skill');
|
||
});
|
||
|
||
test('kits in battle: injected kits keyed by the chess id reach the operator only, keyed by the charId the stand-in only; without one the stand-in runs the generic kit', () => {
|
||
const calls = [];
|
||
const spy = (tag) => (bb, chess, def) => { calls.push([tag, def.charId]); return { skill: null, talents: [] }; };
|
||
const h = makeBattle({
|
||
units: [{ chessId: 'chess_char_4_04_a', row: 10, col: 4, standIn: true }, { chessId: 'chess_char_4_04_b', row: 11, col: 4 }],
|
||
kits: { chess_char_4_04_a: spy('chess'), char_607_cspec: spy('standIn') },
|
||
});
|
||
h.step();
|
||
assert.deepEqual(calls.sort(), [['chess', CHESS.chess_char_4_04_b.charId], ['standIn', 'char_607_cspec']]);
|
||
checkInvariants(h.b);
|
||
const g = makeBattle({ units: [{ chessId: 'chess_char_4_04_a', row: 10, col: 4, standIn: true }, { chessId: 'chess_char_4_04_b', row: 11, col: 4 }] });
|
||
g.step();
|
||
const [s, o] = [g.unit(1), g.unit(2)];
|
||
assert.equal(s.kit.generic, true, 'the stand-in: generic kit');
|
||
assert.equal(s.kit.talents.length, 1, 'plus its one unconditional stat talent');
|
||
assert.ok(!o.kit.generic, 'the operator: her own kit');
|
||
assert.equal(g.b.errors.length, 0);
|
||
});
|
||
|
||
test('kits: a stand-in kit has no default skill — `skills[skillId]` is its spec, `skill` is never used (the generic spec instead)', () => {
|
||
const run = (kit) => {
|
||
const h = makeBattle({ units: [{ chessId: 'chess_char_4_07_a', row: 10, col: 4, standIn: true }], kits: { char_601_cguard: () => kit } });
|
||
h.step();
|
||
return h.unit(1).kit;
|
||
};
|
||
const k1 = run({ skill: { kind: 'duration', mods: { atkPct: 9 } }, talents: [] });
|
||
assert.equal(k1.skillSource, 'generic');
|
||
assert.deepEqual(k1.skill.mods, { atkPct: 0.35 }, '攻击力强化·β型 at Lv4: atk 0.35 from the blackboard');
|
||
const k2 = run({ skills: { 'skcom_atk_up[2]': { kind: 'duration', mods: { atkPct: 0.123 } } }, talents: [] });
|
||
assert.equal(k2.skillSource, 'skills');
|
||
assert.deepEqual(k2.skill.mods, { atkPct: 0.123 });
|
||
});
|
||
|
||
test('loadout sync keeps a stand-in whose entry also names a skill / module; UnitInfo carries standInFor for the client', () => {
|
||
const h = makeBattle({ units: [
|
||
{ chessId: 'chess_char_4_07_b', row: 10, col: 4, standIn: true, skillIndex: 0, moduleId: 'none' },
|
||
{ chessId: 'chess_char_4_04_a', row: 11, col: 4 },
|
||
] });
|
||
h.step();
|
||
const [s, o] = [h.unit(1), h.unit(2)];
|
||
assert.deepEqual([s.def.charId, s.skill.id], ['char_601_cguard', 'skcom_atk_up[2]']);
|
||
assert.equal(unitInfo(s).standInFor, CHESS.chess_char_4_07_b.charId);
|
||
assert.equal(unitInfo(s).spine, 'char_601_cguard', 'the stand-in model');
|
||
assert.equal(unitInfo(o).standInFor, undefined);
|
||
assert.equal(h.b.errors.length, 0);
|
||
});
|
||
|
||
test('harness: standIn: { skillIndex, moduleId } fields another skill / module of the stand-in (standInRec); a chess without a stand-in throws', () => {
|
||
const r = standInRec('chess_char_5_11_b', { skillIndex: 0 });
|
||
assert.deepEqual([r.charId, r.skill.skillId, r.module.id, r.module.active, r.standInFor], ['char_613_acmedc', 'skchr_acmedc_1', 'uniequip_002_acmedc', true, CHESS.chess_char_5_11_b.charId]);
|
||
assert.deepEqual(standInRec('chess_char_5_11_b'), standInRecord(CHESS.chess_char_5_11_b, BACKUPS), 'no selection: the chess backup');
|
||
const h = makeBattle({ units: [{ chessId: 'chess_char_5_11_b', row: 10, col: 4, standIn: { skillIndex: 0, moduleId: 'none' } }] });
|
||
h.step();
|
||
const u = h.unit(1);
|
||
assert.deepEqual([u.def.charId, u.skill.id, !!u.def.raw.module?.active, u.def.standInFor], ['char_613_acmedc', 'skchr_acmedc_1', false, CHESS.chess_char_5_11_b.charId]);
|
||
assert.equal(u.base.atk, unitForm(BACKUPS, 'char_613_acmedc', CHESS.chess_char_5_11_b.status).stats.atk, 'no module: the form ATK');
|
||
assert.throws(() => standInRec('chess_char_1_01_a'), /no 补位 stand-in/);
|
||
assert.throws(() => standInRec('chess_char_5_11_b', { skillIndex: 7 }), /has no skill/);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// the eight 预备干员 on the generic path
|
||
|
||
/** The skill buff mods the generic spec should give for a stat skill's blackboard (generic.js key map). */
|
||
function statMods(bb) {
|
||
const m = {};
|
||
if (bb.atk) m.atkPct = bb.atk;
|
||
if (bb.def) m.defPct = bb.def;
|
||
if (bb.attack_speed) m.aspd = bb.attack_speed;
|
||
return m;
|
||
}
|
||
|
||
test('the eight 预备干员: every chess record they replace fields the stand-in body; the skill fires with its blackboard numbers; the stat talent applies', () => {
|
||
const seen = new Set();
|
||
for (const c of RESERVE_RECORDS) {
|
||
const charId = c.backup.charId;
|
||
const form = unitForm(BACKUPS, charId, c.status);
|
||
const sk = form.skills.find((s) => s.index === c.backup.skillIndex);
|
||
const where = `${c.chessId} ${c.name} → ${BACKUPS.units[charId].name} ${sk.name}`;
|
||
const medic = charId === 'char_605_cmedic';
|
||
const units = [{ chessId: c.chessId, row: 9, col: 5, dir: 'RIGHT', standIn: true, carryState: { sp: 999 } }];
|
||
if (medic) units.push({ chessId: 'chess_char_1_02_a', row: 10, col: 6 });
|
||
const h = makeBattle({
|
||
defs: { enemies: DUMMY }, units, timeLimit: 60, autoFinish: false, flags: { dpPerSec: 0 },
|
||
enemies: medic ? [] : [{ key: 'enemy_dummy', route: 0, time: 0 }], hooks: ['skillStart'],
|
||
});
|
||
const u = h.unit(1);
|
||
if (medic) { h.step(); const a = h.unit(2); h.b.loseHp(a, a.hp * 0.6); }
|
||
// the stand-in body and the generic kit, never the replaced operator's
|
||
assert.deepEqual([u.def.charId, u.def.standInFor, u.skill.id, u.kit.generic], [charId, c.charId, sk.skillId, true], where);
|
||
for (const k of ['maxHp', 'atk', 'def']) assert.equal(u.base[k], form.stats[k], `${where}: base ${k}`);
|
||
// the unconditional stat talent (攻击力+8% / 防御力+10% / 攻击速度+9), as a persistent talent buff
|
||
const talent = form.talents[0];
|
||
const tMods = statTalentMods({ description: talent.desc, bb: talent.bb });
|
||
assert.ok(tMods, `${where}: ${talent.desc} is a plain stat talent`);
|
||
assert.deepEqual(u.findBuff('talent:generic:0')?.mods, tMods, `${where}: talent ${talent.name}`);
|
||
const dp0 = h.b.players[0].dp;
|
||
if (sk.skillType === 'PASSIVE') {
|
||
h.step();
|
||
assert.deepEqual(u.findBuff(`generic:passive:${sk.skillId}`)?.mods, statMods(sk.bb), `${where}: deploy buff`);
|
||
} else {
|
||
assert.ok(h.runUntil(() => u.skill.activations > 0, 30), `${where}: the skill fires`);
|
||
h.step();
|
||
if (sk.prefabId === 'skcom_charge_cost') {
|
||
assert.equal(u.skill.rule, 'SP_FULL', `${where}: AUTO, nothing to wait for`);
|
||
assert.equal(h.b.players[0].dp, dp0 + sk.bb.cost, `${where}: +${sk.bb.cost} DP`);
|
||
} else {
|
||
const buff = u.findBuff(`skill:${u.id}`);
|
||
assert.deepEqual(buff?.mods, statMods(sk.bb), `${where}: skill buff from the blackboard`);
|
||
assert.equal(u.skill.kind, 'duration', where);
|
||
assert.equal(u.skill.duration, sk.duration, `${where}: ${sk.duration} s`);
|
||
// the stat the skill raises, exactly: base × (1 + talent + skill) or base + talent + skill
|
||
if (sk.bb.atk) close(u.s.atk, form.stats.atk * (1 + (tMods.atkPct ?? 0) + sk.bb.atk), `${where}: ATK`);
|
||
if (sk.bb.def) close(u.s.def, form.stats.def * (1 + (tMods.defPct ?? 0) + sk.bb.def), `${where}: DEF`);
|
||
if (sk.bb.attack_speed) assert.equal(u.s.aspd, form.stats.aspd + (tMods.aspd ?? 0) + sk.bb.attack_speed, `${where}: ASPD`);
|
||
}
|
||
}
|
||
checkInvariants(h.b);
|
||
assert.equal(h.b.errors.length, 0, `${where}: ${JSON.stringify(h.b.errors[0])}`);
|
||
seen.add(charId);
|
||
}
|
||
assert.equal(RESERVE_RECORDS.length, 28);
|
||
assert.deepEqual([...seen].sort(), RESERVES);
|
||
});
|
||
|
||
test('一击即退 (预备干员-特种 for 伊内丝): ATK / DEF up for exactly its 10 s from every deployment, normal and elite', () => {
|
||
for (const id of ['chess_char_4_04_a', 'chess_char_4_04_b']) {
|
||
const c = CHESS[id];
|
||
const form = unitForm(BACKUPS, 'char_607_cspec', c.status);
|
||
const sk = form.skills.find((s) => s.index === c.backup.skillIndex);
|
||
assert.deepEqual([sk.skillId, sk.skillType, sk.duration], ['skcom_born_up[2]', 'PASSIVE', 10]);
|
||
const h = makeBattle({ units: [{ chessId: id, row: 10, col: 4, standIn: true }], timeLimit: 200, autoFinish: false });
|
||
const u = h.unit(1);
|
||
const on = () => { close(u.s.atk, form.stats.atk * (1.08 + sk.bb.atk), `${id} ATK on`); close(u.s.def, form.stats.def * (1 + sk.bb.def), `${id} DEF on`); };
|
||
const off = () => { close(u.s.atk, form.stats.atk * 1.08, `${id} ATK off`); close(u.s.def, form.stats.def, `${id} DEF off`); };
|
||
h.run(9.9);
|
||
on();
|
||
h.run(0.2);
|
||
off();
|
||
// knocked out and redeployed (再部署时间大幅度减少): the buff starts again with the new deployment
|
||
h.b.kill(u);
|
||
assert.ok(h.runUntil(() => u.alive && u.deployed, 60), `${id}: redeployed`);
|
||
const back = h.b.time;
|
||
h.step();
|
||
on();
|
||
h.runUntil(() => h.b.time >= back + 9.9, 20);
|
||
on();
|
||
h.run(0.2);
|
||
off();
|
||
checkInvariants(h.b);
|
||
assert.equal(h.b.errors.length, 0);
|
||
}
|
||
});
|
||
|
||
test('冲锋号令·γ型 (预备干员-先锋 for 圣约送葬人): +12 DP as soon as the SP is full — no enemy needed — then again after each refill', () => {
|
||
for (const id of ['chess_char_5_01_a', 'chess_char_5_01_b']) {
|
||
const c = CHESS[id];
|
||
const sk = unitForm(BACKUPS, 'char_600_cpione', c.status).skills.find((s) => s.index === c.backup.skillIndex);
|
||
assert.deepEqual([sk.skillId, sk.skillType, sk.bb.cost], ['skcom_charge_cost[3]', 'AUTO', 12]);
|
||
const h = makeBattle({ units: [{ chessId: id, row: 10, col: 4, standIn: true }], timeLimit: 200, autoFinish: false, flags: { dpPerSec: 0 }, hooks: ['skillStart'] });
|
||
const u = h.unit(1);
|
||
const dp = () => h.b.players[0].dp;
|
||
h.step();
|
||
const dp0 = dp();
|
||
// initial SP initSp, then +1/s: the first cast when the SP reaches the cost (41 − 12 s normal, 38 − 14 s elite)
|
||
assert.ok(h.runUntil(() => u.skill.activations === 1, 60));
|
||
close(Math.round(h.b.time), sk.spCost - sk.initSp, `${id}: first cast at full SP`);
|
||
h.step();
|
||
assert.equal(dp(), dp0 + 12, `${id}: +12 DP`);
|
||
assert.ok(h.runUntil(() => u.skill.activations === 2, 60));
|
||
h.step();
|
||
assert.equal(dp(), dp0 + 24, `${id}: +12 DP again`);
|
||
assert.equal(h.hooksOf('skillStart').length, 2);
|
||
assert.equal(h.b.enemies.length, 0, 'no enemy at all');
|
||
checkInvariants(h.b);
|
||
}
|
||
});
|
||
|
||
/**
|
||
* A real battle (the 重装 trigger check of test/sim/feedback1-tank-triggers.test.js): the stand-in of `chessId` on
|
||
* (9,5) of 战场#01(下半) facing the gate with its SP full; one 萨卡兹枯朽前锋 (melee) walks the lane from 3 s. Returns the
|
||
* first cast (time, reason, an enemy on its range or blocked then) and the time the unit first took damage.
|
||
*/
|
||
function firstCast(chessId) {
|
||
const h = makeBattle({
|
||
stageId: 'act2autochess_m01', seed: 3, timeLimit: 120, autoFinish: false,
|
||
units: [{ chessId, row: 9, col: 5, dir: 'RIGHT', standIn: true, carryState: { sp: 999 } }],
|
||
enemies: [{ key: 'enemy_1422_lrsldr', route: 0, time: 3 }],
|
||
});
|
||
const u = h.unit(chessId);
|
||
const out = { rule: u.skill.rule, cast: null, reason: null, enemyInRange: false, blocking: false, hurt: null };
|
||
h.b.on('damaged', (ctx) => { if (ctx.target === u && out.hurt == null) out.hurt = h.b.time; }, { priority: 1000 });
|
||
h.b.on('skillStart', (ctx) => {
|
||
if (ctx.unit !== u || out.cast != null) return;
|
||
out.cast = h.b.time;
|
||
out.reason = ctx.reason;
|
||
const keys = new Set(u.baseRangeKeys || u.rangeKeys);
|
||
out.enemyInRange = h.b.enemies.some((e) => e.alive && e.deployed && bodyInKeys(e, keys));
|
||
out.blocking = u.blocking.length > 0;
|
||
}, { priority: 1000 });
|
||
h.runUntil(() => out.cast != null && out.hurt != null, 40);
|
||
checkInvariants(h.b);
|
||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||
return { ...out, charId: u.def.charId, skill: u.skill.id, trigger: u.def.skill.trigger };
|
||
}
|
||
|
||
test('预备干员-重装 (for 泥岩 S2, 乌尔比安 S3): casts with an enemy in range before anything hits it — the owner\'s 重装 exception, rawRule TAKE_DAMAGE', () => {
|
||
for (const id of ['chess_char_4_18_a', 'chess_char_4_18_b', 'chess_char_5_05_a', 'chess_char_5_05_b']) {
|
||
const r = firstCast(id);
|
||
const where = `${id}: ${JSON.stringify(r)}`;
|
||
assert.equal(r.charId, 'char_602_cdfend', where);
|
||
assert.deepEqual(r.trigger, { rule: 'DEFAULT', grid: null }, where);
|
||
assert.equal(r.rule, 'DEFAULT', where);
|
||
assert.ok(r.cast != null && r.cast >= 3, `cast once the enemy is on the field — ${where}`);
|
||
assert.equal(r.reason, 'DEFAULT', where);
|
||
assert.ok(r.enemyInRange || r.blocking, `an enemy on its tile at the cast — ${where}`);
|
||
assert.ok(r.hurt == null || r.hurt > r.cast, `cast before the first hit — ${where}`);
|
||
}
|
||
// the data keeps the official row as rawRule, on every skill of both 重装 stand-ins (Mechanist's kit reads it as is)
|
||
for (const charId of ['char_602_cdfend', 'char_610_acfend']) {
|
||
for (const f of Object.values(BACKUPS.units[charId].forms)) for (const s of f.skills) assert.deepEqual([s.trigger.rule, s.trigger.rawRule], ['DEFAULT', 'TAKE_DAMAGE'], `${charId} ${s.skillId}`);
|
||
}
|
||
});
|