feedback1 integrate: WV a knocked-out operator keeps its tile and redeploys there; 突袭 never lands on it
This commit is contained in:
commit
f213347ff2
21 files changed
+619
-96
No files matched your search
@@ -307,7 +307,7 @@ test('突袭: idle member relocates next to a ground enemy (SP kept) with ATK/HP
|
||||
checkInvariants(h2.b);
|
||||
});
|
||||
|
||||
test('突袭: the jump is a redeployment — deploy fires (部署时 effects), full HP, SP kept; later redeploys use the board tile', () => {
|
||||
test('突袭: the jump is a redeployment — deploy fires (部署时 effects), full HP, SP kept; knocked out there, it comes back there', () => {
|
||||
const bb = bondBb('raidShip');
|
||||
const defs = { chess: { r_m: chessRec({ id: 'r_m', bonds: ['raidShip'], skill: { spCost: 40, initSp: 0, duration: 10 } }) }, enemies: DUMMY };
|
||||
let spAtJump = null;
|
||||
@@ -329,10 +329,15 @@ test('突袭: the jump is a redeployment — deploy fires (部署时 effects), f
|
||||
close(u.hp, u.s.maxHp, 'fresh deployment: full HP');
|
||||
close(u.s.maxHp, 2000 * (1 + bb.base_max_hp), 'raid HP bonus');
|
||||
assert.equal(h.result?.().perPlayer?.p1?.deaths ?? 0, 0, 'not counted as a death');
|
||||
// knocked out later: the auto-redeploy goes back to its board tile, without the raid bonus
|
||||
// knocked out later: it lies on its landing tile and comes back there ("原地留下一个“倒地干员”…满足再部署条件时…
|
||||
// 自动部署至该位置", PRTS 卫戍协议/帮助 — player report F5 after 0.1.0), without the raid bonus; its home stays the
|
||||
// board tile
|
||||
const landed = [u.tileR, u.tileC];
|
||||
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
|
||||
assert.deepEqual([u.tileR, u.tileC], landed, 'lies where it fell');
|
||||
h.b.redeploy(u);
|
||||
assert.deepEqual([u.tileR, u.tileC], [12, 3], 'home tile unchanged');
|
||||
assert.deepEqual([u.tileR, u.tileC], landed, 'redeployed where it lay');
|
||||
assert.deepEqual([u.homeR, u.homeC], [12, 3], 'home tile unchanged');
|
||||
close(u.s.maxHp, 2000, 'bonus gone after leaving the field');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
@@ -301,11 +301,12 @@ test('user playtest #4 (DESIGN §18): picking by tile, timers, 机变 two taps,
|
||||
assert.deepEqual(ELEMENT_ORDER.slice(0, 4), ['neural', 'erosion', 'burn', 'apoptosis']);
|
||||
assert.deepEqual({ ...DOWN_STATE }, { COUNTING: 0, WAIT_DP: 1, WAIT_TILE: 2 });
|
||||
assert.match(DESIGN, /\*\*爆发冷却\*\*/);
|
||||
assert.match(DESIGN, /`down: \[\[id, respawnAt \(game s\), respawnTime \(s\), state\]\]`/);
|
||||
// player report F5 after 0.1.0: the entries carry the tile the operator lies on (DESIGN §8.2 follows at integration)
|
||||
assert.match(DESIGN, /`down: \[\[id, respawnAt \(game s\), respawnTime \(s\), state(, row, col)?\]\]`/);
|
||||
assert.match(SIM, /`burstLocked\(unit\)` in damage\.js/);
|
||||
assert.match(SIM, /损伤抵抗 = the target's data `epResistance`/);
|
||||
assert.ok(!/800 phys; no lock/.test(SIM), 'SIM §3: operators\' 侵蚀 locks too');
|
||||
assert.match(SIM, /`down: \[\[id, respawnAt, respawnTime, state\]\]`/);
|
||||
assert.match(SIM, /`down: \[\[id, respawnAt, respawnTime, state, row, col\]\]`/);
|
||||
// #5 / #11 / #12 data rules
|
||||
const special = DATA.items.chess_item_4_09_e_a;
|
||||
assert.ok(special.shopExcluded && special.shopExcludedBy, '灼燃维式重锤 is never sold');
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// Player report F5 after the 0.1.0 release in headless Chrome through the render demo (public/dev/render-demo.html,
|
||||
// real Spine models): the real sim's frames (Battle.snapshot / drainEvents, fed like the client runner's local feed) of
|
||||
// an operator that jumped onto a teammate's home tile (a 突袭 landing) and was knocked out there. Officially it goes back
|
||||
// to its own home ("若干员被击倒的位置为其他干员或召唤物的初始位置,则在被击倒后,尝试返回其自身的初始位置", PRTS
|
||||
// 卫戍协议/帮助): the knocked-down model and its ring are drawn on its own home (b.snap `down` row / col), a press there
|
||||
// selects it, the teammate's home stays empty, and it redeploys on that tile.
|
||||
//
|
||||
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/feedback1f-down-tile.browser.test.js
|
||||
// Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/feedback1f-down-*.png.
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { makeBattle } from '../helpers/battleHarness.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const OUT = path.join(ROOT, 'test/e2e/out');
|
||||
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
|
||||
const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and downloaded assets)';
|
||||
|
||||
describe('a knocked-out operator is drawn on the tile it lies on (real sim frames, headless Chrome)', { skip }, () => {
|
||||
let srv, browser;
|
||||
before(async () => {
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-first-run'] });
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
});
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
await srv?.close();
|
||||
});
|
||||
|
||||
test('fell on a teammate\'s home → drawn, picked and redeployed on its own home', async () => {
|
||||
// 宴 (home 12,3) and 休谟斯 (home 10,3), real chess on 战场#01; no enemies, the battle keeps running
|
||||
const h = makeBattle({ stageId: 'act2autochess_m01', units: [{ chessId: 'chess_char_1_18_a', row: 12, col: 3 }, { chessId: 'chess_char_2_09_a', row: 10, col: 3 }], autoFinish: false, timeLimit: 300 });
|
||||
const b = h.b;
|
||||
b.step();
|
||||
const A = h.unit('chess_char_1_18_a'), B = h.unit('chess_char_2_09_a');
|
||||
const page = await browser.newPage();
|
||||
const problems = [];
|
||||
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
page.on('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); });
|
||||
const feed = async (n) => {
|
||||
for (let i = 0; i < n; i++) {
|
||||
b.step();
|
||||
const ev = b.drainEvents();
|
||||
await page.evaluate((snap, ev) => {
|
||||
const v = window.__demo.view;
|
||||
if (ev.length) v.pushEvents({ t: 'b.ev', fieldId: snap.fieldId, gt: snap.t, ev });
|
||||
v.pushSnapshot({ t: 'b.snap', gt: snap.t, ...snap });
|
||||
return new Promise((r) => requestAnimationFrame(() => r()));
|
||||
}, b.snapshot(), ev);
|
||||
}
|
||||
};
|
||||
const viewOf = (id) => page.evaluate((id) => { const x = window.__demo.view.debug.views.get(id); return x ? { x: x.x, y: x.y, alive: x.alive, down: x.down ? x.down.state : null, ring: !!x._downRing?.root?.visible } : null; }, id);
|
||||
const pickAt = (r, c) => page.evaluate((r, c) => {
|
||||
const v = window.__demo.view, t = v.tileScreen(r, c), rc = v.debug.app.view.getBoundingClientRect();
|
||||
return v.debug.pick.battleUnitAt(t.x - rc.left, t.y - rc.top)?.id ?? null;
|
||||
}, r, c);
|
||||
try {
|
||||
await page.setViewport({ width: 1280, height: 720 });
|
||||
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`);
|
||||
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
|
||||
const meta = { ...b.fieldMeta(), fieldId: b.fieldId };
|
||||
await page.evaluate((meta) => {
|
||||
const v = window.__demo.view;
|
||||
v.enterBattle(meta);
|
||||
v.setCamera('normal', { rect: meta.rect, side: 'L', instant: true });
|
||||
v.setLocalFeed({ on: true, speed: 2 });
|
||||
}, meta);
|
||||
await feed(20);
|
||||
await new Promise((r) => setTimeout(r, 1500)); // Spine models load
|
||||
// A away from its home (11,6), B lands on A's home (12,3) — a 突袭-like redeployment — and is knocked out there
|
||||
b.retreat(A, { reason: 'raid' });
|
||||
assert.ok(b.redeploy(A, { free: true, tile: [11, 6] }));
|
||||
b.retreat(B, { reason: 'raid' });
|
||||
assert.ok(b.redeploy(B, { free: true, tile: [12, 3] }));
|
||||
await feed(30);
|
||||
assert.deepEqual(await viewOf(B.id).then((v) => [v.x, v.y]), [3, 12], 'B stands on A\'s home');
|
||||
b.kill(B);
|
||||
await feed(90);
|
||||
await new Promise((r) => setTimeout(r, 1200)); // the Die clip runs out (its last frame stays)
|
||||
await feed(10);
|
||||
const vb = await viewOf(B.id);
|
||||
await page.screenshot({ path: path.join(OUT, 'feedback1f-down-home.png') });
|
||||
assert.equal(vb.alive, false);
|
||||
assert.equal(vb.down, 0, 'knocked down, counting');
|
||||
assert.ok(vb.ring, 'its redeploy ring');
|
||||
assert.deepEqual([vb.x, vb.y], [3, 10], 'drawn on its own home (10,3), not on A\'s (12,3)');
|
||||
assert.equal(await pickAt(10, 3), B.id, 'a press on its own home selects it');
|
||||
assert.equal(await pickAt(12, 3), null, 'A\'s home is empty');
|
||||
// its timer runs out (休谟斯 70 s at 2×): it redeploys on its own home
|
||||
while (!B.alive && b.time < 200) b.step();
|
||||
assert.ok(B.alive, 'redeployed');
|
||||
assert.deepEqual([B.tileR, B.tileC], [10, 3]);
|
||||
await feed(30);
|
||||
const back = await viewOf(B.id);
|
||||
await page.screenshot({ path: path.join(OUT, 'feedback1f-down-redeployed.png') });
|
||||
assert.equal(back.alive, true);
|
||||
assert.equal(back.down, null);
|
||||
assert.deepEqual([back.x, back.y], [3, 10]);
|
||||
assert.deepEqual(problems, []);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// Player report F5 after the 0.1.0 release, client side: b.snap `down` entries carry the tile a knocked-out operator
|
||||
// lies on ([id, respawnAt, respawnTime, state, row, col] — where it fell, or its own home when it fell on another board
|
||||
// piece's home: PRTS 卫戍协议/帮助 "若干员被击倒的位置为其他干员或召唤物的初始位置,则在被击倒后,尝试返回其自身的初始
|
||||
// 位置"; sim Battle._layBody). render/interp.js keeps them and render/units.js draws the knocked-down view on that tile
|
||||
// (headless fake PIXI, test/render/fakepixi.js); older 4-element entries keep the view where it is.
|
||||
import { test, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
|
||||
import { presetCamera } from '../../public/js/render/projection.js';
|
||||
import { SnapshotBuffer, normalizeSnapshot } from '../../public/js/render/interp.js';
|
||||
import { makeBattle, chessRec } from '../helpers/battleHarness.js';
|
||||
|
||||
let fake, UnitView, DOWN_STATE, groundZ;
|
||||
before(async () => {
|
||||
fake = installFakePixi();
|
||||
({ UnitView, DOWN_STATE, groundZ } = await import('../../public/js/render/units.js'));
|
||||
});
|
||||
after(() => fake.restore());
|
||||
|
||||
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
|
||||
const view = (info = {}) => new UnitView(fakeViewCtx(fake.P, { cam }), { id: 1, side: 'ally', kind: 'chess', defId: 'char_x', tier: 3, x: 5, y: 10, maxHp: 1000, dir: 'RIGHT', ...info });
|
||||
const frames = (v, n) => { for (let i = 0; i < n; i++) v.update(1 / 60, cam(), i / 60); };
|
||||
|
||||
test('interp keeps the body tile of a `down` entry (integers only); a 4-element entry stays as it was', () => {
|
||||
const s = normalizeSnapshot({ t: 2, units: [], down: [[7, 20, 18, 0, 9, 8], [8, 21, 18, 0], [9, 22, 18, 0, 9.5, 'x']] });
|
||||
assert.deepEqual(s.down, [[7, 20, 18, 0, 9, 8], [8, 21, 18, 0], [9, 22, 18, 0]]);
|
||||
const buf = new SnapshotBuffer({ delay: 0 });
|
||||
buf.push({ t: 1, units: [], down: [[7, 20, 18, 0, 10, 3]] }, 0);
|
||||
assert.deepEqual(buf.downAt(1), [[7, 20, 18, 0, 10, 3]]);
|
||||
});
|
||||
|
||||
test('the knocked-down view is drawn on the tile the entry names: a body back on its home moves there', () => {
|
||||
const v = view({ x: 6, y: 12 }); // fell on another piece's home (12,6)
|
||||
frames(v, 2);
|
||||
v.setDown([1, 30, 20, DOWN_STATE.COUNTING, 10, 3], 10); // it lies on its own home (10,3)
|
||||
assert.equal(v.alive, false);
|
||||
assert.deepEqual([v.x, v.y], [3, 10]);
|
||||
frames(v, 30);
|
||||
assert.equal(v.z, groundZ(v.ctx, 3, 10), 'on that tile\'s ground');
|
||||
assert.ok(v._downRing.root.visible, 'its ring follows');
|
||||
// a 4-element entry (an older feed) leaves it where it is
|
||||
const w = view({ id: 2, x: 4, y: 11 });
|
||||
frames(w, 2);
|
||||
w.setDown([2, 30, 20, DOWN_STATE.COUNTING], 10);
|
||||
assert.deepEqual([w.x, w.y], [4, 11]);
|
||||
});
|
||||
|
||||
test('end to end: the sim\'s snapshot names the tile the body lies on, the view follows it', () => {
|
||||
const op = (id) => chessRec({ id, profession: 'WARRIOR', skill: null });
|
||||
const h = makeBattle({ defs: { chess: { e_a: op('e_a'), e_b: op('e_b') } }, units: [{ chessId: 'e_a', row: 12, col: 3 }, { chessId: 'e_b', row: 10, col: 3 }], content: 'none', autoFinish: false });
|
||||
h.step();
|
||||
const b = h.b, A = h.unit('e_a'), B = h.unit('e_b');
|
||||
b.retreat(A, { reason: 'raid' });
|
||||
b.redeploy(A, { free: true, tile: [11, 6] });
|
||||
b.retreat(B, { reason: 'raid' });
|
||||
b.redeploy(B, { free: true, tile: [12, 3] }); // on A's home
|
||||
const v = view({ id: B.id, x: B.x, y: B.y });
|
||||
frames(v, 2);
|
||||
b.kill(B);
|
||||
const d = normalizeSnapshot(b.snapshot()).down.find((e) => e[0] === B.id);
|
||||
assert.deepEqual(d.slice(4), [10, 3], 'back on its own home');
|
||||
v.setDown(d, b.time);
|
||||
assert.deepEqual([v.x, v.y], [3, 10]);
|
||||
});
|
||||
+14
-12
@@ -1,6 +1,6 @@
|
||||
// test/sim/downelem.test.js — user playtest #4 items 8 and 9, sim side (server/sim/Battle.js snapshot / fieldMeta,
|
||||
// damage.js elementView): b.snap `down` lists the knocked-out operators waiting to redeploy on their own tile (the
|
||||
// client keeps them on the field knocked down with a redeploy countdown) and `elem` the element gauge each unit shows
|
||||
// damage.js elementView): b.snap `down` lists the knocked-out operators waiting to redeploy on the tile they lie on (the
|
||||
// client keeps them there knocked down with a redeploy countdown) and `elem` the element gauge each unit shows
|
||||
// (PRTS 元素: the fullest gauge, its 爆发冷却) — entries, states, and what is left out. User playtest #5 item 2: an
|
||||
// operator entering a battle knocked out (联防 carryState `down`, constants.js FORCED_EXIT) is down the same way.
|
||||
|
||||
@@ -16,7 +16,7 @@ const op = (id, stats = {}) => chessRec({ id, profession: 'WARRIOR', stats: { at
|
||||
const TOKEN = { name: '测试召唤物', stats: { maxHp: 100, atk: 0, def: 0, res: 0, blockCnt: 0, cost: 0, respawnTime: 0 } };
|
||||
const downOf = (h, id) => (h.snapshot().down || []).find((d) => d[0] === id) || null;
|
||||
|
||||
test('a knocked-out operator is `down` until it redeploys: its timer, then waiting for its tile / the DP; then back', () => {
|
||||
test('a knocked-out operator is `down` until it redeploys: its timer, then waiting for the DP (no ally takes its tile); then back', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_a: op('t_a'), t_b: op('t_b') } }, units: [{ chessId: 't_a', row: 9, col: 5 }, { chessId: 't_b', row: 10, col: 5 }],
|
||||
content: 'none', autoFinish: false, timeLimit: 120, flags: { dpInit: 0, dpPerSec: 1, dpMax: 99 },
|
||||
@@ -31,18 +31,20 @@ test('a knocked-out operator is `down` until it redeploys: its timer, then waiti
|
||||
approx(d[1], Math.round((t0 + 6) * 100) / 100, 0.011);
|
||||
approx(d[2], 6, 0.011);
|
||||
assert.equal(d[3], DOWN_STATE.COUNTING);
|
||||
assert.deepEqual(d.slice(4), [9, 5], 'the tile it lies on (player report F5 after 0.1.0)');
|
||||
assert.ok(h.b.fieldMeta().units.some((u) => u.id === a.id), 'a client joining now gets its UnitInfo');
|
||||
h.run(6.5);
|
||||
assert.equal(downOf(h, a.id)[3], DOWN_STATE.WAIT_DP, 'timer done, 6.5 DP < cost 12');
|
||||
const tok = h.b.spawnToken('p1', 'token_test', 9, 5, { def: TOKEN });
|
||||
assert.ok(tok && tok.alive, 'a summon stands on its tile');
|
||||
assert.equal(downOf(h, a.id)[3], DOWN_STATE.WAIT_TILE);
|
||||
h.run(7);
|
||||
assert.ok(!a.alive, 'no redeploy while its tile is taken');
|
||||
assert.equal(downOf(h, a.id)[3], DOWN_STATE.WAIT_TILE);
|
||||
h.b.retreat(tok, { reason: 'expired', permanent: true });
|
||||
h.step();
|
||||
assert.ok(a.alive && a.deployed, 'redeployed once the tile is free (DP 13.5 ≥ 12)');
|
||||
// "倒地干员所在地块视为可部署,但所有我方单位在此处的部署行为将被阻止" (PRTS 卫戍协议/帮助): WAIT_TILE (its tile taken)
|
||||
// is a safeguard only — no summon (or any ally) is deployed on the tile it lies on
|
||||
assert.equal(h.b.spawnToken('p1', 'token_test', 9, 5, { def: TOKEN }), null, 'no summon on its tile');
|
||||
assert.equal(h.b.isReservedTile(9, 5), true);
|
||||
h.run(5);
|
||||
assert.ok(!a.alive, 'no redeploy while the DP is short (11.5 < 12)');
|
||||
assert.equal(downOf(h, a.id)[3], DOWN_STATE.WAIT_DP);
|
||||
h.run(1);
|
||||
assert.ok(a.alive && a.deployed, 'redeployed on its tile once the DP is there (12.5 ≥ 12)');
|
||||
assert.deepEqual([a.tileR, a.tileC], [9, 5]);
|
||||
assert.equal(downOf(h, a.id), null);
|
||||
assert.equal(h.snapshot().down, undefined);
|
||||
assert.ok(h.eventsOf('deploy').some((e) => e[1] === a.id));
|
||||
|
||||
@@ -369,7 +369,7 @@ test('skill end: onEnd runs with the skill mods and range still applied, then th
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 9) redeploy { free, tile, keepSp }
|
||||
|
||||
test('redeploy: { tile } lands on another tile (home unchanged, later redeploys at home); { keepSp } restores SP before deploy fires', () => {
|
||||
test('redeploy: { tile } lands on another tile (home unchanged; knocked out there, it comes back there); { keepSp } restores SP before deploy fires', () => {
|
||||
let atDeploy = null;
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_gd: guard({ skill: { spCost: 10, initSp: 2 } }), t_x: guard({ id: 't_x' }) } },
|
||||
@@ -391,7 +391,8 @@ test('redeploy: { tile } lands on another tile (home unchanged, later redeploys
|
||||
assert.equal(h.b.unitAt(12, 8), u);
|
||||
approx(atDeploy, sp, 1e-9, 'SP restored before the deploy hook');
|
||||
approx(u.hp, u.s.maxHp);
|
||||
// plain redeploy: initSp; knocked out later → the auto-redeploy goes home
|
||||
// plain redeploy: initSp; knocked out there later → the auto-redeploy comes back on that tile (PRTS 卫戍协议/帮助
|
||||
// "…原地留下一个“倒地干员”…满足再部署条件时…自动部署至该位置"; player report F5 after 0.1.0); a retreat → home
|
||||
h.b.retreat(u);
|
||||
h.b.redeploy(u, { tile: [12, 8] });
|
||||
approx(u.skill.sp, 2, 1e-9, 'without keepSp: initial SP');
|
||||
@@ -399,7 +400,10 @@ test('redeploy: { tile } lands on another tile (home unchanged, later redeploys
|
||||
p.dp = 50;
|
||||
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
|
||||
assert.ok(h.runUntil(() => u.alive, u.base.respawnTime + 1));
|
||||
assert.deepEqual([u.tileR, u.tileC], [10, 4], 'auto-redeploy on the home tile');
|
||||
assert.deepEqual([u.tileR, u.tileC], [12, 8], 'auto-redeploy on the tile it lay on');
|
||||
h.b.retreat(u);
|
||||
assert.equal(h.b.redeploy(u), true);
|
||||
assert.deepEqual([u.tileR, u.tileC], [10, 4], 'a retreated unit comes back on its home tile');
|
||||
// paid redeploy on a tile
|
||||
h.b.retreat(u);
|
||||
const dp = p.dp;
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// Player report F5 after the 0.1.0 release: "突袭盟约的干员会移动到濒死干员的尸体上" — a 突袭 member jumping
|
||||
// ("保留技力立即再部署至一名地面敌人周围") landed on the tile of a knocked-out (濒死) operator lying there, and an
|
||||
// operator's timed redeploy came back on its home tile although another operator lay knocked out on it.
|
||||
// Official (PRTS 卫戍协议/帮助 §作战阶段 单位部署):
|
||||
// "干员退场后,将返回隐藏的待部署区,并原地留下一个“倒地干员”以供查看信息,满足再部署条件时,移除场上的该倒地干员并
|
||||
// 自动部署至该位置。"
|
||||
// "倒地干员所在地块视为可部署,但所有我方单位在此处的部署行为将被阻止。"
|
||||
// "若干员被击倒的位置为其他干员或召唤物的初始位置,则在被击倒后,尝试返回其自身的初始位置。"
|
||||
// So a knocked-out operator lies where it fell (on its own home tile when it fell on another board piece's home and its
|
||||
// own is free), comes back on that tile, and no ally deployment (a 突袭 landing, a redeploy, a summon, a device, a
|
||||
// move) takes it meanwhile.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { makeMatch } from '../match/harness.js';
|
||||
import { buildBattleSpec, createBattleFromSpec } from '../../server/sim/spec.js';
|
||||
import { inRange } from '../../server/sim/content/support/index.js';
|
||||
|
||||
const RAID = { raidShip: { count: 2, active: true, tier: 1, layers: 0 } };
|
||||
const raider = (id) => chessRec({ id, bonds: ['raidShip'], profession: 'WARRIOR', skill: null });
|
||||
const plain = (id) => chessRec({ id, bonds: [], profession: 'WARRIOR', skill: null });
|
||||
const tileOf = (u) => [u.tileR, u.tileC];
|
||||
const kill = (b, u) => b.dealDamage(null, u, { amount: 1e9, type: 'true' });
|
||||
/** The b.snap `down` entry of `u` ([id, respawnAt, respawnTime, state, row, col]) or null. */
|
||||
const downEntry = (b, u) => (b.snapshot().down || []).find((d) => d[0] === u.id) ?? null;
|
||||
/** Where the client draws the knocked-out `u`: the tile its `down` entry names (a 4-element entry: where it fell). */
|
||||
const bodyOf = (b, u) => { const d = downEntry(b, u); return d && d.length >= 6 ? d.slice(4) : tileOf(u); };
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// the player scenario on the real product path
|
||||
|
||||
/**
|
||||
* The normal field of a solo player at PREP of `round` (seed `seed`, stage `stageId`) with exactly `pieces`
|
||||
* ([chessId, row, col]) on the board: the real Match, PlayerState, chess data and round wave, built like
|
||||
* Match._ccField (the BattleSpec the browser and the server both build their Battle from).
|
||||
*/
|
||||
function realField({ pieces, round, seed, stageId }) {
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed }).start();
|
||||
const ps = h.ps('p_0');
|
||||
assert.ok(h.drive(() => { if (ps.lp > 0 && ps.lp < 300) ps.lp = 300; return h.m.phase === 'PREP' && h.m.round === round; }), `PREP R${round}`);
|
||||
h.setStage(stageId);
|
||||
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
|
||||
ps.board.clear();
|
||||
ps.hand.fill(null);
|
||||
for (const [id, r, c] of pieces) ps.board.set(`${r},${c}`, ps.newPiece('chess', id));
|
||||
ps.recompute();
|
||||
const spec = buildBattleSpec({ ...h.m._normalOpts(ps), battleId: 'feedback1f', fieldId: 'n:p_0', kind: 'normal', content: h.m.battleContent });
|
||||
return createBattleFromSpec(spec, h.m.ds, { quiet: true, recordEvents: false });
|
||||
}
|
||||
|
||||
// Six 突袭 members (宴 休谟斯 斯卡蒂 瑕光 伊内丝 缄默德克萨斯 史尔特尔 仇白 耀骑士临光 — real chess) on 战场#07 / 战场#08 (下半)
|
||||
// against the real round waves: on 0.1.0 each run put a member on a knocked-out operator — a 突袭 landing on the body
|
||||
// of a member that fell after its own jump, or a member's timed redeploy on its home tile where a teammate lay that
|
||||
// had jumped there and fallen.
|
||||
const RUNS = [
|
||||
{ stageId: 'act2autochess_m03', round: 12, seed: 4, pieces: [['chess_char_4_04_a', 12, 2], ['chess_char_4_16_a', 10, 2], ['chess_char_5_07_a', 12, 5], ['chess_char_6_15_a', 10, 5], ['chess_char_6_17_a', 9, 4], ['chess_char_1_18_a', 11, 6]] },
|
||||
{ stageId: 'act2autochess_m04', round: 6, seed: 3, pieces: [['chess_char_3_12_a', 12, 3], ['chess_char_4_04_a', 11, 3], ['chess_char_4_16_a', 10, 3], ['chess_char_5_07_a', 9, 3], ['chess_char_6_15_a', 12, 4], ['chess_char_6_17_a', 11, 4]] },
|
||||
{ stageId: 'act2autochess_m04', round: 12, seed: 1, pieces: [['chess_char_2_09_a', 12, 3], ['chess_char_3_05_a', 11, 3], ['chess_char_3_12_a', 10, 3], ['chess_char_4_04_a', 9, 3], ['chess_char_4_16_a', 12, 4], ['chess_char_5_07_a', 11, 4]] },
|
||||
{ stageId: 'act2autochess_m04', round: 12, seed: 1, pieces: [['chess_char_2_09_a', 12, 2], ['chess_char_3_05_a', 10, 2], ['chess_char_3_12_a', 12, 5], ['chess_char_4_04_a', 10, 5], ['chess_char_4_16_a', 9, 4], ['chess_char_5_07_a', 11, 6]] },
|
||||
{ stageId: 'act2autochess_m04', round: 12, seed: 4, pieces: [['chess_char_4_04_a', 12, 3], ['chess_char_4_16_a', 11, 3], ['chess_char_5_07_a', 10, 3], ['chess_char_6_15_a', 9, 3], ['chess_char_6_17_a', 12, 4], ['chess_char_1_18_a', 11, 4]] },
|
||||
];
|
||||
|
||||
test('F5 real match: no 突袭 landing, redeploy or summon ever takes the tile of a knocked-out operator; it comes back where it lies', () => {
|
||||
let jumps = 0, downs = 0, comebacks = 0;
|
||||
const bad = [];
|
||||
for (const run of RUNS) {
|
||||
const b = realField(run);
|
||||
const tag = `${run.stageId} R${run.round} seed ${run.seed}`;
|
||||
const lay = new Map(); // knocked-out operator → the tile it lies on
|
||||
b.on('death', (c) => {
|
||||
const u = c.unit;
|
||||
if (c.reason !== 'killed' || u.kind !== 'op' || !b.isDown(u)) return;
|
||||
downs++;
|
||||
const body = bodyOf(b, u);
|
||||
lay.set(u, body);
|
||||
// it lies where it fell — on its own home when it fell on another board piece's home and its own is free
|
||||
const other = b.allyUnits.find((a) => a !== u && a.uid != null && !a.removed && a.homeR === u.tileR && a.homeC === u.tileC);
|
||||
const homeFree = !b.allyUnits.some((a) => a !== u && ((a.alive && a.tileR === u.homeR && a.tileC === u.homeC) || (!a.alive && !a.removed && b.restTile(a).join() === `${u.homeR},${u.homeC}`)));
|
||||
if (other && (u.tileR !== u.homeR || u.tileC !== u.homeC) && homeFree && body.join() !== `${u.homeR},${u.homeC}`) bad.push(`${tag} t=${b.time.toFixed(1)}: ${u.name} lies on ${other.name}'s home ${body} (its own ${u.homeR},${u.homeC} is free)`);
|
||||
else if (!other && body.join() !== tileOf(u).join()) bad.push(`${tag} t=${b.time.toFixed(1)}: ${u.name} fell on ${tileOf(u)} but lies on ${body}`);
|
||||
});
|
||||
b.on('deploy', (c) => {
|
||||
const u = c.unit;
|
||||
if (c.initial || u.side !== 'ally') return;
|
||||
if (u.kind === 'op' && (u.tileR !== u.homeR || u.tileC !== u.homeC) && !lay.has(u)) jumps++;
|
||||
for (const [d, t] of lay) {
|
||||
if (d === u || d.alive) continue;
|
||||
if (t[0] === u.tileR && t[1] === u.tileC) bad.push(`${tag} t=${b.time.toFixed(1)}: ${u.name} deployed on ${t} where ${d.name} lies knocked out`);
|
||||
}
|
||||
if (lay.has(u)) {
|
||||
const t = lay.get(u);
|
||||
lay.delete(u);
|
||||
comebacks++;
|
||||
if (t[0] !== u.tileR || t[1] !== u.tileC) bad.push(`${tag} t=${b.time.toFixed(1)}: ${u.name} lay on ${t} but redeployed on ${tileOf(u)}`);
|
||||
}
|
||||
});
|
||||
while (!b.finished && b.time < 400) b.step();
|
||||
}
|
||||
assert.deepEqual(bad, []);
|
||||
// the scenario: members jumped, fell and came back
|
||||
assert.ok(jumps >= 20 && downs >= 10 && comebacks >= 5, `jumps ${jumps}, knocked out ${downs}, redeploys ${comebacks}`);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// the rule, step by step (synthetic chess, flat stage)
|
||||
|
||||
test('F5 突袭: a member never lands on a knocked-out member\'s body; the fallen member comes back on the tile it lies on', () => {
|
||||
const defs = {
|
||||
chess: { r_a: raider('r_a'), r_b: raider('r_b') },
|
||||
enemies: { enemy_f5_dummy: enemyRec({ key: 'enemy_f5_dummy', hp: 1e7, speed: 0 }), enemy_f5_bait: enemyRec({ key: 'enemy_f5_bait', hp: 2500, speed: 0 }) },
|
||||
};
|
||||
// A idles from the start; B kills the bait in front of it first (~5 s), so it becomes idle later than A
|
||||
const h = makeBattle({
|
||||
defs, bonds: RAID, units: [{ chessId: 'r_a', row: 12, col: 3 }, { chessId: 'r_b', row: 10, col: 3 }],
|
||||
enemies: [{ key: 'enemy_f5_dummy', pos: [9, 8] }, { key: 'enemy_f5_bait', pos: [10, 4] }],
|
||||
});
|
||||
const A = h.unit('r_a'), B = h.unit('r_b');
|
||||
assert.ok(h.runUntil(() => A.tileR !== 12 || A.tileC !== 3, 12), 'A jumped');
|
||||
const L = tileOf(A);
|
||||
assert.deepEqual([B.tileR, B.tileC], [10, 3], 'B still busy / idle at home');
|
||||
kill(h.b, A);
|
||||
assert.ok(h.b.isDown(A), 'A knocked out');
|
||||
assert.deepEqual(downEntry(h.b, A).slice(4), L, 'A lies where it fell: b.snap down carries that tile');
|
||||
assert.equal(h.b.isReservedTile(L[0], L[1]), true, 'its tile is reserved for it');
|
||||
assert.equal(h.b.isReservedTile(12, 3), false, 'its home is not (only the body\'s tile blocks)');
|
||||
assert.ok(h.runUntil(() => B.tileR !== 10 || B.tileC !== 3, 20), 'B jumped');
|
||||
assert.notDeepEqual(tileOf(B), L, 'B did not land on A\'s body');
|
||||
assert.ok(inRange(B, h.enemy('enemy_f5_dummy')), 'B still lands next to the enemy, with it in range');
|
||||
// "满足再部署条件时,移除场上的该倒地干员并自动部署至该位置"
|
||||
assert.ok(h.runUntil(() => A.alive, 30), 'A redeployed after its timer');
|
||||
assert.deepEqual(tileOf(A), L, 'on the tile it lay on');
|
||||
assert.deepEqual([A.homeR, A.homeC], [12, 3], 'its home (the board tile) is unchanged');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('F5 engine: every ally placement refuses the tile a knocked-out operator lies on — redeploy, summon, device, move', () => {
|
||||
const defs = { chess: { d_a: plain('d_a'), d_b: plain('d_b'), d_c: plain('d_c') } };
|
||||
const h = makeBattle({ defs, units: [{ chessId: 'd_a', row: 12, col: 3 }, { chessId: 'd_b', row: 10, col: 3 }, { chessId: 'd_c', row: 11, col: 5 }] });
|
||||
h.step();
|
||||
const b = h.b, A = h.unit('d_a'), B = h.unit('d_b'), C = h.unit('d_c');
|
||||
// A moved away like a 突袭 jump, then knocked out there
|
||||
b.retreat(A, { reason: 'raid' });
|
||||
assert.ok(b.redeploy(A, { free: true, tile: [9, 8] }));
|
||||
kill(b, A);
|
||||
assert.ok(b.isDown(A));
|
||||
assert.deepEqual(tileOf(A), [9, 8]);
|
||||
assert.equal(b.isReservedTile(9, 8), true);
|
||||
b.retreat(B, { reason: 'raid' });
|
||||
assert.equal(b.redeploy(B, { free: true, tile: [9, 8] }), false, 'a redeploy onto the body is refused');
|
||||
assert.ok(b.redeploy(B, { free: true }), 'B back home');
|
||||
assert.deepEqual(tileOf(B), [10, 3]);
|
||||
assert.equal(b.spawnToken('p1', 'token_10028_vigil_wolf', 9, 8), null, 'a summon onto the body is refused');
|
||||
assert.equal(b.spawnDevice('f5_device', 9, 8, { hp: 100 }), null, 'a device onto the body is refused');
|
||||
assert.equal(b.relocate(C, 9, 8), false, 'moving an operator onto the body is refused');
|
||||
assert.deepEqual(tileOf(C), [11, 5]);
|
||||
// a tactician at (9,6) whose range holds (9,8) — the nearer tile — and (9,9)
|
||||
assert.equal(b.findTacticalPoint({ baseRangeKeys: [9 * 21 + 8, 9 * 21 + 9], tileR: 9, tileC: 6, dir: 'RIGHT' })?.join(), '9,9', 'no tactical point on the body');
|
||||
// its own redeploy comes back there ("自动部署至该位置")
|
||||
assert.ok(b.redeploy(A, { free: true }));
|
||||
assert.deepEqual(tileOf(A), [9, 8]);
|
||||
assert.equal(b.isReservedTile(9, 8), true, 'standing there now');
|
||||
checkInvariants(b);
|
||||
});
|
||||
|
||||
test('F5 "若干员被击倒的位置为其他干员或召唤物的初始位置…尝试返回其自身的初始位置": a body on another piece\'s home goes back to its own', () => {
|
||||
const defs = { chess: { h_a: plain('h_a'), h_b: plain('h_b') } };
|
||||
const setup = () => {
|
||||
const h = makeBattle({ defs, units: [{ chessId: 'h_a', row: 12, col: 3 }, { chessId: 'h_b', row: 10, col: 3 }] });
|
||||
h.step();
|
||||
const b = h.b, A = h.unit('h_a'), B = h.unit('h_b');
|
||||
b.retreat(A, { reason: 'raid' });
|
||||
assert.ok(b.redeploy(A, { free: true, tile: [11, 6] }), 'A away from its home');
|
||||
b.retreat(B, { reason: 'raid' });
|
||||
assert.ok(b.redeploy(B, { free: true, tile: [12, 3] }), 'B on A\'s home (free: A is away)');
|
||||
return { h, b, A, B };
|
||||
};
|
||||
{
|
||||
const { h, b, A, B } = setup();
|
||||
let fellAt = null;
|
||||
b.on('death', (c) => { if (c.unit === B) fellAt = [B.tileR, B.tileC]; });
|
||||
kill(b, B);
|
||||
assert.ok(b.isDown(B));
|
||||
assert.deepEqual(fellAt, [12, 3], 'the death handlers (被击倒时 effects) still see where it fell');
|
||||
assert.deepEqual(downEntry(b, B).slice(4), [10, 3], 'B lies on its own home, not on A\'s (b.snap down)');
|
||||
assert.deepEqual(b.restTile(B), [10, 3]);
|
||||
assert.equal(b.isReservedTile(12, 3), false, 'A\'s home stays free');
|
||||
assert.equal(b.isReservedTile(10, 3), true);
|
||||
// A knocked out too: it lies where it is and comes back there
|
||||
kill(b, A);
|
||||
assert.deepEqual(downEntry(b, A).slice(4), [11, 6], 'A fell on no other piece\'s home: it stays');
|
||||
assert.ok(h.runUntil(() => A.alive && B.alive, 40), 'both redeploy');
|
||||
assert.deepEqual(tileOf(B), [10, 3]);
|
||||
assert.deepEqual(tileOf(A), [11, 6]);
|
||||
checkInvariants(b);
|
||||
}
|
||||
{
|
||||
// its own home taken (A moved onto it): the body stays where it fell [ASSUMED: one attempt, at the knock-out]
|
||||
const { b, A, B } = setup();
|
||||
b.retreat(A, { reason: 'raid' });
|
||||
assert.ok(b.redeploy(A, { free: true, tile: [10, 3] }), 'A on B\'s home');
|
||||
kill(b, B);
|
||||
assert.deepEqual(downEntry(b, B).slice(4), [12, 3], 'B stays on the tile it fell on');
|
||||
assert.equal(b.isReservedTile(12, 3), true);
|
||||
checkInvariants(b);
|
||||
}
|
||||
});
|
||||
|
||||
test('F5 safeguard: the `down` state reports WAIT_TILE whenever _checkRedeploys holds a body back — a living unit or another body on its tile', async () => {
|
||||
const { DOWN_STATE } = await import('../../server/sim/constants.js');
|
||||
const op = (id) => chessRec({ id, profession: 'WARRIOR', stats: { atk: 0, maxHp: 1000, def: 0, blockCnt: 0, cost: 1, respawnTime: 2 }, skill: null });
|
||||
const h = makeBattle({
|
||||
defs: { chess: { s_a: op('s_a'), s_b: op('s_b') } }, units: [{ chessId: 's_a', row: 9, col: 5 }, { chessId: 's_b', row: 10, col: 5 }],
|
||||
content: 'none', autoFinish: false, timeLimit: 120, flags: { dpInit: 50, dpPerSec: 1, dpMax: 99 },
|
||||
});
|
||||
h.step();
|
||||
const b = h.b, A = h.unit('s_a'), B = h.unit('s_b');
|
||||
kill(b, A);
|
||||
kill(b, B);
|
||||
// unreachable through the engine (no ally deploys on a body tile; _layBody checks isReservedTile): force two bodies
|
||||
// onto one tile to check that the client's state and the redeploy check agree
|
||||
B.body = [9, 5];
|
||||
h.run(3);
|
||||
assert.ok(!A.alive && !B.alive, 'neither redeploys onto a tile another body lies on');
|
||||
assert.equal(downEntry(b, A)[3], DOWN_STATE.WAIT_TILE, 'A: its tile is taken (not COUNTING with the DP there)');
|
||||
assert.equal(downEntry(b, B)[3], DOWN_STATE.WAIT_TILE);
|
||||
B.body = [10, 5];
|
||||
assert.ok(h.runUntil(() => A.alive && B.alive, 5), 'both redeploy once the tiles are their own');
|
||||
assert.deepEqual(tileOf(A), [9, 5]);
|
||||
assert.deepEqual(tileOf(B), [10, 5]);
|
||||
checkInvariants(b);
|
||||
});
|
||||
@@ -340,12 +340,13 @@ test('helpers validate their inputs: spawnDevice / relocate / spawnToken / spawn
|
||||
assert.ok(g.baseRangeKeys.includes(10 * 21 + 6), 'initial (DEFAULT trigger) range follows the unit');
|
||||
b.kill(g);
|
||||
assert.equal(b.relocate(g, 12, 6), false, 'dead units stay put');
|
||||
b.redeploy(g);
|
||||
b.redeploy(g); // back on the tile it was knocked out on, (10,6) (PRTS 卫戍协议/帮助: 自动部署至该位置)
|
||||
// spawnToken on a busy tile (even with force) → null, no half-built token left behind
|
||||
const aliveBefore = b.allyUnits.filter((u) => u.alive).length;
|
||||
const hooks = hookCount(b);
|
||||
assert.equal(g.alive, true);
|
||||
assert.equal(b.spawnToken('p1', 'x_token', 9, 5, { def: { name: 't', stats: { maxHp: 100 } }, force: true }), null);
|
||||
assert.deepEqual([g.tileR, g.tileC], [10, 6]);
|
||||
assert.equal(b.spawnToken('p1', 'x_token', 10, 6, { def: { name: 't', stats: { maxHp: 100 } }, force: true }), null);
|
||||
assert.equal(b.allyUnits.filter((u) => u.alive).length, aliveBefore, 'no half-built token');
|
||||
assert.ok(hookCount(b) <= hooks);
|
||||
const t = b.spawnToken('p1', 'x_token', 12, 8, { def: { name: 't', stats: { maxHp: 100 } }, hp: NaN, stats: { atk: NaN, def: 50 } });
|
||||
|
||||
Reference in new issue
Block a user