feedback5: merge O7 — 自选 kits for 早露, 提丰 and 娜仁图亚; engine: targeting priority 'heaviest' (攻城手) and boomerang flight hooks (boomerangCaught / boomerangOnward); SIM.md keeps both streams' profile keys; op_amgoat expects S3's ACTIVE_RANGE (b2a4388); golden: only diy moved

This commit is contained in:
sganggs committed 2026-10-06 00:07:31 +08:00
commit 85e75500b4
12 files changed
+2131 -104

No files matched your search

+8 -4
View File
@@ -752,6 +752,7 @@ registration order. `battle.off(handle)` / `battle.off(name, fn)` / `battle.offO
| `enemySpawn` / `enemyLeak` | `{ enemy }` | |
| `elementBurst` | `{ source, target, element }` | before the burst's lock/effects; same-element fills of `target` are already refused |
| `dodge` | `{ source, target, dmg }` | an attack was dodged |
| `boomerangCaught` | `{ unit, attackId, isSkill, x, y }` | a 回环射手 boomerang came back to its thrower (`ai.js throwBoomerang`; `isSkill` = thrown by a skill attack): 娜仁图亚 LPS-Y "每回收5次回旋投射物", S3 "投射物全部回收时" |
| `layerGain` | `{ playerId, bondId, n, reason, source, tile }` | mutable `n` before recording (魔王 +1 …), then clamped to the room left under `BOND_LAYER_CAP` (999); not emitted for a bond already at the cap; `tile` = `[r, c]` where `source` stands — or was knocked out this very instant ("被击倒时" gains) — else null (`addLayers` opts.tile overrides) |
| `merchantPay` | `{ unit, cost, cancel }` | a merchant (行商) is about to pay its periodic DP; change `cost` or set `cancel` |
| `battleEnd` | `{ result }` | may still add layer gains / coins |
@@ -1143,11 +1144,14 @@ S3 未照耀的荣光 — its CUSTOM_RANGE trigger also counts flyers). A stun /
3.75 back = `BOOMERANG_RETURN_SPEED`, PRTS 跃跃; droneBomb 5 = 暴鸰's bomb, the official projectile_bombd); melee/`none` hits are
instant, and so are `'beam'` hits (a 锁定攻击范围 AoE without a projectile — `rangeAoe` profiles: "在攻击前摇结束时选取范围内的全体目标,同时造成伤害", PRTS 作战机制). Kit-settable profile flags beyond the
table: `hitSleep` (targets and damages sleeping enemies — "可以攻击沉睡的敌人"), `onEachHit(b, u, victim, hctx)`, `dmgMul`,
`afterHit`, `afterAttack`, `canAttack`, `hitsFn`, `priority`, `blockFly`, `noHeal`, `skipEnemy(e)` (an enemy the unit never
`afterHit`, `afterAttack`, `canAttack`, `hitsFn`, `priority` (targeting.js PRIORITY_FNS — `'heaviest'`: the 攻城手 trait
"优先攻击重量最重的敌人", the highest current 重量等级 first: 早露 / 提丰), `blockFly`, `noHeal`, `skipEnemy(e)` (an enemy the unit never
selects — its attacks, the enemies it blocks and its skill-trigger targets: targeting.js canTargetEnemy; 嵯峨 "不攻击重伤
单位"), `boomerang` (the projectile stays
`'boomerang'` whatever the data's generic ranged projectile says), `rangeAoe` (applied after every override: sets
`allInRange` and, on a ranged profile, the instant `'beam'`) (see the header of professions.js).
`'boomerang'` whatever the data's generic ranged projectile says), `boomerangOnward(ctx)` (a boomerang's flight after its
first hit belongs to content: ctx `hit(target, x, y)` / `comeBack(x, y)` — 娜仁图亚 S1's bounces, S2's dash; the loopshooter
row), `rangeAoe` (applied after every override: sets `allInRange` and, on a ranged profile, the instant `'beam'`) (see the
header of professions.js).
| sub | behaviour |
|---|---|
@@ -1158,7 +1162,7 @@ selects — its attacks, the enemies it blocks and its skill-trigger targets: ta
| blastcaster | `rangeAoe`: every selectable enemy on its line at once, the same damage near and far, instant (`'beam'`) — "超远距离的群体法术伤害" is the whole line, not a splash (primary: PRTS 作战机制 §AOE伤害判定 names 伊芙利特's 炎爆 a 锁定攻击范围 AoE, and 炎爆 is her next-attack skill "下次攻击造成…" (PRTS 伊芙利特 S2), so a 轰击术师 normal attack; secondary: Terra Wiki, Blast Caster; supporting: PRTS 溅射半径一览 documents no splash radius for it; community report E3). A stealthed enemy is not struck unless it is revealed or blocked (a 锁定范围 AoE cannot hit a 隐匿 unit, PRTS 作战机制) |
| bombarder | ground-only splash 0.9 (PRTS 溅射半径一览: 投掷手 0.9; 1.0 until 0.1.1) + aftershock(s) at 50 % ATK (bb append_atk_scale / times); 迷迭香's S2 末梢阻断 1.5 (the same table) |
| hunter | 8 bullets (bb value), ×1.2 ATK (bb atk_scale), reloads 1/s after 1 s without attacking; can't attack when empty |
| loopshooter | 回环射手 (user playtest #3): every attack throws a boomerang (`ai.js throwBoomerang`, projectile `'boomerang'`) out to the target at 15 tiles/s — it hits on arrival — and back to the thrower's current position at 3.75 tiles/s without damage (PRTS 跃跃 "投射物飞行速度15,返回时飞行速度3.75"); attacks only while holding it (every boomerang thrown caught — "必须回收全部回旋投掷物才可以进行下一次攻击", `unit.trait.boomerangsOut`) and with the attack cooldown ready, so the real interval is the longer of the two; a target dead mid-flight is not hit (it still flies to the last position and back); knocked out / withdrawn ⇒ lost, a redeployed thrower holds a fresh one; 跃跃 S2's extra boomerangs share the one flight (cnt hits) |
| loopshooter | 回环射手 (user playtest #3): every attack throws a boomerang (`ai.js throwBoomerang`, projectile `'boomerang'`) out to the target at 15 tiles/s — it hits on arrival — and back to the thrower's current position at 3.75 tiles/s without damage (PRTS 跃跃 "投射物飞行速度15,返回时飞行速度3.75"); attacks only while holding it (every boomerang thrown caught — "必须回收全部回旋投掷物才可以进行下一次攻击", `unit.trait.boomerangsOut`) and with the attack cooldown ready, so the real interval is the longer of the two; a target dead mid-flight is not hit (it still flies to the last position and back); knocked out / withdrawn ⇒ lost, a redeployed thrower holds a fresh one; 跃跃 S2's extra boomerangs share the one flight (cnt hits); each catch fires `boomerangCaught`, and an attack profile's `boomerangOnward(ctx)` flies it on after the first hit before it turns back — ctx `hit` (a hit of the same attack), `comeBack(x, y)` once (a content error sends it back from the hit point): 娜仁图亚 S1 bounces, S2 dashes on and hits on its way back (kits/ops/op-narant.js) |
| reaperrange | hits every enemy in range; ×1.5 (bb atk_scale) on the trait front grid (or its own line ahead) — both along its direction |
| chain | chain N (trait text/bb max_target) with −15 % per jump (bb chain.atk_scale), 1.7-tile jumps (constants.js CHAIN_RADIUS, PRTS 溅射半径一览: 链术师 1.7; 1.8 until 0.1.1), sluggish on each hit |
| funnel | drone damage 20 % → +15 %/hit on the same target → 110 % (bb init/delta/max) |
+23 -3
View File
@@ -185,19 +185,39 @@ export function performAttack(b, u, prof, targets, opts = null) {
* current position at BOOMERANG_RETURN_SPEED, dealing nothing on the way back, and is caught (u.trait.boomerangsOut −1:
* the thrower attacks again once every boomerang is back). A thrower knocked out / withdrawn meanwhile loses it — nothing
* returns to a unit off the field or to a later deployment of it (the deploy hook hands it a fresh one).
* Content: a catch fires the hook `boomerangCaught` { unit, attackId, isSkill, x, y } (娜仁图亚 LPS-Y "每回收5次回旋投射物",
* S3 "投射物全部回收时"); an attack profile with `boomerangOnward(ctx)` (a skill's attack override: 娜仁图亚 S1's bounces,
* S2's dash) takes the flight over after the first hit — ctx { battle, unit, profile, target, x, y, attackId, isSkill,
* home(), hit(target, x, y) (resolveHit with this attack's profile), comeBack(x, y) (sends it back from there, once;
* returns the return projectile or null) } — and a content error there sends it back from the hit point.
*/
function throwBoomerang(b, u, prof, t, info) {
const seq = u.deploySeq;
u.trait.boomerangsOut = (u.trait.boomerangsOut || 0) + 1;
const home = () => u.alive && u.deployed && u.deploySeq === seq;
let sent = false;
const comeBack = (x, y) => {
if (sent || !home()) return null;
sent = true;
// hitDead: flies on to the thrower's last position even while it is hidden, caught there when it is still home
return b.addProjectile({ from: { x, y }, target: u, speed: BOOMERANG_RETURN_SPEED, visual: 'boomerangReturn', source: u, hitDead: true,
onHit: (r) => {
if (!home() || !(u.trait.boomerangsOut > 0)) return;
u.trait.boomerangsOut--;
if (b._hooks.boomerangCaught) b.emit('boomerangCaught', { unit: u, attackId: info.attackId ?? 0, isSkill: !!info.isSkill, x: r.x, y: r.y });
} });
};
b.addProjectile({ from: u, target: t, speed: PROJECTILE_SPEEDS.boomerang, visual: 'boomerang', source: u, hitDead: true,
onHit: (c) => {
// (guarded on its own: a content error in the hit must not cost the thrower its boomerang for the battle)
if (c.target || prof.splashRadius > 0) b._safe(() => resolveHit(b, u, prof, c.target, info, c.x, c.y), 'boomerang.hit', u);
if (!home()) return;
// hitDead: flies on to the thrower's last position even while it is hidden, caught there when it is still home
b.addProjectile({ from: { x: c.x, y: c.y }, target: u, speed: BOOMERANG_RETURN_SPEED, visual: 'boomerangReturn', source: u, hitDead: true,
onHit: () => { if (home() && u.trait.boomerangsOut > 0) u.trait.boomerangsOut--; } });
if (typeof prof.boomerangOnward === 'function') {
const ctx = { battle: b, unit: u, profile: prof, target: c.target, x: c.x, y: c.y, attackId: info.attackId ?? 0, isSkill: !!info.isSkill,
home, comeBack, hit: (tgt, x, y) => b._safe(() => resolveHit(b, u, prof, tgt, info, x, y), 'boomerang.hit', u) };
if (b._safe(() => { prof.boomerangOnward(ctx); return true; }, 'boomerang.onward', u) === true) return;
}
comeBack(c.x, c.y);
} });
}
+3
View File
@@ -89,6 +89,9 @@ export const OPERATOR_KIT_FILES = Object.freeze([
'op-amgoat.js',
'op-cerber.js',
'op-heyak.js',
'op-poca.js',
'op-typhon.js',
'op-narant.js',
]);
/**
+276
View File
@@ -0,0 +1,276 @@
// server/sim/content/kits/ops/op-narant.js — 娜仁图亚 (char_4138_narant) 自选 operator kit: 6★ 回环射手 (狙击), an owned-6★ pick
// of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_4138_narant, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 娜仁图亚 (备注 of 旋刃
// and 恶魇); PRTS 分支特性信息 回环射手; gamedata_const ba.steal 偷取; PRTS 命中率.
// - Trait (回环射手) "持有回旋投射物时才能够攻击(投射物需要时间回收)": the profession's boomerang (professions.js loopshooter,
// ai.js throwBoomerang: out 15, back 3.75 tiles/s; she attacks again once every boomerang is caught); hits air units.
// - Module LPS-X “吹尽狂沙”: trait "攻击周围8格的敌人时攻击力提升至110%" (trait bb atk_scale; the hidden module talent repeats it):
// ×atk_scale on her attack hits on an enemy within one tile (3 × 3, as 跃跃's LPS-X). Stage 2+ “我见,我得” (below).
// - Module LPS-Y “致我们的老大”: trait "每回收5次回旋投射物时获得1点技力" (come_back_cnt / sp): every come_back_cnt boomerangs caught
// (hook boomerangCaught; an S3 flight carries cnt) give sp SP — none while a skill of hers runs (the AK rule: giveSp)
// [ASSUMED: the catches count all the same]. Stage 2+ 婀娜虚影 (below).
// - T1 “我见,我得” "在场时,每次攻击到敌人偷取其25点攻击力(最高250点)与20点防御力(最高200点)": every attack hit of hers that lands
// (normal attacks, S1 bounces, S2 hits and its way back, each S3 boomerang — not S3's recall blast [ASSUMED]) steals:
// the enemy loses steal_atk ATK / steal_def DEF of its base (flat, at most steal_atk_max / steal_def_max in all) and she
// gains as much (flat, the same caps) — ba.steal "减少目标的基础属性作为自身加成,目标减少和自身加成的属性不超过指定上限(同类属性
// 取最高)": one loss per enemy, the largest of the 娜仁图亚 that stole from it. Both last while she is on the field (在场时)
// [ASSUMED: they end when she leaves]; each steal adds to both sides, each capped on its own [ASSUMED]. LPS-X stage 2+ (caps
// 275 / 220 → 300 / 240) "攻击到周围8格的敌人时偷取触发2次": the count is parsed from the talent text (no blackboard key).
// - T2 婀娜虚影 "获得35%的物理与法术闪避,周围8格内敌人的物理与法术命中率-20%": dodgePhys / dodgeArts +prob; every physical / arts
// attack of an enemy within one tile of her misses with damage_hitrate_* (PRTS 命中率: one roll when the attack's first
// damage instance comes — as Raidian's 诱引, standin-acsupo.js —, a miss cancels every instance and its riders; two
// 娜仁图亚 never add up). LPS-Y stage 3 −30 % and "6秒内未受到伤害时,攻击力+15%" (hidden atk / interval): ATK +atk while she
// has taken no damage (流失 aside) for `interval` s — from her deployment on, as 远牙's 专注 [ASSUMED].
// - S1 旋刃 (切换: a toggle that stays on until she leaves — the kits' reading, 银灰 S2 [ASSUMED]): her range loses its far
// column (攻击距离-1: ability_range_forward_extend −1; the engine's rangeExtend only grows), her boomerang hits for
// attack@atk_scale × ATK and then bounces up to attack@times times (PRTS 备注: radius BOUNCE_RADIUS from the boomerang,
// an enemy this attack has not hit first, then the nearest; BOUNCE_SPEED tiles/s), each bounce a hit of the same attack;
// then it flies back. The data's DEFAULT trigger (the running range is smaller).
// - S2 恶魇 (30 s, attack SP): each hit attack@atk_scale × ATK + 停顿 attack@sluggish s; the boomerang then flies on
// move_ahead_time s at DASH_SPEED tiles/s (PRTS 备注 "抵达目标所在位置后以1.0的速度再向前运动0.5s,随后返回") and on its way
// back hits every enemy within attack@projectile_range (备注 "投射物碰撞半径1.0") once for attack@atk_scale_comeback × ATK.
// - S3 吞日 (20 s): each attack throws cnt boomerangs at the target (each attack@atk_scale × ATK; they fly together, as
// 跃跃's S2 [ASSUMED: the text names no other targets]); when they are caught she deals atk_scale_aoe × ATK physical to at
// most attack@aoe.max_target enemies within one tile (her order) and 停顿 `sluggish` s — a skill hit, not an attack.
import { num, talentBb, moduleBb, traitBb, skillRec, statBuff, toggleBuff, onHitBy, enemiesInGrid, cheb, up, giveSp } from '../shared/tier1.js';
import { canTargetEnemy } from '../../../targeting.js';
import { isHpLoss } from '../../../damage.js';
const S1 = 'skchr_narant_1';
const S2 = 'skchr_narant_2';
const S3 = 'skchr_narant_3';
/** PRTS 旋刃 备注: "弹射半径1.7…弹射期间投射物移动速度6.0". */
const BOUNCE_RADIUS = 1.7;
const BOUNCE_SPEED = 6;
/** PRTS 恶魇 备注: "以1.0的速度再向前运动0.5s" (the time is the blackboard's move_ahead_time). */
const DASH_SPEED = 1;
/** "周围8格": her tile and the eight around it. */
const AROUND = Object.freeze([[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 0], [0, 1], [1, -1], [1, 0], [1, 1]].map(Object.freeze));
/** 婀娜虚影's battle-wide `hit` handler runs before the enemy content's own riders (200), as Raidian's 诱引. */
const MISS_PRIORITY = 300;
const LOOT_KEY = 'talent:narant:loot';
const STOLEN_KEY = 'narant:stolen';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const count = (v, d) => Math.max(1, Math.floor(num(v, d)));
/** The named talent record `i` (data index ≥ 0) of the composed record. */
const talentRec = (chess, i) => (chess?.talents ?? []).filter((t) => t && t.index !== -1)[i] ?? null;
/** A range grid whose rows lose their `n` farthest tiles ("攻击距离-n"). */
function shortenGrid(grid, n) {
const far = new Map();
for (const [dr, dc] of grid) far.set(dr, Math.max(far.get(dr) ?? -Infinity, dc));
return grid.filter(([dr, dc]) => dc <= far.get(dr) - n).map(([dr, dc]) => [dr, dc]);
}
// ---- T1 偷取: the losses of the enemies, one battle-wide table (同类属性取最高 over several thieves) ----------------------
/** battle → Map(enemy → Map(thief id → { a, d })). */
const STOLEN = new WeakMap();
const stolenOf = (battle) => { let m = STOLEN.get(battle); if (!m) STOLEN.set(battle, (m = new Map())); return m; };
function applyLoss(battle, e, per) {
let a = 0, d = 0;
for (const v of per.values()) { a = Math.max(a, v.a); d = Math.max(d, v.d); }
if (!(a > 0) && !(d > 0)) { battle.removeBuff(e, STOLEN_KEY); return; }
battle.addBuff(e, { key: STOLEN_KEY, mods: { atkFlat: -a, defFlat: -d }, tags: ['steal'] });
}
// ---- T2 命中率: one battle-wide handler over every 娜仁图亚 of the battle -------------------------------------------------
/** battle → { srcs: [{ unit, phys, arts }], last: WeakMap(enemy → { id, miss }) }. */
const MISS = new WeakMap();
function missCut(st, e, type) {
let cut = 0;
for (const s of st.srcs) {
const v = type === 'arts' ? s.arts : s.phys;
if (v > cut && up(s.unit) && cheb(s.unit, e) <= 1) cut = v;
}
return Math.min(1, cut);
}
function missHit(battle, st, ctx) {
const e = ctx.source, d = ctx.dmg;
if (!e || e.side !== 'enemy' || !d || !d.isAttack || d.cancel || (d.type !== 'phys' && d.type !== 'arts')) return;
// one roll per attack: every damage instance of it shares the first instance's result
const prev = d.attackId ? st.last.get(e) : null;
let miss;
if (prev && prev.id === d.attackId) miss = prev.miss;
else {
const cut = missCut(st, e, d.type);
miss = cut > 0 && battle.rng() < cut;
if (d.attackId) st.last.set(e, { id: d.attackId, miss });
}
if (!miss) return;
d.cancel = true;
ctx.stopPropagation = true;
if (ctx.target) battle.fx('dodge', { x: ctx.target.x, y: ctx.target.y, id: ctx.target.id });
}
function installMiss(battle, unit, phys, arts) {
if (!(phys > 0) && !(arts > 0)) return;
let st = MISS.get(battle);
if (!st) {
st = { srcs: [], last: new WeakMap() };
MISS.set(battle, st);
// no owner: it serves every 娜仁图亚 of the battle, whichever is on the field
battle.on('hit', (ctx) => missHit(battle, st, ctx), { priority: MISS_PRIORITY });
}
st.srcs.push({ unit, phys, arts });
}
export default {
char_4138_narant: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const tb = traitBb(chess);
const hidden = moduleBb(chess);
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const cnt = count(b3.cnt, 3);
/** S1: the boomerang bounces from (ctx.x, ctx.y) — at most attack@times jumps — then flies back. */
const bounce = (ctx) => {
const { battle, unit } = ctx;
const hit = new Set(ctx.target ? [ctx.target] : []);
let on = ctx.target, x = ctx.x, y = ctx.y, left = count(b1['attack@times'], 3);
const next = () => {
if (left <= 0 || !ctx.home()) { ctx.comeBack(x, y); return; }
const cands = battle.foesInRadius(x, y, BOUNCE_RADIUS).filter((e) => e !== on && canTargetEnemy(unit, e, ctx.profile));
if (!cands.length) { ctx.comeBack(x, y); return; }
const d2 = (e) => (e.x - x) ** 2 + (e.y - y) ** 2;
cands.sort((a, b) => (hit.has(a) ? 1 : 0) - (hit.has(b) ? 1 : 0) || d2(a) - d2(b) || a.spawnSeq - b.spawnSeq);
const tgt = cands[0];
left--;
battle.addProjectile({ from: { x, y }, target: tgt, speed: BOUNCE_SPEED, visual: 'boomerang', source: unit, hitDead: true,
onHit: (c) => {
x = c.x; y = c.y;
on = c.target;
if (c.target) { hit.add(c.target); ctx.hit(c.target, c.x, c.y); }
next();
} });
battle.fx('bounce', { x, y, id: unit.id, target: tgt.id });
};
next();
};
/** S2: the boomerang dashes on, then hits every enemy it passes on its way back (once each). */
const dash = (ctx) => {
const { battle, unit } = ctx;
let dx = ctx.x - unit.x, dy = ctx.y - unit.y;
const len = Math.hypot(dx, dy);
if (len > 1e-9) { dx /= len; dy /= len; } else { const [fr, fc] = unit.fwd; dx = fc; dy = fr; }
const ahead = DASH_SPEED * Math.max(0, num(b2['attack@move_ahead_time'], 0.5));
battle.addProjectile({ from: { x: ctx.x, y: ctx.y }, to: { x: ctx.x + dx * ahead, y: ctx.y + dy * ahead }, speed: DASH_SPEED, visual: 'boomerang', source: unit, hitDead: true,
onHit: (c) => {
const back = ctx.comeBack(c.x, c.y);
if (!back) return;
const r = num(b2['attack@projectile_range'], 1), scale = num(b2['attack@atk_scale_comeback'], 1);
const passed = new Set();
const h = battle.on('tick', () => {
for (const e of battle.foesInRadius(back.x, back.y, r)) {
if (passed.has(e) || !canTargetEnemy(unit, e, ctx.profile)) continue;
passed.add(e);
battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'phys', isAttack: true, isSkill: true, attackId: ctx.attackId, tags: ['skill', 'narant:comeback'] });
}
if ((back.x === back.tx && back.y === back.ty) || !ctx.home()) battle.off(h);
}, { owner: unit });
} });
};
/** S3: the recall blast on at most attack@aoe.max_target enemies around her. */
const blast = (battle, unit) => {
if (!up(unit)) return;
const n = count(b3['attack@aoe.max_target'], 3), scale = num(b3.atk_scale_aoe, 1), slug = num(b3.sluggish);
battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1, id: unit.id, skill: 'narant:sunswallower' });
for (const e of enemiesInGrid(battle, unit, AROUND, { n, canHitFly: true })) {
battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'phys', isSkill: true, tags: ['skill', 'narant:blast'] });
if (e.alive && slug > 0) battle.applyStatus(e, 'sluggish', { duration: slug, source: unit });
}
};
return {
skills: {
[S1]: {
kind: 'toggle',
targeting: { rangeGrid: shortenGrid(chess.rangeGrid ?? [[0, 1]], Math.max(0, -Math.round(num(b1.ability_range_forward_extend, -1)))) },
attack: { atkScale: num(b1['attack@atk_scale'], 1), boomerangOnward: bounce },
},
[S2]: {
kind: 'duration',
attack: { atkScale: num(b2['attack@atk_scale'], 1), onHitStatus: { key: 'sluggish', duration: num(b2['attack@sluggish'], 1) }, boomerangOnward: dash },
},
[S3]: { kind: 'duration', attack: { atkScale: num(b3['attack@atk_scale'], 1), hits: cnt } },
},
talents: [
{ install(battle, unit) { // “我见,我得”: each landing attack hit steals ATK / DEF (LPS-X stage 2+: twice within one tile)
const sa = num(t0['attack@steal_atk']), sd = num(t0['attack@steal_def']);
if (!(sa > 0) && !(sd > 0)) return;
const capA = num(t0['attack@steal_atk_max'], Infinity), capD = num(t0['attack@steal_def_max'], Infinity);
const m = /周围8格的敌人时偷取触发(\d+)次/.exec(String(talentRec(chess, 0)?.desc ?? ''));
const nearTimes = m ? Math.max(1, +m[1]) : 1;
const losses = stolenOf(battle);
const victims = new Set();
const reset = () => { unit.mem.narantLoot = { a: 0, d: 0 }; };
reset();
battle.on('deploy', (c) => { if (c.unit === unit) reset(); }, { owner: unit });
battle.on('damaged', (c) => {
const e = c.target;
if (c.source !== unit || !c.dmg?.isAttack || !e || e.side !== 'enemy' || !up(unit)) return;
const times = nearTimes > 1 && cheb(unit, e) <= 1 ? nearTimes : 1;
const me = unit.mem.narantLoot;
let per = losses.get(e);
if (!per) losses.set(e, (per = new Map()));
const loss = per.get(unit.id) ?? { a: 0, d: 0 };
for (let i = 0; i < times; i++) {
me.a = Math.min(capA, me.a + sa); me.d = Math.min(capD, me.d + sd);
loss.a = Math.min(capA, loss.a + sa); loss.d = Math.min(capD, loss.d + sd);
}
per.set(unit.id, loss);
victims.add(e);
battle.addBuff(unit, { key: LOOT_KEY, mods: { atkFlat: me.a, defFlat: me.d }, tags: ['talent'] });
if (e.alive) applyLoss(battle, e, per);
}, { owner: unit });
// 在场时: off the field she keeps nothing (her buff goes with the removal) and the enemies get back what she took
battle.on('death', (c) => {
if (c.unit !== unit) return;
for (const e of victims) {
const per = losses.get(e);
if (!per || !per.delete(unit.id)) continue;
if (e.alive) applyLoss(battle, e, per);
}
victims.clear();
}, { owner: unit });
} },
{ install(battle, unit) { // 婀娜虚影: dodge; enemies within one tile miss; LPS-Y stage 3: ATK + while unhurt
const p = num(t1.prob);
statBuff(battle, unit, 'talent:narant:dodge', { dodgePhys: p, dodgeArts: p });
installMiss(battle, unit, Math.abs(num(t1.damage_hitrate_physical)), Math.abs(num(t1.damage_hitrate_magical)));
const atk = num(hidden.atk), iv = num(hidden.interval);
if (!(atk > 0) || !(iv > 0)) return;
unit.mem.narantHurt = -Infinity;
battle.on('damaged', (c) => { if (c.target === unit && c.amount > 0 && !isHpLoss(c.dmg)) unit.mem.narantHurt = battle.time; }, { owner: unit });
battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.narantHurt = -Infinity; }, { owner: unit });
toggleBuff(battle, unit, 'talent:narant:calm', () => battle.time - unit.mem.narantHurt >= iv - 1e-9, { atkPct: atk });
} },
],
install(battle, unit) {
// LPS-X: ×atk_scale on attack hits within one tile
const near = num(tb.atk_scale, 1);
if (near !== 1) onHitBy(battle, unit, ({ target, dmg }) => { if (dmg.isAttack && cheb(unit, target) <= 1) dmg.amount *= near; });
// LPS-Y: sp SP every come_back_cnt boomerangs caught; S3: the recall blast
const every = Math.floor(num(tb.come_back_cnt)), sp = num(tb.sp);
const s3 = unit.skill?.id === S3;
if (!(every > 0 && sp > 0) && !s3) return;
unit.mem.narantCaught = 0;
battle.on('boomerangCaught', (c) => {
if (c.unit !== unit) return;
const flightS3 = s3 && c.isSkill;
if (every > 0 && sp > 0) {
unit.mem.narantCaught += flightS3 ? cnt : 1;
while (unit.mem.narantCaught >= every) { unit.mem.narantCaught -= every; giveSp(unit, sp, 'module'); }
}
if (flightS3) blast(battle, unit);
}, { owner: unit });
},
};
},
};
+170
View File
@@ -0,0 +1,170 @@
// server/sim/content/kits/ops/op-poca.js — 早露 (char_197_poca) 自选 operator kit: 6★ 攻城手 (狙击), an owned-6★ pick of the
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_197_poca, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 早露 (备注 of
// 深入骨髓, 学生楷模 and 雪崩击); PRTS 分支特性信息 攻城手 ("可对空。攻击范围不包含自身所在地块").
// - Trait (攻城手) "优先攻击重量最重的敌人": target priority 'heaviest' (targeting.js: the highest current 重量等级 first, then
// the usual order); ranged physical arrows that hit air units (data canHitFly, PRTS "可对空"), range 4-3.
// - Module SIE-X 攻城器械训练装置: trait "攻击重量较重(重量等级大于等于3)的敌人时,攻击力提升至115%" — the hidden module talent
// (atk_scale / value, merged into 深入骨髓's blackboard): ×atk_scale on her attacks (normal attacks and the S3 strikes, the
// profile dmgMul — the kits' "攻击…时攻击力提升至X%" convention, 斯卡蒂 DRE-X) on an enemy of 重量等级 ≥ value. Stage 2+ 深入骨髓
// "…并额外造成60%攻击力的物理伤害" (extra_atk_scale): every attack hit that lands on such an enemy is followed by a physical hit
// of extra_atk_scale × ATK (which 深入骨髓's 物理穿透 also gets).
// - Module SIE-Y “心”: trait "攻击越远的敌人造成的伤害越高(最高提升12%)" (trait bb min_dist / max_dist / damage_scale): her attack
// damage × (1 + damage_scale × clamp((d − min_dist) / (max_dist − min_dist), 0, 1)), d = her distance to the target in tiles —
// the kits' reading of these keys (远牙, 协律, 芳汀 modules) [ASSUMED linear: PRTS states no formula]. Stage 2+: 学生楷模 below.
// - T1 深入骨髓 "攻击重量较重(重量等级大于等于3)的敌人时,无视其防御力的60%": every damage she deals to an enemy of 重量等级 ≥ value
// ignores def_penetrate of its DEF (DamageInfo defIgnorePct, added — PRTS 备注 "临时获取永久的物理穿透(百分比)Buff(不可叠加,
// 直接加算)").
// - T2 学生楷模 "编入队伍时,所有【乌萨斯学生自治团】干员攻击力+8%": every 【乌萨斯学生自治团】 operator of her player's team ATK +atk
// for the whole battle, deployed or not (the kits' 编入队伍时 convention, 斯卡蒂 深海掠食者). SIE-Y stage 3: +12 % and
// "每有一名【乌萨斯学生自治团】干员处于技能期间时,【乌萨斯学生自治团】干员的攻击力额外+15%(最多+45%)" (hidden init_atk /
// max_atk): the number of those operators on the field whose skill runs, re-read every STUDENT_TICK s and at every skill start
// (PRTS 备注 "每0.3秒及每次开启技能时更新一次自身的额外加成") [ASSUMED: her team's operators only, as the base talent].
// - S1 攻击力强化·γ型 (30 s): ATK +atk. S2 分裂射击 (60 s): ATK +atk, attack@max_target targets.
// - S3 雪崩击 (6 / 7 s): ATK +atk; at the cast, harpoons link the max_target heaviest enemies of her range (her own target
// order); each linked enemy is 束缚 (bind) while linked and takes one attack every hit_interval s, hit_duration /
// hit_interval attacks in all, the first as the harpoons land (the tick after the cast) [ASSUMED] — her own attacks (Battle.forceAttack:
// every attack rider of hers applies), instant along the links; she makes no other attack meanwhile [ASSUMED: the
// strikes are what "每秒受到一次攻击" names]. PRTS 备注: a link breaks when its enemy is knocked out, 离地 (OUT_OF_GROUND —
// no enemy of the mode holds it) or 消失 (hidden), every link at once when she is stunned / frozen / silenced, and the
// skill ends as soon as no link is left. The binds end with their links.
import { num, talentBb, moduleBb, moduleOn, traitBb, skillRec, statBuff, onHitBy, enemiesInGrid, up } from '../shared/tier1.js';
import { hasHp } from '../../../damage.js';
const S1 = 'skcom_atk_up[3]';
const S2 = 'skchr_poca_2';
const S3 = 'skchr_poca_3';
/** 【乌萨斯学生自治团】: character_table teamId `student` (古米, 凛冬, 烈夏, 苦艾, 真理, 早露, 怒潮凛冬); data/chess.json has no team id. */
const STUDENTS = new Set(['char_196_sunbr', 'char_115_headbr', 'char_194_leto', 'char_405_absin', 'char_195_glassb',
'char_197_poca', 'char_1051_headb2']);
/** PRTS 学生楷模 备注: the SIE-Y extra is re-read every 0.3 s (and at every skill start). */
const STUDENT_TICK = 0.3;
const STUDENT_KEY = 'talent:poca:students';
const DRIVE_KEY = 'talent:poca:studentSkills';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const count = (v, d) => Math.max(1, Math.floor(num(v, d)));
const isStudent = (a) => !!a && a.kind === 'op' && STUDENTS.has(a.def?.charId);
/** A running skill of `a` (a passive is no 技能期间). */
const inSkill = (a) => !!(a.skill && a.skill.active && a.skill.kind !== 'passive');
export default {
char_197_poca: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const heavyAt = num(t0.value, 3);
const heavy = (e) => !!e && e.side === 'enemy' && e.weight >= heavyAt;
// SIE-X (hidden part on 深入骨髓's index: atk_scale / value; stage 2+ extra_atk_scale), SIE-Y (trait bb; hidden
// init_atk / max_atk at stage 3) — chess is the loadout-resolved record, so no module ⇒ none of these keys
const xScale = moduleOn(chess) ? num(t0.atk_scale, 1) : 1;
const extra = num(t0.extra_atk_scale);
const tb = traitBb(chess);
const hidden = moduleBb(chess);
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
/** S3: the links of the running cast (unit.mem.pocaS3 = { links: Set, left, timer }). */
const unbind = (battle, unit, e) => {
const b = e.findBuff('bind');
if (b && b.source === unit) battle.removeBuff(e, b);
};
const linkBroken = (e) => !e.alive || !e.deployed || e.hidden || !!e.s.flags.outOfGround;
const strike = (battle, unit, st) => {
const targets = [...st.links].filter((e) => !linkBroken(e));
if (!targets.length || !unit.canAct || st.left <= 0) return;
st.left--;
battle.forceAttack(unit, targets);
};
return {
skills: {
[S1]: { kind: 'duration', mods: { atkPct: num(b1.atk) } },
[S2]: { kind: 'duration', mods: { atkPct: num(b2.atk) }, targeting: { maxTargets: count(b2['attack@max_target'], 2) } },
[S3]: {
kind: 'duration',
mods: { atkPct: num(b3.atk) },
// no attack of her own while the links hold; the strikes are instant along them ('beam')
attack: { noAttack: true, projectile: 'beam' },
onStart({ battle, unit, skill }) {
const links = enemiesInGrid(battle, unit, null, { n: count(b3.max_target, 3), priority: 'heaviest', canHitFly: true });
const st = { links: new Set(links), left: count(num(b3.hit_duration, skill.duration) / Math.max(0.1, num(b3.hit_interval, 1)), 6), timer: null };
unit.mem.pocaS3 = st;
for (const e of links) {
battle.applyStatus(e, 'bind', { duration: skill.timeLeft, source: unit });
battle.fx('harpoon', { x: e.x, y: e.y, id: unit.id, target: e.id });
}
// the harpoons land on the next tick (after the cast's skillStart handlers — 学生楷模's re-read), then every
// hit_interval s
st.timer = battle.after(0, () => {
if (unit.mem.pocaS3 !== st) return;
strike(battle, unit, st);
st.timer = battle.every(Math.max(0.1, num(b3.hit_interval, 1)), (b, sc) => {
if (unit.mem.pocaS3 !== st || st.left <= 0) { sc.cancel(); return; }
strike(battle, unit, st);
}, { owner: unit });
}, { owner: unit });
},
onTick({ battle, unit, skill }) {
const st = unit.mem.pocaS3;
if (!st) return;
const f = unit.s.flags;
if (f.stun || f.freeze || f.silence) { skill.end('interrupted'); return; }
for (const e of [...st.links]) if (linkBroken(e)) { st.links.delete(e); unbind(battle, unit, e); }
if (!st.links.size) skill.end('unlinked');
},
onEnd({ battle, unit }) {
const st = unit.mem.pocaS3;
unit.mem.pocaS3 = null;
if (!st) return;
if (st.timer) st.timer.cancel();
for (const e of st.links) unbind(battle, unit, e);
},
},
},
talents: [
{ install(battle, unit) { // 深入骨髓: +def_penetrate 物理穿透 on heavy enemies; SIE-X stage 2+: the extra physical hit
const pen = num(t0.def_penetrate);
if (pen > 0) onHitBy(battle, unit, ({ target, dmg }) => { if (heavy(target)) dmg.defIgnorePct += pen; });
if (extra > 0) {
battle.on('damaged', (c) => {
if (c.source !== unit || !c.dmg?.isAttack || !heavy(c.target) || !hasHp(c.target)) return;
battle.dealDamage(unit, c.target, { amount: unit.s.atk * extra, type: 'phys', tags: ['talent', 'poca:extra'] });
}, { owner: unit });
}
} },
{ install(battle, unit) { // 学生楷模: every student of her team ATK +atk; SIE-Y stage 3: +init_atk per student in skill
const team = battle.allyUnits.filter((a) => a.ownerId === unit.ownerId && isStudent(a));
for (const a of team) statBuff(battle, a, STUDENT_KEY, { atkPct: num(t1.atk) });
const per = num(hidden.init_atk), cap = num(hidden.max_atk, Infinity);
if (!(per > 0)) return;
const update = () => {
const v = Math.min(cap, per * team.filter((a) => up(a) && inSkill(a)).length);
for (const a of team) {
const cur = a.findBuff(DRIVE_KEY);
if (!(v > 0)) { if (cur) battle.removeBuff(a, cur); continue; }
if (!cur || cur.mods?.atkPct !== v) battle.addBuff(a, { key: DRIVE_KEY, mods: { atkPct: v }, persist: true, allowDead: true, tags: ['talent'], source: unit });
}
};
battle.every(STUDENT_TICK, update, { owner: unit, immediate: true });
battle.on('skillStart', (c) => { if (team.includes(c.unit)) update(); }, { owner: unit });
} },
],
// 攻城手: heaviest first; SIE-X ×atk_scale on her attacks against heavy enemies
trait: { priority: 'heaviest', ...(xScale !== 1 ? { dmgMul: (b, u, target) => (heavy(target) ? xScale : 1) } : null) },
install(battle, unit) {
// SIE-Y: the farther the target, the more damage (attacks only)
const ds = num(tb.damage_scale), lo = num(tb.min_dist, 1), hi = num(tb.max_dist, 4.5);
if (ds > 0) {
onHitBy(battle, unit, ({ target, dmg }) => {
if (!dmg.isAttack) return;
const d = Math.hypot(target.x - unit.x, target.y - unit.y);
dmg.mul *= 1 + ds * Math.max(0, Math.min(1, (d - lo) / Math.max(1e-6, hi - lo)));
});
}
},
};
},
};
+212
View File
@@ -0,0 +1,212 @@
// server/sim/content/kits/ops/op-typhon.js — 提丰 (char_2012_typhon) 自选 operator kit: 6★ 攻城手 (狙击), an owned-6★ pick of
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and all three modules at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_2012_typhon, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 提丰 (备注 of 锐如兽牙,
// 重如沼泥, 冰原秩序 and “永恒狩猎”); PRTS 分支特性信息 攻城手 ("可对空").
// - Trait (攻城手) "优先攻击重量最重的敌人": target priority 'heaviest' (targeting.js); ranged physical arrows that hit air units,
// range 4-3 (as 早露, kits/ops/op-poca.js).
// - Module SIE-X 自然的包容: trait "攻击重量较重(重量等级大于等于3)的敌人时,攻击力提升至115%" (the hidden module talent's atk_scale /
// value on 锐如兽牙's index): ×atk_scale on her attacks against an enemy of 重量等级 ≥ value (profile dmgMul); stage 2+ 锐如兽牙's
// numbers (12 % a stack, 10 s) come with the composed talent.
// - Module SIE-Y 冰原的影子: trait "攻击越远的敌人造成的伤害越高(最高提升12%)" — as 早露's SIE-Y (her attack damage, linear between
// min_dist and max_dist [ASSUMED]); ASPD in the stats; stage 2+ 重如沼泥 "前两次…攻击力提升至220%" (max_stack_cnt / atk_scale).
// - Module ISW-A 提丰特限证章: its trait and talent parts say "在集成战略中" — no effect in this mode (the hidden damage_scale /
// arrow-rain blackboards are ignored); its HP / ATK are in the stats.
// - T1 锐如兽牙 "连续攻击时逐渐无视敌人的防御力,最高无视其防御力的50%(每次攻击提升10%的无视防御比例),8秒内未攻击则失去加成": every
// attack she makes adds a stack (max_stack_cnt) of +def_penetrate 物理穿透 (defIgnorePct, added — PRTS 备注 "增加自身物理穿透(百分
// 比)属性(直接加算)") before its damage ("获得的加成可应用于当次伤害"), all of them lost `duration` s after her last attack.
// - T2 重如沼泥 "技能期间对每个敌人首次造成伤害时,攻击力提升至160%并使目标停顿3秒": while a skill of hers runs, her first (SIE-Y stage
// 2+: first max_stack_cnt) damage instances on each enemy deal ×atk_scale (攻击力倍率) and, when they land, 停顿 `sluggish` s —
// every damage of hers, the S3 arrow rain included ("造成伤害"); the marks are cleared when the skill ends or she leaves
// (PRTS 备注 "非首次标记…自身技能结束时或自身离场时移除"). A dodged instance spends its mark [ASSUMED: "于计算伤害前触发"].
// - S1 迅捷打击·γ型 (35 s): ATK +atk, ASPD +attack_speed.
// - S2 冰原秩序: ATK +atk; every attack shoots two arrows, at two different enemies when there are two (targeting 2), else both
// at the one (PRTS "优先攻击不同目标"); each arrow attack@prob to stun attack@stun s. first_duration s on its first cast of a
// deployment, then "持续时间无限" until she leaves (a toggle the kit ends after first_duration s; PRTS 备注 "每次部署提丰时,
// 重新计算该技能的使用次数").
// - S3 “永恒狩猎” (ammo attack@s3_trigger_time): base attack time +base_attack_time s (flat: batMod). At the cast she marks the first
// enemy of her own range (未开启技能时的攻击范围: the initial range, her heaviest-first order); the mark area (radius MARK_RADIUS,
// 碰撞判定 — foesInRadius) follows that enemy until it leaves the field, then stays; its enemies are re-read every MARK_SCAN s.
// Her range is the whole field (the card shows 全场) but she attacks only while an enemy is in the mark area; each attack
// deals nothing itself and drops a round of s3_max_hit_num arrows, RAIN_GAP s apart, each on a random enemy of the area: a
// physical skill hit (not an attack: PRTS "不属于普通攻击/技能攻击的直接伤害", so the SIE-X / SIE-Y attack riders do not apply
// [ASSUMED]) of s3_atk_scale × ATK, then 晕眩 s3_stun s once that damage landed.
// Auto-cast: the data's DEFAULT (the whole-field range exists only in PRTS's note; the mark is picked from her own range).
import { num, talentBb, moduleOn, traitBb, skillRec, batMod, onHitBy, skillBusy, up } from '../shared/tier1.js';
import { canTargetEnemy, sortEnemyTargets } from '../../../targeting.js';
import { ROWS, COLS } from '../../../constants.js';
const S1 = 'skcom_quickattack[3]';
const S2 = 'skchr_typhon_2';
const S3 = 'skchr_typhon_3';
/** PRTS S3 备注: "标记区域半径1.3(碰撞判定),每0.2s更新范围内的敌人", "箭雨的每支箭间隔0.1s且分别索敌". */
const MARK_RADIUS = 1.3;
const MARK_SCAN = 0.2;
const RAIN_GAP = 0.1;
/** S3 "攻击范围扩大至全场": every offset that reaches the whole 19 × 21 field whatever her direction. */
const FIELD = (() => {
const m = Math.max(ROWS, COLS) - 1;
const g = [];
for (let dr = -m; dr <= m; dr++) for (let dc = -m; dc <= m; dc++) g.push(Object.freeze([dr, dc]));
return Object.freeze(g);
})();
const FANG_KEY = 'talent:typhon:fang';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const count = (v, d) => Math.max(1, Math.floor(num(v, d)));
export default {
char_2012_typhon: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const tb = traitBb(chess);
// SIE-X: the hidden part merged into 锐如兽牙's blackboard (no module / SIE-Y / ISW-A ⇒ no atk_scale there)
const xScale = moduleOn(chess) ? num(t0.atk_scale, 1) : 1;
const heavyAt = num(t0.value, 3);
const heavy = (e) => !!e && e.side === 'enemy' && e.weight >= heavyAt;
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
/** S3: the enemies of the mark area (last scan) her attacks may still select. */
const areaFoes = (unit, st) => st.inArea.filter((e) => e.alive && e.deployed && !e.hidden && canTargetEnemy(unit, e, unit.profile));
const follow = (st) => { const t = st.target; if (t && t.alive && t.deployed && !t.hidden) { st.x = t.x; st.y = t.y; } };
const scan = (battle, unit, st) => { st.inArea = battle.foesInRadius(st.x, st.y, MARK_RADIUS).filter((e) => canTargetEnemy(unit, e, unit.profile)); };
const rain = (battle, unit) => {
const st = unit.mem.typhonMark;
if (!st) return;
battle.fx('arrowRain', { x: st.x, y: st.y, radius: MARK_RADIUS, id: unit.id });
const scale = num(b3['attack@s3_atk_scale'], 1), stun = num(b3['attack@s3_stun']);
const arrow = () => {
const pool = areaFoes(unit, st);
if (!pool.length) return;
const e = battle.rng.pick(pool);
const dealt = battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'phys', isSkill: true, tags: ['skill', 'typhon:rain'] });
if (dealt > 0 && e.alive && stun > 0) battle.applyStatus(e, 'stun', { duration: stun, source: unit });
};
arrow();
for (let k = 1; k < count(b3['attack@s3_max_hit_num'], 4); k++) battle.after(RAIN_GAP * k, arrow, { owner: unit });
};
return {
skills: {
[S1]: { kind: 'duration', mods: { atkPct: num(b1.atk), aspd: num(b1.attack_speed) } },
[S2]: {
kind: 'toggle',
mods: { atkPct: num(b2.atk) },
targeting: { maxTargets: 2 },
attack: {
onHit({ battle, unit, target }) {
if (target && target.alive && battle.rng.chance(num(b2['attack@prob']))) battle.applyStatus(target, 'stun', { duration: num(b2['attack@stun'], 1), source: unit });
},
},
onStart({ unit, skill }) {
unit.mem.typhonS2Uses = (unit.mem.typhonS2Uses ?? 0) + 1;
skill.timeLeft = unit.mem.typhonS2Uses > 1 ? Infinity : Math.max(0.01, num(b2.first_duration, skill.duration));
},
onTick({ skill, dt }) {
if (!Number.isFinite(skill.timeLeft)) return;
skill.timeLeft -= dt;
if (skill.timeLeft <= 1e-9) skill.end('duration');
},
},
[S3]: {
kind: 'ammo',
ammo: count(b3['attack@s3_trigger_time'], 8),
mods: { batPct: batMod(b3.base_attack_time, chess) },
targeting: { rangeGrid: FIELD },
attack: {
projectile: 'beam',
hitsFn: () => 0,
canAttack: (battle, unit) => !!unit.mem.typhonMark && areaFoes(unit, unit.mem.typhonMark).length > 0,
onHit({ battle, unit }) { rain(battle, unit); },
},
onStart({ battle, unit }) {
const cands = battle.enemiesInKeys(unit.baseRangeKeys ?? unit.rangeKeys, unit, unit.profile);
sortEnemyTargets(battle, unit, cands, 'heaviest');
const t = cands[0] ?? null;
const st = { target: t, x: t ? t.x : unit.x, y: t ? t.y : unit.y, inArea: [], timer: null };
unit.mem.typhonMark = st;
scan(battle, unit, st);
st.timer = battle.every(MARK_SCAN, (b, sc) => {
if (unit.mem.typhonMark !== st) { sc.cancel(); return; }
follow(st);
scan(battle, unit, st);
}, { owner: unit });
battle.fx('mark', { x: st.x, y: st.y, radius: MARK_RADIUS, id: unit.id, target: t ? t.id : null });
},
onTick({ unit }) { const st = unit.mem.typhonMark; if (st) follow(st); },
onEnd({ unit }) {
const st = unit.mem.typhonMark;
unit.mem.typhonMark = null;
if (st && st.timer) st.timer.cancel();
},
},
},
talents: [
{ install(battle, unit) { // 锐如兽牙: +def_penetrate 物理穿透 a stack per attack (max_stack_cnt), gone `duration` s after the last
const per = num(t0.def_penetrate);
if (!(per > 0)) return;
const max = count(t0.max_stack_cnt, 5), dur = num(t0.duration, 8);
battle.on('beforeAttack', (ctx) => {
if (ctx.attacker !== unit || !up(unit)) return;
battle.addBuff(unit, { key: FANG_KEY, duration: dur, refresh: 'stack', maxStacks: max, mods: { defIgnorePct: per }, tags: ['talent'] });
}, { owner: unit });
} },
{ install(battle, unit) { // 重如沼泥: during a skill, the first max_stack_cnt damages on each enemy ×atk_scale + 停顿
const scale = num(t1.atk_scale, 1), slug = num(t1.sluggish), max = count(t1.max_stack_cnt, 1);
const marked = new WeakSet();
battle.on('skillStart', (c) => { if (c.unit === unit) unit.mem.typhonMud = new Map(); }, { owner: unit });
battle.on('skillEnd', (c) => { if (c.unit === unit) unit.mem.typhonMud = null; }, { owner: unit });
battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.typhonMud = null; }, { owner: unit });
onHitBy(battle, unit, ({ target, dmg }) => {
const marks = unit.mem.typhonMud;
if (!marks || !skillBusy(unit)) return;
const n = marks.get(target.id) ?? 0;
if (n >= max) return;
marks.set(target.id, n + 1);
dmg.amount *= scale;
marked.add(dmg);
});
battle.on('damaged', (c) => {
if (c.source !== unit || !marked.has(c.dmg) || !c.target.alive || !(slug > 0)) return;
battle.applyStatus(c.target, 'sluggish', { duration: slug, source: unit });
}, { owner: unit });
} },
],
// 攻城手: heaviest first; SIE-X ×atk_scale on her attacks against heavy enemies
trait: { priority: 'heaviest', ...(xScale !== 1 ? { dmgMul: (b, u, target) => (heavy(target) ? xScale : 1) } : null) },
install(battle, unit) {
// S2: two arrows per attack — both at the one enemy when it is the only target
if (unit.skill?.id === S2) {
battle.on('beforeAttack', (ctx) => {
if (ctx.attacker !== unit || !unit.skill.active || ctx.targets.length !== 1) return;
ctx.targets = [ctx.targets[0], ctx.targets[0]];
}, { owner: unit });
}
// S3: the round is aimed at the marked enemy (else another one of the area): its 'atk' line points there
if (unit.skill?.id === S3) {
battle.on('beforeAttack', (ctx) => {
const st = unit.mem.typhonMark;
if (ctx.attacker !== unit || !unit.skill.active || !st) return;
const pool = areaFoes(unit, st);
const t = pool.includes(st.target) ? st.target : pool[0];
if (t) ctx.targets = [t];
}, { owner: unit });
}
// S2 counts its casts per deployment
battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.typhonS2Uses = 0; }, { owner: unit });
// SIE-Y: the farther the target, the more damage (attacks only)
const ds = num(tb.damage_scale), lo = num(tb.min_dist, 1), hi = num(tb.max_dist, 4.5);
if (ds > 0) {
onHitBy(battle, unit, ({ target, dmg }) => {
if (!dmg.isAttack) return;
const d = Math.hypot(target.x - unit.x, target.y - unit.y);
dmg.mul *= 1 + ds * Math.max(0, Math.min(1, (d - lo) / Math.max(1e-6, hi - lo)));
});
}
},
};
},
};
+3
View File
@@ -183,6 +183,9 @@ const PRIORITY_FNS = {
boss: (e) => (e.isBoss ? 0 : 1),
notBurst: (e) => (e.s.flags.burstLock ? 1 : 0),
ground: (e) => (e.isFlying ? 1 : 0),
// 攻城手 trait "优先攻击重量最重的敌人" (早露 / 提丰: kits/ops/op-poca.js, op-typhon.js): the highest current 重量等级
// (Unit.weight: massLevel with 失重 etc.) first, the usual order after that
heaviest: (e) => -e.weight,
};
/**
+2 -2
View File
@@ -161,13 +161,13 @@ test('S2 点燃 (AUTO, 2 charges, data DEFAULT): main target RES ×0.85 / ×0.8
}
});
test('S3 火山 (MANUAL, data SKILL_RANGE on x-3): 15 s, ATK +70 % / +85 %, interval 1.6 − 1.1 = 0.5 s, range x-3 while on, lava on 4 / 5 random different enemies of it per attack (all when fewer); back after', () => {
test('S3 火山 (MANUAL, data ACTIVE_RANGE on x-3 — 「攻击范围增大」 is an attack-range change, owner rule 2026-10-05): 15 s, ATK +70 % / +85 %, interval 1.6 − 1.1 = 0.5 s, range x-3 while on, lava on 4 / 5 random different enemies of it per attack (all when fewer); back after', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const { h, u } = field({ tier, elite, skill: 2, seed: 9 });
const n = sk.bb['attack@max_target'];
assert.deepEqual([u.skill.rule, sk.trigger.rawRule, sk.rangeId, sk.duration, sk.bb.atk, sk.bb.base_attack_time, n, sk.spCost, sk.initSp],
['SKILL_RANGE', 'DEFAULT', 'x-3', 15, elite ? 0.85 : 0.7, -1.1, elite ? 5 : 4, 80, elite ? 40 : 33], `T${tier}`);
['ACTIVE_RANGE', 'DEFAULT', 'x-3', 15, elite ? 0.85 : 0.7, -1.1, elite ? 5 : 4, 80, elite ? 40 : 33], `T${tier}`);
assert.deepEqual(u.skill.triggerGrid, sk.rangeGrid, `T${tier}: the trigger grid is x-3`);
h.run(0.6);
const atk0 = u.s.atk;
+376
View File
@@ -0,0 +1,376 @@
// test/content/op_narant.test.js — the 自选 operator kit of 娜仁图亚 (char_4138_narant, 6★ 回环射手; kit
// server/sim/content/kits/ops/op-narant.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, LPS-X
// “吹尽狂沙” or LPS-Y “致我们的老大” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the
// form of that slot status); the fidelity checklist of kits/README.md item by item. Also the two engine hooks of the
// boomerang flight it relies on (ai.js throwBoomerang: `boomerangCaught`, a profile's `boomerangOnward`).
// Run: node --test test/content/op_narant.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';
import { PROJECTILE_SPEEDS, BOOMERANG_RETURN_SPEED } from '../../server/sim/constants.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 NARANT = 'char_4138_narant';
const FORMS = BACKUPS.units[NARANT].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const LPSX = 'uniequip_002_narant', LPSY = 'uniequip_003_narant';
const S1 = 'skchr_narant_1', S2 = 'skchr_narant_2', S3 = 'skchr_narant_3';
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
const 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_brute: dummy('enemy_brute', { atk: 400, def: 300 }),
enemy_fly: dummy('enemy_fly', { motion: 'FLY' }),
// a ranged attacker that shoots twice a second at whatever ally its 2.5-tile radius holds (an ATK her steals cannot drain)
enemy_shooter: dummy('enemy_shooter', { atk: 5000, bat: 0.5, range: 2.5 }),
};
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, LPSX, LPSY].map((m) => [t, true, m]))];
/** LPS-Y stage 3 婀娜虚影: ATK +15 % while unhurt — she starts unhurt. */
const calmAtk = (tier, elite, mod) => (elite && mod === LPSY && tier === 6 ? 0.15 : 0);
/** A battle with 娜仁图亚 as uid 1 at (row, col) facing RIGHT, plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 3, others = [], seed = 5, hooks = [] } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'boomerangCaught', 'spGain', 'attack', ...hooks], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: NARANT, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const hitsBy = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u);
const atkHits = (h, u) => hitsBy(h, u).filter((c) => c.dmg?.isAttack);
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
const TICK = 1 / 30;
test('娜仁图亚 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, y-7 range, ranged physical boomerangs that hit air, block 1, 35 % dodge, no 特质', () => {
assert.equal(OPERATOR_KITS[NARANT], KITS[NARANT]);
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], [NARANT, 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.dmgType, u.profile.projectile, u.profile.canHitFly, u.base.bat, typeof u.profile.canAttack],
[1, 'ranged', 'phys', 'boomerang', true, 1, 'function'], `${label(f)}: 回环射手`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: y-7`);
assert.deepEqual([u.s.dodgePhys, u.s.dodgeArts], [0.35, 0.35], `${label(f)}: 婀娜虚影 dodge`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [BACKUPS.diy.operators[NARANT].bonds, []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
done(h);
}
}
// E2 Lv1 2000 / 546 / 144, E2 Lv60 2331 / 632 / 161; LPS-X +25 / +25 → +45 / +45 ATK / DEF, LPS-Y +210 / +20 → +300 / +40 HP / ATK
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [2000, 546, 2331, 632]);
assert.deepEqual([modOf(5, LPSX).attr, modOf(6, LPSX).attr, modOf(5, LPSY).attr, modOf(6, LPSY).attr], [{ atk: 25, def: 25 }, { atk: 45, def: 45 }, { maxHp: 210, atk: 20 }, { maxHp: 300, atk: 40 }]);
});
test('a 自选 pick: 娜仁图亚 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(NARANT));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(NARANT), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: NARANT, skillIndex: 0, uniEquipId: LPSX } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[5]]: { charId: NARANT, skillIndex: 0, uniEquipId: LPSX } } });
});
test('trait 回环射手 (engine): one boomerang out at 15 and back at 3.75 tiles/s, the next throw once it is caught — each catch fires boomerangCaught { unit, attackId, isSkill }; a flyer is hit', () => {
const { h, u } = field({ tier: 6, elite: true, skill: 2 });
const fly = h.spawn('enemy_fly', { pos: [10, 6] });
h.run(6);
const hits = atkHits(h, u), caught = h.hooksOf('boomerangCaught').filter((c) => c.unit === u);
assert.ok(hits.length >= 2 && hits.every((c) => c.target === fly), '可对空');
assert.ok(caught.length >= 2);
for (const c of caught) assert.ok(hits.some((x) => x.dmg.attackId === c.attackId) && c.isSkill === false, 'the catch names its attack');
const d = 3;
const flight = d / PROJECTILE_SPEEDS.boomerang + d / BOOMERANG_RETURN_SPEED;
approx(caught[0].t - hits[0].t, d / BOOMERANG_RETURN_SPEED, 'back at 3.75 tiles/s', 0.1);
approx(hits[1].t - hits[0].t, flight, 'she waits for it (longer than her 1 s interval)', 0.1);
assert.ok(h.eventsOf('atk').filter((e) => e[1] === u.id).every((e) => e[3] === 'boomerang'));
done(h);
});
test('S1 旋刃 (切换: on until she leaves; data DEFAULT): range −1 (the far column of y-7), 140 % / 160 % ATK, then up to 3 bounces (radius 1.7, an enemy this attack has not hit first, then the nearest; 6 tiles/s), then back; a lone enemy: no bounce', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
const { h, u } = field({ tier, elite, skill: 0 });
assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.spCost, u.skill.initSp, sk.bb['attack@times'], sk.bb['attack@atk_scale'], sk.bb.ability_range_forward_extend],
['DEFAULT', 'toggle', elite ? 8 : 9, 0, 3, elite ? 1.6 : 1.4, -1], `T${tier}`);
const lone = h.spawn('enemy_dummy', { pos: [10, 5] });
assert.ok(h.runUntil(() => u.skill.active, 12), `T${tier}: on once its SP is full and an enemy is in range`);
const short = formOf(tier, elite).rangeGrid.filter(([, c]) => c < 3);
assert.deepEqual(u.liveRangeGrid, short, `T${tier}: 攻击距离-1`);
h.run(3);
const one = atkHits(h, u).filter((c) => c.dmg.isSkill);
assert.ok(one.length >= 1 && one.every((c) => c.target === lone), `T${tier}: one hit per attack on a lone enemy`);
assert.equal(new Set(one.map((c) => c.dmg.attackId)).size, one.length, `T${tier}: no bounce`);
const before = atkHits(h, u).filter((c) => c.t < one[0].t - 1e-9).length; // her normal attacks' steals so far
approx(one[0].amount, (u.base.atk + Math.min(250, 25 * before)) * sk.bb['attack@atk_scale'], `T${tier}: ${sk.bb['attack@atk_scale'] * 100} % (+ the ATK stolen before)`);
// A (lone) at 10,5; B at 11,5 (1 from A); C at 9,6 (1.41 from A, 2.24 from B)
const b = h.spawn('enemy_dummy', { pos: [11, 5] }), c = h.spawn('enemy_dummy', { pos: [9, 6] });
const far = h.spawn('enemy_dummy', { pos: [12, 5] }); // 2 from A, 1 from B: reachable from B only
h.run(0.1);
const n0 = atkHits(h, u).length;
h.run(4);
const after = atkHits(h, u).slice(n0);
const id = after[0].dmg.attackId;
const chain = after.filter((x) => x.dmg.attackId === id);
assert.equal(chain.length, 4, `T${tier}: the hit + 3 bounces`);
assert.equal(chain[0].target, lone, `T${tier}: her target`);
assert.equal(chain[1].target, b, `T${tier}: nearest not yet hit (B, 1 tile)`);
assert.equal(chain[2].target, far, `T${tier}: from B the one not yet hit (12,5), before A`);
approx(chain[1].t - chain[0].t, 1 / 6 + TICK, `T${tier}: bounce at 6 tiles/s`, 0.07);
assert.ok(chain.every((x) => x.dmg.isAttack && x.dmg.isSkill && x.type === 'phys'), `T${tier}: hits of the attack`);
assert.ok(!chain.some((x) => x.target === c) || chain[3].target === c, `T${tier}: C (2.24 from B) only from A`);
// on until she leaves; redeployed: off, own range
h.run(30);
assert.ok(u.skill.active, `T${tier}: still on`);
h.b.retreat(u);
h.b.redeploy(u);
assert.ok(!u.skill.active, `T${tier}: off after a redeploy`);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: y-7 again`);
done(h);
}
});
test('S2 恶魇 (30 s, attack SP 18 from 5 / 8): 210 % / 225 % ATK + 停顿 1 s; the boomerang flies on 0.5 s, then hits every enemy within 1.0 of its way back once for 155 % / 170 %; farther ones untouched', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
const { h, u } = field({ tier, elite, skill: 1 });
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, u.skill.initSp, sk.duration, sk.bb['attack@atk_scale'], sk.bb['attack@atk_scale_comeback'], sk.bb['attack@sluggish'], sk.bb['attack@move_ahead_time'], sk.bb['attack@projectile_range']],
['DEFAULT', 'attack', 18, elite ? 8 : 5, 30, elite ? 2.25 : 2.1, elite ? 1.7 : 1.55, 1, 0.5, 1], `T${tier}`);
const target = h.spawn('enemy_dummy', { pos: [10, 6] });
const side = h.spawn('enemy_dummy', { pos: [11, 5] }); // 1.0 from the return line (row 10): hit on the way back
const off = h.spawn('enemy_dummy', { pos: [12, 4] }); // 2 rows off: never
h.b.addBuff(target, { key: 'test:taunt', mods: { taunt: 5 } }); // her target
// attack SP: one per normal attack
const sp0 = u.skill.sp;
h.run(3.5);
const normal = atkHits(h, u).filter((c) => !c.dmg.isSkill).length;
approx(u.skill.sp, Math.min(18, sp0 + normal), `T${tier}: +1 SP an attack`);
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`);
approx(u.skill.timeLeft, 30, `T${tier}: 30 s`, 0.1);
h.run(3);
const sk2 = atkHits(h, u).filter((c) => c.dmg.isSkill);
const id = sk2[0].dmg.attackId;
const main = sk2.find((c) => c.dmg.attackId === id && !c.dmg.tags?.includes('narant:comeback'));
const back = sk2.filter((c) => c.dmg.attackId === id && c.dmg.tags?.includes('narant:comeback'));
assert.equal(main.target, target, `T${tier}: her target`);
assert.deepEqual(back.map((c) => c.target).sort((a, b) => a.id - b.id), [target, side].sort((a, b) => a.id - b.id), `T${tier}: once each within 1.0 of the way back`);
assert.ok(back.every((c) => c.t - main.t >= 0.5), `T${tier}: after the 0.5 s dash`);
assert.ok(!sk2.some((c) => c.target === off), `T${tier}: 2 tiles off the line`);
assert.ok(back.every((c) => c.dmg.isAttack && c.type === 'phys'), `T${tier}: hits of the attack`);
const slows = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish');
assert.ok(slows.length && slows.every((c) => c.target === target && Math.abs(c.duration - 1) < 1e-9), `T${tier}: 停顿 1 s on the hit target only`);
// the hit's ATK scale: her ATK at that hit (base + the steals before it) × 225 %
const before = atkHits(h, u).filter((c) => c.t < main.t - 1e-9).length;
const sa = formOf(tier, elite).talents.find((t) => t.index === 0).bb['attack@steal_atk'];
approx(main.amount, (u.base.atk + Math.min(250, sa * before)) * sk.bb['attack@atk_scale'], `T${tier}: ${sk.bb['attack@atk_scale'] * 100} %`);
done(h);
}
});
test('S3 吞日 (20 s): 3 boomerangs per attack (3 hits of 145 % / 160 %, flying together); once they are caught she strikes at most 3 enemies within one tile for 130 % / 140 % and 停顿 1 s (no attack)', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const { h, u } = field({ tier, elite, skill: 2 });
assert.deepEqual([u.skill.rule, u.skill.spCost, u.skill.initSp, sk.duration, sk.bb.cnt, sk.bb['attack@atk_scale'], sk.bb.atk_scale_aoe, sk.bb['attack@aoe.max_target'], sk.bb.sluggish],
['DEFAULT', elite ? 36 : 39, elite ? 17 : 14, 20, 3, elite ? 1.6 : 1.45, elite ? 1.4 : 1.3, 3, 1], `T${tier}`);
const target = h.spawn('enemy_dummy', { pos: [10, 6] });
const around = [[9, 3], [11, 3], [9, 4], [11, 4]].map((pos) => h.spawn('enemy_dummy', { pos }));
h.b.addBuff(target, { key: 'test:taunt', mods: { taunt: 5 } }); // her target: the far one
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`);
approx(u.skill.timeLeft, 20, `T${tier}: 20 s`, 0.1);
h.run(2.5);
const sk3 = atkHits(h, u).filter((c) => c.dmg.isSkill);
const id = sk3[0].dmg.attackId;
const three = sk3.filter((c) => c.dmg.attackId === id);
assert.equal(three.length, 3, `T${tier}: 3 boomerangs`);
assert.ok(three.every((c) => c.target === target && c.t === three[0].t), `T${tier}: together, at the target`);
const caught = h.hooksOf('boomerangCaught').find((c) => c.unit === u && c.attackId === id);
assert.ok(caught?.isSkill, `T${tier}: caught`);
const blast = hitsBy(h, u).filter((c) => c.dmg.tags?.includes('narant:blast') && Math.abs(c.t - caught.t) < 1e-9);
assert.equal(blast.length, 3, `T${tier}: at most 3 of the 4 around her`);
assert.ok(blast.every((c) => around.includes(c.target) && !c.dmg.isAttack && c.dmg.isSkill && c.type === 'phys'), `T${tier}: skill hits within one tile`);
const slows = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish' && Math.abs(c.t - caught.t) < 1e-9);
assert.equal(slows.length, 3, `T${tier}: 停顿 on each`);
for (const c of slows) approx(c.duration, 1, '1 s');
done(h);
}
});
test('T1 “我见,我得”: every landing attack hit steals 25 ATK / 20 DEF — the enemy loses, she gains, each capped at 250 / 200 (LPS-X stage 3: 300 / 240, twice within one tile); two of her: the larger loss, not the sum; a dodged hit steals nothing; all given back when she leaves', () => {
for (const f of [[5, false, null], [6, true, null], [5, true, LPSX], [6, true, LPSX], [6, true, LPSY]]) {
const [tier, elite, mod] = f;
const t0 = formOf(tier, elite).talents.find((t) => t.index === 0).bb;
const tc = mod === LPSX && tier === 6 ? modOf(6, LPSX).talentChanges.find((t) => t.talentIndex === 0) : null;
const capA = tc?.bb['attack@steal_atk_max'] ?? t0['attack@steal_atk_max'], capD = tc?.bb['attack@steal_def_max'] ?? t0['attack@steal_def_max'];
assert.deepEqual([t0['attack@steal_atk'], t0['attack@steal_def'], capA, capD], [25, 20, ...(tc ? [300, 240] : [250, 200])], label(f));
const twice = !!tc;
if (twice) assert.match(tc.desc, /攻击到周围8格的敌人时偷取触发2次/);
// one hit at two tiles, then one at one tile
{
const { h, u } = field({ tier, elite, mod, skill: 2 });
const e = h.spawn('enemy_brute', { pos: [10, 5] });
assert.ok(h.runUntil(() => atkHits(h, u).length === 1, 3));
assert.deepEqual(u.findBuff('talent:narant:loot')?.mods, { atkFlat: 25, defFlat: 20 }, `${label(f)}: she gains`);
assert.deepEqual(e.findBuff('narant:stolen')?.mods, { atkFlat: -25, defFlat: -20 }, `${label(f)}: the enemy loses`);
assert.deepEqual([e.s.atk, e.s.def], [375, 280], `${label(f)}: its stats`);
h.b.kill(e, null);
const near = h.spawn('enemy_brute', { pos: [10, 4] });
assert.ok(h.runUntil(() => atkHits(h, u).length === 2, 4));
const k = twice ? 2 : 1;
assert.deepEqual(near.findBuff('narant:stolen')?.mods, { atkFlat: -25 * k, defFlat: -20 * k }, `${label(f)}: within one tile ×${k}`);
assert.deepEqual(u.findBuff('talent:narant:loot')?.mods, { atkFlat: 25 * (1 + k), defFlat: 20 * (1 + k) }, `${label(f)}: hers`);
// the caps
h.run(40);
assert.deepEqual(u.findBuff('talent:narant:loot')?.mods, { atkFlat: capA, defFlat: capD }, `${label(f)}: her cap`);
assert.deepEqual(near.findBuff('narant:stolen')?.mods, { atkFlat: -capA, defFlat: -capD }, `${label(f)}: its cap`);
approx(u.s.atk, (u.base.atk + capA) * (1 + calmAtk(tier, elite, mod)), `${label(f)}: her ATK`);
// she leaves: nothing kept, everything given back
h.b.retreat(u);
h.step();
assert.equal(u.findBuff('talent:narant:loot'), null, `${label(f)}: her gains go`);
assert.equal(near.findBuff('narant:stolen'), null, `${label(f)}: its losses come back`);
assert.deepEqual([near.s.atk, near.s.def], [400, 300]);
done(h);
}
}
// two of her (a T5 and a T6 piece) hitting one enemy: the larger loss, never the sum; a dodged hit steals nothing
const { h, u } = field({ tier: 5, skill: 2, others: [{ uid: 2, diy: { slot: SLOT[6], charId: NARANT, skillIndex: 2 }, row: 12, col: 3 }] });
const v = h.unit(2);
const e = h.spawn('enemy_brute', { pos: [11, 5] });
h.run(8);
const n1 = atkHits(h, u).filter((c) => c.target === e).length, n2 = atkHits(h, v).filter((c) => c.target === e).length;
assert.ok(n1 >= 3 && n2 >= 3, `both hit it (${n1}, ${n2})`);
assert.deepEqual(e.findBuff('narant:stolen')?.mods.atkFlat, -Math.min(250, 25 * Math.max(n1, n2)), 'the larger loss');
const ghost = h.spawn('enemy_brute', { pos: [11, 4] });
h.b.kill(e, null);
h.b.addBuff(ghost, { key: 'test:dodge', mods: { dodgePhys: 1 } });
h.run(4);
assert.ok(h.hooksOf('damaged').every((c) => c.target !== ghost), 'every hit dodged');
assert.equal(ghost.findBuff('narant:stolen'), null, 'a dodged hit steals nothing');
done(h);
});
test('T2 婀娜虚影: enemies within one tile of her miss 20 % (LPS-Y stage 3: 30 %) of their physical / arts attacks — one roll per attack —, farther ones never; LPS-Y stage 3: ATK +15 % after 6 s without damage', () => {
for (const f of [[5, false, null], [6, true, LPSY], [5, true, LPSY]]) {
const [tier, elite, mod] = f;
const t1 = formOf(tier, elite).talents.find((t) => t.index === 1).bb;
const cut = mod === LPSY && tier === 6 ? -modOf(6, LPSY).talentChanges.find((t) => t.talentIndex === 1).bb.damage_hitrate_physical : -t1.damage_hitrate_physical;
assert.equal(cut, mod === LPSY && tier === 6 ? 0.3 : 0.2, label(f));
for (const [pos, inside] of [[[10, 4], true], [[11, 5], false]]) {
const { h, u } = field({ tier, elite, mod, skill: 2, seed: 13 });
h.b.removeBuff(u, 'talent:narant:dodge'); // her own 闪避 aside: every lost shot is a miss
h.b.addBuff(u, { key: 'test:tank', mods: { hpFlat: 1e9 } });
const shooter = h.spawn('enemy_shooter', { pos });
h.run(150);
const shots = h.hooksOf('attack').filter((c) => c.attacker === shooter).length;
const landed = h.hooksOf('damaged').filter((c) => c.source === shooter).length;
assert.ok(shots > 200, `${label(f)}: ${shots} shots`);
const rate = 1 - landed / shots;
if (inside) assert.ok(Math.abs(rate - cut) < 0.07, `${label(f)}: ${(rate * 100).toFixed(1)} % missed ≈ ${cut * 100} %`);
else assert.ok(landed >= shots - 1, `${label(f)}: two tiles away: no miss (${landed} / ${shots}, the last one may still fly)`);
done(h);
}
}
// the calm ATK
const { h, u } = field({ tier: 6, elite: true, mod: LPSY, skill: 2 });
assert.deepEqual(modOf(6, LPSY).talentChanges.find((t) => t.talentIndex === -1).bb, { atk: 0.15, interval: 6 });
h.step();
assert.deepEqual(u.findBuff('talent:narant:calm')?.mods, { atkPct: 0.15 }, 'unhurt since her deployment');
h.b.dealDamage(null, u, { amount: 10, type: 'true' });
h.step();
assert.equal(u.findBuff('talent:narant:calm'), null, 'hurt: gone');
h.run(5.5);
assert.equal(u.findBuff('talent:narant:calm'), null, 'not yet');
h.run(0.6);
assert.ok(u.findBuff('talent:narant:calm'), '6 s later: back');
h.b.loseHp(u, 10, {});
h.step();
assert.ok(u.findBuff('talent:narant:calm'), 'a 流失 is no damage');
done(h);
});
test('LPS-X “吹尽狂沙”: ×1.1 on her attack hits within one tile (stages 1 and 3), not beyond; none without it', () => {
for (const [tier, mod] of [[5, LPSX], [6, LPSX], [6, LPSY], [5, null]]) {
const near = mod === LPSX ? 1.1 : 1;
if (mod === LPSX) assert.deepEqual(modOf(tier, LPSX).traitOverride.bb, { atk_scale: 1.1 });
for (const [pos, d1] of [[[10, 4], true], [[10, 5], false], [[11, 4], true]]) {
const { h, u } = field({ tier, elite: true, mod, skill: 2 });
const e = h.spawn('enemy_dummy', { pos });
assert.ok(h.runUntil(() => atkHits(h, u).length === 1, 3));
approx(atkHits(h, u)[0].amount, u.base.atk * (1 + calmAtk(tier, true, mod)) * (d1 ? near : 1), `T${tier} ${mod}: ${pos}`);
assert.equal(atkHits(h, u)[0].target, e);
done(h);
}
}
});
test('LPS-Y “致我们的老大”: 1 SP every 5 boomerangs caught (an S3 throw carries 3), none while her skill runs; none without it', () => {
for (const [tier, mod] of [[5, LPSY], [6, LPSY], [6, LPSX]]) {
if (mod === LPSY) assert.deepEqual(modOf(tier, LPSY).traitOverride.bb, { come_back_cnt: 5, sp: 1 });
const { h, u } = field({ tier, elite: true, mod, skill: 1 }); // S2: attack SP — the time SP stays out of it
h.spawn('enemy_dummy', { pos: [10, 6] });
h.run(9);
const caught = h.hooksOf('boomerangCaught').filter((c) => c.unit === u && !c.isSkill).length;
const gains = h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'module').length;
assert.ok(caught >= 5, `T${tier} ${mod}: ${caught} caught`);
assert.equal(gains, mod === LPSY ? Math.floor(caught / 5) : 0, `T${tier} ${mod}: ${gains} module SP`);
done(h);
}
// S3: each throw is 3 boomerangs; while S3 runs no SP is gained (the catches still count)
const { h, u } = field({ tier: 6, elite: true, mod: LPSY, skill: 2 });
h.spawn('enemy_dummy', { pos: [10, 6] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4));
h.run(6);
const during = h.hooksOf('boomerangCaught').filter((c) => c.unit === u && c.isSkill).length;
assert.ok(during >= 3, `${during} S3 throws caught`);
assert.equal(u.mem.narantCaught, (3 * during) % 5, 'the S3 catches count 3 each');
assert.equal(h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'module' && c.amount > 0 && u.skill.active).length, 0, 'no SP during S3');
done(h);
});
test('engine: a profile `boomerangOnward` takes the flight over after the first hit — ctx.hit / ctx.comeBack once — and a content error there still brings the boomerang back', () => {
const calls = [];
const kits = {
[NARANT]: () => ({
skills: Object.fromEntries([S1, S2, S3].map((id) => [id, { kind: 'duration' }])),
trait: {
boomerangOnward(ctx) {
calls.push(ctx.attackId);
if (calls.length === 2) throw new Error('kit bug');
ctx.battle.after(0.5, () => { assert.equal(typeof ctx.comeBack(ctx.x, ctx.y), 'object'); assert.equal(ctx.comeBack(ctx.x, ctx.y), null, 'once'); });
},
},
}),
};
const h = makeBattle({ defs: { enemies: ENEMIES }, timeLimit: 120, autoFinish: false, seed: 1, kits, verbose: false, hooks: ['boomerangCaught'],
units: [{ uid: 1, diy: { slot: SLOT[6], charId: NARANT, skillIndex: 2 }, elite: true, row: 10, col: 3 }] });
h.step();
const u = h.unit(1);
h.spawn('enemy_dummy', { pos: [10, 6] });
h.run(6);
assert.ok(calls.length >= 3, `${calls.length} flights taken over`);
assert.ok(h.hooksOf('boomerangCaught').length >= 2, 'caught every time (the error one too)');
assert.ok(h.b.errors.some((e) => /boomerang\.onward/.test(e.label ?? e.where ?? JSON.stringify(e))), 'the error is logged');
assert.equal(u.trait.boomerangsOut <= 1, true);
checkInvariants(h.b);
});
+343
View File
@@ -0,0 +1,343 @@
// test/content/op_poca.test.js — the 自选 operator kit of 早露 (char_197_poca, 6★ 攻城手; kit
// server/sim/content/kits/ops/op-poca.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in every
// form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, SIE-X
// 攻城器械训练装置 or SIE-Y “心” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form
// of that slot status); the fidelity checklist of kits/README.md item by item.
// Run: node --test test/content/op_poca.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 POCA = 'char_197_poca';
const FORMS = BACKUPS.units[POCA].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const SIEX = 'uniequip_002_poca', SIEY = 'uniequip_003_poca';
const S1 = 'skcom_atk_up[3]', S2 = 'skchr_poca_2', S3 = 'skchr_poca_3';
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
const 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_light: dummy('enemy_light', { mass: 1 }), enemy_mid: dummy('enemy_mid', { mass: 2 }), enemy_heavy: dummy('enemy_heavy', { mass: 3 }),
enemy_armor: dummy('enemy_armor', { mass: 3, def: 600 }), enemy_plate: dummy('enemy_plate', { mass: 2, def: 600 }),
enemy_fly: dummy('enemy_fly', { mass: 1, motion: 'FLY' }), enemy_hfly: dummy('enemy_hfly', { mass: 4, motion: 'FLY' }),
};
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, SIEX, SIEY].map((m) => [t, true, m]))];
/** 学生楷模: she is a 【乌萨斯学生自治团】 operator herself — her ATK always carries the talent's +atk. */
const studentAtk = (tier, elite, mod) => (elite && mod === SIEY && tier === 6 ? 0.12 : 0.08);
/** A battle with 早露 as uid 1 at (row, col) facing RIGHT, plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 3, others = [], seed = 5 } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 600, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: POCA, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const hitsBy = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u);
const atkHits = (h, u) => hitsBy(h, u).filter((c) => c.dmg?.isAttack);
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
test('早露 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 4-3 range without her tile, ranged physical arrows that hit air, heaviest first, block 1, no 特质', () => {
assert.equal(OPERATOR_KITS[POCA], KITS[POCA]);
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], [POCA, 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.dmgType, u.profile.canHitFly, u.profile.groundOnly, u.profile.priority, u.base.bat],
[1, 'ranged', 'phys', true, false, 'heaviest', 2.4], `${label(f)}: 攻城手`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 4-3`);
assert.ok(!u.liveRangeGrid.some(([r, c]) => r === 0 && c === 0), `${label(f)}: PRTS 攻城手 "攻击范围不包含自身所在地块"`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [BACKUPS.diy.operators[POCA].bonds, []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
done(h);
}
}
// the numbers of the forms (zh_CN): E2 Lv1 1351 / 856 / 100, E2 Lv60 1619 / 973 / 115; SIE-X +60 / +30 → +90 / +45,
// SIE-Y +70 / +20 → +100 / +30 ATK / DEF
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [1351, 856, 1619, 973]);
assert.deepEqual([modOf(5, SIEX).attr, modOf(6, SIEX).attr, modOf(5, SIEY).attr, modOf(6, SIEY).attr], [{ atk: 60, def: 30 }, { atk: 90, def: 45 }, { atk: 70, def: 20 }, { atk: 100, def: 30 }]);
});
test('a 自选 pick: 早露 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(POCA));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(POCA), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: POCA, skillIndex: 2, uniEquipId: SIEY } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[5]]: { charId: POCA, skillIndex: 2, uniEquipId: SIEY } } });
});
test('trait 攻城手 "优先攻击重量最重的敌人": the heaviest enemy of her range first, wherever it stands; a lighter one only once it is heavier (失重 moves it down); flyers are targets too', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const { h, u } = field({ tier, elite, skill: 0 });
const light = h.spawn('enemy_light', { pos: [10, 5] }), heavy = h.spawn('enemy_heavy', { pos: [11, 7] });
const n0 = atkHits(h, u).length;
assert.ok(h.runUntil(() => atkHits(h, u).length > n0, 4), `T${tier}: an attack`);
assert.equal(atkHits(h, u).at(-1).target, heavy, `T${tier}: weight 3 before weight 1 (the nearer one)`);
h.b.addBuff(heavy, { key: 'test:weightless', mods: { massFlat: -3 } });
const n1 = atkHits(h, u).length;
assert.ok(h.runUntil(() => atkHits(h, u).length > n1, 4));
assert.equal(atkHits(h, u).at(-1).target, light, `T${tier}: 失重 to 0 — now the light one first`);
const fly = h.spawn('enemy_hfly', { pos: [9, 6] });
const n2 = atkHits(h, u).length;
assert.ok(h.runUntil(() => atkHits(h, u).length > n2, 4));
assert.equal(atkHits(h, u).at(-1).target, fly, `T${tier}: a weight-4 flyer first (可对空)`);
done(h);
}
});
test('S1 攻击力强化·γ型 (MANUAL, data DEFAULT): ATK +45 % / +60 % for 30 s, SP 37 / 35 from 5 / 10; cast with an enemy in her range at her attack', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
const { h, u } = field({ tier, elite, skill: 0 });
assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.spType, u.skill.spCost, u.skill.initSp, sk.duration, sk.bb.atk],
['DEFAULT', 'duration', 'time', elite ? 35 : 37, elite ? 10 : 5, 30, elite ? 0.6 : 0.45], `T${tier}`);
const st = studentAtk(tier, elite, null);
approx(u.s.atk, u.base.atk * (1 + st), `T${tier}: 学生楷模 only`);
u.skill.gainSp(999);
h.run(2);
assert.equal(u.skill.activations, 0, `T${tier}: no enemy, no cast`);
h.spawn('enemy_light', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`);
approx(u.skill.timeLeft, 30, `T${tier}: 30 s`, 0.01);
approx(u.s.atk, u.base.atk * (1 + st + sk.bb.atk), `T${tier}: ATK +${sk.bb.atk * 100} %`);
h.runUntil(() => !u.skill.active, 31);
approx(u.s.atk, u.base.atk * (1 + st), `T${tier}: back`);
done(h);
}
});
test('S2 分裂射击 (60 s): ATK +45 % / +60 % and two targets per attack (attack@max_target 2) — the two heaviest; a lone enemy gets one arrow', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
const { h, u } = field({ tier, elite, skill: 1 });
assert.deepEqual([u.skill.rule, sk.duration, sk.bb.atk, sk.bb['attack@max_target'], u.skill.spCost, u.skill.initSp], ['DEFAULT', 60, elite ? 0.6 : 0.45, 2, 80, elite ? 45 : 42], `T${tier}`);
const lone = h.spawn('enemy_light', { pos: [10, 6] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4));
approx(u.skill.timeLeft, 60, `T${tier}: 60 s`, 0.05);
approx(u.s.atk, u.base.atk * (1 + studentAtk(tier, elite, null) + sk.bb.atk), `T${tier}: ATK`);
h.run(0.6);
const n0 = atkHits(h, u).length;
h.run(2.5);
const one = atkHits(h, u).slice(n0);
assert.ok(one.length >= 1 && one.every((c) => c.target === lone), `T${tier}: one target, one arrow per attack`);
const mid = h.spawn('enemy_mid', { pos: [11, 6] }), heavy = h.spawn('enemy_heavy', { pos: [9, 5] });
h.run(0.5);
const n1 = atkHits(h, u).length;
h.run(2.5);
const ids = atkHits(h, u).slice(n1).map((c) => c.dmg.attackId);
const first = atkHits(h, u).slice(n1).filter((c) => c.dmg.attackId === ids[0]);
assert.deepEqual(first.map((c) => c.target).sort((a, b) => a.id - b.id), [mid, heavy].sort((a, b) => a.id - b.id), `T${tier}: the two heaviest (weights 3, 2), not the light one`);
done(h);
}
});
test('S3 雪崩击 (6 / 7 s): harpoons link the 3 heaviest enemies of her range (a flyer too), 束缚 them, and strike each every second — 6 / 7 strikes at ATK +10 % (attacks), no other attack; the binds end with it', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const { h, u } = field({ tier, elite, skill: 2 });
assert.deepEqual([u.skill.rule, sk.duration, sk.bb.max_target, sk.bb.hit_interval, sk.bb.hit_duration, sk.bb.atk, u.skill.spCost, u.skill.initSp],
['DEFAULT', elite ? 7 : 6, 3, 1, elite ? 7 : 6, 0.1, elite ? 34 : 37, elite ? 12 : 11], `T${tier}`);
const light = h.spawn('enemy_light', { pos: [10, 5] }), mid = h.spawn('enemy_mid', { pos: [10, 6] });
const heavy = h.spawn('enemy_heavy', { pos: [11, 6] }), hfly = h.spawn('enemy_hfly', { pos: [9, 6] });
const outside = h.spawn('enemy_heavy', { pos: [10, 9] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`);
const t0 = h.b.time;
const linked = [mid, heavy, hfly];
for (const e of linked) assert.equal(e.findBuff('bind')?.source, u, `T${tier}: ${e.defId} bound`);
assert.equal(light.findBuff('bind'), null, `T${tier}: the 4th heaviest is not linked`);
assert.equal(outside.findBuff('bind'), null, `T${tier}: out of range`);
approx(u.s.atk, u.base.atk * (1 + studentAtk(tier, elite, null) + 0.1), `T${tier}: ATK +10 %`);
assert.ok(h.runUntil(() => !u.skill.active, 10), `T${tier}: ends`);
approx(h.b.time - t0, sk.duration, `T${tier}: ${sk.duration} s`, 0.02);
const hits = hitsBy(h, u);
for (const e of linked) {
const on = hits.filter((c) => c.target === e);
assert.equal(on.length, sk.duration, `T${tier}: ${on.length} strikes on ${e.defId}`);
assert.ok(on.every((c) => c.dmg.isAttack && c.dmg.isSkill && c.type === 'phys'), `T${tier}: physical skill attacks`);
on.forEach((c, i) => i && approx(c.t - on[i - 1].t, 1, `T${tier}: one a second`, 0.02));
}
assert.ok(!hits.some((c) => c.target === light || c.target === outside), `T${tier}: nobody else is hit`);
for (const e of linked) assert.equal(e.findBuff('bind'), null, `T${tier}: unbound at the end`);
// the strikes are instant along the links
assert.ok(h.eventsOf('atk').filter((ev) => ev[1] === u.id).some((ev) => ev[3] === 'beam'));
done(h);
}
});
test('S3 雪崩击 (PRTS 备注): a link breaks when its enemy is knocked out or vanishes, the skill ends at once when none is left; a stun / silence on her breaks every link', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
// all links lost ⇒ the skill ends early
{
const { h, u } = field({ tier, elite, skill: 2 });
const a = h.spawn('enemy_heavy', { pos: [10, 6] }), b = h.spawn('enemy_mid', { pos: [11, 6] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4));
h.run(1.2);
h.b.kill(a, null);
h.step();
assert.ok(u.skill.active, `T${tier}: one link left`);
b.hidden = true; // 消失
h.step();
assert.ok(!u.skill.active, `T${tier}: no link left ⇒ ends at once`);
b.hidden = false;
assert.equal(b.findBuff('bind'), null, `T${tier}: its bind went with the link`);
done(h);
}
for (const status of ['stun', 'silence']) {
const { h, u } = field({ tier, elite, skill: 2 });
const a = h.spawn('enemy_heavy', { pos: [10, 6] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4));
h.run(1.5);
h.b.applyStatus(u, status, { duration: 2, source: null });
h.step();
assert.ok(!u.skill.active, `T${tier}: ${status} breaks the links`);
assert.equal(a.findBuff('bind'), null, `T${tier}: ${status}: unbound`);
done(h);
}
}
});
test('T1 深入骨髓: her damage ignores 60 % of the DEF of an enemy of 重量等级 ≥ 3 (exact numbers), none below; the S3 strikes too', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const t0 = formOf(tier, elite).talents.find((t) => t.index === 0);
assert.deepEqual([t0.bb.value, t0.bb.def_penetrate], [3, 0.6], `T${tier}: data`);
for (const skill of [0, 2]) {
const { h, u } = field({ tier, elite, skill });
const armor = h.spawn('enemy_armor', { pos: [10, 6] }), plate = h.spawn('enemy_plate', { pos: [11, 6] });
if (skill === 2) { u.skill.gainSp(999); assert.ok(h.runUntil(() => u.skill.active, 4)); h.run(2.2); } else h.run(12);
const on = (e) => atkHits(h, u).filter((c) => c.target === e);
assert.ok(on(armor).length, `T${tier} S${skill + 1}: hit the weight-3 one`);
for (const c of on(armor)) approx(c.amount, u.s.atk - 600 * (1 - 0.6), `T${tier} S${skill + 1}: weight 3, DEF 600 × 40 %`);
if (skill === 2) {
assert.ok(on(plate).length, `T${tier}: S3 links the weight-2 one too`);
for (const c of on(plate)) approx(c.amount, u.s.atk - 600, `T${tier} S3: weight 2, full DEF`);
}
done(h);
}
}
});
test('T2 学生楷模: every 【乌萨斯学生自治团】 operator of her team — her and 古米, deployed or not — ATK +8 %, nobody else; SIE-Y stage 3: +12 % and +15 % per student in skill (≤ +45 %), re-read every 0.3 s', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const others = [{ uid: 2, chessId: 'chess_char_1_10_a', row: 12, col: 3 }, { uid: 3, chessId: 'chess_char_1_02_a', row: 12, col: 6 }];
const { h, u } = field({ tier, elite, mod, skill: 0, others });
const gummy = h.unit(2), yak = h.unit(3);
const v = studentAtk(tier, elite, mod);
for (const a of [u, gummy]) assert.deepEqual(a.findBuff('talent:poca:students')?.mods, { atkPct: v }, `${label(f)}: ${a.def.charId}`);
assert.equal(yak.findBuff('talent:poca:students'), null, `${label(f)}: 角峰 is no student`);
h.b.retreat(u);
h.run(0.5);
assert.ok(gummy.findBuff('talent:poca:students'), `${label(f)}: 编入队伍时 — still there with her off the field`);
assert.ok(u.findBuff('talent:poca:students'), `${label(f)}: kept through her retreat`);
assert.equal(!!gummy.findBuff('talent:poca:studentSkills'), false, `${label(f)}: nobody in skill`);
done(h);
}
// SIE-Y stage 3: the extra +15 % per student operator on the field whose skill runs, at most +45 %
const t6 = formOf(6, true).modules.find((m) => m.uniEquipId === SIEY).talentChanges;
assert.deepEqual([t6.find((t) => t.talentIndex === 1).bb.atk, t6.find((t) => t.talentIndex === -1).bb], [0.12, { init_atk: 0.15, max_atk: 0.45 }]);
const others = [
{ uid: 2, chessId: 'chess_char_1_10_a', row: 12, col: 3, skillIndex: 1 }, { uid: 3, chessId: 'chess_char_1_10_b', row: 12, col: 5, skillIndex: 1 },
{ uid: 4, diy: { slot: 'chess_char_6_diy2_a', charId: 'char_1051_headb2', skillIndex: 0 }, row: 11, col: 8 },
{ uid: 5, chessId: 'chess_char_1_02_a', row: 12, col: 7 },
];
const { h, u } = field({ tier: 6, elite: true, mod: SIEY, skill: 0, others });
const team = [u, h.unit(2), h.unit(3), h.unit(4)];
assert.ok(team.every((a) => a && a.deployed), 'four students on the field');
const drive = (a) => a.findBuff('talent:poca:studentSkills')?.mods.atkPct ?? 0;
const cast = (a) => { a.skill.gainSp(999); a.skill.activate('test'); assert.ok(a.skill.active, `${a.def.charId} in skill`); };
cast(u);
h.step(); // at once: 每次开启技能时
for (const a of team) approx(drive(a), 0.15, `one in skill: ${a.def.charId}`);
assert.equal(drive(h.unit(5)), 0, '角峰 gets nothing');
approx(u.s.atk, u.base.atk * (1 + 0.12 + 0.15 + skillOf(6, true, S1).bb.atk), 'her ATK: +12 % +15 % (and her S1)');
cast(h.unit(2));
h.step();
approx(drive(h.unit(4)), 0.3, 'two in skill');
cast(h.unit(3));
cast(h.unit(4));
h.step();
approx(drive(u), 0.45, 'four in skill: capped at +45 %');
u.skill.end('test');
h.run(0.35); // the 0.3 s re-read
approx(drive(h.unit(2)), 0.45, 'three in skill');
for (const a of team.slice(1)) a.skill.end('test');
h.run(0.35);
assert.equal(drive(u), 0, 'nobody in skill');
done(h);
});
test('SIE-X 攻城器械训练装置: ×1.15 on her attacks against 重量等级 ≥ 3 (normal attacks, S3 strikes), nothing below; stage 3 深入骨髓 adds a 60 % ATK physical hit on such enemies (with the DEF ignore)', () => {
for (const [tier, mod] of [[5, null], [5, SIEX], [6, SIEX], [6, SIEY], [6, null]]) {
const xs = mod === SIEX ? 1.15 : 1;
const extra = mod === SIEX && tier === 6 ? 0.6 : 0;
if (mod === SIEX) assert.deepEqual(modOf(tier, SIEX).talentChanges.find((t) => t.hidden).bb, { atk_scale: 1.15, value: 3 }, `T${tier}: data`);
for (const skill of [0, 2]) {
const { h, u } = field({ tier, elite: true, mod, skill });
const heavy = h.spawn('enemy_heavy', { pos: [10, 5] }), mid = h.spawn('enemy_mid', { pos: [10, 6] });
if (skill === 2) { u.skill.gainSp(999); assert.ok(h.runUntil(() => u.skill.active, 4)); h.run(2.2); } else h.run(12);
const main = atkHits(h, u);
const onH = main.filter((c) => c.target === heavy), onM = main.filter((c) => c.target === mid);
// (SIE-Y's own distance factor: 2 / 3 tiles)
const far = (d) => (mod === SIEY ? 1 + 0.12 * (d - 1) / 3.5 : 1);
assert.ok(onH.length, `T${tier} ${mod} S${skill + 1}: hits the heavy one`);
for (const c of onH) approx(c.amount, u.s.atk * xs * far(2), `T${tier} ${mod} S${skill + 1}: ×${xs} on weight 3`);
for (const c of onM) approx(c.amount, u.s.atk * far(3), `T${tier} ${mod} S${skill + 1}: ×1 on weight 2`);
const ex = hitsBy(h, u).filter((c) => c.dmg.tags?.includes('poca:extra'));
if (extra) {
assert.equal(ex.length, onH.length, `T${tier}: one extra hit per attack hit on the heavy one`);
assert.ok(ex.every((c) => c.target === heavy && c.type === 'phys' && !c.dmg.isAttack), `T${tier}: physical, not an attack`);
for (const c of ex) approx(c.amount, u.s.atk * extra, `T${tier}: 60 % ATK`);
} else assert.equal(ex.length, 0, `T${tier} ${mod}: no extra hit`);
done(h);
}
}
// the extra hit gets 深入骨髓's 物理穿透 too: weight 3, DEF 600
const { h, u } = field({ tier: 6, elite: true, mod: SIEX, skill: 0 });
const armor = h.spawn('enemy_armor', { pos: [10, 6] });
h.run(6);
const ex = hitsBy(h, u).filter((c) => c.target === armor && c.dmg.tags?.includes('poca:extra'));
assert.ok(ex.length);
for (const c of ex) approx(c.amount, u.s.atk * 0.6 - 600 * 0.4, 'extra hit: DEF 600 × 40 %');
done(h);
});
test('SIE-Y “心”: her attack damage rises with the distance, linearly from 1 tile to 4.5 (at most +12 %), stages 1 and 3; nothing without it', () => {
for (const [tier, mod] of [[5, SIEY], [6, SIEY], [6, SIEX], [5, null]]) {
const tb = mod === SIEY ? modOf(tier, SIEY).traitOverride.bb : {};
if (mod === SIEY) assert.deepEqual(tb, { max_dist: 4.5, min_dist: 1, damage_scale: 0.12 }, `T${tier}: data`);
for (const [r, c] of [[10, 5], [10, 6], [10, 7], [11, 6], [12, 5]]) {
const { h, u } = field({ tier, elite: true, mod, skill: 0 });
const e = h.spawn('enemy_light', { pos: [r, c] });
h.run(3);
const hit = atkHits(h, u).find((x) => x.target === e);
assert.ok(hit, `T${tier} ${mod}: hit at ${r},${c}`);
const d = Math.hypot(c - 3, r - 10);
const want = mod === SIEY ? 1 + 0.12 * Math.max(0, Math.min(1, (d - 1) / 3.5)) : 1;
approx(hit.amount, u.s.atk * want, `T${tier} ${mod}: ${d.toFixed(2)} tiles ×${want.toFixed(4)}`);
done(h);
}
}
});
+343
View File
@@ -0,0 +1,343 @@
// test/content/op_typhon.test.js — the 自选 operator kit of 提丰 (char_2012_typhon, 6★ 攻城手; kit
// server/sim/content/kits/ops/op-typhon.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, SIE-X
// 自然的包容, SIE-Y 冰原的影子 or ISW-A 提丰特限证章 at stage 1 (tier 5) / 3 (tier 6). Every number is read back from
// data/backups.json (the form of that slot status); the fidelity checklist of kits/README.md item by item.
// Run: node --test test/content/op_typhon.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 TYPHON = 'char_2012_typhon';
const FORMS = BACKUPS.units[TYPHON].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const SIEX = 'uniequip_002_typhon', SIEY = 'uniequip_003_typhon', ISWA = 'uniequip_004_typhon';
const S1 = 'skcom_quickattack[3]', S2 = 'skchr_typhon_2', S3 = 'skchr_typhon_3';
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
const 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_light: dummy('enemy_light', { mass: 1 }), enemy_mid: dummy('enemy_mid', { mass: 2 }), enemy_heavy: dummy('enemy_heavy', { mass: 3 }),
enemy_plate: dummy('enemy_plate', { mass: 1, def: 600 }), enemy_fly: dummy('enemy_fly', { mass: 1, motion: 'FLY' }),
enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.8, mass: 3 }),
};
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, SIEX, SIEY, ISWA].map((m) => [t, true, m]))];
/** A battle with 提丰 as uid 1 at (row, col) facing RIGHT, plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 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', 'statusApplied'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: TYPHON, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const hitsBy = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u);
const atkHits = (h, u) => hitsBy(h, u).filter((c) => c.dmg?.isAttack);
const rainHits = (h, u) => hitsBy(h, u).filter((c) => c.dmg?.tags?.includes('typhon:rain'));
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
test('提丰 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes (SIE-Y ASPD), 4-3 range, ranged physical arrows that hit air, heaviest first, no 特质', () => {
assert.equal(OPERATOR_KITS[TYPHON], KITS[TYPHON]);
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], [TYPHON, 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), 100 + (m?.attr.aspd ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.profile.priority, u.base.bat], [1, 'ranged', 'phys', true, 'heaviest', 2.4], `${label(f)}: 攻城手`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 4-3`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [BACKUPS.diy.operators[TYPHON].bonds, []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
done(h);
}
}
// E2 Lv1 1310 / 867 / 92, E2 Lv60 1570 / 985 / 106; SIE-X +120 HP / +60 ATK → +210 / +90, SIE-Y +70 ATK / +5 ASPD → +94 / +7,
// ISW-A +100 / +70 → +150 / +90
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [1310, 867, 1570, 985]);
assert.deepEqual([modOf(5, SIEX).attr, modOf(6, SIEX).attr, modOf(5, SIEY).attr, modOf(6, SIEY).attr, modOf(5, ISWA).attr, modOf(6, ISWA).attr],
[{ maxHp: 120, atk: 60 }, { maxHp: 210, atk: 90 }, { atk: 70, aspd: 5 }, { atk: 94, aspd: 7 }, { maxHp: 100, atk: 70 }, { maxHp: 150, atk: 90 }]);
});
test('a 自选 pick: 提丰 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(TYPHON));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(TYPHON), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: TYPHON, skillIndex: 1, uniEquipId: ISWA } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: TYPHON, skillIndex: 1, uniEquipId: ISWA } } });
});
test('trait 攻城手: the heaviest enemy of her range first; a flyer is a target', () => {
const { h, u } = field({ tier: 6, elite: true });
const light = h.spawn('enemy_light', { pos: [10, 5] }), heavy = h.spawn('enemy_heavy', { pos: [9, 6] });
h.run(3);
assert.equal(atkHits(h, u)[0].target, heavy);
h.b.kill(heavy, null);
h.b.kill(light, null);
const fly = h.spawn('enemy_fly', { pos: [11, 5] });
h.run(3);
assert.ok(atkHits(h, u).some((c) => c.target === fly), '可对空');
done(h);
});
test('S1 迅捷打击·γ型 (MANUAL, data DEFAULT): ATK +27 % / +34 %, ASPD +25 / +35 for 35 s; SP 42 / 39 from 5 / 10', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
const { h, u } = field({ tier, elite, skill: 0 });
assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.spCost, u.skill.initSp, sk.duration, sk.bb.atk, sk.bb.attack_speed],
['DEFAULT', 'duration', elite ? 39 : 42, elite ? 10 : 5, 35, elite ? 0.34 : 0.27, elite ? 35 : 25], `T${tier}`);
u.skill.gainSp(999);
h.run(2);
assert.equal(u.skill.activations, 0, `T${tier}: no enemy, no cast`);
h.spawn('enemy_light', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.active, 4));
approx(u.skill.timeLeft, 35, `T${tier}: 35 s`, 0.01);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
assert.equal(u.s.aspd, 100 + sk.bb.attack_speed, `T${tier}: ASPD`);
h.runUntil(() => !u.skill.active, 36);
assert.deepEqual([u.s.atk, u.s.aspd], [u.base.atk, 100], `T${tier}: back`);
done(h);
}
});
test('S2 冰原秩序: ATK +26 % / +35 %, two arrows per attack — two enemies when there are two, both at a lone one — each with 40 % to stun 1 s; 20 s on the first cast of a deployment, then 持续时间无限 until she leaves (a redeploy counts again)', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
const { h, u } = field({ tier, elite, skill: 1, seed: 9 });
assert.deepEqual([u.skill.rule, u.skill.kind, sk.duration, sk.bb.first_duration, sk.bb.atk, sk.bb['attack@prob'], sk.bb['attack@stun'], u.skill.spCost, u.skill.initSp],
['DEFAULT', 'toggle', 20, 20, elite ? 0.35 : 0.26, 0.4, 1, elite ? 51 : 53, elite ? 40 : 37], `T${tier}`);
const lone = h.spawn('enemy_light', { pos: [10, 6] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`);
const t0 = h.b.time;
approx(u.skill.timeLeft, 20, `T${tier}: first cast 20 s`, 0.05);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
h.run(5);
const s2 = atkHits(h, u).filter((c) => c.dmg.isSkill);
const byAttack = Map.groupBy(s2, (c) => c.dmg.attackId);
assert.ok(byAttack.size >= 2, `T${tier}: attacks`);
for (const list of byAttack.values()) assert.deepEqual(list.map((c) => c.target), [lone, lone], `T${tier}: both arrows at the lone enemy`);
const other = h.spawn('enemy_mid', { pos: [11, 6] });
h.run(5);
const later = atkHits(h, u).filter((c) => c.dmg.isSkill && c.t > h.b.time - 4.5);
for (const list of Map.groupBy(later, (c) => c.dmg.attackId).values()) {
assert.deepEqual(list.map((c) => c.target).sort((a, b) => a.id - b.id), [lone, other].sort((a, b) => a.id - b.id), `T${tier}: two different enemies`);
}
assert.ok(h.runUntil(() => !u.skill.active, 15), `T${tier}: the first cast ends`);
approx(h.b.time - t0, 20, `T${tier}: after 20 s`, 0.05);
// the second cast never ends
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: second cast`);
assert.equal(u.skill.timeLeft, Infinity, `T${tier}: 持续时间无限`);
h.run(120);
assert.ok(u.skill.active, `T${tier}: still on after 120 s`);
// the stun: 40 % of the arrows, 1 s
const all = atkHits(h, u).filter((c) => c.dmg.isSkill);
const stuns = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'stun');
assert.ok(all.length > 100, `T${tier}: ${all.length} arrows`);
assert.ok(stuns.length / all.length > 0.32 && stuns.length / all.length < 0.48, `T${tier}: ${stuns.length} / ${all.length} stuns ≈ 40 %`);
for (const c of stuns) approx(c.duration, 1, `T${tier}: 1 s`);
// a redeploy counts its casts again
h.b.retreat(u);
h.b.redeploy(u);
assert.ok(!u.skill.active && u.deployed, `T${tier}: back on the field, skill off`);
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast after the redeploy`);
approx(u.skill.timeLeft, 20, `T${tier}: the first cast of this deployment — 20 s again`, 0.05);
done(h);
}
});
test('S3 “永恒狩猎”: 7 / 8 rounds, attack interval 2.4 + 3.1 s; the mark on the heaviest enemy of her own range; range 全场 but attacks only with an enemy in the mark area (radius 1.3); each round 4 arrows 0.1 s apart on random enemies of the area, 145 % / 160 % ATK physical skill hits (no attack), 0.4 s stun once they land', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const { h, u } = field({ tier, elite, skill: 2, seed: 7 });
assert.deepEqual([u.skill.rule, u.skill.kind, sk.durationType, sk.bb['attack@s3_trigger_time'], sk.bb.base_attack_time, sk.bb['attack@s3_atk_scale'], sk.bb['attack@s3_max_hit_num'], sk.bb['attack@s3_stun'], u.skill.spCost, u.skill.initSp],
['DEFAULT', 'ammo', 'AMMO', elite ? 8 : 7, 3.1, elite ? 1.6 : 1.45, 4, 0.4, elite ? 43 : 47, 25], `T${tier}`);
const marked = h.spawn('enemy_heavy', { pos: [10, 6] }), near = h.spawn('enemy_light', { pos: [11, 6] });
const away = h.spawn('enemy_mid', { pos: [9, 5] }); // in her range, 1.4+ tiles from the mark: never hit
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`);
const t0 = h.b.time;
assert.equal(u.mem.typhonMark.target, marked, `T${tier}: the heaviest of her range is marked`);
assert.ok(u.liveRangeGrid.length >= 400, `T${tier}: the card shows 全场`);
approx(u.s.interval, 2.4 + 3.1, `T${tier}: interval 5.5 s`);
assert.ok(h.runUntil(() => !u.skill.active, 60), `T${tier}: ammo spent`);
h.run(0.5);
const rain = rainHits(h, u);
assert.equal(atkHits(h, u).filter((c) => c.t >= t0 - 0.05).length, 0, `T${tier}: the round itself deals nothing`);
assert.equal(rain.length, sk.bb['attack@s3_trigger_time'] * 4, `T${tier}: ${rain.length} arrows (rounds × 4)`);
assert.ok(rain.every((c) => c.target === marked || c.target === near), `T${tier}: the area only`);
assert.ok(rain.some((c) => c.target === near) && rain.some((c) => c.target === marked), `T${tier}: random among the area`);
assert.ok(rain.every((c) => c.type === 'phys' && c.dmg.isSkill && !c.dmg.isAttack), `T${tier}: physical skill hits, no attack`);
const rounds = Map.groupBy(rain, (c) => Math.round((c.t - t0) / 5.5));
assert.equal(rounds.size, sk.bb['attack@s3_trigger_time'], `T${tier}: one round per bullet`);
for (const list of rounds.values()) list.forEach((c, i) => i && approx(c.t - list[i - 1].t, 0.1, `T${tier}: 0.1 s apart`, 0.05));
// amounts: ATK × s3_atk_scale (DEF 0), the first hit on each enemy ×1.6 (重如沼泥)
const firsts = new Set([marked, near].map((e) => rain.find((c) => c.target === e)));
for (const c of rain) approx(c.amount, u.s.atk * sk.bb['attack@s3_atk_scale'] * (firsts.has(c) ? 1.6 : 1), `T${tier}: s3_atk_scale`);
const stuns = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'stun');
assert.equal(stuns.length, rain.length, `T${tier}: a stun per landed arrow`);
for (const c of stuns) approx(c.duration, 0.4, `T${tier}: 0.4 s`);
assert.ok(!hitsBy(h, u).some((c) => c.target === away), `T${tier}: outside the mark area`);
assert.ok(u.liveRangeGrid.length < 20, `T${tier}: own range back`);
done(h);
}
});
test('S3 “永恒狩猎”: no enemy in the mark area ⇒ no attack and no bullet spent; the mark follows its enemy and stays where it left the field', () => {
const { h, u } = field({ tier: 6, elite: true, skill: 2, seed: 3 });
const walker = h.spawn('enemy_walk', { routeIndex: 0 });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 30), 'cast once the walker is in her range');
assert.equal(u.mem.typhonMark.target, walker);
const x0 = u.mem.typhonMark.x;
h.run(1);
assert.notEqual(u.mem.typhonMark.x, x0, 'the mark moves with its enemy');
const ammo = u.skill.ammoLeft;
h.b.kill(walker, null);
const spot = [u.mem.typhonMark.x, u.mem.typhonMark.y];
h.run(12);
assert.equal(u.skill.ammoLeft, ammo, 'nothing in the area: no round');
assert.deepEqual([u.mem.typhonMark.x, u.mem.typhonMark.y], spot, 'the mark stays');
const later = h.spawn('enemy_light', { pos: [Math.round(spot[1]), Math.round(spot[0])] });
assert.ok(h.runUntil(() => rainHits(h, u).some((c) => c.target === later), 12), 'an enemy at the old spot is hit');
assert.equal(u.skill.ammoLeft, ammo - 1);
done(h);
});
test('T1 锐如兽牙: each attack adds 10 % (SIE-X stage 3: 12 %) DEF ignore up to 5 stacks, applied to that attack already; all gone 8 s (10 s) after her last attack', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [5, true, SIEX], [6, true, SIEX]]) {
const t0 = formOf(tier, elite).talents.find((t) => t.index === 0).bb;
const tc = mod ? modOf(tier, mod).talentChanges.find((t) => t.talentIndex === 0 && !t.hidden)?.bb : null;
const per = tc?.def_penetrate ?? t0.def_penetrate, dur = tc?.duration ?? t0.duration;
assert.deepEqual([per, dur, tc?.max_stack_cnt ?? t0.max_stack_cnt], tier === 6 && mod === SIEX ? [0.12, 10, 5] : [0.1, 8, 5], label([tier, elite, mod]));
const { h, u } = field({ tier, elite, mod, skill: 0 });
const e = h.spawn('enemy_plate', { pos: [10, 6] });
h.run(16);
const list = atkHits(h, u).filter((c) => c.target === e);
assert.ok(list.length >= 6, `${label([tier, elite, mod])}: ${list.length} attacks`);
list.forEach((c, i) => approx(c.amount, u.s.atk - 600 * (1 - per * Math.min(5, i + 1)), `${label([tier, elite, mod])}: attack ${i + 1}`));
h.b.kill(e, null);
const last = list.at(-1).t;
h.run(last + dur - 1 - h.b.time);
assert.equal(u.findBuff('talent:typhon:fang')?.stacks, 5, `${label([tier, elite, mod])}: still there ${dur - 1} s after the last attack`);
h.run(1.5);
assert.equal(u.findBuff('talent:typhon:fang'), null, `${label([tier, elite, mod])}: lost after ${dur} s without an attack`);
done(h);
}
});
test('T2 重如沼泥: while her skill runs, the first damage on each enemy deals ×1.6 and 停顿 3 s (SIE-Y stage 3: the first two, ×2.2); not outside a skill; a new cast clears the marks', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [5, true, SIEY], [6, true, SIEY], [6, true, ISWA]]) {
const t1 = formOf(tier, elite).talents.find((t) => t.index === 1).bb;
const tc = mod === SIEY && tier === 6 ? modOf(tier, SIEY).talentChanges.find((t) => t.talentIndex === 1).bb : null;
const n = tc?.max_stack_cnt ?? 1, scale = tc?.atk_scale ?? t1.atk_scale;
assert.deepEqual([scale, tc?.sluggish ?? t1.sluggish], [tier === 6 && mod === SIEY ? 2.2 : 1.6, 3], label([tier, elite, mod]));
const { h, u } = field({ tier, elite, mod, skill: 0 });
const a = h.spawn('enemy_light', { pos: [10, 5] });
const far = (d) => (mod === SIEY ? 1 + 0.12 * (d - 1) / 3.5 : 1);
h.run(5);
assert.ok(atkHits(h, u).length && atkHits(h, u).every((c) => c.target === a), `${label([tier, elite, mod])}: attacks`);
for (const c of atkHits(h, u)) approx(c.amount, u.s.atk * far(2), `${label([tier, elite, mod])}: no skill, no bonus`);
assert.equal(h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish').length, 0);
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4));
const t0 = h.b.time;
h.run(8);
const during = atkHits(h, u).filter((c) => c.t >= t0 - 0.05);
assert.ok(during.length > n + 1, `${label([tier, elite, mod])}: attacks during S1`);
during.forEach((c, i) => approx(c.amount, u.s.atk * far(2) * (i < n ? scale : 1), `${label([tier, elite, mod])}: hit ${i + 1}`));
const slows = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish');
assert.equal(slows.length, n, `${label([tier, elite, mod])}: 停顿 with the marked hits`);
for (const c of slows) approx(c.duration, 3, '3 s');
// a second enemy gets its own first hit; a new cast clears the marks
u.skill.end('test');
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 5));
const t1s = h.b.time;
h.run(3);
const again = atkHits(h, u).filter((c) => c.t >= t1s - 0.05);
approx(again[0].amount, u.s.atk * far(2) * scale, `${label([tier, elite, mod])}: marks cleared by the new cast`);
done(h);
}
});
test('SIE-X 自然的包容: ×1.15 on her attacks against 重量等级 ≥ 3, nothing below; the S3 arrow rain (no attack) is not raised', () => {
for (const [tier, mod] of [[5, SIEX], [6, SIEX], [6, null], [6, ISWA]]) {
const xs = mod === SIEX ? 1.15 : 1;
if (mod === SIEX) assert.deepEqual(modOf(tier, SIEX).talentChanges.find((t) => t.hidden).bb, { atk_scale: 1.15, value: 3 });
const { h, u } = field({ tier, elite: true, mod, skill: 0 });
const heavy = h.spawn('enemy_heavy', { pos: [10, 5] });
h.run(4);
const mid = h.spawn('enemy_mid', { pos: [10, 6] });
h.b.kill(heavy, null);
h.run(4);
for (const c of atkHits(h, u)) approx(c.amount, u.s.atk * (c.target.weight >= 3 ? xs : 1), `T${tier} ${mod}: ${c.target.defId}`);
assert.ok(atkHits(h, u).some((c) => c.target === mid) && atkHits(h, u).some((c) => c.target.weight >= 3));
done(h);
}
// S3: rain hits on a weight-3 enemy are s3_atk_scale × ATK (× 1.6 the first)
const { h, u } = field({ tier: 6, elite: true, mod: SIEX, skill: 2 });
h.spawn('enemy_heavy', { pos: [10, 6] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4));
h.run(7);
const rain = rainHits(h, u);
rain.forEach((c, i) => approx(c.amount, u.s.atk * 1.6 * (i === 0 ? 1.6 : 1), `rain ${i + 1}: no ×1.15`));
done(h);
});
test('SIE-Y 冰原的影子: her attack damage rises with the distance, linearly 1 → 4.5 tiles, at most +12 % (stages 1 and 3); the S3 rain is not raised', () => {
for (const tier of [5, 6]) {
assert.deepEqual(modOf(tier, SIEY).traitOverride.bb, { max_dist: 4.5, min_dist: 1, damage_scale: 0.12 });
for (const [r, c] of [[10, 5], [10, 7], [11, 6]]) {
const { h, u } = field({ tier, elite: true, mod: SIEY, skill: 0 });
const e = h.spawn('enemy_light', { pos: [r, c] });
h.run(3);
const d = Math.hypot(c - 3, r - 10);
approx(atkHits(h, u).find((x) => x.target === e).amount, u.s.atk * (1 + 0.12 * (d - 1) / 3.5), `T${tier}: ${d.toFixed(2)} tiles`);
done(h);
}
}
const { h, u } = field({ tier: 6, elite: true, mod: SIEY, skill: 2 });
h.spawn('enemy_light', { pos: [10, 7] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4));
h.run(7);
const n = modOf(6, SIEY).talentChanges.find((t) => t.talentIndex === 1).bb.max_stack_cnt;
rainHits(h, u).forEach((c, i) => approx(c.amount, u.s.atk * 1.6 * (i < n ? 2.2 : 1), `rain ${i + 1}: no distance bonus`));
done(h);
});
test('ISW-A 提丰特限证章: only its HP / ATK — the 集成战略 trait (damage_scale 1.4 on heavy enemies) and arrow-rain mark do nothing here', () => {
for (const tier of [5, 6]) {
const m = modOf(tier, ISWA);
assert.match(m.traitOverride.moduleDesc, /^在集成战略中/);
assert.ok(m.talentChanges.some((t) => t.bb.damage_scale === 1.4), 'the data carries the IS part');
const { h, u } = field({ tier, elite: true, mod: ISWA, skill: 0 });
const heavy = h.spawn('enemy_heavy', { pos: [10, 5] });
h.run(8);
for (const c of atkHits(h, u).filter((x) => x.target === heavy)) approx(c.amount, u.s.atk, `T${tier}: ×1 on a weight-3 enemy`);
h.run(30);
assert.equal(h.hooksOf('damaged').filter((c) => c.source === u && !c.dmg.isAttack).length, 0, `T${tier}: no arrow rain of its own`);
done(h);
}
});
+372 -95
View File
@@ -862,16 +862,16 @@
"snaps": "154c679b 3d6402ec 6e769538 6ab466ef 62867862 da8a1d4c b45529ad 173fec15 5cf39f28 50909bd4 9b896c35 f3a66c2e e37801fa 65130b32 aa798f03 165a46dc 8a61b747 404ad7d9 7f4ac4d0 e744d033 b0313af3"
},
"diy-022": {
"about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 5_diy1_a=csnipe 5_diy2_b=csnipe 5_diy1_a=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo",
"fast": true,
"about": "5_diy1_a=poca/S1 5_diy2_a=poca/S2 5_diy1_a=poca/S3 5_diy2_b=poca/S1 5_diy1_b=poca/S2 5_diy2_b=poca/S3 5_diy1_b=poca/S1/002_poca 5_diy2_b=poca/S2/002_poca 5_diy1_b=poca/S3/002_poca 5_diy2_b=poca/S1/003_poca 5_diy1_b=poca/S2/003_poca 5_diy2_b=poca/S3/003_poca",
"fast": false,
"kind": "normal",
"stageId": "act2autochess_m04",
"modeId": "mode_multi_abyss",
"round": 12,
"seed": 4043654158,
"waveId": "act1autochess_h05",
"time": 217.17,
"ticks": 6515,
"time": 206.93,
"ticks": 6208,
"reason": "cleared",
"killed": 0,
"total": 60,
@@ -879,112 +879,112 @@
"bossHpLeft": null,
"rngDraws": 0,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":0,"total":60,"perfect":false,"leaks":60,"leaksUncounted":0,"coins":0,"dmg":238265,"bossDmg":0,"heal":15821,"deaths":11,"layerGains":{}}},
"players": {"p1":{"killed":0,"total":60,"perfect":false,"leaks":60,"leaksUncounted":0,"coins":0,"dmg":535895,"bossDmg":0,"heal":0,"deaths":7,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,78,0,193,9709,0,14,0],
["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,69,0,636,10746,0,40,1],
["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,80,0,2406,10463,0,14,1],
["p1",4,"op","chess_char_5_diy2_b",2,"none",0,0,81,0,6489,14796,0,25,1],
["p1",5,"op","chess_char_5_diy1_a",2,null,1271,1,3,0,4404,0,0,186,3],
["p1",6,"op","chess_char_5_diy2_b",2,"none",1510,1,4,0,5674,0,0,191,3],
["p1",7,"op","chess_char_5_diy1_a",2,null,1133,1,11,0,62578,0,0,134,3],
["p1",8,"op","chess_char_5_diy2_b",2,"none",1360,1,13,0,41175,0,0,75,3],
["p1",9,"op","chess_char_5_diy1_a",2,null,1218,1,5,0,0,0,15821,34,3],
["p1",10,"op","chess_char_5_diy2_b",2,"none",1374,1,10,0,0,0,0,0,0],
["p1",11,"op","chess_char_5_diy1_a",2,null,932,1,12,0,52535,0,0,133,3],
["p1",12,"op","chess_char_5_diy2_b",2,"none",1131,1,14,0,62176,0,0,134,3],
["p1",1,"op","chess_char_5_diy1_a",0,null,1351,1,3,0,38048,0,0,77,3],
["p1",2,"op","chess_char_5_diy2_a",1,null,1351,1,4,0,58772,0,0,77,2],
["p1",3,"op","chess_char_5_diy1_a",2,null,1351,1,11,0,44539,0,0,81,4],
["p1",4,"op","chess_char_5_diy2_b",0,"none",1619,1,13,0,37364,0,0,45,3],
["p1",5,"op","chess_char_5_diy1_b",1,"none",1619,1,5,0,82590,0,0,78,2],
["p1",6,"op","chess_char_5_diy2_b",2,"none",1619,1,8,0,54654,0,0,90,4],
["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_poca",1619,1,12,0,50894,0,0,53,3],
["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_poca",1619,1,14,0,52566,0,0,45,2],
["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_poca",1619,1,9,0,89669,0,0,89,4],
["p1",10,"op","chess_char_5_diy2_b",0,"uniequip_003_poca",1619,1,79,0,13062,3238,0,20,1],
["p1",11,"op","chess_char_5_diy1_b",1,"uniequip_003_poca",1619,1,77,0,8443,3238,0,10,0],
["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_003_poca",0,0,74,0,5295,4857,0,7,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_1045_hammer_2":[12,0,12,9413,62906],"enemy_1061_zomshd":[48,0,48,36302,193719]},
"hooks": [1,81,6515,1074,1074,1499,1499,28159,11,71,24,24,0,30191,267,19,60,60,1,11,0,0,0],
"events": {"atk":1074,"deploy":19,"die":11,"dmg":1499,"heal":34,"leak":60,"skill":48,"spawn":74,"status":526},
"fx": {},
"snaps": "ed45c79d 41c1e676 2e14b30d b48b95c8 4b801cd2 41ab4233 9026deca a3296660 429cf1b6 89decde1 7777667a d5618a1c 8ca97be1 052d4cef 6c2cd06c d430d438 4c299321 c4a11a5a b70b2578 d2452c1b 77f7fcda a3cbdc7d"
"enemies": {"enemy_1045_hammer_2":[12,0,12,1619,164019],"enemy_1061_zomshd":[48,0,48,9714,387756]},
"hooks": [1,80,6208,685,685,1203,1203,31970,7,67,28,25,0,39469,21,7,60,60,1,7,0,0,0],
"events": {"atk":806,"deploy":18,"die":7,"dmg":1203,"fx":21,"leak":60,"skill":53,"spawn":74,"status":32},
"fx": {"harpoon":21},
"snaps": "6a0025a2 426819db 775d6cb5 dfe3f669 b26f6595 8b0e5a0e 72f7240c cdf7a042 d9a495e9 0c7b39f9 ff99b470 bf54eb6c 71799613 59d2013e 4183b241 ede97f16 814a1499 aa6ca3de e3f9536b 5e7fb6fc 512f6b19"
},
"diy-023": {
"about": "5_diy1_a=acfend 5_diy2_b=acfend 5_diy1_a=acpion 5_diy2_b=acpion 5_diy1_a=acguad 5_diy2_b=acguad 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc",
"about": "6_diy1_a=poca/S1 6_diy2_a=poca/S2 6_diy1_a=poca/S3 6_diy2_b=poca/S1 6_diy1_b=poca/S2 6_diy2_b=poca/S3 6_diy1_b=poca/S1/002_poca 6_diy2_b=poca/S2/002_poca 6_diy1_b=poca/S3/002_poca 6_diy2_b=poca/S1/003_poca 6_diy1_b=poca/S2/003_poca 6_diy2_b=poca/S3/003_poca",
"fast": false,
"kind": "normal",
"stageId": "act1autochess_m01",
"modeId": "mode_multi_funny",
"round": 12,
"round": 13,
"seed": 3417269346,
"waveId": "act1autochess_h05",
"time": 227.1,
"ticks": 6813,
"waveId": "act1autochess_h06",
"time": 221.1,
"ticks": 6633,
"reason": "cleared",
"killed": 69,
"total": 90,
"killed": 47,
"total": 117,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 590,
"rngDraws": 0,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":69,"total":90,"perfect":false,"leaks":21,"leaksUncounted":0,"coins":0,"dmg":1023477,"bossDmg":0,"heal":14586,"deaths":8,"layerGains":{}}},
"players": {"p1":{"killed":47,"total":117,"perfect":false,"leaks":70,"leaksUncounted":0,"coins":0,"dmg":1042562,"bossDmg":0,"heal":0,"deaths":14,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",2,null,1521.6,1,109,2,15495,5294,0,65,1],
["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",3241.64,1,110,1,12911,3500,0,72,1],
["p1",3,"op","chess_char_5_diy1_a",2,null,833,1,111,3,17512,3754,0,35,4],
["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",1904,1,112,11,98576,3808,0,46,4],
["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,99,3,32149,5276,0,76,2],
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",3411,1,8,8,77972,12979,0,80,4],
["p1",7,"op","chess_char_5_diy1_a",2,null,1272,1,4,1,12574,0,0,99,2],
["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",1587,1,5,7,43402,0,0,167,3],
["p1",9,"op","chess_char_5_diy1_a",2,null,1372,1,11,14,325543,0,0,77,2],
["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",1749,1,13,19,387342,0,0,82,2],
["p1",11,"op","chess_char_5_diy1_a",2,null,1224,1,6,0,0,0,14586,36,2],
["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",1414,1,14,0,0,0,0,0,0],
["p1",1,"op","chess_char_6_diy1_a",0,null,1351,1,4,7,85736,0,0,80,3],
["p1",2,"op","chess_char_6_diy2_a",1,null,1351,1,5,7,125362,0,0,80,2],
["p1",3,"op","chess_char_6_diy1_a",2,null,1351,1,11,1,139446,0,0,101,5],
["p1",4,"op","chess_char_6_diy2_b",0,"none",1619,1,13,1,105060,0,0,80,3],
["p1",5,"op","chess_char_6_diy1_b",1,"none",1619,1,6,5,137216,0,0,69,2],
["p1",6,"op","chess_char_6_diy2_b",2,"none",1619,1,14,7,145348,0,0,96,4],
["p1",7,"op","chess_char_6_diy1_b",0,"uniequip_002_poca",0,0,114,3,22602,3238,0,11,1],
["p1",8,"op","chess_char_6_diy2_b",1,"uniequip_002_poca",899,1,141,3,104270,3958,0,30,1],
["p1",9,"op","chess_char_6_diy1_b",2,"uniequip_002_poca",0,0,89,3,60020,3238,0,26,1],
["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_003_poca",0,0,140,2,28158,4857,0,23,1],
["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_003_poca",0,0,133,2,18630,4857,0,17,0],
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_003_poca",0,0,121,6,70714,3238,0,42,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_1116_liprr_2":[27,23,4,530,184905],"enemy_1119_vofsd":[39,31,8,7229,394032],"enemy_1121_lifbos_2":[24,15,9,26852,444539]},
"hooks": [1,112,6813,1008,1008,2264,2242,4720,77,98,27,25,0,36404,0,68,90,21,1,78,0,0,22],
"events": {"atk":1164,"deploy":19,"die":77,"dmg":2242,"fx":208,"heal":53,"leak":21,"skill":52,"spawn":105,"status":140},
"fx": {"aoe":83,"dodge":22,"dp":22,"liberate":17,"slash":64},
"snaps": "41a18c9d bd868b70 27d2436e b7492865 3ab2f457 71e187b0 a5cd2c30 fe0d034b bbeb3ead 65ae5d1e bc1f9489 f11d020f 48862f4d a3dbe46b 02028874 38b6aabd d482bde2 5e407c1e 0271a614 23fb46c3 df7cee7b 1a073763 e8b77221"
"enemies": {"enemy_1166_dusbr_2":[39,14,25,4625,75458],"enemy_1170_dushld_2":[39,22,17,647,258699],"enemy_9011_acrefr":[39,11,28,18114,708404]},
"hooks": [1,141,6633,717,717,937,937,0,61,131,25,23,0,34331,33,30,117,70,1,61,0,0,0],
"events": {"atk":878,"deploy":21,"die":61,"dmg":937,"fx":33,"leak":70,"skill":48,"spawn":132,"status":173},
"fx": {"harpoon":33},
"snaps": "2396b314 63475841 b7f68828 583dcd01 baa6c527 135c1f6a b2eb28ec ca0bbd88 28626132 14902c56 709ad698 2b948334 d225c05c 2937d4aa 74dc85c0 38aa9732 615938fb 65e596ac b7c9619d 84a20769 9badaec0 6314a7b9 88891e77"
},
"diy-024": {
"about": "6_diy1_a=acfend 6_diy2_b=acfend 5_diy1_a=sharp2 5_diy2_b=sharp2 6_diy1_a=acpion 6_diy2_b=acpion 5_diy1_a=acspec 5_diy2_b=acspec 6_diy1_a=acguad 6_diy2_b=acguad 5_diy1_a=acsupo 5_diy2_b=acsupo",
"about": "5_diy1_a=typhon/S1 5_diy2_a=typhon/S2 5_diy1_a=typhon/S3 5_diy2_b=typhon/S1 5_diy1_b=typhon/S2 5_diy2_b=typhon/S3 5_diy1_b=typhon/S1/002_typhon 5_diy2_b=typhon/S2/002_typhon 5_diy1_b=typhon/S3/002_typhon 5_diy2_b=typhon/S1/003_typhon 5_diy1_b=typhon/S2/003_typhon 5_diy2_b=typhon/S3/003_typhon",
"fast": false,
"kind": "normal",
"stageId": "act1autochess_m02",
"modeId": "mode_single_hard",
"round": 13,
"round": 12,
"seed": 762369271,
"waveId": "act1autochess_h06",
"time": 228.6,
"ticks": 6858,
"waveId": "act1autochess_h05",
"time": 211.93,
"ticks": 6358,
"reason": "cleared",
"killed": 50,
"total": 150,
"killed": 14,
"total": 90,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 361,
"rngDraws": 296,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":50,"total":150,"perfect":false,"leaks":100,"leaksUncounted":0,"coins":0,"dmg":577408,"bossDmg":0,"heal":0,"deaths":20,"layerGains":{}}},
"players": {"p1":{"killed":14,"total":90,"perfect":false,"leaks":76,"leaksUncounted":0,"coins":0,"dmg":631476,"bossDmg":0,"heal":0,"deaths":15,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_6_diy1_a",2,null,0,0,149,3,23880,5208,0,93,2],
["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",1158.22,1,1,1,20866,2273,0,87,2],
["p1",3,"op","chess_char_5_diy1_a",0,null,554.31,1,166,4,38417,3176,0,82,2],
["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",0,0,158,11,122141,4748,0,114,2],
["p1",5,"op","chess_char_6_diy1_a",2,null,1529,1,178,10,73708,3058,0,27,3],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",1904,1,176,5,112191,3808,0,44,4],
["p1",7,"op","chess_char_5_diy1_a",2,null,1154,1,168,0,4091,6924,0,55,0],
["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",1448,1,177,0,7334,2896,0,39,0],
["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,3,1,5989,2638,0,17,1],
["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",0,0,153,2,36613,7062,0,82,2],
["p1",11,"op","chess_char_5_diy1_a",2,null,1160,1,6,4,54429,0,0,139,3],
["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",1430,1,8,9,77750,0,0,152,3]
["p1",1,"op","chess_char_5_diy1_a",0,null,1310,1,4,2,62623,0,0,92,3],
["p1",2,"op","chess_char_5_diy2_a",1,null,1310,1,7,1,104722,0,0,80,2],
["p1",3,"op","chess_char_5_diy1_a",2,null,1310,1,5,5,108989,0,0,62,3],
["p1",4,"op","chess_char_5_diy2_b",0,"none",1570,1,8,0,82344,0,0,94,3],
["p1",5,"op","chess_char_5_diy1_b",1,"none",0,0,89,1,25333,3140,0,21,1],
["p1",6,"op","chess_char_5_diy2_b",2,"none",0,0,103,1,62971,3140,0,21,1],
["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_typhon",0,0,80,0,11038,3380,0,10,1],
["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_typhon",1690,1,111,0,37583,3380,0,23,1],
["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_typhon",0,0,70,0,36233,3380,0,15,1],
["p1",10,"op","chess_char_5_diy2_b",0,"uniequip_003_typhon",1570,1,110,0,12325,3140,0,15,0],
["p1",11,"op","chess_char_5_diy1_b",1,"uniequip_003_typhon",0,0,2,3,36265,1570,0,23,1],
["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_003_typhon",0,0,76,1,51052,3140,0,19,1]
],
"enemies": {"enemy_1249_lysdb_2":[60,23,37,12691,277690],"enemy_1251_lysyta":[51,19,32,951,106585],"enemy_1329_cbshld":[39,8,31,28149,193132]},
"hooks": [1,178,6858,1304,1304,2040,2118,0,70,170,24,29,0,27635,12377,61,150,100,1,70,0,0,18],
"events": {"atk":1366,"deploy":28,"die":70,"dmg":2101,"fx":273,"leak":100,"skill":45,"spawn":162,"status":955},
"fx": {"aoe":91,"displace":8,"dodge":46,"dp":22,"shieldBreak":50,"slash":56},
"snaps": "bf720db8 3551effc aec52c42 fddc5dfc 5a03ba5d 8759b1f3 c422cfd3 46c45547 1e6a1048 114adde0 5a35acfe 65b1e213 17b8e6ba 80238f40 a9480213 e5bf3780 4a4c8f92 d2daf783 37a3cba2 2207b2e3 c43ba464 b96a5ea8 e898dc69"
"enemies": {"enemy_1116_liprr_2":[27,4,23,3761,85903],"enemy_1119_vofsd":[39,7,32,17860,206292],"enemy_1121_lifbos_2":[24,3,21,2649,339281]},
"hooks": [1,111,6358,521,521,703,703,2097,29,105,18,14,37,22312,342,18,90,76,1,29,0,0,0],
"events": {"atk":601,"deploy":21,"die":29,"dmg":703,"fx":47,"leak":76,"skill":32,"spawn":102,"status":561},
"fx": {"arrowRain":37,"liberate":4,"mark":6},
"snaps": "fcc892a1 24babc47 f1bd2169 a922fe90 767ad48b 14dac19c 92ed3790 ea74ae16 5b021cd8 757d6cd8 694a1cb7 a9ff8f82 286d5dd9 edb66a8e 7f91bfd5 48adec92 55c6cafc 8d206ee2 f1afebad 13c83e66 06ea2918 4efe0d3e"
},
"diy-025": {
"about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc 6_diy1_a=acsupo 6_diy2_b=acsupo",
"about": "5_diy1_b=typhon/S1/004_typhon 5_diy2_b=typhon/S2/004_typhon 5_diy1_b=typhon/S3/004_typhon 6_diy1_a=typhon/S1 6_diy2_a=typhon/S2 6_diy1_a=typhon/S3 6_diy2_b=typhon/S1 6_diy1_b=typhon/S2 6_diy2_b=typhon/S3 6_diy1_b=typhon/S1/002_typhon 6_diy2_b=typhon/S2/002_typhon 6_diy1_b=typhon/S3/002_typhon",
"fast": false,
"kind": "normal",
"stageId": "act1autochess_m03",
@@ -995,35 +995,312 @@
"time": 230,
"ticks": 6900,
"reason": "timeout",
"killed": 19,
"killed": 17,
"total": 93,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 161,
"rngDraws": 107,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":19,"total":93,"perfect":false,"leaks":74,"leaksUncounted":0,"coins":0,"dmg":188129,"bossDmg":0,"heal":12396,"deaths":30,"layerGains":{}}},
"players": {"p1":{"killed":17,"total":93,"perfect":false,"leaks":76,"leaksUncounted":0,"coins":0,"dmg":241437,"bossDmg":0,"heal":0,"deaths":20,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,5,0,0,1865,0,0,0],
["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,7,8,32894,2494,0,16,1],
["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,127,0,0,11168,0,0,0],
["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",1218.18,1,126,0,0,12711,0,0,0],
["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,114,2,27126,2544,0,22,1],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",1643,1,128,3,40029,5274,0,28,1],
["p1",7,"op","chess_char_6_diy1_a",2,null,0,0,69,2,7324,3365,0,4,0],
["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",0,0,4,4,66037,1819,0,9,1],
["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,6,0,0,1451,5675,10,1],
["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",0,0,9,0,0,3252,6721,14,1],
["p1",11,"op","chess_char_6_diy1_a",2,null,0,0,76,0,5096,2978,0,10,1],
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",0,0,75,0,9623,5690,0,12,1],
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_004_typhon",1670,1,118,2,28231,3340,0,22,1],
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_004_typhon",0,0,114,0,27924,5010,0,14,1],
["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_004_typhon",1670,1,117,0,5798,3340,0,6,0],
["p1",4,"op","chess_char_6_diy1_a",0,null,0,0,107,0,9994,2620,0,9,1],
["p1",5,"op","chess_char_6_diy2_a",1,null,0,0,5,0,24452,1310,0,15,1],
["p1",6,"op","chess_char_6_diy1_a",2,null,0,0,7,2,37527,1310,0,9,1],
["p1",7,"op","chess_char_6_diy2_b",0,"none",0,0,73,0,5665,3140,0,6,0],
["p1",8,"op","chess_char_6_diy1_b",1,"none",0,0,61,1,7711,3140,0,5,1],
["p1",9,"op","chess_char_6_diy2_b",2,"none",0,0,8,7,32240,1570,0,10,1],
["p1",10,"op","chess_char_6_diy1_b",0,"uniequip_002_typhon",0,0,10,3,13212,1780,0,13,1],
["p1",11,"op","chess_char_6_diy2_b",1,"uniequip_002_typhon",0,0,72,0,17117,3560,0,6,1],
["p1",12,"op","chess_char_6_diy1_b",2,"uniequip_002_typhon",0,0,9,2,31567,1780,0,9,1],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
],
"enemies": {"enemy_1005_yokai":[33,8,25,0,19102],"enemy_1005_yokai_2":[48,11,35,14812,48875],"enemy_1005_yokai_3":[12,0,12,39799,120152]},
"hooks": [1,128,6900,397,397,477,490,48,49,121,8,23,0,7222,492,0,93,72,1,49,0,0,0],
"events": {"atk":445,"deploy":32,"die":49,"dmg":490,"fx":17,"heal":33,"leak":72,"skill":16,"spawn":108,"status":106},
"fx": {"aoe":1,"dodge":5,"spGain":11},
"snaps": "23838382 76cbf86d d3ab405e cee04457 5c293fcc e3a8b2aa 467d33a8 822b9661 2c316180 8998addc 70e829a7 927c2541 812fb701 8d8e74fa 7b940531 f24d2714 326d6baf ed42cbb6 f905a7a2 ea68c7f1 7fcaf95d 21d52b01 648da7f9"
"enemies": {"enemy_1005_yokai":[33,8,25,0,23321],"enemy_1005_yokai_2":[48,8,39,12841,46424],"enemy_1005_yokai_3":[12,1,11,19059,171693]},
"hooks": [1,118,6900,231,231,265,265,0,37,112,10,10,15,8638,108,0,93,75,1,37,0,0,0],
"events": {"atk":255,"deploy":22,"die":37,"dmg":265,"fx":18,"leak":75,"skill":20,"spawn":108,"status":120},
"fx": {"arrowRain":15,"mark":3},
"snaps": "bc5ea01e a44fc03a ec4c7ef4 2414807a 1f43caa7 df794d2c d51b39d2 4a509e38 aae784f7 27b8f3e7 62540756 71c1bb31 5367bad9 40b78ae0 1b2c2a4b 777a9d1e 9e4b2ee8 f9a7b944 d980c3ad 86b27eb4 27a7eabb 831168f4 86f9c7b0"
},
"diy-026": {
"about": "6_diy1_b=typhon/S1/003_typhon 6_diy2_b=typhon/S2/003_typhon 6_diy1_b=typhon/S3/003_typhon 6_diy2_b=typhon/S1/004_typhon 6_diy1_b=typhon/S2/004_typhon 6_diy2_b=typhon/S3/004_typhon 5_diy1_a=narant/S1 5_diy2_a=narant/S2 5_diy1_a=narant/S3 5_diy2_b=narant/S1 5_diy1_b=narant/S2 5_diy2_b=narant/S3",
"fast": false,
"kind": "normal",
"stageId": "act1autochess_m04",
"modeId": "mode_multi_hard",
"round": 13,
"seed": 1776549337,
"waveId": "act1autochess_h06",
"time": 206,
"ticks": 6180,
"reason": "cleared",
"killed": 31,
"total": 165,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 356,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":31,"total":165,"perfect":false,"leaks":134,"leaksUncounted":0,"coins":0,"dmg":1141991,"bossDmg":0,"heal":0,"deaths":19,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_6_diy1_b",0,"uniequip_003_typhon",1570,1,4,2,160903,0,0,99,3],
["p1",2,"op","chess_char_6_diy2_b",1,"uniequip_003_typhon",1570,1,5,1,292016,0,0,85,2],
["p1",3,"op","chess_char_6_diy1_b",2,"uniequip_003_typhon",1570,1,15,5,208055,0,0,52,3],
["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_004_typhon",0,0,170,1,14527,3440,0,16,1],
["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_004_typhon",0,0,110,1,25238,3440,0,13,1],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_004_typhon",0,0,104,1,20656,3440,0,14,1],
["p1",7,"op","chess_char_5_diy1_a",0,null,0,0,123,3,138013,4000,0,67,2],
["p1",8,"op","chess_char_5_diy2_a",1,null,2000,1,180,2,43116,4000,0,48,1],
["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,150,4,63264,4000,0,41,1],
["p1",10,"op","chess_char_5_diy2_b",0,"none",0,0,190,6,91535,6993,0,33,1],
["p1",11,"op","chess_char_5_diy1_b",1,"none",2331,1,192,4,83132,4662,0,25,1],
["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,95,0,1537,4662,0,18,0],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
],
"enemies": {"enemy_1249_lysdb_2":[60,11,49,10335,375992],"enemy_1251_lysyta":[51,15,36,2142,196661],"enemy_1254_lypa_2":[54,5,49,22380,676728]},
"hooks": [1,192,6180,600,600,2654,2595,0,50,184,17,13,20,12914,350,33,165,134,1,50,0,0,14],
"events": {"atk":669,"deploy":24,"die":50,"dmg":2595,"fx":354,"leak":134,"skill":30,"spawn":180,"status":405},
"fx": {"aoe":15,"arrowRain":20,"bounce":232,"dodge":38,"mark":4,"shieldBreak":45},
"snaps": "18c1d1a4 065f6e5c 4a5bb275 246f8b2e 13758717 4fed6070 4f439e1b e42aed66 265a15a2 189242c8 4207d100 eeb76745 6fda3d0d c8bcd6b5 5c225bd6 b1e4155f 596b59c6 89c2e1cd 4ab35ad8 2b8fefe1 f2370231"
},
"diy-027": {
"about": "5_diy1_b=narant/S1/002_narant 5_diy2_b=narant/S2/002_narant 5_diy1_b=narant/S3/002_narant 5_diy2_b=narant/S1/003_narant 5_diy1_b=narant/S2/003_narant 5_diy2_b=narant/S3/003_narant 6_diy1_a=narant/S1 6_diy2_a=narant/S2 6_diy1_a=narant/S3 6_diy2_b=narant/S1 6_diy1_b=narant/S2 6_diy2_b=narant/S3",
"fast": false,
"kind": "normal",
"stageId": "act1autochess_m05",
"modeId": "mode_single_normal",
"round": 13,
"seed": 1149582295,
"waveId": "act1autochess_h06",
"time": 196.93,
"ticks": 5908,
"reason": "cleared",
"killed": 111,
"total": 111,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 0,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":111,"total":111,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1986860,"bossDmg":0,"heal":0,"deaths":0,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_narant",2331,1,10,7,144384,0,0,92,1],
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_narant",2331,1,11,7,153068,0,0,91,3],
["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_narant",2331,1,1,0,535,0,0,6,1],
["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_003_narant",2541,1,3,0,12082,0,0,4,1],
["p1",5,"op","chess_char_5_diy1_b",1,"uniequip_003_narant",2541,1,5,12,239360,0,0,96,3],
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_003_narant",2541,1,7,17,245956,0,0,176,4],
["p1",7,"op","chess_char_6_diy1_a",0,null,2000,1,9,19,315225,0,0,120,1],
["p1",8,"op","chess_char_6_diy2_a",1,null,2000,1,12,24,418911,0,0,180,4],
["p1",9,"op","chess_char_6_diy1_a",2,null,2000,1,2,0,234,0,0,6,0],
["p1",10,"op","chess_char_6_diy2_b",0,"none",2331,1,4,0,13748,0,0,5,1],
["p1",11,"op","chess_char_6_diy1_b",1,"none",2331,1,6,9,227350,0,0,98,3],
["p1",12,"op","chess_char_6_diy2_b",2,"none",2331,1,8,13,216007,0,0,177,4]
],
"enemies": {"enemy_1010_demon":[54,54,0,0,755827],"enemy_1320_wdrrl_2":[18,18,0,0,839808],"enemy_1422_lrsldr_2":[39,39,0,0,400308]},
"hooks": [1,123,5908,1051,1051,2498,2498,0,111,111,26,21,0,10907,256,0,111,0,1,111,0,0,0],
"events": {"atk":1051,"deploy":12,"die":111,"dmg":2498,"fx":509,"skill":47,"spawn":123,"status":251},
"fx": {"aoe":140,"bounce":369},
"snaps": "00fbd370 8c991923 96dc2bea 8d706ab9 43830855 d2a46ee0 09d5fe80 fd5bcc83 a88f6aca 1077c610 42023519 3f71e919 f0900b6b 05d99ec6 f7f79850 b612552c 23d34fc4 0835ffb5 8536e543 3091e242"
},
"diy-028": {
"about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 6_diy1_b=narant/S1/002_narant 6_diy2_b=narant/S2/002_narant 6_diy1_b=narant/S3/002_narant 6_diy2_b=narant/S1/003_narant 6_diy1_b=narant/S2/003_narant 6_diy2_b=narant/S3/003_narant 5_diy1_a=csnipe 5_diy2_b=csnipe",
"fast": true,
"kind": "normal",
"stageId": "act1autochess_m06",
"modeId": "mode_multi_abyss",
"round": 13,
"seed": 2789905475,
"waveId": "act1autochess_h06",
"time": 209.47,
"ticks": 6284,
"reason": "cleared",
"killed": 9,
"total": 147,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 121,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":9,"total":147,"perfect":false,"leaks":138,"leaksUncounted":0,"coins":0,"dmg":730326,"bossDmg":0,"heal":0,"deaths":23,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,97,0,654,4256,0,4,0],
["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,169,0,14365,10002,0,18,0],
["p1",5,"op","chess_char_6_diy1_b",0,"uniequip_002_narant",2331,1,173,1,67234,4662,0,20,2],
["p1",6,"op","chess_char_6_diy2_b",1,"uniequip_002_narant",0,0,108,1,110299,4662,0,26,1],
["p1",7,"op","chess_char_6_diy1_b",2,"uniequip_002_narant",0,0,127,3,174608,4662,0,43,2],
["p1",8,"op","chess_char_6_diy2_b",0,"uniequip_003_narant",0,0,94,1,122987,5262,0,29,2],
["p1",9,"op","chess_char_6_diy1_b",1,"uniequip_003_narant",0,0,88,0,83429,5262,0,28,2],
["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_003_narant",0,0,143,3,147500,5262,0,39,1],
["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,159,0,5253,3813,0,18,0],
["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,153,0,3998,4530,0,14,0]
],
"enemies": {"enemy_1010_demon_2":[36,1,35,22806,317498],"enemy_1071_dftman":[51,6,45,1753,194180],"enemy_1072_dlancer":[60,2,58,27814,218648]},
"hooks": [1,173,6284,330,330,634,617,0,32,170,10,9,0,7331,124,30,147,138,1,32,0,0,17],
"events": {"atk":330,"deploy":26,"die":32,"dmg":617,"fx":166,"leak":138,"skill":19,"spawn":159,"status":199},
"fx": {"aoe":34,"bounce":94,"dodge":38},
"snaps": "46ac6de1 2112c33e 837a4ca6 d3ee52bf c1ba00c7 a9527591 0ab35d3e a6d4d348 ef74a88f 9464be05 0c0cf77b 9fe08ceb d1faeac8 d57e45f0 3f5a7950 d5f0437e 97116a6c f3594fa0 3e97535d e3b4a06c 09cf44e6"
},
"diy-029": {
"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_m07",
"modeId": "mode_multi_funny",
"round": 12,
"seed": 2642056114,
"waveId": "act1autochess_h05",
"time": 203.93,
"ticks": 6118,
"reason": "cleared",
"killed": 35,
"total": 114,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 61,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":35,"total":114,"perfect":false,"leaks":79,"leaksUncounted":0,"coins":0,"dmg":351370,"bossDmg":0,"heal":0,"deaths":14,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,115,1,5012,5208,0,65,1],
["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",0,0,107,0,6422,6742,0,32,2],
["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,96,7,33528,3058,0,33,4],
["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,91,20,167275,3808,0,92,5],
["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,97,3,27895,5276,0,49,2],
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,92,4,65144,6822,0,60,2],
["p1",7,"op","chess_char_5_diy1_a",2,null,1133,1,4,0,13450,0,0,96,3],
["p1",8,"op","chess_char_5_diy2_b",2,"none",1360,1,7,0,15482,0,0,94,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,8532,0,0,72,2],
["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,110,0,8630,2262,0,62,2],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null]
],
"enemies": {"enemy_1166_dusbr_2":[45,21,24,2758,102053],"enemy_1169_duphlx_2":[51,12,39,4945,126678],"enemy_1172_dugago":[18,2,16,25472,122640]},
"hooks": [1,135,6118,1009,1009,1517,1496,0,49,128,26,26,0,26499,134,64,114,79,1,64,0,0,21],
"events": {"atk":1009,"deploy":19,"die":49,"dmg":1496,"fx":188,"leak":79,"skill":52,"spawn":128,"status":414},
"fx": {"aoe":46,"dodge":21,"dp":36,"revive":8,"slash":69,"stone":8},
"snaps": "d294bf74 f726af71 b04a5d86 cc738f7b ae57a036 3981ef1a b09e1223 1d3de17a f52ea4c9 0c6bf8b5 dd94b32b 41e1f44b 7b8565f0 5f461c30 c6321a2a b5ad3265 5be21e97 4ddfcf65 c9263b7b a7a973da 60dd319e"
},
"diy-030": {
"about": "5_diy1_a=sharp2 5_diy2_b=sharp2 5_diy1_a=acspec 5_diy2_b=acspec 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc 5_diy1_a=acsupo 5_diy2_b=acsupo",
"fast": false,
"kind": "normal",
"stageId": "act2autochess_m01",
"modeId": "mode_single_hard",
"round": 12,
"seed": 4282451111,
"waveId": "act1autochess_h05",
"time": 230,
"ticks": 6900,
"reason": "timeout",
"killed": 24,
"total": 42,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 360,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":24,"total":42,"perfect":false,"leaks":18,"leaksUncounted":0,"coins":0,"dmg":300294,"bossDmg":0,"heal":10910,"deaths":31,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",0,null,0,0,6,3,41257,1865,0,30,1],
["p1",2,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",0,0,58,4,47553,4748,0,34,1],
["p1",3,"op","chess_char_5_diy1_a",2,null,1154,1,74,0,0,11540,0,0,0],
["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",638.46,1,75,0,0,13203,0,0,0],
["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,2,0,64600,1272,0,50,1],
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",0,0,3,2,91461,1889,0,46,1],
["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,4,0,7285,1372,0,5,1],
["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",0,0,48,1,34810,7510,0,11,1],
["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,8,0,0,3647,6959,14,1],
["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",0,0,43,0,0,6191,3951,6,1],
["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,1,0,6208,1160,0,10,1],
["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",0,0,5,0,7121,1430,0,9,1]
],
"enemies": {"enemy_1005_yokai_2":[18,18,0,222,8571],"enemy_1005_yokai_3":[12,3,6,25395,152682],"enemy_9009_acfort":[12,3,9,30211,139041]},
"hooks": [1,75,6900,410,410,661,694,35,55,70,10,28,0,9568,225,0,42,15,1,41,0,0,0],
"events": {"atk":520,"deploy":33,"die":55,"dmg":694,"fx":57,"heal":31,"leak":15,"skill":20,"spawn":54,"status":74},
"fx": {"aoe":5,"beam":46,"dodge":3,"spGain":3},
"snaps": "d299cac5 9e5fecae 9fef873f 0041e6d5 bf4aa79e e9d81067 2b840ee7 5f2b2a5c 73fc1c64 a0d69be3 19bf79bd 438e2070 74bb8728 42487a28 de9f9caf 88600853 8c90dabd 84058f3d af1ad7d9 010e3167 6db40d66 e9645c1d 284f3878"
},
"diy-031": {
"about": "6_diy1_a=acfend 6_diy2_b=acfend 6_diy1_a=acpion 6_diy2_b=acpion 6_diy1_a=acguad 6_diy2_b=acguad 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc",
"fast": false,
"kind": "normal",
"stageId": "act2autochess_m02",
"modeId": "mode_multi_normal",
"round": 13,
"seed": 1788669353,
"waveId": "act1autochess_h06",
"time": 230,
"ticks": 6900,
"reason": "timeout",
"killed": 12,
"total": 36,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 211,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":12,"total":36,"perfect":false,"leaks":24,"leaksUncounted":0,"coins":0,"dmg":191861,"bossDmg":0,"heal":28856,"deaths":25,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_6_diy1_a",2,null,0,0,61,0,0,5208,0,0,0],
["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",0,0,5,0,0,3431,0,0,0],
["p1",3,"op","chess_char_6_diy1_a",2,null,63.15,1,64,0,7369,5241,0,0,1],
["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",0,0,63,0,47963,8259,0,0,3],
["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,50,0,0,10086,0,0,0],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",0,0,44,0,0,21845,0,0,0],
["p1",7,"op","chess_char_6_diy1_a",2,null,0,0,59,0,573,3816,0,1,0],
["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",0,0,62,0,71082,5806,0,45,1],
["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,36,1,9537,5112,0,6,1],
["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",0,0,4,0,55338,1819,0,10,1],
["p1",11,"op","chess_char_6_diy1_a",2,null,0,0,53,0,0,3280,16322,22,1],
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",0,0,47,0,0,4850,12534,19,1],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null]
],
"enemies": {"enemy_1005_yokai_2":[12,12,0,570,4443],"enemy_1005_yokai_3":[12,0,11,40977,83772],"enemy_9009_acfort":[12,0,12,37205,103646]},
"hooks": [1,64,6900,320,320,443,409,74,37,60,9,9,0,11179,0,0,36,23,1,26,0,0,34],
"events": {"atk":353,"deploy":26,"die":37,"dmg":409,"fx":120,"heal":68,"leak":23,"skill":18,"spawn":50,"status":28},
"fx": {"aoe":3,"beam":37,"dodge":34,"dp":4,"slash":32,"spGain":10},
"snaps": "8bb89486 af5a3103 edf9cdf6 9c8aa642 4004d4b6 398064e7 b94ae3bd e4f347a5 8cf52583 63ae2ad4 2d11279d 74b6b79f dbb9f658 f3eeb437 731e600c 26663686 39a333c1 595094aa 1bf50db7 39b3ef7d eee313e8 326f4d94 97a0c299"
},
"diy-032": {
"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": "act2autochess_m03",
"modeId": "mode_multi_hard",
"round": 13,
"seed": 148338108,
"waveId": "act1autochess_h06",
"time": 216.33,
"ticks": 6490,
"reason": "cleared",
"killed": 2,
"total": 117,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 69,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":2,"total":117,"perfect":false,"leaks":115,"leaksUncounted":0,"coins":0,"dmg":73158,"bossDmg":0,"heal":0,"deaths":25,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,112,0,14601,3730,0,11,1],
["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,5,2,33453,2494,0,9,1],
["p1",3,"op","chess_char_6_diy1_a",2,null,1154,1,146,0,14003,8078,0,53,0],
["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",0,0,141,0,7814,13356,0,8,0],
["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,131,0,2241,3480,0,6,0],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",0,0,145,0,1046,4350,0,2,0],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
],
"enemies": {"enemy_10031_cnvsld":[39,2,37,3710,53063],"enemy_1019_jshoot_2":[57,0,57,17616,17501],"enemy_9008_acbunn":[21,0,21,14162,2594]},
"hooks": [1,146,6490,226,226,213,228,0,27,142,2,18,0,2389,10,23,117,115,1,27,0,0,0],
"events": {"atk":238,"deploy":26,"die":27,"dmg":226,"fx":1,"leak":115,"skill":4,"spawn":126,"status":16},
"fx": {"displace":1},
"snaps": "038e9c71 b94393f2 b660dbec 471efee3 ea2c5cd8 67a95602 be0382a8 ed05227c 9d9b8d44 97b9ccc2 cebb0260 f89cd753 07c1d2ac a9ae2748 9b26f9da 162fa605 4b05eaa4 0287acaf ec32f6a6 40f84535 dad58c31 43eb8c7a"
}
}
}