Files
ark-stronghold-protocol/test/sim/feedback1d-push.test.js
T
sganggs d374e537b9 feedback1d WM: 野鬃 S2 pushes along her direction; 琳琅诗怀雅 S3 coin push radial
Player report D2: 野鬃's skill sometimes pushed enemies the opposite way.
Her S2 text "往攻击方向…推开" is the 推击手 wording that PRTS 推与拉 calls a
directional force; the remake pushed radially at full force, so an enemy
grabbed behind her centre (or at her back) flew 1.7-2.14 tiles towards the
objective. Now directional with the official correction (> 45 deg off or
< 0.25 tile: radial at force level -2, at most 0.44 tile).

Direction audit: 琳琅诗怀雅 S3's coin push is radial (PRTS note), it was
pushed along her facing. The generic kit reads 往攻击方向 as directional.
2026-10-03 04:06:08 +08:00

211 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Player feedback after 0.1.0, batch 4, workstream WM — D2 "干员野鬃的技能有时会把怪物往攻击方向相反方向推".
//
// Official: 野鬃 S2 夹枪冲锋 "攻击会把目标往攻击方向中等力度地推开" — the very wording of the 推击手 pushes (阿消 S1 / S2, 温蒂 S1,
// 食铁兽 S1, 见行者 S1: "往攻击方向…推开"), which PRTS 推与拉 calls 方向力 ("如果该目标被沿着设定的方向推动,则该力是方向力…
// 常见的推击手特种干员的推力即为此种力"): a push along her deploy direction, turned radial at 受力等级 −2 when the target
// is more than 45° off that direction or nearer than 0.25 tile ("特殊修正"). The remake pushed her targets radially at
// full force, so an enemy behind her centre — one that walked through her tile while her block was full and that she
// grabbed once a push freed it (the hand-over, DESIGN §19.2), or one coming at her back — was thrown 1.7–2.14 tiles
// the other way, towards the protection objective (real act2 m01 R5: 7 % of her pushes facing RIGHT, up to 42 % in
// other facings). Now: directional, so such a target moves at most the −2 distance (≤ 0.44 tile).
// Audit (direction only): 琳琅诗怀雅 S3's coin push "向前推开" is radial — PRTS 备注 "推开效果为径向推动" — and the remake
// pushed it along her facing (and at −2 off the 45° cone); now radial from her centre, full force.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec } from '../helpers/battleHarness.js';
import { getDefaultSource, hasGeneratedData } from '../../server/sim/simdata.js';
import { PUSH_TILES, PUSH_DIRECTIONAL_MIN_DIST } from '../../server/sim/constants.js';
import { rotateOffset, toLocal } from '../../server/sim/dir.js';
import { Battle } from '../../server/sim/Battle.js';
import { getData } from '../../server/data.js';
import { GameData } from '../../server/match/gamedata.js';
import { setupMatchWaves, buildNormalWave } from '../../server/match/waves.js';
import { createRng } from '../../server/sim/rng.js';
const REAL = { skip: !hasGeneratedData() };
const approx = (a, b, eps, msg) => assert.ok(Math.abs(a - b) <= eps, `${msg ?? ''} ${a} ≈ ${b}`);
const WILDMN = 'chess_char_1_19_a';
const foe = (key, o = {}) => enemyRec({ key, hp: 1e8, speed: 0, atk: 0, ...o });
const FULL = PUSH_TILES[0]; // 中力 (1) vs weight 1 = 受力等级 0
const WEAK = PUSH_TILES[-2]; // the same push turned radial by the 特殊修正 (受力等级 −2)
// her tile per facing on the flat stage: room ahead for a full push and behind for a short one
const HOME = { RIGHT: [10, 4], LEFT: [10, 7], UP: [10, 5], DOWN: [11, 5] };
/**
* 野鬃 (S2 ready) facing `dir`, one static weight-1 enemy at `off` = [dRow, dCol] from her centre in her facing-RIGHT
* frame. Returns the enemy's displacement by her first skill hit in that frame: { fwd, side, len }.
*/
function wildmnHit(dir, off, { side = 'L', kind = 'normal', mass = 1 } = {}) {
const [hr, hc] = HOME[dir];
// the mirrored right half of a boss field: board (r, c) → field (r − 7, 20 − c), RIGHT ↔ LEFT
const fr = kind === 'boss' ? hr - 7 : hr, fc = side === 'R' ? 20 - hc : hc;
const fieldDir = side === 'R' ? { RIGHT: 'LEFT', LEFT: 'RIGHT', UP: 'UP', DOWN: 'DOWN' }[dir] : dir;
const [ar, ac] = rotateOffset(off[0], off[1], fieldDir);
let before = null, moved = null;
const h = makeBattle({
kind, timeLimit: 30, autoFinish: false, hooks: [],
defs: { enemies: { enemy_t: foe('enemy_t', { mass }) } },
players: [{ playerId: 'p1', seat: 0, side, colOffset: 0, units: [{ uid: 1, kind: 'chess', chessId: WILDMN, row: hr, col: hc, dir, carryState: { sp: 1e3 } }], bonds: {}, playerEffects: [] }],
enemies: [{ key: 'enemy_t', pos: [fr + ar, fc + ac] }],
setup(b) {
b.on('beforeAttack', (c) => { if (c.attacker.defId === WILDMN && c.isSkill && c.targets[0]) before = [c.targets[0].y, c.targets[0].x]; }, { priority: -2000 });
b.on('attack', (c) => { if (c.attacker.defId === WILDMN && c.isSkill && before && !moved) moved = [c.targets[0].y - before[0], c.targets[0].x - before[1]]; }, { priority: -2000 });
},
});
const u = h.unit(WILDMN);
assert.equal(u.dir, fieldDir, 'field direction');
assert.ok(h.runUntil(() => moved != null, 6), `${side} ${dir} ${off}: a skill hit`);
const [lf, ll] = toLocal(moved[0], moved[1], fieldDir); // [left (+row in the RIGHT frame), forward]
return { fwd: ll, side: lf, len: Math.hypot(lf, ll) };
}
test('D2 野鬃 S2 pushes along her direction ("往攻击方向", the 推击手 wording) — straight ahead the full 中力 distance, every facing', REAL, () => {
const ds = getDefaultSource();
assert.match(ds.rawChess(WILDMN).skill.desc, /往攻击方向/, 'data: the directional wording');
assert.equal(ds.rawChess(WILDMN).skill.bb['attack@force'], 1, 'data: 中等力度 = 1');
for (const dir of ['RIGHT', 'UP', 'LEFT', 'DOWN']) {
const m = wildmnHit(dir, [0, 0.7]);
approx(m.fwd, FULL, 1e-6, `${dir}: forward`);
approx(m.side, 0, 1e-9, `${dir}: no sideways`);
}
});
test('D2 野鬃 S2: a target within 45° of her direction goes straight along it (not radially sideways)', REAL, () => {
for (const dir of ['RIGHT', 'UP', 'LEFT', 'DOWN']) {
for (const lat of [0.3, -0.3]) {
const m = wildmnHit(dir, [lat, 0.6]); // 26.6° off, inside her block radius and her range (tile ahead)
approx(m.fwd, FULL, 1e-6, `${dir} ${lat}: along her direction`);
approx(m.side, 0, 1e-9, `${dir} ${lat}: no sideways (radial would be ${(FULL * lat / Math.hypot(lat, 0.6)).toFixed(2)})`);
}
}
});
test('D2 野鬃 S2: a target behind her, beside her (> 45° off) or on her centre — radial at 受力等级 −2, never a full throw backwards', REAL, () => {
for (const dir of ['RIGHT', 'UP', 'LEFT', 'DOWN']) {
// behind her centre (blocked by her, as after the hand-over): the user's case — it used to fly 1.7 tiles backwards
const back = wildmnHit(dir, [0, -0.45]);
approx(back.len, WEAK, 1e-6, `${dir} behind: the −2 distance`);
assert.ok(back.fwd < 0 && Math.abs(back.side) < 1e-9, `${dir} behind: radial (away from her)`);
// beside her, 63° off: radial, −2
const beside = wildmnHit(dir, [0.6, 0.3]);
approx(beside.len, WEAK, 1e-6, `${dir} beside: the −2 distance`);
approx(beside.side / beside.len, 0.6 / Math.hypot(0.6, 0.3), 1e-6, `${dir} beside: radial`);
// nearer than 0.25 tile: radial, −2
const near = wildmnHit(dir, [0.1, 0.1]);
assert.ok(Math.hypot(0.1, 0.1) < PUSH_DIRECTIONAL_MIN_DIST);
approx(near.len, WEAK, 1e-6, `${dir} near: the −2 distance`);
approx(near.side, near.fwd, 1e-6, `${dir} near: radial`);
}
});
test('D2 野鬃 S2 on the mirrored right half of the Final Assault field: the same pushes in its own direction', REAL, () => {
for (const dir of ['RIGHT', 'LEFT']) {
const ahead = wildmnHit(dir, [0, 0.7], { side: 'R', kind: 'boss' });
approx(ahead.fwd, FULL, 1e-6, `R ${dir}: forward`);
approx(ahead.side, 0, 1e-9, `R ${dir}: no sideways`);
const diag = wildmnHit(dir, [0.3, 0.6], { side: 'R', kind: 'boss' });
approx(diag.fwd, FULL, 1e-6, `R ${dir} diagonal: along its direction`);
approx(diag.side, 0, 1e-9, `R ${dir} diagonal: no sideways`);
const back = wildmnHit(dir, [0, -0.45], { side: 'R', kind: 'boss' });
approx(back.len, WEAK, 1e-6, `R ${dir} behind: the −2 distance`);
assert.ok(back.fwd < 0, `R ${dir} behind: away from her`);
}
});
// ---------------------------------------------------------------------------------------------------------------------
// The real product path: the act2 m01 lane, the match's own R5 wave (server/match/waves.js), 野鬃 on the melee tiles
// next to the lower lane's last bend. Enemies walk through her tile while she holds one; when a push frees her block
// she grabs the one behind her centre — and threw it towards the objective.
function realRun(chessId, r, c, dir, round = 5) {
const quiet = { warn() {}, error() {}, info() {}, debug() {} };
const data = getData({ log: quiet });
const modeId = 'mode_multi_normal';
const gd = new GameData(data, modeId);
const setup = setupMatchWaves(gd, createRng(11 + round));
const w = buildNormalWave(gd, createRng(11 + round + round), setup.factions, round);
const b = new Battle({
seed: 7 + r * 31 + c, kind: 'normal', modeId, round, stageId: 'act2autochess_m01', timeLimit: w.timeLimit || 60,
routes: w.routes, spawns: w.spawns, enemyOverrides: w.overrides ?? {}, fieldId: 'n:P1', logger: quiet,
players: [{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, kind: 'chess', chessId, row: r, col: c, dir }] }],
flags: { layerGainsEnabled: true, dpInit: 10, dpPerSec: 1, dpMax: 99 },
});
const u = b.allyUnits.find((x) => x.defId === chessId);
const pushes = [];
const orig = b.push.bind(b);
b.push = (e, force, o = {}) => {
if (o.from !== u) return orig(e, force, o);
const x0 = e.x, y0 = e.y, behind = (x0 - u.x) * u.fwd[1] + (y0 - u.y) * u.fwd[0] < 0;
const moved = orig(e, force, o);
if (moved > 1e-6) pushes.push({ dx: e.x - x0, dy: e.y - y0, moved, behind, massLevel: e.s.massLevel });
return moved;
};
for (let n = 0; !b.finished && n < 30 * 120; n++) b.step();
return { u, pushes };
}
test('D2 real battle (act2 m01, R5 official wave): no push of 野鬃 throws an enemy behind her backwards further than the −2 distance', REAL, () => {
let behind = 0, total = 0;
for (const [r, c] of [[9, 3], [9, 4], [9, 5]]) {
for (const chessId of ['chess_char_1_19_a', 'chess_char_1_19_b']) {
const { u, pushes } = realRun(chessId, r, c, 'RIGHT');
const [fr, fc] = u.fwd;
for (const p of pushes) {
total++;
const along = p.dx * fc + p.dy * fr;
if (p.behind) behind++;
// either straight along her direction, or the −2 radial push (受力等级 = 1 − weight − 2)
if (along < p.moved * (1 - 1e-6)) {
assert.ok(p.moved <= PUSH_TILES[-1] + 1e-6, `(${r},${c}) ${chessId}: off-direction push of ${p.moved.toFixed(2)} tiles (weight ${p.massLevel})`);
}
}
}
}
assert.ok(total > 50, `pushes ${total}`);
assert.ok(behind > 0, 'the hand-over case happens (an enemy grabbed behind her centre)');
});
// ---------------------------------------------------------------------------------------------------------------------
// 琳琅诗怀雅 S3 千金一掷 — PRTS 备注 "金币弹道…推开效果为径向推动": the coin push is radial from her centre.
function swireCoin(off, dir = 'RIGHT') {
const id = 'chess_char_3_04_a';
const [hr, hc] = [10, 5];
const [ar, ac] = rotateOffset(off[0], off[1], dir);
const h = makeBattle({
timeLimit: 30, autoFinish: false, hooks: [],
defs: { enemies: { enemy_t: foe('enemy_t', { mass: 1 }) } },
units: [{ chessId: id, row: hr, col: hc, dir, skillIndex: 2 }],
enemies: [{ key: 'enemy_t', pos: [hr + ar, hc + ac] }],
});
const u = h.unit(id);
assert.equal(u.skill.id, 'skchr_swire2_3');
h.step();
const e = h.enemies()[0];
assert.ok(u.skill.active || u.skill.activate('test', { free: true }), 'S3 on');
u.mem.coins = 1;
const y0 = e.y, x0 = e.x;
u.skill.end('manual');
const [ls, lf] = toLocal(e.y - y0, e.x - x0, dir);
return { fwd: lf, side: ls, len: Math.hypot(ls, lf) };
}
test('D2 audit: 琳琅诗怀雅 S3 coins push radially from her centre at full force (PRTS 备注 "推开效果为径向推动")', REAL, () => {
const ds = getDefaultSource();
const s3 = ds.rawChess('chess_char_3_04_a').skills.find((s) => s.skillId === 'skchr_swire2_3');
assert.equal(s3.bb.force, 0, 'data: 小力 (normal)');
const full = PUSH_TILES[-1]; // 小力 (0) vs weight 1
for (const dir of ['RIGHT', 'UP']) {
// 20.6° off her direction: radial, so partly sideways (the remake pushed it straight along her facing)
const a = swireCoin([0.3, 0.8], dir);
approx(a.len, full, 1e-6, `${dir} cone: full distance`);
approx(a.side / a.len, 0.3 / Math.hypot(0.3, 0.8), 1e-6, `${dir} cone: radial`);
// 66° off: radial at full force (the remake turned it into a −2 push: nothing at all)
const b = swireCoin([0.45, 0.2], dir);
approx(b.len, full, 1e-6, `${dir} beside: full distance`);
approx(b.side / b.len, 0.45 / Math.hypot(0.45, 0.2), 1e-6, `${dir} beside: radial`);
}
});