diff --git a/docs/SIM.md b/docs/SIM.md index da0cd396..bf130f26 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -607,7 +607,10 @@ of coverage per 3 s), kept because the current wording no longer says so (feedba | `defDown` / `resDown` | defMul 1 − value / RES −value (*strongest*) | 0.3 / 20 | Unknown keys become a flag buff `{ [key]: true }`. Flags `noBlock` (blocks nothing) and `tremble` exist for custom buffs; -a custom buff with `flags.sleep` also blocks nothing and is untargetable/invulnerable like the status. +a custom buff with `flags.sleep` also blocks nothing and is untargetable/invulnerable like the status. `noNewBlock` = the +unit takes no new enemy by contact (`Battle._blockerFor`) and keeps the blocks it holds — content hands it its blockees +(酒神's 迷狂牢笼, kits/ops/op-phatm2.js: PRTS "只在生成/刷新时判定阻挡新的敌人"); `undying` = a content 不死 window (淬羽赫默 S3, +kits/ops/op-slent2.js — its own `fatal` hook holds it), which items/battle.js `holdsUndying` reports like 坚固维式重锤's. **Element gauges** (`unit.elem = {burn, neural, apoptosis, erosion, necrosis}`; capacity `unit.gaugeMax` = 1000, enemy leaders (rank BOSS / boss units) 2000): deal `{ type:'element', element, amount }` (fires `elementHit` first; the gauge @@ -745,13 +748,14 @@ registration order. `battle.off(handle)` / `battle.off(name, fn)` / `battle.offO | `deploy` | `{ unit, initial, move? }` | ops/tokens (initial & redeploy), enemies (`initial:false`), devices; `move: true` = a 【移动】 (`moveRedeploy`: 乌尔比安 S3 — no exit before it, the unit keeps its buffs) | | `tick` | `{ dt }` | end of every tick | | `beforeAttack` | `{ attacker, targets, isSkill, profile }` | allies **and** enemies; replace/filter `ctx.targets` | +| `enemyAttackStart` | `{ enemy, targets }` | an enemy starts a normal attack (it has targets), **before** 麻痹 may interrupt it (ai.js enemyAttack; the client's ON_BEFORE_ABILITY_SPELL_ON): a burst a handler causes interrupts that very attack (酒神 堕梦, PRTS 备注 "触发的元素爆发可打断当次普攻"); a handler that kills or stuns the enemy ends it | | `attack` | `{ attacker, targets, isSkill }` | an attack/heal was performed (projectiles may still be in flight) | | `hit` | `{ source, target, dmg, credit }` | before mitigation; mutate `dmg` (not fired for gauge fills — see `elementHit`). `source` may be null (terrain; 无来源 `dmg.sourceless` bursts, whose `credit` names the unit credited) | | `elementHit` | `{ source, target, dmg }` | before a gauge fill (`dmg.type === 'element'`); mutate `dmg.amount`/`dmg.mul`, set `dmg.cancel` | | `damaged` | `{ source, target, amount, type, dmg, credit }` | after application (`amount` may be 0 when shielded); element fills too (with their source); 无来源: `source` null, `credit` set | | `hpDamage` | `{ source, target, amount, dmg, credit }` | a damage instance after shields, before the HP loss (§4; not a 流失): lower `amount` only (a raise is ignored) — HP floors / reductions ordered after a barrier (kits/ops/op-huang.js, op-zuole.js) | | `heal` | `{ source, target, amount, opts }` | mutable `amount` | -| `fatal` | `{ unit, source, credit, dmg, amount, prevented }` | HP would reach 0 — set `prevented` (substitutes, kit savers, 不死 / 复活 items, 埃芒加德; 不屈 is a `death` hook). Fired by every HP loss of a unit without a boss pool — hits of any type, element bursts, 无来源 damage, `loseHp` 流失. Order: kits' own savers (10 … −60) → items' 不死 (坚固维式重锤 — once per deployment: `items/battle.js deploymentOf`, a key every deploy changes and an in-place 复活 changes too; one battle-level hook holds the running windows (`holdsUndying`), so a window outlasts a lend, DESIGN §21.21 — the lock `PRIO_REVIVE` −100 after the substitutes (−100, registered first), the running windows `PRIO_UNDYING_HELD` −99 before them: a 傀儡师 holding 不死 does not switch, PRTS 分支特性信息 傀儡师 "未持有不死的情况下", DESIGN §22.11) → items' 复活 (M3茧甲, `PRIO_RESPAWN` −101: PRTS "复活" acts on a knock-out, which a 不死 prevents) → 埃芒加德 (−110); both 复活 revive in place and call `revivedInPlace` (a new deployment for the lock) | +| `fatal` | `{ unit, source, credit, dmg, amount, prevented }` | HP would reach 0 — set `prevented` (substitutes, kit savers, 不死 / 复活 items, 埃芒加德; 不屈 is a `death` hook). Fired by every HP loss of a unit without a boss pool — hits of any type, element bursts, 无来源 damage, `loseHp` 流失. Order: kits' own savers (10 … −60) → items' 不死 (坚固维式重锤 — once per deployment: `items/battle.js deploymentOf`, a key every deploy changes and an in-place 复活 changes too; one battle-level hook holds the running windows (`holdsUndying`), so a window outlasts a lend, DESIGN §21.21 — the lock `PRIO_REVIVE` −100 after the substitutes (−100, registered first), the running windows `PRIO_UNDYING_HELD` −99 before them: a 傀儡师 holding 不死 does not switch, PRTS 分支特性信息 傀儡师 "未持有不死的情况下", DESIGN §22.11) → 淬羽赫默 S3's 不死 (−100.5: PRTS 衍生生命 "无畏者协议提供的不死效果仅在目标没有不死效果时才会触发"; its running windows held at −99 like the hammer's) → items' 复活 (M3茧甲, `PRIO_RESPAWN` −101: PRTS "复活" acts on a knock-out, which a 不死 prevents) → 埃芒加德 (−110); both 复活 revive in place and call `revivedInPlace` (a new deployment for the lock) | | `dollSwitch` | `{ unit, reason, done }` | content switches a 傀儡师 to its <替身> now (归溟幽灵鲨 S2 "技能结束后立刻切换为<替身>": no lethal HP loss); its trait does it unless it already is one or is not on the field, and sets `done` | | `dollSwap` | `{ unit, form }` | a 傀儡师 starts a switch — to its <替身> (`form` `'doll'`) or back to its <本体> (`null`); not when it is knocked out as the 替身 (不屈 rolls on it: "切换<替身>与<本体>时") | | `kill` | `{ killer, victim }` | victim HP reached 0 (a handler may revive by restoring HP) | diff --git a/server/sim/ai.js b/server/sim/ai.js index 9f061af9..a7930a5a 100644 --- a/server/sim/ai.js +++ b/server/sim/ai.js @@ -682,6 +682,13 @@ function enemyAttack(b, e) { if (own && targets.length) targets = targets.filter((a) => own(a)); if (targets.length > 1) sortAllyTargets(e, targets); if (!targets.length) return false; + // `enemyAttackStart` { enemy, targets }: the enemy starts a normal attack, before 麻痹 may interrupt it (the client's + // ON_BEFORE_ABILITY_SPELL_ON of an attack that 麻痹 can interrupt — 酒神 堕梦 "攻击范围内的敌人普通攻击时受到70点神经损伤", + // PRTS 备注 "于敌人成功普通攻击前之前触发(意味着触发的元素爆发可打断当次普攻…)": a burst it causes interrupts this attack) + if (b._hooks.enemyAttackStart) { + b.emit('enemyAttackStart', { enemy: e, targets }); + if (!e.alive || e.s.flags.stun) return false; + } // 麻痹 (ba.palsy): each stack interrupts one normal attack. The `palsyTrigger` hook { enemy, buff, keep } fires as it // does (PRTS 真言 备注: "触发麻痹时" = the attack interrupted by 麻痹, not a stack gained): a handler may set `keep` — the // stack is not consumed (真言 噤声限域 "触发麻痹时有N%概率不消耗麻痹层数") — or deal damage (the enemy may die there) diff --git a/server/sim/battle/blocking.js b/server/sim/battle/blocking.js index 53b81c59..f63d76f8 100644 --- a/server/sim/battle/blocking.js +++ b/server/sim/battle/blocking.js @@ -71,7 +71,9 @@ export class BattleBlocking { * unit (起飞, flag `liftoff`: "不阻挡地面敌人…可以阻挡飞行敌人") blocks flyers only. */ _blockerFor(u, e, w) { - if (!u.alive || !u.deployed || u.hidden || u.s.flags.noBlock || u.s.flags.sleep) return false; + // flag `noNewBlock`: a unit that takes no new enemy by contact — it blocks only those content hands it (酒神's 迷狂牢笼, + // PRTS "只在生成/刷新时判定阻挡新的敌人"); the blocks it holds go on as usual + if (!u.alive || !u.deployed || u.hidden || u.s.flags.noBlock || u.s.flags.sleep || u.s.flags.noNewBlock) return false; if (e.isFlying && !(u.s.flags.blockFly || (u.profile && u.profile.blockFly))) return false; // ground enemies: only a ground unit that has not taken off (起飞 "不阻挡地面敌人": flag `liftoff`), standing on a tile // ground units can pass (not a fenced 围墙 / 围栏 tile) diff --git a/server/sim/content/items/battle.js b/server/sim/content/items/battle.js index 3e3b1602..8874e265 100644 --- a/server/sim/content/items/battle.js +++ b/server/sim/content/items/battle.js @@ -254,9 +254,14 @@ export function revivedInPlace(u) { if (u && u.mem) u.mem.revives = (u.mem.reviv * on the unit (`mem.undyingUntil`, `mem.undyingAt`) and a battle-level hook holds it (hammerAcquire), so it outlives the * grant that started it — a lend running out mid-window leaves the 不死 for its 8 s [ASSUMED: the 异常效果 outlasts its * source] — and ends with the deployment. 信仰搅拌机 S2 steps aside while it holds (kits/ops/chess_char_4_01-rmixer.js). + * A content 不死 window counts too: a buff with the flag `undying` (淬羽赫默 S3 无畏者协议, kits/ops/op-slent2.js — its own + * battle-level hook holds it), so the savers that do not spend themselves while a 不死 holds ("_dontConsumeWhenUndeadable": + * 左乐, 莱恩哈特, 信仰搅拌机) step aside for it as well. */ export function holdsUndying(battle, u) { - return !!u && u.mem.undyingAt != null && battle.time < u.mem.undyingUntil && u.mem.undyingAt === deploymentOf(u); + if (!u) return false; + if (u.mem.undyingAt != null && battle.time < u.mem.undyingUntil && u.mem.undyingAt === deploymentOf(u)) return true; + return !!(u.s && u.s.flags.undying); } /** Effective multiplier of a hammer type on `u` and its params (null when the type does not apply). */ function hammerMul(battle, rt, u, hs, type) { diff --git a/server/sim/content/kits/README.md b/server/sim/content/kits/README.md index d35e4514..26455376 100644 --- a/server/sim/content/kits/README.md +++ b/server/sim/content/kits/README.md @@ -191,7 +191,12 @@ it on a legal tile (the token's position / `ownerRange`), loses it with its owne match hands every placed piece to the battle as a PlayerBattleInput token unit `{ kind: 'token', tokenId, ownerUid }` — so a kit finds its pieces before the battle starts as `battle.allyUnits` with `kind === 'token'`, its `defId` and `ownerUnit === unit`, still undeployed (e.g. `op-bgsnow.js` gives its 打字机 pieces their kit in a talent's `install` -with `battle._setupUnit(t, kit)`; `op-cgbird.js` counts its 幻影). A kit test fields a piece the same way: +with `battle._setupUnit(t, kit)`; `op-cgbird.js` counts its 幻影). A **skill's** summon piece (the variant's `sources` has +'skill', not 'talent') is docked by content/tokens.js before the units are set up — it deploys once, free, at the battle +start, then takes the field when the kit calls `releaseSkillSummon(battle, unit, tokenId)` — and the dock hooks are owned +by the piece: give it its kit with `battle._setupUnit(t, kit)` and **no** `offOwner` (the generic token kit registers no +hook), and clear the owner's `mem.summonStock[tokenId]` when the skill takes the summon back (`op-slent2.js` 夜灯, +`op-phatm2.js` 本能的召唤). A kit test fields a piece the same way: `units: [op, { uid, kind: 'token', tokenId, ownerUid, row, col }]` (`test/content/op_bgsnow.test.js`); the match side is covered by `test/match/diy-shop.test.js` (a DIY 鸿雪's 打字机 from prep to battle). A 召唤师 whose talent holds a deck of summons ("可以使用5个召唤物(最多同时部署3个)") takes `shared/summoner.js summonDeck` — the holding, a placed piece's return diff --git a/server/sim/content/kits/index.js b/server/sim/content/kits/index.js index daf44696..f840dfcc 100644 --- a/server/sim/content/kits/index.js +++ b/server/sim/content/kits/index.js @@ -142,6 +142,9 @@ export const OPERATOR_KIT_FILES = Object.freeze([ 'op-mgllan.js', 'op-ling.js', 'op-radian.js', + 'op-slent2.js', + 'op-haruka.js', + 'op-phatm2.js', ]); /** diff --git a/server/sim/content/kits/ops/op-haruka.js b/server/sim/content/kits/ops/op-haruka.js new file mode 100644 index 00000000..7caaaea0 --- /dev/null +++ b/server/sim/content/kits/ops/op-haruka.js @@ -0,0 +1,230 @@ +// server/sim/content/kits/ops/op-haruka.js — 遥 (char_4202_haruka) 自选 operator kit: 6★ 护佑者 (辅助), an owned-6★ pick of the +// tier-5 and tier-6 自选 slots; every skill, both talents, the trait and her module (BLS-Y 迟来的纪念) at every form. Kit +// contract and the 自选 rules: ../README.md ("How to add an operator (自选)"). +// +// Forms (data/backups.json units.char_4202_haruka): normal = E2 Lv1, skills at rank 4, no module; elite = E2 Lv60, rank 7, the +// picked module at stage 1 (tier 5) or 3 (tier 6) — the owner's decision of 2026-10-05. Potential 0 [ASSUMED: no account]. +// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json), PRTS 遥 (浮光泡影 备注 +// "进行普通攻击判定时,若范围内存在有效目标,则在本次攻击/治疗后同时触发一次赋予浮泡行为;若无有效目标,且范围内存在可赋予 +// 浮泡的单位,则本次普通攻击改为赋予浮泡…赋予浮泡时,仅选择不具有浮泡的我方单位,优先选择其中生命比例最低者…触发浮泡破碎的 +// 行动节点为“成功受到伤害后”;仅来源于敌方,且造成了生命值真实降低的伤害可触发浮泡破碎;触发后,浮泡将在0.5秒延迟后破碎"; +// 扶摇花火 备注 "治疗量的计算使用遥赋予浮泡时的缓存攻击力"; 夜啼彩羽 备注 "浮泡破碎后优先给予生命比例最低的单位浮泡"; 幽隙栖萤 +// 备注 "伤害溅射半径1.7,可对空…治疗量为0时将不会造成法术伤害…每次部署时,重新计算该技能的使用次数"; 夏末游鳞 备注 "触发浮空 +// 效果与触发浮泡破碎的判定采用相同逻辑;即将破碎的浮泡仍能对伤害来源制造浮空效果…无法对持有无敌/不可选中/浮空免疫的来源生效 +// (伤害绑定于该浮空效果上)"), and the client's battle data read from the local install — charpack char_4202_haruka (every +// mode's attack = AttackAndShield when it has a target, else ShieldOnly; the shield selector: allies, no haruka_t_1 holder, +// lowest HP share first, max_target_shield; S2 adds attack@max_target_heal_add / max_target_shield_add to the trait's and the +// talent's counts) and buff_template_data haruka_t_1 / haruka_t_1_delay / haruka_s_2 / haruka_s_2[damage] / haruka_s_3_scale +// / haruka_s_3_debuff_delay / haruka_s_3_debuff. +// - Trait (护佑者) "攻击造成法术伤害,技能开启后改为治疗友方单位(治疗量相当于75%攻击力)": ranged arts bolts (y-6, hits air +// units), blocks 1, ground enemies target her; while any skill runs every attack heals the most injured allies of her +// range (attack@max_target_heal of them, heal_scale × ATK). BLS-Y “迟来的纪念” "攻击对2个目标…改为治疗2个友方单位" +// (attack@max_target / attack@max_target_heal 2). Every skill keeps the data trigger: a 辅助's basic strategy casts on +// an enemy she is about to attack (S1 / S2 DEFAULT), S3 ACTIVE_RANGE on its y-8 (the owner's rule of 2026-10-05). +// - T1 浮光泡影 "每过一个攻击间隔,使范围内一名友方单位获得30%庇护的浮泡,浮泡在受到敌方伤害的短暂延迟后破碎": with each +// attack or heal she gives a bubble to max_target_shield allies of her current range that hold none (operators and summons, +// 禁疗 ones too — ignoreHealFree; never a 孤立 one; herself included), the lowest HP share first; with no target to attack +// or heal but such an ally, the attack itself becomes the bubble (it takes the attack interval); with neither she waits. +// A bubble (no time limit, it outlasts her [ASSUMED: the ability's buff, not removed on her leaving]) gives 庇护 +// damage_resistance (phys / arts taken ×(1 − v); the client's override key damage_resistance[bonus] — not the common 庇护 +// damage_resistance[inf] of other operators, so it multiplies with those) — while her S3 runs ×damage_resistance_scale +// (haruka_s_3_scale re-reads every bubble of hers at its start and end). Damage from an enemy (a `source` on the enemy side) +// that lowered its holder's HP sets it off: 0.5 s (bb interval) later it breaks — once, later hits change nothing. +// - T2 扶摇花火 "浮泡破碎时,为所在的单位治疗相当于遥攻击力25%的生命值": the break heals the holder heal_scale × her ATK as it was +// when she gave the bubble (a 治疗 — her heal, also after she left). BLS-Y stage 2+ "并有20%的概率回复1点技力": then prob to +// give sp SP to the holder (the client's ModifySp on the BUFF_OWNER). +// - S1 夜啼彩羽 (MANUAL, DEFAULT, 19 / 20 s): ASPD +attack_speed; heals; while it runs a breaking bubble at once gives a new one +// to the ally of the 8 tiles around its holder that holds none, the lowest HP share first (haruka_t_1_delay → shield_s1). +// - S2 幽隙栖萤 (MANUAL, DEFAULT, 22 / 23 s): heals (attack@max_target_heal_add more targets), max_target_shield_add more +// bubbles per attack (rank 7: +1 / +1); every heal of hers meanwhile (the trait's and T2's — ON_AFTER_OUTPUT_HEAL, the heal's +// value after the healing multipliers; none under 禁疗) deals that value × atk_scale_extra as arts damage to +// max_target_extra enemies within ability_range_radius (1.7) of the healed unit, air units too — the nearest first +// [ASSUMED: the ExtraSelect order]. "第二次及以后使用时攻击力+25%,且持续时间无限": the uses of the deployment are counted +// (PRTS); from the second on ATK +atk and it never ends (haruka_s_2[first].atk = 0 for the first). +// - S3 夏末游鳞 (MANUAL, ACTIVE_RANGE, 40 s): ATK +atk, range y-8, attack interval +base_attack_time s (−0.4 / −0.6 on 1.6), +// heals, her bubbles ×damage_resistance_scale; an enemy whose damage sets one of her bubbles off (the same test; also a +// bubble already breaking) is 浮空 levitate_duration s (the catalogue status: immunity, 抵抗, half on 重量 > 3; not an +// invulnerable / untargetable one) and while it stays levitated takes atk_scale × her ATK arts damage per second (the +// first after 0.5 s, then every `interval` s; 持续伤害, not dodgeable; 无来源 at her last ATK once she left). + +import { num, up, traitBb, talentBb, skillRec, batMod, giveSp } from '../shared/tier1.js'; +import { absoluteRangeKeys } from '../../../targeting.js'; +import { COLS } from '../../../constants.js'; + +const S1 = 'skchr_haruka_1'; +const S2 = 'skchr_haruka_2'; +const S3 = 'skchr_haruka_3'; +/** A bubble (haruka_t_1): one per unit, whoever gave it — the selector skips holders. */ +export const BUBBLE = 'haruka:bubble'; +/** The S3 levitation damage (haruka_s_3_debuff). */ +export const FLOAT_DOT = 'haruka:float'; +const protectMods = (v) => ({ physTakenMul: 1 - v, artsTakenMul: 1 - v }); +/** 浮光泡影: the break delay when the talent carries no `interval`. */ +const BREAK_DELAY = 0.5; +/** S3's levitation damage: the first tick (haruka_s_3_debuff firstTriggerInterval). */ +const FLOAT_FIRST = 0.5; +/** 幽隙栖萤: the splash radius when the blackboard carries none (PRTS 备注 1.7). */ +const S2_RADIUS = 1.7; +/** The 8 tiles around a unit and its own (夜啼彩羽's shield_s1 area; the holder itself is left out). */ +const X4 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]); + +const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; +const tileKey = (r, c) => r * COLS + c; +const skillOn = (u, id) => !!(u && u.skill && u.skill.id === id && u.skill.active); + +/** Allies that may get a bubble from `by` on tile keys `keys`: no bubble yet, not 孤立; the lowest HP share first. */ +function bubbleCands(battle, by, keys, except = null) { + const out = battle.allyUnits.filter((a) => a.alive && a.deployed && !a.hidden && a.kind !== 'device' && a !== except + && keys.has(tileKey(a.tileR, a.tileC)) && !a.findBuff(BUBBLE) && battle.allySelectable(a, by)); + out.sort((a, b) => a.hpRatio - b.hpRatio || a.deploySeq - b.deploySeq || a.id - b.id); + return out; +} + +export default { + char_4202_haruka: (bb, chess) => { + const tb = traitBb(chess); + const t1 = talentBb(chess, 0), t2 = talentBb(chess, 1); + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + const s2 = skillRec(chess, S2), s3 = skillRec(chess, S3); + const atkTargets = Math.max(1, Math.floor(num(tb['attack@max_target'], 1))); + const healTargets = Math.max(1, Math.floor(num(tb['attack@max_target_heal'], 1))); + const healAttack = (n) => Object.freeze({ + dmgType: 'heal', heal: n > 1 ? { mode: 'multi', count: n } : { mode: 'single' }, healScale: num(tb.heal_scale, 0.75), + maxTargets: n, projectile: 'none', + }); + const shelter = num(t1.damage_resistance, 0.3), breakDelay = num(t1.interval, BREAK_DELAY); + const bubbles = Math.max(1, Math.floor(num(t1.max_target_shield, 1))); + const t2Heal = num(t2.heal_scale), t2Prob = num(t2.prob), t2Sp = num(t2.sp); + const s3Scale = num(b3.damage_resistance_scale, 1); + /** Bubbles per grant: the talent's, + S2's add while it runs. */ + const grantCount = (u) => bubbles + (skillOn(u, S2) ? Math.max(0, Math.floor(num(b2.max_target_shield_add))) : 0); + /** A bubble's 庇护 now: ×damage_resistance_scale while her S3 runs. */ + const shelterNow = (u) => shelter * (skillOn(u, S3) ? s3Scale : 1); + + /** Give `a` a bubble from `u` (its 庇护, her ATK of now for the T2 heal). */ + function bubble(battle, u, a) { + const v = shelterNow(u); + battle.addBuff(a, { + key: BUBBLE, source: u, visible: true, mods: protectMods(v), tags: ['talent'], + data: { v, atk: u.s.atk, heal: t2Heal, prob: t2Prob, sp: t2Sp, boom: false }, + }); + battle.fx('shield', { x: a.x, y: a.y, id: a.id, kind: 'bubble' }); + } + /** One bubble grant of `u` on her current range: up to grantCount allies. Returns how many got one. */ + function grant(battle, u) { + const list = bubbleCands(battle, u, u.rangeKeySet || new Set(u.rangeKeys || [])); + const n = Math.min(grantCount(u), list.length); + for (let i = 0; i < n; i++) bubble(battle, u, list[i]); + return n; + } + /** Re-read the 庇护 of every bubble of `u` (S3's start / end). */ + function rescale(battle, u) { + const v = shelterNow(u); + for (const a of battle.allyUnits) { + const b = a.findBuff(BUBBLE); + if (!b || b.source !== u || !b.data || Math.abs(b.data.v - v) < 1e-12) continue; + b.data = { ...b.data, v }; + b.mods = protectMods(v); + a.markDirty(); + } + } + /** S3: levitate the enemy whose damage set a bubble of `u` off, and its per-second damage while it floats. */ + function levitate(battle, u, e) { + if (!e || !e.alive || e.side !== 'enemy' || e.s.flags.invulnerable || e.s.flags.untargetable) return; + const dur = num(b3.levitate_duration); + if (!(dur > 0) || !battle.applyStatus(e, 'levitate', { duration: dur, source: u })) return; + const iv = Math.max(0.1, num(b3.interval, 1)), scale = num(b3.atk_scale); + let lastAtk = u.s.atk; + battle.addBuff(e, { + key: `${FLOAT_DOT}#${u.id}`, duration: dur + 0.05, source: u, tags: ['skill'], + data: { next: battle.time + FLOAT_FIRST }, + onTick: ({ battle: b, unit: tgt, buff }) => { + if (!tgt.s.flags.levitate) { b.removeBuff(tgt, buff); return; } + if (b.time + 1e-9 < buff.data.next) return; + buff.data = { next: buff.data.next + iv }; + const here = up(u); + if (here) lastAtk = u.s.atk; + b.dealDamage(u, tgt, { amount: lastAtk * scale, type: 'arts', canDodge: false, isSkill: true, sourceless: !here, tags: ['skill', 'dot', 'haruka:float'] }); + }, + }); + battle.fx('levitate', { x: e.x, y: e.y, id: e.id }); + } + /** A bubble of `u` on `a` breaks (the delay is over): T2's heal and SP, S1's next bubble. */ + function burst(battle, u, a, buff) { + battle.removeBuff(a, buff); + const d = buff.data; + if (d.heal > 0) { + battle.heal(u, a, d.atk * d.heal); + battle.fx('heal', { x: a.x, y: a.y, id: a.id }); + if (d.prob > 0 && d.sp > 0 && battle.rng.chance(d.prob)) giveSp(a, d.sp); + } + if (skillOn(u, S1) && up(u)) { + const keys = new Set(absoluteRangeKeys(X4, a.tileR, a.tileC, a.dir || 'RIGHT', 0)); + const next = bubbleCands(battle, u, keys, a)[0]; + if (next) bubble(battle, u, next); + } + } + + return { + trait: atkTargets > 1 ? { maxTargets: atkTargets } : null, + skills: { + [S1]: { kind: 'duration', mods: { aspd: num(b1.attack_speed) }, attack: healAttack(healTargets) }, + [S2]: { + kind: 'duration', attack: healAttack(healTargets + Math.max(0, Math.floor(num(b2['attack@max_target_heal_add'])))), + onStart({ battle, unit }) { + unit.mem.harukaS2Uses = (unit.mem.harukaS2Uses ?? 0) + 1; + unit.mem.harukaS2Endless = unit.mem.harukaS2Uses >= 2; + const atk = unit.mem.harukaS2Endless ? num(b2.atk) : num(b2['haruka_s_2[first].atk']); + if (atk) battle.addBuff(unit, { key: 'haruka:s2:atk', mods: { atkPct: atk }, tags: ['skill'] }); + }, + // 持续时间无限 (2nd use on): the bar stays full and the skill never ends + onTick({ unit, skill }) { if (unit.mem.harukaS2Endless) skill.timeLeft = Math.max(skill.timeLeft, s2?.duration ?? skill.duration); }, + onEnd({ battle, unit }) { battle.removeBuff(unit, 'haruka:s2:atk'); unit.mem.harukaS2Endless = false; }, + }, + [S3]: { + kind: 'duration', + mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess) }, + targeting: { rangeGrid: s3?.rangeGrid ?? null }, + attack: healAttack(healTargets), + onStart({ battle, unit }) { rescale(battle, unit); }, + onEnd({ battle, unit }) { rescale(battle, unit); }, // (`active` is already false: ×1 again) + }, + }, + talents: [ + { install(battle, unit) { // 浮光泡影: a bubble with each attack / heal, or instead of one; the break + battle.on('attack', (c) => { if (c.attacker === unit && up(unit)) grant(battle, unit); }, { owner: unit }); + // no target to attack or heal but an ally to bubble: the attack becomes the bubble (ShieldOnly) + battle.on('tick', () => { + if (!up(unit) || !unit.canAct || unit.s.flags.disarm || unit.atkCd > 0 || unit.trait.hadTarget !== false) return; + if (grant(battle, unit) > 0) unit.atkCd = Math.max(unit.atkCd, unit.s.interval); + }, { owner: unit }); + battle.on('damaged', (c) => { + const a = c.target, src = c.source; + if (!a || a.side !== 'ally' || !src || src.side !== 'enemy' || !(c.amount > 0) || c.type === 'element') return; + const b = a.findBuff(BUBBLE); + if (!b || b.source !== unit) return; + if (skillOn(unit, S3) && up(unit)) levitate(battle, unit, src); + if (b.data.boom) return; + b.data = { ...b.data, boom: true }; + battle.after(breakDelay, () => { if (a.alive && a.findBuff(BUBBLE) === b) burst(battle, unit, a, b); }, { owner: a }); + }, { owner: unit }); + battle.on('deploy', (c) => { if (c.unit === unit) { unit.mem.harukaS2Uses = 0; unit.mem.harukaS2Endless = false; } }, { owner: unit }); + } }, + { install(battle, unit) { // 幽隙栖萤: each heal of hers while S2 runs ⇒ arts damage around the healed unit + if (unit.skill?.id !== S2) return; + const scale = num(b2.atk_scale_extra), n = Math.max(0, Math.floor(num(b2.max_target_extra))); + const r = num(b2.ability_range_radius, S2_RADIUS); + battle.on('heal', (c) => { + if (c.source !== unit || c.opts?.regen || !skillOn(unit, S2) || !(c.amount > 0) || !(scale > 0) || !n) return; + const t = c.target; + const foes = battle.foesInRadius(t.x, t.y, r); + foes.sort((x, y) => Math.hypot(x.x - t.x, x.y - t.y) - Math.hypot(y.x - t.x, y.y - t.y) || x.spawnSeq - y.spawnSeq); + const amount = c.amount * scale; + for (const e of foes.slice(0, n)) battle.dealDamage(unit, e, { amount, type: 'arts', isSkill: true, tags: ['skill', 'haruka:s2'] }); + if (foes.length) battle.fx('aoe', { x: t.x, y: t.y, radius: r, id: unit.id, skill: 'haruka:s2' }); + }, { owner: unit, priority: -1000 }); + } }, + ], + }; + }, +}; diff --git a/server/sim/content/kits/ops/op-phatm2.js b/server/sim/content/kits/ops/op-phatm2.js new file mode 100644 index 00000000..919fd840 --- /dev/null +++ b/server/sim/content/kits/ops/op-phatm2.js @@ -0,0 +1,387 @@ +// server/sim/content/kits/ops/op-phatm2.js — 酒神 (char_1042_phatm2) 自选 operator kit: 6★ 巫役 (辅助), an owned-6★ pick of the +// tier-5 and tier-6 自选 slots; every skill, both talents, the trait, her module (RIT-X 酒神之心) at every form, and the kits of +// her summons 本能的召唤 (token_10054_phatm2_encdool) and 迷狂牢笼 (token_10055_phatm2_mndclv). Kit contract and the 自选 +// rules: ../README.md ("How to add an operator (自选)"). +// +// Forms (data/backups.json units.char_1042_phatm2): normal = E2 Lv1, skills at rank 4, no module; elite = E2 Lv60, rank 7, the +// picked module at stage 1 (tier 5) or 3 (tier 6) — the owner's decision of 2026-10-05. Potential 0 [ASSUMED: no account]. +// Sources: character_table / skill_table / battle_equip_table / token_table / range_table (zh_CN, as built into backups.json), +// PRTS 酒神 (形为心役 备注 "溅射半径1.3(中点判定);先造成神经损伤与溅射,随后造成伤害"; 堕梦 备注 "于敌人成功普通攻击前之前触发 +// (意味着触发的元素爆发可打断当次普攻,且不会触发成功攻击前的效果)"; 群体性谵妄 备注 on 本能的召唤; 空剧场 备注 "技能开始时, +// 对攻击范围内所有处于元素损伤爆发中的敌人触发一次第一天赋…尝试为其生成一次迷狂牢笼(不刷新已存在的牢笼)…迷狂牢笼只能生成于 +// 不存在角色类单位的低地地块…所在地块已存在迷狂牢笼时改为将其刷新1次"), PRTS 本能的召唤 / 迷狂牢笼 (their 备注), and the +// client's battle data read from the local install — charpack char_1042_phatm2 (T1: phatm2_t_1 an active buff of every +// attack; T2: phatm2_t_2 on the enemies of her range, phatm2_t_2[global] on every enemy, phatm2_t_2[s3] in S3's range; S3: +// SpawnToken → phatm2_s_3[token] on the enemies of its range) and buff_template_data phatm2_t_1 / [range], phatm2_t_2 / +// [global] / [s3], phatm2_s_1[unmove], phatm2_s_2 / [damage] / [die], phatm2_s2_token_cache, token_phatm2_encdool_* , +// phatm2_s_3[trigger] / [sanity] / [token] / [token][trigger] / [die] / [token_cooldown] / [token_clear_cooldown], +// token_phatm2_mndclv[passive]. +// - Trait (巫役) "攻击造成法术伤害,可以造成元素损伤": ranged arts bolts (y-2, hits air units), blocks 1, ground enemies target +// her. RIT-X “酒神之心” (trait bb ep_damage_scale) "对精英和领袖敌人造成的元素损伤提升18%": every element fill she deals +// (her summon's too: "来源均为酒神自身") on an ELITE / BOSS / leader enemy ×1.18 — an `elementHit` multiplier (the kits' +// convention: 塑心 强弱法). +// - T1 形为心役 "攻击附带相当于攻击力30%的神经损伤,并对目标周围其他敌人造成一次相当于攻击力20%的神经损伤": every damage +// instance of her normal attacks first gives its target attack@ep_damage_ratio × ATK 神经损伤 and every other selectable +// enemy within range_radius (1.3, 中点判定; S2 talent@range_radius 1.5) of it ep_damage_ratio × ATK — before the damage (a +// late `hit` handler, after any cancel; a hit dodged afterwards still carried it [ASSUMED], as 塑心 S2). S1's two hits +// each carry it [ASSUMED: per damage instance]. +// - T2 堕梦 "在场时,全场处于神经损伤爆发期间的敌人攻击速度-12;攻击范围内的敌人普通攻击时受到70点神经损伤": while she is on +// the field every enemy in a 神经损伤 burst (its 爆发冷却, `neuralBurst`) has ASPD −|attack_speed| (one "同名" effect, +// the strongest — priorityBBKeys); an enemy of her attack range starting a normal attack first takes `value` 神经损伤 — +// the engine's `enemyAttackStart` hook, before 麻痹 is checked, so a burst it causes interrupts that very attack. RIT-X +// stage 2+: −20 / 90 (the talent change). +// - S1 暗夜回声 (AUTO, attack SP 3, data DEFAULT — a "next attack"): that attack picks an enemy not in a burst first +// (`notBurst`) and hits twice for atk_scale × ATK arts (times), then 束缚 (`bind`) for `unmove` s; while that root holds +// (its final duration after 抵抗) the target takes 神经损伤 ×ep_damage_scale from anyone (phatm2_s_1[unmove] +// EpDamageScale; one effect per target, the strongest) — the root lands after the two hits [ASSUMED]. +// - S2 群体性谵妄 (AUTO, 持续时间无限 ⇒ a toggle until she leaves; an AUTO skill acting on herself and her summon: SP_FULL — +// the owner's AUTO rule): ASPD +attack_speed, T1's splash radius talent@range_radius; her placed 本能的召唤 piece takes the +// field on its tile when the skill starts and again each time it is ready ("退场时返还1个可部署的本能的召唤": its own 25 s +// redeploy time) while the skill runs (content/tokens.js releaseSkillSummon; like every placed skill summon it also deploys +// once, free, at the battle start — PRTS 卫戍协议/帮助); her leaving withdraws it ("酒神退场时强制撤退场上的本能的召唤"). +// 本能的召唤 (kit below): untargetable, no attack, blocks nothing; every 0.1 s it 诱导s (the catalogue `attract`, to its +// tile, for the rest of its life) the enemies of its x-1 (13 tiles) it can lure — at most max_target in all, ELITE / leader +// first [ASSUMED: then the nearest], only "可达" ones (a ground path to its tile; flyers always); it leaves 10 s after its +// deployment or as soon as one of them is within ability_range_radius (0.4) of it; whenever it leaves (any reason) every +// selectable enemy of its x-1 is 停顿 `sluggish` s and for buff_time s takes, every interval_damage s from 0.1 s on, +// ep_damage_ratio_token × ATK 神经损伤 (fixed: "不受目标损伤抗性影响" — the 损伤抵抗 share added back) and atk_scale × ATK arts +// (持续伤害, not dodgeable) — her ATK when it was deployed ("缓存攻击力"), credited to her (无来源 once she left). +// - S3 空剧场 (MANUAL, ACTIVE_RANGE on y-8, 30 s): range y-8, ATK +atk, an enemy not in a burst first; every enemy she gives +// 神经损伤 meanwhile (a real gauge gain) takes ep_damage_ratio × her ATK 神经损伤 0.1 s later and every `interval` s after +// until it is in a burst (any element) or the skill ends; enemies of her range in a 神经损伤 爆发冷却 recover +// talent@ep_break_recover_speed (+50 %) faster (the cooldown runs 1.5×); an enemy of her range whose 神经损伤 bursts gets a +// 迷狂牢笼 on its ground tile (a low tile with no unit on it; an existing 牢笼 there is refreshed instead). At its start every +// enemy of her range already in an element burst sets T1's splash off around it once and, in a 神经损伤 burst, gets a 牢笼 +// (none refreshed); 牢笼 left from an earlier cast stay. When it ends every 牢笼 of hers lasts +// phatm2_s_3[token_cooldown].interval (30) s more; her leaving withdraws them. +// 迷狂牢笼 (kit below): 孤立 + 禁疗, no attack, 迷彩 (only the enemies it blocks target it), every damage it takes from +// one of them is 1, any other damage (无来源 too) none — 3 HP: three hits; at its spawn / refresh it blocks every unblocked +// enemy on its tile, ground or air, whatever its capacity, back to full HP, block count = their weight; it takes no other +// enemy by contact (flag `noNewBlock`); with nobody blocked it leaves at once. +// Statuses: 束缚 (S1), 停顿 + 诱导 (本能的召唤); 神经损伤 bursts (engine: 3 麻痹 + 6000 元素伤害). + +import { num, up, traitBb, talentBb, skillRec, once } from '../shared/tier1.js'; +import { isElite, onElementHit, elementDmg } from '../shared/tier6.js'; +import { releaseSkillSummon } from '../../tokens.js'; +import { absoluteRangeKeys } from '../../../targeting.js'; +import { bodyInKeys } from '../../../body.js'; +import { hasHp, burstLocked } from '../../../damage.js'; +import { MIN_DAMAGE_RATIO } from '../../../constants.js'; + +const S1 = 'skchr_phatm2_1'; +const S2 = 'skchr_phatm2_2'; +const S3 = 'skchr_phatm2_3'; +export const CALL = 'token_10054_phatm2_encdool'; +export const CAGE = 'token_10055_phatm2_mndclv'; +/** phatm2_s_1[unmove]: the root's 神经损伤 amplifier (one per target, the strongest). */ +export const UNMOVE = 'phatm2:unmove'; +/** phatm2_t_2[attack_speed]: the ASPD cut of enemies in a 神经损伤 burst (one per enemy, the strongest). */ +export const DREAM = 'phatm2:dream'; +/** The client's trigger period of her field / range effects. */ +const IV = 0.1; +const HOLD = IV + 0.05; +/** 本能的召唤 / 迷狂牢笼 numbers the token records may lack (PRTS 本能的召唤: 10 s, 0.4; the S3 cooldown 30 s). */ +const CALL_LIFE = 10, CALL_ARRIVE = 0.4, CAGE_AFTER = 30; +/** 本能的召唤's area when its record carries no grid: x-1 (range_table: the 13-tile diamond). */ +const X1 = Object.freeze([[2, 0], [1, -1], [1, 0], [1, 1], [0, -2], [0, -1], [0, 0], [0, 1], [0, 2], [-1, -1], [-1, 0], [-1, 1], [-2, 0]]); +/** Selection profile of her effects on enemies: ground and air. */ +const ANY = Object.freeze({ canHitFly: true }); + +const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; +const skillOn = (u, id) => !!(u && u.skill && u.skill.id === id && u.skill.active); +const keysAt = (grid, u) => new Set(absoluteRangeKeys(grid, u.tileR, u.tileC, u.dir, 0)); + +/** + * 神经损伤 of a fixed value that ignores the target's 损伤抵抗 (PRTS 群体性谵妄 备注 "不受目标损伤抗性影响": the client's + * ApplyFixedElementDamage): the share the engine takes off (damage.js elementIntake) is added back first. + */ +function fixedNeural(battle, src, e, amount, sourceless = false) { + if (!(amount > 0) || !hasHp(e)) return 0; + const res = Math.min(1, Math.max(0, (Number(e.def?.epResistance) || 0) / 100)); + const k = Math.max(MIN_DAMAGE_RATIO, 1 - res); + return battle.dealDamage(src, e, { type: 'element', element: 'neural', amount: amount / k, sourceless, tags: ['skill', 'phatm2:fixed'] }); +} + +/** + * 本能的召唤's kit (the skill-summon piece of her S2: a fresh one per piece). `owner` = 酒神, `b2` = her S2 blackboard + * (fallback for the token's own skill blackboard). + */ +function callKit(t, owner, b2) { + const sb = t.def?.skill?.bb ?? {}; + const pick = (k, d) => num(sb[k], num(b2[k], d)); + const life = num((t.def?.talents ?? []).find((x) => x && x.bb && x.bb.duration != null)?.bb?.duration, pick('interval', CALL_LIFE)); + const cap = Math.max(1, Math.floor(pick('max_target', 4))), arrive = pick('ability_range_radius', CALL_ARRIVE); + const grid = t.def?.rangeGrid ?? X1; + return { + skill: null, + trait: { noAttack: true }, + install(battle, tok) { + let lured = [], endAt = 0, atk = 0; + const mine = (e) => { const a = e.findBuff('attract'); return a && a.source === tok ? a : null; }; + battle.on('deploy', (c) => { + if (c.unit !== tok) return; + lured = []; + endAt = battle.time + life; + atk = owner.s.atk; // 缓存攻击力: her ATK at the deployment (PRTS 备注) + const seq = tok.deploySeq; + battle.after(life, () => { if (tok.alive && tok.deploySeq === seq) battle.retreat(tok, { reason: 'expired', permanent: true }); }, { owner: tok }); + }, { owner: tok }); + // 诱导 the enemies entering its x-1 (every 0.1 s, at most `cap` in all) + battle.every(IV, () => { + if (!up(tok) || lured.length >= cap) return; + const keys = keysAt(grid, tok); + const cands = battle.enemiesInKeys([...keys], tok, ANY).filter((e) => !lured.includes(e) && !e.s.flags.attract + && (e.motion === 'FLY' || !!battle.grid.waypoints(Math.round(e.y), Math.round(e.x), tok.tileR, tok.tileC))); + cands.sort((a, b) => (isElite(b) - isElite(a)) || (Math.hypot(a.x - tok.x, a.y - tok.y) - Math.hypot(b.x - tok.x, b.y - tok.y)) || a.spawnSeq - b.spawnSeq); + for (const e of cands) { + if (lured.length >= cap) break; + if (battle.applyStatus(e, 'attract', { duration: Math.max(0.05, endAt - battle.time), source: tok, point: [tok.tileR, tok.tileC] })) { + lured.push(e); + battle.fx('lure', { x: e.x, y: e.y, id: e.id, src: tok.id }); + } + } + }, { owner: tok }); + // the first lured enemy within `arrive` of it: it leaves + battle.on('tick', () => { + if (!up(tok)) return; + if (lured.some((e) => e.alive && mine(e) && Math.hypot(e.x - tok.x, e.y - tok.y) <= arrive + 1e-9)) battle.retreat(tok, { reason: 'expired', permanent: true }); + }, { owner: tok }); + // leaving (any reason): 停顿 + the damage over time on every selectable enemy of its x-1; the 诱导 ends + battle.on('death', (c) => { + if (c.unit !== tok || battle.finished) return; + for (const e of lured) { const a = e.alive ? mine(e) : null; if (a) battle.removeBuff(e, a); } + lured = []; + const keys = keysAt(grid, tok); + const slow = pick('sluggish', 6), dur = pick('buff_time', 6), iv = Math.max(0.1, pick('interval_damage', 0.5)); + const ep = pick('ep_damage_ratio_token', 0), scale = pick('atk_scale', 1), a0 = atk; + battle.fx('aoe', { x: tok.x, y: tok.y, radius: 2, id: tok.id, skill: 'phatm2:call' }); + for (const e of battle.enemiesInKeys([...keys], tok, ANY)) { + if (slow > 0) battle.applyStatus(e, 'sluggish', { duration: slow, source: owner }); + battle.addBuff(e, { + key: `phatm2:delirium#${owner.id}`, duration: dur, source: owner, tags: ['skill'], data: { next: battle.time + IV }, + onTick: ({ battle: b, unit: tgt, buff }) => { + if (b.time + 1e-9 < buff.data.next) return; + buff.data = { next: buff.data.next + iv }; + const gone = !owner.alive; + fixedNeural(b, owner, tgt, a0 * ep, gone); + if (tgt.alive) b.dealDamage(owner, tgt, { amount: a0 * scale, type: 'arts', canDodge: false, isSkill: true, sourceless: gone, tags: ['skill', 'dot', 'phatm2:delirium'] }); + }, + }); + } + }, { owner: tok, priority: 10 }); + // "退场时返还1个可部署的本能的召唤": while her S2 runs it comes back after its own redeploy time (the dock's, −10, first) + battle.on('death', (c) => { + if (c.unit === tok && !battle.finished && skillOn(owner, S2) && up(owner)) releaseSkillSummon(battle, owner, CALL); + }, { owner: tok, priority: -20 }); + }, + }; +} + +/** 迷狂牢笼's kit (spawned by her S3: a fresh one per cage). */ +function cageKit() { + return { + skill: null, + trait: { noAttack: true }, + install(battle, cage) { + battle.addBuff(cage, { key: 'trait:cage', flags: { isolated: true, noHeal: true, camou: true, noNewBlock: true }, persist: true, allowDead: true }); + // only the enemies it blocks hurt it, 1 per hit + battle.on('hit', (c) => { if (c.target === cage && (!c.source || !cage.blocking.includes(c.source))) c.dmg.cancel = true; }, { owner: cage, priority: 1000 }); + battle.on('hpDamage', (c) => { if (c.target === cage && c.amount > 1) c.amount = 1; }, { owner: cage }); + battle.on('tick', () => { + if (!up(cage)) return; + if (!cage.blocking.length || (cage.mem.cageUntil != null && battle.time + 1e-9 >= cage.mem.cageUntil)) battle.retreat(cage, { reason: 'expired', permanent: true }); + }, { owner: cage }); + }, + }; +} + +/** A 牢笼 blocks every unblocked enemy on its tile (spawn / refresh): full HP again, block count = their weight. */ +function cageBlock(battle, cage) { + let added = 0; + for (const e of battle.enemies) { + if (!e.alive || e.hidden || e.blockedBy || Math.round(e.y) !== cage.tileR || Math.round(e.x) !== cage.tileC) continue; + const f = e.s.flags; + if (f.unblockable || f.levitate || f.fear) continue; + e.blockedBy = cage; + cage.blocking.push(e); + e.moving = false; + added++; + if (battle.hasHook('blocked')) battle.emit('blocked', { blocker: cage, enemy: e }); + } + if (added) { + let w = 0; + for (const e of cage.blocking) w += e.blockWeight ?? 1; + battle.addBuff(cage, { key: 'cage:block', mods: { blockCnt: w }, persist: true, allowDead: true }); + cage.hp = cage.s.maxHp; + } + return added; +} + +/** 空剧场: a 牢笼 on enemy `e`'s ground tile — or the one already there refreshed (`refresh`). */ +function cageAt(battle, owner, e, refresh) { + const r = Math.round(e.y), c = Math.round(e.x); + if (!battle.grid.inRect(r, c)) return null; + const there = battle.allyUnits.find((t) => t.alive && t.deployed && t.kind === 'token' && t.defId === CAGE && t.tileR === r && t.tileC === c); + if (there) { if (refresh) cageBlock(battle, there); return there; } + if (!battle.grid.isLow(r, c) || battle.unitAt(r, c)) return null; + const cage = battle.spawnToken(owner, CAGE, r, c, { kit: cageKit() }); + if (!cage) return null; + if (!skillOn(owner, S3)) cage.mem.cageUntil = battle.time + CAGE_AFTER; + cageBlock(battle, cage); + battle.fx('summon', { x: cage.x, y: cage.y, id: cage.id, token: CAGE }); + return cage; +} + +const callsOf = (battle, owner) => battle.allyUnits.filter((t) => t.kind === 'token' && t.defId === CALL && t.ownerUnit === owner); +const cagesOf = (battle, owner) => battle.allyUnits.filter((t) => t.kind === 'token' && t.defId === CAGE && t.ownerUnit === owner && t.alive); + +export default { + char_1042_phatm2: (bb, chess) => { + const tb = traitBb(chess); + const t1 = talentBb(chess, 0), t2 = talentBb(chess, 1); + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + const s3 = skillRec(chess, S3); + const eliteScale = num(tb.ep_damage_scale, 1); + const mainRatio = num(t1['attack@ep_damage_ratio']), splashRatio = num(t1.ep_damage_ratio); + const splashR = num(t1.range_radius, 1.3), s2R = num(b2['talent@range_radius'], splashR); + const dreamAspd = Math.abs(num(t2.attack_speed)), dreamValue = num(t2.value); + const s3Ratio = num(b3.ep_damage_ratio), s3Iv = Math.max(0.1, num(b3.interval, 1)); + const s3Recover = num(b3['talent@ep_break_recover_speed']), s3After = num(b3['phatm2_s_3[token_cooldown].interval'], CAGE_AFTER); + const s3DotKey = (u) => `phatm2:s3dot#${u.id}`; + /** T1's splash around `t`: every other selectable enemy within the radius (中点判定). */ + const splash = (battle, unit, t, r) => { + if (!(splashRatio > 0)) return; + for (const e of battle.foesInRadius(t.x, t.y, r, true)) if (e !== t) elementDmg(battle, unit, e, 'neural', unit.s.atk * splashRatio, ['talent']); + }; + return { + skills: { + [S1]: { + kind: 'instant', + targeting: { priority: 'notBurst' }, + attack: { + atkScale: num(b1.atk_scale, 1), hits: Math.max(1, Math.floor(num(b1.times, 2))), + onHit({ battle, unit, target }) { + if (!target || !target.alive || !battle.applyStatus(target, 'bind', { duration: num(b1.unmove), source: unit })) return; + const root = target.buffs.find((b) => b.status === 'bind'); + const dur = root && Number.isFinite(root.timeLeft) ? root.timeLeft : num(b1.unmove); + battle.applyStrongest(target, UNMOVE, { duration: dur, value: num(b1.ep_damage_scale, 1), mods: () => ({}), source: unit }); + }, + }, + }, + [S2]: { + kind: 'toggle', trigger: 'SP_FULL', mods: { aspd: num(b2.attack_speed) }, + onStart({ battle, unit }) { releaseSkillSummon(battle, unit, CALL); }, + onEnd({ battle, unit }) { withdrawCalls(battle, unit); }, + }, + [S3]: { + kind: 'duration', + mods: { atkPct: num(b3.atk) }, + targeting: { rangeGrid: s3?.rangeGrid ?? null, priority: 'notBurst' }, + onStart({ battle, unit }) { + for (const c of cagesOf(battle, unit)) c.mem.cageUntil = null; // phatm2_s_3[token_clear_cooldown] + for (const e of [...battle.enemies]) { + if (!e.alive || e.hidden || !e.s.flags.burstLock || !bodyInKeys(e, unit.rangeKeySet)) continue; + splash(battle, unit, e, splashR); + if (e.alive && e.findBuff('neuralBurst')) cageAt(battle, unit, e, false); + } + }, + onTick({ battle, unit, dt }) { + if (!(s3Recover > 0)) return; + for (const e of battle.enemies) { + if (!e.alive || e.hidden || !bodyInKeys(e, unit.rangeKeySet)) continue; + const lock = e.findBuff('neuralBurst'); + if (lock && Number.isFinite(lock.timeLeft)) lock.timeLeft -= dt * s3Recover; + } + }, + onEnd({ battle, unit }) { + const key = s3DotKey(unit); + for (const e of battle.enemies) if (e.findBuff(key)) battle.removeBuff(e, key); + for (const c of cagesOf(battle, unit)) c.mem.cageUntil = battle.time + s3After; + }, + }, + }, + talents: [ + { install(battle, unit) { // 形为心役: each hit of her attacks — its target, then the enemies around it + battle.on('hit', (c) => { + const t = c.target, d = c.dmg; + if (c.source !== unit || !t || t.side !== 'enemy' || !d || d.cancel || !d.isAttack || d.isSplash || !hasHp(t)) return; + if (mainRatio > 0) elementDmg(battle, unit, t, 'neural', unit.s.atk * mainRatio, ['talent']); + splash(battle, unit, t, skillOn(unit, S2) ? s2R : splashR); + }, { owner: unit, priority: -1000 }); + } }, + { install(battle, unit) { // 堕梦: ASPD cut in a 神经损伤 burst (the field); 神经损伤 on a normal attack (her range) + if (dreamAspd > 0) { + const slow = (e) => battle.applyStrongest(e, DREAM, { duration: HOLD, value: dreamAspd, mods: (v) => ({ aspd: -v }), source: unit }); + battle.every(IV, () => { + if (!up(unit)) return; + for (const e of battle.enemies) if (e.alive && !e.hidden && e.findBuff('neuralBurst')) slow(e); + }, { owner: unit, immediate: true }); + battle.on('elementBurst', (c) => { if (up(unit) && c.element === 'neural' && c.target && c.target.side === 'enemy' && c.target.alive) slow(c.target); }, { owner: unit }); + } + if (dreamValue > 0) { + battle.on('enemyAttackStart', (c) => { + const e = c.enemy; + if (!up(unit) || !e || !hasHp(e) || !bodyInKeys(e, unit.rangeKeySet)) return; + elementDmg(battle, unit, e, 'neural', dreamValue, ['talent']); + }, { owner: unit }); + } + } }, + { install(battle, unit) { // 本能的召唤: her placed pieces run its kit; her leaving withdraws it and every 迷狂牢笼 + for (const t of callsOf(battle, unit)) { + if (t.alive || t.deployed) continue; + // (no offOwner for a piece set up before her: its hooks are content/tokens.js's dock hooks — the generic token + // kit registers none — and they must stay) + battle._setupUnit(t, callKit(t, unit, b2)); + } + battle.on('death', (c) => { + if (c.unit !== unit) return; + withdrawCalls(battle, unit); + for (const t of cagesOf(battle, unit)) battle.retreat(t, { reason: 'expired', permanent: true }); + }, { owner: unit }); + } }, + ], + install(battle, unit) { + // RIT-X: her element damage on elite / leader enemies ×ep_damage_scale + if (eliteScale > 1) onElementHit(battle, unit, (c) => (c.source === unit && c.target.side === 'enemy' && isElite(c.target) ? eliteScale : 1)); + // S1's root: 神经损伤 taken ×its value while it holds (one battle-wide handler, whoever rooted) + once(battle, 'phatm2:unmove', () => { + battle.on('elementHit', (c) => { + const d = c.dmg, t = c.target; + if (!d || d.type !== 'element' || d.element !== 'neural' || !t) return; + const v = t.findBuff(UNMOVE)?.data?.value; + if (v > 1) d.mul *= v; + }); + }); + if (unit.skill?.id !== S3) return; + // 空剧场: her 神经损伤 starts the per-second 神经损伤 (until a burst); a 神经损伤 burst in her range: a 牢笼 + battle.on('damaged', (c) => { + const e = c.target; + if (!skillOn(unit, S3) || c.source !== unit || c.type !== 'element' || c.dmg?.element !== 'neural' || !(c.amount > 0)) return; + if (!e || e.side !== 'enemy' || !e.alive || e.findBuff(s3DotKey(unit)) || burstLocked(e)) return; + battle.addBuff(e, { + key: s3DotKey(unit), source: unit, tags: ['skill'], data: { next: battle.time + IV }, + onTick: ({ battle: b, unit: tgt, buff }) => { + if (burstLocked(tgt)) { b.removeBuff(tgt, buff); return; } + if (b.time + 1e-9 < buff.data.next) return; + buff.data = { next: buff.data.next + s3Iv }; + elementDmg(b, unit, tgt, 'neural', unit.s.atk * s3Ratio, ['skill', 'dot', 'phatm2:theatre']); + if (tgt.alive && burstLocked(tgt)) b.removeBuff(tgt, buff); + }, + }); + }, { owner: unit }); + battle.on('elementBurst', (c) => { + const e = c.target; + if (c.element !== 'neural' || !skillOn(unit, S3) || !up(unit) || !e || e.side !== 'enemy' || !e.alive || !bodyInKeys(e, unit.rangeKeySet)) return; + cageAt(battle, unit, e, true); + }, { owner: unit }); + }, + }; + }, +}; + +/** Her S2's end or her leaving: the 本能的召唤 in stock is gone and the one on the field leaves. */ +function withdrawCalls(battle, unit) { + const stock = unit.mem.summonStock; + if (stock) stock[CALL] = 0; + for (const t of callsOf(battle, unit)) if (t.alive) battle.retreat(t, { reason: 'expired', permanent: true }); +} diff --git a/server/sim/content/kits/ops/op-slent2.js b/server/sim/content/kits/ops/op-slent2.js new file mode 100644 index 00000000..bcc4ab9b --- /dev/null +++ b/server/sim/content/kits/ops/op-slent2.js @@ -0,0 +1,275 @@ +// server/sim/content/kits/ops/op-slent2.js — 淬羽赫默 (char_1031_slent2) 自选 operator kit: 6★ 护佑者 (辅助), an owned-6★ pick of +// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait, both modules (BLS-X 难得清醒, BLS-Y 可控动量) at every +// form, and the kit of her summon 夜灯 (token_10029_slent2_protrb). Kit contract and the 自选 rules: ../README.md ("How to add +// an operator (自选)"). +// +// Forms (data/backups.json units.char_1031_slent2): normal = E2 Lv1, skills at rank 4, no module; elite = E2 Lv60, rank 7, the +// picked module at stage 1 (tier 5) or 3 (tier 6) — the owner's decision of 2026-10-05. Potential 0 [ASSUMED: no account]. +// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json), PRTS 淬羽赫默 (无声砥柱 备注 +// "根据其已损失的生命值比例(0%~70%,以1%为最小单位)线性提升…每当庇护数值变化时,重新为该友方单位附加庇护BUFF"; 丰润羽翼 备注 +// on the BLS-Y barrier; 俯瞰视界 备注 "技能期间撤回夜灯时,夜灯立刻返回待部署区并进入再部署时间"; 无畏者协议 备注 "只在没有持有 +// 不死的我方干员正在应用致命伤害时…触发…该效果不会因淬羽赫默的离场消失"), PRTS 夜灯, PRTS 衍生生命 (致命伤害: "无畏者协议提供的 +// 不死效果仅在目标没有不死效果时才会触发"), and the client's battle data read from the local install — charpack +// char_1031_slent2 (talent abilities: slent2_t_1 on the allies of her range every 0.1 s, removed when they leave; slent2_t_2 +// likewise, its selector ignoring 禁疗 — ignoreHealFree; the S1 / S2 / S3 modes: attacks become heals of max_target injured +// allies, the lowest HP share first) and buff_template_data slent2_t_1 / slent2_t_2 / slent2_s_2 / slent2_s_3 / +// slent2_e_002[max_hp] / slent2_e_003_t2 (+ [shield]); the template atk_to_hp_recovery is the one PRTS documents as +// 「生命回复速度」 for 絮雨 天赋 and 琴柳 S2 ("生命恢复的提供方式为增加目标的“生命回复速度”属性,不受治疗加成和禁疗影响"). +// - Trait (护佑者) "攻击造成法术伤害,技能开启后改为治疗友方单位(治疗量相当于75%攻击力)": ranged arts bolts (the SUPPORT +// default; 3×3-and-more y-6, hits air units), blocks 1, ground enemies target her (no flag); while ANY of her skills runs +// every attack heals the most injured ally of her range instead (trait heal_scale × ATK; allies = operators and summons). +// BLS-X “难得清醒”: heal_scale 1 (100 %). BLS-Y “可控动量” "攻击对2个目标造成法术伤害,技能开启后改为治疗2个友方单位" +// (trait attack@max_target 2): two targets / two heals. +// Her skills are no heal skills for the 技能策略: a 辅助's basic strategy casts on an enemy she is about to attack (PRTS +// 卫戍协议/帮助: only 医疗干员 attack the units needing a heal), so every skill keeps the data's DEFAULT. +// - T1 无声砥柱 "使攻击范围内的友军获得10%的庇护…(低于30%生命时获得最大24%的庇护)": every ally of her range (herself +// included) holds 庇护 (ba.protect "受到的物理和法术伤害降低相应比例(同名效果取最高)"; the client's damage_resistance[inf], +// the 庇护 the other kits share as `protect` — battle.applyStrongest) of damage_resistance_base × (1 + resistance_scale × n), +// n = its lost HP in whole hp_ratio (1 %) steps, at most 1 − min_hp_ratio (70 steps ⇒ 10 % × 2.4 = 24 %), re-read every +// 0.1 s (the client's trigger) and gone at once when it leaves her range (a lower value takes over within one refresh). +// S3 / the 夜灯 scale damage_resistance_base (the client's `_scaleCertainKeyList`), i.e. the whole value. +// - T2 丰润羽翼 "攻击范围内生命低于50%的友军每秒恢复相当于淬羽赫默攻击力5%的生命,【莱茵生命】干员的恢复效果翻倍": while an +// ally of her range is below hp_ratio of its max HP it gains 生命回复速度 (an hpRegen buff, checklist 11 — the template +// atk_to_hp_recovery, see above: 禁疗 does not stop it, no 治疗加成) of atk_to_hp_recovery_ratio × her ATK, from 1 s after it +// dropped below (waitFirstTriggerInterval) and re-read every 1 s; a 【莱茵生命】 operator (character_table groupId "rhine") +// gets it twice (the client's [normal] + [rhine] buffs). One buff per 淬羽赫默 (independentCharacterSource). +// BLS-X stage 2+ (hidden max_hp; slent2_e_002[max_hp]) "攻击范围内友军的最大生命值+10%": allies of her range max HP +// +max_hp (MAX_HP MULTIPLIER = Σpct), ×2 for a 【莱茵生命】 operator; and 7 % ATK per second (the talent change's ratio). +// BLS-Y stage 2+ (hidden hp_ratio / scale; slent2_e_003_t2) "其中每个干员低于50%生命时仅一次立刻获得淬羽赫默生命上限50%的屏障": +// an operator of her range gets a 屏障 (absorbs every type) of scale × her max HP (×2 for 【莱茵生命】, the client's +// multi 2) once per deployment of hers (the card mark, cleared when she leaves): when it enters her range below hp_ratio, or +// on a damage instance that would leave it below hp_ratio of its max HP ("在每次“受到伤害前”进行预判"): the barrier takes +// that very hit (an `hpDamage` handler run first, after the other shields — PRTS: "能以更早的时间点触发…一定程度抵挡可能致命 +// 的伤害"). Summons get no barrier ("其中每个干员") [ASSUMED: the text; the client data shows one validator]. +// - S1 进取之心 (MANUAL, DEFAULT, 25 s): ATK +atk; heals (trait). +// - S2 俯瞰视界 (MANUAL, DEFAULT, 12 s): ASPD +attack_speed; heals; "技能期间可以使用一个辅助无人机…技能结束或淬羽赫默离场时 +// 销毁" (slent2_s_2: WithdrawTokens at its end, the token count recharged): her placed 夜灯 piece takes the field on its tile +// when the skill starts (content/tokens.js releaseSkillSummon; like every placed skill summon it also deploys once, free, at +// the battle start — PRTS 卫戍协议/帮助) and is withdrawn when the skill ends (into its 5 s redeploy time, PRTS 备注) or when +// she leaves the field; a piece already up when the skill starts serves as its drone [ASSUMED: the start deploy lasts until +// the first end]. 夜灯 (token kit below): untargetable ("不会受到攻击", data), no attack (ATK 0), blocks nothing; its passive +// "使周围八格友军享受2.1倍的淬羽赫默第一天赋效果": the allies of its x-4 (its own tile and the 8 around) hold her T1 庇护 × +// damage_resistance_scale (its skill blackboard: 2.1 / 2.4) — the same shared 庇护, so the stronger value holds. +// - S3 无畏者协议 (MANUAL, DEFAULT, 60 s, skill_max_trigger_time 2 uses per battle — the 3rd never comes: no SP after the +// 2nd; "可主动关闭" is a manual stop the 技能策略 never makes, so it runs its full time): ATK +atk, T1 ×talent_scale, heals; +// once per cast the first operator of her range taking lethal damage (not its own — CheckEntitySuicide; not while it already +// holds 不死 — "_dontConsumeWhenUndeadable") keeps ≥ 1 HP and holds 不死 for grave_duration s (slent2_shallow_grave, buff +// flag `undying`; it outlasts her leaving). Order of the `fatal` step (items/battle.js): after the kits' own savers, a +// 傀儡师's switch and 坚固维式重锤's lock (PRIO_REVIVE −100), before the 复活 (PRIO_RESPAWN −101); the window it opens is +// held like the hammer's (PRIO_UNDYING_HELD −99). +// Statuses: none applied to enemies. Damage: arts normal attacks only. + +import { num, up, traitBb, moduleBb, talentBb, skillRec, once } from '../shared/tier1.js'; +import { releaseSkillSummon } from '../../tokens.js'; +import { holdsUndying, PRIO_UNDYING_HELD, PRIO_REVIVE, PRIO_RESPAWN } from '../../items/battle.js'; +import { absoluteRangeKeys } from '../../../targeting.js'; +import { COLS } from '../../../constants.js'; + +const S1 = 'skchr_slent2_1'; +const S2 = 'skchr_slent2_2'; +const S3 = 'skchr_slent2_3'; +export const LAMP = 'token_10029_slent2_protrb'; +/** 庇护 (ba.protect, the client's damage_resistance[inf]): one "同名效果取最高" effect per unit (battle.applyStrongest). */ +export const PROTECT = 'protect'; +const protectMods = (v) => ({ physTakenMul: 1 - v, artsTakenMul: 1 - v }); +/** The client's trigger period of slent2_t_1 / slent2_t_2 (talent abilities, triggerInterval 0.1). */ +export const TALENT_IV = 0.1; +/** Lifetime of a refreshed effect: a little longer than the refresh period, so it never lapses while it holds. */ +const HOLD = TALENT_IV + 0.05; +/** atk_to_hp_recovery: its trigger interval (1 s, the first after one interval). */ +const REGEN_IV = 1; +/** BLS-X stage 2+ max HP (slent2_e_002[max_hp]): one "同名" effect per unit. */ +export const MAXHP_KEY = 'slent2:maxhp'; +/** S3's 不死 (slent2_shallow_grave). */ +export const GRAVE_KEY = 'slent2:grave'; +/** S3's place in the `fatal` step: after the items' 不死 lock (−100), before their 复活 (−101) — see the header. */ +const PRIO_GRAVE = (PRIO_REVIVE + PRIO_RESPAWN) / 2; +/** 夜灯's area when its record carries no grid: its tile and the 8 around (x-4). */ +const X4 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]); +/** 【莱茵生命】 (character_table groupId "rhine": every member, so a 自选 pick counts too). */ +export const RHINE = Object.freeze(new Set([ + 'char_128_plosis', 'char_135_halo', 'char_242_otter', 'char_108_silent', 'char_249_mlyss', 'char_134_ifrit', + 'char_1047_halo2', 'char_248_mgllan', 'char_1031_slent2', 'char_4212_nasti', 'char_4048_doroth', 'char_202_demkni', +])); + +const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; +const tileKey = (r, c) => r * COLS + c; +/** An operator of the 莱茵生命 group (summons are no 干员). */ +export const isRhine = (a) => !!a && a.kind === 'op' && RHINE.has(a.def?.charId ?? a.defId); +const inKeys = (keys, a) => !!keys && keys.has(tileKey(a.tileR, a.tileC)); + +/** + * 无声砥柱's 庇护 for an ally at its HP: base × scale × (1 + rs × n), n = its lost HP in whole `step` steps (at most + * 1 − min_hp_ratio) — the client's HpRatioTrigger count, PRTS 备注 "以1%为最小单位". + */ +export function shelterOf(t, ally, scale = 1) { + const base = num(t.damage_resistance_base) * scale; + if (!(base > 0)) return 0; + const step = num(t.hp_ratio, 0.01) || 0.01; + const lost = Math.min(Math.max(0, 1 - ally.hpRatio), Math.max(0, 1 - num(t.min_hp_ratio, 0.3))); + const n = Math.floor(lost / step + 1e-9); + return Math.min(0.95, base * (1 + num(t.resistance_scale) * n)); +} + +/** The allies (operators and summons; never a 孤立 one) on absolute tile keys `keys`. */ +function alliesOn(battle, by, keys) { + return battle.allyUnits.filter((a) => a.alive && a.deployed && !a.hidden && a.kind !== 'device' && inKeys(keys, a) && battle.allySelectable(a, by)); +} + +/** + * 夜灯's kit: untargetable ("不会受到攻击": the def's flag, Battle._setupUnit), no attack (ATK 0 ⇒ resolveProfile noAttack), + * no skill to cast; while it stands, every TALENT_IV s the allies of its x-4 hold her T1 庇护 × damage_resistance_scale. + * `owner` = 淬羽赫默, `t1` = her T1 blackboard (fallback when the token def carries none). + */ +function lampKit(t, owner, t1) { + const def = t.def; + const tal = (def?.talents ?? []).find((x) => x && x.bb && x.bb.damage_resistance_base != null) ?? null; + const tb = tal?.bb ?? t1; + const grid = tal?.rangeGrid ?? def?.rangeGrid ?? X4; + const scale = num(def?.skill?.bb?.damage_resistance_scale, num(owner.skill?.bb?.damage_resistance_scale, 1)); + return { + skill: null, + trait: { noAttack: true }, + install(battle, lamp) { + battle.every(TALENT_IV, () => { + if (!up(lamp)) return; + const keys = new Set(absoluteRangeKeys(grid, lamp.tileR, lamp.tileC, lamp.dir, 0)); + for (const a of alliesOn(battle, lamp, keys)) { + const v = shelterOf(tb, a, scale); + if (v > 0) battle.applyStrongest(a, PROTECT, { duration: HOLD, value: v, mods: protectMods, source: lamp }); + } + }, { owner: lamp, immediate: true }); + }, + }; +} + +export default { + char_1031_slent2: (bb, chess) => { + const tb = traitBb(chess); + const t1 = talentBb(chess, 0), t2 = talentBb(chess, 1), hidden = moduleBb(chess); + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + const targets = Math.max(1, Math.floor(num(tb['attack@max_target'], 1))); + const healAttack = Object.freeze({ + dmgType: 'heal', heal: targets > 1 ? { mode: 'multi', count: targets } : { mode: 'single' }, + healScale: num(tb.heal_scale, 0.75), projectile: 'none', + }); + const regenRatio = num(t2.atk_to_hp_recovery_ratio), regenBelow = num(t2.hp_ratio, 0.5); + const maxHp = num(hidden.max_hp); // BLS-X stage 2+ + const barrierScale = num(hidden.scale), barrierBelow = num(hidden.hp_ratio, 0.5); // BLS-Y stage 2+ + const graveDur = num(b3.grave_duration), s3Uses = Math.max(1, Math.floor(num(b3.skill_max_trigger_time, 2))); + const talentScale = num(b3.talent_scale, 1); + return { + trait: targets > 1 ? { maxTargets: targets } : null, + skills: { + [S1]: { kind: 'duration', mods: { atkPct: num(b1.atk) }, attack: healAttack }, + [S2]: { + kind: 'duration', mods: { aspd: num(b2.attack_speed) }, attack: healAttack, + onStart({ battle, unit }) { + // a 夜灯 already up (the battle-start deploy) is this skill's drone; else her placed piece takes the field + if (!battle.allyUnits.some((t) => t.kind === 'token' && t.defId === LAMP && t.ownerUnit === unit && t.alive)) releaseSkillSummon(battle, unit, LAMP); + }, + onEnd({ battle, unit }) { withdrawLamps(battle, unit); }, + }, + [S3]: { + kind: 'duration', mods: { atkPct: num(b3.atk) }, attack: healAttack, + onStart({ unit }) { unit.mem.slent2Grave = true; }, // the one 不死 of this cast (slent2_shallow_grave[self]) + onEnd({ unit }) { unit.mem.slent2Grave = false; }, + }, + }, + talents: [ + { install(battle, unit) { // 无声砥柱 (+ S3 ×talent_scale) and 丰润羽翼 (+ BLS-X max HP, BLS-Y barrier on entering) + const regenKey = `talent:slent2:regen#${unit.id}`; + const regen = new Map(); // ally → { next: battle time of its next atk_to_hp_recovery trigger } + let prevIn = new Set(); // the allies of her range at the previous scan (slent2_t_2 started on them) + const stopRegen = (a) => { regen.delete(a); if (a.findBuff(regenKey)) battle.removeBuff(a, regenKey); }; + battle.on('deploy', (c) => { if (c.unit === unit) { unit.mem.slent2Marks = new Set(); prevIn = new Set(); } }, { owner: unit }); + battle.every(TALENT_IV, () => { + if (!up(unit)) { + for (const a of [...regen.keys()]) stopRegen(a); + prevIn = new Set(); + return; + } + const scale = unit.skill?.id === S3 && unit.skill.active ? talentScale : 1; + const allies = alliesOn(battle, unit, unit.rangeKeySet); + const now = new Set(allies); + for (const a of allies) { + const v = shelterOf(t1, a, scale); + if (v > 0) battle.applyStrongest(a, PROTECT, { duration: HOLD, value: v, mods: protectMods, source: unit }); + const mult = isRhine(a) ? 2 : 1; + if (maxHp > 0) battle.applyStrongest(a, MAXHP_KEY, { duration: HOLD, value: maxHp * mult, mods: (x) => ({ hpPct: x }), source: unit }); + if (regenRatio > 0 && a.hpRatio < regenBelow - 1e-9) { + const st = regen.get(a) ?? { next: battle.time + REGEN_IV }; + regen.set(a, st); + if (battle.time + 1e-9 >= st.next) { + st.next += REGEN_IV; + battle.addBuff(a, { key: regenKey, mods: { hpRegen: unit.s.atk * regenRatio * mult }, source: unit, tags: ['talent'] }); + } + } else if (regen.has(a)) stopRegen(a); + if (barrierScale > 0 && !prevIn.has(a) && a.hpRatio < barrierBelow - 1e-9) giveBarrier(battle, unit, a, null); + } + for (const a of [...regen.keys()]) if (!now.has(a) || !a.alive) stopRegen(a); + prevIn = now; + }, { owner: unit, immediate: true }); + // BLS-Y: a damage instance that would leave an operator of her range below barrierBelow — the barrier takes it + if (barrierScale > 0) { + battle.on('hpDamage', (c) => { + const a = c.target; + if (!up(unit) || !a || a.side !== 'ally' || !(c.amount > 0) || !inKeys(unit.rangeKeySet, a)) return; + if (a.hp - c.amount < a.s.maxHp * barrierBelow - 1e-9) giveBarrier(battle, unit, a, c); + }, { owner: unit, priority: 100 }); + } + /** BLS-Y barrier on operator `a` (once per deployment of hers); `c` = the hpDamage ctx it takes, if any. */ + function giveBarrier(b, u, a, c) { + const marks = u.mem.slent2Marks ?? (u.mem.slent2Marks = new Set()); + if (a.kind !== 'op' || marks.has(a.id)) return; + marks.add(a.id); + let cap = u.s.maxHp * barrierScale * (isRhine(a) ? 2 : 1); + if (c) { const took = Math.min(cap, c.amount); c.amount -= took; cap -= took; } + if (cap > 1e-6) b.addBuff(a, { key: `slent2:barrier#${u.id}`, shield: cap, source: u, visible: true, tags: ['talent'] }); + b.fx('shield', { x: a.x, y: a.y, id: a.id }); + } + } }, + { install(battle, unit) { // 夜灯: her placed pieces run the drone's kit; withdrawn when she leaves the field + for (const t of battle.allyUnits) { + if (t.kind !== 'token' || t.defId !== LAMP || t.ownerUnit !== unit || t.alive || t.deployed) continue; + // (no offOwner for a piece set up before her: its hooks are content/tokens.js's dock hooks — the generic token + // kit registers none — and they must stay) + battle._setupUnit(t, lampKit(t, unit, t1)); + } + battle.on('death', (c) => { if (c.unit === unit) withdrawLamps(battle, unit); }, { owner: unit }); + } }, + ], + install(battle, unit) { + if (unit.skill?.id !== S3) return; + // 同一次作战中最多使用2次: after the last use no SP comes back (activations count every deployment of the battle) + battle.on('spGain', (c) => { + if (c.unit === unit && !unit.skill.active && unit.skill.activations >= s3Uses) c.amount = 0; + }, { owner: unit }); + // the one 不死 of each cast: the first operator of her range taking lethal damage (see the header) + battle.on('fatal', (c) => { + const a = c.unit; + if (c.prevented || !unit.mem.slent2Grave || !unit.skill?.active || !up(unit)) return; + if (!a || a.side !== 'ally' || a.kind !== 'op' || !inKeys(unit.rangeKeySet, a)) return; + if (c.source === a || c.credit === a || holdsUndying(battle, a)) return; + c.prevented = true; + unit.mem.slent2Grave = false; + battle.addBuff(a, { key: GRAVE_KEY, duration: graveDur, flags: { undying: true }, source: unit, visible: true, tags: ['skill'] }); + battle.fx('undying', { x: a.x, y: a.y, id: a.id, duration: graveDur }); + }, { owner: unit, priority: PRIO_GRAVE }); + // the 不死 window, held whoever opened it (one battle-wide hook, like 坚固维式重锤's running windows) + once(battle, 'slent2:grave', () => { + battle.on('fatal', (c) => { if (!c.prevented && c.unit && c.unit.findBuff(GRAVE_KEY)) c.prevented = true; }, { priority: PRIO_UNDYING_HELD }); + }); + }, + }; + }, +}; + +/** 俯瞰视界's end or her leaving: her 夜灯 leave the field (into their redeploy time) and the drone in stock is gone. */ +function withdrawLamps(battle, unit) { + const stock = unit.mem.summonStock; + if (stock) stock[LAMP] = 0; + for (const t of battle.allyUnits) { + if (t.kind === 'token' && t.defId === LAMP && t.ownerUnit === unit && t.alive) battle.retreat(t, { reason: 'expired', permanent: true }); + } +} diff --git a/test/content/op_haruka.test.js b/test/content/op_haruka.test.js new file mode 100644 index 00000000..20297af8 --- /dev/null +++ b/test/content/op_haruka.test.js @@ -0,0 +1,345 @@ +// test/content/op_haruka.test.js — the 自选 operator kit of 遥 (char_4202_haruka, 6★ 护佑者; kit +// server/sim/content/kits/ops/op-haruka.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 or BLS-Y +// “迟来的纪念” at stage 1 (tier 5) / 3 (tier 6). Numbers from data/backups.json; the fidelity checklist of kits/README.md. +// Run: node --test test/content/op_haruka.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { makeBattle, enemyRec, chessRec, 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 ID = 'char_4202_haruka'; +const FORMS = BACKUPS.units[ID].forms; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const Y = 'uniequip_002_haruka'; +const S1 = 'skchr_haruka_1', S2 = 'skchr_haruka_2', S3 = 'skchr_haruka_3'; +const BUBBLE = 'haruka:bubble'; +const statusOf = (tier, elite) => (elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'); +const formOf = (tier, elite) => FORMS[statusOf(tier, elite)]; +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_fly: dummy('enemy_fly', { motion: 'FLY' }), enemy_heavy: dummy('enemy_heavy', { mass: 4 }), + enemy_noflt: dummy('enemy_noflt', { immunities: { levitate: true } }), +}; +const PLAIN_REC = chessRec({ id: 'test_plain_a', charId: 'char_test_plain', profession: 'CASTER', skill: null, stats: { maxHp: 2000, def: 0, res: 0 } }); +const FORMS_ALL = [[5, false, null], [6, false, null], [5, true, null], [5, true, Y], [6, true, null], [6, true, Y]]; +const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`; + +/** 遥 as uid 1 at (10, 5) facing RIGHT, plus `others`. */ +function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], seed = 5 } = {}) { + const h = makeBattle({ + defs: { enemies: ENEMIES, chess: { test_plain_a: PLAIN_REC } }, timeLimit: 900, autoFinish: false, seed, + flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'heal', 'statusApplied', 'attack'], captureNoisy: true, + units: [{ uid: 1, diy: { slot: SLOT[tier], charId: ID, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others], + }); + h.step(); + return { h, u: h.unit(1) }; +} +function done(h) { + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); +} +const ally = (uid, row, col) => ({ uid, chessId: 'test_plain_a', row, col }); +/** Clear every bubble and make her next attack (or bubble) come at once — the battle start already gave one. */ +function fresh(h, u) { + for (const x of h.b.allyUnits) h.b.removeBuff(x, BUBBLE); + u.atkCd = 0; +} +const bubbled = (h) => h.b.allyUnits.filter((a) => a.findBuff(BUBBLE)).map((a) => a.uid).sort(); +const heals = (h, u, from = 0) => h.hooksOf('heal').slice(from).filter((x) => x.source === u && !x.opts?.regen); + +test('遥 in every 自选 form: her kit (all three skills authored), the form\'s stats + module attributes, y-6, 护佑者 (ranged arts, hits air, blocks 1; BLS-Y 2 targets), S1 / S2 DEFAULT, S3 ACTIVE_RANGE on its y-8, ground-targetable, 空构 bond, no 特质', () => { + assert.equal(OPERATOR_KITS[ID], KITS[ID]); + 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], [ID, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f)); + assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.res], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def, form.stats.res], `${label(f)}: stats`); + assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.base.bat], [1, 'ranged', 'arts', true, 1.6], `${label(f)}: 护佑者`); + assert.equal(u.profile.maxTargets, mod === Y ? 2 : 1, `${label(f)}: targets`); + assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: y-6`); + assert.equal(u.skill.rule, skill === 2 ? 'ACTIVE_RANGE' : 'DEFAULT', `${label(f)}: trigger`); + if (skill === 2) assert.deepEqual(u.skill.triggerGrid, form.skills[2].rangeGrid, `${label(f)}: the y-8 trigger grid`); + assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['emptyShip'], []], `${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); + } + } + 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], [1415, 398, 1728, 452]); + assert.deepEqual([modOf(5, Y).attr, modOf(6, Y).attr], [{ maxHp: 130, atk: 30 }, { maxHp: 210, atk: 52 }]); +}); + +test('a 自选 pick: 遥 is offered at tiers 5 and 6 and a roster with her passes validateDiyPicks', () => { + const data = { chess: CHESS, backups: BACKUPS }; + assert.ok(KITTED_CHARS.includes(ID)); + for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(ID), `tier ${t}`); + assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: ID, skillIndex: 1, uniEquipId: Y } }, { data, kitted: KITTED_CHARS }), + { ok: true, picks: { [SLOT[5]]: { charId: ID, skillIndex: 1, uniEquipId: Y } } }); +}); + +test('Trait: arts attacks (BLS-Y: two targets); while a skill runs every attack heals the most injured ally (BLS-Y: two) for 75 % of her ATK', () => { + for (const [tier, elite, mod] of [[5, false, null], [6, true, Y]]) { + const { h, u } = field({ tier, elite, mod, skill: 0, others: [ally(2, 11, 6), ally(3, 9, 6)] }); + h.spawn('enemy_dummy', { pos: [10, 7] }); h.spawn('enemy_fly', { pos: [11, 7] }); + h.run(3); + const hits = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack); + assert.ok(hits.length && hits.every((c) => c.type === 'arts')); + assert.equal(new Set(hits.map((c) => c.target)).size, mod === Y ? 2 : 1, `${label([tier, elite, mod])}: targets`); + const [a, b] = [h.unit(2), h.unit(3)]; + a.hp = 600; b.hp = 900; + const n0 = h.hooksOf('heal').length; + u.skill.gainSp(999); + assert.ok(h.runUntil(() => heals(h, u, n0).length > 0, 4)); + const first = heals(h, u, n0).filter((x, i, l) => Math.abs(x.t - l[0].t) < 1e-9); + assert.deepEqual(first.map((x) => x.target.uid).sort(), mod === Y ? [2, 3] : [2]); + for (const x of first) approx(x.amount, u.s.atk * 0.75, '75 % ATK'); + done(h); + } +}); + +test('T1 浮光泡影: with each attack a bubble goes to the ally of her range with the lowest HP share that holds none (herself too); with nothing to attack the attack becomes the bubble, one per attack interval; 30 % 庇护 on physical and arts, not true; out of range nobody', () => { + const { h, u } = field({ others: [ally(2, 11, 6), ally(3, 9, 6), ally(4, 10, 9)] }); + const [a, b, out] = [h.unit(2), h.unit(3), h.unit(4)]; + a.hp = 1000; b.hp = 1200; out.hp = 100; + fresh(h, u); + // no enemy: ShieldOnly at once (a, 50 %), then one interval later b (60 %), then herself (full) + h.run(0.05); + assert.deepEqual(bubbled(h), [2]); + h.run(1.6); + assert.deepEqual(bubbled(h), [2, 3]); + h.run(1.6); + assert.deepEqual(bubbled(h), [1, 2, 3]); + assert.equal(h.hooksOf('attack').filter((c) => c.attacker === u).length, 0, 'no attack'); + h.run(3.2); + assert.deepEqual(bubbled(h), [1, 2, 3], 'nobody left in range (the one at (10, 9) is outside)'); + const bub = a.findBuff(BUBBLE); + approx(bub.data.v, 0.3, '30 %', 1e-9); + const e = h.spawn('enemy_dummy', { pos: [11, 9] }); + for (const [type, want] of [['phys', 70], ['arts', 70], ['true', 100]]) { + const hp0 = b.hp; + // a 无来源 hit: the bubble takes no notice + h.b.dealDamage(e, b, { amount: 100, type, canDodge: false, sourceless: true }); + approx(hp0 - b.hp, want, `${type}`); + b.hp = 1200; + } + assert.ok(b.findBuff(BUBBLE), '无来源 damage never breaks a bubble'); + // with a target: a bubble after each attack + for (const x of [a, b, u]) h.b.removeBuff(x, BUBBLE); + h.spawn('enemy_dummy', { pos: [10, 7] }); + const n0 = h.hooksOf('attack').length; + assert.ok(h.runUntil(() => h.hooksOf('attack').slice(n0).some((c) => c.attacker === u), 2)); + h.step(); + assert.deepEqual(bubbled(h), [2], 'the attack and its bubble'); + done(h); +}); + +test('T1 / T2: an enemy hit that lowers the holder\'s HP breaks its bubble 0.5 s later (once; a fully shielded hit does not count) and heals it 25 % of her ATK as it was when she gave it; BLS-Y stage 3: 20 % to give the holder 1 SP; stage 1: never', () => { + for (const [tier, elite, mod] of [[5, false, null], [6, true, Y], [5, true, Y]]) { + const { h, u } = field({ tier, elite, mod, others: [ally(2, 11, 6)] }); + const a = h.unit(2); + a.hp = 1000; + fresh(h, u); + h.run(0.05); + const b = a.findBuff(BUBBLE); + assert.ok(b, 'bubbled'); + const atk0 = u.s.atk; + h.b.addBuff(u, { key: 'test:atk', mods: { atkPct: 1 } }); // her ATK doubles after the grant + const e = h.spawn('enemy_dummy', { pos: [11, 9] }); + h.b.addBuff(a, { key: 'test:shield', shield: 500 }); + h.b.dealDamage(e, a, { amount: 300, type: 'true' }); + h.run(0.6); + assert.equal(a.findBuff(BUBBLE), b, 'a hit the shield took whole: no break'); + h.b.removeBuff(a, 'test:shield'); + const n0 = h.hooksOf('heal').length; + h.b.dealDamage(e, a, { amount: 100, type: 'true' }); + h.run(0.25); + h.b.dealDamage(e, a, { amount: 100, type: 'true' }); + h.run(0.2); + assert.equal(a.findBuff(BUBBLE), b, 'still there at 0.45 s'); + h.run(0.1); + assert.notEqual(a.findBuff(BUBBLE), b, 'broken 0.5 s after the first hit'); + const hs = heals(h, u, n0).filter((x) => x.target === a); + assert.equal(hs.length, 1, 'one break, one heal'); + approx(hs[0].amount, atk0 * 0.25, '25 % of the ATK of the grant'); + void u; + done(h); + } + // BLS-Y stage 3: the dice + for (const [tier, want] of [[6, true], [5, false]]) { + const { h, u } = field({ tier, elite: true, mod: Y, others: [{ uid: 2, chessId: 'chess_char_1_08_a', row: 11, col: 6 }] }); + const tex = h.unit(2); + const e = h.spawn('enemy_dummy', { pos: [11, 9] }); + let gained = 0, breaks = 0; + h.b.on('spGain', (c) => { if (c.unit === tex && c.reason === 'talent') gained += c.amount; }); + for (let i = 0; i < 300; i++) { + tex.skill.sp = 0; tex.skill.charges = 0; + if (!tex.findBuff(BUBBLE)) h.run(0.05); + if (!tex.findBuff(BUBBLE)) { h.run(1.7); continue; } + tex.hp = tex.s.maxHp * 0.5; + h.b.dealDamage(e, tex, { amount: 1, type: 'true' }); + h.run(0.55); + breaks++; + } + if (want) assert.ok(gained / breaks > 0.12 && gained / breaks < 0.28, `≈ 20 %: ${gained} / ${breaks}`); + else assert.equal(gained, 0, 'stage 1: no SP'); + void u; + done(h); + } +}); + +test('S1 夜啼彩羽 (DEFAULT): ASPD +30 / +45 for 19 / 20 s, heals; a bubble breaking meanwhile at once gives a new one to the ally of the 8 around its holder that has none, the lowest HP share first', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S1); + const { h, u } = field({ tier, elite, skill: 0, others: [ally(2, 11, 6), ally(3, 12, 6), ally(4, 12, 7), ally(5, 9, 9)] }); + assert.deepEqual([sk.duration, sk.bb.attack_speed, u.skill.spCost, sk.initSp], [elite ? 20 : 19, elite ? 45 : 30, 25, elite ? 10 : 7]); + const [a, b, c] = [h.unit(2), h.unit(3), h.unit(4)]; + h.spawn('enemy_dummy', { pos: [10, 7] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 3)); + approx(u.skill.timeLeft, sk.duration, 'duration', 0.05); + approx(u.s.aspd, 100 + sk.bb.attack_speed, 'ASPD'); + for (const x of h.b.allyUnits) h.b.removeBuff(x, BUBBLE); + h.b.addBuff(a, { key: BUBBLE, source: u, data: { v: 0.3, atk: u.s.atk, heal: 0.25, prob: 0, sp: 0, boom: false } }); + b.hp = 1500; c.hp = 1200; // both around a; c has the lower share + const e = h.spawn('enemy_dummy', { pos: [11, 9] }); + h.b.dealDamage(e, a, { amount: 10, type: 'true' }); + h.run(0.52); + assert.ok(!a.findBuff(BUBBLE) && c.findBuff(BUBBLE), 'the new bubble on the lowest share of the 8 around'); + assert.equal(c.findBuff(BUBBLE).source, u); + h.runUntil(() => !u.skill.active, 25); + approx(u.s.aspd, 100, 'back'); + done(h); + } +}); + +test('S2 幽隙栖萤: every heal of hers meanwhile deals heal × 140 % / 200 % arts damage to the 2 / 3 nearest enemies within 1.7 of the healed unit (air units too); rank 7: one more heal target and one more bubble; the first use lasts 22 / 23 s with no ATK bonus, the next ones ATK +20 % / +25 % and endless; the count restarts with a deployment', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S2); + assert.deepEqual([sk.duration, sk.bb.atk_scale_extra, sk.bb.max_target_extra, sk.bb['attack@max_target_heal_add'], sk.bb.max_target_shield_add, sk.bb.atk, sk.bb.ability_range_radius], + [elite ? 23 : 22, elite ? 2 : 1.4, elite ? 3 : 2, elite ? 1 : 0, elite ? 1 : 0, elite ? 0.25 : 0.2, 1.7]); + const { h, u } = field({ tier, elite, skill: 1, others: [ally(2, 11, 6), ally(3, 9, 6), ally(4, 10, 4)] }); + const [a, b] = [h.unit(2), h.unit(3)]; + h.spawn('enemy_dummy', { pos: [9, 7] }); // her target, √5 from a + const near = [h.spawn('enemy_dummy', { pos: [12, 6] }), h.spawn('enemy_fly', { pos: [11, 7] }), h.spawn('enemy_dummy', { pos: [12, 7] })]; + h.spawn('enemy_dummy', { pos: [13, 9] }); // too far from (11, 6) + h.run(0.1); + for (const x of h.b.allyUnits) h.b.removeBuff(x, BUBBLE); + a.hp = 400; b.hp = 1000; + const n0 = h.hooksOf('heal').length; + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 3)); + approx(u.s.atk, u.base.atk, 'first use: no ATK bonus'); + assert.ok(h.runUntil(() => heals(h, u, n0).length > 0, 3)); + const t0 = heals(h, u, n0)[0].t; + const first = heals(h, u, n0).filter((x) => Math.abs(x.t - t0) < 1e-9); + assert.deepEqual(first.map((x) => x.target.uid).sort(), elite ? [2, 3] : [2], 'rank 7: two heal targets'); + const ha = first.find((x) => x.target === a); + const dmg = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.tags?.includes('haruka:s2') && Math.abs(c.t - t0) < 1e-9); + // around a (11, 6): the three near enemies; (12, 7) is √2 ≈ 1.41 away — the 2 nearest at rank 4 are (12, 6) and (11, 7) + const onA = dmg.filter((c) => near.includes(c.target)); + assert.equal(new Set(onA.map((c) => c.target)).size, elite ? 3 : 2, 'max_target_extra'); + for (const c of onA) approx(c.amount, ha.amount * sk.bb.atk_scale_extra, `heal × ${sk.bb.atk_scale_extra}`); + assert.ok(onA.some((c) => c.target === near[1]), 'the flyer too'); + assert.ok(onA.every((c) => c.type === 'arts')); + // rank 7: two bubbles per attack + h.step(); + assert.ok(bubbled(h).length >= (elite ? 2 : 1), 'bubbles per attack'); + h.runUntil(() => !u.skill.active, 30); + approx(h.hooksOf('skillEnd').filter((c) => c.unit === u).at(-1).t - h.hooksOf('skillStart').filter((c) => c.unit === u).at(-1).t, sk.duration, 'first use: its duration', 0.05); + // second use: ATK bonus, endless + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 4)); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), 'second use: ATK'); + h.run(sk.duration * 3); + assert.ok(u.skill.active, 'endless'); + // a new deployment: the first use again + h.b.retreat(u); + h.b.redeploy(u); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 4)); + approx(u.s.atk, u.base.atk, 'first use of the new deployment'); + done(h); + } +}); + +test('S3 夏末游鳞 (ACTIVE_RANGE): cast with an enemy on its y-8 outside her y-6; range y-8 while on, back after; ATK +25 % / +40 %; interval 1.6 − 0.4 / 0.6 s; heals; her bubbles ×1.4 / ×1.7 (also those given before); an enemy setting one off is 浮空 3 / 4 s (half on 重量 > 3, none when immune) and takes 60 % / 70 % ATK arts each second of it (from 0.5 s)', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S3); + const form = formOf(tier, elite); + assert.deepEqual([sk.duration, sk.bb.atk, sk.bb.base_attack_time, sk.bb.damage_resistance_scale, sk.bb.levitate_duration, sk.bb.atk_scale, sk.bb.interval], + [40, elite ? 0.4 : 0.25, elite ? -0.6 : -0.4, elite ? 1.7 : 1.4, elite ? 4 : 3, elite ? 0.7 : 0.6, 1]); + const { h, u } = field({ tier, elite, skill: 2, others: [ally(2, 11, 6), ally(3, 9, 6)] }); + const a = h.unit(2); + h.run(0.1); + assert.ok(a.findBuff(BUBBLE) || h.unit(3).findBuff(BUBBLE) || u.findBuff(BUBBLE), 'bubbles before'); + const holder = [a, h.unit(3), u].find((x) => x.findBuff(BUBBLE)); + u.skill.gainSp(999); + h.run(2); + assert.equal(u.skill.activations, 0, 'no enemy: no cast'); + h.spawn('enemy_dummy', { pos: [10, 8] }); // y-8 (+3), not y-6 + assert.ok(h.runUntil(() => u.skill.active, 3), 'cast on the y-8'); + assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, 'y-8 while on'); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), 'ATK'); + approx(u.s.interval, 1.6 + sk.bb.base_attack_time, 'interval'); + approx(holder.findBuff(BUBBLE).data.v, 0.3 * sk.bb.damage_resistance_scale, 'a bubble given before: rescaled', 1e-9); + // levitation: a hit on a bubbled ally + for (const x of h.b.allyUnits) h.b.removeBuff(x, BUBBLE); + h.b.addBuff(a, { key: BUBBLE, source: u, data: { v: 0.42, atk: u.s.atk, heal: 0, prob: 0, sp: 0, boom: false } }); + const e = h.spawn('enemy_dummy', { pos: [12, 9] }); + h.b.dealDamage(e, a, { amount: 50, type: 'true' }); + const lev = h.hooksOf('statusApplied').filter((c) => c.target === e && c.status === 'levitate'); + assert.equal(lev.length, 1); + approx(lev[0].duration, sk.bb.levitate_duration, 'levitate'); + assert.ok(e.isFlying, 'an air unit meanwhile'); + const n0 = h.hooksOf('damaged').length; + h.run(sk.bb.levitate_duration + 0.2); + const ticks = h.hooksOf('damaged').slice(n0).filter((c) => c.target === e && c.dmg?.tags?.includes('haruka:float')); + assert.equal(ticks.length, sk.bb.levitate_duration, 'one tick a second: 0.5, 1.5 …'); + approx(ticks[0].t - lev[0].t, 0.5, 'first tick at 0.5 s', 0.05); + for (const c of ticks) { approx(c.amount, u.s.atk * sk.bb.atk_scale, `${sk.bb.atk_scale * 100} % ATK`); assert.equal(c.type, 'arts'); } + // heavy: half; immune: none + const heavy = h.spawn('enemy_heavy', { pos: [12, 10] }), immune = h.spawn('enemy_noflt', { pos: [12, 11] }); + h.b.addBuff(a, { key: BUBBLE, source: u, data: { v: 0.42, atk: u.s.atk, heal: 0, prob: 0, sp: 0, boom: false } }); + h.b.dealDamage(heavy, a, { amount: 10, type: 'true' }); + h.b.dealDamage(immune, a, { amount: 10, type: 'true' }); + approx(h.hooksOf('statusApplied').filter((c) => c.target === heavy && c.status === 'levitate')[0].duration, sk.bb.levitate_duration / 2, '重量 > 3: half'); + assert.equal(h.hooksOf('statusApplied').filter((c) => c.target === immune && c.status === 'levitate').length, 0, 'immune'); + h.run(2); + assert.equal(h.hooksOf('damaged').filter((c) => c.target === immune && c.dmg?.tags?.includes('haruka:float')).length, 0, 'no damage without the levitation'); + h.runUntil(() => !u.skill.active, 45); + assert.deepEqual(u.liveRangeGrid, form.rangeGrid, 'back to y-6'); + approx(u.s.interval, 1.6, 'interval back'); + done(h); + } +}); + +test('outside S3 a bubble levitates nobody; a bubble outlasts her leaving and still heals with the ATK of its grant', () => { + const { h, u } = field({ skill: 0, others: [ally(2, 11, 6)] }); + const a = h.unit(2); + a.hp = 1000; + fresh(h, u); + h.run(0.05); + assert.ok(a.findBuff(BUBBLE)); + const atk = u.s.atk; + const e = h.spawn('enemy_dummy', { pos: [11, 9] }); + h.b.retreat(u); + h.step(); + assert.ok(a.findBuff(BUBBLE), 'still there'); + const n0 = h.hooksOf('heal').length; + h.b.dealDamage(e, a, { amount: 10, type: 'true' }); + assert.equal(h.hooksOf('statusApplied').filter((c) => c.status === 'levitate').length, 0); + h.run(0.6); + approx(heals(h, u, n0)[0].amount, atk * 0.25, 'her heal after she left'); + done(h); +}); diff --git a/test/content/op_phatm2.test.js b/test/content/op_phatm2.test.js new file mode 100644 index 00000000..0e41cd5e --- /dev/null +++ b/test/content/op_phatm2.test.js @@ -0,0 +1,430 @@ +// test/content/op_phatm2.test.js — the 自选 operator kit of 酒神 (char_1042_phatm2, 6★ 巫役; kit +// server/sim/content/kits/ops/op-phatm2.js) and of her summons 本能的召唤 (token_10054_phatm2_encdool) and 迷狂牢笼 +// (token_10055_phatm2_mndclv), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy; 本能的召唤 as the +// placed hand piece of her player) in every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, +// rank 7) with no module or RIT-X “酒神之心” at stage 1 (tier 5) / 3 (tier 6). Numbers from data/backups.json; the +// fidelity checklist of kits/README.md. +// Run: node --test test/content/op_phatm2.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 ID = 'char_1042_phatm2'; +const CALL = 'token_10054_phatm2_encdool', CAGE = 'token_10055_phatm2_mndclv'; +const FORMS = BACKUPS.units[ID].forms; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const X = 'uniequip_002_phatm2'; +const S1 = 'skchr_phatm2_1', S2 = 'skchr_phatm2_2', S3 = 'skchr_phatm2_3'; +const statusOf = (tier, elite) => (elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'); +const formOf = (tier, elite) => FORMS[statusOf(tier, elite)]; +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_fly: dummy('enemy_fly', { motion: 'FLY' }), enemy_elite: dummy('enemy_elite', { rank: 'ELITE' }), + enemy_shooter: dummy('enemy_shooter', { atk: 300, range: 2, bat: 1 }), + enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 1, mass: 0, atk: 50 }), + enemy_walk_elite: enemyRec({ key: 'enemy_walk_elite', hp: 1e9, speed: 1, mass: 0, atk: 50, rank: 'ELITE' }), +}; +const FORMS_ALL = [[5, false, null], [6, false, null], [5, true, null], [5, true, X], [6, true, null], [6, true, X]]; +const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`; + +/** 酒神 as uid 1 at (10, 5) facing RIGHT, plus `others`; `call` = the 本能的召唤 piece's tile (uid 9) or null. */ +function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], call = null, callFirst = false, seed = 5 } = {}) { + const op = { uid: 1, diy: { slot: SLOT[tier], charId: ID, skillIndex: skill, uniEquipId: mod }, elite, row, col }; + const piece = call ? [{ uid: 9, kind: 'token', tokenId: CALL, ownerUid: 1, row: call[0], col: call[1] }] : []; + const h = makeBattle({ + defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, + flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'death', 'deploy', 'attack', 'elementBurst'], captureNoisy: true, + units: callFirst ? [...piece, op, ...others] : [op, ...piece, ...others], + }); + h.step(); + return { h, u: h.unit(1), callUnit: h.b.allyUnits.find((a) => a.kind === 'token' && a.defId === CALL) ?? null }; +} +function done(h) { + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); +} +const elemOf = (h, src, from = 0) => h.hooksOf('damaged').slice(from).filter((c) => c.source === src && c.type === 'element'); +const cagesOf = (h) => h.b.allyUnits.filter((a) => a.kind === 'token' && a.defId === CAGE && a.alive); +/** No attacks of hers (to isolate an effect). */ +const quiet = (h, u) => h.b.addBuff(u, { key: 'test:disarm', flags: { disarm: true } }); + +test('酒神 in every 自选 form: her kit (all three skills authored), the form\'s stats + module attributes, y-2, 巫役 (ranged arts, hits air, blocks 1), S1 AUTO on attack SP (data DEFAULT), S2 at full SP (kit), S3 ACTIVE_RANGE on its y-8, ground-targetable, 维多利亚 bond, no 特质', () => { + assert.equal(OPERATOR_KITS[ID], KITS[ID]); + 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], [ID, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f)); + assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.res], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def, form.stats.res], `${label(f)}: stats`); + assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.base.bat, u.profile.maxTargets], [1, 'ranged', 'arts', true, 1.6, 1], `${label(f)}: 巫役`); + assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: y-2`); + assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.kind], [['DEFAULT', 'attack', 'instant'], ['SP_FULL', 'time', 'toggle'], ['ACTIVE_RANGE', 'time', 'duration']][skill], `${label(f)}: trigger / SP / kind`); + if (skill === 2) assert.deepEqual(u.skill.triggerGrid, form.skills[2].rangeGrid, `${label(f)}: the y-8 trigger grid`); + assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['victoriaShip'], []], `${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); + } + } + 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], [936, 411, 1111, 463]); + assert.deepEqual([modOf(5, X).attr, modOf(6, X).attr], [{ maxHp: 125, atk: 30 }, { maxHp: 200, atk: 42 }]); +}); + +test('a 自选 pick: 酒神 is offered at tiers 5 and 6 and a roster with her passes validateDiyPicks', () => { + const data = { chess: CHESS, backups: BACKUPS }; + assert.ok(KITTED_CHARS.includes(ID)); + for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(ID), `tier ${t}`); + assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: ID, skillIndex: 2, uniEquipId: X } }, { data, kitted: KITTED_CHARS }), + { ok: true, picks: { [SLOT[6]]: { charId: ID, skillIndex: 2, uniEquipId: X } } }); +}); + +test('T1 形为心役: every hit of her attacks first gives its target 30 % ATK 神经损伤 and every other enemy within 1.3 of it (中点, air too) 20 % ATK — then the arts damage; one 1.5 away nothing', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const { h, u } = field({ tier, elite, skill: 2 }); + const t1 = formOf(tier, elite).talents[0].bb; + assert.deepEqual(t1, { 'attack@ep_damage_ratio': 0.3, ep_damage_ratio: 0.2, range_radius: 1.3 }); + const main = h.spawn('enemy_dummy', { pos: [10, 6] }); + const nearFly = h.spawn('enemy_fly', { pos: [11, 6] }); + const far = h.spawn('enemy_dummy', { pos: [11.5, 7] }); // ≈ 1.8 from (10, 6) + const n0 = h.hooksOf('damaged').length; + assert.ok(h.runUntil(() => h.hooksOf('damaged').slice(n0).some((c) => c.source === u && c.dmg?.isAttack && c.target === main), 3)); + const seq = h.hooksOf('damaged').slice(n0).filter((c) => c.source === u); + const iHit = seq.findIndex((c) => c.dmg?.isAttack && c.target === main); + const before = seq.slice(0, iHit); + const mainEl = before.find((c) => c.type === 'element' && c.target === main); + const flyEl = before.find((c) => c.type === 'element' && c.target === nearFly); + assert.ok(mainEl && flyEl, '神经损伤 before the damage'); + assert.equal(mainEl.dmg.element, 'neural'); + approx(mainEl.amount, u.s.atk * 0.3, '30 % ATK'); + approx(flyEl.amount, u.s.atk * 0.2, '20 % ATK to the flyer around'); + assert.ok(!before.some((c) => c.target === far), 'outside the 1.3'); + approx(seq[iHit].amount, u.s.atk, 'arts damage = ATK'); + assert.equal(seq[iHit].type, 'arts'); + done(h); + } +}); + +test('RIT-X “酒神之心”: her 神经损伤 on an ELITE / leader enemy ×1.18 (stages 1 and 3), on a normal one ×1; none without the module', () => { + for (const [tier, elite, mod] of [[5, true, X], [6, true, X], [6, true, null], [5, false, null]]) { + const { h, u } = field({ tier, elite, mod, skill: 2 }); + const want = mod ? 1.18 : 1; + if (mod) assert.equal(modOf(tier, mod).traitOverride.bb.ep_damage_scale, 1.18); + const el = h.spawn('enemy_elite', { pos: [10, 6] }); + const n0 = h.hooksOf('damaged').length; + assert.ok(h.runUntil(() => elemOf(h, u, n0).some((c) => c.target === el), 3)); + approx(elemOf(h, u, n0).find((c) => c.target === el).amount, u.s.atk * 0.3 * want, `${label([tier, elite, mod])}: elite`); + h.b.kill(el); + const nm = h.spawn('enemy_dummy', { pos: [10, 6] }); + const n1 = h.hooksOf('damaged').length; + assert.ok(h.runUntil(() => elemOf(h, u, n1).some((c) => c.target === nm), 3)); + approx(elemOf(h, u, n1).find((c) => c.target === nm).amount, u.s.atk * 0.3, 'normal enemy'); + done(h); + } +}); + +test('T2 堕梦: while she is on the field every enemy in a 神经损伤 burst has ASPD −12 (RIT-X stage 3 −20), on the whole field; none outside the burst or once she left', () => { + for (const [tier, elite, mod] of [[5, false, null], [6, true, X], [5, true, X]]) { + const { h, u } = field({ tier, elite, mod, skill: 2 }); + quiet(h, u); + const v = formOf(tier, elite).talents[1].bb.attack_speed + (mod === X && tier === 6 ? -8 : 0); + const e = h.spawn('enemy_dummy', { pos: [12, 15] }); // far outside her range + const aspd0 = e.s.aspd; + e.elem.neural = 999; + h.b.dealDamage(u, e, { type: 'element', element: 'neural', amount: 10 }); + assert.ok(e.findBuff('neuralBurst'), 'burst'); + h.step(); + approx(e.s.aspd, aspd0 + v, `ASPD ${v}`); + h.b.retreat(u); + h.run(0.3); + approx(e.s.aspd, aspd0, 'she left: gone'); + done(h); + } +}); + +test('T2 堕梦: an enemy of her range starting a normal attack first takes 70 (RIT-X stage 3: 90) 神经损伤; a burst from it interrupts that very attack (麻痹); outside her range nothing', () => { + for (const [tier, elite, mod] of [[5, false, null], [6, true, X]]) { + const { h, u } = field({ tier, elite, mod, skill: 2 }); + quiet(h, u); + const val = mod === X && tier === 6 ? 90 : 70; + const sh = h.spawn('enemy_shooter', { pos: [10, 7] }); // in her y-2, 2 tiles from her + const n0 = h.hooksOf('damaged').length; + assert.ok(h.runUntil(() => h.hooksOf('damaged').slice(n0).some((c) => c.source === sh && c.target === u), 3), 'it shoots her'); + const el = elemOf(h, u, n0).filter((c) => c.target === sh); + assert.equal(el.length, 1, 'one 神经损伤 per attack'); + approx(el[0].amount, val, `${val}`); + // a burst from it: the attack is interrupted + sh.elem.neural = 1000 - val + 1; + const hp0 = u.hp, n1 = h.hooksOf('damaged').length; + h.run(1.05); + assert.ok(sh.findBuff('neuralBurst'), 'burst on its attack'); + assert.ok(!h.hooksOf('damaged').slice(n1).some((c) => c.source === sh && c.target === u && c.dmg?.isAttack), 'that attack never lands'); + assert.equal(u.hp, hp0); + // outside her range + const out = h.spawn('enemy_shooter', { pos: [12, 8] }); + h.b.relocate(u, 10, 5); + const n2 = h.hooksOf('damaged').length; + h.run(3); + assert.ok(!elemOf(h, u, n2).some((c) => c.target === out), '(12, 8) is outside her y-2'); + done(h); + } +}); + +test('S1 暗夜回声 (AUTO, 3 attack SP): every 4th attack hits twice for 105 % / 120 % ATK arts, an enemy not in a burst first, and roots it 2 / 3 s; while that root holds 神经损伤 on it ×1.3 / ×1.5 (from anyone)', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S1); + assert.deepEqual([sk.spCost, sk.initSp, sk.bb.atk_scale, sk.bb.times, sk.bb.unmove, sk.bb.ep_damage_scale], [3, 0, elite ? 1.2 : 1.05, 2, elite ? 3 : 2, elite ? 1.5 : 1.3]); + const { h, u } = field({ tier, elite, skill: 0 }); + const burst = h.spawn('enemy_dummy', { pos: [10, 6] }); + const fresh = h.spawn('enemy_dummy', { pos: [10, 6] }); + h.b.addBuff(burst, { key: 'neuralBurst', duration: 100, flags: { burstLock: true } }); + assert.ok(h.runUntil(() => u.skill.activations === 1, 10)); + h.run(0.5); // the bolts land + const atks = h.hooksOf('attack').filter((c) => c.attacker === u); + assert.deepEqual(atks.map((c) => c.isSkill), [false, false, false, true], 'three plain attacks, then the skill\'s'); + const skillHits = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack && c.dmg.isSkill); + assert.equal(skillHits.length, 2, 'two hits'); + assert.ok(skillHits.every((c) => c.target === fresh), 'the enemy not in a burst'); + for (const c of skillHits) approx(c.amount, u.s.atk * sk.bb.atk_scale, `${sk.bb.atk_scale * 100} %`); + const bind = h.hooksOf('statusApplied').find((c) => c.status === 'bind' && c.target === fresh); + assert.ok(bind, 'rooted'); + approx(bind.duration, sk.bb.unmove, 'root time'); + const n0 = h.hooksOf('damaged').length; + const other = h.spawn('enemy_dummy', { pos: [12, 12] }); + h.b.dealDamage(other, fresh, { type: 'element', element: 'neural', amount: 100 }); + approx(h.hooksOf('damaged').slice(n0).find((c) => c.target === fresh && c.type === 'element').amount, 100 * sk.bb.ep_damage_scale, `×${sk.bb.ep_damage_scale} while rooted`); + h.run(sk.bb.unmove + 0.1); + const n1 = h.hooksOf('damaged').length; + h.b.dealDamage(other, fresh, { type: 'element', element: 'neural', amount: 100 }); + approx(h.hooksOf('damaged').slice(n1).find((c) => c.target === fresh && c.type === 'element').amount, 100, '×1 after it'); + done(h); + } +}); + +test('S2 群体性谵妄 (AUTO, at full SP, endless): cast after 33 / 30 s with no enemy; ASPD +20 / +25; T1\'s splash radius 1.5; it never ends', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S2); + assert.deepEqual([sk.spCost, sk.initSp, sk.bb.attack_speed, sk.bb['talent@range_radius']], [elite ? 30 : 33, 0, elite ? 25 : 20, 1.5]); + const { h, u } = field({ tier, elite, skill: 1 }); + h.run(sk.spCost - 0.5); + assert.equal(u.skill.activations, 0); + assert.ok(h.runUntil(() => u.skill.active, 1), 'at full SP'); + approx(u.s.aspd, 100 + sk.bb.attack_speed, 'ASPD'); + const main = h.spawn('enemy_dummy', { pos: [10, 6] }); + const at14 = h.spawn('enemy_dummy', { pos: [11.4, 6] }); // 1.4 away: inside 1.5, outside 1.3 + const n0 = h.hooksOf('damaged').length; + assert.ok(h.runUntil(() => elemOf(h, u, n0).some((c) => c.target === at14), 3), 'the 1.5 splash'); + void main; + h.run(200); + assert.ok(u.skill.active, 'endless'); + done(h); + } +}); + +test('S2 + 本能的召唤: the placed piece deploys at the battle start (untargetable, no attack, no block), 诱导s the enemies entering its x-1 (at most 4, ELITE first), leaves 10 s after or when one reaches it; leaving: every enemy of its x-1 停顿 6 s and 12 × (15 % / 20 % ATK 神经损伤 + 100 % / 120 % ATK arts) over 6 s at her ATK of its deployment; back with S2 and again 25 s after each exit while it runs; her leaving withdraws it', () => { + for (const [tier, elite, callFirst] of [[5, false, false], [6, true, true]]) { + const sk = skillOf(tier, elite, S2); + const { h, u, callUnit } = field({ tier, elite, skill: 1, call: [10, 6], callFirst }); + const call = callUnit; + assert.ok(call && call.alive, 'deployed with the board'); + assert.ok(call.s.flags.untargetable && call.profile.noAttack && call.s.blockCnt === 0, 'untargetable, no attack, no block'); + const atkAtDeploy = u.s.atk; + // nothing to lure: it leaves after 10 s; the enemies of its x-1 then get the 停顿 and the damage + const inside = h.spawn('enemy_dummy', { pos: [11, 7] }); + h.b.addBuff(inside, { key: 'test:noattract', flags: { attract: true } }); // (already lured elsewhere: not taken) + h.b.addBuff(u, { key: 'test:atk', mods: { atkPct: 1 } }); // her ATK changes after the deployment + quiet(h, u); + assert.ok(h.runUntil(() => !call.alive, 11), 'leaves after 10 s'); + approx(h.hooksOf('death').find((c) => c.unit === call).t, 10, '10 s', 0.05); + h.b.removeBuff(inside, 'test:noattract'); + const slow = h.hooksOf('statusApplied').find((c) => c.status === 'sluggish' && c.target === inside); + assert.ok(slow); + approx(slow.duration, sk.bb.sluggish, '停顿 6 s'); + const n0 = h.hooksOf('damaged').length; + h.run(6.2); + const ticks = h.hooksOf('damaged').slice(n0).filter((c) => c.target === inside && c.source === u); + const arts = ticks.filter((c) => c.type === 'arts'), neural = ticks.filter((c) => c.type === 'element'); + assert.equal(arts.length, 12, '12 arts ticks'); + // 12 神经损伤 ticks — unless the gauge bursts first (then the 爆发冷却 takes no more: elite 12 × 92.6 > 1000) + const per = atkAtDeploy * sk.bb.ep_damage_ratio_token; + assert.equal(neural.length, Math.min(12, Math.ceil(1000 / per)), '神经损伤 ticks'); + for (const c of arts) approx(c.amount, atkAtDeploy * sk.bb.atk_scale, `arts ${sk.bb.atk_scale * 100} % of the cached ATK`); + for (const c of neural) approx(c.amount, atkAtDeploy * sk.bb.ep_damage_ratio_token, `神经损伤 ${sk.bb.ep_damage_ratio_token * 100} %`); + assert.ok(arts.every((c) => c.dmg.tags.includes('dot') && c.dmg.canDodge === false), '持续伤害'); + h.b.removeBuff(u, 'test:atk'); + h.b.kill(inside); + // S2 (at full SP: 33 / 30 s) gives it back, after its own 25 s redeploy time from its exit at 10 s + assert.ok(h.runUntil(() => u.skill.active, 20)); + approx(h.b.time, sk.spCost, 'S2 at full SP', 0.05); + assert.ok(!call.alive, 'still in its redeploy time'); + assert.ok(h.runUntil(() => call.alive, 6), 'back with S2'); + approx(h.b.time, 35, 'at 10 + 25 s', 0.6); + const t0 = h.b.time; + // five walkers on row 9 enter its x-1 at (9, 7) together: four are lured, the elite first; the first to arrive ends it + const w = [h.spawn('enemy_walk', { routeIndex: 0 }), h.spawn('enemy_walk', { routeIndex: 0 }), h.spawn('enemy_walk', { routeIndex: 0 }), + h.spawn('enemy_walk', { routeIndex: 0 }), h.spawn('enemy_walk_elite', { routeIndex: 0 })]; + assert.ok(h.runUntil(() => !call.alive, 12), 'gone again'); + const lured = h.hooksOf('statusApplied').filter((c) => c.status === 'attract' && c.source === call && c.t >= t0); + assert.equal(lured.length, 4, 'max_target 4'); + assert.equal(lured[0].target, w[4], 'the elite first'); + assert.equal(new Set(lured.map((c) => c.target)).size, 4); + const exitT = h.hooksOf('death').filter((c) => c.unit === call).at(-1).t; + assert.ok(exitT - t0 < 10 - 0.5, `left on an arrival, before its 10 s (${(exitT - t0).toFixed(2)} s)`); + assert.ok(lured.some((c) => Math.hypot(c.target.x - 6, c.target.y - 10) <= 0.4 + 1e-6), 'one of them within 0.4 of it'); + assert.ok(w.every((e) => !e.findBuff('attract') || e.findBuff('attract').source !== call), 'the 诱导 ends with it'); + // 25 s later it is back (S2 still runs) + h.run(24.5); + assert.ok(!call.alive); + assert.ok(h.runUntil(() => call.alive, 1.5), 'back after its own redeploy time'); + h.b.retreat(u); + h.step(); + assert.ok(!call.alive, 'withdrawn when she leaves'); + h.run(30); + assert.ok(!call.alive, 'not back without her'); + done(h); + } +}); + +test('本能的召唤: an ELITE in its x-1 is lured before normal enemies; only reachable ground enemies (flyers always)', () => { + const { h, u, callUnit } = field({ skill: 1, call: [11, 6] }); + quiet(h, u); + const n = [h.spawn('enemy_dummy', { pos: [12, 6] }), h.spawn('enemy_dummy', { pos: [11, 5] }), h.spawn('enemy_dummy', { pos: [11, 7] }), + h.spawn('enemy_dummy', { pos: [10, 6] }), h.spawn('enemy_elite', { pos: [12, 7] })]; + h.run(0.15); + const lured = h.hooksOf('statusApplied').filter((c) => c.status === 'attract' && c.source === callUnit).map((c) => c.target); + assert.equal(lured.length, 4, 'four'); + assert.equal(lured[0], n[4], 'the elite first'); + done(h); +}); + +test('S3 空剧场 (ACTIVE_RANGE on y-8): range y-8 while on, ATK +75 % / +95 %, an enemy not in a burst first; her 神经损伤 starts 10 % ATK 神经损伤 a second (from 0.1 s) until a burst; the 爆发冷却 of enemies in her range runs 1.5×', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S3); + const form = formOf(tier, elite); + assert.deepEqual([sk.duration, sk.bb.atk, sk.bb.ep_damage_ratio, sk.bb.interval, sk.bb['talent@ep_break_recover_speed'], sk.bb['phatm2_s_3[token_cooldown].interval']], + [30, elite ? 0.95 : 0.75, 0.1, 1, 0.5, 30]); + const { h, u } = field({ tier, elite, skill: 2 }); + u.skill.gainSp(999); + h.run(2); + assert.equal(u.skill.activations, 0, 'no enemy: no cast'); + const e = h.spawn('enemy_dummy', { pos: [10, 8] }); // y-8 (+3), not y-2 + assert.ok(h.runUntil(() => u.skill.active, 3), 'cast on the y-8'); + assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, 'y-8 while on'); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), 'ATK'); + const n0 = h.hooksOf('damaged').length; + assert.ok(h.runUntil(() => elemOf(h, u, n0).some((c) => c.target === e), 3), 'her first 神经损伤'); + const t0 = elemOf(h, u, n0).find((c) => c.target === e).t; + quiet(h, u); + h.run(2.2); + const dot = elemOf(h, u, n0).filter((c) => c.target === e && c.dmg.tags.includes('phatm2:theatre')); + assert.ok(dot.length >= 2, `${dot.length} ticks`); + approx(dot[0].t - t0, 0.1, 'first at 0.1 s', 0.05); + approx(dot[1].t - dot[0].t, 1, 'then each second', 0.05); + for (const c of dot) approx(c.amount, u.s.atk * 0.1, '10 % ATK'); + // until a burst + e.elem.neural = 999; + h.run(1.1); + assert.ok(e.findBuff('neuralBurst'), 'burst'); + const n1 = h.hooksOf('damaged').length; + h.run(3); + assert.equal(elemOf(h, u, n1).filter((c) => c.target === e && c.dmg.tags.includes('phatm2:theatre')).length, 0, 'stops in a burst'); + // the 爆发冷却 runs 1.5× in her range + const lock = e.findBuff('neuralBurst'); + const left = lock.timeLeft; + h.run(1); + approx(left - lock.timeLeft, 1.5, 'cooldown 1.5×', 0.05); + h.runUntil(() => !u.skill.active, 35); + assert.deepEqual(u.liveRangeGrid, form.rangeGrid, 'back to y-2'); + done(h); + } +}); + +test('S3 迷狂牢笼: a 神经损伤 burst of an enemy in her range puts a 牢笼 on its ground tile that blocks the enemies on it (air too), takes 1 per hit from them only, falls after 3 hits; it takes nobody else; refreshed by a burst on its tile; leaves with nobody blocked, 30 s after the skill, or when she leaves', () => { + const { h, u } = field({ skill: 2 }); + h.spawn('enemy_dummy', { pos: [10, 8] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 3)); + quiet(h, u); + const a = h.spawn('enemy_shooter', { pos: [11, 7] }), fly = h.spawn('enemy_fly', { pos: [11, 7] }); + a.elem.neural = 990; + h.b.dealDamage(u, a, { type: 'element', element: 'neural', amount: 20 }); + const cage = cagesOf(h)[0]; + assert.ok(cage, 'a 牢笼'); + assert.deepEqual([cage.tileR, cage.tileC, cage.ownerUnit === u], [11, 7, true]); + assert.ok(a.blockedBy === cage && fly.blockedBy === cage, 'both blocked, the flyer too'); + assert.equal(cage.s.blockCnt, 2, 'block count = their weight'); + assert.ok(cage.s.flags.isolated && cage.s.flags.noHeal && cage.profile.noAttack, '孤立, 禁疗, no attack'); + // damage: from another enemy nothing, from a blocked one 1 + const other = h.spawn('enemy_dummy', { pos: [12, 12] }); + h.b.dealDamage(other, cage, { amount: 500, type: 'phys' }); + assert.equal(cage.hp, 3, 'immune to an enemy it does not block'); + h.b.dealDamage(null, cage, { amount: 500, type: 'true', sourceless: true }); + assert.equal(cage.hp, 3, '无来源: nothing'); + h.b.dealDamage(fly, cage, { amount: 500, type: 'phys' }); + assert.equal(cage.hp, 2, '1 per hit'); + // an enemy arriving on its tile is not taken by contact + const late = h.spawn('enemy_dummy', { pos: [11, 7] }); + h.run(0.2); + assert.equal(late.blockedBy, null, 'no new block by contact'); + // refreshed by its 神经损伤 burst on that tile: HP back, it is blocked too + late.elem.neural = 999; + h.b.dealDamage(u, late, { type: 'element', element: 'neural', amount: 10 }); + assert.equal(late.blockedBy, cage, 'refreshed: blocked'); + assert.equal(cage.hp, 3, 'full HP again'); + assert.equal(cage.s.blockCnt, 3); + assert.equal(cagesOf(h).length, 1, 'no second 牢笼 on the tile'); + // three hits from a blocked enemy: gone, the enemies released + for (let i = 0; i < 3; i++) h.b.dealDamage(fly, cage, { amount: 100, type: 'phys' }); + assert.ok(!cage.alive, 'three hits'); + assert.ok(!a.blockedBy && !fly.blockedBy, 'released'); + done(h); +}); + +test('S3 迷狂牢笼: no 牢笼 on a tile with a unit or on high ground; at the skill start the enemies of her range already in a burst set T1\'s splash off once and get one; 牢笼 last 30 s after the skill; her leaving withdraws them', () => { + const { h, u } = field({ skill: 2, others: [{ uid: 2, chessId: 'chess_char_1_02_a', row: 11, col: 6 }] }); + quiet(h, u); + // already bursting before the skill + const pre = h.spawn('enemy_dummy', { pos: [10, 7] }), side = h.spawn('enemy_dummy', { pos: [10.9, 7] }); + pre.elem.neural = 999; + h.b.dealDamage(u, pre, { type: 'element', element: 'neural', amount: 10 }); + assert.ok(pre.findBuff('neuralBurst')); + const n0 = h.hooksOf('damaged').length; + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 3)); + const splash = elemOf(h, u, n0).filter((c) => c.target === side); + assert.equal(splash.length, 1, 'T1\'s splash once around it'); + approx(splash[0].amount, u.s.atk * 0.2, '20 % of her (S3) ATK'); + const c1 = cagesOf(h).find((c) => c.tileR === 10 && c.tileC === 7); + assert.ok(c1 && pre.blockedBy === c1, 'a 牢笼 for the bursting one'); + // a unit on the tile: none + const onAlly = h.spawn('enemy_dummy', { pos: [11, 6] }); + onAlly.elem.neural = 999; + h.b.dealDamage(u, onAlly, { type: 'element', element: 'neural', amount: 10 }); + assert.ok(!cagesOf(h).some((c) => c.tileR === 11 && c.tileC === 6), 'not on 角峰\'s tile'); + // the skill ends: 30 s more + u.skill.end('test'); + h.run(29.5); + if (pre.alive && pre.blockedBy === c1) assert.ok(c1.alive, 'still there at 29.5 s'); + h.run(1); + assert.ok(!c1.alive, 'gone 30 s after the skill'); + // her leaving withdraws them + u.skill.gainSp(999); + const late = h.spawn('enemy_dummy', { pos: [12, 7] }); + assert.ok(h.runUntil(() => u.skill.active, 4)); + late.elem.neural = 999; + h.b.dealDamage(u, late, { type: 'element', element: 'neural', amount: 10 }); + assert.ok(cagesOf(h).length >= 1); + h.b.retreat(u); + h.step(); + assert.equal(cagesOf(h).length, 0, 'withdrawn with her'); + done(h); +}); diff --git a/test/content/op_slent2.test.js b/test/content/op_slent2.test.js new file mode 100644 index 00000000..aba528a2 --- /dev/null +++ b/test/content/op_slent2.test.js @@ -0,0 +1,366 @@ +// test/content/op_slent2.test.js — the 自选 operator kit of 淬羽赫默 (char_1031_slent2, 6★ 护佑者; kit +// server/sim/content/kits/ops/op-slent2.js) and of her summon 夜灯 (token_10029_slent2_protrb), fielded the production way +// (a DIY slot + its `diy` pick, simdata getDiy; the 夜灯 as the placed hand piece of her player) in every form: tiers 5 / 6, +// normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, BLS-X “难得清醒” or BLS-Y “可控动量” +// at stage 1 (tier 5) / 3 (tier 6). Numbers from data/backups.json; the fidelity checklist of kits/README.md. +// Run: node --test test/content/op_slent2.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { makeBattle, enemyRec, chessRec, 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 { holdsUndying } from '../../server/sim/content/items/battle.js'; +import { shelterOf, RHINE } from '../../server/sim/content/kits/ops/op-slent2.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 ID = 'char_1031_slent2'; +const LAMP = 'token_10029_slent2_protrb'; +const FORMS = BACKUPS.units[ID].forms; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const X = 'uniequip_002_slent2', Y = 'uniequip_003_slent2'; +const S1 = 'skchr_slent2_1', S2 = 'skchr_slent2_2', S3 = 'skchr_slent2_3'; +const statusOf = (tier, elite) => (elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'); +const formOf = (tier, elite) => FORMS[statusOf(tier, elite)]; +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_fly: dummy('enemy_fly', { motion: 'FLY' }) }; +/** A synthetic 【莱茵生命】 ally (伊芙利特's charId; no skill) and a plain one. */ +const RHINE_REC = chessRec({ id: 'test_rhine_a', charId: 'char_134_ifrit', profession: 'CASTER', skill: null, stats: { maxHp: 2000, def: 0, res: 0 } }); +const PLAIN_REC = chessRec({ id: 'test_plain_a', charId: 'char_test_plain', profession: 'CASTER', skill: null, stats: { maxHp: 2000, def: 0, res: 0 } }); +const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, X, Y].map((m) => [t, true, m]))]; +const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`; + +/** 淬羽赫默 as uid 1 at (10, 5) facing RIGHT, plus `others`; `lamp` = the 夜灯 piece's tile (uid 9) or null. */ +function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], lamp = null, lampFirst = false, seed = 5, flags = {} } = {}) { + const op = { uid: 1, diy: { slot: SLOT[tier], charId: ID, skillIndex: skill, uniEquipId: mod }, elite, row, col }; + const piece = lamp ? [{ uid: 9, kind: 'token', tokenId: LAMP, ownerUid: 1, row: lamp[0], col: lamp[1] }] : []; + const h = makeBattle({ + defs: { enemies: ENEMIES, chess: { test_rhine_a: RHINE_REC, test_plain_a: PLAIN_REC } }, timeLimit: 900, autoFinish: false, seed, + flags: { dpPerSec: 0, dpMax: 999, ...flags }, hooks: ['damaged', 'skillStart', 'skillEnd', 'heal', 'death', 'deploy', 'fatal'], captureNoisy: true, + units: lampFirst ? [...piece, op, ...others] : [op, ...piece, ...others], + }); + h.step(); + return { h, u: h.unit(1), lampUnit: h.b.allyUnits.find((a) => a.kind === 'token' && a.defId === LAMP) ?? null }; +} +function done(h) { + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); +} +const ally = (uid, row, col, id = 'test_plain_a') => ({ uid, chessId: id, row, col }); +const protectOf = (a) => a.findBuff('protect')?.data?.value ?? 0; + +test('淬羽赫默 in every 自选 form: her kit (all three skills authored), the form\'s stats + module attributes, y-6, 护佑者 (ranged arts, hits air, blocks 1; BLS-Y 2 targets), the data DEFAULT triggers, ground-targetable, 空构 bond, no 特质', () => { + assert.equal(OPERATOR_KITS[ID], KITS[ID]); + 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], [ID, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f)); + // BLS-X stage 3 gives her (a 莱茵生命 operator in her own range) max HP +10 % ×2 + const selfHp = m?.uniEquipId === X && tier === 6 ? 1.2 : 1; + approx(u.s.maxHp, (form.stats.maxHp + (m?.attr.maxHp ?? 0)) * selfHp, `${label(f)}: max HP`); + assert.deepEqual([u.base.atk, u.base.def, u.base.res], [form.stats.atk + (m?.attr.atk ?? 0), form.stats.def, form.stats.res], `${label(f)}: stats`); + assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.base.bat], [1, 'ranged', 'arts', true, 1.6], `${label(f)}: 护佑者`); + assert.equal(u.profile.maxTargets, mod === Y && elite ? 2 : 1, `${label(f)}: targets`); + assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: y-6`); + assert.equal(u.skill.rule, 'DEFAULT', `${label(f)}: trigger`); + assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['emptyShip'], []], `${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); + } + } + 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], [1445, 387, 1765, 440]); + assert.deepEqual([modOf(5, X).attr, modOf(6, X).attr, modOf(5, Y).attr, modOf(6, Y).attr], [{ maxHp: 100, atk: 45 }, { maxHp: 150, atk: 70 }, { maxHp: 150, atk: 40 }, { maxHp: 200, atk: 62 }]); +}); + +test('a 自选 pick: 淬羽赫默 is offered at tiers 5 and 6 and a roster with her passes validateDiyPicks', () => { + const data = { chess: CHESS, backups: BACKUPS }; + assert.ok(KITTED_CHARS.includes(ID)); + for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(ID), `tier ${t}`); + assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: ID, skillIndex: 2, uniEquipId: Y } }, { data, kitted: KITTED_CHARS }), + { ok: true, picks: { [SLOT[6]]: { charId: ID, skillIndex: 2, uniEquipId: Y } } }); +}); + +test('Trait: arts attacks on enemies (BLS-Y: two targets, flyers too); while a skill runs every attack heals the most injured ally of her range for heal_scale × ATK (0.75; BLS-X 1.0; BLS-Y two allies) instead', () => { + for (const [tier, elite, mod] of [[5, false, null], [6, true, X], [6, true, Y]]) { + const tb = (elite ? modOf(tier, mod).traitOverride : formOf(tier, elite).trait).bb; + const { h, u } = field({ tier, elite, mod, skill: 0, others: [ally(2, 11, 6), ally(3, 9, 6), ally(4, 10, 6)] }); + const e1 = h.spawn('enemy_dummy', { pos: [10, 7] }), e2 = h.spawn('enemy_fly', { pos: [11, 7] }); + h.run(4); + assert.equal(u.skill.activations, 0, 'no SP yet'); + const hits = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack); + assert.ok(hits.length >= 2 && hits.every((c) => c.type === 'arts'), `${tier}${mod}: arts hits`); + assert.equal(new Set(hits.map((c) => c.target)).size, mod === Y ? 2 : 1, `${tier}${mod}: targets`); + if (mod === Y) assert.ok(hits.some((c) => c.target === e2), 'the flyer too'); + void e1; + const [a, b, c] = [h.unit(2), h.unit(3), h.unit(4)]; + a.hp = a.s.maxHp * 0.3; b.hp = b.s.maxHp * 0.5; c.hp = c.s.maxHp * 0.9; + const n0 = h.hooksOf('heal').length; + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 3), 'cast with an enemy in range'); + assert.ok(h.runUntil(() => h.hooksOf('heal').slice(n0).some((x) => x.source === u && !x.opts?.regen), 3), 'heals'); + const heals = h.hooksOf('heal').slice(n0).filter((x) => x.source === u && !x.opts?.regen); + const first = heals.filter((x) => Math.abs(x.t - heals[0].t) < 1e-9); + assert.deepEqual(first.map((x) => x.target.uid).sort(), (mod === Y ? [2, 3] : [2]).sort(), `${tier}${mod}: the most injured`); + for (const x of first) approx(x.amount, u.s.atk * tb.heal_scale, `${tier}${mod}: ${tb.heal_scale * 100} % ATK`); + const atk0 = h.hooksOf('damaged').filter((x) => x.source === u && x.dmg?.isAttack && x.t > heals[0].t - 1e-9); + assert.equal(atk0.length, 0, 'no attack on enemies while the skill heals'); + done(h); + } +}); + +test('S1 进取之心 (MANUAL, DEFAULT): ATK +35 % / +50 % for 25 s, SP 35 / 30 from 10; cast on an enemy in her range, not on an injured ally alone', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S1); + const { h, u } = field({ tier, elite, skill: 0, others: [ally(2, 10, 6)] }); + assert.deepEqual([u.skill.spCost, sk.initSp, sk.duration, sk.bb.atk], [elite ? 30 : 35, 10, 25, elite ? 0.5 : 0.35]); + h.unit(2).hp = 100; + u.skill.gainSp(999); + h.run(3); + assert.equal(u.skill.activations, 0, 'an injured ally alone casts nothing (a 辅助, not a 医疗)'); + h.spawn('enemy_dummy', { pos: [10, 7] }); + assert.ok(h.runUntil(() => u.skill.active, 3)); + approx(u.skill.timeLeft, 25, 'duration', 0.01); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), 'ATK'); + h.runUntil(() => !u.skill.active, 30); + approx(u.s.atk, u.base.atk, 'back'); + done(h); + } +}); + +test('S2 俯瞰视界 + 夜灯: ASPD +20 / +30 for 12 s; the placed 夜灯 deploys at the battle start, leaves when the skill ends, comes back on its tile with the next cast and leaves when she does; the allies of its x-4 hold her T1 庇护 ×2.1 / ×2.4; untargetable, no attack', () => { + for (const [tier, elite, lampFirst] of [[5, false, false], [6, true, true]]) { + const sk = skillOf(tier, elite, S2); + const { h, u, lampUnit } = field({ tier, elite, skill: 1, lamp: [12, 8], lampFirst, others: [ally(2, 12, 9), ally(3, 9, 9)] }); + assert.deepEqual([sk.duration, sk.bb.attack_speed, sk.bb.damage_resistance_scale], [12, elite ? 30 : 20, elite ? 2.4 : 2.1]); + const lamp = lampUnit; + assert.ok(lamp && lamp.alive, 'deployed with the board (the battle-start deploy of a placed skill summon)'); + assert.ok(lamp.s.flags.untargetable && lamp.profile.noAttack && lamp.s.blockCnt === 0, 'untargetable, no attack, no block'); + const a = h.unit(2), far = h.unit(3); + a.hp = a.s.maxHp * 0.5; + h.run(0.3); + // (12, 9) is next to the 夜灯 and outside her y-6: 20 % × 2.1 / 2.4; (9, 9) outside both + const t1 = formOf(tier, elite).talents[0].bb; + approx(protectOf(a), shelterOf(t1, a, sk.bb.damage_resistance_scale), 'the 夜灯\'s 庇护', 1e-9); + approx(protectOf(a), 0.2 * sk.bb.damage_resistance_scale, '50 % HP: 20 % × scale', 1e-9); + assert.equal(protectOf(far), 0, 'outside'); + h.spawn('enemy_dummy', { pos: [10, 7] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 3)); + approx(u.s.aspd, 100 + sk.bb.attack_speed, 'ASPD'); + assert.ok(lamp.alive, 'the start 夜灯 is this skill\'s drone'); + assert.ok(h.runUntil(() => !u.skill.active, 13)); + h.step(); + assert.ok(!lamp.alive, 'withdrawn at the skill end'); + h.run(0.3); + assert.equal(protectOf(a), 0, 'its 庇护 is gone'); + // next cast (after its 5 s redeploy time): back on its tile + h.run(6); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 4)); + h.step(); + assert.ok(lamp.alive && lamp.tileR === 12 && lamp.tileC === 8, 'back on its tile'); + h.b.retreat(u); + h.step(); + assert.ok(!lamp.alive, 'withdrawn when she leaves'); + h.run(8); + assert.ok(!lamp.alive, 'not back without her skill'); + done(h); + } +}); + +test('S2 without a placed 夜灯: nothing deploys; S1 / S3 picks never field the piece', () => { + const { h, u } = field({ skill: 1 }); + h.spawn('enemy_dummy', { pos: [10, 7] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 3)); + assert.equal(h.b.allyUnits.filter((a) => a.defId === LAMP).length, 0); + done(h); + for (const skill of [0, 2]) { + const r = field({ skill, lamp: [12, 8] }); + r.h.run(2); + assert.ok(!r.lampUnit.alive, `skill ${skill}: no 夜灯`); + done(r.h); + } +}); + +test('T1 无声砥柱: every ally of her range (herself too) holds 庇护 base × (1 + 2 % per 1 % HP lost, at most 70 steps): 10 % → 20 % at half HP → 24 % at ≤ 30 %; it cuts physical and arts damage, not true; gone once the ally leaves her range; S3 ×1.5 / ×1.8', () => { + for (const [tier, elite, mod] of [[5, false, null], [6, true, X]]) { + const { h, u } = field({ tier, elite, mod, skill: 2, others: [ally(2, 10, 6), ally(3, 10, 9)] }); + const a = h.unit(2), out = h.unit(3); + const t1 = formOf(tier, elite).talents[0].bb; + assert.deepEqual(t1, { damage_resistance_base: 0.1, resistance_scale: 0.02, hp_ratio: 0.01, min_hp_ratio: 0.3 }); + h.run(0.2); + approx(protectOf(a), 0.1, 'full HP: 10 %', 1e-9); + approx(protectOf(u), 0.1, 'herself', 1e-9); + assert.equal(protectOf(out), 0, '(10, 9) is outside her y-6'); + for (const [ratio, want] of [[0.5, 0.2], [0.555, 0.1 * (1 + 0.02 * 44)], [0.3, 0.24], [0.1, 0.24]]) { + a.hp = a.s.maxHp * ratio; + h.run(0.25); + approx(protectOf(a), want, `HP ${ratio}`, 1e-9); + } + // the cut: physical and arts ×(1 − 24 %), true untouched + const e = h.spawn('enemy_dummy', { pos: [11, 9] }); + for (const type of ['phys', 'arts', 'true']) { + const hp0 = a.hp; + h.b.dealDamage(e, a, { amount: 100, type, canDodge: false }); + approx(hp0 - a.hp, type === 'true' ? 100 : 76, `${type}`, 1e-6); + a.hp = a.s.maxHp * 0.1; + } + // S3: ×talent_scale + const sk3 = skillOf(tier, elite, S3); + h.spawn('enemy_dummy', { pos: [10, 7] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 3)); + a.hp = a.s.maxHp * 0.1; + h.run(0.25); + approx(protectOf(a), 0.24 * sk3.bb.talent_scale, `S3 ×${sk3.bb.talent_scale}`, 1e-9); + // leaving the range: gone within one refresh + assert.ok(h.b.relocate(a, 12, 9)); + h.run(0.3); + assert.equal(protectOf(a), 0, 'gone outside her range'); + done(h); + } +}); + +test('T2 丰润羽翼: an ally of her range below 50 % HP gets 生命回复速度 5 % of her ATK (hpRegen, from 1 s after; 禁疗 does not stop it), a 【莱茵生命】 operator twice; none at ≥ 50 % or outside; BLS-X stage 3: 7 % and max HP +10 % (×2 莱茵生命) in her range; stage 1: neither', () => { + for (const [tier, elite, mod] of [[5, false, null], [5, true, X], [6, true, X]]) { + const x3 = mod === X && tier === 6; + const { h, u } = field({ tier, elite, mod, skill: 0, others: [ally(2, 10, 6), ally(3, 11, 6, 'test_rhine_a'), ally(4, 9, 6)] }); + const a = h.unit(2), r = h.unit(3), full = h.unit(4); + assert.ok(RHINE.has(r.def.charId) && !RHINE.has(a.def.charId)); + const ratio = x3 ? 0.07 : 0.05; + assert.equal(formOf(tier, elite).talents[1].bb.atk_to_hp_recovery_ratio, 0.05); + approx(a.s.maxHp, 2000 * (x3 ? 1.1 : 1), 'max HP'); + approx(r.s.maxHp, 2000 * (x3 ? 1.2 : 1), '莱茵生命 max HP'); + h.b.addBuff(a, { key: 'test:healFree', flags: { noHeal: true, healFree: true } }); + a.hp = a.s.maxHp * 0.4; r.hp = r.s.maxHp * 0.4; + h.run(0.95); + assert.equal(a.s.hpRegen, 0, 'not before 1 s'); + h.run(0.2); + approx(a.s.hpRegen, u.s.atk * ratio, `${ratio * 100} % ATK`); + approx(r.s.hpRegen, u.s.atk * ratio * 2, '莱茵生命 ×2'); + assert.equal(full.s.hpRegen, 0, 'full HP: none'); + const hp0 = a.hp; + h.run(2); + approx(a.hp - hp0, u.s.atk * ratio * 2, '2 s of regeneration through 禁疗', 0.02); + a.hp = a.s.maxHp * 0.6; + h.run(0.15); + assert.equal(a.s.hpRegen, 0, 'stops at ≥ 50 %'); + h.b.relocate(r, 10, 9); + h.run(0.15); + assert.equal(r.s.hpRegen, 0, 'stops outside her range'); + done(h); + } +}); + +test('BLS-Y stage 3 (丰润羽翼): a hit that would leave an operator of her range below 50 % meets a 屏障 of 50 % of her max HP first (莱茵生命 ×2) — once per deployment of hers; entering her range below 50 % gives it too; stage 1 / no module: none', () => { + for (const [tier, elite, mod] of [[6, true, Y], [5, true, Y], [6, true, null]]) { + const on = mod === Y && tier === 6; + const { h, u } = field({ tier, elite, mod, skill: 0, others: [ally(2, 10, 6), ally(3, 11, 6, 'test_rhine_a'), ally(4, 10, 9)] }); + const a = h.unit(2), r = h.unit(3), out = h.unit(4); + const e = h.spawn('enemy_dummy', { pos: [11, 9] }); + h.run(0.2); + const cap = u.s.maxHp * 0.5; + // 2000 HP: 900 leaves 1100 (55 %): no barrier; then 200 would leave 900 (45 %) + h.b.dealDamage(e, a, { amount: 900, type: 'true' }); + assert.equal(a.hp, 1100, 'above 50 %: no barrier'); + h.b.dealDamage(e, a, { amount: 200, type: 'true' }); + if (on) { + assert.equal(a.hp, 1100, 'the barrier took the hit'); + approx(a.findBuff(`slent2:barrier#${u.id}`)?.shield ?? 0, cap - 200, 'what is left of it'); + } else assert.equal(a.hp, 900, 'no barrier'); + // once: a later drop gives none + a.hp = 1100; + for (const b of a.buffs.filter((x) => x.key.startsWith('slent2:barrier'))) h.b.removeBuff(a, b); + h.b.dealDamage(e, a, { amount: 200, type: 'true' }); + assert.equal(a.hp, 900, 'once per deployment'); + h.b.dealDamage(e, r, { amount: 1200, type: 'true' }); + if (on) approx(r.hp, 2000 - Math.max(0, 1200 - 2 * cap), '莱茵生命: twice the barrier'); + // entering her range below 50 % + out.hp = 500; + h.b.relocate(out, 11, 5); + h.run(0.2); + assert.equal(!!out.findBuff(`slent2:barrier#${u.id}`), on, 'on entering below 50 %'); + // a new deployment of hers clears the marks + if (on) { + h.b.retreat(u); + h.b.redeploy(u); + h.run(0.2); + a.hp = 1100; + h.b.dealDamage(e, a, { amount: 200, type: 'true' }); + assert.equal(a.hp, 1100, 'again after her redeploy'); + } + done(h); + } +}); + +test('S3 无畏者协议 (60 s, at most 2 casts per battle): ATK +20 % / +30 %; the first operator of her range taking lethal damage keeps 1 HP and holds 不死 for 6 / 7 s (outlasting her) — once per cast, not a summon, not one outside her range, not one already holding 不死; no third cast', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S3); + const { h, u } = field({ tier, elite, skill: 2, others: [ally(2, 10, 6), ally(3, 11, 6), ally(4, 10, 9)] }); + assert.deepEqual([sk.duration, sk.bb.atk, sk.bb.grave_duration, sk.bb.skill_max_trigger_time, u.skill.spCost, sk.initSp], [60, elite ? 0.3 : 0.2, elite ? 7 : 6, 2, elite ? 42 : 46, 20]); + const a = h.unit(2), b = h.unit(3), out = h.unit(4); + const e = h.spawn('enemy_dummy', { pos: [10, 7] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 3)); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), 'ATK'); + h.b.dealDamage(e, out, { amount: 1e6, type: 'true' }); + assert.ok(!out.alive, 'outside her range: no 不死'); + h.b.dealDamage(e, a, { amount: 1e6, type: 'true' }); + assert.ok(a.alive && a.hp === 1, 'kept at 1 HP'); + assert.ok(a.findBuff('slent2:grave') && holdsUndying(h.b, a), '不死 (holdsUndying sees it)'); + approx(a.findBuff('slent2:grave').timeLeft, sk.bb.grave_duration, 'duration', 0.01); + h.b.retreat(u); // the 不死 outlasts her + h.run(sk.bb.grave_duration - 0.5); + h.b.dealDamage(e, a, { amount: 1e6, type: 'true' }); + assert.ok(a.alive, 'held during the window'); + h.b.dealDamage(e, b, { amount: 1e6, type: 'true' }); + assert.ok(!b.alive, 'once per cast'); + h.run(1); + h.b.dealDamage(e, a, { amount: 1e6, type: 'true' }); + assert.ok(!a.alive, 'the window is over'); + done(h); + } + // two casts per battle, then no SP + const { h, u } = field({ skill: 2 }); + h.spawn('enemy_dummy', { pos: [10, 7] }); + for (let i = 1; i <= 2; i++) { + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.activations === i, 5), `cast ${i}`); + h.runUntil(() => !u.skill.active, 61); + } + h.run(100); + assert.equal(u.skill.activations, 2, 'no third cast'); + assert.equal(u.skill.charges, 0); + assert.equal(u.skill.gainSp(50), 0, 'no SP after the last use'); + done(h); +}); + +test('S3: a summon is no 干员 (no 不死); a lethal hit on an operator already holding 不死 leaves the cast\'s 不死 for the next one', () => { + const { h, u, lampUnit } = field({ skill: 2, others: [ally(2, 10, 6), ally(3, 11, 6)] }); + void lampUnit; + const e = h.spawn('enemy_dummy', { pos: [10, 7] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 3)); + const a = h.unit(2), b = h.unit(3); + // a summon of her range (a 夜灯 spawned for the test) takes no 不死 + const tok = h.b.spawnToken(u, LAMP, 11, 5, { anySource: true }); + assert.ok(tok && tok.alive && u.rangeKeySet.has(11 * 21 + 5)); + h.b.dealDamage(e, tok, { amount: 1e6, type: 'true' }); + assert.ok(!tok.alive && !tok.findBuff('slent2:grave'), 'a summon is no 干员'); + h.b.addBuff(a, { key: 'test:undying', flags: { undying: true }, duration: 5 }); + h.b.on('fatal', (c) => { if (c.unit === a && a.findBuff('test:undying')) c.prevented = true; }, { priority: -99 }); + h.b.dealDamage(e, a, { amount: 1e6, type: 'true' }); + assert.ok(a.alive && !a.findBuff('slent2:grave'), 'its own 不死 held it: the cast keeps its charge'); + h.b.dealDamage(e, b, { amount: 1e6, type: 'true' }); + assert.ok(b.alive && b.findBuff('slent2:grave'), 'the next operator gets it'); + done(h); +}); diff --git a/test/golden/diy.json b/test/golden/diy.json index 16b358f2..e57bb154 100644 --- a/test/golden/diy.json +++ b/test/golden/diy.json @@ -5217,8 +5217,8 @@ "snaps": "a3cff5cf d0583459 7816abc1 39def2d7 5b7f0286 c110a75a 695623ec 79136be7 a4d0b9a8 c2fa26f5 89fd1b16 5b47f0ed a212ac82 437033f1 c8d1ff58 534daf69 0c5a4ec8 caa8471f 0b73655b 49a2eb59 1a30387d 9158dadd 38cd69de" }, "diy-122": { - "about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 6_diy1_b=radian/S1/002_radian 6_diy2_b=radian/S2/002_radian 6_diy1_b=radian/S3/002_radian 6_diy2_b=radian/S1/003_radian 6_diy1_b=radian/S2/003_radian 6_diy2_b=radian/S3/003_radian 5_diy1_a=csnipe 5_diy2_b=csnipe", - "fast": true, + "about": "6_diy1_b=radian/S1/002_radian 6_diy2_b=radian/S2/002_radian 6_diy1_b=radian/S3/002_radian 6_diy2_b=radian/S1/003_radian 6_diy1_b=radian/S2/003_radian 6_diy2_b=radian/S3/003_radian 5_diy1_a=slent2/S1 5_diy2_a=slent2/S2 5_diy1_a=slent2/S3 5_diy2_b=slent2/S1 5_diy1_b=slent2/S2 5_diy2_b=slent2/S3", + "fast": false, "kind": "normal", "stageId": "act1autochess_m01", "modeId": "mode_multi_hard", @@ -5234,113 +5234,113 @@ "bossHpLeft": null, "rngDraws": 0, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":0,"total":123,"perfect":false,"leaks":123,"leaksUncounted":0,"coins":0,"dmg":290050,"bossDmg":0,"heal":0,"deaths":18,"layerGains":{}}}, + "players": {"p1":{"killed":0,"total":123,"perfect":false,"leaks":123,"leaksUncounted":0,"coins":0,"dmg":289224,"bossDmg":0,"heal":35390,"deaths":16,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,138,0,152,6384,0,4,0], - ["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,136,0,648,8724,0,9,0], - ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,140,0,7110,7692,0,18,0], - ["p1",4,"op","chess_char_5_diy2_b",2,"none",0,0,133,0,7740,10002,0,15,0], - ["p1",5,"op","chess_char_6_diy1_b",0,"uniequip_002_radian",1158,1,4,0,33369,0,0,131,4], - ["p1",6,"op","chess_char_6_diy2_b",1,"uniequip_002_radian",1158,1,5,0,40275,0,0,127,4], - ["p1",7,"op","chess_char_6_diy1_b",2,"uniequip_002_radian",1158,1,11,0,54384,0,0,129,3], - ["p1",8,"op","chess_char_6_diy2_b",0,"uniequip_003_radian",993,1,13,0,37218,0,0,135,4], - ["p1",9,"op","chess_char_6_diy1_b",1,"uniequip_003_radian",993,1,6,0,40898,0,0,122,4], - ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_003_radian",993,1,14,0,59223,0,0,132,3], - ["p1",11,"op","chess_char_5_diy1_a",2,null,1271,1,151,0,882,3813,0,7,0], - ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,143,0,8152,4530,0,23,1], + ["p1",1,"op","chess_char_6_diy1_b",0,"uniequip_002_radian",1158,1,4,0,33165,0,0,131,4], + ["p1",2,"op","chess_char_6_diy2_b",1,"uniequip_002_radian",1158,1,5,0,40835,0,0,129,4], + ["p1",3,"op","chess_char_6_diy1_b",2,"uniequip_002_radian",1158,1,11,0,57174,0,0,134,3], + ["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_003_radian",993,1,13,0,38776,0,0,136,4], + ["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_003_radian",993,1,6,0,41560,0,0,124,4], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_003_radian",993,1,14,0,59121,0,0,132,3], + ["p1",7,"op","chess_char_5_diy1_a",0,null,0,0,148,0,2554,10770,5502,23,1], + ["p1",8,"op","chess_char_5_diy2_a",1,null,0,0,146,0,4296,4873,538,19,0], + ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,139,0,4528,13518,9770,58,1], + ["p1",10,"op","chess_char_5_diy2_b",0,"none",0,0,138,0,4092,5669,374,17,0], + ["p1",11,"op","chess_char_5_diy1_b",1,"none",0,0,134,0,2024,5507,212,9,0], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,131,0,1100,20734,18994,43,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_1010_demon":[54,0,53,18271,131927],"enemy_1422_lrsldr_2":[39,0,39,2889,92931],"enemy_1425_lrcmra_2":[30,0,30,19985,65192]}, - "hooks": [1,151,6900,901,901,901,901,0,18,140,23,23,0,28074,220,29,123,122,1,18,0,0,0], - "events": {"atk":901,"deploy":25,"die":18,"dmg":901,"engage":12,"fx":8,"leak":122,"skill":46,"spawn":138,"status":236}, - "fx": {"shield":8}, - "snaps": "a5a3cc35 894a57a2 4b9f5176 6f48baf1 d88ae70f 473a84f3 74691a8e c52ef874 00871d23 c05d950a 33730f93 855c888e 8745c85a 2ff3981d ee3f55ee b28e36be 40d4d1ab 8151b1a7 10d62df3 1d5b7380 425c900d 412ab4f6 ce1f9af0" + "enemies": {"enemy_1010_demon":[54,0,53,35416,114473],"enemy_1422_lrsldr_2":[39,0,39,18914,105487],"enemy_1425_lrcmra_2":[30,0,30,6740,69264]}, + "hooks": [1,148,6900,1058,1058,970,970,2601,16,138,25,25,0,28518,226,16,123,122,1,17,0,0,0], + "events": {"atk":1058,"deploy":22,"die":16,"dmg":970,"engage":12,"fx":9,"heal":87,"leak":122,"skill":50,"spawn":138,"status":250}, + "fx": {"shield":8,"undying":1}, + "snaps": "19191fcf 1d0dba78 f470eaef c5134454 b45ee383 30798725 0999838f 9bdfec73 df515beb 6aade2dd 304f007b 9e0478d7 c315937c b6cf43a4 782d51f9 70b71cba d841b1b3 53bdfe56 c955e66b 1fcca0e4 75f58321 0ad54f22 f0c2122a" }, "diy-123": { - "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", + "about": "5_diy1_b=slent2/S1/002_slent2 5_diy2_b=slent2/S2/002_slent2 5_diy1_b=slent2/S3/002_slent2 5_diy2_b=slent2/S1/003_slent2 5_diy1_b=slent2/S2/003_slent2 5_diy2_b=slent2/S3/003_slent2 6_diy1_a=slent2/S1 6_diy2_a=slent2/S2 6_diy1_a=slent2/S3 6_diy2_b=slent2/S1 6_diy1_b=slent2/S2 6_diy2_b=slent2/S3", "fast": false, "kind": "normal", "stageId": "act1autochess_m02", "modeId": "mode_single_normal", - "round": 12, + "round": 13, "seed": 983089170, - "waveId": "act1autochess_h05", - "time": 217.53, - "ticks": 6526, - "reason": "cleared", - "killed": 16, - "total": 90, + "waveId": "act1autochess_h06", + "time": 230, + "ticks": 6900, + "reason": "timeout", + "killed": 13, + "total": 36, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 0, + "rngDraws": 11, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":16,"total":90,"perfect":false,"leaks":74,"leaksUncounted":0,"coins":0,"dmg":305235,"bossDmg":0,"heal":0,"deaths":16,"layerGains":{}}}, + "players": {"p1":{"killed":13,"total":36,"perfect":false,"leaks":23,"leaksUncounted":0,"coins":0,"dmg":138812,"bossDmg":0,"heal":102234,"deaths":20,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,112,0,611,7812,0,19,0], - ["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",0,0,99,0,6445,6742,0,26,2], - ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,89,4,25438,3058,0,24,4], - ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,79,3,83870,3808,0,25,4], - ["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,84,1,37691,5276,0,34,2], - ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,71,6,46607,6822,0,31,2], - ["p1",7,"op","chess_char_5_diy1_a",2,null,1133,1,5,2,26403,0,0,95,3], - ["p1",8,"op","chess_char_5_diy2_b",2,"none",1360,1,8,0,52045,0,0,158,3], - ["p1",9,"op","chess_char_5_diy1_a",2,null,1218,1,6,0,0,0,0,0,0], - ["p1",10,"op","chess_char_5_diy2_b",2,"none",1374,1,9,0,0,0,0,0,0], - ["p1",11,"op","chess_char_5_diy1_a",2,null,932,1,105,0,6636,1864,0,28,0], - ["p1",12,"op","chess_char_5_diy2_b",2,"none",1131,1,103,0,19488,1131,0,70,2] + ["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_slent2",0,0,59,1,8236,4235,15683,47,2], + ["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_slent2",391.24,1,56,0,19594,11003,9671,70,3], + ["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_slent2",0,0,58,0,6111,6836,17807,50,1], + ["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_003_slent2",0,0,54,0,22272,8564,16445,58,2], + ["p1",5,"op","chess_char_5_diy1_b",1,"uniequip_003_slent2",0,0,1,0,20448,1915,1080,28,2], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_003_slent2",0,0,3,0,8736,2420,15670,46,1], + ["p1",7,"op","chess_char_6_diy1_a",0,null,907.65,1,57,0,8127,2463,7306,49,2], + ["p1",8,"op","chess_char_6_diy2_a",1,null,727.87,1,55,0,15635,5605,5395,65,2], + ["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,52,1,4925,4606,271,13,0], + ["p1",10,"op","chess_char_6_diy2_b",0,"none",0,0,51,0,4576,5753,458,11,0], + ["p1",11,"op","chess_char_6_diy1_b",1,"none",0,0,2,0,11616,1765,1089,36,2], + ["p1",12,"op","chess_char_6_diy2_b",2,"none",0,0,53,0,8536,84599,11360,57,1] ], - "enemies": {"enemy_1229_darmy":[27,5,22,5381,47975],"enemy_1229_darmy_2":[51,10,41,14921,150809],"enemy_9007_acelem":[12,1,11,16212,106450]}, - "hooks": [1,112,6526,573,573,842,898,0,32,106,22,21,0,22719,98,33,90,74,1,37,0,2,0], - "events": {"atk":573,"deploy":22,"die":32,"dmg":898,"engage":10,"fx":106,"leak":74,"skill":43,"spawn":102,"status":238}, - "fx": {"aoe":24,"beam":12,"burst":2,"dp":15,"slash":53}, - "snaps": "06c160ad 60856643 f2759344 c1828e1a 2d800fed b15a5a70 1dde37fe a2c4da33 062734bc b8121d72 29030c51 f7b6b38b 6ba27ec2 429425fd 35458d83 075dbff4 e90e78aa 235ce236 5dbe07be 649356b2 6da48fb9 024e3c4c" + "enemies": {"enemy_1005_yokai_2":[12,12,0,136,2893],"enemy_1005_yokai_3":[12,0,11,70354,50146],"enemy_9009_acfort":[12,1,11,69272,85774]}, + "hooks": [1,59,6900,913,913,744,744,1250,33,55,18,18,0,22609,0,0,36,22,1,32,0,0,0], + "events": {"atk":983,"deploy":23,"die":33,"dmg":744,"engage":12,"fx":46,"heal":244,"leak":22,"skill":36,"spawn":48,"status":26}, + "fx": {"beam":44,"undying":2}, + "snaps": "b16e798f ee059fec 8ad3bfc7 3d6d8ebe 0cef81b3 3e6d32d8 da3d71eb f93663d5 13f1bf4c 1427fcee 5d955be0 61d23a2a fc44ffff 6bfa751e 79d902a9 b4e0eb21 b334bdd1 ab3a70a0 edee414e 52fc350a adb3ce09 488b6129 6dbe5135" }, "diy-124": { - "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", + "about": "6_diy1_b=slent2/S1/002_slent2 6_diy2_b=slent2/S2/002_slent2 6_diy1_b=slent2/S3/002_slent2 6_diy2_b=slent2/S1/003_slent2 6_diy1_b=slent2/S2/003_slent2 6_diy2_b=slent2/S3/003_slent2 5_diy1_a=haruka/S1 5_diy2_a=haruka/S2 5_diy1_a=haruka/S3 5_diy2_b=haruka/S1 5_diy1_b=haruka/S2 5_diy2_b=haruka/S3", "fast": false, "kind": "normal", "stageId": "act1autochess_m03", "modeId": "mode_multi_abyss", - "round": 12, + "round": 13, "seed": 3637921807, - "waveId": "act1autochess_h05", + "waveId": "act1autochess_h06", "time": 230, "ticks": 6900, "reason": "timeout", - "killed": 19, - "total": 114, + "killed": 3, + "total": 147, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 1060, + "rngDraws": 0, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":19,"total":114,"perfect":false,"leaks":95,"leaksUncounted":0,"coins":0,"dmg":875741,"bossDmg":0,"heal":90677,"deaths":7,"layerGains":{}}}, + "players": {"p1":{"killed":3,"total":147,"perfect":false,"leaks":144,"leaksUncounted":0,"coins":0,"dmg":366819,"bossDmg":0,"heal":143980,"deaths":1,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_diy1_a",0,null,1865,1,134,2,32671,3730,0,52,2], - ["p1",2,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",2374,1,136,5,86795,7181,0,59,2], - ["p1",3,"op","chess_char_5_diy1_a",2,null,1154,1,135,1,35817,31242,0,195,0], - ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",789.36,1,122,4,60783,31411,0,213,0], - ["p1",5,"op","chess_char_5_diy1_a",2,null,1272,1,8,2,32124,0,0,111,2], - ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",1587,1,12,0,55163,0,0,120,2], - ["p1",7,"op","chess_char_5_diy1_a",2,null,1372,1,14,2,80444,0,0,49,2], - ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",1749,1,4,3,402021,0,0,84,3], - ["p1",9,"op","chess_char_5_diy1_a",2,null,1224,1,6,0,0,0,39232,76,2], - ["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",1211.94,1,9,0,0,30207,51445,76,3], - ["p1",11,"op","chess_char_5_diy1_a",2,null,1160,1,13,0,44372,0,0,158,3], - ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",1430,1,15,0,45551,0,0,130,2], + ["p1",1,"op","chess_char_6_diy1_b",0,"uniequip_002_slent2",2298,1,6,0,20859,0,24292,124,4], + ["p1",2,"op","chess_char_6_diy2_b",1,"uniequip_002_slent2",2298,1,11,1,29640,0,10044,129,5], + ["p1",3,"op","chess_char_6_diy1_b",2,"uniequip_002_slent2",2298,1,14,1,17854,0,13242,101,2], + ["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_003_slent2",1965,1,4,0,29367,0,7597,81,4], + ["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_003_slent2",1965,1,5,0,48041,0,4541,116,5], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_003_slent2",2358,1,7,0,34086,19467,21058,110,2], + ["p1",7,"op","chess_char_5_diy1_a",0,null,1556.5,1,12,0,17472,0,9369,109,5], + ["p1",8,"op","chess_char_5_diy2_a",1,null,1556.5,1,15,0,23546,0,9318,52,2], + ["p1",9,"op","chess_char_5_diy1_a",2,null,1556.5,1,8,0,30895,53685,12140,119,3], + ["p1",10,"op","chess_char_5_diy2_b",0,"none",1900.8,1,10,1,19822,27403,11008,112,5], + ["p1",11,"op","chess_char_5_diy1_b",1,"none",1900.8,1,85,0,46449,19336,4708,56,2], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",1900.8,1,9,0,48789,25991,16664,118,3], ["-","~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_10124_uashld_2":[12,0,12,1442,109491],"enemy_1043_zomsbr":[72,19,53,46440,483740],"enemy_1061_zomshd":[30,0,29,55889,282511]}, - "hooks": [1,136,6900,1658,1658,2344,2424,42351,26,120,23,23,0,36633,7376,27,114,94,1,26,0,0,0], - "events": {"atk":1877,"deploy":19,"die":26,"dmg":2416,"engage":10,"fx":48,"heal":262,"leak":94,"skill":53,"spawn":129,"status":840}, - "fx": {"aoe":20,"displace":4,"dodge":16,"spGain":8}, - "snaps": "1eb6ea3d 97f3ff30 4261b18e c01456c9 0b5c62d2 fab04043 177e6583 b52b5b2e be9178b3 8b79e9c3 1922dfdf a92f76da 8d4df36e ef41dec7 ad0b61b1 661c90f4 e2890b0a 59077292 95d5bd04 0243fc56 ac750351 03a10c61 9ba3299d" + "enemies": {"enemy_1010_demon_2":[36,0,35,45739,83976],"enemy_1071_dftman":[51,2,49,12571,78341],"enemy_1072_dlancer":[60,1,57,87571,204503]}, + "hooks": [1,163,6900,1507,1507,1705,1705,695,4,145,42,38,0,42811,57,63,147,141,1,5,0,0,0], + "events": {"atk":1709,"deploy":13,"die":4,"dmg":1703,"engage":12,"fx":683,"heal":493,"leak":141,"skill":80,"spawn":162,"status":660}, + "fx": {"aoe":80,"heal":264,"levitate":57,"shield":281,"undying":1}, + "snaps": "ab31c401 593ca698 3f161f46 c7d7b74b 6fac8158 239a95a3 d66a3e6d 2cbd9073 b6e2f25c 4edeb819 2dab0390 f67eb664 86218b39 68bd30dc 03ffc17d ad9bf4a1 aa51e149 0d1cbd08 cbf073fb 4185d001 dd7968d9 3f78af44 ee57d9a2" }, "diy-125": { - "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", + "about": "5_diy1_b=haruka/S1/002_haruka 5_diy2_b=haruka/S2/002_haruka 5_diy1_b=haruka/S3/002_haruka 6_diy1_a=haruka/S1 6_diy2_a=haruka/S2 6_diy1_a=haruka/S3 6_diy2_b=haruka/S1 6_diy1_b=haruka/S2 6_diy2_b=haruka/S3 6_diy1_b=haruka/S1/002_haruka 6_diy2_b=haruka/S2/002_haruka 6_diy1_b=haruka/S3/002_haruka", "fast": false, "kind": "normal", "stageId": "act1autochess_m04", @@ -5348,71 +5348,313 @@ "round": 13, "seed": 1997254780, "waveId": "act1autochess_h06", - "time": 230, - "ticks": 6900, - "reason": "timeout", - "killed": 84, + "time": 203.63, + "ticks": 6109, + "reason": "cleared", + "killed": 123, "total": 123, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 343, + "rngDraws": 90, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":84,"total":123,"perfect":false,"leaks":39,"leaksUncounted":0,"coins":0,"dmg":357927,"bossDmg":0,"heal":66543,"deaths":16,"layerGains":{}}}, + "players": {"p1":{"killed":123,"total":123,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":629853,"bossDmg":0,"heal":128695,"deaths":0,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_6_diy1_a",2,null,1893.31,1,123,2,5031,3315,0,33,1], - ["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",3431,1,151,5,13919,3431,0,21,1], - ["p1",3,"op","chess_char_6_diy1_a",2,null,126.12,1,127,12,20826,2932,0,21,5], - ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",1887.63,1,150,7,32985,6563,0,15,3], - ["p1",5,"op","chess_char_6_diy1_a",2,null,2498,1,94,17,82020,30131,0,83,4], - ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",3415.68,1,122,22,98741,12217,0,83,2], - ["p1",7,"op","chess_char_6_diy1_a",2,null,1272,1,142,0,3495,1272,0,4,1], - ["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",1643,1,146,10,48097,1643,0,73,1], - ["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,136,1,10025,4116,0,8,0], - ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",0,0,7,4,42788,1819,0,23,1], - ["p1",11,"op","chess_char_6_diy1_a",2,null,0,0,88,0,0,10841,20168,35,2], - ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",1186.2,1,84,0,0,21228,46375,67,3], + ["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_haruka",1858,1,4,0,0,0,3164,9,1], + ["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_haruka",1858,1,5,1,7912,0,1590,6,1], + ["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_haruka",1858,1,15,17,73075,14554,9713,114,2], + ["p1",4,"op","chess_char_6_diy1_a",0,null,1415,1,6,0,2866,0,6513,48,3], + ["p1",5,"op","chess_char_6_diy2_a",1,null,1415,1,8,8,35170,0,11720,97,2], + ["p1",6,"op","chess_char_6_diy1_a",2,null,1415,1,10,16,47405,14408,9923,130,2], + ["p1",7,"op","chess_char_6_diy2_b",0,"none",1728,1,12,7,21082,4346,8175,124,5], + ["p1",8,"op","chess_char_6_diy1_b",1,"none",1728,1,14,16,97813,10827,13434,80,2], + ["p1",9,"op","chess_char_6_diy2_b",2,"none",1728,1,7,1,17016,5796,13003,68,2], + ["p1",10,"op","chess_char_6_diy1_b",0,"uniequip_002_haruka",1938,1,9,3,19112,12473,10312,90,4], + ["p1",11,"op","chess_char_6_diy2_b",1,"uniequip_002_haruka",1938,1,11,30,232380,38855,27752,125,2], + ["p1",12,"op","chess_char_6_diy1_b",2,"uniequip_002_haruka",1938,1,13,20,76022,27435,13396,113,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], ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null] ], - "enemies": {"enemy_1019_jshoot_2":[57,47,9,26899,136695],"enemy_1299_ymkilr_2":[27,14,13,42870,161687],"enemy_1422_lrsldr_2":[39,23,16,8181,142594]}, - "hooks": [1,151,6900,1072,1072,2676,2599,203,100,138,24,23,0,26014,0,97,123,38,1,100,0,0,77], - "events": {"atk":1124,"deploy":25,"die":100,"dmg":2599,"engage":10,"fx":179,"heal":178,"leak":38,"skill":47,"spawn":138,"status":16}, - "fx": {"aoe":33,"dodge":77,"dp":27,"slash":40,"spGain":2}, - "snaps": "21dd8d16 cabbff5b caf3b53e 7c015a2c 6747114f f22456ce ed18dbd1 652b0a6d 11ae3137 c8c632f8 f25abdbc 4667ab53 fab2e5d0 ed9faf27 fcec0ce7 e591aee0 5f8d1706 17d2d702 aeab44ae 4e764aa3 2c433a83 5127ab47 837a8334" + "enemies": {"enemy_1019_jshoot_2":[57,57,0,19648,147744],"enemy_1299_ymkilr_2":[27,27,0,75006,285120],"enemy_1422_lrsldr_2":[39,39,0,5622,205920]}, + "hooks": [1,138,6109,1425,1425,2509,2509,1034,123,123,28,24,0,30459,106,119,123,0,1,123,0,0,0], + "events": {"atk":1700,"deploy":12,"die":123,"dmg":2509,"engage":10,"fx":1024,"heal":845,"skill":52,"spawn":138,"status":769}, + "fx": {"aoe":324,"heal":291,"levitate":106,"shield":303}, + "snaps": "37a19233 3498a973 d7fd62db d432a99e 817dc20a 14c36445 1e36dfc0 0214eca3 4a66caa3 d431f91a 76c9483a 90a33b46 64d1a3cc f5b92579 7f25eb9f c4e59fde 114d2732 e3f08694 1106788c d2ed4180 056d1923" }, "diy-126": { - "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", + "about": "5_diy1_a=phatm2/S1 5_diy2_a=phatm2/S2 5_diy1_a=phatm2/S3 5_diy2_b=phatm2/S1 5_diy1_b=phatm2/S2 5_diy2_b=phatm2/S3 5_diy1_b=phatm2/S1/002_phatm2 5_diy2_b=phatm2/S2/002_phatm2 5_diy1_b=phatm2/S3/002_phatm2 6_diy1_a=phatm2/S1 6_diy2_a=phatm2/S2 6_diy1_a=phatm2/S3", "fast": false, "kind": "normal", "stageId": "act1autochess_m05", "modeId": "mode_single_hard", - "round": 13, + "round": 12, "seed": 356460561, + "waveId": "act1autochess_h05", + "time": 230, + "ticks": 6900, + "reason": "timeout", + "killed": 100, + "total": 102, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 0, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":100,"total":102,"perfect":false,"leaks":2,"leaksUncounted":0,"coins":0,"dmg":2039144,"bossDmg":0,"heal":0,"deaths":0,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",0,null,936,1,10,2,133730,0,0,98,24], + ["p1",2,"op","chess_char_5_diy2_a",1,null,936,1,11,3,176570,0,0,162,1], + ["p1",3,"op","chess_char_5_diy1_a",2,null,936,1,1,2,67780,0,0,51,2], + ["p1",4,"op","chess_char_5_diy2_b",0,"none",1111,1,3,5,71994,0,0,52,13], + ["p1",5,"op","chess_char_5_diy1_b",1,"none",1111,1,5,7,93448,0,0,129,1], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",1111,1,7,14,466653,0,0,135,3], + ["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_phatm2",1236,1,9,9,150648,0,0,135,33], + ["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_phatm2",1236,1,12,9,263202,0,0,172,1], + ["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_phatm2",1236,1,2,4,86955,0,0,51,2], + ["p1",10,"op","chess_char_6_diy1_a",0,null,936,1,4,0,26175,0,0,52,13], + ["p1",11,"op","chess_char_6_diy2_a",1,null,936,1,6,8,152179,0,0,125,1], + ["p1",12,"op","chess_char_6_diy1_a",2,null,936,1,8,18,349810,0,0,135,3], + ["p1","~1","token","token_10055_phatm2_mndclv",null,null,3,0,28,0,0,0,0,0,null], + ["p1","~2","token","token_10055_phatm2_mndclv",null,null,3,0,29,0,0,0,0,0,null], + ["p1","~3","token","token_10055_phatm2_mndclv",null,null,0,0,30,0,0,16,0,0,null], + ["p1","~4","token","token_10055_phatm2_mndclv",null,null,3,0,33,0,0,0,0,0,null], + ["p1","~5","token","token_10055_phatm2_mndclv",null,null,3,0,34,0,0,0,0,0,null], + ["p1","~6","token","token_10055_phatm2_mndclv",null,null,3,0,38,0,0,0,0,0,null], + ["p1","~7","token","token_10055_phatm2_mndclv",null,null,3,0,39,0,0,0,0,0,null], + ["p1","~8","token","token_10055_phatm2_mndclv",null,null,3,0,44,0,0,0,0,0,null], + ["p1","~9","token","token_10055_phatm2_mndclv",null,null,3,0,46,0,0,0,0,0,null], + ["p1","~10","token","token_10055_phatm2_mndclv",null,null,3,0,49,0,0,0,0,0,null], + ["p1","~11","token","token_10055_phatm2_mndclv",null,null,0,0,56,0,0,3,0,0,null], + ["p1","~12","token","token_10055_phatm2_mndclv",null,null,3,0,81,0,0,1,0,0,null], + ["p1","~13","token","token_10055_phatm2_mndclv",null,null,3,0,82,0,0,0,0,0,null], + ["p1","~14","token","token_10055_phatm2_mndclv",null,null,2,0,83,0,0,1,0,0,null], + ["p1","~15","token","token_10055_phatm2_mndclv",null,null,0,0,85,0,0,15,0,0,null], + ["p1","~16","token","token_10055_phatm2_mndclv",null,null,3,0,87,0,0,0,0,0,null], + ["p1","~17","token","token_10055_phatm2_mndclv",null,null,0,0,102,0,0,6,0,0,null], + ["p1","~18","token","token_10055_phatm2_mndclv",null,null,0,0,110,0,0,5,0,0,null], + ["p1","~19","token","token_10055_phatm2_mndclv",null,null,3,0,127,0,0,0,0,0,null], + ["p1","~20","token","token_10055_phatm2_mndclv",null,null,2,0,128,0,0,1,0,0,null], + ["p1","~21","token","token_10055_phatm2_mndclv",null,null,1,0,129,0,0,7,0,0,null], + ["p1","~22","token","token_10055_phatm2_mndclv",null,null,3,0,130,0,0,0,0,0,null] + ], + "enemies": {"enemy_1166_dusbr_2":[45,45,0,37,273715],"enemy_1170_dushld_2":[27,27,0,11,335923],"enemy_9011_acrefr":[30,28,0,7,1484094]}, + "hooks": [1,136,6900,1352,1352,3126,6459,0,105,122,97,93,0,18830,411,118,102,0,1,105,0,332,0], + "events": {"atk":1352,"deploy":34,"die":122,"dmg":6459,"engage":12,"fx":593,"skill":190,"spawn":136,"status":936}, + "fx": {"burst":332,"palsy":239,"summon":22}, + "snaps": "c4749bd0 699d0248 17649b18 7ab3fc70 09e3e1e3 6258b044 7238ac54 9f57607a 65ba9fc3 0b8e90f3 add042bf e94c1af2 fdf621ea 0e16b2df 721d914c d0aaa024 f828a7ac 7da502ae c52b962c 070c0db9 2a07d5b3 eca02286 05f89efe" + }, + "diy-127": { + "about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 6_diy1_b=phatm2/S1 6_diy2_b=phatm2/S2 6_diy1_b=phatm2/S3 6_diy2_b=phatm2/S1/002_phatm2 6_diy1_b=phatm2/S2/002_phatm2 6_diy2_b=phatm2/S3/002_phatm2 5_diy1_a=csnipe 5_diy2_b=csnipe", + "fast": true, + "kind": "normal", + "stageId": "act1autochess_m06", + "modeId": "mode_multi_normal", + "round": 13, + "seed": 3010682921, "waveId": "act1autochess_h06", "time": 230, "ticks": 6900, "reason": "timeout", - "killed": 10, + "killed": 30, "total": 105, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 170, + "rngDraws": 0, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":10,"total":105,"perfect":false,"leaks":95,"leaksUncounted":0,"coins":0,"dmg":171811,"bossDmg":0,"heal":0,"deaths":20,"layerGains":{}}}, + "players": {"p1":{"killed":30,"total":105,"perfect":false,"leaks":75,"leaksUncounted":0,"coins":0,"dmg":1853739,"bossDmg":0,"heal":0,"deaths":12,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_6_diy1_a",0,null,1865,1,128,2,23625,3730,0,51,2], - ["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",2494,1,129,6,88477,4988,0,57,1], - ["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,127,2,19834,9232,0,83,0], - ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",1484,1,130,0,14467,11872,0,70,0], - ["p1",5,"op","chess_char_6_diy1_a",2,null,1160,1,5,0,4933,0,0,31,1], - ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",1450,1,6,0,20475,0,0,82,2] + ["p1",1,"op","chess_char_5_diy1_a",2,null,7.11,1,3,0,4818,2121,0,144,3], + ["p1",2,"op","chess_char_5_diy2_b",2,"none",2908,1,6,0,0,0,0,0,0], + ["p1",3,"op","chess_char_5_diy1_a",2,null,2564,1,9,0,0,0,0,0,0], + ["p1",4,"op","chess_char_5_diy2_b",2,"none",0,0,127,0,7994,10002,0,41,0], + ["p1",5,"op","chess_char_6_diy1_b",0,"none",1111,1,1,8,375991,0,0,131,32], + ["p1",6,"op","chess_char_6_diy2_b",1,"none",274.62,1,4,4,332693,836,0,164,1], + ["p1",7,"op","chess_char_6_diy1_b",2,"none",1111,1,124,8,321763,1111,0,93,3], + ["p1",8,"op","chess_char_6_diy2_b",0,"uniequip_002_phatm2",1311,1,2,5,446608,0,0,131,32], + ["p1",9,"op","chess_char_6_diy1_b",1,"uniequip_002_phatm2",0,0,131,4,158830,2622,0,95,1], + ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_phatm2",474.62,1,135,1,193400,3458,0,53,2], + ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,118,0,3784,3813,0,30,0], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",1510,1,136,0,7859,1510,0,152,2], + ["p1","~1","token","token_10055_phatm2_mndclv",null,null,3,0,0,0,0,0,0,0,null], + ["p1","~2","token","token_10055_phatm2_mndclv",null,null,3,0,0,0,0,0,0,0,null], + ["p1","~3","token","token_10055_phatm2_mndclv",null,null,3,0,28,0,0,0,0,0,null], + ["p1","~4","token","token_10055_phatm2_mndclv",null,null,3,0,0,0,0,0,0,0,null], + ["p1","~5","token","token_10055_phatm2_mndclv",null,null,3,0,32,0,0,0,0,0,null], + ["p1","~6","token","token_10055_phatm2_mndclv",null,null,3,0,0,0,0,0,0,0,null], + ["p1","~7","token","token_10055_phatm2_mndclv",null,null,3,0,34,0,0,0,0,0,null], + ["p1","~8","token","token_10055_phatm2_mndclv",null,null,3,0,38,0,0,0,0,0,null], + ["p1","~9","token","token_10055_phatm2_mndclv",null,null,3,0,41,0,0,0,0,0,null], + ["p1","~10","token","token_10055_phatm2_mndclv",null,null,3,0,75,0,0,0,0,0,null], + ["p1","~11","token","token_10055_phatm2_mndclv",null,null,3,0,0,0,0,0,0,0,null], + ["p1","~12","token","token_10055_phatm2_mndclv",null,null,3,0,76,0,0,0,0,0,null], + ["p1","~13","token","token_10055_phatm2_mndclv",null,null,3,0,77,0,0,0,0,0,null], + ["p1","~14","token","token_10055_phatm2_mndclv",null,null,3,0,0,0,0,0,0,0,null], + ["p1","~15","token","token_10055_phatm2_mndclv",null,null,3,1,133,0,0,0,0,0,null], + ["p1","~16","token","token_10055_phatm2_mndclv",null,null,3,1,134,0,0,0,0,0,null], + ["p1","~17","token","token_10055_phatm2_mndclv",null,null,3,0,0,0,0,0,0,0,null], + ["p1","~18","token","token_10055_phatm2_mndclv",null,null,3,0,0,0,0,0,0,0,null] ], - "enemies": {"enemy_1165_duhond":[51,10,41,866,73785],"enemy_1174_duholy_2":[24,0,23,12955,102349],"enemy_1175_dushdo_2":[30,0,30,16001,67989]}, - "hooks": [1,130,6900,441,441,2345,2417,0,30,124,6,21,0,10384,2389,33,105,94,1,30,0,0,3], - "events": {"atk":485,"deploy":25,"die":30,"dmg":2387,"engage":6,"fx":8,"leak":94,"skill":45,"spawn":111,"status":481}, - "fx": {"displace":5,"dodge":3}, - "snaps": "08186948 cba346f8 3113c1fa 3712ce41 c1534fdd 8a993ce5 c092176d 86d47d10 903c4f73 0bf3993b f31f3e91 67179f99 c5384e6c 41f8b47e 8d537e1e d1394a25 4f860256 61990651 bf5d7786 d729c8d1 3aa4f821 3e5745ec aaa11631" + "enemies": {"enemy_1116_liprr_2":[27,9,18,3738,280676],"enemy_1119_vofsd":[48,15,29,15202,794636],"enemy_1121_lifbos_2":[30,6,24,6534,778428]}, + "hooks": [1,136,6900,1074,1074,1383,3796,1745,42,121,76,73,0,17719,311,60,105,71,1,42,0,251,0], + "events": {"atk":1074,"deploy":31,"die":50,"dmg":3796,"engage":10,"fx":486,"leak":71,"skill":149,"spawn":127,"status":752}, + "fx": {"burst":251,"liberate":3,"palsy":222,"summon":10}, + "snaps": "ff349734 43b3c2d1 5e09fdbf 588f8bb7 62eaf80f bf97ee30 1b8c2f7d 647f33c5 e595a119 78a82ab2 0afc7940 29efd966 5faf9bc5 4a9efa2e a6f21a05 1e94de9b a6059141 152adafe 8779d264 2b7ac375 c3fb645c 3dd7bb76 e51f73ed" + }, + "diy-128": { + "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": "act1autochess_m07", + "modeId": "mode_multi_hard", + "round": 12, + "seed": 1370290236, + "waveId": "act1autochess_h05", + "time": 192, + "ticks": 5760, + "reason": "cleared", + "killed": 7, + "total": 93, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 18, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":7,"total":93,"perfect":false,"leaks":86,"leaksUncounted":0,"coins":0,"dmg":234674,"bossDmg":0,"heal":0,"deaths":16,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,84,1,14487,5208,0,52,1], + ["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",0,0,76,1,13007,6742,0,41,1], + ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,114,1,14653,4587,0,18,1], + ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,110,1,73028,5712,0,27,2], + ["p1",5,"op","chess_char_5_diy1_a",2,null,2638,1,117,1,8944,5276,0,13,0], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,73,2,51862,6822,0,33,1], + ["p1",7,"op","chess_char_5_diy1_a",2,null,1133,1,4,0,14757,0,0,56,3], + ["p1",8,"op","chess_char_5_diy2_b",2,"none",1360,1,7,0,16360,0,0,56,3], + ["p1",9,"op","chess_char_5_diy1_a",2,null,1218,1,9,0,0,0,0,0,0], + ["p1",10,"op","chess_char_5_diy2_b",2,"none",1374,1,11,0,0,0,0,0,0], + ["p1",11,"op","chess_char_5_diy1_a",2,null,932,1,13,0,13331,0,0,57,2], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,70,0,14245,2262,0,38,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_10065_ftzlc":[63,7,56,12751,169866],"enemy_10067_ftsjc":[30,0,30,23858,64809]}, + "hooks": [1,117,5760,641,641,734,750,0,23,109,16,16,0,20287,95,37,93,86,1,26,0,0,8], + "events": {"atk":641,"deploy":22,"die":23,"dmg":750,"engage":10,"fx":83,"leak":86,"skill":32,"spawn":107,"status":196}, + "fx": {"dodge":8,"dp":5,"explode":14,"ignite":32,"slash":24}, + "snaps": "b735394e 44df6814 4ede3523 ceaca815 0990904d 0cf45d51 3f5f612a 3392f0f3 a2cb1d61 6c52af78 9415f3b8 49b70461 7f144b13 c9769b73 a22d4adb 6b2c276d 4b214962 73629044 b830e5dc 0cb0f98c" + }, + "diy-129": { + "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_m01", + "modeId": "mode_single_normal", + "round": 12, + "seed": 208547970, + "waveId": "act1autochess_h05", + "time": 213.77, + "ticks": 6413, + "reason": "cleared", + "killed": 40, + "total": 159, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 260, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":40,"total":159,"perfect":false,"leaks":119,"leaksUncounted":0,"coins":0,"dmg":227420,"bossDmg":0,"heal":4309,"deaths":28,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",0,null,0,0,6,9,34156,1865,0,26,1], + ["p1",2,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",0,0,9,7,75183,2374,0,31,1], + ["p1",3,"op","chess_char_5_diy1_a",2,null,1154,1,190,0,7213,9373,0,21,0], + ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",95.94,1,188,1,18424,10040,0,33,0], + ["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,166,13,44376,2544,0,48,1], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",758.45,1,189,7,35386,2416,0,37,1], + ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,4,0,0,1372,0,0,0], + ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",0,0,114,3,11915,4113,0,5,1], + ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,8,0,0,2434,2643,7,1], + ["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",0,0,103,0,0,5171,1666,3,1], + ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,1,0,0,1160,0,0,0], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",0,0,102,0,768,2860,0,2,1] + ], + "enemies": {"enemy_1009_lurker":[72,18,54,2183,64312],"enemy_1019_jshoot_2":[51,13,38,16138,60642],"enemy_1404_msnip":[36,9,27,27401,102466]}, + "hooks": [1,190,6413,485,485,717,754,15,68,187,8,22,0,8825,82,56,159,119,1,68,0,0,1], + "events": {"atk":560,"deploy":31,"die":68,"dmg":751,"engage":8,"fx":9,"heal":13,"leak":119,"skill":48,"spawn":171,"status":77}, + "fx": {"aoe":3,"displace":3,"dodge":1,"spGain":2}, + "snaps": "cb7c5d0f 22a665b0 b0241d3f 8234db69 6d6af92a 4df4630a fbb444f9 7e8e6ab5 d60faeb1 c543720a 31f9bc0d 2619ab64 9dc146d2 0df3dd1d 845dbd2b 7141ab9c aaad4844 c5a03dc3 284e9904 bb59254b 5385151c 09b91371" + }, + "diy-130": { + "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": "act2autochess_m02", + "modeId": "mode_multi_abyss", + "round": 13, + "seed": 2862651535, + "waveId": "act1autochess_h06", + "time": 220.7, + "ticks": 6621, + "reason": "cleared", + "killed": 1, + "total": 102, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 369, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":1,"total":102,"perfect":false,"leaks":101,"leaksUncounted":0,"coins":0,"dmg":937924,"bossDmg":0,"heal":87780,"deaths":16,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",2,null,0,0,118,0,443,7812,0,7,0], + ["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",0,0,121,0,1812,10293,0,15,0], + ["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,114,0,13093,5419,0,11,1], + ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",0,0,113,0,37999,8915,0,11,2], + ["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,108,0,91547,38701,0,80,4], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",0,0,107,0,159873,57383,0,81,4], + ["p1",7,"op","chess_char_6_diy1_a",2,null,1272,1,8,0,16194,0,0,45,2], + ["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",1643,1,9,1,125710,0,0,156,2], + ["p1",9,"op","chess_char_6_diy1_a",2,null,1372,1,13,0,31368,0,0,37,2], + ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",1819,1,4,0,459886,0,0,85,3], + ["p1",11,"op","chess_char_6_diy1_a",2,null,1224,1,6,0,0,0,40530,44,2], + ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",1464,1,10,0,0,0,47250,44,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_1267_nhpbr_2":[27,0,27,2985,177195],"enemy_1270_nhstlk_2":[36,1,35,21217,245338],"enemy_9006_actoxi":[39,0,39,104320,515392]}, + "hooks": [1,126,6621,804,804,1335,1327,183,17,118,24,23,0,28925,0,35,102,101,1,22,0,0,8], + "events": {"atk":921,"deploy":22,"die":17,"dmg":1327,"engage":10,"fx":66,"heal":181,"leak":101,"skill":47,"spawn":116,"status":36}, + "fx": {"aoe":14,"dodge":8,"dp":3,"slash":24,"spGain":17}, + "snaps": "991457ea e05dc464 b3371c0c 96324561 4e55a051 849b28ac 6aaff9a0 e19921e6 e0540cc5 9dda1ab1 0d299067 9b6f5ee2 e612af65 30447a83 843f3646 1d4d8894 581172e5 afeaf942 e3a403f5 27743746 e033f10e a3b5099b" + }, + "diy-131": { + "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": "act2autochess_m03", + "modeId": "mode_multi_funny", + "round": 13, + "seed": 1967364563, + "waveId": "act1autochess_h06", + "time": 213.2, + "ticks": 6396, + "reason": "cleared", + "killed": 34, + "total": 123, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 287, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":34,"total":123,"perfect":false,"leaks":89,"leaksUncounted":0,"coins":0,"dmg":303954,"bossDmg":0,"heal":0,"deaths":18,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,115,2,12461,3730,0,44,1], + ["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,94,11,80882,4988,0,58,2], + ["p1",3,"op","chess_char_6_diy1_a",2,null,1154,1,148,5,20771,8078,0,95,0], + ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",273.68,1,147,5,28658,11598,0,87,0], + ["p1",5,"op","chess_char_6_diy1_a",2,null,1160,1,6,7,64863,0,0,146,2], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",1450,1,7,4,96319,0,0,156,3], + ["-","~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_1006_shield_3":[21,0,21,13429,72237],"enemy_1043_zomsbr":[66,27,39,93,102526],"enemy_1402_tgshd_2":[36,7,29,14872,129191]}, + "hooks": [1,148,6396,693,693,920,1011,11182,52,141,8,21,0,10798,12327,37,123,89,1,52,0,0,0], + "events": {"atk":764,"deploy":22,"die":52,"dmg":945,"engage":6,"fx":503,"leak":89,"skill":47,"spawn":132,"status":927}, + "fx": {"displace":19,"dodge":5,"expose":479}, + "snaps": "d456be72 24986da0 263291b2 6601c930 43a57b58 57e6c19a e9793217 f2307500 9bf48dd7 a1e1185f f757a9dd 9feca978 f1099997 885b8762 df7edeeb d75a4c78 dbb2b23e 3942d750 75b12534 b102163d 3fee0d49 966739a7" } } }