Files
ark-stronghold-protocol/test/content/rockrock-s2-lock.test.js
T

268 lines
14 KiB
JavaScript

// 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);
}
});
}