feedback1f WV: a knocked-out operator keeps its tile; 突袭 never lands on it

Player report F5 after 0.1.0: "突袭盟约的干员会移动到濒死干员的尸体上".

Real. A knocked-out operator left the occupancy map at once and only its
home tile stayed reserved. A 突袭 member that fell after its jump lay on the
landing tile, which nothing reserved, so the next member's jump (same target,
same scoring) landed on it; and a member's timed redeploy came back on its home
where a teammate that had jumped there lay. Real matches (six real 突袭
members, 战场#07 / #08, R6 / R9 / R12, 96 battles): 6 placements on a body
(3 jumps, 3 home redeploys) before, 0 of 526 jumps after.

Official (PRTS 卫戍协议/帮助, 作战阶段 / 单位部署):
- "干员退场后…原地留下一个“倒地干员”…满足再部署条件时,移除场上的该倒地干员
  并自动部署至该位置"
- "倒地干员所在地块视为可部署,但所有我方单位在此处的部署行为将被阻止"
- "若干员被击倒的位置为其他干员或召唤物的初始位置,则在被击倒后,尝试返回其
  自身的初始位置"

Now (sim/Battle.js):
- A knocked-out operator lies on `unit.body`: the tile it fell on, or its
  own home when it fell on another board piece's home and its home is free
  (one attempt at the knock-out, assumed). x / y / tileR / tileC keep where
  it fell, so the kill / death handlers still act there.
- Every redeploy comes back there (restTile): the timed one, 不屈's and
  阿戈尔's. A retreated operator still comes back on its home.
- downOn(r, c): _deploy (redeploys, the 突袭 landing, summons), spawnDevice
  and relocate refuse the tile for every ally of the field; isReservedTile
  reports it, so every automatic picker skips it (突袭 raidTile, tactical
  points, summon / device pickers, 维娜's lions). The kit helpers that kept
  their own home-tile loop (tier1 summonTileFree, tier3 / tier4 freeTile) go
  through isReservedTile.
- b.snap `down` entries gain the body tile: [id, respawnAt, respawnTime,
  state, row, col]. DOWN_STATE.WAIT_TILE becomes a safeguard only.

Tests: test/sim/feedback1f-downed-tile.test.js (real Match / PlayerState /
BattleSpec runs with real 突袭 chess, the 突袭 jump, the engine refusals,
the return home); downelem and bonds_addon follow the official rule.
This commit is contained in:
sganggs committed 2026-10-03 07:16:24 +08:00
1 parent 60ed4fdab9
commit 5a3d562b75
12 files changed
+374 -71

No files matched your search

+4 -8
View File
@@ -181,16 +181,12 @@ export function makeZone(battle, caster, { x, y, radius, duration, interval = 1,
}
/**
* A tile a summon may take: inside the field, nobody on it, and not the home tile of a board unit that has not
* deployed yet / waits to redeploy (a summon there would stop that operator from redeploying until it leaves).
* A tile a summon may take: inside the field and not reserved (Battle.isReservedTile: nobody on it, no knocked-out
* operator lying there, not the home tile of a board unit that has not deployed yet / waits to redeploy — a summon
* there would stop that operator from redeploying until it leaves).
*/
export function summonTileFree(battle, r, c) {
if (!Number.isInteger(r) || !Number.isInteger(c) || !battle.grid.inRect(r, c) || battle.unitAt(r, c)) return false;
for (const u of battle.allyUnits) {
if (u.alive || u.removed || u.kind === 'device') continue;
if (u.homeR === r && u.homeC === c) return false;
}
return true;
return Number.isInteger(r) && Number.isInteger(c) && battle.grid.inRect(r, c) && !battle.isReservedTile(r, c);
}
/** First free tile around `unit` (Chebyshev ring 1, front first — offsets rotated by its direction) where `ok(r, c)` holds. */
+4 -6
View File
@@ -125,14 +125,12 @@ function textNum(text, re, fallback) {
return /^\d+(\.\d+)?$/.test(m[1]) ? +m[1] : (CN_NUM[m[1]] ?? fallback);
}
/**
* A tile a summon may take: inside the field, nobody on it, and not the home tile of an ally that has not deployed
* yet / waits to redeploy (the initial deployment runs top→bottom: a summon placed while it runs must not steal a
* later board unit's tile — that unit would never deploy; a dead operator must be able to come back).
* A tile a summon may take: inside the field and not reserved (Battle.isReservedTile: nobody on it, no knocked-out
* operator lying there, not the home tile of an ally that has not deployed yet / waits to redeploy — the initial
* deployment runs top→bottom: a summon placed while it runs must not steal a later board unit's tile).
*/
function freeTile(battle, r, c) {
if (!Number.isInteger(r) || !Number.isInteger(c) || !battle.grid.inRect(r, c) || battle.unitAt(r, c)) return false;
for (const u of battle.allyUnits) if (!u.alive && !u.removed && u.kind !== 'device' && u.homeR === r && u.homeC === c) return false;
return true;
return Number.isInteger(r) && Number.isInteger(c) && battle.grid.inRect(r, c) && !battle.isReservedTile(r, c);
}
/** Walkable ground tile a melee summon can stand on. */
const groundTile = (battle, r, c) => battle.grid.groundPassable(r, c) && battle.grid.canStand(r, c);
+5 -2
View File
@@ -119,10 +119,13 @@ const reveal = (battle, enemies) => { for (const e of enemies) if (e.s.flags.ste
function shove(battle, e, from, force) {
return e && e.alive ? battle.push(e, num(force, 0), { from }) : 0;
}
/** Free tile for a summon/device: in the rect, standable, empty and not the home tile of any ally (dead ones redeploy there). */
/**
* Free tile for a summon/device: in the rect, standable, not reserved (Battle.isReservedTile: empty, no knocked-out
* operator lying there) and not the home tile of any ally (dead ones redeploy there).
*/
function freeTile(battle, r, c, { ranged = false, ground = false } = {}) {
if (!Number.isInteger(r) || !Number.isInteger(c) || !battle.grid.inRect(r, c)) return false;
if (battle.unitAt(r, c)) return false;
if (battle.isReservedTile(r, c)) return false;
if (!battle.grid.canStand(r, c, { ranged })) return false;
if (ground && !battle.grid.groundPassable(r, c)) return false;
for (const u of battle.allyUnits) if (!u.removed && u.homeR === r && u.homeC === c && u.kind !== 'device') return false;