diff --git a/docs/SIM.md b/docs/SIM.md index 3894bb29..6ac15217 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -521,7 +521,7 @@ registration order. `battle.off(handle)` / `battle.off(name, fn)` / `battle.offO | `elementHit` | `{ source, target, dmg }` | before a gauge fill (`dmg.type === 'element'`); mutate `dmg.amount`/`dmg.mul`, set `dmg.cancel` | | `damaged` | `{ source, target, amount, type, dmg, credit }` | after application (`amount` may be 0 when shielded); element fills too (with their source); 无来源: `source` null, `credit` set | | `heal` | `{ source, target, amount, opts }` | mutable `amount` | -| `fatal` | `{ unit, source, credit, dmg, amount, prevented }` | HP would reach 0 — set `prevented` (substitutes, 不屈, 复活 items) | +| `fatal` | `{ unit, source, credit, dmg, amount, prevented }` | HP would reach 0 — set `prevented` (substitutes, 不屈, 复活 items). Fired by every HP loss — hits of any type, element bursts, 无来源 damage, `loseHp` 流失. Order: kits' own savers (10 … −60) → items' 不死 (坚固维式重锤, `PRIO_REVIVE` −100; substitutes −100) → items' 复活 (M3茧甲, `PRIO_RESPAWN` −101: PRTS "复活" acts on a knock-out, which a 不死 prevents) → 埃芒加德 (−110) | | `kill` | `{ killer, victim }` | victim HP reached 0 (a handler may revive by restoring HP) | | `death` | `{ unit, reason:'killed'|'leak'|'retreat'|'merchant'|'expired'|'forcedExit', killer }` | unit removed (`'forcedExit'`: an operator entering 联防 knocked out, §1.1) | | `skillStart` / `skillEnd` | `{ unit, skill, reason }` | mutate `skill.ammoLeft` / `skill.timeLeft` in skillStart | diff --git a/server/sim/content/bands/battle.js b/server/sim/content/bands/battle.js index 01fd5e3f..67e8d30d 100644 --- a/server/sim/content/bands/battle.js +++ b/server/sim/content/bands/battle.js @@ -40,7 +40,7 @@ import { weaknessRetype, addShieldLayer, PRIO_REVIVE } from '../items/battle.js' import { spawnMapChar } from '../tokens.js'; export const AMEDIC_BAND = 'band_amedic'; -/** 'fatal' priority of 埃芒加德: after the operators' own items (PRIO_REVIVE) and every talent / skill saver. */ +/** 'fatal' priority of 埃芒加德: after the operators' own items (PRIO_REVIVE / PRIO_RESPAWN) and every talent / skill saver. */ export const PRIO_BAND_REVIVE = PRIO_REVIVE - 10; const keyOf = (bandId, part = '') => `band:${bandId}${part ? `:${part}` : ''}`; diff --git a/server/sim/content/items/battle.js b/server/sim/content/items/battle.js index efa4b14a..5bfc4463 100644 --- a/server/sim/content/items/battle.js +++ b/server/sim/content/items/battle.js @@ -19,11 +19,14 @@ // core bond is active). Combo partners (bbStr.equip_chess_id / other_equip) are item keys without the _a/_b suffix: // either quality satisfies them; lent items count as carried. Both are evaluated live, at the moment of use. // -// Hook priorities (non-default): 'fatal' — consumable death savers (坚固维式重锤's first lethal hit, M3茧甲 revives) -// run LAST (PRIO_REVIVE −100: "被击倒时" = nothing else prevented the knock-down, so a skill's / talent's own undying -// (kits use 10 … −60) never wastes a charge); 骑士戒律's free in-skill undying runs early (20). Flat damage reduction -// 'hit' −10 (after the other damage modifiers). Proc damage dealt by items carries the tag 'item' and never -// re-triggers item procs. +// Hook priorities (non-default): 'fatal' — consumable death savers run LAST, so a skill's / talent's own undying (kits +// use 10 … −60) never wastes a charge: 坚固维式重锤's first-lethal-hit lock (异常效果 不死) at PRIO_REVIVE −100, then the +// M3茧甲 revive at PRIO_RESPAWN −101 — PRTS 卫戍协议:盟约 下半/PRTS盟约记录 备注 "“复活”的实现方式为:受益者因移动之外的 +// 原因退场时下次部署的再部署时间和费用归零": a revive acts on a knock-out, which a 不死 prevents, so the lock always comes +// first whatever the equip order (player report F1 after 0.1.0: with the 茧甲 equipped first the revive ran first and +// the first lethal hit showed no lock); 埃芒加德's band revive follows (bands/battle.js PRIO_BAND_REVIVE −110). +// 骑士戒律's free in-skill undying runs early (20). Flat damage reduction 'hit' −10 (after the other damage modifiers). +// Proc damage dealt by items carries the tag 'item' and never re-triggers item procs. // // 蒸汽之心 (Victoria carrier) — LIVE model: the hammer types (灼燃/坚固/加速/战栗维式重锤) carried by the owner's operators // that are on the field right now (cached ≤ 0.5 s, dropped whenever an operator deploys or leaves) are granted to the @@ -58,6 +61,8 @@ const procTags = (k) => ['item', `item:${k}`]; const chance = (battle, p) => p > 0 && (p >= 1 || battle.rng() < p); /** 'fatal' priorities (see header). */ export const PRIO_REVIVE = -100; +/** The items' 复活 (M3茧甲): acts on a knock-out, so after every 不死 (PRIO_REVIVE) — see header. */ +export const PRIO_RESPAWN = PRIO_REVIVE - 1; const PRIO_FREE_UNDYING = 20; /** Stat keys of a stat buff → mods (直接乘算, see header). */ @@ -532,7 +537,7 @@ const BY_ITEM = { c.prevented = true; u.hp = u.s.maxHp; fxOn(battle, 'revive', u, 'item:chess_item_4_12_e', rec.id, { left: max - used }); - }, PRIO_REVIVE); + }, PRIO_RESPAWN); // after the hammer's 不死 lock, whatever the equip order (header) }, // 催泪瓦斯: on attack prob ⇒ 1 麻痹 stack chess_item_5_01_e(battle, u, rec, S) { diff --git a/test/content/feedback1f-hammer.test.js b/test/content/feedback1f-hammer.test.js new file mode 100644 index 00000000..79b3454d --- /dev/null +++ b/test/content/feedback1f-hammer.test.js @@ -0,0 +1,138 @@ +// Player report F1 after 0.1.0 (2026-10-03): "维多利亚坚固锤子的锁血没生效" — 坚固维式重锤 (chess_item_3_09_e_a / _b): +// "首次受到致命伤害时生命值不低于1,持续8秒" (activity_table act2autochess eff_acarm043 / eff_acgarm043, blackboard +// undeadable_duration 8; PRTS 卫戍协议:盟约 下半/PRTS盟约记录 备注 "持有不死" — 异常效果 不死 UNDEADABLE "重设常规生命值时不会 +// 使其低于1"). The lock itself works for every kind of lethal damage (pinned here on synthetic and real operators); what +// failed was the order against M3茧甲: with the 茧甲 equipped before the hammer, its revive ran first at the same priority +// and the first lethal hit showed no lock. PRTS (same page, M3茧甲 / 埃芒加德 / 阿戈尔 备注): "“复活”的实现方式为:受益者因移动 +// 之外的原因退场时下次部署的再部署时间和费用归零" — a revive acts on a knock-out (退场), which a 不死 prevents, so the lock always +// comes first (items/battle.js PRIO_RESPAWN). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js'; +import { PRIO_REVIVE, PRIO_RESPAWN } from '../../server/sim/content/items/battle.js'; +import { PRIO_BAND_REVIVE } from '../../server/sim/content/bands/battle.js'; + +const HAMMER = 'chess_item_3_09_e_a'; +const HAMMER_B = 'chess_item_3_09_e_b'; +const M3 = 'chess_item_4_12_e_a'; +const SOLVENT = 'chess_item_1_05_e_a'; + +/** Synthetic operator: 2000 HP, 500 ATK, 200 DEF, 1 s attacks, 20 s redeploy. */ +const op = (id) => chessRec({ + id, profession: 'WARRIOR', bonds: [], tier: 1, rangeGrid: [[0, 0], [0, 1]], + stats: { maxHp: 2000, atk: 500, def: 200, res: 10, aspd: 100, bat: 1, respawnTime: 20, spRecovery: 1, blockCnt: 2 }, + skill: { spCost: 60, duration: 5, initSp: 0 }, +}); + +/** One synthetic carrier at (10, 4) with `items` and a harmless static dummy at (10, 9). */ +function carrier(items) { + const h = makeBattle({ + defs: { chess: { t_op: op('t_op') }, enemies: { e_d: enemyRec({ key: 'e_d', hp: 1e7, speed: 0 }) } }, + units: [{ chessId: 't_op', row: 10, col: 4, items }], + enemies: [{ key: 'e_d', pos: [10, 9] }], timeLimit: 999, autoFinish: false, + }); + h.step(1); + return { h, u: h.unit('t_op'), e: h.b.enemies.find((x) => x.alive) }; +} + +/** fx events of `kind` on `u` (the hammer's lock: 'undying'; M3茧甲: 'revive'). */ +function fxLog(h, u) { + const log = []; + const fx0 = h.b.fx.bind(h.b); + h.b.fx = (kind, p) => { if (p && p.id === u.id) log.push([kind, Math.round(h.b.time * 100) / 100, p.src ?? null]); return fx0(kind, p); }; + return log; +} + +test('F1 坚固维式重锤: every kind of lethal damage is held at ≥ 1 HP for 8 s (hit, burst, 流失, 无来源, own drain)', () => { + const kinds = { + phys: ({ h, u, e }) => h.b.dealDamage(e, u, { amount: 1e7, type: 'phys', canDodge: false }), + arts: ({ h, u, e }) => h.b.dealDamage(e, u, { amount: 1e7, type: 'arts', canDodge: false }), + true: ({ h, u, e }) => h.b.dealDamage(e, u, { amount: 1e7, type: 'true', canDodge: false }), + sourceless: ({ h, u, e }) => h.b.dealDamage(e, u, { amount: 1e7, type: 'true', canDodge: false, sourceless: true }), + // a full 神经 gauge bursts on the carrier: stun, then 1000 无来源 true damage (sim/constants.js ELEMENT) + burst: ({ h, u, e }) => { u.hp = 5; h.b.dealDamage(e, u, { type: 'element', element: 'neural', amount: 5000 }); }, + hpLoss: ({ h, u, e }) => h.b.loseHp(u, 1e7, { source: e }), + hpLossSourceless: ({ h, u, e }) => h.b.loseHp(u, 1e7, { source: e, sourceless: true }), + }; + for (const [name, hit] of Object.entries(kinds)) { + for (const id of [HAMMER, HAMMER_B]) { + const c = carrier([id]); + const log = fxLog(c.h, c.u); + hit(c); + assert.ok(c.u.alive && c.u.hp >= 1, `${name} ${id}: the first lethal ${name} leaves ≥ 1 HP`); + assert.deepEqual(log.filter((x) => x[0] === 'undying').map((x) => x[2]), ['item:hammer'], `${name}: the hammer's lock`); + c.h.run(7.8); + for (let i = 0; i < 3; i++) c.h.b.dealDamage(c.e, c.u, { amount: 1e7, type: 'true', canDodge: false }); + assert.ok(c.u.alive && c.u.hp >= 1, `${name} ${id}: still held inside the 8 s`); + c.h.run(0.4); + c.h.b.dealDamage(c.e, c.u, { amount: 1e7, type: 'true', canDodge: false }); + assert.equal(c.u.alive, false, `${name} ${id}: knocked out after the 8 s`); + } + } +}); + +test('F1 坚固维式重锤: the carrier\'s own 源石溶剂 drain cannot finish it inside the lock', () => { + const c = carrier([HAMMER, SOLVENT]); + c.u.hp = 1; + c.h.run(1.05); // the drain's first tick (60 per second) is lethal at 1 HP + assert.ok(c.u.alive && c.u.hp >= 1, 'held by the lock'); + c.h.run(6.5); + assert.ok(c.u.alive, 'every drain tick inside the 8 s is held'); + c.h.run(2.5); + assert.equal(c.u.alive, false, 'the first tick after the 8 s knocks it out'); +}); + +test('F1 坚固维式重锤: one lock per battle (a redeploy does not re-arm it [ASSUMED]); a new battle re-arms it', () => { + const c = carrier([HAMMER]); + const hit = () => c.h.b.dealDamage(c.e, c.u, { amount: 1e7, type: 'true', canDodge: false }); + hit(); + assert.ok(c.u.alive); + c.h.run(8.2); + hit(); + assert.equal(c.u.alive, false, 'knocked out after the window'); + c.h.runUntil(() => c.u.alive, 60); + assert.ok(c.u.alive, 'redeployed'); + hit(); + assert.equal(c.u.alive, false, 'the second deployment of the battle has no lock left'); + const next = carrier([HAMMER]); + next.h.b.dealDamage(next.e, next.u, { amount: 1e7, type: 'true', canDodge: false }); + assert.ok(next.u.alive && next.u.hp >= 1, 'the next battle locks again'); +}); + +test('F1 坚固维式重锤 + M3茧甲: the lock comes before the revive whatever the equip order (PRTS: a revive acts on 退场)', () => { + assert.ok(PRIO_RESPAWN < PRIO_REVIVE && PRIO_RESPAWN > PRIO_BAND_REVIVE, 'revive items: after every 不死, before 埃芒加德'); + for (const items of [[M3, HAMMER], [HAMMER, M3]]) { + const c = carrier(items); + const log = fxLog(c.h, c.u); + const hit = () => c.h.b.dealDamage(c.e, c.u, { amount: 1e7, type: 'true', canDodge: false }); + hit(); + assert.ok(c.u.alive && c.u.hp < 2, `${items}: the first lethal hit is held at 1 HP (lock), not revived to full`); + assert.deepEqual(log.map((x) => x[0]), ['undying'], `${items}: lock first`); + c.h.run(8.2); + hit(); + assert.ok(c.u.alive, `${items}: after the 8 s the 茧甲 revives`); + assert.equal(Math.round(c.u.hp), 2000, 'full HP'); + assert.deepEqual(log.map((x) => x[0]), ['undying', 'revive']); + hit(); + assert.equal(c.u.alive, false, `${items}: nothing left`); + } +}); + +test('F1 坚固维式重锤 on a real operator in a real fight: 角峰 blocking scaled-up real enemies stands 8 s at 1 HP', () => { + for (const id of [HAMMER, HAMMER_B]) { + const h = makeBattle({ + stageId: 'flat', seed: 7, + units: [{ chessId: 'chess_char_1_02_a', row: 9, col: 5, items: [id] }], + enemies: Array.from({ length: 6 }, (_, i) => ({ key: 'enemy_1422_lrsldr', time: i * 0.5, route: 0, mods: { atkMul: 30, hpMul: 50 } })), + timeLimit: 120, autoFinish: false, + }); + const u = h.unit('chess_char_1_02_a'); + const log = fxLog(h, u); + let out = null; + h.b.on('death', (c) => { if (c.unit === u && out == null) out = h.b.time; }); + h.runUntil(() => out != null, 60); + const lock = log.find((x) => x[0] === 'undying'); + assert.ok(lock && lock[2] === 'item:hammer', `${id}: the hammer locked (${JSON.stringify(log)})`); + assert.ok(out != null && out >= lock[1] + 8 - 1e-6, `${id}: knocked out ${out} ≥ 8 s after the lock at ${lock[1]}`); + } +});