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

+39 -11
View File
@@ -119,7 +119,7 @@
// human is left at all the match ends ('abandoned'); when nobody alive is left it ends as 'eliminated'.
import { C2S, unitStatsEntry } from '../../shared/protocol.js';
import { PHASE, ERR, EMOTES, EMOTE_COOLDOWN_MS, GEO, modeIdFor } from '../../shared/constants.js';
import { PHASE, ERR, EMOTES, EMOTE_COOLDOWN_MS, GEO, modeIdFor, layerGainRoom } from '../../shared/constants.js';
import { Battle } from '../sim/Battle.js';
import { DataSource } from '../sim/simdata.js';
import { createRng, deriveSeed } from '../sim/rng.js';
@@ -174,7 +174,8 @@ const BOSS_RESULT_GRACE_MS = 6000;
* pool per BOSS_MIN_CLEAR_GS game seconds (20 % of the pool per game second; the balance model's fastest mean kills
* take ≈ 18–20 s at ≈ 5 %/s per field, docs/BALANCE.md §3), the credited LP cost ≤ BOSS_LP_BURST + BOSS_LP_PER_GS per
* game second (a leader's "扣除所有目标生命" comes after ≥ 200 s). Reports are cumulative: what exceeds the budget is
* credited later as the budget grows (the boss clock re-applies the latest report), never lost.
* credited later as the budget grows (the boss clock re-applies the latest report), never lost — a 'cleared' b.result
* whose report covers the pool waits for it too (`heldResult`; 999-layer kills take 2–4 game s), it is not handed over.
*/
const BOSS_MIN_CLEAR_GS = 5;
const BOSS_LP_BURST = 10;
@@ -1909,8 +1910,9 @@ export class Match {
progress: { gt: 0, killed: 0, total, leaks: 0, done: false }, lastProgressAt: this.sched.now(),
bossAcked: 0, bossBy: {}, lpAcked: 0, lpCum: 0, deadlineTimer: null, doneTimer: null, waitTimer: null,
// boss fields: the latest client reports (re-credited as the plausibility budget grows), the server run's
// CreditPool, humans demoted for an implausible result (never the authority of this field again)
bossReported: null, lpReported: 0, credit: null, demoted: new Set(),
// CreditPool, humans demoted for an implausible result (never the authority of this field again), a 'cleared'
// b.result waiting for the budget to credit the pool it emptied (`heldResult`, _onResult)
bossReported: null, lpReported: 0, credit: null, demoted: new Set(), heldResult: null,
};
}
@@ -2306,7 +2308,7 @@ export class Match {
_onResult(ps, msg) {
if (!this.clientCombat) return fail(ERR.WRONG_PHASE, 'server-run combat');
const f = this._fieldByBattle(msg.battleId);
if (!f || f.done || f.mode !== 'client' || f.authority !== ps.playerId) return OK;
if (!f || f.done || f.heldResult || f.mode !== 'client' || f.authority !== ps.playerId) return OK;
const bossLike = f.kind === 'boss' || f.kind === 'hidden';
const v = validateClientResult(f.spec, msg.result, { gd: this.gd });
if (!v.ok) {
@@ -2334,12 +2336,23 @@ export class Match {
// the pair's fight (and, with every field done, end the Final Assault as a defeat). The field is handed to the
// partner's replica or the server instead, and the sender never reports it again.
if (!this._finalEnding && !(pool && pool.hp <= 0)) {
// 'cleared' while the budget still holds part of the client's cumulative report back: when that report covers
// what the pool holds, the client emptied the pool as it saw it, only faster than BOSS_MIN_CLEAR_GS lets the
// server credit (999-layer boards kill a 绝境 leader in 2–4 game s; official "boss一秒死", DESIGN §20.10). The
// result waits: the boss clock credits the report as the budget grows and the pool's end (_endFinal) completes
// the field with it — no takeover, nothing credited faster than the budget (a forged report gains nothing a
// b.progress could not already get). The 1 HP slack is the pool's dust floor (BOSS_POOL_MIN_HP).
const reported = Math.max(sum, f.bossReported ? f.bossReported.cum : 0);
if (result.reason === 'cleared' && pool && reported - f.bossAcked >= pool.hp - 1) {
f.heldResult = result;
return OK;
}
this.verifyStats.rejected++;
this.log.warn?.(`[match ${this.roomCode}] ${f.fieldId}: implausible boss result from ${ps.playerId} (${result.reason} while the pool holds ${pool ? Math.round(pool.hp) : '?'}) — handed over`);
this._bossHandover(f, 'invalid', { demote: true });
return OK;
}
for (const pid of f.players) if (result.perPlayer[pid]) result.perPlayer[pid].bossDamage = Math.max(result.perPlayer[pid].bossDamage || 0, f.bossBy[pid] || 0);
this._bossResultDamage(f, result);
} else {
result = this._verifyResult(f, result);
}
@@ -2350,6 +2363,11 @@ export class Match {
return OK;
}
/** A boss field's accepted client result: each player's bossDamage is at least what the server credited them. */
_bossResultDamage(f, result) {
for (const pid of f.players) if (result.perPlayer[pid]) result.perPlayer[pid].bossDamage = Math.max(result.perPlayer[pid].bossDamage || 0, f.bossBy[pid] || 0);
}
/**
* SP_VERIFY: re-simulate an accepted client result ('all': now, the server's result wins on a mismatch; 'sample':
* ~1 battle in 8 in a later callback, mismatches are only logged).
@@ -2555,7 +2573,7 @@ export class Match {
* its partner (a replica already running) takes over, else the server.
*/
_bossHandover(f, why, { demote = false } = {}) {
if (f.done || f.mode !== 'client') return;
if (f.done || f.mode !== 'client' || f.heldResult) return; // a held 'cleared' result is in: nothing left to run
const prev = f.authority;
if (demote && prev) f.demoted.add(prev);
const next = this._authorityFor(f, prev);
@@ -2581,7 +2599,7 @@ export class Match {
if (f.bossReported && f.bossReported.cum > f.bossAcked) this._creditBoss(f, f.bossReported.cum, f.bossReported.by);
if (f.lpReported > f.lpAcked) this._creditLp(f, f.lpReported, true);
}
if (f.cc && !f.done && f.mode === 'client' && now - f.lastProgressAt > BOSS_SILENCE_MS) {
if (f.cc && !f.done && f.mode === 'client' && !f.heldResult && now - f.lastProgressAt > BOSS_SILENCE_MS) {
this.log.info?.(`[match ${this.roomCode}] ${f.fieldId}: no progress from ${f.authority} — handing the field over`);
this._bossHandover(f, 'silent');
}
@@ -2615,6 +2633,13 @@ export class Match {
continue; // _fieldDone below (after every field was told)
}
for (const pid of this._humansShowing(f)) this.sendTo(pid, { t: 'b.end', battleId: f.battleId, fieldId: f.fieldId, reason });
if (f.heldResult) {
// its authority already reported 'cleared' (_onResult): that result completes the field
f.result = f.heldResult;
this._bossResultDamage(f, f.result);
f.resultSource = 'client';
continue; // _fieldDone below
}
f.waitTimer = this.later(this.scaled(BOSS_RESULT_GRACE_MS), () => {
f.waitTimer = null;
if (f.done) return;
@@ -2623,7 +2648,7 @@ export class Match {
});
}
if (this.pacer) { this.pacer.stop(); this.pacer = null; }
for (const f of this.fields) if (f.cc && !f.done && f.mode === 'server') this._fieldDone(f);
for (const f of this.fields) if (f.cc && !f.done && (f.mode === 'server' || f.heldResult)) this._fieldDone(f);
}
/** b.pool { hp, max, teamLp, acked } to everyone (≤ 4 Hz; `force` skips the dedupe, never the rate). */
@@ -2689,11 +2714,14 @@ export class Match {
if (coins > 0) { ps.pendingFunds += coins; ps.stats.fundsGained += coins; }
for (const b of ps.bounties) b.roundsLeft--;
ps.bounties = ps.bounties.filter((b) => b.roundsLeft > 0);
// IN_BATTLE layer gains (normal battles only)
// IN_BATTLE layer gains (normal battles only), at most the room left under BOND_LAYER_CAP (999, as the battle's
// live copy: Battle.addLayers); a bond at the cap gains nothing and dispatches nothing
for (const [bondId, n] of Object.entries(r.layerGains || {})) {
if (!this.gd.bond(bondId) || !(n > 0)) continue;
const before = ps.layers[bondId] || 0;
ps.layers[bondId] = before + Math.floor(n);
const add = layerGainRoom(before, Math.floor(n));
if (!(add > 0)) continue;
ps.layers[bondId] = before + add;
this.dispatch(ps, 'onLayers', { bondId, from: before, to: ps.layers[bondId], reason: 'battle' });
}
this._charDamageTickers(ps, r);
+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 });
+34
View File
@@ -16,6 +16,9 @@
// freeze toggle released
// prep handlers buy / sell / refresh / levelUp pay exactly price / +sell price / refresh price (free first) / level
// price, the level rises by one and its price resets to the next base; Ready only with an empty temp
// merges a merge consuming a deployed copy puts the elite on that copy's tile (of several, the first in deploy
// order legal for it — board.js mergeTile; a transformed piece's own tile counts) with its facing, else
// into the hand / temp; the deploy count never grows (PRTS 卫戍协议/帮助, user playtest #6 follow-up)
// combat start nothing overdue in temp, everyone ready, funds lost (carry bands excepted), unfrozen shop cleared,
// one field per alive player
// drafts every seat holds an allowed band with LP = totalHp; 机变: one card per alive player, card ↔ picker
@@ -38,6 +41,7 @@
import { PHASE } from '../../shared/constants.js';
import { collectViolations } from './invariants.js';
import { mergeTile, pieceDir, canPlace, positionClass } from './board.js';
import { pairPlayers, bossPoolHp, hiddenEligible } from './finalAssault.js';
import { helperOrder } from './unite.js';
import { BAND_TURN_SECONDS } from './Match.js';
@@ -221,6 +225,36 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
});
return res;
});
// merges (PRTS 卫戍协议/帮助 "若消耗已部署至作战区的干员,则发送至作战区对应位置", user playtest #6 follow-up): with a
// deployed copy among the consumed ones the elite stands on the first such tile in deploy order that is legal for
// it (the incoming piece's own tile when a transformation merged it in place), with that copy's facing; else in the
// hand / temp. A merge never grows the deploy count. Every owned normal copy is consumed (merges are immediate).
wrap(ps, '_mergeChess', function (orig, baseId, incoming, opts) {
const tiles = new Map(); // tile key → facing of the copy standing there
for (const [k, p] of ps.board) if (p.kind === 'chess' && !gd.isGolden(p.id) && gd.baseIdOf(p.id) === baseId) tiles.set(k, pieceDir(p));
if (opts && opts.fromKey) tiles.set(opts.fromKey, pieceDir({ dir: opts.fromDir }));
// a transformation (transformChess) detached its deployed carrier before the merge: it still counts as deployed
const deployed0 = ps.deployCount + (opts && opts.fromKey ? 1 : 0);
const elite = orig(baseId, incoming, opts);
if (elite) check('merge', () => {
const id = ps.playerId;
const loc = ps.find(elite.uid);
if (!loc) { fail(`${id}: the elite of ${baseId} is not owned after its merge`); return; }
if (ps.deployCount > deployed0) fail(`${id}: a merge of ${baseId} grew the deploy count ${deployed0} → ${ps.deployCount}`);
// a pure read of the deploy field (Match.deployMapFor, as invariants.js): the audit must not refresh the cache
const dmap = typeof m.deployMapFor === 'function' ? m.deployMapFor(ps) : ps.deployMap();
const pos = positionClass(gd.chess(elite.id));
const want = mergeTile([...tiles.keys()].map((key) => ({ key })), (r, c) => canPlace(dmap, pos, r, c));
if (want) {
if (loc.area !== 'board' || loc.key !== want.key) fail(`${id}: the elite of ${baseId} went to ${loc.area} ${loc.key || ''}, expected the deployed copy's tile ${want.key}`);
else if (pieceDir(elite) !== tiles.get(want.key)) fail(`${id}: the elite of ${baseId} faces ${pieceDir(elite)}, its copy faced ${tiles.get(want.key)}`);
} else if (loc.area === 'board' && (!tiles.has(loc.key) || ps.hand.some((x) => x == null) || ps.temp.some((x) => x == null))) {
// only the no-room fallback (hand and temp full, no deployed tile legal for it) leaves it on a copy's tile
fail(`${id}: the elite of ${baseId} took tile ${loc.key} although no consumed copy stood on a legal tile`);
}
});
return elite;
});
wrap(ps, 'endPrep', function (orig) {
// a temp piece that got there by other means (not _putTemp) was in temp during this prep: due now
for (const p of ps.temp) if (p && !dueOf(ps).has(p.uid)) dueOf(ps).set(p.uid, endedOf(ps));
+15
View File
@@ -151,6 +151,21 @@ export function boardOrder(board) {
.sort((a, b) => b.r - a.r || a.c - b.c);
}
/**
* Where a merge's elite stands (PRTS 卫戍协议/帮助 "若消耗已部署至作战区的干员,则发送至作战区对应位置"): of the board tiles
* the consumed copies stood on (`[{ key, dir }]`), the first in deployment order (top→bottom, left→right — the copy the
* battle deploys first; the right-hand boss half is mirrored on screen but deploys in the same board order) that
* `legal(r, c)` accepts for the elite, or null (⇒ the hand). [ASSUMED] the order: the official text names one position.
* public/js/ui/gameLogic.js mergeTarget mirrors it for the client.
* @param {Array<{ key: string, dir?: string }>} tiles
* @param {(r: number, c: number) => boolean} [legal]
* @returns {{ key: string, dir?: string, r: number, c: number } | null}
*/
export function mergeTile(tiles, legal = () => true) {
const sorted = (tiles || []).map((t) => { const [r, c] = parseKey(t.key); return { ...t, r, c }; }).sort((a, b) => b.r - a.r || a.c - b.c);
return sorted.find((t) => inField(t.r, t.c) && legal(t.r, t.c)) || null;
}
/** Index of the free slot to fill (right→left, config.economy.handFillOrder), or -1. */
export function freeSlot(arr) {
for (let i = arr.length - 1; i >= 0; i--) if (arr[i] == null) return i;
+3 -1
View File
@@ -12,7 +12,9 @@
// (free levels always; the −1/round discount is waited for early), then keep buying / refreshing with what is
// left: merge progress (pairs → elites) > bond thresholds (focus core bond, add-ons 2/3) > role needs
// (blockers, anti-air when the wave flies, one or two healers) > tier; with a full board a buy must improve the
// best lineup. Items only when a deployed operator can carry them.
// best lineup. Items only when a deployed operator can carry them. A merge that consumes a deployed copy leaves
// the elite on that copy's tile (PRTS 卫戍协议/帮助, PlayerState._mergeChess): nothing here assumes it in the hand —
// steps 3–4 plan it like any owned unit (kept, moved or benched).
// 3. lineup: the deployed set maximizes unit value + activated bond tiers (exact counting via computeBonds) +
// composition (chooseLineup: greedy seed + swap hill-climbing).
// 4. placement (planLayout): the round's routes are traced over the own board from the enemy preview (ground
+13 -5
View File
@@ -30,6 +30,7 @@
// offspring bound) — the live counter's clamp
// syntheticResult(players, progress) stand-in when a boss field's client never reported
import { TICK, SNAPSHOT_EVERY } from '../sim/constants.js';
import { layerGainRoom } from '../../shared/constants.js';
import { uniteLeft } from '../sim/spec.js';
export const MAX_TICKS_PER_INTERVAL = 8;
@@ -633,7 +634,13 @@ export function specBounds(spec, gd = null) {
if (gd) for (const t of summonsOf(gd, defId)) defIds.add(t);
}
}
players.set(p.playerId, { chess, all, defIds, bonds: gd ? layerBondsOf(p, gd) : null });
// the layers each bond starts the battle with (PlayerBattleInput.bonds): a gain never passes BOND_LAYER_CAP
const startLayers = new Map();
for (const [id, b] of Object.entries(p.bonds && typeof p.bonds === 'object' ? p.bonds : {})) {
const v = Number(b && b.layers);
if (Number.isFinite(v) && v > 0) startLayers.set(id, v);
}
players.set(p.playerId, { chess, all, defIds, bonds: gd ? layerBondsOf(p, gd) : null, startLayers });
}
const round = Number(spec && spec.round) || 0;
return {
@@ -679,9 +686,9 @@ const sameMods = (a, b) => {
* `counted: false` only for enemies that never count (data notCountInTotal, countInTotal false, boss / part entries,
* content spawns); 联防: leaks + never-spawned re-entries per (enemy, leaker) ≤ what that leaker sent in, and a split /
* summon only on a leaker who sent in its parent, ≤ the parents' data offspring count (offspringPerParent); per-bond layer
* gains ≤ 60 + 4·round, only on bonds the player's lineup / band / effects / items name, none when the spec disables
* gains; coins ≤ the spawns' bounty coins; perfect consistent with the counted leaks; unit states only for the
* player's own units, within range.
* gains ≤ 60 + 4·round and ≤ the room left under BOND_LAYER_CAP (999) from the bond's starting layers, only on bonds the
* player's lineup / band / effects / items name, none when the spec disables gains; coins ≤ the spawns' bounty coins;
* perfect consistent with the counted leaks; unit states only for the player's own units, within range.
* @returns {{ ok: true, result: object } | { ok: false, reason: string }}
*/
export function validateClientResult(spec, raw, { gd = null } = {}) {
@@ -763,7 +770,8 @@ export function validateClientResult(spec, raw, { gd = null } = {}) {
if (typeof p.perfect !== 'boolean' || p.perfect !== (countedLeaks === 0)) return bad('perfect');
const layerGains = {};
for (const [bondId, n] of Object.entries(p.layerGains || {})) {
if (!finiteIn(n, 0, B.layerCap)) return bad('layer bound');
// ≤ 60 + 4·round, and never past BOND_LAYER_CAP from the layers the bond started with (Battle.addLayers clamps)
if (!finiteIn(n, 0, Math.min(B.layerCap, layerGainRoom(own.startLayers.get(bondId) || 0, Infinity)))) return bad('layer bound');
if (n > 0 && spec.flags && spec.flags.layerGainsEnabled === false) return bad('layers disabled');
if (gd && typeof gd.bond === 'function' && !gd.bond(bondId)) return bad('bond');
if (n > 0 && own.bonds && !own.bonds.has(bondId)) return bad('layer bond');
+15 -12
View File
@@ -8,10 +8,13 @@
// RIGHT side (the sim mirrors the right side: board col c → field col 20 − c with the piece direction RIGHT ↔
// LEFT, UP / DOWN unchanged (DESIGN §3, research 09 §1.2 ConvertChessPositionInfoToBossMap); board rows 9–12 →
// boss rows 2–5, sim/constants BOSS_ROW_OFFSET). `bossFieldPlacement` gives that mapping for UIs / tools.
// * Shared boss HP pool (research 08 §6 #7, DESIGN §14 corrections): co-op = bloodPoint[difficulty] as is — split
// leaders share it, "敌方领袖的总生命值不变", no alive-player factor; solo = bloodPoint × config bossHpScale.solo (0.25,
// flagged unknown); × the tuning bossHpMul when data/tuning.json still has one (docs/BALANCE.md); shared by every
// boss field; bosses are never scaled by enemyScale.
// * Shared boss HP pool (DESIGN §20.10, GameData.bossPoolShare): one pool shared by every boss field (official tip
// "所有人将一起对敌方领袖造成伤害"); co-op = bloodPoint[difficulty] whatever the number of alive players (notice 5114's
// "敌方领袖的总生命值不变" is about the mirrored copies of a pair field sharing it, not about that number); config
// bossHpScale.aliveScaling true scales it × alive / 4 (巴哈姆特 12294 "聯機隊友(撤退/死掉)變少,最後boss血條也會變少" — one
// community note, no proportion; off until the user confirms it); solo = bloodPoint × config bossHpScale.solo (0.25,
// flagged unknown); × the tuning bossHpMul when data/tuning.json still has one (docs/BALANCE.md); bosses are never
// scaled by enemyScale.
// * Overtime: bossTurnHpReduceTime counts REAL seconds like the level's 120 s maxPlayTime (which runs out first; the
// battle goes on): from 150 real s (300 game s on the 2× field clock) the team loses bossOvertimeDrainPerSec (1) LP
// per real second (gamedata.js bossOvertimeDue); m.public.deadline = the 120 s countdown, m.public.overtimeAt = the
@@ -51,22 +54,22 @@ export function pairPlayers(alive) {
return groups;
}
/** Shared boss HP for a boss id. */
/** Shared boss HP for a boss id with `aliveCount` alive players (GameData.bossPoolShare; omitted ⇒ a full team). */
export function bossPoolHp(gd, bossId, aliveCount) {
const boss = gd.boss(bossId);
const diff = gd.difficulty;
let base = boss && boss.bloodPoint && Number.isFinite(boss.bloodPoint[diff]) ? boss.bloodPoint[diff] : null;
if (base == null && boss && boss.bloodPoint) base = Object.values(boss.bloodPoint).find((v) => Number.isFinite(v)) ?? null;
if (base == null) base = 500000;
const scale = gd.mode.bossHpScale && typeof gd.mode.bossHpScale === 'object' ? gd.mode.bossHpScale : {};
const cfg = gd.config.bossHpScale && typeof gd.config.bossHpScale === 'object' ? gd.config.bossHpScale : {};
const tune = typeof gd.bossHpMul === 'function' ? gd.bossHpMul(bossId) : 1;
if (gd.isSolo) {
const s = Number.isFinite(scale.solo) ? scale.solo : Number.isFinite(cfg.solo) ? cfg.solo : 0.25;
return Math.max(1, Math.round(base * s * tune));
let share;
if (typeof gd.bossPoolShare === 'function') share = gd.bossPoolShare(aliveCount);
else {
const scale = gd.mode.bossHpScale && typeof gd.mode.bossHpScale === 'object' ? gd.mode.bossHpScale : {};
const cfg = gd.config.bossHpScale && typeof gd.config.bossHpScale === 'object' ? gd.config.bossHpScale : {};
share = gd.isSolo ? (Number.isFinite(scale.solo) ? scale.solo : Number.isFinite(cfg.solo) ? cfg.solo : 0.25) : 1;
}
void aliveCount; // co-op: the same pool whatever the number of alive players
return Math.max(1, Math.round(base * tune));
return Math.max(1, Math.round(base * share * tune));
}
/**
+26 -7
View File
@@ -97,7 +97,7 @@ export class GameData {
/**
* Leader HP pool multiplier — always 1 (no custom balance). Kept for callers written against the old tuning layer
* (finalAssault.bossPoolHp); use `bossPoolHp` for the official pool.
* (finalAssault.bossPoolHp); use `bossPoolHp` / `bossPoolShare` for the official pool.
* @deprecated
*/
bossHpMul(bossId) { // eslint-disable-line no-unused-vars
@@ -105,23 +105,42 @@ export class GameData {
}
/**
* Official shared leader HP pool (research 08 §6 #7): co-op = bloodPoint[difficulty] as is — split leaders share one
* pool, "敌方领袖的总生命值不变", no alive-player scaling; solo = bloodPoint × config bossHpScale.solo (0.25,
* [ASSUMED], flagged in data). Leaders are never scaled by enemyScale.
* Official shared leader HP pool (DESIGN §20.10): ONE pool for every boss field of the match (official tip "最终攻势中,
* 所有人将一起对敌方领袖造成伤害"; the mirrored copies of a pair field share it — notice 5114 "两侧的敌方领袖共享生命值
* (敌方领袖的总生命值不变)", which is about those copies, not about the number of players). Co-op = bloodPoint
* [difficulty]; with config bossHpScale.aliveScaling (default false) × alive / aliveFull (4) — 巴哈姆特 12294 "聯機隊友
* (撤退/死掉)變少,最後boss血條也會變少" is one community note without a proportion, kept off until confirmed (it would
* shorten fights after eliminations, the opposite of the playtest report); `aliveCount` omitted ⇒ a full team. Solo = bloodPoint ×
* bossHpScale.solo (0.25 = one player of four, [ASSUMED]). Leaders are never scaled by enemyScale ("领袖单位于服务器的
* 生命值加成不受上述加成影响").
* @param {string} bossId
* @param {number} [aliveCount] alive players at the Final Assault / Hidden Core start (co-op)
* @returns {number}
*/
bossPoolHp(bossId) {
bossPoolHp(bossId, aliveCount) {
const boss = this.boss(bossId);
const diff = this.difficulty;
let base = boss && boss.bloodPoint && Number.isFinite(boss.bloodPoint[diff]) ? boss.bloodPoint[diff] : null;
if (base == null && boss && boss.bloodPoint) base = Object.values(boss.bloodPoint).find((v) => Number.isFinite(v)) ?? null;
if (base == null) base = 500000;
return Math.max(1, Math.round(base * this.bossPoolShare(aliveCount)));
}
/**
* Multiplier of bloodPoint for the leader pool (see bossPoolHp): solo = bossHpScale.solo (0.25); co-op = coop (1) ×
* min(alive, aliveFull) / aliveFull when bossHpScale.aliveScaling (mode entry first, then the global one).
* @param {number} [aliveCount]
*/
bossPoolShare(aliveCount) {
const ms = this.mode.bossHpScale && typeof this.mode.bossHpScale === 'object' ? this.mode.bossHpScale : {};
const cs = this.config.bossHpScale && typeof this.config.bossHpScale === 'object' ? this.config.bossHpScale : {};
const pick = (k, d) => (Number.isFinite(ms[k]) && ms[k] > 0 ? ms[k] : Number.isFinite(cs[k]) && cs[k] > 0 ? cs[k] : d);
const mul = this.isSolo ? pick('solo', 0.25) : pick('coop', 1);
return Math.max(1, Math.round(base * mul));
if (this.isSolo) return pick('solo', 0.25);
const scaling = typeof ms.aliveScaling === 'boolean' ? ms.aliveScaling : cs.aliveScaling === true;
const full = Math.max(1, Math.floor(pick('aliveFull', 4)));
const n = Number(aliveCount);
const alive = scaling && Number.isFinite(n) && n >= 1 ? Math.min(full, Math.floor(n)) : full;
return pick('coop', 1) * (alive / full);
}
/** config.titles with the tuning overrides (stat / rule per title id) merged in. */
+8 -2
View File
@@ -8,12 +8,14 @@
// copy, an elite ≤ goldenCopies; merges are immediate (never `mergeCount` normal copies of one chess, never
// two copies of a mergeable normal item); every token's owner chess is deployed
// board tiles inside the own region and legal for the piece; no items on the board; chess count ≤ deploy cap
// bonds ps.bonds equals a fresh computeBonds() (every mutation recomputed them)
// (where a merge's elite goes — a consumed deployed copy's tile, else the hand — needs the state before the
// merge: audit.js checks it per merge)
// bonds ps.bonds equals a fresh computeBonds() (every mutation recomputed them); every bond's layers 0 … BOND_LAYER_CAP
// shop slot count follows the rolled layout; ids known; banned chess never offered by the shop / rewards
// elim. an eliminated player owns nothing (board, hand, temp, shop, offers, bounties, funds)
// match phase known; teamLp / boss pool within range; combat fields match the alive players
import { PHASE } from '../../shared/constants.js';
import { PHASE, BOND_LAYER_CAP } from '../../shared/constants.js';
import { FIELD, canPlace, positionClass, parseKey } from './board.js';
import { computeBonds } from './bondsMeta.js';
@@ -52,6 +54,10 @@ export function collectViolations(m, { limit = 25 } = {}) {
if (!(ps.shop.level >= 1 && ps.shop.level <= gd.maxShopLevel)) fail(`${id}: shop level ${ps.shop.level}`);
if (!(ps.shop.upgradePrice >= 0)) fail(`${id}: upgradePrice ${ps.shop.upgradePrice}`);
if (!Number.isInteger(ps.shop.freeRefreshes) || ps.shop.freeRefreshes < 0) fail(`${id}: freeRefreshes ${ps.shop.freeRefreshes}`);
// the official per-bond layer cap (shared/constants.js): no writer may pass it
for (const [b, v] of Object.entries(ps.layers || {})) {
if (!(Number.isFinite(v) && v >= 0 && (!(BOND_LAYER_CAP > 0) || v <= BOND_LAYER_CAP))) fail(`${id}: ${b} layers ${v}`);
}
if (!ps.alive) {
if (ps.board.size) fail(`${id}: eliminated but keeps ${ps.board.size} board pieces`);