From 407533851781fcb2f0f6662f827a72494f7e132b Mon Sep 17 00:00:00 2001 From: sganggs Date: Tue, 6 Oct 2026 00:08:13 +0800 Subject: [PATCH] =?UTF-8?q?feedback5=20O6:=20=E8=87=AA=E9=80=89=20operator?= =?UTF-8?q?=20kits=20for=20=E9=BB=91,=20=E7=84=B0=E7=8B=90=E9=BE=99?= =?UTF-8?q?=E6=A2=93=E5=85=B0=20and=20=E9=B8=BF=E9=9B=AA=20(+=20her=20?= =?UTF-8?q?=E2=80=9C=E6=89=93=E5=AD=97=E6=9C=BA=E2=80=9D)=20=E2=80=94=20ev?= =?UTF-8?q?ery=20skill,=20talent,=20trait=20and=20module=20at=20every=20fo?= =?UTF-8?q?rm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - op-shwaz.js 黑: 破甲箭头 (one roll per attack, raised by S1 / S2 / S3; ×atk_scale, then defDown on a landed hit; numbers from the base talent + ARC-Y's named change — the data folds ARC-Y's hidden trait talent into talent 0 at stage 1), 交叉火力 (field aura; ARC-X stage 3 "携带": her player's snipers for the battle), ARC-Y 正前方 ×1.05 undodgeable, S3 3-2 + interval +0.4; triggers from the data (S3 SKILL_RANGE — reported). - op-orchd2.js 焰狐龙梓兰: 三连击 normal attack cut to 33.3 % after DEF (PRTS 特性备注), 强击瓶专家 stacks per round, 翔虫机动 (respawn −15 / −18 s, ATK +15 / 20 % for 30 s on a redeploy at her mark), S1 刚射 / 刚连射 (charges, refund without a target, stun per arrow), S2 飞翔瞪射 (free cast at every deployment, 起飞, three volleys on the 4-4, landing on the 1-3, timed by the official skeleton), S3 龙之箭 (3 s charge, piercing arrow at 10 tiles/s, strikes every 0.25 tile, push). - op-bgsnow.js 鸿雪: S1 toggle + proc, S2 点题 (立即, 普通 + 2 × 持续伤害), S3 3-1 with 正前方3格 scale, ARC-Y as 黑's; her placed “打字机” pieces get their kit at her install (25 s life, 40 s redeploy × S2's 65 / 60 %, 5 DP; the skill copies; 弱点速记 DEF cut 18 / 23 %, ARC-X stage 3 28 / 33 % with 八格). - kits/index.js OPERATOR_KIT_FILES: one line per file. Tests: test/content/op_shwaz / op_orchd2 / op_bgsnow (23). - Golden: only the diy family moved — diy-003 … diy-006 now hold the 66 new 自选 configurations (黑, 焰狐龙梓兰, 鸿雪) in place of the prototype picks, diy-007 … diy-012 are new (the prototype battles moved down); every other family is byte-identical. test/golden/README.md: 12 diy battles, 61 of 156. --- server/sim/content/kits/index.js | 7 +- server/sim/content/kits/ops/op-bgsnow.js | 226 ++++++++++++ server/sim/content/kits/ops/op-orchd2.js | 306 ++++++++++++++++ server/sim/content/kits/ops/op-shwaz.js | 163 +++++++++ test/content/op_bgsnow.test.js | 306 ++++++++++++++++ test/content/op_orchd2.test.js | 340 ++++++++++++++++++ test/content/op_shwaz.test.js | 271 ++++++++++++++ test/golden/README.md | 4 +- test/golden/diy.json | 440 +++++++++++++++++------ 9 files changed, 1960 insertions(+), 103 deletions(-) create mode 100644 server/sim/content/kits/ops/op-bgsnow.js create mode 100644 server/sim/content/kits/ops/op-orchd2.js create mode 100644 server/sim/content/kits/ops/op-shwaz.js create mode 100644 test/content/op_bgsnow.test.js create mode 100644 test/content/op_orchd2.test.js create mode 100644 test/content/op_shwaz.test.js diff --git a/server/sim/content/kits/index.js b/server/sim/content/kits/index.js index b47d568b..f0142e8d 100644 --- a/server/sim/content/kits/index.js +++ b/server/sim/content/kits/index.js @@ -78,7 +78,12 @@ export const STANDIN_KIT_FILES = Object.freeze(['standin-acguad.js', 'standin-sh * The 自选 operator kit files (`ops/op-.js`, registry key = the operator's charId, an owned-6★ pick of * data/backups.json; a new file is appended). An operator without a file is not offered as a 自选 pick (KITTED_CHARS). */ -export const OPERATOR_KIT_FILES = Object.freeze(['op-siege.js']); +export const OPERATOR_KIT_FILES = Object.freeze([ + 'op-siege.js', + 'op-shwaz.js', + 'op-orchd2.js', + 'op-bgsnow.js', +]); /** * The 预备干员 whose every skill the generic kit covers exactly (冲锋号令, 攻击力 / 防御力 / 治疗强化, 战术咏唱, 一击即退 — diff --git a/server/sim/content/kits/ops/op-bgsnow.js b/server/sim/content/kits/ops/op-bgsnow.js new file mode 100644 index 00000000..b0336b0b --- /dev/null +++ b/server/sim/content/kits/ops/op-bgsnow.js @@ -0,0 +1,226 @@ +// server/sim/content/kits/ops/op-bgsnow.js — 鸿雪 (char_4055_bgsnow) 自选 operator kit: 6★ 重射手 (狙击), an owned-6★ pick of +// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait, both modules (ARC-Y 打字机色带, ARC-X 仪式感) at +// every form, and the kit of her summon “打字机” (token_10026_bgsnow_subbow). Kit contract and the 自选 rules: ../README.md +// ("How to add an operator (自选)"). +// +// Forms (data/backups.json units.char_4055_bgsnow): normal = E2 Lv1, skills at rank 4, no module; elite = E2 Lv60, rank 7, +// the picked module at stage 1 (tier 5) or 3 (tier 6) — the owner's decision of 2026-10-05. Potential 0 [ASSUMED: no +// account]. Sources: character_table / skill_table / battle_equip_table / token_table (zh_CN, as built into backups.json) +// and PRTS 鸿雪 + “打字机” (点题 备注; the {{**|82%|下降18%}} marks = a final ×0.82; PRTS 伤害分类: "鸿雪2技能三段伤害的后2段" +// are 持续伤害). +// - Trait (重射手) "高精度的近距离射击": ranged physical arrows, 3-6, can hit air units (PRTS 分支特性信息 重射手 "可对空"), +// blocks 1, ground enemies target her (no flag). +// - Module ARC-Y “打字机色带” trait "攻击正前方的敌人时攻击力提升至105%且无视其物理闪避" (trait bb atk_scale): her damage on +// an enemy whose body is on her straight line ahead ×atk_scale, not dodgeable — 点题's two 持续伤害 hits included +// [ASSUMED: they are two of its "3次攻击"]; 正前方 = local row 0, column ≥ 0 (her own tile included, as the 3-2 "前方3格" +// lines count it) [ASSUMED]. Stage 2+ "属性更强的打字机": the typewriter's stats of the data (token module attributes). +// - Module ARC-X “仪式感”: "再部署时间减少" (attribute respawn_time −25, in the stats); stage 2+ upgrades 弱点速记 (below). +// - T1 自动打字机 "可使用一个持续25秒的“打字机”,“打字机”拥有和自己一样的技能,拥有独立的再部署时间": the typewriter is a +// hand piece the player places (the mode's talent summons, content/tokens.js header): it deploys with the board, leaves +// after 25 s (the token's hidden talent `interval`), and comes back on its tile after its own redeploy time (data 40 s; × +// respawn_time while she carries S2), paying its cost (5 DP), while she is on the field [ASSUMED: as the 海嗣]. Its skill +// is the copy of hers the data gives (bySkill: sktok_bgsnow_1 / 2 / 3 — the 卫戍 strategy force-opens a summon's manual +// skill, PRTS 卫戍协议/帮助): free (spCost 0), once per deployment, by the data's DEFAULT. It is untargetable ("不会受到 +// 攻击"), blocks nothing, hits air units. content/tokens.js has no kit for it: her install gives her pieces this one. +// - T2 弱点速记 "“打字机”的攻击会使命中目标的防御力下降18%,持续4秒;若“打字机”放在鸿雪周围四格则效果提升至23%": every +// damage the typewriter's attacks land cuts the target's DEF by bgsnow_token[def_down]_1 (_2 while the typewriter stands +// on one of the 4 tiles next to her — while she is on the field [ASSUMED]) for `duration` s: the catalogue `defDown` +// (final ×, 同名 keeps the strongest). ARC-X stage 2+: 28 % / 33 % and "周围八格" (the talent text names the ring). +// - S1 抑扬格 (AUTO, attack SP, data DEFAULT — an AUTO attack buff waits for her next attack): ATK +atk until she leaves the +// field ("持续时间无限": a toggle), and every attack meanwhile `prob` to deal ×atk_scale (one roll per attack). +// - S2 点题 (MANUAL, 2 charges, data SKILL_RANGE on its 3-1): passive — the typewriter's redeploy time ×respawn_time; +// active — 3 attacks at once ("立即": the cast takes the next attack now) on one target of the 3-1, atk_scale × ATK each: +// the first a physical 普通伤害 attack, the other two physical 持续伤害 (PRTS 伤害分类: tags 'dot', not dodgeable). +// PRTS 备注 "仅在干员自身范围内有可攻击目标时可释放": the data's SKILL_RANGE fires for any enemy of the 3-1, untargetable +// ones too; the cast then waits (pending) for an attackable target — reported as an open question (the 备注 reading +// of the strategy would be the basic one on her own range). +// - S3 锐笔速写 (MANUAL, data ACTIVE_RANGE on its 3-1): range 3-1 while it runs, attack interval base_attack_time s (−0.6 on +// 1.6 s), every attack atk_scale × ATK, bgsnow_s_3[atk_up].atk_scale × ATK on the 正前方3格 (the 3-2 line, her tile +// included [ASSUMED]). + +import { num, up, traitBb, skillRec, batMod } from '../shared/tier1.js'; +import { absoluteRangeKeys } from '../../../targeting.js'; +import { bodyKeys, bodyInKeys } from '../../../body.js'; +import { toLocal } from '../../../dir.js'; +import { hasHp } from '../../../damage.js'; +import { COLS } from '../../../constants.js'; + +const S1 = 'skchr_bgsnow_1'; +const S2 = 'skchr_bgsnow_2'; +const S3 = 'skchr_bgsnow_3'; +export const TYPEWRITER = 'token_10026_bgsnow_subbow'; +const TS1 = 'sktok_bgsnow_1'; +const TS2 = 'sktok_bgsnow_2'; +const TS3 = 'sktok_bgsnow_3'; +/** 锐笔速写's 正前方3格: the 3-2 line (range_table) — her tile and the three ahead. */ +const FRONT_3 = Object.freeze([[0, 0], [0, 1], [0, 2], [0, 3]]); +/** 点题's two 持续伤害 hits (PRTS 伤害分类 attackType BUFF). */ +const TAG_PRECIS = 'bgsnow:precis'; +/** 自动打字机: the typewriter's life when neither the token nor the talent carries it (PRTS: 持续25秒 at E2). */ +const LIFE_FALLBACK = 25; + +const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; +/** First talent of a normalised token def holding `key` (token talents keep no index). */ +const tokTalent = (def, key) => (def?.talents ?? []).find((t) => t && t.bb && t.bb[key] != null) ?? null; + +/** ARC-Y 正前方: the enemy's body touches the unit's straight line ahead (local row 0, local column ≥ 0). */ +function inFrontLine(unit, e) { + for (const k of bodyKeys(e)) { + const [lr, lc] = toLocal(Math.floor(k / COLS) - unit.tileR, (k % COLS) - unit.tileC, unit.dir); + if (lr === 0 && lc >= 0) return true; + } + return false; +} + +/** 点题 / its copy: the two 持续伤害 hits after the attack's first (普通伤害) hit, on the same target. */ +function precisFollowUps(battle, unit, target, scale) { + for (let i = 0; i < 2 && target && target.alive; i++) { + battle.dealDamage(unit, target, { amount: unit.s.atk * scale, type: 'phys', isSkill: true, canDodge: false, tags: ['skill', 'dot', TAG_PRECIS] }); + } +} + +/** 抑扬格 / its copy: while `on(unit)`, every attack `prob` to deal ×scale (one roll per attack). */ +function installIamb(battle, unit, prob, scale, on) { + if (!(prob > 0) || !(scale > 0) || scale === 1) return; + battle.on('hit', (ctx) => { + const d = ctx.dmg, t = ctx.target; + if (ctx.source !== unit || !t || t.side !== 'enemy' || !d.isAttack || d.isSplash || d.type === 'element' || !on(unit)) return; + const m = unit.mem; + if (!d.attackId || m.iambRollId !== d.attackId) { + m.iambRollId = d.attackId; + m.iambProc = battle.rng.chance(prob); + if (m.iambProc) battle.fx('crit', { x: t.x, y: t.y, id: unit.id }); + } + if (m.iambProc) d.amount *= scale; + }, { owner: unit }); +} + +/** + * The typewriter's kit: its skill (the copy of her skill the data gives), 弱点速记, its life and its redeploys. + * `owner` = 鸿雪, `chess` = her composed record. + */ +function typewriterKit(t, owner, chess) { + const def = t.def; + const sk = def.skill ?? null; + const tb = sk?.bb ?? {}; + const weak = tokTalent(def, 'duration'); + const wb = weak?.bb ?? {}; + const cut1 = -num(wb['bgsnow_token[def_down]_1.def']), cut2 = -num(wb['bgsnow_token[def_down]_2.def'], -cut1); + const ring8 = /八格/.test(String(weak?.description ?? '')); + const ownTalent = (chess?.talents ?? []).find((x) => x && x.index === 0)?.bb ?? {}; + const life = num(tokTalent(def, 'interval')?.bb?.interval, num(ownTalent.duration, LIFE_FALLBACK)); + const respawnMul = owner.skill?.id === S2 ? num(bbOf(chess, S2).respawn_time, 1) : 1; + const near = () => { + if (!up(owner)) return false; + const dr = Math.abs(t.tileR - owner.tileR), dc = Math.abs(t.tileC - owner.tileC); + return ring8 ? Math.max(dr, dc) === 1 : dr + dc === 1; + }; + const skillOn = (u) => !!(u.skill && u.skill.active); + let skill = null; + if (sk?.id === TS1) skill = { kind: 'toggle', mods: { atkPct: num(tb.atk) } }; + else if (sk?.id === TS2) { + const scale = num(tb.atk_scale, 1); + skill = { kind: 'instant', attack: { atkScale: scale, onHit({ battle, unit, target }) { precisFollowUps(battle, unit, target, scale); } } }; + } else if (sk?.id === TS3) { + skill = { + kind: 'duration', + targeting: { rangeGrid: sk.rangeGrid ?? [[0, 0], [0, 1], [0, 2], [0, 3], [0, 4]] }, + mods: { batPct: batMod(tb.base_attack_time, { stats: def.stats }) }, + attack: { atkScale: num(tb['attack@atk_scale'], 1) }, + }; + } + return { + skill, + talents: [{ install(battle, unit) { // 弱点速记: every damage of its attacks cuts the target's DEF (near her: more) + const dur = num(wb.duration, 4); + if (!(cut1 > 0) || !(dur > 0)) return; + battle.on('damaged', (ctx) => { + if (ctx.source !== unit || !ctx.target || ctx.target.side !== 'enemy' || !hasHp(ctx.target) || ctx.dmg?.type === 'element') return; + battle.applyStatus(ctx.target, 'defDown', { duration: dur, value: near() ? cut2 : cut1, source: unit }); + }, { owner: unit }); + } }], + install(battle, unit) { + if (sk?.id === TS1) installIamb(battle, unit, num(tb.prob), num(tb.atk_scale, 1), skillOn); + // 持续25秒: leaves `life` s after each deployment + battle.on('deploy', (ctx) => { + if (ctx.unit !== unit || !(life > 0)) return; + const seq = unit.deploySeq; + battle.after(life, () => { if (unit.alive && unit.deploySeq === seq) battle.retreat(unit, { reason: 'expired', permanent: true }); }, { owner: unit }); + }, { owner: unit }); + // 拥有独立的再部署时间: back on its tile after its redeploy time, paying its cost, while 鸿雪 stands (the piece is kept: + // `removed` false, as content/tokens.js enableRespawn) + battle.on('death', (ctx) => { + if (ctx.unit !== unit || battle.finished) return; + unit.removed = false; + const at = battle.time + Math.max(0, num(unit.base.respawnTime) * respawnMul); + unit.mem.respawnAt = at; + battle.every(0.25, (b, sched) => { + if (unit.alive || unit.removed) { sched.cancel(); return; } + if (b.time + 1e-9 < at || !up(owner)) return; + if (b.redeploy(unit, { free: false })) sched.cancel(); + }, { owner: unit }); + }, { owner: unit, priority: -10 }); + }, + }; +} + +export default { + char_4055_bgsnow: (bb, chess) => { + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + const s2 = skillRec(chess, S2), s3 = skillRec(chess, S3); + const frontScale = num(traitBb(chess).atk_scale, 1); + const s2Scale = num(b2.atk_scale, 1); + const s3Scale = num(b3.atk_scale, 1), s3Front = num(b3['bgsnow_s_3[atk_up].atk_scale'], s3Scale); + return { + skills: { + [S1]: { kind: 'toggle', mods: { atkPct: num(b1.atk) } }, + [S2]: { + kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant', + // the 3-1 selects the target; her attack range stays her own (a 技能范围, not a range change) + targeting: { rangeGrid: s2?.rangeGrid ?? null, showOwnRange: true }, + attack: { atkScale: s2Scale, onHit({ battle, unit, target }) { precisFollowUps(battle, unit, target, s2Scale); } }, + onStart({ unit }) { unit.atkCd = 0; }, // "立即": the pending attack goes off now when a target is there + }, + [S3]: { + kind: 'duration', + targeting: { rangeGrid: s3?.rangeGrid ?? null }, + mods: { batPct: batMod(b3.base_attack_time, chess) }, + attack: { atkScale: s3Scale }, + }, + }, + talents: [ + // 自动打字机 (and 弱点速记, the typewriter's own talent): her typewriter pieces run the typewriter's kit — set up + // here, before the battle starts (Battle._setupUnit with a kit, as spawnToken's `kit` option) + { install(battle, unit) { + for (const t of battle.allyUnits) { + if (t.kind !== 'token' || t.defId !== TYPEWRITER || t.ownerUnit !== unit || t.alive || t.deployed) continue; + if (t.kit) battle.offOwner(t); // a piece set up before her: drop its generic kit's hooks + battle._setupUnit(t, typewriterKit(t, unit, chess)); + } + } }, + ], + install(battle, unit) { + const sid = unit.skill?.id; + if (sid === S1) installIamb(battle, unit, num(b1.prob), num(b1.atk_scale, 1), (u) => !!u.skill?.active); + // 锐笔速写: 正前方3格 ⇒ atk_up's scale instead of the skill's + if (sid === S3 && s3Front !== s3Scale && s3Scale > 0) { + battle.on('hit', (ctx) => { + const d = ctx.dmg, t = ctx.target; + if (ctx.source !== unit || !t || t.side !== 'enemy' || !d.isAttack || !unit.skill?.active) return; + if (bodyInKeys(t, absoluteRangeKeys(FRONT_3, unit.tileR, unit.tileC, unit.dir, 0))) d.amount *= s3Front / s3Scale; + }, { owner: unit }); + } + // ARC-Y 打字机色带: 攻击正前方的敌人时攻击力提升至105%且无视其物理闪避 + if (frontScale > 0 && frontScale !== 1) { + battle.on('hit', (ctx) => { + const d = ctx.dmg, t = ctx.target; + if (ctx.source !== unit || !t || t.side !== 'enemy' || d.type === 'element') return; + if (!(d.isAttack || d.tags?.includes(TAG_PRECIS)) || !inFrontLine(unit, t)) return; + d.amount *= frontScale; + d.canDodge = false; + }, { owner: unit }); + } + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/op-orchd2.js b/server/sim/content/kits/ops/op-orchd2.js new file mode 100644 index 00000000..1b2004ad --- /dev/null +++ b/server/sim/content/kits/ops/op-orchd2.js @@ -0,0 +1,306 @@ +// server/sim/content/kits/ops/op-orchd2.js — 焰狐龙梓兰 (char_1048_orchd2) 自选 operator kit: 6★ 重射手 (狙击), an owned-6★ +// pick of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and her module (ARC-X 梓兰特制箭靶) at every +// form. Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)"). +// +// Forms (data/backups.json units.char_1048_orchd2): normal = E2 Lv1, skills at rank 4, no module; elite = E2 Lv60, rank 7, +// ARC-X at stage 1 (tier 5) or 3 (tier 6) — the owner's decision of 2026-10-05. Potential 0 [ASSUMED: no account]. +// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 焰狐龙梓兰 (特性备注, +// the 备注 of both talents and all three skills, quoted where used); the official skeleton of char_1048_orchd2 (front .skel: +// the Skill_X animations and their OnAttack events) for the moments the skills act — PRTS 飞翔瞪射 备注 "三轮攻击的时间模式 +// 均为跟随动画", 龙之箭 备注 (frame counts of the first / later casts). NB she came with a 联动寻访 (PRTS 获得方式; Monster +// Hunter); her team is 预备行动组 (reserve6), not one of build-data DIY_EXCLUDED_TEAMS, so the data offers her — reported. +// - Trait (重射手) "高精度的近距离射击": ranged physical arrows, 3-6, can hit air units (PRTS 分支特性信息 重射手 "可对空"), +// blocks 1, ground enemies target her except while airborne (S2). PRTS 特性备注: her normal attack is a 三连击 of 100 % +// ATK each, and every normal-attack damage is cut to 33.3 % after DEF / RES (hidden talent attack@damage_scale 0.333): +// profile hits 3 (the count is in no blackboard: NORMAL_HITS) and dmg.mul × damage_scale on her non-skill attack damage. +// - T1 强击瓶专家 "部署后首次开启技能时,接下来50次攻击的攻击力提升至115%": the first cast of each deployment gives +// power_attack_count stacks; each attack ROUND (备注: a normal attack's three hits, a skill volley, 飞翔瞪射's landing, one +// 龙之箭) spends one and all of that round's damage is ×power_attack_scale (an ATK-scale multiplier, 备注). A round spends +// it at its first hit, a 龙之箭 when it is shot [ASSUMED: the 备注 spends it "于弹道脱手前" — only a round whose arrows all +// fizzle differs]. The first cast also uses the longer Skill_X_Begin_First clip (备注): its waits below are the longer ones. +// - T2 翔虫机动 "再部署时间-15秒且不提高部署费用;部署至上次部署位置周围时,30秒内攻击力+15%…可以部署在近战位": the hidden +// talent's respawn_time (−15; ARC-X stage 3 −18) shortens her redeploy time; when she leaves the field a mark stays on her +// tile (备注) and a later deployment inside its range (bbStr ignore_build_type_target_range: x-1, ARC-X stage 3 x-2, from +// range_table) gives ATK +atk for atk_duration s — the mode's redeploy brings her back on that very tile, so every +// redeploy does. "不提高部署费用" and "可以部署在近战位": nothing in battle (the mode never raises a redeploy cost; the +// board places her). +// - Module ARC-X “梓兰特制箭靶”: "再部署时间减少" (attribute respawn_time −25, in the stats) + ATK / DEF; stage 3 upgrades T2. +// - S1 刚射 (MANUAL, charges 2 / 3, data DEFAULT): the cast's attack is 4 arrows of atk_scale_1 × ATK (the text's counts: +// "发射4支", "发射5支" — parsed); with a charge left it spends one more (备注: "消耗2次充能所需的技力") for 刚连射 1.167 s +// later (Skill_1_End_2: OnAttack at 0.667 / 1.833 s): 5 arrows of atk_scale_2 × ATK, each stun_prob to stun `stun` s; +// no target then ⇒ the skill stops and the charge comes back (备注). No normal attack until the end clip is over. +// "充能至最大层数时自动释放一次": the DEFAULT rule casts it as soon as she is about to attack — the 3 s operation cooldown +// never binds at max charges (a refill takes ≥ 10 s), so no extra rule. +// - S2 飞翔瞪射 (MANUAL, charges 2 / 3, data SKILL_RANGE on its 4-4): 起飞 (liftoff + blockFly, as 蒂比: no ground enemy +// blocked or attacking her) for 4.2 s, no normal attack (备注 "自身丢失全部视野"), three volleys at every target of the 4-4 +// (air units too) of 3 / 4 / 5 arrows × attack@atk_scale_loop each, then she lands with attack@atk_scale_end × ATK on every +// enemy of the 1-3 (备注 "降落时伤害范围为1-3"; air units too [ASSUMED]). Timeline from the skeleton: volleys 0.167 s into +// each 0.833 s Skill_2_Loop after the 0.667 s Skill_2_Begin (first cast: Begin_First 1.5 s, the skill 0.833 s longer), +// the landing 0.233 s into Skill_2_End. "部署后立即释放一次": every deployment casts it at once, free (备注 "无视技力…不会消耗 +// 技力"). +// - S3 龙之箭 (MANUAL, charges 1 / 2, data DEFAULT): after 3 s (Skill_3_Begin 1.5 s + wait_duration; first cast +// Begin_First 2.167 s, 备注 ≈110 frames) and 0.1 s more (Skill_3_End OnAttack) a piercing arrow flies straight ahead from +// her at 10 tiles/s (备注), collision radius 0.5 (备注), until it leaves the field: every dist_interval tiles it deals +// atk_scale × ATK physical, then atk_scale_magic × ATK arts (备注 order) to every enemy around it (air units too +// [ASSUMED]), and it pushes each enemy it touches with 力度 `force` (中等力度) along its flight, once per +// knockback_duration s per enemy (备注). ATK is read when it is shot [ASSUMED]. Her range does not change (备注 "不影响 +// 实际视野"). No normal attack until Skill_3_End is over (1.233 s). +// [ASSUMED] for the three skills: a stun / freeze does not cancel the scheduled volleys, bursts and arrow (no animation +// model); the wind-up of 刚射 before its first shot (Skill_1_Begin + 0.667 s) is not modelled, as no attack's wind-up is. + +import { num, up, skillRec } from '../shared/tier1.js'; +import { performAttack, acquireTargets } from '../../../ai.js'; +import { absoluteRangeKeys } from '../../../targeting.js'; +import { dirVec } from '../../../dir.js'; +import { hasHp } from '../../../damage.js'; + +const S1 = 'skchr_orchd2_1'; +const S2 = 'skchr_orchd2_2'; +const S3 = 'skchr_orchd2_3'; +/** PRTS 特性备注 "普通攻击为三连击": the hits of her normal attack (no blackboard key). */ +export const NORMAL_HITS = 3; +/** + * The official skeleton of char_1048_orchd2 (front .skel), seconds: clip lengths and their OnAttack events. + * Skill_1: Begin 0.1 (First 1.0), End_1 1.5 (shot 0.667), End_2 2.667 (shots 0.667, 1.833); Skill_2: Begin 0.667 (First + * 1.5), three Loop clips 0.833 (shot 0.167), End 1.0 (landing 0.233); Skill_3: Begin 1.5 (First 2.167), End 1.233 (shot 0.1). + */ +export const CLIP = Object.freeze({ + s1Shot: 0.667, s1BurstShot: 1.833, s1End1: 1.5, s1End2: 2.667, + s2Begin: 0.667, s2BeginFirst: 1.5, s2Loop: 0.833, s2LoopShot: 0.167, s2EndShot: 0.233, + s3Begin: 1.5, s3BeginFirst: 2.167, s3EndShot: 0.1, s3End: 1.233, +}); +/** 龙之箭 (PRTS 备注): flight speed (tiles/s), collision radius (tiles), lifetime (s). */ +export const ARROW = Object.freeze({ speed: 10, radius: 0.5, life: 30 }); +/** 1-3 (range_table): 飞翔瞪射's landing area (PRTS 备注). */ +const GRID_1_3 = Object.freeze([[1, 1], [0, 0], [0, 1], [-1, 1]]); +/** 翔虫机动's mark ranges (range_table): x-1 = |dr| + |dc| ≤ 2; x-2 = the 5 × 5 square without its corners. */ +const MARK_RANGE = Object.freeze({ + 'x-1': (dr, dc) => Math.abs(dr) + Math.abs(dc) <= 2, + 'x-2': (dr, dc) => Math.abs(dr) <= 2 && Math.abs(dc) <= 2 && !(Math.abs(dr) === 2 && Math.abs(dc) === 2), +}); +const TAG_BURST = 'orchd2:burst'; +const TAG_ARROW = 'orchd2:arrow'; +const LOCK = 'orchd2:lock'; +const LIFT = 'orchd2:liftoff'; +const WIREBUG = 'talent:orchd2:wirebug'; + +const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; +/** A talent of the record by a blackboard key it carries (index-independent: hidden talents included). */ +const talentWith = (chess, key) => (chess?.talents ?? []).find((t) => t && t.bb && t.bb[key] != null) ?? null; +/** Numbers of a skill text ("发射4支…发射5支", "分别射出3、4、5支"), or `fallback`. */ +function textCounts(desc, re, fallback) { + const m = re.exec(String(desc ?? '')); + if (!m) return fallback; + const out = m[1].split(/[、,,]/).map((s) => Number.parseInt(s, 10)).filter((n) => n > 0); + return out.length ? out : fallback; +} + +/** No normal attack for `s` seconds (the rest of a skill clip). */ +function lock(battle, unit, s) { + if (s > 0) battle.addBuff(unit, { key: LOCK, duration: s, flags: { disarm: true }, tags: ['skill'] }); +} + +export default { + char_1048_orchd2: (bb, chess) => { + const s1 = skillRec(chess, S1), s2 = skillRec(chess, S2), s3 = skillRec(chess, S3); + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + const shots = [...String(s1?.desc ?? '').matchAll(/发射(\d+)支/g)].map((m) => Number(m[1])); + const arrows1 = shots[0] > 0 ? shots[0] : 4, arrows2 = shots[1] > 0 ? shots[1] : 5; + const volleys = textCounts(s2?.desc, /射出([\d、]+)支/, [3, 4, 5]); + const power = talentWith(chess, 'power_attack_count')?.bb ?? {}; + const wire = talentWith(chess, 'atk_duration') ?? { bb: {}, bbStr: {} }; + const respawn = num(talentWith(chess, 'respawn_time')?.bb?.respawn_time); + const normalCut = num(talentWith(chess, 'attack@damage_scale')?.bb?.['attack@damage_scale'], 1); + const markIn = MARK_RANGE[wire.bbStr?.ignore_build_type_target_range] ?? MARK_RANGE['x-1']; + + /** The first cast of this deployment: T1's stacks and the longer first clip. */ + const firstCast = (unit) => { + if (unit.mem.orchdCast) return false; + unit.mem.orchdCast = true; + unit.mem.orchdPower = Math.max(0, Math.floor(num(power.power_attack_count))); + return true; + }; + /** T1: one stack for a round ⇒ its multiplier (1 without stacks). */ + const takePower = (unit) => { + if (!(unit.mem.orchdPower > 0)) return 1; + unit.mem.orchdPower--; + return num(power.power_attack_scale, 1); + }; + /** A skill attack profile: her own (targeting, projectile) with the skill's scale and hit count. */ + const skillProfile = (unit, atkScale, hits, extra = {}) => ({ ...unit.profile, isSkill: true, atkScale, hits, tags: [], ...extra }); + /** Targetable enemies of a facing-RIGHT grid around her (air units too: she can hit them). */ + const inGrid = (battle, unit, grid) => battle.enemiesInKeys(absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0), unit, { canHitFly: true }); + /** The skill still runs this very activation (scheduled steps of a cast). */ + const running = (unit, seq, act) => up(unit) && unit.deploySeq === seq && !!unit.skill?.active && unit.skill.activations === act; + + /** 龙之箭: shot from her tile straight ahead (see the header). */ + const shootArrow = (battle, unit) => { + const mul = takePower(unit); + const atk = unit.s.atk; + const [fr, fc] = dirVec(unit.dir); + const ux = fc, uy = fr; // x = column, y = row + const every = Math.max(0.05, num(b3.dist_interval, 0.25)); + const maxDist = num(b3.max_dist, 99); + const force = num(b3.force, 1), cd = num(b3.knockback_duration, 1); + const phys = atk * num(b3.atk_scale) * mul, arts = atk * num(b3.atk_scale_magic) * mul; + const pushReady = new Map(); + let x = unit.x, y = unit.y, acc = 0, flown = 0, age = 0; + battle.fx('dragonArrow', { x, y, id: unit.id, dx: ux, dy: uy, speed: ARROW.speed }); + const strike = () => { + for (const e of battle.foesInRadius(x, y, ARROW.radius)) { + if (phys > 0) battle.dealDamage(unit, e, { amount: phys, type: 'phys', isSkill: true, tags: ['skill', TAG_ARROW] }); + if (arts > 0 && e.alive) battle.dealDamage(unit, e, { amount: arts, type: 'arts', isSkill: true, tags: ['skill', TAG_ARROW] }); + } + }; + const h = battle.on('tick', ({ dt }) => { + const step = ARROW.speed * dt; + let moved = 0; + while (moved < step - 1e-9) { + const s = Math.min(step - moved, every - acc); + x += ux * s; y += uy * s; moved += s; acc += s; flown += s; + if (acc >= every - 1e-9) { acc = 0; strike(); } + } + // the collision (push) is checked once per frame (备注), each enemy at most once per knockback_duration + for (const e of battle.foesInRadius(x, y, ARROW.radius)) { + if ((pushReady.get(e) ?? -Infinity) > battle.time + 1e-9) continue; + pushReady.set(e, battle.time + cd); + battle.push(e, force, { from: { x, y }, dir: { x: ux, y: uy }, fixed: true }); + } + age += dt; + if (age >= ARROW.life || flown >= maxDist || !battle.grid.inRect(Math.round(y), Math.round(x))) battle.off(h); + }, { owner: unit }); + }; + + return { + trait: { hits: NORMAL_HITS }, + skills: { + [S1]: { + kind: num(s1?.maxChargeTime, 1) > 1 ? 'charges' : 'instant', + attack: { atkScale: num(b1.atk_scale_1, 1), hits: arrows1 }, + onStart({ battle, unit, skill }) { + firstCast(unit); + const burst = skill.charges >= 1; + if (burst) skill.charges -= 1; // "若还有充能则额外消耗1层施展刚连射" + lock(battle, unit, (burst ? CLIP.s1End2 : CLIP.s1End1) - CLIP.s1Shot); + battle.fx('quadShot', { x: unit.x, y: unit.y, id: unit.id }); + if (!burst) return; + const seq = unit.deploySeq; + battle.after(CLIP.s1BurstShot - CLIP.s1Shot, () => { + if (!up(unit) || unit.deploySeq !== seq) return; + const prof = skillProfile(unit, num(b1.atk_scale_2, 1), arrows2, { tags: [TAG_BURST], maxTargets: 1, allInRange: false }); + const targets = acquireTargets(battle, unit, prof).slice(0, 1); + if (!targets.length) { // "找不到目标,将立刻终止技能并返还与1次充能等额的技力" + unit.skill?.addCharge(1); + battle.removeBuff(unit, LOCK); + return; + } + performAttack(battle, unit, prof, targets); + }, { owner: unit }); + }, + }, + [S2]: { + kind: 'duration', + attack: { noAttack: true }, + onStart({ battle, unit, skill }) { + const lead = firstCast(unit) ? CLIP.s2BeginFirst : CLIP.s2Begin; + if (lead > CLIP.s2Begin) skill.extend(lead - CLIP.s2Begin); + battle.addBuff(unit, { key: LIFT, flags: { liftoff: true, blockFly: true }, tags: ['skill'] }); + battle.releaseBlocked(unit); + battle.fx('takeoff', { x: unit.x, y: unit.y, id: unit.id }); + const seq = unit.deploySeq, act = skill.activations; + const grid = s2?.rangeGrid ?? []; + volleys.forEach((n, i) => battle.after(lead + i * CLIP.s2Loop + CLIP.s2LoopShot, () => { + if (!running(unit, seq, act)) return; + const targets = inGrid(battle, unit, grid); + if (targets.length) performAttack(battle, unit, skillProfile(unit, num(b2['attack@atk_scale_loop'], 1), n, { allInRange: true }), targets); + }, { owner: unit })); + battle.after(lead + volleys.length * CLIP.s2Loop + CLIP.s2EndShot, () => { + if (!running(unit, seq, act)) return; + battle.removeBuff(unit, LIFT); + battle.releaseBlocked(unit); + const targets = inGrid(battle, unit, GRID_1_3); + battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1, id: unit.id, skill: 'orchd2:landing' }); + if (targets.length) performAttack(battle, unit, skillProfile(unit, num(b2['attack@atk_scale_end'], 1), 1, { allInRange: true, projectile: 'none', attack: 'melee' }), targets); + }, { owner: unit }); + }, + onEnd({ battle, unit }) { + if (unit.findBuff(LIFT)) { battle.removeBuff(unit, LIFT); battle.releaseBlocked(unit); } + }, + }, + [S3]: { + kind: num(s3?.maxChargeTime, 1) > 1 ? 'charges' : 'instant', + attack: { noAttack: true }, // the clip: the cast waits (pending) with no normal attack until it ends below + onStart({ battle, unit, skill }) { + const lead = (firstCast(unit) ? CLIP.s3BeginFirst : CLIP.s3Begin) + num(b3.wait_duration, 1.5); + const seq = unit.deploySeq, act = skill.activations; + battle.after(lead + CLIP.s3EndShot, () => { if (running(unit, seq, act)) shootArrow(battle, unit); }, { owner: unit }); + battle.after(lead + CLIP.s3End, () => { if (running(unit, seq, act)) skill.end('clip'); }, { owner: unit }); + }, + }, + }, + talents: [ + { install(battle, unit) { // 强击瓶专家: a round's first hit spends one stack; every hit of that round ×scale + const rounds = new Map(); + battle.on('hit', (ctx) => { + const d = ctx.dmg; + if (ctx.source !== unit || !ctx.target || ctx.target.side !== 'enemy' || !d.isAttack || !d.attackId || d.type === 'element') return; + let mul = rounds.get(d.attackId); + if (mul === undefined) { + mul = takePower(unit); + rounds.set(d.attackId, mul); + if (rounds.size > 16) rounds.delete(rounds.keys().next().value); + } + if (mul !== 1) d.amount *= mul; + }, { owner: unit }); + } }, + { install(battle, unit) { // 翔虫机动: shorter redeploy; the mark on her last tile ⇒ ATK +atk for atk_duration s + if (respawn && !unit.mem.orchdRespawn) { + unit.mem.orchdRespawn = true; + unit.base.respawnTime = Math.max(0, unit.base.respawnTime + respawn); + } + const a = num(wire.bb?.atk), dur = num(wire.bb?.atk_duration); + battle.on('death', (ctx) => { + if (ctx.unit === unit) unit.mem.orchdMark = [unit.tileR, unit.tileC]; + }, { owner: unit }); + battle.on('deploy', (ctx) => { + if (ctx.unit !== unit) return; + const mark = unit.mem.orchdMark; + unit.mem.orchdMark = null; // "持续存在直至下次焰狐龙梓兰部署" + if (!mark || !(a > 0) || !(dur > 0) || !markIn(unit.tileR - mark[0], unit.tileC - mark[1])) return; + battle.addBuff(unit, { key: WIREBUG, duration: dur, mods: { atkPct: a }, visible: true, tags: ['talent'] }); + battle.fx('buff', { x: unit.x, y: unit.y, id: unit.id, kind: 'wirebug' }); + }, { owner: unit }); + } }, + ], + install(battle, unit) { + // every deployment: T1 and the first-clip rule start over; S2 "部署后立即释放一次" (free, after the deployment + // is done — a 联防 SP carry is applied first) + battle.on('deploy', (ctx) => { + if (ctx.unit !== unit) return; + unit.mem.orchdCast = false; + unit.mem.orchdPower = 0; + if (unit.skill?.id !== S2) return; + const seq = unit.deploySeq; + battle.after(0, () => { + if (up(unit) && unit.deploySeq === seq && unit.skill && !unit.skill.active) unit.skill.activate('deploy', { free: true }); + }, { owner: unit }); + }, { owner: unit, priority: 10 }); + // 特性备注: every normal-attack damage ×damage_scale after DEF / RES + if (normalCut !== 1) { + battle.on('hit', (ctx) => { + const d = ctx.dmg; + if (ctx.source === unit && d.isAttack && !d.isSkill && d.type !== 'element') d.mul *= normalCut; + }, { owner: unit }); + } + // 刚连射: every arrow stun_prob to stun the target hit + if (unit.skill?.id === S1) { + const p = num(b1.stun_prob), s = num(b1.stun); + battle.on('damaged', (ctx) => { + if (ctx.source !== unit || !ctx.dmg?.tags?.includes(TAG_BURST) || !hasHp(ctx.target) || ctx.target.side !== 'enemy') return; + if (p > 0 && s > 0 && battle.rng.chance(p)) battle.applyStatus(ctx.target, 'stun', { duration: s, source: unit }); + }, { owner: unit }); + } + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/op-shwaz.js b/server/sim/content/kits/ops/op-shwaz.js new file mode 100644 index 00000000..97ea0243 --- /dev/null +++ b/server/sim/content/kits/ops/op-shwaz.js @@ -0,0 +1,163 @@ +// server/sim/content/kits/ops/op-shwaz.js — 黑 (char_340_shwaz) 自选 operator kit: 6★ 重射手 (狙击), an owned-6★ pick of the +// tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules (ARC-X 定制弩配件套装, ARC-Y 老剃刀) at +// every form. Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)"). +// +// Forms (data/backups.json units.char_340_shwaz): normal = E2 Lv1, skills at rank 4, no module; elite = E2 Lv60, rank 7, +// the picked module at stage 1 (tier 5) or 3 (tier 6) — the owner's decision of 2026-10-05. Potential 0 [ASSUMED: no +// account]. Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json) and PRTS 黑 +// (破甲箭头 备注 "攻击力提升效果为提升攻击力倍率"; 战术的终结 "攻击间隔略微增大(+0.4)"; the {{**|80%|下降20%}} marks of the +// DEF cut = a final ×0.8). +// - Trait (重射手) "高精度的近距离射击": the plain closerange profile — ranged physical arrows, 3-6, can hit air units +// (PRTS 分支特性信息 重射手 "可对空"), blocks 1, ground enemies target her (no flag). +// - T1 破甲箭头 "攻击时,20%几率当次攻击的攻击力提升至160%,并使命中目标的防御力下降20%,持续5秒": one roll per attack +// (`prob`); on a proc the attack's damage ×atk_scale (an ATK-scale multiplier, PRTS 备注, so it multiplies the skills' +// own scales) and, once the hit landed (命中: a dodged hit does nothing), the target's DEF ×(1 + def) for +// defdown_duration s — the catalogue `defDown` (final ×0.8, PRTS; 同名 keeps the strongest). The skills raise the chance: +// S1's next attack to S1 talent@prob, S2 / S3's attacks while they run to their talent@prob (every attack a skill makes +// carries dmg.isSkill — S2's empty attack override marks its attacks for that). ARC-Y stage 2+ upgrades it (stage 3: +// 170 %, DEF −25 %) — read from the base talent and the module's NAMED change: ARC-Y's hidden talent part (talentIndex 0, +// isHideTalent, atk_scale 1.05, prefabKey "10") is the trait's 105 % as a talent of its own in the game, which the +// data's merge by talentIndex folds into 破甲箭头 at stage 1 (composed atk_scale 1.05) — reported, not used. +// - T2 交叉火力 "场上存在黑和另外至少一名【狙击】干员时,所有【狙击】干员的攻击力+8%": while she is on the field and another +// 【狙击】 operator is too, every 【狙击】 operator of the field (her included; a partner's in a shared field too [ASSUMED, +// as 推进之王's 万兽之王]) ATK +atk, refreshed every 0.5 s (lapses within 0.6 s when the condition ends); several sources +// keep the strongest. ARC-X stage 2+ "携带黑和另外至少一名【狙击】干员时…+13%": 携带 = in the squad — the player's own +// pieces of the battle, deployed or not — so with another 【狙击】 piece in her player's battle every 【狙击】 operator of +// that player ATK +13 % for the whole battle (persist; a partner's operators are not in her squad [ASSUMED]). +// - Module ARC-X “定制弩配件套装”: "再部署时间减少" (attribute respawn_time −25, in the stats: 70 → 45 s) + HP / ATK. +// - Module ARC-Y “老剃刀” trait "攻击正前方的敌人时攻击力提升至105%且无视其物理闪避" (trait bb atk_scale): her attack damage +// on an enemy whose body is on her straight line ahead ×atk_scale and not dodgeable (canDodge false — her damage is +// physical only). 正前方 = local row 0, column ≥ 0 in her facing — her own tile included, as S3's 3-2 "前方3格" counts +// it [ASSUMED: no source draws the line]. +// - S1 强弩 (AUTO, attack SP 4, data DEFAULT): the next attack ×atk_scale (and the T1 chance above). +// - S2 暮眼锐瞳 (MANUAL, data DEFAULT): ATK +atk for 33 / 36 s, T1 chance talent@prob. +// - S3 战术的终结 (MANUAL): range → 3-2 (her tile + 3 ahead) while it runs, attack interval +base_attack_time s (a flat +// +0.4 on her 1.6 s, PRTS), ATK +atk, T1 chance 100 %. Trigger: the data's SKILL_RANGE on 3-2 — build-data reads "攻击范围 +// 改为前方3格" as a 技能范围 of its own (its ATTACK_RANGE_CHANGE pattern has no "改为"); the strategy table's reading would +// be the basic strategy (DEFAULT) — reported as an open question, the data rule is kept. + +import { num, up, traitBb, moduleOn, skillRec, batMod } from '../shared/tier1.js'; +import { bodyKeys } from '../../../body.js'; +import { toLocal } from '../../../dir.js'; +import { hasHp } from '../../../damage.js'; +import { COLS } from '../../../constants.js'; + +const S1 = 'skchr_shwaz_1'; +const S2 = 'skchr_shwaz_2'; +const S3 = 'skchr_shwaz_3'; +/** 交叉火力's buff (the field aura and the squad version share it: one effect per operator, the strongest). */ +const CROSSFIRE = 'talent:shwaz:crossfire'; +/** 交叉火力's refresh interval (s), as installAura. */ +const AURA_EVERY = 0.5; + +const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; +/** A 【狙击】 operator (summons are never operators). */ +const isSniperOp = (a) => !!a && a.kind === 'op' && a.def?.profession === 'SNIPER'; + +/** The module the record fights with (its active one), or null. */ +function activeModule(chess) { + if (!moduleOn(chess)) return null; + return (chess.modules ?? []).find((m) => m && m.uniEquipId === chess.module.id) ?? null; +} + +/** + * 破甲箭头's blackboard: the base talent (no module) with the module's NAMED changes of talent 0 only — never the hidden + * trait part (see the header). + */ +function pierceBb(chess) { + const base = (chess?.talentsBase ?? chess?.talents ?? []).find((t) => t && t.index === 0)?.bb ?? {}; + const ups = (activeModule(chess)?.talentChanges ?? []).filter((t) => t && t.talentIndex === 0 && !t.hidden && t.name); + return Object.assign({}, base, ...ups.map((t) => t.bb || {})); +} + +/** The record's 交叉火力 (talent 1, the module's change included): its blackboard and whether it reads 携带 (squad). */ +function crossfireOf(chess) { + const t = (chess?.talents ?? []).find((x) => x && x.index === 1) ?? null; + return { atk: num(t?.bb?.atk), squad: /携带/.test(String(t?.desc ?? '')) }; +} + +/** ARC-Y 正前方: the enemy's body touches the unit's straight line ahead (local row 0, local column ≥ 0). */ +function inFrontLine(unit, e) { + for (const k of bodyKeys(e)) { + const [lr, lc] = toLocal(Math.floor(k / COLS) - unit.tileR, (k % COLS) - unit.tileC, unit.dir); + if (lr === 0 && lc >= 0) return true; + } + return false; +} + +export default { + char_340_shwaz: (bb, chess) => { + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + const s3 = skillRec(chess, S3); + const t0 = pierceBb(chess); + const cross = crossfireOf(chess); + const frontScale = num(traitBb(chess).atk_scale, 1); + return { + skills: { + [S1]: { kind: 'instant', attack: { atkScale: num(b1.atk_scale, 1) } }, + // (an empty attack override: the attacks made while it runs carry dmg.isSkill — 破甲箭头's raised chance) + [S2]: { kind: 'duration', mods: { atkPct: num(b2.atk) }, attack: {} }, + [S3]: { + kind: 'duration', + targeting: { rangeGrid: s3?.rangeGrid ?? [[0, 0], [0, 1], [0, 2], [0, 3]] }, + mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess) }, + }, + }, + talents: [ + { install(battle, unit) { // 破甲箭头: one roll per attack; ×atk_scale on a proc, then DEF cut on the target hit + const scale = num(t0.atk_scale, 1), cut = -num(t0.def), dur = num(t0.defdown_duration, 5), base = num(t0.prob); + const sid = unit.skill?.id; + const raised = num(bbOf(chess, sid)['talent@prob'], base); + battle.on('hit', (ctx) => { + const d = ctx.dmg, t = ctx.target; + if (ctx.source !== unit || !t || t.side !== 'enemy' || !d.isAttack || d.isSplash || d.type === 'element') return; + const m = unit.mem; + if (!d.attackId || m.shwazRollId !== d.attackId) { + m.shwazRollId = d.attackId; + const p = d.isSkill ? raised : base; + m.shwazProc = p > 0 && battle.rng.chance(p); + if (m.shwazProc) battle.fx('crit', { x: t.x, y: t.y, id: unit.id }); + } + if (!m.shwazProc) return; + d.amount *= scale; + d.shwazPierce = true; + }, { owner: unit }); + battle.on('damaged', (ctx) => { + if (ctx.source !== unit || !ctx.dmg?.shwazPierce || !(cut > 0) || !hasHp(ctx.target) || ctx.target.side !== 'enemy') return; + battle.applyStatus(ctx.target, 'defDown', { duration: dur, value: cut, source: unit }); + }, { owner: unit }); + } }, + { install(battle, unit) { // 交叉火力: field aura, or (ARC-X stage 2+) the squad's snipers for the whole battle + const v = cross.atk; + if (!(v > 0)) return; + const give = (a, extra) => { + const cur = a.findBuff(CROSSFIRE); + if (cur && cur.source !== unit && (cur.data?.v ?? 0) > v && cur.timeLeft > 0.05) return; + battle.addBuff(a, { key: CROSSFIRE, mods: { atkPct: v }, source: unit, data: { v }, ...extra }); + }; + if (cross.squad) { + const squad = battle.allyUnits.filter((a) => isSniperOp(a) && a.ownerId === unit.ownerId); + if (squad.some((a) => a !== unit)) for (const a of squad) give(a, { persist: true, allowDead: true, tags: ['talent'] }); + return; + } + battle.every(AURA_EVERY, () => { + if (!up(unit)) return; + const snipers = battle.alliesFor(unit).filter(isSniperOp); + if (!snipers.some((a) => a !== unit)) return; + for (const a of snipers) give(a, { duration: AURA_EVERY + 0.1, tags: ['aura'] }); + }, { owner: unit, immediate: true }); + } }, + ], + install(battle, unit) { + // ARC-Y 老剃刀: 攻击正前方的敌人时攻击力提升至105%且无视其物理闪避 + if (!(frontScale > 0) || frontScale === 1) return; + battle.on('hit', (ctx) => { + const d = ctx.dmg, t = ctx.target; + if (ctx.source !== unit || !t || t.side !== 'enemy' || !d.isAttack || d.type === 'element' || !inFrontLine(unit, t)) return; + d.amount *= frontScale; + d.canDodge = false; + }, { owner: unit }); + }, + }; + }, +}; diff --git a/test/content/op_bgsnow.test.js b/test/content/op_bgsnow.test.js new file mode 100644 index 00000000..97d993b6 --- /dev/null +++ b/test/content/op_bgsnow.test.js @@ -0,0 +1,306 @@ +// test/content/op_bgsnow.test.js — the 自选 operator kit of 鸿雪 (char_4055_bgsnow, 6★ 重射手; kit +// server/sim/content/kits/ops/op-bgsnow.js) and of her summon “打字机” (token_10026_bgsnow_subbow), fielded the production +// way (a DIY slot + its `diy` pick, simdata getDiy; the typewriter as the placed hand piece of her player) in every form: +// tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, ARC-Y “打字机色带” or +// ARC-X “仪式感” at stage 1 (tier 5) / 3 (tier 6). Numbers from data/backups.json; the fidelity checklist of kits/README.md. +// Run: node --test test/content/op_bgsnow.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; +import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js'; +import { diyPool, validateDiyPicks } from '../../shared/diy.js'; + +const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8')); +const CHESS = load('chess'); +const BACKUPS = load('backups'); +const SNOW = 'char_4055_bgsnow'; +const TOK = 'token_10026_bgsnow_subbow'; +const FORMS = BACKUPS.units[SNOW].forms; +const TOKREC = BACKUPS.tokens[TOK]; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const Y = 'uniequip_002_bgsnow', X = 'uniequip_003_bgsnow'; +const S1 = 'skchr_bgsnow_1', S2 = 'skchr_bgsnow_2', S3 = 'skchr_bgsnow_3'; +const statusOf = (tier, elite) => (elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'); +const formOf = (tier, elite) => FORMS[statusOf(tier, elite)]; +const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id); +const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null); +/** The typewriter's variant of a form, with the pick's skill / module (getDiyToken). */ +const tokOf = (tier, elite, skill, mod) => { + let v = TOKREC.variants[`${SNOW}@${statusOf(tier, elite)}`]; + if (v.bySkill?.[skill]) v = { ...v, ...v.bySkill[skill] }; + if (mod && v.byModule?.[mod]) v = { ...v, ...v.byModule[mod] }; + return v; +}; +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' }) }; +const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, Y, X].map((m) => [t, true, m]))]; + +/** 鸿雪 as uid 1 at (10, 5) facing RIGHT; `tok` = the typewriter piece's tile (uid 2) or null. */ +function field({ tier = 5, elite = false, mod = null, skill = 0, tok = null, tokFirst = false, seed = 5, others = [] } = {}) { + const op = { uid: 1, diy: { slot: SLOT[tier], charId: SNOW, skillIndex: skill, uniEquipId: mod }, elite, row: 10, col: 5 }; + const piece = tok ? [{ uid: 2, kind: 'token', tokenId: TOK, ownerUid: 1, row: tok[0], col: tok[1] }] : []; + const h = makeBattle({ + defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, + flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'death', 'deploy', 'attack', 'dodge'], captureNoisy: true, + units: tokFirst ? [...piece, op, ...others] : [op, ...piece, ...others], + }); + h.step(); + return { h, u: h.unit(1), t: h.b.allyUnits.find((a) => a.kind === 'token' && a.defId === TOK) ?? null }; +} +const from = (h, src) => h.hooksOf('damaged').filter((c) => c.source === src); +function done(h) { + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); +} +const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`; + +test('鸿雪 in every 自选 form: her kit, the form\'s stats + module attributes, 3-6, ranged physical, hits air, blocks 1, ground-targetable, the data triggers; offered as a pick', () => { + assert.equal(OPERATOR_KITS[SNOW], KITS[SNOW]); + 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], [SNOW, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f)); + assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.respawnTime], + [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), form.stats.respawnTime + (m?.attr.respawnTime ?? 0)], `${label(f)}: stats`); + assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat], [1, 'ranged', true, 'phys', 1.6], `${label(f)}: 重射手`); + assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-6`); + assert.equal(u.skill.rule, form.skills[skill].trigger.rule, `${label(f)}: the data's trigger`); + assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`); + done(h); + } + } + assert.deepEqual(formOf(6, true).skills.map((s) => s.trigger.rule), ['DEFAULT', 'SKILL_RANGE', 'ACTIVE_RANGE']); + assert.deepEqual([modOf(5, Y).attr, modOf(6, Y).attr, modOf(5, X).attr, modOf(6, X).attr], + [{ maxHp: 130, atk: 55 }, { maxHp: 170, atk: 75 }, { atk: 80, def: 10, respawnTime: -25 }, { atk: 120, def: 16, respawnTime: -25 }]); + const data = { chess: CHESS, backups: BACKUPS }; + assert.ok(KITTED_CHARS.includes(SNOW) && [5, 6].every((t) => diyPool(t, { data, kitted: KITTED_CHARS }).includes(SNOW))); + assert.equal(validateDiyPicks({ [SLOT[5]]: { charId: SNOW, skillIndex: 1, uniEquipId: X } }, { data, kitted: KITTED_CHARS }).ok, true); +}); + +test('S1 抑扬格 (AUTO, attack SP 29 / 25, data DEFAULT): after that many attacks ATK +35 % / +50 % until she leaves the field, every attack meanwhile 30 % to deal ×145 % / ×160 %', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S1); + assert.deepEqual([sk.spCost, sk.bb.atk, sk.bb.prob, sk.bb.atk_scale], elite ? [25, 0.5, 0.3, 1.6] : [29, 0.35, 0.3, 1.45], `T${tier}`); + const { h, u } = field({ tier, elite, skill: 0, seed: 12 }); + assert.deepEqual([u.skill.kind, u.skill.rule, u.skill.spType], ['toggle', 'DEFAULT', 'attack'], `T${tier}`); + h.spawn('enemy_dummy', { pos: [11, 6] }); + assert.ok(h.runUntil(() => u.skill.active, 60), `T${tier}: cast`); + const before = from(h, u); + assert.equal(before.length, sk.spCost, `T${tier}: after ${sk.spCost} attacks`); + assert.ok(before.every((c) => Math.abs(c.amount - u.base.atk) < 1e-6), `T${tier}: plain before it`); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`); + h.run(500); + assert.ok(u.skill.active, `T${tier}: 持续时间无限`); + const after = from(h, u).slice(before.length); + const atk = u.base.atk * (1 + sk.bb.atk); + for (const c of after) assert.ok([atk, atk * sk.bb.atk_scale].some((v) => Math.abs(c.amount - v) < 1e-6), `T${tier}: ${c.amount}`); + const r = after.filter((c) => c.amount > atk + 1e-6).length / after.length; + assert.ok(Math.abs(r - sk.bb.prob) < 0.06, `T${tier}: ${r}`); + h.b.retreat(u); + assert.ok(!u.skill.active, `T${tier}: ends when she leaves`); + done(h); + } +}); + +test('S2 点题 (MANUAL, 2 charges, data SKILL_RANGE on 3-1): an enemy 3 tiles ahead (3-1, not 3-6) casts it and is struck at once: atk_scale × ATK as a 普通伤害 attack, then twice as 持续伤害 (not dodgeable); her card range stays 3-6', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S2); + assert.deepEqual([sk.bb.atk_scale, sk.bb.respawn_time, sk.maxChargeTime, sk.spCost, sk.initSp], elite ? [2, 0.6, 2, 10, 6] : [1.8, 0.65, 2, 11, 3], `T${tier}`); + const { h, u } = field({ tier, elite, skill: 1, seed: 3 }); + assert.deepEqual([u.skill.kind, u.skill.rule, u.skill.maxCharges], ['charges', 'SKILL_RANGE', 2], `T${tier}`); + u.skill.gainSp(999); + h.run(1); + assert.equal(u.skill.activations, 0, `T${tier}: nobody in range`); + const e = h.spawn('enemy_dummy', { pos: [10, 8] }); + h.b.addBuff(e, { key: 'test:dodge', mods: { dodgePhys: 1 } }); + assert.ok(h.runUntil(() => u.skill.activations === 1, 1), `T${tier}: cast for the enemy on 3-1`); + const tc = h.b.time; + assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: the card keeps 3-6`); + h.run(0.5); + const atk = h.hooksOf('attack').filter((c) => c.attacker === u); + approx(atk[0].t, tc, `T${tier}: 立即`, 0.04); + assert.equal(atk[0].isSkill, true); + const dodged = h.hooksOf('dodge').filter((c) => c.source === u); + const hits = from(h, u); + assert.equal(dodged.length, 1, `T${tier}: the 普通伤害 hit can be dodged (a dodge enemy)`); + assert.equal(hits.length, 2, `T${tier}: the two 持续伤害 hits cannot`); + for (const c of hits) { + approx(c.amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ×${sk.bb.atk_scale}`); + assert.deepEqual([c.dmg.isAttack, c.dmg.canDodge, c.dmg.tags.includes('dot'), c.type], [false, false, true, 'phys']); + } + assert.equal(u.skill.charges, 1, `T${tier}: one of two charges spent`); + h.b.removeBuff(e, 'test:dodge'); + const n0 = from(h, u).length; + h.run(3.5); + const second = from(h, u).slice(n0).filter((c) => c.dmg.isSkill); + assert.deepEqual(second.map((c) => [c.dmg.isAttack, c.dmg.tags.includes('dot')]), [[true, false], [false, true], [false, true]], `T${tier}: the second charge 3 s later: 普通 then 2 × 持续`); + done(h); + } +}); + +test('S3 锐笔速写 (MANUAL, data ACTIVE_RANGE on 3-1): an enemy only on [0,3] casts it; range 3-1 (and back), interval 1.6 − 0.6 s, ×155 % / 170 % off her line, ×185 % / 215 % on the 正前方3格', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S3); + assert.deepEqual([sk.duration, sk.bb.base_attack_time, sk.bb.atk_scale, sk.bb['bgsnow_s_3[atk_up].atk_scale'], sk.rangeId], elite ? [27, -0.6, 1.7, 2.15, '3-1'] : [26, -0.6, 1.55, 1.85, '3-1'], `T${tier}`); + const { h, u } = field({ tier, elite, skill: 2, seed: 7 }); + assert.deepEqual([u.skill.kind, u.skill.rule], ['duration', 'ACTIVE_RANGE']); + u.skill.gainSp(999); + const far = h.spawn('enemy_dummy', { pos: [10, 8] }); + assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: cast for [0,3] (outside 3-6)`); + assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: 3-1 while it runs`); + approx(u.s.interval, 1.0, `T${tier}: interval`); + const side = h.spawn('enemy_dummy', { pos: [11, 7] }); + h.b.kill(far, null); + const front = h.spawn('enemy_dummy', { pos: [10, 7] }); + h.run(8); + const onFront = from(h, u).filter((c) => c.dmg.isAttack && c.target === front); + h.b.kill(front, null); + h.run(4); + const onSide2 = from(h, u).filter((c) => c.dmg.isAttack && c.target === side); + assert.ok(onFront.length > 3 && onSide2.length > 1, `T${tier}: both struck (${onFront.length} / ${onSide2.length})`); + for (const c of onFront) approx(c.amount, u.base.atk * sk.bb['bgsnow_s_3[atk_up].atk_scale'], `T${tier}: 正前方3格`); + for (const c of onSide2) approx(c.amount, u.base.atk * sk.bb.atk_scale, `T${tier}: off the line`); + u.skill.extend(-999); + h.step(2); + assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: back to 3-6`); + approx(u.s.interval, 1.6, `T${tier}: interval back`); + done(h); + } +}); + +test('ARC-Y 打字机色带 trait: ×105 % and no dodge on her straight line ahead (stages 1 and 3) — 点题\'s 持续伤害 hits too; off the line it dodges; ARC-X / no module nothing', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const y = elite && mod === Y; + const { h, u } = field({ tier, elite, mod, skill: 1, seed: 3 }); + u.skill.charges = 0; u.skill.sp = 0; + h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true } }); + const front = h.spawn('enemy_dummy', { pos: [10, 7] }); + const side = h.spawn('enemy_dummy', { pos: [11, 7] }); + for (const e of [front, side]) h.b.addBuff(e, { key: 'test:dodge', mods: { dodgePhys: 1 } }); + h.run(12); + const onFront = from(h, u).filter((c) => c.target === front); + if (y) { + assert.ok(onFront.length > 4, `${label(f)}: the front enemy is hit`); + for (const c of onFront) approx(c.amount, u.s.atk * 1.05, `${label(f)}: ×1.05`); + // 点题 on the front enemy: all three hits ×1.05 + h.b.removeBuff(u, 'test:noSp'); + u.skill.addCharge(1); + const n0 = from(h, u).length; + h.run(0.5); + const s2 = from(h, u).slice(n0).filter((c) => c.dmg.isSkill); + assert.equal(s2.length, 3, `${label(f)}: 点题's three hits land`); + for (const c of s2) approx(c.amount, u.s.atk * skillOf(tier, true, S2).bb.atk_scale * 1.05, `${label(f)}: 点题 ×1.05`); + h.b.kill(front, null); + h.run(6); + assert.ok(h.hooksOf('dodge').filter((c) => c.source === u && c.target === side).length > 2, `${label(f)}: off the line it dodges`); + } else { + assert.equal(onFront.length, 0, `${label(f)}: every attack dodged`); + } + done(h); + } +}); + +test('“打字机”: her placed piece runs its kit (not the generic one) — the variant\'s stats (ARC-Y stage 3 stronger), untargetable, 3-2, blocks 0, hits air; deploys with the board, leaves after 25 s, back on its tile after 40 s (S2: ×65 % / ×60 %) for 5 DP, never while 鸿雪 is down', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + for (const skill of [0, 1, 2]) { + const v = tokOf(tier, elite, skill, elite ? mod : null); + const { h, t } = field({ tier, elite, mod, skill, tok: [12, 3], tokFirst: skill === 1 }); + assert.ok(t && t.alive && t.deployed, `${label(f)} S${skill + 1}: deployed with the board`); + assert.deepEqual([!!t.kit.generic, t.skill.id, t.base.atk, t.base.maxHp, t.base.respawnTime, t.base.cost], [false, v.skill.skillId, v.stats.atk, v.stats.maxHp, 40, 5], `${label(f)} S${skill + 1}`); + assert.deepEqual([!!t.s.flags.untargetable, t.s.blockCnt, t.profile.canHitFly, t.profile.attack, t.liveRangeGrid], [true, 0, true, 'ranged', [[0, 0], [0, 1], [0, 2], [0, 3]]], `${label(f)} S${skill + 1}: 不会受到攻击`); + done(h); + } + } + assert.deepEqual([tokOf(5, false, 0).stats.atk, tokOf(5, true, 0).stats.atk, tokOf(6, true, 0, Y).stats.atk, tokOf(5, true, 0, Y).stats.atk], [666, 799, 869, 799]); + // life and redeploys (S1: 40 s; S2: ×respawn_time) + for (const [tier, elite, skill, mul] of [[5, false, 0, 1], [5, false, 1, 0.65], [6, true, 1, 0.6]]) { + const { h, u, t } = field({ tier, elite, skill, tok: [12, 3] }); + h.b.players[0].dp = 20; + assert.ok(h.runUntil(() => !t.alive, 26)); + approx(h.b.time, 25, `T${tier} S${skill + 1}: 25 s`, 0.05); + assert.equal(h.hooksOf('death').find((c) => c.unit === t).reason, 'expired'); + assert.ok(h.runUntil(() => t.alive, 60)); + approx(h.b.time, 25 + 40 * mul, `T${tier} S${skill + 1}: back after ${40 * mul} s`, 0.3); + assert.deepEqual([t.tileR, t.tileC, h.b.players[0].dp], [12, 3, 15], `T${tier} S${skill + 1}: on its tile, 5 DP`); + assert.ok(u.alive); + done(h); + } + // 鸿雪 down: the typewriter waits for her + const { h, u, t } = field({ tier: 5, elite: false, skill: 0, tok: [12, 3] }); + h.b.players[0].dp = 0; + h.run(10); + h.b.kill(u, null); + h.run(60); + assert.ok(!t.alive, 'not while 鸿雪 is down'); + h.b.players[0].dp = 25; + assert.ok(h.runUntil(() => u.alive, 30), '鸿雪 back'); + assert.ok(h.runUntil(() => t.alive, 1), 'then the typewriter'); + done(h); +}); + +test('“打字机”\'s skill is the copy of hers, free once per deployment: S1 ATK +35 % / +50 % for good with the 30 % proc, S2 three hits of ×180 % / ×200 % (普通 + 2 × 持续), S3 4-1 + interval −0.6 s + ×185 % / ×215 % for 16 / 17 s', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + for (const skill of [0, 1, 2]) { + const v = tokOf(tier, elite, skill, null); + const tb = v.skill.bb; + const { h, t } = field({ tier, elite, skill, tok: [11, 5], seed: 4 }); + h.spawn('enemy_dummy', { pos: [11, 7] }); + assert.deepEqual([t.skill.rule, t.skill.spCost, t.skill.charges], ['DEFAULT', 0, 1], `T${tier} S${skill + 1}: free, ready`); + assert.ok(h.runUntil(() => t.skill.activations === 1, 1), `T${tier} S${skill + 1}: cast at its first attack`); + if (skill === 0) { + approx(t.s.atk, t.base.atk * (1 + tb.atk), `T${tier}: S1 copy ATK`); + h.run(20); + assert.ok(t.skill.active, `T${tier}: S1 copy stays`); + const hits = from(h, t); + const atk = t.base.atk * (1 + tb.atk); + assert.ok(hits.some((c) => Math.abs(c.amount - atk * tb.atk_scale) < 1e-6) && hits.some((c) => Math.abs(c.amount - atk) < 1e-6), `T${tier}: S1 copy procs ×${tb.atk_scale}`); + } else if (skill === 1) { + h.run(0.5); + const s2 = from(h, t).filter((c) => c.dmg.isSkill); + assert.deepEqual(s2.map((c) => [c.dmg.isAttack, c.dmg.tags.includes('dot')]), [[true, false], [false, true], [false, true]], `T${tier}: S2 copy`); + for (const c of s2) approx(c.amount, t.base.atk * tb.atk_scale, `T${tier}: S2 copy ×${tb.atk_scale}`); + } else { + assert.deepEqual(t.liveRangeGrid, [[0, 0], [0, 1], [0, 2], [0, 3], [0, 4]], `T${tier}: S3 copy 4-1`); + approx(t.s.interval, 1.0, `T${tier}: S3 copy interval`); + approx(t.skill.timeLeft, v.skill.duration, `T${tier}: ${v.skill.duration} s`, 0.05); + h.run(3); + for (const c of from(h, t).filter((x) => x.dmg.isSkill)) approx(c.amount, t.base.atk * tb['attack@atk_scale'], `T${tier}: S3 copy ×${tb['attack@atk_scale']}`); + } + h.run(24); + assert.equal(t.skill.activations, 1, `T${tier} S${skill + 1}: once per deployment`); + done(h); + } + } +}); + +test('弱点速记: every damage of the typewriter cuts the target\'s DEF 18 % for 4 s, 23 % on one of the 4 tiles next to 鸿雪 (not diagonal); ARC-X stage 3: 28 % / 33 % and the 8 tiles; 鸿雪 down ⇒ the base cut', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const x3 = tier === 6 && elite && mod === X; + const tal = tokOf(tier, elite, 2, elite ? mod : null).talents.find((x) => x && x.bb.duration); + const c1 = -tal.bb['bgsnow_token[def_down]_1.def'], c2 = -tal.bb['bgsnow_token[def_down]_2.def']; + assert.deepEqual([c1, c2, tal.bb.duration], x3 ? [0.28, 0.33, 4] : [0.18, 0.23, 4], label(f)); + for (const [tile, near] of [[[11, 5], true], [[11, 4], x3], [[12, 3], false]]) { + const { h, u, t } = field({ tier, elite, mod, skill: 0, tok: tile, seed: 2 }); + h.spawn('enemy_dummy', { pos: [tile[0], tile[1] + 2] }); + h.run(4); + const cuts = h.hooksOf('statusApplied').filter((c) => c.source === t && c.status === 'defDown'); + assert.ok(cuts.length >= 2, `${label(f)} ${tile}: cuts`); + for (const c of cuts) assert.deepEqual([c.value, c.duration], [near ? c2 : c1, 4], `${label(f)} ${tile}: ${near ? 'near' : 'apart'}`); + if (near) { + h.b.kill(u, null); + h.run(3); + const after = h.hooksOf('statusApplied').filter((c) => c.source === t && c.status === 'defDown').slice(cuts.length); + assert.ok(after.length && after.every((c) => c.value === c1), `${label(f)}: 鸿雪 down ⇒ ${c1}`); + } + done(h); + } + } +}); diff --git a/test/content/op_orchd2.test.js b/test/content/op_orchd2.test.js new file mode 100644 index 00000000..296bcabe --- /dev/null +++ b/test/content/op_orchd2.test.js @@ -0,0 +1,340 @@ +// test/content/op_orchd2.test.js — the 自选 operator kit of 焰狐龙梓兰 (char_1048_orchd2, 6★ 重射手; kit +// server/sim/content/kits/ops/op-orchd2.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in +// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module or ARC-X +// “梓兰特制箭靶” at stage 1 (tier 5) / 3 (tier 6). Numbers from data/backups.json; the skill clips' moments from the kit's +// CLIP (the official skeleton); the fidelity checklist of kits/README.md item by item. +// Run: node --test test/content/op_orchd2.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; +import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js'; +import { diyPool } from '../../shared/diy.js'; +import { CLIP, ARROW, NORMAL_HITS } from '../../server/sim/content/kits/ops/op-orchd2.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 ORCHD = 'char_1048_orchd2'; +const FORMS = BACKUPS.units[ORCHD].forms; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const X = 'uniequip_002_orchd2'; +const S1 = 'skchr_orchd2_1', S2 = 'skchr_orchd2_2', S3 = 'skchr_orchd2_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); +/** The form's talents with the module's changes (by talentIndex — no hidden trait part here). */ +const talentsOf = (tier, elite, mod) => { + const out = formOf(tier, elite).talents.map((t) => ({ ...t, bb: { ...t.bb }, bbStr: { ...t.bbStr } })); + for (const ch of modOf(tier, mod)?.talentChanges ?? []) { + const t = out.find((x) => x.index === ch.talentIndex); + Object.assign(t.bb, ch.bb); Object.assign(t.bbStr, ch.bbStr ?? {}); + } + return out; +}; +const tBb = (tier, elite, mod, key) => talentsOf(tier, elite, mod).find((t) => t.bb[key] != null).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_def: dummy('enemy_def', { def: 100 }), + enemy_heavy: dummy('enemy_heavy', { mass: 5 }), enemy_gun: dummy('enemy_gun', { atk: 50, range: 2.5, bat: 1 }), +}; +const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, X].map((m) => [t, true, m]))]; +const POWER = 'power_attack_count'; + +function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], seed = 5 } = {}) { + const h = makeBattle({ + defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, + flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'deploy', 'attack'], captureNoisy: true, + units: [{ uid: 1, diy: { slot: SLOT[tier], charId: ORCHD, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others], + }); + h.step(); + return { h, u: h.unit(1) }; +} +const mine = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u); +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'}`; +/** Damage events grouped by attack (attackId). */ +const rounds = (list) => { const m = new Map(); for (const c of list) { const k = c.dmg.attackId; if (!m.has(k)) m.set(k, []); m.get(k).push(c); } return [...m.values()]; }; + +test('焰狐龙梓兰 in every 自选 form: her kit, the form\'s stats + ARC-X, redeploy 70 − 25 (ARC-X) − 15 / 18 (翔虫机动) s, 3-6, a 三连击 sniper that hits air, the data triggers', () => { + assert.equal(OPERATOR_KITS[ORCHD], KITS[ORCHD]); + 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], [ORCHD, 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`); + const rt = tBb(tier, elite, mod, 'respawn_time').respawn_time; + assert.equal(u.base.respawnTime, form.stats.respawnTime + (m?.attr.respawnTime ?? 0) + rt, `${label(f)}: redeploy time`); + assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.profile.hits, u.base.bat], [1, 'ranged', true, 'phys', NORMAL_HITS, 1.6], `${label(f)}: 重射手, 三连击`); + assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-6`); + assert.equal(u.skill.rule, form.skills[skill].trigger.rule, `${label(f)}: the data's trigger`); + // S2 is cast at every deployment: airborne right after the first step + assert.equal(!!u.s.flags.liftoff, skill === 1, `${label(f)}: ground-targetable unless airborne`); + done(h); + } + } + assert.deepEqual(formOf(6, true).skills.map((s) => s.trigger.rule), ['DEFAULT', 'SKILL_RANGE', 'DEFAULT']); + assert.deepEqual([55, 30, 27], [[5, false, null], [5, true, X], [6, true, X]].map(([t, e, m]) => formOf(t, e).stats.respawnTime + (modOf(t, m)?.attr.respawnTime ?? 0) + tBb(t, e, m, 'respawn_time').respawn_time)); + assert.ok(KITTED_CHARS.includes(ORCHD) && [5, 6].every((t) => diyPool(t, { data: { chess: CHESS, backups: BACKUPS }, kitted: KITTED_CHARS }).includes(ORCHD)), 'offered at tiers 5 / 6'); +}); + +test('trait (PRTS 特性备注): a normal attack is 3 hits of 100 % ATK, each ×0.333 after DEF; skill attacks are not cut; air units are hit', () => { + for (const [tier, elite, mod] of [[5, false, null], [6, true, X]]) { + const cut = tBb(tier, elite, mod, 'attack@damage_scale')['attack@damage_scale']; + assert.equal(cut, 0.333); + const { h, u } = field({ tier, elite, mod, skill: 2 }); + u.skill.sp = 0; + const e = h.spawn('enemy_def', { pos: [10, 7] }); + h.run(20); + const r = rounds(mine(h, u).filter((c) => c.dmg.isAttack && c.target === e)); + assert.ok(r.length >= 5, `T${tier}: ${r.length} attacks`); + for (const g of r) { + assert.equal(g.length, NORMAL_HITS, `T${tier}: 三连击`); + for (const c of g) { approx(c.amount, (u.s.atk - 100) * cut, `T${tier}: (ATK − DEF) × 33.3 %`); assert.deepEqual([c.type, c.dmg.isSkill], ['phys', false]); } + } + h.b.kill(e, null); + const f = h.spawn('enemy_fly', { pos: [11, 6] }); + h.run(5); + assert.ok(mine(h, u).some((c) => c.target === f), `T${tier}: the flyer is attacked`); + done(h); + } +}); + +test('S1 刚射 (MANUAL, charges 2 / 3, data DEFAULT): one charge — 4 arrows of 100 % / 120 %; a charge left — it is spent for 刚连射 1.167 s later, 5 arrows of 140 % / 160 %; no normal attack until the clip ends; no target for 刚连射 ⇒ the charge comes back; each 刚连射 arrow 20 % to stun 2 s', () => { + for (const [tier, elite, mod] of [[5, false, null], [6, true, X]]) { + const sk = skillOf(tier, elite, S1); + const pw = tBb(tier, elite, mod, POWER).power_attack_scale; + assert.deepEqual([sk.bb.atk_scale_1, sk.bb.atk_scale_2, sk.bb.stun_prob, sk.bb.stun, sk.maxChargeTime, sk.spCost, sk.initSp], + elite ? [1.2, 1.6, 0.2, 2, 3, 5, 10] : [1, 1.4, 0.2, 2, 2, 7, 7], `T${tier}`); + // (a) one charge: the 4 arrows only, the next normal attack once the clip and the attack interval are over + { + const { h, u } = field({ tier, elite, mod, skill: 0, seed: 17 }); + assert.deepEqual([u.skill.kind, u.skill.rule, u.skill.maxCharges, u.skill.charges], ['charges', 'DEFAULT', sk.maxChargeTime, Math.floor(sk.initSp / sk.spCost)], `T${tier}`); + u.skill.charges = 1; + h.spawn('enemy_dummy', { pos: [10, 7] }); + assert.ok(h.runUntil(() => u.skill.activations === 1, 2)); + const t0 = h.b.time; + assert.equal(u.skill.charges, 0, `T${tier}: one charge spent`); + h.run(3); + const first = rounds(mine(h, u).filter((c) => c.dmg.isSkill)); + assert.deepEqual(first.map((g) => g.length), [4], `T${tier}: one round of 4 arrows`); + for (const c of first[0]) approx(c.amount, u.s.atk * sk.bb.atk_scale_1 * pw, `T${tier}: ×atk_scale_1 (×强击瓶专家)`); + const plain = h.hooksOf('attack').filter((c) => c.attacker === u && !c.isSkill && c.t > t0 + 1e-6); + assert.ok(plain.length && plain[0].t >= t0 + Math.max(CLIP.s1End1 - CLIP.s1Shot, 1.6) - 0.04, `T${tier}: the next normal attack after the clip`); + done(h); + } + // (b) full charges: 刚连射 too, 1.167 s after the first shot, both charges spent, no normal attack inside the 2.0 s clip + { + const { h, u } = field({ tier, elite, mod, skill: 0, seed: 17 }); + u.skill.gainSp(999); + const ch0 = u.skill.charges; + h.spawn('enemy_dummy', { pos: [10, 7] }); + assert.ok(h.runUntil(() => u.skill.activations === 1, 2)); + const t1 = h.b.time; + assert.equal(u.skill.charges, ch0 - 2, `T${tier}: two charges for 刚射 + 刚连射`); + h.run(3); + const shots = h.hooksOf('attack').filter((c) => c.attacker === u && c.isSkill); + approx(shots[1].t - shots[0].t, CLIP.s1BurstShot - CLIP.s1Shot, `T${tier}: 1.167 s apart`, 0.04); + const sec = rounds(mine(h, u).filter((c) => c.dmg.isSkill)); + assert.deepEqual(sec.map((g) => g.length), [4, 5], `T${tier}: 4 then 5 arrows`); + for (const c of sec[1]) approx(c.amount, u.s.atk * sk.bb.atk_scale_2 * pw, `T${tier}: ×atk_scale_2`); + const plain = h.hooksOf('attack').filter((c) => c.attacker === u && !c.isSkill); + assert.ok(plain.length && plain[0].t >= t1 + CLIP.s1End2 - CLIP.s1Shot - 0.04, `T${tier}: no normal attack inside the 2.0 s clip (${plain[0]?.t} vs ${t1})`); + done(h); + } + // (c) its target gone before 刚连射: nothing more, the charge back + { + const { h, u } = field({ tier, elite, mod, skill: 0, seed: 17 }); + u.skill.gainSp(999); + const ch0 = u.skill.charges; + const e = h.spawn('enemy_dummy', { pos: [10, 7] }); + assert.ok(h.runUntil(() => u.skill.activations === 1, 2)); + h.b.kill(e, null); + h.run(1.5); + assert.equal(u.skill.charges, ch0 - 1, `T${tier}: the second charge is back`); + assert.equal(h.hooksOf('attack').filter((c) => c.attacker === u && c.isSkill).length, 1, `T${tier}: no 刚连射`); + assert.equal(u.findBuff('orchd2:lock'), null, `T${tier}: the skill stopped (no clip left)`); + done(h); + } + } + // (d) the stun: every 刚连射 arrow rolls stun_prob for `stun` s + const { h, u } = field({ tier: 6, elite: true, mod: X, skill: 0, seed: 9 }); + const e = h.spawn('enemy_dummy', { pos: [10, 7] }); + for (let i = 0; i < 60; i++) { + u.skill.gainSp(999); + const n = u.skill.activations; + h.runUntil(() => u.skill.activations > n, 5); + h.run(3.5); + } + const arrows = mine(h, u).filter((c) => c.dmg.tags?.includes('orchd2:burst')); + const stuns = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'stun' && c.target === e); + assert.equal(arrows.length, 60 * 5); + assert.ok(Math.abs(stuns.length / arrows.length - 0.2) < 0.06, `stun rate ${stuns.length} / ${arrows.length}`); + for (const c of stuns) assert.equal(c.duration, 2); + done(h); +}); + +test('S2 飞翔瞪射: cast free at every deployment (SP untouched); 起飞 (no ground enemy blocks / hits her) and no normal attack; volleys of 3 / 4 / 5 arrows ×135 % / 150 % at every target of the 4-4 (air too), landing ×255 % / 270 % on the 1-3; the first cast 0.833 s longer; then the data\'s SKILL_RANGE', () => { + for (const [tier, elite, mod] of [[5, false, null], [6, true, X]]) { + const sk = skillOf(tier, elite, S2); + const pw = tBb(tier, elite, mod, POWER).power_attack_scale; + assert.deepEqual([sk.bb['attack@atk_scale_loop'], sk.bb['attack@atk_scale_end'], sk.duration, sk.maxChargeTime, sk.initSp, sk.spCost, sk.rangeId], + elite ? [1.5, 2.7, 4.2, 3, 14, 20, '4-4'] : [1.35, 2.55, 4.2, 2, 11, 20, '4-4'], `T${tier}`); + const h = makeBattle({ defs: { enemies: ENEMIES }, timeLimit: 300, autoFinish: false, seed: 6, flags: { dpPerSec: 0, dpMax: 999 }, + hooks: ['damaged', 'skillStart', 'skillEnd', 'attack'], captureNoisy: true, + enemies: [{ key: 'enemy_dummy', pos: [10, 7] }, { key: 'enemy_fly', pos: [11, 9] }, { key: 'enemy_dummy', pos: [9, 6] }, { key: 'enemy_gun', pos: [11, 5] }], + units: [{ uid: 1, diy: { slot: SLOT[tier], charId: ORCHD, skillIndex: 1, uniEquipId: mod }, elite, row: 10, col: 5 }] }); + h.step(); + const u = h.unit(1); + const [front, fly, near, gun] = h.b.enemies; + assert.deepEqual([u.skill.active, h.hooksOf('skillStart')[0]?.reason, u.skill.charges, u.skill.sp], [true, 'deploy', 0, sk.initSp], `T${tier}: the free deploy cast`); + assert.ok(u.s.flags.liftoff && u.s.flags.blockFly, `T${tier}: airborne`); + const lead = CLIP.s2BeginFirst; // the first cast of the deployment + const landAt = lead + 3 * CLIP.s2Loop + CLIP.s2EndShot; + h.run(landAt + 0.3); + const shots = h.hooksOf('attack').filter((c) => c.attacker === u); + assert.equal(shots.length, 4, `T${tier}: three volleys and the landing — no normal attack`); + shots.slice(0, 3).forEach((c, i) => approx(c.t, lead + i * CLIP.s2Loop + CLIP.s2LoopShot, `T${tier}: volley ${i + 1}`, 0.04)); + approx(shots[3].t, landAt, `T${tier}: landing`, 0.04); + const dmg = mine(h, u); + const vol = rounds(dmg.filter((c) => c.dmg.attackId !== dmg.at(-1).dmg.attackId)); + assert.deepEqual(vol.map((g) => g.length), [6, 8, 10], `T${tier}: 3 / 4 / 5 arrows at each of the two 4-4 targets`); + for (const g of vol) { + assert.deepEqual([...new Set(g.map((c) => c.target))].sort((a, b) => a.id - b.id), [front, fly].sort((a, b) => a.id - b.id), `T${tier}: the 4-4 targets (the flyer too)`); + for (const c of g) approx(c.amount, u.base.atk * sk.bb['attack@atk_scale_loop'] * pw, `T${tier}: ×loop`); + } + const land = dmg.filter((c) => c.dmg.attackId === dmg.at(-1).dmg.attackId); + assert.deepEqual(land.map((c) => c.target), [near], `T${tier}: the landing hits the 1-3 only`); + approx(land[0].amount, u.base.atk * sk.bb['attack@atk_scale_end'] * pw, `T${tier}: ×end`); + assert.ok(!u.s.flags.liftoff, `T${tier}: landed`); + assert.equal(h.hooksOf('damaged').filter((c) => c.source === gun && c.target === u && c.t < landAt - 1e-6).length, 0, `T${tier}: the ground shooter could not hit her airborne`); + assert.ok(h.runUntil(() => !u.skill.active, 2)); + approx(h.hooksOf('skillEnd')[0].t, sk.duration + (CLIP.s2BeginFirst - CLIP.s2Begin), `T${tier}: 4.2 + 0.833 s`, 0.05); + h.run(3); + assert.ok(h.hooksOf('damaged').some((c) => c.source === gun && c.target === u), `T${tier}: on the ground she is hit`); + // later casts: SKILL_RANGE on 4-4 (the 1-3 neighbour alone does not cast it), the 0.667 s start + for (const e of [front, fly]) h.b.kill(e, null); + u.skill.charges = 1; + u.skill.sp = 0; + h.run(4); + assert.equal(u.skill.activations, 1, `T${tier}: nobody on the 4-4, no cast`); + h.spawn('enemy_dummy', { pos: [9, 8] }); + assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: an enemy on the 4-4 casts it`); + const tc = h.b.time; + assert.equal(h.hooksOf('skillStart').at(-1).reason, 'SKILL_RANGE'); + h.runUntil(() => !u.skill.active, 6); + approx(h.b.time - tc, sk.duration, `T${tier}: 4.2 s`, 0.06); + const v2 = h.hooksOf('attack').filter((c) => c.attacker === u && c.t >= tc - 1e-6); + approx(v2[0].t - tc, CLIP.s2Begin + CLIP.s2LoopShot, `T${tier}: first volley 0.833 s in`, 0.04); + done(h); + } +}); + +test('S3 龙之箭: the arrow leaves 3.1 s after the cast (3.767 s for the first cast), flies straight ahead out of the field, hits every enemy on its way (air too, beyond her range) for ×240 % / 300 % physical then ×30 % / 40 % arts each 0.25 tile, pushes the light ones (中等力度) — not the off-line ones; no normal attack until its clip ends; her range stays', () => { + for (const [tier, elite, mod] of [[5, false, null], [6, true, X]]) { + const sk = skillOf(tier, elite, S3); + const pw = tBb(tier, elite, mod, POWER).power_attack_scale; + assert.deepEqual([sk.bb.atk_scale, sk.bb.atk_scale_magic, sk.bb.wait_duration, sk.bb.dist_interval, sk.bb.force, sk.bb.knockback_duration, sk.maxChargeTime], + elite ? [3, 0.4, 1.5, 0.25, 1, 1, 2] : [2.4, 0.3, 1.5, 0.25, 1, 1, 1], `T${tier}`); + const { h, u } = field({ tier, elite, mod, skill: 2, seed: 2, col: 2 }); + assert.deepEqual([u.skill.kind, u.skill.rule], [elite ? 'charges' : 'instant', 'DEFAULT']); + const light = h.spawn('enemy_dummy', { pos: [10, 3] }); + const heavy = h.spawn('enemy_heavy', { pos: [10, 6] }); // outside her 3-6 range + const fly = h.spawn('enemy_fly', { pos: [10, 8] }); + const off = h.spawn('enemy_dummy', { pos: [11, 6] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 2)); + const tc = h.b.time, n0 = mine(h, u).length; + assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: range unchanged`); + const shot = tc + CLIP.s3BeginFirst + sk.bb.wait_duration + CLIP.s3EndShot; + h.run(shot - tc + 2); + const arrow = mine(h, u).slice(n0).filter((c) => c.dmg.tags?.includes('orchd2:arrow')); + approx(Math.min(...arrow.map((c) => c.t)), shot + 1 / 30, `T${tier}: shot at +3.767 s (first cast; the tile next to her is reached the tick after)`, 0.04); + for (const e of [light, heavy, fly]) { + const on = arrow.filter((c) => c.target === e); + const ph = on.filter((c) => c.type === 'phys'), ar = on.filter((c) => c.type === 'arts'); + assert.ok(ph.length >= 4 && ph.length === ar.length, `T${tier}: ${e.defId}: ${ph.length} strikes`); + for (const c of ph) approx(c.amount, u.base.atk * sk.bb.atk_scale * pw, `T${tier}: physical`); + for (const c of ar) approx(c.amount, u.base.atk * sk.bb.atk_scale_magic * pw, `T${tier}: arts`); + } + assert.equal(arrow.filter((c) => c.target === off).length, 0, `T${tier}: off the line, untouched`); + approx(light.x, 3 + 2.14, `T${tier}: the light one pushed 2.14 tiles (中力 − weight 0)`, 0.02); + assert.equal(heavy.x, 6, `T${tier}: weight 5 does not move`); + const plain = h.hooksOf('attack').filter((c) => c.attacker === u && c.t > tc + 1e-6); + assert.ok(plain.every((c) => c.t >= tc + CLIP.s3BeginFirst + sk.bb.wait_duration + CLIP.s3End - 0.04), `T${tier}: no normal attack inside the clip`); + assert.ok(!u.skill.active, `T${tier}: the clip is over`); + // a later cast: 3.1 s + for (const e of [heavy, fly, off]) h.b.kill(e, null); + h.b.relocate?.(light, 10, 3); + light.x = 3; light.y = 10; + u.skill.gainSp(999); + const a0 = u.skill.activations; + assert.ok(h.runUntil(() => u.skill.activations > a0, 5)); + const t2 = h.b.time, n1 = mine(h, u).length; + h.run(4.5); + const later = mine(h, u).slice(n1).filter((c) => c.dmg.tags?.includes('orchd2:arrow')); + approx(later[0].t - t2, CLIP.s3Begin + sk.bb.wait_duration + CLIP.s3EndShot + 1 / 30, `T${tier}: shot at +3.1 s`, 0.04); + done(h); + } + assert.deepEqual([ARROW.speed, ARROW.radius], [10, 0.5]); +}); + +test('T1 强击瓶专家: from the first cast of a deployment the next 50 attack rounds ×115 % (a 三连击 is one round), none before, none after; a redeploy starts over', () => { + for (const [tier, elite, mod] of [[5, false, null], [6, true, X]]) { + const pb = tBb(tier, elite, mod, POWER); + assert.deepEqual([pb.power_attack_count, pb.power_attack_scale], [50, 1.15]); + const { h, u } = field({ tier, elite, mod, skill: 0, seed: 1 }); + const cut = tBb(tier, elite, mod, 'attack@damage_scale')['attack@damage_scale']; + u.skill.charges = 0; u.skill.sp = 0; + h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true } }); + const e = h.spawn('enemy_dummy', { pos: [10, 7] }); + h.run(8); + const before = rounds(mine(h, u)); + assert.ok(before.length >= 4 && before.every((g) => g.every((c) => Math.abs(c.amount - u.s.atk * cut) < 1e-6)), `T${tier}: no bonus before a cast`); + u.skill.addCharge(1); + assert.ok(h.runUntil(() => u.skill.activations === 1, 3)); + const n0 = mine(h, u).length; + h.run(1.6 * 60); + const after = rounds(mine(h, u).slice(n0)); + const boosted = after.filter((g) => g[0].amount > (g[0].dmg.isSkill ? u.s.atk * skillOf(tier, elite, S1).bb.atk_scale_1 : u.s.atk * cut) * 1.01); + assert.equal(boosted.length, 50, `T${tier}: 50 rounds`); + assert.ok(after.indexOf(boosted.at(-1)) === 49 && after.length > 51, `T${tier}: the first 50, then none`); + assert.equal(u.mem.orchdPower, 0); + h.b.retreat(u); + h.b.redeploy(u); + assert.equal(u.mem.orchdPower, 0, `T${tier}: a redeploy starts over`); + assert.ok(e.alive); + done(h); + } +}); + +test('T2 翔虫机动: knocked out, she comes back after 70 − 15 s (ARC-X: −25 −15 / −18) on her tile with ATK +15 % (ARC-X stage 3: +20 %) for 30 s; never at the first deployment', () => { + for (const f of [[5, false, null], [5, true, X], [6, true, X]]) { + const [tier, elite, mod] = f; + const wb = tBb(tier, elite, mod, 'atk_duration'); + assert.deepEqual([wb.atk, wb.atk_duration], [tier === 6 && mod === X ? 0.2 : 0.15, 30], label(f)); + const { h, u } = field({ tier, elite, mod, skill: 2 }); + assert.equal(u.findBuff('talent:orchd2:wirebug'), null, `${label(f)}: not at the first deployment`); + h.b.players[0].dp = 999; + const at = [u.tileR, u.tileC]; + h.b.kill(u, null); + approx(u.respawnAt - u.deathAt, u.base.respawnTime, label(f)); + assert.ok(h.runUntil(() => u.alive, u.base.respawnTime + 1), `${label(f)}: back`); + approx(h.b.time - u.deathAt, u.base.respawnTime, `${label(f)}: after ${u.base.respawnTime} s`, 0.05); + assert.deepEqual([u.tileR, u.tileC], at, `${label(f)}: on her tile`); + const b = u.findBuff('talent:orchd2:wirebug'); + assert.deepEqual(b?.mods, { atkPct: wb.atk }, label(f)); + approx(b.timeLeft, wb.atk_duration, label(f), 0.01); + approx(u.s.atk, u.base.atk * (1 + wb.atk), label(f)); + h.run(31); + assert.equal(u.findBuff('talent:orchd2:wirebug'), null, `${label(f)}: gone after 30 s`); + done(h); + } +}); diff --git a/test/content/op_shwaz.test.js b/test/content/op_shwaz.test.js new file mode 100644 index 00000000..5081044a --- /dev/null +++ b/test/content/op_shwaz.test.js @@ -0,0 +1,271 @@ +// test/content/op_shwaz.test.js — the 自选 operator kit of 黑 (char_340_shwaz, 6★ 重射手; kit +// server/sim/content/kits/ops/op-shwaz.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, ARC-X +// “定制弩配件套装” or ARC-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_shwaz.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; +import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js'; +import { diyPool, validateDiyPicks } from '../../shared/diy.js'; + +const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8')); +const CHESS = load('chess'); +const BACKUPS = load('backups'); +const SHWAZ = 'char_340_shwaz'; +const FORMS = BACKUPS.units[SHWAZ].forms; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const X = 'uniequip_002_shwaz', Y = 'uniequip_003_shwaz'; +const S1 = 'skchr_shwaz_1', S2 = 'skchr_shwaz_2', S3 = 'skchr_shwaz_3'; +const PROVE = 'chess_char_1_07_a', YAK = 'chess_char_1_02_a'; +/** 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); +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' }) }; +/** Every 自选 form: [tier, elite, module]. */ +const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, X, Y].map((m) => [t, true, m]))]; +/** 破甲箭头 of a form: the base talent, upgraded by ARC-Y's NAMED change only (stage 3: 170 % / −25 %). */ +const pierce = (tier, elite, mod) => ({ ...formOf(tier, elite).talents[0].bb, ...(modOf(tier, mod)?.talentChanges ?? []).filter((t) => t.talentIndex === 0 && t.name).reduce((o, t) => ({ ...o, ...t.bb }), {}) }); + +/** 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, players = null } = {}) { + const h = makeBattle({ + defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, + flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'dodge'], captureNoisy: true, + ...(players ? { players } : { units: [{ uid: 1, diy: { slot: SLOT[tier], charId: SHWAZ, 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'}`; + +test('黑 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 3-6, ranged physical, hits air, blocks 1, ground-targetable, data triggers', () => { + assert.equal(OPERATOR_KITS[SHWAZ], KITS[SHWAZ]); + 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], [SHWAZ, 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`); + // ARC-X “定制弩配件套装”: "再部署时间减少" — the attribute respawn_time −25 (70 → 45 s) + assert.equal(u.base.respawnTime, form.stats.respawnTime + (m?.attr.respawnTime ?? 0), `${label(f)}: redeploy time`); + assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat], [1, 'ranged', true, 'phys', 1.6], `${label(f)}: 重射手`); + assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-6`); + assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`); + assert.equal(u.skill.rule, form.skills[skill].trigger.rule, `${label(f)}: the data's trigger`); + done(h); + } + } + // the data: S1 / S2 DEFAULT, S3 SKILL_RANGE on 3-2 (the open question of the kit header); E2 Lv1 1393 / 646 / 164, Lv60 1685 / 775 / 204 + assert.deepEqual(formOf(6, true).skills.map((s) => s.trigger.rule), ['DEFAULT', 'DEFAULT', 'SKILL_RANGE']); + assert.deepEqual([FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.atk], [646, 775]); + assert.deepEqual([modOf(5, X).attr, modOf(6, X).attr, modOf(5, Y).attr, modOf(6, Y).attr], + [{ maxHp: 130, atk: 55, respawnTime: -25 }, { maxHp: 170, atk: 75, respawnTime: -25 }, { atk: 80, def: 10 }, { atk: 120, def: 16 }]); +}); + +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(SHWAZ)); + for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(SHWAZ), `tier ${t}`); + assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: SHWAZ, skillIndex: 2, uniEquipId: Y } }, { data, kitted: KITTED_CHARS }), + { ok: true, picks: { [SLOT[6]]: { charId: SHWAZ, skillIndex: 2, uniEquipId: Y } } }); +}); + +test('S1 强弩 (AUTO, attack SP 4, data DEFAULT): every 5th attack ×175 % / 190 % ATK, 破甲箭头 then at 60 % / 70 % (20 % otherwise); air units are hit', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S1), t0 = pierce(tier, elite, null); + const { h, u } = field({ tier, elite, skill: 0, seed: 21 }); + assert.deepEqual([u.skill.kind, u.skill.rule, u.skill.spCost, u.skill.spType, sk.bb.atk_scale, sk.bb['talent@prob']], + ['instant', 'DEFAULT', 4, 'attack', elite ? 1.9 : 1.75, elite ? 0.7 : 0.6], `T${tier}`); + h.spawn('enemy_fly', { pos: [10, 6] }); // a flyer in front: she hits air units + h.run(800); + const hits = atkHits(h, u); + const sk1 = hits.filter((c) => c.dmg.isSkill), plain = hits.filter((c) => !c.dmg.isSkill); + assert.ok(Math.abs(sk1.length * 5 - hits.length) <= 5, `T${tier}: one S1 attack in 5 (${sk1.length} / ${hits.length})`); + const base = u.s.atk; + for (const c of sk1) assert.ok([base * sk.bb.atk_scale, base * sk.bb.atk_scale * t0.atk_scale].some((v) => Math.abs(c.amount - v) < 1e-6), `T${tier}: S1 hit ${c.amount}`); + for (const c of plain) assert.ok([base, base * t0.atk_scale].some((v) => Math.abs(c.amount - v) < 1e-6), `T${tier}: plain hit ${c.amount}`); + const rate = (list, scale) => list.filter((c) => c.amount > base * scale + 1e-6).length / list.length; + const r1 = rate(sk1, sk.bb.atk_scale), r0 = rate(plain, 1); + assert.ok(Math.abs(r1 - sk.bb['talent@prob']) < 0.12, `T${tier}: 破甲箭头 on S1 ${r1}`); + assert.ok(Math.abs(r0 - t0.prob) < 0.07, `T${tier}: 破甲箭头 otherwise ${r0}`); + assert.ok(hits.every((c) => c.type === 'phys' && c.target.motion === 'FLY'), `T${tier}: physical, on the flyer`); + done(h); + } +}); + +test('S2 暮眼锐瞳 (MANUAL, data DEFAULT): ATK +85 % / +100 % for 33 / 36 s, 破甲箭头 at 40 % / 45 % meanwhile; no cast without an enemy', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S2), t0 = pierce(tier, elite, null); + const { h, u } = field({ tier, elite, skill: 1, seed: 8 }); + assert.deepEqual([u.skill.kind, u.skill.rule, sk.duration, sk.bb.atk, sk.bb['talent@prob']], ['duration', 'DEFAULT', elite ? 36 : 33, elite ? 1 : 0.85, elite ? 0.45 : 0.4], `T${tier}`); + u.skill.gainSp(999); + h.run(2); + assert.equal(u.skill.activations, 0, `T${tier}: no enemy, no cast`); + h.spawn('enemy_dummy', { pos: [10, 7] }); + assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast with an enemy in range`); + approx(u.skill.timeLeft, sk.duration, `T${tier}: ${sk.duration} s`, 0.01); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK +${sk.bb.atk * 100} %`); + const n0 = atkHits(h, u).length; + u.skill.extend(600); + h.runUntil(() => !u.skill.active, 700); + const during = atkHits(h, u).slice(n0); + const atk = u.base.atk * (1 + sk.bb.atk); + assert.ok(during.length > 300 && during.every((c) => c.dmg.isSkill), `T${tier}: ${during.length} attacks, marked`); + const r = during.filter((c) => c.amount > atk + 1e-6).length / during.length; + assert.ok(Math.abs(r - sk.bb['talent@prob']) < 0.07, `T${tier}: 破甲箭头 ${r}`); + for (const c of during.slice(0, 20)) assert.ok([atk, atk * t0.atk_scale].some((v) => Math.abs(c.amount - v) < 1e-6), `T${tier}: ${c.amount}`); + approx(u.s.atk, u.base.atk, `T${tier}: ATK back`); + done(h); + } +}); + +test('S3 战术的终结: range 3-2 while it runs (and back), interval 1.6 + 0.4 s, ATK +120 % / +140 %, 破甲箭头 on every attack; the data\'s SKILL_RANGE: an enemy on 3-2 casts it, one only on the rest of 3-6 does not', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S3), t0 = pierce(tier, elite, null); + assert.deepEqual(sk.rangeGrid, [[0, 0], [0, 1], [0, 2], [0, 3]]); + const { h, u } = field({ tier, elite, skill: 2, seed: 4 }); + assert.deepEqual([u.skill.kind, u.skill.rule, sk.duration, sk.bb.atk, sk.bb.base_attack_time, sk.bb['talent@prob']], + ['duration', 'SKILL_RANGE', elite ? 21 : 18, elite ? 1.4 : 1.2, 0.4, 1], `T${tier}`); + u.skill.gainSp(999); + const side = h.spawn('enemy_dummy', { pos: [11, 6] }); // in 3-6, not in 3-2 + h.run(3); + assert.equal(u.skill.activations, 0, `T${tier}: the side tile does not cast it`); + assert.ok(atkHits(h, u).some((c) => c.target === side), `T${tier}: she attacks it (3-6)`); + const far = h.spawn('enemy_dummy', { pos: [10, 8] }); // 3 tiles ahead: 3-2, outside 3-6 + assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: cast for the enemy on 3-2`); + assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: range 3-2 while it runs`); + approx(u.s.interval, 2.0, `T${tier}: interval +0.4 s`); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`); + const n0 = atkHits(h, u).length; + h.runUntil(() => !u.skill.active, 30); + const during = atkHits(h, u).slice(n0); + assert.ok(during.length >= 8 && during.every((c) => c.target === far), `T${tier}: only the 3-2 enemy (${during.length})`); + for (const c of during) approx(c.amount, u.base.atk * (1 + sk.bb.atk) * t0.atk_scale, `T${tier}: every attack procs`); + const cuts = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'defDown' && c.target === far); + assert.ok(cuts.length >= during.length - 1, `T${tier}: DEF cut each time`); + assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: back to 3-6`); + approx(u.s.interval, 1.6, `T${tier}: interval back`); + done(h); + } +}); + +test('T1 破甲箭头: a proc ×160 % (ARC-Y stage 3: 170 %) and, once it hit, DEF ×0.8 (×0.75) for 5 s (defDown); ARC-Y stage 1 keeps 160 % (its hidden trait part is not 破甲箭头); a dodged attack cuts nothing', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const t0 = pierce(tier, elite, mod); + assert.deepEqual([t0.atk_scale, t0.def, t0.prob, t0.defdown_duration], tier === 6 && mod === Y ? [1.7, -0.25, 0.2, 5] : [1.6, -0.2, 0.2, 5], label(f)); + const { h, u } = field({ tier, elite, mod, skill: 2, seed: 13 }); + const e = h.spawn('enemy_dummy', { pos: [11, 6] }); // off her front line: no ARC-Y bonus + h.run(120); + const hits = atkHits(h, u).filter((c) => c.target === e); + const procs = hits.filter((c) => c.amount > u.s.atk + 1e-6); + assert.ok(procs.length > 5 && procs.length < hits.length * 0.4, `${label(f)}: ${procs.length} / ${hits.length}`); + for (const c of procs) approx(c.amount, u.s.atk * t0.atk_scale, `${label(f)}: ×${t0.atk_scale}`); + const cuts = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'defDown'); + assert.equal(cuts.length, procs.length, `${label(f)}: one DEF cut per proc`); + for (const c of cuts) assert.deepEqual([c.value, c.duration], [-t0.def, t0.defdown_duration], label(f)); + // a dodged attack: no hit, no cut + const d = h.spawn('enemy_dummy', { pos: [9, 6] }); + h.b.addBuff(d, { key: 'test:dodge', mods: { dodgePhys: 1 } }); + h.b.kill(e, null); + const c0 = cuts.length; + h.run(60); + assert.ok(h.hooksOf('dodge').filter((c) => c.source === u).length > 20, `${label(f)}: dodged`); + assert.equal(h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'defDown').length, c0, `${label(f)}: nothing cut`); + done(h); + } +}); + +test('T2 交叉火力: with another 【狙击】 operator on the field every sniper (her too, a partner\'s too) ATK +8 % — not 角峰, not alone, not once she leaves; ARC-X stage 3 "携带": +13 % on her player\'s snipers for the whole battle', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const squad = tier === 6 && mod === X; + const v = squad ? 0.13 : 0.08; + const t1 = { ...formOf(tier, elite).talents[1].bb, ...(modOf(tier, mod)?.talentChanges ?? []).filter((t) => t.talentIndex === 1).reduce((o, t) => ({ ...o, ...t.bb }), {}) }; + assert.equal(t1.atk, v, label(f)); + // alone: nothing + const solo = field({ tier, elite, mod, skill: 0 }); + solo.h.run(1); + assert.equal(solo.u.findBuff('talent:shwaz:crossfire'), null, `${label(f)}: alone`); + done(solo.h); + const { h, u } = field({ tier, elite, mod, skill: 0, others: [{ uid: 2, chessId: PROVE, row: 12, col: 3 }, { uid: 3, chessId: YAK, row: 9, col: 3 }] }); + h.run(1); + const prove = h.unit(2), yak = h.unit(3); + for (const a of [u, prove]) assert.deepEqual(a.findBuff('talent:shwaz:crossfire')?.mods, { atkPct: v }, `${label(f)}: ${a.def.name}`); + assert.equal(yak.findBuff('talent:shwaz:crossfire'), null, `${label(f)}: 角峰 (重装)`); + approx(u.s.atk, u.base.atk * (1 + v), `${label(f)}: her ATK`); + h.b.retreat(prove); + h.run(1); + assert.equal(!!u.findBuff('talent:shwaz:crossfire'), squad, `${label(f)}: 普罗旺斯 off the field (携带: still in the squad)`); + h.b.redeploy(prove); + h.run(1); + h.b.retreat(u); + h.run(1); + assert.equal(!!prove.findBuff('talent:shwaz:crossfire'), squad, `${label(f)}: 黑 off the field`); + done(h); + } + // a partner's sniper on a shared field: the field version counts it and buffs it; the squad version does not + const pair = (mod, tier) => field({ players: [ + { playerId: 'p1', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, kind: 'chess', chessId: tier === 6 ? 'chess_char_6_diy1_b' : 'chess_char_5_diy1_b', diy: { charId: SHWAZ, skillIndex: 0, uniEquipId: mod }, row: 10, col: 5 }], bonds: {}, bandId: null, playerEffects: [] }, + { playerId: 'p2', seat: 1, side: 'L', colOffset: 0, units: [{ uid: 1, kind: 'chess', chessId: PROVE, row: 12, col: 3 }], bonds: {}, bandId: null, playerEffects: [] }, + ] }); + for (const [mod, tier, want] of [[null, 6, 0.08], [X, 6, null]]) { + const { h } = pair(mod, tier); + h.run(1); + const mine = h.b.allyUnits.find((a) => a.ownerId === 'p1'), theirs = h.b.allyUnits.find((a) => a.ownerId === 'p2'); + assert.equal(mine.findBuff('talent:shwaz:crossfire')?.mods.atkPct ?? null, want, `${mod ?? 'none'}: 黑`); + assert.equal(theirs.findBuff('talent:shwaz:crossfire')?.mods.atkPct ?? null, want, `${mod ?? 'none'}: the partner's 普罗旺斯`); + done(h); + } +}); + +test('ARC-Y 老剃刀 trait: an enemy on her straight line ahead takes ×105 % and cannot dodge (stages 1 and 3); off the line it dodges; ARC-X / no module change nothing; facing UP turns the line', () => { + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + const y = elite && mod === Y; + if (y) assert.deepEqual(formOf(tier, true).modules.find((m) => m.uniEquipId === Y).traitOverride.bb, { atk_scale: 1.05 }, label(f)); + const { h, u } = field({ tier, elite, mod, skill: 0, seed: 3 }); + u.skill.sp = 0; u.skill.charges = 0; + const front = h.spawn('enemy_dummy', { pos: [10, 7] }); + const side = h.spawn('enemy_dummy', { pos: [11, 7] }); + for (const e of [front, side]) h.b.addBuff(e, { key: 'test:dodge', mods: { dodgePhys: 1 } }); + h.run(40); + const onFront = atkHits(h, u).filter((c) => c.target === front); + const dodged = h.hooksOf('dodge').filter((c) => c.source === u); + if (y) { + assert.ok(onFront.length > 10, `${label(f)}: the front enemy is hit (${onFront.length})`); + for (const c of onFront.filter((x) => !x.dmg.isSkill)) assert.ok([u.s.atk * 1.05, u.s.atk * 1.05 * pierce(tier, elite, mod).atk_scale].some((v) => Math.abs(c.amount - v) < 1e-6), `${label(f)}: ×1.05 (${c.amount})`); + h.b.kill(front, null); + h.run(10); + assert.ok(h.hooksOf('dodge').filter((c) => c.source === u && c.target === side).length > 3, `${label(f)}: off the line it dodges`); + } else { + assert.equal(onFront.length, 0, `${label(f)}: no bonus — every attack dodged`); + assert.ok(dodged.length > 10, label(f)); + } + done(h); + } + // facing UP: the line is the column above her + const h = makeBattle({ defs: { enemies: ENEMIES }, timeLimit: 60, autoFinish: false, seed: 2, flags: { dpPerSec: 0 }, hooks: ['damaged'], captureNoisy: true, + units: [{ uid: 1, diy: { slot: 6, charId: SHWAZ, skillIndex: 0, uniEquipId: Y }, elite: true, row: 10, col: 5, dir: 'UP' }] }); + h.step(); + const u = h.unit(1); + const e = h.spawn('enemy_dummy', { pos: [12, 5] }); + h.run(20); + const plain = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && !c.dmg.isSkill); + assert.ok(plain.length > 5); + for (const c of plain) assert.ok([1.05, 1.05 * pierce(6, true, Y).atk_scale].some((k) => Math.abs(c.amount - u.base.atk * k) < 1e-6), `UP: two tiles above her is her line (${c.amount})`); + done(h); +}); diff --git a/test/golden/README.md b/test/golden/README.md index 6e070f89..e50a443c 100644 --- a/test/golden/README.md +++ b/test/golden/README.md @@ -19,7 +19,7 @@ through. | `fields` | 22 battles: every Final Assault / Hidden Core leader on a pair and a solo template (shared pool, 200 s cap) and the 联防 field with 1 and 2 helpers on its own map (carried HP / SP, a knocked-out operator, two leakers' enemies) | | `matches` | 17 matches to the end in virtual time: 16 bot-only (solo 标准 / 险境 / 绝境 / 终极 × 2 seeds, co-op 2 / 3 / 4, one server-run combat match, two runs boosted to the Hidden Core) and one co-op match whose human seat (AI 托管, offline) does not own 7 NORMAL chess — they fight as their 补位 stand-ins (its digest lists them per round, `standIns`) | | `standins` | 10 battles: every NORMAL chess record (normal and elite, 110) fielded as its 补位 stand-in (`standIn: true`: the stand-in's body, backup skill / module and kit — all 17 stand-ins, every skill a chess names for them), 12 per battle by strength band, laid out by the stand-in's position on a real stage, against the round's real wave three times over plus 8 ground enemy kinds (melee stand-ins always meet an enemy), an item each, bonds from the board | -| `diy` | 6 battles of 自选 pieces (a DIY slot with its `diy` pick, `shared/diy.js`): every owned 6★ with an operator kit (`kits/index.js OPERATOR_KITS` — 推进之王 so far) in each form of tiers 5 and 6 (normal; elite with no module and with each module) under each skill, then every prototype pick with a kit at its locked selection, normal and elite, 12 per battle against the round's real wave; a new operator kit adds its scenarios | +| `diy` | 12 battles of 自选 pieces (a DIY slot with its `diy` pick, `shared/diy.js`): every owned 6★ with an operator kit (`kits/index.js OPERATOR_KITS` — 推进之王, 黑, 焰狐龙梓兰 and 鸿雪 so far) in each form of tiers 5 and 6 (normal; elite with no module and with each module) under each skill, then every prototype pick with a kit at its locked selection, normal and elite, 12 per battle against the round's real wave; a new operator kit adds its scenarios | Battles go through the production BattleSpec path (`server/sim/spec.js`, as browsers and the server's headless fields run them); matches construct `Match` directly with a `VirtualScheduler`. Every option is explicit, so a change to a @@ -32,7 +32,7 @@ to 2 decimals. `npm test` runs the fast subset (the scenarios marked `"fast": true`: every chess record with its default loadout, every stage and every non-leader enemy kind, every bond at its top tier, six fields, six matches, the five stand-in -battles of the normal records, the first 自选 battle of the operator kits and of the prototypes — 61 of the 150). `GOLDEN_FULL=1` checks everything. +battles of the normal records, the first 自选 battle of the operator kits and of the prototypes — 61 of the 156). `GOLDEN_FULL=1` checks everything. ## Workflow diff --git a/test/golden/diy.json b/test/golden/diy.json index d842b158..6ee7c792 100644 --- a/test/golden/diy.json +++ b/test/golden/diy.json @@ -90,130 +90,130 @@ "snaps": "d7b739d1 f5e170c4 bc463f9c 6ebd80ed 93f8e1ae ea1947a7 9541a28f e8dbac5b b0b3c196 14ae4a64 46640828 01c55ea9 0f261678 f447848b 0aad3542 db5850a3 631cc5ca 455c1dbc 68ad7134 24c71ebb 34a3ee93 37b05130 7a58a87e" }, "diy-003": { - "about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 5_diy1_a=csnipe 5_diy2_b=csnipe 5_diy1_a=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo", - "fast": true, + "about": "5_diy1_a=shwaz/S1 5_diy2_a=shwaz/S2 5_diy1_a=shwaz/S3 5_diy2_b=shwaz/S1 5_diy1_b=shwaz/S2 5_diy2_b=shwaz/S3 5_diy1_b=shwaz/S1/002_shwaz 5_diy2_b=shwaz/S2/002_shwaz 5_diy1_b=shwaz/S3/002_shwaz 5_diy2_b=shwaz/S1/003_shwaz 5_diy1_b=shwaz/S2/003_shwaz 5_diy2_b=shwaz/S3/003_shwaz", + "fast": false, "kind": "normal", "stageId": "act1autochess_m03", "modeId": "mode_single_normal", "round": 12, "seed": 3865246633, "waveId": "act1autochess_h05", - "time": 196.5, - "ticks": 5895, + "time": 187.43, + "ticks": 5623, "reason": "cleared", - "killed": 8, + "killed": 21, "total": 93, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 0, + "rngDraws": 666, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":8,"total":93,"perfect":false,"leaks":85,"leaksUncounted":0,"coins":0,"dmg":306215,"bossDmg":0,"heal":22436,"deaths":11,"layerGains":{}}}, + "players": {"p1":{"killed":21,"total":93,"perfect":false,"leaks":72,"leaksUncounted":0,"coins":0,"dmg":671339,"bossDmg":0,"heal":0,"deaths":12,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,114,0,690,6384,0,13,0], - ["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,98,1,7246,10191,0,56,1], - ["p1",3,"op","chess_char_5_diy1_a",2,null,2564,1,116,1,16020,8139,0,28,1], - ["p1",4,"op","chess_char_5_diy2_b",2,"none",0,0,74,3,34176,12589,0,37,2], - ["p1",5,"op","chess_char_5_diy1_a",2,null,871,1,8,1,22261,400,0,129,3], - ["p1",6,"op","chess_char_5_diy2_b",2,"none",1510,1,12,0,32187,200,0,108,3], - ["p1",7,"op","chess_char_5_diy1_a",2,null,1133,1,14,0,30588,200,0,77,2], - ["p1",8,"op","chess_char_5_diy2_b",2,"none",1160,1,4,1,71148,200,0,154,3], - ["p1",9,"op","chess_char_5_diy1_a",2,null,1218,1,6,0,0,600,18186,48,3], - ["p1",10,"op","chess_char_5_diy2_b",2,"none",1374,1,115,0,0,8077,4251,12,2], - ["p1",11,"op","chess_char_5_diy1_a",2,null,932,1,13,1,40672,2000,0,123,3], - ["p1",12,"op","chess_char_5_diy2_b",2,"none",1131,1,15,0,51226,800,0,132,3], + ["p1",1,"op","chess_char_5_diy1_a",0,null,593,1,6,1,45541,800,0,66,13], + ["p1",2,"op","chess_char_5_diy2_a",1,null,268,1,11,1,53456,1125,0,69,2], + ["p1",3,"op","chess_char_5_diy1_a",2,null,468,1,14,0,36190,925,0,63,0], + ["p1",4,"op","chess_char_5_diy2_b",0,"none",760,1,4,4,102430,925,0,106,21], + ["p1",5,"op","chess_char_5_diy1_b",1,"none",0,0,5,4,95519,1685,0,69,2], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",0,0,80,0,30978,3370,0,37,2], + ["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_shwaz",123.06,1,12,4,89648,1692,0,88,17], + ["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_shwaz",615,1,15,3,152691,1200,0,110,3], + ["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_shwaz",1815,1,117,1,27678,5445,0,19,1], + ["p1",10,"op","chess_char_5_diy2_b",0,"uniequip_003_shwaz",1685,1,110,1,8987,3370,0,10,1], + ["p1",11,"op","chess_char_5_diy1_b",1,"uniequip_003_shwaz",1685,1,112,1,10406,3370,0,12,0], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_003_shwaz",0,0,77,1,17815,3370,0,21,1], ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null] ], - "enemies": {"enemy_1267_nhpbr_2":[36,8,28,17650,131613],"enemy_1270_nhstlk_2":[27,0,27,15235,63417],"enemy_9006_actoxi":[30,0,30,16896,111185]}, - "hooks": [1,116,5895,1089,1089,1215,1215,60,19,104,26,24,0,28385,254,28,93,85,1,19,0,0,0], - "events": {"atk":1089,"deploy":20,"die":19,"dmg":1215,"fx":8,"heal":60,"leak":85,"skill":50,"spawn":108,"status":504}, - "fx": {"zone":8}, - "snaps": "b1b2dda9 6ac577d6 84811e80 eb6305ea 52a34c95 c2fd4731 45525f0b dbcc3ddb ddfcbc11 0874e859 206cd671 4c83f44b f4f5331a 194da7d5 1fe13dbf 6b7331b4 4cf19629 7bae5e13 5b7078fc 65a8a1f5" + "enemies": {"enemy_1267_nhpbr_2":[36,15,21,10329,223304],"enemy_1270_nhstlk_2":[27,2,25,10394,179967],"enemy_9006_actoxi":[30,4,26,4960,268068]}, + "hooks": [1,117,5623,708,708,997,997,0,33,105,63,62,0,12475,187,17,93,72,1,33,0,0,0], + "events": {"atk":708,"deploy":21,"die":33,"dmg":997,"fx":221,"leak":72,"skill":125,"spawn":108,"status":157}, + "fx": {"crit":202,"zone":19}, + "snaps": "ab432541 10d870b4 03dda609 8c408032 53172b41 7450209f 557878c6 cd8a7a01 5fcc3a7e ffa61f1e 1d10cc54 c7efcb48 ddf8d6ed a1c11a82 ec6e12fc afd5e081 56ef6663 fb12f8c7 cce38529" }, "diy-004": { - "about": "5_diy1_a=acfend 5_diy2_b=acfend 5_diy1_a=acpion 5_diy2_b=acpion 5_diy1_a=acguad 5_diy2_b=acguad 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc", + "about": "6_diy1_a=shwaz/S1 6_diy2_a=shwaz/S2 6_diy1_a=shwaz/S3 6_diy2_b=shwaz/S1 6_diy1_b=shwaz/S2 6_diy2_b=shwaz/S3 6_diy1_b=shwaz/S1/002_shwaz 6_diy2_b=shwaz/S2/002_shwaz 6_diy1_b=shwaz/S3/002_shwaz 6_diy2_b=shwaz/S1/003_shwaz 6_diy1_b=shwaz/S2/003_shwaz 6_diy2_b=shwaz/S3/003_shwaz", "fast": false, "kind": "normal", "stageId": "act1autochess_m04", "modeId": "mode_multi_abyss", - "round": 12, + "round": 13, "seed": 1210847140, - "waveId": "act1autochess_h05", - "time": 224.77, - "ticks": 6743, + "waveId": "act1autochess_h06", + "time": 211.87, + "ticks": 6356, "reason": "cleared", - "killed": 21, - "total": 117, + "killed": 0, + "total": 111, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 350, + "rngDraws": 541, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":21,"total":117,"perfect":false,"leaks":96,"leaksUncounted":0,"coins":0,"dmg":374057,"bossDmg":0,"heal":25487,"deaths":23,"layerGains":{}}}, + "players": {"p1":{"killed":0,"total":111,"perfect":false,"leaks":111,"leaksUncounted":0,"coins":0,"dmg":541105,"bossDmg":0,"heal":0,"deaths":17,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,116,0,0,5208,0,0,0], - ["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",100.37,1,143,0,0,6642,0,0,0], - ["p1",3,"op","chess_char_5_diy1_a",2,null,1060.28,1,145,0,4912,3527,0,0,1], - ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,98,5,85077,5533,0,0,3], - ["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,104,0,0,8356,0,0,0], - ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,100,0,0,20296,0,0,0], - ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,132,0,16051,2544,0,16,1], - ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",0,0,135,5,91286,3174,0,37,1], - ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,77,0,6653,2744,0,3,0], - ["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",0,0,7,11,170077,1749,0,21,1], - ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,144,0,0,5333,10172,24,1], - ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",0,0,94,0,0,8375,15315,21,1], + ["p1",1,"op","chess_char_6_diy1_a",0,null,1393,1,4,0,90678,0,0,119,23], + ["p1",2,"op","chess_char_6_diy2_a",1,null,1393,1,5,0,116021,0,0,124,3], + ["p1",3,"op","chess_char_6_diy1_a",2,null,1393,1,15,0,123247,0,0,114,4], + ["p1",4,"op","chess_char_6_diy2_b",0,"none",1685,1,137,0,24574,3370,0,26,5], + ["p1",5,"op","chess_char_6_diy1_b",1,"none",0,0,76,0,35576,3370,0,33,1], + ["p1",6,"op","chess_char_6_diy2_b",2,"none",0,0,125,0,26512,5055,0,29,0], + ["p1",7,"op","chess_char_6_diy1_b",0,"uniequip_002_shwaz",1855,1,135,0,12706,5565,0,12,1], + ["p1",8,"op","chess_char_6_diy2_b",1,"uniequip_002_shwaz",1855,1,118,0,51370,3710,0,37,1], + ["p1",9,"op","chess_char_6_diy1_b",2,"uniequip_002_shwaz",0,0,67,0,21957,3710,0,13,1], + ["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_003_shwaz",0,0,9,0,13564,1685,0,12,2], + ["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_003_shwaz",0,0,11,0,10868,1685,0,10,0], + ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_003_shwaz",0,0,13,0,14031,1685,0,12,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":[51,16,35,0,83756],"enemy_1005_yokai_2":[54,5,49,24107,111017],"enemy_1005_yokai_3":[12,0,12,42273,179284]}, - "hooks": [1,145,6743,501,501,975,936,86,44,140,9,9,0,14181,0,0,117,96,1,44,0,0,39], - "events": {"atk":594,"deploy":25,"die":44,"dmg":936,"fx":87,"heal":78,"leak":96,"skill":18,"spawn":132,"status":8}, - "fx": {"aoe":1,"dodge":39,"dp":9,"slash":32,"spGain":6}, - "snaps": "6b632c98 e087bd9f f5b9da13 f7038fad bb4d7811 5178d072 0c0de684 872f1892 81773c41 3d296a6a 66f1329a 3a99c8b1 aa011a13 319c8ec5 26231cc7 f808b9be 73dd7977 75857859 817f18b6 d1cdd533 cd927f27 64106a51 89f62784" + "enemies": {"enemy_1010_demon":[54,0,54,16580,311889],"enemy_1320_wdrrl_2":[18,0,18,0,61034],"enemy_1422_lrsldr_2":[39,0,39,9190,257992]}, + "hooks": [1,137,6356,579,579,1921,1921,0,17,128,42,42,0,13087,167,16,111,111,1,17,0,0,0], + "events": {"atk":579,"deploy":23,"die":17,"dmg":1921,"fx":167,"leak":111,"skill":84,"spawn":126,"status":188}, + "fx": {"crit":167}, + "snaps": "070638af 89d4ae9f 63469448 0a388046 3bfe8a59 bbc25451 89815067 d03ae37b 6dea06e2 a9c7fcf8 f97d541b e392c2aa 321550ed 5d91d302 15d98ce6 becf0c8e 696b68cd f250af65 fbd80cc2 23fb161e f3f25187 47efcb02" }, "diy-005": { - "about": "6_diy1_a=acfend 6_diy2_b=acfend 5_diy1_a=sharp2 5_diy2_b=sharp2 6_diy1_a=acpion 6_diy2_b=acpion 5_diy1_a=acspec 5_diy2_b=acspec 6_diy1_a=acguad 6_diy2_b=acguad 5_diy1_a=acsupo 5_diy2_b=acsupo", + "about": "5_diy1_a=orchd2/S1 5_diy2_a=orchd2/S2 5_diy1_a=orchd2/S3 5_diy2_b=orchd2/S1 5_diy1_b=orchd2/S2 5_diy2_b=orchd2/S3 5_diy1_b=orchd2/S1/002_orchd2 5_diy2_b=orchd2/S2/002_orchd2 5_diy1_b=orchd2/S3/002_orchd2 6_diy1_a=orchd2/S1 6_diy2_a=orchd2/S2 6_diy1_a=orchd2/S3", "fast": false, "kind": "normal", "stageId": "act1autochess_m05", "modeId": "mode_multi_funny", - "round": 13, + "round": 12, "seed": 823748262, - "waveId": "act1autochess_h06", - "time": 230, - "ticks": 6900, - "reason": "timeout", - "killed": 63, - "total": 123, + "waveId": "act1autochess_h05", + "time": 205.1, + "ticks": 6153, + "reason": "cleared", + "killed": 39, + "total": 60, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 117, + "rngDraws": 25, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":63,"total":123,"perfect":false,"leaks":60,"leaksUncounted":0,"coins":0,"dmg":274732,"bossDmg":0,"heal":0,"deaths":20,"layerGains":{}}}, + "players": {"p1":{"killed":39,"total":60,"perfect":false,"leaks":21,"leaksUncounted":0,"coins":0,"dmg":612070,"bossDmg":0,"heal":0,"deaths":11,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_6_diy1_a",2,null,1834,1,132,5,19446,3374,0,80,2], - ["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",2142.36,1,141,5,29488,4720,0,101,2], - ["p1",3,"op","chess_char_5_diy1_a",0,null,1865,1,154,3,27639,3730,0,42,1], - ["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",2374,1,152,11,43106,4748,0,39,1], - ["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,135,3,18986,4587,0,18,1], - ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",1808.52,1,143,18,92036,3903,0,40,4], - ["p1",7,"op","chess_char_5_diy1_a",2,null,117.2,1,130,12,39744,6807,0,115,0], - ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",1448,1,1,0,0,0,0,0,0], - ["p1",9,"op","chess_char_6_diy1_a",2,null,2638,1,3,0,0,0,0,0,0], - ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",3531,1,5,0,0,0,0,0,0], - ["p1",11,"op","chess_char_5_diy1_a",2,null,1160,1,153,0,1743,2320,0,7,0], - ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",1120.4,1,145,0,2545,3170,0,7,1] + ["p1",1,"op","chess_char_5_diy1_a",0,null,1358,1,10,0,6533,0,0,68,16], + ["p1",2,"op","chess_char_5_diy2_a",1,null,1358,1,11,0,14057,0,0,68,7], + ["p1",3,"op","chess_char_5_diy1_a",2,null,1358,1,1,1,9167,0,0,77,6], + ["p1",4,"op","chess_char_5_diy2_b",0,"none",1643,1,3,0,16918,0,0,104,36], + ["p1",5,"op","chess_char_5_diy1_b",1,"none",1643,1,5,5,138677,0,0,127,9], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",1643,1,7,1,37314,0,0,95,8], + ["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_orchd2",1643,1,9,0,0,0,0,0,0], + ["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_orchd2",1643,1,12,0,49869,0,0,120,8], + ["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_orchd2",235,1,80,28,278358,4694,0,33,7], + ["p1",10,"op","chess_char_6_diy1_a",0,null,0,0,78,1,2403,4074,0,31,8], + ["p1",11,"op","chess_char_6_diy2_a",1,null,0,0,75,1,40303,4074,0,31,5], + ["p1",12,"op","chess_char_6_diy1_a",2,null,0,0,70,2,18472,4074,0,22,1] ], - "enemies": {"enemy_1019_jshoot_2":[57,37,20,9953,112024],"enemy_1299_ymkilr_2":[27,6,20,25821,123649],"enemy_1422_lrsldr_2":[39,20,19,1584,123193]}, - "hooks": [1,154,6900,835,835,3335,3375,0,83,142,12,15,0,16427,38,83,123,59,1,83,0,0,0], - "events": {"atk":851,"deploy":31,"die":83,"dmg":3367,"fx":150,"leak":59,"skill":22,"spawn":135,"status":47}, - "fx": {"aoe":83,"displace":3,"dp":26,"slash":38}, - "snaps": "aa4b712f 51a2360b 59868653 c815c7eb 7dd47d06 3f3f03f4 89e883c3 60e1e7f6 2355716a 5d734980 75cde05f c0dd657c 4e5bd257 0f8a040d b1aafe05 c04cbb36 e6d37890 8457998a a19143e5 d709bc2b 4283543b 0d9edf54 6ef9963d" + "enemies": {"enemy_1045_hammer_2":[12,4,8,14399,163896],"enemy_1061_zomshd":[48,35,13,2517,487535]}, + "hooks": [1,80,6153,804,804,4200,4200,19492,50,71,111,110,0,43797,6,18,60,21,1,50,0,0,0], + "events": {"atk":935,"deploy":20,"die":50,"dmg":4200,"fx":175,"leak":21,"skill":221,"spawn":72,"status":27}, + "fx": {"aoe":29,"buff":8,"displace":28,"dragonArrow":21,"quadShot":60,"takeoff":29}, + "snaps": "7541895e 9722de68 04c99c01 b3218acc 9261302b 42229a76 9b9dd065 51d9053e 2c4e8079 37c11eec a7817eaa e2c35c85 cb1f5ae1 01c3306d e235b7c5 defc2da3 5fde9f5d fdc6bb1f 47c6c147 e7d867d1 6946f673" }, "diy-006": { - "about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc 6_diy1_a=acsupo 6_diy2_b=acsupo", + "about": "6_diy1_b=orchd2/S1 6_diy2_b=orchd2/S2 6_diy1_b=orchd2/S3 6_diy2_b=orchd2/S1/002_orchd2 6_diy1_b=orchd2/S2/002_orchd2 6_diy2_b=orchd2/S3/002_orchd2 5_diy1_a=bgsnow/S1 5_diy2_a=bgsnow/S2 5_diy1_a=bgsnow/S3 5_diy2_b=bgsnow/S1 5_diy1_b=bgsnow/S2 5_diy2_b=bgsnow/S3", "fast": false, "kind": "normal", "stageId": "act1autochess_m06", @@ -221,35 +221,275 @@ "round": 13, "seed": 2464539579, "waveId": "act1autochess_h06", - "time": 210.23, - "ticks": 6307, + "time": 209.03, + "ticks": 6271, "reason": "cleared", - "killed": 5, + "killed": 16, "total": 81, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 123, + "rngDraws": 25, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":5,"total":81,"perfect":false,"leaks":76,"leaksUncounted":0,"coins":0,"dmg":185202,"bossDmg":0,"heal":4048,"deaths":30,"layerGains":{}}}, + "players": {"p1":{"killed":16,"total":81,"perfect":false,"leaks":65,"leaksUncounted":0,"coins":0,"dmg":404038,"bossDmg":0,"heal":0,"deaths":22,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,3,0,11325,1865,0,7,1], - ["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,6,3,52957,2494,0,16,1], - ["p1",3,"op","chess_char_6_diy1_a",2,null,1154,1,112,0,15444,8178,0,22,0], - ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",0,0,109,2,51553,12663,0,57,0], - ["p1",5,"op","chess_char_6_diy1_a",2,null,1272,1,113,0,4256,2544,0,15,0], - ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",0,0,105,0,10180,3286,0,19,0], - ["p1",7,"op","chess_char_6_diy1_a",2,null,0,0,5,0,3060,1372,0,2,0], - ["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",0,0,2,0,30589,1819,0,4,1], - ["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,8,0,0,2028,2464,7,1], - ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",0,0,61,0,0,3890,1584,6,1], - ["p1",11,"op","chess_char_6_diy1_a",2,null,0,0,58,0,2490,3291,0,7,1], - ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",0,0,60,0,3347,3320,0,8,0] + ["p1",1,"op","chess_char_6_diy1_b",0,"none",0,0,95,0,38029,4929,0,16,7], + ["p1",2,"op","chess_char_6_diy2_b",1,"none",0,0,81,6,143617,3286,0,24,4], + ["p1",3,"op","chess_char_6_diy1_b",2,"none",0,0,90,0,4400,4929,0,7,1], + ["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_002_orchd2",0,0,103,2,55563,4929,0,17,7], + ["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_002_orchd2",0,0,32,7,113856,3286,0,14,3], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_orchd2",0,0,50,0,14303,4929,0,8,1], + ["p1",7,"op","chess_char_5_diy1_a",0,null,0,0,12,0,948,1369,0,2,0], + ["p1",8,"op","chess_char_5_diy2_a",1,null,0,0,7,0,6358,1369,0,8,1], + ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,9,0,2370,1369,0,5,0], + ["p1",10,"op","chess_char_5_diy2_b",0,"none",0,0,11,0,1824,1656,0,3,0], + ["p1",11,"op","chess_char_5_diy1_b",1,"none",0,0,4,1,12075,1656,0,11,3], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,6,0,10696,1656,0,12,1] ], - "enemies": {"enemy_1267_nhpbr_2":[27,5,22,7765,89733],"enemy_1270_nhstlk_2":[36,0,36,14278,68347],"enemy_1272_nhtank_2":[18,0,18,24706,27121]}, - "hooks": [1,113,6307,262,262,449,476,16,35,111,6,21,0,5828,86,24,81,76,1,35,0,0,0], - "events": {"atk":283,"deploy":32,"die":35,"dmg":441,"fx":58,"heal":15,"leak":76,"skill":12,"spawn":93,"status":142}, - "fx": {"aoe":7,"displace":21,"spGain":6,"zone":24}, - "snaps": "1dcf4706 8bd55683 d93e4d02 ab832ad5 132699ab f37b039d f12175ca fe30225b 6b2b77c0 2caefa3a 1018f9e1 a60a2364 f81df3c0 d0f2780e 0e940f53 372c90ef 46af3a81 a6b2adf1 63234976 3b8b7c59 b62aa8d4" + "enemies": {"enemy_1267_nhpbr_2":[27,11,16,100,107273],"enemy_1270_nhstlk_2":[36,4,32,13399,208206],"enemy_1272_nhtank_2":[18,1,17,21864,88559]}, + "hooks": [1,103,6271,176,176,643,643,0,38,103,28,28,0,6552,8,18,81,65,1,38,0,0,0], + "events": {"atk":219,"deploy":22,"die":38,"dmg":643,"fx":69,"leak":65,"skill":56,"spawn":93,"status":55}, + "fx": {"aoe":7,"buff":10,"dragonArrow":2,"quadShot":14,"takeoff":7,"zone":29}, + "snaps": "0ff3da34 eebf2f83 f86ea75a 53271705 e8e17748 10f438be 26f108c4 be78a601 aa0141c1 710b8f1d b8d23f9c 0639175c 4c494674 a177e1bf 9d40de6d d37c62f7 3c00194d b3c78666 d4911850 6fcfee8d 89d8cc2c" + }, + "diy-007": { + "about": "5_diy1_b=bgsnow/S1/002_bgsnow 5_diy2_b=bgsnow/S2/002_bgsnow 5_diy1_b=bgsnow/S3/002_bgsnow 5_diy2_b=bgsnow/S1/003_bgsnow 5_diy1_b=bgsnow/S2/003_bgsnow 5_diy2_b=bgsnow/S3/003_bgsnow 6_diy1_a=bgsnow/S1 6_diy2_a=bgsnow/S2 6_diy1_a=bgsnow/S3 6_diy2_b=bgsnow/S1 6_diy1_b=bgsnow/S2 6_diy2_b=bgsnow/S3", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m07", + "modeId": "mode_multi_normal", + "round": 13, + "seed": 1837403644, + "waveId": "act1autochess_h06", + "time": 211.87, + "ticks": 6356, + "reason": "cleared", + "killed": 11, + "total": 123, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 169, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":11,"total":123,"perfect":false,"leaks":112,"leaksUncounted":0,"coins":0,"dmg":333377,"bossDmg":0,"heal":0,"deaths":4,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_bgsnow",1786,1,5,0,8790,0,0,50,1], + ["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_bgsnow",1786,1,7,0,14937,0,0,57,12], + ["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_bgsnow",1786,1,9,0,7445,0,0,56,2], + ["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_003_bgsnow",1656,1,11,0,11838,0,0,51,1], + ["p1",5,"op","chess_char_5_diy1_b",1,"uniequip_003_bgsnow",1656,1,13,0,18046,0,0,51,11], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_003_bgsnow",1656,1,6,7,74942,0,0,150,3], + ["p1",7,"op","chess_char_6_diy1_a",0,null,1369,1,8,0,3169,0,0,50,1], + ["p1",8,"op","chess_char_6_diy2_a",1,null,1369,1,10,2,60625,0,0,126,18], + ["p1",9,"op","chess_char_6_diy1_a",2,null,1369,1,12,0,49188,0,0,149,3], + ["p1",10,"op","chess_char_6_diy2_b",0,"none",1656,1,14,0,73742,0,0,122,1], + ["p1",11,"op","chess_char_6_diy1_b",1,"none",1656,1,133,2,10096,3312,0,15,3], + ["p1",12,"op","chess_char_6_diy2_b",2,"none",1656,1,129,0,560,3312,0,9,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_1006_shield_3":[21,0,21,3312,33986],"enemy_1043_zomsbr":[66,11,55,1374,257145],"enemy_1402_tgshd_2":[36,0,36,1938,42246]}, + "hooks": [1,141,6356,899,899,985,985,20538,15,127,57,53,0,27860,0,6,123,112,1,15,0,0,0], + "events": {"atk":899,"deploy":16,"die":15,"dmg":985,"fx":524,"leak":112,"skill":110,"spawn":137,"status":130}, + "fx": {"crit":45,"expose":479}, + "snaps": "7c8b1a6a 446c0c56 799157ea ab79eed5 b2b28700 088603cd 775e8cc3 7164f5f2 b9ea3330 84283433 d97cfa14 7f7b5348 7e410aca c92d3189 562e59c3 5fe6997e fa502c61 68f955eb b93119af e528980f b7781968 91c82542" + }, + "diy-008": { + "about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 6_diy1_b=bgsnow/S1/002_bgsnow 6_diy2_b=bgsnow/S2/002_bgsnow 6_diy1_b=bgsnow/S3/002_bgsnow 6_diy2_b=bgsnow/S1/003_bgsnow 6_diy1_b=bgsnow/S2/003_bgsnow 6_diy2_b=bgsnow/S3/003_bgsnow 5_diy1_a=csnipe 5_diy2_b=csnipe", + "fast": true, + "kind": "normal", + "stageId": "act2autochess_m01", + "modeId": "mode_multi_hard", + "round": 13, + "seed": 3478524561, + "waveId": "act1autochess_h06", + "time": 221.23, + "ticks": 6637, + "reason": "cleared", + "killed": 4, + "total": 147, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 95, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":4,"total":147,"perfect":false,"leaks":143,"leaksUncounted":0,"coins":0,"dmg":347594,"bossDmg":0,"heal":0,"deaths":22,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,98,0,165,4256,0,6,0], + ["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,143,0,5244,5816,0,38,1], + ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,172,0,3111,7692,0,15,0], + ["p1",4,"op","chess_char_5_diy2_b",2,"none",1870.15,1,173,0,15330,8132,0,18,1], + ["p1",5,"op","chess_char_6_diy1_b",0,"uniequip_002_bgsnow",1826,1,2,2,94832,0,0,120,1], + ["p1",6,"op","chess_char_6_diy2_b",1,"uniequip_002_bgsnow",1826,1,3,1,114659,0,0,138,22], + ["p1",7,"op","chess_char_6_diy1_b",2,"uniequip_002_bgsnow",1826,1,4,1,81377,0,0,144,3], + ["p1",8,"op","chess_char_6_diy2_b",0,"uniequip_003_bgsnow",0,0,156,0,7893,4968,0,18,0], + ["p1",9,"op","chess_char_6_diy1_b",1,"uniequip_003_bgsnow",0,0,83,0,20268,3312,0,23,5], + ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_003_bgsnow",0,0,56,0,510,3312,0,11,0], + ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,168,0,286,3813,0,11,1], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,163,0,3919,4530,0,29,1] + ], + "enemies": {"enemy_1166_dusbr_2":[39,3,36,11279,152576],"enemy_1169_duphlx_2":[57,1,56,13781,153310],"enemy_1170_dushld_2":[51,0,51,20770,41707]}, + "hooks": [1,173,6637,691,691,741,741,0,26,169,35,34,0,18241,0,32,147,143,1,26,0,0,0], + "events": {"atk":691,"deploy":26,"die":26,"dmg":741,"fx":29,"leak":143,"skill":69,"spawn":159,"status":147}, + "fx": {"crit":29}, + "snaps": "f7fcf200 6e3880e4 657eff54 19808845 e8ab803f a640a48f d7a1b7d6 8798410b f974d6cf d50c53a8 9a9e9c5e a0517d03 32dca213 e2c669e7 317335a1 17d7a4a0 ce0f4bd1 45f8dcf4 dd177c3c c281f902 4c1a8a24 a398d8dc 9696d7af" + }, + "diy-009": { + "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_m02", + "modeId": "mode_single_normal", + "round": 12, + "seed": 2612428600, + "waveId": "act1autochess_h05", + "time": 230, + "ticks": 6900, + "reason": "timeout", + "killed": 18, + "total": 42, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 100, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":18,"total":42,"perfect":false,"leaks":24,"leaksUncounted":0,"coins":0,"dmg":60423,"bossDmg":0,"heal":11501,"deaths":27,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,3,0,0,2604,0,0,0], + ["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",0,0,5,0,0,3404,0,0,0], + ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,64,0,12281,4302,0,0,2], + ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,69,1,19550,7605,0,0,3], + ["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,47,0,0,7726,0,0,0], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,44,0,0,6822,0,0,0], + ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,52,0,0,2266,0,0,0], + ["p1",8,"op","chess_char_5_diy2_b",2,"none",0,0,50,0,16580,4004,0,35,1], + ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,67,0,0,5674,2020,7,1], + ["p1",10,"op","chess_char_5_diy2_b",2,"none",0,0,71,0,0,5543,9480,20,1], + ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,70,0,5609,3380,0,15,1], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,68,0,6403,3963,0,17,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_2":[18,18,0,830,5156],"enemy_1005_yokai_3":[12,0,11,28478,30434],"enemy_9009_acfort":[12,0,12,27987,24833]}, + "hooks": [1,71,6900,462,462,467,442,27,45,68,10,10,0,12457,32,0,42,23,1,28,0,0,25], + "events": {"atk":462,"deploy":27,"die":45,"dmg":442,"fx":121,"heal":27,"leak":23,"skill":20,"spawn":56,"status":107}, + "fx": {"beam":55,"dodge":25,"dp":6,"slash":35}, + "snaps": "8f962d08 3056b267 beb14b31 2394cb94 2b9de697 194c89b5 3a08d0d2 1d09774e 959dc47d 270d0004 f0773a73 3566bde9 b4abb17e 8201dc8f a0766de3 267f5f64 24e29cf1 b763dc25 c2f98e66 db226502 88b350d1 0dc0611a 604e4306" + }, + "diy-010": { + "about": "5_diy1_a=sharp2 5_diy2_b=sharp2 5_diy1_a=acspec 5_diy2_b=acspec 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc 5_diy1_a=acsupo 5_diy2_b=acsupo", + "fast": false, + "kind": "normal", + "stageId": "act2autochess_m03", + "modeId": "mode_multi_abyss", + "round": 12, + "seed": 4252812069, + "waveId": "act1autochess_h05", + "time": 208.93, + "ticks": 6268, + "reason": "cleared", + "killed": 0, + "total": 114, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 113, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":0,"total":114,"perfect":false,"leaks":114,"leaksUncounted":0,"coins":0,"dmg":100335,"bossDmg":0,"heal":6344,"deaths":30,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",0,null,0,0,5,0,15630,1865,0,9,1], + ["p1",2,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",0,0,7,0,18138,3029,0,5,1], + ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,145,0,25048,10069,0,63,0], + ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",0,0,147,0,19052,13642,0,19,0], + ["p1",5,"op","chess_char_5_diy1_a",2,null,445.63,1,148,0,6079,3370,0,15,0], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",0,0,141,0,5804,5761,0,12,0], + ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,13,0,1663,1372,0,2,0], + ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",0,0,73,0,1993,5153,0,2,0], + ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,4,0,0,1657,3351,5,1], + ["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",0,0,10,0,0,1414,2993,6,1], + ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,6,0,2988,1160,0,7,0], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",0,0,8,0,3939,1430,0,7,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_1019_jshoot_2":[51,0,51,9812,8520],"enemy_1381_winman":[27,0,27,9427,90722],"enemy_1389_winbab_2":[36,0,36,30683,1093]}, + "hooks": [1,148,6268,331,331,310,319,18,30,144,5,21,0,5538,61,21,114,114,1,30,0,0,0], + "events": {"atk":343,"deploy":31,"die":30,"dmg":304,"fx":16,"heal":16,"leak":114,"skill":10,"spawn":129,"status":58}, + "fx": {"displace":8,"spGain":8}, + "snaps": "2971e9ff e77895c1 13abdad5 03f3c6e3 025969bd f22bbc34 92c20fec fd2f44ee 674a7f68 561c16b2 a94168d2 1b34b5cf 1d6ac33f b46b69d8 57111593 2c0e5d03 c4e78702 e9d8f752 1a9f888a c9d4b090 82ba1d23" + }, + "diy-011": { + "about": "6_diy1_a=acfend 6_diy2_b=acfend 6_diy1_a=acpion 6_diy2_b=acpion 6_diy1_a=acguad 6_diy2_b=acguad 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc", + "fast": false, + "kind": "normal", + "stageId": "act2autochess_m04", + "modeId": "mode_multi_funny", + "round": 13, + "seed": 538212341, + "waveId": "act1autochess_h06", + "time": 230, + "ticks": 6900, + "reason": "timeout", + "killed": 83, + "total": 144, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 754, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":83,"total":144,"perfect":false,"leaks":61,"leaksUncounted":0,"coins":0,"dmg":727357,"bossDmg":0,"heal":57340,"deaths":20,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",2,null,0,0,7,8,27898,13166,0,80,2], + ["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",2829.8,1,167,7,27469,8851,0,66,1], + ["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,6,16,140111,3022,0,54,6], + ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",0,0,144,14,159200,6358,0,8,5], + ["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,143,2,11845,7595,0,14,1], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",0,0,136,20,76014,33300,0,54,3], + ["p1",7,"op","chess_char_6_diy1_a",2,null,0,0,3,2,30581,1272,0,62,2], + ["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",0,0,4,12,238233,1643,0,161,2], + ["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,153,1,9117,5500,0,8,1], + ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",1819,1,169,0,6889,7367,0,6,0], + ["p1",11,"op","chess_char_6_diy1_a",2,null,0,0,5,0,0,1644,40995,55,2], + ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",1464,1,168,0,0,8573,16345,21,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_2021_syfish":[99,73,24,10687,302163],"enemy_2025_syufo":[45,10,35,87604,438052]}, + "hooks": [1,169,6900,1412,1412,2733,2987,143,103,162,26,26,0,27540,0,64,144,59,1,104,0,33,62], + "events": {"atk":1560,"deploy":23,"die":103,"dmg":2987,"fx":320,"heal":134,"leak":59,"skill":52,"spawn":158,"status":123}, + "fx": {"aoe":80,"burst":33,"dodge":62,"dp":41,"slash":88,"spGain":16}, + "snaps": "55a12ce8 172ad35e b858882a 66672e2c bf89e3e3 9be6cb9e a0514ec0 d7b44a56 39783f17 ba8e20c8 09cd87af 746573df b9e27578 e7dd7dfc cb04e699 92d3e0b3 7bdb1773 b6882cdd a0d06e3c 5eab91e4 24e99439 b044b69e 7d8c5fa0" + }, + "diy-012": { + "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_m01", + "modeId": "mode_single_hard", + "round": 13, + "seed": 3192456040, + "waveId": "act1autochess_h06", + "time": 230, + "ticks": 6900, + "reason": "timeout", + "killed": 10, + "total": 117, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 178, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":10,"total":117,"perfect":false,"leaks":107,"leaksUncounted":0,"coins":0,"dmg":228417,"bossDmg":0,"heal":0,"deaths":23,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,145,1,34634,5595,0,56,1], + ["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,143,7,57826,7482,0,40,1], + ["p1",3,"op","chess_char_6_diy1_a",2,null,1154,1,146,0,22026,9232,0,68,0], + ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",1484,1,147,2,58947,13356,0,67,0], + ["p1",5,"op","chess_char_6_diy1_a",2,null,1160,1,4,0,23667,0,0,150,3], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",1450,1,5,0,31317,0,0,155,3], + ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], + ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], + ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null] + ], + "enemies": {"enemy_1166_dusbr_2":[39,8,31,3534,97703],"enemy_1170_dushld_2":[39,2,36,10197,64140],"enemy_9011_acrefr":[39,0,37,21934,66574]}, + "hooks": [1,147,6900,622,622,893,944,0,33,137,8,23,0,11211,11900,38,117,104,1,33,0,0,0], + "events": {"atk":668,"deploy":27,"die":33,"dmg":838,"fx":20,"leak":104,"skill":14,"spawn":126,"status":1135}, + "fx": {"displace":16,"dodge":4}, + "snaps": "d28bb789 63eb985d 4d0f9eff 5acb7032 16fffd96 ad116d10 106aebb2 764edc40 ffc24dbd d20cfab0 2ec7d68f c9765510 e0639444 cbfcf6d4 de2fc904 6c97bbcb 69043b03 56f9d1aa c7b63229 79cdb25e 4e2e6e65 d377e48b d6594021" } } }