From 2dcead0c9b45fe96dc2382309121866527270e57 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 07:45:58 +0800 Subject: [PATCH] feedback1f WV: down state agrees with the redeploy check; comments and research 01 follow the body-tile rule --- docs/research/01-core-rules.md | 2 +- server/sim/Battle.js | 12 +++++++----- server/sim/constants.js | 6 +++--- test/sim/feedback1f-downed-tile.test.js | 25 +++++++++++++++++++++++++ 4 files changed, 36 insertions(+), 9 deletions(-) diff --git a/docs/research/01-core-rules.md b/docs/research/01-core-rules.md index dd8518cf..acfce466 100644 --- a/docs/research/01-core-rules.md +++ b/docs/research/01-core-rules.md @@ -846,7 +846,7 @@ Local copies are in `scratchpad/gd/extra/critic/` (`bw_盟约.txt`, `bw_sweep.tx | 2 | BAND_CHECK "3 random + reroll" [ASSUMED] | **All unlocked bands are shown.** In co-op, players pick **one at a time in a random order** shown on the left ("以随机顺序进行策略轮选"). Each co-op player may **skip once**. The step lasts 50 s; on timeout the band is 华法琳. **Duplicates are allowed:** the official illustration shows 2 teammates' picks still un-greyed in the grid. [ASSUMED] 12 s per turn inside the 50 s step. | VERIFIED except the per-turn timer | | 2 | banned bonds "1 core + 1 add-on" [ASSUMED] | Replaced by the verified rule in **A2**. | VERIFIED | | 1, 12 | one ×0.7/×0.8 multiplier per mode; `aceffect_enemy_*` mapping [ASSUMED] | Replaced by the **per-round table in A3**. | VERIFIED (PRTS, user-sourced) | -| 4.3 | "automatic, paying DP" | At battle start **all board units deploy for free**, one by one in order: 下半 top→bottom, then left→right; right-side players in the boss round are mirrored.
**DP starts at 10, +1/s, cap 99.** These are the round-level `options`. `initialCost` 0 in `m0X` is the terrain file, not the round level.
DP is only spent on **automatic redeploy**: the unit returns to its original tile when its respawn timer is done, the tile is free, and DP ≥ `cost`.
Deploy effects do not fire during prep placement. Battle speed is forced to **2×**. | VERIFIED | +| 4.3 | "automatic, paying DP" | At battle start **all board units deploy for free**, one by one in order: 下半 top→bottom, then left→right; right-side players in the boss round are mirrored.
**DP starts at 10, +1/s, cap 99.** These are the round-level `options`. `initialCost` 0 in `m0X` is the terrain file, not the round level.
DP is only spent on **automatic redeploy**: the unit returns to its original tile when its respawn timer is done, the tile is free, and DP ≥ `cost`.
Precisely (PRTS 卫戍协议/帮助 §作战阶段 单位部署, checked after player report F5): the unit comes back on the tile where its 倒地干员 lies ("原地留下一个“倒地干员”…满足再部署条件时,移除场上的该倒地干员并自动部署至该位置"). That is its original tile unless it was moved off it (a 突袭 jump, 乌尔比安's anchor). No ally may deploy on that tile ("倒地干员所在地块视为可部署,但所有我方单位在此处的部署行为将被阻止"). One knocked out on another piece's initial position goes back to its own ("若干员被击倒的位置为其他干员或召唤物的初始位置,则在被击倒后,尝试返回其自身的初始位置").
Deploy effects do not fire during prep placement. Battle speed is forced to **2×**. | VERIFIED | | 4.3 | leaked/timeout | When `maxPlayTime` ends, the battle stops. Every enemy not killed (still alive, or reached the goal) is "未击倒" and feeds 联防/LP. [ASSUMED] `maxPlayTime` is **game-time** seconds, so the client at 2× shows it ticking twice as fast. Last spawns land at 15/38/73 s against limits of 45/55/115 s, which leaves the time needed to kill walkers. | VERIFIED + ASSUMED unit | | 3 | solo prep 300 s | 下半 solo has **no timer** on prep or 机变 ("休整期及机变阶段没有时间限制…24小时内随时返回"). Treat `normalPhaseTime` 300 and `specialPhaseTime` 150 as an optional "timed solo" setting. | VERIFIED | | 4.4 | 联防 partner rule "fewest leaks" | Meaningless, because helpers are perfect by definition. There are **at most 2 helpers**: "所有防卫成功的玩家(最多2名)". Default choice: highest LP, then seat index [ASSUMED]. 1 helper uses `escaped_single` (8 units); 2 helpers use `escaped_multi` (20). | VERIFIED cap | diff --git a/server/sim/Battle.js b/server/sim/Battle.js index 9a1186ce..4d91b15d 100644 --- a/server/sim/Battle.js +++ b/server/sim/Battle.js @@ -843,8 +843,9 @@ export class Battle { // deployment / death / redeploy /** - * Deploy `u` on its home tile, or on `tile` ([r, c]: a one-off landing tile — the home stays the tile of later - * redeploys). `keepSp` = { sp, charges } restored right after the skill reset, before the `deploy` hook fires. + * Deploy `u` on its home tile, or on `tile` ([r, c]: a one-off landing tile — the home stays the board tile: a + * withdrawn operator comes back there, a knocked-out one on its body tile, restTile). `keepSp` = { sp, charges } + * restored right after the skill reset, before the `deploy` hook fires. */ _deploy(u, { initial = false, carry = null, tile = null, keepSp = null } = {}) { if ((u.alive && u.deployed) || u._removing) return false; @@ -2217,14 +2218,15 @@ export class Battle { } /** - * constants.js DOWN_STATE of a down operator: its timer runs, or it waits for its tile / the DP (_checkRedeploys). No - * ally may take the tile it lies on (downOn), so WAIT_TILE is a safeguard only. + * constants.js DOWN_STATE of a down operator: its timer runs, or it waits for its tile / the DP — the same checks as + * _checkRedeploys (a living unit or another body on its rest tile). No ally may take the tile it lies on (downOn), so + * WAIT_TILE is a safeguard only. */ _downState(u) { if (this.time + 1e-9 < u.respawnAt) return DOWN_STATE.COUNTING; const [r, c] = this.restTile(u); const occ = this._occ[r * COLS + c]; - if (occ && occ.alive && occ !== u) return DOWN_STATE.WAIT_TILE; + if ((occ && occ.alive && occ !== u) || this.downOn(r, c, u)) return DOWN_STATE.WAIT_TILE; const ps = this.getPlayer(u.ownerId); return !ps || ps.dp + 1e-9 < u.base.cost ? DOWN_STATE.WAIT_DP : DOWN_STATE.COUNTING; } diff --git a/server/sim/constants.js b/server/sim/constants.js index 8103d7d7..de340dda 100644 --- a/server/sim/constants.js +++ b/server/sim/constants.js @@ -139,9 +139,9 @@ export const DP_DEFAULTS = Object.freeze({ dpInit: 10, dpPerSec: 1, dpMax: 99 }) */ export const AUTO_OP_COOLDOWN = 3; /** - * State of a knocked-out operator waiting to redeploy on its own tile (b.snap `down` entries, Battle.snapshot): its - * respawn timer runs (COUNTING), then it waits for the player's DP to reach its cost (WAIT_DP) or for its tile to be - * free (WAIT_TILE). render/units.js mirrors these codes. + * State of a knocked-out operator waiting to redeploy on the tile it lies on (b.snap `down` entries, Battle.snapshot): + * its respawn timer runs (COUNTING), then it waits for the player's DP to reach its cost (WAIT_DP) or for its tile to be + * free (WAIT_TILE — a safeguard: no ally deploys on a body's tile, Battle.downOn). render/units.js mirrors these codes. */ export const DOWN_STATE = Object.freeze({ COUNTING: 0, WAIT_DP: 1, WAIT_TILE: 2 }); /** diff --git a/test/sim/feedback1f-downed-tile.test.js b/test/sim/feedback1f-downed-tile.test.js index da9da481..c1c93089 100644 --- a/test/sim/feedback1f-downed-tile.test.js +++ b/test/sim/feedback1f-downed-tile.test.js @@ -204,3 +204,28 @@ test('F5 "若干员被击倒的位置为其他干员或召唤物的初始位置 checkInvariants(b); } }); + +test('F5 safeguard: the `down` state reports WAIT_TILE whenever _checkRedeploys holds a body back — a living unit or another body on its tile', async () => { + const { DOWN_STATE } = await import('../../server/sim/constants.js'); + const op = (id) => chessRec({ id, profession: 'WARRIOR', stats: { atk: 0, maxHp: 1000, def: 0, blockCnt: 0, cost: 1, respawnTime: 2 }, skill: null }); + const h = makeBattle({ + defs: { chess: { s_a: op('s_a'), s_b: op('s_b') } }, units: [{ chessId: 's_a', row: 9, col: 5 }, { chessId: 's_b', row: 10, col: 5 }], + content: 'none', autoFinish: false, timeLimit: 120, flags: { dpInit: 50, dpPerSec: 1, dpMax: 99 }, + }); + h.step(); + const b = h.b, A = h.unit('s_a'), B = h.unit('s_b'); + kill(b, A); + kill(b, B); + // unreachable through the engine (no ally deploys on a body tile; _layBody checks isReservedTile): force two bodies + // onto one tile to check that the client's state and the redeploy check agree + B.body = [9, 5]; + h.run(3); + assert.ok(!A.alive && !B.alive, 'neither redeploys onto a tile another body lies on'); + assert.equal(downEntry(b, A)[3], DOWN_STATE.WAIT_TILE, 'A: its tile is taken (not COUNTING with the DP there)'); + assert.equal(downEntry(b, B)[3], DOWN_STATE.WAIT_TILE); + B.body = [10, 5]; + assert.ok(h.runUntil(() => A.alive && B.alive, 5), 'both redeploy once the tiles are their own'); + assert.deepEqual(tileOf(A), [9, 5]); + assert.deepEqual(tileOf(B), [10, 5]); + checkInvariants(b); +});