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.
214 lines
12 KiB
JavaScript
214 lines
12 KiB
JavaScript
// 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`);
|
||
}
|
||
});
|