Files
ark-stronghold-protocol/test/content/feedback1f-hammer.test.js
T
sganggs a3a0ddd2be feedback5 CB1: a 复活 (埃芒加德's 命结之秘, M3茧甲) is a knock-out and an immediate free redeploy — 砾's and 瑕光's 被击倒时 / 部署时 layers fire on every revive (community report 「卡西米尔和艾芒加德策略似乎有BUG:像砾和瑕光这种死亡和部署的叠层效果…无法触发」, items 9 / 37)
PRTS 卫戍协议:盟约 下半/PRTS盟约记录, 埃芒加德 and M3茧甲 备注: "“复活”的实现方式为:受益者因移动之外的原因退场时下次部署的
再部署时间和费用归零". Both revived in place as `fatal` savers, so the operator was never knocked out nor deployed again:
砾 (<部署时> 卡西米尔 +1, <被击倒时> 不屈 +2), 瑕光 (<部署时> +4, 12 per battle), the 卡西米尔 bond's per-deployment ATK,
克莱门莎 / 卡莱莎 and every other 被击倒时 / 部署时 effect skipped the first 3 knock-outs (measured: only the 4th, real
death stacked). Now items/battle.js reviveNow, a `death` hook (M3茧甲 PRIO_RESPAWN 13, 埃芒加德 PRIO_BAND_REVIVE 12,
ahead of 阿戈尔 5 at 11 and 不屈 at 10): the knock-out stands and the operator redeploys at once, free, where it lies
(full HP, SP reset, a new deployment for 坚固维式重锤's lock — revivedInPlace and the `revives` counter are gone). The
ctx carries `revivedBy`, so 阿戈尔 5 still does not count such a knock-out as the member's first (the owner's decision
of 2026-10-05: an operator's own revive uses no slot; 埃芒加德 [ASSUMED] alike, unchanged). 淬羽赫默 S3 keeps its place
right after the hammer's lock (PRIO_REVIVE − 0.5). Tests: test/content/feedback5-revive-redeploy.test.js (fails on the
parent); bands / bonds_core / feedback1f-hammer / op_lessng / op_zuole / docs-consistency updated to the redeploy. Docs:
SIM.md (fatal, death), design/engine.md (fatal, death), PLAYING.md (复活), research 04 (M3茧甲).

Golden: the battles with M3茧甲 or 埃芒加德 whose operators fell — a revive is now a knock-out (deaths count, kill events,
redeploy, SP reset, 被击倒时 / 部署时 effects): roster-012 / 026 / 033 / 040 / 047, boss-boss_6-pair, hidden-boss_8-pair /
boss_9-pair, standin-07; matches solo-NORMAL-1, solo-HARD-1, solo-ABYSS-1.
2026-10-07 01:56:43 +08:00

290 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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). One
// ordering fault is fixed: with M3茧甲 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). The scope is one lock per DEPLOYMENT (the user's decision, 2026-10-03: "每次部署一次"):
// until then a redeployed carrier had none, the likeliest reading of the report. The lock belongs to the deployment,
// not to the grant (QA after the integration: a hammer lent by 萨尔贡 × 娜仁图亚 came back spent, and its window ended
// with the lend), and an in-place 复活 (M3茧甲, 埃芒加德) is a new deployment — PRTS's 复活 is a 0-time / 0-cost
// redeploy [ASSUMED]. Pinned too: the 阿戈尔 battle-start devour spends the lock of the first deployment.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
import { PRIO_REVIVE, PRIO_RESPAWN, holdsUndying, lendItemEffects, itemGrants } 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');
});
// Scope: once per deployment (the user's first-hand memory of the official mode, 2026-10-03: "每次部署一次"; the text
// says only 首次). Before, a carrier knocked out after its lock came back without one — the likeliest reading of
// "锁血没生效" (forced-hammer bot matches, 3 绝境 + 4 困难: 27 of 382 carrier knock-outs came in a later deployment with
// the lock spent).
test('F1 坚固维式重锤: one lock per deployment — the redeploy after a knock-out re-arms it; a new battle too', () => {
const c = carrier([HAMMER]);
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.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.ok(c.u.alive && c.u.hp >= 1 && c.u.hp < 2, 'the second deployment locks again');
assert.equal(log.filter((x) => x[0] === 'undying').length, 2, 'two locks, one per deployment');
c.h.run(7.8);
hit();
assert.ok(c.u.alive, 'held for the whole 8 s of the second lock');
c.h.run(0.4);
hit();
assert.equal(c.u.alive, false, 'and only once in that deployment');
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 坚固维式重锤: a retreat + redeploy (a 突袭 jump) is a new deployment — fresh lock, the old window ends', () => {
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 && holdsUndying(c.h.b, c.u), 'lock running');
c.h.run(2);
c.h.b.retreat(c.u, { reason: 'raid' });
assert.ok(c.h.b.redeploy(c.u, { free: true, tile: [10, 5], keepSp: true }), 'redeployed one tile on');
assert.ok(!holdsUndying(c.h.b, c.u), 'the first deployment\'s window ended with it');
c.h.run(1);
hit();
assert.ok(c.u.alive && c.u.hp >= 1 && c.u.hp < 2, 'the new deployment has its own lock');
c.h.run(8.2);
hit();
assert.equal(c.u.alive, false, 'used up for this deployment');
});
// The other way a lock is gone before the enemies hit: the 阿戈尔 battle-start devour ("吞噬身前一格干员对其造成5000点物理
// 伤害") is a lethal hit on the fodder, so a hammer carrier in front of an 阿戈尔 survives it at 1 HP and spends the lock
// of its first deployment at t = 0 (in forced-hammer 绝境 bot matches 10 of 179 locks went this way). Kept: the text calls
// it damage and 异常效果 不死 holds any HP reset at 1 [ASSUMED that the official engine does the same].
test('F1 坚固维式重锤 in front of an 阿戈尔: the battle-start devour spends the lock (fodder kept at 1 HP for 8 s)', () => {
const g = (id) => chessRec({ id, bonds: ['egirShip'], profession: 'WARRIOR', skill: null, stats: { atk: 1000, maxHp: 10000, def: 0, blockCnt: 2 } });
const h = makeBattle({
defs: { chess: { g1_a: g('g1_a'), g2_a: g('g2_a'), g3_a: g('g3_a'), t_op: op('t_op') }, enemies: { e_d: enemyRec({ key: 'e_d', hp: 1e7, speed: 0 }) } },
units: [{ chessId: 'g1_a', row: 10, col: 3 }, { chessId: 't_op', row: 10, col: 4, items: [HAMMER] }, { chessId: 'g2_a', row: 12, col: 3 }, { chessId: 'g3_a', row: 12, col: 5 }],
bonds: { egirShip: { count: 3, active: true, tier: 1, layers: 0 } },
enemies: [{ key: 'e_d', pos: [9, 9] }], timeLimit: 999, autoFinish: false,
});
const u = h.unit('t_op');
const log = fxLog(h, u);
h.step(1);
assert.ok(u.alive && u.hp >= 1 && u.hp < 2, 'devoured (5000 > 2000 HP) but held at 1 HP');
assert.deepEqual(log.filter((x) => x[0] === 'undying').map((x) => [x[1], x[2]]), [[0, 'item:hammer']], 'the lock fired at t = 0');
const e = h.b.enemies.find((x) => x.alive);
h.run(8.2);
h.b.dealDamage(e, u, { amount: 1e7, type: 'true', canDodge: false });
assert.equal(u.alive, false, 'the first enemy kill after the window finds no lock left in that deployment');
});
test('F1 坚固维式重锤 + M3茧甲: the lock comes before the revive whatever the equip order (PRTS: a revive acts on 退场)', () => {
// revive items: a `death` hook (after every 不死 — a fatal saver), before 埃芒加德's
assert.ok(Number.isFinite(PRIO_REVIVE) && PRIO_RESPAWN > PRIO_BAND_REVIVE, 'revive items 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']);
// the revive stands for PRTS's 0-time / 0-cost redeploy: a new deployment, so the lock is armed again
hit();
assert.ok(c.u.alive && c.u.hp < 2, `${items}: the revived carrier locks again`);
assert.deepEqual(log.map((x) => x[0]), ['undying', 'revive', 'undying']);
c.h.run(8.2);
hit();
assert.equal(c.u.alive, false, `${items}: nothing left (one revive, one lock in that deployment)`);
}
});
// The 埃芒加德 strategy's revive (命结之秘, the first 3 knock-downs of the battle revive at once) stands in place for the same
// redeploy as M3茧甲's: the lock is armed again after it.
test('F1 坚固维式重锤 + 埃芒加德: the band revive re-arms the lock, once per revive', () => {
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: [HAMMER] }], bandId: 'band_ermengard',
enemies: [{ key: 'e_d', pos: [10, 9] }], timeLimit: 999, autoFinish: false,
});
h.step(1);
const u = h.unit('t_op');
const e = h.b.enemies.find((x) => x.alive);
const log = fxLog(h, u);
const hit = () => h.b.dealDamage(e, u, { amount: 1e7, type: 'true', canDodge: false });
hit();
h.run(8.2);
hit();
assert.ok(u.alive && Math.round(u.hp) === 2000, 'the band revives the carrier after its first lock');
hit();
assert.ok(u.alive && u.hp < 2, 'locked again after the revive');
h.run(1);
hit();
assert.ok(u.alive && u.hp < 2, 'inside the new window');
assert.deepEqual(log.map((x) => [x[0], x[2]]), [['undying', 'item:hammer'], ['revive', 'band:band_ermengard'], ['undying', 'item:hammer']]);
});
// 萨尔贡 × 娜仁图亚 lends a carrier's items to its neighbours for 60 s (items/battle.js lendItemEffects). The lock belongs to
// the borrower's deployment, not to the lend: a borrower knocked out while lent, whose lend ran out while it was down,
// gets a fresh lock when it is lent the hammer again in its next deployment (QA: it came back spent — the per-grant
// hook that re-armed it was gone with the lend); and a window started through the lend lasts its 8 s after the lend
// ends [ASSUMED: the 异常效果 不死 outlasts its source] (QA: it ended with the lend).
/** A lender carrying the hammer at (10, 4), a borrower (no items) at (11, 4) and a static dummy. */
function lendPair() {
const h = makeBattle({
defs: { chess: { t_op: op('t_op'), t_bor: op('t_bor') }, enemies: { e_d: enemyRec({ key: 'e_d', hp: 1e7, speed: 0 }) } },
units: [{ chessId: 't_op', row: 10, col: 4, items: [HAMMER] }, { chessId: 't_bor', row: 11, col: 4 }],
enemies: [{ key: 'e_d', pos: [10, 9] }], timeLimit: 999, autoFinish: false,
});
h.step(1);
return { h, lender: h.unit('t_op'), u: h.unit('t_bor'), e: h.b.enemies.find((x) => x.alive) };
}
const lent = (h, u) => itemGrants(h.b, u).some((g) => g.lent && g.key === 'chess_item_3_09_e');
test('F1 坚固维式重锤 lent by 萨尔贡 × 娜仁图亚: a fresh lock in the next deployment, though the lend ran out while it was down', () => {
{
const { h, lender, u, e } = lendPair();
const log = fxLog(h, u);
const hit = () => h.b.dealDamage(e, u, { amount: 1e7, type: 'true', canDodge: false });
assert.equal(lendItemEffects(h.b, lender, u, { duration: 12 }), 1, 'the hammer is lent');
hit();
assert.ok(u.alive && holdsUndying(h.b, u), 'the lent hammer locks');
h.run(8.2);
hit();
assert.equal(u.alive, false, 'knocked out after the window');
h.run(5);
assert.ok(!lent(h, u), 'the lend ran out while it was down');
h.runUntil(() => u.alive, 60);
assert.ok(u.alive, 'redeployed');
hit();
assert.equal(u.alive, false, 'no hammer, no lock');
h.runUntil(() => u.alive, 60);
assert.equal(lendItemEffects(h.b, lender, u, { duration: 12 }), 1, 'lent again in this deployment');
hit();
assert.ok(u.alive && u.hp < 2, 'a new deployment with the hammer: a fresh lock');
h.run(8.2);
lendItemEffects(h.b, lender, u, { duration: 12 });
hit();
assert.equal(u.alive, false, 'a lend refreshed in the same deployment does not re-arm it');
assert.equal(log.filter((x) => x[0] === 'undying').length, 2);
}
});
test('F1 坚固维式重锤 lent by 萨尔贡 × 娜仁图亚: a window started through the lend lasts its 8 s after the lend ends', () => {
{
const { h, lender, u, e } = lendPair();
const hit = () => h.b.dealDamage(e, u, { amount: 1e7, type: 'true', canDodge: false });
lendItemEffects(h.b, lender, u, { duration: 3 });
h.run(2);
hit();
assert.ok(u.alive && u.hp < 2, 'locked 1 s before the lend ends');
h.run(3);
assert.ok(!lent(h, u), 'the lend is over');
hit();
assert.ok(u.alive && holdsUndying(h.b, u), 'still held: the window runs its 8 s');
h.run(5.2);
hit();
assert.equal(u.alive, false, 'knocked out once the 8 s are over');
}
});
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]}`);
}
});