From c454b610756058e23884c3a2ec2a7477092af236 Mon Sep 17 00:00:00 2001 From: sganggs Date: Tue, 6 Oct 2026 01:25:21 +0800 Subject: [PATCH] =?UTF-8?q?feedback5=20O9:=20=E8=87=AA=E9=80=89=20operator?= =?UTF-8?q?=20kits=20=E5=B8=95=E6=8B=89=E6=96=AF,=20=E6=AD=A2=E9=A2=82=20a?= =?UTF-8?q?nd=20=E8=B4=9D=E6=B4=9B=E5=86=85=20(with=20his=20=E7=89=B5?= =?UTF-8?q?=E7=BB=8A)=20=E2=80=94=20every=20skill,=20talent,=20trait=20and?= =?UTF-8?q?=20module=20(stage=201=20/=203)=20at=20every=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - op-pallas.js (char_485_pallas, 教官): 英雄的诞生 精力充沛 on every 米诺斯 operator (one buff per ally, the highest active bonus — her S3's too), 女神的振奋 on every damage instance (her 1-1, through 禁疗; INS-Y stage 3 the 米诺斯 extra), S1 two strikes, S2 range +1 / ATK / stun (data DEFAULT: build-data's ATTACK_RANGE_CHANGE misses "攻击范围向前延伸一格" — reported, not worked around), S3 three targets and the front-holder buff (low ground, operators only, passed on retreat / deploy). INS-Y's "可以额外部署在远程位" is a prep placement rule (owner's decision of 2026-10-04): unchanged, reported. - op-lessng.js (char_4011_lessng, 无畏者): 苦痛专注 (hit-time cut while blocking, 无来源 / allies / himself included), 痛楚砺刃 (any damage aimed at him, dodged too), S2 the deploy window (2 strikes, ATK, talent ×2), S3 status immunity, blocked-target scale, the cast under a 异常状态 (600 arts first, cleanse, then HP; 沉默 does not hold it back), DRE-X ×1.15 on a blocked target, DRE-Y once-per-deployment save and stage 3's 12 % DEF ignore. - op-demetr.js (char_4037_demetr, 斗士): 【手段】 (one per enemy, stacks before the hit, 8 and 停顿 under S2, back to 5 on the 0.1 s update), the HP-ratio damage bonus, 街头直觉 from every deployment (a 【移动】 too), S1 two strikes, S2 3-13 / three targets, S3 the 【移动】s (x-2 around his located tile, buildable low-ground tiles, in-place move without a target, re-target after 1 s without damage or when the elite he hit falls), 牵绊 (无敌 / untargetable / 孤立), the fatal-hit 不死 + end, the 【返回】 (SP emptied, a short 不死) and the forced retreat; FGT-Y ASPD above half HP. - kits/index.js: the three files appended to OPERATOR_KIT_FILES. - Tests: test/content/op_pallas.test.js (9), op_lessng.test.js (10), op_demetr.test.js (10). - Golden: only diy moved — diy-049 … 054 now field the three operators (every form × module × skill), the prototype / reserve battles moved down to diy-054 … 058 (new ids, same packing); diy-001 … 048 and every other family unchanged. --- server/sim/content/kits/index.js | 3 + server/sim/content/kits/ops/op-demetr.js | 315 +++++++++++++++ server/sim/content/kits/ops/op-lessng.js | 160 ++++++++ server/sim/content/kits/ops/op-pallas.js | 168 ++++++++ test/content/op_demetr.test.js | 377 ++++++++++++++++++ test/content/op_lessng.test.js | 363 +++++++++++++++++ test/content/op_pallas.test.js | 324 ++++++++++++++++ test/golden/diy.json | 470 +++++++++++++++++------ 8 files changed, 2062 insertions(+), 118 deletions(-) create mode 100644 server/sim/content/kits/ops/op-demetr.js create mode 100644 server/sim/content/kits/ops/op-lessng.js create mode 100644 server/sim/content/kits/ops/op-pallas.js create mode 100644 test/content/op_demetr.test.js create mode 100644 test/content/op_lessng.test.js create mode 100644 test/content/op_pallas.test.js diff --git a/server/sim/content/kits/index.js b/server/sim/content/kits/index.js index 99f513f5..31674214 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-pallas.js', + 'op-lessng.js', + 'op-demetr.js', ]); /** diff --git a/server/sim/content/kits/ops/op-demetr.js b/server/sim/content/kits/ops/op-demetr.js new file mode 100644 index 00000000..efcd419d --- /dev/null +++ b/server/sim/content/kits/ops/op-demetr.js @@ -0,0 +1,315 @@ +// server/sim/content/kits/ops/op-demetr.js — 贝洛内 (char_4037_demetr) 自选 operator kit: 6★ 斗士 (近卫), an owned-6★ pick of +// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait, his module at every form and his summon 牵绊. +// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)"). +// +// Forms (data/backups.json units.char_4037_demetr, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module; +// elite = E2 Lv60, rank 7, FGT-Y (his only module) at stage 1 (tier 5) or 3 (tier 6) or none. Potential 0 [ASSUMED: no +// account]. Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json; the 牵绊 token +// record backups.json tokens.token_10065_demetr_dmtpos); PRTS 贝洛内 (家族手段 备注 "防御减少效果于贝洛内每次普通攻击时,计算伤害前 +// 施加(可影响当次攻击)…防御减少方式为最终乘算…多个贝洛内之间的【手段】间仅防御减少量最高者生效…每0.1秒自然更新一次;当具有≥6层层数 +// 的【手段】自然更新/响应更新时,若该【手段】的来源(贝洛内)不处于2技能状态或已离场,层数将强制变回5层…增伤效果为线性提升,可对任何来自 +// 贝洛内的伤害(不止普通攻击)生效"; 街头直觉 备注 "部署时立刻获得80%物理/法术闪避,之后每秒衰减2%(至多衰减20次),最终变为40%"; +// 家主的余裕 备注 "可触发两次第一天赋"; 军师的手段 备注; 清算 备注 — the 牵绊 rules, "每次移动的目标地块必须为合法、可部署、未部署有 +// 牵绊/贝洛内自己以外单位的地面地块", "在受到致命伤害结算后立即结束技能,因返回而部署时获得短暂的不死", "若技能开启时,若存在目标则会立刻 +// 尝试移动;否则会尝试原地移动一次", "技能结束时,若不位于初始位置且场上不存在牵绊,贝洛内将被强制撤退"); PRTS 牵绊 备注 (无敌, 孤立, +// 晕眩 / 冻结 / 寒冷 / 沉睡 免疫); PRTS 卫戍协议/帮助 特殊策略 (【清算】 "在自定义范围内存在敌人时释放技能", x-2); gamedata_const +// ba.addash 移动 "不退场,以当前血量在目标位置部署"; the client's battle data — charpack char_4037_demetr (every attack ability +// carries demetr_t1[def_dec]; S2 mode `demetr_s2[mark]`; S3 modes), skchr_demetr_3 (FlyToTarget / FlyToTargetSecond: the +// x-2 selector around his located tile, buildable melee low-ground tiles empty but for him and his token; RespawnEnd), buff +// _template_data `demetr_t1[def_dec]` / `[def_dec_core]` / `[dmg_inc]`, `demetr_t2[evade]`, `demetr_s3[bonus]` / `[target_timer]` +// / `[timer]` / `[elite_finish_listener]` / `[undeadable]` / `[restore]`; his skeleton (Skill_3_Back 0.5 s). +// - Trait (斗士) "能够阻挡一个敌人": the profession default (block 1, melee, ground only). Module FGT-Y “实用的工具” "生命值高于 +// 50%时攻击速度+10" (trait bb attack_speed / hp_ratio): ASPD while HP > hp_ratio. +// - T1 家族手段 (bb attack@def / attack@limited_stack_cnt / attack@s2_limited_stack_cnt / attack@def_dec_duration, +// min/max_hp_ratio, min/max_add_on_scale): each of his attack hits puts 【手段】 on its target before the damage (it counts +// for that hit): one stack more (at most limited_stack_cnt, s2_limited_stack_cnt while S2 runs), DEF ×(1 − |def| × stacks) +// (FINAL_SCALER), def_dec_duration s from the last hit; at s2_limited_stack_cnt stacks the target is 停顿 (sluggish) for as +// long as it keeps them; every 0.1 s a 【手段】 above limited_stack_cnt whose 贝洛内 is not in S2 (or not on the field) drops +// back to limited_stack_cnt, keeping its time. One 【手段】 per enemy: several 贝洛内 (a shared field) share its stacks and +// the higher per-stack value [ASSUMED: the client keeps one per source and only the highest reduction acts]. And every +// damage of his ×(1 + add-on), the add-on linear from min_add_on_scale at min_hp_ratio of the target's HP to +// max_add_on_scale at max_hp_ratio and below (DamageScaleAccordingToHpRatio, the target's HP before the hit). FGT-Y stage 3: +// 8 % per stack, +42 % at most. +// - T2 街头直觉 (bb init_prob / dec_prob / trig_cnt): from every deployment — a 【移动】 is one (demetr_s3_t[born_sync]) — +// physical and arts dodge init_prob, −dec_prob each second trig_cnt times (80 % → 40 %), then stays. +// - S1 家主的余裕 (AUTO, attack SP 4; data DEFAULT): the next attack strikes its target twice for atk_scale × ATK physical +// (`_additionalTimes` 1 — both strikes land in the attack [ASSUMED: the skeleton's 0.2 s between them is not modelled]); +// each strike adds a 【手段】 stack. +// - S2 军师的手段 (MANUAL, 15 / 18 s; data ACTIVE_RANGE on its 3-13): range 3-13, ASPD +attack_speed, every attack strikes +// attack@max_target targets for attack@atk_scale × ATK physical; 【手段】 up to s2_limited_stack_cnt and 停顿 at the cap. Its +// attack is a projectile in the client (projectile_chr_demetr_s2): the hits land at the attack [ASSUMED: flight not +// modelled]; ground targets only, as every attack of his (selector targetMotion 1). +// - S3 清算 (MANUAL, 30 s; data CUSTOM_RANGE on x-2): ATK +atk, ASPD +attack_speed, each damage instance attack@…prob to be +// ×prob_atk_scale (AtkScaleUp: on the attack's ATK, before DEF). At the cast he 【移动】s (Battle.moveRedeploy: no exit, HP +// / buffs / the skill kept, deploy effects again — 街头直觉's 80 %) onto the tile of the first ground enemy of the x-2 around +// his located tile (his tile at the cast) whose tile he may take — a melee-buildable low-ground tile of the field, nobody +// on it but his 牵绊 or himself, no knocked-out operator, no other piece's reserved tile — in the usual target order +// (sortEnemyTargets); without one he moves in place once. He takes another target the same way when the elite / leader +// enemy he attacked last (within attack@finish_listener_duration s) is knocked out, and whenever 1 s (interval × time_stack) +// passed without him dealing damage — retried every interval while no target exists, never while he cannot act. Leaving his +// located tile he leaves 牵绊 there (when none of his is up); moving back onto it retreats 牵绊 first. A lethal hit while it +// runs does not knock him out: 不死, untargetable, blocking nothing, 禁疗 (demetr_s3[undeadable]) and the skill ends on the +// next tick. At the end he returns — a 【移动】 onto his located tile (in place when he is there) that empties his SP — and +// gets a short 不死 (RETURN_UNDYING); not on that tile and no 牵绊 of his up (or the tile taken) ⇒ forced retreat ('retreat'). +// The client's wind-up / landing clips (Skill_3_ReStart_Begin 0.2 s, _End 0.3 s) are not modelled: each move is instant +// [ASSUMED]. +// - 牵绊 (token_10065_demetr_dmtpos, S3 only — its variant bySkill 2): "贝洛内技能结束时返回该位置,并且其他干员不能部署" — it holds +// his located tile; 无敌, untargetable, 孤立, no attack, blocks nothing (blockCnt 0) — PRTS 牵绊 备注. +// Melee, ground-only (data canHitFly false: no anti-air); ground enemies target him normally (no 起飞 / 迷彩 / 隐匿). + +import { num, talentBb, traitBb, skillRec, up, onHitBy, toggleBuff, once } from '../shared/tier1.js'; +import { isElite } from '../shared/tier6.js'; +import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js'; + +const S1 = 'skchr_demetr_1'; +const S2 = 'skchr_demetr_2'; +const S3 = 'skchr_demetr_3'; +export const BOND = 'token_10065_demetr_dmtpos'; +/** S3's search area when the data carries none (x-2: the 5 × 5 square around him without its corners). */ +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]]); +/** "因返回而部署时获得短暂的不死" (PRTS 备注; no number): his return clip, Skill_3_Back 0.5 s [ASSUMED]. */ +export const RETURN_UNDYING = 0.5; +/** 【手段】 on an enemy (one per enemy) and its 停顿 at the S2 cap; the 【手段】 refresh tick (PRTS 备注 "每0.1秒自然更新一次"). */ +export const MEANS_KEY = 'talent:demetr:means'; +export const MEANS_SLUG_KEY = 'talent:demetr:means:sluggish'; +const MEANS_TICK = 0.1; +export const STREET_KEY = 'talent:demetr:street'; +export const UNDEAD_KEY = 'skill:demetr:undeadable'; + +const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; +const s2On = (u) => !!(u && u.skill && u.skill.active && u.skill.id === S2); +const s3On = (u) => !!(u && u.skill && u.skill.active && u.skill.id === S3); + +/** 牵绊's kit (a fresh one per summon): no attack, 无敌 and 孤立 (spawnToken adds untargetable). */ +export function bondKit() { + return { + skill: null, + trait: { noAttack: true }, + install(battle, tok) { + battle.addBuff(tok, { key: 'token:demetr:bond', flags: { invulnerable: true, isolated: true }, persist: true, allowDead: true }); + }, + }; +} + +/** Put / refresh 【手段】 on `e` (the stack rule of demetr_t1[def_dec]) and its 停顿 at the S2 cap. */ +function applyMeans(battle, unit, e, p) { + const cur = e.findBuff(MEANS_KEY); + const old = cur?.data?.stacks ?? 0; + const cap = s2On(unit) ? p.cap8 : p.cap5; + const stacks = old < cap ? old + 1 : old; + const other = cur && cur.data?.src !== unit && up(cur.data?.src) ? num(cur.data?.pct) : 0; + const pct = Math.max(p.pct, other); + setMeans(battle, e, unit, { stacks, pct, cap5: p.cap5, cap8: p.cap8 }, p.dur); +} +function setMeans(battle, e, src, data, dur) { + battle.addBuff(e, { key: MEANS_KEY, duration: dur, refresh: 'replace', source: src, mods: { defMul: Math.max(0, 1 - data.pct * data.stacks) }, data: { ...data, src }, tags: ['talent'] }); + if (data.stacks >= data.cap8) battle.addBuff(e, { key: MEANS_SLUG_KEY, duration: dur, refresh: 'replace', source: src, status: 'sluggish', mods: { moveMul: 0.2 }, visible: true, tags: ['talent'] }); + else battle.removeBuff(e, MEANS_SLUG_KEY); +} +/** The 0.1 s update of every 【手段】 (battle-wide, once): above the normal cap without its 贝洛内 in S2 ⇒ back to it. */ +function installMeansTick(battle) { + once(battle, 'demetr:means', () => battle.every(MEANS_TICK, () => { + for (const e of battle.enemies) { + if (!e.alive) continue; + const b = e.findBuff(MEANS_KEY); + const d = b?.data; + if (!d || !(d.stacks > d.cap5)) continue; + if (d.src && d.src.alive && d.src.deployed && s2On(d.src)) continue; + setMeans(battle, e, d.src, { stacks: d.cap5, pct: d.pct, cap5: d.cap5, cap8: d.cap8 }, b.timeLeft); + } + })); +} + +export default { + char_4037_demetr: (bb, chess) => { + const tb = traitBb(chess); // FGT-Y: attack_speed / hp_ratio + const t0 = talentBb(chess, 0); // 家族手段 (FGT-Y stage 3: def −0.08, max_add_on_scale 0.42) + const t1 = talentBb(chess, 1); // 街头直觉 + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + const s2 = skillRec(chess, S2), s3 = skillRec(chess, S3); + const area = s3?.trigger?.customRangeGrid ?? X2; + const means = { + pct: Math.abs(num(t0['attack@def'])), cap5: Math.max(1, Math.floor(num(t0['attack@limited_stack_cnt'], 5))), + cap8: Math.max(1, Math.floor(num(t0['attack@s2_limited_stack_cnt'], 8))), dur: num(t0['attack@def_dec_duration'], 10), + }; + const hpHi = num(t0.min_hp_ratio, 1), hpLo = num(t0.max_hp_ratio, 0.2); + const addLo = num(t0.min_add_on_scale), addHi = num(t0.max_add_on_scale); + const iv = num(b3['attack@demetr_s3[target_timer].interval'], 0.5); + const idle = iv * Math.max(1, num(b3['attack@demetr_s3[target_timer].time_stack'], 2)); + const listen = num(b3['attack@finish_listener_duration'], 3); + const prob = num(b3['attack@demetr_s3[bonus].prob']), probScale = num(b3['attack@demetr_s3[bonus].prob_atk_scale'], 1); + + /** A tile he may 【移动】 onto (FlyToTarget selector options, PRTS 备注). */ + const tileOk = (battle, unit, st, r, c) => { + if (!battle.grid.inRect(r, c) || !battle.grid.canStand(r, c) || !battle.grid.isLow(r, c) || battle.downOn(r, c)) return false; + if (r === unit.tileR && c === unit.tileC) return true; + const occ = battle.unitAt(r, c); + if (occ) return occ === st.bond; + return !battle.isReservedTile(r, c); + }; + /** The first ground enemy of the x-2 around his located tile standing on a tile he may take. */ + const pickTarget = (battle, unit, st) => { + const keys = absoluteRangeKeys(area, st.home[0], st.home[1], unit.dir, 0); + const list = battle.enemiesInKeys(keys, unit, { canHitFly: false, groundOnly: true }) + .filter((e) => !e.isFlying && tileOk(battle, unit, st, Math.round(e.y), Math.round(e.x))); + sortEnemyTargets(battle, unit, list, unit.profile?.priority ?? null); + return list[0] ?? null; + }; + const retreatBond = (battle, st) => { + if (st.bond && st.bond.alive) battle.retreat(st.bond, { reason: 'expired', permanent: true }); + st.bond = null; + }; + /** One 【移动】 of S3 onto (r, c): 牵绊 retreated first when it is his located tile, left there when he leaves it. */ + const moveTo = (battle, unit, st, r, c) => { + const [hr, hc] = st.home; + const home = r === hr && c === hc; + if (home && (unit.tileR !== hr || unit.tileC !== hc)) retreatBond(battle, st); + const fromX = unit.x, fromY = unit.y; + if (!battle.moveRedeploy(unit, r, c)) return false; + battle.fx('teleport', { x: unit.x, y: unit.y, id: unit.id, fromX, fromY }); + if (!home && !(st.bond && st.bond.alive)) st.bond = battle.spawnToken(unit, BOND, hr, hc, { untargetable: true, kit: bondKit() }); + return true; + }; + /** FlyToTarget(Second): move onto the next target's tile; false without one (or when he cannot act). */ + const flyTo = (battle, unit, st) => { + if (!up(unit) || !unit.canAct || st.undead) return false; + const t = pickTarget(battle, unit, st); + if (!t || !moveTo(battle, unit, st, Math.round(t.y), Math.round(t.x))) return false; + st.next = battle.time + idle; + return true; + }; + + return { + skills: { + [S1]: { kind: 'instant', attack: { atkScale: num(b1.atk_scale, 1), hits: 2 } }, + [S2]: { + kind: 'duration', + mods: { aspd: num(b2.attack_speed) }, + targeting: { ...(s2?.rangeGrid?.length ? { rangeGrid: s2.rangeGrid } : {}), maxTargets: Math.max(1, num(b2['attack@max_target'], 3)) }, + attack: { atkScale: num(b2['attack@atk_scale'], 1) }, + }, + [S3]: { + kind: 'duration', + mods: { atkPct: num(b3['attack@demetr_s3[bonus].atk']), aspd: num(b3['attack@demetr_s3[bonus].attack_speed']) }, + onStart({ battle, unit, skill }) { + const st = { home: [unit.tileR, unit.tileC], bond: null, next: battle.time + idle, act: skill.activations, lastHit: null, lastHitAt: -Infinity, retarget: false, undead: false }; + unit.mem.demetrS3 = st; + // "若技能开启时,若存在目标则会立刻尝试移动;否则会尝试原地移动一次" + if (!flyTo(battle, unit, st) && up(unit)) moveTo(battle, unit, st, unit.tileR, unit.tileC); + }, + onTick({ battle, unit }) { + const st = unit.mem.demetrS3; + if (!st) return; + // the elite / leader he attacked last fell: FlyToTargetSecond at once (one try; the idle timer runs on) + if (st.retarget) { + st.retarget = false; + if (flyTo(battle, unit, st)) return; + } + if (battle.time + 1e-9 < st.next) return; + // no target (or he cannot act): the timer keeps counting — the next try at its next interval + if (!flyTo(battle, unit, st)) while (st.next <= battle.time + 1e-9) st.next += iv; + }, + onEnd({ battle, unit }) { + const st = unit.mem.demetrS3; + unit.mem.demetrS3 = null; + if (!st) return; + battle.removeBuff(unit, UNDEAD_KEY); + if (!unit.alive || !unit.deployed) { retreatBond(battle, st); return; } + const [hr, hc] = st.home; + const atHome = unit.tileR === hr && unit.tileC === hc; + const bondUp = !!(st.bond && st.bond.alive); + retreatBond(battle, st); + const fromX = unit.x, fromY = unit.y; + // 【返回】: a 【移动】 onto the located tile (in place when he stands on it) that empties the SP (demetr_s3[restore]) + if ((atHome || bondUp) && battle.moveRedeploy(unit, hr, hc, { clearSp: true })) { + if (!atHome) battle.fx('teleport', { x: unit.x, y: unit.y, id: unit.id, fromX, fromY }); + unit.mem.demetrGraceUntil = battle.time + RETURN_UNDYING; + return; + } + battle.retreat(unit, { reason: 'retreat' }); // "贝洛内将被强制撤退" + }, + }, + }, + talents: [ + { install(battle, unit) { // 家族手段 + installMeansTick(battle); + battle.on('hit', (c) => { + if (c.source !== unit || !c.dmg?.isAttack || !c.target || c.target.side !== 'enemy' || !c.target.alive) return; + if (means.pct > 0) applyMeans(battle, unit, c.target, means); + }, { owner: unit, priority: 10 }); + if (addHi || addLo) { + onHitBy(battle, unit, (c) => { + const r = c.target.hpRatio; + const t = hpHi > hpLo ? Math.min(1, Math.max(0, (hpHi - r) / (hpHi - hpLo))) : (r <= hpLo ? 1 : 0); + c.dmg.mul *= 1 + addLo + (addHi - addLo) * t; + }); + } + } }, + { install(battle, unit) { // 街头直觉 + const init = num(t1.init_prob), dec = num(t1.dec_prob), cnt = Math.max(0, Math.floor(num(t1.trig_cnt))); + if (!(init > 0)) return; + const set = (p) => battle.addBuff(unit, { key: STREET_KEY, mods: { dodgePhys: p, dodgeArts: p }, data: { p }, tags: ['talent'] }); + battle.on('deploy', (ctx) => { + if (ctx.unit !== unit) return; + const seq = unit.deploySeq; + let p = init, n = 0; + set(p); + if (!(dec > 0) || !cnt) return; + battle.every(1, (b, sc) => { + if (!unit.alive || unit.deploySeq !== seq) { sc.cancel(); return; } + p = Math.abs(p - dec); + set(p); + if (++n >= cnt) sc.cancel(); + }, { owner: unit }); + }, { owner: unit }); + } }, + ], + install(battle, unit) { + // FGT-Y: 生命值高于50%时攻击速度+10 + const aspd = num(tb.attack_speed); + if (aspd) toggleBuff(battle, unit, 'trait:demetr:aspd', () => unit.hpRatio > num(tb.hp_ratio, 0.5), { aspd }); + // S3 清算: ×prob_atk_scale at prob per damage instance (AtkScaleUp: the attack's ATK, before DEF) + if (prob > 0 && probScale !== 1) { + onHitBy(battle, unit, (c) => { + if (s3On(unit) && battle.rng.chance(prob)) c.dmg.amount *= probScale; + }); + } + // S3: the target he attacked last (an elite / leader one re-targets him when it falls within `listen` s) + battle.on('hit', (c) => { + const st = unit.mem.demetrS3; + if (!st || c.source !== unit || !c.dmg?.isAttack || !c.target || c.target.side !== 'enemy') return; + st.lastHit = c.target; + st.lastHitAt = battle.time; + }, { owner: unit }); + // S3: 1 s without output damage re-targets him (the timer restarts with each damage instance he deals) + battle.on('damaged', (c) => { + const st = unit.mem.demetrS3; + if (st && c.source === unit && c.target && c.target.side === 'enemy') st.next = battle.time + idle; + }, { owner: unit }); + battle.on('death', (c) => { + const st = unit.mem.demetrS3; + if (!st || c.reason !== 'killed' || c.unit !== st.lastHit || !isElite(c.unit) || battle.time - st.lastHitAt > listen + 1e-9) return; + st.lastHit = null; + st.retarget = true; // taken in his next skill tick (the enemy index of that tick sees every enemy) + }, { owner: unit }); + // S3: 受到致命伤害时不撤退但会结束技能 (demetr_s3[undeadable]); the return's short 不死 + battle.on('fatal', (c) => { + if (c.unit !== unit || c.prevented) return; + const st = unit.mem.demetrS3; + if (st && s3On(unit)) { + c.prevented = true; + if (st.undead) return; + st.undead = true; + battle.addBuff(unit, { key: UNDEAD_KEY, flags: { untargetable: true, noBlock: true, healFree: true }, tags: ['skill'], visible: true }); + battle.fx('undying', { x: unit.x, y: unit.y, id: unit.id }); + const act = st.act; + battle.after(0, () => { if (unit.mem.demetrS3 === st && s3On(unit) && unit.skill.activations === act) unit.skill.end('fatal'); }, { owner: unit }); + return; + } + if (battle.time < (unit.mem.demetrGraceUntil ?? -Infinity) - 1e-9) c.prevented = true; + }, { owner: unit, priority: -50 }); + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/op-lessng.js b/server/sim/content/kits/ops/op-lessng.js new file mode 100644 index 00000000..58eb2060 --- /dev/null +++ b/server/sim/content/kits/ops/op-lessng.js @@ -0,0 +1,160 @@ +// server/sim/content/kits/ops/op-lessng.js — 止颂 (char_4011_lessng) 自选 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_4011_lessng, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module; +// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account]. +// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 止颂 (苦痛专注 备注 +// "将降低无来源或任意来源于非“自身阻挡的敌人”的物理、法术伤害…伤害降低效果于“受到伤害前”生效…额外效果为在计算伤害前,若伤害 +// 目标满足条件则临时获取…物理穿透(百分比)Buff(不可叠加,直接加算)"; 痛楚砺刃 备注 "天赋于受到伤害时触发,无需成功受到伤害也可 +// 生效…重复受到伤害时,重置本天赋持续时间"; 苦修破誓 备注 "技能期间,持有状态免疫、晕眩反制、寒冷反制、冻结反制…技能开启时,若有 +// 异常状态,会先对自身造成法术伤害(来源为自身,可致死),然后清除自身所有异常状态,最后再获得技能效果"; DRE-X 备注 "于每次计算伤害时检查 +// 目标是否处于阻挡/被阻挡状态…提升效果为攻击力倍率提升"; DRE-Y 备注 "属于免死效果,触发效果后获得属性变化后生命上限100%的生命复原"); +// the client's battle data — buff_template_data `lessng_t1` (ON_APPLYING_MODIFIER: he blocks, the source is not blocked by +// him ⇒ DamageScale 1 − damage_resistance on PHYSICAL_AND_MAGICAL), `lessng_t2` (ON_TAKE_DAMAGE ⇒ ATK MULTIPLIER for +// add_atk_duration), `lessng_s3[atk_scale]` / `lessng_e_002_trait[blocked]` (ON_CALCULATE_DAMAGE, CheckBlocked of the +// target — by anyone — ⇒ AtkScaleUp), `lessng_s3[anti]`, `lessng_e_003_trait` / `lessng_e_003_talent`; charpack +// char_4011_lessng (S2 mode: `_additionalTimes` 1, its talent with `_applyTalentScale`), skchr_lessng_2 (`_castOnLocate`, +// the mode for `duration` s), skchr_lessng_3 (`_canUseInAbnormalState`); his skeleton (Skill_2_Loop: OnAttack 0.300 / 0.567 s). +// - Trait (无畏者) "能够阻挡一个敌人": the profession default (block 1, melee, ground only). Module DRE-X “沉锋之束” "攻击被阻挡 +// 的敌人时攻击力提升至115%" (trait bb atk_scale): ×atk_scale on his attack's ATK against a target blocked by anyone (a +// profile dmgMul — the scale of the attack, before DEF). Module DRE-Y “苦修者的抗压训练” "被击倒时不撤退且回复所有生命但生命上限 +// -60%,攻击速度+30(单次部署只触发1次)" (trait bb max_hp 0.4 / attack_speed / hp_ratio): the first lethal hit of a deployment +// is prevented, max HP ×max_hp (FINAL_SCALER) and ASPD +attack_speed for the rest of that deployment, HP set to hp_ratio of +// the new max (生命复原, not a heal); not while a 不死 already holds him ("_dontConsumeWhenUndeadable": 坚固维式重锤's window, +// items/battle.js holdsUndying). +// - T1 苦痛专注 "阻挡时,受到来自非自身阻挡敌人的物理和法术伤害降低35%" (bb damage_resistance): while he blocks an enemy, a +// physical or arts damage instance whose source is not an enemy he blocks — 无来源, an ally, himself included — is +// ×(1 − damage_resistance) (`hit`: before the 伤判 / barrier steps). S2's window doubles it (talent_scale). DRE-Y stage 3: +// 40 %, and "攻击自身阻挡的敌人时,无视目标12%的防御力" (the hidden module talent's def_penetrate: defIgnorePct on his damage to an +// enemy he blocks). +// - T2 痛楚砺刃 "受到伤害后,攻击力+12%,持续15秒(不可叠加)" (bb atk / add_atk_duration): every damage instance aimed at him +// (`hit`, so a dodged or shielded one too; never a 流失) sets ATK +atk for add_atk_duration s, refreshed, never stacked. +// DRE-X stage 3: +20 %. +// - S1 强力击·γ型 (AUTO, attack SP 3; data DEFAULT): the next attack at atk_scale × ATK. +// - S2 虔修对决 (PASSIVE, 部署后, 18 / 21 s): from every deployment for the skill's duration: 苦痛专注 ×talent_scale, ATK +atk and +// every attack strikes twice (2连击 — both strikes land in the attack [ASSUMED: the skeleton's 0.267 s between them is not +// modelled]); then back to his single attack (lessng_s2[switch_mode]). +// - S3 苦修破誓 (MANUAL, 20 s; data DEFAULT): max HP +max_hp (MULTIPLIER), attacks on a blocked target (by anyone) at +// lessng_s3[atk_scale] × ATK, immune to every 异常状态 (晕眩, 寒冷, 冻结 … — tier6 ABNORMAL) while it runs. Cast while under a +// 异常状态: magical_value arts damage to himself first (source himself, may knock him out; 苦痛专注 and 痛楚砺刃 see it as any +// damage), then every 异常状态 is cleansed, then the effects apply (the HP buff after the damage). "可以无视异常状态开启": the +// basic strategy casts it when he is about to attack, and 沉默 does not hold it back; 晕眩 / 冻结 / 沉睡 stop his attack and so +// the cast [ASSUMED: the official 基础策略 waits for an attack too]. +// Melee, ground-only (data canHitFly false: no anti-air); ground enemies target him normally (no 起飞 / 迷彩 / 隐匿). + +import { num, talentBb, moduleBb, traitBb, skillRec, up } from '../shared/tier1.js'; +import { ABNORMAL, hasAbnormal, cleanseAbnormal } from '../shared/tier6.js'; +import { holdsUndying } from '../../items/battle.js'; + +const S1 = 'skchr_lessng_1'; +const S2 = 'skchr_lessng_2'; +const S3 = 'skchr_lessng_3'; +/** S2 虔修对决's window after a deployment (2连击, ATK, 苦痛专注 ×talent_scale). */ +export const DUEL_KEY = 'skill:lessng:duel'; +/** S3 苦修破誓's effects while it runs (max HP; the status immunity and the blocked-target scale read it). */ +export const OATH_KEY = 'skill:lessng:oath'; +export const PAIN_KEY = 'talent:lessng:pain'; +export const REBORN_KEY = 'trait:lessng:reborn'; + +const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; +/** He blocks at least one living enemy (苦痛专注 "阻挡时"). */ +const blocking = (u) => u.blocking.some((e) => e && e.alive && e.blockedBy === u); + +export default { + char_4011_lessng: (bb, chess) => { + const tb = traitBb(chess); // DRE-X: atk_scale; DRE-Y: max_hp / attack_speed / hp_ratio / value + const t0 = talentBb(chess, 0); // 苦痛专注 (DRE-Y stage 3: 0.4) + const t1 = talentBb(chess, 1); // 痛楚砺刃 (DRE-X stage 3: 0.2) + const hb = moduleBb(chess); // DRE-Y stage 3: def_penetrate + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + const s2 = skillRec(chess, S2); + const blockedScale = num(tb.atk_scale, 1); + const oathScale = num(b3['lessng_s3[atk_scale].atk_scale'], 1); + const duelOn = (u) => !!u.findBuff(DUEL_KEY); + const oathOn = (u) => !!u.findBuff(OATH_KEY); + return { + skills: { + [S1]: { kind: 'instant', attack: { atkScale: num(b1.atk_scale, 1) } }, + [S2]: { + kind: 'passive', + onStart({ battle, unit }) { // every deployment (skills.js reset → passive start) + const dur = num(s2?.duration); + if (dur > 0) battle.addBuff(unit, { key: DUEL_KEY, duration: dur, mods: { atkPct: num(b2.atk) }, tags: ['skill'], visible: true }); + }, + }, + [S3]: { + kind: 'duration', + onStart({ battle, unit }) { + if (hasAbnormal(unit)) { + battle.dealDamage(unit, unit, { amount: num(b3.magical_value), type: 'arts', canDodge: false, isSkill: true, tags: ['skill', 'lessngOath'] }); + if (!unit.alive) return; + cleanseAbnormal(battle, unit); + } + battle.addBuff(unit, { key: OATH_KEY, mods: { hpPct: num(b3.max_hp) }, tags: ['skill'], visible: true }); + }, + onEnd({ battle, unit }) { battle.removeBuff(unit, OATH_KEY); }, + }, + }, + trait: { + // S2's 2连击 (only while its window runs; 1 otherwise and under every other skill) + hitsFn: (b, u) => (duelOn(u) ? 2 : 1), + // DRE-X ×atk_scale and S3 ×lessng_s3[atk_scale] on a blocked target (CheckBlocked of the target: by anyone) + dmgMul: (b, u, t) => (t && t.blockedBy ? blockedScale * (oathOn(u) ? oathScale : 1) : 1), + }, + talents: [ + { install(battle, unit) { // 苦痛专注 + const cut = num(t0.damage_resistance), scale2 = num(b2.talent_scale, 1); + if (!(cut > 0)) return; + battle.on('hit', (c) => { + if (c.target !== unit || !up(unit) || !blocking(unit)) return; + const d = c.dmg; + if (!d || (d.type !== 'phys' && d.type !== 'arts')) return; + const src = c.source; // null = 无来源 + if (src && src.side === 'enemy' && src.blockedBy === unit) return; + d.mul *= Math.max(0, 1 - cut * (duelOn(unit) ? scale2 : 1)); + }, { owner: unit }); + } }, + { install(battle, unit) { // 痛楚砺刃 + const atk = num(t1.atk), dur = num(t1.add_atk_duration, 15); + if (!atk) return; + battle.on('hit', (c) => { + if (c.target !== unit || !up(unit)) return; + battle.addBuff(unit, { key: PAIN_KEY, duration: dur, refresh: 'replace', mods: { atkPct: atk }, tags: ['talent'], visible: true }); + }, { owner: unit }); + } }, + ], + install(battle, unit) { + // DRE-Y stage 3: 攻击自身阻挡的敌人时,无视目标12%的防御力 (every damage of his to an enemy he blocks) + const pen = num(hb.def_penetrate); + if (pen > 0) { + battle.on('hit', (c) => { + if (c.source === unit && c.target && c.target.side === 'enemy' && c.target.blockedBy === unit) c.dmg.defIgnorePct += pen; + }, { owner: unit }); + } + // DRE-Y trait: 被击倒时不撤退且回复所有生命但生命上限-60%,攻击速度+30(单次部署只触发1次) + if (tb.hp_ratio != null && (tb.max_hp != null || tb.value != null)) { + const hpMul = tb.max_hp != null ? num(tb.max_hp, 0.4) : 1 - num(tb.value, 0.6); + let usedSeq = -1; + battle.on('fatal', (c) => { + if (c.unit !== unit || c.prevented || usedSeq === unit.deploySeq || holdsUndying(battle, unit)) return; + c.prevented = true; + usedSeq = unit.deploySeq; + battle.addBuff(unit, { key: REBORN_KEY, mods: { hpMul: Math.max(0.05, hpMul), aspd: num(tb.attack_speed) }, tags: ['trait'], visible: true }); + unit.hp = Math.max(1, unit.s.maxHp * num(tb.hp_ratio, 1)); + battle.fx('revive', { x: unit.x, y: unit.y, id: unit.id, module: true }); + }, { owner: unit, priority: -60 }); + } + // S3 苦修破誓: status immunity while it runs; "可以无视异常状态开启" — 沉默 does not hold the basic-strategy cast back + battle.on('beforeStatus', (c) => { + if (c.target === unit && oathOn(unit) && ABNORMAL.has(c.status)) c.cancel = true; + }, { owner: unit }); + battle.on('beforeAttack', (c) => { + const sk = unit.skill; + if (c.attacker !== unit || !sk || sk.id !== S3 || !sk.ready || sk.active || sk.opCooling || !unit.s.flags.silence) return; + sk.activate('abnormal'); + }, { owner: unit }); + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/op-pallas.js b/server/sim/content/kits/ops/op-pallas.js new file mode 100644 index 00000000..3f91f5d0 --- /dev/null +++ b/server/sim/content/kits/ops/op-pallas.js @@ -0,0 +1,168 @@ +// server/sim/content/kits/ops/op-pallas.js — 帕拉斯 (char_485_pallas) 自选 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_485_pallas, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module; +// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account]. +// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 帕拉斯 (英勇的祝福 备注 +// "持有增益的干员撤退时,增益立刻转交至帕拉斯;自身持有增益,满足条件的干员部署时,帕拉斯的增益立刻转交给对方"; 女神的振奋 备注 +// "本天赋无视自身获得的禁疗,且必定进行治疗"); gamedata_const ba.strong 精力充沛 "生命值高于一定比例时获得一定属性加成(同类属性取 +// 最高)"; the client's battle data — charpack char_485_pallas (英雄的诞生: an aura on every ally tagged `minos`; S3: an aura on +// her 1-1 for operators only, professionMask without TOKEN / TRAP), skchr_pallas_1 (`_additionalTimes` 1), buff_template_data +// `pallas_t_2` / `pallas_t_2[heal]`, `pallas_s_3[peak]`, `pallas_e_t_2[trigger]` and [uc]equips pallas_equip_2_x_p2. +// - Trait (教官) "可以攻击到较远敌人,攻击自身未阻挡的敌人时攻击力提升至120%": the profession profile (professions.js +// instructor: ×atk_scale on a target she does not block, her attacks only; range 2-2). Module INS-X “故乡的山岩” raises +// it to 130% (its traitOverride bb atk_scale, read by the profile through the composed record). +// - T1 英雄的诞生 "在场时,所有【米诺斯】干员生命值高于80%时获得+25%攻击力的精力充沛" (bb peak_performance.hp_ratio / .atk): +// while she is deployed, every allied operator of nation minos (her included — the aura has no self exclusion) gets ATK +// +atk while its HP ratio is above hp_ratio. INS-X stage 3: above 50 %, +30 % (the module's talent change). 精力充沛 of +// several sources is "同类属性取最高": one buff per ally carrying the highest ATK bonus whose HP condition holds (her T1 +// and her S3 alike, several 帕拉斯 of a shared field too), re-evaluated every tick. 帕拉斯 is the only minos operator of +// the data (chess.json and backups.json), so in practice the talent is hers. +// - T2 女神的振奋 "每攻击一名敌人时为自身与身前一格的我方干员恢复40点生命值" (bb value): pallas_t_2 is ON_OUTPUT_DAMAGE — every +// damage instance she deals (S1's two strikes, each S3 target) heals every ally on her 1-1 (her tile and the tile in +// front, rotated with her) `value` HP, a heal that ignores 禁疗 (FixedValueHeal _ignoreHealFree; PRTS 备注) but not +// "不成为其他角色的治疗目标" (the heal's selector is HEAL-purpose). INS-Y “赫里亚之辉” stage 2+: value 60 and "并使所有【米诺斯】干员 +// 额外恢复40点生命值" (talent bb pallas_e_t_2.value): pallas_e_t_2[trigger] (ON_OUTPUT_DAMAGE) uses pallas_e_heal on every +// ally its aura marks — tags minos, _selfOption INCLUDE, so herself too — for that much more (also ignoring 禁疗). +// - Module INS-Y "可以额外部署在远程位" (hidden module talent buildable_type 2) is a placement rule of the prep, not of the +// battle: by the owner's decision of 2026-10-04 (shared/highGround.js) only elite 歌蕾蒂娅 + HOK-Y may take a 高台, so a +// 帕拉斯 piece stays on the melee tiles — an open question for the owner (DESIGN draft 0.2.0.o9), nothing in this kit. +// - S1 胜利的连击 (AUTO, attack SP 3; data DEFAULT): the next attack strikes its target twice for atk_scale × ATK physical +// (skchr_pallas_1 `_additionalTimes` 1, `_triggerDelta` 0.1 s: the second strike 0.1 s after the first — both land in the +// attack here [ASSUMED: the 0.1 s is not modelled]); the instructor ×1.2 applies to each strike. +// - S2 信念的长鞭 (MANUAL, 20 / 22 s): attack range +ability_range_forward_extend tile forward (2-2 ⇒ 4 tiles: targeting +// rangeExtend, the card's live range follows), ATK +atk, every attack attack@buff_prob to stun its target attack@stun s. +// Its trigger is the data's DEFAULT: tools/build-data.mjs ATTACK_RANGE_CHANGE does not read "攻击范围向前延伸一格", so the +// owner's ACTIVE_RANGE rule (a running range that strictly contains the own one) does not reach it — reported, not +// worked around (the brief: report a trigger that looks wrong). +// - S3 英勇的祝福 (MANUAL, 30 s; data DEFAULT): ATK +atk, attacks hit attack@max_target (3) targets; the operator deployed +// on the tile in front of her, when that tile is low ground (pallas_s_3[peak] CheckHeightTypeOfRootTile LOWLAND: on any +// other tile the buff is finished on it), holds "生命值高于80%获得+35%攻击力的精力充沛、防御力+20%、阻挡数+1" +// (attack@peak_performance.hp_ratio / .atk, attack@def, attack@block_cnt) — else she holds it herself; the holder is +// re-read every tick, so it passes to her the moment the front operator leaves and back when one deploys there (PRTS +// 备注). Summons do not hold it (the aura's professionMask). The DEF / block part is per source (independentCharacterSource). +// Melee, ground-only (data canHitFly false: no anti-air); ground enemies target her normally (no 起飞 / 迷彩 / 隐匿). + +import { num, talentBb, skillRec, up, alliesInGridOf } from '../shared/tier1.js'; +import { frontOf } from '../../../dir.js'; + +const S1 = 'skchr_pallas_1'; +const S2 = 'skchr_pallas_2'; +const S3 = 'skchr_pallas_3'; +/** The nation of 英雄的诞生 / pallas_e_heal (the client's `minos` tag = character_table nationId minos). */ +const MINOS = 'minos'; +/** T2's heal area: her 1-1 (pallas_t_2 `_rangeId` 1-1) — her tile and the tile in front. */ +const RANGE_1_1 = Object.freeze([[0, 0], [0, 1]]); +/** The one 精力充沛 buff of an ally (ba.strong "同类属性取最高"). */ +export const PEAK_KEY = 'talent:pallas:peak'; +/** The S3 holder's DEF / block buff, per 帕拉斯 (independentCharacterSource). */ +export const holderKey = (unit) => `skill:pallas:blessing#${unit.id}`; + +const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; +const isMinos = (a) => !!a && a.kind === 'op' && (a.def?.raw?.nationId ?? a.def?.nationId) === MINOS; + +/** + * The 精力充沛 sources of a battle (a Set of `(ally) => ATK bonus`, 0 when one does not apply); the first registration + * installs the battle-wide tick that keeps, on every deployed ally, one buff with the highest bonus whose HP condition + * holds (never a sum: "同类属性取最高"). + */ +const PEAKS = new WeakMap(); +function peakSources(battle) { + let set = PEAKS.get(battle); + if (set) return set; + PEAKS.set(battle, (set = new Set())); + battle.on('tick', () => { + for (const a of battle.allyUnits) { + if (!a.alive || !a.deployed || a.kind === 'device') continue; + let v = 0; + for (const f of set) v = Math.max(v, f(a)); + const cur = a.findBuff(PEAK_KEY); + if ((cur?.data?.v ?? 0) === v) continue; + if (v > 0) battle.addBuff(a, { key: PEAK_KEY, mods: { atkPct: v }, data: { v }, tags: ['talent'] }); + else battle.removeBuff(a, PEAK_KEY); + } + }); + return set; +} + +/** + * The holder of 英勇的祝福's buff: the operator deployed on the tile in front of her when it stands on low ground (and her + * aura may select it), else herself. + */ +export function blessingHolder(battle, unit) { + const [fr, fc] = frontOf(unit.tileR, unit.tileC, unit.dir, 1); + const a = battle.grid.inRect(fr, fc) ? battle.unitAt(fr, fc) : null; + if (a && a !== unit && a.side === 'ally' && a.kind === 'op' && a.deployed && a.ground !== false && battle.grid.isLow(fr, fc) + && battle.allySelectable(a, unit)) return a; + return unit; +} + +export default { + char_485_pallas: (bb, chess) => { + const t0 = talentBb(chess, 0); // 英雄的诞生 (INS-X stage 3: hp_ratio 0.5, atk 0.3) + const t1 = talentBb(chess, 1); // 女神的振奋 (INS-Y stage 3: value 60, pallas_e_t_2.value 40) + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + const blessDef = num(b3['attack@def']), blessBlock = num(b3['attack@block_cnt']); + const blessHp = num(b3['attack@peak_performance.hp_ratio'], 0.8), blessAtk = num(b3['attack@peak_performance.atk']); + /** Move 英勇的祝福 to the current holder (DEF / block here; its 精力充沛 goes through the shared tick). */ + const bless = ({ battle, unit }) => { + const st = unit.mem.pallasBless ?? (unit.mem.pallasBless = { holder: null }); + const h = unit.skill?.active && up(unit) ? blessingHolder(battle, unit) : null; + if (st.holder === h) return; + if (st.holder) battle.removeBuff(st.holder, holderKey(unit)); + st.holder = h; + if (h) battle.addBuff(h, { key: holderKey(unit), source: unit, mods: { defPct: blessDef, blockCnt: blessBlock }, tags: ['skill'], visible: true }); + }; + return { + skills: { + [S1]: { kind: 'instant', attack: { atkScale: num(b1.atk_scale, 1), hits: 2 } }, + [S2]: { + kind: 'duration', + mods: { atkPct: num(b2.atk) }, + targeting: { rangeExtend: Math.max(0, Math.round(num(b2.ability_range_forward_extend))) }, + attack: { + onHit({ battle, unit, target }) { + if (target && target.alive && target.side === 'enemy' && battle.rng.chance(num(b2['attack@buff_prob']))) { + battle.applyStatus(target, 'stun', { duration: num(b2['attack@stun']), source: unit }); + } + }, + }, + }, + [S3]: { + kind: 'duration', + mods: { atkPct: num(b3.atk) }, + targeting: { maxTargets: Math.max(1, num(b3['attack@max_target'], 3)) }, + onStart: bless, + onTick: bless, + onEnd: bless, + }, + }, + talents: [ + { install(battle, unit) { // 英雄的诞生 (and S3's 精力充沛, same 同类取最高 buff) + const hp0 = num(t0['peak_performance.hp_ratio'], 0.8), atk0 = num(t0['peak_performance.atk']); + peakSources(battle).add((a) => { + if (!up(unit) || !battle.allySelectable(a, unit)) return 0; + let v = 0; + if (atk0 > 0 && isMinos(a) && a.hpRatio > hp0) v = atk0; + if (blessAtk > 0 && unit.mem.pallasBless?.holder === a && unit.skill?.active && a.hpRatio > blessHp) v = Math.max(v, blessAtk); + return v; + }); + } }, + { install(battle, unit) { // 女神的振奋 (+ INS-Y stage 2+: the minos extra heal) + const value = num(t1.value), extra = num(t1['pallas_e_t_2.value']); + if (!(value > 0) && !(extra > 0)) return; + battle.on('damaged', (c) => { + if (c.source !== unit || !c.target || c.target.side !== 'enemy' || c.type === 'element' || !up(unit)) return; + if (value > 0) { + for (const a of alliesInGridOf(battle, unit, RANGE_1_1)) battle.heal(unit, a, value, { self: a === unit, ignoreHealFree: true, tags: ['talent'] }); + } + if (extra > 0) { + for (const a of battle.alliesFor(unit)) if (isMinos(a)) battle.heal(unit, a, extra, { self: a === unit, ignoreHealFree: true, tags: ['talent', 'module'] }); + } + }, { owner: unit }); + } }, + ], + }; + }, +}; diff --git a/test/content/op_demetr.test.js b/test/content/op_demetr.test.js new file mode 100644 index 00000000..b6ea8c40 --- /dev/null +++ b/test/content/op_demetr.test.js @@ -0,0 +1,377 @@ +// test/content/op_demetr.test.js — the 自选 operator kit of 贝洛内 (char_4037_demetr, 6★ 斗士; kit +// server/sim/content/kits/ops/op-demetr.js) and his summon 牵绊 (token_10065_demetr_dmtpos), 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 FGT-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_demetr.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 { BOND, MEANS_KEY, MEANS_SLUG_KEY, STREET_KEY, UNDEAD_KEY, RETURN_UNDYING } from '../../server/sim/content/kits/ops/op-demetr.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 DEMETR = 'char_4037_demetr'; +const FORMS = BACKUPS.units[DEMETR].forms; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const FGTY = 'uniequip_002_demetr'; +const S1 = 'skchr_demetr_1', S2 = 'skchr_demetr_2', S3 = 'skchr_demetr_3'; +const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0']; +const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id); +const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null); +const talentOf = (u, i) => u.def.raw.talents.find((t) => t.index === i)?.bb ?? {}; +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_armor: dummy('enemy_armor', { def: 1000 }), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }), + enemy_elite: dummy('enemy_elite', { rank: 'ELITE' }), enemy_hit: dummy('enemy_hit', { atk: 3000, bat: 1 }), +}; +const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, FGTY].map((m) => [t, true, m]))]; + +/** A battle with 贝洛内 as uid 1 at (row, col) facing RIGHT, plus `others`. */ +function field({ tier = 5, elite = false, mod = null, skill = 2, row = 10, col = 5, others = [], seed = 5, rows = null } = {}) { + const h = makeBattle({ + defs: { enemies: ENEMIES }, timeLimit: 600, autoFinish: false, seed, ...(rows ? { flat: { rows } } : {}), + flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'deploy', 'death'], captureNoisy: true, + units: [{ uid: 1, diy: { slot: SLOT[tier], charId: DEMETR, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others], + }); + h.step(); + return { h, u: h.unit(1) }; +} +const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack); +const moves = (h, u) => h.hooksOf('deploy').filter((c) => c.unit === u && c.move); +const bondOf = (h) => h.b.allyUnits.find((a) => a.kind === 'token' && a.defId === BOND && a.alive) ?? null; +const noSp = (h, u) => h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true } }); // 阻回: the MANUAL skill stays off +const meansOf = (e) => e.findBuff(MEANS_KEY)?.data?.stacks ?? 0; +function done(h) { + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); +} +const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`; + +test('贝洛内 in every 自选 form: his operator kit (all three skills authored), the form\'s stats + module attributes, 1-1, blocks 1, melee ground-only, 0.78 s, 叙拉古, no 特质', () => { + assert.equal(OPERATOR_KITS[DEMETR], KITS[DEMETR]); + 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], [DEMETR, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f)); + assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0)], `${label(f)}: stats`); + assert.deepEqual([u.base.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.sub, u.base.bat, u.dmgType], [1, 'melee', false, 'fighter', 0.78, 'phys'], `${label(f)}: 斗士`); + assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`); + assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds, u.def.raw.nationId], [['siracusaShip'], [], 'siracusa'], `${label(f)}: bonds / 特质`); + assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target him`); + done(h); + } + } + // E2 Lv1 2152 / 490 / 298, E2 Lv60 2466 / 566 / 336; FGT-Y +140 / +50 → +230 / +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], [2152, 490, 2466, 566]); + assert.deepEqual([modOf(5, FGTY).attr, modOf(6, FGTY).attr], [{ maxHp: 140, atk: 50 }, { maxHp: 230, atk: 80 }]); +}); + +test('a 自选 pick: 贝洛内 is offered at tiers 5 and 6 and a roster with him passes validateDiyPicks', () => { + const data = { chess: CHESS, backups: BACKUPS }; + assert.ok(KITTED_CHARS.includes(DEMETR)); + for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(DEMETR), `tier ${t}`); + assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: DEMETR, skillIndex: 2, uniEquipId: FGTY } }, { data, kitted: KITTED_CHARS }), + { ok: true, picks: { [SLOT[6]]: { charId: DEMETR, skillIndex: 2, uniEquipId: FGTY } } }); +}); + +test('FGT-Y “实用的工具”: ASPD +10 while his HP is above 50 % (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 on = elite && mod === FGTY; + if (on) assert.deepEqual(u.def.raw.trait.bb, { attack_speed: 10, hp_ratio: 0.5 }, label(f)); + h.step(); + assert.equal(u.s.aspd, 100 + (on ? 10 : 0), `${label(f)}: full HP`); + u.hp = u.s.maxHp * 0.5; + h.step(); + assert.equal(u.s.aspd, 100, `${label(f)}: at 50 %`); + u.hp = u.s.maxHp * 0.51; + h.step(); + assert.equal(u.s.aspd, 100 + (on ? 10 : 0), `${label(f)}: above again`); + done(h); + } +}); + +test('T1 家族手段: each attack hit puts one more 【手段】 stack on its target before its damage (DEF ×(1 − 7 % × n), FGT-Y stage 3: 8 %; at most 5) for 10 s; and his damage ×1 → ×1.28 (×1.42) linearly as the target falls from 100 % to 20 % HP', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const { h, u } = field({ tier, elite, mod, skill: 2 }); + noSp(h, u); + const t = talentOf(u, 0); + const y3 = elite && mod === FGTY && tier === 6; + assert.deepEqual([t['attack@def'], t['attack@limited_stack_cnt'], t['attack@s2_limited_stack_cnt'], t['attack@def_dec_duration'], t.min_hp_ratio, t.max_hp_ratio, t.max_add_on_scale], + [y3 ? -0.08 : -0.07, 5, 8, 10, 1, 0.2, y3 ? 0.42 : 0.28], `${label(f)}: the talent of the form`); + const pct = -t['attack@def']; + const e = h.spawn('enemy_armor', { pos: [10, 6] }); + const n0 = atkHits(h, u).length; + h.run(8); + const hits = atkHits(h, u).slice(n0); + assert.ok(hits.length >= 7, `${label(f)}: ${hits.length} attacks`); + hits.forEach((c, i) => { + const k = Math.min(5, i + 1); + approx(c.amount, Math.max(u.s.atk - 1000 * (1 - pct * k), u.s.atk * 0.05), `${label(f)}: attack ${i + 1} at ${k} stack(s)`); + }); + assert.equal(meansOf(e), 5, `${label(f)}: 5 at most`); + approx(e.s.def, 1000 * (1 - pct * 5), `${label(f)}: DEF ×${1 - pct * 5} (final multiplier)`); + // the HP bonus: 60 % HP ⇒ halfway; 20 % HP and below ⇒ the most + for (const [ratio, frac] of [[0.6, 0.5], [0.2, 1], [0.05, 1]]) { + e.hp = e.s.maxHp * ratio; + const m0 = atkHits(h, u).length; + h.runUntil(() => atkHits(h, u).length > m0, 3); + const c = atkHits(h, u)[m0]; + approx(c.amount, Math.max(u.s.atk - e.s.def, u.s.atk * 0.05) * (1 + t.max_add_on_scale * frac), `${label(f)}: at ${ratio * 100} % HP ×${1 + t.max_add_on_scale * frac}`); + } + // 10 s after the last hit it is gone + h.b.retreat(u); + h.run(9.5); + assert.equal(meansOf(e), 5, `${label(f)}: still there after 9.5 s`); + h.run(0.6); + assert.equal(e.findBuff(MEANS_KEY), null, `${label(f)}: gone 10 s after his last hit`); + approx(e.s.def, 1000, `${label(f)}: DEF back`); + done(h); + } +}); + +test('T2 街头直觉: 80 % physical and arts dodge from every deployment, −2 % each second for 20 s, then 40 % for good', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const { h, u } = field({ tier, elite, mod, skill: 0 }); + const t = talentOf(u, 1); + assert.deepEqual([t.init_prob, t.dec_prob, t.trig_cnt], [0.8, 0.02, 20], label(f)); + approx(u.s.dodgePhys, 0.8, `${label(f)}: at the deployment`); + assert.deepEqual(u.findBuff(STREET_KEY)?.mods, { dodgePhys: 0.8, dodgeArts: 0.8 }, `${label(f)}: one buff, both types`); + approx(u.s.dodgeArts, 0.8, `${label(f)}: arts too`); + h.run(1.02); + approx(u.s.dodgePhys, 0.78, `${label(f)}: after 1 s`); + h.run(9); + approx(u.s.dodgePhys, 0.6, `${label(f)}: after 10 s`); + h.run(10); + approx(u.s.dodgePhys, 0.4, `${label(f)}: after 20 s`); + h.run(30); + approx(u.s.dodgeArts, 0.4, `${label(f)}: stays 40 %`); + const e = h.spawn('enemy_dummy', { pos: [11, 8] }); + let dodged = 0; + for (let i = 0; i < 400; i++) { u.hp = u.s.maxHp; if (h.b.dealDamage(e, u, { amount: 10, type: i % 2 ? 'phys' : 'arts' }) === 0) dodged++; } + assert.ok(Math.abs(dodged / 400 - 0.4) < 0.07, `${label(f)}: ${dodged} / 400 dodged`); + h.b.retreat(u); + h.b.redeploy(u); + h.step(); + approx(u.s.dodgePhys, 0.8, `${label(f)}: a new deployment starts again`); + done(h); + } +}); + +test('S1 家主的余裕 (AUTO, attack SP 4, data DEFAULT): the next attack strikes twice for 180 % / 210 % ATK — two 【手段】 stacks', () => { + for (const [tier, elite, mod] of [[5, false, null], [6, true, FGTY]]) { + const sk = skillOf(tier, elite, S1); + const { h, u } = field({ tier, elite, mod, skill: 0 }); + assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, sk.skillType, sk.bb.atk_scale], ['DEFAULT', 'attack', 4, 'AUTO', elite ? 2.1 : 1.8], `T${tier}`); + const e = h.spawn('enemy_dummy', { pos: [10, 6] }); + assert.ok(h.runUntil(() => atkHits(h, u).some((c) => c.dmg.isSkill), 20), `T${tier}: cast`); + const sk1 = atkHits(h, u).filter((c) => c.dmg.isSkill); + assert.equal(sk1.length, 2, `T${tier}: two strikes`); + assert.equal(atkHits(h, u).filter((c) => !c.dmg.isSkill).length, 4, `T${tier}: on the 5th attack`); + // (×1.0000x: the target's HP is not quite full any more — 家族手段's HP bonus) + for (const c of sk1) approx(c.amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} %`, 1e-4); + assert.equal(meansOf(e), 5, `T${tier}: 4 plain + 2 strikes ⇒ capped at 5`); + done(h); + } +}); + +test('S2 军师的手段 (MANUAL, data ACTIVE_RANGE on its 3-13): 15 / 18 s, range 3-13, ASPD +40 / +60, three targets at 155 % / 170 %; 【手段】 up to 8 with 停顿 at 8, back to 5 within 0.1 s after (or once he leaves)', () => { + for (const [tier, elite, mod] of [[5, false, null], [6, true, FGTY]]) { + const sk = skillOf(tier, elite, S2); + const b = sk.bb; + assert.deepEqual([sk.duration, b.attack_speed, b['attack@max_target'], b['attack@atk_scale'], sk.rangeId], [elite ? 18 : 15, elite ? 60 : 40, 3, elite ? 1.7 : 1.55, '3-13'], `T${tier}`); + const { h, u } = field({ tier, elite, mod, skill: 1 }); + assert.deepEqual([u.skill.rule, sk.trigger.rawRule], ['ACTIVE_RANGE', 'DEFAULT'], `T${tier}: the owner's ACTIVE_RANGE rule`); + assert.deepEqual(sk.trigger.customRangeGrid, sk.rangeGrid); + u.skill.gainSp(999); + const far = h.spawn('enemy_dummy', { pos: [10, 8] }); // [0, 3]: only in the 3-13 + assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: cast with an enemy in the 3-13 only`); + approx(u.skill.timeLeft, sk.duration, `T${tier}: ${sk.duration} s`, 0.05); + assert.deepEqual([...u.liveRangeGrid].sort(), [...sk.rangeGrid].sort(), `T${tier}: 3-13 while it runs`); + assert.equal(u.s.aspd, 100 + b.attack_speed + (mod ? 10 : 0), `T${tier}: ASPD`); + const es = [far, h.spawn('enemy_dummy', { pos: [11, 6] }), h.spawn('enemy_dummy', { pos: [9, 7] }), h.spawn('enemy_dummy', { pos: [10, 7] })]; + const t0 = h.b.time; + h.run(6); + const per = new Map(); + for (const c of atkHits(h, u).filter((x) => x.t > t0)) { + per.set(c.dmg.attackId, (per.get(c.dmg.attackId) ?? new Set()).add(c.target)); + const base = u.s.atk * b['attack@atk_scale']; + approx(c.amount, base, `T${tier}: ${b['attack@atk_scale'] * 100} %`, 0.0001); + } + assert.ok(per.size >= 4 && [...per.values()].every((s) => s.size === 3), `T${tier}: three targets per attack`); + const hitOnes = es.filter((e) => meansOf(e) > 0); + assert.ok(hitOnes.some((e) => meansOf(e) === 8), `T${tier}: 8 stacks during S2`); + for (const e of hitOnes) { + assert.ok(meansOf(e) <= 8); + assert.equal(!!e.findBuff(MEANS_SLUG_KEY), meansOf(e) === 8, `T${tier}: 停顿 exactly at 8`); + if (meansOf(e) === 8) assert.ok(e.buffs.some((x) => x.status === 'sluggish'), `T${tier}: a 停顿 status`); + } + h.runUntil(() => !u.skill.active, 20); + h.run(0.12); + for (const e of hitOnes) { + assert.equal(meansOf(e), Math.min(5, meansOf(e)), `T${tier}: back to 5`); + assert.ok(meansOf(e) <= 5 && !e.findBuff(MEANS_SLUG_KEY), `T${tier}: no 停顿 after`); + } + assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: 1-1 again`); + done(h); + } + // he leaves the field during S2 ⇒ back to 5 at the next update + const { h, u } = field({ tier: 6, elite: true, mod: FGTY, skill: 1 }); + u.skill.gainSp(999); + const e = h.spawn('enemy_dummy', { pos: [10, 6] }); + h.runUntil(() => meansOf(e) === 8, 20); + assert.ok(u.skill.active); + h.b.retreat(u); + h.run(0.12); + assert.equal(meansOf(e), 5, 'he left: 5'); + done(h); +}); + +test('S3 清算 (MANUAL, data CUSTOM_RANGE on x-2): 30 s, ATK +110 / 140 %, ASPD +30 / +40; he 【移动】s onto the first ground target of the x-2 (dodge back to 80 %), leaves 牵绊 on his tile; 35 / 40 % of his hits ×1.4 / 1.5', () => { + for (const [tier, elite, mod] of [[5, false, null], [6, true, FGTY]]) { + const sk = skillOf(tier, elite, S3); + const b = sk.bb; + assert.deepEqual([sk.duration, b['attack@demetr_s3[bonus].atk'], b['attack@demetr_s3[bonus].attack_speed'], b['attack@demetr_s3[bonus].prob'], b['attack@demetr_s3[bonus].prob_atk_scale'], sk.overrideTokenKey], + [30, elite ? 1.4 : 1.1, elite ? 40 : 30, elite ? 0.4 : 0.35, elite ? 1.5 : 1.4, BOND], `T${tier}`); + const { h, u } = field({ tier, elite, mod, skill: 2, seed: 13 }); + assert.deepEqual([u.skill.rule, sk.trigger.rawRule, sk.trigger.customRangeGrid.length], ['CUSTOM_RANGE', 'CUSTOM_RANGE_SEARCH_ENEMY', 21], `T${tier}: x-2 (21 tiles)`); + h.run(10); + u.skill.gainSp(999); + h.run(1); + assert.equal(u.skill.activations, 0, `T${tier}: no enemy around, no cast`); + assert.ok(u.s.dodgePhys < 0.8, 'his dodge decayed'); + const e = h.spawn('enemy_dummy', { pos: [11, 7] }); // [1, 2] of his x-2 + assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: cast`); + assert.deepEqual([u.tileR, u.tileC], [11, 7], `T${tier}: moved onto its tile`); + assert.equal(moves(h, u).length, 1, `T${tier}: one 【移动】`); + approx(u.s.dodgePhys, 0.8, `T${tier}: 街头直觉 restarts with the 【移动】`); + approx(u.s.atk, u.base.atk * (1 + b['attack@demetr_s3[bonus].atk']), `T${tier}: ATK +${b['attack@demetr_s3[bonus].atk'] * 100} %`); + assert.equal(u.s.aspd, 100 + b['attack@demetr_s3[bonus].attack_speed'] + (mod ? 10 : 0), `T${tier}: ASPD`); + const bond = bondOf(h); + assert.ok(bond, `T${tier}: 牵绊`); + assert.deepEqual([bond.tileR, bond.tileC, bond.ownerUnit === u, bond.s.flags.invulnerable, bond.s.flags.untargetable, bond.s.flags.isolated, bond.profile.noAttack, bond.s.blockCnt], + [10, 5, true, true, true, true, true, 0], `T${tier}: on his tile, 无敌 / untargetable / 孤立, no attack, blocks nothing`); + assert.equal(h.b.isReservedTile(10, 5), true, `T${tier}: nobody else deploys there`); + // the crits + const t0 = h.b.time; + u.skill.extend(200); + h.run(150); + const hits = atkHits(h, u).filter((c) => c.t > t0 && c.target === e); + const atk = u.s.atk; + const big = hits.filter((c) => Math.abs(c.amount - atk * b['attack@demetr_s3[bonus].prob_atk_scale'] * (c.dmg.mul ?? 1)) < 1e-3 * atk).length; + const plain = hits.filter((c) => Math.abs(c.amount - atk * (c.dmg.mul ?? 1)) < 1e-3 * atk).length; + assert.equal(big + plain, hits.length, `T${tier}: every hit is ×1 or ×${b['attack@demetr_s3[bonus].prob_atk_scale']}`); + assert.ok(Math.abs(big / hits.length - b['attack@demetr_s3[bonus].prob']) < 0.06, `T${tier}: ${big} / ${hits.length} ≈ ${b['attack@demetr_s3[bonus].prob']}`); + done(h); + } +}); + +test('S3 清算 targets: ground enemies only, on a tile he may take (melee-buildable low ground, nobody but him / 牵绊) — else he 【移动】s in place once; another target 1 s after his last damage, at once when the elite he attacked falls; moving home retreats 牵绊 first', () => { + // (a) a flyer and an enemy on a floor tile (build NONE): an in-place move, 牵绊 never placed + { + const rows = { 11: '##hrrrfrrrfrrrrrrrf##' }; // (11, 6) = floor + const { h, u } = field({ tier: 6, elite: true, mod: FGTY, skill: 2, rows }); + assert.equal(h.b.grid.canStand(11, 6), false); + h.spawn('enemy_fly', { pos: [10, 6] }); + const onFloor = h.spawn('enemy_dummy', { pos: [11, 6] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 1), 'cast (the flyer counts for CUSTOM_RANGE)'); + assert.deepEqual([u.tileR, u.tileC, moves(h, u).length, !!bondOf(h)], [10, 5, 1, false], 'an in-place 【移动】, no 牵绊'); + assert.ok(onFloor.alive); + done(h); + } + // (b) the idle re-target, the elite re-target, home + { + const { h, u } = field({ tier: 6, elite: true, mod: FGTY, skill: 2 }); + const e1 = h.spawn('enemy_dummy', { pos: [11, 7] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 1)); + assert.deepEqual([u.tileR, u.tileC], [11, 7]); + h.run(2); + h.b.kill(e1, null); + const last = Math.max(...atkHits(h, u).map((c) => c.t)); + const e2 = h.spawn('enemy_dummy', { pos: [9, 6] }); + assert.ok(h.runUntil(() => u.tileR === 9 && u.tileC === 6, 3), 'the next target'); + assert.ok(h.b.time >= last + 1 - 1e-6 && h.b.time <= last + 1 + 0.1, `1 s after his last damage (${h.b.time} vs ${last})`); + assert.equal(moves(h, u).length, 2); + assert.deepEqual([bondOf(h).tileR, bondOf(h).tileC], [10, 5], 'one 牵绊, still on his tile'); + assert.equal(h.b.allyUnits.filter((a) => a.kind === 'token' && a.alive).length, 1); + // the elite he attacks falls ⇒ at once (the next tick), not 1 s later + h.b.kill(e2, null); + const el = h.spawn('enemy_elite', { pos: [9, 6] }); // on his tile + h.runUntil(() => atkHits(h, u).some((c) => c.target === el), 2); + const home = h.spawn('enemy_dummy', { pos: [10, 5] }); // on 牵绊's tile: his located tile + h.b.kill(el, null); + const tk = h.b.time; + assert.ok(h.runUntil(() => u.tileR === 10 && u.tileC === 5, 0.2), 'back home on the next tick'); + assert.ok(h.b.time - tk < 0.05, 'at once'); + assert.equal(bondOf(h), null, '牵绊 retreated first (his located tile)'); + assert.ok(home.alive); + // the end: a 【返回】 in place, the SP emptied, a short 不死 + h.runUntil(() => !u.skill.active, 40); + assert.deepEqual([u.tileR, u.tileC], [10, 5]); + assert.ok(u.skill.sp < 0.1, `SP emptied (${u.skill.sp})`); + assert.equal(moves(h, u).length, 4, 'cast, idle, elite, return'); + done(h); + } +}); + +test('S3 清算: a lethal hit while it runs ⇒ 不死 / untargetable / no block / 禁疗, the skill ends at once and he returns (a short 不死 after); away without 牵绊 at the end ⇒ forced retreat', () => { + { + const { h, u } = field({ tier: 5, elite: false, skill: 2 }); + const e = h.spawn('enemy_dummy', { pos: [11, 7] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 1)); + assert.deepEqual([u.tileR, u.tileC], [11, 7]); + h.b.dealDamage(e, u, { amount: 1e7, type: 'true' }); + assert.ok(u.alive, 'not knocked out'); + assert.ok(u.hp >= 1 && u.hp <= 1.0001, 'held at 1 HP'); + assert.deepEqual([u.s.flags.untargetable, u.s.flags.noBlock, u.s.flags.healFree], [true, true, true], 'demetr_s3[undeadable]'); + h.b.dealDamage(e, u, { amount: 1e7, type: 'true' }); + assert.ok(u.alive, 'still 不死 until the skill ends'); + h.step(); + h.step(); + assert.ok(!u.skill.active, 'the skill ended'); + assert.deepEqual([u.tileR, u.tileC, !!bondOf(h), !!u.findBuff(UNDEAD_KEY)], [10, 5, false, false], 'returned home'); + assert.equal(h.hooksOf('skillEnd').find((c) => c.unit === u)?.reason, 'fatal'); + h.b.dealDamage(e, u, { amount: 1e7, type: 'true' }); + assert.ok(u.alive, `a short 不死 after the return (${RETURN_UNDYING} s)`); + h.run(RETURN_UNDYING + 0.05); + h.b.dealDamage(e, u, { amount: 1e7, type: 'true' }); + assert.ok(!u.alive, 'then a knock-out'); + done(h); + } + { + const { h, u } = field({ tier: 6, elite: true, mod: FGTY, skill: 2 }); + h.spawn('enemy_dummy', { pos: [9, 6] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 1)); + h.b.retreat(bondOf(h), { reason: 'expired', permanent: true }); + h.runUntil(() => !u.skill.active, 40); + assert.ok(!u.alive && u.removeReason === 'retreat', 'forced retreat'); + assert.ok(h.b.isDown(u), 'lies down and redeploys by the normal rule'); + done(h); + } + // 牵绊 belongs to S3: never under S1 / S2 + for (const skill of [0, 1]) { + const { h, u } = field({ tier: 6, elite: true, mod: FGTY, skill }); + for (const pos of [[10, 6], [11, 7], [9, 6]]) h.spawn('enemy_dummy', { pos }); + u.skill.gainSp(999); + h.run(30); + assert.equal(h.b.allyUnits.filter((a) => a.kind === 'token').length, 0, `S${skill + 1}: no 牵绊`); + assert.equal(moves(h, u).length, 0, `S${skill + 1}: no 【移动】`); + done(h); + } +}); diff --git a/test/content/op_lessng.test.js b/test/content/op_lessng.test.js new file mode 100644 index 00000000..b0a41b98 --- /dev/null +++ b/test/content/op_lessng.test.js @@ -0,0 +1,363 @@ +// test/content/op_lessng.test.js — the 自选 operator kit of 止颂 (char_4011_lessng, 6★ 无畏者; kit +// server/sim/content/kits/ops/op-lessng.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, DRE-X +// “沉锋之束” or DRE-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_lessng.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js'; +import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js'; +import { DUEL_KEY, OATH_KEY, PAIN_KEY, REBORN_KEY } from '../../server/sim/content/kits/ops/op-lessng.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 LESSNG = 'char_4011_lessng'; +const FORMS = BACKUPS.units[LESSNG].forms; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const DREX = 'uniequip_002_lessng', DREY = 'uniequip_003_lessng'; +const S1 = 'skchr_lessng_1', S2 = 'skchr_lessng_2', S3 = 'skchr_lessng_3'; +const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0']; +const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id); +const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null); +const talentOf = (u, i) => u.def.raw.talents.find((t) => t.index === i)?.bb ?? {}; +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_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.6, mass: 0 }) }; +const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, DREX, DREY].map((m) => [t, true, m]))]; +/** A plain ally (no kit, no damage modifiers) for friendly-damage checks. */ +const PLAIN = chessRec({ id: 'test_plain_a', name: '测试', skill: null }); + +/** A battle with 止颂 as uid 1 at (row, col) facing RIGHT, plus `others`. */ +function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], seed = 5 } = {}) { + const h = makeBattle({ + defs: { enemies: ENEMIES, chess: { test_plain_a: PLAIN } }, timeLimit: 600, autoFinish: false, seed, + flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied'], captureNoisy: true, + units: [{ uid: 1, diy: { slot: SLOT[tier], charId: LESSNG, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others], + }); + h.step(); + return { h, u: h.unit(1) }; +} +/** 止颂 on row 9 with a walker blocked by her. */ +function blocked(o = {}) { + const r = field({ row: 9, ...o }); + const w = r.h.spawn('enemy_walk', { routeIndex: 0 }); + assert.ok(r.h.runUntil(() => w.blockedBy === r.u, 30), 'the walker reaches her'); + return { ...r, w }; +} +const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack); +const noSp = (h, u) => h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true } }); // 阻回: the MANUAL skill stays off +function done(h) { + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); +} +const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`; + +test('止颂 in every 自选 form: his operator kit (all three skills authored), the form\'s stats + module attributes, 1-1, blocks 1, melee ground-only, 1.5 s, 莱塔尼亚, no 特质', () => { + assert.equal(OPERATOR_KITS[LESSNG], KITS[LESSNG]); + 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], [LESSNG, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f)); + assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0)], `${label(f)}: stats`); + assert.deepEqual([u.base.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.sub, u.base.bat, u.dmgType], [1, 'melee', false, 'fearless', 1.5, 'phys'], `${label(f)}: 无畏者`); + assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`); + assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds, u.def.raw.nationId], [['emptyShip'], [], 'leithanien'], `${label(f)}: bonds / 特质`); + assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target him`); + done(h); + } + } + // E2 Lv1 2798 / 866 / 232, E2 Lv60 3384 / 984 / 262; DRE-X +230 / +17 / +17 → +400 / +30 / +30; DRE-Y +300 / +55 → +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], [2798, 866, 3384, 984]); + assert.deepEqual([modOf(5, DREX).attr, modOf(6, DREX).attr, modOf(5, DREY).attr, modOf(6, DREY).attr], + [{ maxHp: 230, atk: 17, def: 17 }, { maxHp: 400, atk: 30, def: 30 }, { maxHp: 300, atk: 55 }, { maxHp: 450, atk: 80 }]); +}); + +test('a 自选 pick: 止颂 is offered at tiers 5 and 6 and a roster with him passes validateDiyPicks', () => { + const data = { chess: CHESS, backups: BACKUPS }; + assert.ok(KITTED_CHARS.includes(LESSNG)); + for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(LESSNG), `tier ${t}`); + assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: LESSNG, skillIndex: 1, uniEquipId: DREY } }, { data, kitted: KITTED_CHARS }), + { ok: true, picks: { [SLOT[6]]: { charId: LESSNG, skillIndex: 1, uniEquipId: DREY } } }); +}); + +test('T1 苦痛专注: while he blocks, physical / arts damage from anyone but an enemy he blocks — 无来源, allies, himself included — ×0.65 (DRE-Y stage 3: ×0.6); none while not blocking, none on true damage', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const y3 = elite && mod === DREY && tier === 6; + const cut = talentOf(field({ tier, elite, mod }).u, 0).damage_resistance; + assert.equal(cut, y3 ? 0.4 : 0.35, `${label(f)}: the talent of the form`); + const { h, u, w } = blocked({ tier, elite, mod, others: [{ uid: 2, chessId: 'test_plain_a', row: 11, col: 3 }] }); + const ally = h.unit(2); + const other = h.spawn('enemy_dummy', { pos: [11, 8] }); + const hit = (src, d) => { u.hp = u.s.maxHp; return h.b.dealDamage(src, u, d); }; // (full HP: the return value is the HP removed) + const arts = (src) => hit(src, { amount: 1000, type: 'arts' }); + approx(arts(other), 1000 * (1 - cut), `${label(f)}: an enemy he does not block`); + approx(arts(w), 1000, `${label(f)}: the enemy he blocks`); + approx(arts(null), 1000 * (1 - cut), `${label(f)}: 无来源`); + approx(arts(ally), 1000 * (1 - cut), `${label(f)}: an ally`); + approx(arts(u), 1000 * (1 - cut), `${label(f)}: himself`); + approx(hit(other, { amount: 1000, type: 'true' }), 1000, `${label(f)}: true damage`); + approx(hit(other, { amount: 2000, type: 'phys' }), (2000 - u.s.def) * (1 - cut), `${label(f)}: physical`); + u.hp = u.s.maxHp; + h.b.releaseBlocked(u); + h.b.kill(w, null); + h.step(); + assert.equal(u.blocking.length, 0); + approx(arts(other), 1000, `${label(f)}: not while he blocks nobody`); + done(h); + } +}); + +test('T2 痛楚砺刃: any damage aimed at him (a dodged one too) ⇒ ATK +12 % (DRE-X stage 3: +20 %) for 15 s, refreshed, never stacked', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const { h, u } = field({ tier, elite, mod }); + const t = talentOf(u, 1); + const x3 = elite && mod === DREX && tier === 6; + assert.deepEqual([t.atk, t.add_atk_duration], [x3 ? 0.2 : 0.12, 15], `${label(f)}: the talent of the form`); + const e = h.spawn('enemy_dummy', { pos: [11, 8] }); + assert.equal(u.findBuff(PAIN_KEY), null); + h.b.addBuff(u, { key: 'test:dodge', mods: { dodgePhys: 1 } }); + assert.equal(h.b.dealDamage(e, u, { amount: 500, type: 'phys' }), 0, `${label(f)}: dodged`); + assert.deepEqual(u.findBuff(PAIN_KEY)?.mods, { atkPct: t.atk }, `${label(f)}: still triggered`); + approx(u.s.atk, u.base.atk * (1 + t.atk), `${label(f)}: ATK +${t.atk * 100} %`); + h.b.removeBuff(u, 'test:dodge'); + h.run(10); + h.b.dealDamage(e, u, { amount: 100, type: 'arts' }); + h.b.dealDamage(null, u, { amount: 100, type: 'true' }); + approx(u.findBuff(PAIN_KEY).timeLeft, 15, `${label(f)}: refreshed to 15 s`, 0.01); + approx(u.s.atk, u.base.atk * (1 + t.atk), `${label(f)}: not stacked`); + h.run(15.1); + assert.equal(u.findBuff(PAIN_KEY), null, `${label(f)}: gone 15 s after the last hit`); + done(h); + } +}); + +test('S1 强力击·γ型 (AUTO, attack SP 3, data DEFAULT): the next attack at 205 % / 225 % ATK (× DRE-X 115 % on a blocked target)', () => { + for (const [tier, elite, mod] of [[5, false, null], [6, true, DREX], [5, true, DREY]]) { + const sk = skillOf(tier, elite, S1); + const { h, u, w } = blocked({ tier, elite, mod, skill: 0 }); + assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, sk.skillType, sk.bb.atk_scale], ['DEFAULT', 'attack', 3, 'AUTO', elite ? 2.25 : 2.05], `T${tier}`); + const scale = mod === DREX ? modOf(tier, DREX).traitOverride.bb.atk_scale : 1; + const t0 = h.b.time; + assert.ok(h.runUntil(() => atkHits(h, u).some((c) => c.dmg.isSkill && c.t > t0), 20), `T${tier}: cast`); + const hits = atkHits(h, u).filter((c) => c.t > t0 && c.target === w); + const sk1 = hits.find((c) => c.dmg.isSkill); + approx(sk1.amount, u.s.atk * sk.bb.atk_scale * scale, `T${tier}: ${sk.bb.atk_scale * 100} %`); + for (const c of hits.filter((x) => !x.dmg.isSkill)) approx(c.amount, u.s.atk * scale, `T${tier}: plain attacks`); + done(h); + } +}); + +test('S2 虔修对决 (PASSIVE, 部署后): for 18 / 21 s from every deployment ATK +25 / 35 %, two strikes per attack and 苦痛专注 ×2; then single strikes', () => { + for (const [tier, elite, mod] of [[5, false, null], [6, true, DREY], [6, true, DREX]]) { + const sk = skillOf(tier, elite, S2); + assert.deepEqual([sk.skillType, sk.spType, sk.duration, sk.bb.atk, sk.bb.talent_scale], ['PASSIVE', 'ON_DEPLOY', elite ? 21 : 18, elite ? 0.35 : 0.25, 2], `T${tier}`); + const { h, u, w } = blocked({ tier, elite, mod, skill: 1 }); + const cut = talentOf(u, 0).damage_resistance; + assert.ok(h.b.time < sk.duration, 'the walker arrived inside the window'); + assert.deepEqual(u.findBuff(DUEL_KEY)?.mods, { atkPct: sk.bb.atk }, `T${tier}: ATK +${sk.bb.atk * 100} %`); + const other = h.spawn('enemy_dummy', { pos: [11, 8] }); + approx(h.b.dealDamage(other, u, { amount: 1000, type: 'arts' }), 1000 * (1 - cut * 2), `T${tier}: 苦痛专注 ×2`); + const t0 = h.b.time; + h.runUntil(() => !u.findBuff(DUEL_KEY), 30); + approx(h.b.time, sk.duration, `T${tier}: the window ends ${sk.duration} s after the deployment`, 0.05); + const inWin = new Map(); + for (const c of atkHits(h, u).filter((x) => x.t > t0)) inWin.set(c.dmg.attackId, (inWin.get(c.dmg.attackId) ?? 0) + 1); + assert.ok(inWin.size >= 2 && [...inWin.values()].every((n) => n === 2), `T${tier}: two strikes per attack (${[...inWin.values()]})`); + const t1 = h.b.time; + h.run(5); + const after = new Map(); + for (const c of atkHits(h, u).filter((x) => x.t > t1)) after.set(c.dmg.attackId, (after.get(c.dmg.attackId) ?? 0) + 1); + assert.ok(after.size >= 2 && [...after.values()].every((n) => n === 1), `T${tier}: single strikes after`); + approx(u.s.atk, u.base.atk * (1 + (u.findBuff(PAIN_KEY) ? talentOf(u, 1).atk : 0)), `T${tier}: ATK back`); + approx(h.b.dealDamage(other, u, { amount: 1000, type: 'arts' }), 1000 * (1 - cut), `T${tier}: 苦痛专注 ×1`); + // a redeploy restarts the window + h.b.retreat(u); + h.b.redeploy(u); + h.step(); + approx(u.findBuff(DUEL_KEY)?.timeLeft ?? 0, sk.duration, `T${tier}: again for ${sk.duration} s`, 0.05); + assert.equal(w.alive, true); + done(h); + } +}); + +test('S3 苦修破誓 (MANUAL, data DEFAULT): 20 s, max HP +65 / 80 %, a blocked target at 160 % / 180 % ATK (× DRE-X), no 异常状态 while it runs; then all back', () => { + for (const [tier, elite, mod] of [[5, false, null], [6, true, DREX], [5, true, DREY]]) { + const sk = skillOf(tier, elite, S3); + const b = sk.bb; + assert.deepEqual([sk.duration, b.max_hp, b['lessng_s3[atk_scale].atk_scale'], b.magical_value, sk.skillType], [20, elite ? 0.8 : 0.65, elite ? 1.8 : 1.6, 600, 'MANUAL'], `T${tier}`); + const { h, u, w } = blocked({ tier, elite, mod, skill: 2 }); + assert.equal(u.skill.rule, 'DEFAULT'); + const unb = h.spawn('enemy_dummy', { pos: [9, 5] }); // on his tile beside the walker: he blocks only one + const hp0 = u.hp, max0 = u.s.maxHp; + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`); + approx(u.skill.timeLeft, 20, `T${tier}: 20 s`, 0.05); + approx(u.s.maxHp, max0 * (1 + b.max_hp), `T${tier}: max HP +${b.max_hp * 100} %`); + approx(u.hp / u.s.maxHp, hp0 / max0, `T${tier}: ratio kept`); + for (const st of ['stun', 'cold', 'freeze', 'sleep', 'silence', 'fear']) assert.equal(h.b.applyStatus(u, st, { duration: 3, source: w }), false, `T${tier}: immune to ${st}`); + const scale = mod === DREX ? modOf(tier, DREX).traitOverride.bb.atk_scale : 1; + const t0 = h.b.time; + h.run(6); + const hits = atkHits(h, u).filter((c) => c.t > t0); + assert.ok(hits.length >= 3); + for (const c of hits) { + if (c.target === w) approx(c.amount, u.s.atk * b['lessng_s3[atk_scale].atk_scale'] * scale, `T${tier}: blocked ×${b['lessng_s3[atk_scale].atk_scale']}`); + else approx(c.amount, unb.blockedBy ? u.s.atk * b['lessng_s3[atk_scale].atk_scale'] * scale : u.s.atk, `T${tier}: the other target`); + } + h.runUntil(() => !u.skill.active, 20); + h.step(); + assert.equal(u.findBuff(OATH_KEY), null); + approx(u.s.maxHp, max0, `T${tier}: max HP back`); + assert.equal(h.b.applyStatus(u, 'stun', { duration: 0.5, source: w }), true, `T${tier}: stuns land again`); + done(h); + } +}); + +test('S3 苦修破誓 cast under a 异常状态: 600 arts to himself first (苦痛专注 applies while he blocks; may knock him out), every 异常状态 cleansed, then the HP; 沉默 does not hold the cast back, 晕眩 does (no attack)', () => { + for (const [tier, elite, mod] of [[5, false, null], [6, true, DREX]]) { + const cut = 0.35; + // cold: he still attacks, the basic strategy casts; he blocks ⇒ 600 × 0.65 + { + const { h, u, w } = blocked({ tier, elite, mod, skill: 2 }); + h.b.applyStatus(u, 'cold', { duration: 20, source: w }); + const hp0 = u.hp, max0 = u.s.maxHp; + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 5), `T${tier}: cast while cold`); + const self = h.hooksOf('damaged').filter((c) => c.source === u && c.target === u); + assert.equal(self.length, 1, `T${tier}: one self hit`); + assert.deepEqual([self[0].type, Math.round(self[0].amount)], ['arts', Math.round(600 * (1 - cut))], `T${tier}: 600 arts ×0.65`); + assert.ok(!u.buffs.some((x) => x.status === 'cold'), `T${tier}: cold cleansed`); + approx(u.hp, (hp0 - 600 * (1 - cut)) / max0 * u.s.maxHp, `T${tier}: the HP buff after the damage (ratio of the hurt HP)`); + assert.ok(u.findBuff(PAIN_KEY), `T${tier}: 痛楚砺刃 saw it`); + done(h); + } + // 沉默: the engine's basic strategy refuses a silenced cast — this skill "可以无视异常状态开启" + { + const { h, u } = field({ tier, elite, mod, skill: 2 }); + h.spawn('enemy_dummy', { pos: [10, 6] }); + h.b.applyStatus(u, 'silence', { duration: 20, source: null }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 5), `T${tier}: cast while silenced`); + assert.equal(h.hooksOf('skillStart').find((c) => c.unit === u)?.reason, 'abnormal'); + assert.equal(Math.round(h.hooksOf('damaged').find((c) => c.source === u && c.target === u)?.amount), 600, `T${tier}: 600 arts (he blocks nobody)`); + assert.ok(!u.s.flags.silence, `T${tier}: silence cleansed`); + done(h); + } + // 晕眩: no attack ⇒ no cast until it ends (then no abnormal state, no self damage) + { + const { h, u } = field({ tier, elite, mod, skill: 2 }); + h.spawn('enemy_dummy', { pos: [10, 6] }); + h.b.applyStatus(u, 'stun', { duration: 3, source: null }); + u.skill.gainSp(999); + h.run(2.5); + assert.equal(u.skill.activations, 0, `T${tier}: not while stunned`); + assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: once the stun ends`); + assert.equal(h.hooksOf('damaged').filter((c) => c.source === u && c.target === u).length, 0, `T${tier}: no self damage`); + done(h); + } + // the self damage may knock him out (可致死) + { + const { h, u } = field({ tier, elite, mod, skill: 2 }); + h.spawn('enemy_dummy', { pos: [10, 6] }); + h.b.applyStatus(u, 'cold', { duration: 20, source: null }); + u.hp = 300; + u.skill.gainSp(999); + assert.ok(h.runUntil(() => !u.alive, 5), `T${tier}: knocked out by his own 600`); + assert.equal(u.findBuff(OATH_KEY), null); + done(h); + } + } +}); + +test('DRE-X “沉锋之束”: his attacks ×115 % on a target blocked by anyone (another operator\'s too), ×1 on an unblocked one — 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: 2, others: [{ uid: 2, chessId: 'chess_char_1_02_a', row: 10, col: 6 }] }); + noSp(h, u); + const yak = h.unit(2); + const e = h.spawn('enemy_dummy', { pos: [10, 6] }); // blocked by 角峰, on his front tile + h.step(); + assert.equal(e.blockedBy, yak); + const x = elite && mod === DREX; + if (x) assert.equal(u.def.raw.trait.bb.atk_scale, 1.15, label(f)); + h.run(6); + const hits = atkHits(h, u).filter((c) => c.target === e); + assert.ok(hits.length >= 3, label(f)); + for (const c of hits) approx(c.amount, u.s.atk * (x ? 1.15 : 1), `${label(f)}: ×${x ? 1.15 : 1}`); + h.b.releaseBlocked(yak); + h.b.retreat(yak); + h.step(); + assert.ok(!e.blockedBy); + const t0 = h.b.time; + h.run(4); + for (const c of atkHits(h, u).filter((y) => y.t > t0)) approx(c.amount, u.s.atk, `${label(f)}: unblocked ×1`); + done(h); + } +}); + +test('DRE-Y “苦修者的抗压训练”: the first lethal hit of a deployment ⇒ max HP ×0.4, ASPD +30, full HP (once; again after a redeploy; not while a 不死 holds him); stage 3: 12 % of DEF ignored on the enemy he blocks', () => { + for (const tier of [5, 6]) { + const { h, u } = field({ tier, elite: true, mod: DREY }); + const tb = u.def.raw.trait.bb; + assert.deepEqual([tb.attack_speed, tb.max_hp, tb.hp_ratio, tb.value], [30, 0.4, 1, 0.6], `T${tier}: trait bb`); + const e = h.spawn('enemy_dummy', { pos: [11, 8] }); + const max0 = u.s.maxHp, aspd0 = u.s.aspd; + h.b.dealDamage(e, u, { amount: 1e7, type: 'true' }); + assert.ok(u.alive, `T${tier}: not knocked out`); + approx(u.s.maxHp, max0 * 0.4, `T${tier}: max HP ×0.4`); + approx(u.hp, u.s.maxHp, `T${tier}: full HP of the new max`); + assert.equal(u.s.aspd, aspd0 + 30, `T${tier}: ASPD +30`); + assert.ok(u.findBuff(REBORN_KEY)); + h.b.dealDamage(e, u, { amount: 1e7, type: 'true' }); + assert.ok(!u.alive, `T${tier}: once per deployment`); + h.b.redeploy(u); + h.step(); + approx(u.s.maxHp, max0, `T${tier}: a new deployment: max HP back`); + h.b.dealDamage(e, u, { amount: 1e7, type: 'true' }); + assert.ok(u.alive, `T${tier}: again after the redeploy`); + done(h); + // a running 不死 (坚固维式重锤's window) holds him instead: the module is not used + const r = field({ tier, elite: true, mod: DREY }); + r.u.mem.undyingAt = `${r.u.deploySeq}:0`; + r.u.mem.undyingUntil = r.h.b.time + 5; + const hammer = r.h.b.on('fatal', (c) => { if (c.unit === r.u && !c.prevented) c.prevented = true; }, { priority: -99 }); + const f = r.h.spawn('enemy_dummy', { pos: [11, 8] }); + r.h.b.dealDamage(f, r.u, { amount: 1e7, type: 'true' }); + assert.ok(r.u.alive && r.u.hp < 2 && !r.u.findBuff(REBORN_KEY), `T${tier}: held by the 不死, the module untouched`); + r.h.b.off(hammer); + r.u.mem.undyingUntil = 0; + r.h.b.dealDamage(f, r.u, { amount: 1e7, type: 'true' }); + assert.ok(r.u.alive && r.u.findBuff(REBORN_KEY), `T${tier}: the module afterwards`); + done(r.h); + } + // stage 3's hidden part: 无视目标12%的防御力 on the enemy he blocks (none at stage 1, none on an unblocked enemy) + for (const tier of [5, 6]) { + const ENEMY_DEF = 1000; + const h = makeBattle({ + defs: { enemies: { enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.6, mass: 0, def: ENEMY_DEF }), enemy_dummy: dummy('enemy_dummy', { def: ENEMY_DEF }) } }, + timeLimit: 600, autoFinish: false, seed: 5, flags: { dpPerSec: 0 }, hooks: ['damaged'], captureNoisy: true, + units: [{ uid: 1, diy: { slot: SLOT[tier], charId: LESSNG, skillIndex: 2, uniEquipId: DREY }, elite: true, row: 9, col: 5 }], + }); + h.step(); + const u = h.unit(1); + noSp(h, u); + const w = h.spawn('enemy_walk', { routeIndex: 0 }); + assert.ok(h.runUntil(() => w.blockedBy === u, 30)); + const t0 = h.b.time; + h.run(6); + const pen = tier === 6 ? 0.12 : 0; + const hits = atkHits(h, u).filter((c) => c.t > t0 && c.target === w); + assert.ok(hits.length >= 3); + for (const c of hits) approx(c.amount, Math.max(u.s.atk - ENEMY_DEF * (1 - pen), u.s.atk * 0.05), `T${tier}: DEF ×${1 - pen}`); + done(h); + } +}); diff --git a/test/content/op_pallas.test.js b/test/content/op_pallas.test.js new file mode 100644 index 00000000..6a02a6fd --- /dev/null +++ b/test/content/op_pallas.test.js @@ -0,0 +1,324 @@ +// test/content/op_pallas.test.js — the 自选 operator kit of 帕拉斯 (char_485_pallas, 6★ 教官; kit +// server/sim/content/kits/ops/op-pallas.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, INS-X +// “故乡的山岩” or INS-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_pallas.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js'; +import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js'; +import { PEAK_KEY, holderKey } from '../../server/sim/content/kits/ops/op-pallas.js'; +import { diyPool, validateDiyPicks, diyRecord } from '../../shared/diy.js'; +import { positionClass } from '../../server/match/board.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 PALLAS = 'char_485_pallas'; +const FORMS = BACKUPS.units[PALLAS].forms; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const INSX = 'uniequip_002_pallas', INSY = 'uniequip_003_pallas'; +const S1 = 'skchr_pallas_1', S2 = 'skchr_pallas_2', S3 = 'skchr_pallas_3'; +/** The unit form of a slot (tier, normal / elite). */ +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); +/** A talent of the composed record the unit fights with (module changes applied). */ +const talentOf = (u, i) => u.def.raw.talents.find((t) => t.index === i)?.bb ?? {}; +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_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.6, mass: 0 }) }; +/** A 米诺斯 operator of no kit (帕拉斯 is the only one of the data): a plain melee record with nationId minos. */ +const MINOS_OP = { ...chessRec({ id: 'test_minos_a', name: '米诺斯测试', stats: { maxHp: 2000, atk: 500, def: 200, blockCnt: 1 }, skill: null }), nationId: 'minos' }; +/** Every 自选 form: [tier, elite, module]. */ +const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, INSX, INSY].map((m) => [t, true, m]))]; + +/** A battle with 帕拉斯 as uid 1 at (row, col) facing RIGHT, plus `others`. */ +function field({ tier = 5, elite = false, mod = null, skill = 2, row = 10, col = 5, others = [], seed = 5, rows = null } = {}) { + const h = makeBattle({ + defs: { enemies: ENEMIES, chess: { test_minos_a: MINOS_OP } }, timeLimit: 600, autoFinish: false, seed, + ...(rows ? { stageId: undefined, flat: { rows } } : {}), + flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'heal', 'skillStart', 'skillEnd', 'statusApplied'], captureNoisy: true, + units: [{ uid: 1, diy: { slot: SLOT[tier], charId: PALLAS, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others], + }); + h.step(); + return { h, u: h.unit(1) }; +} +const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack); +function done(h) { + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); +} +const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`; +/** The instructor scale of a form (trait bb atk_scale; INS-X's override). */ +const traitScale = (tier, elite, mod) => (elite && mod ? modOf(tier, mod).traitOverride.bb.atk_scale : formOf(tier, elite).trait.bb.atk_scale); + +test('帕拉斯 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 2-2, blocks 2, melee ground-only, 教官 ×1.2 (INS-X ×1.3), 米诺斯, no 特质', () => { + assert.equal(OPERATOR_KITS[PALLAS], KITS[PALLAS]); + 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], [PALLAS, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f)); + assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.aspd], + [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), form.stats.aspd + (m?.attr.aspd ?? 0)], `${label(f)}: stats`); + assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.sub, u.base.bat, u.dmgType], [2, 'melee', false, 'instructor', 1.05, 'phys'], `${label(f)}: 教官`); + approx(u.profile.unblockedScale, traitScale(tier, elite, mod), `${label(f)}: 教官 scale`); + assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 2-2`); + assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds, u.def.raw.nationId], [['emptyShip'], [], 'minos'], `${label(f)}: bonds / 特质 / nation`); + assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`); + done(h); + } + } + // the numbers of the forms (zh_CN): E2 Lv1 1413 / 577 / 382, E2 Lv60 1778 / 650 / 430; INS-X +150 HP +55 ATK → +210 / +83, + // trait 130 %; INS-Y +40 ATK +3 ASPD → +60 / +5 + 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.atk], [1413, 577, 382, 1778, 650]); + assert.deepEqual([modOf(5, INSX).attr, modOf(6, INSX).attr, modOf(5, INSY).attr, modOf(6, INSY).attr], [{ maxHp: 150, atk: 55 }, { maxHp: 210, atk: 83 }, { atk: 40, aspd: 3 }, { atk: 60, aspd: 5 }]); + assert.deepEqual([formOf(5, false).trait.bb.atk_scale, modOf(5, INSX).traitOverride.bb.atk_scale, modOf(6, INSY).traitOverride.bb.atk_scale], [1.2, 1.3, 1.2]); +}); + +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(PALLAS)); + for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(PALLAS), `tier ${t}`); + assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: PALLAS, skillIndex: 2, uniEquipId: INSY } }, { data, kitted: KITTED_CHARS }), + { ok: true, picks: { [SLOT[5]]: { charId: PALLAS, skillIndex: 2, uniEquipId: INSY } } }); +}); + +test('trait 教官: her attack ×1.2 (INS-X ×1.3) on an enemy she does not block, ×1 on the one she blocks; flyers are out of her reach', () => { + for (const f of [[5, false, null], [5, true, INSX], [6, true, INSX], [6, true, INSY]]) { + const [tier, elite, mod] = f; + const scale = traitScale(tier, elite, mod); + const noSp = (h, u) => h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true } }); // (阻回: S3 stays off) + { + const { h, u } = field({ tier, elite, mod, skill: 2 }); + noSp(h, u); + const e = h.spawn('enemy_dummy', { pos: [10, 7] }); + const fly = h.spawn('enemy_fly', { pos: [10, 6] }); + h.run(6); + const hits = atkHits(h, u); + assert.ok(hits.length >= 3, `${label(f)}: she attacks (${hits.length})`); + for (const c of hits) { + assert.equal(c.target, e, `${label(f)}: only the ground dummy`); + approx(c.amount, u.s.atk * scale, `${label(f)}: unblocked ×${scale}`); + assert.equal(c.type, 'phys'); + } + assert.ok(!h.hooksOf('damaged').some((c) => c.target === fly), `${label(f)}: no anti-air`); + done(h); + } + { + const { h, u } = field({ tier, elite, mod, skill: 2, row: 9 }); + noSp(h, u); + const w = h.spawn('enemy_walk', { routeIndex: 0 }); + assert.ok(h.runUntil(() => w.blockedBy === u, 30), `${label(f)}: the walker reaches her`); + const t0 = h.b.time; + h.run(6); + const hits = atkHits(h, u).filter((c) => c.t > t0); + assert.ok(hits.length >= 3, `${label(f)}: she attacks the enemy she blocks`); + for (const c of hits) approx(c.amount, u.s.atk, `${label(f)}: blocked ×1`); + done(h); + } + } +}); + +test('T1 英雄的诞生: while she is deployed, every 米诺斯 operator (her too) ATK +25 % above 80 % HP (INS-X stage 3: above 50 %, +30 %) — one 精力充沛, not for others, gone when she leaves', () => { + const others = [{ uid: 2, chessId: 'test_minos_a', row: 11, col: 3 }, { uid: 3, chessId: 'chess_char_1_02_a', row: 12, col: 7 }]; + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const { h, u } = field({ tier, elite, mod, skill: 2, others }); + h.step(); + const minos = h.unit(2), yak = h.unit(3); + const t = talentOf(u, 0); + const x3 = elite && mod === INSX && tier === 6; + assert.deepEqual([t['peak_performance.hp_ratio'], t['peak_performance.atk']], x3 ? [0.5, 0.3] : [0.8, 0.25], `${label(f)}: the talent of the form`); + const v = t['peak_performance.atk'], thr = t['peak_performance.hp_ratio']; + for (const a of [u, minos]) { + assert.deepEqual(a.findBuff(PEAK_KEY)?.mods, { atkPct: v }, `${label(f)}: ${a.name}`); + approx(a.s.atk, a.base.atk * (1 + v), `${label(f)}: ${a.name} ATK`); + } + assert.equal(yak.findBuff(PEAK_KEY), null, `${label(f)}: 角峰 (谢拉格) gets nothing`); + // the HP condition, re-read every tick: strictly above hp_ratio + minos.hp = minos.s.maxHp * thr; + h.step(); + assert.equal(minos.findBuff(PEAK_KEY), null, `${label(f)}: at ${thr * 100} % HP`); + minos.hp = minos.s.maxHp * 0.6; + h.step(); + assert.equal(!!minos.findBuff(PEAK_KEY), x3, `${label(f)}: at 60 % HP`); + minos.hp = minos.s.maxHp; + h.step(); + assert.ok(minos.findBuff(PEAK_KEY), `${label(f)}: back above`); + h.b.retreat(u); + h.step(); + assert.equal(minos.findBuff(PEAK_KEY), null, `${label(f)}: gone once she leaves the field`); + done(h); + } +}); + +test('T2 女神的振奋: every damage instance she deals heals her and the ally on the tile in front 40 HP (INS-Y stage 3: 60, and every 米诺斯 operator — her too — 40 more), through 禁疗, not a 不成为治疗目标 ally, not the one behind', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const others = [{ uid: 2, chessId: 'chess_char_1_02_a', row: 10, col: 6 }, { uid: 3, chessId: 'test_minos_a', row: 10, col: 4 }]; + const { h, u } = field({ tier, elite, mod, skill: 2, others }); + const front = h.unit(2), behind = h.unit(3); + const t = talentOf(u, 1); + const y3 = elite && mod === INSY && tier === 6; + assert.deepEqual([t.value, t['pallas_e_t_2.value']], y3 ? [60, 40] : [40, undefined], `${label(f)}: the talent of the form`); + for (const a of [u, front, behind]) a.hp = a.s.maxHp * 0.5; + h.b.addBuff(front, { key: 'test:healFree', flags: { healFree: true } }); // 禁疗: the heal ignores it + h.spawn('enemy_dummy', { pos: [10, 7] }); + h.run(5); + const out = h.hooksOf('damaged').filter((c) => c.source === u && c.target.side === 'enemy'); + assert.ok(out.length >= 3, `${label(f)}: ${out.length} damage instances`); + const heals = (a, amount) => h.hooksOf('heal').filter((c) => c.source === u && c.target === a && Math.abs(c.amount - amount) < 1e-6).length; + assert.equal(heals(u, t.value), out.length, `${label(f)}: herself ${t.value} per instance`); + assert.equal(heals(front, t.value), out.length, `${label(f)}: the ally in front (禁疗 too)`); + assert.equal(heals(behind, t.value), 0, `${label(f)}: not the ally behind`); + const extra = (a) => h.hooksOf('heal').filter((c) => c.source === u && c.target === a && c.opts?.tags?.includes('module')).length; + assert.deepEqual([extra(u), extra(behind), extra(front)], y3 ? [out.length, out.length, 0] : [0, 0, 0], `${label(f)}: the 米诺斯 extra (INS-Y stage 3): her and the 米诺斯 operator behind`); + if (y3) assert.ok(h.hooksOf('heal').filter((c) => c.opts?.tags?.includes('module')).every((c) => Math.abs(c.amount - 40) < 1e-6), `${label(f)}: 40 each`); + assert.ok(front.hp > front.s.maxHp * 0.5, `${label(f)}: the front ally gained HP`); + // 不成为其他角色的治疗目标 (noHeal): not healed + const n = h.hooksOf('heal').length; + h.b.addBuff(front, { key: 'test:noHeal', flags: { noHeal: true } }); + const hp = front.hp; + h.run(3); + assert.equal(front.hp, hp, `${label(f)}: no heal reaches a noHeal ally`); + assert.ok(h.hooksOf('heal').length > n, `${label(f)}: she still heals herself`); + done(h); + } +}); + +test('S1 胜利的连击 (AUTO, attack SP 3, data DEFAULT): the next attack strikes twice for 125 % / 145 % ATK, each ×the 教官 scale, and heals twice', () => { + for (const [tier, elite, mod] of [[5, false, null], [6, true, INSX], [5, true, INSY]]) { + const sk = skillOf(tier, elite, S1); + const { h, u } = field({ tier, elite, mod, skill: 0 }); + assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, sk.initSp, sk.skillType, sk.bb.atk_scale], ['DEFAULT', 'attack', 3, 0, 'AUTO', elite ? 1.45 : 1.25], `T${tier}`); + h.spawn('enemy_dummy', { pos: [10, 7] }); + assert.ok(h.runUntil(() => u.skill.activations === 1 && atkHits(h, u).some((c) => c.dmg.isSkill), 20), `T${tier}: cast on her 4th attack`); + const sk1 = atkHits(h, u).filter((c) => c.dmg.isSkill); + const id = sk1[0].dmg.attackId; + const strikes = sk1.filter((c) => c.dmg.attackId === id); + assert.equal(strikes.length, 2, `T${tier}: two strikes`); + for (const c of strikes) approx(c.amount, u.s.atk * sk.bb.atk_scale * traitScale(tier, elite, mod), `T${tier}: ${sk.bb.atk_scale * 100} % × 教官`); + assert.equal(atkHits(h, u).filter((c) => c.dmg.attackId < id).length, 3, `T${tier}: after 3 plain attacks`); + assert.equal(h.hooksOf('heal').filter((c) => c.source === u && c.target === u).length, atkHits(h, u).length, `T${tier}: one 女神的振奋 per strike`); + done(h); + } +}); + +test('S2 信念的长鞭 (MANUAL, data DEFAULT): 20 / 22 s, range 2-2 + 1 tile forward, ATK +35 / 50 %, each attack 60 / 70 % to stun 0.2 s; all back after', () => { + for (const [tier, elite, mod] of [[5, false, null], [6, true, INSY]]) { + const sk = skillOf(tier, elite, S2); + const { h, u } = field({ tier, elite, mod, skill: 1, seed: 9 }); + // the data's DEFAULT: tools/build-data.mjs ATTACK_RANGE_CHANGE misses "攻击范围向前延伸一格" (reported, not worked around) + assert.deepEqual([u.skill.rule, sk.trigger.rawRule, sk.duration, sk.bb.atk, sk.bb.ability_range_forward_extend, sk.bb['attack@buff_prob'], sk.bb['attack@stun']], + ['DEFAULT', 'DEFAULT', elite ? 22 : 20, elite ? 0.5 : 0.35, 1, elite ? 0.7 : 0.6, 0.2], `T${tier}`); + const far = h.spawn('enemy_dummy', { pos: [10, 8] }); // 3 tiles ahead: only in the extended range + u.skill.gainSp(999); + h.run(2); + assert.equal(u.skill.activations, 0, `T${tier}: nothing in her own 2-2: no cast (DEFAULT)`); + const near = h.spawn('enemy_dummy', { pos: [10, 7] }); + assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast on her attack`); + approx(u.skill.timeLeft, sk.duration, `T${tier}: ${sk.duration} s`, 0.01); + assert.deepEqual(u.liveRangeGrid, [[0, 0], [0, 1], [0, 2], [0, 3]], `T${tier}: one tile more forward`); + const v = talentOf(u, 0)['peak_performance.atk']; + approx(u.s.atk, u.base.atk * (1 + v + sk.bb.atk), `T${tier}: ATK +${sk.bb.atk * 100} %`); + h.b.kill(near, null); + const n0 = atkHits(h, u).length; + u.skill.extend(300); + h.run(150); + const hits = atkHits(h, u).slice(n0); + assert.ok(hits.length > 100 && hits.every((c) => c.target === far), `T${tier}: the far dummy is hit (${hits.length})`); + const stuns = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'stun' && c.target === far); + const r = stuns.length / hits.length; + assert.ok(Math.abs(r - sk.bb['attack@buff_prob']) < 0.08, `T${tier}: ${stuns.length} / ${hits.length} stuns ≈ ${sk.bb['attack@buff_prob']}`); + for (const c of stuns) approx(c.duration, 0.2, `T${tier}: stun 0.2 s`); + h.runUntil(() => !u.skill.active, 200); + h.step(); + assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: back to 2-2`); + approx(u.s.atk, u.base.atk * (1 + v), `T${tier}: ATK back`); + done(h); + } +}); + +test('S3 英勇的祝福 (MANUAL, data DEFAULT): 30 s, ATK +55 / 70 %, three targets; the operator on the low-ground tile in front holds 精力充沛 +35 / 40 % (> 80 % HP), DEF +20 / 25 %, block +1 — else she does, and it moves on deploy / retreat', () => { + for (const [tier, elite, mod] of [[5, false, null], [6, true, INSX], [6, true, INSY]]) { + const sk = skillOf(tier, elite, S3); + const b = sk.bb; + assert.deepEqual([sk.duration, b.atk, b['attack@max_target'], b['attack@peak_performance.atk'], b['attack@peak_performance.hp_ratio'], b['attack@def'], b['attack@block_cnt']], + [30, elite ? 0.7 : 0.55, 3, elite ? 0.4 : 0.35, 0.8, elite ? 0.25 : 0.2, 1], `T${tier}`); + const v1 = (u) => talentOf(u, 0)['peak_performance.atk']; + // (a) 角峰 (an operator on a road tile) in front: it holds + { + const { h, u } = field({ tier, elite, mod, skill: 2, others: [{ uid: 2, chessId: 'chess_char_1_02_a', row: 10, col: 6 }] }); + const yak = h.unit(2); + assert.equal(u.skill.rule, 'DEFAULT', `T${tier}: data DEFAULT`); + for (const pos of [[10, 5], [10, 6], [10, 7]]) h.spawn('enemy_dummy', { pos }); // three enemies on her 2-2 + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`); + h.step(); + approx(u.skill.timeLeft, 30, `T${tier}: 30 s`, 0.1); + assert.deepEqual(yak.findBuff(holderKey(u))?.mods, { defPct: b['attack@def'], blockCnt: 1 }, `T${tier}: 角峰 holds`); + assert.equal(yak.s.blockCnt, yak.base.blockCnt + 1, `T${tier}: 角峰 block +1`); + approx(yak.s.def, yak.base.def * (1 + b['attack@def']), `T${tier}: 角峰 DEF`); + assert.deepEqual(yak.findBuff(PEAK_KEY)?.mods, { atkPct: b['attack@peak_performance.atk'] }, `T${tier}: 角峰 精力充沛`); + assert.equal(u.findBuff(holderKey(u)), null, `T${tier}: not her`); + assert.equal(u.s.blockCnt, 2); + approx(u.s.atk, u.base.atk * (1 + v1(u) + b.atk), `T${tier}: her ATK +${b.atk * 100} % (+ her own T1)`); + yak.hp = yak.s.maxHp * 0.8; + h.step(); + assert.equal(yak.findBuff(PEAK_KEY), null, `T${tier}: 角峰 at 80 % HP: no 精力充沛`); + // three targets per attack + const t0 = h.b.time; + h.run(4); + const per = new Map(); + for (const c of atkHits(h, u).filter((x) => x.t > t0)) per.set(c.dmg.attackId, (per.get(c.dmg.attackId) ?? new Set()).add(c.target)); + assert.ok(per.size >= 2 && [...per.values()].every((s) => s.size === 3), `T${tier}: three targets per attack`); + // 角峰 leaves ⇒ she holds at once (同类取最高: her 精力充沛 is the higher of T1 and S3); a new operator in front takes it back + h.b.retreat(yak); + h.step(); + assert.deepEqual(u.findBuff(holderKey(u))?.mods, { defPct: b['attack@def'], blockCnt: 1 }, `T${tier}: passed to her`); + assert.equal(u.s.blockCnt, 3, `T${tier}: her block +1`); + assert.deepEqual(u.findBuff(PEAK_KEY)?.mods, { atkPct: Math.max(v1(u), b['attack@peak_performance.atk']) }, `T${tier}: one 精力充沛, the highest`); + approx(u.s.atk, u.base.atk * (1 + b.atk + Math.max(v1(u), b['attack@peak_performance.atk'])), `T${tier}: her ATK`); + h.b.redeploy(yak, { free: true }); + h.step(); + assert.ok(yak.findBuff(holderKey(u)) && !u.findBuff(holderKey(u)), `T${tier}: back to the redeployed 角峰`); + h.runUntil(() => !u.skill.active, 40); + h.step(); + assert.ok(!yak.findBuff(holderKey(u)) && !u.findBuff(holderKey(u)) && yak.s.blockCnt === yak.base.blockCnt, `T${tier}: gone at the end`); + assert.deepEqual(u.findBuff(PEAK_KEY)?.mods, { atkPct: v1(u) }, `T${tier}: her T1 only`); + done(h); + } + // (b) the tile in front is high ground (a ranged operator on it): she holds (the empty tile: (a) after the retreat) + { + const rows = { 10: '##hrrrhrrrfrrrrrrrf##' }; // (10,6) = 高台 'h' + const { h, u } = field({ tier, elite, mod, skill: 2, rows, others: [{ uid: 2, chessId: 'chess_char_1_01_a', row: 10, col: 6 }] }); + const sniper = h.unit(2); + assert.equal(h.b.grid.isLow(10, 6), false); + h.spawn('enemy_dummy', { pos: [10, 7] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 3)); + h.step(); + assert.ok(u.findBuff(holderKey(u)) && !sniper.findBuff(holderKey(u)), `T${tier}: an operator on a 高台 in front does not hold it`); + done(h); + } + } +}); + +test('INS-Y 「可以额外部署在远程位」: the data carries it (hidden buildable_type 2); a placement rule of the prep, not of the battle — a 帕拉斯 piece stays on melee tiles (owner\'s decision of 2026-10-04, shared/highGround.js)', () => { + for (const tier of [5, 6]) { + const m = modOf(tier, INSY); + assert.equal(m.traitOverride.moduleDesc, '可以额外部署在远程位'); + assert.deepEqual(m.talentChanges.find((t) => t.talentIndex === -1)?.bb, { buildable_type: 2 }); + const rec = diyRecord(SLOT[tier], { charId: PALLAS, skillIndex: 0, uniEquipId: INSY }, { elite: true, data: { chess: CHESS, backups: BACKUPS } }); + assert.equal(positionClass(rec, INSY), 'melee', `T${tier}: placement unchanged`); + const { h, u } = field({ tier, elite: true, mod: INSY, skill: 2 }); + assert.equal(u.def.position, 'MELEE'); + done(h); + } +}); diff --git a/test/golden/diy.json b/test/golden/diy.json index 0793c28c..44eb2d9a 100644 --- a/test/golden/diy.json +++ b/test/golden/diy.json @@ -1974,133 +1974,126 @@ "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_diy1_a=pallas/S1 5_diy2_a=pallas/S2 5_diy1_a=pallas/S3 5_diy2_b=pallas/S1 5_diy1_b=pallas/S2 5_diy2_b=pallas/S3 5_diy1_b=pallas/S1/002_pallas 5_diy2_b=pallas/S2/002_pallas 5_diy1_b=pallas/S3/002_pallas 5_diy2_b=pallas/S1/003_pallas 5_diy1_b=pallas/S2/003_pallas 5_diy2_b=pallas/S3/003_pallas", + "fast": false, "kind": "normal", "stageId": "act1autochess_m05", "modeId": "mode_multi_normal", "round": 12, "seed": 2215018795, "waveId": "act1autochess_h05", - "time": 230, - "ticks": 6900, - "reason": "timeout", - "killed": 2, - "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":{}}}, - "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] - ], - "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" - }, - "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", - "fast": false, - "kind": "normal", - "stageId": "act1autochess_m06", - "modeId": "mode_multi_hard", - "round": 12, - "seed": 574294847, - "waveId": "act1autochess_h05", - "time": 214.8, - "ticks": 6444, + "time": 206.2, + "ticks": 6186, "reason": "cleared", "killed": 0, "total": 60, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 115, + "rngDraws": 19, "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":60,"perfect":false,"leaks":60,"leaksUncounted":0,"coins":0,"dmg":39849,"bossDmg":0,"heal":4160,"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_5_diy1_a",0,null,0,0,84,0,1512,4439,600,18,4], + ["p1",2,"op","chess_char_5_diy2_a",1,null,0,0,58,0,2904,3866,640,27,1], + ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,83,0,769,4239,320,14,0], + ["p1",4,"op","chess_char_5_diy2_b",0,"none",0,0,56,0,5808,4876,1080,31,7], + ["p1",5,"op","chess_char_5_diy1_b",1,"none",0,0,78,0,1595,5454,160,13,0], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",0,0,76,0,1321,6094,880,25,0], + ["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_pallas",0,0,77,0,1340,6504,480,13,2], + ["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_pallas",1928,1,1,0,0,0,0,0,0], + ["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_pallas",1928,1,3,0,0,0,0,0,0], + ["p1",10,"op","chess_char_5_diy2_b",0,"uniequip_003_pallas",1778,1,5,0,0,0,0,0,0], + ["p1",11,"op","chess_char_5_diy1_b",1,"uniequip_003_pallas",1778,1,7,0,0,0,0,0,0], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_003_pallas",1778,1,9,0,24600,0,0,54,2] ], - "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_1045_hammer_2":[12,0,12,19341,18405],"enemy_1061_zomshd":[48,0,48,16131,84404]}, + "hooks": [1,84,6186,251,251,1838,1838,12941,19,79,16,16,0,8607,14,37,60,60,1,19,0,0,0], + "events": {"atk":251,"deploy":24,"die":19,"dmg":1838,"heal":104,"leak":60,"skill":32,"spawn":72,"status":32}, + "fx": {}, + "snaps": "b1c1d46d d599c705 1cfb5a81 3765fe6d 9861e3be ce5f475b 2f9fb6c2 d551f6df 3aa73b51 53221793 de1b3476 0911823f 7ba09425 0e26127d c98586c6 1cc8eea6 3bafa7e5 9c1adf13 0c097d1d 2e666e98 23a03742" + }, + "diy-050": { + "about": "6_diy1_a=pallas/S1 6_diy2_a=pallas/S2 6_diy1_a=pallas/S3 6_diy2_b=pallas/S1 6_diy1_b=pallas/S2 6_diy2_b=pallas/S3 6_diy1_b=pallas/S1/002_pallas 6_diy2_b=pallas/S2/002_pallas 6_diy1_b=pallas/S3/002_pallas 6_diy2_b=pallas/S1/003_pallas 6_diy1_b=pallas/S2/003_pallas 6_diy2_b=pallas/S3/003_pallas", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m06", + "modeId": "mode_multi_hard", + "round": 13, + "seed": 574294847, + "waveId": "act1autochess_h06", + "time": 209.53, + "ticks": 6286, + "reason": "cleared", + "killed": 3, + "total": 105, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 17, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":3,"total":105,"perfect":false,"leaks":102,"leaksUncounted":0,"coins":0,"dmg":91734,"bossDmg":0,"heal":6440,"deaths":21,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,129,0,5496,4999,280,9,1], + ["p1",2,"op","chess_char_6_diy2_a",1,null,1413,1,7,0,0,0,0,0,0], + ["p1",3,"op","chess_char_6_diy1_a",2,null,1413,1,9,0,0,0,0,0,0], + ["p1",4,"op","chess_char_6_diy2_b",0,"none",0,0,116,0,10215,5854,520,14,2], + ["p1",5,"op","chess_char_6_diy1_b",1,"none",1778,1,122,0,3649,3905,255,12,1], + ["p1",6,"op","chess_char_6_diy2_b",2,"none",0,0,67,0,2831,3876,200,8,0], + ["p1",7,"op","chess_char_6_diy1_b",0,"uniequip_002_pallas",0,0,65,0,1060,4216,240,7,1], + ["p1",8,"op","chess_char_6_diy2_b",1,"uniequip_002_pallas",0,0,72,1,12517,4416,515,12,1], + ["p1",9,"op","chess_char_6_diy1_b",2,"uniequip_002_pallas",0,0,75,1,28505,6091,960,17,1], + ["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_003_pallas",0,0,92,0,10998,4011,595,15,3], + ["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_003_pallas",0,0,108,0,6622,3956,1354,16,1], + ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_003_pallas",0,0,5,1,9842,2619,1522,12,1] + ], + "enemies": {"enemy_1165_duhond":[51,3,48,8869,71069],"enemy_1174_duholy_2":[24,0,24,14919,14131],"enemy_1175_dushdo_2":[30,0,30,20155,6535]}, + "hooks": [1,129,6286,220,220,241,241,448,24,126,12,12,0,6367,14,40,105,102,1,24,0,0,0], + "events": {"atk":234,"deploy":24,"die":24,"dmg":241,"heal":153,"leak":102,"skill":24,"spawn":117,"status":177}, + "fx": {}, + "snaps": "02c59f03 f3d86c1a ccd137f9 63455ac5 b17fae08 cd2870bb 558760b2 3846c3a2 4d2ae965 4bfaeb07 89ff0405 ec320b47 e7534046 c7cb9044 72e67a6c 552de1d3 9e620246 8a3c864b 64dd1cf7 ded966b4 c8294548" }, "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=lessng/S1 5_diy2_a=lessng/S2 5_diy1_a=lessng/S3 5_diy2_b=lessng/S1 5_diy1_b=lessng/S2 5_diy2_b=lessng/S3 5_diy1_b=lessng/S1/002_lessng 5_diy2_b=lessng/S2/002_lessng 5_diy1_b=lessng/S3/002_lessng 5_diy2_b=lessng/S1/003_lessng 5_diy1_b=lessng/S2/003_lessng 5_diy2_b=lessng/S3/003_lessng", "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, + "waveId": "act1autochess_h05", + "time": 206.27, + "ticks": 6188, "reason": "cleared", - "killed": 197, - "total": 290, + "killed": 52, + "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":52,"total":99,"perfect":false,"leaks":47,"leaksUncounted":0,"coins":0,"dmg":574662,"bossDmg":0,"heal":0,"deaths":18,"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,117,3,27200,5596,0,38,8], + ["p1",2,"op","chess_char_5_diy2_a",1,null,1398.63,1,110,3,24638,4197,0,44,0], + ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,104,2,34297,7859,0,48,2], + ["p1",4,"op","chess_char_5_diy2_b",0,"none",0,0,98,7,61019,6768,0,58,14], + ["p1",5,"op","chess_char_5_diy1_b",1,"none",0,0,109,6,81159,6768,0,63,0], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",0,0,95,5,62211,8009,0,57,2], + ["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_lessng",0,0,86,8,80958,7228,0,56,14], + ["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_lessng",0,0,3,1,16450,3614,0,23,0], + ["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_lessng",0,0,5,2,27997,4062,0,31,2], + ["p1",10,"op","chess_char_5_diy2_b",0,"uniequip_003_lessng",0,0,7,5,57437,5003,0,52,13], + ["p1",11,"op","chess_char_5_diy1_b",1,"uniequip_003_lessng",3684,1,121,5,43603,5100,0,43,0], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_003_lessng",0,0,11,5,57693,7097,0,45,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":48,"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,26,16,63516,322257],"enemy_1320_wdrrl_2":[12,0,12,0,30011],"enemy_1422_lrsldr_2":[45,26,19,7784,222394]}, + "hooks": [1,121,6188,830,830,863,863,0,70,117,57,62,0,8927,0,70,99,47,1,73,0,0,0], + "events": {"atk":830,"deploy":20,"die":70,"dmg":863,"fx":3,"leak":47,"skill":114,"spawn":113,"status":86}, + "fx": {"revive":3}, + "snaps": "e19d072b 531f88ce 40770adf 6ee9a1c4 f083853a b5053669 d28f2f40 d1eb0ae8 dbf89517 f75e64a8 faa47d5b c9c441a9 1d211b20 96e89d56 9c8ee124 6130e688 bc77ea52 f1010dcd f71800b1 00b8be7f ba1c0623" }, "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_a=lessng/S1 6_diy2_a=lessng/S2 6_diy1_a=lessng/S3 6_diy2_b=lessng/S1 6_diy1_b=lessng/S2 6_diy2_b=lessng/S3 6_diy1_b=lessng/S1/002_lessng 6_diy2_b=lessng/S2/002_lessng 6_diy1_b=lessng/S3/002_lessng 6_diy2_b=lessng/S1/003_lessng 6_diy1_b=lessng/S2/003_lessng 6_diy2_b=lessng/S3/003_lessng", "fast": false, "kind": "normal", "stageId": "act2autochess_m01", @@ -2108,35 +2101,276 @@ "round": 13, "seed": 1210502478, "waveId": "act1autochess_h06", - "time": 218.57, - "ticks": 6557, + "time": 219.57, + "ticks": 6587, "reason": "cleared", - "killed": 0, + "killed": 3, "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":3,"total":123,"perfect":false,"leaks":120,"leaksUncounted":0,"coins":0,"dmg":188093,"bossDmg":0,"heal":0,"deaths":22,"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_a",0,null,0,0,145,0,6596,8394,0,7,1], + ["p1",2,"op","chess_char_6_diy2_a",1,null,0,0,119,0,10923,5596,0,8,0], + ["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,83,0,1906,5596,0,3,0], + ["p1",4,"op","chess_char_6_diy2_b",0,"none",0,0,128,0,2972,6768,0,4,0], + ["p1",5,"op","chess_char_6_diy1_b",1,"none",0,0,138,0,19911,10152,0,8,0], + ["p1",6,"op","chess_char_6_diy2_b",2,"none",0,0,5,0,3367,5544,0,2,1], + ["p1",7,"op","chess_char_6_diy1_b",0,"uniequip_002_lessng",0,0,84,0,19090,7568,0,12,2], + ["p1",8,"op","chess_char_6_diy2_b",1,"uniequip_002_lessng",0,0,1,0,3113,3784,0,3,0], + ["p1",9,"op","chess_char_6_diy1_b",2,"uniequip_002_lessng",0,0,2,0,21178,6811,0,10,1], + ["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_003_lessng",0,0,6,0,1855,5297,0,2,0], + ["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_003_lessng",0,0,80,1,58069,9169,0,24,0], + ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_003_lessng",0,0,76,2,39111,11597,0,29,1] ], - "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":8,"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,3,54,35258,80445],"enemy_1299_ymkilr_2":[27,0,27,43139,38308],"enemy_1422_lrsldr_2":[39,0,39,7878,69340]}, + "hooks": [1,145,6587,328,328,349,349,0,25,145,6,14,0,2752,0,25,123,120,1,30,0,0,0], + "events": {"atk":328,"deploy":22,"die":25,"dmg":349,"fx":5,"leak":120,"skill":12,"spawn":135,"status":76}, + "fx": {"revive":5}, + "snaps": "cd4c0914 a9ecc919 6de3f3af 391312d7 25d39249 07bcdea5 57c4b7d8 bd27f4a1 dcbcaf31 6d6dc344 8e6fb702 4d8bbdb9 5998ea80 321d1110 b994c042 a8515723 38167de3 445dae88 e13bc0d1 4b2cd047 31f53000 22ee9ec1" + }, + "diy-053": { + "about": "5_diy1_a=demetr/S1 5_diy2_a=demetr/S2 5_diy1_a=demetr/S3 5_diy2_b=demetr/S1 5_diy1_b=demetr/S2 5_diy2_b=demetr/S3 5_diy1_b=demetr/S1/002_demetr 5_diy2_b=demetr/S2/002_demetr 5_diy1_b=demetr/S3/002_demetr 6_diy1_a=demetr/S1 6_diy2_a=demetr/S2 6_diy1_a=demetr/S3", + "fast": false, + "kind": "normal", + "stageId": "act2autochess_m02", + "modeId": "mode_multi_funny", + "round": 12, + "seed": 2850577873, + "waveId": "act1autochess_h05", + "time": 212.47, + "ticks": 6374, + "reason": "cleared", + "killed": 78, + "total": 114, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 395, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":78,"total":114,"perfect":false,"leaks":36,"leaksUncounted":0,"coins":0,"dmg":609594,"bossDmg":0,"heal":0,"deaths":7,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",0,null,700,1,152,1,5041,3604,0,18,3], + ["p1",2,"op","chess_char_5_diy2_a",1,null,1184,1,148,1,31451,3120,0,35,1], + ["p1",3,"op","chess_char_5_diy1_a",2,null,142,1,154,0,14269,4162,0,51,2], + ["p1",4,"op","chess_char_5_diy2_b",0,"none",1498,1,150,6,18045,3434,0,37,7], + ["p1",5,"op","chess_char_5_diy1_b",1,"none",1922,1,138,14,125199,3010,0,118,3], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",2466,1,153,17,96433,2466,0,143,2], + ["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_demetr",2495.84,1,131,14,95220,2716,0,144,28], + ["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_demetr",2338,1,8,9,62696,268,0,88,3], + ["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_demetr",734,1,149,13,100385,1872,0,123,3], + ["p1",10,"op","chess_char_6_diy1_a",0,null,1858,1,9,1,3182,294,0,10,2], + ["p1",11,"op","chess_char_6_diy2_a",1,null,700,1,10,2,57675,1452,0,65,4], + ["p1",12,"op","chess_char_6_diy1_a",2,null,1668,1,151,0,0,484,0,0,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] + ], + "enemies": {"enemy_1166_dusbr_2":[45,34,11,270,157250],"enemy_1169_duphlx_2":[51,40,11,2328,241789],"enemy_1172_dugago":[18,4,14,24284,210555]}, + "hooks": [1,154,6374,1072,1072,1324,1199,0,85,121,61,60,0,21844,0,83,114,36,1,100,0,0,125], + "events": {"atk":1292,"deploy":38,"die":85,"dmg":1199,"fx":160,"leak":36,"skill":121,"spawn":128,"status":167}, + "fx": {"dodge":125,"revive":10,"stone":14,"teleport":10,"undying":1}, + "snaps": "f1c7675f 6ef4b679 bc70f6df 2758ad06 c671adec e8dc0452 33ac2c01 72d1ae88 20b3f886 b54788a9 7dcc96d2 ba1498dd d989a725 9818ca09 607d344a a3ceeda3 5d4e970e b08568dd 5455cac0 dfb31009 8610aa6f a717adee" + }, + "diy-054": { + "about": "5_diy1_a=cdfend 5_diy2_b=cdfend 6_diy1_b=demetr/S1 6_diy2_b=demetr/S2 6_diy1_b=demetr/S3 6_diy2_b=demetr/S1/002_demetr 6_diy1_b=demetr/S2/002_demetr 6_diy2_b=demetr/S3/002_demetr 5_diy1_a=cguard 5_diy2_b=cguard 5_diy1_a=csnipe 5_diy2_b=csnipe", + "fast": true, + "kind": "normal", + "stageId": "act2autochess_m03", + "modeId": "mode_single_hard", + "round": 13, + "seed": 196461732, + "waveId": "act1autochess_h06", + "time": 230, + "ticks": 6900, + "reason": "timeout", + "killed": 2, + "total": 66, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 145, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":2,"total":66,"perfect":false,"leaks":64,"leaksUncounted":0,"coins":0,"dmg":17584,"bossDmg":0,"heal":0,"deaths":24,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,87,0,0,4256,0,0,0], + ["p1",2,"op","chess_char_5_diy2_b",2,"none",2804.93,1,103,0,0,5919,0,0,0], + ["p1",3,"op","chess_char_6_diy1_b",0,"none",2466,1,11,0,0,0,0,0,0], + ["p1",4,"op","chess_char_6_diy2_b",1,"none",2466,1,106,0,0,4932,0,0,0], + ["p1",5,"op","chess_char_6_diy1_b",2,"none",2466,1,104,0,0,4932,0,0,2], + ["p1",6,"op","chess_char_6_diy2_b",0,"uniequip_002_demetr",0,0,67,0,0,5392,0,0,0], + ["p1",7,"op","chess_char_6_diy1_b",1,"uniequip_002_demetr",0,0,64,0,0,5392,0,0,0], + ["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_demetr",1993.23,1,105,0,0,6095,0,0,2], + ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,74,0,0,5128,0,0,0], + ["p1",10,"op","chess_char_5_diy2_b",2,"none",0,0,69,0,0,6668,0,0,0], + ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,102,1,6796,3813,0,28,1], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,99,1,10788,4530,0,31,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_1005_yokai_2":[12,1,10,4251,2571],"enemy_1041_lazerd_2":[24,1,23,52805,14727],"enemy_1042_frostd":[30,0,30,0,285]}, + "hooks": [1,106,6900,297,297,288,203,0,26,89,6,6,0,12679,0,0,66,63,1,29,0,0,85], + "events": {"atk":297,"deploy":37,"die":26,"dmg":203,"fx":92,"leak":63,"skill":12,"spawn":81,"status":66}, + "fx": {"dodge":85,"teleport":4,"undying":3}, + "snaps": "dec4fe99 3b5c3a11 ff2f9359 315c8382 c5a5a260 bfea48cc e98e0708 3d4e59df 9759cd28 e36c07b4 c9a2701f fafbd9d2 d51a357d 0511bcb7 cd17f541 d4cf77b9 0c67904b d677b0af 1f01e5d4 e2eab50c a8bd154c 81a57edb fe30e4ea" + }, + "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=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo", + "fast": false, + "kind": "normal", + "stageId": "act2autochess_m04", + "modeId": "mode_multi_normal", + "round": 12, + "seed": 1597707536, + "waveId": "act1autochess_h05", + "time": 228.5, + "ticks": 6855, + "reason": "cleared", + "killed": 26, + "total": 117, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 109, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":26,"total":117,"perfect":false,"leaks":91,"leaksUncounted":0,"coins":0,"dmg":116177,"bossDmg":0,"heal":1527,"deaths":24,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,120,0,0,5208,0,0,0], + ["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",0,0,116,0,0,6742,0,0,0], + ["p1",3,"op","chess_char_5_diy1_a",2,null,1513.21,1,146,6,18964,3074,0,0,2], + ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",533.8,1,144,12,53249,5178,0,0,3], + ["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,99,0,0,5276,0,0,0], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,95,0,0,6822,0,0,0], + ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,3,2,2801,1133,0,7,1], + ["p1",8,"op","chess_char_5_diy2_b",2,"none",0,0,4,3,23106,1360,0,44,1], + ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,141,0,0,4624,1341,4,1], + ["p1",10,"op","chess_char_5_diy2_b",2,"none",1374,1,145,0,0,3305,186,1,1], + ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,134,3,9241,1864,0,25,1], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,143,0,8816,3393,0,19,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,14,37,0,29083],"enemy_1005_yokai_2":[54,12,42,15091,46247],"enemy_1005_yokai_3":[12,0,12,32888,40847]}, + "hooks": [1,146,6855,565,565,623,590,5,50,141,11,11,0,14473,41,0,117,91,1,50,0,0,33], + "events": {"atk":565,"deploy":27,"die":50,"dmg":590,"fx":79,"heal":5,"leak":91,"skill":22,"spawn":131,"status":91}, + "fx": {"dodge":33,"dp":23,"slash":23}, + "snaps": "ce9ca917 dc0c23c1 ec28421e 3235af3b 7760df97 37714ea1 2d79aa03 16bcb957 d2059f17 ebee2762 29c4b543 6856ac9b f6227083 c41b8525 a8160d33 202679e9 c5cc7507 c3f7c3db 878a0b36 2200d567 8cab3a1c ad9d8bf1 a37c78c3" + }, + "diy-056": { + "about": "5_diy1_a=sharp2 5_diy2_b=sharp2 5_diy1_a=acspec 5_diy2_b=acspec 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc 5_diy1_a=acsupo 5_diy2_b=acsupo", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m01", + "modeId": "mode_multi_hard", + "round": 12, + "seed": 3238296381, + "waveId": "act1autochess_h05", + "time": 212, + "ticks": 6360, + "reason": "cleared", + "killed": 2, + "total": 75, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 115, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":2,"total":75,"perfect":false,"leaks":73,"leaksUncounted":0,"coins":0,"dmg":149973,"bossDmg":0,"heal":3310,"deaths":32,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",0,null,0,0,7,0,9094,1865,0,11,1], + ["p1",2,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",0,0,9,1,21055,2374,0,10,1], + ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,110,0,21484,11540,0,17,0], + ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",1448,1,112,0,44302,14480,0,18,0], + ["p1",5,"op","chess_char_5_diy1_a",2,null,1272,1,111,0,9235,1272,0,26,0], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",0,0,5,0,21680,1587,0,24,1], + ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,11,0,9181,1872,0,2,1], + ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",0,0,13,0,5501,1749,0,3,0], + ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,6,0,0,1224,1482,8,1], + ["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",0,0,58,0,0,4657,1829,3,0], + ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,61,0,2034,2320,0,6,0], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",0,0,8,1,6409,2412,0,10,1], + ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], + ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], + ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null] + ], + "enemies": {"enemy_1267_nhpbr_2":[36,2,34,9446,89200],"enemy_1270_nhstlk_2":[27,0,27,6783,54853],"enemy_1272_nhtank_2":[12,0,12,31122,5919]}, + "hooks": [1,112,6360,204,204,406,419,13,34,107,6,26,0,6739,529,26,75,73,1,34,0,0,0], + "events": {"atk":217,"deploy":34,"die":34,"dmg":355,"fx":56,"heal":12,"leak":73,"skill":12,"spawn":90,"status":210}, + "fx": {"aoe":3,"displace":31,"dodge":2,"spGain":2,"zone":18}, + "snaps": "755acf8e 65e848ea aee3c8d0 c0dbf6b8 6b1b17d0 1de17c9a 41c7cf67 6d809617 89487051 d85151c9 4bc2c240 aba326b4 08e6438e 6a018fd6 da4578ac acba1b10 77b5ea51 b4647e71 ebb093c7 f862b038 cf3f0a5c 698411b8" + }, + "diy-057": { + "about": "6_diy1_a=acfend 6_diy2_b=acfend 6_diy1_a=acpion 6_diy2_b=acpion 6_diy1_a=acguad 6_diy2_b=acguad 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m02", + "modeId": "mode_single_normal", + "round": 13, + "seed": 583790378, + "waveId": "act1autochess_h06", + "time": 230, + "ticks": 6900, + "reason": "timeout", + "killed": 27, + "total": 147, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 957, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":27,"total":147,"perfect":false,"leaks":120,"leaksUncounted":0,"coins":0,"dmg":816188,"bossDmg":0,"heal":89315,"deaths":5,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",2,null,0,0,163,0,1057,7812,0,37,0], + ["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",3159.36,1,1,0,10504,6519,0,98,2], + ["p1",3,"op","chess_char_6_diy1_a",2,null,1529,1,153,0,13774,5174,0,95,5], + ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",1904,1,156,5,117662,6159,0,52,8], + ["p1",5,"op","chess_char_6_diy1_a",2,null,2638,1,10,5,49374,16637,0,121,4], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",3531,1,11,9,97537,16240,0,128,5], + ["p1",7,"op","chess_char_6_diy1_a",2,null,1272,1,5,0,19178,0,0,171,3], + ["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",1643,1,8,1,97455,0,0,192,3], + ["p1",9,"op","chess_char_6_diy1_a",2,null,1372,1,6,6,175317,0,0,77,2], + ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",1819,1,9,1,234331,0,0,82,3], + ["p1",11,"op","chess_char_6_diy1_a",2,null,1224,1,12,0,0,35218,38674,78,3], + ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",1464,1,2,0,0,7074,50642,74,3] + ], + "enemies": {"enemy_1166_dusbr_2":[39,10,29,1287,210292],"enemy_1169_duphlx_2":[57,10,46,56400,279740],"enemy_1170_dushld_2":[51,7,42,43145,326156]}, + "hooks": [1,163,6900,1572,1572,3294,3267,251,32,149,41,37,0,46308,0,43,147,117,1,32,0,0,27], + "events": {"atk":1850,"deploy":16,"die":32,"dmg":3267,"fx":236,"heal":232,"leak":117,"skill":78,"spawn":159,"status":172}, + "fx": {"aoe":91,"dodge":27,"dp":18,"slash":98,"spGain":2}, + "snaps": "763f5829 14e476f8 8427cf50 1f17887d 31accd4f bca21d62 979b8a03 056885f6 7be715ee 7f7c774a 9f8ec3ab 9e65a95f 4f03c156 3a02c890 a875ae0f c4a6206c dd5bb5e4 14eadfdf 87568458 e6be5156 3d7da02b bb73bb00 1d514e99" + }, + "diy-058": { + "about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acsupo 6_diy2_b=acsupo", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m03", + "modeId": "mode_multi_abyss", + "round": 13, + "seed": 2224511831, + "waveId": "act1autochess_h06", + "time": 208.07, + "ticks": 6242, + "reason": "cleared", + "killed": 0, + "total": 105, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 53, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":0,"total":105,"perfect":false,"leaks":105,"leaksUncounted":0,"coins":0,"dmg":94634,"bossDmg":0,"heal":0,"deaths":26,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,4,0,2431,1865,0,8,0], + ["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,5,0,20502,2494,0,12,1], + ["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,134,0,22891,11540,0,20,0], + ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",1484,1,135,0,38022,13356,0,21,0], + ["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,76,0,9532,2320,0,29,2], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",0,0,121,0,1255,4350,0,4,0], + ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], + ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], + ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null] + ], + "enemies": {"enemy_1229_darmy":[27,0,27,2887,28278],"enemy_1229_darmy_2":[57,0,57,6910,40954],"enemy_1275_dwlock_2":[21,0,21,26127,25401]}, + "hooks": [1,135,6242,149,149,288,335,0,26,131,3,22,0,3213,711,22,105,105,1,26,0,0,1], + "events": {"atk":153,"deploy":27,"die":26,"dmg":275,"fx":60,"leak":105,"skill":6,"spawn":114,"status":206}, + "fx": {"beam":26,"displace":33,"dodge":1}, + "snaps": "94943173 294f52f9 121acf93 d3fc4a35 953aa247 3c7a92c8 3ab8693e 76fa1f07 0d33591f a915e831 339dbaef 60e1df5b 95bdb81e 74579379 3e516ade 76530183 87d55d71 15280bc2 0961f61a c2a04d2f a28c2997" } } }