Deploy-timed skills run the normal duration lifecycle: one charge on deployment, a duration bar, a real end, again on the next deployment (#109)
宴 S2, 斯卡蒂 S2, 野鬃 S1, 砾 S1 / S2, 伊内丝 S3, 缄默德克萨斯 S1–S3 and 耀骑士临光 S2 used a separate timed buff while the skill stayed active; equipment and bond effects that read the skill state now see it end. 伊内丝's first sentry placement starts no skill. Contributed by @Sukvii.
This commit is contained in:
1 parent
ede8e7e773
commit
ffdb1293c4
22 files changed
+607
-104
No files matched your search
+11
-1
@@ -281,7 +281,14 @@ Engine helpers (content must use these, never touch internals): `battle.dealDama
|
||||
|
||||
SP: `spType` `INCREASE_WITH_TIME` (+`spRecovery`/s), `INCREASE_WHEN_ATTACK` (+1 per attack), `INCREASE_WHEN_TAKEN_DAMAGE` (+1 per hit taken); starts at `initSp`; no SP gain while a duration skill is active. `maxChargeTime > 1` ⇒ charges.
|
||||
|
||||
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), `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; and 余 S2, `SKILL_RANGE` on its x-1 (§22.10)), `SP_FULL` (immediately — 执旗手 / 战术家 / 吟游者 MANUAL skills, and AUTO DP skills such as 伺夜 S1, 焰尾 S1, 凛御银灰 S1), `CUSTOM_RANGE` (enemy inside the custom grid), `SEARCH` (an enemy inside the initial range, checked every tick — no attack needed; 解放者 / 阵法术师 MANUAL skills), `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. PASSIVE skills are always on.
|
||||
Timed deployment skills use `kind: 'duration'`, `activateOnDeploy: true`, `spCost: 0`, `spType: 'none'`, `trigger: 'NEVER'`.
|
||||
Deployment sets landing state, then `reset()` consumes one charge through `activate('deploy')`, then emits `deploy`;
|
||||
`skillStart` fires before deploy hooks. In 联防 this is a fresh deployment activation; no previous active skill state is restored.
|
||||
Duration end cleans the window and emits `skillEnd`; it leaves zero charges and `ready=false`, even after SP gifts.
|
||||
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), `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; and 余 S2, `SKILL_RANGE` on its x-1 (§22.10)), `SP_FULL` (immediately — 执旗手 / 战术家 / 吟游者 MANUAL skills, and AUTO DP skills such as 伺夜 S1, 焰尾 S1, 凛御银灰 S1), `CUSTOM_RANGE` (enemy inside the custom grid), `SEARCH` (an enemy inside the initial range, checked every tick — no attack needed; 解放者 / 阵法术师 MANUAL skills), `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
|
||||
@@ -290,6 +297,7 @@ export default {
|
||||
chess_char_1_01: (bb, chess) => ({
|
||||
skill: {
|
||||
kind: 'duration' | 'ammo' | 'instant' | 'charges' | 'passive' | 'toggle',
|
||||
activateOnDeploy: false, // optional boolean: consume a deployment charge synchronously in reset
|
||||
duration: bb.duration, // duration kind
|
||||
ammo: bb['attack@trigger_time'], // ammo kind
|
||||
mods: { atkPct: bb.atk, batPct: bb.base_attack_time }, // applied while active
|
||||
@@ -423,6 +431,8 @@ S→C:
|
||||
- `m.private` — the recipient's full PlayerState view (§6.2 minus server-only data) on every change.
|
||||
- `m.field` — field meta when watching starts: `{ fieldId, kind, rect, stageId, units: [UnitInfo], effects? }` (the units on the field, knocked-out operators waiting to redeploy included — `Battle.fieldMeta`, §18.3). The prep-scouting field (`Match.prepFieldMeta`) puts the scouted player's **hand (整备区) and 临时整备区 into `units` as well**: held pieces as units on the hand row (row 7, col = hand slot) and temp pieces on the temp row (row 8, cols 4..8 = the temp slots; PRTS 帮助 counts the temp area with the hand; a held item is a `kind: 'item'` unit the client draws as its floating plate) — the scout renders exactly like the own prep bench, for every watcher alike, the spectator seat's copy equal to a teammate's (§23.19's "never a private view" stays the no-`m.private` rule); the scout signature `_prepScoutSig` carries the hand / temp slots, so a piece moved to another slot is a change — and carries `effects`: the scouted player's effects view (策略 / 机变 / 悬赏 …), the watched player's effects column ready-made
|
||||
- NOTE: game time in `b.snap`/`b.ev` is carried as **`gt`** (the frame's `t` is the message type).
|
||||
- Active finite zero-SP timed skills display `sp=timeLeft`, `spMax=duration` and `UF.SKILL`; after end they display
|
||||
`0/0`. Snapshot display capacity does not change runtime SP/cost.
|
||||
- `b.snap` — `{ fieldId, gt, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]] , dp, killed, total, down?, elem? }` 20 Hz. Optional, only when non-empty (§18.3): `down: [[id, respawnAt (game s), respawnTime (s), state, row, col]]` — knocked-out operators waiting to redeploy, `state` 0 counting | 1 timer done, waiting for DP | 2 timer done, its tile taken — a safeguard, no ally deploys there (sim/constants.js `DOWN_STATE`); `row, col` = the tile it lies on and comes back on (§21.24); `elem: [[id, element, fill, cooldownEnd (game s), cooldown (s)]]` — the one gauge a unit shows: the fullest (ties by the official element id 神经 < 侵蚀 < 灼燃 < 凋亡), fill with 2 decimals rounded DOWN and kept at 0.01–0.99 outside a burst (§20.8); during a 爆发冷却 the bursting element with fill 1 and the cooldown numbers (else 0, 0)
|
||||
- `b.ev` — `{ fieldId, ev: [ ... ] }` event tuples: `['spawn', UnitInfo]`, `['atk', srcId, tgtId, projKind]`, `['dmg', tgtId, amount, type]`, `['heal', tgtId, amount]`, `['skill', id, on:0|1]`, `['die', id, reason]` (reason `'killed'`, `'retreat'`, `'expired'` …; only `'killed'` plays an operator's knock-down sound), `['status', id, key, on]`, `['fx', kind, x, y, extra]` (`'hitCap' {id, n}` = a cancelled 限伤 hit, drawn as nothing, §20.12; `extra.form` = the unit's model form from then on (an enemy's mode, a 傀儡师's 替身 §22.11) — `shared/protocol.js fxForm`, state like a spawn: never dropped by the client, §21.4), `['layer', playerId, bondId, n]`, `['bounty', playerId, coins]`, `['deploy', id]`
|
||||
- `m.toast {kind, text}` · `m.ticker {text, id, type, priority, playerId}` (the strip plays the highest priority first, §21.10) · `m.emote {playerId, id}` · `m.result {...}`
|
||||
|
||||
+26
-3
@@ -150,6 +150,8 @@ later redeploys keep those gifts); `carryState: { sp }` a board summon
|
||||
piece's SP only (PRTS "召唤物仅修改技力"). Nothing else is carried: a skill running at the end of the own combat enters
|
||||
switched off with the SP it had left — spent at its activation, 0 for a one-charge skill (`unitsEnd.skillActive` is
|
||||
reported, never carried; community report #34 — it used to restart for free).
|
||||
Timed deployment skills (`activateOnDeploy`) trigger once from the new deployment, consuming their deployment charge;
|
||||
they do not resume the previous skill state.
|
||||
`carryState: { down: true }` = an operator knocked out at the end of the helper's own combat (PRTS 卫戍协议/帮助 "上一阶段为
|
||||
退场状态的干员强制退场", user playtest #5 item 2): `start()` deploys it with everyone (initial `deploy` fires), then — before
|
||||
`battleStart` — withdraws it with reason `FORCED_EXIT` (constants.js `'forcedExit'`) and HP 0 (the end-of-phase HP ratio, as after
|
||||
@@ -830,6 +832,11 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged
|
||||
- Kinds: `duration` (mods for `duration` s), `ammo` (mods until `ammo` attacks were made, optional duration cap),
|
||||
`instant` (onStart + optional one-shot attack override applied to the next attack), `charges` (instant with charges),
|
||||
`passive` (always on from deployment, no SP), `toggle` (stays on until death once activated).
|
||||
- Timed deployment skills use `duration`, `activateOnDeploy: true`, `spCost: 0`, `spType: 'none'`, `trigger: 'NEVER'`.
|
||||
Landing state is set before `reset()` synchronously calls `activate('deploy')`; `skillStart` precedes the `deploy`
|
||||
hooks. Each deployment grants and consumes one charge. Duration end clears effects and fires `skillEnd('duration')`;
|
||||
staying deployed or receiving SP never refills that charge. Early removal ends the active window with `death`;
|
||||
removing an expired skill emits no second end. `activations` accumulates across deployments.
|
||||
- `end()`: `active` turns false, then **onEnd runs while the skill's mods / range are still applied** (finishers and
|
||||
end bursts use the skill's stats and range), then they are removed (kept when onEnd re-activated the skill), then
|
||||
`skillEnd` fires. `onAttack` ctx carries `noAmmo` (set it to true: this attack spends no bullet, no `ammoUsed` — 流明's
|
||||
@@ -869,6 +876,17 @@ has the generic spec. Summons: `battle.tokenDef(tokenId, unit)` / `battle.spawnT
|
||||
(token skill of that slot: `variants[owner].bySkill[i]`, module attributes: `.byModule[id]`). Coverage of the selectable
|
||||
skills: `node tools/kit-coverage.mjs --missing [--tier N] [--strict]`.
|
||||
|
||||
Timed deployment kits: 宴 S2 uses `bb.duration` and skill ATK mods, loses current HP once on landing, and converts normal
|
||||
physical attacks to arts only while S2 is active; S1 has no such conversion. 斯卡蒂 S2 uses its own record's
|
||||
`s.bb.duration` / `s.bb.atk`; DRE-Y revival in place does not restart it. 野鬃 S1 uses its record's `duration` /
|
||||
`bb.attack_speed`, with the existing deploy discount talent. 砾 S1/S2 retain their one-second DEF/barrier decay steps
|
||||
(damaged barrier scales with capacity); skill end removes the decay buff. 伊内丝 S3's first deployment places the sentry
|
||||
with zero effect time and retreats with a refreshed redeploy timer; later deployments recall it and open the full
|
||||
ATK/DP window, then stay deployed. 缄默德克萨斯 S1/S2/S3's first kill per deployment heals and explicitly ends/restarts
|
||||
the skill for a full duration, including a synchronous kill inside its deploy burst; S3 rain is cancelled on end,
|
||||
while enemy silence/DoT/RES cuts keep their own expiry. 耀骑士临光 S2 ends naturally by retreating with the preceding
|
||||
operator's faction-based redeploy multiplier; an early death only cleans the skill and shields.
|
||||
|
||||
Element conventions of the kits (user playtest #5 #3; official term dictionary: 元素损伤 = the gauge, 元素伤害 = HP damage):
|
||||
- **元素损伤 dealt** = `{ type: 'element', element, amount }` with the official base: "N%攻击力的…损伤" ⇒ `N × ATK` at that
|
||||
moment, "伤害N%的…损伤" / "相当于法术伤害N%" ⇒ `N ×` the HP damage just dealt (post-mitigation, `damaged` ctx.amount).
|
||||
@@ -896,9 +914,10 @@ Element conventions of the kits (user playtest #5 #3; official term dictionary:
|
||||
```js
|
||||
{
|
||||
kind: 'duration'|'ammo'|'instant'|'charges'|'passive'|'toggle',
|
||||
activateOnDeploy: bool, // optional: reset activates synchronously and consumes a deployment charge
|
||||
duration, // s (duration kind; optional cap for ammo)
|
||||
ammo, // attacks (ammo kind)
|
||||
spCost, initSp, charges, spType: 'time'|'attack'|'hurt', // optional overrides of the data values
|
||||
spCost, initSp, charges, spType: 'time'|'attack'|'hurt'|'none', // optional overrides of the data values
|
||||
trigger: 'DEFAULT' | { rule, grid, allies?, hpAtMost? }, // optional override (allies / hpAtMost: an injured ally condition, §7.1)
|
||||
heal: bool, // heal-type skill for the DEFAULT trigger (default: unit is a healer)
|
||||
mods: { …mod keys }, // buff while active (instant: only during the pending attack)
|
||||
@@ -941,8 +960,10 @@ whose skill "恢复…友方/友军…生命" heals the most injured ally in ran
|
||||
"立即流失N%当前生命" ⇒ self HP loss at start (宴, 风丸); `hp_ratio` + "恢复/回复…生命" ⇒ self heal at start; `force` ⇒ on-hit
|
||||
displacement by the official 力度 − 重量 rules (拖拽/hookmaster: `pullToFront`; else `push`, directional for 往攻击方向 / 朝部署方向 /
|
||||
向前 / 身前方向 and 推击手, radial otherwise — §6; only a fallback: hand-written kits follow the client templates
|
||||
`knockback[dir]` / `knockback[relative]`, e.g. 琳琅诗怀雅 S3's "向前推开" is radial). **Passive** skills only apply stat mods (timed when the text says "N秒内":
|
||||
宴 +65 % ATK for 14 s) and the self/counter effects — their scales describe procs that need a kit. Instant skills with
|
||||
`knockback[dir]` / `knockback[relative]`, e.g. 琳琅诗怀雅 S3's "向前推开" is radial). A passive with self stat mods,
|
||||
positive `bb.duration` and "N秒内" uses a deployment duration (宴 +65 % ATK for 14 s); its mods, targeting, arts conversion
|
||||
and attack/status overrides follow that window. Other **passive** skills apply permanent stat mods and self/counter
|
||||
effects — their scales describe procs that need a kit. Instant skills with
|
||||
mods/targeting but no attack override apply them to the next attack (the skill range is switched in for that attack).
|
||||
|
||||
### 7.5 Worked examples (real operators, numbers from blackboards)
|
||||
@@ -1161,6 +1182,8 @@ Unknown subprofessions fall back to the profession default (test `professions.te
|
||||
- `snapshot()` → `{ fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, dps?, boss?, down?, elem? }`.
|
||||
`sp/spMax` show remaining duration/ammo as a draining bar while a timed skill is active (ammo: `ammoLeft / ammoMax`, the
|
||||
activation's real total — 拉特兰's and 逃犯引渡手续's extra bullets included, community report #35). Units in DIE state stay 0.8 s.
|
||||
Active finite zero-SP duration skills display `timeLeft/duration` using `duration` as `spMax`; after end they show
|
||||
`0/0`. This display capacity leaves runtime SP/cost unchanged; active skills carry `UF.SKILL`.
|
||||
`down: [[id, respawnAt, respawnTime, state, row, col]]` (only when non-empty) = operators lying down waiting to redeploy
|
||||
(`Battle.isDown(u)`: reason `'killed'` or `FORCED_EXIT` (§1.1 carryState `down`), or a forced exit `'retreat'` /
|
||||
`'merchant'` (GitHub #60) — every removal but the 突袭 `'raid'` —, not removed for good, deployed at least once, a finite
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
// "附带…凋亡/灼燃/神经损伤" + ep_damage_ratio ⇒ element damage on hit (× damage dealt when the text says "伤害N%的…损伤",
|
||||
// else × ATK); "屏障" + shield_max_hp_ratio / hp_ratio ⇒ self shield at start decaying over its duration (砾, 新约能天使);
|
||||
// "立即流失N%当前生命" + hp_ratio ⇒ self HP loss at start (宴, 风丸); hp_ratio + "恢复/回复…生命" ⇒ self heal at start.
|
||||
// Passive skills only apply stat mods (for bb.duration s when the text says "N秒内": 宴) and the self/counter effects
|
||||
// Passive skills with stat mods + bb.duration + "N秒内" use a deployment duration; other passives apply stat mods and the self/counter effects
|
||||
// above — their scales describe procs (bombs, sword rain, counters) that need a hand-authored kit.
|
||||
// force→onHit displacement with the official 力度 − 重量 rules (Battle.push / pullToFront): a pull "至面前" when the text says
|
||||
// 拖拽 or for hookmasters, else a push — along the unit's direction when the text says 往攻击方向 (the 推击手 wording, PRTS
|
||||
@@ -107,10 +107,8 @@ export function genericSkillSpec(sk, bb = sk?.bb ?? {}, def = null) {
|
||||
if (!sk) return null;
|
||||
const g = getter(bb);
|
||||
const ga = attackGetter(bb);
|
||||
const kind = genericKind(sk, bb);
|
||||
let kind = genericKind(sk, bb);
|
||||
const desc = String(sk.description || '');
|
||||
const passive = kind === 'passive';
|
||||
const timed = kind === 'duration' || kind === 'ammo' || kind === 'toggle';
|
||||
// "受到攻击时…造成…" numbers belong to a counter effect (the operator's own, or an ally's: 刺玫 "该角色受到攻击时")
|
||||
const counterCtx = /受到(敌人的)?攻击时/.test(desc);
|
||||
const counterText = counterCtx && !/该(角色|干员|单位)受到攻击时/.test(desc);
|
||||
@@ -149,9 +147,16 @@ export function genericSkillSpec(sk, bb = sk?.bb ?? {}, def = null) {
|
||||
set('resIgnoreFlat', g('magic_resist_penetrate_fixed'));
|
||||
set('defIgnoreFlat', g('def_penetrate_fixed'));
|
||||
|
||||
// Effect inference keeps the original kind; a deployment window only changes the lifecycle.
|
||||
const passive = kind === 'passive';
|
||||
const timed = kind === 'duration' || kind === 'ammo' || kind === 'toggle';
|
||||
const passiveTimed = passive && Object.keys(mods).length && num(bb.duration) > 0 && /\d+(\.\d+)?秒内/.test(desc) ? num(bb.duration) : 0;
|
||||
if (passiveTimed) kind = 'duration';
|
||||
|
||||
// ---- targeting / attack override (never for passives: their scales describe procs)
|
||||
const targeting = {};
|
||||
const attack = {};
|
||||
if (passiveTimed && /攻击[^。;]*(造成|变为|变成)[^。;]*法术伤害|伤害类型变为法术/.test(desc)) attack.dmgType = 'arts';
|
||||
const mt = ga('max_target');
|
||||
if (!passive && mt !== undefined && mt > 0) targeting.maxTargets = Math.floor(mt);
|
||||
const ext = g('ability_range_forward_extend');
|
||||
@@ -273,13 +278,12 @@ export function genericSkillSpec(sk, bb = sk?.bb ?? {}, def = null) {
|
||||
id: sk.id,
|
||||
name: sk.name,
|
||||
kind,
|
||||
duration: kind === 'duration' ? sk.duration : (kind === 'ammo' && sk.duration > 0 ? sk.duration : undefined),
|
||||
duration: kind === 'duration' ? (passiveTimed || sk.duration) : (kind === 'ammo' && sk.duration > 0 ? sk.duration : undefined),
|
||||
ammo,
|
||||
};
|
||||
|
||||
// passive stat buffs limited in time ("部署后…在14秒内攻击力+65%", 宴) become a timed buff at each deployment
|
||||
const passiveTimed = passive && Object.keys(mods).length && num(bb.duration) > 0 && /\d+(\.\d+)?秒内/.test(desc) ? num(bb.duration) : 0;
|
||||
if (Object.keys(mods).length && !passiveTimed) spec.mods = mods;
|
||||
if (passiveTimed) Object.assign(spec, { activateOnDeploy: true, spCost: 0, spType: 'none', trigger: 'NEVER' });
|
||||
if (Object.keys(mods).length) spec.mods = mods;
|
||||
if (Object.keys(targeting).length) spec.targeting = targeting;
|
||||
// instant/charges skills act on the next attack: mods/targeting without an explicit attack still need one
|
||||
if (!Object.keys(attack).length && (kind === 'instant' || kind === 'charges') && (spec.mods || spec.targeting)) spec.attack = {};
|
||||
@@ -288,10 +292,6 @@ export function genericSkillSpec(sk, bb = sk?.bb ?? {}, def = null) {
|
||||
// ---- start / end effects
|
||||
const starts = [];
|
||||
const ends = [];
|
||||
if (passiveTimed) {
|
||||
const m = { ...mods };
|
||||
starts.push(({ battle, unit }) => battle.addBuff(unit, { key: `generic:passive:${sk.id ?? 'skill'}`, duration: passiveTimed, mods: m, tags: ['skill'] }));
|
||||
}
|
||||
const hr = g('hp_ratio');
|
||||
if (hr !== undefined && hr > 0 && hr <= 1 && /立即流失\d+(\.\d+)?%(的)?当前生命/.test(desc)) {
|
||||
starts.push(({ battle, unit }) => { const loss = unit.hp * hr; if (loss > 0 && unit.hp - loss >= 1) battle.loseHp(unit, loss, { source: unit }); });
|
||||
|
||||
@@ -901,7 +901,6 @@ export default {
|
||||
// hp_recovery_per_sec_by_max_hp_ratio × max HP per second (the 生命回复速度 attribute: works under her 武者 no-heal).
|
||||
chess_char_1_18_a: (bb, chess) => {
|
||||
const t = talentBb(chess, 0);
|
||||
const buffKey = 'utage:s2';
|
||||
const s1 = skillBbOf(chess, 'skchr_utage_1');
|
||||
return {
|
||||
skills: {
|
||||
@@ -913,16 +912,16 @@ export default {
|
||||
},
|
||||
},
|
||||
skill: {
|
||||
kind: 'passive',
|
||||
kind: 'duration', activateOnDeploy: true, duration: num(bb.duration), spCost: 0, spType: 'none', trigger: 'NEVER',
|
||||
mods: { atkPct: num(bb.atk) },
|
||||
onStart({ battle, unit }) {
|
||||
const loss = unit.hp * num(bb.hp_ratio);
|
||||
if (loss > 0 && unit.hp - loss >= 1) battle.loseHp(unit, loss, { source: unit });
|
||||
battle.addBuff(unit, { key: buffKey, duration: num(bb.duration), mods: { atkPct: num(bb.atk) }, tags: ['skill'], visible: true });
|
||||
battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1, id: unit.id, skill: 'breach' });
|
||||
},
|
||||
},
|
||||
talents: [{ install(battle, unit) {
|
||||
onHitBy(battle, unit, ({ dmg }) => { if (dmg.isAttack && dmg.type === 'phys' && unit.findBuff(buffKey)) dmg.type = 'arts'; });
|
||||
onHitBy(battle, unit, ({ dmg }) => { if (dmg.isAttack && dmg.type === 'phys' && unit.skill?.id === 'skchr_utage_2' && unit.skill.active) dmg.type = 'arts'; });
|
||||
const maxAs = num(t.min_attack_speed), minHp = num(t.min_hp_ratio);
|
||||
if (maxAs > 0 && minHp < 1) {
|
||||
battle.on('tick', () => {
|
||||
@@ -965,11 +964,8 @@ export default {
|
||||
return {
|
||||
skills: {
|
||||
skchr_wildmn_1: {
|
||||
kind: 'passive',
|
||||
onStart({ battle, unit }) {
|
||||
const d = num(r1?.duration);
|
||||
if (d > 0) battle.addBuff(unit, { key: 'wildmn:s1', duration: d, mods: { aspd: num(r1?.bb?.attack_speed) }, tags: ['skill'], visible: true });
|
||||
},
|
||||
kind: 'duration', activateOnDeploy: true, duration: num(r1?.duration), spCost: 0, spType: 'none', trigger: 'NEVER',
|
||||
mods: { aspd: num(r1?.bb?.attack_speed) },
|
||||
},
|
||||
},
|
||||
skill: {
|
||||
|
||||
@@ -547,7 +547,7 @@ export default {
|
||||
const t = talentBb(chess, 0);
|
||||
return {
|
||||
skill: {
|
||||
kind: 'passive',
|
||||
kind: 'duration', activateOnDeploy: true, duration: num(bb.duration, 10), spCost: 0, spType: 'none', trigger: 'NEVER',
|
||||
onStart({ battle, unit }) {
|
||||
const total = unit.s.maxHp * num(bb.hp_ratio);
|
||||
const dur = num(bb.duration, 10);
|
||||
@@ -566,10 +566,11 @@ export default {
|
||||
});
|
||||
battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id });
|
||||
},
|
||||
onEnd({ battle, unit }) { battle.removeBuff(unit, 'gravel:rats'); },
|
||||
},
|
||||
skills: {
|
||||
skchr_gravel_1: {
|
||||
kind: 'passive',
|
||||
kind: 'duration', activateOnDeploy: true, duration: num(bb.duration), spCost: 0, spType: 'none', trigger: 'NEVER',
|
||||
onStart({ battle, unit }) {
|
||||
const v = num(bb.def), dur = num(bb.duration);
|
||||
if (!(v > 0) || !(dur > 0)) return;
|
||||
@@ -584,6 +585,7 @@ export default {
|
||||
});
|
||||
battle.fx('buff', { x: unit.x, y: unit.y, id: unit.id, kind: 'def' });
|
||||
},
|
||||
onEnd({ battle, unit }) { battle.removeBuff(unit, 'gravel:shadow'); },
|
||||
},
|
||||
},
|
||||
talents: [{ install(battle, unit) {
|
||||
|
||||
@@ -608,10 +608,8 @@ const KITS = {
|
||||
skills: altSkills(chess, d, bb, {
|
||||
'skcom_quickattack[3]': statSkill,
|
||||
skchr_skadi_2: (s) => ({
|
||||
kind: 'passive',
|
||||
onStart({ battle, unit }) { // "部署后N秒内攻击力+X" (a passive starts at every deployment)
|
||||
battle.addBuff(unit, { key: 'skill:skadi_wave', duration: num(s.bb.duration, 20), mods: { atkPct: num(s.bb.atk) }, visible: true });
|
||||
},
|
||||
kind: 'duration', activateOnDeploy: true, duration: num(s.bb.duration, 20), spCost: 0, spType: 'none', trigger: 'NEVER',
|
||||
mods: { atkPct: num(s.bb.atk) },
|
||||
}),
|
||||
}),
|
||||
talents: [
|
||||
|
||||
@@ -495,21 +495,9 @@ const kits = {
|
||||
},
|
||||
}),
|
||||
skchr_ines_3: () => ({
|
||||
kind: 'passive',
|
||||
kind: 'duration', duration: s3Dur, spCost: 0, spType: 'none', trigger: 'NEVER',
|
||||
onStart({ battle, unit }) {
|
||||
if (!unit.mem.inesS3Placed) {
|
||||
// 首次部署: place a 影哨 (her talent's sentry, left on retreat) and leave; 立刻刷新再部署时间 — the redeploy
|
||||
// (auto, paying her DP cost like every redeploy) follows as soon as it is affordable
|
||||
unit.mem.inesS3Placed = true;
|
||||
battle.after(0, () => {
|
||||
if (!unit.alive || !unit.deployed) return;
|
||||
battle.retreat(unit, { reason: 'retreat' });
|
||||
unit.respawnAt = battle.time;
|
||||
battle.fx('sentry', { x: unit.x, y: unit.y, id: unit.id });
|
||||
}, { owner: unit });
|
||||
return;
|
||||
}
|
||||
battle.addBuff(unit, { key: 'ines:s3', duration: s3Dur, mods: { atkPct: num(bb.atk) }, visible: true });
|
||||
battle.addBuff(unit, { key: 'ines:s3', mods: { atkPct: num(bb.atk) }, visible: true });
|
||||
// 立刻收回影哨: the sentry flies back to her and hits ≤ max_target enemies on its way (within
|
||||
// projectile_range of the segment, [ASSUMED] nearest to its start first)
|
||||
const from = unit.mem.inesSentryAt;
|
||||
@@ -533,6 +521,7 @@ const kits = {
|
||||
}
|
||||
battle.fx('beam', { x: ax, y: ay, tx: bx, ty: by, id: unit.id });
|
||||
},
|
||||
onEnd({ battle, unit }) { battle.removeBuff(unit, 'ines:s3'); },
|
||||
}),
|
||||
}),
|
||||
skill: {
|
||||
@@ -602,11 +591,27 @@ const kits = {
|
||||
],
|
||||
install(battle, unit) {
|
||||
if (S3) {
|
||||
battle.on('deploy', (c) => {
|
||||
if (c.unit !== unit || c.move) return;
|
||||
if (unit.mem.inesS3Placed) {
|
||||
unit.skill.activate('deploy');
|
||||
return;
|
||||
}
|
||||
// 首次部署 only places a 影哨 and retreats; it is not a skill activation.
|
||||
// 立刻刷新再部署时间: redeploy as soon as its DP cost is affordable.
|
||||
unit.mem.inesS3Placed = true;
|
||||
battle.after(0, () => {
|
||||
if (!unit.alive || !unit.deployed) return;
|
||||
battle.retreat(unit, { reason: 'retreat' });
|
||||
unit.respawnAt = battle.time;
|
||||
battle.fx('sentry', { x: unit.x, y: unit.y, id: unit.id });
|
||||
}, { owner: unit });
|
||||
}, { owner: unit });
|
||||
// where the talent's 影哨 stays (every leave but an expiry), for the recall of the next deployment
|
||||
battle.on('death', (c) => { if (c.unit === unit && c.reason !== 'expired') unit.mem.inesSentryAt = { x: unit.x, y: unit.y }; }, { owner: unit, priority: 5 });
|
||||
// 技能期间每对一个敌人造成伤害就获得1点部署费用
|
||||
battle.on('damaged', (c) => {
|
||||
if (c.source !== unit || c.target.side !== 'enemy' || !(c.amount > 0) || c.type === 'element' || !unit.findBuff('ines:s3')) return;
|
||||
if (c.source !== unit || c.target.side !== 'enemy' || !(c.amount > 0) || c.type === 'element' || !unit.skill?.active || !(unit.skill.timeLeft > 0)) return;
|
||||
battle.addDp(unit.ownerId, num(bb.cost, 1));
|
||||
}, { owner: unit });
|
||||
}
|
||||
@@ -1214,17 +1219,12 @@ const kits = {
|
||||
const g = grid(def.skill?.rangeGrid) || [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 0], [0, 1], [1, -1], [1, 0], [1, 1]];
|
||||
const S1 = isSel(def, 'skchr_texas2_1'), S2 = isSel(def, 'skchr_texas2_2');
|
||||
const dur = num(def.skill?.duration, S1 ? 11 : S2 ? 8 : 6);
|
||||
const talentAtk = (battle, unit) => battle.addBuff(unit, { key: 'texas2:rainAtk', duration: dur, mods: { atkPct: num(t0.atk, 0.2) } });
|
||||
const castS1 = (battle, unit) => {
|
||||
if (!unit.alive || !unit.deployed) return;
|
||||
talentAtk(battle, unit);
|
||||
battle.addBuff(unit, { key: 'texas2:drizzle', duration: dur, mods: { atkPct: num(bb.atk) }, visible: true });
|
||||
battle.fx('swordRain', { x: unit.x, y: unit.y, id: unit.id });
|
||||
};
|
||||
const castS2 = (battle, unit) => {
|
||||
if (!unit.alive || !unit.deployed) return;
|
||||
talentAtk(battle, unit);
|
||||
battle.addBuff(unit, { key: 'texas2:shower', duration: dur, mods: { atkPct: num(bb.atk) }, visible: true });
|
||||
const mr = num(bb.magic_resistance, 0);
|
||||
for (const e of targetsInGrid(battle, unit, g)) {
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1.5), type: 'arts', isSkill: true, tags: ['skill', 'burst'] });
|
||||
@@ -1235,16 +1235,14 @@ const kits = {
|
||||
};
|
||||
const castS3 = (battle, unit) => {
|
||||
if (!unit.alive || !unit.deployed) return;
|
||||
talentAtk(battle, unit);
|
||||
for (const e of targetsInGrid(battle, unit, g)) {
|
||||
for (let i = 0; i < 2 && e.alive; i++) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb['appear.atk_scale'], 1.15), type: 'arts', isSkill: true, tags: ['skill', 'burst'] });
|
||||
if (e.alive) battle.applyStatus(e, 'stun', { duration: num(bb['appear.stun'], 1.5), source: unit });
|
||||
}
|
||||
battle.fx('swordStorm', { x: unit.x, y: unit.y, id: unit.id });
|
||||
unit.mem.rainUntil = battle.time + dur;
|
||||
unit.mem.rainTimer?.cancel();
|
||||
unit.mem.rainTimer = battle.every(Math.max(0.1, num(bb['texas2_s_3[sword].interval'], 1)), (b, sched) => {
|
||||
if (!unit.alive || !unit.deployed || b.time > unit.mem.rainUntil + 1e-9) { sched.cancel(); return; }
|
||||
if (!unit.alive || !unit.deployed || !unit.skill?.active) { sched.cancel(); return; }
|
||||
const list = targetsInGrid(b, unit, g);
|
||||
sortEnemyTargets(b, unit, list, null);
|
||||
for (const e of list.slice(0, Math.max(1, Math.floor(num(bb.max_target, 2))))) {
|
||||
@@ -1253,28 +1251,62 @@ const kits = {
|
||||
b.fx('swordRain', { x: e.x, y: e.y, id: e.id });
|
||||
}
|
||||
}, { owner: unit });
|
||||
// Duration advances by dt in the ally phase; align the rain with that clock so the final wave lands before end.
|
||||
unit.mem.rainTimer.due -= battle.dt;
|
||||
};
|
||||
const cast = S1 ? castS1 : S2 ? castS2 : castS3;
|
||||
const skill = {
|
||||
kind: 'duration', activateOnDeploy: true, duration: dur, spCost: 0, spType: 'none', trigger: 'NEVER',
|
||||
mods: { atkPct: num(t0.atk, 0.2) + (S1 || S2 ? num(bb.atk) : 0) },
|
||||
onStart({ battle, unit, reason }) {
|
||||
if (reason === 'kill' && unit.mem.texasRecastPending) {
|
||||
unit.mem.texasRecastPending = false; // the synchronous burst already performed this recast
|
||||
return;
|
||||
}
|
||||
unit.mem.texasCasting = true;
|
||||
cast(battle, unit);
|
||||
unit.mem.texasCasting = false;
|
||||
},
|
||||
onEnd({ unit, reason }) {
|
||||
if (reason === 'recast' && unit.mem.texasRecastPending) return;
|
||||
unit.mem.rainTimer?.cancel();
|
||||
},
|
||||
};
|
||||
return {
|
||||
skill: { kind: 'passive', onStart({ battle, unit }) { cast(battle, unit); } },
|
||||
skill,
|
||||
skills: alt(def, {
|
||||
skchr_texas2_1: () => ({ kind: 'passive', onStart({ battle, unit }) { cast(battle, unit); } }),
|
||||
skchr_texas2_2: () => ({ kind: 'passive', onStart({ battle, unit }) { cast(battle, unit); } }),
|
||||
skchr_texas2_1: () => skill,
|
||||
skchr_texas2_2: () => skill,
|
||||
}),
|
||||
// S2: 攻击变为二连击 while the passive lasts
|
||||
trait: S2 ? { hitsFn: (b, u) => (u.findBuff('texas2:shower') ? 2 : 1) } : undefined,
|
||||
trait: S2 ? { hitsFn: (b, u) => (u.skill?.active ? 2 : 1) } : undefined,
|
||||
talents: [
|
||||
{ install(battle, unit) { // 德克萨斯传统 (2nd half): first kill of each deployment ⇒ full heal + recast
|
||||
// (the passive's deploy burst runs inside skill.reset, BEFORE the `deploy` hook: the per-deployment state is
|
||||
// therefore reset when she leaves the field, so a kill by the deploy burst itself counts)
|
||||
battle.on('death', (c) => { if (c.unit === unit) unit.mem.texasKilled = false; }, { owner: unit });
|
||||
battle.on('death', (c) => {
|
||||
if (c.unit !== unit) return;
|
||||
unit.mem.texasKilled = unit.mem.texasCasting = unit.mem.texasRecastPending = false;
|
||||
}, { owner: unit });
|
||||
battle.on('kill', (c) => {
|
||||
if (c.killer !== unit || !unit.alive || c.victim.side !== 'enemy' || unit.mem.texasKilled) return;
|
||||
unit.mem.texasKilled = true;
|
||||
battle.removeBuff(unit, 'texas2:swordplay');
|
||||
battle.heal(unit, unit, unit.s.maxHp * num(t0.hp_ratio, 1), { self: true });
|
||||
cast(battle, unit);
|
||||
if (unit.mem.texasCasting) {
|
||||
unit.mem.texasRecastPending = true;
|
||||
cast(battle, unit); // preserve the burst's synchronous reentry and damage order
|
||||
} else {
|
||||
unit.skill.end('recast');
|
||||
unit.skill.activate('kill', { free: true });
|
||||
}
|
||||
}, { owner: unit });
|
||||
battle.on('skillStart', (c) => {
|
||||
if (c.unit !== unit || !unit.mem.texasRecastPending) return;
|
||||
// Complete the lifecycle after observers received the outer start; the burst already recast above.
|
||||
unit.skill.end('recast');
|
||||
unit.skill.activate('kill', { free: true });
|
||||
}, { owner: unit, priority: -2000 });
|
||||
} },
|
||||
{ install(battle, unit) { // 德克萨斯剑术: until her first kill after each deployment: ASPD +8, −25 % damage taken
|
||||
battle.on('deploy', (c) => {
|
||||
@@ -1290,7 +1322,7 @@ const kits = {
|
||||
const dot = num(bb['attack@texas2_s_1[dot].dot_damage'], 260), dotIv = Math.max(0.1, num(bb['attack@texas2_s_1[dot].interval'], 1));
|
||||
battle.on('damaged', (c) => {
|
||||
const e = c.target;
|
||||
if (c.source !== unit || !c.dmg?.isAttack || e.side !== 'enemy' || !e.alive || !unit.findBuff('texas2:drizzle')) return;
|
||||
if (c.source !== unit || !c.dmg?.isAttack || e.side !== 'enemy' || !e.alive || !unit.skill?.active) return;
|
||||
battle.applyStatus(e, 'silence', { duration: sil, source: unit });
|
||||
battle.addBuff(e, { key: `texas2:drizzleDot:${unit.id}`, duration: dotDur + 1e-6, interval: dotIv, source: unit, refresh: 'extend', // a re-hit refreshes it, the per-second ticks keep their rhythm
|
||||
onTick: ({ unit: t }) => battle.dealDamage(unit, t, { amount: dot, type: 'arts', isSkill: true, tags: ['skill', 'dot'] }) });
|
||||
@@ -1299,7 +1331,7 @@ const kits = {
|
||||
if (S2) {
|
||||
// 攻击…造成法术伤害 while the passive lasts (the double hit is the kit trait's hitsFn)
|
||||
battle.on('hit', (c) => {
|
||||
if (c.source === unit && c.dmg.isAttack && c.dmg.type === 'phys' && unit.findBuff('texas2:shower')) c.dmg.type = 'arts';
|
||||
if (c.source === unit && c.dmg.isAttack && c.dmg.type === 'phys' && unit.skill?.active) c.dmg.type = 'arts';
|
||||
}, { owner: unit, priority: 50 });
|
||||
}
|
||||
const a = num(tb.atk, 0); // module (elite): ATK +10 % with no ally on the 4 adjacent tiles
|
||||
|
||||
@@ -2679,23 +2679,24 @@ function nearl2(bb, chess, def) {
|
||||
// (whole hits negated); then she withdraws and this redeploy time is ×respawn_time (×1 when the operator deployed
|
||||
// right before her is 【卡西米尔】)
|
||||
skchr_nearl2_2: {
|
||||
kind: 'passive',
|
||||
kind: 'duration', activateOnDeploy: true, duration: num(def?.skill?.duration, 22), spCost: 0, spType: 'none', trigger: 'NEVER',
|
||||
mods: { atkPct: num(bb.atk) },
|
||||
onStart({ battle, unit, skill }) {
|
||||
const dur = num(skill.duration, num(def?.skill?.duration, 22));
|
||||
if (!(dur > 0)) return;
|
||||
const prev = lastOps.map?.get(unit.ownerId);
|
||||
const combo = !!prev && prev !== unit && hasBond(prev, 'kazimierzShip');
|
||||
battle.addBuff(unit, { key: 'nearl2:night', mods: { atkPct: num(bb.atk) }, duration: dur, visible: true });
|
||||
unit.mem.nearl2Combo = !!prev && prev !== unit && hasBond(prev, 'kazimierzShip');
|
||||
const hits = Math.floor(num(bb.times));
|
||||
if (hits > 0) battle.addBuff(unit, { key: 'nearl2:shield', shieldHits: hits, duration: dur, visible: true });
|
||||
const seq = unit.deploySeq;
|
||||
battle.after(dur, () => {
|
||||
if (!live(unit) || unit.deploySeq !== seq) return;
|
||||
battle.retreat(unit, { reason: 'retreat' });
|
||||
const mul = combo ? num(bb['nearl2_s_2[withdraw][combo].respawn_time'], 1) : num(bb.respawn_time, 1);
|
||||
if (Number.isFinite(unit.respawnAt) && mul !== 1) unit.respawnAt = battle.time + (unit.respawnAt - battle.time) * mul;
|
||||
battle.fx('disappear', { x: unit.x, y: unit.y, id: unit.id, combo });
|
||||
}, { owner: unit });
|
||||
},
|
||||
onEnd({ battle, unit, reason }) {
|
||||
battle.removeBuff(unit, 'nearl2:shield');
|
||||
if (reason !== 'duration' || !live(unit)) return;
|
||||
const combo = unit.mem.nearl2Combo;
|
||||
battle.retreat(unit, { reason: 'retreat' });
|
||||
const mul = combo ? num(bb['nearl2_s_2[withdraw][combo].respawn_time'], 1) : num(bb.respawn_time, 1);
|
||||
if (Number.isFinite(unit.respawnAt) && mul !== 1) unit.respawnAt = battle.time + (unit.respawnAt - battle.time) * mul;
|
||||
battle.fx('disappear', { x: unit.x, y: unit.y, id: unit.id, combo });
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -211,6 +211,7 @@ export class SkillRuntime {
|
||||
this.gainSp(carry && Number.isFinite(carry.sp) ? carry.sp : this.initSp, 'init', true);
|
||||
// a free (spCost 0) non-passive skill is available once per deployment
|
||||
if (this.spCost <= 0) this.charges = this.maxCharges;
|
||||
if (this.spec.activateOnDeploy) this.activate('deploy');
|
||||
}
|
||||
|
||||
_startPassive() {
|
||||
|
||||
@@ -89,13 +89,16 @@ export function animOf(u, t) {
|
||||
/** Snapshot tuple for one unit. */
|
||||
export function unitTuple(u, t) {
|
||||
const sk = u.skill;
|
||||
const spMax = sk && !sk.noSkill ? sk.spCost : 0;
|
||||
let spMax = sk && !sk.noSkill ? sk.spCost : 0;
|
||||
let sp = sk && !sk.noSkill ? sk.sp : 0;
|
||||
if (sk && sk.active && sk.isTimed) {
|
||||
// show remaining duration/ammo as a draining bar — ammo out of the activation's real total (base + bullets added:
|
||||
// 拉特兰, 逃犯引渡手续, refills; community report #35), so every bullet shortens it
|
||||
if (sk.kind === 'ammo') sp = spMax * (sk.ammoLeft / Math.max(1, sk.ammoMax || sk.ammo || 0, sk.ammoLeft));
|
||||
else if (Number.isFinite(sk.timeLeft) && sk.duration > 0) sp = spMax * (sk.timeLeft / sk.duration);
|
||||
else if (Number.isFinite(sk.timeLeft) && sk.duration > 0) {
|
||||
if (spMax === 0) spMax = sk.duration;
|
||||
sp = spMax * (sk.timeLeft / sk.duration);
|
||||
}
|
||||
}
|
||||
// hp is rounded up (a living unit never shows 0) but never above the rounded max HP
|
||||
const maxHp = Math.max(1, Math.round(u.s.maxHp));
|
||||
|
||||
@@ -71,6 +71,28 @@ test('迅捷: member skill end +12 SP (p=1 at high L), ≥40 layers every operat
|
||||
assert.equal(off.s_x, 0);
|
||||
});
|
||||
|
||||
test('迅捷 / 突袭: SP gifts after end do not recharge a zero-SP deployment skill or trigger a ready raid', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_deploy: chessRec({ id: 't_deploy', bonds: ['swiftShip', 'raidShip'], skill: { spCost: 0 } }) } },
|
||||
units: [{ chessId: 't_deploy', row: 10, col: 4 }],
|
||||
bonds: { swiftShip: bond(1, 1000), raidShip: bond(1) }, captureNoisy: true,
|
||||
kits: { t_deploy: () => ({ skill: {
|
||||
kind: 'duration', activateOnDeploy: true, duration: 2, spCost: 0, spType: 'none', trigger: 'NEVER',
|
||||
} }) },
|
||||
});
|
||||
h.b.start();
|
||||
const u = h.unit('t_deploy'), seq = u.deploySeq;
|
||||
h.run(5);
|
||||
assert.deepEqual(h.hooksOf('skillEnd').map((c) => c.reason), ['duration']);
|
||||
assert.ok(h.hooksOf('spGain').some((c) => c.unit === u), 'swift end handler attempted SP gain');
|
||||
assert.equal(u.skill.sp, 0);
|
||||
assert.equal(u.skill.charges, 0);
|
||||
assert.equal(u.skill.ready, false);
|
||||
assert.equal(u.skill.activations, 1);
|
||||
assert.equal(u.deploySeq, seq, 'skill end alone does not meet raid readiness');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('迅捷 / 不屈 proc chances: p = min(1, base + per·L) at each layer count', () => {
|
||||
const sw = bondBb('swiftShip'), ind = bondBb('indomShip');
|
||||
close(procChance(sw, 0), 0.20);
|
||||
|
||||
@@ -165,6 +165,24 @@ test('炎国短刀: each skill activation ATK +5 % / +8 % (one multiplier), at m
|
||||
}
|
||||
});
|
||||
|
||||
test('炎国短刀: timed deployment skill adds one stack per deployment, including carry.skillActive', () => {
|
||||
const id = 'chess_char_1_19_a';
|
||||
for (const [item, per] of [[A('3_04'), 0.05], [B('3_04'), 0.08]]) {
|
||||
const h = makeBattle({ units: [{ chessId: id, row: 10, col: 4, skillIndex: 0, items: [item], carryState: { skillActive: true } }] });
|
||||
h.b.start();
|
||||
const u = h.unit(id);
|
||||
assert.equal(u.skill.activations, 1);
|
||||
close(u.s.atk, u.base.atk * (1 + per), 'one deployment stack');
|
||||
h.run(u.skill.duration + 1);
|
||||
close(u.s.atk, u.base.atk * (1 + per), 'no extra stack on end');
|
||||
h.b.retreat(u);
|
||||
assert.ok(h.b.redeploy(u, { free: true }));
|
||||
assert.equal(u.skill.activations, 2);
|
||||
close(u.s.atk, u.base.atk * (1 + per * 2), 'one more on redeployment');
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
|
||||
test('迅捷作战粮: on deploy SP +3 / +6, plus as much per other operator sharing a bond', () => {
|
||||
for (const [id, each] of [[A('3_05'), 3], [B('3_05'), 6]]) {
|
||||
const ops = { t_op: op('t_op', { bonds: ['swiftShip'] }), t_a: op('t_a', { bonds: ['swiftShip'] }), t_b: op('t_b', { bonds: ['yanShip'] }) };
|
||||
|
||||
@@ -444,6 +444,8 @@ test('1_18 宴 S1 分神: no attacks, block 0 (releases), DEF +def, regenerates
|
||||
assert.equal(u.s.blockCnt, 0);
|
||||
assert.equal(u.blocking.length, 0);
|
||||
assert.equal(e.blockedBy, null, 'blocked enemy released');
|
||||
h.b.dealDamage(u, e, { amount: 100, type: 'phys', isAttack: true });
|
||||
assert.equal(dealt(h, u).at(-1).type, 'phys', 'active S1 does not inherit S2 arts conversion');
|
||||
approx(u.s.def, u.base.def * (1 + bb.def), `${id} DEF`);
|
||||
approx(u.s.hpRegen, u.s.maxHp * bb.hp_recovery_per_sec_by_max_hp_ratio, 'regen');
|
||||
u.hp = u.s.maxHp * 0.3;
|
||||
@@ -462,23 +464,71 @@ test('1_18 宴 S1 分神: no attacks, block 0 (releases), DEF +def, regenerates
|
||||
}
|
||||
});
|
||||
|
||||
test('1_19 野鬃 S1 骑枪刺击 (PASSIVE): ASPD +attack_speed for `duration` s after every deployment', () => {
|
||||
test('1_18 宴 S2: deployment loses current HP once; ATK and normal-attack arts conversion end with the duration', () => {
|
||||
for (const id of pair('18')) {
|
||||
const s = rec(id, 'skchr_utage_2'), bb = s.bb;
|
||||
const h = run({ defs: { chess: noGarrison(id), enemies: { e: dummy('e') } },
|
||||
units: [entry(id, s.skillId, { row: 10, col: 4, carryState: { hpPct: 0.5, skillActive: true } })] });
|
||||
h.b.start();
|
||||
const u = h.unit(id);
|
||||
assert.equal(u.skill.kind, 'duration');
|
||||
assert.equal(u.skill.activations, 1);
|
||||
assert.equal(u.skill.charges, 0);
|
||||
approx(u.hp, u.s.maxHp * 0.5 * (1 - bb.hp_ratio), 'one loss from carried current HP');
|
||||
approx(u.s.atk, u.base.atk * (1 + bb.atk));
|
||||
const e = h.spawn('e', { pos: [10, 8] });
|
||||
const hit = () => { h.b.dealDamage(u, e, { amount: 100, type: 'phys', isAttack: true }); return dealt(h, u).at(-1); };
|
||||
assert.equal(hit().type, 'arts');
|
||||
assert.ok(!hit().dmg.isSkill, 'normal attack marker retained');
|
||||
h.run(bb.duration / 2);
|
||||
approx(u.skill.timeLeft, bb.duration / 2);
|
||||
h.run(bb.duration / 2 + 0.1);
|
||||
assert.equal(u.skill.active, false);
|
||||
assert.equal(u.skill.ready, false);
|
||||
approx(u.s.atk, u.base.atk);
|
||||
assert.equal(hit().type, 'phys');
|
||||
assert.deepEqual(ended(h, u).map((c) => c.reason), ['duration']);
|
||||
const hp = u.hp;
|
||||
h.run(3);
|
||||
approx(u.hp, hp, 'no second loss while staying deployed');
|
||||
h.b.retreat(u);
|
||||
assert.ok(h.b.redeploy(u, { free: true }));
|
||||
assert.equal(u.skill.activations, 2);
|
||||
approx(u.hp, u.s.maxHp * (1 - bb.hp_ratio), 'new deployment loses current HP once');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('1_19 野鬃 S1 骑枪刺击: ASPD +attack_speed for `duration` s after every deployment', () => {
|
||||
for (const id of pair('19')) {
|
||||
const s = rec(id, 'skchr_wildmn_1');
|
||||
const h = run({ defs: { chess: noGarrison(id) }, units: [entry(id, s.skillId, { row: 9, col: 5 })] });
|
||||
const u = sel(h, id, s.skillId);
|
||||
assert.equal(u.skill.kind, 'passive');
|
||||
h.step();
|
||||
h.b.start();
|
||||
assert.equal(u.skill.kind, 'duration');
|
||||
assert.equal(u.skill.active, true);
|
||||
assert.equal(u.skill.activations, 1);
|
||||
assert.equal(u.skill.charges, 0);
|
||||
assert.equal(u.skill.ready, false);
|
||||
assert.equal(started(h, u)[0].reason, 'deploy');
|
||||
approx(u.s.aspd, u.base.aspd + s.bb.attack_speed, `${id} ASPD after deployment`);
|
||||
h.run(s.duration - 0.5);
|
||||
h.run(s.duration / 2);
|
||||
approx(u.skill.timeLeft, s.duration / 2);
|
||||
assert.deepEqual(h.snapshot().units.find((t) => t[0] === u.id).slice(5, 7), [s.duration / 2, s.duration]);
|
||||
h.run(s.duration / 2 - 0.5);
|
||||
approx(u.s.aspd, u.base.aspd + s.bb.attack_speed, 'still up');
|
||||
h.run(1);
|
||||
approx(u.s.aspd, u.base.aspd, `expired after ${s.duration} s`);
|
||||
assert.equal(u.skill.active, false);
|
||||
assert.equal(u.skill.ready, false);
|
||||
assert.deepEqual(ended(h, u).map((c) => c.reason), ['duration']);
|
||||
h.b.kill(u, null);
|
||||
h.step();
|
||||
assert.ok(h.b.redeploy(u, { free: true }));
|
||||
h.step();
|
||||
approx(u.s.aspd, u.base.aspd + s.bb.attack_speed, 'again after the redeployment');
|
||||
assert.equal(u.skill.activations, 2);
|
||||
assert.equal(ended(h, u).length, 1, 'expired skill does not end again on death');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -342,12 +342,20 @@ test('2_12 砾 S1 影袭: at deployment DEF +def decaying to 0 over `duration` s
|
||||
h.run(0.5);
|
||||
approx(u.s.def, u.base.def * (1 + aura + bb.def), `${id} full bonus`);
|
||||
assert.equal(u.s.shield, 0, 'no 鼠群 barrier');
|
||||
assert.equal(u.skill.kind, 'duration');
|
||||
assert.equal(u.skill.active, true);
|
||||
assert.equal(u.skill.ready, false);
|
||||
assert.equal(h.snapshot().units.find((t) => t[0] === u.id)[6], bb.duration);
|
||||
h.run(1);
|
||||
approx(u.s.def, u.base.def * (1 + aura + bb.def * (bb.duration - 1) / bb.duration), 'one step down after 1 s');
|
||||
h.run(0.4);
|
||||
approx(u.s.def, u.base.def * (1 + aura + bb.def * (bb.duration - 1) / bb.duration), 'steps once per second');
|
||||
h.run(bb.duration);
|
||||
approx(u.s.def, u.base.def * (1 + aura), 'gone');
|
||||
assert.equal(u.skill.active, false);
|
||||
assert.equal(u.skill.ready, false);
|
||||
assert.equal(u.findBuff('gravel:shadow'), null);
|
||||
assert.equal(h.hooksOf('skillEnd').filter((c) => c.unit === u && c.reason === 'duration').length, 1);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -368,19 +368,34 @@ test('3_05 斯卡蒂 S1 迅捷打击·γ型 ATK/ASPD; S2 跃浪击 ATK + for `du
|
||||
const g = makeBattle({ timeLimit: 60, units: [U(id, 'skchr_skadi_2', 10, 4)] });
|
||||
const v = g.unit(id);
|
||||
g.step();
|
||||
assert.equal(v.skill.kind, 'passive');
|
||||
assert.equal(v.skill.kind, 'duration');
|
||||
assert.equal(v.skill.active, true);
|
||||
assert.equal(v.skill.charges, 0);
|
||||
assert.equal(v.skill.ready, false);
|
||||
assert.equal(g.hooksOf('skillStart')[0].reason, 'deploy');
|
||||
approx(v.s.atk, v.base.atk * (1 + b2.atk + own));
|
||||
if (id.endsWith('_b')) {
|
||||
const left = v.skill.timeLeft;
|
||||
g.b.dealDamage(null, v, { type: 'true', amount: 1e7 });
|
||||
assert.equal(v.alive, true, 'DRE-Y revives in place during the window');
|
||||
assert.equal(v.skill.activations, 1);
|
||||
approx(v.skill.timeLeft, left, 1e-9, 'revival preserves the remaining skill time');
|
||||
}
|
||||
g.run(b2.duration - 0.5);
|
||||
approx(v.s.atk, v.base.atk * (1 + b2.atk + own));
|
||||
g.run(1);
|
||||
approx(v.s.atk, v.base.atk * (1 + own), 1e-6, 'gone after its duration');
|
||||
assert.equal(v.skill.active, false);
|
||||
assert.equal(v.skill.ready, false);
|
||||
assert.deepEqual(g.hooksOf('skillEnd').map((c) => c.reason), ['duration']);
|
||||
// redeployed ⇒ again
|
||||
g.b.dealDamage(null, v, { type: 'true', amount: 1e7 });
|
||||
if (!v.alive) {
|
||||
g.b.redeploy(v, { free: true });
|
||||
g.step();
|
||||
approx(v.s.atk, v.base.atk * (1 + b2.atk + own), 1e-6, 'every deployment');
|
||||
}
|
||||
assert.equal(v.skill.activations, 1, 'DRE-Y in-place revival does not deploy or restart S2');
|
||||
if (v.alive) g.b.retreat(v);
|
||||
assert.ok(g.b.redeploy(v, { free: true }));
|
||||
approx(v.skill.timeLeft, b2.duration);
|
||||
assert.equal(v.skill.activations, 2);
|
||||
approx(v.s.atk, v.base.atk * (1 + b2.atk + own), 1e-6, 'every deployment');
|
||||
done(g);
|
||||
}
|
||||
// DRE-X: ×atk_scale on blocked enemies; no DRE-Y revive
|
||||
|
||||
@@ -265,6 +265,13 @@ test('伊内丝 S3 独影归途: first deployment leaves a sentry and retreats;
|
||||
const sk = D(id, 2).skill, bb = sk.bb;
|
||||
const h = battle([U(id, 10, 5, 2)], { flags: { dpInit: 99, dpPerSec: 0 } });
|
||||
const u = h.unit(id);
|
||||
h.b.start();
|
||||
assert.equal(u.skill.kind, 'duration');
|
||||
assert.equal(u.skill.active, false, 'first deployment does not activate the skill');
|
||||
assert.equal(u.skill.activations, 0);
|
||||
assert.equal(h.hooksOf('skillStart').filter((c) => c.unit === u).length, 0, 'placing a sentry emits no skillStart');
|
||||
assert.equal(u.skill.timeLeft, 0, 'first deployment places the sentry without a full effect window');
|
||||
assert.equal(u.findBuff('ines:s3'), null);
|
||||
const near = [];
|
||||
for (let i = 0; i < 3; i++) near.push(h.spawn('enemy_dummy', { pos: [10, 6] }), h.spawn('enemy_dummy', { pos: [10, 4] }));
|
||||
const far = h.spawn('enemy_dummy', { pos: [10, 8] });
|
||||
@@ -273,9 +280,14 @@ test('伊内丝 S3 独影归途: first deployment leaves a sentry and retreats;
|
||||
assert.ok(left && left.reason === 'retreat', `${id}: leaves right after the first deployment`);
|
||||
assert.ok(h.runUntil(() => u.alive && u.deployed, 2), 'redeploy timer refreshed: back at once');
|
||||
const buff = u.findBuff('ines:s3');
|
||||
assert.ok(buff, 'passive buff on the redeploy');
|
||||
assert.ok(buff, 'ATK effect on the redeploy');
|
||||
approx(buff.mods.atkPct, bb.atk, 1e-9);
|
||||
approx(buff.timeLeft, sk.duration, 0.1);
|
||||
approx(u.skill.timeLeft, sk.duration, 0.1);
|
||||
assert.equal(u.skill.active, true);
|
||||
assert.equal(u.skill.activations, 1);
|
||||
assert.equal(h.hooksOf('skillStart').filter((c) => c.unit === u).length, 1, 'the redeployment starts the skill once');
|
||||
assert.equal(u.skill.ready, false);
|
||||
assert.equal(h.snapshot().units.find((t) => t[0] === u.id)[6], sk.duration);
|
||||
const recall = dmgBy(h, u, tagged('sentryRecall'));
|
||||
assert.equal(recall.length, bb.max_target, `${id}: recall hits ≤ ${bb.max_target} of 6`);
|
||||
assert.ok(recall.every((c) => near.includes(c.target)) && !recall.some((c) => c.target === far));
|
||||
@@ -287,6 +299,15 @@ test('伊内丝 S3 独影归途: first deployment leaves a sentry and retreats;
|
||||
assert.ok(hits > 0);
|
||||
assert.equal(h.b.getPlayer('p1').dp, dpAt + hits * bb.cost, 'one DP per damage while the passive lasts');
|
||||
assert.equal(h.hooksOf('death').filter((c) => c.unit === u).length, 1, 'only the first deployment leaves');
|
||||
h.run(sk.duration);
|
||||
assert.equal(u.skill.active, false);
|
||||
assert.equal(u.skill.ready, false);
|
||||
assert.equal(u.findBuff('ines:s3'), null);
|
||||
const dpEnd = h.b.getPlayer('p1').dp;
|
||||
h.run(2);
|
||||
assert.equal(h.b.getPlayer('p1').dp, dpEnd, 'no skill DP after the actual window ends');
|
||||
assert.equal(h.hooksOf('death').filter((c) => c.unit === u).length, 1, 'stays deployed after the window');
|
||||
assert.deepEqual(h.hooksOf('skillEnd').filter((c) => c.unit === u).map((c) => c.reason), ['duration']);
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
@@ -611,10 +632,11 @@ test('缄默德克萨斯 S1 细雨无声: passive ATK up for its duration; hits
|
||||
const h = battle([U(id, 10, 4, 0)]);
|
||||
h.step();
|
||||
const u = h.unit(id);
|
||||
const buff = u.findBuff('texas2:drizzle');
|
||||
assert.ok(buff, `${id}: passive active on deployment`);
|
||||
approx(buff.mods.atkPct, bb.atk, 1e-9);
|
||||
assert.ok(u.findBuff('texas2:rainAtk'), 'talent 德克萨斯传统 ATK during the passive');
|
||||
assert.equal(u.skill.kind, 'duration');
|
||||
assert.equal(u.skill.active, true);
|
||||
assert.equal(u.skill.charges, 0);
|
||||
approx(u.skill.spec.mods.atkPct, bb.atk + D(id, 0).talents[0].bb.atk, 1e-9, 'skill and 德克萨斯传统 ATK');
|
||||
assert.equal(h.snapshot().units.find((t) => t[0] === u.id)[6], sk.duration);
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 5] });
|
||||
h.run(3);
|
||||
assert.ok(e.s.flags.silence, 'hit target silenced (失去特殊能力)');
|
||||
@@ -623,7 +645,10 @@ test('缄默德克萨斯 S1 细雨无声: passive ATK up for its duration; hits
|
||||
assert.ok(dot.length >= 2, `a DoT tick every second although she re-hits faster (${dot.length})`);
|
||||
for (const c of dot) approx(c.amount, bb['attack@texas2_s_1[dot].dot_damage'], 1e-9, 'fixed arts per second (RES 0)');
|
||||
h.run(sk.duration);
|
||||
assert.ok(!u.findBuff('texas2:drizzle'), 'ends with the skill duration');
|
||||
assert.equal(u.skill.active, false, 'ends with the skill duration');
|
||||
assert.equal(u.skill.ready, false);
|
||||
assert.equal(h.hooksOf('skillEnd').filter((c) => c.unit === u && c.reason === 'duration').length, 1);
|
||||
assert.ok(e.s.flags.silence && e.findBuff(`texas2:drizzleDot:${u.id}`), 'applied enemy effects keep their own duration');
|
||||
h.run(bb['attack@silence'] + 1);
|
||||
assert.ok(!e.s.flags.silence && !e.findBuff(`texas2:drizzleDot:${u.id}`), 'no new silence / DoT after the passive');
|
||||
}
|
||||
@@ -657,8 +682,8 @@ test('缄默德克萨斯 S2 阵雨连绵: deploy burst atk_scale arts + RES down
|
||||
approx(burst[0].amount, u.s.atk * bb.atk_scale * 0.5, 1e-6, 'arts burst vs RES 50');
|
||||
approx(e.s.res, 50 * (1 + bb.magic_resistance), 1e-6, 'RES down');
|
||||
assert.equal(far.s.res, 50);
|
||||
approx(u.findBuff('texas2:shower').mods.atkPct, bb.atk, 1e-9);
|
||||
approx(u.findBuff('texas2:shower').timeLeft, sk.duration, 1e-6);
|
||||
approx(u.skill.spec.mods.atkPct, bb.atk + D(id, 1).talents[0].bb.atk, 1e-9);
|
||||
approx(u.skill.timeLeft, sk.duration, 1e-6);
|
||||
h.run(2);
|
||||
const hits = dmgBy(h, u, (c) => c.dmg.isAttack);
|
||||
assert.ok(hits.length >= 2 && hits.every((c) => c.type === 'arts'), 'arts attacks');
|
||||
@@ -666,6 +691,8 @@ test('缄默德克萨斯 S2 阵雨连绵: deploy burst atk_scale arts + RES down
|
||||
for (const c of hits) per.set(c.dmg.attackId, (per.get(c.dmg.attackId) ?? 0) + 1);
|
||||
assert.ok([...per.values()].every((v) => v === 2), 'double hits');
|
||||
h.run(sk.duration);
|
||||
assert.equal(u.skill.active, false);
|
||||
assert.equal(u.skill.ready, false);
|
||||
const n0 = dmgBy(h, u, (c) => c.dmg.isAttack).length;
|
||||
h.run(3);
|
||||
const late = dmgBy(h, u, (c) => c.dmg.isAttack).slice(n0);
|
||||
@@ -673,6 +700,57 @@ test('缄默德克萨斯 S2 阵雨连绵: deploy burst atk_scale arts + RES down
|
||||
}
|
||||
});
|
||||
|
||||
test('缄默德克萨斯 S1/S2/S3: first kill reopens a full window, including a kill inside the deployment burst', () => {
|
||||
for (const id of pair('chess_char_4_16_a')) for (const index of [0, 1, 2]) {
|
||||
const life = [];
|
||||
const h = battle([U(id, 10, 4, index)], { extra: { setup(b) {
|
||||
for (const name of ['skillStart', 'skillEnd']) b.on(name, (c) => life.push([name, c.reason]), { priority: 1000 });
|
||||
} } });
|
||||
h.b.start();
|
||||
const u = h.unit(id), sk = u.skill;
|
||||
h.run(2);
|
||||
u.hp = u.s.maxHp / 2;
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 5] });
|
||||
h.b.kill(e, u);
|
||||
assert.equal(u.mem.texasKilled, true);
|
||||
approx(u.hpRatio, 1);
|
||||
assert.equal(u.findBuff('texas2:swordplay'), null);
|
||||
assert.equal(sk.activations, 2);
|
||||
approx(sk.timeLeft, sk.duration);
|
||||
assert.equal(sk.charges, 0);
|
||||
assert.equal(sk.ready, false);
|
||||
assert.deepEqual(h.hooksOf('skillEnd').map((c) => c.reason), ['recast']);
|
||||
h.b.kill(h.spawn('enemy_dummy', { pos: [10, 5] }), u);
|
||||
assert.equal(sk.activations, 2, 'only first kill recasts');
|
||||
h.run(sk.duration + 0.1);
|
||||
assert.equal(sk.active, false);
|
||||
assert.deepEqual(h.hooksOf('skillEnd').map((c) => c.reason), ['recast', 'duration']);
|
||||
assert.deepEqual(h.hooksOf('skillStart').map((c) => c.reason), ['deploy', 'kill']);
|
||||
h.b.retreat(u);
|
||||
const lifeAt = life.length;
|
||||
const weak = h.spawn('enemy_dummy', { pos: [10, 5] });
|
||||
weak.hp = 1;
|
||||
assert.ok(h.b.redeploy(u, { free: true }));
|
||||
if (index === 0) h.b.kill(weak, u); // S1 has no deployment burst
|
||||
assert.equal(u.mem.texasKilled, true);
|
||||
assert.equal(sk.activations, 4, 'new deployment and first-kill recast, including synchronous burst reentry');
|
||||
assert.deepEqual(life.slice(lifeAt), [['skillStart', 'deploy'], ['skillEnd', 'recast'], ['skillStart', 'kill']]);
|
||||
assert.equal(u.findBuff('texas2:swordplay'), null);
|
||||
approx(sk.timeLeft, sk.duration);
|
||||
if (index === 2) {
|
||||
const target = h.spawn('enemy_dummy', { pos: [10, 5] });
|
||||
h.run(sk.duration + 0.1);
|
||||
const waves = new Set(dmgBy(h, u, tagged('swordRain')).filter((c) => c.target === target).map((c) => c.t));
|
||||
assert.equal(waves.size, sk.duration, 'reentrant deployment keeps a full set of rain waves');
|
||||
const n = dmgBy(h, u, tagged('swordRain')).length;
|
||||
h.run(2);
|
||||
assert.equal(dmgBy(h, u, tagged('swordRain')).length, n, 'rain stops at skill end');
|
||||
assert.ok(target.alive);
|
||||
}
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
|
||||
test('缄默德克萨斯 module none: no ATK +10 % when alone (EXE-Y default has it)', () => {
|
||||
const has = (m) => { const h = battle([U('chess_char_4_16_b', 10, 4, null, m)]); h.run(0.5); return !!h.unit('chess_char_4_16_b').findBuff('texas2:module'); };
|
||||
assert.equal(has(null), true);
|
||||
|
||||
@@ -999,7 +999,11 @@ test('6_17 耀骑士临光 S2 逐夜烁光: on deploy ATK + and 3 护盾 layers
|
||||
const u = h.unit(id);
|
||||
usesSkill(u, sid);
|
||||
h.step();
|
||||
approx(u.findBuff('nearl2:night').mods.atkPct, bb.atk);
|
||||
approx(u.skill.spec.mods.atkPct, bb.atk);
|
||||
assert.equal(u.skill.kind, 'duration');
|
||||
assert.equal(u.skill.active, true);
|
||||
assert.equal(u.skill.ready, false);
|
||||
assert.equal(h.snapshot().units.find((t) => t[0] === u.id)[6], dur);
|
||||
assert.equal(u.findBuff('nearl2:shield').shieldHits, bb.times);
|
||||
h.run(bb.times * 1.2 + 1);
|
||||
const taken = h.hooksOf('damaged').filter((c) => c.target === u);
|
||||
@@ -1009,15 +1013,30 @@ test('6_17 耀骑士临光 S2 逐夜烁光: on deploy ATK + and 3 护盾 layers
|
||||
assert.ok(h.runUntil(() => !u.alive, dur + 1));
|
||||
approx(h.b.time, dur, 'withdraws when it ends', 0.05);
|
||||
approx(u.respawnAt - h.b.time, u.base.respawnTime * bb.respawn_time, 'this redeploy ×1.25', 1e-3);
|
||||
assert.equal(u.removeReason, 'retreat');
|
||||
assert.equal(u.skill.active, false);
|
||||
assert.deepEqual(h.hooksOf('skillEnd').filter((c) => c.unit === u).map((c) => c.reason), ['duration']);
|
||||
done(h);
|
||||
|
||||
const k = run({ defs: { chess: { kaz: plain('kaz', { bonds: ['kazimierzShip'] }) } }, units: [U(id, sid, 10, 4), { chessId: 'kaz', row: 12, col: 4 }] });
|
||||
const ku = k.unit(id);
|
||||
k.step(2);
|
||||
assert.ok(ku.alive && ku.findBuff('nearl2:night'));
|
||||
assert.ok(ku.alive && ku.skill.active);
|
||||
assert.ok(k.runUntil(() => !ku.alive, dur + 1));
|
||||
approx(ku.respawnAt - k.b.time, ku.base.respawnTime, '卡西米尔 before her: no extension', 1e-3);
|
||||
done(k);
|
||||
|
||||
const dead = run({ units: [U(id, sid, 10, 4, { moduleId: 'none' })] });
|
||||
dead.step();
|
||||
const du = dead.unit(id);
|
||||
dead.b.kill(du);
|
||||
const respawnAt = du.respawnAt;
|
||||
approx(respawnAt - dead.b.time, du.base.respawnTime, 'early death keeps ordinary redeployment');
|
||||
assert.equal(du.findBuff('nearl2:shield'), null);
|
||||
dead.run(dur + 1);
|
||||
assert.equal(du.respawnAt, respawnAt, 'no delayed retreat or multiplier after death');
|
||||
assert.deepEqual(dead.hooksOf('skillEnd').filter((c) => c.unit === du).map((c) => c.reason), ['death']);
|
||||
done(dead);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -942,6 +942,10 @@ test('2_12 砾: 鼠群 barrier hp_ratio × max HP decaying over `duration` s;
|
||||
assert.equal(u.base.cost, raw(id).stats.cost + t.cost);
|
||||
h.step();
|
||||
const full = u.s.maxHp * bb.hp_ratio;
|
||||
assert.equal(u.skill.kind, 'duration');
|
||||
assert.equal(u.skill.active, true);
|
||||
assert.equal(u.skill.ready, false);
|
||||
assert.deepEqual(h.snapshot().units.find((t) => t[0] === u.id).slice(5, 7), [Math.round(u.skill.timeLeft * 10) / 10, bb.duration]);
|
||||
approx(u.s.shield, full, 'full barrier', 1e-2);
|
||||
h.run(bb.duration / 2 + 0.5);
|
||||
approx(u.s.shield, full / 2, 'half decayed', 1e-2);
|
||||
@@ -949,6 +953,9 @@ test('2_12 砾: 鼠群 barrier hp_ratio × max HP decaying over `duration` s;
|
||||
approx(u.s.shield, full / 2, 'PRTS: the capacity steps once per second', 1e-2);
|
||||
h.run(bb.duration / 2);
|
||||
assert.equal(u.s.shield, 0);
|
||||
assert.equal(u.skill.active, false);
|
||||
assert.equal(u.skill.ready, false);
|
||||
assert.equal(u.findBuff('gravel:rats'), null);
|
||||
done(h);
|
||||
// PRTS "受到伤害后,剩余容量根据屏障最大容量等比变化": a damaged barrier shrinks in proportion, not to the capacity
|
||||
const hd = run({ units: [{ chessId: id, row: 9, col: 5 }] });
|
||||
@@ -960,6 +967,12 @@ test('2_12 砾: 鼠群 barrier hp_ratio × max HP decaying over `duration` s;
|
||||
hd.run(bb.duration / 2 + 0.5);
|
||||
approx(ud.s.shield, cap0 * 0.4 / 2, 'remaining × capacity ratio (a plain cap would keep 40 %)', 1e-2);
|
||||
assert.ok(ud.hp === ud.s.maxHp);
|
||||
hd.b.retreat(ud);
|
||||
assert.equal(ud.findBuff('gravel:rats'), null);
|
||||
assert.equal(ud.skill.active, false);
|
||||
assert.ok(hd.b.redeploy(ud, { free: true }));
|
||||
approx(ud.skill.timeLeft, bb.duration);
|
||||
approx(ud.s.shield, cap0, 'fresh shield on redeployment');
|
||||
done(hd);
|
||||
const idb = 'chess_char_2_12_b', tb = tal(idb);
|
||||
const cheapRec = chessRec({ id: 't_cheap', stats: { cost: tb['cond.cost'] } });
|
||||
|
||||
@@ -645,7 +645,8 @@ test('缄默德克萨斯 S3 (passive): deploy burst 2 × 115 % arts + 1.5 s stun
|
||||
assert.equal(waves.size, D(id).skill.duration, 'one rain per second while the passive lasts');
|
||||
assert.ok([...waves.values()].every((n) => n === bb.max_target), '≤ 2 distinct targets');
|
||||
approx(rain[0].amount, u.base.atk * (1 + t0.atk) * bb.atk_scale, 1e-6);
|
||||
assert.equal(u.findBuff('texas2:rainAtk'), null, 'ATK bonus ends with the passive');
|
||||
assert.equal(u.skill.active, false, 'ATK bonus ends with the skill');
|
||||
approx(u.s.atk, u.base.atk);
|
||||
});
|
||||
|
||||
test('缄默德克萨斯: first kill ⇒ full heal + recast; 剑术 buff removed', () => {
|
||||
@@ -660,7 +661,8 @@ test('缄默德克萨斯: first kill ⇒ full heal + recast; 剑术 buff removed
|
||||
assert.ok(u.mem.texasKilled);
|
||||
approx(u.hpRatio, 1, 1e-3, 'full heal');
|
||||
assert.equal(u.findBuff('texas2:swordplay'), null);
|
||||
assert.ok(u.findBuff('texas2:rainAtk'), 'passive recast');
|
||||
assert.equal(u.skill.active, true, 'skill recast');
|
||||
assert.equal(u.skill.activations, 2);
|
||||
});
|
||||
|
||||
test('星熊 S2 (passive): DEF +13 % and 65 % ATK thorns; 战术装甲 negates ~25 % of hits; 重装 aura DEF +6 %', () => {
|
||||
|
||||
@@ -78,7 +78,7 @@ test('耀骑士临光 S2: withdrawn at the skill end, she lies down on her tile
|
||||
const h = field([{ chessId: NEARL2, row: 10, col: 4, skillIndex: idx }]);
|
||||
const u = h.unit(NEARL2);
|
||||
h.step();
|
||||
assert.ok(u.alive && u.findBuff('nearl2:night'), 'S2 on deploy');
|
||||
assert.ok(u.alive && u.skill.active, 'S2 on deploy');
|
||||
assert.ok(h.runUntil(() => !u.alive, 40), 'withdraws when the skill ends');
|
||||
assert.equal(u.removeReason, 'retreat');
|
||||
const d = downOf(h, u);
|
||||
|
||||
@@ -4,15 +4,160 @@ import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
|
||||
import { genericSkillSpec, genericKind } from '../../server/sim/content/generic.js';
|
||||
import { getDefaultSource } from '../../server/sim/simdata.js';
|
||||
import { UF } from '../../shared/constants.js';
|
||||
|
||||
const approx = (a, b, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${a} ≈ ${b}`);
|
||||
const RANGE3 = [[0, 0], [0, 1], [0, 2], [0, 3], [1, 0], [1, 1], [1, 2], [1, 3], [-1, 0], [-1, 1], [-1, 2], [-1, 3]];
|
||||
const dummy = (o = {}) => enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0, ...o });
|
||||
|
||||
test('deployment duration: synchronous start, one charge, countdown, carry and fresh redeployment', () => {
|
||||
for (const carryState of [undefined, { hpPct: 0.5, sp: 0, skillActive: true }]) {
|
||||
const order = [];
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_deploy: sniperWith({ spCost: 0 }, { id: 't_deploy' }) } },
|
||||
units: [{ chessId: 't_deploy', row: 10, col: 4, carryState }], timeLimit: 120,
|
||||
kits: { t_deploy: () => ({ skill: {
|
||||
kind: 'duration', activateOnDeploy: true, duration: 4, spCost: 0, spType: 'none', trigger: 'NEVER', mods: { atkPct: 1 },
|
||||
} }) },
|
||||
setup(b) {
|
||||
for (const name of ['skillStart', 'deploy']) b.on(name, (c) => order.push([name, c.reason, c.unit.skill.timeLeft]));
|
||||
},
|
||||
});
|
||||
h.b.start();
|
||||
const u = h.unit('t_deploy'), sk = u.skill;
|
||||
assert.deepEqual(order, [['skillStart', 'deploy', 4], ['deploy', undefined, 4]]);
|
||||
assert.equal(sk.active, true);
|
||||
assert.equal(sk.activations, 1);
|
||||
assert.equal(sk.charges, 0);
|
||||
assert.equal(sk.ready, false);
|
||||
approx(u.s.atk, 200);
|
||||
const tuple = () => h.snapshot().units.find((t) => t[0] === u.id);
|
||||
assert.ok(tuple()[7] & UF.SKILL);
|
||||
assert.deepEqual(tuple().slice(5, 7), [4, 4]);
|
||||
h.run(2);
|
||||
approx(sk.timeLeft, 2);
|
||||
assert.deepEqual(tuple().slice(5, 7), [2, 4]);
|
||||
h.run(2.1);
|
||||
assert.equal(sk.active, false);
|
||||
assert.equal(sk.ready, false);
|
||||
assert.equal(sk.charges, 0);
|
||||
assert.equal(sk.sp, 0);
|
||||
approx(u.s.atk, 100);
|
||||
assert.ok(!(tuple()[7] & UF.SKILL));
|
||||
assert.deepEqual(tuple().slice(5, 7), [0, 0]);
|
||||
assert.deepEqual(h.hooksOf('skillEnd').map((c) => c.reason), ['duration']);
|
||||
assert.equal(sk.gainSp(100, 'talent'), 0);
|
||||
h.run(5);
|
||||
assert.equal(sk.activations, 1);
|
||||
h.b.retreat(u);
|
||||
assert.equal(h.hooksOf('skillEnd').length, 1, 'expired skill does not end again on retreat');
|
||||
assert.ok(h.b.redeploy(u, { free: true }));
|
||||
assert.equal(sk.activations, 2);
|
||||
approx(sk.timeLeft, 4);
|
||||
assert.equal(sk.charges, 0);
|
||||
h.invariants();
|
||||
}
|
||||
});
|
||||
|
||||
test('deployment duration: early retreat/death cleans effects and leaves no stale end on redeployment', () => {
|
||||
for (const remove of ['retreat', 'kill']) {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_deploy: sniperWith({ spCost: 0 }, { id: 't_deploy' }) } },
|
||||
units: [{ chessId: 't_deploy', row: 10, col: 4 }],
|
||||
kits: { t_deploy: () => ({ skill: {
|
||||
kind: 'duration', activateOnDeploy: true, duration: 4, spCost: 0, spType: 'none', trigger: 'NEVER', mods: { aspd: 50 },
|
||||
} }) },
|
||||
});
|
||||
const u = h.unit('t_deploy');
|
||||
h.run(1);
|
||||
h.b[remove](u);
|
||||
assert.equal(u.skill.active, false);
|
||||
approx(u.s.aspd, u.base.aspd);
|
||||
assert.deepEqual(h.hooksOf('skillEnd').map((c) => c.reason), ['death']);
|
||||
assert.ok(h.b.redeploy(u, { free: true }));
|
||||
h.run(3.1);
|
||||
assert.equal(u.skill.active, true, 'old deployment deadline does not end the new window');
|
||||
assert.equal(h.hooksOf('skillEnd').length, 1);
|
||||
h.run(1);
|
||||
assert.deepEqual(h.hooksOf('skillEnd').map((c) => c.reason), ['death', 'duration']);
|
||||
assert.equal(u.skill.ready, false);
|
||||
h.invariants();
|
||||
}
|
||||
});
|
||||
|
||||
function sniperWith(skill, o = {}) {
|
||||
return chessRec({ id: 't_sn', profession: 'SNIPER', subProfessionId: 'closerange', stats: { atk: 100, bat: 1, ...(o.stats || {}) }, rangeGrid: RANGE3, skill, ...o });
|
||||
}
|
||||
|
||||
test('generic timed passive: deployment window owns stats and arts hits without reinterpreting passive proc parameters; permanent passive stays on', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: {
|
||||
t_sn: sniperWith({ skillType: 'PASSIVE', duration: 0, spCost: 0,
|
||||
description: '部署后在4秒内攻击力+100%,攻击造成法术伤害',
|
||||
bb: { atk: 1, duration: 4, max_target: 2, 'attack@cold': 1, ability_range_forward_extend: 1 } }),
|
||||
t_p: sniperWith({ skillType: 'PASSIVE', duration: 0, spCost: 0, description: '攻击力+100%', bb: { atk: 1, duration: 4 } }, { id: 't_p' }),
|
||||
}, enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 't_sn', row: 10, col: 4 }, { chessId: 't_p', row: 12, col: 4 }],
|
||||
content: 'generic', captureNoisy: true,
|
||||
});
|
||||
h.b.start();
|
||||
const u = h.unit('t_sn'), p = h.unit('t_p');
|
||||
assert.equal(u.skill.kind, 'duration');
|
||||
assert.equal(u.skill.activations, 1);
|
||||
assert.equal(u.skill.ready, false);
|
||||
assert.deepEqual(u.rangeKeys, u.baseRangeKeys, 'passive parameters do not extend the attack range');
|
||||
h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
h.run(2);
|
||||
approx(u.s.atk, 200);
|
||||
const hits = h.hooksOf('damaged').filter((c) => c.source === u);
|
||||
assert.ok(hits.length && hits.every((c) => c.type === 'arts'));
|
||||
assert.equal(new Set(hits.map((c) => c.target)).size, 1, 'passive max_target does not turn normal attacks into multi-target attacks');
|
||||
assert.deepEqual(h.snapshot().units.find((t) => t[0] === u.id).slice(5, 7), [2, 4]);
|
||||
h.run(2.1);
|
||||
assert.equal(u.skill.active, false);
|
||||
assert.equal(u.skill.charges, 0);
|
||||
approx(u.s.atk, 100);
|
||||
assert.deepEqual(u.rangeKeys, u.baseRangeKeys);
|
||||
h.run(2);
|
||||
const late = h.hooksOf('damaged').filter((c) => c.source === u && c.t > 4.5);
|
||||
assert.ok(late.length && late.every((c) => c.type === 'phys'));
|
||||
assert.equal(h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'cold').length, 0,
|
||||
'passive proc parameters do not become attack statuses');
|
||||
assert.equal(p.skill.kind, 'passive');
|
||||
assert.equal(p.skill.active, true);
|
||||
approx(p.s.atk, 200);
|
||||
h.invariants();
|
||||
});
|
||||
|
||||
test('generic 宴 S2: real normal/elite descriptions convert damage to arts only during the deployment window', () => {
|
||||
const ds = getDefaultSource();
|
||||
for (const id of ['chess_char_1_18_a', 'chess_char_1_18_b']) {
|
||||
const index = ds.rawChess(id).skills.find((s) => s.skillId === 'skchr_utage_2').index;
|
||||
const h = makeBattle({
|
||||
content: 'generic', timeLimit: 40, captureNoisy: true,
|
||||
units: [{ chessId: id, skillIndex: index, row: 10, col: 4 }],
|
||||
defs: { enemies: { enemy_dummy: dummy({ atk: 0 }) } },
|
||||
});
|
||||
h.b.start();
|
||||
const u = h.unit(id);
|
||||
h.spawn('enemy_dummy', { pos: [10, 4] });
|
||||
const hits = () => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg.isAttack);
|
||||
h.run(2);
|
||||
assert.ok(hits().length > 0 && hits().every((c) => c.type === 'arts'), `${id}: active S2 deals arts damage`);
|
||||
h.run(u.skill.timeLeft + h.TICK);
|
||||
assert.equal(u.skill.active, false);
|
||||
assert.equal(u.skill.ready, false);
|
||||
const endedAt = hits().length;
|
||||
h.run(3);
|
||||
const late = hits().slice(endedAt);
|
||||
assert.ok(late.length > 0 && late.every((c) => c.type === 'phys'), `${id}: normal physical attacks after expiry`);
|
||||
assert.equal(u.skill.activations, 1);
|
||||
assert.deepEqual(h.b.errors, []);
|
||||
h.invariants();
|
||||
}
|
||||
});
|
||||
|
||||
test('SP: time regen, starts at initSp, capped at cost; DEFAULT waits for an enemy in the initial range', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_sn: sniperWith({ spCost: 5, initSp: 2, duration: 3, bb: { atk: 1 } }) }, enemies: { enemy_dummy: dummy() } },
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//
|
||||
// The chess: elite 野鬃 (chess_char_1_19_b). Default = S2 夹枪冲锋 (25/40 SP) + module 长枪替补套装 (ATK +40, ASPD +3).
|
||||
// Chosen here: S1 骑枪刺击 (ON_DEPLOY: "部署后攻击速度+100" for 25 s) + 不装备 — so right after the unit deploys the
|
||||
// local battle must show the S1 buff (wildmn:s1, ASPD +100, skill active) and the no-module stats (ATK 524, ASPD 100).
|
||||
// local battle must show S1 active with a draining duration bar, ASPD +100 and the no-module stats (ATK 524, ASPD 100).
|
||||
// The server is test/e2e/fastServer.mjs with its starter-kit hook (SP_START_CHESS: the elite in the hand at round 1);
|
||||
// everything else is the real match engine and the real UI driven by real mouse input.
|
||||
// Screenshots: test/e2e/out/loadout-battle-*.png.
|
||||
@@ -16,6 +16,9 @@ import assert from 'node:assert/strict';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf } from '../e2e/client.mjs';
|
||||
import { makeBattle } from '../helpers/battleHarness.js';
|
||||
import { UF } from '../../shared/constants.js';
|
||||
import { COLORS } from '../../public/js/render/style.js';
|
||||
|
||||
const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets'));
|
||||
const BASE = 'chess_char_1_19_a';
|
||||
@@ -129,15 +132,16 @@ describe('user playtest #2 item 1 — loadout chosen in the UI fights in the loc
|
||||
const e = r && [...r._entries.values()].find((x) => x.own && x.battle);
|
||||
if (!e) return false;
|
||||
const u = e.battle.allyUnits.find((x) => x.uid === uid);
|
||||
if (!u || !u.findBuff?.('wildmn:s1')) return false;
|
||||
const b = u.findBuff('wildmn:s1');
|
||||
if (!u?.skill?.active || u.skill.id !== 'skchr_wildmn_1') return false;
|
||||
const tuple = e.battle.snapshot().units.find((t) => t[0] === u.id);
|
||||
const entry = (e.spec.players || []).flatMap((p) => p.units || []).find((x) => x.uid === uid) || null;
|
||||
return {
|
||||
authoritative: e.authoritative, t: e.battle.time,
|
||||
spec: entry && { chessId: entry.chessId, skillIndex: entry.skillIndex, moduleId: entry.moduleId },
|
||||
skill: u.skill?.id ?? null, source: u.kit?.skillSource ?? null, active: !!u.skill?.active,
|
||||
loadout: u.def?.loadout ?? null, atk: u.def?.stats?.atk, aspd: u.def?.stats?.aspd,
|
||||
buff: { aspd: b.mods?.aspd ?? null },
|
||||
liveAspd: u.s.aspd, timeLeft: u.skill.timeLeft, duration: u.skill.duration, ready: u.skill.ready,
|
||||
bar: tuple && { value: tuple[5], max: tuple[6] },
|
||||
};
|
||||
}, { timeout: 30000, polling: 100 }, piece.uid).then((h) => h.jsonValue())
|
||||
.catch(async (err) => {
|
||||
@@ -157,7 +161,11 @@ describe('user playtest #2 item 1 — loadout chosen in the UI fights in the loc
|
||||
assert.equal(got.atk, rec.statsBase.atk, 'no module: base ATK (the default module adds +40)');
|
||||
assert.equal(got.aspd, rec.statsBase.aspd, 'no module: base ASPD');
|
||||
assert.notEqual(rec.stats.atk, rec.statsBase.atk, 'control: the default module would change ATK');
|
||||
assert.equal(got.buff.aspd, s1.bb.attack_speed, 'S1 signature: ASPD +100 on deploy');
|
||||
assert.equal(got.liveAspd - got.aspd, s1.bb.attack_speed, 'S1 signature: ASPD +100 on deploy');
|
||||
assert.equal(got.ready, false, 'deployment consumed its sole charge');
|
||||
assert.equal(got.duration, s1.duration);
|
||||
assert.equal(got.bar.max, s1.duration, 'zero-SP skill has a duration bar');
|
||||
assert.ok(Math.abs(got.bar.value - got.timeLeft) <= 0.06);
|
||||
assert.ok(got.active, 'S1 is active right after deploying');
|
||||
assert.ok(got.t < 10, `S1 fires on deploy (t = ${got.t}), long before S2 could charge (25/40 SP)`);
|
||||
await sleep(800);
|
||||
@@ -171,4 +179,63 @@ describe('user playtest #2 item 1 — loadout chosen in the UI fights in the loc
|
||||
await srv.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('野鬃 S1 duration bar: full on deploy, half remaining, hidden after expiry (real renderer)', { timeout: 120000 }, async () => {
|
||||
const h = makeBattle({
|
||||
stageId: 'act2autochess_m01', autoFinish: false, timeLimit: 60,
|
||||
units: [{ chessId: ELITE, skillIndex: 0, moduleId: 'none', row: 9, col: 5 }],
|
||||
});
|
||||
h.b.start();
|
||||
const u = h.unit(ELITE);
|
||||
assert.equal(u.skill.id, 'skchr_wildmn_1');
|
||||
const meta = { ...h.b.fieldMeta(), stageId: 'act2autochess_m01' };
|
||||
const frames = [];
|
||||
for (const [name, elapsed, share] of [['full', 0, 1], ['half', u.skill.duration / 2, 0.5], ['ended', u.skill.duration / 2 + h.TICK, 0]]) {
|
||||
h.run(elapsed);
|
||||
const { t, ...rest } = h.snapshot();
|
||||
frames.push({ name, share, snap: { ...rest, t: 'b.snap', gt: t } });
|
||||
assert.equal(u.skill.ready, false, `${name}: no remaining deploy charge`);
|
||||
assert.equal(u.skill.active, name !== 'ended');
|
||||
}
|
||||
const srv = await startRealServer();
|
||||
const P = (await import('puppeteer-core')).default;
|
||||
const c = new Client(P, srv.base, 'duration', { prefix: 'loadout-battle' });
|
||||
try {
|
||||
await c.open();
|
||||
await c.page.goto(`${srv.base}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`);
|
||||
await c.page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
|
||||
assert.equal(await c.page.evaluate(() => window.__demo.error ?? null), null);
|
||||
for (const f of frames) {
|
||||
await c.page.evaluate((meta, snap) => {
|
||||
const v = window.__demo.view;
|
||||
v.enterBattle(meta);
|
||||
v.setCamera('normal', { rect: meta.rect, side: 'L', instant: true });
|
||||
v.setLocalFeed({ on: true, speed: 2 });
|
||||
v.pushSnapshot(snap);
|
||||
}, meta, f.snap);
|
||||
await c.page.waitForFunction((id, spMax) => {
|
||||
const x = window.__demo.view.debug.views.get(id);
|
||||
return x?.spineReady && x.hud.visible && x.hud.alpha > 0.99 && x.hpFill.visible && x.spMax === spMax;
|
||||
}, { timeout: 15000 }, u.id, f.snap.units.find((t) => t[0] === u.id)[6]);
|
||||
const bar = await c.page.evaluate((id) => {
|
||||
const x = window.__demo.view.debug.views.get(id);
|
||||
return { visible: x.spFill.visible, bg: x.spBg.visible, share: x.spFill.width / (x.spBg.width - 2),
|
||||
tint: x.spFill.tint, flags: x.flags, glow: x.spGlow.visible, sp: x.sp, max: x.spMax };
|
||||
}, u.id);
|
||||
assert.equal(bar.glow, false, `${f.name}: no ready highlight`);
|
||||
assert.equal(!!(bar.flags & UF.SKILL), f.name !== 'ended');
|
||||
assert.equal(bar.visible, f.name !== 'ended');
|
||||
assert.equal(bar.bg, f.name !== 'ended');
|
||||
if (bar.visible) {
|
||||
assert.ok(Math.abs(bar.share - f.share) < 0.002, `${f.name}: drawn share ${bar.share}`);
|
||||
assert.equal(bar.tint, COLORS.spActive);
|
||||
} else assert.deepEqual([bar.sp, bar.max], [0, 0]);
|
||||
await c.shot(`duration-${f.name}`);
|
||||
}
|
||||
assert.deepEqual(problemsOf([c]), []);
|
||||
} finally {
|
||||
await c.close();
|
||||
await srv.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user