feedback2 WB: 突袭 jumps only to a tile from which its target is in range, on the idle trigger too (GitHub issue #51)
Up to 0.1.1 only the 技能就绪 trigger required a landing tile that covers the target; the 10 s idle trigger took raidTile's fallback (a tile that covers nothing), so a member with nothing reachable hopped every 10 s between useless tiles and never went for a second ground enemy it could have hit. Now both triggers walk the candidates in the existing order (own field first, most advanced first, at most 8) and jump to the first one with a covering tile; none -> the member stays, its idle time keeps counting and the 0.25 s poll looks again. [ASSUMED]: the official text only says "再部署至一名地面敌人周围". raidTile now enumerates only the tiles a range offset leads back to from the enemy's body (same choice as the old 25-tile scan whenever a covering tile existed: 200 000 random cases), and a player's members share one sorted candidate list per poll (rebuilt after a jump), so a search that finds nothing stays cheap: +5.8 us per tick on the heavy boss field with 18 members that never reach anything, against +5.5 us for 0.1.1 (the plain requirement on the old scan: +29.6 us). Tests: two "突袭 #51" cases in test/content/bonds_addon.test.js.
This commit is contained in:
1 parent
2a03467897
commit
76e60a186b
2 files changed
+148
-40
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user