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

+14 -2
View File
@@ -264,7 +264,7 @@ test('item merge with a full hand AND a full temp: the golden item takes the equ
m.dispose();
});
test('a merge completed during SETTLE (突变细胞) keeps its reward offer for the next prep; a full hand sends the elite to a freed board tile', () => {
test('a merge completed during SETTLE (突变细胞) keeps its reward offer for the next prep; with a full hand the elite waits in temp for that prep', () => {
const X = 'chess_char_2_04_a';
const fillers = Object.values(DATA.items).filter((i) => i.itemType === 'EQUIP' && !i.isGolden && !String(i.kind || '').startsWith('consume')).map((i) => i.itemId ?? i.id).filter(Boolean);
for (const onBoard of [false, true]) {
@@ -295,7 +295,19 @@ test('a merge completed during SETTLE (突变细胞) keeps its reward offer for
assert.ok(elite, 'the elite survived the round start');
assert.deepEqual(ps.offers.map((o) => o.source), ['merge'], 'the promotion reward waits for this prep');
assert.ok(ps.privateView().shop.rewardOffer, 'and is shown');
if (onBoard) assert.ok(tiles.includes(ps.find(elite.uid).key), 'full hand outside PREP: the elite took a freed board tile, not temp');
if (onBoard) {
// research 01 A1: the elite goes to the hand, never straight to a board tile — the full hand overflows it into
// temp, where it stays (not wiped at the round start) and blocks Ready until the player places it
assert.equal(ps.find(elite.uid).area, 'temp', 'full hand outside PREP: the elite overflowed into temp');
assert.ok(ps.privateView().temp.some((v) => v && v.uid === elite.uid), 'shown in the temp row');
assert.equal(ps.privateView().canReady, false);
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { error: ERR.TEMP_NOT_EMPTY });
const [r, c] = tiles[0].split(',').map(Number);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: elite.uid, to: { area: 'board', row: r, col: c } }), { ok: true }, 'placed on a freed tile');
assert.ok(ps.tempEmpty && ps.privateView().canReady);
} else {
assert.equal(ps.find(elite.uid).area, 'hand', 'the consumed hand copies freed the slots');
}
assert.ok(ps.tempEmpty);
checkInvariants(m);
// it expires at the end of that prep like any other offer
+76 -1
View File
@@ -1,7 +1,8 @@
// The browser battle runner (public/js/battle/runner.js) under Node with a fake socket, a manual clock and manual
// animation frames: pacing (60 ticks per real second at 2×, ≤ 8 per frame), the b.snap / b.ev feed and the field meta,
// b.progress / b.result when authoritative (valid protocol frames, the same result as the server's simulation),
// fast-forward to `elapsed`, b.end (forced / takeover), the hidden-tab pump, the boss pool sync, phase clearing.
// fast-forward to `elapsed`, b.end (forced / takeover), the hidden-tab pump, the boss pool sync, phase clearing, the live
// leak count of normal fields (state().leaks, user playtest #3 item 2).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createBattleRunner, ticksPerFrameCap } from '../../public/js/battle/runner.js';
@@ -329,3 +330,77 @@ test('solo pause: m.public.paused freezes every local battle clock (no ticks, no
assert.ok(Math.abs(e.battle.tickCount - (t1 + 60)) <= 2, `resumed on the same clock, no catch-up burst (${e.battle.tickCount})`);
r.runner.dispose();
});
/** realStart() with an empty board: every enemy of the wave walks into the blue gate. */
function leakingStart(seed = 7307) {
const start = realStart(seed);
const spec = JSON.parse(JSON.stringify(start.spec));
for (const p of spec.players) p.units = [];
return { ...start, spec };
}
test('live leaks (user playtest #3 item 2): state().leaks carries the own field\'s counted leaks as they happen — authoritative or display — published in the frame of each leak; the settle rule\'s count at the end', async () => {
const start = leakingStart();
const r = rig();
const seen = [];
r.store.subscribe((s, prev) => {
const n = s.match.battle && s.match.battle.leaks ? s.match.battle.leaks[start.fieldId] : undefined;
const was = prev.match.battle && prev.match.battle.leaks ? prev.match.battle.leaks[start.fieldId] : undefined;
if (n !== was && n !== undefined) seen.push(n);
});
r.net.emit('b.start', start);
await r.settle();
const e = r.runner._entries.get(start.battleId);
assert.deepEqual(r.store.get().match.battle.leaks, { [start.fieldId]: 0 }, 'published with the field, 0 before any leak');
// step frame by frame: whenever the battle recorded a counted leak, the store has it after that same frame
for (let i = 0; i < 200 * 60 && !e.done; i++) {
r.advance(1000 / 60);
const now = specMod.battleProgress(e.battle).leaks;
assert.equal(r.store.get().match.battle.leaks[start.fieldId], now, `frame ${i}: the store follows the battle`);
}
assert.ok(e.done, 'finished');
const res = e.battle.result();
const counted = Object.values(res.perPlayer).reduce((n, pp) => n + pp.leaked.filter((l) => l && l.counted !== false).length, 0);
assert.ok(counted >= 3, `the empty board leaks (${counted})`);
assert.equal(r.store.get().match.battle.leaks[start.fieldId], counted, 'the settle rule\'s count (Match.settle: counted !== false)');
assert.equal(seen[seen.length - 1], counted, 'the last publish carries the final count');
assert.ok(seen.every((n, i) => i === 0 || n > seen[i - 1]), `one publish per new count, increasing (${seen})`);
r.runner.dispose();
// a display replica of the own field (the server simulates it: reconnect after a takeover) counts the same
const r2 = rig();
r2.net.emit('b.start', { ...start, authoritative: false, watch: false, elapsed: 30 });
await r2.settle();
const e2 = r2.runner._entries.get(start.battleId);
assert.equal(r2.store.get().match.battle.leaks[start.fieldId], specMod.battleProgress(e2.battle).leaks, 'counted during the silent catch-up');
for (let i = 0; i < 200 && !e2.done; i++) r2.advance(1000, 50);
assert.equal(r2.store.get().match.battle.leaks[start.fieldId], counted);
assert.equal(r2.net.sent.length, 0, 'still never reports');
// the round moves on: the next prep drops the battles and their counts
r2.store.patch('match', { public: { phase: 'SETTLE' } });
assert.equal(r2.store.get().match.battle.leaks[start.fieldId], counted, 'kept through SETTLE (the top bar resets on the settled m.private)');
r2.store.patch('match', { public: { phase: 'PREP' } });
assert.equal(r2.store.get().match.battle, null);
r2.runner.dispose();
});
test('live leaks: a boss field is not counted (the merged team LP moves through b.pool); b.end forced publishes the final count', async () => {
const start = leakingStart(7308);
const r = rig();
r.net.emit('b.start', start);
await r.settle();
r.advance(1500);
r.net.emit('b.end', { battleId: start.battleId, fieldId: start.fieldId, reason: 'timeout' });
await r.settle();
const e = r.runner._entries.get(start.battleId);
assert.ok(e.done);
const res = e.battle.result();
const counted = Object.values(res.perPlayer).reduce((n, pp) => n + pp.leaked.filter((l) => l && l.counted !== false).length, 0);
assert.equal(r.store.get().match.battle.leaks[start.fieldId], counted, 'timeout leaks (the enemies left on the field) are counted at once');
r.runner.dispose();
const r2 = rig();
r2.net.emit('b.start', { ...start, kind: 'boss', fieldId: 'b1', battleId: `${start.battleId}x` });
await r2.settle();
assert.deepEqual(r2.store.get().match.battle.leaks, {}, 'only normal fields');
r2.runner.dispose();
});
+218
View File
@@ -0,0 +1,218 @@
// 临时整备区 (temp overflow) lifetime (PlayerState tempDue, research 06 §4.3 "直到溢出情况排除才可开始进行作战" /
// "处于临时整备区的调度资源,在进入下一回合后会自动销毁"): a temp piece is resolved at the deadline of the first prep in
// which its player could act on it. What overflows after that — battle-result grants, SETTLE merges, returned
// equipment, <休整期结束时> grants after Ready — survives the round start and is shown and usable in the next prep.
// FakeBattle; the rule auditor (server/match/audit.js) watches every match.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ERR, PHASE } from '../../shared/constants.js';
import { MetaRegistry } from '../../server/match/effectsMeta.js';
import { registerBuiltins } from '../../server/match/builtinMeta.js';
import { attachAudit } from '../../server/match/audit.js';
import { DATA, makeMatch, give, giveItem, checkInvariants, chessOfTier, legalTileFor } from './harness.js';
/** Plain equipment (no equip / merge side effects): fillers for the hand and the pieces granted by the tests. */
const PLAIN = Object.values(DATA.items)
.filter((i) => i.itemType === 'EQUIP' && !i.isGolden && i.kind === 'passive')
.map((i) => i.itemId ?? i.id).filter(Boolean);
const GRANTS = PLAIN.slice(-3);
const FILL = PLAIN.slice(0, 10);
/**
* Co-op match (2 humans: timed prep, FakeBattle) in PREP R1 with p_0's hand full of plain items and one carrier on the
* board; `grants(ctx)` runs for p_0 on the hook `hook` (registry global). Audited from the start.
*/
function setup({ seed = 1500, hook = 'onBattleResult', grants = (ctx) => { for (const id of GRANTS.slice(0, 2)) ctx.grantItem(id); } } = {}) {
const reg = registerBuiltins(new MetaRegistry(), DATA);
let armed = false;
reg.global('temp_test', { [hook](ctx) { if (armed && ctx.playerId === 'p_0') { armed = false; grants(ctx); } } });
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed, fake: true, registry: reg }).start();
const audit = attachAudit(h.m);
h.toPrep(1);
const m = h.m;
const ps = h.ps('p_0');
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
ps.recompute();
const carrierId = chessOfTier(2, (c) => !m.gd.placeableTokens(c.chessId).length).find((id) => m.pool.has(id));
const carrier = give(m, ps, carrierId, 'board', legalTileFor(m, ps, carrierId));
for (let i = 0; ps.hand.some((x) => x == null); i++) giveItem(m, ps, FILL[i]);
return { h, m, ps, audit, carrier, arm: () => { armed = true; } };
}
test('equipment granted after the battle overflows into temp, survives the round start and is usable in the next prep', () => {
const { h, m, ps, audit, carrier, arm } = setup();
arm();
h.drive(() => m.phase === PHASE.SETTLE && m.round === 1);
const got = ps.temp.filter(Boolean).map((p) => p.id);
assert.deepEqual(got.sort(), GRANTS.slice(0, 2).sort(), 'the battle-result grants overflowed into temp (full hand)');
h.toPrep(2);
const kept = ps.temp.filter(Boolean);
assert.deepEqual(kept.map((p) => p.id).sort(), GRANTS.slice(0, 2).sort(), 'not wiped at the round start');
const view = ps.privateView();
assert.deepEqual(view.temp.filter(Boolean).map((v) => v.uid).sort(), kept.map((p) => p.uid).sort(), 'shown in the temp row');
assert.equal(view.canReady, false);
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { error: ERR.TEMP_NOT_EMPTY }, 'Ready is blocked until the overflow is cleared');
// usable: equip one straight from temp, destroy the other
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: kept[0].uid, targetUid: carrier.uid }), { ok: true });
assert.ok(carrier.items.some((it) => it.uid === kept[0].uid), 'equipped from temp');
assert.deepEqual(m.handle('p_0', { t: 'g.destroy', uid: kept[1].uid }), { ok: true });
assert.ok(ps.tempEmpty && ps.privateView().canReady);
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { ok: true });
checkInvariants(m);
h.drive(() => m.phase === PHASE.COMBAT && m.round === 2);
assert.deepEqual(audit.violations, [], audit.violations.join('\n'));
m.dispose();
});
/** A tier-1 chess of this match's pool without summons (a plain card for the grants). */
const plainChess = (m) => chessOfTier(1, (c) => !m.gd.placeableTokens(c.chessId).length).find((id) => m.pool.has(id) && m.pool.left(id) > 0);
test('a chess granted after the battle can be moved into a freed hand slot; what is left at the next deadline is resolved', () => {
let T = null;
const { h, m, ps, audit, arm } = setup({ seed: 1501, grants: (ctx) => { ctx.grantChess(T); ctx.grantItem(GRANTS[2]); } });
T = plainChess(m);
const cap = m.pool.cap(T);
arm();
h.toPrep(2);
const chess = ps.temp.find((p) => p && p.kind === 'chess');
const item = ps.temp.find((p) => p && p.kind === 'item');
assert.ok(chess && chess.id === T && item && item.id === GRANTS[2], 'both grants waited in temp');
assert.equal(m.pool.left(T), cap - 1, 'the temp chess holds its pool copy');
// free a hand slot (destroy a filler) and take the chess into it
const filler = ps.hand.findIndex((p) => p && p.kind === 'item');
assert.deepEqual(m.handle('p_0', { t: 'g.destroy', uid: ps.hand[filler].uid }), { ok: true });
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: chess.uid, to: { area: 'hand', idx: filler } }), { ok: true });
assert.equal(ps.find(chess.uid).area, 'hand');
// the item stays unhandled: the R2 deadline (co-op prep is timed) destroys it
const p1 = h.ps('p_1');
assert.deepEqual(m.handle('p_1', { t: 'g.ready', ready: true }), { ok: true });
assert.ok(m.deadline > h.sched.now());
h.sched.advance(m.deadline - h.sched.now() + 1);
assert.notEqual(m.phase, PHASE.PREP);
assert.ok(p1.ready && ps.ready);
assert.ok(ps.tempEmpty, 'resolved at the deadline of the prep it was shown in');
assert.ok(!ps.find(item.uid), 'the item was destroyed');
assert.equal(ps.find(chess.uid).area, 'hand', 'the chess taken out of temp is kept');
assert.equal(m.pool.left(T), cap - 1);
checkInvariants(m);
assert.deepEqual(audit.violations, [], audit.violations.join('\n'));
m.dispose();
});
test('a chess still in temp at the next deadline is sold back: its pool copy returns', () => {
let T = null;
const { h, m, ps, audit, arm } = setup({ seed: 1502, grants: (ctx) => { ctx.grantChess(T); } });
T = plainChess(m);
const cap = m.pool.cap(T);
arm();
h.toPrep(2);
assert.ok(ps.temp.some((p) => p && p.id === T));
m.handle('p_1', { t: 'g.ready', ready: true });
h.sched.advance(m.deadline - h.sched.now() + 1);
assert.notEqual(m.phase, PHASE.PREP);
assert.ok(ps.tempEmpty);
assert.equal(m.pool.left(T), cap, 'copy back in the shared pool');
checkInvariants(m);
assert.deepEqual(audit.violations, [], audit.violations.join('\n'));
m.dispose();
});
test('<休整期结束时> grants after Ready overflow into temp and are kept for the next prep (never wiped unseen)', () => {
const { h, m, ps, audit, arm } = setup({ seed: 1503, hook: 'onPrepEnd' });
arm();
// both players ready with empty temps → the prep ends → onPrepEnd grants overflow into p_0's temp
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { ok: true });
assert.deepEqual(m.handle('p_1', { t: 'g.ready', ready: true }), { ok: true });
h.run(() => m.phase !== PHASE.PREP);
assert.equal(ps.temp.filter(Boolean).length, 2, 'the prep-end grants are in temp');
const uids = ps.temp.filter(Boolean).map((p) => p.uid).sort();
assert.ok(ps.temp.filter(Boolean).every((p) => ps.tempDue(p) === ps.prepsEnded), 'due at the next prep');
h.toPrep(2);
assert.deepEqual(ps.temp.filter(Boolean).map((p) => p.uid).sort(), uids, 'kept through COMBAT, SETTLE and the round start');
assert.equal(ps.privateView().canReady, false);
// left alone they expire at this prep's deadline
m.handle('p_1', { t: 'g.ready', ready: true });
h.sched.advance(m.deadline - h.sched.now() + 1);
assert.notEqual(m.phase, PHASE.PREP);
assert.ok(ps.tempEmpty);
assert.deepEqual(audit.violations, [], audit.violations.join('\n'));
m.dispose();
});
test('an overflow while ready waits for the next prep; un-readying makes it due at this prep', () => {
for (const unready of [false, true]) {
const { h, m, ps, audit } = setup({ seed: 1504 });
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { ok: true });
// a passive gain while p_0 is ready (a teammate's effect, a delayed grant …)
const got = ps.acquireItem(GRANTS[0], { source: 'test' });
assert.equal(ps.find(got.uid).area, 'temp');
assert.equal(ps.tempDue(got), ps.prepsEnded + 1, 'the player could not act on it any more: due at the next prep');
if (unready) {
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: false }), { ok: true });
assert.equal(ps.tempDue(got), ps.prepsEnded, 'un-ready: it can be handled now, so it is due at this prep');
}
m.handle('p_1', { t: 'g.ready', ready: true });
// still ready: everybody is, so the prep ends at once; un-readied: the deadline ends it
if (unready) h.sched.advance(m.deadline - h.sched.now() + 1);
else h.run(() => m.phase !== PHASE.PREP);
assert.notEqual(m.phase, PHASE.PREP);
if (unready) assert.ok(!ps.find(got.uid), 'resolved at this deadline');
else assert.equal(ps.find(got.uid)?.area, 'temp', 'kept for the next prep');
checkInvariants(m);
assert.deepEqual(audit.violations, [], `${unready}:\n${audit.violations.join('\n')}`);
m.dispose();
}
});
test('an overflow during the prep before Ready expires at that prep\'s deadline (and is due now)', () => {
const { h, m, ps, audit } = setup({ seed: 1505 });
const got = ps.acquireItem(GRANTS[1], { source: 'test' });
assert.equal(ps.find(got.uid).area, 'temp');
assert.equal(ps.tempDue(got), ps.prepsEnded);
m.handle('p_1', { t: 'g.ready', ready: true });
h.sched.advance(m.deadline - h.sched.now() + 1);
assert.notEqual(m.phase, PHASE.PREP);
assert.ok(!ps.find(got.uid) && ps.tempEmpty);
assert.deepEqual(audit.violations, [], audit.violations.join('\n'));
m.dispose();
});
test('the rule auditor flags a temp piece kept past its prep deadline (engine regression guard)', () => {
const { h, m, ps, audit } = setup({ seed: 1506 });
ps.resolveTemp = () => {}; // simulated engine bug: the deadline never resolves temp
ps.acquireItem(GRANTS[1], { source: 'test' });
m.handle('p_1', { t: 'g.ready', ready: true });
h.sched.advance(m.deadline - h.sched.now() + 1);
assert.notEqual(m.phase, PHASE.PREP);
assert.ok(audit.violations.some((v) => /p_0: temp piece .* survived its prep end/.test(v)), audit.violations.join('\n'));
assert.ok(!audit.violations.some((v) => v.includes('p_1')));
m.dispose();
});
test('a bot clears the temp contents it finds at the start of its prep and readies before the deadline', () => {
const reg = registerBuiltins(new MetaRegistry(), DATA);
let armed = true;
reg.global('temp_test', {
onBattleResult(ctx) {
if (!armed || ctx.playerId !== 'ai_0') return;
armed = false;
for (let i = 0; i < 12; i++) ctx.grantItem(PLAIN[i % PLAIN.length]);
},
});
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 1507, fake: true, registry: reg }).start();
const audit = attachAudit(h.m);
const m = h.m;
h.toPrep(2);
const bot = h.ps('ai_0');
assert.ok(!bot.tempEmpty, 'the bot starts its prep with an overflow');
const deadline = m.deadline;
assert.ok(h.run(() => bot.ready || m.phase !== PHASE.PREP));
assert.ok(bot.ready && h.sched.now() < deadline, 'ready before the deadline');
assert.ok(bot.tempEmpty);
checkInvariants(m);
h.drive(() => m.phase === PHASE.COMBAT && m.round === 2);
assert.deepEqual(audit.violations, [], audit.violations.join('\n'));
m.dispose();
});