diff --git a/server/sim/content/kits/index.js b/server/sim/content/kits/index.js index 99f513f5..3ab59902 100644 --- a/server/sim/content/kits/index.js +++ b/server/sim/content/kits/index.js @@ -103,6 +103,9 @@ export const OPERATOR_KIT_FILES = Object.freeze([ 'op-ifrit.js', 'op-aphris.js', 'op-lin.js', + 'op-nian.js', + 'op-hsgma2.js', + 'op-thumpy.js', ]); /** diff --git a/server/sim/content/kits/ops/op-hsgma2.js b/server/sim/content/kits/ops/op-hsgma2.js new file mode 100644 index 00000000..44c2af2b --- /dev/null +++ b/server/sim/content/kits/ops/op-hsgma2.js @@ -0,0 +1,291 @@ +// server/sim/content/kits/ops/op-hsgma2.js — 斩业星熊 (char_1044_hsgma2) 自选 operator kit: 6★ 驭法铁卫 (重装), an owned-6★ +// pick of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and her module at every form. +// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)"). +// +// Forms (data/backups.json units.char_1044_hsgma2, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module; +// elite = E2 Lv60, rank 7, AST-X at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account]. +// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json), gamedata_const +// ba.berserk 坚忍 ("根据已损失的生命值获得相应比例的属性加成,损失一定比例时达最大加成") and ba.sluggish 停顿, PRTS 斩业星熊 (业火 备注: +// 负生命值 — "期间持有禁疗,一切生命值变动将先计入负生命值来抵消/增加负生命值…只有负生命值达到上限时才会被正常击倒…负生命值与真实生命值 +// 无关", "生命恢复的提供方式为增加自身的“生命回复速度(百分比)”属性,不受治疗加成和禁疗影响", "需成功受到伤害,生命恢复效果才会失效并 +// 重新计时"; S1 备注 "反伤效果会直接选中伤害来源,但仅对阵营为敌方的来源造成伤害"; S2 备注 "治疗效果无视自身的禁疗,计算治疗量时以目标 +// 受伤前后的生命值差的绝对值为准", "投出的盾牌以自身前方1.2格为起点,逆时针旋转365°,角速度90°/s,碰撞半径1.0,碰撞范围内存在可选目标时 +// 速度-80%", "技能持续至盾牌旋转365°并收回时结束"; S3 备注 "技能期间攻击可对空", 临死模式 — 阻回 / 沉默 / 不死, allies' 不死 and the +// lethal excess as her 流失, the forced exit, "若携带X模组,则X模组的“造成额外伤害”效果依然生效"; AST-X 备注 — the extra damage's +// conditions "①此为普通攻击或普通攻击附带的伤害;②伤害来源于斩业星熊自身;③不为本模组特性的伤害…伤害为法术附加伤害", "仅对敌方 +// 单位反弹伤害,伤害为法术普通伤害"), PRTS 卫戍协议/帮助 技能策略 (her S3 row "于技能持续时间过半后…关闭技能(进入其技能效果的 +// 临死模式)" added in 上半, REMOVED with 下半; "通常不会自动关闭技能"). +// - Trait (驭法铁卫) "技能开启时普通攻击会造成法术伤害": physical attacks while no skill runs (the kit's trait dmgType — the +// data's 'arts' is the skill-on type) and arts while one runs (every skill's attack override); block 3, ground-only +// melee on her 1-1. Module AST-X 无迹 adds "且攻击和受到攻击时对目标额外造成10%攻击力的法术伤害" (trait bb atk_scale): every +// damage instance of her normal attacks (skill attacks included; not the shield, a counter or the module's own damage) +// is followed by atk_scale × ATK arts on that enemy (附加伤害: no dodge), and every enemy attack she takes returns +// atk_scale × ATK arts (普通伤害) to the attacker — whether a skill runs or not [ASSUMED: PRTS's list of the checked +// conditions names no skill period]. +// - T1 业火 "生命降至0时进入“我执”…": a lethal damage instance leaves her on the field in 我执 instead (the damage past her +// HP opens the negative pool — the engine keeps her at its 1-HP floor for the official 0); in 我执 every later damage +// instance goes to the pool (no HP loss; it still counts as damage taken: 受击回复 SP, counters) until the pool would +// reach max_minus_hp_ratio × max HP — that blow knocks her out as usual (unless a 不死 holds her); she holds 禁疗 (flags +// noHeal + healFree, the status 'healFree'); after [heal].interval s without a damage instance landing she gets the +// 生命回复速度(百分比) attribute [heal].hp_recovery_per_sec_by_max_hp_ratio (an hpRegenRatio buff — not 治疗: 禁疗 does +// not stop it, no 治疗加成 scales it); whatever raises her HP above the floor (that regeneration, S2's heal that ignores +// 禁疗) clears the pool first, and she leaves 我执 once it is empty. A 流失 that reaches 0 counts whole into the pool +// [ASSUMED: the engine reports no HP before a loss]. +// - T2 鬼之架势 "在场时,自身获得最高+50法术抗性和35%攻击力的坚忍(损失70%生命值时达到最大加成)": RES +min_magic_resistance and ATK +// +min_atk scaled linearly by the HP lost from max_hp_ratio down to min_hp_ratio (坚忍), re-read every tick; in 我执 at +// its maximum. AST-X stage 3: the maximum at 50 % lost (min_hp_ratio 0.5) and "达到最大加成时,攻击力额外+15%" (atk). +// - S1 恶业苦果 (AUTO, 受击回复 SP, 持续时间无限 — a toggle): ATK +atk, DEF +def, arts attacks; every enemy attack she takes +// deals atk_scale × ATK arts to the attacker (a direct pick of the damage source, enemies only). A self buff: the owner's +// AUTO rule fires it as soon as its SP is full (`trigger: 'SP_FULL'`, as 引星棘刺 S1 — kits/README.md checklist 5). +// - S2 无始无明 (AUTO, attack SP; data DEFAULT): the cast replaces the attack about to be made — the shield throw, three hits +// of attack@atk_scale × ATK arts on every enemy she blocks (none blocked: her target [ASSUMED]); then the shield circles +// her: from 1.2 tiles ahead, counter-clockwise 365° at 90°/s, ×0.2 while a targetable ground enemy [ASSUMED: no 对空 +// note, unlike S3] is within 1.0 of it; every `interval` s from the throw [ASSUMED] each such enemy takes 停顿 `sluggish` +// s and shield_atk_scale × ATK arts, and she heals heal_ratio × the HP it lost (ignoring her 禁疗). The skill runs until +// the turn is done (its return taken as instant [ASSUMED]): no SP meanwhile, and her normal attacks of the flight are arts. +// - S3 地狱变相 (MANUAL; data ACTIVE_RANGE on the running x-2 — the owner's rule over the 重装 exception, rawRule +// TAKE_DAMAGE): `duration` s — range x-2, max HP +max_hp, ATK +atk, two hits on up to attack@max_target enemies, air units +// too (PRTS 备注), arts. 主动关闭 (a close: `skill.end('manual')`) starts the 临死模式 for before_dead_duration s: the +// skill's bonuses and range stay, four hits, 阻回 + 沉默, 不死 (her negative pool never knocks her out); every other allied +// operator on her range holds 不死 and the part of a damage instance past its HP becomes her 流失 (a 流失 it takes is not +// passed on); then she leaves the field (a retreat with the death animation, as 史尔特尔's 余烬; the official partial DP +// refund of a withdrawal is not modelled — no retreat in the sim refunds DP). No strategy of this mode closes it: PRTS +// 帮助 lists the 上半 row that did as removed in 下半, so in a fight the skill simply runs its `duration`. + +import { num, talentBb, traitBb, skillRec, up } from '../shared/tier1.js'; +import { canTargetEnemy } from '../../../targeting.js'; +import { bodyInRadius } from '../../../body.js'; +import { hasHp } from '../../../damage.js'; +import { COLS } from '../../../constants.js'; + +const S1 = 'skchr_hsgma2_1'; +const S2 = 'skchr_hsgma2_2'; +const S3 = 'skchr_hsgma2_3'; +/** S2's shield (PRTS 备注): start 1.2 tiles ahead, 365° counter-clockwise at 90°/s, collision radius 1.0, −80 % near a target. */ +const SHIELD_START = 1.2; +const SHIELD_TURN = 365; +const SHIELD_SPEED = 90; +const SHIELD_RADIUS = 1.0; +const SHIELD_SLOW = 0.8; +/** An engine cap on S2's run (the shield's turn ends it long before: 365° at 18°/s takes 20.3 s). */ +const S2_CAP = 60; +/** S3's x-2 when the data carries no grid. */ +const X2 = Object.freeze([[2, -1], [2, 0], [2, 1], [1, -2], [1, -1], [1, 0], [1, 1], [1, 2], [0, -2], [0, -1], [0, 0], [0, 1], + [0, 2], [-1, -2], [-1, -1], [-1, 0], [-1, 1], [-1, 2], [-2, -1], [-2, 0], [-2, 1]]); +const GROUND = Object.freeze({ canHitFly: false }); +const EGO = 'talent:hsgma2:ego'; +const EGO_REGEN = 'talent:hsgma2:egoRegen'; +const BERSERK = 'talent:hsgma2:berserk'; +const DYING = 'skill:hsgma2:dying'; +const ASTX = 'hsgma2:astx'; + +const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; +const nz = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => Number.isFinite(v) && v !== 0)); +const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v); + +export default { + char_1044_hsgma2: (bb, chess) => { + const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1); + const tb = traitBb(chess); + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + const throwParts = { hits: 3, atkScale: num(b2['attack@atk_scale'], 1), hitAllBlocked: true }; + // this unit's attack overrides (the kit is built per unit): S2's throw turns into plain arts attacks after it lands, + // S3's two hits become four in the 临死模式 + const s2Attack = { dmgType: 'arts' }; + const s3Attack = { dmgType: 'arts', hits: 2 }; + const plainS2 = () => { delete s2Attack.hits; delete s2Attack.atkScale; delete s2Attack.hitAllBlocked; }; + + /** S2: the shield's centre after `turned` degrees (row 0 is the bottom row: counter-clockwise = a growing angle). */ + const shieldAt = (unit, sh) => { + const a = sh.a0 + (sh.turned * Math.PI) / 180; + return { x: unit.x + SHIELD_START * Math.cos(a), y: unit.y + SHIELD_START * Math.sin(a) }; + }; + const touching = (battle, unit, p) => battle.enemies.filter((e) => canTargetEnemy(unit, e, GROUND) && bodyInRadius(e, p.x, p.y, SHIELD_RADIUS)); + /** S2: one shield pulse — 停顿, the arts hit, and her heal of heal_ratio × the HP it removed (ignoring her 禁疗). */ + const shieldPulse = (battle, unit, p) => { + for (const e of touching(battle, unit, p)) { + battle.applyStatus(e, 'sluggish', { duration: num(b2.sluggish, 1), source: unit }); + if (!e.alive) continue; + const dealt = battle.dealDamage(unit, e, { amount: unit.s.atk * num(b2.shield_atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'hsgma2:shield'] }); + if (dealt > 0 && unit.alive) battle.heal(unit, unit, dealt * num(b2.heal_ratio), { self: true, ignoreHealFree: true }); + battle.fx('strike', { x: p.x, y: p.y, id: unit.id, skill: 'hsgma2:shield' }); + } + }; + + return { + trait: { dmgType: 'phys' }, + skills: { + [S1]: { + kind: 'toggle', trigger: { rule: 'SP_FULL' }, + mods: nz({ atkPct: num(b1.atk), defPct: num(b1.def) }), + attack: { dmgType: 'arts' }, + }, + [S2]: { + kind: 'duration', duration: S2_CAP, + attack: s2Attack, + onStart({ unit }) { Object.assign(s2Attack, throwParts); unit.mem.hsShield = null; }, + onAttack({ battle, unit }) { + if (!('hits' in s2Attack)) return; + plainS2(); // the throw was this attack: the shield flies from now on + const [fr, fc] = unit.fwd; + unit.mem.hsShield = { a0: Math.atan2(fr, fc), turned: 0, acc: 0 }; + battle.fx('zone', { x: unit.x, y: unit.y, radius: SHIELD_START + SHIELD_RADIUS, dur: SHIELD_TURN / SHIELD_SPEED, id: unit.id, skill: 'hsgma2:shield' }); + shieldPulse(battle, unit, shieldAt(unit, unit.mem.hsShield)); + }, + onTick({ battle, unit, skill, dt }) { + const sh = unit.mem.hsShield; + if (!sh) return; + const iv = Math.max(0.05, num(b2.interval, 0.5)); + sh.acc += dt; + while (sh.acc + 1e-9 >= iv && unit.mem.hsShield === sh) { sh.acc -= iv; shieldPulse(battle, unit, shieldAt(unit, sh)); } + if (unit.mem.hsShield !== sh) return; + const slowed = touching(battle, unit, shieldAt(unit, sh)).length > 0; + sh.turned += SHIELD_SPEED * (slowed ? 1 - SHIELD_SLOW : 1) * dt; + if (sh.turned >= SHIELD_TURN - 1e-9) skill.end('shield'); + }, + onEnd({ unit }) { unit.mem.hsShield = null; plainS2(); }, + }, + [S3]: { + kind: 'duration', + mods: nz({ hpPct: num(b3.max_hp), atkPct: num(b3.atk) }), + targeting: { rangeGrid: skillRec(chess, S3)?.rangeGrid ?? X2, maxTargets: Math.max(1, Math.floor(num(b3['attack@max_target'], 1))), canHitFly: true }, + attack: s3Attack, + onStart({ unit }) { s3Attack.hits = 2; unit.mem.hsDying = null; }, + onEnd({ battle, unit, skill, reason }) { + const dying = unit.mem.hsDying; + if (!dying && reason === 'manual' && unit.alive && unit.deployed) { + // 主动关闭 ⇒ 临死模式: the skill keeps running (its bonuses, range and override stay; no skillStart — it is + // no new cast) for before_dead_duration s with four hits, 阻回 + 沉默, and 不死 (the fatal hooks below) + const dur = num(b3.before_dead_duration, 11); + unit.mem.hsDying = { seq: unit.deploySeq }; + s3Attack.hits = 4; + skill.active = true; + skill.timeLeft = dur; + battle.addBuff(unit, { key: DYING, duration: dur, flags: { noSp: true, silence: true }, visible: true, tags: ['skill'] }); + battle.fx('undying', { x: unit.x, y: unit.y, id: unit.id }); + return; + } + if (!dying) return; + unit.mem.hsDying = null; + s3Attack.hits = 2; + battle.removeBuff(unit, DYING); + // "11秒后强制退出战场" (视为撤回干员) + if (reason === 'duration' && unit.alive && unit.deployed) battle.retreat(unit, { reason: 'retreat', dying: true }); + }, + }, + }, + talents: [ + { install(battle, unit) { // 业火: 我执 — the negative pool, 禁疗, the quiet-time regeneration + const capOf = () => num(t0.max_minus_hp_ratio, 2) * unit.s.maxHp; + const quiet = num(t0['hsgma2_t_1[heal].interval'], 5); + const regen = num(t0['hsgma2_t_1[heal].hp_recovery_per_sec_by_max_hp_ratio']); + const enter = (pool) => { + unit.mem.hsEgo = { pool: Math.max(0, pool), lastHurt: battle.time }; + battle.addBuff(unit, { key: EGO, status: 'healFree', flags: { noHeal: true, healFree: true }, tags: ['talent'] }); + battle.fx('undying', { x: unit.x, y: unit.y, id: unit.id, talent: 'hsgma2:ego' }); + }; + const leave = () => { + unit.mem.hsEgo = null; + battle.removeBuff(unit, EGO); + battle.removeBuff(unit, EGO_REGEN); + }; + battle.on('deploy', (c) => { if (c.unit === unit) { unit.mem.hsOverflow = false; leave(); } }, { owner: unit }); + // a damage instance (after the shields): into the pool while in 我执, or the lethal one opens it + battle.on('hpDamage', (c) => { + if (c.target !== unit || !unit.alive) return; + const ego = unit.mem.hsEgo; + if (ego) { + ego.lastHurt = battle.time; + if (ego.pool + c.amount < capOf()) { ego.pool += c.amount; c.amount = 0; } else unit.mem.hsOverflow = true; + return; + } + if (c.amount < unit.hp) return; + const over = c.amount - unit.hp; + if (over < capOf()) { c.amount = Math.max(0, unit.hp - 1); enter(over); } else unit.mem.hsOverflow = true; + }, { owner: unit, priority: -50 }); + // HP reaching 0 anyway: the 临死模式's 不死 holds her; a full pool knocks her out; a 流失 goes to the pool + battle.on('fatal', (c) => { + if (c.unit !== unit || c.prevented) return; + const overflow = !!unit.mem.hsOverflow; + unit.mem.hsOverflow = false; + const ego = unit.mem.hsEgo; + if (unit.mem.hsDying) { + c.prevented = true; + if (ego) ego.pool = Math.min(capOf(), ego.pool + c.amount); else enter(Math.min(capOf(), c.amount)); + return; + } + if (overflow) return; + const pool = (ego ? ego.pool : 0) + c.amount; + if (pool >= capOf()) return; + c.prevented = true; + if (ego) ego.pool = pool; else enter(pool); + }, { owner: unit, priority: -40 }); + battle.on('tick', () => { + unit.mem.hsOverflow = false; + const ego = unit.mem.hsEgo; + if (!ego) return; + if (!up(unit)) { leave(); return; } + // HP above the 1-HP floor (her regeneration, a heal that ignores 禁疗) clears the pool first + const surplus = unit.hp - 1; + if (surplus > 0 && ego.pool > 0) { const take = Math.min(surplus, ego.pool); ego.pool -= take; unit.hp -= take; } + if (!(ego.pool > 1e-9)) { leave(); return; } + const want = regen > 0 && battle.time - ego.lastHurt >= quiet - 1e-9; + const has = !!unit.findBuff(EGO_REGEN); + if (want && !has) battle.addBuff(unit, { key: EGO_REGEN, mods: { hpRegenRatio: regen }, tags: ['talent'] }); + else if (!want && has) battle.removeBuff(unit, EGO_REGEN); + }, { owner: unit }); + } }, + { install(battle, unit) { // 鬼之架势: 坚忍 RES / ATK by the HP lost; AST-X stage 3: ATK +atk more at the maximum + const hi = num(t1.max_hp_ratio, 1), lo = num(t1.min_hp_ratio, 0.3); + const res0 = num(t1.max_magic_resistance), res1 = num(t1.min_magic_resistance); + const atk0 = num(t1.max_atk), atk1 = num(t1.min_atk), extra = num(t1.atk); + if (!(hi > lo) || !(res0 || res1 || atk0 || atk1)) return; + battle.on('tick', () => { + const cur = unit.findBuff(BERSERK); + if (!up(unit)) { if (cur) battle.removeBuff(unit, BERSERK); return; } + const f = clamp01((hi - unit.hpRatio) / (hi - lo)); + if (cur && Math.abs((cur.data?.f ?? -1) - f) < 1e-4) return; + const mods = nz({ resFlat: res0 + (res1 - res0) * f, atkPct: atk0 + (atk1 - atk0) * f + (f >= 1 - 1e-9 ? extra : 0) }); + if (!Object.keys(mods).length) { if (cur) battle.removeBuff(unit, BERSERK); return; } + battle.addBuff(unit, { key: BERSERK, mods, data: { f }, tags: ['talent'] }); + }, { owner: unit }); + } }, + ], + install(battle, unit) { + // S1 恶业苦果: every enemy attack she takes ⇒ atk_scale × ATK arts back to the attacker + battle.on('damaged', (c) => { + const s = c.source; + if (c.target !== unit || !unit.alive || unit.skill?.id !== S1 || !unit.skill.active) return; + if (!s || s.side !== 'enemy' || !s.alive || !c.dmg?.isAttack) return; + battle.dealDamage(unit, s, { amount: unit.s.atk * num(b1.atk_scale), type: 'arts', canDodge: false, isSkill: true, ignoreSelect: true, tags: ['skill', 'counter'] }); + battle.fx('counter', { x: s.x, y: s.y, id: unit.id }); + }, { owner: unit }); + // AST-X 无迹: her attack damage ⇒ +atk_scale × ATK arts (附加伤害); an enemy attack she takes ⇒ atk_scale × ATK arts back + const astx = num(tb.atk_scale); + if (astx > 0) { + battle.on('damaged', (c) => { + const t = c.target; + if (c.source !== unit || !c.dmg?.isAttack || c.type === 'element' || !t || t.side !== 'enemy' || !hasHp(t)) return; + battle.dealDamage(unit, t, { amount: unit.s.atk * astx, type: 'arts', canDodge: false, tags: ['module', ASTX] }); + }, { owner: unit, priority: -20 }); + battle.on('damaged', (c) => { + const s = c.source; + if (c.target !== unit || !unit.alive || !s || s.side !== 'enemy' || !s.alive || !c.dmg?.isAttack) return; + battle.dealDamage(unit, s, { amount: unit.s.atk * astx, type: 'arts', ignoreSelect: true, tags: ['module', ASTX, 'counter'] }); + }, { owner: unit }); + } + // S3 临死模式: every other allied operator on her range holds 不死; the part of a damage instance past its HP is her 流失 + const shelters = (t) => !!unit.mem.hsDying && up(unit) && !!t && t !== unit && t.side === 'ally' && t.kind === 'op' && t.alive + && !!unit.rangeKeySet && unit.rangeKeySet.has(t.tileR * COLS + t.tileC); + battle.on('hpDamage', (c) => { + const t = c.target; + if (!shelters(t) || c.amount < t.hp) return; + const over = c.amount - t.hp; + c.amount = Math.max(0, t.hp - 1); + if (over > 0) battle.loseHp(unit, over, { source: c.credit ?? null, tags: ['skill', 'hsgma2:shoulder'] }); + }, { owner: unit, priority: -60 }); + battle.on('fatal', (c) => { if (!c.prevented && shelters(c.unit)) c.prevented = true; }, { owner: unit, priority: -40 }); + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/op-nian.js b/server/sim/content/kits/ops/op-nian.js new file mode 100644 index 00000000..43cd983c --- /dev/null +++ b/server/sim/content/kits/ops/op-nian.js @@ -0,0 +1,170 @@ +// server/sim/content/kits/ops/op-nian.js — 年 (char_2014_nian) 自选 operator kit: 6★ 铁卫 (重装), an owned-6★ pick of the +// tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form. +// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)"). +// +// Forms (data/backups.json units.char_2014_nian, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module; +// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account]. +// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json), gamedata_const +// ba.shield 护盾 ("每层护盾可以抵挡一次伤害") and ba.buffres 抵抗, PRTS 年 (积甲成山 备注 "根据重装干员数量获得的治疗效果加成与 +// 生命上限加成叠加时以加法叠加"; 干明可鉴 备注 "攻击力与防御力增益持续时间无限"; 铜印 备注 "沉默效果与反伤效果会直接选中伤害来源, +// 但仅对阵营为敌方的来源产生效果"; 铁御 技能范围 x-2). +// - Trait (铁卫) "能够阻挡三个敌人": the profession default (block 3 from the data stats), ground-only physical melee on her +// 1-1. Module PRO-X 混沌阵列 adds "阻挡敌人时防御力+20%" (trait bb def): DEF + while she blocks at least one enemy. +// Module PRO-Y “请勿叩门” "能够阻挡四个敌人" (attr block_cnt +1, already in the stats). From stage 2 its trait part carries +// the blackboard of 积甲成山's module text (battle_equip_table: TRAIT part max_hp / heal_scale / heal_scale_addition / +// heal_scale_max_value / max_stack_cnt, the talent part only the team bonus) "场上每有一名重装干员,自身受到治疗效果和生命上限 +// 额外提升4%,最多叠加3层": n = the 重装 operators on the field, her included [ASSUMED: "每有一名重装干员" counts every 重装 on +// the field] and a teammate's in a shared field (alliesFor), at most max_stack_cnt — max HP +max_hp × n (直接乘算 with her +// other "+%" bonuses) and healing received ×(heal_scale + heal_scale_addition × n), at most heal_scale_max_value (the +// PRTS note's 加法叠加: the stacks add up, 104 / 108 / 112 %). Re-counted every tick. +// - T1 积甲成山 "编入队伍时,所有【重装】职业干员的生命上限+16%": every 重装 operator of her player's team (her included) max HP +// +max_hp for the whole battle, deployed or not (the kits' 编入队伍时 convention: 斯卡蒂 深海掠食者, 早露 学生楷模). PRO-Y +// stage 3: +21 % (the module talent change). +// - T2 干明可鉴 "部署后立即获得3层护盾": `times` 护盾 layers at every deployment (a hit-negating barrier: buff shieldHits — each +// layer negates one damage instance). PRO-X stage 2+ (talent change atk / def / sp / max_stack_cnt) "每层护盾破裂时,攻击力 +// +7%,防御力+7%且获得3点技力": each broken layer one more stack of ATK / DEF + (at most max_stack_cnt; "持续时间无限": until she +// leaves the field [ASSUMED: a deployment's layers give a deployment's stacks]) and +sp SP (none while a skill runs: AK's +// no-SP-during-a-skill rule, tier1 giveSp). +// - S1 锡灼 (MANUAL, time SP; data DEFAULT — the owner's 重装 cast-in-range exception, rawRule TAKE_DAMAGE): `duration` s of +// DEF +def and ATK +atk; normal attacks deal arts damage. +// - S2 铜印 (MANUAL; data DEFAULT): `duration` s — stops attacking; DEF +def, block +block_cnt; every enemy attack she takes +// (each damage instance of one, as 星熊 荆棘 / 泡泡) deals atk_scale × ATK arts to the attacker and silences it `silence` +// s ("失去特殊能力"): a direct pick of the damage source (ignoreSelect), enemies only (PRTS 备注), even when a 护盾 layer +// negated the hit. +// - S3 铁御 (MANUAL; data DEFAULT — its x-2 is a 技能范围 for the allies, no attack-range change): `duration` s of ATK +// +[self].atk (block +[self].block_cnt = 0); every other allied operator on her x-2 ("周围其他友方干员": summons are no 干员 +// [ASSUMED]) gets DEF +[ally].def, block +[ally].block_cnt and 抵抗 (the engine `resist` status, value +// −one_minus_status_resistance), refreshed every AURA s while the skill runs; two 年 of a shared field keep the stronger +// (installAura's rule). The DEF / block buff ends with the skill, the 抵抗 within AURA_DUR. + +import { num, talentBb, traitBb, skillRec, statBuff, toggleBuff, up, giveSp } from '../shared/tier1.js'; +import { absoluteRangeKeys } from '../../../targeting.js'; +import { COLS } from '../../../constants.js'; + +const S1 = 'skchr_nian_1'; +const S2 = 'skchr_nian_2'; +const S3 = 'skchr_nian_3'; +/** 铁御's aura: refresh period and buff lifetime (s) — the tier-4 kits' AURA / AURA_DUR. */ +const AURA = 0.2; +const AURA_DUR = 0.25; +/** 铁御's 技能范围 x-2 when the data carries no grid. */ +const X2 = Object.freeze([[2, -1], [2, 0], [2, 1], [1, -2], [1, -1], [1, 0], [1, 1], [1, 2], [0, -2], [0, -1], [0, 0], [0, 1], + [0, 2], [-1, -2], [-1, -1], [-1, 0], [-1, 1], [-1, 2], [-2, -1], [-2, 0], [-2, 1]]); +const SHIELD = 'talent:nian:shield'; +const BREAK = 'talent:nian:shieldBreak'; +const GUARD = 'skill:nian:ironGuard'; +const FORT = 'trait:nian:fortify'; + +const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; +/** A 【重装】 operator (summons are never 重装 operators). */ +const isTank = (a) => !!a && a.kind === 'op' && a.def?.profession === 'TANK'; +/** The mods of `o` without its zero entries. */ +const nz = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => Number.isFinite(v) && v !== 0)); + +export default { + char_2014_nian: (bb, chess) => { + const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1); + const tb = traitBb(chess); + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + const guardGrid = skillRec(chess, S3)?.rangeGrid ?? X2; + const allyDef = num(b3['nian_s_3[ally].def']), allyBlock = num(b3['nian_s_3[ally].block_cnt']); + const resist = Math.min(0.95, Math.max(0, -num(b3.one_minus_status_resistance))); + /** 铁御: the other allied operators on her x-2 get the DEF / block buff and 抵抗 for AURA_DUR. */ + const ironGuard = (battle, unit) => { + const keys = new Set(absoluteRangeKeys(guardGrid, unit.tileR, unit.tileC, unit.dir, 0)); + for (const a of battle.alliesFor(unit)) { + if (a === unit || a.kind !== 'op' || a.hidden || !keys.has(a.tileR * COLS + a.tileC)) continue; + const cur = a.findBuff(GUARD); + if (!(cur && cur.source !== unit && (cur.data?.v ?? 0) > allyDef && cur.timeLeft > 0.05)) { + battle.addBuff(a, { key: GUARD, duration: AURA_DUR, mods: nz({ defPct: allyDef, blockCnt: allyBlock }), source: unit, data: { v: allyDef }, tags: ['skill', 'aura'] }); + } + if (resist > 0) battle.applyStatus(a, 'resist', { duration: AURA_DUR, value: resist, source: unit }); + } + }; + return { + skills: { + [S1]: { kind: 'duration', mods: nz({ defPct: num(b1.def), atkPct: num(b1.atk) }), attack: { dmgType: 'arts' } }, + [S2]: { kind: 'duration', mods: nz({ defPct: num(b2.def), blockCnt: num(b2.block_cnt) }), attack: { noAttack: true } }, + [S3]: { + kind: 'duration', + mods: nz({ atkPct: num(b3['nian_s_3[self].atk']), blockCnt: num(b3['nian_s_3[self].block_cnt']) }), + onStart({ battle, unit }) { + unit.mem.nianGuardAcc = 0; + ironGuard(battle, unit); + battle.fx('buff', { x: unit.x, y: unit.y, id: unit.id, kind: 'nian:ironGuard' }); + }, + onTick({ battle, unit, dt }) { + unit.mem.nianGuardAcc = (unit.mem.nianGuardAcc ?? 0) + dt; + if (unit.mem.nianGuardAcc + 1e-9 < AURA) return; + unit.mem.nianGuardAcc -= AURA; + ironGuard(battle, unit); + }, + onEnd({ battle, unit }) { + for (const a of battle.allyUnits) { + const b = a.findBuff(GUARD); + if (b && b.source === unit) battle.removeBuff(a, b); + } + }, + }, + }, + talents: [ + { install(battle, unit) { // 积甲成山: every 重装 operator of her team max HP +max_hp (PRO-Y stage 3: the module's value) + const hp = num(t0.max_hp); + if (!(hp > 0)) return; + for (const a of battle.allyUnits) if (isTank(a) && a.ownerId === unit.ownerId) statBuff(battle, a, 'talent:nian:armor', { hpPct: hp }); + } }, + { install(battle, unit) { // 干明可鉴: `times` 护盾 layers at every deployment; PRO-X stage 2+: ATK / DEF stack + SP per broken layer + const times = Math.max(0, Math.floor(num(t1.times))); + if (!(times > 0)) return; + const atk = num(t1.atk), def = num(t1.def), sp = num(t1.sp), cap = Math.max(1, Math.floor(num(t1.max_stack_cnt, 1))); + battle.on('deploy', (c) => { + if (c.unit !== unit) return; + unit.mem.nianLayers = times; + battle.addBuff(unit, { key: SHIELD, shieldHits: times, visible: true, tags: ['talent'] }); + battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id, n: times }); + }, { owner: unit }); + if (!(atk || def || sp)) return; + battle.on('damaged', (c) => { + if (c.target !== unit || !unit.alive) return; + const left = unit.findBuff(SHIELD)?.shieldHits ?? 0; + const broken = (unit.mem.nianLayers ?? 0) - left; + if (!(broken > 0)) return; + unit.mem.nianLayers = left; + for (let i = 0; i < broken; i++) { + if (atk || def) battle.addBuff(unit, { key: BREAK, refresh: 'stack', stacks: 1, maxStacks: cap, mods: nz({ atkPct: atk, defPct: def }), visible: true, tags: ['talent'] }); + giveSp(unit, sp); + } + battle.fx('shieldBreak', { x: unit.x, y: unit.y, id: unit.id }); + }, { owner: unit }); + } }, + ], + install(battle, unit) { + // PRO-X 混沌阵列: 阻挡敌人时防御力+20% + const blockDef = num(tb.def); + if (blockDef) toggleBuff(battle, unit, 'trait:nian:block', () => unit.blocking.length > 0, { defPct: blockDef }); + // PRO-Y stage 2+: per 重装 operator on the field max HP +max_hp and healing received +heal_scale_addition (≤ the cap) + const perHp = num(tb.max_hp), healAdd = num(tb.heal_scale_addition), stackCap = Math.floor(num(tb.max_stack_cnt)); + if ((perHp > 0 || healAdd > 0) && stackCap > 0) { + const healBase = num(tb.heal_scale, 1), healMax = num(tb.heal_scale_max_value, Infinity); + battle.on('tick', () => { + const cur = unit.findBuff(FORT); + const n = up(unit) ? Math.min(stackCap, battle.alliesFor(unit).filter(isTank).length) : 0; + if ((cur?.data?.n ?? 0) === n) return; + if (!(n > 0)) { battle.removeBuff(unit, FORT); return; } + battle.addBuff(unit, { key: FORT, mods: nz({ hpPct: perHp * n, healingTakenMul: Math.min(healMax, healBase + healAdd * n) }), data: { n }, tags: ['trait'] }); + }, { owner: unit }); + } + // S2 铜印: every enemy attack she takes ⇒ atk_scale × ATK arts back to the attacker + silence `silence` s + const scale = num(b2.atk_scale), sil = num(b2.silence); + battle.on('damaged', (c) => { + const src = c.source; + if (c.target !== unit || !unit.alive || unit.skill?.id !== S2 || !unit.skill.active) return; + if (!src || src.side !== 'enemy' || !src.alive || !c.dmg?.isAttack) return; + if (scale > 0) battle.dealDamage(unit, src, { amount: unit.s.atk * scale, type: 'arts', canDodge: false, isSkill: true, ignoreSelect: true, tags: ['skill', 'counter'] }); + if (sil > 0 && src.alive) battle.applyStatus(src, 'silence', { duration: sil, source: unit }); + battle.fx('counter', { x: src.x, y: src.y, id: unit.id }); + }, { owner: unit }); + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/op-thumpy.js b/server/sim/content/kits/ops/op-thumpy.js new file mode 100644 index 00000000..b458150c --- /dev/null +++ b/server/sim/content/kits/ops/op-thumpy.js @@ -0,0 +1,301 @@ +// server/sim/content/kits/ops/op-thumpy.js — 珊比 (char_4235_thumpy) 自选 operator kit: 6★ 本源铁卫 (重装), an owned-6★ +// pick of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and her module at every form. +// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)"). +// +// Forms (data/backups.json units.char_4235_thumpy, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module; +// elite = E2 Lv60, rank 7, PRP-X at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account]. +// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json), gamedata_const +// ba.dt.erosion2 侵蚀损伤·我方 / ba.barrier 屏障 / ba.sluggish 停顿, PRTS 珊比 (talent 备注 "每次珊比成功造成伤害后,产生侵蚀 +// 损伤/冷却减少效果;且实际上侵蚀损伤与冷却减少为互斥的效果…侵蚀损伤爆发期间将改为减少当前爆发持续时间", "每次珊比成功造成伤害后, +// 才会对目标施加用于检测侵蚀损伤爆发的【脚板标记】…珊比退场时清除所有已施加的标记", "若触发天赋时珊比已有来自此天赋的屏障,则改为 +// 为已有屏障Buff累加伤害吸收量"; S1 备注 "推动效果为径向推力,攻击到未被阻挡的敌人时也会生效"; S2 备注 — the spraying order, +// "强力胶的效果施加无视无法选择,但其不会对隐藏、隐匿、无敌的单位产生停顿与伤害效果", "停顿效果先于此技能的物理伤害生效", "追加侵蚀 +// 损伤的“其他干员”判断以伤害的来源为准,无来源伤害始终无法触发此效果"; S3 备注 — the conveyor "影响自身前方4格可通行地块(不包括 +// 自身所在格,中心判定)内的所有未被阻挡的地面敌方敌人,可影响隐匿单位,无法影响自缚单位", "每帧…传送0.8/30格…卫戍协议为每秒20帧", +// "伤害效果影响自身所在格及正前方4格内所有地面敌方单位,以及所有被自身阻挡的敌方单位"). +// - Trait (本源铁卫) "能够阻挡三个敌人,可以造成元素损伤": the profession default (block 3), ground-only physical melee on her +// own tile (range 0-1). Module PRP-X 出发的勇气 adds "阻挡敌人时,自身造成的元素损伤提升15%" (trait bb ep_damage_scale): +// every element fill she deals ×ep_damage_scale while she blocks — an `elementHit` multiplier (the kits' convention: 菲莱 / +// 余 PRP-X). +// - T1 探险理论: "珊比受到的元素损伤降低15%" (ep_damage_scale 0.85): an `elementHit` multiplier on her at priority 20 (the +// operators' 受到的元素损伤 convention, docs/SIM.md §7.2). "珊比造成物理伤害时,附带攻击力10%的侵蚀损伤,并使目标的侵蚀损伤冷却减少 +// 1秒" (ep_damage_ratio[trigger] / duration_dec): after each physical damage instance of hers that removes HP — attacks, +// the glue and conveyor ticks too ("每次珊比成功造成伤害后") — ep_damage_ratio[trigger] × ATK 侵蚀损伤 on the target, or, +// while its 侵蚀 burst (爆发冷却) runs, that cooldown −duration_dec s instead. The skills' own ep_damage_ratio[trigger] 0 +// is read as their ability carrying no rider of its own [ASSUMED: PRTS names no exception to the talent]. +// - T2 坚硬脚板: her HP damage marks the enemy (【脚板标记】, kept through a 重生, cleared when she leaves the field); a marked +// enemy's 侵蚀 burst — whoever filled it — gives her one stack of DEF +def (PRP-X stage 3: and ATK +atk; flat, at most +// max_stack_cnt, until she leaves the field [ASSUMED]) and shield_value 屏障 (one barrier buff: a further trigger adds to +// it), at most scale × her max HP. +// - S1 “还不走?” (MANUAL; data DEFAULT — the owner's 重装 cast-in-range exception, rawRule TAKE_DAMAGE): `duration` s of +// ATK / DEF +; she strikes every enemy she blocks at once and pushes each one she strikes with 小力 (attack@force 0) — +// radially from her (PRTS 备注), Battle.push's official 力度 − 重量 distance. +// - S2 “慢慢走~” (MANUAL; DEFAULT): `duration` s of ATK / DEF +atk / +def. At the cast she sprays glue on her own tile (not +// counted) and max_cnt − 1 (4, "占据4格") passable ground tiles by PRTS's order — front tile, its right, its left, its +// front (front blocked: her left, her right), then spreading from the last sprayed tile not spread yet: right, left, +// front — left / right / front being hers (glueTiles). Every `interval` s, the cast included [ASSUMED: the first pulse at +// the cast], every ground enemy on the glue [ASSUMED: the glue lies on the ground; flyers pass over it] — untargetable +// ones too, never a 隐藏 / 隐匿 / 无敌 one — takes 停顿 `sluggish` s, then atk_scale × ATK physical. While it lies, +// another allied operator's physical damage that removes HP from an enemy on the glue adds ep_damage_ratio[damage] × her +// ATK 侵蚀损伤 from her (her PRP-X scales it) [ASSUMED: the rider is hers]; 无来源 damage never does. +// - S3 “不准走!” (MANUAL; DEFAULT): `duration` s — block +block_cnt, strikes every blocked enemy at once, ATK / DEF +. The +// conveyor: the passable tiles among the max_cnt − 1 tiles ahead of her (not hers); every tick each unblocked ground enemy +// standing on one (by its centre tile), 重量 ≤ mass_level, not 自缚 — 隐匿 ones too — is carried towards her after its own +// movement by CONVEYOR_FRAME × CONVEYOR_FPS × dt: the official 0.8 / 30 tile per frame at 卫戍协议's 20 frames per second +// (PRTS: 0.533 tiles/s here, not the regular game's 0.8), never into an impassable tile (keeping 0.2 tile off its edge on a +// horizontal belt). Every `interval` s, the cast included [ASSUMED], every targetable ground enemy on her tile and the +// max_cnt − 1 tiles ahead and every enemy she blocks takes atk_scale × ATK physical; an enemy there whose 侵蚀 bursts +// loses thumpy[ep_break_water].def DEF more — permanent and stacking, as the burst's own −120 [ASSUMED]. + +import { num, talentBb, traitBb, skillRec, up } from '../shared/tier1.js'; +import { offsetTile, frontOf, dirVec } from '../../../dir.js'; +import { bodyInKeys } from '../../../body.js'; +import { enemyStealthed, tileKeyOf } from '../../../targeting.js'; +import { hasHp } from '../../../damage.js'; +import { COLS } from '../../../constants.js'; + +const S1 = 'skchr_thumpy_1'; +const S2 = 'skchr_thumpy_2'; +const S3 = 'skchr_thumpy_3'; +/** PRTS S3 备注: the conveyor carries 0.8 / 30 tile per frame ("传送带矢量"; conveyor_speed / 30) … */ +const CONVEYOR_FRAMES = 30; +/** … and 卫戍协议 runs 20 frames per second ("如果游戏的实际运行帧率发生改变(例如卫戍协议为每秒20帧),位移量也将随之变化"). */ +const CONVEYOR_FPS = 20; +/** PRTS S3 备注: a horizontal belt keeps its enemies 0.2 tile off the edge of an impassable tile. */ +const BELT_EDGE = 0.2; +/** "占据4格" / "向前展开4格" when the blackboard has no max_cnt (it counts her own tile too: max_cnt − 1). */ +const TILES = 4; +/** Her facing-RIGHT frame: right hand, left hand, front ([dRow, dCol]; row 0 is the bottom row — sim/dir.js). */ +const RIGHT = Object.freeze([-1, 0]); +const LEFT = Object.freeze([1, 0]); +const FRONT = Object.freeze([0, 1]); +const SOLE = 'talent:thumpy:sole'; +const BARRIER = 'talent:thumpy:barrier'; +const BELT_BREAK = 'skill:thumpy:beltBreak'; + +const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; +const nz = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => Number.isFinite(v) && v !== 0)); +/** + * "可用地块" of the glue / belt: passable ground of the field with no obstacle on it (a 射击台 / mound / 阻隔工事 tile is no + * ground an enemy stands on; the mode's stages have no 假高台 / 坑洞 tiles). + */ +const usable = (battle, r, c) => battle.grid.groundPassable(r, c); + +/** + * S2's glue tiles (PRTS 备注 order): her own tile when usable (not counted, never spreads), then `count` tiles — the front + * tile, its right, its left and its front (front unusable: her left, then her right), then spreading from the last sprayed + * tile that has not spread yet to its right, left and front, until `count` tiles or nothing left to spread. Absolute keys. + */ +function glueTiles(battle, unit, count) { + const keys = []; + const sprayed = new Set(); + const list = []; + const abs = (lr, lc) => offsetTile(unit.tileR, unit.tileC, lr, lc, unit.dir); + const spray = (lr, lc) => { + if (list.length >= count || sprayed.has(`${lr},${lc}`)) return; + const [r, c] = abs(lr, lc); + if (!usable(battle, r, c)) return; + sprayed.add(`${lr},${lc}`); + list.push({ lr, lc, spread: false }); + keys.push(r * COLS + c); + }; + const near = ([lr, lc], [dr, dc]) => [lr + dr, lc + dc]; + if (usable(battle, unit.tileR, unit.tileC)) { sprayed.add('0,0'); keys.push(unit.tileR * COLS + unit.tileC); } + const front = [0, 1]; + if (usable(battle, ...abs(...front))) { + for (const d of [[0, 0], RIGHT, LEFT, FRONT]) spray(...near(front, d)); + } else { + for (const d of [LEFT, RIGHT]) spray(...near([0, 0], d)); + } + while (list.length < count) { + let at = null; + for (let i = list.length - 1; i >= 0; i--) if (!list[i].spread) { at = list[i]; break; } + if (!at) break; + at.spread = true; + for (const d of [RIGHT, LEFT, FRONT]) spray(...near([at.lr, at.lc], d)); + } + return keys; +} + +export default { + char_4235_thumpy: (bb, chess) => { + const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1); + const tb = traitBb(chess); + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + const glueCount = Math.max(0, Math.round(num(b2.max_cnt, TILES + 1)) - 1); + const beltLen = Math.max(0, Math.round(num(b3.max_cnt, TILES + 1)) - 1); + const beltRate = num(b3.conveyor_speed) / CONVEYOR_FRAMES * CONVEYOR_FPS; + const massCap = num(b3.mass_level, 4); + + /** S2: one glue pulse — 停顿 first, then the physical hit (PRTS 备注), on every ground enemy on the glue. */ + const gluePulse = (battle, unit, glue) => { + for (const e of battle.enemies) { + if (!e.alive || e.hidden || e.isFlying || !bodyInKeys(e, glue) || enemyStealthed(e) || e.s.flags.invulnerable) continue; + battle.applyStatus(e, 'sluggish', { duration: num(b2.sluggish), source: unit }); + if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * num(b2.atk_scale), type: 'phys', isSkill: true, ignoreSelect: true, tags: ['skill', 'thumpy:glue'] }); + } + }; + /** S3: the enemies the belt damages — targetable ground enemies on her tile and the tiles ahead, and every one she blocks. */ + const beltVictims = (battle, unit, belt) => { + const out = battle.enemiesInKeys(belt.hurtKeys, unit, { canHitFly: false }); + for (const e of battle.blockedTargets(unit, unit.profile)) if (!out.includes(e)) out.push(e); + return out; + }; + /** S3: the conveyor carries the unblocked ground enemies on its tiles towards her (after their own movement). */ + const carry = (battle, unit, belt, dt) => { + const [fr, fc] = belt.fwd; + const step = beltRate * dt; + if (!(step > 0)) return; + for (const e of battle.enemies) { + if (!e.alive || e.hidden || e.isFlying || e.blockedBy || e.s.flags.selfBound || e.s.massLevel > massCap) continue; + if (!belt.carryKeys.has(tileKeyOf(e))) continue; + let nx = e.x - fc * step, ny = e.y - fr * step; + const r = Math.round(ny), c = Math.round(nx); + if (!usable(battle, r, c)) { + // never into an impassable tile; a horizontal belt keeps BELT_EDGE off its edge + const er = Math.round(e.y), ec = Math.round(e.x); + if (fc !== 0) nx = ec - fc * (0.5 - BELT_EDGE); else ny = er - fr * (0.5 - 1e-3); + if (fc !== 0 ? (nx - e.x) * -fc <= 0 : (ny - e.y) * -fr <= 0) continue; + } + e.x = nx; e.y = ny; + if (e.route) e.route.pts = null; + } + }; + + return { + skills: { + [S1]: { + kind: 'duration', + mods: nz({ atkPct: num(b1.atk), defPct: num(b1.def) }), + attack: { + hitAllBlocked: true, + onEachHit({ battle, unit, target }) { + if (target && target.alive && target.side === 'enemy') battle.push(target, num(b1['attack@force']), { from: unit }); + }, + }, + }, + [S2]: { + kind: 'duration', + mods: nz({ atkPct: num(b2.atk), defPct: num(b2.def) }), + onStart({ battle, unit }) { + const keys = glueTiles(battle, unit, glueCount); + unit.mem.thumpyGlue = { keys: new Set(keys), acc: 0 }; + const mid = (f) => keys.reduce((s, k) => s + f(k), 0) / Math.max(1, keys.length); + battle.fx('zone', { x: mid((k) => k % COLS), y: mid((k) => Math.floor(k / COLS)), radius: 1.2, dur: num(skillRec(chess, S2)?.duration), id: unit.id, skill: 'thumpy:glue' }); + gluePulse(battle, unit, unit.mem.thumpyGlue.keys); + }, + onTick({ battle, unit, dt }) { + const g = unit.mem.thumpyGlue; + if (!g) return; + const iv = Math.max(0.05, num(b2.interval, 0.7)); + g.acc += dt; + while (g.acc + 1e-9 >= iv && unit.alive) { g.acc -= iv; gluePulse(battle, unit, g.keys); } + }, + onEnd({ unit }) { unit.mem.thumpyGlue = null; }, + }, + [S3]: { + kind: 'duration', + mods: nz({ atkPct: num(b3.atk), defPct: num(b3.def), blockCnt: num(b3.block_cnt) }), + attack: { hitAllBlocked: true }, + onStart({ battle, unit }) { + const ahead = []; + for (let k = 1; k <= beltLen; k++) ahead.push(frontOf(unit.tileR, unit.tileC, unit.dir, k)); + const inField = ahead.filter(([r, c]) => battle.grid.inRect(r, c)); + const belt = { + fwd: dirVec(unit.dir), acc: 0, + carryKeys: new Set(inField.filter(([r, c]) => usable(battle, r, c)).map(([r, c]) => r * COLS + c)), + hurtKeys: [unit.tileR * COLS + unit.tileC, ...inField.map(([r, c]) => r * COLS + c)], + }; + belt.hurtSet = new Set(belt.hurtKeys); + unit.mem.thumpyBelt = belt; + const [fr, fc] = belt.fwd, half = (beltLen + 1) / 2; + battle.fx('zone', { x: unit.x + fc * half, y: unit.y + fr * half, radius: half, dur: num(skillRec(chess, S3)?.duration), id: unit.id, skill: 'thumpy:belt' }); + for (const e of beltVictims(battle, unit, belt)) battle.dealDamage(unit, e, { amount: unit.s.atk * num(b3.atk_scale), type: 'phys', isSkill: true, tags: ['skill', 'thumpy:belt'] }); + }, + onTick({ battle, unit, dt }) { + const belt = unit.mem.thumpyBelt; + if (!belt) return; + carry(battle, unit, belt, dt); + const iv = Math.max(0.05, num(b3.interval, 1)); + belt.acc += dt; + while (belt.acc + 1e-9 >= iv && unit.alive) { + belt.acc -= iv; + for (const e of beltVictims(battle, unit, belt)) battle.dealDamage(unit, e, { amount: unit.s.atk * num(b3.atk_scale), type: 'phys', isSkill: true, tags: ['skill', 'thumpy:belt'] }); + } + }, + onEnd({ unit }) { unit.mem.thumpyBelt = null; }, + }, + }, + talents: [ + { install(battle, unit) { // 探险理论: 元素损伤 taken ×ep_damage_scale; her physical damage ⇒ 侵蚀损伤 or the burst −duration_dec s + const taken = num(t0.ep_damage_scale, 1); + if (taken !== 1) { + battle.on('elementHit', (c) => { + if (c.target === unit && c.dmg?.type === 'element') c.dmg.mul *= taken; + }, { owner: unit, priority: 20 }); + } + const ratio = num(t0['ep_damage_ratio[trigger]']), dec = num(t0.duration_dec); + battle.on('damaged', (c) => { + const t = c.target; + if (c.source !== unit || c.type !== 'phys' || !(c.amount > 0) || !t || t.side !== 'enemy' || !hasHp(t)) return; + const lock = t.findBuff('erosionBurst'); + if (lock) { if (dec > 0) lock.timeLeft = Math.max(0, lock.timeLeft - dec); return; } + if (ratio > 0) battle.dealDamage(unit, t, { type: 'element', element: 'erosion', amount: unit.s.atk * ratio, tags: ['talent', 'thumpy:theory'] }); + }, { owner: unit, priority: -10 }); + } }, + { install(battle, unit) { // 坚硬脚板: her HP damage marks; a marked enemy's 侵蚀 burst ⇒ DEF (+ATK) stack and 屏障 + const def = num(t1.def), atk = num(t1.atk), shield = num(t1.shield_value), cap = Math.max(1, Math.floor(num(t1.max_stack_cnt, 1))); + const capHp = num(t1.scale, 3); + if (!(def || atk || shield)) return; + const marks = () => unit.mem.thumpyMarks ?? (unit.mem.thumpyMarks = new Set()); + battle.on('deploy', (c) => { if (c.unit === unit) marks().clear(); }, { owner: unit }); + battle.on('death', (c) => { if (c.unit === unit) marks().clear(); }, { owner: unit }); + battle.on('damaged', (c) => { + if (c.source === unit && c.target && c.target.side === 'enemy' && c.type !== 'element' && c.amount > 0) marks().add(c.target); + }, { owner: unit, priority: 10 }); + battle.on('elementBurst', (c) => { + const t = c.target; + if (c.element !== 'erosion' || !t || t.side !== 'enemy' || !up(unit) || !marks().has(t)) return; + if (def || atk) battle.addBuff(unit, { key: SOLE, refresh: 'stack', stacks: 1, maxStacks: cap, mods: nz({ defFlat: def, atkFlat: atk }), visible: true, tags: ['talent'] }); + if (shield > 0) { + const max = capHp * unit.s.maxHp; + const cur = unit.findBuff(BARRIER); + if (cur) { cur.shield = Math.min(max, (cur.shield || 0) + shield); unit.markDirty(); } + else battle.addBuff(unit, { key: BARRIER, shield: Math.min(max, shield), visible: true, tags: ['talent'] }); + } + battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id, talent: 'thumpy:sole' }); + }, { owner: unit }); + } }, + ], + install(battle, unit) { + // PRP-X: 阻挡敌人时,自身造成的元素损伤提升15% + const epScale = num(tb.ep_damage_scale, 1); + if (epScale !== 1) { + battle.on('elementHit', (c) => { + if (c.source === unit && c.dmg?.type === 'element' && c.target?.side === 'enemy' && unit.blocking.length > 0) c.dmg.mul *= epScale; + }, { owner: unit }); + } + // S2: another operator's physical damage on an enemy in the glue ⇒ ep_damage_ratio[damage] × her ATK 侵蚀损伤 + const rider = num(b2['ep_damage_ratio[damage]']); + if (rider > 0) { + battle.on('damaged', (c) => { + const g = unit.mem.thumpyGlue, s = c.source, t = c.target; + if (!g || !up(unit) || !s || s === unit || s.side !== 'ally' || s.kind !== 'op' || c.type !== 'phys' || !(c.amount > 0)) return; + if (!t || t.side !== 'enemy' || t.isFlying || !hasHp(t) || !bodyInKeys(t, g.keys)) return; + battle.dealDamage(unit, t, { type: 'element', element: 'erosion', amount: unit.s.atk * rider, tags: ['skill', 'thumpy:glue'] }); + }, { owner: unit }); + } + // S3: an enemy on the belt's tiles (or blocked by her) whose 侵蚀 bursts ⇒ DEF −30 more, for good + const breakDef = num(b3['thumpy[ep_break_water].def']); + if (breakDef) { + battle.on('elementBurst', (c) => { + const belt = unit.mem.thumpyBelt, t = c.target; + if (!belt || !up(unit) || c.element !== 'erosion' || !t || t.side !== 'enemy') return; + if (t.blockedBy !== unit && (t.isFlying || !bodyInKeys(t, belt.hurtSet))) return; + battle.addBuff(t, { key: BELT_BREAK, refresh: 'stack', stacks: 1, maxStacks: 1e6, mods: { defFlat: breakDef }, source: unit }); + }, { owner: unit }); + } + }, + }; + }, +}; diff --git a/test/content/op_hsgma2.test.js b/test/content/op_hsgma2.test.js new file mode 100644 index 00000000..3f1902de --- /dev/null +++ b/test/content/op_hsgma2.test.js @@ -0,0 +1,391 @@ +// test/content/op_hsgma2.test.js — the 自选 operator kit of 斩业星熊 (char_1044_hsgma2, 6★ 驭法铁卫; kit +// server/sim/content/kits/ops/op-hsgma2.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 AST-X +// 无迹 at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form of that slot status); +// the fidelity checklist of kits/README.md item by item. +// Run: node --test test/content/op_hsgma2.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 HSG = 'char_1044_hsgma2'; +const FORMS = BACKUPS.units[HSG].forms; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const ASTX = 'uniequip_002_hsgma2'; +const S1 = 'skchr_hsgma2_1', S2 = 'skchr_hsgma2_2', S3 = 'skchr_hsgma2_3'; +const TEXAS = 'chess_char_1_08_a', YAK = 'chess_char_1_02_a'; +const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0']; +const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id); +const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null); +const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`); +const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o }); +const ENEMIES = { + enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }), + enemy_shooter: dummy('enemy_shooter', { atk: 300, bat: 1, range: 4 }), +}; +/** Every 自选 form: [tier, elite, module]. */ +const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, ASTX].map((m) => [t, true, m]))]; +const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`; + +/** A battle with 斩业星熊 as uid 1 at (row, col) facing RIGHT, plus `others`. */ +function field({ tier = 5, elite = false, mod = null, skill = 2, row = 10, col = 5, others = [], seed = 5 } = {}) { + const h = makeBattle({ + defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, + flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'heal', 'skillStart', 'skillEnd', 'statusApplied', 'fatal', 'death'], captureNoisy: true, + units: [{ uid: 1, diy: { slot: SLOT[tier], charId: HSG, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others], + }); + h.step(); + return { h, u: h.unit(1) }; +} +const skillBuff = (u) => u.findBuff(`skill:${u.id}`); +const noSp = (h, u) => h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true }, duration: Infinity }); +const mine = (h, u, from = 0) => h.hooksOf('damaged').slice(from).filter((c) => c.source === u && c.target?.side === 'enemy'); +function done(h) { + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); +} + +test('斩业星熊 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 1-1 range, blocks 3, melee ground-only, physical attacks while no skill runs (the data\'s arts is the skill-on type), 炎, no 特质', () => { + assert.equal(OPERATOR_KITS[HSG], KITS[HSG]); + 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], [HSG, 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, 15], `${label(f)}: stats`); + assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat], [3, 'melee', false, 'phys', 1.6], `${label(f)}: 驭法铁卫`); + assert.equal(form.dmgType, 'arts', 'the data carries the skill-on type'); + assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`); + assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['yanShip'], []], `${label(f)}: bonds / 特质`); + assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth && !u.s.flags.untargetable, `${label(f)}: ground enemies target her`); + done(h); + } + } + // the numbers of the forms (zh_CN): E2 Lv1 2592 / 533 / 431, E2 Lv60 3228 / 622 / 517; AST-X +190 / +55 → +325 / +80 + 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], [2592, 533, 3228, 622]); + assert.deepEqual([modOf(5, ASTX).attr, modOf(6, ASTX).attr], [{ maxHp: 190, atk: 55 }, { maxHp: 325, atk: 80 }]); +}); + +test('a 自选 pick: 斩业星熊 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => { + const data = { chess: CHESS, backups: BACKUPS }; + assert.ok(KITTED_CHARS.includes(HSG)); + for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(HSG), `tier ${t}`); + assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: HSG, skillIndex: 2, uniEquipId: ASTX } }, { data, kitted: KITTED_CHARS }), + { ok: true, picks: { [SLOT[6]]: { charId: HSG, skillIndex: 2, uniEquipId: ASTX } } }); +}); + +test('triggers: S1 (AUTO, self buff) fires at full SP with no enemy (the kit\'s SP_FULL); S2 (AUTO) keeps the data DEFAULT; S3 (MANUAL) the data\'s ACTIVE_RANGE on its x-2 (rawRule TAKE_DAMAGE): an enemy two tiles ahead casts it, a shot from outside the x-2 does not', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const form = formOf(tier, elite); + assert.deepEqual(form.skills.map((s) => [s.skillType, s.trigger.rule, s.trigger.rawRule]), [['AUTO', 'DEFAULT', 'DEFAULT'], ['AUTO', 'DEFAULT', 'DEFAULT'], ['MANUAL', 'ACTIVE_RANGE', 'TAKE_DAMAGE']], `T${tier}: data`); + assert.deepEqual(form.skills[2].trigger.customRangeGrid, form.skills[2].rangeGrid, `T${tier}: the running x-2`); + // S1: no enemy at all + const a = field({ tier, elite, skill: 0 }); + assert.deepEqual([a.u.skill.rule, a.u.skill.spType, a.u.skill.kind], ['SP_FULL', 'hurt', 'toggle'], `T${tier} S1`); + a.u.skill.gainSp(999); + assert.ok(a.h.runUntil(() => a.u.skill.active, 0.2), `T${tier} S1: cast at once, no enemy`); + done(a.h); + // S2: waits for an attack + const b = field({ tier, elite, skill: 1 }); + assert.deepEqual([b.u.skill.rule, b.u.skill.spType], ['DEFAULT', 'attack'], `T${tier} S2`); + b.u.skill.gainSp(999); + b.h.run(2); + assert.equal(b.u.skill.activations, 0, `T${tier} S2: no target, no cast`); + b.h.spawn('enemy_dummy', { pos: [10, 6] }); + assert.ok(b.h.runUntil(() => b.u.skill.activations === 1, 2), `T${tier} S2: cast on her next attack`); + done(b.h); + // S3: the x-2 + const c = field({ tier, elite, skill: 2 }); + assert.equal(c.u.skill.rule, 'ACTIVE_RANGE', `T${tier} S3`); + c.h.spawn('enemy_shooter', { pos: [10, 9] }); + c.u.skill.gainSp(999); + c.h.run(3); + assert.ok(c.h.hooksOf('damaged').some((x) => x.target === c.u), `T${tier} S3: she is being shot`); + assert.equal(c.u.skill.activations, 0, `T${tier} S3: a hit from outside the x-2 casts nothing`); + c.h.spawn('enemy_dummy', { pos: [10, 7] }); + assert.ok(c.h.runUntil(() => c.u.skill.activations === 1, 1), `T${tier} S3: an enemy two tiles ahead (outside her 1-1) casts it`); + done(c.h); + } +}); + +test('S1 恶业苦果 (持续时间无限): ATK / DEF +40 % / +55 %, arts attacks, every enemy attack she takes returns 100 % / 130 % ATK arts to the attacker (ranged too); it never ends on its own', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S1); + assert.deepEqual([sk.duration, sk.spCost, sk.initSp, sk.bb.atk, sk.bb.def, sk.bb.atk_scale], [-1, 15, 0, elite ? 0.55 : 0.4, elite ? 0.55 : 0.4, elite ? 1.3 : 1], `T${tier}`); + const { h, u } = field({ tier, elite, skill: 0 }); + const near = h.spawn('enemy_dummy', { pos: [10, 5] }), far = h.spawn('enemy_shooter', { pos: [10, 8] }); + h.run(2); + const b0 = h.hooksOf('damaged').length; + h.b.dealDamage(near, u, { amount: 10, type: 'phys', isAttack: true }); + assert.ok(!mine(h, u, b0).some((c) => c.dmg.tags?.includes('counter')), `T${tier}: no counter before the cast`); + assert.ok(mine(h, u).filter((c) => c.dmg.isAttack).every((c) => c.type === 'phys'), `T${tier}: physical attacks before`); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 0.2), `T${tier}: cast`); + assert.deepEqual(skillBuff(u).mods, { atkPct: sk.bb.atk, defPct: sk.bb.def }, `T${tier}: the skill buff`); + for (const src of [near, far]) { + const n = h.hooksOf('damaged').length; + h.b.dealDamage(src, u, { amount: 10, type: 'phys', isAttack: true }); + const back = mine(h, u, n).filter((c) => c.target === src && c.dmg.tags?.includes('counter')); + assert.equal(back.length, 1, `T${tier}: a counter on ${src.defId}`); + assert.equal(back[0].type, 'arts'); + approx(back[0].amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`); + } + const n1 = h.hooksOf('damaged').length; + h.run(60); + assert.ok(u.skill.active, `T${tier}: still on after 60 s`); + const atk = mine(h, u, n1).filter((c) => c.dmg.isAttack && c.target === near); + assert.ok(atk.length > 10 && atk.every((c) => c.type === 'arts'), `T${tier}: arts attacks while it runs`); + done(h); + } +}); + +test('S2 无始无明 (AUTO, attack SP 9 / 7): the cast\'s attack throws the shield — 3 hits of 55 % / 70 % ATK arts on every blocked enemy; the shield then turns 365° at 90°/s (4.06 s) around her, no SP meanwhile, her attacks of the flight in arts; then plain physical attacks and SP again', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S2); + assert.deepEqual([sk.spCost, sk.initSp, sk.bb['attack@atk_scale'], sk.bb.shield_atk_scale, sk.bb.sluggish, sk.bb.interval, sk.bb.heal_ratio], + [elite ? 7 : 9, 0, elite ? 0.7 : 0.55, elite ? 1.1 : 0.8, 1, 0.5, elite ? 0.15 : 0.1], `T${tier}`); + const { h, u } = field({ tier, elite, skill: 1 }); + // two enemies on her own tile, blocked: the shield (1.2 tiles out, radius 1.0) never reaches her centre + const a = h.spawn('enemy_dummy', { pos: [10, 5] }), b = h.spawn('enemy_dummy', { pos: [10, 5] }); + assert.ok(h.runUntil(() => u.blocking.length === 2, 1), `T${tier}: both blocked`); + u.skill.gainSp(999); + const n0 = h.hooksOf('damaged').length; + assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`); + const t0 = h.b.time; + const throwHits = mine(h, u, n0).filter((c) => c.dmg.isAttack); + assert.equal(new Set(throwHits.map((c) => c.dmg.attackId)).size, 1, `T${tier}: one attack`); + for (const e of [a, b]) { + const hits = throwHits.filter((c) => c.target === e); + assert.equal(hits.length, 3, `T${tier}: 3 hits on each blocked enemy`); + for (const c of hits) { assert.equal(c.type, 'arts'); approx(c.amount, u.s.atk * sk.bb['attack@atk_scale'], `T${tier}: ${sk.bb['attack@atk_scale'] * 100} %`); } + } + const sp0 = u.skill.sp; + const n1 = h.hooksOf('damaged').length; + assert.ok(h.runUntil(() => !u.skill.active, 10), `T${tier}: the turn ends it`); + approx(h.b.time - t0, 365 / 90, `T${tier}: 365° at 90°/s`, 0.02); + assert.equal(u.skill.sp, sp0, `T${tier}: no SP during the flight`); + const flight = mine(h, u, n1).filter((c) => c.dmg.isAttack); + assert.ok(flight.length >= 2 && flight.every((c) => c.type === 'arts'), `T${tier}: ${flight.length} arts attacks during it`); + assert.ok(!mine(h, u, n1).some((c) => c.dmg.tags?.includes('hsgma2:shield')), `T${tier}: nothing within 1.0 of the shield`); + const n2 = h.hooksOf('damaged').length; + h.run(4); + const after = mine(h, u, n2).filter((c) => c.dmg.isAttack); + assert.ok(after.length > 0 && after.every((c) => c.type === 'phys' && Math.abs(c.amount - Math.max(u.s.atk * 0.05, u.s.atk)) < 1e-6), `T${tier}: plain physical attacks after`); + assert.ok(u.skill.sp > 0, `T${tier}: attack SP again`); + done(h); + } +}); + +test('S2 shield: within 1.0 of it a targetable ground enemy slows it to 18°/s; every 0.5 s from the throw each such enemy takes 停顿 1 s and 80 % / 110 % ATK arts, and she heals 10 % / 15 % of the HP it lost (through her 禁疗 too); a flyer is untouched', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S2); + const { h, u } = field({ tier, elite, skill: 1 }); + const front = h.spawn('enemy_dummy', { pos: [10, 6] }), fly = h.spawn('enemy_fly', { pos: [11, 5] }); + h.b.addBuff(u, { key: 'test:healFree', flags: { healFree: true, noHeal: true } }); // her 禁疗 (as in 我执) + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`); + const t0 = h.b.time; + const atk = u.s.atk; // at full HP (no 坚忍): the hits and heals below use it + h.runUntil(() => h.hooksOf('damaged').some((c) => c.source === u && c.dmg.tags?.includes('hsgma2:shield')), 1); + u.hp = 100; // from now on her heals show in her HP (坚忍 then raises her ATK: amounts are checked on the first hit) + h.runUntil(() => !u.skill.active, 15); + // slowed while the shield is within 1.0 of the enemy one tile ahead: |θ| ≤ acos(0.6) at both ends of the turn + const slow = (2 * Math.acos(0.6) * 180) / Math.PI - 0; + const startArc = (Math.acos(0.6) * 180) / Math.PI, endArc = 365 - (360 - startArc); + const want = startArc / 18 + endArc / 18 + (365 - startArc - endArc) / 90; + approx(h.b.time - t0, want, `T${tier}: ${want.toFixed(2)} s with the slowed arcs (${slow.toFixed(1)}°)`, 0.02); + const shield = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg.tags?.includes('hsgma2:shield')); + assert.ok(shield.length >= 10 && shield.every((c) => c.target === front && c.type === 'arts'), `T${tier}: ${shield.length} shield hits, the front enemy only`); + approx(shield[0].amount, atk * sk.bb.shield_atk_scale, `T${tier}: ${sk.bb.shield_atk_scale * 100} % ATK`); + const sl = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish'); + assert.equal(sl.length, shield.length, `T${tier}: 停顿 with every hit`); + assert.ok(sl.every((c) => c.target === front && Math.abs(c.duration - 1) < 1e-9), `T${tier}: 1 s`); + const heals = h.hooksOf('heal').filter((c) => c.target === u && c.source === u); + assert.ok(heals.length >= shield.length, `T${tier}: a heal per hit`); + approx(heals[0].amount, shield[0].amount * sk.bb.heal_ratio, `T${tier}: ${sk.bb.heal_ratio * 100} % of the HP lost`); + assert.ok(u.hp > 100, `T${tier}: healed through 禁疗`); + assert.ok(!h.hooksOf('damaged').some((c) => c.source === u && c.target === fly), `T${tier}: the flyer untouched`); + done(h); + } +}); + +test('S3 地狱变相 (32 s): range x-2, max HP +55 % / +70 %, ATK +150 % / +180 %, two arts hits on up to 2 enemies — air units too; back to 1-1 and physical after', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S3); + assert.deepEqual([sk.duration, sk.spCost, sk.initSp, sk.rangeId, sk.bb.max_hp, sk.bb.atk, sk.bb['attack@max_target'], sk.bb.before_dead_duration], + [32, elite ? 50 : 55, elite ? 31 : 28, 'x-2', elite ? 0.7 : 0.55, elite ? 1.8 : 1.5, 2, 11], `T${tier}`); + const { h, u } = field({ tier, elite, skill: 2 }); + const fly = h.spawn('enemy_fly', { pos: [10, 7] }); + const g1 = h.spawn('enemy_dummy', { pos: [9, 6] }), g2 = h.spawn('enemy_dummy', { pos: [11, 6] }), g3 = h.spawn('enemy_dummy', { pos: [10, 6] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: cast`); + assert.deepEqual(skillBuff(u).mods, { hpPct: sk.bb.max_hp, atkPct: sk.bb.atk }, `T${tier}: the skill buff`); + approx(u.s.maxHp, u.base.maxHp * (1 + sk.bb.max_hp), `T${tier}: max HP`); + assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: x-2 while it runs`); + const n0 = h.hooksOf('damaged').length; + h.run(6); + const atk = mine(h, u, n0).filter((c) => c.dmg.isAttack); + const byAttack = new Map(); + for (const c of atk) byAttack.set(c.dmg.attackId, [...(byAttack.get(c.dmg.attackId) ?? []), c]); + assert.ok(byAttack.size >= 2, `T${tier}: attacks`); + for (const [, list] of byAttack) { + const targets = new Set(list.map((c) => c.target.id)); + assert.equal(targets.size, 2, `T${tier}: two of the four enemies on her x-2 per attack`); + for (const id of targets) assert.equal(list.filter((c) => c.target.id === id).length, 2, `T${tier}: two hits each`); + assert.ok(list.every((c) => c.type === 'arts'), `T${tier}: arts`); + } + // with the ground enemies gone, the flyer on her x-2 is struck (技能期间攻击可对空) + for (const g of [g1, g2, g3]) h.b.kill(g, null); + const n1 = h.hooksOf('damaged').length; + h.run(3); + assert.ok(mine(h, u, n1).some((c) => c.target === fly && c.dmg.isAttack && c.type === 'arts'), `T${tier}: air units too`); + u.skill.extend(-999); + h.step(); + assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: back to 1-1`); + approx(u.s.maxHp, u.base.maxHp, `T${tier}: max HP back`); + done(h); + } +}); + +test('S3 临死模式 (a 主动关闭 only — no strategy of this mode closes it): 11 s with the bonuses kept, four hits, 阻回 + 沉默 + 不死; an allied operator on her range survives at 1 HP and the excess is her 流失, one outside falls; then she leaves the field; a plain stop or the time running out does nothing of it', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const { h, u } = field({ tier, elite, skill: 2, others: [{ uid: 2, chessId: TEXAS, row: 11, col: 6 }, { uid: 3, chessId: YAK, row: 12, col: 9 }] }); + const tex = h.unit(2), yak = h.unit(3); + const e = h.spawn('enemy_dummy', { pos: [10, 6] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: cast`); + h.run(2); + u.skill.end('manual'); + assert.ok(u.skill.active && u.mem.hsDying, `T${tier}: the 临死模式 keeps the skill running`); + approx(u.skill.timeLeft, 11, `T${tier}: 11 s`); + assert.deepEqual(u.findBuff('skill:hsgma2:dying')?.flags, { noSp: true, silence: true }, `T${tier}: 阻回 + 沉默`); + assert.equal(u.skill.spec.attack.hits, 4, `T${tier}: four hits`); + assert.ok(skillBuff(u), `T${tier}: the bonuses stay`); + assert.equal(h.hooksOf('skillStart').filter((c) => c.unit === u).length, 1, `T${tier}: no second cast`); + // 不死 for her … + h.b.dealDamage(e, u, { amount: 1e9, type: 'true' }); + assert.ok(u.alive, `T${tier}: she cannot be knocked out`); + // … and for 德克萨斯 on her x-2 (the excess becomes her 流失); 角峰 outside falls + const over = 500; + h.b.dealDamage(e, tex, { amount: tex.hp + over, type: 'true' }); + assert.deepEqual([tex.alive, tex.hp], [true, 1], `T${tier}: 德克萨斯 at 1 HP`); + const loss = h.hooksOf('damaged').filter((c) => c.target === u && c.dmg?.tags?.includes('hsgma2:shoulder')); + assert.equal(loss.length, 1, `T${tier}: one 流失`); + approx(loss[0].amount, over, `T${tier}: the excess`); + assert.ok(u.alive && u.mem.hsEgo, `T${tier}: (she is in 我执 since the blow above; 不死 holds her)`); + h.b.dealDamage(e, yak, { amount: yak.hp + 10, type: 'true' }); + assert.equal(yak.alive, false, `T${tier}: 角峰 (off her range) falls`); + h.run(11.1); + const out = h.hooksOf('death').filter((c) => c.unit === u); + assert.deepEqual(out.map((c) => [c.reason, c.dying]), [['retreat', true]], `T${tier}: she leaves the field after 11 s`); + done(h); + // a plain stop / the duration: no 临死模式 + for (const how of ['stop', 'time']) { + const g = field({ tier, elite, skill: 2 }); + g.h.spawn('enemy_dummy', { pos: [10, 6] }); + g.u.skill.gainSp(999); + assert.ok(g.h.runUntil(() => g.u.skill.active, 1)); + if (how === 'stop') g.u.skill.stop(); else g.h.runUntil(() => !g.u.skill.active, 40); + assert.deepEqual([g.u.skill.active, !!g.u.mem.hsDying, g.u.alive], [false, false, true], `T${tier} ${how}: no 临死模式`); + done(g.h); + } + } +}); + +test('T1 业火 我执: a lethal hit leaves her on the field (HP floor) with the excess as negative HP; later damage goes there (受击回复 SP still), 禁疗 meanwhile; 200 % of max HP knocks her out; 5 s without damage ⇒ 生命回复速度 5 %/s clears it and she leaves 我执', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const t0 = formOf(tier, elite).talents.find((t) => t.index === 0).bb; + assert.deepEqual([t0.max_minus_hp_ratio, t0['hsgma2_t_1[heal].interval'], t0['hsgma2_t_1[heal].hp_recovery_per_sec_by_max_hp_ratio']], [2, 5, 0.05], `T${tier}`); + const { h, u } = field({ tier, elite, skill: 0, others: [{ uid: 2, chessId: TEXAS, row: 12, col: 9 }] }); + const e = h.spawn('enemy_dummy', { pos: [10, 8] }); + h.step(); + const max = u.s.maxHp; + h.b.dealDamage(e, u, { amount: u.hp + 300, type: 'true' }); + assert.ok(u.alive && u.mem.hsEgo, `T${tier}: in 我执`); + assert.equal(u.hp, 1, `T${tier}: the engine's HP floor`); + approx(u.mem.hsEgo.pool, 300, `T${tier}: the excess`); + assert.deepEqual(u.findBuff('talent:hsgma2:ego')?.flags, { noHeal: true, healFree: true }, `T${tier}: 禁疗`); + const sp = u.skill.sp; + h.b.dealDamage(e, u, { amount: 500, type: 'true' }); + assert.deepEqual([u.hp, Math.round(u.mem.hsEgo.pool), u.skill.sp], [1, 800, sp + 1], `T${tier}: into the pool, +1 受击回复 SP`); + assert.equal(h.b.heal(h.unit(2), u, 1000), 0, `T${tier}: no heal from others`); + // every HP change goes to the negative HP first: a heal that passes 禁疗 (S2's) lowers it, a 流失 raises it + h.b.heal(u, u, 200, { self: true, ignoreHealFree: true }); + h.step(); + assert.deepEqual([u.hp, Math.round(u.mem.hsEgo.pool)], [1, 600], `T${tier}: a heal that ignores 禁疗 clears negative HP first`); + h.b.loseHp(u, 200, { source: e }); + assert.deepEqual([u.alive, u.hp, Math.round(u.mem.hsEgo.pool)], [true, 1, 800], `T${tier}: a 流失 adds to it`); + // quiet: the regeneration starts at 5 s and clears the pool at 5 % max HP / s + h.run(4.9); + assert.equal(u.findBuff('talent:hsgma2:egoRegen'), null, `T${tier}: not before 5 s`); + h.run(0.2); + assert.deepEqual(u.findBuff('talent:hsgma2:egoRegen')?.mods, { hpRegenRatio: 0.05 }, `T${tier}: 生命回复速度(百分比)`); + const t1 = h.b.time; + assert.ok(h.runUntil(() => !u.mem.hsEgo, 30), `T${tier}: out of 我执`); + approx(h.b.time - t1, 800 / (0.05 * max), `T${tier}: 800 at ${0.05 * max} / s`, 0.03); + assert.ok(!u.findBuff('talent:hsgma2:ego') && !u.findBuff('talent:hsgma2:egoRegen'), `T${tier}: 禁疗 / regeneration gone`); + // again into 我执, then 200 % + h.b.dealDamage(e, u, { amount: u.hp + 100, type: 'true' }); + assert.ok(u.mem.hsEgo, `T${tier}: 我执 again`); + h.b.dealDamage(e, u, { amount: 2 * max - 100 - 1, type: 'true' }); + assert.ok(u.alive, `T${tier}: just under 200 %`); + h.b.dealDamage(e, u, { amount: 5, type: 'true' }); + assert.equal(u.alive, false, `T${tier}: 200 % knocks her out`); + assert.equal(u.removeReason, 'killed'); + done(h); + } +}); + +test('T2 鬼之架势 坚忍: RES up to +50 and ATK up to +35 % by the HP lost, full at 70 % lost; AST-X stage 3: full at 50 % lost and then ATK +15 % more', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const x3 = elite && mod === ASTX && tier === 6; + const t1 = formOf(tier, elite).talents.find((t) => t.index === 1).bb; + const { h, u } = field({ tier, elite, mod, skill: 0 }); + const tb = u.def.raw.talents.find((t) => t.index === 1).bb; + assert.deepEqual(tb, x3 ? { ...t1, min_hp_ratio: 0.5, atk: 0.15 } : t1, `${label(f)}: data`); + const lo = x3 ? 0.5 : 0.3; + for (const r of [1, 0.85, lo, 0.1]) { + u.hp = r * u.s.maxHp; + h.step(); + const fac = Math.min(1, (1 - r) / (1 - lo)); + const b = u.findBuff('talent:hsgma2:berserk'); + if (fac === 0) { assert.equal(b, null, `${label(f)} ${r}: nothing at full HP`); continue; } + approx(b.mods.resFlat, 50 * fac, `${label(f)} ${r}: RES`); + approx(b.mods.atkPct, 0.35 * fac + (x3 && fac >= 1 ? 0.15 : 0), `${label(f)} ${r}: ATK`); + } + approx(u.s.res, 15 + 50, `${label(f)}: RES 65 at the most`); + done(h); + } +}); + +test('AST-X 无迹 trait (stages 1 and 3, skill or not): every damage instance of her attacks adds 10 % ATK arts (no dodge) on that enemy; every enemy attack she takes returns 10 % ATK arts; none without the module', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const x = elite && mod === ASTX; + if (x) assert.deepEqual(modOf(tier, ASTX).traitOverride.bb, { atk_scale: 0.1 }, label(f)); + const { h, u } = field({ tier, elite, mod, skill: 2 }); + noSp(h, u); + const e = h.spawn('enemy_dummy', { pos: [10, 6] }); + h.run(3.5); + const atk = mine(h, u).filter((c) => c.dmg.isAttack); + const extra = mine(h, u).filter((c) => c.dmg.tags?.includes('hsgma2:astx')); + assert.ok(atk.length >= 2, `${label(f)}: attacks`); + assert.equal(extra.length, x ? atk.length : 0, `${label(f)}: one extra per attack hit`); + if (x) for (const c of extra) { assert.deepEqual([c.type, c.dmg.canDodge, c.dmg.isAttack], ['arts', false, false]); approx(c.amount, 0.1 * u.s.atk, `${label(f)}: 10 % ATK`); } + const n = h.hooksOf('damaged').length; + h.b.dealDamage(e, u, { amount: 10, type: 'phys', isAttack: true }); + const back = mine(h, u, n).filter((c) => c.target === e && c.dmg.tags?.includes('hsgma2:astx')); + assert.equal(back.length, x ? 1 : 0, `${label(f)}: returned when attacked`); + if (x) approx(back[0].amount, 0.1 * u.s.atk, `${label(f)}: 10 % ATK back`); + done(h); + } +}); diff --git a/test/content/op_nian.test.js b/test/content/op_nian.test.js new file mode 100644 index 00000000..68b6ff51 --- /dev/null +++ b/test/content/op_nian.test.js @@ -0,0 +1,290 @@ +// test/content/op_nian.test.js — the 自选 operator kit of 年 (char_2014_nian, 6★ 铁卫; kit +// server/sim/content/kits/ops/op-nian.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, PRO-X +// 混沌阵列 or PRO-Y “请勿叩门” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form +// of that slot status); the fidelity checklist of kits/README.md item by item. +// Run: node --test test/content/op_nian.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 NIAN = 'char_2014_nian'; +const FORMS = BACKUPS.units[NIAN].forms; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const PROX = 'uniequip_002_nian', PROY = 'uniequip_003_nian'; +const S1 = 'skchr_nian_1', S2 = 'skchr_nian_2', S3 = 'skchr_nian_3'; +const YAK = 'chess_char_1_02_a', TEXAS = 'chess_char_1_08_a'; // 角峰 (重装), 德克萨斯 (先锋) +const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0']; +const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id); +const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null); +const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`); +const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o }); +const ENEMIES = { + enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }), + enemy_hitter: dummy('enemy_hitter', { atk: 2000, bat: 1 }), enemy_shooter: dummy('enemy_shooter', { atk: 300, bat: 1, range: 3 }), +}; +/** Every 自选 form: [tier, elite, module]. */ +const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, PROX, PROY].map((m) => [t, true, m]))]; +const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`; + +/** A battle with 年 as uid 1 at (row, col) facing RIGHT, plus `others`. */ +function field({ tier = 5, elite = false, mod = null, skill = 2, row = 10, col = 5, others = [], seed = 5 } = {}) { + const h = makeBattle({ + defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, + flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'heal', 'skillStart', 'skillEnd', 'statusApplied'], captureNoisy: true, + units: [{ uid: 1, diy: { slot: SLOT[tier], charId: NIAN, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others], + }); + h.step(); + return { h, u: h.unit(1) }; +} +const skillBuff = (u) => u.findBuff(`skill:${u.id}`); +function done(h) { + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); +} + +test('年 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 1-1 range, blocks 3 (PRO-Y: 4), physical melee ground-only, 炎, no 特质', () => { + assert.equal(OPERATOR_KITS[NIAN], KITS[NIAN]); + 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], [NIAN, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f)); + assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.blockCnt], + [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), 3 + (m?.attr.blockCnt ?? 0)], `${label(f)}: stats`); + assert.equal(u.s.blockCnt, mod === PROY ? 4 : 3, `${label(f)}: 铁卫 blocks 3, “请勿叩门” 4`); + assert.deepEqual([u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat], ['melee', false, 'phys', 1.5], `${label(f)}: 铁卫`); + assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`); + assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['yanShip'], []], `${label(f)}: bonds / 特质`); + assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth && !u.s.flags.untargetable, `${label(f)}: ground enemies target her`); + done(h); + } + } + // the numbers of the forms (zh_CN): E2 Lv1 2737 / 513 / 529, E2 Lv60 3375 / 583 / 660; PRO-X +200 HP / +60 DEF → + // +375 / +50 / +80, PRO-Y +320 / +35 / +21 / block +1 → +550 / +70 / +30 / block +1 + assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/1/4/0'].stats.def, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.def], [2737, 513, 529, 3375, 660]); + assert.deepEqual([modOf(5, PROX).attr, modOf(6, PROX).attr, modOf(5, PROY).attr, modOf(6, PROY).attr], + [{ maxHp: 200, def: 60 }, { maxHp: 375, atk: 50, def: 80 }, { maxHp: 320, atk: 35, def: 21, blockCnt: 1 }, { maxHp: 550, atk: 70, def: 30, blockCnt: 1 }]); +}); + +test('a 自选 pick: 年 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => { + const data = { chess: CHESS, backups: BACKUPS }; + assert.ok(KITTED_CHARS.includes(NIAN)); + for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(NIAN), `tier ${t}`); + assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: NIAN, skillIndex: 2, uniEquipId: PROY } }, { data, kitted: KITTED_CHARS }), + { ok: true, picks: { [SLOT[6]]: { charId: NIAN, skillIndex: 2, uniEquipId: PROY } } }); +}); + +test('triggers: every skill is MANUAL with the data\'s DEFAULT (the owner\'s 重装 cast-in-range exception; rawRule TAKE_DAMAGE) — a ranged hit from outside her 1-1 casts nothing, an enemy on her front tile casts; a flyer there neither draws an attack nor a cast', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + for (const skill of [0, 1, 2]) { + const sk = formOf(tier, elite).skills[skill]; + assert.deepEqual([sk.skillType, sk.trigger.rule, sk.trigger.rawRule], ['MANUAL', 'DEFAULT', 'TAKE_DAMAGE'], `T${tier} S${skill + 1}: data`); + const { h, u } = field({ tier, elite, skill }); + assert.equal(u.skill.rule, 'DEFAULT', `T${tier} S${skill + 1}`); + h.spawn('enemy_shooter', { pos: [10, 8] }); + h.spawn('enemy_fly', { pos: [10, 6] }); + u.skill.gainSp(999); + h.run(3); + assert.ok(h.hooksOf('damaged').some((c) => c.target === u && c.source?.defId === 'enemy_shooter'), `T${tier} S${skill + 1}: she is being shot`); + assert.equal(u.skill.activations, 0, `T${tier} S${skill + 1}: no cast on a hit (no TAKE_DAMAGE) nor for the flyer in her range`); + assert.equal(u.stats.attacks, 0, `T${tier} S${skill + 1}: ground-only — the flyer on her front tile is no target`); + h.spawn('enemy_dummy', { pos: [10, 6] }); + assert.ok(h.runUntil(() => u.skill.activations === 1, 3), `T${tier} S${skill + 1}: cast with a ground enemy in her range`); + done(h); + } + } +}); + +test('S1 锡灼 (23 / 26 s): DEF +40 % / +55 %, ATK +20 % / +30 %, her normal attacks deal arts damage; physical again after', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S1); + const { h, u } = field({ tier, elite, skill: 0 }); + assert.deepEqual([sk.duration, sk.spCost, sk.initSp, sk.bb.def, sk.bb.atk], [elite ? 26 : 23, 30, 10, elite ? 0.55 : 0.4, elite ? 0.3 : 0.2], `T${tier}`); + const e = h.spawn('enemy_dummy', { pos: [10, 5] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`); + assert.deepEqual(skillBuff(u).mods, { defPct: sk.bb.def, atkPct: sk.bb.atk }, `T${tier}: the skill buff`); + approx(u.skill.timeLeft, sk.duration, `T${tier}: ${sk.duration} s`, 0.01); + const n0 = u.stats.attacks; + h.run(4); + const mine = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && c.dmg.isAttack); + assert.ok(u.stats.attacks > n0 && mine.length > 0 && mine.every((c) => c.type === 'arts'), `T${tier}: arts attacks during the skill`); + approx(mine[mine.length - 1].amount, u.s.atk, `T${tier}: 100 % ATK (RES 0)`); + u.skill.extend(-999); + h.run(3); + assert.ok(h.hooksOf('damaged').filter((c) => c.source === u && c.dmg.isAttack).slice(-1)[0].type === 'phys', `T${tier}: physical once it ends`); + assert.equal(skillBuff(u), null, `T${tier}: buff gone`); + done(h); + } +}); + +test('S2 铜印 (31 / 32 s): stops attacking; DEF +80 % / +100 %, block +1; every enemy attack she takes returns 60 % / 70 % ATK arts to the attacker (ranged ones too, a direct pick) and silences it 3 s; nothing for a non-attack hit, nothing once it ends', () => { + for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [6, true, PROY]]) { + const sk = skillOf(tier, elite, S2); + const { h, u } = field({ tier, elite, mod, skill: 1 }); + assert.deepEqual([sk.duration, sk.spCost, sk.bb.def, sk.bb.block_cnt, sk.bb.atk_scale, sk.bb.silence], [elite ? 32 : 31, 50, elite ? 1 : 0.8, 1, elite ? 0.7 : 0.6, 3], `T${tier}`); + const near = h.spawn('enemy_dummy', { pos: [10, 6] }); + const far = h.spawn('enemy_shooter', { pos: [10, 8] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`); + assert.deepEqual(skillBuff(u).mods, { defPct: sk.bb.def, blockCnt: 1 }, `T${tier}: the skill buff`); + assert.equal(u.s.blockCnt, (mod === PROY ? 4 : 3) + 1, `T${tier}: block +1`); + const n0 = u.stats.attacks; + h.run(3); + assert.equal(u.stats.attacks, n0, `T${tier}: no attack while it runs`); + for (const src of [near, far]) { + const before = h.hooksOf('damaged').length; + h.b.dealDamage(src, u, { amount: 100, type: 'phys', isAttack: true }); + const back = h.hooksOf('damaged').slice(before).filter((c) => c.source === u && c.target === src); + assert.equal(back.length, 1, `T${tier}: one counter on ${src.defId}`); + assert.equal(back[0].type, 'arts', `T${tier}: arts`); + approx(back[0].amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`); + const sil = h.hooksOf('statusApplied').filter((c) => c.source === u && c.target === src && c.status === 'silence').slice(-1)[0]; + assert.ok(sil && Math.abs(sil.duration - 3) < 1e-9, `T${tier}: ${src.defId} silenced 3 s`); + } + const before = h.hooksOf('damaged').length; + h.b.dealDamage(near, u, { amount: 100, type: 'phys' }); + assert.ok(!h.hooksOf('damaged').slice(before).some((c) => c.source === u), `T${tier}: a non-attack hit returns nothing`); + u.skill.extend(-999); + h.run(0.1); + const after = h.hooksOf('damaged').length; + h.b.dealDamage(near, u, { amount: 100, type: 'phys', isAttack: true }); + assert.ok(!h.hooksOf('damaged').slice(after).some((c) => c.source === u), `T${tier}: no counter once it ended`); + assert.equal(u.s.blockCnt, mod === PROY ? 4 : 3, `T${tier}: block back`); + done(h); + } +}); + +test('S3 铁御 (40 s): ATK +65 % / +80 % (her block +0); every other allied operator on her x-2 gets DEF +40 % / +50 %, block +1 and 抵抗 (0.5) while it runs, one outside nothing; all gone after', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S3); + assert.deepEqual([sk.duration, sk.spCost, sk.initSp, sk.rangeId, sk.bb['nian_s_3[self].atk'], sk.bb['nian_s_3[self].block_cnt'], sk.bb['nian_s_3[ally].def'], sk.bb['nian_s_3[ally].block_cnt'], sk.bb.one_minus_status_resistance], + [40, 85, elite ? 56 : 48, 'x-2', elite ? 0.8 : 0.65, 0, elite ? 0.5 : 0.4, 1, -0.5], `T${tier}`); + // 德克萨斯 one row up and one tile ahead (inside x-2), 角峰 two rows up and three tiles ahead (outside) + const { h, u } = field({ tier, elite, skill: 2, others: [{ uid: 2, chessId: TEXAS, row: 11, col: 6 }, { uid: 3, chessId: YAK, row: 12, col: 8 }] }); + const tex = h.unit(2), yak = h.unit(3); + h.spawn('enemy_dummy', { pos: [10, 6] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`); + assert.deepEqual(skillBuff(u).mods, { atkPct: sk.bb['nian_s_3[self].atk'] }, `T${tier}: her ATK`); + assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: her attack range stays 1-1 (x-2 is the skill's area)`); + h.run(1); + assert.deepEqual(tex.findBuff('skill:nian:ironGuard')?.mods, { defPct: sk.bb['nian_s_3[ally].def'], blockCnt: 1 }, `T${tier}: 德克萨斯 inside x-2`); + assert.equal(tex.s.blockCnt, tex.base.blockCnt + 1, `T${tier}: his block +1`); + assert.equal(h.b.resistOf(tex), 0.5, `T${tier}: 抵抗`); + assert.deepEqual([yak.findBuff('skill:nian:ironGuard'), h.b.resistOf(yak)], [null, 0], `T${tier}: 角峰 outside x-2`); + assert.equal(u.findBuff('skill:nian:ironGuard'), null, `T${tier}: not herself ("其他")`); + h.runUntil(() => !u.skill.active, 60); + assert.equal(tex.findBuff('skill:nian:ironGuard'), null, `T${tier}: the DEF / block buff ends with the skill`); + h.run(0.3); + assert.equal(h.b.resistOf(tex), 0, `T${tier}: the 抵抗 lapses`); + done(h); + } +}); + +test('T1 积甲成山: every 重装 operator of her team (her included) max HP +16 % for the whole battle — +21 % with PRO-Y stage 3; a 先锋 nothing', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const { h, u } = field({ tier, elite, mod, skill: 0, others: [{ uid: 2, chessId: YAK, row: 11, col: 3 }, { uid: 3, chessId: TEXAS, row: 11, col: 7 }] }); + const yak = h.unit(2), tex = h.unit(3); + const want = elite && mod === PROY && tier === 6 ? 0.21 : 0.16; + assert.equal(u.def.raw.talents.find((t) => t.index === 0).bb.max_hp, want, `${label(f)}: data`); + for (const a of [u, yak]) assert.deepEqual(a.findBuff('talent:nian:armor')?.mods, { hpPct: want }, `${label(f)}: ${a.defId}`); + assert.equal(tex.findBuff('talent:nian:armor'), null, `${label(f)}: 德克萨斯 (先锋)`); + assert.ok(yak.findBuff('talent:nian:armor').persist, `${label(f)}: kept through a knock-out (编入队伍时)`); + if (!(elite && mod === PROY && tier === 6)) approx(yak.s.maxHp, yak.base.maxHp * (1 + want), `${label(f)}: 角峰's max HP`); + done(h); + } +}); + +test('T2 干明可鉴: 3 护盾 layers at every deployment, each negating one damage instance; PRO-X stage 3: each broken layer ATK / DEF +7 % (≤ 3 stacks) and +3 SP; stage 1 and the other forms none', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const { h, u } = field({ tier, elite, mod, skill: 0, row: 9, col: 5 }); + const t1 = u.def.raw.talents.find((t) => t.index === 1).bb; + const x3 = elite && mod === PROX && tier === 6; + assert.deepEqual(t1, x3 ? { times: 3, atk: 0.07, def: 0.07, sp: 3, max_stack_cnt: 3 } : { times: 3 }, `${label(f)}: data`); + assert.equal(u.findBuff('talent:nian:shield')?.shieldHits, 3, `${label(f)}: 3 layers`); + const e = h.spawn('enemy_dummy', { pos: [9, 8] }); + assert.ok(u.s.maxHp > 0); // (stats are lazy: settle the HP ratio before reading the HP) + const hp0 = u.hp; + u.skill.sp = 0; + for (let i = 1; i <= 3; i++) { + const sp = u.skill.sp; + h.b.dealDamage(e, u, { amount: 1500, type: 'phys', isAttack: true }); + assert.equal(u.hp, hp0, `${label(f)}: hit ${i} negated`); + assert.equal(u.findBuff('talent:nian:shield')?.shieldHits ?? 0, 3 - i, `${label(f)}: ${3 - i} layers left`); + assert.equal(u.findBuff('talent:nian:shieldBreak')?.stacks ?? 0, x3 ? i : 0, `${label(f)}: stacks`); + approx(u.skill.sp - sp, x3 ? 3 : 0, `${label(f)}: SP of layer ${i}`); + } + if (x3) assert.deepEqual(u.findBuff('talent:nian:shieldBreak').mods, { atkPct: 0.07, defPct: 0.07 }, 'per stack (×3)'); + if (x3) approx(u.s.atk, u.base.atk * (1 + 0.21), `${label(f)}: ATK +21 %`); + h.b.dealDamage(e, u, { amount: 1500, type: 'phys', isAttack: true }); + assert.ok(u.hp < hp0, `${label(f)}: the 4th hit lands`); + assert.equal(u.findBuff('talent:nian:shieldBreak')?.stacks ?? 0, x3 ? 3 : 0, `${label(f)}: no 4th stack`); + // a new deployment: 3 new layers, the stacks of the last one are gone + h.b.retreat(u); + h.b.redeploy(u); + h.step(); + assert.equal(u.findBuff('talent:nian:shield')?.shieldHits, 3, `${label(f)}: 3 layers again`); + assert.equal(u.findBuff('talent:nian:shieldBreak'), null, `${label(f)}: stacks reset`); + done(h); + } +}); + +test('PRO-X 混沌阵列 trait: DEF +20 % while she blocks an enemy (stages 1 and 3); none without it', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const { h, u } = field({ tier, elite, mod, skill: 0 }); + const x = elite && mod === PROX; + if (x) assert.deepEqual(u.def.raw.trait.bb, { def: 0.2 }, label(f)); + h.run(0.5); + assert.equal(u.findBuff('trait:nian:block'), null, `${label(f)}: nothing blocked`); + h.spawn('enemy_dummy', { pos: [10, 5] }); + assert.ok(h.runUntil(() => u.blocking.length > 0, 2), `${label(f)}: blocking`); + h.step(); + assert.deepEqual(u.findBuff('trait:nian:block')?.mods ?? null, x ? { defPct: 0.2 } : null, `${label(f)}: DEF +20 %`); + done(h); + } +}); + +test('PRO-Y “请勿叩门”: blocks 4 at both stages; stage 3 only: per 重装 operator on the field (her included, ≤ 3) max HP +4 % and healing received ×(1 + 0.04 n)', () => { + for (const [tier, n] of [[5, 4], [6, 1], [6, 2], [6, 3], [6, 4]]) { + const yaks = Array.from({ length: n - 1 }, (_, i) => ({ uid: 2 + i, chessId: YAK, row: 12, col: 3 + 2 * i })); + const { h, u } = field({ tier, elite: true, mod: PROY, skill: 0, others: [...yaks, { uid: 9, chessId: TEXAS, row: 9, col: 9 }] }); + h.step(); + assert.equal(u.s.blockCnt, 4, `T${tier}: blocks 4`); + const tb = u.def.raw.trait.bb; + if (tier === 5) { + assert.deepEqual(tb, {}, 'stage 1: no trait blackboard'); + assert.equal(u.findBuff('trait:nian:fortify'), null, 'stage 1: nothing per 重装'); + done(h); + continue; + } + assert.deepEqual(tb, { max_hp: 0.04, heal_scale: 1, max_stack_cnt: 3, heal_scale_addition: 0.04, heal_scale_max_value: 1.12 }, 'stage 3'); + const k = Math.min(3, n); + const fort = u.findBuff('trait:nian:fortify'); + approx(fort.mods.hpPct, 0.04 * k, `${n} 重装: max HP`); + approx(fort.mods.healingTakenMul, 1 + 0.04 * k, `${n} 重装: healing received`); + approx(u.s.maxHp, u.base.maxHp * (1 + 0.21 + 0.04 * k), `${n} 重装: with 积甲成山's 21 %`); + // a heal of 1000 lands as 1000 × (1 + 0.04 k) + u.hp = 1; + const got = h.b.heal(h.unit(9), u, 1000); + approx(got, 1000 * (1 + 0.04 * k), `${n} 重装: heal`); + if (n === 2) { // a 重装 leaving the field takes its stack away + h.b.retreat(h.unit(2)); + h.step(); + approx(u.findBuff('trait:nian:fortify').mods.hpPct, 0.04, 'back to 1 stack'); + } + done(h); + } +}); diff --git a/test/content/op_thumpy.test.js b/test/content/op_thumpy.test.js new file mode 100644 index 00000000..e27eff72 --- /dev/null +++ b/test/content/op_thumpy.test.js @@ -0,0 +1,356 @@ +// test/content/op_thumpy.test.js — the 自选 operator kit of 珊比 (char_4235_thumpy, 6★ 本源铁卫; kit +// server/sim/content/kits/ops/op-thumpy.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 PRP-X +// 出发的勇气 at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form of that slot +// status); the fidelity checklist of kits/README.md item by item. +// Run: node --test test/content/op_thumpy.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 THUMPY = 'char_4235_thumpy'; +const FORMS = BACKUPS.units[THUMPY].forms; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const PRPX = 'uniequip_002_thumpy'; +const S1 = 'skchr_thumpy_1', S2 = 'skchr_thumpy_2', S3 = 'skchr_thumpy_3'; +const TEXAS = 'chess_char_1_08_a'; +const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0']; +const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id); +const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null); +const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`); +const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o }); +const ENEMIES = { + enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }), enemy_m1: dummy('enemy_m1', { mass: 1 }), + enemy_m4: dummy('enemy_m4', { mass: 4 }), enemy_m5: dummy('enemy_m5', { mass: 5 }), + enemy_shooter: dummy('enemy_shooter', { atk: 300, bat: 1, range: 3 }), +}; +/** Every 自选 form: [tier, elite, module]. */ +const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, PRPX].map((m) => [t, true, m]))]; +const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`; +const K = (r, c) => r * 21 + c; +const sortN = (a) => [...a].sort((x, y) => x - y); + +/** A battle with 珊比 as uid 1 at (row, col) facing `dir` (RIGHT), plus `others`. */ +function field({ tier = 5, elite = false, mod = null, skill = 2, row = 10, col = 5, dir = 'RIGHT', others = [], seed = 5 } = {}) { + const h = makeBattle({ + defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, + flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'elementBurst'], captureNoisy: true, + units: [{ uid: 1, diy: { slot: SLOT[tier], charId: THUMPY, skillIndex: skill, uniEquipId: mod }, elite, row, col, dir }, ...others], + }); + h.step(); + return { h, u: h.unit(1) }; +} +const skillBuff = (u) => u.findBuff(`skill:${u.id}`); +/** Keep her own SP (and so her casts) out of the way (阻回). */ +const noSp = (h, u) => h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true }, duration: Infinity }); +function done(h) { + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); +} + +test('珊比 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, range 0-1 (her own tile), blocks 3, physical melee ground-only, no 特质', () => { + assert.equal(OPERATOR_KITS[THUMPY], KITS[THUMPY]); + 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], [THUMPY, 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, 10], `${label(f)}: stats`); + assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat], [3, 'melee', false, 'phys', 1.6], `${label(f)}: 本源铁卫`); + assert.deepEqual(u.liveRangeGrid, [[0, 0]], `${label(f)}: 0-1`); + assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [BACKUPS.diy.operators[THUMPY].bonds, []], `${label(f)}: bonds / 特质`); + assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth && !u.s.flags.untargetable, `${label(f)}: ground enemies target her`); + done(h); + } + } + // the numbers of the forms (zh_CN): E2 Lv1 2565 / 536 / 430, E2 Lv60 3194 / 625 / 516; PRP-X +250 / +50 → +450 / +80 + 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], [2565, 536, 3194, 625]); + assert.deepEqual([modOf(5, PRPX).attr, modOf(6, PRPX).attr], [{ maxHp: 250, atk: 50 }, { maxHp: 450, atk: 80 }]); +}); + +test('a 自选 pick: 珊比 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => { + const data = { chess: CHESS, backups: BACKUPS }; + assert.ok(KITTED_CHARS.includes(THUMPY)); + for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(THUMPY), `tier ${t}`); + assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: THUMPY, skillIndex: 1, uniEquipId: PRPX } }, { data, kitted: KITTED_CHARS }), + { ok: true, picks: { [SLOT[5]]: { charId: THUMPY, skillIndex: 1, uniEquipId: PRPX } } }); +}); + +test('triggers: every skill is MANUAL with the data\'s DEFAULT (the owner\'s 重装 cast-in-range exception; rawRule TAKE_DAMAGE) — shot from afar she casts nothing, an enemy on her tile casts; a flyer on it is no target', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + for (const skill of [0, 1, 2]) { + const sk = formOf(tier, elite).skills[skill]; + assert.deepEqual([sk.skillType, sk.trigger.rule, sk.trigger.rawRule], ['MANUAL', 'DEFAULT', 'TAKE_DAMAGE'], `T${tier} S${skill + 1}: data`); + const { h, u } = field({ tier, elite, skill }); + assert.equal(u.skill.rule, 'DEFAULT'); + h.spawn('enemy_shooter', { pos: [10, 7] }); + h.spawn('enemy_fly', { pos: [10, 5] }); + u.skill.gainSp(999); + h.run(3); + assert.ok(h.hooksOf('damaged').some((c) => c.target === u && c.source?.defId === 'enemy_shooter'), `T${tier} S${skill + 1}: she is being shot`); + assert.deepEqual([u.skill.activations, u.stats.attacks], [0, 0], `T${tier} S${skill + 1}: no cast, no attack on the flyer`); + h.spawn('enemy_dummy', { pos: [10, 5] }); + assert.ok(h.runUntil(() => u.skill.activations === 1, 3), `T${tier} S${skill + 1}: cast with an enemy on her tile`); + done(h); + } + } +}); + +test('S1 “还不走?” (30 s): ATK / DEF +28 % / +37 %; she strikes every enemy she blocks at once and pushes each one radially with 小力 (weight 0: 1.7 tiles; weight 1: 0.44)', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S1); + assert.deepEqual([sk.duration, sk.spCost, sk.bb.atk, sk.bb.def, sk.bb['attack@force']], [30, elite ? 34 : 37, elite ? 0.37 : 0.28, elite ? 0.37 : 0.28, 0], `T${tier}`); + const { h, u } = field({ tier, elite, skill: 0 }); + const right = h.spawn('enemy_dummy', { pos: [10, 5.4] }), left = h.spawn('enemy_dummy', { pos: [10, 4.6] }), upE = h.spawn('enemy_m1', { pos: [10.4, 5] }); + assert.ok(h.runUntil(() => u.blocking.length === 3, 1), `T${tier}: blocks all three`); + const at = [right, left, upE].map((e) => [e.x, e.y]); + u.skill.gainSp(999); + const n0 = h.hooksOf('damaged').length; + // the cast comes as she is about to attack: that attack is the first skill attack (same tick) + assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`); + assert.deepEqual(skillBuff(u).mods, { atkPct: sk.bb.atk, defPct: sk.bb.def }, `T${tier}: the skill buff`); + const hit = h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && c.dmg.isAttack && c.dmg.isSkill); + assert.equal(new Set(hit.map((c) => c.dmg.attackId)).size, 1, `T${tier}: one attack`); + assert.deepEqual(sortN(hit.map((c) => c.target.id)), sortN([right.id, left.id, upE.id]), `T${tier}: every blocked enemy struck at once`); + approx(right.x - at[0][0], 1.7, `T${tier}: weight 0 pushed right 1.7`); + approx(at[1][0] - left.x, 1.7, `T${tier}: weight 0 pushed left 1.7`); + approx(upE.y - at[2][1], 0.44, `T${tier}: weight 1 pushed up 0.44`); + done(h); + } +}); + +test('S2 “慢慢走~” glue: her tile + 4 passable tiles in PRTS\'s order — open ground: the cross on her front tile; front blocked (facing the field edge): left, right, then spreading', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S2); + assert.deepEqual([sk.bb.max_cnt, sk.bb.interval, sk.bb.sluggish], [5, 0.7, 0.5], `T${tier}`); + const a = field({ tier, elite, skill: 1 }); + noSp(a.h, a.u); + a.u.skill.addCharge(1); + a.u.skill.activate('test'); + // facing RIGHT (row 0 is the bottom row: her right hand is row − 1): own (10,5), front (10,6), its right (9,6), its + // left (11,6), its front (10,7) + assert.deepEqual(sortN(a.u.mem.thumpyGlue.keys), sortN([K(10, 5), K(10, 6), K(9, 6), K(11, 6), K(10, 7)]), `T${tier}: open ground`); + done(a.h); + // facing UP on the top lane (row 12): the front (13,5) is no ground ⇒ her left (12,4), right (12,6), then from (12,6): + // its right (12,7), then from (12,7): (12,8) + const b = field({ tier, elite, skill: 1, row: 12, col: 5, dir: 'UP' }); + noSp(b.h, b.u); + b.u.skill.addCharge(1); + b.u.skill.activate('test'); + assert.deepEqual(sortN(b.u.mem.thumpyGlue.keys), sortN([K(12, 5), K(12, 4), K(12, 6), K(12, 7), K(12, 8)]), `T${tier}: front blocked`); + done(b.h); + } +}); + +test('S2 “慢慢走~” (20 s): ATK / DEF +48 % / +57 %; from the cast every 0.7 s each ground enemy on the glue (untargetable ones too) takes 停顿 0.5 s, then 37 % / 43 % ATK physical; none off the glue, no flyer, no 隐匿 one', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S2); + assert.deepEqual([sk.duration, sk.spCost, sk.bb.atk, sk.bb.def, sk.bb.atk_scale], [20, elite ? 27 : 30, elite ? 0.57 : 0.48, elite ? 0.57 : 0.48, elite ? 0.43 : 0.37], `T${tier}`); + const { h, u } = field({ tier, elite, skill: 1 }); + noSp(h, u); + const on = h.spawn('enemy_dummy', { pos: [11, 6] }), front = h.spawn('enemy_dummy', { pos: [10, 7] }); + const off = h.spawn('enemy_dummy', { pos: [10, 8] }), fly = h.spawn('enemy_fly', { pos: [9, 6] }); + const ghost = h.spawn('enemy_dummy', { pos: [11, 6] }), hidden = h.spawn('enemy_dummy', { pos: [10, 7] }); + h.b.addBuff(ghost, { key: 'test:untargetable', flags: { untargetable: true } }); + h.b.addBuff(hidden, { key: 'test:stealth', flags: { stealth: true } }); + h.step(); + u.skill.addCharge(1); + u.skill.activate('test'); + assert.deepEqual(skillBuff(u).mods, { atkPct: sk.bb.atk, defPct: sk.bb.def }, `T${tier}: the skill buff`); + h.runUntil(() => !u.skill.active, 25); + const glue = (e) => h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && c.dmg.tags?.includes('thumpy:glue') && c.type === 'phys'); + for (const e of [on, front, ghost]) { + const hits = glue(e); + assert.equal(hits.length, 29, `T${tier}: ${e.tileR},${e.tileC} — a pulse at 0, 0.7 … 19.6 s`); + const sl = h.hooksOf('statusApplied').filter((c) => c.source === u && c.target === e && c.status === 'sluggish'); + assert.equal(sl.length, 29, `T${tier}: 停顿 each pulse`); + assert.ok(sl.every((c) => Math.abs(c.duration - 0.5) < 1e-9), `T${tier}: 0.5 s`); + } + // the hit is atk_scale × her ATK during the skill (DEF 0); the first lands in the cast's tick + const ref = glue(on)[0]; + approx(ref.amount, u.base.atk * (1 + sk.bb.atk) * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`); + for (const e of [off, fly, hidden]) assert.equal(glue(e).length, 0, `T${tier}: ${e.defId} at ${e.tileR},${e.tileC} untouched`); + assert.equal(u.mem.thumpyGlue, null, `T${tier}: the glue is gone with the skill`); + done(h); + } +}); + +test('S2 rider: while the glue lies, another operator\'s physical damage to an enemy on it adds 15 % of 珊比\'s ATK 侵蚀损伤 (from her); off the glue, arts, and 无来源 damage add none', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S2); + assert.equal(sk.bb['ep_damage_ratio[damage]'], 0.15, `T${tier}`); + const { h, u } = field({ tier, elite, skill: 1, others: [{ uid: 2, chessId: TEXAS, row: 12, col: 9 }] }); + const tex = h.unit(2); + noSp(h, u); + const on = h.spawn('enemy_dummy', { pos: [11, 6] }), off = h.spawn('enemy_dummy', { pos: [11, 8] }); + h.step(); + u.skill.addCharge(1); + u.skill.activate('test'); + const ero = (e) => e.elem.erosion; + const e0 = ero(on); + h.b.dealDamage(tex, on, { amount: 100, type: 'phys' }); + approx(ero(on) - e0, 0.15 * u.s.atk, `T${tier}: +15 % of her ATK`); + const burstless = h.hooksOf('damaged').filter((c) => c.type === 'element' && c.target === on).slice(-1)[0]; + assert.equal(burstless.source, u, `T${tier}: the 侵蚀损伤 is hers`); + const e1 = ero(on); + h.b.dealDamage(tex, on, { amount: 100, type: 'arts' }); + h.b.dealDamage(tex, on, { amount: 100, type: 'phys', sourceless: true }); + assert.equal(ero(on), e1, `T${tier}: arts / 无来源 add none`); + h.b.dealDamage(tex, off, { amount: 100, type: 'phys' }); + assert.equal(ero(off), 0, `T${tier}: off the glue none`); + u.skill.extend(-999); + h.step(); + const e2 = ero(on); + h.b.dealDamage(tex, on, { amount: 100, type: 'phys' }); + assert.equal(ero(on), e2, `T${tier}: none once the glue is gone`); + done(h); + } +}); + +test('S3 “不准走!” (60 s): block +2, strikes every blocked enemy, ATK +65 % / +80 %, DEF +75 % / +105 %; the belt carries unblocked ground enemies of 重量 ≤ 4 on the 4 tiles ahead towards her at 0.8 × 20 / 30 tile/s; heavier ones, flyers and blocked ones stay', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S3); + assert.deepEqual([sk.duration, sk.spCost, sk.bb.atk, sk.bb.def, sk.bb.block_cnt, sk.bb.conveyor_speed, sk.bb.mass_level, sk.bb.max_cnt, sk.bb.interval], + [60, elite ? 57 : 58, elite ? 0.8 : 0.65, elite ? 1.05 : 0.75, 2, 0.8, 4, 5, 1], `T${tier}`); + const { h, u } = field({ tier, elite, skill: 2, col: 4 }); + noSp(h, u); + const m4 = h.spawn('enemy_m4', { pos: [10, 7] }), m5 = h.spawn('enemy_m5', { pos: [10, 8] }); + const fly = h.spawn('enemy_fly', { pos: [10, 6] }), past = h.spawn('enemy_dummy', { pos: [10, 9] }); + h.step(); + u.skill.addCharge(1); + u.skill.activate('test'); + assert.deepEqual(skillBuff(u).mods, { atkPct: sk.bb.atk, defPct: sk.bb.def, blockCnt: 2 }, `T${tier}: the skill buff`); + assert.equal(u.s.blockCnt, 5, `T${tier}: block 3 + 2`); + assert.deepEqual(sortN(u.mem.thumpyBelt.carryKeys), sortN([K(10, 5), K(10, 6), K(10, 7), K(10, 8)]), `T${tier}: the belt`); + const x0 = m4.x; + h.run(1); + approx(x0 - m4.x, (0.8 * 20) / 30, `T${tier}: 0.533 tile in a second`, 1e-3); + assert.deepEqual([m5.x, fly.x, past.x], [8, 6, 9], `T${tier}: 重量 5, the flyer and the enemy past the belt stay`); + assert.ok(h.runUntil(() => m4.blockedBy === u, 6), `T${tier}: carried until she blocks it`); + const xb = m4.x; + h.run(1); + assert.equal(m4.x, xb, `T${tier}: a blocked enemy is no longer carried`); + done(h); + } +}); + +test('S3 belt damage: every second from the cast, each targetable ground enemy on her tile and the 4 tiles ahead and each one she blocks takes 27 % / 33 % ATK physical; an enemy whose 侵蚀 bursts there loses 30 DEF more (stacking, for good)', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S3); + assert.deepEqual([sk.bb.atk_scale, sk.bb['thumpy[ep_break_water].def']], [elite ? 0.33 : 0.27, -30], `T${tier}`); + const { h, u } = field({ tier, elite, skill: 2, col: 4 }); + noSp(h, u); + const own = h.spawn('enemy_m5', { pos: [10, 4] }), ahead = h.spawn('enemy_m5', { pos: [10, 8] }); + const beyond = h.spawn('enemy_m5', { pos: [10, 9] }), fly = h.spawn('enemy_fly', { pos: [10, 7] }), side = h.spawn('enemy_m5', { pos: [11, 5] }); + h.step(); + u.skill.addCharge(1); + u.skill.activate('test'); + h.run(5.05); + const belt = (e) => h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && c.dmg.tags?.includes('thumpy:belt')); + for (const e of [own, ahead]) { + const hits = belt(e); + assert.equal(hits.length, 6, `T${tier}: ${e.tileR},${e.tileC} — at 0, 1 … 5 s`); + assert.equal(hits[0].type, 'phys'); + approx(hits[0].amount, u.base.atk * (1 + sk.bb.atk) * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`); + } + for (const e of [beyond, fly, side]) assert.equal(belt(e).length, 0, `T${tier}: ${e.defId} at ${e.tileR},${e.tileC} spared`); + // 侵蚀 bursts: on the belt ⇒ −30 more; off it ⇒ the burst's own −120 only + for (const e of [ahead, side]) h.b.dealDamage(null, e, { type: 'element', element: 'erosion', amount: 1000 }); + assert.deepEqual([ahead.findBuff('skill:thumpy:beltBreak')?.mods, side.findBuff('skill:thumpy:beltBreak')], [{ defFlat: -30 }, null], `T${tier}: the belt's extra cut`); + assert.equal(ahead.s.def, Math.max(0, 0 - 120 - 30), `T${tier}: DEF 0 floor`); + assert.equal(ahead.findBuff('skill:thumpy:beltBreak').timeLeft, Infinity, `T${tier}: for good`); + h.run(8.1); // the burst lock (8 s) ends: a second burst stacks + h.b.dealDamage(null, ahead, { type: 'element', element: 'erosion', amount: 1000 }); + assert.equal(ahead.findBuff('skill:thumpy:beltBreak').stacks, 2, `T${tier}: stacking`); + done(h); + } +}); + +test('T1 探险理论: 元素损伤 she takes ×0.85; her physical damage adds 10 % ATK 侵蚀损伤 (not her arts), or — while the target\'s 侵蚀 burst runs — shortens that burst by 1 s instead', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const { h, u } = field({ tier, elite, mod, skill: 0, row: 9 }); + noSp(h, u); + assert.deepEqual(u.def.raw.talents.find((t) => t.index === 0).bb, { ep_damage_scale: 0.85, duration_dec: 1, 'ep_damage_ratio[trigger]': 0.1 }, label(f)); + const e = h.spawn('enemy_dummy', { pos: [10, 8] }); + h.step(); + h.b.dealDamage(e, u, { type: 'element', element: 'burn', amount: 100 }); + approx(u.elem.burn, 85, `${label(f)}: 元素损伤 taken ×0.85`); + h.b.dealDamage(u, e, { amount: 100, type: 'phys' }); + approx(e.elem.erosion, 0.1 * u.s.atk, `${label(f)}: 10 % ATK 侵蚀损伤 (not blocking: no module bonus)`); + const e0 = e.elem.erosion; + h.b.dealDamage(u, e, { amount: 100, type: 'arts' }); + assert.equal(e.elem.erosion, e0, `${label(f)}: not on arts damage`); + h.b.dealDamage(null, e, { type: 'element', element: 'erosion', amount: 1000 }); + const lock = e.findBuff('erosionBurst'); + assert.ok(lock, `${label(f)}: bursting`); + const left = lock.timeLeft; + h.b.dealDamage(u, e, { amount: 100, type: 'phys' }); + approx(lock.timeLeft, left - 1, `${label(f)}: the burst 1 s shorter`); + done(h); + } +}); + +test('T2 坚硬脚板: an enemy she has damaged whose 侵蚀 bursts gives her DEF +8 (PRP-X stage 3: DEF +12, ATK +5; ≤ 30 stacks) and 250 (400) 屏障 added to one barrier, ≤ 300 % max HP; unmarked enemies nothing; the marks go when she leaves', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const { h, u } = field({ tier, elite, mod, skill: 0, row: 9 }); + noSp(h, u); + const x3 = elite && mod === PRPX && tier === 6; + const t1 = u.def.raw.talents.find((t) => t.index === 1).bb; + assert.deepEqual(t1, x3 ? { def: 12, atk: 5, shield_value: 400, max_stack_cnt: 30, scale: 3 } : { def: 8, atk: 0, shield_value: 250, max_stack_cnt: 30, scale: 3 }, label(f)); + const marked = h.spawn('enemy_dummy', { pos: [10, 8] }), other = h.spawn('enemy_dummy', { pos: [11, 8] }); + h.step(); + h.b.dealDamage(null, other, { type: 'element', element: 'erosion', amount: 1000 }); + assert.equal(u.findBuff('talent:thumpy:sole'), null, `${label(f)}: an unmarked enemy's burst`); + h.b.dealDamage(u, marked, { amount: 10, type: 'arts' }); + h.b.dealDamage(null, marked, { type: 'element', element: 'erosion', amount: 1000 }); + assert.deepEqual(u.findBuff('talent:thumpy:sole')?.mods, x3 ? { defFlat: 12, atkFlat: 5 } : { defFlat: 8 }, `${label(f)}: the stack`); + assert.equal(u.findBuff('talent:thumpy:barrier')?.shield, t1.shield_value, `${label(f)}: 屏障`); + h.run(8.1); + h.b.dealDamage(null, marked, { type: 'element', element: 'erosion', amount: 1000 }); + assert.equal(u.findBuff('talent:thumpy:sole').stacks, 2, `${label(f)}: two stacks`); + approx(u.s.def, u.base.def + 2 * t1.def, `${label(f)}: DEF`); + assert.equal(u.findBuff('talent:thumpy:barrier').shield, 2 * t1.shield_value, `${label(f)}: the same barrier grows`); + u.findBuff('talent:thumpy:barrier').shield = 3 * u.s.maxHp - 10; + h.run(8.1); + h.b.dealDamage(null, marked, { type: 'element', element: 'erosion', amount: 1000 }); + approx(u.findBuff('talent:thumpy:barrier').shield, 3 * u.s.maxHp, `${label(f)}: capped at 300 % max HP`); + // she leaves the field: the marks go; back on it, the enemy's burst gives nothing until she damages it again + h.b.retreat(u); + h.b.redeploy(u); + h.step(); + h.run(8.1); + h.b.dealDamage(null, marked, { type: 'element', element: 'erosion', amount: 1000 }); + assert.equal(u.findBuff('talent:thumpy:sole'), null, `${label(f)}: marks cleared when she left`); + done(h); + } +}); + +test('PRP-X 出发的勇气 trait: every 元素损伤 she deals ×1.15 while she blocks (stages 1 and 3); ×1 when blocking nobody or without the module', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const { h, u } = field({ tier, elite, mod, skill: 0 }); + noSp(h, u); + const x = elite && mod === PRPX; + if (x) assert.deepEqual(u.def.raw.trait.bb, { ep_damage_scale: 1.15 }, label(f)); + const far = h.spawn('enemy_dummy', { pos: [10, 8] }); + h.step(); + h.b.dealDamage(u, far, { amount: 100, type: 'phys' }); + approx(far.elem.erosion, 0.1 * u.s.atk, `${label(f)}: blocking nobody`); + const near = h.spawn('enemy_dummy', { pos: [10, 5] }); + assert.ok(h.runUntil(() => near.blockedBy === u, 1), `${label(f)}: blocking`); + const g0 = far.elem.erosion; + h.b.dealDamage(u, far, { amount: 100, type: 'phys' }); + approx(far.elem.erosion - g0, 0.1 * u.s.atk * (x ? 1.15 : 1), `${label(f)}: while blocking`); + done(h); + } +}); diff --git a/test/golden/diy.json b/test/golden/diy.json index a81e67e1..b131e268 100644 --- a/test/golden/diy.json +++ b/test/golden/diy.json @@ -1974,8 +1974,8 @@ "snaps": "8eac4d32 fa5c2376 bc1e9184 84fb1db6 b4b3b807 cc6f6399 c665e4ff 261094cd 5d5c61fd 1bf27396 b526ffa6 e433a3a2 7d8bd521 d7eb687d 4d18ecc9 f640c5fd 0912a7d6 cc22df71 1a833e56 d877d28b 8a887ef8 8014c7dd" }, "diy-049": { - "about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 5_diy1_a=csnipe 5_diy2_b=csnipe 5_diy1_a=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo", - "fast": true, + "about": "5_diy2_b=nian/S1/003_nian 5_diy1_b=nian/S2/003_nian 5_diy2_b=nian/S3/003_nian 5_diy1_a=nian/S1 5_diy2_a=nian/S2 5_diy1_a=nian/S3 5_diy2_b=nian/S1 5_diy1_b=nian/S2 5_diy2_b=nian/S3 5_diy1_b=nian/S1/002_nian 5_diy2_b=nian/S2/002_nian 5_diy1_b=nian/S3/002_nian", + "fast": false, "kind": "normal", "stageId": "act1autochess_m05", "modeId": "mode_multi_normal", @@ -1985,122 +1985,115 @@ "time": 230, "ticks": 6900, "reason": "timeout", - "killed": 2, + "killed": 0, "total": 60, "unspawned": 0, "bossHpLeft": null, "rngDraws": 0, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":2,"total":60,"perfect":false,"leaks":58,"leaksUncounted":0,"coins":0,"dmg":267738,"bossDmg":0,"heal":59968,"deaths":6,"layerGains":{}}}, + "players": {"p1":{"killed":0,"total":60,"perfect":false,"leaks":60,"leaksUncounted":0,"coins":0,"dmg":81326,"bossDmg":0,"heal":0,"deaths":14,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,76,0,304,6384,0,22,1], - ["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,75,0,652,8724,0,41,2], - ["p1",3,"op","chess_char_5_diy1_a",2,null,2564,1,6,0,21282,21790,0,137,3], - ["p1",4,"op","chess_char_5_diy2_b",2,"none",3334,1,8,2,22607,38177,0,115,4], - ["p1",5,"op","chess_char_5_diy1_a",2,null,1271,1,10,0,2272,0,0,99,2], - ["p1",6,"op","chess_char_5_diy2_b",2,"none",1510,1,11,0,2916,0,0,108,3], - ["p1",7,"op","chess_char_5_diy1_a",2,null,1133,1,1,0,0,0,0,0,0], - ["p1",8,"op","chess_char_5_diy2_b",2,"none",1360,1,3,0,97722,0,0,178,4], - ["p1",9,"op","chess_char_5_diy1_a",2,null,1218,1,5,0,0,0,29529,69,4], - ["p1",10,"op","chess_char_5_diy2_b",2,"none",1374,1,7,0,0,0,30439,60,4], - ["p1",11,"op","chess_char_5_diy1_a",2,null,932,1,9,0,56880,0,0,144,3], - ["p1",12,"op","chess_char_5_diy2_b",2,"none",1131,1,12,0,63104,0,0,136,3] + ["p1",1,"op","chess_char_5_diy2_b",0,"uniequip_003_nian",0,0,78,0,28088,8572,0,59,2], + ["p1",2,"op","chess_char_5_diy1_b",1,"uniequip_003_nian",3523.05,1,81,0,14152,5049,0,38,2], + ["p1",3,"op","chess_char_5_diy2_b",2,"uniequip_003_nian",0,0,63,0,9053,8572,0,47,2], + ["p1",4,"op","chess_char_5_diy1_a",0,null,0,0,71,0,7541,6350,0,41,1], + ["p1",5,"op","chess_char_5_diy2_a",1,null,3174.92,1,82,0,4412,6350,0,16,1], + ["p1",6,"op","chess_char_5_diy1_a",2,null,0,0,76,0,590,9525,0,23,0], + ["p1",7,"op","chess_char_5_diy2_b",0,"none",590.11,1,80,0,17490,11155,0,50,2], + ["p1",8,"op","chess_char_5_diy1_b",1,"none",3915,1,1,0,0,0,0,0,0], + ["p1",9,"op","chess_char_5_diy2_b",2,"none",3915,1,3,0,0,0,0,0,0], + ["p1",10,"op","chess_char_5_diy1_b",0,"uniequip_002_nian",4147,1,5,0,0,0,0,0,0], + ["p1",11,"op","chess_char_5_diy2_b",1,"uniequip_002_nian",4147,1,7,0,0,0,0,0,0], + ["p1",12,"op","chess_char_5_diy1_b",2,"uniequip_002_nian",4147,1,9,0,0,0,0,0,0] ], - "enemies": {"enemy_1045_hammer_2":[12,0,11,37471,77249],"enemy_1061_zomshd":[48,2,45,37605,269489]}, - "hooks": [1,76,6900,1205,1205,3051,3051,29902,8,64,33,29,0,37113,285,23,60,56,1,8,0,0,0], - "events": {"atk":1205,"deploy":16,"die":8,"dmg":3051,"heal":129,"leak":56,"skill":62,"spawn":72,"status":555}, - "fx": {}, - "snaps": "c6bdc18a 6fb12fe1 c3c94d6f f42e9bba 6ea4c93a ff3506ac f551dbb2 248e7a29 e877982d 0b8ac60e d21ffc26 89eda7b5 7ef13ac0 2d2fce6f 32484e38 3407d0b5 1b59f452 55141007 fc87be97 6239dcab e11e96f9 250fe5b6 a75b766c" + "enemies": {"enemy_1045_hammer_2":[12,0,12,22394,28372],"enemy_1061_zomshd":[48,0,41,33180,156554]}, + "hooks": [1,82,6900,590,590,3223,3223,20288,14,67,10,9,0,15635,505,70,60,53,1,14,0,0,0], + "events": {"atk":590,"deploy":22,"die":14,"dmg":3175,"fx":67,"leak":53,"skill":19,"spawn":72,"status":142}, + "fx": {"buff":2,"counter":43,"shield":22}, + "snaps": "1ad2183e 31436dc6 162db746 33388938 61688d2f 0472b4ef 94c39e4b 95f61859 3a70fec9 11ddf128 538ff406 c6a3a4be fadf11ea 33b4744d 57e75bf3 8594a4d7 e618d366 40e9e04d 5a0b2dbb 92e0c87e 6e8be006 cf3a4f90 e2c51214" }, "diy-050": { - "about": "5_diy1_a=acfend 5_diy2_b=acfend 5_diy1_a=acpion 5_diy2_b=acpion 5_diy1_a=acguad 5_diy2_b=acguad 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc", + "about": "6_diy2_b=nian/S1/003_nian 6_diy1_b=nian/S2/003_nian 6_diy2_b=nian/S3/003_nian 6_diy1_a=nian/S1 6_diy2_a=nian/S2 6_diy1_a=nian/S3 6_diy2_b=nian/S1 6_diy1_b=nian/S2 6_diy2_b=nian/S3 6_diy1_b=nian/S1/002_nian 6_diy2_b=nian/S2/002_nian 6_diy1_b=nian/S3/002_nian", "fast": false, "kind": "normal", "stageId": "act1autochess_m06", "modeId": "mode_multi_hard", - "round": 12, + "round": 13, "seed": 574294847, - "waveId": "act1autochess_h05", - "time": 214.8, - "ticks": 6444, + "waveId": "act1autochess_h06", + "time": 210.47, + "ticks": 6314, "reason": "cleared", "killed": 0, - "total": 60, + "total": 105, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 115, + "rngDraws": 0, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":0,"total":60,"perfect":false,"leaks":60,"leaksUncounted":0,"coins":0,"dmg":157435,"bossDmg":0,"heal":12039,"deaths":23,"layerGains":{}}}, + "players": {"p1":{"killed":0,"total":105,"perfect":false,"leaks":105,"leaksUncounted":0,"coins":0,"dmg":42730,"bossDmg":0,"heal":0,"deaths":19,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,82,0,28,5208,0,2,0], - ["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",3371,1,6,0,0,0,0,0,0], - ["p1",3,"op","chess_char_5_diy1_a",2,null,1529,1,9,0,20856,0,0,0,7], - ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,72,0,174,5712,0,6,0], - ["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,67,0,13430,5276,0,29,1], - ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,78,0,61051,12366,0,48,2], - ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,85,0,698,3816,0,13,1], - ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",0,0,84,0,2755,4761,0,39,1], - ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,52,0,19051,2744,0,7,1], - ["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",0,0,51,0,39393,3498,0,7,1], - ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,47,0,0,2448,1260,3,0], - ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",0,0,54,0,0,9324,10779,8,1] + ["p1",1,"op","chess_char_6_diy2_b",0,"uniequip_003_nian",0,0,126,0,3258,15072,0,11,1], + ["p1",2,"op","chess_char_6_diy1_b",1,"uniequip_003_nian",4867,1,7,0,0,0,0,0,0], + ["p1",3,"op","chess_char_6_diy2_b",2,"uniequip_003_nian",4867,1,9,0,0,0,0,0,0], + ["p1",4,"op","chess_char_6_diy1_a",0,null,0,0,119,0,1898,9525,0,17,0], + ["p1",5,"op","chess_char_6_diy2_a",1,null,0,0,75,0,3848,6350,0,0,2], + ["p1",6,"op","chess_char_6_diy1_a",2,null,0,0,73,0,795,6350,0,12,0], + ["p1",7,"op","chess_char_6_diy2_b",0,"none",0,0,68,0,1284,7830,0,18,1], + ["p1",8,"op","chess_char_6_diy1_b",1,"none",0,0,96,0,14307,7830,0,7,1], + ["p1",9,"op","chess_char_6_diy2_b",2,"none",0,0,109,0,7919,7830,0,17,1], + ["p1",10,"op","chess_char_6_diy1_b",0,"uniequip_002_nian",0,0,1,0,2264,4350,0,4,1], + ["p1",11,"op","chess_char_6_diy2_b",1,"uniequip_002_nian",0,0,3,0,4561,4350,0,0,1], + ["p1",12,"op","chess_char_6_diy1_b",2,"uniequip_002_nian",0,0,5,0,2595,4350,0,3,1] ], - "enemies": {"enemy_1045_hammer_2":[12,0,12,15694,22503],"enemy_1061_zomshd":[48,0,48,39458,134932]}, - "hooks": [1,85,6444,245,245,412,396,10112,23,83,15,15,0,16629,0,28,60,60,1,26,0,0,16], - "events": {"atk":263,"deploy":25,"die":23,"dmg":396,"fx":86,"heal":23,"leak":60,"skill":30,"spawn":72,"status":14}, - "fx": {"aoe":5,"dodge":16,"dp":7,"slash":56,"spGain":2}, - "snaps": "580b238a aa91295b 3af047b3 a833e612 f6a0b595 1eba1563 0c047aab 7838a66c 34fc5adc 627a4814 4d7c5d84 41a4b79a 721f8563 f6d5a347 76cda6c2 74e73fdb 220b1dd6 2f7b90e4 d3a2ce26 6a84c6a3 1aae6574 0e8ef572" + "enemies": {"enemy_1165_duhond":[51,0,51,4082,30703],"enemy_1174_duholy_2":[24,0,24,20164,2589],"enemy_1175_dushdo_2":[30,0,30,49591,9438]}, + "hooks": [1,126,6314,340,340,402,402,0,19,124,9,9,0,11443,375,58,105,105,1,19,0,0,0], + "events": {"atk":340,"deploy":21,"die":19,"dmg":345,"fx":94,"leak":105,"skill":18,"spawn":117,"status":285}, + "fx": {"buff":2,"counter":62,"shield":21,"shieldBreak":9}, + "snaps": "32c508d1 7fb27f91 1e862c88 ada7c060 c2899984 383b70f2 24fae586 47ebbe16 4b5a8eda 97692424 140fe8ca e3a306f9 4356fe33 a86a479a 3db4401e 2f7b03f7 27739027 66be9939 f23fd6ef 8db2c4d4 a3bacd91" }, "diy-051": { - "about": "6_diy1_a=acfend 6_diy2_b=acfend 5_diy1_a=sharp2 5_diy2_b=sharp2 6_diy1_a=acpion 6_diy2_b=acpion 5_diy1_a=acspec 5_diy2_b=acspec 6_diy1_a=acguad 6_diy2_b=acguad 5_diy1_a=acsupo 5_diy2_b=acsupo", + "about": "5_diy1_a=hsgma2/S1 5_diy2_a=hsgma2/S2 5_diy1_a=hsgma2/S3 5_diy2_b=hsgma2/S1 5_diy1_b=hsgma2/S2 5_diy2_b=hsgma2/S3 5_diy1_b=hsgma2/S1/002_hsgma2 5_diy2_b=hsgma2/S2/002_hsgma2 5_diy1_b=hsgma2/S3/002_hsgma2 6_diy1_a=hsgma2/S1 6_diy2_a=hsgma2/S2 6_diy1_a=hsgma2/S3", "fast": false, "kind": "normal", "stageId": "act1autochess_m07", "modeId": "mode_single_normal", - "round": 13, + "round": 12, "seed": 3864737019, - "waveId": "act1autochess_h06", - "time": 204.8, - "ticks": 6144, - "reason": "cleared", - "killed": 197, - "total": 290, + "waveId": "act1autochess_h05", + "time": 230, + "ticks": 6900, + "reason": "timeout", + "killed": 94, + "total": 99, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 282, + "rngDraws": 0, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":197,"total":290,"perfect":false,"leaks":93,"leaksUncounted":0,"coins":0,"dmg":444885,"bossDmg":0,"heal":0,"deaths":15,"layerGains":{}}}, + "players": {"p1":{"killed":94,"total":99,"perfect":false,"leaks":5,"leaksUncounted":0,"coins":0,"dmg":1212423,"bossDmg":0,"heal":47022,"deaths":10,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_6_diy1_a",2,null,0,0,4,7,21881,2604,0,65,1], - ["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",0,0,7,11,35637,3431,0,87,2], - ["p1",3,"op","chess_char_5_diy1_a",0,null,1560.61,1,279,11,28493,2169,0,91,2], - ["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",1890.36,1,277,51,98505,2858,0,110,3], - ["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,241,13,39114,3058,0,54,3], - ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",0,0,163,43,73993,3808,0,55,4], - ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,285,5,26351,8078,0,87,0], - ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",1448,1,3,7,19,0,0,16,0], - ["p1",9,"op","chess_char_6_diy1_a",2,null,2638,1,5,10,60,0,0,60,3], - ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",2339.65,1,8,13,64107,1191,0,82,3], - ["p1",11,"op","chess_char_5_diy1_a",2,null,1160,1,6,10,18397,0,0,78,2], - ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",1430,1,9,16,38328,0,0,93,2], + ["p1",1,"op","chess_char_5_diy1_a",0,null,0,0,4,5,57503,2592,0,54,1], + ["p1",2,"op","chess_char_5_diy2_a",1,null,2592,1,113,1,21703,2592,1585,18,1], + ["p1",3,"op","chess_char_5_diy1_a",2,null,2592,1,109,1,52103,3726,531,33,1], + ["p1",4,"op","chess_char_5_diy2_b",0,"none",4.41,1,10,19,140956,3227,6424,81,1], + ["p1",5,"op","chess_char_5_diy1_b",1,"none",503.66,1,12,16,277078,20953,22603,105,7], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",96.92,1,93,15,189776,7945,0,76,2], + ["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_hsgma2",6.33,1,75,14,130898,6835,63,79,1], + ["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_hsgma2",1,1,3,4,70786,6544,6884,42,4], + ["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_hsgma2",0,0,5,2,78006,4045,0,43,2], + ["p1",10,"op","chess_char_6_diy1_a",0,null,0,0,118,0,1485,5186,1404,43,0], + ["p1",11,"op","chess_char_6_diy2_a",1,null,2592,1,119,2,60768,2594,6237,41,2], + ["p1",12,"op","chess_char_6_diy1_a",2,null,2592,1,120,15,131360,3304,1291,81,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_1195_sfyin": [39,30,9,4143,113683], - "enemy_1196_msfyin": [60,32,28,0,65], - "enemy_1197_sfshu": [48,38,10,10130,184299], - "enemy_1198_msfshu": [114,78,36,0,243], - "enemy_1199_sfjin": [18,11,7,12925,146349], - "enemy_1200_msfjin": [11,8,3,0,246] - }, - "hooks": [1,314,6144,1470,1470,1969,2082,0,212,305,25,30,0,33234,6218,91,290,93,1,212,0,0,11], - "events": {"atk":1512,"deploy":22,"die":212,"dmg":2076,"fx":273,"leak":93,"skill":64,"spawn":304,"status":488}, - "fx": {"aoe":56,"displace":1,"dodge":22,"dp":63,"slash":52,"summon":79}, - "snaps": "cf051c06 587dcba3 94ed6adb bba4fd81 815661ad cbf17744 a8d0c19b 32b88916 1e8bde83 51a1d669 b023b185 df725cf4 bf4681aa 2c372e04 1a001766 3d81d3d6 7f8effe5 11826d80 b4fb05d0 0eed6d6c 65f6503d" + "enemies": {"enemy_1010_demon":[42,42,0,19638,489888],"enemy_1320_wdrrl_2":[12,7,4,46424,337623],"enemy_1422_lrsldr_2":[45,45,0,3481,384912]}, + "hooks": [1,120,6900,1503,1503,3200,3200,3103,104,108,24,22,0,11731,719,105,99,4,1,104,0,0,0], + "events": {"atk":1606,"deploy":19,"die":104,"dmg":2878,"fx":1024,"heal":549,"leak":4,"skill":46,"spawn":113,"status":281}, + "fx": {"counter":257,"explode":10,"strike":719,"undying":24,"zone":14}, + "snaps": "36780204 dac8d98e 6f2e981c 459ada1b 042719cc cf606764 a0f56997 4c425c7e b6159d94 a81a5366 644837d2 641691ef 2ba0a786 a89e77f0 a93a6da4 5a12d51a 6f311289 85cea690 c2c18a05 573f95c3 e900e46c 68d5d9a9 c98071e3" }, "diy-052": { - "about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc 6_diy1_a=acsupo 6_diy2_b=acsupo", + "about": "6_diy1_b=hsgma2/S1 6_diy2_b=hsgma2/S2 6_diy1_b=hsgma2/S3 6_diy2_b=hsgma2/S1/002_hsgma2 6_diy1_b=hsgma2/S2/002_hsgma2 6_diy2_b=hsgma2/S3/002_hsgma2 5_diy1_a=thumpy/S1 5_diy2_a=thumpy/S2 5_diy1_a=thumpy/S3 5_diy2_b=thumpy/S1 5_diy1_b=thumpy/S2 5_diy2_b=thumpy/S3", "fast": false, "kind": "normal", "stageId": "act2autochess_m01", @@ -2108,35 +2101,246 @@ "round": 13, "seed": 1210502478, "waveId": "act1autochess_h06", - "time": 218.57, - "ticks": 6557, - "reason": "cleared", - "killed": 0, + "time": 230, + "ticks": 6900, + "reason": "timeout", + "killed": 23, "total": 123, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 81, + "rngDraws": 0, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":0,"total":123,"perfect":false,"leaks":123,"leaksUncounted":0,"coins":0,"dmg":76446,"bossDmg":0,"heal":2762,"deaths":33,"layerGains":{}}}, + "players": {"p1":{"killed":23,"total":123,"perfect":false,"leaks":100,"leaksUncounted":0,"coins":0,"dmg":780056,"bossDmg":0,"heal":14644,"deaths":16,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,6,0,2101,1865,0,8,0], - ["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,9,0,4660,2494,0,8,0], - ["p1",3,"op","chess_char_6_diy1_a",2,null,1154,1,157,0,13058,11540,0,21,0], - ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",0,0,156,0,24836,14840,0,17,0], - ["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,151,0,3559,2544,0,18,0], - ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",0,0,3,0,11692,1643,0,23,0], - ["p1",7,"op","chess_char_6_diy1_a",2,null,0,0,4,0,9700,1372,0,5,1], - ["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",0,0,11,0,1022,1819,0,1,0], - ["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,86,0,0,4120,2092,4,1], - ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",0,0,74,0,0,4018,670,2,0], - ["p1",11,"op","chess_char_6_diy1_a",2,null,0,0,1,0,1487,1160,0,3,1], - ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",0,0,5,0,4330,1450,0,7,1] + ["p1",1,"op","chess_char_6_diy1_b",0,"none",1,1,94,5,209942,6455,0,74,1], + ["p1",2,"op","chess_char_6_diy2_b",1,"none",2547.98,1,122,6,167130,15078,12998,55,3], + ["p1",3,"op","chess_char_6_diy1_b",2,"none",1,1,133,11,214459,8531,1646,64,2], + ["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_002_hsgma2",1857.31,1,143,1,36153,5249,0,22,1], + ["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_002_hsgma2",3553,1,144,0,7024,7106,0,6,0], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_hsgma2",0,0,5,0,127199,6040,0,18,1], + ["p1",7,"op","chess_char_5_diy1_a",0,null,0,0,75,0,472,5130,0,2,0], + ["p1",8,"op","chess_char_5_diy2_a",1,null,0,0,1,0,10133,2565,0,2,1], + ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,2,0,2068,2565,0,2,1], + ["p1",10,"op","chess_char_5_diy2_b",0,"none",0,0,6,0,556,3194,0,1,1], + ["p1",11,"op","chess_char_5_diy1_b",1,"none",0,0,74,0,1625,6388,0,5,0], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,66,0,3295,6388,0,7,0] ], - "enemies": {"enemy_1019_jshoot_2":[57,0,57,29550,4113],"enemy_1299_ymkilr_2":[27,0,27,14576,26756],"enemy_1422_lrsldr_2":[39,0,39,4739,45577]}, - "hooks": [1,157,6557,277,277,283,291,7,33,156,4,24,0,6272,192,29,123,123,1,33,0,0,0], - "events": {"atk":278,"deploy":34,"die":33,"dmg":269,"fx":19,"heal":7,"leak":123,"skill":39,"spawn":135,"status":92}, - "fx": {"aoe":2,"displace":15,"spGain":2}, - "snaps": "21be44ba 23e43266 29788d55 2f1037e9 363ad836 051606c5 26acdfeb 8dc54607 7a7c6a86 7ebe59b3 d1661fdb 203d434c 2e82423b f7e9ffc1 7a1afa0e 8998df31 0ca38cb6 f3ac5677 87bcc7cf 0f5f3879 0e082cac 62241cef" + "enemies": {"enemy_1019_jshoot_2":[57,16,40,20398,366623],"enemy_1299_ymkilr_2":[27,0,27,43104,118193],"enemy_1422_lrsldr_2":[39,7,31,11187,295240]}, + "hooks": [1,144,6900,930,930,1743,1805,496,39,137,11,9,0,5236,271,65,123,98,1,39,0,1,0], + "events": {"atk":995,"deploy":21,"die":39,"dmg":1523,"fx":401,"heal":160,"leak":98,"skill":20,"spawn":135,"status":168}, + "fx": {"burst":1,"counter":153,"shield":1,"strike":230,"undying":11,"zone":5}, + "snaps": "2bd122f0 4ac3385c 96a39114 a5893cd8 051e791a 5a5a93c5 e35983bb 9ff770c5 06c88612 31239379 f4bf2aa3 13c07a44 7ad6caf0 01eeb1c6 a69add99 acbe0984 39667970 60ffd978 4b7d264c 01ee3f5c 0db9891d 0cf72c7f 2513055f" + }, + "diy-053": { + "about": "5_diy1_b=thumpy/S1/002_thumpy 5_diy2_b=thumpy/S2/002_thumpy 5_diy1_b=thumpy/S3/002_thumpy 6_diy1_a=thumpy/S1 6_diy2_a=thumpy/S2 6_diy1_a=thumpy/S3 6_diy2_b=thumpy/S1 6_diy1_b=thumpy/S2 6_diy2_b=thumpy/S3 6_diy1_b=thumpy/S1/002_thumpy 6_diy2_b=thumpy/S2/002_thumpy 6_diy1_b=thumpy/S3/002_thumpy", + "fast": false, + "kind": "normal", + "stageId": "act2autochess_m02", + "modeId": "mode_multi_funny", + "round": 13, + "seed": 2850577873, + "waveId": "act1autochess_h06", + "time": 209.6, + "ticks": 6288, + "reason": "cleared", + "killed": 114, + "total": 114, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 0, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":114,"total":114,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":959040,"bossDmg":0,"heal":0,"deaths":0,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_thumpy",3444,1,3,0,0,0,0,0,0], + ["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_thumpy",3444,1,4,0,0,0,0,0,0], + ["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_thumpy",3444,1,5,15,133079,0,0,9,1], + ["p1",4,"op","chess_char_6_diy1_a",0,null,2239.4,1,7,0,15316,326,0,16,1], + ["p1",5,"op","chess_char_6_diy2_a",1,null,2551.8,1,11,32,259439,13,0,34,3], + ["p1",6,"op","chess_char_6_diy1_a",2,null,2235,1,13,37,282521,330,0,95,2], + ["p1",7,"op","chess_char_6_diy2_b",0,"none",2784.8,1,14,12,115470,409,0,130,3], + ["p1",8,"op","chess_char_6_diy1_b",1,"none",3167.6,1,8,6,40542,26,0,2,1], + ["p1",9,"op","chess_char_6_diy2_b",2,"none",2886,1,12,12,112674,308,0,92,2], + ["p1",10,"op","chess_char_6_diy1_b",0,"uniequip_002_thumpy",3644,1,9,0,0,0,0,0,0], + ["p1",11,"op","chess_char_6_diy2_b",1,"uniequip_002_thumpy",3644,1,10,0,0,0,0,0,0], + ["p1",12,"op","chess_char_6_diy1_b",2,"uniequip_002_thumpy",3644,1,6,0,0,0,0,0,0], + ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], + ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null] + ], + "enemies": {"enemy_1381_winman":[27,27,0,436,150336],"enemy_1381_winman_2":[48,48,0,581,359424],"enemy_1387_winshd":[39,39,0,396,449280]}, + "hooks": [1,128,6288,1005,1005,3140,5232,0,114,114,13,13,0,18520,726,165,114,0,1,114,0,187,0], + "events": {"atk":1225,"deploy":12,"die":114,"dmg":4689,"fx":657,"skill":26,"spawn":128,"status":1799}, + "fx": {"burst":187,"displace":52,"shield":409,"zone":9}, + "snaps": "e16d0b90 705560d0 873d70c8 8336cfb8 6756edb4 df4b2178 eb458684 ddf18b37 9e09661f 83a30c0d 851d3cf5 2772710d 3183643a db6cc3d2 43b11e92 1b642500 ac4b6665 577c73aa 4d053ae7 13261199 fbb19022" + }, + "diy-054": { + "about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 5_diy1_a=csnipe 5_diy2_b=csnipe 5_diy1_a=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo", + "fast": true, + "kind": "normal", + "stageId": "act2autochess_m03", + "modeId": "mode_single_hard", + "round": 12, + "seed": 196461732, + "waveId": "act1autochess_h05", + "time": 230, + "ticks": 6900, + "reason": "timeout", + "killed": 8, + "total": 96, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 0, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":8,"total":96,"perfect":false,"leaks":88,"leaksUncounted":0,"coins":0,"dmg":276105,"bossDmg":0,"heal":70718,"deaths":5,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",2,null,1765.97,1,5,1,10566,7022,0,160,3], + ["p1",2,"op","chess_char_5_diy2_b",2,"none",2498.09,1,7,1,8600,6809,0,170,3], + ["p1",3,"op","chess_char_5_diy1_a",2,null,2564,1,12,1,10379,28340,0,142,3], + ["p1",4,"op","chess_char_5_diy2_b",2,"none",2946.6,1,14,2,34779,29705,0,147,4], + ["p1",5,"op","chess_char_5_diy1_a",2,null,1271,1,9,1,33524,0,0,215,3], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",1510,1,11,0,45208,0,0,217,4], + ["p1",7,"op","chess_char_5_diy1_a",2,null,1133,1,13,1,41204,0,0,97,3], + ["p1",8,"op","chess_char_5_diy2_b",2,"none",1360,1,15,1,75980,0,0,155,3], + ["p1",9,"op","chess_char_5_diy1_a",2,null,1218,1,4,0,0,0,30020,74,4], + ["p1",10,"op","chess_char_5_diy2_b",2,"none",1374,1,10,0,0,0,40697,76,4], + ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,114,0,6399,2796,0,17,1], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",1131,1,111,0,9466,2262,0,23,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_1381_winman":[27,4,23,1661,85135],"enemy_1381_winman_2":[39,3,32,20045,100512],"enemy_1387_winshd":[30,1,25,55228,90458]}, + "hooks": [1,115,6900,2302,2302,2152,2152,150,13,93,36,31,0,39947,40,20,96,80,1,13,0,0,0], + "events": {"atk":2302,"deploy":16,"die":13,"dmg":2152,"heal":150,"leak":80,"skill":67,"spawn":111,"status":57}, + "fx": {}, + "snaps": "0c70223b 8876b977 95ebcf88 847125ea b16e9e48 a5d17a72 3b727b05 d0175d34 5b032c82 e9d4a061 9150ccd1 acdabdc7 127f7345 999513d8 e6ebe999 1ec971ba f9df8603 43b4e06a b437c3f8 dc0ec48d b5221902 7eff9446 8f5e1779" + }, + "diy-055": { + "about": "5_diy1_a=acfend 5_diy2_b=acfend 5_diy1_a=acpion 5_diy2_b=acpion 5_diy1_a=acguad 5_diy2_b=acguad 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc", + "fast": false, + "kind": "normal", + "stageId": "act2autochess_m04", + "modeId": "mode_multi_normal", + "round": 12, + "seed": 1597707536, + "waveId": "act1autochess_h05", + "time": 221.63, + "ticks": 6649, + "reason": "cleared", + "killed": 100, + "total": 117, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 918, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":100,"total":117,"perfect":false,"leaks":17,"leaksUncounted":0,"coins":0,"dmg":583978,"bossDmg":0,"heal":54030,"deaths":14,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",2,null,2370.15,1,7,0,0,3387,0,0,0], + ["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",1996.1,1,8,0,0,17917,0,0,0], + ["p1",3,"op","chess_char_5_diy1_a",2,null,1529,1,6,8,66172,0,0,0,7], + ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,124,25,98772,7106,0,0,4], + ["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,128,0,0,11659,0,0,0], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",1762.4,1,137,0,0,24076,0,0,0], + ["p1",7,"op","chess_char_5_diy1_a",2,null,1272,1,3,25,71286,0,0,67,2], + ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",1587,1,4,31,290058,0,0,174,3], + ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,132,5,27138,5423,0,8,1], + ["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",0,0,129,6,30554,6749,0,7,1], + ["p1",11,"op","chess_char_5_diy1_a",2,null,1224,1,5,0,0,0,46602,70,2], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",0,0,140,0,0,7071,7429,18,1], + ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], + ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null] + ], + "enemies": {"enemy_1005_yokai":[51,51,0,0,97463],"enemy_1005_yokai_2":[54,49,5,22120,193886],"enemy_1005_yokai_3":[12,0,12,61268,292630]}, + "hooks": [1,140,6649,1115,1115,1769,1686,155,114,131,21,20,0,25143,0,0,117,17,1,114,0,0,83], + "events": {"atk":1297,"deploy":21,"die":114,"dmg":1686,"fx":237,"heal":143,"leak":17,"skill":41,"spawn":131,"status":22}, + "fx": {"aoe":15,"dodge":83,"dp":44,"slash":83,"spGain":12}, + "snaps": "728b67d6 6f920f9a 52d3550a f16ded50 a007fb81 ba536139 34a8d590 97c71e46 16103e6c 81b44abd c395e7f3 1f072f37 1d6605ca 8786f693 5801287c fa9752ad 1f691d2b 04d081ed 01cb5c62 161d0061 093d41d6 bedcd071 9c3113e6" + }, + "diy-056": { + "about": "6_diy1_a=acfend 6_diy2_b=acfend 5_diy1_a=sharp2 5_diy2_b=sharp2 6_diy1_a=acpion 6_diy2_b=acpion 5_diy1_a=acspec 5_diy2_b=acspec 6_diy1_a=acguad 6_diy2_b=acguad 5_diy1_a=acsupo 5_diy2_b=acsupo", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m01", + "modeId": "mode_multi_hard", + "round": 13, + "seed": 3238296381, + "waveId": "act1autochess_h06", + "time": 220.87, + "ticks": 6626, + "reason": "cleared", + "killed": 33, + "total": 135, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 302, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":33,"total":135,"perfect":false,"leaks":102,"leaksUncounted":0,"coins":0,"dmg":318418,"bossDmg":0,"heal":0,"deaths":30,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",2,null,0,0,7,2,19515,2604,0,46,1], + ["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",0,0,84,0,2684,6862,0,14,0], + ["p1",3,"op","chess_char_5_diy1_a",0,null,0,0,118,0,8569,3730,0,17,0], + ["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",0,0,13,1,4817,2374,0,8,0], + ["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,142,0,3639,4587,0,9,0], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",0,0,160,4,23276,5712,0,13,2], + ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,167,3,38504,9232,0,43,0], + ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",1448,1,171,4,43499,10136,0,51,0], + ["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,10,0,18610,2638,0,13,1], + ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",0,0,82,4,35108,7062,0,22,1], + ["p1",11,"op","chess_char_5_diy1_a",2,null,1160,1,4,7,52118,0,0,133,2], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",1430,1,5,8,68077,0,0,144,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_1195_sfyin": [39,6,33,11472,101857], + "enemy_1196_msfyin": [12,10,2,0,22], + "enemy_1197_sfshu": [48,8,40,17435,164002], + "enemy_1198_msfshu": [24,9,15,0,35], + "enemy_1207_sfji": [12,0,12,26031,52502] + }, + "hooks": [1,171,6626,771,771,938,935,0,63,165,10,24,0,15263,9924,53,135,102,1,72,0,0,10], + "events": {"atk":771,"deploy":33,"die":63,"dmg":898,"fx":100,"leak":102,"skill":49,"spawn":150,"status":763}, + "fx": {"aoe":16,"displace":27,"dodge":22,"dp":6,"slash":15,"summon":14}, + "snaps": "f8b5dfe8 d9e0d502 6e94838a 6b3d7d80 a2825999 335bbab4 b4e559aa be3de37a 09ef0915 8b5a719c 69701297 92c2ff26 ac137f70 8765e11d 7ea1daac 8d484ded 36bbc03f 599f28a5 b6a7e5c0 87e341e6 8f9d34b4 730c99d2" + }, + "diy-057": { + "about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc 6_diy1_a=acsupo 6_diy2_b=acsupo", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m02", + "modeId": "mode_single_normal", + "round": 13, + "seed": 583790378, + "waveId": "act1autochess_h06", + "time": 230, + "ticks": 6900, + "reason": "timeout", + "killed": 20, + "total": 147, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 1548, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":20,"total":147,"perfect":false,"leaks":127,"leaksUncounted":0,"coins":0,"dmg":606525,"bossDmg":0,"heal":80261,"deaths":15,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",0,null,994.83,1,173,6,29904,4600,0,46,1], + ["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",1334.76,1,160,4,70625,3653,0,103,2], + ["p1",3,"op","chess_char_6_diy1_a",2,null,1154,1,172,0,2842,5770,0,77,0], + ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",1484,1,171,1,19763,8078,0,82,0], + ["p1",5,"op","chess_char_6_diy1_a",2,null,1272,1,5,1,54315,0,0,217,3], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",1643,1,8,2,100440,0,0,204,3], + ["p1",7,"op","chess_char_6_diy1_a",2,null,1372,1,6,2,59142,0,0,62,2], + ["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",1819,1,9,1,235300,0,0,82,3], + ["p1",9,"op","chess_char_6_diy1_a",2,null,1224,1,10,0,0,26739,39996,77,3], + ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",1464,1,11,0,0,24521,40265,84,3], + ["p1",11,"op","chess_char_6_diy1_a",2,null,1160,1,12,2,23266,28343,0,146,4], + ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",0,0,151,1,10929,2900,0,54,1] + ], + "enemies": {"enemy_1166_dusbr_2":[39,9,30,25663,188457],"enemy_1169_duphlx_2":[57,7,49,65001,214974],"enemy_1170_dushld_2":[51,4,47,13941,203094]}, + "hooks": [1,173,6900,1600,1600,2888,2989,283,35,161,25,31,0,33493,9368,33,147,126,1,35,0,0,0], + "events": {"atk":1976,"deploy":26,"die":35,"dmg":2971,"fx":62,"heal":248,"leak":126,"skill":70,"spawn":159,"status":1082}, + "fx": {"aoe":19,"displace":11,"dodge":32}, + "snaps": "52c111b1 b3888005 54228bd9 fed55d11 15be21e1 03270ab2 a7ceabe9 58cb89e8 89d98da8 ec18ec96 9bb38715 662b8da7 1b544744 213682e7 2ed156db c40d1e3b ec410ed5 00453988 dfb63b8f 0136ea36 7616f3d2 69a25d94 f45e4116" } } }