feedback5: merge O8 — 自选 kits for 闪灵, 夜莺 (with her 幻影) and 嵯峨 (劝善's 重伤); engine: a shield may be limited to one damage type (shieldType), a profile may skip enemies it never targets (skipEnemy); golden: only diy moved

This commit is contained in:
sganggs committed 2026-10-06 00:01:51 +08:00
commit 89ec6aabb6
14 files changed
+1938 -125

No files matched your search

+8 -4
View File
@@ -509,12 +509,14 @@ per source — DoTs and slows do that on purpose; a damage-taken / DEF / RES mod
## 3. Buffs, mods, statuses
`battle.addBuff(unit, { key, duration=Infinity, refresh='replace'|'extend'|'stack'|'independent'|'keep', stacks, maxStacks,
mods, flags, onTick(ctx), interval, onExpire(ctx), onRemove(ctx), tags, shield, shieldHits, persist, visible, data, allowDead })`
mods, flags, onTick(ctx), interval, onExpire(ctx), onRemove(ctx), tags, shield, shieldHits, shieldType, persist, visible, data, allowDead })`
- `replace`: new instance replaces the old; `extend`: keep the longer remaining time, take the new mods; `stack`: +stacks
up to maxStacks, timer reset; `independent`: separate timers, at most `maxStacks` alive (oldest dropped); `keep`: ignore.
- Additive mods scale with stacks (`value × stacks`), `*Mul` mods multiply (`value ^ stacks`).
- `onTick({battle, unit, buff, dt})` every tick, or every `interval` s. `persist: true` survives death/redeploy.
- `shield` = HP absorbed (consumed, buff removed when empty); `shieldHits` = number of damage instances fully negated.
- `shield` = HP absorbed (consumed, buff removed when empty); `shieldHits` = number of damage instances fully negated;
`shieldType` ('phys' | 'arts' | 'true' | 'elemental') = a 屏障 that absorbs that damage type only (夜莺 S2 法术护盾 "能吸收…
法术伤害"); none = every type (PRTS 术语释义 屏障 "若无特殊说明,屏障可吸收全种类伤害"). Shields are spent oldest first.
- `visible: true` emits `['status', id, key, 1/0]` client events. `battle.removeBuff(unit, key|buff)`.
**Mod keys** — additive: `atkFlat atkPct defFlat defPct hpFlat hpPct resFlat aspd batPct blockCnt rangeExtend
@@ -678,7 +680,7 @@ PRTS 异常效果: 无法选择 effects "仅在选择时生效"); checked before
with `ceil(final) ≥ BOSS_HIT_LIMIT` (300000, shared/constants.js) is cancelled whole: returns 0 before shields (阿利斯泰尔's
`boss:vest` barrier stays untouched; a `hit`-step block such as 假想敌:再生's aura acts earlier) [ASSUMED order], no HP /
pool loss, no credit or stats, no `dmg` event, no `damaged` / `fatal` / kill; an fx `hitCap` `{ id, n }` marks it and
draws nothing; research 11) → shields → HP loss
draws nothing; research 11) → shields (a typed one — buff `shieldType` — only for its damage type) → HP loss
(boss units: routed to `sharedBoss.damage(playerId, amount)`; a pool left under 1 HP is emptied) → if HP ≤ 0: **`fatal`** (`ctx.prevented = true` keeps the
unit at ≥ 1 HP) → **`damaged`** → SP-on-hurt / TAKE_DAMAGE → `kill` + `death`.
@@ -1141,7 +1143,9 @@ 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`, `boomerang` (the projectile stays
`afterHit`, `afterAttack`, `canAttack`, `hitsFn`, `priority`, `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).
+3
View File
@@ -155,6 +155,9 @@ export function makeBuff(b) {
tags: b.tags ?? [],
shield: b.shield ?? (b.mods && b.mods.shield) ?? 0,
shieldHits: b.shieldHits ?? 0,
// a 屏障 that absorbs one damage type only (damage.js absorbShields): 'phys' | 'arts' | 'true' | 'elemental'; null =
// every type (PRTS 术语释义 屏障 "若无特殊说明,屏障可吸收全种类伤害") — 夜莺 S2 法术护盾 "能吸收…法术伤害"
shieldType: b.shieldType ?? null,
persist: !!b.persist,
status: b.status ?? null,
visible: b.visible ?? false,
+3
View File
@@ -82,6 +82,9 @@ export const OPERATOR_KIT_FILES = Object.freeze(['op-siege.js',
'op-chen.js',
'op-irene.js',
'op-helage.js',
'op-shining.js',
'op-cgbird.js',
'op-saga.js',
]);
/**
+158
View File
@@ -0,0 +1,158 @@
// server/sim/content/kits/ops/op-cgbird.js — 夜莺 (char_179_cgbird) 自选 operator kit: 6★ 群愈师 (医疗), an owned-6★ pick of
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait, both modules at every form and her summon 幻影.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_179_cgbird, 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; the 幻影 token record
// backups.json tokens.token_10003_cgbird_bird with its owner-form / module variants) and PRTS 夜莺 (S2 备注
// "多个屏障吸收量可叠加,持续时间独立计算,优先消耗先生成的屏障,法术抗性增加效果不可叠加"; 转瞬即逝的幻影 备注 "幻影的持有上限为3个,
// 每次部署夜莺时补充2个幻影 / Y模组2级时…补充3个 / 夜莺离场时,已部署的幻影会留在场上"); PRTS 卫戍协议/帮助 §作战阶段 (summons:
// "所有手动部署的召唤物,无视所属干员的持有状态,不消耗持有数量,作战开始时立即部署一次", "若战场区初始部署有召唤物,若召唤物在
// 战斗期间退场,将在满足条件后立即原地再部署1个").
// - Trait (群愈师) "同时恢复三个友方单位的生命": the profession default (3 heals). Module RIN-Y “莺歌” "同时恢复四个友方单位的
// 生命" (hidden module talent attack@max_target / skill@max_target 4): 4 heals (kit trait, as 调香师's RIN-Y). RIN-X
// “封闭的希望” "攻击范围扩大": her range becomes the module's own grid (its range-only talent change, talentIndex −1: y-2
// plus the centre tile [0,3] — PRTS y-3), as shared/loadoutRecord.js attackRangeGrid draws it; 圣域 keeps its own y-4.
// - T1 白恶魔的庇护 "攻击范围内的友方单位法术抗性+15": every ally (operators and summons) standing in her current attack range
// — her own tile included — RES +magic_resistance (flat). RIN-X stage 2+ "…且受到的治疗效果提升5%" (talent change
// heal_scale): also healing received ×heal_scale there. Refreshed every 0.2 s; two sources keep the strongest.
// - T2 转瞬即逝的幻影 (summon token_10003_cgbird_bird: no attack, blocks nothing, RES 75, taunt level 1 — its stats — and
// its talent "30%的物理闪避…每秒流失3%的最大生命": dodgePhys prob, a 流失 of hp_ratio × its max HP every second; RIN-Y
// stage 3 "40%的物理闪避": the module variant's talent). The 幻影 is a hand piece (the match's per-player shop): a placed
// one deploys with the board at the battle start for free and without using her stock. Each deployment of 夜莺 adds
// cnt (2; RIN-Y stage 2+: 3) to her stock, at most PHANTOM_HOLD_CAP; a placed 幻影 that falls comes back on its own tile
// once its redeploy time (respawnTime) has passed and she holds one — using one, paying its DP cost as a deployment
// does [ASSUMED: "满足条件" = a 幻影 held, its redeploy time, the DP], whether 夜莺 is on the field or not (deployed
// 幻影 stay when she leaves: PRTS 备注). No tokens.js kit: its talent and its return are this kit's (owner-coupled,
// as tokens.js "Managed mode"), applied at each of its deployments.
// - S1 治疗强化·γ型 (MANUAL, 30 s, data DEFAULT — heal skill): ATK +atk.
// - S2 法术护盾 (AUTO, 2 charges, data DEFAULT — a "next heal" waits for her heal, as 闪灵's S2): "下次治疗使所有目标获得一个
// 持续N秒的屏障,屏障能吸收相当于夜莺攻击力X%的法术伤害,同时使目标法术抗性+Y" — every target of that heal (3 / 4) gets a
// 屏障 of atk_scale × her ATK for duration s that absorbs arts damage only (buff shieldType 'arts', damage.js
// absorbShields) and RES +magic_resistance (flat) while one of her barriers lasts (op-shining.js grantBarrier: each
// barrier its own timer, older ones first, the RES bonus not stacking) [ASSUMED: the bonus goes with the last barrier].
// - S3 圣域 (MANUAL, 60 s, data ACTIVE_RANGE on its y-4 — the owner's rule: an injured ally inside it): range y-4 while it
// runs, ATK +atk; every ally in that range RES +magic_resistance (×(1 + v): "法术抗性+105%") and dodgeArts prob
// ("获得20%的法术闪避"), refreshed every 0.2 s.
// Medic: heals only (dmgType heal), ranged, no anti-air question; ground enemies target her normally (no 起飞 / 迷彩 / 隐匿).
import { num, talentBb, moduleBb, moduleOn, skillRec } from '../shared/tier1.js';
import { rangeAura, grantBarrier } from './op-shining.js';
const S1 = 'skcom_heal_up[3]';
const S2 = 'skchr_cgbird_2';
const S3 = 'skchr_cgbird_3';
export const PHANTOM = 'token_10003_cgbird_bird';
/** PRTS 转瞬即逝的幻影 备注 "幻影的持有上限为3个" (no blackboard key). */
export const PHANTOM_HOLD_CAP = 3;
/** Seconds between two tries of a fallen 幻影 to come back (tile busy, DP short, none held). */
const RETRY = 0.25;
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/**
* RIN-X "攻击范围扩大": the picked module's own range grid (its range-only talent change, talentIndex −1), or null — the
* rule of shared/loadoutRecord.js attackRangeGrid (the card / board range) and shared/tier5.js moduleRangeUp.
*/
function moduleRange(chess) {
if (!chess?.isGolden || !moduleOn(chess) || !/攻击范围扩大/.test(String(chess?.trait?.moduleDesc ?? ''))) return null;
const rec = (chess.modules ?? []).find((m) => m && m.uniEquipId === chess.module.id);
const g = (rec?.talentChanges ?? []).find((t) => t && t.talentIndex === -1 && Array.isArray(t.rangeGrid) && t.rangeGrid.length)?.rangeGrid;
return g ? g.map((p) => [p[0], p[1]]) : null;
}
/**
* The 幻影's own talent blackboard (`prob`, `hp_ratio`) from its def at the owner's form and module: the last talent
* holding `prob` — a module's upgrade (RIN-Y stage 3: 40 %) follows the base talent in the variant data.
* @param {object} def normalised token def
*/
export function phantomTalent(def) {
let out = {};
for (const t of def?.talents ?? []) if (t && t.bb && t.bb.prob != null) out = t.bb;
return out;
}
export default {
char_179_cgbird: (bb, chess) => {
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s2 = skillRec(chess, S2), s3 = skillRec(chess, S3);
const t0 = talentBb(chess, 0); // 白恶魔的庇护: magic_resistance (RIN-X stage 2+: heal_scale)
const t1 = talentBb(chess, 1); // 转瞬即逝的幻影: cnt (RIN-Y stage 2+: 3)
const hidden = moduleBb(chess); // RIN-Y: attack@max_target
const heals = Math.floor(num(hidden['attack@max_target']));
const grid = moduleRange(chess);
const picked = chess?.skill?.skillId ?? null;
const tokenId = (chess?.talents ?? []).find((t) => t && t.tokenKey)?.tokenKey ?? PHANTOM;
return {
trait: heals > 0 ? { heal: { mode: 'multi', count: heals } } : undefined,
skills: {
[S1]: { kind: 'duration', heal: true, mods: { atkPct: num(b1.atk) } },
[S2]: {
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
heal: true,
attack: { healScale: 1 }, // the next heal (ai.js doHeal calls onHit for every heal target)
onHit({ battle, unit, target }) {
if (!target || target.side !== 'ally') return;
grantBarrier(battle, unit, target, {
key: 'cgbird:barrier', statKey: 'cgbird:barrierRes', shield: unit.s.atk * num(b2.atk_scale),
duration: num(b2.duration), mods: { resFlat: num(b2.magic_resistance) }, shieldType: 'arts',
});
},
},
[S3]: { kind: 'duration', heal: true, mods: { atkPct: num(b3.atk) }, targeting: s3?.rangeGrid ? { rangeGrid: s3.rangeGrid } : undefined },
},
talents: [
{ install(battle, unit) { // 白恶魔的庇护: allies in her attack range RES +magic_resistance; RIN-X stage 2+: healing ×heal_scale
const r = num(t0.magic_resistance), hs = num(t0.heal_scale, 1);
if (!(r > 0) && hs === 1) return;
const mods = { ...(r ? { resFlat: r } : null), ...(hs !== 1 ? { healingTakenMul: hs } : null) };
rangeAura(battle, unit, { key: 'talent:cgbird:res', value: r + (hs - 1) * 100, mods });
} },
{ install(battle, unit) { // 转瞬即逝的幻影: the 幻影's talent, her stock, a fallen 幻影's return
const cnt = Math.max(0, Math.floor(num(t1.cnt)));
if (!(cnt > 0)) return;
unit.mem.phantomStock = 0;
const mine = (t) => !!t && t.kind === 'token' && t.defId === tokenId && t.ownerUnit === unit;
battle.on('deploy', (ctx) => {
const u = ctx.unit;
if (u === unit) {
unit.mem.phantomStock = Math.min(PHANTOM_HOLD_CAP, (unit.mem.phantomStock ?? 0) + cnt);
return;
}
if (!mine(u)) return;
const tal = phantomTalent(u.def);
const p = num(tal.prob), hr = num(tal.hp_ratio);
if (p > 0) battle.addBuff(u, { key: 'cgbird:phantomDodge', mods: { dodgePhys: p }, tags: ['talent'] });
if (hr > 0) {
battle.addBuff(u, {
key: 'cgbird:phantomDrain', interval: 1, tags: ['talent'],
onTick: ({ unit: t }) => battle.loseHp(t, t.s.maxHp * hr, { silent: true, tags: ['cgbird:phantom'] }),
});
}
}, { owner: unit });
battle.on('death', (ctx) => {
const t = ctx.unit;
if (!mine(t) || ctx.reason !== 'killed' || battle.finished) return;
t.removed = false; // the piece stays (Battle.redeploy accepts it; its hooks are kept)
const at = battle.time + Math.max(0, num(t.base.respawnTime));
battle.every(RETRY, (b, sched) => {
if (t.alive || t.removed || b.finished) { sched.cancel(); return; }
if (b.time + 1e-9 < at || !((unit.mem.phantomStock ?? 0) > 0)) return;
if (b.redeploy(t, { free: false })) { unit.mem.phantomStock--; sched.cancel(); }
}, { owner: unit });
}, { owner: unit });
} },
],
install(battle, unit) {
// RIN-X: the module's range is her own (the initial range of the DEFAULT trigger too); 圣域 runs its y-4
if (grid) { unit.rangeGrid = grid; battle.refreshRange(unit); }
// 圣域: allies in her (y-4) range RES ×(1 + magic_resistance), arts dodge prob, while the skill runs
if (picked === S3) {
const mr = num(b3.magic_resistance), p = num(b3.prob);
if (mr > 0 || p > 0) rangeAura(battle, unit, { key: 'skill:cgbird:sanctuary', value: mr, mods: { resMul: 1 + mr, dodgeArts: p }, on: () => !!unit.skill?.active });
}
},
};
},
};
+231
View File
@@ -0,0 +1,231 @@
// server/sim/content/kits/ops/op-saga.js — 嵯峨 (char_362_saga) 自选 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_362_saga, 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); gamedata_const ba.dying
// "重伤: 移速下降且无法被阻挡,10秒后自然死亡,被击杀后使击杀者回复数点技力"; PRTS 嵯峨 (劝善 备注 "本天赋第一部分实际效果为:
// 自身造成的伤害不会致命(至多使目标生命值降至1点),成功造成伤害后若目标生命值小于等于1则为目标附加重伤"; 清明 备注 "嵯峨每次
// 部署都可以触发一次本天赋"; S2 备注 "可选择重伤单位", "攻击附加延迟检测Buff:0.7s后进行检测,强制击杀持有重伤的目标(击杀来源为
// 嵯峨自身),随后解除目标的重伤"; S3 备注 "造成伤害前判断目标生命值"); PRTS 术语释义 重伤 ("重伤Buff包含:移动速度-80%,禁疗、
// 禁用近战,阻挡数、生命回复速度、生命回复速度(百分比)归零,从消失状态变为出现的瞬间立刻死亡(消失状态期间不会因重伤自然死亡)",
// "重伤造成的自然死亡为强制死亡(也即可以杀死持有不死的单位),视为无来源击杀", "重伤期间的单位无法再被施加重伤"); PRTS 异常效果
// 禁用近战 "敌人类单位无法进行阻挡攻击,无法被阻挡".
// - Trait (尖兵) "能够阻挡两个敌人": the profession default (block 2). SOL-X 何处寻驮? "阻挡敌人时攻击力和防御力各+8%"
// (trait bb atk / def): +atk / +def while she blocks an enemy. SOL-Y 身向云泥 "首次部署时部署费用-4" (hidden
// runtime_cost −4): the first deployment of a battle costs nothing in this mode ⇒ no effect (as 推进之王's SOL-Y).
// - T1 劝善: her damage is never lethal to an enemy (a `fatal` hook keeps it at 1 HP — every damage of hers: attacks, 除恶;
// not a 无来源 one, which has no source); after a damage of hers an enemy at ≤ 1 HP becomes 重伤 (the DYING buff, once:
// none while it holds one): move speed ×(1 + move_speed) (−80 %), unblockable (禁用近战: released by its blocker, no
// block attack), 禁疗 (healFree / noHeal), its HP regeneration zeroed; it dies `interval` s later (10) — a forced, 无来源
// death (battle.kill with no killer: no SP, 不死 does not hold it) — not while it is 消失 (hidden), and at once when it
// comes back from 消失. Whoever kills a 重伤 enemy gets `sp` SP (2; "击杀者技力+2", giveSp: not during a timed skill).
// "嵯峨不攻击重伤单位": the kit trait `skipEnemy` (targeting.js canTargetEnemy) — no normal attack, block target or
// trigger target of hers is a 重伤 enemy. SOL-X stage 2+ "对生命值低于一半的单位造成的伤害提升15%" (hidden hp_ratio /
// damage_scale): ×damage_scale on every damage of hers whose target is below hp_ratio before it.
// A shared-pool leader (boss rounds: its HP is the pool's, damage.js runs no `fatal` step for it) is outside 劝善
// [ASSUMED: her damage there is ordinary].
// - T2 清明 "生命值低于40%时,仅一次获得70%物理闪避和每秒回复5%的最大生命,持续15秒": once per deployment (PRTS 备注), the first
// time a damage leaves her below hp_ratio of her max HP: dodgePhys prob and hpRegenRatio for `duration` s — the
// blackboard key hp_recovery_per_sec_by_max_hp_ratio is the 生命回复速度(百分比) attribute (PRTS 异常效果 禁疗 / 术语释义
// 重伤 name it), as 宴 S1 / 山 / 幽灵鲨 / 蜜蜡 / 烛煌 apply it [ASSUMED: no 备注 of 清明 says so; 余's 闲云隐市 with the same
// key is still open, DESIGN 0.2.0.l3]. SOL-Y stage 2+: the talent change's numbers — at stage 3 the text reads 6 % (its
// potential-5 text 7 %) while the blackboard is 0.065: the game applies the blackboard, so 6.5 % per second.
// - S1 冲锋号令·γ型 (AUTO, no target): +cost DP at once; an AUTO skill acting on nobody fires as soon as its SP is full
// (`trigger: 'SP_FULL'`, as 推进之王's / 德克萨斯's kits cast the same skill — the owner's AUTO rule).
// - S2 除恶 (MANUAL, 2 charges, data SKILL_RANGE on its x-6 cross: any enemy there, 无视其不可选中): +cost DP; the up to
// S2_MAX (6, the text's — no blackboard key) selectable ground enemies of the cross ("地面敌人"; a 重伤 one included:
// "可选择重伤单位"), best targets first (her priority) [ASSUMED order], take atk_scale × ATK physical (a skill damage;
// 劝善 applies); S2_CHECK s later each of them still 重伤 is killed (killer 嵯峨: she gets its SP, stats and bounty; a
// forced kill — battle.kill, no `fatal` step), then its 重伤 is lifted (should a kill handler have revived it).
// - S3 怒目 (MANUAL, 20 s, data ACTIVE_RANGE on her 1-1 + 1): +cost DP every interval s while it runs (value in all; the
// rest of value at its natural end), attack interval +base_attack_time s (+0.5 on her 1.05), 攻击距离 +1
// (targeting.rangeExtend), ATK +atk, every enemy she blocks at once (hitAllBlocked; one target in range while she
// blocks none), and one more attack on each target that was below attack@hp_ratio before the hit (PRTS 备注: an HP
// snapshot at beforeAttack; the extra attack is a forced one a tick later, no 重伤 target).
// Melee physical, ground-only (canHitFly false), ground enemies target her normally (no 起飞 / 迷彩 / 隐匿).
import { num, talentBb, moduleBb, traitBb, skillRec, toggleBuff, batMod, giveSp, up } from '../shared/tier1.js';
import { absoluteRangeKeys, canTargetEnemy, sortEnemyTargets } from '../../../targeting.js';
const S1 = 'skcom_charge_cost[3]';
const S2 = 'skchr_saga_2';
const S3 = 'skchr_saga_3';
/** The 重伤 buff (gamedata_const ba.dying): one per enemy — "重伤期间的单位无法再被施加重伤". */
export const DYING = 'saga:dying';
/** S2 除恶 备注 "攻击附加延迟检测Buff:0.7s后进行检测" (no blackboard key). */
export const S2_CHECK = 0.7;
/** S2 除恶 "对十字范围内最多6名地面敌人" — the text's number when it cannot be read (no blackboard key). */
const S2_MAX = 6;
/** The x-6 cross (skill range) when the data carries no grid. */
const X6 = Object.freeze([[2, 0], [1, 0], [0, -2], [0, -1], [0, 0], [0, 1], [0, 2], [-1, 0], [-2, 0]]);
/** 除恶's selection: ground enemies only ("地面敌人"), 重伤 ones included (no skipEnemy). */
const GROUND = Object.freeze({ canHitFly: false, groundOnly: true });
/** Is `e` 重伤 (whoever put it there)? */
export const isDying = (e) => !!(e && typeof e.findBuff === 'function' && e.findBuff(DYING));
/**
* 重伤 on enemy `e` from `saga` (PRTS 术语释义 重伤): move speed ×(1 + ms), unblockable (禁用近战), 禁疗, no HP regeneration;
* a forced, 无来源 death `life` s later — never while 消失, at once when it comes back from 消失. The killer of a 重伤
* enemy gets `sp` SP (the talent's kill hook reads `data.sp`). Nothing when it already holds one.
*/
function applyDying(battle, saga, e, { sp, life, ms }) {
if (!e || !e.alive || isDying(e)) return;
battle.addBuff(e, {
key: DYING, source: saga, visible: true,
mods: { moveMul: Math.max(0, 1 + ms), hpRegen: -1e9 },
flags: { unblockable: true, healFree: true, noHeal: true },
data: { sp, until: battle.time + life, hidden: !!e.hidden },
onTick: ({ battle: b, unit: x, buff }) => {
const d = buff.data;
if ((d.hidden && !x.hidden) || (!x.hidden && b.time + 1e-9 >= d.until)) { b.kill(x, null); return; }
d.hidden = !!x.hidden;
},
});
battle.fx('dying', { x: e.x, y: e.y, id: e.id, from: saga.id });
}
export default {
char_362_saga: (bb, chess) => {
const b1 = skillRec(chess, S1)?.bb ?? {}, b2 = skillRec(chess, S2)?.bb ?? {}, b3 = skillRec(chess, S3)?.bb ?? {};
const s2 = skillRec(chess, S2);
const t0 = talentBb(chess, 0); // 劝善: move_speed, sp, interval
const t1 = talentBb(chess, 1); // 清明: hp_ratio, hp_recovery_per_sec_by_max_hp_ratio, prob, duration
const hidden = moduleBb(chess); // SOL-X stage 2+: hp_ratio, damage_scale; SOL-Y: runtime_cost (no effect)
const tb = traitBb(chess); // SOL-X: atk, def while blocking
const picked = chess?.skill?.skillId ?? null;
const maxHit = Math.max(1, Math.floor(num(/最多(\d+)名/.exec(String(s2?.desc ?? ''))?.[1], S2_MAX)));
return {
// 劝善 "嵯峨不攻击重伤单位"
trait: { skipEnemy: isDying },
skills: {
[S1]: {
kind: 'instant', trigger: 'SP_FULL',
onStart({ battle, unit }) {
const n = num(b1.cost);
battle.addDp(unit.ownerId, n);
battle.fx('dp', { x: unit.x, y: unit.y, n, id: unit.id });
},
},
[S2]: {
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
onStart({ battle, unit }) {
const n = num(b2.cost);
battle.addDp(unit.ownerId, n);
battle.fx('dp', { x: unit.x, y: unit.y, n, id: unit.id });
const keys = absoluteRangeKeys(s2?.rangeGrid ?? X6, unit.tileR, unit.tileC, unit.dir, 0);
const list = battle.enemiesInKeys(keys, unit, GROUND);
sortEnemyTargets(battle, unit, list, unit.profile?.priority ?? null);
const hit = list.slice(0, maxHit);
battle.fx('aoe', { x: unit.x, y: unit.y, radius: 2, id: unit.id, skill: 'saga:cleanse', n: hit.length });
for (const e of hit) {
battle.dealDamage(unit, e, { amount: unit.s.atk * num(b2.atk_scale, 1), type: 'phys', isSkill: true, tags: ['saga:s2'] });
if (!e.alive) continue;
battle.addBuff(e, {
key: 'saga:s2check', refresh: 'independent', maxStacks: 99, duration: S2_CHECK, source: unit,
onExpire: ({ battle: b, unit: x }) => {
if (!x.alive || !isDying(x)) return;
b.kill(x, unit);
if (x.alive) b.removeBuff(x, DYING);
},
});
}
},
},
[S3]: {
kind: 'duration',
mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess) },
targeting: { rangeExtend: num(b3.ability_range_forward_extend) },
attack: { hitAllBlocked: true },
onStart({ unit }) { unit.mem.sagaDp = { acc: 0, got: 0 }; },
onTick({ battle, unit, dt }) {
const st = unit.mem.sagaDp;
const iv = num(b3.interval, 1), n = num(b3.cost), total = num(b3.value);
if (!st || !(iv > 0) || !(n > 0)) return;
st.acc += dt;
while (st.acc + 1e-9 >= iv && st.got + n <= total + 1e-9) {
st.acc -= iv;
st.got += n;
battle.addDp(unit.ownerId, n);
}
},
onEnd({ battle, unit, reason }) {
const st = unit.mem.sagaDp;
unit.mem.sagaDp = null;
// the last grant falls on the very end of its 20 s
if (st && reason === 'duration' && num(b3.value) - st.got > 1e-9) battle.addDp(unit.ownerId, num(b3.value) - st.got);
},
},
},
talents: [
{ install(battle, unit) { // 劝善
const sp = num(t0.sp), life = num(t0.interval, 10), ms = num(t0.move_speed, -0.8);
// "自身造成的伤害不会致命(至多使目标生命值降至1点)" — before any other saver: it never was lethal
battle.on('fatal', (c) => {
if (c.source === unit && c.unit && c.unit.side === 'enemy' && !c.prevented) c.prevented = true;
}, { owner: unit, priority: 100 });
// "成功造成伤害后若目标生命值小于等于1则为目标附加重伤"
battle.on('damaged', (c) => {
const e = c.target;
if (c.source !== unit || !e || e.side !== 'enemy' || !e.alive || e.bossPool || c.type === 'element' || e.hp > 1) return;
applyDying(battle, unit, e, { sp, life, ms });
}, { owner: unit });
// "被击杀后使击杀者回复数点技力" — the killer of a 重伤 enemy she marked (none for the 无来源 natural death)
battle.on('kill', (c) => {
const d = c.victim && typeof c.victim.findBuff === 'function' ? c.victim.findBuff(DYING) : null;
if (!d || d.source !== unit || !c.killer || c.killer.side !== 'ally') return;
giveSp(c.killer, num(d.data?.sp));
}, { owner: unit });
// SOL-X stage 2+: "对生命值低于一半的单位造成的伤害提升15%"
const hr = num(hidden.hp_ratio), ds = num(hidden.damage_scale, 1);
if (hr > 0 && ds !== 1) {
battle.on('hit', (c) => {
if (c.source === unit && c.target && c.target.side === 'enemy' && c.target.hpRatio < hr) c.dmg.mul *= ds;
}, { owner: unit });
}
} },
{ install(battle, unit) { // 清明: once per deployment below hp_ratio — dodgePhys prob, hpRegenRatio, `duration` s
const hr = num(t1.hp_ratio), p = num(t1.prob), regen = num(t1.hp_recovery_per_sec_by_max_hp_ratio), dur = num(t1.duration);
if (!(hr > 0) || !(dur > 0)) return;
battle.on('damaged', (c) => {
if (c.target !== unit || !up(unit) || unit.mem.qingmingAt === unit.deploySeq) return;
if (!(unit.hp < unit.s.maxHp * hr)) return;
unit.mem.qingmingAt = unit.deploySeq;
battle.addBuff(unit, { key: 'talent:saga:qingming', duration: dur, mods: { dodgePhys: p, hpRegenRatio: regen }, visible: true, tags: ['talent'] });
battle.fx('buff', { x: unit.x, y: unit.y, id: unit.id, kind: 'saga:qingming' });
}, { owner: unit });
} },
],
install(battle, unit) {
// SOL-X 何处寻驮?: 阻挡敌人时攻击力和防御力各+8%
const ma = num(tb.atk), md = num(tb.def);
if (ma || md) toggleBuff(battle, unit, 'trait:saga:block', () => unit.blocking.length > 0, { atkPct: ma, defPct: md });
// 怒目: one more attack on each target below attack@hp_ratio (checked before the hit) — a forced attack a tick later
if (picked !== S3) return;
const hp3 = num(b3['attack@hp_ratio']);
if (!(hp3 > 0)) return;
let half = null;
battle.on('beforeAttack', (c) => {
if (c.attacker !== unit || unit.mem.sagaExtra || !unit.skill?.active) return;
half = (c.targets || []).filter((e) => e && e.side === 'enemy' && e.hpRatio < hp3);
}, { owner: unit, priority: -100 });
battle.on('attack', (c) => {
if (c.attacker !== unit || unit.mem.sagaExtra) return;
const list = half;
half = null;
if (!list || !list.length) return;
const dep = unit.deploySeq;
battle.after(0, () => {
if (!up(unit) || unit.deploySeq !== dep || !unit.canAct || unit.s.flags.disarm) return;
const t = list.filter((e) => e.alive && canTargetEnemy(unit, e, unit.profile));
if (!t.length) return;
unit.mem.sagaExtra = true;
try { battle.forceAttack(unit, t, { noAmmo: true }); } finally { unit.mem.sagaExtra = false; }
}, { owner: unit });
}, { owner: unit });
},
};
},
};
+142
View File
@@ -0,0 +1,142 @@
// server/sim/content/kits/ops/op-shining.js — 闪灵 (char_147_shining) 自选 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_147_shining, 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) and PRTS 闪灵 (S2 备注
// "多个屏障吸收量可叠加,持续时间独立计算,优先消耗先生成的屏障,防御力提升效果不可叠加"; PRTS 术语释义 屏障 "若无特殊说明,
// 屏障可吸收全种类伤害").
// - Trait (医师) "恢复友方单位生命": the profession default (one heal on the lowest-HP% injured ally of her 3-3 range).
// Module PHY-Y 干枯剑鞘 "治疗地面单位时治疗量提升15%" (trait bb heal_scale): ×heal_scale on a heal whose target stands on
// a 地面 tile (unit.ground, as 赫默's PHY-Y); PHY-X “使徒” "治疗生命值低于50%的友方单位时治疗量提升15%" (heal_scale,
// hp_ratio): ×heal_scale when the target is below hp_ratio of its max HP before the heal (as 录武官's / 华法琳's PHY-X).
// - T1 黑恶魔的庇护 "攻击范围内的友方单位防御力+60": every ally (operators and summons: 友方单位) standing in her current
// attack range — her own tile included — DEF +def (flat). PHY-Y stage 2+ "防御力+100,地面单位防御力额外+40" (talent
// change def / def_lowland): def_lowland more on a 地面 tile. Refreshed every AURA s; two sources keep the strongest.
// - T2 法典 "攻击速度+10": ASPD +attack_speed. PHY-X stage 2+ "攻击速度+15,装备技能2时获得+25%攻击力,装备技能3时技力自然
// 回复速度+0.6/秒" (talent change attack_speed; the hidden module talent's atk / sp_recovery_per_sec): ATK +atk while 自动
// 掩护 is her picked skill, SP +sp_recovery_per_sec per second while 教条力场 is.
// - S1 信条 (MANUAL, 20 s, data DEFAULT — a heal skill: cast as she is about to heal an injured ally in range): ATK +atk,
// ASPD +attack_speed.
// - S2 自动掩护 (AUTO, 1 charge at rank 4 / 2 at rank 7, data DEFAULT): "下次治疗使目标获得一个持续N秒的屏障,屏障可以吸收相当于
// 闪灵攻击力X%的伤害,同时使目标防御力+Y%" — a "next heal" waits for her heal, as 推进之王's S2 waits for his attack (the
// owner's SP_FULL rule is for AUTO skills that act on nobody). The heal's target gets a 屏障 of atk_scale × her ATK for
// duration s (every damage type) and DEF +def while it holds: each barrier is its own buff (its own timer; older ones
// absorb first — buff order), the DEF bonus one buff per target, kept while one of her barriers lasts [ASSUMED: it goes
// with the last barrier, broken or expired — the 备注 only says it does not stack].
// - S3 教条力场 (MANUAL, 60 s, data DEFAULT): ATK +atk; every ally in her attack range DEF +def (%), refreshed every AURA s.
// Medic: heals only (dmgType heal), ranged, no anti-air question; ground enemies target her normally (no 起飞 / 迷彩 / 隐匿).
import { num, talentBb, moduleBb, traitBb, moduleOn, skillRec, statBuff, up } from '../shared/tier1.js';
const S1 = 'skchr_shining_1';
const S2 = 'skchr_shining_2';
const S3 = 'skchr_shining_3';
const PHY_Y = 'uniequip_002_shining';
const PHY_X = 'uniequip_003_shining';
/** Aura refresh period and buff life (s): an ally leaving the range loses the buff within one refresh (tier-4 AURA). */
const AURA = 0.2;
const AURA_DUR = 0.25;
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const pickedSkill = (chess) => chess?.skill?.skillId ?? null;
/**
* Keep buff `key` on every ally standing in `unit`'s current attack range (operators and summons, its own tile included;
* never a 孤立 unit — Battle.alliesInGrid) while `on()` holds, refreshed every AURA s; it lapses AURA_DUR s after the
* source stops refreshing it. Several sources of the same effect keep the strongest `value` (same-named buffs).
* Shared with 夜莺's kit (op-cgbird.js).
* @param {object} battle
* @param {object} unit the source
* @param {{ key: string, value: number, mods: object|((ally: object) => object), on?: () => boolean }} o
*/
export function rangeAura(battle, unit, { key, value, mods, on = () => true }) {
battle.every(AURA, () => {
if (!up(unit) || !on()) return;
for (const a of battle.alliesInGrid(unit)) {
const cur = a.findBuff(key);
if (cur && cur.source !== unit && cur.source?.alive && (cur.data?.v ?? 0) > value && cur.timeLeft > 0.05) continue;
battle.addBuff(a, { key, duration: AURA_DUR, mods: typeof mods === 'function' ? mods(a) : mods, source: unit, data: { v: value }, tags: ['aura'] });
}
}, { owner: unit, immediate: true });
}
/**
* A 屏障 on `target` (闪灵 S2 自动掩护, 夜莺 S2 法术护盾): `shield` HP for `duration` s — of `shieldType` damage only when
* given (damage.js absorbShields), else every type — plus the buff `statKey` (`mods`) while any barrier `key` lasts. PRTS
* 备注 of both skills: "多个屏障吸收量可叠加,持续时间独立计算,优先消耗先生成的屏障,…效果不可叠加" — each barrier is its own
* buff (refresh 'independent': its own timer; absorbShields walks the buffs oldest first), the stat bonus one buff whose
* life follows the barriers (it ends when the last one breaks or expires). Shared with 夜莺's kit (op-cgbird.js).
* @param {object} battle
* @param {object} source the operator
* @param {object} target
* @param {{ key: string, statKey: string, shield: number, duration: number, mods: object, shieldType?: string|null }} o
*/
export function grantBarrier(battle, source, target, { key, statKey, shield, duration, mods, shieldType = null }) {
if (!target || !target.alive || !(shield > 0) || !(duration > 0)) return;
const sync = () => { if (!target.buffs.some((b) => b.key === key)) battle.removeBuff(target, statKey); };
battle.addBuff(target, {
key, refresh: 'independent', maxStacks: 99, duration, shield, shieldType, source, visible: true, onRemove: sync, onExpire: sync,
});
battle.addBuff(target, { key: statKey, refresh: 'extend', duration, mods, source });
battle.fx('shield', { x: target.x, y: target.y, id: target.id, from: source.id });
}
export default {
char_147_shining: (bb, chess) => {
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s2 = skillRec(chess, S2);
const t0 = talentBb(chess, 0); // 黑恶魔的庇护: def (PHY-Y stage 2+: def 100, def_lowland)
const t1 = talentBb(chess, 1); // 法典: attack_speed (PHY-X stage 2+: 15)
const hidden = moduleBb(chess); // PHY-X stage 2+: atk (with S2 picked), sp_recovery_per_sec (with S3 picked)
const tb = traitBb(chess); // PHY-Y: heal_scale; PHY-X: heal_scale, hp_ratio
const mod = moduleOn(chess) ? chess.module.id : null;
const picked = pickedSkill(chess);
return {
skills: {
[S1]: { kind: 'duration', heal: true, mods: { atkPct: num(b1.atk), aspd: num(b1.attack_speed) } },
[S2]: {
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
heal: true,
attack: { healScale: 1 }, // the next heal (ai.js doHeal calls onHit per heal target)
onHit({ battle, unit, target }) {
if (!target || target.side !== 'ally') return;
grantBarrier(battle, unit, target, {
key: 'shining:barrier', statKey: 'shining:barrierDef', shield: unit.s.atk * num(b2.atk_scale),
duration: num(b2.duration), mods: { defPct: num(b2.def) },
});
},
},
[S3]: { kind: 'duration', heal: true, mods: { atkPct: num(b3.atk) } },
},
talents: [
{ install(battle, unit) { // 黑恶魔的庇护: allies in her attack range DEF +def; PHY-Y stage 2+: 地面 units +def_lowland
const d = num(t0.def), low = num(t0.def_lowland);
if (!(d > 0) && !(low > 0)) return;
rangeAura(battle, unit, { key: 'talent:shining:def', value: d + low, mods: (a) => ({ defFlat: d + (a.ground ? low : 0) }) });
} },
{ install(battle, unit) { // 法典: ASPD +attack_speed; PHY-X stage 2+: ATK +atk with S2, SP +0.6/s with S3
statBuff(battle, unit, 'talent:shining:aspd', { aspd: num(t1.attack_speed) });
if (picked === S2) statBuff(battle, unit, 'talent:shining:s2', { atkPct: num(hidden.atk) });
if (picked === S3) statBuff(battle, unit, 'talent:shining:s3', { spRecoveryFlat: num(hidden.sp_recovery_per_sec) });
} },
],
install(battle, unit) {
// 教条力场: allies in her attack range DEF +def % while the skill runs
if (picked === S3 && num(b3.def) > 0) {
rangeAura(battle, unit, { key: 'skill:shining:def', value: num(b3.def), mods: { defPct: num(b3.def) }, on: () => !!unit.skill?.active });
}
// module traits: PHY-Y heals on 地面 units, PHY-X heals on allies below hp_ratio, ×heal_scale
const hs = num(tb.heal_scale, 1);
if (!mod || hs === 1) return;
const boosted = mod === PHY_X ? (t) => t.hpRatio < num(tb.hp_ratio) : mod === PHY_Y ? (t) => !!t.ground : null;
if (!boosted) return;
battle.on('heal', (ctx) => {
if (ctx.source !== unit || ctx.opts?.regen || !ctx.target || !boosted(ctx.target)) return;
ctx.amount *= hs;
}, { owner: unit });
},
};
},
};
+13 -7
View File
@@ -5,7 +5,8 @@
// → dodge (phys/arts, canDodge) → mitigation (phys: DEF, arts: RES, true: none)
// → × source dmgDealtMul (× phys/artsDealtMul) × target dmgTakenMul (not for 元素伤害) × type-taken mul × dmg.mul
// → 限伤 (leaders in boss / hidden battles: a hit of ceil(final) ≥ BOSS_HIT_LIMIT is cancelled, see leaderHitCancelled)
// → shields (hit-negating barriers first, then HP shields) → HP loss (boss pool routing) → 'damaged' hook
// → shields (hit-negating barriers first, then HP shields; a typed one — buff `shieldType` — only its damage type)
// → HP loss (boss pool routing) → 'damaged' hook
// → SP-on-hurt / TAKE_DAMAGE trigger → fatal/kill.
// Damage-dealt stats (the source's `stats.dmg`, the player's `damageDealt`) count only HP removed from the other side:
// self and friendly damage (a 源石溶剂 drain, an operator's own 流失) is the target's `taken` and keeps the kill credit.
@@ -180,14 +181,19 @@ export function leaderHitCancelled(battle, target, amount) {
return true;
}
/** Absorb damage with shields on `target`. Returns the remaining amount. */
export function absorbShields(battle, target, amount) {
/**
* Absorb damage with shields on `target`. Returns the remaining amount. `type` = the damage type: a shield buff with a
* `shieldType` absorbs only that type (夜莺 S2 "屏障能吸收…法术伤害"); one without absorbs every type (PRTS 术语释义 屏障
* "若无特殊说明,屏障可吸收全种类伤害"). Older shields first (buff order: "优先消耗先生成的屏障").
*/
export function absorbShields(battle, target, amount, type = null) {
if (amount <= 0) return 0;
let changed = false;
let rest = amount;
const absorbs = (b) => !b.shieldType || b.shieldType === type;
for (let i = 0; i < target.buffs.length && rest > 0; i++) {
const b = target.buffs[i];
if (b.shieldHits > 0) {
if (b.shieldHits > 0 && absorbs(b)) {
b.shieldHits--;
rest = 0;
if (b.shieldHits <= 0 && !(b.shield > 0) && !b.mods && !b.flags) { battle._removeBuffAt(target, i); i--; }
@@ -197,7 +203,7 @@ export function absorbShields(battle, target, amount) {
}
for (let i = 0; i < target.buffs.length && rest > 0; i++) {
const b = target.buffs[i];
if (b.shield > 0) {
if (b.shield > 0 && absorbs(b)) {
const take = Math.min(b.shield, rest);
b.shield -= take;
rest -= take;
@@ -247,7 +253,7 @@ export function dealDamage(battle, source, target, dmgIn) {
// recognise their own (tagged) damage — never re-create such a loss with a fresh loseHp.
if (ts.flags.hitCount || ts.flags.hitCountArts) {
const counts = !(ts.flags.hitCountArts && !ts.flags.hitCount && type === 'phys');
return applyHpLoss(battle, source, target, absorbShields(battle, target, counts ? 1 : 0), dmg);
return applyHpLoss(battle, source, target, absorbShields(battle, target, counts ? 1 : 0, type), dmg);
}
// 无来源 damage (element bursts) takes nothing from its source's stats; the source still gets the credit below
const ss = source && source.s && !dmg.sourceless ? source.s : null;
@@ -267,7 +273,7 @@ export function dealDamage(battle, source, target, dmgIn) {
// 限伤: a leader's hit of ≥ BOSS_HIT_LIMIT in a boss / hidden battle is cancelled before it reaches shields / HP — what
// ran before it (the attack, its SP, `hit` hook effects, separate element 损伤) stays; nothing after it happens
if (final > 0 && leaderHitCancelled(battle, target, final)) return 0;
final = absorbShields(battle, target, final);
final = absorbShields(battle, target, final, type);
return applyHpLoss(battle, source, target, final, dmg);
}
+1
View File
@@ -21,6 +21,7 @@
// noHeal bool (cannot be healed by others) blockFly bool onHitStatus {key, duration, value}
// dmgMul(battle, unit, target) → number afterHit(battle, unit, target, {dealt,x,y})
// canAttack(battle, unit) → bool afterAttack(battle, unit, targets)
// skipEnemy(enemy) → bool (an enemy the unit never selects — targeting.js canTargetEnemy; 嵯峨 "不攻击重伤单位")
// hitsFn(battle, unit) → n install(battle, unit) — per-unit hooks, called once at setup
// dollNoAttack bool (傀儡师: its <替身> makes no normal attack and casts no skill — 归溟幽灵鲨, kit trait)
// tb — the unit's trait blackboard (data `trait.bb`), used for tunables (module upgrades included on elites)
+6 -1
View File
@@ -74,7 +74,11 @@ export function enemyStealthed(e) {
return false;
}
/** Can `attacker` (ally) target enemy `e` at all (ignoring range)? */
/**
* Can `attacker` (ally) target enemy `e` at all (ignoring range)? A profile's own exclusion `skipEnemy(e)` (a kit trait:
* 嵯峨 劝善 "嵯峨不攻击重伤单位") keeps such an enemy out of the unit's normal targets, the enemies it blocks
* (blockedTargets) and the target condition of its skill triggers (they pass `unit.profile`).
*/
export function canTargetEnemy(attacker, e, profile) {
if (!e.alive || e.hidden || !e.deployed) return false;
const f = e.s.flags;
@@ -82,6 +86,7 @@ export function canTargetEnemy(attacker, e, profile) {
if (f.stealth && enemyStealthed(e)) return false;
if (e.isFlying && !(profile && profile.canHitFly)) return false;
if (profile && profile.groundOnly && e.isFlying) return false;
if (profile && typeof profile.skipEnemy === 'function' && profile.skipEnemy(e)) return false;
return true;
}
+303
View File
@@ -0,0 +1,303 @@
// test/content/op_cgbird.test.js — the 自选 operator kit of 夜莺 (char_179_cgbird, 6★ 群愈师; kit
// server/sim/content/kits/ops/op-cgbird.js) and her summon 幻影 (token_10003_cgbird_bird), fielded the production way (a DIY
// slot + its `diy` pick, simdata getDiy; the 幻影 as a board token piece of hers) in every form: tiers 5 / 6, normal (E2 Lv1,
// skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, RIN-X “封闭的希望” or RIN-Y “莺歌” at stage 1
// (tier 5) / 3 (tier 6). Every number is read back from data/backups.json; the fidelity checklist of kits/README.md.
// Run: node --test test/content/op_cgbird.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks, diyRecord } from '../../shared/diy.js';
import { attackRangeGrid } from '../../shared/loadoutRecord.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 CGBIRD = 'char_179_cgbird';
const PHANTOM = 'token_10003_cgbird_bird';
const FORMS = BACKUPS.units[CGBIRD].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const RINX = 'uniequip_002_cgbird', RINY = 'uniequip_003_cgbird';
const S1 = 'skcom_heal_up[3]', S2 = 'skchr_cgbird_2', S3 = 'skchr_cgbird_3';
const statusOf = (tier, elite) => (elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0');
const formOf = (tier, elite) => FORMS[statusOf(tier, elite)];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
const tokenVariant = (tier, elite, mod) => {
const v = BACKUPS.tokens[PHANTOM].variants[`${CGBIRD}@${statusOf(tier, elite)}`];
return mod && v.byModule?.[mod] ? { ...v, ...v.byModule[mod] } : v;
};
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const ENEMIES = { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e9, speed: 0, mass: 0 }) };
const ALLIES = { test_melee_a: chessRec({ id: 'test_melee_a', profession: 'WARRIOR', stats: { maxHp: 10000, def: 100, res: 10, atk: 0 }, skill: null }) };
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, RINX, RINY].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/**
* 夜莺 as uid 1 on the 高台 tile (11,2) facing RIGHT — her y-2: rows 10–12, cols 1–4; RIN-X adds (11,5); 圣域's y-4 adds
* (11,5) and (9,1)–(9,3) —, allies (melee, no skill) at (11,3) uid 2, (10,3) uid 3, (12,3) uid 4, (10,4) uid 5 (all in
* y-2), (11,5) uid 6 (RIN-X / y-4 only), (9,3) uid 7 (y-4 only), (11,8) uid 8 (outside); `phantom` = a board 幻影 of hers.
*/
function field({ tier = 5, elite = false, mod = null, skill = 0, seed = 5, phantom = null, dp = 0 } = {}) {
const ally = (uid, row, col) => ({ uid, chessId: 'test_melee_a', row, col });
const h = makeBattle({
defs: { enemies: ENEMIES, chess: ALLIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999, dpInit: dp }, hooks: ['damaged', 'heal', 'attack', 'skillStart', 'skillEnd', 'death', 'deploy'], captureNoisy: true,
units: [
{ uid: 1, diy: { slot: SLOT[tier], charId: CGBIRD, skillIndex: skill, uniEquipId: mod }, elite, row: 11, col: 2 },
ally(2, 11, 3), ally(3, 10, 3), ally(4, 12, 3), ally(5, 10, 4), ally(6, 11, 5), ally(7, 9, 3), ally(8, 11, 8),
...(phantom ? [{ uid: 9, kind: 'token', tokenId: PHANTOM, ownerUid: 1, row: phantom[0], col: phantom[1] }] : []),
],
});
h.step();
const u = h.unit(1);
return { h, u, a: (uid) => h.unit(uid), bird: phantom ? h.b.allyUnits.find((t) => t.kind === 'token' && t.uid === 9) : null };
}
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
const rangeOf = (tier, elite, mod) => {
const rec = diyRecord(SLOT[tier], { charId: CGBIRD, skillIndex: 0, uniEquipId: mod }, { elite, data: { chess: CHESS, backups: BACKUPS } });
return attackRangeGrid(rec);
};
test('夜莺 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, y-2 (RIN-X: its grid), 3 heals (RIN-Y: 4), no 特质', () => {
assert.equal(OPERATOR_KITS[CGBIRD], KITS[CGBIRD]);
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], [CGBIRD, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.res, u.base.aspd],
[form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def, form.stats.res + (m?.attr.res ?? 0), form.stats.aspd + (m?.attr.aspd ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.profile.dmgType, u.profile.heal?.mode, u.profile.heal?.count, u.profile.attack], ['heal', 'multi', elite && mod === RINY ? 4 : 3, 'ranged'], `${label(f)}: 群愈师`);
const rinx = elite && mod === RINX;
const mg = rinx ? modOf(tier, RINX).talentChanges.find((t) => t.talentIndex === -1).rangeGrid : form.rangeGrid;
assert.deepEqual(u.liveRangeGrid, mg, `${label(f)}: range`);
assert.deepEqual(u.liveRangeGrid, rangeOf(tier, elite, mod), `${label(f)}: the range the card draws (attackRangeGrid)`);
assert.equal(mg.length, rinx ? 13 : 12, `${label(f)}: y-2 (+ [0,3])`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['emptyShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth && !u.s.flags.untargetable, `${label(f)}: enemies target her`);
done(h);
}
}
assert.deepEqual([modOf(5, RINX).attr, modOf(6, RINX).attr, modOf(5, RINY).attr, modOf(6, RINY).attr], [{ atk: 40, res: 5 }, { atk: 65, res: 5 }, { maxHp: 160, aspd: 5 }, { maxHp: 220, aspd: 7 }]);
});
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(CGBIRD));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(CGBIRD), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: CGBIRD, skillIndex: 1, uniEquipId: RINY } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: CGBIRD, skillIndex: 1, uniEquipId: RINY } } });
});
test('群愈师 heals 3 allies at once; RIN-Y “莺歌” 4 (stages 1 and 3)', () => {
for (const f of [[5, false, null], [5, true, RINY], [6, true, RINY], [6, true, RINX]]) {
const [tier, elite, mod] = f;
const { h, u, a } = field({ tier, elite, mod, skill: 0 });
for (const uid of [2, 3, 4, 5]) a(uid).hp = 1000 + uid;
const n0 = h.hooksOf('attack').length;
assert.ok(h.runUntil(() => h.hooksOf('attack').slice(n0).some((c) => c.attacker === u), 6), label(f));
const at = h.hooksOf('attack').slice(n0).find((c) => c.attacker === u);
assert.equal(new Set(at.targets.map((t) => t.uid)).size, elite && mod === RINY ? 4 : 3, `${label(f)}: heals per attack`);
done(h);
}
});
test('S1 治疗强化·γ型 (MANUAL, data DEFAULT): 30 s of ATK +55 % / +70 %, cast on an injured ally in range; 35 / 32 SP from 20', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
const { h, u, a } = field({ tier, elite, mod: elite ? RINX : null, skill: 0 });
assert.deepEqual([u.skill.rule, u.skill.spCost, sk.initSp, sk.duration, sk.bb.atk], ['DEFAULT', elite ? 32 : 35, 20, 30, elite ? 0.7 : 0.55], `T${tier}`);
u.skill.gainSp(999);
a(8).hp = 100; // outside her range: no cast
h.run(2);
assert.equal(u.skill.activations, 0, `T${tier}: nobody injured in range`);
a(2).hp = 100;
assert.ok(h.runUntil(() => u.skill.active, 5), `T${tier}: cast`);
approx(u.skill.timeLeft, 30, `T${tier}: 30 s`, 0.01);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
h.runUntil(() => !u.skill.active, 35);
approx(u.s.atk, u.base.atk, `T${tier}: back`);
done(h);
}
});
test('S2 法术护盾 (AUTO, data DEFAULT, 2 charges): the next heal gives each of its targets a 屏障 of 50 % / 60 % ATK for 3.5 / 4 s that absorbs arts damage only, and RES +10 / +15 while it holds', () => {
for (const f of [[5, false, null], [6, true, RINY]]) {
const [tier, elite, mod] = f;
const sk = skillOf(tier, elite, S2);
const { h, u, a } = field({ tier, elite, mod, skill: 1 });
assert.deepEqual([u.skill.rule, u.skill.maxCharges, u.skill.kind, sk.spCost, sk.bb.atk_scale, sk.bb.duration, sk.bb.magic_resistance],
['DEFAULT', 2, 'charges', 10, elite ? 0.6 : 0.5, elite ? 4 : 3.5, elite ? 15 : 10], label(f));
u.skill.gainSp(999);
h.run(2);
assert.equal(u.skill.activations, 0, `${label(f)}: nobody injured, no cast`);
const hurt = [2, 3, 4, 5].map(a);
hurt.forEach((x, i) => { x.hp = 1000 + i; });
assert.ok(h.runUntil(() => u.skill.activations === 1 && hurt.some((x) => x.findBuff('cgbird:barrier')), 5), `${label(f)}: cast with a heal`);
const got = hurt.filter((x) => x.findBuff('cgbird:barrier'));
const first = got.map((x) => x.findBuff('cgbird:barrier'));
assert.equal(got.length, elite && mod === RINY ? 4 : 3, `${label(f)}: every target of that heal`);
for (const x of got) {
const b = x.findBuff('cgbird:barrier');
approx(b.shield, u.s.atk * sk.bb.atk_scale, `${label(f)}: ${sk.bb.atk_scale * 100} % ATK`);
approx(b.duration, sk.bb.duration, `${label(f)}: ${sk.bb.duration} s`);
assert.equal(b.shieldType, 'arts', `${label(f)}: arts only`);
assert.deepEqual(x.findBuff('cgbird:barrierRes')?.mods, { resFlat: sk.bb.magic_resistance }, `${label(f)}: RES bonus`);
}
const x = got[0], b = x.findBuff('cgbird:barrier');
const hp0 = x.hp, sh0 = b.shield;
h.b.dealDamage(null, x, { amount: 50, type: 'phys', canDodge: false });
assert.ok(x.hp < hp0, `${label(f)}: physical damage passes`);
approx(b.shield, sh0, `${label(f)}: the barrier untouched by physical damage`);
h.b.dealDamage(null, x, { amount: 20, type: 'true' });
approx(b.shield, sh0, `${label(f)}: … and by true damage`);
const hp1 = x.hp;
h.b.dealDamage(null, x, { amount: 30, type: 'arts', canDodge: false });
approx(x.hp, hp1, `${label(f)}: arts damage absorbed`);
assert.ok(b.shield < sh0, `${label(f)}: the barrier took it`);
h.b.dealDamage(null, x, { amount: 1e6, type: 'arts', canDodge: false, resIgnorePct: 1 });
assert.equal(x.findBuff('cgbird:barrier'), null, `${label(f)}: broken`);
assert.equal(x.findBuff('cgbird:barrierRes'), null, `${label(f)}: the RES bonus goes with it`);
h.run(sk.bb.duration + 0.1);
got.forEach((y, i) => assert.ok(!y.buffs.includes(first[i]), `${label(f)}: the first heal's barrier expired`));
done(h);
}
});
test('S3 圣域 (MANUAL, data ACTIVE_RANGE on y-4): cast for an injured ally inside y-4 only; 60 s, range y-4, ATK +40 % / +50 %, allies in it RES ×1.9 / ×2.05 and 15 % / 20 % arts dodge; all back after', () => {
for (const f of [[5, false, null], [6, true, null], [6, true, RINX]]) {
const [tier, elite, mod] = f;
const sk = skillOf(tier, elite, S3);
const { h, u, a } = field({ tier, elite, mod, skill: 2 });
assert.deepEqual([u.skill.rule, sk.duration, sk.spCost, sk.rangeId, sk.bb.atk, sk.bb.magic_resistance, sk.bb.prob],
['ACTIVE_RANGE', 60, 120, 'y-4', elite ? 0.5 : 0.4, elite ? 1.05 : 0.9, elite ? 0.2 : 0.15], label(f));
assert.deepEqual(sk.trigger.customRangeGrid, sk.rangeGrid, `${label(f)}: the trigger grid is its running range`);
const base = u.liveRangeGrid;
u.skill.gainSp(999);
a(8).hp = 100; // outside y-4
h.run(2);
assert.equal(u.skill.activations, 0, `${label(f)}: no injured ally in y-4`);
a(7).hp = 100; // (9,3): y-4 only
assert.ok(h.runUntil(() => u.skill.active, 2), `${label(f)}: cast for the ally 2 rows down`);
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `${label(f)}: y-4 while it runs`);
h.run(0.3);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `${label(f)}: ATK`);
for (const uid of [2, 6, 7]) assert.deepEqual(a(uid).findBuff('skill:cgbird:sanctuary')?.mods, { resMul: 1 + sk.bb.magic_resistance, dodgeArts: sk.bb.prob }, `${label(f)}: uid ${uid} in y-4`);
assert.deepEqual(u.findBuff('skill:cgbird:sanctuary')?.mods, { resMul: 1 + sk.bb.magic_resistance, dodgeArts: sk.bb.prob }, `${label(f)}: herself`);
assert.equal(a(8).findBuff('skill:cgbird:sanctuary'), null, `${label(f)}: outside`);
// RES = (10 + 15 talent) × (1 + v) on an ally (base 10)
approx(a(7).s.res, Math.min(100, (10 + 15) * (1 + sk.bb.magic_resistance)), `${label(f)}: RES`);
approx(a(7).s.dodgeArts, sk.bb.prob, `${label(f)}: arts dodge`);
u.skill.end('test');
h.run(0.3);
assert.deepEqual(u.liveRangeGrid, base, `${label(f)}: own range back`);
for (const uid of [2, 6, 7]) assert.equal(a(uid).findBuff('skill:cgbird:sanctuary'), null, `${label(f)}: uid ${uid} after`);
done(h);
}
});
test('T1 白恶魔的庇护: allies in her range RES +15 (herself too, not outside); RIN-X: its range tile too, stage 3 also 受到的治疗效果 ×1.05', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u, a } = field({ tier, elite, mod, skill: 0 });
h.run(0.3);
const rinx = elite && mod === RINX, x3 = rinx && tier === 6;
const want = x3 ? { resFlat: 15, healingTakenMul: 1.05 } : { resFlat: 15 };
for (const uid of [2, 3, 4, 5]) assert.deepEqual(a(uid).findBuff('talent:cgbird:res')?.mods, want, `${label(f)}: uid ${uid}`);
assert.deepEqual(u.findBuff('talent:cgbird:res')?.mods, want, `${label(f)}: herself`);
assert.deepEqual(a(6).findBuff('talent:cgbird:res')?.mods ?? null, rinx ? want : null, `${label(f)}: (11,5) — the RIN-X tile`);
for (const uid of [7, 8]) assert.equal(a(uid).findBuff('talent:cgbird:res'), null, `${label(f)}: uid ${uid} outside`);
approx(a(2).s.res, 10 + 15, `${label(f)}: RES`);
if (x3) {
a(2).hp = 1000;
const n0 = h.hooksOf('heal').length;
assert.ok(h.runUntil(() => h.hooksOf('heal').slice(n0).some((c) => c.target === a(2) && c.source === u), 6));
const c = h.hooksOf('heal').slice(n0).find((x) => x.target === a(2) && x.source === u);
approx(c.amount, u.s.atk * 1.05, `${label(f)}: healing received ×1.05`);
}
done(h);
}
});
test('幻影 (T2): the token of her form — HP 3000 / 4989, RES 75, blocks nothing, taunt 1, no attack; 30 % physical dodge (RIN-Y stage 3: 40 %), loses 3 % of its max HP each second', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u, bird } = field({ tier, elite, mod, skill: 0, phantom: [12, 8] }); // outside her range: no heals on it
const v = tokenVariant(tier, elite, mod);
assert.ok(bird && bird.alive, `${label(f)}: deployed with the board`);
assert.equal(bird.ownerUnit, u, `${label(f)}: hers`);
assert.deepEqual([bird.base.maxHp, bird.base.res, bird.s.blockCnt, bird.s.taunt, bird.profile.noAttack], [v.stats.maxHp, 75, 0, 1, true], `${label(f)}: stats`);
assert.equal(v.stats.maxHp, elite ? 4989 : 3000);
const y3 = elite && tier === 6 && mod === RINY;
approx(bird.s.dodgePhys, y3 ? 0.4 : 0.3, `${label(f)}: physical dodge`);
assert.equal(bird.s.dodgeArts, 0, `${label(f)}: no arts dodge`);
const hp0 = bird.hp;
h.run(3.02);
approx(hp0 - bird.hp, 3 * 0.03 * bird.s.maxHp, `${label(f)}: 3 × 3 % in 3 s`, 1e-6);
assert.ok(h.hooksOf('damaged').some((c) => c.target === bird && c.dmg?.tags?.includes('hpLoss')), `${label(f)}: a 流失`);
// enemies: a 30 % dodge on physical hits, none on arts
let dodged = 0;
for (let i = 0; i < 400; i++) if (h.b.dealDamage(null, bird, { amount: 1, type: 'phys' }) === 0) dodged++;
assert.ok(Math.abs(dodged / 400 - (y3 ? 0.4 : 0.3)) < 0.08, `${label(f)}: ${dodged} / 400 dodged`);
done(h);
}
});
test('幻影: each 夜莺 deployment adds 2 (RIN-Y stage 3: 3) to her stock, at most 3; a fallen 幻影 comes back on its tile 20 s later using one and paying 5 DP; it stays when she leaves', () => {
for (const f of [[5, true, RINY], [6, true, RINY], [6, false, null]]) {
const [tier, elite, mod] = f;
const { h, u, bird } = field({ tier, elite, mod, skill: 0, phantom: [12, 8], dp: 50 });
const cnt = elite && tier === 6 && mod === RINY ? 3 : 2;
assert.equal(u.mem.phantomStock, cnt, `${label(f)}: her deployment gave ${cnt}; the board piece used none`);
const tile = [bird.tileR, bird.tileC];
h.b.kill(bird, null);
assert.ok(!bird.alive && !bird.removed, `${label(f)}: fallen, kept`);
h.run(19.5);
assert.ok(!bird.alive, `${label(f)}: not before its 20 s`);
const dp0 = h.b.players[0].dp;
assert.ok(h.runUntil(() => bird.alive, 1), `${label(f)}: back after 20 s`);
assert.deepEqual([bird.tileR, bird.tileC], tile, `${label(f)}: on its own tile`);
assert.equal(u.mem.phantomStock, cnt - 1, `${label(f)}: one used`);
approx(dp0 - h.b.players[0].dp, 5, `${label(f)}: its DP cost`);
approx(bird.hp, bird.s.maxHp, `${label(f)}: full HP`, 0.05);
approx(bird.s.dodgePhys, tokenVariant(tier, elite, mod).talents.at(-1).bb.prob, `${label(f)}: its talent again`);
// she leaves: the deployed 幻影 stays; she comes back: +cnt (cap 3)
h.b.retreat(u);
h.step();
assert.ok(bird.alive, `${label(f)}: stays when she leaves`);
h.b.redeploy(u, { free: true });
assert.equal(u.mem.phantomStock, Math.min(3, cnt - 1 + cnt), `${label(f)}: +${cnt}, at most 3`);
// none held: a fallen 幻影 waits
u.mem.phantomStock = 0;
h.b.kill(bird, null);
h.run(30);
assert.ok(!bird.alive, `${label(f)}: no 幻影 held, none comes`);
u.mem.phantomStock = 1;
assert.ok(h.runUntil(() => bird.alive, 1), `${label(f)}: comes as soon as one is held`);
assert.equal(u.mem.phantomStock, 0);
done(h);
}
});
test('幻影 without DP waits for it; 夜莺 heals her 幻影 (an injured 友方单位 in range) and her S2 barrier reaches it', () => {
const { h, u, bird } = field({ tier: 6, elite: true, mod: RINY, skill: 1, phantom: [12, 4], dp: 0 });
// the drain makes it an injured ally of her range
u.skill.gainSp(999);
assert.ok(h.runUntil(() => bird.findBuff('cgbird:barrier'), 6), 'barrier on the 幻影 with the next heal');
assert.ok(h.hooksOf('heal').some((c) => c.source === u && c.target === bird), 'healed');
h.b.kill(bird, null);
h.run(25);
assert.ok(!bird.alive, 'no DP: still down');
h.b.players[0].dp = 5;
assert.ok(h.runUntil(() => bird.alive, 1), 'back once the DP is there');
approx(h.b.players[0].dp, 0, 'paid 5');
done(h);
});
+379
View File
@@ -0,0 +1,379 @@
// test/content/op_saga.test.js — the 自选 operator kit of 嵯峨 (char_362_saga, 6★ 尖兵; kit
// server/sim/content/kits/ops/op-saga.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, SOL-Y 身向云泥 or
// SOL-X 何处寻驮? at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json; the fidelity
// checklist of kits/README.md item by item (劝善's 重伤 per PRTS 术语释义 重伤 / the talent and S2 备注).
// Run: node --test test/content/op_saga.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 { DYING, S2_CHECK, isDying } from '../../server/sim/content/kits/ops/op-saga.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 SAGA = 'char_362_saga';
const FORMS = BACKUPS.units[SAGA].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const SOLY = 'uniequip_002_saga', SOLX = 'uniequip_003_saga';
const S1 = 'skcom_charge_cost[3]', S2 = 'skchr_saga_2', S3 = 'skchr_saga_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_fly: dummy('enemy_fly', { motion: 'FLY' }), enemy_weak: dummy('enemy_weak', { hp: 100 }),
enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.6, mass: 0 }),
};
// a regenerating enemy (data/enemies.json has two: 200 HP/s for enemy_1061_zomshd)
ENEMIES.enemy_regen = { ...dummy('enemy_regen', { hp: 100 }), stats: { ...dummy('enemy_regen', { hp: 100 }).stats, hpRecoveryPerSec: 200 } };
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, SOLY, SOLX].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/** A battle with 嵯峨 as uid 1 at (row, col) facing RIGHT, plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], seed = 5, dp = 0 } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999, dpInit: dp }, hooks: ['damaged', 'skillStart', 'skillEnd', 'kill', 'death', 'spGain', 'attack'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: SAGA, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const hitsBy = (h, u, from = 0) => h.hooksOf('damaged').slice(from).filter((c) => c.source === u && c.target?.side === 'enemy');
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
test('嵯峨 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 1-1, blocks 2, melee ground-only, no 特质', () => {
assert.equal(OPERATOR_KITS[SAGA], KITS[SAGA]);
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], [SAGA, 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.base.bat, typeof u.profile.skipEnemy], [2, 'melee', 'phys', false, 1.05, 'function'], `${label(f)}: 尖兵`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['emptyShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
done(h);
}
}
// E2 Lv1 1609 / 434 / 297, E2 Lv60 2004 / 498 / 347; SOL-Y +75 / +45 / +15 → +125 / +55 / +20, SOL-X +50 / +28 → +70 / +44
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], [1609, 434, 2004, 498]);
assert.deepEqual([modOf(5, SOLY).attr, modOf(6, SOLY).attr, modOf(5, SOLX).attr, modOf(6, SOLX).attr],
[{ maxHp: 75, atk: 45, def: 15 }, { maxHp: 125, atk: 55, def: 20 }, { atk: 50, def: 28 }, { atk: 70, def: 44 }]);
});
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(SAGA));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(SAGA), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: SAGA, skillIndex: 1, uniEquipId: SOLX } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[5]]: { charId: SAGA, skillIndex: 1, uniEquipId: SOLX } } });
});
test('melee, ground-only: no attack on a flyer in her range; a ground enemy there is attacked (physical, 100 % ATK)', () => {
const { h, u } = field({ tier: 5, skill: 0 });
const fly = h.spawn('enemy_fly', { pos: [10, 6] });
h.run(3);
assert.equal(hitsBy(h, u).filter((c) => c.target === fly).length, 0, 'flyer untouched');
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(h.runUntil(() => hitsBy(h, u).some((c) => c.target === e), 3));
const c = hitsBy(h, u).find((x) => x.target === e);
assert.deepEqual([c.type, c.dmg.isAttack], ['phys', true]);
approx(c.amount, u.s.atk, '100 % ATK');
done(h);
});
test('S1 冲锋号令·γ型 (AUTO): +12 DP as soon as its SP is full — no enemy needed (SP_FULL); 41 / 38 SP from 12 / 14', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
const { h, u } = field({ tier, elite, mod: elite ? SOLX : null, skill: 0 });
assert.deepEqual([u.skill.rule, u.skill.spCost, sk.initSp, sk.bb.cost], ['SP_FULL', elite ? 38 : 41, elite ? 14 : 12, 12], `T${tier}`);
h.b.players[0].dp = 0;
h.run(sk.spCost - sk.initSp - 0.5);
assert.equal(u.skill.activations, 0, `T${tier}: not before`);
assert.ok(h.runUntil(() => u.skill.activations === 1, 1), `T${tier}: at full SP, no enemy`);
approx(h.b.players[0].dp, 12, `T${tier}: +12 DP`);
done(h);
}
});
test('S2 除恶 (MANUAL, data SKILL_RANGE on x-6, 2 charges): +4 DP; the cross\'s ground enemies take 280 % / 320 % ATK physical — at most 6, flyers and other tiles untouched', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
const { h, u } = field({ tier, elite, mod: elite ? SOLY : null, skill: 1 });
assert.deepEqual([u.skill.rule, u.skill.maxCharges, sk.rangeId, sk.bb.atk_scale, sk.bb.cost, sk.spCost, sk.initSp], ['SKILL_RANGE', 2, 'x-6', elite ? 3.2 : 2.8, 4, elite ? 16 : 18, elite ? 10 : 9], `T${tier}`);
assert.match(sk.desc, /最多6名地面敌人/);
const cross = [[12, 5], [11, 5], [10, 3], [10, 4], [10, 6], [10, 7], [9, 5]];
const ground = cross.map((pos) => h.spawn('enemy_dummy', { pos }));
const fly = h.spawn('enemy_fly', { pos: [8, 5] });
const diag = h.spawn('enemy_dummy', { pos: [11, 6] });
h.b.players[0].dp = 0;
const n0 = hitsBy(h, u).length;
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations === 1, 1), `T${tier}: cast (enemies in the cross)`);
const hit = hitsBy(h, u, n0).filter((c) => c.dmg.tags?.includes('saga:s2'));
assert.equal(hit.length, 6, `T${tier}: at most 6`);
assert.ok(hit.every((c) => ground.includes(c.target)), `T${tier}: ground enemies of the cross only`);
assert.equal(new Set(hit.map((c) => c.target)).size, 6, `T${tier}: six different`);
for (const c of hit) {
approx(c.amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`);
assert.deepEqual([c.type, c.dmg.isSkill, c.dmg.isAttack], ['phys', true, false], `T${tier}: a physical skill damage`);
}
assert.ok(!hit.some((c) => c.target === fly || c.target === diag), `T${tier}: flyer / diagonal untouched`);
approx(h.b.players[0].dp, 4, `T${tier}: +4 DP`);
assert.equal(u.skill.charges, 1, `T${tier}: one charge left`);
done(h);
}
});
test('S2 除恶: its trigger counts any enemy in the cross (a flyer too, 无视其不可选中) but hits ground ones only', () => {
const { h, u } = field({ tier: 6, elite: true, skill: 1 });
const fly = h.spawn('enemy_fly', { pos: [9, 5] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations === 1, 1), 'cast for a flyer alone in the cross');
assert.equal(hitsBy(h, u).filter((c) => c.target === fly).length, 0, 'but not hit');
done(h);
});
test('S2 除恶: a 重伤 enemy is selected (可选择重伤单位) and every hit enemy still 重伤 0.7 s later is killed by her (killer 嵯峨, +2 SP each)', () => {
const { h, u } = field({ tier: 6, elite: true, skill: 1 });
const sk = skillOf(6, true, S2);
const weak = h.spawn('enemy_weak', { pos: [10, 7] }); // in the cross, outside her 1-1
const front = h.spawn('enemy_weak', { pos: [10, 6] }); // in her 1-1
assert.ok(h.runUntil(() => isDying(front), 3), 'her attack: 重伤');
assert.deepEqual([front.hp, weak.hp], [1, 100]);
// 除恶 comes by itself once a charge is ready (SKILL_RANGE: enemies in the cross)
assert.ok(h.runUntil(() => u.skill.activations === 1, sk.spCost - sk.initSp + 1), 'cast');
const tc = h.b.time;
assert.ok(hitsBy(h, u).some((c) => c.target === front && c.dmg.tags?.includes('saga:s2')), '可选择重伤单位');
assert.ok(isDying(weak) && weak.hp === 1, 'the weak one: 1 HP, 重伤');
h.run(S2_CHECK - 0.1);
assert.ok(weak.alive && front.alive, 'not before 0.7 s');
assert.ok(h.runUntil(() => !weak.alive && !front.alive, 0.2), 'both killed at 0.7 s');
const kills = h.hooksOf('kill').filter((c) => c.victim === weak || c.victim === front);
assert.ok(kills.length === 2 && kills.every((c) => c.killer === u), 'killer: 嵯峨');
assert.equal(u.stats.kills, 2);
const talentSp = h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'talent' && c.t >= tc).reduce((n, c) => n + c.amount, 0);
approx(talentSp, 4, '2 × 2 SP');
done(h);
});
test('S3 怒目 (MANUAL, data ACTIVE_RANGE on 1-1 + 1): 20 s, ATK +70 % / +100 %, interval 1.05 + 0.5 s, range +1, every blocked enemy at once, +20 DP over the skill; all 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, row: 9, col: 5 });
assert.deepEqual([u.skill.rule, sk.duration, sk.bb.atk, sk.bb.base_attack_time, sk.bb.ability_range_forward_extend, sk.bb.cost, sk.bb.interval, sk.bb.value, sk.bb['attack@hp_ratio']],
['ACTIVE_RANGE', 20, elite ? 1 : 0.7, 0.5, 1, 1, 1, 20, 0.5], `T${tier}`);
assert.deepEqual(sk.trigger.customRangeGrid, [[0, 0], [0, 1], [0, 2]], `T${tier}: her 1-1 + 1`);
u.skill.gainSp(999);
const far = h.spawn('enemy_dummy', { pos: [9, 7] }); // two tiles ahead: the running range only
assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: cast for an enemy 2 tiles ahead`);
h.b.players[0].dp = 0;
assert.deepEqual(u.liveRangeGrid, [[0, 0], [0, 1], [0, 2]], `T${tier}: range +1`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
approx(u.s.interval, 1.55, `T${tier}: +0.5 s`);
assert.ok(h.runUntil(() => hitsBy(h, u).some((c) => c.target === far), 3), `T${tier}: reaches 2 tiles ahead`);
h.b.kill(far, null);
// two walkers blocked: one attack hits both
h.spawn('enemy_walk', { routeIndex: 0 });
h.spawn('enemy_walk', { routeIndex: 0, pos: [9, 9] });
assert.ok(h.runUntil(() => u.blocking.length === 2, 30), `T${tier}: blocking two`);
const n0 = h.hooksOf('attack').length;
assert.ok(h.runUntil(() => h.hooksOf('attack').slice(n0).some((c) => c.attacker === u), 3));
const at = h.hooksOf('attack').slice(n0).find((c) => c.attacker === u);
assert.equal(at.targets.length, 2, `T${tier}: every blocked enemy`);
h.runUntil(() => !u.skill.active, 25);
approx(h.b.players[0].dp, 20, `T${tier}: +20 DP in all`, 1e-6);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: 1-1 again`);
approx(u.s.interval, 1.05, `T${tier}: interval back`);
done(h);
}
});
test('S3 怒目: DP comes gradually (1 per second) and one more attack follows each attack on a target below half HP (checked before the hit), none above', () => {
const { h, u } = field({ tier: 6, elite: true, skill: 2 });
const low = h.spawn('enemy_dummy', { pos: [10, 6] });
low.hp = low.s.maxHp * 0.4;
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 1));
h.b.players[0].dp = 0;
h.run(5.5);
approx(h.b.players[0].dp, 5, '5 DP after 5.5 s', 1e-6);
// hits on the target, grouped by attack: an extra attack lands one tick after its attack
const groups = (hits) => {
const out = [];
for (const c of hits) { const g = out.at(-1); if (g && c.t - g.at(-1).t < 0.1) g.push(c); else out.push([c]); }
return out.map((g) => g.length);
};
const n0 = hitsBy(h, u).length;
h.run(6);
const low6 = groups(hitsBy(h, u, n0).filter((c) => c.target === low && c.dmg.isAttack));
assert.ok(low6.length >= 3, `${low6.length} attacks in 6 s (1.55 s apart)`);
assert.ok(low6.slice(0, -1).every((n) => n === 2), `every attack followed by one more: ${low6}`);
// above half: no extra attack
h.b.kill(low, null);
const high = h.spawn('enemy_dummy', { pos: [10, 6] });
high.hp = high.s.maxHp * 0.6;
const n1 = hitsBy(h, u).length;
h.run(5);
const high5 = groups(hitsBy(h, u, n1).filter((c) => c.target === high && c.dmg.isAttack));
assert.ok(high5.length >= 2 && high5.every((n) => n === 1), `one hit per attack: ${high5}`);
done(h);
});
test('T1 劝善: her damage leaves an enemy at 1 HP and 重伤 (move ×0.2, unblockable, 禁疗, no regeneration); she never attacks it; it dies 10 s later (无来源, no SP); a 重伤 enemy takes no second 重伤', () => {
for (const f of [[5, false, null], [6, true, SOLY]]) {
const { h, u } = field({ tier: f[0], elite: f[1], mod: f[2], skill: 2 });
const t0 = BACKUPS.units[SAGA].forms[f[1] ? '2/60/7/3' : '2/1/4/0'].talents[0].bb;
assert.deepEqual(t0, { move_speed: -0.8, sp: 2, interval: 10 });
const e = h.spawn('enemy_weak', { pos: [10, 6] });
assert.ok(h.runUntil(() => isDying(e), 3), `${label(f)}: 重伤`);
const at = h.b.time;
assert.deepEqual([e.alive, e.hp], [true, 1], `${label(f)}: 1 HP left`);
const d = e.findBuff(DYING);
assert.equal(d.source, u);
approx(d.mods.moveMul, 0.2, `${label(f)}: move −80 %`);
assert.equal(d.mods.hpRegen, -1e9, `${label(f)}: regeneration zeroed`);
assert.deepEqual(d.flags, { unblockable: true, healFree: true, noHeal: true }, `${label(f)}: 禁用近战 / 禁疗`);
approx(e.s.moveSpeed, e.base.moveSpeed * 0.2, `${label(f)}: move`);
assert.ok(e.s.hpRegen <= 0, `${label(f)}: no regeneration (a cancelling hpRegen)`);
assert.equal(h.b.heal(e, e, 50, { self: true }), 0, `${label(f)}: 禁疗`);
// she never attacks it again
const n0 = hitsBy(h, u).length, a0 = u.stats.attacks;
h.run(4);
assert.equal(hitsBy(h, u, n0).length, 0, `${label(f)}: no damage of hers`);
assert.equal(u.stats.attacks, a0, `${label(f)}: no attack`);
// no second 重伤
h.b.dealDamage(u, e, { amount: 1000, type: 'true' });
assert.equal(e.findBuff(DYING), d, `${label(f)}: the same 重伤`);
assert.equal(e.hp, 1, `${label(f)}: still not lethal`);
// 10 s: a forced, 无来源 death
const sp0 = u.skill.sp;
h.run(at + 10 - h.b.time - 0.1);
assert.ok(e.alive, `${label(f)}: alive before 10 s`);
assert.ok(h.runUntil(() => !e.alive, 0.2), `${label(f)}: dead at 10 s`);
const k = h.hooksOf('kill').find((c) => c.victim === e);
assert.deepEqual([k.killer, e.removeReason], [null, 'killed'], `${label(f)}: 无来源 kill`);
assert.equal(h.b.killed, 1, `${label(f)}: counted`);
assert.equal(h.hooksOf('spGain').filter((c) => c.reason === 'talent').length, 0, `${label(f)}: no SP for it`);
assert.ok(u.skill.sp >= sp0, `${label(f)}: (time SP only)`);
done(h);
}
});
test('T1 劝善: a 重伤 enemy regenerates nothing (生命回复速度归零) — one with 200 HP/s stays at 1 HP', () => {
const { h, u } = field({ tier: 5, skill: 2 });
const e = h.spawn('enemy_regen', { pos: [10, 6] });
assert.equal(e.s.hpRegen, 200, 'it regenerates');
assert.ok(h.runUntil(() => isDying(e), 3));
h.run(3);
assert.deepEqual([e.alive, e.hp], [true, 1], 'no HP back while 重伤');
assert.equal(hitsBy(h, u).filter((c) => c.target === e).length, 1, 'one hit of hers, then none');
done(h);
});
test('T1 劝善: whoever kills a 重伤 enemy gets 2 SP; 重伤 never kills while 消失 and kills at once when it comes back', () => {
const others = [{ uid: 2, chessId: 'chess_char_1_08_a', row: 12, col: 9 }];
const { h } = field({ tier: 5, skill: 2, others });
const texas = h.unit(2);
const e = h.spawn('enemy_weak', { pos: [10, 6] });
assert.ok(h.runUntil(() => isDying(e), 3));
const sp0 = texas.skill.sp;
h.b.dealDamage(texas, e, { amount: 1e6, type: 'true' });
assert.ok(!e.alive, 'another operator\'s damage kills it');
approx(texas.skill.sp - sp0, 2, '+2 SP to 德克萨斯');
// 消失
const g = h.spawn('enemy_weak', { pos: [10, 6] });
assert.ok(h.runUntil(() => isDying(g), 3));
h.step();
g.hidden = true;
h.run(12);
assert.ok(g.alive, 'no natural death while 消失');
g.hidden = false;
h.step();
assert.ok(!g.alive, 'dead the moment it appears');
assert.equal(h.hooksOf('kill').find((c) => c.victim === g).killer, null);
done(h);
});
test('T1 劝善 SOL-X stage 3: her damage on a target below half HP ×1.15 (stage 1 and the other forms: none)', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0 });
const x3 = elite && tier === 6 && mod === SOLX;
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
const hitAt = (ratio) => {
e.hp = e.s.maxHp * ratio;
const n0 = hitsBy(h, u).length;
assert.ok(h.runUntil(() => hitsBy(h, u, n0).length > 0, 3), label(f));
return hitsBy(h, u, n0)[0].amount;
};
const lo = hitAt(0.4), hi = hitAt(0.6);
approx(lo / hi, x3 ? 1.15 : 1, `${label(f)}: below / above half`);
if (x3) assert.match(u.def.raw.talents.find((t) => t.index === 0).desc, /对生命值低于一半的单位造成的伤害提升15%/);
done(h);
}
});
test('T2 清明: once per deployment, the first time she drops below 40 %: 70 % physical dodge and 5 % max HP/s for 15 s; SOL-Y stage 3: 6.5 % (its blackboard) for 18 s', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0, row: 12, col: 9 });
const y3 = elite && tier === 6 && mod === SOLY;
const tb = u.def.raw.talents.find((t) => t.index === 1).bb;
assert.deepEqual(tb, { hp_ratio: 0.4, hp_recovery_per_sec_by_max_hp_ratio: y3 ? 0.065 : 0.05, prob: 0.7, duration: y3 ? 18 : 15 }, label(f));
h.b.dealDamage(null, u, { amount: u.s.maxHp * 0.55, type: 'true' });
assert.equal(u.findBuff('talent:saga:qingming'), null, `${label(f)}: at 45 % nothing`);
h.b.dealDamage(null, u, { amount: u.s.maxHp * 0.1, type: 'true' });
const b = u.findBuff('talent:saga:qingming');
assert.ok(b, `${label(f)}: below 40 %`);
assert.deepEqual(b.mods, { dodgePhys: 0.7, hpRegenRatio: y3 ? 0.065 : 0.05 }, `${label(f)}: mods`);
approx(b.duration, y3 ? 18 : 15, `${label(f)}: duration`);
approx(u.s.dodgePhys, 0.7, `${label(f)}: dodge`);
const hp0 = u.hp;
h.run(2.01);
approx(u.hp - hp0, 2 * (y3 ? 0.065 : 0.05) * u.s.maxHp, `${label(f)}: regeneration`, 0.02);
h.run(b.duration);
assert.equal(u.findBuff('talent:saga:qingming'), null, `${label(f)}: over`);
u.hp = u.s.maxHp;
h.b.dealDamage(null, u, { amount: u.s.maxHp * 0.7, type: 'true' });
assert.equal(u.findBuff('talent:saga:qingming'), null, `${label(f)}: only once`);
h.b.retreat(u);
h.b.redeploy(u, { free: true });
h.b.dealDamage(null, u, { amount: u.s.maxHp * 0.7, type: 'true' });
assert.ok(u.findBuff('talent:saga:qingming'), `${label(f)}: again after a new deployment`);
done(h);
}
});
test('SOL-X 何处寻驮? trait: ATK / DEF +8 % while she blocks an enemy (stages 1 and 3); none without it; SOL-Y\'s 首次部署时部署费用-4 changes nothing in battle', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0, row: 9, col: 5 });
const solx = elite && mod === SOLX;
if (solx) assert.deepEqual(u.def.raw.trait.bb, { atk: 0.08, def: 0.08 }, label(f));
h.run(1);
assert.equal(u.findBuff('trait:saga:block'), null, `${label(f)}: nothing blocked`);
h.spawn('enemy_walk', { routeIndex: 0 });
assert.ok(h.runUntil(() => u.blocking.length > 0, 30), `${label(f)}: the walker reaches her`);
h.step();
assert.equal(!!u.findBuff('trait:saga:block'), solx, `${label(f)}: blocking`);
if (solx) assert.deepEqual(u.findBuff('trait:saga:block').mods, { atkPct: 0.08, defPct: 0.08 });
if (elite && mod === SOLY) {
assert.deepEqual(u.def.raw.talents.filter((t) => t.index === -1).map((t) => t.bb.runtime_cost).filter((v) => v != null), [-4], `${label(f)}: the data carries it`);
assert.equal(u.base.cost, formOf(tier, true).stats.cost, `${label(f)}: no cost change`);
}
done(h);
}
});
+252
View File
@@ -0,0 +1,252 @@
// test/content/op_shining.test.js — the 自选 operator kit of 闪灵 (char_147_shining, 6★ 医师; kit
// server/sim/content/kits/ops/op-shining.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, PHY-Y
// 干枯剑鞘 or PHY-X “使徒” 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_shining.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { 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 SHINING = 'char_147_shining';
const FORMS = BACKUPS.units[SHINING].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const PHYY = 'uniequip_002_shining', PHYX = 'uniequip_003_shining';
const S1 = 'skchr_shining_1', S2 = 'skchr_shining_2', S3 = 'skchr_shining_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 ENEMIES = { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e9, speed: 0, mass: 0 }) };
/** Synthetic allies with no skill: a melee one (地面 tiles) and a ranged one (高台). */
const ALLIES = {
test_melee_a: chessRec({ id: 'test_melee_a', profession: 'WARRIOR', stats: { maxHp: 10000, def: 100, atk: 0 }, skill: null }),
test_ranged_a: chessRec({ id: 'test_ranged_a', profession: 'SNIPER', stats: { maxHp: 10000, def: 100, atk: 0 }, skill: null }),
};
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, PHYY, PHYX].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/**
* A battle with 闪灵 as uid 1 on the 高台 tile (11,2) facing RIGHT (her 3-3: rows 10–12, cols 2–5), a melee ally on the
* 地面 tile (10,4) (uid 2) and a ranged ally on the 高台 tile (10,2) (uid 3) — both in her range —, a melee ally outside it
* at (10,8) (uid 4).
*/
function field({ tier = 5, elite = false, mod = null, skill = 0, seed = 5, extra = [] } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES, chess: ALLIES }, timeLimit: 600, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'heal', 'skillStart', 'skillEnd'], captureNoisy: true,
units: [
{ uid: 1, diy: { slot: SLOT[tier], charId: SHINING, skillIndex: skill, uniEquipId: mod }, elite, row: 11, col: 2 },
{ uid: 2, chessId: 'test_melee_a', row: 10, col: 4 },
{ uid: 3, chessId: 'test_ranged_a', row: 10, col: 2 },
{ uid: 4, chessId: 'test_melee_a', row: 10, col: 8 },
...extra,
],
});
h.step();
return { h, u: h.unit(1), ground: h.unit(2), high: h.unit(3), out: h.unit(4) };
}
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
const healsOn = (h, u, t, from = 0) => h.hooksOf('heal').slice(from).filter((c) => c.source === u && c.target === t && !c.opts?.regen);
test('闪灵 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 3-3, one heal (医师), no 特质', () => {
assert.equal(OPERATOR_KITS[SHINING], KITS[SHINING]);
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], [SHINING, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.aspd], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), form.stats.aspd + (m?.attr.aspd ?? 0)], `${label(f)}: stats`);
// a heal attack selects injured allies only (ai.js acquireTargets): no enemy, so no anti-air question
assert.deepEqual([u.profile.dmgType, u.profile.heal?.mode, u.profile.attack, u.base.bat], ['heal', 'single', 'ranged', 2.85], `${label(f)}: 医师`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-3`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['emptyShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth && !u.s.flags.untargetable, `${label(f)}: enemies target her`);
done(h);
}
}
// the numbers of the forms (zh_CN): E2 Lv1 1451 / 424 / 110, E2 Lv60 1558 / 494 / 129; PHY-Y +35 ATK +5 ASPD → +60 / +7,
// PHY-X +45 ATK +13 DEF → +63 / +20
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], [1451, 424, 1558, 494]);
assert.deepEqual([modOf(5, PHYY).attr, modOf(6, PHYY).attr, modOf(5, PHYX).attr, modOf(6, PHYX).attr], [{ atk: 35, aspd: 5 }, { atk: 60, aspd: 7 }, { atk: 45, def: 13 }, { atk: 63, def: 20 }]);
});
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(SHINING));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(SHINING), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: SHINING, skillIndex: 2, uniEquipId: PHYX } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[5]]: { charId: SHINING, skillIndex: 2, uniEquipId: PHYX } } });
});
test('S1 信条 (MANUAL, data DEFAULT): cast on an injured ally in range only; 20 s of ATK +58 % / +67 % and ASPD +10 / +20; 47 / 44 SP from 10 / 14', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
const { h, u, ground } = field({ tier, elite, mod: elite ? PHYY : null, skill: 0 });
assert.deepEqual([u.skill.rule, u.skill.spCost, sk.initSp, sk.duration, sk.bb.atk, sk.bb.attack_speed],
['DEFAULT', elite ? 44 : 47, elite ? 14 : 10, 20, elite ? 0.67 : 0.58, elite ? 20 : 10], `T${tier}`);
const aspd0 = u.s.aspd;
u.skill.gainSp(999);
h.run(3);
assert.equal(u.skill.activations, 0, `T${tier}: nobody injured, no cast`);
ground.hp = 100;
assert.ok(h.runUntil(() => u.skill.active, 5), `T${tier}: cast as she heals`);
approx(u.skill.timeLeft, 20, `T${tier}: 20 s`, 0.01);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
approx(u.s.aspd, aspd0 + sk.bb.attack_speed, `T${tier}: ASPD`);
h.runUntil(() => !u.skill.active, 25);
approx(u.s.atk, u.base.atk, `T${tier}: ATK back`);
approx(u.s.aspd, aspd0, `T${tier}: ASPD back`);
done(h);
}
});
test('S2 自动掩护 (AUTO, data DEFAULT, 1 / 2 charges): the next heal gives its target a 屏障 of 28 % / 35 % ATK for 2 / 3 s (every damage type) and DEF +25 % / +35 % while it holds', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
const { h, u, ground } = field({ tier, elite, mod: elite ? PHYX : null, skill: 1 });
assert.deepEqual([u.skill.rule, u.skill.maxCharges, u.skill.kind, sk.spCost, sk.bb.atk_scale, sk.bb.duration, sk.bb.def],
['DEFAULT', elite ? 2 : 1, elite ? 'charges' : 'instant', 7, elite ? 0.35 : 0.28, elite ? 3 : 2, elite ? 0.35 : 0.25], `T${tier}`);
u.skill.gainSp(999);
h.run(2);
assert.equal(u.skill.activations, 0, `T${tier}: nobody injured, no cast (it waits for a heal)`);
ground.hp = 100;
const def0 = ground.s.def;
assert.ok(h.runUntil(() => ground.findBuff('shining:barrier'), 5), `T${tier}: the barrier comes with the heal`);
assert.equal(healsOn(h, u, ground).length, 1, `T${tier}: one heal so far`);
const bar = ground.findBuff('shining:barrier');
approx(bar.shield, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`);
approx(bar.duration, sk.bb.duration, `T${tier}: ${sk.bb.duration} s`);
assert.equal(bar.shieldType, null, `T${tier}: absorbs every damage type`);
assert.deepEqual(ground.findBuff('shining:barrierDef')?.mods, { defPct: sk.bb.def }, `T${tier}: DEF bonus`);
approx(ground.s.def, def0 * (1 + sk.bb.def), `T${tier}: DEF`, 1e-6);
// the barrier takes physical and arts damage alike
const hp0 = ground.hp, sh0 = bar.shield;
h.b.dealDamage(null, ground, { amount: 10, type: 'arts', canDodge: false });
approx(ground.hp, hp0, `T${tier}: arts absorbed`);
approx(bar.shield, sh0 - 10, `T${tier}: shield −10`);
h.b.dealDamage(null, ground, { amount: 1e6, type: 'true' });
assert.equal(ground.findBuff('shining:barrier'), null, `T${tier}: broken`);
assert.equal(ground.findBuff('shining:barrierDef'), null, `T${tier}: the DEF bonus goes with it`);
done(h);
}
});
test('S2 自动掩护: barriers stack with their own timers, the older absorbs first, the DEF bonus does not stack; all gone after their time', () => {
const sk = skillOf(6, true, S2);
const { h, u, ground } = field({ tier: 6, elite: true, mod: PHYY, skill: 1 });
ground.hp = 100;
u.skill.gainSp(999);
assert.ok(h.runUntil(() => ground.buffs.filter((b) => b.key === 'shining:barrier').length === 1, 5));
ground.hp = 100; // still the most injured
assert.ok(h.runUntil(() => ground.buffs.filter((b) => b.key === 'shining:barrier').length === 2, 8), 'the second charge on the next heal');
const [a, b] = ground.buffs.filter((x) => x.key === 'shining:barrier');
assert.ok(a.seq < b.seq && a.timeLeft < b.timeLeft, 'own timers');
assert.equal(ground.buffs.filter((x) => x.key === 'shining:barrierDef').length, 1, 'one DEF bonus');
approx(ground.findBuff('shining:barrierDef').mods.defPct, sk.bb.def, 'not doubled');
approx(ground.s.shield, a.shield + b.shield, 'the absorption adds up');
const sa = a.shield, sb = b.shield;
h.b.dealDamage(null, ground, { amount: sa / 2, type: 'true' });
approx(a.shield, sa / 2, 'the older one first');
approx(b.shield, sb, 'the newer one untouched');
h.run(sk.bb.duration + 0.1);
assert.equal(ground.findBuff('shining:barrier'), null, 'expired');
assert.equal(ground.findBuff('shining:barrierDef'), null, 'the DEF bonus with them');
done(h);
});
test('S3 教条力场 (MANUAL, data DEFAULT): 60 s, ATK +30 % / +40 %, every ally in her range DEF +45 % / +60 % (not outside it); gone after it ends', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const { h, u, ground, high, out } = field({ tier, elite, mod: elite ? PHYX : null, skill: 2 });
assert.deepEqual([u.skill.rule, sk.duration, sk.spCost, sk.initSp, sk.bb.atk, sk.bb.def], ['DEFAULT', 60, 120, 100, elite ? 0.4 : 0.3, elite ? 0.6 : 0.45], `T${tier}`);
u.skill.gainSp(999);
ground.hp = 100;
assert.ok(h.runUntil(() => u.skill.active, 5), `T${tier}: cast on the injured ally`);
h.run(0.3);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
for (const a of [ground, high, u]) assert.deepEqual(a.findBuff('skill:shining:def')?.mods, { defPct: sk.bb.def }, `T${tier}: ${a.defId} in range`);
assert.equal(out.findBuff('skill:shining:def'), null, `T${tier}: outside her range`);
u.skill.end('test');
h.run(0.3);
for (const a of [ground, high, u]) assert.equal(a.findBuff('skill:shining:def'), null, `T${tier}: gone once it ends`);
done(h);
}
});
test('T1 黑恶魔的庇护: allies in her range DEF +60 (herself too, not outside); PHY-Y stage 3: +100 and 地面 units +40 more (stage 1: +60 only)', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u, ground, high, out } = field({ tier, elite, mod, skill: 0 });
h.run(0.3);
const y3 = elite && tier === 6 && mod === PHYY;
const d = y3 ? 100 : 60, low = y3 ? 40 : 0;
assert.deepEqual(ground.findBuff('talent:shining:def')?.mods, { defFlat: d + low }, `${label(f)}: 地面 ally`);
assert.deepEqual(high.findBuff('talent:shining:def')?.mods, { defFlat: d }, `${label(f)}: 高台 ally`);
assert.deepEqual(u.findBuff('talent:shining:def')?.mods, { defFlat: d }, `${label(f)}: herself (高台)`);
assert.equal(out.findBuff('talent:shining:def'), null, `${label(f)}: outside her range`);
approx(ground.s.def, 100 + d + low, `${label(f)}: DEF of the 地面 ally`);
if (y3) assert.match(u.def.raw.talents.find((t) => t.index === 0).desc, /防御力\+100,地面单位防御力额外\+40/);
h.b.retreat(u);
h.run(0.3);
assert.equal(ground.findBuff('talent:shining:def'), null, `${label(f)}: gone once she leaves`);
done(h);
}
});
test('T2 法典: ASPD +10; PHY-X stage 3: ASPD +15, ATK +25 % with 自动掩护 picked, SP +0.6/s with 教条力场 picked (stage 1: ASPD +10 only)', () => {
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 x3 = elite && tier === 6 && mod === PHYX;
approx(u.s.aspd, u.base.aspd + (x3 ? 15 : 10), `${label(f)} S${skill + 1}: ASPD`);
approx(u.s.atk, u.base.atk * (1 + (x3 && skill === 1 ? 0.25 : 0)), `${label(f)} S${skill + 1}: ATK`);
approx(u.s.spRecovery, 1 + (x3 && skill === 2 ? 0.6 : 0), `${label(f)} S${skill + 1}: SP/s`);
if (x3 && skill === 2) {
const sp0 = u.skill.sp;
h.run(5);
approx(u.skill.sp - sp0, 5 * 1.6, `${label(f)}: 1.6 SP/s`, 0.01);
}
done(h);
}
}
});
test('module traits: PHY-Y heals on 地面 units ×1.15 (not on 高台), PHY-X heals on allies below 50 % ×1.15 (stages 1 and 3); none without a module', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u, ground, high } = field({ tier, elite, mod, skill: 0 });
const tb = u.def.raw.trait.bb;
const y = elite && mod === PHYY, x = elite && mod === PHYX;
if (y) assert.deepEqual(tb, { heal_scale: 1.15 }, label(f));
if (x) assert.deepEqual(tb, { heal_scale: 1.15, hp_ratio: 0.5 }, label(f));
const heal = (t, hp) => {
t.hp = hp;
const n0 = h.hooksOf('heal').length;
assert.ok(h.runUntil(() => healsOn(h, u, t, n0).length > 0, 6), `${label(f)}: a heal on ${t.defId}`);
return healsOn(h, u, t, n0)[0].amount;
};
// 地面 vs 高台, both below 50 %
approx(heal(ground, 100), u.s.atk * (y || x ? 1.15 : 1), `${label(f)}: 地面 ally at 1 %`);
ground.hp = ground.s.maxHp;
approx(heal(high, 100), u.s.atk * (x ? 1.15 : 1), `${label(f)}: 高台 ally at 1 %`);
high.hp = high.s.maxHp;
// above 50 %
approx(heal(ground, 6000), u.s.atk * (y ? 1.15 : 1), `${label(f)}: 地面 ally at 60 %`);
ground.hp = ground.s.maxHp;
approx(heal(high, 6000), u.s.atk, `${label(f)}: 高台 ally at 60 %`);
done(h);
}
});
+355 -113
View File
@@ -332,131 +332,131 @@
"snaps": "7f272ba7 3debf791 1552b043 e5673051 2a31733f ddde2cf0 5172c3bc 404d8953 6eeecaa1 2126ddc6 45873ba7 e5e82592 2f4b4237 7b847c95 df2b1332 af30a96c 1daa2f7e ab8bd9f1 9949f039 898d148b cf4f2c24 47d56ee7 2d43e9f7"
},
"diy-009": {
"about": "5_diy1_a=cdfend 5_diy2_b=cdfend 6_diy1_b=helage/S1/002_helage 6_diy2_b=helage/S2/002_helage 6_diy1_b=helage/S3/002_helage 6_diy2_b=helage/S1/003_helage 6_diy1_b=helage/S2/003_helage 6_diy2_b=helage/S3/003_helage 5_diy1_a=cguard 5_diy2_b=cguard 5_diy1_a=csnipe 5_diy2_b=csnipe",
"fast": true,
"about": "6_diy1_b=helage/S1/002_helage 6_diy2_b=helage/S2/002_helage 6_diy1_b=helage/S3/002_helage 6_diy2_b=helage/S1/003_helage 6_diy1_b=helage/S2/003_helage 6_diy2_b=helage/S3/003_helage 5_diy1_a=shining/S1 5_diy2_a=shining/S2 5_diy1_a=shining/S3 5_diy2_b=shining/S1 5_diy1_b=shining/S2 5_diy2_b=shining/S3",
"fast": false,
"kind": "normal",
"stageId": "act2autochess_m02",
"modeId": "mode_single_normal",
"round": 13,
"seed": 2612428600,
"waveId": "act1autochess_h06",
"time": 217.2,
"ticks": 6516,
"time": 204,
"ticks": 6120,
"reason": "cleared",
"killed": 22,
"killed": 19,
"total": 102,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 5,
"rngDraws": 15,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":22,"total":102,"perfect":false,"leaks":80,"leaksUncounted":0,"coins":0,"dmg":582096,"bossDmg":0,"heal":56534,"deaths":19,"layerGains":{}}},
"players": {"p1":{"killed":19,"total":102,"perfect":false,"leaks":83,"leaksUncounted":0,"coins":0,"dmg":673617,"bossDmg":0,"heal":74372,"deaths":11,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,84,0,836,4256,0,11,1],
["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,126,0,3495,8724,0,34,1],
["p1",3,"op","chess_char_6_diy1_b",0,"uniequip_002_helage",0,0,104,4,85330,17582,9878,116,28],
["p1",4,"op","chess_char_6_diy2_b",1,"uniequip_002_helage",0,0,82,5,94287,17473,9769,109,2],
["p1",5,"op","chess_char_6_diy1_b",2,"uniequip_002_helage",1218.05,1,123,4,128169,18334,11848,116,2],
["p1",6,"op","chess_char_6_diy2_b",0,"uniequip_003_helage",0,0,13,3,46377,10621,6305,59,14],
["p1",7,"op","chess_char_6_diy1_b",1,"uniequip_003_helage",0,0,74,3,84940,20049,11816,93,2],
["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_003_helage",0,0,9,2,49846,11075,6917,54,1],
["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,114,0,8215,7692,0,19,0],
["p1",10,"op","chess_char_5_diy2_b",2,"none",0,0,78,0,12025,6668,0,17,1],
["p1",11,"op","chess_char_5_diy1_a",2,null,529.87,1,6,1,24282,741,0,162,3],
["p1",12,"op","chess_char_5_diy2_b",2,"none",1110,1,7,0,44294,400,0,181,3],
["p1",1,"op","chess_char_6_diy1_b",0,"uniequip_002_helage",0,0,92,3,104297,18702,10998,131,32],
["p1",2,"op","chess_char_6_diy2_b",1,"uniequip_002_helage",0,0,95,4,94847,17360,9656,122,2],
["p1",3,"op","chess_char_6_diy1_b",2,"uniequip_002_helage",0,0,104,2,108379,18558,10854,115,1],
["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_003_helage",0,0,81,5,97344,20680,12233,121,29],
["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_003_helage",3502,1,117,3,104104,14487,10311,103,3],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_003_helage",0,0,78,2,164646,26946,18695,131,2],
["p1",7,"op","chess_char_5_diy1_a",0,null,1451,1,8,0,0,0,0,0,0],
["p1",8,"op","chess_char_5_diy2_a",1,null,1451,1,9,0,0,0,300,6,3],
["p1",9,"op","chess_char_5_diy1_a",2,null,1451,1,13,0,0,0,0,0,0],
["p1",10,"op","chess_char_5_diy2_b",0,"none",1558,1,4,0,0,425,625,13,2],
["p1",11,"op","chess_char_5_diy1_b",1,"none",1558,1,6,0,0,700,650,9,6],
["p1",12,"op","chess_char_5_diy2_b",2,"none",1558,1,10,0,0,500,50,1,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_1267_nhpbr_2":[27,8,19,11162,90580],"enemy_1270_nhstlk_2":[36,3,33,28923,102825],"enemy_9006_actoxi":[39,11,28,79793,388692]},
"hooks": [1,126,6516,1298,1298,1710,1706,3374,41,121,58,57,0,19311,0,49,102,80,1,45,0,0,4],
"events": {"atk":1363,"deploy":22,"die":41,"dmg":1706,"fx":27,"heal":643,"leak":80,"skill":115,"spawn":116,"status":94},
"fx": {"dodge":4,"undying":4,"zone":19},
"snaps": "e29cd4d6 00a66794 45dc06f7 52a7cd66 5f3b2160 ed0908eb 0e080bc8 e966908e 198063cc a8e9f5f4 cae33815 5540905a 3b38966f 6aa46423 3af14704 ec6cc5da e40ffb54 80663a8b 33e91ce8 fe1765a2 daf87795 9570b43a"
"enemies": {"enemy_1267_nhpbr_2":[27,5,22,7978,52595],"enemy_1270_nhstlk_2":[36,6,30,24943,203774],"enemy_9006_actoxi":[39,8,31,82815,417247]},
"hooks": [1,122,6120,1054,1054,1505,1494,3892,30,113,81,79,0,16446,0,30,102,83,1,35,0,0,11],
"events": {"atk":1184,"deploy":18,"die":30,"dmg":1473,"fx":38,"heal":829,"leak":83,"skill":160,"spawn":116,"status":160},
"fx": {"dodge":11,"shield":9,"undying":5,"zone":13},
"snaps": "1afa3e85 a42c9c43 b7185f40 c33edc74 1a66f242 a0042849 d7671dd0 7ffed81d ac31fc99 7ec30e1f 4b39965c dd728478 04956ef0 f20d0551 2b2ed25e 9b0fe665 52d96316 c1396136 476c7670 d5abc325 11f69749"
},
"diy-010": {
"about": "5_diy1_a=acfend 5_diy2_b=acfend 5_diy1_a=acpion 5_diy2_b=acpion 5_diy1_a=acguad 5_diy2_b=acguad 5_diy1_a=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo",
"about": "5_diy1_b=shining/S1/002_shining 5_diy2_b=shining/S2/002_shining 5_diy1_b=shining/S3/002_shining 5_diy2_b=shining/S1/003_shining 5_diy1_b=shining/S2/003_shining 5_diy2_b=shining/S3/003_shining 6_diy1_a=shining/S1 6_diy2_a=shining/S2 6_diy1_a=shining/S3 6_diy2_b=shining/S1 6_diy1_b=shining/S2 6_diy2_b=shining/S3",
"fast": false,
"kind": "normal",
"stageId": "act2autochess_m03",
"modeId": "mode_multi_abyss",
"round": 12,
"round": 13,
"seed": 4252812069,
"waveId": "act1autochess_h05",
"time": 198.83,
"ticks": 5965,
"reason": "cleared",
"killed": 1,
"total": 114,
"waveId": "act1autochess_h06",
"time": 230,
"ticks": 6900,
"reason": "timeout",
"killed": 0,
"total": 165,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 83,
"rngDraws": 0,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":1,"total":114,"perfect":false,"leaks":113,"leaksUncounted":0,"coins":0,"dmg":88013,"bossDmg":0,"heal":2979,"deaths":25,"layerGains":{}}},
"players": {"p1":{"killed":0,"total":165,"perfect":false,"leaks":165,"leaksUncounted":0,"coins":0,"dmg":0,"bossDmg":0,"heal":264278,"deaths":2,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,5,0,1224,2604,0,8,0],
["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",0,0,6,0,6376,3371,0,17,1],
["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,139,0,19464,4587,0,16,1],
["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,132,0,14161,6079,0,6,1],
["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,81,0,2402,7097,0,3,0],
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,123,1,32254,6822,0,13,1],
["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,108,0,3736,2266,0,8,0],
["p1",8,"op","chess_char_5_diy2_b",2,"none",0,0,78,0,3843,2720,0,7,0],
["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,76,0,0,2803,1939,6,0],
["p1",10,"op","chess_char_5_diy2_b",2,"none",0,0,71,0,0,3171,1040,4,0],
["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,142,0,3160,2796,0,8,0],
["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,80,0,1392,2262,0,3,0],
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_shining",1558,1,8,0,0,0,45413,89,4],
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_shining",1558,1,11,0,0,0,34860,80,32],
["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_shining",1558,1,13,0,0,0,35167,75,2],
["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_003_shining",1558,1,15,0,0,0,10179,42,3],
["p1",5,"op","chess_char_5_diy1_b",1,"uniequip_003_shining",1558,1,4,0,0,0,37323,76,30],
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_003_shining",1558,1,9,0,0,0,0,0,0],
["p1",7,"op","chess_char_6_diy1_a",0,null,0,0,162,0,0,17826,14024,30,1],
["p1",8,"op","chess_char_6_diy2_a",1,null,1201.87,1,7,0,0,54297,20999,79,27],
["p1",9,"op","chess_char_6_diy1_a",2,null,1451,1,10,0,0,53781,19961,61,2],
["p1",10,"op","chess_char_6_diy2_b",0,"none",1558,1,12,0,0,42571,15867,48,4],
["p1",11,"op","chess_char_6_diy1_b",1,"none",1558,1,14,0,0,55380,642,4,4],
["p1",12,"op","chess_char_6_diy2_b",2,"none",1558,1,6,0,0,43575,29844,81,2],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
],
"enemies": {"enemy_1019_jshoot_2":[51,0,51,10772,5074],"enemy_1381_winman":[27,1,26,3589,81570],"enemy_1389_winbab_2":[36,0,36,32218,1369]},
"hooks": [1,142,5965,362,362,362,341,10,26,139,4,4,0,8414,11,22,114,113,1,67,0,0,21],
"events": {"atk":362,"deploy":25,"die":26,"dmg":341,"fx":43,"heal":10,"leak":113,"skill":8,"spawn":129,"status":22},
"fx": {"aoe":4,"dodge":21,"dp":2,"slash":16},
"snaps": "f95ec6b3 ed7d838d fb316f9c dab9952a 3ece6e1c cc394bf0 4406f186 500cf7f9 85835d90 0fbaf17d 904fc882 ab8ec4e4 42d37e57 1cf0f337 dbd9f5b6 d472d89b 3e7d68bd 953cd5f8 e3b8230a 1ab756a8"
"enemies": {"enemy_1249_lysdb_2":[60,0,56,223408,0],"enemy_1251_lysyta":[51,0,50,42571,0],"enemy_1254_lypa_2":[54,0,54,1451,0]},
"hooks": [1,181,6900,1120,1120,455,455,665,2,162,111,106,0,50219,0,7,165,160,1,2,0,0,0],
"events": {"atk":1120,"deploy":13,"die":2,"dmg":455,"fx":93,"heal":665,"leak":160,"skill":217,"spawn":180,"status":177},
"fx": {"shield":93},
"snaps": "750ba13e 2fcf65ad eac3728b a33618bb 15ca8e40 282c8fa1 c30131da 3a66fbfd c2af99aa 61f11975 1fa0ad25 1aface34 bfdc1f66 c1331f65 91e11ac8 f6d2c429 3b5f5f64 ff47b8d8 ab2fb9ec 0e1af6c3 2d923386 c81cebb3 fccf3ca2"
},
"diy-011": {
"about": "5_diy1_a=sharp2 5_diy2_b=sharp2 5_diy1_a=acspec 5_diy2_b=acspec 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc 5_diy1_a=acsupo 5_diy2_b=acsupo",
"about": "6_diy1_b=shining/S1/002_shining 6_diy2_b=shining/S2/002_shining 6_diy1_b=shining/S3/002_shining 6_diy2_b=shining/S1/003_shining 6_diy1_b=shining/S2/003_shining 6_diy2_b=shining/S3/003_shining 5_diy1_a=cgbird/S1 5_diy2_a=cgbird/S2 5_diy1_a=cgbird/S3 5_diy2_b=cgbird/S1 5_diy1_b=cgbird/S2 5_diy2_b=cgbird/S3",
"fast": false,
"kind": "normal",
"stageId": "act2autochess_m04",
"modeId": "mode_multi_funny",
"round": 12,
"round": 13,
"seed": 538212341,
"waveId": "act1autochess_h05",
"time": 210.13,
"ticks": 6304,
"waveId": "act1autochess_h06",
"time": 216.2,
"ticks": 6486,
"reason": "cleared",
"killed": 83,
"total": 114,
"killed": 0,
"total": 144,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 1149,
"rngDraws": 0,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":83,"total":114,"perfect":false,"leaks":31,"leaksUncounted":0,"coins":0,"dmg":674779,"bossDmg":0,"heal":30475,"deaths":5,"layerGains":{}}},
"players": {"p1":{"killed":0,"total":144,"perfect":false,"leaks":144,"leaksUncounted":0,"coins":0,"dmg":0,"bossDmg":0,"heal":77096,"deaths":20,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",0,null,1099,1,132,12,52984,13333,0,108,3],
["p1",2,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",1787,1,131,19,125786,8523,0,123,3],
["p1",3,"op","chess_char_5_diy1_a",2,null,1154,1,133,1,4263,9289,0,80,0],
["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",978,1,108,3,7850,9148,0,105,0],
["p1",5,"op","chess_char_5_diy1_a",2,null,1272,1,3,8,18374,0,0,185,2],
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",1587,1,4,8,29112,0,0,189,3],
["p1",7,"op","chess_char_5_diy1_a",2,null,1372,1,11,24,275224,0,0,83,2],
["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",1749,1,13,7,85351,0,0,51,2],
["p1",9,"op","chess_char_5_diy1_a",2,null,1224,1,5,0,0,0,30475,58,2],
["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",1414,1,10,0,0,0,0,0,0],
["p1",11,"op","chess_char_5_diy1_a",2,null,1160,1,12,0,29193,0,0,137,2],
["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",1430,1,14,0,46642,0,0,131,2],
["p1",1,"op","chess_char_6_diy1_b",0,"uniequip_002_shining",0,0,3,0,0,1729,5705,32,2],
["p1",2,"op","chess_char_6_diy2_b",1,"uniequip_002_shining",0,0,4,0,0,2366,8150,31,15],
["p1",3,"op","chess_char_6_diy1_b",2,"uniequip_002_shining",0,0,147,0,0,5556,11587,32,1],
["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_003_shining",0,0,123,0,0,17520,8613,22,1],
["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_003_shining",0,0,5,0,0,6847,9494,25,11],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_003_shining",0,0,150,0,0,13479,10693,30,1],
["p1",7,"op","chess_char_5_diy1_a",0,null,0,0,139,0,0,11754,5600,23,1],
["p1",8,"op","chess_char_5_diy2_a",1,null,1346,1,163,0,0,17274,2771,14,4],
["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,128,0,0,6987,1803,7,1],
["p1",10,"op","chess_char_5_diy2_b",0,"none",1584,1,168,0,0,8073,4518,26,2],
["p1",11,"op","chess_char_5_diy1_b",1,"none",0,0,157,0,0,10575,2347,29,10],
["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,149,0,0,4954,5814,25,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_10124_uashld_2":[12,3,9,23019,173497],"enemy_1043_zomsbr":[72,64,8,297,212674],"enemy_1061_zomshd":[30,16,14,16977,315575]},
"hooks": [1,133,6304,1429,1429,3149,3332,39660,88,119,21,21,0,33441,7581,38,114,31,1,88,0,0,0],
"events": {"atk":1866,"deploy":17,"die":88,"dmg":3329,"fx":14,"heal":94,"leak":31,"skill":39,"spawn":128,"status":771},
"fx": {"aoe":9,"spGain":5},
"snaps": "9e077552 b355e47c b24fef80 9630611b ba6aaa68 a55a9e35 55de81c3 0200ff41 1c975365 6d65c907 2fa8927d 65a7475d c5555eff 97949f97 1b5b5829 eca6487a 5c3a07cb ca46ec36 57dff451 c868cecd de89d5db"
"enemies": {"enemy_2021_syfish":[99,0,99,3468,21200],"enemy_2025_syufo":[45,0,45,103645,0]},
"hooks": [1,168,6486,802,802,1049,1296,310,20,164,50,50,0,20120,0,3,144,144,1,20,0,32,0],
"events": {"atk":816,"deploy":22,"die":20,"dmg":1279,"fx":73,"heal":310,"leak":144,"skill":100,"spawn":158,"status":184},
"fx": {"burst":32,"shield":41},
"snaps": "0bbe086d a51c9486 734da093 6e403c59 89c36eb9 ad02f34b 1cf2ec72 86ec90f8 4af4dbb6 c769970b 0bc76e87 34271fbd 49e1da1f 752f90f1 79bfc451 c019b528 770ecaa8 ccc6bbd0 54e64ce1 ff80d032 92aa1e3f 522c48b4"
},
"diy-012": {
"about": "6_diy1_a=acfend 6_diy2_b=acfend 6_diy1_a=acpion 6_diy2_b=acpion 6_diy1_a=acguad 6_diy2_b=acguad 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc",
"about": "5_diy1_b=cgbird/S1/002_cgbird 5_diy2_b=cgbird/S2/002_cgbird 5_diy1_b=cgbird/S3/002_cgbird 5_diy2_b=cgbird/S1/003_cgbird 5_diy1_b=cgbird/S2/003_cgbird 5_diy2_b=cgbird/S3/003_cgbird 6_diy1_a=cgbird/S1 6_diy2_a=cgbird/S2 6_diy1_a=cgbird/S3 6_diy2_b=cgbird/S1 6_diy1_b=cgbird/S2 6_diy2_b=cgbird/S3",
"fast": false,
"kind": "normal",
"stageId": "act1autochess_m01",
@@ -467,38 +467,38 @@
"time": 230,
"ticks": 6900,
"reason": "timeout",
"killed": 25,
"killed": 0,
"total": 117,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 1031,
"rngDraws": 0,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":25,"total":117,"perfect":false,"leaks":92,"leaksUncounted":0,"coins":0,"dmg":917801,"bossDmg":0,"heal":18388,"deaths":13,"layerGains":{}}},
"players": {"p1":{"killed":0,"total":117,"perfect":false,"leaks":117,"leaksUncounted":0,"coins":0,"dmg":0,"bossDmg":0,"heal":221237,"deaths":0,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_6_diy1_a",2,null,0,0,140,0,1944,8114,0,26,1],
["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",0,0,121,0,10155,6862,0,85,2],
["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,133,0,7609,4587,0,20,1],
["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",0,0,130,6,51717,5712,0,19,2],
["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,90,1,23844,5276,0,50,2],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",3531,1,8,6,145774,18086,0,124,5],
["p1",7,"op","chess_char_6_diy1_a",2,null,1272,1,4,0,33279,0,0,157,2],
["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",1643,1,5,6,96326,0,0,192,3],
["p1",9,"op","chess_char_6_diy1_a",2,null,1372,1,11,4,255529,0,0,78,2],
["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",1819,1,13,2,291625,0,0,83,3],
["p1",11,"op","chess_char_6_diy1_a",2,null,1224,1,6,0,0,0,18388,45,2],
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",1464,1,14,0,0,0,0,0,0],
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_cgbird",1584,1,4,0,0,0,32997,70,4],
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_cgbird",1584,1,5,0,0,0,25644,60,22],
["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_cgbird",1584,1,11,0,0,0,28300,69,2],
["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_003_cgbird",1744,1,13,0,0,0,27360,66,4],
["p1",5,"op","chess_char_5_diy1_b",1,"uniequip_003_cgbird",1744,1,6,0,0,0,16088,50,21],
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_003_cgbird",1744,1,14,0,0,0,9803,25,2],
["p1",7,"op","chess_char_6_diy1_a",0,null,1346,1,7,0,0,31119,17697,51,3],
["p1",8,"op","chess_char_6_diy2_a",1,null,1346,1,9,0,0,31424,19737,59,22],
["p1",9,"op","chess_char_6_diy1_a",2,null,1346,1,10,0,0,32339,15247,60,2],
["p1",10,"op","chess_char_6_diy2_b",0,"none",1584,1,12,0,0,31470,11800,40,3],
["p1",11,"op","chess_char_6_diy1_b",1,"none",1584,1,15,0,0,50129,8172,35,17],
["p1",12,"op","chess_char_6_diy2_b",2,"none",1584,1,8,0,0,44758,8394,46,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_1166_dusbr_2":[39,14,25,1375,203044],"enemy_1170_dushld_2":[39,8,30,14781,221543],"enemy_9011_acrefr":[39,3,35,32481,493214]},
"hooks": [1,140,6900,1061,1061,3100,3078,67,38,128,25,22,0,33015,0,48,117,90,1,42,0,0,22],
"events": {"atk":1376,"deploy":20,"die":38,"dmg":3078,"fx":136,"heal":57,"leak":90,"skill":47,"spawn":132,"status":123},
"fx": {"aoe":81,"dodge":22,"dp":9,"slash":24},
"snaps": "5798fc78 bc32724f 41014680 b37499e1 77e4e7eb 80e45344 7e9b1723 c72901e2 a0b40309 48335b9f 4eeefd52 e9aa29a1 5be95678 4ab19904 6c6204e0 7a33f9b8 8284dbb5 3e831607 c6b72c15 45727786 199e1234 e693dda1 4aabdf76"
"enemies": {"enemy_1166_dusbr_2":[39,0,35,126351,0],"enemy_1170_dushld_2":[39,0,37,94886,0],"enemy_9011_acrefr":[39,0,39,0,0]},
"hooks": [1,132,6900,1158,1158,527,527,806,0,111,104,98,0,54914,0,6,117,111,1,0,0,0,0],
"events": {"atk":1333,"deploy":12,"dmg":527,"fx":103,"heal":806,"leak":111,"skill":202,"spawn":132,"status":299},
"fx": {"shield":103},
"snaps": "f04bf967 9f5c9c5c d6a9416d fc635ddc 1e6e9238 6907ac7e f50097bf 040bfa98 ff0ef0db 7fbe31c3 c2d46c25 65d9e9ec 7695151e 083dd2ec 68bbdb69 e5787895 1e220d13 719a7ef1 ec3be9c1 400634d4 8b493a66 e2fbf6b3 bbf7ff21"
},
"diy-013": {
"about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acsupo 6_diy2_b=acsupo",
"about": "5_diy1_a=saga/S1 5_diy2_a=saga/S2 5_diy1_a=saga/S3 5_diy2_b=saga/S1 5_diy1_b=saga/S2 5_diy2_b=saga/S3 6_diy1_b=cgbird/S1/002_cgbird 6_diy2_b=cgbird/S2/002_cgbird 6_diy1_b=cgbird/S3/002_cgbird 6_diy2_b=cgbird/S1/003_cgbird 6_diy1_b=cgbird/S2/003_cgbird 6_diy2_b=cgbird/S3/003_cgbird",
"fast": false,
"kind": "normal",
"stageId": "act1autochess_m02",
@@ -509,26 +509,268 @@
"time": 219.27,
"ticks": 6578,
"reason": "cleared",
"killed": 3,
"killed": 1,
"total": 132,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 25,
"rngDraws": 74,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":3,"total":132,"perfect":false,"leaks":129,"leaksUncounted":0,"coins":0,"dmg":61168,"bossDmg":0,"heal":0,"deaths":28,"layerGains":{}}},
"players": {"p1":{"killed":1,"total":132,"perfect":false,"leaks":131,"leaksUncounted":0,"coins":0,"dmg":27223,"bossDmg":0,"heal":17294,"deaths":26,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,2,0,15633,1865,0,8,1],
["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,1,2,28961,2494,0,15,1],
["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,160,1,4413,12694,0,13,0],
["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",1484,1,161,0,8683,14840,0,9,0],
["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,144,0,1909,3480,0,5,0],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",0,0,77,0,1569,2900,0,3,0]
["p1",1,"op","chess_char_5_diy1_a",0,null,0,0,158,0,3742,6238,91,13,1],
["p1",2,"op","chess_char_5_diy2_a",1,null,699.26,1,159,1,4125,5425,925,10,2],
["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,152,0,8504,6762,0,12,1],
["p1",4,"op","chess_char_5_diy2_b",0,"none",0,0,92,0,3650,6580,43,10,1],
["p1",5,"op","chess_char_5_diy1_b",1,"none",0,0,145,0,4228,7154,321,8,1],
["p1",6,"op","chess_char_5_diy2_b",2,"none",0,0,77,0,2974,4952,397,8,0],
["p1",7,"op","chess_char_6_diy1_b",0,"uniequip_002_cgbird",0,0,139,0,0,3492,4210,6,1],
["p1",8,"op","chess_char_6_diy2_b",1,"uniequip_002_cgbird",0,0,87,0,0,4671,1935,6,2],
["p1",9,"op","chess_char_6_diy1_b",2,"uniequip_002_cgbird",0,0,94,0,0,4741,2698,5,1],
["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_003_cgbird",0,0,90,0,0,6410,975,3,1],
["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_003_cgbird",0,0,73,0,0,4910,1302,4,0],
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_003_cgbird",0,0,2,0,0,2293,4396,7,1]
],
"enemies": {"enemy_1019_jshoot_2":[57,2,55,9362,21392],"enemy_1381_winman":[27,1,26,526,39174],"enemy_1389_winbab_2":[48,0,48,28385,602]},
"hooks": [1,161,6578,288,288,172,187,0,31,160,2,23,0,2383,13,14,132,129,1,31,0,0,0],
"events": {"atk":295,"deploy":29,"die":31,"dmg":182,"fx":5,"leak":129,"skill":4,"spawn":138,"status":26},
"fx": {"displace":4,"dodge":1},
"snaps": "e5204fab 982d07f3 584cb188 44820d62 8695b17e 5d029e3f ce97f399 5b27b71e 4cbc4a9f 5565a881 55fe5bb0 1854b616 0b410158 0618ca70 a9b7268d 2cfac889 7fb3e5b8 15a58d3f 87049a47 0b595d41 cc6b4807 964b9bf0"
"enemies": {"enemy_1019_jshoot_2":[57,1,56,15011,14682],"enemy_1381_winman":[27,0,27,3180,12277],"enemy_1389_winbab_2":[48,0,48,45437,264]},
"hooks": [1,159,6578,404,404,275,224,649,27,158,12,12,0,7864,0,24,132,131,1,27,0,0,51],
"events": {"atk":417,"deploy":27,"die":27,"dmg":224,"fx":78,"heal":42,"leak":131,"skill":24,"spawn":144,"status":36},
"fx": {"aoe":3,"buff":16,"dodge":51,"dp":5,"dying":1,"shield":2},
"snaps": "52b05b5f f22752af 0a9753b5 76d0ebb9 fd2291fe ff2c1a4d ba47168f 4d3f2f7c 0580bd3e 06df4477 57a732d6 7c19ed8b e36079b8 15e75a84 4792e70e 93d2efa1 bba44dad e1c8068a 9b0ec217 85a052ab c2760f40 eb222e59"
},
"diy-014": {
"about": "5_diy1_b=saga/S1/002_saga 5_diy2_b=saga/S2/002_saga 5_diy1_b=saga/S3/002_saga 5_diy2_b=saga/S1/003_saga 5_diy1_b=saga/S2/003_saga 5_diy2_b=saga/S3/003_saga 6_diy1_a=saga/S1 6_diy2_a=saga/S2 6_diy1_a=saga/S3 6_diy2_b=saga/S1 6_diy1_b=saga/S2 6_diy2_b=saga/S3",
"fast": false,
"kind": "normal",
"stageId": "act1autochess_m03",
"modeId": "mode_multi_hard",
"round": 13,
"seed": 2178544450,
"waveId": "act1autochess_h06",
"time": 216.8,
"ticks": 6504,
"reason": "cleared",
"killed": 9,
"total": 129,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 162,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":9,"total":129,"perfect":false,"leaks":120,"leaksUncounted":0,"coins":0,"dmg":168412,"bossDmg":0,"heal":20577,"deaths":30,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_saga",0,0,112,0,6691,5984,1826,32,1],
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_saga",0,0,164,0,25014,8333,2096,37,3],
["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_saga",0,0,151,0,21546,7588,1351,27,1],
["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_003_saga",0,0,159,0,10154,8156,2144,31,1],
["p1",5,"op","chess_char_5_diy1_b",1,"uniequip_003_saga",0,0,162,1,12116,8677,2665,32,3],
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_003_saga",0,0,145,0,38572,7512,1500,44,2],
["p1",7,"op","chess_char_6_diy1_a",0,null,0,0,142,0,2017,6449,1622,21,0],
["p1",8,"op","chess_char_6_diy2_a",1,null,1609,1,163,0,1501,3835,617,15,2],
["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,131,0,20603,4422,1204,31,1],
["p1",10,"op","chess_char_6_diy2_b",0,"none",2004,1,149,0,1020,5768,1760,26,1],
["p1",11,"op","chess_char_6_diy1_b",1,"none",0,0,94,0,9226,6550,2542,28,4],
["p1",12,"op","chess_char_6_diy2_b",2,"none",0,0,99,0,19953,5257,1249,17,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_10124_uashld_2":[18,0,18,27168,3796],"enemy_1043_zomsbr":[66,9,57,14627,148353],"enemy_1061_zomshd":[45,0,45,36738,16263]},
"hooks": [1,164,6504,642,642,723,617,20102,39,159,21,21,0,18170,0,64,129,120,1,40,0,0,106],
"events": {"atk":689,"deploy":32,"die":39,"dmg":617,"fx":172,"leak":120,"skill":42,"spawn":144,"status":69},
"fx": {"aoe":12,"buff":30,"dodge":106,"dp":15,"dying":9},
"snaps": "139545f0 29393223 af71a5ff f0163efe a9da66a7 136d4411 c07df6f7 80c80aa5 9fb44dd7 4ebb38f0 5334365a 625edb1c 0c3fc668 a66e9ef9 dc0775ba 570d25e8 6398e94c ebde2a20 72f3754c bcb5d344 8311f146 9e54ae25"
},
"diy-015": {
"about": "5_diy1_a=cdfend 5_diy2_b=cdfend 6_diy1_b=saga/S1/002_saga 6_diy2_b=saga/S2/002_saga 6_diy1_b=saga/S3/002_saga 6_diy2_b=saga/S1/003_saga 6_diy1_b=saga/S2/003_saga 6_diy2_b=saga/S3/003_saga 5_diy1_a=cguard 5_diy2_b=cguard 5_diy1_a=csnipe 5_diy2_b=csnipe",
"fast": true,
"kind": "normal",
"stageId": "act1autochess_m04",
"modeId": "mode_single_normal",
"round": 13,
"seed": 2805115174,
"waveId": "act1autochess_h06",
"time": 228.93,
"ticks": 6868,
"reason": "cleared",
"killed": 19,
"total": 129,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 145,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":19,"total":129,"perfect":false,"leaks":110,"leaksUncounted":0,"coins":0,"dmg":200146,"bossDmg":0,"heal":20892,"deaths":22,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,156,0,262,6384,0,19,1],
["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,138,0,604,5816,0,38,1],
["p1",3,"op","chess_char_6_diy1_b",0,"uniequip_002_saga",0,0,158,0,968,9420,3033,35,2],
["p1",4,"op","chess_char_6_diy2_b",1,"uniequip_002_saga",0,0,101,3,24966,8239,3981,52,7],
["p1",5,"op","chess_char_6_diy1_b",2,"uniequip_002_saga",927.35,1,159,0,36825,10635,5176,50,2],
["p1",6,"op","chess_char_6_diy2_b",0,"uniequip_003_saga",2004,1,161,0,4279,6890,2882,60,2],
["p1",7,"op","chess_char_6_diy1_b",1,"uniequip_003_saga",0,0,132,0,59591,7007,2999,88,7],
["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_003_saga",2004,1,160,0,31668,6829,2821,61,2],
["p1",9,"op","chess_char_5_diy1_a",2,null,2564,1,152,0,4398,5128,0,17,1],
["p1",10,"op","chess_char_5_diy2_b",2,"none",3334,1,145,0,2300,6668,0,14,1],
["p1",11,"op","chess_char_5_diy1_a",2,null,1271,1,4,0,15488,0,0,198,3],
["p1",12,"op","chess_char_5_diy2_b",2,"none",1510,1,5,2,18797,0,0,206,4],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
],
"enemies": {"enemy_10124_uashld_2":[18,0,18,25716,36236],"enemy_1043_zomsbr":[66,19,47,2425,177615],"enemy_1061_zomshd":[45,0,45,38785,118952]},
"hooks": [1,161,6868,1261,1261,3359,3255,44463,41,151,33,32,0,28415,0,62,129,110,1,49,0,0,104],
"events": {"atk":1302,"deploy":29,"die":41,"dmg":3255,"fx":167,"leak":110,"skill":65,"spawn":144,"status":44},
"fx": {"aoe":14,"buff":14,"dodge":104,"dp":18,"dying":17},
"snaps": "bba0f774 83a7fe2a 8e13d5aa 4bb67145 e79aa0b4 38e547de 36b43066 56a64a01 d4d007b7 6e27fab1 f6f8669c bbacc967 be4172d9 d55bad91 4d605caf fb745e4a 93002000 0e1af5c3 a3d025c7 82b41ecd 8e13ba87 c7c33611 5048d3b1"
},
"diy-016": {
"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_m05",
"modeId": "mode_multi_abyss",
"round": 12,
"seed": 1164779827,
"waveId": "act1autochess_h05",
"time": 230,
"ticks": 6900,
"reason": "timeout",
"killed": 10,
"total": 129,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 0,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":10,"total":129,"perfect":false,"leaks":119,"leaksUncounted":0,"coins":0,"dmg":1302151,"bossDmg":0,"heal":64999,"deaths":8,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",2,null,2604,1,146,0,4582,6677,0,21,1],
["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",3371,1,4,1,29987,38053,0,161,3],
["p1",3,"op","chess_char_5_diy1_a",2,null,1529,1,147,0,305817,7402,0,61,4],
["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",1904,1,148,1,673341,9901,0,65,5],
["p1",5,"op","chess_char_5_diy1_a",2,null,2638,1,9,0,110941,15041,0,161,4],
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,96,0,75825,6822,0,52,2],
["p1",7,"op","chess_char_5_diy1_a",2,null,1133,1,10,0,13450,0,0,63,2],
["p1",8,"op","chess_char_5_diy2_b",2,"none",1360,1,11,0,29042,0,0,114,3],
["p1",9,"op","chess_char_5_diy1_a",2,null,1218,1,1,0,0,0,28266,69,4],
["p1",10,"op","chess_char_5_diy2_b",2,"none",1374,1,3,0,0,0,36732,75,4],
["p1",11,"op","chess_char_5_diy1_a",2,null,932,1,5,0,25478,0,0,142,4],
["p1",12,"op","chess_char_5_diy2_b",2,"none",1131,1,7,0,33688,0,0,161,4]
],
"enemies": {"enemy_1288_duskls":[36,4,32,8694,384228],"enemy_1288_duskls_2":[51,4,47,18716,487509],"enemy_1292_duskld":[42,2,36,56485,593026]},
"hooks": [1,148,6900,1334,1334,6884,6884,144,18,133,40,35,0,39897,302,49,129,115,1,31,0,0,0],
"events": {"atk":1334,"deploy":19,"die":18,"dmg":6884,"fx":161,"heal":144,"leak":115,"skill":75,"spawn":141,"status":595},
"fx": {"aoe":69,"dp":10,"ember":10,"slash":72},
"snaps": "950798c3 af3aacaa 15bc2a8e 1db933ba afcb4e5a 9dd60327 313fb4fc a9a7c988 995cd476 18dfeaeb f0e3d1e3 5e96d2bc 85790c53 e2a78c2e 82e24969 b7cbc177 724c41b8 eeb13a15 4ca505dc 87cbb93c a1aec30f 6e8f8a35 88e54a01"
},
"diy-017": {
"about": "5_diy1_a=sharp2 5_diy2_b=sharp2 5_diy1_a=acspec 5_diy2_b=acspec 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc 5_diy1_a=acsupo 5_diy2_b=acsupo",
"fast": false,
"kind": "normal",
"stageId": "act1autochess_m06",
"modeId": "mode_multi_funny",
"round": 12,
"seed": 1791740796,
"waveId": "act1autochess_h05",
"time": 230,
"ticks": 6900,
"reason": "timeout",
"killed": 31,
"total": 129,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 1152,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":31,"total":129,"perfect":false,"leaks":98,"leaksUncounted":0,"coins":0,"dmg":706203,"bossDmg":0,"heal":65284,"deaths":9,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",0,null,0,0,143,1,16557,3730,0,65,2],
["p1",2,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",2374,1,6,10,297934,0,0,163,3],
["p1",3,"op","chess_char_5_diy1_a",2,null,1154,1,9,0,0,0,0,0,0],
["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",1448,1,12,0,9103,17433,0,245,0],
["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,144,4,33164,2544,0,102,1],
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",0,0,138,10,63698,3174,0,103,1],
["p1",7,"op","chess_char_5_diy1_a",2,null,1372,1,146,3,81968,1676,0,40,1],
["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",0,0,139,2,133296,3498,0,42,2],
["p1",9,"op","chess_char_5_diy1_a",2,null,1224,1,8,0,0,13670,39528,92,3],
["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",1414,1,10,0,0,11233,25756,85,3],
["p1",11,"op","chess_char_5_diy1_a",2,null,1160,1,11,0,34031,12085,0,145,4],
["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",1430,1,7,1,36453,10559,0,145,3]
],
"enemies": {"enemy_1288_duskls":[36,9,26,16821,139007],"enemy_1288_duskls_2":[51,12,38,16750,229385],"enemy_1292_duskld":[42,10,29,46031,337811]},
"hooks": [1,146,6900,1736,1736,2223,2411,326,40,133,23,20,0,32700,19289,76,129,93,1,122,0,0,12],
"events": {"atk":1984,"deploy":17,"die":40,"dmg":2411,"fx":186,"heal":276,"leak":93,"skill":43,"spawn":141,"status":1376},
"fx": {"aoe":9,"dodge":52,"ember":82,"revive":43},
"snaps": "554a5ebc 9e43a33e bb797ba0 b3fa2b0d a8d36bc6 31b302ef 529006ab 38501f79 36327a80 cf730116 c5d3cb85 d2c1eacc 1b1ccb54 2ee482e3 e9a344fb b1094511 cf3ba2a6 b06c6743 8b06b756 21ac81b3 56fa3031 a0a55b07 00a9721a"
},
"diy-018": {
"about": "6_diy1_a=acfend 6_diy2_b=acfend 6_diy1_a=acpion 6_diy2_b=acpion 6_diy1_a=acguad 6_diy2_b=acguad 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc",
"fast": false,
"kind": "normal",
"stageId": "act1autochess_m07",
"modeId": "mode_single_hard",
"round": 13,
"seed": 151019241,
"waveId": "act1autochess_h06",
"time": 229.47,
"ticks": 6884,
"reason": "cleared",
"killed": 14,
"total": 66,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 140,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":14,"total":66,"perfect":false,"leaks":52,"leaksUncounted":0,"coins":0,"dmg":145919,"bossDmg":0,"heal":10797,"deaths":22,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_6_diy1_a",2,null,0,0,3,0,0,2604,0,0,0],
["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",0,0,6,0,0,5264,0,0,0],
["p1",3,"op","chess_char_6_diy1_a",2,null,1529,1,92,1,8350,3058,0,0,3],
["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",1175.46,1,91,2,46935,5252,0,0,4],
["p1",5,"op","chess_char_6_diy1_a",2,null,2638,1,94,0,0,6617,0,0,0],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",0,0,56,0,0,7062,0,0,0],
["p1",7,"op","chess_char_6_diy1_a",2,null,1272,1,95,2,9262,2544,0,19,1],
["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",1643,1,93,2,37345,3989,0,26,1],
["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,9,3,21458,2098,0,8,1],
["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",0,0,57,4,22568,6623,0,10,1],
["p1",11,"op","chess_char_6_diy1_a",2,null,0,0,88,0,0,4800,1128,6,1],
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",0,0,76,0,0,4293,9669,12,1],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null]
],
"enemies": {"enemy_1005_yokai_2":[12,4,8,2859,10285],"enemy_1041_lazerd_2":[24,7,17,51346,73453],"enemy_1042_frostd":[30,3,27,0,62181]},
"hooks": [1,95,6884,219,219,409,406,30,36,88,13,13,0,15472,0,0,66,52,1,36,0,0,3],
"events": {"atk":265,"deploy":27,"die":36,"dmg":406,"fx":77,"heal":28,"leak":52,"skill":26,"spawn":80,"status":70},
"fx": {"aoe":8,"dodge":3,"dp":10,"slash":49,"spGain":7},
"snaps": "f2a9f471 04b39488 23993030 35ed6830 b5437606 d3f911db 07212186 f3242d93 226a6dd0 4e481925 fe09eafe ed71cd44 5fc4c6b7 9b821568 d267706d e3bb0e6d c9278acf 5dab0b7b e63dd125 ff34cf11 72bc01ed 3d6ce7c2 155f6593"
},
"diy-019": {
"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_m01",
"modeId": "mode_multi_normal",
"round": 13,
"seed": 777575195,
"waveId": "act1autochess_h06",
"time": 226.37,
"ticks": 6791,
"reason": "cleared",
"killed": 0,
"total": 123,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 48,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":0,"total":123,"perfect":false,"leaks":123,"leaksUncounted":0,"coins":0,"dmg":80622,"bossDmg":0,"heal":0,"deaths":26,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,84,0,2677,3730,0,10,0],
["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,81,0,10830,4988,0,10,1],
["p1",3,"op","chess_char_6_diy1_a",2,null,445.15,1,150,0,14344,12249,0,21,0],
["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",1305.72,1,151,0,40481,13534,0,25,0],
["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,144,0,5093,2320,0,13,1],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",0,0,2,0,7196,1450,0,12,1]
],
"enemies": {"enemy_1019_jshoot_2":[57,0,57,27300,13901],"enemy_1299_ymkilr_2":[27,0,27,8814,15599],"enemy_1422_lrsldr_2":[39,0,39,2157,51121]},
"hooks": [1,151,6791,268,268,311,322,0,26,149,3,22,0,2673,831,24,123,123,1,26,0,0,0],
"events": {"atk":269,"deploy":28,"die":26,"dmg":292,"fx":17,"leak":123,"skill":6,"spawn":129,"status":148},
"fx": {"displace":16,"dodge":1},
"snaps": "d3c36d93 c8105b93 41795df3 3046aeff a5dda3f7 c89d03fb 8b0755bf 812c58cf 0e95d7b1 e01cf127 9fac8774 2622d747 5741d29b ae6a2b21 361fa71f 174be52a dc2ff8bd 55bf9d28 6f794424 593fdf3b c893bb85 57a582f3 c2eec42e"
}
}
}
+84
View File
@@ -0,0 +1,84 @@
// test/sim/feedback5-o8-engine.test.js — two additive engine rules the 0.2.0 自选 kits of 夜莺 and 嵯峨 need:
// - a 屏障 of one damage type (buff `shieldType`, damage.js absorbShields): 夜莺 S2 法术护盾 "能吸收…法术伤害"; a shield
// without a type absorbs every type (PRTS 术语释义 屏障 "若无特殊说明,屏障可吸收全种类伤害"); older shields first;
// - a profile's own target exclusion (`skipEnemy(e)`, targeting.js canTargetEnemy): 嵯峨 "不攻击重伤单位" — no normal
// attack on such an enemy, not as a block target, and the DEFAULT trigger condition ignores it.
// Run: node --test test/sim/feedback5-o8-engine.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { canTargetEnemy } from '../../server/sim/targeting.js';
const ENEMIES = { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e9, speed: 0, mass: 0 }) };
const CHESS = { test_guard_a: chessRec({ id: 'test_guard_a', profession: 'WARRIOR', stats: { maxHp: 10000, atk: 300, def: 0, res: 0 }, skill: { duration: 5, spCost: 1, initSp: 0, trigger: { rule: 'DEFAULT' } } }) };
function field(kits = undefined) {
const h = makeBattle({
defs: { enemies: ENEMIES, chess: CHESS }, timeLimit: 600, autoFinish: false, seed: 3, kits,
hooks: ['damaged', 'skillStart'], captureNoisy: true,
units: [{ uid: 1, chessId: 'test_guard_a', row: 10, col: 5 }],
});
h.step();
return { h, u: h.unit(1) };
}
test('typed shield: an arts 屏障 absorbs arts damage only; an untyped one every type; the older shield is spent first', () => {
const { h, u } = field();
h.b.addBuff(u, { key: 'test:artsBarrier', shield: 100, shieldType: 'arts', duration: 30 });
for (const type of ['phys', 'true', 'elemental']) {
const hp = u.hp;
h.b.dealDamage(null, u, { amount: 10, type, canDodge: false });
assert.equal(u.hp, hp - 10, `${type} passes the arts barrier`);
}
assert.equal(u.findBuff('test:artsBarrier').shield, 100);
assert.equal(u.s.shield, 100, 'it counts in the shield bar');
let hp = u.hp;
h.b.dealDamage(null, u, { amount: 40, type: 'arts', canDodge: false });
assert.deepEqual([u.hp, u.findBuff('test:artsBarrier').shield], [hp, 60], 'arts absorbed');
// an untyped shield added later: arts goes to the older (typed) one first, physical to the untyped one
h.b.addBuff(u, { key: 'test:barrier', shield: 50, duration: 30 });
h.b.dealDamage(null, u, { amount: 30, type: 'arts', canDodge: false });
assert.deepEqual([u.findBuff('test:artsBarrier').shield, u.findBuff('test:barrier').shield], [30, 50], 'older first');
h.b.dealDamage(null, u, { amount: 20, type: 'phys', canDodge: false });
assert.deepEqual([u.findBuff('test:artsBarrier').shield, u.findBuff('test:barrier').shield], [30, 30], 'physical: the untyped one only');
hp = u.hp;
h.b.dealDamage(null, u, { amount: 100, type: 'arts', canDodge: false });
assert.equal(u.findBuff('test:artsBarrier'), null, 'spent and removed');
assert.equal(u.findBuff('test:barrier'), null, 'the rest on the untyped one, spent too');
assert.equal(u.hp, hp - 40, '100 − 30 − 30 reaches HP');
// a typed hit-negating barrier
h.b.addBuff(u, { key: 'test:physBlock', shieldHits: 1, shieldType: 'phys', duration: 30 });
hp = u.hp;
h.b.dealDamage(null, u, { amount: 25, type: 'arts', canDodge: false });
assert.equal(u.hp, hp - 25, 'arts passes a physical block');
h.b.dealDamage(null, u, { amount: 25, type: 'phys', canDodge: false });
assert.equal(u.hp, hp - 25, 'the physical hit is negated');
assert.equal(u.findBuff('test:physBlock'), null);
checkInvariants(h.b);
});
test('profile skipEnemy: the unit never selects such an enemy — no attack, no block target, no DEFAULT trigger — and other enemies stay its targets', () => {
const skip = new Set();
const { h, u } = field({ test_guard_a: () => ({ skill: { kind: 'duration' }, trait: { skipEnemy: (e) => skip.has(e) } }) });
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
skip.add(e);
assert.equal(canTargetEnemy(u, e, u.profile), false);
assert.equal(canTargetEnemy(u, e, { canHitFly: false }), true, 'another profile still may');
h.run(3);
assert.equal(h.hooksOf('damaged').filter((c) => c.source === u).length, 0, 'never attacked');
assert.equal(u.skill.activations, 0, 'DEFAULT: no target');
// blocked: still not a target
const w = h.spawn('enemy_dummy', { pos: [10, 5] });
skip.add(w);
h.run(1);
assert.ok(u.blocking.includes(w), 'she blocks it');
assert.equal(h.b.blockedTargets(u, u.profile).length, 0, 'not a block target');
assert.equal(h.hooksOf('damaged').filter((c) => c.source === u).length, 0);
// an ordinary enemy is attacked
const o = h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(h.runUntil(() => h.hooksOf('damaged').some((c) => c.source === u && c.target === o), 3), 'attacks the other one');
assert.ok(h.hooksOf('damaged').filter((c) => c.source === u).every((c) => c.target === o), 'only that one');
assert.ok(u.skill.activations >= 1, 'DEFAULT fires for it');
checkInvariants(h.b);
});