Files
sganggs d4f868cd19 Playtest #4 fixes: tile picking, knocked-down state, element gauges, draft timer, live stats, shop items, drones, 赫默, 炎佑, UI flash
- #1 picking by the tile under the pointer (prep, battle, pen, equipment drops); the dragged model stays
  under the pointer (mouse and touch); pixel probe removed; phone ghost-click on the underframe fixed
- #2 two-tap confirm on 机变 cards (like the shop)
- #3 a 同盟 room with one human is untimed like 独立模拟 (no countdown outside battles)
- #4 strategy draft: one synced countdown, 30 s per human turn, timeout takes the highlighted band
- #5 special 维式重锤 (4) and 突变细胞 no longer sold; item card shows where they come from
- #6 operator skill-mode sounds no longer play on normal attacks (纯烬艾雅法拉 and 55 others)
- #7 detail card shows live battle stats and the prep-time battle-start stats (g.unitStats)
- #8 official element gauges on units; burst cooldown (阻回) per PRTS
- #9 knocked-down operators stay on their tile with a redeploy countdown ring
- #10 drone behaviour per PRTS (暴鸰, 先兆者 shells, 黑云, 萨科塔之眼)
- #11 赫默's drone only from her skill; placeable summons limited to talent tokens
- #12 炎佑 stays where it is with no enemies; 祛恶之焰 as a continuous channel
- #13 the occasional giant bond icon (loading texture scaled ×46) fixed
- docs (DESIGN §18), recordings, data and tests updated (2467 unit tests, all browser suites green)
2026-09-30 05:35:45 +08:00

220 lines
12 KiB
JavaScript

// 临时整备区 (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]);
},
});
// two humans: the prep stays timed (a single human is untimed, Match.soloUntimed — user playtest #4 item 3)
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, 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();
});