From 12fa92d9f602ba0b4dde597118ffe6fb775a02f1 Mon Sep 17 00:00:00 2001 From: sganggs Date: Tue, 6 Oct 2026 00:01:27 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feedback5=20O7:=20engine=20hooks=20for=20th?= =?UTF-8?q?e=20=E6=94=BB=E5=9F=8E=E6=89=8B=20/=20=E5=9B=9E=E7=8E=AF?= =?UTF-8?q?=E5=B0=84=E6=89=8B=20kits=20=E2=80=94=20targeting=20priority=20?= =?UTF-8?q?'heaviest'=20and=20boomerang=20flight=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - targeting.js PRIORITY_FNS `heaviest` (-e.weight: the current 重量等级, 失重 included): the 攻城手 trait "优先攻击重量最重的敌人" for the 早露 / 提丰 kits (`trait.priority`); no other unit uses it. - ai.js throwBoomerang: a catch emits the hook `boomerangCaught` { unit, attackId, isSkill, x, y } (only when a handler exists); an attack profile's `boomerangOnward(ctx)` takes the flight over after the first hit — ctx.hit(target, x, y) (a hit of the same attack, resolveHit with its profile), ctx.comeBack(x, y) (the usual way back, once) — and a content error there still sends the boomerang back. For 娜仁图亚 LPS-Y "每回收5次回旋投射物", S3 "投射物全部回收时", S1's bounces and S2's dash (the kits come in the next commit). - docs/SIM.md §5 (the hook) and §8 (the profile flags, the loopshooter row, the priority). - Additive and behaviour-preserving: no unit of the base has a handler or the flag, so golden is unchanged. --- docs/SIM.md | 12 ++++++++---- server/sim/ai.js | 26 +++++++++++++++++++++++--- server/sim/targeting.js | 3 +++ 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/docs/SIM.md b/docs/SIM.md index 67b576a8..56777bab 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -750,6 +750,7 @@ registration order. `battle.off(handle)` / `battle.off(name, fn)` / `battle.offO | `enemySpawn` / `enemyLeak` | `{ enemy }` | | | `elementBurst` | `{ source, target, element }` | before the burst's lock/effects; same-element fills of `target` are already refused | | `dodge` | `{ source, target, dmg }` | an attack was dodged | +| `boomerangCaught` | `{ unit, attackId, isSkill, x, y }` | a 回环射手 boomerang came back to its thrower (`ai.js throwBoomerang`; `isSkill` = thrown by a skill attack): 娜仁图亚 LPS-Y "每回收5次回旋投射物", S3 "投射物全部回收时" | | `layerGain` | `{ playerId, bondId, n, reason, source, tile }` | mutable `n` before recording (魔王 +1 …), then clamped to the room left under `BOND_LAYER_CAP` (999); not emitted for a bond already at the cap; `tile` = `[r, c]` where `source` stands — or was knocked out this very instant ("被击倒时" gains) — else null (`addLayers` opts.tile overrides) | | `merchantPay` | `{ unit, cost, cancel }` | a merchant (行商) is about to pay its periodic DP; change `cost` or set `cancel` | | `battleEnd` | `{ result }` | may still add layer gains / coins | @@ -1141,9 +1142,12 @@ S3 未照耀的荣光 — its CUSTOM_RANGE trigger also counts flyers). A stun / 3.75 back = `BOOMERANG_RETURN_SPEED`, PRTS 跃跃; droneBomb 5 = 暴鸰's bomb, the official projectile_bombd); melee/`none` hits are instant, and so are `'beam'` hits (a 锁定攻击范围 AoE without a projectile — `rangeAoe` profiles: "在攻击前摇结束时选取范围内的全体目标,同时造成伤害", PRTS 作战机制). Kit-settable profile flags beyond the table: `hitSleep` (targets and damages sleeping enemies — "可以攻击沉睡的敌人"), `onEachHit(b, u, victim, hctx)`, `dmgMul`, -`afterHit`, `afterAttack`, `canAttack`, `hitsFn`, `priority`, `blockFly`, `noHeal`, `boomerang` (the projectile stays -`'boomerang'` whatever the data's generic ranged projectile says), `rangeAoe` (applied after every override: sets -`allInRange` and, on a ranged profile, the instant `'beam'`) (see the header of professions.js). +`afterHit`, `afterAttack`, `canAttack`, `hitsFn`, `priority` (targeting.js PRIORITY_FNS — `'heaviest'`: the 攻城手 trait +"优先攻击重量最重的敌人", the highest current 重量等级 first: 早露 / 提丰), `blockFly`, `noHeal`, `boomerang` (the projectile stays +`'boomerang'` whatever the data's generic ranged projectile says), `boomerangOnward(ctx)` (a boomerang's flight after its +first hit belongs to content: ctx `hit(target, x, y)` / `comeBack(x, y)` — 娜仁图亚 S1's bounces, S2's dash; the loopshooter +row), `rangeAoe` (applied after every override: sets `allInRange` and, on a ranged profile, the instant `'beam'`) (see the +header of professions.js). | sub | behaviour | |---|---| @@ -1154,7 +1158,7 @@ table: `hitSleep` (targets and damages sleeping enemies — "可以攻击沉睡 | blastcaster | `rangeAoe`: every selectable enemy on its line at once, the same damage near and far, instant (`'beam'`) — "超远距离的群体法术伤害" is the whole line, not a splash (primary: PRTS 作战机制 §AOE伤害判定 names 伊芙利特's 炎爆 a 锁定攻击范围 AoE, and 炎爆 is her next-attack skill "下次攻击造成…" (PRTS 伊芙利特 S2), so a 轰击术师 normal attack; secondary: Terra Wiki, Blast Caster; supporting: PRTS 溅射半径一览 documents no splash radius for it; community report E3). A stealthed enemy is not struck unless it is revealed or blocked (a 锁定范围 AoE cannot hit a 隐匿 unit, PRTS 作战机制) | | bombarder | ground-only splash 0.9 (PRTS 溅射半径一览: 投掷手 0.9; 1.0 until 0.1.1) + aftershock(s) at 50 % ATK (bb append_atk_scale / times); 迷迭香's S2 末梢阻断 1.5 (the same table) | | hunter | 8 bullets (bb value), ×1.2 ATK (bb atk_scale), reloads 1/s after 1 s without attacking; can't attack when empty | -| loopshooter | 回环射手 (user playtest #3): every attack throws a boomerang (`ai.js throwBoomerang`, projectile `'boomerang'`) out to the target at 15 tiles/s — it hits on arrival — and back to the thrower's current position at 3.75 tiles/s without damage (PRTS 跃跃 "投射物飞行速度15,返回时飞行速度3.75"); attacks only while holding it (every boomerang thrown caught — "必须回收全部回旋投掷物才可以进行下一次攻击", `unit.trait.boomerangsOut`) and with the attack cooldown ready, so the real interval is the longer of the two; a target dead mid-flight is not hit (it still flies to the last position and back); knocked out / withdrawn ⇒ lost, a redeployed thrower holds a fresh one; 跃跃 S2's extra boomerangs share the one flight (cnt hits) | +| loopshooter | 回环射手 (user playtest #3): every attack throws a boomerang (`ai.js throwBoomerang`, projectile `'boomerang'`) out to the target at 15 tiles/s — it hits on arrival — and back to the thrower's current position at 3.75 tiles/s without damage (PRTS 跃跃 "投射物飞行速度15,返回时飞行速度3.75"); attacks only while holding it (every boomerang thrown caught — "必须回收全部回旋投掷物才可以进行下一次攻击", `unit.trait.boomerangsOut`) and with the attack cooldown ready, so the real interval is the longer of the two; a target dead mid-flight is not hit (it still flies to the last position and back); knocked out / withdrawn ⇒ lost, a redeployed thrower holds a fresh one; 跃跃 S2's extra boomerangs share the one flight (cnt hits); each catch fires `boomerangCaught`, and an attack profile's `boomerangOnward(ctx)` flies it on after the first hit before it turns back — ctx `hit` (a hit of the same attack), `comeBack(x, y)` once (a content error sends it back from the hit point): 娜仁图亚 S1 bounces, S2 dashes on and hits on its way back (kits/ops/op-narant.js) | | reaperrange | hits every enemy in range; ×1.5 (bb atk_scale) on the trait front grid (or its own line ahead) — both along its direction | | chain | chain N (trait text/bb max_target) with −15 % per jump (bb chain.atk_scale), 1.7-tile jumps (constants.js CHAIN_RADIUS, PRTS 溅射半径一览: 链术师 1.7; 1.8 until 0.1.1), sluggish on each hit | | funnel | drone damage 20 % → +15 %/hit on the same target → 110 % (bb init/delta/max) | diff --git a/server/sim/ai.js b/server/sim/ai.js index f40b4882..5e061be6 100644 --- a/server/sim/ai.js +++ b/server/sim/ai.js @@ -185,19 +185,39 @@ export function performAttack(b, u, prof, targets, opts = null) { * current position at BOOMERANG_RETURN_SPEED, dealing nothing on the way back, and is caught (u.trait.boomerangsOut −1: * the thrower attacks again once every boomerang is back). A thrower knocked out / withdrawn meanwhile loses it — nothing * returns to a unit off the field or to a later deployment of it (the deploy hook hands it a fresh one). + * Content: a catch fires the hook `boomerangCaught` { unit, attackId, isSkill, x, y } (娜仁图亚 LPS-Y "每回收5次回旋投射物", + * S3 "投射物全部回收时"); an attack profile with `boomerangOnward(ctx)` (a skill's attack override: 娜仁图亚 S1's bounces, + * S2's dash) takes the flight over after the first hit — ctx { battle, unit, profile, target, x, y, attackId, isSkill, + * home(), hit(target, x, y) (resolveHit with this attack's profile), comeBack(x, y) (sends it back from there, once; + * returns the return projectile or null) } — and a content error there sends it back from the hit point. */ function throwBoomerang(b, u, prof, t, info) { const seq = u.deploySeq; u.trait.boomerangsOut = (u.trait.boomerangsOut || 0) + 1; const home = () => u.alive && u.deployed && u.deploySeq === seq; + let sent = false; + const comeBack = (x, y) => { + if (sent || !home()) return null; + sent = true; + // hitDead: flies on to the thrower's last position even while it is hidden, caught there when it is still home + return b.addProjectile({ from: { x, y }, target: u, speed: BOOMERANG_RETURN_SPEED, visual: 'boomerangReturn', source: u, hitDead: true, + onHit: (r) => { + if (!home() || !(u.trait.boomerangsOut > 0)) return; + u.trait.boomerangsOut--; + if (b._hooks.boomerangCaught) b.emit('boomerangCaught', { unit: u, attackId: info.attackId ?? 0, isSkill: !!info.isSkill, x: r.x, y: r.y }); + } }); + }; b.addProjectile({ from: u, target: t, speed: PROJECTILE_SPEEDS.boomerang, visual: 'boomerang', source: u, hitDead: true, onHit: (c) => { // (guarded on its own: a content error in the hit must not cost the thrower its boomerang for the battle) if (c.target || prof.splashRadius > 0) b._safe(() => resolveHit(b, u, prof, c.target, info, c.x, c.y), 'boomerang.hit', u); if (!home()) return; - // hitDead: flies on to the thrower's last position even while it is hidden, caught there when it is still home - b.addProjectile({ from: { x: c.x, y: c.y }, target: u, speed: BOOMERANG_RETURN_SPEED, visual: 'boomerangReturn', source: u, hitDead: true, - onHit: () => { if (home() && u.trait.boomerangsOut > 0) u.trait.boomerangsOut--; } }); + if (typeof prof.boomerangOnward === 'function') { + const ctx = { battle: b, unit: u, profile: prof, target: c.target, x: c.x, y: c.y, attackId: info.attackId ?? 0, isSkill: !!info.isSkill, + home, comeBack, hit: (tgt, x, y) => b._safe(() => resolveHit(b, u, prof, tgt, info, x, y), 'boomerang.hit', u) }; + if (b._safe(() => { prof.boomerangOnward(ctx); return true; }, 'boomerang.onward', u) === true) return; + } + comeBack(c.x, c.y); } }); } diff --git a/server/sim/targeting.js b/server/sim/targeting.js index 5d2ebcf9..4c4101e2 100644 --- a/server/sim/targeting.js +++ b/server/sim/targeting.js @@ -178,6 +178,9 @@ const PRIORITY_FNS = { boss: (e) => (e.isBoss ? 0 : 1), notBurst: (e) => (e.s.flags.burstLock ? 1 : 0), ground: (e) => (e.isFlying ? 1 : 0), + // 攻城手 trait "优先攻击重量最重的敌人" (早露 / 提丰: kits/ops/op-poca.js, op-typhon.js): the highest current 重量等级 + // (Unit.weight: massLevel with 失重 etc.) first, the usual order after that + heaviest: (e) => -e.weight, }; /** From 5643dab2cb1b2e806788f6188a5383cfe0334848 Mon Sep 17 00:00:00 2001 From: sganggs Date: Tue, 6 Oct 2026 00:02:23 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feedback5=20O7:=20=E8=87=AA=E9=80=89=20oper?= =?UTF-8?q?ator=20kits=20for=20=E6=97=A9=E9=9C=B2,=20=E6=8F=90=E4=B8=B0=20?= =?UTF-8?q?and=20=E5=A8=9C=E4=BB=81=E5=9B=BE=E4=BA=9A=20=E2=80=94=20every?= =?UTF-8?q?=20skill,=20talent,=20trait=20and=20module=20at=20every=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - kits/ops/op-poca.js 早露 (攻城手): heaviest first; 深入骨髓 60 % DEF ignore vs 重量等级 ≥ 3; 学生楷模 ATK on every 【乌萨斯学生自治团】 operator of her team (teamId student); S1 ATK, S2 ATK + 2 targets, S3 雪崩击 links the 3 heaviest, binds them and strikes each every second (6 / 7 attacks, PRTS link-break rules); SIE-X ×1.15 vs heavy + Lv3 60 % ATK extra hit; SIE-Y distance bonus ≤ 12 % + Lv3 学生楷模 +12 % and +15 % per student in skill (≤ 45 %). - kits/ops/op-typhon.js 提丰 (攻城手): 锐如兽牙 stacking DEF ignore; 重如沼泥 first hit per enemy per cast ×1.6 + 停顿 3 s; S1 ATK / ASPD; S2 two arrows (both at a lone enemy), 40 % stun, 20 s on the first cast of a deployment then endless; S3 永恒狩猎 mark (radius 1.3, follows its enemy), whole-field range, 4-arrow rain per bullet with 0.4 s stuns; SIE-X ×1.15, Lv3 12 % / 10 s; SIE-Y distance bonus, Lv3 two marks ×2.2; ISW-A attributes only (集成战略 parts N/A). - kits/ops/op-narant.js 娜仁图亚 (回环射手): 偷取 25 ATK / 20 DEF per landing attack hit (caps 250 / 200; LPS-X Lv3 300 / 240, twice within one tile; the largest thief wins; given back when she leaves); 婀娜虚影 35 % dodge and −20 % 命中率 for enemies within one tile (LPS-Y Lv3 −30 % and ATK +15 % after 6 s unhurt); S1 旋刃 toggle, range −1, up to 3 bounces; S2 恶魇 dash and the sweep on its way back; S3 吞日 3 boomerangs and the recall blast; LPS-X ×1.1 within one tile; LPS-Y 1 SP per 5 boomerangs caught. - Data triggers unchanged (all nine skills MANUAL / DEFAULT). Registered in OPERATOR_KIT_FILES (one line per file). - Tests: test/content/op_poca.test.js (11), op_typhon.test.js (12), op_narant.test.js (11, two on the engine hooks). - Golden: test/golden/diy.json only — diy-003 … diy-009 now field the three kits in every form × module × skill, the prototypes / 预备干员 re-packed into diy-009 … diy-013; diy-001 / diy-002 (推进之王) and every other family unchanged. --- server/sim/content/kits/index.js | 7 +- server/sim/content/kits/ops/op-narant.js | 276 +++++++++++++ server/sim/content/kits/ops/op-poca.js | 170 ++++++++ server/sim/content/kits/ops/op-typhon.js | 212 ++++++++++ test/content/op_narant.test.js | 376 ++++++++++++++++++ test/content/op_poca.test.js | 343 ++++++++++++++++ test/content/op_typhon.test.js | 343 ++++++++++++++++ test/golden/diy.json | 477 ++++++++++++++++++----- 8 files changed, 2104 insertions(+), 100 deletions(-) create mode 100644 server/sim/content/kits/ops/op-narant.js create mode 100644 server/sim/content/kits/ops/op-poca.js create mode 100644 server/sim/content/kits/ops/op-typhon.js create mode 100644 test/content/op_narant.test.js create mode 100644 test/content/op_poca.test.js create mode 100644 test/content/op_typhon.test.js diff --git a/server/sim/content/kits/index.js b/server/sim/content/kits/index.js index b47d568b..afc9418c 100644 --- a/server/sim/content/kits/index.js +++ b/server/sim/content/kits/index.js @@ -78,7 +78,12 @@ export const STANDIN_KIT_FILES = Object.freeze(['standin-acguad.js', 'standin-sh * The 自选 operator kit files (`ops/op-.js`, registry key = the operator's charId, an owned-6★ pick of * data/backups.json; a new file is appended). An operator without a file is not offered as a 自选 pick (KITTED_CHARS). */ -export const OPERATOR_KIT_FILES = Object.freeze(['op-siege.js']); +export const OPERATOR_KIT_FILES = Object.freeze([ + 'op-siege.js', + 'op-poca.js', + 'op-typhon.js', + 'op-narant.js', +]); /** * The 预备干员 whose every skill the generic kit covers exactly (冲锋号令, 攻击力 / 防御力 / 治疗强化, 战术咏唱, 一击即退 — diff --git a/server/sim/content/kits/ops/op-narant.js b/server/sim/content/kits/ops/op-narant.js new file mode 100644 index 00000000..ebb1a7bc --- /dev/null +++ b/server/sim/content/kits/ops/op-narant.js @@ -0,0 +1,276 @@ +// server/sim/content/kits/ops/op-narant.js — 娜仁图亚 (char_4138_narant) 自选 operator kit: 6★ 回环射手 (狙击), an owned-6★ pick +// of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form. +// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)"). +// +// Forms (data/backups.json units.char_4138_narant, the DIY slot statuses): 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). Potential 0 [ASSUMED: no account]. +// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 娜仁图亚 (备注 of 旋刃 +// and 恶魇); PRTS 分支特性信息 回环射手; gamedata_const ba.steal 偷取; PRTS 命中率. +// - Trait (回环射手) "持有回旋投射物时才能够攻击(投射物需要时间回收)": the profession's boomerang (professions.js loopshooter, +// ai.js throwBoomerang: out 15, back 3.75 tiles/s; she attacks again once every boomerang is caught); hits air units. +// - Module LPS-X “吹尽狂沙”: trait "攻击周围8格的敌人时攻击力提升至110%" (trait bb atk_scale; the hidden module talent repeats it): +// ×atk_scale on her attack hits on an enemy within one tile (3 × 3, as 跃跃's LPS-X). Stage 2+ “我见,我得” (below). +// - Module LPS-Y “致我们的老大”: trait "每回收5次回旋投射物时获得1点技力" (come_back_cnt / sp): every come_back_cnt boomerangs caught +// (hook boomerangCaught; an S3 flight carries cnt) give sp SP — none while a skill of hers runs (the AK rule: giveSp) +// [ASSUMED: the catches count all the same]. Stage 2+ 婀娜虚影 (below). +// - T1 “我见,我得” "在场时,每次攻击到敌人偷取其25点攻击力(最高250点)与20点防御力(最高200点)": every attack hit of hers that lands +// (normal attacks, S1 bounces, S2 hits and its way back, each S3 boomerang — not S3's recall blast [ASSUMED]) steals: +// the enemy loses steal_atk ATK / steal_def DEF of its base (flat, at most steal_atk_max / steal_def_max in all) and she +// gains as much (flat, the same caps) — ba.steal "减少目标的基础属性作为自身加成,目标减少和自身加成的属性不超过指定上限(同类属性 +// 取最高)": one loss per enemy, the largest of the 娜仁图亚 that stole from it. Both last while she is on the field (在场时) +// [ASSUMED: they end when she leaves]; each steal adds to both sides, each capped on its own [ASSUMED]. LPS-X stage 2+ (caps +// 275 / 220 → 300 / 240) "攻击到周围8格的敌人时偷取触发2次": the count is parsed from the talent text (no blackboard key). +// - T2 婀娜虚影 "获得35%的物理与法术闪避,周围8格内敌人的物理与法术命中率-20%": dodgePhys / dodgeArts +prob; every physical / arts +// attack of an enemy within one tile of her misses with damage_hitrate_* (PRTS 命中率: one roll when the attack's first +// damage instance comes — as Raidian's 诱引, standin-acsupo.js —, a miss cancels every instance and its riders; two +// 娜仁图亚 never add up). LPS-Y stage 3 −30 % and "6秒内未受到伤害时,攻击力+15%" (hidden atk / interval): ATK +atk while she +// has taken no damage (流失 aside) for `interval` s — from her deployment on, as 远牙's 专注 [ASSUMED]. +// - S1 旋刃 (切换: a toggle that stays on until she leaves — the kits' reading, 银灰 S2 [ASSUMED]): her range loses its far +// column (攻击距离-1: ability_range_forward_extend −1; the engine's rangeExtend only grows), her boomerang hits for +// attack@atk_scale × ATK and then bounces up to attack@times times (PRTS 备注: radius BOUNCE_RADIUS from the boomerang, +// an enemy this attack has not hit first, then the nearest; BOUNCE_SPEED tiles/s), each bounce a hit of the same attack; +// then it flies back. The data's DEFAULT trigger (the running range is smaller). +// - S2 恶魇 (30 s, attack SP): each hit attack@atk_scale × ATK + 停顿 attack@sluggish s; the boomerang then flies on +// move_ahead_time s at DASH_SPEED tiles/s (PRTS 备注 "抵达目标所在位置后以1.0的速度再向前运动0.5s,随后返回") and on its way +// back hits every enemy within attack@projectile_range (备注 "投射物碰撞半径1.0") once for attack@atk_scale_comeback × ATK. +// - S3 吞日 (20 s): each attack throws cnt boomerangs at the target (each attack@atk_scale × ATK; they fly together, as +// 跃跃's S2 [ASSUMED: the text names no other targets]); when they are caught she deals atk_scale_aoe × ATK physical to at +// most attack@aoe.max_target enemies within one tile (her order) and 停顿 `sluggish` s — a skill hit, not an attack. + +import { num, talentBb, moduleBb, traitBb, skillRec, statBuff, toggleBuff, onHitBy, enemiesInGrid, cheb, up, giveSp } from '../shared/tier1.js'; +import { canTargetEnemy } from '../../../targeting.js'; +import { isHpLoss } from '../../../damage.js'; + +const S1 = 'skchr_narant_1'; +const S2 = 'skchr_narant_2'; +const S3 = 'skchr_narant_3'; +/** PRTS 旋刃 备注: "弹射半径1.7…弹射期间投射物移动速度6.0". */ +const BOUNCE_RADIUS = 1.7; +const BOUNCE_SPEED = 6; +/** PRTS 恶魇 备注: "以1.0的速度再向前运动0.5s" (the time is the blackboard's move_ahead_time). */ +const DASH_SPEED = 1; +/** "周围8格": her tile and the eight around it. */ +const AROUND = Object.freeze([[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 0], [0, 1], [1, -1], [1, 0], [1, 1]].map(Object.freeze)); +/** 婀娜虚影's battle-wide `hit` handler runs before the enemy content's own riders (200), as Raidian's 诱引. */ +const MISS_PRIORITY = 300; +const LOOT_KEY = 'talent:narant:loot'; +const STOLEN_KEY = 'narant:stolen'; + +const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; +const count = (v, d) => Math.max(1, Math.floor(num(v, d))); +/** The named talent record `i` (data index ≥ 0) of the composed record. */ +const talentRec = (chess, i) => (chess?.talents ?? []).filter((t) => t && t.index !== -1)[i] ?? null; + +/** A range grid whose rows lose their `n` farthest tiles ("攻击距离-n"). */ +function shortenGrid(grid, n) { + const far = new Map(); + for (const [dr, dc] of grid) far.set(dr, Math.max(far.get(dr) ?? -Infinity, dc)); + return grid.filter(([dr, dc]) => dc <= far.get(dr) - n).map(([dr, dc]) => [dr, dc]); +} + +// ---- T1 偷取: the losses of the enemies, one battle-wide table (同类属性取最高 over several thieves) ---------------------- +/** battle → Map(enemy → Map(thief id → { a, d })). */ +const STOLEN = new WeakMap(); +const stolenOf = (battle) => { let m = STOLEN.get(battle); if (!m) STOLEN.set(battle, (m = new Map())); return m; }; +function applyLoss(battle, e, per) { + let a = 0, d = 0; + for (const v of per.values()) { a = Math.max(a, v.a); d = Math.max(d, v.d); } + if (!(a > 0) && !(d > 0)) { battle.removeBuff(e, STOLEN_KEY); return; } + battle.addBuff(e, { key: STOLEN_KEY, mods: { atkFlat: -a, defFlat: -d }, tags: ['steal'] }); +} + +// ---- T2 命中率: one battle-wide handler over every 娜仁图亚 of the battle ------------------------------------------------- +/** battle → { srcs: [{ unit, phys, arts }], last: WeakMap(enemy → { id, miss }) }. */ +const MISS = new WeakMap(); +function missCut(st, e, type) { + let cut = 0; + for (const s of st.srcs) { + const v = type === 'arts' ? s.arts : s.phys; + if (v > cut && up(s.unit) && cheb(s.unit, e) <= 1) cut = v; + } + return Math.min(1, cut); +} +function missHit(battle, st, ctx) { + const e = ctx.source, d = ctx.dmg; + if (!e || e.side !== 'enemy' || !d || !d.isAttack || d.cancel || (d.type !== 'phys' && d.type !== 'arts')) return; + // one roll per attack: every damage instance of it shares the first instance's result + const prev = d.attackId ? st.last.get(e) : null; + let miss; + if (prev && prev.id === d.attackId) miss = prev.miss; + else { + const cut = missCut(st, e, d.type); + miss = cut > 0 && battle.rng() < cut; + if (d.attackId) st.last.set(e, { id: d.attackId, miss }); + } + if (!miss) return; + d.cancel = true; + ctx.stopPropagation = true; + if (ctx.target) battle.fx('dodge', { x: ctx.target.x, y: ctx.target.y, id: ctx.target.id }); +} +function installMiss(battle, unit, phys, arts) { + if (!(phys > 0) && !(arts > 0)) return; + let st = MISS.get(battle); + if (!st) { + st = { srcs: [], last: new WeakMap() }; + MISS.set(battle, st); + // no owner: it serves every 娜仁图亚 of the battle, whichever is on the field + battle.on('hit', (ctx) => missHit(battle, st, ctx), { priority: MISS_PRIORITY }); + } + st.srcs.push({ unit, phys, arts }); +} + +export default { + char_4138_narant: (bb, chess) => { + const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1); + const tb = traitBb(chess); + const hidden = moduleBb(chess); + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + const cnt = count(b3.cnt, 3); + + /** S1: the boomerang bounces from (ctx.x, ctx.y) — at most attack@times jumps — then flies back. */ + const bounce = (ctx) => { + const { battle, unit } = ctx; + const hit = new Set(ctx.target ? [ctx.target] : []); + let on = ctx.target, x = ctx.x, y = ctx.y, left = count(b1['attack@times'], 3); + const next = () => { + if (left <= 0 || !ctx.home()) { ctx.comeBack(x, y); return; } + const cands = battle.foesInRadius(x, y, BOUNCE_RADIUS).filter((e) => e !== on && canTargetEnemy(unit, e, ctx.profile)); + if (!cands.length) { ctx.comeBack(x, y); return; } + const d2 = (e) => (e.x - x) ** 2 + (e.y - y) ** 2; + cands.sort((a, b) => (hit.has(a) ? 1 : 0) - (hit.has(b) ? 1 : 0) || d2(a) - d2(b) || a.spawnSeq - b.spawnSeq); + const tgt = cands[0]; + left--; + battle.addProjectile({ from: { x, y }, target: tgt, speed: BOUNCE_SPEED, visual: 'boomerang', source: unit, hitDead: true, + onHit: (c) => { + x = c.x; y = c.y; + on = c.target; + if (c.target) { hit.add(c.target); ctx.hit(c.target, c.x, c.y); } + next(); + } }); + battle.fx('bounce', { x, y, id: unit.id, target: tgt.id }); + }; + next(); + }; + + /** S2: the boomerang dashes on, then hits every enemy it passes on its way back (once each). */ + const dash = (ctx) => { + const { battle, unit } = ctx; + let dx = ctx.x - unit.x, dy = ctx.y - unit.y; + const len = Math.hypot(dx, dy); + if (len > 1e-9) { dx /= len; dy /= len; } else { const [fr, fc] = unit.fwd; dx = fc; dy = fr; } + const ahead = DASH_SPEED * Math.max(0, num(b2['attack@move_ahead_time'], 0.5)); + battle.addProjectile({ from: { x: ctx.x, y: ctx.y }, to: { x: ctx.x + dx * ahead, y: ctx.y + dy * ahead }, speed: DASH_SPEED, visual: 'boomerang', source: unit, hitDead: true, + onHit: (c) => { + const back = ctx.comeBack(c.x, c.y); + if (!back) return; + const r = num(b2['attack@projectile_range'], 1), scale = num(b2['attack@atk_scale_comeback'], 1); + const passed = new Set(); + const h = battle.on('tick', () => { + for (const e of battle.foesInRadius(back.x, back.y, r)) { + if (passed.has(e) || !canTargetEnemy(unit, e, ctx.profile)) continue; + passed.add(e); + battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'phys', isAttack: true, isSkill: true, attackId: ctx.attackId, tags: ['skill', 'narant:comeback'] }); + } + if ((back.x === back.tx && back.y === back.ty) || !ctx.home()) battle.off(h); + }, { owner: unit }); + } }); + }; + + /** S3: the recall blast on at most attack@aoe.max_target enemies around her. */ + const blast = (battle, unit) => { + if (!up(unit)) return; + const n = count(b3['attack@aoe.max_target'], 3), scale = num(b3.atk_scale_aoe, 1), slug = num(b3.sluggish); + battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1, id: unit.id, skill: 'narant:sunswallower' }); + for (const e of enemiesInGrid(battle, unit, AROUND, { n, canHitFly: true })) { + battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'phys', isSkill: true, tags: ['skill', 'narant:blast'] }); + if (e.alive && slug > 0) battle.applyStatus(e, 'sluggish', { duration: slug, source: unit }); + } + }; + + return { + skills: { + [S1]: { + kind: 'toggle', + targeting: { rangeGrid: shortenGrid(chess.rangeGrid ?? [[0, 1]], Math.max(0, -Math.round(num(b1.ability_range_forward_extend, -1)))) }, + attack: { atkScale: num(b1['attack@atk_scale'], 1), boomerangOnward: bounce }, + }, + [S2]: { + kind: 'duration', + attack: { atkScale: num(b2['attack@atk_scale'], 1), onHitStatus: { key: 'sluggish', duration: num(b2['attack@sluggish'], 1) }, boomerangOnward: dash }, + }, + [S3]: { kind: 'duration', attack: { atkScale: num(b3['attack@atk_scale'], 1), hits: cnt } }, + }, + talents: [ + { install(battle, unit) { // “我见,我得”: each landing attack hit steals ATK / DEF (LPS-X stage 2+: twice within one tile) + const sa = num(t0['attack@steal_atk']), sd = num(t0['attack@steal_def']); + if (!(sa > 0) && !(sd > 0)) return; + const capA = num(t0['attack@steal_atk_max'], Infinity), capD = num(t0['attack@steal_def_max'], Infinity); + const m = /周围8格的敌人时偷取触发(\d+)次/.exec(String(talentRec(chess, 0)?.desc ?? '')); + const nearTimes = m ? Math.max(1, +m[1]) : 1; + const losses = stolenOf(battle); + const victims = new Set(); + const reset = () => { unit.mem.narantLoot = { a: 0, d: 0 }; }; + reset(); + battle.on('deploy', (c) => { if (c.unit === unit) reset(); }, { owner: unit }); + battle.on('damaged', (c) => { + const e = c.target; + if (c.source !== unit || !c.dmg?.isAttack || !e || e.side !== 'enemy' || !up(unit)) return; + const times = nearTimes > 1 && cheb(unit, e) <= 1 ? nearTimes : 1; + const me = unit.mem.narantLoot; + let per = losses.get(e); + if (!per) losses.set(e, (per = new Map())); + const loss = per.get(unit.id) ?? { a: 0, d: 0 }; + for (let i = 0; i < times; i++) { + me.a = Math.min(capA, me.a + sa); me.d = Math.min(capD, me.d + sd); + loss.a = Math.min(capA, loss.a + sa); loss.d = Math.min(capD, loss.d + sd); + } + per.set(unit.id, loss); + victims.add(e); + battle.addBuff(unit, { key: LOOT_KEY, mods: { atkFlat: me.a, defFlat: me.d }, tags: ['talent'] }); + if (e.alive) applyLoss(battle, e, per); + }, { owner: unit }); + // 在场时: off the field she keeps nothing (her buff goes with the removal) and the enemies get back what she took + battle.on('death', (c) => { + if (c.unit !== unit) return; + for (const e of victims) { + const per = losses.get(e); + if (!per || !per.delete(unit.id)) continue; + if (e.alive) applyLoss(battle, e, per); + } + victims.clear(); + }, { owner: unit }); + } }, + { install(battle, unit) { // 婀娜虚影: dodge; enemies within one tile miss; LPS-Y stage 3: ATK + while unhurt + const p = num(t1.prob); + statBuff(battle, unit, 'talent:narant:dodge', { dodgePhys: p, dodgeArts: p }); + installMiss(battle, unit, Math.abs(num(t1.damage_hitrate_physical)), Math.abs(num(t1.damage_hitrate_magical))); + const atk = num(hidden.atk), iv = num(hidden.interval); + if (!(atk > 0) || !(iv > 0)) return; + unit.mem.narantHurt = -Infinity; + battle.on('damaged', (c) => { if (c.target === unit && c.amount > 0 && !isHpLoss(c.dmg)) unit.mem.narantHurt = battle.time; }, { owner: unit }); + battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.narantHurt = -Infinity; }, { owner: unit }); + toggleBuff(battle, unit, 'talent:narant:calm', () => battle.time - unit.mem.narantHurt >= iv - 1e-9, { atkPct: atk }); + } }, + ], + install(battle, unit) { + // LPS-X: ×atk_scale on attack hits within one tile + const near = num(tb.atk_scale, 1); + if (near !== 1) onHitBy(battle, unit, ({ target, dmg }) => { if (dmg.isAttack && cheb(unit, target) <= 1) dmg.amount *= near; }); + // LPS-Y: sp SP every come_back_cnt boomerangs caught; S3: the recall blast + const every = Math.floor(num(tb.come_back_cnt)), sp = num(tb.sp); + const s3 = unit.skill?.id === S3; + if (!(every > 0 && sp > 0) && !s3) return; + unit.mem.narantCaught = 0; + battle.on('boomerangCaught', (c) => { + if (c.unit !== unit) return; + const flightS3 = s3 && c.isSkill; + if (every > 0 && sp > 0) { + unit.mem.narantCaught += flightS3 ? cnt : 1; + while (unit.mem.narantCaught >= every) { unit.mem.narantCaught -= every; giveSp(unit, sp, 'module'); } + } + if (flightS3) blast(battle, unit); + }, { owner: unit }); + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/op-poca.js b/server/sim/content/kits/ops/op-poca.js new file mode 100644 index 00000000..e10e3ed0 --- /dev/null +++ b/server/sim/content/kits/ops/op-poca.js @@ -0,0 +1,170 @@ +// server/sim/content/kits/ops/op-poca.js — 早露 (char_197_poca) 自选 operator kit: 6★ 攻城手 (狙击), an owned-6★ pick of the +// tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form. +// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)"). +// +// Forms (data/backups.json units.char_197_poca, the DIY slot statuses): 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). Potential 0 [ASSUMED: no account]. +// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 早露 (备注 of +// 深入骨髓, 学生楷模 and 雪崩击); PRTS 分支特性信息 攻城手 ("可对空。攻击范围不包含自身所在地块"). +// - Trait (攻城手) "优先攻击重量最重的敌人": target priority 'heaviest' (targeting.js: the highest current 重量等级 first, then +// the usual order); ranged physical arrows that hit air units (data canHitFly, PRTS "可对空"), range 4-3. +// - Module SIE-X 攻城器械训练装置: trait "攻击重量较重(重量等级大于等于3)的敌人时,攻击力提升至115%" — the hidden module talent +// (atk_scale / value, merged into 深入骨髓's blackboard): ×atk_scale on her attacks (normal attacks and the S3 strikes, the +// profile dmgMul — the kits' "攻击…时攻击力提升至X%" convention, 斯卡蒂 DRE-X) on an enemy of 重量等级 ≥ value. Stage 2+ 深入骨髓 +// "…并额外造成60%攻击力的物理伤害" (extra_atk_scale): every attack hit that lands on such an enemy is followed by a physical hit +// of extra_atk_scale × ATK (which 深入骨髓's 物理穿透 also gets). +// - Module SIE-Y “心”: trait "攻击越远的敌人造成的伤害越高(最高提升12%)" (trait bb min_dist / max_dist / damage_scale): her attack +// damage × (1 + damage_scale × clamp((d − min_dist) / (max_dist − min_dist), 0, 1)), d = her distance to the target in tiles — +// the kits' reading of these keys (远牙, 协律, 芳汀 modules) [ASSUMED linear: PRTS states no formula]. Stage 2+: 学生楷模 below. +// - T1 深入骨髓 "攻击重量较重(重量等级大于等于3)的敌人时,无视其防御力的60%": every damage she deals to an enemy of 重量等级 ≥ value +// ignores def_penetrate of its DEF (DamageInfo defIgnorePct, added — PRTS 备注 "临时获取永久的物理穿透(百分比)Buff(不可叠加, +// 直接加算)"). +// - T2 学生楷模 "编入队伍时,所有【乌萨斯学生自治团】干员攻击力+8%": every 【乌萨斯学生自治团】 operator of her player's team ATK +atk +// for the whole battle, deployed or not (the kits' 编入队伍时 convention, 斯卡蒂 深海掠食者). SIE-Y stage 3: +12 % and +// "每有一名【乌萨斯学生自治团】干员处于技能期间时,【乌萨斯学生自治团】干员的攻击力额外+15%(最多+45%)" (hidden init_atk / +// max_atk): the number of those operators on the field whose skill runs, re-read every STUDENT_TICK s and at every skill start +// (PRTS 备注 "每0.3秒及每次开启技能时更新一次自身的额外加成") [ASSUMED: her team's operators only, as the base talent]. +// - S1 攻击力强化·γ型 (30 s): ATK +atk. S2 分裂射击 (60 s): ATK +atk, attack@max_target targets. +// - S3 雪崩击 (6 / 7 s): ATK +atk; at the cast, harpoons link the max_target heaviest enemies of her range (her own target +// order); each linked enemy is 束缚 (bind) while linked and takes one attack every hit_interval s, hit_duration / +// hit_interval attacks in all, the first as the harpoons land (the tick after the cast) [ASSUMED] — her own attacks (Battle.forceAttack: +// every attack rider of hers applies), instant along the links; she makes no other attack meanwhile [ASSUMED: the +// strikes are what "每秒受到一次攻击" names]. PRTS 备注: a link breaks when its enemy is knocked out, 离地 (OUT_OF_GROUND — +// no enemy of the mode holds it) or 消失 (hidden), every link at once when she is stunned / frozen / silenced, and the +// skill ends as soon as no link is left. The binds end with their links. + +import { num, talentBb, moduleBb, moduleOn, traitBb, skillRec, statBuff, onHitBy, enemiesInGrid, up } from '../shared/tier1.js'; +import { hasHp } from '../../../damage.js'; + +const S1 = 'skcom_atk_up[3]'; +const S2 = 'skchr_poca_2'; +const S3 = 'skchr_poca_3'; +/** 【乌萨斯学生自治团】: character_table teamId `student` (古米, 凛冬, 烈夏, 苦艾, 真理, 早露, 怒潮凛冬); data/chess.json has no team id. */ +const STUDENTS = new Set(['char_196_sunbr', 'char_115_headbr', 'char_194_leto', 'char_405_absin', 'char_195_glassb', + 'char_197_poca', 'char_1051_headb2']); +/** PRTS 学生楷模 备注: the SIE-Y extra is re-read every 0.3 s (and at every skill start). */ +const STUDENT_TICK = 0.3; +const STUDENT_KEY = 'talent:poca:students'; +const DRIVE_KEY = 'talent:poca:studentSkills'; + +const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; +const count = (v, d) => Math.max(1, Math.floor(num(v, d))); +const isStudent = (a) => !!a && a.kind === 'op' && STUDENTS.has(a.def?.charId); +/** A running skill of `a` (a passive is no 技能期间). */ +const inSkill = (a) => !!(a.skill && a.skill.active && a.skill.kind !== 'passive'); + +export default { + char_197_poca: (bb, chess) => { + const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1); + const heavyAt = num(t0.value, 3); + const heavy = (e) => !!e && e.side === 'enemy' && e.weight >= heavyAt; + // SIE-X (hidden part on 深入骨髓's index: atk_scale / value; stage 2+ extra_atk_scale), SIE-Y (trait bb; hidden + // init_atk / max_atk at stage 3) — chess is the loadout-resolved record, so no module ⇒ none of these keys + const xScale = moduleOn(chess) ? num(t0.atk_scale, 1) : 1; + const extra = num(t0.extra_atk_scale); + const tb = traitBb(chess); + const hidden = moduleBb(chess); + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + + /** S3: the links of the running cast (unit.mem.pocaS3 = { links: Set, left, timer }). */ + const unbind = (battle, unit, e) => { + const b = e.findBuff('bind'); + if (b && b.source === unit) battle.removeBuff(e, b); + }; + const linkBroken = (e) => !e.alive || !e.deployed || e.hidden || !!e.s.flags.outOfGround; + const strike = (battle, unit, st) => { + const targets = [...st.links].filter((e) => !linkBroken(e)); + if (!targets.length || !unit.canAct || st.left <= 0) return; + st.left--; + battle.forceAttack(unit, targets); + }; + + return { + skills: { + [S1]: { kind: 'duration', mods: { atkPct: num(b1.atk) } }, + [S2]: { kind: 'duration', mods: { atkPct: num(b2.atk) }, targeting: { maxTargets: count(b2['attack@max_target'], 2) } }, + [S3]: { + kind: 'duration', + mods: { atkPct: num(b3.atk) }, + // no attack of her own while the links hold; the strikes are instant along them ('beam') + attack: { noAttack: true, projectile: 'beam' }, + onStart({ battle, unit, skill }) { + const links = enemiesInGrid(battle, unit, null, { n: count(b3.max_target, 3), priority: 'heaviest', canHitFly: true }); + const st = { links: new Set(links), left: count(num(b3.hit_duration, skill.duration) / Math.max(0.1, num(b3.hit_interval, 1)), 6), timer: null }; + unit.mem.pocaS3 = st; + for (const e of links) { + battle.applyStatus(e, 'bind', { duration: skill.timeLeft, source: unit }); + battle.fx('harpoon', { x: e.x, y: e.y, id: unit.id, target: e.id }); + } + // the harpoons land on the next tick (after the cast's skillStart handlers — 学生楷模's re-read), then every + // hit_interval s + st.timer = battle.after(0, () => { + if (unit.mem.pocaS3 !== st) return; + strike(battle, unit, st); + st.timer = battle.every(Math.max(0.1, num(b3.hit_interval, 1)), (b, sc) => { + if (unit.mem.pocaS3 !== st || st.left <= 0) { sc.cancel(); return; } + strike(battle, unit, st); + }, { owner: unit }); + }, { owner: unit }); + }, + onTick({ battle, unit, skill }) { + const st = unit.mem.pocaS3; + if (!st) return; + const f = unit.s.flags; + if (f.stun || f.freeze || f.silence) { skill.end('interrupted'); return; } + for (const e of [...st.links]) if (linkBroken(e)) { st.links.delete(e); unbind(battle, unit, e); } + if (!st.links.size) skill.end('unlinked'); + }, + onEnd({ battle, unit }) { + const st = unit.mem.pocaS3; + unit.mem.pocaS3 = null; + if (!st) return; + if (st.timer) st.timer.cancel(); + for (const e of st.links) unbind(battle, unit, e); + }, + }, + }, + talents: [ + { install(battle, unit) { // 深入骨髓: +def_penetrate 物理穿透 on heavy enemies; SIE-X stage 2+: the extra physical hit + const pen = num(t0.def_penetrate); + if (pen > 0) onHitBy(battle, unit, ({ target, dmg }) => { if (heavy(target)) dmg.defIgnorePct += pen; }); + if (extra > 0) { + battle.on('damaged', (c) => { + if (c.source !== unit || !c.dmg?.isAttack || !heavy(c.target) || !hasHp(c.target)) return; + battle.dealDamage(unit, c.target, { amount: unit.s.atk * extra, type: 'phys', tags: ['talent', 'poca:extra'] }); + }, { owner: unit }); + } + } }, + { install(battle, unit) { // 学生楷模: every student of her team ATK +atk; SIE-Y stage 3: +init_atk per student in skill + const team = battle.allyUnits.filter((a) => a.ownerId === unit.ownerId && isStudent(a)); + for (const a of team) statBuff(battle, a, STUDENT_KEY, { atkPct: num(t1.atk) }); + const per = num(hidden.init_atk), cap = num(hidden.max_atk, Infinity); + if (!(per > 0)) return; + const update = () => { + const v = Math.min(cap, per * team.filter((a) => up(a) && inSkill(a)).length); + for (const a of team) { + const cur = a.findBuff(DRIVE_KEY); + if (!(v > 0)) { if (cur) battle.removeBuff(a, cur); continue; } + if (!cur || cur.mods?.atkPct !== v) battle.addBuff(a, { key: DRIVE_KEY, mods: { atkPct: v }, persist: true, allowDead: true, tags: ['talent'], source: unit }); + } + }; + battle.every(STUDENT_TICK, update, { owner: unit, immediate: true }); + battle.on('skillStart', (c) => { if (team.includes(c.unit)) update(); }, { owner: unit }); + } }, + ], + // 攻城手: heaviest first; SIE-X ×atk_scale on her attacks against heavy enemies + trait: { priority: 'heaviest', ...(xScale !== 1 ? { dmgMul: (b, u, target) => (heavy(target) ? xScale : 1) } : null) }, + install(battle, unit) { + // SIE-Y: the farther the target, the more damage (attacks only) + const ds = num(tb.damage_scale), lo = num(tb.min_dist, 1), hi = num(tb.max_dist, 4.5); + if (ds > 0) { + onHitBy(battle, unit, ({ target, dmg }) => { + if (!dmg.isAttack) return; + const d = Math.hypot(target.x - unit.x, target.y - unit.y); + dmg.mul *= 1 + ds * Math.max(0, Math.min(1, (d - lo) / Math.max(1e-6, hi - lo))); + }); + } + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/op-typhon.js b/server/sim/content/kits/ops/op-typhon.js new file mode 100644 index 00000000..3ce93f0d --- /dev/null +++ b/server/sim/content/kits/ops/op-typhon.js @@ -0,0 +1,212 @@ +// server/sim/content/kits/ops/op-typhon.js — 提丰 (char_2012_typhon) 自选 operator kit: 6★ 攻城手 (狙击), an owned-6★ pick of +// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and all three modules at every form. +// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)"). +// +// Forms (data/backups.json units.char_2012_typhon, the DIY slot statuses): 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). Potential 0 [ASSUMED: no account]. +// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 提丰 (备注 of 锐如兽牙, +// 重如沼泥, 冰原秩序 and “永恒狩猎”); PRTS 分支特性信息 攻城手 ("可对空"). +// - Trait (攻城手) "优先攻击重量最重的敌人": target priority 'heaviest' (targeting.js); ranged physical arrows that hit air units, +// range 4-3 (as 早露, kits/ops/op-poca.js). +// - Module SIE-X 自然的包容: trait "攻击重量较重(重量等级大于等于3)的敌人时,攻击力提升至115%" (the hidden module talent's atk_scale / +// value on 锐如兽牙's index): ×atk_scale on her attacks against an enemy of 重量等级 ≥ value (profile dmgMul); stage 2+ 锐如兽牙's +// numbers (12 % a stack, 10 s) come with the composed talent. +// - Module SIE-Y 冰原的影子: trait "攻击越远的敌人造成的伤害越高(最高提升12%)" — as 早露's SIE-Y (her attack damage, linear between +// min_dist and max_dist [ASSUMED]); ASPD in the stats; stage 2+ 重如沼泥 "前两次…攻击力提升至220%" (max_stack_cnt / atk_scale). +// - Module ISW-A 提丰特限证章: its trait and talent parts say "在集成战略中" — no effect in this mode (the hidden damage_scale / +// arrow-rain blackboards are ignored); its HP / ATK are in the stats. +// - T1 锐如兽牙 "连续攻击时逐渐无视敌人的防御力,最高无视其防御力的50%(每次攻击提升10%的无视防御比例),8秒内未攻击则失去加成": every +// attack she makes adds a stack (max_stack_cnt) of +def_penetrate 物理穿透 (defIgnorePct, added — PRTS 备注 "增加自身物理穿透(百分 +// 比)属性(直接加算)") before its damage ("获得的加成可应用于当次伤害"), all of them lost `duration` s after her last attack. +// - T2 重如沼泥 "技能期间对每个敌人首次造成伤害时,攻击力提升至160%并使目标停顿3秒": while a skill of hers runs, her first (SIE-Y stage +// 2+: first max_stack_cnt) damage instances on each enemy deal ×atk_scale (攻击力倍率) and, when they land, 停顿 `sluggish` s — +// every damage of hers, the S3 arrow rain included ("造成伤害"); the marks are cleared when the skill ends or she leaves +// (PRTS 备注 "非首次标记…自身技能结束时或自身离场时移除"). A dodged instance spends its mark [ASSUMED: "于计算伤害前触发"]. +// - S1 迅捷打击·γ型 (35 s): ATK +atk, ASPD +attack_speed. +// - S2 冰原秩序: ATK +atk; every attack shoots two arrows, at two different enemies when there are two (targeting 2), else both +// at the one (PRTS "优先攻击不同目标"); each arrow attack@prob to stun attack@stun s. first_duration s on its first cast of a +// deployment, then "持续时间无限" until she leaves (a toggle the kit ends after first_duration s; PRTS 备注 "每次部署提丰时, +// 重新计算该技能的使用次数"). +// - S3 “永恒狩猎” (ammo attack@s3_trigger_time): base attack time +base_attack_time s (flat: batMod). At the cast she marks the first +// enemy of her own range (未开启技能时的攻击范围: the initial range, her heaviest-first order); the mark area (radius MARK_RADIUS, +// 碰撞判定 — foesInRadius) follows that enemy until it leaves the field, then stays; its enemies are re-read every MARK_SCAN s. +// Her range is the whole field (the card shows 全场) but she attacks only while an enemy is in the mark area; each attack +// deals nothing itself and drops a round of s3_max_hit_num arrows, RAIN_GAP s apart, each on a random enemy of the area: a +// physical skill hit (not an attack: PRTS "不属于普通攻击/技能攻击的直接伤害", so the SIE-X / SIE-Y attack riders do not apply +// [ASSUMED]) of s3_atk_scale × ATK, then 晕眩 s3_stun s once that damage landed. +// Auto-cast: the data's DEFAULT (the whole-field range exists only in PRTS's note; the mark is picked from her own range). + +import { num, talentBb, moduleOn, traitBb, skillRec, batMod, onHitBy, skillBusy, up } from '../shared/tier1.js'; +import { canTargetEnemy, sortEnemyTargets } from '../../../targeting.js'; +import { ROWS, COLS } from '../../../constants.js'; + +const S1 = 'skcom_quickattack[3]'; +const S2 = 'skchr_typhon_2'; +const S3 = 'skchr_typhon_3'; +/** PRTS S3 备注: "标记区域半径1.3(碰撞判定),每0.2s更新范围内的敌人", "箭雨的每支箭间隔0.1s且分别索敌". */ +const MARK_RADIUS = 1.3; +const MARK_SCAN = 0.2; +const RAIN_GAP = 0.1; +/** S3 "攻击范围扩大至全场": every offset that reaches the whole 19 × 21 field whatever her direction. */ +const FIELD = (() => { + const m = Math.max(ROWS, COLS) - 1; + const g = []; + for (let dr = -m; dr <= m; dr++) for (let dc = -m; dc <= m; dc++) g.push(Object.freeze([dr, dc])); + return Object.freeze(g); +})(); +const FANG_KEY = 'talent:typhon:fang'; + +const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; +const count = (v, d) => Math.max(1, Math.floor(num(v, d))); + +export default { + char_2012_typhon: (bb, chess) => { + const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1); + const tb = traitBb(chess); + // SIE-X: the hidden part merged into 锐如兽牙's blackboard (no module / SIE-Y / ISW-A ⇒ no atk_scale there) + const xScale = moduleOn(chess) ? num(t0.atk_scale, 1) : 1; + const heavyAt = num(t0.value, 3); + const heavy = (e) => !!e && e.side === 'enemy' && e.weight >= heavyAt; + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + + /** S3: the enemies of the mark area (last scan) her attacks may still select. */ + const areaFoes = (unit, st) => st.inArea.filter((e) => e.alive && e.deployed && !e.hidden && canTargetEnemy(unit, e, unit.profile)); + const follow = (st) => { const t = st.target; if (t && t.alive && t.deployed && !t.hidden) { st.x = t.x; st.y = t.y; } }; + const scan = (battle, unit, st) => { st.inArea = battle.foesInRadius(st.x, st.y, MARK_RADIUS).filter((e) => canTargetEnemy(unit, e, unit.profile)); }; + const rain = (battle, unit) => { + const st = unit.mem.typhonMark; + if (!st) return; + battle.fx('arrowRain', { x: st.x, y: st.y, radius: MARK_RADIUS, id: unit.id }); + const scale = num(b3['attack@s3_atk_scale'], 1), stun = num(b3['attack@s3_stun']); + const arrow = () => { + const pool = areaFoes(unit, st); + if (!pool.length) return; + const e = battle.rng.pick(pool); + const dealt = battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'phys', isSkill: true, tags: ['skill', 'typhon:rain'] }); + if (dealt > 0 && e.alive && stun > 0) battle.applyStatus(e, 'stun', { duration: stun, source: unit }); + }; + arrow(); + for (let k = 1; k < count(b3['attack@s3_max_hit_num'], 4); k++) battle.after(RAIN_GAP * k, arrow, { owner: unit }); + }; + + return { + skills: { + [S1]: { kind: 'duration', mods: { atkPct: num(b1.atk), aspd: num(b1.attack_speed) } }, + [S2]: { + kind: 'toggle', + mods: { atkPct: num(b2.atk) }, + targeting: { maxTargets: 2 }, + attack: { + onHit({ battle, unit, target }) { + if (target && target.alive && battle.rng.chance(num(b2['attack@prob']))) battle.applyStatus(target, 'stun', { duration: num(b2['attack@stun'], 1), source: unit }); + }, + }, + onStart({ unit, skill }) { + unit.mem.typhonS2Uses = (unit.mem.typhonS2Uses ?? 0) + 1; + skill.timeLeft = unit.mem.typhonS2Uses > 1 ? Infinity : Math.max(0.01, num(b2.first_duration, skill.duration)); + }, + onTick({ skill, dt }) { + if (!Number.isFinite(skill.timeLeft)) return; + skill.timeLeft -= dt; + if (skill.timeLeft <= 1e-9) skill.end('duration'); + }, + }, + [S3]: { + kind: 'ammo', + ammo: count(b3['attack@s3_trigger_time'], 8), + mods: { batPct: batMod(b3.base_attack_time, chess) }, + targeting: { rangeGrid: FIELD }, + attack: { + projectile: 'beam', + hitsFn: () => 0, + canAttack: (battle, unit) => !!unit.mem.typhonMark && areaFoes(unit, unit.mem.typhonMark).length > 0, + onHit({ battle, unit }) { rain(battle, unit); }, + }, + onStart({ battle, unit }) { + const cands = battle.enemiesInKeys(unit.baseRangeKeys ?? unit.rangeKeys, unit, unit.profile); + sortEnemyTargets(battle, unit, cands, 'heaviest'); + const t = cands[0] ?? null; + const st = { target: t, x: t ? t.x : unit.x, y: t ? t.y : unit.y, inArea: [], timer: null }; + unit.mem.typhonMark = st; + scan(battle, unit, st); + st.timer = battle.every(MARK_SCAN, (b, sc) => { + if (unit.mem.typhonMark !== st) { sc.cancel(); return; } + follow(st); + scan(battle, unit, st); + }, { owner: unit }); + battle.fx('mark', { x: st.x, y: st.y, radius: MARK_RADIUS, id: unit.id, target: t ? t.id : null }); + }, + onTick({ unit }) { const st = unit.mem.typhonMark; if (st) follow(st); }, + onEnd({ unit }) { + const st = unit.mem.typhonMark; + unit.mem.typhonMark = null; + if (st && st.timer) st.timer.cancel(); + }, + }, + }, + talents: [ + { install(battle, unit) { // 锐如兽牙: +def_penetrate 物理穿透 a stack per attack (max_stack_cnt), gone `duration` s after the last + const per = num(t0.def_penetrate); + if (!(per > 0)) return; + const max = count(t0.max_stack_cnt, 5), dur = num(t0.duration, 8); + battle.on('beforeAttack', (ctx) => { + if (ctx.attacker !== unit || !up(unit)) return; + battle.addBuff(unit, { key: FANG_KEY, duration: dur, refresh: 'stack', maxStacks: max, mods: { defIgnorePct: per }, tags: ['talent'] }); + }, { owner: unit }); + } }, + { install(battle, unit) { // 重如沼泥: during a skill, the first max_stack_cnt damages on each enemy ×atk_scale + 停顿 + const scale = num(t1.atk_scale, 1), slug = num(t1.sluggish), max = count(t1.max_stack_cnt, 1); + const marked = new WeakSet(); + battle.on('skillStart', (c) => { if (c.unit === unit) unit.mem.typhonMud = new Map(); }, { owner: unit }); + battle.on('skillEnd', (c) => { if (c.unit === unit) unit.mem.typhonMud = null; }, { owner: unit }); + battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.typhonMud = null; }, { owner: unit }); + onHitBy(battle, unit, ({ target, dmg }) => { + const marks = unit.mem.typhonMud; + if (!marks || !skillBusy(unit)) return; + const n = marks.get(target.id) ?? 0; + if (n >= max) return; + marks.set(target.id, n + 1); + dmg.amount *= scale; + marked.add(dmg); + }); + battle.on('damaged', (c) => { + if (c.source !== unit || !marked.has(c.dmg) || !c.target.alive || !(slug > 0)) return; + battle.applyStatus(c.target, 'sluggish', { duration: slug, source: unit }); + }, { owner: unit }); + } }, + ], + // 攻城手: heaviest first; SIE-X ×atk_scale on her attacks against heavy enemies + trait: { priority: 'heaviest', ...(xScale !== 1 ? { dmgMul: (b, u, target) => (heavy(target) ? xScale : 1) } : null) }, + install(battle, unit) { + // S2: two arrows per attack — both at the one enemy when it is the only target + if (unit.skill?.id === S2) { + battle.on('beforeAttack', (ctx) => { + if (ctx.attacker !== unit || !unit.skill.active || ctx.targets.length !== 1) return; + ctx.targets = [ctx.targets[0], ctx.targets[0]]; + }, { owner: unit }); + } + // S3: the round is aimed at the marked enemy (else another one of the area): its 'atk' line points there + if (unit.skill?.id === S3) { + battle.on('beforeAttack', (ctx) => { + const st = unit.mem.typhonMark; + if (ctx.attacker !== unit || !unit.skill.active || !st) return; + const pool = areaFoes(unit, st); + const t = pool.includes(st.target) ? st.target : pool[0]; + if (t) ctx.targets = [t]; + }, { owner: unit }); + } + // S2 counts its casts per deployment + battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.typhonS2Uses = 0; }, { owner: unit }); + // SIE-Y: the farther the target, the more damage (attacks only) + const ds = num(tb.damage_scale), lo = num(tb.min_dist, 1), hi = num(tb.max_dist, 4.5); + if (ds > 0) { + onHitBy(battle, unit, ({ target, dmg }) => { + if (!dmg.isAttack) return; + const d = Math.hypot(target.x - unit.x, target.y - unit.y); + dmg.mul *= 1 + ds * Math.max(0, Math.min(1, (d - lo) / Math.max(1e-6, hi - lo))); + }); + } + }, + }; + }, +}; diff --git a/test/content/op_narant.test.js b/test/content/op_narant.test.js new file mode 100644 index 00000000..bc1bc857 --- /dev/null +++ b/test/content/op_narant.test.js @@ -0,0 +1,376 @@ +// test/content/op_narant.test.js — the 自选 operator kit of 娜仁图亚 (char_4138_narant, 6★ 回环射手; kit +// server/sim/content/kits/ops/op-narant.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in +// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, LPS-X +// “吹尽狂沙” or LPS-Y “致我们的老大” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the +// form of that slot status); the fidelity checklist of kits/README.md item by item. Also the two engine hooks of the +// boomerang flight it relies on (ai.js throwBoomerang: `boomerangCaught`, a profile's `boomerangOnward`). +// Run: node --test test/content/op_narant.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +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 { PROJECTILE_SPEEDS, BOOMERANG_RETURN_SPEED } from '../../server/sim/constants.js'; + +const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8')); +const CHESS = load('chess'); +const BACKUPS = load('backups'); +const NARANT = 'char_4138_narant'; +const FORMS = BACKUPS.units[NARANT].forms; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const LPSX = 'uniequip_002_narant', LPSY = 'uniequip_003_narant'; +const S1 = 'skchr_narant_1', S2 = 'skchr_narant_2', S3 = 'skchr_narant_3'; +const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0']; +const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id); +const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null); +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_brute: dummy('enemy_brute', { atk: 400, def: 300 }), + enemy_fly: dummy('enemy_fly', { motion: 'FLY' }), + // a ranged attacker that shoots twice a second at whatever ally its 2.5-tile radius holds (an ATK her steals cannot drain) + enemy_shooter: dummy('enemy_shooter', { atk: 5000, bat: 0.5, range: 2.5 }), +}; +/** Every 自选 form: [tier, elite, module]. */ +const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, LPSX, LPSY].map((m) => [t, true, m]))]; +/** LPS-Y stage 3 婀娜虚影: ATK +15 % while unhurt — she starts unhurt. */ +const calmAtk = (tier, elite, mod) => (elite && mod === LPSY && tier === 6 ? 0.15 : 0); + +/** A battle with 娜仁图亚 as uid 1 at (row, col) facing RIGHT, plus `others`. */ +function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 3, others = [], seed = 5, hooks = [] } = {}) { + const h = makeBattle({ + defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, + flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'boomerangCaught', 'spGain', 'attack', ...hooks], captureNoisy: true, + units: [{ uid: 1, diy: { slot: SLOT[tier], charId: NARANT, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others], + }); + h.step(); + return { h, u: h.unit(1) }; +} +const hitsBy = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u); +const atkHits = (h, u) => hitsBy(h, u).filter((c) => c.dmg?.isAttack); +function done(h) { + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); +} +const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`; +const TICK = 1 / 30; + +test('娜仁图亚 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, y-7 range, ranged physical boomerangs that hit air, block 1, 35 % dodge, no 特质', () => { + assert.equal(OPERATOR_KITS[NARANT], KITS[NARANT]); + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + for (const skill of [0, 1, 2]) { + const { h, u } = field({ tier, elite, mod, skill }); + const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null; + assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [NARANT, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f)); + assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0)], `${label(f)}: stats`); + assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.projectile, u.profile.canHitFly, u.base.bat, typeof u.profile.canAttack], + [1, 'ranged', 'phys', 'boomerang', true, 1, 'function'], `${label(f)}: 回环射手`); + assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: y-7`); + assert.deepEqual([u.s.dodgePhys, u.s.dodgeArts], [0.35, 0.35], `${label(f)}: 婀娜虚影 dodge`); + assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [BACKUPS.diy.operators[NARANT].bonds, []], `${label(f)}: bonds / 特质`); + assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`); + done(h); + } + } + // E2 Lv1 2000 / 546 / 144, E2 Lv60 2331 / 632 / 161; LPS-X +25 / +25 → +45 / +45 ATK / DEF, LPS-Y +210 / +20 → +300 / +40 HP / ATK + assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [2000, 546, 2331, 632]); + assert.deepEqual([modOf(5, LPSX).attr, modOf(6, LPSX).attr, modOf(5, LPSY).attr, modOf(6, LPSY).attr], [{ atk: 25, def: 25 }, { atk: 45, def: 45 }, { maxHp: 210, atk: 20 }, { maxHp: 300, atk: 40 }]); +}); + +test('a 自选 pick: 娜仁图亚 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => { + const data = { chess: CHESS, backups: BACKUPS }; + assert.ok(KITTED_CHARS.includes(NARANT)); + for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(NARANT), `tier ${t}`); + assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: NARANT, skillIndex: 0, uniEquipId: LPSX } }, { data, kitted: KITTED_CHARS }), + { ok: true, picks: { [SLOT[5]]: { charId: NARANT, skillIndex: 0, uniEquipId: LPSX } } }); +}); + +test('trait 回环射手 (engine): one boomerang out at 15 and back at 3.75 tiles/s, the next throw once it is caught — each catch fires boomerangCaught { unit, attackId, isSkill }; a flyer is hit', () => { + const { h, u } = field({ tier: 6, elite: true, skill: 2 }); + const fly = h.spawn('enemy_fly', { pos: [10, 6] }); + h.run(6); + const hits = atkHits(h, u), caught = h.hooksOf('boomerangCaught').filter((c) => c.unit === u); + assert.ok(hits.length >= 2 && hits.every((c) => c.target === fly), '可对空'); + assert.ok(caught.length >= 2); + for (const c of caught) assert.ok(hits.some((x) => x.dmg.attackId === c.attackId) && c.isSkill === false, 'the catch names its attack'); + const d = 3; + const flight = d / PROJECTILE_SPEEDS.boomerang + d / BOOMERANG_RETURN_SPEED; + approx(caught[0].t - hits[0].t, d / BOOMERANG_RETURN_SPEED, 'back at 3.75 tiles/s', 0.1); + approx(hits[1].t - hits[0].t, flight, 'she waits for it (longer than her 1 s interval)', 0.1); + assert.ok(h.eventsOf('atk').filter((e) => e[1] === u.id).every((e) => e[3] === 'boomerang')); + done(h); +}); + +test('S1 旋刃 (切换: on until she leaves; data DEFAULT): range −1 (the far column of y-7), 140 % / 160 % ATK, then up to 3 bounces (radius 1.7, an enemy this attack has not hit first, then the nearest; 6 tiles/s), then back; a lone enemy: no bounce', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S1); + const { h, u } = field({ tier, elite, skill: 0 }); + assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.spCost, u.skill.initSp, sk.bb['attack@times'], sk.bb['attack@atk_scale'], sk.bb.ability_range_forward_extend], + ['DEFAULT', 'toggle', elite ? 8 : 9, 0, 3, elite ? 1.6 : 1.4, -1], `T${tier}`); + const lone = h.spawn('enemy_dummy', { pos: [10, 5] }); + assert.ok(h.runUntil(() => u.skill.active, 12), `T${tier}: on once its SP is full and an enemy is in range`); + const short = formOf(tier, elite).rangeGrid.filter(([, c]) => c < 3); + assert.deepEqual(u.liveRangeGrid, short, `T${tier}: 攻击距离-1`); + h.run(3); + const one = atkHits(h, u).filter((c) => c.dmg.isSkill); + assert.ok(one.length >= 1 && one.every((c) => c.target === lone), `T${tier}: one hit per attack on a lone enemy`); + assert.equal(new Set(one.map((c) => c.dmg.attackId)).size, one.length, `T${tier}: no bounce`); + const before = atkHits(h, u).filter((c) => c.t < one[0].t - 1e-9).length; // her normal attacks' steals so far + approx(one[0].amount, (u.base.atk + Math.min(250, 25 * before)) * sk.bb['attack@atk_scale'], `T${tier}: ${sk.bb['attack@atk_scale'] * 100} % (+ the ATK stolen before)`); + // A (lone) at 10,5; B at 11,5 (1 from A); C at 9,6 (1.41 from A, 2.24 from B) + const b = h.spawn('enemy_dummy', { pos: [11, 5] }), c = h.spawn('enemy_dummy', { pos: [9, 6] }); + const far = h.spawn('enemy_dummy', { pos: [12, 5] }); // 2 from A, 1 from B: reachable from B only + h.run(0.1); + const n0 = atkHits(h, u).length; + h.run(4); + const after = atkHits(h, u).slice(n0); + const id = after[0].dmg.attackId; + const chain = after.filter((x) => x.dmg.attackId === id); + assert.equal(chain.length, 4, `T${tier}: the hit + 3 bounces`); + assert.equal(chain[0].target, lone, `T${tier}: her target`); + assert.equal(chain[1].target, b, `T${tier}: nearest not yet hit (B, 1 tile)`); + assert.equal(chain[2].target, far, `T${tier}: from B the one not yet hit (12,5), before A`); + approx(chain[1].t - chain[0].t, 1 / 6 + TICK, `T${tier}: bounce at 6 tiles/s`, 0.07); + assert.ok(chain.every((x) => x.dmg.isAttack && x.dmg.isSkill && x.type === 'phys'), `T${tier}: hits of the attack`); + assert.ok(!chain.some((x) => x.target === c) || chain[3].target === c, `T${tier}: C (2.24 from B) only from A`); + // on until she leaves; redeployed: off, own range + h.run(30); + assert.ok(u.skill.active, `T${tier}: still on`); + h.b.retreat(u); + h.b.redeploy(u); + assert.ok(!u.skill.active, `T${tier}: off after a redeploy`); + assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: y-7 again`); + done(h); + } +}); + +test('S2 恶魇 (30 s, attack SP 18 from 5 / 8): 210 % / 225 % ATK + 停顿 1 s; the boomerang flies on 0.5 s, then hits every enemy within 1.0 of its way back once for 155 % / 170 %; farther ones untouched', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S2); + const { h, u } = field({ tier, elite, skill: 1 }); + assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, u.skill.initSp, sk.duration, sk.bb['attack@atk_scale'], sk.bb['attack@atk_scale_comeback'], sk.bb['attack@sluggish'], sk.bb['attack@move_ahead_time'], sk.bb['attack@projectile_range']], + ['DEFAULT', 'attack', 18, elite ? 8 : 5, 30, elite ? 2.25 : 2.1, elite ? 1.7 : 1.55, 1, 0.5, 1], `T${tier}`); + const target = h.spawn('enemy_dummy', { pos: [10, 6] }); + const side = h.spawn('enemy_dummy', { pos: [11, 5] }); // 1.0 from the return line (row 10): hit on the way back + const off = h.spawn('enemy_dummy', { pos: [12, 4] }); // 2 rows off: never + h.b.addBuff(target, { key: 'test:taunt', mods: { taunt: 5 } }); // her target + // attack SP: one per normal attack + const sp0 = u.skill.sp; + h.run(3.5); + const normal = atkHits(h, u).filter((c) => !c.dmg.isSkill).length; + approx(u.skill.sp, Math.min(18, sp0 + normal), `T${tier}: +1 SP an attack`); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`); + approx(u.skill.timeLeft, 30, `T${tier}: 30 s`, 0.1); + h.run(3); + const sk2 = atkHits(h, u).filter((c) => c.dmg.isSkill); + const id = sk2[0].dmg.attackId; + const main = sk2.find((c) => c.dmg.attackId === id && !c.dmg.tags?.includes('narant:comeback')); + const back = sk2.filter((c) => c.dmg.attackId === id && c.dmg.tags?.includes('narant:comeback')); + assert.equal(main.target, target, `T${tier}: her target`); + assert.deepEqual(back.map((c) => c.target).sort((a, b) => a.id - b.id), [target, side].sort((a, b) => a.id - b.id), `T${tier}: once each within 1.0 of the way back`); + assert.ok(back.every((c) => c.t - main.t >= 0.5), `T${tier}: after the 0.5 s dash`); + assert.ok(!sk2.some((c) => c.target === off), `T${tier}: 2 tiles off the line`); + assert.ok(back.every((c) => c.dmg.isAttack && c.type === 'phys'), `T${tier}: hits of the attack`); + const slows = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish'); + assert.ok(slows.length && slows.every((c) => c.target === target && Math.abs(c.duration - 1) < 1e-9), `T${tier}: 停顿 1 s on the hit target only`); + // the hit's ATK scale: her ATK at that hit (base + the steals before it) × 225 % + const before = atkHits(h, u).filter((c) => c.t < main.t - 1e-9).length; + const sa = formOf(tier, elite).talents.find((t) => t.index === 0).bb['attack@steal_atk']; + approx(main.amount, (u.base.atk + Math.min(250, sa * before)) * sk.bb['attack@atk_scale'], `T${tier}: ${sk.bb['attack@atk_scale'] * 100} %`); + done(h); + } +}); + +test('S3 吞日 (20 s): 3 boomerangs per attack (3 hits of 145 % / 160 %, flying together); once they are caught she strikes at most 3 enemies within one tile for 130 % / 140 % and 停顿 1 s (no attack)', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S3); + const { h, u } = field({ tier, elite, skill: 2 }); + assert.deepEqual([u.skill.rule, u.skill.spCost, u.skill.initSp, sk.duration, sk.bb.cnt, sk.bb['attack@atk_scale'], sk.bb.atk_scale_aoe, sk.bb['attack@aoe.max_target'], sk.bb.sluggish], + ['DEFAULT', elite ? 36 : 39, elite ? 17 : 14, 20, 3, elite ? 1.6 : 1.45, elite ? 1.4 : 1.3, 3, 1], `T${tier}`); + const target = h.spawn('enemy_dummy', { pos: [10, 6] }); + const around = [[9, 3], [11, 3], [9, 4], [11, 4]].map((pos) => h.spawn('enemy_dummy', { pos })); + h.b.addBuff(target, { key: 'test:taunt', mods: { taunt: 5 } }); // her target: the far one + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`); + approx(u.skill.timeLeft, 20, `T${tier}: 20 s`, 0.1); + h.run(2.5); + const sk3 = atkHits(h, u).filter((c) => c.dmg.isSkill); + const id = sk3[0].dmg.attackId; + const three = sk3.filter((c) => c.dmg.attackId === id); + assert.equal(three.length, 3, `T${tier}: 3 boomerangs`); + assert.ok(three.every((c) => c.target === target && c.t === three[0].t), `T${tier}: together, at the target`); + const caught = h.hooksOf('boomerangCaught').find((c) => c.unit === u && c.attackId === id); + assert.ok(caught?.isSkill, `T${tier}: caught`); + const blast = hitsBy(h, u).filter((c) => c.dmg.tags?.includes('narant:blast') && Math.abs(c.t - caught.t) < 1e-9); + assert.equal(blast.length, 3, `T${tier}: at most 3 of the 4 around her`); + assert.ok(blast.every((c) => around.includes(c.target) && !c.dmg.isAttack && c.dmg.isSkill && c.type === 'phys'), `T${tier}: skill hits within one tile`); + const slows = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish' && Math.abs(c.t - caught.t) < 1e-9); + assert.equal(slows.length, 3, `T${tier}: 停顿 on each`); + for (const c of slows) approx(c.duration, 1, '1 s'); + done(h); + } +}); + +test('T1 “我见,我得”: every landing attack hit steals 25 ATK / 20 DEF — the enemy loses, she gains, each capped at 250 / 200 (LPS-X stage 3: 300 / 240, twice within one tile); two of her: the larger loss, not the sum; a dodged hit steals nothing; all given back when she leaves', () => { + for (const f of [[5, false, null], [6, true, null], [5, true, LPSX], [6, true, LPSX], [6, true, LPSY]]) { + const [tier, elite, mod] = f; + const t0 = formOf(tier, elite).talents.find((t) => t.index === 0).bb; + const tc = mod === LPSX && tier === 6 ? modOf(6, LPSX).talentChanges.find((t) => t.talentIndex === 0) : null; + const capA = tc?.bb['attack@steal_atk_max'] ?? t0['attack@steal_atk_max'], capD = tc?.bb['attack@steal_def_max'] ?? t0['attack@steal_def_max']; + assert.deepEqual([t0['attack@steal_atk'], t0['attack@steal_def'], capA, capD], [25, 20, ...(tc ? [300, 240] : [250, 200])], label(f)); + const twice = !!tc; + if (twice) assert.match(tc.desc, /攻击到周围8格的敌人时偷取触发2次/); + // one hit at two tiles, then one at one tile + { + const { h, u } = field({ tier, elite, mod, skill: 2 }); + const e = h.spawn('enemy_brute', { pos: [10, 5] }); + assert.ok(h.runUntil(() => atkHits(h, u).length === 1, 3)); + assert.deepEqual(u.findBuff('talent:narant:loot')?.mods, { atkFlat: 25, defFlat: 20 }, `${label(f)}: she gains`); + assert.deepEqual(e.findBuff('narant:stolen')?.mods, { atkFlat: -25, defFlat: -20 }, `${label(f)}: the enemy loses`); + assert.deepEqual([e.s.atk, e.s.def], [375, 280], `${label(f)}: its stats`); + h.b.kill(e, null); + const near = h.spawn('enemy_brute', { pos: [10, 4] }); + assert.ok(h.runUntil(() => atkHits(h, u).length === 2, 4)); + const k = twice ? 2 : 1; + assert.deepEqual(near.findBuff('narant:stolen')?.mods, { atkFlat: -25 * k, defFlat: -20 * k }, `${label(f)}: within one tile ×${k}`); + assert.deepEqual(u.findBuff('talent:narant:loot')?.mods, { atkFlat: 25 * (1 + k), defFlat: 20 * (1 + k) }, `${label(f)}: hers`); + // the caps + h.run(40); + assert.deepEqual(u.findBuff('talent:narant:loot')?.mods, { atkFlat: capA, defFlat: capD }, `${label(f)}: her cap`); + assert.deepEqual(near.findBuff('narant:stolen')?.mods, { atkFlat: -capA, defFlat: -capD }, `${label(f)}: its cap`); + approx(u.s.atk, (u.base.atk + capA) * (1 + calmAtk(tier, elite, mod)), `${label(f)}: her ATK`); + // she leaves: nothing kept, everything given back + h.b.retreat(u); + h.step(); + assert.equal(u.findBuff('talent:narant:loot'), null, `${label(f)}: her gains go`); + assert.equal(near.findBuff('narant:stolen'), null, `${label(f)}: its losses come back`); + assert.deepEqual([near.s.atk, near.s.def], [400, 300]); + done(h); + } + } + // two of her (a T5 and a T6 piece) hitting one enemy: the larger loss, never the sum; a dodged hit steals nothing + const { h, u } = field({ tier: 5, skill: 2, others: [{ uid: 2, diy: { slot: SLOT[6], charId: NARANT, skillIndex: 2 }, row: 12, col: 3 }] }); + const v = h.unit(2); + const e = h.spawn('enemy_brute', { pos: [11, 5] }); + h.run(8); + const n1 = atkHits(h, u).filter((c) => c.target === e).length, n2 = atkHits(h, v).filter((c) => c.target === e).length; + assert.ok(n1 >= 3 && n2 >= 3, `both hit it (${n1}, ${n2})`); + assert.deepEqual(e.findBuff('narant:stolen')?.mods.atkFlat, -Math.min(250, 25 * Math.max(n1, n2)), 'the larger loss'); + const ghost = h.spawn('enemy_brute', { pos: [11, 4] }); + h.b.kill(e, null); + h.b.addBuff(ghost, { key: 'test:dodge', mods: { dodgePhys: 1 } }); + h.run(4); + assert.ok(h.hooksOf('damaged').every((c) => c.target !== ghost), 'every hit dodged'); + assert.equal(ghost.findBuff('narant:stolen'), null, 'a dodged hit steals nothing'); + done(h); +}); + +test('T2 婀娜虚影: enemies within one tile of her miss 20 % (LPS-Y stage 3: 30 %) of their physical / arts attacks — one roll per attack —, farther ones never; LPS-Y stage 3: ATK +15 % after 6 s without damage', () => { + for (const f of [[5, false, null], [6, true, LPSY], [5, true, LPSY]]) { + const [tier, elite, mod] = f; + const t1 = formOf(tier, elite).talents.find((t) => t.index === 1).bb; + const cut = mod === LPSY && tier === 6 ? -modOf(6, LPSY).talentChanges.find((t) => t.talentIndex === 1).bb.damage_hitrate_physical : -t1.damage_hitrate_physical; + assert.equal(cut, mod === LPSY && tier === 6 ? 0.3 : 0.2, label(f)); + for (const [pos, inside] of [[[10, 4], true], [[11, 5], false]]) { + const { h, u } = field({ tier, elite, mod, skill: 2, seed: 13 }); + h.b.removeBuff(u, 'talent:narant:dodge'); // her own 闪避 aside: every lost shot is a miss + h.b.addBuff(u, { key: 'test:tank', mods: { hpFlat: 1e9 } }); + const shooter = h.spawn('enemy_shooter', { pos }); + h.run(150); + const shots = h.hooksOf('attack').filter((c) => c.attacker === shooter).length; + const landed = h.hooksOf('damaged').filter((c) => c.source === shooter).length; + assert.ok(shots > 200, `${label(f)}: ${shots} shots`); + const rate = 1 - landed / shots; + if (inside) assert.ok(Math.abs(rate - cut) < 0.07, `${label(f)}: ${(rate * 100).toFixed(1)} % missed ≈ ${cut * 100} %`); + else assert.ok(landed >= shots - 1, `${label(f)}: two tiles away: no miss (${landed} / ${shots}, the last one may still fly)`); + done(h); + } + } + // the calm ATK + const { h, u } = field({ tier: 6, elite: true, mod: LPSY, skill: 2 }); + assert.deepEqual(modOf(6, LPSY).talentChanges.find((t) => t.talentIndex === -1).bb, { atk: 0.15, interval: 6 }); + h.step(); + assert.deepEqual(u.findBuff('talent:narant:calm')?.mods, { atkPct: 0.15 }, 'unhurt since her deployment'); + h.b.dealDamage(null, u, { amount: 10, type: 'true' }); + h.step(); + assert.equal(u.findBuff('talent:narant:calm'), null, 'hurt: gone'); + h.run(5.5); + assert.equal(u.findBuff('talent:narant:calm'), null, 'not yet'); + h.run(0.6); + assert.ok(u.findBuff('talent:narant:calm'), '6 s later: back'); + h.b.loseHp(u, 10, {}); + h.step(); + assert.ok(u.findBuff('talent:narant:calm'), 'a 流失 is no damage'); + done(h); +}); + +test('LPS-X “吹尽狂沙”: ×1.1 on her attack hits within one tile (stages 1 and 3), not beyond; none without it', () => { + for (const [tier, mod] of [[5, LPSX], [6, LPSX], [6, LPSY], [5, null]]) { + const near = mod === LPSX ? 1.1 : 1; + if (mod === LPSX) assert.deepEqual(modOf(tier, LPSX).traitOverride.bb, { atk_scale: 1.1 }); + for (const [pos, d1] of [[[10, 4], true], [[10, 5], false], [[11, 4], true]]) { + const { h, u } = field({ tier, elite: true, mod, skill: 2 }); + const e = h.spawn('enemy_dummy', { pos }); + assert.ok(h.runUntil(() => atkHits(h, u).length === 1, 3)); + approx(atkHits(h, u)[0].amount, u.base.atk * (1 + calmAtk(tier, true, mod)) * (d1 ? near : 1), `T${tier} ${mod}: ${pos}`); + assert.equal(atkHits(h, u)[0].target, e); + done(h); + } + } +}); + +test('LPS-Y “致我们的老大”: 1 SP every 5 boomerangs caught (an S3 throw carries 3), none while her skill runs; none without it', () => { + for (const [tier, mod] of [[5, LPSY], [6, LPSY], [6, LPSX]]) { + if (mod === LPSY) assert.deepEqual(modOf(tier, LPSY).traitOverride.bb, { come_back_cnt: 5, sp: 1 }); + const { h, u } = field({ tier, elite: true, mod, skill: 1 }); // S2: attack SP — the time SP stays out of it + h.spawn('enemy_dummy', { pos: [10, 6] }); + h.run(9); + const caught = h.hooksOf('boomerangCaught').filter((c) => c.unit === u && !c.isSkill).length; + const gains = h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'module').length; + assert.ok(caught >= 5, `T${tier} ${mod}: ${caught} caught`); + assert.equal(gains, mod === LPSY ? Math.floor(caught / 5) : 0, `T${tier} ${mod}: ${gains} module SP`); + done(h); + } + // S3: each throw is 3 boomerangs; while S3 runs no SP is gained (the catches still count) + const { h, u } = field({ tier: 6, elite: true, mod: LPSY, skill: 2 }); + h.spawn('enemy_dummy', { pos: [10, 6] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 4)); + h.run(6); + const during = h.hooksOf('boomerangCaught').filter((c) => c.unit === u && c.isSkill).length; + assert.ok(during >= 3, `${during} S3 throws caught`); + assert.equal(u.mem.narantCaught, (3 * during) % 5, 'the S3 catches count 3 each'); + assert.equal(h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'module' && c.amount > 0 && u.skill.active).length, 0, 'no SP during S3'); + done(h); +}); + +test('engine: a profile `boomerangOnward` takes the flight over after the first hit — ctx.hit / ctx.comeBack once — and a content error there still brings the boomerang back', () => { + const calls = []; + const kits = { + [NARANT]: () => ({ + skills: Object.fromEntries([S1, S2, S3].map((id) => [id, { kind: 'duration' }])), + trait: { + boomerangOnward(ctx) { + calls.push(ctx.attackId); + if (calls.length === 2) throw new Error('kit bug'); + ctx.battle.after(0.5, () => { assert.equal(typeof ctx.comeBack(ctx.x, ctx.y), 'object'); assert.equal(ctx.comeBack(ctx.x, ctx.y), null, 'once'); }); + }, + }, + }), + }; + const h = makeBattle({ defs: { enemies: ENEMIES }, timeLimit: 120, autoFinish: false, seed: 1, kits, verbose: false, hooks: ['boomerangCaught'], + units: [{ uid: 1, diy: { slot: SLOT[6], charId: NARANT, skillIndex: 2 }, elite: true, row: 10, col: 3 }] }); + h.step(); + const u = h.unit(1); + h.spawn('enemy_dummy', { pos: [10, 6] }); + h.run(6); + assert.ok(calls.length >= 3, `${calls.length} flights taken over`); + assert.ok(h.hooksOf('boomerangCaught').length >= 2, 'caught every time (the error one too)'); + assert.ok(h.b.errors.some((e) => /boomerang\.onward/.test(e.label ?? e.where ?? JSON.stringify(e))), 'the error is logged'); + assert.equal(u.trait.boomerangsOut <= 1, true); + checkInvariants(h.b); +}); diff --git a/test/content/op_poca.test.js b/test/content/op_poca.test.js new file mode 100644 index 00000000..31dddfb7 --- /dev/null +++ b/test/content/op_poca.test.js @@ -0,0 +1,343 @@ +// test/content/op_poca.test.js — the 自选 operator kit of 早露 (char_197_poca, 6★ 攻城手; kit +// server/sim/content/kits/ops/op-poca.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in every +// form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, SIE-X +// 攻城器械训练装置 or SIE-Y “心” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form +// of that slot status); the fidelity checklist of kits/README.md item by item. +// Run: node --test test/content/op_poca.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +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'; + +const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8')); +const CHESS = load('chess'); +const BACKUPS = load('backups'); +const POCA = 'char_197_poca'; +const FORMS = BACKUPS.units[POCA].forms; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const SIEX = 'uniequip_002_poca', SIEY = 'uniequip_003_poca'; +const S1 = 'skcom_atk_up[3]', S2 = 'skchr_poca_2', S3 = 'skchr_poca_3'; +const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0']; +const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id); +const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null); +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_light: dummy('enemy_light', { mass: 1 }), enemy_mid: dummy('enemy_mid', { mass: 2 }), enemy_heavy: dummy('enemy_heavy', { mass: 3 }), + enemy_armor: dummy('enemy_armor', { mass: 3, def: 600 }), enemy_plate: dummy('enemy_plate', { mass: 2, def: 600 }), + enemy_fly: dummy('enemy_fly', { mass: 1, motion: 'FLY' }), enemy_hfly: dummy('enemy_hfly', { mass: 4, motion: 'FLY' }), +}; +/** Every 自选 form: [tier, elite, module]. */ +const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, SIEX, SIEY].map((m) => [t, true, m]))]; +/** 学生楷模: she is a 【乌萨斯学生自治团】 operator herself — her ATK always carries the talent's +atk. */ +const studentAtk = (tier, elite, mod) => (elite && mod === SIEY && tier === 6 ? 0.12 : 0.08); + +/** A battle with 早露 as uid 1 at (row, col) facing RIGHT, plus `others`. */ +function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 3, others = [], seed = 5 } = {}) { + const h = makeBattle({ + defs: { enemies: ENEMIES }, timeLimit: 600, autoFinish: false, seed, + flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied'], captureNoisy: true, + units: [{ uid: 1, diy: { slot: SLOT[tier], charId: POCA, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others], + }); + h.step(); + return { h, u: h.unit(1) }; +} +const hitsBy = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u); +const atkHits = (h, u) => hitsBy(h, u).filter((c) => c.dmg?.isAttack); +function done(h) { + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); +} +const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`; + +test('早露 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 4-3 range without her tile, ranged physical arrows that hit air, heaviest first, block 1, no 特质', () => { + assert.equal(OPERATOR_KITS[POCA], KITS[POCA]); + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + for (const skill of [0, 1, 2]) { + const { h, u } = field({ tier, elite, mod, skill }); + const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null; + assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [POCA, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f)); + assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0)], `${label(f)}: stats`); + assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.profile.groundOnly, u.profile.priority, u.base.bat], + [1, 'ranged', 'phys', true, false, 'heaviest', 2.4], `${label(f)}: 攻城手`); + assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 4-3`); + assert.ok(!u.liveRangeGrid.some(([r, c]) => r === 0 && c === 0), `${label(f)}: PRTS 攻城手 "攻击范围不包含自身所在地块"`); + assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [BACKUPS.diy.operators[POCA].bonds, []], `${label(f)}: bonds / 特质`); + assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`); + done(h); + } + } + // the numbers of the forms (zh_CN): E2 Lv1 1351 / 856 / 100, E2 Lv60 1619 / 973 / 115; SIE-X +60 / +30 → +90 / +45, + // SIE-Y +70 / +20 → +100 / +30 ATK / DEF + assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [1351, 856, 1619, 973]); + assert.deepEqual([modOf(5, SIEX).attr, modOf(6, SIEX).attr, modOf(5, SIEY).attr, modOf(6, SIEY).attr], [{ atk: 60, def: 30 }, { atk: 90, def: 45 }, { atk: 70, def: 20 }, { atk: 100, def: 30 }]); +}); + +test('a 自选 pick: 早露 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => { + const data = { chess: CHESS, backups: BACKUPS }; + assert.ok(KITTED_CHARS.includes(POCA)); + for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(POCA), `tier ${t}`); + assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: POCA, skillIndex: 2, uniEquipId: SIEY } }, { data, kitted: KITTED_CHARS }), + { ok: true, picks: { [SLOT[5]]: { charId: POCA, skillIndex: 2, uniEquipId: SIEY } } }); +}); + +test('trait 攻城手 "优先攻击重量最重的敌人": the heaviest enemy of her range first, wherever it stands; a lighter one only once it is heavier (失重 moves it down); flyers are targets too', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const { h, u } = field({ tier, elite, skill: 0 }); + const light = h.spawn('enemy_light', { pos: [10, 5] }), heavy = h.spawn('enemy_heavy', { pos: [11, 7] }); + const n0 = atkHits(h, u).length; + assert.ok(h.runUntil(() => atkHits(h, u).length > n0, 4), `T${tier}: an attack`); + assert.equal(atkHits(h, u).at(-1).target, heavy, `T${tier}: weight 3 before weight 1 (the nearer one)`); + h.b.addBuff(heavy, { key: 'test:weightless', mods: { massFlat: -3 } }); + const n1 = atkHits(h, u).length; + assert.ok(h.runUntil(() => atkHits(h, u).length > n1, 4)); + assert.equal(atkHits(h, u).at(-1).target, light, `T${tier}: 失重 to 0 — now the light one first`); + const fly = h.spawn('enemy_hfly', { pos: [9, 6] }); + const n2 = atkHits(h, u).length; + assert.ok(h.runUntil(() => atkHits(h, u).length > n2, 4)); + assert.equal(atkHits(h, u).at(-1).target, fly, `T${tier}: a weight-4 flyer first (可对空)`); + done(h); + } +}); + +test('S1 攻击力强化·γ型 (MANUAL, data DEFAULT): ATK +45 % / +60 % for 30 s, SP 37 / 35 from 5 / 10; cast with an enemy in her range at her attack', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S1); + const { h, u } = field({ tier, elite, skill: 0 }); + assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.spType, u.skill.spCost, u.skill.initSp, sk.duration, sk.bb.atk], + ['DEFAULT', 'duration', 'time', elite ? 35 : 37, elite ? 10 : 5, 30, elite ? 0.6 : 0.45], `T${tier}`); + const st = studentAtk(tier, elite, null); + approx(u.s.atk, u.base.atk * (1 + st), `T${tier}: 学生楷模 only`); + u.skill.gainSp(999); + h.run(2); + assert.equal(u.skill.activations, 0, `T${tier}: no enemy, no cast`); + h.spawn('enemy_light', { pos: [10, 6] }); + assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`); + approx(u.skill.timeLeft, 30, `T${tier}: 30 s`, 0.01); + approx(u.s.atk, u.base.atk * (1 + st + sk.bb.atk), `T${tier}: ATK +${sk.bb.atk * 100} %`); + h.runUntil(() => !u.skill.active, 31); + approx(u.s.atk, u.base.atk * (1 + st), `T${tier}: back`); + done(h); + } +}); + +test('S2 分裂射击 (60 s): ATK +45 % / +60 % and two targets per attack (attack@max_target 2) — the two heaviest; a lone enemy gets one arrow', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S2); + const { h, u } = field({ tier, elite, skill: 1 }); + assert.deepEqual([u.skill.rule, sk.duration, sk.bb.atk, sk.bb['attack@max_target'], u.skill.spCost, u.skill.initSp], ['DEFAULT', 60, elite ? 0.6 : 0.45, 2, 80, elite ? 45 : 42], `T${tier}`); + const lone = h.spawn('enemy_light', { pos: [10, 6] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 4)); + approx(u.skill.timeLeft, 60, `T${tier}: 60 s`, 0.05); + approx(u.s.atk, u.base.atk * (1 + studentAtk(tier, elite, null) + sk.bb.atk), `T${tier}: ATK`); + h.run(0.6); + const n0 = atkHits(h, u).length; + h.run(2.5); + const one = atkHits(h, u).slice(n0); + assert.ok(one.length >= 1 && one.every((c) => c.target === lone), `T${tier}: one target, one arrow per attack`); + const mid = h.spawn('enemy_mid', { pos: [11, 6] }), heavy = h.spawn('enemy_heavy', { pos: [9, 5] }); + h.run(0.5); + const n1 = atkHits(h, u).length; + h.run(2.5); + const ids = atkHits(h, u).slice(n1).map((c) => c.dmg.attackId); + const first = atkHits(h, u).slice(n1).filter((c) => c.dmg.attackId === ids[0]); + assert.deepEqual(first.map((c) => c.target).sort((a, b) => a.id - b.id), [mid, heavy].sort((a, b) => a.id - b.id), `T${tier}: the two heaviest (weights 3, 2), not the light one`); + done(h); + } +}); + +test('S3 雪崩击 (6 / 7 s): harpoons link the 3 heaviest enemies of her range (a flyer too), 束缚 them, and strike each every second — 6 / 7 strikes at ATK +10 % (attacks), no other attack; the binds end with it', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S3); + const { h, u } = field({ tier, elite, skill: 2 }); + assert.deepEqual([u.skill.rule, sk.duration, sk.bb.max_target, sk.bb.hit_interval, sk.bb.hit_duration, sk.bb.atk, u.skill.spCost, u.skill.initSp], + ['DEFAULT', elite ? 7 : 6, 3, 1, elite ? 7 : 6, 0.1, elite ? 34 : 37, elite ? 12 : 11], `T${tier}`); + const light = h.spawn('enemy_light', { pos: [10, 5] }), mid = h.spawn('enemy_mid', { pos: [10, 6] }); + const heavy = h.spawn('enemy_heavy', { pos: [11, 6] }), hfly = h.spawn('enemy_hfly', { pos: [9, 6] }); + const outside = h.spawn('enemy_heavy', { pos: [10, 9] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`); + const t0 = h.b.time; + const linked = [mid, heavy, hfly]; + for (const e of linked) assert.equal(e.findBuff('bind')?.source, u, `T${tier}: ${e.defId} bound`); + assert.equal(light.findBuff('bind'), null, `T${tier}: the 4th heaviest is not linked`); + assert.equal(outside.findBuff('bind'), null, `T${tier}: out of range`); + approx(u.s.atk, u.base.atk * (1 + studentAtk(tier, elite, null) + 0.1), `T${tier}: ATK +10 %`); + assert.ok(h.runUntil(() => !u.skill.active, 10), `T${tier}: ends`); + approx(h.b.time - t0, sk.duration, `T${tier}: ${sk.duration} s`, 0.02); + const hits = hitsBy(h, u); + for (const e of linked) { + const on = hits.filter((c) => c.target === e); + assert.equal(on.length, sk.duration, `T${tier}: ${on.length} strikes on ${e.defId}`); + assert.ok(on.every((c) => c.dmg.isAttack && c.dmg.isSkill && c.type === 'phys'), `T${tier}: physical skill attacks`); + on.forEach((c, i) => i && approx(c.t - on[i - 1].t, 1, `T${tier}: one a second`, 0.02)); + } + assert.ok(!hits.some((c) => c.target === light || c.target === outside), `T${tier}: nobody else is hit`); + for (const e of linked) assert.equal(e.findBuff('bind'), null, `T${tier}: unbound at the end`); + // the strikes are instant along the links + assert.ok(h.eventsOf('atk').filter((ev) => ev[1] === u.id).some((ev) => ev[3] === 'beam')); + done(h); + } +}); + +test('S3 雪崩击 (PRTS 备注): a link breaks when its enemy is knocked out or vanishes, the skill ends at once when none is left; a stun / silence on her breaks every link', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + // all links lost ⇒ the skill ends early + { + const { h, u } = field({ tier, elite, skill: 2 }); + const a = h.spawn('enemy_heavy', { pos: [10, 6] }), b = h.spawn('enemy_mid', { pos: [11, 6] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 4)); + h.run(1.2); + h.b.kill(a, null); + h.step(); + assert.ok(u.skill.active, `T${tier}: one link left`); + b.hidden = true; // 消失 + h.step(); + assert.ok(!u.skill.active, `T${tier}: no link left ⇒ ends at once`); + b.hidden = false; + assert.equal(b.findBuff('bind'), null, `T${tier}: its bind went with the link`); + done(h); + } + for (const status of ['stun', 'silence']) { + const { h, u } = field({ tier, elite, skill: 2 }); + const a = h.spawn('enemy_heavy', { pos: [10, 6] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 4)); + h.run(1.5); + h.b.applyStatus(u, status, { duration: 2, source: null }); + h.step(); + assert.ok(!u.skill.active, `T${tier}: ${status} breaks the links`); + assert.equal(a.findBuff('bind'), null, `T${tier}: ${status}: unbound`); + done(h); + } + } +}); + +test('T1 深入骨髓: her damage ignores 60 % of the DEF of an enemy of 重量等级 ≥ 3 (exact numbers), none below; the S3 strikes too', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const t0 = formOf(tier, elite).talents.find((t) => t.index === 0); + assert.deepEqual([t0.bb.value, t0.bb.def_penetrate], [3, 0.6], `T${tier}: data`); + for (const skill of [0, 2]) { + const { h, u } = field({ tier, elite, skill }); + const armor = h.spawn('enemy_armor', { pos: [10, 6] }), plate = h.spawn('enemy_plate', { pos: [11, 6] }); + if (skill === 2) { u.skill.gainSp(999); assert.ok(h.runUntil(() => u.skill.active, 4)); h.run(2.2); } else h.run(12); + const on = (e) => atkHits(h, u).filter((c) => c.target === e); + assert.ok(on(armor).length, `T${tier} S${skill + 1}: hit the weight-3 one`); + for (const c of on(armor)) approx(c.amount, u.s.atk - 600 * (1 - 0.6), `T${tier} S${skill + 1}: weight 3, DEF 600 × 40 %`); + if (skill === 2) { + assert.ok(on(plate).length, `T${tier}: S3 links the weight-2 one too`); + for (const c of on(plate)) approx(c.amount, u.s.atk - 600, `T${tier} S3: weight 2, full DEF`); + } + done(h); + } + } +}); + +test('T2 学生楷模: every 【乌萨斯学生自治团】 operator of her team — her and 古米, deployed or not — ATK +8 %, nobody else; SIE-Y stage 3: +12 % and +15 % per student in skill (≤ +45 %), re-read every 0.3 s', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const others = [{ uid: 2, chessId: 'chess_char_1_10_a', row: 12, col: 3 }, { uid: 3, chessId: 'chess_char_1_02_a', row: 12, col: 6 }]; + const { h, u } = field({ tier, elite, mod, skill: 0, others }); + const gummy = h.unit(2), yak = h.unit(3); + const v = studentAtk(tier, elite, mod); + for (const a of [u, gummy]) assert.deepEqual(a.findBuff('talent:poca:students')?.mods, { atkPct: v }, `${label(f)}: ${a.def.charId}`); + assert.equal(yak.findBuff('talent:poca:students'), null, `${label(f)}: 角峰 is no student`); + h.b.retreat(u); + h.run(0.5); + assert.ok(gummy.findBuff('talent:poca:students'), `${label(f)}: 编入队伍时 — still there with her off the field`); + assert.ok(u.findBuff('talent:poca:students'), `${label(f)}: kept through her retreat`); + assert.equal(!!gummy.findBuff('talent:poca:studentSkills'), false, `${label(f)}: nobody in skill`); + done(h); + } + // SIE-Y stage 3: the extra +15 % per student operator on the field whose skill runs, at most +45 % + const t6 = formOf(6, true).modules.find((m) => m.uniEquipId === SIEY).talentChanges; + assert.deepEqual([t6.find((t) => t.talentIndex === 1).bb.atk, t6.find((t) => t.talentIndex === -1).bb], [0.12, { init_atk: 0.15, max_atk: 0.45 }]); + const others = [ + { uid: 2, chessId: 'chess_char_1_10_a', row: 12, col: 3, skillIndex: 1 }, { uid: 3, chessId: 'chess_char_1_10_b', row: 12, col: 5, skillIndex: 1 }, + { uid: 4, diy: { slot: 'chess_char_6_diy2_a', charId: 'char_1051_headb2', skillIndex: 0 }, row: 11, col: 8 }, + { uid: 5, chessId: 'chess_char_1_02_a', row: 12, col: 7 }, + ]; + const { h, u } = field({ tier: 6, elite: true, mod: SIEY, skill: 0, others }); + const team = [u, h.unit(2), h.unit(3), h.unit(4)]; + assert.ok(team.every((a) => a && a.deployed), 'four students on the field'); + const drive = (a) => a.findBuff('talent:poca:studentSkills')?.mods.atkPct ?? 0; + const cast = (a) => { a.skill.gainSp(999); a.skill.activate('test'); assert.ok(a.skill.active, `${a.def.charId} in skill`); }; + cast(u); + h.step(); // at once: 每次开启技能时 + for (const a of team) approx(drive(a), 0.15, `one in skill: ${a.def.charId}`); + assert.equal(drive(h.unit(5)), 0, '角峰 gets nothing'); + approx(u.s.atk, u.base.atk * (1 + 0.12 + 0.15 + skillOf(6, true, S1).bb.atk), 'her ATK: +12 % +15 % (and her S1)'); + cast(h.unit(2)); + h.step(); + approx(drive(h.unit(4)), 0.3, 'two in skill'); + cast(h.unit(3)); + cast(h.unit(4)); + h.step(); + approx(drive(u), 0.45, 'four in skill: capped at +45 %'); + u.skill.end('test'); + h.run(0.35); // the 0.3 s re-read + approx(drive(h.unit(2)), 0.45, 'three in skill'); + for (const a of team.slice(1)) a.skill.end('test'); + h.run(0.35); + assert.equal(drive(u), 0, 'nobody in skill'); + done(h); +}); + +test('SIE-X 攻城器械训练装置: ×1.15 on her attacks against 重量等级 ≥ 3 (normal attacks, S3 strikes), nothing below; stage 3 深入骨髓 adds a 60 % ATK physical hit on such enemies (with the DEF ignore)', () => { + for (const [tier, mod] of [[5, null], [5, SIEX], [6, SIEX], [6, SIEY], [6, null]]) { + const xs = mod === SIEX ? 1.15 : 1; + const extra = mod === SIEX && tier === 6 ? 0.6 : 0; + if (mod === SIEX) assert.deepEqual(modOf(tier, SIEX).talentChanges.find((t) => t.hidden).bb, { atk_scale: 1.15, value: 3 }, `T${tier}: data`); + for (const skill of [0, 2]) { + const { h, u } = field({ tier, elite: true, mod, skill }); + const heavy = h.spawn('enemy_heavy', { pos: [10, 5] }), mid = h.spawn('enemy_mid', { pos: [10, 6] }); + if (skill === 2) { u.skill.gainSp(999); assert.ok(h.runUntil(() => u.skill.active, 4)); h.run(2.2); } else h.run(12); + const main = atkHits(h, u); + const onH = main.filter((c) => c.target === heavy), onM = main.filter((c) => c.target === mid); + // (SIE-Y's own distance factor: 2 / 3 tiles) + const far = (d) => (mod === SIEY ? 1 + 0.12 * (d - 1) / 3.5 : 1); + assert.ok(onH.length, `T${tier} ${mod} S${skill + 1}: hits the heavy one`); + for (const c of onH) approx(c.amount, u.s.atk * xs * far(2), `T${tier} ${mod} S${skill + 1}: ×${xs} on weight 3`); + for (const c of onM) approx(c.amount, u.s.atk * far(3), `T${tier} ${mod} S${skill + 1}: ×1 on weight 2`); + const ex = hitsBy(h, u).filter((c) => c.dmg.tags?.includes('poca:extra')); + if (extra) { + assert.equal(ex.length, onH.length, `T${tier}: one extra hit per attack hit on the heavy one`); + assert.ok(ex.every((c) => c.target === heavy && c.type === 'phys' && !c.dmg.isAttack), `T${tier}: physical, not an attack`); + for (const c of ex) approx(c.amount, u.s.atk * extra, `T${tier}: 60 % ATK`); + } else assert.equal(ex.length, 0, `T${tier} ${mod}: no extra hit`); + done(h); + } + } + // the extra hit gets 深入骨髓's 物理穿透 too: weight 3, DEF 600 + const { h, u } = field({ tier: 6, elite: true, mod: SIEX, skill: 0 }); + const armor = h.spawn('enemy_armor', { pos: [10, 6] }); + h.run(6); + const ex = hitsBy(h, u).filter((c) => c.target === armor && c.dmg.tags?.includes('poca:extra')); + assert.ok(ex.length); + for (const c of ex) approx(c.amount, u.s.atk * 0.6 - 600 * 0.4, 'extra hit: DEF 600 × 40 %'); + done(h); +}); + +test('SIE-Y “心”: her attack damage rises with the distance, linearly from 1 tile to 4.5 (at most +12 %), stages 1 and 3; nothing without it', () => { + for (const [tier, mod] of [[5, SIEY], [6, SIEY], [6, SIEX], [5, null]]) { + const tb = mod === SIEY ? modOf(tier, SIEY).traitOverride.bb : {}; + if (mod === SIEY) assert.deepEqual(tb, { max_dist: 4.5, min_dist: 1, damage_scale: 0.12 }, `T${tier}: data`); + for (const [r, c] of [[10, 5], [10, 6], [10, 7], [11, 6], [12, 5]]) { + const { h, u } = field({ tier, elite: true, mod, skill: 0 }); + const e = h.spawn('enemy_light', { pos: [r, c] }); + h.run(3); + const hit = atkHits(h, u).find((x) => x.target === e); + assert.ok(hit, `T${tier} ${mod}: hit at ${r},${c}`); + const d = Math.hypot(c - 3, r - 10); + const want = mod === SIEY ? 1 + 0.12 * Math.max(0, Math.min(1, (d - 1) / 3.5)) : 1; + approx(hit.amount, u.s.atk * want, `T${tier} ${mod}: ${d.toFixed(2)} tiles ×${want.toFixed(4)}`); + done(h); + } + } +}); diff --git a/test/content/op_typhon.test.js b/test/content/op_typhon.test.js new file mode 100644 index 00000000..47ad9d0d --- /dev/null +++ b/test/content/op_typhon.test.js @@ -0,0 +1,343 @@ +// test/content/op_typhon.test.js — the 自选 operator kit of 提丰 (char_2012_typhon, 6★ 攻城手; kit +// server/sim/content/kits/ops/op-typhon.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in +// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, SIE-X +// 自然的包容, SIE-Y 冰原的影子 or ISW-A 提丰特限证章 at stage 1 (tier 5) / 3 (tier 6). Every number is read back from +// data/backups.json (the form of that slot status); the fidelity checklist of kits/README.md item by item. +// Run: node --test test/content/op_typhon.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +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'; + +const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8')); +const CHESS = load('chess'); +const BACKUPS = load('backups'); +const TYPHON = 'char_2012_typhon'; +const FORMS = BACKUPS.units[TYPHON].forms; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const SIEX = 'uniequip_002_typhon', SIEY = 'uniequip_003_typhon', ISWA = 'uniequip_004_typhon'; +const S1 = 'skcom_quickattack[3]', S2 = 'skchr_typhon_2', S3 = 'skchr_typhon_3'; +const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0']; +const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id); +const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null); +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_light: dummy('enemy_light', { mass: 1 }), enemy_mid: dummy('enemy_mid', { mass: 2 }), enemy_heavy: dummy('enemy_heavy', { mass: 3 }), + enemy_plate: dummy('enemy_plate', { mass: 1, def: 600 }), enemy_fly: dummy('enemy_fly', { mass: 1, motion: 'FLY' }), + enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.8, mass: 3 }), +}; +/** Every 自选 form: [tier, elite, module]. */ +const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, SIEX, SIEY, ISWA].map((m) => [t, true, m]))]; + +/** A battle with 提丰 as uid 1 at (row, col) facing RIGHT, plus `others`. */ +function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 3, others = [], seed = 5 } = {}) { + const h = makeBattle({ + defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, + flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied'], captureNoisy: true, + units: [{ uid: 1, diy: { slot: SLOT[tier], charId: TYPHON, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others], + }); + h.step(); + return { h, u: h.unit(1) }; +} +const hitsBy = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u); +const atkHits = (h, u) => hitsBy(h, u).filter((c) => c.dmg?.isAttack); +const rainHits = (h, u) => hitsBy(h, u).filter((c) => c.dmg?.tags?.includes('typhon:rain')); +function done(h) { + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); +} +const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`; + +test('提丰 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes (SIE-Y ASPD), 4-3 range, ranged physical arrows that hit air, heaviest first, no 特质', () => { + assert.equal(OPERATOR_KITS[TYPHON], KITS[TYPHON]); + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + for (const skill of [0, 1, 2]) { + const { h, u } = field({ tier, elite, mod, skill }); + const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null; + assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [TYPHON, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f)); + assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.aspd], + [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), 100 + (m?.attr.aspd ?? 0)], `${label(f)}: stats`); + assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.profile.priority, u.base.bat], [1, 'ranged', 'phys', true, 'heaviest', 2.4], `${label(f)}: 攻城手`); + assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 4-3`); + assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [BACKUPS.diy.operators[TYPHON].bonds, []], `${label(f)}: bonds / 特质`); + assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`); + done(h); + } + } + // E2 Lv1 1310 / 867 / 92, E2 Lv60 1570 / 985 / 106; SIE-X +120 HP / +60 ATK → +210 / +90, SIE-Y +70 ATK / +5 ASPD → +94 / +7, + // ISW-A +100 / +70 → +150 / +90 + assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [1310, 867, 1570, 985]); + assert.deepEqual([modOf(5, SIEX).attr, modOf(6, SIEX).attr, modOf(5, SIEY).attr, modOf(6, SIEY).attr, modOf(5, ISWA).attr, modOf(6, ISWA).attr], + [{ maxHp: 120, atk: 60 }, { maxHp: 210, atk: 90 }, { atk: 70, aspd: 5 }, { atk: 94, aspd: 7 }, { maxHp: 100, atk: 70 }, { maxHp: 150, atk: 90 }]); +}); + +test('a 自选 pick: 提丰 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => { + const data = { chess: CHESS, backups: BACKUPS }; + assert.ok(KITTED_CHARS.includes(TYPHON)); + for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(TYPHON), `tier ${t}`); + assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: TYPHON, skillIndex: 1, uniEquipId: ISWA } }, { data, kitted: KITTED_CHARS }), + { ok: true, picks: { [SLOT[6]]: { charId: TYPHON, skillIndex: 1, uniEquipId: ISWA } } }); +}); + +test('trait 攻城手: the heaviest enemy of her range first; a flyer is a target', () => { + const { h, u } = field({ tier: 6, elite: true }); + const light = h.spawn('enemy_light', { pos: [10, 5] }), heavy = h.spawn('enemy_heavy', { pos: [9, 6] }); + h.run(3); + assert.equal(atkHits(h, u)[0].target, heavy); + h.b.kill(heavy, null); + h.b.kill(light, null); + const fly = h.spawn('enemy_fly', { pos: [11, 5] }); + h.run(3); + assert.ok(atkHits(h, u).some((c) => c.target === fly), '可对空'); + done(h); +}); + +test('S1 迅捷打击·γ型 (MANUAL, data DEFAULT): ATK +27 % / +34 %, ASPD +25 / +35 for 35 s; SP 42 / 39 from 5 / 10', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S1); + const { h, u } = field({ tier, elite, skill: 0 }); + assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.spCost, u.skill.initSp, sk.duration, sk.bb.atk, sk.bb.attack_speed], + ['DEFAULT', 'duration', elite ? 39 : 42, elite ? 10 : 5, 35, elite ? 0.34 : 0.27, elite ? 35 : 25], `T${tier}`); + u.skill.gainSp(999); + h.run(2); + assert.equal(u.skill.activations, 0, `T${tier}: no enemy, no cast`); + h.spawn('enemy_light', { pos: [10, 6] }); + assert.ok(h.runUntil(() => u.skill.active, 4)); + approx(u.skill.timeLeft, 35, `T${tier}: 35 s`, 0.01); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`); + assert.equal(u.s.aspd, 100 + sk.bb.attack_speed, `T${tier}: ASPD`); + h.runUntil(() => !u.skill.active, 36); + assert.deepEqual([u.s.atk, u.s.aspd], [u.base.atk, 100], `T${tier}: back`); + done(h); + } +}); + +test('S2 冰原秩序: ATK +26 % / +35 %, two arrows per attack — two enemies when there are two, both at a lone one — each with 40 % to stun 1 s; 20 s on the first cast of a deployment, then 持续时间无限 until she leaves (a redeploy counts again)', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S2); + const { h, u } = field({ tier, elite, skill: 1, seed: 9 }); + assert.deepEqual([u.skill.rule, u.skill.kind, sk.duration, sk.bb.first_duration, sk.bb.atk, sk.bb['attack@prob'], sk.bb['attack@stun'], u.skill.spCost, u.skill.initSp], + ['DEFAULT', 'toggle', 20, 20, elite ? 0.35 : 0.26, 0.4, 1, elite ? 51 : 53, elite ? 40 : 37], `T${tier}`); + const lone = h.spawn('enemy_light', { pos: [10, 6] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`); + const t0 = h.b.time; + approx(u.skill.timeLeft, 20, `T${tier}: first cast 20 s`, 0.05); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`); + h.run(5); + const s2 = atkHits(h, u).filter((c) => c.dmg.isSkill); + const byAttack = Map.groupBy(s2, (c) => c.dmg.attackId); + assert.ok(byAttack.size >= 2, `T${tier}: attacks`); + for (const list of byAttack.values()) assert.deepEqual(list.map((c) => c.target), [lone, lone], `T${tier}: both arrows at the lone enemy`); + const other = h.spawn('enemy_mid', { pos: [11, 6] }); + h.run(5); + const later = atkHits(h, u).filter((c) => c.dmg.isSkill && c.t > h.b.time - 4.5); + for (const list of Map.groupBy(later, (c) => c.dmg.attackId).values()) { + assert.deepEqual(list.map((c) => c.target).sort((a, b) => a.id - b.id), [lone, other].sort((a, b) => a.id - b.id), `T${tier}: two different enemies`); + } + assert.ok(h.runUntil(() => !u.skill.active, 15), `T${tier}: the first cast ends`); + approx(h.b.time - t0, 20, `T${tier}: after 20 s`, 0.05); + // the second cast never ends + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: second cast`); + assert.equal(u.skill.timeLeft, Infinity, `T${tier}: 持续时间无限`); + h.run(120); + assert.ok(u.skill.active, `T${tier}: still on after 120 s`); + // the stun: 40 % of the arrows, 1 s + const all = atkHits(h, u).filter((c) => c.dmg.isSkill); + const stuns = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'stun'); + assert.ok(all.length > 100, `T${tier}: ${all.length} arrows`); + assert.ok(stuns.length / all.length > 0.32 && stuns.length / all.length < 0.48, `T${tier}: ${stuns.length} / ${all.length} stuns ≈ 40 %`); + for (const c of stuns) approx(c.duration, 1, `T${tier}: 1 s`); + // a redeploy counts its casts again + h.b.retreat(u); + h.b.redeploy(u); + assert.ok(!u.skill.active && u.deployed, `T${tier}: back on the field, skill off`); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast after the redeploy`); + approx(u.skill.timeLeft, 20, `T${tier}: the first cast of this deployment — 20 s again`, 0.05); + done(h); + } +}); + +test('S3 “永恒狩猎”: 7 / 8 rounds, attack interval 2.4 + 3.1 s; the mark on the heaviest enemy of her own range; range 全场 but attacks only with an enemy in the mark area (radius 1.3); each round 4 arrows 0.1 s apart on random enemies of the area, 145 % / 160 % ATK physical skill hits (no attack), 0.4 s stun once they land', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S3); + const { h, u } = field({ tier, elite, skill: 2, seed: 7 }); + assert.deepEqual([u.skill.rule, u.skill.kind, sk.durationType, sk.bb['attack@s3_trigger_time'], sk.bb.base_attack_time, sk.bb['attack@s3_atk_scale'], sk.bb['attack@s3_max_hit_num'], sk.bb['attack@s3_stun'], u.skill.spCost, u.skill.initSp], + ['DEFAULT', 'ammo', 'AMMO', elite ? 8 : 7, 3.1, elite ? 1.6 : 1.45, 4, 0.4, elite ? 43 : 47, 25], `T${tier}`); + const marked = h.spawn('enemy_heavy', { pos: [10, 6] }), near = h.spawn('enemy_light', { pos: [11, 6] }); + const away = h.spawn('enemy_mid', { pos: [9, 5] }); // in her range, 1.4+ tiles from the mark: never hit + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`); + const t0 = h.b.time; + assert.equal(u.mem.typhonMark.target, marked, `T${tier}: the heaviest of her range is marked`); + assert.ok(u.liveRangeGrid.length >= 400, `T${tier}: the card shows 全场`); + approx(u.s.interval, 2.4 + 3.1, `T${tier}: interval 5.5 s`); + assert.ok(h.runUntil(() => !u.skill.active, 60), `T${tier}: ammo spent`); + h.run(0.5); + const rain = rainHits(h, u); + assert.equal(atkHits(h, u).filter((c) => c.t >= t0 - 0.05).length, 0, `T${tier}: the round itself deals nothing`); + assert.equal(rain.length, sk.bb['attack@s3_trigger_time'] * 4, `T${tier}: ${rain.length} arrows (rounds × 4)`); + assert.ok(rain.every((c) => c.target === marked || c.target === near), `T${tier}: the area only`); + assert.ok(rain.some((c) => c.target === near) && rain.some((c) => c.target === marked), `T${tier}: random among the area`); + assert.ok(rain.every((c) => c.type === 'phys' && c.dmg.isSkill && !c.dmg.isAttack), `T${tier}: physical skill hits, no attack`); + const rounds = Map.groupBy(rain, (c) => Math.round((c.t - t0) / 5.5)); + assert.equal(rounds.size, sk.bb['attack@s3_trigger_time'], `T${tier}: one round per bullet`); + for (const list of rounds.values()) list.forEach((c, i) => i && approx(c.t - list[i - 1].t, 0.1, `T${tier}: 0.1 s apart`, 0.05)); + // amounts: ATK × s3_atk_scale (DEF 0), the first hit on each enemy ×1.6 (重如沼泥) + const firsts = new Set([marked, near].map((e) => rain.find((c) => c.target === e))); + for (const c of rain) approx(c.amount, u.s.atk * sk.bb['attack@s3_atk_scale'] * (firsts.has(c) ? 1.6 : 1), `T${tier}: s3_atk_scale`); + const stuns = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'stun'); + assert.equal(stuns.length, rain.length, `T${tier}: a stun per landed arrow`); + for (const c of stuns) approx(c.duration, 0.4, `T${tier}: 0.4 s`); + assert.ok(!hitsBy(h, u).some((c) => c.target === away), `T${tier}: outside the mark area`); + assert.ok(u.liveRangeGrid.length < 20, `T${tier}: own range back`); + done(h); + } +}); + +test('S3 “永恒狩猎”: no enemy in the mark area ⇒ no attack and no bullet spent; the mark follows its enemy and stays where it left the field', () => { + const { h, u } = field({ tier: 6, elite: true, skill: 2, seed: 3 }); + const walker = h.spawn('enemy_walk', { routeIndex: 0 }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 30), 'cast once the walker is in her range'); + assert.equal(u.mem.typhonMark.target, walker); + const x0 = u.mem.typhonMark.x; + h.run(1); + assert.notEqual(u.mem.typhonMark.x, x0, 'the mark moves with its enemy'); + const ammo = u.skill.ammoLeft; + h.b.kill(walker, null); + const spot = [u.mem.typhonMark.x, u.mem.typhonMark.y]; + h.run(12); + assert.equal(u.skill.ammoLeft, ammo, 'nothing in the area: no round'); + assert.deepEqual([u.mem.typhonMark.x, u.mem.typhonMark.y], spot, 'the mark stays'); + const later = h.spawn('enemy_light', { pos: [Math.round(spot[1]), Math.round(spot[0])] }); + assert.ok(h.runUntil(() => rainHits(h, u).some((c) => c.target === later), 12), 'an enemy at the old spot is hit'); + assert.equal(u.skill.ammoLeft, ammo - 1); + done(h); +}); + +test('T1 锐如兽牙: each attack adds 10 % (SIE-X stage 3: 12 %) DEF ignore up to 5 stacks, applied to that attack already; all gone 8 s (10 s) after her last attack', () => { + for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [5, true, SIEX], [6, true, SIEX]]) { + const t0 = formOf(tier, elite).talents.find((t) => t.index === 0).bb; + const tc = mod ? modOf(tier, mod).talentChanges.find((t) => t.talentIndex === 0 && !t.hidden)?.bb : null; + const per = tc?.def_penetrate ?? t0.def_penetrate, dur = tc?.duration ?? t0.duration; + assert.deepEqual([per, dur, tc?.max_stack_cnt ?? t0.max_stack_cnt], tier === 6 && mod === SIEX ? [0.12, 10, 5] : [0.1, 8, 5], label([tier, elite, mod])); + const { h, u } = field({ tier, elite, mod, skill: 0 }); + const e = h.spawn('enemy_plate', { pos: [10, 6] }); + h.run(16); + const list = atkHits(h, u).filter((c) => c.target === e); + assert.ok(list.length >= 6, `${label([tier, elite, mod])}: ${list.length} attacks`); + list.forEach((c, i) => approx(c.amount, u.s.atk - 600 * (1 - per * Math.min(5, i + 1)), `${label([tier, elite, mod])}: attack ${i + 1}`)); + h.b.kill(e, null); + const last = list.at(-1).t; + h.run(last + dur - 1 - h.b.time); + assert.equal(u.findBuff('talent:typhon:fang')?.stacks, 5, `${label([tier, elite, mod])}: still there ${dur - 1} s after the last attack`); + h.run(1.5); + assert.equal(u.findBuff('talent:typhon:fang'), null, `${label([tier, elite, mod])}: lost after ${dur} s without an attack`); + done(h); + } +}); + +test('T2 重如沼泥: while her skill runs, the first damage on each enemy deals ×1.6 and 停顿 3 s (SIE-Y stage 3: the first two, ×2.2); not outside a skill; a new cast clears the marks', () => { + for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [5, true, SIEY], [6, true, SIEY], [6, true, ISWA]]) { + const t1 = formOf(tier, elite).talents.find((t) => t.index === 1).bb; + const tc = mod === SIEY && tier === 6 ? modOf(tier, SIEY).talentChanges.find((t) => t.talentIndex === 1).bb : null; + const n = tc?.max_stack_cnt ?? 1, scale = tc?.atk_scale ?? t1.atk_scale; + assert.deepEqual([scale, tc?.sluggish ?? t1.sluggish], [tier === 6 && mod === SIEY ? 2.2 : 1.6, 3], label([tier, elite, mod])); + const { h, u } = field({ tier, elite, mod, skill: 0 }); + const a = h.spawn('enemy_light', { pos: [10, 5] }); + const far = (d) => (mod === SIEY ? 1 + 0.12 * (d - 1) / 3.5 : 1); + h.run(5); + assert.ok(atkHits(h, u).length && atkHits(h, u).every((c) => c.target === a), `${label([tier, elite, mod])}: attacks`); + for (const c of atkHits(h, u)) approx(c.amount, u.s.atk * far(2), `${label([tier, elite, mod])}: no skill, no bonus`); + assert.equal(h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish').length, 0); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 4)); + const t0 = h.b.time; + h.run(8); + const during = atkHits(h, u).filter((c) => c.t >= t0 - 0.05); + assert.ok(during.length > n + 1, `${label([tier, elite, mod])}: attacks during S1`); + during.forEach((c, i) => approx(c.amount, u.s.atk * far(2) * (i < n ? scale : 1), `${label([tier, elite, mod])}: hit ${i + 1}`)); + const slows = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish'); + assert.equal(slows.length, n, `${label([tier, elite, mod])}: 停顿 with the marked hits`); + for (const c of slows) approx(c.duration, 3, '3 s'); + // a second enemy gets its own first hit; a new cast clears the marks + u.skill.end('test'); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 5)); + const t1s = h.b.time; + h.run(3); + const again = atkHits(h, u).filter((c) => c.t >= t1s - 0.05); + approx(again[0].amount, u.s.atk * far(2) * scale, `${label([tier, elite, mod])}: marks cleared by the new cast`); + done(h); + } +}); + +test('SIE-X 自然的包容: ×1.15 on her attacks against 重量等级 ≥ 3, nothing below; the S3 arrow rain (no attack) is not raised', () => { + for (const [tier, mod] of [[5, SIEX], [6, SIEX], [6, null], [6, ISWA]]) { + const xs = mod === SIEX ? 1.15 : 1; + if (mod === SIEX) assert.deepEqual(modOf(tier, SIEX).talentChanges.find((t) => t.hidden).bb, { atk_scale: 1.15, value: 3 }); + const { h, u } = field({ tier, elite: true, mod, skill: 0 }); + const heavy = h.spawn('enemy_heavy', { pos: [10, 5] }); + h.run(4); + const mid = h.spawn('enemy_mid', { pos: [10, 6] }); + h.b.kill(heavy, null); + h.run(4); + for (const c of atkHits(h, u)) approx(c.amount, u.s.atk * (c.target.weight >= 3 ? xs : 1), `T${tier} ${mod}: ${c.target.defId}`); + assert.ok(atkHits(h, u).some((c) => c.target === mid) && atkHits(h, u).some((c) => c.target.weight >= 3)); + done(h); + } + // S3: rain hits on a weight-3 enemy are s3_atk_scale × ATK (× 1.6 the first) + const { h, u } = field({ tier: 6, elite: true, mod: SIEX, skill: 2 }); + h.spawn('enemy_heavy', { pos: [10, 6] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 4)); + h.run(7); + const rain = rainHits(h, u); + rain.forEach((c, i) => approx(c.amount, u.s.atk * 1.6 * (i === 0 ? 1.6 : 1), `rain ${i + 1}: no ×1.15`)); + done(h); +}); + +test('SIE-Y 冰原的影子: her attack damage rises with the distance, linearly 1 → 4.5 tiles, at most +12 % (stages 1 and 3); the S3 rain is not raised', () => { + for (const tier of [5, 6]) { + assert.deepEqual(modOf(tier, SIEY).traitOverride.bb, { max_dist: 4.5, min_dist: 1, damage_scale: 0.12 }); + for (const [r, c] of [[10, 5], [10, 7], [11, 6]]) { + const { h, u } = field({ tier, elite: true, mod: SIEY, skill: 0 }); + const e = h.spawn('enemy_light', { pos: [r, c] }); + h.run(3); + const d = Math.hypot(c - 3, r - 10); + approx(atkHits(h, u).find((x) => x.target === e).amount, u.s.atk * (1 + 0.12 * (d - 1) / 3.5), `T${tier}: ${d.toFixed(2)} tiles`); + done(h); + } + } + const { h, u } = field({ tier: 6, elite: true, mod: SIEY, skill: 2 }); + h.spawn('enemy_light', { pos: [10, 7] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 4)); + h.run(7); + const n = modOf(6, SIEY).talentChanges.find((t) => t.talentIndex === 1).bb.max_stack_cnt; + rainHits(h, u).forEach((c, i) => approx(c.amount, u.s.atk * 1.6 * (i < n ? 2.2 : 1), `rain ${i + 1}: no distance bonus`)); + done(h); +}); + +test('ISW-A 提丰特限证章: only its HP / ATK — the 集成战略 trait (damage_scale 1.4 on heavy enemies) and arrow-rain mark do nothing here', () => { + for (const tier of [5, 6]) { + const m = modOf(tier, ISWA); + assert.match(m.traitOverride.moduleDesc, /^在集成战略中/); + assert.ok(m.talentChanges.some((t) => t.bb.damage_scale === 1.4), 'the data carries the IS part'); + const { h, u } = field({ tier, elite: true, mod: ISWA, skill: 0 }); + const heavy = h.spawn('enemy_heavy', { pos: [10, 5] }); + h.run(8); + for (const c of atkHits(h, u).filter((x) => x.target === heavy)) approx(c.amount, u.s.atk, `T${tier}: ×1 on a weight-3 enemy`); + h.run(30); + assert.equal(h.hooksOf('damaged').filter((c) => c.source === u && !c.dmg.isAttack).length, 0, `T${tier}: no arrow rain of its own`); + done(h); + } +}); diff --git a/test/golden/diy.json b/test/golden/diy.json index d842b158..1d143dc4 100644 --- a/test/golden/diy.json +++ b/test/golden/diy.json @@ -90,130 +90,130 @@ "snaps": "d7b739d1 f5e170c4 bc463f9c 6ebd80ed 93f8e1ae ea1947a7 9541a28f e8dbac5b b0b3c196 14ae4a64 46640828 01c55ea9 0f261678 f447848b 0aad3542 db5850a3 631cc5ca 455c1dbc 68ad7134 24c71ebb 34a3ee93 37b05130 7a58a87e" }, "diy-003": { - "about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 5_diy1_a=csnipe 5_diy2_b=csnipe 5_diy1_a=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo", - "fast": true, + "about": "5_diy1_a=poca/S1 5_diy2_a=poca/S2 5_diy1_a=poca/S3 5_diy2_b=poca/S1 5_diy1_b=poca/S2 5_diy2_b=poca/S3 5_diy1_b=poca/S1/002_poca 5_diy2_b=poca/S2/002_poca 5_diy1_b=poca/S3/002_poca 5_diy2_b=poca/S1/003_poca 5_diy1_b=poca/S2/003_poca 5_diy2_b=poca/S3/003_poca", + "fast": false, "kind": "normal", "stageId": "act1autochess_m03", "modeId": "mode_single_normal", "round": 12, "seed": 3865246633, "waveId": "act1autochess_h05", - "time": 196.5, - "ticks": 5895, + "time": 187.67, + "ticks": 5630, "reason": "cleared", - "killed": 8, + "killed": 6, "total": 93, "unspawned": 0, "bossHpLeft": null, "rngDraws": 0, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":8,"total":93,"perfect":false,"leaks":85,"leaksUncounted":0,"coins":0,"dmg":306215,"bossDmg":0,"heal":22436,"deaths":11,"layerGains":{}}}, + "players": {"p1":{"killed":6,"total":93,"perfect":false,"leaks":87,"leaksUncounted":0,"coins":0,"dmg":549541,"bossDmg":0,"heal":0,"deaths":11,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,114,0,690,6384,0,13,0], - ["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,98,1,7246,10191,0,56,1], - ["p1",3,"op","chess_char_5_diy1_a",2,null,2564,1,116,1,16020,8139,0,28,1], - ["p1",4,"op","chess_char_5_diy2_b",2,"none",0,0,74,3,34176,12589,0,37,2], - ["p1",5,"op","chess_char_5_diy1_a",2,null,871,1,8,1,22261,400,0,129,3], - ["p1",6,"op","chess_char_5_diy2_b",2,"none",1510,1,12,0,32187,200,0,108,3], - ["p1",7,"op","chess_char_5_diy1_a",2,null,1133,1,14,0,30588,200,0,77,2], - ["p1",8,"op","chess_char_5_diy2_b",2,"none",1160,1,4,1,71148,200,0,154,3], - ["p1",9,"op","chess_char_5_diy1_a",2,null,1218,1,6,0,0,600,18186,48,3], - ["p1",10,"op","chess_char_5_diy2_b",2,"none",1374,1,115,0,0,8077,4251,12,2], - ["p1",11,"op","chess_char_5_diy1_a",2,null,932,1,13,1,40672,2000,0,123,3], - ["p1",12,"op","chess_char_5_diy2_b",2,"none",1131,1,15,0,51226,800,0,132,3], + ["p1",1,"op","chess_char_5_diy1_a",0,null,1351,1,6,1,33931,0,0,47,2], + ["p1",2,"op","chess_char_5_diy2_a",1,null,1001,1,11,1,65033,350,0,65,1], + ["p1",3,"op","chess_char_5_diy1_a",2,null,1001,1,14,0,68561,350,0,68,4], + ["p1",4,"op","chess_char_5_diy2_b",0,"none",1619,1,4,1,74197,0,0,74,3], + ["p1",5,"op","chess_char_5_diy1_b",1,"none",1594,1,5,2,101699,25,0,74,2], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",0,0,79,0,26172,3238,0,20,1], + ["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_poca",1019,1,12,0,58260,600,0,54,3], + ["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_poca",1019,1,15,0,72440,600,0,55,1], + ["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_poca",1619,1,116,0,9456,3238,0,10,0], + ["p1",10,"op","chess_char_5_diy2_b",0,"uniequip_003_poca",0,0,110,0,9051,4857,0,10,0], + ["p1",11,"op","chess_char_5_diy1_b",1,"uniequip_003_poca",1619,1,109,1,6611,3238,0,7,0], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_003_poca",0,0,74,0,24129,3238,0,15,1], ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null] ], - "enemies": {"enemy_1267_nhpbr_2":[36,8,28,17650,131613],"enemy_1270_nhstlk_2":[27,0,27,15235,63417],"enemy_9006_actoxi":[30,0,30,16896,111185]}, - "hooks": [1,116,5895,1089,1089,1215,1215,60,19,104,26,24,0,28385,254,28,93,85,1,19,0,0,0], - "events": {"atk":1089,"deploy":20,"die":19,"dmg":1215,"fx":8,"heal":60,"leak":85,"skill":50,"spawn":108,"status":504}, - "fx": {"zone":8}, - "snaps": "b1b2dda9 6ac577d6 84811e80 eb6305ea 52a34c95 c2fd4731 45525f0b dbcc3ddb ddfcbc11 0874e859 206cd671 4c83f44b f4f5331a 194da7d5 1fe13dbf 6b7331b4 4cf19629 7bae5e13 5b7078fc 65a8a1f5" + "enemies": {"enemy_1267_nhpbr_2":[36,6,30,3608,122379],"enemy_1270_nhstlk_2":[27,0,27,11269,172031],"enemy_9006_actoxi":[30,0,30,4857,255131]}, + "hooks": [1,116,5630,535,535,713,713,0,17,104,18,15,0,28723,14,13,93,87,1,17,0,0,0], + "events": {"atk":632,"deploy":20,"die":17,"dmg":713,"fx":20,"leak":87,"skill":33,"spawn":108,"status":40}, + "fx": {"harpoon":14,"zone":6}, + "snaps": "eef0a79d 44a5d462 3e690f47 b3107a28 2d4181bf 7ebe96f1 1268ba76 316e4d82 4c5398ed 1b2e0881 8dc86b18 c3b04d40 b3602468 561e1b30 3d6fc5c8 195dec21 de95e009 b3157eda f0823c39" }, "diy-004": { - "about": "5_diy1_a=acfend 5_diy2_b=acfend 5_diy1_a=acpion 5_diy2_b=acpion 5_diy1_a=acguad 5_diy2_b=acguad 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc", + "about": "6_diy1_a=poca/S1 6_diy2_a=poca/S2 6_diy1_a=poca/S3 6_diy2_b=poca/S1 6_diy1_b=poca/S2 6_diy2_b=poca/S3 6_diy1_b=poca/S1/002_poca 6_diy2_b=poca/S2/002_poca 6_diy1_b=poca/S3/002_poca 6_diy2_b=poca/S1/003_poca 6_diy1_b=poca/S2/003_poca 6_diy2_b=poca/S3/003_poca", "fast": false, "kind": "normal", "stageId": "act1autochess_m04", "modeId": "mode_multi_abyss", - "round": 12, + "round": 13, "seed": 1210847140, - "waveId": "act1autochess_h05", - "time": 224.77, - "ticks": 6743, + "waveId": "act1autochess_h06", + "time": 211.87, + "ticks": 6356, "reason": "cleared", - "killed": 21, - "total": 117, + "killed": 0, + "total": 111, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 350, + "rngDraws": 0, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":21,"total":117,"perfect":false,"leaks":96,"leaksUncounted":0,"coins":0,"dmg":374057,"bossDmg":0,"heal":25487,"deaths":23,"layerGains":{}}}, + "players": {"p1":{"killed":0,"total":111,"perfect":false,"leaks":111,"leaksUncounted":0,"coins":0,"dmg":389077,"bossDmg":0,"heal":0,"deaths":16,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,116,0,0,5208,0,0,0], - ["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",100.37,1,143,0,0,6642,0,0,0], - ["p1",3,"op","chess_char_5_diy1_a",2,null,1060.28,1,145,0,4912,3527,0,0,1], - ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,98,5,85077,5533,0,0,3], - ["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,104,0,0,8356,0,0,0], - ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,100,0,0,20296,0,0,0], - ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,132,0,16051,2544,0,16,1], - ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",0,0,135,5,91286,3174,0,37,1], - ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,77,0,6653,2744,0,3,0], - ["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",0,0,7,11,170077,1749,0,21,1], - ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,144,0,0,5333,10172,24,1], - ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",0,0,94,0,0,8375,15315,21,1], + ["p1",1,"op","chess_char_6_diy1_a",0,null,1351,1,4,0,82933,0,0,82,3], + ["p1",2,"op","chess_char_6_diy2_a",1,null,1351,1,5,0,124106,0,0,82,2], + ["p1",3,"op","chess_char_6_diy1_a",2,null,1351,1,15,0,68650,0,0,73,4], + ["p1",4,"op","chess_char_6_diy2_b",0,"none",1619,1,135,0,17869,3238,0,19,0], + ["p1",5,"op","chess_char_6_diy1_b",1,"none",0,0,107,0,7468,3238,0,9,0], + ["p1",6,"op","chess_char_6_diy2_b",2,"none",0,0,130,0,21432,4857,0,18,0], + ["p1",7,"op","chess_char_6_diy1_b",0,"uniequip_002_poca",1619,1,119,0,2081,3238,0,2,0], + ["p1",8,"op","chess_char_6_diy2_b",1,"uniequip_002_poca",0,0,68,0,4615,3238,0,4,0], + ["p1",9,"op","chess_char_6_diy1_b",2,"uniequip_002_poca",0,0,7,0,28666,1619,0,12,1], + ["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_003_poca",0,0,9,0,8746,1619,0,9,0], + ["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_003_poca",0,0,75,0,13305,3238,0,11,0], + ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_003_poca",0,0,13,0,9207,1619,0,9,0], ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null] ], - "enemies": {"enemy_1005_yokai":[51,16,35,0,83756],"enemy_1005_yokai_2":[54,5,49,24107,111017],"enemy_1005_yokai_3":[12,0,12,42273,179284]}, - "hooks": [1,145,6743,501,501,975,936,86,44,140,9,9,0,14181,0,0,117,96,1,44,0,0,39], - "events": {"atk":594,"deploy":25,"die":44,"dmg":936,"fx":87,"heal":78,"leak":96,"skill":18,"spawn":132,"status":8}, - "fx": {"aoe":1,"dodge":39,"dp":9,"slash":32,"spGain":6}, - "snaps": "6b632c98 e087bd9f f5b9da13 f7038fad bb4d7811 5178d072 0c0de684 872f1892 81773c41 3d296a6a 66f1329a 3a99c8b1 aa011a13 319c8ec5 26231cc7 f808b9be 73dd7977 75857859 817f18b6 d1cdd533 cd927f27 64106a51 89f62784" + "enemies": {"enemy_1010_demon":[54,0,54,13241,242388],"enemy_1320_wdrrl_2":[18,0,18,0,128796],"enemy_1422_lrsldr_2":[39,0,39,8314,107704]}, + "hooks": [1,135,6356,362,362,1756,1756,0,16,127,10,9,0,20141,7,15,111,111,1,16,0,0,0], + "events": {"atk":403,"deploy":21,"die":16,"dmg":1756,"fx":7,"leak":111,"skill":19,"spawn":126,"status":14}, + "fx": {"harpoon":7}, + "snaps": "9cb371b0 1898adc0 cf612a15 d2cb6763 4913d872 3f713374 e7ef9e56 ba82d082 5aeb7eb4 ab09714a 39d77c20 0f5cec30 751e0d70 c934a879 a8424b9e 36476a3a fee16806 f3dfe0ff 676b0941 736a9bc3 2884bad2 ee010f2a" }, "diy-005": { - "about": "6_diy1_a=acfend 6_diy2_b=acfend 5_diy1_a=sharp2 5_diy2_b=sharp2 6_diy1_a=acpion 6_diy2_b=acpion 5_diy1_a=acspec 5_diy2_b=acspec 6_diy1_a=acguad 6_diy2_b=acguad 5_diy1_a=acsupo 5_diy2_b=acsupo", + "about": "5_diy1_a=typhon/S1 5_diy2_a=typhon/S2 5_diy1_a=typhon/S3 5_diy2_b=typhon/S1 5_diy1_b=typhon/S2 5_diy2_b=typhon/S3 5_diy1_b=typhon/S1/002_typhon 5_diy2_b=typhon/S2/002_typhon 5_diy1_b=typhon/S3/002_typhon 5_diy2_b=typhon/S1/003_typhon 5_diy1_b=typhon/S2/003_typhon 5_diy2_b=typhon/S3/003_typhon", "fast": false, "kind": "normal", "stageId": "act1autochess_m05", "modeId": "mode_multi_funny", - "round": 13, + "round": 12, "seed": 823748262, - "waveId": "act1autochess_h06", - "time": 230, - "ticks": 6900, - "reason": "timeout", - "killed": 63, - "total": 123, + "waveId": "act1autochess_h05", + "time": 213.03, + "ticks": 6391, + "reason": "cleared", + "killed": 28, + "total": 60, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 117, + "rngDraws": 481, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":63,"total":123,"perfect":false,"leaks":60,"leaksUncounted":0,"coins":0,"dmg":274732,"bossDmg":0,"heal":0,"deaths":20,"layerGains":{}}}, + "players": {"p1":{"killed":28,"total":60,"perfect":false,"leaks":32,"leaksUncounted":0,"coins":0,"dmg":636784,"bossDmg":0,"heal":0,"deaths":7,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_6_diy1_a",2,null,1834,1,132,5,19446,3374,0,80,2], - ["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",2142.36,1,141,5,29488,4720,0,101,2], - ["p1",3,"op","chess_char_5_diy1_a",0,null,1865,1,154,3,27639,3730,0,42,1], - ["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",2374,1,152,11,43106,4748,0,39,1], - ["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,135,3,18986,4587,0,18,1], - ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",1808.52,1,143,18,92036,3903,0,40,4], - ["p1",7,"op","chess_char_5_diy1_a",2,null,117.2,1,130,12,39744,6807,0,115,0], - ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",1448,1,1,0,0,0,0,0,0], - ["p1",9,"op","chess_char_6_diy1_a",2,null,2638,1,3,0,0,0,0,0,0], - ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",3531,1,5,0,0,0,0,0,0], - ["p1",11,"op","chess_char_5_diy1_a",2,null,1160,1,153,0,1743,2320,0,7,0], - ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",1120.4,1,145,0,2545,3170,0,7,1] + ["p1",1,"op","chess_char_5_diy1_a",0,null,1310,1,10,1,15991,0,0,29,2], + ["p1",2,"op","chess_char_5_diy2_a",1,null,1310,1,11,0,67376,0,0,58,2], + ["p1",3,"op","chess_char_5_diy1_a",2,null,1310,1,1,1,59295,0,0,55,2], + ["p1",4,"op","chess_char_5_diy2_b",0,"none",1570,1,3,0,32813,0,0,88,3], + ["p1",5,"op","chess_char_5_diy1_b",1,"none",1570,1,5,1,56648,0,0,77,2], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",1570,1,7,6,77746,0,0,58,3], + ["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_typhon",1690,1,9,3,28924,0,0,32,2], + ["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_typhon",1690,1,12,0,124550,0,0,58,2], + ["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_typhon",424,1,76,5,70702,2956,0,38,3], + ["p1",10,"op","chess_char_5_diy2_b",0,"uniequip_003_typhon",0,0,74,6,29641,3140,0,56,1], + ["p1",11,"op","chess_char_5_diy1_b",1,"uniequip_003_typhon",0,0,71,1,24875,3140,0,43,2], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_003_typhon",0,0,64,4,48223,3140,0,31,2] ], - "enemies": {"enemy_1019_jshoot_2":[57,37,20,9953,112024],"enemy_1299_ymkilr_2":[27,6,20,25821,123649],"enemy_1422_lrsldr_2":[39,20,19,1584,123193]}, - "hooks": [1,154,6900,835,835,3335,3375,0,83,142,12,15,0,16427,38,83,123,59,1,83,0,0,0], - "events": {"atk":851,"deploy":31,"die":83,"dmg":3367,"fx":150,"leak":59,"skill":22,"spawn":135,"status":47}, - "fx": {"aoe":83,"displace":3,"dp":26,"slash":38}, - "snaps": "aa4b712f 51a2360b 59868653 c815c7eb 7dd47d06 3f3f03f4 89e883c3 60e1e7f6 2355716a 5d734980 75cde05f c0dd657c 4e5bd257 0f8a040d b1aafe05 c04cbb36 e6d37890 8457998a a19143e5 d709bc2b 4283543b 0d9edf54 6ef9963d" + "enemies": {"enemy_1045_hammer_2":[12,5,7,1570,185799],"enemy_1061_zomshd":[48,23,25,10806,502025]}, + "hooks": [1,76,6391,653,653,2213,2213,33397,35,67,26,20,52,30982,459,12,60,32,1,35,0,0,0], + "events": {"atk":796,"deploy":16,"die":35,"dmg":2213,"fx":62,"leak":32,"skill":46,"spawn":72,"status":574}, + "fx": {"arrowRain":52,"mark":10}, + "snaps": "17d15d31 0e1a4c75 9dd27936 948cc11e 05ae213c 4e20ce5b c3250ae8 d40cd3b3 5dee70a2 7a8dff47 9257f765 4a0d01cb a057347a bc75efa0 c06ebeb9 37d02f50 db37fdfa de4490bd 9e0ad30f a1661dad 4a02a694 d75a15ef" }, "diy-006": { - "about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc 6_diy1_a=acsupo 6_diy2_b=acsupo", + "about": "5_diy1_b=typhon/S1/004_typhon 5_diy2_b=typhon/S2/004_typhon 5_diy1_b=typhon/S3/004_typhon 6_diy1_a=typhon/S1 6_diy2_a=typhon/S2 6_diy1_a=typhon/S3 6_diy2_b=typhon/S1 6_diy1_b=typhon/S2 6_diy2_b=typhon/S3 6_diy1_b=typhon/S1/002_typhon 6_diy2_b=typhon/S2/002_typhon 6_diy1_b=typhon/S3/002_typhon", "fast": false, "kind": "normal", "stageId": "act1autochess_m06", @@ -221,35 +221,314 @@ "round": 13, "seed": 2464539579, "waveId": "act1autochess_h06", - "time": 210.23, - "ticks": 6307, + "time": 209.03, + "ticks": 6271, "reason": "cleared", - "killed": 5, + "killed": 3, "total": 81, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 123, + "rngDraws": 47, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":5,"total":81,"perfect":false,"leaks":76,"leaksUncounted":0,"coins":0,"dmg":185202,"bossDmg":0,"heal":4048,"deaths":30,"layerGains":{}}}, + "players": {"p1":{"killed":3,"total":81,"perfect":false,"leaks":78,"leaksUncounted":0,"coins":0,"dmg":129824,"bossDmg":0,"heal":0,"deaths":19,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,3,0,11325,1865,0,7,1], - ["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,6,3,52957,2494,0,16,1], - ["p1",3,"op","chess_char_6_diy1_a",2,null,1154,1,112,0,15444,8178,0,22,0], - ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",0,0,109,2,51553,12663,0,57,0], - ["p1",5,"op","chess_char_6_diy1_a",2,null,1272,1,113,0,4256,2544,0,15,0], - ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",0,0,105,0,10180,3286,0,19,0], - ["p1",7,"op","chess_char_6_diy1_a",2,null,0,0,5,0,3060,1372,0,2,0], - ["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",0,0,2,0,30589,1819,0,4,1], - ["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,8,0,0,2028,2464,7,1], - ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",0,0,61,0,0,3890,1584,6,1], - ["p1",11,"op","chess_char_6_diy1_a",2,null,0,0,58,0,2490,3291,0,7,1], - ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",0,0,60,0,3347,3320,0,8,0] + ["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_004_typhon",1670,1,99,1,13552,3340,0,15,0], + ["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_004_typhon",0,0,82,0,27271,3340,0,13,1], + ["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_004_typhon",0,0,91,0,13148,3340,0,8,1], + ["p1",4,"op","chess_char_6_diy1_a",0,null,1310,1,102,0,5661,1310,0,9,0], + ["p1",5,"op","chess_char_6_diy2_a",1,null,0,0,8,0,6713,1310,0,8,1], + ["p1",6,"op","chess_char_6_diy1_a",2,null,0,0,57,0,1995,2620,0,5,0], + ["p1",7,"op","chess_char_6_diy2_b",0,"none",0,0,50,0,3430,3140,0,4,0], + ["p1",8,"op","chess_char_6_diy1_b",1,"none",0,0,7,0,5965,1570,0,5,1], + ["p1",9,"op","chess_char_6_diy2_b",2,"none",0,0,55,0,2715,3140,0,6,0], + ["p1",10,"op","chess_char_6_diy1_b",0,"uniequip_002_typhon",0,0,51,0,3065,3560,0,4,0], + ["p1",11,"op","chess_char_6_diy2_b",1,"uniequip_002_typhon",0,0,4,1,24527,1780,0,9,1], + ["p1",12,"op","chess_char_6_diy1_b",2,"uniequip_002_typhon",0,0,6,1,21783,1780,0,8,1] ], - "enemies": {"enemy_1267_nhpbr_2":[27,5,22,7765,89733],"enemy_1270_nhstlk_2":[36,0,36,14278,68347],"enemy_1272_nhtank_2":[18,0,18,24706,27121]}, - "hooks": [1,113,6307,262,262,449,476,16,35,111,6,21,0,5828,86,24,81,76,1,35,0,0,0], - "events": {"atk":283,"deploy":32,"die":35,"dmg":441,"fx":58,"heal":15,"leak":76,"skill":12,"spawn":93,"status":142}, - "fx": {"aoe":7,"displace":21,"spGain":6,"zone":24}, - "snaps": "1dcf4706 8bd55683 d93e4d02 ab832ad5 132699ab f37b039d f12175ca fe30225b 6b2b77c0 2caefa3a 1018f9e1 a60a2364 f81df3c0 d0f2780e 0e940f53 372c90ef 46af3a81 a6b2adf1 63234976 3b8b7c59 b62aa8d4" + "enemies": {"enemy_1267_nhpbr_2":[27,1,26,4270,30792],"enemy_1270_nhstlk_2":[36,1,35,12010,57798],"enemy_1272_nhtank_2":[18,1,17,13950,41234]}, + "hooks": [1,102,6271,140,140,218,218,0,22,100,6,6,3,6782,50,13,81,78,1,22,0,0,0], + "events": {"atk":158,"deploy":21,"die":22,"dmg":218,"fx":17,"leak":78,"skill":12,"spawn":93,"status":68}, + "fx": {"arrowRain":3,"mark":2,"zone":12}, + "snaps": "17676897 a0d25d51 23334247 c7155bf9 0ff0a8a1 504e0de2 8ed64048 1df0b316 c967fa79 4832aa56 06f55bae 4821b884 a918bd9d 854b6072 907a4122 81bc0ef7 45dba1c3 6f6fae2d 1781944d a747cbc7 9a798664" + }, + "diy-007": { + "about": "6_diy1_b=typhon/S1/003_typhon 6_diy2_b=typhon/S2/003_typhon 6_diy1_b=typhon/S3/003_typhon 6_diy2_b=typhon/S1/004_typhon 6_diy1_b=typhon/S2/004_typhon 6_diy2_b=typhon/S3/004_typhon 5_diy1_a=narant/S1 5_diy2_a=narant/S2 5_diy1_a=narant/S3 5_diy2_b=narant/S1 5_diy1_b=narant/S2 5_diy2_b=narant/S3", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m07", + "modeId": "mode_multi_normal", + "round": 13, + "seed": 1837403644, + "waveId": "act1autochess_h06", + "time": 217.63, + "ticks": 6529, + "reason": "cleared", + "killed": 69, + "total": 123, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 457, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":69,"total":123,"perfect":false,"leaks":54,"leaksUncounted":0,"coins":0,"dmg":1658335,"bossDmg":0,"heal":0,"deaths":4,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_b",0,"uniequip_003_typhon",1570,1,5,3,147454,0,0,103,3], + ["p1",2,"op","chess_char_6_diy2_b",1,"uniequip_003_typhon",1570,1,7,4,237031,0,0,93,2], + ["p1",3,"op","chess_char_6_diy1_b",2,"uniequip_003_typhon",1570,1,9,7,198328,0,0,67,3], + ["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_004_typhon",1720,1,11,0,76443,0,0,93,3], + ["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_004_typhon",1720,1,13,1,132048,0,0,65,2], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_004_typhon",1720,1,6,1,111612,0,0,57,3], + ["p1",7,"op","chess_char_5_diy1_a",0,null,2000,1,8,9,70905,0,0,181,1], + ["p1",8,"op","chess_char_5_diy2_a",1,null,2000,1,10,17,184859,0,0,195,5], + ["p1",9,"op","chess_char_5_diy1_a",2,null,2000,1,12,4,55779,0,0,209,4], + ["p1",10,"op","chess_char_5_diy2_b",0,"none",2331,1,14,19,320731,0,0,202,1], + ["p1",11,"op","chess_char_5_diy1_b",1,"none",0,0,113,1,89353,4662,0,105,3], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,90,3,33791,4662,0,70,2], + ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], + ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null] + ], + "enemies": {"enemy_1006_shield_3":[21,0,21,2331,510204],"enemy_1043_zomsbr":[66,64,2,46,488534],"enemy_1402_tgshd_2":[36,5,31,6947,659597]}, + "hooks": [1,139,6529,1484,1484,3785,3770,10242,73,127,32,25,42,22939,898,8,123,54,1,73,0,0,15], + "events": {"atk":1595,"deploy":14,"die":73,"dmg":3770,"fx":3301,"leak":54,"skill":57,"spawn":137,"status":1321}, + "fx": {"aoe":111,"arrowRain":42,"bounce":1001,"dodge":25,"expose":2116,"mark":6}, + "snaps": "b9626065 582025e2 3257dff6 c1925814 9e9da734 8766877f ad5c2dc1 19cfd133 fe190b4c de92d351 ec09d7aa fd2c9d56 d918d84d 341fedf7 a99c53ec 7bc66863 b2064ac1 d6f8bcf4 9b55b8b6 d45ded66 052283bc bf18e7f6" + }, + "diy-008": { + "about": "5_diy1_b=narant/S1/002_narant 5_diy2_b=narant/S2/002_narant 5_diy1_b=narant/S3/002_narant 5_diy2_b=narant/S1/003_narant 5_diy1_b=narant/S2/003_narant 5_diy2_b=narant/S3/003_narant 6_diy1_a=narant/S1 6_diy2_a=narant/S2 6_diy1_a=narant/S3 6_diy2_b=narant/S1 6_diy1_b=narant/S2 6_diy2_b=narant/S3", + "fast": false, + "kind": "normal", + "stageId": "act2autochess_m01", + "modeId": "mode_multi_hard", + "round": 13, + "seed": 3478524561, + "waveId": "act1autochess_h06", + "time": 218.97, + "ticks": 6569, + "reason": "cleared", + "killed": 53, + "total": 147, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 174, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":53,"total":147,"perfect":false,"leaks":94,"leaksUncounted":0,"coins":0,"dmg":1992843,"bossDmg":0,"heal":0,"deaths":21,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_narant",2331,1,2,7,487101,0,0,154,1], + ["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_narant",2331,1,3,15,668186,0,0,173,4], + ["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_narant",2331,1,4,14,281905,0,0,200,4], + ["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_003_narant",1644.15,1,172,5,78520,5979,0,43,2], + ["p1",5,"op","chess_char_5_diy1_b",1,"uniequip_003_narant",0,0,121,4,151146,5082,0,56,2], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_003_narant",0,0,169,4,60342,7623,0,48,1], + ["p1",7,"op","chess_char_6_diy1_a",0,null,0,0,123,0,18980,4000,0,41,2], + ["p1",8,"op","chess_char_6_diy2_a",1,null,0,0,170,1,109226,6000,0,60,3], + ["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,142,1,22351,4000,0,78,1], + ["p1",10,"op","chess_char_6_diy2_b",0,"none",0,0,161,0,62101,6993,0,37,1], + ["p1",11,"op","chess_char_6_diy1_b",1,"none",0,0,138,0,17482,4662,0,20,1], + ["p1",12,"op","chess_char_6_diy2_b",2,"none",0,0,143,2,35503,4662,0,64,1] + ], + "enemies": {"enemy_1166_dusbr_2":[39,23,16,2651,540442],"enemy_1169_duphlx_2":[57,19,38,4811,759225],"enemy_1170_dushld_2":[51,11,40,41539,693176]}, + "hooks": [1,172,6569,1067,1067,3032,3013,0,74,168,23,21,0,11006,353,34,147,94,1,74,0,0,19], + "events": {"atk":1067,"deploy":25,"die":74,"dmg":3013,"fx":870,"leak":94,"skill":44,"spawn":159,"status":740}, + "fx": {"aoe":131,"bounce":708,"dodge":31}, + "snaps": "93746c5e 517c10f7 0cc79b93 7905f3b7 9e118c07 0f367d60 e101b3e9 032fe74a f2b4060a a35d4b69 c048cb76 2bd9e050 93960a61 e717c857 d2623de9 69ed3655 8abf4c87 b2bd13b8 4805f179 eb64c847 de7c183d 79c3dc35" + }, + "diy-009": { + "about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 6_diy1_b=narant/S1/002_narant 6_diy2_b=narant/S2/002_narant 6_diy1_b=narant/S3/002_narant 6_diy2_b=narant/S1/003_narant 6_diy1_b=narant/S2/003_narant 6_diy2_b=narant/S3/003_narant 5_diy1_a=csnipe 5_diy2_b=csnipe", + "fast": true, + "kind": "normal", + "stageId": "act2autochess_m02", + "modeId": "mode_single_normal", + "round": 13, + "seed": 2612428600, + "waveId": "act1autochess_h06", + "time": 201.6, + "ticks": 6048, + "reason": "cleared", + "killed": 68, + "total": 102, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 93, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":68,"total":102,"perfect":false,"leaks":34,"leaksUncounted":0,"coins":0,"dmg":1899867,"bossDmg":0,"heal":0,"deaths":14,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,104,0,2248,4256,0,12,1], + ["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,95,0,4300,5816,0,20,1], + ["p1",3,"op","chess_char_5_diy1_a",2,null,283.7,1,124,0,16954,7408,0,27,0], + ["p1",4,"op","chess_char_5_diy2_b",2,"none",1322.45,1,123,1,26149,8680,0,34,1], + ["p1",5,"op","chess_char_6_diy1_b",0,"uniequip_002_narant",1731,1,8,2,81385,600,0,44,1], + ["p1",6,"op","chess_char_6_diy2_b",1,"uniequip_002_narant",768.74,1,9,3,112820,1562,0,68,3], + ["p1",7,"op","chess_char_6_diy1_b",2,"uniequip_002_narant",406.47,1,13,9,171287,1925,0,84,3], + ["p1",8,"op","chess_char_6_diy2_b",0,"uniequip_003_narant",0,0,4,21,644808,2631,0,130,1], + ["p1",9,"op","chess_char_6_diy1_b",1,"uniequip_003_narant",577.61,1,103,17,410436,4684,0,110,4], + ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_003_narant",1389.87,1,109,15,385446,3872,0,125,3], + ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,14,0,37526,1271,0,184,3], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",1510,1,126,0,6508,3020,0,18,1], + ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], + ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null] + ], + "enemies": {"enemy_1267_nhpbr_2":[27,22,5,16980,251882],"enemy_1270_nhstlk_2":[36,21,15,12583,740247],"enemy_9006_actoxi":[39,25,14,10963,907739]}, + "hooks": [1,126,6048,963,963,2406,2406,0,82,116,22,20,0,16870,230,31,102,34,1,82,0,0,0], + "events": {"atk":963,"deploy":22,"die":82,"dmg":2406,"fx":537,"leak":34,"skill":42,"spawn":116,"status":360}, + "fx": {"aoe":75,"bounce":388,"dodge":27,"zone":47}, + "snaps": "b6dbde73 5d55f1ea f6a9f33e 6d0cf056 a6f0fc91 29c1780c 07cfaa86 c2328eff 6ebdda39 dcaf0878 e97f48f4 8fce2dfd 9c40ee32 fd197fde 5e435faa 75cd5b39 96ed73a3 ee0fe96d 53c00d41 4e6ffe69 7f9decee" + }, + "diy-010": { + "about": "5_diy1_a=acfend 5_diy2_b=acfend 5_diy1_a=acpion 5_diy2_b=acpion 5_diy1_a=acguad 5_diy2_b=acguad 5_diy1_a=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo", + "fast": false, + "kind": "normal", + "stageId": "act2autochess_m03", + "modeId": "mode_multi_abyss", + "round": 12, + "seed": 4252812069, + "waveId": "act1autochess_h05", + "time": 198.83, + "ticks": 5965, + "reason": "cleared", + "killed": 1, + "total": 114, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 83, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":1,"total":114,"perfect":false,"leaks":113,"leaksUncounted":0,"coins":0,"dmg":88013,"bossDmg":0,"heal":2979,"deaths":25,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,5,0,1224,2604,0,8,0], + ["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",0,0,6,0,6376,3371,0,17,1], + ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,139,0,19464,4587,0,16,1], + ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,132,0,14161,6079,0,6,1], + ["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,81,0,2402,7097,0,3,0], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,123,1,32254,6822,0,13,1], + ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,108,0,3736,2266,0,8,0], + ["p1",8,"op","chess_char_5_diy2_b",2,"none",0,0,78,0,3843,2720,0,7,0], + ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,76,0,0,2803,1939,6,0], + ["p1",10,"op","chess_char_5_diy2_b",2,"none",0,0,71,0,0,3171,1040,4,0], + ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,142,0,3160,2796,0,8,0], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,80,0,1392,2262,0,3,0], + ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], + ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], + ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null] + ], + "enemies": {"enemy_1019_jshoot_2":[51,0,51,10772,5074],"enemy_1381_winman":[27,1,26,3589,81570],"enemy_1389_winbab_2":[36,0,36,32218,1369]}, + "hooks": [1,142,5965,362,362,362,341,10,26,139,4,4,0,8414,11,22,114,113,1,67,0,0,21], + "events": {"atk":362,"deploy":25,"die":26,"dmg":341,"fx":43,"heal":10,"leak":113,"skill":8,"spawn":129,"status":22}, + "fx": {"aoe":4,"dodge":21,"dp":2,"slash":16}, + "snaps": "f95ec6b3 ed7d838d fb316f9c dab9952a 3ece6e1c cc394bf0 4406f186 500cf7f9 85835d90 0fbaf17d 904fc882 ab8ec4e4 42d37e57 1cf0f337 dbd9f5b6 d472d89b 3e7d68bd 953cd5f8 e3b8230a 1ab756a8" + }, + "diy-011": { + "about": "5_diy1_a=sharp2 5_diy2_b=sharp2 5_diy1_a=acspec 5_diy2_b=acspec 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc 5_diy1_a=acsupo 5_diy2_b=acsupo", + "fast": false, + "kind": "normal", + "stageId": "act2autochess_m04", + "modeId": "mode_multi_funny", + "round": 12, + "seed": 538212341, + "waveId": "act1autochess_h05", + "time": 210.13, + "ticks": 6304, + "reason": "cleared", + "killed": 83, + "total": 114, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 1149, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":83,"total":114,"perfect":false,"leaks":31,"leaksUncounted":0,"coins":0,"dmg":674779,"bossDmg":0,"heal":30475,"deaths":5,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",0,null,1099,1,132,12,52984,13333,0,108,3], + ["p1",2,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",1787,1,131,19,125786,8523,0,123,3], + ["p1",3,"op","chess_char_5_diy1_a",2,null,1154,1,133,1,4263,9289,0,80,0], + ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",978,1,108,3,7850,9148,0,105,0], + ["p1",5,"op","chess_char_5_diy1_a",2,null,1272,1,3,8,18374,0,0,185,2], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",1587,1,4,8,29112,0,0,189,3], + ["p1",7,"op","chess_char_5_diy1_a",2,null,1372,1,11,24,275224,0,0,83,2], + ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",1749,1,13,7,85351,0,0,51,2], + ["p1",9,"op","chess_char_5_diy1_a",2,null,1224,1,5,0,0,0,30475,58,2], + ["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",1414,1,10,0,0,0,0,0,0], + ["p1",11,"op","chess_char_5_diy1_a",2,null,1160,1,12,0,29193,0,0,137,2], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",1430,1,14,0,46642,0,0,131,2], + ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], + ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null] + ], + "enemies": {"enemy_10124_uashld_2":[12,3,9,23019,173497],"enemy_1043_zomsbr":[72,64,8,297,212674],"enemy_1061_zomshd":[30,16,14,16977,315575]}, + "hooks": [1,133,6304,1429,1429,3149,3332,39660,88,119,21,21,0,33441,7581,38,114,31,1,88,0,0,0], + "events": {"atk":1866,"deploy":17,"die":88,"dmg":3329,"fx":14,"heal":94,"leak":31,"skill":39,"spawn":128,"status":771}, + "fx": {"aoe":9,"spGain":5}, + "snaps": "9e077552 b355e47c b24fef80 9630611b ba6aaa68 a55a9e35 55de81c3 0200ff41 1c975365 6d65c907 2fa8927d 65a7475d c5555eff 97949f97 1b5b5829 eca6487a 5c3a07cb ca46ec36 57dff451 c868cecd de89d5db" + }, + "diy-012": { + "about": "6_diy1_a=acfend 6_diy2_b=acfend 6_diy1_a=acpion 6_diy2_b=acpion 6_diy1_a=acguad 6_diy2_b=acguad 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m01", + "modeId": "mode_single_hard", + "round": 13, + "seed": 3192456040, + "waveId": "act1autochess_h06", + "time": 230, + "ticks": 6900, + "reason": "timeout", + "killed": 25, + "total": 117, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 1031, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":25,"total":117,"perfect":false,"leaks":92,"leaksUncounted":0,"coins":0,"dmg":917801,"bossDmg":0,"heal":18388,"deaths":13,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",2,null,0,0,140,0,1944,8114,0,26,1], + ["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",0,0,121,0,10155,6862,0,85,2], + ["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,133,0,7609,4587,0,20,1], + ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",0,0,130,6,51717,5712,0,19,2], + ["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,90,1,23844,5276,0,50,2], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",3531,1,8,6,145774,18086,0,124,5], + ["p1",7,"op","chess_char_6_diy1_a",2,null,1272,1,4,0,33279,0,0,157,2], + ["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",1643,1,5,6,96326,0,0,192,3], + ["p1",9,"op","chess_char_6_diy1_a",2,null,1372,1,11,4,255529,0,0,78,2], + ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",1819,1,13,2,291625,0,0,83,3], + ["p1",11,"op","chess_char_6_diy1_a",2,null,1224,1,6,0,0,0,18388,45,2], + ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",1464,1,14,0,0,0,0,0,0], + ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], + ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], + ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null] + ], + "enemies": {"enemy_1166_dusbr_2":[39,14,25,1375,203044],"enemy_1170_dushld_2":[39,8,30,14781,221543],"enemy_9011_acrefr":[39,3,35,32481,493214]}, + "hooks": [1,140,6900,1061,1061,3100,3078,67,38,128,25,22,0,33015,0,48,117,90,1,42,0,0,22], + "events": {"atk":1376,"deploy":20,"die":38,"dmg":3078,"fx":136,"heal":57,"leak":90,"skill":47,"spawn":132,"status":123}, + "fx": {"aoe":81,"dodge":22,"dp":9,"slash":24}, + "snaps": "5798fc78 bc32724f 41014680 b37499e1 77e4e7eb 80e45344 7e9b1723 c72901e2 a0b40309 48335b9f 4eeefd52 e9aa29a1 5be95678 4ab19904 6c6204e0 7a33f9b8 8284dbb5 3e831607 c6b72c15 45727786 199e1234 e693dda1 4aabdf76" + }, + "diy-013": { + "about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acsupo 6_diy2_b=acsupo", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m02", + "modeId": "mode_multi_normal", + "round": 13, + "seed": 3819019976, + "waveId": "act1autochess_h06", + "time": 219.27, + "ticks": 6578, + "reason": "cleared", + "killed": 3, + "total": 132, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 25, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":3,"total":132,"perfect":false,"leaks":129,"leaksUncounted":0,"coins":0,"dmg":61168,"bossDmg":0,"heal":0,"deaths":28,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,2,0,15633,1865,0,8,1], + ["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,1,2,28961,2494,0,15,1], + ["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,160,1,4413,12694,0,13,0], + ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",1484,1,161,0,8683,14840,0,9,0], + ["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,144,0,1909,3480,0,5,0], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",0,0,77,0,1569,2900,0,3,0] + ], + "enemies": {"enemy_1019_jshoot_2":[57,2,55,9362,21392],"enemy_1381_winman":[27,1,26,526,39174],"enemy_1389_winbab_2":[48,0,48,28385,602]}, + "hooks": [1,161,6578,288,288,172,187,0,31,160,2,23,0,2383,13,14,132,129,1,31,0,0,0], + "events": {"atk":295,"deploy":29,"die":31,"dmg":182,"fx":5,"leak":129,"skill":4,"spawn":138,"status":26}, + "fx": {"displace":4,"dodge":1}, + "snaps": "e5204fab 982d07f3 584cb188 44820d62 8695b17e 5d029e3f ce97f399 5b27b71e 4cbc4a9f 5565a881 55fe5bb0 1854b616 0b410158 0618ca70 a9b7268d 2cfac889 7fb3e5b8 15a58d3f 87049a47 0b595d41 cc6b4807 964b9bf0" } } }