feedback1 integrate: follow-ups from the workstream notes (official rules)

- an operator's radius area damage skips unblocked 隐匿 and untargetable enemies (Battle.foesInRadius;
  PRTS 作战机制 AOE伤害判定), the 逐火 embers included
- PRTS 溅射半径一览: 投掷手 0.9, 链术师 1.7, 格雷伊 1.0; 迷迭香 S2 radius 1.5
- 阿罗玛's first-attack mark is shared between 阿罗玛 and leaves with its setter (PRTS 备注)
- 奥术法阵 on the field: 炎佑's damage silences its target (PRTS 盟约记录 备注)
- skills restart from their initial cooldown when a 重生 ends (PRTS 特殊机制)
- 高准度伦蒂尼姆城防自行炮: lock in range, 3x3 area, every 0.5 s up to 10 shots, 失衡/控制免疫 meanwhile
- 琳琅诗怀雅 S3 coins go to range 2-4 in front and the units she blocks
- 阿戈尔 devour is a 物理流失 reduced by the target's DEF
This commit is contained in:
sganggs committed 2026-10-03 07:57:28 +08:00
1 parent 4e68100065
commit 75e2f02590
21 files changed
+346 -112

No files matched your search

+6 -5
View File
@@ -305,7 +305,7 @@ test('拉特兰: ammo ×(1.05+0.015L) floored; 6: every ammo used → all member
// ---------------------------------------------------------------------------------------------------------------------
// 阿戈尔
test('阿戈尔: HP ×(1.35+0.01L); devour chain (left first): 5000 phys, base ATK + block, layers = devoured tiers', () => {
test('阿戈尔: HP ×(1.35+0.01L); devour chain (left first): 5000 物理流失 less the target\'s DEF, base ATK + block, layers = devoured tiers', () => {
const list = [
['g1_a', ['egirShip']], ['g2_a', ['egirShip']], ['g3_a', ['egirShip']],
['fod_a', ['preciShip'], { tier: 4, stats: { atk: 700, blockCnt: 3, maxHp: 20000, def: 900 } }],
@@ -319,13 +319,14 @@ test('阿戈尔: HP ×(1.35+0.01L); devour chain (left first): 5000 phys, base A
const [g1, g2, g3, f] = ['g1_a', 'g2_a', 'g3_a', 'fod_a'].map((id) => h.unit(id));
const devours = tagged(h, 'bond:egir:devour');
assert.deepEqual(devours.map((c) => [c.source.defId, c.target.defId]), [['g1_a', 'g2_a'], ['g1_a', 'fod_a'], ['g2_a', 'fod_a']]);
for (const c of devours) close(c.amount, 5000);
// PRTS 盟约记录 "造成5000点物理流失"; PRTS 作战机制: a 物理流失 is reduced by the target's DEF
for (const c of devours) close(c.amount, Math.max(5000 - c.target.s.def, 5000 * 0.05));
close(g1.s.atk, 1000 + 1000 + 700, 1e-6, 'g1 gains g2 + fodder base ATK');
close(g2.s.atk, 1000 + 700);
close(g3.s.atk, 1000);
assert.equal(g1.s.blockCnt, 2 + 2 + 3);
assert.equal(g2.s.blockCnt, 2 + 3);
close(f.hp, 20000 - 10000, 1e-6, '流失: DEF 900 ignored');
close(f.hp, 20000 - 2 * (5000 - 900), 1e-6, '物理流失: less DEF 900 (DEF-free until 0.1.1)');
const Lnow = L + 1 + 4; // g2 tier 1 + fodder tier 4, once each
assert.equal(h.b.getPlayer('p1').bonds.egirShip.layers, Lnow);
h.step(2);
@@ -334,7 +335,7 @@ test('阿戈尔: HP ×(1.35+0.01L); devour chain (left first): 5000 phys, base A
checkInvariants(h.b);
});
test('阿戈尔 devour: 流失 ignores the marker’s damage bonuses and the target’s shields; a dead marker’s marks are cancelled; kill → marker', () => {
test('阿戈尔 devour: 物理流失 ignores the marker’s damage bonuses and the target’s shields; a dead marker’s marks are cancelled; kill → marker', () => {
const list = [
['g1_a', ['egirShip', 'kjeragShip']], ['g2_a', ['egirShip'], { stats: { maxHp: 3000 } }], ['g3_a', ['egirShip']],
['fod_a', ['preciShip'], { tier: 2, stats: { maxHp: 20000, def: 500 } }],
@@ -353,7 +354,7 @@ test('阿戈尔 devour: 流失 ignores the marker’s damage bonuses and the tar
assert.ok(h.hooksOf('kill').some((c) => c.victim === g2 && c.killer === g1), 'kill credited to the marker');
const dv = tagged(h, 'bond:egir:devour');
assert.deepEqual(dv.map((c) => [c.source.defId, c.target.defId]), [['g1_a', 'g2_a'], ['g1_a', 'fod_a']], 'g2’s own mark on the fodder is cancelled');
close(f.hp, 20000 - 5000, 1e-6, 'exactly 5000: no ×1.25, no DEF, shield untouched');
close(f.hp, 20000 - (5000 - 500), 1e-6, '5000 less its DEF 500: no ×1.25, shield untouched');
assert.equal(f.buffs.find((b) => b.key === 'test:shield')?.shield, 3000);
close(g1.s.atk, 1000 + 1000 + 1000, 1e-6, 'the base ATK of everything it marked stays');
assert.equal(h.b.getPlayer('p1').bonds.egirShip.layers, 1 + 2, 'layers = tiers of the devoured units');
+25 -8
View File
@@ -1092,15 +1092,27 @@ test(`${nm('enemy_1062_rager_2')}: loses ${tb('enemy_1062_rager_2', 'periodic_da
approx(e.s.maxHp - e.hp, 3 * tb('enemy_1062_rager_2', 'periodic_damage.damage'));
});
test(`${nm('enemy_1273_stmgun_2')}: Cannon locks the highest-max-HP unit and bombards (arts ATK×${skb('enemy_1273_stmgun_2', 'Cannon').bb.atk_scale})`, () => {
test(`${nm('enemy_1273_stmgun_2')}: Cannon locks the highest-max-HP unit in range, bombards the highest HP% of its 9 tiles every 0.5 s up to 10 times (arts ATK×${skb('enemy_1273_stmgun_2', 'Cannon').bb.atk_scale}), 失衡免疫 + control immunity meanwhile (PRTS)`, () => {
const h = arena({ units: [{ chessId: 't_gun', row: 12, col: 3 }, { chessId: 't_wall', row: 11, col: 3 }], chess: { t_wall: WALL('t_wall', { stats: { maxHp: 2e7 } }) } });
h.step();
const e = put(h, 'enemy_1273_stmgun_2', [10, 9]);
e.profile.noAttack = true;
const far = arena({ units: [{ chessId: 't_gun', row: 12, col: 3 }] });
far.step();
const e0 = put(far, 'enemy_1273_stmgun_2', [10, 9]);
e0.profile.noAttack = true;
const s = skb('enemy_1273_stmgun_2', 'Cannon');
h.run(s.initCooldown + 4);
assert.ok(h.eventsOf('fx').some((f) => f[1] === 'telegraph' && f[4].kind === 'cannon'));
far.run(s.initCooldown + 1);
assert.ok(!far.eventsOf('fx').some((f) => f[1] === 'telegraph' && f[4].kind === 'cannon'), 'nobody in its range: no cast');
const e = put(h, 'enemy_1273_stmgun_2', [11, 5]);
e.profile.noAttack = true;
h.run(s.initCooldown + 0.1);
const tel = h.eventsOf('fx').find((f) => f[1] === 'telegraph' && f[4].kind === 'cannon');
assert.ok(tel && tel[2] === 3 && tel[3] === 11, 'centred on the highest max-HP unit\'s tile');
assert.ok(e.findBuff('ab:cannon')?.flags.noDisplace, '失衡免疫 during the bombardment');
assert.equal(h.b.applyStatus(e, 'stun', { duration: 1 }), false, '晕眩免疫 during it');
h.run(5.5);
assert.equal(h.eventsOf('fx').filter((f) => f[1] === 'explode' && f[4].kind === 'cannon').length, 10, '10 shots');
assert.ok(h.unit('t_wall').stats.taken + h.unit('t_gun').stats.taken > 0);
assert.ok(!e.findBuff('ab:cannon') && h.b.applyStatus(e, 'stun', { duration: 1 }) !== false, 'over afterwards');
});
for (const key of ['enemy_1501_demonk', 'enemy_10018_sgrobh']) {
@@ -1818,15 +1830,20 @@ test(`${nm('enemy_1525_blkswb')}: 抵抗; ignores ${tb('enemy_1525_blkswb', 'Def
const first = h.hooksOf('damaged').find((c) => c.source === e);
approx(first.amount, e.s.atk * skb('enemy_1525_blkswb', 'Blink').bb.atk_scale - 500, 1e-6, '速杀 on its blocker');
assert.ok(e.x < 5 - 0.4, `passed through (x ${e.x})`);
// form 2 (its 速杀 held back so it keeps fighting the wall)
// form 2
const CASTER = chessRec({ id: 't_caster', profession: 'CASTER', projectile: 'none', stats: { atk: 1, maxHp: 1e7, bat: 1, blockCnt: 0 }, rangeGrid: [[0, 0]], skill: { spCost: 999 } });
const h2 = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }, { chessId: 't_caster', row: 10, col: 4 }], chess: { t_wall: WALL('t_wall', { def: 500 }), t_caster: CASTER },
captureNoisy: true, hooks: ['damaged', 'statusApplied'] });
h2.step();
const k = put(h2, 'enemy_1525_blkswb', [9, 5]);
for (const a of k.mem.ab.list) if (a.id === 'blink' || a.id === 'blink2') a.left = 999;
// its 速杀 held back so it keeps fighting the wall (form 2's starts from its initial cooldown 0 when the 重生 ends)
for (const a of k.mem.ab.list) if (a.id === 'blink') a.left = 999; else if (a.id === 'blink2') a.cond = () => false;
killed(h2, k, null);
h2.run(tb('enemy_1525_blkswb', 'Reborn.duration') + tb('enemy_1525_blkswb', 'Reborn.invincible') + 0.1);
assert.ok(h2.runUntil(() => k.form === 'form2', 10), 'the 重生 ends');
// PRTS 特殊机制 §重生 "重生结束时,重置自身的通用技能与当前形态的技能冷却为初始冷却"
const b1 = k.mem.ab.list.find((a) => a.id === 'blink');
assert.ok(b1.left > (b1.icd ?? 0) - 0.04 && b1.left <= (b1.icd ?? 0) + 1e-9, `速杀 (held at 999) back to its initial cooldown (${b1.left} vs ${b1.icd})`);
h2.run(tb('enemy_1525_blkswb', 'Reborn.invincible') + 0.1);
assert.ok(k.s.flags.stealth, 'second form: 隐匿');
const t0 = h2.b.time, a0 = k.stats.attacks;
h2.runUntil(() => k.stats.attacks >= a0 + 1, 20);
+2 -1
View File
@@ -691,7 +691,8 @@ test('6_11 缪尔赛思 S2 生态耦合: +cost DP; melee copies regenerate and t
test('6_12 迷迭香 S1 思维膨大: the next attack adds extra_atk_scale × ATK arts to every enemy it hits', () => {
for (const id of both('chess_char_6_12')) {
const sid = 'skchr_rosmon_1', bb = bbOf(id, sid);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 7] }, { key: 'e', pos: [10, 8] }] });
// the second enemy inside the 投掷手 splash (PRTS 溅射半径一览 0.9) of the first
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 7] }, { key: 'e', pos: [10, 7.6] }] });
const u = h.unit(id);
usesSkill(u, sid);
assert.ok(h.runUntil(() => dealt(h, u, (c) => c.type === 'arts').length >= 2, 6));
+125
View File
@@ -0,0 +1,125 @@
// Follow-ups of the 0.1.1 integration (the workstreams' cross-workstream notes and last review rounds), each against its
// primary source:
// * PRTS 作战机制 §AOE伤害判定 "AOE的判定是对攻击范围内的每个可以被选中的敌人进行判定" + 隐匿 "无法被敌方的索敌机制和Buff选择器选中":
// radius area damage of an operator (profession splash, skill circles — Battle.foesInRadius) skips an unblocked,
// unrevealed 隐匿 enemy and the untargetable (player report #8: the 逐火 余烬; until 0.1.1 they were hit);
// * PRTS 溅射半径一览: 投掷手 0.9, 链术师 1.7, 扩散术师 1.1 (格雷伊 1.0), 迷迭香 末梢阻断 1.5;
// * PRTS 阿罗玛 天赋 备注: the 非首次标记 is shared by every 阿罗玛 and leaves with its setter;
// * PRTS 盟约记录 奥术法阵 备注: 炎佑's damage silences its target while a carrier is on the field;
// * PRTS 特殊机制 §重生: skills restart from their initial cooldown when a 重生 ends (test/content/enemies_bosses 锏);
// * PRTS 高准度伦蒂尼姆城防自行炮 (test/content/enemies_bosses); PRTS 琳琅诗怀雅 S3 备注 range 2-4 (below).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
import { resolveProfile } from '../../server/sim/professions.js';
import { CHAIN_RADIUS } from '../../server/sim/constants.js';
import { spawnYanyou } from '../../server/sim/content/tokens.js';
const ds = getDefaultSource();
const dummy = (o = {}) => enemyRec({ key: 'enemy_dummy', hp: 1e8, atk: 0, speed: 0, ...o });
const stealth = (h, e) => h.b.addBuff(e, { key: 'test:stealth', flags: { stealth: true }, persist: true });
const dmgTo = (h, src, e) => h.hooksOf('damaged').filter((c) => c.source === src && c.target === e).reduce((s, c) => s + c.amount, 0);
test('profession splash skips an unblocked 隐匿 enemy and hits it once revealed (PRTS §AOE伤害判定: only enemies that can be selected)', () => {
for (const reveal of [false, true]) {
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: 'chess_char_1_14_a', row: 10, col: 3 }], timeLimit: 60, hooks: ['damaged'], captureNoisy: true, autoFinish: false });
h.step();
const u = h.unit('chess_char_1_14_a');
const main = h.spawn('enemy_dummy', { pos: [10, 5] });
const hid = h.spawn('enemy_dummy', { pos: [10, 4.4] }); // 0.6 from the main target: inside 格雷伊's 1.0 splash
stealth(h, hid);
if (reveal) h.b.addBuff(hid, { key: 'test:reveal', flags: { reveal: true }, persist: true });
h.run(6);
assert.ok(dmgTo(h, u, main) > 0, 'the main target is struck');
if (reveal) assert.ok(dmgTo(h, u, hid) > 0, 'revealed: splashed (and targetable)');
else assert.equal(dmgTo(h, u, hid), 0, 'unblocked 隐匿: never splashed (it used to be)');
checkInvariants(h.b);
}
});
test('Battle.foesInRadius: no untargetable or unblocked 隐匿 enemy; a blocked or revealed one counts; enemiesInRadius keeps them all', () => {
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [], timeLimit: 30, autoFinish: false });
h.step();
const [a, b, c, d] = [[10, 5], [10, 5.2], [10, 5.4], [10, 5.6]].map((pos) => h.spawn('enemy_dummy', { pos }));
stealth(h, b);
h.b.addBuff(c, { key: 'test:untargetable', flags: { untargetable: true }, persist: true });
stealth(h, d);
h.b.addBuff(d, { key: 'test:reveal', flags: { reveal: true }, persist: true });
assert.deepEqual(h.b.foesInRadius(5, 10, 1).map((e) => e.id).sort(), [a.id, d.id].sort());
assert.equal(h.b.enemiesInRadius(5, 10, 1).length, 4, 'enemy-side auras and heals still reach them all');
});
test('PRTS 溅射半径一览: 投掷手 0.9, 扩散术师 1.1 but 格雷伊 1.0, 链术师 jumps 1.7', () => {
const prof = (id) => resolveProfile(ds.getChess(id));
assert.equal(prof('chess_char_6_12_a').splashRadius, 0.9, '迷迭香 (投掷手)');
assert.equal(prof('chess_char_1_14_a').splashRadius, 1.0, '格雷伊');
assert.equal(prof('chess_char_1_14_b').splashRadius, 1.0, '格雷伊 (elite)');
for (const id of ['chess_char_4_02_a', 'chess_char_4_14_a', 'chess_char_5_12_a', 'chess_char_5_21_a']) assert.equal(prof(id).splashRadius, 1.1, id);
assert.equal(CHAIN_RADIUS, 1.7);
for (const id of ['chess_char_1_03_a', 'chess_char_6_05_a']) assert.equal(prof(id).chain.radius, 1.7, id);
});
test('阿罗玛\'s 非首次标记 is shared (PRTS 备注 "不同阿罗玛之间的非首次标记通用") and leaves with its setter ("自身离场时移除自身已施加的标记")', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() } },
units: [{ uid: 'A', chessId: 'chess_char_4_10_a', row: 10, col: 1 }, { uid: 'B', chessId: 'chess_char_4_10_a', row: 10, col: 2 }],
timeLimit: 60, hooks: ['statusApplied', 'damaged'], captureNoisy: true, autoFinish: false,
});
h.step();
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
const [A, B] = ['A', 'B'].map((uid) => h.b.allyUnits.find((u) => u.uid === uid));
assert.ok(h.runUntil(() => dmgTo(h, A, e) > 0 && dmgTo(h, B, e) > 0, 15), 'both 阿罗玛 strike it');
h.run(1);
const lev = () => h.hooksOf('statusApplied').filter((c) => c.target === e && c.status === 'levitate');
assert.equal(lev().length, 1, 'one 浮空 for the pair (each used to trigger her own)');
const mark = e.findBuff('aroma:bubbled');
assert.ok(mark, 'the shared mark');
const setter = mark.source, other = setter === A ? B : A;
h.b.kill(setter, null);
assert.ok(!e.findBuff('aroma:bubbled'), 'its setter left: the mark goes');
const n = lev().length;
assert.ok(h.runUntil(() => lev().length > n, 10), 'the other 阿罗玛\'s next attack is a first attack again');
assert.equal(e.findBuff('aroma:bubbled').source, other);
checkInvariants(h.b);
});
test('奥术法阵 on the field: 炎佑\'s damage silences its target (PRTS 盟约记录 备注 "造成伤害时使目标失去特殊能力5秒"); none without a carrier', () => {
const ARC = 'chess_item_3_08_e_a';
for (const carried of [true, false]) {
const units = [{ chessId: 'chess_char_1_02_a', row: 12, col: 2, ...(carried ? { items: [ARC] } : {}) }];
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units, timeLimit: 60, hooks: ['statusApplied', 'damaged'], captureNoisy: true, autoFinish: false });
h.step();
const [y] = spawnYanyou(h.b, 'p1', { atk: 400, hp: 50000 });
const e = h.spawn('enemy_dummy', { pos: [9, 7] });
assert.ok(h.runUntil(() => dmgTo(h, y, e) > 0, 20), '炎佑 deals damage');
h.run(0.2);
const sil = h.hooksOf('statusApplied').filter((c) => c.target === e && c.status === 'silence' && c.source === y);
if (carried) {
assert.ok(sil.length > 0, 'silenced by 炎佑');
assert.ok(Math.abs(sil[0].duration - 5) < 1e-9, '5 s');
} else assert.equal(sil.length, 0, 'no carrier: no rider');
checkInvariants(h.b);
}
});
test('琳琅诗怀雅 S3 pays her coins to the ground enemies of range 2-4 in front (PRTS 备注), not only her 1-1 attack range', () => {
const id = 'chess_char_3_04_a';
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 3, skillIndex: 2 }], timeLimit: 60, hooks: ['damaged'], captureNoisy: true, autoFinish: false });
h.step();
const u = h.unit(id);
assert.equal(u.skill.id, 'skchr_swire2_3');
const near = h.spawn('enemy_dummy', { pos: [10, 4] }); // her attack range (1-1)
const far = h.spawn('enemy_dummy', { pos: [10, 5] }); // range 2-4 only (two tiles ahead)
const side = h.spawn('enemy_dummy', { pos: [11, 4] }); // range 2-4's diagonal front
const out = h.spawn('enemy_dummy', { pos: [10, 6] }); // outside both
assert.ok(h.runUntil(() => u.skill.active, 20), 'S3 on');
u.mem.coins = 10;
const cash = () => h.hooksOf('damaged').filter((c) => c.source === u && (c.dmg?.tags || []).includes('swire2Cash'));
assert.ok(h.runUntil(() => cash().length >= 10, 10), 'the full purse is spent');
const hit = new Set(cash().map((c) => c.target));
assert.ok(hit.has(far) || hit.has(side), 'coins reach range 2-4 beyond her attack range');
assert.ok(!hit.has(out), 'nothing outside range 2-4');
for (const e of hit) assert.ok([near, far, side].includes(e));
checkInvariants(h.b);
});