feedback2 WK: an operator forced off the field (史尔特尔's 余烬, 耀骑士临光 S2, 骑士戒律 + 竞技旗, 伊内丝 S3, a 商人 that cannot pay) lies down on its tile and redeploys there like a knocked-out one, still no kill (PRTS 卫戍协议/帮助; GitHub #60)
This commit is contained in:
1 parent
68da23e8ed
commit
42d9c3744b
6 files changed
+154
-32
No files matched your search
+9
-5
@@ -86,8 +86,11 @@ timer done, tile free, DP ≥ cost), 不屈's, 阿戈尔's — comes back there
|
|||||||
killed) lies on its own home instead when that is free (else it stays [ASSUMED: one attempt]); x / y / tileR / tileC keep
|
killed) lies on its own home instead when that is free (else it stays [ASSUMED: one attempt]); x / y / tileR / tileC keep
|
||||||
where it fell for the `kill` / `death` handlers. "倒地干员所在地块视为可部署,但所有我方单位在此处的部署
|
where it fell for the `kill` / `death` handlers. "倒地干员所在地块视为可部署,但所有我方单位在此处的部署
|
||||||
行为将被阻止": `downOn(r, c)` — `_deploy` (redeploys, the 突袭 landing, summons), `spawnDevice` and `relocate` refuse that
|
行为将被阻止": `downOn(r, c)` — `_deploy` (redeploys, the 突袭 landing, summons), `spawnDevice` and `relocate` refuse that
|
||||||
tile and `isReservedTile` reports it, so every automatic picker skips it. A withdrawn operator that is not down (a
|
tile and `isReservedTile` reports it, so every automatic picker skips it. The rule covers every 退场 (GitHub #60): an
|
||||||
retreat) comes back on its home tile.
|
operator forced out by its own effects (`retreat` reason `'retreat'`: 史尔特尔's 余烬, 耀骑士临光 S2, 骑士戒律 + 竞技旗,
|
||||||
|
伊内丝 S3; `'merchant'`: a 商人 that cannot pay) lies down and comes back the same way — still no kill (its death reason is
|
||||||
|
not `'killed'`: no 被击倒 effects, 不屈, 阿戈尔 or knock-down count); only the 突袭 retreat (`'raid'`, redeployed at once on
|
||||||
|
its landing tile) and permanent removals leave nothing.
|
||||||
|
|
||||||
### 1.1 Coordinates (PlayerBattleInput units)
|
### 1.1 Coordinates (PlayerBattleInput units)
|
||||||
|
|
||||||
@@ -746,7 +749,7 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged
|
|||||||
| `spawnEnemy(enemyKey, { routeIndex, route, pos, mods, tag, sourcePlayerId, ownerPlayerId, bounty, countInTotal, def })` | returns the enemy, or null past `MAX_ALIVE_ENEMIES`; `def` = inline record (enemies.json shape or normalised) for keys missing from data |
|
| `spawnEnemy(enemyKey, { routeIndex, route, pos, mods, tag, sourcePlayerId, ownerPlayerId, bounty, countInTotal, def })` | returns the enemy, or null past `MAX_ALIVE_ENEMIES`; `def` = inline record (enemies.json shape or normalised) for keys missing from data |
|
||||||
| `spawnDevice(key, row, col, { hp, obstacle, blockCnt, name, def, res, atk, bat, aspd })`, `setObstacle(r, c, on)` | obstacles re-path enemies; spawnDevice returns null outside the rect, on a living unit or on a knocked-out operator's tile |
|
| `spawnDevice(key, row, col, { hp, obstacle, blockCnt, name, def, res, atk, bat, aspd })`, `setObstacle(r, c, on)` | obstacles re-path enemies; spawnDevice returns null outside the rect, on a living unit or on a knocked-out operator's tile |
|
||||||
| `isReservedTile(r, c)` | true when a living unit stands there or it is the rest tile (`restTile`) of an ally piece that has not deployed yet / waits to redeploy — the tile a knocked-out operator lies on, else the home tile — every automatic picker (the 突袭 landing tile, tactical points, summon / device tiles) must skip these (`findTacticalPoint` does) |
|
| `isReservedTile(r, c)` | true when a living unit stands there or it is the rest tile (`restTile`) of an ally piece that has not deployed yet / waits to redeploy — the tile a knocked-out operator lies on, else the home tile — every automatic picker (the 突袭 landing tile, tactical points, summon / device tiles) must skip these (`findTacticalPoint` does) |
|
||||||
| `downOn(r, c, except?)`, `restTile(u)` | the knocked-out operator lying on a tile (no ally deploys / moves there: `_deploy`, `spawnDevice`, `relocate`); the tile a withdrawn ally comes back on — its `body` tile when knocked out, else its home (§1) |
|
| `downOn(r, c, except?)`, `restTile(u)` | the down operator (`isDown`: knocked out or forced out) lying on a tile (no ally deploys / moves there: `_deploy`, `spawnDevice`, `relocate`); the tile a withdrawn ally comes back on — its `body` tile when down, else its home (§1) |
|
||||||
| `addProjectile({ from, target | to:{x,y}, speed, onHit(ctx), visual, source, hitDead })` | homing; fizzles if the target dies unless `hitDead` |
|
| `addProjectile({ from, target | to:{x,y}, speed, onHit(ctx), visual, source, hitDead })` | homing; fizzles if the target dies unless `hitDead` |
|
||||||
| `allySelectable(ally, by)`, `alliesFor(by, ownerId?)` | may an ability of ally `by` select `ally` — never a 孤立 unit (炎佑, 从不混淆的方向) but `by` itself (PRTS 选择器: "若掩码中孤立为1,且选择器的阵营与目标为友好关系,则不可选中"); `allies()` without the 孤立 ones — content picks ally targets (buffs, auras, 全场 talents, heal picks) through these, `alliesInGrid` too |
|
| `allySelectable(ally, by)`, `alliesFor(by, ownerId?)` | may an ability of ally `by` select `ally` — never a 孤立 unit (炎佑, 从不混淆的方向) but `by` itself (PRTS 选择器: "若掩码中孤立为1,且选择器的阵营与目标为友好关系,则不可选中"); `allies()` without the 孤立 ones — content picks ally targets (buffs, auras, 全场 talents, heal picks) through these, `alliesInGrid` too |
|
||||||
| `unitsInGrid(unit, grid, {side, extend})`, `alliesInGrid(unit)`, `enemiesInRadius(x, y, r, centre?)`, `foesInRadius(x, y, r, centre?)`, `alliesInRadius(x, y, r, ownerId?)` | grid offsets are relative to facing RIGHT, rotated by `unit.dir`; enemies by their body (§2 hit areas: a huge enemy on every tile it occupies / within `r` of its rectangle; `centre` = splash around a target, a 中点判定 by position; `foesInRadius` = the enemies an operator's area effect can select — no untargetable enemy, none whose 隐匿 is on (`targeting.js enemyStealthed`: not blocked, not revealed, not within 3 s of its last block), PRTS 作战机制 §AOE伤害判定 — while enemy-side auras / heals and collisions keep `enemiesInRadius`) |
|
| `unitsInGrid(unit, grid, {side, extend})`, `alliesInGrid(unit)`, `enemiesInRadius(x, y, r, centre?)`, `foesInRadius(x, y, r, centre?)`, `alliesInRadius(x, y, r, ownerId?)` | grid offsets are relative to facing RIGHT, rotated by `unit.dir`; enemies by their body (§2 hit areas: a huge enemy on every tile it occupies / within `r` of its rectangle; `centre` = splash around a target, a 中点判定 by position; `foesInRadius` = the enemies an operator's area effect can select — no untargetable enemy, none whose 隐匿 is on (`targeting.js enemyStealthed`: not blocked, not revealed, not within 3 s of its last block), PRTS 作战机制 §AOE伤害判定 — while enemy-side auras / heals and collisions keep `enemiesInRadius`) |
|
||||||
@@ -1144,8 +1147,9 @@ Unknown subprofessions fall back to the profession default (test `professions.te
|
|||||||
|
|
||||||
- `snapshot()` → `{ fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, dps?, boss?, down?, elem? }`.
|
- `snapshot()` → `{ fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, dps?, boss?, down?, elem? }`.
|
||||||
`sp/spMax` show remaining duration/ammo as a draining bar while a timed skill is active. Units in DIE state stay 0.8 s.
|
`sp/spMax` show remaining duration/ammo as a draining bar while a timed skill is active. Units in DIE state stay 0.8 s.
|
||||||
`down: [[id, respawnAt, respawnTime, state, row, col]]` (only when non-empty) = knocked-out operators waiting to redeploy
|
`down: [[id, respawnAt, respawnTime, state, row, col]]` (only when non-empty) = operators lying down waiting to redeploy
|
||||||
(`Battle.isDown(u)`: reason `'killed'` or `FORCED_EXIT` (§1.1 carryState `down`), deployed at least once, a finite
|
(`Battle.isDown(u)`: any removal reason but the 突袭 `'raid'` — `'killed'`, `FORCED_EXIT` (§1.1 carryState `down`), a forced
|
||||||
|
exit `'retreat'` / `'merchant'` (GitHub #60) —, not removed for good, deployed at least once, a finite
|
||||||
respawn timer; `state` = constants.js
|
respawn timer; `state` = constants.js
|
||||||
`DOWN_STATE`: 0 counting, 1 timer done / DP short, 2 timer done / its tile taken — a safeguard: no ally deploys on a
|
`DOWN_STATE`: 0 counting, 1 timer done / DP short, 2 timer done / its tile taken — a safeguard: no ally deploys on a
|
||||||
knocked-out operator's tile, §1; `row, col` = the tile it lies on and comes back on, `unit.body`); `elem: [[id, element, fill,
|
knocked-out operator's tile, §1; `row, col` = the tile it lies on and comes back on, `unit.body`); `elem: [[id, element, fill,
|
||||||
|
|||||||
+27
-18
@@ -10,9 +10,10 @@
|
|||||||
// the players of a shared field side by side), fires `deploy` (initial) for each unit, forces out the operators that
|
// the players of a shared field side by side), fires `deploy` (initial) for each unit, forces out the operators that
|
||||||
// enter already knocked out (`carryState.down`, 联防: constants.js FORCED_EXIT — down on their tile, redeploy timer
|
// enter already knocked out (`carryState.down`, 联防: constants.js FORCED_EXIT — down on their tile, redeploy timer
|
||||||
// running) and then fires `battleStart`.
|
// running) and then fires `battleStart`.
|
||||||
// A knocked-out operator lies on its `body` tile — where it fell, or its own home when it fell on another board piece's
|
// An operator that left the field — knocked out, or forced out by its own effects (史尔特尔's 余烬 …, GitHub #60) — lies
|
||||||
// home — and redeploys there; no ally deploys or moves onto that tile meanwhile (PRTS 卫戍协议/帮助 §作战阶段 单位部署;
|
// on its `body` tile — where it fell, or its own home when it fell on another board piece's home — and redeploys there;
|
||||||
// `_layBody`, `downOn`, `restTile`, `isReservedTile`; docs/SIM.md §1).
|
// no ally deploys or moves onto that tile meanwhile (PRTS 卫戍协议/帮助 §作战阶段 单位部署; `isDown`, `_layBody`, `downOn`,
|
||||||
|
// `restTile`, `isReservedTile`; docs/SIM.md §1).
|
||||||
// Tick order: scheduled callbacks → spawns → DP → buffs → enemies (attack, move, block) → enemy index →
|
// Tick order: scheduled callbacks → spawns → DP → buffs → enemies (attack, move, block) → enemy index →
|
||||||
// allies (skill tick, attack) → projectiles → redeploys → boss sync → `tick` hook → release hooks of removed units →
|
// allies (skill tick, attack) → projectiles → redeploys → boss sync → `tick` hook → release hooks of removed units →
|
||||||
// time += TICK → end checks. A forceEnd() requested mid-step ends the step after the current phase (docs/SIM.md §1.4).
|
// time += TICK → end checks. A forceEnd() requested mid-step ends the step after the current phase (docs/SIM.md §1.4).
|
||||||
@@ -848,8 +849,8 @@ export class Battle {
|
|||||||
// deployment / death / redeploy
|
// deployment / death / redeploy
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deploy `u` on its home tile, or on `tile` ([r, c]: a one-off landing tile — the home stays the board tile: a
|
* Deploy `u` on its home tile, or on `tile` ([r, c]: a one-off landing tile — the home stays the board tile; an
|
||||||
* withdrawn operator comes back there, a knocked-out one on its body tile, restTile). `keepSp` = { sp, charges }
|
* operator that left the field comes back on its body tile, restTile). `keepSp` = { sp, charges }
|
||||||
* restored right after the skill reset, before the `deploy` hook fires.
|
* restored right after the skill reset, before the `deploy` hook fires.
|
||||||
*/
|
*/
|
||||||
_deploy(u, { initial = false, carry = null, tile = null, keepSp = null } = {}) {
|
_deploy(u, { initial = false, carry = null, tile = null, keepSp = null } = {}) {
|
||||||
@@ -917,7 +918,10 @@ export class Battle {
|
|||||||
this._remove(unit, 'killed', killer);
|
this._remove(unit, 'killed', killer);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Withdraw an ally without a kill (it may redeploy after its respawn time). */
|
/**
|
||||||
|
* Withdraw an ally without a kill (it may redeploy after its respawn time): an operator lies down where it stood and
|
||||||
|
* comes back there (isDown, GitHub #60) — unless `permanent`, or the 突袭 retreat ('raid') that redeploys it at once.
|
||||||
|
*/
|
||||||
retreat(unit, { reason = 'retreat', permanent = false } = {}) {
|
retreat(unit, { reason = 'retreat', permanent = false } = {}) {
|
||||||
if (!unit || !unit.alive || unit.side !== 'ally') return;
|
if (!unit || !unit.alive || unit.side !== 'ally') return;
|
||||||
this._remove(unit, reason, null, permanent);
|
this._remove(unit, reason, null, permanent);
|
||||||
@@ -1006,8 +1010,8 @@ export class Battle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Immediately redeploy a dead (or retreated) ally on its rest tile (restTile: where a knocked-out operator lies, else
|
* Immediately redeploy a dead (or retreated) ally on its rest tile (restTile: where an operator that left the field
|
||||||
* its home tile). opts:
|
* lies, else its home tile). opts:
|
||||||
* free=true no DP cost (false: pays `base.cost`, refused when the player lacks the DP)
|
* free=true no DP cost (false: pays `base.cost`, refused when the player lacks the DP)
|
||||||
* tile=[r,c] land on this in-rect tile instead (the home stays the board tile); refused (false) when the tile is
|
* tile=[r,c] land on this in-rect tile instead (the home stays the board tile); refused (false) when the tile is
|
||||||
* outside the rect, a living unit stands there or another knocked-out operator lies there — no fallback
|
* outside the rect, a living unit stands there or another knocked-out operator lies there — no fallback
|
||||||
@@ -1044,9 +1048,9 @@ export class Battle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Automatic redeploys (DESIGN §5.5): a withdrawn operator whose timer is done comes back on its rest tile — a knocked-out
|
* Automatic redeploys (DESIGN §5.5): an operator that left the field and whose timer is done comes back on its rest
|
||||||
* one where it lies ("满足再部署条件时,移除场上的该倒地干员并自动部署至该位置", PRTS 卫戍协议/帮助) — when that tile is
|
* tile — where it lies ("满足再部署条件时,移除场上的该倒地干员并自动部署至该位置", PRTS 卫戍协议/帮助) — when that tile
|
||||||
* free and its player has the DP.
|
* is free and its player has the DP.
|
||||||
*/
|
*/
|
||||||
_checkRedeploys() {
|
_checkRedeploys() {
|
||||||
for (const u of this.allyUnits) {
|
for (const u of this.allyUnits) {
|
||||||
@@ -2057,7 +2061,7 @@ export class Battle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The tile a withdrawn ally comes back on (redeploy, _checkRedeploys): a knocked-out operator's body tile — where it
|
* The tile a withdrawn ally comes back on (redeploy, _checkRedeploys): a down operator's body tile (isDown) — where it
|
||||||
* fell, or its home (_layBody) — else its home tile.
|
* fell, or its home (_layBody) — else its home tile.
|
||||||
*/
|
*/
|
||||||
restTile(u) {
|
restTile(u) {
|
||||||
@@ -2263,7 +2267,7 @@ export class Battle {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact full snapshot of this field (DESIGN §8.2 b.snap), plus (only when non-empty):
|
* Compact full snapshot of this field (DESIGN §8.2 b.snap), plus (only when non-empty):
|
||||||
* down: [[id, respawnAt, respawnTime, state, row, col]] — knocked-out operators waiting to redeploy (isDown): the
|
* down: [[id, respawnAt, respawnTime, state, row, col]] — operators that left the field waiting to redeploy (isDown): the
|
||||||
* game time their respawn timer ends, its length (s), constants.js DOWN_STATE and the tile they lie on (and
|
* game time their respawn timer ends, its length (s), constants.js DOWN_STATE and the tile they lie on (and
|
||||||
* come back on: _layBody — where they fell, or their home);
|
* come back on: _layBody — where they fell, or their home);
|
||||||
* elem: [[id, element, fill, cooldownEnd, cooldown]] — the element gauge each unit shows (damage.js elementView).
|
* elem: [[id, element, fill, cooldownEnd, cooldown]] — the element gauge each unit shows (damage.js elementView).
|
||||||
@@ -2300,13 +2304,18 @@ export class Battle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A knocked-out operator waiting to redeploy on the tile it lies on (DESIGN §5.5: after its respawn time, when the
|
* An operator lying on the field, waiting to redeploy on that tile (DESIGN §5.5: after its respawn time, when the tile
|
||||||
* tile is free and DP ≥ cost; _layBody, downOn): killed — or entering the battle knocked out (FORCED_EXIT, 联防) —
|
* is free and DP ≥ cost; _layBody, downOn). PRTS 卫戍协议/帮助 §作战阶段 单位部署: "干员退场后,将返回隐藏的待部署区,并原地
|
||||||
* not withdrawn, not removed for good, after it was deployed. The client keeps its model on that tile knocked down
|
* 留下一个“倒地干员”以供查看信息,满足再部署条件时,移除场上的该倒地干员并自动部署至该位置。" — every 退场 (GitHub #60, the owner's
|
||||||
* with a redeploy countdown (b.snap `down`, render/units.js); summons, devices and enemies simply leave.
|
* decision of 2026-10-04): knocked out ('killed'), entering the battle knocked out (FORCED_EXIT, 联防) and forced out
|
||||||
|
* by its own effects ('retreat': 史尔特尔's 余烬, 耀骑士临光 S2, 骑士戒律 + 竞技旗, 伊内丝 S3; 'merchant': a 商人 that cannot
|
||||||
|
* pay) — except the 突袭 retreat ('raid', redeployed at once on its landing tile); not removed for good, after it was
|
||||||
|
* deployed. A forced exit stays no kill: its 'die' / `death` reason is not 'killed' (no 被击倒 effect, 不屈, 阿戈尔, no
|
||||||
|
* knock-down count). The client keeps its model on that tile knocked down with a redeploy countdown (b.snap `down`,
|
||||||
|
* render/units.js); summons, devices and enemies simply leave.
|
||||||
*/
|
*/
|
||||||
isDown(u) {
|
isDown(u) {
|
||||||
return !!u && u.side === 'ally' && u.kind === 'op' && !u.alive && !u.removed && (u.removeReason === 'killed' || u.removeReason === FORCED_EXIT)
|
return !!u && u.side === 'ally' && u.kind === 'op' && !u.alive && !u.removed && u.removeReason !== 'raid'
|
||||||
&& u.deploySeq > 0 && Number.isFinite(u.respawnAt);
|
&& u.deploySeq > 0 && Number.isFinite(u.respawnAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1117,7 +1117,9 @@ const KITS = {
|
|||||||
// while 余烬 is unused). "触发本天赋后,获得禁疗与不死": 不死 = every later lethal blow is prevented (`mem.ember`);
|
// while 余烬 is unused). "触发本天赋后,获得禁疗与不死": 不死 = every later lethal blow is prevented (`mem.ember`);
|
||||||
// 禁疗 (异常效果 HEAL_FREE "无法成为治疗类能力的目标,且受到的治疗量变为0", an HP-regen attribute excepted) = flags
|
// 禁疗 (异常效果 HEAL_FREE "无法成为治疗类能力的目标,且受到的治疗量变为0", an HP-regen attribute excepted) = flags
|
||||||
// noHeal (no heal pick, no heal from others) + healFree (her own heals too; S3's start heal "无视禁疗"), shown as
|
// noHeal (no heal pick, no heal from others) + healFree (her own heals too; S3's start heal "无视禁疗"), shown as
|
||||||
// the status 'healFree' until she leaves. "强制退出战场视为撤回干员": a retreat (Battle.retreat drops the buff).
|
// the status 'healFree' until she leaves. "强制退出战场视为撤回干员": a retreat (Battle.retreat drops the buff) — she
|
||||||
|
// lies down where she stood and redeploys there, like every operator that leaves the field (PRTS 卫戍协议/帮助
|
||||||
|
// "干员退场后…原地留下一个“倒地干员”…自动部署至该位置"; Battle.isDown, GitHub #60).
|
||||||
const wait = num(t1['surtr_t_2[withdraw].interval'], 8);
|
const wait = num(t1['surtr_t_2[withdraw].interval'], 8);
|
||||||
battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.ember = false; }, { owner: unit });
|
battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.ember = false; }, { owner: unit });
|
||||||
battle.on('fatal', (c) => {
|
battle.on('fatal', (c) => {
|
||||||
|
|||||||
@@ -50,23 +50,25 @@ test('a knocked-out operator is `down` until it redeploys: its timer, then waiti
|
|||||||
assert.ok(h.eventsOf('deploy').some((e) => e[1] === a.id));
|
assert.ok(h.eventsOf('deploy').some((e) => e[1] === a.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('summons, devices, enemies and withdrawn / removed operators are never `down`', () => {
|
test('summons, devices, enemies, operators removed for good and the 突袭 retreat are never `down`; an operator forced out is (GitHub #60)', () => {
|
||||||
const h = makeBattle({
|
const h = makeBattle({
|
||||||
defs: { chess: { t_a: op('t_a'), t_b: op('t_b'), t_c: op('t_c') }, enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 100, speed: 0 }) } },
|
defs: { chess: { t_a: op('t_a'), t_b: op('t_b'), t_c: op('t_c'), t_d: op('t_d') }, enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 100, speed: 0 }) } },
|
||||||
units: [{ chessId: 't_a', row: 9, col: 5 }, { chessId: 't_b', row: 10, col: 5 }, { chessId: 't_c', row: 11, col: 5 }],
|
units: [{ chessId: 't_a', row: 9, col: 5 }, { chessId: 't_b', row: 10, col: 5 }, { chessId: 't_c', row: 11, col: 5 }, { chessId: 't_d', row: 9, col: 6 }],
|
||||||
enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], content: 'none', autoFinish: false, timeLimit: 120,
|
enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], content: 'none', autoFinish: false, timeLimit: 120,
|
||||||
});
|
});
|
||||||
h.step();
|
h.step();
|
||||||
const tok = h.b.spawnToken('p1', 'token_test', 12, 5, { def: TOKEN });
|
const tok = h.b.spawnToken('p1', 'token_test', 12, 5, { def: TOKEN });
|
||||||
h.b.kill(tok);
|
h.b.kill(tok);
|
||||||
h.b.retreat(h.unit('t_a'), { reason: 'retreat' });
|
|
||||||
h.b.retreat(h.unit('t_b'), { reason: 'expired', permanent: true });
|
h.b.retreat(h.unit('t_b'), { reason: 'expired', permanent: true });
|
||||||
|
h.b.retreat(h.unit('t_d'), { reason: 'raid' }); // 突袭: redeployed at once on its landing tile (here: nowhere)
|
||||||
h.b.kill(h.enemy('enemy_dummy'));
|
h.b.kill(h.enemy('enemy_dummy'));
|
||||||
assert.equal(h.snapshot().down, undefined);
|
assert.equal(h.snapshot().down, undefined);
|
||||||
|
// PRTS 卫戍协议/帮助 "干员退场后…原地留下一个“倒地干员”…": a forced exit (史尔特尔's 余烬 …) lies down like a knock-out
|
||||||
|
h.b.retreat(h.unit('t_a'), { reason: 'retreat' });
|
||||||
h.b.kill(h.unit('t_c'));
|
h.b.kill(h.unit('t_c'));
|
||||||
assert.deepEqual(h.snapshot().down.map((d) => d[0]), [h.unit('t_c').id], 'only the knocked-out operator');
|
assert.deepEqual(h.snapshot().down.map((d) => d[0]), [h.unit('t_a').id, h.unit('t_c').id], 'the forced-out and the knocked-out operator');
|
||||||
const ids = new Set(h.b.fieldMeta().units.map((u) => u.id));
|
const ids = new Set(h.b.fieldMeta().units.map((u) => u.id));
|
||||||
assert.ok(ids.has(h.unit('t_c').id) && !ids.has(h.unit('t_a').id) && !ids.has(tok.id));
|
assert.ok(ids.has(h.unit('t_c').id) && ids.has(h.unit('t_a').id) && !ids.has(h.unit('t_b').id) && !ids.has(h.unit('t_d').id) && !ids.has(tok.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('carryState.down (联防): deployed with everyone, forced out at once — down on its tile without the knock-out hooks — then back after its timer', () => {
|
test('carryState.down (联防): deployed with everyone, forced out at once — down on its tile without the knock-out hooks — then back after its timer', () => {
|
||||||
|
|||||||
@@ -392,7 +392,8 @@ test('redeploy: { tile } lands on another tile (home unchanged; knocked out ther
|
|||||||
approx(atDeploy, sp, 1e-9, 'SP restored before the deploy hook');
|
approx(atDeploy, sp, 1e-9, 'SP restored before the deploy hook');
|
||||||
approx(u.hp, u.s.maxHp);
|
approx(u.hp, u.s.maxHp);
|
||||||
// plain redeploy: initSp; knocked out there later → the auto-redeploy comes back on that tile (PRTS 卫戍协议/帮助
|
// plain redeploy: initSp; knocked out there later → the auto-redeploy comes back on that tile (PRTS 卫戍协议/帮助
|
||||||
// "…原地留下一个“倒地干员”…满足再部署条件时…自动部署至该位置"; player report F5 after 0.1.0); a retreat → home
|
// "…原地留下一个“倒地干员”…满足再部署条件时…自动部署至该位置"; player report F5 after 0.1.0); a retreat too (every 退场:
|
||||||
|
// GitHub #60)
|
||||||
h.b.retreat(u);
|
h.b.retreat(u);
|
||||||
h.b.redeploy(u, { tile: [12, 8] });
|
h.b.redeploy(u, { tile: [12, 8] });
|
||||||
approx(u.skill.sp, 2, 1e-9, 'without keepSp: initial SP');
|
approx(u.skill.sp, 2, 1e-9, 'without keepSp: initial SP');
|
||||||
@@ -402,8 +403,9 @@ test('redeploy: { tile } lands on another tile (home unchanged; knocked out ther
|
|||||||
assert.ok(h.runUntil(() => u.alive, u.base.respawnTime + 1));
|
assert.ok(h.runUntil(() => u.alive, u.base.respawnTime + 1));
|
||||||
assert.deepEqual([u.tileR, u.tileC], [12, 8], 'auto-redeploy on the tile it lay on');
|
assert.deepEqual([u.tileR, u.tileC], [12, 8], 'auto-redeploy on the tile it lay on');
|
||||||
h.b.retreat(u);
|
h.b.retreat(u);
|
||||||
|
assert.deepEqual(h.b.snapshot().down?.map((d) => d.slice(4)), [[12, 8]], 'a retreated operator lies where it stood');
|
||||||
assert.equal(h.b.redeploy(u), true);
|
assert.equal(h.b.redeploy(u), true);
|
||||||
assert.deepEqual([u.tileR, u.tileC], [10, 4], 'a retreated unit comes back on its home tile');
|
assert.deepEqual([u.tileR, u.tileC], [12, 8], 'and comes back there, not on its home tile (GitHub #60)');
|
||||||
// paid redeploy on a tile
|
// paid redeploy on a tile
|
||||||
h.b.retreat(u);
|
h.b.retreat(u);
|
||||||
const dp = p.dp;
|
const dp = p.dp;
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// test/sim/feedback2-forced-exit-down.test.js — GitHub #60 「42突袭死后会消失」 (42 = 史尔特尔): every operator that leaves
|
||||||
|
// the field lies down on its tile and redeploys there — PRTS 卫戍协议/帮助 §作战阶段 单位部署: "干员退场后,将返回隐藏的待部署区,
|
||||||
|
// 并原地留下一个“倒地干员”以供查看信息,满足再部署条件时,移除场上的该倒地干员并自动部署至该位置。" (the owner's decision of
|
||||||
|
// 2026-10-04). A forced exit — 史尔特尔's 余烬 (8 s after the lethal blow), 耀骑士临光 S2's withdrawal, the 骑士戒律 + 竞技旗
|
||||||
|
// combo, 伊内丝 S3's first deployment, a 商人 that cannot pay — used to leave no body and no ring and to come back on the
|
||||||
|
// board tile (Battle.isDown took only 'killed' / FORCED_EXIT); it still is no kill. The 突袭 retreat ('raid') redeploys at
|
||||||
|
// once and leaves nothing; a real knock-out is unchanged.
|
||||||
|
|
||||||
|
import { test } 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 { makeBattle, enemyRec } from '../helpers/battleHarness.js';
|
||||||
|
|
||||||
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
|
const chess = JSON.parse(readFileSync(path.join(ROOT, 'data/chess.json'), 'utf8'));
|
||||||
|
const SURTR = 'chess_char_5_07_a', NEARL2 = 'chess_char_6_17_a', INSIDE = 'chess_char_1_01_a';
|
||||||
|
const DUMMY = { enemy_far: enemyRec({ key: 'enemy_far', hp: 1e9, speed: 0, atk: 0 }) };
|
||||||
|
const approx = (a, b, msg, eps = 0.05) => assert.ok(Math.abs(a - b) <= eps, `${msg}: ${a} vs ${b}`);
|
||||||
|
const RAID = { raidShip: { count: 2, active: true, tier: 1, layers: 10 } };
|
||||||
|
|
||||||
|
function field(units, bonds = {}) {
|
||||||
|
return makeBattle({ defs: { enemies: DUMMY }, units, bonds, enemies: [{ key: 'enemy_far', pos: [12, 8] }], autoFinish: false, timeLimit: 300, hooks: ['death', 'kill', 'deploy'] });
|
||||||
|
}
|
||||||
|
const downOf = (h, u) => (h.b.snapshot().down || []).find((d) => d[0] === u.id) ?? null;
|
||||||
|
|
||||||
|
/** Lethal blow on 史尔特尔: 余烬 holds her at 1 HP, then forces her out 8 s later. */
|
||||||
|
function ember(h, u) {
|
||||||
|
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
|
||||||
|
assert.ok(u.alive && u.hp >= 1, '余烬 holds her at 1 HP');
|
||||||
|
assert.ok(h.runUntil(() => !u.alive, 10), 'forced out');
|
||||||
|
assert.equal(u.removeReason, 'retreat');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('史尔特尔 after 余烬 lies down on her tile (b.snap down, field meta) and redeploys there after 70 s — no kill', () => {
|
||||||
|
const h = field([{ chessId: SURTR, row: 10, col: 3, dir: 'RIGHT' }]);
|
||||||
|
const u = h.unit(SURTR);
|
||||||
|
h.run(1);
|
||||||
|
ember(h, u);
|
||||||
|
assert.ok(h.b.isDown(u));
|
||||||
|
const d = downOf(h, u);
|
||||||
|
assert.ok(d, 'the 倒地干员 and its redeploy ring');
|
||||||
|
assert.deepEqual(d.slice(4), [10, 3]);
|
||||||
|
approx(d[2], 70, 'her redeploy time');
|
||||||
|
assert.ok(h.b.fieldMeta().units.some((x) => x.id === u.id), 'a client joining late draws her down');
|
||||||
|
assert.equal(h.hooksOf('kill').filter((c) => c.victim === u).length, 0, 'no knock-out hooks');
|
||||||
|
assert.deepEqual(h.hooksOf('death').filter((c) => c.unit === u).map((c) => c.reason), ['retreat']);
|
||||||
|
assert.equal(h.b.result().perPlayer.p1.deaths, 0, 'no knock-down counted');
|
||||||
|
h.b.getPlayer(u.ownerId).dp = 99;
|
||||||
|
const out = u.deathAt;
|
||||||
|
assert.ok(h.runUntil(() => u.alive, 75));
|
||||||
|
approx(h.b.time - out, 70, 'back after her redeploy time', 0.1);
|
||||||
|
assert.deepEqual([u.tileR, u.tileC], [10, 3]);
|
||||||
|
assert.equal(downOf(h, u), null);
|
||||||
|
h.invariants();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('史尔特尔 with 突袭: forced out after her jump she lies on the landing tile and comes back there, not on her board tile', () => {
|
||||||
|
const h = field([{ chessId: SURTR, row: 10, col: 3, dir: 'RIGHT' }], RAID);
|
||||||
|
const u = h.unit(SURTR);
|
||||||
|
assert.ok(h.runUntil(() => u.tileR !== u.homeR || u.tileC !== u.homeC, 30), 'jumped');
|
||||||
|
const at = [u.tileR, u.tileC];
|
||||||
|
ember(h, u);
|
||||||
|
assert.deepEqual(downOf(h, u)?.slice(4), at, 'down where she stood');
|
||||||
|
assert.ok(h.b.isReservedTile(at[0], at[1]), 'no ally lands on her body');
|
||||||
|
h.b.getPlayer(u.ownerId).dp = 99;
|
||||||
|
const n = h.hooksOf('deploy').filter((c) => c.unit === u).length;
|
||||||
|
assert.ok(h.runUntil(() => u.alive, 75));
|
||||||
|
assert.equal(h.hooksOf('deploy').filter((c) => c.unit === u).length, n + 1);
|
||||||
|
assert.deepEqual([u.tileR, u.tileC], at, 'redeployed on that tile (it used to be her board tile)');
|
||||||
|
assert.deepEqual([u.homeR, u.homeC], [10, 3], 'her home is unchanged');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('耀骑士临光 S2: withdrawn at the skill end, she lies down on her tile with the ×1.25 redeploy ring and comes back there', () => {
|
||||||
|
const idx = chess[NEARL2].skills.findIndex((s) => (s.skillId ?? s.id) === 'skchr_nearl2_2');
|
||||||
|
assert.ok(idx >= 0);
|
||||||
|
const h = field([{ chessId: NEARL2, row: 10, col: 4, skillIndex: idx }]);
|
||||||
|
const u = h.unit(NEARL2);
|
||||||
|
h.step();
|
||||||
|
assert.ok(u.alive && u.findBuff('nearl2:night'), 'S2 on deploy');
|
||||||
|
assert.ok(h.runUntil(() => !u.alive, 40), 'withdraws when the skill ends');
|
||||||
|
assert.equal(u.removeReason, 'retreat');
|
||||||
|
const d = downOf(h, u);
|
||||||
|
assert.deepEqual(d?.slice(4), [10, 4]);
|
||||||
|
approx(d[2], u.base.respawnTime * 1.25, 'this redeploy ×1.25', 0.02);
|
||||||
|
assert.equal(h.hooksOf('kill').filter((c) => c.victim === u).length, 0);
|
||||||
|
h.b.getPlayer(u.ownerId).dp = 99;
|
||||||
|
assert.ok(h.runUntil(() => u.alive, d[2] + 2));
|
||||||
|
assert.deepEqual([u.tileR, u.tileC], [10, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a real knock-out is unchanged: down on its tile, the kill hooks and the knock-down count', () => {
|
||||||
|
const h = field([{ chessId: INSIDE, row: 10, col: 3, dir: 'RIGHT' }]);
|
||||||
|
const u = h.unit(INSIDE);
|
||||||
|
h.run(1);
|
||||||
|
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
|
||||||
|
assert.ok(!u.alive);
|
||||||
|
assert.equal(u.removeReason, 'killed');
|
||||||
|
assert.deepEqual(downOf(h, u)?.slice(4), [10, 3]);
|
||||||
|
assert.equal(h.hooksOf('kill').filter((c) => c.victim === u).length, 1);
|
||||||
|
assert.equal(h.b.result().perPlayer.p1.deaths, 1);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user