diff --git a/docs/SIM.md b/docs/SIM.md index 6dc2251e..6015988d 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -796,6 +796,7 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged | `allySelectable(ally, by)`, `alliesFor(by, ownerId?)` | may an ability of ally `by` select `ally` — never a 孤立 unit (炎佑, 从不混淆的方向) but `by` itself (PRTS 选择器: "若掩码中孤立为1,且选择器的阵营与目标为友好关系,则不可选中"); `allies()` without the 孤立 ones — content picks ally targets (buffs, auras, 全场 talents, heal picks) through these, `alliesInGrid` too | | `unitsInGrid(unit, grid, {side, extend})`, `alliesInGrid(unit)`, `enemiesInRadius(x, y, r, centre?)`, `foesInRadius(x, y, r, centre?)`, `alliesInRadius(x, y, r, ownerId?)` | grid offsets are relative to facing RIGHT, rotated by `unit.dir`; enemies by their body (§2 hit areas: a huge enemy on every tile it occupies / within `r` of its rectangle; `centre` = splash around a target, a 中点判定 by position; `foesInRadius` = the enemies an operator's area effect can select — no untargetable enemy, none whose 隐匿 is on (`targeting.js enemyStealthed`: not blocked, not revealed, not within 3 s of its last block), PRTS 作战机制 §AOE伤害判定 — while enemy-side auras / heals and collisions keep `enemiesInRadius`) | | `enemiesInKeys(keys, attacker, profile)`, `blockedTargets(unit, profile)` | targetable enemies whose body is on the tiles (a huge one listed once); the enemies a unit blocks — always selectable by it, a ranged operator on a melee tile included (§1.2 Blocking) | +| `setAllyTarget(unit, on)`, `allyTargetsInKeys(keys, attacker)`, `isAllyTarget(unit)` | an **ally target**: an ally unit our attacks select like an enemy — 白铁's 铁钳号·原型机, a summon of the enemy camp ("可被我方干员攻击但不受伤害", PRTS; kits/ops/op-ironmn.js). The attack loop (ai.js `acquireTargets`) appends the registered ones standing on the attacker's range after every enemy (their 嘲讽等级 −2 puts them last: one target ⇒ an enemy first; all in range / more targets ⇒ them too) and ranged attacks fly to them; heals, skills that pick their own victims and enemies never select them (the kit gives it `untargetable` against enemies and cancels the damage it takes in its `hit` hook). None registered ⇒ the loop is unchanged | | `allies(ownerId?)`, `aliveEnemies()`, `unitAt(r, c)`, `unitById(id)`, `tileInfo(r, c)`, `lowestHpAllyInRange(unit)` | | | `addLayers(playerId, bondId, n, reason, {source})`, `addCoins(playerId, n)` | layers are a no-op when `flags.layerGainsEnabled` is false (unite/boss); a gain adds at most the room left under `BOND_LAYER_CAP` (999, shared/constants.js `layerGainRoom`: the client's `AddBondCount` min(L + n, 999)) on the live copy — or, without one, on the battle's own gains — and returns what it added (0 at the cap: no hook, no event) | | `getPlayer(playerId)` | `{ playerId, seat, side, colOffset, mirror, dir (default unit direction: RIGHT, mirrored side LEFT), facing (its sign), bonds (live copy, layers updated by addLayers), bandId, playerEffects, lpForBoss, dp, units }` | diff --git a/server/sim/ai.js b/server/sim/ai.js index d91b0b21..d1dc91e8 100644 --- a/server/sim/ai.js +++ b/server/sim/ai.js @@ -15,7 +15,9 @@ // range, blocker first); every blocker whose attack hits enemies — a ranged operator on a melee tile included — may // always target the enemies it blocks, in range or not, whatever its facing, and targets them first (acquireTargets, // Battle.blockedTargets; user playtest #6: "阻挡了就一定要能打到"); a heal attack keeps selecting injured allies while -// its unit blocks (PRTS 卫戍协议/帮助 "对于医疗干员(咒愈师分支除外),攻击目标为需要治疗的单位"). +// its unit blocks (PRTS 卫戍协议/帮助 "对于医疗干员(咒愈师分支除外),攻击目标为需要治疗的单位"). An ally target +// (Battle.setAllyTarget: 白铁's 铁钳号·原型机, an enemy-camp summon our operators attack, 嘲讽等级 −2) on the range comes after +// every enemy (acquireTargets) and a ranged attack flies to it like to an enemy. // Unblocked ranged enemies attack allies within their radius and stand for each attack's clip — through its wind-up // and until the clip ends — then walk on (attackStand, GitHub #58; ATTACK_PAUSE after the strike when no clip is known; // 「不停止移动」 attackers never stop); the candidates pass the enemy's own rule (`e.profile.canTarget`) and are ordered @@ -135,6 +137,15 @@ export function acquireTargets(b, u, prof) { // (0.7071) reaches past its own tile, so a blocked enemy may stand outside a short range or behind its facing (user // playtest #5 item 4); a ranged operator on a melee tile too (user playtest #6: "阻挡了就一定要能打到") if (u.blocking.length) for (const e of b.blockedTargets(u, prof)) if (!cands.includes(e)) cands.push(e); + // an ally target (Battle.setAllyTarget: 白铁's 铁钳号·原型机, an enemy-camp summon with 嘲讽等级 −2) after every enemy + const extra = b._allyTargets && b._allyTargets.size ? b.allyTargetsInKeys(u.rangeKeys, u) : null; + if (extra && extra.length) { + if (prof.allInRange) return cands.concat(extra); + sortEnemyTargets(b, u, cands, prof.priority); + const all = cands.concat(extra); + const n = Math.max(1, Math.floor((prof.maxTargets || 1) + u.s.maxTargets)); + return n >= all.length ? all : all.slice(0, n); + } if (!cands.length) return cands; if (prof.allInRange) return cands; const n = Math.max(1, Math.floor((prof.maxTargets || 1) + u.s.maxTargets)); @@ -162,9 +173,10 @@ export function performAttack(b, u, prof, targets, opts = null) { b._ev(['atk', u.id, t.id, vis]); if (isHeal) { doHeal(b, u, prof, t); continue; } const info = { isSkill, index: i, attackId }; - if (ranged && t.side === 'enemy' && prof.projectile === 'boomerang') { + const foe = t.side === 'enemy' || b.isAllyTarget(t); + if (ranged && foe && prof.projectile === 'boomerang') { throwBoomerang(b, u, prof, t, info); - } else if (ranged && t.side === 'enemy') { + } else if (ranged && foe) { const speed = PROJECTILE_SPEEDS[prof.projectile] ?? PROJECTILE_SPEED; // projectiles land even if the shooter died meanwhile (damage is credited to it) b.addProjectile({ from: u, target: t, speed, visual: prof.projectile, source: u, hitDead: prof.splashRadius > 0, diff --git a/server/sim/battle/queries.js b/server/sim/battle/queries.js index 2c3dcd7a..b4a526ae 100644 --- a/server/sim/battle/queries.js +++ b/server/sim/battle/queries.js @@ -1,5 +1,6 @@ // server/sim/battle/queries.js — Battle methods: queries: the per-tick enemy tile index, enemies / allies by tile keys, -// grid, radius and selectability, players, and the range rebuild of an ally (current, base and extra range keys). +// grid, radius and selectability, the ally targets (allies our attacks select like enemies), players, and the range +// rebuild of an ally (current, base and extra range keys). // Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle). import { ROWS, COLS } from '../constants.js'; @@ -41,6 +42,35 @@ export class BattleQueries { return out; } + /** + * Ally units an ally's attack selects like an enemy — the "ally targets" content registers (setAllyTarget): 白铁's + * 铁钳号·原型机, a summon of the enemy camp that "可被我方干员攻击但不受伤害" (PRTS 铁钳号·原型机 备注 "该召唤物阵营为敌方"; + * kits/ops/op-ironmn.js). Those standing on one of `keys`, alive and deployed — never `attacker` itself, and an ally target + * selects none. ai.js acquireTargets appends them after the enemies (their 嘲讽等级 −2 puts them last). [] at once + * while none is registered, so every other battle runs exactly as before. + */ + allyTargetsInKeys(keys, attacker) { + const set = this._allyTargets; + if (!set || !set.size || !keys || !attacker || set.has(attacker)) return []; + const ks = keys === attacker.rangeKeys && attacker.rangeKeySet ? attacker.rangeKeySet : new Set(keys); + const out = []; + for (const a of set) if (a.alive && a.deployed && !a.hidden && ks.has(a.tileR * COLS + a.tileC)) out.push(a); + return out; + } + + /** Register (`on`) or drop an ally target (allyTargetsInKeys); one off the field is skipped while registered. */ + setAllyTarget(unit, on = true) { + if (!unit || unit.side !== 'ally') return false; + if (on) (this._allyTargets ??= new Set()).add(unit); + else if (this._allyTargets) this._allyTargets.delete(unit); + return true; + } + + /** Is `unit` a registered ally target (setAllyTarget)? */ + isAllyTarget(unit) { + return !!(unit && this._allyTargets && this._allyTargets.has(unit)); + } + /** * A living enemy whose body is on any of `keys` — targetable or not (stealthed, untargetable, asleep, flying): the * 技能范围 trigger "技能范围内存在敌人(无视其不可选中)" (skills.js SKILL_RANGE). diff --git a/server/sim/content/kits/index.js b/server/sim/content/kits/index.js index 639bb013..c6992ed1 100644 --- a/server/sim/content/kits/index.js +++ b/server/sim/content/kits/index.js @@ -133,6 +133,9 @@ export const OPERATOR_KIT_FILES = Object.freeze([ 'op-haak.js', 'op-phatom.js', 'op-crosly.js', + 'op-ironmn.js', + 'op-nasti.js', + 'op-necras.js', ]); /** diff --git a/server/sim/content/kits/ops/op-ironmn.js b/server/sim/content/kits/ops/op-ironmn.js new file mode 100644 index 00000000..2840d496 --- /dev/null +++ b/server/sim/content/kits/ops/op-ironmn.js @@ -0,0 +1,405 @@ +// server/sim/content/kits/ops/op-ironmn.js — 白铁 (char_4072_ironmn) 自选 operator kit: 6★ 工匠 (辅助), an owned-6★ pick of +// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait, both modules (CRA-X 铁钳号·爬行者, CRA-Y 海布里印记) +// at every form, and the kits of his three <支援装置>: 白铁™多功能平台 (token_10027_ironmn_pile1, 极致火力 / pile2, 高效补给) +// and 铁钳号·原型机 (token_10027_ironmn_pile3). Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)"). +// +// Forms (data/backups.json units.char_4072_ironmn): 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 / the token rows (zh_CN, as built into backups.json), PRTS +// 白铁 (S1 / S2 备注 "支援装置的效果可叠加", S2 "携带该技能时,支援装置每秒流失最大生命值0.4%的生命值", S3 "此时的支援装置为敌对 +// 阵营的召唤物单位"; 节约经费 "手动撤回白铁周围8格的装置也可以触发本天赋"; the module pages: CRA-X 白铁™多功能平台 −1 / 铁钳号 −4 +// 部署费用, CRA-Y −5 s / −10 s 再部署时间), PRTS 白铁™多功能平台 / 铁钳号·原型机 (备注: 持有禁疗、无敌 / "该召唤物阵营为敌方…提供 +// 我方视野", 禁疗 孤立 不可阻挡 失衡免疫 元素免疫, 晕眩 寒冷 冻结 浮空 束缚 沉默 沉睡免疫, "受到伤害时取消此伤害,若该伤害来源为非 +// 无职业的辅助职业的工匠单位,治疗自身相当于最大生命值1%的生命值"; 团结的力量 "实际范围为自身及身后一格…多个支援装置的天赋效果可 +// 叠加"; skill "伤害半径1.1,造成物理普通伤害…可对空", "仅能选择不持有不可选中/隐匿/无敌的敌方单位(无视孤立)"), PRTS 分支特性信息 +// 工匠 ("干员部署后,按天赋描述数量补充支援装置持有数", "干员离场后,附属的支援装置随之消失"), PRTS 卫戍协议/帮助 (§战斗部署 a hand +// summon comes in its deploy-limit count; §作战阶段 "所有手动部署的召唤物…作战开始时立即部署一次", "若战场区初始部署有召唤物,若召唤 +// 物在战斗期间退场,将在满足条件后立即原地再部署1个"), and the client's battle data read from the local install — charpack +// char_4072_ironmn (ironmn_t_1 charge_token[born]; ironmn_t_2 on his own tokens of his x-4: ON_OWNER_FINISH ⇒ Dice(prob) ⇒ +// RechargeToken(cnt); ironmn_c die_to_kill_token), the token prefabs pile1 / 2 / 3 (talent auras on ally operators — +// professionMask 639 — of the device range, `disableOverride` = one buff per device; pile2 modify_sp[trigger] + the +// ironmn_pile_bleed timer of 1 s; pile3: Trait ironmn_pile3_trait, 团结的力量 damage_resistance[phy] on targetSide 2 = our +// operators of its b-1, `ignoreTargetFree`), the skill prefabs (skchr_ironmn_1 / 2 / 3: devices to their skill mode, +// RechargeToken at S1 start / S2 end / S3 start, KillTokens at S1 end; sktok_ironmn_pile3: one target (`_maxTarget` 1), +// projectile_chr_ironmn_pile3 at 10 tiles/s, `_exceptTraceTarget` splash, the bleed on the cast event), the equip prefabs +// (CRA-X: trigger_charge_token +cnt at each deployment, stage 2+ ironmn_e_002[sp_recover] on the device's x-4; CRA-Y stage +// 2+: ironmn_e_003_t[attr] ATTACK_SPEED on the device talent's targets, its own buff per device) and buff_template_data +// (ironmn_s_1 / 2 / 3, ironmn_t_2, ironmn_pile_bleed / pile3_bleed: PURE DamageViaMaxHpRatio `_skipModifierEvent` — a 流失 —, +// ironmn_pile3_s[main] / [splash]: PHYSICAL AdvancedApplyDamage on the host's ATK, attackType NORMAL, charge_token[born], +// trigger_charge_token, die_to_kill_token). +// +// - Trait (工匠) "能够阻挡两个敌人,使用<支援装置>协助作战": melee physical (professions.js craftsman), 1-1, blocks 2, ground only; +// the enemies he blocks are always his targets (Battle.blockedTargets, PRTS 分支特性信息 "可以且优先攻击自身阻挡的单位"). +// - <支援装置> = the hand pieces of the picked skill's device (data `overrideTokenKey`: S1 pile1, S2 pile2, S3 pile3 — the +// variant `sources`, server/match/player/diy.js placeableTokens). Every placed piece deploys with the board at the battle +// start for free (the mode's rule). His 持有 (stock) is cnt (T1, 3) + CRA-X's trait cnt (1) after each of his deployments +// (charge_token[born] + trigger_charge_token), capped there (持有上限). A device that leaves the field comes back on its own +// tile — after its redeploy time (data respawnTime, CRA-Y less), paying its cost (data, CRA-X less) and spending one of the +// stock — while he stands; when he leaves, his devices go with him (die_to_kill_token) and come back the same way after his +// return (stock refilled). The devices never attack, block nobody, are 禁疗; 白铁™多功能平台 is untargetable and invulnerable +// ("不会受到攻击"; PRTS 持有无敌). +// - T1 战地工程师 "可以携带3个<支援装置>(最多可部署2个)": the stock above; the 2 deployable are the hand pieces. CRA-Y stage 2+ +// "自身装置天赋生效的干员攻击速度+6(此效果不受技能影响)": every operator a device's talent reaches ASPD +attack_speed (the +// token's module talent; one per device), never scaled by S1. +// - T2 节约经费 "当白铁周围8格的自身装置损毁时,有70%的几率回收使白铁额外获得1个装置": a device of his leaving the field (killed, +// destroyed by S1, withdrawn) while on his x-4 (talent range) ⇒ prob ⇒ stock +cnt (capped) — not when he leaves himself +// (his talents go first). CRA-X stage 2+ adds "当白铁周围8格存在自身装置时技力回复速度+0.2/秒" (the token's module talent +// sp_recovery_per_sec; one effect however many devices — the client's ironmn_e_002[sp_recover], one buff key) and prob 0.9. +// - Module CRA-X “铁钳号·爬行者” "<支援装置>的持有上限+1且部署费用减少": the stock +1 (trait cnt) and the device costs of the data +// (2 / 2 / 6). CRA-Y “海布里印记” "<支援装置>的再部署时间减少": the data's respawn times (5 / 5 / 10 s). +// - 白铁™多功能平台 (S1): "使攻击范围内一名友方干员的攻击力+12%" — every operator on its range (1-1: its tile and the one it +// faces) ATK +atk (Σpct); while 白铁's S1 runs ×talent_scale (2.5 / 3: "装置效果提升至…倍"). +// - 白铁™多功能平台 (S2): "使攻击范围内一名友方干员每3.5秒额外获得1点技力,每秒流失0.4%生命" — every operator on its range gets 1 +// SP each `default.interval` s it stays there (the first one interval after it came; none while its skill runs — AK: no SP +// during a skill); the device loses hp_ratio of its max HP every second (a 流失: invulnerable as it is, it runs out). While +// 白铁's S2 runs: s2.interval s and s2.hp_ratio (both timers restart when the mode changes, as the client re-creates the +// buffs on its mode switch). +// - 铁钳号·原型机 (S3), an enemy-camp summon: our attacks select it like an enemy, after every real one (嘲讽等级 −2; +// Battle.setAllyTarget — the engine extension of this kit), enemies never (untargetable); every damage it takes is cancelled +// and gives it 1 SP (受击回复), and +hp_ratio of its max HP when the source is a 工匠 operator (trait, ignores 禁疗). 团结的力 +// 量: the operator on its b-1 (its tile and the one behind it) takes physical damage ×(1 − damage_resistance) — one effect per +// device. Its skill (AUTO, 5 SP, data DEFAULT — it never attacks, so the engine checks it every tick: an enemy, flyers too, in +// its 3-13): it loses hp_ratio of its max HP (流失), and a projectile (10 tiles/s) to the first enemy of its range deals +// 白铁's ATK (at the cast) × atk_scale physical to it and × atk_scale_2 to the other enemies within 1.1 of it (中点判定; +// not that one: `_exceptTraceTarget`), 普通伤害, air units too. Immune to the control statuses PRTS lists, never displaced. +// - S1 极致火力 (MANUAL, data DEFAULT): 16 / 17 s, every attack attack@atk_scale × ATK; +1 stock at the start; at its end every +// device of his on the field is destroyed (KillTokens — T2 may recover them). +// - S2 高效补给 (MANUAL, DEFAULT): 30 s, ATK +atk, DEF +def, every enemy he blocks at once (hitAllBlocked); +1 stock at its end. +// - S3 铁钳号·原型机 (MANUAL, DEFAULT): 30 s, ATK +atk, ASPD +attack_speed; +1 stock at the start. +// [ASSUMED] the stock refilled by +cnt on each deployment, capped at cnt + the CRA-X +1; a RechargeToken beyond the cap is lost; +// the crab charges only from the attacks that select it (an ally's area skill that picks enemies does not see it); summons +// attack it too. Open (data, report): the hand count of his devices is the data's deployLimit 1 — tools/build-data.mjs takes +// the attribute frame's maxDeployCount and leaves out the token's hidden talent max_deploy_count +1 (E2 "最多可部署2个"). + +import { num, talentBb, talentGrid, traitBb, skillRec, up, giveSp } from '../shared/tier1.js'; +import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js'; +import { COLS } from '../../../constants.js'; +import { frontOf } from '../../../dir.js'; + +const S1 = 'skchr_ironmn_1'; +const S2 = 'skchr_ironmn_2'; +const S3 = 'skchr_ironmn_3'; +export const PILE1 = 'token_10027_ironmn_pile1'; +export const PILE2 = 'token_10027_ironmn_pile2'; +export const CRAB = 'token_10027_ironmn_pile3'; +const DEVICES = new Set([PILE1, PILE2, CRAB]); +/** 节约经费's range when the data carries none: his tile and the 8 around it (x-4). */ +const X4 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]); +/** A device's 1-1 when its def carries none: its tile and the one it faces. */ +const FRONT1 = Object.freeze([[0, 0], [0, 1]]); +/** 铁钳号·原型机's skill: PRTS 备注 "伤害半径1.1"; projectile_chr_ironmn_pile3 `_speed` 10. */ +const CRAB_SPLASH = 1.1; +const CRAB_SPEED = 10; +/** Statuses 铁钳号·原型机 is immune to (PRTS 备注: 晕眩 寒冷 冻结 浮空 束缚 沉默 沉睡免疫). */ +const CRAB_IMMUNE = new Set(['stun', 'cold', 'freeze', 'levitate', 'bind', 'silence', 'sleep']); +/** Seconds between two tries of a device waiting to come back (timer, stock, DP, tile). */ +const RETRY = 0.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; +const tokBb = (def, key) => tokTalent(def, key)?.bb ?? {}; +/** An operator of our side a device's talent may reach (professionMask 639: operators, not summons). */ +const isOp = (a) => !!a && a.kind === 'op' && a.side === 'ally'; +/** + * A 工匠's <支援装置> state for the battle: `stock` (持有), `cap` (持有上限), `devices` (its pieces). Shared with 娜斯提 + * (op-nasti.js). + */ +export const deviceState = (unit) => unit.mem.craftsman ?? (unit.mem.craftsman = { stock: 0, cap: 0, devices: new Set() }); +/** The owner's devices standing on the field. */ +export const devicesUp = (unit) => [...deviceState(unit).devices].filter((d) => up(d)); +/** Stock +n, capped at the 持有上限 (RechargeToken). */ +export function rechargeDevices(unit, n) { + const st = deviceState(unit); + st.stock = Math.min(st.cap, st.stock + Math.max(0, n)); +} + +/** + * Keep buff `key` (one per device: `disableOverride`) on every operator `select` returns, exactly while it does; `mods(a)` + * re-read whenever `sig()` changes (a skill switching the device's mode). Removed from everyone when the device leaves. + */ +export function deviceAura(battle, dev, { key, select, mods, sig = () => 0 }) { + const held = new Map(); // operator → signature it was given + const drop = (a) => { if (a.findBuff(key)) battle.removeBuff(a, key); held.delete(a); }; + battle.on('tick', () => { + if (!up(dev)) { for (const a of [...held.keys()]) drop(a); return; } + const want = select(); + const s = sig(); + for (const a of [...held.keys()]) if (!want.includes(a) || !up(a)) drop(a); + for (const a of want) { + if (!up(a)) continue; + if (held.get(a) === s && a.findBuff(key)) continue; + const m = mods(a); + if (!Object.keys(m).length) { drop(a); continue; } + battle.addBuff(a, { key, mods: m, source: dev, tags: ['talent', 'device'] }); + held.set(a, s); + } + }, { owner: dev }); + battle.on('death', (ctx) => { if (ctx.unit === dev) for (const a of [...held.keys()]) drop(a); }, { owner: dev, priority: 10 }); +} + +/** Operators of our side standing on `keys` (absolute tile keys), the device itself excluded. */ +export function opsOn(battle, dev, keys, { ignoreSelect = false } = {}) { + const set = keys instanceof Set ? keys : new Set(keys); + const out = []; + for (const a of battle.allyUnits) { + if (!up(a) || a.hidden || !isOp(a) || !set.has(a.tileR * COLS + a.tileC)) continue; + if (!ignoreSelect && !battle.allySelectable(a, dev)) continue; + out.push(a); + } + return out; +} + +/** + * A device piece's return (PRTS 卫戍协议/帮助 "若战场区初始部署有召唤物…退场,将在满足条件后立即原地再部署1个"): once it left the + * field it comes back on its own tile after its redeploy time (data respawnTime), paying its cost (`costOf(dev)`, default its + * data cost) and spending one of the owner's stock, while the owner stands. `onLeave(dev)` runs as it leaves (节约经费 — + * not when it leaves with its owner: `dev.mem.withOwner`), `onBack(dev)` when it is back. Shared with 娜斯提 (op-nasti.js). + */ +export function deviceReturn(battle, dev, owner, { onLeave = null, costOf = null, onBack = null } = {}) { + battle.on('death', (ctx) => { + if (ctx.unit !== dev || battle.finished) return; + if (!dev.mem.withOwner && onLeave) onLeave(dev); + dev.mem.withOwner = false; + dev.removed = false; // the piece stays its tile's (content/tokens.js enableRespawn): back there later + const at = battle.time + Math.max(0, num(dev.base.respawnTime)); + const seq = (dev.mem.returnSeq ?? 0) + 1; + dev.mem.returnSeq = seq; + const tryBack = () => { + if (battle.finished || dev.alive || dev.removed || dev.mem.returnSeq !== seq) return; + const st = deviceState(owner); + if (battle.time + 1e-9 >= at && up(owner) && st.stock > 0) { + const cost = Math.max(0, costOf ? costOf(dev) : num(dev.base.cost)); + const ps = battle.getPlayer(dev.ownerId); + if (ps && ps.dp + 1e-9 >= cost && battle.redeploy(dev, { free: true })) { + ps.dp = Math.max(0, ps.dp - cost); + st.stock--; + battle.fx('summon', { x: dev.x, y: dev.y, id: dev.id, src: owner.id }); + if (onBack) onBack(dev); + return; + } + } + battle.after(Math.max(RETRY, at - battle.time), tryBack, { owner: dev }); + }; + battle.after(Math.max(0, at - battle.time), tryBack, { owner: dev }); + }, { owner: dev, priority: -10 }); +} + +/** The owner leaving withdraws its devices on the field (die_to_kill_token / KillTokens), but those `keep(dev)` spares. */ +export function devicesLeaveWithOwner(battle, owner, keep = null) { + battle.on('death', (ctx) => { + if (ctx.unit !== owner) return; + for (const d of devicesUp(owner)) { + if (keep && keep(d)) continue; + d.mem.withOwner = true; + battle.retreat(d, { reason: 'expired' }); + } + }, { owner }); +} + +export default { + char_4072_ironmn: (bb, chess) => { + const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1); + const grid1 = talentGrid(chess, 1) ?? X4; + const extraCnt = num(traitBb(chess).cnt); // CRA-X: 持有上限+1 (and trigger_charge_token +1) + const cnt = num(t0.cnt, 3) + extraCnt; + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + + /** Device kit (`dev` = the piece, `owner` = 白铁). */ + const deviceKit = (dev, owner) => { + const def = dev.def; + const sk = def.skill ?? null; + const tb = sk?.bb ?? {}; + const aspd = num(tokBb(def, 'attack_speed').attack_speed); // CRA-Y stage 2+ (the token's module talent) + const grid = dev.rangeGrid ?? def.rangeGrid ?? FRONT1; + const keys = () => absoluteRangeKeys(grid, dev.tileR, dev.tileC, dev.dir, 0); + const s1On = () => up(owner) && !!owner.skill?.active && owner.skill.id === S1; + const s2On = () => up(owner) && !!owner.skill?.active && owner.skill.id === S2; + const common = (battle) => { + battle.addBuff(dev, { key: 'ironmn:device', flags: { untargetable: true, noHeal: true, ...(dev.defId === CRAB ? { isolated: true, noDisplace: true } : { invulnerable: true }) }, persist: true, allowDead: true }); + if (aspd) { + // CRA-Y: "自身装置天赋生效的干员攻击速度+6(此效果不受技能影响)" — on the operators the device's talent reaches + const reach = dev.defId === CRAB + ? () => opsOn(battle, dev, [dev.tileR * COLS + dev.tileC, (([r, c]) => r * COLS + c)(frontOf(dev.tileR, dev.tileC, dev.dir, -1))], { ignoreSelect: true }) + : () => opsOn(battle, dev, keys()); + deviceAura(battle, dev, { key: `ironmn:aspd:${dev.id}`, select: reach, mods: () => ({ aspd }) }); + } + }; + if (dev.defId === PILE1) { + const atk = num(tokBb(def, 'atk').atk); + const scale = num(tb.talent_scale, num(b1.fake_scale, 1)); + return { + skill: null, + trait: { noAttack: true }, + talents: [], + install(battle) { + common(battle); + // 支援火力: ATK +atk on the operators of its 1-1 (×talent_scale while 白铁's S1 runs) + deviceAura(battle, dev, { key: `ironmn:pile1:${dev.id}`, select: () => opsOn(battle, dev, keys()), + sig: () => (s1On() ? 1 : 0), mods: () => ({ atkPct: atk * (s1On() ? scale : 1) }) }); + }, + }; + } + if (dev.defId === PILE2) { + const iDef = num(tokBb(def, 'default.interval')['default.interval'], 3.5); + const iS2 = num(tb['s2.interval'], num(b2.fake_interval, iDef)); + const hpDef = num(tokBb(def, 'hp_ratio').hp_ratio); + const hpS2 = num(tb['s2.hp_ratio'], hpDef * 2); + return { + skill: null, + trait: { noAttack: true }, + talents: [], + install(battle) { + common(battle); + const m = { mode: null, next: new Map(), bleedAt: Infinity }; + battle.on('deploy', (ctx) => { if (ctx.unit === dev) { m.mode = null; m.next.clear(); } }, { owner: dev }); + battle.on('tick', () => { + if (!up(dev)) return; + const mode = s2On() ? 'S2' : 'default'; + if (mode !== m.mode) { m.mode = mode; m.next.clear(); m.bleedAt = battle.time + 1; } // the buffs re-made on a mode switch + const every = mode === 'S2' ? iS2 : iDef; + // 战地补给: 1 SP every `every` s to each operator staying on its 1-1 + const now = opsOn(battle, dev, keys()); + for (const a of [...m.next.keys()]) if (!now.includes(a)) m.next.delete(a); + for (const a of now) { + if (!m.next.has(a)) { m.next.set(a, battle.time + every); continue; } + if (battle.time + 1e-9 < m.next.get(a)) continue; + m.next.set(a, m.next.get(a) + every); + if (giveSp(a, 1, 'device') > 0) battle.fx('spGift', { x: a.x, y: a.y, id: a.id, from: dev.id }); + } + // 每秒流失0.4%生命 (S2: ×2): a 流失, invulnerable or not + if (battle.time + 1e-9 >= m.bleedAt) { + m.bleedAt += 1; + const r = mode === 'S2' ? hpS2 : hpDef; + if (r > 0) battle.loseHp(dev, dev.s.maxHp * r, { source: dev, tags: ['device'] }); + } + }, { owner: dev }); + }, + }; + } + // 铁钳号·原型机 + const dr = num(tokBb(def, 'damage_resistance').damage_resistance); + const healRatio = num(def.traitBb?.hp_ratio, 0.01); + const atkScale = num(tb.atk_scale, 1), atkScale2 = num(tb.atk_scale_2, 0), loss = num(tb.hp_ratio); + const skillGrid = sk?.rangeGrid ?? grid; + return { + skill: { + kind: 'instant', + onStart({ battle, unit }) { + // 技能开启时自身流失2.5%的生命 (PURE DamageViaMaxHpRatio, `_skipModifierEvent`: a 流失 its trait does not cancel) + const atk = up(owner) ? owner.s.atk : 0; + const keysNow = absoluteRangeKeys(skillGrid, unit.tileR, unit.tileC, unit.dir, 0); + const list = battle.enemiesInKeys(keysNow, unit, { canHitFly: true }); + sortEnemyTargets(battle, unit, list, null); + const target = list[0] ?? null; + if (loss > 0) battle.loseHp(unit, unit.s.maxHp * loss, { source: unit, tags: ['device'] }); + if (!target || !(atk > 0)) return; + battle.addProjectile({ from: unit, target, speed: CRAB_SPEED, visual: 'bomb', source: unit, hitDead: true, + onHit: ({ target: t, x, y }) => { + if (t && t.alive) battle.dealDamage(unit, t, { amount: atk * atkScale, type: 'phys', isSkill: true, tags: ['summon', 'skill'] }); + const cx = t ? t.x : x, cy = t ? t.y : y; + if (atkScale2 > 0) { + for (const e of battle.foesInRadius(cx, cy, CRAB_SPLASH, true)) { + if (e === target || !e.alive) continue; + battle.dealDamage(unit, e, { amount: atk * atkScale2, type: 'phys', isSkill: true, tags: ['summon', 'skill'] }); + } + } + battle.fx('aoe', { x: cx, y: cy, radius: CRAB_SPLASH, id: unit.id, skill: 'ironmn:crab' }); + } }); + }, + }, + trait: { noAttack: true, canHitFly: true }, + talents: [], + install(battle) { + common(battle); + battle.on('deploy', (ctx) => { if (ctx.unit === dev) battle.setAllyTarget(dev, true); }, { owner: dev }); + battle.on('death', (ctx) => { if (ctx.unit === dev) battle.setAllyTarget(dev, false); }, { owner: dev, priority: 5 }); + // trait: every damage cancelled; 受击回复 +1 SP; a 工匠 operator's damage heals it hp_ratio of its max HP + battle.on('hit', (ctx) => { + if (ctx.target !== dev || !up(dev)) return; + ctx.dmg.cancel = true; + if (dev.skill && !dev.skill.active) dev.skill.gainSp(1, 'hurt'); + const src = ctx.source ?? ctx.credit; + if (src && src.kind === 'op' && src.def?.profession === 'SUPPORT' && src.profile?.sub === 'craftsman' && healRatio > 0) { + battle.heal(dev, dev, dev.s.maxHp * healRatio, { self: true, ignoreHealFree: true }); + } + }, { owner: dev, priority: 100 }); + battle.on('elementHit', (ctx) => { if (ctx.target === dev) ctx.dmg.cancel = true; }, { owner: dev }); // 元素免疫 + battle.on('beforeStatus', (ctx) => { if (ctx.target === dev && CRAB_IMMUNE.has(ctx.status)) ctx.cancel = true; }, { owner: dev }); + // 团结的力量: the operator on its tile / the one behind it — physical damage taken ×(1 − damage_resistance), per device + if (dr > 0) { + const behind = () => { + const [r, c] = frontOf(dev.tileR, dev.tileC, dev.dir, -1); + return opsOn(battle, dev, [dev.tileR * COLS + dev.tileC, r * COLS + c], { ignoreSelect: true }); + }; + deviceAura(battle, dev, { key: `ironmn:crab:${dev.id}`, select: behind, mods: () => ({ physTakenMul: 1 - dr }) }); + } + }, + }; + }; + + return { + skills: { + [S1]: { + kind: 'duration', + attack: { atkScale: num(b1['attack@atk_scale'], 1) }, + onStart({ unit }) { rechargeDevices(unit, 1); }, + onEnd({ battle, unit }) { + // 技能结束时所有场上的装置被销毁 (KillTokens) + for (const d of devicesUp(unit)) battle.kill(d, null); + }, + }, + [S2]: { + kind: 'duration', + mods: { atkPct: num(b2.atk), defPct: num(b2.def) }, + attack: { hitAllBlocked: true }, + onEnd({ unit }) { rechargeDevices(unit, 1); }, + }, + [S3]: { + kind: 'duration', + mods: { atkPct: num(b3.atk), aspd: num(b3.attack_speed) }, + onStart({ unit }) { rechargeDevices(unit, 1); }, + }, + }, + talents: [ + { install(battle, unit) { + // 战地工程师: his device pieces run the device kits (set up here, before the battle starts — as op-bgsnow.js); the + // stock refilled on each of his deployments; his devices leave with him (die_to_kill_token) + const st = deviceState(unit); + st.cap = cnt; + const prob = num(t1.prob), gain = num(t1.cnt, 1); + // 节约经费: a device of his leaving his x-4 ⇒ prob ⇒ +cnt + const onLeave = (dev) => { + if (!up(unit) || !absoluteRangeKeys(grid1, unit.tileR, unit.tileC, unit.dir, 0).includes(dev.tileR * COLS + dev.tileC)) return; + if (!(prob > 0) || !battle.rng.chance(prob)) return; + rechargeDevices(unit, gain); + battle.fx('spGift', { x: unit.x, y: unit.y, id: unit.id, from: dev.id }); + }; + for (const t of battle.allyUnits) { + if (t.kind !== 'token' || !DEVICES.has(t.defId) || t.ownerUnit !== unit || t.alive || t.deployed) continue; + if (t.kit) battle.offOwner(t); + battle._setupUnit(t, deviceKit(t, unit)); + deviceReturn(battle, t, unit, { onLeave }); + st.devices.add(t); + } + battle.on('deploy', (ctx) => { if (ctx.unit === unit) rechargeDevices(unit, cnt); }, { owner: unit }); + devicesLeaveWithOwner(battle, unit); + } }, + { install(battle, unit) { + // 节约经费 (the roll is the device lifecycle's, above); CRA-X stage 2+: SP +0.2/s while a device of his stands on + // his x-4 (one effect) + const spDef = [...deviceState(unit).devices].map((d) => num(tokBb(d.def, 'sp_recovery_per_sec').sp_recovery_per_sec)).find((v) => v > 0) ?? 0; + if (!(spDef > 0)) return; + const key = 'talent:ironmn:spRecover'; + battle.on('tick', () => { + const area = new Set(absoluteRangeKeys(grid1, unit.tileR, unit.tileC, unit.dir, 0)); + const want = up(unit) && devicesUp(unit).some((d) => area.has(d.tileR * COLS + d.tileC)); + const has = unit.findBuff(key); + if (want && !has) battle.addBuff(unit, { key, mods: { spRecoveryFlat: spDef }, tags: ['talent'] }); + else if (!want && has) battle.removeBuff(unit, key); + }, { owner: unit }); + } }, + ], + }; + }, +}; diff --git a/server/sim/content/kits/ops/op-nasti.js b/server/sim/content/kits/ops/op-nasti.js new file mode 100644 index 00000000..89cfc9db --- /dev/null +++ b/server/sim/content/kits/ops/op-nasti.js @@ -0,0 +1,423 @@ +// server/sim/content/kits/ops/op-nasti.js — 娜斯提 (char_4212_nasti) 自选 operator kit: 6★ 工匠 (辅助), an owned-6★ pick of the +// tier-5 and tier-6 自选 slots; every skill, both talents, the trait, the module CRA-X 工程师们 at every form, and the kits of her +// three <支援装置>: “质检专员” (token_10059_nasti_nstdef, S1), “监工专员” (token_10060_nasti_nstchr, S2) and “应急承重小组” +// (token_10061_nasti_nstbld, S3: the 小工程师 and the 高台 it builds). Kit contract and the 自选 rules: ../README.md ("How to +// add an operator (自选)"). The device stock / return / leave-with-the-owner rules are 白铁's (op-ironmn.js), shared. +// +// Forms (data/backups.json units.char_4212_nasti): normal = E2 Lv1, skills at rank 4, no module; elite = E2 Lv60, rank 7, the +// 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 / the token rows (zh_CN, as built into backups.json), PRTS +// 娜斯提 (前方施工 备注 "所有装置的效果均为对自身攻击范围内所有单位生效的光环效果", "自身<支援装置>提供的效果均可叠加", "首个 +// 装置部署费用-3;…若装置攻击范围内存在另一同来源同名的装置,将使用自身攻击范围+对方攻击范围形成复合范围"; 注意安全 备注 "远程敌人出现后, +// 此天赋额外效果在娜斯提退场前永久生效", "对敌人/干员的判定无视不可选中类效果/孤立", "“高台干员”指实际可部署于远程位的干员"; S2 备注 "此 +// 技能提供的屏障持续时间无限;自身退场时,清除此技能提供的所有屏障"; CRA-X: 质检 / 监工 −2, 应急承重小组 −3 部署费用), PRTS “质检专员” +// / “监工专员” / “应急承重小组” (备注: 常态持有禁疗、无敌 / 禁疗、静默; 监工 "生命值满/持有者技能开启期间,获得阻回、静默", 取缔无效流程 +// "生命回复速度属性不受此影响", skill "流失生命效果于获得技力/屏障效果后紧接着处理…会先提供技力和屏障再导致装置死亡"; 应急承重小组: the two +// forms, "前方地块为可部署近战位的低地地块…则在前方地块上召唤一个高台形态的此装置。高台形态的此装置不计入部署数量上限", "仅高台形态装置 +// 已激活,且生命值未满时…可对其进行治疗,仅成功治疗后流失生命值", 未激活 = 装置类 + 无敌 + 不可阻挡, 激活 = 默认类, 阻挡数+1, 高地 / +// 仅远程, 重叠部署; 第二次升级起 +1000 / +100 / +5; 最高级 阻回), PRTS 分支特性信息 工匠, PRTS 卫戍协议/帮助 (§战斗部署, §作战阶段 — +// as op-ironmn.js), and the client's battle data read from the local install — charpack char_4212_nasti (nasti_t_1 +// charge_token[born]; T2: PassiveTrigger nasti_t2[res] = damage_resistance[inf], AuraTrigger on enemies of applyWay RANGED +// (`_applyWay` 2, every level and motion) ⇒ AuraBuff: OwnerBuff nasti_t2[res_plus] (overrides [res]) + nasti_t2[add_sp], and +// AuraBuffCore on our operators (professionMask 639, `_buildableType` 2, ignoring 孤立) the same two; nasti_a[die_kill_token] +// KillTokens of modes 0–4 — a 3-times-upgraded 高台 (mode 5) stays; S1 / S2 / S3 modes), the token prefabs nstdef / nstchr / +// nstbld (range abilities with the CRA-X stage-3 SharedValidator: same token id, same host, host holding nasti_e[class_3_mark]; +// nstchr: EnsureBlood nasti_nstchr[blood_one], full_blood ⇒ 阻回, Special mode RecoverSp add_char_sp / update_shield / +// dec_blood; nstbld: BuildFactory create_m3 (Nstlv1Tile: buildableType melee, low ground, not tile type 16), m345_update on a +// full SP, M4–M6 modes `_keepCurrentPassableMask`), the skill prefabs skchr_nasti_1 / 2 / 3 and sktok_nasti_nstchr (add_hp +// +value, `_hpRatio` 1), the equip prefabs (trigger_charge_token +cnt at each deployment; stage 2+ nasti_e[first_token_cost_dec] +// UNTIL_NEXT_SPAWN; stage 3 nasti_e[class_3_mark]) and buff_template_data (the templates named here). +// +// - Trait (工匠) "能够阻挡两个敌人,使用<支援装置>协助作战": melee physical, 1-1, blocks 2, ground only — and the device rules of +// op-ironmn.js: the picked skill's hand pieces deploy with the board for free; her stock is cnt (3) + CRA-X's trait cnt (1) +// after each of her deployments, capped there; a device that left comes back on its tile after its redeploy time, paying +// its cost, spending one stock, while she stands; her leaving withdraws her devices (a level-3 高台 stays). +// - T1 前方施工 "可以携带3个<支援装置>(最多可部署2个)": that stock; 2 hand pieces (data deployLimit 2). Every device's effect is +// an aura on the operators of its range (1-1: its tile and the one it faces); her devices' effects stack (one buff per +// device). CRA-X stage 2+ "首个装置部署费用减少": after each of her deployments her next device deployment costs value (−3) +// less (UNTIL_NEXT_SPAWN — the battle-start deployment, free here, uses it up); stage 3 "装置可与攻击范围内其他装置共同提供效 +// 果": a device with another same-name device of hers on its range also reaches that device's range (one step). +// - T2 注意安全 "获得10%庇护;远程敌人出现后,改为使自身和场上的高台干员获得15%庇护,且每6秒获得1点技力": 庇护 (ba.protect, the +// shared applyStrongest key 'protect': physical and arts damage taken ×(1 − v), the strongest wins) nasti_t2[res] on her; +// once a 远程 enemy (applyWay RANGED — ALL too [ASSUMED: the 2 bit]) is on the field, untargetable or stealthed too, until she +// leaves: [res_plus] on her and on every operator of our side whose position is 远程 (RANGED / ALL — "实际可部署于远程位"), 孤立 +// ones too, and each of them +1 SP every interval s it holds it (the first one interval after; none at full SP or during a +// skill). +// - Module CRA-X “工程师们” "<支援装置>的持有上限+1且部署费用减少": stock +1, the data costs (2 / 2 / 3). +// - “质检专员” (S1): "使前方干员的防御力+25%" (Σpct); while her S1 runs: DEF +talent@def instead, block +talent@block_cnt, and +// it loses talent@hp_ratio of its max HP each second (流失; the first one 1 s into the skill). +// - “监工专员” (S2): max HP 8 (data), init_hp 3 at each deployment ("仅可通过自身技能补充生命"); its skill (AUTO, 6 SP over +// time) "开启技能时回复1点生命值" fires whenever its SP is full and its HP is not (阻回 while its HP is full or 娜斯提's skill +// runs — the client's full_blood / Special mode). Activated by her S2 (the devices on the field at the cast): every +// talent@interval s, the first 0.05 s in: each operator of its range +talent@sp SP (none during its skill) and a 屏障 of +// talent@shield_each_hp_rate × 娜斯提's max HP (one barrier per operator from her, filled up to talent@shield_max_hp_rate × +// her max HP; it lasts until used or until she leaves), then the device loses talent@hp HP; activated devices leave when +// her skill ends. +// - “应急承重小组” (S3): the 小工程师 (invulnerable, untargetable, 禁疗, 阻回) builds the 高台 on the tile it faces when it is +// deployed, if she has none and that tile is a free low melee tile (canStand melee, low); the 高台 (one per 娜斯提, outside the +// deploy limit) gains 1 SP / s of its own (the token's INCREASE_WITH_TIME) and each 小工程师 whose range holds it gives it +// m1_sp SP each second (m2_sp while her S3 runs) while it is not at its top level; a full SP (50) upgrades it (SP cleared): +// level 1 = activated (attackable, block 1), level 2 = +1000 max HP / +100 DEF / +5 RES (m5 attribute_up), level 3 = the +// m6 values instead + 阻回. A 小工程师 heals an activated 高台 of its range that is not at full HP hp_ratio of its max HP +// each second (×2 in her S3) and then loses 2 % of its own (hp_ratio × cnt — "仅成功治疗后流失生命值", 流失). Before level 1 the +// 高台 is invulnerable, untargetable and blocks nobody. The 高台's tile stays walkable (`_keepCurrentPassableMask`) and keeps +// its look of ground; the operator bonuses of a 高台 (m4 / m5 / m6_bonus: +ATK / +max HP for the operator deployed on it) +// never apply here — nobody deploys during a battle of this mode and the 高台 is only built on a free tile [ASSUMED: N/A]. +// - S1 “拱卫” (MANUAL, data DEFAULT): 38 / 40 s, ATK +atk, DEF +def, every enemy she blocks at once; +1 stock at the start. +// - S2 “执行” (MANUAL, DEFAULT): 4 s, 停止攻击; every attack@nasti_s2[update_shield].interval s a 屏障 of shield_each_hp_rate × +// her max HP on herself, up to shield_max_hp_rate × it (infinite, gone when she leaves); activates her 监工专员; +1 stock at +// its end. +// - S3 栖脚地 (MANUAL, DEFAULT): 15 s, ATK +atk, DEF +def; her 小工程师 work twice as hard; +1 stock at the start. +// [ASSUMED] (beyond op-ironmn.js's): the 监工专员 loses its 1 HP per pulse with or without an operator in range (dec_blood is a +// passive buff of the ability); the combined range one step deep; the 小工程师's 1 s heal / SP timer runs from its deployment. + +import { num, talentBb, traitBb, skillRec, up, giveSp } from '../shared/tier1.js'; +import { absoluteRangeKeys } from '../../../targeting.js'; +import { COLS } from '../../../constants.js'; +import { frontOf } from '../../../dir.js'; +import { deviceState, devicesUp, rechargeDevices, deviceAura, opsOn, deviceReturn, devicesLeaveWithOwner } from './op-ironmn.js'; + +const S1 = 'skchr_nasti_1'; +const S2 = 'skchr_nasti_2'; +const S3 = 'skchr_nasti_3'; +export const NSTDEF = 'token_10059_nasti_nstdef'; +export const NSTCHR = 'token_10060_nasti_nstchr'; +export const NSTBLD = 'token_10061_nasti_nstbld'; +const DEVICES = new Set([NSTDEF, NSTCHR, NSTBLD]); +/** A device's 1-1 when its def carries none. */ +const FRONT1 = Object.freeze([[0, 0], [0, 1]]); +/** 庇护 (ba.protect): one "同名效果取最高" effect per unit, whoever grants it (battle.applyStrongest; op-judge.js, op-zumama.js). */ +const PROTECT = 'protect'; +const protectMods = (v) => ({ physTakenMul: 1 - v, artsTakenMul: 1 - v }); +/** 注意安全's 庇护 refresh: a little longer than the check period, so it never lapses while it holds. */ +const T2_PERIOD = 0.25; +const PROTECT_HOLD = T2_PERIOD + 0.05; +/** 监工专员 Special mode: dec_blood `firstTriggerInterval` 0.05 s. */ +const CHR_FIRST = 0.05; +/** 小工程师 talent buffs: triggerInterval 1 s. */ +const BLD_EVERY = 1; +/** Enemies that count as 远程 (AuraTriggerCore `_applyWay` 2). */ +const RANGED_WAYS = new Set(['RANGED', 'ALL']); +/** Operators "实际可部署于远程位" (`_buildableType` 2). */ +const RANGED_POS = new Set(['RANGED', 'ALL']); + +const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; +const tokBb = (def, key) => (def?.talents ?? []).find((t) => t && t.bb && t.bb[key] != null)?.bb ?? {}; +const tileOf = (u) => u.tileR * COLS + u.tileC; +const ownKeys = (u) => absoluteRangeKeys(u.rangeGrid ?? u.def?.rangeGrid ?? FRONT1, u.tileR, u.tileC, u.dir, 0); +/** The barrier key of the shields her S2 devices give (one per operator from her: nasti_nstchr_m2[shield_core]). */ +const shieldKey = (owner) => `nasti:shield:${owner.id}`; +const SELF_SHIELD = 'nasti:s2shield'; + +export default { + char_4212_nasti: (bb, chess) => { + const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1); + const cnt = num(t0.cnt, 3) + num(traitBb(chess).cnt); + const firstCut = -num(t0.value); // CRA-X stage 2+: 首个装置部署费用 −3 + const combined = !!(chess?.module?.active && num(chess.module.level) >= 3); // stage 3: nasti_e[class_3_mark] + const resBase = num(t1['nasti_t2[res].damage_resistance']); + const resPlus = num(t1['nasti_t2[res_plus].damage_resistance'], resBase); + const spEvery = num(t1['nasti_t2[add_sp].interval']); + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + const s2Every = num(b2['attack@nasti_s2[update_shield].interval'], 0.5); + const s2Each = num(b2['attack@nasti_s2[update_shield].shield_each_hp_rate']); + const s2Max = num(b2['attack@nasti_s2[update_shield].shield_max_hp_rate']); + + const skillOn = (owner, id) => up(owner) && !!owner.skill?.active && owner.skill.id === id; + /** + * The tiles a device's effect reaches: its own range, and with CRA-X stage 3 the range of every same-name device of hers + * standing on it ("复合范围", one step). + */ + const reachOf = (battle, dev, owner) => { + const keys = new Set(ownKeys(dev)); + if (combined) { + for (const d of devicesUp(owner)) { + if (d === dev || d.defId !== dev.defId || !keys.has(tileOf(d))) continue; + for (const k of ownKeys(d)) keys.add(k); + } + } + return keys; + }; + + /** “质检专员”. */ + const defKit = (dev, owner) => { + const def = dev.def, tb = def.skill?.bb ?? {}; + const base = num(tokBb(def, 'def').def); + const s1Def = num(tb['talent@def'], num(b1['talent@def'], base)); + const s1Block = num(tb['talent@block_cnt']); + const s1Loss = num(tb['talent@hp_ratio']); + return { + skill: null, + trait: { noAttack: true }, + talents: [], + install(battle) { + battle.addBuff(dev, { key: 'nasti:device', flags: { untargetable: true, invulnerable: true, noHeal: true }, persist: true, allowDead: true }); + // 严禁偷工减料 / S1 mode: the operators of its range + deviceAura(battle, dev, { key: `nasti:nstdef:${dev.id}`, select: () => opsOn(battle, dev, reachOf(battle, dev, owner)), + sig: () => (skillOn(owner, S1) ? 1 : 0), + mods: () => (skillOn(owner, S1) ? { defPct: s1Def, ...(s1Block ? { blockCnt: s1Block } : {}) } : { defPct: base }) }); + // S1 mode: −talent@hp_ratio of its max HP each second (nasti_nstdef_t[self_damage], first after 1 s) + let next = Infinity, was = false; + battle.on('tick', () => { + const on = up(dev) && skillOn(owner, S1); + if (on && !was) next = battle.time + 1; + was = on; + if (!on || battle.time + 1e-9 < next) return; + next += 1; + if (s1Loss > 0) battle.loseHp(dev, dev.s.maxHp * s1Loss, { source: dev, tags: ['device'] }); + }, { owner: dev }); + }, + }; + }; + + /** “监工专员”. */ + const chrKit = (dev, owner) => { + const def = dev.def, tb = def.skill?.bb ?? {}; + const initHp = num(tokBb(def, 'init_hp').init_hp, 3); + const heal = num(tb.value, 1); + const sp = num(tb['talent@sp'], 1); + const each = num(tb['talent@shield_each_hp_rate'], num(b2['talent@shield_each_hp_rate'])); + const cap = num(tb['talent@shield_max_hp_rate'], num(b2['talent@shield_max_hp_rate'])); + const loss = num(tb['talent@hp'], 1); + return { + skill: { + kind: 'instant', trigger: 'SP_FULL', + onStart({ battle, unit }) { battle.heal(unit, unit, heal, { self: true, ignoreHealFree: true }); }, + }, + trait: { noAttack: true }, + talents: [], + install(battle) { + battle.addBuff(dev, { key: 'nasti:device', flags: { untargetable: true, invulnerable: true, noHeal: true }, persist: true, allowDead: true }); + // 取缔无效流程: deployed with init_hp of its (data) max HP + battle.on('deploy', (ctx) => { if (ctx.unit === dev) { dev.hp = Math.max(1, Math.min(dev.s.maxHp, initHp)); dev.mem.nastiActive = false; } }, { owner: dev }); + // 阻回 while its HP is full or 娜斯提's skill runs (full_blood; the Special mode) + battle.on('tick', () => { + if (!up(dev)) return; + const stop = dev.hp >= dev.s.maxHp - 1e-9 || (up(owner) && !!owner.skill?.active); + const has = dev.findBuff('nasti:nstchr:stop'); + if (stop && !has) battle.addBuff(dev, { key: 'nasti:nstchr:stop', flags: { noSp: true } }); + else if (!stop && has) battle.removeBuff(dev, 'nasti:nstchr:stop'); + }, { owner: dev, priority: 5 }); + // activated by her S2: pulses (SP + her barrier to the operators of its range), then −1 HP each + dev.mem.nastiPulse = (b) => { + if (!up(dev) || !dev.mem.nastiActive) return; + for (const a of opsOn(b, dev, reachOf(b, dev, owner))) { + if (giveSp(a, sp, 'device') > 0) b.fx('spGift', { x: a.x, y: a.y, id: a.id, from: dev.id }); + const hp = up(owner) ? owner.s.maxHp : 0; + const key = shieldKey(owner); + const cur = a.findBuff(key); + const have = cur ? cur.shield : 0; + const next = Math.min(hp * cap, have + hp * each); + if (next > have + 1e-9) { + if (cur) { cur.shield = next; a.markDirty(); } else b.addBuff(a, { key, shield: next, visible: true, source: owner, tags: ['skill'] }); + b.fx('shield', { x: a.x, y: a.y, id: a.id, from: dev.id }); + } + } + if (loss > 0) b.loseHp(dev, loss, { source: dev, tags: ['device'] }); + }; + }, + }; + }; + + /** “应急承重小组” — the 小工程师 (a hand piece). */ + const bldKit = (dev, owner) => { + const def = dev.def; + const tb0 = tokBb(def, 'm1_sp'); + const sp1 = num(tb0.m1_sp, 1), sp2 = num(tb0.m2_sp, 2 * sp1); + const heal1 = num(tb0['nasti_nstbld_m1[heal_m456].hp_ratio']), heal2 = num(tb0['nasti_nstbld_m2[heal_m456].hp_ratio'], 2 * heal1); + const cost1 = heal1 * num(tb0['nasti_nstbld_m1[heal_m456].cnt'], 1), cost2 = heal2 * num(tb0['nasti_nstbld_m2[heal_m456].cnt'], 1); + return { + skill: null, + // its heal of the 高台 ignores the 高台's 禁疗 ("_ignoreHealFree"): it heals through it (damage.js heal healThrough) + trait: { noAttack: true, healThrough: (h, t) => !!t && t.mem?.nastiPlatform === true && t.ownerUnit === owner }, + talents: [], + install(battle) { + battle.addBuff(dev, { key: 'nasti:device', flags: { untargetable: true, invulnerable: true, noHeal: true, noSp: true }, persist: true, allowDead: true }); + battle.on('deploy', (ctx) => { + if (ctx.unit !== dev || !up(owner)) return; + // 承重结构部署: the 高台 on the tile it faces, if she has none and the tile is a free low melee tile + const cur = owner.mem.nastiPlatform; + if (cur && (cur.alive || !cur.removed)) return; + const [r, c] = frontOf(dev.tileR, dev.tileC, dev.dir, 1); + if (!battle.grid.inRect(r, c) || battle.isReservedTile(r, c) || !battle.grid.canStand(r, c, { ranged: false }) || !battle.grid.isLow(r, c)) return; + const p = battle.spawnToken(owner, NSTBLD, r, c, { kit: platformKit(owner), dir: dev.dir, anySource: true }); + if (p) battle.fx('summon', { x: p.x, y: p.y, id: p.id, src: dev.id }); + }, { owner: dev }); + // every second: SP to the 高台 of its range below its top level; an activated, injured one healed (then −2 % own) + battle.every(BLD_EVERY, () => { + if (!up(dev)) return; + const p = owner.mem.nastiPlatform; + if (!p || !up(p) || !reachOf(battle, dev, owner).has(tileOf(p))) return; + const m2 = skillOn(owner, S3); + const lvl = p.mem.nastiLevel ?? 0; + if (lvl < 3 && p.skill && p.skill.sp < p.skill.spCost) p.skill.gainSp(m2 ? sp2 : sp1, 'device'); + if (lvl >= 1 && p.hp < p.s.maxHp - 1e-6) { + const healed = battle.heal(dev, p, p.s.maxHp * (m2 ? heal2 : heal1), { ignoreHealFree: true }); + if (healed > 0) battle.loseHp(dev, dev.s.maxHp * (m2 ? cost2 : cost1), { source: dev, tags: ['device'] }); + } + }, { owner: dev }); + }, + }; + }; + + /** The 高台 (a spawned unit, no hand piece): levels 0 (未激活) → 3. */ + const platformKit = (owner) => { + const levelUp = (battle, p) => { + const lvl = Math.min(3, (p.mem.nastiLevel ?? 0) + 1); + p.mem.nastiLevel = lvl; + const tb = p.def.skill?.bb ?? {}; + if (lvl === 1) { + battle.removeBuff(p, 'nasti:platform:inert'); + battle.addBuff(p, { key: 'nasti:platform:block', mods: { blockCnt: 1 }, persist: true, allowDead: true }); + } else { + const m = lvl === 2 ? 'm5' : 'm6'; + battle.addBuff(p, { key: 'nasti:platform:attr', persist: true, allowDead: true, mods: { + hpFlat: num(tb[`attack@nasti_nstbld_${m}[attribute_up].max_hp`]), defFlat: num(tb[`attack@nasti_nstbld_${m}[attribute_up].def`]), + resFlat: num(tb[`attack@nasti_nstbld_${m}[attribute_up].magic_resistance`]) } }); + if (lvl === 3) battle.addBuff(p, { key: 'nasti:platform:top', flags: { noSp: true }, persist: true, allowDead: true }); + } + battle.fx('grow', { x: p.x, y: p.y, id: p.id, n: lvl }); + }; + return { + skill: { kind: 'instant', trigger: 'SP_FULL', onStart({ battle, unit }) { levelUp(battle, unit); } }, + trait: { noAttack: true }, + talents: [], + install(battle, p) { + p.mem.nastiPlatform = true; + p.mem.nastiLevel = 0; + owner.mem.nastiPlatform = p; + // the 小工程师's "不会受到攻击" (the token text, Battle._setupUnit) is not the 高台's: untargetable only until activated + battle.removeBuff(p, 'trait:untargetable'); + battle.addBuff(p, { key: 'nasti:platform', flags: { noHeal: true }, persist: true, allowDead: true }); // 常态持有禁疗 + battle.addBuff(p, { key: 'nasti:platform:inert', flags: { untargetable: true, invulnerable: true }, persist: true, allowDead: true }); + battle.on('death', (ctx) => { if (ctx.unit === p && owner.mem.nastiPlatform === p) owner.mem.nastiPlatform = null; }, { owner: p }); + }, + }; + }; + + const kitOf = (dev, owner) => (dev.defId === NSTDEF ? defKit(dev, owner) : dev.defId === NSTCHR ? chrKit(dev, owner) : bldKit(dev, owner)); + + return { + skills: { + [S1]: { + kind: 'duration', + mods: { atkPct: num(b1.atk), defPct: num(b1.def) }, + attack: { hitAllBlocked: true }, + onStart({ unit }) { rechargeDevices(unit, 1); }, + }, + [S2]: { + kind: 'duration', + attack: { noAttack: true }, + onStart({ battle, unit }) { + const m = unit.mem; + m.nastiS2 = { n: 0, at: battle.time }; + // 立即激活场上装置: the 监工专员 on the field now + const chr = devicesUp(unit).filter((d) => d.defId === NSTCHR); + for (const d of chr) d.mem.nastiActive = true; + const seq = unit.skill.activations; + const shield = () => { + const s = m.nastiS2; + if (!s) return; + s.n++; + if (!up(unit)) return; + const cap = unit.s.maxHp * s2Max; + const cur = unit.findBuff(SELF_SHIELD); + const have = cur ? cur.shield : 0; + const next = Math.min(cap, have + unit.s.maxHp * s2Each); + if (next > have + 1e-9) { + if (cur) { cur.shield = next; unit.markDirty(); } else battle.addBuff(unit, { key: SELF_SHIELD, shield: next, visible: true, tags: ['skill'] }); + battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id }); + } + }; + m.nastiS2.shield = shield; + battle.every(s2Every, (b, sc) => { + if (!unit.skill.active || unit.skill.activations !== seq || unit.skill.id !== S2) { sc.cancel(); return; } + shield(); + }, { owner: unit }); + // the devices' pulses: CHR_FIRST s in, then every talent@interval + const ev = chr.length ? num(chr[0].def.skill?.bb?.['talent@interval'], 0.5) : 0.5; + const pulse = () => { for (const d of chr) if (d.mem.nastiPulse) d.mem.nastiPulse(battle); }; + battle.after(CHR_FIRST, () => { + if (!unit.skill.active || unit.skill.activations !== seq) return; + pulse(); + battle.every(ev, (b, sc) => { + if (!unit.skill.active || unit.skill.activations !== seq) { sc.cancel(); return; } + pulse(); + }, { owner: unit }); + }, { owner: unit }); + }, + onEnd({ battle, unit, skill }) { + const s = unit.mem.nastiS2; + // the last shield tick that lands with the end (every interval of the duration) + if (s && s.shield && up(unit)) while (s.n < Math.floor(skill.duration / s2Every + 1e-9)) s.shield(); + unit.mem.nastiS2 = null; + // 激活的装置在娜斯提技能结束时离场 + for (const d of devicesUp(unit)) if (d.defId === NSTCHR && d.mem.nastiActive) battle.kill(d, null); + rechargeDevices(unit, 1); + }, + }, + [S3]: { + kind: 'duration', + mods: { atkPct: num(b3.atk), defPct: num(b3.def) }, + onStart({ unit }) { rechargeDevices(unit, 1); }, + }, + }, + talents: [ + { install(battle, unit) { + // 前方施工: her device pieces run their kits; the stock; CRA-X stage 2+ the first device after each deployment −3 DP + const st = deviceState(unit); + st.cap = cnt; + for (const t of battle.allyUnits) { + if (t.kind !== 'token' || !DEVICES.has(t.defId) || t.ownerUnit !== unit || t.alive || t.deployed) continue; + if (t.kit) battle.offOwner(t); + battle._setupUnit(t, kitOf(t, unit)); + deviceReturn(battle, t, unit, { + costOf: (d) => Math.max(0, num(d.base.cost) - (unit.mem.nastiFirst ? firstCut : 0)), + }); + battle.on('deploy', (ctx) => { if (ctx.unit === t) unit.mem.nastiFirst = false; }, { owner: t, priority: -20 }); + st.devices.add(t); + } + battle.on('deploy', (ctx) => { + if (ctx.unit !== unit) return; + rechargeDevices(unit, cnt); + unit.mem.nastiFirst = firstCut > 0; + }, { owner: unit }); + devicesLeaveWithOwner(battle, unit); + // she leaves: her S2 barriers go (nasti_s2[finish_shield]); a 高台 below level 3 goes with her (KillTokens modes 0–4) + battle.on('death', (ctx) => { + if (ctx.unit !== unit) return; + for (const a of battle.allyUnits) if (a.findBuff(shieldKey(unit))) battle.removeBuff(a, shieldKey(unit)); + battle.removeBuff(unit, SELF_SHIELD); + const p = unit.mem.nastiPlatform; + if (p && up(p) && (p.mem.nastiLevel ?? 0) < 3) battle.kill(p, null); + }, { owner: unit }); + } }, + { install(battle, unit) { + // 注意安全: 庇护 res; once a 远程 enemy is on the field (until she leaves): res_plus on her and our 远程 operators, +1 SP + // every interval s + let triggered = false; + const timers = new Map(); + battle.on('deploy', (ctx) => { if (ctx.unit === unit) { triggered = false; timers.clear(); } }, { owner: unit }); + battle.every(T2_PERIOD, () => { + if (!up(unit)) { triggered = false; timers.clear(); return; } + if (!triggered) triggered = battle.enemies.some((e) => e.alive && !e.hidden && RANGED_WAYS.has(String(e.def?.applyWay ?? '').toUpperCase())); + if (!triggered) { + if (resBase > 0) battle.applyStrongest(unit, PROTECT, { duration: PROTECT_HOLD, value: resBase, mods: protectMods, source: unit }); + return; + } + const who = [unit, ...battle.allyUnits.filter((a) => a !== unit && up(a) && !a.hidden && a.kind === 'op' && RANGED_POS.has(String(a.def?.position ?? '').toUpperCase()))]; + for (const a of [...timers.keys()]) if (!who.includes(a)) timers.delete(a); + for (const a of who) { + if (resPlus > 0) battle.applyStrongest(a, PROTECT, { duration: PROTECT_HOLD, value: resPlus, mods: protectMods, source: unit }); + if (!(spEvery > 0)) continue; + if (!timers.has(a)) { timers.set(a, battle.time + spEvery); continue; } + if (battle.time + 1e-9 < timers.get(a)) continue; + timers.set(a, timers.get(a) + spEvery); + if (a.skill && a.skill.sp < a.skill.spCost && giveSp(a, 1, 'talent') > 0) battle.fx('spGift', { x: a.x, y: a.y, id: a.id, from: unit.id }); + } + }, { owner: unit, immediate: true }); + } }, + ], + }; + }, +}; diff --git a/server/sim/content/kits/ops/op-necras.js b/server/sim/content/kits/ops/op-necras.js new file mode 100644 index 00000000..af42e4e2 --- /dev/null +++ b/server/sim/content/kits/ops/op-necras.js @@ -0,0 +1,396 @@ +// server/sim/content/kits/ops/op-necras.js — 死芒 (char_450_necras) 自选 operator kit: 6★ 塑灵术师 (术师), an owned-6★ pick of the +// tier-5 and tier-6 自选 slots; every skill, both talents, the trait, the module SOC-X 未处理的遗产 at every form, and the kit of +// her summon 悲叹的仆役 (token_10043_necras_skeltn) and its special form. Kit contract and the 自选 rules: ../README.md ("How to +// add an operator (自选)"). +// +// Forms (data/backups.json units.char_450_necras): normal = E2 Lv1, skills at rank 4, no module; elite = E2 Lv60, rank 7, the +// 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 / the token row (zh_CN, as built into backups.json), PRTS 死芒 +// (复燃 备注 "天赋无视敌人隐匿效果。优先在被击倒敌人所在地块进行召唤,此地块不可部署或非通过击杀触发此天赋时于攻击范围内随机可部署地块 +// (优先存在敌人的)进行召唤,召唤物的朝向与死芒保持一致", "无法再次召唤时尝试升级召唤物(无视孤立):优先选择未升级的>生命值最低>仇恨值 +// 最高的召唤物,令其升级(若未升级)并治疗其100%生命值(无视禁疗…不受治疗效果增减影响)"; the module page; S1 备注 "重新召唤以第一天赋的 +// 逻辑进行。每个被移除的已升级的召唤物提供2次召唤;重新召唤每间隔0.1s进行一次", "被动伤害范围半径1.5(中点判定),可对空,伤害来源为死芒 +// 自身"; S2 备注 "额外召唤以第一天赋的逻辑进行", "可被眩晕/冰冻/沉默效果打断;目标被击倒/离地,或进入消失状态时中断与该目标的链接;所有链接 +// 全部中断时立刻中止技能"; S3 备注 "场上不存在特殊召唤物时,下一个生成的召唤物会变为特殊召唤物…最大生命值+200%,攻击力+200%,防御力+100%, +// 法术抗性+10,攻击间隔+0.7,阻挡数+1", "特殊召唤物每次升级时:最大生命值+80%,攻击力+80%,防御力+40%;第3/6次升级时额外获得:获得阻挡数 +// +1,技能攻击距离+1", "攻击范围扩大并进行技能描述的普通攻击(触发攻击无需目标),直至达到重复上限后停止技能", "可以消耗普通召唤物时,消耗一个 +// 普通召唤物进行治疗(必定治疗且无视禁疗)…消耗已升级的普通召唤物时该流程会进行2次"), PRTS 悲叹的仆役 (备注: 持有禁疗; "自身不是特殊形态 +// 召唤物时,持有阻回、沉默,且自身的技力自然回复速度属性强制归0"; "普通攻击造成法术伤害,可以且优先攻击自身阻挡的单位"; 怨火缠身 "仅持有 +// 者携带技能冠死以冕时,自身才会携带此技能"), PRTS 分支特性信息 塑灵术师 ("可对空", "干员离场时,其附属的召唤物会随之退场"), and the client's +// battle data read from the local install — charpack char_450_necras (talent 1 necras_t_1 on the enemies of her range, +// stealth ignored; NecrasTokenSelector: melee low buildable tiles of her range, enemy tiles first, random; SpawnToken = +// [TrySpawnToken, UpgradeToken], UpgradeToken = [TryUpgradeToken (a token without necras_t_1[upgrade], lowest HP first), HealToken +// (a token that is no special form)]; talent 2 necras_t_2 on her and her tokens; talent 3 necras_t_3 (S1 only) and talent 4 +// necras_t_4 (S3 only) on her tokens; UpgradeDragon; the S3 mode attack with `_allowNoTarget` and necras_s_3[before_attack] / +// [after_attack]), the token prefab token_10043_necras_skeltn (skeltn_block_skill: 沉默 + 阻回 + SP_RECOVERY 0; skeltn_heal_free +// 禁疗; Default / Dragon / Skill modes; the special form holds necras_t_1[upgrade]), the skill prefabs skchr_necras_1 / 2 / 3 and +// sktok_necras_skeltn_3, projectile_chr_necras_s2 (`_getLifeTimeFromBB` hit_duration, `_isSilenceable`, `_dontHitInvincibleTarget`, +// `_detachAllBuffsWhenStopped`), the equip prefabs (necras_e_002_tr; stage 3 necras_e_002_t on the enemies of her range and +// necras_e_002_t[token] on those of her tokens' ranges) and buff_template_data (necras_t_1 / [spawn_token] / [upgrade], +// necras_t_2 DamageScale, necras_t_3 / [damage], necras_t_4 / [dragon], necras_s_1 / [fallback], necras_s_2 / [damage_sleep], +// necras_s_3 / [after_attack], necras_upgrade_dragon, necras_e_002_tr AtkScaleUp, necras_e_002_t / [token]). +// +// - Trait (塑灵术师) "攻击造成法术伤害,可以通过击倒敌人生成召唤物,可攻击到自身召唤物阻挡的敌人": ranged arts, 3-1, hits air; the tiles of +// the enemies her 悲叹的仆役 block are her targets too (Battle.setExtraRange, re-read every tick). SOC-X adds "且攻击力提升至115%" +// (trait bb atk_scale; necras_e_002_tr AtkScaleUp): her damage on an enemy one of her summons blocks × atk_scale. +// - T1 复燃 "攻击范围内敌人被击倒时生成一个悲叹的仆役,最多召唤3个,若无法再次召唤则使一个悲叹的仆役升级(提升阻挡数与更多的生命、攻击和防御)": +// an enemy knocked out on her attack range (by anyone; stealth ignored) queues one summon on its tile; queued summons are +// made one every 0.1 s: with fewer than max_token_cnt of hers on the field, a 悲叹的仆役 on the hinted tile when it is a free, +// low melee tile of her range, else on a random such tile of her range (those with an enemy first), facing as she does — +// no tile ⇒ the upgrade instead; at the cap: the upgrade — a 悲叹的仆役 not yet upgraded (lowest HP, then the highest 仇恨值) +// gets max_hp / atk / def (Σpct) and block_cnt, every one already upgraded: the lowest-HP one (not the special form) — and is +// healed to full (无视禁疗). SOC-X stage 2+ (the module's own talent part, read apart from the base talent whose `atk` it +// shares): "自身和召唤物攻击范围内敌人被击倒时在10秒内自身攻击力+25%,并在攻击范围内生成一个悲叹的仆役": a knock-out on her range +// or on one of her summons' ranges ⇒ her ATK +atk for atk_duration s (refreshed), and one on a summon's range only queues a +// summon with no tile (a random tile of her range). +// - T2 回光黯淡 "自身和召唤物攻击生命低于50%的敌人时攻击力提升至140%": every damage she or her summons deal to an enemy below +// hp_ratio HP × damage_scale (necras_t_2 DamageScale: the final damage). +// - 悲叹的仆役: the token's stats for her form; arts melee, x-5, blocks 1 (upgraded 2), 禁疗; the normal form holds 阻回 / 沉默 (no +// skill). Her leaving withdraws them; each is withdrawn when she leaves (WithdrawTokens). +// - S1 噩愿 (MANUAL, data DEFAULT): passive — each 悲叹的仆役 made or upgraded (the T1 upgrade) strikes every enemy within +// range_radius (中点判定, air units too) for her ATK × atk_scale arts (source: her); active — every 悲叹的仆役 of hers is +// withdrawn and queues 1 summon (2 for an upgraded one); none ⇒ 1. +// - S2 折朽 (MANUAL, DEFAULT): 12 s; up to max_target enemies of her range are linked: 沉睡 while linked and every interval s her +// ATK × atk_scale arts on each (through the sleep: necras_s_2[damage_sleep]); she makes no attack meanwhile. A link breaks when +// its target is knocked out, airborne (浮空 / a flyer's ascent) or gone (hidden, removed); the skill ends when none is left, +// or when she is stunned, frozen or silenced. A linked target knocked out queues additional_token_cnt summons (no tile). +// - S3 冠死以冕 (MANUAL, data SKILL_RANGE on its 3-3): passive — when she has no special form, the next 悲叹的仆役 made becomes it +// (her hidden talent: +200 % max HP / ATK, +100 % DEF, +10 RES, +0.7 s attack interval, block +1), which holds 怨火缠身 +// (AUTO, 15 SP over time, 8 s: every second its ATK × attack@atk_scale arts on every enemy of its 1-1, the first at the cast +// [ASSUMED]) and is upgraded only by this skill (≤ attack@max_stack_cnt): +attack@max_hp / atk / def (Σpct per upgrade), healed +// attack@hp_ratio of its max HP; the 3rd and 6th add block +attack@block_cnt and 攻击距离 +attack@ability_range_forward_extend. +// Active — her range is the 3-3 while it runs; two "attacks" at her attack interval (the first at the cast; no target needed): +// each strikes every enemy of the 3-3 for ATK × attack@atk_scale arts, then consumes her earliest normal 悲叹的仆役 (withdrawn) +// and upgrades the special form once — twice (0.1 s apart) when the consumed one was upgraded. +// [ASSUMED] the earliest-made normal summon is the one consumed (PRTS: 实体栈 order, unpredictable); the special form's 攻击距离 +// widens its x-5 too (the attribute ABILITY_RANGE_FORWARD_EXTEND); the S3 strikes are skill damage (no normal-attack procs). + +import { num, talentBb, traitBb, skillRec, moduleOn, up, batMod } from '../shared/tier1.js'; +import { absoluteRangeKeys, sortEnemyTargets, aggroCmp } from '../../../targeting.js'; +import { bodyInKeys } from '../../../body.js'; +import { COLS } from '../../../constants.js'; + +const S1 = 'skchr_necras_1'; +const S2 = 'skchr_necras_2'; +const S3 = 'skchr_necras_3'; +export const SKELETON = 'token_10043_necras_skeltn'; +/** The summon queue: one summon / upgrade every 0.1 s (necras_t_1[spawn_token] interval). */ +const QUEUE_EVERY = 0.1; +/** UpgradeDragon twice for an upgraded summon: the second 0.1 s later (necras_s_3[delay_upgrade]). */ +const DELAY_UPGRADE = 0.1; +const UPGRADE_KEY = 'necras:upgrade'; +const DRAGON_KEY = 'necras:dragon'; +const DRAGON_UP_KEY = 'necras:dragonUp'; + +const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; +const tileKey = (r, c) => r * COLS + c; +/** 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; +} +/** Her state for the battle. */ +const stateOf = (unit) => unit.mem.necras ?? (unit.mem.necras = { queue: [], skels: [], dragon: null, timer: null }); +const skelsUp = (unit) => stateOf(unit).skels.filter((s) => up(s)); +/** Her own attack range (no extra keys): the live grid behind her rangeKeys. */ +const ownRange = (unit) => new Set(absoluteRangeKeys(unit.liveRangeGrid ?? unit.rangeGrid ?? [[0, 0]], unit.tileR, unit.tileC, unit.dir, 0)); + +export default { + char_450_necras: (bb, chess) => { + const base0 = talentBb({ talents: chess?.talentsBase ?? chess?.talents }, 0); // 复燃 without the module part + const mod = activeModule(chess); + const modT0 = (mod?.talentChanges ?? []).filter((t) => t && t.talentIndex === 0).reduce((o, t) => Object.assign(o, t.bb ?? {}), {}); + const t1 = talentBb(chess, 1); + const dragonBb = (chess?.talents ?? []).find((t) => t && t.bb && t.bb.base_attack_time != null)?.bb ?? {}; + const maxCnt = num(base0.max_token_cnt, 3); + const up0 = { hpPct: num(base0.max_hp), atkPct: num(base0.atk), defPct: num(base0.def), blockCnt: num(base0.block_cnt) }; + const killAtk = num(modT0.atk), killDur = num(modT0.atk_duration); + const lowHp = num(t1.hp_ratio), lowMul = num(t1.damage_scale, 1); + const traitScale = num(traitBb(chess).atk_scale, 1); + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + const s3 = skillRec(chess, S3); + + /** Free low melee tiles of her range, those with an enemy first (NecrasTokenSelector). */ + const spawnTiles = (battle, unit) => { + const keys = ownRange(unit); + const free = [], withEnemy = []; + for (const k of keys) { + const r = Math.floor(k / COLS), c = k % COLS; + if (!battle.grid.inRect(r, c) || battle.isReservedTile(r, c) || !battle.grid.canStand(r, c, { ranged: false })) continue; + free.push([r, c]); + if (battle.enemies.some((e) => e.alive && !e.hidden && Math.round(e.y) === r && Math.round(e.x) === c)) withEnemy.push([r, c]); + } + return withEnemy.length ? withEnemy : free; + }; + const tileOk = (battle, unit, r, c) => Number.isInteger(r) && Number.isInteger(c) && battle.grid.inRect(r, c) + && ownRange(unit).has(tileKey(r, c)) && !battle.isReservedTile(r, c) && battle.grid.canStand(r, c, { ranged: false }); + + /** S1's passive strike around a summon made or upgraded. */ + const s1Strike = (battle, unit, s) => { + if (unit.skill?.id !== S1 || !up(unit)) return; + const scale = num(b1.atk_scale), rad = num(b1.range_radius, 1.5); + if (!(scale > 0)) return; + for (const e of battle.foesInRadius(s.x, s.y, rad, true)) { + if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', isSkill: true, tags: ['skill', 'necras:s1'] }); + } + battle.fx('aoe', { x: s.x, y: s.y, radius: rad, id: unit.id, skill: 'necras:s1' }); + }; + + /** The T1 upgrade (or, every summon upgraded, the heal of the lowest one) — never the special form. */ + const upgradeOne = (battle, unit) => { + const list = skelsUp(unit).filter((s) => !s.mem.necrasDragon); + if (!list.length) return false; + const byHp = (a, b) => a.hp - b.hp || aggroCmp(a, b); + const fresh = list.filter((s) => !s.findBuff(UPGRADE_KEY)).sort(byHp); + const s = fresh[0] ?? list.slice().sort(byHp)[0]; + if (fresh.length) { + const mods = Object.fromEntries(Object.entries(up0).filter(([, v]) => v)); + battle.addBuff(s, { key: UPGRADE_KEY, mods, persist: true, allowDead: true, tags: ['talent'] }); + s1Strike(battle, unit, s); + } + battle.heal(s, s, s.s.maxHp, { self: true, ignoreHealFree: true }); + battle.fx('grow', { x: s.x, y: s.y, id: s.id }); + return true; + }; + + /** A new 悲叹的仆役 on [r, c]; the special form when S3 is carried and she has none. */ + const makeSkeleton = (battle, unit, r, c) => { + const s = battle.spawnToken(unit, SKELETON, r, c, { kit: skeletonKit(unit, battle.tokenDef(SKELETON, unit)), dir: unit.dir, anySource: true }); + if (!s) return null; + const st = stateOf(unit); + st.skels.push(s); + if (unit.skill?.id === S3 && !(st.dragon && up(st.dragon))) { + st.dragon = s; + s.mem.necrasDragon = true; + battle.addBuff(s, { key: DRAGON_KEY, persist: true, allowDead: true, tags: ['talent'], mods: { + hpPct: num(dragonBb.max_hp), atkPct: num(dragonBb.atk), defPct: num(dragonBb.def), resFlat: num(dragonBb.magic_resistance), + batPct: batMod(dragonBb.base_attack_time, { stats: s.def.stats }), blockCnt: num(dragonBb.block_cnt) } }); + battle.removeBuff(s, 'necras:normal'); + s.hp = s.s.maxHp; + } + battle.fx('summon', { x: s.x, y: s.y, id: s.id, src: unit.id }); + s1Strike(battle, unit, s); + return s; + }; + + /** Process the queue head (one summon / upgrade per QUEUE_EVERY). */ + const step = (battle, unit) => { + const st = stateOf(unit); + if (!up(unit)) { st.queue.length = 0; return; } + if (!st.queue.length) return; + const hint = st.queue.shift(); + if (skelsUp(unit).length < maxCnt) { + let tile = hint && tileOk(battle, unit, hint[0], hint[1]) ? hint : null; + if (!tile) { const opts = spawnTiles(battle, unit); tile = opts.length ? battle.rng.pick(opts) : null; } + if (tile && makeSkeleton(battle, unit, tile[0], tile[1])) return; + } + upgradeOne(battle, unit); + }; + /** Queue `n` summons (tile hint or null). */ + const queue = (battle, unit, n, hint = null) => { + const st = stateOf(unit); + for (let i = 0; i < n; i++) st.queue.push(hint); + if (st.timer) return; + st.timer = battle.every(QUEUE_EVERY, (b, sc) => { + if (!st.queue.length) { sc.cancel(); st.timer = null; return; } + step(b, unit); + }, { owner: unit }); + }; + + /** UpgradeDragon: one more upgrade of her special form (≤ max), healed attack@hp_ratio. */ + const upgradeDragon = (battle, unit) => { + const d = stateOf(unit).dragon; + if (!d || !up(d)) return; + const max = num(b3['attack@max_stack_cnt'], 6); + const n = Math.min(max, (d.mem.necrasUps ?? 0) + 1); + d.mem.necrasUps = n; + const third = Math.floor(n / 3); + battle.addBuff(d, { key: DRAGON_UP_KEY, persist: true, allowDead: true, tags: ['skill'], mods: { + hpPct: num(b3['attack@max_hp']) * n, atkPct: num(b3['attack@atk']) * n, defPct: num(b3['attack@def']) * n, + ...(num(b3['attack@magic_resistance']) ? { resFlat: num(b3['attack@magic_resistance']) * n } : {}), + ...(third ? { blockCnt: num(b3['attack@block_cnt'], 1) * third, rangeExtend: num(b3['attack@ability_range_forward_extend'], 1) * third } : {}) } }); + battle.heal(d, d, d.s.maxHp * num(b3['attack@hp_ratio']), { self: true, ignoreHealFree: true }); + battle.fx('grow', { x: d.x, y: d.y, id: d.id, n }); + }; + + /** 悲叹的仆役 kit (`def` = its token def for her loadout: 怨火缠身 only when she carries S3). */ + const skeletonKit = (owner, def) => { + const sk = def?.skill ?? null; + const sb = sk?.bb ?? {}; + const grid = sk?.rangeGrid ?? [[0, 0], [0, 1]]; + const scale = num(sb['attack@atk_scale']); + const dragonSkill = sk && sk.id === 'sktok_necras_skeltn_3' ? { + // 怨火缠身 (the special form only: the normal form holds 阻回 / 沉默): every second its ATK × attack@atk_scale arts on every + // enemy of its 1-1 (攻击距离 included), 8 s + kind: 'duration', + duration: num(sb['attack@duration'], num(sk.duration, 8)), + attack: { noAttack: true }, + onStart({ battle: b, unit: me }) { + const seq = me.skill.activations; + const hit = () => { + if (!me.skill.active || me.skill.activations !== seq || !up(me)) return false; + const keys = absoluteRangeKeys(grid, me.tileR, me.tileC, me.dir, me.s.rangeExtend || 0); + for (const e of b.enemiesInKeys(keys, me, { canHitFly: false })) b.dealDamage(me, e, { amount: me.s.atk * scale, type: 'arts', isSkill: true, tags: ['summon', 'skill'] }); + b.fx('aoe', { x: me.x, y: me.y, radius: 1, id: me.id, skill: 'necras:dragon' }); + return true; + }; + hit(); + b.every(1, (b2, sc) => { if (!hit()) sc.cancel(); }, { owner: me }); + }, + } : null; + return { + skill: dragonSkill, + trait: {}, + talents: [], + install(battle, me) { + battle.addBuff(me, { key: 'necras:healFree', flags: { noHeal: true }, persist: true, allowDead: true }); // 禁疗 + battle.addBuff(me, { key: 'necras:normal', flags: { noSp: true, silence: true }, persist: true, allowDead: true }); + // T2 回光黯淡: her summons' damage too + battle.on('hit', (ctx) => { + if (ctx.source !== me || !ctx.target || ctx.target.side !== 'enemy') return; + if (lowHp > 0 && lowMul !== 1 && ctx.target.hpRatio < lowHp) ctx.dmg.mul = (ctx.dmg.mul ?? 1) * lowMul; + }, { owner: me }); + }, + }; + }; + + return { + skills: { + [S1]: { + kind: 'instant', + onStart({ battle, unit }) { + // 立刻重新召唤所有悲叹的仆役: each withdrawn, 1 summon (2 if upgraded); none ⇒ 1 + const list = skelsUp(unit); + let n = 0; + for (const s of list) { + n += s.findBuff(UPGRADE_KEY) ? 2 : 1; + battle.retreat(s, { reason: 'expired', permanent: true }); + } + queue(battle, unit, n || 1, null); + }, + }, + [S2]: { + kind: 'duration', + attack: { noAttack: true }, + onStart({ battle, unit }) { + const max = Math.max(1, Math.floor(num(b2.max_target, 2))); + const list = battle.enemiesInKeys([...ownRange(unit)], unit, unit.profile); + sortEnemyTargets(battle, unit, list, unit.profile?.priority ?? null); + const links = list.slice(0, max).map((e) => ({ e, seq: e.deploySeq, flying: e.isFlying })); + const seq = unit.skill.activations; + const dur = unit.skill.timeLeft; + unit.mem.necrasLinks = links; + for (const l of links) { + if (battle.applyStatus(l.e, 'sleep', { duration: dur, source: unit })) battle.fx('sleep', { x: l.e.x, y: l.e.y, id: l.e.id }); + } + const broken = (l) => !l.e.alive || l.e.hidden || l.e.removed || l.e.deploySeq !== l.seq || (!l.flying && l.e.isFlying); + const scale = num(b2.atk_scale); + const every = num(b2.interval, 0.5); + battle.every(every, (b, sc) => { + if (!unit.skill.active || unit.skill.activations !== seq || unit.skill.id !== S2) { sc.cancel(); return; } + for (const l of links) if (!l.done && !broken(l)) b.dealDamage(unit, l.e, { amount: unit.s.atk * scale, type: 'arts', isSkill: true, ignoreSleep: true, tags: ['skill', 'necras:s2'] }); + }, { owner: unit }); + // the links: a broken one releases its target; none left (or her 晕眩 / 冰冻 / 沉默) ends the skill + battle.every(0.05, (b, sc) => { + if (!unit.skill.active || unit.skill.activations !== seq || unit.skill.id !== S2) { sc.cancel(); return; } + for (const l of links) { + if (l.done || !broken(l)) continue; + l.done = true; + if (l.e.alive && l.e.findBuff('sleep')) b.removeStatus(l.e, 'sleep'); + } + const f = unit.s.flags; + if (links.every((l) => l.done) || f.stun || f.freeze || f.silence) unit.skill.end('links'); + }, { owner: unit }); + }, + onEnd({ battle, unit }) { + for (const l of unit.mem.necrasLinks ?? []) { + if (!l.done && l.e.alive && l.e.findBuff('sleep')) battle.removeStatus(l.e, 'sleep'); + l.done = true; + } + unit.mem.necrasLinks = null; + }, + }, + [S3]: { + kind: 'duration', + duration: 999, + targeting: { rangeGrid: s3?.rangeGrid ?? null }, + attack: { noAttack: true }, + onStart({ battle, unit }) { + const seq = unit.skill.activations; + const strike = (k) => { + if (!unit.skill.active || unit.skill.activations !== seq || !up(unit)) return; + // a normal attack of the S3 mode: none while she cannot act (晕眩 / 冻结 …) — it waits + if (!unit.canAct) { battle.after(0.1, () => strike(k), { owner: unit }); return; } + const scale = num(b3['attack@atk_scale']); + for (const e of battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile)) { + battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', isSkill: true, tags: ['skill', 'necras:s3'] }); + } + battle.fx('aoe', { x: unit.x, y: unit.y, radius: 3, id: unit.id, skill: 'necras:s3' }); + // 消耗一个悲叹的仆役使特殊仆役升级 (twice for an upgraded one) + const prey = skelsUp(unit).filter((s) => !s.mem.necrasDragon).sort((a, b) => a.deploySeq - b.deploySeq)[0] ?? null; + if (prey) { + const twice = !!prey.findBuff(UPGRADE_KEY); + battle.retreat(prey, { reason: 'expired', permanent: true }); + upgradeDragon(battle, unit); + if (twice) battle.after(DELAY_UPGRADE, () => upgradeDragon(battle, unit), { owner: unit }); + } + const reps = Math.max(1, Math.floor(num(b3['attack@necras_s_3[attack_cnt].max_stack_cnt'], 2))); + if (k + 1 >= reps) { unit.skill.end('done'); return; } + battle.after(unit.s.interval, () => strike(k + 1), { owner: unit }); + }; + strike(0); + }, + }, + }, + talents: [ + { install(battle, unit) { + // 复燃 (+ SOC-X stage 2+): a knock-out on her range / on her summons' ranges + battle.on('death', (ctx) => { + const e = ctx.unit; + if (!e || e.side !== 'enemy' || ctx.reason !== 'killed' || !up(unit)) return; + const mine = bodyInKeys(e, ownRange(unit)); + const theirs = !mine && killAtk > 0 && skelsUp(unit).some((s) => bodyInKeys(e, new Set(s.rangeKeys ?? []))); + if (!mine && !theirs) return; + if (killAtk > 0 && killDur > 0) battle.addBuff(unit, { key: 'necras:killAtk', duration: killDur, mods: { atkPct: killAtk }, tags: ['talent'] }); + queue(battle, unit, 1, mine ? [Math.round(e.y), Math.round(e.x)] : null); + }, { owner: unit }); + // her summons leave with her (WithdrawTokens) + battle.on('death', (ctx) => { + if (ctx.unit !== unit) return; + const st = stateOf(unit); + st.queue.length = 0; + for (const s of skelsUp(unit)) battle.retreat(s, { reason: 'expired', permanent: true }); + }, { owner: unit }); + // S2: a linked target knocked out ⇒ additional_token_cnt summons + battle.on('death', (ctx) => { + if (ctx.reason !== 'killed' || !up(unit) || unit.skill?.id !== S2 || !unit.skill.active) return; + const l = (unit.mem.necrasLinks ?? []).find((x) => x.e === ctx.unit && !x.done); + if (!l) return; + l.done = true; + queue(battle, unit, Math.max(0, Math.floor(num(b2.additional_token_cnt, 2))), null); + }, { owner: unit, priority: -5 }); + } }, + { install(battle, unit) { + // 回光黯淡: her damage on an enemy below hp_ratio × damage_scale (final); SOC-X: × atk_scale on one her summon blocks + battle.on('hit', (ctx) => { + if (ctx.source !== unit || !ctx.target || ctx.target.side !== 'enemy') return; + if (traitScale !== 1 && ctx.target.blockedBy && stateOf(unit).skels.includes(ctx.target.blockedBy)) ctx.dmg.amount *= traitScale; + if (lowHp > 0 && lowMul !== 1 && ctx.target.hpRatio < lowHp) ctx.dmg.mul = (ctx.dmg.mul ?? 1) * lowMul; + }, { owner: unit }); + } }, + ], + install(battle, unit) { + // trait: the enemies her summons block are her targets too + battle.on('tick', () => { + if (!up(unit)) return; + const keys = []; + for (const s of skelsUp(unit)) for (const e of s.blocking) if (e.alive && e.blockedBy === s) keys.push(tileKey(Math.round(e.y), Math.round(e.x))); + const uniq = [...new Set(keys)].sort((a, b) => a - b); + const cur = unit.extraRangeKeys ?? []; + if (uniq.length === cur.length && uniq.every((k, i) => k === cur[i])) return; + battle.setExtraRange(unit, uniq.length ? uniq : null); + }, { owner: unit }); + }, + }; + }, +}; diff --git a/test/content/op_ironmn.test.js b/test/content/op_ironmn.test.js new file mode 100644 index 00000000..076185e9 --- /dev/null +++ b/test/content/op_ironmn.test.js @@ -0,0 +1,457 @@ +// test/content/op_ironmn.test.js — the 自选 operator kit of 白铁 (char_4072_ironmn, 6★ 工匠; kit +// server/sim/content/kits/ops/op-ironmn.js) and of his <支援装置> 白铁™多功能平台 (token_10027_ironmn_pile1 / pile2) and 铁钳号·原型机 +// (token_10027_ironmn_pile3), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy; the devices as the placed +// hand pieces of his player) in every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) +// with no module, CRA-X 铁钳号·爬行者 or CRA-Y 海布里印记 at stage 1 (tier 5) / 3 (tier 6). Numbers from data/backups.json; the +// fidelity checklist of kits/README.md (the engine's ally targets themselves: test/sim/feedback5-o23-engine.test.js). +// Run: node --test test/content/op_ironmn.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, diyRecordOf } 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 IRON = 'char_4072_ironmn'; +const P1 = 'token_10027_ironmn_pile1', P2 = 'token_10027_ironmn_pile2', CRAB = 'token_10027_ironmn_pile3'; +const TOK = [P1, P2, CRAB]; +const FORMS = BACKUPS.units[IRON].forms; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const X = 'uniequip_002_ironmn', Y = 'uniequip_003_ironmn'; +const S1 = 'skchr_ironmn_1', S2 = 'skchr_ironmn_2', S3 = 'skchr_ironmn_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); +/** A device's variant for a form, with the pick's skill / module (getDiyToken). */ +const tokOf = (id, tier, elite, skill, mod) => { + let v = BACKUPS.tokens[id].variants[`${IRON}@${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' }), + enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.6, mass: 0 }), + enemy_hit: dummy('enemy_hit', { atk: 1000, bat: 1, blockCnt: 1 }), +}; +const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, X, Y].map((m) => [t, true, m]))]; +const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`; + +/** + * 白铁 as uid 1 at (row, col) facing `dir`; `pieces` = his device pieces [[r, c, dir?]] (uids 10…, the picked skill's + * device); `others` = more units. + */ +function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, dir = 'RIGHT', pieces = [], others = [], seed = 5, dp = 99 } = {}) { + const units = [ + { uid: 1, diy: { slot: SLOT[tier], charId: IRON, skillIndex: skill, uniEquipId: mod }, elite, row, col, dir }, + ...pieces.map(([r, c, d], i) => ({ uid: 10 + i, kind: 'token', tokenId: TOK[skill], ownerUid: 1, row: r, col: c, dir: d ?? 'RIGHT' })), + ...others, + ]; + const h = makeBattle({ + defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, + flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'death', 'deploy', 'statusApplied', 'spGain', 'heal'], captureNoisy: true, + units, + }); + h.step(); + h.b.players[0].dp = dp; + const u = h.unit(1); + const devs = h.b.allyUnits.filter((a) => a.kind === 'token' && a.defId === TOK[skill]).sort((a, b) => a.uid - b.uid); + return { h, u, devs }; +} +const st = (u) => u.mem.craftsman; +function done(h) { + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); +} +/** Force every rng.chance roll to `v` (returns the probabilities asked for). */ +function forceChance(h, v) { + const asked = []; + h.b.rng.chance = (p) => { asked.push(p); return v; }; + return asked; +} + +test('白铁 in every 自选 form: his kit (all three skills authored), stats + module attributes, 1-1, blocks 2, melee physical ground-only, 维多利亚, no 特质, the data triggers', () => { + assert.equal(OPERATOR_KITS[IRON], KITS[IRON]); + 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], [IRON, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f)); + assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.aspd], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), form.stats.aspd + (m?.attr.aspd ?? 0)], `${label(f)}: stats`); + assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.profile.sub, u.base.bat], [2, 'melee', false, 'phys', 'craftsman', 1.5], `${label(f)}: 工匠`); + assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`); + assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['victoriaShip'], []], `${label(f)}: bonds / 特质`); + 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 him`); + done(h); + } + } + assert.deepEqual(formOf(6, true).skills.map((s) => s.trigger.rule), ['DEFAULT', 'DEFAULT', 'DEFAULT']); + assert.deepEqual([modOf(5, X).attr, modOf(6, X).attr, modOf(5, Y).attr, modOf(6, Y).attr], + [{ atk: 25, def: 25, aspd: 5 }, { atk: 45, def: 45, aspd: 5 }, { maxHp: 260, def: 45 }, { maxHp: 400, def: 60 }]); + const data = { chess: CHESS, backups: BACKUPS }; + assert.deepEqual(diyRecordOf(CHESS[SLOT[5]], { charId: IRON, skillIndex: 0 }, data).bonds, ['victoriaShip']); + assert.ok(KITTED_CHARS.includes(IRON)); + for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(IRON), `tier ${t}`); + assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: IRON, skillIndex: 2, uniEquipId: Y } }, { data, kitted: KITTED_CHARS }), + { ok: true, picks: { [SLOT[6]]: { charId: IRON, skillIndex: 2, uniEquipId: Y } } }); +}); + +test('<支援装置> hand pieces: the picked skill\'s device only (variant sources), deploy limit 1 in the data (official 1 + max_deploy_count 1 = 2: reported), the module costs / redeploy times', () => { + for (const [skill, id] of TOK.entries()) { + for (const s of [0, 1, 2]) assert.equal(tokOf(id, 6, true, s, null).sources.includes('skill'), s === skill, `${id} under S${s + 1}`); + const v = tokOf(id, 6, true, skill, null); + assert.equal(v.stats.deployLimit, 1, `${id}: the data's deploy limit`); + assert.equal(v.talents.find((t) => t.bb.max_deploy_count != null).bb.max_deploy_count, 1, `${id}: the hidden max_deploy_count (E2 "最多可部署2个")`); + } + // CRA-X 部署费用 −1 / −1 / −4, CRA-Y 再部署时间 −5 / −5 / −10 (PRTS module page) + const cost = (mod) => TOK.map((id, s) => tokOf(id, 6, true, s, mod).stats.cost); + const resp = (mod) => TOK.map((id, s) => tokOf(id, 6, true, s, mod).stats.respawnTime); + assert.deepEqual([cost(null), cost(X), cost(Y)], [[3, 3, 10], [2, 2, 6], [3, 3, 10]]); + assert.deepEqual([resp(null), resp(X), resp(Y)], [[10, 10, 20], [10, 10, 20], [5, 5, 10]]); +}); + +test('战地工程师 / the device rules: stock 3 (CRA-X 4) after his deployment; a device that left comes back on its tile after its redeploy time, paying its cost and one stock; none without stock or DP; his devices leave with him and return after him', () => { + for (const [tier, elite, mod] of [[5, false, null], [5, true, X], [6, true, Y]]) { + const f = label([tier, elite, mod]); + const { h, u, devs } = field({ tier, elite, mod, skill: 0, pieces: [[11, 7]], dp: 99 }); + const d = devs[0]; + const v = tokOf(P1, tier, elite, 0, mod); + const cap = 3 + (mod === X ? 1 : 0); + assert.deepEqual([st(u).stock, st(u).cap, d.alive, d.tileR, d.tileC], [cap, cap, true, 11, 7], `${f}: deployed for free with the board, stock ${cap}`); + assert.deepEqual([d.base.cost, d.base.respawnTime], [v.stats.cost, v.stats.respawnTime], `${f}: the variant's cost / redeploy time`); + assert.ok(d.s.flags.untargetable && d.s.flags.invulnerable && d.s.flags.noHeal, `${f}: 不会受到攻击, 无敌, 禁疗`); + forceChance(h, false); + h.b.kill(d, null); + assert.equal(d.alive, false); + h.run(v.stats.respawnTime - 0.3); + assert.equal(d.alive, false, `${f}: not before its redeploy time`); + h.run(0.6); + assert.deepEqual([d.alive, d.tileR, d.tileC, st(u).stock, h.b.players[0].dp], [true, 11, 7, cap - 1, 99 - v.stats.cost], `${f}: back on its tile, −${v.stats.cost} DP, −1 stock`); + // no stock ⇒ it waits + st(u).stock = 0; + h.b.kill(d, null); + h.run(v.stats.respawnTime + 2); + assert.equal(d.alive, false, `${f}: no stock, no return`); + st(u).stock = 1; + h.run(0.5); + assert.ok(d.alive, `${f}: back as soon as he holds one`); + // no DP ⇒ it waits + st(u).stock = 1; + h.b.players[0].dp = 0; + h.b.kill(d, null); + h.run(v.stats.respawnTime + 2); + assert.equal(d.alive, false, `${f}: no DP, no return`); + h.b.players[0].dp = 50; + h.run(0.5); + assert.ok(d.alive, `${f}: back once the DP is there`); + // he leaves ⇒ his devices go; his return refills the stock and brings them back + st(u).stock = 0; + h.b.kill(u, null); + assert.equal(d.alive, false, `${f}: gone with him (die_to_kill_token)`); + h.b.players[0].dp = 99; + assert.ok(h.runUntil(() => u.alive, 200), `${f}: he redeploys`); + assert.equal(st(u).stock, cap, `${f}: stock refilled (charge_token[born])`); + assert.ok(h.runUntil(() => d.alive, 30), `${f}: the device back after him`); + assert.equal(st(u).stock, cap - 1); + done(h); + } +}); + +test('节约经费: a device of his leaving his x-4 ⇒ prob (0.7; CRA-X stage 3 0.9) ⇒ +1 stock (capped); none when the roll fails, outside his x-4 or when it goes with him', () => { + for (const [tier, elite, mod, prob] of [[5, false, null, 0.7], [5, true, X, 0.7], [6, true, X, 0.9], [6, true, Y, 0.7]]) { + const f = label([tier, elite, mod]); + const { h, u, devs } = field({ tier, elite, mod, skill: 0, pieces: [[11, 6], [10, 8]], dp: 99 }); + const [near, far] = devs; + const cap = st(u).cap; + st(u).stock = 1; + let asked = forceChance(h, true); + h.b.kill(near, null); + assert.deepEqual([asked, st(u).stock], [[prob], 2], `${f}: on his x-4 — rolled ${prob}, +1`); + asked = forceChance(h, true); + h.b.kill(far, null); + assert.deepEqual([asked, st(u).stock], [[], 2], `${f}: two tiles away — no roll`); + h.run(25); + assert.ok(near.alive && far.alive); + st(u).stock = 1; + forceChance(h, false); + h.b.kill(near, null); + assert.equal(st(u).stock, 1, `${f}: a failed roll`); + h.run(15); + st(u).stock = cap; + forceChance(h, true); + h.b.kill(near, null); + assert.equal(st(u).stock, cap, `${f}: capped at ${cap}`); + h.run(15); + st(u).stock = 0; + asked = forceChance(h, true); + h.b.kill(u, null); + assert.deepEqual([asked, st(u).stock], [[], 0], `${f}: leaving with him — no roll`); + done(h); + } +}); + +test('S1 极致火力 + 白铁™多功能平台: ATK +12 % on the operator it faces (×2.5 / ×3 while S1 runs); S1 16 / 17 s, 140 % / 170 % ATK attacks, +1 stock at the start, every device destroyed at its end', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S1); + const v = tokOf(P1, tier, elite, 0, null); + const { h, u, devs } = field({ tier, elite, skill: 0, pieces: [[11, 4], [9, 8]], others: [{ uid: 3, chessId: 'chess_char_1_08_a', row: 11, col: 5 }] }); + const tx = h.unit(3); + const [d] = devs; + h.run(0.2); + assert.deepEqual([u.skill.rule, sk.duration, sk.bb['attack@atk_scale'], sk.bb.fake_scale, v.skill.bb.talent_scale, v.talents[0].bb.atk], ['DEFAULT', elite ? 17 : 16, elite ? 1.7 : 1.4, elite ? 3 : 2.5, elite ? 3 : 2.5, 0.12], `T${tier}`); + assert.deepEqual(tx.findBuff(`ironmn:pile1:${d.id}`)?.mods, { atkPct: 0.12 }, `T${tier}: 支援火力 on the operator it faces`); + approx(tx.s.atk, tx.base.atk * 1.12, `T${tier}: ATK +12 %`); + assert.equal(u.findBuff(`ironmn:pile1:${d.id}`), null, `T${tier}: not on 白铁 (not on its range)`); + st(u).stock = 1; + forceChance(h, false); + u.skill.gainSp(999); + const e = h.spawn('enemy_dummy', { pos: [10, 6] }); + assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast with an enemy in his range`); + assert.equal(st(u).stock, 2, `T${tier}: +1 stock at the start`); + approx(u.skill.timeLeft, sk.duration, `T${tier}: ${sk.duration} s`, 0.02); + h.step(2); + approx(tx.s.atk, tx.base.atk * (1 + 0.12 * sk.bb.fake_scale), `T${tier}: ×${sk.bb.fake_scale} while S1 runs`); + const n0 = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e).length; + h.run(3); + const hits = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e).slice(n0); + assert.ok(hits.length >= 1); + for (const c of hits) approx(c.amount, u.s.atk * sk.bb['attack@atk_scale'], `T${tier}: ${sk.bb['attack@atk_scale'] * 100} % ATK`); + const alive0 = devs.filter((x) => x.alive).length; + assert.equal(alive0, 2); + h.runUntil(() => !u.skill.active, sk.duration + 1); + h.step(); + assert.deepEqual(devs.map((x) => x.alive), [false, false], `T${tier}: 技能结束时所有场上的装置被销毁`); + assert.equal(tx.findBuff(`ironmn:pile1:${d.id}`), null, `T${tier}: its buff gone with it`); + assert.ok(h.runUntil(() => devs.every((x) => x.alive), 30), `T${tier}: both back (stock 2)`); + approx(tx.s.atk, tx.base.atk * 1.12, `T${tier}: ×1 after the skill`); + done(h); + } +}); + +test('S2 高效补给 + 白铁™多功能平台: +1 SP every 3.5 s to the operator it faces (2.5 / 2 s while S2 runs), the device loses 0.4 % (0.8 %) max HP / s; S2 30 s, ATK / DEF +25 / 40 %, every blocked enemy at once, +1 stock at its end', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S2); + const v = tokOf(P2, tier, elite, 1, null); + const { h, u, devs } = field({ tier, elite, skill: 1, pieces: [[11, 4]], others: [{ uid: 3, chessId: 'chess_char_1_02_a', row: 11, col: 5 }] }); + const yak = h.unit(3); + const [d] = devs; + assert.deepEqual([sk.duration, sk.bb.atk, sk.bb.def, v.talents[0].bb['default.interval'], v.skill.bb['s2.interval'], v.talents.find((t) => t.bb.hp_ratio).bb.hp_ratio, v.skill.bb['s2.hp_ratio']], + [30, elite ? 0.4 : 0.25, elite ? 0.4 : 0.25, 3.5, elite ? 2 : 2.5, 0.004, 0.008], `T${tier}`); + const gifts = () => h.hooksOf('spGain').filter((c) => c.unit === yak && c.reason === 'device').map((c) => c.t); + yak.skill.sp = 0; + h.run(11); + const g = gifts(); + assert.equal(g.length, 3, `T${tier}: 3 SP gifts in 11 s`); + approx(g[1] - g[0], 3.5, `T${tier}: every 3.5 s`, 0.02); + approx(d.hp, d.s.maxHp * (1 - 0.004 * Math.floor(h.b.time)), `T${tier}: 0.4 % / s`, 0.01); + // S2: two blocked enemies at once, ATK / DEF, interval and bleed + const a = h.spawn('enemy_dummy', { pos: [10, 6] }); + const b = h.spawn('enemy_walk', { routeIndex: 0 }); + void b; + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 3)); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`); + approx(u.s.def, u.base.def * (1 + sk.bb.def), `T${tier}: DEF`); + assert.deepEqual(u.skill.spec.attack, { hitAllBlocked: true }, `T${tier}: 攻击所有阻挡的敌人`); + const hp0 = d.hp, t0 = h.b.time; + yak.skill.sp = 0; + const n0 = gifts().length; + h.run(10.05); + const g2 = gifts().slice(n0); + approx(g2[1] - g2[0], v.skill.bb['s2.interval'], `T${tier}: every ${v.skill.bb['s2.interval']} s while S2 runs`, 0.02); + approx(hp0 - d.hp, d.s.maxHp * 0.008 * Math.floor(h.b.time - t0 + 1e-9), `T${tier}: 0.8 % / s while S2 runs`, 0.05); + void a; + st(u).stock = 1; + h.runUntil(() => !u.skill.active, 25); + assert.equal(st(u).stock, 2, `T${tier}: +1 stock at its end`); + done(h); + } + // S2 hits every enemy it blocks + const { h, u } = field({ tier: 6, elite: true, skill: 1, row: 9, col: 5 }); + h.spawn('enemy_walk', { routeIndex: 0 }); + h.run(0.4); + h.spawn('enemy_walk', { routeIndex: 0 }); + assert.ok(h.runUntil(() => u.blocking.length === 2, 40), 'two blocked'); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 3)); + const n0 = h.hooksOf('damaged').length; + h.run(4); + const ids = new Set(h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && c.dmg?.isAttack).map((c) => c.target.id)); + assert.equal(ids.size, 2, 'both blocked enemies hit'); + done(h); +}); + +test('S3 铁钳号·原型机: 30 s, ATK +25 / 40 %, ASPD +25 / 40, +1 stock at the start; the 铁钳号 is our attacks\' last target (an enemy first), takes no damage, +1 SP per hit, a 工匠\'s hit heals it 1 %; enemies never target it; immune to the PRTS statuses', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S3); + const v = tokOf(CRAB, tier, elite, 2, null); + // the crab faces 白铁's front tile; 德克萨斯 (UP) has it on her range + const { h, u, devs } = field({ tier, elite, skill: 2, pieces: [[10, 6]], others: [{ uid: 3, chessId: 'chess_char_1_08_a', row: 9, col: 6, dir: 'UP' }] }); + const tx = h.unit(3); + const [c] = devs; + assert.deepEqual([sk.duration, sk.bb.atk, sk.bb.attack_speed, v.skill.spCost, v.skill.spType, v.skill.bb.hp_ratio, v.trait.bb.hp_ratio, v.stats.tauntLevel], + [30, elite ? 0.4 : 0.25, elite ? 40 : 25, 5, 'INCREASE_WHEN_TAKEN_DAMAGE', 0.025, 0.01, -2], `T${tier}`); + assert.ok(h.b.isAllyTarget(c), `T${tier}: an ally target`); + assert.ok(c.s.flags.untargetable && c.s.flags.isolated && c.s.flags.noHeal && c.s.flags.noDisplace && !c.s.flags.invulnerable, `T${tier}: 不会受到攻击 (enemies), 孤立, 禁疗, 失衡免疫, not 无敌`); + h.run(3); + const hitsBy = (src) => h.hooksOf('damaged').filter((x) => x.target === c && x.source === src).length; + const atkOn = (src) => h.events.filter((e) => e[0] === 'atk' && e[1] === src.id && e[2] === c.id).length; + assert.ok(atkOn(u) > 0 && atkOn(tx) > 0, `T${tier}: 白铁 and 德克萨斯 attack it (no enemy around)`); + assert.equal(hitsBy(u) + hitsBy(tx), 0, `T${tier}: no damage reaches it`); + assert.equal(c.hp, c.s.maxHp); + assert.equal(c.skill.sp, 5, `T${tier}: 5 SP (full) after 3 s of hits — 受击回复`); + assert.ok(h.hooksOf('spGain').filter((x) => x.unit === c && x.reason === 'hurt').length >= 5, `T${tier}: one SP per hit`); + // an enemy in an attacker's range comes first (one on the crab's tile, in 白铁's range) + const e = h.spawn('enemy_dummy', { pos: [10, 6] }); + h.step(); + const a0 = atkOn(u); + h.run(3); + assert.ok(h.events.filter((x) => x[0] === 'atk' && x[1] === u.id && x[2] === e.id).length >= 1, `T${tier}: 白铁 attacks the enemy`); + assert.equal(atkOn(u), a0, `T${tier}: not the crab meanwhile`); + h.b.kill(e, null); + h.step(); + // heal from a 工匠's hit only + c.hp = c.s.maxHp * 0.5; + const healBy = () => h.hooksOf('heal').filter((x) => x.target === c).length; + const h0 = healBy(); + h.b.dealDamage(u, c, { amount: 100, type: 'phys', isAttack: true }); + approx(c.hp, c.s.maxHp * 0.51, `T${tier}: 白铁 heals it 1 %`); + h.b.dealDamage(tx, c, { amount: 100, type: 'phys', isAttack: true }); + approx(c.hp, c.s.maxHp * 0.51, `T${tier}: 德克萨斯 does not`); + assert.equal(healBy() - h0, 1); + // statuses PRTS lists, element, enemies' attacks + for (const s of ['stun', 'cold', 'freeze', 'levitate', 'bind', 'silence', 'sleep']) assert.equal(h.b.applyStatus(c, s, { duration: 3, source: e }), false, `T${tier}: ${s} immune`); + // S3 itself (an enemy in his range: the data's DEFAULT) + h.spawn('enemy_dummy', { pos: [10, 6] }); + st(u).stock = 1; + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`); + assert.equal(u.s.aspd, u.base.aspd + sk.bb.attack_speed, `T${tier}: ASPD`); + approx(u.skill.timeLeft, 30, `T${tier}: 30 s`, 0.05); + assert.equal(st(u).stock, 2, `T${tier}: +1 stock at the start`); + done(h); + } +}); + +test('铁钳号·原型机\'s skill (5 SP, an enemy in its 3-13, flyers too): −2.5 % max HP (流失), then 白铁\'s ATK × 230 % / 260 % physical on the first enemy and × 110 % / 120 % on every other within 1.1 of it; 团结的力量: the operator behind it takes ×0.8 physical (not arts)', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const v = tokOf(CRAB, tier, elite, 2, null); + const { h, u, devs } = field({ tier, elite, skill: 2, row: 12, col: 3, pieces: [[10, 5]], others: [{ uid: 3, chessId: 'chess_char_1_02_a', row: 10, col: 4 }] }); + const yak = h.unit(3); + const [c] = devs; + assert.deepEqual([v.skill.bb.atk_scale, v.skill.bb.atk_scale_2, v.talents[0].bb.damage_resistance], [elite ? 2.6 : 2.3, elite ? 1.2 : 1.1, 0.2], `T${tier}`); + assert.deepEqual(yak.findBuff(`ironmn:crab:${c.id}`)?.mods, { physTakenMul: 0.8 }, `T${tier}: 角峰 behind it`); + // damage taken by 角峰: physical ×0.8, arts unchanged + const p = h.b.dealDamage(null, yak, { amount: 1000, type: 'phys', canDodge: false, sourceless: true }); + const ar = h.b.dealDamage(null, yak, { amount: 1000, type: 'arts', canDodge: false, sourceless: true }); + approx(p, Math.max(1000 - yak.s.def, 50) * 0.8, `T${tier}: physical ×0.8`, 1e-3); + approx(ar, 1000 * (1 - yak.s.res / 100), `T${tier}: arts ×1`, 1e-3); + yak.hp = yak.s.maxHp; + // its skill: an enemy in the 3-13, SP full + const main = h.spawn('enemy_fly', { pos: [10, 7] }); + const near = h.spawn('enemy_dummy', { pos: [10, 8] }); + const far = h.spawn('enemy_dummy', { pos: [12, 8] }); + c.skill.gainSp(99); + const atk = u.s.atk; + const hp0 = c.hp; + assert.ok(h.runUntil(() => c.skill.activations === 1, 1), `T${tier}: cast at full SP with an enemy in range`); + approx(hp0 - c.hp, c.s.maxHp * 0.025, `T${tier}: −2.5 % max HP`); + h.run(1); + const by = (t) => h.hooksOf('damaged').filter((x) => x.source === c && x.target === t); + assert.equal(by(main).length, 1, `T${tier}: the flyer is the first target (可对空)`); + approx(by(main)[0].amount, atk * v.skill.bb.atk_scale, `T${tier}: ${v.skill.bb.atk_scale * 100} % of 白铁's ATK`); + assert.equal(by(near).length, 1, `T${tier}: the enemy within 1.1`); + approx(by(near)[0].amount, atk * v.skill.bb.atk_scale_2, `T${tier}: ${v.skill.bb.atk_scale_2 * 100} %`); + assert.equal(by(far).length, 0, `T${tier}: farther than 1.1 untouched`); + assert.deepEqual([by(main)[0].type, by(main)[0].dmg.isSkill], ['phys', true]); + done(h); + } +}); + +test('CRA-X: stage 3 节约经费 "当白铁周围8格存在自身装置时技力回复速度+0.2/秒" (one effect) — none at stage 1, none with the device outside his x-4', () => { + for (const [tier, mod, want] of [[5, X, 0], [6, X, 0.2], [6, Y, 0], [6, null, 0]]) { + const f = label([tier, true, mod]); + const { h, u, devs } = field({ tier, elite: true, mod, skill: 0, pieces: [[11, 6], [9, 4]] }); + h.step(); + assert.equal(u.s.spRecovery, u.base.spRecovery + want, `${f}: SP recovery`); + if (want) { + assert.deepEqual(u.findBuff('talent:ironmn:spRecover').mods, { spRecoveryFlat: 0.2 }); + for (const d of devs) h.b.retreat(d, { reason: 'expired' }); + h.step(); + assert.equal(u.s.spRecovery, u.base.spRecovery, `${f}: none once no device stands on his x-4`); + } + done(h); + } +}); + +test('CRA-Y: stage 3 "自身装置天赋生效的干员攻击速度+6(此效果不受技能影响)" — the operator a 平台 faces / the one behind a 铁钳号, +6 also while S1 runs; none at stage 1', () => { + for (const [tier, want] of [[5, 0], [6, 6]]) { + for (const skill of [0, 1, 2]) { + const f = `T${tier} CRA-Y S${skill + 1}`; + const piece = skill === 2 ? [10, 7] : [11, 4]; + const op = skill === 2 ? { uid: 3, chessId: 'chess_char_1_08_a', row: 10, col: 6 } : { uid: 3, chessId: 'chess_char_1_08_a', row: 11, col: 5 }; + const { h, devs } = field({ tier, elite: true, mod: Y, skill, pieces: [piece], others: [op] }); + const tx = h.unit(3); + h.step(); + assert.equal(tx.s.aspd, tx.base.aspd + want, `${f}: ASPD`); + if (want) assert.deepEqual(tx.findBuff(`ironmn:aspd:${devs[0].id}`).mods, { aspd: 6 }); + done(h); + } + } + const { h, u } = field({ tier: 6, elite: true, mod: Y, skill: 0, pieces: [[11, 4]], others: [{ uid: 3, chessId: 'chess_char_1_08_a', row: 11, col: 5 }] }); + const tx = h.unit(3); + u.skill.gainSp(999); + h.spawn('enemy_dummy', { pos: [10, 6] }); + assert.ok(h.runUntil(() => u.skill.active, 3)); + h.step(); + assert.equal(tx.s.aspd, tx.base.aspd + 6, 'not scaled by S1'); + done(h); +}); + +test('铁钳号·原型机: enemies never select it (a ranged enemy beside it shoots 白铁 instead); its own HP loss can knock it out — then 节约经费 rolls and it returns like any device (20 s, CRA-Y 10 s; 10 DP, CRA-X 6)', () => { + const shooter = enemyRec({ key: 'enemy_shooter', hp: 1e9, speed: 0, mass: 0, range: 2.5, atk: 50, bat: 1 }); + for (const [tier, elite, mod] of [[5, false, null], [6, true, X], [6, true, Y]]) { + const f = label([tier, elite, mod]); + const units = [ + { uid: 1, diy: { slot: SLOT[tier], charId: IRON, skillIndex: 2, uniEquipId: mod }, elite, row: 10, col: 5 }, + { uid: 10, kind: 'token', tokenId: CRAB, ownerUid: 1, row: 11, col: 6 }, + ]; + const h = makeBattle({ defs: { enemies: { ...ENEMIES, enemy_shooter: shooter } }, timeLimit: 900, autoFinish: false, seed: 7, flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'death'], captureNoisy: true, units }); + h.step(); + h.b.players[0].dp = 99; + const u = h.unit(1); + const c = h.b.allyUnits.find((a) => a.defId === CRAB); + const e = h.spawn('enemy_shooter', { pos: [9, 7] }); + h.run(5); + const atk = h.events.filter((x) => x[0] === 'atk' && x[1] === e.id); + assert.ok(atk.length > 0 && atk.every((x) => x[2] !== c.id), `${f}: the enemy shoots, never at the crab`); + assert.equal(c.hp, c.s.maxHp, `${f}: untouched`); + // knocked out by its own HP loss + h.b.kill(e, null); + h.step(); + const v = tokOf(CRAB, tier, elite, 2, mod); + c.hp = 10; + c.skill.gainSp(99); + const asked = forceChance(h, true); + h.spawn('enemy_dummy', { pos: [11, 8] }); + assert.ok(h.runUntil(() => !c.alive, 2), `${f}: its −2.5 % knocks it out`); + assert.deepEqual(asked.length, 1, `${f}: 节约经费 rolled (on his x-4)`); + const t0 = h.b.time; + assert.ok(h.runUntil(() => c.alive, v.stats.respawnTime + 2), `${f}: back`); + approx(h.b.time - t0, v.stats.respawnTime, `${f}: after ${v.stats.respawnTime} s`, 0.3); + approx(h.b.players[0].dp, 99 - v.stats.cost, `${f}: −${v.stats.cost} DP`); + assert.equal(c.hp, c.s.maxHp, `${f}: at full HP`); + void u; + done(h); + } +}); diff --git a/test/content/op_nasti.test.js b/test/content/op_nasti.test.js new file mode 100644 index 00000000..71627ca5 --- /dev/null +++ b/test/content/op_nasti.test.js @@ -0,0 +1,299 @@ +// test/content/op_nasti.test.js — the 自选 operator kit of 娜斯提 (char_4212_nasti, 6★ 工匠; kit +// server/sim/content/kits/ops/op-nasti.js) and of her <支援装置> “质检专员” (token_10059_nasti_nstdef), “监工专员” +// (token_10060_nasti_nstchr) and “应急承重小组” (token_10061_nasti_nstbld: the 小工程师 and its 高台), fielded the production way (a +// DIY slot + its `diy` pick; the devices as the placed hand pieces of her player) in every form: tiers 5 / 6, normal (E2 Lv1, +// skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module or CRA-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_nasti.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 NASTI = 'char_4212_nasti'; +const DEF = 'token_10059_nasti_nstdef', CHR = 'token_10060_nasti_nstchr', BLD = 'token_10061_nasti_nstbld'; +const TOK = [DEF, CHR, BLD]; +const FORMS = BACKUPS.units[NASTI].forms; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const X = 'uniequip_002_nasti'; +const S1 = 'skchr_nasti_1', S2 = 'skchr_nasti_2', S3 = 'skchr_nasti_3'; +const statusOf = (tier, elite) => (elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'); +const formOf = (tier, elite) => FORMS[statusOf(tier, elite)]; +const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id); +const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null); +const tokOf = (id, tier, elite, skill, mod) => { + let v = BACKUPS.tokens[id].variants[`${NASTI}@${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_rng: dummy('enemy_rng', { range: 2 }), + enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.6, mass: 0 }), + enemy_hit: enemyRec({ key: 'enemy_hit', hp: 1e9, speed: 0.6, mass: 0, atk: 400, bat: 1 }), +}; +const FORMS_ALL = [[5, false, null], [6, false, null], [5, true, null], [5, true, X], [6, true, null], [6, true, X]]; +const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`; + +function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, dir = 'RIGHT', pieces = [], others = [], seed = 5, dp = 99 } = {}) { + const units = [ + { uid: 1, diy: { slot: SLOT[tier], charId: NASTI, skillIndex: skill, uniEquipId: mod }, elite, row, col, dir }, + ...pieces.map(([r, c, d], i) => ({ uid: 10 + i, kind: 'token', tokenId: TOK[skill], ownerUid: 1, row: r, col: c, dir: d ?? 'RIGHT' })), + ...others, + ]; + const h = makeBattle({ + defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, + flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'death', 'deploy', 'spGain', 'heal'], captureNoisy: true, + units, + }); + h.step(); + h.b.players[0].dp = dp; + const u = h.unit(1); + const devs = h.b.allyUnits.filter((a) => a.kind === 'token' && a.defId === TOK[skill] && a.uid != null).sort((a, b) => a.uid - b.uid); + return { h, u, devs }; +} +const st = (u) => u.mem.craftsman; +const platformOf = (u) => u.mem.nastiPlatform ?? null; +function done(h) { + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); +} +const protectOf = (a) => a.findBuff('protect')?.mods ?? null; + +test('娜斯提 in every 自选 form: her kit (all three skills authored), stats + module attributes, 1-1, blocks 2, melee physical ground-only, 协防 (no faction bond), no 特质, the data triggers; offered as a pick', () => { + assert.equal(OPERATOR_KITS[NASTI], KITS[NASTI]); + 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], [NASTI, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f)); + assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0)], `${label(f)}: stats`); + assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.profile.sub, u.base.bat], [2, 'melee', false, 'phys', 'craftsman', 1.5], `${label(f)}: 工匠`); + assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`); + assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['emptyShip'], []], `${label(f)}: bonds / 特质`); + 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', 'DEFAULT', 'DEFAULT']); + assert.deepEqual([modOf(5, X).attr, modOf(6, X).attr], [{ maxHp: 78, atk: 26, def: 25 }, { maxHp: 145, atk: 40, def: 45 }]); + const data = { chess: CHESS, backups: BACKUPS }; + assert.ok(KITTED_CHARS.includes(NASTI)); + for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(NASTI), `tier ${t}`); + assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: NASTI, skillIndex: 1, uniEquipId: X } }, { data, kitted: KITTED_CHARS }), + { ok: true, picks: { [SLOT[5]]: { charId: NASTI, skillIndex: 1, uniEquipId: X } } }); +}); + +test('<支援装置> hand pieces: the picked skill\'s device (variant sources), 2 of them (deploy limit 2, "最多可部署2个"); CRA-X costs 2 / 2 / 3; stock 3 (CRA-X 4); CRA-X stage 3: the first device deployment after each of hers costs 3 less', () => { + for (const [skill, id] of TOK.entries()) { + for (const s of [0, 1, 2]) assert.equal(tokOf(id, 6, true, s, null).sources.includes('skill'), s === skill, `${id} under S${s + 1}`); + assert.equal(tokOf(id, 6, true, skill, null).stats.deployLimit, 2, `${id}: 2 hand pieces`); + } + assert.deepEqual(TOK.map((id, s) => tokOf(id, 6, true, s, null).stats.cost), [4, 4, 6]); + assert.deepEqual(TOK.map((id, s) => tokOf(id, 6, true, s, X).stats.cost), [2, 2, 3]); + assert.deepEqual(TOK.map((id, s) => tokOf(id, 6, true, s, null).stats.respawnTime), [8, 4, 6]); + for (const [tier, elite, mod, cap, cut] of [[5, false, null, 3, 0], [5, true, X, 4, 0], [6, true, X, 4, 3]]) { + const f = label([tier, elite, mod]); + const { h, u, devs } = field({ tier, elite, mod, skill: 0, pieces: [[11, 7], [9, 7]] }); + const [a, b] = devs; + assert.deepEqual([st(u).stock, st(u).cap, a.alive, b.alive], [cap, cap, true, true], `${f}: stock, both deployed free`); + assert.equal(u.mem.nastiFirst, false, `${f}: the battle-start deployment used the first-device cut`); + // she leaves and comes back: the first device back costs `cut` less, the next one its full cost + h.b.kill(u, null); + assert.ok(!a.alive && !b.alive, `${f}: her devices leave with her`); + h.b.players[0].dp = 999; + assert.ok(h.runUntil(() => u.alive, 200), `${f}: back`); + h.b.players[0].dp = 100; + assert.ok(h.runUntil(() => a.alive && b.alive, 20), `${f}: both devices back`); + approx(h.b.players[0].dp, 100 - Math.max(0, a.base.cost - cut) - a.base.cost, `${f}: the first back ${Math.max(0, a.base.cost - cut)} DP, the second ${a.base.cost}`); + done(h); + } +}); + +test('注意安全: 10 % 庇护 on her; once a 远程 enemy is on the field (until she leaves) 15 % on her and on our 远程 operators (not the 近战 ones), and +1 SP every 6 s for them', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const t1 = formOf(tier, elite).talents.find((t) => t.index === 1).bb; + assert.deepEqual([t1['nasti_t2[res].damage_resistance'], t1['nasti_t2[res_plus].damage_resistance'], t1['nasti_t2[add_sp].interval']], [0.1, 0.15, 6]); + const { h, u } = field({ tier, elite, skill: 0, others: [{ uid: 3, chessId: 'chess_char_1_14_a', row: 9, col: 3 }, { uid: 4, chessId: 'chess_char_1_08_a', row: 12, col: 3 }] }); + const prov = h.unit(3), tx = h.unit(4); + h.run(0.5); + assert.deepEqual([protectOf(u), protectOf(prov), protectOf(tx)], [{ physTakenMul: 0.9, artsTakenMul: 0.9 }, null, null], `T${tier}: 10 % on her only`); + h.spawn('enemy_dummy', { pos: [11, 9] }); + h.run(0.5); + assert.deepEqual(protectOf(u), { physTakenMul: 0.9, artsTakenMul: 0.9 }, `T${tier}: a 近战 enemy changes nothing`); + const rng = h.spawn('enemy_rng', { pos: [12, 9] }); + h.run(0.5); + assert.deepEqual([protectOf(u), protectOf(prov), protectOf(tx)], [{ physTakenMul: 0.85, artsTakenMul: 0.85 }, { physTakenMul: 0.85, artsTakenMul: 0.85 }, null], `T${tier}: 15 % on her and 格雷伊 (远程), not 德克萨斯`); + const gifts = (a) => h.hooksOf('spGain').filter((c) => c.unit === a && c.reason === 'talent').map((c) => c.t); + prov.skill.sp = 0; + u.skill.sp = 0; + const g0 = gifts(prov).length; + h.run(12.1); + assert.equal(gifts(prov).length - g0, 2, `T${tier}: +1 SP every 6 s (格雷伊)`); + assert.ok(gifts(u).length >= 2, `T${tier}: and her`); + assert.equal(gifts(tx).length, 0, `T${tier}: not 德克萨斯`); + h.b.kill(rng, null); + h.run(1); + assert.deepEqual(protectOf(prov), { physTakenMul: 0.85, artsTakenMul: 0.85 }, `T${tier}: permanent until she leaves`); + h.b.kill(u, null); + h.run(1); + assert.equal(protectOf(prov), null, `T${tier}: gone with her`); + done(h); + } +}); + +test('S1 “拱卫” + “质检专员”: DEF +25 % on the operator it faces; while S1 runs DEF +40 / 46 %, block +1 and the device −2 % max HP / s; S1 38 / 40 s, ATK / DEF +52 / 65 %, every blocked enemy at once, +1 stock at the start', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S1); + const v = tokOf(DEF, tier, elite, 0, null); + const { h, u, devs } = field({ tier, elite, skill: 0, pieces: [[11, 4]], others: [{ uid: 3, chessId: 'chess_char_1_02_a', row: 11, col: 5 }] }); + const yak = h.unit(3); + const [d] = devs; + assert.deepEqual([sk.duration, sk.bb.atk, sk.bb.def, sk.bb['talent@def'], v.talents[1].bb.def, v.skill.bb['talent@block_cnt'], v.skill.bb['talent@hp_ratio']], + [elite ? 40 : 38, elite ? 0.65 : 0.52, elite ? 0.65 : 0.52, elite ? 0.46 : 0.4, 0.25, 1, 0.02], `T${tier}`); + h.step(); + assert.deepEqual(yak.findBuff(`nasti:nstdef:${d.id}`)?.mods, { defPct: 0.25 }, `T${tier}: 严禁偷工减料`); + assert.ok(d.s.flags.untargetable && d.s.flags.invulnerable && d.s.flags.noHeal, `T${tier}: 不会受到攻击, 无敌, 禁疗`); + st(u).stock = 1; + u.skill.gainSp(999); + h.spawn('enemy_dummy', { pos: [10, 6] }); + assert.ok(h.runUntil(() => u.skill.active, 3)); + assert.equal(st(u).stock, 2, `T${tier}: +1 stock`); + approx(u.skill.timeLeft, sk.duration, `T${tier}: ${sk.duration} s`, 0.02); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`); + assert.deepEqual(u.skill.spec.attack, { hitAllBlocked: true }); + h.step(); + assert.deepEqual(yak.findBuff(`nasti:nstdef:${d.id}`)?.mods, { defPct: sk.bb['talent@def'], blockCnt: 1 }, `T${tier}: the S1 mode`); + assert.equal(yak.s.blockCnt, yak.base.blockCnt + 1, `T${tier}: block +1`); + const hp0 = d.hp; + h.run(5.02); + approx(hp0 - d.hp, d.s.maxHp * 0.02 * 5, `T${tier}: −2 % / s`, 1e-3); + h.runUntil(() => !u.skill.active, sk.duration + 1); + h.step(); + assert.deepEqual(yak.findBuff(`nasti:nstdef:${d.id}`)?.mods, { defPct: 0.25 }, `T${tier}: back to +25 %`); + approx(d.hp, d.s.maxHp * (1 - 0.02 * (sk.duration - 1)), `T${tier}: ${sk.duration - 1} ticks of −2 % (at 1 … ${sk.duration - 1} s; the skill ends at ${sk.duration})`, 1e-3); + done(h); + } +}); + +test('S2 “执行” + “监工专员”: deployed at 3 / 8 HP, +1 HP every 6 SP (阻回 at full HP); S2 4 s: no attack, a barrier of 6 / 8 % of her max HP each 0.5 s up to 48 / 64 %; the device: +1 SP and a 3 / 4 % barrier to the operator it faces each 0.5 s, −1 HP each, leaves at the end; +1 stock at the end; her barriers go when she leaves', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S2); + const v = tokOf(CHR, tier, elite, 1, null); + const { h, u, devs } = field({ tier, elite, skill: 1, pieces: [[11, 4]], others: [{ uid: 3, chessId: 'chess_char_1_02_a', row: 11, col: 5 }] }); + const yak = h.unit(3); + const [d] = devs; + assert.deepEqual([sk.duration, sk.bb['attack@nasti_s2[update_shield].shield_each_hp_rate'], sk.bb['attack@nasti_s2[update_shield].shield_max_hp_rate'], v.skill.bb['talent@shield_each_hp_rate'], v.skill.bb['talent@shield_max_hp_rate'], v.stats.maxHp, v.talents[1].bb.init_hp, v.skill.spCost], + [4, elite ? 0.08 : 0.06, elite ? 0.64 : 0.48, elite ? 0.04 : 0.03, elite ? 0.64 : 0.48, 8, 3, 6], `T${tier}`); + assert.deepEqual([d.hp, d.s.maxHp], [3, 8], `T${tier}: 3 / 8 HP`); + h.run(12.2); + assert.equal(d.hp, 5, `T${tier}: +1 HP every 6 s`); + h.run(18.2); + assert.equal(d.hp, 8, `T${tier}: full`); + assert.ok(d.s.flags.noSp, `T${tier}: 阻回 at full HP`); + // S2: 8 pulses over 4 s on 角峰; the device dies on its last HP; her own barrier + st(u).stock = 1; + yak.skill.sp = 0; + u.skill.gainSp(999); + h.spawn('enemy_dummy', { pos: [10, 6] }); + assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`); + assert.deepEqual(u.skill.spec.attack, { noAttack: true }, `T${tier}: 停止攻击`); + const t0 = h.b.time; + h.runUntil(() => !u.skill.active, 6); + approx(h.b.time - t0, 4, `T${tier}: 4 s`, 0.03); + const each = u.s.maxHp * v.skill.bb['talent@shield_each_hp_rate']; + approx(yak.findBuff(`nasti:shield:${u.id}`)?.shield ?? 0, 8 * each, `T${tier}: 8 pulses of ${v.skill.bb['talent@shield_each_hp_rate'] * 100} % of her max HP`, 1e-3); + assert.equal(h.hooksOf('spGain').filter((c) => c.unit === yak && c.reason === 'device').length, 8, `T${tier}: 8 SP`); + approx(u.findBuff('nasti:s2shield').shield, u.s.maxHp * sk.bb['attack@nasti_s2[update_shield].shield_max_hp_rate'], `T${tier}: her barrier at the cap`, 1e-3); + assert.equal(d.alive, false, `T${tier}: the device spent (8 HP) / left at the end`); + assert.equal(st(u).stock, 2, `T${tier}: +1 stock at the end (the device not back yet)`); + assert.ok(h.runUntil(() => d.alive, 10), `T${tier}: back after 4 s`); + assert.deepEqual([d.hp, st(u).stock], [3, 1]); + // she leaves: every barrier of her S2 goes + h.b.kill(u, null); + assert.ok(!yak.findBuff(`nasti:shield:${u.id}`) && !u.findBuff('nasti:s2shield'), `T${tier}: barriers gone with her`); + done(h); + } +}); + +test('S3 栖脚地 + 应急承重小组: the 小工程师 builds the 高台 on the free low tile it faces (none on a taken tile); 50 SP a level (its own 1 / s + 1 / s from the 小工程师, 2 / s in S3); level 1 attackable, block 1; level 2 +1000 HP / +100 DEF / +5 RES; level 3 +2000 / +200 / +10, 阻回; heals 1 % (2 %) for −2 %; stays when she leaves only at level 3; S3 15 s, ATK / DEF +100 / 130 %', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S3); + const v = tokOf(BLD, tier, elite, 2, null); + const { h, u, devs } = field({ tier, elite, skill: 2, pieces: [[11, 4], [9, 4, 'UP']], others: [{ uid: 3, chessId: 'chess_char_1_02_a', row: 10, col: 4 }] }); + const [eng, blocked] = devs; + const p = platformOf(u); + assert.ok(p && p.alive, `T${tier}: the 高台 built`); + assert.deepEqual([p.tileR, p.tileC, p.mem.nastiLevel], [11, 5, 0], `T${tier}: on the tile it faces`); + assert.equal(h.b.allyUnits.filter((a) => a.defId === BLD && a.mem.nastiPlatform).length, 1, `T${tier}: one 高台 (the other 小工程师 faces 角峰)`); + void blocked; + assert.ok(p.s.flags.untargetable && p.s.flags.invulnerable && p.s.blockCnt === 0, `T${tier}: 未激活 — untargetable, invulnerable, blocks nobody`); + assert.ok(eng.s.flags.untargetable && eng.s.flags.invulnerable && eng.s.flags.noHeal, `T${tier}: the 小工程师 不会受到攻击`); + assert.deepEqual([v.skill.spCost, v.talents[0].bb.m1_sp, v.talents[0].bb.m2_sp, v.talents[0].bb['nasti_nstbld_m1[heal_m456].hp_ratio'], v.talents[0].bb['nasti_nstbld_m2[heal_m456].hp_ratio']], [50, 1, 2, 0.01, 0.02], `T${tier}`); + // level 1 after 25 s (1 + 1 SP / s) + assert.ok(h.runUntil(() => p.mem.nastiLevel === 1, 27), `T${tier}: level 1`); + approx(h.b.time, 25, `T${tier}: 50 SP at 2 / s`, 0.08); + assert.ok(!p.s.flags.untargetable && !p.s.flags.invulnerable && p.s.blockCnt === 1, `T${tier}: 激活 — attackable, block 1`); + // S3: 3 SP / s (1 own + 2), level 2 + u.skill.gainSp(999); + h.spawn('enemy_dummy', { pos: [10, 6] }); + assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK +${sk.bb.atk * 100} %`); + approx(u.skill.timeLeft, 15, `T${tier}: 15 s`, 0.05); + const sp0 = p.skill.sp, tS = h.b.time; + h.run(5); + approx(p.skill.sp - sp0, 3 * 5, `T${tier}: 3 SP / s in S3`, 0.05); + void tS; + assert.ok(h.runUntil(() => p.mem.nastiLevel === 2, 20), `T${tier}: level 2`); + approx(p.s.maxHp, v.stats.maxHp + 1000, `T${tier}: +1000 HP`); + assert.deepEqual([p.s.def, p.s.res], [v.stats.def + 100, v.stats.res + 5], `T${tier}: +100 DEF / +5 RES`); + // heal: 1 % of its max HP for −2 % of the 小工程师's + h.runUntil(() => !u.skill.active, 20); + p.hp = p.s.maxHp * 0.5; + const e0 = eng.hp, p0 = p.hp; + h.run(1.01); + approx(p.hp - p0, p.s.maxHp * 0.01, `T${tier}: heals 1 %`, 0.02); + approx(e0 - eng.hp, eng.s.maxHp * 0.02, `T${tier}: −2 % own`, 0.02); + // level 3: 阻回; stays when she leaves + p.skill.gainSp(999); + h.step(2); + assert.equal(p.mem.nastiLevel, 3, `T${tier}: level 3`); + approx(p.s.maxHp, v.stats.maxHp + 2000, `T${tier}: +2000 HP`); + assert.ok(p.s.flags.noSp, `T${tier}: 阻回 at the top level`); + h.b.kill(u, null); + assert.ok(p.alive, `T${tier}: a level-3 高台 stays when she leaves`); + assert.ok(!eng.alive, `T${tier}: the 小工程师 goes with her`); + done(h); + } + // below level 3 the 高台 goes with her; no 高台 on a taken tile + const { h, u } = field({ tier: 6, elite: true, skill: 2, pieces: [[11, 4]], others: [{ uid: 3, chessId: 'chess_char_1_02_a', row: 11, col: 5 }] }); + assert.equal(platformOf(u), null, 'the tile it faces is taken: no 高台'); + done(h); + const r2 = field({ tier: 6, elite: true, skill: 2, pieces: [[11, 4]] }); + const p2 = platformOf(r2.u); + r2.h.b.kill(r2.u, null); + assert.ok(!p2.alive, 'a level-0 高台 goes with her'); + done(r2.h); +}); + +test('CRA-X stage 3 "装置可与攻击范围内其他装置共同提供效果": a 质检专员 with another on its range also reaches that one\'s range (the operator ahead gets both) — not at stage 1 or without the module', () => { + for (const [tier, mod, want] of [[6, X, 2], [5, X, 1], [6, null, 1]]) { + const { h, devs } = field({ tier, elite: true, mod, skill: 0, pieces: [[11, 3], [11, 4]], others: [{ uid: 3, chessId: 'chess_char_1_02_a', row: 11, col: 5 }] }); + const yak = h.unit(3); + h.step(); + const n = devs.filter((d) => yak.findBuff(`nasti:nstdef:${d.id}`)).length; + assert.equal(n, want, `T${tier} ${mod ?? 'none'}: ${want} device(s) reach 角峰`); + approx(yak.s.def, yak.base.def * (1 + 0.25 * want), `T${tier}: DEF +${25 * want} %`); + done(h); + } +}); diff --git a/test/content/op_necras.test.js b/test/content/op_necras.test.js new file mode 100644 index 00000000..659e42e1 --- /dev/null +++ b/test/content/op_necras.test.js @@ -0,0 +1,390 @@ +// test/content/op_necras.test.js — the 自选 operator kit of 死芒 (char_450_necras, 6★ 塑灵术师; kit +// server/sim/content/kits/ops/op-necras.js) and of her summon 悲叹的仆役 (token_10043_necras_skeltn, and its special form), +// fielded the production way (a DIY slot + its `diy` pick) in every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) +// and elite (E2 Lv60, rank 7) with no module or SOC-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_necras.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 NEC = 'char_450_necras'; +const SKEL = 'token_10043_necras_skeltn'; +const FORMS = BACKUPS.units[NEC].forms; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const X = 'uniequip_002_necras'; +const S1 = 'skchr_necras_1', S2 = 'skchr_necras_2', S3 = 'skchr_necras_3'; +const statusOf = (tier, elite) => (elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'); +const formOf = (tier, elite) => FORMS[statusOf(tier, elite)]; +const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id); +const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null); +const skelOf = (tier, elite, skill) => { + let v = BACKUPS.tokens[SKEL].variants[`${NEC}@${statusOf(tier, elite)}`]; + if (v.bySkill?.[skill]) v = { ...v, ...v.bySkill[skill] }; + 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 ENEMIES = { + enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e9, speed: 0, mass: 0 }), + enemy_weak: enemyRec({ key: 'enemy_weak', hp: 100, speed: 0, mass: 0 }), + enemy_fly: enemyRec({ key: 'enemy_fly', hp: 1e9, speed: 0, mass: 0, motion: 'FLY' }), + enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.5, mass: 0 }), +}; +const FORMS_ALL = [[5, false, null], [6, false, null], [5, true, null], [5, true, X], [6, true, null], [6, true, X]]; +const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`; + +/** 死芒 as uid 1 at (10, 3) facing RIGHT: her 3-1 is rows 9–11 × cols 3–5 and (10, 6). */ +function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 3, others = [], seed = 5 } = {}) { + const h = makeBattle({ + defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, + flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'death', 'deploy', 'statusApplied', 'heal'], captureNoisy: true, + units: [{ uid: 1, diy: { slot: SLOT[tier], charId: NEC, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others], + }); + h.step(); + return { h, u: h.unit(1) }; +} +const skels = (u) => (u.mem.necras?.skels ?? []).filter((s) => s.alive); +function done(h) { + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); +} +/** Knock an enemy out at `pos` (the kill is credited to nobody: anyone's kill counts). */ +function killAt(h, pos, key = 'enemy_weak') { + const e = h.spawn(key, { pos }); + h.step(); + h.b.kill(e, null); + return e; +} + +test('死芒 in every 自选 form: her kit (all three skills authored), stats + module attributes, 3-1, blocks 1, ranged arts that hit air, 维多利亚, no 特质, the data triggers (S3 SKILL_RANGE on its 3-3); offered as a pick', () => { + assert.equal(OPERATOR_KITS[NEC], KITS[NEC]); + 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], [NEC, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f)); + assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0)], `${label(f)}: stats`); + assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat], [1, 'ranged', true, 'arts', 1.6], `${label(f)}: 塑灵术师`); + assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-1`); + assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['victoriaShip'], []], `${label(f)}: bonds / 特质`); + assert.equal(u.skill.rule, form.skills[skill].trigger.rule, `${label(f)}: the data's trigger`); + done(h); + } + } + assert.deepEqual(formOf(6, true).skills.map((s) => s.trigger.rule), ['DEFAULT', 'DEFAULT', 'SKILL_RANGE']); + assert.deepEqual([modOf(5, X).attr, modOf(6, X).attr], [{ maxHp: 120, atk: 35 }, { maxHp: 200, atk: 55 }]); + const data = { chess: CHESS, backups: BACKUPS }; + assert.ok(KITTED_CHARS.includes(NEC)); + for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(NEC), `tier ${t}`); + assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: NEC, skillIndex: 2, uniEquipId: X } }, { data, kitted: KITTED_CHARS }), + { ok: true, picks: { [SLOT[6]]: { charId: NEC, skillIndex: 2, uniEquipId: X } } }); +}); + +test('复燃: a knock-out on her range (anyone\'s, stealth ignored) makes a 悲叹的仆役 on its tile 0.1 s later (facing as she does); off her range nothing; up to 3, then the upgrade (+100 % HP / ATK, +40 % DEF, block +1, healed to full; not yet upgraded first), all upgraded: the lowest healed; they leave with her', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const t0 = formOf(tier, elite).talents.find((t) => t.index === 0).bb; + const v = skelOf(tier, elite, 0); + assert.deepEqual([t0.max_token_cnt, t0.max_hp, t0.atk, t0.def, t0.block_cnt], [3, 1, 1, 0.4, 1], `T${tier}`); + const { h, u } = field({ tier, elite, skill: 1 }); + killAt(h, [10, 5]); + assert.equal(skels(u).length, 0, `T${tier}: not at once`); + h.run(0.11); + const [a] = skels(u); + assert.ok(a, `T${tier}: one 0.1 s later`); + assert.deepEqual([a.tileR, a.tileC, a.dir, a.defId], [10, 5, u.dir, SKEL], `T${tier}: on the kill tile, facing as she does`); + assert.deepEqual([a.base.maxHp, a.base.atk, a.base.def, a.s.blockCnt, a.profile.dmgType, a.profile.attack, a.profile.canHitFly], [v.stats.maxHp, v.stats.atk, v.stats.def, 1, 'arts', 'melee', false], `T${tier}: the token's stats, arts melee, ground only`); + assert.deepEqual(a.liveRangeGrid, v.rangeGrid, `T${tier}: x-5`); + assert.ok(a.s.flags.noHeal && a.s.flags.noSp && a.s.flags.silence, `T${tier}: 禁疗, 阻回, 沉默`); + killAt(h, [10, 8]); + h.run(0.2); + assert.equal(skels(u).length, 1, `T${tier}: off her range — nothing`); + killAt(h, [9, 4]); + h.run(0.2); + killAt(h, [11, 4]); + h.run(0.2); + assert.equal(skels(u).length, 3, `T${tier}: three`); + const [, b, c] = skels(u); + b.hp = b.s.maxHp * 0.5; + c.hp = c.s.maxHp * 0.3; + killAt(h, [10, 4]); + h.run(0.2); + assert.equal(skels(u).length, 3, `T${tier}: no 4th`); + assert.ok(c.findBuff('necras:upgrade') && !a.findBuff('necras:upgrade') && !b.findBuff('necras:upgrade'), `T${tier}: the lowest-HP one upgraded`); + assert.deepEqual(c.findBuff('necras:upgrade').mods, { hpPct: 1, atkPct: 1, defPct: 0.4, blockCnt: 1 }); + assert.deepEqual([c.hp, c.s.maxHp, c.s.blockCnt], [c.base.maxHp * 2, c.base.maxHp * 2, 2], `T${tier}: healed to full, block 2`); + killAt(h, [10, 4]); + h.run(0.2); + killAt(h, [10, 4]); + h.run(0.2); + assert.ok(skels(u).every((s) => s.findBuff('necras:upgrade')), `T${tier}: all three upgraded`); + b.hp = 100; + killAt(h, [10, 4]); + h.run(0.2); + assert.equal(b.hp, b.s.maxHp, `T${tier}: all upgraded — the lowest healed`); + h.b.kill(u, null); + assert.equal(skels(u).length, 0, `T${tier}: they leave with her`); + done(h); + } +}); + +test('复燃: no deployable kill tile ⇒ a random free low melee tile of her range, those with an enemy first; no tile at all ⇒ the upgrade instead', () => { + const { h, u } = field({ tier: 6, elite: true, skill: 1, others: [ + { uid: 3, chessId: 'chess_char_1_02_a', row: 9, col: 3 }, { uid: 4, chessId: 'chess_char_1_02_a', row: 9, col: 4 }, { uid: 5, chessId: 'chess_char_1_02_a', row: 9, col: 5 }, + { uid: 6, chessId: 'chess_char_1_02_a', row: 11, col: 3 }, { uid: 7, chessId: 'chess_char_1_02_a', row: 11, col: 4 }, { uid: 8, chessId: 'chess_char_1_02_a', row: 11, col: 5 }, + ] }); + // her range: (9|10|11, 3–5) + (10, 6); free: (10, 4), (10, 5), (10, 6); an enemy stands on (10, 6) + h.spawn('enemy_dummy', { pos: [10, 6] }); + // a kill on a taken tile (an operator on it) ⇒ the tile with the enemy + killAt(h, [9, 4]); + h.run(0.2); + const [a] = skels(u); + assert.deepEqual([a.tileR, a.tileC], [10, 6], 'the free tile with an enemy'); + killAt(h, [9, 5]); + h.run(0.2); + killAt(h, [9, 5]); + h.run(0.2); + assert.deepEqual(skels(u).map((s) => [s.tileR, s.tileC]).sort(), [[10, 4], [10, 5], [10, 6]], 'then the other free tiles'); + done(h); +}); + +test('回光黯淡: her and her summons\' damage on an enemy below 50 % HP ×1.4 (the final damage); SOC-X "攻击力提升至115%" on an enemy her summon blocks (stage 1 and 3), which she can attack anywhere', () => { + for (const [tier, elite, mod] of [[5, false, null], [5, true, X], [6, true, X]]) { + const f = label([tier, elite, mod]); + const { h, u } = field({ tier, elite, mod, skill: 1, row: 9, col: 3 }); + const e = h.spawn('enemy_dummy', { pos: [9, 5] }); + h.run(3); + const hits = () => h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && c.dmg.isAttack); + const full = hits().at(-1).amount; + approx(full, u.s.atk, `${f}: ×1 at full HP`); + e.hp = e.s.maxHp * 0.49; + const n0 = hits().length; + h.run(2); + approx(hits().slice(n0).at(-1).amount, u.s.atk * 1.4, `${f}: ×1.4 below 50 %`); + done(h); + } + // the trait: she attacks the enemy her summon blocks out of her range (×1.15 with SOC-X) + for (const [tier, elite, mod, mul] of [[5, false, null, 1], [5, true, X, 1.15], [6, true, X, 1.15]]) { + const f = label([tier, elite, mod]); + const { h, u } = field({ tier, elite, mod, skill: 1, row: 12, col: 8 }); + // her range: rows 11–13 × cols 8–10 and (12, 11); a 悲叹的仆役 made on (11, 9) blocks a walker on row 11 coming from the right + killAt(h, [11, 9]); + h.run(0.2); + const [s] = skels(u); + assert.deepEqual([s.tileR, s.tileC], [11, 9]); + const w = h.spawn('enemy_walk', { pos: [11, 13] }); + assert.ok(h.runUntil(() => w.blockedBy === s, 20), `${f}: blocked by her summon`); + h.step(3); + assert.ok(u.extraRangeKeys && u.extraRangeKeys.includes(Math.round(w.y) * 21 + Math.round(w.x)), `${f}: its tile is her target`); + const n0 = h.hooksOf('damaged').filter((c) => c.source === u && c.target === w).length; + h.run(4); + const hit = h.hooksOf('damaged').filter((c) => c.source === u && c.target === w && c.dmg.isAttack).slice(n0); + assert.ok(hit.length >= 1, `${f}: she attacks it`); + approx(hit[0].amount, u.s.atk * mul, `${f}: ×${mul}`); + done(h); + } +}); + +test('SOC-X stage 3: a knock-out on her range or her summons\' ranges ⇒ her ATK +25 % for 10 s (refreshed); one on a summon\'s range only also makes a 悲叹的仆役 (a random tile of her range) — none of it at stage 1', () => { + for (const [tier, mod, on] of [[5, X, false], [6, X, true], [6, null, false]]) { + const f = label([tier, true, mod]); + const { h, u } = field({ tier, elite: true, mod, skill: 1, row: 10, col: 3 }); + // a summon on the tip of her range (10, 6): its x-5 reaches (10, 7), (9, 6), (11, 6) — outside hers + killAt(h, [10, 6]); + h.run(0.2); + const [s] = skels(u); + assert.deepEqual([s.tileR, s.tileC], [10, 6]); + assert.equal(!!u.findBuff('necras:killAtk'), on, `${f}: ATK buff`); + if (on) { + assert.deepEqual(u.findBuff('necras:killAtk').mods, { atkPct: 0.25 }); + approx(u.findBuff('necras:killAtk').timeLeft, 10 - 0.2, `${f}: 10 s`, 0.02); + h.run(5); + killAt(h, [9, 4]); + approx(u.findBuff('necras:killAtk').timeLeft, 10, `${f}: refreshed`, 0.02); + h.run(0.2); + } + const n = skels(u).length; + killAt(h, [10, 7]); + h.run(0.2); + assert.equal(skels(u).length, on ? n + 1 : n, `${f}: the summon-range kill makes one (stage 3 only)`); + done(h); + } +}); + +test('S1 噩愿: each 悲叹的仆役 made or upgraded strikes every enemy within 1.5 for her ATK × 310 % / 370 % arts (air units too); the cast re-makes them all — 1 per summon, 2 per upgraded one, 1 with none', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S1); + assert.deepEqual([sk.bb.atk_scale, sk.bb.range_radius], [elite ? 3.7 : 3.1, 1.5]); + const { h, u } = field({ tier, elite, skill: 0 }); + const near = h.spawn('enemy_fly', { pos: [11, 5] }); + const far = h.spawn('enemy_dummy', { pos: [12, 7] }); + killAt(h, [10, 5]); + h.run(0.2); + const by = (t) => h.hooksOf('damaged').filter((c) => c.source === u && c.target === t && c.dmg.tags?.includes('necras:s1')); + assert.equal(by(near).length, 1, `T${tier}: the flyer within 1.5`); + approx(by(near)[0].amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`); + assert.equal(by(far).length, 0, `T${tier}: farther untouched`); + // fill to 3 and upgrade one (another strike) + killAt(h, [10, 4]); + h.run(0.2); + killAt(h, [11, 4]); + h.run(0.2); + const k0 = by(near).length; + killAt(h, [10, 4]); + h.run(0.2); + assert.ok(skels(u).some((s) => s.findBuff('necras:upgrade'))); + assert.ok(by(near).length >= k0, `T${tier}: the upgrade strikes too`); + // the cast: 2 + 1 + 1 = 4 credits ⇒ 3 summons + 1 upgrade + const old = skels(u).map((s) => s.id); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.activations === 1, 4), `T${tier}: cast`); + h.run(0.5); + const now = skels(u); + assert.equal(now.length, 3, `T${tier}: three again`); + assert.ok(now.every((s) => !old.includes(s.id)), `T${tier}: all re-made`); + assert.equal(now.filter((s) => s.findBuff('necras:upgrade')).length, 1, `T${tier}: the 4th credit upgrades one`); + done(h); + } + // none on the field ⇒ one + const { h, u } = field({ tier: 6, elite: true, skill: 0 }); + h.spawn('enemy_dummy', { pos: [10, 5] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.activations === 1, 4)); + h.run(0.3); + assert.equal(skels(u).length, 1, 'none ⇒ one'); + done(h); +}); + +test('S2 折朽: 12 s; up to 2 enemies of her range sleep while linked and take her ATK × 100 % / 130 % arts every 0.5 s through it; no attack meanwhile; a linked one knocked out ⇒ +2 summons; every link broken ⇒ the skill ends and the sleep with it', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S2); + assert.deepEqual([sk.duration, sk.bb.atk_scale, sk.bb.max_target, sk.bb.interval, sk.bb.additional_token_cnt], [12, elite ? 1.3 : 1, 2, 0.5, 2], `T${tier}`); + const { h, u } = field({ tier, elite, skill: 1 }); + const a = h.spawn('enemy_dummy', { pos: [10, 5] }); + const b = h.spawn('enemy_dummy', { pos: [9, 5] }); + const c = h.spawn('enemy_dummy', { pos: [11, 5] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`); + const slept = [a, b, c].filter((e) => e.findBuff('sleep')); + assert.equal(slept.length, 2, `T${tier}: two asleep`); + assert.deepEqual(u.skill.spec.attack, { noAttack: true }); + const t0 = h.b.time; + h.run(3.01); + const ticks = h.hooksOf('damaged').filter((x) => x.source === u && slept.includes(x.target) && x.dmg.tags?.includes('necras:s2') && x.t > t0 - 1e-9); + assert.equal(ticks.length, 12, `T${tier}: 6 ticks each in 3 s`); + for (const x of ticks) approx(x.amount, u.s.atk * sk.bb.atk_scale * (1 - x.target.s.res / 100), `T${tier}: ${sk.bb.atk_scale * 100} % arts`, 1e-3); + assert.ok(ticks.every((x) => x.target.findBuff('sleep')), `T${tier}: through the sleep`); + // a linked one knocked out ⇒ +2 summons (and 复燃's own one on its tile) + const n0 = skels(u).length; + h.b.kill(slept[0], null); + h.run(0.4); + assert.equal(skels(u).length, n0 + 3, `T${tier}: 2 + 1 summons`); + assert.ok(u.skill.active, `T${tier}: one link left`); + h.b.kill(slept[1], null); + h.step(3); + assert.equal(u.skill.active, false, `T${tier}: every link broken ⇒ ends`); + done(h); + } + // the end releases the sleep; a stun interrupts it + const { h, u } = field({ tier: 6, elite: true, skill: 1 }); + const e = h.spawn('enemy_dummy', { pos: [10, 5] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 3)); + assert.ok(e.findBuff('sleep')); + h.b.applyStatus(u, 'stun', { duration: 1 }); + h.step(3); + assert.ok(!u.skill.active && !e.findBuff('sleep'), 'stunned: interrupted, the sleep gone'); + done(h); +}); + +test('S3 冠死以冕: the next 悲叹的仆役 is the special form (+200 % HP / ATK, +100 % DEF, +10 RES, +0.7 s, block +1; 怨火缠身 15 SP / 8 s, 120 % ATK / s on its 1-1); the cast (SKILL_RANGE, 3-3 meanwhile): 2 strikes at her attack interval on every enemy of the 3-3 (550 % / 650 %), each consuming a normal summon to upgrade it (twice for an upgraded one; +80 / 80 / 40 %, heal 20 %, 3rd: block +1, 攻击距离 +1)', () => { + for (const [tier, elite] of [[5, false], [6, true]]) { + const sk = skillOf(tier, elite, S3); + const v = skelOf(tier, elite, 2); + const dr = formOf(tier, elite).talents.find((t) => t.index === 3).bb; + assert.deepEqual([dr.max_hp, dr.atk, dr.def, dr.magic_resistance, dr.base_attack_time, dr.block_cnt], [2, 2, 1, 10, 0.7, 1]); + assert.deepEqual([sk.bb['attack@atk_scale'], sk.bb['attack@max_hp'], sk.bb['attack@atk'], sk.bb['attack@def'], sk.bb['attack@hp_ratio'], sk.bb['attack@max_stack_cnt'], sk.rangeId], [elite ? 6.5 : 5.5, 0.8, 0.8, 0.4, 0.2, 6, '3-3'], `T${tier}`); + assert.deepEqual([v.skill.skillId, v.skill.spCost, v.skill.bb['attack@atk_scale'], v.skill.bb['attack@duration']], ['sktok_necras_skeltn_3', 15, 1.2, 8]); + const { h, u } = field({ tier, elite, skill: 2 }); + killAt(h, [10, 5]); + h.run(0.2); + const [d] = skels(u); + assert.ok(d.mem.necrasDragon, `T${tier}: the first one is the special form`); + assert.deepEqual([d.s.maxHp, d.s.atk, d.s.def, d.s.res, d.s.blockCnt], [d.base.maxHp * 3, d.base.atk * 3, d.base.def * 2, d.base.res + 10, 2], `T${tier}: the special form's attributes`); + approx(d.s.bat, d.base.bat + 0.7, `T${tier}: +0.7 s`); + assert.ok(!d.s.flags.noSp && d.skill.spCost === 15, `T${tier}: its skill charges`); + killAt(h, [10, 4]); + h.run(0.2); + killAt(h, [11, 4]); + h.run(0.2); + const [, n1, n2] = skels(u); + assert.ok(!n1.mem.necrasDragon && !n2.mem.necrasDragon && n1.s.flags.noSp, `T${tier}: the others normal, 阻回`); + // upgrade n1 (the earliest normal one), so its consumption upgrades twice + killAt(h, [10, 4]); + h.run(0.2); + assert.equal([n1, n2].filter((x) => x.findBuff('necras:upgrade')).length, 1, `T${tier}: one normal summon upgraded`); + const up1 = n1.findBuff('necras:upgrade') ? n1 : n2; + // the cast + const foe = h.spawn('enemy_dummy', { pos: [9, 6] }); + const u0atk = u.s.atk; + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: SKILL_RANGE — an enemy in the 3-3`); + assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: 3-3 while it runs`); + const tCast = h.b.time; + assert.ok(h.runUntil(() => !u.skill.active, 5), `T${tier}: ends after its 2 strikes`); + const strikes = h.hooksOf('damaged').filter((x) => x.source === u && x.target === foe && x.dmg.tags?.includes('necras:s3')); + assert.equal(strikes.length, 2, `T${tier}: 2 strikes`); + approx(strikes[0].amount, u0atk * sk.bb['attack@atk_scale'] * (1 - foe.s.res / 100), `T${tier}: ${sk.bb['attack@atk_scale'] * 100} % arts`, 1e-3); + approx(strikes[1].t - strikes[0].t, u.s.interval, `T${tier}: at her attack interval`, 0.05); + void tCast; + h.run(0.3); + assert.ok(!n1.alive && !n2.alive, `T${tier}: both normal summons consumed`); + void up1; + assert.equal(d.mem.necrasUps, 3, `T${tier}: 2 (the upgraded one) + 1 upgrades`); + assert.deepEqual(d.findBuff('necras:dragonUp').mods, { hpPct: 0.8 * 3, atkPct: 0.8 * 3, defPct: 0.4 * 3, blockCnt: 1, rangeExtend: 1 }, `T${tier}: 3 upgrades — block +1, 攻击距离 +1`); + assert.equal(u.liveRangeGrid.length, formOf(tier, elite).rangeGrid.length, `T${tier}: her range back`); + done(h); + } +}); + +test('怨火缠身 (the special form\'s skill): 15 SP over time, 8 s, its ATK × 120 % arts every second on every enemy of its 1-1; the normal forms never cast (阻回)', () => { + const { h, u } = field({ tier: 6, elite: true, skill: 2 }); + killAt(h, [10, 5]); + h.run(0.2); + const [d] = skels(u); + const e = h.spawn('enemy_dummy', { pos: [10, 6] }); + assert.ok(h.runUntil(() => d.skill.active, 17), 'cast at 15 SP with an enemy in range'); + approx(d.skill.timeLeft, 8, '8 s', 0.05); + const t0 = h.b.time; + h.runUntil(() => !d.skill.active, 10); + const ticks = h.hooksOf('damaged').filter((x) => x.source === d && x.target === e && x.dmg.isSkill && x.t >= t0 - 1e-9); + assert.equal(ticks.length, 8, 'one per second for 8 s'); + approx(ticks[0].amount, d.s.atk * 1.2 * (1 - e.s.res / 100), '120 % arts', 1e-3); + done(h); +}); + +test('复燃: a stealthed enemy knocked out on her range counts (天赋无视隐匿); with no free tile left the summon becomes the upgrade', () => { + const tiles = [[9, 3], [9, 4], [9, 5], [11, 3], [11, 4], [11, 5], [10, 4], [10, 6]]; + const { h, u } = field({ tier: 6, elite: true, skill: 1, others: tiles.map(([r, c], i) => ({ uid: 3 + i, chessId: 'chess_char_1_02_a', row: r, col: c })) }); + const e = h.spawn('enemy_weak', { pos: [10, 5] }); + h.step(); + h.b.addBuff(e, { key: 'test:stealth', flags: { stealth: true } }); + h.step(); + h.b.kill(e, null); + h.run(0.2); + const [s1] = skels(u); + assert.ok(s1, 'a summon for the stealthed one'); + assert.deepEqual([s1.tileR, s1.tileC], [10, 5]); + killAt(h, [9, 4]); + h.run(0.2); + assert.equal(skels(u).length, 1, 'no free tile: no second summon'); + assert.ok(s1.findBuff('necras:upgrade'), '… the upgrade instead'); + done(h); +}); diff --git a/test/golden/diy.json b/test/golden/diy.json index b253f1e5..ea854446 100644 --- a/test/golden/diy.json +++ b/test/golden/diy.json @@ -4495,8 +4495,8 @@ "snaps": "9e71a576 5f85bc01 c3ec0c6e 907b650d 8d0a1507 16eb2156 378d038b 6506577e 30807fb9 f2c86b9d ec752e72 d7303d83 83002a0a 01b70ca0 09e839c4 98d2358c 161de0dc 5f1fb139 3915cceb aeddf47d a8963bbe 76037450 84a29f92" }, "diy-106": { - "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=ironmn/S1 5_diy2_a=ironmn/S2 5_diy1_a=ironmn/S3 5_diy2_b=ironmn/S1 5_diy1_b=ironmn/S2 5_diy2_b=ironmn/S3 5_diy1_b=ironmn/S1/002_ironmn 5_diy2_b=ironmn/S2/002_ironmn 5_diy1_b=ironmn/S3/002_ironmn 5_diy2_b=ironmn/S1/003_ironmn 5_diy1_b=ironmn/S2/003_ironmn 5_diy2_b=ironmn/S3/003_ironmn", + "fast": false, "kind": "normal", "stageId": "act1autochess_m07", "modeId": "mode_multi_abyss", @@ -4512,111 +4512,111 @@ "bossHpLeft": null, "rngDraws": 0, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":0,"total":126,"perfect":false,"leaks":126,"leaksUncounted":0,"coins":0,"dmg":59922,"bossDmg":0,"heal":8548,"deaths":25,"layerGains":{}}}, + "players": {"p1":{"killed":0,"total":126,"perfect":false,"leaks":126,"leaksUncounted":0,"coins":0,"dmg":44928,"bossDmg":0,"heal":0,"deaths":22,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,3,0,1808,2128,0,8,0], - ["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,6,0,9112,3634,0,34,1], - ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,83,0,1003,6580,0,8,0], - ["p1",4,"op","chess_char_5_diy2_b",2,"none",0,0,86,0,14738,7835,0,17,0], - ["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,153,0,9754,4473,0,27,1], - ["p1",6,"op","chess_char_5_diy2_b",2,"none",0,0,94,0,11704,3746,0,27,1], - ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,90,0,2802,2266,0,12,0], - ["p1",8,"op","chess_char_5_diy2_b",2,"none",0,0,79,0,3020,2720,0,11,0], - ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,141,0,0,5763,2109,6,0], - ["p1",10,"op","chess_char_5_diy2_b",2,"none",0,0,128,0,0,3807,6439,10,1], - ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,112,0,2354,1864,0,12,0], - ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,148,0,3628,4041,0,16,1], + ["p1",1,"op","chess_char_5_diy1_a",0,null,0,0,150,0,2284,6126,0,8,0], + ["p1",2,"op","chess_char_5_diy2_a",1,null,0,0,108,0,3119,4084,0,8,0], + ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,126,0,3536,4084,0,8,0], + ["p1",4,"op","chess_char_5_diy2_b",0,"none",0,0,87,0,3138,4986,0,9,0], + ["p1",5,"op","chess_char_5_diy1_b",1,"none",0,0,140,0,6108,7479,0,12,0], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",0,0,77,0,2092,4986,0,6,0], + ["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_ironmn",0,0,71,0,1185,4986,0,6,0], + ["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_ironmn",0,0,3,0,3453,2493,0,5,1], + ["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_ironmn",0,0,5,0,7676,2493,0,10,1], + ["p1",10,"op","chess_char_5_diy2_b",0,"uniequip_003_ironmn",0,0,7,0,5637,2753,0,8,1], + ["p1",11,"op","chess_char_5_diy1_b",1,"uniequip_003_ironmn",0,0,9,0,2545,2753,0,5,0], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_003_ironmn",0,0,81,0,4156,5506,0,11,0], ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null] ], - "enemies": {"enemy_1251_lysyta_2":[54,0,54,8019,34461],"enemy_1252_lysytb_2":[42,0,42,35352,24551],"enemy_1329_cbshld_2":[30,0,30,5487,911]}, - "hooks": [1,153,5814,380,380,358,358,16,25,151,5,5,0,8987,28,10,126,126,1,25,0,0,0], - "events": {"atk":380,"deploy":25,"die":25,"dmg":358,"heal":16,"leak":126,"skill":10,"spawn":140,"status":50}, + "enemies": {"enemy_1251_lysyta_2":[54,0,54,16902,36707],"enemy_1252_lysytb_2":[42,0,42,17404,7700],"enemy_1329_cbshld_2":[30,0,30,18422,521]}, + "hooks": [1,150,5814,251,251,249,249,0,22,148,3,3,0,8526,0,43,126,126,1,22,0,0,0], + "events": {"atk":253,"deploy":22,"die":22,"dmg":249,"leak":126,"skill":6,"spawn":140}, "fx": {}, - "snaps": "57186c39 a89f274b e649d7d9 75621917 006f6ba9 7f3b8dda bb3864de e8193348 16cc7f17 db8194aa e07167eb 0946e952 5d27c316 103f7135 f7e98be4 ef855c6a d34a4bb6 5d39faae c6e66696 77bde8df" + "snaps": "3a9f9447 79572e55 5c0d9af5 3c790371 c9cc0b63 80cd9e44 2b74cf88 10724b1e 93648206 da37a7be 1d80cfc3 4b885726 fdfe1174 ef7324bf bc908605 f495a489 678b216a 0c01cc94 6e99ea23 6b03c96b" }, "diy-107": { - "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=ironmn/S1 6_diy2_a=ironmn/S2 6_diy1_a=ironmn/S3 6_diy2_b=ironmn/S1 6_diy1_b=ironmn/S2 6_diy2_b=ironmn/S3 6_diy1_b=ironmn/S1/002_ironmn 6_diy2_b=ironmn/S2/002_ironmn 6_diy1_b=ironmn/S3/002_ironmn 6_diy2_b=ironmn/S1/003_ironmn 6_diy1_b=ironmn/S2/003_ironmn 6_diy2_b=ironmn/S3/003_ironmn", "fast": false, "kind": "normal", "stageId": "act2autochess_m01", "modeId": "mode_multi_funny", - "round": 12, + "round": 13, "seed": 4020340196, - "waveId": "act1autochess_h05", - "time": 203.3, - "ticks": 6099, + "waveId": "act1autochess_h06", + "time": 223.97, + "ticks": 6719, "reason": "cleared", - "killed": 90, - "total": 90, + "killed": 5, + "total": 96, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 450, + "rngDraws": 0, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":90,"total":90,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":792864,"bossDmg":0,"heal":34212,"deaths":7,"layerGains":{}}}, + "players": {"p1":{"killed":5,"total":96,"perfect":false,"leaks":91,"leaksUncounted":0,"coins":0,"dmg":65704,"bossDmg":0,"heal":0,"deaths":23,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_diy1_a",2,null,2604,1,106,0,2245,2604,0,9,1], - ["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",2367.8,1,105,0,4281,4374,0,19,1], - ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,100,5,45221,3058,0,15,4], - ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,7,3,48414,1904,0,24,6], - ["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,95,13,46166,11997,0,51,3], - ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",3411,1,8,3,33047,3326,0,19,2], - ["p1",7,"op","chess_char_5_diy1_a",2,null,1272,1,2,0,13858,0,0,19,1], - ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",1587,1,3,38,146945,0,0,177,2], - ["p1",9,"op","chess_char_5_diy1_a",2,null,1372,1,4,1,19328,0,0,20,1], - ["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",1495.56,1,12,27,433358,24418,0,89,3], - ["p1",11,"op","chess_char_5_diy1_a",2,null,1224,1,1,0,0,0,11831,36,2], - ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",1414,1,5,0,0,0,22381,66,2] + ["p1",1,"op","chess_char_6_diy1_a",0,null,2042,1,120,0,4377,4084,0,13,1], + ["p1",2,"op","chess_char_6_diy2_a",1,null,0,0,119,0,4720,6126,0,20,0], + ["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,71,1,3143,4084,0,12,1], + ["p1",4,"op","chess_char_6_diy2_b",0,"none",0,0,99,1,6842,4986,0,14,1], + ["p1",5,"op","chess_char_6_diy1_b",1,"none",0,0,69,0,5463,4986,0,17,0], + ["p1",6,"op","chess_char_6_diy2_b",2,"none",0,0,106,0,5775,4986,0,14,1], + ["p1",7,"op","chess_char_6_diy1_b",0,"uniequip_002_ironmn",0,0,67,0,5382,4986,0,14,1], + ["p1",8,"op","chess_char_6_diy2_b",1,"uniequip_002_ironmn",0,0,113,1,8070,4986,0,13,1], + ["p1",9,"op","chess_char_6_diy1_b",2,"uniequip_002_ironmn",0,0,2,1,6199,2493,0,11,1], + ["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_003_ironmn",0,0,6,1,4672,2893,0,7,1], + ["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_003_ironmn",0,0,65,0,5022,5786,0,18,0], + ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_003_ironmn",0,0,61,0,6041,5786,0,19,0] ], - "enemies": {"enemy_1229_darmy":[27,27,0,3192,108864],"enemy_1229_darmy_2":[51,51,0,12310,269280],"enemy_9007_acelem":[12,12,0,36180,414720]}, - "hooks": [1,106,6099,594,594,1735,1777,160,97,97,28,27,0,32480,0,62,90,0,1,97,0,2,0], - "events": {"atk":764,"deploy":16,"die":97,"dmg":1777,"fx":154,"heal":142,"skill":55,"spawn":102,"status":62}, - "fx": {"aoe":52,"beam":25,"burst":2,"dp":18,"slash":57}, - "snaps": "03984647 ace8dbe4 49520875 0ad8fbe6 ba7c56bb e58d0120 a539ef32 0e2bc9be 3b333b61 db04449a dace616b 63a39256 5c34a56e d1619dfd 63bd653a d74deb99 1d733ab1 6e8cbc08 04eb4bb7 74b3c8bb 80dc47fc" + "enemies": {"enemy_1229_darmy":[27,2,25,12215,18973],"enemy_1229_darmy_2":[57,3,54,35687,44645],"enemy_9007_acelem":[12,0,12,8279,2087]}, + "hooks": [1,120,6719,345,345,404,558,0,28,119,8,8,0,13945,0,51,96,91,1,28,0,2,0], + "events": {"atk":349,"deploy":24,"die":28,"dmg":558,"fx":9,"leak":91,"skill":16,"spawn":108,"status":18}, + "fx": {"beam":7,"burst":2}, + "snaps": "313d2a61 8128fd71 a90f20a0 dbb4f993 cd0e7f87 22382d59 9c4c90be e16faec1 403431b9 2e79a446 8393ac30 80940b48 8e20f125 eb123769 016fa3a1 42d6e291 c380f813 fdceb4c7 d5f6f009 e1ce6b90 cbb63499 8d7362dc 2d3dc7e0" }, "diy-108": { - "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=nasti/S1 5_diy2_a=nasti/S2 5_diy1_a=nasti/S3 5_diy2_b=nasti/S1 5_diy1_b=nasti/S2 5_diy2_b=nasti/S3 5_diy1_b=nasti/S1/002_nasti 5_diy2_b=nasti/S2/002_nasti 5_diy1_b=nasti/S3/002_nasti 6_diy1_a=nasti/S1 6_diy2_a=nasti/S2 6_diy1_a=nasti/S3", "fast": false, "kind": "normal", "stageId": "act2autochess_m02", "modeId": "mode_single_hard", - "round": 13, + "round": 12, "seed": 1366160473, - "waveId": "act1autochess_h06", - "time": 230, - "ticks": 6900, - "reason": "timeout", - "killed": 15, - "total": 36, + "waveId": "act1autochess_h05", + "time": 219.47, + "ticks": 6584, + "reason": "cleared", + "killed": 0, + "total": 72, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 95, + "rngDraws": 0, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":15,"total":36,"perfect":false,"leaks":21,"leaksUncounted":0,"coins":0,"dmg":223366,"bossDmg":0,"heal":0,"deaths":32,"layerGains":{}}}, + "players": {"p1":{"killed":0,"total":72,"perfect":false,"leaks":72,"leaksUncounted":0,"coins":0,"dmg":0,"bossDmg":0,"heal":0,"deaths":22,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_6_diy1_a",2,null,0,0,3,0,0,2604,0,0,0], - ["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",0,0,4,0,0,3431,0,0,0], - ["p1",3,"op","chess_char_5_diy1_a",0,null,0,0,5,1,41288,1865,0,28,1], - ["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",0,0,8,2,117437,2374,0,51,1], - ["p1",5,"op","chess_char_6_diy1_a",2,null,1051.96,1,71,0,9905,3535,0,0,2], - ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",0,0,68,0,22211,5712,0,0,2], - ["p1",7,"op","chess_char_5_diy1_a",2,null,1154,1,72,0,0,10386,0,0,0], - ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",1448,1,73,0,0,10136,0,0,0], - ["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,43,0,0,5276,0,0,0], - ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",0,0,52,0,0,7062,0,0,0], - ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,6,0,13315,1160,0,28,1], - ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",0,0,54,0,19210,2860,0,23,1], + ["p1",1,"op","chess_char_5_diy1_a",0,null,1888.22,1,97,0,0,3926,0,0,0], + ["p1",2,"op","chess_char_5_diy2_a",1,null,1938,1,98,0,0,3876,0,0,0], + ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,92,0,0,3876,0,0,0], + ["p1",4,"op","chess_char_5_diy2_b",0,"none",0,0,96,0,0,4734,0,0,0], + ["p1",5,"op","chess_char_5_diy1_b",1,"none",0,0,63,0,0,4734,0,0,0], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",0,0,59,0,0,4734,0,0,0], + ["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_nasti",0,0,56,0,0,4890,0,0,0], + ["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_nasti",0,0,8,0,0,2445,0,0,0], + ["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_nasti",0,0,60,0,0,4890,0,0,0], + ["p1",10,"op","chess_char_6_diy1_a",0,null,0,0,66,0,0,3876,0,0,0], + ["p1",11,"op","chess_char_6_diy2_a",1,null,0,0,64,0,0,3876,0,0,0], + ["p1",12,"op","chess_char_6_diy1_a",2,null,0,0,6,0,0,1938,0,0,0], ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null] ], - "enemies": {"enemy_1005_yokai_2":[12,12,0,102,639],"enemy_1005_yokai_3":[12,0,11,29968,87430],"enemy_9009_acfort":[12,3,9,26331,135297]}, - "hooks": [1,73,6900,439,439,497,550,0,47,67,8,24,0,10810,814,0,36,20,1,35,0,0,17], - "events": {"atk":472,"deploy":35,"die":47,"dmg":550,"fx":92,"leak":20,"skill":48,"spawn":50,"status":162}, - "fx": {"beam":35,"dodge":21,"dp":4,"slash":32}, - "snaps": "7c7487b3 43a949cf 206930da 90551654 2113b5a2 a7c4855b 59932997 42395489 3f83a15d ffbd721d 646072f3 fc3be7c4 b210ec54 960a9e51 aa1af455 09ccfce3 50e6e3d0 380e639b 9eaf5bef 3b943f89 3933d1fc 3718a2f5 a8ae4307" + "enemies": {"enemy_1005_yokai_2":[18,0,18,819,0],"enemy_1041_lazerd_2":[24,0,24,46976,0],"enemy_1042_frostd":[30,0,30,0,0]}, + "hooks": [1,98,6584,197,197,181,181,0,22,94,0,0,0,7405,0,0,72,72,1,22,0,0,0], + "events": {"atk":197,"deploy":24,"die":22,"dmg":181,"fx":25,"leak":72,"spawn":86,"status":46}, + "fx": {"spGift":25}, + "snaps": "56640ba8 1c39d9ef 0f24c7b3 0fa0cfa7 45a9ab31 f3d3e707 455f2fc5 ebaf173f d017acd6 ba452c9a 1b7373b8 1b4eba9a 7a84314e d23104cc 21feb09c 4ded970b 7cf42f09 51ad43fa 3f316046 80c09be1 d936b443 611528aa" }, "diy-109": { - "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=nasti/S1 6_diy2_b=nasti/S2 6_diy1_b=nasti/S3 6_diy2_b=nasti/S1/002_nasti 6_diy1_b=nasti/S2/002_nasti 6_diy2_b=nasti/S3/002_nasti 5_diy1_a=necras/S1 5_diy2_a=necras/S2 5_diy1_a=necras/S3 5_diy2_b=necras/S1 5_diy1_b=necras/S2 5_diy2_b=necras/S3", "fast": false, "kind": "normal", "stageId": "act2autochess_m03", @@ -4624,38 +4624,309 @@ "round": 13, "seed": 500258608, "waveId": "act1autochess_h06", - "time": 215.33, - "ticks": 6460, - "reason": "cleared", - "killed": 15, + "time": 230, + "ticks": 6900, + "reason": "timeout", + "killed": 53, "total": 123, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 453, + "rngDraws": 35, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":15,"total":123,"perfect":false,"leaks":108,"leaksUncounted":0,"coins":0,"dmg":280261,"bossDmg":0,"heal":30749,"deaths":28,"layerGains":{}}}, + "players": {"p1":{"killed":53,"total":123,"perfect":false,"leaks":70,"leaksUncounted":0,"coins":0,"dmg":779475,"bossDmg":0,"heal":23859,"deaths":18,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,5,0,1376,2285,0,5,0], - ["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,7,0,982,2494,0,2,0], - ["p1",3,"op","chess_char_6_diy1_a",2,null,1154,1,155,2,36128,9372,0,79,0], - ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",1484,1,156,2,52788,20167,0,62,0], - ["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,140,4,59650,2964,0,77,1], - ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",0,0,154,6,91210,11325,0,66,1], - ["p1",7,"op","chess_char_6_diy1_a",2,null,0,0,86,0,3596,3133,0,4,0], - ["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",0,0,83,0,19042,8583,0,5,1], - ["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,4,0,0,2326,11819,20,1], - ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",0,0,10,0,0,4670,18930,25,1], - ["p1",11,"op","chess_char_6_diy1_a",2,null,0,0,108,0,5752,2320,0,16,1], - ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",0,0,8,1,9738,1450,0,16,1], + ["p1",1,"op","chess_char_6_diy1_b",0,"none",813.52,1,183,3,28597,6287,0,32,2], + ["p1",2,"op","chess_char_6_diy2_b",1,"none",0,0,143,1,12666,4734,0,28,3], + ["p1",3,"op","chess_char_6_diy1_b",2,"none",377.74,1,172,3,35651,6723,0,47,3], + ["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_002_nasti",2060.13,1,187,2,22965,5476,0,32,2], + ["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_002_nasti",1212.06,1,91,3,44631,3812,0,90,5], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_nasti",0,0,163,4,46469,5024,0,54,3], + ["p1",7,"op","chess_char_5_diy1_a",0,null,1443,1,188,3,29486,1443,0,42,5], + ["p1",8,"op","chess_char_5_diy2_a",1,null,0,0,10,6,69691,1443,0,47,4], + ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,113,0,33963,2886,0,18,2], + ["p1",10,"op","chess_char_5_diy2_b",0,"none",0,0,103,0,6266,3524,0,12,1], + ["p1",11,"op","chess_char_5_diy1_b",1,"none",239.3,1,4,10,153058,1523,0,76,8], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,8,7,164359,1762,0,65,6], + ["-","~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], + ["p1","~1","token","token_10043_necras_skeltn",null,null,0,0,21,0,925,1744,0,4,null], + ["p1","~2","token","token_10043_necras_skeltn",null,null,0,0,36,1,9234,5557,3834,25,null], + ["p1","~3","token","token_10043_necras_skeltn",null,null,0,0,37,0,1814,4605,0,3,0], + ["p1","~4","token","token_10043_necras_skeltn",null,null,0,0,38,0,1387,5232,0,2,0], + ["p1","~5","token","token_10043_necras_skeltn",null,null,0,0,39,0,1210,1535,0,6,null], + ["p1","~6","token","token_10043_necras_skeltn",null,null,0,0,40,0,202,1535,0,1,null], + ["p1","~7","token","token_10043_necras_skeltn",null,null,0,0,41,0,806,1535,0,4,null], + ["p1","~8","token","token_10043_necras_skeltn",null,null,0,0,45,5,46383,5232,0,18,3], + ["p1","~9","token","token_10043_necras_skeltn",null,null,0,0,47,0,1169,1535,0,5,null], + ["p1","~10","token","token_10043_necras_skeltn",null,null,0,0,48,0,3537,3785,297,8,null], + ["p1","~11","token","token_10043_necras_skeltn",null,null,0,0,50,0,1048,1535,0,4,null], + ["p1","~12","token","token_10043_necras_skeltn",null,null,155.89,0,53,0,2379,3737,1646,7,null], + ["p1","~13","token","token_10043_necras_skeltn",null,null,1535,0,54,0,1008,0,0,5,null], + ["p1","~14","token","token_10043_necras_skeltn",null,null,0,0,55,0,1845,3070,0,5,null], + ["p1","~15","token","token_10043_necras_skeltn",null,null,0,0,56,0,635,3261,191,2,null], + ["p1","~16","token","token_10043_necras_skeltn",null,null,2497.95,0,59,0,0,1121,1097,0,null], + ["p1","~17","token","token_10043_necras_skeltn",null,null,0,0,63,1,5694,6164,2676,15,null], + ["p1","~18","token","token_10043_necras_skeltn",null,null,0,0,92,0,605,1535,0,3,null], + ["p1","~19","token","token_10043_necras_skeltn",null,null,0,0,94,0,605,3070,0,2,null], + ["p1","~20","token","token_10043_necras_skeltn",null,null,0,0,96,0,1935,1535,0,8,null], + ["p1","~21","token","token_10043_necras_skeltn",null,null,0,0,97,0,806,3070,0,2,null], + ["p1","~22","token","token_10043_necras_skeltn",null,null,0,0,98,0,1210,3070,0,3,null], + ["p1","~23","token","token_10043_necras_skeltn",null,null,0,0,100,0,806,1535,0,4,null], + ["p1","~24","token","token_10043_necras_skeltn",null,null,0,0,108,0,462,1744,0,2,null], + ["p1","~25","token","token_10043_necras_skeltn",null,null,0,0,110,0,3745,5232,0,5,0], + ["p1","~26","token","token_10043_necras_skeltn",null,null,0,0,112,0,403,1535,0,2,null], + ["p1","~27","token","token_10043_necras_skeltn",null,null,0,0,116,0,647,1744,0,2,null], + ["p1","~28","token","token_10043_necras_skeltn",null,null,3070,0,118,0,0,0,0,0,null], + ["p1","~29","token","token_10043_necras_skeltn",null,null,3070,0,119,0,0,0,0,0,null], + ["p1","~30","token","token_10043_necras_skeltn",null,null,2879.32,0,121,0,0,465,549,0,null], + ["p1","~31","token","token_10043_necras_skeltn",null,null,712.15,0,122,0,0,823,0,0,null], + ["p1","~32","token","token_10043_necras_skeltn",null,null,2876.74,0,125,0,2081,2355,0,3,0], + ["p1","~33","token","token_10043_necras_skeltn",null,null,0,0,132,0,1699,1744,0,9,null], + ["p1","~34","token","token_10043_necras_skeltn",null,null,0,0,133,0,8485,5608,2120,18,null], + ["p1","~35","token","token_10043_necras_skeltn",null,null,712.15,0,151,0,1210,823,0,6,null], + ["p1","~36","token","token_10043_necras_skeltn",null,null,0,0,162,0,1156,3488,0,3,null], + ["p1","~37","token","token_10043_necras_skeltn",null,null,3488,1,170,2,15252,5331,6797,31,null], + ["p1","~38","token","token_10043_necras_skeltn",null,null,3488,1,175,2,11289,4652,4652,27,null] + ], + "enemies": {"enemy_1019_jshoot_2":[57,34,21,68313,289208],"enemy_1299_ymkilr_2":[27,2,25,61482,125997],"enemy_1422_lrsldr_2":[39,17,22,15384,364270]}, + "hooks": [1,188,6900,1796,1796,2240,2240,51,98,175,47,44,0,27488,22,106,123,68,1,98,0,0,0], + "events": {"atk":1811,"deploy":62,"die":107,"dmg":1991,"fx":398,"heal":26,"leak":68,"skill":91,"spawn":176,"status":38}, + "fx": {"aoe":54,"grow":51,"shield":55,"sleep":22,"spGift":178,"summon":38}, + "snaps": "94942597 3f389ccc 71e59db9 99137ad5 10526115 3f9add15 dbabcaf1 5edc4dac 7b40b75d 2cf88d9c c681c609 228ae7fe d8dd326b e9e118c9 864cf266 5138d0c5 fe8ac5f4 dfd62790 8c473664 0ba217de 13127480 e7a5c977 31b40ca3" + }, + "diy-110": { + "about": "5_diy1_b=necras/S1/002_necras 5_diy2_b=necras/S2/002_necras 5_diy1_b=necras/S3/002_necras 6_diy1_a=necras/S1 6_diy2_a=necras/S2 6_diy1_a=necras/S3 6_diy2_b=necras/S1 6_diy1_b=necras/S2 6_diy2_b=necras/S3 6_diy1_b=necras/S1/002_necras 6_diy2_b=necras/S2/002_necras 6_diy1_b=necras/S3/002_necras", + "fast": false, + "kind": "normal", + "stageId": "act2autochess_m04", + "modeId": "mode_multi_hard", + "round": 13, + "seed": 2140781790, + "waveId": "act1autochess_h06", + "time": 223.03, + "ticks": 6691, + "reason": "cleared", + "killed": 9, + "total": 144, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 19, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":9,"total":144,"perfect":false,"leaks":135,"leaksUncounted":0,"coins":0,"dmg":347681,"bossDmg":0,"heal":5866,"deaths":22,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_necras",1882,1,190,0,56617,3764,0,22,3], + ["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_necras",0,0,169,3,24339,3764,0,20,2], + ["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_necras",0,0,189,0,12902,5646,0,19,1], + ["p1",4,"op","chess_char_6_diy1_a",0,null,0,0,181,0,2336,4329,0,9,0], + ["p1",5,"op","chess_char_6_diy2_a",1,null,0,0,5,0,33495,1443,0,17,2], + ["p1",6,"op","chess_char_6_diy1_a",2,null,0,0,8,2,67305,1443,0,23,2], + ["p1",7,"op","chess_char_6_diy2_b",0,"none",0,0,117,0,6990,3524,0,17,2], + ["p1",8,"op","chess_char_6_diy1_b",1,"none",0,0,105,0,1893,3524,0,7,0], + ["p1",9,"op","chess_char_6_diy2_b",2,"none",0,0,127,0,59085,3524,0,24,2], + ["p1",10,"op","chess_char_6_diy1_b",0,"uniequip_002_necras",0,0,6,1,21437,1962,0,18,2], + ["p1",11,"op","chess_char_6_diy2_b",1,"uniequip_002_necras",0,0,7,2,28281,1962,0,14,1], + ["p1",12,"op","chess_char_6_diy1_b",2,"uniequip_002_necras",0,0,116,0,12799,3924,0,18,1], + ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], + ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], + ["p1","~1","token","token_10043_necras_skeltn",null,null,0,0,25,0,723,1744,0,5,null], + ["p1","~2","token","token_10043_necras_skeltn",null,null,3488,0,33,0,780,0,0,2,null], + ["p1","~3","token","token_10043_necras_skeltn",null,null,0,0,34,1,3359,3488,0,10,null], + ["p1","~4","token","token_10043_necras_skeltn",null,null,0,0,35,0,1618,4456,968,4,null], + ["p1","~5","token","token_10043_necras_skeltn",null,null,0,0,36,0,706,1535,0,2,null], + ["p1","~6","token","token_10043_necras_skeltn",null,null,0,0,37,0,1089,4605,0,3,0], + ["p1","~7","token","token_10043_necras_skeltn",null,null,100.97,0,38,0,4133,6774,3387,12,null], + ["p1","~8","token","token_10043_necras_skeltn",null,null,0,0,39,0,121,1535,0,1,0], + ["p1","~9","token","token_10043_necras_skeltn",null,null,0,0,44,0,882,4088,1511,4,0], + ["p1","~10","token","token_10043_necras_skeltn",null,null,0,0,48,0,756,4605,0,2,0], + ["p1","~11","token","token_10043_necras_skeltn",null,null,0,0,53,0,809,3488,0,2,null], + ["p1","~12","token","token_10043_necras_skeltn",null,null,0,0,55,0,1214,1744,0,3,null], + ["p1","~13","token","token_10043_necras_skeltn",null,null,0,0,61,0,809,1744,0,2,null], + ["p1","~14","token","token_10043_necras_skeltn",null,null,0,0,62,0,353,1535,0,1,null], + ["p1","~15","token","token_10043_necras_skeltn",null,null,0,0,63,0,445,1744,0,2,null], + ["p1","~16","token","token_10043_necras_skeltn",null,null,0,0,64,0,202,1744,0,1,null], + ["p1","~17","token","token_10043_necras_skeltn",null,null,0,0,66,0,809,1744,0,2,null], + ["p1","~18","token","token_10043_necras_skeltn",null,null,0,0,67,0,353,1535,0,1,null], + ["p1","~19","token","token_10043_necras_skeltn",null,null,0,0,69,0,607,1744,0,3,null], + ["p1","~20","token","token_10043_necras_skeltn",null,null,0,0,70,0,289,1744,0,2,null], + ["p1","~21","token","token_10043_necras_skeltn",null,null,0,0,73,0,145,1744,0,1,null] + ], + "enemies": {"enemy_1251_lysyta_2":[51,9,42,6741,117116],"enemy_1252_lysytb_2":[54,0,54,60636,113654],"enemy_1329_cbshld_2":[39,0,39,24772,151911]}, + "hooks": [1,190,6691,441,441,1548,1548,25,50,187,18,18,0,12477,9,35,144,135,1,50,0,0,0], + "events": {"atk":441,"deploy":44,"die":52,"dmg":1548,"fx":76,"heal":5,"leak":135,"skill":36,"spawn":179,"status":14}, + "fx": {"aoe":21,"grow":25,"sleep":9,"summon":21}, + "snaps": "8fcc5e4f 0f7da186 17d196b7 b58c651e 4c9a47bd 86f69a05 8c551995 be136342 d6497f7b 32baaaff da84bd33 af2653ed b9f3ea94 7788fcaa 14724b5e 07d44336 39c114a6 dc76a54d c41d4fc4 b6f2c2eb 438f2af0 ebf05254 15fe2e83" + }, + "diy-111": { + "about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 5_diy1_a=csnipe 5_diy2_b=csnipe 5_diy1_a=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo", + "fast": true, + "kind": "normal", + "stageId": "act1autochess_m01", + "modeId": "mode_single_normal", + "round": 12, + "seed": 452407399, + "waveId": "act1autochess_h05", + "time": 216.83, + "ticks": 6505, + "reason": "cleared", + "killed": 13, + "total": 99, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 0, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":13,"total":99,"perfect":false,"leaks":86,"leaksUncounted":0,"coins":0,"dmg":304937,"bossDmg":0,"heal":8682,"deaths":15,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,123,0,1472,6384,0,32,0], + ["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,81,1,11090,5816,0,90,2], + ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,120,1,19609,7692,0,33,0], + ["p1",4,"op","chess_char_5_diy2_b",2,"none",3334,1,124,4,33655,6668,0,37,1], + ["p1",5,"op","chess_char_5_diy1_a",2,null,1271,1,4,4,51924,0,0,185,3], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",1510,1,5,1,71779,0,0,187,3], + ["p1",7,"op","chess_char_5_diy1_a",2,null,1133,1,11,1,41478,0,0,153,3], + ["p1",8,"op","chess_char_5_diy2_b",2,"none",1360,1,13,0,49245,0,0,159,3], + ["p1",9,"op","chess_char_5_diy1_a",2,null,1218,1,6,0,0,0,8682,26,3], + ["p1",10,"op","chess_char_5_diy2_b",2,"none",1374,1,14,0,0,0,0,0,0], + ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,114,0,3950,2796,0,17,0], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,86,1,20735,10944,0,78,2], ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null] ], - "enemies": {"enemy_1019_jshoot_2":[57,10,47,52058,82202],"enemy_1299_ymkilr_2":[27,0,27,9819,9240],"enemy_1422_lrsldr_2":[39,5,34,9213,188819]}, - "hooks": [1,156,6460,632,632,849,850,92,43,151,7,20,0,9133,611,30,123,108,1,43,0,0,0], - "events": {"atk":705,"deploy":30,"die":43,"dmg":822,"fx":36,"heal":76,"leak":108,"skill":42,"spawn":138,"status":149}, - "fx": {"aoe":2,"displace":17,"dodge":2,"spGain":15}, - "snaps": "17351197 d85b764f 170d4969 276e2353 d5273c76 84c8775e 178ead82 e788140a 7761c24b 8d4c0668 9647f92d 7caa4901 24e1ffd3 00675a6a 82e3afd4 846d95f4 87dfd927 4f075077 94a7b0a4 a3a639d3 4179fb65 3ad0ed6c" + "enemies": {"enemy_1010_demon":[42,2,40,21226,115683],"enemy_1320_wdrrl_2":[12,0,12,5886,12532],"enemy_1422_lrsldr_2":[45,11,34,13189,176722]}, + "hooks": [1,124,6505,1247,1247,1221,1221,26,28,114,20,19,0,28400,94,34,99,86,1,28,0,0,0], + "events": {"atk":1247,"deploy":22,"die":28,"dmg":1221,"fx":1,"heal":26,"leak":86,"skill":39,"spawn":114,"status":187}, + "fx": {"explode":1}, + "snaps": "c483ab5c c43d24d1 3012b5e1 e015a05e 6b17b1fe 40152fac 120f3052 92460f9e 6d430249 0820bac0 f6e91bf2 f81209a2 fa43731e 7086293d cd16e934 56c0a32c e19ec163 5a5ef609 334df640 1a69c7ed 52f1c2f5 da5425f6" + }, + "diy-112": { + "about": "5_diy1_a=acfend 5_diy2_b=acfend 5_diy1_a=acpion 5_diy2_b=acpion 5_diy1_a=acguad 5_diy2_b=acguad 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m02", + "modeId": "mode_multi_abyss", + "round": 12, + "seed": 3106580722, + "waveId": "act1autochess_h05", + "time": 197.6, + "ticks": 5928, + "reason": "cleared", + "killed": 9, + "total": 135, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 204, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":9,"total":135,"perfect":false,"leaks":126,"leaksUncounted":0,"coins":0,"dmg":415122,"bossDmg":0,"heal":16340,"deaths":22,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,3,1,3246,2604,0,25,0], + ["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",0,0,1,1,14125,3371,0,45,1], + ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,124,2,41582,6571,0,29,3], + ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,104,3,101035,6650,0,27,3], + ["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,103,0,21884,5680,0,19,1], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,101,2,65446,11148,0,33,2], + ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,111,0,20912,3294,0,22,1], + ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",119.44,1,158,0,25637,5375,0,39,1], + ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,6,0,68224,2164,0,14,1], + ["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",0,0,95,0,53031,4607,0,13,1], + ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,141,0,0,4932,1260,3,0], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",0,0,153,0,0,3438,15080,14,1] + ], + "enemies": {"enemy_2021_syfish":[105,9,96,13256,339526],"enemy_2025_syufo":[30,0,30,46578,75596]}, + "hooks": [1,158,5928,586,586,945,1056,33,31,157,15,15,0,13917,0,30,135,126,1,59,0,16,29], + "events": {"atk":632,"deploy":23,"die":31,"dmg":1056,"fx":153,"heal":32,"leak":126,"skill":30,"spawn":147,"status":66}, + "fx": {"aoe":42,"burst":16,"dodge":29,"dp":11,"slash":48,"spGain":7}, + "snaps": "f1fffa0d 2e4ead28 c68dbfb0 75d60e92 28dd7c37 394efa53 2f2aabd0 2343aed0 5ead163f 3efc95b3 7649823f e5961d99 7f86e2ed 8c75f677 2b7c65c1 de7b4948 22db0221 4ff706e7 f02a4505 f70972db" + }, + "diy-113": { + "about": "6_diy1_a=acfend 6_diy2_b=acfend 5_diy1_a=sharp2 5_diy2_b=sharp2 6_diy1_a=acpion 6_diy2_b=acpion 5_diy1_a=acspec 5_diy2_b=acspec 6_diy1_a=acguad 6_diy2_b=acguad 5_diy1_a=acsupo 5_diy2_b=acsupo", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m03", + "modeId": "mode_multi_funny", + "round": 13, + "seed": 1466195980, + "waveId": "act1autochess_h06", + "time": 200.47, + "ticks": 6014, + "reason": "cleared", + "killed": 69, + "total": 144, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 203, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":69,"total":144,"perfect":false,"leaks":75,"leaksUncounted":0,"coins":0,"dmg":508542,"bossDmg":0,"heal":0,"deaths":24,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",2,null,0,0,4,4,17615,2604,0,49,1], + ["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",0,0,5,6,24416,3431,0,48,1], + ["p1",3,"op","chess_char_5_diy1_a",0,null,0,0,160,6,53183,3730,0,70,1], + ["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",0,0,155,15,157188,4748,0,87,1], + ["p1",5,"op","chess_char_6_diy1_a",2,null,41,1,172,5,40925,4546,0,21,2], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",1805,1,175,16,100705,3907,0,33,4], + ["p1",7,"op","chess_char_5_diy1_a",2,null,1121,1,176,1,25672,9265,0,50,0], + ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",1185,1,121,4,15855,1711,0,34,0], + ["p1",9,"op","chess_char_6_diy1_a",2,null,2041,1,166,4,18403,3235,0,22,1], + ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",1250,1,143,6,17122,5812,0,14,1], + ["p1",11,"op","chess_char_5_diy1_a",2,null,1160,1,178,1,17171,1160,0,53,1], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",0,0,135,1,20287,2860,0,42,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_2021_syfish":[99,58,41,7589,243386],"enemy_2025_syufo":[45,11,34,39420,265157]}, + "hooks": [1,178,6014,1142,1142,1613,2045,0,93,168,14,23,0,18585,1230,71,144,75,1,93,0,32,16], + "events": {"atk":1184,"deploy":31,"die":93,"dmg":2031,"fx":189,"leak":75,"skill":49,"spawn":159,"status":356}, + "fx": {"aoe":60,"burst":32,"displace":5,"dodge":22,"dp":27,"slash":43}, + "snaps": "de90f0a5 759b0c3a c39a5b8a fa9acb47 839ff747 a9da663c 92684f45 89723c39 48fd5534 c8914ffd f805490d c236abdb f8e576f2 c08df0a5 c194716c e8ee08eb 096b0ab7 b3b92f7b b13fcc00 9be81275" + }, + "diy-114": { + "about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc 6_diy1_a=acsupo 6_diy2_b=acsupo", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m04", + "modeId": "mode_single_hard", + "round": 13, + "seed": 4120508441, + "waveId": "act1autochess_h06", + "time": 205.67, + "ticks": 6170, + "reason": "cleared", + "killed": 122, + "total": 192, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 991, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":122,"total":192,"perfect":false,"leaks":70,"leaksUncounted":0,"coins":0,"dmg":670162,"bossDmg":0,"heal":46494,"deaths":25,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,178,5,43295,3934,0,51,1], + ["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,6,22,13148,2494,0,39,1], + ["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,219,2,21846,8078,0,19,0], + ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",1484,1,224,5,52253,13200,0,51,0], + ["p1",5,"op","chess_char_6_diy1_a",2,null,1272,1,4,23,79207,0,0,180,2], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",1643,1,5,35,114330,0,0,186,2], + ["p1",7,"op","chess_char_6_diy1_a",2,null,1372,1,15,4,216108,0,0,75,2], + ["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",0,0,205,9,72144,3638,0,33,1], + ["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,190,0,0,2908,11119,25,1], + ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",0,0,12,0,0,24392,35374,57,2], + ["p1",11,"op","chess_char_6_diy1_a",2,null,0,0,139,1,13963,4532,0,46,1], + ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",0,0,14,2,43869,19327,0,91,2], + ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], + ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], + ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null] + ], + "enemies": { + "enemy_1197_sfshu": [15,11,4,1177,60295], + "enemy_1197_sfshu_2": [39,22,17,11441,182028], + "enemy_1198_msfshu": [33,28,5,0,87], + "enemy_1198_msfshu_2": [66,51,15,0,216], + "enemy_9010_acpupp": [39,10,29,60856,505701] + }, + "hooks": [1,224,6170,969,969,3329,3121,155,147,217,15,29,0,21470,5920,45,192,70,1,161,0,0,0], + "events": {"atk":1309,"deploy":29,"die":147,"dmg":3064,"fx":156,"heal":114,"leak":70,"skill":56,"spawn":207,"status":688}, + "fx": {"aoe":18,"displace":24,"dodge":5,"ember":14,"revive":2,"shieldBreak":41,"spGain":5,"summon":33,"telegraph":14}, + "snaps": "052f6301 53938868 38f85ad5 39e3b0ee 87b525a1 113be68f a004c4d3 f24bde7d 54cd2c7c 0ed4e64a f8ceb24d 42d7ad5a e71b7cd4 6db6b3d9 6594d286 6a111077 2d0b44a1 366e5b47 86d5661e 095a92d8 3492d771" } } } diff --git a/test/sim/feedback5-o23-engine.test.js b/test/sim/feedback5-o23-engine.test.js new file mode 100644 index 00000000..0a976ab0 --- /dev/null +++ b/test/sim/feedback5-o23-engine.test.js @@ -0,0 +1,72 @@ +// test/sim/feedback5-o23-engine.test.js — the additive engine rule the 0.2.0 自选 kit of 白铁 needs: ally targets +// (Battle.setAllyTarget / allyTargetsInKeys / isAllyTarget, ai.js acquireTargets / performAttack). 白铁's 铁钳号·原型机 is a +// summon of the enemy camp (PRTS 铁钳号·原型机 备注 "该召唤物阵营为敌方") that "可被我方干员攻击但不受伤害": our attacks select a +// registered ally standing on the attacker's range after every enemy (嘲讽等级 −2), ranged attacks fly to it; heals never +// select it; with none registered the attack loop is exactly the old one. Cancelling the damage it takes is the kit's job +// (test/content/op_ironmn.test.js); here a plain guard is registered and takes the hits. +// Run: node --test test/sim/feedback5-o23-engine.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js'; +import { acquireTargets } from '../../server/sim/ai.js'; + +const ENEMIES = { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e9, speed: 0, mass: 0 }) }; +const CHESS = { + test_guard_a: chessRec({ id: 'test_guard_a', profession: 'WARRIOR', stats: { maxHp: 1e7, atk: 300, def: 0, res: 0 } }), + test_sniper_a: chessRec({ id: 'test_sniper_a', profession: 'SNIPER', stats: { maxHp: 2000, atk: 200, def: 0, res: 0 }, rangeGrid: [[0, 0], [0, 1], [0, 2], [0, 3]] }), + test_medic_a: chessRec({ id: 'test_medic_a', profession: 'MEDIC', subProf: 'physician', stats: { maxHp: 2000, atk: 100, def: 0, res: 0 }, rangeGrid: [[0, 0], [0, 1], [0, 2]] }), +}; + +function field() { + const h = makeBattle({ + defs: { enemies: ENEMIES, chess: CHESS }, timeLimit: 600, autoFinish: false, seed: 3, hooks: ['damaged'], captureNoisy: true, + units: [ + { uid: 1, chessId: 'test_guard_a', row: 10, col: 4 }, // melee 1-1: (10, 4), (10, 5) + { uid: 2, chessId: 'test_guard_a', row: 10, col: 5 }, // the ally target + { uid: 3, chessId: 'test_sniper_a', row: 10, col: 2 }, // ranged: (10, 2) … (10, 5) + { uid: 4, chessId: 'test_medic_a', row: 10, col: 3 }, + ], + }); + h.step(); + return { h, guard: h.unit(1), target: h.unit(2), sniper: h.unit(3), medic: h.unit(4) }; +} + +test('ally targets: none registered ⇒ nobody selects an ally; registered ⇒ the attackers whose range holds it select it, after every enemy', () => { + const { h, guard, target, sniper, medic } = field(); + assert.equal(h.b.isAllyTarget(target), false); + assert.deepEqual(acquireTargets(h.b, guard, guard.profile), [], 'not registered: no target'); + assert.ok(h.b.setAllyTarget(target, true)); + assert.ok(h.b.isAllyTarget(target)); + assert.deepEqual(h.b.allyTargetsInKeys(guard.rangeKeys, guard).map((u) => u.id), [target.id]); + assert.deepEqual(h.b.allyTargetsInKeys(target.rangeKeys, target), [], 'never itself'); + assert.deepEqual(acquireTargets(h.b, guard, guard.profile).map((u) => u.id), [target.id], 'no enemy: the ally target'); + assert.deepEqual(acquireTargets(h.b, medic, medic.profile), [], 'a healer never selects it'); + // an enemy in range comes first; all-in-range / two targets take both + const e = h.spawn('enemy_dummy', { pos: [10, 5] }); + h.step(); + assert.deepEqual(acquireTargets(h.b, guard, guard.profile).map((u) => u.id), [e.id], 'one target: the enemy'); + assert.deepEqual(acquireTargets(h.b, guard, { ...guard.profile, maxTargets: 2 }).map((u) => u.id), [e.id, target.id], 'two: the enemy, then it'); + assert.deepEqual(acquireTargets(h.b, guard, { ...guard.profile, allInRange: true }).map((u) => u.id), [e.id, target.id], 'all in range: both'); + h.b.setAllyTarget(target, false); + assert.deepEqual(acquireTargets(h.b, guard, { ...guard.profile, maxTargets: 2 }).map((u) => u.id), [e.id], 'dropped: enemies only'); + void sniper; + checkInvariants(h.b); +}); + +test('ally targets: attacks on it are real attacks (melee instant, ranged by projectile); off the field it is skipped', () => { + const { h, guard, target, sniper } = field(); + h.b.setAllyTarget(target, true); + h.run(3); + const by = (src) => h.hooksOf('damaged').filter((c) => c.source === src && c.target === target && c.dmg?.isAttack); + assert.ok(by(guard).length >= 1, 'the guard hits it'); + assert.ok(by(sniper).length >= 1, 'the sniper hits it'); + let flew = false; + for (let i = 0; i < 90 && !flew; i++) { h.step(); flew = h.b.projectiles.list.some((p) => p.source === sniper && p.target === target); } + assert.ok(flew, 'by projectile'); + h.b.retreat(target, { reason: 'retreat' }); + h.step(); + assert.deepEqual(h.b.allyTargetsInKeys(guard.rangeKeys, guard), [], 'off the field: skipped while registered'); + assert.ok(h.b.isAllyTarget(target)); + checkInvariants(h.b); +});