Files
ark-stronghold-protocol/test/sim/feedback1-residuals.test.js
sganggs d6b1d1bd26 feedback1 integrate: 坚固维式重锤's lock belongs to the deployment - a lent hammer and in-place revives follow it (QA)
The lock and its re-arm hook lived in the carrier's hammer grants, so a hammer lent by 萨尔贡 × 娜仁图亚 came back spent
in the borrower's next deployment once the lend had run out while it was down, and a window started through the lend
ended with it. deploymentOf marks the deployment whose lock is spent; one battle-level hook holds every running window
(holdsUndying, also what 信仰搅拌机 S2 steps aside for), so a window outlasts the lend. M3茧甲 and 埃芒加德 revive in place
for PRTS's 0-time / 0-cost redeploy and now open a new deployment (revivedInPlace), like 阿戈尔 5's real redeploy.
2026-10-03 17:47:07 +08:00

126 lines
6.9 KiB
JavaScript

// Residual fixes after the independent QA of `feedback1` (player feedback after 0.1.0, DESIGN §21.19), real data in the
// real sim:
// - 余 S2 厚礼上宾 never teleports a 自缚 unit: PRTS 余 S2 备注 "处于消失状态的/持有自缚的单位不视为可达目标" — 守墓石像's
// 转换模式 is 自缚 (its PRTS 天赋), so the statue stays where it stands (no push or pull moves it either, §21.9).
// - 信仰搅拌机 S2 八臂电锯侠: PRTS 备注 "弹药量不足时仍可抵挡致命伤害,此时将消耗所有剩余弹药并退出技能状态" and
// "自身持有不死时此效果不会生效" (it used to let the lethal hit through with fewer than 30 bullets [ASSUMED]).
// The 重生 + same-hit status case (妮芙 S2's fear on the hit that makes an ember) is in feedback1-transform.test.js.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { getData } from '../../server/data.js';
import { holdsUndying } from '../../server/sim/content/items/battle.js';
const DATA = getData({ log: { warn() {}, error() {}, info() {} } });
test('余 S2: a 守墓石像 in its statue form (自缚) stays put; in its ground form it is teleported onto 余\'s tile', () => {
for (const statue of [true, false]) {
const h = makeBattle({
stageId: 'flat', autoFinish: false, timeLimit: 120,
units: [{ chessId: 'chess_char_6_03_a', row: 10, col: 5, dir: 'RIGHT', skillIndex: 1 }],
enemies: [
{ key: 'enemy_1172_dugago', time: 0, pos: [11, 6], route: { motion: 'WALK', start: [11, 6], end: [11, 6], steps: [{ t: 'wait', s: 99999 }] } },
// a walker 余 blocks: S2 (her default trigger) fires once she is blocking
{ key: 'enemy_1000_gopro', time: 2, route: { motion: 'WALK', start: [10, 9], end: [10, 2], checkpoints: [] } },
],
});
h.step(1);
const st = h.b.enemies.find((e) => e.defId === 'enemy_1172_dugago');
const yu = h.unit('chess_char_6_03_a');
if (statue) {
h.b.dealDamage(yu, st, { amount: 1e7, type: 'true', tags: ['test'] });
assert.ok(st.alive && st.findBuff('ab:stone'), 'knocked out once: the statue form');
assert.ok(st.s.flags.selfBound && st.s.flags.noMove, '自缚');
} else assert.ok(!st.s.flags.selfBound, 'the ground form is not 自缚');
h.step(1);
let cast = null;
h.b.on('skillStart', (c) => { if (c.unit === yu && cast == null) cast = h.b.time; });
h.b.on('tick', () => { if (cast == null && yu.skill && !yu.skill.active) yu.skill.sp = yu.skill.spCost; st.hp = st.s.maxHp; });
const x0 = st.x, y0 = st.y;
assert.ok(h.runUntil(() => cast != null, 12), 'S2 fires');
h.step(3);
const moved = Math.hypot(st.x - x0, st.y - y0);
if (statue) assert.ok(moved < 1e-9, `the statue stays (moved ${moved.toFixed(2)})`);
else assert.ok(Math.abs(st.x - yu.tileC) < 1e-9 && Math.abs(st.y - yu.tileR) < 1e-9, `teleported onto 余 (at ${st.x}, ${st.y})`);
assert.equal(h.b.errorCount, 0);
checkInvariants(h.b);
}
});
test('信仰搅拌机 S2: a lethal hit with fewer than 30 bullets left is still blocked — every bullet goes and the skill ends; with 30 or more it costs 30', () => {
const id = 'chess_char_4_01_a';
const cost = DATA.chess[id].skills[1].bb.ammo_cost;
assert.equal(cost, 30, 'the data cost');
for (const [left, after] of [[12, 0], [40, 40 - cost], [cost, 0]]) {
const h = makeBattle({
defs: { enemies: { e_hit: enemyRec({ key: 'e_hit', hp: 1e8, atk: 0, speed: 0 }) } },
units: [{ chessId: id, row: 9, col: 5, skillIndex: 1 }], enemies: [{ key: 'e_hit', pos: [9, 8] }],
timeLimit: 60, autoFinish: false,
});
h.step(2);
const u = h.unit(id);
const foe = h.b.enemies[0];
assert.equal(u.skill.id, 'skchr_rmixer_2');
assert.ok(u.skill.activate('test', { free: true }), 'S2 on');
u.skill.ammoLeft = left;
const hp = u.hp;
h.b.dealDamage(foe, u, { amount: u.s.maxHp * 10, type: 'true', tags: ['test'] });
assert.ok(u.alive, `${left} bullets: the lethal hit is blocked`);
assert.ok(Math.abs(u.hp - hp) < 1e-6, 'HP back to its value before the hit');
assert.equal(u.skill.ammoLeft, after, `${left} bullets → ${after}`);
assert.equal(u.skill.active, after > 0, after > 0 ? 'the skill goes on' : 'out of bullets: the skill ends');
assert.equal(h.b.errorCount, 0);
}
});
test('信仰搅拌机 S2 steps aside while she holds 不死 (坚固维式重锤\'s window): the hammer takes the lethal hit, no bullet spent', () => {
const id = 'chess_char_4_01_a';
const h = makeBattle({
defs: { enemies: { e_hit: enemyRec({ key: 'e_hit', hp: 1e8, atk: 0, speed: 0 }) } },
units: [{ chessId: id, row: 9, col: 5, skillIndex: 1, items: ['chess_item_3_09_e_a'] }], enemies: [{ key: 'e_hit', pos: [9, 8] }],
timeLimit: 60, autoFinish: false,
});
h.step(2);
const u = h.unit(id);
const foe = h.b.enemies[0];
const lethal = () => h.b.dealDamage(foe, u, { amount: u.s.maxHp * 10, type: 'true', tags: ['test'] });
lethal(); // before the skill: the hammer's lock starts (8 s of 不死)
assert.ok(u.alive && holdsUndying(h.b, u), 'the hammer holds her');
assert.ok(u.skill.activate('test', { free: true }), 'S2 on');
const ammo = u.skill.ammoLeft;
lethal();
assert.ok(u.alive, 'still standing (不死)');
assert.equal(u.skill.ammoLeft, ammo, 'no bullet spent while she holds 不死');
h.run(8.2); // the window is over: S2 guards again
assert.ok(u.skill.active);
lethal();
assert.ok(u.alive, 'S2 blocks it');
assert.equal(u.skill.ammoLeft, ammo - 30);
assert.equal(h.b.errorCount, 0);
});
test('信仰搅拌机 S2 guards again in a new deployment although the old 不死 window has not run out (holdsUndying is per deployment)', () => {
const id = 'chess_char_4_01_a';
const h = makeBattle({
defs: { enemies: { e_hit: enemyRec({ key: 'e_hit', hp: 1e8, atk: 0, speed: 0 }) } },
units: [{ chessId: id, row: 9, col: 5, skillIndex: 1, items: ['chess_item_3_09_e_a'] }], enemies: [{ key: 'e_hit', pos: [9, 8] }],
timeLimit: 60, autoFinish: false,
});
h.step(2);
const u = h.unit(id);
const foe = h.b.enemies[0];
const lethal = () => h.b.dealDamage(foe, u, { amount: u.s.maxHp * 10, type: 'true', tags: ['test'] });
lethal();
assert.ok(holdsUndying(h.b, u), 'the hammer\'s window runs');
h.run(1);
h.b.retreat(u, { reason: 'raid' }); // a 突袭-style retreat + redeploy 3 s into the window
assert.ok(h.b.redeploy(u, { free: true, tile: [9, 6] }), 'redeployed');
assert.ok(!holdsUndying(h.b, u), 'the window ended with its deployment');
assert.ok(u.skill.activate('test', { free: true }), 'S2 on');
const ammo = u.skill.ammoLeft;
lethal();
assert.ok(u.alive, 'S2 blocks it');
assert.equal(u.skill.ammoLeft, ammo - 30, 'she holds no 不死 in this deployment: S2 spends its bullets');
assert.equal(h.b.errorCount, 0);
});