feedback1 integrate: WM 野鬃 S2 pushes along her facing; 琳琅诗怀雅 S3 coins radial

# Conflicts:
#	docs/SIM.md
This commit is contained in:
sganggs committed 2026-10-03 06:37:02 +08:00
commit da0aa28d5f
8 files changed
+251 -22

No files matched your search

+213
View File
@@ -0,0 +1,213 @@
// Player feedback after 0.1.0, batch 4, workstream WM — D2 "干员野鬃的技能有时会把怪物往攻击方向相反方向推".
//
// Official: 野鬃 S2 夹枪冲锋 "攻击会把目标往攻击方向中等力度地推开" is a 方向力 (PRTS 推与拉: "如果该目标被沿着设定的方向推动,
// 则该力是方向力…常见的推击手特种干员的推力即为此种力"; the 推击手 use the same "往攻击方向…推开" wording). Client data: her
// S2 attack ability (charpack char_496_wildmn, anim Skill_2) carries buff wildmn_s_2[force] of template knockback[dir],
// which buff_template_data defines as Knockback {_useSourceDirection: true, _decreaseForceLevelWhenNotInDirection: 2}:
// 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 备注 "推开效果为径向推动"; client
// swire2_s_3[knockback] of template knockback[relative] (_useSourceDirection false) — 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`);
}
});