Deploy-timed skills run the normal duration lifecycle: one charge on deployment, a duration bar, a real end, again on the next deployment (#109)
宴 S2, 斯卡蒂 S2, 野鬃 S1, 砾 S1 / S2, 伊内丝 S3, 缄默德克萨斯 S1–S3 and 耀骑士临光 S2 used a separate timed buff while the skill stayed active; equipment and bond effects that read the skill state now see it end. 伊内丝's first sentry placement starts no skill. Contributed by @Sukvii.
This commit is contained in:
1 parent
ede8e7e773
commit
ffdb1293c4
22 files changed
+607
-104
No files matched your search
@@ -78,7 +78,7 @@ test('耀骑士临光 S2: withdrawn at the skill end, she lies down on her tile
|
||||
const h = field([{ chessId: NEARL2, row: 10, col: 4, skillIndex: idx }]);
|
||||
const u = h.unit(NEARL2);
|
||||
h.step();
|
||||
assert.ok(u.alive && u.findBuff('nearl2:night'), 'S2 on deploy');
|
||||
assert.ok(u.alive && u.skill.active, 'S2 on deploy');
|
||||
assert.ok(h.runUntil(() => !u.alive, 40), 'withdraws when the skill ends');
|
||||
assert.equal(u.removeReason, 'retreat');
|
||||
const d = downOf(h, u);
|
||||
|
||||
@@ -4,15 +4,160 @@ import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
|
||||
import { genericSkillSpec, genericKind } from '../../server/sim/content/generic.js';
|
||||
import { getDefaultSource } from '../../server/sim/simdata.js';
|
||||
import { UF } from '../../shared/constants.js';
|
||||
|
||||
const approx = (a, b, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${a} ≈ ${b}`);
|
||||
const RANGE3 = [[0, 0], [0, 1], [0, 2], [0, 3], [1, 0], [1, 1], [1, 2], [1, 3], [-1, 0], [-1, 1], [-1, 2], [-1, 3]];
|
||||
const dummy = (o = {}) => enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0, ...o });
|
||||
|
||||
test('deployment duration: synchronous start, one charge, countdown, carry and fresh redeployment', () => {
|
||||
for (const carryState of [undefined, { hpPct: 0.5, sp: 0, skillActive: true }]) {
|
||||
const order = [];
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_deploy: sniperWith({ spCost: 0 }, { id: 't_deploy' }) } },
|
||||
units: [{ chessId: 't_deploy', row: 10, col: 4, carryState }], timeLimit: 120,
|
||||
kits: { t_deploy: () => ({ skill: {
|
||||
kind: 'duration', activateOnDeploy: true, duration: 4, spCost: 0, spType: 'none', trigger: 'NEVER', mods: { atkPct: 1 },
|
||||
} }) },
|
||||
setup(b) {
|
||||
for (const name of ['skillStart', 'deploy']) b.on(name, (c) => order.push([name, c.reason, c.unit.skill.timeLeft]));
|
||||
},
|
||||
});
|
||||
h.b.start();
|
||||
const u = h.unit('t_deploy'), sk = u.skill;
|
||||
assert.deepEqual(order, [['skillStart', 'deploy', 4], ['deploy', undefined, 4]]);
|
||||
assert.equal(sk.active, true);
|
||||
assert.equal(sk.activations, 1);
|
||||
assert.equal(sk.charges, 0);
|
||||
assert.equal(sk.ready, false);
|
||||
approx(u.s.atk, 200);
|
||||
const tuple = () => h.snapshot().units.find((t) => t[0] === u.id);
|
||||
assert.ok(tuple()[7] & UF.SKILL);
|
||||
assert.deepEqual(tuple().slice(5, 7), [4, 4]);
|
||||
h.run(2);
|
||||
approx(sk.timeLeft, 2);
|
||||
assert.deepEqual(tuple().slice(5, 7), [2, 4]);
|
||||
h.run(2.1);
|
||||
assert.equal(sk.active, false);
|
||||
assert.equal(sk.ready, false);
|
||||
assert.equal(sk.charges, 0);
|
||||
assert.equal(sk.sp, 0);
|
||||
approx(u.s.atk, 100);
|
||||
assert.ok(!(tuple()[7] & UF.SKILL));
|
||||
assert.deepEqual(tuple().slice(5, 7), [0, 0]);
|
||||
assert.deepEqual(h.hooksOf('skillEnd').map((c) => c.reason), ['duration']);
|
||||
assert.equal(sk.gainSp(100, 'talent'), 0);
|
||||
h.run(5);
|
||||
assert.equal(sk.activations, 1);
|
||||
h.b.retreat(u);
|
||||
assert.equal(h.hooksOf('skillEnd').length, 1, 'expired skill does not end again on retreat');
|
||||
assert.ok(h.b.redeploy(u, { free: true }));
|
||||
assert.equal(sk.activations, 2);
|
||||
approx(sk.timeLeft, 4);
|
||||
assert.equal(sk.charges, 0);
|
||||
h.invariants();
|
||||
}
|
||||
});
|
||||
|
||||
test('deployment duration: early retreat/death cleans effects and leaves no stale end on redeployment', () => {
|
||||
for (const remove of ['retreat', 'kill']) {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_deploy: sniperWith({ spCost: 0 }, { id: 't_deploy' }) } },
|
||||
units: [{ chessId: 't_deploy', row: 10, col: 4 }],
|
||||
kits: { t_deploy: () => ({ skill: {
|
||||
kind: 'duration', activateOnDeploy: true, duration: 4, spCost: 0, spType: 'none', trigger: 'NEVER', mods: { aspd: 50 },
|
||||
} }) },
|
||||
});
|
||||
const u = h.unit('t_deploy');
|
||||
h.run(1);
|
||||
h.b[remove](u);
|
||||
assert.equal(u.skill.active, false);
|
||||
approx(u.s.aspd, u.base.aspd);
|
||||
assert.deepEqual(h.hooksOf('skillEnd').map((c) => c.reason), ['death']);
|
||||
assert.ok(h.b.redeploy(u, { free: true }));
|
||||
h.run(3.1);
|
||||
assert.equal(u.skill.active, true, 'old deployment deadline does not end the new window');
|
||||
assert.equal(h.hooksOf('skillEnd').length, 1);
|
||||
h.run(1);
|
||||
assert.deepEqual(h.hooksOf('skillEnd').map((c) => c.reason), ['death', 'duration']);
|
||||
assert.equal(u.skill.ready, false);
|
||||
h.invariants();
|
||||
}
|
||||
});
|
||||
|
||||
function sniperWith(skill, o = {}) {
|
||||
return chessRec({ id: 't_sn', profession: 'SNIPER', subProfessionId: 'closerange', stats: { atk: 100, bat: 1, ...(o.stats || {}) }, rangeGrid: RANGE3, skill, ...o });
|
||||
}
|
||||
|
||||
test('generic timed passive: deployment window owns stats and arts hits without reinterpreting passive proc parameters; permanent passive stays on', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: {
|
||||
t_sn: sniperWith({ skillType: 'PASSIVE', duration: 0, spCost: 0,
|
||||
description: '部署后在4秒内攻击力+100%,攻击造成法术伤害',
|
||||
bb: { atk: 1, duration: 4, max_target: 2, 'attack@cold': 1, ability_range_forward_extend: 1 } }),
|
||||
t_p: sniperWith({ skillType: 'PASSIVE', duration: 0, spCost: 0, description: '攻击力+100%', bb: { atk: 1, duration: 4 } }, { id: 't_p' }),
|
||||
}, enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 't_sn', row: 10, col: 4 }, { chessId: 't_p', row: 12, col: 4 }],
|
||||
content: 'generic', captureNoisy: true,
|
||||
});
|
||||
h.b.start();
|
||||
const u = h.unit('t_sn'), p = h.unit('t_p');
|
||||
assert.equal(u.skill.kind, 'duration');
|
||||
assert.equal(u.skill.activations, 1);
|
||||
assert.equal(u.skill.ready, false);
|
||||
assert.deepEqual(u.rangeKeys, u.baseRangeKeys, 'passive parameters do not extend the attack range');
|
||||
h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
h.run(2);
|
||||
approx(u.s.atk, 200);
|
||||
const hits = h.hooksOf('damaged').filter((c) => c.source === u);
|
||||
assert.ok(hits.length && hits.every((c) => c.type === 'arts'));
|
||||
assert.equal(new Set(hits.map((c) => c.target)).size, 1, 'passive max_target does not turn normal attacks into multi-target attacks');
|
||||
assert.deepEqual(h.snapshot().units.find((t) => t[0] === u.id).slice(5, 7), [2, 4]);
|
||||
h.run(2.1);
|
||||
assert.equal(u.skill.active, false);
|
||||
assert.equal(u.skill.charges, 0);
|
||||
approx(u.s.atk, 100);
|
||||
assert.deepEqual(u.rangeKeys, u.baseRangeKeys);
|
||||
h.run(2);
|
||||
const late = h.hooksOf('damaged').filter((c) => c.source === u && c.t > 4.5);
|
||||
assert.ok(late.length && late.every((c) => c.type === 'phys'));
|
||||
assert.equal(h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'cold').length, 0,
|
||||
'passive proc parameters do not become attack statuses');
|
||||
assert.equal(p.skill.kind, 'passive');
|
||||
assert.equal(p.skill.active, true);
|
||||
approx(p.s.atk, 200);
|
||||
h.invariants();
|
||||
});
|
||||
|
||||
test('generic 宴 S2: real normal/elite descriptions convert damage to arts only during the deployment window', () => {
|
||||
const ds = getDefaultSource();
|
||||
for (const id of ['chess_char_1_18_a', 'chess_char_1_18_b']) {
|
||||
const index = ds.rawChess(id).skills.find((s) => s.skillId === 'skchr_utage_2').index;
|
||||
const h = makeBattle({
|
||||
content: 'generic', timeLimit: 40, captureNoisy: true,
|
||||
units: [{ chessId: id, skillIndex: index, row: 10, col: 4 }],
|
||||
defs: { enemies: { enemy_dummy: dummy({ atk: 0 }) } },
|
||||
});
|
||||
h.b.start();
|
||||
const u = h.unit(id);
|
||||
h.spawn('enemy_dummy', { pos: [10, 4] });
|
||||
const hits = () => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg.isAttack);
|
||||
h.run(2);
|
||||
assert.ok(hits().length > 0 && hits().every((c) => c.type === 'arts'), `${id}: active S2 deals arts damage`);
|
||||
h.run(u.skill.timeLeft + h.TICK);
|
||||
assert.equal(u.skill.active, false);
|
||||
assert.equal(u.skill.ready, false);
|
||||
const endedAt = hits().length;
|
||||
h.run(3);
|
||||
const late = hits().slice(endedAt);
|
||||
assert.ok(late.length > 0 && late.every((c) => c.type === 'phys'), `${id}: normal physical attacks after expiry`);
|
||||
assert.equal(u.skill.activations, 1);
|
||||
assert.deepEqual(h.b.errors, []);
|
||||
h.invariants();
|
||||
}
|
||||
});
|
||||
|
||||
test('SP: time regen, starts at initSp, capped at cost; DEFAULT waits for an enemy in the initial range', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_sn: sniperWith({ spCost: 5, initSp: 2, duration: 3, bb: { atk: 1 } }) }, enemies: { enemy_dummy: dummy() } },
|
||||
|
||||
Reference in new issue
Block a user