diff --git a/docs/SIM.md b/docs/SIM.md index 53caf011..8b610d65 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -850,7 +850,7 @@ instance) and skip `'counter'` / `'reflect'` damage. When the guard trips, the l | `onFieldBoard(r, c)` | whether a field tile lies on the board of a player of this field — `onOwnBoard` of any of `players` (the players whose units the battle holds; an eliminated or absent teammate is not one): both halves of the two-helper 联防 field and of a Final Assault / Hidden Core pair field, the own half only for a lone 联防 helper and on a solo boss field, never a boss field's hand / 临时整备区 rows; the 突袭 landing takes only such tiles (DESIGN §25.18, §26.1) | | `addDp(playerId, n)`, `retreat(unit, {reason, permanent, dying})`, `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/ops/chess_char_5_05-ulpia.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 | +| `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 — but not for a skill that costs no SP, which the reset re-arms as at every deployment (伊内丝 S3 casts again on a 突袭 landing) — 突袭 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) | | `displace(enemy, {x, y}, tiles)` | the raw mover behind push / pull: along passable tiles (by `motion`: FLY in the rect, else ground-passable), no weight rule; nothing for 失衡免疫 (`noDisplace`: 近地悬浮 incl. 喷气人's 飞行模式, 浮空, the 胄 parts, 守墓石像's statue and flight), 静态刚体 (`def.staticBody`) and leaders (`_displaceable`); unblocks + re-paths | diff --git a/server/sim/battle/deploy.js b/server/sim/battle/deploy.js index 3f991d9d..604b9471 100644 --- a/server/sim/battle/deploy.js +++ b/server/sim/battle/deploy.js @@ -56,7 +56,11 @@ export class BattleDeploy { const sk = u.skill; // "自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位将进入冷却" (PRTS 卫戍协议/帮助; skills.js) if (initial) sk.opReadyAt = this.time + this.flags.startOpCooldown; - if (keepSp && !sk.noSkill && sk.kind !== 'passive' && !sk.active) { + // keepSp (突袭 "保留技力"): the SP / charges before the jump — not for a skill that costs no SP (spCost 0, the + // deploy-timed skills), which reset() just re-armed as at every deployment: the retreat ended it, so its snapshot + // holds 0 charges, and writing that back took the charge 伊内丝 S3's deploy hook casts with (community report: + // 伊内丝激活突袭瞬移以后3技能被吞; those with activateOnDeploy run already, skipped by `!sk.active`) + if (keepSp && !sk.noSkill && sk.kind !== 'passive' && !sk.active && sk.spCost > 0) { sk.charges = Math.max(0, Math.min(sk.maxCharges, Math.floor(fin(keepSp.charges, 0)))); sk.sp = Math.max(0, Math.min(sk.spCost, fin(keepSp.sp, 0))); if (sk.charges >= sk.maxCharges) sk.sp = sk.spCost; diff --git a/test/content/feedback7-ines-raid.test.js b/test/content/feedback7-ines-raid.test.js new file mode 100644 index 00000000..3b48df55 --- /dev/null +++ b/test/content/feedback7-ines-raid.test.js @@ -0,0 +1,69 @@ +// test/content/feedback7-ines-raid.test.js — 伊内丝 S3 独影归途 under 突袭 (community report: 伊内丝激活突袭瞬移以后3技能被吞). +// S3 costs no SP and runs from every deployment but the first (skill_table skchr_ines_3: PASSIVE, spCost 0, "部署后…"; +// her kit casts it in its deploy hook). The 突袭 jump (activity_table bondeffect_raid "保留技力立即再部署") retreats her — +// ending the running S3 — and redeploys her with the SP kept (Battle.redeploy keepSp): that snapshot held 0 charges and +// overwrote the charge reset() gives a free skill at every deployment, so the landing could not recast S3. A skill that +// costs SP still keeps its SP / charges. +// Run: node --test test/content/feedback7-ines-raid.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; + +function field(skillIndex) { + const landings = []; + const h = makeBattle({ + defs: { enemies: { e_walk: enemyRec({ key: 'e_walk', hp: 1e7, speed: 0.5, atk: 0 }) } }, + timeLimit: 20, autoFinish: false, seed: 1, flags: { dpInit: 99, dpPerSec: 0, dpMax: 999 }, + bonds: { raidShip: { count: 2, active: true, tier: 1, layers: 0 } }, + units: [{ uid: 1, chessId: 'chess_char_4_04_a', row: 12, col: 3, dir: 'RIGHT', skillIndex }], + enemies: [{ key: 'e_walk', time: 1.2, routeIndex: 0, count: 1 }], + hooks: ['skillStart', 'skillEnd', 'death'], + setup: (b) => { + // priority 1000: after the keepSp restore, before her kit's deploy hook (which casts S3) + b.on('deploy', ({ unit }) => { + if (unit.uid !== 1 || b.time < 0.5) return; + const sk = unit.skill; + landings.push({ t: b.time, tile: [unit.tileR, unit.tileC], charges: sk.charges, sp: sk.sp, active: sk.active }); + }, { priority: 1000 }); + }, + }); + h.step(); + return { h, landings, u: h.b.allyUnits.find((x) => x.uid === 1) }; +} + +test('伊内丝 S3 starts again on the 突袭 landing: the free charge reset() gave is not overwritten by the kept 0', () => { + const { h, landings, u } = field(2); + assert.equal(u.skill.id, 'skchr_ines_3'); + h.run(4); + assert.ok(h.hooksOf('death').some((c) => c.unit === u && c.reason === 'raid'), 'she jumped'); + assert.equal(landings.length, 1); + assert.notDeepEqual(landings[0].tile, [12, 3], 'landed beside the enemy'); + assert.equal(landings[0].charges, 1, 'armed at the landing'); + const starts = h.hooksOf('skillStart').filter((c) => c.unit === u); + assert.equal(starts.length, 2, 'the second deployment and the landing'); + assert.ok(Math.abs(starts[1].t - landings[0].t) < 1e-9); + assert.equal(u.skill.active, true); + assert.ok(u.skill.timeLeft > 5, `a fresh S3 (${u.skill.timeLeft.toFixed(2)} s left)`); + assert.ok(u.findBuff('ines:s3'), 'its ATK buff'); + checkInvariants(h.b); +}); + +test('突袭 still keeps the SP of a skill that costs SP (伊内丝 S2: a full charge, and a partial bar)', () => { + const full = field(1); + assert.equal(full.u.skill.id, 'skchr_ines_2'); + full.u.skill.gainSp(999, 'test'); + full.h.run(4); + assert.equal(full.landings.length, 1); + assert.equal(full.landings[0].charges, 1); + assert.equal(full.landings[0].sp, full.u.skill.spCost); + checkInvariants(full.h.b); + + const part = field(1); + const initSp = part.u.skill.sp; + part.h.run(12); + assert.equal(part.landings.length, 1, 'the idle trigger'); + assert.equal(part.landings[0].charges, 0); + assert.ok(part.landings[0].sp > initSp + 5, `kept ${part.landings[0].sp} (a bar that started at ${initSp})`); + checkInvariants(part.h.b); +});