feedback5: merge WE1 — fidelity (sim rules): 秘术师 store at the attack check (深靛; 维伊 / 黑键 plug into it), enemy attacks land at their damage frame so a short stun / freeze / sleep / 浮空 / 缴械 / 恐惧 before it cancels the swing, 武者 heal per damage instance through 禁疗, 庇护 the strongest only, PHY-X 不高于 50 %, 链愈师 jumps in the 3×3, a summon readied at its owner's redeploy, Misery's duration skills, 伺夜 S2 in 战术点形态, centurions hit up to block-count targets; merge fixes: 黑键's kit keeps the up() import WE2's S2 cast needs (WE1 dropped it with its tick store — the first regeneration had stored 9288 swallowed errors in diy-114), the 自选 refusal names 集成战略 / 生息演算, tools/golden.mjs prints a missing value, test/golden.test.js fails on any stored engine error; golden: vs WE1 only the W4B / WE2 scenarios differ (roster-042, bond-egirShip-high, coop2-ABYSS-10-boosted, coop2-NORMAL-70-diy, diy-045 / 078 / 114 / 115 / 116)

This commit is contained in:
sganggs committed 2026-10-06 16:52:11 +08:00
commit e2bb090f44
60 files changed
+6888 -6103

No files matched your search

+1 -1
View File
@@ -219,7 +219,7 @@ All paths are URL paths relative to the site root, for example `/assets/char/ava
The enemies' attack clip lengths are also a data input: `tools/build-data.mjs` copies each enemy model's
`anims.attack.loop` length and first `hits` time into data/enemies.json `attackAnim` (the sim stands an unblocked
ranged enemy for that clip, GitHub #58; docs/DATA.md §9) — rebuild the data after a manifest change that touches them.
ranged enemy for that clip, GitHub #58, and strikes every enemy attack at that frame, 0.2.0; docs/DATA.md §9) — rebuild the data after a manifest change that touches them.
### The `Roles` object
+1 -1
View File
@@ -368,7 +368,7 @@ blackboards (transitively). Level = `randomEnemyAttributeDict[key].level` (0 for
| `hitArea` | `{"w":4.95,"h":2.95,"dx":0,"dy":1}` (`enemy_9013_acstmk`) | **huge units only** (巨型单位, 7 keys: 假想敌:胄 ×2, 假想敌:管 ×2 — the 隐秘核心 one `dx` 1 —, 盐风主教昆图斯, 阿利斯泰尔,帝国余晖, “萨米的意志”): the hit rectangle, `w` tiles along the columns × `h` along the rows, centred on the unit's position moved `dx` columns right / `dy` rows up (sim/body.js; user playtest #5). Not in the game tables (the collider lives in the prefab): `tools/build-data.mjs HIT_AREAS` by `prefabKey`, from PRTS "巨型单位:受击判定区域为长4.95、宽2.95的长方形,向上偏移1.0" and PRTS盟约记录 (the season's 阿利斯泰尔 / “萨米的意志” versions); absent = a point. All 7 are also 自缚 + 无法被阻挡 (PRTS 天赋 — not a data field either: content/bosses.js `SELF_BOUND`) |
| `staticBody` | `true` (`enemy_1005_yokai` 妖怪) | **静态刚体 only** (28 keys: every air unit of the mode except “炎佑” — 妖怪 ×3, 御4, 暴鸰, 法术大师 ×2, 寒霜, 帝国炮火先兆者 ×2, 枯朽之种, 枯朽萃聚使徒, 护障 ×2, 远眺, 愧悔魂灵圣杯, 假想敌:黑云, “斩胄之剑”, “破胄之锤”, 刺胄之弹, 未装配刀片, 防护背心, 冲击式施术单元, 节日气球, “萨科塔之翼 / 之眼 / 昂首” — plus the ground boss 盐风主教昆图斯): pushes and pulls never move it, the skills still hit it (sim `Battle._displaceable`; player report after 0.1.0). PRTS 特殊机制 静态刚体: "该单位的Unity刚体的刚体类型为部分静态(Kinematic)或静态(Static)…无法产生任何速度或移动…※是否为静态刚体与单位的行动方式无关". Not in the game tables (the rigidbody lives in the prefab): `tools/build-data.mjs STATIC_BODIES` by key, from the 天赋 "{{特殊机制|静态刚体}}" of each enemy's PRTS page (every enemy of the file, read 2026-10-03); absent = a dynamic body |
| `modelScale` | `0.5926` (`enemy_1005_yokai_3` 威龙) | official drawn size of the enemy's Spine model relative to the standard (user playtest #6): the battle prefab's transform scale down to its Spine renderer (Graphic × FaceSwitcher × Spine; SkeletonDataAsset.scale is 0.01 for every enemy skeleton) ÷ 0.27, the standard of 1454 of the client's 2147 enemy prefabs (2080 have exactly one Spine renderer) and of the operators' battle skins. The renderer multiplies `UNIT.modelScale` by it (render/units.js `enemyModelScale`). 125 keys (122 prefabs) carry one, from 0.5926 (威龙 0.16; 妖怪 0.7407, 寒霜 0.6667) to 2.2222 (青铜镜 / 青瓷茶器 0.6); absent = 1. Not in the game tables: `tools/build-data.mjs MODEL_SCALES` by `prefabKey`, read from the local client by `tools/local-extract/enemy_scales.py` |
| `attackAnim` | `{"clip":"Attack","dur":1,"hit":0.533}` (`enemy_1019_jshoot` 隐形弩手) | the enemy's attack clip — the one the client plays for its attacks (data/assets.json `anims.attack.loop` of its model, not an Idle stand-in) —, its length (s) and first strike frame (`hits`, the OnAttack event; absent when the clip has none). An unblocked ranged enemy stands for this clip at each attack (sim/ai.js `attackStand`, GitHub #58). 213 keys; absent = no attack clip known (御4, 寒霜, the 岁 relics …: the sim keeps `ATTACK_PAUSE`). Not in the game tables: `tools/build-data.mjs enemyAttackAnim` reads the committed asset manifest (tools/fetch-assets.mjs, from the Spine skeletons) |
| `attackAnim` | `{"clip":"Attack","dur":1,"hit":0.533}` (`enemy_1019_jshoot` 隐形弩手) | the enemy's attack clip — the one the client plays for its attacks (data/assets.json `anims.attack.loop` of its model, not an Idle stand-in) —, its length (s) and first strike frame (`hits`, the OnAttack event; absent when the clip has none). An unblocked ranged enemy stands for this clip at each attack (sim/ai.js `attackStand`, GitHub #58), and every enemy attack strikes at `hit` after its swing starts — a stun before it cuts the swing (sim/ai.js `attackWindup`, 0.2.0). 213 keys; absent = no attack clip known (御4, 寒霜, the 岁 relics …: the sim keeps `ATTACK_PAUSE`). Not in the game tables: `tools/build-data.mjs enemyAttackAnim` reads the committed asset manifest (tools/fetch-assets.mjs, from the Spine skeletons) |
| `attackMoves` | `true` | **「不停止移动」 attackers only**: the handbook ability text says it attacks on the move (“十字路口”量产型's 四向攻击) — it never stops to attack (sim/ai.js `attackStand`). No enemy of the mode has it (build-data `attacksOnTheMove`) |
## 10. `factions.json` — special enemies (特训敌人)
+3 -3
View File
@@ -293,9 +293,9 @@ 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 × 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).
- **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; 「同时攻击阻挡的所有敌人」 — 强攻手 / 重剑手 / 推击手 and the skills worded so, `hitAllBlocked` — up to the block count, never fewer than 1, since 0.2.0: PRTS 分支特性信息 "普通攻击最大目标数等于阻挡数(不会低于1)"), 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).
- **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). Every enemy attack strikes at its clip's damage frame after its swing starts — the first one of a contact too —, and a stun / freeze / sleep / 浮空, hiding, 缴械, 恐惧 or 战栗 (blocked) before that frame cuts the swing: no strike, the next swing starts from its wind-up (0.2.0, `ai.js attackWindup`; PRTS 状态机 / 异常效果 STUNNED; no clip known: no wind-up). 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).
- **Damage**: phys `max(A − max(0, D×(1−defIgnorePct) − defIgnoreFlat), 0.05·A)`; arts `max(A × (1 − R'/100), 0.05·A)` with `R' = max(0, R×(1−resIgnorePct) − resIgnoreFlat)`; true = A; 元素伤害 ('elemental') `max(A × (1 − 元素抗性/100), 0.05·A)`. Then × `dmgDealtMul` (source) × `dmgTakenMul` & type-specific taken multipliers (target) × `dmg.mul` — 元素伤害 takes 元素脆弱 (`elementalTakenMul`) alone, not `dmgTakenMul` (脆弱 = "受到的物理、法术、真实伤害提升"). Then **限伤**: on a leader (`isBoss` — the 10 bossInfoDict enemyIds and their mirrored copies; never parts, drones or escorts) in a `'boss'` / `'hidden'` battle a hit with `ceil(final) ≥ 300000` is cancelled — no HP or pool loss, no credit, no damage number, no `damaged` / kill (`shared/constants.js BOSS_HIT_LIMIT`, `damage.js leaderHitCancelled`, after every multiplier and the hit-step blocks, before HP shields; `loseHp` too — a part's 传递, a drone's death; the client's `MAX_BATTLE_DAMAGE`, §20.12). Then shields absorb; dodge checks for phys/arts. Element damage fills a gauge (1000; enemy leaders 2000) × `elemTakenMul` × max(0.05, 1 − 损伤抵抗/100), 损伤抵抗 = data `epResistance` (PRTS 元素; the 5 % floor of DMG_e, PRTS 游戏数据基础). An element fill on a target with no HP left is refused (damage.js `hasHp`: no burst on a corpse). A full gauge bursts with the side-specific official effects, as **无来源** damage (no damage-dealt multiplier or penetration of the unit that filled the gauge, which keeps the kill and the stats; §19.5) (sim/constants.js `ELEMENT`; operators: 灼燃 1200 arts + RES −20, 神经 stun 10 s then 1000 true, 凋亡 15 s of 阻回 (`noSp`: no SP gain of any kind) + 静默 with −1 SP/s and 100 arts/s, 侵蚀 DEF −100 then 800 phys; enemies: the "·我方" terms). Every burst starts a **爆发冷却** = its duration (灼燃 / 神经 / 侵蚀 10 s — enemies' 侵蚀 8 s — 凋亡 15 s) during which NO element of the unit fills or can be recovered; when it ends every gauge of the unit resets (§18.3).
- **Leak**: an enemy reaching its goal is removed, counted as leaked for its field owner (`sourcePlayerId` for unite). LP accounting is done by the match, not the battle.
- **Results**: `damageDealt` / a unit's `dmg` count only HP removed from the other side — a unit's own drain or 流失 and friendly damage are the target's `taken` only (§21.12).
@@ -312,7 +312,7 @@ Duration end cleans the window and emits `skillEnd`; it leaves zero charges and
Redeployment opens a new full window. Permanent passives retain `passive`; first-kill recasts of 缄默德克萨斯 explicitly
restart a full window. Special timed kits and their effect rules are described in SIM §7.2.
Auto-trigger rules (`skillTrigger.rule` in data — the official 技能策略, PRTS 卫戍协议/帮助 §作战阶段 技能操作; §20.2): `DEFAULT` (SP ready AND about to attack/heal AND an enemy/injured ally inside the **initial** range, or an enemy the unit blocks; with `trigger.allies` + `hpAtMost` an ally of the skill grid at or below that HP ratio — 塞雷娅 S1 急救, whose cast replaces that attack and is withdrawn with its charge if the ally recovers first), `SKILL_RANGE` (a MANUAL skill with a 技能范围 of its own, not an attack-range change: any living enemy, stealthed ones included, on the skill range, checked every tick — "仅在技能范围内存在敌人(无视其不可选中)时释放技能"; 13 skills / 26 records by the official rule, 14 / 28 in the data with 余 S2's deviation, §22.10), `ACTIVE_RANGE` (the owner's deliberate deviation of 2026-10-05, 0.2.0: a MANUAL skill on the basic strategy — or, by the owner's further decisions of the same day, on the `SEARCH` row, and 深巡 S2 on top of its §21.29 DEFAULT — whose attack range while it runs strictly contains the operator's own range — its rangeId grid, or the own range grown by `ability_range_forward_extend` — checks the DEFAULT condition on that running range every tick, no attack needed; `customRangeGrid` = the running range, `rawRule` the official row (DEFAULT, SEARCH, 深巡's TAKE_DAMAGE); 39 skills / 78 records, of them 薄绿 S1, 蜜蜡 S1, 卡涅利安 S3, 玛恩纳 S2 and 安洁莉娜 S3 from SEARCH; 烛煌 S2 / S3, whose ranges do not contain her 3-1, keep DEFAULT), `TAKE_DAMAGE` (on taking damage — any damage, 无来源 included, never a 流失; every MANUAL 重装 skill, §21.12 — but the six of the deliberate deviation, §21.29: 深巡 / 雷蛇 S2, 号角 S2 / S3 and 灰毫 S1 / S2 are `DEFAULT`, `rawRule` keeping the official TAKE_DAMAGE — 深巡 S2 then `ACTIVE_RANGE` on its 3-2; and 余 S2, `SKILL_RANGE` on its x-1 (§22.10); the 重装 of data/backups.json — the 补位 stand-ins 预备干员-重装 / Mechanist and every 自选 重装 — cast with an enemy in range, `DEFAULT` (or `ACTIVE_RANGE` when the skill's running range is larger), by the owner's decisions of 2026-10-05), `SP_FULL` (immediately — 执旗手 / 战术家 / 吟游者 MANUAL skills, AUTO DP skills such as 焰尾 S1, 凛御银灰 S1 (伺夜 S1 / S2 too, but only while his pack is on the field — PRTS 备注 「仅场上存在狼群时可触发技能」, the kit's own cast; the owner's decision of 2026-10-05 for S1; S1 also while the pack sits in its 战术点形态, which the cast ends — S2 waits for it to come back [ASSUMED]; 黑键 S2 荒芜回响 likewise only while one placeable tile of his range is free, no enemy needed — PRTS 备注 「攻击范围内不存在可部署位时,技能不会被触发」, its kit's cast, 0.2.0), and, by their kits, AUTO skills whose effects act on allies only: 引星棘刺 S1, 浊心斯卡蒂 S2, 纯烬艾雅法拉 S1 — the owner's decision of 2026-10-05 for the last), `CUSTOM_RANGE` (enemy inside the custom grid), `SEARCH` (an enemy inside the initial range, checked every tick — no attack needed; 解放者 / 阵法术师 MANUAL skills and 安洁莉娜 S2, those without a larger running range), `GDGLOW_SKILL_2` (a valid target anywhere on the field: 荒芜拉普兰德, 纯烬艾雅法拉 S3), special ids. The class rows cover **every MANUAL skill** of the class and never an AUTO skill (AUTO skills keep their own rule — an inference: PRTS names whole classes; the data's `skillIndex 0` / `-1` sentinel argues the other way, §20.2). **Operation cooldown**: the engine auto-casts a MANUAL skill no sooner than `AUTO_OP_COOLDOWN` (3 s) after its previous cast and after the unit's battle-start deployment ("自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位将进入冷却"; `Battle._deploy` initial, `battle.flags.startOpCooldown`); kits with an automatic cast of their own check `skill.opCooling`; a pending "next attack" never takes a second charge. Permanent passive skills are always on; finite deployment effects use the duration lifecycle above.
Auto-trigger rules (`skillTrigger.rule` in data — the official 技能策略, PRTS 卫戍协议/帮助 §作战阶段 技能操作; §20.2): `DEFAULT` (SP ready AND about to attack/heal AND an enemy/injured ally inside the **initial** range, or an enemy the unit blocks; with `trigger.allies` + `hpAtMost` an ally of the skill grid at or below that HP ratio — 塞雷娅 S1 急救, whose cast replaces that attack and is withdrawn with its charge if the ally recovers first), `SKILL_RANGE` (a MANUAL skill with a 技能范围 of its own, not an attack-range change: any living enemy, stealthed ones included, on the skill range, checked every tick — "仅在技能范围内存在敌人(无视其不可选中)时释放技能"; 13 skills / 26 records by the official rule, 14 / 28 in the data with 余 S2's deviation, §22.10), `ACTIVE_RANGE` (the owner's deliberate deviation of 2026-10-05, 0.2.0: a MANUAL skill on the basic strategy — or, by the owner's further decisions of the same day, on the `SEARCH` row, and 深巡 S2 on top of its §21.29 DEFAULT — whose attack range while it runs strictly contains the operator's own range — its rangeId grid, or the own range grown by `ability_range_forward_extend` — checks the DEFAULT condition on that running range every tick, no attack needed; `customRangeGrid` = the running range, `rawRule` the official row (DEFAULT, SEARCH, 深巡's TAKE_DAMAGE); 39 skills / 78 records, of them 薄绿 S1, 蜜蜡 S1, 卡涅利安 S3, 玛恩纳 S2 and 安洁莉娜 S3 from SEARCH; 烛煌 S2 / S3, whose ranges do not contain her 3-1, keep DEFAULT), `TAKE_DAMAGE` (on taking damage — any damage, 无来源 included, never a 流失; every MANUAL 重装 skill, §21.12 — but the six of the deliberate deviation, §21.29: 深巡 / 雷蛇 S2, 号角 S2 / S3 and 灰毫 S1 / S2 are `DEFAULT`, `rawRule` keeping the official TAKE_DAMAGE — 深巡 S2 then `ACTIVE_RANGE` on its 3-2; and 余 S2, `SKILL_RANGE` on its x-1 (§22.10); the 重装 of data/backups.json — the 补位 stand-ins 预备干员-重装 / Mechanist and every 自选 重装 — cast with an enemy in range, `DEFAULT` (or `ACTIVE_RANGE` when the skill's running range is larger), by the owner's decisions of 2026-10-05), `SP_FULL` (immediately — 执旗手 / 战术家 / 吟游者 MANUAL skills, AUTO DP skills such as 焰尾 S1, 凛御银灰 S1 (伺夜 S1 / S2 too, but only while his pack is on the field — PRTS 备注 「仅场上存在狼群时可触发技能」, the kit's own cast; the owner's decision of 2026-10-05 for S1; S1 also while the pack sits in its 战术点形态, which the cast ends — S2 too since 0.2.0: its DP at once, its gift kept for the returning pack [ASSUMED: S2's 备注 uses S1's words]; 黑键 S2 荒芜回响 likewise only while one placeable tile of his range is free, no enemy needed — PRTS 备注 「攻击范围内不存在可部署位时,技能不会被触发」, its kit's cast, 0.2.0), and, by their kits, AUTO skills whose effects act on allies only: 引星棘刺 S1, 浊心斯卡蒂 S2, 纯烬艾雅法拉 S1 — the owner's decision of 2026-10-05 for the last), `CUSTOM_RANGE` (enemy inside the custom grid), `SEARCH` (an enemy inside the initial range, checked every tick — no attack needed; 解放者 / 阵法术师 MANUAL skills and 安洁莉娜 S2, those without a larger running range), `GDGLOW_SKILL_2` (a valid target anywhere on the field: 荒芜拉普兰德, 纯烬艾雅法拉 S3), special ids. The class rows cover **every MANUAL skill** of the class and never an AUTO skill (AUTO skills keep their own rule — an inference: PRTS names whole classes; the data's `skillIndex 0` / `-1` sentinel argues the other way, §20.2). **Operation cooldown**: the engine auto-casts a MANUAL skill no sooner than `AUTO_OP_COOLDOWN` (3 s) after its previous cast and after the unit's battle-start deployment ("自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位将进入冷却"; `Battle._deploy` initial, `battle.flags.startOpCooldown`); kits with an automatic cast of their own check `skill.opCooling`; a pending "next attack" never takes a second charge. Permanent passive skills are always on; finite deployment effects use the duration lifecycle above.
**SkillSpec** (authored per chess in `sim/content/kits/tierN.js`; values come from the chess's skill blackboard at its level — normal Lv4, elite Lv7 — so a spec is a *function of the blackboard*):
```js
+1 -1
View File
@@ -76,7 +76,7 @@
- 作战中:海嗣、狼群、流形、支援装置开战时就在摆放的位置部署;医疗探机、诅咒娃娃开战时也在摆放的位置免费部署一次(持续 10 / 15 秒,和官方一样不需要先开技能),之后所属干员每次发动技能时再次在原位出现;所属干员被击倒、再部署回场时,技能给的那一个如果还没出现,会立刻出现。巫恋退场时诅咒娃娃随之撤退,赫默退场时已出现的医疗探机不受影响。医疗探机、诅咒娃娃、支援装置、海嗣没有摆放就不会出现;狼群、流形(战术家的援军)没有摆放时会在战术点出现。狼群最后一只狼影被击倒后进入战术点形态:暂时离开战斗(不阻挡、不攻击、不会被攻击,格子仍然占着),经过狼影的恢复时间(25 秒)后在原地满血、带着 1 只狼影回来,之后重新开始每 25 秒加 1 只狼影;伺夜退场时狼群随之撤退,伺夜再部署回场时狼群重新带着 2 只狼影出现。
- **阻挡**看接触:干员阻挡中心距离约 0.7 格以内、与它接触的敌人(地面干员挡不住飞行和近地悬浮的敌人)。敌人卡在两名干员之间时,阻挡它的干员倒下、撤退或被晕眩后,另一名仍与它接触、还有空余阻挡数的干员会**立刻接替阻挡**;都没有空位时敌人才会走掉。迎面走来的敌人会停在干员格子前面。**阻挡了就一定能打到**:任何干员(包括站在近战位的远程干员和召唤物)总能攻击自己阻挡的敌人,不管它在不在攻击范围内、在干员哪一侧,并且优先攻击它(医疗干员除外:咒愈师以外的医疗挡住敌人时照常治疗队友,不会攻击它;「停止攻击」类技能期间也除外;技能原文另有限制的也按原文:普罗旺斯二技能开启时不以生命值高于 80% 的敌人为目标,寒檀二技能的冰凌随机落在攻击范围内的地块上)。
- 开战时干员按**逐列从左到右、每列从上到下**的顺序部署(最左一列最先,与阿戈尔吞噬「更靠左和靠上」的顺序相同),召唤物在所有干员之后。被阻挡的敌人先打阻挡它的干员;没被阻挡的远程敌人先打嘲讽等级高的,同级时打最后部署的(召唤物、最右一列靠下的干员和刚再部署的干员更容易挨打)。隐匿的干员只会被它阻挡的敌人攻击,没被它挡住的敌人的溅射、爆炸、范围技能、留下的伤害区域(包括假想敌:蚀裂被击倒后喷出的毒雾)以及深池伙友卫队、扎罗的减攻速光环也落不到它身上(和官方一样;写明「无视无法选择」的效果如污染秽蚀照常能打到,寒霜的减攻速光环和“墓碑”这类地图效果照常生效);**迷彩**(如忍冬三技能期间击倒敌人后、寒芒克洛丝一技能)同样只会被它阻挡的敌人攻击,也显示为半透明,但躲不开溅射、爆炸和范围伤害(同时打范围内所有单位的普通攻击——如“斩胄之剑”“破胄之锤”“灵幛”——打不到它),也不算隐匿(不触发叙拉古等「隐匿时」效果)。叙拉古的隐匿按时(32 秒 + 每层 0.4 秒)结束;忍冬的迷彩按官方持续到她下一次开技能。**蒂比起飞**期间只挡飞行敌人,地面敌人不会选她为目标(普通攻击、溅射、技能范围、燃烧区域和要选中目标的减益都落不到她身上);飞行和悬浮敌人、写明「无视无法选择」的效果(如污染秽蚀)照常能打到她;地面敌人的光环和全场效果(如“墓碑”让全场攻击力、防御力减半)、她攻击敌人时招来的反制(如重装侦察兵的暴露)照常生效,起飞前已挂上的持续伤害也照常跳。
- **技能自动释放**按官方「技能策略」:一般的手动技能在技能就绪、即将攻击 / 治疗且初始攻击范围内有敌人(治疗技能:有受伤的队友)时释放;**带独立技能范围**的技能(如德克萨斯「剑雨」、凛御银灰「御敌的锋锐」、锏「归于宁静」,不含「攻击范围扩大」类)在**技能范围内出现敌人**时就释放,不必先攻击;**开启后攻击范围比平时大**的手动技能(如莫斯提马「序时之匙」、银灰「真银斩」、史尔特尔「黄昏」、白面鸮「脑啡肽」)在**开启后的攻击范围内出现敌人**(治疗技能:有受伤的队友)时就释放,不必等敌人走进平时的攻击范围,这是本作按玩家反馈有意与官方不同的地方;**重装干员**的手动技能在受到伤害时释放(例外:深巡、雷蛇的二技能,号角的二、三技能,灰毫的一、二技能按玩家反馈改为攻击范围内有敌人时就释放(深巡二技能按开启后更远的攻击范围判断),余的二技能「厚礼上宾」改为周围两格(技能范围)内有敌人时就释放,这是本作有意与官方不同的地方;深巡一技能仍要受到伤害);执旗手 / 战术家 / 吟游者技能就绪即释放,解放者 / 阵法术师在初始攻击范围内有敌人时释放(开启后攻击范围扩大的技能——薄绿「风语」、蜜蜡「沙暴扩散」、卡涅利安「食噬之印」、玛恩纳「未宽解的悲哀」,以及安洁莉娜「秘杖·反重力模式」——同样按开启后的攻击范围判断)。**自动触发**的技能按其本身规则:只作用于队友的(如引星棘刺「度算浪波」、浊心斯卡蒂「同葬无光之愿」、纯烬艾雅法拉「无声润物」)技力满就释放,不用等敌人或伤员;伺夜「领袖的呼唤」「领袖的馈赠」只在场上有狼群时释放:狼群处于战术点形态时,「领袖的呼唤」也会释放并让它立刻带着 1 只狼影回到场上(狼影未满时改为加 1 只狼影,已满时把狼群的生命值回满),「领袖的馈赠」则要等狼群回来;又如古米「备用军粮」在身边(技能范围内)有受伤的队友时就会发动,下一次攻击改为治疗该队友(不用先接敌;她的二技能「食粮烹制」是手动技能,按重装规则在受到伤害时释放);塞雷娅「急救」在她即将攻击时、身边有生命不高于一半的队友才发动,这次攻击直接改为治疗;新约能天使二技能的护盾按官方给攻击范围内**仇恨最高**的队友(嘲讽等级高的优先,同级时给最后部署的),不一定是站在最前面挨打的那个。手动技能的自动释放有 3 秒冷却:开战部署后先等 3 秒,之后同一干员两次自动释放至少间隔 3 秒(可充能的技能不会同时放完)。
- **技能自动释放**按官方「技能策略」:一般的手动技能在技能就绪、即将攻击 / 治疗且初始攻击范围内有敌人(治疗技能:有受伤的队友)时释放;**带独立技能范围**的技能(如德克萨斯「剑雨」、凛御银灰「御敌的锋锐」、锏「归于宁静」,不含「攻击范围扩大」类)在**技能范围内出现敌人**时就释放,不必先攻击;**开启后攻击范围比平时大**的手动技能(如莫斯提马「序时之匙」、银灰「真银斩」、史尔特尔「黄昏」、白面鸮「脑啡肽」)在**开启后的攻击范围内出现敌人**(治疗技能:有受伤的队友)时就释放,不必等敌人走进平时的攻击范围,这是本作按玩家反馈有意与官方不同的地方;**重装干员**的手动技能在受到伤害时释放(例外:深巡、雷蛇的二技能,号角的二、三技能,灰毫的一、二技能按玩家反馈改为攻击范围内有敌人时就释放(深巡二技能按开启后更远的攻击范围判断),余的二技能「厚礼上宾」改为周围两格(技能范围)内有敌人时就释放,这是本作有意与官方不同的地方;深巡一技能仍要受到伤害);执旗手 / 战术家 / 吟游者技能就绪即释放,解放者 / 阵法术师在初始攻击范围内有敌人时释放(开启后攻击范围扩大的技能——薄绿「风语」、蜜蜡「沙暴扩散」、卡涅利安「食噬之印」、玛恩纳「未宽解的悲哀」,以及安洁莉娜「秘杖·反重力模式」——同样按开启后的攻击范围判断)。**自动触发**的技能按其本身规则:只作用于队友的(如引星棘刺「度算浪波」、浊心斯卡蒂「同葬无光之愿」、纯烬艾雅法拉「无声润物」)技力满就释放,不用等敌人或伤员;伺夜「领袖的呼唤」「领袖的馈赠」只在场上有狼群时释放:狼群处于战术点形态时,「领袖的呼唤」也会释放并让它立刻带着 1 只狼影回到场上(狼影未满时改为加 1 只狼影,已满时把狼群的生命值回满),「领袖的馈赠」也会释放(立即获得费用,强化留给回来的狼群的下一次攻击);又如古米「备用军粮」在身边(技能范围内)有受伤的队友时就会发动,下一次攻击改为治疗该队友(不用先接敌;她的二技能「食粮烹制」是手动技能,按重装规则在受到伤害时释放);塞雷娅「急救」在她即将攻击时、身边有生命不高于一半的队友才发动,这次攻击直接改为治疗;新约能天使二技能的护盾按官方给攻击范围内**仇恨最高**的队友(嘲讽等级高的优先,同级时给最后部署的),不一定是站在最前面挨打的那个。手动技能的自动释放有 3 秒冷却:开战部署后先等 3 秒,之后同一干员两次自动释放至少间隔 3 秒(可充能的技能不会同时放完)。
- **推开 / 拉拽**按官方「力度 − 重量」决定距离:例如野鬃二技能(中等力度)把重量 0 / 1 / 2 / 3 的敌人推开约 2.1 / 1.7 / 0.4 / 0.1 格,重量 4 以上推不动——轻型敌人反而推得更远。野鬃二技能沿她的朝向推开:正前方 45° 以内按上面的距离,身后、侧面或贴身的敌人只被轻推一下(约 0.4 格以内);琳琅诗怀雅三技能的金币则从她身边向外推开,落在她前方 2–4 格的范围(官方「范围2-4」)内的地面敌人和她阻挡的敌人身上。攻击速度最低为 20(沼泽、寒霜等减速叠满时也不会更慢)。
- **生命回复不是治疗**(与官方一致):吟游者的特性(魔王、浊心斯卡蒂及其海嗣)、调香师的天赋、瑕光二技能、铃兰三技能、锡人炼金单元给友军的「每秒恢复生命」是提高对方的**生命回复速度**,不是治疗:收割者 / 不屈者 / 武者(如隐德来希、圣约送葬人、折桠)这类「无法被友方治疗」的干员和处于禁疗的干员也能吃到,治疗量加成(如「受到的治疗效果提升」)也不会放大它;铃兰三技能开启后的第一秒没有回复。直接治疗(医疗干员的治疗、技能的「恢复…生命」)仍然对这些干员无效。
- **元素损伤条**在单位自己的血条正下方:左边是元素图标,白条是剩余的元素值,只有真正爆发时才会耗尽;爆发后条变成元素的颜色、图标闪烁,在冷却期间慢慢回满(这段时间不会再积累元素损伤)。
+7 -7
View File
@@ -301,7 +301,7 @@ and, unblocked, stand for each attack's clip — `attackStand`: data/enemies.jso
(cooldown ≤ the strike frame, a target in range) and the rest of the clip after the strike, shortened when the attacks
come quicker than the clip; `ATTACK_PAUSE` (0.35 s) after the strike when no clip is known; an `attackMoves`
(「不停止移动」) enemy never stops; a stun ends the stand; only the walking waits — a route WAIT keeps running and
DISAPPEAR / APPEAR legs still happen, hiding ends the stand; GitHub #58 — then walk on; `fear`/`disarm` stop attacks; `dmgType 'none'` enemies
DISAPPEAR / APPEAR legs still happen, hiding ends the stand; GitHub #58 — then walk on. Every normal attack strikes at its clip's damage frame (`attackWindup`: `attackAnim.hit`, shortened like the clip; 0 with no clip known — the strike as the swing starts): the cooldown runs down to the frame and the swing (`enemy.swing`) starts when it reaches the wind-up with a target in reach — the whole wind-up from that tick when the cooldown ran out before (it walked ready, its swing was cut); a stun / freeze / sleep / 浮空, hiding, 缴械, 恐惧, 战栗 while blocked or losing every target before the frame cuts the swing — no strike, the next one starts from its wind-up (PRTS 状态机 ATTACK / COMBAT "每帧检查异常状态", 异常效果 STUNNED / DISARMED "正在进行的普通攻击将被中断"; GitHub #187 / #170, 0.2.0) —, a strike already made stays made (its shot lands); the targets are taken at the frame [ASSUMED]; `fear`/`disarm` stop attacks; `dmgType 'none'` enemies
never attack — unless content arms them through `enemy.profile` (`noAttack: false`, `melee`, `dmgType`, `maxTargets`:
转译基底·α's 寻仇者 / 特战术师 forms, which then attack like any enemy); `dmgType 'heal'` enemies heal the lowest-HP% enemy in their radius instead. A `noMove` enemy stands (not `moving`, drawn idle). Content can take over an
enemy's attack: `enemy.profile.deferHit` = the engine makes the attack (target, timing, the `'atk'` event) but deals no
@@ -576,7 +576,7 @@ application competes with instead of its `value`, which stays its effect (Raidia
catalogue status the same rule (one invisible buff `key` per target whatever applies it — no immunity, 抵抗, status hooks
or icon): the engine default for two same-named buffs (PRTS 作战机制 "同名buff的默认叠加策略buff只能表现出一个"). 奥术 uses
it, so the two players of a pair field compete for one instance instead of multiplying, and so do 灵知's 坚冰 and the
莱恩哈特 / 缄默德克萨斯 RES cuts, once keyed per unit (DESIGN §20.10). "Strongest" = the largest |value|. PRTS 盟约记录's
莱恩哈特 / 缄默德克萨斯 RES cuts, once keyed per unit (DESIGN §20.10). 庇护 (gamedata_const ba.protect "受到的物理和法术伤害降低相应比例(同名效果取最高)") is one such effect whoever grants it: every kit holds it under the shared key (`kits/shared/tier1.js` `PROTECT` / `holdProtect`, mods phys / artsTakenMul ×(1 − v)) — 宴, 余, 缪尔赛思, 赫拉格, 左乐 and 赫德雷 since 0.2.0 (they used private multipliers that multiplied with each other) —, except 遥's bubbles (the client's damage_resistance[bonus], which multiplies with the common damage_resistance[inf]). "Strongest" = the largest |value|. PRTS 盟约记录's
奥术 note "※同一单位仅可对同一目标同时施加1个该盟约法术伤害提升效果" limits each unit to one instance per target; read with
the engine default — a newer same-named buff waits inert until the earlier ones end (PRTS 常见同名状态 "默认叠加方式") — and
巴哈姆特 12316 ("共享型buff會跟對面搶"), one instance is effective at a time, which is what applyStrongest keeps; strongest
@@ -1192,7 +1192,7 @@ S3 未照耀的荣光 — its CUSTOM_RANGE trigger also counts flyers). A stun /
3.75 back = `BOOMERANG_RETURN_SPEED`, PRTS 跃跃; droneBomb 5 = 暴鸰's bomb, the official projectile_bombd); melee/`none` hits are
instant, and so are `'beam'` hits (a 锁定攻击范围 AoE without a projectile — `rangeAoe` profiles: "在攻击前摇结束时选取范围内的全体目标,同时造成伤害", PRTS 作战机制). Kit-settable profile flags beyond the
table: `hitSleep` (targets and damages sleeping enemies — "可以攻击沉睡的敌人"), `onEachHit(b, u, victim, hctx)`, `dmgMul`,
`afterHit`, `afterAttack`, `canAttack`, `hitsFn`, `priority` (targeting.js PRIORITY_FNS — `'heaviest'`: the 攻城手 trait
`afterHit`, `afterAttack`, `canAttack`, `hitsFn(b, u, info)`, `storeEnergy` / `releaseEnergy` (the 秘术师 store, below), `priority` (targeting.js PRIORITY_FNS — `'heaviest'`: the 攻城手 trait
"优先攻击重量最重的敌人", the highest current 重量等级 first: 早露 / 提丰; `'elite'`: "优先攻击精英或领袖敌人", an ELITE / BOSS
rank enemy or a leader first: 薇薇安娜 S3), `blockFly`, `noHeal`, `skipEnemy(e)` (an enemy the unit never
selects — its attacks, the enemies it blocks and its skill-trigger targets: targeting.js canTargetEnemy; 嵯峨 "不攻击重伤
@@ -1215,11 +1215,11 @@ header of professions.js).
| reaperrange | hits every enemy in range; ×1.5 (bb atk_scale) on the trait front grid (or its own line ahead) — both along its direction |
| chain | chain N (trait text/bb max_target) with −15 % per jump (bb chain.atk_scale), 1.7-tile jumps (constants.js CHAIN_RADIUS, PRTS 溅射半径一览: 链术师 1.7; 1.8 until 0.1.1), sluggish on each hit |
| funnel | drone damage 20 % → +15 %/hit on the same target → 110 % (bb init/delta/max) |
| mystic | stores up to 3 (bb times) attacks while idle, fires them all at once |
| mystic | 秘术师 (PRTS 分支特性信息 秘术师, GitHub #181): at its attack check (the attack ready, able to act, not disarmed) with no valid target — no target, or its kit's `canAttack` false (深靛 never picks a bound enemy) — it stores one energy (`storeEnergy`, up to bb times: 3, 深靛's MSC-X 4), an attack action: the attack interval restarts; a full store idles with the attack ready. The energies leave with its next attack that happens (`releaseEnergy` in `performAttack`, after `beforeAttack`: an attack cancelled before its shot keeps them) and land with the main hit, one arts attack hit each (`hitsFn` reads the hit's `info.energy`); a redeployment holds none [ASSUMED]. 维伊 / 黑键 plug their own `storeEnergy` (转置能量, elite energies) into the same check |
| phalanx | no attack & DEF +200 %, RES +20 (bb) while the skill is off; while on, `rangeAoe`: each attack strikes every selectable enemy on its range at once (blocked enemies included; a stealthed one only when revealed or blocked), the same damage near and far, instant (`'beam'`) — "群体法术伤害" (secondary: Terra Wiki, Phalanx Caster: "attacks hit all enemies within their range"; supporting: PRTS 林 S3 备注 "单次普攻最多触发1次效果" — one normal attack can kill several — the same 锁定攻击范围 shape as the 轰击术师, and PRTS 溅射半径一览 documents no splash radius for it (it omits the 撼地者 too, so this is not proof); no primary source states a target cap; community report E3: it used to be one bolt + a 1.1 splash). 卡涅利安's charged S1 keeps the skill-off trait, 不攻击 included (kit `canAttack`, PRTS 备注) |
| physician | heal the lowest HP% injured ally in range (a skill `targeting.maxTargets` widens any heal profile) |
| ringhealer | heal 3 allies |
| chainhealer | heal bounces 3× (−25 %, bb chain.*) within 2.5 tiles |
| chainhealer | the heal jumps on to 3 units in all (−25 % per jump, bb chain.*), each jump inside the 3×3 of tiles around the last one healed (range x-4), never twice to one unit, the lowest HP ratio first — a full-HP ally too (healed for nothing, the chain goes on from it) — then the latest deployed; no 禁疗 / 孤立 unit (a healer's `healThrough` summon excepted) — PRTS 分支特性信息 链愈师 (`ai.js chainHealNext`, shared with Mon3tr's kit; until 0.2.0: the most injured ally within 2.5 tiles) |
| healer (流明) | heal ×0.8 (bb heal_scale) beyond 2 tiles |
| wandermedic | heal + reduce element gauges by 50 % ATK (bb ep_heal_ratio); also targets uninjured allies with gauge |
| incantationmedic | arts attack; EVERY damage the unit deals heals the lowest ally in range for 50 % (bb scale) of it — the official trait buff (`vendla_tr` / `reed2_tr` / `titi_tr`) is ON_AFTER_OUTPUT_DAMAGE, so skill and DoT damage heals too (缇缇's 凝固的时光 ticks, 焰影苇草's S2 fireballs while she is disarmed); a skill that triggers it for one named ally says so ("仅对该角色触发…特性") and the damage instance carries that ally (`DamageInfo.traitAlly`) |
@@ -1228,8 +1228,8 @@ header of professions.js).
| craftsman | melee phys (support devices via kit) |
| shotprotector | ranged phys, can hit FLY, blocks 3 |
| fortress | melee single target while blocking, ranged 1.0 splash otherwise, ground only (never hits FLY) |
| unyield / musha / reaper | cannot be healed by others; the heal fires on every enemy the unit damages itself — a normal attack (musha 50 / bb value per hit; reaper 50 × min(hits, block)) and any damage it outputs that no buff produced (the official trait's ON_OUTPUT_DAMAGE, which is why 隐德来希's S2 血镰 cuts heal her while she is disarmed), 50 (bb value) per enemy, the reaper's capped at the block count per instant |
| centurion / crusher / pusher | hit every blocked enemy at once |
| unyield / musha / reaper | cannot be healed by others. musha: one heal of 50 (bb value) per damage instance it deals to an enemy (PRTS 分支特性信息 武者 "特性治疗于干员每次输出伤害时触发(不局限于攻击)"; the official `utage_trait` / `helage_trait` / `zuole_trait` ON_OUTPUT_DAMAGE): every hit of a normal attack — a double strike heals twice, a dodged hit not at all — and every skill / item / bond damage instance; not a 流失, an element 损伤 or a talent / DoT damage. reaper: 50 × min(enemies hit, block) per normal attack and 50 per other damage instance it outputs that no buff produced (the official trait's ON_OUTPUT_DAMAGE, which is why 隐德来希's S2 血镰 cuts heal her while she is disarmed), capped at the block count per instant. Both trait heals ignore 禁疗 (PRTS 分支特性信息 武者 / 收割者 "通过自身特性/天赋/技能产生的作用于自身的治疗效果会无视自身的禁疗") |
| centurion / crusher / pusher | "同时攻击阻挡的所有敌人" (`hitAllBlocked`): each attack takes up to the block count of targets (never fewer than 1) from the range and the enemies it blocks, the blocked ones first — PRTS 分支特性信息 "普通攻击最大目标数等于阻挡数(不会低于1)", PRTS 作战机制 §AOE伤害判定 (强攻手: a 锁定人数 AoE taking the targets of its range up to its cap); the client's selector flag `_limitedMaxTargetNumToBlockedCnt`, carried by every skill worded so too (忍冬 S3, 左乐 S2, Mon3tr S3, 凯尔希's Mon3tr under S2 … — kits set `attack.hitAllBlocked`). Until 0.2.0 it struck the blocked enemies only |
| hammer | 50 % splash (bb atk_scale_2) to others within 1 tile |
| instructor | ×1.2 (bb atk_scale) vs enemies it doesn't block |
| librator | no attack & block 0 while the skill is off; ATK +5 %/s up to +200 % (bb atk / max_stack_cnt), reset at skill end; elite module starts at +100 % (bb init_atk) |
+129 -52
View File
@@ -18,9 +18,11 @@
// its unit blocks (PRTS 卫戍协议/帮助 "对于医疗干员(咒愈师分支除外),攻击目标为需要治疗的单位"). An ally target
// (Battle.setAllyTarget: 白铁's 铁钳号·原型机, an enemy-camp summon our operators attack, 嘲讽等级 −2) on the range comes after
// every enemy (acquireTargets) and a ranged attack flies to it like to an enemy.
// Unblocked ranged enemies attack allies within their radius and stand for each attack's clip — through its wind-up
// and until the clip ends — then walk on (attackStand, GitHub #58; ATTACK_PAUSE after the strike when no clip is known;
// 「不停止移动」 attackers never stop); the candidates pass the enemy's own rule (`e.profile.canTarget`) and are ordered
// Every enemy attack strikes at its clip's damage frame after its swing starts (attackWindup; a stun before the frame
// cuts the swing — enemyAttack, GitHub #187). Unblocked ranged enemies attack allies within their radius and stand for
// each attack's clip — through its wind-up and until the clip ends — then walk on (attackStand, GitHub #58;
// ATTACK_PAUSE after the strike when no clip is known; 「不停止移动」 attackers never stop); the candidates pass the
// enemy's own rule (`e.profile.canTarget`) and are ordered
// blocker → taunt → latest deployed (targeting.js sortAllyTargets). An enemy's damage type is its data's unless content
// arms it (`e.profile.dmgType`: 转译基底's forms, whose data never attacks). Reaching the final leg's end = leak. A `fear` (恐惧) status suspends the route: the
// enemy runs between random checkpoints away from the fear's source (fear.js moveFeared; a self-inflicted fear
@@ -79,20 +81,30 @@ export function updateAlly(b, u, dt) {
if (prof.noAttackUnlessSkill && !(sk && sk.active)) return;
if (u.s.flags.disarm) return;
if (u.atkCd > 0) return;
if (prof.canAttack && !prof.canAttack(b, u)) return;
// (a 秘术师's `canAttack` is its own target rule: false = no valid target — 深靛's bound enemies, 维伊's marked ones)
if (prof.canAttack && !prof.canAttack(b, u)) { if (prof.storeEnergy) { u.trait.hadTarget = false; storeEnergy(b, u, prof); } return; }
let targets = acquireTargets(b, u, prof);
if (!targets.length) { u.trait.hadTarget = false; return; }
if (!targets.length) { u.trait.hadTarget = false; storeEnergy(b, u, prof); return; }
u.trait.hadTarget = true;
if (sk && sk.onAboutToAttack()) {
prof = effectiveProfile(u);
if (prof.noAttack || !u.alive) return;
targets = acquireTargets(b, u, prof);
if (!targets.length) return;
if (!targets.length) { storeEnergy(b, u, prof); return; } // the cast left none (a range change): still this check
}
performAttack(b, u, prof, targets);
u.atkCd = Math.max(u.atkCd, u.s.interval);
}
/**
* The attack check of a ready unit found no valid target: a 秘术师 profile stores one energy instead (`storeEnergy`,
* professions.js installMystic — PRTS 分支特性信息 秘术师 "若无有效目标且能量储存数未满,则改为储存一份攻击能量(属于攻击
* 行为)": the attack interval starts again); with a full store it idles, ready to attack.
*/
function storeEnergy(b, u, prof) {
if (prof.storeEnergy && prof.storeEnergy(b, u)) u.atkCd = Math.max(u.atkCd, u.s.interval);
}
/** Release blocked enemies beyond the current block capacity (latest blocked first). */
export function enforceBlockCapacity(b, u) {
const cap = u.alive && u.deployed ? u.s.blockCnt : 0;
@@ -128,10 +140,6 @@ export function acquireTargets(b, u, prof) {
}
prof._fortressMelee = false;
}
if (prof.hitAllBlocked && u.blocking.length) {
const t = u.blocking.filter((e) => canTargetEnemy(u, e, prof));
if (t.length) return t;
}
const cands = b.enemiesInKeys(u.rangeKeys, u, prof);
// "可以选择且优先选择阻挡单位" (PRTS 选择器): the enemies a unit blocks are always selectable by it — the block radius
// (0.7071) reaches past its own tile, so a blocked enemy may stand outside a short range or behind its facing (user
@@ -143,16 +151,29 @@ export function acquireTargets(b, u, prof) {
if (prof.allInRange) return cands.concat(extra);
sortEnemyTargets(b, u, cands, prof.priority);
const all = cands.concat(extra);
const n = Math.max(1, Math.floor((prof.maxTargets || 1) + u.s.maxTargets));
const n = targetCount(u, prof);
return n >= all.length ? all : all.slice(0, n);
}
if (!cands.length) return cands;
if (prof.allInRange) return cands;
const n = Math.max(1, Math.floor((prof.maxTargets || 1) + u.s.maxTargets));
const n = targetCount(u, prof);
sortEnemyTargets(b, u, cands, prof.priority);
return n >= cands.length ? cands : cands.slice(0, n);
}
/**
* How many targets one attack of `u` takes. `hitAllBlocked` — "同时攻击阻挡的所有敌人": the 强攻手 / 重剑手 / 推击手 traits and every
* skill worded so, whose client selectors carry `_limitedMaxTargetNumToBlockedCnt` (with `_allowZeroBlockCntLimit` off) —
* takes up to its block count, never fewer than 1, from its range and the enemies it blocks, the blocked ones first
* (acquireTargets' order): PRTS 分支特性信息 强攻手 / 重剑手 / 推击手 "普通攻击最大目标数等于阻挡数(不会低于1)", PRTS 作战机制 §AOE伤害判定
* "锁定人数的无弹道AOE攻击(例如近卫分支“强攻手”)…在抬手时选取范围内的全体目标(不超过其攻击目标上限)", PRTS 忍冬 S3 备注 "可对空" (a
* flyer she cannot block). Until 0.2.0 it struck the blocked enemies only (one in range when it blocked none).
*/
function targetCount(u, prof) {
const base = prof.hitAllBlocked ? Math.max(1, Math.floor(u.s.blockCnt)) : (prof.maxTargets || 1);
return Math.max(1, Math.floor(base + u.s.maxTargets));
}
/** Perform an attack/heal with profile `prof` against `targets`. opts: { noAmmo } (Battle.forceAttack). */
export function performAttack(b, u, prof, targets, opts = null) {
const isSkill = !!prof.isSkill;
@@ -174,11 +195,13 @@ export function performAttack(b, u, prof, targets, opts = null) {
}
const ranged = !prof._fortressMelee && prof.attack === 'ranged' && prof.projectile && prof.projectile !== 'none' && prof.projectile !== 'beam';
const vis = prof._fortressMelee ? 'none' : (prof.projectile || 'none');
// 秘术师: the stored energies leave with this attack, at its main target (professions.js installMystic)
const energy = !isHeal && prof.releaseEnergy ? prof.releaseEnergy(b, u) : 0;
for (let i = 0; i < targets.length; i++) {
const t = targets[i];
b._ev(['atk', u.id, t.id, vis]);
if (isHeal) { doHeal(b, u, prof, t); continue; }
const info = { isSkill, index: i, attackId };
const info = { isSkill, index: i, attackId, energy: i === 0 ? energy : 0 };
const foe = t.side === 'enemy' || b.isAllyTarget(t);
if (ranged && foe && prof.projectile === 'boomerang') {
throwBoomerang(b, u, prof, t, info);
@@ -260,7 +283,7 @@ export function resolveHit(b, u, prof, target, info, x, y) {
if (target && target.alive) {
let mulT = skillMul;
if (prof.dmgMul) { const m = typeof prof.dmgMul === 'function' ? prof.dmgMul(b, u, target) : prof.dmgMul; if (Number.isFinite(m)) mulT *= m; }
const hits = prof.hitsFn ? prof.hitsFn(b, u) : Math.max(1, prof.hits || 1);
const hits = prof.hitsFn ? prof.hitsFn(b, u, info) : Math.max(1, prof.hits || 1);
let dealtMain = 0;
for (let h = 0; h < hits && target.alive; h++) {
dealtMain += b.dealDamage(u, target, { amount: atk * scale * mulT, type: baseType, isAttack: true, isSkill: info.isSkill, tags: prof.tags || [], attackId });
@@ -319,6 +342,27 @@ export function resolveHit(b, u, prof, target, info, x, y) {
}
}
/**
* The next unit of a 链愈师 heal chain of `healer` from `prev` — PRTS 分支特性信息 链愈师: "跳跃范围为x-4,无特殊说明的场合一次
* 治疗链不会对已跳跃过的单位重复跳跃", "优先跳跃至范围内生命比例最低>部署时间点最晚的我方单位。可选择满生命我方单位为跳跃目标,但仍受
* 禁疗制约": an ally on the 3×3 of tiles around prev's (range x-4), not yet in this chain (`seen` ids), no device, no hidden
* or 孤立 unit, no 禁疗 / 无法被治疗 one (as Battle.injuredAlliesInKeys: the healer's `healThrough` lets its own summon in —
* 凯尔希 / Mon3tr); the lowest HP ratio first — a full-HP ally too, healed for nothing, and the chain jumps on from it —,
* then the latest deployed (aggroSeq). Null when none. Shared by the profession (doHeal) and Mon3tr's kit.
*/
export function chainHealNext(b, healer, prev, seen) {
const through = healer && healer.profile && typeof healer.profile.healThrough === 'function' ? healer.profile.healThrough : null;
const r0 = prev.tileR, c0 = prev.tileC;
let best = null;
for (const a of b.allyUnits) {
if (!a.alive || !a.deployed || a.hidden || a.kind === 'device' || seen.has(a.id)) continue;
if (Math.abs(a.tileR - r0) > 1 || Math.abs(a.tileC - c0) > 1) continue;
if (a !== healer && (a.s.flags.isolated || (a.s.flags.noHeal && !(through && through(healer, a))) || (a.profile && a.profile.noHeal))) continue;
if (!best || a.hpRatio < best.hpRatio - 1e-12 || (Math.abs(a.hpRatio - best.hpRatio) <= 1e-12 && a.aggroSeq > best.aggroSeq)) best = a;
}
return best;
}
function doHeal(b, u, prof, t) {
const atk = u.s.atk;
const scale = (prof.atkScale ?? 1) * (prof.healScale ?? 1) * u.s.atkScaleMul;
@@ -332,13 +376,7 @@ function doHeal(b, u, prof, t) {
let prev = t;
const n = Math.max(1, h.count || 3);
for (let k = 1; k < n; k++) {
let best = null, bd = Infinity;
for (const a of b.alliesInRadius(prev.x, prev.y, 2.5, null)) {
// 禁疗 / noHeal units are no heal target for the bounces either (as injuredAlliesInKeys; 史尔特尔's 余烬, GitHub #52)
if (seen.has(a.id) || a.hp >= a.s.maxHp || a.kind === 'device' || a.s.flags.noHeal || (a.profile && a.profile.noHeal)) continue;
const d = a.hpRatio;
if (d < bd) { bd = d; best = a; }
}
const best = chainHealNext(b, u, prev, seen);
if (!best) break;
seen.add(best.id);
b._ev(['atk', prev.id, best.id, 'chainHeal']);
@@ -487,9 +525,13 @@ export function updateEnemy(b, e, dt) {
if (!e.alive) return;
// hidden (teleporting) enemies only advance wait legs
const stunned = e.s.flags.stun;
const prevCd = e.atkCd;
if (e.atkCd > 0 && !stunned && !e.hidden) e.atkCd = Math.max(0, e.atkCd - dt);
// a stun / freeze / sleep / 浮空 — or leaving the field — takes the enemy out of its attack: a swing short of its damage
// frame does not land, and the attack starts again from its wind-up afterwards (enemyAttack)
if (e.swing && (stunned || e.hidden)) e.swing = false;
// true: an unblocked ranged enemy in the wind-up of its next attack with a target in range (it stands)
const winding = !e.hidden && !stunned && enemyAttack(b, e);
const winding = !e.hidden && !stunned && enemyAttack(b, e, prevCd);
if (!e.alive) return;
// a stun / freeze / sleep cuts the attack clip short: no stand left once it ends [ASSUMED]
if (stunned && !e.hidden) { e.atkStandUntil = -Infinity; return; }
@@ -644,43 +686,31 @@ export function attackStand(e, out = { wind: 0, rest: 0 }) {
if (e.profile?.attackMoves ?? e.def?.attackMoves) return out;
const a = e.def?.attackAnim;
if (!a || !(a.dur > 0)) { out.rest = ATTACK_PAUSE; return out; }
const iv = e.s.interval;
const speed = iv > 0 && iv < a.dur ? a.dur / iv : 1;
const hit = Number.isFinite(a.hit) ? Math.min(a.dur, Math.max(0, a.hit)) : a.dur / 2;
const speed = clipSpeed(e, a);
const hit = clipHit(a);
out.wind = hit / speed;
out.rest = (a.dur - hit) / speed;
return out;
}
const STAND = { wind: 0, rest: 0 };
/** The attack clip's strike frame (s into the clip); half the clip when the manifest names none. */
const clipHit = (a) => (Number.isFinite(a.hit) ? Math.min(a.dur, Math.max(0, a.hit)) : a.dur / 2);
/** The clip plays faster when the attacks come quicker than it. */
const clipSpeed = (e, a) => { const iv = e.s.interval; return iv > 0 && iv < a.dur ? a.dur / iv : 1; };
/**
* One tick of an enemy's attack: attacks when its cooldown is over and a target is in reach. Returns true while an
* unblocked ranged enemy is in the wind-up of its next attack (cooldown ≤ its clip's wind-up, attackStand) with a
* target in range: it stands (updateEnemy).
* The damage frame of an enemy's normal attack: seconds from the start of its swing to the strike — its attack clip's
* strike frame (data/enemies.json `attackAnim.hit`, half the clip when the manifest names none), shortened with the clip
* when the attacks come quicker than it (as attackStand; a 「不停止移动」 attacker swings on the move); 0 with no clip known
* (the strike as the swing starts — the rule before 0.2.0).
*/
function enemyAttack(b, e) {
const def = e.def;
if (e.profile && e.profile.noAttack) return false;
const dmgType = (e.profile && e.profile.dmgType) || def.dmgType;
if (dmgType === 'none' || e.s.atk <= 0) return false;
if (e.s.flags.fear || e.s.flags.disarm) return false;
if (e.s.flags.tremble && e.blockedBy) return false; // 战栗: 被阻挡后无法进行普通攻击
if (dmgType === 'heal') { if (e.atkCd <= 0) enemyHeal(b, e, e.base.rangeRadius); return false; }
// applyWay MELEE enemies only ever hit their blocker, even when their data carries a rangeRadius (粉碎攻坚手 2.5,
// 宿主士兵 2.5, 深池伙友卫队 1.4 … — that radius belongs to their abilities/splash, handled by content).
// Content may flip it with `e.profile.melee = false`.
const melee = e.profile?.melee ?? def.applyWay === 'MELEE';
const radius = melee ? 0 : e.base.rangeRadius;
// the range circle takes an ally whose 0.25 collider touches it (PRTS 作战机制 §碰撞体积: 索敌 uses the colliders)
const reach = radius > 0 ? radius + ALLY_COLLIDER_RADIUS : 0;
// `e.profile.canTarget(ally)`: the enemy's own target rule (只攻击地面单位, 不会攻击飞行单位 …; content/enemies/*.js),
// applied to the candidates before the priority sort and the target count
const own = e.profile && typeof e.profile.canTarget === 'function' ? e.profile.canTarget : null;
if (e.atkCd > 0) {
// the wind-up of the next attack (GitHub #58): an unblocked ranged enemy stands once a target is in range
if (e.blockedBy || radius <= 0 || !(e.atkCd <= attackStand(e, STAND).wind + 1e-9)) return false;
return b.alliesInRadius(e.x, e.y, reach, null).some((a) => canTargetAlly(e, a, true) && (!own || own(a)));
}
export function attackWindup(e) {
const a = e.def?.attackAnim;
return a && a.dur > 0 ? clipHit(a) / clipSpeed(e, a) : 0;
}
/** The allies `e` could hit now: its blocker (and, with a range, the others in reach), passing its own target rule. */
function attackTargets(b, e, radius, reach, own) {
let targets = [];
if (e.blockedBy) {
const bl = e.blockedBy;
@@ -692,8 +722,55 @@ function enemyAttack(b, e) {
targets = b.alliesInRadius(e.x, e.y, reach, null).filter((a) => canTargetAlly(e, a, true));
}
if (own && targets.length) targets = targets.filter((a) => own(a));
return targets;
}
/**
* One tick of an enemy's attack. Its cooldown (`atkCd`) counts down to the damage frame of its next attack: the swing
* starts `attackWindup` before it — the cooldown down to the wind-up with a target in reach (attackTargets) — and lasts
* while a target stays in reach; at the frame the attack is made on the targets in reach then. An enemy whose cooldown
* ran out before it had a target (walking, a swing cut short) swings from the start: the whole wind-up from that tick.
* A swing short of its frame is cut by a stun / freeze / sleep / 浮空 / leaving the field (updateEnemy), 缴械, 恐惧, 战栗
* while blocked, or losing every target: no damage, and the next swing starts from its wind-up again — PRTS 状态机
* (ATTACK / COMBAT "每帧检查异常状态,若有则切换到异常状态的状态") and 异常效果 (STUNNED, DISARMED "正在进行的普通攻击将被
* 中断"); GitHub #187 / #170 (a 0.1 s 卡西米尔 pulse, 忍冬 S3's 0.2 s stun). A strike already made stays made (a shot in
* flight lands). An enemy with no attack clip known has no wind-up: it strikes as the swing starts (the old rule).
* [ASSUMED] the targets are taken at the frame (the swing needs one in reach every tick; the official selection happens
* as the swing starts); an enemy healer's heal has no wind-up.
* Returns true while an unblocked ranged enemy swings (its wind-up): it stands (updateEnemy). `prevCd`: the cooldown
* before this tick's countdown.
*/
function enemyAttack(b, e, prevCd) {
const def = e.def;
if (e.profile && e.profile.noAttack) { e.swing = false; return false; }
const dmgType = (e.profile && e.profile.dmgType) || def.dmgType;
if (dmgType === 'none' || e.s.atk <= 0) { e.swing = false; return false; }
// 恐惧 / 缴械 / 战栗 (被阻挡后无法进行普通攻击): no normal attack, and the one swung is interrupted
if (e.s.flags.fear || e.s.flags.disarm || (e.s.flags.tremble && e.blockedBy)) { e.swing = false; return false; }
if (dmgType === 'heal') { if (e.atkCd <= 0) enemyHeal(b, e, e.base.rangeRadius); return false; }
// applyWay MELEE enemies only ever hit their blocker, even when their data carries a rangeRadius (粉碎攻坚手 2.5,
// 宿主士兵 2.5, 深池伙友卫队 1.4 … — that radius belongs to their abilities/splash, handled by content).
// Content may flip it with `e.profile.melee = false`.
const melee = e.profile?.melee ?? def.applyWay === 'MELEE';
const radius = melee ? 0 : e.base.rangeRadius;
// the range circle takes an ally whose 0.25 collider touches it (PRTS 作战机制 §碰撞体积: 索敌 uses the colliders)
const reach = radius > 0 ? radius + ALLY_COLLIDER_RADIUS : 0;
// `e.profile.canTarget(ally)`: the enemy's own target rule (只攻击地面单位, 不会攻击飞行单位 …; content/enemies/*.js),
// applied to the candidates before the priority sort and the target count
const own = e.profile && typeof e.profile.canTarget === 'function' ? e.profile.canTarget : null;
const wind = attackWindup(e);
if (e.atkCd > wind + 1e-9) { e.swing = false; return false; } // the cooldown runs
let targets = attackTargets(b, e, radius, reach, own);
if (!targets.length) { e.swing = false; return false; } // ready, nobody in reach: no swing (or it is cut)
if (!e.swing) {
e.swing = true;
// ready before this tick (it walked with its cooldown over, or its swing was cut): the whole wind-up from now
if (prevCd < wind) e.atkCd = wind;
}
// the wind-up (GitHub #58): an unblocked ranged enemy stands through it (a 「不停止移动」 one walks on)
if (e.atkCd > 0) return !e.blockedBy && radius > 0 && !(e.profile?.attackMoves ?? def.attackMoves);
e.swing = false;
if (targets.length > 1) sortAllyTargets(e, targets);
if (!targets.length) return false;
// `enemyAttackStart` { enemy, targets }: the enemy starts a normal attack, before 麻痹 may interrupt it (the client's
// ON_BEFORE_ABILITY_SPELL_ON of an attack that 麻痹 can interrupt — 酒神 堕梦 "攻击范围内的敌人普通攻击时受到70点神经损伤",
// PRTS 备注 "于敌人成功普通攻击前之前触发(意味着触发的元素爆发可打断当次普攻…)": a burst it causes interrupts this attack)
+1
View File
@@ -132,6 +132,7 @@ export class BattleSpawns {
e.atkCd = 0;
e.pauseUntil = -Infinity; // content holds (暴鸰's drop)
e.atkStandUntil = -Infinity; // standing for its attack clip (ai.js attackStand)
e.swing = false; // a normal attack swung, its damage frame not reached yet (ai.js enemyAttack)
// every enemy profile starts with the same fields (stable object shapes keep the hot loop's property reads fast);
// `dmgType` null = the data's (content may arm a data-unarmed enemy: ai.js enemyAttack)
e.profile = { noAttack: def.dmgType === 'none', maxTargets: 1, atkScale: 1, dmgType: null };
+2 -1
View File
@@ -320,7 +320,8 @@ Each item is a mistake this project already made once. Tick every one for every
`test/match/feedback1-placement.test.js` "#9 data: 狼群 and 流形 are owner-range summons" (DESIGN §20.1, §21.3);
`test/content/playtest6_summons.test.js`, `tokens_summons.test.js`; `feedback1b_kits.test.js` "B3 维娜·维多利亚 S3".
- [ ] **9. Status effects** — every status applied, with its official duration and stacking (寒冷 on 寒冷 ⇒ 冻结 for the
longer cold, 沉默 removes abilities, 恐惧, 睡眠, 浮空 …; "同名效果取最高" = `applyStrongest`). Examples:
longer cold, 沉默 removes abilities, 恐惧, 睡眠, 浮空 …; "同名效果取最高" = `applyStrongest`; 庇护 = the shared
`PROTECT` key, `holdProtect` in `shared/tier1.js`). Examples:
`test/sim/combat.test.js` "cold on cold ⇒ freeze for the longer cold …", "sleep: target is untargetable and
inactive; fear stops enemy attacks", "statuses: sleep = invulnerable except `hitSleep` attackers; levitate …";
`enemies_bosses.test.js` "沉默: exactly the abilities whose handbook line is SILENCE-flagged can be silenced".
@@ -51,7 +51,8 @@ export default {
install(battle, unit) {
if (tb.hp_ratio == null || num(tb.heal_scale, 1) === 1) return;
// GUA-X on her skill heals (S1's heal-mode attack, S2's attack heals while it runs — herself included, she stands
// in the grid), never her natural / self regeneration
// in the grid), never her natural / self regeneration; strictly below hp_ratio (the client's sunbr_e_trait is
// set_heal_scale_by_hpratio: FilterByTargetHpRatio LT)
battle.on('heal', (ctx) => {
if (ctx.source !== unit || !unit.skill?.active || ctx.opts?.regen || ctx.opts?.self) return;
if (ctx.target.hpRatio < num(tb.hp_ratio)) ctx.amount *= num(tb.heal_scale, 1);
@@ -11,7 +11,8 @@ export default {
// 柔光缚目: attacks bind the target `duration` s with `prob`; bound enemies are never chosen as targets.
// Alternate S1 灯塔守卫者: for `duration` s the skill range (4-1), attack interval ×(1 + base_attack_time) (PRTS:
// "大幅度缩短(-80%)" ⇒ ×0.2 — a flat −0.8 s on 3 s would be less than S2's "略微缩短 ×0.7"), every attack hits for
// attack@atk_scale × ATK arts. Elite module MSC-X (store 4) is the mystic profile of the module trait (TUNE.mystic).
// attack@atk_scale × ATK arts. Elite module MSC-X (store 4) is the mystic profile of the module trait (TUNE.mystic);
// the store itself is the shared 秘术师 profile (professions.js installMystic: at the attack check, PRTS 分支特性信息).
chess_char_1_17_a: (bb, chess, def) => {
const t = talentBb(chess, 0);
const dScale = num(bb['indigo_s_2[damage].atk_scale']);
@@ -28,12 +29,11 @@ export default {
},
},
trait: {
// with only bound enemies in range (or blocked by her — always her targets, Battle.blockedTargets) she holds her
// fire (the mystic trait stores the energy meanwhile)
// with only bound enemies in range (or blocked by her — always her targets, Battle.blockedTargets) she has no valid
// target: she holds her fire and the mystic trait stores an energy at the attack check (professions.js
// installMystic; a bind on her only target included)
canAttack(battle, u) {
const ok = battle.enemiesInKeys(u.rangeKeys, u, u.profile).some((e) => !bound(e)) || battle.blockedTargets(u, u.profile).some((e) => !bound(e));
if (!ok) u.trait.hadTarget = false;
return ok;
return battle.enemiesInKeys(u.rangeKeys, u, u.profile).some((e) => !bound(e)) || battle.blockedTargets(u, u.profile).some((e) => !bound(e));
},
afterHit(battle, u, target) {
if (!target || !target.alive || target.side !== 'enemy') return;
@@ -1,7 +1,7 @@
// server/sim/content/kits/ops/chess_char_1_18-utage.js — 宴 (char_337_utage) kit, tier 1.
// Conventions of the tier-1 kits: ../shared/tier1.js; kit contract and rules: ../README.md.
import { num, talentBb, up, onHitBy, onHitOn, skillBbOf } from '../shared/tier1.js';
import { num, talentBb, up, onHitBy, onHitOn, skillBbOf, holdProtect, PROTECT_TICK_HOLD } from '../shared/tier1.js';
export default {
// ---------------------------------------------------------------------------------------------------------------
@@ -44,11 +44,14 @@ export default {
battle.addBuff(unit, { key: 'utage:serious', mods: { aspd: v }, data: { v }, tags: ['talent'] });
}, { owner: unit });
}
// 庇护 (ba.protect): "受到的物理和法术伤害降低相应比例" — physical and arts damage only
// 庇护 (ba.protect "受到的物理和法术伤害降低相应比例(同名效果取最高)") while below hp_ratio HP: the shared 庇护 — the
// strongest of every source holds (holdProtect) —, refreshed every tick and at each hit on her (a hit that takes
// her below it: the next ones already have it)
if (t.damage_resistance != null && t.hp_ratio != null) {
onHitOn(battle, unit, ({ dmg }) => {
if ((dmg.type === 'phys' || dmg.type === 'arts') && unit.hpRatio < num(t.hp_ratio)) dmg.mul *= 1 - num(t.damage_resistance);
});
const dr = num(t.damage_resistance), below = num(t.hp_ratio);
const keep = () => { if (up(unit) && unit.hpRatio < below) holdProtect(battle, unit, dr, PROTECT_TICK_HOLD, unit); };
battle.on('tick', keep, { owner: unit });
onHitOn(battle, unit, keep);
}
} }],
};
@@ -136,7 +136,8 @@ export default {
if (num(tb.damage_resistance) > 0) {
kit.install = (battle, unit) => battle.addBuff(unit, { key: 'trait:blemsh_guard', mods: { dmgTakenMul: 1 - num(tb.damage_resistance) }, persist: true, allowDead: true });
} else if (num(tb.heal_scale) > 0) {
// GUA-X "治疗生命值低于50%的友方单位时治疗量提升15%" (the HP before the heal)
// GUA-X "治疗生命值低于50%的友方单位时治疗量提升15%" (the HP before the heal; strictly below — the client's blemsh_e_trait
// is set_heal_scale_by_hpratio, FilterByTargetHpRatio LT, unlike 黍's heal_scale_up[hpratio][LE])
kit.install = (battle, unit) => battle.on('heal', (ctx) => {
if (ctx.source === unit && ctx.target && ctx.target.hpRatio < num(tb.hp_ratio, 0.5)) ctx.amount *= num(tb.heal_scale, 1);
}, { owner: unit });
@@ -162,10 +162,10 @@ export default {
};
// S2: the pack's empowered next attack (armed by the cast; ×scale on its hits; a kill pays once)
const installGift = (battle, unit) => {
// the cast: ready, the pack standing, no unused gift on it (PRTS 备注 「仅场上存在狼群,且狼群未获得此技能的充能时可触发
// 技能」; a pack in its 战术点形态 does not count — the 备注 does not say [ASSUMED])
// the cast: ready, the pack on the field in either form, no unused gift on it (PRTS 备注 「仅场上存在狼群,且狼群未获得此技能的
// 充能时可触发技能」 — the words of S1's, whose 备注 counts the 战术点形态 [ASSUMED the same for S2])
battle.on('tick', () => {
const sk = unit.skill, w = wolfOf(unit);
const sk = unit.skill, w = packOf(unit);
if (!alive(unit) || !sk || !sk.ready || sk.active || !unit.canAct || unit.s.flags.silence || !w || w.mem.vigilGift) return;
sk.activate('SP_FULL');
}, { owner: unit });
@@ -272,18 +272,20 @@ export default {
}),
// (自动触发, PRTS 备注 「仅场上存在狼群,且狼群未获得此技能的充能时可触发技能」: no enemy needed — the kit casts it as soon
// as it is ready while the pack stands and holds no unused gift (installGift); until 0.2.0 the data's DEFAULT made it
// wait for 伺夜's own next attack, so it never fired with no enemy in his range. A pack in its 战术点形态 does not
// count: S1's 备注 lists that form, S2's says nothing of it, so S2 waits for the pack to come back [ASSUMED]; a gift
// the pack holds stays on it through the form [ASSUMED])
// wait for 伺夜's own next attack, so it never fired with no enemy in his range. A pack in its 战术点形态 counts as on the
// field — S2's 备注 uses S1's words and S1's lists that form [ASSUMED for S2; the 0.2.0 follow-up's recommended
// reading]: the cast pays its DP at once and the gift waits on the pack, its next attack once it is back (until 0.2.0
// S2 waited for the pack); a gift the pack holds stays on it through the form [ASSUMED])
skchr_vigil_2: (s) => ({
kind: 'instant',
trigger: 'NEVER',
onStart({ battle, unit }) {
dpGain(battle, unit, num(s.bb.cost, 0));
const w = wolfOf(unit);
const w = packOf(unit);
if (!w) return;
// (a pack in its 战术点形态 has no HP to restore: it comes back at full HP, the gift kept on it)
const hr = num(s.bb['vigil_wolf_s_2.hp_ratio'], 0);
if (hr > 0) battle.heal(w, w, w.s.maxHp * hr, { self: true });
if (hr > 0 && w.alive) battle.heal(w, w, w.s.maxHp * hr, { self: true });
w.mem.vigilGift = { scale: num(s.bb['vigil_wolf_s_2.atk_scale'], 1), dp: num(s.bb['vigil_wolf_s_2.cost'], 0), paid: false };
fx(battle, 'buff', w, { src: unit.id, skill: 'vigil_2' });
},
@@ -34,7 +34,7 @@ export default withDefaults({
if (t.side !== 'ally' || !t.alive || !(c.amount > 0) || isHpLoss(c.dmg) || !t.findBuff(`reckpr:guard:${unit.id}`)) return;
battle.heal(unit, t, num(bb['attack@fixed_heal_value'], 80));
}, { owner: unit });
installLowHpHealBonus(battle, unit, tb);
installLowHpHealBonus(battle, unit, tb); // PHY-X: reckpr_e_002_tr filters LT (strictly below)
},
};
},
@@ -43,7 +43,7 @@ export default withDefaults({
}, { owner: unit });
} }],
install(battle, unit) {
installLowHpHealBonus(battle, unit, tb);
installLowHpHealBonus(battle, unit, tb, { atOrBelow: true }); // PHY-X: heal_scale_up[hpratio][LE] (≤)
if (!S1) return;
battle.on('beforeAttack', (c) => {
if (c.attacker !== unit) return;
@@ -52,7 +52,7 @@ export default {
c.amount += c.target.s.maxHp * ratio * num(unit.s.healingDealtMul, 1) * num(c.target.s.healingTakenMul, 1);
battle.fx('healAoe', { x: c.target.x, y: c.target.y, id: c.target.id, r: 0.5 });
}, { owner: unit, priority: 10 });
lowHpHealUp(battle, unit, tb);
lowHpHealUp(battle, unit, tb, { atOrBelow: true }); // PHY-X: heal_scale_up[hpratio][LE] (≤)
},
};
},
@@ -103,7 +103,7 @@ export default {
install(battle, unit) {
// S2 only: the NEVER-trigger skill casts itself when an ally in its area is injured
if (!sid || sid === 'skchr_demkni_2') autoCast(battle, unit, () => zone(battle, unit).some((a) => a.hp < a.s.maxHp - 1e-6));
lowHpHealUp(battle, unit, tb);
lowHpHealUp(battle, unit, tb); // GUA-X: set_heal_scale_by_hpratio filters LT (strictly below)
if (num(tb.damage_resistance) > 0) permBuff(battle, unit, 'saria:moduleY', { dmgTakenMul: 1 - num(tb.damage_resistance) });
},
};
@@ -43,7 +43,7 @@ export default {
const b = a.findBuff('reckpr:guard');
if (b && b.data.src === unit) battle.heal(unit, a, num(b.data.value));
}, { owner: unit });
lowHpHealUp(battle, unit, tb);
lowHpHealUp(battle, unit, tb); // PHY-X: reckpr_e_002_tr filters LT (strictly below)
},
};
},
@@ -6,6 +6,7 @@ import {
num, bv, tbb, moduleBb, live, opsOf, ANY, onDefaultSkill, selectedSkill, skillGridOf, AROUND8, onElementHit,
elementDmg, aura,
} from '../shared/tier6.js';
import { holdProtect } from '../shared/tier1.js';
/**
* "传送至自身位置": a ground enemy that can reach `unit`'s tile on the ground grid is moved onto it (unblocked, its
@@ -89,9 +90,10 @@ function yu(bb, chess, def) {
talents: [
{ install(battle, unit) { // 礼尚往来: 庇护 while blocking + DoT on blocked enemies
const dr = num(t0.damage_resistance), sc = bv(t0, 'atk_scale'), er = bv(t0, 'ep_damage_ratio'), iv = Math.max(0.1, bv(t0, 'interval', 1));
// 庇护 (ba.protect): 受到的物理和法术伤害降低相应比例 — true / element damage is not reduced
// 庇护 (ba.protect): 受到的物理和法术伤害降低相应比例(同名效果取最高)— true / element damage is not reduced; the shared
// 庇护 (holdProtect: the strongest of every source holds)
aura(battle, unit, 0.1, () => {
if (dr > 0 && unit.blocking.length) battle.addBuff(unit, { key: 'yu:shelter', mods: { physTakenMul: 1 - dr, artsTakenMul: 1 - dr }, duration: 0.2, refresh: 'replace' });
if (dr > 0 && unit.blocking.length) holdProtect(battle, unit, dr, 0.2, unit);
});
aura(battle, unit, iv, () => {
for (const e of unit.blocking.slice()) {
@@ -5,6 +5,7 @@ import {
num, bv, tbb, tdesc, moduleBb, parseN, live, isTok, onDefaultSkill, selectedSkill, AROUND8, N4, bstate, bestTile,
pullToward,
} from '../shared/tier6.js';
import { holdProtect } from '../shared/tier1.js';
/** 莱茵生命 (character_table groupId "rhine") members of this season's pool (checked against the official table). */
const RHINE = new Set(['char_108_silent', 'char_128_plosis', 'char_202_demkni', 'char_249_mlyss', 'char_1047_halo2']);
@@ -139,7 +140,9 @@ function mlyss(bb, chess, def) {
const ranged = !!(m && m.ranged);
t.profile.hits = on && ranged && live(t) ? t.mem.mlyssHits * 2 : t.mem.mlyssHits; // 二连击
if (on && m && !ranged && live(t)) {
battle.addBuff(t, { key: 'mlyss:eco', duration: 0.4, refresh: 'replace', visible: true, mods: { hpRegenRatio: regen, physTakenMul: 1 - dr, artsTakenMul: 1 - dr } });
battle.addBuff(t, { key: 'mlyss:eco', duration: 0.4, refresh: 'replace', visible: true, mods: { hpRegenRatio: regen } });
// "获得15%的庇护": the shared 庇护 (holdProtect — 同名效果取最高 with every other source)
holdProtect(battle, t, dr, 0.4, unit);
}
}
}, { owner: unit });
+13 -16
View File
@@ -12,8 +12,8 @@
// - Trait (秘术师) "攻击造成法术伤害,在找不到攻击目标时可以将攻击能量储存起来之后一齐发射(最多3个)" (bb times; MSC-X 4):
// ranged arts bolts, hits air units (分支特性信息 "可对空"), blocks 1, ground enemies target him. The store (分支特性信息:
// "能量储存与攻击占用相同的攻击间隔:…若无有效目标且能量储存数未满,则改为储存一份攻击能量(属于攻击行为)"): when his attack is
// ready and he has no valid target he stores one energy and his attack interval starts again — this kit's own store,
// as 维伊's (op-veen.js; the engine's mystic profile stores one interval after the attack is ready). His next attack
// ready and he has no valid target he stores one energy and his attack interval starts again — the shared 秘术师
// profile (professions.js installMystic) with this kit's `storeEnergy` (his elite energy, S3), as 维伊's. His next attack
// releases them at its target ("一齐发射"), each hitting for ATK × the attack's scale × the energy scale (talent 1) as
// an arts 普通伤害 attack hit ("由储存能量形成的弹道造成攻击力100%的法术普通伤害"); they land with the main bolt (one
// volley to one target [ASSUMED], as 维伊's), the later ones only on a target still alive. [ASSUMED] a redeployment holds
@@ -65,7 +65,6 @@
import { num, talentBb, moduleBb, traitBb, skillRec, batMod, up, toggleBuff } from '../shared/tier1.js';
import { canTargetEnemy, sortEnemyTargets } from '../../../targeting.js';
import { acquireTargets, effectiveProfile } from '../../../ai.js';
import { hasHp } from '../../../damage.js';
import { COLS } from '../../../constants.js';
@@ -215,21 +214,19 @@ export default {
trait: {
hitsFn: () => 1, // the engine's main hit only: the stored energies are this kit's (land)
onEachHit(battle, unit, victim, hc) { if (hc.kind === 'main' && victim && victim.side === 'enemy') land(battle, unit, victim, hc); },
// the store (the shared profile calls it at his attack check with no valid target — professions.js installMystic:
// an attack action, the interval restarts): the trait's energies first, then the elite one; during S3 elite ones
// only (false = a full store: he idles)
storeEnergy(battle, unit) {
const v = stateOf(unit);
if (skillOn(unit, S3)) { if (v.n + v.e >= N + E) return false; v.e++; }
else if (v.n < N) v.n++;
else if (v.e < E) v.e++;
else return false;
return true;
},
install(battle, unit) {
battle.on('deploy', (ctx) => { if (ctx.unit === unit && !ctx.move) unit.trait.ebn = null; }, { owner: unit });
// the store: his attack ready and no valid target ⇒ one energy, an attack action (the interval restarts); the
// attack loop ran first this tick (a valid target was attacked instead)
battle.on('tick', () => {
if (!up(unit) || !unit.canAct || unit.s.flags.disarm || unit.atkCd > 0) return;
const prof = effectiveProfile(unit);
if (prof.noAttack || acquireTargets(battle, unit, prof).length) return;
const v = stateOf(unit);
if (skillOn(unit, S3)) { if (v.n + v.e >= N + E) return; v.e++; }
else if (v.n < N) v.n++;
else if (v.e < E) v.e++;
else return;
unit.atkCd = unit.s.interval;
}, { owner: unit });
battle.on('death', (ctx) => { if (ctx.unit && ctx.unit.side === 'enemy') stateOf(unit).q.delete(ctx.unit.id); }, { owner: unit });
},
},
+9 -19
View File
@@ -10,9 +10,8 @@
// 属性加成,损失一定比例时达最大加成(同类属性取最高)") and ba.protect 庇护 ("受到的物理和法术伤害降低相应比例(同名效果取最高)"), his
// battle skeleton char_188_helage.skel (Skill: two OnAttack, Skill_2: two, Skill_3_Loop: one).
// - Trait (武者) "不成为其他角色的治疗目标,每次攻击到敌人后回复自身70生命": the profession default (professions.js `musha`: no
// heal from others, value HP per enemy an attack hits), ground-only melee on his 1-1, block 1. The branch heals on every
// damage instance he deals: the engine heals once per enemy an attack strikes, so the second hit of S1 / S2's 二连击 on
// an enemy heals once more here (the trait's `value`, a self heal).
// heal from others, `value` HP on every damage instance he deals — PRTS 分支特性信息 武者 — so the second hit of S1 / S2's
// 二连击 heals too), ground-only melee on his 1-1, block 1.
// - T1 月盈星亏 "在场时,自身获得最高+100攻击速度的坚忍(损失70%生命值时达到最大加成)": ASPD + min_attack_speed × the share of
// the HP lost up to 1 − min_hp_ratio (linear, 坚忍), refreshed every tick. SBL-X stage 3: +130 at 50 % lost (the module
// talent change).
@@ -26,7 +25,7 @@
// parts at once].
// - Module SBL-X “藏锋” "生命值低于50%时,获得25%的庇护" (a display trait part; the effect is the hidden talent part hp_ratio /
// damage_resistance, merged into talent 0's blackboard): below hp_ratio of his max HP the physical and arts damage he takes
// ×(1 − damage_resistance) (as 宴's elite module).
// ×(1 − damage_resistance) (as 宴's elite module) — 庇护, "同名效果取最高": the shared effect (tier1.js holdProtect).
// - Module SBL-Y “热的雪” "被击倒时不撤退且回复30%生命(单次部署只触发1次)" (trait bb hp_ratio): the first lethal blow of a
// deployment leaves him on the field at hp_ratio of his max HP (a kit saver, priority −50 as 斯卡蒂's module).
// - S1 新月 (AUTO, attack SP, DEFAULT): the next attack at atk_scale × ATK, twice (Skill clip: two OnAttack).
@@ -34,7 +33,7 @@
// - S3 满月 (MANUAL, time SP, data ACTIVE_RANGE on the running 1-1 + 1): `duration` s — ATK +atk, attack range +1 tile
// forward (ability_range_forward_extend), up to attack@max_target targets at once; back to 1-1 after.
import { num, traitBb, skillRec, toggleBuff, up, onHitOn } from '../shared/tier1.js';
import { num, traitBb, skillRec, toggleBuff, up, onHitOn, holdProtect, PROTECT_TICK_HOLD } from '../shared/tier1.js';
const S1 = 'skchr_helage_1';
const S2 = 'skchr_helage_2';
@@ -90,22 +89,13 @@ export default {
} },
],
install(battle, unit) {
// 武者: the trait heal on every damage instance — the engine heals once per enemy an attack strikes, so each further
// hit of the same attack on that enemy (S1, S2's 二连击) heals once more
battle.on('damaged', (c) => {
const d = c.dmg;
if (c.source !== unit || !unit.alive || !d || !d.isAttack || !d.attackId || !c.target || c.target.side !== 'enemy') return;
const m = unit.mem;
if (m.helageAttackId !== d.attackId) { m.helageAttackId = d.attackId; m.helageStruck = new Set(); }
if (!m.helageStruck.has(c.target.id)) { m.helageStruck.add(c.target.id); return; }
battle.heal(unit, unit, num(unit.profile?.selfHeal, num(tb.value, 70)), { self: true });
}, { owner: unit, priority: -10 });
// SBL-X “藏锋”: 生命值低于50%时,获得25%的庇护 (physical and arts damage taken)
// SBL-X “藏锋”: 生命值低于50%时,获得25%的庇护 — the shared 庇护 (holdProtect: the strongest of every source holds),
// refreshed every tick and at each hit on him while he is below
const dr = num(t0.damage_resistance), drBelow = num(t0.hp_ratio);
if (dr > 0 && drBelow > 0) {
onHitOn(battle, unit, ({ dmg }) => {
if ((dmg.type === 'phys' || dmg.type === 'arts') && unit.hpRatio < drBelow) dmg.mul *= 1 - dr;
});
const keep = () => { if (up(unit) && unit.hpRatio < drBelow) holdProtect(battle, unit, dr, PROTECT_TICK_HOLD, unit); };
battle.on('tick', keep, { owner: unit });
onHitOn(battle, unit, keep);
}
// SBL-Y “热的雪”: 被击倒时不撤退且回复30%生命(单次部署只触发1次)
const rise = num(tb.hp_ratio);
+10 -6
View File
@@ -27,8 +27,9 @@
// - T2 余火之氅 "使自身与身后一格的友军获得18%的庇护" (damage_resistance, talent grid b-1: his tile and the one behind; CRU-X
// stage 3 "28%…且造成的物理伤害提升10%" — hodrer_equip_1_3_p2 gives damage_resistance[inf] and hodrer_equip[damage_scale_up]
// (ON_OUTPUT_DAMAGE PHYSICAL ×damage_scale) to the same targets, ally side, any motion, removed when they leave): 庇护
// (ba.protect "受到的物理和法术伤害降低相应比例(同名效果取最高)") = phys / artsTakenMul 1 − value as one instance per ally
// (applyStrongest) with physDealtMul ×damage_scale, renewed every PROTECT_IV s on the allies of the grid while he is deployed.
// (ba.protect "受到的物理和法术伤害降低相应比例(同名效果取最高)") = phys / artsTakenMul 1 − value — the shared 庇护 of every source
// (tier1.js holdProtect: damage_resistance[inf] is the common key) — and physDealtMul ×damage_scale (its own one instance per
// ally), renewed every PROTECT_IV s on the allies of the grid while he is deployed.
// - S1 重锋不熄 (AUTO, hit SP: INCREASE_WHEN_ATTACK, data DEFAULT): the next attack (every enemy he blocks) at atk_scale × ATK;
// hodrer_s[heal] (ON_ABILITY_SPELL_ON, HealViaMaxHpRatio) heals hp_ratio of his max HP once for that attack.
// - S2 余烬重荷 (MANUAL, data DEFAULT): "被动效果:攻击力+16%" (atk; the skill's extra ability hodrer_s_2[passive]) while it is
@@ -44,7 +45,7 @@
// then on it takes attack@damage 无来源 true damage (NoSourceDamage PURE: damage.js periodicDamage, credited to him) every
// second of its own (triggerInterval 1 s from its mark) until the skill ends (a derived buff of the skill's).
import { num, talentBb, talentGrid, moduleBb, traitBb, skillRec, statBuff, up, batMod, alliesInGridOf } from '../shared/tier1.js';
import { num, talentBb, talentGrid, moduleBb, traitBb, skillRec, statBuff, up, batMod, alliesInGridOf, holdProtect } from '../shared/tier1.js';
import { periodicDamage } from '../../../damage.js';
const S1 = 'skchr_hodrer_1';
@@ -53,7 +54,8 @@ const S3 = 'skchr_hodrer_3';
/** 余火之氅's grid when the data carries none: his tile and the one behind him (range b-1). */
const BEHIND = Object.freeze([Object.freeze([0, -1]), Object.freeze([0, 0])]);
const PROTECT_IV = 0.25;
const PROTECT_KEY = 'talent:hodrer:protect';
/** CRU-X stage 3's 物理伤害 + (hodrer_equip[damage_scale_up]): one instance per ally (the 庇护 is the shared key). */
const DMG_UP_KEY = 'talent:hodrer:dmgUp';
const S2_PASSIVE_KEY = 'skill:hodrer:s2passive';
/** hodrer_s_3[damage] / hodrer_s_3[burn]: triggerInterval 1 s, waitFirstTriggerInterval. */
const SMOKE_IV = 1;
@@ -140,10 +142,12 @@ export default {
const dr = num(t1.damage_resistance), ds = num(t1.damage_scale, 1);
const grid = talentGrid(chess, 1) ?? BEHIND;
if (!(dr > 0) && ds === 1) return;
const mods = (v) => ({ physTakenMul: 1 - v, artsTakenMul: 1 - v, ...(ds !== 1 ? { physDealtMul: ds } : {}) });
const give = () => {
if (!up(unit)) return;
for (const a of alliesInGridOf(battle, unit, grid)) battle.applyStrongest(a, PROTECT_KEY, { duration: PROTECT_IV + 0.1, value: dr, mods, source: unit });
for (const a of alliesInGridOf(battle, unit, grid)) {
holdProtect(battle, a, dr, PROTECT_IV + 0.1, unit);
if (ds !== 1) battle.applyStrongest(a, DMG_UP_KEY, { duration: PROTECT_IV + 0.1, value: ds, mods: (v) => ({ physDealtMul: v }), source: unit });
}
};
battle.every(PROTECT_IV, give, { owner: unit });
battle.on('battleStart', give, { owner: unit });
+12 -7
View File
@@ -33,7 +33,9 @@
// [ASSUMED: given here]); its DEF is ×0 while its tile is outside her attack range (kalts_t_1[token_def_down]; PHY-Y stage
// 3: inside it ASPD +attack_speed, DEF +def — kalts_t_1[token_def_down_finish]). It is withdrawn when she leaves (no
// rattle); it comes back on its tile its respawnTime (25 s) after it left, paying its cost (10 DP), only while she stands —
// her (re)deployment readies a waiting one at once (charge_token[born]) [ASSUMED: the 卫戍 auto redeploy of a placed summon].
// her (re)deployment readies a waiting one at once (charge_token[born]: RechargeToken with timing NORMAL, while its own timer
// is the summon's charge_token[finish], ON_FINISH — the one reading of every summoner kit, 傀影's twin included)
// [ASSUMED: the 卫戍 auto redeploy of a placed summon].
// - T2 不毁重构: Mon3tr knocked out (not withdrawn; S3's 流失 counts) ⇒ every selectable enemy of its 3×3 (the talent's x-4,
// air units too) takes `value` (1200) true damage (溅射, not dodgeable) and is stunned `stun` (3) s. PHY-X stage 3 (the
// token's module talent: 3.5 s, 1500, hp_ratio): also once per deployment when a hit leaves it at or below hp_ratio of
@@ -41,8 +43,9 @@
// - S1 指令:结构加固 (MANUAL, data DEFAULT — a heal skill: cast as she is about to heal): her DEF +def and Mon3tr's DEF
// +attack@def while it runs; 物理格挡 prob (damage_block[phy]: a physical damage instance she takes is blocked whole —
// not a dodge, nothing ignores it; a 流失 is no damage).
// - S2 指令:战术协同 (MANUAL, data DEFAULT, 绑定 Mon3tr): her ASPD +attack_speed; Mon3tr ATK +attack@atk and each of its attacks
// strikes every enemy it blocks (one target when it blocks none [ASSUMED]).
// - S2 指令:战术协同 (MANUAL, data DEFAULT, 绑定 Mon3tr): her ASPD +attack_speed; Mon3tr ATK +attack@atk and "可以攻击阻挡的所有
// 敌人": each of its attacks takes up to its block count (3) of targets, the blocked ones first (the 强攻手 rule — PRTS 分支特性信息
// "普通攻击最大目标数等于阻挡数(不会低于1)"; its mode's _limitedMaxTargetNumToBlockedCnt).
// - S3 指令:熔毁 (MANUAL, data DEFAULT, 绑定 Mon3tr, 18 s): Mon3tr DEF +attack@def, its attacks deal true damage, its ATK
// +attack@atk × the remaining share of the skill, updated every second from the cast (+190 % … +10.6 %); unless it killed
// an enemy meanwhile, at the skill's normal end it loses attack@hp_ratio × its max HP (a 流失 that may knock it out).
@@ -52,6 +55,7 @@
import { num, talentBb, traitBb, moduleOn, skillRec, up } from '../shared/tier1.js';
import { absoluteRangeKeys } from '../../../targeting.js';
import { COLS } from '../../../constants.js';
import { acquireTargets } from '../../../ai.js';
const S1 = 'skchr_kalts_1';
const S2 = 'skchr_kalts_2';
@@ -130,11 +134,12 @@ function mon3trKit(owner, { t0, b1, b2, b3 }) {
}
holdBuff(battle, m, 'kalts:s3:mon3tr', skillOn(owner, S3), { atkPct: s3Atk, defPct: num(b3['attack@def']) });
}, { owner: m });
// S2: each attack strikes every enemy it blocks
// S2: "Mon3tr可以攻击阻挡的所有敌人" — the 强攻手 rule (its mode's _limitedMaxTargetNumToBlockedCnt): up to its block count
// of targets, the blocked ones first (ai.js acquireTargets with `hitAllBlocked`)
battle.on('beforeAttack', (c) => {
if (c.attacker !== m || !skillOn(owner, S2)) return;
const blocked = battle.blockedTargets(m, c.profile);
if (blocked.length) c.targets = blocked;
if (c.attacker !== m || !skillOn(owner, S2) || !c.profile) return;
const t = acquireTargets(battle, m, { ...c.profile, hitAllBlocked: true });
if (t.length) c.targets = t;
}, { owner: m });
// S3: true damage; a kill keeps its HP
battle.on('hit', (c) => {
+14 -26
View File
@@ -27,8 +27,9 @@
// 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 ×
// chain is replaced: heal mode single + `chainFrom`, its links the profession's — ai.js chainHealNext): after the heal on
// the main target the heal jumps to the lowest-HP-ratio ally (a full-HP one too — PRTS 分支特性信息 链愈师; ties: the latest
// deployed) 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
@@ -54,8 +55,9 @@
// - 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
// damage_per_second HP/s (8 every 0.1 s); "同时攻击阻挡的所有敌人" (her selector's _limitedMaxTargetNumToBlockedCnt): each attack
// takes up to her block count of ground enemies of her range, the blocked ones first (ai.js `hitAllBlocked`, the 强攻手 rule
// of PRTS 分支特性信息) 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
@@ -63,6 +65,7 @@
import { num, talentBb, traitBb, skillRec, batMod, installAura, up } from '../shared/tier1.js';
import { COLS } from '../../../constants.js';
import { chainHealNext } from '../../../ai.js';
const S1 = 'skchr_monstr_1';
const S2 = 'skchr_monstr_2';
@@ -101,28 +104,13 @@ const prostsInRange = (battle, unit) => prostsOf(battle, unit).find((t) => up(t)
/** `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.
* amount. Each link is the 链愈师 one (ai.js chainHealNext — the 3×3 around the last one healed, the lowest HP ratio a
* full-HP ally included, then the latest deployed; no 禁疗 unit but her 重构体, her `healThrough`).
*/
function chainFrom(battle, unit, main, base, { count, step, extra, fullProsts = false, mainDone = false, selfFree = false }) {
function chainFrom(battle, unit, main, base, { count, step, extra, 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);
@@ -131,7 +119,7 @@ function chainFrom(battle, unit, main, base, { count, step, extra, fullProsts =
const seen = new Set([main.id]);
let cur = main;
while (counted < count) {
const next = nextLink(battle, unit, cur, seen, fullProsts);
const next = chainHealNext(battle, unit, cur, seen);
if (!next) break;
seen.add(next.id);
if (calm > 0) calm--; else amount *= step;
@@ -212,7 +200,7 @@ export default {
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 },
attack: { dmgType: 'true', attack: 'melee', projectile: 'none', canHitFly: false, groundOnly: true, hitAllBlocked: true },
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)
@@ -280,12 +268,12 @@ export default {
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 });
chainFrom(battle, unit, t, base, { ...chain, count: s1on ? s1Count : chain.count, 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) });
chainFrom(battle, unit, t, unit.s.atk * unit.s.atkScaleMul, chain);
}, { owner: unit });
}
}
+11 -4
View File
@@ -38,10 +38,10 @@
// each of its own deployments. When he leaves the field (knocked out, withdrawn, forced out) it is withdrawn (PRTS "强制
// 撤退"; the client's KillTokens); a twin off the field comes back on its tile — paying its cost (5 DP) as a deployment,
// the 卫戍 auto redeploy of a placed summon [ASSUMED, as 鸿雪's 打字机 / 凯尔希's Mon3tr] — once its redeploy time has
// passed since its LAST DEPLOYMENT (PRTS 备注: the timer starts at the summon's deployment) and only while he stands (it
// is recharged by his deployment, withdrawn by his leaving) [ASSUMED: his redeployment does not cut the twin's own timer
// short — the client's RechargeToken NORMAL; 虚影精通 / EXE-X would matter little otherwise; the 凯尔希 kit reads the same
// charge_token[born] as "readies at once"]. EXE-Y stage 2+ "本体和虚影同时在场时,攻击力各+10%": the twin's hidden module
// passed since its LAST DEPLOYMENT (PRTS 备注: the timer starts at the summon's deployment; the twin's charge_token[finish]
// recharges it ON_FINISH) and only while he stands; his (re)deployment readies a waiting twin at once (his
// charge_token[born]: RechargeToken with timing NORMAL — read the same way for every summoner, 凯尔希's Mon3tr included;
// until 0.2.0 this kit let the twin's own timer run on). EXE-Y stage 2+ "本体和虚影同时在场时,攻击力各+10%": the twin's hidden module
// talent `atk` — ATK +atk on both while both are on the field.
// - T2 虚影精通 "虚影的再部署时间-10秒" (EXE-X stage 3: −16): the twin's own talent respawn_time, added to its 45 s.
// - S1 暗夜魅影 (被动, at each deployment): physical dodge `prob` and a 屏障 of hp_ratio × max HP that absorbs physical damage
@@ -210,6 +210,13 @@ export default {
// "傀影退场时强制撤退场上的镜中虚影" (KillTokens): a withdrawal, not a knock-out
for (const t of twinsOf(battle, unit)) if (t.alive) battle.retreat(t, { reason: 'retreat' });
}, { owner: unit });
// charge_token[born] (RechargeToken, timing NORMAL): his (re)deployment readies a waiting twin at once — the
// twin's own timer is the ON_FINISH recharge of its charge_token[finish] (as every summoner kit reads it)
// [ASSUMED: NORMAL = usable now, from the enum and the first deployment]
battle.on('deploy', (c) => {
if (c.unit !== unit || c.move) return;
for (const t of twinsOf(battle, unit)) if (!t.alive && !t.removed) t.mem.twinReadyAt = battle.time;
}, { owner: unit });
// 本体和虚影同时在场时,攻击力各+N%: the twin's hidden module talent (his text when no twin def resolves)
const both = num(tokTalentBb(battle.tokenDef(TWIN, unit), 'atk').atk, /本体和虚影同时在场时/.test(t0desc)
? num(+(/攻击力各\+(\d+)%/.exec(t0desc)?.[1]) / 100) : 0);
+3 -2
View File
@@ -10,7 +10,8 @@
// - Trait (医师) "恢复友方单位生命": the profession default (one heal on the lowest-HP% injured ally of her 3-3 range).
// Module PHY-Y 干枯剑鞘 "治疗地面单位时治疗量提升15%" (trait bb heal_scale): ×heal_scale on a heal whose target stands on
// a 地面 tile (unit.ground, as 赫默's PHY-Y); PHY-X “使徒” "治疗生命值低于50%的友方单位时治疗量提升15%" (heal_scale,
// hp_ratio): ×heal_scale when the target is below hp_ratio of its max HP before the heal (as 录武官's / 华法琳's PHY-X).
// hp_ratio): ×heal_scale when the target is at or below hp_ratio of its max HP before the heal — the client's
// shining_e_003_tr is heal_scale_up[hpratio][LE] (as 华法琳's PHY-X; 录武官's reckpr_e_002_tr is LT, strictly below).
// - T1 黑恶魔的庇护 "攻击范围内的友方单位防御力+60": every ally (operators and summons: 友方单位) standing in her current
// attack range — her own tile included — DEF +def (flat). PHY-Y stage 2+ "防御力+100,地面单位防御力额外+40" (talent
// change def / def_lowland): def_lowland more on a 地面 tile. Refreshed every AURA s; two sources keep the strongest.
@@ -130,7 +131,7 @@ export default {
// module traits: PHY-Y heals on 地面 units, PHY-X heals on allies below hp_ratio, ×heal_scale
const hs = num(tb.heal_scale, 1);
if (!mod || hs === 1) return;
const boosted = mod === PHY_X ? (t) => t.hpRatio < num(tb.hp_ratio) : mod === PHY_Y ? (t) => !!t.ground : null;
const boosted = mod === PHY_X ? (t) => t.hpRatio <= num(tb.hp_ratio) + 1e-9 : mod === PHY_Y ? (t) => !!t.ground : null;
if (!boosted) return;
battle.on('heal', (ctx) => {
if (ctx.source !== unit || ctx.opts?.regen || !ctx.target || !boosted(ctx.target)) return;
+14 -18
View File
@@ -9,9 +9,9 @@
// - Trait (秘术师) "攻击造成法术伤害,在找不到攻击目标时可以将攻击能量储存起来之后一齐发射" (ranged arts, hits air units,
// targetable by ground enemies). PRTS 分支特性信息 秘术师: "能量储存与攻击占用相同的攻击间隔:在进行攻击判定时,若范围内存在
// 有效目标,则进行普通攻击;若无有效目标且能量储存数未满,则改为储存一份攻击能量(属于攻击行为)" — when her attack is ready
// and she has no valid target she stores one energy and her attack interval starts again (this kit replaces the
// engine's mystic profile, which stores one interval after the attack is ready); the energies fly with her next attack,
// at its target ("由储存能量形成的弹道造成攻击力100%的法术普通伤害").
// and she has no valid target she stores one energy and her attack interval starts again (the shared 秘术师 profile,
// professions.js installMystic, with this kit's `storeEnergy`); the energies fly with her next attack, at its target
// ("由储存能量形成的弹道造成攻击力100%的法术普通伤害").
// T1 “在挥刀之前” "特性储存的能量达到3个时,合成为1个转置能量(至多储存等同于9倍的能量)" (trait bb merge_cnt 3, times 9):
// 3 stored make 1 转置能量, at most times / merge_cnt of those, then no more storing (备注 "转置能量数量达到上限时,停止储存
// 攻击能量"); a 转置能量 hits for merge_cnt × ATK (备注 "攻击力300%的法术普通伤害"). Every shot uses the ATK it left with
@@ -39,7 +39,7 @@
// preferring an enemy not hit yet by it, else another one, else the same (text "优先不同目标" [ASSUMED order]), each
// attack@bounce_atk_scale × the cached ATK arts. The skill ends when the bullets run out.
import { num, talentBb, moduleBb, traitBb, skillRec, batMod, up, toggleBuff } from '../shared/tier1.js';
import { num, talentBb, moduleBb, traitBb, skillRec, batMod, toggleBuff } from '../shared/tier1.js';
import { canTargetEnemy, sortEnemyTargets } from '../../../targeting.js';
const S1 = 'skchr_veen_1';
@@ -146,23 +146,19 @@ export default {
trait: {
hitsFn: () => 0, // the volley's hits are the kit's (land)
onEachHit(battle, unit, victim, hc) { if (hc.kind === 'main' && victim && victim.side === 'enemy') land(battle, unit, victim, hc.attackId, !!hc.isSkill); },
// only enemies under her 战争技艺 in range: she holds her fire (and stores energy)
canAttack(battle, u) {
const ok = validTargets(battle, u, u.profile).length > 0;
if (!ok) u.trait.hadTarget = false;
return ok;
// only enemies under her 战争技艺 in range: no valid target — she holds her fire (and stores energy)
canAttack(battle, u) { return validTargets(battle, u, u.profile).length > 0; },
// the mystic store (the shared profile calls it at her attack check with no valid target — professions.js
// installMystic: an attack action, the interval restarts): one energy, 3 merge into a 转置能量; none once the
// 转置能量 are at their cap (a full store: she idles)
storeEnergy(battle, unit) {
const v = stateOf(unit);
if (v.t >= tMax) return false;
if (++v.s >= merge) { v.s -= merge; v.t++; }
return true;
},
install(battle, unit) {
battle.on('deploy', (ctx) => { if (ctx.unit === unit) unit.trait.veen = null; }, { owner: unit });
// the mystic store: her attack ready and no valid target ⇒ one energy, an attack action (the interval restarts);
// 3 merge into a 转置能量 (the attack loop ran first this tick: a valid target was attacked instead)
battle.on('tick', () => {
const v = stateOf(unit);
if (!up(unit) || !unit.canAct || unit.s.flags.disarm || unit.atkCd > 0 || v.t >= tMax) return;
if (validTargets(battle, unit, unit.profile).length) return;
if (++v.s >= merge) { v.s -= merge; v.t++; }
unit.atkCd = unit.s.interval;
}, { owner: unit });
battle.on('death', (ctx) => { if (ctx.unit && ctx.unit.side === 'enemy') stateOf(unit).q.delete(ctx.unit.id); }, { owner: unit });
},
},
+27 -13
View File
@@ -9,8 +9,8 @@
// `[shield_core]`, `zuole_s_3` / `[shield]`, `zuole_e_002[resistance]`, `zuole_e_003_talent`, `zuole_e_003_trait`);
// Arknights Terra Wiki "Zuo Le" (the barrier caps count each skill's own barrier only).
// - Trait (武者) "不成为其他角色的治疗目标,每次攻击到敌人后回复自身70生命": the profession default (musha: no heal from
// others, `value` per enemy hit; zuole_trait heals on every damage he outputs — the profession heals a normal attack
// per target and any other damage per instance, so S1's extra strikes are separate damage instances).
// others; zuole_trait heals on every damage he outputs — the profession heals `value` per damage instance, PRTS 分支特性信息
// 武者, so S1's extra strikes heal too).
// - T1 秉烛照影 "在场时,自身获得最高+50攻击速度和技力自然回复速度+2/秒的坚忍(损失70%生命值时达到最大加成)" (bb
// min_attack_speed / min_sp_recovery_per_sec / min_hp_ratio): 坚忍 (ba.berserk "根据已损失的生命值获得相应比例的属性
// 加成") — ASPD and SP recovery × min(1, lost HP ÷ (1 − min_hp_ratio)) while he is deployed. SBL-X stage 3: +70 /
@@ -20,7 +20,8 @@
// (zuole_e_003_talent, ON_CALCULATE_DAMAGE): "…且当次攻击的攻击力提升至120%…80%": a success also scales that damage
// instance by atk_scale.
// - Module SBL-X “岂苦夜长” (trait moduleDesc "生命值低于50%时,获得25%的庇护"; the hidden part merged into the first talent's
// bb: hp_ratio / damage_resistance): 庇护 (ba.protect "受到的物理和法术伤害降低相应比例") while HP < hp_ratio
// bb: hp_ratio / damage_resistance): 庇护 (ba.protect "受到的物理和法术伤害降低相应比例(同名效果取最高)": the shared effect of
// every source, tier1.js holdProtect) while HP < hp_ratio
// (zuole_e_002[resistance]: the 庇护 buff is created when HP drops below it and finished when it is back). PRTS S2 备注:
// 行险 resets it, it comes back 0.1 s later "并因此导致庇护的BUFF顺序落后于本技能的屏障" — a 庇护 created after his
// barrier cuts only what passes the barrier (the `hpDamage` hook), one created before it cuts the hit first.
@@ -29,7 +30,8 @@
// not while a 不死 already holds him ("_dontConsumeWhenUndeadable": 坚固维式重锤's window, items/battle.js holdsUndying).
// - S1 破虏 (AUTO, 可充能 2 次): the next attack at atk_scale × ATK; "自身生命低于80%时额外攻击1次,低于50%时额外攻击2次"
// (hp_ratio_double / hp_ratio_tripple): one / two more strikes on that target at the same scale, separate damage
// instances [ASSUMED: the same target — nothing when it fell]. The data's DEFAULT trigger (an AUTO "next attack").
// instances [ASSUMED: the same target — nothing when it fell; the HP read when the skill fires, before the strike's
// own trait heal]. The data's DEFAULT trigger (an AUTO "next attack").
// - S2 行险 (MANUAL, 12 s): "立即流失50%当前生命" (zuole_s_2[shield] DamageViaCurHpRatio, undeadable: never below 1 HP;
// a 流失), then a barrier of scale × max HP (ba.barrier "可以吸收一定数值的伤害") added to the one he still has, capped at
// max_scale × max HP at each gain (PRTS "上限于每次获取屏障时实时发生变化"; Terra: the cap counts this skill's barrier
@@ -45,7 +47,7 @@
// at max_scale × max HP at each gain, lasting shield_duration s from the latest gain (PRTS "每次获取屏障时重置剩余持续
// 时间为15s").
import { num, talentBb, traitBb, up, onHitBy, onHitOn, giveSp, skillRec } from '../shared/tier1.js';
import { num, talentBb, traitBb, up, onHitBy, onHitOn, giveSp, skillRec, holdProtect, PROTECT, PROTECT_TICK_HOLD } from '../shared/tier1.js';
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
import { isHpLoss } from '../../../damage.js';
import { holdsUndying } from '../../items/battle.js';
@@ -96,10 +98,14 @@ export default {
skills: {
[S1]: {
kind: num(s1?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
// the HP that decides the extra strikes: when the skill fires, before its strike lands (the trait heals at each
// damage instance — professions.js musha — so after the first strike he would read healed) [ASSUMED]
onStart({ unit }) { unit.mem.zuoleS1Hp = unit.hpRatio; },
attack: {
atkScale: num(b1.atk_scale, 1),
onHit({ battle, unit, target }) {
const r = unit.hpRatio;
const r = unit.mem.zuoleS1Hp ?? unit.hpRatio;
unit.mem.zuoleS1Hp = null;
const extra = (r < num(b1.hp_ratio_double, 0.8) ? 1 : 0) + (r < num(b1.hp_ratio_tripple, 0.5) ? 1 : 0);
for (let i = 0; i < extra && target && target.alive; i++) {
battle.dealDamage(unit, target, { amount: unit.s.atk * num(b1.atk_scale, 1), type: 'phys', isSkill: true, tags: ['skill'] });
@@ -123,9 +129,12 @@ export default {
data: { createdAt: battle.time } });
}
battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id });
// SBL-X: 行险 resets the 庇护, which is judged again PROTECT_RESET s later (after the barrier)
// SBL-X: 行险 resets the 庇护, which is judged again PROTECT_RESET s later (after the barrier) — his own held 庇护
// goes now (another source's comes back with its next refresh)
P.since = null;
P.blockedUntil = battle.time + PROTECT_RESET;
const held = unit.findBuff(PROTECT);
if (held && held.source === unit) battle.removeBuff(unit, held);
},
},
[S3]: {
@@ -197,8 +206,10 @@ export default {
if (!unit.mem.zuoleS3 || c.source !== unit || !c.target || c.target.side !== 'enemy' || isHpLoss(c.dmg) || c.type === 'element') return;
gainS3Barrier(battle, unit);
}, { owner: unit });
// SBL-X 庇护: active while HP < protectRatio, judged again PROTECT_RESET s after 行险
if (protectCut > 0) {
// SBL-X 庇护: active while HP < protectRatio, judged again PROTECT_RESET s after 行险 — the shared 庇护 (holdProtect: the
// strongest of every source holds), refreshed every tick and at each hit on him. When the 庇护 held is his own and his
// barrier is older than it, his cut comes after the barrier (the hpDamage hook) instead of before it
if (protectCut > 0 && protectCut < 1) {
const active = () => {
const now = battle.time;
if (!up(unit) || now < P.blockedUntil - 1e-9) { P.since = null; return false; }
@@ -206,13 +217,16 @@ export default {
P.since = null;
return false;
};
const keep = () => { if (active()) holdProtect(battle, unit, protectCut, PROTECT_TICK_HOLD, unit); };
const own = () => { const b = unit.findBuff(PROTECT); return !!b && b.source === unit && b.data?.value === protectCut; };
const afterBarrier = new WeakSet();
battle.on('tick', active, { owner: unit });
battle.on('tick', keep, { owner: unit });
onHitOn(battle, unit, ({ dmg }) => {
if ((dmg.type !== 'phys' && dmg.type !== 'arts') || !active()) return;
keep();
if ((dmg.type !== 'phys' && dmg.type !== 'arts') || P.since == null || !own()) return;
const bar = barrierOf(unit);
if (bar && bar.shield > 0 && (bar.data?.createdAt ?? Infinity) < P.since) afterBarrier.add(dmg);
else dmg.mul *= 1 - protectCut;
// (the held buff's mods cut this hit before the barrier: undo that and cut what passes the barrier instead)
if (bar && bar.shield > 0 && (bar.data?.createdAt ?? Infinity) < P.since) { dmg.mul /= 1 - protectCut; afterBarrier.add(dmg); }
});
battle.on('hpDamage', (c) => { if (c.target === unit && afterBarrier.has(c.dmg)) c.amount *= 1 - protectCut; }, { owner: unit });
}
@@ -25,7 +25,7 @@ export default {
// Talents 攫升 / 超脱: content/tokens.js mapCharTalents (the strategy's Touch too); the module's 超脱 upgrade (8 SP at
// module Lv3) comes with the record's talents.
// Module PHY-X (elite, trait addition): "治疗生命值低于50%的友方单位时治疗量提升15%" — every heal of hers on an ally
// below `hp_ratio` HP (strictly: "低于") ×`heal_scale` (the trait blackboard). With S3's ×1.35 / ×1.40 it
// below `hp_ratio` HP (strictly: "低于"; the client's acmedc_e_tr filters LT) ×`heal_scale` (the trait blackboard). With S3's ×1.35 / ×1.40 it
// multiplies (×1.61 at Lv7): both are `heal_scale` blackboards, the 治疗倍率, and PRTS 游戏数据基础 §倍率 says "同种
// 倍率间叠乘" — no Touch-specific note or buff template exists. S3's extra heal is a heal of hers like any other: its
// recipient below `hp_ratio` gets the ×1.15 too (its base is taken before either bonus).
+12 -7
View File
@@ -12,8 +12,10 @@ import { bodyInKeys } from '../../../body.js';
const S1 = 'skchr_acspec_1';
const S2 = 'skchr_acspec_2';
const S3 = 'skchr_acspec_3';
const S1_KEY = 'acspec:s1';
const PULL_TAG = 'acspec:pull';
/** A 被动 ON_DEPLOY skill with a duration: a duration skill every deployment starts (no SP, never cast otherwise). */
const ON_DEPLOY = Object.freeze({ kind: 'duration', activateOnDeploy: true, spCost: 0, spType: 'none', trigger: 'NEVER' });
const s1On = (unit) => !!(unit.skill && unit.skill.active && unit.skill.id === S1);
/**
* 四维分离's "周围四格": her tile and the four tiles beside it [ASSUMED: her own tile counts — an enemy she blocks stands
* on it, and the talent is about her being alone with one enemy].
@@ -63,9 +65,12 @@ export default {
// attack@prob (PRTS 备注: it is her normal attack's basic logic; EXE-X levels 2 / 3 make it +atk ATK and a higher
// chance). Talent 2 四维分离: ATK +atk while exactly `cnt` enemy stands on her tile or the four beside it.
// All three skills are 被动, ON_DEPLOY — they start at every deployment (the battle-start one included, when no enemy
// is on the field yet: S3 then hits nothing, as in the official mode; it matters on a redeploy):
// S1 物理的服从: for the skill's duration (10 s) the talent-1 chance is attack@prob and physical dodge prob.
// S2 战争的恭顺: for the skill's duration (10 s) ATK +atk, ASPD +attack_speed.
// is on the field yet: S3 then hits nothing, as in the official mode; it matters on a redeploy). S1 / S2 last the
// skill's duration (10 s): duration skills started by the deployment (`activateOnDeploy`, the deploy-timed skill
// contract of PR #109 — their own skillStart / skillEnd and the client's skill on / off), not a passive holding a timed
// buff:
// S1 物理的服从: while it runs the talent-1 chance is attack@prob and physical dodge prob.
// S2 战争的恭顺: while it runs ATK +atk, ASPD +attack_speed.
// S3 空间的归依: spaceBurst above (ground only), right after each deployment (install).
// Module EXE-X (trait atk): ATK +atk while no ally stands on the four tiles beside her (PRTS: unlike the usual EXE-X;
// shared/tier4.js lonely, as 缄默德克萨斯's module of the same text).
@@ -81,11 +86,11 @@ export default {
const cnt = Math.max(1, Math.floor(num(t1.cnt, 1)));
return {
skills: {
[S1]: { kind: 'passive', onStart({ battle, unit }) { battle.addBuff(unit, { key: S1_KEY, duration: num(r1?.duration, 10), mods: { dodgePhys: num(b1.prob) }, tags: ['skill'] }); } },
[S2]: { kind: 'passive', onStart({ battle, unit }) { battle.addBuff(unit, { key: 'acspec:s2', duration: num(r2?.duration, 10), mods: { atkPct: num(b2.atk), aspd: num(b2.attack_speed) }, tags: ['skill'] }); } },
[S1]: { ...ON_DEPLOY, duration: num(r1?.duration, 10), mods: { dodgePhys: num(b1.prob) } },
[S2]: { ...ON_DEPLOY, duration: num(r2?.duration, 10), mods: { atkPct: num(b2.atk), aspd: num(b2.attack_speed) } },
[S3]: { kind: 'passive' },
},
trait: { hitsFn: (battle, unit) => (battle.rng.chance(unit.findBuff(S1_KEY) ? s1Prob : talentProb) ? 2 : 1) },
trait: { hitsFn: (battle, unit) => (battle.rng.chance(s1On(unit) ? s1Prob : talentProb) ? 2 : 1) },
talents: [
{ install(battle, unit) { statBuff(battle, unit, 'acspec:t1', { atkPct: num(t0.atk) }); } },
{ install(battle, unit) { toggleBuff(battle, unit, 'acspec:t2', () => enemiesAround4(battle, unit) === cnt, { atkPct: num(t1.atk) }); } },
+16 -1
View File
@@ -27,7 +27,7 @@
// counter {id} · crit {id} · dp {n, id} · heal {id} · taunt {id} · summon {id, token} · pull {id} · sonic {radius} ·
// shield {id} · overload {id} · takeoff {id} · sleep {id} · buff {id, kind} · reveal {id} · dodge (engine kind).
import { COLS } from '../../../constants.js';
import { COLS, TICK } from '../../../constants.js';
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
import { offsetTile } from '../../../dir.js';
import { bodyInKeys, bodyTileReach } from '../../../body.js';
@@ -94,6 +94,21 @@ export function onDamagedOn(battle, unit, fn, priority = 0) {
return battle.on('damaged', (ctx) => { if (ctx.target === unit) fn(ctx); }, { owner: unit, priority });
}
/**
* 庇护 (gamedata_const ba.protect "受到的物理和法术伤害降低相应比例(同名效果取最高)"; PRTS 术语释义 庇护): ONE effect per unit
* whoever grants it — battle.applyStrongest under this key, the strongest value holds and a weaker one resumes when it
* outlasts it. (遥's bubbles are a separate key, the client's damage_resistance[bonus] — they multiply with it.)
*/
export const PROTECT = 'protect';
/** The mods of a 庇护 value: physical and arts damage taken ×(1 − v). */
export const protectMods = (v) => ({ physTakenMul: 1 - v, artsTakenMul: 1 - v });
/** A hold that outlives one tick and lapses in the next: refreshed every tick while its condition lasts. */
export const PROTECT_TICK_HOLD = 1.5 * TICK;
/** Hold 庇护 `value` on `target` for `duration` s (the shared PROTECT effect). */
export function holdProtect(battle, target, value, duration, source = null) {
if (value > 0) battle.applyStrongest(target, PROTECT, { duration, value, mods: protectMods, source });
}
/** Targetable enemies inside `grid` (relative to the unit; null ⇒ current range), best targets first. */
export function enemiesInGrid(battle, unit, grid, { n = 0, priority = null, canHitFly = true, groundOnly = false } = {}) {
const keys = grid ? absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0) : unit.rangeKeys;
+8 -2
View File
@@ -146,10 +146,16 @@ function pullToFront(battle, unit, e, force) {
}
/** Physician module trait (录武官, 华法琳 elites): heals on allies below hp_ratio are ×heal_scale. */
function installLowHpHealBonus(battle, unit, tb) {
/**
* The module trait "治疗生命值低于50%的友方单位时治疗量提升15%" (heal_scale, hp_ratio): the client's module buff filters the
* target's HP before the heal with its own comparison — `atOrBelow` for the template heal_scale_up[hpratio][LE] (华法琳's
* PHY-X), strictly below otherwise (录武官's reckpr_e_002_tr: LT). [ASSUMED as before: not on herself]
*/
function installLowHpHealBonus(battle, unit, tb, { atOrBelow = false } = {}) {
const s = num(tb.heal_scale, 0), r = num(tb.hp_ratio, 0);
if (!(s > 0) || !(r > 0)) return;
battle.on('heal', (c) => { if (c.source === unit && c.target !== unit && c.target.hpRatio < r) c.amount *= s; }, { owner: unit });
const low = atOrBelow ? (t) => t.hpRatio <= r + 1e-9 : (t) => t.hpRatio < r;
battle.on('heal', (c) => { if (c.source === unit && c.target !== unit && low(c.target)) c.amount *= s; }, { owner: unit });
}
// DESIGN §5.6 documents `(bb, chess)`; content/index.js also passes the normalised def — rebuild it when absent.
+8 -3
View File
@@ -150,13 +150,18 @@ function burstDamageUp(battle, unit, mul) {
}, { owner: unit });
}
/** Modules PHY-X / GUA-X: heals on allies below hp_ratio ×heal_scale. */
function lowHpHealUp(battle, unit, tb) {
/**
* Modules PHY-X / GUA-X "治疗生命值低于50%的友方单位时治疗量提升15%": heals on allies below hp_ratio ×heal_scale. The client's
* module buff picks its comparison: `atOrBelow` for heal_scale_up[hpratio][LE] (华法琳's PHY-X), strictly below for 塞雷娅's
* GUA-X (set_heal_scale_by_hpratio: LT) and 录武官's PHY-X (reckpr_e_002_tr: LT).
*/
function lowHpHealUp(battle, unit, tb, { atOrBelow = false } = {}) {
const mul = num(tb.heal_scale), thr = num(tb.hp_ratio);
if (!(mul > 1) || !(thr > 0)) return;
const low = atOrBelow ? (t) => t.hpRatio <= thr + 1e-9 : (t) => t.hpRatio < thr;
battle.on('heal', (c) => {
if (c.source !== unit || c.opts?.regen || !c.target || c.target.side !== 'ally') return;
if (c.target.hpRatio < thr) c.amount *= mul;
if (low(c.target)) c.amount *= mul;
}, { owner: unit });
}
+67 -29
View File
@@ -8,7 +8,8 @@
// projectile 'none'|'beam'|'arrow'|'bolt'|'bomb'|'lob'|'orb'|'drone'|'boomerang' ('beam': an instant hit drawn as a
// line; 'boomerang': out to the target and back to the thrower, ai.js throwBoomerang)
// boomerang bool (回环射手: keeps 'boomerang')
// canHitFly bool maxTargets n (≥1) hitAllBlocked bool (attack every blocked enemy)
// canHitFly bool maxTargets n (≥1) hitAllBlocked bool ("同时攻击阻挡的所有敌人": up to the block count of
// targets, blocked ones first — ai.js targetCount)
// allInRange bool (every enemy on the range at once) splashRadius tiles (around the struck target)
// rangeAoe bool (a 锁定攻击范围 AoE without a projectile — SUB table or kit trait, applied by resolveProfile after
// every override: allInRange + instant 'beam' hits on a ranged profile; only selectable enemies are
@@ -24,7 +25,10 @@
// skipEnemy(enemy) → bool (an enemy the unit never selects — targeting.js canTargetEnemy; 嵯峨 "不攻击重伤单位")
// healThrough(healer, ally) → bool (a healer that selects and heals that ally through its 禁疗 — Battle
// injuredAlliesInKeys, damage.js heal; 凯尔希 on her Mon3tr)
// hitsFn(battle, unit) → n install(battle, unit) — per-unit hooks, called once at setup
// hitsFn(battle, unit, info) → n (info: the hit's { isSkill, index, attackId, energy })
// install(battle, unit) — per-unit hooks, called once at setup
// storeEnergy(battle, unit) → bool / releaseEnergy(battle, unit) → n (秘术师: the attack check found no valid target ⇒
// store one energy, false when full; the energies leaving with an attack — installMystic)
// dollNoAttack bool (傀儡师: its <替身> makes no normal attack and casts no skill — 归溟幽灵鲨, kit trait)
// tb — the unit's trait blackboard (data `trait.bb`), used for tunables (module upgrades included on elites)
// Behaviour per subprofession is documented in docs/SIM.md §Professions. Front / side tests use the unit's direction
@@ -55,26 +59,46 @@ export const PROFESSION_DEFAULTS = Object.freeze({
// install helpers (per-unit hooks). All use engine helpers only; tunables come from unit.profile.
/**
* 武者 / 收割者 self-heal ("每次攻击到敌人回复自身50生命"; 收割者 adds "最大生效数等于阻挡数"). Normal attacks arrive as one
* 'attack' event whose `targets` are every enemy hit.
* 武者 (musha) trait "每次攻击到敌人后回复自身N生命" (bb value) with the branch rules of PRTS 分支特性信息 武者: "特性治疗于干员每次
* 输出伤害时触发(不局限于攻击)" and "常态持有禁疗;通过自身特性/天赋/技能产生的作用于自身的治疗效果会无视自身的禁疗". The official
* trait buffs (buff_template_data `utage_trait`, `helage_trait`, `zuole_trait`) fire on ON_OUTPUT_DAMAGE: one heal per damage
* instance the unit deals to an enemy — every hit of a normal attack (the second hit of a double strike, a splash or
* chain victim) and every skill / item / bond damage instance credited to it; a dodged hit deals none (no `damaged`).
* Not a 流失 (no damage dealt), not an element 损伤 (a gauge fill, no HP damage — as 咒愈师), not a buff-made damage
* (talent / DoT tags: attackType BUFF — [ASSUMED] as before; only 隐德来希's template filters it and no 武者 kit deals such
* damage). The heal ignores 禁疗 (`ignoreHealFree`: an abnormal flag is one switch whoever set it — PRTS 异常效果).
*/
const installMushaHeal = (battle, unit) => {
battle.on('damaged', (c) => {
if (c.source !== unit || !unit.alive || !c.target || c.target.side !== 'enemy') return;
const dmg = c.dmg;
if (!dmg || isHpLoss(dmg) || c.type === 'element') return;
const tags = dmg.tags || [];
if (tags.includes('talent') || tags.includes('dot') || tags.includes('periodic')) return;
battle.heal(unit, unit, unit.profile.selfHeal ?? 50, { self: true, ignoreHealFree: true });
}, { owner: unit, priority: -10 });
};
/**
* 收割者 (reaper) self-heal ("每次攻击到敌人回复自身50生命,最大生效数等于阻挡数"). Normal attacks arrive as one 'attack' event
* whose `targets` are every enemy hit.
*
* The official trait is a buff ON the operator that fires on ON_OUTPUT_DAMAGE — buff_template_data `etlchi_trait`,
* `excu2_trait`, `utage_trait`, `helage_trait`, `zuole_trait` all react to any damage the unit outputs, and 隐德来希's
* filters the attackType BUFF out (damage produced by a buff/talent — e.g. her own 萃血 DoT). So skill damage heals too:
* 隐德来希's S2 blood sickles restore her life although the skill stops her attacks (`attack: { noAttack: true }`) — the
* sickle's AOEDamage nodes are attackType NORMAL and PRTS 备注 says "伤害来源始终视为隐德来希". Every such hit arrives as
* one 'damaged' event per enemy, so the reaper cap ("最大生效数") is applied per instant here: the official `[heal_fake]`
* window is 0.05 s and its stack count is the block number (`SetStackCountViaBlockNum`). [ASSUMED] the sim uses the same
* `battle.time` as its window, so simultaneous hits (both 血镰, an AoE) share the block-count cap; a normal attack has its
* own event and its own cap, as before.
* `excu2_trait` react to any damage the unit outputs, and 隐德来希's filters the attackType BUFF out (damage produced by a
* buff/talent — e.g. her own 萃血 DoT). So skill damage heals too: 隐德来希's S2 blood sickles restore her life although the
* skill stops her attacks (`attack: { noAttack: true }`) — the sickle's AOEDamage nodes are attackType NORMAL and PRTS 备注
* says "伤害来源始终视为隐德来希". Every such hit arrives as one 'damaged' event per enemy, so the reaper cap ("最大生效数") is
* applied per instant here: the official `[heal_fake]` window is 0.05 s and its stack count is the block number
* (`SetStackCountViaBlockNum`; PRTS 分支特性信息 收割者 "触发后的0.05s内最多只能累加等于自身阻挡数的治疗次数"). [ASSUMED] the sim uses
* the same `battle.time` as its window, so simultaneous hits (both 血镰, an AoE) share the block-count cap; a normal attack
* has its own event and its own cap, as before. Like the 武者's, the heal ignores 禁疗 (PRTS 分支特性信息 收割者 "通过自身特性/
* 天赋/技能产生的作用于自身的治疗效果会无视自身的禁疗").
*/
const installSelfHealOnHit = (capByBlock) => (battle, unit) => {
const heal = (n) => { if (n > 0 && unit.alive) battle.heal(unit, unit, (unit.profile.selfHeal ?? 50) * n, { self: true }); };
const installReaperHeal = (battle, unit) => {
const heal = (n) => { if (n > 0 && unit.alive) battle.heal(unit, unit, (unit.profile.selfHeal ?? 50) * n, { self: true, ignoreHealFree: true }); };
battle.on('attack', (ctx) => {
if (ctx.attacker !== unit || !unit.alive) return;
let n = ctx.targets.length;
if (capByBlock) n = Math.min(n, Math.max(1, unit.s.blockCnt));
heal(n);
heal(Math.min(ctx.targets.length, Math.max(1, unit.s.blockCnt)));
}, { owner: unit, priority: -10 });
battle.on('damaged', (c) => {
if (c.source !== unit || !unit.alive || !c.target || c.target.side !== 'enemy') return;
@@ -84,7 +108,7 @@ const installSelfHealOnHit = (capByBlock) => (battle, unit) => {
if (tags.includes('talent') || tags.includes('dot') || tags.includes('periodic')) return; // attackType BUFF
const mem = unit.mem;
if (mem.selfHealAt !== battle.time) { mem.selfHealAt = battle.time; mem.selfHealN = 0; }
if (capByBlock && mem.selfHealN >= Math.max(1, unit.s.blockCnt)) return;
if (mem.selfHealN >= Math.max(1, unit.s.blockCnt)) return;
mem.selfHealN++;
heal(1);
}, { owner: unit, priority: -10 });
@@ -362,16 +386,23 @@ const installFunnel = (battle, unit) => {
unit.trait.funnelScale = unit.profile.funnel?.init ?? 0.2;
};
/**
* 秘术师 (mystic) trait "攻击造成法术伤害,在找不到攻击目标时可以将攻击能量储存起来之后一齐发射(最多3个)" (bb times; 深靛's
* MSC-X 4) with the branch rules of PRTS 分支特性信息 秘术师: "能量储存与攻击占用相同的攻击间隔:在进行攻击判定时,若范围内存在
* 有效目标,则进行普通攻击;若无有效目标且能量储存数未满,则改为储存一份攻击能量(属于攻击行为);仅没有有效目标,且能量储存数已满的
* 情况下才进入待机状态". The attack loop (ai.js updateAlly) calls the profile's `storeEnergy` at its attack check — the attack
* ready, the unit able to act and not disarmed (PRTS 异常效果 缴械 "秘术师储存能量同样无法进行") — when it finds no valid target
* (no target, or the profile's `canAttack` false: a 秘术师 kit's own target rule — 深靛 never picks a bound enemy, so a bind
* on her only target is no valid target); a stored energy restarts the attack interval, a full store idles (the next
* valid target is attacked at once). The energies leave with the next attack that happens (`releaseEnergy`, called by
* performAttack after `beforeAttack`: "攻击被打断且弹道未能成功生成的情况下,储存的能量不会被消耗") with its main target and land
* with its main hit, one damage instance each (`hitsFn`; "由储存能量形成的弹道造成攻击力100%的法术普通伤害" — each as the
* main hit). [ASSUMED] a redeployment holds no energy (a free move — 乌尔比安 S3 — keeps them). Kits with their own store
* (维伊's 转置能量, 黑键's elite energies) replace `storeEnergy` and release theirs from an `attack` hook.
*/
const installMystic = (battle, unit) => {
unit.trait.stored = 0;
const max = unit.profile.storeMax ?? 3;
battle.on('tick', () => {
if (!unit.canAct) return;
if (unit.atkCd <= 0 && !unit.trait.hadTarget && unit.trait.stored < max) {
unit.trait.storeAcc = (unit.trait.storeAcc ?? 0) + battle.dt;
if (unit.trait.storeAcc >= unit.s.interval) { unit.trait.storeAcc = 0; unit.trait.stored++; }
}
}, { owner: unit });
battle.on('deploy', (ctx) => { if (ctx.unit === unit && !ctx.move) unit.trait.stored = 0; }, { owner: unit });
};
/** Refresh period / lifetime (s) of a bard trait's 生命回复速度 buff: it lapses within BARD_REGEN_DUR once the bard stops. */
@@ -480,7 +511,14 @@ export const SUB = Object.freeze({
return unit.trait.funnelScale;
} }),
mystic: P({ install: installMystic,
hitsFn: (battle, unit) => { const n = 1 + (unit.trait.stored ?? 0); unit.trait.stored = 0; return n; } }),
storeEnergy: (battle, unit) => {
const n = unit.trait.stored ?? 0;
if (n >= (unit.profile.storeMax ?? 3)) return false;
unit.trait.stored = n + 1;
return true;
},
releaseEnergy: (battle, unit) => { const n = unit.trait.stored ?? 0; unit.trait.stored = 0; return n; },
hitsFn: (battle, unit, info) => 1 + (info?.energy ?? 0) }),
phalanx: P({ noAttackUnlessSkill: true, rangeAoe: true, install: installPhalanx }),
primcaster: P({}),
corecaster: P({}),
@@ -520,8 +558,8 @@ export const SUB = Object.freeze({
const [fr, fc] = frontOf(unit.tileR, unit.tileC, unit.dir);
return bodyOnTile(target, unit.tileR, unit.tileC) || bodyOnTile(target, fr, fc) ? 1 : (unit.profile.rangedScale ?? 0.8);
} }),
musha: P({ noHeal: true, install: installSelfHealOnHit(false) }),
reaper: P({ noHeal: true, allInRange: true, install: installSelfHealOnHit(true) }),
musha: P({ noHeal: true, install: installMushaHeal }),
reaper: P({ noHeal: true, allInRange: true, install: installReaperHeal }),
sword: P({ hits: 2 }),
artsfghter: P({ dmgType: 'arts' }),
swordmaster: P({ hits: 2 }),
+1 -1
View File
@@ -263,7 +263,7 @@ export function validateDiyPicks(picks, { data, kitted = null } = { data: null }
const { charId } = c.pick;
if (!ok(charId)) return { error: 'BAD_TARGET', detail: `${charId} has no kit yet` };
const mod = c.pick.uniEquipId ? diyModuleOf(slotId, charId, c.pick.uniEquipId, data) : null;
if (mod && !isDiyModule(mod)) return { error: 'BAD_TARGET', detail: `${charId}: ${c.pick.uniEquipId} is a module of another game mode (${mod.typeName})` };
if (mod && !isDiyModule(mod)) return { error: 'BAD_TARGET', detail: `${charId}: ${c.pick.uniEquipId} is a module of another game mode (${mod.typeName}, ${/^RA-/.test(mod.typeName || '') ? '生息演算' : '集成战略'})` };
const tier = diy.slots[slotId].tier;
const inTier = byTier.get(tier) ?? new Set();
if (inTier.has(charId)) return { error: 'BAD_TARGET', detail: `${charId} fills two tier-${tier} slots` };
+11 -5
View File
@@ -11,6 +11,7 @@ import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.
import * as enemiesMod from '../../server/sim/content/enemies.js';
import * as bossesMod from '../../server/sim/content/bosses.js';
import { spawnYanyou } from '../../server/sim/content/tokens.js';
import { attackWindup } from '../../server/sim/ai.js';
const E = JSON.parse(fs.readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8'));
const W = JSON.parse(fs.readFileSync(new URL('../../data/waves.json', import.meta.url), 'utf8'));
@@ -291,8 +292,9 @@ for (const [key, n] of Object.entries(DEATH_SPAWN)) {
for (const key of ['enemy_1207_sfji', 'enemy_1207_sfji_2']) {
// PRTS 天赋 "攻击力+X%,持有4个【断刃】 / 每次成功攻击后消耗1个【断刃】,攻击结束时若已耗尽【断刃】,则立刻切换为无断刃模式并失去攻击力
// 加成" (popup: "清空当次攻击间隔" — it attacks again at once); GitHub #107: until 0.2.0 each attack stacked another layer
test(`${nm(key)}: one ATK layer while it holds a blade, gone with the 4th attack (which is followed at once by the next); unspent blades become 矛头 on death (at least 1)`, () => {
// 加成" (popup: "清空当次攻击间隔" — it attacks again at once: since 0.2.0 its next swing starts at once and strikes at the
// clip's damage frame, ai.js attackWindup); GitHub #107: until 0.2.0 each attack stacked another layer
test(`${nm(key)}: one ATK layer while it holds a blade, gone with the 4th attack (which is followed at once by the next swing); unspent blades become 矛头 on death (at least 1)`, () => {
const per = tb(key, 'Atkup.atk') ?? tb(key, 'AtkUp.atk'), cnt = tb(key, 'DeadSpawn.cnt');
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }] });
h.step();
@@ -304,8 +306,10 @@ for (const key of ['enemy_1207_sfji', 'enemy_1207_sfji_2']) {
h.runUntil(() => e.stats.attacks >= cnt, 20);
approx(e.s.atk, base, 1e-6, 'the last blade spent: 无断刃模式, no bonus');
const tLast = h.b.time;
assert.ok(h.runUntil(() => e.stats.attacks >= cnt + 1, 0.2), 'the mode switch clears the attack interval: the next attack at once');
assert.ok(h.b.time - tLast < e.s.interval / 2, `${h.b.time - tLast} s after the 4th attack`);
const wind = attackWindup(e);
assert.ok(wind > 0 && h.runUntil(() => e.stats.attacks >= cnt + 1, wind + 0.2), 'the mode switch clears the attack interval: the next swing at once');
assert.ok(Math.abs(h.b.time - tLast - wind) <= 3 * h.TICK, `${h.b.time - tLast} s after the 4th attack: its wind-up (${wind} s)`);
assert.ok(h.b.time - tLast < e.s.interval / 2);
const child = E[key].talents.bbStr['DeadSpawn.enemy_key'];
killed(h, e, null);
assert.equal(alive(h, child).length, 1, 'no blade left: still one 矛头');
@@ -1289,6 +1293,7 @@ test(`${nm('enemy_10027_vtsk')}: entrance barrage (${skb('enemy_10027_vtsk', 'Ap
assert.ok(barrage.every((c) => c.target === h.unit('t_wall')), 'the highest-HP unit');
approx(barrage[0].amount, e.s.atk);
h.runUntil(() => e.stats.attacks >= 1, 10);
h.run(1); // (its shot lands: since 0.2.0 the first strike comes at the clip's damage frame, after the barrage window)
const d = h.hooksOf('damaged').find((c) => c.source === e && c.dmg.isAttack);
approx(d.amount, e.s.atk * tb('enemy_10027_vtsk', 'range.attack@atk_scale_range'));
h.run(skb('enemy_10027_vtsk', 'MultiCombat').initCooldown + 6);
@@ -2412,7 +2417,8 @@ test('“斩胄之剑” / “破胄之锤” 初始模式 attack: every ally in
const h = bossArena({ units: [{ chessId: 't_wall', row: 10, col: 9 }, { chessId: 't_wall2', row: 10, col: 7 }] });
h.step();
put(h, key, [3, 8], { tag: 'part' });
h.run(1);
// (the first attack: its 0.8 / 0.867 s wind-up, then the shots — since 0.2.0 an attack strikes at its damage frame)
h.runUntil(() => h.unit('t_wall').stats.taken > 0 && h.unit('t_wall2').stats.taken > 0, 3);
approx(h.unit('t_wall').stats.taken, E[key].stats.atk * scale, 1e-6, key);
approx(h.unit('t_wall2').stats.taken, E[key].stats.atk * scale, 1e-6, key);
}
+43
View File
@@ -0,0 +1,43 @@
// test/content/feedback5-lowhp-heal.test.js — the module trait "治疗生命值低于50%的友方单位时治疗量提升15%" (PHY-X / GUA-X) compares the
// target's HP the way the client's module buff does (follow-up 19): FilterByTargetHpRatio LT — strictly below — for 瑕光 /
// 古米 / 塞雷娅's GUA-X (set_heal_scale_by_hpratio) and 录武官's PHY-X (reckpr_e_002_tr); LE — at or below — for the template
// heal_scale_up[hpratio][LE] of 华法琳's and 闪灵's PHY-X (and 黍's GUA-X, 凯尔希's PHY-X, already so). The text's 「低于」 is the
// display; the buff templates decide (the local client's [uc]equips.ab prefabs and buff_template_data, read 2026-10-06).
// Run: node --test test/content/feedback5-lowhp-heal.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
const WALL = chessRec({ id: 't_wall', stats: { maxHp: 1e6, atk: 0, def: 0 }, skill: null });
/** The bonus factor `healer` gives a heal on an ally at `ratio` of its max HP (1 = none). */
function factorAt(unitSpec, ratio) {
const h = makeBattle({ defs: { chess: { t_wall: WALL } }, units: [unitSpec, { chessId: 't_wall', row: 11, col: 5 }], autoFinish: false, timeLimit: 30, seed: 3 });
h.step();
const healer = h.b.allyUnits.find((u) => u.uid === 1), t = h.unit('t_wall');
t.hp = t.s.maxHp * ratio;
const before = t.hp;
const base = 100 * healer.s.healingDealtMul * t.s.healingTakenMul;
h.b.heal(healer, t, 100);
checkInvariants(h.b);
return (t.hp - before) / base;
}
const near = (a, b, msg) => assert.ok(Math.abs(a - b) < 1e-9, `${msg}: ${a} vs ${b}`);
test('LE (heal_scale_up[hpratio][LE]): 华法琳 (tier 4 and 5) and 闪灵 PHY-X heal an ally at exactly 50 % HP ×1.15', () => {
for (const id of ['chess_char_4_26_b', 'chess_char_5_04_b']) {
near(factorAt({ uid: 1, chessId: id, row: 10, col: 5 }, 0.5), 1.15, `${id} at 50 %`);
near(factorAt({ uid: 1, chessId: id, row: 10, col: 5 }, 0.6), 1, `${id} at 60 %`);
}
const shining = { uid: 1, diy: { slot: 6, charId: 'char_147_shining', skillIndex: 0, uniEquipId: 'uniequip_003_shining' }, elite: true, row: 10, col: 5 };
near(factorAt(shining, 0.5), 1.15, '闪灵 PHY-X at 50 %');
near(factorAt(shining, 0.6), 1, '闪灵 PHY-X at 60 %');
});
test('LT (set_heal_scale_by_hpratio, reckpr_e_002_tr): 塞雷娅 GUA-X and 录武官 PHY-X (tier 4 and 5) give nothing at exactly 50 %, ×1.15 below', () => {
for (const id of ['chess_char_5_11_b', 'chess_char_4_15_b', 'chess_char_5_23_b']) {
near(factorAt({ uid: 1, chessId: id, row: 10, col: 5 }, 0.5), 1, `${id} at 50 %`);
near(factorAt({ uid: 1, chessId: id, row: 10, col: 5 }, 0.49), 1.15, `${id} at 49 %`);
}
});
+81
View File
@@ -0,0 +1,81 @@
// test/content/feedback5-protect.test.js — 庇护 is one effect per unit whoever grants it (gamedata_const ba.protect / PRTS 术语
// 庇护 "受到的物理和法术伤害降低相应比例(同名效果取最高)"): the older kits — 宴, 余, 缪尔赛思 (chess), 赫拉格, 左乐, 赫德雷 (自选) —
// hold it through the shared key the other 自选 kits use (kits/shared/tier1.js PROTECT / holdProtect → battle.applyStrongest),
// so the strongest value applies instead of a product of every source. 遥's bubbles keep their own key (the client's
// damage_resistance[bonus], which multiplies with the common damage_resistance[inf]).
// Run: node --test test/content/feedback5-protect.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 { PROTECT, holdProtect } from '../../server/sim/content/kits/shared/tier1.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
const ds = getDefaultSource();
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 done = (h) => { checkInvariants(h.b); assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); };
/** 宴 (elite: 庇护 below hp_ratio) at 20 % HP with nothing to hit her. */
function utage() {
const h = makeBattle({ units: [{ chessId: 'chess_char_1_18_b', row: 9, col: 5 }], autoFinish: false, timeLimit: 60, seed: 3 });
h.step();
const u = h.unit('chess_char_1_18_b');
u.hp = u.s.maxHp * 0.2;
h.step();
return { h, u, dr: ds.rawChess('chess_char_1_18_b').talents.filter((t) => t.index !== -1)[0].bb.damage_resistance };
}
/** Physical damage she takes from a 1000-point hit (her DEF taken off). */
const physTaken = (h, u) => { const hp = u.hp; h.b.dealDamage(null, u, { amount: 1000, type: 'phys', canDodge: false }); const d = hp - u.hp; u.hp = hp; return d; };
test('宴 below her threshold holds the shared 庇护; a stronger one from another source replaces it (no product), a weaker one waits under it', () => {
const { h, u, dr } = utage();
assert.ok(dr > 0 && dr < 0.5);
const b = u.findBuff(PROTECT);
assert.ok(b && b.source === u && b.data.value === dr, 'her own 庇护, on the shared key');
const base = 1000 - u.s.def;
approx(physTaken(h, u), base * (1 - dr), 'hers alone');
// a stronger 庇护 from another source: the highest counts
holdProtect(h.b, u, 0.5, 10, { id: 'other' });
h.step();
approx(physTaken(h, u), base * (1 - 0.5), 'the stronger one only — not ×(1 − dr) as well');
assert.equal(u.buffs.filter((x) => x.key === PROTECT).length, 1, 'one 庇护 effect');
done(h);
// a weaker one: hers holds
const w = utage();
holdProtect(w.h.b, w.u, 0.1, 10, { id: 'other' });
w.h.step();
approx(physTaken(w.h, w.u), (1000 - w.u.s.def) * (1 - w.dr), 'hers — the weaker 庇护 waits');
// back above the threshold her own lapses within a tick; the weaker one resumes for its remaining time
w.u.hp = w.u.s.maxHp;
w.h.step(3);
const after = w.u.findBuff(PROTECT);
assert.ok(after && after.data.value === 0.1, 'the weaker 庇护 resumed');
done(w.h);
});
test('余 (blocking), 缪尔赛思\'s melee copies, 赫拉格 / 左乐 (SBL-X, below 50 %) and 赫德雷 (余火之氅) all hold 庇护 on the shared key', () => {
const src = (f) => readFileSync(new URL(`../../server/sim/content/kits/ops/${f}`, import.meta.url), 'utf8');
for (const f of ['chess_char_1_18-utage.js', 'chess_char_6_03-yu.js', 'chess_char_6_11-mlyss.js', 'op-helage.js', 'op-zuole.js', 'op-hodrer.js']) {
assert.match(src(f), /holdProtect\(/, `${f} uses the shared 庇护`);
assert.doesNotMatch(src(f), /physTakenMul: 1 - dr|dmg\.mul \*= 1 - (dr|protectCut|num\(t\.damage_resistance\))/, `${f}: no private 庇护 multiplier left`);
}
// 遥's bubble stays its own effect (damage_resistance[bonus])
assert.doesNotMatch(src('op-haruka.js'), /holdProtect\(/);
// 赫拉格 (SBL-X, tier 5) at 40 % HP: his 庇护 on the shared key
const h = makeBattle({
defs: { enemies: { e: enemyRec({ key: 'e', hp: 1e9, speed: 0 }) } },
units: [{ diy: { slot: 5, charId: 'char_188_helage', skillIndex: 0, uniEquipId: 'uniequip_002_helage' }, elite: true, row: 10, col: 5 }],
autoFinish: false, timeLimit: 30, seed: 3,
});
h.step();
const u = h.b.allyUnits.find((x) => x.def?.charId === 'char_188_helage');
u.hp = u.s.maxHp * 0.4;
h.step();
const b = u.findBuff(PROTECT);
assert.ok(b && b.source === u && b.data.value === 0.25, 'his SBL-X 庇护');
u.hp = u.s.maxHp;
h.step(3);
assert.equal(u.findBuff(PROTECT), null, 'gone above 50 %');
done(h);
});
@@ -0,0 +1,41 @@
// test/content/feedback5-summon-recharge.test.js — one reading of the summoners' charge_token[born] (follow-up 20): the client's
// RechargeToken on the owner's birth runs with timing NORMAL — the summon is ready at once —, while the summon's own
// charge_token[finish] (ON_FINISH) is the timer that runs from its deployment (PRTS 傀影 备注 "部署召唤物后立刻开始计算再部署时间").
// 傀影's 镜中虚影 (op-phatom.js) now reads it as 凯尔希's Mon3tr (op-kalts.js) and the other summoner kits do: when the owner
// is deployed again, a summon still waiting for its own timer comes back at once (paying its cost).
// Run: node --test test/content/feedback5-summon-recharge.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, checkInvariants } from '../helpers/battleHarness.js';
/** The owner (uid 1, a tier-5 自选 piece) and its placed summon (uid 2). */
function field(charId, tokenId) {
const h = makeBattle({
timeLimit: 900, autoFinish: false, seed: 5, flags: { dpInit: 99, dpPerSec: 0, dpMax: 999 },
units: [{ uid: 1, diy: { slot: 'chess_char_5_diy1_a', charId, skillIndex: 0 }, row: 10, col: 2 }, { uid: 2, kind: 'token', tokenId, ownerUid: 1, row: 10, col: 4 }],
});
h.step();
return { h, u: h.unit(1), t: h.unit(2) };
}
for (const [name, charId, tokenId] of [['傀影 / 镜中虚影', 'char_250_phatom', 'token_10007_phatom_twin'], ['凯尔希 / Mon3tr', 'char_003_kalts', 'token_10002_kalts_mon3tr']]) {
test(`${name}: the owner's redeployment readies a waiting summon at once (charge_token[born], timing NORMAL); its own timer still holds it while the owner stands`, () => {
const { h, u, t } = field(charId, tokenId);
assert.ok(u.alive && t.alive, 'both deployed');
h.run(2);
// knocked out with the owner standing: its own timer (from its deployment) holds it
h.b.kill(t, null);
h.run(3);
assert.ok(!t.alive, 'waits for its own timer');
// the owner leaves and comes back long before that timer has run
h.b.retreat(u);
assert.ok(h.b.redeploy(u, { free: true }), 'the owner back');
const dp = h.b.players[0].dp;
assert.ok(h.runUntil(() => t.alive, 1), 'the summon comes back at once with the owner');
assert.ok(h.b.time < 10, `at ${h.b.time.toFixed(2)} s — its own timer (≥ 25 s) has not run`);
assert.equal(h.b.players[0].dp, dp - t.base.cost, 'paying its cost');
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
});
}
+11 -8
View File
@@ -1036,7 +1036,7 @@ test('3_19 伺夜 S2 领袖的馈赠: +DP, the pack recovers HP, its next attack
}
});
test('3_19 伺夜 S2 领袖的馈赠 (自动触发): no enemy needed — cast at full SP while the pack stands and holds no unused gift (PRTS 备注), never while it is in its 战术点形态 [ASSUMED] — cast once it is back (kit pack and prep piece)', () => {
test('3_19 伺夜 S2 领袖的馈赠 (自动触发): no enemy needed — cast at full SP while the pack is on the field and holds no unused gift (PRTS 备注), in its 战术点形态 too [ASSUMED: S1\'s words] — the DP at once, the gift kept for the returning pack (kit pack and prep piece)', () => {
for (const id of BOTH('chess_char_3_19_a')) for (const piece of [false, true]) {
const b = SB(id, 'skchr_vigil_2'), tag = `${id} ${piece ? 'piece' : 'kit pack'}`;
const h = makeBattle({ defs: { chess: noGarrison(id) }, timeLimit: 60, flags: { dpPerSec: 0 }, units: vigilUnits(id, 'skchr_vigil_2', piece) });
@@ -1052,17 +1052,20 @@ test('3_19 伺夜 S2 领袖的馈赠 (自动触发): no enemy needed — cast at
fill(u);
h.run(3);
assert.equal(u.skill.activations, 1, '"狼群未获得此技能的充能时可触发": no second cast while the gift is unused');
// the gift spent (as by a bite), the pack in its 战术点形态: no cast (the 备注 does not count that form for S2 —
// [ASSUMED]); once it is back, the waiting cast fires
// the gift spent (as by a bite), the pack in its 战术点形态: the cast fires there too (its 备注 uses S1's words, which count
// that form — [ASSUMED]; until 0.2.0 it waited for the pack): +cost DP at once, the gift waits on the pack
w.mem.vigilGift = null;
for (let i = 0; i < 10 && w.alive; i++) h.b.dealDamage(null, w, { amount: 1e9, type: 'true' }); // a wolf is lost per KO
assert.ok(wolfTacticalPoint(w), `${tag}: 战术点形态`);
h.run(5);
assert.equal(u.skill.activations, 1, `${tag}: no cast while the pack is in its 战术点形态`);
assert.ok(u.skill.ready, 'the SP waits full');
const dp1 = p.dp;
assert.ok(h.runUntil(() => u.skill.activations === 2, 0.5), `${tag}: cast while the pack is in its 战术点形态`);
approx(p.dp, dp1 + b.cost, 1e-6, `${tag}: +cost DP at once`);
assert.ok(w.mem.vigilGift && !w.alive, `${tag}: the gift waits on the pack`);
fill(u);
h.run(3);
assert.equal(u.skill.activations, 2, `${tag}: no second cast while that gift is unused`);
assert.ok(h.runUntil(() => w.alive, WOLF_IV(id)), `${tag}: the pack is back`);
assert.ok(h.runUntil(() => u.skill.activations === 2, 0.5), `${tag}: cast once it stands again`);
assert.ok(w.mem.vigilGift, 'the returned pack holds the gift');
assert.ok(w.mem.vigilGift, `${tag}: the returned pack holds the gift for its next attack`);
done(h);
}
});
+5 -1
View File
@@ -708,7 +708,11 @@ test('6_11 缪尔赛思 S2 生态耦合: +cost DP; melee copies regenerate and t
const eco = tok().findBuff('mlyss:eco');
assert.ok(eco, 'melee copy buffed');
approx(eco.mods.hpRegenRatio, bb.hp_recovery_per_sec_by_max_hp_ratio);
approx(eco.mods.physTakenMul, 1 - bb.damage_resistance);
// 庇护: the shared effect of every source (同名效果取最高 — kits/shared/tier1.js holdProtect)
const protect = tok().findBuff('protect');
assert.ok(protect && protect.source === u, 'the 庇护 she grants');
approx(protect.mods.physTakenMul, 1 - bb.damage_resistance);
approx(protect.mods.artsTakenMul, 1 - bb.damage_resistance);
done(h);
// ranged copy: a sniper to copy
+2 -2
View File
@@ -281,8 +281,8 @@ test('余: blocked enemies take 40 % ATK arts + 12 % burn per second, 庇护 whi
assert.ok(dot.length >= 2);
const burn = h.dmg.filter((d) => d.src === u.id && d.element === 'burn');
assert.ok(burn.length >= 2);
// 庇护 (ba.protect): physical and arts damage only
assert.ok(u.findBuff('yu:shelter') && near(u.s.physTakenMul, 1 - t0.damage_resistance) && near(u.s.artsTakenMul, 1 - t0.damage_resistance));
// 庇护 (ba.protect): physical and arts damage only — the shared effect of every source (kits/shared/tier1.js holdProtect)
assert.ok(u.findBuff('protect')?.source === u && near(u.s.physTakenMul, 1 - t0.damage_resistance) && near(u.s.artsTakenMul, 1 - t0.damage_resistance));
assert.equal(u.s.trueTakenMul, 1, '庇护 does not reduce true damage');
assert.equal(u.s.dmgTakenMul, 1);
const base = u.base.maxHp;
+3 -2
View File
@@ -20,7 +20,8 @@ const FORMS = BACKUPS.units[HOD].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const CRUX = 'uniequip_002_hodrer', CRUY = 'uniequip_003_hodrer';
const S1 = 'skchr_hodrer_1', S2 = 'skchr_hodrer_2', S3 = 'skchr_hodrer_3';
const PROTECT = 'talent:hodrer:protect';
const PROTECT = 'protect'; // the shared 庇护 of every source (kits/shared/tier1.js holdProtect)
const DMG_UP = 'talent:hodrer:dmgUp'; // CRU-X stage 3's 物理伤害 + (its own one instance per ally)
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
@@ -173,7 +174,7 @@ test('T2 余火之氅: he and the ally on the tile behind him get 18 % 庇护 (C
assert.ok(m, `${label(f)}: ${a.def.charId} protected`);
approx(m.physTakenMul, 1 - dr, `${label(f)}: phys`);
approx(m.artsTakenMul, 1 - dr, `${label(f)}: arts`);
assert.equal(m.physDealtMul ?? 1, x3 ? 1.1 : 1, `${label(f)}: 物理伤害`);
assert.equal(a.findBuff(DMG_UP)?.mods.physDealtMul ?? 1, x3 ? 1.1 : 1, `${label(f)}: 物理伤害`);
}
assert.equal(beside.findBuff(PROTECT), null, `${label(f)}: not beside him`);
const hp = behind.hp;
+16 -8
View File
@@ -2,7 +2,9 @@
// server/sim/content/kits/ops/standin-acspec.js) on every chess she replaces: 缄默德克萨斯 (tier 4, S2), 山 (tier 5, S3 +
// EXE-X), 新约能天使 / 锏 (tier 6, S3 + EXE-X), normal (E2 Lv1, skill 4) and elite (E2 Lv60, skill 7). Her three skills
// are ON_DEPLOY passives: they start at every deployment — at the battle start no enemy is on the field yet, so S3 hits
// nothing there and matters on a redeploy (再部署时间大幅度减少: 18 s). Numbers are read back from data/backups.json.
// nothing there and matters on a redeploy (再部署时间大幅度减少: 18 s). S1 / S2 last 10 s: since 0.2.0 they are duration skills
// started by every deployment (activateOnDeploy — PR #109's deploy-timed contract, their own skillStart / skillEnd), no
// longer a passive holding a 10 s buff. Numbers are read back from data/backups.json.
// Run: node --test test/content/standin_acspec.test.js
import { test } from 'node:test';
@@ -26,7 +28,7 @@ const ALLY = 'chess_char_1_02_a'; // 角峰, a melee blocker
function battle(units, o = {}) {
return makeBattle({
defs: { enemies: ENEMIES }, units, timeLimit: o.timeLimit ?? 400, autoFinish: false, seed: o.seed ?? 5,
flags: { dpPerSec: 0 }, hooks: ['damaged', 'deploy', 'death'], captureNoisy: true,
flags: { dpPerSec: 0 }, hooks: ['damaged', 'deploy', 'death', 'skillStart', 'skillEnd'], captureNoisy: true,
});
}
const MISERY = (id, standIn = true) => ({ chessId: id, row: 10, col: 4, standIn });
@@ -38,7 +40,7 @@ function done(h) {
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
test('Misery on every chess she replaces (normal + elite): her body (再部署 18 s), the backup skill as a passive, this kit; EXE-X on the tier-5 / 6 elites only', () => {
test('Misery on every chess she replaces (normal + elite): her body (再部署 18 s), the backup skill (S2 a deploy-timed duration skill, S3 a passive), this kit; EXE-X on the tier-5 / 6 elites only', () => {
const ids = UNIT.standsIn.flatMap((a) => [a, a.replace(/_a$/, '_b')]);
assert.equal(ids.length, 8);
const withModule = [];
@@ -48,7 +50,8 @@ test('Misery on every chess she replaces (normal + elite): her body (再部署 1
h.step();
const u = h.unit(1);
const sk = form.skills.find((s) => s.index === c.backup.skillIndex);
assert.deepEqual([u.def.charId, u.def.standInFor, u.skill.id, u.skill.kind, !!u.kit.generic, u.kit.skillSource], [CHAR, c.charId, sk.skillId, 'passive', false, 'skills'], id);
const kind = sk.skillId === 'skchr_acspec_3' ? 'passive' : 'duration';
assert.deepEqual([u.def.charId, u.def.standInFor, u.skill.id, u.skill.kind, !!u.kit.generic, u.kit.skillSource], [CHAR, c.charId, sk.skillId, kind, false, 'skills'], id);
assert.deepEqual([sk.skillType, sk.spType], ['PASSIVE', 'ON_DEPLOY'], id);
const mod = c.backup.uniEquipId && c.status.equipLevel > 0 ? form.modules.find((m) => m.uniEquipId === c.backup.uniEquipId) : null;
assert.equal(!!u.def.raw.module?.active, !!mod, `${id}: module`);
@@ -137,7 +140,7 @@ test('Misery S1 物理的服从 (no chess names it — a 自选 slot may): for 1
const h = battle([MISERY(id, { skillIndex: 0 })]);
h.step();
const u = h.unit(1);
assert.deepEqual([u.skill.id, u.skill.kind, sk.duration], ['skchr_acspec_1', 'passive', 10]);
assert.deepEqual([u.skill.id, u.skill.kind, u.skill.duration, sk.duration], ['skchr_acspec_1', 'duration', 10, 10]);
const prob = () => { let p = null; u.profile.hitsFn({ rng: { chance: (x) => { p = x; return false; } } }, u); return p; };
const on = () => { approx(u.s.dodgePhys, bb.prob, `${id}: dodge ${bb.prob}`); assert.equal(prob(), bb['attack@prob'], `${id}: chance ${bb['attack@prob']}`); };
const off = () => { assert.equal(u.s.dodgePhys, 0, `${id}: no dodge`); assert.equal(prob(), talentBb(u, 0)['attack@prob'], `${id}: the talent's chance`); };
@@ -162,9 +165,13 @@ test('Misery S2 战争的恭顺 (缄默德克萨斯): for 10 s from every deploy
const h = battle([MISERY(id)]);
h.step();
const u = h.unit(1);
assert.deepEqual([u.skill.id, sk.duration], ['skchr_acspec_2', 10]);
const on = () => { approx(u.s.atk, u.base.atk * (1 + bb.atk), `${id}: ATK +${bb.atk}`); assert.equal(u.s.aspd, 100 + bb.attack_speed, `${id}: ASPD`); };
const off = () => { approx(u.s.atk, u.base.atk, `${id}: ATK back`); assert.equal(u.s.aspd, 100, `${id}: ASPD back`); };
assert.deepEqual([u.skill.id, u.skill.kind, u.skill.duration, sk.duration], ['skchr_acspec_2', 'duration', 10, 10]);
// the deploy-timed contract (PR #109): the skill runs — skillStart now, skillEnd 10 s later — and then is off
const on = () => { approx(u.s.atk, u.base.atk * (1 + bb.atk), `${id}: ATK +${bb.atk}`); assert.equal(u.s.aspd, 100 + bb.attack_speed, `${id}: ASPD`); assert.ok(u.skill.active, `${id}: running`); };
const off = () => { approx(u.s.atk, u.base.atk, `${id}: ATK back`); assert.equal(u.s.aspd, 100, `${id}: ASPD back`); assert.ok(!u.skill.active, `${id}: over`); };
const starts = () => h.hooksOf('skillStart').filter((c) => c.unit === u).length;
const ends = () => h.hooksOf('skillEnd').filter((c) => c.unit === u).length;
assert.deepEqual([starts(), ends()], [1, 0], `${id}: started by the deployment`);
on();
h.run(9.8);
on();
@@ -176,6 +183,7 @@ test('Misery S2 战争的恭顺 (缄默德克萨斯): for 10 s from every deploy
on();
h.run(10.1);
off();
assert.deepEqual([starts(), ends()], [2, 2], `${id}: one window per deployment (the retreat ends none — it had ended)`);
done(h);
}
});
+14
View File
@@ -44,6 +44,20 @@ test('golden files list exactly the corpus scenarios (regenerate with npm run go
}
});
// a stored digest that records engine errors means the corpus was regenerated over a crash (0.2.0: a merge dropped an
// import a kit's tick handler used, and `npm run golden:update` stored 9288 swallowed errors as "the expected result")
test('no stored scenario records engine errors (errors.count is 0 everywhere)', () => {
for (const f of FAMILY_NAMES) {
for (const [id, dg] of Object.entries(storedDigests(loadGolden(f)))) {
if (!dg || typeof dg !== 'object' || !dg.errors) continue;
// battles: { count, kinds }; matches: { engine, logged, sim, dispatcher }
const e = dg.errors;
const n = typeof e.count === 'number' ? e.count : Object.values(e).reduce((a, v) => a + (typeof v === 'number' ? v : 0), 0);
assert.equal(n, 0, `${f} ${id}: ${n} engine error(s) ${JSON.stringify(e)} — fix the crash, then regenerate`);
}
}
});
for (const f of FAMILY_NAMES) {
test(`golden ${f}: ${FULL ? 'every scenario' : 'the fast subset (GOLDEN_FULL=1 for all)'} reproduces its stored digest`, () => {
const computed = digests[f];
+487 -487
View File
File diff suppressed because it is too large. Load diff
+2634 -2637
View File
File diff suppressed because it is too large. Load diff
+629 -672
View File
File diff suppressed because it is too large. Load diff
+328 -323
View File
File diff suppressed because it is too large. Load diff
+1443 -1428
View File
File diff suppressed because it is too large. Load diff
+271 -271
View File
@@ -20,58 +20,58 @@
"time": 170,
"ticks": 5100,
"reason": "timeout",
"killed": 17,
"killed": 22,
"total": 50,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 620,
"rngDraws": 595,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {
"killed": 17,
"killed": 22,
"total": 50,
"perfect": false,
"leaks": 33,
"leaks": 28,
"leaksUncounted": 0,
"coins": 4,
"dmg": 240621,
"dmg": 280742,
"bossDmg": 0,
"heal": 17519,
"deaths": 24,
"heal": 26765,
"deaths": 19,
"layerGains": {"raidShip":13,"visiShip":2}
}
},
"units": [
["p1",1,"op","chess_char_3_12_a",2,null,0,0,72,3,40439,3657,0,19,2],
["p1",2,"op","chess_char_4_09_a",0,null,0,0,1,2,32698,2984,0,17,1],
["p1",3,"op","chess_char_3_05_a",2,null,2505,1,74,2,29899,11886,0,45,2],
["p1",4,"op","chess_char_3_18_a",2,null,0,0,7,1,14915,3668,0,36,1],
["p1",5,"op","chess_char_4_04_a",1,null,497.45,1,91,2,12814,6576,0,82,10],
["p1",6,"op","chess_char_4_07_a",1,null,0,0,75,1,18472,13600,0,43,1],
["p1",7,"op","chess_char_4_16_a",1,null,0,0,88,0,3482,9543,0,103,0],
["p1",8,"op","chess_char_3_01_a",2,null,1271,1,5,0,19777,0,0,141,2],
["p1",9,"op","chess_char_3_04_a",2,null,1218,1,84,0,0,1599,17519,35,2],
["p1",10,"op","chess_char_3_21_a",2,null,716.85,1,6,4,45212,555,0,138,2],
["p1",11,"op","chess_char_4_02_a",1,null,1272,1,90,2,12787,1653,0,68,1],
["p1",12,"op","chess_char_4_13_a",1,null,0,0,86,0,10126,1624,0,26,1]
["p1",1,"op","chess_char_3_12_a",2,null,0,0,73,3,40405,3648,0,20,2],
["p1",2,"op","chess_char_4_09_a",0,null,458.8,1,83,5,55322,5509,0,34,2],
["p1",3,"op","chess_char_3_05_a",2,null,1865.8,1,77,3,29828,11858,0,46,2],
["p1",4,"op","chess_char_3_18_a",2,null,2638,1,7,2,32526,8770,0,75,2],
["p1",5,"op","chess_char_4_04_a",1,null,1264.1,1,91,2,17806,8706,0,100,9],
["p1",6,"op","chess_char_4_07_a",1,null,0,0,74,1,16008,10455,0,37,1],
["p1",7,"op","chess_char_4_16_a",1,null,807.8,1,92,1,9136,11490,0,98,14],
["p1",8,"op","chess_char_3_01_a",2,null,1271,1,5,0,15937,0,0,131,2],
["p1",9,"op","chess_char_3_04_a",2,null,1218,1,8,0,0,0,26765,59,3],
["p1",10,"op","chess_char_3_21_a",2,null,161.7,1,6,1,42388,1110,0,121,2],
["p1",11,"op","chess_char_4_02_a",1,null,1272,1,93,3,11537,1724,0,61,1],
["p1",12,"op","chess_char_4_13_a",1,null,812,1,84,1,9849,812,0,27,1]
],
"enemies": {
"enemy_10001_trslim": [1,1,0,17,4860],
"enemy_10001_trslim": [1,1,0,8,4860],
"enemy_1000_gopro_2": [1,1,0,0,1836],
"enemy_10018_sgrobh": [1,1,0,2810,8100],
"enemy_1001_bigbo": [1,0,1,1235,2581],
"enemy_10027_vtsk": [1,0,1,15097,2542],
"enemy_10018_sgrobh": [1,1,0,2811,8100],
"enemy_1001_bigbo": [1,0,1,1301,3217],
"enemy_10027_vtsk": [1,0,1,16122,2471],
"enemy_10031_cnvsld": [1,0,1,0,0],
"enemy_10034_cnvsax": [1,0,1,364,2496],
"enemy_10034_cnvsax": [1,0,1,0,0],
"enemy_10042_prtrop": [1,0,1,0,0],
"enemy_1116_liprr_2": [21,11,10,5655,116700],
"enemy_1119_vofsd": [12,2,8,13284,62929],
"enemy_1121_lifbos_2": [9,1,7,11244,38577]
"enemy_1116_liprr_2": [21,12,9,5347,131366],
"enemy_1119_vofsd": [12,6,6,19166,93491],
"enemy_1121_lifbos_2": [9,1,7,9882,35401]
},
"hooks": [1,91,5100,870,870,1441,1438,3866,41,82,25,33,0,18850,26,37,50,30,1,42,5,0,6],
"events": {"atk":1013,"bounty":2,"deploy":41,"die":52,"dmg":1438,"engage":11,"fx":62,"heal":35,"layer":5,"leak":30,"skill":47,"spawn":62,"status":126},
"fx": {"beam":4,"blink":11,"dodge":6,"dp":5,"garrison":5,"liberate":14,"slash":16,"telegraph":1},
"snaps": "dec536bc cbfb0e19 608df77d 4bc1be00 31528b18 50c15974 172a64b1 80ed52af 452e6c6e 2cbbaf73 d2893b65 71598545 0ff0a232 8497b8ca 56857b5a 512860f1 25c11ae9"
"hooks": [1,93,5100,939,939,1515,1509,3842,41,82,41,39,0,22264,26,41,50,27,1,41,5,0,6],
"events": {"atk":1088,"bounty":2,"deploy":43,"die":55,"dmg":1509,"engage":11,"fx":61,"heal":59,"layer":5,"leak":27,"skill":80,"spawn":62,"status":123},
"fx": {"blink":14,"dodge":7,"dp":5,"garrison":5,"liberate":13,"slash":16,"telegraph":1},
"snaps": "8b8be66d 3d41101a 548df15a 1009f8b0 08d13c52 9fe4b099 64799759 70e168de 0b87fdee 880a5dbc 4750bf5e 71d1cbac a6d3a940 eda8aa0d 5dec048f 8b7b727d 0b96370a"
},
"standin-02": {
"about": "normal band 5 as stand-ins: chess_char_4_17_a→char_610_acfend chess_char_4_18_a→char_602_cdfend chess_char_5_05_a→char_602_cdfend chess_char_4_19_a→char_608_acpion chess_char_5_01_a→char_600_cpione chess_char_4_22_a→char_609_acguad chess_char_4_23_a→char_609_acguad chess_char_5_06_a→char_609_acguad chess_char_4_20_a→char_603_csnipe chess_char_4_24_a→char_604_ccast chess_char_5_02_a→char_604_ccast chess_char_5_03_a→char_612_accast",
@@ -89,42 +89,42 @@
"total": 68,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 113,
"rngDraws": 108,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {"killed":9,"total":68,"perfect":false,"leaks":59,"leaksUncounted":0,"coins":0,"dmg":98109,"bossDmg":0,"heal":0,"deaths":23,"layerGains":{"preciShip":12}}
"p1": {"killed":9,"total":68,"perfect":false,"leaks":59,"leaksUncounted":0,"coins":0,"dmg":102545,"bossDmg":0,"heal":0,"deaths":22,"layerGains":{"preciShip":12}}
},
"units": [
["p1",1,"op","chess_char_4_17_a",1,null,0,0,3,0,57,2604,0,4,1],
["p1",1,"op","chess_char_4_17_a",1,null,0,0,3,1,294,2604,0,4,1],
["p1",2,"op","chess_char_4_18_a",1,null,0,0,78,0,2350,6810,0,6,1],
["p1",3,"op","chess_char_5_05_a",2,null,0,0,70,0,14,4256,0,1,0],
["p1",4,"op","chess_char_4_19_a",1,null,0,0,66,0,316,3058,0,13,1],
["p1",5,"op","chess_char_5_01_a",2,null,0,0,91,0,35,3675,0,2,0],
["p1",6,"op","chess_char_4_22_a",1,null,0,0,71,0,5165,5276,0,9,0],
["p1",7,"op","chess_char_4_23_a",1,null,0,0,64,0,5497,5276,0,9,0],
["p1",3,"op","chess_char_5_05_a",2,null,0,0,72,0,41,4256,0,3,1],
["p1",4,"op","chess_char_4_19_a",1,null,0,0,66,0,632,3058,0,12,1],
["p1",5,"op","chess_char_5_01_a",2,null,154.94,1,91,0,52,3520,0,3,0],
["p1",6,"op","chess_char_4_22_a",1,null,0,0,74,0,6886,5276,0,11,0],
["p1",7,"op","chess_char_4_23_a",1,null,0,0,65,0,5497,5276,0,9,0],
["p1",8,"op","chess_char_5_06_a",2,null,0,0,1,0,0,2638,0,0,0],
["p1",9,"op","chess_char_4_20_a",1,null,1271,1,92,1,11656,2542,0,25,0],
["p1",10,"op","chess_char_4_24_a",1,null,0,0,90,0,3736,3399,0,8,0],
["p1",11,"op","chess_char_5_02_a",2,null,0,0,84,3,13253,2266,0,25,1],
["p1",12,"op","chess_char_5_03_a",2,null,0,0,4,5,56032,1372,0,10,1]
["p1",10,"op","chess_char_4_24_a",1,null,0,0,90,0,4203,3399,0,9,0],
["p1",11,"op","chess_char_5_02_a",2,null,0,0,84,2,14070,2266,0,26,1],
["p1",12,"op","chess_char_5_03_a",2,null,0,0,4,5,56863,1372,0,10,1]
],
"enemies": {
"enemy_10042_prtrop_2": [1,0,1,4567,14393],
"enemy_10043_sailor": [1,1,0,1137,23649],
"enemy_10044_wintun": [1,0,1,4374,12094],
"enemy_10042_prtrop_2": [1,0,1,4429,14122],
"enemy_10043_sailor": [1,1,0,1024,23649],
"enemy_10044_wintun": [1,0,1,3812,11990],
"enemy_10045_parrot": [1,0,1,0,4099],
"enemy_10054_cjhot": [1,0,1,1676,1665],
"enemy_1005_yokai": [27,8,19,0,29182],
"enemy_1005_yokai_2": [27,0,24,11401,2816],
"enemy_1005_yokai_3": [6,0,6,20017,14371],
"enemy_10065_ftzlc": [1,0,1,0,560],
"enemy_10054_cjhot": [1,0,1,2686,1997],
"enemy_1005_yokai": [27,8,19,0,29752],
"enemy_1005_yokai_2": [27,0,24,10786,3283],
"enemy_1005_yokai_3": [6,0,6,20260,18054],
"enemy_10065_ftzlc": [1,0,1,19,640],
"enemy_10067_ftsjc": [1,0,1,0,680],
"enemy_1006_shield": [1,0,1,0,760]
},
"hooks": [1,92,5700,337,337,558,523,0,32,88,5,5,0,12532,1,13,68,56,1,32,3,0,41],
"events": {"atk":345,"deploy":24,"die":32,"dmg":523,"engage":11,"fx":81,"layer":3,"leak":56,"skill":10,"spawn":80,"status":2},
"fx": {"aoe":4,"dodge":41,"dp":4,"explode":27,"garrison":3,"garrisonGrant":1,"zone":1},
"snaps": "9d2468c6 9a32f006 99b2aa33 a01ecc39 97bb00b0 2fcede4b fe594f15 377f7691 e87b0fd3 af44759d 7b13ea89 61cd9b40 4f9d1182 5905fb57 d697ca06 c37e9513 3102b854 296cdd49 4d5efa28"
"hooks": [1,92,5700,335,335,575,545,0,31,87,6,6,0,13069,1,13,68,56,1,31,3,0,41],
"events": {"atk":343,"deploy":24,"die":31,"dmg":545,"engage":11,"fx":83,"layer":3,"leak":56,"skill":12,"spawn":80,"status":2},
"fx": {"aoe":4,"dodge":41,"dp":4,"explode":29,"garrison":3,"garrisonGrant":1,"zone":1},
"snaps": "bb11ef91 3dacfd27 a1a9e1ba 0c1cbf13 1a25c591 303d857a 3dc0bf41 8f47df11 f254e91e 21b1a437 0bef3640 47e52a7a d371b6b0 8a488ba7 9a40aca9 786c46b4 ca780f2a f8b06cfe 10e274ca"
},
"standin-03": {
"about": "normal band 5 as stand-ins: chess_char_5_08_a→char_610_acfend chess_char_5_14_a→char_608_acpion chess_char_5_19_a→char_617_sharp2 chess_char_5_07_a→char_601_cguard chess_char_5_13_a→char_609_acguad chess_char_5_17_a→char_615_acspec chess_char_5_10_a→char_606_csuppo chess_char_5_11_a→char_613_acmedc chess_char_5_12_a→char_614_acsupo chess_char_5_15_a→char_603_csnipe chess_char_5_18_a→char_611_acnipe chess_char_5_20_a→char_614_acsupo",
@@ -138,44 +138,44 @@
"time": 190,
"ticks": 5700,
"reason": "timeout",
"killed": 46,
"killed": 44,
"total": 74,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 247,
"rngDraws": 317,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":46,"total":74,"perfect":false,"leaks":28,"leaksUncounted":0,"coins":8,"dmg":342938,"bossDmg":0,"heal":45325,"deaths":12,"layerGains":{}}},
"players": {"p1":{"killed":44,"total":74,"perfect":false,"leaks":30,"leaksUncounted":0,"coins":8,"dmg":357249,"bossDmg":0,"heal":48257,"deaths":10,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_08_a",2,null,515.8,1,6,0,11779,9335,0,131,2],
["p1",2,"op","chess_char_5_14_a",2,null,0,0,89,24,159150,7595,0,61,6],
["p1",3,"op","chess_char_5_19_a",0,null,0,0,90,6,38255,15228,0,94,2],
["p1",4,"op","chess_char_5_07_a",2,null,2564,1,91,2,10656,2984,0,18,1],
["p1",5,"op","chess_char_5_13_a",2,null,0,0,11,7,58468,14481,0,126,4],
["p1",6,"op","chess_char_5_17_a",2,null,1154,1,93,3,11205,3361,0,59,0],
["p1",7,"op","chess_char_5_10_a",2,null,932,1,2,0,8982,0,0,77,2],
["p1",8,"op","chess_char_5_11_a",2,null,1224,1,3,0,0,0,45325,72,2],
["p1",9,"op","chess_char_5_12_a",2,null,1160,1,4,1,22227,0,0,86,2],
["p1",10,"op","chess_char_5_15_a",2,null,0,0,84,2,8190,12762,0,91,2],
["p1",11,"op","chess_char_5_18_a",2,null,0,0,1,0,5656,1272,0,20,1],
["p1",12,"op","chess_char_5_20_a",2,null,1972,1,92,1,8370,1972,0,35,1]
["p1",1,"op","chess_char_5_08_a",2,null,2206.8,1,91,0,5923,3841,0,59,1],
["p1",2,"op","chess_char_5_14_a",2,null,0,0,90,23,142481,7595,0,63,6],
["p1",3,"op","chess_char_5_19_a",0,null,349.6,1,89,6,47622,15311,0,100,3],
["p1",4,"op","chess_char_5_07_a",2,null,843.2,1,7,1,29523,11092,0,77,3],
["p1",5,"op","chess_char_5_13_a",2,null,822,1,11,7,56384,11384,0,123,4],
["p1",6,"op","chess_char_5_17_a",2,null,1154,1,92,0,4254,4200,0,59,0],
["p1",7,"op","chess_char_5_10_a",2,null,932,1,2,1,8953,0,0,74,2],
["p1",8,"op","chess_char_5_11_a",2,null,1224,1,3,0,0,0,48257,71,2],
["p1",9,"op","chess_char_5_12_a",2,null,1160,1,4,0,19483,0,0,77,2],
["p1",10,"op","chess_char_5_15_a",2,null,0,0,84,2,8336,14152,0,96,2],
["p1",11,"op","chess_char_5_18_a",2,null,0,0,1,4,27171,1272,0,32,1],
["p1",12,"op","chess_char_5_20_a",2,null,0,0,5,0,7118,1972,0,39,2]
],
"enemies": {
"enemy_1006_shield_2": [1,1,0,679,8000],
"enemy_1006_shield_3": [1,0,1,12959,19581],
"enemy_1006_shield_2": [1,1,0,582,8000],
"enemy_1006_shield_3": [1,0,1,10399,13075],
"enemy_10073_mpcar": [1,1,0,12,1600],
"enemy_1007_slime": [1,1,0,0,440],
"enemy_10081_mpplai": [1,1,0,0,2000],
"enemy_10087_hlchgr": [1,1,0,58,4800],
"enemy_10087_hlchgr": [1,1,0,38,4800],
"enemy_10094_crstf": [1,1,0,28,3200],
"enemy_10097_crshd": [1,1,0,0,8000],
"enemy_1166_dusbr_2": [42,31,11,3354,115737],
"enemy_1170_dushld_2": [12,6,4,28840,54328],
"enemy_9011_acrefr": [12,2,3,23060,125251]
"enemy_1166_dusbr_2": [42,30,12,2728,121684],
"enemy_1170_dushld_2": [12,5,6,28117,47066],
"enemy_9011_acrefr": [12,2,6,28914,147383]
},
"hooks": [1,93,5700,1154,1154,1775,1770,135,58,77,25,27,0,28761,3090,58,74,19,1,58,0,0,8],
"events": {"atk":1247,"bounty":4,"deploy":19,"die":58,"dmg":1767,"engage":11,"fx":170,"heal":124,"leak":19,"skill":56,"spawn":86,"status":614},
"fx": {"aoe":60,"displace":1,"dodge":18,"dp":30,"phase":1,"slash":48,"sp":2,"spGain":10},
"snaps": "dbcb5cec 56a8a8f6 e8d97a5d d2594a66 b6f753a4 78a2f35e 80349f15 2cd292dd d7240cae 4f120b33 69d90058 45d5da9a ae0b244b 17b17251 4df0cf30 f2c7c569 baa94919 07ba89e9 e4a68e4d"
"hooks": [1,92,5700,1098,1098,1657,1660,130,54,79,28,28,0,28379,3587,52,74,25,1,56,0,0,8],
"events": {"atk":1229,"bounty":4,"deploy":18,"die":54,"dmg":1659,"engage":11,"fx":131,"heal":126,"leak":25,"skill":60,"spawn":86,"status":621},
"fx": {"aoe":30,"displace":1,"dodge":13,"dp":29,"phase":1,"slash":48,"sp":1,"spGain":8},
"snaps": "530ce9ab 3259d8c0 918a3cff a85e2d90 3b23751a 8f259c98 ac7cc207 e97f970a 310fc8e2 3ccf05ce 133339f2 3ce55224 a14db521 246e0ee0 c7100c7e 91ae8513 4b6a214c 8c1f8a7b 41afe990"
},
"standin-04": {
"about": "normal band 6 as stand-ins: chess_char_6_03_a→char_610_acfend chess_char_6_02_a→char_608_acpion chess_char_6_07_a→char_617_sharp2 chess_char_6_10_a→char_617_sharp2 chess_char_6_11_a→char_608_acpion chess_char_6_06_a→char_609_acguad chess_char_5_22_a→char_611_acnipe chess_char_6_01_a→char_611_acnipe chess_char_6_04_a→char_614_acsupo chess_char_6_05_a→char_611_acnipe chess_char_6_08_a→char_612_accast chess_char_6_09_a→char_613_acmedc",
@@ -186,62 +186,62 @@
"round": 12,
"seed": 428893583,
"waveId": "act1autochess_h05",
"time": 201.7,
"ticks": 6051,
"time": 196.37,
"ticks": 5891,
"reason": "cleared",
"killed": 136,
"killed": 127,
"total": 158,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 644,
"rngDraws": 656,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {
"killed": 136,
"killed": 127,
"total": 158,
"perfect": false,
"leaks": 22,
"leaks": 31,
"leaksUncounted": 0,
"coins": 8,
"dmg": 1309822,
"dmg": 1279084,
"bossDmg": 0,
"heal": 23527,
"deaths": 12,
"heal": 23617,
"deaths": 13,
"layerGains": {"sargonShip":5}
}
},
"units": [
["p1",1,"op","chess_char_6_03_a",2,null,0,0,174,0,698,7812,0,6,0],
["p1",2,"op","chess_char_6_02_a",2,null,1529,1,179,1,2975,3058,0,7,2],
["p1",3,"op","chess_char_6_07_a",0,null,0,0,172,9,135322,3730,0,74,1],
["p1",4,"op","chess_char_6_10_a",0,null,0,0,166,5,94409,3730,0,75,2],
["p1",5,"op","chess_char_6_11_a",2,null,1026.4,1,178,11,100320,8547,0,40,4],
["p1",6,"op","chess_char_6_06_a",2,null,2080.4,1,156,9,111400,19283,0,111,4],
["p1",7,"op","chess_char_5_22_a",2,null,1272,1,3,2,25528,0,0,19,2],
["p1",8,"op","chess_char_6_01_a",2,null,1272,1,4,34,325813,0,0,164,2],
["p1",9,"op","chess_char_6_04_a",2,null,1160,1,13,7,65845,0,0,121,2],
["p1",10,"op","chess_char_6_05_a",2,null,1272,1,14,18,234561,2453,0,122,2],
["p1",11,"op","chess_char_6_08_a",2,null,1372,1,5,39,212952,0,0,57,2],
["p1",12,"op","chess_char_6_09_a",2,null,1224,1,11,0,0,0,23527,37,2],
["p1",1,"op","chess_char_6_03_a",2,null,0,0,173,0,379,7812,0,3,0],
["p1",2,"op","chess_char_6_02_a",2,null,0,0,175,2,4099,4587,0,9,1],
["p1",3,"op","chess_char_6_07_a",0,null,0,0,169,9,146057,3730,0,77,1],
["p1",4,"op","chess_char_6_10_a",0,null,1865,1,182,0,26824,3730,0,28,1],
["p1",5,"op","chess_char_6_11_a",2,null,1529,1,179,11,105846,7699,0,33,4],
["p1",6,"op","chess_char_6_06_a",2,null,2080.4,1,157,13,102010,19207,0,105,3],
["p1",7,"op","chess_char_5_22_a",2,null,1272,1,3,2,36737,0,0,27,2],
["p1",8,"op","chess_char_6_01_a",2,null,1272,1,4,41,375365,0,0,160,3],
["p1",9,"op","chess_char_6_04_a",2,null,1160,1,13,5,66082,0,0,118,2],
["p1",10,"op","chess_char_6_05_a",2,null,1272,1,14,16,199814,2964,0,123,2],
["p1",11,"op","chess_char_6_08_a",2,null,1372,1,5,28,215871,0,0,59,2],
["p1",12,"op","chess_char_6_09_a",2,null,1224,1,11,0,0,0,23617,34,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_10098_crhro": [1,1,0,3450,41472],
"enemy_10099_crvln": [1,1,0,2453,20736],
"enemy_10098_crhro": [1,1,0,1577,41472],
"enemy_10099_crvln": [1,1,0,2964,20736],
"enemy_1009_lurker": [1,1,0,16,3041],
"enemy_1010_demon": [1,1,0,0,10368],
"enemy_1010_demon_2": [28,22,6,15447,543909],
"enemy_1010_demon_2": [28,20,8,11373,543506],
"enemy_10112_ymgds": [1,1,0,0,6912],
"enemy_10116_ymgtop": [1,1,0,2924,13824],
"enemy_10118_ymgprc": [1,0,1,3965,18459],
"enemy_1071_dftman": [63,63,0,447,243855],
"enemy_1072_dlancer": [60,45,15,19911,429319]
"enemy_10116_ymgtop": [1,1,0,3450,13824],
"enemy_10118_ymgprc": [1,0,1,5235,10561],
"enemy_1071_dftman": [63,60,3,290,241610],
"enemy_1072_dlancer": [60,41,19,24824,407969]
},
"hooks": [1,181,6051,918,918,1945,1944,62,148,170,25,24,0,31170,4160,44,158,22,1,149,2,0,13],
"events": {"atk":1080,"bounty":4,"deploy":21,"die":148,"dmg":1944,"engage":11,"fx":87,"heal":45,"layer":2,"leak":22,"skill":49,"spawn":172,"status":440},
"fx": {"aoe":7,"dodge":13,"dp":18,"garrison":2,"revive":1,"slash":29,"sp":12,"spGain":3,"telegraph":2},
"snaps": "29a29463 6fc96b2d 184b448a fce3c126 45d67357 4c47175a 1292297b 3e42d44e b7bf91a6 dd4e2386 0b4615ac 81792172 a056e50b 7e87af96 dd9818ad 8c9001ef b2db6556 9bf17d05 66cf3ead 709fbe1f c88b2f46"
"hooks": [1,182,5891,853,853,1865,1862,60,140,171,23,22,0,28489,4121,43,158,31,1,141,2,0,12],
"events": {"atk":998,"bounty":4,"deploy":22,"die":140,"dmg":1862,"engage":11,"fx":77,"heal":44,"layer":2,"leak":31,"skill":45,"spawn":172,"status":464},
"fx": {"aoe":4,"dodge":15,"dp":18,"garrison":2,"revive":1,"slash":24,"sp":8,"spGain":3,"telegraph":2},
"snaps": "1184691a ac872d2c 04c21bcf 302d411f 5ea6c6b7 1d6c2c6a d1adbc33 d86e6315 835cf160 a3576945 32ffef8a f9d81c1e 035b4b09 12f6f260 41a84026 a6a6ba61 9dd38c5e dcbccdf8 93092800 8b97b584"
},
"standin-05": {
"about": "normal band 6 as stand-ins: chess_char_6_17_a→char_608_acpion chess_char_6_13_a→char_615_acspec chess_char_6_19_a→char_615_acspec chess_char_6_12_a→char_612_accast chess_char_6_15_a→char_614_acsupo chess_char_6_18_a→char_612_accast chess_char_6_20_a→char_613_acmedc",
@@ -252,47 +252,47 @@
"round": 12,
"seed": 4096833445,
"waveId": "act1autochess_h05",
"time": 214.33,
"ticks": 6430,
"time": 215.57,
"ticks": 6467,
"reason": "cleared",
"killed": 7,
"killed": 5,
"total": 104,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 246,
"rngDraws": 215,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {"killed":7,"total":104,"perfect":false,"leaks":97,"leaksUncounted":0,"coins":0,"dmg":426178,"bossDmg":0,"heal":1353,"deaths":22,"layerGains":{"raidShip":3}}
"p1": {"killed":5,"total":104,"perfect":false,"leaks":99,"leaksUncounted":0,"coins":0,"dmg":414926,"bossDmg":0,"heal":451,"deaths":22,"layerGains":{"raidShip":3}}
},
"units": [
["p1",1,"op","chess_char_6_17_a",2,null,0,0,128,1,103267,9556,0,43,1],
["p1",2,"op","chess_char_6_13_a",2,null,0,0,132,1,25092,9232,0,87,0],
["p1",3,"op","chess_char_6_19_a",2,null,0,0,134,2,29265,9232,0,141,0],
["p1",4,"op","chess_char_6_12_a",2,null,671.78,1,7,1,143681,975,0,85,2],
["p1",5,"op","chess_char_6_15_a",2,null,0,0,133,2,54479,4965,0,113,2],
["p1",6,"op","chess_char_6_18_a",2,null,1372,1,85,0,70393,1372,0,42,2],
["p1",7,"op","chess_char_6_20_a",2,null,1224,1,4,0,0,1353,1353,3,1],
["p1",1,"op","chess_char_6_17_a",2,null,0,0,130,1,102521,9556,0,46,1],
["p1",2,"op","chess_char_6_13_a",2,null,1154,1,135,0,23355,9232,0,75,0],
["p1",3,"op","chess_char_6_19_a",2,null,0,0,133,3,24753,9232,0,127,0],
["p1",4,"op","chess_char_6_12_a",2,null,671.78,1,7,0,141278,975,0,83,2],
["p1",5,"op","chess_char_6_15_a",2,null,0,0,134,1,52715,4965,0,109,2],
["p1",6,"op","chess_char_6_18_a",2,null,1372,1,85,0,70303,1372,0,42,2],
["p1",7,"op","chess_char_6_20_a",2,null,1224,1,4,0,0,451,451,1,1],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
],
"enemies": {
"enemy_10122_uacann_2": [1,0,1,6380,12182],
"enemy_10122_uacann_2": [1,0,1,7356,12074],
"enemy_10124_uashld_2": [1,0,1,0,5764],
"enemy_10127_rkmbst_2": [1,0,1,834,0],
"enemy_10127_rkmbst_2": [1,0,1,1154,0],
"enemy_10138_xdsnow": [1,0,1,0,1276],
"enemy_10141_xdpeng_2": [1,0,1,4211,14094],
"enemy_10144_xdelk_2": [1,0,1,0,42],
"enemy_10156_mncrer": [1,0,1,0,738],
"enemy_10141_xdpeng_2": [1,0,1,771,638],
"enemy_10144_xdelk_2": [1,0,1,0,0],
"enemy_10156_mncrer": [1,0,1,0,1476],
"enemy_10159_mntrjn": [1,0,1,0,0],
"enemy_1381_winman": [27,5,22,3499,98764],
"enemy_1381_winman_2": [39,1,38,5799,153206],
"enemy_1387_winshd": [30,1,29,15960,140112]
"enemy_1381_winman": [27,4,23,2007,92343],
"enemy_1381_winman_2": [39,0,39,7769,165158],
"enemy_1387_winshd": [30,1,29,16725,136197]
},
"hooks": [1,134,6430,620,620,988,988,6,29,128,8,24,0,14868,2437,28,104,97,1,29,1,0,0],
"events": {"atk":651,"deploy":27,"die":31,"dmg":901,"engage":6,"fx":58,"heal":3,"layer":1,"leak":97,"skill":47,"spawn":114,"status":514},
"fx": {"aoe":5,"blink":2,"camouflage":1,"displace":22,"dodge":3,"dp":2,"explode":6,"garrison":1,"garrisonGrant":2,"slash":8,"zone":6},
"snaps": "8d7b3223 b6dafa64 3940bc8e fd79b9e2 e5e85618 6fcfc479 90958489 71bfbb91 d12c224c cd2c9c7f 4a9fff33 68e48a86 4b97e027 5f8e8dad da62239a 8601c0dc 73717b6d 9e4f784f 818e7ed1 c1f2a855 33118eab 9bafc0bc"
"hooks": [1,135,6467,577,577,964,964,2,27,128,8,24,0,14795,2484,27,104,99,1,27,1,0,0],
"events": {"atk":608,"deploy":28,"die":29,"dmg":872,"engage":6,"fx":65,"heal":1,"layer":1,"leak":99,"skill":50,"spawn":114,"status":505},
"fx": {"aoe":5,"blink":2,"camouflage":1,"displace":29,"dodge":1,"dp":2,"explode":7,"garrison":1,"garrisonGrant":2,"slash":8,"zone":7},
"snaps": "7d1098cf eed27d14 68262b97 c6f234c7 8b38f4e3 21342a44 02b1ea83 cb4a0d82 ab536b9b 983e88e0 79e6d79f 29c12df3 10ec6f87 f8880188 2817d38e c8292cb4 c36fbc55 11c36bec 13e7ad67 fa20b3bf b4378733 2c35030e"
},
"standin-06": {
"about": "elite band 5 as stand-ins: chess_char_3_12_b→char_608_acpion chess_char_4_09_b→char_617_sharp2 chess_char_3_05_b→char_601_cguard chess_char_3_18_b→char_609_acguad chess_char_4_04_b→char_607_cspec chess_char_4_07_b→char_601_cguard chess_char_4_16_b→char_615_acspec chess_char_3_01_b→char_603_csnipe chess_char_3_04_b→char_605_cmedic chess_char_3_21_b→char_611_acnipe chess_char_4_02_b→char_611_acnipe chess_char_4_13_b→char_614_acsupo",
@@ -303,61 +303,61 @@
"round": 10,
"seed": 1442657168,
"waveId": "act1autochess_h03",
"time": 182.73,
"ticks": 5482,
"time": 183.7,
"ticks": 5511,
"reason": "cleared",
"killed": 46,
"killed": 50,
"total": 98,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 295,
"rngDraws": 345,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {
"killed": 46,
"killed": 50,
"total": 98,
"perfect": false,
"leaks": 52,
"leaks": 48,
"leaksUncounted": 0,
"coins": 8,
"dmg": 621924,
"dmg": 676478,
"bossDmg": 0,
"heal": 27685,
"deaths": 32,
"layerGains": {"raidShip":26,"visiShip":4}
"heal": 30426,
"deaths": 31,
"layerGains": {"raidShip":26,"visiShip":12}
}
},
"units": [
["p1",1,"op","chess_char_3_12_b",2,"none",1904,1,138,10,143498,5544,956,21,3],
["p1",2,"op","chess_char_4_09_b",0,"none",0,0,6,9,109862,3510,0,21,1],
["p1",3,"op","chess_char_3_05_b",2,"none",0,0,93,2,26416,9128,0,18,2],
["p1",4,"op","chess_char_3_18_b",2,"none",0,0,92,3,33920,9190,2808,27,2],
["p1",5,"op","chess_char_4_04_b",1,"none",0,0,137,0,13321,10188,0,31,8],
["p1",6,"op","chess_char_4_07_b",1,"none",0,0,96,1,23318,10676,0,23,1],
["p1",7,"op","chess_char_4_16_b",1,"none",0,0,136,3,27487,13878,0,36,0],
["p1",8,"op","chess_char_3_01_b",2,"none",0,0,132,2,23235,3020,0,28,1],
["p1",9,"op","chess_char_3_04_b",2,"none",0,0,4,0,0,13615,23921,23,2],
["p1",10,"op","chess_char_3_21_b",2,"none",0,0,117,11,155638,7911,0,47,1],
["p1",11,"op","chess_char_4_02_b",1,"none",0,0,2,5,48494,1483,0,31,1],
["p1",12,"op","chess_char_4_13_b",1,"none",0,0,99,0,16734,3262,0,23,2]
["p1",1,"op","chess_char_3_12_b",2,"none",1904,1,140,10,155395,5504,915,21,3],
["p1",2,"op","chess_char_4_09_b",0,"none",0,0,6,10,119787,3510,0,39,1],
["p1",3,"op","chess_char_3_05_b",2,"none",0,0,90,2,23950,8566,0,18,2],
["p1",4,"op","chess_char_3_18_b",2,"none",0,0,93,3,28745,9190,2808,27,2],
["p1",5,"op","chess_char_4_04_b",1,"none",0,0,137,0,25476,10224,0,40,9],
["p1",6,"op","chess_char_4_07_b",1,"none",0,0,101,3,28031,10981,0,27,1],
["p1",7,"op","chess_char_4_16_b",1,"none",0,0,139,7,42175,16104,0,70,12],
["p1",8,"op","chess_char_3_01_b",2,"none",0,0,122,1,16716,3020,0,24,1],
["p1",9,"op","chess_char_3_04_b",2,"none",0,0,4,0,0,15932,26703,25,2],
["p1",10,"op","chess_char_3_21_b",2,"none",0,0,118,12,175716,7911,0,55,1],
["p1",11,"op","chess_char_4_02_b",1,"none",0,0,124,2,41362,2966,0,27,1],
["p1",12,"op","chess_char_4_13_b",1,"none",0,0,103,0,19124,2704,0,26,2]
],
"enemies": {
"enemy_10162_mnctpt": [1,1,0,7990,39813],
"enemy_1019_jshoot": [1,1,0,423,4479],
"enemy_1019_jshoot_2": [1,1,0,1526,6718],
"enemy_1021_bslime": [1,1,0,3434,6121],
"enemy_1023_jmage": [1,1,0,2086,5474],
"enemy_10162_mnctpt": [1,1,0,7378,39813],
"enemy_1019_jshoot": [1,1,0,295,4479],
"enemy_1019_jshoot_2": [1,1,0,837,6718],
"enemy_1021_bslime": [1,1,0,1648,6121],
"enemy_1023_jmage": [1,1,0,2497,5474],
"enemy_1025_reveng": [1,1,0,0,12442],
"enemy_1026_aghost": [1,1,0,0,9456],
"enemy_1043_zomsbr": [1,1,0,0,6287],
"enemy_1251_lysyta_2": [57,29,28,15958,256380],
"enemy_1252_lysytb_2": [21,8,13,24284,223998],
"enemy_1329_cbshld_2": [12,1,11,35708,50755]
"enemy_1043_zomsbr": [1,1,0,0,6285],
"enemy_1251_lysyta_2": [57,33,24,13447,289829],
"enemy_1252_lysytb_2": [21,8,13,29953,226804],
"enemy_1329_cbshld_2": [12,1,11,40556,69057]
},
"hooks": [1,138,5482,523,523,776,802,93,78,137,24,36,0,6341,31,56,98,52,1,86,5,0,4],
"events": {"atk":611,"bounty":4,"deploy":40,"die":85,"dmg":802,"engage":11,"fx":59,"heal":66,"layer":5,"leak":52,"skill":48,"spawn":110,"status":107},
"fx": {"blink":7,"camouflage":1,"dodge":8,"dp":13,"explode":1,"garrison":5,"slash":24},
"snaps": "32c77df2 e0a686fc 511e3625 2a2c9595 4d454cd6 ba68fe2a 509a2a40 0f18d74e 26c72bef 4d6d5cce 834f8ce9 462b0854 0fec1e43 fd1bd922 ab1c6896 53173cd5 dbdfb42d 6f942256 4207d16e"
"hooks": [1,140,5511,596,596,875,906,94,81,139,37,37,0,7322,36,60,98,48,1,89,7,0,4],
"events": {"atk":684,"bounty":4,"deploy":42,"die":91,"dmg":906,"engage":11,"fx":62,"heal":67,"layer":7,"leak":48,"skill":74,"spawn":110,"status":117},
"fx": {"blink":10,"camouflage":1,"dodge":6,"dp":13,"explode":1,"garrison":7,"slash":24},
"snaps": "be6d1821 fa43a30d 52099f77 a860ccb6 ea311a6e 44affa90 9dabf4d8 83adbc77 77dfae35 f02fd736 470fba6a a9871a60 b7e054aa ae8c4b73 55df9303 1c374baa 0d780d78 e03a2b60 b7879d42"
},
"standin-07": {
"about": "elite band 6 as stand-ins: chess_char_4_17_b→char_610_acfend chess_char_4_18_b→char_602_cdfend chess_char_5_05_b→char_602_cdfend chess_char_4_19_b→char_608_acpion chess_char_5_01_b→char_600_cpione chess_char_4_22_b→char_609_acguad chess_char_4_23_b→char_609_acguad chess_char_5_06_b→char_609_acguad chess_char_4_20_b→char_603_csnipe chess_char_4_24_b→char_604_ccast chess_char_5_02_b→char_604_ccast chess_char_5_03_b→char_612_accast",
@@ -368,24 +368,24 @@
"round": 12,
"seed": 816286799,
"waveId": "act1autochess_h05",
"time": 222.97,
"ticks": 6689,
"time": 223.57,
"ticks": 6707,
"reason": "cleared",
"killed": 8,
"killed": 9,
"total": 80,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 80,
"rngDraws": 86,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {
"killed": 8,
"killed": 9,
"total": 80,
"perfect": false,
"leaks": 72,
"leaks": 71,
"leaksUncounted": 0,
"coins": 4,
"dmg": 126420,
"dmg": 124008,
"bossDmg": 0,
"heal": 0,
"deaths": 22,
@@ -393,38 +393,38 @@
}
},
"units": [
["p1",1,"op","chess_char_4_17_b",1,"none",0,0,3,0,42,3211,0,2,1],
["p1",1,"op","chess_char_4_17_b",1,"none",0,0,3,0,210,3211,0,2,1],
["p1",2,"op","chess_char_4_18_b",1,"none",0,0,83,0,30,9306,0,1,1],
["p1",3,"op","chess_char_5_05_b",2,"none",0,0,97,0,572,5816,0,1,1],
["p1",4,"op","chess_char_4_19_b",1,"none",1847.63,1,107,1,367,3864,0,5,1],
["p1",3,"op","chess_char_5_05_b",2,"none",0,0,97,0,1145,5816,0,2,1],
["p1",4,"op","chess_char_4_19_b",1,"none",1847.63,1,107,0,230,3864,0,7,1],
["p1",5,"op","chess_char_5_01_b",2,"none",1036.53,1,105,0,2197,3985,0,5,3],
["p1",6,"op","chess_char_4_22_b",1,"none",3191,1,109,0,1178,14884,0,5,1],
["p1",7,"op","chess_char_4_23_b",1,"none",0,0,67,0,3484,6610,0,8,1],
["p1",6,"op","chess_char_4_22_b",1,"none",3191,1,109,0,1178,15724,0,5,1],
["p1",7,"op","chess_char_4_23_b",1,"none",0,0,68,0,3484,6865,0,8,1],
["p1",8,"op","chess_char_5_06_b",2,"uniequip_002_acguad",0,0,10,0,0,3820,0,0,0],
["p1",9,"op","chess_char_4_20_b",1,"none",1255.06,1,106,2,16976,4326,0,31,1],
["p1",10,"op","chess_char_4_24_b",1,"none",1007.59,1,108,0,5460,3072,0,18,1],
["p1",11,"op","chess_char_5_02_b",2,"none",0,0,102,1,8032,4080,0,19,1],
["p1",12,"op","chess_char_5_03_b",2,"uniequip_002_accast",0,0,4,4,88082,1749,0,10,1],
["p1",9,"op","chess_char_4_20_b",1,"none",1255.06,1,106,2,14281,4581,0,27,1],
["p1",10,"op","chess_char_4_24_b",1,"none",1007.59,1,108,0,5710,3072,0,19,1],
["p1",11,"op","chess_char_5_02_b",2,"none",0,0,102,1,8684,4080,0,19,1],
["p1",12,"op","chess_char_5_03_b",2,"uniequip_002_accast",0,0,4,6,86859,1749,0,10,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": [18,2,16,3894,7075],
"enemy_1041_lazerd_2": [24,3,21,41810,33530],
"enemy_1042_frostd": [30,1,29,0,24206],
"enemy_1045_hammer": [1,1,0,1914,15552],
"enemy_1045_hammer_2": [1,0,1,6132,25057],
"enemy_1046_agent": [1,1,0,57,3888],
"enemy_1050_lslime": [1,0,1,9425,1320],
"enemy_1061_zomshd": [1,0,1,31,9241],
"enemy_1062_rager_2": [1,0,1,1459,14551],
"enemy_1005_yokai_2": [18,3,15,3871,7232],
"enemy_1041_lazerd_2": [24,3,21,43406,32062],
"enemy_1042_frostd": [30,1,29,0,25469],
"enemy_1045_hammer": [1,1,0,2117,15552],
"enemy_1045_hammer_2": [1,0,1,5327,20514],
"enemy_1046_agent": [1,1,0,72,3888],
"enemy_1050_lslime": [1,0,1,9701,0],
"enemy_1061_zomshd": [1,0,1,61,13862],
"enemy_1062_rager_2": [1,0,1,1519,13929],
"enemy_1067_snslime": [1,0,1,0,0],
"enemy_1071_dftman": [1,0,1,0,0]
},
"hooks": [1,109,6689,304,304,581,573,378,30,102,13,13,0,8920,9,11,80,72,1,33,2,0,8],
"events": {"atk":330,"bounty":2,"deploy":27,"die":30,"dmg":573,"engage":11,"fx":22,"layer":2,"leak":72,"skill":26,"spawn":94,"status":103},
"fx": {"aoe":1,"dodge":8,"dp":6,"garrison":2,"garrisonGrant":1,"palsy":1,"revive":3},
"snaps": "ef9ff66a 4f4ac6fc 133f7f1c a187a8c8 46d89701 4bb496a8 0dfccf57 81173658 8c42876d 4718c77c d3b80c8e df3c692d 0576a74b 7684de92 e59b0c43 849795d5 e99812de 3f883cd6 6bb9cdf6 b246f597 289cd790 2f34e6ff 997f51f3"
"hooks": [1,109,6707,301,301,585,576,545,31,102,13,13,0,8990,10,14,80,71,1,34,2,0,9],
"events": {"atk":327,"bounty":2,"deploy":27,"die":31,"dmg":576,"engage":11,"fx":23,"layer":2,"leak":71,"skill":26,"spawn":94,"status":111},
"fx": {"aoe":1,"dodge":9,"dp":5,"garrison":2,"garrisonGrant":1,"palsy":2,"revive":3},
"snaps": "801e90ec 5f30137a 56120166 3365328a cd10076b ddf2ae60 00e0a9a1 f0fbc83c 9a8e0ded c4876430 15bb6d00 e61302d2 75a24a51 0b7275b4 f728ca3f 437d1568 ed5e8b79 5a14602a ac144838 2dcf417a 7b12357a 77ef33ec ff8819ba"
},
"standin-08": {
"about": "elite band 6 as stand-ins: chess_char_5_08_b→char_610_acfend chess_char_5_14_b→char_608_acpion chess_char_5_19_b→char_617_sharp2 chess_char_5_07_b→char_601_cguard chess_char_5_13_b→char_609_acguad chess_char_5_17_b→char_615_acspec chess_char_5_10_b→char_606_csuppo chess_char_5_11_b→char_613_acmedc chess_char_5_12_b→char_614_acsupo chess_char_5_15_b→char_603_csnipe chess_char_5_18_b→char_611_acnipe chess_char_5_20_b→char_614_acsupo",
@@ -438,47 +438,47 @@
"time": 230,
"ticks": 6900,
"reason": "timeout",
"killed": 43,
"killed": 36,
"total": 179,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 1025,
"rngDraws": 1087,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":43,"total":179,"perfect":false,"leaks":136,"leaksUncounted":0,"coins":2,"dmg":2172511,"bossDmg":0,"heal":95038,"deaths":13,"layerGains":{}}},
"players": {"p1":{"killed":36,"total":179,"perfect":false,"leaks":143,"leaksUncounted":0,"coins":2,"dmg":1859123,"bossDmg":0,"heal":138461,"deaths":10,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_08_b",2,"uniequip_002_acfend",3371,1,203,4,94861,14912,0,116,3],
["p1",2,"op","chess_char_5_14_b",2,"uniequip_002_acpion",0,0,124,14,988772,21777,0,61,10],
["p1",3,"op","chess_char_5_19_b",0,"uniequip_002_sharp2",4154.5,1,205,8,235963,23398,0,64,2],
["p1",4,"op","chess_char_5_07_b",2,"none",3334,1,204,2,34977,19978,0,29,2],
["p1",5,"op","chess_char_5_13_b",2,"uniequip_002_acguad",0,0,161,5,103856,25240,0,51,3],
["p1",6,"op","chess_char_5_17_b",2,"uniequip_002_acspec",1448,1,200,4,83107,24504,0,112,0],
["p1",7,"op","chess_char_5_10_b",2,"none",1131,1,4,2,79514,0,0,153,4],
["p1",8,"op","chess_char_5_11_b",2,"uniequip_002_acmedc",2262.4,1,5,0,0,0,95038,74,3],
["p1",9,"op","chess_char_5_12_b",2,"uniequip_002_acsupo",1430,1,11,0,112393,0,0,146,3],
["p1",10,"op","chess_char_5_15_b",2,"none",1510,1,13,0,77843,0,0,215,4],
["p1",11,"op","chess_char_5_18_b",2,"uniequip_002_acnipe",2380.5,1,6,4,262886,0,0,169,3],
["p1",12,"op","chess_char_5_20_b",2,"uniequip_002_acsupo",1430,1,14,0,98338,0,0,125,3],
["p1",1,"op","chess_char_5_08_b",2,"uniequip_002_acfend",3167.17,1,165,4,83889,36030,0,116,3],
["p1",2,"op","chess_char_5_14_b",2,"uniequip_002_acpion",1904,1,194,7,473061,9225,0,48,8],
["p1",3,"op","chess_char_5_19_b",0,"uniequip_002_sharp2",0,0,156,7,350021,33747,0,99,3],
["p1",4,"op","chess_char_5_07_b",2,"none",3334,1,203,1,30240,30649,0,29,2],
["p1",5,"op","chess_char_5_13_b",2,"uniequip_002_acguad",2176.17,1,129,7,177239,32096,0,89,4],
["p1",6,"op","chess_char_5_17_b",2,"uniequip_002_acspec",1448,1,188,5,87979,26616,0,142,0],
["p1",7,"op","chess_char_5_10_b",2,"none",1131,1,4,0,74712,0,0,155,4],
["p1",8,"op","chess_char_5_11_b",2,"uniequip_002_acmedc",2262.4,1,5,0,0,0,138461,78,3],
["p1",9,"op","chess_char_5_12_b",2,"uniequip_002_acsupo",1430,1,11,2,99017,0,0,136,3],
["p1",10,"op","chess_char_5_15_b",2,"none",1510,1,13,0,76181,0,0,234,4],
["p1",11,"op","chess_char_5_18_b",2,"uniequip_002_acnipe",2380.5,1,6,3,299976,0,0,164,3],
["p1",12,"op","chess_char_5_20_b",2,"uniequip_002_acsupo",1430,1,14,0,106807,0,0,136,3],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
],
"enemies": {
"enemy_1072_dlancer": [1,1,0,3008,37150],
"enemy_1081_sotisd": [1,0,1,0,27122],
"enemy_1092_mdgint": [1,0,1,1448,67],
"enemy_1116_liprr": [1,0,1,0,7702],
"enemy_1116_liprr_2": [1,0,1,0,1829],
"enemy_1081_sotisd": [1,0,1,0,27095],
"enemy_1092_mdgint": [1,0,1,0,33],
"enemy_1116_liprr": [1,0,1,0,6095],
"enemy_1116_liprr_2": [1,0,1,0,2364],
"enemy_1118_lidbox_2": [1,0,1,0,1344],
"enemy_1119_vofsd": [1,0,1,0,889],
"enemy_1121_lifbos": [1,0,1,0,1525],
"enemy_1249_lysdb_2": [60,13,44,41971,986578],
"enemy_1251_lysyta": [63,28,35,8822,665956],
"enemy_1254_lypa_2": [48,1,47,74560,442348]
"enemy_1121_lifbos": [1,0,1,0,1997],
"enemy_1249_lysdb_2": [60,16,44,44297,925272],
"enemy_1251_lysyta": [63,18,45,8302,477784],
"enemy_1254_lypa_2": [48,1,46,112756,379100]
},
"hooks": [1,205,6900,1764,1764,3493,3533,147,56,189,40,44,0,26478,16569,72,179,133,1,59,0,0,10],
"events": {"atk":2013,"bounty":1,"deploy":23,"die":56,"dmg":3508,"engage":11,"fx":308,"heal":130,"leak":133,"skill":90,"spawn":194,"status":1297},
"fx": {"aoe":90,"displace":12,"dodge":31,"dp":24,"shieldBreak":59,"slash":80,"sp":1,"spGain":11},
"snaps": "4d4dc5ab 00efc642 4dc3aa32 13e9533b a3617bdf 587927d0 9fe3b7b8 0b39dceb 2981edf9 2ba1ce8c ef825d5b 85fd9608 a83f0a12 9e6b1271 7c5bf7ee f6ad8d8e c76286f6 037365de bf979497 771a38f7 b74aaf11 518326a3 19e5e338"
"hooks": [1,203,6900,1914,1914,3154,3238,154,46,188,40,41,0,27753,17018,59,179,142,1,51,0,0,24],
"events": {"atk":2198,"bounty":1,"deploy":21,"die":46,"dmg":3227,"engage":11,"fx":296,"heal":145,"leak":142,"skill":85,"spawn":194,"status":1301},
"fx": {"aoe":89,"displace":4,"dodge":50,"dp":15,"shieldBreak":60,"slash":64,"sp":4,"spGain":10},
"snaps": "c4ba1bfb 2dfd8eae 9edfcbdb 398871eb 8ec5c018 bdeb7f73 3a80c711 b3542ba8 7c2bcdeb 332b7d77 cfba3c82 e42b9504 e04cec26 299bbeab 55c539ae fb163352 d1b5dcef e0628f10 16c64f3c f935f6ce dfa3bfea 0a6ae300 8f573ae7"
},
"standin-09": {
"about": "elite band 7 as stand-ins: chess_char_6_03_b→char_610_acfend chess_char_6_02_b→char_608_acpion chess_char_6_07_b→char_617_sharp2 chess_char_6_10_b→char_617_sharp2 chess_char_6_11_b→char_608_acpion chess_char_6_06_b→char_609_acguad chess_char_5_22_b→char_611_acnipe chess_char_6_01_b→char_611_acnipe chess_char_6_04_b→char_614_acsupo chess_char_6_05_b→char_611_acnipe chess_char_6_08_b→char_612_accast chess_char_6_09_b→char_613_acmedc",
@@ -489,31 +489,31 @@
"round": 13,
"seed": 2308517178,
"waveId": "act1autochess_h06",
"time": 198.9,
"ticks": 5967,
"time": 198.43,
"ticks": 5953,
"reason": "cleared",
"killed": 158,
"total": 158,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 257,
"rngDraws": 232,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {"killed":158,"total":158,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":8,"dmg":924432,"bossDmg":0,"heal":30129,"deaths":1,"layerGains":{"sargonShip":20}}
"p1": {"killed":158,"total":158,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":8,"dmg":924222,"bossDmg":0,"heal":29638,"deaths":1,"layerGains":{"sargonShip":20}}
},
"units": [
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_acfend",4974.95,1,8,0,0,0,0,0,0],
["p1",2,"op","chess_char_6_02_b",2,"uniequip_002_acpion",1904,1,6,0,0,0,0,0,0],
["p1",3,"op","chess_char_6_07_b",0,"uniequip_002_sharp2",2494,1,9,0,0,0,0,0,0],
["p1",4,"op","chess_char_6_10_b",0,"uniequip_002_sharp2",2494,1,11,20,146204,0,0,174,4],
["p1",5,"op","chess_char_6_11_b",2,"uniequip_002_acpion",1834,1,13,45,168227,12564,0,58,23],
["p1",6,"op","chess_char_6_06_b",2,"uniequip_002_acguad",3531,1,14,10,62057,943,0,157,4],
["p1",4,"op","chess_char_6_10_b",0,"uniequip_002_sharp2",2494,1,11,27,129672,0,0,173,3],
["p1",5,"op","chess_char_6_11_b",2,"uniequip_002_acpion",1904,1,13,35,186014,11957,0,60,20],
["p1",6,"op","chess_char_6_06_b",2,"uniequip_002_acguad",3531,1,14,18,61248,763,0,155,4],
["p1",7,"op","chess_char_5_22_b",2,"uniequip_002_acnipe",1587,1,4,0,0,0,0,0,0],
["p1",8,"op","chess_char_6_01_b",2,"uniequip_002_acnipe",1643,1,5,0,0,0,0,0,0],
["p1",9,"op","chess_char_6_04_b",2,"uniequip_002_acsupo",2465,1,137,8,65378,2465,0,91,2],
["p1",10,"op","chess_char_6_05_b",2,"uniequip_002_acnipe",1643,1,7,22,90616,0,0,96,2],
["p1",11,"op","chess_char_6_08_b",2,"uniequip_002_accast",1819,1,10,53,391951,0,0,83,2],
["p1",12,"op","chess_char_6_09_b",2,"uniequip_002_acmedc",1394,1,12,0,0,16762,30129,83,3],
["p1",8,"op","chess_char_6_01_b",2,"uniequip_002_acnipe",1643,1,5,0,948,0,0,4,1],
["p1",9,"op","chess_char_6_04_b",2,"uniequip_002_acsupo",2465,1,141,7,73191,2465,0,92,2],
["p1",10,"op","chess_char_6_05_b",2,"uniequip_002_acnipe",1643,1,7,20,89868,0,0,93,2],
["p1",11,"op","chess_char_6_08_b",2,"uniequip_002_accast",1819,1,10,51,383282,0,0,78,2],
["p1",12,"op","chess_char_6_09_b",2,"uniequip_002_acmedc",1394,1,12,0,0,16988,29638,83,3],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
@@ -522,19 +522,19 @@
"enemy_1121_lifbos_2": [1,1,0,0,24000],
"enemy_1148_dssbr": [1,1,0,0,2688],
"enemy_1148_dssbr_2": [1,1,0,0,3456],
"enemy_1158_divman": [1,1,0,195,3168],
"enemy_1158_divman": [1,1,0,153,3168],
"enemy_1160_hvyslr": [1,1,0,0,9600],
"enemy_1160_hvyslr_2": [1,1,0,133,13440],
"enemy_1161_tidmag": [1,1,0,1336,5760],
"enemy_1161_tidmag_2": [1,1,0,1391,7680],
"enemy_1249_lysdb_2": [60,60,0,1459,345600],
"enemy_1161_tidmag": [1,1,0,554,5760],
"enemy_1161_tidmag_2": [1,1,0,1758,7680],
"enemy_1249_lysdb_2": [60,60,0,2085,345600],
"enemy_1251_lysyta": [51,51,0,0,134640],
"enemy_1329_cbshld": [39,39,0,3019,374400]
"enemy_1329_cbshld": [39,39,0,2290,374400]
},
"hooks": [1,174,5967,836,836,1660,1803,179,159,159,40,38,0,28209,2688,60,158,0,1,159,3,1,23],
"events": {"atk":974,"bounty":4,"deploy":13,"die":159,"dmg":1782,"engage":6,"fx":330,"heal":135,"layer":3,"skill":78,"spawn":173,"status":334},
"fx": {"aoe":2,"burst":1,"dodge":28,"dp":68,"garrison":3,"shieldBreak":60,"slash":152,"sp":16},
"snaps": "526d4c63 f04a2d68 53d511ef 5f8b4334 d796abdd 122e9876 81bad943 4c6a838a eb463850 1e1d2025 875b599b 2d763f55 51548a07 73b07326 88a8ea6f ec211cd8 e329b226 03d1aa7b 63fd2bb2 afee939a"
"hooks": [1,174,5953,812,812,1599,1718,168,159,159,37,36,0,29335,2593,62,158,0,1,159,3,1,17],
"events": {"atk":917,"bounty":4,"deploy":13,"die":159,"dmg":1707,"engage":7,"fx":298,"heal":127,"layer":3,"skill":73,"spawn":173,"status":324},
"fx": {"aoe":2,"burst":1,"dodge":19,"dp":55,"garrison":3,"shieldBreak":60,"slash":144,"sp":12,"strike":2},
"snaps": "526d4c63 f61576c6 e1be6631 53288d14 7de80a4f 26575d3b 14b2dc7b 09bff769 03f7af9b 81a2c2a0 80d45921 19ddd297 25520dc8 270c7d09 288a246a 05a0a7ec ed5b2582 a7da7717 af272d65 435b22b3"
},
"standin-10": {
"about": "elite band 7 as stand-ins: chess_char_6_17_b→char_608_acpion chess_char_6_13_b→char_615_acspec chess_char_6_19_b→char_615_acspec chess_char_6_12_b→char_612_accast chess_char_6_15_b→char_614_acsupo chess_char_6_18_b→char_612_accast chess_char_6_20_b→char_613_acmedc",
@@ -545,46 +545,46 @@
"round": 13,
"seed": 3949114127,
"waveId": "act1autochess_h06",
"time": 216.2,
"ticks": 6486,
"reason": "cleared",
"killed": 25,
"time": 230,
"ticks": 6900,
"reason": "timeout",
"killed": 28,
"total": 131,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 245,
"rngDraws": 262,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {"killed":25,"total":131,"perfect":false,"leaks":106,"leaksUncounted":0,"coins":0,"dmg":508719,"bossDmg":0,"heal":10260,"deaths":25,"layerGains":{"raidShip":6}}
"p1": {"killed":28,"total":131,"perfect":false,"leaks":103,"leaksUncounted":0,"coins":2,"dmg":537630,"bossDmg":0,"heal":10974,"deaths":25,"layerGains":{"raidShip":6}}
},
"units": [
["p1",1,"op","chess_char_6_17_b",2,"uniequip_002_acpion",0,0,164,8,199340,14855,0,64,9],
["p1",2,"op","chess_char_6_13_b",2,"uniequip_002_acspec",0,0,163,6,43893,11872,0,99,0],
["p1",3,"op","chess_char_6_19_b",2,"uniequip_002_acspec",1484,1,165,3,65411,16324,0,126,0],
["p1",1,"op","chess_char_6_17_b",2,"uniequip_002_acpion",480.36,1,164,9,211459,13918,0,77,10],
["p1",2,"op","chess_char_6_13_b",2,"uniequip_002_acspec",0,0,165,5,46951,13356,0,105,0],
["p1",3,"op","chess_char_6_19_b",2,"uniequip_002_acspec",1484,1,166,4,74447,16324,0,138,0],
["p1",4,"op","chess_char_6_12_b",2,"uniequip_002_accast",2265.49,1,4,4,78264,281,0,32,2],
["p1",5,"op","chess_char_6_15_b",2,"uniequip_002_acsupo",0,0,147,2,52466,6493,0,38,3],
["p1",6,"op","chess_char_6_18_b",2,"uniequip_002_accast",1819,1,8,2,69344,618,0,44,2],
["p1",7,"op","chess_char_6_20_b",2,"uniequip_002_acmedc",1464,1,9,0,0,3373,10260,17,1],
["p1",5,"op","chess_char_6_15_b",2,"uniequip_002_acsupo",0,0,147,2,54506,6494,0,39,3],
["p1",6,"op","chess_char_6_18_b",2,"uniequip_002_accast",1819,1,8,4,72003,900,0,43,2],
["p1",7,"op","chess_char_6_20_b",2,"uniequip_002_acmedc",1464,1,9,0,0,4263,10974,19,1],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null]
],
"enemies": {
"enemy_1006_shield_3": [21,1,20,14948,163592],
"enemy_1043_zomsbr": [66,20,46,789,124139],
"enemy_1162_magmot": [1,0,1,4063,11534],
"enemy_1165_duhond": [1,1,0,139,4645],
"enemy_1006_shield_3": [21,1,20,16907,164045],
"enemy_1043_zomsbr": [66,22,44,926,136167],
"enemy_1162_magmot": [1,0,1,4704,11483],
"enemy_1165_duhond": [1,1,0,93,4645],
"enemy_1165_duhond_2": [1,0,1,0,0],
"enemy_1166_dusbr": [1,0,1,0,0],
"enemy_1166_dusbr_2": [1,0,1,0,0],
"enemy_1168_dumage": [1,1,0,1476,7465],
"enemy_1168_dumage_2": [1,0,1,3178,0],
"enemy_1169_duphlx": [1,0,1,0,0],
"enemy_1402_tgshd_2": [36,2,34,29224,197345]
"enemy_1168_dumage": [1,1,0,1897,7465],
"enemy_1168_dumage_2": [1,1,0,4932,10783],
"enemy_1169_duphlx": [1,0,1,0,1489],
"enemy_1402_tgshd_2": [36,2,33,26077,201554]
},
"hooks": [1,165,6486,549,549,865,877,6809,50,159,17,36,0,8308,1125,49,131,106,1,50,1,1,0],
"events": {"atk":554,"deploy":32,"die":53,"dmg":802,"engage":6,"fx":530,"heal":21,"layer":1,"leak":106,"skill":74,"spawn":140,"status":378},
"fx": {"aoe":4,"blink":3,"burst":1,"displace":17,"dodge":3,"dp":17,"expose":414,"garrison":1,"garrisonGrant":3,"slash":67},
"snaps": "80709a69 1b180600 2040e410 16970933 7ec28fab 061d6583 09f2da3e 98333fee 741e4051 c5f8165a 55db9474 b9b2bf46 d72aea5d 20d591d8 3077f062 ab32900d eb4540a2 37c492a6 3e3cd8f7 20a4cb8e 22ada53e b9ce62f2"
"hooks": [1,166,6900,579,579,900,914,7045,53,158,18,38,0,8737,1167,49,131,102,1,53,1,1,0],
"events": {"atk":586,"bounty":1,"deploy":33,"die":56,"dmg":848,"engage":6,"fx":538,"heal":25,"layer":1,"leak":102,"skill":78,"spawn":140,"status":384},
"fx": {"aoe":5,"blink":3,"burst":1,"displace":18,"dodge":1,"dp":19,"expose":413,"garrison":1,"garrisonGrant":3,"slash":74},
"snaps": "fba27448 de0d1730 e90ea321 52a21ef0 85a0833c 2a8e4ffa 3b4a24e3 f3d67a0b 9f0134f0 9cb0d19a e7da4c3d 843017d8 4557a167 130155ab 0afb1b21 d4aa9c6c c0ffca41 9e9e17c9 975fc2dd 20398d66 00b5e499 52760797 5a270974"
}
}
}
+17 -1
View File
@@ -143,7 +143,9 @@ describe('#8 深池逐火: a knock-out is a walking 隐匿 ember that a blocker
for (const u of dusk) assert.equal(deaths.get(u.id), 1, `${u.defId} #${u.id} dies exactly once`);
assert.equal(ember.size, dusk.length, 'every 逐火 went through its ember');
assert.deepEqual([...bad], [], 'ember rules');
assert.ok(walked > 1, `an ember whose blocker fell walks on (${walked.toFixed(2)} tiles)`);
// (until 0.2.0 one of 百炼嘉维尔's embers walked on once she fell; since 0.2.0 she — a 强攻手 — strikes up to her block count
// of targets (ai.js targetCount) and holds: an ember whose blocker falls is checked on its own in the test below)
assert.ok(walked >= 0);
});
for (const key of EMBERS) {
@@ -198,6 +200,20 @@ describe('#8 深池逐火: a knock-out is a walking 隐匿 ember that a blocker
assert.equal(h2.b.killed, 1);
assert.equal(h2.eventsOf('die').filter((ev) => ev[1] === f.id).length, 1, 'one die event');
checkInvariants(h2.b);
// its blocker falls: the ember walks on (隐匿 again — nobody else can target it)
const h3 = arena({ units: [{ chessId: 't_wall', row: 9, col: 7 }], kits: QUIET_GUNS });
h3.step();
const g = put(h3, key, { mods: { speedMul: 1 } });
assert.ok(h3.runUntil(() => !!g.blockedBy, 20), 'blocked');
h3.b.kill(g, null);
h3.run(HUSK_REBIRTH + 0.1);
assert.ok(g.blockedBy, 'an ember held by its blocker');
const gx = g.x, gy = g.y;
h3.b.kill(h3.unit('t_wall'), null);
h3.run(3);
assert.ok(g.alive && Math.hypot(g.x - gx, g.y - gy) > 0.5, `an ember whose blocker fell walks on (${Math.hypot(g.x - gx, g.y - gy).toFixed(2)} tiles)`);
checkInvariants(h3.b);
});
}
+17 -7
View File
@@ -4,6 +4,9 @@
// `attackAnim`, tools/build-data.mjs from the asset manifest; server/sim/ai.js attackStand). It used to stop only
// ATTACK_PAUSE (0.35 s) after each strike while the client played the attack clip over the whole walk. An enemy with no
// clip known keeps the old 0.35 s (test/sim/combat.test.js); a 「不停止移动」 attacker never stops; a stun cuts the stand.
// Since 0.2.0 (GitHub #187 / #170) every attack has its wind-up — the strike comes at the clip's damage frame after the
// swing starts, also the first one of an enemy whose cooldown ran out before it had a target (it used to strike at once)
// — and a stun before the frame cuts the swing (test/sim/feedback5-enemy-swing.test.js).
import { test } from 'node:test';
import assert from 'node:assert/strict';
@@ -101,9 +104,10 @@ test('a 「不停止移动」 attacker (attackMoves) keeps walking through its a
};
const moving = run(true), standing = run(false);
assert.ok(moving.atks.length >= 3 && standing.atks.length >= 3);
for (const t of moving.atks.slice(1, 3)) assert.ok(walked(moving.log, t - 0.5, t + 1) > 0.6, `walks through the attack at ${t.toFixed(2)}`);
// (the first attack lands as the target comes into range, its cooldown long over: no wind-up to stand through)
for (const t of standing.atks.slice(1, 3)) assert.equal(walked(standing.log, t - 0.5 + 2 * TICK, t + 1 - TICK), 0, 'the same enemy without it stands');
for (const t of moving.atks.slice(0, 3)) assert.ok(walked(moving.log, t - 0.5, t + 1) > 0.6, `walks through the attack at ${t.toFixed(2)}`);
// (since 0.2.0 the first attack too: its cooldown long over as the target comes into range, it swings from the start —
// the whole 0.5 s wind-up stood before the strike)
for (const t of standing.atks.slice(0, 3)) assert.equal(walked(standing.log, t - 0.5 + 2 * TICK, t + 1 - TICK), 0, 'the same enemy without it stands');
});
test('no attack clip known: ATTACK_PAUSE after the strike and no wind-up stand; a stun cuts a stand short', () => {
@@ -116,8 +120,13 @@ test('no attack clip known: ATTACK_PAUSE after the strike and no wind-up stand;
});
h.step();
const e = h.enemy('enemy_slow');
assert.ok(e.lastAttackAt >= 0, 'attacked at once');
assert.ok(Math.abs(e.atkStandUntil - (h.b.time - TICK + 2.5)) < 1e-6, 'stands the 2.5 s after the strike');
// (0.2.0: a target in range at once, its cooldown over — it swings at once and strikes at the 0.5 s damage frame; it
// used to strike in the first tick)
assert.equal(e.lastAttackAt, -Infinity, 'swinging, not struck yet');
h.runUntil(() => e.lastAttackAt >= 0, 2);
assert.ok(Math.abs(e.lastAttackAt - (TICK + 0.5)) < TICK + 1e-6, `struck at the damage frame (${e.lastAttackAt.toFixed(3)})`);
h.step();
assert.ok(Math.abs(e.atkStandUntil - (e.lastAttackAt + 2.5)) < 1e-6, 'stands the 2.5 s after the strike');
h.run(0.5);
const x0 = e.x;
h.b.applyStatus(e, 'stun', { duration: 0.5 });
@@ -156,8 +165,9 @@ test('a ranged enemy with a target in range on a WAIT checkpoint leaves when the
assert.equal(free.attacks, 0);
assert.ok(sniper.attacks >= 2 && quick.attacks >= 5);
assert.ok(Math.abs(sniper.waited - free.waited) < 1e-9 && Math.abs(quick.waited - free.waited) < 1e-9, `the wait ends at ${free.waited.toFixed(2)} s for all three`);
// it walks on once the attack clip running at the end of the wait is over (the attack at 2.7 s: until 3.2 s)
assert.ok(sniper.moved > free.moved && sniper.moved <= 3.2 + 2 * TICK, `leaves at ${sniper.moved?.toFixed(2)} s (no target: ${free.moved.toFixed(2)} s)`);
// it walks on once the attack clip running at the end of the wait is over (since 0.2.0 the first strike at its 0.5 s
// damage frame, the next one 2.7 s later at 3.2 s: its clip until 3.7 s; it used to strike at once — until 3.2 s)
assert.ok(sniper.moved > free.moved && sniper.moved <= 3.7 + 2 * TICK, `leaves at ${sniper.moved?.toFixed(2)} s (no target: ${free.moved.toFixed(2)} s)`);
assert.equal(quick.moved, null, 'an enemy attacking quicker than its clip stands while the target stays in range');
});
+48
View File
@@ -0,0 +1,48 @@
// test/sim/feedback5-block-targets.test.js — "同时攻击阻挡的所有敌人" takes up to the block count of targets (the queued 0.2.0
// question; PRTS 分支特性信息 强攻手 / 重剑手 / 推击手 "普通攻击最大目标数等于阻挡数(不会低于1)", PRTS 作战机制 §AOE伤害判定 "锁定人数的无
// 弹道AOE攻击(例如近卫分支“强攻手”)…在抬手时选取范围内的全体目标(不超过其攻击目标上限)"; the client's selector flag
// _limitedMaxTargetNumToBlockedCnt) — server/sim/ai.js acquireTargets / targetCount (`hitAllBlocked`). Until 0.2.0 it struck
// the enemies it blocked only (one in range when it blocked none).
// Run: node --test test/sim/feedback5-block-targets.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
// a 强攻手 (block 3) whose range is its tile and the three ahead
const CENT = chessRec({ id: 't_cent', subProfessionId: 'centurion', stats: { maxHp: 1e6, atk: 100, def: 1e4, blockCnt: 3, bat: 1 }, skill: null, rangeGrid: [[0, 0], [0, 1], [0, 2], [0, 3]] });
const dummy = (key) => enemyRec({ key, hp: 1e9, speed: 0, atk: 0 });
/** The targets of its first attack, with enemies at `at` ([row, col] each). */
function firstAttack(at, setup = null) {
const h = makeBattle({
defs: { chess: { t_cent: CENT }, enemies: { e: dummy('e') } }, units: [{ chessId: 't_cent', row: 10, col: 5 }],
enemies: at.map((pos) => ({ key: 'e', pos })), content: 'none', autoFinish: false, timeLimit: 30, hooks: ['attack'], captureNoisy: true,
setup,
});
const u = h.unit('t_cent');
assert.ok(h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === u), 3));
const atk = h.hooksOf('attack').find((c) => c.attacker === u);
checkInvariants(h.b);
return { h, u, targets: atk.targets };
}
test('blocking one enemy with two more in its range: one attack strikes all three (up to its block count, the blocked one first)', () => {
const { u, targets } = firstAttack([[10, 5], [10, 6], [10, 7]]);
assert.equal(u.blocking.length, 1, 'it blocks the one on its tile');
assert.equal(targets.length, 3);
assert.equal(targets[0], u.blocking[0], 'the blocked enemy first');
});
test('blocking none: up to its block count from the range; never more than the block count; never fewer than 1', () => {
const free = firstAttack([[10, 6], [10, 7], [10, 8]]);
assert.equal(free.u.blocking.length, 0);
assert.equal(free.targets.length, 3, 'three in range, none blocked: three targets');
const crowd = firstAttack([[10, 5], [10, 6], [10, 6], [10, 7], [10, 7], [10, 8]]);
assert.equal(crowd.targets.length, 3, 'six in reach: its block count of them');
assert.ok(crowd.u.blocking.every((e) => crowd.targets.includes(e)), 'every enemy it blocks among them');
const zero = firstAttack([[10, 6], [10, 7]], (b) => {
b.on('deploy', (c) => { if (c.unit.defId === 't_cent') b.addBuff(c.unit, { key: 'test:noBlock', mods: { blockCnt: -99 } }); });
});
assert.equal(zero.targets.length, 1, 'block count 0: still one target (不会低于1)');
});
+58
View File
@@ -0,0 +1,58 @@
// test/sim/feedback5-chain-heal.test.js — the 链愈师 heal chain (follow-up 27; PRTS 分支特性信息 链愈师 "跳跃范围为x-4,无特殊说明
// 的场合一次治疗链不会对已跳跃过的单位重复跳跃", "优先跳跃至范围内生命比例最低>部署时间点最晚的我方单位。可选择满生命我方单位为跳跃目标,
// 但仍受禁疗制约") — server/sim/ai.js chainHealNext / doHeal (莎草's profession chain; Mon3tr's kit uses the same links). It used
// to jump to the most injured ally within 2.5 tiles (never a full-HP one, ties by list order).
// Run: node --test test/sim/feedback5-chain-heal.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
const GRID = []; for (let r = -2; r <= 2; r++) for (let c = 0; c <= 6; c++) GRID.push([r, c]);
const HEALER = chessRec({ id: 't_chain', profession: 'MEDIC', subProfessionId: 'chainhealer', stats: { maxHp: 1e4, atk: 100, bat: 1 }, skill: null, rangeGrid: GRID });
const wall = (id) => chessRec({ id, stats: { maxHp: 1e4, atk: 0, def: 0 }, skill: null });
/** The healer on (10, 2) and walls at `at` = { id: [row, col, hpRatio] }; returns the chain of its first heal (ids → wall ids). */
function firstChain(at, setup = null) {
const ids = Object.keys(at);
const h = makeBattle({
defs: { chess: { t_chain: HEALER, ...Object.fromEntries(ids.map((id) => [id, wall(id)])) } },
units: [{ chessId: 't_chain', row: 10, col: 2 }, ...ids.map((id) => ({ chessId: id, row: at[id][0], col: at[id][1] }))],
content: 'none', autoFinish: false, timeLimit: 30, seed: 3,
});
h.step(); // deployed (the healer heals nobody yet: everyone is at full HP)
for (const id of ids) { const u = h.unit(id); u.hp = u.s.maxHp * at[id][2]; }
if (setup) setup(h);
const n0 = h.events.length;
for (let i = 0; i < 90 && !h.events.slice(n0).some((e) => e[0] === 'atk'); i++) h.step(); // (step drains the events)
const name = new Map(h.b.allyUnits.map((u) => [u.id, u.defId]));
const ev = h.events.slice(n0).filter((e) => e[0] === 'atk');
checkInvariants(h.b);
return { h, main: name.get(ev.find((e) => e[3] !== 'chainHeal')[2]), jumps: ev.filter((e) => e[3] === 'chainHeal').map((e) => [name.get(e[1]), name.get(e[2])]) };
}
test('the jump reaches the 3×3 around the last one healed (x-4), not an ally two tiles away; each jump goes on from where it landed', () => {
// A (most injured) on (10, 5); B two tiles right of it (inside 2.5 tiles, outside its 3×3), C on its diagonal
const r = firstChain({ t_a: [10, 5, 0.5], t_b: [10, 7, 0.6], t_c: [11, 6, 0.7] });
assert.equal(r.main, 't_a');
assert.deepEqual(r.jumps, [['t_a', 't_c'], ['t_c', 't_b']], 'A → C (its 3×3) → B (C\'s 3×3); it used to jump A → B → C');
});
test('a full-HP ally is a jump target when no injured one is in reach — healed for nothing, the chain jumps on from it', () => {
const r = firstChain({ t_a: [10, 5, 0.5], t_d: [10, 6, 1], t_e: [10, 7, 0.6] });
assert.equal(r.main, 't_a');
assert.deepEqual(r.jumps, [['t_a', 't_d'], ['t_d', 't_e']], 'A → D (full HP) → E');
assert.equal(r.h.unit('t_d').hp, r.h.unit('t_d').s.maxHp);
});
test('ties of the HP ratio: the latest deployed first; a 禁疗 unit is never a jump target', () => {
const tie = firstChain({ t_a: [10, 5, 0.5], t_f: [9, 5, 0.8], t_g: [11, 5, 0.8] });
const [f, g] = [tie.h.unit('t_f'), tie.h.unit('t_g')];
const later = f.aggroSeq > g.aggroSeq ? 't_f' : 't_g';
assert.deepEqual(tie.jumps[0], ['t_a', later], 'the later deployed of the two');
const banned = firstChain({ t_a: [10, 5, 0.5], t_n: [10, 6, 0.2], t_m: [11, 5, 0.9] }, (h) => {
h.b.addBuff(h.unit('t_n'), { key: 'test:healFree', status: 'healFree', flags: { noHeal: true, healFree: true } });
});
assert.equal(banned.main, 't_a', 'the 禁疗 unit is no main target either');
assert.deepEqual(banned.jumps[0], ['t_a', 't_m'], 'the 禁疗 unit is skipped');
});
+117
View File
@@ -0,0 +1,117 @@
// test/sim/feedback5-enemy-swing.test.js — an enemy's normal attack strikes at its clip's damage frame, and a stun before the
// frame cuts the swing (GitHub #187 point 1 「短轴眩晕无法打断敌人攻击,比如:六卡西米尔、异德三、忍冬三」, #170; PRTS 状态机
// ATTACK / COMBAT "每帧检查异常状态,若有则切换到异常状态的状态", PRTS 异常效果 STUNNED / DISARMED "正在进行的普通攻击将被中断") —
// server/sim/ai.js updateEnemy / enemyAttack / attackWindup. The damage frame is data/enemies.json `attackAnim.hit` (the
// clip's strike frame), shortened with the clip when the attacks come quicker than it; no clip known ⇒ no wind-up.
// Run: node --test test/sim/feedback5-enemy-swing.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { TICK } from '../../server/sim/constants.js';
import { attackWindup } from '../../server/sim/ai.js';
const HIT = 0.4; // the clip's strike frame
const BAT = 2; // attack interval
const near = (a, b, msg, ticks = 2) => assert.ok(Math.abs(a - b) <= ticks * TICK + 1e-9, `${msg}: ${a.toFixed(3)} vs ${b.toFixed(3)}`);
/** A wall operator and one enemy (melee on the wall's tile — blocked at once —, or ranged 3 tiles away). */
function field({ ranged = false, clip = true } = {}) {
const rec = {
...enemyRec({ key: 'enemy_sw', hp: 1e9, atk: 100, bat: BAT, speed: 0, ...(ranged ? { range: 3 } : null) }),
...(clip ? { attackAnim: { clip: 'Attack', dur: 1, hit: HIT } } : null),
};
const h = makeBattle({
defs: { chess: { t_wall: chessRec({ id: 't_wall', stats: { maxHp: 1e9, atk: 0, def: 0 }, skill: null }) }, enemies: { enemy_sw: rec } },
units: [{ chessId: 't_wall', row: 10, col: 6 }], enemies: [{ key: 'enemy_sw', pos: ranged ? [10, 9] : [10, 6] }],
content: 'none', autoFinish: false, timeLimit: 60, hooks: ['attack', 'damaged'], captureNoisy: true,
});
h.step();
return { h, e: h.enemy('enemy_sw'), wall: h.unit('t_wall') };
}
const strikes = (h, e) => h.hooksOf('attack').filter((c) => c.attacker === e).map((c) => c.t);
const hits = (h, e) => h.hooksOf('damaged').filter((c) => c.source === e && c.dmg.isAttack).map((c) => c.t);
const done = (h) => { checkInvariants(h.b); assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); };
test('attackWindup: the clip\'s strike frame, shortened with the clip when the attacks come quicker than it; 0 with no clip', () => {
const w = (anim, interval) => attackWindup({ def: { attackAnim: anim }, s: { interval } });
assert.equal(w({ dur: 1, hit: 0.4 }, 2), 0.4);
assert.equal(w({ dur: 2, hit: 1 }, 1), 0.5);
assert.equal(w({ dur: 1 }, 2), 0.5, 'no strike frame named: half the clip');
assert.equal(w(undefined, 2), 0);
});
test('a blocked enemy swings when its cooldown is over and strikes at the damage frame: the first strike one wind-up after the block (its cooldown long over), then one per attack interval', () => {
const { h, e } = field();
const t0 = h.b.time; // blocked in the first tick
h.run(4.6);
const s = strikes(h, e);
assert.ok(s.length >= 3, `${s.length} strikes`);
near(s[0], t0 + HIT, 'the first strike: the whole wind-up from the block (it used to land at once)');
near(s[1] - s[0], BAT, 'then one per interval', 1);
near(s[2] - s[1], BAT, 'then one per interval', 1);
assert.deepEqual(hits(h, e), s, 'melee: the damage at the strike');
done(h);
});
for (const dur of [0.1, 0.2]) {
test(`a ${dur} s stun before the damage frame cuts the swing: no strike at that frame, the attack starts again from its wind-up after the stun (卡西米尔 6's pulse, 忍冬 S3)`, () => {
const { h, e } = field();
assert.ok(h.runUntil(() => strikes(h, e).length === 1, 2));
const t1 = strikes(h, e)[0];
// its next swing starts at t1 + BAT − HIT; the stun lands 0.2 s into it
h.runUntil(() => h.b.time >= t1 + BAT - HIT + 0.2 - 1e-9, 3);
assert.ok(e.swing, 'swinging');
const tStun = h.b.time;
h.b.applyStatus(e, 'stun', { duration: dur });
h.run(dur + HIT + 0.3);
const s = strikes(h, e);
assert.equal(s.filter((t) => t > t1 && t < tStun + dur).length, 0, 'the cut swing never struck');
near(s[1], tStun + dur + HIT, 'the next strike: the stun, then the whole wind-up again', 3);
assert.ok(s[1] > t1 + BAT + dur + 0.1, `later than a mere pause of the countdown (${(t1 + BAT + dur).toFixed(2)})`);
done(h);
});
}
test('a stun during the cooldown (before the swing) only pauses it; 缴械 cuts a swing like a stun; a strike already made stays made (a ranged shot in flight lands)', () => {
// the cooldown: a 0.5 s stun 0.5 s after a strike delays the next one by 0.5 s
const a = field();
assert.ok(a.h.runUntil(() => strikes(a.h, a.e).length === 1, 2));
const t1 = strikes(a.h, a.e)[0];
a.h.run(0.5);
a.h.b.applyStatus(a.e, 'stun', { duration: 0.5 });
a.h.run(BAT + 0.3);
near(strikes(a.h, a.e)[1], t1 + BAT + 0.5, 'paused by the stun, no new wind-up', 3);
done(a.h);
// 缴械 mid-swing: the swing is cut, a new one starts once it ends
const b = field();
assert.ok(b.h.runUntil(() => strikes(b.h, b.e).length === 1, 2));
const u1 = strikes(b.h, b.e)[0];
b.h.runUntil(() => b.h.b.time >= u1 + BAT - HIT + 0.2 - 1e-9, 3);
const tD = b.h.b.time;
b.h.b.addBuff(b.e, { key: 'test:disarm', duration: 0.1, flags: { disarm: true } });
b.h.run(HIT + 0.5);
near(strikes(b.h, b.e)[1], tD + 0.1 + HIT, '缴械 "正在进行的普通攻击将被中断": the whole wind-up after it', 3);
done(b.h);
// ranged: stunned just after the strike — the shot flies on and lands
const c = field({ ranged: true });
assert.ok(c.h.runUntil(() => strikes(c.h, c.e).length === 1, 3));
assert.equal(hits(c.h, c.e).length, 0, 'the shot is in flight');
c.h.b.applyStatus(c.e, 'stun', { duration: 1 });
c.h.run(0.6);
assert.equal(hits(c.h, c.e).length, 1, 'a strike already made is not undone');
done(c.h);
});
test('no attack clip known: no wind-up — it strikes as its swing starts, so a stun only pauses its cooldown (the rule before 0.2.0)', () => {
const { h, e } = field({ clip: false });
assert.equal(attackWindup(e), 0);
h.step();
assert.equal(strikes(h, e).length, 1, 'struck in its first tick blocked');
const t1 = strikes(h, e)[0];
h.runUntil(() => h.b.time >= t1 + BAT - 0.1 - 1e-9, 3);
h.b.applyStatus(e, 'stun', { duration: 0.1 });
h.run(0.5);
near(strikes(h, e)[1], t1 + BAT + 0.1, 'paused by the stun', 3);
done(h);
});
+81
View File
@@ -0,0 +1,81 @@
// test/sim/feedback5-musha-heal.test.js — the 武者 trait heal on every damage instance (PRTS 分支特性信息 武者 "特性治疗于干员每次
// 输出伤害时触发(不局限于攻击)", "常态持有禁疗;通过自身特性/天赋/技能产生的作用于自身的治疗效果会无视自身的禁疗") —
// server/sim/professions.js installMushaHeal; 赫拉格's kit no longer patches his double strikes (op-helage.js), 左乐's S1 reads
// his HP when it fires (op-zuole.js).
// Run: node --test test/sim/feedback5-musha-heal.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
const VALUE = 50;
/** A 武者 (trait bb value 50) hitting `hits` times per attack; an enemy that dodges `dodge` of physical hits. */
function field({ hits = 1, dodge = 0, kit = null } = {}) {
const musha = chessRec({ id: 't_musha', subProfessionId: 'musha', stats: { maxHp: 1e6, atk: 100, bat: 1, def: 0 }, skill: null });
musha.trait = { desc: '', bb: { value: VALUE } };
const medic = chessRec({ id: 't_medic', profession: 'MEDIC', stats: { maxHp: 1000, atk: 300, bat: 1 }, skill: null, rangeGrid: [[0, 0], [0, -1], [0, -2]] });
const h = makeBattle({
defs: { chess: { t_musha: musha, t_medic: medic }, enemies: { e: { ...enemyRec({ key: 'e', hp: 1e9, speed: 0 }) } } },
kits: { t_musha: () => ({ trait: { hits, ...(kit?.trait ?? {}) }, ...(kit?.rest ?? {}) }) },
units: [{ chessId: 't_musha', row: 10, col: 5 }, { chessId: 't_medic', row: 10, col: 7 }], enemies: [{ key: 'e', pos: [10, 6] }],
content: 'generic', autoFinish: false, timeLimit: 60, hooks: ['heal', 'damaged', 'attack', 'dodge'], captureNoisy: true,
setup: (b) => { if (dodge) b.on('enemySpawn', ({ enemy }) => { b.addBuff(enemy, { key: 'test:dodge', mods: { dodgePhys: dodge } }); }); },
});
h.step();
const u = h.unit('t_musha');
u.hp = u.s.maxHp / 2;
return { h, u, e: h.enemies()[0] };
}
const traitHeals = (h, u) => h.hooksOf('heal').filter((c) => c.source === u && c.target === u && c.opts?.self && !c.opts?.tags);
const done = (h) => { checkInvariants(h.b); assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); };
test('a double strike heals twice: one heal of the trait value per damage instance of the attack (it used to heal once per enemy struck)', () => {
const { h, u } = field({ hits: 2 });
h.run(3.05);
const atks = h.hooksOf('attack').filter((c) => c.attacker === u).length;
const hits = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg.isAttack).length;
assert.ok(atks >= 3 && hits === 2 * atks, `${atks} attacks, ${hits} hits`);
assert.equal(traitHeals(h, u).length, hits, 'a heal per hit');
assert.ok(traitHeals(h, u).every((c) => c.amount === VALUE));
done(h);
});
test('every damage instance it outputs heals, not only attacks; a dodged hit, a 流失, an element 损伤 and a talent / DoT damage do not', () => {
const { h, u, e } = field({ dodge: 1 });
h.run(2.05);
assert.ok(h.hooksOf('dodge').some((c) => c.source === u), 'its hits were dodged');
assert.equal(traitHeals(h, u).length, 0, 'a dodged hit deals no damage: no heal');
const n0 = traitHeals(h, u).length;
h.b.dealDamage(u, e, { amount: 100, type: 'arts', isSkill: true, canDodge: false, tags: ['skill'] });
h.b.dealDamage(u, e, { amount: 100, type: 'true', canDodge: false, tags: ['item', 'item:test'] });
assert.equal(traitHeals(h, u).length - n0, 2, 'skill and item damage heal');
h.b.loseHp(e, 100, { source: u });
h.b.dealDamage(u, e, { type: 'element', element: 'burn', amount: 100 });
h.b.dealDamage(u, e, { amount: 100, type: 'arts', canDodge: false, tags: ['talent', 'dot'] });
assert.equal(traitHeals(h, u).length - n0, 2, 'no heal for a 流失, an element 损伤 or a DoT');
done(h);
});
test('禁疗 does not stop the trait heal ("会无视自身的禁疗"); another unit\'s heal still does not reach it', () => {
const { h, u } = field();
h.b.addBuff(u, { key: 'test:healFree', status: 'healFree', flags: { noHeal: true, healFree: true } });
const hp0 = u.hp;
h.run(1.05);
assert.ok(traitHeals(h, u).length >= 1 && u.hp > hp0, 'healed through 禁疗');
assert.equal(h.hooksOf('heal').filter((c) => c.target === u && c.source !== u).length, 0, 'the medic never heals it');
assert.equal(h.b.heal(h.unit('t_medic'), u, 500), 0);
done(h);
});
test('宴 (the chess): one heal per hit of her attacks', () => {
const h = makeBattle({
defs: { enemies: { e: enemyRec({ key: 'e', hp: 1e9, speed: 0 }) } }, units: [{ chessId: 'chess_char_1_18_a', row: 10, col: 5 }],
enemies: [{ key: 'e', pos: [10, 6] }], autoFinish: false, timeLimit: 60, hooks: ['heal', 'damaged'], captureNoisy: true, seed: 5,
});
h.run(8);
const u = h.unit('chess_char_1_18_a');
const hits = h.hooksOf('damaged').filter((c) => c.source === u && c.target.side === 'enemy' && c.type !== 'element' && !(c.dmg.tags || []).includes('dot')).length;
assert.ok(hits >= 3);
assert.equal(traitHeals(h, u).length, hits);
done(h);
});
+118
View File
@@ -0,0 +1,118 @@
// test/sim/feedback5-mystic-store.test.js — the 秘术师 energy store at the attack check (GitHub #181; PRTS 分支特性信息 秘术师
// "能量储存与攻击占用相同的攻击间隔:在进行攻击判定时,若范围内存在有效目标,则进行普通攻击;若无有效目标且能量储存数未满,则改为储存
// 一份攻击能量(属于攻击行为);仅没有有效目标,且能量储存数已满的情况下才进入待机状态", "攻击被打断且弹道未能成功生成的情况下,储存的
// 能量不会被消耗"; PRTS 异常效果 缴械 "秘术师储存能量同样无法进行") — server/sim/professions.js installMystic (storeEnergy /
// releaseEnergy / hitsFn), server/sim/ai.js updateAlly / performAttack, 深靛's kit (a bound enemy is no valid target).
// Run: node --test test/sim/feedback5-mystic-store.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
const ds = getDefaultSource();
const ID = 'chess_char_1_17_a'; // 深靛 (normal: trait times 3)
const ELITE = 'chess_char_1_17_b'; // 深靛 elite (MSC-X: times 4)
const TICK = 1 / 30;
const approx = (a, b, msg, eps = TICK + 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, ...o });
/** Her record without the talent's random bind (柔光缚目 prob 0): the binds of these tests are placed by hand. */
const noBind = (id) => {
const r = ds.rawChess(id);
return { [id]: { ...r, talents: r.talents.map((t) => ({ ...t, bb: { ...t.bb, prob: 0 } })) } };
};
function field({ id = ID, enemies = [], setup } = {}) {
return makeBattle({
seed: 3, autoFinish: false, timeLimit: 300, hooks: ['attack', 'damaged'], captureNoisy: true, setup,
defs: { chess: noBind(id), enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4 }], enemies,
});
}
const attacksOf = (h, u) => h.hooksOf('attack').filter((c) => c.attacker === u);
const done = (h) => { checkInvariants(h.b); assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); };
test('no target: her first attack check stores at once (an attack action: the interval restarts), then one energy per attack interval up to the trait times; full ⇒ idle with the attack ready', () => {
for (const [id, max] of [[ID, 3], [ELITE, 4]]) {
const h = field({ id });
const u = h.unit(id);
h.step();
assert.equal(u.trait.stored, 1, `${id}: the attack is ready at deployment — stored at the first check`);
const iv = u.s.interval;
approx(u.atkCd, iv, `${id}: storing took the attack interval`, 1e-9);
let last = h.b.time;
for (let n = 2; n <= max; n++) {
assert.ok(h.runUntil(() => u.trait.stored === n, iv + 1), `${id}: energy ${n}`);
approx(h.b.time - last, iv, `${id}: energy ${n} one interval after the last`);
last = h.b.time;
}
h.run(3 * iv);
assert.equal(u.trait.stored, max, `${id}: at most ${max}`);
assert.equal(u.atkCd, 0, `${id}: full — idle, the attack ready`);
assert.equal(attacksOf(h, u).length, 0);
done(h);
}
});
test('a target in range while she holds energies: attacked at once, the energies leave with that attack and land with its main hit, one arts attack hit each', () => {
const h = field({ enemies: [{ key: 'e', pos: [10, 6], time: 12 }] });
const u = h.unit(ID);
assert.ok(h.runUntil(() => attacksOf(h, u).length === 1, 15));
approx(h.b.time, 12, 'attacked in the tick the enemy appeared (the full store idled with the attack ready)', 2 * TICK);
assert.equal(u.trait.stored, 0, 'released with the attack (before the bolt lands)');
h.run(1);
const hits = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg.isAttack);
assert.equal(hits.length, 1 + 3, 'the main hit + 3 energies');
assert.ok(hits.every((c) => c.type === 'arts' && c.dmg.attackId === hits[0].dmg.attackId), 'arts, one attack');
assert.ok(hits.every((c) => Math.abs(c.amount - hits[0].amount) < 1e-6), 'each energy hits as the main hit');
done(h);
});
test('a bind on her only target is no valid target: her next attack check stores an energy, and the freed enemy waits for the interval the store took (GitHub #181)', () => {
const h = field({ enemies: [{ key: 'e', pos: [10, 6] }] });
const u = h.unit(ID);
assert.ok(h.runUntil(() => attacksOf(h, u).length === 1, 2));
const t0 = h.b.time, iv = u.s.interval;
assert.equal(u.trait.stored, 0);
const [e] = h.enemies();
h.b.applyStatus(e, 'bind', { duration: 4 });
assert.ok(h.runUntil(() => u.trait.stored === 1, iv + 1));
approx(h.b.time - t0, iv, 'stored at her next attack check, during the bind');
assert.equal(attacksOf(h, u).length, 1, 'no attack at the bound enemy');
assert.ok(h.runUntil(() => attacksOf(h, u).length === 2, iv + 1));
approx(h.b.time - t0, 2 * iv, 'the bind ended at +4 s; her next check comes an interval after the store', 2 * TICK + 1e-6);
assert.equal(u.trait.stored, 0, 'the energy left with that attack');
h.run(1);
const n = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg.isAttack && c.t > t0 + 2 * iv - 0.1).length;
assert.equal(n, 2, 'the main hit + the stored energy');
done(h);
});
test('disarmed (缴械) or stunned: no storing; an attack cancelled before its shot keeps the energies; a redeployment holds none [ASSUMED]', () => {
const h = field();
const u = h.unit(ID);
h.step();
assert.equal(u.trait.stored, 1);
h.b.addBuff(u, { key: 'test:disarm', duration: 10, flags: { disarm: true } });
h.run(9.5);
assert.equal(u.trait.stored, 1, 'disarmed: "秘术师储存能量同样无法进行"');
h.run(1);
assert.equal(u.trait.stored, 2, 'stored at the first check after the disarm');
h.b.applyStatus(u, 'stun', { duration: 10 });
h.run(9.5);
assert.equal(u.trait.stored, 2, 'stunned: no attack check');
h.run(4);
assert.equal(u.trait.stored, 3);
// an attack whose shot never forms (beforeAttack empties it): the energies stay
let cancel = true;
h.b.on('beforeAttack', (ctx) => { if (ctx.attacker === u && cancel) ctx.targets = []; });
h.spawn('e', { pos: [10, 6] });
h.run(0.5);
assert.equal(attacksOf(h, u).length, 0);
assert.equal(u.trait.stored, 3, '"攻击被打断且弹道未能成功生成的情况下,储存的能量不会被消耗"');
cancel = false;
// knocked out holding them: the redeployment starts empty
h.b.retreat(u);
h.b.redeploy(u, { free: true });
assert.equal(u.trait.stored, 0, 'a redeployment holds no energy');
done(h);
});
+1 -1
View File
@@ -1127,7 +1127,7 @@ export function compareFamily(stored, computed) {
}
export function formatDiffs(family, list, { maxScenarios = 12, maxDiffs = 8 } = {}) {
const fmt = (v) => { const s = typeof v === 'string' ? v : JSON.stringify(v); return s.length > 160 ? `${s.slice(0, 157)}…` : s; };
const fmt = (v) => { const s = typeof v === 'string' ? v : (JSON.stringify(v) ?? String(v)); return s.length > 160 ? `${s.slice(0, 157)}…` : s; };
const lines = [`golden ${family}: ${list.length} scenario(s) differ`];
for (const { scenario, diffs } of list.slice(0, maxScenarios)) {
lines.push(` ${scenario}:`);