diff --git a/docs/DESIGN.md b/docs/DESIGN.md index eb0e8819..8c7a5828 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -292,7 +292,7 @@ Engine helpers (content must use these, never touch internals): `battle.dealDama - **Initial deployment** at t=0, free: operators by column — the left column first, top to bottom within a column (right-side boss player: from its own left, the highest field column) — then the summons (PRTS 卫戍协议/帮助 "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物", read as a scan: down each column, the columns left to right — the order PRTS gives the 阿戈尔 devour, "更靠左和靠上"; row-major until 0.1.3, §23.23; `aggroSeq` ranks every start-of-battle summon after all operators) — a skill's placed summon (赫默 医疗探机, 巫恋 诅咒娃娃) included, once and free ("所有手动部署的召唤物,无视所属干员的持有状态…作战开始时立即部署一次"; afterwards it re-appears on its tile each time the owner's skill gives one, §20.1); on a shared field (联防, boss) the players' i-th operators deploy together [ASSUMED]. Deploy effects fire (`deploy` with `initial: true`). Then operators carried into 联防 knocked out (`carryState.down`) are forced out (`FORCED_EXIT`, HP 0, §19.3). Then `battleStart`, after which the forced-out operators' timers are re-read (redeploy-time effects that start with the battle, e.g. 机变 征召, cover them too). - **DP**: per player, start 10, +1/s, cap 99; used only for auto-redeploy (dead op: after `respawnTime`, if the tile it lies on is free and DP ≥ cost ⇒ redeploy there with full HP, SP = initSp). A knocked-out operator (and, since 0.1.2, one forced out by its own effects, §22.15) stays **down** on the tile it fell on — its own home when it fell on another board piece's home (that piece on the field or not) and its home is free — until it redeploys there, and no ally deploys or moves onto that tile meanwhile (PRTS 卫戍协议/帮助 §作战阶段 单位部署; `Battle.downOn` / `restTile` / `_layBody`, §21.24; b.snap `down`, §18.3: counting / waiting for DP / its tile taken, a safeguard) — also one that entered 联防 knocked out (`FORCED_EXIT`, its full redeploy timer — confirmed by the user — with the battle-start redeploy effects, §19.3); summons, devices and enemies leave at once. -- **Blocking** (contact radius, PRTS 游戏数据基础 §阻挡半径 / 作战机制 "中点判定…案例:阻挡"; `constants.js BLOCK_RADIUS`, `Battle._checkBlock`, §19.2): an operator with free capacity blocks an unblocked, blockable enemy whose position lies within its block radius of its centre — ground 0.7071 (squared 0.49999037), 0.8944 for blockFly units vs flyers (起飞), devices 0.4472 (阻隔工事 / 障碍物); air units (FLY, 近地悬浮, 浮空) only by blockFly units. A ground enemy is never blocked by a unit standing on a tile ground units cannot pass (围墙 / 围栏: PRTS 地形机制 "部署在其中的单位,若当前阻挡类型为'地面阻挡'则无法阻挡敌人" — an enemy pushed or pulled against the fence walks on; air blocking there stays [ASSUMED], §21.23) nor by an airborne (起飞) unit, which blocks flyers only (§21.22). The nearest such operator wins (ties: scan order [ASSUMED]). It is re-checked every tick, moving or not, so another operator in contact with room takes the enemy over when its blocker dies, is withdrawn or is stunned, and an operator whose capacity frees up grabs an overlapping enemy; one that finds no room walks on. A head-on enemy stops at contact (≈ 0.71 tile), outside the blocker's tile. Enemy uses `blockCnt` capacity (default 1). Blocked enemies stop moving and attack their blocker if melee (or anyone in range if ranged, blocker first). **Every blocker** — melee units (重装要塞 / 领主 / 哨戒铁卫 included), summons and a ranged operator on a melee tile alike — whose attack hits enemies may always target the enemies it blocks, in range or not, whatever its facing, and targets them first ("可以选择且优先选择阻挡单位", PRTS 选择器; the user's rule after playtest #6, "阻挡了就一定要能打到": officially the collision pushes a blocked enemy to its blocker's front; `Battle.blockedTargets`, `targeting.js sortEnemyTargets`; §20.3 — it replaces the playtest #5 QA's melee-only restriction of §19.2). A healer (dmgType `heal`, or a skill attack turned into a heal) selects injured allies only and keeps healing while it blocks (PRTS 卫戍协议/帮助 "对于医疗干员(咒愈师分支除外),攻击目标为需要治疗的单位"; §21.17); 咒愈师 follow the blocker rule. The huge leaders are 自缚 + unblockable (§7). +- **Blocking** (contact radius, PRTS 游戏数据基础 §阻挡半径 / 作战机制 "中点判定…案例:阻挡"; `constants.js BLOCK_RADIUS`, `Battle._checkBlock`, §19.2): an operator with free capacity blocks an unblocked, blockable enemy whose position lies within its block radius of its centre — ground 0.7071 (squared 0.49999037), 0.8944 × the blocker's 阻挡半径倍率 for blockFly units vs flyers (起飞; mod `blockRadiusScale` = 倍率 − 1 — PRTS 游戏数据基础 "飞行阻挡半径 = 0.8944 × 阻挡半径倍率", 凯尔希·思衡托 +0.23), devices 0.4472 (阻隔工事 / 障碍物); air units (FLY, 近地悬浮, 浮空) only by blockFly units. A ground enemy is never blocked by a unit standing on a tile ground units cannot pass (围墙 / 围栏: PRTS 地形机制 "部署在其中的单位,若当前阻挡类型为'地面阻挡'则无法阻挡敌人" — an enemy pushed or pulled against the fence walks on; air blocking there stays [ASSUMED], §21.23) nor by an airborne (起飞) unit, which blocks flyers only (§21.22). The nearest such operator wins (ties: scan order [ASSUMED]). It is re-checked every tick, moving or not, so another operator in contact with room takes the enemy over when its blocker dies, is withdrawn or is stunned, and an operator whose capacity frees up grabs an overlapping enemy; one that finds no room walks on. A head-on enemy stops at contact (≈ 0.71 tile), outside the blocker's tile. Enemy uses `blockCnt` capacity (default 1). Blocked enemies stop moving and attack their blocker if melee (or anyone in range if ranged, blocker first). **Every blocker** — melee units (重装要塞 / 领主 / 哨戒铁卫 included), summons and a ranged operator on a melee tile alike — whose attack hits enemies may always target the enemies it blocks, in range or not, whatever its facing, and targets them first ("可以选择且优先选择阻挡单位", PRTS 选择器; the user's rule after playtest #6, "阻挡了就一定要能打到": officially the collision pushes a blocked enemy to its blocker's front; `Battle.blockedTargets`, `targeting.js sortEnemyTargets`; §20.3 — it replaces the playtest #5 QA's melee-only restriction of §19.2). A healer (dmgType `heal`, or a skill attack turned into a heal) selects injured allies only and keeps healing while it blocks (PRTS 卫戍协议/帮助 "对于医疗干员(咒愈师分支除外),攻击目标为需要治疗的单位"; §21.17); 咒愈师 follow the blocker rule. The huge leaders are 自缚 + unblockable (§7). - **Operator attacks**: every `interval` s if a valid target exists; targets = up to `maxTargets` (default 1) from `unitsInGrid(rangeGrid)` (a `rangeAoe` profile — 阵法术师, 轰击术师 — takes every selectable enemy in range, §21.18), priority: (1) the enemies it blocks (every blocker, in range or not, §20.3), (2) profession/trait priorities (e.g. fly-first snipers, `trait` text parsed by build-data into `targetPriority`), (3) enemy with the **least remaining path distance** to its goal, ties → earliest spawned. Melee (no ranged trait) and ground-only attacks cannot hit air units (`Unit.isFlying`: FLY, or a 近地悬浮 / 浮空 enemy — PRTS "算作空中单位", §19.1). Stealthed enemies can't be targeted unless blocked or revealed — or within 3 s of a block's end (§22.8). Ranged ops fire projectiles (speed ~12 tiles/s, instant for melee/arts-beam classes as data indicates); damage applies on impact; a 锁定攻击范围 AoE (`rangeAoe`) strikes every selectable enemy on its range at once with instant hits (§21.18). - **Healers** (`dmgType heal`): target the ally in range with the lowest HP% below 100%, never a 禁疗 / 孤立 unit (tokens.json `abnormal`, `noHeal`; §20.3; 史尔特尔 during 余烬, §22.7), and never the enemies they block — also while blocking (§21.17); do nothing otherwise (SP still regenerates). - **Enemy AI**: follow the route (checkpoints; WALK paths recomputed on the grid when devices change); when blocked stop & fight (`dmgType 'none'` enemies never attack unless content arms them through `e.profile` — 转译基底's armed forms, §21.4); ranged enemies (`rangeRadius > 0`) attack ops whose 0.25 collider touches their radius (centre distance ≤ `rangeRadius` + 0.25, §3, §20.4): the enemy's own target rule filters the candidates first (`profile.canTarget`: 只攻击地面单位 …), then priority blocker → special priority → highest taunt → **latest deployed** (`aggroSeq`) — PRTS 索敌 "阻挡→特殊优先级→仇恨值→最早出现"; a stealthed ally is attacked only by the enemy it blocks (§19.1), and an enemy's area effects — splash, blasts, area skills, zones, jumps — and buff auras skip it unless it blocks that enemy, while 迷彩 does not protect from splash and the other area effects (`targeting.js areaSelectable` / `auraSelectable`, §22.12); an airborne (起飞) ally is never a ground enemy's selection — picks, area damage, zones and statuses skip it, while 无视无法选择 abilities, direct picks, debuff ticks and flying units still reach it (§21.22); unblocked, they stand for each attack's clip (enemies.json `attackAnim`: its wind-up before the strike and the rest of the clip after it, shortened when the attacks come quicker than the clip; `ATTACK_PAUSE = 0.35 s` after the strike when no clip is known; a 「不停止移动」 attacker never stops; §22.14). FLY enemies ignore ground obstacles (only blockFly units block them) and follow their checkpoint route. A feared enemy runs between random 恐惧可达地块 away from the source (PRTS 诱发移动; `fear.js`); 恐惧 outranks 诱导; the 恐惧 / 诱导 walks re-plan from where the enemy stands after a push or an obstacle change, and the route re-plans when they end (§20.4). diff --git a/docs/SIM.md b/docs/SIM.md index 6dc2251e..df9ceee4 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -219,8 +219,10 @@ to a new pick. A `wait` leg keeps counting; when the fear ends the route re-plan **Blocking** (`Battle._checkBlock`, official contact rule — PRTS 游戏数据基础 §阻挡半径, 作战机制 "中点判定 … 案例: 阻挡"; user playtest #5 item 4): an unblocked, blockable enemy is blocked by an ally (or device) whose centre is within its block radius of the enemy's position — `constants.js BLOCK_RADIUS`: ground 0.7071 (compared as d² < 0.49999037, the -tile's circumscribed circle), air 0.8944 (blockFly units against flyers; an airborne 起飞 unit — flag `liftoff` — blocks -flyers only, PRTS 术语释义 起飞 "阻挡模式变为空中阻挡"), devices 0.4472 — while that blocker has free +tile's circumscribed circle), air 0.8944 × the blocker's 阻挡半径倍率 (blockFly units against flyers; an airborne 起飞 +unit — flag `liftoff` — blocks flyers only, PRTS 术语释义 起飞 "阻挡模式变为空中阻挡"; mod `blockRadiusScale` = 倍率 − 1, +PRTS 游戏数据基础 "飞行阻挡半径 = 0.8944 × 阻挡半径倍率": 凯尔希·思衡托's 遗尘守望 and S1, +0.23 — ground blocking ignores it), +devices 0.4472 — while that blocker has free capacity for the enemy's `blockWeight` (data `blockCnt`). A unit standing on a tile ground units cannot pass — the fenced 围墙 / 围栏 tiles (`b`: low, deployable, flyers only; the only low tiles of that kind on the stages) — blocks no ground enemy (PRTS 围墙 / 围栏 地形机制 "部署在其中的单位,若当前阻挡类型为'地面阻挡'则无法阻挡敌人"; @@ -483,7 +485,8 @@ targets take more damage, pull strength), movement, pathing, terrain under the e heuristics and the range keys taken from blocked enemies (a huge enemy is never blocked) keep the position. `unit.s` (lazy, recomputed after any buff change): `maxHp, atk, def, res, aspd, bat, interval, blockCnt, moveSpeed, -rangeExtend, baseRangeExtend (its permanent part: persist + never-expiring buffs), massLevel (base + ΣmassFlat, ≥ 0 — +rangeExtend, blockRadiusScale (阻挡半径倍率 − 1: the air-block radius, §1.2), baseRangeExtend (its permanent part: persist + +never-expiring buffs), massLevel (base + ΣmassFlat, ≥ 0 — `unit.weight`), maxTargets (+n), taunt, dodgePhys, dodgeArts, defIgnoreFlat/Pct, resIgnoreFlat/Pct, dmgDealtMul, physDealtMul, artsDealtMul, dmgTakenMul, physTakenMul, artsTakenMul, trueTakenMul, elemTakenMul (元素损伤倍率: gauge fills), elementalTakenMul (元素脆弱: 元素伤害), healingDealtMul, healingTakenMul, atkScaleMul, spRecovery, spCostFlat, redeployMul, @@ -524,7 +527,8 @@ mods, flags, onTick(ctx), interval, onExpire(ctx), onRemove(ctx), tags, shield, **Mod keys** — additive: `atkFlat atkPct defFlat defPct hpFlat hpPct resFlat aspd batPct blockCnt rangeExtend defIgnoreFlat defIgnorePct resIgnoreFlat resIgnorePct dodgePhys dodgeArts spRecoveryFlat maxTargets taunt hpRegen -hpRegenRatio spCostFlat moveFlat massFlat` (重量 levels: 失重 = `massFlat: −1`; never edit `base.massLevel`); +hpRegenRatio spCostFlat moveFlat massFlat blockRadiusScale` (重量 levels: 失重 = `massFlat: −1`; never edit `base.massLevel`; +`blockRadiusScale` = 阻挡半径倍率 − 1, the air-block radius of §1.2); multiplicative: `atkMul defMul hpMul resMul moveMul dmgDealtMul dmgTakenMul physTakenMul artsTakenMul trueTakenMul elemTakenMul elementalTakenMul healingDealtMul healingTakenMul spRecoveryMul redeployMul atkScaleMul physDealtMul artsDealtMul`. A `rangeExtend` on a `persist` never-expiring buff is **permanent**: it also widens the initial range (§7.1). It widens diff --git a/server/sim/battle/blocking.js b/server/sim/battle/blocking.js index 53b81c59..b7d32234 100644 --- a/server/sim/battle/blocking.js +++ b/server/sim/battle/blocking.js @@ -14,7 +14,9 @@ export class BattleBlocking { * a fenced tile blocks no ground enemy, _blockerFor). Checked every tick for every * unblocked enemy, moving or not, so an enemy overlapping an operator is taken over as soon as its blocker is gone or * the operator's capacity frees up (user playtest #5 item 4). Several blockers in contact → the nearest [ASSUMED], - * ties → the first in row-then-column scan order. + * ties → the first in row-then-column scan order. The air radius grows with the blocker's 阻挡半径倍率 (PRTS 游戏数据基础 + * "飞行阻挡半径 = 0.8944 × 阻挡半径倍率"; mod `blockRadiusScale` = 倍率 − 1: 凯尔希·思衡托's 遗尘守望 / S1, +0.23); ground + * blocking ignores it (PRTS 数值范围 "对地面阻挡的单位不生效"). Below 1.5 tiles the 3×3 scan still finds every blocker. */ _checkBlock(e) { if (e.blockedBy || e.hidden || !e.alive) return !!e.blockedBy; @@ -32,7 +34,8 @@ export class BattleBlocking { const o = this._occ[r * COLS + c]; if (!o || !this._blockerFor(o, e, w)) continue; const d2 = (e.x - c) * (e.x - c) + (e.y - r) * (e.y - r); - const r2 = o.kind === 'device' ? BLOCK_RADIUS_SQ.device : fly ? BLOCK_RADIUS_SQ.fly : BLOCK_RADIUS_SQ.ground; + const k = fly ? 1 + (o.s.blockRadiusScale || 0) : 1; + const r2 = o.kind === 'device' ? BLOCK_RADIUS_SQ.device : fly ? BLOCK_RADIUS_SQ.fly * k * k : BLOCK_RADIUS_SQ.ground; if (d2 < r2 && d2 < bd) { u = o; bd = d2; } } } diff --git a/server/sim/buffs.js b/server/sim/buffs.js index d65c7d2a..d4cf4ded 100644 --- a/server/sim/buffs.js +++ b/server/sim/buffs.js @@ -21,6 +21,8 @@ export const ADD_KEYS = Object.freeze([ 'atkFlat', 'atkPct', 'defFlat', 'defPct', 'hpFlat', 'hpPct', 'resFlat', 'aspd', 'batPct', 'blockCnt', 'rangeExtend', 'defIgnoreFlat', 'defIgnorePct', 'resIgnoreFlat', 'resIgnorePct', 'dodgePhys', 'dodgeArts', 'spRecoveryFlat', 'maxTargets', 'taunt', 'hpRegen', 'hpRegenRatio', 'spCostFlat', 'moveFlat', 'massFlat', + // 阻挡半径倍率 (PRTS 数值范围 BLOCK_RADIUS_SCALE, default 1): the air-block radius 0.8944 × (1 + Σ) — Battle._checkBlock + 'blockRadiusScale', ]); /** Multiplicative mod keys (product; ^ stacks). */ export const MUL_KEYS = Object.freeze([ diff --git a/server/sim/content/kits/index.js b/server/sim/content/kits/index.js index 639bb013..24c610ce 100644 --- a/server/sim/content/kits/index.js +++ b/server/sim/content/kits/index.js @@ -133,6 +133,8 @@ export const OPERATOR_KIT_FILES = Object.freeze([ 'op-haak.js', 'op-phatom.js', 'op-crosly.js', + 'op-kalts2.js', + 'op-monstr.js', ]); /** diff --git a/server/sim/content/kits/ops/op-kalts2.js b/server/sim/content/kits/ops/op-kalts2.js new file mode 100644 index 00000000..bf25f235 --- /dev/null +++ b/server/sim/content/kits/ops/op-kalts2.js @@ -0,0 +1,359 @@ +// server/sim/content/kits/ops/op-kalts2.js — 凯尔希·思衡托 (char_1052_kalts2) 自选 operator kit: 6★ 守望者 (医疗), an owned-6★ +// pick of the tier-5 and tier-6 自选 slots; every skill, both talents and the trait at every form (she has no module), +// and the kit of her summon 战术锚点 (token_10068_kalts2_mtship). Kit contract and the 自选 rules: ../README.md ("How to add +// an operator (自选)"). +// +// Forms (data/backups.json units.char_1052_kalts2): normal = E2 Lv1, skills at rank 4; elite = E2 Lv60, rank 7 (no module, +// so tiers 5 and 6 field the same elite) — the owner's decision of 2026-10-05. Potential 0 [ASSUMED: no account]. +// Sources: character_table / skill_table / token_table (zh_CN, as built into backups.json); PRTS 凯尔希·思衡托 (T1 备注 +// "治疗效果无视禁疗…除非该目标为Mon3tr(凯尔希的召唤物)", "阻挡范围加成为提升自身的“阻挡半径倍率”属性"; T2 备注 "无视孤立", +// "“进入”的方式包括…部署时攻击范围碰撞到单位、单位部署在…攻击范围内…2技能攻击范围扩大时额外区域碰撞到的单位…3技能移动前发射的弹道 +// 的碰撞", "来自此天赋的护盾最高1层"; S1 备注 "仅判断目标是否持有对地规避"; S2 备注 "伤害与治疗溅射半径均为1.5,可对空;伤害为真实普 +// 通伤害", the enemy / ally target order and effect order; S3 备注: the operator choice, 【待投放】, the 3.5-speed 2.2-radius flight, +// its 无敌 / 强制缴械 / 不可阻挡, the paused skill time, the landing facing); PRTS 战术锚点 ("常态持有无敌"); PRTS 卫戍协议/帮助 +// (§战斗部署 placed summons, §作战阶段 auto redeploy "满足再部署条件时…自动部署至该位置"); gamedata_const ba.liftoff (起飞 +// "阻挡模式变为空中阻挡…阻挡半径变为0.8944"); PRTS 游戏数据基础 §阻挡半径 ("飞行阻挡半径 = 0.8944 × 阻挡半径倍率"); the client's battle +// data: charpack char_1052_kalts2 (attack triggers `_ignoreHealFree` + `_mon3terId` token_10002_kalts_mon3tr; talent "1": +// kalts2_t_1 [abnormal 35] + kalts2_t_1[buff] (MAX_HP / DEF MULTIPLIER, BLOCK_CNT ADDITION) + block_radius_scaler[inf]; talent +// "2" aura kalts2_t_2 (professionMask 639 = operators, ignoreTargetFree, `_removeBuffWhenTargetLeave`); mode s1 Aura +// block_radius_scaler[inf] on allies holding abnormal 35, `_selfOption` 2, `_interval` 0.3; mode s2 AttackEnemy (PURE, +// activeBuff sluggish, targetMotion 3) / HealAlly abilities, 10-count; mode s3 trigger `_maxNum` 2; StartTransporting +// (projectile_chr_kalts2_s3_2, activeBuff kalts2_t_2); S3OperatorSelector (`_maxNum` 2, `_checkPlayerSideSameWithOwner`); +// Invincible abnormal [5, 18, 3]); buff_template_data (kalts2_t_1, kalts2_t_2, kalts2_t_2[hp_recover] HP_RECOVERY_PER_SEC +// ×rhodes_bonus for group rhodes, kalts2_t_2[shield] BlockDamage once, INFINITY; kalts2_s_3 / [core] / [token_ctrl] +// RechargeToken cnt 1 then WithdrawTokens + cnt_minus 0; kalts2_s_3[on_token_born], kalts2_s3_trigger_transporting +// (every 0.1 s), kalts2_s_3[withdraw] (0.6 s, cost_scale 0, Withdraw); token_mtship_t_1 [abnormal 5] → on_token_born on its +// host); the projectile prefabs (projectile_chr_kalts2_s2_dmg / _heal speed 12 + the 1.5 sub-ranges; _s3_2 speed 3.5). +// - Trait (守望者) "恢复友方单位生命,并且可以起飞": the MEDIC profile (one heal per attack on the lowest-HP-ratio injured ally of +// her y-6). Her heals and their selection pass 凯尔希's Mon3tr (token_10002_kalts_mon3tr) through its 禁疗 (`healThrough`); +// other 禁疗 units stay out of the selection, and "治疗效果无视禁疗" changes nothing else here (no heal of hers reaches an +// unselected 禁疗 unit). 起飞 itself is T1's. +// - T1 遗尘守望: max HP / DEF +max_hp / +def (0.25), block +block_cnt, 阻挡半径倍率 +block_radius_scale (mod +// `blockRadiusScale`: her air-block radius 0.8944 × 1.23, Battle._checkBlock), and 起飞 from her deployment (flags +// `liftoff` + `blockFly`: no ground enemy selects or is blocked by her, she blocks flyers — kalts2_t_1 ChangeCharBlockMode +// FLY, from a ranged tile too). The 生命修复单元 is her model. +// - T2 医者丰碑: every other friendly OPERATOR (no summon: professionMask 639; 孤立 ones too) that enters her attack range — +// her current one, S2's y-11 included — gets 1 护盾 layer (one damage instance blocked whole, no expiry, at most one from +// this talent) and 生命回复速度 +hp_recovery_per_sec (×rhodes_bonus for a 罗德岛 operator, nationId rhodes) for buff_duration +// s (an hpRegen buff: HP_RECOVERY_PER_SEC, never a heal — kits/README.md item 11; 不可叠加: the strongest, a new entry refreshes +// it). An entry = not in her range on the tick before: her (re)deployment and her S3 landing (everyone in range), a unit +// deployed / redeployed / moved into it, the range growing; S3's flight touches more (below). +// - S1 应急肃正防线 (MANUAL, data DEFAULT — a heal skill: cast as she is about to heal): ATK +atk, ASPD +attack_speed for its +// 35 s; every OTHER friendly operator of the field holding 起飞 (flag `liftoff`: 蒂比's skills, 司霆惊蛰) gets 阻挡半径倍率 +// +attack@block_radius_scale while it runs (refreshed every 0.3 s; 同名效果取最高 — nothing on a unit whose own 倍率 is at +// least as high) [ASSUMED: the whole field — the aura has no range selector and the text says 所有]. +// - S2 保护性拒止 (MANUAL, data ACTIVE_RANGE on its y-11 — the owner's rule for a heal skill: an injured ally inside the y-11; +// 10 发弹药): range y-11, ATK +atk. Each attack fires a 医疗单元: at an enemy she can select (air units too; the ones she +// blocks first, then the usual order) — on impact every enemy within 1.5 of it takes attack@atk_scale × ATK true damage +// (普通伤害: an attack hit, not 溅射) and attack@sluggish s 停顿, then every ally within 1.5 (summons too: the heal range +// selector has no profession mask; no 孤立 one) is healed for attack@heal_scale × ATK; with no enemy to select, at the +// lowest-HP-ratio injured ally of the y-11 — heal first, then the damage, no 停顿. The shot flies at the engine's orb speed +// (10 tiles/s; the official 12) [ASSUMED] and fizzles when its enemy dies on the way [ASSUMED]. "可以随时停止技能": the +// automatic battle never stops it. +// - S3 破梏重生 (MANUAL, data DEFAULT, 35 s): ATK +atk, base attack time base_attack_time (a flat −1.55 s on her 2.85 s), one +// more heal target (2). "立刻获得战术锚点": one anchor in stock (content/tokens.js releaseSkillSummon — the placed piece +// deploys on its tile if it is off the field and ready). With her anchor standing (one deployed while she stood: +// kalts2_s_3[on_token_born] on her, lost when she leaves — `k2link`), 0.1 s after the cast (kalts2_s3_trigger_transporting), +// or at once when an anchor deploys while S3 runs: +// ① up to 2 other operators of her player inside her attack range — the latest deployed first (PRTS "部署时间点最晚的"; +// the battle-start board deploys in one tick here, so its order — `deploySeq` — stands for the official one-by-one +// deployment times) — are withdrawn 0.6 s later into 【待投放】 (部署费用 ×0, 再部署时间 0, only inside her +// pre-move range): the automatic battle redeploys them at once on their tiles, free — a fresh deployment (full HP, initial +// SP) [ASSUMED: the 卫戍 auto redeploy of a 待投放 operator, PRTS 卫戍协议/帮助 "满足再部署条件时…自动部署至该位置"]; +// ② she leaves the field visually (hidden: 无敌, 缴械, 不可阻挡, no status) and flies from her tile to the anchor at 3.5 +// tiles/s, her skill time paused; every operator within 2.2 of the flying point gets T2's entry (once per flight), and +// her range stays on her tile until she lands (the 待投放 operators redeployed there enter it); +// ③ on arrival the anchor is withdrawn (her old instance's token_ctrl ends: WithdrawTokens, stock 0) and she 【移动】s +// (Battle.moveRedeploy) onto its tile, facing the anchor's direction; the skill goes on with its remaining time. Should +// the move be refused, she comes back on her own tile [ASSUMED; officially she is knocked out]. +// At the skill's end any anchor of hers on the field is withdrawn and the stock emptied (token_ctrl). The flight's begin +// clip and its 生命回复速度 ×0 are not modelled [ASSUMED]. +// - 战术锚点 (token_10068_kalts2_mtship, placed in prep — "仅可以部署在凯尔希·思衡托攻击范围外的远程位" is the match's +// placement rule): 无敌 and never targeted ("不会受到攻击"), no attack, blocks nothing; its range (hers) is only shown. +// A placed piece is docked by content/tokens.js (a skill's summon: deployed for free at the battle start, then only when +// her S3 gives one and its 70 s redeploy time has passed, on its own tile). + +import { num, talentBb, skillRec, batMod, up } from '../shared/tier1.js'; +import { releaseSkillSummon } from '../../tokens.js'; +import { COLS } from '../../../constants.js'; + +const S1 = 'skchr_kalts2_1'; +const S2 = 'skchr_kalts2_2'; +const S3 = 'skchr_kalts2_3'; +/** Her summon 战术锚点 (S3). */ +export const ANCHOR = 'token_10068_kalts2_mtship'; +/** 凯尔希's Mon3tr: her heals and their selection pass its 禁疗 (charpack `_mon3terId`). */ +const KALTS_MON3TR = 'token_10002_kalts_mon3tr'; +/** S2's damage / heal radius around the struck unit (PRTS S2 备注 "伤害与治疗溅射半径均为1.5"). */ +const S2_RADIUS = 1.5; +/** S1's aura refresh (charpack mode s1 Aura `_interval`). */ +const S1_AURA_IV = 0.3; +/** S3: the first transport check after the cast (kalts2_s3_trigger_transporting triggerInterval). */ +const TRANSPORT_CHECK = 0.1; +/** S3: the flight speed (projectile_chr_kalts2_s3_2 `_speed`) and its collision radius (PRTS S3 备注). */ +const TRANSPORT_SPEED = 3.5; +const TRANSPORT_RADIUS = 2.2; +/** S3: the operators chosen are withdrawn this long after the flight starts (kalts2_s_3[withdraw] firstTriggerInterval). */ +const WITHDRAW_DELAY = 0.6; +/** S3: at most this many operators (S3OperatorSelector `_maxNum`). */ +const WITHDRAW_MAX = 2; + +const T1_KEY = 'talent:kalts2:t1'; +const T2_SHIELD = 'talent:kalts2:shield'; +const T2_REGEN = 'talent:kalts2:regen'; +const S1_KEY = 'skill:kalts2:blockRadius'; +const ANCHOR_KEY = 'kalts2:anchor'; +const TAG_S2 = 'kalts2:s2'; + +const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; +const skillOn = (unit, id) => !!(unit.skill && unit.skill.active && unit.skill.id === id); +const isAnchorOf = (t, unit) => !!t && t.kind === 'token' && t.defId === ANCHOR && t.ownerUnit === unit; +const anchorsOf = (battle, unit) => battle.allyUnits.filter((t) => isAnchorOf(t, unit)); +const isRhodes = (a) => (a?.def?.raw?.nationId ?? a?.def?.nationId) === 'rhodes'; +/** A friendly operator other than `unit` standing on the field. */ +const otherOp = (a, unit) => a !== unit && a.kind === 'op' && up(a) && !a.hidden; +/** The anchor stock content/tokens.js releaseSkillSummon keeps on its owner. */ +function setStock(unit, n) { + const s = unit.mem.summonStock || (unit.mem.summonStock = {}); + s[ANCHOR] = n; +} + +/** The 战术锚点's kit: 无敌, never targeted, no attack, blocks nothing. */ +function anchorKit() { + return { + skill: null, + talents: [], + trait: { noAttack: true }, + install(battle, t) { + battle.addBuff(t, { key: ANCHOR_KEY, flags: { invulnerable: true, untargetable: true, noBlock: true }, persist: true, allowDead: true }); + }, + }; +} + +/** T2's entry for operator `a`: 1 护盾 layer unless one from this talent is there, the 生命回复速度 buff (strongest). */ +function enterT2(battle, unit, a, t1) { + if (!a.findBuff(T2_SHIELD)) battle.addBuff(a, { key: T2_SHIELD, shieldHits: 1, source: unit, tags: ['talent'] }); + const v = num(t1.hp_recovery_per_sec) * (isRhodes(a) ? num(t1.rhodes_bonus, 1) : 1); + const d = num(t1.buff_duration); + if (v > 0 && d > 0) battle.applyStrongest(a, T2_REGEN, { duration: d, value: v, mods: (x) => ({ hpRegen: x }), source: unit }); + battle.fx('shield', { x: a.x, y: a.y, id: a.id }); +} + +/** + * S2's 医疗单元 landing on `target` (an enemy, or an injured ally when no enemy could be selected): true damage + 停顿 on + * the enemies within 1.5, a heal on the allies within 1.5 — damage first on an enemy, heal first (and no 停顿) on an ally. + */ +function s2Burst(battle, unit, target, attackId, b2) { + const x = target.x, y = target.y; + const atk = unit.s.atk; + const dmgAmt = atk * num(b2['attack@atk_scale'], 1); + const healAmt = atk * num(b2['attack@heal_scale'], 1); + const slug = num(b2['attack@sluggish']); + const onEnemy = target.side === 'enemy'; + const hurt = () => { + for (const e of battle.foesInRadius(x, y, S2_RADIUS, true)) { + if (!e.alive) continue; + battle.dealDamage(unit, e, { amount: dmgAmt, type: 'true', isAttack: true, isSkill: true, attackId, tags: [TAG_S2] }); + if (onEnemy && slug > 0 && e.alive) battle.applyStatus(e, 'sluggish', { duration: slug, source: unit }); + } + }; + const mend = () => { + for (const a of battle.alliesInRadius(x, y, S2_RADIUS)) if (battle.allySelectable(a, unit)) battle.heal(unit, a, healAmt, { ignoreHealFree: true }); + }; + battle.fx('aoe', { x, y, radius: S2_RADIUS, id: unit.id, skill: TAG_S2 }); + battle.fx('healAoe', { x, y, radius: S2_RADIUS, id: unit.id }); + if (onEnemy) { hurt(); mend(); } else { mend(); hurt(); } +} + +/** + * S2's HealAlly ability: with no enemy to select in her range (blocked ones included) she fires at the lowest-HP-ratio + * injured ally instead (an attack in every respect: Battle.forceAttack, one bullet). Returns false so the engine's attack + * loop makes no attack of its own then. + */ +function s2CanAttack(battle, unit) { + const prof = { canHitFly: true }; + if (battle.enemiesInKeys(unit.rangeKeys, unit, prof).length || battle.blockedTargets(unit, prof).length) return true; + const ally = battle.injuredAlliesInKeys(unit.rangeKeys, unit)[0]; + if (ally && battle.forceAttack(unit, [ally])) unit.atkCd = Math.max(unit.atkCd, unit.s.interval); + return false; +} + +/** The flight's point at the current time. */ +function flightPoint(battle, f) { + const k = f.T > 0 ? Math.min(1, Math.max(0, (battle.time - f.t0) / f.T)) : 1; + return { x: f.from.x + (f.to.x - f.from.x) * k, y: f.from.y + (f.to.y - f.from.y) * k }; +} + +/** The flight's collisions: T2's entry for every operator within 2.2 of its point (once per flight). */ +function collide(battle, unit, f) { + const enter = unit.mem.kalts2Enter; + if (!enter) return; + const p = flightPoint(battle, f); + const r2 = TRANSPORT_RADIUS * TRANSPORT_RADIUS + 1e-9; + for (const a of battle.allyUnits) { + if (!otherOp(a, unit) || f.hit.has(a)) continue; + if ((a.x - p.x) * (a.x - p.x) + (a.y - p.y) * (a.y - p.y) > r2) continue; + f.hit.add(a); + enter(a); + } +} + +/** S3 ③: the arrival — the anchor withdrawn, the stock emptied, a 【移动】 onto its tile facing its direction. */ +function land(battle, unit) { + const f = unit.mem.k2flight; + if (!f) return; + unit.mem.k2flight = null; + unit.hidden = false; + if (!unit.alive || !unit.deployed) return; + collide(battle, unit, f); + unit.mem.k2landed = true; + setStock(unit, 0); + if (f.anchor.alive) battle.retreat(f.anchor, { reason: 'expired' }); + const dir0 = unit.dir; + unit.dir = f.dir; + if (!battle.moveRedeploy(unit, f.r, f.c)) { + unit.dir = dir0; + battle.refreshRange(unit); + } + battle.fx('teleport', { x: unit.x, y: unit.y, id: unit.id, fromX: f.from.x, fromY: f.from.y }); +} + +/** S3 ①②: the 待投放 operators and the flight to `anchor`. */ +function startTransport(battle, unit, anchor) { + const picks = battle.allyUnits + .filter((a) => otherOp(a, unit) && a.ownerId === unit.ownerId && unit.rangeKeySet?.has(a.tileR * COLS + a.tileC)) + .sort((a, b) => b.deploySeq - a.deploySeq) + .slice(0, WITHDRAW_MAX); + for (const a of picks) { + const seq = a.deploySeq; + battle.after(WITHDRAW_DELAY, () => { + // kalts2_s_3[withdraw]: CheckUnitAlive(her) + CheckCharSkillAffecting(her), then Withdraw — and back at once + if (!up(a) || a.deploySeq !== seq || !unit.alive || !skillOn(unit, S3)) return; + battle.retreat(a, { reason: 'retreat' }); + if (a.alive) return; + if (!battle.redeploy(a, { free: true })) a.respawnAt = Math.min(a.respawnAt, battle.time); // 再部署时间视为0 + }, { owner: unit }); + } + const from = { x: unit.x, y: unit.y }, to = { x: anchor.x, y: anchor.y }; + const T = Math.hypot(to.x - from.x, to.y - from.y) / TRANSPORT_SPEED; + const f = { t0: battle.time, T, from, to, hit: new Set(), anchor, r: anchor.tileR, c: anchor.tileC, dir: anchor.dir }; + unit.mem.k2flight = f; + battle.releaseBlocked(unit); + unit.hidden = true; + battle.fx('disappear', { x: unit.x, y: unit.y, id: unit.id }); + collide(battle, unit, f); + battle.after(T, () => land(battle, unit), { owner: unit }); +} + +/** S3: start the transport when it can (S3 on, not done yet this cast, her linked anchor standing). */ +function tryTransport(battle, unit) { + const m = unit.mem; + if (!skillOn(unit, S3) || m.k2flight || m.k2landed || !up(unit) || unit.hidden) return; + const a = m.k2link; + if (!a || !up(a) || !isAnchorOf(a, unit)) return; + startTransport(battle, unit, a); +} + +export default { + char_1052_kalts2: (bb, chess) => { + const t0 = talentBb(chess, 0); // 遗尘守望: max_hp, def, block_cnt, block_radius_scale + const t1 = talentBb(chess, 1); // 医者丰碑: buff_duration, hp_recovery_per_sec, rhodes_bonus + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + const s2 = skillRec(chess, S2); + const picked = chess?.skill?.skillId ?? null; + return { + trait: { healThrough: (healer, a) => !!a && a.kind === 'token' && a.defId === KALTS_MON3TR }, + skills: { + [S1]: { kind: 'duration', mods: { atkPct: num(b1.atk), aspd: num(b1.attack_speed) } }, + [S2]: { + kind: 'ammo', + ammo: Math.max(1, Math.floor(num(b2['attack@trigger_time'], 10))), + mods: { atkPct: num(b2.atk) }, + targeting: s2?.rangeGrid ? { rangeGrid: s2.rangeGrid } : undefined, + attack: { + dmgType: 'true', canHitFly: true, + hitsFn: () => 0, // the damage is the 医疗单元's burst (s2Burst), not a hit on the target itself + canAttack: (battle, unit) => s2CanAttack(battle, unit), + onEachHit: (ctx) => { if (ctx.kind === 'main' && ctx.target) s2Burst(ctx.battle, ctx.unit, ctx.target, ctx.attackId, b2); }, + }, + }, + [S3]: { + kind: 'duration', + mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess) }, + targeting: { maxTargets: 2 }, // 额外治疗1个目标 (her s3 heal trigger `_maxNum` 2) + onStart({ battle, unit }) { + unit.mem.k2landed = false; + releaseSkillSummon(battle, unit, ANCHOR); // 立刻获得战术锚点 (a docked piece off the field deploys now when ready) + battle.after(TRANSPORT_CHECK, () => tryTransport(battle, unit), { owner: unit }); + }, + onTick({ unit, skill, dt }) { if (unit.mem.k2flight) skill.timeLeft += dt; }, // 技能剩余时间暂停消耗 while flying + onEnd({ battle, unit }) { + // kalts2_s_3[token_ctrl] ON_BUFF_FINISH: WithdrawTokens, RechargeToken cnt_minus 0 (refresh) + setStock(unit, 0); + for (const t of anchorsOf(battle, unit)) if (t.alive) battle.retreat(t, { reason: 'expired' }); + if (unit.mem.k2flight) { unit.mem.k2flight = null; unit.hidden = false; } + }, + }, + }, + talents: [ + { install(battle, unit) { // 遗尘守望 + const mods = {}; + for (const [k, v] of [['hpPct', num(t0.max_hp)], ['defPct', num(t0.def)], ['blockCnt', num(t0.block_cnt)], ['blockRadiusScale', num(t0.block_radius_scale)]]) if (v) mods[k] = v; + battle.addBuff(unit, { key: T1_KEY, mods, flags: { liftoff: true, blockFly: true }, persist: true, allowDead: true, tags: ['talent'] }); + battle.on('deploy', (c) => { if (c.unit === unit && !c.move) battle.fx('takeoff', { x: unit.x, y: unit.y, id: unit.id }); }, { owner: unit }); + } }, + { install(battle, unit) { // 医者丰碑: the operators entering her attack range + if (!(num(t1.buff_duration) > 0)) return; + const enter = (a) => enterT2(battle, unit, a, t1); + unit.mem.kalts2Enter = enter; // S3's flight collisions + let inside = new Set(); + battle.on('deploy', (c) => { if (c.unit === unit) inside = new Set(); }, { owner: unit }); + battle.on('tick', () => { + // (also while S3's flight hides her: her range stays on her tile until the landing — the 待投放 operators + // redeployed there enter it) + if (!up(unit) || !unit.rangeKeySet) return; + const now = new Set(); + for (const a of battle.allyUnits) { + if (!otherOp(a, unit) || !unit.rangeKeySet.has(a.tileR * COLS + a.tileC)) continue; + now.add(a); + if (!inside.has(a)) enter(a); + } + inside = now; + }, { owner: unit }); + } }, + ], + install(battle, unit) { + // her 战术锚点 pieces run the anchor kit (set up before content/tokens.js docks them: its install runs after the kits) + for (const t of battle.allyUnits) { + if (!isAnchorOf(t, unit) || t.alive || t.deployed) continue; + if (t.kit) battle.offOwner(t); + battle._setupUnit(t, anchorKit()); + } + // kalts2_s_3[on_token_born]: an anchor deployed while she stands links to her until it or she leaves; while S3 runs + // its deployment starts the transport at once + battle.on('deploy', (c) => { + if (!isAnchorOf(c.unit, unit)) return; + if (up(unit)) unit.mem.k2link = c.unit; + tryTransport(battle, unit); + }, { owner: unit }); + battle.on('death', (c) => { if (c.unit === unit || c.unit === unit.mem.k2link) unit.mem.k2link = null; }, { owner: unit }); + // S3's flight: its collisions every tick + battle.on('tick', () => { const f = unit.mem.k2flight; if (f && unit.alive) collide(battle, unit, f); }, { owner: unit }); + // S1: every other 起飞 operator's 阻挡半径倍率 +attack@block_radius_scale while it runs (同名效果取最高) + const v1 = num(b1['attack@block_radius_scale']); + if (picked === S1 && v1 > 0) { + battle.every(S1_AURA_IV, () => { + if (!up(unit) || !skillOn(unit, S1)) return; + for (const a of battle.allyUnits) { + if (!otherOp(a, unit) || !a.s.flags.liftoff) continue; + const cur = a.findBuff(S1_KEY); + const own = (a.s.blockRadiusScale || 0) - (cur ? num(cur.mods?.blockRadiusScale) : 0); + if (own >= v1 - 1e-9 || (cur && cur.source !== unit && num(cur.data?.v) > v1 && cur.timeLeft > 0.05)) continue; + battle.addBuff(a, { key: S1_KEY, duration: S1_AURA_IV + 0.1, mods: { blockRadiusScale: v1 }, source: unit, data: { v: v1 }, tags: ['skill'] }); + } + }, { owner: unit, immediate: true }); + } + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/op-monstr.js b/server/sim/content/kits/ops/op-monstr.js new file mode 100644 index 00000000..6455b102 --- /dev/null +++ b/server/sim/content/kits/ops/op-monstr.js @@ -0,0 +1,337 @@ +// server/sim/content/kits/ops/op-monstr.js — Mon3tr (char_4179_monstr) 自选 operator kit: 6★ 链愈师 (医疗), an owned-6★ pick of +// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and her module (XAH-X 记忆存档) at every form, and the +// kit of her summon 重构体 (token_10050_monstr_prosts). Kit contract and the 自选 rules: ../README.md ("How to add an operator +// (自选)"). +// +// Forms (data/backups.json units.char_4179_monstr): normal = E2 Lv1, skills at rank 4, no module; elite = E2 Lv60, rank 7, +// XAH-X at stage 1 (tier 5) or 3 (tier 6) — the owner's decision of 2026-10-05. Potential 0 [ASSUMED: no account]. +// Sources: character_table / skill_table / battle_equip_table / token_table (zh_CN, as built into backups.json); PRTS Mon3tr +// (T1 备注 "治疗效果无视禁疗,但对象选取受禁疗制约,可以强行选择自身的重构体作为自身普通攻击的治疗目标", "攻击力加成效果同名效果取最高"; +// T2 备注 "该效果重复触发刷新持续时间,不会因自身退场而消失"; S1 备注 "技能范围与技能期间治疗范围无关"; S2 备注 "可选择满生命值的重构体 +// 进行治疗;重构体受到Mon3tr普通攻击治疗的0.6s后,Mon3tr再次以重构体为目标进行一次跳跃治疗", "仅技能期间治疗触发的第二天赋效果提升; +// …叠加时取最高"; S3 备注: the start (撤退重构体, 【移动】, the 路标形态 marker), 不死 ("期间受到致命伤害结算后立即结束技能;技能结束后 +// 返回…以不高于1点的生命值在场;因返回而部署时获得1s不死"), "期间对自身进行治疗时,治疗跳跃不会衰减且不占用跳跃数", the return); PRTS +// 重构体 (备注 "持有禁疗(可以被Mon3tr无视)、不可阻挡", "嘲讽等级-1", "Mon3tr因移动之外的原因退场时强制撤退场上的重构体", 通常形态 +// "阻挡数强制归0,每0.1s流失8生命值…退场时返还1个可部署的重构体"); PRTS 卫戍协议/帮助 (§战斗部署 placed summons, §作战阶段 +// "若召唤物在战斗期间退场,将在满足条件后立即原地再部署1个"); the client's battle data: charpack char_4179_monstr (attacks +// `_ignoreHealFree`; talent "1" monstr_t_1[born_charge] / [die_to_kil_token]; talent "2" monstr_t_2[atk_spd], scaled in the S2 +// mode by `_applyTalentScale` on attack_speed; modes S3_Reborn (Combat / Attack: PURE, targetMotion 1, `_maxNum` 3 +// `_limitedMaxTargetNumToBlockedCnt`; ExtraHeal projectile_chr_monstr_s3 on every attack; talents monstr[free_jump], +// monstr_s_3[bleeding] (damage_per_second × 0.1 every 0.1 s), [reborned_passive], [sync_hp]); `_config` mapping S3's atk / +// damage_per_second / max_hp / block_cnt / base_attack_time and T1's attack@chain.extra_cnt); the skill prefabs (skchr_monstr_1 +// next heal; skchr_monstr_2 its attack's activeBuff monstr_s_2[proj] lifeTime 0.6 → projectile_chr_monstr_s2_extra from the +// reconstruct; skchr_monstr_3 `_onlyAvailableWhenTokenValid`, the Respawn / RespawnEnd abilities, monstr_s_3[end_undeadable_ +// restore] 1 s); the projectile prefabs (every heal projectile: `_atkScale` 0.75 = chain.atk_scale, the jump Selector +// `_rangeId` x-4 `_maxTarget` 3 lowest HP ratio `_useAdditionalTargetCount`, `_freeJumpValidator` monstr[free_jump]); the +// reconstruct's prefab (monstr[block_free] abnormal [3, 7] + taunt_level −1; prosts[atk_up] aura from the host's `atk`; +// prosts_t_1[born_charge] BLOCK_CNT ×0, interval 0.1, firstTriggerInterval 1, FixedValueDamage PURE `damage` on itself); +// buff_template_data (monstr_t_2[atk_spd] ON_OUTPUT_MODIFIER IsHeal; monstr_s_3[undead] ON_BEFORE_TRY_SET_HP_ZERO). +// - Trait (链愈师) "恢复友方单位生命,且会在3个友方单位间跳跃,每次跳跃治疗量降低25%" — the chain is this kit's (the profession's +// 2.5-tile chain is replaced: heal mode single + `chainFrom`), as her heal projectiles have it: after the heal on the main +// target the heal jumps to the lowest-HP-ratio injured ally of the 3×3 around the last one healed (x-4), each jump × +// attack@chain.atk_scale (0.75; XAH-X 0.85), up to attack@chain.max_target units healed (3). A 重构体 (and she herself while S3 +// runs: monstr[free_jump]) is a free link: healing it uses no target of the count and the next jump does not decay (XAH-X +// stage 3: the next attack@chain.extra_cnt jumps after that neither). Her heals pass 禁疗 ("治疗效果无视禁疗"); her selection +// takes her own 重构体 through its 禁疗 (`healThrough`), no other 禁疗 unit [ASSUMED: the jumps too]. +// - Module XAH-X 记忆存档: the trait's 0.85 per jump (traitOverride); stage 3 changes T1 (below); ATK / DEF from the attributes. +// - T1 自我修复 (cnt 1): her 重构体 is a hand piece the player places (the talent's summon). "可以在攻击范围内的地面使用" — a +// ground tile of her attack range — is not in the token data (`ownerRange` false), so the match does not hold the piece to +// it and the battle takes it where it stands [open question]. Its kit: 禁疗 (only her heals reach it), blocks nothing, taunt +// −1, loses 8 HP every 0.1 s from 1 s after it lands (流失, Battle.loseHp — PRTS "每0.1s流失8生命值"; trait damage_per_second +// 80); every ally of its +// 3×3 but itself (no 孤立 one) ATK +atk (0.15; XAH-X stage 3 0.25; 同名效果取最高). Gone (destroyed or withdrawn) ⇒ back on its +// tile its redeploy time (15 s) after, paying its cost (3 DP), only while she stands — her deployment (not a 【移动】) +// readies a waiting one at once (monstr_t_1[born_charge]) [ASSUMED: the 卫戍 auto redeploy of a placed summon, as 凯尔希's +// Mon3tr]; withdrawn when she leaves the field (not on her S3 moves). +// - T2 战术协同: every heal of hers (chain jumps included) gives its target and herself ASPD +attack_speed for buff_duration s — +// ×talent_scale (S2) for a heal made while S2 runs; one effect per unit, the strongest (同名效果取最高 — a weaker one that +// outlasts it resumes afterwards: Battle.applyStrongest); a new one refreshes it. +// - S1 策略:超压链接 (AUTO, attack SP, data DEFAULT — a "next heal" waits for her heal, as 闪灵's S2): that heal is heal_scale × +// ATK and heals up to chain.max_target (4) units. +// - S2 策略:超负荷 (MANUAL, data SKILL_RANGE on its y-2 — an enemy there; 30 s): her own 重构体 inside her range is her heal +// target, even at full HP (with nobody injured she heals it all the same: Battle.forceAttack); 0.6 s after each heal of her +// attack on it a second chain heal starts on it (her ATK × 1); T2 ×talent_scale. +// - S3 策略:熔毁 (MANUAL, data DEFAULT, 25 s; only while her 重构体 stands — the cast is refused otherwise): the 重构体 is withdrawn +// (its piece waits: back once its tile is free again) and she 【移动】s onto its tile; meanwhile range x-4, ATK +atk, base attack +// time base_attack_time (a flat −1.5 s on 2.85 s), block +block_cnt, max HP +max_hp (the HP ratio kept), 流失 of +// damage_per_second HP/s (8 every 0.1 s); her attacks hit every ground enemy she blocks (one in range when she blocks none — +// [ASSUMED] as 凯尔希's Mon3tr S2) for ATK true damage, and each attack starts a chain heal on herself at attack@heal_scale × ATK +// (she is a free link). 不死: a hit or 流失 that would knock her out leaves her at 1 HP, untargetable and blocking nothing, and +// the skill ends on the next tick. At the end she 【返回】s to the tile she left (her SP emptied; the max HP drop keeps the ratio, +// so after a fatal blow she stands at ≤ 1 HP) with 1 s of 不死. The 路标形态 marker on her tile is not modelled: should another +// unit stand there, she stays where she is [ASSUMED; officially she is knocked out]. + +import { num, talentBb, traitBb, skillRec, batMod, installAura, up } from '../shared/tier1.js'; +import { COLS } from '../../../constants.js'; + +const S1 = 'skchr_monstr_1'; +const S2 = 'skchr_monstr_2'; +const S3 = 'skchr_monstr_3'; +/** Her summon 重构体 (T1). */ +export const PROSTS = 'token_10050_monstr_prosts'; +/** S2: the second chain starts this long after a heal of hers on her 重构体 (monstr_s_2[proj] lifeTime). */ +const S2_EXTRA_DELAY = 0.6; +/** The 重构体's 流失: every 0.1 s, from 1 s after it lands (prosts_t_1[born_charge] interval / firstTriggerInterval). */ +const PROSTS_BLEED_IV = 0.1; +const PROSTS_BLEED_FIRST = 1; +/** The 重构体's ATK aura refresh [ASSUMED: the aura's interval is not in the data]. */ +const PROSTS_AURA_IV = 0.1; +/** S3's 流失 period (monstr_s_3[bleeding] interval). */ +const S3_BLEED_IV = 0.1; +/** S3: 不死 after the return (monstr_s_3[end_undeadable_restore] lifeTime). */ +const RETURN_UNDEAD = 1; +/** How often a waiting 重构体 tries to come back once ready. */ +const RETRY = 0.1; + +const T2_KEY = 'talent:monstr:aspd'; +const PROSTS_ATK_KEY = 'talent:monstr:prostsAtk'; +const PROSTS_ABN_KEY = 'monstr:prosts:abnormal'; +const S3_UNDEAD_KEY = 'skill:monstr:undead'; +const TAG_PROSTS_BLEED = 'monstr:prostsBleed'; +const TAG_S3_BLEED = 'monstr:meltdownBleed'; +/** Her heals "无视禁疗" (every heal ability of hers: `_ignoreHealFree`). */ +const HEAL_OPTS = Object.freeze({ ignoreHealFree: true }); + +const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {}; +const skillOn = (unit, id) => !!(unit.skill && unit.skill.active && unit.skill.id === id); +const isProstsOf = (t, unit) => !!t && t.kind === 'token' && t.defId === PROSTS && t.ownerUnit === unit; +const prostsOf = (battle, unit) => battle.allyUnits.filter((t) => isProstsOf(t, unit)); +/** Her 重构体 standing inside her current attack range. */ +const prostsInRange = (battle, unit) => prostsOf(battle, unit).find((t) => up(t) && !!unit.rangeKeySet && unit.rangeKeySet.has(t.tileR * COLS + t.tileC)) ?? null; +/** `a` stands on the 3×3 around `b` (x-4: a heal jump's reach, the 重构体's aura). */ +const nearX4 = (a, b) => Math.abs(a.tileR - b.tileR) <= 1 && Math.abs(a.tileC - b.tileC) <= 1; + +/** + * The next link of a chain heal from `cur`: the lowest-HP-ratio injured ally of its 3×3 not healed yet by this chain (her + * own 重构体 even at full HP when `fullProsts`); never a 禁疗 unit but her 重构体. + */ +function nextLink(battle, unit, cur, seen, fullProsts) { + let best = null; + for (const a of battle.allyUnits) { + if (!a.alive || !a.deployed || a.hidden || a.kind === 'device' || seen.has(a.id) || !nearX4(a, cur)) continue; + const mine = isProstsOf(a, unit); + if (a !== unit && ((a.s.flags.noHeal && !mine) || (a.profile && a.profile.noHeal))) continue; + if (!(a.hp < a.s.maxHp - 1e-6) && !(fullProsts && mine)) continue; + if (!best || a.hpRatio < best.hpRatio - 1e-12 || (Math.abs(a.hpRatio - best.hpRatio) <= 1e-12 && a.deploySeq < best.deploySeq)) best = a; + } + return best; +} + +/** + * A chain heal of hers from `main` (already healed by her attack when `mainDone`): up to `count` units healed, × `step` per + * jump; a free link (her 重构体; herself with `selfFree`) uses no target of the count and the next 1 + `extra` jumps keep the + * amount. + */ +function chainFrom(battle, unit, main, base, { count, step, extra, fullProsts = false, mainDone = false, selfFree = false }) { + const free = (t) => isProstsOf(t, unit) || (selfFree && t === unit); + let amount = base; + if (!mainDone) battle.heal(unit, main, amount, HEAL_OPTS); + let counted = free(main) ? 0 : 1; + let calm = free(main) ? 1 + extra : 0; + const seen = new Set([main.id]); + let cur = main; + while (counted < count) { + const next = nextLink(battle, unit, cur, seen, fullProsts); + if (!next) break; + seen.add(next.id); + if (calm > 0) calm--; else amount *= step; + battle._ev(['atk', cur.id, next.id, 'chainHeal']); + battle.heal(unit, next, amount, HEAL_OPTS); + if (free(next)) calm = 1 + extra; else counted++; + cur = next; + } +} + +/** The 重构体's kit (owner = Mon3tr): 禁疗, no block, taunt −1, its 流失, the ATK aura, its return. */ +function prostsKit(owner, { atk }) { + return { + skill: null, + talents: [], + trait: { noAttack: true }, + install(battle, t) { + const dps = num(t.def?.traitBb?.damage_per_second, 80); + battle.addBuff(t, { key: PROSTS_ABN_KEY, flags: { noHeal: true, noBlock: true }, mods: { taunt: -1 }, persist: true, allowDead: true }); + if (atk > 0) installAura(battle, t, { key: PROSTS_ATK_KEY, select: (a) => a !== t && nearX4(a, t), mods: { atkPct: atk }, value: atk, interval: PROSTS_AURA_IV }); + battle.on('deploy', (c) => { + if (c.unit !== t) return; + const seq = t.deploySeq; + battle.after(PROSTS_BLEED_FIRST, () => { + if (t.deploySeq !== seq || !up(t)) return; + battle.every(PROSTS_BLEED_IV, (b, sched) => { + if (t.deploySeq !== seq || !up(t)) { sched.cancel(); return; } + b.loseHp(t, dps * PROSTS_BLEED_IV, { source: t, tags: [TAG_PROSTS_BLEED] }); + }, { owner: t, immediate: true }); + }, { owner: t }); + }, { owner: t }); + // gone (destroyed or withdrawn): the piece stays and comes back on its tile once ready, while she stands + battle.on('death', (c) => { + if (c.unit !== t || battle.finished) return; + t.removed = false; + t.mem.readyAt = battle.time + Math.max(0, num(t.base.respawnTime)); + if (t.mem.retry) return; + t.mem.retry = battle.every(RETRY, (b, sched) => { + const stop = () => { sched.cancel(); t.mem.retry = null; }; + if (t.alive || t.removed || b.finished) { stop(); return; } + if (!up(owner) || b.time + 1e-9 < t.mem.readyAt) return; + if (b.redeploy(t, { free: false })) stop(); + }, { owner: t }); + }, { owner: t, priority: -10 }); + }, + }; +} + +/** S2: with nobody injured in her range she heals her 重构体 there all the same ("可选择满生命值的重构体进行治疗"). */ +function s2CanAttack(battle, unit) { + const r = prostsInRange(battle, unit); + if (!r || battle.injuredAlliesInKeys(unit.rangeKeys, unit).length) return true; + if (battle.forceAttack(unit, [r])) unit.atkCd = Math.max(unit.atkCd, unit.s.interval); + return false; +} + +export default { + char_4179_monstr: (bb, chess) => { + const tb = traitBb(chess); // attack@chain.max_target (3), attack@chain.atk_scale (0.75; XAH-X 0.85) + const t0 = talentBb(chess, 0); // 自我修复: cnt, atk (XAH-X stage 3: 0.25 + attack@chain.extra_cnt 1) + const t1 = talentBb(chess, 1); // 战术协同: buff_duration, attack_speed + const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3); + const s1 = skillRec(chess, S1), s3 = skillRec(chess, S3); + const chain = { + count: Math.max(1, Math.floor(num(tb['attack@chain.max_target'], 3))), + step: num(tb['attack@chain.atk_scale'], 0.75), + extra: Math.max(0, Math.floor(num(t0['attack@chain.extra_cnt']))), + }; + const s1Count = Math.max(1, Math.floor(num(b1['chain.max_target'], chain.count))); + const picked = chess?.skill?.skillId ?? null; + return { + // the chain is chainFrom's (heal mode single: no profession chain); her 重构体 is selectable through its 禁疗 + trait: { heal: { mode: 'single' }, healThrough: (healer, a) => isProstsOf(a, healer) }, + skills: { + [S1]: { kind: num(s1?.maxChargeTime, 1) > 1 ? 'charges' : 'instant', attack: { healScale: num(b1.heal_scale, 1) } }, + [S2]: { kind: 'duration', attack: { canAttack: (battle, unit) => s2CanAttack(battle, unit) } }, + [S3]: { + kind: 'duration', + mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess), blockCnt: num(b3.block_cnt), hpFlat: num(b3.max_hp) }, + targeting: s3?.rangeGrid ? { rangeGrid: s3.rangeGrid } : undefined, + attack: { dmgType: 'true', attack: 'melee', projectile: 'none', canHitFly: false, groundOnly: true, hitAllBlocked: true, maxTargets: 1 }, + onStart({ battle, unit, skill }) { + const r = prostsOf(battle, unit).find(up); + if (!r) { skill.end('noToken'); skill.addCharge(1); return; } // (refused before: the activate guard) + const home = { r: unit.tileR, c: unit.tileC, dir: unit.dir }; + const to = [r.tileR, r.tileC]; + battle.retreat(r, { reason: 'retreat', permanent: true }); // 撤退场上的重构体(返回待部署区) + const fromX = unit.x, fromY = unit.y; + if (!battle.moveRedeploy(unit, to[0], to[1])) { skill.end('blocked'); skill.addCharge(1); return; } // 强制结束技能并返还技力 + unit.mem.mHome = home; + battle.fx('teleport', { x: unit.x, y: unit.y, id: unit.id, fromX, fromY }); + }, + onTick({ unit, skill }) { if (unit.mem.mFatal) skill.end('fatal'); }, // 受到致命伤害结算后立即结束技能 + onEnd({ battle, unit }) { + const h = unit.mem.mHome; + unit.mem.mHome = null; + unit.mem.mFatal = false; + battle.removeBuff(unit, S3_UNDEAD_KEY); + if (!h || !unit.alive || !unit.deployed) return; + // 【返回】 to the tile she left: SP emptied (S3_Restore ModifySp −100 %), 1 s 不死 + const fromX = unit.x, fromY = unit.y, dir0 = unit.dir; + unit.dir = h.dir; + if (battle.moveRedeploy(unit, h.r, h.c, { clearSp: true })) battle.fx('teleport', { x: unit.x, y: unit.y, id: unit.id, fromX, fromY }); + else unit.dir = dir0; + unit.mem.mUndeadUntil = battle.time + RETURN_UNDEAD; + }, + }, + }, + talents: [ + { install(battle, unit) { // 自我修复: her pieces run the 重构体's kit; withdrawn when she leaves; readied by her deployment + for (const t of battle.allyUnits) { + if (!isProstsOf(t, unit) || t.alive || t.deployed) continue; + if (t.kit) battle.offOwner(t); + battle._setupUnit(t, prostsKit(unit, { atk: num(t0.atk) })); + } + battle.on('death', (c) => { + if (c.unit !== unit) return; + for (const t of prostsOf(battle, unit)) if (t.alive) battle.retreat(t, { reason: 'retreat', permanent: true }); + }, { owner: unit }); + battle.on('deploy', (c) => { + if (c.unit !== unit || c.move) return; + for (const t of prostsOf(battle, unit)) if (!t.alive && !t.removed) t.mem.readyAt = battle.time; + }, { owner: unit }); + } }, + { install(battle, unit) { // 战术协同: every heal of hers ⇒ target and herself ASPD + (×talent_scale under S2) + const d = num(t1.buff_duration), v0 = num(t1.attack_speed); + if (!(d > 0) || !(v0 > 0)) return; + battle.on('heal', (c) => { + if (c.source !== unit || c.opts?.regen || !c.target || c.target.side !== 'ally' || !(c.amount > 0)) return; + const v = v0 * (skillOn(unit, S2) ? num(b2.talent_scale, 1) : 1); + const give = (x) => battle.applyStrongest(x, T2_KEY, { duration: d, value: v, mods: (a) => ({ aspd: a }), source: unit }); + give(c.target); + if (c.target !== unit && unit.alive) give(unit); + }, { owner: unit }); + } }, + ], + install(battle, unit) { + // the chain heals: after each heal attack of hers a chain from its target(s); under S3 every attack starts one on her + battle.on('attack', (c) => { + if (c.attacker !== unit || !unit.alive) return; + const prof = battle.effectiveProfile(unit); + if (prof.heal && prof.dmgType === 'heal') { + const sk = unit.skill; + const s1on = !!(sk && sk.id === S1 && sk.active && sk.pending); + const s2on = skillOn(unit, S2); + const base = unit.s.atk * (prof.atkScale ?? 1) * (prof.healScale ?? 1) * unit.s.atkScaleMul; + for (const t of c.targets || []) { + if (!t || t.side !== 'ally') continue; + chainFrom(battle, unit, t, base, { ...chain, count: s1on ? s1Count : chain.count, fullProsts: s2on, mainDone: true }); + // S2: 0.6 s after her attack healed her 重构体, a second chain on it (her ATK × 1) + if (s2on && isProstsOf(t, unit)) { + battle.after(S2_EXTRA_DELAY, () => { + if (!unit.alive || !up(t)) return; + chainFrom(battle, unit, t, unit.s.atk * unit.s.atkScaleMul, { ...chain, fullProsts: skillOn(unit, S2) }); + }, { owner: unit }); + } + } + } else if (skillOn(unit, S3) && unit.mem.mHome && !unit.mem.mFatal) { + chainFrom(battle, unit, unit, unit.s.atk * num(b3['attack@heal_scale'], 0.5) * unit.s.atkScaleMul, { ...chain, selfFree: true }); + } + }, { owner: unit }); + // S2: her 重构体 inside her range is the heal target + if (picked === S2) { + battle.on('beforeAttack', (c) => { + if (c.attacker !== unit || !skillOn(unit, S2) || !c.profile || c.profile.dmgType !== 'heal') return; + const r = prostsInRange(battle, unit); + if (r) c.targets = [r]; + }, { owner: unit, priority: 10 }); + } + if (picked !== S3) return; + // S3 `_onlyAvailableWhenTokenValid`: every activation path is refused while her 重构体 is not on the field + const sk = unit.skill; + if (sk && sk.id === S3 && !Object.prototype.hasOwnProperty.call(sk, 'activate')) { + const activate = sk.activate; + Object.defineProperty(sk, 'activate', { + configurable: true, writable: true, enumerable: false, + value(reason, opts) { return prostsOf(battle, unit).some(up) ? activate.call(this, reason, opts) : false; }, + }); + } + // S3's 流失 + battle.every(S3_BLEED_IV, () => { + if (!skillOn(unit, S3) || !unit.mem.mHome || unit.mem.mFatal || !up(unit)) return; + battle.loseHp(unit, num(b3.damage_per_second) * S3_BLEED_IV, { source: unit, tags: [TAG_S3_BLEED] }); + }, { owner: unit }); + // S3 不死; the 1 s 不死 after the return + battle.on('fatal', (c) => { + if (c.unit !== unit) return; + if (skillOn(unit, S3) && unit.mem.mHome) { + c.prevented = true; + if (!unit.mem.mFatal) { + unit.mem.mFatal = true; + battle.addBuff(unit, { key: S3_UNDEAD_KEY, flags: { untargetable: true, noBlock: true }, tags: ['skill'] }); + battle.releaseBlocked(unit); + battle.fx('undying', { x: unit.x, y: unit.y, id: unit.id }); + } + return; + } + if (battle.time < num(unit.mem.mUndeadUntil, -Infinity) - 1e-9) c.prevented = true; + }, { owner: unit, priority: 10 }); + }, + }; + }, +}; diff --git a/server/sim/units.js b/server/sim/units.js index 25e07a06..364fd3cd 100644 --- a/server/sim/units.js +++ b/server/sim/units.js @@ -130,6 +130,8 @@ export class Unit { blockCnt: Math.max(0, fin(Math.round(b.blockCnt + a('blockCnt')), 0)), moveSpeed: Math.max(0, fin((b.moveSpeed + a('moveFlat')) * m('moveMul'), fin(b.moveSpeed, 0))), rangeExtend: Math.max(0, Math.round(a('rangeExtend'))), + // 阻挡半径倍率 − 1 (PRTS 数值范围 BLOCK_RADIUS_SCALE "影响阻挡模式为飞行阻挡的单位的阻挡半径"): Battle._checkBlock + blockRadiusScale: Math.max(0, fin(a('blockRadiusScale'), 0)), baseRangeExtend: Math.max(0, fin(Math.round(permRangeExtend), 0)), // permanent part (initial range) massLevel: Math.max(0, fin(fin(b.massLevel, 0) + a('massFlat'), 0)), maxTargets: a('maxTargets'), diff --git a/test/content/op_kalts2.test.js b/test/content/op_kalts2.test.js new file mode 100644 index 00000000..0633148b --- /dev/null +++ b/test/content/op_kalts2.test.js @@ -0,0 +1,360 @@ +// test/content/op_kalts2.test.js — the 自选 operator kit of 凯尔希·思衡托 (char_1052_kalts2, 6★ 守望者; kit +// server/sim/content/kits/ops/op-kalts2.js) and her summon 战术锚点 (token_10068_kalts2_mtship), fielded the production way (a +// DIY slot + its `diy` pick; the anchor as a token piece of hers) in every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4) +// and elite (E2 Lv60, rank 7) — she has no module. Every number is read back from data/backups.json (the form of that slot +// status, the token's variant); the fidelity checklist of kits/README.md item by item. +// Run: node --test test/content/op_kalts2.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 { COLS } from '../../server/sim/constants.js'; + +const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8')); +const CHESS = load('chess'); +const BACKUPS = load('backups'); +const KALTS2 = 'char_1052_kalts2'; +const ANCHOR = 'token_10068_kalts2_mtship'; +const FORMS = BACKUPS.units[KALTS2].forms; +const TOKEN = BACKUPS.tokens[ANCHOR]; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const S1 = 'skchr_kalts2_1', S2 = 'skchr_kalts2_2', S3 = 'skchr_kalts2_3'; +const statusKey = (tier, elite) => (elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'); +const formOf = (tier, elite) => FORMS[statusKey(tier, elite)]; +const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id); +const talentOf = (tier, elite, i) => formOf(tier, elite).talents.find((t) => t.index === i).bb; +const variantOf = (tier, elite) => TOKEN.variants[`${KALTS2}@${statusKey(tier, elite)}`]; +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_biter: dummy('enemy_biter', { atk: 400, range: 1.2, bat: 1 }), + enemy_flybiter: dummy('enemy_flybiter', { atk: 300, range: 1.6, bat: 1, motion: 'FLY' }), +}; +/** Every 自选 form: [tier, elite] (no module). */ +const FORMS_ALL = [[5, false], [6, false], [5, true], [6, true]]; +const label = ([tier, elite]) => `T${tier} ${elite ? 'elite' : 'normal'}`; +const YAK = 'chess_char_1_02_a', TEXAS = 'chess_char_1_08_a', PERFUMER = 'chess_char_2_14_a'; + +/** A battle with 凯尔希·思衡托 as uid 1 at (10, 4) facing RIGHT (`anchor`: her piece as uid 2), plus `others`. */ +function field({ tier = 5, elite = false, skill = 0, anchor = null, others = [], seed = 5, dp = 99 } = {}) { + const units = [{ uid: 1, diy: { slot: SLOT[tier], charId: KALTS2, skillIndex: skill }, elite, row: 10, col: 4 }]; + if (anchor) units.push({ uid: 2, kind: 'token', tokenId: ANCHOR, ownerUid: 1, row: anchor.row, col: anchor.col, dir: anchor.dir ?? 'RIGHT' }); + const log = []; // heal / damaged in the order they happen + const h = makeBattle({ + defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, + flags: { dpInit: dp, dpPerSec: 0, dpMax: 999 }, + hooks: ['damaged', 'heal', 'skillStart', 'skillEnd', 'statusApplied', 'death', 'deploy', 'blocked'], captureNoisy: true, + units: [...units, ...others], + setup: (b) => { + b.on('heal', (c) => { if (!c.opts?.regen) log.push({ name: 'heal', ...c }); }, { priority: -1000 }); + b.on('damaged', (c) => log.push({ name: 'damaged', ...c }), { priority: -1000 }); + }, + }); + h.step(); + return { h, u: h.unit(1), a: anchor ? h.unit(2) : null, log }; +} +const heals = (h, u) => h.hooksOf('heal').filter((c) => c.source === u && !c.opts?.regen); +const inRange = (u, x) => u.rangeKeySet.has(x.tileR * COLS + x.tileC); +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 with 遗尘守望, y-6, one heal per attack, blocks 2, 起飞, 罗德岛, no module, no 特质; her 战术锚点: the form\'s token stats', () => { + assert.equal(OPERATOR_KITS[KALTS2], KITS[KALTS2]); + for (const f of FORMS_ALL) { + const [tier, elite] = f; + for (const skill of [0, 1, 2]) { + const { h, u, a } = field({ tier, elite, skill, anchor: { row: 10, col: 9 } }); + const form = formOf(tier, elite), t0 = talentOf(tier, elite, 0); + assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [KALTS2, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f)); + assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.bat, u.base.cost], [form.stats.maxHp, form.stats.atk, form.stats.def, 2.85, 19], `${label(f)}: stats`); + approx(u.s.maxHp, form.stats.maxHp * (1 + t0.max_hp), `${label(f)}: 生命上限 +25 %`); + approx(u.s.def, form.stats.def * (1 + t0.def), `${label(f)}: 防御力 +25 %`); + assert.deepEqual([u.s.blockCnt, u.s.blockRadiusScale, t0.block_cnt, t0.block_radius_scale], [2, 0.23, 1, 0.23], `${label(f)}: 阻挡数 +1, 阻挡半径倍率 +0.23`); + assert.deepEqual([u.profile.dmgType, u.profile.heal, u.profile.attack, u.def.subProf], ['heal', { mode: 'single' }, 'ranged', 'watchman'], `${label(f)}: 守望者 heal`); + assert.deepEqual([u.s.flags.liftoff, u.s.flags.blockFly], [true, true], `${label(f)}: 起飞`); + assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: y-6`); + assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds, u.def.tokens, u.def.raw.nationId, !!u.def.raw.module?.active, form.modules?.length ?? 0], + [BACKUPS.diy.operators[KALTS2].bonds, [], [ANCHOR], 'rhodes', false, 0], `${label(f)}: bonds / 特质 / summon / no module`); + const ts = variantOf(tier, elite).stats; + assert.deepEqual([a.base.maxHp, a.base.atk, a.base.cost, a.base.respawnTime, a.s.blockCnt], [ts.maxHp, 0, 3, 70, 0], `${label(f)}: 战术锚点 stats`); + assert.deepEqual([a.s.flags.invulnerable, a.s.flags.untargetable, a.profile.noAttack, !!a.kit.generic], [true, true, true, false], `${label(f)}: 战术锚点 无敌, never targeted, no attack`); + assert.equal(a.alive, skill === 2, `${label(f)}: the anchor piece takes the field only with S3 (a skill's summon)`); + done(h); + } + } + // the numbers of the forms (zh_CN): E2 Lv1 1393 / 408 / 220, E2 Lv60 1543 / 476 / 257; the anchor 2235 HP, 3 DP, 70 s + 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/3'].stats.atk], [1393, 408, 1543, 476]); + assert.deepEqual([variantOf(5, false).stats.maxHp, variantOf(6, true).stats.cost, TOKEN.placeable], [2235, 3, true]); +}); + +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(KALTS2)); + for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(KALTS2), `tier ${t}`); + assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: KALTS2, skillIndex: 2 } }, { data, kitted: KITTED_CHARS }), + { ok: true, picks: { [SLOT[5]]: { charId: KALTS2, skillIndex: 2, uniEquipId: null } } }); +}); + +test('trait: her heals take 凯尔希\'s Mon3tr through its 禁疗 (her selection too); another 禁疗 summon stays out', () => { + const others = [{ uid: 3, diy: { slot: 6, charId: 'char_003_kalts', skillIndex: 0 }, row: 12, col: 2 }, + { uid: 4, kind: 'token', tokenId: 'token_10002_kalts_mon3tr', ownerUid: 3, row: 10, col: 5 }]; + const { h, u } = field({ skill: 0, others }); + const m = h.unit(4); + assert.ok(m.s.flags.noHeal && inRange(u, m), '凯尔希\'s Mon3tr: 禁疗, in her range'); + m.hp = m.s.maxHp * 0.4; + const n0 = heals(h, u).length; + assert.ok(h.runUntil(() => heals(h, u).length > n0, 4), 'a heal'); + assert.equal(heals(h, u)[n0].target, m, 'it is her target'); + approx(heals(h, u)[n0].amount, u.s.atk, 'a full heal through its 禁疗'); + done(h); +}); + +test('T1 遗尘守望 起飞: no ground enemy targets her or is blocked by her; she blocks a flyer 1.0 tile away (air radius 0.8944 × 1.23), two at most; the mod blockRadiusScale on any blocker', () => { + const { h, u } = field({ skill: 0, others: [{ uid: 3, chessId: YAK, row: 11, col: 7 }] }); + const yak = h.unit(3); + const g = h.spawn('enemy_biter', { pos: [10, 5] }); + const f1 = h.spawn('enemy_fly', { pos: [10, 5] }); + const f2 = h.spawn('enemy_fly', { pos: [11, 4] }); + const f3 = h.spawn('enemy_fly', { pos: [9, 4] }); + h.run(3); + assert.equal(g.blockedBy, null, 'a ground enemy on the next tile is not blocked'); + assert.equal(h.hooksOf('damaged').filter((c) => c.target === u).length, 0, 'nor does it attack her (对地规避)'); + assert.equal([f1, f2, f3].filter((e) => e.blockedBy === u).length, 2, 'two of the three flyers 1.0 away: block 2'); + assert.ok(f1.blockedBy === u, 'the first one in contact'); + // the engine rule on another unit: a blockFly unit blocks a flyer 1.0 away only with 阻挡半径倍率 ≥ 1.118 + const f4 = h.spawn('enemy_fly', { pos: [11, 8] }); + h.b.addBuff(yak, { key: 'test:air', flags: { blockFly: true } }); + h.run(0.5); + assert.equal(f4.blockedBy, null, 'radius 0.8944: out of reach'); + h.b.addBuff(yak, { key: 'test:scale', mods: { blockRadiusScale: 0.23 } }); + h.run(0.5); + assert.equal(f4.blockedBy, yak, 'radius 0.8944 × 1.23: blocked'); + assert.equal(yak.s.blockRadiusScale, 0.23); + done(h); +}); + +test('T2 医者丰碑: an operator entering her range gets 1 护盾 layer (one damage instance blocked) and 生命回复速度 +50 for 30 s (罗德岛 +100); summons and she get nothing; a new entry refreshes, never a 2nd layer', () => { + for (const f of [[5, false], [6, true]]) { + const [tier, elite] = f; + const t1 = talentOf(tier, elite, 1); + assert.deepEqual([t1.buff_duration, t1.hp_recovery_per_sec, t1.rhodes_bonus], [30, 50, 2], label(f)); + const others = [{ uid: 3, chessId: YAK, row: 10, col: 5 }, { uid: 4, chessId: PERFUMER, row: 9, col: 5 }, + { uid: 5, chessId: TEXAS, row: 12, col: 8 }, { uid: 6, diy: { slot: 6, charId: 'char_003_kalts', skillIndex: 0 }, row: 12, col: 2 }, + { uid: 7, kind: 'token', tokenId: 'token_10002_kalts_mon3tr', ownerUid: 6, row: 11, col: 5 }]; + const { h, u } = field({ tier, elite, skill: 0, others }); + const yak = h.unit(3), pf = h.unit(4), tx = h.unit(5), m = h.unit(7); + assert.equal(pf.def.raw.nationId, 'rhodes'); + assert.ok(yak.findBuff('talent:kalts2:shield') && yak.findBuff('talent:kalts2:shield').shieldHits === 1, `${label(f)}: 角峰 1 护盾 layer`); + assert.deepEqual([yak.findBuff('talent:kalts2:regen')?.mods, pf.findBuff('talent:kalts2:regen')?.mods], [{ hpRegen: 50 }, { hpRegen: 100 }], `${label(f)}: 50 / 罗德岛 100 HP/s (生命回复速度)`); + approx(yak.findBuff('talent:kalts2:regen').timeLeft, 30, `${label(f)}: 30 s`, 0.01); + assert.ok(!m.findBuff('talent:kalts2:shield') && !m.findBuff('talent:kalts2:regen'), `${label(f)}: no summon`); + assert.ok(!u.findBuff('talent:kalts2:shield') && !u.findBuff('talent:kalts2:regen'), `${label(f)}: not herself`); + assert.ok(!tx.findBuff('talent:kalts2:regen'), `${label(f)}: 德克萨斯 outside her range`); + // the 护盾 blocks one damage instance whole + const hp0 = yak.hp; + assert.equal(h.b.dealDamage(null, yak, { amount: 500, type: 'true', sourceless: true }), 0, `${label(f)}: blocked`); + assert.equal(yak.hp, hp0); + assert.equal(yak.findBuff('talent:kalts2:shield'), null, `${label(f)}: used up`); + assert.ok(h.b.dealDamage(null, yak, { amount: 500, type: 'true', sourceless: true }) > 0, `${label(f)}: the next one lands`); + // still inside: no new entry; out and back in: a new layer and a fresh 30 s + h.run(5); + assert.equal(yak.findBuff('talent:kalts2:shield'), null, `${label(f)}: no entry while it stays`); + approx(yak.findBuff('talent:kalts2:regen').timeLeft, 25, `${label(f)}: ticking`, 0.05); + h.b.relocate(yak, 10, 8); + h.step(); + assert.ok(!inRange(u, yak)); + h.b.relocate(yak, 10, 5); + h.step(); + assert.ok(yak.findBuff('talent:kalts2:shield'), `${label(f)}: re-entry: 护盾 again`); + approx(yak.findBuff('talent:kalts2:regen').timeLeft, 30, `${label(f)}: re-entry: 30 s again`, 0.05); + h.b.relocate(yak, 10, 8); h.step(); h.b.relocate(yak, 10, 5); h.step(); + assert.equal(yak.buffs.filter((b) => b.key === 'talent:kalts2:shield').length, 1, `${label(f)}: one layer at most`); + // 30 s on: the regen ends (德克萨斯 entering later gets its own) + const r0 = tx.s.hpRegen; // (调香师's own 熏衣草 regen on everyone) + assert.ok(h.b.relocate(tx, 9, 3)); + h.step(); + approx(tx.s.hpRegen - r0, 50, `${label(f)}: 德克萨斯 enters`); + assert.deepEqual(tx.findBuff('talent:kalts2:regen')?.mods, { hpRegen: 50 }); + done(h); + } +}); + +test('S1 应急肃正防线 (MANUAL, data DEFAULT — as she is about to heal): 35 s, ATK +60 % / +75 %, ASPD +25 / +35; every other 起飞 operator 阻挡半径倍率 +0.23 meanwhile (not a grounded one, not her twice)', () => { + for (const f of [[5, false], [6, true]]) { + const [tier, elite] = f; + const sk = skillOf(tier, elite, S1); + assert.deepEqual([sk.duration, sk.bb.atk, sk.bb.attack_speed, sk.bb['attack@block_radius_scale'], sk.spCost, sk.initSp, sk.trigger.rule], + [35, elite ? 0.75 : 0.6, elite ? 35 : 25, 0.23, 35, elite ? 20 : 18, 'DEFAULT'], label(f)); + const others = [{ uid: 3, chessId: YAK, row: 10, col: 5 }, { uid: 4, chessId: TEXAS, row: 12, col: 9 }]; + const { h, u } = field({ tier, elite, skill: 0, others }); + const yak = h.unit(3), tx = h.unit(4); + h.b.addBuff(tx, { key: 'test:liftoff', flags: { liftoff: true } }); + assert.equal(u.skill.rule, 'DEFAULT'); + u.skill.gainSp(999); + h.run(2); + assert.equal(u.skill.activations, 0, `${label(f)}: nobody injured: no cast`); + yak.hp = yak.s.maxHp * 0.5; + assert.ok(h.runUntil(() => u.skill.active, 4), `${label(f)}: cast as she heals`); + approx(u.skill.timeLeft, 35, `${label(f)}: 35 s`, 0.05); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `${label(f)}: ATK`); + assert.equal(u.s.aspd, u.base.aspd + sk.bb.attack_speed, `${label(f)}: ASPD`); + h.run(0.4); + assert.equal(tx.s.blockRadiusScale, 0.23, `${label(f)}: the 起飞 德克萨斯 (anywhere on the field)`); + assert.equal(yak.s.blockRadiusScale, 0, `${label(f)}: 角峰 is not 起飞`); + assert.equal(u.s.blockRadiusScale, 0.23, `${label(f)}: hers stays 0.23 (同名效果取最高)`); + h.runUntil(() => !u.skill.active, 40); + h.run(0.5); + assert.deepEqual([tx.s.blockRadiusScale, u.s.atk, u.s.aspd], [0, u.base.atk, u.base.aspd], `${label(f)}: all back`); + done(h); + } +}); + +test('S2 保护性拒止 (MANUAL, data ACTIVE_RANGE on its y-11: an injured ally there): 10 shots, range y-11, ATK +; at an enemy (air too): true damage + 5 s 停顿 within 1.5, then the heal there; at an ally (no enemy): heal first, no 停顿', () => { + for (const f of [[5, false], [6, true]]) { + const [tier, elite] = f; + const sk = skillOf(tier, elite, S2); + assert.deepEqual([sk.bb.atk, sk.bb['attack@atk_scale'], sk.bb['attack@heal_scale'], sk.bb['attack@sluggish'], sk.bb['attack@trigger_time'], sk.rangeId, sk.trigger.rule], + [elite ? 1.25 : 1.1, elite ? 3.5 : 3.1, elite ? 1.7 : 1.35, 5, 10, 'y-11', 'ACTIVE_RANGE'], label(f)); + const others = [{ uid: 3, chessId: YAK, row: 10, col: 7 }, { uid: 4, chessId: TEXAS, row: 11, col: 4 }]; + const { h, u, log } = field({ tier, elite, skill: 1, others }); + const yak = h.unit(3); + const mine = (from) => log.slice(from).filter((c) => c.source === u); + assert.deepEqual([u.skill.rule, u.skill.kind], ['ACTIVE_RANGE', 'ammo'], label(f)); + assert.ok(!inRange(u, yak), `${label(f)}: (10,7) is outside her y-6`); + u.skill.gainSp(999); + h.run(1.5); + assert.equal(u.skill.activations, 0, `${label(f)}: nobody injured: no cast`); + // an ally shot first: 角峰 injured inside the y-11 only, no enemy she can select (the dummy is beyond it) + const e = h.spawn('enemy_dummy', { pos: [11, 8] }); // √2 from 角峰, outside the y-11 (col +4) + const n0 = log.length; + yak.hp = yak.s.maxHp * 0.3; + assert.ok(h.runUntil(() => u.skill.active, 0.5), `${label(f)}: cast for the y-11`); + assert.deepEqual([u.liveRangeGrid, u.skill.ammoMax], [sk.rangeGrid, 10], `${label(f)}: y-11, 10 shots`); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `${label(f)}: ATK`); + assert.ok(h.runUntil(() => u.skill.ammoLeft === 9, 3), `${label(f)}: one shot (the cast's attack)`); + const shot = mine(n0); + assert.deepEqual(shot.slice(0, 2).map((c) => [c.name, c.target]), [['heal', yak], ['damaged', e]], `${label(f)}: at an ally — heal first, then the enemy within 1.5`); + approx(shot[0].amount, u.s.atk * sk.bb['attack@heal_scale'], `${label(f)}: heal ${sk.bb['attack@heal_scale'] * 100} % ATK`); + approx(shot[1].amount, u.s.atk * sk.bb['attack@atk_scale'], `${label(f)}: ${sk.bb['attack@atk_scale'] * 100} % ATK true`); + assert.equal(h.hooksOf('statusApplied').filter((c) => c.source === u).length, 0, `${label(f)}: no 停顿 from an ally shot`); + // an enemy inside the y-11 is the target from now on — a flyer + const fly = h.spawn('enemy_fly', { pos: [9, 7] }); // 1.0 from 角峰 + const n1 = log.length; + assert.ok(h.runUntil(() => mine(n1).some((c) => c.name === 'damaged'), 4), `${label(f)}: another shot lands (a 医疗单元 in flight)`); + const burst = mine(n1); + assert.equal(burst[0].name, 'damaged', `${label(f)}: at an enemy — damage first`); + assert.equal(burst[0].target, fly, `${label(f)}: the flyer (可对空)`); + for (const c of burst.filter((x) => x.name === 'damaged')) assert.deepEqual([c.type, c.dmg.isAttack, c.dmg.isSplash, c.dmg.isSkill], ['true', true, false, true], `${label(f)}: 真实普通伤害`); + assert.ok(burst.some((c) => c.name === 'heal' && c.target === yak), `${label(f)}: then 角峰 (1.0 away) healed`); + assert.ok(!burst.some((c) => c.target === e), `${label(f)}: the dummy (2.2 from the flyer) untouched`); + const slugs = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish'); + assert.ok(slugs.length >= 1 && slugs.every((c) => c.duration === 5 && c.target === fly), `${label(f)}: 停顿 5 s on the enemy struck`); + h.runUntil(() => !u.skill.active, 40); + assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `${label(f)}: back to y-6 after 10 shots`); + done(h); + } +}); + +test('S3 破梏重生 (MANUAL, data DEFAULT): 35 s, ATK +, attack interval 1.30 s (−1.55 flat), 2 heal targets; with no anchor she stays', () => { + for (const f of [[5, false], [6, true]]) { + const [tier, elite] = f; + const sk = skillOf(tier, elite, S3); + assert.deepEqual([sk.duration, sk.bb.atk, sk.bb.base_attack_time, sk.spCost, sk.initSp, sk.trigger.rule, sk.overrideTokenKey], + [35, elite ? 1.25 : 1.1, -1.55, elite ? 60 : 65, elite ? 30 : 25, 'DEFAULT', ANCHOR], label(f)); + const others = [{ uid: 3, chessId: YAK, row: 10, col: 5 }, { uid: 4, chessId: TEXAS, row: 11, col: 5 }]; + const { h, u } = field({ tier, elite, skill: 2, others }); + const yak = h.unit(3), tx = h.unit(4); + u.skill.gainSp(999); + yak.hp = yak.s.maxHp * 0.3; tx.hp = tx.s.maxHp * 0.4; + const n0 = heals(h, u).length; + assert.ok(h.runUntil(() => u.skill.active, 4), label(f)); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `${label(f)}: ATK`); + approx(u.s.interval, 2.85 - 1.55, `${label(f)}: 1.30 s`); + h.runUntil(() => heals(h, u).length >= n0 + 2, 3); + const two = heals(h, u).slice(n0, n0 + 2); // the cast's heal (same tick) already heals two + assert.deepEqual(two.map((c) => c.target.id).sort(), [yak.id, tx.id].sort(), `${label(f)}: two targets per heal`); + assert.equal(two[0].t, two[1].t, `${label(f)}: in one attack`); + h.run(2); + assert.deepEqual([u.tileR, u.tileC, u.hidden], [10, 4, false], `${label(f)}: no anchor: no move`); + h.runUntil(() => !u.skill.active, 40); + approx(u.s.interval, 2.85, `${label(f)}: interval back`); + done(h); + } +}); + +test('S3 with her anchor: 0.1 s after the cast she flies to it (hidden, 无敌, the skill time paused, 3.5 tiles/s; operators within 2.2 of her flight enter T2), the two latest-deployed operators of her range are withdrawn 0.6 s on and back at once; she lands on its tile facing its way; the anchor is withdrawn for the rest of the skill', () => { + for (const f of [[5, false], [6, true]]) { + const [tier, elite] = f; + // deployment order (left column first, top row first): (11,5) → (10,5) → (9,5); (12,7) beside her flight + const others = [{ uid: 3, chessId: YAK, row: 11, col: 5 }, { uid: 4, chessId: TEXAS, row: 10, col: 5 }, { uid: 5, chessId: PERFUMER, row: 9, col: 5 }, + { uid: 6, chessId: 'chess_char_2_02_a', row: 12, col: 7 }]; + const { h, u, a } = field({ tier, elite, skill: 2, anchor: { row: 10, col: 9, dir: 'UP' }, others }); + const yak = h.unit(3), tx = h.unit(4), pf = h.unit(5), sil = h.unit(6); + assert.ok(a.alive && u.mem.k2link === a, `${label(f)}: the anchor deployed at the start, linked to her`); + assert.ok(!inRange(u, a), `${label(f)}: outside her range`); + assert.ok(!sil.findBuff('talent:kalts2:regen'), `${label(f)}: 赫默 (12,7) is outside her range`); + const seq = [yak.deploySeq, tx.deploySeq, pf.deploySeq]; + assert.ok(seq[0] < seq[1] && seq[1] < seq[2], label(f)); + u.skill.gainSp(999); + yak.hp = yak.s.maxHp * 0.3; + assert.ok(h.runUntil(() => u.skill.active, 4), label(f)); + const t0 = h.b.time; + h.runUntil(() => u.hidden, 0.2); + assert.ok(u.hidden && u.mem.k2flight, `${label(f)}: flying`); + approx(h.b.time - t0, 0.1, `${label(f)}: 0.1 s after the cast`, 0.4); + const tl = u.skill.timeLeft; + assert.equal(h.b.dealDamage(null, u, { amount: 5000, type: 'true', sourceless: true }), 0, `${label(f)}: 无敌 in flight`); + const T = 5 / 3.5; + h.run(0.7); + approx(u.skill.timeLeft, tl, `${label(f)}: skill time paused`, 1e-6); + // the two latest deployed (护理 (9,5) and 德克萨斯 (10,5)) withdrawn and back on their tiles; 角峰 stays + for (const x of [pf, tx]) { + assert.ok(h.hooksOf('death').some((c) => c.unit === x && c.reason === 'retreat'), `${label(f)}: ${x.defId} withdrawn`); + assert.ok(x.alive && x.deploySeq > seq[2], `${label(f)}: ${x.defId} back at once`); + } + assert.ok(!h.hooksOf('death').some((c) => c.unit === yak), `${label(f)}: 角峰 (the first deployed) stays`); + assert.deepEqual([tx.tileR, tx.tileC, pf.tileR, pf.tileC], [10, 5, 9, 5], `${label(f)}: on their tiles`); + h.runUntil(() => !u.hidden, T); + assert.ok(!u.hidden, `${label(f)}: landed`); + approx(h.b.time - t0, 0.1 + T, `${label(f)}: after ${T.toFixed(2)} s of flight`, 0.1); + assert.deepEqual([u.tileR, u.tileC, u.dir], [10, 9, 'UP'], `${label(f)}: on the anchor's tile, facing its way`); + assert.ok(!a.alive && !a.removed && a.mem.docked, `${label(f)}: the anchor withdrawn (its piece kept)`); + assert.equal(u.mem.summonStock[ANCHOR], 0, `${label(f)}: stock emptied`); + assert.ok(u.skill.active, label(f)); + approx(u.skill.timeLeft, tl, `${label(f)}: the remaining time kept`, 0.1); + assert.equal(sil.findBuff('talent:kalts2:regen')?.mods.hpRegen, 50, `${label(f)}: 赫默, 2.0 from her flight, entered T2`); + h.runUntil(() => !u.skill.active, 40); + h.run(5); + assert.ok(!a.alive, `${label(f)}: no anchor back after the skill (stock 0)`); + done(h); + } +}); + +test('the anchor link: she leaves the field before her S3 ⇒ the anchor on the field no longer moves her (kalts2_s_3[on_token_born] is lost with her); with S1 / S2 the anchor piece never takes the field', () => { + const { h, u, a } = field({ tier: 6, elite: true, skill: 2, anchor: { row: 10, col: 9 }, others: [{ uid: 3, chessId: YAK, row: 10, col: 5 }] }); + const yak = h.unit(3); + assert.equal(u.mem.k2link, a); + h.b.kill(u, null); + h.step(); + assert.equal(u.mem.k2link, null, 'link lost'); + assert.ok(h.runUntil(() => u.alive, 90), 'she redeploys'); + assert.ok(a.alive, 'the anchor still stands'); + u.skill.gainSp(999); + yak.hp = yak.s.maxHp * 0.3; + assert.ok(h.runUntil(() => u.skill.active, 4)); + h.run(3); + assert.deepEqual([u.tileR, u.tileC, u.hidden], [10, 4, false], 'no transport'); + done(h); + for (const skill of [0, 1]) { + const r = field({ skill, anchor: { row: 10, col: 9 } }); + r.h.run(2); + assert.ok(!r.a.alive && r.a.deferDeploy, `S${skill + 1}: the anchor stays off the field`); + done(r.h); + } +}); diff --git a/test/content/op_monstr.test.js b/test/content/op_monstr.test.js new file mode 100644 index 00000000..74b8a6af --- /dev/null +++ b/test/content/op_monstr.test.js @@ -0,0 +1,377 @@ +// test/content/op_monstr.test.js — the 自选 operator kit of Mon3tr (char_4179_monstr, 6★ 链愈师; kit +// server/sim/content/kits/ops/op-monstr.js) and her summon 重构体 (token_10050_monstr_prosts), fielded the production way (a +// DIY slot + its `diy` pick; the 重构体 as a 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 or XAH-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 token's variant); the fidelity checklist of kits/README.md +// item by item. +// Run: node --test test/content/op_monstr.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; +import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js'; +import { diyPool, validateDiyPicks } from '../../shared/diy.js'; + +const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8')); +const CHESS = load('chess'); +const BACKUPS = load('backups'); +const MONSTR = 'char_4179_monstr'; +const PROSTS = 'token_10050_monstr_prosts'; +const FORMS = BACKUPS.units[MONSTR].forms; +const TOKEN = BACKUPS.tokens[PROSTS]; +const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; +const XAHX = 'uniequip_002_monstr'; +const S1 = 'skchr_monstr_1', S2 = 'skchr_monstr_2', S3 = 'skchr_monstr_3'; +const statusKey = (tier, elite) => (elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'); +const formOf = (tier, elite) => FORMS[statusKey(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 variantOf = (tier, elite) => TOKEN.variants[`${MONSTR}@${statusKey(tier, elite)}`]; +/** The trait / T1 blackboards a form fights with (module changes applied). */ +const traitOf = (tier, elite, mod) => (elite && mod ? modOf(tier, mod).traitOverride.bb : formOf(tier, elite).trait.bb); +const t0Of = (tier, elite, mod) => (elite && mod && modOf(tier, mod).talentChanges.find((t) => t.talentIndex === 0)?.bb) || formOf(tier, elite).talents.find((t) => t.index === 0).bb; +const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`); +const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o }); +const ENEMIES = { + enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }), + enemy_biter: dummy('enemy_biter', { atk: 300, range: 1.2, bat: 1 }), +}; +/** Every 自选 form: [tier, elite, module]. */ +const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, XAHX].map((m) => [t, true, m]))]; +const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`; +const YAK = 'chess_char_1_02_a', ESTELLE = 'chess_char_1_12_a', SILENCE = 'chess_char_2_02_a', PERFUMER = 'chess_char_2_14_a'; + +/** + * A battle with Mon3tr as uid 1 at (10, 4) facing RIGHT (`prosts`: her piece as uid 2), plus `others`; `log` = her heals / + * damage in the order they happen. + */ +function field({ tier = 5, elite = false, mod = null, skill = 0, prosts = null, others = [], seed = 5, dp = 99 } = {}) { + const units = [{ uid: 1, diy: { slot: SLOT[tier], charId: MONSTR, skillIndex: skill, uniEquipId: mod }, elite, row: 10, col: 4 }]; + if (prosts) units.push({ uid: 2, kind: 'token', tokenId: PROSTS, ownerUid: 1, row: prosts.row, col: prosts.col }); + const log = []; + const h = makeBattle({ + defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, + flags: { dpInit: dp, dpPerSec: 0, dpMax: 999 }, + hooks: ['damaged', 'heal', 'skillStart', 'skillEnd', 'death', 'deploy', 'fatal'], captureNoisy: true, + units: [...units, ...others], + setup: (b) => { + b.on('heal', (c) => { if (!c.opts?.regen) log.push({ name: 'heal', ...c, t: b.time }); }, { priority: -1000 }); + b.on('damaged', (c) => log.push({ name: 'damaged', ...c, t: b.time }), { priority: -1000 }); + }, + }); + h.step(); + return { h, u: h.unit(1), p: prosts ? h.unit(2) : null, log }; +} +const healsOf = (log, u, from = 0) => log.slice(from).filter((c) => c.name === 'heal' && c.source === u); +const setHp = (x, r) => { x.hp = x.s.maxHp * r; }; +function done(h) { + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); +} + +test('Mon3tr in every 自选 form: her operator kit (all three skills authored), stats + module attributes, y-2, one main heal per attack (her own chain), blocks 1, ground-targetable, 罗德岛, no 特质; her 重构体: the form\'s token stats, 禁疗, block 0, taunt −1', () => { + assert.equal(OPERATOR_KITS[MONSTR], KITS[MONSTR]); + for (const f of FORMS_ALL) { + const [tier, elite, mod] = f; + for (const skill of [0, 1, 2]) { + const { h, u, p } = field({ tier, elite, mod, skill, prosts: { row: 10, col: 6 } }); + 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], [MONSTR, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f)); + assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.bat, u.base.cost], [form.stats.maxHp, form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), 2.85, 18], `${label(f)}: stats`); + assert.deepEqual([u.profile.dmgType, u.profile.heal, u.profile.attack, u.def.subProf, u.s.blockCnt], ['heal', { mode: 'single' }, 'ranged', 'chainhealer', 1], `${label(f)}: 链愈师`); + assert.deepEqual(u.def.traitBb, traitOf(tier, elite, mod), `${label(f)}: the trait (XAH-X 0.85)`); + assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: y-2`); + assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds, u.def.tokens, u.def.raw.nationId], [BACKUPS.diy.operators[MONSTR].bonds, [], [PROSTS], 'rhodes'], `${label(f)}: bonds / 特质 / summon`); + assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: enemies target her`); + const ts = ((elite && mod && variantOf(tier, elite).byModule?.[mod]) || variantOf(tier, elite)).stats; + assert.deepEqual([p.base.maxHp, p.base.def, p.base.atk, p.base.cost, p.base.respawnTime, p.s.blockCnt, p.s.taunt], [ts.maxHp, ts.def, 0, 3, 15, 0, -1], `${label(f)}: 重构体 stats`); + assert.deepEqual([p.s.flags.noHeal, p.s.flags.noBlock, p.profile.noAttack, !!p.kit.generic, p.alive], [true, true, true, false, true], `${label(f)}: 禁疗, blocks nothing, no attack, on the field`); + done(h); + } + } + // E2 Lv1 1492 / 422 / 176, E2 Lv60 1653 / 492 / 206; XAH-X +40 / +30 → +65 / +40 ATK / DEF; the 重构体 4292 / 181 → 5048 / 208 + 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], [1492, 422, 1653, 492]); + assert.deepEqual([modOf(5, XAHX).attr, modOf(6, XAHX).attr], [{ atk: 40, def: 30 }, { atk: 65, def: 40 }]); + assert.deepEqual([variantOf(5, false).stats.maxHp, variantOf(5, false).stats.def, variantOf(6, true).stats.maxHp], [4292, 181, 5048]); + assert.deepEqual([t0Of(5, true, XAHX).atk, t0Of(6, true, XAHX).atk, t0Of(6, true, XAHX)['attack@chain.extra_cnt'], t0Of(6, false, null).atk], [0.15, 0.25, 1, 0.15]); +}); + +test('a 自选 pick: Mon3tr is offered at tiers 5 and 6 (she has a kit) and a roster with her and XAH-X passes validateDiyPicks', () => { + const data = { chess: CHESS, backups: BACKUPS }; + assert.ok(KITTED_CHARS.includes(MONSTR)); + for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(MONSTR), `tier ${t}`); + assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: MONSTR, skillIndex: 2, uniEquipId: XAHX } }, { data, kitted: KITTED_CHARS }), + { ok: true, picks: { [SLOT[6]]: { charId: MONSTR, skillIndex: 2, uniEquipId: XAHX } } }); +}); + +test('trait 链愈师: the heal jumps to the lowest-HP-ratio injured ally of the 3×3 around the last one healed, ×0.75 per jump (XAH-X ×0.85), 3 units healed; a full-HP unit and one two tiles away are skipped', () => { + for (const f of [[5, false, null], [6, true, null], [5, true, XAHX], [6, true, XAHX]]) { + const [tier, elite, mod] = f; + const step = traitOf(tier, elite, mod)['attack@chain.atk_scale']; + assert.equal(step, mod ? 0.85 : 0.75, label(f)); + const others = [{ uid: 3, chessId: YAK, row: 10, col: 5 }, { uid: 4, chessId: ESTELLE, row: 11, col: 6 }, { uid: 5, chessId: SILENCE, row: 12, col: 7 }, + { uid: 6, chessId: PERFUMER, row: 9, col: 7 }, { uid: 7, chessId: 'chess_char_1_11_a', row: 10, col: 6 }]; + const { h, u, log } = field({ tier, elite, mod, skill: 2, others }); + const [A, B, C, F, G] = [3, 4, 5, 6, 7].map((i) => h.unit(i)); + setHp(A, 0.2); setHp(B, 0.5); setHp(C, 0.6); setHp(F, 0.3); + const n0 = log.length; + assert.ok(h.runUntil(() => healsOf(log, u, n0).length > 0, 4), label(f)); + h.step(); + const hs = healsOf(log, u, n0).filter((c) => c.t === healsOf(log, u, n0)[0].t); + assert.deepEqual(hs.map((c) => c.target), [A, B, C], `${label(f)}: A → B → C (G full, F two tiles off)`); + const atk = u.s.atk; + approx(hs[0].amount, atk, `${label(f)}: the main heal`); + approx(hs[1].amount, atk * step, `${label(f)}: ×${step}`); + approx(hs[2].amount, atk * step * step, `${label(f)}: ×${step}²`); + assert.ok(!healsOf(log, u, n0).some((c) => c.target === F || c.target === G), `${label(f)}: F / G not healed`); + done(h); + } +}); + +test('T1 自我修复 free link: her 重构体 in a chain uses no target of the count and the next jump keeps the amount; XAH-X stage 3: one more jump without decay', () => { + for (const f of [[5, false, null], [5, true, XAHX], [6, true, XAHX]]) { + const [tier, elite, mod] = f; + const step = traitOf(tier, elite, mod)['attack@chain.atk_scale']; + const extra = t0Of(tier, elite, mod)['attack@chain.extra_cnt'] ?? 0; + assert.equal(extra, tier === 6 && mod ? 1 : 0, label(f)); + const others = [{ uid: 3, chessId: YAK, row: 10, col: 5 }, { uid: 4, chessId: ESTELLE, row: 11, col: 6 }, { uid: 5, chessId: SILENCE, row: 12, col: 7 }]; + const { h, u, p, log } = field({ tier, elite, mod, skill: 2, prosts: { row: 11, col: 5 }, others }); + const [A, B, C] = [3, 4, 5].map((i) => h.unit(i)); + setHp(A, 0.2); setHp(p, 0.3); setHp(B, 0.5); setHp(C, 0.6); + const n0 = log.length; + assert.ok(h.runUntil(() => healsOf(log, u, n0).length > 0, 4), label(f)); + const hs = healsOf(log, u, n0).filter((c) => c.t === healsOf(log, u, n0)[0].t); + assert.deepEqual(hs.map((c) => c.target), [A, p, B, C], `${label(f)}: A → 重构体 → B → C (4 healed: the 重构体 is not counted)`); + const atk = u.s.atk; + approx(hs[1].amount, atk * step, `${label(f)}: the jump onto it decays`); + approx(hs[2].amount, atk * step, `${label(f)}: the next one does not`); + approx(hs[3].amount, atk * step * (extra ? 1 : step), `${label(f)}: then ${extra ? 'still no decay (下次跳跃也不衰减)' : 'it decays again'}`); + done(h); + } +}); + +test('T1 自我修复 — the 重构体: only her heals reach it (禁疗), it loses 8 HP every 0.1 s from 1 s after it lands, every ally of its 3×3 ATK +15 % (XAH-X stage 3 +25 %; not 2 tiles away); gone ⇒ back 15 s later for 3 DP while she stands; withdrawn when she leaves, her redeployment readies it', () => { + for (const f of [[5, false, null], [6, true, XAHX], [5, true, XAHX]]) { + const [tier, elite, mod] = f; + const atkUp = t0Of(tier, elite, mod).atk; + const others = [{ uid: 3, chessId: YAK, row: 11, col: 6 }, { uid: 4, chessId: ESTELLE, row: 12, col: 6 }, { uid: 5, chessId: SILENCE, row: 9, col: 3 }]; + const { h, u, p } = field({ tier, elite, mod, skill: 0, prosts: { row: 10, col: 6 }, others, dp: 50 }); + const yak = h.unit(3), tx = h.unit(4), sil = h.unit(5); + h.run(0.3); + approx(yak.s.atk, yak.base.atk * (1 + atkUp), `${label(f)}: 角峰 next to it ATK +${atkUp * 100} %`); + approx(u.s.atk, u.base.atk, `${label(f)}: Mon3tr (two tiles off) nothing`); + approx(tx.s.atk, tx.base.atk, `${label(f)}: 艾丝黛尔 (two rows off) nothing`); + assert.equal(p.findBuff('talent:monstr:prostsAtk'), null, `${label(f)}: not itself`); + // the 流失: none in the first second, then 80 per second + const hp0 = p.hp; + assert.equal(hp0, p.s.maxHp); + h.run(0.6); + assert.equal(p.hp, hp0, `${label(f)}: nothing before 1 s`); + h.run(1.0); + const lost = hp0 - p.hp; + assert.ok(lost > 60 && lost < 100, `${label(f)}: ≈80 HP in the next second (${lost})`); + assert.ok(h.hooksOf('damaged').some((c) => c.target === p && c.dmg?.tags?.includes('hpLoss')), `${label(f)}: a 流失`); + // 禁疗: 赫默 never heals it; Mon3tr does + setHp(p, 0.3); + h.run(6); + assert.equal(h.hooksOf('heal').filter((c) => c.source === sil && c.target === p).length, 0, `${label(f)}: no heal from 赫默`); + assert.ok(h.hooksOf('heal').some((c) => c.source === u && c.target === p), `${label(f)}: Mon3tr heals it`); + // destroyed: back 15 s later for 3 DP + const dp0 = h.b.players[0].dp; + h.b.kill(p, null); + h.run(14.5); + assert.ok(!p.alive, `${label(f)}: not before 15 s`); + h.run(0.7); + assert.ok(p.alive, `${label(f)}: back`); + assert.deepEqual([p.tileR, p.tileC], [10, 6], `${label(f)}: on its tile`); + approx(h.b.players[0].dp, dp0 - 3, `${label(f)}: 3 DP`); + // she leaves: withdrawn; it comes with her redeployment at once + h.b.kill(u, null); + h.step(); + assert.ok(!p.alive && !p.removed, `${label(f)}: withdrawn with her`); + assert.ok(h.runUntil(() => u.alive, 90), `${label(f)}: she redeploys`); + assert.ok(h.runUntil(() => p.alive, 0.3), `${label(f)}: at once`); + done(h); + } +}); + +test('T2 战术协同: each heal of hers gives its target and her ASPD +20 for 10 s (no stacking: refreshed); under S2 ×1.8 / ×2.3 — the strongest effect stays', () => { + for (const f of [[5, false, null], [6, true, XAHX]]) { + const [tier, elite, mod] = f; + const t1 = formOf(tier, elite).talents.find((t) => t.index === 1).bb; + const s2 = skillOf(tier, elite, S2); + assert.deepEqual([t1.attack_speed, t1.buff_duration, s2.bb.talent_scale], [20, 10, elite ? 2.3 : 1.8], label(f)); + const others = [{ uid: 3, chessId: YAK, row: 10, col: 5 }]; + const { h, u, log } = field({ tier, elite, mod, skill: 1, others }); + const yak = h.unit(3); + setHp(yak, 0.3); + const n0 = log.length; + assert.ok(h.runUntil(() => healsOf(log, u, n0).length > 0, 4), label(f)); + assert.deepEqual([yak.findBuff('talent:monstr:aspd')?.mods, u.findBuff('talent:monstr:aspd')?.mods], [{ aspd: 20 }, { aspd: 20 }], `${label(f)}: target and herself`); + approx(yak.findBuff('talent:monstr:aspd').timeLeft, 10, `${label(f)}: 10 s`, 0.05); + assert.equal(yak.s.aspd, yak.base.aspd + 20, label(f)); + // S2 (an enemy in her y-2): ×talent_scale + h.spawn('enemy_dummy', { pos: [11, 6] }); + u.skill.gainSp(999); + assert.ok(h.runUntil(() => u.skill.active, 2), `${label(f)}: S2`); + setHp(yak, 0.3); + const n1 = log.length; + assert.ok(h.runUntil(() => healsOf(log, u, n1).some((c) => c.target === yak), 6), label(f)); + approx(yak.findBuff('talent:monstr:aspd').mods.aspd, 20 * s2.bb.talent_scale, `${label(f)}: ×${s2.bb.talent_scale}`); + approx(u.findBuff('talent:monstr:aspd').mods.aspd, 20 * s2.bb.talent_scale, `${label(f)}: herself too`); + done(h); + } +}); + +test('S1 策略:超压链接 (AUTO, attack SP 5 / 3, data DEFAULT — the next heal): that heal is 135 % / 170 % ATK and heals 4 units', () => { + for (const f of [[5, false, null], [6, true, XAHX]]) { + const [tier, elite, mod] = f; + const sk = skillOf(tier, elite, S1); + const step = traitOf(tier, elite, mod)['attack@chain.atk_scale']; + assert.deepEqual([sk.skillType, sk.spType, sk.spCost, sk.initSp, sk.bb.heal_scale, sk.bb['chain.max_target'], sk.trigger.rule], + ['AUTO', 'INCREASE_WHEN_ATTACK', elite ? 3 : 5, 0, elite ? 1.7 : 1.35, 4, 'DEFAULT'], label(f)); + const others = [{ uid: 3, chessId: YAK, row: 10, col: 5 }, { uid: 4, chessId: ESTELLE, row: 11, col: 6 }, { uid: 5, chessId: SILENCE, row: 12, col: 7 }, { uid: 6, chessId: PERFUMER, row: 12, col: 8 }]; + const { h, u, log } = field({ tier, elite, mod, skill: 0, others }); + const [A, B, C, D] = [3, 4, 5, 6].map((i) => h.unit(i)); + assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost], ['DEFAULT', 'attack', sk.spCost], label(f)); + u.skill.gainSp(999); + setHp(A, 0.2); setHp(B, 0.4); setHp(C, 0.5); setHp(D, 0.6); + const n0 = log.length; + assert.ok(h.runUntil(() => u.skill.activations === 1, 4), `${label(f)}: cast as she heals`); + const hs = healsOf(log, u, n0); + assert.deepEqual(hs.slice(0, 4).map((c) => c.target), [A, B, C, D], `${label(f)}: 4 units`); + const base = u.s.atk * sk.bb.heal_scale; + hs.slice(0, 4).forEach((c, i) => approx(c.amount, base * step ** i, `${label(f)}: link ${i}`)); + assert.ok(!u.skill.active, `${label(f)}: spent on that heal`); + done(h); + } +}); + +test('S2 策略:超负荷 (MANUAL, data SKILL_RANGE on y-2: an enemy there; 30 s): her 重构体 in range is her heal target, even at full HP with nobody injured; 0.6 s after her attack healed it a second chain starts on it', () => { + for (const f of [[5, false, null], [6, true, XAHX]]) { + const [tier, elite, mod] = f; + const sk = skillOf(tier, elite, S2); + assert.deepEqual([sk.duration, sk.trigger.rule, sk.rangeId, sk.spCost, sk.initSp], [30, 'SKILL_RANGE', 'y-2', 15, elite ? 8 : 7], label(f)); + const others = [{ uid: 3, chessId: YAK, row: 11, col: 5 }, { uid: 4, chessId: ESTELLE, row: 12, col: 6 }]; + const { h, u, p, log } = field({ tier, elite, mod, skill: 1, prosts: { row: 10, col: 5 }, others }); + const yak = h.unit(3), tx = h.unit(4); + assert.equal(u.skill.rule, 'SKILL_RANGE'); + u.skill.gainSp(999); + setHp(yak, 0.3); + h.run(2); + assert.equal(u.skill.activations, 0, `${label(f)}: an injured ally, no enemy: no cast`); + h.spawn('enemy_dummy', { pos: [9, 6] }); + assert.ok(h.runUntil(() => u.skill.active, 0.5), `${label(f)}: an enemy in her y-2`); + approx(u.skill.timeLeft, 30, `${label(f)}: 30 s`, 0.05); + // 角峰 is lower, the 重构体 is still the target + setHp(p, 0.9); setHp(yak, 0.2); + let n0 = log.length; + assert.ok(h.runUntil(() => healsOf(log, u, n0).length > 0, 4), label(f)); + const first = healsOf(log, u, n0); + assert.equal(first[0].target, p, `${label(f)}: the 重构体 first`); + const t0 = first[0].t; + // the second chain 0.6 s later, on it again + h.run(0.7); + const again = healsOf(log, u, n0).filter((c) => c.t > t0 + 0.5); + assert.ok(again.length > 0, `${label(f)}: a second chain`); + approx(again[0].t - t0, 0.6, `${label(f)}: 0.6 s later`, 0.06); + assert.equal(again[0].target, p, `${label(f)}: starting on the 重构体`); + approx(again[0].amount, u.s.atk, `${label(f)}: her ATK`); + // nobody injured: she heals the full-HP 重构体 anyway + setHp(yak, 1); setHp(tx, 1); setHp(u, 1); p.hp = p.s.maxHp; + n0 = log.length; + h.run(4); + assert.ok(healsOf(log, u, n0).some((c) => c.target === p), `${label(f)}: the full-HP 重构体 healed`); + done(h); + } +}); + +test('S3 策略:熔毁 (MANUAL, data DEFAULT, 25 s): refused without her 重构体; with it: it is withdrawn and she moves onto its tile — x-4, ATK +, interval 1.35 s, block 3, max HP +5000 (ratio kept), 流失 80/s; true damage on every ground enemy she blocks, a chain heal on herself (0.5 ATK, a free link) each attack; back at the end with SP 0, the 重构体 returns', () => { + for (const f of [[5, false, null], [6, true, XAHX]]) { + const [tier, elite, mod] = f; + const sk = skillOf(tier, elite, S3); + assert.deepEqual([sk.duration, sk.bb.atk, sk.bb.base_attack_time, sk.bb.block_cnt, sk.bb.max_hp, sk.bb.damage_per_second, sk.bb['attack@heal_scale'], sk.rangeId, sk.trigger.rule], + [25, elite ? 2.8 : 2.5, -1.5, 2, 5000, 80, 0.5, 'x-4', 'DEFAULT'], label(f)); + // refused without the 重构体 (destroyed: its piece waits) + const r0 = field({ tier, elite, mod, skill: 2, prosts: { row: 10, col: 6 }, others: [{ uid: 3, chessId: YAK, row: 11, col: 4 }] }); + r0.h.b.kill(r0.p, null); + r0.u.skill.gainSp(999); + setHp(r0.h.unit(3), 0.3); + r0.h.run(3); + assert.equal(r0.u.skill.activations, 0, `${label(f)}: no cast without the 重构体`); + assert.ok(r0.u.skill.ready, `${label(f)}: still ready`); + done(r0.h); + const others = [{ uid: 3, chessId: YAK, row: 11, col: 4 }, { uid: 4, chessId: ESTELLE, row: 9, col: 7 }]; + const { h, u, p, log } = field({ tier, elite, mod, skill: 2, prosts: { row: 10, col: 6 }, others, dp: 50 }); + const yak = h.unit(3), tx = h.unit(4); + const hpRatio = 0.8; + setHp(u, hpRatio); + u.skill.gainSp(999); + setHp(yak, 0.3); + assert.ok(h.runUntil(() => u.skill.active, 4), label(f)); + assert.ok(!p.alive && !p.removed, `${label(f)}: the 重构体 withdrawn (its piece waits)`); + assert.deepEqual([u.tileR, u.tileC, u.ground], [10, 6, true], `${label(f)}: on its tile (a ground tile)`); + assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `${label(f)}: x-4`); + approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `${label(f)}: ATK`); + approx(u.s.interval, 2.85 - 1.5, `${label(f)}: 1.35 s`); + assert.equal(u.s.blockCnt, 3, `${label(f)}: block 3`); + approx(u.s.maxHp, u.base.maxHp + 5000, `${label(f)}: max HP +5000`); + approx(u.hpRatio, hpRatio, `${label(f)}: the ratio kept`, 1e-3); + // the 流失 + const hpA = u.hp; + h.run(1); + approx(hpA - u.hp, 80, `${label(f)}: 80 per second`, 0.15); + // the blocked: two ground enemies on her tile, a flyer beside her + const g1 = h.spawn('enemy_dummy', { pos: [10, 6] }); + const g2 = h.spawn('enemy_dummy', { pos: [10, 6] }); + const fly = h.spawn('enemy_fly', { pos: [10, 7] }); + h.step(); + assert.deepEqual([g1.blockedBy, g2.blockedBy], [u, u], `${label(f)}: blocked`); + const n0 = log.length; + assert.ok(h.runUntil(() => log.slice(n0).some((c) => c.name === 'damaged' && c.source === u && c.target.side === 'enemy'), 3), label(f)); + h.step(); + const atkHits = log.slice(n0).filter((c) => c.name === 'damaged' && c.source === u && c.dmg?.isAttack); + const id = atkHits[0].dmg.attackId; + const one = atkHits.filter((c) => c.dmg.attackId === id); + assert.deepEqual(one.map((c) => c.target).sort((a, b) => a.id - b.id), [g1, g2].sort((a, b) => a.id - b.id), `${label(f)}: both blocked enemies, not the flyer`); + for (const c of one) { assert.equal(c.type, 'true', `${label(f)}: true damage`); approx(c.amount, u.s.atk, `${label(f)}: 100 % ATK`); } + assert.ok(!log.slice(n0).some((c) => c.target === fly), `${label(f)}: ground only`); + const self = log.slice(n0).find((c) => c.name === 'heal' && c.source === u && c.target === u); + assert.ok(self, `${label(f)}: the attack heals herself`); + approx(self.amount, u.s.atk * 0.5, `${label(f)}: 50 % ATK`); + // the end: back on her tile, SP 0, the 重构体 returns (its 15 s are long past) for 3 DP + const dp0 = h.b.players[0].dp; + h.runUntil(() => !u.skill.active, 30); + assert.deepEqual([u.tileR, u.tileC, u.skill.sp], [10, 4, 0], `${label(f)}: 【返回】, SP emptied`); + assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `${label(f)}: y-2 again`); + approx(u.s.maxHp, u.base.maxHp, `${label(f)}: max HP back`); + assert.ok(h.runUntil(() => p.alive, 0.5), `${label(f)}: the 重构体 back`); + assert.deepEqual([p.tileR, p.tileC], [10, 6], label(f)); + approx(h.b.players[0].dp, dp0 - 3, `${label(f)}: 3 DP`); + assert.ok(tx.alive, label(f)); + done(h); + } +}); + +test('S3 不死: a fatal blow leaves her at 1 HP (untargetable, blocking nothing), the skill ends on the next tick and she returns at ≤ 1 HP with 1 s of 不死; afterwards a fatal blow knocks her out', () => { + const { h, u, p } = field({ tier: 6, elite: true, mod: XAHX, skill: 2, prosts: { row: 10, col: 6 }, others: [{ uid: 3, chessId: YAK, row: 11, col: 4 }] }); + u.skill.gainSp(999); + setHp(h.unit(3), 0.3); + assert.ok(h.runUntil(() => u.skill.active, 4)); + assert.deepEqual([u.tileR, u.tileC], [10, 6]); + h.b.dealDamage(null, u, { amount: 1e7, type: 'true', sourceless: true }); + assert.deepEqual([u.alive, u.hp, u.s.flags.untargetable, u.s.flags.noBlock, u.skill.active], [true, 1, true, true, true], 'held at 1 HP'); + assert.ok(h.hooksOf('fatal').some((c) => c.unit === u && c.prevented)); + h.step(); + assert.ok(!u.skill.active, 'the skill ended'); + assert.deepEqual([u.tileR, u.tileC], [10, 4], 'returned'); + assert.ok(!u.s.flags.untargetable, 'targetable again'); + h.b.dealDamage(null, u, { amount: 1e7, type: 'true', sourceless: true }); + assert.ok(u.alive && u.hp <= 1, '1 s 不死 after the return'); + h.run(1.1); + h.b.dealDamage(null, u, { amount: 1e7, type: 'true', sourceless: true }); + assert.ok(!u.alive, 'then a knock-out'); + assert.ok(!p.alive, 'her 重构体 off the field'); + done(h); +}); diff --git a/test/golden/diy.json b/test/golden/diy.json index b253f1e5..de1b9234 100644 --- a/test/golden/diy.json +++ b/test/golden/diy.json @@ -4495,48 +4495,48 @@ "snaps": "9e71a576 5f85bc01 c3ec0c6e 907b650d 8d0a1507 16eb2156 378d038b 6506577e 30807fb9 f2c86b9d ec752e72 d7303d83 83002a0a 01b70ca0 09e839c4 98d2358c 161de0dc 5f1fb139 3915cceb aeddf47d a8963bbe 76037450 84a29f92" }, "diy-106": { - "about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 5_diy1_a=csnipe 5_diy2_b=csnipe 5_diy1_a=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo", - "fast": true, + "about": "5_diy1_a=kalts2/S1 5_diy2_a=kalts2/S2 5_diy1_a=kalts2/S3 5_diy2_b=kalts2/S1 5_diy1_b=kalts2/S2 5_diy2_b=kalts2/S3 6_diy1_a=kalts2/S1 6_diy2_a=kalts2/S2 6_diy1_a=kalts2/S3 6_diy2_b=kalts2/S1 6_diy1_b=kalts2/S2 6_diy2_b=kalts2/S3", + "fast": false, "kind": "normal", "stageId": "act1autochess_m07", "modeId": "mode_multi_abyss", - "round": 12, + "round": 13, "seed": 352330692, - "waveId": "act1autochess_h05", - "time": 193.8, - "ticks": 5814, + "waveId": "act1autochess_h06", + "time": 206.53, + "ticks": 6196, "reason": "cleared", "killed": 0, - "total": 126, + "total": 117, "unspawned": 0, "bossHpLeft": null, "rngDraws": 0, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":0,"total":126,"perfect":false,"leaks":126,"leaksUncounted":0,"coins":0,"dmg":59922,"bossDmg":0,"heal":8548,"deaths":25,"layerGains":{}}}, + "players": {"p1":{"killed":0,"total":117,"perfect":false,"leaks":117,"leaksUncounted":0,"coins":0,"dmg":0,"bossDmg":0,"heal":0,"deaths":0,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,3,0,1808,2128,0,8,0], - ["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,6,0,9112,3634,0,34,1], - ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,83,0,1003,6580,0,8,0], - ["p1",4,"op","chess_char_5_diy2_b",2,"none",0,0,86,0,14738,7835,0,17,0], - ["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,153,0,9754,4473,0,27,1], - ["p1",6,"op","chess_char_5_diy2_b",2,"none",0,0,94,0,11704,3746,0,27,1], - ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,90,0,2802,2266,0,12,0], - ["p1",8,"op","chess_char_5_diy2_b",2,"none",0,0,79,0,3020,2720,0,11,0], - ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,141,0,0,5763,2109,6,0], - ["p1",10,"op","chess_char_5_diy2_b",2,"none",0,0,128,0,0,3807,6439,10,1], - ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,112,0,2354,1864,0,12,0], - ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,148,0,3628,4041,0,16,1], + ["p1",1,"op","chess_char_5_diy1_a",0,null,1741.25,1,5,0,0,0,0,0,0], + ["p1",2,"op","chess_char_5_diy2_a",1,null,1741.25,1,7,0,0,0,0,0,0], + ["p1",3,"op","chess_char_5_diy1_a",2,null,1741.25,1,9,0,0,0,0,0,0], + ["p1",4,"op","chess_char_5_diy2_b",0,"none",1928.75,1,11,0,0,0,0,0,0], + ["p1",5,"op","chess_char_5_diy1_b",1,"none",1928.75,1,13,0,0,0,0,0,0], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",1928.75,1,6,0,0,0,0,0,0], + ["p1",7,"op","chess_char_6_diy1_a",0,null,1741.25,1,8,0,0,0,0,0,0], + ["p1",8,"op","chess_char_6_diy2_a",1,null,1741.25,1,10,0,0,0,0,0,0], + ["p1",9,"op","chess_char_6_diy1_a",2,null,1741.25,1,12,0,0,0,0,0,0], + ["p1",10,"op","chess_char_6_diy2_b",0,"none",1928.75,1,14,0,0,0,0,0,0], + ["p1",11,"op","chess_char_6_diy1_b",1,"none",1928.75,1,3,0,0,0,0,0,0], + ["p1",12,"op","chess_char_6_diy2_b",2,"none",1928.75,1,4,0,0,0,0,0,0], ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null] ], - "enemies": {"enemy_1251_lysyta_2":[54,0,54,8019,34461],"enemy_1252_lysytb_2":[42,0,42,35352,24551],"enemy_1329_cbshld_2":[30,0,30,5487,911]}, - "hooks": [1,153,5814,380,380,358,358,16,25,151,5,5,0,8987,28,10,126,126,1,25,0,0,0], - "events": {"atk":380,"deploy":25,"die":25,"dmg":358,"heal":16,"leak":126,"skill":10,"spawn":140,"status":50}, - "fx": {}, - "snaps": "57186c39 a89f274b e649d7d9 75621917 006f6ba9 7f3b8dda bb3864de e8193348 16cc7f17 db8194aa e07167eb 0946e952 5d27c316 103f7135 f7e98be4 ef855c6a d34a4bb6 5d39faae c6e66696 77bde8df" + "enemies": {"enemy_1166_dusbr_2":[39,0,39,0,0],"enemy_1169_duphlx_2":[57,0,57,0,0],"enemy_1172_dugago":[21,0,21,0,0]}, + "hooks": [1,131,6196,0,0,0,0,0,0,117,0,0,0,8044,0,0,117,117,1,0,0,0,0], + "events": {"deploy":12,"fx":81,"leak":117,"spawn":131,"status":117}, + "fx": {"shield":69,"takeoff":12}, + "snaps": "ccc1569a 7999b522 dc7a568a ec4f6f74 10d4b114 031c504f 28e84098 6d770d64 f89924b0 b02f3be6 756308a2 e34ac8a6 ade9e07e ea1b33d3 d41b5c48 9780bf2f 2ea21fea 0955f226 69a9eb19 4bf17f0e a4ddcc48" }, "diy-107": { - "about": "5_diy1_a=acfend 5_diy2_b=acfend 5_diy1_a=acpion 5_diy2_b=acpion 5_diy1_a=acguad 5_diy2_b=acguad 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc", + "about": "5_diy1_a=monstr/S1 5_diy2_a=monstr/S2 5_diy1_a=monstr/S3 5_diy2_b=monstr/S1 5_diy1_b=monstr/S2 5_diy2_b=monstr/S3 5_diy1_b=monstr/S1/002_monstr 5_diy2_b=monstr/S2/002_monstr 5_diy1_b=monstr/S3/002_monstr 6_diy1_a=monstr/S1 6_diy2_a=monstr/S2 6_diy1_a=monstr/S3", "fast": false, "kind": "normal", "stageId": "act2autochess_m01", @@ -4544,39 +4544,39 @@ "round": 12, "seed": 4020340196, "waveId": "act1autochess_h05", - "time": 203.3, - "ticks": 6099, - "reason": "cleared", - "killed": 90, + "time": 230, + "ticks": 6900, + "reason": "timeout", + "killed": 0, "total": 90, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 450, + "rngDraws": 0, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":90,"total":90,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":792864,"bossDmg":0,"heal":34212,"deaths":7,"layerGains":{}}}, + "players": {"p1":{"killed":0,"total":90,"perfect":false,"leaks":90,"leaksUncounted":0,"coins":0,"dmg":0,"bossDmg":0,"heal":260434,"deaths":0,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_diy1_a",2,null,2604,1,106,0,2245,2604,0,9,1], - ["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",2367.8,1,105,0,4281,4374,0,19,1], - ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,100,5,45221,3058,0,15,4], - ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,7,3,48414,1904,0,24,6], - ["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,95,13,46166,11997,0,51,3], - ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",3411,1,8,3,33047,3326,0,19,2], - ["p1",7,"op","chess_char_5_diy1_a",2,null,1272,1,2,0,13858,0,0,19,1], - ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",1587,1,3,38,146945,0,0,177,2], - ["p1",9,"op","chess_char_5_diy1_a",2,null,1372,1,4,1,19328,0,0,20,1], - ["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",1495.56,1,12,27,433358,24418,0,89,3], - ["p1",11,"op","chess_char_5_diy1_a",2,null,1224,1,1,0,0,0,11831,36,2], - ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",1414,1,5,0,0,0,22381,66,2] + ["p1",1,"op","chess_char_5_diy1_a",0,null,1492,1,2,0,0,0,22788,83,13], + ["p1",2,"op","chess_char_5_diy2_a",1,null,1492,1,3,0,0,0,26502,85,3], + ["p1",3,"op","chess_char_5_diy1_a",2,null,1492,1,4,0,0,0,19818,80,0], + ["p1",4,"op","chess_char_5_diy2_b",0,"none",1553,1,6,0,0,35186,22540,89,13], + ["p1",5,"op","chess_char_5_diy1_b",1,"none",1653,1,9,0,0,23530,23492,88,1], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",1653,1,10,0,0,23741,15632,75,0], + ["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_monstr",1653,1,7,0,0,34018,23623,77,10], + ["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_monstr",1653,1,11,0,0,23952,21553,90,1], + ["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_monstr",1653,1,8,0,0,33583,25688,79,0], + ["p1",10,"op","chess_char_6_diy1_a",0,null,1180.8,1,12,0,0,25286,20091,68,5], + ["p1",11,"op","chess_char_6_diy2_a",1,null,1492,1,1,0,0,32246,21677,77,1], + ["p1",12,"op","chess_char_6_diy1_a",2,null,1333.6,1,5,0,0,29462,17028,78,0] ], - "enemies": {"enemy_1229_darmy":[27,27,0,3192,108864],"enemy_1229_darmy_2":[51,51,0,12310,269280],"enemy_9007_acelem":[12,12,0,36180,414720]}, - "hooks": [1,106,6099,594,594,1735,1777,160,97,97,28,27,0,32480,0,62,90,0,1,97,0,2,0], - "events": {"atk":764,"deploy":16,"die":97,"dmg":1777,"fx":154,"heal":142,"skill":55,"spawn":102,"status":62}, - "fx": {"aoe":52,"beam":25,"burst":2,"dp":18,"slash":57}, - "snaps": "03984647 ace8dbe4 49520875 0ad8fbe6 ba7c56bb e58d0120 a539ef32 0e2bc9be 3b333b61 db04449a dace616b 63a39256 5c34a56e d1619dfd 63bd653a d74deb99 1d733ab1 6e8cbc08 04eb4bb7 74b3c8bb 80dc47fc" + "enemies": {"enemy_1229_darmy":[27,0,23,96508,0],"enemy_1229_darmy_2":[51,0,47,135033,0],"enemy_9007_acelem":[12,0,11,29462,0]}, + "hooks": [1,102,6900,1621,1621,1392,1859,1285,0,81,47,47,0,490,0,9,90,81,1,0,0,40,0], + "events": {"atk":1937,"deploy":12,"dmg":1859,"fx":41,"heal":1284,"leak":81,"skill":94,"spawn":102,"status":77}, + "fx": {"beam":1,"burst":40}, + "snaps": "aaf95668 533f2864 617aef21 b49c1754 f4cd0e7c ebf0f57b eae6809f 4f4973cf 596a05d9 1dca3db2 1da78780 1de3ea69 025eae17 1dbbe670 2d4e063e c70c087c 5e75c144 c3441ead b47160c9 ba8a20ae 7fd02f4c 06627592 0f1deca3" }, "diy-108": { - "about": "6_diy1_a=acfend 6_diy2_b=acfend 5_diy1_a=sharp2 5_diy2_b=sharp2 6_diy1_a=acpion 6_diy2_b=acpion 5_diy1_a=acspec 5_diy2_b=acspec 6_diy1_a=acguad 6_diy2_b=acguad 5_diy1_a=acsupo 5_diy2_b=acsupo", - "fast": false, + "about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 6_diy1_b=monstr/S1 6_diy2_b=monstr/S2 6_diy1_b=monstr/S3 6_diy2_b=monstr/S1/002_monstr 6_diy1_b=monstr/S2/002_monstr 6_diy2_b=monstr/S3/002_monstr 5_diy1_a=csnipe 5_diy2_b=csnipe", + "fast": true, "kind": "normal", "stageId": "act2autochess_m02", "modeId": "mode_single_hard", @@ -4586,76 +4586,192 @@ "time": 230, "ticks": 6900, "reason": "timeout", - "killed": 15, + "killed": 12, "total": 36, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 95, + "rngDraws": 12, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":15,"total":36,"perfect":false,"leaks":21,"leaksUncounted":0,"coins":0,"dmg":223366,"bossDmg":0,"heal":0,"deaths":32,"layerGains":{}}}, + "players": {"p1":{"killed":12,"total":36,"perfect":false,"leaks":24,"leaksUncounted":0,"coins":0,"dmg":20413,"bossDmg":0,"heal":86241,"deaths":24,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_6_diy1_a",2,null,0,0,3,0,0,2604,0,0,0], - ["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",0,0,4,0,0,3431,0,0,0], - ["p1",3,"op","chess_char_5_diy1_a",0,null,0,0,5,1,41288,1865,0,28,1], - ["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",0,0,8,2,117437,2374,0,51,1], - ["p1",5,"op","chess_char_6_diy1_a",2,null,1051.96,1,71,0,9905,3535,0,0,2], - ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",0,0,68,0,22211,5712,0,0,2], - ["p1",7,"op","chess_char_5_diy1_a",2,null,1154,1,72,0,0,10386,0,0,0], - ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",1448,1,73,0,0,10136,0,0,0], - ["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,43,0,0,5276,0,0,0], - ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",0,0,52,0,0,7062,0,0,0], - ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,6,0,13315,1160,0,28,1], - ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",0,0,54,0,19210,2860,0,23,1], + ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,3,0,0,2128,0,0,0], + ["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,5,0,0,3514,0,0,0], + ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,58,0,0,8014,0,0,0], + ["p1",4,"op","chess_char_5_diy2_b",2,"none",0,0,52,0,0,40362,0,0,0], + ["p1",5,"op","chess_char_6_diy1_b",0,"none",0,0,56,0,0,4632,10811,25,6], + ["p1",6,"op","chess_char_6_diy2_b",1,"none",0,0,55,0,0,9541,15079,31,1], + ["p1",7,"op","chess_char_6_diy1_b",2,"none",1653,1,61,0,0,17108,6354,18,0], + ["p1",8,"op","chess_char_6_diy2_b",0,"uniequip_002_monstr",0,0,63,0,0,6871,17616,33,8], + ["p1",9,"op","chess_char_6_diy1_b",1,"uniequip_002_monstr",0,0,59,0,0,4977,18039,33,1], + ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_monstr",0,0,53,0,0,18215,18341,34,0], + ["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,54,0,5467,6222,0,22,0], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,62,0,14946,9667,0,31,1], ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null] ], - "enemies": {"enemy_1005_yokai_2":[12,12,0,102,639],"enemy_1005_yokai_3":[12,0,11,29968,87430],"enemy_9009_acfort":[12,3,9,26331,135297]}, - "hooks": [1,73,6900,439,439,497,550,0,47,67,8,24,0,10810,814,0,36,20,1,35,0,0,17], - "events": {"atk":472,"deploy":35,"die":47,"dmg":550,"fx":92,"leak":20,"skill":48,"spawn":50,"status":162}, - "fx": {"beam":35,"dodge":21,"dp":4,"slash":32}, - "snaps": "7c7487b3 43a949cf 206930da 90551654 2113b5a2 a7c4855b 59932997 42395489 3f83a15d ffbd721d 646072f3 fc3be7c4 b210ec54 960a9e51 aa1af455 09ccfce3 50e6e3d0 380e639b 9eaf5bef 3b943f89 3933d1fc 3718a2f5 a8ae4307" + "enemies": {"enemy_1005_yokai_2":[12,12,0,728,2121],"enemy_1005_yokai_3":[12,0,11,61742,1216],"enemy_9009_acfort":[12,0,12,68782,17076]}, + "hooks": [1,63,6900,564,564,387,387,206,36,59,17,17,0,5611,0,0,36,23,1,24,0,0,0], + "events": {"atk":596,"deploy":25,"die":36,"dmg":387,"fx":43,"heal":206,"leak":23,"skill":34,"spawn":50,"status":23}, + "fx": {"beam":43}, + "snaps": "3868e4a8 32cfb83c 2a5847de 47aae40b e61ae62f 9e68b017 56f8e0fb 7161e2a8 b586e16a a930f382 79857faf da28a8f0 e339131f c06b18b5 294afda3 45c41b14 61ac71ac 121e2585 1b5fc8d7 2313cd11 9737892c 9c1c92ed 89ececb5" }, "diy-109": { - "about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc 6_diy1_a=acsupo 6_diy2_b=acsupo", + "about": "5_diy1_a=acfend 5_diy2_b=acfend 5_diy1_a=acpion 5_diy2_b=acpion 5_diy1_a=acguad 5_diy2_b=acguad 5_diy1_a=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo", "fast": false, "kind": "normal", "stageId": "act2autochess_m03", "modeId": "mode_multi_normal", - "round": 13, + "round": 12, "seed": 500258608, - "waveId": "act1autochess_h06", - "time": 215.33, - "ticks": 6460, - "reason": "cleared", - "killed": 15, - "total": 123, + "waveId": "act1autochess_h05", + "time": 230, + "ticks": 6900, + "reason": "timeout", + "killed": 9, + "total": 114, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 453, + "rngDraws": 84, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":15,"total":123,"perfect":false,"leaks":108,"leaksUncounted":0,"coins":0,"dmg":280261,"bossDmg":0,"heal":30749,"deaths":28,"layerGains":{}}}, + "players": {"p1":{"killed":9,"total":114,"perfect":false,"leaks":105,"leaksUncounted":0,"coins":0,"dmg":436116,"bossDmg":0,"heal":34284,"deaths":16,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,5,0,1376,2285,0,5,0], - ["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,7,0,982,2494,0,2,0], - ["p1",3,"op","chess_char_6_diy1_a",2,null,1154,1,155,2,36128,9372,0,79,0], - ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",1484,1,156,2,52788,20167,0,62,0], - ["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,140,4,59650,2964,0,77,1], - ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",0,0,154,6,91210,11325,0,66,1], - ["p1",7,"op","chess_char_6_diy1_a",2,null,0,0,86,0,3596,3133,0,4,0], - ["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",0,0,83,0,19042,8583,0,5,1], - ["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,4,0,0,2326,11819,20,1], - ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",0,0,10,0,0,4670,18930,25,1], - ["p1",11,"op","chess_char_6_diy1_a",2,null,0,0,108,0,5752,2320,0,16,1], - ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",0,0,8,1,9738,1450,0,16,1], + ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,113,0,3501,5208,0,65,1], + ["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",0,0,115,0,12158,6742,0,62,1], + ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,132,1,14258,4587,0,15,1], + ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",1678.71,1,139,3,202773,12739,0,64,5], + ["p1",5,"op","chess_char_5_diy1_a",2,null,2638,1,15,2,84279,24799,0,157,4], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,112,3,66023,6822,0,47,2], + ["p1",7,"op","chess_char_5_diy1_a",2,null,1133,1,137,0,8546,1133,0,61,2], + ["p1",8,"op","chess_char_5_diy2_b",2,"none",736.41,1,10,0,29918,624,0,190,4], + ["p1",9,"op","chess_char_5_diy1_a",2,null,1218,1,12,0,0,779,21853,53,4], + ["p1",10,"op","chess_char_5_diy2_b",2,"none",1374,1,14,0,0,0,12430,38,4], + ["p1",11,"op","chess_char_5_diy1_a",2,null,932,1,140,0,5333,1864,0,46,1], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,109,0,9326,2262,0,67,2], ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null] ], - "enemies": {"enemy_1019_jshoot_2":[57,10,47,52058,82202],"enemy_1299_ymkilr_2":[27,0,27,9819,9240],"enemy_1422_lrsldr_2":[39,5,34,9213,188819]}, - "hooks": [1,156,6460,632,632,849,850,92,43,151,7,20,0,9133,611,30,123,108,1,43,0,0,0], - "events": {"atk":705,"deploy":30,"die":43,"dmg":822,"fx":36,"heal":76,"leak":108,"skill":42,"spawn":138,"status":149}, - "fx": {"aoe":2,"displace":17,"dodge":2,"spGain":15}, - "snaps": "17351197 d85b764f 170d4969 276e2353 d5273c76 84c8775e 178ead82 e788140a 7761c24b 8d4c0668 9647f92d 7caa4901 24e1ffd3 00675a6a 82e3afd4 846d95f4 87dfd927 4f075077 94a7b0a4 a3a639d3 4179fb65 3ad0ed6c" + "enemies": {"enemy_1166_dusbr_2":[45,7,38,10640,134702],"enemy_1169_duphlx_2":[51,2,49,15366,108013],"enemy_1172_dugago":[18,0,17,41553,193401]}, + "hooks": [1,140,6900,1262,1262,1647,1623,91,25,129,31,28,0,31544,112,44,114,104,1,36,0,0,24], + "events": {"atk":1262,"deploy":23,"die":25,"dmg":1623,"fx":143,"heal":91,"leak":104,"skill":59,"spawn":129,"status":309}, + "fx": {"aoe":49,"dodge":24,"dp":10,"revive":6,"slash":48,"stone":6}, + "snaps": "4d1209f1 b3260553 22b87cb1 d677635e 68796d83 35ba34de 7424abb5 28710132 108c366c d224966c 9ee44ea0 842b17de 4cc18478 51b76f35 1d697235 995aaa08 73e7adc4 cbd87ae1 36bd1637 844fadc5 eefe7557 7c1300ca 97df0da6" + }, + "diy-110": { + "about": "5_diy1_a=sharp2 5_diy2_b=sharp2 5_diy1_a=acspec 5_diy2_b=acspec 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc 5_diy1_a=acsupo 5_diy2_b=acsupo", + "fast": false, + "kind": "normal", + "stageId": "act2autochess_m04", + "modeId": "mode_multi_hard", + "round": 12, + "seed": 2140781790, + "waveId": "act1autochess_h05", + "time": 228.73, + "ticks": 6862, + "reason": "cleared", + "killed": 8, + "total": 102, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 1091, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":8,"total":102,"perfect":false,"leaks":94,"leaksUncounted":0,"coins":0,"dmg":689939,"bossDmg":0,"heal":12640,"deaths":23,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",0,null,0,0,132,4,31136,10089,0,33,1], + ["p1",2,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",0,0,135,2,108437,9449,0,43,1], + ["p1",3,"op","chess_char_5_diy1_a",2,null,1154,1,137,0,17956,10806,0,26,0], + ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",1448,1,136,0,32276,16982,0,41,0], + ["p1",5,"op","chess_char_5_diy1_a",2,null,1272,1,3,1,60873,0,0,197,2], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",1587,1,4,0,107377,0,0,200,3], + ["p1",7,"op","chess_char_5_diy1_a",2,null,1372,1,11,1,230227,0,0,84,2], + ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",1749,1,13,0,53993,0,0,55,2], + ["p1",9,"op","chess_char_5_diy1_a",2,null,1224,1,5,0,0,0,12640,25,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,20727,0,0,141,2], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",1430,1,14,0,26937,0,0,132,2], + ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], + ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null] + ], + "enemies": {"enemy_1166_dusbr_2":[45,8,37,7484,315810],"enemy_1170_dushld_2":[27,0,27,17702,149257],"enemy_9011_acrefr":[30,0,30,22141,260852]}, + "hooks": [1,137,6862,1034,1034,3454,3530,34,31,125,17,33,0,32054,9596,32,102,94,1,31,0,0,0], + "events": {"atk":1396,"deploy":33,"die":31,"dmg":3451,"fx":45,"heal":32,"leak":94,"skill":61,"spawn":116,"status":1142}, + "fx": {"aoe":10,"displace":12,"spGain":23}, + "snaps": "420918e6 2708d6ed 4f263102 03f8b055 7286f585 54a28451 a1d0d172 05faa2a9 b91da4d8 4f2742b8 65e41df2 60c24b73 b816e943 9d92e0d4 6b182fff e223813c 19b158a6 06277803 ed8ef216 d2c84879 eaf0cfa9 de2dd5ef feed0ad3" + }, + "diy-111": { + "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_m01", + "modeId": "mode_single_normal", + "round": 13, + "seed": 452407399, + "waveId": "act1autochess_h06", + "time": 230, + "ticks": 6900, + "reason": "timeout", + "killed": 87, + "total": 150, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 833, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":87,"total":150,"perfect":false,"leaks":63,"leaksUncounted":0,"coins":0,"dmg":1246786,"bossDmg":0,"heal":17719,"deaths":10,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",2,null,575.85,1,170,0,9397,7422,0,68,2], + ["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",2613.1,1,172,1,14396,4249,0,106,1], + ["p1",3,"op","chess_char_6_diy1_a",2,null,0,0,163,1,15801,4587,0,27,1], + ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",0,0,159,8,87954,5712,0,32,3], + ["p1",5,"op","chess_char_6_diy1_a",2,null,2638,1,173,1,9258,5276,0,33,1], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",3531,1,8,9,75994,14929,0,97,4], + ["p1",7,"op","chess_char_6_diy1_a",2,null,1272,1,4,3,35846,0,0,95,2], + ["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",1643,1,5,25,130351,0,0,194,3], + ["p1",9,"op","chess_char_6_diy1_a",2,null,1372,1,11,27,429294,0,0,78,2], + ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",1819,1,13,12,438495,0,0,83,2], + ["p1",11,"op","chess_char_6_diy1_a",2,null,1224,1,6,0,0,0,17719,41,2], + ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",1464,1,14,0,0,0,0,0,0], + ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], + ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], + ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null] + ], + "enemies": {"enemy_1249_lysdb_2":[60,33,25,7724,541497],"enemy_1251_lysyta":[51,35,16,905,222652],"enemy_1329_cbshld":[39,19,20,33546,482637]}, + "hooks": [1,173,6900,1185,1185,2901,2822,58,97,158,23,21,0,35181,0,88,150,61,1,101,0,0,19], + "events": {"atk":1444,"deploy":20,"die":97,"dmg":2822,"fx":198,"heal":58,"leak":61,"skill":44,"spawn":165,"status":8}, + "fx": {"aoe":74,"dodge":19,"dp":13,"shieldBreak":60,"slash":32}, + "snaps": "53a47bd2 4ea5e2f1 3fa6829f 372f41a8 4aa89456 4d7d2038 cd765f90 6163c3bf 249aaaf9 84674ef9 87186149 17a34f78 1cca8123 425cda0c e1cdcd06 83342211 c98e81a7 0404eb5a 38a6e8e8 beadf96d fc2760c7 9f7c829a 8af633fa" + }, + "diy-112": { + "about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acsupo 6_diy2_b=acsupo", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m02", + "modeId": "mode_multi_abyss", + "round": 13, + "seed": 3106580722, + "waveId": "act1autochess_h06", + "time": 214.33, + "ticks": 6430, + "reason": "cleared", + "killed": 1, + "total": 105, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 55, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":1,"total":105,"perfect":false,"leaks":104,"leaksUncounted":0,"coins":0,"dmg":85268,"bossDmg":0,"heal":0,"deaths":24,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,81,0,22653,3730,0,12,1], + ["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,1,1,34166,2494,0,15,1], + ["p1",3,"op","chess_char_6_diy1_a",2,null,1154,1,131,0,8673,9232,0,26,0], + ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",670.54,1,130,0,13916,12685,0,13,0], + ["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,128,0,2722,3480,0,8,0], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",0,0,67,0,3138,2900,0,8,1] + ], + "enemies": {"enemy_1158_divman":[51,1,50,6114,52552],"enemy_1160_hvyslr_2":[24,0,24,5837,18182],"enemy_1161_tidmag_2":[30,0,30,22570,14534]}, + "hooks": [1,131,6430,187,187,237,301,0,25,129,3,19,0,2487,56,15,105,104,1,25,0,4,1], + "events": {"atk":214,"deploy":26,"die":25,"dmg":287,"fx":16,"leak":104,"skill":39,"spawn":111,"status":94}, + "fx": {"burst":4,"displace":10,"dodge":2}, + "snaps": "0fdeb99b 2fc7ded4 445ab87f f27ef11c f56a9f26 af462f80 69da57fa afc2e0b4 cc31fcba faeb24a9 d364d537 b1a96f93 883e2bdd dab2271d ddcc8706 c6c8b95e 6073e7d8 f0b5b1b2 b030b896 39689147 6ed93392 e908d87f" } } }