feedback1 integrate: no status on a 无敌 + 无法选中 unit; 余 S2 skips 自缚 units; 信仰搅拌机 S2 blocks with too few bullets

- Battle.applyStatus: a unit both invulnerable and untargetable takes no status from the
  other side (PRTS 无敌 "无法被不同阵营选中"), so the status carried by the hit that knocks an
  enemy out (妮芙 S2's fear, a 叙拉古 proc) no longer lands after its 重生 cleanse.
- Flag selfBound (自缚: 守墓石像's 转换模式, the 自缚 leaders, the 胄 parts' anchor): 余 S2
  never teleports such a unit (PRTS 余 S2 备注 "持有自缚的单位不视为可达目标").
- 信仰搅拌机 S2: with fewer than ammo_cost bullets the lethal hit is still blocked, every
  bullet goes and the skill ends; it steps aside while 坚固维式重锤's 不死 window holds her
  (PRTS 备注), replacing the [ASSUMED] no-guard rule.
This commit is contained in:
sganggs committed 2026-10-03 11:52:52 +08:00
1 parent f142012033
commit 94a99a3b1a
11 files changed
+153 -13

No files matched your search

+6 -2
View File
@@ -263,7 +263,8 @@ operators put on the enemy — the buffs with an ally source and source-less cat
("重生结束时,重置自身的通用技能与当前形态的技能冷却为初始冷却"; `rebirthCooldowns`, since 0.1.1) — and keeps [ASSUMED: the
whitelist] its `persist` talents, what it or
another enemy gave it (锏's self-applied 抵抗, enemy auras) and source-less field state (terrain, airflow, element burst
locks): a 逐火 knocked out while feared is no unblockable ember. 重生 / form changes: `reborn()` (first knock-out → second
locks): a 逐火 knocked out while feared is no unblockable ember — nor one feared by the knocking-out hit itself (the
重生's 无敌 + 无法选中 refuses it, `Battle.applyStatus`). 重生 / form changes: `reborn()` (first knock-out → second
form; fx forms 'reborn' → 'form2'), `statue()` (守墓石像: melee only while blocked; first knock-out → 10 s unblockable,
immobile statue → a flyer with ranged arts attacks that skip flyers; forms 'stone' → 'fly'), `husk()` (talent
Revive[Trigger], every knock-out: 1 s 重生 — 无敌, 无法阻挡, immobile — then a hit-count husk that
@@ -448,7 +449,10 @@ attribute such as 安洁莉娜's 兼职工作 still applies, PRTS 异常效果),
as +1 taunt level (DESIGN §5.3).
**Statuses** — `battle.applyStatus(target, key, { duration, source, value, force, refresh, point })` (returns true if
applied); honours enemy/op immunities (`stun`, `silence`, `sleep`, `frozen`, `levitate`, `feared`) unless `force`; fires
applied); a unit that is 无敌 and 无法选中 at once (flags `invulnerable` + `untargetable`: a 重生 in progress, a hovering or
永久无敌 leader part) takes no status from the other side, `force` included (PRTS 无敌 "无法被不同阵营选中" — so the status
a knocking-out hit carries, 妮芙 S2's fear say, does not land after the 重生's cleanse; since 0.1.1's QA); honours
enemy/op immunities (`stun`, `silence`, `sleep`, `frozen`, `levitate`, `feared`) unless `force`; fires
`beforeStatus` (cancellable; handlers may also change `ctx.duration` / `ctx.value`), then applies 抵抗 (`resist`, below)
and the 浮空 weight rule, then `statusApplied { source, target, status, duration (final), value, entered }` — `entered`
= the target carried no buff of that status before (a refresh / a weaker "取最高" application is not an entry: "进入…时"). Effects follow the official term table
+5
View File
@@ -1280,9 +1280,14 @@ export class Battle {
* was applied and from where (fear.js stampFear: the fan of 恐惧可达地块 its movement uses). A stunned/sleeping operator
* releases the enemies it blocks; a feared/levitated/unblockable/attracted enemy is released by its blocker.
* `statusApplied` reports the final duration and `entered` (the target did not carry the status before).
* A unit that is 无敌 and 无法选中 at once (a 重生 in progress, a hovering or 永久无敌 leader part) takes no status from
* the other side, `force` included — PRTS 无敌 "无法被不同阵营选中": so a status carried by the very hit that knocked an
* enemy out does not land after its 重生's cleanse (DESIGN §21.4).
*/
applyStatus(target, key, opts = {}) {
if (!target || !target.alive) return false;
const src = opts.source;
if (src && src.side && src.side !== target.side && target.s.flags.invulnerable && target.s.flags.untargetable) return false;
const tpl = STATUS[key] || { flags: { [key]: true } };
let duration = opts.duration == null ? Infinity : Number(opts.duration);
let value = opts.value;
+3
View File
@@ -33,6 +33,9 @@ export const FLAG_KEYS = Object.freeze([
'stun', 'freeze', 'sleep', 'silence', 'disarm', 'stealth', 'invulnerable', 'unblockable', 'levitate', 'fear',
'cold', 'reveal', 'bind', 'noHeal', 'untargetable', 'blockFly', 'noMove', 'noSp', 'burstLock', 'hidden',
'noBlock', 'tremble', 'hitCount', 'hitCountArts', 'attract', 'float', 'noDisplace', 'isolated', 'camou',
// 自缚 (the unit's own immobility: 守墓石像's 转换模式, the 自缚 leaders) beside its `noMove` — 束缚 sets noMove too, and
// only 自缚 makes a unit "不视为可达目标" for 余 S2's teleport (PRTS 余 S2 备注)
'selfBound',
]);
/**
+2 -2
View File
@@ -240,7 +240,7 @@ export function registerMeta() {}
function onSpawn(b, e) {
const kit = BOSS_KITS[e.defId];
if (typeof kit !== 'function') return;
if (SELF_BOUND.includes(e.defId)) b.addBuff(e, { key: 'boss:selfBound', persist: true, flags: { noMove: true, unblockable: true } });
if (SELF_BOUND.includes(e.defId)) b.addBuff(e, { key: 'boss:selfBound', persist: true, flags: { noMove: true, selfBound: true, unblockable: true } });
const tpl = templateOf(b);
const ab = abOf(b, e);
let list = [];
@@ -484,7 +484,7 @@ function kitBlade(ab, e, b, tpl) {
spawn(b2, e2) {
hover(b2, true);
// 自缚 (moved by hand) · 不可阻挡 · 失衡免疫 (PRTS 天赋 "{{特殊机制|静态刚体}},不可阻挡、失衡免疫…" — data `staticBody` too)
b2.addBuff(e2, { key: 'boss:anchor', persist: true, flags: { noMove: true, unblockable: true, noDisplace: true } });
b2.addBuff(e2, { key: 'boss:anchor', persist: true, flags: { noMove: true, selfBound: true, unblockable: true, noDisplace: true } });
if (BLADE_ATK_SCALE[e2.defId]) e2.profile.atkScale = BLADE_ATK_SCALE[e2.defId];
},
before(c, b2, e2) { const l = targetsNear(b2, e2, e2.base.rangeRadius || 1.6, { ranged: false }); if (l.length) c.targets = l; }, // 范围物理伤害
+1 -1
View File
@@ -944,7 +944,7 @@ function statue(ab) {
ab2.immune = new Set([...(ab2.immune || []), 'levitate']);
setForm(b, e, 'stone', 'stone', { dur });
b.addBuff(e, {
key: 'ab:stone', duration: dur, visible: true, flags: { noMove: true, unblockable: true, noDisplace: true },
key: 'ab:stone', duration: dur, visible: true, flags: { noMove: true, selfBound: true, unblockable: true, noDisplace: true },
mods: { defFlat: T(ab, 'stone.def') ?? 0, resFlat: T(ab, 'stone.magic_resistance') ?? 0 },
onExpire: ({ battle }) => {
if (!e.alive) return;
+1
View File
@@ -279,6 +279,7 @@ function hammerAcquire(battle, rt, u) {
hs.undyingUsed = true;
const dur = num(p.undeadable_duration, 8) * m;
hs.undyingUntil = battle.time + dur;
u.mem.undyingUntil = hs.undyingUntil; // the carrier holds 不死 (信仰搅拌机 S2 steps aside: kits/tier4.js)
c.prevented = true;
fxOn(battle, 'undying', u, 'item:hammer', 'chess_item_3_09_e', { duration: dur });
}, PRIO_REVIVE);
+9 -4
View File
@@ -281,17 +281,22 @@ const kits = {
install(battle, unit) {
if (S2) {
// S2 "技能期间若受到致命伤害,立即消耗N发弹药抵挡这次伤害": the lethal hit is negated (HP back to its value
// before the hit) while at least N bullets are left ([ASSUMED]: fewer bullets ⇒ no guard); 0 left ends the skill
// before the hit) and N bullets go; with fewer left it still blocks, spends them all and the skill ends (PRTS 备注
// "弹药量不足时仍可抵挡致命伤害,此时将消耗所有剩余弹药并退出技能状态"). "自身持有不死时此效果不会生效": a 不死
// that runs before it (骑士戒律, priority 20) has already prevented the hit; 坚固维式重锤's held window
// (unit.mem.undyingUntil, items/battle.js) runs last, so the guard steps aside while it lasts
const cost = Math.max(1, Math.floor(num(bb.ammo_cost, 30)));
battle.on('hit', (c) => { if (c.target === unit) unit.mem.rmixerPre = { dmg: c.dmg, hp: unit.hp }; }, { owner: unit, priority: -100 });
battle.on('fatal', (c) => {
const sk = unit.skill;
if (c.unit !== unit || c.prevented || !sk || !sk.active || sk.kind !== 'ammo' || sk.ammoLeft < cost) return;
if (c.unit !== unit || c.prevented || !sk || !sk.active || sk.kind !== 'ammo' || !(sk.ammoLeft > 0)) return;
if (battle.time < (unit.mem.undyingUntil ?? -Infinity)) return;
c.prevented = true;
const pre = unit.mem.rmixerPre;
unit.hp = pre && pre.dmg === c.dmg ? Math.max(1, Math.min(unit.s.maxHp, pre.hp)) : 1;
sk.ammoLeft -= cost;
battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id, n: cost });
const spent = Math.min(cost, sk.ammoLeft);
sk.ammoLeft -= spent;
battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id, n: spent });
if (sk.ammoLeft <= 0) sk.end('ammo');
}, { owner: unit });
}
+4 -2
View File
@@ -199,10 +199,12 @@ function cleanseAbnormal(battle, u) {
}
/**
* "传送至自身位置": a ground enemy that can reach `unit`'s tile on the ground grid is moved onto it (unblocked, its
* route re-planned from there — the engine then blocks it on that tile when capacity allows). Flyers / bosses stay.
* route re-planned from there — the engine then blocks it on that tile when capacity allows). Flyers / bosses stay, and
* so does a 自缚 unit (flag `selfBound`, e.g. 守墓石像's 转换模式): PRTS 余 S2 备注 "处于消失状态的/持有自缚的单位不视为可达
* 目标" (束缚 alone does not exempt it, so not `noMove`).
*/
function teleportEnemy(battle, unit, e) {
if (!e || !e.alive || e.isFlying || e.isBoss) return false;
if (!e || !e.alive || e.isFlying || e.isBoss || e.s.flags.selfBound) return false;
const r = unit.tileR, c = unit.tileC;
if (!battle.grid.groundPassable(r, c)) return false;
const er = Math.round(e.y), ec = Math.round(e.x);
+6 -2
View File
@@ -113,7 +113,7 @@ test('信仰搅拌机 S1 铳骑主考官 (自动触发 ⇒ DEFAULT: hurt SP, fir
}
});
test('信仰搅拌机 S2 八臂电锯侠: ammo skill, ATK/DEF up; a lethal hit is negated for ammo_cost bullets (not with fewer)', () => {
test('信仰搅拌机 S2 八臂电锯侠: ammo skill, ATK/DEF up; a lethal hit is negated for ammo_cost bullets; with fewer it is still negated, every bullet goes and the skill ends (PRTS 备注)', () => {
for (const id of pair('chess_char_4_01_a')) {
const bb = D(id, 1).skill.bb;
for (const enough of [true, false]) {
@@ -136,7 +136,11 @@ test('信仰搅拌机 S2 八臂电锯侠: ammo skill, ATK/DEF up; a lethal hit i
const shots = h.hooksOf('attack').filter((c) => c.attacker === u).length;
assert.equal(u.skill.ammoLeft, before - bb.ammo_cost - shots, `${bb.ammo_cost} bullets spent`);
} else {
assert.ok(!u.alive, `${id}: fewer than ${bb.ammo_cost} bullets ⇒ no guard`);
// PRTS 备注 "弹药量不足时仍可抵挡致命伤害,此时将消耗所有剩余弹药并退出技能状态" (0.1.0: [ASSUMED] no guard)
assert.ok(u.alive, `${id}: fewer than ${bb.ammo_cost} bullets still block the lethal hit`);
approx(u.hp, 500, 1e-9, 'HP kept');
assert.equal(u.skill.ammoLeft, 0, 'every bullet spent');
assert.equal(u.skill.active, false, 'the skill ends');
}
}
}
+99
View File
@@ -0,0 +1,99 @@
// 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';
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 && u.mem.undyingUntil > h.b.time, '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);
});
+17
View File
@@ -519,6 +519,23 @@ describe('a 重生 clears what operators put on the enemy (PRTS 特殊机制 §
assert.ok(h.b.time < 2 + HUSK_REBIRTH + tb('enemy_1288_duskls', 'Revive[Trigger].interval') + 10);
});
test('a status carried by the knocking-out hit itself (妮芙 S2 / 叙拉古 6: damage, then `if (target.alive) applyStatus`) does not land after the cleanse — 无敌 + 无法选中 takes no status from the other side (PRTS 无敌 "无法被不同阵营选中")', () => {
const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }], kits: QUIET_GUNS });
h.step();
const e = put(h, 'enemy_1288_duskls', { pos: [10, 8], mods: { speedMul: 0 } });
const gun = h.unit('t_gun');
h.b.dealDamage(gun, e, { amount: 1e7, type: 'true', tags: ['test'] });
assert.ok(e.alive && e.form === 'husk' && e.s.flags.invulnerable && e.s.flags.untargetable, 'knocked out: its 1 s 重生');
// the same hit's rider, as the kits run it
if (e.alive) assert.equal(h.b.applyStatus(e, 'fear', { duration: 4, source: gun }), false, 'the fear is refused');
assert.equal(h.b.applyStatus(e, 'sluggish', { duration: 4, source: gun }), false, 'so is any other status from an operator');
assert.equal(e.findBuff('fear'), null);
assert.ok(!e.s.flags.fear, 'not feared');
h.run(HUSK_REBIRTH + 0.1);
assert.ok(!e.s.flags.fear && !e.s.flags.unblockable && e.s.flags.stealth, 'after the 重生: a 隐匿 ember that can be blocked, not a feared one');
assert.ok(h.b.applyStatus(e, 'fear', { duration: 4, source: gun }), 'a later fear lands again');
});
test('锏\'s 重生 clears an operator\'s slow and 脆弱 but keeps its 抵抗 (a self-applied talent status); 守墓石像\'s instant 重生 too', () => {
const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }], kits: QUIET_GUNS });
h.step();