feedback5: merge O23 — 自选 kits for 白铁 (devices, 铁钳号), 娜斯提 (three 支援装置, the built 高台) and 死芒 (复燃 servants, special form); engine (additive): an ally our attacks select (queries / ai, after enemies in target order, never healed); golden: only diy moved
This commit is contained in:
commit
9f68afe9f9
12 files changed
+2909
-133
No files matched your search
@@ -802,6 +802,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 }` |
|
||||
|
||||
+15
-3
@@ -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));
|
||||
@@ -168,9 +179,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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -145,6 +145,9 @@ export const OPERATOR_KIT_FILES = Object.freeze([
|
||||
'op-slent2.js',
|
||||
'op-haruka.js',
|
||||
'op-phatm2.js',
|
||||
'op-ironmn.js',
|
||||
'op-nasti.js',
|
||||
'op-necras.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 });
|
||||
} },
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
// 娜斯提 (前方施工 备注 "所有装置的效果均为对自身攻击范围内所有单位生效的光环效果", "自身<支援装置>提供的效果均可叠加", "<X模组>首个
|
||||
// 装置部署费用-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 });
|
||||
} },
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
+417
-129
@@ -5443,8 +5443,8 @@
|
||||
"snaps": "c4749bd0 699d0248 17649b18 7ab3fc70 09e3e1e3 6258b044 7238ac54 9f57607a 65ba9fc3 0b8e90f3 add042bf e94c1af2 fdf621ea 0e16b2df 721d914c d0aaa024 f828a7ac 7da502ae c52b962c 070c0db9 2a07d5b3 eca02286 05f89efe"
|
||||
},
|
||||
"diy-127": {
|
||||
"about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 6_diy1_b=phatm2/S1 6_diy2_b=phatm2/S2 6_diy1_b=phatm2/S3 6_diy2_b=phatm2/S1/002_phatm2 6_diy1_b=phatm2/S2/002_phatm2 6_diy2_b=phatm2/S3/002_phatm2 5_diy1_a=csnipe 5_diy2_b=csnipe",
|
||||
"fast": true,
|
||||
"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 6_diy1_b=phatm2/S1 6_diy2_b=phatm2/S2 6_diy1_b=phatm2/S3 6_diy2_b=phatm2/S1/002_phatm2 6_diy1_b=phatm2/S2/002_phatm2 6_diy2_b=phatm2/S3/002_phatm2",
|
||||
"fast": false,
|
||||
"kind": "normal",
|
||||
"stageId": "act1autochess_m06",
|
||||
"modeId": "mode_multi_normal",
|
||||
@@ -5454,133 +5454,139 @@
|
||||
"time": 230,
|
||||
"ticks": 6900,
|
||||
"reason": "timeout",
|
||||
"killed": 30,
|
||||
"killed": 35,
|
||||
"total": 105,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 0,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":30,"total":105,"perfect":false,"leaks":75,"leaksUncounted":0,"coins":0,"dmg":1853739,"bossDmg":0,"heal":0,"deaths":12,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":35,"total":105,"perfect":false,"leaks":70,"leaksUncounted":0,"coins":0,"dmg":2349501,"bossDmg":0,"heal":0,"deaths":9,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_diy1_a",2,null,7.11,1,3,0,4818,2121,0,144,3],
|
||||
["p1",2,"op","chess_char_5_diy2_b",2,"none",2908,1,6,0,0,0,0,0,0],
|
||||
["p1",3,"op","chess_char_5_diy1_a",2,null,2564,1,9,0,0,0,0,0,0],
|
||||
["p1",4,"op","chess_char_5_diy2_b",2,"none",0,0,127,0,7994,10002,0,41,0],
|
||||
["p1",5,"op","chess_char_6_diy1_b",0,"none",1111,1,1,8,375991,0,0,131,32],
|
||||
["p1",6,"op","chess_char_6_diy2_b",1,"none",274.62,1,4,4,332693,836,0,164,1],
|
||||
["p1",7,"op","chess_char_6_diy1_b",2,"none",1111,1,124,8,321763,1111,0,93,3],
|
||||
["p1",8,"op","chess_char_6_diy2_b",0,"uniequip_002_phatm2",1311,1,2,5,446608,0,0,131,32],
|
||||
["p1",9,"op","chess_char_6_diy1_b",1,"uniequip_002_phatm2",0,0,131,4,158830,2622,0,95,1],
|
||||
["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_phatm2",474.62,1,135,1,193400,3458,0,53,2],
|
||||
["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,118,0,3784,3813,0,30,0],
|
||||
["p1",12,"op","chess_char_5_diy2_b",2,"none",1510,1,136,0,7859,1510,0,152,2],
|
||||
["p1","~1","token","token_10055_phatm2_mndclv",null,null,3,0,0,0,0,0,0,0,null],
|
||||
["p1","~2","token","token_10055_phatm2_mndclv",null,null,3,0,0,0,0,0,0,0,null],
|
||||
["p1","~3","token","token_10055_phatm2_mndclv",null,null,3,0,28,0,0,0,0,0,null],
|
||||
["p1","~4","token","token_10055_phatm2_mndclv",null,null,3,0,0,0,0,0,0,0,null],
|
||||
["p1","~5","token","token_10055_phatm2_mndclv",null,null,3,0,32,0,0,0,0,0,null],
|
||||
["p1",1,"op","chess_char_5_diy1_a",0,null,438.15,1,3,2,10075,1604,0,101,3],
|
||||
["p1",2,"op","chess_char_5_diy2_a",1,null,2042,1,6,0,0,0,0,0,0],
|
||||
["p1",3,"op","chess_char_5_diy1_a",2,null,2042,1,9,0,0,0,0,0,0],
|
||||
["p1",4,"op","chess_char_5_diy2_b",0,"none",0,0,119,0,671,7479,0,24,0],
|
||||
["p1",5,"op","chess_char_5_diy1_b",1,"none",0,0,7,0,15538,2493,0,129,3],
|
||||
["p1",6,"op","chess_char_5_diy2_b",2,"none",2493,1,132,0,5014,4986,0,62,1],
|
||||
["p1",7,"op","chess_char_6_diy1_b",0,"none",1111,1,1,5,258449,0,0,126,31],
|
||||
["p1",8,"op","chess_char_6_diy2_b",1,"none",617.59,1,4,4,323680,493,0,161,1],
|
||||
["p1",9,"op","chess_char_6_diy1_b",2,"none",274.62,1,5,5,557742,836,0,134,3],
|
||||
["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_002_phatm2",474.62,1,2,7,424896,836,0,131,32],
|
||||
["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_002_phatm2",474.62,1,128,6,289020,2147,0,111,2],
|
||||
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_phatm2",0,0,99,6,464417,2622,0,53,2],
|
||||
["p1","~1","token","token_10055_phatm2_mndclv",null,null,3,0,28,0,0,0,0,0,null],
|
||||
["p1","~2","token","token_10055_phatm2_mndclv",null,null,0,0,29,0,0,6,0,0,null],
|
||||
["p1","~3","token","token_10055_phatm2_mndclv",null,null,0,0,30,0,0,4,0,0,null],
|
||||
["p1","~4","token","token_10055_phatm2_mndclv",null,null,3,0,32,0,0,0,0,0,null],
|
||||
["p1","~5","token","token_10055_phatm2_mndclv",null,null,1,0,39,0,0,2,0,0,null],
|
||||
["p1","~6","token","token_10055_phatm2_mndclv",null,null,3,0,0,0,0,0,0,0,null],
|
||||
["p1","~7","token","token_10055_phatm2_mndclv",null,null,3,0,34,0,0,0,0,0,null],
|
||||
["p1","~8","token","token_10055_phatm2_mndclv",null,null,3,0,38,0,0,0,0,0,null],
|
||||
["p1","~9","token","token_10055_phatm2_mndclv",null,null,3,0,41,0,0,0,0,0,null],
|
||||
["p1","~10","token","token_10055_phatm2_mndclv",null,null,3,0,75,0,0,0,0,0,null],
|
||||
["p1","~7","token","token_10055_phatm2_mndclv",null,null,3,0,74,0,0,0,0,0,null],
|
||||
["p1","~8","token","token_10055_phatm2_mndclv",null,null,3,0,75,0,0,0,0,0,null],
|
||||
["p1","~9","token","token_10055_phatm2_mndclv",null,null,3,0,0,0,0,0,0,0,null],
|
||||
["p1","~10","token","token_10055_phatm2_mndclv",null,null,3,0,0,0,0,0,0,0,null],
|
||||
["p1","~11","token","token_10055_phatm2_mndclv",null,null,3,0,0,0,0,0,0,0,null],
|
||||
["p1","~12","token","token_10055_phatm2_mndclv",null,null,3,0,76,0,0,0,0,0,null],
|
||||
["p1","~13","token","token_10055_phatm2_mndclv",null,null,3,0,77,0,0,0,0,0,null],
|
||||
["p1","~12","token","token_10055_phatm2_mndclv",null,null,3,0,0,0,0,0,0,0,null],
|
||||
["p1","~13","token","token_10055_phatm2_mndclv",null,null,3,0,0,0,0,0,0,0,null],
|
||||
["p1","~14","token","token_10055_phatm2_mndclv",null,null,3,0,0,0,0,0,0,0,null],
|
||||
["p1","~15","token","token_10055_phatm2_mndclv",null,null,3,1,133,0,0,0,0,0,null],
|
||||
["p1","~16","token","token_10055_phatm2_mndclv",null,null,3,1,134,0,0,0,0,0,null],
|
||||
["p1","~15","token","token_10055_phatm2_mndclv",null,null,3,0,0,0,0,0,0,0,null],
|
||||
["p1","~16","token","token_10055_phatm2_mndclv",null,null,3,0,0,0,0,0,0,0,null],
|
||||
["p1","~17","token","token_10055_phatm2_mndclv",null,null,3,0,0,0,0,0,0,0,null],
|
||||
["p1","~18","token","token_10055_phatm2_mndclv",null,null,3,0,0,0,0,0,0,0,null]
|
||||
["p1","~18","token","token_10055_phatm2_mndclv",null,null,3,0,0,0,0,0,0,0,null],
|
||||
["p1","~19","token","token_10055_phatm2_mndclv",null,null,3,0,0,0,0,0,0,0,null],
|
||||
["p1","~20","token","token_10055_phatm2_mndclv",null,null,3,0,0,0,0,0,0,0,null],
|
||||
["p1","~21","token","token_10055_phatm2_mndclv",null,null,3,0,0,0,0,0,0,0,null],
|
||||
["p1","~22","token","token_10055_phatm2_mndclv",null,null,3,0,117,0,0,0,0,0,null],
|
||||
["p1","~23","token","token_10055_phatm2_mndclv",null,null,0,0,118,0,0,5,0,0,null],
|
||||
["p1","~24","token","token_10055_phatm2_mndclv",null,null,3,0,0,0,0,0,0,0,null]
|
||||
],
|
||||
"enemies": {"enemy_1116_liprr_2":[27,9,18,3738,280676],"enemy_1119_vofsd":[48,15,29,15202,794636],"enemy_1121_lifbos_2":[30,6,24,6534,778428]},
|
||||
"hooks": [1,136,6900,1074,1074,1383,3796,1745,42,121,76,73,0,17719,311,60,105,71,1,42,0,251,0],
|
||||
"events": {"atk":1074,"deploy":31,"die":50,"dmg":3796,"engage":10,"fx":486,"leak":71,"skill":149,"spawn":127,"status":752},
|
||||
"fx": {"burst":251,"liberate":3,"palsy":222,"summon":10},
|
||||
"snaps": "ff349734 43b3c2d1 5e09fdbf 588f8bb7 62eaf80f bf97ee30 1b8c2f7d 647f33c5 e595a119 78a82ab2 0afc7940 29efd966 5faf9bc5 4a9efa2e a6f21a05 1e94de9b a6059141 152adafe 8779d264 2b7ac375 c3fb645c 3dd7bb76 e51f73ed"
|
||||
"enemies": {"enemy_1116_liprr_2":[27,12,15,5313,373818],"enemy_1119_vofsd":[48,18,29,10828,928079],"enemy_1121_lifbos_2":[30,5,21,7373,1047604]},
|
||||
"hooks": [1,132,6900,1093,1093,1525,4645,0,47,118,78,76,0,22247,391,86,105,65,1,47,0,328,0],
|
||||
"events": {"atk":1135,"deploy":27,"die":53,"dmg":4645,"engage":10,"fx":690,"leak":65,"skill":154,"spawn":126,"status":938},
|
||||
"fx": {"burst":328,"liberate":1,"palsy":352,"summon":9},
|
||||
"snaps": "92d74d6f 19e080ba 84556549 3ba89a27 8cbe8636 180eb4a2 25fc0509 84e1a783 475aa2d4 5f2d2bc0 9ebcb5d4 67c57af0 d9bb9d96 b315452d 89fc63d4 850bee95 54557caa f7eea76c ba318d70 078c5aeb 16f3e6b9 32dd293c 77966ae0"
|
||||
},
|
||||
"diy-128": {
|
||||
"about": "5_diy1_a=acfend 5_diy2_b=acfend 5_diy1_a=acpion 5_diy2_b=acpion 5_diy1_a=acguad 5_diy2_b=acguad 5_diy1_a=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo",
|
||||
"about": "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 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",
|
||||
"fast": false,
|
||||
"kind": "normal",
|
||||
"stageId": "act1autochess_m07",
|
||||
"modeId": "mode_multi_hard",
|
||||
"round": 12,
|
||||
"round": 13,
|
||||
"seed": 1370290236,
|
||||
"waveId": "act1autochess_h05",
|
||||
"time": 192,
|
||||
"ticks": 5760,
|
||||
"waveId": "act1autochess_h06",
|
||||
"time": 211.4,
|
||||
"ticks": 6342,
|
||||
"reason": "cleared",
|
||||
"killed": 7,
|
||||
"total": 93,
|
||||
"killed": 0,
|
||||
"total": 165,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 18,
|
||||
"rngDraws": 0,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":7,"total":93,"perfect":false,"leaks":86,"leaksUncounted":0,"coins":0,"dmg":234674,"bossDmg":0,"heal":0,"deaths":16,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":0,"total":165,"perfect":false,"leaks":165,"leaksUncounted":0,"coins":0,"dmg":28009,"bossDmg":0,"heal":0,"deaths":23,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,84,1,14487,5208,0,52,1],
|
||||
["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",0,0,76,1,13007,6742,0,41,1],
|
||||
["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,114,1,14653,4587,0,18,1],
|
||||
["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,110,1,73028,5712,0,27,2],
|
||||
["p1",5,"op","chess_char_5_diy1_a",2,null,2638,1,117,1,8944,5276,0,13,0],
|
||||
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,73,2,51862,6822,0,33,1],
|
||||
["p1",7,"op","chess_char_5_diy1_a",2,null,1133,1,4,0,14757,0,0,56,3],
|
||||
["p1",8,"op","chess_char_5_diy2_b",2,"none",1360,1,7,0,16360,0,0,56,3],
|
||||
["p1",9,"op","chess_char_5_diy1_a",2,null,1218,1,9,0,0,0,0,0,0],
|
||||
["p1",10,"op","chess_char_5_diy2_b",2,"none",1374,1,11,0,0,0,0,0,0],
|
||||
["p1",11,"op","chess_char_5_diy1_a",2,null,932,1,13,0,13331,0,0,57,2],
|
||||
["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,70,0,14245,2262,0,38,2],
|
||||
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_ironmn",0,0,182,0,656,7479,0,6,0],
|
||||
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_ironmn",0,0,190,0,6421,7479,0,15,0],
|
||||
["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_ironmn",0,0,172,0,1781,7479,0,5,0],
|
||||
["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_003_ironmn",0,0,156,0,8227,5506,0,17,1],
|
||||
["p1",5,"op","chess_char_5_diy1_b",1,"uniequip_003_ironmn",0,0,96,0,1392,5506,0,7,0],
|
||||
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_003_ironmn",0,0,91,0,112,5506,0,4,0],
|
||||
["p1",7,"op","chess_char_6_diy1_a",0,null,0,0,85,0,1993,4084,0,5,0],
|
||||
["p1",8,"op","chess_char_6_diy2_a",1,null,0,0,3,0,768,2042,0,4,0],
|
||||
["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,5,0,2460,2042,0,5,0],
|
||||
["p1",10,"op","chess_char_6_diy2_b",0,"none",0,0,7,0,1036,2493,0,5,0],
|
||||
["p1",11,"op","chess_char_6_diy1_b",1,"none",0,0,9,0,1554,2493,0,7,0],
|
||||
["p1",12,"op","chess_char_6_diy2_b",2,"none",0,0,100,0,1610,4986,0,9,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_10065_ftzlc":[63,7,56,12751,169866],"enemy_10067_ftsjc":[30,0,30,23858,64809]},
|
||||
"hooks": [1,117,5760,641,641,734,750,0,23,109,16,16,0,20287,95,37,93,86,1,26,0,0,8],
|
||||
"events": {"atk":641,"deploy":22,"die":23,"dmg":750,"engage":10,"fx":83,"leak":86,"skill":32,"spawn":107,"status":196},
|
||||
"fx": {"dodge":8,"dp":5,"explode":14,"ignite":32,"slash":24},
|
||||
"snaps": "b735394e 44df6814 4ede3523 ceaca815 0990904d 0cf45d51 3f5f612a 3392f0f3 a2cb1d61 6c52af78 9415f3b8 49b70461 7f144b13 c9769b73 a22d4adb 6b2c276d 4b214962 73629044 b830e5dc 0cb0f98c"
|
||||
"enemies": {"enemy_1249_lysdb_2":[60,0,60,20749,8688],"enemy_1251_lysyta":[51,0,51,9898,18927],"enemy_1254_lypa_2":[54,0,54,26448,394]},
|
||||
"hooks": [1,190,6342,214,214,214,206,0,23,188,1,1,0,8622,0,43,165,165,1,23,0,0,0],
|
||||
"events": {"atk":214,"deploy":23,"die":23,"dmg":206,"engage":12,"fx":8,"leak":165,"skill":2,"spawn":179},
|
||||
"fx": {"shieldBreak":8},
|
||||
"snaps": "bbce30bd 6cc5009f 649818bd 28c272d5 25acc4e8 ccdb3ca6 e85b1bb8 553fc390 9b2abe11 596023e6 2a2add24 afade29d 3e8049c5 72a07209 61a79135 572cd571 10e94bea 9de78af0 50db5948 6a92b788 812a7698 1fbb52b7"
|
||||
},
|
||||
"diy-129": {
|
||||
"about": "5_diy1_a=sharp2 5_diy2_b=sharp2 5_diy1_a=acspec 5_diy2_b=acspec 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc 5_diy1_a=acsupo 5_diy2_b=acsupo",
|
||||
"about": "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 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",
|
||||
"fast": false,
|
||||
"kind": "normal",
|
||||
"stageId": "act2autochess_m01",
|
||||
"modeId": "mode_single_normal",
|
||||
"round": 12,
|
||||
"round": 13,
|
||||
"seed": 208547970,
|
||||
"waveId": "act1autochess_h05",
|
||||
"time": 213.77,
|
||||
"ticks": 6413,
|
||||
"waveId": "act1autochess_h06",
|
||||
"time": 218.6,
|
||||
"ticks": 6558,
|
||||
"reason": "cleared",
|
||||
"killed": 40,
|
||||
"total": 159,
|
||||
"killed": 2,
|
||||
"total": 102,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 260,
|
||||
"rngDraws": 0,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":40,"total":159,"perfect":false,"leaks":119,"leaksUncounted":0,"coins":0,"dmg":227420,"bossDmg":0,"heal":4309,"deaths":28,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":2,"total":102,"perfect":false,"leaks":100,"leaksUncounted":0,"coins":0,"dmg":76712,"bossDmg":0,"heal":0,"deaths":23,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_diy1_a",0,null,0,0,6,9,34156,1865,0,26,1],
|
||||
["p1",2,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",0,0,9,7,75183,2374,0,31,1],
|
||||
["p1",3,"op","chess_char_5_diy1_a",2,null,1154,1,190,0,7213,9373,0,21,0],
|
||||
["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",95.94,1,188,1,18424,10040,0,33,0],
|
||||
["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,166,13,44376,2544,0,48,1],
|
||||
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",758.45,1,189,7,35386,2416,0,37,1],
|
||||
["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,4,0,0,1372,0,0,0],
|
||||
["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",0,0,114,3,11915,4113,0,5,1],
|
||||
["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,8,0,0,2434,2643,7,1],
|
||||
["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",0,0,103,0,0,5171,1666,3,1],
|
||||
["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,1,0,0,1160,0,0,0],
|
||||
["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",0,0,102,0,768,2860,0,2,1]
|
||||
["p1",1,"op","chess_char_6_diy1_b",0,"uniequip_002_ironmn",0,0,125,0,8151,7479,0,15,1],
|
||||
["p1",2,"op","chess_char_6_diy2_b",1,"uniequip_002_ironmn",2493,1,126,0,5531,4986,0,11,1],
|
||||
["p1",3,"op","chess_char_6_diy1_b",2,"uniequip_002_ironmn",0,0,120,0,5947,7479,0,14,1],
|
||||
["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_003_ironmn",0,0,104,0,6964,5786,0,12,1],
|
||||
["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_003_ironmn",0,0,74,0,3849,5786,0,11,0],
|
||||
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_003_ironmn",0,0,113,0,4108,5786,0,10,1],
|
||||
["p1",7,"op","chess_char_5_diy1_a",0,null,0,0,66,0,1363,3876,0,7,0],
|
||||
["p1",8,"op","chess_char_5_diy2_a",1,null,0,0,1,0,618,1938,0,2,1],
|
||||
["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,2,0,8489,1938,0,11,1],
|
||||
["p1",10,"op","chess_char_5_diy2_b",0,"none",0,0,6,2,22195,2367,0,15,1],
|
||||
["p1",11,"op","chess_char_5_diy1_b",1,"none",0,0,70,0,5327,4734,0,13,1],
|
||||
["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,62,0,4169,4734,0,11,0]
|
||||
],
|
||||
"enemies": {"enemy_1009_lurker":[72,18,54,2183,64312],"enemy_1019_jshoot_2":[51,13,38,16138,60642],"enemy_1404_msnip":[36,9,27,27401,102466]},
|
||||
"hooks": [1,190,6413,485,485,717,754,15,68,187,8,22,0,8825,82,56,159,119,1,68,0,0,1],
|
||||
"events": {"atk":560,"deploy":31,"die":68,"dmg":751,"engage":8,"fx":9,"heal":13,"leak":119,"skill":48,"spawn":171,"status":77},
|
||||
"fx": {"aoe":3,"displace":3,"dodge":1,"spGain":2},
|
||||
"snaps": "cb7c5d0f 22a665b0 b0241d3f 8234db69 6d6af92a 4df4630a fbb444f9 7e8e6ab5 d60faeb1 c543720a 31f9bc0d 2619ab64 9dc146d2 0df3dd1d 845dbd2b 7141ab9c aaad4844 c5a03dc3 284e9904 bb59254b 5385151c 09b91371"
|
||||
"enemies": {"enemy_1267_nhpbr_2":[27,2,25,2555,40000],"enemy_1270_nhstlk_2":[36,0,36,29550,30114],"enemy_9006_actoxi":[39,0,39,24784,6598]},
|
||||
"hooks": [1,126,6558,330,330,408,408,0,25,125,9,9,0,10014,0,48,102,100,1,25,0,0,0],
|
||||
"events": {"atk":345,"deploy":24,"die":25,"dmg":399,"engage":12,"fx":18,"leak":100,"skill":18,"spawn":114,"status":40},
|
||||
"fx": {"shield":16,"zone":2},
|
||||
"snaps": "8b599cc6 48d99fa5 a69351f0 c11b367c d50588af 8d228d77 ab0fed19 04ac0d7e 376d499d 45cf9b15 9ddaadfb df5d1765 5d30798c 9635f47f 4a5e4307 23278ddd 8d0e5f5e 899ab4fc 4c923b53 4e5ecf46 808c96ee 19aacf11"
|
||||
},
|
||||
"diy-130": {
|
||||
"about": "6_diy1_a=acfend 6_diy2_b=acfend 6_diy1_a=acpion 6_diy2_b=acpion 6_diy1_a=acguad 6_diy2_b=acguad 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc",
|
||||
"about": "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 6_diy2_b=nasti/S1 6_diy1_b=nasti/S2 6_diy2_b=nasti/S3 6_diy1_b=nasti/S1/002_nasti 6_diy2_b=nasti/S2/002_nasti 6_diy1_b=nasti/S3/002_nasti",
|
||||
"fast": false,
|
||||
"kind": "normal",
|
||||
"stageId": "act2autochess_m02",
|
||||
@@ -5588,73 +5594,355 @@
|
||||
"round": 13,
|
||||
"seed": 2862651535,
|
||||
"waveId": "act1autochess_h06",
|
||||
"time": 220.7,
|
||||
"ticks": 6621,
|
||||
"time": 201.4,
|
||||
"ticks": 6042,
|
||||
"reason": "cleared",
|
||||
"killed": 1,
|
||||
"killed": 0,
|
||||
"total": 102,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 369,
|
||||
"rngDraws": 0,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":1,"total":102,"perfect":false,"leaks":101,"leaksUncounted":0,"coins":0,"dmg":937924,"bossDmg":0,"heal":87780,"deaths":16,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":0,"total":102,"perfect":false,"leaks":102,"leaksUncounted":0,"coins":0,"dmg":51305,"bossDmg":0,"heal":0,"deaths":21,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_6_diy1_a",2,null,0,0,118,0,443,7812,0,7,0],
|
||||
["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",0,0,121,0,1812,10293,0,15,0],
|
||||
["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,114,0,13093,5419,0,11,1],
|
||||
["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",0,0,113,0,37999,8915,0,11,2],
|
||||
["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,108,0,91547,38701,0,80,4],
|
||||
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",0,0,107,0,159873,57383,0,81,4],
|
||||
["p1",7,"op","chess_char_6_diy1_a",2,null,1272,1,8,0,16194,0,0,45,2],
|
||||
["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",1643,1,9,1,125710,0,0,156,2],
|
||||
["p1",9,"op","chess_char_6_diy1_a",2,null,1372,1,13,0,31368,0,0,37,2],
|
||||
["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",1819,1,4,0,459886,0,0,85,3],
|
||||
["p1",11,"op","chess_char_6_diy1_a",2,null,1224,1,6,0,0,0,40530,44,2],
|
||||
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",1464,1,10,0,0,0,47250,44,2],
|
||||
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_nasti",0,0,122,0,4011,7335,0,8,1],
|
||||
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_nasti",2445,1,127,0,4150,4890,0,10,1],
|
||||
["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_nasti",0,0,106,0,10347,4890,0,11,1],
|
||||
["p1",4,"op","chess_char_6_diy1_a",0,null,0,0,115,0,1245,3876,0,5,0],
|
||||
["p1",5,"op","chess_char_6_diy2_a",1,null,0,0,69,0,1536,3876,0,4,0],
|
||||
["p1",6,"op","chess_char_6_diy1_a",2,null,0,0,67,0,1745,3876,0,5,0],
|
||||
["p1",7,"op","chess_char_6_diy2_b",0,"none",0,0,61,0,2153,4734,0,7,0],
|
||||
["p1",8,"op","chess_char_6_diy1_b",1,"none",0,0,68,0,1295,4734,0,5,0],
|
||||
["p1",9,"op","chess_char_6_diy2_b",2,"none",0,0,63,0,2411,4734,0,9,0],
|
||||
["p1",10,"op","chess_char_6_diy1_b",0,"uniequip_002_nasti",0,0,9,0,838,2512,0,2,0],
|
||||
["p1",11,"op","chess_char_6_diy2_b",1,"uniequip_002_nasti",0,0,10,0,419,2512,0,1,1],
|
||||
["p1",12,"op","chess_char_6_diy1_b",2,"uniequip_002_nasti",2512,1,6,0,21155,0,0,31,4],
|
||||
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
|
||||
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null]
|
||||
],
|
||||
"enemies": {"enemy_1267_nhpbr_2":[27,0,27,2985,177195],"enemy_1270_nhstlk_2":[36,1,35,21217,245338],"enemy_9006_actoxi":[39,0,39,104320,515392]},
|
||||
"hooks": [1,126,6621,804,804,1335,1327,183,17,118,24,23,0,28925,0,35,102,101,1,22,0,0,8],
|
||||
"events": {"atk":921,"deploy":22,"die":17,"dmg":1327,"engage":10,"fx":66,"heal":181,"leak":101,"skill":47,"spawn":116,"status":36},
|
||||
"fx": {"aoe":14,"dodge":8,"dp":3,"slash":24,"spGain":17},
|
||||
"snaps": "991457ea e05dc464 b3371c0c 96324561 4e55a051 849b28ac 6aaff9a0 e19921e6 e0540cc5 9dda1ab1 0d299067 9b6f5ee2 e612af65 30447a83 843f3646 1d4d8894 581172e5 afeaf942 e3a403f5 27743746 e033f10e a3b5099b"
|
||||
"enemies": {"enemy_1267_nhpbr_2":[27,0,27,7657,15836],"enemy_1270_nhstlk_2":[36,0,36,17202,20585],"enemy_9006_actoxi":[39,0,39,23109,14883]},
|
||||
"hooks": [1,127,6042,193,193,213,213,0,21,123,8,8,0,9667,0,38,102,102,1,21,0,0,0],
|
||||
"events": {"atk":194,"deploy":23,"die":21,"dmg":207,"engage":12,"fx":16,"leak":102,"skill":16,"spawn":116,"status":28},
|
||||
"fx": {"shield":16},
|
||||
"snaps": "4632ae20 24966367 b6d1a168 84949257 4be9835a f237b271 0ac0c2c7 879ea264 9003e9c4 69e2b7ca be0fb246 101b09e5 c3302c1c 8eb425c8 1a169de9 0f5f3e47 a841b3ab 3c71daa4 6448a7de 271dd58e 5e0e8283"
|
||||
},
|
||||
"diy-131": {
|
||||
"about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acsupo 6_diy2_b=acsupo",
|
||||
"about": "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 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",
|
||||
"fast": false,
|
||||
"kind": "normal",
|
||||
"stageId": "act2autochess_m03",
|
||||
"modeId": "mode_multi_funny",
|
||||
"round": 13,
|
||||
"round": 12,
|
||||
"seed": 1967364563,
|
||||
"waveId": "act1autochess_h06",
|
||||
"time": 213.2,
|
||||
"ticks": 6396,
|
||||
"waveId": "act1autochess_h05",
|
||||
"time": 189.9,
|
||||
"ticks": 5697,
|
||||
"reason": "cleared",
|
||||
"killed": 34,
|
||||
"killed": 135,
|
||||
"total": 135,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 30,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":135,"total":135,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":979200,"bossDmg":0,"heal":40886,"deaths":1,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_diy1_a",0,null,1443,1,8,2,24458,0,0,70,7],
|
||||
["p1",2,"op","chess_char_5_diy2_a",1,null,1443,1,11,10,88149,0,0,75,6],
|
||||
["p1",3,"op","chess_char_5_diy1_a",2,null,1443,1,13,19,166938,0,0,109,7],
|
||||
["p1",4,"op","chess_char_5_diy2_b",0,"none",1132,1,15,42,223798,630,0,116,11],
|
||||
["p1",5,"op","chess_char_5_diy1_b",1,"none",1762,1,4,6,59014,0,0,24,5],
|
||||
["p1",6,"op","chess_char_5_diy2_b",2,"none",1762,1,9,13,124486,0,0,91,8],
|
||||
["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_necras",1882,1,5,1,182,0,0,1,1],
|
||||
["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_necras",1882,1,7,7,39594,0,0,40,4],
|
||||
["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_necras",1882,1,10,9,63392,0,0,68,7],
|
||||
["p1",10,"op","chess_char_6_diy1_a",0,null,673,1,12,2,30558,770,0,79,7],
|
||||
["p1",11,"op","chess_char_6_diy2_a",1,null,123,1,165,5,34816,2763,0,36,3],
|
||||
["p1",12,"op","chess_char_6_diy1_a",2,null,1443,1,6,1,32142,0,0,29,7],
|
||||
["-","~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,3770,1,19,6,36390,835,0,16,5],
|
||||
["p1","~2","token","token_10043_necras_skeltn",null,null,3277.2,0,20,0,884,222,22,2,null],
|
||||
["p1","~3","token","token_10043_necras_skeltn",null,null,3488,0,27,0,4734,4453,4453,9,null],
|
||||
["p1","~4","token","token_10043_necras_skeltn",null,null,3070,1,33,1,1295,0,0,4,null],
|
||||
["p1","~5","token","token_10043_necras_skeltn",null,null,13603.2,1,34,0,0,0,0,0,3],
|
||||
["p1","~6","token","token_10043_necras_skeltn",null,null,3488,0,43,0,0,0,0,0,0],
|
||||
["p1","~7","token","token_10043_necras_skeltn",null,null,3488,0,44,1,5113,4935,4935,10,null],
|
||||
["p1","~8","token","token_10043_necras_skeltn",null,null,1744,0,53,0,0,0,0,0,0],
|
||||
["p1","~9","token","token_10043_necras_skeltn",null,null,3488,0,55,0,0,708,1311,0,0],
|
||||
["p1","~10","token","token_10043_necras_skeltn",null,null,3488,1,58,0,0,0,0,0,null],
|
||||
["p1","~11","token","token_10043_necras_skeltn",null,null,4605,1,59,0,0,0,0,0,0],
|
||||
["p1","~12","token","token_10043_necras_skeltn",null,null,3488,1,60,0,0,316,316,0,null],
|
||||
["p1","~13","token","token_10043_necras_skeltn",null,null,3191.8,0,63,1,4705,2884,2588,9,null],
|
||||
["p1","~14","token","token_10043_necras_skeltn",null,null,3488,0,73,0,0,0,0,0,0],
|
||||
["p1","~15","token","token_10043_necras_skeltn",null,null,3488,0,77,0,0,0,0,0,0],
|
||||
["p1","~16","token","token_10043_necras_skeltn",null,null,3437.2,0,79,2,5535,4061,4010,10,null],
|
||||
["p1","~17","token","token_10043_necras_skeltn",null,null,2226.4,0,96,2,5397,6106,4855,10,null],
|
||||
["p1","~18","token","token_10043_necras_skeltn",null,null,3488,0,107,0,0,211,211,0,0],
|
||||
["p1","~19","token","token_10043_necras_skeltn",null,null,3386.4,0,112,1,4975,2946,2844,10,null],
|
||||
["p1","~20","token","token_10043_necras_skeltn",null,null,1744,0,116,0,0,0,0,0,0],
|
||||
["p1","~21","token","token_10043_necras_skeltn",null,null,3488,0,117,0,0,1228,1429,0,0],
|
||||
["p1","~22","token","token_10043_necras_skeltn",null,null,3488,0,125,1,5399,3303,3303,10,null],
|
||||
["p1","~23","token","token_10043_necras_skeltn",null,null,3488,0,140,0,0,105,105,0,0],
|
||||
["p1","~24","token","token_10043_necras_skeltn",null,null,799.4,0,143,0,4942,7705,5017,9,null],
|
||||
["p1","~25","token","token_10043_necras_skeltn",null,null,1342,0,159,0,0,402,0,0,0],
|
||||
["p1","~26","token","token_10043_necras_skeltn",null,null,3462.6,0,160,1,5453,2455,2430,10,null],
|
||||
["p1","~27","token","token_10043_necras_skeltn",null,null,3488,0,166,0,0,1719,1920,0,0],
|
||||
["p1","~28","token","token_10043_necras_skeltn",null,null,3488,0,173,2,5504,1137,1137,10,null],
|
||||
["p1","~29","token","token_10043_necras_skeltn",null,null,3488,1,178,0,486,0,0,1,0],
|
||||
["p1","~30","token","token_10043_necras_skeltn",null,null,3059,1,181,0,862,11,0,2,null]
|
||||
],
|
||||
"enemies": {"enemy_2021_syfish":[105,105,0,7326,403200],"enemy_2025_syufo":[30,30,0,42579,576000]},
|
||||
"hooks": [1,181,5697,1208,1208,1763,1943,268,136,158,81,80,0,54150,41,88,135,0,1,136,0,15,0],
|
||||
"events": {"atk":1208,"deploy":43,"die":158,"dmg":1943,"engage":27,"fx":504,"heal":69,"skill":161,"spawn":180,"status":119},
|
||||
"fx": {"aoe":150,"burst":15,"grow":268,"phase":18,"sleep":23,"summon":30},
|
||||
"snaps": "e06bb91d 3d9603df 235a870d 610af988 e5adbf49 c087e51c 9580ba01 de2151cd 8ec08976 09a31624 b5a17549 01ba521e afdab2ca 7c0620a6 e7ef14a1 498df6e0 62fd682d cc89ff94 12c6a7a5"
|
||||
},
|
||||
"diy-132": {
|
||||
"about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 6_diy1_b=necras/S1 6_diy2_b=necras/S2 6_diy1_b=necras/S3 6_diy2_b=necras/S1/002_necras 6_diy1_b=necras/S2/002_necras 6_diy2_b=necras/S3/002_necras 5_diy1_a=csnipe 5_diy2_b=csnipe",
|
||||
"fast": true,
|
||||
"kind": "normal",
|
||||
"stageId": "act2autochess_m04",
|
||||
"modeId": "mode_single_hard",
|
||||
"round": 13,
|
||||
"seed": 326579774,
|
||||
"waveId": "act1autochess_h06",
|
||||
"time": 198.8,
|
||||
"ticks": 5964,
|
||||
"reason": "cleared",
|
||||
"killed": 123,
|
||||
"total": 123,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 287,
|
||||
"rngDraws": 26,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":34,"total":123,"perfect":false,"leaks":89,"leaksUncounted":0,"coins":0,"dmg":303954,"bossDmg":0,"heal":0,"deaths":18,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":123,"total":123,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1100899,"bossDmg":0,"heal":80557,"deaths":2,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,115,2,12461,3730,0,44,1],
|
||||
["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,94,11,80882,4988,0,58,2],
|
||||
["p1",3,"op","chess_char_6_diy1_a",2,null,1154,1,148,5,20771,8078,0,95,0],
|
||||
["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",273.68,1,147,5,28658,11598,0,87,0],
|
||||
["p1",5,"op","chess_char_6_diy1_a",2,null,1160,1,6,7,64863,0,0,146,2],
|
||||
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",1450,1,7,4,96319,0,0,156,3],
|
||||
["p1",1,"op","chess_char_5_diy1_a",2,null,213.74,1,7,0,380,1914,0,4,1],
|
||||
["p1",2,"op","chess_char_5_diy2_b",2,"none",2908,1,8,0,0,0,0,0,0],
|
||||
["p1",3,"op","chess_char_5_diy1_a",2,null,2564,1,6,0,0,0,0,0,0],
|
||||
["p1",4,"op","chess_char_5_diy2_b",2,"none",3334,1,9,0,0,0,0,0,0],
|
||||
["p1",5,"op","chess_char_6_diy1_b",0,"none",1762,1,3,1,13338,0,0,6,2],
|
||||
["p1",6,"op","chess_char_6_diy2_b",1,"none",1762,1,4,1,23910,0,0,13,1],
|
||||
["p1",7,"op","chess_char_6_diy1_b",2,"none",1762,1,11,6,59742,0,0,38,5],
|
||||
["p1",8,"op","chess_char_6_diy2_b",0,"uniequip_002_necras",57.23,1,13,9,71000,1905,0,61,6],
|
||||
["p1",9,"op","chess_char_6_diy1_b",1,"uniequip_002_necras",1962,1,5,2,13327,0,0,16,1],
|
||||
["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_necras",1962,1,10,39,323654,0,0,108,8],
|
||||
["p1",11,"op","chess_char_5_diy1_a",2,null,1271,1,12,8,47570,0,0,178,3],
|
||||
["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,97,3,30621,3020,0,84,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,2651.32,0,24,2,3785,4349,4008,10,null],
|
||||
["p1","~2","token","token_10043_necras_skeltn",null,null,1744,0,29,0,566,0,0,2,null],
|
||||
["p1","~3","token","token_10043_necras_skeltn",null,null,9417.6,1,30,1,3565,1716,2080,3,4],
|
||||
["p1","~4","token","token_10043_necras_skeltn",null,null,0,0,31,0,4439,5232,0,6,0],
|
||||
["p1","~5","token","token_10043_necras_skeltn",null,null,3488,0,32,0,0,0,0,0,null],
|
||||
["p1","~6","token","token_10043_necras_skeltn",null,null,3488,1,39,0,6254,8534,8534,12,null],
|
||||
["p1","~7","token","token_10043_necras_skeltn",null,null,3459.5,0,40,0,3191,1182,2022,9,0],
|
||||
["p1","~8","token","token_10043_necras_skeltn",null,null,3488,1,41,0,9098,57,57,19,null],
|
||||
["p1","~9","token","token_10043_necras_skeltn",null,null,13603.2,1,45,43,416415,11542,14835,44,7],
|
||||
["p1","~10","token","token_10043_necras_skeltn",null,null,0,0,46,0,3745,3488,0,7,null],
|
||||
["p1","~11","token","token_10043_necras_skeltn",null,null,3488,1,49,1,554,0,0,3,null],
|
||||
["p1","~12","token","token_10043_necras_skeltn",null,null,3488,1,50,0,0,0,0,0,null],
|
||||
["p1","~13","token","token_10043_necras_skeltn",null,null,3459.5,0,52,0,231,28,0,1,0],
|
||||
["p1","~14","token","token_10043_necras_skeltn",null,null,0,0,53,0,231,1744,0,1,0],
|
||||
["p1","~15","token","token_10043_necras_skeltn",null,null,1417.62,0,54,1,6384,5341,3270,13,0],
|
||||
["p1","~16","token","token_10043_necras_skeltn",null,null,2683.81,0,58,0,5063,804,0,10,0],
|
||||
["p1","~17","token","token_10043_necras_skeltn",null,null,1371.71,0,67,0,173,372,0,1,0],
|
||||
["p1","~18","token","token_10043_necras_skeltn",null,null,2423.35,0,69,1,6119,10888,9824,12,0],
|
||||
["p1","~19","token","token_10043_necras_skeltn",null,null,1744,0,93,0,243,0,0,1,null],
|
||||
["p1","~20","token","token_10043_necras_skeltn",null,null,0,0,94,0,243,1744,0,1,null],
|
||||
["p1","~21","token","token_10043_necras_skeltn",null,null,0,0,95,0,694,1744,0,3,null],
|
||||
["p1","~22","token","token_10043_necras_skeltn",null,null,3488,0,96,0,231,0,0,1,0],
|
||||
["p1","~23","token","token_10043_necras_skeltn",null,null,3459.5,0,98,1,6252,8879,8851,14,0],
|
||||
["p1","~24","token","token_10043_necras_skeltn",null,null,1451.54,0,100,0,5318,5860,3824,10,0],
|
||||
["p1","~25","token","token_10043_necras_skeltn",null,null,1247.61,0,105,0,462,496,0,2,0],
|
||||
["p1","~26","token","token_10043_necras_skeltn",null,null,3431.01,0,112,0,5306,6183,7477,12,0],
|
||||
["p1","~27","token","token_10043_necras_skeltn",null,null,1083.02,0,113,0,3029,2729,619,9,0],
|
||||
["p1","~28","token","token_10043_necras_skeltn",null,null,1451.54,0,130,0,3387,2407,541,7,null],
|
||||
["p1","~29","token","token_10043_necras_skeltn",null,null,0,0,136,2,4024,6576,4020,10,0],
|
||||
["p1","~30","token","token_10043_necras_skeltn",null,null,27.29,0,139,0,6115,11686,8692,12,0],
|
||||
["p1","~31","token","token_10043_necras_skeltn",null,null,0,0,147,0,3006,3488,0,6,null],
|
||||
["p1","~32","token","token_10043_necras_skeltn",null,null,1495.81,0,154,0,231,248,0,1,0],
|
||||
["p1","~33","token","token_10043_necras_skeltn",null,null,1744,1,155,0,0,0,0,0,0],
|
||||
["p1","~34","token","token_10043_necras_skeltn",null,null,0,0,156,0,647,1744,0,2,0],
|
||||
["p1","~35","token","token_10043_necras_skeltn",null,null,0,0,160,0,2774,3716,352,6,0],
|
||||
["p1","~36","token","token_10043_necras_skeltn",null,null,1619.9,0,165,0,231,124,0,1,0],
|
||||
["p1","~37","token","token_10043_necras_skeltn",null,null,1619.9,0,167,0,231,124,0,1,0],
|
||||
["p1","~38","token","token_10043_necras_skeltn",null,null,3431.01,1,168,2,4656,749,940,11,0],
|
||||
["p1","~39","token","token_10043_necras_skeltn",null,null,3459.5,1,170,0,462,391,610,1,0]
|
||||
],
|
||||
"enemies": {"enemy_1019_jshoot_2":[57,57,0,13712,255302],"enemy_1299_ymkilr_2":[27,27,0,103761,492687],"enemy_1422_lrsldr_2":[39,39,0,3532,355830]},
|
||||
"hooks": [1,177,5964,1154,1154,1519,1519,139,134,155,39,39,0,31791,2,137,123,0,1,134,0,0,0],
|
||||
"events": {"atk":1154,"deploy":52,"die":155,"dmg":1519,"engage":45,"fx":323,"heal":88,"skill":78,"spawn":176,"status":3},
|
||||
"fx": {"aoe":143,"grow":139,"sleep":2,"summon":39},
|
||||
"snaps": "fca07158 7e491823 1379df7d 43eca66a 1670212d 34467249 ce2fd1dd 106168b3 2159db6f 66183b01 3f2c0eb3 117ea2a7 1cd2cb58 bec7ece2 6863bf40 ef5528a5 10c16399 2b2413f1 c9fee135 a483f71b"
|
||||
},
|
||||
"diy-133": {
|
||||
"about": "5_diy1_a=acfend 5_diy2_b=acfend 5_diy1_a=acpion 5_diy2_b=acpion 5_diy1_a=acguad 5_diy2_b=acguad 5_diy1_a=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo",
|
||||
"fast": false,
|
||||
"kind": "normal",
|
||||
"stageId": "act1autochess_m01",
|
||||
"modeId": "mode_multi_normal",
|
||||
"round": 12,
|
||||
"seed": 953190138,
|
||||
"waveId": "act1autochess_h05",
|
||||
"time": 218.27,
|
||||
"ticks": 6548,
|
||||
"reason": "cleared",
|
||||
"killed": 173,
|
||||
"total": 243,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 117,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":173,"total":243,"perfect":false,"leaks":70,"leaksUncounted":0,"coins":0,"dmg":509327,"bossDmg":0,"heal":35627,"deaths":7,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,198,1,9062,5208,0,41,1],
|
||||
["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",3121.56,1,252,5,24943,3620,0,81,1],
|
||||
["p1",3,"op","chess_char_5_diy1_a",2,null,1529,1,261,6,18839,7756,0,40,2],
|
||||
["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",1904,1,151,100,195205,11634,0,107,6],
|
||||
["p1",5,"op","chess_char_5_diy1_a",2,null,2638,1,15,14,93664,21198,0,140,3],
|
||||
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",3045.96,1,233,10,25732,3776,0,42,2],
|
||||
["p1",7,"op","chess_char_5_diy1_a",2,null,1133,1,4,8,15364,0,0,83,3],
|
||||
["p1",8,"op","chess_char_5_diy2_b",2,"none",1360,1,5,16,47409,0,0,148,4],
|
||||
["p1",9,"op","chess_char_5_diy1_a",2,null,1218,1,11,0,0,0,19362,59,3],
|
||||
["p1",10,"op","chess_char_5_diy2_b",2,"none",1374,1,13,0,0,0,16264,58,3],
|
||||
["p1",11,"op","chess_char_5_diy1_a",2,null,932,1,6,12,18431,0,0,107,3],
|
||||
["p1",12,"op","chess_char_5_diy2_b",2,"none",1131,1,14,1,60680,0,0,133,3],
|
||||
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
|
||||
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
|
||||
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
|
||||
],
|
||||
"enemies": {"enemy_1006_shield_3":[21,0,21,13429,72237],"enemy_1043_zomsbr":[66,27,39,93,102526],"enemy_1402_tgshd_2":[36,7,29,14872,129191]},
|
||||
"hooks": [1,148,6396,693,693,920,1011,11182,52,141,8,21,0,10798,12327,37,123,89,1,52,0,0,0],
|
||||
"events": {"atk":764,"deploy":22,"die":52,"dmg":945,"engage":6,"fx":503,"leak":89,"skill":47,"spawn":132,"status":927},
|
||||
"fx": {"displace":19,"dodge":5,"expose":479},
|
||||
"snaps": "d456be72 24986da0 263291b2 6601c930 43a57b58 57e6c19a e9793217 f2307500 9bf48dd7 a1e1185f f757a9dd 9feca978 f1099997 885b8762 df7edeeb d75a4c78 dbb2b23e 3942d750 75b12534 b102163d 3fee0d49 966739a7"
|
||||
"enemies": {
|
||||
"enemy_1195_sfyin": [45,26,19,13482,144786],
|
||||
"enemy_1196_msfyin": [52,45,7,0,91],
|
||||
"enemy_1197_sfshu": [39,28,11,13145,181727],
|
||||
"enemy_1198_msfshu": [84,61,23,0,195],
|
||||
"enemy_1199_sfjin": [12,11,1,26565,182424],
|
||||
"enemy_1200_msfjin": [11,2,9,0,104]
|
||||
},
|
||||
"hooks": [1,264,6548,1492,1492,1952,1915,117,180,250,34,33,0,39609,222,69,243,70,1,180,0,0,37],
|
||||
"events": {"atk":1492,"deploy":18,"die":180,"dmg":1915,"engage":10,"fx":323,"heal":117,"leak":70,"skill":67,"spawn":258,"status":456},
|
||||
"fx": {"aoe":52,"dodge":37,"dp":114,"slash":55,"summon":65},
|
||||
"snaps": "bc6da2c9 ceebca03 cdedd66c 4125c5f9 0142c8a8 b8d589a4 0abad4db 48f74225 831cf5aa c20fb442 0f7f866d 1b522268 513dbd00 272f78e4 f8c39620 6413c2bd 7ee6e8b4 83bfc984 c53e60c8 bee2518e 35f20f3b 28db8fee"
|
||||
},
|
||||
"diy-134": {
|
||||
"about": "5_diy1_a=sharp2 5_diy2_b=sharp2 5_diy1_a=acspec 5_diy2_b=acspec 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc 5_diy1_a=acsupo 5_diy2_b=acsupo",
|
||||
"fast": false,
|
||||
"kind": "normal",
|
||||
"stageId": "act1autochess_m02",
|
||||
"modeId": "mode_multi_hard",
|
||||
"round": 12,
|
||||
"seed": 3608019556,
|
||||
"waveId": "act1autochess_h05",
|
||||
"time": 208.03,
|
||||
"ticks": 6241,
|
||||
"reason": "cleared",
|
||||
"killed": 2,
|
||||
"total": 60,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 1049,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":2,"total":60,"perfect":false,"leaks":58,"leaksUncounted":0,"coins":0,"dmg":558618,"bossDmg":0,"heal":29304,"deaths":21,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_diy1_a",0,null,0,0,61,0,13900,3730,0,16,1],
|
||||
["p1",2,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",0,0,68,1,55591,4748,0,37,2],
|
||||
["p1",3,"op","chess_char_5_diy1_a",2,null,1154,1,87,0,942,5770,0,24,0],
|
||||
["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",1448,1,85,0,4428,7240,0,24,0],
|
||||
["p1",5,"op","chess_char_5_diy1_a",2,null,1272,1,5,0,14792,0,0,181,2],
|
||||
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",1587,1,8,0,30614,0,0,177,2],
|
||||
["p1",7,"op","chess_char_5_diy1_a",2,null,1372,1,6,1,137220,0,0,51,2],
|
||||
["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",1749,1,9,0,261767,0,0,77,2],
|
||||
["p1",9,"op","chess_char_5_diy1_a",2,null,1224,1,88,0,0,3201,3296,8,1],
|
||||
["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",0,0,11,0,0,15092,26008,21,1],
|
||||
["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,71,0,21082,17194,0,40,1],
|
||||
["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",1430,1,89,0,18281,2860,0,22,1]
|
||||
],
|
||||
"enemies": {"enemy_1045_hammer_2":[12,0,12,4838,121840],"enemy_1061_zomshd":[48,2,46,54996,436778]},
|
||||
"hooks": [1,89,6241,759,759,1939,1993,26777,23,81,15,25,0,22089,2415,24,60,58,1,23,0,0,0],
|
||||
"events": {"atk":1095,"deploy":29,"die":23,"dmg":1979,"engage":10,"fx":13,"heal":53,"leak":58,"skill":54,"spawn":72,"status":266},
|
||||
"fx": {"aoe":8,"dodge":4,"spGain":1},
|
||||
"snaps": "eb37fe41 2feae3f0 5c31d8ff f72ff3aa 32f606ba 526ce9c0 49700653 5006595f c5c6846e 6d2b9b8e 4b024976 e92d04be 471cd084 7f606005 f119fdc0 5677942e 4fe9bcbf 9d0af4bf 4077bae2 9168ce11 ab027285"
|
||||
},
|
||||
"diy-135": {
|
||||
"about": "6_diy1_a=acfend 6_diy2_b=acfend 6_diy1_a=acpion 6_diy2_b=acpion 6_diy1_a=acguad 6_diy2_b=acguad 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc",
|
||||
"fast": false,
|
||||
"kind": "normal",
|
||||
"stageId": "act1autochess_m03",
|
||||
"modeId": "mode_single_normal",
|
||||
"round": 13,
|
||||
"seed": 3994643046,
|
||||
"waveId": "act1autochess_h06",
|
||||
"time": 216.23,
|
||||
"ticks": 6487,
|
||||
"reason": "cleared",
|
||||
"killed": 139,
|
||||
"total": 150,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 391,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":139,"total":150,"perfect":false,"leaks":11,"leaksUncounted":0,"coins":0,"dmg":1550587,"bossDmg":0,"heal":109719,"deaths":4,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_6_diy1_a",2,null,2604,1,167,0,3442,2604,0,30,1],
|
||||
["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",2646.22,1,7,1,22576,4540,0,130,2],
|
||||
["p1",3,"op","chess_char_6_diy1_a",2,null,1529,1,169,26,218591,23279,0,102,10],
|
||||
["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",1494.95,1,11,63,603310,32568,0,159,15],
|
||||
["p1",5,"op","chess_char_6_diy1_a",2,null,1912.7,1,152,2,28838,15194,0,91,3],
|
||||
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",2661.84,1,15,10,107218,19493,0,155,5],
|
||||
["p1",7,"op","chess_char_6_diy1_a",2,null,1272,1,8,0,9284,0,0,49,2],
|
||||
["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",1643,1,12,2,55255,0,0,111,2],
|
||||
["p1",9,"op","chess_char_6_diy1_a",2,null,1372,1,14,0,30553,0,0,40,2],
|
||||
["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",1819,1,4,35,471521,0,0,80,3],
|
||||
["p1",11,"op","chess_char_6_diy1_a",2,null,1224,1,6,0,0,0,52763,71,2],
|
||||
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",1464,1,168,0,0,23065,56956,50,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_1249_lysdb_2":[60,57,3,19813,650353],"enemy_1251_lysyta":[51,49,2,2808,252854],"enemy_1329_cbshld":[39,33,6,98122,647381]},
|
||||
"hooks": [1,169,6487,1589,1589,4054,3954,233,143,154,49,47,0,47498,0,146,150,11,1,143,0,0,40],
|
||||
"events": {"atk":1708,"deploy":16,"die":143,"dmg":3954,"engage":10,"fx":494,"heal":232,"leak":11,"skill":96,"spawn":165,"status":50},
|
||||
"fx": {"aoe":79,"dodge":40,"dp":114,"shieldBreak":60,"slash":197,"spGain":4},
|
||||
"snaps": "3a143536 45205b96 e8d7d1d8 00f7e32c ce3c78b4 2407bf14 13b01dbe 3fec0a84 ee21dba1 0cc379c6 b04c03a9 9eee7fbb b11dc910 c1766ce2 0f77c84f 9fe87e87 75e19b69 4a45b886 895b892d 6b459f6c cfac3759 f5653f35"
|
||||
},
|
||||
"diy-136": {
|
||||
"about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acsupo 6_diy2_b=acsupo",
|
||||
"fast": false,
|
||||
"kind": "normal",
|
||||
"stageId": "act1autochess_m04",
|
||||
"modeId": "mode_multi_abyss",
|
||||
"round": 13,
|
||||
"seed": 2354181867,
|
||||
"waveId": "act1autochess_h06",
|
||||
"time": 225.6,
|
||||
"ticks": 6768,
|
||||
"reason": "cleared",
|
||||
"killed": 1,
|
||||
"total": 123,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 130,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":1,"total":123,"perfect":false,"leaks":122,"leaksUncounted":0,"coins":0,"dmg":293038,"bossDmg":0,"heal":0,"deaths":24,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,149,0,281,5595,0,10,0],
|
||||
["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,89,1,41351,4988,0,15,1],
|
||||
["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,153,0,27405,12694,0,18,0],
|
||||
["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",1484,1,152,0,64271,11872,0,52,0],
|
||||
["p1",5,"op","chess_char_6_diy1_a",2,null,1160,1,4,0,59475,0,0,141,3],
|
||||
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",1450,1,5,0,100254,0,0,162,3],
|
||||
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
|
||||
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
|
||||
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
|
||||
],
|
||||
"enemies": {"enemy_1006_shield_3":[21,0,21,9388,58708],"enemy_1043_zomsbr":[66,1,65,8698,238677],"enemy_1402_tgshd_2":[36,0,36,17063,131985]},
|
||||
"hooks": [1,153,6768,459,459,2469,2495,24678,25,147,7,24,0,10141,11244,28,123,122,1,25,0,0,0],
|
||||
"events": {"atk":470,"deploy":27,"die":25,"dmg":2389,"engage":6,"fx":257,"leak":122,"skill":44,"spawn":132,"status":875},
|
||||
"fx": {"displace":30,"dodge":6,"expose":221},
|
||||
"snaps": "3b9227e1 1631291e 032dd42b e8c97fce 06b2c450 146801be a9ba58c8 42440c2a ee76d4c2 8ac7769c f06d9109 be306e36 b9697bbb 7631a029 e3f80b10 e1a55253 af28ac55 b4ecedd5 6565411e 93bc5900 9a306169 33d6050f e195f073"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user