feedback3: merge WI — 乌尔比安 S3 move and return are free redeploys, 波登可's spore radius is 0.9, 琳琅诗怀雅 S3 shoots at 10 coins when an enemy is in her attack range

This commit is contained in:
sganggs committed 2026-10-04 21:25:44 +08:00
commit a3ebca0ddf
13 files changed
+547 -31

No files matched your search

+4 -3
View File
@@ -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`),
+8 -3
View File
@@ -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;
+3
View File
@@ -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';
+26
View File
@@ -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).
+9 -7
View File
@@ -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 });
}
+4 -4
View File
@@ -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,
+12 -4
View File
@@ -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;
+10 -3
View File
@@ -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 });
}
},
},
+6 -2
View File
@@ -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) => {
+13 -5
View File
@@ -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
+76
View File
@@ -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');
});
+74
View File
@@ -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);
});
+302
View File
@@ -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);
});