Seen: the Art 教鞭 (杜宾's strategy) drew three 战术特训 cards and added a random one — a documented simplification (docs/META.md §2.5: a personal choice would need its own phase, protocol message, timer and AI takeover). Official: act2autochess choice_event hunter_band_1 is a PERSONAL_CHOOSE; PRTS 下半 记录 §法术 教鞭 "使用后销毁,于3个战术特训的悬赏任务中选择一项". The owner decided on 2026-10-08 to follow it. Taken from PR #353 (code and tests by @Sukvii), ported to the split 0.2.2 modules. Now: ctx.offerBountyChoice -> Match.offerBountyChoice keeps up to three different mode-eligible cards (15 of 20 in 标准 / 险境, 20 in 绝境 / 终极; one meta-rng shuffle) in PlayerState.personalChoice, m.private only (same cards after a reconnect); g.choice { idx, choiceId } confirms it (Match.pickPersonalChoice: owner, id, round, index; a request without a choiceId is still the public draft, a stale id never falls through to it). No new phase or timer: Ready is refused while it is open, the prep's deadline picks at random before the temp pieces and Ready, a bot or a 托管 seat picks with the bounty scorer (botPickCard, the bots' seeded rng; at the prep's start, after the cast, before Ready; the scheduler's fallback picks at random if a step fails). The client reuses ChoiceOverlay (personal: no order, no turns, the prep's countdown) over a locked prep, the Ready reasons, the mock harness; six UI strings in en / ja / ko / zh-TW. An eliminated player's offer goes with it; invariants.js flags an offer outside its owner's own open prep. Not taken from the PR: its §25.24 in the released docs/history/0.2.0.md (the 0.2.2 section is the integrator's), its edits of the Ready toggle beyond the personal-choice case, its tail placement of the i18n keys. Golden: coop2-NORMAL-8-serverrun (ai_0 杜宾 casts 教鞭 from R2 on and now scores the three cards: teamLp 39 -> 34, virtualMs 121400 -> 118400, rng shop 511 -> 483 / bots 18063 -> 18070 / meta 112 -> 103) and coop2-ABYSS-10-boosted (ai_1 杜宾: virtualMs 121650 -> 124650, shop 590 -> 589 / bots 22075 -> 22096 / meta 141 -> 154); both first differ in round 2, every other scenario and family is unchanged; node tools/golden.mjs --twice is identical. Tests: items.test.js (three distinct cards, the tapped one applied once, nothing before the confirm, a refused cast keeps the Art / count / rng / uid, an eliminated player's offer), economy.test.js (Ready refused, the deadline picks before endPrep, solo prep untimed), connection.test.js (two players' offers, reconnect, foreign / duplicate / old ids, an offline player's pick at the deadline), bot.test.js (托管 takeover incl. the failing-step fallback), bot-smarter.test.js (the scorer's pick, deterministic per seed, no rng draw when nothing is pending), followups.test.js (R14 / R15), draft.test.js, unitStats.test.js (protocol), hardening.test.js (invariant), gameLogic / playtest3 / playtest4 (view model, Ready reason, ChoiceView, actions), mock.e2e.test.js and playtest2.real.e2e.test.js (browser, SP_E2E=1).
213 lines
14 KiB
JavaScript
213 lines
14 KiB
JavaScript
// server/match/invariants.js — engine invariants (DESIGN §11) as a non-throwing check. Used by the test harness
|
|
// (i18n-ignore-file: developer reports in English with the game's terms, never shown to players — docs/I18N.md)
|
|
// (test/match/harness.js checkInvariants asserts the list is empty) and by tools/matchrun.mjs --check sweeps.
|
|
//
|
|
// collectViolations(m) → string[] (empty when every invariant holds):
|
|
// pool 0 ≤ left ≤ cap and left + Σ copies held by pieces == cap per base chess; non-pool chess hold 0 copies;
|
|
// a player's 自选 stock (0.2.0, player/diy.js) the same against its own pieces of each slotted slot — a DIY
|
|
// piece is always a slotted slot of its owner's, and a DIY shop / reward card one of its stocked slots
|
|
// economy funds / pendingFunds non-negative integers, LP finite, shop level in range, prices ≥ 0
|
|
// pieces unique uids; hand 10 / temp 5 slots; temp holds pieces only while the hand is full (a free hand slot pulls
|
|
// a temp piece in, PlayerState._fillHandFromTemp); chess carry ≤ equipPerChess known items; a normal piece
|
|
// holds ≤ 1 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 (a range-bound summon inside its owner's attack
|
|
// range); no items on the board; chess count ≤ deploy cap
|
|
// (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)
|
|
// choice a player's open 教鞭 choice (`personalChoice`) is one of an alive, not-Ready player in the PREP of its own round,
|
|
// with one to three different cards
|
|
// match phase known; teamLp / boss pool within range; combat fields match the alive players
|
|
|
|
import { PHASE, BOND_LAYER_CAP } from '../../shared/constants.js';
|
|
import { FIELD, canPlace, placeClass, positionClass, parseKey } from './board.js';
|
|
import { computeBonds } from './bondsMeta.js';
|
|
|
|
const PHASES = new Set(Object.values(PHASE));
|
|
|
|
/**
|
|
* @param {import('./Match.js').Match} m
|
|
* @param {{ limit?: number }} [opts]
|
|
* @returns {string[]}
|
|
*/
|
|
export function collectViolations(m, { limit = 25 } = {}) {
|
|
const out = [];
|
|
const fail = (msg) => { if (out.length < limit) out.push(msg); };
|
|
const gd = m.gd;
|
|
const held = new Map();
|
|
const uids = new Set();
|
|
const banned = new Set(m.bannedChess || []);
|
|
const note = (ps, p) => {
|
|
if (!Number.isInteger(p.uid) || p.uid <= 0) fail(`${ps.playerId}: bad uid ${p.uid}`);
|
|
else if (uids.has(p.uid)) fail(`${ps.playerId}: duplicate uid ${p.uid}`);
|
|
uids.add(p.uid);
|
|
};
|
|
|
|
if (!PHASES.has(m.phase)) fail(`unknown phase ${m.phase}`);
|
|
if (m.teamLp != null && !(Number.isFinite(m.teamLp) && m.teamLp >= 0)) fail(`teamLp ${m.teamLp}`);
|
|
if (m.bossPool && !(m.bossPool.hp >= 0 && m.bossPool.hp <= m.bossPool.maxHp)) fail(`boss pool ${m.bossPool.hp}/${m.bossPool.maxHp}`);
|
|
|
|
for (const ps of m.players.values()) {
|
|
const id = ps.playerId;
|
|
// the player's view of the data (its slotted 自选 slots are its operators) and the copies its 自选 pieces hold
|
|
const pgd = ps.gd || gd;
|
|
const diyHeld = new Map();
|
|
if (!Number.isInteger(ps.funds) || ps.funds < 0) fail(`${id}: funds ${ps.funds}`);
|
|
if (!Number.isInteger(ps.pendingFunds) || ps.pendingFunds < 0) fail(`${id}: pendingFunds ${ps.pendingFunds}`);
|
|
if (!Number.isFinite(ps.lp)) fail(`${id}: lp ${ps.lp}`);
|
|
if (ps.alive && m.phase !== PHASE.SETTLE && ps.lp <= 0 && m.teamLp == null && m.round > 0 && ps.bandId) fail(`${id}: alive with lp ${ps.lp}`);
|
|
if (ps.hand.length !== gd.benchSize) fail(`${id}: hand has ${ps.hand.length} slots`);
|
|
if (ps.temp.length !== gd.tempSize) fail(`${id}: temp has ${ps.temp.length} slots`);
|
|
// 临时整备区 = overflow only (PRTS 卫戍协议/帮助 §手牌区 "常规手牌区出现空位时自动移入", GitHub #82)
|
|
if (ps.temp.some(Boolean) && ps.hand.some((x) => x == null)) fail(`${id}: a temp piece waits while a hand slot is free`);
|
|
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`);
|
|
if (ps.hand.some(Boolean) || ps.temp.some(Boolean)) fail(`${id}: eliminated but keeps hand/temp pieces`);
|
|
if (ps.shop.slots.length || ps.offers.length || ps.bounties.length) fail(`${id}: eliminated but keeps shop/offers/bounties`);
|
|
if (ps.funds || ps.pendingFunds) fail(`${id}: eliminated with funds ${ps.funds}+${ps.pendingFunds}`);
|
|
}
|
|
// 教鞭's personal choice (Match.offerBountyChoice): resolved before the prep ends — by its owner, the deadline or the bot
|
|
const pc = ps.personalChoice;
|
|
if (pc) {
|
|
if (!ps.alive || ps.ready || m.phase !== PHASE.PREP || pc.round !== m.round) fail(`${id}: a personal choice outside its own prep (${m.phase} R${m.round}, offered in R${pc.round})`);
|
|
if (!Array.isArray(pc.cards) || !pc.cards.length || pc.cards.length > 3 || new Set(pc.cards.map((c) => c && c.effectId)).size !== pc.cards.length) fail(`${id}: personal choice of ${pc.cards && pc.cards.length} cards`);
|
|
}
|
|
|
|
// pieces
|
|
const all = [...ps.board.values(), ...ps.hand.filter(Boolean), ...ps.temp.filter(Boolean)];
|
|
const boardChessUids = new Set();
|
|
for (const p of ps.board.values()) if (p.kind === 'chess') boardChessUids.add(p.uid);
|
|
const itemCounts = new Map();
|
|
// A copy gained as the previous prep ended (PlayerState.acquireItem deferMerge) waits until the next prep.
|
|
// It does not count toward "merges are immediate" until that prep's checkItemMerges consumes it.
|
|
const countItem = (it) => { if (it.deferMerge) return; itemCounts.set(it.id, (itemCounts.get(it.id) || 0) + 1); };
|
|
for (const p of all) {
|
|
note(ps, p);
|
|
if (p.kind === 'chess') {
|
|
const rec = pgd.chess(p.id);
|
|
if (!rec) { fail(`${id}: unknown chess ${p.id}`); continue; }
|
|
if (rec.isDiy && !rec.diyFor) fail(`${id}: owns ${p.id}, a 自选 slot it has not filled`);
|
|
if (!Array.isArray(p.items) || p.items.length > gd.equipPerChess) fail(`${id}: ${p.id} carries ${p.items && p.items.length} items`);
|
|
for (const it of p.items || []) {
|
|
note(ps, it);
|
|
if (it.kind !== 'item' || !gd.item(it.id)) fail(`${id}: ${p.id} carries a bad item ${it.kind}:${it.id}`);
|
|
else if (gd.item(it.id).itemType !== 'EQUIP') fail(`${id}: ${p.id} carries a non-equipment item ${it.id}`);
|
|
countItem(it);
|
|
}
|
|
const maxCopies = rec.isGolden ? gd.goldenCopies : 1;
|
|
if (!Number.isInteger(p.poolCopies) || p.poolCopies < 0 || p.poolCopies > maxCopies) fail(`${id}: ${p.id} holds ${p.poolCopies} copies`);
|
|
const base = gd.baseIdOf(p.id);
|
|
const tally = rec.isDiy ? diyHeld : held;
|
|
tally.set(base, (tally.get(base) || 0) + (p.poolCopies || 0));
|
|
} else if (p.kind === 'item') {
|
|
if (!gd.item(p.id)) fail(`${id}: unknown item ${p.id}`);
|
|
countItem(p);
|
|
} else if (p.kind === 'token') {
|
|
if (!pgd.token(p.id)) fail(`${id}: unknown token ${p.id}`);
|
|
if (!(p.count >= 1)) fail(`${id}: token stack count ${p.count}`);
|
|
// summons exist only while their owner is deployed (withdrawing / selling / merging it removes them)
|
|
if (!boardChessUids.has(p.ownerUid)) fail(`${id}: token ${p.uid} without a deployed owner (${p.ownerUid})`);
|
|
} else {
|
|
fail(`${id}: piece of unknown kind ${p.kind}`);
|
|
}
|
|
}
|
|
// board
|
|
let deployed = 0;
|
|
// a pure read (Match.deployMapFor: the field the player deploys on now) — PlayerState.deployMap() would update the
|
|
// player's cached field / legality flag, and this checker must not change when a later recompute moves pieces
|
|
const dmap = typeof m.deployMapFor === 'function' ? m.deployMapFor(ps) : ps.deployMap();
|
|
for (const [k, p] of ps.board) {
|
|
const [r, c] = parseKey(k);
|
|
if (!(r >= FIELD.r0 && r <= FIELD.r1 && c >= FIELD.c0 && c <= FIELD.c1)) fail(`${id}: piece outside the board at ${k}`);
|
|
if (p.kind === 'item') { fail(`${id}: item ${p.id} stands on the board`); continue; }
|
|
const rec = p.kind === 'token' ? pgd.token(p.id) : pgd.chess(p.id);
|
|
const cls = p.kind === 'chess' ? placeClass(ps, rec) : positionClass(rec);
|
|
if (rec && !canPlace(dmap, cls, r, c)) fail(`${id}: ${p.id} on an illegal tile ${k}`);
|
|
// a "只能部署在召唤者攻击范围内" summon inside its owner's attack range (PlayerState.summonRange: a pure read)
|
|
const range = p.kind === 'token' && typeof ps.summonRange === 'function' ? ps.summonRange(p) : null;
|
|
if (range && !range.has(k)) fail(`${id}: ${p.id} on ${k}, outside its owner's attack range`);
|
|
// an outside-bound summon (战术锚点, PlayerState.summonExcluded) outside it
|
|
const out = p.kind === 'token' && typeof ps.summonExcluded === 'function' ? ps.summonExcluded(p) : null;
|
|
if (out && out.has(k)) fail(`${id}: ${p.id} on ${k}, inside its owner's attack range`);
|
|
if (p.kind === 'chess') deployed++;
|
|
}
|
|
if (deployed > ps.deployCap) fail(`${id}: ${deployed} chess deployed > cap ${ps.deployCap}`);
|
|
// merges are immediate
|
|
const copies = new Map();
|
|
for (const p of all) if (p.kind === 'chess' && pgd.chess(p.id) && !gd.isGolden(p.id)) { const b = gd.baseIdOf(p.id); copies.set(b, (copies.get(b) || 0) + 1); }
|
|
for (const [b, n] of copies) {
|
|
const need = gd.mergeCount(b);
|
|
if (need > 1 && gd.goldenIdOf(b) && n >= need) fail(`${id}: owns ${n} normal copies of ${b} (merges at ${need})`);
|
|
}
|
|
for (const [itemId, n] of itemCounts) {
|
|
const rec = gd.item(itemId);
|
|
if (!rec || rec.isGolden || rec.itemType !== 'EQUIP' || !rec.mergeable) continue;
|
|
const need = Number.isInteger(rec.upgradeNum) ? rec.upgradeNum : gd.itemMergeCount;
|
|
const gid = rec.upgradeChessId || rec.goldenId;
|
|
if (need > 1 && need < 100 && gid && gd.item(gid) && n >= need) fail(`${id}: owns ${n} copies of item ${itemId} (merges at ${need})`);
|
|
}
|
|
// bonds are up to date
|
|
try {
|
|
const fresh = computeBonds(pgd, ps);
|
|
if (JSON.stringify(fresh) !== JSON.stringify(ps.bonds)) fail(`${id}: stale bonds (missing recompute)`);
|
|
} catch (e) {
|
|
fail(`${id}: computeBonds threw ${e && e.message}`);
|
|
}
|
|
// shop
|
|
if (ps.alive) {
|
|
const layout = ps.shop.layout;
|
|
if (layout && ps.shop.slots.length !== layout.chess + layout.item) fail(`${id}: ${ps.shop.slots.length} shop slots for layout ${layout.chess}+${layout.item}`);
|
|
ps.shop.slots.forEach((s, i) => {
|
|
if (!s) return;
|
|
if (s.kind === 'chess' ? !gd.chess(s.id) : !gd.item(s.id)) fail(`${id}: shop slot ${i} unknown ${s.kind} ${s.id}`);
|
|
if (!Number.isInteger(s.basePrice) || s.basePrice < 0) fail(`${id}: shop slot ${i} basePrice ${s.basePrice}`);
|
|
if (s.kind === 'chess' && banned.has(gd.baseIdOf(s.id))) fail(`${id}: banned chess ${s.id} in the shop`);
|
|
if (s.kind === 'chess' && gd.chess(s.id)?.isDiy && !(ps.diyStock && ps.diyStock.has(gd.baseIdOf(s.id)))) fail(`${id}: 自选 slot ${s.id} in the shop without its stock`);
|
|
});
|
|
for (const o of ps.offers) {
|
|
for (const s of o.slots || []) {
|
|
if (s.kind === 'item' ? !gd.item(s.id) : !gd.chess(s.id)) fail(`${id}: bad reward slot ${s.kind} ${s.id}`);
|
|
if (s.kind !== 'item' && banned.has(gd.baseIdOf(s.id)) && o.source === 'merge') fail(`${id}: banned chess ${s.id} offered as a merge reward`);
|
|
if (s.kind !== 'item' && gd.chess(s.id)?.isDiy && !(ps.diyStock && ps.diyStock.has(gd.baseIdOf(s.id)))) fail(`${id}: 自选 slot ${s.id} offered without its stock`);
|
|
}
|
|
if (!o.slots || !o.slots.length || o.slots.length > 6) fail(`${id}: reward offer with ${o.slots && o.slots.length} slots`);
|
|
}
|
|
}
|
|
// 自选 stock accounting (player/diy.js): left + held == cap per slotted slot; a slot without stock holds nothing
|
|
for (const [base, e] of ps.diyStock ? ps.diyStock.entries : []) {
|
|
if (!(e.left >= 0 && e.left <= e.cap)) fail(`${id}: 自选 stock ${base}: left ${e.left} cap ${e.cap}`);
|
|
const h = diyHeld.get(base) || 0;
|
|
if (e.left + h !== e.cap) fail(`${id}: 自选 stock ${base}: left ${e.left} + held ${h} != cap ${e.cap}`);
|
|
}
|
|
for (const [base, n] of diyHeld) if (!(ps.diyStock && ps.diyStock.has(base)) && n !== 0) fail(`${id}: 自选 slot ${base} without stock holds ${n} copies`);
|
|
}
|
|
|
|
// shared pool accounting
|
|
for (const [base, e] of m.pool.entries) {
|
|
if (!(e.left >= 0 && e.left <= e.cap)) fail(`pool ${base}: left ${e.left} cap ${e.cap}`);
|
|
const h = held.get(base) || 0;
|
|
if (e.left + h !== e.cap) fail(`pool ${base}: left ${e.left} + held ${h} != cap ${e.cap}`);
|
|
}
|
|
for (const [base, n] of held) if (!m.pool.has(base) && n !== 0) fail(`non-pool chess ${base} holds ${n} copies`);
|
|
|
|
// combat fields
|
|
if (m.phase === PHASE.COMBAT) {
|
|
// one field per alive player (+ the force-ended field of a player who quit during this combat)
|
|
const alive = m.order.filter((p) => p.alive || (p.left && m.fields.some((f) => f.fieldId === `n:${p.playerId}`))).map((p) => `n:${p.playerId}`).sort();
|
|
const ids = m.fields.map((f) => f.fieldId).sort();
|
|
if (JSON.stringify(alive) !== JSON.stringify(ids)) fail(`combat fields ${ids.join(',')} != alive ${alive.join(',')}`);
|
|
}
|
|
return out;
|
|
}
|