Playtest #3 fixes: lost models, picking, live LP, temp overflow, 蕾缪安 S3, 回环射手, FX, detail card, spinners
- #1 lost operator models: the Spine store no longer hands out a skeleton whose unload is in flight (PIXI.Assets.unload is async); eviction runs 1 s after a release and the empty-scene budget only after 3 s - #7 picking: one shared rule (render/pick.js, pixel probe where bodies overlap) for prep/battle clicks, hover, the enemy pen and equipment drops; the drag ghost stands on its drop tile; hover probes capped at one per frame - #2 the top-bar LP drops live as the own battle's enemies enter the blue gate (−N, 联防中), teammates' pending LP in the team panel - #3 temp overflow pieces that arrive after a prep deadline survive through the next prep (tempDue); temp-row warning and ready-button reason - #4 蕾缪安 S3: locks every 0.5 s, then one shell every 0.3 s in lock order (PRTS notes), bombardShell fx - #5 回环射手: real boomerang (out 15, back 3.75 tiles/s, attack only when every boomerang is caught) - #6 visible projectiles, muzzle flashes, slashes, skill bursts and auras at unchanged frame cost - #8 特质 right under the detail card header, with its official type icon - #9 no spinning busy cursors / spinners next to texts; game data warmed before the match - docs, recordings and tests updated (2414 unit tests, all browser suites green)
This commit is contained in:
1 parent
01a44cd288
commit
0aba602ecf
72 files changed
+5132
-571
No files matched your search
+29
-1
@@ -72,6 +72,9 @@
|
||||
// duplicate b.result (a browser re-sending one it believes lost) is answered ok and changes nothing.
|
||||
// Solo pause (g.pause { on }, setPause): freezes the field clocks, result deadlines, server release timers, the boss
|
||||
// clock and the server pacers; m.public.paused; resume shifts every clock / deadline by the pause.
|
||||
// Live LP (user playtest #3 item 2): during COMBAT / 联防 m.public players[].pendingLp = min(lpCapPerRound, the counted
|
||||
// leaks of the player's own battle so far) (_pendingLpView; omitted when 0) — the teammates' rows of the team panel
|
||||
// show lp − pendingLp; the settlement lands with the SETTLE view, where it is gone.
|
||||
// opts.clientCombat default true (env SP_COMBAT=server → false: the legacy server-run + snapshot streaming mode)
|
||||
// opts.verify 'off' | 'sample' | 'all' (env SP_VERIFY, default 'off'): re-simulate accepted client results
|
||||
// ('sample': ~1 in 8, in a later callback, mismatches logged; 'all': before accepting — the
|
||||
@@ -125,7 +128,7 @@ import {
|
||||
FieldRunner, DeadBattle, GAME_SPEED, snapFrame, runHeadless, timelineAt, HeadlessPacer, syntheticResult,
|
||||
validateClientResult, RESULT_GRACE_MS, BOSS_SILENCE_MS, HARD_CAP_SECONDS, HeadlessJob, HEADLESS_SLICE_MS, CATCHUP_TICKS_PER_INTERVAL,
|
||||
} from './fields.js';
|
||||
import { buildBattleSpec, createBattleFromSpec, resultDigest, compactResult as compactForVerify } from '../sim/spec.js';
|
||||
import { buildBattleSpec, createBattleFromSpec, resultDigest, compactResult as compactForVerify, battleProgress } from '../sim/spec.js';
|
||||
import { CreditPool } from './finalAssault.js';
|
||||
import { buildResult } from './results.js';
|
||||
import { botPrepBeginSteps, botPrepEndSteps, botPickBand, botPickCard } from './bot.js';
|
||||
@@ -753,6 +756,8 @@ export class Match {
|
||||
fieldId: this.fieldOf(ps),
|
||||
status: this.statusOf(ps),
|
||||
autoplay: ps.autoplay,
|
||||
// the LP this round's own battle will cost at settlement so far (COMBAT / 联防 only, omitted when 0)
|
||||
...this._pendingLpView(ps),
|
||||
})),
|
||||
fields: this.fields.map((f) => {
|
||||
const v = { fieldId: f.fieldId, kind: f.kind, players: f.players.slice(), live: !!f.live };
|
||||
@@ -1778,6 +1783,29 @@ export class Match {
|
||||
return { killed: f.progress.killed, total: f.progress.total, done: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* LP a player's own battle of this normal round will cost at settlement so far — settle()'s min(lpCapPerRound,
|
||||
* counted leaks) — for the teammates' live LP (m.public players[].pendingLp, user playtest #3 item 2; the own client
|
||||
* counts its local battle itself). COMBAT: the recorded result once every field is done, else the field's result, else
|
||||
* the authority's b.progress leaks (a server-run field reports none before its result is released); 联防: the own
|
||||
* battle's count — the most the 联防 can charge (settle: min(cap, survivors) ≤ min(cap, counted)). Omitted when 0 and
|
||||
* in every other phase (boss rounds charge the merged team LP live).
|
||||
* @returns {{ pendingLp?: number }}
|
||||
*/
|
||||
_pendingLpView(ps) {
|
||||
if (!ps || !ps.alive || (this.phase !== PHASE.COMBAT && this.phase !== PHASE.UNITE)) return {};
|
||||
const counted = (r) => (r && Array.isArray(r.leaked) ? r.leaked.filter((l) => l && l.counted !== false).length : 0);
|
||||
let n = 0;
|
||||
if (this.lastResults.has(ps.playerId)) n = counted(this.lastResults.get(ps.playerId));
|
||||
else if (this.phase === PHASE.COMBAT) {
|
||||
const f = this.fields.find((x) => x && x.kind === 'normal' && Array.isArray(x.players) && x.players.includes(ps.playerId));
|
||||
if (f && f.cc) n = f.done && f.result ? counted(f.result.perPlayer && f.result.perPlayer[ps.playerId]) : Number(f.progress && f.progress.leaks) || 0;
|
||||
else if (f && f.battle) { try { n = battleProgress(f.battle).leaks; } catch { n = 0; } }
|
||||
}
|
||||
const loss = Math.min(this.gd.lpCapPerRound, Math.max(0, Math.trunc(Number(n) || 0)));
|
||||
return loss > 0 ? { pendingLp: loss } : {};
|
||||
}
|
||||
|
||||
/** The connected human who simulates a field: lowest seat among its players (normal: the owner). */
|
||||
_authorityFor(f, exclude = null) {
|
||||
let best = null;
|
||||
|
||||
+68
-17
@@ -3,8 +3,14 @@
|
||||
// Handlers validate → mutate → recompute bonds → mark the private view dirty. They never throw on bad input; they
|
||||
// return `{ ok: true }` or `{ error: ERR.*, detail? }`. Rules (research 00-INDEX §3–§4, 01 A1, 04 §2):
|
||||
// * Hand (整备区) 10 slots filled right→left, temp (临时整备区) 5 slots. A full hand refuses buys / withdrawals,
|
||||
// except a purchase that completes a merge and a withdrawal whose own summon stack frees a slot. Passive gains (merge results, grants, returned equipment) overflow
|
||||
// into temp; temp blocks Ready; temp is wiped at the next round start (and auto-resolved at the prep deadline).
|
||||
// except a purchase that completes a merge and a withdrawal whose own summon stack frees a slot. Passive gains (merge
|
||||
// results, grants, returned equipment) overflow into temp; temp blocks Ready ("直到溢出情况排除才可开始进行作战").
|
||||
// A temp piece is resolved (chess sold back to the pool, items / summon stacks destroyed) at the deadline of the
|
||||
// first prep in which the player could act on it (tempDue): a piece that overflowed during a prep before Ready
|
||||
// expires at that prep's end; one that arrived after Ready, at the prep end (<休整期结束时> grants), in COMBAT /
|
||||
// SETTLE (battle-result grants, merges, returned equipment) or at the next round start / 机变 stays visible and
|
||||
// usable (move to a free hand slot, place, equip, destroy, sell) through the NEXT prep — nothing is destroyed before
|
||||
// the player saw it in a prep ("处于临时整备区的调度资源,在进入下一回合后会自动销毁").
|
||||
// * Board: own region rows 9–12 × cols 2–10, legality from the stage legend (board.js); deploy cap 8 (+effects);
|
||||
// tokens (placeable summons) don't use deploy slots. Board↔hand swaps are always allowed. A terrain change
|
||||
// (terrain 机变 cards, content overrides) withdraws the pieces left on tiles they may no longer occupy (hand,
|
||||
@@ -14,8 +20,10 @@
|
||||
// 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
|
||||
// reward offer of 3 free chess of tier min(level+1, 6) is queued (pick 1, expires at prep end; an offer earned
|
||||
// after the prep — SETTLE / Final Assault effects — is kept for the next prep). Outside PREP a full hand sends the
|
||||
// elite to a freed board tile of a consumed copy rather than to temp (wiped at the next round start).
|
||||
// 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.
|
||||
// * 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
|
||||
@@ -81,6 +89,10 @@ export class PlayerState {
|
||||
this.hand = new Array(HAND_SIZE).fill(null);
|
||||
/** @type {Array<any>} */
|
||||
this.temp = new Array(TEMP_SIZE).fill(null);
|
||||
/** preps of this player that ended so far (endPrep) = index of the current (or next) prep */
|
||||
this.prepsEnded = 0;
|
||||
/** @type {Map<number, number>} temp piece uid → index of the prep whose deadline resolves it (see tempDue) */
|
||||
this._tempDue = new Map();
|
||||
/** @type {Map<string, any>} 'r,c' → piece */
|
||||
this.board = new Map();
|
||||
/** persistent bond layers */
|
||||
@@ -128,6 +140,30 @@ export class PlayerState {
|
||||
get deployCount() { let n = 0; for (const p of this.board.values()) if (p.kind === 'chess') n++; return n; }
|
||||
get tempEmpty() { return this.temp.every((x) => x == null); }
|
||||
|
||||
/**
|
||||
* Index of the prep whose deadline resolves a temp piece (compare with `prepsEnded`): recorded when the piece entered
|
||||
* temp (_putTemp); a piece put there by other means counts as due at the current (or next) prep.
|
||||
*/
|
||||
tempDue(piece) {
|
||||
const due = piece ? this._tempDue.get(piece.uid) : undefined;
|
||||
return Number.isInteger(due) ? due : this.prepsEnded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Due prep of a piece entering temp now: the current prep while the player can still act on it (PREP, not ready);
|
||||
* after Ready or at the prep end (onPrepEnd grants) the next one; outside PREP (COMBAT, SETTLE, ROUND_START, 机变)
|
||||
* the next prep to end — `prepsEnded` then already names it.
|
||||
*/
|
||||
_tempDueNow() {
|
||||
return this.prepsEnded + (this.m.phase === PHASE.PREP && this.ready ? 1 : 0);
|
||||
}
|
||||
|
||||
/** Every write of a piece into a temp slot goes through here (records its due prep). */
|
||||
_putTemp(i, piece) {
|
||||
this.temp[i] = piece;
|
||||
this._tempDue.set(piece.uid, this._tempDueNow());
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the operator loadout (DESIGN §16) after re-checking it against this match's data. Accepts the checked
|
||||
* form `{ id: { skill, module|null } }` or raw `room.loadout` entries. Returns false (loadout unchanged) when it does
|
||||
@@ -259,7 +295,7 @@ export class PlayerState {
|
||||
_detach(loc) {
|
||||
if (!loc) return;
|
||||
if (loc.area === 'hand') this.hand[loc.idx] = null;
|
||||
else if (loc.area === 'temp') this.temp[loc.idx] = null;
|
||||
else if (loc.area === 'temp') { this.temp[loc.idx] = null; this._tempDue.delete(loc.piece.uid); }
|
||||
else if (loc.area === 'board') this.board.delete(loc.key);
|
||||
else if (loc.area === 'equipped') {
|
||||
const i = loc.holder.items.indexOf(loc.piece);
|
||||
@@ -268,7 +304,8 @@ export class PlayerState {
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a piece into the hand (right→left) or, when `allowTemp`, the temp slots. Returns 'hand' | 'temp' | null.
|
||||
* Put a piece into the hand (right→left) or, when `allowTemp`, the temp slots (due at the deadline of the first prep
|
||||
* in which the player can act on it, _tempDueNow). Returns 'hand' | 'temp' | null.
|
||||
*/
|
||||
stow(piece, { allowTemp = true, toTemp = false, preferIdx = null } = {}) {
|
||||
if (!toTemp) {
|
||||
@@ -281,7 +318,7 @@ export class PlayerState {
|
||||
if (!allowTemp) return null;
|
||||
}
|
||||
const j = freeSlot(this.temp);
|
||||
if (j >= 0) { this.temp[j] = piece; return 'temp'; }
|
||||
if (j >= 0) { this._putTemp(j, piece); return 'temp'; }
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -411,10 +448,9 @@ export class PlayerState {
|
||||
this.grantTokensFor(elite);
|
||||
return 'board';
|
||||
};
|
||||
// outside PREP (a merge completed by SETTLE / battle-result effects) the player cannot clear the temp slots before
|
||||
// they are wiped at the next round start: with a full hand the elite takes a freed board tile instead
|
||||
const canAct = this.m.phase === PHASE.PREP;
|
||||
let where = this.stow(elite, { allowTemp: canAct || !boardLoc });
|
||||
// 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
|
||||
@@ -896,7 +932,7 @@ export class PlayerState {
|
||||
/** Put a piece into the container slot described by `loc` (hand/temp idx), or anywhere free. */
|
||||
_putBack(loc, piece) {
|
||||
if (loc.area === 'hand' && this.hand[loc.idx] == null) { this.hand[loc.idx] = piece; return true; }
|
||||
if (loc.area === 'temp' && this.temp[loc.idx] == null) { this.temp[loc.idx] = piece; return true; }
|
||||
if (loc.area === 'temp' && this.temp[loc.idx] == null) { this._putTemp(loc.idx, piece); return true; }
|
||||
return !!this.stow(piece, { allowTemp: true });
|
||||
}
|
||||
|
||||
@@ -1006,7 +1042,7 @@ export class PlayerState {
|
||||
this.hand[idx] = piece;
|
||||
if (occ) {
|
||||
if (loc.area === 'hand') this.hand[loc.idx] = occ;
|
||||
else this.temp[loc.idx] = occ;
|
||||
else this._putTemp(loc.idx, occ);
|
||||
}
|
||||
this.recompute();
|
||||
return OK;
|
||||
@@ -1161,18 +1197,25 @@ export class PlayerState {
|
||||
if (on && !this.tempEmpty) return fail(ERR.TEMP_NOT_EMPTY);
|
||||
if (this.ready === !!on) return OK;
|
||||
this.ready = !!on;
|
||||
// un-ready: the player can act again, so what overflowed while it was ready is due at this prep's deadline
|
||||
if (!on) for (const p of this.temp) if (p && this.tempDue(p) > this.prepsEnded) this._tempDue.set(p.uid, this.prepsEnded);
|
||||
this.dirty();
|
||||
this.m.onReadyChanged(this);
|
||||
return OK;
|
||||
}
|
||||
|
||||
/** Prep deadline / round start: temp contents are sold (copies back to the pool) or destroyed. */
|
||||
/**
|
||||
* Prep deadline: every temp piece due at this prep (tempDue ≤ prepsEnded) is resolved — a chess is sold back (its
|
||||
* pool copies return, its summons are removed), items and summon stacks are destroyed. Pieces that overflowed after
|
||||
* the player could no longer act on them (after Ready, at the prep end) are kept for the next prep.
|
||||
*/
|
||||
resolveTemp() {
|
||||
let changed = false;
|
||||
for (let i = 0; i < this.temp.length; i++) {
|
||||
const p = this.temp[i];
|
||||
if (!p) continue;
|
||||
if (!p || this.tempDue(p) > this.prepsEnded) continue;
|
||||
this.temp[i] = null;
|
||||
this._tempDue.delete(p.uid);
|
||||
changed = true;
|
||||
if (p.kind === 'chess') {
|
||||
this.removeTokensOf(p.uid);
|
||||
@@ -1194,8 +1237,9 @@ export class PlayerState {
|
||||
this.m.dispatch(this, 'onIncome', ev);
|
||||
const nonNeg = (v) => (Number.isFinite(v) && v > 0 ? Math.trunc(v) : 0);
|
||||
this.addFunds(nonNeg(ev.income) + nonNeg(ev.pending), { reason: 'income' });
|
||||
this.resolveTemp();
|
||||
// reward offers of the last prep already expired at its end (endPrep); what is still queued was earned after it —
|
||||
// temp is NOT wiped here: the last prep's deadline resolved what the player could act on (endPrep); what overflowed
|
||||
// after it (battle-result grants, SETTLE merges, returned equipment) is shown and usable in this prep (tempDue).
|
||||
// Likewise reward offers of the last prep already expired at its end; what is still queued was earned after it —
|
||||
// a merge completed during SETTLE / the Final Assault (突变细胞, battle-result grants) — and is shown in this prep
|
||||
this.ready = false;
|
||||
this.rollShop({ keepFrozen: true });
|
||||
@@ -1204,8 +1248,14 @@ export class PlayerState {
|
||||
this.recompute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prep deadline (Match.endPrep, after the <休整期结束时> onPrepEnd effects): the temp pieces due at this prep are
|
||||
* resolved; what overflowed after Ready or during onPrepEnd stays for the next prep, which `prepsEnded` now names.
|
||||
*/
|
||||
endPrep() {
|
||||
this.resolveTemp();
|
||||
this.prepsEnded++;
|
||||
for (const uid of [...this._tempDue.keys()]) if (!this.temp.some((p) => p && p.uid === uid)) this._tempDue.delete(uid);
|
||||
this.offers = [];
|
||||
this.clearUnfrozenShop();
|
||||
if (!this.gd.leftoverKeptBands.includes(this.bandId)) this.funds = 0;
|
||||
@@ -1225,6 +1275,7 @@ export class PlayerState {
|
||||
this.board.clear();
|
||||
this.hand.fill(null);
|
||||
this.temp.fill(null);
|
||||
this._tempDue.clear();
|
||||
this.offers = [];
|
||||
this.bounties = [];
|
||||
this.shop.slots = [];
|
||||
|
||||
+48
-15
@@ -2,15 +2,22 @@
|
||||
//
|
||||
// attachAudit(m) wraps a live Match's phase transitions and a few prep handlers (instance-level wrappers; the engine
|
||||
// is untouched) and records every rule violation it observes, next to the structural invariants of invariants.js:
|
||||
// prep end leftover funds lost (carry bands excepted), temp resolved, reward offers expired
|
||||
// temp (临时整备区) every piece entering a temp slot (PlayerState._putTemp) is due at the deadline of the first
|
||||
// prep in which its player could act on it: the current prep when it arrived in PREP before Ready,
|
||||
// the next one when it arrived after Ready / at the prep end, else (COMBAT, SETTLE, ROUND_START, 机变)
|
||||
// the next prep to end; a piece that got into temp by other means counts as due at the prep in
|
||||
// progress when it is first seen at a prep end. No temp piece may outlive its due prep.
|
||||
// prep end leftover funds lost (carry bands excepted), temp resolved (only pieces due at a later prep stay),
|
||||
// reward offers expired
|
||||
// round start income = config income(r) (= min(3 + r, 12)) + pending funds, upgrade price −1 (floor 0) from R2,
|
||||
// temp wiped, offers earned after the last prep kept, frozen slots kept in place (same id; chess slots
|
||||
// keep their index, the item slot(s) follow the chess slots, so a level-up moves them right),
|
||||
// everything else rerolled with tier ≤ shop level from the unbanned pool, freeze toggle released
|
||||
// temp not wiped (nothing overdue), offers earned after the last prep kept, frozen slots kept in
|
||||
// place (same id; chess slots keep their index, the item slot(s) follow the chess slots, so a
|
||||
// level-up moves them right), everything else rerolled with tier ≤ shop level from the unbanned pool,
|
||||
// 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
|
||||
// combat start temp empty, everyone ready, funds lost (carry bands excepted), unfrozen shop cleared, one field per
|
||||
// alive player
|
||||
// price, the level rises by one and its price resets to the next base; Ready only with an empty temp
|
||||
// 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
|
||||
// maps consistent, 6 (co-op) / 3 (solo) cards
|
||||
// 联防 decided after the COMBAT_END pause from the players still in: runs iff co-op with ≥ 1 leaker and
|
||||
@@ -74,7 +81,34 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
|
||||
if (hook === 'onIncome' && ev && ps) incomeEv.set(ps, { initial: { income: ev.income, pending: ev.pending }, ev });
|
||||
return orig(ps, hook, ev, opts);
|
||||
});
|
||||
// temp arrivals: ps → Map(uid → index of the prep whose deadline resolves it); ps → preps ended (own count)
|
||||
const tempDue = new Map();
|
||||
const prepsEnded = new Map();
|
||||
const endedOf = (ps) => prepsEnded.get(ps) || 0;
|
||||
const dueOf = (ps) => { let d = tempDue.get(ps); if (!d) tempDue.set(ps, (d = new Map())); return d; };
|
||||
const overdue = (ps, label) => {
|
||||
const d = dueOf(ps);
|
||||
for (const p of ps.temp) {
|
||||
if (!p) continue;
|
||||
const due = d.get(p.uid);
|
||||
if (due != null && due < endedOf(ps)) fail(`${ps.playerId}: temp piece ${p.id} (due at prep ${due}) survived ${label} (${endedOf(ps)} preps ended)`);
|
||||
}
|
||||
};
|
||||
for (const ps of m.players.values()) {
|
||||
wrap(ps, '_putTemp', function (orig, i, piece) {
|
||||
const res = orig(i, piece);
|
||||
// after Ready / at the prep end the player cannot act on it any more: due at the next prep
|
||||
if (piece) dueOf(ps).set(piece.uid, endedOf(ps) + (m.phase === PHASE.PREP && ps.ready ? 1 : 0));
|
||||
return res;
|
||||
});
|
||||
wrap(ps, 'setReady', function (orig, on) {
|
||||
const was = ps.ready;
|
||||
const res = orig(on);
|
||||
// un-ready: the player can act on what arrived while it was ready — due at this prep again
|
||||
if (res && res.ok && was && !on) for (const p of ps.temp) if (p && (dueOf(ps).get(p.uid) ?? 0) > endedOf(ps)) dueOf(ps).set(p.uid, endedOf(ps));
|
||||
if (res && res.ok && on) check('ready', () => { if (!ps.tempEmpty) fail(`${ps.playerId}: ready with a non-empty temp`); });
|
||||
return res;
|
||||
});
|
||||
wrap(ps, '_rollChessSlot', function (orig) {
|
||||
const s = orig();
|
||||
if (s) {
|
||||
@@ -114,7 +148,8 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
|
||||
const upWant = r > 1 ? Math.max(0, up0 - 1) : up0;
|
||||
if (ps.shop.upgradePrice !== upWant) fail(`${id}: upgrade price ${up0} → ${ps.shop.upgradePrice}, expected ${upWant}`);
|
||||
if (ps.pendingFunds !== 0) fail(`${id}: pending funds not cleared`);
|
||||
if (!ps.tempEmpty) fail(`${id}: temp not wiped at round start`);
|
||||
// temp is not wiped here: what overflowed after the last prep's deadline is shown in this prep
|
||||
overdue(ps, 'the round start');
|
||||
// offers of the last prep expired at its end; the ones queued after it (SETTLE merges) wait for this prep
|
||||
if (offers0.some((o) => !ps.offers.includes(o))) fail(`${id}: a reward offer earned after the prep was dropped at the round start`);
|
||||
if (ps.shop.frozen) fail(`${id}: freeze toggle still on after the round start`);
|
||||
@@ -183,20 +218,18 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
|
||||
return res;
|
||||
});
|
||||
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));
|
||||
const res = orig();
|
||||
prepsEnded.set(ps, endedOf(ps) + 1);
|
||||
check('prep end', () => {
|
||||
// leftover funds are lost at prep end (carry bands excepted); gains after this (SETTLE effects) are kept
|
||||
if (ps.funds !== 0 && !carries(ps)) fail(`${ps.playerId}: kept ${ps.funds} funds past the prep end without a carry band`);
|
||||
if (!ps.tempEmpty) fail(`${ps.playerId}: temp not resolved at prep end`);
|
||||
overdue(ps, 'its prep end');
|
||||
if (ps.offers.length) fail(`${ps.playerId}: reward offer survived the prep end`);
|
||||
});
|
||||
return res;
|
||||
});
|
||||
wrap(ps, 'setReady', function (orig, on) {
|
||||
const res = orig(on);
|
||||
if (res && res.ok && on) check('ready', () => { if (!ps.tempEmpty) fail(`${ps.playerId}: ready with a non-empty temp`); });
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
// ---- phases ------------------------------------------------------------------------------------------------
|
||||
@@ -283,7 +316,7 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
|
||||
check('combat start', () => {
|
||||
for (const ps of m.alivePlayers()) {
|
||||
const id = ps.playerId;
|
||||
if (!ps.tempEmpty) fail(`${id}: temp not empty at combat start`);
|
||||
overdue(ps, 'into combat');
|
||||
if (!ps.ready) fail(`${id}: not ready at combat start`);
|
||||
if (ps.funds !== 0 && !carries(ps)) fail(`${id}: kept ${ps.funds} funds into combat`);
|
||||
if (ps.offers.length) fail(`${id}: reward offer survived the prep`);
|
||||
|
||||
Reference in new issue
Block a user