feedback3 WI: 乌尔比安 S3 【移动】 and 【返回】 are free redeploys, and the return clears his SP
This commit is contained in:
1 parent
d5b945f14b
commit
819a5ad4ea
9 files changed
+444
-18
No files matched your search
+4
-3
@@ -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`),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user