diff --git a/docs/SIM.md b/docs/SIM.md index 101e57c0..87998083 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -710,7 +710,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) | @@ -767,7 +767,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) | @@ -1168,7 +1169,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 17cc8c8f..fc232337 100644 --- a/server/sim/Battle.js +++ b/server/sim/Battle.js @@ -1930,6 +1930,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/tier1.js b/server/sim/content/kits/tier1.js index a61fb734..acfb21ef 100644 --- a/server/sim/content/kits/tier1.js +++ b/server/sim/content/kits/tier1.js @@ -241,8 +241,8 @@ export function batMod(v, chess = null, desc = '') { // ================================================================================================================= // tier-local constants that exist nowhere in data -/** Radius (tiles) of 波登可's spore cloud "在周围产生…孢子群" (no blackboard key; ≈ the 3×3 around the vial). */ -const SPORE_RADIUS = 1.2; +/** Radius (tiles) of 波登可's spore cloud. No blackboard key; PRTS 波登可 备注 "孢子群范围半径为0.9,可对空". */ +const SPORE_RADIUS = 0.9; /** "周围8格" = Chebyshev ring 1 ⇒ Euclidean radius covering the 8 neighbours. */ export const RING1 = 1.5; /** 普罗旺斯 杀戮嗅觉 "普通攻击不再以生命值高于80%的敌人作为目标" — the 80 % exists only in the skill text. */ @@ -750,8 +750,8 @@ export default { }, // --------------------------------------------------------------------------------------------------------------- - // 1_13 波登可 孢子扩散: a vial bursts on the current target into a projectile_delay_time s spore cloud: enemies inside - // are 停顿 and lose their abilities (silence) and take atk_scale × ATK arts per second. + // 1_13 波登可 孢子扩散: a vial bursts on the current target into a projectile_delay_time s spore cloud (radius 0.9, + // PRTS 备注, 可对空): enemies inside are 停顿 and lose their abilities (silence) and take atk_scale × ATK arts per second. // 园丁: every 【辅助】 operator on the field ATK +atk. Elite module (DEC-X): +sp_recovery_per_sec SP/s with an enemy in range. // Alternate S1 花香疗法: ATK +atk, normal attacks heal the most injured ally in range instead (ATK per attack). A heal // skill: DEFAULT fires it with an injured ally inside her initial range (research 03 §1.4) — also checked every tick, diff --git a/server/sim/content/kits/tier3.js b/server/sim/content/kits/tier3.js index 0248b628..835ce819 100644 --- a/server/sim/content/kits/tier3.js +++ b/server/sim/content/kits/tier3.js @@ -405,8 +405,10 @@ const KITS = { // closing it spends every coin on random ground enemies of range 2-4 in front and those she blocks (atk_scale phys + // a small push, radial despite the text's 向前 — PRTS 备注 "推开效果为径向推动"; client charpack char_1033_swire2: // the RandomGold ability (Skill_3_End) carries swire2_s_3[knockback] of template knockback[relative]; 地面敌方单位, - // 弹道不可对空). Auto-close (the mode casts everything itself; the player's "主动关闭" is not available): once the - // purse is full (10) and a coin target stands there. 精锐 module MER-Y: ATK +4 % per trait payment (≤ 5 stacks). + // 弹道不可对空). It does not close itself when the purse is full (PRTS 卫戍协议/帮助 「通常不会自动关闭技能」; the skill + // text is 「可随时主动关闭」). Owner 2026-10-04: at the cap (金币上限为10) she shoots once an enemy is in the skill's + // attack range (the 1-tile range, not the coin-mark 2-4 grid). Below the cap, or with nobody there, it stays open. + // 精锐 module MER-Y: ATK +4 % per trait payment (≤ 5 stacks). chess_char_3_04_a: (bb, chess, def) => { const d = defOf(chess, def); const t0 = talentBb(d, 0), t1 = talentBb(d, 1); @@ -493,18 +495,24 @@ const KITS = { skills: altSkills(chess, d, bb, { [S1]: () => ({ kind: 'passive' }), // (coins → heals: installS1) [S3]: (s) => { - const cash = num(s.bb.atk_scale, 1), force = num(s.bb.force, 0), full = num(s.bb.sp, 10); + const cash = num(s.bb.atk_scale, 1), force = num(s.bb.force, 0); // the 【金币标记】 targets: ground enemies on range 2-4 in front of her (range_table "2-4") and every unit she blocks const coinMarks = (battle, unit) => { const list = enemiesOn(battle, unit, gridKeys(SWIRE2_COIN_GRID, unit), 0, { ...unit.profile, canHitFly: false }); for (const e of unit.blocking || []) if (e.alive && !e.isFlying && !list.includes(e)) list.push(e); return list; }; + const purseCap = Math.max(1, Math.floor(num(s.bb.sp, 10))); return { kind: 'toggle', attack: { hits: 2 }, + // Owner 2026-10-04: at the cap, shoot once a ground enemy is in the skill attack range (data rangeGrid, + // the same 1-tile range as her attack). The coin marks (前方 2-4) are who the burst pays, not the trigger. + // Nobody in that range: stay open and keep the coins. Below the cap: stay open. onTick({ battle, unit, skill }) { - if ((unit.mem.coins ?? 0) >= full && coinMarks(battle, unit).length) skill.end('manual'); + if (!skill.active || (unit.mem.coins ?? 0) + 1e-9 < purseCap) return; + const keys = gridKeys(skillGrid ?? unit.rangeGrid, unit); + if (enemiesOn(battle, unit, keys, 0, { ...unit.profile, canHitFly: false }).length) skill.end('manual'); }, onEnd({ battle, unit, reason }) { if (reason !== 'manual' || !unit.alive) return; 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 8bc322b3..72faa1ff 100644 --- a/server/sim/content/tokens.js +++ b/server/sim/content/tokens.js @@ -904,7 +904,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, @@ -917,7 +921,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/content/kits_t1t2.test.js b/test/content/kits_t1t2.test.js index 98fb94dc..d1b3bb8f 100644 --- a/test/content/kits_t1t2.test.js +++ b/test/content/kits_t1t2.test.js @@ -427,13 +427,21 @@ test('1_13 波登可: 孢子扩散 cloud — projectile_delay_time pulses of atk approx(u.s.atk, u.base.atk * (1 + t.atk), 'self is SUPPORT'); approx(goat.s.atk, goat.base.atk * (1 + t.atk), 'other SUPPORT'); const [a, b, far] = h.enemies(); - for (const e of [a, b]) { + // PRTS 备注 "孢子群范围半径为0.9": the vial lands on one enemy; the next tile (centre distance 1) is outside 0.9 and inside the old 1.2 + const zone = h.eventsOf('fx').find((e) => e[1] === 'zone' && e[4]?.skill === 'spores'); + assert.equal(zone[4].radius, 0.9); + let inside = 0; + for (const e of [a, b, far]) { + const d = Math.hypot(e.x - zone[2], e.y - zone[3]); const z = dealt(h, u, (c) => c.target === e && (c.dmg.tags || []).includes('zone')); - assert.equal(z.length, bb.projectile_delay_time, 'one pulse per second'); - for (const c of z) approx(c.amount, u.s.atk * bb.atk_scale); - assert.ok(statuses(h, 'silence', (c) => c.target === e).length > 0 && statuses(h, 'sluggish', (c) => c.target === e).length > 0); + if (d <= 0.9) { + inside++; + assert.equal(z.length, bb.projectile_delay_time, 'one pulse per second'); + for (const c of z) approx(c.amount, u.s.atk * bb.atk_scale); + assert.ok(statuses(h, 'silence', (c) => c.target === e).length > 0 && statuses(h, 'sluggish', (c) => c.target === e).length > 0); + } else assert.equal(z.length, 0, `outside 0.9 (d=${d})`); } - assert.equal(dealt(h, u, (c) => c.target === far && (c.dmg.tags || []).includes('zone')).length, 0); + assert.equal(inside, 1, 'only the enemy the vial landed on'); done(h); // elite module: +sp_recovery_per_sec SP/s while an enemy is in range 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-swire-s3.test.js b/test/sim/feedback3-swire-s3.test.js new file mode 100644 index 00000000..518e21f1 --- /dev/null +++ b/test/sim/feedback3-swire-s3.test.js @@ -0,0 +1,74 @@ +// test/sim/feedback3-swire-s3.test.js — 琳琅诗怀雅 S3 千金一掷. Owner 2026-10-04: at the purse cap of 10 +// she shoots once a ground enemy is in the skill attack range (the 1-tile range). Below the cap, or with +// the enemy only further ahead, the skill stays open and the coins stay. A manual close still spends. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; +import { hasGeneratedData } from '../../server/sim/simdata.js'; + +const REAL = { skip: !hasGeneratedData() }; +const ID = 'chess_char_3_04_a'; +const foe = enemyRec({ key: 'e_sw', hp: 1e9, atk: 0, speed: 0, def: 0, res: 0, mass: 1 }); + +function battle(pos) { + return makeBattle({ + defs: { enemies: { e_sw: foe } }, + units: [{ chessId: ID, row: 10, col: 5, skillIndex: 2 }], + enemies: pos ? [{ key: 'e_sw', pos }] : [], + hooks: ['damaged', 'skillEnd'], autoFinish: false, timeLimit: 30, + }); +} +const arm = (h) => { + h.step(); + const u = h.unit(ID); + assert.equal(u.skill.id, 'skchr_swire2_3'); + assert.ok(u.skill.active || u.skill.activate('test', { free: true })); + return u; +}; + +test('琳琅诗怀雅 S3 shoots at the cap only once an enemy is in the skill attack range', REAL, () => { + const under = battle([10, 6]); + const u9 = arm(under); + u9.mem.coins = 9; + under.run(2); + assert.equal(u9.skill.active, true, '9 coins do not close it'); + assert.equal(u9.mem.coins, 9); + + const far = battle([10, 7]); // two tiles ahead: outside the 1-tile attack range, inside nothing she swings at + const uf = arm(far); + uf.mem.coins = 10; + far.run(0.5); + assert.equal(uf.skill.active, true, 'an enemy outside the attack range does not make her shoot'); + assert.equal(uf.mem.coins, 10); + + const none = battle(null); + const un = arm(none); + un.mem.coins = 10; + none.run(0.5); + assert.equal(un.skill.active, true, 'nobody in range: the purse stays'); + assert.equal(un.mem.coins, 10); + + const h = battle([10, 6]); // one tile ahead: the skill attack range + const u = arm(h); + const e = h.enemies()[0]; + const hp0 = e.hp; + u.mem.coins = 10; + h.run(0.2); + assert.equal(u.skill.active, false, 'the enemy in the attack range is shot'); + assert.equal(u.mem.coins, 0, 'every coin is spent'); + assert.ok(e.hp < hp0, 'the coins hit that enemy'); + assert.equal(h.b.errors.length, 0); + checkInvariants(h.b); +}); + +test('琳琅诗怀雅 S3: a manual close below the cap still spends', REAL, () => { + const h = battle(null); + const u = arm(h); + u.mem.coins = 4; + assert.doesNotThrow(() => u.skill.end('manual')); + assert.equal(u.skill.active, false); + assert.equal(u.mem.coins, 0); + assert.equal(h.hooksOf('damaged').length, 0); + checkInvariants(h.b); +}); 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); +});