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

+148 -5
View File
@@ -13,7 +13,9 @@
// boss-field deployment, the phone prep camera — and the normative §3 / §5.1 / §5.5 / §6.1 / §7 lines that changed; user
// playtest #6 (DESIGN §20): summons placed by hand (start deploy), skill triggers and the operation cooldown, every
// blocker hits what it blocks, push force vs weight, the ASPD floor, the enemies' collider reach, the boss pool floor,
// multi-round bounties lasting two battles, the 联防 counter, the element gauge — and the user's settled decisions.
// multi-round bounties lasting two battles, the 联防 counter, the element gauge — and the user's settled decisions; the
// playtest6b follow-up (DESIGN §20.10–§20.13): leader HP and 直接乘算, the elite on the consumed copy's tile, the official
// 999 layer cap (one implementation) and 限伤 300000, the 假想敌:胄 kit — and the normative lines they rewrote.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -27,9 +29,10 @@ import { moduleTypeIconUrl } from '../public/js/ui/assetUrls.js';
import { validateC2S } from '../shared/protocol.js';
import { ERR, PHASE } from '../shared/constants.js';
import { PROJECTILE_SPEEDS, BOOMERANG_RETURN_SPEED, ELEMENT, ELEMENT_ORDER, DOWN_STATE, BLOCK_RADIUS, FORCED_EXIT, ASPD_MIN, BOSS_POOL_MIN_HP, AUTO_OP_COOLDOWN, ALLY_COLLIDER_RADIUS } from '../server/sim/constants.js';
import { SKILL_SUMMON_START_DEPLOY } from '../shared/constants.js';
import { SKILL_SUMMON_START_DEPLOY, BOND_LAYER_CAP, BOSS_HIT_LIMIT, layerGainRoom } from '../shared/constants.js';
import * as SIM_CONST from '../server/sim/constants.js';
import { MULTI_ROUND_BOUNTY_BATTLES } from '../server/match/choices.js';
import { SELF_BOUND } from '../server/sim/content/bosses.js';
import { SELF_BOUND, PART_TRANSFER, BLADE_TRANSFER, DRONE_LINK_BASE } from '../server/sim/content/bosses.js';
import * as BOARD from '../server/match/board.js';
import * as DAMAGE from '../server/sim/damage.js';
import { SUB } from '../server/sim/professions.js';
@@ -391,7 +394,7 @@ test('user playtest #6 (DESIGN §20): summons, skill triggers, blocking, push fo
const sub = (a, b) => DESIGN.slice(DESIGN.indexOf(`### ${a}`), DESIGN.indexOf(`### ${b}`));
const S20 = sec(20);
assert.match(DESIGN, /## 20\. User playtest #6 \(v2\.5\)/);
for (let i = 1; i <= 9; i++) assert.match(S20, new RegExp(`### 20\\.${i} `), `§20.${i}`);
for (let i = 1; i <= 13; i++) assert.match(S20, new RegExp(`### 20\\.${i} `), `§20.${i}`);
const intro = S20.slice(0, S20.indexOf('### 20.1'));
for (let i = 1; i <= 19; i++) assert.match(intro, new RegExp(`#${i} `), `the intro maps report #${i}`);
const s55 = DESIGN.slice(DESIGN.indexOf('### 5.5'), DESIGN.indexOf('### 5.6'));
@@ -467,5 +470,145 @@ test('user playtest #6 (DESIGN §20): summons, skill triggers, blocking, push fo
assert.match(sec(10), /a tap anywhere on the card, its confirm strip included, is the card's tap/);
assert.match(sec(18), /Each card shows its full effect text \(§20\.7\)/);
// README: the test count stays in the right order of magnitude
assert.match(README, /约 27\d0 项/);
assert.match(README, /约 28\d0 项/);
});
test('user playtest #6 follow-up: a merge consuming a deployed copy puts the elite on that tile (code + research + META / PLAYING / SIM agree)', () => {
const R01 = doc('docs/research/01-core-rules.md');
const INDEX = doc('docs/research/00-INDEX.md');
const PS = doc('server/match/PlayerState.js');
// code: one deployed copy + one hand copy + the acquired one → the elite on the deployed copy's tile
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 71 }).start();
h.toPrep(1);
h.setStage('act2autochess_m04');
const m = h.m;
const ps = h.ps('p_0');
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);
ps.recompute();
const id = Object.values(DATA.chess).find((c) => !c.isGolden && c.tier === 1 && c.position === 'MELEE' && m.pool.has(c.chessId) && m.gd.mergeCount(c.chessId) === 3 && !m.gd.placeableTokens(c.chessId).length).chessId;
const [r, c] = BOARD.legalTiles(ps.deployMap(), 'melee')[0];
const a = ps.newPiece('chess', id, { poolCopies: m.pool.take(id, 1) });
a.dir = 'UP';
ps.board.set(BOARD.tileKey(r, c), a);
ps.hand[0] = ps.newPiece('chess', id, { poolCopies: m.pool.take(id, 1) });
ps.recompute();
const elite = ps.acquireChess(id, { source: 'test' });
assert.equal(ps.find(elite.uid).key, BOARD.tileKey(r, c), 'the elite on the deployed copy\'s tile');
assert.equal(elite.dir, 'UP');
m.dispose();
// the research no longer says "not to a board tile"; every doc names the official sentence
const official = /若消耗已部署至作战区的干员,则发送至作战区对应位置/;
assert.ok(!/1 elite goes to the hand, not to a board tile\.\*\*/.test(R01), 'research 01 A1 row 7 is corrected');
assert.match(R01, /\| 7 \| promotion reward[^\n]*to that copy's board position/);
// its machine-readable companion too (not read by build-data; the tile among several deployed copies is assumed)
const promo = JSON.parse(doc('docs/research/01-core-data.json'))._criticAddendum.promotion;
assert.match(promo.eliteGoesTo, official, '01-core-data.json promotion.eliteGoesTo quotes PRTS');
assert.notEqual(promo.eliteGoesTo, 'hand');
assert.equal(promo.eliteTileAmongSeveralDeployed?.assumed, true);
for (const [name, text] of [['research 01', R01], ['00-INDEX', INDEX], ['META', META], ['SIM', SIM], ['PlayerState', PS]]) assert.match(text.replace(/\s+|\/\/\s/g, ''), official, `${name} quotes PRTS`);
assert.match(INDEX, /to \*\*that copy's board position\*\*/);
assert.match(META, /of several, the one that deploys first \(row desc, then col asc; `board\.js mergeTile`/);
assert.ok(!/it takes a freed\s+board tile of a consumed copy only when the hand and temp are both full/.test(META), 'META: the old fallback-only wording is gone');
assert.ok(!/only with the hand and temp both full does it take a/.test(PS), 'PlayerState header: the old fallback-only wording is gone');
assert.match(PLAYING, /精锐会直接出现在\*\*那名干员的位置\*\*/);
assert.ok(!/精锐进入整备区(满了进入临时整备区),原先配发的装备退回整备区。/.test(PLAYING), 'PLAYING: the hand-only wording is gone');
});
test('playtest6b follow-up (DESIGN §20.10–§20.13): leader HP, 直接乘算, elite to the board, one official 999 cap, 限伤 300000, the 胄 kit — code and every doc agree', () => {
const sec = (n) => DESIGN.slice(DESIGN.indexOf(`## ${n}.`), DESIGN.indexOf(`## ${n + 1}.`) > 0 ? DESIGN.indexOf(`## ${n + 1}.`) : undefined);
const S20 = sec(20);
const subsec = (n) => { const a = S20.indexOf(`### 20.${n} `); const b = S20.indexOf('\n### 20.', a + 5); return S20.slice(a, b > 0 ? b : undefined); };
const intro = S20.slice(0, S20.indexOf('### 20.1 '));
const s209 = subsec(9);
const BALANCE = doc('docs/BALANCE.md');
const R02 = doc('docs/research/02-bonds.md');
const R11 = doc('docs/research/11-limits-official.md');
// the intro names the follow-up and the four sections, in merge order
assert.match(intro, /\*\*Follow-up \(`playtest6b`, 2026-10-02\)\.\*\*/);
for (const n of [10, 11, 12, 13]) assert.match(intro, new RegExp(`§20\\.${n}`), `the intro names §20.${n}`);
// one layer cap: shared/constants.js only — the boss-HP branch's sim/constants.js copy is gone
assert.equal(BOND_LAYER_CAP, 999);
assert.equal(layerGainRoom(995, 10), 4);
assert.ok(!('BOND_LAYER_CAP' in SIM_CONST) && !('layerRoom' in SIM_CONST), 'no second cap in server/sim/constants.js');
for (const f of ['server/match/PlayerState.js', 'server/match/Match.js', 'server/sim/Battle.js']) {
const src = doc(f);
assert.match(src, /layerGainRoom/, `${f} clamps with layerGainRoom`);
assert.ok(!/layerRoom\b/.test(src), `${f}: no layerRoom`);
}
// 限伤: official constant, not an overflow — no doc keeps the boss-HP branch's "fixed-point overflow, not modelled"
assert.equal(BOSS_HIT_LIMIT, 300000);
for (const [name, text] of [['BALANCE', BALANCE], ['DATA', DATA_MD], ['PLAYING', PLAYING], ['SIM', SIM], ['META', META], ['research 02', R02], ['DESIGN', DESIGN]]) {
assert.ok(!/reads as the engine's fixed-point overflow|is \[inference\] and is not modelled|Not modelled: the largest damage/.test(text), `${name}: 限伤 is not called an unmodelled overflow`);
assert.ok(!/BOND_LAYER_CAP`?\)? ?\[ASSUMED\]|999 \[ASSUMED\]|awaiting the user's confirmation/.test(text), `${name}: the 999 cap is official, not [ASSUMED]`);
}
assert.match(BALANCE, /superseded by that binary evidence/);
assert.match(R11, /MAX_BATTLE_DAMAGE = 300000/);
assert.match(R02, /MAX_GARRISON_STACK = 999/);
// §20.9: elite settled, 999 / 限伤 official with their flips, the fixed leader pool settled (aliveScaling off in the data)
assert.match(s209, /\| A merge's elite \(§20\.11\) \| takes the consumed deployed copy's tile/);
assert.match(s209, /`shared\/constants\.js BOND_LAYER_CAP = 0`/);
assert.match(s209, /`shared\/constants\.js BOSS_HIT_LIMIT = 0`/);
assert.match(s209, /Settled by the user \("保持固定血量"\)[^\n]*aliveScaling` is \*\*false\*\*/);
assert.equal(DATA.config.bossHpScale.aliveScaling, false, 'the user chose the fixed leader pool');
assert.equal(SIM_CONST.DIRECT_BONUS_STACKING, 'add');
assert.match(s209, /DIRECT_BONUS_STACKING = 'multiply'/);
// the normative lines
const s52 = DESIGN.slice(DESIGN.indexOf('### 5.2'), DESIGN.indexOf('### 5.3'));
const s53 = DESIGN.slice(DESIGN.indexOf('### 5.3'), DESIGN.indexOf('### 5.4'));
const s55 = DESIGN.slice(DESIGN.indexOf('### 5.5'), DESIGN.indexOf('### 5.6'));
const s62 = DESIGN.slice(DESIGN.indexOf('### 6.2'), DESIGN.indexOf('### 6.3'));
const s63 = DESIGN.slice(DESIGN.indexOf('### 6.3'), DESIGN.indexOf('### 6.4'));
assert.match(s52, /are 直接乘算 and go into `Σpct`/);
assert.match(s53, /`Battle\.applyStrongest`/);
assert.match(s55, /\*\*限伤\*\*[^\n]*`ceil\(final\) ≥ 300000` is cancelled[^\n]*before HP shields/);
assert.match(s62, /elite onto the consumed deployed copy's tile, else to the hand \(§20\.11\)/);
assert.ok(!/A merge's elite always goes to the hand/.test(DESIGN), '§6.2: the hand-first wording is gone');
assert.match(s63, /`BOND_LAYER_CAP` = 999 layers \(the client's `MAX_GARRISON_STACK`/);
assert.match(sec(8), /`'hitCap' \{id, n\}` = a cancelled 限伤 hit, drawn as nothing/);
assert.match(sec(9), /fx\.promote instead of fx\.deploy/);
assert.match(sec(14), /`layerGains ≤ min\(60 \+ 4·round, 999 − the bond's starting layers\)`/);
assert.match(sec(2), /pick\.js, promote\.js/);
// leader parts and drones: one share, 1:1 (PRTS 等量); drones read the pool
assert.equal(PART_TRANSFER, 1);
assert.equal(BLADE_TRANSFER, PART_TRANSFER);
assert.equal(DRONE_LINK_BASE, 'pool');
assert.match(sec(7), /pass every damage they take to their leader 1:1 as 无来源 HP loss \(`PART_TRANSFER` 1/);
assert.match(SIM, /`PART_TRANSFER` 1 for 斩胄之剑 \/ 破胄之锤 \(`BLADE_TRANSFER`, the same\s+constant\) and 碎铳之簧/);
assert.ok(!/`PART_TRANSFER` 0\.5 \[ASSUMED\]/.test(SIM), 'SIM: no ½ spring transfer');
assert.match(PLAYING, /无人机死亡时(不论是谁击落;漏过去不算)/);
// §20.13: the 胄 audit, short
assert.match(subsec(13), /fires \*\*one\*\* 刺胄之弹 below 20 % too/);
assert.match(subsec(13), /\*\*any\*\* drone death costs the leader 2 % max HP/);
});
test('playtest6b QA residuals (DESIGN §20.14): the held boss result, the cue between two preps, the 可晋升 line — code and every doc agree', () => {
const S20 = DESIGN.slice(DESIGN.indexOf('## 20.'));
const s2014 = S20.slice(S20.indexOf('### 20.14 '));
assert.ok(s2014.length > 100, '§20.14 exists');
const intro = S20.slice(0, S20.indexOf('### 20.1 '));
assert.match(intro, /residual issues are handled in §20\.14/);
// the held 'cleared' result: Match.js = §14 = §20.14 = META
const match = doc('server/match/Match.js');
assert.match(match, /if \(result\.reason === 'cleared' && pool && reported - f\.bossAcked >= pool\.hp - 1\) \{\s*f\.heldResult = result;/);
assert.match(match, /_bossHandover\(f, why, \{ demote = false \} = \{\}\) \{\s*if \(f\.done \|\| f\.mode !== 'client' \|\| f\.heldResult\) return;/);
assert.match(match, /const BOSS_MIN_CLEAR_GS = 5;/, 'the budget itself is unchanged');
const s14 = DESIGN.slice(DESIGN.indexOf('## 14.'), DESIGN.indexOf('## 15.'));
assert.match(s14, /it is \*\*held\*\* \(`f\.heldResult`/);
assert.match(META, /waits for the budget instead \(`f\.heldResult`/);
assert.match(s2014, /\*\*Fast boss kills handed over \(fixed\)\.\*\*/);
// the promotion cue between two preps (render/app.js promoBase) and the touch line (shopBar mergeHint)
assert.match(doc('public/js/render/app.js'), /if \(prepPieces\.length\) promoBase = prepPieces;/);
assert.match(doc('public/js/ui/shopBar.js'), /export function mergeHint\(priv, chessId\)/);
assert.match(s2014, /`promoBase`/);
assert.match(s2014, /可晋升:精锐干员将出现在作战区原位置/);
assert.match(PLAYING, /详情卡里也会写明去向/);
// the measurements: real seconds, the integrated build, the pre-merge numbers labelled
const s2010 = S20.slice(S20.indexOf('### 20.10 '), S20.indexOf('### 20.11 '));
assert.match(s2010, /\*\*Measured\*\* \(real seconds/);
assert.match(s2010, /Integrated build \(QA 6b on `cca11e6`/);
const BALANCE = doc('docs/BALANCE.md');
assert.match(BALANCE, /\*\*2026-10-02 — the integrated build \(QA 6b/);
assert.match(BALANCE, /measured on the boss-HP workstream's boards, \*\*before\*\* the elite-to-board merge/);
});