Files
sganggs 97538091ed feedback1d WM: cite the client push templates for 野鬃 / 琳琅诗怀雅
The direction rules now rest on client data instead of the skill wording:
野鬃 S2's attack ability carries buff wildmn_s_2[force] of template
knockback[dir] (Knockback, _useSourceDirection true, force level -2 when
off direction); 琳琅诗怀雅 S3, 山 S3 and 莫斯提马 S3 use knockback[relative]
(radial); 圣聆初雪 S1 is KnockBackWithCharacterDirection (no correction).
Comments, SIM.md and the test header cite these. The generic kit header
notes its text keywords are only a fallback. PLAYING.md explains 野鬃's
push direction to players. No behaviour change.
2026-10-03 04:31:36 +08:00

214 lines
12 KiB
JavaScript
Raw Permalink 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 夹枪冲锋 "攻击会把目标往攻击方向中等力度地推开" 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`);
}
});