diff --git a/server/sim/content/kits/tier4.js b/server/sim/content/kits/tier4.js index ae8bb9e0..774bf911 100644 --- a/server/sim/content/kits/tier4.js +++ b/server/sim/content/kits/tier4.js @@ -575,11 +575,12 @@ const kits = { } }, { install(battle, unit) { // 影哨: reveal + −30 % move speed in range; a sentry keeps it after she leaves (max 1) const mods = { moveMul: Math.max(0, 1 + num(t1.move_speed, -0.3)) }; + // priority 20: before 不屈's redeploy (death priority 10), whose deployment starts S3 and its recall battle.on('death', (c) => { if (c.unit !== unit || c.reason === 'expired') return; unit.mem.sentry = new Set(unit.baseRangeKeys || unit.rangeKeys || []); battle.fx('sentry', { x: unit.x, y: unit.y, id: unit.id }); - }, { owner: unit }); + }, { owner: unit, priority: 20 }); battle.every(AURA, () => { const set = new Set(); if (unit.alive && unit.deployed) for (const k of unit.rangeKeys || []) set.add(k); @@ -608,7 +609,8 @@ const kits = { }, { owner: unit }); }, { owner: unit }); // where the talent's 影哨 stays (every leave but an expiry), for the recall of the next deployment - battle.on('death', (c) => { if (c.unit === unit && c.reason !== 'expired') unit.mem.inesSentryAt = { x: unit.x, y: unit.y }; }, { owner: unit, priority: 5 }); + // (priority 20: recorded before 不屈's redeploy at death priority 10 starts the next deployment's S3 and its recall) + battle.on('death', (c) => { if (c.unit === unit && c.reason !== 'expired') unit.mem.inesSentryAt = { x: unit.x, y: unit.y }; }, { owner: unit, priority: 20 }); // 技能期间每对一个敌人造成伤害就获得1点部署费用 battle.on('damaged', (c) => { if (c.source !== unit || c.target.side !== 'enemy' || !(c.amount > 0) || c.type === 'element' || !unit.skill?.active || !(unit.skill.timeLeft > 0)) return; diff --git a/server/sim/skills.js b/server/sim/skills.js index 634cdc3a..d04d7929 100644 --- a/server/sim/skills.js +++ b/server/sim/skills.js @@ -503,8 +503,8 @@ export class SkillRuntime { /** * End the active skill. onEnd runs while the skill's mods / range are still applied (end-of-skill effects — finishers, - * bombardments — use the skill's stats and range; `active` is already false), then they are removed (kept when onEnd - * re-activated the skill), then `skillEnd` fires. + * bombardments — use the skill's stats and range; `active` is already false), then they are removed and `skillEnd` + * fires — neither when onEnd re-activated the skill (the new cast owns both). */ end(reason = 'end') { if (!this.active || this.kind === 'passive' && reason !== 'death') return; @@ -517,7 +517,11 @@ export class SkillRuntime { this.ammoMax = 0; const n = this.activations; this._call('onEnd', { reason }); - if (!this.active && this.activations === n) this._removeMods(); + // onEnd started the next cast (耀骑士临光 S2 retreats on its duration end and 不屈 redeploys her inside that call; the + // deploy-timed skill starts again, PR #109): that cast owns the mods and its events — a trailing skillEnd would make + // listeners (骑士戒律) clear the new cast and the client would see the skill off while it runs + if (this.active || this.activations !== n) return; + this._removeMods(); if (b._hooks.skillEnd) b.emit('skillEnd', { unit: u, skill: this, reason }); if (this.kind !== 'passive') b._ev(['skill', u.id, 0]); } diff --git a/test/sim/feedback4-restart-in-end.test.js b/test/sim/feedback4-restart-in-end.test.js new file mode 100644 index 00000000..2bbb9346 --- /dev/null +++ b/test/sim/feedback4-restart-in-end.test.js @@ -0,0 +1,51 @@ +// A skill restarted inside its own end (0.1.4 release review): 耀骑士临光 S2 retreats when its duration ends and 不屈 +// redeploys her inside that call, starting the deploy-timed skill again (PR #109) — the new cast keeps its effects and +// the client never sees it off; 伊内丝 S3's recall still finds its 影哨 when 不屈 redeploys her at once. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec } from '../helpers/battleHarness.js'; + +const bond = (tier, layers = 0, count = 3) => ({ count, active: tier > 0, tier, layers }); + +test('耀骑士临光 S2 restarted by 不屈 inside its duration end keeps 骑士戒律 and stays on for the client', () => { + const NEARL = 'chess_char_6_17_a'; + const ends = []; + const h = makeBattle({ + seed: 1, autoFinish: false, timeLimit: 220, flags: { dpInit: 99 }, + bonds: { kazimierzShip: bond(1, 0), indomShip: bond(1, 999) }, + defs: { enemies: { enemy_hit: enemyRec({ key: 'enemy_hit', hp: 10, atk: 0, speed: 0 }) } }, + units: [{ chessId: NEARL, row: 10, col: 5, dir: 'RIGHT', skillIndex: 1, items: ['chess_item_6_10_e_a', 'chess_item_4_07_e_a'] }], + setup(b) { b.on('skillEnd', (c) => { if (c.unit?.defId === NEARL) ends.push({ active: c.skill.active, reason: c.reason }); }); }, + }); + const u = h.unit(NEARL); + const combo = () => u.buffs.some((x) => x.key.endsWith(':combo')); + h.run(0.3); + assert.ok(u.skill.active && combo(), 'the first cast runs with its combo'); + h.runUntil(() => u.alive && u.skill.activations >= 2 && u.skill.active, 60); + h.run(0.3); + assert.ok(u.skill.active, 'the second cast runs'); + assert.ok(combo(), 'the restarted cast keeps 骑士戒律'); + assert.ok(ends.every((e) => !e.active), 'no skillEnd fires for a skill that is running again'); + const own = h.eventsOf('skill').filter((e) => e[1] === u.id).map((e) => e[2]); + assert.equal(own.at(-1), 1, 'the client sees the skill on'); +}); + +test('伊内丝 S3: knocked out mid-battle, 不屈\'s immediate redeploy recalls the 影哨 she leaves', () => { + const INES = 'chess_char_4_04_a'; + const h = makeBattle({ + seed: 1, autoFinish: false, timeLimit: 10, flags: { dpInit: 99 }, + bonds: { indomShip: bond(1, 999) }, + defs: { enemies: { enemy_ines: enemyRec({ key: 'enemy_ines', hp: 80000, speed: 0, atk: 0, def: 0 }) } }, + units: [{ chessId: INES, row: 10, col: 5, dir: 'RIGHT', skillIndex: 2 }], + enemies: [{ key: 'enemy_ines', time: 0, pos: [10, 6] }], + }); + h.step(1); + const u = h.unit(INES), e = h.enemies()[0]; + const recalls = []; + h.b.on('damaged', (c) => { if (c.source === u && (c.dmg?.tags || []).includes('sentryRecall')) recalls.push(c.amount); }); + h.b.dealDamage(e, u, { amount: u.s.maxHp * 10, type: 'true', canDodge: false }); // knocked out: 不屈 redeploys at once + h.step(0.2); + assert.ok(u.alive && u.deployed, 'redeployed by 不屈'); + assert.equal(recalls.length, 1, 'the redeploy\'s S3 recalls the 影哨 left at her fall'); + assert.equal(u.mem.sentry, null, 'and the 影哨 is spent'); +});