From 819a5ad4ea813333efef4de335b84fe858d75ac3 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sun, 4 Oct 2026 20:33:29 +0800 Subject: [PATCH] =?UTF-8?q?feedback3=20WI:=20=E4=B9=8C=E5=B0=94=E6=AF=94?= =?UTF-8?q?=E5=AE=89=20S3=20=E3=80=90=E7=A7=BB=E5=8A=A8=E3=80=91=20and=20?= =?UTF-8?q?=E3=80=90=E8=BF=94=E5=9B=9E=E3=80=91=20are=20free=20redeploys,?= =?UTF-8?q?=20and=20the=20return=20clears=20his=20SP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/SIM.md | 7 +- public/js/render/interp.js | 11 +- public/js/render/spine.js | 3 + server/sim/Battle.js | 26 ++ server/sim/content/devices.js | 16 +- server/sim/content/kits/tier5.js | 13 +- server/sim/content/tokens.js | 8 +- test/render/feedback3-move-deploy.test.js | 76 ++++++ test/sim/feedback3-ulpia-move.test.js | 302 ++++++++++++++++++++++ 9 files changed, 444 insertions(+), 18 deletions(-) create mode 100644 test/render/feedback3-move-deploy.test.js create mode 100644 test/sim/feedback3-ulpia-move.test.js diff --git a/docs/SIM.md b/docs/SIM.md index 3049d9f0..25c0220f 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -709,7 +709,7 @@ registration order. `battle.off(handle)` / `battle.off(name, fn)` / `battle.offO | name | ctx | notes | |---|---|---| | `battleStart` | `{}` | after the initial deployment | -| `deploy` | `{ unit, initial }` | ops/tokens (initial & redeploy), enemies (`initial:false`), devices | +| `deploy` | `{ unit, initial, move? }` | ops/tokens (initial & redeploy), enemies (`initial:false`), devices; `move: true` = a 【移动】 (`moveRedeploy`: 乌尔比安 S3 — no exit before it, the unit keeps its buffs) | | `tick` | `{ dt }` | end of every tick | | `beforeAttack` | `{ attacker, targets, isSkill, profile }` | allies **and** enemies; replace/filter `ctx.targets` | | `attack` | `{ attacker, targets, isSkill }` | an attack/heal was performed (projectiles may still be in flight) | @@ -766,7 +766,8 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged | `addLayers(playerId, bondId, n, reason, {source})`, `addCoins(playerId, n)` | layers are a no-op when `flags.layerGainsEnabled` is false (unite/boss); a gain adds at most the room left under `BOND_LAYER_CAP` (999, shared/constants.js `layerGainRoom`: the client's `AddBondCount` min(L + n, 999)) on the live copy — or, without one, on the battle's own gains — and returns what it added (0 at the cap: no hook, no event) | | `getPlayer(playerId)` | `{ playerId, seat, side, colOffset, mirror, dir (default unit direction: RIGHT, mirrored side LEFT), facing (its sign), bonds (live copy, layers updated by addLayers), bandId, playerEffects, lpForBoss, dp, units }` | | `mapTile(ps, row, col, abs?)` / `mapDir(ps, dir, abs?)` | board → field tile / direction of a player (the FA right-side mirror) | -| `addDp(playerId, n)`, `retreat(unit, {reason, permanent})`, `relocate(unit, r, c)` | | +| `addDp(playerId, n)`, `retreat(unit, {reason, permanent})`, `relocate(unit, r, c)` | `relocate` only changes the tile (state kept, no event) | +| `moveRedeploy(unit, r, c, { clearSp })` | a 【移动】 (PRTS 术语释义: "不退场,以当前血量在目标位置部署", a special retreat + redeploy): `relocate`'s checks (false = refused, nothing changed), then a new deployment (`deploySeq` / `aggroSeq` / `deployedAt`) and `deploy {initial:false, move:true}` (deploy effects fire again); no `die` / `death`, timer or cost (不屈 / 阿戈尔's revive never see it); HP, buffs and a running skill are kept (owner's deviation, DESIGN §22.3); `clearSp` empties the SP before the deploy handlers run — 乌尔比安 S3's move (kits/tier5.js) and 【返回】 (`clearSp`; also the 从不混淆的方向 fallback, content/tokens.js) | | `redeploy(unit, { free=true, tile, keepSp })` | immediate (re)deployment of a dead/retreated ally (full HP, `deploy {initial:false}`); `free: false` pays `base.cost` DP (refused without it); without `tile` it lands on the unit's rest tile (`restTile`: where a knocked-out operator lies, else home); `tile: [r, c]` lands on that tile once (home unchanged; refused when off-rect, occupied or a knocked-out operator's tile, no fallback); `keepSp` keeps SP/charges (保留技力), restored before `deploy` fires — 突袭 raids, 阿戈尔 / 不屈 revives where the unit lies | | `refreshRange(unit)`, `setExtraRange(unit, keys)`, `rangeChanged(unit)` | rebuild the ranges after changing `unit.rangeGrid` (流形 copies); extra targetable tiles (absolute keys; merged into every later rebuild until set again; `null` clears; never in `baseRangeKeys`): 蕾缪安 wanted, 维娜 S3 | | `push(enemy, force, {from, dir, fixed, fixedAngle, inward, effect})`, `pull(enemy, force, {to, center, stop})`, `pullToFront(enemy, unit, force)`, `forceLevel(enemy, force)`, `pushDistance(enemy, force, {effect})` | the official 位移 (PRTS 游戏数据基础 §重量公式 / 推与拉; user playtest #6 item 14): 受力等级 = 力度 (微小力 −1, 小力 0, 中力 1, 较大力 2, 大力 3 …) − current 重量等级 (massLevel, 失重 counts). Push distance per level (`constants.js PUSH_TILES`, PRTS 推与拉's 弹道 column): ≤ −3 → 0, −2 → 0.12, −1 → 0.44, 0 → 1.7, 1 → 2.14, 2 → 2.96, ≥ 3 → 3.53 tiles; `effect` = a 特效 push (`PUSH_TILES_EFFECT`: −2 → 0.085, −1 → 0.374, 0 → 1.562, 1 → 1.987, 2 → 2.773, ≥ 3 → 3.331 — 见行者 S1 / S2, `PUSH_EFFECT_SKILLS`; every other pusher uses the 弹道 column [ASSUMED]); radial (away from `from`; the client's buff template `knockback[relative]`: 莫斯提马 S3, 山 S3, 琳琅诗怀雅 S3 — also PRTS 备注 "推开效果为径向推动") unless `dir` (directional; template `knockback[dir]` = Knockback {`_useSourceDirection` true, `_decreaseForceLevelWhenNotInDirection` 2}: 推击手, 野鬃 S2 "往攻击方向" (charpack char_496_wildmn: buff `wildmn_s_2[force]`) — > 45° off or < 0.25 tile ⇒ radial and level −2; `fixed` waives both (圣聆初雪 S1 朝部署方向, `KnockBackWithCharacterDirection`), `fixedAngle` only the angle (见行者 S2)); `inward` = a push towards `from` (薄绿 S2), stopping at the 急停 radius. Pull: level ≥ 0 → to `to` / the 急停 radius around `center`, −1 → 35 % of the way, −2 → 0.03, ≤ −3 → 0; `pullToFront` aims at the 拉力起点 0.5 tile ahead of the unit with the 急停 radius 0.6708 around it (never moves an enemy the unit itself blocks — nor does a pull towards an ally's centre, e.g. the 流形 S3 pulse) A 静态刚体 (data `staticBody`: every air unit of the mode except “炎佑”, plus 昆图斯 — PRTS 特殊机制 "可以进入失衡状态…但物理层面上无法产生任何速度或移动", "与单位的行动方式无关") moves 0 from every source while the skills that reach it still hit it (薄绿 S2, 锏 S3, the 钩索师 …; player report after 0.1.0) | @@ -1167,7 +1168,7 @@ Unknown subprofessions fall back to the profession default (test `professions.te knocked-out operator's tile, §1; `row, col` = the tile it lies on and comes back on, `unit.body`); `elem: [[id, element, fill, cooldownEnd, cooldown]]` (only when non-empty) = `elementView` of every unit with a gauge or a running 爆发冷却 (§3). `fieldMeta()` lists the knocked-out operators too (a client joining mid-battle shows them; DESIGN §18.3). -- `drainEvents()` tuples: `['spawn', UnitInfo]` (first appearance), `['deploy', id]` (every (re)deploy), `['atk', src, tgt, projKind]` +- `drainEvents()` tuples: `['spawn', UnitInfo]` (first appearance), `['deploy', id]` (every (re)deploy and 【移动】 — the client plays the deploy and its interpolation snaps instead of sliding), `['atk', src, tgt, projKind]` (`none|arrow|bolt|bomb|lob|orb|drone|enemy|boomerang|droneBomb|chain|chainHeal`; a boomerang's way back has no event — the renderer flies it back to the thrower at `BOOMERANG_RETURN_SPEED`; an enemy's `profile.shot` may name another kind, e.g. `mortar` for 帝国炮火先兆者, which the renderer does not draw — its fx `bombardShell` is the shell), `['dmg', tgt, amount, type]` (`phys|arts|true|burn|neural|necrosis|apoptosis`), diff --git a/public/js/render/interp.js b/public/js/render/interp.js index 49ffd26a..1ab8cc28 100644 --- a/public/js/render/interp.js +++ b/public/js/render/interp.js @@ -12,7 +12,8 @@ // * sample(): per unit, lerps x/y/hp/sp between the two snapshots bracketing renderT; flags/anim come from // the older one. A unit missing from the newer snapshot (died/left mid-buffer) holds its last position // until renderT reaches the newer snapshot; a unit that only exists in the newer one (spawned mid-buffer) -// appears when renderT reaches it. Moves longer than `teleport` tiles between two snapshots snap. +// appears when renderT reaches it. Moves longer than `teleport` tiles between two snapshots snap, and so does a +// unit whose deploy animation starts in between (a redeploy while it stays listed: 乌尔比安's 【移动】, a 突袭 jump). // * event queue: a `b.ev` batch is stamped with its game time (`gt`, the snapshot it was drained with); a batch // without one is placed inside the latest snapshot interval (newest snapshot time minus half an interval), // and handed out by `takeEvents()` once renderT passes the stamp. Stale cosmetic events (> `eventMaxLag` @@ -30,8 +31,11 @@ // Game times in both (`cooldownEnd`, `respawnAt`) are on the snapshots' clock, so a view compares them with renderT. import { fxForm } from '../../../shared/protocol.js'; +import { ANIM } from '../../../shared/constants.js'; export const TUPLE = Object.freeze({ ID: 0, X: 1, Y: 2, HP: 3, MAXHP: 4, SP: 5, SPMAX: 6, FLAGS: 7, ANIM: 8, EL: 9, EL_FILL: 10, EL_UNTIL: 11, EL_DUR: 12 }); +/** The unit was (re)deployed between tuples `a` and the newer `b`: `b` plays the deploy animation, `a` did not (sim snapshot animOf). */ +const redeployed = (a, b) => b[8] === ANIM.DEPLOY && a[8] !== ANIM.DEPLOY; /** Element keys a snapshot `elem` entry may carry (server/sim/constants.js ELEMENT_ORDER). */ const ELEMENT_KEYS = new Set(['neural', 'erosion', 'burn', 'apoptosis', 'necrosis']); @@ -282,7 +286,8 @@ export class SnapshotBuffer { const b = B ? B.units.get(id) : null; if (b) { const dx = b[1] - a[1], dy = b[2] - a[2]; - const tele = dx * dx + dy * dy > this.teleport * this.teleport; + // (a deployment in between — the newer snapshot starts its deploy animation — lands on its tile, no slide) + const tele = dx * dx + dy * dy > this.teleport * this.teleport || redeployed(a, b); o.x = tele ? (alpha < 1 ? a[1] : b[1]) : a[1] + dx * alpha; o.y = tele ? (alpha < 1 ? a[2] : b[2]) : a[2] + dy * alpha; o.vx = !tele && span > 0 ? dx / span : 0; @@ -298,7 +303,7 @@ export class SnapshotBuffer { const dtp = A.t - P.t; if (p && dtp > 0) { vx = (a[1] - p[1]) / dtp; vy = (a[2] - p[2]) / dtp; - if (vx * vx + vy * vy > (this.teleport / dtp) ** 2) { vx = 0; vy = 0; } + if (vx * vx + vy * vy > (this.teleport / dtp) ** 2 || redeployed(p, a)) { vx = 0; vy = 0; } } } o.x = a[1] + vx * ext; diff --git a/public/js/render/spine.js b/public/js/render/spine.js index 13baee77..82d509fa 100644 --- a/public/js/render/spine.js +++ b/public/js/render/spine.js @@ -356,6 +356,9 @@ export class SpineActor { this.skillOn = on; const sk = this.roles.skill; if (this.mode === 'stun' || this.mode === 'die') return; + // a skill that ends as the unit (re)deploys — 乌尔比安's 【返回】 is a 【移动】 (sim Battle.moveRedeploy) right before + // his S3's 'skill' off event — lets the deploy clip play out (then the plain idle) instead of cutting it with the End + if (!on && this.mode === 'deploy') return; if (on && sk) { if (this.has(sk.begin)) { this.mode = 'skillBegin'; diff --git a/server/sim/Battle.js b/server/sim/Battle.js index 5b52e929..b62afb0b 100644 --- a/server/sim/Battle.js +++ b/server/sim/Battle.js @@ -1912,6 +1912,32 @@ export class Battle { return true; } + /** + * 【移动】 (PRTS 术语释义 移动: "不退场,以当前血量在目标位置部署 … 本质上为一次特殊的撤退-再部署行为"): `unit` leaves its + * tile (relocate: same checks, false = refused, nothing changed) and is deployed on (r, c) at once. A new deployment — + * deploySeq / aggroSeq / deployedAt (the deploy animation; once-per-deployment effects re-arm) — announced by + * `deploy` { initial: false, move: true }: deploy effects fire again ("可以通过移动行为多次触发部署时触发的效果"). Not an + * exit: no `die` / `death`, so no knock-out, no redeploy timer or cost ("因移动撤退时不会积累再部署惩罚") — 不屈 and + * 阿戈尔's revive, which act on exits "因移动之外的原因" (PRTS 阿戈尔 备注), never see it. HP, buffs and a running skill + * stay: officially nothing carries over but what the mover's skill names (乌尔比安 S3: 技能进度 and the second talent's + * stacks); the remake keeps the rest too (owner's deviation, DESIGN §22.3). `clearSp`: the 技力 is emptied before the + * deploy handlers run ("部署时将清空技力"; 乌尔比安's 【返回】 "将清空技力,但仍可以享受后续由其他效果提供的技力"). + */ + moveRedeploy(unit, r, c, { clearSp = false } = {}) { + if (!this.relocate(unit, r, c)) return false; + unit.deploySeq = ++this._deploySeq; + unit.aggroSeq = unit.deploySeq; + unit.deployedAt = this.time; + const sk = unit.skill; + if (clearSp && sk && !sk.noSkill && sk.kind !== 'passive' && !(sk.active && sk.isTimed)) { + sk.sp = 0; + sk.charges = sk.spCost <= 0 ? sk.maxCharges : 0; // (a free skill is available once per deployment: skills.js reset) + } + this._ev(['deploy', unit.id]); + if (this._hooks.deploy) this.emit('deploy', { unit, initial: false, move: true }); + return true; + } + /** * 受力等级 of enemy `e` under a push / pull of 力度 `force` (微小力 −1, 小力 0, 中力 1, 较大力 2, 大力 3, 大力+1 4, 特大力 5): * force − its current 重量等级 (massLevel incl. 失重) — PRTS 游戏数据基础 §重量公式, 推与拉 (user playtest #6 item 14). diff --git a/server/sim/content/devices.js b/server/sim/content/devices.js index 51270496..221f5269 100644 --- a/server/sim/content/devices.js +++ b/server/sim/content/devices.js @@ -374,11 +374,11 @@ function enterTerrain(battle, st, u, code) { * so without it the loss continues. One effect per unit (PRTS 作战机制: "同名buff的默认叠加策略buff只能表现出一个"): a * unit that already carries it gets its full `duration` back and keeps its per-second rhythm — no second effect, no * extra tick [ASSUMED: the time counts from the last contact — so an operator deployed on it, always in contact, drains - * past `duration`]. An operator moved off the tile (Battle.relocate: 乌尔比安 S3, 夕's 小自在 …) keeps it for its time; - * leaving the field drops it with every buff; a 重生 clears it (enemies.js rebirthCleanse: PRTS 特殊机制 §重生 "清空自身 - * 身上除白名单外所有Buff") and contact gives it again while the unit is on the tile [ASSUMED]. The tick (infectionDamage) - * is true damage no unit deals (无来源), tagged 'terrain' = 环境伤害 (PRTS 自然环境 lists 活性源石), not 'dot' [ASSUMED: - * PRTS 伤害分类's list of BUFF damage does not name it]. + * past `duration`]. An operator moved off the tile (Battle.relocate: 夕's 小自在 …; Battle.moveRedeploy: 乌尔比安 S3) + * keeps it for its time; leaving the field drops it with every buff; a 重生 clears it (enemies.js rebirthCleanse: PRTS + * 特殊机制 §重生 "清空自身身上除白名单外所有Buff") and contact gives it again while the unit is on the tile [ASSUMED]. The + * tick (infectionDamage) is true damage no unit deals (无来源), tagged 'terrain' = 环境伤害 (PRTS 自然环境 lists 活性源石), + * not 'dot' [ASSUMED: PRTS 伤害分类's list of BUFF damage does not name it]. */ function touchInfection(battle, st, u) { const I = st.infection; @@ -490,11 +490,13 @@ function ensureTick(battle, st) { for (const u of st.turrets.values()) tickTurret(battle, u, dt); }); // a unit that (re)deploys / spawns starts fresh (death drops its buffs) and gets its tile's terrain at once: smog - // must already hide an operator from the first enemy shot of the battle + // must already hide an operator from the first enemy shot of the battle. A 【移动】 (ctx.move: 乌尔比安 S3, + // Battle.moveRedeploy) keeps its buffs, so it changes tiles like a walk instead: starting from nothing would strand + // the old tile's smog / airflow buff on it battle.on('deploy', (ctx) => { const u = ctx.unit; if (!u || !u.mem || u.kind === 'device') return; - u.mem.terrain = 0; u.mem.airAtk = 0; u.mem.airMul = 1; u.mem.airPx = null; + if (!ctx.move) { u.mem.terrain = 0; u.mem.airAtk = 0; u.mem.airMul = 1; u.mem.airPx = null; } refreshUnit(battle, st, u); }, { priority: 90 }); } diff --git a/server/sim/content/kits/tier5.js b/server/sim/content/kits/tier5.js index 41ba0877..f2beb571 100644 --- a/server/sim/content/kits/tier5.js +++ b/server/sim/content/kits/tier5.js @@ -765,7 +765,7 @@ const KITS = { // 乌尔比安 — S3 必须开辟的通路 (25 s, CUSTOM_RANGE row ahead): max HP/ATK +, throws an anchor forward that stops on the // first enemy or at max distance — on his own tile while he blocks: 135 % ATK phys + 6 s stun around it // (projectile_range); moves onto the anchor tile when deployable and not his own (a 从不混淆的方向 marker keeps his tile) - // and returns at skill end. + // and returns at skill end — both 【移动】, i.e. free redeploys (Battle.moveRedeploy; the return empties his SP). // T1 本性的坚守: heal 100 (160 below 50 %) on every hit taken. T2 血脉的哺养: per kill +120 max HP / +30 ATK (×9), // other Abyssal Hunters +50 %. Module (elite): healing received ×1.2. // S1 必须促成的接触 (instant): the anchor lands on the best enemy of the skill range (beyond his own range, unblocked @@ -846,7 +846,10 @@ const KITS = { const dest = [[sr, sc], frontOf(unit.tileR, unit.tileC, unit.dir, stop + 1)].find(ok); if (dest == null) return; const home = [unit.tileR, unit.tileC]; - if (!battle.relocate(unit, dest[0], dest[1])) return; + // the 【移动】 is a redeploy on the new tile (Battle.moveRedeploy: a new deployment, deploy effects fire again, + // no exit) that keeps the running skill — "【移动】后仅继承下列效果:技能进度、第二天赋叠加层数"; the rest of + // his buffs are kept too (owner's deviation, DESIGN §22.3). The marker is deployed after the move (备注 ③) + if (!battle.moveRedeploy(unit, dest[0], dest[1]) || !unit.alive || !unit.skill?.active) return; const marker = battle.spawnToken(unit, tokenId, home[0], home[1], { untargetable: true, kit: { skill: null, trait: { noAttack: true } } }); unit.mem.anchorHome = { r: home[0], c: home[1], marker }; battle.fx('teleport', { x: unit.x, y: unit.y, id: unit.id, fromX, fromY }); @@ -857,8 +860,12 @@ const KITS = { if (!h) return; if (h.marker && h.marker.alive) battle.retreat(h.marker, { reason: 'expired', permanent: true }); if (unit.alive && unit.deployed) { + // ④ 【返回】: a 【移动】 back to his tile "【返回】时将清空技力,但仍可以享受后续由其他效果提供的技力" — the SP is + // emptied before the deploy effects of the return run (迅捷作战粮, 黄沙罗盘 … still give theirs, so do skillEnd + // effects after it: 迅捷). Here in onEnd the skill's mods are still on (skills.js end) [ASSUMED: officially the + // return comes "技能结束后"; none of the deploy effects that can reach him reads his stats] const fromX = unit.x, fromY = unit.y; - if (battle.relocate(unit, h.r, h.c)) battle.fx('teleport', { x: unit.x, y: unit.y, id: unit.id, fromX, fromY }); + if (battle.moveRedeploy(unit, h.r, h.c, { clearSp: true })) battle.fx('teleport', { x: unit.x, y: unit.y, id: unit.id, fromX, fromY }); } }, }, diff --git a/server/sim/content/tokens.js b/server/sim/content/tokens.js index 7d3b1a9c..29dc90c8 100644 --- a/server/sim/content/tokens.js +++ b/server/sim/content/tokens.js @@ -903,7 +903,11 @@ function champagne(bb) { }; } -/** 从不混淆的方向 (乌尔比安 S3): marker of the owner's original tile; the owner returns there when the skill ends. */ +/** + * 从不混淆的方向 (乌尔比安 S3): marker of the owner's original tile; the owner returns there when the skill ends — a + * 【移动】 (Battle.moveRedeploy, SP emptied: PRTS 乌尔比安 S3 备注 "【返回】时将清空技力"). The 乌尔比安 kit brings him back + * itself (its onEnd retreats the marker first); this is the fallback for a kit that only places the marker. + */ function ulpiaMarker() { return { skill: null, @@ -916,7 +920,7 @@ function ulpiaMarker() { const r = unit.tileR, c = unit.tileC; battle.retreat(unit, { reason: 'expired', permanent: true }); const o = ownerOf(unit); - if (o && o.alive && o.deployed && (o.tileR !== r || o.tileC !== c) && battle.relocate(o, r, c)) battle.fx('ulpiaReturn', { x: c, y: r, id: o.id }); + if (o && o.alive && o.deployed && (o.tileR !== r || o.tileC !== c) && battle.moveRedeploy(o, r, c, { clearSp: true })) battle.fx('ulpiaReturn', { x: c, y: r, id: o.id }); }; // runs before bindToOwnerSkill's plain retreat (higher priority): retreat + bring the owner home battle.on('skillEnd', (ctx) => { diff --git a/test/render/feedback3-move-deploy.test.js b/test/render/feedback3-move-deploy.test.js new file mode 100644 index 00000000..7b45ce76 --- /dev/null +++ b/test/render/feedback3-move-deploy.test.js @@ -0,0 +1,76 @@ +// test/render/feedback3-move-deploy.test.js — 乌尔比安's S3 【移动】 and 【返回】 are redeploys since 0.1.3 (sim +// Battle.moveRedeploy: a 'deploy' event, the snapshot's deploy animation). The client shows them as deploys: the +// interpolation snaps a unit whose deploy animation starts between two snapshots instead of sliding it there, and a +// skill that ends as the unit deploys (the 【返回】 comes right before the S3's 'skill' off event) lets the deploy clip +// (Start) play out instead of cutting it with the skill's End clip. Real manifest entry, headless fake PIXI. + +import { test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { installFakePixi } from './fakepixi.js'; +import { SnapshotBuffer } from '../../public/js/render/interp.js'; +import { ANIM } from '../../shared/constants.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const assets = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8')); +const snap = (t, units) => ({ fieldId: 'n:1', t, units, dp: 10, killed: 0, total: 5 }); +const U = (id, x, y, anim = ANIM.IDLE) => [id, x, y, 100, 100, 5, 10, 0, anim]; + +let fake, SpineActor; +before(async () => { + fake = installFakePixi(); + ({ SpineActor } = await import('../../public/js/render/spine.js')); +}); +after(() => fake.restore()); + +test('interp: a unit redeployed between two snapshots (its deploy animation starts) snaps to its tile; a walk still slides', () => { + const b = new SnapshotBuffer({ teleport: 2.5 }); + b.push(snap(0, [U(1, 3, 10), U(2, 3, 11)]), 0); + b.push(snap(0.1, [U(1, 5, 10, ANIM.DEPLOY), U(2, 5, 11)]), 0.05); // 1: a 2-tile 【移动】; 2: a 2-tile slide + const mid = b.sample(0.05); + assert.equal(mid.get(1).x, 3, 'the old tile until the new snapshot'); + assert.equal(mid.get(1).vx, 0); + assert.equal(mid.get(2).x, 4, 'a plain move interpolates'); + assert.equal(b.sample(0.1).get(1).x, 5, 'then the new tile'); + // already deploying in both snapshots (deployed < 0.5 s before): an ordinary lerp + const c = new SnapshotBuffer({ teleport: 2.5 }); + c.push(snap(0, [U(1, 3, 10, ANIM.DEPLOY)]), 0); + c.push(snap(0.1, [U(1, 4, 10, ANIM.DEPLOY)]), 0.05); + assert.equal(c.sample(0.05).get(1).x, 3.5); + // a stalled stream right after the jump: no extrapolation along it + const d = new SnapshotBuffer({ teleport: 2.5, maxExtrapolate: 0.1, rate: 2 }); + d.push(snap(0, [U(1, 3, 10)]), 0); + d.push(snap(0.1, [U(1, 5, 10, ANIM.DEPLOY)]), 0.05); + assert.equal(d.sample(0.25).get(1).x, 5, 'stays on its new tile'); +}); + +test('SpineActor: 乌尔比安\'s 【返回】 — deploy then skill off keeps the Start clip, then the plain idle; skill off alone plays the End', () => { + const entry = assets.chars.char_4145_ulpia.spine.front; + const make = () => { + const a = new SpineActor({ animations: Object.keys(entry.animations).map((name) => ({ name })) }, entry); + a.setSkillIndex(2); + return a; + }; + const run = (a, s, dt = 1 / 60) => { for (let t = 0; t < s - 1e-9; t += dt) a.update(dt); }; + assert.deepEqual([make().roles.deploy, make().roles.skill.end], ['Start', 'Skill_3_End'], 'the manifest roles'); + const a = make(); + a.setSkill(true); // S3 on ('skill' 1) … + a.deploy(); // … and his 【移动】 ('deploy') in the same batch: the Start clip on the anchor tile + assert.equal(a.current, 'Start'); + run(a, 3); + assert.equal(a.mode, 'base'); + a.deploy(); // the 【返回】 ('deploy') … + a.setSkill(false); // … right before the S3's 'skill' 0 + assert.equal(a.mode, 'deploy'); + assert.equal(a.current, 'Start', 'the deploy clip is not cut by Skill_3_End'); + run(a, entry.animations.Start + 0.1); + assert.equal(a.mode, 'base'); + assert.equal(a.current, 'Idle', 'then the plain idle (the skill is off)'); + const b = make(); + b.setSkill(true); + run(b, 3); + b.setSkill(false); + assert.equal(b.current, 'Skill_3_End', 'a skill ending without a deploy still plays its End clip'); +}); diff --git a/test/sim/feedback3-ulpia-move.test.js b/test/sim/feedback3-ulpia-move.test.js new file mode 100644 index 00000000..aa9f178e --- /dev/null +++ b/test/sim/feedback3-ulpia-move.test.js @@ -0,0 +1,302 @@ +// test/sim/feedback3-ulpia-move.test.js — community report 「乌尔比安三技能结束后不属于再部署」 (0.1.3): his S3 【移动】 to the +// anchor and the 【返回】 at its end only changed his tile (Battle.relocate), so no deploy effect saw them — 迅捷作战粮's SP, +// 突袭手雷's window, 卡西米尔 / 叙拉古 / garrison <部署时> effects … — and the return did not empty his SP. +// Official: PRTS 乌尔比安 S3 备注 — the move and the 【返回】 are both 【移动】, "【移动】后仅继承下列效果:技能进度、第二天赋叠加层数", +// "【返回】时将清空技力,但仍可以享受后续由其他效果提供的技力"; PRTS 术语释义 【移动】 "不退场,以当前血量在目标位置部署", +// "本质上为一次特殊的撤退-再部署行为", "因移动撤退时不会积累再部署惩罚", "可以通过移动行为多次触发部署时触发的效果", +// "部署时将清空技力". Now both are Battle.moveRedeploy: a new deployment with a `deploy` { move: true } event, no exit. +// Owner's deviation kept (DESIGN §22.3): his buffs — the 阿戈尔 devour gains — stay through both moves. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; +import { hasGeneratedData } from '../../server/sim/simdata.js'; +import { ANIM } from '../../shared/constants.js'; +import { TOKEN_IDS } from '../../server/sim/content/tokens.js'; + +const REAL = { skip: !hasGeneratedData() }; +const ULP = 'chess_char_5_05_a'; +const RATION = 'chess_item_3_05_e_a'; // 迅捷作战粮 "部署时,获得3点技力;每有一个同盟约的其他干员,额外获得3点技力" +const GRENADE = 'chess_item_3_11_e_a'; // 突袭手雷 "每次部署后的10秒内,攻击时使目标晕眩2秒" +const close = (a, b, eps, msg = '') => assert.ok(Math.abs(a - b) <= eps, `${msg} expected ${b} ± ${eps}, got ${a}`); +const dummy = enemyRec({ key: 'e_dummy', hp: 1e9, speed: 0, def: 0, res: 0 }); +const clean = (h) => { assert.deepEqual(h.b.errors.map((e) => `${e.label}: ${e.message}`), []); checkInvariants(h.b); }; +const ITEMS = hasGeneratedData() ? JSON.parse(readFileSync(new URL('../../data/items.json', import.meta.url), 'utf8')) : {}; +const itemBb = (id) => ITEMS[id]?.buffs?.[0]?.bb ?? null; +const deploysOf = (h, u) => h.hooksOf('deploy').filter((c) => c.unit === u); + +test('乌尔比安 S3: the 【移动】 and the 【返回】 are free redeploys — deploy effects fire, the return empties the SP first, no exit', REAL, () => { + const ration = itemBb(RATION), grenade = itemBb(GRENADE); + assert.ok(ration && ration.sp_each_person > 0 && grenade && grenade.duration > 0 && grenade.stun > 0, 'item data'); + const h = makeBattle({ + defs: { enemies: { e_dummy: dummy } }, + // 深巡 (阿戈尔, like him) stands apart: the ration counts it as "同盟约的其他干员" + units: [{ chessId: ULP, row: 10, col: 3, items: [RATION, GRENADE] }, { chessId: 'chess_char_1_04_a', row: 12, col: 3 }], + enemies: [{ key: 'e_dummy', pos: [10, 6] }], hooks: ['deploy', 'death', 'skillEnd'], autoFinish: false, timeLimit: 120, + }); + const b = h.b, u = h.unit(ULP); + const gift = ration.sp_each_person * 2; + const stuns = []; + let endT = null; + b.on('statusApplied', (c) => { if (c.source === u && c.status === 'stun') stuns.push({ t: b.time, d: c.duration }); }); + b.on('skillEnd', (c) => { if (c.unit === u) endT = b.time; }); + h.step(); + assert.equal(deploysOf(h, u).length, 1, 'the battle-start deployment'); + h.run(3); + const seq0 = u.deploySeq, dp0 = b.getPlayer('p1').dp, hpPct0 = u.hp / u.s.maxHp, respawn0 = u.respawnAt; + u.skill.gainSp(1000); + assert.ok(h.runUntil(() => u.skill.active, 2)); + const castT = u.skill.lastStart; + // the 【移动】: a new deployment on the anchor tile, flagged as a move, while the skill keeps running + assert.deepEqual([u.tileR, u.tileC], [10, 6], 'moved onto the anchor tile'); + const mv = deploysOf(h, u); + assert.equal(mv.length, 2); + assert.equal(mv[1].move, true, 'deploy { move: true }'); + assert.equal(mv[1].initial, false); + assert.ok(u.deploySeq > seq0, 'a new deployment'); + close(u.deployedAt, castT, 1e-9, 'deployedAt = the move'); + assert.ok(u.skill.active, '技能进度 carries: the skill runs on'); + assert.ok(b.getPlayer('p1').dp >= dp0 - 1e-9, 'free (no DP paid)'); + assert.equal(u.respawnAt, respawn0, 'no redeploy timer'); + close(u.hp / u.s.maxHp, hpPct0, 1e-9, 'its HP'); + const ev = h.eventsOf('deploy').filter((e) => e[1] === u.id); + assert.equal(ev.length, 2, "the client's deploy event"); + const tup = b.snapshot().units.find((t) => t[0] === u.id); + assert.equal(tup[8], ANIM.DEPLOY, 'the snapshot plays the deploy'); + // 突袭手雷: its window starts again at the landing (castT + duration), past the battle-start one (0 + duration) + // leftover SP while the skill runs (gifts of 'init' kind reach a running skill): the return must empty it + u.skill.sp = 7; + assert.ok(h.runUntil(() => !u.skill.active, 30)); + close(endT - castT, u.skill.duration, 0.1, 'the skill ran its own duration'); + // the 【返回】: another move, home; the SP emptied before the deploy effects, the ration's gift on top + assert.deepEqual([u.tileR, u.tileC], [10, 3], 'back home'); + const back = deploysOf(h, u); + assert.equal(back.length, 3); + assert.equal(back[2].move, true); + close(u.deployedAt, endT, 1e-9); + close(u.skill.sp, gift, 0.1, 'the SP after the return = the ration only (7 cleared)'); + const late = stuns.filter((s) => Math.abs(s.d - grenade.stun) < 1e-9 && s.t > grenade.duration + 0.05); + assert.ok(late.length > 0, `grenade stuns after ${grenade.duration} s (window from the landing at ${castT.toFixed(2)})`); + assert.ok(late.every((s) => s.t <= castT + grenade.duration + 1e-6), 'and none past it'); + assert.equal(h.hooksOf('death').filter((c) => c.unit === u).length, 0, 'never an exit'); + assert.equal(h.eventsOf('die').filter((e) => e[1] === u.id).length, 0, 'no die, so no FORCED_EXIT and no knock-out'); + assert.equal(b._perPlayer.p1.deaths, 0, 'the redeploy count (knock-outs) stays 0'); + assert.equal(u.respawnAt, respawn0, 'no redeploy timer after the return either'); + assert.ok(b.getPlayer('p1').dp >= dp0 - 1e-9); + clean(h); +}); + +test('乌尔比安 S3 with 不屈 at 100 %: the moves roll nothing (a 【移动】 is no exit); a knock-out still does', REAL, () => { + const h = makeBattle({ + defs: { enemies: { e_dummy: dummy } }, + units: [{ chessId: ULP, row: 10, col: 3 }], enemies: [{ key: 'e_dummy', pos: [10, 6] }], + bonds: { indomShip: { count: 2, active: true, tier: 1, layers: 300 } }, hooks: ['deploy', 'death'], autoFinish: false, timeLimit: 120, + }); + const b = h.b, u = h.unit(ULP); + h.step(); + u.skill.gainSp(1000); + assert.ok(h.runUntil(() => u.skill.active, 2)); + assert.equal(deploysOf(h, u).at(-1).move, true); + assert.ok(h.runUntil(() => !u.skill.active, 30)); + assert.equal(deploysOf(h, u).length, 3, 'start + move + return'); + assert.equal(h.hooksOf('death').filter((c) => c.unit === u).length, 0); + assert.equal(u.mem.indomShip, undefined, '不屈 never rolled'); + assert.equal(h.eventsOf('fx').filter((e) => e[1] === 'revive').length, 0); + // the same bond does fire on a real knock-out: back at once, free + b.dealDamage(null, u, { amount: 1e9, type: 'true' }); + assert.ok(h.hooksOf('death').some((c) => c.unit === u && c.reason === 'killed')); + assert.ok(u.alive && u.mem.indomShip === b.time, '不屈 redeployed it'); + clean(h); +}); + +test('乌尔比安 S3: the 阿戈尔 devour gains stay through the 【移动】 and the 【返回】 (owner\'s deviation, DESIGN §22.3)', REAL, () => { + const fod = chessRec({ id: 't_fod', profession: 'WARRIOR', skill: null, stats: { atk: 1000, maxHp: 100, def: 0, blockCnt: 2 } }); + const h = makeBattle({ + defs: { enemies: { e_dummy: dummy }, chess: { t_fod: fod } }, + units: [{ chessId: ULP, row: 10, col: 3 }, { chessId: 't_fod', row: 10, col: 4 }], enemies: [{ key: 'e_dummy', pos: [10, 7] }], + bonds: { egirShip: { count: 3, active: true, tier: 1, layers: 0 } }, hooks: ['deploy'], autoFinish: false, timeLimit: 120, + }); + const u = h.unit(ULP); + h.step(); + const gain = () => u.buffs.find((x) => x.key === 'bond:egir:devour'); + assert.ok(gain(), 'he devoured the piece in front of him at the battle start'); + const mods = { ...gain().mods }, atk0 = u.s.atk, block0 = u.s.blockCnt; + assert.equal(mods.atkFlat, 1000); + u.skill.gainSp(1000); + assert.ok(h.runUntil(() => u.skill.active, 2)); + assert.deepEqual([u.tileR, u.tileC], [10, 7], 'moved'); + assert.equal(deploysOf(h, u).at(-1).move, true); + assert.deepEqual({ ...gain().mods }, mods, 'kept through the 【移动】'); + assert.ok(h.runUntil(() => !u.skill.active, 30)); + assert.deepEqual([u.tileR, u.tileC], [10, 3], 'back'); + assert.equal(deploysOf(h, u).at(-1).move, true); + assert.deepEqual({ ...gain().mods }, mods, 'kept through the 【返回】'); + close(u.s.atk, atk0, 1e-6, 'ATK as before the cast'); + assert.equal(u.s.blockCnt, block0); + clean(h); +}); + +test('乌尔比安 S3 while he blocks (GitHub #33, §22.3 unchanged): no 【移动】, no new deployment, nothing to return from', REAL, () => { + const h = makeBattle({ + defs: { enemies: { e_dummy: dummy } }, + units: [{ chessId: ULP, row: 9, col: 3 }], enemies: [{ key: 'e_dummy', pos: [9, 3] }], hooks: ['deploy'], autoFinish: false, timeLimit: 120, + }); + const u = h.unit(ULP); + h.step(); + const e = h.b.enemies[0]; + assert.equal(e.blockedBy, u); + const seq0 = u.deploySeq; + u.skill.gainSp(1000); + assert.ok(h.runUntil(() => u.skill.active, 2)); + assert.deepEqual([u.tileR, u.tileC], [9, 3]); + assert.ok(h.runUntil(() => !u.skill.active, 30)); + assert.equal(u.deploySeq, seq0, 'the same deployment'); + assert.equal(deploysOf(h, u).length, 1, 'only the battle-start deploy'); + assert.ok(!h.b.allyUnits.some((x) => x.kind === 'token'), 'no 从不混淆的方向'); + clean(h); +}); + +test('从不混淆的方向 fallback: a kit that only places the marker — the owner comes back by a 【移动】 with its SP emptied', REAL, () => { + const kit = () => ({ talents: [], skill: { kind: 'duration', duration: 3, trigger: 'SP_FULL', spCost: 10, initSp: 10, + onStart({ battle, unit }) { + const [r, c] = [unit.tileR, unit.tileC]; + if (battle.moveRedeploy(unit, 10, 7)) battle.spawnToken(unit, TOKEN_IDS.ulpiaMarker, r, c); + } } }); + const h = makeBattle({ kits: { [ULP]: kit }, units: [{ chessId: ULP, row: 10, col: 4 }], hooks: ['deploy'], autoFinish: false, timeLimit: 20 }); + h.step(2); + const u = h.unit(ULP); + assert.ok(u.skill.active && u.tileC === 7); + u.skill.sp = 5; + assert.ok(h.runUntil(() => !u.skill.active, 5)); + assert.deepEqual([u.tileR, u.tileC], [10, 4], 'back home'); + const d = deploysOf(h, u); + assert.equal(d.length, 3); + assert.ok(d[1].move && d[2].move); + assert.ok(u.skill.sp < 0.5, `SP emptied (${u.skill.sp})`); + clean(h); +}); + +test('乌尔比安 S3 out and back: a 替身 stays up, 不屈 and the 阿戈尔 revive do not spend, the knock-out count stays 0', REAL, () => { + // 风丸 is a 傀儡师. Her 替身 is already up when he casts; both of his moves in the one skill must leave her there. + const DOLL = 'chess_char_2_11_a'; + const h = makeBattle({ + defs: { enemies: { e_dummy: dummy } }, + units: [{ chessId: ULP, row: 10, col: 3 }, { chessId: DOLL, row: 12, col: 8 }], + enemies: [{ key: 'e_dummy', pos: [10, 6] }], + bonds: { + egirShip: { count: 9, active: true, tier: 2, layers: 0 }, + indomShip: { count: 2, active: true, tier: 1, layers: 300 }, + }, + hooks: ['deploy', 'death', 'dollSwap'], autoFinish: false, timeLimit: 120, + }); + const b = h.b, u = h.unit(ULP), doll = h.unit(DOLL); + h.step(); + doll.profile.dollDuration = 80; // still the 替身 when the 25 s skill returns + b.dealDamage(null, doll, { amount: 1e9, type: 'true' }); + assert.equal(doll.form, 'doll', 'she switched to the 替身'); + assert.equal(doll.alive, true); + const swaps = () => h.hooksOf('dollSwap').filter((c) => c.unit === doll).length; + const swaps0 = swaps(); + const respawn0 = u.respawnAt; + u.skill.gainSp(1000); + assert.ok(h.runUntil(() => u.skill.active, 2)); + assert.equal(deploysOf(h, u).at(-1).move, true); + assert.ok(h.runUntil(() => !u.skill.active, 30)); + assert.equal(deploysOf(h, u).length, 3, 'start + move + return'); + assert.equal(doll.form, 'doll', 'the 替身 was not knocked out of'); + assert.equal(doll.alive && doll.deployed, true); + assert.equal(swaps(), swaps0, 'neither move started a 替身 switch'); + assert.equal(h.hooksOf('death').filter((c) => c.unit === u || c.unit === doll).length, 0); + assert.equal(h.eventsOf('fx').filter((e) => e[1] === 'revive').length, 0, '不屈 and 阿戈尔 revived nobody'); + assert.equal(u.mem.indomShip, undefined); + assert.equal(b._perPlayer.p1.deaths, 0, 'redeploy count unchanged'); + assert.equal(u.respawnAt, respawn0); + // the charge is still there: a real knock-out spends it + b.dealDamage(null, u, { amount: 1e9, type: 'true' }); + assert.ok(u.alive, '阿戈尔 brought him back'); + assert.equal(h.eventsOf('fx').filter((e) => e[1] === 'revive').length, 1); + assert.equal(b._perPlayer.p1.deaths, 1, 'only the real knock-out counts'); + clean(h); +}); + +test('a 【移动】 fires each deploy effect once: 卡西米尔 +20 % per move, 叙拉古 refreshes without doubling, 大帝 stacks once', REAL, () => { + // 大买家-style listeners and these three all key off `deploy`. A move is a new deployment, so each fires again — + // once. 大帝's permanent redeploy stack is the one that accumulates: PRTS 加急调派 "每次部署后再部署时间减少50%", and a + // 【移动】 is a deployment (术语释义 移动). Doubling inside the same move would be the bug. + const kaz = chessRec({ id: 't_kaz', profession: 'WARRIOR', bonds: ['kazimierzShip'], skill: null, stats: { atk: 1000, maxHp: 2000, def: 0, blockCnt: 1, respawnTime: 20 } }); + const sira = chessRec({ id: 't_sira', profession: 'WARRIOR', bonds: ['siracusaShip'], skill: { spCost: 30, initSp: 0, duration: 8 }, stats: { atk: 1000, maxHp: 2000, def: 0, aspd: 100, blockCnt: 1, respawnTime: 20 } }); + const emp = chessRec({ id: 't_emp', profession: 'WARRIOR', skill: { spCost: 30, initSp: 0, duration: 8 }, stats: { atk: 500, maxHp: 2000, def: 0, blockCnt: 1, respawnTime: 20 } }); + const h = makeBattle({ + defs: { chess: { t_kaz: kaz, t_sira: sira, t_emp: emp }, enemies: { e_dummy: dummy } }, + units: [ + { chessId: ULP, row: 10, col: 3 }, + { chessId: 't_kaz', row: 11, col: 2 }, + { chessId: 't_sira', row: 12, col: 2 }, + { chessId: 't_emp', row: 12, col: 6 }, + ], + enemies: [{ key: 'e_dummy', pos: [10, 6] }], + bonds: { + kazimierzShip: { count: 3, active: true, tier: 1, layers: 200 }, // cap 50 + 1×L = 250 %, above the steps below + siracusaShip: { count: 3, active: true, tier: 1, layers: 0 }, + }, + bandId: 'band_emperor', hooks: ['deploy'], autoFinish: false, timeLimit: 120, + }); + const b = h.b, u = h.unit(ULP), kz = h.unit('t_kaz'), sr = h.unit('t_sira'), em = h.unit('t_emp'); + h.step(); + // 4 operators deployed at the start. 卡西米尔 is +20 % each, so four steps = +80 %. + close(kz.s.atk, 1000 * 1.8, 1e-6, 'initial deployments'); + assert.equal(sr.findBuff('bond:siracusa').stacks, 1); + const aspd = sr.findBuff('bond:siracusa').mods.aspd; + h.run(1.5); + const left = sr.findBuff('bond:siracusa').timeLeft; + em.skill.sp = 11; + assert.ok(b.moveRedeploy(em, 12, 7)); // no clearSp: the 11 stays (the outbound 【移动】 does not empty SP) + assert.equal(em.skill.sp, 11); + assert.equal(em.findBuff('band:band_emperor').stacks, 2, 'initial deploy + this move, not two stacks for one move'); + close(kz.s.atk, 1000 * 2.0, 1e-6, 'that move counted once for 卡西米尔'); + assert.ok(b.moveRedeploy(sr, 12, 3)); + assert.equal(sr.findBuff('bond:siracusa').stacks, 1); + assert.equal(sr.findBuff('bond:siracusa').mods.aspd, aspd, '叙拉古 replaces the buff; it does not stack'); + assert.ok(sr.findBuff('bond:siracusa').timeLeft > left + 1, 'the new deployment starts the window again'); + close(kz.s.atk, 1000 * 2.2, 1e-6, 'her move counted once'); + u.skill.gainSp(1000); + assert.ok(h.runUntil(() => u.skill.active, 2)); + close(kz.s.atk, 1000 * 2.4, 1e-6, 'his 【移动】 counts once'); + assert.equal(sr.findBuff('bond:siracusa').stacks, 1, 'he is not 叙拉古: his move does not stack her buff'); + assert.ok(h.runUntil(() => !u.skill.active, 30)); + close(kz.s.atk, 1000 * 2.6, 1e-6, 'the 【返回】 counts once more'); + assert.equal(em.findBuff('band:band_emperor').stacks, 2, 'his moves did not stack someone else\'s 大帝 buff'); + em.skill.sp = 9; + assert.ok(b.moveRedeploy(em, 12, 5, { clearSp: true })); + assert.ok(em.skill.sp < 0.5, 'clearSp empties it before deploy handlers; nothing here grants SP back'); + assert.equal(em.findBuff('band:band_emperor').stacks, 3); + // 0.5^3 of 20 s. A double stack on any one of the three deployments would be 0.5^4 or lower. + b.kill(em); + close(em.respawnAt - b.time, 20 * 0.5 ** 3, 1e-6, 'one stack per deployment'); + clean(h); +}); + +test('Battle.moveRedeploy: refused like relocate (nothing changes); 气流 follows the tile change instead of sticking (devices.js)', REAL, () => { + const op = chessRec({ id: 't_g', profession: 'WARRIOR', skill: null, stats: { atk: 1000, maxHp: 10000, def: 0 } }); + // act2 m01: (11,5) lies in a DOWN blower's flow, (11,8) does not + const h = makeBattle({ stageId: 'act2autochess_m01', defs: { chess: { t_g: op, t_h: { ...op, chessId: 't_h', baseId: 't_h', charId: 't_h' } } }, + units: [{ chessId: 't_g', row: 11, col: 5, dir: 'DOWN' }, { chessId: 't_h', row: 12, col: 8 }], hooks: ['deploy'], autoFinish: false, timeLimit: 20 }); + h.run(0.5); + const u = h.unit('t_g'), b = h.b; + assert.ok(u.findBuff('terrain:airflow') && u.s.atk > 1000, 'with the flow'); + const seq0 = u.deploySeq; + assert.equal(b.moveRedeploy(u, 12, 8), false, 'a taken tile'); + assert.equal(b.moveRedeploy(u, 30, 8), false, 'outside the field'); + assert.equal(u.deploySeq, seq0); + assert.deepEqual([u.tileR, u.tileC], [11, 5]); + assert.ok(b.moveRedeploy(u, 11, 8)); + assert.equal(deploysOf(h, u).at(-1).move, true); + h.run(0.2); + assert.ok(!u.findBuff('terrain:airflow'), 'the old tile\'s airflow ends'); + close(u.s.atk, 1000, 1e-6); + assert.ok(b.moveRedeploy(u, 11, 5)); + h.run(0.2); + assert.ok(u.findBuff('terrain:airflow') && u.s.atk > 1000, 'the new tile\'s airflow starts'); + clean(h); +});