Playtest #6 follow-up (v2.5.1): leader HP review — 直接乘算, one debuff instance, 999 layer cap, 限伤; elite to the board; 假想敌:胄 kit

- co-op leaders died within seconds: leader HP is the official data (40 values pinned); bond / strategy / item /
  机变 / 特质 "+X%" bonuses now add up (PRTS 直接乘算) instead of compounding; a same-named enemy debuff keeps one
  (strongest) instance per target (奥术 across a pair field); leader parts pass damage 1:1; fixed co-op pool (the user's call)
- official limits read from the client: each bond stops at 999 layers (MAX_GARRISON_STACK); a leader hit of 300000+ in
  boss / hidden battles is cancelled (MAX_BATTLE_DAMAGE, 限伤) — shared/constants.js switches, research 11
- a merge that consumes a deployed copy puts the elite on that tile and facing (user-confirmed); gold target highlight,
  promotion effect
- 假想敌:胄 per PRTS: one shell, shells within range, any drone death costs 2 % max HP, 剑/锤 sorties and 瘫痪
- DESIGN §20.10–§20.14; 2842 unit tests, all 28 browser suites green; Chrome = Node on boss fields
This commit is contained in:
sganggs committed 2026-10-02 04:19:28 +08:00
1 parent 6464e4b46a
commit 620f88c0d8
81 files changed
+4194 -447

No files matched your search

+37
View File
@@ -389,6 +389,43 @@ describe('render engine in headless Chrome', { skip }, () => {
assert.deepEqual(r.battle, [['UP', true, 1], ['LEFT', true, -1], ['RIGHT', false, 1]]);
});
test('a merge completed between two preps (band grant at ROUND_START, after a battle) still plays the promotion cue (QA 6b)', async () => {
const { page, problems } = await open('scene=prep&panel=0', 1600, 900);
await wait(1500);
const r = await page.evaluate(async () => {
const v = window.__demo.view, st = window.__demo.scene.state;
const rows = await (await fetch('/data/chess.json')).json();
const chess = rows.chess || rows;
const prev = JSON.parse(JSON.stringify(st));
const b0 = prev.board.find((p) => p.kind === 'chess' && chess[p.id] && !chess[p.id].isGolden && chess[p.id].goldenId);
if (!b0) return { skip: 'no normal chess on the demo board' };
// the other copy waits in the hand
const uids = [...prev.hand, ...prev.board, ...(prev.temp || [])].filter(Boolean).map((p) => p.uid);
const hu = Math.max(...uids) + 1;
prev.hand = [{ uid: hu, kind: 'chess', id: b0.id }, ...prev.hand.filter((p) => p && p.uid !== hu)].slice(0, 9);
v.setPrep(prev, { editable: true });
const n0 = v.debug.promotions.length;
// a battle rebuilds the scene; the merge's elite takes the deployed copy's tile at the next round start
v.enterBattle({ fieldId: 'x', kind: 'normal', units: [] });
const next = JSON.parse(JSON.stringify(prev));
next.hand = next.hand.filter((p) => p.uid !== hu);
const eu = hu + 1;
next.board = next.board.map((p) => (p.uid === b0.uid ? { uid: eu, kind: 'chess', id: chess[b0.id].goldenId, golden: true, row: b0.row, col: b0.col, dir: b0.dir } : p));
v.setPrep(next, { editable: true });
const p = v.debug.promotions.slice(n0);
// a plain round rebuild (no merge) cues nothing
v.enterBattle({ fieldId: 'y', kind: 'normal', units: [] });
v.setPrep(JSON.parse(JSON.stringify(next)), { editable: true });
return { p, after: v.debug.promotions.length - n0, row: b0.row, col: b0.col, eu };
});
await page.close();
assert.deepEqual(problems, []);
assert.equal(r.skip, undefined, 'the demo board has a normal operator');
assert.equal(r.p.length, 1, JSON.stringify(r.p));
assert.deepEqual([r.p[0].uid, r.p[0].area, r.p[0].row, r.p[0].col, r.p[0].copies], [r.eu, 'board', r.row, r.col, 2], 'the elite on the copy\'s tile, both copies');
assert.equal(r.after, 1, 'no cue for a rebuild without a merge');
});
test('drops outside the stage report client coords; a refused drop tweens back home', async () => {
const { page, problems } = await open('scene=prep&panel=0', 1600, 900);
await wait(2000);
+88
View File
@@ -0,0 +1,88 @@
// User playtest #6 follow-up — the promotion cue of a merge (render/promote.js, render/fx.js promote, render/app.js
// setPrep). A merge's elite appears on the board tile of the deployed copy it replaced (PRTS 卫戍协议/帮助 "若消耗已部署至
// 作战区的干员,则发送至作战区对应位置"), else on a bench slot; setPrep recognises it (a new elite uid while normal copies of
// the same operator vanished) and plays fx.promote there instead of the plain deploy flash. Headless fake PIXI.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
import { promotionsOf } from '../../public/js/render/promote.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const CHESS = JSON.parse(readFileSync(path.join(ROOT, 'data/chess.json'), 'utf8'));
const ROWS = CHESS.chess || CHESS;
const chessOf = (id) => ROWS[id] || null;
const A = 'chess_char_1_01_a', B = 'chess_char_1_02_a';
const gold = (id) => ROWS[id].goldenId;
const e = (uid, id, area, o = {}) => ({ uid, area, piece: { uid, kind: 'chess', id, golden: !!ROWS[id]?.isGolden, ...o }, key: `p:${uid}`, ...o });
describe('promotionsOf', () => {
test('a new elite whose normal copies vanished is a promotion (board or bench)', () => {
const prev = [e(1, A, 'board', { row: 10, col: 3 }), e(2, A, 'board', { row: 11, col: 5 }), e(3, B, 'hand', { idx: 9 })];
const next = [e(9, gold(A), 'board', { row: 11, col: 5 }), e(3, B, 'hand', { idx: 9 })];
const p = promotionsOf(prev, next, chessOf);
assert.deepEqual([...p.keys()], [9]);
assert.deepEqual(p.get(9).map((g) => g.uid).sort(), [1, 2]);
const hand = promotionsOf([e(1, A, 'hand', { idx: 9 }), e(2, A, 'hand', { idx: 8 })], [e(7, gold(A), 'hand', { idx: 9 })], chessOf);
assert.deepEqual([...hand.keys()], [7], 'an elite arriving in the hand is promoted too');
});
test('not a promotion: the first state, a sale, an elite gained without copies vanishing, another operator', () => {
assert.equal(promotionsOf([], [e(9, gold(A), 'board', { row: 10, col: 3 })], chessOf).size, 0, 'first setPrep');
assert.equal(promotionsOf([e(1, A, 'board')], [], chessOf).size, 0, 'a sale');
assert.equal(promotionsOf([e(1, B, 'hand')], [e(9, gold(A), 'hand')], chessOf).size, 0, 'a granted elite while another operator left');
assert.equal(promotionsOf([e(1, A, 'hand')], [e(1, A, 'board', { row: 10, col: 3 }), e(5, B, 'hand')], chessOf).size, 0, 'a move and a buy');
assert.equal(promotionsOf([e(4, gold(A), 'hand')], [e(4, gold(A), 'board', { row: 9, col: 2 })], chessOf).size, 0, 'an elite placed by hand');
});
test('two merges in one update are told apart by operator', () => {
const prev = [e(1, A, 'board'), e(2, A, 'hand'), e(3, B, 'hand'), e(4, B, 'temp')];
const next = [e(10, gold(A), 'board'), e(11, gold(B), 'hand')];
const p = promotionsOf(prev, next, chessOf);
assert.deepEqual(p.get(10).map((g) => g.uid).sort(), [1, 2]);
assert.deepEqual(p.get(11).map((g) => g.uid).sort(), [3, 4]);
});
});
describe('fx.promote', () => {
let fake, FX;
before(async () => { fake = installFakePixi(); FX = await import('../../public/js/render/fx.js'); });
after(() => fake.restore());
const cam = presetCamera('prep', { width: 1600, height: 900 });
function makeFx() {
const P = fake.P;
const ctx = fakeViewCtx(P);
return new FX.FxSystem({
P, layers: ctx.layers, cam: () => cam, heightAt: () => 0, settings: { quality: 'high', damageNumbers: true },
timeScale: () => 1, loadLevel: () => 0, subProfOf: () => null, view: () => null,
screenSize: () => ({ width: 1600, height: 900 }), fieldTop: () => 120,
});
}
const view = (x, y) => ({ id: 'p:9', x, y, z: 0, hover: 0, _headTiles: 1.2, alive: true, destroyed: false, isEnemy: false, info: { defId: gold(A) } });
const texName = (fx, sp) => Object.keys(fx.tex).find((k) => fx.tex[k] === sp.texture);
test('a gold pillar, a shockwave and a hex ring at the elite, streaks from the other copies; counted', () => {
const fx = makeFx();
const before = fx.counts;
assert.equal(before.promotions, 0);
fx.promote(view(5, 10), [{ x: 3, y: 11, z: 0 }]);
const names = fx.parts.map((p) => texName(fx, p.sp));
assert.ok(names.filter((n) => n === 'pillar').length >= 2, `pillars (${names.join(',')})`);
assert.ok(names.includes('streak'), 'a streak from the other consumed copy');
assert.ok(fx.rings.length >= 2, 'shockwave + hex ring');
assert.equal(fx.counts.promotions, 1);
fx.promote(null);
assert.equal(fx.counts.promotions, 1, 'no view: nothing');
});
});
test('render/app.js plays the promotion cue instead of the deploy flash for a merge\'s elite', () => {
const src = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8');
assert.match(src, /import \{ promotionsOf \} from '\.\/promote\.js';/);
assert.match(src, /const before = prepPieces\.length \? prepPieces : promoBase;[\s\S]{0,80}promotionsOf\(before, list, \(id\) => data\.chess\(id\)\)/, 'compared with the previous state');
assert.match(src, /if \(prepPieces\.length\) promoBase = prepPieces;\s*prepPieces = \[\];/, 'a battle keeps the last prep state: a merge between two preps is cued too (QA 6b)');
assert.match(src, /if \(promoFrom\.has\(e\.uid\)\) \{[\s\S]{0,400}fx\.promote\(v,[\s\S]{0,400}\} else if \(e\.area === 'board' && prevBoard\.size && !prevBoard\.has\(e\.uid\)\) \{ v\.onDeploy\?\.\(\); fx\.deploy\(v\); \}/);
assert.match(src, /promotions,/, 'the cue log is exposed on view.debug (E2E)');
});
+36
View File
@@ -0,0 +1,36 @@
// 限伤 on screen (docs/research/11-limits-official.md §2, DESIGN §20.12): a leader's hit of ≥ 300000 in a boss battle
// is cancelled by the sim (server/sim/damage.js leaderHitCancelled), which emits ['fx', 'hitCap', x, y, { id, n }] and
// no 'dmg' event. The official shows no number for it [ASSUMED]; the renderer draws nothing for the event.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { FxSystem, FX_KINDS, fxSpec } from '../../public/js/render/fx.js';
import { presetCamera } from '../../public/js/render/projection.js';
function recordingFx() {
const cam = presetCamera('boss', { width: 1280, height: 720 });
const calls = [];
const fx = Object.create(FxSystem.prototype);
const rec = (name) => (...a) => { calls.push(name); return name === '_where' || name === '_point' ? { x: a[0], y: a[1], z: 0, v: null } : undefined; };
Object.assign(fx, {
ctx: { cam: () => cam, heightAt: () => 0, fieldRect: () => ({ r0: 0, r1: 5, c0: 0, c1: 20 }), timeScale: () => 2 },
_p: { x: 0, y: 0, s: 0, depth: 0 }, _q: { x: 0, y: 0, s: 0, depth: 0 }, locks: [],
_where: rec('_where'), _point: rec('_point'), particle: rec('particle'), ring: rec('ring'), burst: rec('burst'),
explosion: rec('explosion'), flashScreen: rec('flashScreen'), tileFlash: rec('tileFlash'),
});
fx._chest = (v, out) => Object.assign(out, cam.project(0, 0, 0.5, out));
return { fx, calls };
}
test('hitCap: listed in FX_KINDS as a kind that draws nothing', () => {
assert.equal(FX_KINDS.hitCap.a, 'none');
assert.equal(fxSpec('hitCap', { id: 3, n: 300000 }).a, 'none');
});
test('hitCap: simFx draws nothing (no number, no spark); an unknown kind still gets its generic sparkle', () => {
const { fx, calls } = recordingFx();
fx.simFx('hitCap', 10, 3, { id: 7, n: 412345 });
assert.deepEqual(calls, [], 'nothing drawn for a cancelled leader hit');
fx.simFx('zzzUnknownKind', 10, 3, {});
assert.ok(calls.includes('particle'), 'the generic sparkle still works');
});