diff --git a/docs/SIM.md b/docs/SIM.md index 45f0ae1f..f7fbfd90 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -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 diff --git a/server/sim/Battle.js b/server/sim/Battle.js index bd4850a5..d1419e28 100644 --- a/server/sim/Battle.js +++ b/server/sim/Battle.js @@ -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; diff --git a/server/sim/buffs.js b/server/sim/buffs.js index ee3b86e5..cb8a7598 100644 --- a/server/sim/buffs.js +++ b/server/sim/buffs.js @@ -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', ]); /** diff --git a/server/sim/content/bosses.js b/server/sim/content/bosses.js index 9b2a5b37..c6231f2c 100644 --- a/server/sim/content/bosses.js +++ b/server/sim/content/bosses.js @@ -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; }, // 范围物理伤害 diff --git a/server/sim/content/enemies.js b/server/sim/content/enemies.js index 0fa96ea0..63d9c9a5 100644 --- a/server/sim/content/enemies.js +++ b/server/sim/content/enemies.js @@ -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; diff --git a/server/sim/content/items/battle.js b/server/sim/content/items/battle.js index 3bc978f2..86fc799f 100644 --- a/server/sim/content/items/battle.js +++ b/server/sim/content/items/battle.js @@ -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); diff --git a/server/sim/content/kits/tier4.js b/server/sim/content/kits/tier4.js index b44d1133..3a195f6f 100644 --- a/server/sim/content/kits/tier4.js +++ b/server/sim/content/kits/tier4.js @@ -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 }); } diff --git a/server/sim/content/kits/tier6.js b/server/sim/content/kits/tier6.js index a7ff2b12..8a99b390 100644 --- a/server/sim/content/kits/tier6.js +++ b/server/sim/content/kits/tier6.js @@ -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); diff --git a/test/content/kits_alt_t4.test.js b/test/content/kits_alt_t4.test.js index 98a5bac9..c5c5849d 100644 --- a/test/content/kits_alt_t4.test.js +++ b/test/content/kits_alt_t4.test.js @@ -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'); } } } diff --git a/test/sim/feedback1-residuals.test.js b/test/sim/feedback1-residuals.test.js new file mode 100644 index 00000000..d90afd3a --- /dev/null +++ b/test/sim/feedback1-residuals.test.js @@ -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); +}); diff --git a/test/sim/feedback1-transform.test.js b/test/sim/feedback1-transform.test.js index 8e876b94..aa2c7e57 100644 --- a/test/sim/feedback1-transform.test.js +++ b/test/sim/feedback1-transform.test.js @@ -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();