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

+65 -1
View File
@@ -12,8 +12,9 @@ import {
bondMembers, bannedPerBond, priceTone, mergeProgress, shopBlockReason, deploySets, indexPieces, placementContext, canPlace,
boardTargets, dropIntent, normalizeDraft, normalizeSp, groupEnemies, factionTypes, snapHud, bossFrac, attackInterval, fmtNum,
rangeGridBox, shortcutFor, sanitizeSettings, DEFAULT_SETTINGS, normalizeResult, cycleField, fieldLabel, homeFieldId,
activeBubbles, sortedPlayers, tileKey, prepCapsuleLabel, prepCamera,
activeBubbles, sortedPlayers, tileKey, prepCapsuleLabel, prepCamera, pickPieceAt, equipRetarget,
} from '../../public/js/ui/gameLogic.js';
import { presetCamera } from '../../public/js/render/projection.js';
import { pairPlayers } from '../../server/match/finalAssault.js';
import { PHASE, GEO } from '../../shared/constants.js';
@@ -492,3 +493,66 @@ describe('prepCamera (research 09 §1.2: the Final Assault prep on the own half
assert.deepEqual(prepCamera(pub(14, [pl('a', 0)]), 'a'), { kind: 'bossPrep', opts: { side: 'L' } });
});
});
// user playtest #3 item 7: equipment dropped on the operator standing behind another selected the one in front
describe('pickPieceAt / equipRetarget (the unit under a dragged item)', () => {
// two operators in one column on the official prep camera: uid 1 on row 9 (front), uid 2 on row 10 (behind, same
// height); `shape` = the drawn body the render engine reports through view.pieceScreenRect
const cam = presetCamera('prep', { width: 1920, height: 1080 });
const unit = (uid, row, col) => {
const p = cam.project(col, row, 0);
const shape = { kind: 'chibi', x: p.x, y: p.y, s: p.s, h: 1.27, flip: 1, depth: -cam.depthOf(col, row, 0) * 100 + col * 0.001 };
// the pre-fix rect (0.7 × 1.28 tiles) — plain rects are what the DOM fallback reports
const rect = { left: p.x - 0.35 * p.s, right: p.x + 0.35 * p.s, top: p.y - 1.18 * p.s, bottom: p.y + 0.1 * p.s };
return { uid, row, col, p, shape, rect };
};
const A = unit(1, 9, 4), B = unit(2, 10, 4);
const engine = [A, B].map((u) => ({ uid: u.uid, ...u.rect, shape: u.shape, tile: { row: u.row, col: u.col } }));
const plain = [A, B].map((u) => ({ uid: u.uid, ...u.rect }));
const up = (u, k, dx = 0) => [u.p.x + dx * u.p.s, u.p.y - k * u.p.s];
test('engine shapes: the face / torso of each is its own; the lower half of the unit behind is no longer the front one\'s', () => {
for (const k of [1.0, 0.6, 0.35]) assert.equal(pickPieceAt(engine, ...up(B, k), { row: 10, col: 4 }), 2, `B ${k} tile up`);
for (const k of [0.86, 0.5, 0.1]) assert.equal(pickPieceAt(engine, ...up(A, k), { row: 9, col: 4 }), 1, `A ${k} tile up`);
// B's tile showing beside A's head: B (the old front-most rect gave A)
const side = up(B, 0, 0.3);
assert.equal(pickPieceAt(engine, ...side, { row: 10, col: 4 }), 2);
assert.equal(pickPieceAt(plain, ...side), 1, 'plain rects: the overlap still favours the nearer centre');
// off both bodies: the tile under the point decides
assert.equal(pickPieceAt(engine, ...up(B, 0, 0.47), { row: 10, col: 4 }), 2);
assert.equal(pickPieceAt(engine, ...up(B, 0, 0.47), { row: 10, col: 5 }), null);
});
test('plain rects (DOM fallback): the rect whose centre is nearest relative to its size, ties → the front-most', () => {
const rects = [
{ uid: 1, left: 100, right: 160, top: 400, bottom: 520 }, // front (lower on screen)
{ uid: 2, left: 100, right: 160, top: 350, bottom: 470 }, // behind
];
assert.equal(pickPieceAt(rects, 130, 450), 1, 'nearer the front one\'s centre');
assert.equal(pickPieceAt(rects, 130, 430), 2, 'nearer the one behind (the front-most rule gave 1)');
assert.equal(pickPieceAt(rects, 130, 435), 1, 'equidistant: the front-most');
assert.equal(pickPieceAt(rects, 130, 360), 2);
assert.equal(pickPieceAt(rects, 10, 10), null);
assert.equal(pickPieceAt(rects, NaN, 10), null);
assert.equal(pickPieceAt(null, 1, 1), null);
assert.equal(pickPieceAt([{ uid: 'x', left: 0, right: 9, top: 0, bottom: 9 }, null], 5, 5), null);
});
test('equipRetarget sends the item to the unit the engine picked (hitUid), Arts never', () => {
const priv = {
alive: true, ready: false, funds: 10, deployCap: 8, deployCount: 2,
board: [piece(RANGED, { row: 9, col: 3 }), piece(RANGED, { row: 10, col: 3 })],
hand: [item(EQUIP), item(MAGIC), ...new Array(GEO.HAND_SIZE - 2).fill(null)],
temp: new Array(GEO.TEMP_SIZE).fill(null), shop: { slots: [] },
};
const ctx = placementContext({ priv, stage: STAGE, editable: true, getChess, getToken, getItem });
const [front, behind] = priv.board;
const [eq, art] = priv.hand;
const onFrontTile = { area: 'board', row: 9, col: 3 };
assert.deepEqual(equipRetarget(ctx, eq.uid, onFrontTile, behind.uid), { area: 'board', row: 10, col: 3 });
assert.deepEqual(dropIntent(ctx, eq.uid, equipRetarget(ctx, eq.uid, onFrontTile, behind.uid)).fields, { itemUid: eq.uid, targetUid: behind.uid });
assert.deepEqual(equipRetarget(ctx, art.uid, onFrontTile, behind.uid), onFrontTile, 'an Art is used on its tile');
assert.deepEqual(equipRetarget(ctx, eq.uid, onFrontTile, null), onFrontTile);
assert.deepEqual(equipRetarget(ctx, front.uid, onFrontTile, behind.uid), onFrontTile, 'units are not retargeted');
});
});
+466
View File
@@ -0,0 +1,466 @@
// Regression tests (Node) of user playtest #3, HUD / UI items:
// 2 the own LP drops live while the own battle's enemies enter the blue gate — pendingLoss / liveLp (the settle
// rule min(lpCapPerRound, counted leaks), reset when the settled m.private lands, 联防中 during 联防), the team
// panel rows (rowLp: own live value, teammates' m.public players[].pendingLp), the LP tower's −N tick, and the
// server's pendingLp (server/match/Match.js _pendingLpView)
// 3 the temp overflow row (临时整备区): the ready button's visible reason, the row's frame geometry (tempRowFrame)
// 8 the operator's own effect (特质 / garrison) right under the detail card's header (CHESS_SECTIONS, rendered order)
// 9 no spinning busy indicators next to texts: no wait / progress cursors, the button's busy bar instead of a
// spinning ring, the 机变 card's 选择中 strip (never stuck), a still hourglass; the data store retries a transient
// failure and every data file the match UI reads is one the match screen waits for (GAME_FILES); the 特质 chip's
// icon is the garrison's official type icon (the spoked 特异化 glyph only on 特异化 特质, not on every 休整期 one)
// (browser counterparts: the scratch checks of the playtest-3 workstream; the E2E suites cover the flows)
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { PHASE, GEO } from '../../shared/constants.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
// the browser data store reads the real data files from disk (ChessDetail / ChoiceOverlay renders below)
globalThis.fetch = async (url) => {
const name = String(url).split('/').pop();
try {
const body = readFileSync(path.join(ROOT, 'data', name), 'utf8');
return { ok: true, status: 200, json: async () => JSON.parse(body) };
} catch {
return { ok: false, status: 404, json: async () => ({}) };
}
};
const { pendingLoss, liveLp, ownLeaks, pendingTip, tempInfo, tempReadyReason, ReadyToggle, TEMP_RULE } = await import('../../public/js/ui/hud.js');
const { rowLp } = await import('../../public/js/ui/teamPanel.js');
const { LpTower, GAME_FILES } = await import('../../public/js/ui/gameComponents.js');
const { tempRowFrame, tempRowRule } = await import('../../public/js/ui/underframe.js');
const { ChessDetail, CHESS_SECTIONS, garrisonTypeIconKey } = await import('../../public/js/ui/detailPanel.js');
const { ChoiceOverlay, pickBusy } = await import('../../public/js/ui/choiceOverlay.js');
const { Button, Spinner } = await import('../../public/js/ui/components.js');
const { data, createDataStore, RETRY_DELAYS_MS } = await import('../../public/js/data.js');
/** Every vnode of a preact tree (htm output), depth first. */
function* walk(v) {
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
if (!v || typeof v !== 'object') return;
yield v;
yield* walk(v.props?.children);
}
/** The text of a vnode tree in document order. */
const textOf = (v) => {
if (v == null || typeof v === 'boolean') return '';
if (typeof v === 'string' || typeof v === 'number') return String(v);
if (Array.isArray(v)) return v.map(textOf).join('');
return typeof v === 'object' ? textOf(v.props?.children) : '';
};
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
// ---- 2: live LP ----------------------------------------------------------------------------------------------------
describe('2: the own LP drops live while the battle runs', () => {
test('pendingLoss is the settle rule: min(lpCapPerRound, counted leaks), never negative', () => {
assert.equal(pendingLoss(0), 0);
assert.equal(pendingLoss(3), 3);
assert.equal(pendingLoss(10), 10);
assert.equal(pendingLoss(14), 10, 'capped (config lpCapPerRound 10)');
assert.equal(pendingLoss(14, 12), 12, 'the configured cap');
for (const junk of [-3, NaN, null, undefined, 'x']) assert.equal(pendingLoss(junk), 0, String(junk));
const cfg = JSON.parse(read('data/config.json'));
assert.equal(cfg.lpCapPerRound, 10, 'the cap the client reads (gd.config.lpCapPerRound)');
});
test('the own count: the further of the local runner\'s and the server\'s (a display replica stands still while the player watches a teammate)', () => {
assert.equal(ownLeaks(3, 1), 3, 'the local battle is ahead of the ~1 Hz b.progress');
assert.equal(ownLeaks(2, 5), 5, 'a display replica paused while watching another field: the server moved on');
assert.equal(ownLeaks(undefined, 4), 4, 'no local entry (reconnect, still loading)');
assert.equal(ownLeaks(4, undefined), 4);
for (const [a, b] of [[undefined, undefined], [NaN, null], [-2, 'x']]) assert.equal(ownLeaks(a, b), 0);
// the game screen feeds liveLp with it
assert.match(read('public/js/screens/game.js'), /leaks: ownLeaks\(localLeaks, meP\?\.pendingLp\)/);
});
/** Feed liveLp a sequence of states, keeping its base like the game screen (lpBaseRef). */
const run = (steps) => {
let base = null;
return steps.map((s) => { const r = liveLp(base, s); base = r.base; return r; });
};
test('COMBAT: lp − pending as leaks come in; the settled m.private (sent before the SETTLE m.public) drops the pending part — never subtracted twice', () => {
const at = (o) => ({ phase: PHASE.COMBAT, round: 5, lp: 30, statsLeaks: 7, leaks: 0, cap: 10, ...o });
const out = run([
at({}),
at({ leaks: 1 }),
at({ leaks: 3 }),
// the settlement: m.private (lp 27, stats.leaks 10) lands while m.public still says COMBAT (Match.flush order)
at({ leaks: 3, lp: 27, statsLeaks: 10 }),
// then the SETTLE m.public
{ phase: PHASE.SETTLE, round: 5, lp: 27, statsLeaks: 10, leaks: 3, cap: 10 },
// the next round
{ phase: PHASE.PREP, round: 6, lp: 27, statsLeaks: 10, leaks: 0, cap: 10 },
{ phase: PHASE.COMBAT, round: 6, lp: 27, statsLeaks: 10, leaks: 0, cap: 10 },
{ phase: PHASE.COMBAT, round: 6, lp: 27, statsLeaks: 10, leaks: 2, cap: 10 },
]);
assert.deepEqual(out.map((r) => r.shown), [30, 29, 27, 27, 27, 27, 27, 25]);
assert.deepEqual(out.map((r) => r.pending), [0, 1, 3, 0, 0, 0, 0, 2]);
assert.ok(out.every((r) => r.unite === false));
});
test('a settlement that charged nothing (a 联防 saved everything) is seen through stats.leaks; the SETTLE phase alone drops it too', () => {
const at = (o) => ({ phase: PHASE.UNITE, round: 8, lp: 20, statsLeaks: 4, leaks: 5, cap: 10, ...o });
const out = run([
{ phase: PHASE.COMBAT, round: 8, lp: 20, statsLeaks: 4, leaks: 5, cap: 10 },
at({}),
at({ statsLeaks: 9 }), // settled: lp unchanged (0 lost), the counted leaks recorded
{ phase: PHASE.SETTLE, round: 8, lp: 20, statsLeaks: 9, leaks: 5, cap: 10 },
]);
assert.deepEqual(out.map((r) => r.shown), [15, 15, 20, 20]);
assert.deepEqual(out.map((r) => r.unite), [false, true, false, false], '联防中 while the 联防 may still save part of it');
// without stats (older view): the SETTLE phase ends it
const bare = run([{ phase: PHASE.UNITE, round: 8, lp: 20, leaks: 5 }, { phase: PHASE.SETTLE, round: 8, lp: 20, leaks: 5 }]);
assert.deepEqual(bare.map((r) => r.shown), [15, 20]);
});
test('clamped at 0; nothing pending for the eliminated, in prep, in boss rounds or without an LP', () => {
assert.equal(liveLp(null, { phase: PHASE.COMBAT, round: 3, lp: 4, leaks: 9 }).shown, 0);
assert.equal(liveLp(null, { phase: PHASE.COMBAT, round: 3, lp: 4, leaks: 9 }).pending, 4, 'never more than the LP left');
assert.equal(liveLp(null, { phase: PHASE.COMBAT, round: 3, lp: 40, leaks: 9, alive: false }).pending, 0);
for (const phase of [PHASE.PREP, PHASE.ROUND_START, PHASE.SP_DRAFT, PHASE.SETTLE, PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE, PHASE.RESULT]) {
const r = liveLp(null, { phase, round: 3, lp: 40, leaks: 9 });
assert.deepEqual([r.pending, r.shown, r.base], [0, 40, null], phase);
}
assert.deepEqual(liveLp(null, { phase: PHASE.COMBAT, round: 3, lp: undefined, leaks: 2 }), { base: null, pending: 0, shown: null, unite: false });
assert.match(pendingTip(30, 3), /结算时扣除 3 点/);
assert.match(pendingTip(30, 10, { unite: true }), /联防中/);
assert.equal(pendingTip(30, 0), null);
});
test('team panel rows: the own row takes the top bar\'s live value, a teammate\'s m.public players[].pendingLp; only in COMBAT / 联防', () => {
const pub = { phase: PHASE.COMBAT };
assert.deepEqual(rowLp({ lp: 30, pendingLp: 3 }, pub), { lp: 30, pending: 3, unite: false });
assert.deepEqual(rowLp({ lp: 2, pendingLp: 3 }, pub), { lp: 2, pending: 2, unite: false }, 'clamped at the LP left');
assert.deepEqual(rowLp({ lp: 30 }, pub), { lp: 30, pending: 0, unite: false });
assert.deepEqual(rowLp({ lp: 30, pendingLp: 4 }, { phase: PHASE.UNITE }), { lp: 30, pending: 4, unite: true });
assert.deepEqual(rowLp({ lp: 30, pendingLp: 4 }, { phase: PHASE.SETTLE }), { lp: 30, pending: 0, unite: false });
assert.deepEqual(rowLp({ lp: 30, pendingLp: 4, alive: false }, pub), { lp: 30, pending: 0, unite: false });
// the own row: the top bar's numbers (m.private lp — m.public may lag behind it by a throttle interval)
assert.deepEqual(rowLp({ lp: 30, pendingLp: 1 }, pub, { lp: 30, pending: 5, unite: false }), { lp: 30, pending: 5, unite: false });
assert.deepEqual(rowLp({ lp: 30 }, pub, { lp: 25, pending: 0, unite: false }), { lp: 25, pending: 0, unite: false }, 'settled m.private first');
assert.deepEqual(rowLp({ lp: 30 }, { phase: PHASE.FINAL_ASSAULT }, { lp: 99, pending: 5 }), { lp: 30, pending: 0, unite: false }, 'boss rounds: the row keeps its own share');
});
test('LP tower: lp − pending in red with a −N tick keyed by the pending value (it pops again on every leak), the 联防中 tag', () => {
const plain = LpTower({ value: 24, size: 'lg' });
assert.ok(!hasClass(plain, 'is-pending'));
assert.equal(textOf(plain), '24');
const v = LpTower({ value: 24, size: 'lg', pending: 3, tip: 'x' });
assert.ok(hasClass(v, 'is-pending'));
const pend = [...walk(v)].find((n) => hasClass(n, 'lp__pend'));
assert.equal(pend.key, 3, 'keyed: a new count re-mounts the tick (its pop animation plays again)');
assert.equal(textOf(pend), '−3');
const val = [...walk(v)].find((n) => hasClass(n, 'lp__val'));
assert.equal(textOf(val), '21');
assert.equal(v.props.title, 'x');
const unite = LpTower({ value: 5, pending: 9, note: '联防中' });
assert.equal(textOf([...walk(unite)].find((n) => hasClass(n, 'lp__val'))), '0', 'clamped');
assert.equal(textOf([...walk(unite)].find((n) => hasClass(n, 'lp__pend'))), '−5');
assert.equal(textOf([...walk(unite)].find((n) => hasClass(n, 'lp__note'))), '联防中');
assert.equal(textOf(LpTower({ value: null, pending: 3 })), '--');
});
test('server: m.public players[].pendingLp = min(cap, the authority\'s reported leaks) during COMBAT, the own battle\'s count in 联防, gone at SETTLE', async () => {
const { makeMatch } = await import('../match/harness.js');
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, bots: 0, seed: 7331, captureFrames: false, clientCombat: true, clients: false });
h.autoHumans();
h.m.start();
h.run(() => h.m.phase === PHASE.COMBAT && h.m.round === 1);
const m = h.m;
const view = (pid) => m.publicView().players.find((p) => p.playerId === pid);
const f = m.fields.find((x) => x.players.includes('p_0'));
assert.equal(f.mode, 'client');
assert.equal('pendingLp' in view('p_0'), false, 'omitted while nothing leaked');
m.handle('p_0', { t: 'b.progress', battleId: f.battleId, gt: 5, killed: 1, total: 9, leaks: 3 });
assert.equal(view('p_0').pendingLp, 3);
assert.equal('pendingLp' in view('p_1'), false);
m.handle('p_0', { t: 'b.progress', battleId: f.battleId, gt: 9, killed: 1, total: 20, leaks: 14 });
assert.equal(view('p_0').pendingLp, m.gd.lpCapPerRound, 'capped');
// 联防: the recorded own results
m.phase = PHASE.UNITE;
m.lastResults = new Map([['p_0', { leaked: [{ enemyKey: 'a' }, { enemyKey: 'b', counted: false }, { enemyKey: 'c', counted: true }] }]]);
assert.equal(view('p_0').pendingLp, 2, 'counted !== false, like settle()');
assert.equal('pendingLp' in view('p_1'), false);
for (const phase of [PHASE.SETTLE, PHASE.PREP, PHASE.FINAL_ASSAULT]) {
m.phase = phase;
assert.equal('pendingLp' in view('p_0'), false, phase);
}
m.dispose();
});
});
// ---- 3: the temp overflow row --------------------------------------------------------------------------------------
describe('3: the temp overflow row (临时整备区)', () => {
const priv = (temp) => ({ alive: true, ready: false, canReady: !temp.some(Boolean), temp });
test('the ready button shows why it is refused, under it, not only on hover', () => {
const p = priv([{ uid: 1, kind: 'item', id: 'x' }, { uid: 2, kind: 'chess', id: 'y' }, null, null, null]);
assert.deepEqual(tempInfo(p), { count: 2, items: 1 });
assert.match(tempReadyReason(p), /^临时整备区还有 2 个单位:/);
assert.ok(tempReadyReason(p).includes(TEMP_RULE));
assert.match(TEMP_RULE, /休整期结束时.*销毁/, 'says what happens at the end of the prep');
const v = ReadyToggle({ priv: p, onToggle() {}, readyCount: 1, total: 4 });
const why = [...walk(v)].find((n) => hasClass(n, 'readywrap__why'));
assert.ok(why, 'visible reason');
assert.equal(textOf(why), '临时整备区 2 个单位待处理');
const btn = [...walk(v)].find((n) => n.type === 'button');
assert.equal(btn.props.disabled, true);
// empty temp: no reason, enabled
const ok = ReadyToggle({ priv: priv([null, null, null, null, null]), onToggle() {} });
assert.equal([...walk(ok)].find((n) => hasClass(n, 'readywrap__why')), undefined);
assert.ok(![...walk(ok)].find((n) => n.type === 'button').props.disabled);
assert.deepEqual(tempInfo(null), { count: 0, items: 0 });
});
test('the row frame: the outer corners of both end tiles, the label on the side with room (mirrored Final Assault prep too)', () => {
// tileScreen polys: [back-left, back-right, front-right, front-left] (render/app.js tileScreen corner order)
const tile = (cx, y, w = 100, d = 60) => ({ x: cx, y, s: w, poly: [[cx - w / 2 + 6, y - d / 2], [cx + w / 2 - 6, y - d / 2], [cx + w / 2, y + d / 2], [cx - w / 2, y + d / 2]] });
const a = tile(800, 640), b = tile(1260, 640);
const f = tempRowFrame(a, b, { labelW: 290, gap: 10, vw: 1920 });
assert.deepEqual(f.quad, [[756, 610], [1304, 610], [1310, 670], [750, 670]]);
assert.deepEqual([f.left, f.right, f.top, f.bottom, f.y], [750, 1310, 610, 670, 640]);
assert.equal(f.side, 'left');
// the Final Assault prep of the right-hand player draws the board mirrored: the ends swap on screen
assert.deepEqual(tempRowFrame(b, a, { labelW: 290, gap: 10, vw: 1920 }).quad, f.quad);
// no room on the left (a narrow phone): the label goes right
assert.equal(tempRowFrame(tile(200, 300), tile(600, 300), { labelW: 290, gap: 10, vw: 1200 }).side, 'right');
// neither side has room: left (clipped rather than covering the row)
assert.equal(tempRowFrame(tile(200, 300), tile(600, 300), { labelW: 290, gap: 10, vw: 900 }).side, 'left');
for (const bad of [null, {}, { poly: [[0, 0]] }, { poly: [[0, 0], [1, 0], [1, NaN], [0, 1]] }]) assert.equal(tempRowFrame(bad, b), null);
});
test('the row\'s label matches the server\'s due rule (PlayerState tempDue): destroyed at this prep\'s end unless ready — then kept through the next prep', () => {
assert.match(tempRowRule(false), /才能准备;休整期结束时仍在此处的将被销毁$/);
// Ready is refused while the row holds pieces: what lies there while ready arrived after it (due at the next prep)
assert.match(tempRowRule(true), /保留到下个休整期/);
assert.match(tempRowRule(true), /取消准备则在本休整期结束时销毁/);
});
test('the game screen frames the row only on the own prep board while it holds pieces (source contract)', () => {
const game = read('public/js/screens/game.js');
assert.match(game, /const tempNotice = temp\.count > 0 && !!view && viewKind !== 'loading' && showPrep && alive && !pen && !sp;/);
assert.match(game, /<\$\{TempRowNotice\} view=\$\{view\} count=\$\{temp\.count\} items=\$\{temp\.items\} label=\$\{!drag && !facing\}\s+ready=\$\{phase === PHASE\.PREP && !!priv\?\.ready\} \/>/);
assert.match(game, /toast\(tempReadyReason\(L\.priv\) \|\| refused, 'warn'\)/, 'Space says why');
const css = read('public/css/screens/game-panels.css');
assert.match(css, /\.tempnote \{ position: fixed; inset: 0; z-index: 2; pointer-events: none; \}/, 'over the field, under the HUD, never takes the pointer');
});
test('while the ready button shows its reason the effects column moves down a line (on phones the ready count ran into it)', () => {
const game = read('public/js/screens/game.js');
// the same condition as ReadyToggle's reason: PREP, alive, not ready, pieces in the row
assert.match(game, /const readyWhy = phase === PHASE\.PREP && alive && !priv\?\.ready && temp\.count > 0;/);
assert.match(game, /readyWhy && 'has-readywhy'/);
const css = read('public/css/screens/game.css');
assert.match(css, /\.gm__effects \{ position: absolute; right: \.3rem; top: 2\.4rem; \}/);
assert.match(css, /\.gm\.has-readywhy \.gm__effects \{ top: calc\(2\.4rem \+ max\(\.3rem, 16px\)\); \}/);
});
});
// ---- 8: the operator's own effect first --------------------------------------------------------------------------------
describe('8: 特质 right under the detail card\'s header', () => {
test('the block order', () => {
assert.deepEqual(CHESS_SECTIONS, ['head', 'garrison', 'trait', 'stats', 'skill', 'module', 'equip', 'talents', 'actions']);
});
test('a rendered operator card: header, then 特质 (trigger + description), then the rest in CHESS_SECTIONS order', async () => {
await data.loadAll('chess', 'garrisons', 'assets', 'bonds', 'items');
const c = data.lookup('chess', 'chess_char_3_04_b'); // 琳琅诗怀雅 (elite): <休整期结束时> garrison, modules, talents
assert.ok(c && c.garrisonIds && c.garrisonIds[0]);
const g = data.lookup('garrisons', c.garrisonIds[0]);
assert.equal(g.eventType, 'SERVER_PREP_FIN');
const blocks = ChessDetail({ chess: c, piece: { uid: 7, kind: 'chess', id: c.chessId, golden: true, items: [] }, editable: false, onSell() {}, bonds: [], loadout: null, onBond: null });
const keys = blocks.map((b) => b.key);
assert.deepEqual(keys, ['head', 'garrison', 'trait', 'stats', 'skill', 'module', 'equip', 'talents']);
assert.equal(blocks[1].props.garrison, g, 'the operator\'s own garrison');
// the garrison block itself: the 特质 label, the trigger chip and the description
const gb = blocks[1].type(blocks[1].props);
assert.ok(hasClass(gb, 'dgarrison'));
assert.ok(textOf(gb).includes('特质'));
assert.ok(textOf(gb).includes(g.eventTypeDesc));
const rich = [...walk(gb)].find((n) => n.props?.text != null);
assert.equal(rich.props.text, g.descRaw || g.desc);
// an operator without a garrison (a summon's chess record may have none) simply starts with the trait
const bare = ChessDetail({ chess: { ...c, garrisonIds: [] }, piece: null, editable: false, bonds: [], loadout: null });
assert.deepEqual(bare.map((b) => b.key), ['head', 'trait', 'stats', 'skill', 'module', 'talents']);
});
test('the 特质 chip carries the garrison\'s official type icon (eventTypeIcon) — the spoked 特异化 glyph only on 特异化', async () => {
await data.loadAll('garrisons', 'assets', 'chess');
const all = data.list('garrisons');
assert.ok(all.length > 200);
const bad = all.filter((g) => garrisonTypeIconKey(g) !== `s_${g.eventTypeIcon}`);
assert.deepEqual(bad.map((g) => g.garrisonId), [], 'every garrison shows its own type icon');
// (the spoked icon_support reads like a loading spinner: it used to stand before every <休整期开始时 / 结束时> 特质)
const spoked = all.filter((g) => garrisonTypeIconKey(g) === 's_icon_support');
assert.ok(spoked.length > 0 && spoked.every((g) => g.eventTypeDesc === '特异化'), 'icon_support = 特异化 only');
const prep = all.filter((g) => g.eventType === 'SERVER_PREP_START' || g.eventType === 'SERVER_PREP_FIN');
assert.ok(prep.filter((g) => garrisonTypeIconKey(g) !== 's_icon_support').length > prep.length / 2, 'most 休整期 特质 are 持续叠加 / 整备能力');
// no official icon in the record: a guess by the trigger, never a path from the data
assert.equal(garrisonTypeIconKey({ eventType: 'SERVER_PREP_FIN' }), 's_icon_bond');
assert.equal(garrisonTypeIconKey({ eventType: 'IN_BATTLE', eventTypeIcon: '../../x' }), 's_icon_battle');
assert.equal(garrisonTypeIconKey(null), 's_icon_bond');
// the rendered card of 琳琅诗怀雅 (elite): <休整期结束时> · 整备能力 → the gold 整备能力 icon
const c = data.lookup('chess', 'chess_char_3_04_b');
const g = data.lookup('garrisons', c.garrisonIds[0]);
assert.equal(g.eventTypeDesc, '整备能力');
const blocks = ChessDetail({ chess: c, piece: null, editable: false, bonds: [], loadout: null });
const gb = blocks.find((b) => b.key === 'garrison');
const img = [...walk(gb.type(gb.props))].find((n) => hasClass(n, 'dgarrison__icon'));
assert.match(String(img.props.src), /garrisonTypeIcon\/s_icon_gold\.png$/);
});
test('css: a compact gold-accented block; stats in a 4 × 2 grid so the skill stays close', () => {
const css = read('public/css/screens/game-panels.css');
assert.match(css, /\.dgarrison \{[^}]*border-left: 3px solid var\(--gold-2\)/);
assert.match(css, /\.dstats \{[^}]*grid-template-columns: repeat\(4, minmax\(0, 1fr\)\)/);
assert.match(read('public/css/devices.css'), /@media \(max-height: 460px\) \{\n {2}\.dhead__art \{ height: 1\.66rem; \}\n {2}\.dhead__en \{ display: none; \}/);
});
});
// ---- 9: no spinner next to texts; data loaded once, before the match screen --------------------------------------------
describe('9: busy indicators and data loading', () => {
const cssFiles = () => {
const out = [];
const walkDir = (d) => { for (const e of readdirSync(path.join(ROOT, d), { withFileTypes: true })) { const p = `${d}/${e.name}`; if (e.isDirectory()) walkDir(p); else if (p.endsWith('.css')) out.push(p); } };
walkDir('public/css');
return out;
};
test('no wait / progress cursor anywhere (the OS shows a spinning wheel next to the pointer — over the detail card beside an underframe button, the 交流 button …)', () => {
for (const f of cssFiles()) assert.doesNotMatch(read(f), /cursor:\s*(wait|progress)/, f);
});
test('a busy button keeps its icon and gets a delayed sweeping bar — no spinning ring', () => {
const v = Button({ loading: true, icon: 'play', children: '继续作战' });
assert.equal(v.props.disabled, true);
assert.equal(v.props['aria-busy'], 'true');
const nodes = [...walk(v)];
assert.ok(nodes.some((n) => hasClass(n, 'btn__icon')), 'the icon stays');
assert.ok(nodes.some((n) => hasClass(n, 'btn__busy')));
assert.ok(!nodes.some((n) => hasClass(n, 'btn__spin')));
const idle = [...walk(Button({ icon: 'play', children: 'x' }))];
assert.ok(!idle.some((n) => hasClass(n, 'btn__busy')));
const css = read('public/css/components.css');
assert.doesNotMatch(css, /\.btn__spin/);
assert.match(css, /\.btn__busy \{[^}]*animation: btn-busy-in 1ms linear 250ms forwards;/, 'only after 250 ms: quick replies show nothing');
assert.match(css, /@keyframes btn-busy \{ from \{ transform: translateX\(-100%\); \} to \{ transform: translateX\(250%\); \} \}/, 'a sweep, not a rotation');
});
test('机变: the card being picked shows 选择中 (no spinner) only until the pick shows in m.public', () => {
assert.equal(pickBusy(2, { idx: 2 }, null), true);
assert.equal(pickBusy(2, { idx: 1 }, null), false);
assert.equal(pickBusy(null, { idx: 2 }, null), false);
assert.equal(pickBusy(2, { idx: 2 }, 2), false, 'the pick landed: never stuck on the card');
assert.equal(pickBusy(0, { idx: 0 }, 0), false, 'index 0 too');
assert.equal(pickBusy(2, { idx: 2, takenBy: 'p_1' }, null), false);
const sp = { family: 'tactic', name: '战术决策', desc: '', cards: [{ idx: 0, name: 'A', desc: 'a' }, { idx: 1, name: 'B', desc: 'b' }], order: ['me'], turnPid: 'me', pickOf: new Map(), untimed: true };
const v = ChoiceOverlay({ pub: { players: [{ playerId: 'me', seat: 0, name: 'Me' }] }, sp, myId: 'me', solo: true, onPick() {}, busyIdx: 1 });
const nodes = [...walk(v)];
const busyCards = nodes.filter((n) => hasClass(n, 'spcard') && hasClass(n, 'is-busy'));
assert.equal(busyCards.length, 1);
assert.equal(textOf(nodes.find((n) => hasClass(n, 'spcard__busy'))), '选择中');
assert.ok(!nodes.some((n) => n.type === Spinner), 'no spinner over the card text');
// the pick landed (m.public) while the request still waits for its reply
const landed = ChoiceOverlay({ pub: { players: [] }, sp: { ...sp, pickOf: new Map([['me', 1]]), cards: [sp.cards[0], { ...sp.cards[1], takenBy: 'me' }] }, myId: 'me', solo: true, onPick() {}, busyIdx: 1 });
assert.ok(![...walk(landed)].some((n) => hasClass(n, 'spcard__busy')));
});
test('the "作战结束,等待队友完成作战" hourglass stands still', () => {
const css = read('public/css/screens/game.css');
assert.doesNotMatch(css, /hg-flip/);
assert.match(css, /\.chud__msg \.icon \{ width: \.22rem; height: \.22rem; color: var\(--gold\); \}/);
});
test('data store: a transient failure is retried (the file stays loading), a 404 / bad JSON is not; a superseded retry stays silent', async () => {
const waits = [];
const wait = async (ms) => { waits.push(ms); };
let calls = 0;
const flaky = createDataStore({
wait,
fetch: async () => {
calls++;
if (calls === 1) throw new TypeError('Failed to fetch');
if (calls === 2) return { ok: false, status: 502, json: async () => ({}) };
return { ok: true, status: 200, json: async () => ({ list: [{ id: 'x', v: 1 }] }) };
},
});
const p = flaky.load('chess');
assert.equal(flaky.status('chess'), 'loading');
await p;
assert.equal(flaky.status('chess'), 'ready');
assert.equal(flaky.lookup('chess', 'x').v, 1);
assert.deepEqual(waits, RETRY_DELAYS_MS.slice(0, 2));
assert.deepEqual(RETRY_DELAYS_MS, [600, 2000]);
const warn = console.warn;
console.warn = () => {};
try {
let n404 = 0;
const gone = createDataStore({ wait, fetch: async () => { n404++; return { ok: false, status: 404, json: async () => ({}) }; } });
await gone.load('bands');
assert.equal(gone.status('bands'), 'missing');
assert.equal(n404, 1, 'a 404 is final');
let nBad = 0;
const bad = createDataStore({ wait, fetch: async () => { nBad++; return { ok: true, status: 200, json: async () => JSON.parse('{"broken": ') }; } });
await bad.load('bands');
assert.equal(bad.status('bands'), 'missing');
assert.equal(nBad, 1, 'a delivered file that is not JSON is final');
let nDown = 0;
const down = createDataStore({ wait, fetch: async () => { nDown++; throw new TypeError('offline'); } });
await down.load('items');
assert.equal(down.status('items'), 'missing');
assert.equal(nDown, 1 + RETRY_DELAYS_MS.length, 'gives up after the retries');
// invalidated while waiting to retry: the old load stops, the new one decides
let release;
let nInv = 0;
const inv = createDataStore({
wait: () => new Promise((r) => { release = r; }),
fetch: async () => { nInv++; if (nInv === 1) throw new TypeError('offline'); return { ok: true, status: 200, json: async () => ({ a: { v: 2 } }) }; },
});
const seen = [];
inv.subscribe((name) => seen.push(`${name}:${inv.status(name)}`));
const first = inv.load('config');
await new Promise((r) => setImmediate(r));
const second = inv.invalidate('config');
await second;
release();
await first;
assert.equal(inv.status('config'), 'ready');
assert.equal(inv.get('config').a.v, 2);
assert.equal(nInv, 2, 'the superseded load did not fetch again');
assert.deepEqual(seen, ['config:loading', 'config:ready']);
} finally { console.warn = warn; }
});
test('every data file the in-match UI reads is in GAME_FILES (the match screen waits for them: no text appears late); main.js warms them in a room', () => {
const files = [
...readdirSync(path.join(ROOT, 'public/js/ui')).filter((f) => f.endsWith('.js')).map((f) => `public/js/ui/${f}`),
...['game', 'briefing', 'bandDraft', 'result'].map((s) => `public/js/screens/${s}.js`),
];
const used = new Set();
for (const f of files) for (const m of read(f).matchAll(/data\.(?:get|lookup|list|load|status)\('([A-Za-z-]+)'/g)) used.add(m[1]);
for (const m of read('public/js/ui/gameComponents.js').matchAll(/useData\('([A-Za-z-]+)'/g)) used.add(m[1]);
assert.ok(used.size >= 10, [...used].join(','));
const missing = [...used].filter((n) => !GAME_FILES.includes(n));
assert.deepEqual(missing, [], 'read by the match UI but not awaited by the match screen');
const main = read('public/js/main.js');
assert.match(main, /if \(s\.room && !prev\.room\) warmGameData\(\);/);
assert.match(main, /data\.loadAll\(GAME_FILES\)/);
});
});
test('GEO: the temp row the notice frames is the server\'s (row 8, cols 4–8)', () => {
assert.deepEqual([GEO.TEMP_ROW, GEO.TEMP_C0, GEO.TEMP_SIZE], [8, 4, 5]);
});
+1 -1
View File
@@ -202,7 +202,7 @@ describe('equipment dropped on an operator', () => {
};
const ctx = placementContext({ priv, stage: DATA.stages.act2autochess_m01, editable: true, getChess, getToken, getItem });
test('pickPieceAt picks the front-most sprite under the pointer', () => {
test('pickPieceAt: the rect whose centre is nearest (overlap → the nearer unit)', () => {
const rects = [
{ uid: 1, left: 100, right: 160, top: 400, bottom: 520 }, // row 9 (nearer the camera: lower on screen)
{ uid: 2, left: 100, right: 160, top: 350, bottom: 470 }, // row 10, behind