Seen (PR #402, measured again on 0.2.2): TICK = 1/30 is not a binary fraction. An attack cooldown counted down from 1 s stops at 2.1e-16 after thirty steps and the fire check `atkCd > 0` waited a 31st frame; 300 time gains of 1/30 SP add up to 9.999999999999975 and `sp >= cost` charged a 10-SP skill a frame late. Over the 265 golden battles 30.85 % of the consecutive attacks on a whole-frame interval came a frame late (control, other intervals: 0.09 %), and 4 157 end-of-tick states sat within 1e-9 under a cost. The engine already took timers within 1e-9 as done (skill timeLeft, buffs, every() / after(), enemy abilities, nextCastAt, the 失衡 end); these two did not. Official: PRTS 作战机制/sandbox 「帧对齐机制对攻速的影响」 — one frame = 1/30 s; since 2019-12-24 a non-integer frame count is rounded (四舍五入); an exact 1 s is 30 frames under any rule. Now: ai.js attackCountdown(cd, dt) (left within 1e-9 of 0 is 0) for the operators' cooldown (the canAct guard kept) and the enemies' (not while stunned or hidden; the 0.2.2 失衡 swing cut untouched) — an enemy's whole-frame wind-up is exact too (0.4 s: 12 frames, 13 before). The same countdown for the two strict `cd -= dt; if (cd > 0)` attack clocks of the same shape: the 双眼皮 turrets (content/devices.js; BAT 4 s: 121 → 120 frames) and 寒檀's S2 icicles (0.5 s: 16 → 15). SkillRuntime.gainSp takes SP within 1e-9 of the cost as full for every charge (a tiny negative remainder → 0); _normalize compares the same way. Left as they are: a non-integer count still ends on the frame that crosses 0 (⌈30 × interval⌉ — the official 四舍五入 is an older, separate difference); the count-up content clocks that restart from 0 (bosses.js self-timers, kit auras — PR #402's list); attack / hurt SP (+1 on an integer cost) unchanged. Tests: test/sim/tick-tolerance.test.js (new, 7: the PR's four adapted — residue, attackCountdown over bat 0.1–10 s × 14 ASPD = ⌈30 × interval⌉, 1 s operator every 30 / 3 s ranged enemy every 90, time SP 4/10/16/20/30/50 at exactly 30 × N gains for one and two charges — plus the 1.2 s / 1.25 s control (36 / 38), the turret at 0 layers (120) and 寒檀's icicles (15); the four engine tests fail on the parent: 31 / 91, cost 4 at 121, 121, 16). The PR's seven test edits, re-run: the same 8 assertions failed on the new timing and each edit passes on the parent and on the fix — feedback2-surtr-stealth (count once no arrow is in flight), kits_alt_t2 拉普兰德 S1 (dummy ATK 300 → 250: 120 × 15 = her 1791 HP by 59.5 s), kits_alt_t6 (medic smoke at half HP; 余 S1 up to the first 燃烧 burst), op_jesca2 (the T6 shield returns a tick after the cast), op_lmlee (hits before the burst), op_logos (count after the clear), op_narant (the first attack thrown after the new enemies). node --test (with the golden of the next commit): 5703 tests, 5701 pass, 0 fail, 2 skipped; eslint 0 errors (135 warnings, the parent's 135); typecheck, check:imports, smoke ok. feedback7 R23-TICK: golden for whole-frame attack intervals and SP costs (PR #402) — 281 of 283 scenarios move: roster 49/49, bonds 46/46, fields 22/22, matches 18/18, standins 10/10, diy 136/138; solo-NORMAL-1 now loses its Final Assault (below) npm run golden:update on the previous commit. Every family moves because both sides attack on time (more enemy kinds than operator forms sit on whole-frame intervals: 3 s, 4 s, 1 s …) and time SP charges a frame sooner. Measured over the 265 battles: whole-frame consecutive attacks a frame late 30.85 % → 0.04 % (control 0.09 % → 0.13 %; 1 s gaps at 30 frames 256 → 3 776 of ~3 800, 3 s at 90 frames 0 → 6 657 of 6 657), SP float-short states 4 157 → 0. Moved battles' totals: kills 10 647 → 10 625, leaks 12 398 → 12 445, deaths 2 913 → 2 944. Unchanged: diy-040, diy-069. End results that flip (one frame cascades; the causes as measured with the matches' battles instrumented): - solo-NORMAL-1: won → lost in round 14 (passed 14 → 13, team LP 8 → 0): the Final Assault on 卢西恩 (pool 390 000) runs as before for two minutes (his 0.4 s wind-up a frame shorter), then the team's damage on the pool trails — 884 left when the overtime drain (from 300 game s) takes the last of the 17 LP at 334 s; before, it emptied at 318 s, 8 LP left. - solo-HARD-1: LP 10 → 12 — round 8 lets one more through (26 → 25; rounds 11–12 even it out) and the Final Assault ends at 300.6 s, before the overtime drain (304.8 s and 2 LP before). - solo-NORMAL-2: LP 9 → 25 — round 5 lets one more through (26 → 25, a coin less), the bot's shop path parts from round 6 and its later board (突袭, 232 layers at round 13 against 94) clears rounds 12–13 with no leak (7 + 18 before). - coop2-NORMAL-3: team LP 63 → 68 — ai_1's round-13 battle leaks 1 instead of 6 (its round-12 leaker now goes to 联防). - coop2-ABYSS-10-boosted: team LP 583 → 630 — the Final Assault ends at 606.6 s (639.6) and the Hidden Core at 335.9 s (397.8): 47 LP less of overtime drain. - coop2-NORMAL-14-standins: team LP 41 → 40 — ai_0's round-13 battle lets one 潜伏者 through (23 → 22). - roster-016: cleared at 220.3 s → time-out at 230 s (主角阵营角色 leaks instead of falling; kills 101 → 105, leaks 26 → 22). - roster-041: time-out → cleared at 189.0 s, perfect (the last 粉碎攻坚组长 falls inside the 190 s). - bond-suntShip-high: cleared at 164.2 s → time-out at 190 s (kills 11 → 13, deaths 4 → 2). - standin-04: perfect → 10 leaks (deaths 8 → 10; 萨卡兹大剑组长 / 萨卡兹穿刺手 / 澪 get through). - diy-005 / diy-008 (艾丽妮 / 赫拉格): cleared at 225.3 s → time-out at 230 s (a last 宿主重装士兵 / 深池方阵指挥官 walking). - diy-127 (白铁 / 酒神): time-out → cleared at 225.8 s (deaths 7 → 13, leaks 57 → 72). - diy-131 (死芒): perfect → 1 leak (a 掠海漂移体). The other matches keep their ends (solo-FUNNY-1/2, solo-HARD-2, solo-ABYSS-1/2, coop2-HARD-4, coop3-ABYSS-5, coop4-FUNNY-6, coop4-ABYSS-7, coop2-NORMAL-8-serverrun, solo-HARD-9-boosted, coop2-NORMAL-70-diy). PR #402's own 0.2.1 table (coop2-NORMAL-14-standins loss → win, solo-HARD-2 / solo-ABYSS-2 earlier exits) was measured before §27 and does not repeat here.
351 lines
24 KiB
JavaScript
351 lines
24 KiB
JavaScript
// test/content/op_jesca2.test.js — the 自选 operator kit of 涤火杰西卡 (char_1034_jesca2, 6★ 哨戒铁卫; kit
|
||
// server/sim/content/kits/ops/op-jesca2.js) and her summon 机动盾牌 (token_10032_jesca2_jckshd), fielded the production way (a
|
||
// DIY slot + its `diy` pick; the shield as a token piece of hers) in every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4,
|
||
// no module) and elite (E2 Lv60, rank 7) with no module, SPT-X “私家英雄” or SPT-Y 未曾风化 at stage 1 (tier 5) / 3 (tier 6).
|
||
// Every number is read back from data/backups.json (the form of that slot status, the token's variant); the fidelity
|
||
// checklist of kits/README.md item by item.
|
||
// Run: node --test test/content/op_jesca2.test.js
|
||
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { readFileSync } from 'node:fs';
|
||
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
|
||
import { diyPool, validateDiyPicks } from '../../shared/diy.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 JESCA = 'char_1034_jesca2';
|
||
const SHIELD = 'token_10032_jesca2_jckshd';
|
||
const FORMS = BACKUPS.units[JESCA].forms;
|
||
const TOKEN = BACKUPS.tokens[SHIELD];
|
||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||
const SPTX = 'uniequip_002_jesca2', SPTY = 'uniequip_003_jesca2';
|
||
const S1 = 'skchr_jesca2_1', S2 = 'skchr_jesca2_2', S3 = 'skchr_jesca2_3';
|
||
const statusKey = (tier, elite) => (elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0');
|
||
const formOf = (tier, elite) => FORMS[statusKey(tier, elite)];
|
||
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
|
||
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
|
||
const variantOf = (tier, elite) => TOKEN.variants[`${JESCA}@${statusKey(tier, elite)}`];
|
||
const tokenStats = (tier, elite, mod) => (elite && mod && variantOf(tier, elite).byModule?.[mod]?.stats) || variantOf(tier, elite).stats;
|
||
const tokenTalent = (tier, elite, mod) => ((elite && mod && variantOf(tier, elite).byModule?.[mod]?.talents) || variantOf(tier, elite).talents).find((t) => t.bb.duration != null).bb;
|
||
const talentOf = (tier, elite, mod, i) => {
|
||
const m = elite ? modOf(tier, mod) : null;
|
||
return m?.talentChanges?.find((t) => t.talentIndex === i)?.bb ?? formOf(tier, elite).talents.find((t) => t.index === i).bb;
|
||
};
|
||
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
|
||
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
|
||
const ENEMIES = {
|
||
enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }),
|
||
enemy_shooter: dummy('enemy_shooter', { atk: 200, range: 2, bat: 0.5 }),
|
||
};
|
||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, SPTX, SPTY].map((m) => [t, true, m]))];
|
||
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||
const K = (r, c) => r * 21 + c;
|
||
const sortN = (a) => [...a].sort((x, y) => x - y);
|
||
/** Her shield above her (she turns UP), beside her (the tile right of her: in front, no turn), behind her (she turns LEFT). */
|
||
const ABOVE = { row: 11, col: 4 }, FRONT = { row: 10, col: 5 }, BEHIND = { row: 10, col: 3 };
|
||
|
||
/** A battle with 涤火杰西卡 as uid 1 at (10, 4) facing RIGHT (`shield`: her piece as uid 2), plus `others`. */
|
||
function field({ tier = 5, elite = false, mod = null, skill = 0, shield = ABOVE, others = [], seed = 5, dp = 99 } = {}) {
|
||
const units = [{ uid: 1, diy: { slot: SLOT[tier], charId: JESCA, skillIndex: skill, uniEquipId: mod }, elite, row: 10, col: 4 }];
|
||
if (shield) units.push({ uid: 2, kind: 'token', tokenId: SHIELD, ownerUid: 1, row: shield.row, col: shield.col });
|
||
const h = makeBattle({
|
||
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
|
||
flags: { dpInit: dp, dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'death', 'deploy', 'spGain', 'ammoUsed'], captureNoisy: true,
|
||
units: [...units, ...others],
|
||
});
|
||
h.step();
|
||
return { h, u: h.unit(1), t: shield ? h.unit(2) : null };
|
||
}
|
||
const bombHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.tags?.includes('jesca2:bomb'));
|
||
function done(h) {
|
||
checkInvariants(h.b);
|
||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||
}
|
||
|
||
test('涤火杰西卡 in every 自选 form: her operator kit (all three skills authored), stats + module attributes, 2-2 (SPT-Y: +1), blocks 3, ranged physical, hits air, 维多利亚, no 特质; her 机动盾牌 piece: the form\'s token stats, 禁疗, taunt 1, no attack, blocks 2', () => {
|
||
assert.equal(OPERATOR_KITS[JESCA], KITS[JESCA]);
|
||
for (const f of FORMS_ALL) {
|
||
const [tier, elite, mod] = f;
|
||
for (const skill of [0, 1, 2]) {
|
||
const { h, u, t } = field({ tier, elite, mod, skill, shield: FRONT });
|
||
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null;
|
||
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [JESCA, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
|
||
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0)], `${label(f)}: stats`);
|
||
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.base.bat], [3, 'ranged', 'phys', true, 1.2], `${label(f)}: 哨戒铁卫`);
|
||
const ext = mod === SPTY ? 1 : 0;
|
||
assert.deepEqual(sortN(u.rangeKeys), sortN([...Array(3 + ext).keys()].map((i) => K(10, 4 + i))), `${label(f)}: 2-2${ext ? ' + 1' : ''}`);
|
||
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds, u.def.tokens], [['victoriaShip'], [], [SHIELD]], `${label(f)}: bonds / 特质 / summon`);
|
||
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
|
||
const ts = tokenStats(tier, elite, mod);
|
||
assert.deepEqual([t.base.maxHp, t.base.def, t.base.atk, t.s.blockCnt, t.base.cost, t.base.respawnTime], [ts.maxHp, ts.def, 0, 2, 5, 30], `${label(f)}: shield stats`);
|
||
assert.deepEqual([t.s.flags.noHeal, t.s.taunt, t.profile.noAttack, t.alive], [true, tokenTalent(tier, elite, mod).taunt_level, true, true], `${label(f)}: 禁疗 / taunt / no attack`);
|
||
done(h);
|
||
}
|
||
}
|
||
// E2 Lv1 2589 / 461 / 522, E2 Lv60 3265 / 520 / 651 (full potential: ATK +28); SPT-X +180 / +50 / +25 → +250 / +65 / +37,
|
||
// SPT-Y +180 / +52 → +325 / +83; the shield 3041 / 619, 3545 / 709, SPT-X stage 3 3925 / 788 and 60 s
|
||
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.def], [2589, 461, 3265, 651]);
|
||
assert.deepEqual([modOf(5, SPTX).attr, modOf(6, SPTX).attr, modOf(5, SPTY).attr, modOf(6, SPTY).attr], [{ maxHp: 180, atk: 50, def: 25 }, { maxHp: 250, atk: 65, def: 37 }, { maxHp: 180, atk: 52 }, { maxHp: 325, atk: 83 }]);
|
||
assert.deepEqual([tokenStats(5, false).maxHp, tokenStats(5, true).maxHp, tokenStats(6, true, SPTX).maxHp, tokenStats(6, true, SPTX).def, tokenTalent(6, true, SPTX).duration, tokenTalent(5, true, SPTX).duration], [3041, 3545, 3925, 788, 60, 50]);
|
||
});
|
||
|
||
test('a 自选 pick: 涤火杰西卡 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
|
||
const data = { chess: CHESS, backups: BACKUPS };
|
||
assert.ok(KITTED_CHARS.includes(JESCA));
|
||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(JESCA), `tier ${t}`);
|
||
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: JESCA, skillIndex: 2, uniEquipId: SPTY } }, { data, kitted: KITTED_CHARS }),
|
||
{ ok: true, picks: { [SLOT[6]]: { charId: JESCA, skillIndex: 2, uniEquipId: SPTY } } });
|
||
});
|
||
|
||
test('T1 灵活应变: she turns to face her shield (her range turns with her) and back once it leaves; while it stands next to her she and the unit on the tile behind her DEF +18 % (孤立 too), nobody else', () => {
|
||
for (const f of [[5, false, null], [6, true, SPTX], [6, true, SPTY]]) {
|
||
const [tier, elite, mod] = f;
|
||
// shield above (11, 4): she faces UP; behind her is (9, 4); 德克萨斯 at (10, 3) was behind her RIGHT facing only
|
||
const others = [{ uid: 3, chessId: 'chess_char_1_02_a', row: 9, col: 4 }, { uid: 4, chessId: 'chess_char_1_08_a', row: 10, col: 3 }];
|
||
const { h, u, t } = field({ tier, elite, mod, skill: 0, shield: ABOVE, others });
|
||
const yak = h.unit(3), texas = h.unit(4);
|
||
const d = talentOf(tier, elite, mod, 0).def;
|
||
assert.equal(d, 0.18);
|
||
assert.deepEqual([u.dir, t.dir], ['UP', 'UP'], `${label(f)}: she faces her shield, it faces away from her`);
|
||
const ext = mod === SPTY ? 1 : 0;
|
||
assert.deepEqual(sortN(u.rangeKeys), sortN([...Array(3 + ext).keys()].map((i) => K(10 + i, 4))), `${label(f)}: her range turned UP`);
|
||
assert.deepEqual(u.findBuff('talent:jesca2:def')?.mods, { defPct: d }, `${label(f)}: herself`);
|
||
assert.deepEqual(yak.findBuff('talent:jesca2:def')?.mods, { defPct: d }, `${label(f)}: 角峰 behind her`);
|
||
assert.equal(texas.findBuff('talent:jesca2:def'), null, `${label(f)}: 德克萨斯 beside her`);
|
||
approx(u.s.def, u.base.def * (1 + d), `${label(f)}: her DEF`);
|
||
// 孤立 does not keep it off (ignoreAllyTargetFree)
|
||
h.b.addBuff(yak, { key: 'test:isolated', flags: { isolated: true } });
|
||
h.run(0.5);
|
||
assert.ok(yak.findBuff('talent:jesca2:def'), `${label(f)}: a 孤立 unit behind her`);
|
||
// the shield leaves: she turns back to RIGHT, the aura ends
|
||
h.b.retreat(t, { reason: 'expired', permanent: true });
|
||
h.run(0.5);
|
||
assert.deepEqual([u.dir, !!u.findBuff('talent:jesca2:def'), !!yak.findBuff('talent:jesca2:def')], ['RIGHT', false, false], `${label(f)}: back to RIGHT`);
|
||
assert.deepEqual(sortN(u.rangeKeys), sortN([...Array(3 + ext).keys()].map((i) => K(10, 4 + i))), `${label(f)}: her range back`);
|
||
done(h);
|
||
}
|
||
// behind her: she turns LEFT; in front: no turn; on a tile not next to her: no turn, no aura (the prep's placement rule)
|
||
const b = field({ shield: BEHIND });
|
||
assert.equal(b.u.dir, 'LEFT');
|
||
assert.deepEqual(sortN(b.u.rangeKeys), sortN([K(10, 4), K(10, 3), K(10, 2)]), 'her range faces LEFT');
|
||
done(b.h);
|
||
const fr = field({ shield: FRONT });
|
||
assert.deepEqual([fr.u.dir, !!fr.u.findBuff('talent:jesca2:def')], ['RIGHT', true]);
|
||
done(fr.h);
|
||
const far = field({ shield: { row: 12, col: 4 } });
|
||
assert.deepEqual([far.u.dir, !!far.u.findBuff('talent:jesca2:def')], ['RIGHT', false], 'a shield two tiles away');
|
||
done(far.h);
|
||
});
|
||
|
||
test('T1 灵活应变: the shield lasts 50 s (SPT-X stage 3: 60 s), comes back on its tile 30 s after it left paying 5 DP — only while she stands; it falls with her and her redeployment readies it at once', () => {
|
||
for (const f of [[5, false, null], [6, true, SPTX], [5, true, SPTX]]) {
|
||
const [tier, elite, mod] = f;
|
||
const { h, u, t } = field({ tier, elite, mod, skill: 0, shield: ABOVE });
|
||
const life = tokenTalent(tier, elite, mod).duration;
|
||
assert.equal(life, tier === 6 && mod === SPTX ? 60 : 50, label(f));
|
||
h.run(life - 0.6);
|
||
assert.ok(t.alive, `${label(f)}: still there before ${life} s`);
|
||
h.run(0.7);
|
||
assert.ok(!t.alive && !t.removed, `${label(f)}: gone at ${life} s (the piece stays)`);
|
||
assert.equal(u.dir, 'RIGHT');
|
||
const dp0 = h.b.players[0].dp;
|
||
h.run(29.5);
|
||
assert.ok(!t.alive, `${label(f)}: not before 30 s`);
|
||
h.run(0.7);
|
||
assert.ok(t.alive, `${label(f)}: back after 30 s`);
|
||
assert.deepEqual([t.tileR, t.tileC, u.dir], [11, 4, 'UP'], `${label(f)}: on its tile, she faces it again`);
|
||
approx(h.b.players[0].dp, dp0 - 5, `${label(f)}: 5 DP`);
|
||
// she falls ⇒ it falls; waiting while she is down; her redeployment brings it at once
|
||
h.b.kill(u, null);
|
||
h.step();
|
||
assert.ok(!t.alive && !t.removed, `${label(f)}: falls with her`);
|
||
assert.equal(u.dir, 'RIGHT', `${label(f)}: her facing restored for her redeployment`);
|
||
h.run(40);
|
||
assert.ok(!t.alive, `${label(f)}: no shield while she is down`);
|
||
assert.ok(h.runUntil(() => u.alive, 60), `${label(f)}: she redeploys`);
|
||
const back = h.b.time;
|
||
assert.ok(h.runUntil(() => t.alive, 0.2), `${label(f)}: the shield with her (charge_token[born])`);
|
||
assert.ok(h.b.time - back < 0.15, `${label(f)}: at once`);
|
||
assert.equal(u.dir, 'UP');
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('T2 蓄能释放: each damage instance on the shield (next to her) gives her 1 SP with 55 % (SPT-Y stage 3: 65 % and its next redeploy −1 s per success, ≤ 14 s); a 流失 does not count', () => {
|
||
for (const f of [[5, false, null], [6, true, SPTY], [5, true, SPTY]]) {
|
||
const [tier, elite, mod] = f;
|
||
const tb = talentOf(tier, elite, mod, 1);
|
||
const y3 = tier === 6 && mod === SPTY;
|
||
assert.deepEqual([tb.prob, tb.sp, tb.respawn_time ?? 0, tb.respawn_time_max ?? 0], y3 ? [0.65, 1, 1, 14] : [0.55, 1, 0, 0], label(f));
|
||
const { h, u, t } = field({ tier, elite, mod, skill: 0, shield: ABOVE, seed: 17 });
|
||
// a shooter beside the shield and out of her range: it picks the shield (taunt 1), she cannot reach it
|
||
h.spawn('enemy_shooter', { pos: [12, 5] });
|
||
h.run(40);
|
||
const hits = h.hooksOf('damaged').filter((c) => c.target === t && c.amount > 0).length;
|
||
const gains = h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'talent');
|
||
assert.ok(hits > 60, `${label(f)}: ${hits} hits`);
|
||
assert.ok(gains.every((c) => c.amount === 1), `${label(f)}: 1 SP each`);
|
||
const share = gains.length / hits;
|
||
assert.ok(Math.abs(share - tb.prob) < 0.12, `${label(f)}: ${gains.length} / ${hits} ≈ ${tb.prob}`);
|
||
assert.equal(t.mem.respawnCut, y3 ? Math.min(14, gains.length) : 0, `${label(f)}: redeploy cut`);
|
||
const n = gains.length;
|
||
h.b.loseHp(t, 10, { source: null });
|
||
h.step();
|
||
assert.equal(h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'talent').length, n, `${label(f)}: a 流失 gives nothing`);
|
||
if (y3) {
|
||
h.b.retreat(t, { reason: 'expired', permanent: true });
|
||
approx(t.mem.readyAt, h.b.time + 30 - 14, `${label(f)}: back 30 − 14 s later`, 1e-6);
|
||
}
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('S1 坚守阵线 (AUTO, SP_FULL): at full SP with no enemy, 持续时间无限; ATK / DEF +40 % / +55 %; her shield DEF +40 % / +55 % and +20 s of life once per deployment', () => {
|
||
for (const f of [[5, false, null], [6, true, SPTX]]) {
|
||
const [tier, elite, mod] = f;
|
||
const sk = skillOf(tier, elite, S1);
|
||
const { h, u, t } = field({ tier, elite, mod, skill: 0, shield: ABOVE });
|
||
assert.deepEqual([u.skill.rule, sk.trigger.rule, u.skill.kind, sk.spCost, sk.bb.atk, sk.bb.def, sk.bb.duration], ['SP_FULL', 'DEFAULT', 'toggle', elite ? 90 : 100, elite ? 0.55 : 0.4, elite ? 0.55 : 0.4, 20], label(f));
|
||
const life = tokenTalent(tier, elite, mod).duration;
|
||
assert.ok(h.runUntil(() => u.skill.active, sk.spCost + 1), `${label(f)}: cast at ${sk.spCost} s with no enemy`);
|
||
approx(h.b.time, sk.spCost, `${label(f)}: at full SP`, 0.02);
|
||
// the first shield expired before (life < cost) and came back at life + 30: it gets the 20 s now — or, T6 (60 + 30 =
|
||
// the 90 s cost), on its return a tick after the cast (its timers count from its deployment on the first tick)
|
||
assert.ok(h.runUntil(() => t.alive, 0.1), `${label(f)}: the shield is on the field`);
|
||
h.step();
|
||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `${label(f)}: ATK`);
|
||
approx(u.s.def, u.base.def * (1 + sk.bb.def + 0.18), `${label(f)}: DEF (+18 % T1)`);
|
||
approx(t.s.def, t.base.def * (1 + sk.bb.def), `${label(f)}: shield DEF`);
|
||
approx(t.mem.lifeEnd - t.deployedAt, life + 20, `${label(f)}: shield life +20 s`);
|
||
h.run(200);
|
||
assert.ok(u.skill.active, `${label(f)}: still on 200 s later`);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('S2 掩蔽护卫 (MANUAL, data ACTIVE_RANGE — rawRule TAKE_DAMAGE): casts with an enemy on its 2-5 that her 2-2 does not reach; 15 s, range 2-5 (SPT-Y does not grow it), ATK +45 % / +60 %, interval 0.3 s, 75 % physical and arts dodge', () => {
|
||
for (const f of [[5, false, null], [6, true, SPTY], [5, true, SPTY]]) {
|
||
const [tier, elite, mod] = f;
|
||
const sk = skillOf(tier, elite, S2);
|
||
const { h, u } = field({ tier, elite, mod, skill: 1, shield: null });
|
||
assert.deepEqual([u.skill.rule, sk.trigger.rule, sk.trigger.rawRule, sk.duration, sk.bb.atk, sk.bb.base_attack_time, sk.bb.prob], ['ACTIVE_RANGE', 'ACTIVE_RANGE', 'TAKE_DAMAGE', 15, elite ? 0.6 : 0.45, -0.9, 0.75], label(f));
|
||
u.skill.gainSp(999);
|
||
h.run(1);
|
||
assert.equal(u.skill.activations, 0, `${label(f)}: no enemy, no cast`);
|
||
// (11, 6): on the 2-5 (local [1, 2]), not on her 2-2 (+1)
|
||
h.spawn('enemy_dummy', { pos: [11, 6] });
|
||
assert.ok(h.runUntil(() => u.skill.active, 1), `${label(f)}: cast for an enemy only the skill range reaches`);
|
||
approx(u.skill.timeLeft, 15, label(f), 0.01);
|
||
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `${label(f)}: 2-5 exactly`);
|
||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `${label(f)}: ATK`);
|
||
approx(u.s.interval, 0.3, `${label(f)}: 1.2 − 0.9 s`);
|
||
assert.deepEqual([u.s.dodgePhys, u.s.dodgeArts], [0.75, 0.75], `${label(f)}: dodge`);
|
||
h.runUntil(() => !u.skill.active, 20);
|
||
const ext = mod === SPTY ? 1 : 0;
|
||
assert.deepEqual(sortN(u.rangeKeys), sortN([...Array(3 + ext).keys()].map((i) => K(10, 4 + i))), `${label(f)}: back to her own range`);
|
||
assert.deepEqual([u.s.dodgePhys, u.s.interval], [0, 1.2], `${label(f)}: all back`);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('S3 饱和迸射 (MANUAL, data ACTIVE_RANGE on 2-2 + 1): 20 bullets, range +1 (SPT-Y: +2), interval 1.8 s, ATK +240 % / +270 %, DEF +50 % / +65 %, shield DEF +100 % / +135 %; casts with an enemy only the larger range reaches', () => {
|
||
for (const f of [[5, false, null], [6, true, SPTY], [6, true, SPTX]]) {
|
||
const [tier, elite, mod] = f;
|
||
const sk = skillOf(tier, elite, S3);
|
||
const { h, u, t } = field({ tier, elite, mod, skill: 2, shield: BEHIND });
|
||
assert.deepEqual([u.skill.rule, sk.trigger.rule, sk.trigger.rawRule, u.skill.kind, u.skill.ammo], ['ACTIVE_RANGE', 'ACTIVE_RANGE', 'TAKE_DAMAGE', 'ammo', 20], label(f));
|
||
assert.equal(u.dir, 'LEFT');
|
||
const ext = mod === SPTY ? 1 : 0;
|
||
u.skill.gainSp(999);
|
||
// facing LEFT from (10, 4): her own range reaches col 4 − 2 − ext; one more tile only with the skill
|
||
h.spawn('enemy_dummy', { pos: [10, 1 - ext] });
|
||
assert.ok(h.runUntil(() => u.skill.active, 1), `${label(f)}: cast`);
|
||
assert.deepEqual(sortN(u.rangeKeys), sortN([...Array(4 + ext).keys()].map((i) => K(10, 4 - i))), `${label(f)}: +1 tile (${ext ? 'and SPT-Y\'s' : 'no module'})`);
|
||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `${label(f)}: ATK`);
|
||
approx(u.s.def, u.base.def * (1 + sk.bb['jesca2_s_3[def].def'] + 0.18), `${label(f)}: DEF (+18 % T1)`);
|
||
approx(u.s.interval, 1.8, `${label(f)}: 1.2 + 0.6 s`);
|
||
approx(t.s.def, t.base.def * (1 + sk.bb['jesca2_s_3_token[def].def']), `${label(f)}: shield DEF`);
|
||
assert.deepEqual([sk.bb.atk, sk.bb['jesca2_s_3[def].def'], sk.bb['jesca2_s_3_token[def].def']], elite ? [2.7, 0.65, 1.35] : [2.4, 0.5, 1], label(f));
|
||
h.runUntil(() => !u.skill.active, 60);
|
||
assert.equal(h.hooksOf('ammoUsed').filter((c) => c.unit === u).length, 20, `${label(f)}: 20 bullets`);
|
||
assert.equal(t.findBuff('skill:jesca2:shield'), null, `${label(f)}: shield DEF back`);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('S3 shell: with her shield next to her at the cast it flies ahead at 5 tiles / s, bursts on the first enemy it touches: every enemy within 1.7 (air units too) takes 250 % ATK physical and a 5 s stun; no bullet spent; none without a shield, one when a shield comes during the skill, held while she is disarmed', () => {
|
||
for (const f of [[5, false, null], [6, true, SPTY]]) {
|
||
const [tier, elite, mod] = f;
|
||
const sk = skillOf(tier, elite, S3);
|
||
assert.deepEqual([sk.bb['attack@extrabomb.atk_scale'], sk.bb['attack@extrabomb.stun'], sk.bb['attack@extrabomb.projectile_range']], [2.5, 5, 1.7], label(f));
|
||
const { h, u, t } = field({ tier, elite, mod, skill: 2, shield: FRONT });
|
||
const near = h.spawn('enemy_dummy', { pos: [10, 7] }), side = h.spawn('enemy_dummy', { pos: [11, 7] });
|
||
const fly = h.spawn('enemy_fly', { pos: [11, 6] }), far = h.spawn('enemy_dummy', { pos: [12, 8] });
|
||
u.skill.gainSp(999);
|
||
u.skill.activate('test');
|
||
const atk = u.s.atk;
|
||
assert.ok(h.runUntil(() => bombHits(h, u).length > 0, 2), `${label(f)}: the shell lands`);
|
||
// from (10, 4) to the enemy at (10, 7): it touches it 0.5 before, at x 6.5, i.e. 2.5 tiles in 0.5 s
|
||
approx(h.hooksOf('damaged').find((c) => c.dmg?.tags?.includes('jesca2:bomb')).t, 0.5 + h.hooksOf('skillStart').find((c) => c.unit === u).t, `${label(f)}: flight time`, 0.1);
|
||
const hits = bombHits(h, u);
|
||
assert.deepEqual(sortN(hits.map((c) => c.target.id)), sortN([near.id, side.id, fly.id]), `${label(f)}: within 1.7, the flyer too`);
|
||
for (const c of hits) {
|
||
approx(c.amount, atk * 2.5, `${label(f)}: 250 % ATK`);
|
||
assert.deepEqual([c.type, c.dmg.isSkill], ['phys', true], label(f));
|
||
}
|
||
const stuns = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'stun');
|
||
assert.deepEqual(sortN(stuns.map((c) => c.target.id)), sortN([near.id, side.id, fly.id]), `${label(f)}: stunned`);
|
||
for (const c of stuns) approx(c.duration, 5, `${label(f)}: 5 s`);
|
||
assert.ok(!far.findBuff('stun'), `${label(f)}: out of the burst`);
|
||
assert.equal(u.skill.ammoLeft + h.hooksOf('ammoUsed').filter((c) => c.unit === u).length, 20, `${label(f)}: the shell spends no bullet`);
|
||
h.run(3);
|
||
assert.equal(bombHits(h, u).length, hits.length, `${label(f)}: one shell per cast`);
|
||
// a shield coming during the skill fires another (her shield back at once)
|
||
h.b.retreat(t, { reason: 'expired', permanent: true });
|
||
t.mem.readyAt = h.b.time;
|
||
assert.ok(h.runUntil(() => t.alive, 1), label(f));
|
||
assert.ok(h.runUntil(() => bombHits(h, u).length > hits.length, 2), `${label(f)}: a second shell`);
|
||
done(h);
|
||
}
|
||
// no shield: no shell; disarmed: it waits
|
||
const a = field({ skill: 2, shield: null });
|
||
a.h.spawn('enemy_dummy', { pos: [10, 6] });
|
||
a.u.skill.gainSp(999);
|
||
a.u.skill.activate('test');
|
||
a.h.run(3);
|
||
assert.equal(bombHits(a.h, a.u).length, 0, 'no shield: no shell');
|
||
done(a.h);
|
||
const b = field({ skill: 2, shield: FRONT });
|
||
b.h.spawn('enemy_dummy', { pos: [10, 7] });
|
||
b.h.b.addBuff(b.u, { key: 'test:disarm', flags: { disarm: true }, duration: 2 });
|
||
b.u.skill.gainSp(999);
|
||
b.u.skill.activate('test');
|
||
b.h.run(1.5);
|
||
assert.equal(bombHits(b.h, b.u).length, 0, 'disarmed: held');
|
||
assert.ok(b.h.runUntil(() => bombHits(b.h, b.u).length > 0, 2), 'fired once the disarm is gone');
|
||
done(b.h);
|
||
});
|
||
|
||
test('modules: SPT-X reveals the stealthed enemies of her range (stages 1 and 3); SPT-Y is a permanent +1 range (her initial range, S3 on top, never S2); with no module neither', () => {
|
||
for (const f of FORMS_ALL) {
|
||
const [tier, elite, mod] = f;
|
||
const { h, u } = field({ tier, elite, mod, skill: 0, shield: null });
|
||
const inR = h.spawn('enemy_dummy', { pos: [10, 6] }), outR = h.spawn('enemy_dummy', { pos: [10, 9] });
|
||
for (const e of [inR, outR]) h.b.addBuff(e, { key: 'test:stealth', flags: { stealth: true } });
|
||
h.run(0.5);
|
||
assert.equal(!!inR.s.flags.reveal, elite && mod === SPTX, `${label(f)}: reveal in range`);
|
||
assert.equal(!!outR.s.flags.reveal, false, `${label(f)}: never out of range`);
|
||
const ext = elite && mod === SPTY ? 1 : 0;
|
||
assert.equal(u.s.baseRangeExtend, ext, `${label(f)}: permanent extension`);
|
||
assert.deepEqual(u.liveRangeGrid.length, 3 + ext, `${label(f)}: initial range`);
|
||
if (elite && mod) {
|
||
const m = modOf(tier, mod);
|
||
assert.ok(mod === SPTX ? /隐匿/.test(m.traitOverride.moduleDesc) : m.traitOverride.bb.ability_range_forward_extend === 1, `${label(f)}: the data`);
|
||
}
|
||
done(h);
|
||
}
|
||
});
|