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:
Sukvii authored and GitHub committed 2026-10-05 22:36:29 +08:00
1 parent ede8e7e773
commit ffdb1293c4
22 files changed
+607 -104

No files matched your search

+1 -1
View File
@@ -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);
+145
View File
@@ -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() } },