diff --git a/server/sim/content/bonds/addon/battle.js b/server/sim/content/bonds/addon/battle.js index 5006acd2..38d02349 100644 --- a/server/sim/content/bonds/addon/battle.js +++ b/server/sim/content/bonds/addon/battle.js @@ -31,14 +31,16 @@ // + 脆弱 ×damage_scale for weak[limit] s // 助力 deputShip all operators DEF +(base + per·L), redeploy time ×(1 + respawn_time) // 突袭 raidShip member idle ≥ no_attack_duration s (or skill ready) with no enemy in range → "保留技力立即再部署" -// next to the most advanced ground enemy, on a free tile its position may be deployed on -// (grid.canStand: never the 深水区 — player report #3 after 0.1.0, members dropped into 战场#08's -// pool after an enemy wading in it; Battle.isReservedTile: never a tile a knocked-out operator -// lies on — player report F5, members landed on a fallen teammate): a real redeployment (retreat + -// free redeploy on the landing tile, full HP, `deploy` fires — 部署时 traits such as 史尔特尔, -// 突袭手雷, 卡西米尔, 叙拉古) with its SP / charges kept (engine redeploy tile + keepSp); ATK/HP -// +(base + per·L) until it leaves the field; knocked out after a jump it lies where it fell and -// comes back there (the engine's rest tile, Battle._layBody — PRTS 卫戍协议/帮助 "原地留下一个 +// next to the most advanced ground enemy it can reach: on a free tile its position may be deployed +// on from which its range covers that enemy (GitHub issue #51 [ASSUMED]: the first of the 8 most +// advanced that has such a tile; none → it stays and the next poll looks again, never a jump that +// hits nothing); grid.canStand: never the 深水区 (player report #3 after 0.1.0, members dropped +// into 战场#08's pool after an enemy wading in it); Battle.isReservedTile: never a tile a knocked-out +// operator lies on (player report F5, members landed on a fallen teammate). A real redeployment +// (retreat + free redeploy on the landing tile, full HP, `deploy` fires — 部署时 traits such as +// 史尔特尔, 突袭手雷, 卡西米尔, 叙拉古) with its SP / charges kept (engine redeploy tile + keepSp); +// ATK/HP +(base + per·L) until it leaves the field; knocked out after a jump it lies where it fell +// and comes back there (the engine's rest tile, Battle._layBody — PRTS 卫戍协议/帮助 "原地留下一个 // “倒地干员”…自动部署至该位置"; its own home when it fell on another board piece's home); // L ≥ power_bond_stack_cnt: every operator ASPD +power_attack_speed // 不屈 indomShip ground operator knocked out → p = min(1, base + per·L) immediate free redeploy where it lies @@ -52,12 +54,13 @@ // Hooks registered only when a bond needs them. Priorities: `hit` −20 (坚守 redirect, after other modifiers had their // say), `death` 10 (不屈, the bond slot of the revive/redeploy convention); everything else 0. -import { absoluteRangeKeys, canTargetEnemy } from '../../../targeting.js'; -import { localOrder, localBefore } from '../../../dir.js'; +import { canTargetEnemy, extendedGrid } from '../../../targeting.js'; +import { normDir, rotateOffset, localOrder, localBefore } from '../../../dir.js'; +import { bodyKeys } from '../../../body.js'; import { isHpLoss } from '../../../damage.js'; import { num, bondRecord, buffParams, bondTier, bondLayers, isMember, isElite, isGroundOp, onField, playerOps, passiveBuff, - fxOn, N4, N8, bodyInKeys, directMods, + fxOn, N4, N8, directMods, COLS, } from '../../support/index.js'; export const ID = Object.freeze({ @@ -75,6 +78,7 @@ const STEAD_CD = 'bond:steadShip:cd'; const AURA_POLL = 0.25; const RAID_POLL = 0.25; const RAID_SEARCH = 2; // landing tiles within this Chebyshev distance of the target enemy +const RAID_TARGETS = 8; // candidate enemies a poll tries per member, the most advanced first (raidTargets) /** 奥术 vulnerability mods for a multiplier (battle.applyStrongest). */ const arcaneMods = (v) => ({ artsTakenMul: v }); @@ -235,29 +239,59 @@ function updateAura(battle, st) { // ===================================================================================================================== // 突袭 relocation +// +// Either trigger (技能就绪, or no attack for no_attack_duration s) jumps only to a landing tile from which the member's +// attack range covers the enemy it jumps to: the first candidate (raidTargets order, the RAID_TARGETS most advanced) +// that has one; when none has, the member stays where it is and the next poll looks again (its idle time keeps +// counting, so it jumps as soon as one can be reached). The official text only says "再部署至一名地面敌人周围" — +// "never a useless landing" is [ASSUMED] after GitHub issue #51 (up to 0.1.1 the idle trigger landed where nothing was +// in range, hopped between such tiles every 10 s and ignored a second enemy it could have reached; DESIGN §22.2). -function raidTile(battle, u, e) { - const er = Math.round(e.y), ec = Math.round(e.x); - const ranged = u.def?.position === 'RANGED'; - const grid = u.rangeGrid || [[0, 0]]; - const ext = num(u.s.rangeExtend); - let best = null, bs = null; - for (let dr = -RAID_SEARCH; dr <= RAID_SEARCH; dr++) { - for (let dc = -RAID_SEARCH; dc <= RAID_SEARCH; dc++) { - const r = er + dr, c = ec + dc; - if (!battle.grid.inRect(r, c) || battle.isReservedTile(r, c)) continue; - if (!battle.grid.canStand(r, c, { ranged })) continue; - const covers = bodyInKeys(e, absoluteRangeKeys(grid, r, c, u.dir, ext)) ? 0 : 1; - const d = Math.max(Math.abs(dr), Math.abs(dc)) + 0.01 * (Math.abs(dr) + Math.abs(dc)); - // last tie-break: the offset in the unit's facing-RIGHT frame (sim/dir.js localOrder; for a RIGHT-facing unit the - // plain tile-key order), so the landing tile turns with its direction - const s0 = covers, s1 = d, s2 = localOrder(dr, dc, u.dir); - if (!bs || s0 < bs[0] || (s0 === bs[0] && (s1 < bs[1] - 1e-9 || (Math.abs(s1 - bs[1]) <= 1e-9 && localBefore(s2, bs[2]))))) { best = [r, c, covers === 0]; bs = [s0, s1, s2]; } - } - } - return best; // [row, col, targetInRange] +/** + * The member's attack range as offsets from its tile: its own grid + rangeExtend (targeting.js extendedGrid, the + * relative form of absoluteRangeKeys) turned by its direction — flat [dRow, dCol, dRow, dCol, …]. + */ +function raidReach(u) { + const d = normDir(u.dir); + const out = []; + for (const [dr, dc] of extendedGrid(u.rangeGrid || [[0, 0]], num(u.s.rangeExtend))) out.push(...rotateOffset(dr, dc, d)); + return out; } +/** + * Landing tile [row, col] of a jump to enemy `e`, or null: a tile from which the member's range (`reach`, raidReach) + * covers the enemy's body (a huge enemy: any tile it occupies — body.js), within RAID_SEARCH tiles (Chebyshev) of the + * enemy, inside the field rect, that the member's position may be deployed on (grid.canStand: never the 深水区 — + * player report #3 after 0.1.0) and that is free (Battle.isReservedTile: no living unit, no knocked-out operator's + * body — player report F5 —, no waiting piece's tile). The nearest first (Chebyshev, then 0.01·Manhattan); last + * tie-break the offset in the member's facing-RIGHT frame (sim/dir.js localOrder; for a RIGHT-facing unit the plain + * tile-key order), so the landing tile turns with its direction. Only the tiles a range offset leads back to from a + * body tile are looked at, so a search that finds nothing stays cheap. + */ +function raidTile(battle, u, e, reach) { + const er = Math.round(e.y), ec = Math.round(e.x); + const ranged = u.def?.position === 'RANGED'; + let best = null, bd = Infinity, bo = null; + for (const k of bodyKeys(e)) { + const br = Math.floor(k / COLS), bc = k - br * COLS; + for (let i = 0; i < reach.length; i += 2) { + const r = br - reach[i], c = bc - reach[i + 1], dr = r - er, dc = c - ec; + if (Math.abs(dr) > RAID_SEARCH || Math.abs(dc) > RAID_SEARCH) continue; + if (!battle.grid.inRect(r, c) || !battle.grid.canStand(r, c, { ranged }) || battle.isReservedTile(r, c)) continue; + const d = Math.max(Math.abs(dr), Math.abs(dc)) + 0.01 * (Math.abs(dr) + Math.abs(dc)); + const o = localOrder(dr, dc, u.dir); + if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && localBefore(o, bo))) { best = [r, c]; bd = d; bo = o; } + } + } + return best; +} + +/** + * Jump candidates of player `pid`, in priority order: the ground enemies an operator may target (not flying, + * canTargetEnemy) — those of the player's own field (`ownerId`), the others only when it has none —, the most advanced + * first (least remaining path distance, then the earliest spawned) [ASSUMED, research 02 §3.18]. The same list for + * every member of the player (canTargetEnemy reads the enemy, not the attacker). + */ function raidTargets(battle, u, pid) { const own = [], other = []; for (const e of battle.enemies) { @@ -273,6 +307,7 @@ function raidTargets(battle, u, pid) { function raidPoll(battle, st) { const bb = st.bb[ID.raid]; const idle = num(bb.no_attack_duration, 10); + let targets = null; // the player's candidates (raidTargets), shared by its members until a jump changes the field for (const u of st.members[ID.raid]) { if (!onField(u) || !u.canAct) continue; const since = Math.max(u.lastAttackAt ?? -Infinity, u.deployedAt ?? -Infinity, u.mem[KEY.raid] ?? -Infinity); @@ -280,19 +315,22 @@ function raidPoll(battle, st) { const idleOk = battle.time - since >= idle - 1e-9; if (!(ready || idleOk)) continue; if (battle.enemiesInKeys(u.rangeKeys || [], u, u.profile).length) continue; - const targets = raidTargets(battle, u, st.pid); - for (let i = 0; i < targets.length && i < 8; i++) { - const tile = raidTile(battle, u, targets[i]); + const list = (targets ??= raidTargets(battle, u, st.pid)); + if (!list.length) continue; + // either trigger: raidTile only offers tiles with the target in range (without that a ready skill that finds no + // target would redeploy — firing every 部署时 effect — at every poll; the idle trigger, which lacked it up to + // 0.1.1, hopped every 10 s: issue #51) + const reach = raidReach(u); + for (let i = 0; i < list.length && i < RAID_TARGETS; i++) { + const tile = raidTile(battle, u, list[i], reach); if (!tile) continue; - // the "skill ready" path has no idle gate: only jump where the target is in range, else a ready skill that never - // finds a target would redeploy (and fire every 部署时 effect) every poll - if (!idleOk && !tile[2]) continue; + targets = null; // the retreat / redeploy handlers (部署时 effects) may change the enemies: the next member re-sorts const res = raidRedeploy(battle, u, tile[0], tile[1]); if (!res) continue; - u.mem[KEY.raid] = battle.time; + u.mem[KEY.raid] = battle.time; // with deployedAt: the idle time starts again from the landing if (res === 'raid') { battle.addBuff(u, { key: KEY.raid, mods: st.raidMods }); - fxOn(battle, 'blink', u, KEY.raid, 'relocate', { target: targets[i].id }); + fxOn(battle, 'blink', u, KEY.raid, 'relocate', { target: list[i].id }); } break; } diff --git a/test/content/bonds_addon.test.js b/test/content/bonds_addon.test.js index 107a0bab..52cab3b8 100644 --- a/test/content/bonds_addon.test.js +++ b/test/content/bonds_addon.test.js @@ -2,7 +2,7 @@ // 绝技 and the prep side of 助力 远见 奇迹 投资人 调和 (numbers from data/bonds.json, research 02 §3.9–§3.23). import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; +import { makeBattle, chessRec, enemyRec, checkInvariants, flatStage } from '../helpers/battleHarness.js'; import { gainLayers, inRange } from '../../server/sim/content/support/index.js'; import { bondBb, procChance } from '../../server/sim/content/bonds/addon/battle.js'; import { registerMeta } from '../../server/sim/content/bonds/addon.js'; @@ -342,6 +342,76 @@ test('突袭: the jump is a redeployment — deploy fires (部署时 effects), f checkInvariants(h.b); }); +// GitHub issue #51 (reported on 0.1.0, still so in 0.1.1): the 10 s idle jump landed where no enemy was in range and +// then hopped between such tiles every 10 s, and it ignored a second enemy it could have reached. Either trigger now jumps only to a landing tile +// with its target in range — the first candidate that has one — else the member stays and the next poll looks again +// [ASSUMED: the text says only "再部署至一名地面敌人周围"]. Row 9 here: cols 8–9 plain floor (not deployable); a +// RIGHT-facing melee member (its own tile + the tile in front) reaches a speed-0 enemy on (9,9) only from (9,9) or +// (9,8) — neither deployable. +const RAID51_STAGE = flatStage({ rows: { 9: '##Errrrrff' + 'S' + 'rrrrrrr' + 'S##' } }); +const RAID51_ENEMY = { enemy_raid51: enemyRec({ key: 'enemy_raid51', hp: 1e7, speed: 0 }) }; +const raidJumps = (h) => h.hooksOf('deploy').filter((c) => c.unit.kind === 'op' && !c.initial); // (an enemy spawn is a deploy too) + +test('突袭 #51: no landing tile reaches the enemy → neither trigger jumps (no hopping); once one can, the jump comes at once', () => { + const defs = { + chess: { r_m: op('r_m', ['raidShip']), r_s: chessRec({ id: 'r_s', bonds: ['raidShip'], skill: { spCost: 10, initSp: 10 } }) }, + enemies: RAID51_ENEMY, + }; + const h = makeBattle({ + stage: RAID51_STAGE, defs, bonds: { raidShip: bond(1, 10) }, enemies: [{ key: 'enemy_raid51', pos: [9, 9] }], + units: [{ chessId: 'r_m', row: 12, col: 3 }, { chessId: 'r_s', row: 11, col: 3 }], hooks: ['deploy', 'death'], + autoFinish: false, timeLimit: 120, + }); + const m = h.unit('r_m'), s = h.unit('r_s'); + h.run(45); + assert.equal(m.lastAttackAt, -Infinity, 'r_m idle the whole time (the 10 s trigger, four times over)'); + assert.ok(s.skill.ready, 'r_s: its skill stays ready the whole time (the 技能就绪 trigger)'); + assert.deepEqual(raidJumps(h), [], 'no jump: no landing tile has the enemy in range'); + assert.deepEqual(h.hooksOf('death').filter((c) => c.reason === 'raid'), [], 'no 突袭 retreat'); + assert.deepEqual([m.tileR, m.tileC, s.tileR, s.tileC], [12, 3, 11, 3], 'both stay where they are'); + // a ground enemy that can be reached: both jump at the next poll (the idle time kept counting) and land with it in range + const t0 = h.b.time; + const e2 = h.spawn('enemy_raid51', { pos: [12, 7] }); + h.run(0.3); + assert.equal(raidJumps(h).length, 2, 'both members jumped'); + for (const c of raidJumps(h)) assert.ok(c.t - t0 <= 0.25 + 1e-6, `within one poll (${(c.t - t0).toFixed(2)} s)`); + for (const u of [m, s]) { + assert.ok(inRange(u, e2), `${u.defId}: the new enemy in range after the jump (${u.tileR},${u.tileC})`); + assert.ok(Math.max(Math.abs(u.tileR - 12), Math.abs(u.tileC - 7)) <= 2, `${u.defId}: next to it`); + } + checkInvariants(h.b); +}); + +test('突袭 #51: the most advanced enemy out of reach → the jump goes to the next one it can reach and fights there; idle time restarts', () => { + const defs = { chess: { r_m: op('r_m', ['raidShip']) }, enemies: RAID51_ENEMY }; + const idle = bondBb('raidShip').no_attack_duration; + const h = makeBattle({ + stage: RAID51_STAGE, defs, bonds: { raidShip: bond(1, 10) }, hooks: ['deploy', 'death'], autoFinish: false, timeLimit: 120, + enemies: [{ key: 'enemy_raid51', pos: [9, 9] }, { key: 'enemy_raid51', pos: [12, 9] }], units: [{ chessId: 'r_m', row: 12, col: 3 }], + }); + h.step(); + const [e1, e2] = h.enemies(); + assert.deepEqual([e1.y, e1.x, e2.y, e2.x], [9, 9, 12, 9]); + assert.ok(h.b.remainingDistance(e1) < h.b.remainingDistance(e2), 'the one out of reach is the more advanced (first candidate)'); + const u = h.unit('r_m'); + assert.ok(h.runUntil(() => raidJumps(h).length > 0, idle + 1), 'jumped after the idle time'); + const t1 = h.b.time; + assert.ok(inRange(u, e2) && !inRange(u, e1), `landed on ${u.tileR},${u.tileC} with the second enemy in range`); + h.run(5); + assert.ok(u.lastAttackAt > t1 && e2.hp < e2.s.maxHp, 'it attacks the enemy it jumped to'); + assert.equal(raidJumps(h).length, 1, 'busy there: no further jump'); + // its enemy gone, a reachable one elsewhere: the next jump comes the idle time after its last attack, not at once + h.b.dealDamage(null, e2, { amount: 1e9, type: 'true' }); + const last = u.lastAttackAt; + const e3 = h.spawn('enemy_raid51', { pos: [10, 5] }); + assert.ok(!inRange(u, e3)); + assert.ok(h.runUntil(() => raidJumps(h).length === 2, idle + 1), 'jumped again'); + const dt = h.b.time - last; + assert.ok(dt >= idle - 1e-6 && dt <= idle + 0.25 + 1e-6, `${dt.toFixed(2)} s after its last attack`); + assert.ok(inRange(u, e3), 'the new enemy in range'); + checkInvariants(h.b); +}); + test('不屈: knocked-out ground operator redeploys (p=1 at high L); tier 2 every operator +5 SP; inactive / elevated → no', () => { const bb = bondBb('indomShip'); const sk = { spCost: 50, initSp: 0 };