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

+47 -33
View File
@@ -21,13 +21,15 @@
// * Shop: per-level chess slots + item slot, copy-weighted rolls from the SHARED pool (pool.js); refresh 1 (free
// refreshes first), one toggle freezes all unsold slots until the next round start (a manual refresh while frozen
// rerolls everything and the new slots stay frozen), level-up price = base − rounds elapsed (floor 0).
// * Merge: 3 normal copies (风丸 2) on board/hand/temp → 1 elite to the hand; equipment returns to the hand; a
// * Merge: 3 normal copies (风丸 2) on board/hand/temp → 1 elite; equipment returns to the hand; a
// reward offer of 3 different free chess of tier min(level+1, 6) is queued (never one operator twice — user
// playtest #6 item 19; a short tier tops up from the tier below; pick 1, expires at prep end; an offer earned
// after the prep — SETTLE / Final Assault effects — is kept for the next prep). The elite goes to the hand
// (research 01 A1 "1 elite goes to the hand, not to a board tile"), overflow temp — also outside PREP (a
// SETTLE merge's elite waits in temp for the next prep); only with the hand and temp both full does it take a
// freed board tile of a consumed copy.
// after the prep — SETTLE / Final Assault effects — is kept for the next prep). Where the elite goes (PRTS
// 卫戍协议/帮助 "发送1名【精锐】状态的该干员至手牌区(若消耗已部署至作战区的干员,则发送至作战区对应位置)", user
// playtest #6 follow-up): when a consumed copy stood on the board, onto that copy's tile with its facing (of several,
// the one that deploys first — board.js mergeTile, [ASSUMED]); it replaces a deployed copy, so the deploy count
// never grows, and it gets its own summon stack (grantTokensFor). Otherwise to the hand, overflow temp — also
// outside PREP (a SETTLE merge's elite waits in temp for the next prep).
// * Items: equip max 2 (a 3rd replaces the equipped item the player picks — g.equip replaceUid, the oldest when
// absent; equipped items are otherwise locked: g.destroy refuses them),
// 2 identical normal items (hand/temp/equipped) merge into the golden item in the hand, items are never sold
@@ -35,7 +37,8 @@
// * Tokens (PRTS 卫戍协议/帮助 §战斗部署, user playtest #6): placing an owner with manually deployable summons
// (tokens.json `placeable`: 赫默's 医疗探机 and 巫恋's 诅咒娃娃 with their S2, 凯瑟琳's 爬行号·防护单元, 海嗣 / 狼群 /
// 流形) sends one stack (deployLimit copies — 凯瑟琳 2) to the hand, placed by hand like any piece (no deploy slot);
// withdrawing/selling/merging the owner removes its tokens, moving it on the board (also when a summon dragged onto
// withdrawing/selling/merging the owner removes its tokens (an elite that takes a merged copy's tile gets a fresh
// stack of its own, like any deployment), moving it on the board (also when a summon dragged onto
// it swaps it away) sends its placed summons back onto their stack ("移动干员时,其所属召唤物全部退场并重置至手牌区");
// a summon stack removed from temp at a prep deadline comes back at the next round start (startRound tops every
// board owner's summons up to the deploy limit, "干员所属召唤物会于下一回合返还"). In battle a skill's summon takes its
@@ -51,9 +54,9 @@
// it during INFO_CHECK only. battleInput() resolves every chess unit to `skillIndex` + `moduleId` (resolveLoadout:
// normal chess → moduleId null, elite → uniEquipId | 'none'); m.private exposes `loadout`.
import { ERR, GEO, PHASE } from '../../shared/constants.js';
import { ERR, GEO, PHASE, layerGainRoom } from '../../shared/constants.js';
import { checkLoadout, resolveLoadout } from '../../shared/protocol.js';
import { FIELD, tileKey, parseKey, inField, canPlace, positionClass, boardOrder, freeSlot, pieceDir, parseDir } from './board.js';
import { FIELD, tileKey, parseKey, inField, canPlace, positionClass, boardOrder, freeSlot, pieceDir, parseDir, mergeTile } from './board.js';
import { offsetTile } from '../sim/dir.js';
import { computeBonds, bondList, bondSnapshot, activatedLayers } from './bondsMeta.js';
import { itemKey } from './gamedata.js';
@@ -461,15 +464,28 @@ export class PlayerState {
/**
* Merge `need` normal copies of `baseId` (the incoming, not yet stowed piece first, then temp, hand, board) into
* the elite. Returns the elite piece (or null if the elite could not be stored).
* the elite — PRTS 卫戍协议/帮助 §干员的获得与精锐化: "发送1名【精锐】状态的该干员至手牌区(若消耗已部署至作战区的干员,
* 则发送至作战区对应位置)" (the user's playtest #6 follow-up confirms it). The tile (`mergeTile`): when a consumed copy
* stood on the board the elite takes its tile and facing — of several, the one that deploys first (board reading
* order: top → bottom, then left → right) [ASSUMED]; `fromKey` / `fromDir` = the tile the incoming piece stood on (a
* transformation of a deployed operator) counts as such a copy. It replaces a deployed copy, so the deploy count never
* grows. Otherwise the elite goes to the hand, overflow temp — outside PREP too (a SETTLE merge's elite waits in temp
* through the next prep, tempDue). The copies' equipment returns to the hand ("干员晋级后已配发装备会回收至整备区";
* overflow temp; with both full it stays on the elite, up to its equipPerChess (2) slots — any further item is
* destroyed with a log warning, as before the official rule); their summons are removed, and an elite on the board
* gets its own summon stack (grantTokensFor: its loadout, like any deployment). Returns the elite piece (or null if
* the elite could not be stored).
* @param {string} baseId
* @param {any} incoming the acquired, not yet stowed copy (null: only owned copies)
* @param {{ fromKey?: string|null, fromDir?: string }} [opts]
*/
_mergeChess(baseId, incoming) {
_mergeChess(baseId, incoming, { fromKey = null, fromDir = undefined } = {}) {
const need = this.gd.mergeCount(baseId);
const goldenId = this.gd.goldenIdOf(baseId);
if (!(need > 1) || !goldenId) return null;
const locs = this._chessLocations().filter((l) => !this.gd.isGolden(l.piece.id) && this.gd.baseIdOf(l.piece.id) === baseId);
const consumed = [];
if (incoming) consumed.push({ piece: incoming, area: 'new' });
if (incoming) consumed.push({ piece: incoming, area: 'new', key: fromKey || undefined, dir: fromDir });
for (const l of locs) { if (consumed.length >= need) break; consumed.push(l); }
if (consumed.length < need) return null;
let copies = 0;
@@ -482,27 +498,21 @@ export class PlayerState {
l.piece.items = [];
}
const elite = this.newPiece('chess', goldenId, { poolCopies: copies });
const boardLoc = consumed.find((l) => l.area === 'board' && !this.board.has(l.key));
const toBoard = () => {
// same operator as a consumed copy, so its freed tile is legal; it keeps that copy's facing
elite.dir = pieceDir(boardLoc.piece);
this.board.set(boardLoc.key, elite);
this.grantTokensFor(elite);
return 'board';
};
// the elite goes to the hand, overflow temp — outside PREP too (a merge completed by SETTLE / battle-result effects):
// a temp piece that arrived after the prep waits there through the next prep (tempDue)
let where = this.stow(elite, { allowTemp: true });
if (!where && boardLoc) {
// hand and temp both full ⇒ every consumed copy stood on the board: the elite takes the first freed tile
// instead of being lost
where = toBoard();
}
const deployed = consumed.filter((l) => l.key && !this.board.has(l.key)).map((l) => ({ key: l.key, dir: l.area === 'new' ? l.dir : pieceDir(l.piece) }));
const toTile = (t) => { elite.dir = parseDir(t.dir) || 'RIGHT'; this.board.set(t.key, elite); return 'board'; };
const tile = mergeTile(deployed, (r, c) => this._legal(elite, r, c));
let where = tile ? toTile(tile) : this.stow(elite, { allowTemp: true });
// hand and temp full and no deployed tile legal for it (a terrain change not re-checked yet): it stays on the first
// deployed copy's tile rather than being lost, like a piece _evictIllegal finds no room for
if (!where && deployed.length) where = toTile(mergeTile(deployed));
for (const it of items) {
if (this.stow(it, { allowTemp: true })) continue;
if (where && elite.items.length < this.gd.equipPerChess) { elite.items.push(it); continue; }
this.m.log.warn?.(`[match ${this.m.roomCode}] ${this.playerId}: returned item ${it.id} destroyed (no space)`);
}
// deployed like any operator placed by hand: its manually deployable summons join the hand (after the returned
// equipment, which would be lost in temp — a summon stack removed there comes back at the next round start)
if (where === 'board') this.grantTokensFor(elite);
if (!where) {
this.m.pool.give(baseId, copies);
this.m.toast(this, 'warn', '整备区已满,晋升的精锐干员无法放入');
@@ -514,7 +524,7 @@ export class PlayerState {
this.pushRewardOffer('merge');
const rec = this.gd.chess(goldenId);
this.m.tickerFor('GOLDEN_CHAR', [this.name, rec ? rec.name : goldenId], { playerId: this.playerId });
this.m.dispatch(this, 'onMerge', { kind: 'chess', piece: elite, baseId, consumed: consumed.map((l) => l.piece.uid) });
this.m.dispatch(this, 'onMerge', { kind: 'chess', piece: elite, baseId, consumed: consumed.map((l) => l.piece.uid), area: where });
return elite;
}
@@ -533,7 +543,8 @@ export class PlayerState {
/**
* Replace a chess piece by another chess (突变细胞 and similar). The new piece keeps the tile when legal (else goes
* to the hand/temp) and keeps the equipment; pool copies are swapped; completes a merge when possible.
* to the hand/temp) and keeps the equipment; pool copies are swapped; completes a merge when possible — a deployed
* piece's tile then counts as a consumed copy's for the elite (_mergeChess fromKey, when legal for it).
*/
transformChess(piece, newId) {
const loc = this.find(piece.uid);
@@ -549,7 +560,7 @@ export class PlayerState {
let np = this.newPiece('chess', newId, { poolCopies: taken });
np.items = items;
if (!rec.isGolden && this.completesChessMerge(newId)) {
np = this._mergeChess(base, np);
np = this._mergeChess(base, np, loc.area === 'board' ? { fromKey: loc.key, fromDir: pieceDir(piece) } : undefined);
if (!np) { this.recompute(); return null; }
} else if (loc.area === 'board') {
const [r, c] = parseKey(loc.key);
@@ -724,13 +735,16 @@ export class PlayerState {
this.m.dispatch(this, 'onSpend', { amount, reason, total: this.stats.gold });
}
/** Bond layer gain (prep-side). `requireActive` = "使已激活的【X】层数+N". Returns layers added. */
/**
* Bond layer gain (prep-side). `requireActive` = "使已激活的【X】层数+N". Returns layers added: at most the room left
* under BOND_LAYER_CAP (999, shared/constants.js) — a gain at the cap adds 0 and dispatches nothing.
*/
addLayers(bondId, n, { requireActive = false, reason = '' } = {}) {
if (!this.gd.bond(bondId) || !Number.isFinite(n) || n <= 0) return 0;
if (requireActive && !(this.bonds[bondId] && this.bonds[bondId].active)) return 0;
const add = Math.floor(n);
if (add <= 0) return 0;
const before = this.layers[bondId] || 0;
const add = layerGainRoom(before, Math.floor(n));
if (add <= 0) return 0;
this.layers[bondId] = before + add;
this.recompute();
this.m.dispatch(this, 'onLayers', { bondId, from: before, to: before + add, reason });