diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 9112a7ee..0317bb07 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -281,7 +281,14 @@ Engine helpers (content must use these, never touch internals): `battle.dealDama SP: `spType` `INCREASE_WITH_TIME` (+`spRecovery`/s), `INCREASE_WHEN_ATTACK` (+1 per attack), `INCREASE_WHEN_TAKEN_DAMAGE` (+1 per hit taken); starts at `initSp`; no SP gain while a duration skill is active. `maxChargeTime > 1` ⇒ charges. -Auto-trigger rules (`skillTrigger.rule` in data — the official 技能策略, PRTS 卫戍协议/帮助 §作战阶段 技能操作; §20.2): `DEFAULT` (SP ready AND about to attack/heal AND an enemy/injured ally inside the **initial** range, or an enemy the unit blocks; with `trigger.allies` + `hpAtMost` an ally of the skill grid at or below that HP ratio — 塞雷娅 S1 急救, whose cast replaces that attack and is withdrawn with its charge if the ally recovers first), `SKILL_RANGE` (a MANUAL skill with a 技能范围 of its own, not an attack-range change: any living enemy, stealthed ones included, on the skill range, checked every tick — "仅在技能范围内存在敌人(无视其不可选中)时释放技能"; 13 skills / 26 records by the official rule, 14 / 28 in the data with 余 S2's deviation, §22.10), `TAKE_DAMAGE` (on taking damage — any damage, 无来源 included, never a 流失; every MANUAL 重装 skill, §21.12 — but the six of the deliberate deviation, §21.29: 深巡 / 雷蛇 S2, 号角 S2 / S3 and 灰毫 S1 / S2 are `DEFAULT`, `rawRule` keeping the official TAKE_DAMAGE; and 余 S2, `SKILL_RANGE` on its x-1 (§22.10)), `SP_FULL` (immediately — 执旗手 / 战术家 / 吟游者 MANUAL skills, and AUTO DP skills such as 伺夜 S1, 焰尾 S1, 凛御银灰 S1), `CUSTOM_RANGE` (enemy inside the custom grid), `SEARCH` (an enemy inside the initial range, checked every tick — no attack needed; 解放者 / 阵法术师 MANUAL skills), `GDGLOW_SKILL_2` (a valid target anywhere on the field: 荒芜拉普兰德, 纯烬艾雅法拉 S3), special ids. The class rows cover **every MANUAL skill** of the class and never an AUTO skill (AUTO skills keep their own rule — an inference: PRTS names whole classes; the data's `skillIndex 0` / `-1` sentinel argues the other way, §20.2). **Operation cooldown**: the engine auto-casts a MANUAL skill no sooner than `AUTO_OP_COOLDOWN` (3 s) after its previous cast and after the unit's battle-start deployment ("自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位将进入冷却"; `Battle._deploy` initial, `battle.flags.startOpCooldown`); kits with an automatic cast of their own check `skill.opCooling`; a pending "next attack" never takes a second charge. PASSIVE skills are always on. +Timed deployment skills use `kind: 'duration'`, `activateOnDeploy: true`, `spCost: 0`, `spType: 'none'`, `trigger: 'NEVER'`. +Deployment sets landing state, then `reset()` consumes one charge through `activate('deploy')`, then emits `deploy`; +`skillStart` fires before deploy hooks. In 联防 this is a fresh deployment activation; no previous active skill state is restored. +Duration end cleans the window and emits `skillEnd`; it leaves zero charges and `ready=false`, even after SP gifts. +Redeployment opens a new full window. Permanent passives retain `passive`; first-kill recasts of 缄默德克萨斯 explicitly +restart a full window. Special timed kits and their effect rules are described in SIM §7.2. + +Auto-trigger rules (`skillTrigger.rule` in data — the official 技能策略, PRTS 卫戍协议/帮助 §作战阶段 技能操作; §20.2): `DEFAULT` (SP ready AND about to attack/heal AND an enemy/injured ally inside the **initial** range, or an enemy the unit blocks; with `trigger.allies` + `hpAtMost` an ally of the skill grid at or below that HP ratio — 塞雷娅 S1 急救, whose cast replaces that attack and is withdrawn with its charge if the ally recovers first), `SKILL_RANGE` (a MANUAL skill with a 技能范围 of its own, not an attack-range change: any living enemy, stealthed ones included, on the skill range, checked every tick — "仅在技能范围内存在敌人(无视其不可选中)时释放技能"; 13 skills / 26 records by the official rule, 14 / 28 in the data with 余 S2's deviation, §22.10), `TAKE_DAMAGE` (on taking damage — any damage, 无来源 included, never a 流失; every MANUAL 重装 skill, §21.12 — but the six of the deliberate deviation, §21.29: 深巡 / 雷蛇 S2, 号角 S2 / S3 and 灰毫 S1 / S2 are `DEFAULT`, `rawRule` keeping the official TAKE_DAMAGE; and 余 S2, `SKILL_RANGE` on its x-1 (§22.10)), `SP_FULL` (immediately — 执旗手 / 战术家 / 吟游者 MANUAL skills, and AUTO DP skills such as 伺夜 S1, 焰尾 S1, 凛御银灰 S1), `CUSTOM_RANGE` (enemy inside the custom grid), `SEARCH` (an enemy inside the initial range, checked every tick — no attack needed; 解放者 / 阵法术师 MANUAL skills), `GDGLOW_SKILL_2` (a valid target anywhere on the field: 荒芜拉普兰德, 纯烬艾雅法拉 S3), special ids. The class rows cover **every MANUAL skill** of the class and never an AUTO skill (AUTO skills keep their own rule — an inference: PRTS names whole classes; the data's `skillIndex 0` / `-1` sentinel argues the other way, §20.2). **Operation cooldown**: the engine auto-casts a MANUAL skill no sooner than `AUTO_OP_COOLDOWN` (3 s) after its previous cast and after the unit's battle-start deployment ("自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位将进入冷却"; `Battle._deploy` initial, `battle.flags.startOpCooldown`); kits with an automatic cast of their own check `skill.opCooling`; a pending "next attack" never takes a second charge. Permanent passive skills are always on; finite deployment effects use the duration lifecycle above. **SkillSpec** (authored per chess in `sim/content/kits/tierN.js`; values come from the chess's skill blackboard at its level — normal Lv4, elite Lv7 — so a spec is a *function of the blackboard*): ```js @@ -290,6 +297,7 @@ export default { chess_char_1_01: (bb, chess) => ({ skill: { kind: 'duration' | 'ammo' | 'instant' | 'charges' | 'passive' | 'toggle', + activateOnDeploy: false, // optional boolean: consume a deployment charge synchronously in reset duration: bb.duration, // duration kind ammo: bb['attack@trigger_time'], // ammo kind mods: { atkPct: bb.atk, batPct: bb.base_attack_time }, // applied while active @@ -423,6 +431,8 @@ S→C: - `m.private` — the recipient's full PlayerState view (§6.2 minus server-only data) on every change. - `m.field` — field meta when watching starts: `{ fieldId, kind, rect, stageId, units: [UnitInfo], effects? }` (the units on the field, knocked-out operators waiting to redeploy included — `Battle.fieldMeta`, §18.3). The prep-scouting field (`Match.prepFieldMeta`) puts the scouted player's **hand (整备区) and 临时整备区 into `units` as well**: held pieces as units on the hand row (row 7, col = hand slot) and temp pieces on the temp row (row 8, cols 4..8 = the temp slots; PRTS 帮助 counts the temp area with the hand; a held item is a `kind: 'item'` unit the client draws as its floating plate) — the scout renders exactly like the own prep bench, for every watcher alike, the spectator seat's copy equal to a teammate's (§23.19's "never a private view" stays the no-`m.private` rule); the scout signature `_prepScoutSig` carries the hand / temp slots, so a piece moved to another slot is a change — and carries `effects`: the scouted player's effects view (策略 / 机变 / 悬赏 …), the watched player's effects column ready-made - NOTE: game time in `b.snap`/`b.ev` is carried as **`gt`** (the frame's `t` is the message type). +- Active finite zero-SP timed skills display `sp=timeLeft`, `spMax=duration` and `UF.SKILL`; after end they display + `0/0`. Snapshot display capacity does not change runtime SP/cost. - `b.snap` — `{ fieldId, gt, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]] , dp, killed, total, down?, elem? }` 20 Hz. Optional, only when non-empty (§18.3): `down: [[id, respawnAt (game s), respawnTime (s), state, row, col]]` — knocked-out operators waiting to redeploy, `state` 0 counting | 1 timer done, waiting for DP | 2 timer done, its tile taken — a safeguard, no ally deploys there (sim/constants.js `DOWN_STATE`); `row, col` = the tile it lies on and comes back on (§21.24); `elem: [[id, element, fill, cooldownEnd (game s), cooldown (s)]]` — the one gauge a unit shows: the fullest (ties by the official element id 神经 < 侵蚀 < 灼燃 < 凋亡), fill with 2 decimals rounded DOWN and kept at 0.01–0.99 outside a burst (§20.8); during a 爆发冷却 the bursting element with fill 1 and the cooldown numbers (else 0, 0) - `b.ev` — `{ fieldId, ev: [ ... ] }` event tuples: `['spawn', UnitInfo]`, `['atk', srcId, tgtId, projKind]`, `['dmg', tgtId, amount, type]`, `['heal', tgtId, amount]`, `['skill', id, on:0|1]`, `['die', id, reason]` (reason `'killed'`, `'retreat'`, `'expired'` …; only `'killed'` plays an operator's knock-down sound), `['status', id, key, on]`, `['fx', kind, x, y, extra]` (`'hitCap' {id, n}` = a cancelled 限伤 hit, drawn as nothing, §20.12; `extra.form` = the unit's model form from then on (an enemy's mode, a 傀儡师's 替身 §22.11) — `shared/protocol.js fxForm`, state like a spawn: never dropped by the client, §21.4), `['layer', playerId, bondId, n]`, `['bounty', playerId, coins]`, `['deploy', id]` - `m.toast {kind, text}` · `m.ticker {text, id, type, priority, playerId}` (the strip plays the highest priority first, §21.10) · `m.emote {playerId, id}` · `m.result {...}` diff --git a/docs/SIM.md b/docs/SIM.md index 69863bcb..32850d32 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -150,6 +150,8 @@ later redeploys keep those gifts); `carryState: { sp }` a board summon piece's SP only (PRTS "召唤物仅修改技力"). Nothing else is carried: a skill running at the end of the own combat enters switched off with the SP it had left — spent at its activation, 0 for a one-charge skill (`unitsEnd.skillActive` is reported, never carried; community report #34 — it used to restart for free). +Timed deployment skills (`activateOnDeploy`) trigger once from the new deployment, consuming their deployment charge; +they do not resume the previous skill state. `carryState: { down: true }` = an operator knocked out at the end of the helper's own combat (PRTS 卫戍协议/帮助 "上一阶段为 退场状态的干员强制退场", user playtest #5 item 2): `start()` deploys it with everyone (initial `deploy` fires), then — before `battleStart` — withdraws it with reason `FORCED_EXIT` (constants.js `'forcedExit'`) and HP 0 (the end-of-phase HP ratio, as after @@ -830,6 +832,11 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged - Kinds: `duration` (mods for `duration` s), `ammo` (mods until `ammo` attacks were made, optional duration cap), `instant` (onStart + optional one-shot attack override applied to the next attack), `charges` (instant with charges), `passive` (always on from deployment, no SP), `toggle` (stays on until death once activated). +- Timed deployment skills use `duration`, `activateOnDeploy: true`, `spCost: 0`, `spType: 'none'`, `trigger: 'NEVER'`. + Landing state is set before `reset()` synchronously calls `activate('deploy')`; `skillStart` precedes the `deploy` + hooks. Each deployment grants and consumes one charge. Duration end clears effects and fires `skillEnd('duration')`; + staying deployed or receiving SP never refills that charge. Early removal ends the active window with `death`; + removing an expired skill emits no second end. `activations` accumulates across deployments. - `end()`: `active` turns false, then **onEnd runs while the skill's mods / range are still applied** (finishers and end bursts use the skill's stats and range), then they are removed (kept when onEnd re-activated the skill), then `skillEnd` fires. `onAttack` ctx carries `noAmmo` (set it to true: this attack spends no bullet, no `ammoUsed` — 流明's @@ -869,6 +876,17 @@ has the generic spec. Summons: `battle.tokenDef(tokenId, unit)` / `battle.spawnT (token skill of that slot: `variants[owner].bySkill[i]`, module attributes: `.byModule[id]`). Coverage of the selectable skills: `node tools/kit-coverage.mjs --missing [--tier N] [--strict]`. +Timed deployment kits: 宴 S2 uses `bb.duration` and skill ATK mods, loses current HP once on landing, and converts normal +physical attacks to arts only while S2 is active; S1 has no such conversion. 斯卡蒂 S2 uses its own record's +`s.bb.duration` / `s.bb.atk`; DRE-Y revival in place does not restart it. 野鬃 S1 uses its record's `duration` / +`bb.attack_speed`, with the existing deploy discount talent. 砾 S1/S2 retain their one-second DEF/barrier decay steps +(damaged barrier scales with capacity); skill end removes the decay buff. 伊内丝 S3's first deployment places the sentry +with zero effect time and retreats with a refreshed redeploy timer; later deployments recall it and open the full +ATK/DP window, then stay deployed. 缄默德克萨斯 S1/S2/S3's first kill per deployment heals and explicitly ends/restarts +the skill for a full duration, including a synchronous kill inside its deploy burst; S3 rain is cancelled on end, +while enemy silence/DoT/RES cuts keep their own expiry. 耀骑士临光 S2 ends naturally by retreating with the preceding +operator's faction-based redeploy multiplier; an early death only cleans the skill and shields. + Element conventions of the kits (user playtest #5 #3; official term dictionary: 元素损伤 = the gauge, 元素伤害 = HP damage): - **元素损伤 dealt** = `{ type: 'element', element, amount }` with the official base: "N%攻击力的…损伤" ⇒ `N × ATK` at that moment, "伤害N%的…损伤" / "相当于法术伤害N%" ⇒ `N ×` the HP damage just dealt (post-mitigation, `damaged` ctx.amount). @@ -896,9 +914,10 @@ Element conventions of the kits (user playtest #5 #3; official term dictionary: ```js { kind: 'duration'|'ammo'|'instant'|'charges'|'passive'|'toggle', + activateOnDeploy: bool, // optional: reset activates synchronously and consumes a deployment charge duration, // s (duration kind; optional cap for ammo) ammo, // attacks (ammo kind) - spCost, initSp, charges, spType: 'time'|'attack'|'hurt', // optional overrides of the data values + spCost, initSp, charges, spType: 'time'|'attack'|'hurt'|'none', // optional overrides of the data values trigger: 'DEFAULT' | { rule, grid, allies?, hpAtMost? }, // optional override (allies / hpAtMost: an injured ally condition, §7.1) heal: bool, // heal-type skill for the DEFAULT trigger (default: unit is a healer) mods: { …mod keys }, // buff while active (instant: only during the pending attack) @@ -941,8 +960,10 @@ whose skill "恢复…友方/友军…生命" heals the most injured ally in ran "立即流失N%当前生命" ⇒ self HP loss at start (宴, 风丸); `hp_ratio` + "恢复/回复…生命" ⇒ self heal at start; `force` ⇒ on-hit displacement by the official 力度 − 重量 rules (拖拽/hookmaster: `pullToFront`; else `push`, directional for 往攻击方向 / 朝部署方向 / 向前 / 身前方向 and 推击手, radial otherwise — §6; only a fallback: hand-written kits follow the client templates -`knockback[dir]` / `knockback[relative]`, e.g. 琳琅诗怀雅 S3's "向前推开" is radial). **Passive** skills only apply stat mods (timed when the text says "N秒内": -宴 +65 % ATK for 14 s) and the self/counter effects — their scales describe procs that need a kit. Instant skills with +`knockback[dir]` / `knockback[relative]`, e.g. 琳琅诗怀雅 S3's "向前推开" is radial). A passive with self stat mods, +positive `bb.duration` and "N秒内" uses a deployment duration (宴 +65 % ATK for 14 s); its mods, targeting, arts conversion +and attack/status overrides follow that window. Other **passive** skills apply permanent stat mods and self/counter +effects — their scales describe procs that need a kit. Instant skills with mods/targeting but no attack override apply them to the next attack (the skill range is switched in for that attack). ### 7.5 Worked examples (real operators, numbers from blackboards) @@ -1161,6 +1182,8 @@ Unknown subprofessions fall back to the profession default (test `professions.te - `snapshot()` → `{ fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, dps?, boss?, down?, elem? }`. `sp/spMax` show remaining duration/ammo as a draining bar while a timed skill is active (ammo: `ammoLeft / ammoMax`, the activation's real total — 拉特兰's and 逃犯引渡手续's extra bullets included, community report #35). Units in DIE state stay 0.8 s. + Active finite zero-SP duration skills display `timeLeft/duration` using `duration` as `spMax`; after end they show + `0/0`. This display capacity leaves runtime SP/cost unchanged; active skills carry `UF.SKILL`. `down: [[id, respawnAt, respawnTime, state, row, col]]` (only when non-empty) = operators lying down waiting to redeploy (`Battle.isDown(u)`: reason `'killed'` or `FORCED_EXIT` (§1.1 carryState `down`), or a forced exit `'retreat'` / `'merchant'` (GitHub #60) — every removal but the 突袭 `'raid'` —, not removed for good, deployed at least once, a finite diff --git a/server/sim/content/generic.js b/server/sim/content/generic.js index 94f610a5..febde573 100644 --- a/server/sim/content/generic.js +++ b/server/sim/content/generic.js @@ -28,7 +28,7 @@ // "附带…凋亡/灼燃/神经损伤" + ep_damage_ratio ⇒ element damage on hit (× damage dealt when the text says "伤害N%的…损伤", // else × ATK); "屏障" + shield_max_hp_ratio / hp_ratio ⇒ self shield at start decaying over its duration (砾, 新约能天使); // "立即流失N%当前生命" + hp_ratio ⇒ self HP loss at start (宴, 风丸); hp_ratio + "恢复/回复…生命" ⇒ self heal at start. -// Passive skills only apply stat mods (for bb.duration s when the text says "N秒内": 宴) and the self/counter effects +// Passive skills with stat mods + bb.duration + "N秒内" use a deployment duration; other passives apply stat mods and the self/counter effects // above — their scales describe procs (bombs, sword rain, counters) that need a hand-authored kit. // force→onHit displacement with the official 力度 − 重量 rules (Battle.push / pullToFront): a pull "至面前" when the text says // 拖拽 or for hookmasters, else a push — along the unit's direction when the text says 往攻击方向 (the 推击手 wording, PRTS @@ -107,10 +107,8 @@ export function genericSkillSpec(sk, bb = sk?.bb ?? {}, def = null) { if (!sk) return null; const g = getter(bb); const ga = attackGetter(bb); - const kind = genericKind(sk, bb); + let kind = genericKind(sk, bb); const desc = String(sk.description || ''); - const passive = kind === 'passive'; - const timed = kind === 'duration' || kind === 'ammo' || kind === 'toggle'; // "受到攻击时…造成…" numbers belong to a counter effect (the operator's own, or an ally's: 刺玫 "该角色受到攻击时") const counterCtx = /受到(敌人的)?攻击时/.test(desc); const counterText = counterCtx && !/该(角色|干员|单位)受到攻击时/.test(desc); @@ -149,9 +147,16 @@ export function genericSkillSpec(sk, bb = sk?.bb ?? {}, def = null) { set('resIgnoreFlat', g('magic_resist_penetrate_fixed')); set('defIgnoreFlat', g('def_penetrate_fixed')); + // Effect inference keeps the original kind; a deployment window only changes the lifecycle. + const passive = kind === 'passive'; + const timed = kind === 'duration' || kind === 'ammo' || kind === 'toggle'; + const passiveTimed = passive && Object.keys(mods).length && num(bb.duration) > 0 && /\d+(\.\d+)?秒内/.test(desc) ? num(bb.duration) : 0; + if (passiveTimed) kind = 'duration'; + // ---- targeting / attack override (never for passives: their scales describe procs) const targeting = {}; const attack = {}; + if (passiveTimed && /攻击[^。;]*(造成|变为|变成)[^。;]*法术伤害|伤害类型变为法术/.test(desc)) attack.dmgType = 'arts'; const mt = ga('max_target'); if (!passive && mt !== undefined && mt > 0) targeting.maxTargets = Math.floor(mt); const ext = g('ability_range_forward_extend'); @@ -273,13 +278,12 @@ export function genericSkillSpec(sk, bb = sk?.bb ?? {}, def = null) { id: sk.id, name: sk.name, kind, - duration: kind === 'duration' ? sk.duration : (kind === 'ammo' && sk.duration > 0 ? sk.duration : undefined), + duration: kind === 'duration' ? (passiveTimed || sk.duration) : (kind === 'ammo' && sk.duration > 0 ? sk.duration : undefined), ammo, }; - // passive stat buffs limited in time ("部署后…在14秒内攻击力+65%", 宴) become a timed buff at each deployment - const passiveTimed = passive && Object.keys(mods).length && num(bb.duration) > 0 && /\d+(\.\d+)?秒内/.test(desc) ? num(bb.duration) : 0; - if (Object.keys(mods).length && !passiveTimed) spec.mods = mods; + if (passiveTimed) Object.assign(spec, { activateOnDeploy: true, spCost: 0, spType: 'none', trigger: 'NEVER' }); + if (Object.keys(mods).length) spec.mods = mods; if (Object.keys(targeting).length) spec.targeting = targeting; // instant/charges skills act on the next attack: mods/targeting without an explicit attack still need one if (!Object.keys(attack).length && (kind === 'instant' || kind === 'charges') && (spec.mods || spec.targeting)) spec.attack = {}; @@ -288,10 +292,6 @@ export function genericSkillSpec(sk, bb = sk?.bb ?? {}, def = null) { // ---- start / end effects const starts = []; const ends = []; - if (passiveTimed) { - const m = { ...mods }; - starts.push(({ battle, unit }) => battle.addBuff(unit, { key: `generic:passive:${sk.id ?? 'skill'}`, duration: passiveTimed, mods: m, tags: ['skill'] })); - } const hr = g('hp_ratio'); if (hr !== undefined && hr > 0 && hr <= 1 && /立即流失\d+(\.\d+)?%(的)?当前生命/.test(desc)) { starts.push(({ battle, unit }) => { const loss = unit.hp * hr; if (loss > 0 && unit.hp - loss >= 1) battle.loseHp(unit, loss, { source: unit }); }); diff --git a/server/sim/content/kits/tier1.js b/server/sim/content/kits/tier1.js index a24da09e..6400e7c8 100644 --- a/server/sim/content/kits/tier1.js +++ b/server/sim/content/kits/tier1.js @@ -901,7 +901,6 @@ export default { // hp_recovery_per_sec_by_max_hp_ratio × max HP per second (the 生命回复速度 attribute: works under her 武者 no-heal). chess_char_1_18_a: (bb, chess) => { const t = talentBb(chess, 0); - const buffKey = 'utage:s2'; const s1 = skillBbOf(chess, 'skchr_utage_1'); return { skills: { @@ -913,16 +912,16 @@ export default { }, }, skill: { - kind: 'passive', + kind: 'duration', activateOnDeploy: true, duration: num(bb.duration), spCost: 0, spType: 'none', trigger: 'NEVER', + mods: { atkPct: num(bb.atk) }, onStart({ battle, unit }) { const loss = unit.hp * num(bb.hp_ratio); if (loss > 0 && unit.hp - loss >= 1) battle.loseHp(unit, loss, { source: unit }); - battle.addBuff(unit, { key: buffKey, duration: num(bb.duration), mods: { atkPct: num(bb.atk) }, tags: ['skill'], visible: true }); battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1, id: unit.id, skill: 'breach' }); }, }, talents: [{ install(battle, unit) { - onHitBy(battle, unit, ({ dmg }) => { if (dmg.isAttack && dmg.type === 'phys' && unit.findBuff(buffKey)) dmg.type = 'arts'; }); + onHitBy(battle, unit, ({ dmg }) => { if (dmg.isAttack && dmg.type === 'phys' && unit.skill?.id === 'skchr_utage_2' && unit.skill.active) dmg.type = 'arts'; }); const maxAs = num(t.min_attack_speed), minHp = num(t.min_hp_ratio); if (maxAs > 0 && minHp < 1) { battle.on('tick', () => { @@ -965,11 +964,8 @@ export default { return { skills: { skchr_wildmn_1: { - kind: 'passive', - onStart({ battle, unit }) { - const d = num(r1?.duration); - if (d > 0) battle.addBuff(unit, { key: 'wildmn:s1', duration: d, mods: { aspd: num(r1?.bb?.attack_speed) }, tags: ['skill'], visible: true }); - }, + kind: 'duration', activateOnDeploy: true, duration: num(r1?.duration), spCost: 0, spType: 'none', trigger: 'NEVER', + mods: { aspd: num(r1?.bb?.attack_speed) }, }, }, skill: { diff --git a/server/sim/content/kits/tier2.js b/server/sim/content/kits/tier2.js index a5434da6..3414da21 100644 --- a/server/sim/content/kits/tier2.js +++ b/server/sim/content/kits/tier2.js @@ -547,7 +547,7 @@ export default { const t = talentBb(chess, 0); return { skill: { - kind: 'passive', + kind: 'duration', activateOnDeploy: true, duration: num(bb.duration, 10), spCost: 0, spType: 'none', trigger: 'NEVER', onStart({ battle, unit }) { const total = unit.s.maxHp * num(bb.hp_ratio); const dur = num(bb.duration, 10); @@ -566,10 +566,11 @@ export default { }); battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id }); }, + onEnd({ battle, unit }) { battle.removeBuff(unit, 'gravel:rats'); }, }, skills: { skchr_gravel_1: { - kind: 'passive', + kind: 'duration', activateOnDeploy: true, duration: num(bb.duration), spCost: 0, spType: 'none', trigger: 'NEVER', onStart({ battle, unit }) { const v = num(bb.def), dur = num(bb.duration); if (!(v > 0) || !(dur > 0)) return; @@ -584,6 +585,7 @@ export default { }); battle.fx('buff', { x: unit.x, y: unit.y, id: unit.id, kind: 'def' }); }, + onEnd({ battle, unit }) { battle.removeBuff(unit, 'gravel:shadow'); }, }, }, talents: [{ install(battle, unit) { diff --git a/server/sim/content/kits/tier3.js b/server/sim/content/kits/tier3.js index d8c315e2..2cd5c3d3 100644 --- a/server/sim/content/kits/tier3.js +++ b/server/sim/content/kits/tier3.js @@ -608,10 +608,8 @@ const KITS = { skills: altSkills(chess, d, bb, { 'skcom_quickattack[3]': statSkill, skchr_skadi_2: (s) => ({ - kind: 'passive', - onStart({ battle, unit }) { // "部署后N秒内攻击力+X" (a passive starts at every deployment) - battle.addBuff(unit, { key: 'skill:skadi_wave', duration: num(s.bb.duration, 20), mods: { atkPct: num(s.bb.atk) }, visible: true }); - }, + kind: 'duration', activateOnDeploy: true, duration: num(s.bb.duration, 20), spCost: 0, spType: 'none', trigger: 'NEVER', + mods: { atkPct: num(s.bb.atk) }, }), }), talents: [ diff --git a/server/sim/content/kits/tier4.js b/server/sim/content/kits/tier4.js index d4c6c56c..ae8bb9e0 100644 --- a/server/sim/content/kits/tier4.js +++ b/server/sim/content/kits/tier4.js @@ -495,21 +495,9 @@ const kits = { }, }), skchr_ines_3: () => ({ - kind: 'passive', + kind: 'duration', duration: s3Dur, spCost: 0, spType: 'none', trigger: 'NEVER', onStart({ battle, unit }) { - if (!unit.mem.inesS3Placed) { - // 首次部署: place a 影哨 (her talent's sentry, left on retreat) and leave; 立刻刷新再部署时间 — the redeploy - // (auto, paying her DP cost like every redeploy) follows as soon as it is affordable - unit.mem.inesS3Placed = true; - battle.after(0, () => { - if (!unit.alive || !unit.deployed) return; - battle.retreat(unit, { reason: 'retreat' }); - unit.respawnAt = battle.time; - battle.fx('sentry', { x: unit.x, y: unit.y, id: unit.id }); - }, { owner: unit }); - return; - } - battle.addBuff(unit, { key: 'ines:s3', duration: s3Dur, mods: { atkPct: num(bb.atk) }, visible: true }); + battle.addBuff(unit, { key: 'ines:s3', mods: { atkPct: num(bb.atk) }, visible: true }); // 立刻收回影哨: the sentry flies back to her and hits ≤ max_target enemies on its way (within // projectile_range of the segment, [ASSUMED] nearest to its start first) const from = unit.mem.inesSentryAt; @@ -533,6 +521,7 @@ const kits = { } battle.fx('beam', { x: ax, y: ay, tx: bx, ty: by, id: unit.id }); }, + onEnd({ battle, unit }) { battle.removeBuff(unit, 'ines:s3'); }, }), }), skill: { @@ -602,11 +591,27 @@ const kits = { ], install(battle, unit) { if (S3) { + battle.on('deploy', (c) => { + if (c.unit !== unit || c.move) return; + if (unit.mem.inesS3Placed) { + unit.skill.activate('deploy'); + return; + } + // 首次部署 only places a 影哨 and retreats; it is not a skill activation. + // 立刻刷新再部署时间: redeploy as soon as its DP cost is affordable. + unit.mem.inesS3Placed = true; + battle.after(0, () => { + if (!unit.alive || !unit.deployed) return; + battle.retreat(unit, { reason: 'retreat' }); + unit.respawnAt = battle.time; + battle.fx('sentry', { x: unit.x, y: unit.y, id: unit.id }); + }, { 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 }); // 技能期间每对一个敌人造成伤害就获得1点部署费用 battle.on('damaged', (c) => { - if (c.source !== unit || c.target.side !== 'enemy' || !(c.amount > 0) || c.type === 'element' || !unit.findBuff('ines:s3')) return; + if (c.source !== unit || c.target.side !== 'enemy' || !(c.amount > 0) || c.type === 'element' || !unit.skill?.active || !(unit.skill.timeLeft > 0)) return; battle.addDp(unit.ownerId, num(bb.cost, 1)); }, { owner: unit }); } @@ -1214,17 +1219,12 @@ const kits = { const g = grid(def.skill?.rangeGrid) || [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 0], [0, 1], [1, -1], [1, 0], [1, 1]]; const S1 = isSel(def, 'skchr_texas2_1'), S2 = isSel(def, 'skchr_texas2_2'); const dur = num(def.skill?.duration, S1 ? 11 : S2 ? 8 : 6); - const talentAtk = (battle, unit) => battle.addBuff(unit, { key: 'texas2:rainAtk', duration: dur, mods: { atkPct: num(t0.atk, 0.2) } }); const castS1 = (battle, unit) => { if (!unit.alive || !unit.deployed) return; - talentAtk(battle, unit); - battle.addBuff(unit, { key: 'texas2:drizzle', duration: dur, mods: { atkPct: num(bb.atk) }, visible: true }); battle.fx('swordRain', { x: unit.x, y: unit.y, id: unit.id }); }; const castS2 = (battle, unit) => { if (!unit.alive || !unit.deployed) return; - talentAtk(battle, unit); - battle.addBuff(unit, { key: 'texas2:shower', duration: dur, mods: { atkPct: num(bb.atk) }, visible: true }); const mr = num(bb.magic_resistance, 0); for (const e of targetsInGrid(battle, unit, g)) { battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1.5), type: 'arts', isSkill: true, tags: ['skill', 'burst'] }); @@ -1235,16 +1235,14 @@ const kits = { }; const castS3 = (battle, unit) => { if (!unit.alive || !unit.deployed) return; - talentAtk(battle, unit); for (const e of targetsInGrid(battle, unit, g)) { for (let i = 0; i < 2 && e.alive; i++) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb['appear.atk_scale'], 1.15), type: 'arts', isSkill: true, tags: ['skill', 'burst'] }); if (e.alive) battle.applyStatus(e, 'stun', { duration: num(bb['appear.stun'], 1.5), source: unit }); } battle.fx('swordStorm', { x: unit.x, y: unit.y, id: unit.id }); - unit.mem.rainUntil = battle.time + dur; unit.mem.rainTimer?.cancel(); unit.mem.rainTimer = battle.every(Math.max(0.1, num(bb['texas2_s_3[sword].interval'], 1)), (b, sched) => { - if (!unit.alive || !unit.deployed || b.time > unit.mem.rainUntil + 1e-9) { sched.cancel(); return; } + if (!unit.alive || !unit.deployed || !unit.skill?.active) { sched.cancel(); return; } const list = targetsInGrid(b, unit, g); sortEnemyTargets(b, unit, list, null); for (const e of list.slice(0, Math.max(1, Math.floor(num(bb.max_target, 2))))) { @@ -1253,28 +1251,62 @@ const kits = { b.fx('swordRain', { x: e.x, y: e.y, id: e.id }); } }, { owner: unit }); + // Duration advances by dt in the ally phase; align the rain with that clock so the final wave lands before end. + unit.mem.rainTimer.due -= battle.dt; }; const cast = S1 ? castS1 : S2 ? castS2 : castS3; + const skill = { + kind: 'duration', activateOnDeploy: true, duration: dur, spCost: 0, spType: 'none', trigger: 'NEVER', + mods: { atkPct: num(t0.atk, 0.2) + (S1 || S2 ? num(bb.atk) : 0) }, + onStart({ battle, unit, reason }) { + if (reason === 'kill' && unit.mem.texasRecastPending) { + unit.mem.texasRecastPending = false; // the synchronous burst already performed this recast + return; + } + unit.mem.texasCasting = true; + cast(battle, unit); + unit.mem.texasCasting = false; + }, + onEnd({ unit, reason }) { + if (reason === 'recast' && unit.mem.texasRecastPending) return; + unit.mem.rainTimer?.cancel(); + }, + }; return { - skill: { kind: 'passive', onStart({ battle, unit }) { cast(battle, unit); } }, + skill, skills: alt(def, { - skchr_texas2_1: () => ({ kind: 'passive', onStart({ battle, unit }) { cast(battle, unit); } }), - skchr_texas2_2: () => ({ kind: 'passive', onStart({ battle, unit }) { cast(battle, unit); } }), + skchr_texas2_1: () => skill, + skchr_texas2_2: () => skill, }), // S2: 攻击变为二连击 while the passive lasts - trait: S2 ? { hitsFn: (b, u) => (u.findBuff('texas2:shower') ? 2 : 1) } : undefined, + trait: S2 ? { hitsFn: (b, u) => (u.skill?.active ? 2 : 1) } : undefined, talents: [ { install(battle, unit) { // 德克萨斯传统 (2nd half): first kill of each deployment ⇒ full heal + recast // (the passive's deploy burst runs inside skill.reset, BEFORE the `deploy` hook: the per-deployment state is // therefore reset when she leaves the field, so a kill by the deploy burst itself counts) - battle.on('death', (c) => { if (c.unit === unit) unit.mem.texasKilled = false; }, { owner: unit }); + battle.on('death', (c) => { + if (c.unit !== unit) return; + unit.mem.texasKilled = unit.mem.texasCasting = unit.mem.texasRecastPending = false; + }, { owner: unit }); battle.on('kill', (c) => { if (c.killer !== unit || !unit.alive || c.victim.side !== 'enemy' || unit.mem.texasKilled) return; unit.mem.texasKilled = true; battle.removeBuff(unit, 'texas2:swordplay'); battle.heal(unit, unit, unit.s.maxHp * num(t0.hp_ratio, 1), { self: true }); - cast(battle, unit); + if (unit.mem.texasCasting) { + unit.mem.texasRecastPending = true; + cast(battle, unit); // preserve the burst's synchronous reentry and damage order + } else { + unit.skill.end('recast'); + unit.skill.activate('kill', { free: true }); + } }, { owner: unit }); + battle.on('skillStart', (c) => { + if (c.unit !== unit || !unit.mem.texasRecastPending) return; + // Complete the lifecycle after observers received the outer start; the burst already recast above. + unit.skill.end('recast'); + unit.skill.activate('kill', { free: true }); + }, { owner: unit, priority: -2000 }); } }, { install(battle, unit) { // 德克萨斯剑术: until her first kill after each deployment: ASPD +8, −25 % damage taken battle.on('deploy', (c) => { @@ -1290,7 +1322,7 @@ const kits = { const dot = num(bb['attack@texas2_s_1[dot].dot_damage'], 260), dotIv = Math.max(0.1, num(bb['attack@texas2_s_1[dot].interval'], 1)); battle.on('damaged', (c) => { const e = c.target; - if (c.source !== unit || !c.dmg?.isAttack || e.side !== 'enemy' || !e.alive || !unit.findBuff('texas2:drizzle')) return; + if (c.source !== unit || !c.dmg?.isAttack || e.side !== 'enemy' || !e.alive || !unit.skill?.active) return; battle.applyStatus(e, 'silence', { duration: sil, source: unit }); battle.addBuff(e, { key: `texas2:drizzleDot:${unit.id}`, duration: dotDur + 1e-6, interval: dotIv, source: unit, refresh: 'extend', // a re-hit refreshes it, the per-second ticks keep their rhythm onTick: ({ unit: t }) => battle.dealDamage(unit, t, { amount: dot, type: 'arts', isSkill: true, tags: ['skill', 'dot'] }) }); @@ -1299,7 +1331,7 @@ const kits = { if (S2) { // 攻击…造成法术伤害 while the passive lasts (the double hit is the kit trait's hitsFn) battle.on('hit', (c) => { - if (c.source === unit && c.dmg.isAttack && c.dmg.type === 'phys' && unit.findBuff('texas2:shower')) c.dmg.type = 'arts'; + if (c.source === unit && c.dmg.isAttack && c.dmg.type === 'phys' && unit.skill?.active) c.dmg.type = 'arts'; }, { owner: unit, priority: 50 }); } const a = num(tb.atk, 0); // module (elite): ATK +10 % with no ally on the 4 adjacent tiles diff --git a/server/sim/content/kits/tier6.js b/server/sim/content/kits/tier6.js index aff23f24..47c76466 100644 --- a/server/sim/content/kits/tier6.js +++ b/server/sim/content/kits/tier6.js @@ -2679,23 +2679,24 @@ function nearl2(bb, chess, def) { // (whole hits negated); then she withdraws and this redeploy time is ×respawn_time (×1 when the operator deployed // right before her is 【卡西米尔】) skchr_nearl2_2: { - kind: 'passive', + kind: 'duration', activateOnDeploy: true, duration: num(def?.skill?.duration, 22), spCost: 0, spType: 'none', trigger: 'NEVER', + mods: { atkPct: num(bb.atk) }, onStart({ battle, unit, skill }) { const dur = num(skill.duration, num(def?.skill?.duration, 22)); if (!(dur > 0)) return; const prev = lastOps.map?.get(unit.ownerId); - const combo = !!prev && prev !== unit && hasBond(prev, 'kazimierzShip'); - battle.addBuff(unit, { key: 'nearl2:night', mods: { atkPct: num(bb.atk) }, duration: dur, visible: true }); + unit.mem.nearl2Combo = !!prev && prev !== unit && hasBond(prev, 'kazimierzShip'); const hits = Math.floor(num(bb.times)); if (hits > 0) battle.addBuff(unit, { key: 'nearl2:shield', shieldHits: hits, duration: dur, visible: true }); - const seq = unit.deploySeq; - battle.after(dur, () => { - if (!live(unit) || unit.deploySeq !== seq) return; - battle.retreat(unit, { reason: 'retreat' }); - const mul = combo ? num(bb['nearl2_s_2[withdraw][combo].respawn_time'], 1) : num(bb.respawn_time, 1); - if (Number.isFinite(unit.respawnAt) && mul !== 1) unit.respawnAt = battle.time + (unit.respawnAt - battle.time) * mul; - battle.fx('disappear', { x: unit.x, y: unit.y, id: unit.id, combo }); - }, { owner: unit }); + }, + onEnd({ battle, unit, reason }) { + battle.removeBuff(unit, 'nearl2:shield'); + if (reason !== 'duration' || !live(unit)) return; + const combo = unit.mem.nearl2Combo; + battle.retreat(unit, { reason: 'retreat' }); + const mul = combo ? num(bb['nearl2_s_2[withdraw][combo].respawn_time'], 1) : num(bb.respawn_time, 1); + if (Number.isFinite(unit.respawnAt) && mul !== 1) unit.respawnAt = battle.time + (unit.respawnAt - battle.time) * mul; + battle.fx('disappear', { x: unit.x, y: unit.y, id: unit.id, combo }); }, }, }; diff --git a/server/sim/skills.js b/server/sim/skills.js index 1ec06aee..cb14d706 100644 --- a/server/sim/skills.js +++ b/server/sim/skills.js @@ -211,6 +211,7 @@ export class SkillRuntime { this.gainSp(carry && Number.isFinite(carry.sp) ? carry.sp : this.initSp, 'init', true); // a free (spCost 0) non-passive skill is available once per deployment if (this.spCost <= 0) this.charges = this.maxCharges; + if (this.spec.activateOnDeploy) this.activate('deploy'); } _startPassive() { diff --git a/server/sim/snapshot.js b/server/sim/snapshot.js index 2b0c86a5..13241049 100644 --- a/server/sim/snapshot.js +++ b/server/sim/snapshot.js @@ -89,13 +89,16 @@ export function animOf(u, t) { /** Snapshot tuple for one unit. */ export function unitTuple(u, t) { const sk = u.skill; - const spMax = sk && !sk.noSkill ? sk.spCost : 0; + let spMax = sk && !sk.noSkill ? sk.spCost : 0; let sp = sk && !sk.noSkill ? sk.sp : 0; if (sk && sk.active && sk.isTimed) { // show remaining duration/ammo as a draining bar — ammo out of the activation's real total (base + bullets added: // 拉特兰, 逃犯引渡手续, refills; community report #35), so every bullet shortens it if (sk.kind === 'ammo') sp = spMax * (sk.ammoLeft / Math.max(1, sk.ammoMax || sk.ammo || 0, sk.ammoLeft)); - else if (Number.isFinite(sk.timeLeft) && sk.duration > 0) sp = spMax * (sk.timeLeft / sk.duration); + else if (Number.isFinite(sk.timeLeft) && sk.duration > 0) { + if (spMax === 0) spMax = sk.duration; + sp = spMax * (sk.timeLeft / sk.duration); + } } // hp is rounded up (a living unit never shows 0) but never above the rounded max HP const maxHp = Math.max(1, Math.round(u.s.maxHp)); diff --git a/test/content/bonds_addon.test.js b/test/content/bonds_addon.test.js index 2dd68557..f5d796c2 100644 --- a/test/content/bonds_addon.test.js +++ b/test/content/bonds_addon.test.js @@ -71,6 +71,28 @@ test('迅捷: member skill end +12 SP (p=1 at high L), ≥40 layers every operat assert.equal(off.s_x, 0); }); +test('迅捷 / 突袭: SP gifts after end do not recharge a zero-SP deployment skill or trigger a ready raid', () => { + const h = makeBattle({ + defs: { chess: { t_deploy: chessRec({ id: 't_deploy', bonds: ['swiftShip', 'raidShip'], skill: { spCost: 0 } }) } }, + units: [{ chessId: 't_deploy', row: 10, col: 4 }], + bonds: { swiftShip: bond(1, 1000), raidShip: bond(1) }, captureNoisy: true, + kits: { t_deploy: () => ({ skill: { + kind: 'duration', activateOnDeploy: true, duration: 2, spCost: 0, spType: 'none', trigger: 'NEVER', + } }) }, + }); + h.b.start(); + const u = h.unit('t_deploy'), seq = u.deploySeq; + h.run(5); + assert.deepEqual(h.hooksOf('skillEnd').map((c) => c.reason), ['duration']); + assert.ok(h.hooksOf('spGain').some((c) => c.unit === u), 'swift end handler attempted SP gain'); + assert.equal(u.skill.sp, 0); + assert.equal(u.skill.charges, 0); + assert.equal(u.skill.ready, false); + assert.equal(u.skill.activations, 1); + assert.equal(u.deploySeq, seq, 'skill end alone does not meet raid readiness'); + checkInvariants(h.b); +}); + test('迅捷 / 不屈 proc chances: p = min(1, base + per·L) at each layer count', () => { const sw = bondBb('swiftShip'), ind = bondBb('indomShip'); close(procChance(sw, 0), 0.20); diff --git a/test/content/items.test.js b/test/content/items.test.js index 7a4cca55..83d1f1d8 100644 --- a/test/content/items.test.js +++ b/test/content/items.test.js @@ -165,6 +165,24 @@ test('炎国短刀: each skill activation ATK +5 % / +8 % (one multiplier), at m } }); +test('炎国短刀: timed deployment skill adds one stack per deployment, including carry.skillActive', () => { + const id = 'chess_char_1_19_a'; + for (const [item, per] of [[A('3_04'), 0.05], [B('3_04'), 0.08]]) { + const h = makeBattle({ units: [{ chessId: id, row: 10, col: 4, skillIndex: 0, items: [item], carryState: { skillActive: true } }] }); + h.b.start(); + const u = h.unit(id); + assert.equal(u.skill.activations, 1); + close(u.s.atk, u.base.atk * (1 + per), 'one deployment stack'); + h.run(u.skill.duration + 1); + close(u.s.atk, u.base.atk * (1 + per), 'no extra stack on end'); + h.b.retreat(u); + assert.ok(h.b.redeploy(u, { free: true })); + assert.equal(u.skill.activations, 2); + close(u.s.atk, u.base.atk * (1 + per * 2), 'one more on redeployment'); + checkInvariants(h.b); + } +}); + test('迅捷作战粮: on deploy SP +3 / +6, plus as much per other operator sharing a bond', () => { for (const [id, each] of [[A('3_05'), 3], [B('3_05'), 6]]) { const ops = { t_op: op('t_op', { bonds: ['swiftShip'] }), t_a: op('t_a', { bonds: ['swiftShip'] }), t_b: op('t_b', { bonds: ['yanShip'] }) }; diff --git a/test/content/kits_alt_t1.test.js b/test/content/kits_alt_t1.test.js index 993b9a6c..c873b9fa 100644 --- a/test/content/kits_alt_t1.test.js +++ b/test/content/kits_alt_t1.test.js @@ -444,6 +444,8 @@ test('1_18 宴 S1 分神: no attacks, block 0 (releases), DEF +def, regenerates assert.equal(u.s.blockCnt, 0); assert.equal(u.blocking.length, 0); assert.equal(e.blockedBy, null, 'blocked enemy released'); + h.b.dealDamage(u, e, { amount: 100, type: 'phys', isAttack: true }); + assert.equal(dealt(h, u).at(-1).type, 'phys', 'active S1 does not inherit S2 arts conversion'); approx(u.s.def, u.base.def * (1 + bb.def), `${id} DEF`); approx(u.s.hpRegen, u.s.maxHp * bb.hp_recovery_per_sec_by_max_hp_ratio, 'regen'); u.hp = u.s.maxHp * 0.3; @@ -462,23 +464,71 @@ test('1_18 宴 S1 分神: no attacks, block 0 (releases), DEF +def, regenerates } }); -test('1_19 野鬃 S1 骑枪刺击 (PASSIVE): ASPD +attack_speed for `duration` s after every deployment', () => { +test('1_18 宴 S2: deployment loses current HP once; ATK and normal-attack arts conversion end with the duration', () => { + for (const id of pair('18')) { + const s = rec(id, 'skchr_utage_2'), bb = s.bb; + const h = run({ defs: { chess: noGarrison(id), enemies: { e: dummy('e') } }, + units: [entry(id, s.skillId, { row: 10, col: 4, carryState: { hpPct: 0.5, skillActive: true } })] }); + h.b.start(); + const u = h.unit(id); + assert.equal(u.skill.kind, 'duration'); + assert.equal(u.skill.activations, 1); + assert.equal(u.skill.charges, 0); + approx(u.hp, u.s.maxHp * 0.5 * (1 - bb.hp_ratio), 'one loss from carried current HP'); + approx(u.s.atk, u.base.atk * (1 + bb.atk)); + const e = h.spawn('e', { pos: [10, 8] }); + const hit = () => { h.b.dealDamage(u, e, { amount: 100, type: 'phys', isAttack: true }); return dealt(h, u).at(-1); }; + assert.equal(hit().type, 'arts'); + assert.ok(!hit().dmg.isSkill, 'normal attack marker retained'); + h.run(bb.duration / 2); + approx(u.skill.timeLeft, bb.duration / 2); + h.run(bb.duration / 2 + 0.1); + assert.equal(u.skill.active, false); + assert.equal(u.skill.ready, false); + approx(u.s.atk, u.base.atk); + assert.equal(hit().type, 'phys'); + assert.deepEqual(ended(h, u).map((c) => c.reason), ['duration']); + const hp = u.hp; + h.run(3); + approx(u.hp, hp, 'no second loss while staying deployed'); + h.b.retreat(u); + assert.ok(h.b.redeploy(u, { free: true })); + assert.equal(u.skill.activations, 2); + approx(u.hp, u.s.maxHp * (1 - bb.hp_ratio), 'new deployment loses current HP once'); + done(h); + } +}); + +test('1_19 野鬃 S1 骑枪刺击: ASPD +attack_speed for `duration` s after every deployment', () => { for (const id of pair('19')) { const s = rec(id, 'skchr_wildmn_1'); const h = run({ defs: { chess: noGarrison(id) }, units: [entry(id, s.skillId, { row: 9, col: 5 })] }); const u = sel(h, id, s.skillId); - assert.equal(u.skill.kind, 'passive'); - h.step(); + h.b.start(); + assert.equal(u.skill.kind, 'duration'); + assert.equal(u.skill.active, true); + assert.equal(u.skill.activations, 1); + assert.equal(u.skill.charges, 0); + assert.equal(u.skill.ready, false); + assert.equal(started(h, u)[0].reason, 'deploy'); approx(u.s.aspd, u.base.aspd + s.bb.attack_speed, `${id} ASPD after deployment`); - h.run(s.duration - 0.5); + h.run(s.duration / 2); + approx(u.skill.timeLeft, s.duration / 2); + assert.deepEqual(h.snapshot().units.find((t) => t[0] === u.id).slice(5, 7), [s.duration / 2, s.duration]); + h.run(s.duration / 2 - 0.5); approx(u.s.aspd, u.base.aspd + s.bb.attack_speed, 'still up'); h.run(1); approx(u.s.aspd, u.base.aspd, `expired after ${s.duration} s`); + assert.equal(u.skill.active, false); + assert.equal(u.skill.ready, false); + assert.deepEqual(ended(h, u).map((c) => c.reason), ['duration']); h.b.kill(u, null); h.step(); assert.ok(h.b.redeploy(u, { free: true })); h.step(); approx(u.s.aspd, u.base.aspd + s.bb.attack_speed, 'again after the redeployment'); + assert.equal(u.skill.activations, 2); + assert.equal(ended(h, u).length, 1, 'expired skill does not end again on death'); done(h); } }); diff --git a/test/content/kits_alt_t2.test.js b/test/content/kits_alt_t2.test.js index 0577a25f..5a7ee80b 100644 --- a/test/content/kits_alt_t2.test.js +++ b/test/content/kits_alt_t2.test.js @@ -342,12 +342,20 @@ test('2_12 砾 S1 影袭: at deployment DEF +def decaying to 0 over `duration` s h.run(0.5); approx(u.s.def, u.base.def * (1 + aura + bb.def), `${id} full bonus`); assert.equal(u.s.shield, 0, 'no 鼠群 barrier'); + assert.equal(u.skill.kind, 'duration'); + assert.equal(u.skill.active, true); + assert.equal(u.skill.ready, false); + assert.equal(h.snapshot().units.find((t) => t[0] === u.id)[6], bb.duration); h.run(1); approx(u.s.def, u.base.def * (1 + aura + bb.def * (bb.duration - 1) / bb.duration), 'one step down after 1 s'); h.run(0.4); approx(u.s.def, u.base.def * (1 + aura + bb.def * (bb.duration - 1) / bb.duration), 'steps once per second'); h.run(bb.duration); approx(u.s.def, u.base.def * (1 + aura), 'gone'); + assert.equal(u.skill.active, false); + assert.equal(u.skill.ready, false); + assert.equal(u.findBuff('gravel:shadow'), null); + assert.equal(h.hooksOf('skillEnd').filter((c) => c.unit === u && c.reason === 'duration').length, 1); done(h); } }); diff --git a/test/content/kits_alt_t3.test.js b/test/content/kits_alt_t3.test.js index 6d4fb48f..c4ac8fe8 100644 --- a/test/content/kits_alt_t3.test.js +++ b/test/content/kits_alt_t3.test.js @@ -368,19 +368,34 @@ test('3_05 斯卡蒂 S1 迅捷打击·γ型 ATK/ASPD; S2 跃浪击 ATK + for `du const g = makeBattle({ timeLimit: 60, units: [U(id, 'skchr_skadi_2', 10, 4)] }); const v = g.unit(id); g.step(); - assert.equal(v.skill.kind, 'passive'); + assert.equal(v.skill.kind, 'duration'); + assert.equal(v.skill.active, true); + assert.equal(v.skill.charges, 0); + assert.equal(v.skill.ready, false); + assert.equal(g.hooksOf('skillStart')[0].reason, 'deploy'); approx(v.s.atk, v.base.atk * (1 + b2.atk + own)); + if (id.endsWith('_b')) { + const left = v.skill.timeLeft; + g.b.dealDamage(null, v, { type: 'true', amount: 1e7 }); + assert.equal(v.alive, true, 'DRE-Y revives in place during the window'); + assert.equal(v.skill.activations, 1); + approx(v.skill.timeLeft, left, 1e-9, 'revival preserves the remaining skill time'); + } g.run(b2.duration - 0.5); approx(v.s.atk, v.base.atk * (1 + b2.atk + own)); g.run(1); approx(v.s.atk, v.base.atk * (1 + own), 1e-6, 'gone after its duration'); + assert.equal(v.skill.active, false); + assert.equal(v.skill.ready, false); + assert.deepEqual(g.hooksOf('skillEnd').map((c) => c.reason), ['duration']); // redeployed ⇒ again g.b.dealDamage(null, v, { type: 'true', amount: 1e7 }); - if (!v.alive) { - g.b.redeploy(v, { free: true }); - g.step(); - approx(v.s.atk, v.base.atk * (1 + b2.atk + own), 1e-6, 'every deployment'); - } + assert.equal(v.skill.activations, 1, 'DRE-Y in-place revival does not deploy or restart S2'); + if (v.alive) g.b.retreat(v); + assert.ok(g.b.redeploy(v, { free: true })); + approx(v.skill.timeLeft, b2.duration); + assert.equal(v.skill.activations, 2); + approx(v.s.atk, v.base.atk * (1 + b2.atk + own), 1e-6, 'every deployment'); done(g); } // DRE-X: ×atk_scale on blocked enemies; no DRE-Y revive diff --git a/test/content/kits_alt_t4.test.js b/test/content/kits_alt_t4.test.js index c5c5849d..bf06d02a 100644 --- a/test/content/kits_alt_t4.test.js +++ b/test/content/kits_alt_t4.test.js @@ -265,6 +265,13 @@ test('伊内丝 S3 独影归途: first deployment leaves a sentry and retreats; const sk = D(id, 2).skill, bb = sk.bb; const h = battle([U(id, 10, 5, 2)], { flags: { dpInit: 99, dpPerSec: 0 } }); const u = h.unit(id); + h.b.start(); + assert.equal(u.skill.kind, 'duration'); + assert.equal(u.skill.active, false, 'first deployment does not activate the skill'); + assert.equal(u.skill.activations, 0); + assert.equal(h.hooksOf('skillStart').filter((c) => c.unit === u).length, 0, 'placing a sentry emits no skillStart'); + assert.equal(u.skill.timeLeft, 0, 'first deployment places the sentry without a full effect window'); + assert.equal(u.findBuff('ines:s3'), null); const near = []; for (let i = 0; i < 3; i++) near.push(h.spawn('enemy_dummy', { pos: [10, 6] }), h.spawn('enemy_dummy', { pos: [10, 4] })); const far = h.spawn('enemy_dummy', { pos: [10, 8] }); @@ -273,9 +280,14 @@ test('伊内丝 S3 独影归途: first deployment leaves a sentry and retreats; assert.ok(left && left.reason === 'retreat', `${id}: leaves right after the first deployment`); assert.ok(h.runUntil(() => u.alive && u.deployed, 2), 'redeploy timer refreshed: back at once'); const buff = u.findBuff('ines:s3'); - assert.ok(buff, 'passive buff on the redeploy'); + assert.ok(buff, 'ATK effect on the redeploy'); approx(buff.mods.atkPct, bb.atk, 1e-9); - approx(buff.timeLeft, sk.duration, 0.1); + approx(u.skill.timeLeft, sk.duration, 0.1); + assert.equal(u.skill.active, true); + assert.equal(u.skill.activations, 1); + assert.equal(h.hooksOf('skillStart').filter((c) => c.unit === u).length, 1, 'the redeployment starts the skill once'); + assert.equal(u.skill.ready, false); + assert.equal(h.snapshot().units.find((t) => t[0] === u.id)[6], sk.duration); const recall = dmgBy(h, u, tagged('sentryRecall')); assert.equal(recall.length, bb.max_target, `${id}: recall hits ≤ ${bb.max_target} of 6`); assert.ok(recall.every((c) => near.includes(c.target)) && !recall.some((c) => c.target === far)); @@ -287,6 +299,15 @@ test('伊内丝 S3 独影归途: first deployment leaves a sentry and retreats; assert.ok(hits > 0); assert.equal(h.b.getPlayer('p1').dp, dpAt + hits * bb.cost, 'one DP per damage while the passive lasts'); assert.equal(h.hooksOf('death').filter((c) => c.unit === u).length, 1, 'only the first deployment leaves'); + h.run(sk.duration); + assert.equal(u.skill.active, false); + assert.equal(u.skill.ready, false); + assert.equal(u.findBuff('ines:s3'), null); + const dpEnd = h.b.getPlayer('p1').dp; + h.run(2); + assert.equal(h.b.getPlayer('p1').dp, dpEnd, 'no skill DP after the actual window ends'); + assert.equal(h.hooksOf('death').filter((c) => c.unit === u).length, 1, 'stays deployed after the window'); + assert.deepEqual(h.hooksOf('skillEnd').filter((c) => c.unit === u).map((c) => c.reason), ['duration']); checkInvariants(h.b); } }); @@ -611,10 +632,11 @@ test('缄默德克萨斯 S1 细雨无声: passive ATK up for its duration; hits const h = battle([U(id, 10, 4, 0)]); h.step(); const u = h.unit(id); - const buff = u.findBuff('texas2:drizzle'); - assert.ok(buff, `${id}: passive active on deployment`); - approx(buff.mods.atkPct, bb.atk, 1e-9); - assert.ok(u.findBuff('texas2:rainAtk'), 'talent 德克萨斯传统 ATK during the passive'); + assert.equal(u.skill.kind, 'duration'); + assert.equal(u.skill.active, true); + assert.equal(u.skill.charges, 0); + approx(u.skill.spec.mods.atkPct, bb.atk + D(id, 0).talents[0].bb.atk, 1e-9, 'skill and 德克萨斯传统 ATK'); + assert.equal(h.snapshot().units.find((t) => t[0] === u.id)[6], sk.duration); const e = h.spawn('enemy_dummy', { pos: [10, 5] }); h.run(3); assert.ok(e.s.flags.silence, 'hit target silenced (失去特殊能力)'); @@ -623,7 +645,10 @@ test('缄默德克萨斯 S1 细雨无声: passive ATK up for its duration; hits assert.ok(dot.length >= 2, `a DoT tick every second although she re-hits faster (${dot.length})`); for (const c of dot) approx(c.amount, bb['attack@texas2_s_1[dot].dot_damage'], 1e-9, 'fixed arts per second (RES 0)'); h.run(sk.duration); - assert.ok(!u.findBuff('texas2:drizzle'), 'ends with the skill duration'); + assert.equal(u.skill.active, false, 'ends with the skill duration'); + assert.equal(u.skill.ready, false); + assert.equal(h.hooksOf('skillEnd').filter((c) => c.unit === u && c.reason === 'duration').length, 1); + assert.ok(e.s.flags.silence && e.findBuff(`texas2:drizzleDot:${u.id}`), 'applied enemy effects keep their own duration'); h.run(bb['attack@silence'] + 1); assert.ok(!e.s.flags.silence && !e.findBuff(`texas2:drizzleDot:${u.id}`), 'no new silence / DoT after the passive'); } @@ -657,8 +682,8 @@ test('缄默德克萨斯 S2 阵雨连绵: deploy burst atk_scale arts + RES down approx(burst[0].amount, u.s.atk * bb.atk_scale * 0.5, 1e-6, 'arts burst vs RES 50'); approx(e.s.res, 50 * (1 + bb.magic_resistance), 1e-6, 'RES down'); assert.equal(far.s.res, 50); - approx(u.findBuff('texas2:shower').mods.atkPct, bb.atk, 1e-9); - approx(u.findBuff('texas2:shower').timeLeft, sk.duration, 1e-6); + approx(u.skill.spec.mods.atkPct, bb.atk + D(id, 1).talents[0].bb.atk, 1e-9); + approx(u.skill.timeLeft, sk.duration, 1e-6); h.run(2); const hits = dmgBy(h, u, (c) => c.dmg.isAttack); assert.ok(hits.length >= 2 && hits.every((c) => c.type === 'arts'), 'arts attacks'); @@ -666,6 +691,8 @@ test('缄默德克萨斯 S2 阵雨连绵: deploy burst atk_scale arts + RES down for (const c of hits) per.set(c.dmg.attackId, (per.get(c.dmg.attackId) ?? 0) + 1); assert.ok([...per.values()].every((v) => v === 2), 'double hits'); h.run(sk.duration); + assert.equal(u.skill.active, false); + assert.equal(u.skill.ready, false); const n0 = dmgBy(h, u, (c) => c.dmg.isAttack).length; h.run(3); const late = dmgBy(h, u, (c) => c.dmg.isAttack).slice(n0); @@ -673,6 +700,57 @@ test('缄默德克萨斯 S2 阵雨连绵: deploy burst atk_scale arts + RES down } }); +test('缄默德克萨斯 S1/S2/S3: first kill reopens a full window, including a kill inside the deployment burst', () => { + for (const id of pair('chess_char_4_16_a')) for (const index of [0, 1, 2]) { + const life = []; + const h = battle([U(id, 10, 4, index)], { extra: { setup(b) { + for (const name of ['skillStart', 'skillEnd']) b.on(name, (c) => life.push([name, c.reason]), { priority: 1000 }); + } } }); + h.b.start(); + const u = h.unit(id), sk = u.skill; + h.run(2); + u.hp = u.s.maxHp / 2; + const e = h.spawn('enemy_dummy', { pos: [10, 5] }); + h.b.kill(e, u); + assert.equal(u.mem.texasKilled, true); + approx(u.hpRatio, 1); + assert.equal(u.findBuff('texas2:swordplay'), null); + assert.equal(sk.activations, 2); + approx(sk.timeLeft, sk.duration); + assert.equal(sk.charges, 0); + assert.equal(sk.ready, false); + assert.deepEqual(h.hooksOf('skillEnd').map((c) => c.reason), ['recast']); + h.b.kill(h.spawn('enemy_dummy', { pos: [10, 5] }), u); + assert.equal(sk.activations, 2, 'only first kill recasts'); + h.run(sk.duration + 0.1); + assert.equal(sk.active, false); + assert.deepEqual(h.hooksOf('skillEnd').map((c) => c.reason), ['recast', 'duration']); + assert.deepEqual(h.hooksOf('skillStart').map((c) => c.reason), ['deploy', 'kill']); + h.b.retreat(u); + const lifeAt = life.length; + const weak = h.spawn('enemy_dummy', { pos: [10, 5] }); + weak.hp = 1; + assert.ok(h.b.redeploy(u, { free: true })); + if (index === 0) h.b.kill(weak, u); // S1 has no deployment burst + assert.equal(u.mem.texasKilled, true); + assert.equal(sk.activations, 4, 'new deployment and first-kill recast, including synchronous burst reentry'); + assert.deepEqual(life.slice(lifeAt), [['skillStart', 'deploy'], ['skillEnd', 'recast'], ['skillStart', 'kill']]); + assert.equal(u.findBuff('texas2:swordplay'), null); + approx(sk.timeLeft, sk.duration); + if (index === 2) { + const target = h.spawn('enemy_dummy', { pos: [10, 5] }); + h.run(sk.duration + 0.1); + const waves = new Set(dmgBy(h, u, tagged('swordRain')).filter((c) => c.target === target).map((c) => c.t)); + assert.equal(waves.size, sk.duration, 'reentrant deployment keeps a full set of rain waves'); + const n = dmgBy(h, u, tagged('swordRain')).length; + h.run(2); + assert.equal(dmgBy(h, u, tagged('swordRain')).length, n, 'rain stops at skill end'); + assert.ok(target.alive); + } + checkInvariants(h.b); + } +}); + test('缄默德克萨斯 module none: no ATK +10 % when alone (EXE-Y default has it)', () => { const has = (m) => { const h = battle([U('chess_char_4_16_b', 10, 4, null, m)]); h.run(0.5); return !!h.unit('chess_char_4_16_b').findBuff('texas2:module'); }; assert.equal(has(null), true); diff --git a/test/content/kits_alt_t6.test.js b/test/content/kits_alt_t6.test.js index 196d082a..c293489f 100644 --- a/test/content/kits_alt_t6.test.js +++ b/test/content/kits_alt_t6.test.js @@ -999,7 +999,11 @@ test('6_17 耀骑士临光 S2 逐夜烁光: on deploy ATK + and 3 护盾 layers const u = h.unit(id); usesSkill(u, sid); h.step(); - approx(u.findBuff('nearl2:night').mods.atkPct, bb.atk); + approx(u.skill.spec.mods.atkPct, bb.atk); + assert.equal(u.skill.kind, 'duration'); + assert.equal(u.skill.active, true); + assert.equal(u.skill.ready, false); + assert.equal(h.snapshot().units.find((t) => t[0] === u.id)[6], dur); assert.equal(u.findBuff('nearl2:shield').shieldHits, bb.times); h.run(bb.times * 1.2 + 1); const taken = h.hooksOf('damaged').filter((c) => c.target === u); @@ -1009,15 +1013,30 @@ test('6_17 耀骑士临光 S2 逐夜烁光: on deploy ATK + and 3 护盾 layers assert.ok(h.runUntil(() => !u.alive, dur + 1)); approx(h.b.time, dur, 'withdraws when it ends', 0.05); approx(u.respawnAt - h.b.time, u.base.respawnTime * bb.respawn_time, 'this redeploy ×1.25', 1e-3); + assert.equal(u.removeReason, 'retreat'); + assert.equal(u.skill.active, false); + assert.deepEqual(h.hooksOf('skillEnd').filter((c) => c.unit === u).map((c) => c.reason), ['duration']); done(h); const k = run({ defs: { chess: { kaz: plain('kaz', { bonds: ['kazimierzShip'] }) } }, units: [U(id, sid, 10, 4), { chessId: 'kaz', row: 12, col: 4 }] }); const ku = k.unit(id); k.step(2); - assert.ok(ku.alive && ku.findBuff('nearl2:night')); + assert.ok(ku.alive && ku.skill.active); assert.ok(k.runUntil(() => !ku.alive, dur + 1)); approx(ku.respawnAt - k.b.time, ku.base.respawnTime, '卡西米尔 before her: no extension', 1e-3); done(k); + + const dead = run({ units: [U(id, sid, 10, 4, { moduleId: 'none' })] }); + dead.step(); + const du = dead.unit(id); + dead.b.kill(du); + const respawnAt = du.respawnAt; + approx(respawnAt - dead.b.time, du.base.respawnTime, 'early death keeps ordinary redeployment'); + assert.equal(du.findBuff('nearl2:shield'), null); + dead.run(dur + 1); + assert.equal(du.respawnAt, respawnAt, 'no delayed retreat or multiplier after death'); + assert.deepEqual(dead.hooksOf('skillEnd').filter((c) => c.unit === du).map((c) => c.reason), ['death']); + done(dead); } }); diff --git a/test/content/kits_t1t2.test.js b/test/content/kits_t1t2.test.js index d1b3bb8f..84c205f0 100644 --- a/test/content/kits_t1t2.test.js +++ b/test/content/kits_t1t2.test.js @@ -942,6 +942,10 @@ test('2_12 砾: 鼠群 barrier hp_ratio × max HP decaying over `duration` s; assert.equal(u.base.cost, raw(id).stats.cost + t.cost); h.step(); const full = u.s.maxHp * bb.hp_ratio; + assert.equal(u.skill.kind, 'duration'); + assert.equal(u.skill.active, true); + assert.equal(u.skill.ready, false); + assert.deepEqual(h.snapshot().units.find((t) => t[0] === u.id).slice(5, 7), [Math.round(u.skill.timeLeft * 10) / 10, bb.duration]); approx(u.s.shield, full, 'full barrier', 1e-2); h.run(bb.duration / 2 + 0.5); approx(u.s.shield, full / 2, 'half decayed', 1e-2); @@ -949,6 +953,9 @@ test('2_12 砾: 鼠群 barrier hp_ratio × max HP decaying over `duration` s; approx(u.s.shield, full / 2, 'PRTS: the capacity steps once per second', 1e-2); h.run(bb.duration / 2); assert.equal(u.s.shield, 0); + assert.equal(u.skill.active, false); + assert.equal(u.skill.ready, false); + assert.equal(u.findBuff('gravel:rats'), null); done(h); // PRTS "受到伤害后,剩余容量根据屏障最大容量等比变化": a damaged barrier shrinks in proportion, not to the capacity const hd = run({ units: [{ chessId: id, row: 9, col: 5 }] }); @@ -960,6 +967,12 @@ test('2_12 砾: 鼠群 barrier hp_ratio × max HP decaying over `duration` s; hd.run(bb.duration / 2 + 0.5); approx(ud.s.shield, cap0 * 0.4 / 2, 'remaining × capacity ratio (a plain cap would keep 40 %)', 1e-2); assert.ok(ud.hp === ud.s.maxHp); + hd.b.retreat(ud); + assert.equal(ud.findBuff('gravel:rats'), null); + assert.equal(ud.skill.active, false); + assert.ok(hd.b.redeploy(ud, { free: true })); + approx(ud.skill.timeLeft, bb.duration); + approx(ud.s.shield, cap0, 'fresh shield on redeployment'); done(hd); const idb = 'chess_char_2_12_b', tb = tal(idb); const cheapRec = chessRec({ id: 't_cheap', stats: { cost: tb['cond.cost'] } }); diff --git a/test/content/kits_t4.test.js b/test/content/kits_t4.test.js index 18369006..471a90f3 100644 --- a/test/content/kits_t4.test.js +++ b/test/content/kits_t4.test.js @@ -645,7 +645,8 @@ test('缄默德克萨斯 S3 (passive): deploy burst 2 × 115 % arts + 1.5 s stun assert.equal(waves.size, D(id).skill.duration, 'one rain per second while the passive lasts'); assert.ok([...waves.values()].every((n) => n === bb.max_target), '≤ 2 distinct targets'); approx(rain[0].amount, u.base.atk * (1 + t0.atk) * bb.atk_scale, 1e-6); - assert.equal(u.findBuff('texas2:rainAtk'), null, 'ATK bonus ends with the passive'); + assert.equal(u.skill.active, false, 'ATK bonus ends with the skill'); + approx(u.s.atk, u.base.atk); }); test('缄默德克萨斯: first kill ⇒ full heal + recast; 剑术 buff removed', () => { @@ -660,7 +661,8 @@ test('缄默德克萨斯: first kill ⇒ full heal + recast; 剑术 buff removed assert.ok(u.mem.texasKilled); approx(u.hpRatio, 1, 1e-3, 'full heal'); assert.equal(u.findBuff('texas2:swordplay'), null); - assert.ok(u.findBuff('texas2:rainAtk'), 'passive recast'); + assert.equal(u.skill.active, true, 'skill recast'); + assert.equal(u.skill.activations, 2); }); test('星熊 S2 (passive): DEF +13 % and 65 % ATK thorns; 战术装甲 negates ~25 % of hits; 重装 aura DEF +6 %', () => { diff --git a/test/sim/feedback2-forced-exit-down.test.js b/test/sim/feedback2-forced-exit-down.test.js index 3d941151..43db0c5f 100644 --- a/test/sim/feedback2-forced-exit-down.test.js +++ b/test/sim/feedback2-forced-exit-down.test.js @@ -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); diff --git a/test/sim/skills.test.js b/test/sim/skills.test.js index dd72f3c6..d28b2679 100644 --- a/test/sim/skills.test.js +++ b/test/sim/skills.test.js @@ -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() } }, diff --git a/test/ui/loadout-battle.e2e.test.js b/test/ui/loadout-battle.e2e.test.js index c7a35574..fd5d920c 100644 --- a/test/ui/loadout-battle.e2e.test.js +++ b/test/ui/loadout-battle.e2e.test.js @@ -7,7 +7,7 @@ // // The chess: elite 野鬃 (chess_char_1_19_b). Default = S2 夹枪冲锋 (25/40 SP) + module 长枪替补套装 (ATK +40, ASPD +3). // Chosen here: S1 骑枪刺击 (ON_DEPLOY: "部署后攻击速度+100" for 25 s) + 不装备 — so right after the unit deploys the -// local battle must show the S1 buff (wildmn:s1, ASPD +100, skill active) and the no-module stats (ATK 524, ASPD 100). +// local battle must show S1 active with a draining duration bar, ASPD +100 and the no-module stats (ATK 524, ASPD 100). // The server is test/e2e/fastServer.mjs with its starter-kit hook (SP_START_CHESS: the elite in the hand at round 1); // everything else is the real match engine and the real UI driven by real mouse input. // Screenshots: test/e2e/out/loadout-battle-*.png. @@ -16,6 +16,9 @@ import assert from 'node:assert/strict'; import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf } from '../e2e/client.mjs'; +import { makeBattle } from '../helpers/battleHarness.js'; +import { UF } from '../../shared/constants.js'; +import { COLORS } from '../../public/js/render/style.js'; const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets')); const BASE = 'chess_char_1_19_a'; @@ -129,15 +132,16 @@ describe('user playtest #2 item 1 — loadout chosen in the UI fights in the loc const e = r && [...r._entries.values()].find((x) => x.own && x.battle); if (!e) return false; const u = e.battle.allyUnits.find((x) => x.uid === uid); - if (!u || !u.findBuff?.('wildmn:s1')) return false; - const b = u.findBuff('wildmn:s1'); + if (!u?.skill?.active || u.skill.id !== 'skchr_wildmn_1') return false; + const tuple = e.battle.snapshot().units.find((t) => t[0] === u.id); const entry = (e.spec.players || []).flatMap((p) => p.units || []).find((x) => x.uid === uid) || null; return { authoritative: e.authoritative, t: e.battle.time, spec: entry && { chessId: entry.chessId, skillIndex: entry.skillIndex, moduleId: entry.moduleId }, skill: u.skill?.id ?? null, source: u.kit?.skillSource ?? null, active: !!u.skill?.active, loadout: u.def?.loadout ?? null, atk: u.def?.stats?.atk, aspd: u.def?.stats?.aspd, - buff: { aspd: b.mods?.aspd ?? null }, + liveAspd: u.s.aspd, timeLeft: u.skill.timeLeft, duration: u.skill.duration, ready: u.skill.ready, + bar: tuple && { value: tuple[5], max: tuple[6] }, }; }, { timeout: 30000, polling: 100 }, piece.uid).then((h) => h.jsonValue()) .catch(async (err) => { @@ -157,7 +161,11 @@ describe('user playtest #2 item 1 — loadout chosen in the UI fights in the loc assert.equal(got.atk, rec.statsBase.atk, 'no module: base ATK (the default module adds +40)'); assert.equal(got.aspd, rec.statsBase.aspd, 'no module: base ASPD'); assert.notEqual(rec.stats.atk, rec.statsBase.atk, 'control: the default module would change ATK'); - assert.equal(got.buff.aspd, s1.bb.attack_speed, 'S1 signature: ASPD +100 on deploy'); + assert.equal(got.liveAspd - got.aspd, s1.bb.attack_speed, 'S1 signature: ASPD +100 on deploy'); + assert.equal(got.ready, false, 'deployment consumed its sole charge'); + assert.equal(got.duration, s1.duration); + assert.equal(got.bar.max, s1.duration, 'zero-SP skill has a duration bar'); + assert.ok(Math.abs(got.bar.value - got.timeLeft) <= 0.06); assert.ok(got.active, 'S1 is active right after deploying'); assert.ok(got.t < 10, `S1 fires on deploy (t = ${got.t}), long before S2 could charge (25/40 SP)`); await sleep(800); @@ -171,4 +179,63 @@ describe('user playtest #2 item 1 — loadout chosen in the UI fights in the loc await srv.stop(); } }); + + test('野鬃 S1 duration bar: full on deploy, half remaining, hidden after expiry (real renderer)', { timeout: 120000 }, async () => { + const h = makeBattle({ + stageId: 'act2autochess_m01', autoFinish: false, timeLimit: 60, + units: [{ chessId: ELITE, skillIndex: 0, moduleId: 'none', row: 9, col: 5 }], + }); + h.b.start(); + const u = h.unit(ELITE); + assert.equal(u.skill.id, 'skchr_wildmn_1'); + const meta = { ...h.b.fieldMeta(), stageId: 'act2autochess_m01' }; + const frames = []; + for (const [name, elapsed, share] of [['full', 0, 1], ['half', u.skill.duration / 2, 0.5], ['ended', u.skill.duration / 2 + h.TICK, 0]]) { + h.run(elapsed); + const { t, ...rest } = h.snapshot(); + frames.push({ name, share, snap: { ...rest, t: 'b.snap', gt: t } }); + assert.equal(u.skill.ready, false, `${name}: no remaining deploy charge`); + assert.equal(u.skill.active, name !== 'ended'); + } + const srv = await startRealServer(); + const P = (await import('puppeteer-core')).default; + const c = new Client(P, srv.base, 'duration', { prefix: 'loadout-battle' }); + try { + await c.open(); + await c.page.goto(`${srv.base}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`); + await c.page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 }); + assert.equal(await c.page.evaluate(() => window.__demo.error ?? null), null); + for (const f of frames) { + await c.page.evaluate((meta, snap) => { + const v = window.__demo.view; + v.enterBattle(meta); + v.setCamera('normal', { rect: meta.rect, side: 'L', instant: true }); + v.setLocalFeed({ on: true, speed: 2 }); + v.pushSnapshot(snap); + }, meta, f.snap); + await c.page.waitForFunction((id, spMax) => { + const x = window.__demo.view.debug.views.get(id); + return x?.spineReady && x.hud.visible && x.hud.alpha > 0.99 && x.hpFill.visible && x.spMax === spMax; + }, { timeout: 15000 }, u.id, f.snap.units.find((t) => t[0] === u.id)[6]); + const bar = await c.page.evaluate((id) => { + const x = window.__demo.view.debug.views.get(id); + return { visible: x.spFill.visible, bg: x.spBg.visible, share: x.spFill.width / (x.spBg.width - 2), + tint: x.spFill.tint, flags: x.flags, glow: x.spGlow.visible, sp: x.sp, max: x.spMax }; + }, u.id); + assert.equal(bar.glow, false, `${f.name}: no ready highlight`); + assert.equal(!!(bar.flags & UF.SKILL), f.name !== 'ended'); + assert.equal(bar.visible, f.name !== 'ended'); + assert.equal(bar.bg, f.name !== 'ended'); + if (bar.visible) { + assert.ok(Math.abs(bar.share - f.share) < 0.002, `${f.name}: drawn share ${bar.share}`); + assert.equal(bar.tint, COLORS.spActive); + } else assert.deepEqual([bar.sp, bar.max], [0, 0]); + await c.shot(`duration-${f.name}`); + } + assert.deepEqual(problemsOf([c]), []); + } finally { + await c.close(); + await srv.stop(); + } + }); });