From bcf0aeac91651feda614bfa1e7b238c81fab46c9 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 07:18:50 +0800 Subject: [PATCH] =?UTF-8?q?feedback1f=20WS:=20=E5=9D=9A=E5=9B=BA=E7=BB=B4?= =?UTF-8?q?=E5=BC=8F=E9=87=8D=E9=94=A4's=20lock=20always=20comes=20before?= =?UTF-8?q?=20the=20M3=E8=8C=A7=E7=94=B2=20revive=20(PRIO=5FRESPAWN)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Player report F1 after 0.1.0 ("坚固锤子的锁血没生效"). The lock itself works for every kind of lethal damage (hits, element bursts, 无来源 damage, 流失, the carrier's own drain), measured on real operators, real bot matches and the browser's local battle. With M3茧甲 equipped before the hammer, the revive (same 'fatal' priority, registered first) took the first lethal hit instead. PRTS: a 复活 acts on a knock-out, which the hammer's 不死 prevents, so the revive items now run at PRIO_RESPAWN (-101), after every 不死 and before 埃芒加德 (-110). --- docs/SIM.md | 2 +- server/sim/content/bands/battle.js | 2 +- server/sim/content/items/battle.js | 17 +-- test/content/feedback1f-hammer.test.js | 138 +++++++++++++++++++++++++ 4 files changed, 151 insertions(+), 8 deletions(-) create mode 100644 test/content/feedback1f-hammer.test.js 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]}`); + } +});