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:
sganggs committed 2026-09-29 23:42:55 +08:00
1 parent 01a44cd288
commit 0aba602ecf
72 files changed
+5132 -571

No files matched your search

+29 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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`);