diff --git a/server/sim/content/kits/ops/op-ascln.js b/server/sim/content/kits/ops/op-ascln.js index 9ff09d19..38e763ca 100644 --- a/server/sim/content/kits/ops/op-ascln.js +++ b/server/sim/content/kits/ops/op-ascln.js @@ -7,7 +7,11 @@ // account]. Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json) and PRTS // 阿斯卡纶 (死亡拘审 备注 and its {{**}} 最终乘算 (100−18×层数)%, S2 修正 / 备注, S3 备注, the AMB-X note), PRTS 分支特性信息 // 伏击客 ("嘲讽等级为-1"), PRTS 命中率, Arknights Terra Wiki "Ascalon" (死亡拘审: "The MSPD reduction and Arts damage stack -// additively"). +// additively"); the client's battle data read from the local install — buff_template_data ascln_t_1 / ascln_t_1[debuff] +// (ON_OUTPUT_DAMAGE of NORMAL attacks; STACK, the duration refreshed; per trigger buff count × atk_ratio × ATK, MAGICAL, +// attack type BUFF), ascln_t_2 (CheckHeightTypeInRange x-5 ≥ cnt), ascln_s_2[enemy] (ON_OWNER_KILLED of a WALK enemy: +// the debuff in a circle of range_radius on the WALK_ONLY enemies but the dead one), ascln_s_3[self] (heal on +// ON_EVADE_DAMAGE and on a HIT_FAILED damage), ascln_e_003_t_1[heal] (a heal when the enemy holding it finishes). // - Trait (伏击客) "对攻击范围内所有敌人造成伤害;拥有50%的物理和法术闪避且不容易成为敌人的攻击目标": the stalker profile — every // enemy of her range at once, melee, ground only (no "可对空"), physical — and 50 % physical / arts dodge (trait bb prob; // AMB-Y: 65 %). "不容易成为敌人的攻击目标" is her taunt level −1, which her data stats already carry (PRTS 伏击客 "嘲讽等级为 diff --git a/server/sim/content/kits/ops/op-chen2.js b/server/sim/content/kits/ops/op-chen2.js index b8bc05c1..bba750ed 100644 --- a/server/sim/content/kits/ops/op-chen2.js +++ b/server/sim/content/kits/ops/op-chen2.js @@ -4,58 +4,67 @@ // // Forms (data/backups.json units.char_1013_chen2): normal = E2 Lv1, skills at rank 4, no module; elite = E2 Lv60, rank 7, // the picked module at stage 1 (tier 5) or 3 (tier 6) — the owner's decision of 2026-10-05. Potential 0 [ASSUMED: no -// account]. Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json) and PRTS -// 假日威龙陈 (节约风气 修正 and 备注, 假日余韵 备注, S2 / S3 备注, the RPR-X stage-1 note); PRTS 作战机制 §地形TAG; PRTS 分支特性 -// 信息 散射手 ("可对空", the trait's 1-3 front grid); gamedata_const ba.charged (蓄力). +// account]. Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 假日威龙陈 +// (节约风气 修正 and 备注, 假日余韵 备注, S2 / S3 备注, the RPR-X stage-1 note); PRTS 作战机制 §地形TAG; PRTS 分支特性信息 散射手 +// ("可对空", the trait's 1-3 front grid); gamedata_const ba.charged (蓄力); the client's battle data read from the local +// install — charpack char_1013_chen2 (the S2 / S3 modes: an attack plus a RangeAttack firing projectile_chr_chen2_s2 / _s3), +// battle/prefabs [uc]projectiles projectile_chr_chen2_s2 / _s3, buff_template_data chen2_s_debuff, chen2_s_2_enhance_judge, +// chen2_tr, chen2_t_002[spareShot], chen2_e_003_t[map_tag]. // - Trait (散射手) "攻击范围内的所有敌人,对自己前方一横排的敌人攻击力提升至150%": the reaperrange profile — every enemy of -// her range, air units too, ×atk_scale (trait bb) on the trait's front grid (her tile and the three ahead). RPR-X -// overrides it with atk_scale 1.6 (the same trait, 160 %); RPR-Y adds "部署费用减少" (cost −8, in the stats: her -// redeploy after a knock-out costs 8 DP less). Ranged physical arrows; blocks 1; ground enemies target her (no flag). +// her range, air units too, ×atk_scale (trait bb; chen2_tr AtkScaleUp) on the trait's front grid (her tile and the three +// ahead). RPR-X overrides it with atk_scale 1.6; RPR-Y adds "部署费用减少" (cost −8, in the stats: her redeploy after a +// knock-out costs 8 DP less). Ranged physical arrows; blocks 1; ground enemies target her (no flag). // - T1 节约风气 "在场时自身弹药类技能攻击时20%概率不消耗对应弹药,其他【狙击】干员的弹药类技能改为10%概率不消耗(同类效果取最 // 高)": while she is on the field, an attack made by a running 弹药类技能 (PRTS 备注: a skill whose text says "攻击装有X发 // 弹药 / 子弹" — never the 猎手 bullets) spends no ammo with that chance: hers spareshot_chen.prob, another 【狙击】 // operator's 20 % — PRTS 修正 (原因 "描述与游戏实际表现不符合", and the RPR-X stage-1 note "实际应为…其他【狙击】干员…20%"): // the game gives the other snipers what she has, spareshot_chen.prob, not the text's / `prob`'s 10 % [the mechanism is -// ASSUMED; the 20 % is PRTS's]. RPR-X stage 3 (talent change): herself e_spareshot_chen.prob 25 %, other 【狙击】 -// operators …[sniper].prob 20 %, other 远程 (RANGED) operators …[other].prob 12 %. 同类效果取最高: the highest chance of -// every 假日威龙陈 on the field. One roll per attack ("一次性消耗多发弹药时仅进行一次判定"); a spared attack spends nothing -// of what it would ("强制改为实耗0发"), and every `ammoUsed` of it still fires ("不影响以'消耗弹药'为效果触发条件的侦测": -// the bullet is put back in that event, before the skill checks for its last bullet) — 拉特兰 / garrison counters see it. +// ASSUMED; the 20 % is PRTS's]. RPR-X stage 3 (talent change; chen2_t_002[spareShot]: 【狙击】 profession ⇒ [sniper], +// else deploy class RANGED ⇒ [other]): herself e_spareshot_chen.prob 25 %, other 【狙击】 operators 20 %, other 远程 +// operators 12 %. 同类效果取最高: the highest chance of every 假日威龙陈 on the field. One roll per attack ("一次性消耗多发弹药 +// 时仅进行一次判定"); a spared attack spends nothing of what it would ("强制改为实耗0发"), and every `ammoUsed` of it still +// fires ("不影响以'消耗弹药'为效果触发条件的侦测": the bullet is put back in that event, before the skill checks for its last +// bullet) — 拉特兰 / garrison counters see it. // - T2 假日余韵 "攻击速度+8,当场地中存在水地形时改为攻击速度+12" ([common].attack_speed 8, [map].attack_speed +4). 水地形 is // a map TAG (PRTS 作战机制 §地形TAG): no level of this mode carries one (the act1 / act2 autochess level files' mapData.tags // are null), so +8. RPR-Y stage 3 "攻击力+15%,攻击速度+12,当场地中存在水地形时改为攻击力+28%,攻击速度+20,技能开启时,场地 -// 视为水地形": +15 % / +12, and while her skill runs the field holds the water TAG (PRTS 备注 "在技能期间为当前关卡临时添加 -// '水地形TAG';干员撤退时或技能结束时清除") — +28 % / +20 for her and for every 假日威龙陈 of that field. The module row's -// [map] keys are the text's totals (20 / 0.28), the base row's [map] 4 the step over [common] 8 (8 + 4 = 12): read so. +// 视为水地形": +15 % / +12, and while her skill runs the field holds the water TAG (chen2_e_003_t[map_tag]: SetRuntimeMapTag +// water at ON_SKILL_START, cleared at ON_SKILL_FINISH / ON_OWNER_FINISH — one flag of the level, no count: the first +// holder whose skill ends clears it) — +28 % / +20 for every 假日威龙陈 of that field. The module row's [map] keys are the +// text's totals (20 / 0.28), the base row's [map] 4 the step over [common] 8 (8 + 4 = 12): read so. // - S1 高压冲击 (AUTO, attack SP, data DEFAULT — an attack skill): ATK +atk, 4 bullets (attack@trigger_time), every enemy of // her range takes the trait's front-row multiplier ("对攻击范围内的所有敌人应用特性加成", as 送葬人 S1). -// - S2 “堇青之夜” (MANUAL, data DEFAULT): ATK +atk, 8 bullets (attack@trigger_time); 蓄力 (maxChargeTime 2; ba.charged "技力达 -// 到上限可继续回复,回复至上限2倍时进入蓄力状态,此时开启技能会触发额外效果(任何时候开启均消耗全部技力)"): cast with both +// - S2 “堇青之夜” (MANUAL, data DEFAULT): ATK +atk, 8 bullets (attack@trigger_time); 蓄力 (maxChargeTime 2; +// chen2_s_2_enhance_judge: ≥ 2 charges at the cast ⇒ the enhanced mode, then ClearCharacterSp; ba.charged): cast with both // charges stored ⇒ attack@another_trigger_time (20) bullets; every cast empties the SP. Every attack leaves slime (below). -// - S3 “假日风暴” (MANUAL, data ACTIVE_RANGE on its 2-6): range 2-6 while it runs, ATK +atk, every attack strikes twice and -// takes the trait multiplier on every enemy, leaves slime; 32 bullets, 2 per attack — PRTS 备注 "即使剩余弹药数小于2发仍 -// 可'消耗2发弹药'进行攻击;技能仅在弹药数归零后自动结束" (one `ammoUsed` per bullet). -// - Slime (S2 / S3 "每次攻击在范围内生成持续5秒的粘液,地面敌人经过时移动速度-N%、防御力-M(不叠加)"): each struck enemy's tile -// holds slime for attack@projectile_life_time s [ASSUMED: one tile under each enemy an attack strikes — the data calls it -// a projectile with a life time]; a ground enemy standing on slime moves ×(1 + attack@move_speed) and has DEF -// attack@def (flat), refreshed every SLIME_IV s so it lapses just after it leaves [ASSUMED]; 不叠加 — one effect, the -// strongest slime under it; PRTS 备注 "粘液的效果无视无法选择类效果(如隐匿与无敌)": stealthed / invulnerable enemies too. -// The slime outlives her [ASSUMED]. Air units never (地面敌人). +// - S3 “假日风暴” (MANUAL, data ACTIVE_RANGE on its 2-6): range 2-6 while it runs, ATK +atk, every attack strikes twice +// (MultiMeleeAttack _additionalTimes 1) and takes the trait multiplier on every enemy, leaves slime; 32 bullets, 2 per +// attack — PRTS 备注 "即使剩余弹药数小于2发仍可'消耗2发弹药'进行攻击;技能仅在弹药数归零后自动结束" (one `ammoUsed` per bullet). +// - Slime (S2 / S3 "每次攻击在范围内生成持续5秒的粘液,地面敌人经过时移动速度-N%、防御力-M(不叠加)"): each attack's RangeAttack +// fires a projectile that lands at once on her own ground mount point in her direction and lives +// attack@projectile_life_time s; every 0.2 s its range selector (ground enemies, `_ignoreTargetFree`: PRTS 备注 "无视无法选 +// 择类效果(如隐匿与无敌)") gives chen2_slime for 0.25 s — move speed FINAL_SCALER ×(1 + attack@move_speed), DEF +// FINAL_ADDITION attack@def. Here: the tiles of her attack range at the attack hold slime for the life time (a later attack +// refreshes them) [ASSUMED: the projectile's selector range is her attack range — the prefab names no grid; its effect is +// "chen2_skill_02_range_01"], pulsed every 0.2 s; 不叠加 — one effect per 假日威龙陈 (independentCharacterSource: two of +// them each slow); the DEF cut is compensated for the target's DEF multipliers (a final addition). It outlives her: the +// projectiles are not hers to clear [ASSUMED]. Air units never (地面敌人). (Each struck ground enemy's own 0.1 s +// chen2_s_debuff from the attack is inside that range anyway.) import { num, talentBb, skillRec, up } from '../shared/tier1.js'; import { bodyKeys } from '../../../body.js'; -import { COLS } from '../../../constants.js'; +import { aggregateMods } from '../../../buffs.js'; const S1 = 'skchr_chen2_1'; const S2 = 'skchr_chen2_2'; const S3 = 'skchr_chen2_3'; /** PRTS 节约风气 备注: a 弹药类技能 is one whose text says "攻击装有X发弹药" or "攻击装有X发子弹". */ const AMMO_SKILL = /攻击装有\d+发(?:弹药|子弹)/; -/** Slime refresh period and the length of one refresh (the effect lapses SLIME_DUR s after the enemy leaves it). */ -const SLIME_IV = 0.1; -const SLIME_DUR = 0.15; -/** Buff key of the slime effect on an enemy (one for every 假日威龙陈: 不叠加). */ -export const SLIME_KEY = 'chen2:slime'; +/** projectile_chr_chen2_s2 / _s3 `_interval` (the slime's selection period) and chen2_slime's lifeTime. */ +const SLIME_IV = 0.2; +const SLIME_DUR = 0.25; +/** Buff key of a 假日威龙陈's slime effect on an enemy (independentCharacterSource: one per source). */ +export const slimeKey = (src) => `chen2:slime#${src.id}`; const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; const count = (v, d) => Math.max(1, Math.floor(num(v, d))); @@ -102,15 +111,15 @@ function installSpare(battle, unit, probs) { st.srcs.set(unit, probs); } -// ---- 水地形 TAG (RPR-Y stage 3): the units holding it on a battle ------------------------------------------------------- -/** battle → Set(units whose running skill makes the field count as water). */ +// ---- 水地形 TAG (RPR-Y stage 3): the level's one runtime flag ---------------------------------------------------------- +/** battle → { on } (SetRuntimeMapTag water: no count — the last set / clear wins). */ const WATER = new WeakMap(); /** Whether the field of `battle` counts as water terrain now (no level of the mode has the TAG of its own). */ -export const waterOn = (battle) => (WATER.get(battle)?.size ?? 0) > 0; -function setWater(battle, unit, on) { +export const waterOn = (battle) => !!WATER.get(battle)?.on; +function setWater(battle, on) { let s = WATER.get(battle); - if (!s) WATER.set(battle, (s = new Set())); - if (on) s.add(unit); else s.delete(unit); + if (!s) WATER.set(battle, (s = { on: false })); + s.on = on; } /** @@ -125,61 +134,59 @@ export function holidayLines(t) { return { common: { atkPct: ca, aspd: cs }, water: total ? { atkPct: ma || ca, aspd: ms || cs } : { atkPct: ca + ma, aspd: cs + ms } }; } -// ---- slime (S2 / S3): one battle-wide pulse over every puddle ---------------------------------------------------------- -/** battle → Map(tileKey → { until, ms, def, src }). */ +// ---- slime (S2 / S3): one battle-wide pulse over every 假日威龙陈's slimed tiles ------------------------------------------ +/** battle → Map(source unit → Map(tileKey → { until, ms, def })). */ const SLIME = new WeakMap(); -/** The slime puddles of a battle (tile key → puddle), for tests and the pulse. */ -export const slimeOf = (battle) => SLIME.get(battle) ?? null; +/** A source's slimed tiles (tile key → { until, ms, def }), for tests. */ +export const slimeOf = (battle, src) => SLIME.get(battle)?.get(src) ?? null; + +/** The enemy's DEF multiplier from every buff but `key`'s ((1 + Σ defPct) × Π defMul): a FINAL_ADDITION is divided by it. */ +function defFactor(e, key) { + const { add, mul } = aggregateMods(e.buffs.filter((b) => b.key !== key)); + return Math.max(0, 1 + (add.defPct ?? 0)) * (mul.defMul ?? 1); +} function slimeState(battle) { let m = SLIME.get(battle); if (m) return m; m = new Map(); SLIME.set(battle, m); - // no owner: puddles outlive the unit that left them [ASSUMED] + // no owner: the slime outlives the unit that left it [ASSUMED] battle.every(SLIME_IV, () => { - if (!m.size) return; const now = battle.time; - for (const [k, p] of m) if (p.until <= now + 1e-9) m.delete(k); - if (!m.size) return; - for (const e of battle.enemies) { - // 地面敌人 only; 无视无法选择 — no targetability check (stealth, invulnerable) - if (!e.alive || e.hidden || !e.deployed || e.isFlying) continue; - let best = null; - for (const k of bodyKeys(e)) { - const p = m.get(k); - if (p && (!best || Math.abs(p.def) > Math.abs(best.def) || (Math.abs(p.def) === Math.abs(best.def) && Math.abs(p.ms) > Math.abs(best.ms)))) best = p; + for (const [src, tiles] of m) { + for (const [k, p] of tiles) if (p.until <= now + 1e-9) tiles.delete(k); + if (!tiles.size) continue; + const key = slimeKey(src); + for (const e of battle.enemies) { + // 地面敌人 only; 无视无法选择 — no targetability check (stealth, invulnerable) + if (!e.alive || e.hidden || !e.deployed || e.isFlying) continue; + let p = null; + for (const k of bodyKeys(e)) { p = tiles.get(k) ?? null; if (p) break; } + if (!p) continue; + const f = defFactor(e, key); + battle.addBuff(e, { + key, duration: SLIME_DUR, source: src, tags: ['skill', 'slime'], + mods: { moveMul: Math.max(0, 1 + p.ms), defFlat: f > 1e-6 ? p.def / f : p.def }, + }); } - if (!best) continue; - battle.addBuff(e, { key: SLIME_KEY, duration: SLIME_DUR, mods: { moveMul: Math.max(0, 1 + best.ms), defFlat: best.def }, source: best.src, tags: ['skill', 'slime'] }); } }); return m; } -/** - * S2 / S3 attack.onHit (once per struck enemy, at the arrow's impact): slime on that enemy's tile for life s; a puddle - * already there is refreshed, keeping the stronger numbers [ASSUMED: one puddle per tile]. - */ -function slimeOnHit(b) { +/** S2 / S3 onAttack: the tiles of her attack range hold her slime for life s (refreshed by every attack). */ +function leaveSlime(battle, unit, b) { const life = num(b['attack@projectile_life_time'], 5), ms = num(b['attack@move_speed']), def = num(b['attack@def']); - return ({ battle, unit, x, y }) => { - if (!(life > 0) || (!ms && !def)) return; - const r = Math.round(y), c = Math.round(x); - if (!battle.grid.inBounds(r, c)) return; - const m = slimeState(battle); - const k = r * COLS + c, until = battle.time + life, old = m.get(k); - if (!old) { - m.set(k, { until, ms, def, src: unit }); - battle.fx('zone', { x: c, y: r, radius: 0.5, dur: life, id: unit.id, skill: 'chen2:slime' }); - return; - } - if (Math.abs(def) > Math.abs(old.def) || (Math.abs(def) === Math.abs(old.def) && Math.abs(ms) > Math.abs(old.ms))) { - old.ms = ms; old.def = def; old.src = unit; - } - old.until = Math.max(old.until, until); - }; + if (!(life > 0) || (!ms && !def) || !unit.rangeKeys) return; + const m = slimeState(battle); + let tiles = m.get(unit); + if (!tiles) m.set(unit, (tiles = new Map())); + const fresh = !tiles.size; + const until = battle.time + life; + for (const k of unit.rangeKeys) tiles.set(k, { until, ms, def }); + if (fresh) battle.fx('zone', { x: unit.x, y: unit.y, radius: 2, dur: life, id: unit.id, skill: 'chen2:slime' }); } export default { @@ -209,7 +216,7 @@ export default { kind: 'ammo', ammo: count(b2['attack@trigger_time'], 8), mods: { atkPct: num(b2.atk) }, - attack: { onHit: slimeOnHit(b2) }, + attack: {}, // (its attacks are the skill's: isSkill) onStart({ unit, skill }) { // 蓄力: cast with every charge stored (activate took one) ⇒ the charged bullets; any cast spends all the SP const charged = skill.maxCharges > 1 && skill.charges + 1 >= skill.maxCharges; @@ -218,6 +225,7 @@ export default { skill.charges = 0; skill.sp = 0; }, + onAttack({ battle, unit }) { leaveSlime(battle, unit, b2); }, onEnd({ unit }) { unit.mem.chen2Charged = false; }, }, [S3]: { @@ -225,9 +233,10 @@ export default { ammo: count(b3['attack@trigger_time'], 32), mods: { atkPct: num(b3.atk) }, targeting: s3?.rangeGrid ? { rangeGrid: s3.rangeGrid.map((p) => [p[0], p[1]]) } : undefined, - attack: { hits: 2, dmgMul: frontAll, onHit: slimeOnHit(b3) }, - // 每次攻击消耗2发: the second bullet (the engine spends the other one); with one left she still attacks + attack: { hits: 2, dmgMul: frontAll }, onAttack({ battle, unit, skill }) { + leaveSlime(battle, unit, b3); + // 每次攻击消耗2发: the second bullet (the engine spends the other one); with one left she still attacks if (skill.ammoLeft > 1) { skill.ammoLeft--; battle.emit('ammoUsed', { unit, left: skill.ammoLeft, skill }); @@ -255,10 +264,17 @@ export default { apply(); battle.on('tick', apply, { owner: unit }); if (!waterSkill) return; - // RPR-Y stage 3 "技能开启时,场地视为水地形": the TAG while her skill runs, gone when it ends or she leaves - battle.on('skillStart', (ctx) => { if (ctx.unit === unit) { setWater(battle, unit, true); apply(); } }, { owner: unit }); - battle.on('skillEnd', (ctx) => { if (ctx.unit === unit) { setWater(battle, unit, false); apply(); } }, { owner: unit }); - battle.on('death', (ctx) => { if (ctx.unit === unit) setWater(battle, unit, false); }, { owner: unit }); + // RPR-Y stage 3 "技能开启时,场地视为水地形" (chen2_e_003_t[map_tag]): set at her skill's start unless she holds it, + // cleared when her skill ends or she leaves — if she set it + const clear = () => { if (unit.mem.chen2WaterSet) { unit.mem.chen2WaterSet = false; setWater(battle, false); apply(); } }; + battle.on('skillStart', (ctx) => { + if (ctx.unit !== unit || unit.mem.chen2WaterSet) return; + unit.mem.chen2WaterSet = true; + setWater(battle, true); + apply(); + }, { owner: unit }); + battle.on('skillEnd', (ctx) => { if (ctx.unit === unit) clear(); }, { owner: unit }); + battle.on('death', (ctx) => { if (ctx.unit === unit) clear(); }, { owner: unit }); } }, ], }; diff --git a/server/sim/content/kits/ops/op-ray.js b/server/sim/content/kits/ops/op-ray.js index efb3096f..7c709665 100644 --- a/server/sim/content/kits/ops/op-ray.js +++ b/server/sim/content/kits/ops/op-ray.js @@ -9,7 +9,10 @@ // built into backups.json) and PRTS 莱伊 (巡哨伙伴 / 入神 / S1 / S2 / S3 备注), PRTS 沙地兽 (备注 "持有禁疗、无敌", "退场时返还1 // 个可部署的沙地兽", "莱伊退场时强制撤退场上的沙地兽"; its skill "仅在持有者携带技能2时才会携带"), PRTS 分支特性信息 猎手, BWIKI // 莱伊 ("装填间隔即为攻击间隔,初始子弹数即为最大子弹数"), PRTS 卫戍协议/帮助 (a placed summon that leaves is deployed again on -// its tile "在满足条件后"). +// its tile "在满足条件后"); the client's battle data read from the local install — buff_template_data ray_tr / ray_tr_add / +// ray_tr_sub (the magazine), ray_s_1[konckback] (Knockback _useSourceDirection, _decreaseForceLevelWhenNotInDirection 1), +// ray_s_1[kill_add_bullet], ray_t_1[damage_scale] (PHYSICAL DamageScale on targets holding ray_sndbst_aura), ray_s_2[deck], +// ray_s_3[reload] / [check_full] / [sp], ray_sndbst_tr / ray_sndbst_collect, and charpack char_4117_ray. // - Trait (猎手) "攻击时需要消耗子弹且攻击力提升至120%,不攻击时会缓慢地装填子弹(最多8发)" (trait bb value / atk_scale; the // hunter profile's ×atk_scale on every bullet attack, skill attacks included). PRTS 分支特性信息 猎手: "入场时持有自身子弹上限 // 数的子弹" — full at every deployment; "存在子弹且攻击范围内存在目标时进行攻击,不存在子弹/子弹未满且攻击范围内无目标时进行 @@ -44,9 +47,10 @@ // - S2 广域警觉 (AUTO, attack SP; 持续时间无限 — a toggle): range 4-10, ATK +atk, the 沙地兽's redeploy time ×(1 + // respawn_time). Self / summon effects only: it fires at full SP (`trigger: 'SP_FULL'`, the owner's AUTO rule). // Passive "沙地兽撤退时回收命中该区域的子弹": while she carries S2, the bullets of her attacks that hit an enemy inside a -// scouting 沙地兽's area are counted and come back to her magazine when it leaves (≤ the magazine, while she is on the -// field) [ASSUMED: capped at the trait's maximum]; S1's special bullet is no bullet. PRTS "装弹行为不触发攻击回复": no -// reload gives attack SP. +// scouting 沙地兽's area are counted (its own counter: ≤ its trait value 8 — ray_sndbst_tr) and come back to her magazine +// when it leaves, while she is on the field (ray_sndbst_collect → ray_tr_add: HUN-X's extra_add first on an empty +// magazine, capped at her maximum); S1's special bullet is no bullet. PRTS "装弹行为不触发攻击回复": no reload gives +// attack SP. // - S3 “得见光芒” (MANUAL, 16 s, data ACTIVE_RANGE on its 3-8): "立即停止攻击直至子弹装满,装填间隔大幅缩短(-1.2)": at the cast // she stops attacking and reloads (a reload under way is cut to the new interval) until her magazine is full, every reload // while it runs lasting base attack time + reload_interval (0.4 s); range 3-8; every attack attack@atk_scale × ATK @@ -233,8 +237,13 @@ export default { if (!mine(t) || battle.finished) return; t.removed = false; // the piece stays (Battle.redeploy needs it; its hooks are kept) if (m.rayBeast === t) endArea(); - // S2 passive: the bullets that hit in its area come back - if (picked === S2 && m.rayBullets > 0 && up(unit)) unit.trait.ammo = Math.min(ammoMax(unit), (unit.trait.ammo ?? 0) + m.rayBullets); + // S2 passive: the bullets that hit in its area come back (ray_sndbst_collect → ray_tr_add: its count — ≤ its own + // trait value — onto her magazine, HUN-X's extra_add first when she is empty, capped at her maximum) + if (picked === S2 && m.rayBullets > 0 && up(unit)) { + const back = Math.min(m.rayBullets, Math.max(1, Math.floor(num(t.def?.traitBb?.value, ammoMax(unit))))); + const empty = (unit.trait.ammo ?? 0) <= 0 ? extraAdd : 0; + unit.trait.ammo = Math.min(ammoMax(unit), (unit.trait.ammo ?? 0) + empty + back); + } m.rayBullets = 0; const left = battle.time; battle.every(RETRY, (b, sched) => { diff --git a/test/content/op_chen2.test.js b/test/content/op_chen2.test.js index 77d4bdb2..651c0f3f 100644 --- a/test/content/op_chen2.test.js +++ b/test/content/op_chen2.test.js @@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs'; import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js'; import { diyPool, validateDiyPicks } from '../../shared/diy.js'; -import { spareChance, waterOn, holidayLines, slimeOf, SLIME_KEY } from '../../server/sim/content/kits/ops/op-chen2.js'; +import { spareChance, waterOn, holidayLines, slimeOf, slimeKey } from '../../server/sim/content/kits/ops/op-chen2.js'; const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8')); const CHESS = load('chess'); @@ -27,7 +27,7 @@ const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uni const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`); const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o }); const ENEMIES = { - enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }), + enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }), enemy_armor: dummy('enemy_armor', { def: 500 }), enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.5, mass: 0 }), }; const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, RX, RY].map((m) => [t, true, m]))]; @@ -161,38 +161,58 @@ test('S2 “堇青之夜” (MANUAL, data DEFAULT, 蓄力 2): 27 / 24 SP from 5 } }); -test('slime (S2 / S3): each struck enemy\'s tile holds slime 5 s; a ground enemy on it moves ×0.8 / ×0.7 (S2) and loses 100 / 150 DEF (not stacking), a stealthed one too, a flyer never; it lapses after the 5 s', () => { - for (const [tier, elite, skill] of [[5, false, 1], [6, true, 1], [6, true, 2]]) { +test('slime (S2 / S3): each attack slimes the tiles of her attack range (2-5; S3 2-6) for 5 s; a ground enemy there — struck or not, stealthed too — moves ×0.8 / ×0.7 / ×0.6 and loses 100 / 150 / 200 DEF after its multipliers (not stacking); a flyer or an enemy outside never; it lapses 5 s after her last attack; two 假日威龙陈 each slow', () => { + for (const [tier, elite, skill] of [[5, false, 1], [6, true, 1], [5, false, 2], [6, true, 2]]) { const sk = skillOf(tier, elite, [S1, S2, S3][skill]); + const [ms, def, life] = [sk.bb['attack@move_speed'], sk.bb['attack@def'], sk.bb['attack@projectile_life_time']]; const { h, u } = field({ tier, elite, skill }); - const e = h.spawn('enemy_dummy', { pos: [10, 5] }); + const e = h.spawn('enemy_armor', { pos: [10, 5] }); const fly = h.spawn('enemy_fly', { pos: [10, 6] }); + const out = h.spawn('enemy_armor', { pos: [10, 8] }); cast(h, u); - assert.ok(h.runUntil(() => atkHits(h, u).some((c) => c.dmg.isSkill && c.target === e), 5), 'struck'); + assert.ok(h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === u && c.isSkill), 5), 'an attack'); h.run(0.25); - const pud = slimeOf(h.b); - assert.ok(pud && pud.has(10 * 21 + 5) && pud.has(10 * 21 + 6), `T${tier} S${skill + 1}: puddles under both struck enemies`); - const b = e.findBuff(SLIME_KEY); + const tiles = slimeOf(h.b, u); + assert.deepEqual([...tiles.keys()].sort((a, b) => a - b), [...u.rangeKeys].sort((a, b) => a - b), `T${tier} S${skill + 1}: her range (${skill === 2 ? '2-6' : '2-5'})`); + const key = slimeKey(u); + const b = e.findBuff(key); assert.ok(b, `T${tier} S${skill + 1}: the ground enemy is slimed`); - assert.deepEqual(b.mods, { moveMul: 1 + sk.bb['attack@move_speed'], defFlat: sk.bb['attack@def'] }, `T${tier} S${skill + 1}: ×${1 + sk.bb['attack@move_speed']} / ${sk.bb['attack@def']}`); - assert.equal(fly.findBuff(SLIME_KEY), null, `T${tier}: never a flyer`); - approx(e.s.def, Math.max(0, e.base.def + sk.bb['attack@def']), 'DEF'); - // 不叠加: one effect however many puddles / attacks - h.run(5); - assert.equal(e.buffs.filter((x) => x.key === SLIME_KEY).length, 1); - // a stealthed walker passing through (无视无法选择) - const w = h.spawn('enemy_dummy', { pos: [10, 5] }); - h.b.addBuff(w, { key: 'test:stealth', flags: { stealth: true } }); - h.run(0.3); - assert.ok(w.findBuff(SLIME_KEY), 'a stealthed enemy on the slime is slimed'); - // stop the skill and let the puddles run out + approx(b.mods.moveMul, 1 + ms, `×${1 + ms}`); + approx(e.s.def, 500 + def, `DEF ${def}`); + assert.equal(fly.findBuff(key), null, 'never a flyer'); + assert.equal(out.findBuff(key), null, 'not outside her range'); + // a stealthed ground enemy that walks in (never struck: she cannot see it) — 无视无法选择 + const sneak = h.spawn('enemy_armor', { pos: [9, 6] }); + h.b.addBuff(sneak, { key: 'test:stealth', flags: { stealth: true } }); + h.run(0.25); + assert.ok(sneak.findBuff(key), 'a stealthed enemy in the slime is slimed'); + // the DEF cut is a final addition: after a ×0.7 DEF status + h.b.applyStatus(e, 'defDown', { duration: 30, value: 0.3, source: u }); + h.run(0.25); + approx(e.s.def, 500 * 0.7 + def, 'DEF ×0.7 then the cut'); + assert.equal(e.buffs.filter((x) => x.key === key).length, 1, '不叠加'); + // stop her: the slime lapses life s after her last attack u.skill.end('test'); h.b.addBuff(u, { key: 'test:disarm', flags: { disarm: true } }); - h.run(sk.bb['attack@projectile_life_time'] + 0.5); - assert.equal(e.findBuff(SLIME_KEY), null, `T${tier}: gone ${sk.bb['attack@projectile_life_time']} s after the last attack`); - assert.equal(pud.size, 0, 'no puddle left'); + const tLast = h.hooksOf('attack').filter((c) => c.attacker === u).slice(-1)[0].t; + h.run(tLast + life - 0.3 - h.b.time); + assert.ok(e.findBuff(key), 'still slimed'); + h.run(0.7); + assert.equal(e.findBuff(key), null, `gone ${life} s after her last attack`); done(h); } + // two of them: one slow each (independentCharacterSource) + const h = makeBattle({ + defs: { enemies: ENEMIES }, timeLimit: 60, autoFinish: false, seed: 3, flags: { dpPerSec: 0 }, + units: [{ uid: 1, diy: { slot: 5, charId: CHEN, skillIndex: 1 }, row: 10, col: 4 }, { uid: 2, diy: { slot: 6, charId: CHEN, skillIndex: 1 }, row: 11, col: 4 }], + }); + h.step(); + const e = h.spawn('enemy_armor', { pos: [10, 5] }); + for (const id of [1, 2]) h.unit(id).skill.gainSp(999); + h.run(3); + assert.ok(e.findBuff(slimeKey(h.unit(1))) && e.findBuff(slimeKey(h.unit(2))), 'both slimes'); + approx(e.s.def, 500 - 200, 'DEF −100 twice'); + done(h); }); test('S3 “假日风暴” (MANUAL, data ACTIVE_RANGE on 2-6): an enemy only on 2-6 casts it; range 2-6 (and back); ATK +55 % / +70 %, every attack strikes twice with the trait multiplier on every enemy; 32 bullets, 2 per attack (16 attacks), one left still attacks', () => { diff --git a/test/golden/diy.json b/test/golden/diy.json index 9466049f..4b53377e 100644 --- a/test/golden/diy.json +++ b/test/golden/diy.json @@ -1982,35 +1982,35 @@ "round": 12, "seed": 2215018795, "waveId": "act1autochess_h05", - "time": 230, - "ticks": 6900, - "reason": "timeout", - "killed": 23, + "time": 229.93, + "ticks": 6898, + "reason": "cleared", + "killed": 27, "total": 60, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 299, + "rngDraws": 300, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":23,"total":60,"perfect":false,"leaks":37,"leaksUncounted":0,"coins":0,"dmg":1146961,"bossDmg":0,"heal":0,"deaths":10,"layerGains":{}}}, + "players": {"p1":{"killed":27,"total":60,"perfect":false,"leaks":33,"leaksUncounted":0,"coins":0,"dmg":1206322,"bossDmg":0,"heal":0,"deaths":9,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_diy1_a",0,null,1875,1,10,0,22517,0,0,51,5], - ["p1",2,"op","chess_char_5_diy2_a",1,null,1875,1,11,0,37968,0,0,56,3], - ["p1",3,"op","chess_char_5_diy1_a",2,null,1875,1,1,8,77034,0,0,88,2], - ["p1",4,"op","chess_char_5_diy2_b",0,"none",2290,1,3,5,126301,0,0,97,10], - ["p1",5,"op","chess_char_5_diy1_b",1,"none",2290,1,5,3,143056,0,0,96,5], - ["p1",6,"op","chess_char_5_diy2_b",2,"none",2290,1,7,1,420504,0,0,87,2], + ["p1",1,"op","chess_char_5_diy1_a",0,null,1875,1,10,0,26812,0,0,51,5], + ["p1",2,"op","chess_char_5_diy2_a",1,null,1875,1,11,0,43295,0,0,55,3], + ["p1",3,"op","chess_char_5_diy1_a",2,null,1875,1,1,5,54590,0,0,87,2], + ["p1",4,"op","chess_char_5_diy2_b",0,"none",2290,1,3,5,121247,0,0,96,9], + ["p1",5,"op","chess_char_5_diy1_b",1,"none",2290,1,5,1,143603,0,0,86,5], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",2290,1,7,7,485647,0,0,86,2], ["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_chen2",2290,1,9,0,0,0,0,0,0], - ["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_chen2",2290,1,12,0,115378,0,0,80,4], - ["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_chen2",2290,1,79,4,35459,4580,0,26,1], - ["p1",10,"op","chess_char_5_diy2_b",0,"uniequip_003_chen2",1116.3,1,80,1,58145,6159,0,32,3], - ["p1",11,"op","chess_char_5_diy1_b",1,"uniequip_003_chen2",0,0,77,0,50242,7275,0,21,1], - ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_003_chen2",0,0,78,0,60357,7275,0,34,1] + ["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_chen2",2290,1,12,0,123659,0,0,82,4], + ["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_chen2",0,0,72,2,43929,4580,0,23,1], + ["p1",10,"op","chess_char_5_diy2_b",0,"uniequip_003_chen2",2425,1,79,2,16074,4850,0,28,2], + ["p1",11,"op","chess_char_5_diy1_b",1,"uniequip_003_chen2",1116.3,1,78,2,82150,6159,0,38,3], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_003_chen2",0,0,77,3,65315,7275,0,32,1] ], - "enemies": {"enemy_1045_hammer_2":[12,0,12,12971,88407],"enemy_1061_zomshd":[48,23,24,12317,1128449]}, - "hooks": [1,80,6900,699,699,4321,4321,47243,33,69,37,35,397,26577,0,12,60,36,1,33,0,0,0], - "events": {"atk":2237,"deploy":20,"die":33,"dmg":4321,"fx":107,"leak":36,"skill":72,"spawn":72}, - "fx": {"reload":51,"zone":56}, - "snaps": "cd5ab93f 0fdb1e7c 557181fd 2610c393 ffe004fb 79253f5d aacadc7f fca301fb 9f41cc07 88f28c30 1eed72e2 4f156152 051e0c90 2d42fdf6 b5c18831 5882c826 bb8dea4d 7d347742 a689f424 cf326f7e fc946b30 8ca74e36 d030e9c5" + "enemies": {"enemy_1045_hammer_2":[12,0,12,9835,121832],"enemy_1061_zomshd":[48,27,21,13029,1152890]}, + "hooks": [1,79,6898,694,694,4275,4275,45863,36,69,37,33,392,26342,1,14,60,33,1,36,0,0,0], + "events": {"atk":2215,"deploy":19,"die":36,"dmg":4275,"fx":72,"leak":33,"skill":70,"spawn":72,"status":2}, + "fx": {"reload":51,"zone":21}, + "snaps": "cd5ab93f 2396c2a8 5077137f dd863ee2 d4b68780 9808f85e b478a497 d3cf815c 52e73b12 21f1f91b dcae9b46 2b6c86eb 72df1443 707243c1 9fa5d9a1 c2398758 d1d86183 f988258a 8172a2fe 50e548a7 b9a30dce c379a931 ad469f5c" }, "diy-050": { "about": "6_diy1_a=chen2/S1 6_diy2_a=chen2/S2 6_diy1_a=chen2/S3 6_diy2_b=chen2/S1 6_diy1_b=chen2/S2 6_diy2_b=chen2/S3 6_diy1_b=chen2/S1/002_chen2 6_diy2_b=chen2/S2/002_chen2 6_diy1_b=chen2/S3/002_chen2 6_diy2_b=chen2/S1/003_chen2 6_diy1_b=chen2/S2/003_chen2 6_diy2_b=chen2/S3/003_chen2", @@ -2021,35 +2021,35 @@ "round": 13, "seed": 574294847, "waveId": "act1autochess_h06", - "time": 213.6, - "ticks": 6408, + "time": 213.57, + "ticks": 6407, "reason": "cleared", "killed": 9, "total": 105, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 20, + "rngDraws": 21, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":9,"total":105,"perfect":false,"leaks":96,"leaksUncounted":0,"coins":0,"dmg":176875,"bossDmg":0,"heal":0,"deaths":20,"layerGains":{}}}, + "players": {"p1":{"killed":9,"total":105,"perfect":false,"leaks":96,"leaksUncounted":0,"coins":0,"dmg":185004,"bossDmg":0,"heal":0,"deaths":20,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,1,1,4895,1875,0,7,1], - ["p1",2,"op","chess_char_6_diy2_a",1,null,0,0,3,0,12046,1875,0,7,1], - ["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,5,0,7430,1875,0,7,0], - ["p1",4,"op","chess_char_6_diy2_b",0,"none",0,0,2,1,11540,2290,0,7,1], - ["p1",5,"op","chess_char_6_diy1_b",1,"none",0,0,8,2,14249,2290,0,5,1], + ["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,1,0,3193,1875,0,6,0], + ["p1",2,"op","chess_char_6_diy2_a",1,null,0,0,3,0,12580,1875,0,8,1], + ["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,5,0,12686,1875,0,8,0], + ["p1",4,"op","chess_char_6_diy2_b",0,"none",0,0,2,0,5688,2290,0,6,1], + ["p1",5,"op","chess_char_6_diy1_b",1,"none",0,0,8,2,15978,2290,0,5,1], ["p1",6,"op","chess_char_6_diy2_b",2,"none",0,0,67,0,11342,4580,0,6,0], ["p1",7,"op","chess_char_6_diy1_b",0,"uniequip_002_chen2",0,0,65,0,5635,4580,0,6,0], - ["p1",8,"op","chess_char_6_diy2_b",1,"uniequip_002_chen2",0,0,7,2,22739,2290,0,8,1], + ["p1",8,"op","chess_char_6_diy2_b",1,"uniequip_002_chen2",0,0,7,2,29571,2290,0,8,1], ["p1",9,"op","chess_char_6_diy1_b",2,"uniequip_002_chen2",0,0,9,0,10530,2290,0,6,0], ["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_003_chen2",0,0,111,0,14362,7410,0,11,0], - ["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_003_chen2",0,0,118,2,28476,7410,0,11,1], - ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_003_chen2",0,0,125,1,33633,7410,0,12,0] + ["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_003_chen2",0,0,125,4,32571,7410,0,13,1], + ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_003_chen2",0,0,118,1,30868,7410,0,12,0] ], - "enemies": {"enemy_1165_duhond":[51,9,42,4962,106064],"enemy_1174_duholy_2":[24,0,24,16928,28959],"enemy_1175_dushdo_2":[30,0,30,24285,41852]}, - "hooks": [1,125,6408,148,148,298,298,0,29,125,6,6,20,5349,0,27,105,96,1,29,0,0,0], - "events": {"atk":302,"deploy":20,"die":29,"dmg":298,"fx":13,"leak":96,"skill":12,"spawn":117,"status":95}, - "fx": {"reload":4,"zone":9}, - "snaps": "bd64d67f f3df83b6 2b856b69 b6ac8170 0e517a9b f3c238f3 e39423d6 d8b530e9 7deb9725 9e8e1489 6b298bbe ca59d939 083a021d 65b35107 8994b3f8 ca994722 c744f8dc e6e50b34 b7c27ed6 31666d8b 37e1fde4 b8746a75" + "enemies": {"enemy_1165_duhond":[51,9,42,3408,101286],"enemy_1174_duholy_2":[24,0,24,16928,37448],"enemy_1175_dushdo_2":[30,0,30,25840,46269]}, + "hooks": [1,125,6407,148,148,304,304,0,29,125,5,5,21,5444,0,25,105,96,1,29,0,0,0], + "events": {"atk":307,"deploy":20,"die":29,"dmg":304,"fx":8,"leak":96,"skill":10,"spawn":117,"status":93}, + "fx": {"reload":4,"zone":4}, + "snaps": "bd64d67f 89474186 f29e9c8c d2709b01 c98c193b 65062fc6 ce1a7762 7d13c1c1 b2dfa5bf 40376417 76ece10a e7c87d80 94d0f82e 780eb42e 38046d89 c8c7aa8d 5d373dc7 b3db1c31 32680bc2 5263ed68 de37dd9f e4371a62" }, "diy-051": { "about": "5_diy1_a=ray/S1 5_diy2_a=ray/S2 5_diy1_a=ray/S3 5_diy2_b=ray/S1 5_diy1_b=ray/S2 5_diy2_b=ray/S3 5_diy1_b=ray/S1/002_ray 5_diy2_b=ray/S2/002_ray 5_diy1_b=ray/S3/002_ray 5_diy2_b=ray/S1/003_ray 5_diy1_b=ray/S2/003_ray 5_diy2_b=ray/S3/003_ray",