diff --git a/docs/SIM.md b/docs/SIM.md index 5ba0838e..101e57c0 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -269,15 +269,15 @@ a stealthed ally (隐匿, 排气格栅) only for the enemy it blocks — our ope leaves, chain / bounce jumps, 周围四格 additions, whole-column / whole-field skills — select with `targeting.js areaSelectable` (`content/enemies.js areaAllies` / `areaAlliesInTiles` / `fieldAllies`; PRTS 作战机制 §AOE伤害判定 "AOE的判定是对攻击范围内的每个可以被选中的敌人进行判定"; PRTS 异常效果 §无法选择: 隐匿, 不可选中, 无敌 and 对地规避 make -"常见的、来自不同阵营的“选择”行为" skip a unit unless the ability "无视可选性"): no 隐匿 ally unless it blocks that enemy -[ASSUMED: the blocker exception, read from 索敌, for its area abilities too], no untargetable or sleeping one, no +"常见的、来自不同阵营的“选择”行为" skip a unit unless the ability "无视可选性"): no 隐匿 ally, the one blocking that enemy included +(GitHub #97, owner 2026-10-04; the 0.1.2 [ASSUMED] that the blocker's area also hit is withdrawn — the blocked enemy's attack still lands), no untargetable or sleeping one, no airborne 起飞 one for a ground enemy; an invulnerable one still [ASSUMED, as for attacks — §21.22]. 迷彩 is not checked: splash-type damage, 中点判定 / 格子判定 effects and auras ignore it (ba.camou "无法躲避溅射类攻击"; PRTS 异常效果 迷彩 "所有光环类能力、以及涉及中点判定/格子判定的效果均不受迷彩制约") or the enemy's PRTS page says "无视迷彩" — the few sites with none of these hit it [ASSUMED] (DESIGN §22.12). A target the ability locked beforehand (an attack's target, 死亡之眼's channel, a C4, an 爆炸箭) is a direct pick — a 隐匿 it gained meanwhile does not save it (PRTS 异常效果 "'直接选中'的能力 …不受这些仅在选择时生效的异常效果制约"); only the units around it are an area selection (`targetAndArea`). An enemy's -buff aura (深池伙友卫队's field, 扎罗's 远古威慑) takes the allies `targeting.js auraSelectable` accepts — 隐匿 kept out +buff aura (深池伙友卫队's field, 扎罗's 远古威慑) takes the allies `targeting.js auraSelectable` accepts — 隐匿 kept out, the blocker included, (PRTS 作战机制 §隐匿与Buff的关系 "隐匿状态下的单位一般无法被敌方的索敌机制和Buff选择器选中为目标"), an airborne 起飞 ally not [ASSUMED, §21.22]. Until 0.1.2 they took every ally in the area (GitHub issue #32 item 6; DESIGN §22.12). Still on every ally there: the "无视无法选择 / 无视可选性" abilities above (and 远眺's death 暴露, 圆仔's facing count), 寒霜's aura @@ -316,7 +316,7 @@ excepted). 【污染秽蚀】 (萨卡兹枯朽战车's 秽蚀轰击, 同名效果不叠加", user playtest #6): a unit covered by several zones takes one tick per second (`unit.mem.pollutedAt`), so a crowd of dying 萨卡兹枯朽战士 totals 50 / s, not 50 × n. It is "可对空,无视无法选择": it also burns an airborne 起飞 ally (`ignoreSelect`), at the low-ground rate on a low tile. The other zones are no exception to 对地规避: a ground enemy's -燃烧区域 (集团军重型火炮, PRTS "碰撞不受迷彩制约,不可对空" — 迷彩 only) skips an airborne 起飞 ally and an unblocking 隐匿 +燃烧区域 (集团军重型火炮, PRTS "碰撞不受迷彩制约,不可对空" — 迷彩 only) skips an airborne 起飞 ally and a 隐匿 one; the sourceless 毒雾 of 假想敌:蚀裂 skips a 隐匿 one but still reaches an airborne one (PRTS 作战机制's "可以受到无来源的 毒雾伤害" is the stage hazard; until 0.1.2 the cloud took everyone inside). An activated 孽罪奇美拉's aura (`kitChimera`, PRTS "自身半径1.2 范围内的所有单位持续视为受到源石污染区影响,技力自然回复速度倍率-80%,每0.5秒受到50真实持续伤害(同类效果取最高)") is the same @@ -565,7 +565,7 @@ of coverage per 3 s), kept because the current wording no longer says so (feedba |---|---|---| | `stun` | cannot act / move; **a stunned operator blocks nothing** (its blocked enemies are released: taken over by another operator in contact with room, else they walk on — §1.2 Blocking) | – | | `freeze` | stun; **enemies** also RES −15 | – | -| `cold` | ASPD −30; a 2nd cold while cold ⇒ `freeze` 3 s (unless frozen-immune) | – | +| `cold` | ASPD −30; a 2nd cold while cold ⇒ `freeze` for max(remaining cold, the incoming cold after 抵抗) — PRTS 术语释义 寒冷 「持续时间取双方之中最高」 (`COLD_FREEZE_DURATION` 3 s only when neither side has a duration; unless frozen-immune). [ASSUMED] the one catalogue cold uses that 友方 sentence for an enemy-applied cold too | – | | `sleep` | 无敌且无法行动: inactive, untargetable, **takes no damage** (unless the attacker profile has `hitSleep` or the damage `ignoreSleep`), blocks nothing | – | | `slow` | moveMul 1 − value (*strongest*) | default 0.5 | | `sluggish` (停顿) | moveMul 0.2 | – | @@ -576,7 +576,7 @@ of coverage per 3 s), kept because the current wording no longer says so (feedba | `tremble` (战栗) | 被阻挡后无法进行普通攻击: no normal attack **while blocked** (abilities still fire) | – | | `palsy` (麻痹) | each stack cancels one enemy normal attack (max 3, lasts until consumed); refused by 麻痹免疫 (data `palsyImmune`) | stacks, default 1 | | `disarm` | no normal attacks | – | -| `stealth` / `reveal` | 隐匿: untargetable unless blocked (an ally: only the enemy it blocks attacks it, and an enemy's area effects and buff auras skip it unless it blocks that enemy — `targeting.js areaSelectable` / `auraSelectable`, since 0.1.2) / cancels stealth. An enemy's 隐匿 also stays off after a block: each block's end (`Battle._stealthSwitch`, every release path) switches each of its 隐匿 sources off for `STEALTH_RESTORE` (3) s — PRTS 作战机制 §隐匿 "不被阻挡的3秒后重新进入隐匿" — or the source's own "(解除阻挡N秒后恢复)" (buff `data.stealthRestore`: 0 s for 业余竞演者, 节日爵士乐手, 假想敌:骨刺, 流泪小子, 访问团强攻冠军 and 清明's veil, 1 s for the 家族灭迹人); a new block inside it lifts it again and its end restarts the window; our operators' 隐匿 / 迷彩 never lift by blocking (DESIGN §22.8). `targeting.js enemyStealthed` is the one test: the b.snap stealth bit is set only while its 隐匿 is on (drawn solid otherwise); an operator's radius area damage (`foesInRadius`) skips it too (PRTS 作战机制 §AOE伤害判定 "对攻击范围内的每个可以被选中的敌人进行判定"; until 0.1.1 the splash still hit it) | – | +| `stealth` / `reveal` | 隐匿: untargetable unless blocked (an ally: only the enemy it blocks attacks it, and an enemy's area effects and buff auras skip it even when it blocks that enemy — `targeting.js areaSelectable` / `auraSelectable`, GitHub #97, owner 2026-10-04; 0.1.2 made an exception for that blocker) / cancels stealth. An enemy's 隐匿 also stays off after a block: each block's end (`Battle._stealthSwitch`, every release path) switches each of its 隐匿 sources off for `STEALTH_RESTORE` (3) s — PRTS 作战机制 §隐匿 "不被阻挡的3秒后重新进入隐匿" — or the source's own "(解除阻挡N秒后恢复)" (buff `data.stealthRestore`: 0 s for 业余竞演者, 节日爵士乐手, 假想敌:骨刺, 流泪小子, 访问团强攻冠军 and 清明's veil, 1 s for the 家族灭迹人); a new block inside it lifts it again and its end restarts the window; our operators' 隐匿 / 迷彩 never lift by blocking (DESIGN §22.8). `targeting.js enemyStealthed` is the one test: the b.snap stealth bit is set only while its 隐匿 is on (drawn solid otherwise); an operator's radius area damage (`foesInRadius`) skips it too (PRTS 作战机制 §AOE伤害判定 "对攻击范围内的每个可以被选中的敌人进行判定"; until 0.1.1 the splash still hit it) | – | | `camou` (迷彩) | an ally's camouflage (ba.camou "不阻挡时不成为敌方普通攻击的目标(无法躲避溅射类攻击)"): like `stealth` for enemy targeting (only the enemy it blocks attacks it) — but an enemy's splash and other area effects still hit it (`areaSelectable` does not check it) — and on screen (b.snap stealth bit, `snapshot.js flagsOf`), but not 隐匿 for 隐匿-conditions (叙拉古, 家族徽章) and under its own buff keys. 忍冬 S3 (key `vulpis:camou`, until her next cast), 寒芒克洛丝 S1 | – | | `invulnerable` | ignores damage | – | | `levitate` (浮空) | stun + unblockable (unblocks enemies) + 失衡免疫 (`noDisplace`); an air unit meanwhile (`isFlying`: melee cannot hit it); **half duration on units with (current) massLevel > 3**; refused on data flyers (`motion` FLY) and units already levitated (PRTS 异常效果 "若单位数据上为飞行单位…或是持有浮空异常则Buff取消") — a 近地悬浮 enemy is WALK in its data, so it can be levitated; 浮空 is not one of the 近地悬浮 enemies' drop triggers | – | diff --git a/server/match/unite.js b/server/match/unite.js index 452ea9e0..3609486f 100644 --- a/server/match/unite.js +++ b/server/match/unite.js @@ -55,11 +55,16 @@ export function planUnite(m, results) { for (const l of r.leaked || []) { if (!l || l.counted === false) continue; if (!m.gd.enemy(l.enemyKey)) { notReentered.set(ps.playerId, (notReentered.get(ps.playerId) || 0) + 1); continue; } - // kill bounties keep paying in 联防: a 悬赏 card (mods.bountyId) or a bounty set on the SpawnSpec by content - // (copied into mods.bountyCoins by the match) + // kill bounties keep paying in 联防, and only on the card's own enemy. A split or summoned child + // never carries bountyId / bountyCoins (spawnChildren). A leak that still has the card's id but is + // some other enemy — a copy that did not go through spawnChildren — does not collect the card either + // (GitHub #67, #89-2; owner 2026-10-04: the main body only). A bounty set on the SpawnSpec by content + // is copied into mods.bountyCoins by the match and has no card to match. const bountyId = l.mods && l.mods.bountyId; const b = bountyId ? ps.bounties.find((x) => x.id === bountyId) : null; - let bounty = b && b.card && b.card.payout !== 'perfect' && Number(b.card.coin) > 0 ? { coins: Math.trunc(b.card.coin), ownerPlayerId: ps.playerId } : null; + const card = b && b.card; + let bounty = card && card.payout !== 'perfect' && Number(card.coin) > 0 && l.enemyKey === card.enemyKey + ? { coins: Math.trunc(card.coin), ownerPlayerId: ps.playerId } : null; const extra = !bountyId && l.mods ? Math.trunc(Number(l.mods.bountyCoins) || 0) : 0; if (!bounty && extra > 0) bounty = { coins: extra, ownerPlayerId: ps.playerId }; leaked.push({ enemyKey: l.enemyKey, mods: l.mods ? { ...l.mods } : null, lpr: l.lpr ?? 1, sourcePlayerId: ps.playerId, tag: l.tag ?? null, bounty }); diff --git a/server/sim/Battle.js b/server/sim/Battle.js index a518307a..17cc8c8f 100644 --- a/server/sim/Battle.js +++ b/server/sim/Battle.js @@ -1344,10 +1344,12 @@ export class Battle { } /** - * Apply a catalogue status. opts: { duration, source, value, force, refresh, point } — returns true when applied. - * Honours enemy immunities (stun/silence/sleep/frozen/levitate/feared) unless `force`. `beforeStatus` handlers may - * cancel it or change `duration` / `value`. Official rules (buffs.js STATUS): 抵抗 (the `resist` status) shortens the - * RESIST_STATUSES by its value (default half; applied after `beforeStatus`); 浮空 lasts half as long on units heavier + * Apply a catalogue status. opts: { duration, source, value, force, refresh, point, resistApplied } — returns true + * when applied. Honours enemy immunities (stun/silence/sleep/frozen/levitate/feared) unless `force`. `beforeStatus` + * handlers may cancel it or change `duration` / `value`. Official rules (buffs.js STATUS): 抵抗 (the `resist` status) + * shortens the RESIST_STATUSES by its value (default half; applied after `beforeStatus`; `resistApplied` skips that + * pass — the cold-on-cold 冻结 below already used post-抵抗 lengths). A second 寒冷 while 寒冷 remains applies 冻结 for + * max(remaining, this cold after 抵抗) (PRTS 术语释义 寒冷 「持续时间取双方之中最高」). 浮空 lasts half as long on units heavier * than LEVITATE_HALF_WEIGHT (current massLevel); 冻结's RES cut hits enemies only; 麻痹 adds stacks; "同名效果取最高" * statuses (`valued`) keep the strongest value — a weaker application only extends past the stronger one's end (it * then resumes); other statuses refresh to the longer duration. 诱导 (`attract`) walks the enemy to `point` @@ -1383,14 +1385,26 @@ export class Battle { else if (Number.isFinite(d) && d <= 0) return false; value = c.value; } - if (RESIST_STATUSES.has(key)) { + if (RESIST_STATUSES.has(key) && !opts.resistApplied) { const rv = this.resistOf(target); if (rv > 0) duration *= 1 - rv; } if (key === 'levitate' && target.s.massLevel > LEVITATE_HALF_WEIGHT) duration /= 2; if (!(duration > 0)) return false; if (key === 'cold' && target.findBuff('cold') && !(immune && immune.has('frozen'))) { - this.applyStatus(target, 'freeze', { duration: COLD_FREEZE_DURATION, source: opts.source, force: opts.force }); + // PRTS 术语释义 寒冷: 友方寒冷 pairs into 冻结, 「持续时间取双方之中最高」. `duration` is this cold after 抵抗; + // the cold already on the target keeps timeLeft. addBuff refresh 'extend' below sets that cold to the same max. + // COLD_FREEZE_DURATION is only the fallback when neither side has a duration. resistApplied: that max is already + // post-抵抗, so the freeze must not be halved again. + // [ASSUMED] one catalogue cold, so an enemy-applied second cold uses this max too. PRTS states it on the 友方 + // line only (敌方 cold becomes 冻结 when the target already has 敌方 cold or 敌方 冻结). + const prev = target.findBuff('cold'); + const spans = [prev.timeLeft, duration].filter((t) => t === Infinity || (Number.isFinite(t) && t > 0)); + const freezeFor = spans.length ? Math.max(...spans) : COLD_FREEZE_DURATION; + this.applyStatus(target, 'freeze', { + duration: freezeFor, source: opts.source, force: opts.force, + ...(spans.length ? { resistApplied: true } : {}), + }); if (!target.alive) return false; } const source = opts.source ?? null; @@ -1672,7 +1686,7 @@ export class Battle { /** * Every deployed ally within `r` of (x, y) — no selection rule: an enemy's area effect selects among them with - * targeting.js areaSelectable (content/enemies.js areaAllies: no unblocking 隐匿 ally, DESIGN §22.12). + * targeting.js areaSelectable (content/enemies.js areaAllies: no 隐匿 ally, the blocker included, GitHub #97; DESIGN §22.12). */ alliesInRadius(x, y, r, ownerId = null, { includeDevices = false } = {}) { const out = []; diff --git a/server/sim/buffs.js b/server/sim/buffs.js index aafcba12..b69b6189 100644 --- a/server/sim/buffs.js +++ b/server/sim/buffs.js @@ -55,6 +55,8 @@ export const STATUS = Object.freeze({ stun: { flags: { stun: true, noBlock: true }, immune: 'stun' }, // 冻结: 无法移动、攻击及使用技能; 敌方被冻结时,法术抗性-15 (the RES cut applies to enemies only) freeze: { flags: { freeze: true, stun: true }, mods: { resFlat: -FREEZE_RES_DOWN }, enemyOnlyMods: true, immune: 'frozen' }, + // 寒冷: ASPD −30. A second cold while this one remains applies 冻结 for max(remaining, incoming) + // (Battle.applyStatus; PRTS 术语释义 寒冷 「持续时间取双方之中最高」). cold: { flags: { cold: true }, mods: { aspd: COLD_ASPD } }, // 沉睡: 无敌且无法行动 (untargetable, takes no damage — except from `hitSleep` attackers — and blocks nothing) sleep: { flags: { sleep: true, noBlock: true }, immune: 'sleep' }, diff --git a/server/sim/constants.js b/server/sim/constants.js index 44210f81..e6229ace 100644 --- a/server/sim/constants.js +++ b/server/sim/constants.js @@ -129,7 +129,10 @@ export const PULL_ORIGIN = 0.5; export const PULL_STOP_RADIUS = 0.6708; export const PUSH_DIRECTIONAL_MIN_DIST = 0.25; -/** Freeze caused by cold on cold (DESIGN §5.3). */ +/** + * Fallback freeze when a second 寒冷 lands and neither the remaining cold nor the incoming one has a duration + * (Battle.applyStatus). A real duration uses max(remaining, incoming) — PRTS 术语释义 寒冷 「持续时间取双方之中最高」. + */ export const COLD_FREEZE_DURATION = 3; /** 浮空 (ba.levitate): the duration is halved on units heavier than this weight (massLevel). */ export const LEVITATE_HALF_WEIGHT = 3; diff --git a/server/sim/content/bosses.js b/server/sim/content/bosses.js index 271d0236..66c22709 100644 --- a/server/sim/content/bosses.js +++ b/server/sim/content/bosses.js @@ -59,7 +59,7 @@ // (a direct pick) and the ticks of a debuff already on it (【自然涌动】: a tick selects nobody). // Every area effect of a leader or part — pulses, strikes around an echo, blasts, charges and tramples, crosses, columns, // whole-field skills — selects with enemies.js areaAllies / areaAlliesInTiles / fieldAllies (targeting.js -// areaSelectable): no 隐匿 operator that does not block the unit, no untargetable or sleeping one, no 起飞 one for a +// areaSelectable): no 隐匿 operator, the one blocking the unit included (GitHub #97), no untargetable or sleeping one, no 起飞 one for a // ground unit; 迷彩 is not checked (splash-type, 中点判定 / 格子判定 or "无视迷彩" on PRTS; the rest [ASSUMED], DESIGN // §22.12). Only 【盲信之誓】 ("无视无法选择、迷彩") takes everyone on its lines. // LP effects ('lpLoss' hook + result.lpLoss) must be applied by the match (see the report of this module's owner). @@ -127,7 +127,7 @@ const PIPE_STRIKE_GAP = 0.4; const TENTACLE_STUN = 5, TENTACLE_RADIUS = 1; /** 崩坍 delay (PRTS 1 s), targets per growth stage (PRTS 2/4/8) and 物种爆发 delay (PRTS 4 s). */ const ROCKFALL_DELAY = 1, ROCKFALL_TARGETS = [2, 4, 8], DOOM_DELAY = 4; -/** 铳: minimum attack speed (PRTS "最低20") and charge hit radius (PRTS 0.35). */ +/** 铳: minimum attack speed (PRTS "最低20") and charge hit radius (PRTS 0.35 — also “碎铳之簧”'s 追逐模式 trample). */ const ASPD_FLOOR = 20, CHARGE_RADIUS = 0.35; /** 盲信之誓 link half-width (PRTS 0.5). */ const LINK_WIDTH = 0.5; @@ -334,7 +334,7 @@ export function fairOrder(b, e, list, P) { * 斩胄之剑 / 破胄之锤 (PRTS 行动方式 飞行) — and its damage 无来源, so 对地规避 does not stop it: `ignoreSelect` (an airborne * 起飞 ally in the 3×3 is stunned and hurt like the others, although the credited 胄 walks). It is still that unit's area * selection (PRTS: "令自身周围8格内的所有我方单位…" / "…无视迷彩,可对空", no 无视无法选择): areaAlliesInTiles of `by` — no - * 隐匿 ally that does not block it (掷剑 / 掷锤's "无视无法选择" is their pick of the operator they fly at, not the blast). + * 隐匿 ally, the one blocking it included (GitHub #97; 掷剑 / 掷锤's "无视无法选择" is their pick of the operator they fly at, not the blast). */ function stunBlast(b, src, by, r, c, stun, dot, dur, kind) { b.fx('explode', { x: c, y: r, r: 1.5, kind, tiles: 'box' }); @@ -617,10 +617,11 @@ function kitGun(ab, e, b) { if (s2) list.push({ cd: s2.cd, icd: s2.icd, cond: (b2) => b2.enemies.some((o) => o.alive && isSpring(o)), fire(b2, e2) { // 【末日布道】 springs dash to 铳, invulnerable, trampling operators + // PRTS “碎铳之簧” 追逐模式 "不进行普通攻击": `disarm` for the dash (until 0.1.3 it kept shooting while it ran) for (const sp of b2.enemies) { if (!sp.alive || !isSpring(sp) || !sp.mem.ab) continue; sp.mem.ab.dash = { until: b2.time + (s2.bb.dog_duration ?? 0), mul: 1 + (s2.bb.move_speed ?? 0), gun: e2, hit: new Set() }; - b2.addBuff(sp, { key: 'boss:dash', duration: s2.bb.dog_duration ?? 0, visible: true, flags: { invulnerable: true, noMove: true, unblockable: true } }); + b2.addBuff(sp, { key: 'boss:dash', duration: s2.bb.dog_duration ?? 0, visible: true, flags: { invulnerable: true, noMove: true, unblockable: true, disarm: true } }); b2.fx('dash', { x: sp.x, y: sp.y, id: sp.id, tx: e2.x, ty: e2.y }); } }, @@ -686,7 +687,8 @@ function kitSpring(ab, e) { if (!d) return; const g = d.gun && d.gun.alive ? d.gun : gun(b); const arrived = !g || stepToward(e2, g.x, g.y, e2.s.moveSpeed * d.mul * MOVE_SCALE * dt) || Math.hypot(g.x - e2.x, g.y - e2.y) < 1; - for (const u of areaAllies(b, e2, e2.x, e2.y, 0.5)) if (!d.hit.has(u)) { d.hit.add(u); hurt(b, e2, u, e2.s.atk, 'phys'); } + // 追逐模式 "对进入自身0.35半径范围内的我方单位(包括飞行单位)造成一次攻击力100%的物理普通伤害" (until 0.1.3: radius 0.5) + for (const u of areaAllies(b, e2, e2.x, e2.y, CHARGE_RADIUS)) if (!d.hit.has(u)) { d.hit.add(u); hurt(b, e2, u, e2.s.atk, 'phys'); } if (arrived || b.time >= d.until) { e2.mem.ab.dash = null; b.removeBuff(e2, 'boss:dash'); if (e2.route) e2.route.pts = null; } }, }, diff --git a/server/sim/content/enemies.js b/server/sim/content/enemies.js index ee62250f..934e9815 100644 --- a/server/sim/content/enemies.js +++ b/server/sim/content/enemies.js @@ -36,7 +36,7 @@ // (targeting.js aggroCmp — PRTS 索敌: 特殊优先级 → 仇恨值). Area effects — splash, blasts, area skills and statuses, // pulses, the zones an enemy leaves, chain / bounce jumps, 周围四格 additions, whole-column / whole-field skills — // select with areaAllies / areaAlliesInTiles / fieldAllies (targeting.js areaSelectable; PRTS 作战机制 §AOE伤害判定 -// "AOE的判定是对攻击范围内的每个可以被选中的敌人进行判定"): no 隐匿 ally that does not block the enemy, no untargetable +// "AOE的判定是对攻击范围内的每个可以被选中的敌人进行判定"): no 隐匿 ally, the one blocking the enemy included (GitHub #97), no untargetable // or sleeping one, no airborne 起飞 one for a ground enemy; 迷彩 is not checked (splash-type, 中点判定 / 格子判定 and // aura effects ignore it — ba.camou "无法躲避溅射类攻击", PRTS 异常效果 迷彩; the sites with no such reading are // [ASSUMED] in DESIGN §22.12). Buff auras select with auraAllies (auraSelectable: 隐匿 kept out, 起飞 not). A locked @@ -63,7 +63,7 @@ // gauge whose burst is the official one (termDescription ba.dt.erosion: "永久降低100点防御力并受到800点物理伤害"). // DOT — damage zones (污染秽蚀: true damage, one tick per second however many cover a unit, "可对空,无视无法选择" // — `ignoreSelect`, 起飞 / 隐匿 allies included; 燃烧区域: the enemy's area selection — PRTS 集团军重型火炮 -// "碰撞不受迷彩制约,不可对空", no 无视无法选择 —, so no 起飞 or unblocking 隐匿 ally; 假想敌:蚀裂's sourceless +// "碰撞不受迷彩制约,不可对空", no 无视无法选择 —, so no 起飞 or 隐匿 ally; 假想敌:蚀裂's sourceless // 毒雾 skips a 隐匿 ally, not a 起飞 one), bleeding (removed by healing), pulsing damage around an enemy // (area selection). // INVISIBLE — permanent `stealth` flag (engine: untargetable unless blocked or revealed). An operator's radius area @@ -72,7 +72,7 @@ // still hit it); tile selectors (enemiesInKeys) always skipped it. After a block it hides again only // STEALTH_RESTORE (3) s later — or after the "(解除阻挡N秒后恢复)" of its PRTS page (STEALTH_RESTORE_BY_KEY, // 清明's veil 0 s) — Battle._stealthSwitch / targeting.js enemyStealthed (until 0.1.2: at once). -// The mirror rule for an ally's 隐匿 — enemy area effects skip it unless it blocks the enemy — is areaAllies (since 0.1.2, DESIGN §22.12). +// The mirror rule for an ally's 隐匿 — enemy area effects skip it even when it blocks the enemy (0.1.3, GitHub #97) — is areaAllies. // REFLECTION — 折射 (ba.refraction "生效时,法术抗性+70"): RES +refracting.magic_resistance while NOT silenced // (the ability line is SILENCE-flagged: silencing turns it off); 镜膜 also gets max HP +100 % while on. // SPECIAL — mostly stats; prisoners, 穿刺手, 暴虐兵长, 镜卫, 动力装甲 … below. @@ -471,7 +471,7 @@ export function allTargets(b, e) { /** * The allies an AREA effect of enemy `src` selects within `r` of (x, y) — splash, blast, area skill / status, pulse, zone - * tick, chain or bounce jump (targeting.js areaSelectable: no unblocking 隐匿, untargetable or sleeping ally, no 起飞 one + * tick, chain or bounce jump (targeting.js areaSelectable: no 隐匿 ally, the blocker included, no untargetable or sleeping ally, no 起飞 one * for a ground `src`; 迷彩 is not checked). `src` = the enemy whose effect it is (null: none — 隐匿 still applies). A * locked target goes first through targetAndArea. Abilities PRTS marks "无视无法选择 / 无视可选性" use b.alliesInRadius with * `ignoreSelect` instead. @@ -491,7 +491,7 @@ export function fieldAllies(b, src) { } /** - * The allies within `r` of (x, y) a buff aura of enemy `src` takes (targeting.js auraSelectable: no unblocking 隐匿, + * The allies within `r` of (x, y) a buff aura of enemy `src` takes (targeting.js auraSelectable: no 隐匿 ally, the blocker included, * untargetable or sleeping ally — PRTS "隐匿状态下的单位一般无法被敌方的…Buff选择器选中"; an airborne 起飞 ally is still * taken [ASSUMED, §21.22]). 寒霜's aura, which PRTS says ignores 隐匿, keeps b.alliesInRadius (allyAura). */ @@ -554,16 +554,31 @@ export function stayRoute(e, secs = 99999) { return { motion: e.motion === 'FLY' ? 'FLY' : 'WALK', start: p, end: p, steps: [{ t: 'wait', s: secs }] }; } +/** + * Mods copied onto a split or summoned child. The parent's stat multipliers travel; a kill bounty does not + * (owner 2026-10-04: the main body alone carries it — GitHub #67, #89-2). `bountyId` is the 悬赏 card and + * `bountyCoins` is the coin copy the match writes for 联防. The parent object is left as it is. + */ +function modsWithoutBounty(mods) { + if (!mods || typeof mods !== 'object') return mods ?? null; + if (mods.bountyId == null && mods.bountyCoins == null) return mods; + const copy = { ...mods }; + delete copy.bountyId; + delete copy.bountyCoins; + return copy; +} + /** Spawn `n` enemies at the parent's position that continue its route. Returns the spawned units. */ export function spawnChildren(b, parent, key, n, opts = {}) { const out = []; const route = opts.route ?? remainingRoute(parent); const cnt = Math.max(0, Math.min(20, Math.floor(n))); + const mods = modsWithoutBounty(opts.mods ?? parent.mods ?? null); for (let i = 0; i < cnt; i++) { const off = cnt > 1 ? (i - (cnt - 1) / 2) * 0.2 : 0; const pos = opts.pos ?? [parent.y, parent.x + off]; const c = b.spawnEnemy(key, { - pos, route, mods: opts.mods ?? parent.mods ?? null, tag: opts.tag ?? null, countInTotal: opts.countInTotal, + pos, route, mods, tag: opts.tag ?? null, countInTotal: opts.countInTotal, ownerPlayerId: parent.ownerId ?? null, sourcePlayerId: parent.sourcePlayerId ?? null, }); if (c) out.push(c); @@ -701,19 +716,14 @@ const maxTargets = (n) => ({ spawn(b, e) { if (n > 1) e.profile.maxTargets = n; const ep = (el, ratio, sil = false) => ({ sil, dealt(c, b, e) { if (ratio > 0) elem(b, e, c.target, el, e.s.atk * ratio); } }); /** Status on every attack hit. */ const onHitStatus = (key, dur, value) => ({ dealt(c, b, e) { if (dur > 0) b.applyStatus(c.target, key, { duration: dur, source: e, value }); } }); -/** - * The attack becomes a hit on every ally within radius r it can select as an area (深溟巢涌者 "令攻击范围内的所有我方单位每秒 - * 受到…伤害": areaAllies — a 迷彩 ally is hit, an unblocking 隐匿 one not; the attack itself still needs a target). - */ -const hitAllInRadius = (r) => ({ before(c, b, e) { const l = areaAllies(b, e, e.x, e.y, r); if (l.length) c.targets = byPriority(e, l); } }); /** * A normal attack that splashes around its target (PRTS: "对主目标造成…普通伤害,对溅射目标造成…溅射伤害"): 碎骨's grenade, * “巨大的丑东西” / “墓碑”'s unblocked ranged attack, 烹泉 / 沏虹's attack, 集团军重型火炮's shell. Its mode is fixed when the * attack starts (`before`): `unblocked` = only an attack begun while not blocked splashes (the others are the plain melee * hit at `meleeScale`). The main target takes the engine's attack at `scale` × ATK (e.profile.atkScale, a normal attack: * on-hit effects apply); when it lands (the main hit's 'hit', before its dodge) the others around the target take - * `scale` × ATK as a splash — an area selection (areaAllies / areaAlliesInTiles: no 隐匿 ally that does not block the - * enemy, no airborne 起飞 one for a ground enemy; 迷彩 is not checked — DESIGN §22.12). `tiles` 1 = the target's tile and + * `scale` × ATK as a splash — an area selection (areaAllies / areaAlliesInTiles: no 隐匿 ally, the blocker included + * (GitHub #97), no airborne 起飞 one for a ground enemy; 迷彩 is not checked — DESIGN §22.12). `tiles` 1 = the target's tile and * its 8 neighbours (格子判定), else `radius` around the target (中点判定). `noAir`: "不可对空" (a flying ally — the 炎佑 * dragon — is skipped; without it "可溅射飞行单位"). `dodge`: the splash hits may be dodged (烹泉 / 沏虹, whose PRTS 天赋 calls * them 法术普通伤害 — a normal attack's damage); a 溅射伤害 splash cannot. `onEach(b, e, u)` runs on every unit hit (the target @@ -1369,8 +1379,37 @@ function kitTidmag(ab) { }]; } +/** 深溟巢涌者 / 富营养的巢涌者: one pulse every second (PRTS "每秒"; not its attack interval, so 攻速 changes leave it alone). */ +const NEST_PULSE_INTERVAL = 1; +const NEST_PULSE_TAG = 'nestPulse'; +/** + * 深溟巢涌者 / 富营养的巢涌者 (PRTS 天赋): "不进行普通攻击", 抵抗 (一减状态抵抗率 −0.5) and 停顿免疫; "未处于消失状态时,令攻击范围 + * 内的所有我方单位每秒受到攻击力100%的无途径法术伤害" and "每次输出伤害时,再造成攻击力5%的神经损伤" (`EpDamage.ep_damage_ratio`). + * A talent aura, not an attack: `noAttack`, so it walks on while it hurts (ai.js attackStand holds only attackers — until + * 0.1.3 the pulse was its normal attack, which made it stand for its whole 1 s clip whenever an ally was in range: + * community report 「…错误的设置了攻击时不移动导致卡在原地」, GitHub #93), needs no target and pauses while it is hidden on a + * DISAPPEAR leg (消失). The pulse is an area selection (areaAllies: a 迷彩 ally and a flying one — the 炎佑 dragon — are + * hit, a 隐匿 one is not) of radius `rangeRadius` around its centre, like 鼎沸's. [ASSUMED] the pulse can be dodged (闪避: + * PRTS names no attack type, which the site reads as 普通伤害 — the 烹泉 splash rule) and a dodged one adds no 神经. + */ function kitDsubrl(ab, e) { - return [hitAllInRadius(e.base.rangeRadius || 1.6), ep('neural', T(ab, 'EpDamage.ep_damage_ratio') ?? 0), resist(['sluggish'])]; + const ratio = T(ab, 'EpDamage.ep_damage_ratio') ?? 0; + return [resist(['sluggish']), { + spawn(b, e2) { + e2.profile.noAttack = true; + // 每次输出伤害时: every pulse that reaches the unit (not dodged, not cancelled — the old attack's `dealt` rule) + if (ratio > 0) b.on('damaged', (c) => { if (c.source === e2 && c.dmg && c.dmg.tags.includes(NEST_PULSE_TAG)) elem(b, e2, c.target, 'neural', e2.s.atk * ratio); }, { owner: e2 }); + }, + iv: NEST_PULSE_INTERVAL, + tick(b, e2) { + if (e2.hidden) return; + const r = e2.base.rangeRadius || 1.6; + const l = areaAllies(b, e2, e2.x, e2.y, r); + if (!l.length) return; + b.fx('pulse', { x: e2.x, y: e2.y, r, id: e2.id, element: 'neural' }); + for (const u of l) hurt(b, e2, u, e2.s.atk, 'arts', { canDodge: true, tags: [NEST_PULSE_TAG] }); + }, + }]; } /** 掠海漂移体 爬行模式: the stun it takes on dropping (PRTS "进入爬行模式并晕眩0.5秒"; not in its blackboard). */ @@ -1561,7 +1600,10 @@ function kitJazz(ab, e) { const r = (e.def.raw && e.def.raw.stats && e.def.raw.stats.rawRangeRadius) || 2.5; const revealed = (u) => !enemyStealthed(u); // blocked, 反隐 (its 隐匿 returns 0 s after a block) return [stealth(), { - spawn(b, e2) { e2.profile.noAttack = true; }, + // PRTS 天赋 "隐匿期间不进行普通攻击;未受隐匿影响时进入反击模式:仅进行阻挡攻击,造成100%物理伤害": its normal attack only ever + // hits its blocker (`melee`), so a revealed (反隐) unblocked one neither shoots nor stands for its attack clip (until + // 0.1.3 it shot everyone within its 2.5 range and stood still while it had a target — ai.js attackStand) + spawn(b, e2) { e2.profile.noAttack = true; e2.profile.melee = true; }, tick(b, e2) { e2.profile.noAttack = !revealed(e2); }, // 平时不攻击,失去隐匿时反击 }, s ? { // 狂欢式演奏 (PRTS 节日爵士乐手, 反击模式): "仅攻击范围内存在我方单位时可触发:锁定目标持续施法,最多持续10.6s,每0.5s对目标 @@ -1609,7 +1651,7 @@ function kitHolyGuard(ab) { tick(b, e) { const on = b.enemiesInRadius(e.x, e.y, r).some((o) => /enemy_1175_dushdo/.test(o.defId)); if (!on) return; - // a buff aura (auraAllies): no 隐匿 operator that does not block it (PRTS 作战机制 §隐匿与Buff的关系 — no 无视 note on + // a buff aura (auraAllies): no 隐匿 operator, the blocker included (PRTS 作战机制 §隐匿与Buff的关系 — no 无视 note on // its page), but an airborne 起飞 one still [ASSUMED, as §21.22's ground-enemy auras] for (const u of auraAllies(b, e, e.x, e.y, r)) auraBuff(b, u, 'ab:forceField', 0.5, { aspd }, null, true); }, @@ -2032,7 +2074,7 @@ function kitLeaderMisc(key, ab, e) { // PRTS 碎骨 天赋: "不会攻击飞行单位"; below half HP ATK +50 %; "未被阻挡时发射榴弹对目标及其周围八格的我方单位造成相当于攻击力 // 26%的物理伤害,并令其在5秒内防御力下降50%" ("榴弹对主目标造成物理普通伤害,对溅射目标造成物理溅射伤害,可溅射飞行单位") — the // 26 % / 5 s / 3×3 are PRTS's, the −50 % is the blackboard's defdown.def. A blocked attack is its plain melee hit. The - // splash is an area selection: a 隐匿 operator next to the target that does not block it is spared (GitHub issue #32 + // splash is an area selection: a 隐匿 operator is spared, the one blocking this enemy included (GitHub #97; #32 // item 6, DESIGN §22.12). Community report #14 (0.1.2): the grenade used to be a full attack + 100 % on 4 neighbours. const dd = -(T(ab, 'defdown.def') ?? 0); return [lowHpBuff(T(ab, 'atkup.hp_ratio') ?? 0.5, { atkPct: T(ab, 'atkup.atk') ?? 0 }), noAirTargets(), @@ -2478,7 +2520,7 @@ function kitWolfLord(ab) { const P = { form2: false, caught: 0, waitUntil: -Infinity, cast: null }; const caught = (b) => b.allies().some((u) => u.findBuff('ab:fearCage')); const cure = (b) => { for (const u of b.allies()) { const d = u.findBuff('ab:fearCage'); if (d) b.removeBuff(u, d); } }; - // 【远古威慑】 (a buff aura, auraAllies): no 隐匿 operator that does not block it, an airborne 起飞 one still — as 深池伙友卫队's + // 【远古威慑】 (a buff aura, auraAllies): no 隐匿 operator, the blocker included, an airborne 起飞 one still — as 深池伙友卫队's const awe = (b, e) => { for (const u of auraAllies(b, e, e.x, e.y, WOLF_AWE_RADIUS)) auraBuff(b, u, 'ab:ancientAwe', 0.2, { aspd: WOLF_AWE_ASPD }, null, true); }; return [{ spawn(b, e, a, ab2) { @@ -2673,7 +2715,7 @@ export const KITS = Object.freeze({ enemy_1389_winbab_2: kitStealth, // 访问团强攻冠军 · stealth (供暖器 priority n/a) enemy_1404_msnip: kitCrossbow, // 重弩突袭者 · stealth + 直击 (row/column bolt: arts + stun, reveals itself) enemy_10031_cnvsld: kitStealth, // 业余竞演者 · stealth - enemy_10034_cnvsax: kitJazz, // 节日爵士乐手 · stealth, attacks only when revealed; channel on one target (arts + burn) + enemy_10034_cnvsax: kitJazz, // 节日爵士乐手 · stealth; revealed: attacks its blocker only; channel on one target (arts + burn) enemy_10042_prtrop: kitStealth, // 架桥船工 · stealth (bridges n/a) enemy_10042_prtrop_2: kitStealth, // 扶桥老手 · stealth enemy_9008_acbunn: kitBoneSpike, // 假想敌:骨刺 · stealth; hits 3 targets while stealthed @@ -2735,7 +2777,7 @@ export const KITS = Object.freeze({ enemy_10067_ftsjc: kitFlameVine, // 灼藤 · ATK ramps until the 1st attack (3×3 arts + burn), burn on hit, ignites 卷心籽 // --- DOT 持续 - enemy_1234_dsubrl: kitDsubrl, // 深溟巢涌者 · pulse hits every ally in range + neural; 抵抗, immune 停顿 + enemy_1234_dsubrl: kitDsubrl, // 深溟巢涌者 · no attack: an arts pulse on every ally in range each second + neural; 抵抗, immune 停顿 enemy_1234_dsubrl_2: kitDsubrl, // 富营养的巢涌者 · same enemy_1267_nhpbr: kitPolluted, // 萨卡兹枯朽战士 · death: 污染秽蚀 zone (50 / 25 true per second) enemy_1267_nhpbr_2: kitPolluted, // 萨卡兹枯朽战士组长 · same @@ -2989,10 +3031,16 @@ export const KITS = Object.freeze({ sil: true, cd: ab.sk.SwitchModeTrigger ? ab.sk.SwitchModeTrigger.cd : 60, icd: ab.sk.SwitchModeTrigger ? ab.sk.SwitchModeTrigger.icd : 10, 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 - tick(b, e, a) { if (!(a.until > b.time)) 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'); }, + // "每秒对半径1.0范围内的所有我方单位造成…(无视迷彩,不可对空),自身受阻止攻击类异常效果影响期间无法造成此伤害": an area + // selection, skipped while 晕眩 / 冻结 / 浮空 (flag stun), 沉睡 or 缴械 hold it (PRTS 异常效果 §阻止攻击; until 0.1.3 it spun on) + 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'); + }, }; - return [spin, unbalanced((b, e) => { if (spin.until > b.time) { spin.until = 0; b.fx('phase', { x: e.x, y: e.y, id: e.id, kind: 'spinStop' }); } })]; + // 漩涡形态 "不进行普通攻击" (PRTS 天赋): no blocked attack while it spins (until 0.1.3 its blocker took both) + const noAtk = { tick(b, e) { e.profile.noAttack = spin.until > b.time; } }; + return [spin, noAtk, unbalanced((b, e) => { if (spin.until > b.time) { spin.until = 0; b.fx('phase', { x: e.x, y: e.y, id: e.id, kind: 'spinStop' }); } })]; }, enemy_10118_ymgprc: (ab) => { // 澪 · double hits; 寒晖: every 4th attack (spCost 3) is a ×1.5 double hit const scale = (ab.sk.PowerAttack && ab.sk.PowerAttack.bb.atk_scale) || 1; @@ -3067,9 +3115,12 @@ export const KITS = Object.freeze({ b.fx('explode', { x: e.x, y: e.y, r: 0.4, kind: 'wallHit', id: e.id }); hurt(b, null, e, T(ab, 'hitWall.value') ?? 0, 'true', { tags: ['wallHit'] }); })], - enemy_10141_xdpeng_2: (ab) => [unbalanced((b, e, a) => { // 拥霜羽兽 · unbalanced once ⇒ drops its egg: faster, unblockable + // 拥霜羽兽 · PRTS 天赋 "不会攻击飞行单位"; unbalanced once ⇒ 失去蛋的模式 "不进行普通攻击,不可阻挡,移动速度最终提升至200%" (until + // 0.1.3 it kept its ranged attack without the egg and stood for each attack clip — ai.js attackStand —, and shot the 炎佑 dragon) + enemy_10141_xdpeng_2: (ab) => [noAirTargets(), unbalanced((b, e, a) => { if (a.done) return; a.done = true; + e.profile.noAttack = true; b.addBuff(e, { key: 'ab:noEgg', persist: true, visible: true, flags: { unblockable: true }, mods: { moveMul: 1 + (T(ab, 'speed.move_speed') ?? 0) } }); })], }); diff --git a/server/sim/targeting.js b/server/sim/targeting.js index 65f406ab..286cb695 100644 --- a/server/sim/targeting.js +++ b/server/sim/targeting.js @@ -11,8 +11,8 @@ // (3) higher enemy taunt, (4) least remaining path distance to the goal, (5) earliest spawned. Air units // (Unit.isFlying: FLY, 近地悬浮, 浮空) need a profile that can hit them (`canHitFly`, never `groundOnly`). Enemy // priority: sortAllyTargets. 起飞 (an ally's flag `liftoff`) = 对地规避: no ground enemy selects it (evadesGround). -// An enemy's area effects select allies with areaSelectable, its buff auras with auraSelectable (no unblocking 隐匿 -// ally; 迷彩 is not checked — DESIGN §22.12). +// An enemy's area effects select allies with areaSelectable, its buff auras with auraSelectable (no 隐匿 ally, the one +// blocking the source included — GitHub #97; 迷彩 is not checked — DESIGN §22.12). import { COLS, ROWS } from './constants.js'; import { normDir, rotateOffset } from './dir.js'; @@ -112,9 +112,10 @@ export function canTargetAlly(e, a, ranged) { * 任何敌方的能力索敌选中"; PRTS 异常效果 §无法选择: with 隐匿, 不可选中, 无敌, 塔不可选中 or 对地规避 "常见的、来自不同阵营的 * “选择”行为将无视这些单位进行(如同范围内不存在这个单位)", and the abilities PRTS marks "无视可选性" are those that skip * that check. So not: - * - a 隐匿 ally (flag `stealth`: 伪装服, 叙拉古 6, 伊内丝 S2, the 排气格栅 tile) unless it blocks `src` — a blocked enemy - * attacks its blocker whatever its selectability (PRTS 作战机制 "因为“阻挡优先级最高”的效果,敌人会无视一切可选性对该干员 - * 进行攻击"; read for its area abilities too [ASSUMED]); a dead `src` (death blasts) blocks nobody; + * - a 隐匿 ally (flag `stealth`: 伪装服, 叙拉古 6, 伊内丝 S2, the 排气格栅 tile), even when it blocks `src`. The blocked + * enemy still attacks that blocker (canTargetAlly; PRTS 作战机制 "因为“阻挡优先级最高”的效果,敌人会无视一切可选性对该干员 + * 进行攻击"). Its splash, blast, zone and aura do not (GitHub #97, owner 2026-10-04; the 0.1.2 [ASSUMED] that the + * blocker's area also hit is withdrawn). A dead `src` (death blasts) blocks nobody; * - an untargetable (不可选中) or sleeping (沉睡 = 无敌 + 无法行动) ally; * - an airborne 起飞 ally when `src` walks (evadesGround — the same refusal the damage pipeline makes). * An invulnerable (`invulnerable`, 无敌) ally is still selected, as by canTargetAlly — a known deviation from 无法选择 @@ -134,15 +135,15 @@ export function areaSelectable(src, a) { * May a BUFF AURA of enemy-side `src` (a 光环 refreshed on whoever stands in it — 深池伙友卫队's force field, 扎罗's * 远古威慑; content/enemies.js auraAllies) take ally `a`? PRTS 作战机制 §隐匿与Buff的关系 "隐匿状态下的单位一般无法被敌方的 * 索敌机制和Buff选择器选中为目标", "目前明日方舟中使用能选中隐匿状态单位的Buff效果一定是无视隐匿状态起作用的" (its example: - * 寒霜's 攻速下降 Debuff — content/enemies.js allyAura keeps that one on every ally): no 隐匿 ally unless it blocks `src`, - * no untargetable or sleeping one; 迷彩 does not protect ("所有光环类能力…均不受迷彩制约"). Unlike areaSelectable it does not + * 寒霜's 攻速下降 Debuff — content/enemies.js allyAura keeps that one on every ally): no 隐匿 ally, the one blocking `src` + * included (GitHub #97), no untargetable or sleeping one; 迷彩 does not protect ("所有光环类能力…均不受迷彩制约"). Unlike areaSelectable it does not * apply 对地规避: a ground enemy's aura still reaches an airborne 起飞 ally [ASSUMED, DESIGN §21.20 / §21.22]. */ export function auraSelectable(src, a) { if (!a || !a.alive || !a.deployed || a.hidden || a.kind === 'device') return false; const f = a.s.flags; - if (f.untargetable || f.sleep) return false; - return !(f.stealth && !(src && src.alive && src.blockedBy === a)); + if (f.untargetable || f.sleep || f.stealth) return false; + return true; } /** diff --git a/test/content/enemies_bosses.test.js b/test/content/enemies_bosses.test.js index d10f37bc..b00d6b80 100644 --- a/test/content/enemies_bosses.test.js +++ b/test/content/enemies_bosses.test.js @@ -159,6 +159,18 @@ test(`${nm('enemy_10034_cnvsax')}: never attacks while stealthed; once blocked i assert.ok(w.elem.burn > 0, '狂欢式演奏 burns its target (the blocker)'); }); +test(`${nm('enemy_10034_cnvsax')}: revealed (反隐) and not blocked it makes no normal attack and walks on — 反击模式 "仅进行阻挡攻击"`, () => { + const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 6 }] }); + h.step(); + h.b.addBuff(h.unit('t_wall'), { key: 'test:noBlock', persist: true, flags: { noBlock: true } }); + const e = put(h, 'enemy_10034_cnvsax', [9, 8], { move: true, route: { motion: 'WALK', start: [9, 8], end: [9, 1], checkpoints: [] } }); + h.b.addBuff(e, { key: 'test:reveal', persist: true, flags: { reveal: true } }); + const x0 = e.x; + h.run(4); + assert.equal(e.stats.attacks, 0, 'no ranged attack (until 0.1.3 it shot the wall and stood still)'); + approx(x0 - e.x, 4 * e.s.moveSpeed * 0.5, 0.02, 'walked the whole 4 s'); +}); + test(`${nm('enemy_9008_acbunn')}: attacks several targets at once while stealthed`, () => { const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 6 }, { chessId: 't_mage', row: 11, col: 6 }, { chessId: 't_blade', row: 10, col: 7 }], captureNoisy: true, hooks: ['attack'] }); h.step(); @@ -585,8 +597,55 @@ for (const key of ['enemy_1234_dsubrl', 'enemy_1234_dsubrl_2']) { approx(e.findBuff('stun').timeLeft, 2); assert.equal(h.b.applyStatus(e, 'sluggish', { duration: 4 }), false); }); + + // community report 「有个持续造成范围伤害的海嗣敌人错误的设置了攻击时不移动导致卡在原地」: PRTS 天赋 "不进行普通攻击", "未处于消失状态 + // 时,令攻击范围内的所有我方单位每秒受到攻击力100%的无途径法术伤害", "每次输出伤害时,再造成攻击力5%的神经损伤" + test(`${nm(key)}: no normal attack — walks on past an unblocking operator while it pulses 100 % ATK arts + 5 % neural each second; no pulse while hidden`, () => { + const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 6 }], hooks: ['damaged'], captureNoisy: true }); + h.step(); + const g = h.unit('t_gun'); + h.b.addBuff(g, { key: 'test:noBlock', persist: true, flags: { noBlock: true } }); + g.profile.noAttack = true; + const e = put(h, key, [9, 8], { move: true, route: { motion: 'WALK', start: [9, 8], end: [9, 1], checkpoints: [] } }); + const x0 = e.x; + h.run(4); + assert.ok(e.profile.noAttack && e.stats.attacks === 0, 'never attacks'); + approx(x0 - e.x, 4 * e.s.moveSpeed * 0.5, 0.02, 'walked the whole 4 s (MOVE_SCALE 0.5) with the operator in range'); + const mine = h.hooksOf('damaged').filter((c) => c.source === e && c.target === g); + const arts = mine.filter((c) => c.dmg.type === 'arts'); + assert.ok(arts.length >= 3 && arts.every((c) => !c.dmg.isAttack && c.dmg.tags.includes('nestPulse')), `pulses, not attacks (${arts.length})`); + approx(arts[0].dmg.amount, e.s.atk, 1e-9, '100 % ATK'); + const neural = mine.filter((c) => c.dmg.type === 'element'); + assert.equal(neural.length, arts.length, 'one 神经 per pulse'); + approx(neural[0].dmg.amount, e.s.atk * tb(key, 'EpDamage.ep_damage_ratio'), 1e-9, '5 % ATK 神经'); + const times = arts.map((c) => c.t ?? null).filter((t) => t != null); + if (times.length > 1) approx(times[1] - times[0], 1, 1e-6, 'one per second'); + // 消失 (a DISAPPEAR leg): no pulse + const n0 = arts.length; + h.b._setHidden(e, true); + e.x = g.x + 1; e.y = g.y; + h.run(2.05); + assert.equal(h.hooksOf('damaged').filter((c) => c.source === e && c.dmg.type === 'arts').length, n0, 'hidden: no pulse'); + }); } +test('GitHub #93: 深溟巢涌者 keeps walking with the 炎佑 dragon in range; only its pulse (no attack) reaches the dragon', () => { + const h = makeBattle({ seed: 7, autoFinish: false, timeLimit: 120, defs: { chess: { t_wall: WALL('t_wall') } }, kits: { t_wall: NOATK }, + units: [{ chessId: 't_wall', row: 12, col: 1 }], hooks: ['damaged'], captureNoisy: true }); + h.step(); + const [y] = spawnYanyou(h.b, 'p1', { atk: 0, hp: 50000 }); + assert.ok(y && y.isFlying, 'a flying ally'); + const e = h.spawn('enemy_1234_dsubrl', { pos: [Math.round(y.y), Math.round(y.x) + 2], routeIndex: 0, mods: { speedMul: 1 }, + route: { motion: 'WALK', start: [Math.round(y.y), Math.round(y.x) + 2], end: [Math.round(y.y), 0], checkpoints: [] } }); + const x0 = e.x; + h.run(5); + assert.equal(e.stats.attacks, 0, 'no normal attack'); + assert.ok(x0 - e.x >= 5 * e.s.moveSpeed * 0.5 - 0.05, `walked on (${(x0 - e.x).toFixed(2)} tiles in 5 s)`); + const onDragon = h.hooksOf('damaged').filter((c) => c.source === e && c.target === y); + assert.ok(onDragon.length > 0, 'the dragon in range is pulsed'); + assert.ok(onDragon.every((c) => !c.dmg.isAttack && (c.dmg.type === 'element' || c.dmg.tags.includes('nestPulse'))), 'pulse and its 神经 only'); +}); + for (const key of ['enemy_1267_nhpbr', 'enemy_1267_nhpbr_2']) { test(`${nm(key)}: death releases 污染秽蚀 — ground allies within ${tb(key, 'PollutedDie.projectile_range')} lose ${tb(key, 'PollutedDie.polluted_damage_low')} HP/s`, () => { const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 6 }] }); @@ -1277,6 +1336,24 @@ test(`${nm('enemy_10116_ymgtop')}: spinning phase deals ATK×${tb('enemy_10116_y assert.equal(w.stats.taken, t0, 'no more spin damage'); }); +test(`${nm('enemy_10116_ymgtop')}: 漩涡形态 — no normal attack on its blocker while it spins; no spin damage while stunned (阻止攻击)`, () => { + const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 7 }] }); + h.step(); + const e = put(h, 'enemy_10116_ymgtop', [10, 7]); // blocked by the wall + const icd = skb('enemy_10116_ymgtop', 'SwitchModeTrigger').initCooldown; + h.run(icd - 1); + assert.ok(e.blockedBy && e.stats.attacks > 0, 'its blocked attack before the spin (初始形态 仅进行阻挡攻击)'); + h.run(1.5); + const a0 = e.stats.attacks; + h.run(4); + assert.ok(e.profile.noAttack && e.stats.attacks === a0, `no normal attack while spinning (${e.stats.attacks - a0})`); + const w = h.unit('t_wall'); + h.b.applyStatus(e, 'stun', { duration: 3, force: true }); + const t0 = w.stats.taken; + h.run(2.5); + assert.equal(w.stats.taken, t0, 'stunned: no spin damage'); +}); + test(`${nm('enemy_10118_ymgprc')}: double hits; after ${skb('enemy_10118_ymgprc', 'PowerAttack').spCost} attacks the next double hit is ×${skb('enemy_10118_ymgprc', 'PowerAttack').bb.atk_scale}`, () => { const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }], captureNoisy: true, hooks: ['damaged'] }); h.step(); @@ -1797,6 +1874,8 @@ test('失衡: 弧光锋卫 bleeds per tile pushed; 冒失的小弟 is stunned; approx(statuses(h, g.id, 'stun')[0].duration, tb('enemy_10112_ymgds', 'StunAfterUnbalance.stun')); assert.ok(p.s.flags.unblockable); assert.equal(p.findBuff('ab:noEgg').mods.moveMul, 1 + tb('enemy_10141_xdpeng_2', 'speed.move_speed')); + assert.ok(p.profile.noAttack, '拥霜羽兽 失去蛋的模式: 不进行普通攻击 (no attack, so no stand for its clip)'); + assert.equal(typeof p.profile.canTarget === 'function' && p.profile.canTarget({ isFlying: true }), false, '拥霜羽兽: 不会攻击飞行单位'); // 雪孩子: pushed into high ground (row 12 col 2 is 'h' on the flat stage) ⇒ hitWall.value const sn = put(h, 'enemy_10138_xdsnow', [10, 3]); const sn2 = put(h, 'enemy_10138_xdsnow', [11, 6]); @@ -2374,6 +2453,7 @@ test('假想敌:铳 (隐秘核心): damage ×0.2 while springs live; 盲信之 const s2 = W.act1autochess_h08_02.overrides.enemy_9017_achunt_2.skills.find((s) => s.prefabKey === '2'); h.run(s2.initCooldown - h.b.time + 0.2); assert.ok(sp.s.flags.invulnerable && sp.mem.ab.dash); + assert.ok(sp.s.flags.disarm, '追逐模式: 不进行普通攻击'); }); test('“碎铳之簧” 法术护盾 (9018): barrier absorbs arts, physical ×0.1 with a counter; damage passes to 铳', () => { diff --git a/test/sim/combat.test.js b/test/sim/combat.test.js index b7085e9c..674b5d85 100644 --- a/test/sim/combat.test.js +++ b/test/sim/combat.test.js @@ -55,7 +55,7 @@ test('stunned operator neither attacks nor casts but keeps its natural SP recove assert.equal(u.skill.activations, 0, 'silenced: no cast'); }); -test('cold on cold ⇒ freeze 3 s (res −15), frozen-immune enemies only get cold', () => { +test('cold on cold ⇒ freeze for the longer cold (res −15), not a fixed 3 s; frozen-immune enemies only get cold', () => { const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy({ res: 30 }), enemy_icy: enemyRec({ key: 'enemy_icy', speed: 0, hp: 1e6, immunities: { frozen: true } }) } }, enemies: [{ key: 'enemy_dummy', pos: [11, 8] }, { key: 'enemy_icy', pos: [10, 8] }], content: 'none', @@ -70,8 +70,11 @@ test('cold on cold ⇒ freeze 3 s (res −15), frozen-immune enemies only get co assert.equal(e.s.flags.freeze, true); assert.equal(e.s.flags.stun, true); assert.equal(e.s.res, 15); + assert.ok(Math.abs(e.findBuff('freeze').timeLeft - 5) < 1e-6, 'freeze lasts the longer cold (5 s), not 3 s'); h.run(3.1); - assert.ok(!e.s.flags.freeze, 'freeze lasts 3 s'); + assert.ok(e.s.flags.freeze, 'still frozen past the old fixed 3 s'); + h.run(2); + assert.ok(!e.s.flags.freeze, 'freeze ends with that cold'); h.b.applyStatus(icy, 'cold', { duration: 5 }); h.b.applyStatus(icy, 'cold', { duration: 5 }); assert.ok(!icy.s.flags.freeze); diff --git a/test/sim/feedback2-stealth-splash.test.js b/test/sim/feedback2-stealth-splash.test.js index 79f7908f..74b5c7b2 100644 --- a/test/sim/feedback2-stealth-splash.test.js +++ b/test/sim/feedback2-stealth-splash.test.js @@ -12,10 +12,11 @@ // attack; a normal attack on every operator in range skips 迷彩 too). // Cause: the enemy side's area effects (content/enemies.js, content/bosses.js) took every ally in the area; only the // operator side skipped an unblocked 隐匿 enemy (Battle.foesInRadius, 0.1.1). -// Now: targeting.js areaSelectable / enemies.js areaAllies, areaAlliesInTiles, fieldAllies — no 隐匿 ally unless it blocks -// the enemy, no untargetable or sleeping one, no airborne 起飞 one for a ground enemy; 迷彩 is not checked. Buff auras: +// Now: targeting.js areaSelectable / enemies.js areaAllies, areaAlliesInTiles, fieldAllies — no 隐匿 ally, even the one +// blocking the source (GitHub #97, owner 2026-10-04; the blocked enemy's own attack still lands via canTargetAlly), +// no untargetable or sleeping one, no airborne 起飞 one for a ground enemy; 迷彩 is not checked. Buff auras: // auraSelectable / auraAllies (the same without 对地规避). A locked target is hit as a direct pick (targetAndArea). -// (DESIGN §22.12.) +// (DESIGN §22.12 recorded the 0.1.2 blocker exception; §23 withdraws it for areas and auras.) import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -54,7 +55,7 @@ function countFx(h, kind) { return n; } -test('areaSelectable: no 隐匿 ally unless it blocks the source, no untargetable / sleeping one, no 起飞 one for a ground source; 迷彩 is selected', () => { +test('areaSelectable: no 隐匿 ally even when it blocks the source, no untargetable / sleeping one, no 起飞 one for a ground source; 迷彩 is selected', () => { const h = makeBattle({ seed: 2, autoFinish: false, timeLimit: 60, defs: { enemies: { enemy_g: enemyRec({ key: 'enemy_g', hp: 1e7, speed: 0 }), enemy_f: enemyRec({ key: 'enemy_f', hp: 1e7, speed: 0, motion: 'FLY' }) } }, @@ -68,15 +69,15 @@ test('areaSelectable: no 隐匿 ally unless it blocks the source, no untargetabl assert.ok(f.isFlying && !g.isFlying); for (const u of [jf, gm, hd]) assert.ok(areaSelectable(g, u) && areaSelectable(f, u) && areaSelectable(null, u), `${u.def.name}: plain ⇒ selected`); stealth(h, jf); stealth(h, gm); - assert.equal(areaSelectable(g, jf), true, '隐匿 but blocking the source: selected'); + assert.equal(areaSelectable(g, jf), false, '隐匿 blocker: an area still skips it'); assert.equal(areaSelectable(f, jf), false, '隐匿, blocking another enemy: not selected'); assert.equal(areaSelectable(g, gm), false, '隐匿, not blocking: not selected'); assert.equal(areaSelectable(null, gm), false, '隐匿 vs an effect with no selecting enemy'); - assert.deepEqual(areaAllies(h.b, g, g.x, g.y, 1.01).map((u) => u.def.name).sort(), [jf, hd].map((u) => u.def.name).sort(), 'areaAllies: the 隐匿 bystander is left out'); + assert.deepEqual(areaAllies(h.b, g, g.x, g.y, 1.01).map((u) => u.def.name).sort(), [hd.def.name], 'areaAllies: the 隐匿 blocker and the 隐匿 bystander are left out'); camou(h, hd); assert.equal(areaSelectable(g, hd), true, '迷彩: still selected by an area effect ("无法躲避溅射类攻击")'); assert.equal(auraSelectable(g, gm), false, 'a buff aura: 隐匿, not blocking — not taken'); - assert.equal(auraSelectable(g, jf), true, 'a buff aura: 隐匿 blocker — taken'); + assert.equal(auraSelectable(g, jf), false, 'a buff aura: 隐匿 blocker — not taken'); h.b.addBuff(gm, { key: 'test:liftoff', persist: true, flags: { liftoff: true } }); h.b.removeBuff(gm, 'stealth'); assert.equal(areaSelectable(g, gm), false, '起飞 vs a ground source (对地规避)'); @@ -140,7 +141,7 @@ test('#32.6 碎骨: 伪装服\'s 隐匿 (its carrier\'s first damage) spares the done(r.h); }); -test('#32.6 “庞贝”\'s self-blast while blocked hits its 隐匿 blocker and a 迷彩 bystander, not a 隐匿 bystander', REAL, () => { +test('#32.6 “庞贝”\'s self-blast while blocked misses its 隐匿 blocker and a 隐匿 bystander, and hits a 迷彩 bystander', REAL, () => { const h = makeBattle({ seed: 3, timeLimit: 120, autoFinish: false, units: [{ chessId: JF, row: 9, col: 6, dir: 'RIGHT' }, { chessId: GM, row: 10, col: 6, dir: 'RIGHT' }, { chessId: HD, row: 9, col: 7, dir: 'RIGHT' }], @@ -155,7 +156,8 @@ test('#32.6 “庞贝”\'s self-blast while blocked hits its 隐匿 blocker and assert.equal(p.blockedBy, jf, 'blocked by the 隐匿 角峰'); assert.equal(blasts.v, 1, 'one self-blast in 12 s'); const blast = (u) => log.filter((x) => x.target === u && x.skill && !x.attack).length; - assert.equal(blast(jf), 1, '隐匿 blocker: hit (a blocked enemy ignores its blocker\'s selectability)'); + assert.equal(blast(jf), 0, '隐匿 blocker: the blocked enemy\'s blast does not select it'); + assert.ok(log.some((x) => x.target === jf && x.attack), 'the blocked enemy\'s own attack still hits that 隐匿 blocker'); assert.equal(blast(hd), 1, '迷彩 bystander: hit'); assert.equal(blast(gm), 0, '隐匿 bystander (distance 1, not blocking): spared'); done(h); @@ -328,7 +330,8 @@ test('#32.6 review: the 斩胄之剑 / 破胄之锤 hover attack skips 隐匿 an assert.ok(log.some((x) => x.target === h.unit(HD) && !x.attack), '迷彩 红豆: shot inside the zone (in 0.1.1 never)'); done(h); } - // 深溟巢涌者: its pulse rides on its attack — with the plain 古米 in range the 迷彩 红豆 is hit by every pulse; alone it is never + // 深溟巢涌者: its pulse is a talent aura (no attack, 0.1.3) — the 迷彩 红豆 is hit by every pulse, with the plain 古米 in range + // or alone (until 0.1.3 the pulse rode on its attack, which needed a target: alone it was never hit) for (const alone of [false, true]) { const units = [{ chessId: HD, row: 9, col: 6, dir: 'RIGHT' }]; if (!alone) units.push({ chessId: GM, row: 10, col: 7, dir: 'RIGHT' }); @@ -337,9 +340,10 @@ test('#32.6 review: the 斩胄之剑 / 破胄之锤 hover attack skips 隐匿 an camou(h, h.unit(HD)); const log = damageLog(h, 'enemy_1234_dsubrl'); h.run(10); - const on = (id) => log.filter((x) => x.target === h.unit(id) && x.attack).length; - if (alone) assert.equal(on(HD), 0, '迷彩 alone in range: no attack, so no pulse [the engine attack needs a target]'); - else { assert.ok(on(GM) >= 5, `plain 古米 pulsed (${on(GM)})`); assert.equal(on(HD), on(GM), '迷彩 红豆: every pulse too (in 0.1.1 none)'); } + const on = (id) => log.filter((x) => x.target === h.unit(id) && x.tags.includes('nestPulse')).length; + assert.equal(log.filter((x) => x.attack).length, 0, 'no normal attack'); + assert.ok(on(HD) >= 9, `迷彩 红豆 pulsed every second (${on(HD)}; alone: ${alone})`); + if (!alone) assert.equal(on(HD), on(GM), '迷彩 红豆: every pulse the plain 古米 takes'); done(h); } // “萨科塔昂首”'s 【祈祷邀约】 (whole field, "无视迷彩", no 无视无法选择): 角峰 slowed, the 隐匿 古米 not diff --git a/test/sim/feedback3-bounty-children.test.js b/test/sim/feedback3-bounty-children.test.js new file mode 100644 index 00000000..8cae86e0 --- /dev/null +++ b/test/sim/feedback3-bounty-children.test.js @@ -0,0 +1,144 @@ +// GitHub #67 / #89-2: a split or summoned child must not carry the kill bounty. The main body does. +// Owner 2026-10-04: the bounty stays on the body only. +// +// spawnChildren used to copy parent.mods, bountyId and bountyCoins included, so a leaked child paid the +// card again in 联防 (planUnite reads those fields). planUnite also paid any leak whose bountyId matched a +// card, even when the enemy was not the card's enemy. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec } from '../helpers/battleHarness.js'; +import { spawnChildren } from '../../server/sim/content/enemies.js'; +import { planUnite } from '../../server/match/unite.js'; +import { hasGeneratedData } from '../../server/sim/simdata.js'; + +const REAL = { skip: !hasGeneratedData() }; + +function parentBattle() { + return makeBattle({ + content: 'none', autoFinish: false, timeLimit: 30, + defs: { + enemies: { + enemy_parent: enemyRec({ key: 'enemy_parent', hp: 100, speed: 0 }), + enemy_child: enemyRec({ key: 'enemy_child', hp: 100, speed: 0 }), + }, + }, + enemies: [{ + key: 'enemy_parent', pos: [10, 6], + mods: { hpMul: 2, atkMul: 1.5, bountyId: 'b1', bountyCoins: 8 }, + bounty: { coins: 8, ownerPlayerId: 'p1' }, + }], + }); +} + +const coinsOf = (h) => h.eventsOf('bounty').reduce((s, e) => s + e[2], 0); + +test('spawnChildren keeps the stat mods and drops bountyId and bountyCoins', () => { + const h = parentBattle(); + h.step(); + const parent = h.enemy('enemy_parent'); + const kids = spawnChildren(h.b, parent, 'enemy_child', 2); + assert.equal(kids.length, 2); + assert.equal(parent.mods.bountyId, 'b1', 'the body keeps its card id'); + assert.equal(parent.mods.bountyCoins, 8); + for (const c of kids) { + assert.equal(c.mods.hpMul, 2); + assert.equal(c.mods.atkMul, 1.5); + assert.equal(c.mods.bountyId, undefined); + assert.equal(c.mods.bountyCoins, undefined); + assert.equal(c.bounty, null, 'kill payout reads unit.bounty, which a child does not get'); + assert.notEqual(c.mods, parent.mods); + } + const explicit = spawnChildren(h.b, parent, 'enemy_child', 1, { mods: { bountyId: 'b9', bountyCoins: 3, defMul: 4 } }); + assert.equal(explicit[0].mods.defMul, 4); + assert.equal(explicit[0].mods.bountyId, undefined); + assert.equal(explicit[0].mods.bountyCoins, undefined); + assert.equal(parent.mods.bountyId, 'b1', 'an explicit child mods object is not the parent\'s'); + // opts.mods null falls through to the parent, then the bounty fields are stripped + const fallen = spawnChildren(h.b, parent, 'enemy_child', 1, { mods: null }); + assert.equal(fallen[0].mods.hpMul, 2); + assert.equal(fallen[0].mods.bountyId, undefined); +}); + +test('killing the body pays its bounty once; killing the children pays nothing', () => { + const h = parentBattle(); + h.step(); + const parent = h.enemy('enemy_parent'); + const kids = spawnChildren(h.b, parent, 'enemy_child', 2); + h.b.kill(parent, null); + for (const c of kids) h.b.kill(c, null); + assert.equal(coinsOf(h), 8); +}); + +test('磨砻 on death spawns children that do not inherit the bounty; the body still pays', REAL, () => { + const h = makeBattle({ + autoFinish: false, timeLimit: 30, + enemies: [{ + key: 'enemy_1195_sfyin', pos: [10, 6], + mods: { hpMul: 1, bountyId: 'card-yin', bountyCoins: 5 }, + bounty: { coins: 5, ownerPlayerId: 'p1' }, + }], + }); + h.step(); + const parent = h.enemy('enemy_1195_sfyin'); + assert.ok(parent, '磨砻 spawned'); + h.b.kill(parent, null); + const kids = h.b.enemies.filter((e) => e !== parent && e.defId !== 'enemy_1195_sfyin'); + assert.ok(kids.length >= 1, `death children spawned (${kids.length})`); + for (const c of kids) { + assert.ok(!c.mods || (c.mods.bountyId == null && c.mods.bountyCoins == null), `${c.defId} still carries a bounty field`); + assert.equal(c.bounty, null); + } + assert.equal(coinsOf(h), 5); +}); + +function seat(id, seatNo, o = {}) { + return { + playerId: id, seat: seatNo, deployCount: o.deployCount ?? 1, bonds: {}, layers: {}, + board: new Map(), bounties: o.bounties ?? [], + }; +} + +function uniteOf(leaks, bounties) { + const leaker = seat('L', 1, { bounties }); + const helper = seat('H', 0, { deployCount: 3 }); + const m = { + isSolo: false, + alivePlayers: () => [leaker, helper], + gd: { + unite: { maxHelpers: 2 }, + enemy: (k) => (k === 'enemy_parent' || k === 'enemy_child' ? { key: k } : null), + }, + }; + const results = new Map([ + ['L', { perfect: false, leaked: leaks }], + ['H', { perfect: true, leaked: [], unitsEnd: [] }], + ]); + return planUnite(m, results); +} + +const CARD = { id: 'b1', card: { payout: 'kill', coin: 8, enemyKey: 'enemy_parent' } }; + +test('planUnite pays the card only when the leaked enemy is the card\'s enemy', () => { + const plan = uniteOf([ + { enemyKey: 'enemy_parent', mods: { bountyId: 'b1', hpMul: 2 }, counted: true, lpr: 1 }, + { enemyKey: 'enemy_child', mods: { bountyId: 'b1', bountyCoins: 8, hpMul: 2 }, counted: true, lpr: 1 }, + { enemyKey: 'enemy_child', mods: { bountyCoins: 4 }, counted: true, lpr: 1 }, + { enemyKey: 'enemy_parent', mods: { bountyId: 'missing' }, counted: true, lpr: 1 }, + ], [CARD]); + assert.ok(plan); + assert.deepEqual(plan.leaked.map((l) => [l.enemyKey, l.bounty ? l.bounty.coins : null]), [ + ['enemy_parent', 8], + ['enemy_child', null], + ['enemy_child', 4], + ['enemy_parent', null], + ]); +}); + +test('planUnite does not pay a perfect-clear card or a card with no coin', () => { + const leaks = [{ enemyKey: 'enemy_parent', mods: { bountyId: 'b1' }, counted: true, lpr: 1 }]; + const perfect = uniteOf(leaks, [{ id: 'b1', card: { payout: 'perfect', coin: 8, enemyKey: 'enemy_parent' } }]); + assert.equal(perfect.leaked[0].bounty, null); + const zero = uniteOf(leaks, [{ id: 'b1', card: { payout: 'kill', coin: 0, enemyKey: 'enemy_parent' } }]); + assert.equal(zero.leaked[0].bounty, null); +}); diff --git a/test/sim/feedback3-cold-freeze.test.js b/test/sim/feedback3-cold-freeze.test.js new file mode 100644 index 00000000..59c50ab2 --- /dev/null +++ b/test/sim/feedback3-cold-freeze.test.js @@ -0,0 +1,135 @@ +// A second 寒冷 used to apply a fixed 3 s 冻结 (COLD_FREEZE_DURATION) whatever the two colds lasted. +// PRTS 术语释义 寒冷 (id ba.cold): 友方寒冷 pairs into 友方冻结, 「持续时间取双方之中最高」. The cold that remains is +// already max(remaining, incoming) via addBuff refresh 'extend'; the freeze in the same moment must match it. +// 抵抗 shortens the incoming duration before that max. A frozen-immune enemy still gets no freeze. +// [ASSUMED] the engine's one cold uses that 友方 sentence for an enemy-applied cold too (PRTS states the max on +// the 友方 line only). gamedata_const ba.cold names the freeze and not its length. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec } from '../helpers/battleHarness.js'; +import { hasGeneratedData } from '../../server/sim/simdata.js'; + +const REAL = { skip: !hasGeneratedData() }; +const close = (a, b, eps, msg = '') => assert.ok(Math.abs(a - b) <= eps, `${msg} expected ${b} ± ${eps}, got ${a}`); + +function coldBattle(extra = {}) { + return makeBattle({ + defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e6, speed: 0, res: 30 }) } }, + enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], + content: 'none', autoFinish: false, timeLimit: 60, + ...extra, + }); +} + +test('cold 10s then immediately another cold 1s → freeze lasts about 10s, not 3', () => { + const h = coldBattle(); + h.step(); + const e = h.enemy('enemy_dummy'); + h.b.applyStatus(e, 'cold', { duration: 10 }); + h.b.applyStatus(e, 'cold', { duration: 1 }); + close(e.findBuff('cold').timeLeft, 10, 1e-6, 'the shorter cold does not replace the longer one'); + close(e.findBuff('freeze').timeLeft, 10, 1e-6, 'freeze'); + assert.equal(e.s.res, 15, 'frozen: RES −15'); + h.run(3.2); + assert.ok(e.s.flags.freeze, 'still frozen past the old fixed 3 s'); + h.run(7); + assert.ok(!e.s.flags.freeze, 'ends with the 10 s cold'); + assert.equal(e.s.res, 30); +}); + +test('cold 1s then cold 8s → freeze lasts about 8s, not 3', () => { + const h = coldBattle(); + h.step(); + const e = h.enemy('enemy_dummy'); + h.b.applyStatus(e, 'cold', { duration: 1 }); + h.b.applyStatus(e, 'cold', { duration: 8 }); + close(e.findBuff('cold').timeLeft, 8, 1e-6); + close(e.findBuff('freeze').timeLeft, 8, 1e-6); + h.run(3.2); + assert.ok(e.s.flags.freeze, 'still frozen past 3 s'); + h.run(5); + assert.ok(!e.s.flags.freeze, 'ends with the 8 s cold'); +}); + +test('a shorter second cold does not shorten a longer remaining cold\'s freeze', () => { + const h = coldBattle(); + h.step(); + const e = h.enemy('enemy_dummy'); + h.b.applyStatus(e, 'cold', { duration: 10 }); + h.run(6); + const remain = e.findBuff('cold').timeLeft; + assert.ok(remain > 3.5 && remain < 4.5, `about 4 s left, got ${remain}`); + h.b.applyStatus(e, 'cold', { duration: 1 }); + close(e.findBuff('cold').timeLeft, remain, 1e-6, 'cold'); + close(e.findBuff('freeze').timeLeft, remain, 1e-6, 'freeze follows the remaining cold, not 1 s and not 3 s'); + h.run(3.2); + assert.ok(e.s.flags.freeze, 'a 1 s or 3 s freeze would have ended'); + h.run(1.2); + assert.ok(!e.s.flags.freeze); +}); + +test('抵抗 still shortens the incoming duration before the max', () => { + const h = coldBattle({ hooks: ['statusApplied'] }); + h.step(); + const e = h.enemy('enemy_dummy'); + h.b.applyStatus(e, 'cold', { duration: 2 }); + h.b.applyStatus(e, 'resist', { value: 0.5 }); + h.b.applyStatus(e, 'cold', { duration: 10 }); + // incoming 10 × (1 − 0.5) = 5, remaining 2 → both cold and freeze are 5, not 10 and not 2.5 + close(e.findBuff('cold').timeLeft, 5, 1e-6, 'cold'); + close(e.findBuff('freeze').timeLeft, 5, 1e-6, 'freeze'); + const freeze = h.hooksOf('statusApplied').filter((c) => c.target === e && c.status === 'freeze'); + assert.equal(freeze.length, 1); + close(freeze[0].duration, 5, 1e-9, 'the reported freeze duration'); + h.run(3.2); + assert.ok(e.s.flags.freeze, 'not halved a second time down to 2.5 s'); + h.run(2); + assert.ok(!e.s.flags.freeze, 'not the unresisted 10 s'); +}); + +test('a frozen-immune enemy gets the second cold and no freeze', () => { + const h = makeBattle({ + defs: { enemies: { enemy_icy: enemyRec({ key: 'enemy_icy', hp: 1e6, speed: 0, immunities: { frozen: true } }) } }, + enemies: [{ key: 'enemy_icy', pos: [10, 8] }], + content: 'none', autoFinish: false, + }); + h.step(); + const icy = h.enemy('enemy_icy'); + h.b.applyStatus(icy, 'cold', { duration: 10 }); + h.b.applyStatus(icy, 'cold', { duration: 1 }); + assert.ok(icy.s.flags.cold); + close(icy.findBuff('cold').timeLeft, 10, 1e-6, 'the colds still take the longer duration'); + assert.equal(icy.findBuff('freeze'), null); + assert.ok(!icy.s.flags.freeze); +}); + +test('吉兆飞鳞: the stun when a freeze ends follows the longer or shorter cold, not a fixed 3 s', REAL, () => { + const run = (first, second) => { + const h = makeBattle({ + autoFinish: false, timeLimit: 40, hooks: ['statusApplied'], + enemies: [{ key: 'enemy_10045_parrot', pos: [10, 6], mods: { speedMul: 0, hpMul: 100 } }], + }); + h.step(); + const e = h.enemy('enemy_10045_parrot'); + h.b.applyStatus(e, 'cold', { duration: first }); + h.b.applyStatus(e, 'cold', { duration: second }); + const want = Math.max(first, second); + close(e.findBuff('freeze').timeLeft, want, 1e-6, `freeze ${first} then ${second}`); + h.run(3.2); + const early = h.hooksOf('statusApplied').filter((c) => c.target === e && c.status === 'stun'); + if (want > 3.2) { + assert.ok(e.s.flags.freeze, 'still frozen at 3.2 s'); + assert.equal(early.length, 0, '失温坠落 has not fired at the old 3 s mark'); + } + h.run(want - 3.2 + 0.3); + assert.ok(!e.s.flags.freeze, 'the freeze has ended'); + const stuns = h.hooksOf('statusApplied').filter((c) => c.target === e && c.status === 'stun'); + // the 0.05 s 【失温坠落】 stun is what the freeze's end applies; its own status hook then grounds the bird + // (Stun.duration, 8 s). The harness hears the 8 s one first because that hook runs inside the 0.05 s emit. + assert.ok(stuns.some((c) => Math.abs(c.duration - 0.05) < 1e-6), `【失温坠落】 0.05 s stun, got ${stuns.map((c) => c.duration)}`); + return h; + }; + run(1, 1); // shorter than the old 3 s: the stun is not held until 3 s + run(10, 1); // longer than 3 s: no stun at 3 s, stun when the 10 s freeze ends +});