diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 2717ca37..0081275f 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -110,7 +110,7 @@ - **附加盟约**(精准、迅捷、灵巧、奥术、坚守、助力、远见、奇迹、投资人、突袭、不屈、调和、协防干员、独行、绝技):多为 2 / 3 名激活;「独行」需要场上**恰好 1 名**,「绝技」按场上的精锐数量计算,没有势力盟约的干员算「协防干员」。 - **本局禁用**:「标准模拟」与官方相同,关闭拉特兰、阿戈尔、卡西米尔、灵巧、奥术、奇迹、投资人、突袭、独行、绝技这 10 个盟约,它们不会激活。你有这些盟约的成员时(奇迹、投资人也算整备区里的成员),盟约栏末尾显示**灰色带 ✕ 的盟约图标和「本局禁用」**,干员卡、盟约标签和盟约详情也这样标注。「远见」「奇迹」「投资人」计入整备区,盟约详情的「成员」人数也一样计入整备区里的成员(临时整备区不算)。 - **调和**:激活时,场上已有成员的核心盟约人数各 +1(只加 1 人,不论有几名调和干员)。盟约详情会写「含调和 +1」,成员列表上方列出带来这 1 人的调和干员(如缪尔赛思)。 -- **炎佑**:场上 6 名以上「炎」干员时召唤炎佑(9 名时两只)。它追踪全场仇恨最高的敌人并悬停在其上方,技能「祛恶之焰」锁定 1 个目标**持续喷火**(最长 20 秒,每秒对目标周围的敌人造成法术伤害并附带灼燃,喷火时不普攻,目标死亡或被沉默时结束);场上暂时没有敌人时停在原地等待。它持有官方的「孤立」:医疗干员不会治疗它,友方的增益、光环和天赋也不会选中它(敌人照常可以攻击它)。 +- **炎佑**:场上 6 名以上「炎」干员时召唤炎佑(9 名时两只)。它追踪全场仇恨最高的敌人并悬停在其上方,技能「祛恶之焰」锁定 1 个目标**持续喷火**(最长 20 秒,每秒对目标周围的敌人造成法术伤害并附带灼燃,喷火时不普攻,目标死亡或被沉默时结束);场上暂时没有敌人时停在原地等待。它持有官方的「孤立」:医疗干员不会治疗它,友方的增益、光环和天赋也不会选中它(敌人照常可以攻击它)。它是飞行单位:标注「不可对空」的敌人效果(高能源石虫 / 冰爆源石虫 / 卷心籽的死亡爆炸、水遁忍者的漩涡、鼎沸、“萨科塔昂首”的祈祷邀约、“斩胄之剑” / “破胄之锤”的攻击等)不会波及它。 - **层数**:盟约通过各种效果叠加层数,层数越高效果越强(例如「炎」攻击力随层数提升、「远见」每 10 层给资金)。**层数整局保留,但只有盟约激活时才生效**,多数情况下也只有激活的盟约能叠层。每个盟约最多 **999 层**(与官方相同,取自官方客户端的常量):到 999 后再获得的层数直接作废,按层数发放的奖励(如「远见」的资金)也随之停止。作战中获得的层数在盟约栏里**实时**显示,作战结束后一直保留到结算(联防期间也不会退回去)。「调和」「协防干员」「独行」「绝技」与官方一样**不显示层数**(盟约栏、详情和结算卡片都不显示),但叠层的特质 / 策略 / 装备照样给它们加层(PRTS 盟约记录:「部分盟约不会显示叠加层数,但是叠加层数的特质/策略/装备等效果仍然对其生效」),这些层数也计入激活层数总和;它们的效果本身与层数无关。 - **特质在哪里生效**:「进入休整期时」「休整期结束时」「刷新时」的特质(详情卡的第一项)只在干员部署在作战区时生效;文字里写着「此干员在整备区时也有效」的拉普兰德、调香师、波登可,以及激活【炎】/【投资人】时的琳琅诗怀雅,在整备区里也生效(临时整备区不算),与官方数据一致;「获得时」「售出时」的特质在获得、售出的那一刻生效,不论干员在哪里。「远见」「奇迹」「投资人」的「整备区的…干员也可用于激活盟约」只是把整备区的成员算进人数,不会让赫默等干员的特质在整备区生效。写着「使已激活的…」的特质要求盟约当时已经激活:例如「叙拉古」只数作战区里不同的叙拉古干员,整备区里的拉普兰德不算在这 3 名里;拉普兰德每回合只在她经历的第一次主动刷新时叠层(免费刷新也算),那次刷新时叙拉古没有激活,这一回合就不会再叠。 - **加成怎么叠**:盟约、策略、装备、机变卡和特质给的「攻击力 / 防御力 / 生命值 +X%」与技能的「攻击力 +X%」**相加**后再乘到属性上(官方:直接乘算),例如精准 +130%、独行 +60%、一件 +40% 的装备合计 +230%,而不是 ×2.3×1.6×1.4。「伤害提升至 X%」「受到的伤害 +X%」(维多利亚、谢拉格、奥术、脆弱等)这类伤害倍率之间仍然相乘;但同名效果只算一个——同一战场两名玩家都开了「奥术」时,敌人身上只挂一层奥术易伤,取两人中较高的那一个(不再相乘);两名「灵知」的坚冰、两名「莱恩哈特」/「缄默德克萨斯」的法抗削减也一样只算最强的一个。「坚守」的反伤计入被打的那名成员(个人对领袖伤害统计里能看到)。 diff --git a/docs/SIM.md b/docs/SIM.md index ea6a9d7b..84d44db9 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -362,6 +362,10 @@ one; the sourceless 毒雾 of 假想敌:蚀裂 skips a 隐匿 one but still re 范围内的所有单位持续视为受到源石污染区影响,技力自然回复速度倍率-80%,每0.5秒受到50真实持续伤害(同类效果取最高)") is the same kind of damage — 无来源 true (like the terrain it stands for [ASSUMED]), the chimera credited — not a 流失 (player report D1 audit): radius 1.2, a tick every 0.5 s, one tick per unit per 0.5 s however many chimeras reach it (`mem.chimeraAt`). +Every enemy effect PRTS marks "不可对空" skips a flying ally (the 炎佑 dragon, `isFlying`): the 死亡爆炸 of 高能源石虫 / 冰爆源石虫 / +卷心籽 (archetypes.js `deathBoom`, `noAir` by default), 水遁忍者's 漩涡形态, 鼎沸's pulses, “萨科塔昂首”'s 祈祷邀约, the attack of +“斩胄之剑” / “破胄之锤”, the splash and blast of 烹泉 / 沏虹, 庞贝's blast and 集团军重型火炮's shell and 燃烧区域 (the first six +reached it until 0.2.1 — a community report). **Knock-outs that are not deaths** (`content/enemies/archetypes.js`; player reports after 0.1.0): a `killed` ability that keeps the enemy alive hides the knock-out from every later `kill` handler, the kill count, kill credit and the bounty — they all diff --git a/server/sim/content/bosses.js b/server/sim/content/bosses.js index eb3cd4be..8c7f663c 100644 --- a/server/sim/content/bosses.js +++ b/server/sim/content/bosses.js @@ -524,11 +524,12 @@ function kitBlade(ab, e, b, tpl) { // 自缚 (moved by hand) · 不可阻挡 · 失衡免疫 (PRTS 天赋 "{{特殊机制|静态刚体}},不可阻挡、失衡免疫…" — data `staticBody` too) 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]; + e2.profile.canTarget = (u) => !u.isFlying; // "…不可对空" (below) }, - // 范围物理伤害 — "普通攻击对攻击范围内的所有我方单位造成…物理普通伤害": a normal attack on every operator it can target + // 范围物理伤害 — "普通攻击对攻击范围内的所有我方单位造成…物理普通伤害,不可对空": a normal attack on every operator it can target // (canTargetAlly: no 隐匿 or 迷彩 one — it is never blocked —, PRTS 选择器: a normal attack does not ignore 迷彩; until - // 0.1.2 it took them all, `ranged: false`) - before(c, b2, e2) { const l = targetsNear(b2, e2, e2.base.rangeRadius || 1.6); if (l.length) c.targets = l; }, + // 0.1.2 it took them all, `ranged: false`), never a flying ally (the 炎佑 dragon — until 0.2.1 it was hit) + before(c, b2, e2) { const l = targetsNear(b2, e2, e2.base.rangeRadius || 1.6).filter((u) => !u.isFlying); if (l.length) c.targets = l; }, taken(c, b2, e2) { if (c.amount > 0) { // 受到伤害时令假想敌:胄受到等量的无来源生命流失 (【瘫痪】; 出击模式 [ASSUMED], see PART_TRANSFER) const L = leader(b2); diff --git a/server/sim/content/enemies/archetypes.js b/server/sim/content/enemies/archetypes.js index 19185942..a4e16b1c 100644 --- a/server/sim/content/enemies/archetypes.js +++ b/server/sim/content/enemies/archetypes.js @@ -181,15 +181,17 @@ const deathSpawn = (key, cnt, extra = {}) => ({ /** * Death explosion on the allies within r it can select (areaAllies — PRTS 高能源石虫 / 冰爆源石虫 / 卷心籽 死亡爆炸 "…无视迷彩, - * 不可对空", no 无视无法选择: a 隐匿 ally is spared; the dead enemy blocks nobody). + * 不可对空", no 无视无法选择: a 隐匿 ally is spared; the dead enemy blocks nobody). `noAir` (default — all three say so): + * a flying ally (the 炎佑 dragon) is skipped; until 0.2.1 it took the blast (community report: 炎祐吃到了不该吃到的地面伤害). */ -const deathBoom = ({ scale, type = 'phys', r = BOOM_RADIUS, status = null, sil = true, cond = null }) => ({ +const deathBoom = ({ scale, type = 'phys', r = BOOM_RADIUS, status = null, sil = true, cond = null, noAir = true }) => ({ sil, death(c, b, e, a) { if (c.reason !== 'killed' || (cond && !cond(e, a))) return; const atk = e.s.atk; b.fx('explode', { x: e.x, y: e.y, r, kind: 'deathBoom', id: e.id }); for (const u of areaAllies(b, e, e.x, e.y, r)) { + if (noAir && u.isFlying) continue; if (scale > 0) hurt(b, e, u, atk * scale, type); if (status && u.alive) b.applyStatus(u, status.key, { duration: status.dur, source: e, value: status.value }); } diff --git a/server/sim/content/enemies/dot.js b/server/sim/content/enemies/dot.js index 9489e40d..9eebc0cb 100644 --- a/server/sim/content/enemies/dot.js +++ b/server/sim/content/enemies/dot.js @@ -128,7 +128,13 @@ export const DOT_KITS = Object.freeze({ tick(b, e2) { const r = (e2.def.raw.stats && e2.def.raw.stats.rawRangeRadius) || 1.6; b.fx('explode', { x: e2.x, y: e2.y, r, kind: 'boil' }); - for (const u of areaAllies(b, e2, e2.x, e2.y, r)) { hurt(b, e2, u, e2.s.atk * (T(ab, 'aoe.atk_scale') ?? 1), 'arts'); elem(b, e2, u, 'burn', e2.s.atk * (T(ab, 'aoe.ep_damage_ratio') ?? 0)); } + // PRTS 鼎沸 "攻击范围内的所有我方单位每秒受到…法术持续伤害与…灼燃损伤(不可对空)": never a flying ally (the 炎佑 dragon; + // until 0.2.1 it burned) + for (const u of areaAllies(b, e2, e2.x, e2.y, r)) { + if (u.isFlying) continue; + hurt(b, e2, u, e2.s.atk * (T(ab, 'aoe.atk_scale') ?? 1), 'arts'); + elem(b, e2, u, 'burn', e2.s.atk * (T(ab, 'aoe.ep_damage_ratio') ?? 0)); + } }, }], }); diff --git a/server/sim/content/enemies/fly.js b/server/sim/content/enemies/fly.js index 03d9ab6b..cc27640e 100644 --- a/server/sim/content/enemies/fly.js +++ b/server/sim/content/enemies/fly.js @@ -117,12 +117,13 @@ function kitSteal(ab) { } /** “萨科塔昂首” 【祈祷邀约】 (PRTS "令全场我方单位(不可对空,无视迷彩)获得15s【受邀祈祷】攻击速度-30"): a whole-field skill - * selection — every ally but an unblocking 隐匿 / untargetable / sleeping one (fieldAllies; no 无视无法选择). */ + * selection — every ally but an unblocking 隐匿 / untargetable / sleeping one (fieldAllies; no 无视无法选择) and, 不可对空, a + * flying one (the 炎佑 dragon; until 0.2.1 it was slowed too). */ function kitRoar(ab) { const s = ab.sk.Roar; return [selfFear(ab), skill(s, (b, e) => { b.fx('telegraph', { x: e.x, y: e.y, r: 99, kind: 'roar', id: e.id }); - for (const u of fieldAllies(b, e)) b.addBuff(u, { key: 'ab:roar', duration: s.bb.duration ?? 0, refresh: 'extend', mods: { aspd: s.bb.attack_speed ?? 0 }, visible: true }); + for (const u of fieldAllies(b, e)) if (!u.isFlying) b.addBuff(u, { key: 'ab:roar', duration: s.bb.duration ?? 0, refresh: 'extend', mods: { aspd: s.bb.attack_speed ?? 0 }, visible: true }); }, { sil: true })]; } diff --git a/server/sim/content/enemies/special.js b/server/sim/content/enemies/special.js index 0a64f969..1d1d8a22 100644 --- a/server/sim/content/enemies/special.js +++ b/server/sim/content/enemies/special.js @@ -379,10 +379,11 @@ export const SPECIAL_KITS = Object.freeze({ fire(b, e, a) { a.until = b.time + (T(ab, 'EndRotate.rotate_duration') ?? 0); b.fx('telegraph', { x: e.x, y: e.y, r: 1, kind: 'spin', id: e.id }); }, iv: T(ab, 'RotateDamage.interval') ?? 1, // "每秒对半径1.0范围内的所有我方单位造成…(无视迷彩,不可对空),自身受阻止攻击类异常效果影响期间无法造成此伤害": an area - // selection, skipped while 晕眩 / 冻结 / 浮空 (flag stun), 沉睡 or 缴械 hold it (PRTS 异常效果 §阻止攻击; until 0.1.3 it spun on) + // selection, skipped while 晕眩 / 冻结 / 浮空 (flag stun), 沉睡 or 缴械 hold it (PRTS 异常效果 §阻止攻击; until 0.1.3 it spun on); + // 不可对空: never a flying ally (the 炎佑 dragon — until 0.2.1 it was hit) tick(b, e, a) { if (!(a.until > b.time) || e.s.flags.stun || e.s.flags.sleep || e.s.flags.disarm) return; - for (const u of areaAllies(b, e, e.x, e.y, T(ab, 'RotateDamage.attack@range_radius') ?? 1)) hurt(b, e, u, e.s.atk * (T(ab, 'RotateDamage.attack@atk_scale') ?? 1), 'phys'); + for (const u of areaAllies(b, e, e.x, e.y, T(ab, 'RotateDamage.attack@range_radius') ?? 1)) if (!u.isFlying) hurt(b, e, u, e.s.atk * (T(ab, 'RotateDamage.attack@atk_scale') ?? 1), 'phys'); }, }; // 漩涡形态 "不进行普通攻击" (PRTS 天赋): no blocked attack while it spins (until 0.1.3 its blocker took both) diff --git a/test/content/feedback7-yanyou-noair.test.js b/test/content/feedback7-yanyou-noair.test.js new file mode 100644 index 00000000..b0cf65fa --- /dev/null +++ b/test/content/feedback7-yanyou-noair.test.js @@ -0,0 +1,88 @@ +// test/content/feedback7-yanyou-noair.test.js — 炎佑 is a flying ally (PRTS “炎佑” 行动方式 飞行): the enemy effects PRTS marks +// 不可对空 never reach it (community report: 炎祐好像吃到了不该吃到的地面伤害). The death blasts of 高能源石虫 / 冰爆源石虫 / +// 卷心籽 ("…无视迷彩,不可对空"), 水遁忍者's 漩涡形态 ("每秒对半径1.0范围内的所有我方单位造成…(无视迷彩,不可对空)"), “萨科塔昂首”'s +// 祈祷邀约 ("令全场我方单位(不可对空,无视迷彩)获得15s【受邀祈祷】"), 鼎沸's pulses ("攻击范围内的所有我方单位每秒受到…(不可对空)") +// and the attack of “斩胄之剑” / “破胄之锤” ("普通攻击对攻击范围内的所有我方单位造成…物理普通伤害,不可对空") took it until 0.2.1; a +// ground operator in reach still does. +// Run: node --test test/content/feedback7-yanyou-noair.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, checkInvariants } from '../helpers/battleHarness.js'; +import { spawnYanyou } from '../../server/sim/content/tokens.js'; + +/** 角峰 on (10,5), the 炎佑 dragon moved onto (10,6). */ +function field(seed = 4) { + const h = makeBattle({ timeLimit: 60, autoFinish: false, seed, hooks: ['damaged'], captureNoisy: true, + units: [{ uid: 1, chessId: 'chess_char_1_02_a', row: 10, col: 5 }] }); + h.step(); + const dragon = spawnYanyou(h.b, 'p1', { count: 1 })[0]; + assert.ok(dragon && dragon.isFlying, 'a flying ally'); + assert.ok(h.b.relocate(dragon, 10, 6)); + return { h, dragon, op: h.unit(1) }; +} +const still = (e) => { e.base.moveSpeed = 0; e.s.moveSpeed = 0; }; +const tough = (e) => { e.base.maxHp = 1e9; e.s.maxHp = 1e9; e.hp = 1e9; }; +/** Damage `src` dealt to `t` other than its normal attacks. */ +const taken = (h, t, src) => h.hooksOf('damaged').filter((c) => c.target === t && c.source === src && !c.dmg?.isAttack).reduce((s, c) => s + c.amount, 0); + +for (const [name, key, prep] of [ + ['高能源石虫', 'enemy_1021_bslime', null], + ['冰爆源石虫', 'enemy_1067_snslime', null], + ['卷心籽 (ignited)', 'enemy_10065_ftzlc', (e) => { e.mem.ab.ignited = true; }], +]) { + test(`${name}'s death blast (不可对空) spares 炎佑 and still hits a ground operator`, () => { + const { h, dragon, op } = field(); + const e = h.spawn(key, { pos: [10, 6] }); + still(e); + if (prep) prep(e); + h.step(); + const hp0 = dragon.hp; + h.b.dealDamage(null, e, { amount: e.hp + 10, type: 'true', tags: ['test'] }); + h.run(0.3); + assert.equal(e.alive, false); + assert.ok(taken(h, op, e) > 0, 'the ground operator is hit'); + assert.equal(taken(h, dragon, e), 0, 'the flyer is not'); + assert.equal(dragon.hp, hp0); + if (key === 'enemy_1067_snslime') { + assert.ok(op.findBuff('cold'), '寒冷 on the ground operator'); + assert.equal(dragon.findBuff('cold'), null, 'no 寒冷 on the flyer'); + } + checkInvariants(h.b); + }); +} + +test('水遁忍者\'s 漩涡形态 (不可对空) hits the ground operator beside it, never 炎佑', () => { + const { h, dragon, op } = field(6); + const e = h.spawn('enemy_10116_ymgtop', { pos: [10, 6] }); + still(e); + tough(e); + assert.ok(h.runUntil(() => taken(h, op, e) > 0, 40), 'the vortex reached the operator'); + h.run(3); + assert.equal(taken(h, dragon, e), 0); + checkInvariants(h.b); +}); + +test('“萨科塔昂首”\'s 祈祷邀约 (不可对空) slows the ground operator, not 炎佑', () => { + const { h, dragon, op } = field(7); + const e = h.spawn('enemy_10085_hllevi_2', { pos: [10, 8] }); + still(e); + tough(e); + assert.ok(h.runUntil(() => !!op.findBuff('ab:roar'), 40), 'the ground operator is invited'); + assert.equal(dragon.findBuff('ab:roar'), null); + checkInvariants(h.b); +}); + +for (const [name, key] of [['鼎沸', 'enemy_10054_cjhot'], ['“斩胄之剑”', 'enemy_9014_acstma'], ['“破胄之锤”', 'enemy_9015_acstmb']]) { + test(`${name} (不可对空) hits the ground operator beside it, never 炎佑`, () => { + const { h, dragon, op } = field(8); + const e = h.spawn(key, { pos: [10, 6] }); + still(e); + tough(e); + h.run(12); + const dealt = (t) => h.hooksOf('damaged').filter((c) => c.target === t && c.source === e).reduce((s, c) => s + c.amount, 0); + assert.ok(dealt(op) > 0, 'the ground operator is hit'); + assert.equal(dealt(dragon), 0, 'the flyer is not'); + checkInvariants(h.b); + }); +}