Files
sganggs 92a179e71e feedback5 WE2: remove the kits/tier1-6.js re-export shims and shared/standIn.js diyRecord (#5)
Nothing but tests imported them: kits_t3 / kits_alt_t3 / kits_t4 / kits_t5 read
kits/index.js TIER_KITS, playtest5_elements / playtest6-combat / facing the real modules,
simServe checks /sim/content/kits/index.js; test/backups.test.js composes the prototypes
through shared/diy.js diyRecordOf at their locked selection. check:imports passes.
Docs: kits/README.md, DATA.md. Golden: no scenario moved.
2026-10-06 16:07:49 +08:00

212 lines
14 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.
// 4-direction facing in the engine (DESIGN §3 "Facing (corrected)", research 09 §1.2 / §6.1): server/sim/dir.js math,
// rotated range grids (+ rangeExtend before rotating), unit dir from the battle input, the Final Assault right-side
// mirror (RIGHT ↔ LEFT, UP / DOWN kept), UnitInfo.dir, and every engine-level front / side helper (professions,
// skill trigger grids, token placement grids, support geometry, kit helpers).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
DIRS, DIR_VEC, normDir, rotateOffset, toLocal, hSign, mirrorDir, oppositeDir, perpendicular, dirFromDelta, frontOf, offsetTile,
} from '../../server/sim/dir.js';
import { absoluteRangeKeys } from '../../server/sim/targeting.js';
import { COLS } from '../../server/sim/constants.js';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { findSummonTile } from '../../server/sim/content/tokens.js';
import { frontTile, sideTiles, alliesAround, N4 } from '../../server/sim/content/support/index.js';
import { freeTileAround } from '../../server/sim/content/kits/shared/tier1.js';
import { DIRS as PROTOCOL_DIRS } from '../../shared/protocol.js';
const K = (r, c) => r * COLS + c;
const tilesOf = (keys) => keys.map((k) => [Math.floor(k / COLS), k % COLS]).sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const sorted = (list) => list.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const dummy = (key = 'e_d', o = {}) => enemyRec({ key, hp: 1e7, speed: 0, def: 0, res: 0, ...o });
test('dir.js: the DATA.md rotation RIGHT (dr,dc) · UP (dc,−dr) · LEFT (−dr,−dc) · DOWN (−dc,dr), inverse and helpers', () => {
assert.deepEqual([...DIRS], ['UP', 'RIGHT', 'DOWN', 'LEFT']);
assert.deepEqual([...PROTOCOL_DIRS], [...DIRS], 'shared/protocol.js lists the same directions');
assert.deepEqual(rotateOffset(2, 3, 'RIGHT'), [2, 3]);
assert.deepEqual(rotateOffset(2, 3, 'UP'), [3, -2]);
assert.deepEqual(rotateOffset(2, 3, 'LEFT'), [-2, -3]);
assert.deepEqual(rotateOffset(2, 3, 'DOWN'), [-3, 2]);
// the forward cell [0, 1] becomes the direction vector (row 0 = bottom: UP = +row)
for (const d of DIRS) assert.deepEqual(rotateOffset(0, 1, d), [...DIR_VEC[d]], d);
for (const d of DIRS) for (const [dr, dc] of [[0, 0], [1, 2], [-3, 1], [2, -2]]) {
const [ar, ac] = rotateOffset(dr, dc, d);
assert.deepEqual(toLocal(ar, ac, d), [dr, dc], `toLocal inverts rotateOffset (${d})`);
}
assert.equal(normDir('up'), 'UP');
assert.equal(normDir(-1), 'LEFT');
assert.equal(normDir(1), 'RIGHT');
assert.equal(normDir('NORTH'), 'RIGHT');
assert.equal(normDir(undefined, 'DOWN'), 'DOWN');
assert.deepEqual(DIRS.map(hSign), [1, 1, 1, -1], 'horizontal sign only for sprite flipping');
assert.deepEqual(DIRS.map(mirrorDir), ['UP', 'LEFT', 'DOWN', 'RIGHT'], 'FA mirror: RIGHT ↔ LEFT, UP / DOWN kept');
assert.deepEqual(DIRS.map(oppositeDir), ['DOWN', 'LEFT', 'UP', 'RIGHT']);
assert.ok(perpendicular('UP', 'LEFT') && !perpendicular('UP', 'DOWN'));
assert.equal(dirFromDelta(1, 0), 'UP');
assert.equal(dirFromDelta(0, -2), 'LEFT');
assert.equal(dirFromDelta(-1, 0), 'DOWN');
assert.equal(dirFromDelta(0, 0, 'LEFT'), 'LEFT');
assert.deepEqual(frontOf(10, 5, 'DOWN', 2), [8, 5]);
assert.deepEqual(frontOf(10, 5, 'UP', -1), [9, 5], 'k < 0: behind');
assert.deepEqual(offsetTile(10, 5, 1, 0, 'UP'), [10, 4], 'the left hand of an UP unit is the left column');
});
test('absoluteRangeKeys rotates the facing-RIGHT grid; rangeExtend goes along +dCol before rotating', () => {
const grid = [[0, 1], [1, 2]];
assert.deepEqual(tilesOf(absoluteRangeKeys(grid, 10, 5, 'RIGHT')), sorted([[10, 6], [11, 7]]));
assert.deepEqual(tilesOf(absoluteRangeKeys(grid, 10, 5, 'UP')), sorted([[11, 5], [12, 4]]));
assert.deepEqual(tilesOf(absoluteRangeKeys(grid, 10, 5, 'LEFT')), sorted([[10, 4], [9, 3]]));
assert.deepEqual(tilesOf(absoluteRangeKeys(grid, 10, 5, 'DOWN')), sorted([[9, 5], [8, 6]]));
// legacy facing signs still work (−1 = LEFT)
assert.deepEqual(absoluteRangeKeys(grid, 10, 5, -1), absoluteRangeKeys(grid, 10, 5, 'LEFT'));
// +1 forward tile per row, then rotated: a 2-tile line facing UP / DOWN becomes 3 tiles along the column
const line = [[0, 0], [0, 1]];
assert.deepEqual(tilesOf(absoluteRangeKeys(line, 10, 5, 'UP', 1)), [[10, 5], [11, 5], [12, 5]]);
assert.deepEqual(tilesOf(absoluteRangeKeys(line, 10, 5, 'DOWN', 1)), [[8, 5], [9, 5], [10, 5]]);
assert.deepEqual(tilesOf(absoluteRangeKeys(line, 10, 5, 'LEFT', 2)), [[10, 2], [10, 3], [10, 4], [10, 5]]);
});
test('units take `dir` from the battle input (default RIGHT): rotated rangeKeys, derived facing sign, UnitInfo.dir', () => {
const rec = chessRec({ id: 't_sn', profession: 'SNIPER', rangeGrid: [[0, 0], [0, 1], [0, 2]], skill: null });
const h = makeBattle({
defs: { chess: { t_sn: rec, t_sn2: { ...rec, chessId: 't_sn2', baseId: 't_sn2' }, t_sn3: { ...rec, chessId: 't_sn3', baseId: 't_sn3' }, t_sn4: { ...rec, chessId: 't_sn4', baseId: 't_sn4' } } },
units: [
{ chessId: 't_sn', row: 10, col: 3, dir: 'UP' }, { chessId: 't_sn2', row: 10, col: 6, dir: 'LEFT' },
{ chessId: 't_sn3', row: 12, col: 8, dir: 'DOWN' }, { chessId: 't_sn4', row: 9, col: 8 },
],
});
h.step();
const [up, left, down, def] = ['t_sn', 't_sn2', 't_sn3', 't_sn4'].map((id) => h.unit(id));
assert.deepEqual([up.dir, left.dir, down.dir, def.dir], ['UP', 'LEFT', 'DOWN', 'RIGHT']);
assert.deepEqual([up.facing, left.facing, down.facing, def.facing], [1, -1, 1, 1], 'facing = horizontal sign only');
assert.deepEqual(up.fwd, [1, 0]);
assert.deepEqual(tilesOf(up.rangeKeys), [[10, 3], [11, 3], [12, 3]]);
assert.deepEqual(tilesOf(left.rangeKeys), [[10, 4], [10, 5], [10, 6]]);
assert.deepEqual(tilesOf(down.rangeKeys), [[10, 8], [11, 8], [12, 8]]);
assert.deepEqual(tilesOf(down.baseRangeKeys), [[10, 8], [11, 8], [12, 8]], 'the initial (trigger) range is rotated too');
const info = h.b.fieldMeta().units.find((u) => u.defId === 't_sn');
assert.equal(info.dir, 'UP');
assert.equal(h.b.fieldMeta().units.find((u) => u.defId === 't_sn2').facing, -1);
checkInvariants(h.b);
});
test('a unit only hits enemies inside its rotated range (DOWN-facing sniper shoots the row below)', () => {
const rec = chessRec({ id: 't_sn', profession: 'SNIPER', rangeGrid: [[0, 0], [0, 1]], skill: null, stats: { atk: 100 } });
const run = (dir) => {
const h = makeBattle({ defs: { chess: { t_sn: rec }, enemies: { e_d: dummy() } }, units: [{ chessId: 't_sn', row: 11, col: 4, dir }], enemies: [{ key: 'e_d', pos: [10, 4] }], autoFinish: false, timeLimit: 10 });
h.run(3);
return h.hooksOf('attack').length;
};
assert.ok(run('DOWN') > 0, 'facing DOWN: the enemy below is in range');
assert.equal(run('RIGHT'), 0, 'facing RIGHT: out of range');
assert.equal(run('UP'), 0, 'facing UP: out of range');
});
test('Final Assault right side: col c → 20 − c with RIGHT ↔ LEFT, UP / DOWN unchanged', () => {
const rec = chessRec({ id: 't_a', profession: 'SNIPER', rangeGrid: [[0, 0], [0, 1]], skill: null });
const defs = { chess: { t_a: rec, t_b: { ...rec, chessId: 't_b', baseId: 't_b' }, t_c: { ...rec, chessId: 't_c', baseId: 't_c' } } };
const units = [{ uid: 1, kind: 'chess', chessId: 't_a', row: 10, col: 4, dir: 'RIGHT' }, { uid: 2, kind: 'chess', chessId: 't_b', row: 11, col: 5, dir: 'UP' }, { uid: 3, kind: 'chess', chessId: 't_c', row: 12, col: 6, dir: 'LEFT' }];
const h = makeBattle({ kind: 'boss', defs, players: [
{ playerId: 'L', seat: 0, side: 'L', colOffset: 0, units: units.map((u) => ({ ...u })) },
{ playerId: 'R', seat: 1, side: 'R', colOffset: 8, units: units.map((u) => ({ ...u, uid: u.uid + 10 })) },
] });
h.step();
const of = (pid, id) => h.b.allyUnits.find((u) => u.ownerId === pid && u.defId === id);
assert.deepEqual([of('L', 't_a').tileR, of('L', 't_a').tileC, of('L', 't_a').dir], [3, 4, 'RIGHT']);
assert.deepEqual([of('R', 't_a').tileR, of('R', 't_a').tileC, of('R', 't_a').dir], [3, 16, 'LEFT']);
assert.deepEqual([of('R', 't_b').tileC, of('R', 't_b').dir], [15, 'UP'], 'UP stays UP');
assert.deepEqual([of('R', 't_c').tileC, of('R', 't_c').dir], [14, 'RIGHT'], 'LEFT becomes RIGHT');
assert.deepEqual(tilesOf(of('R', 't_a').rangeKeys), [[3, 15], [3, 16]], 'the mirrored range points at the centre');
assert.equal(h.b.getPlayer('R').dir, 'LEFT', 'default direction of the mirrored side');
assert.equal(h.b.mapDir(h.b.getPlayer('R'), 'DOWN'), 'DOWN');
assert.equal(h.b.mapDir(h.b.getPlayer('R'), 'RIGHT', true), 'RIGHT', 'field coordinates are taken as they are');
checkInvariants(h.b);
});
test('professions: 领主 (lord) full damage only on its own tile and the tile in FRONT along its direction', () => {
const rec = chessRec({ id: 't_lord', profession: 'WARRIOR', subProfessionId: 'lord', rangeGrid: [[1, 0], [0, 0], [0, 1], [0, 2], [-1, 0]], skill: null });
const h = makeBattle({ defs: { chess: { t_lord: rec } }, units: [{ chessId: 't_lord', row: 10, col: 4, dir: 'UP' }] });
h.step();
const u = h.unit('t_lord');
const at = (r, c) => u.profile.dmgMul(h.b, u, { x: c, y: r, blockedBy: null });
assert.equal(at(11, 4), 1, 'the tile in front (above) of an UP-facing lord');
assert.equal(at(10, 4), 1, 'its own tile');
assert.ok(at(10, 5) < 1, 'the tile to its right is not in front');
assert.ok(at(12, 4) < 1, 'two tiles ahead: ranged');
});
test('professions: 行刑者 (reaperrange) front bonus follows the direction (own line, at or ahead)', () => {
const rec = chessRec({ id: 't_rr', profession: 'SNIPER', subProfessionId: 'reaperrange', rangeGrid: [[0, 0], [0, 1], [0, 2]], skill: null });
const h = makeBattle({ defs: { chess: { t_rr: rec } }, units: [{ chessId: 't_rr', row: 11, col: 4, dir: 'DOWN' }] });
h.step();
const u = h.unit('t_rr');
u.profile = { ...u.profile, frontGrid: null };
const at = (r, c) => u.profile.dmgMul(h.b, u, { x: c, y: r });
assert.ok(at(10, 4) > 1, 'below a DOWN-facing unit: its front line');
assert.equal(at(12, 4), 1, 'behind it: no bonus');
assert.equal(at(11, 5), 1, 'beside it: no bonus');
});
test('skill CUSTOM_RANGE trigger grids are rotated with the unit', () => {
const rec = chessRec({
id: 't_trig', profession: 'SNIPER', rangeGrid: [[0, 0]],
skill: { kind: 'instant', spCost: 1, initSp: 1, durationType: 'NONE', trigger: { rule: 'CUSTOM_RANGE', grid: [[0, 1], [0, 2]] } },
});
const run = (dir) => {
const h = makeBattle({ defs: { chess: { t_trig: rec }, enemies: { e_d: dummy() } }, units: [{ chessId: 't_trig', row: 9, col: 4, dir }], enemies: [{ key: 'e_d', pos: [11, 4] }], autoFinish: false, timeLimit: 5 });
h.run(2);
return h.hooksOf('skillStart').length;
};
assert.ok(run('UP') > 0, 'UP: the enemy two tiles above triggers the skill');
assert.equal(run('RIGHT'), 0, 'RIGHT: nothing in the trigger grid');
});
test('token placement grids (findSummonTile) and support geometry follow the owner direction', () => {
const rec = chessRec({ id: 't_o', profession: 'SUPPORT', rangeGrid: [[0, 0]], skill: null });
const h = makeBattle({ defs: { chess: { t_o: rec, t_n: { ...rec, chessId: 't_n', baseId: 't_n' }, t_s: { ...rec, chessId: 't_s', baseId: 't_s' } } },
units: [{ chessId: 't_o', row: 10, col: 4, dir: 'UP' }, { chessId: 't_n', row: 11, col: 4 }, { chessId: 't_s', row: 10, col: 3 }] });
h.step();
const o = h.unit('t_o');
assert.deepEqual(findSummonTile(h.b, o, 'near', { grid: [[0, 2]], at: { x: 4, y: 12 } }), [12, 4], 'grid offset [0,2] = two tiles above');
assert.deepEqual(frontTile(o), [11, 4]);
assert.deepEqual(frontTile(o, -1), [9, 4]);
assert.deepEqual(sideTiles(o), [[10, 3], [10, 5]], 'left hand, right hand');
assert.deepEqual(alliesAround(h.b, o, [[0, 1]]).map((a) => a.defId), ['t_n'], 'relative [0,1] = in front');
assert.deepEqual(alliesAround(h.b, o, [[1, 0]]).map((a) => a.defId), ['t_s'], 'relative [1,0] = the left hand');
assert.equal(alliesAround(h.b, o, N4).length, 2);
// tier1 freeTileAround: front first
assert.deepEqual(freeTileAround(h.b, o, (r, c) => !(r === 11 && c === 4 && h.b.unitAt(r, c))), [11, 3], 'front first (occupied by t_n)');
assert.deepEqual(freeTileAround(h.b, o, (r, c) => !(r === 11 && c === 4)), [11, 3], 'then front-left (its left hand is the lower column)');
checkInvariants(h.b);
});
test('spawnToken inherits the owner direction unless given; spawnDevice takes opts.dir', () => {
const rec = chessRec({ id: 't_o', profession: 'SUPPORT', rangeGrid: [[0, 0], [0, 1]], skill: null });
const tok = { id: 'tok_t', name: 'tok', position: 'ALL', stats: { maxHp: 100, atk: 0, def: 0, res: 0, aspd: 100, bat: 1, blockCnt: 0, cost: 0, respawnTime: 0 }, rangeGrid: [[0, 0], [0, 1]] };
const h = makeBattle({ defs: { chess: { t_o: rec } }, units: [{ chessId: 't_o', row: 10, col: 4, dir: 'DOWN' }] });
h.step();
const o = h.unit('t_o');
const a = h.b.spawnToken(o, 'tok_t', 11, 6, { def: tok });
assert.equal(a.dir, 'DOWN');
assert.deepEqual(tilesOf(a.rangeKeys), [[10, 6], [11, 6]]);
const b = h.b.spawnToken(o, 'tok_t', 12, 6, { def: tok, dir: 'LEFT' });
assert.equal(b.dir, 'LEFT');
const c = h.b.spawnToken(o, 'tok_t', 12, 7, { def: tok, facing: -1 });
assert.equal(c.dir, 'LEFT', 'legacy facing −1 ⇒ LEFT');
const d = h.b.spawnDevice('dev_t', 9, 7, { dir: 'UP' });
assert.equal(d.dir, 'UP');
checkInvariants(h.b);
});
test('the legacy facing setter maps ±1 to RIGHT / LEFT (content that still assigns facing)', () => {
const rec = chessRec({ id: 't_o', rangeGrid: [[0, 0]], skill: null });
const h = makeBattle({ defs: { chess: { t_o: rec } }, units: [{ chessId: 't_o', row: 10, col: 4, dir: 'UP' }] });
h.step();
const u = h.unit('t_o');
u.facing = -1;
assert.equal(u.dir, 'LEFT');
u.facing = 1;
assert.equal(u.dir, 'RIGHT');
assert.equal(K(10, 4), u.tileR * COLS + u.tileC);
});