0.2.5-dev: merge claude/036-sim
# Conflicts: # CHANGELOG.md # docs/history/0.2.5.md # test/golden/matches.json
This commit is contained in:
commit
3dd2213e47
42 files changed
+2335
-961
No files matched your search
@@ -50,7 +50,8 @@ test('#2 麦哲伦 S1: an enemy on a standing drone\'s range (air units too) cas
|
||||
const { h, u, ps } = field(MG, 0, [[DRONE_F, 12, 9]]);
|
||||
assert.equal(u.skill.rule, 'DEFAULT');
|
||||
assert.ok(ps[0].alive && ps[0].rangeKeySet.has(12 * COLS + 10) && outOfHerRange(u, 12, 10), 'the setup');
|
||||
h.spawn('enemy_dummy', { pos: [11, 13] }); // outside every area
|
||||
// (11, 13) would be held at the field edge, on the drone's tile (11, 10). Stay on the field and outside every area.
|
||||
h.spawn('enemy_dummy', { pos: [11, 6] });
|
||||
assert.ok(!casts(h, u), 'nobody in her range or a drone\'s: no cast');
|
||||
h.spawn('enemy_dummy', { pos: [12, 10] });
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 1), 'an enemy on the drone\'s range casts S1');
|
||||
|
||||
@@ -659,9 +659,9 @@ test('modules: removing the elite module changes the trait / talent behaviour th
|
||||
const id = 'chess_char_2_10_b';
|
||||
const [a, b] = withAndWithout((m) => {
|
||||
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4, carryState: IDLE, ...mod(m) }], enemies: [{ key: 'e', pos: [10, 6] }] });
|
||||
h.runUntil(() => dealt(h, h.unit(id)).length > 0, 5);
|
||||
h.runUntil(() => dealt(h, h.unit(id), (c) => c.dmg.tags.includes('funnel')).length > 0, 5);
|
||||
const u = h.unit(id);
|
||||
const r = dealt(h, u)[0].amount / u.s.atk;
|
||||
const r = dealt(h, u, (c) => c.dmg.tags.includes('funnel'))[0].amount / u.s.atk;
|
||||
done(h);
|
||||
return r;
|
||||
});
|
||||
|
||||
@@ -1070,7 +1070,7 @@ test('6_17 耀骑士临光 S2 逐夜烁光: on deploy ATK + and 3 护盾 layers
|
||||
// =================================================================================================================
|
||||
// 6_18 荒芜拉普兰德
|
||||
|
||||
test('6_18 荒芜拉普兰德 S1 慵怠者悲鸣: passive 浮游单元+1 (2 hits per attack); toggled on: ATK + and the drones lock still enemies anywhere', () => {
|
||||
test('6_18 荒芜拉普兰德 S1 慵怠者悲鸣: passive 浮游单元+1 (one body plus two additions); toggled on: ATK + and the drones lock still enemies anywhere', () => {
|
||||
for (const id of both('chess_char_6_18')) {
|
||||
const sid = 'skchr_whitw2_1', bb = bbOf(id, sid);
|
||||
const idle = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, sid, 10, 4, { carryState: IDLE })], enemies: [{ key: 'e', pos: [10, 6] }] });
|
||||
@@ -1079,7 +1079,9 @@ test('6_18 荒芜拉普兰德 S1 慵怠者悲鸣: passive 浮游单元+1 (2 hits
|
||||
const a0 = idle.hooksOf('attack').find((c) => c.attacker === iu);
|
||||
assert.ok(a0);
|
||||
idle.run(1);
|
||||
assert.equal(dealt(idle, iu, (c) => c.dmg.attackId === dealt(idle, iu)[0].dmg.attackId).length, 2, 'drone +1');
|
||||
const volley = dealt(idle, iu, (c) => c.dmg.attackId === dealt(idle, iu)[0].dmg.attackId);
|
||||
assert.equal(volley.filter((c) => c.dmg.isAttack).length, 1, 'one body hit');
|
||||
assert.equal(volley.filter((c) => c.dmg.tags.includes('funnel')).length, 2, 'drone +1');
|
||||
done(idle);
|
||||
|
||||
const h = run({
|
||||
|
||||
@@ -7,6 +7,7 @@ import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/batt
|
||||
import { getDefaultSource } from '../../server/sim/simdata.js';
|
||||
import { ELEMENT_GAUGE_MAX, PROJECTILE_SPEEDS, BOOMERANG_RETURN_SPEED } from '../../server/sim/constants.js';
|
||||
import { sortEnemyTargets } from '../../server/sim/targeting.js';
|
||||
import { DRONE_TAG as ROCKROCK_DRONE } from '../../server/sim/content/kits/ops/chess_char_2_10-rockr.js';
|
||||
|
||||
const ds = getDefaultSource();
|
||||
const raw = (id) => ds.rawChess(id);
|
||||
@@ -1341,7 +1342,9 @@ test('2_10 洛洛: 自负此轭 drones stay locked on their target while the ski
|
||||
const t0 = h.b.time;
|
||||
h.run(4);
|
||||
const atks = h.hooksOf('attack').filter((c) => c.attacker === u && c.t > t0);
|
||||
assert.ok(atks.length > 0 && atks.every((c) => c.targets.length === 1 && c.targets[0] === first), 'locked');
|
||||
assert.ok(atks.length > 0 && atks.every((c) => c.targets.length === 1 && c.targets[0] === other), 'body keeps normal targeting');
|
||||
const drones = h.hooksOf('damaged').filter((c) => c.source === u && c.t > t0 && c.dmg.tags?.includes(ROCKROCK_DRONE));
|
||||
assert.ok(drones.length > 0 && drones.every((c) => c.target === first && c.dmg.isSkill && !c.dmg.isAttack), 'drone stays locked');
|
||||
done(h);
|
||||
});
|
||||
|
||||
|
||||
@@ -488,13 +488,13 @@ test('3_13 至简: 神工意匠 charges: ATK% arts ×2 per cast; 忽有所悟 cr
|
||||
const u = h.unit(id);
|
||||
assert.equal(u.skill.maxCharges, bb.ct);
|
||||
h.run(60);
|
||||
const normal = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack && !c.dmg.isSkill).map((c) => c.amount / u.s.atk);
|
||||
const normal = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.tags.includes('funnel') && !c.dmg.isSkill).map((c) => c.amount / u.s.atk);
|
||||
const ramp = (v) => [tb.max_atk_scale, tb.max_atk_scale * t0.atk_scale].some((x) => Math.abs(v - x) < 1e-6);
|
||||
assert.ok(normal.slice(10).every(ramp), 'ramped drone hits: max or max × crit');
|
||||
assert.ok(normal.some((v) => Math.abs(v - tb.max_atk_scale * t0.atk_scale) < 1e-6), 'some crits');
|
||||
assert.ok(normal.some((v) => Math.abs(v - tb.max_atk_scale) < 1e-6), 'some plain hits');
|
||||
const skillAtks = h.hooksOf('attack').filter((c) => c.attacker === u && c.isSkill).length;
|
||||
const sk = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isSkill).map((c) => c.amount / u.s.atk);
|
||||
const sk = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isSkill && c.dmg.isAttack).map((c) => c.amount / u.s.atk);
|
||||
assert.ok(skillAtks >= 2);
|
||||
assert.equal(sk.length, skillAtks * 2, 'two hits per cast');
|
||||
for (const v of sk) assert.ok([bb.atk_scale, bb.atk_scale * t0.atk_scale].some((x) => Math.abs(v - x) < 1e-6), `skill hit ${v}`);
|
||||
@@ -790,16 +790,25 @@ test('3_20 耶拉: 低眉 ATK (ground tiles in range); 心随意动 +1 drone, AT
|
||||
h.step();
|
||||
approx(u.s.atk, u.base.atk * (1 + t0['kjera_t_1[high].atk']));
|
||||
h.run(4);
|
||||
const first = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack).map((c) => c.amount / u.s.atk);
|
||||
const first = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.tags.includes('funnel')).map((c) => c.amount / u.s.atk);
|
||||
assert.ok(first[1] > first[0], 'drone ramps on the same target');
|
||||
fill(u);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 5));
|
||||
const tOn = h.b.time;
|
||||
approx(u.s.atk, u.base.atk * (1 + t0['kjera_t_1[high].atk'] + bb.atk));
|
||||
const full = u.s.atk * u.s.atkScaleMul;
|
||||
h.runUntil(() => !u.skill.active, 30);
|
||||
const sk = h.hooksOf('attack').filter((c) => c.attacker === u && c.isSkill);
|
||||
assert.ok(sk.length > 5 && sk.every((c) => c.targets.length === 1 + bb['attack@cnt']), 'two drone locks');
|
||||
const during = (c) => c.t + 1e-9 >= tOn - h.b.dt && c.t + 1e-9 < h.b.time;
|
||||
const atks = h.hooksOf('attack').filter((c) => c.attacker === u && during(c) && !c.isSkill);
|
||||
assert.ok(atks.length > 5 && atks.every((c) => c.targets.length === 1), 'the body keeps a single in-range target');
|
||||
const drones = h.hooksOf('damaged').filter((c) => c.source === u && during(c) && c.dmg?.tags?.includes('kjera:drone'));
|
||||
assert.ok(drones.length >= atks.length, 'released drones keep firing');
|
||||
const bodies = h.hooksOf('damaged').filter((c) => c.source === u && during(c) && c.dmg?.isAttack && !c.dmg.isSkill
|
||||
&& Math.abs(c.amount - full) < 1e-4);
|
||||
assert.ok(bodies.length >= 2, 'her own hits stay at 100% ATK');
|
||||
const colds = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'cold');
|
||||
assert.ok(colds.length >= 1 && colds.length < sk.length * 2, `cold procs (${colds.length}/${sk.length * 2})`);
|
||||
const chances = bodies.length + drones.length;
|
||||
assert.ok(colds.length >= 1 && colds.length < chances, `cold procs (${colds.length}/${chances})`);
|
||||
approx(colds[0].duration, bb['attack@cold']);
|
||||
done(h);
|
||||
});
|
||||
@@ -1076,24 +1085,33 @@ test('3_19 伺夜: the pack never takes a later board unit’s tile; a 狼群 pi
|
||||
});
|
||||
|
||||
test('3_20 耶拉: 心随意动 with a single enemy — both drones lock it and ramp in parallel', () => {
|
||||
const id = 'chess_char_3_20_b', bb = BB(id), tb = TR(id);
|
||||
const id = 'chess_char_3_20_b', tb = TR(id);
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { enemy_a: dummy('enemy_a') } }, timeLimit: 80, hooks: ['damaged', 'attack'], captureNoisy: true,
|
||||
units: [{ chessId: id, row: 10, col: 2 }], enemies: [{ key: 'enemy_a', pos: [10, 4] }],
|
||||
});
|
||||
const u = h.unit(id);
|
||||
h.run(12);
|
||||
const plain = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack).map((c) => c.amount / u.s.atk);
|
||||
const plain = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.tags.includes('funnel')).map((c) => c.amount / u.s.atk);
|
||||
approx(plain[plain.length - 1], tb.max_atk_scale, 1e-6, 'module ramp cap');
|
||||
fill(u);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 5));
|
||||
const t0 = h.b.time;
|
||||
const n0 = h.hooksOf('damaged').length;
|
||||
h.run(8);
|
||||
const sk = h.hooksOf('attack').filter((c) => c.attacker === u && c.isSkill);
|
||||
assert.ok(sk.length >= 3 && sk.every((c) => c.targets.length === 1 + bb['attack@cnt'] && c.targets.every((t) => t.defId === 'enemy_a')));
|
||||
const hits = h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && c.dmg?.isAttack);
|
||||
assert.ok(hits.length >= 2 * (sk.length - 1));
|
||||
for (let i = 0; i + 1 < hits.length; i += 2) approx(hits[i].amount, hits[i + 1].amount, 1e-9, 'same volley, same scale');
|
||||
const atks = h.hooksOf('attack').filter((c) => c.attacker === u && c.t + 1e-9 >= t0 - h.b.dt && !c.isSkill);
|
||||
assert.ok(atks.length >= 3 && atks.every((c) => c.targets.length === 1 && c.targets.every((t) => t.defId === 'enemy_a')), 'one body target');
|
||||
const drones = h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && c.dmg?.tags?.includes('kjera:drone'));
|
||||
assert.ok(drones.length >= 4, 'both drones stay on the lone enemy');
|
||||
const byT = new Map();
|
||||
for (const c of drones) (byT.get(c.t) ?? byT.set(c.t, []).get(c.t)).push(c);
|
||||
const pairs = [...byT.values()].filter((v) => v.length === 2);
|
||||
assert.ok(pairs.length >= 2, 'the two drones share a volley');
|
||||
for (const v of pairs) approx(v[0].amount, v[1].amount, 1e-9, 'same volley, same scale');
|
||||
const full = u.s.atk * u.s.atkScaleMul;
|
||||
const body = h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && c.dmg?.isAttack && !c.dmg.isSkill
|
||||
&& Math.abs(c.amount - full) < 1e-4);
|
||||
assert.ok(body.length >= 2, 'body stays at 100% ATK while the drones ramp');
|
||||
done(h);
|
||||
});
|
||||
|
||||
@@ -1159,7 +1177,7 @@ test('精锐 numbers from the golden blackboards (module traits handled by the p
|
||||
const ml = m.unit(e13);
|
||||
assert.equal(ml.skill.maxCharges, BB(e13).ct);
|
||||
m.run(25);
|
||||
const ramps = m.hooksOf('damaged').filter((c) => c.source === ml && c.dmg?.isAttack && !c.dmg.isSkill).map((c) => c.amount / ml.s.atk);
|
||||
const ramps = m.hooksOf('damaged').filter((c) => c.source === ml && c.dmg?.tags.includes('funnel') && !c.dmg.isSkill).map((c) => c.amount / ml.s.atk);
|
||||
assert.ok(ramps.some((v) => Math.abs(v - TR(e13).max_atk_scale) < 1e-6), 'module cap 120 %');
|
||||
assert.ok(ramps.every((v) => v <= TR(e13).max_atk_scale * TB(e13, 0).atk_scale + 1e-6));
|
||||
done(m);
|
||||
|
||||
@@ -138,12 +138,16 @@ test('耶拉 S2: two drones ramp independently on two targets, ATK up; 低眉 +1
|
||||
const n0 = dmgBy(h, u).length;
|
||||
h.run(8);
|
||||
const hits = dmgBy(h, u).slice(n0);
|
||||
const onA = hits.filter((c) => c.target === a).map((c) => c.amount), onB = hits.filter((c) => c.target === b).map((c) => c.amount);
|
||||
const drones = hits.filter((c) => c.dmg?.tags?.includes('kjera:drone'));
|
||||
const onA = drones.filter((c) => c.target === a).map((c) => c.amount), onB = drones.filter((c) => c.target === b).map((c) => c.amount);
|
||||
assert.ok(onA.length >= 3 && onB.length >= 3, 'both locked targets attacked');
|
||||
const atk = u.s.atk;
|
||||
approx(onA[0], atk * tr.init_atk_scale, 1e-6, 'ramp start');
|
||||
approx(onA[1], atk * (tr.init_atk_scale + tr.delta_atk_scale), 1e-6, 'ramp +15 %');
|
||||
approx(onB[0], atk * tr.init_atk_scale, 1e-6, 'second drone starts its own ramp');
|
||||
approx(onB[1], atk * (tr.init_atk_scale + tr.delta_atk_scale), 1e-6, 'second drone ramps on its own');
|
||||
const body = hits.filter((c) => c.dmg?.isAttack && !c.dmg.isSkill);
|
||||
assert.ok(body.length >= 1 && body.some((c) => Math.abs(c.amount - atk) < 1e-4), 'her own hit stays at 100% ATK');
|
||||
h.run(15);
|
||||
assert.ok(noisy(h, 'statusApplied').some((c) => c.source === u && c.status === 'cold' && c.duration === bb['attack@cold']), 'cold proc');
|
||||
checkInvariants(h.b);
|
||||
@@ -1032,14 +1036,17 @@ test('耶拉 S2: against a lone enemy both drones lock it and ramp independently
|
||||
const n0 = dmgBy(h, u).length;
|
||||
h.run(4);
|
||||
const hits = dmgBy(h, u).slice(n0);
|
||||
const drones = hits.filter((c) => c.dmg?.tags?.includes('kjera:drone'));
|
||||
const byT = new Map();
|
||||
for (const c of hits) (byT.get(c.t) ?? byT.set(c.t, []).get(c.t)).push(c.amount / u.s.atk);
|
||||
for (const c of drones) (byT.get(c.t) ?? byT.set(c.t, []).get(c.t)).push(c.amount / u.s.atk);
|
||||
const volleys = [...byT.values()];
|
||||
assert.ok(volleys.length >= 3);
|
||||
assert.ok(volleys.every((v) => v.length === 2), 'two drone hits per attack');
|
||||
assert.ok(volleys.every((v) => v.length === 2), 'two drone hits per tick');
|
||||
approx(volleys[0][0], tr.init_atk_scale, 1e-6);
|
||||
approx(volleys[0][1], tr.init_atk_scale, 1e-6, 'second drone starts its own ramp');
|
||||
approx(volleys[1][1], tr.init_atk_scale + tr.delta_atk_scale, 1e-6);
|
||||
const full = u.s.atk * u.s.atkScaleMul;
|
||||
assert.ok(hits.some((c) => c.dmg?.isAttack && !c.dmg.isSkill && Math.abs(c.amount - full) < 1e-4), 'body at 100% ATK');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
|
||||
@@ -997,8 +997,9 @@ test('荒芜拉普兰德: 叙拉古的荣幸 +5 initial SP on every deployment;
|
||||
// (S3 may be running — its drones do not normal-attack): wait for a normal attack after stage 3
|
||||
assert.ok(h.runUntil(() => h.dmg.some((d) => d.src === u.id && d.attack && d.tgt === e.id && d.t > ts), 60));
|
||||
const last = h.dmg.filter((d) => d.src === u.id && d.attack && d.tgt === e.id).at(-1);
|
||||
const same = h.dmg.filter((d) => d.src === u.id && d.attack && d.t === last.t);
|
||||
assert.equal(same.length, 2, 'two drone hits per attack');
|
||||
const same = h.dmg.filter((d) => d.src === u.id && d.t === last.t);
|
||||
assert.equal(same.filter((d) => d.attack).length, 1, 'one body hit per attack');
|
||||
assert.equal(same.filter((d) => d.tags.includes('funnel')).length, 2, 'two drone additions per attack');
|
||||
});
|
||||
|
||||
test('缪尔赛思: 流形 copies stats/range/damage type but not the attack shape (锏 2-hit); a capped melee copy steals nothing more', () => {
|
||||
@@ -1120,7 +1121,8 @@ test('异客: the S3 storm is range x-1 — the 13-tile diamond — around the t
|
||||
// in the diamond (|Δrow| + |Δcol| ≤ 2 by tile): (2, 0) / (0, −2) / (1.95, 0.4) lay outside the old 1.5 circle
|
||||
for (const off of [[0, 0], [2, 0], [0, -2], [-2, 0], [1.45, 1.45], [1.95, 0.4]]) assert.equal(strikes(off), 8, `(${off}) struck by all 8 strikes`);
|
||||
// outside it: tile (+2, +1), and tile (+3, 0) — (1.55, 1.05) was inside the old circle around the target's position
|
||||
for (const off of [[2, 1], [1.55, 1.05], [0, 2.6]]) assert.equal(strikes(off), 0, `(${off}) never struck`);
|
||||
// three tiles off along the columns: the field has no row 13 (a probe there is held on row 12, inside the diamond)
|
||||
for (const off of [[2, 1], [1.55, 1.05], [2.6, 0]]) assert.equal(strikes(off), 0, `(${off}) never struck`);
|
||||
});
|
||||
|
||||
test('蕾缪安: 通缉 needs a continuous 8 s stay in a 拉特兰 range (leaving restarts the count)', () => {
|
||||
|
||||
@@ -80,18 +80,22 @@ test('a 自选 pick: 澄闪 is offered at tiers 5 and 6 and a roster with her pa
|
||||
{ ok: true, picks: { [SLOT[6]]: { charId: GD, skillIndex: 2, uniEquipId: FY } } });
|
||||
});
|
||||
|
||||
test('her normal attack (no skill): one drone hit per attack — init, +delta per hit on the same target, up to the cap (FUN-X from 0.35, FUN-Y to 1.2); 精准导流 lowers the RES it meets', () => {
|
||||
for (const f of [[5, false, null], [5, true, FX], [6, true, FY]]) {
|
||||
test('her normal attack (no skill): a body hit and one drone addition — init, +delta per hit on the same target, up to the cap (FUN-X from 0.35, FUN-Y to 1.2); 精准导流 lowers the RES of both', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 2 });
|
||||
const e = h.spawn('enemy_res', { pos: [10, 6] });
|
||||
h.run(12);
|
||||
const hits = mine(h, u).filter((c) => c.target === e && c.dmg.isAttack);
|
||||
const drones = mine(h, u).filter((c) => c.target === e && c.dmg.tags.includes('funnel'));
|
||||
assert.ok(hits.length >= 8, `${label(f)}: ${hits.length} attacks`);
|
||||
assert.equal(drones.length, hits.length, `${label(f)}: one addition per body hit`);
|
||||
const tb = traitOf(tier, elite, mod).bb, pen = talentOf(tier, elite, mod, 1).bb.magic_resist_penetrate_fixed;
|
||||
hits.slice(0, 8).forEach((c, i) => {
|
||||
const ramp = Math.min(tb.max_atk_scale, tb.init_atk_scale + i * tb.delta_atk_scale);
|
||||
approx(c.amount, u.s.atk * ramp * (1 - (50 - pen) / 100), `${label(f)}: attack ${i + 1} at ${ramp}`);
|
||||
approx(c.amount, u.s.atk * (1 - (50 - pen) / 100), `${label(f)}: body attack ${i + 1}`);
|
||||
approx(drones[i].amount, c.amount * ramp, `${label(f)}: drone at ${ramp}`);
|
||||
assert.equal(drones[i].dmg.attackId, c.dmg.attackId, 'same attack action');
|
||||
assert.equal(c.type, 'arts');
|
||||
});
|
||||
done(h);
|
||||
|
||||
@@ -330,7 +330,7 @@ test('S2 连星 (AUTO, SP_FULL): a triggered piece hits every enemy on its conne
|
||||
assert.deepEqual([sk.spCost, sk.bb['attack@atk_scale'], sk.bb['attack@move_speed'], sk.bb['attack@duration']], elite ? [16, 4.8, -0.4, 6] : [17, 4.2, -0.35, 6], `T${tier}`);
|
||||
const { h, u } = field({ tier, elite, skill: 1, stones: [[10, 6], [10, 7]] });
|
||||
const onLine = h.spawn('enemy_fly', { pos: [10, 9] }); // 3 tiles right of (10,6)…(10,7)+2: on the horizontal line
|
||||
const beyond = h.spawn('enemy_dummy', { pos: [10, 11] }); // 4 tiles past (10,7)
|
||||
const beyond = h.spawn('enemy_dummy', { pos: [10, 2] }); // 4 tiles before (10,6) (the field ends at column 10)
|
||||
const vertical = h.spawn('enemy_dummy', { pos: [12, 7] }); // its battle-start 跟子 (11,7) is 上 of it: the vertical line is on
|
||||
const off = h.spawn('enemy_dummy', { pos: [9, 4] }); // on neither line
|
||||
const trig = h.spawn('enemy_runner', { pos: [10, 7] });
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
// Controlled regression inputs: real kit, seeded Battle, synthetic enemy records and authored routes.
|
||||
// No live enemy movement/HP edits. These cases are distinct from the normal Match/browser route.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { bodyInKeys } from '../../server/sim/body.js';
|
||||
import { DRONE_TAG } from '../../server/sim/content/kits/ops/chess_char_2_10-rockr.js';
|
||||
import { getDefaultSource } from '../../server/sim/simdata.js';
|
||||
|
||||
const route = {
|
||||
motion: 'FLY', start: [10, 6], end: [9, 2],
|
||||
steps: [{ t: 'wait', s: 3 }, { t: 'move', p: [12, 6] }, { t: 'wait', s: 8 },
|
||||
{ t: 'move', p: [10, 6] }, { t: 'wait', s: 40 }, { t: 'move', p: [9, 2] }],
|
||||
};
|
||||
const stationary = { motion: 'FLY', start: [10, 5], end: [9, 2], steps: [{ t: 'wait', s: 90 }, { t: 'move', p: [9, 2] }] };
|
||||
const setup = (id, { skillIndex = 1, moduleId, newcomer = false, staysOutside = false, fragile = false,
|
||||
outsideHp, stationaryLock = false, killer = false } = {}) => {
|
||||
const lockRoute = staysOutside ? { ...route, steps: [...route.steps.slice(0, 2), { t: 'wait', s: 90 }, { t: 'move', p: [9, 2] }] } : route;
|
||||
const h = makeBattle({ seed: 5, timeLimit: 100, autoFinish: false, captureNoisy: true,
|
||||
hooks: ['damaged', 'attack', 'skillStart', 'skillEnd'],
|
||||
defs: { enemies: {
|
||||
enemy_lock: enemyRec({ key: 'enemy_lock', hp: 1e7, speed: 1, motion: 'FLY' }),
|
||||
enemy_fragile: enemyRec({ key: 'enemy_fragile', hp: 300, speed: 1, motion: 'FLY' }),
|
||||
enemy_outside: enemyRec({ key: 'enemy_outside', hp: outsideHp, speed: 1, motion: 'FLY' }),
|
||||
enemy_killer: enemyRec({ key: 'enemy_killer', hp: 1e7, atk: 1e5, range: 4, motion: 'FLY' }),
|
||||
} },
|
||||
units: [{ chessId: id, row: 10, col: 4, skillIndex, moduleId, carryState: { sp: 999 } }],
|
||||
enemies: [{ key: fragile ? 'enemy_fragile' : outsideHp ? 'enemy_outside' : 'enemy_lock',
|
||||
route: fragile || stationaryLock ? stationary : lockRoute },
|
||||
...(newcomer ? [{ key: 'enemy_lock', route: stationary, time: 7 }] : []),
|
||||
...(killer ? [{ key: 'enemy_killer', route: stationary, time: 7 }] : [])],
|
||||
});
|
||||
h.step();
|
||||
return { h, u: h.unit(id), target: h.enemies()[0] };
|
||||
};
|
||||
const hits = (h, u, target, after) => h.hooksOf('damaged').filter(c => c.source === u && c.target === target && c.t >= after && c.amount > 0);
|
||||
const drone = c => c.dmg.tags?.includes(DRONE_TAG);
|
||||
const approx = (actual, expected) => assert.ok(Math.abs(actual - expected) < 1e-7, `${actual} vs ${expected}`);
|
||||
const done = h => { checkInvariants(h.b); assert.deepEqual(h.b.errors, []); };
|
||||
|
||||
for (const id of ['chess_char_2_10_a', 'chess_char_2_10_b']) {
|
||||
test(`${id} S2: damage continues after a living target leaves the body range`, () => {
|
||||
const { h, u, target } = setup(id);
|
||||
assert.ok(u.skill.active);
|
||||
assert.ok(h.runUntil(() => !bodyInKeys(target, u.rangeKeySet), 10));
|
||||
assert.ok(hits(h, u, target, 0).length > 0, 'the in-range control receives damage before leaving');
|
||||
h.run(2); // discard any projectile already in flight at the boundary
|
||||
const t = h.b.time;
|
||||
const bodyAttacks = u.stats.attacks;
|
||||
h.run(4);
|
||||
assert.ok(target.alive && u.alive && u.skill.active && !bodyInKeys(target, u.rangeKeySet));
|
||||
const outHits = hits(h, u, target, t);
|
||||
assert.ok(outHits.length > 0, 'a living out-of-range lock must still receive drone damage');
|
||||
assert.ok(outHits.every(c => drone(c) && c.type === 'arts' && c.dmg.isSkill && !c.dmg.isAttack), 'pursuit damage must be an independent arts skill drone');
|
||||
assert.equal(u.stats.attacks, bodyAttacks, 'the body cannot make attacks with no enemy in range');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test(`${id} S2: an in-range newcomer cannot steal a living out-of-range drone lock`, () => {
|
||||
const { h, u, target } = setup(id, { newcomer: true });
|
||||
assert.ok(h.runUntil(() => h.enemies().length === 2, 10));
|
||||
assert.ok(target.alive && !bodyInKeys(target, u.rangeKeySet));
|
||||
h.run(2);
|
||||
const t = h.b.time;
|
||||
h.run(3);
|
||||
const outHits = hits(h, u, target, t);
|
||||
assert.ok(outHits.length > 0, 'the original target keeps receiving drone damage with another target in range');
|
||||
assert.ok(outHits.every(c => drone(c) && c.dmg.isSkill && !c.dmg.isAttack), 'the body cannot attack the out-of-range lock');
|
||||
const other = h.enemies().find(e => e !== target);
|
||||
assert.equal(u.mem.rockLock, target);
|
||||
assert.equal(hits(h, u, other, t).filter(drone).length, 0, 'the drone ignores the newcomer');
|
||||
const body = hits(h, u, other, t).filter(c => c.dmg.isAttack);
|
||||
assert.ok(body.length > 0, 'the body still attacks the in-range newcomer');
|
||||
assert.ok(body.every(c => !c.dmg.isSkill && !drone(c)));
|
||||
body.forEach(c => approx(c.amount, u.s.atk * u.s.atkScaleMul));
|
||||
done(h);
|
||||
});
|
||||
|
||||
test(`${id} S2: a target returning into range receives damage`, () => {
|
||||
const { h, u, target } = setup(id);
|
||||
assert.ok(h.runUntil(() => !bodyInKeys(target, u.rangeKeySet), 10));
|
||||
assert.ok(h.runUntil(() => bodyInKeys(target, u.rangeKeySet), 20));
|
||||
const t = h.b.time;
|
||||
h.run(3);
|
||||
const ds = hits(h, u, target, 0).filter(drone);
|
||||
assert.ok(ds.some(c => c.t >= t));
|
||||
assert.equal(u.mem.rockLock, target);
|
||||
ds.slice(1).forEach((c, i) => approx(c.t - ds[i].t, Math.ceil(u.s.interval * 30 - 1e-9) / 30));
|
||||
ds.slice(-3).forEach(c => approx(c.amount, u.s.atk * u.s.atkScaleMul * u.profile.funnel.max));
|
||||
done(h);
|
||||
});
|
||||
|
||||
test(`${id} S2: a target killed by combat releases the lock for another in-range enemy`, () => {
|
||||
const { h, u, target } = setup(id, { fragile: true, newcomer: true });
|
||||
assert.equal(u.mem.rockLock, target);
|
||||
assert.ok(h.runUntil(() => !target.alive, 7));
|
||||
assert.ok(h.runUntil(() => h.enemies().some(e => e !== target && e.alive), 10));
|
||||
const other = h.enemies().find(e => e !== target && e.alive);
|
||||
const t = h.b.time;
|
||||
h.run(3);
|
||||
assert.ok(u.alive && u.skill.active && other.alive);
|
||||
assert.equal(u.mem.rockLock, other);
|
||||
assert.ok(hits(h, u, other, t).length > 0);
|
||||
done(h);
|
||||
});
|
||||
|
||||
test(`${id} S2: the duration end clears the lock and stops out-of-range damage`, () => {
|
||||
const { h, u, target } = setup(id, { staysOutside: true });
|
||||
assert.ok(h.runUntil(() => !bodyInKeys(target, u.rangeKeySet), 10));
|
||||
assert.ok(h.runUntil(() => !u.skill.active, 45));
|
||||
assert.equal(h.hooksOf('skillEnd').find(c => c.unit === u)?.reason, 'duration');
|
||||
assert.equal(u.mem.rockLock, null);
|
||||
h.run(2);
|
||||
const t = h.b.time;
|
||||
h.run(4);
|
||||
assert.ok(target.alive && u.alive && !bodyInKeys(target, u.rangeKeySet));
|
||||
assert.equal(hits(h, u, target, t).length, 0);
|
||||
done(h);
|
||||
});
|
||||
|
||||
test(`${id} S1: damage stops outside the body range`, () => {
|
||||
const { h, u, target } = setup(id, { skillIndex: 0 });
|
||||
assert.ok(u.skill.active);
|
||||
assert.ok(h.runUntil(() => !bodyInKeys(target, u.rangeKeySet), 10));
|
||||
h.run(2);
|
||||
const t = h.b.time;
|
||||
h.run(4);
|
||||
assert.equal(hits(h, u, target, t).length, 0);
|
||||
done(h);
|
||||
});
|
||||
|
||||
test(`${id} S2: an out-of-range combat kill waits for a candidate and restarts the trait ramp`, () => {
|
||||
const { h, u, target } = setup(id, { outsideHp: id.endsWith('_a') ? 4000 : 5000, newcomer: true });
|
||||
assert.ok(h.runUntil(() => !bodyInKeys(target, u.rangeKeySet), 10));
|
||||
assert.ok(target.alive);
|
||||
assert.ok(h.runUntil(() => !target.alive, 10));
|
||||
assert.ok(drone(hits(h, u, target, 0).at(-1)), 'the independent drone delivers the killing blow');
|
||||
if (!h.enemies().some(e => e !== target && e.alive)) {
|
||||
h.step(); assert.equal(u.mem.rockLock, null, 'no out-of-range candidate is acquired');
|
||||
}
|
||||
assert.ok(h.runUntil(() => h.enemies().some(e => e !== target && e.alive), 10));
|
||||
const other = h.enemies().find(e => e !== target && e.alive);
|
||||
assert.ok(h.runUntil(() => hits(h, u, other, 0).some(drone), 2));
|
||||
const first = hits(h, u, other, 0).find(drone);
|
||||
approx(first.amount, u.s.atk * u.s.atkScaleMul * u.profile.funnel.init);
|
||||
assert.equal(u.mem.rockLock, other);
|
||||
done(h);
|
||||
});
|
||||
|
||||
test(`${id} S2: explicit stop restores the normal multiplier and clears released state`, () => {
|
||||
const { h, u, target } = setup(id, { staysOutside: true });
|
||||
const saved = u.mem.rockDrone.mul, key = u.mem.rockDrone.key;
|
||||
assert.ok(h.runUntil(() => !bodyInKeys(target, u.rangeKeySet), 10));
|
||||
u.skill.stop();
|
||||
assert.equal(u.profile[key], saved);
|
||||
assert.equal(u.mem.rockDrone, null); assert.equal(u.mem.rockLock, null);
|
||||
h.run(2); const t = h.b.time; h.run(4);
|
||||
assert.equal(hits(h, u, target, t).length, 0);
|
||||
done(h);
|
||||
});
|
||||
|
||||
test(`${id} S2: caster combat death clears the released state`, () => {
|
||||
const { h, u, target } = setup(id, { killer: true, staysOutside: true });
|
||||
assert.ok(h.runUntil(() => !u.alive, 12));
|
||||
assert.equal(u.skill.active, false); assert.equal(u.mem.rockDrone, null); assert.equal(u.mem.rockLock, null);
|
||||
h.run(2); const t = h.b.time; h.run(3);
|
||||
assert.equal(hits(h, u, target, t).length, 0);
|
||||
done(h);
|
||||
});
|
||||
|
||||
test(`${id} S2: live ASPD controls drone cadence; overload uses the live trait cap`, () => {
|
||||
const { h, u } = setup(id, { stationaryLock: true });
|
||||
const f = { ...u.profile.funnel }, scales = [];
|
||||
h.b.on('damaged', c => { if (c.source === u && drone(c)) scales.push({ t: h.b.time, scale: c.amount / (u.s.atk * u.s.atkScaleMul) }); });
|
||||
h.run(4);
|
||||
h.b.addBuff(u, { key: 'test:aspd', mods: { aspd: 100 } });
|
||||
const interval = u.s.interval, t = h.b.time;
|
||||
h.run(4);
|
||||
const fast = scales.filter(c => c.t > t + 1.3);
|
||||
assert.ok(fast.length > 3);
|
||||
fast.slice(1).forEach((c, i) => approx(c.t - fast[i].t, Math.ceil(interval * 30 - 1e-9) / 30));
|
||||
h.run(15);
|
||||
approx(scales.at(-1).scale, f.max * u.def.skill.bb.scale);
|
||||
u.skill.stop();
|
||||
assert.deepEqual(u.profile.funnel, f);
|
||||
assert.ok(u.s.flags.stun, 'stopping during overload keeps the existing post-overload stun');
|
||||
done(h);
|
||||
});
|
||||
}
|
||||
|
||||
for (const moduleId of ['none', 'uniequip_002_rockr']) {
|
||||
test(`elite S2 ${moduleId}: the resolved trait supplies initial ramp and increment`, () => {
|
||||
const { h, u, target } = setup('chess_char_2_10_b', { moduleId, stationaryLock: true });
|
||||
const f = u.profile.funnel;
|
||||
h.run(3);
|
||||
hits(h, u, target, 0).filter(drone).forEach((c, i) => approx(c.amount, u.s.atk * u.s.atkScaleMul * (f.init + i * f.delta)));
|
||||
assert.equal(f.init, moduleId === 'none' ? 0.2 : 0.35);
|
||||
done(h);
|
||||
});
|
||||
}
|
||||
|
||||
for (const sameTarget of [false, true]) {
|
||||
test(`S2: a pre-skill projectile at ${sameTarget ? 'the same' : 'another'} target cannot change the released ramp`, () => {
|
||||
const h = makeBattle({ seed: 5, autoFinish: false, captureNoisy: true, hooks: ['damaged', 'attack'],
|
||||
defs: { enemies: { old: enemyRec({ key: 'old', hp: 1e7, speed: 0, motion: 'FLY' }),
|
||||
preferred: enemyRec({ key: 'preferred', hp: 1e7, speed: 0, motion: 'FLY', taunt: 5 }) } },
|
||||
units: [{ chessId: 'chess_char_2_10_a', row: 10, col: 4, carryState: { sp: 0 } }],
|
||||
enemies: [{ key: 'old', pos: [10, 6] }],
|
||||
});
|
||||
h.step(); // launch an ordinary shot, which has not landed yet
|
||||
const u = h.unit('chess_char_2_10_a'), old = h.enemies()[0];
|
||||
assert.equal(h.hooksOf('attack').length, 1); assert.equal(h.hooksOf('damaged').length, 0);
|
||||
const target = sameTarget ? old : h.spawn('preferred', { pos: [10, 5] });
|
||||
h.step(); // update the spatial index for the second target
|
||||
u.skill.gainSp(999); u.skill.activate('test');
|
||||
assert.equal(u.mem.rockLock, target);
|
||||
h.run(2);
|
||||
assert.ok(hits(h, u, old, 0).some(c => c.dmg.isAttack), 'the old shot really lands');
|
||||
const ds = hits(h, u, target, 0).filter(drone);
|
||||
assert.ok(ds.length >= 3);
|
||||
ds.slice(0, 3).forEach((c, i) => approx(c.amount, u.s.atk * u.s.atkScaleMul * (u.profile.funnel.init + i * u.profile.funnel.delta)));
|
||||
done(h);
|
||||
});
|
||||
}
|
||||
|
||||
test('S2: the shared targetability filter releases an unselectable lock and acquires a legal candidate', () => {
|
||||
const { h, u, target } = setup('chess_char_2_10_a', { newcomer: true });
|
||||
assert.ok(h.runUntil(() => h.enemies().length === 2, 10));
|
||||
const other = h.enemies().find(e => e !== target);
|
||||
h.b.addBuff(target, { key: 'test:untargetable', flags: { untargetable: true } });
|
||||
h.run(2); const t = h.b.time; h.run(2);
|
||||
assert.equal(hits(h, u, target, t).length, 0);
|
||||
assert.equal(u.mem.rockLock, other);
|
||||
assert.ok(hits(h, u, other, t).some(drone));
|
||||
done(h);
|
||||
});
|
||||
|
||||
for (const [id, moduleId] of [['chess_char_2_10_a', undefined], ['chess_char_2_10_b', 'none'], ['chess_char_2_10_b', 'uniequip_002_rockr']]) {
|
||||
test(`${id} ${moduleId ?? 'normal'}: skill data, DEFAULT trigger, range and talent remain intact`, () => {
|
||||
for (const skillIndex of [0, 1]) {
|
||||
const raw = getDefaultSource().getChess(id, { skillIndex, moduleId }).raw;
|
||||
const h = makeBattle({ seed: 5, autoFinish: false, timeLimit: 100, captureNoisy: true,
|
||||
defs: { enemies: { e: enemyRec({ key: 'e', hp: 1e7, speed: 0 }) } },
|
||||
units: [{ chessId: id, row: 10, col: 4, skillIndex, moduleId, carryState: { sp: 999 } }],
|
||||
});
|
||||
h.run(2);
|
||||
const u = h.unit(id);
|
||||
assert.equal(u.skill.active, false, 'DEFAULT waits for a target despite full SP');
|
||||
assert.deepEqual([u.skill.id, u.skill.rule, u.skill.spCost, u.skill.initSp, u.skill.duration, u.skill.spType],
|
||||
[raw.skill.skillId, raw.skill.trigger.rule, raw.skill.spCost, raw.skill.initSp, raw.skill.duration, 'time']);
|
||||
assert.deepEqual(u.liveRangeGrid, raw.rangeGrid);
|
||||
assert.deepEqual([u.profile.canHitFly, u.profile.dmgType, u.profile.attack, u.s.blockCnt], [true, 'arts', 'ranged', 1]);
|
||||
h.spawn('e', { pos: [10, 6] });
|
||||
assert.ok(h.runUntil(() => u.skill.active, 3));
|
||||
approx(u.s.aspd - u.base.aspd, raw.skill.bb.attack_speed);
|
||||
const t = raw.talents.find(v => v.index === 0).bb;
|
||||
h.runUntil(() => h.b.time >= 15.1, 20);
|
||||
approx(u.findBuff('rockr:rock').stacks, 1);
|
||||
approx(u.findBuff('rockr:rock').mods.atkPct, t.atk);
|
||||
u.skill.stop();
|
||||
assert.deepEqual(u.liveRangeGrid, raw.rangeGrid);
|
||||
h.runUntil(() => h.b.time >= t.interval * (t.max_stack_cnt + 1), 80);
|
||||
assert.equal(u.findBuff('rockr:rock').stacks, t.max_stack_cnt);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user