diff --git a/server/sim/Battle.js b/server/sim/Battle.js index 188a5901..caa9b20f 100644 --- a/server/sim/Battle.js +++ b/server/sim/Battle.js @@ -1051,13 +1051,14 @@ export class Battle { for (const u of this.allyUnits) { if (u.alive || u.removed || u.kind !== 'op') continue; if (this.time + 1e-9 < u.respawnAt) continue; + // the DP first: an operator past its timer mostly waits for DP, and the tile checks scan every ally + const ps = this.getPlayer(u.ownerId); + const cost = u.base.cost; + if (!ps || ps.dp + 1e-9 < cost) continue; const [r, c] = this.restTile(u); const occ = this._occ[r * COLS + c]; if (occ && occ.alive && occ !== u) continue; if (this.downOn(r, c, u)) continue; - const ps = this.getPlayer(u.ownerId); - const cost = u.base.cost; - if (!ps || ps.dp + 1e-9 < cost) continue; ps.dp = Math.max(0, ps.dp - cost); this._deploy(u, { initial: false, tile: r === u.homeR && c === u.homeC ? null : [r, c] }); } @@ -2040,17 +2041,19 @@ export class Battle { /** * Where a knocked-out operator lies (it redeploys there): the tile it fell on, except — "若干员被击倒的位置为其他干员或 * 召唤物的初始位置,则在被击倒后,尝试返回其自身的初始位置" (PRTS 卫戍协议/帮助) — a tile that is another board piece's - * home (a piece with a board uid: the 初始位置 is the prep placement), where it goes back to its own home tile when - * that is in the rect and free (isReservedTile: no living unit, no other body, no other waiting piece's tile). It - * stays where it fell when its home is taken [ASSUMED: one attempt, at the knock-out]. Only an operator moved off its - * board tile — a 突袭 jump, 乌尔比安's anchor — can fall elsewhere. Sets `u.body` (b.snap `down` carries it); x / y / - * tileR / tileC keep where it fell, so the `kill` / `death` handlers (被击倒时 effects) still act there. + * home (a piece with a board uid: the 初始位置 is the prep placement — whether that piece is on the field, waiting or + * gone: a summon only leaves its home free once it has expired or been killed, so a removed one must count too), where + * it goes back to its own home tile when that is in the rect and free (isReservedTile: no living unit, no other body, + * no other waiting piece's tile). It stays where it fell when its home is taken [ASSUMED: one attempt, at the + * knock-out]. Only an operator moved off its board tile — a 突袭 jump, 乌尔比安's anchor — can fall elsewhere. Sets + * `u.body` (b.snap `down` carries it); x / y / tileR / tileC keep where it fell, so the `kill` / `death` handlers + * (被击倒时 effects) still act there. */ _layBody(u) { const r = u.tileR, c = u.tileC, hr = u.homeR, hc = u.homeC; u.body = [r, c]; if (r === hr && c === hc) return; - if (!this.allyUnits.some((a) => a !== u && a.uid != null && !a.removed && (a.kind === 'op' || a.kind === 'token') && a.homeR === r && a.homeC === c)) return; + if (!this.allyUnits.some((a) => a !== u && a.uid != null && (a.kind === 'op' || a.kind === 'token') && a.homeR === r && a.homeC === c)) return; if (!this.grid.inRect(hr, hc) || this.isReservedTile(hr, hc)) return; u.body = [hr, hc]; } diff --git a/test/sim/feedback1f-downed-tile.test.js b/test/sim/feedback1f-downed-tile.test.js index c1c93089..2056439a 100644 --- a/test/sim/feedback1f-downed-tile.test.js +++ b/test/sim/feedback1f-downed-tile.test.js @@ -205,6 +205,44 @@ test('F5 "若干员被击倒的位置为其他干员或召唤物的初始位置 } }); +// Rule 3 for a SUMMON's initial position (QA after the integration): a summon leaves its home free only once it has +// expired or been killed — and the removed summon was not counted, so the clause never applied: a 突袭 member that +// landed on the free tile of 浊心斯卡蒂's 海嗣 and fell there lay on it and kept the 海嗣 (远古血亲: it comes back on its +// tile) off the field. Every board piece's 初始位置 counts now, on the field or not. +test('F5 rule 3: a body on the home of a summon that has expired or been killed goes back to its own; the summon comes back', () => { + for (const how of ['expired', 'killed']) { + const h = makeBattle({ + stageId: 'flat', autoFinish: false, timeLimit: 400, bonds: RAID, + units: [ + { chessId: 'chess_char_6_04_a', row: 10, col: 3, uid: 1 }, + { kind: 'token', tokenId: 'token_10017_skadi2_dedant', row: 10, col: 5, uid: 2, ownerUid: 1 }, + { chessId: 'chess_char_1_18_a', row: 12, col: 3, uid: 3 }, + ], + }); + h.step(); + const b = h.b; + const sea = b.allyUnits.find((u) => u.kind === 'token' && u.uid === 2); + const yan = h.unit(3); + assert.ok(sea.alive && sea.tileR === 10 && sea.tileC === 5, 'the 海嗣 on its placed tile'); + if (how === 'killed') kill(b, sea); + else h.runUntil(() => !sea.alive, 120); + assert.ok(!sea.alive && sea.removed, `${how}: the 海嗣 is gone`); + assert.equal(b.isReservedTile(10, 5), false, 'its home is free for an automatic placement'); + b.retreat(yan, { reason: 'raid' }); // the 突袭 landing (raidRedeploy: retreat + redeploy on a free tile) + assert.ok(b.redeploy(yan, { free: true, tile: [10, 5], keepSp: true }), `${how}: 宴 lands on the 海嗣's home`); + kill(b, yan); + assert.deepEqual(b.restTile(yan), [12, 3], `${how}: 宴 lies on her own home, not on the 海嗣's`); + assert.deepEqual(bodyOf(b, yan), [12, 3], 'the client draws her there'); + assert.equal(b.isReservedTile(10, 5), false, 'the 海嗣\'s home stays free'); + if (how === 'expired') { + const back = () => b.allyUnits.find((u) => u.kind === 'token' && u.alive && u.defId === sea.defId) ?? null; + assert.ok(h.runUntil(() => back() != null, 60), 'the 海嗣 comes back (while 宴 still waits to redeploy)'); + assert.deepEqual(tileOf(back()), [10, 5], 'on its own tile'); + } + 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 });