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)
This commit is contained in:
sganggs committed 2026-09-30 05:35:45 +08:00
1 parent 0aba602ecf
commit d4f868cd19
107 files changed
+4510 -1653

No files matched your search

+95 -2
View File
@@ -6,7 +6,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { bgmKeyFor, resolveBgm, SfxLimiter, AudioManager } from '../../public/js/audio.js';
import { bgmKeyFor, resolveBgm, SfxLimiter, AudioManager, normalAttackSfx } from '../../public/js/audio.js';
import { PHASE } from '../../shared/constants.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
@@ -62,6 +62,16 @@ describe('SfxLimiter', () => {
assert.ok(l.tryAcquire(120, 'u1', 'z'));
assert.ok(l.tryAcquire(121, null, 'w'), 'no unit key ⇒ only url gap');
});
test('at most 2 overlapping copies of one sound (official banks: maxSoundAllowed 2)', () => {
const l = new SfxLimiter({ maxVoices: 99, unitCooldownMs: 0, urlGapMs: 0 });
assert.equal(l.maxPerUrl, 2);
assert.ok(l.tryAcquire(0, 'a', 'heal'));
assert.ok(l.tryAcquire(1, 'b', 'heal'));
assert.equal(l.tryAcquire(2, 'c', 'heal'), false, 'a third copy waits');
assert.ok(l.tryAcquire(2, 'c', 'other'), 'other sounds are not affected');
l.release('heal');
assert.ok(l.tryAcquire(3, 'c', 'heal'), 'one ended: room again');
});
});
// ---- fake Web Audio -------------------------------------------------------------------------------------
@@ -130,7 +140,7 @@ describe('AudioManager', () => {
await new Promise((r) => setTimeout(r, 10));
assert.equal(fw.made.started, before, 'prep → combat shares the loop');
// battle events map to unit sounds (UnitInfo.spine = char id)
const charId = Object.keys(manifest.audio.sfx.units).find((k) => manifest.audio.sfx.units[k].attack && k.startsWith('char_'));
const charId = Object.keys(manifest.audio.sfx.units).find((k) => k.startsWith('char_') && normalAttackSfx(k, manifest.audio.sfx.units[k].attack));
a.setFieldUnits([{ id: 1, side: 'ally', spine: charId }, { id: 2, side: 'enemy', spine: 'enemy_nope' }]);
a.handleBattleEvents([['atk', 1, 2, 'arrow'], ['dmg', 2, 100, 'phys'], ['die', 2], ['spawn', { id: 3, side: 'enemy', spine: 'x' }], ['bounty', 'p', 1]]);
await new Promise((r) => setTimeout(r, 10));
@@ -167,3 +177,86 @@ describe('AudioManager', () => {
}
});
});
// user playtest #4 item 6: 纯烬艾雅法拉's skill sound rang outside her skill — her manifest `hit` is her S3 impact
// (p_imp_gtshpbrnch_s, the audio bank ON_ABILITY_HIT.attack.2) and every damage on an ally she had just healed was
// attributed to her ('atk' healer → ally), so ordinary enemy hits on healed allies played it.
describe('impact sounds (user playtest #4 item 6)', () => {
const AGOAT2 = 'char_1016_agoat2';
async function rig(units) {
const fw = fakeWindow();
const urls = [];
const origFetch = globalThis.fetch;
globalThis.fetch = async (u) => { urls.push(u); return { ok: true, arrayBuffer: async () => new ArrayBuffer(8) }; };
const a = new AudioManager({ win: fw.win, getManifest: () => manifest });
a.install();
fw.fire('pointerdown');
a.setFieldUnits(units);
const settle = () => new Promise((r) => setTimeout(r, 5));
return { a, urls, settle, restore: () => { globalThis.fetch = origFetch; } };
}
test('an operator never plays a skill-mode file (_d / _h / _s) as its normal attack or impact; enemies keep their _h', () => {
const u = manifest.audio.sfx.units;
// her impact: the S3 file (built before tools/assets/audio.mjs preferred normal-mode banks) is refused, the normal
// one (projectile_chr_agoat2: p_imp_gtshpbrnch_n) plays
assert.equal(normalAttackSfx(AGOAT2, '/assets/audio/sfx/player/p_imp/p_imp_gtshpbrnch_s.mp3'), false);
assert.equal(normalAttackSfx(AGOAT2, '/assets/audio/sfx/player/p_imp/p_imp_gtshpbrnch_n.mp3'), true);
if (u[AGOAT2].hit) assert.equal(normalAttackSfx(AGOAT2, u[AGOAT2].hit), !/_s\.mp3$/.test(u[AGOAT2].hit));
assert.equal(normalAttackSfx(AGOAT2, u[AGOAT2].attack), true, 'p_atk_gtshpbrnch_n');
assert.equal(normalAttackSfx('char_1014_nearl2', '/x/p_atk_goldspear_s.mp3'), false);
assert.equal(normalAttackSfx('char_1028_texas2', '/x/p_imp_reticentsword_h.mp3'), false);
assert.equal(normalAttackSfx('char_1045_svash2', '/x/p_atk_snwlprdg_n1.mp3'), true);
assert.equal(normalAttackSfx('enemy_1045_hammer', '/x/e_atk_bigaxe_h.mp3'), true, 'enemy _h = heavy weapon');
assert.equal(normalAttackSfx('char_x', null), false);
});
test('a heal never makes the healer the author of the next damage on the healed ally', async () => {
const enemyId = Object.keys(manifest.audio.sfx.units).find((k) => k.startsWith('enemy_') && manifest.audio.sfx.units[k].hit);
const { a, urls, settle, restore } = await rig([
{ id: 1, side: 'ally', kind: 'chess', spine: AGOAT2 }, { id: 2, side: 'ally', kind: 'chess', spine: 'char_x' },
{ id: 3, side: 'enemy', kind: 'enemy', spine: enemyId },
]);
try {
const own = new Set(Object.values(manifest.audio.sfx.units[AGOAT2]).filter((x) => typeof x === 'string'));
a.handleBattleEvents([['atk', 1, 2, 'orb'], ['heal', 2, 300], ['dmg', 2, 120, 'phys'], ['dmg', 2, 80, 'arts']]);
await settle();
assert.ok(urls.includes(manifest.audio.sfx.units[AGOAT2].attack), 'her cast sound');
assert.ok(!urls.some((x) => x === manifest.audio.sfx.units[AGOAT2].hit), 'no impact sound of hers on the ally');
assert.ok(!urls.some((x) => own.has(x) && /_s\.mp3$/.test(x)), 'nothing of her S3');
// a hostile attack still authors its impact — once, and only for a real hit (not an element gauge fill)
a.handleBattleEvents([['atk', 3, 2, 'none'], ['dmg', 2, 900, 'burn']]);
await settle();
assert.ok(!urls.includes(manifest.audio.sfx.units[enemyId].hit), 'a gauge fill is no impact');
a.handleBattleEvents([['dmg', 2, 200, 'phys']]);
await settle();
assert.equal(urls.filter((x) => x === manifest.audio.sfx.units[enemyId].hit).length, 1, 'the impact');
a.limiter.lastByUnit.clear(); a.limiter.lastByUrl.clear();
a.handleBattleEvents([['dmg', 2, 50, 'phys']]);
await settle();
assert.equal(urls.filter((x) => x === manifest.audio.sfx.units[enemyId].hit).length, 1, 'a later tick is not the same attack\'s impact');
} finally { restore(); }
});
test('a chain bounce plays no attack sound of the previous target; a stale attack is no impact', async () => {
const enemyId = Object.keys(manifest.audio.sfx.units).find((k) => k.startsWith('enemy_') && manifest.audio.sfx.units[k].attack && manifest.audio.sfx.units[k].hit);
const charId = Object.keys(manifest.audio.sfx.units).find((k) => k.startsWith('char_') && normalAttackSfx(k, manifest.audio.sfx.units[k].hit) && manifest.audio.sfx.units[k].hit);
const { a, urls, settle, restore } = await rig([
{ id: 1, side: 'ally', kind: 'chess', spine: charId }, { id: 5, side: 'enemy', kind: 'enemy', spine: enemyId },
{ id: 6, side: 'enemy', kind: 'enemy', spine: enemyId },
]);
const perf = globalThis.performance;
let fakeNow = 1000;
globalThis.performance = { now: () => fakeNow };
try {
a.handleBattleEvents([['atk', 5, 6, 'chain']]);
await settle();
assert.ok(!urls.includes(manifest.audio.sfx.units[enemyId].attack), 'the bounce is not an enemy attack');
a.handleBattleEvents([['atk', 1, 5, 'arrow']]);
fakeNow += 4000;
a.handleBattleEvents([['dmg', 5, 100, 'phys']]);
await settle();
assert.ok(!urls.includes(manifest.audio.sfx.units[charId].hit), '4 s later: not that attack\'s impact');
} finally { globalThis.performance = perf; restore(); }
});
});
+111 -9
View File
@@ -1,14 +1,18 @@
// Strategy draft 队友已选 (research 09 §5 / §7, DESIGN §14 corrections): a strategy a teammate already picked cannot be
// chosen again — the server refuses it (server/match/Match.js pickBand → BAD_TARGET '队友已选'), bots re-draw, and the
// UI marks it (screens/bandDraft.js teammateBands). Automatic assignments (12 s turn timeout, 50 s cap, a departing
// seat) give the official default 「华法琳」 only while no teammate holds it, else the first free strategy
// (Match.js defaultBand; the UI names it: bandDraft.js timeoutBand).
// UI marks it (screens/bandDraft.js teammateBands). Automatic assignments (a turn that runs out, a departing seat) give
// the official default 「华法琳」 only while no teammate holds it, else the first free strategy (Match.js defaultBand; the
// UI names it: bandDraft.js timeoutBand) — a timed-out turn takes the highlighted band first (g.bandFocus, Match.js
// timeoutBand; user playtest #4 item 4: one countdown, BAND_TURN_SECONDS per turn, no separate step cap).
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { ERR, PHASE } from '../../shared/constants.js';
import { makeMatch, DATA } from '../match/harness.js';
import { teammateBands, timeoutBand, allowedBands } from '../../public/js/screens/bandDraft.js';
import { teammateBands, timeoutBand, allowedBands, autoPickBand, draftClock, draftTip, draftSelection } from '../../public/js/screens/bandDraft.js';
import { BAND_TURN_SECONDS } from '../../server/match/Match.js';
const TURN_MS = BAND_TURN_SECONDS * 1000;
function draftOf(o) {
const h = makeMatch({ mode: 'coop', ...o }).start();
@@ -98,34 +102,66 @@ describe('server: automatic assignments never duplicate a teammate\'s strategy',
return ids;
};
test('12 s turn timeout: 华法琳 when free, else the first free strategy (sortId order)', () => {
test('a turn that runs out (no highlight): 华法琳 when free, else the first free strategy (sortId order)', () => {
const h = draftOf({ humans: 3, seed: 5 });
const m = h.m;
const [first, second, third] = m.draft.order;
assert.deepEqual(m.handle(first, { t: 'g.band', bandId: 'band_bldsk' }), { ok: true });
assert.equal(m.defaultBand(second), 'band_amiya', 'the next free strategy by sortId');
h.sched.advance(12001);
h.sched.advance(TURN_MS - 1);
assert.equal(m.draft.picks[second], undefined, `the whole ${BAND_TURN_SECONDS} s`);
h.sched.advance(2);
assert.equal(m.draft.picks[second], 'band_amiya', 'timeout never assigns 队友已选 华法琳');
assert.equal(h.ps(second).lp, DATA.bands.band_amiya.totalHp, 'the LP of the assigned strategy');
assert.equal(m.draftTurn(), third);
h.sched.advance(12001);
h.sched.advance(TURN_MS + 1);
assert.equal(m.draft.picks[third], 'band_duyaoy');
distinct(m);
m.dispose();
});
test('a turn that runs out takes the highlighted strategy (g.bandFocus) while it is free', () => {
const h = draftOf({ humans: 3, seed: 5 });
const m = h.m;
const [first, second, third] = m.draft.order;
// highlights may come before the turn; the last one counts; null clears it
assert.deepEqual(m.handle(third, { t: 'g.bandFocus', bandId: 'band_lisa' }), { ok: true });
assert.deepEqual(m.handle(second, { t: 'g.bandFocus', bandId: 'band_amiya' }), { ok: true });
assert.deepEqual(m.handle(second, { t: 'g.bandFocus', bandId: 'band_sarkazb' }), { ok: true });
assert.equal(m.handle(second, { t: 'g.bandFocus', bandId: 'band_nope' }).error, ERR.BAD_TARGET);
assert.deepEqual(m.handle(first, { t: 'g.band', bandId: 'band_lisa' }), { ok: true });
assert.equal(m.handle(first, { t: 'g.bandFocus', bandId: 'band_amiya' }).error, ERR.ALREADY, 'nothing to highlight after the pick');
h.sched.advance(TURN_MS + 1);
assert.equal(m.draft.picks[second], 'band_sarkazb', 'the highlighted strategy');
assert.equal(h.ps(second).lp, DATA.bands.band_sarkazb.totalHp);
// the third player highlighted 华法琳's neighbour band_lisa — a teammate took it meanwhile: the default instead
assert.equal(m.timeoutBand(third), 'band_bldsk', '队友已选: never assigned');
h.sched.advance(TURN_MS + 1);
assert.equal(m.draft.picks[third], 'band_bldsk');
distinct(m);
m.dispose();
const h2 = draftOf({ humans: 2, seed: 5 });
const [a] = h2.m.draft.order;
assert.deepEqual(h2.m.handle(a, { t: 'g.bandFocus', bandId: 'band_amiya' }), { ok: true });
assert.deepEqual(h2.m.handle(a, { t: 'g.bandFocus' }), { ok: true }, 'cleared');
h2.sched.advance(TURN_MS + 1);
assert.equal(h2.m.draft.picks[a], 'band_bldsk', 'no highlight: the default');
assert.equal(h2.m.handle(a, { t: 'g.bandFocus', bandId: 'band_amiya' }).error, ERR.ALREADY);
h2.m.dispose();
});
test('the default stays 华法琳 while nobody holds it (also for a player whose teammates picked others)', () => {
const h = draftOf({ humans: 2, seed: 5 });
const m = h.m;
const [first, second] = m.draft.order;
assert.deepEqual(m.handle(first, { t: 'g.band', bandId: 'band_amiya' }), { ok: true });
assert.equal(m.defaultBand(second), 'band_bldsk');
h.sched.advance(12001);
h.sched.advance(TURN_MS + 1);
assert.equal(m.draft.picks[second], 'band_bldsk');
m.dispose();
});
test('every human idle: the turn timeouts and the 50 s step cap assign distinct strategies', () => {
test('every human idle: the turn timeouts assign distinct strategies (no separate step cap)', () => {
const h = draftOf({ humans: 4, seed: 9 });
const m = h.m;
h.run(() => m.phase !== PHASE.BAND_DRAFT, { maxTime: 120000 });
@@ -195,3 +231,69 @@ describe('UI: timeoutBand (the tip names what a timeout gives me)', () => {
m.dispose();
});
});
describe('UI: one countdown and the highlighted band (user playtest #4 item 4)', () => {
const bands = allowedBands([
{ bandId: 'band_amiya', sortId: 2 }, { bandId: 'band_bldsk', sortId: 1 }, { bandId: 'band_duyaoy', sortId: 3 },
], 'MULTI');
test('draftClock: the step header counts the current turn down (m.public.deadline = draft.turnDeadline); untimed ⇒ none', () => {
assert.deepEqual(draftClock({ deadline: 5000, draft: { turnDeadline: 5000, turnSeconds: 30 } }), { deadline: 5000, total: 30 });
assert.deepEqual(draftClock({ deadline: 0, draft: { turnDeadline: 7000, turnSeconds: 30 } }), { deadline: 7000, total: 30 }, 'the turn deadline stands in');
assert.equal(draftClock({ deadline: 0, draft: { turnDeadline: 0, untimed: true, turnSeconds: 0 } }), null);
assert.equal(draftClock({ deadline: 9000, draft: { untimed: true } }), null, 'an untimed draft shows no clock');
assert.equal(draftClock({ deadline: 0, draft: { turnDeadline: 0 } }), null);
assert.equal(draftClock(null), null);
assert.deepEqual(draftClock({ deadline: 4000, draft: {} }), { deadline: 4000, total: null });
});
test('the server publishes one clock: deadline = turnDeadline, turnSeconds = the turn length; AI seats pick at once', () => {
const h = draftOf({ humans: 2, bots: 2, seed: 9 });
const m = h.m;
h.sched.advance(1);
const turn = m.draftTurn();
assert.ok(turn && !turn.startsWith('ai_'), `a human's turn after the AI seats picked at once (${m.draft.order})`);
const pub = m.publicView();
assert.equal(pub.deadline, pub.draft.turnDeadline, 'one countdown');
assert.equal(pub.draft.turnSeconds, BAND_TURN_SECONDS);
assert.ok(Math.abs(pub.deadline - h.sched.now() - TURN_MS) <= 2, 'a whole turn left');
assert.deepEqual(draftClock(pub), { deadline: pub.deadline, total: BAND_TURN_SECONDS });
m.dispose();
});
test('autoPickBand: the highlighted band while free, else timeoutBand; nothing after my pick', () => {
assert.equal(autoPickBand('band_duyaoy', { bands, taken: new Map() }), 'band_duyaoy');
assert.equal(autoPickBand('band_duyaoy', { bands, taken: new Map([['band_duyaoy', ['a']]]) }), 'band_bldsk', '队友已选 ⇒ the default');
assert.equal(autoPickBand(null, { bands, taken: new Map() }), 'band_bldsk');
assert.equal(autoPickBand('band_unknown', { bands, taken: new Map() }), 'band_bldsk', 'not a band of the mode');
assert.equal(autoPickBand('band_amiya', { bands, taken: new Map([['band_bldsk', ['a']], ['band_amiya', ['b']]]) }), 'band_duyaoy');
assert.equal(autoPickBand('band_amiya', { bands, taken: new Map(), myPick: 'band_amiya' }), null);
});
test('the selection starts on what a timeout gives (华法琳 while free), so the tip names the highlighted band', () => {
assert.equal(draftSelection(null, { bands, taken: new Map(), myPick: null, myTurn: false }), 'band_bldsk');
assert.equal(draftSelection(null, { bands, taken: new Map([['band_bldsk', ['a']]]), myPick: null, myTurn: true }), 'band_amiya');
assert.equal(draftSelection('band_bldsk', { bands, taken: new Map([['band_bldsk', ['a']]]), myPick: null, myTurn: true }), 'band_amiya', 'taken on my turn ⇒ the default');
assert.match(draftTip({ timed: true, turnSeconds: 30, autoName: '华法琳' }), /每位博士有 30 秒,超时将自动选择当前选中的「华法琳」/);
assert.match(draftTip({ timed: true, turnSeconds: 30, autoName: '阿米娅', selected: false }), /超时将自动选择「阿米娅」$/);
assert.equal(draftTip({ timed: true, turnSeconds: 30, autoName: null }), '联合模拟在选择策略时可以进行一次跳过;每位博士有 30 秒');
assert.equal(draftTip({ timed: false, autoName: '华法琳' }), '联合模拟在选择策略时可以进行一次跳过;本局不限时');
});
test('a single human with AI teammates: the draft is untimed (soloUntimed), keeps the co-op order and its skip', () => {
const h = draftOf({ humans: 1, bots: 3, seed: 4 });
const m = h.m;
h.run(() => m.draftTurn() === 'p_0' || m.phase !== PHASE.BAND_DRAFT, { maxTime: 1000 });
assert.equal(m.draftTurn(), 'p_0');
const pub = m.publicView();
assert.equal(pub.deadline, 0);
assert.equal(pub.draft.untimed, true);
assert.equal(pub.draft.turnSeconds, 0);
assert.equal(draftClock(pub), null);
h.sched.advance(10 * 60_000);
assert.equal(m.phase, PHASE.BAND_DRAFT, 'waits for the player');
assert.equal(m.draftTurn(), 'p_0');
if (m.draft.order.length - m.draft.idx > 1) assert.deepEqual(m.handle('p_0', { t: 'g.bandSkip' }), { ok: true }, 'the co-op skip stays');
m.dispose();
});
});
+20 -62
View File
@@ -12,9 +12,8 @@ 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, pickPieceAt, equipRetarget,
activeBubbles, sortedPlayers, tileKey, prepCapsuleLabel, prepCamera, dropFailureReason,
} 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';
@@ -101,6 +100,9 @@ describe('countdown', () => {
assert.equal(phaseTotalSeconds({ phase: PHASE.SP_DRAFT, sp: { cards: [], picks: {} }, players: [] }, config), config.timers.spFirst);
assert.equal(phaseTotalSeconds({ phase: PHASE.SP_DRAFT, sp: { cards: [{}, {}], picks: { a: 0 } }, players: [{ playerId: 'a' }] }, config), config.timers.spTurn);
assert.equal(phaseTotalSeconds(null, config), null);
// the strategy draft: one countdown, the turn's (m.public.draft.turnSeconds; user playtest #4 item 4)
assert.equal(phaseTotalSeconds({ phase: PHASE.BAND_DRAFT, draft: { turnSeconds: 30 } }, config), 30);
assert.equal(phaseTotalSeconds({ phase: PHASE.BAND_DRAFT }, config), config.timers.bandTurn);
});
});
@@ -494,65 +496,21 @@ describe('prepCamera (research 09 §1.2: the Final Assault prep on the own half
});
});
// 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');
// user playtest #4 item 1: a dragged item drops on the tile under the pointer (render/drag.js) and goes to the unit on
// that tile — the highlighted tile; nothing is retargeted to a sprite drawn over another tile any more
describe('equipment dropped on a tile goes to the unit on it', () => {
test('the operator on the drop tile (board or bench), not the one whose head is drawn there; an empty tile says why', () => {
const front = { ...piece(RANGED), row: 9, col: 3 }, behind = { ...piece(RANGED), row: 10, col: 3 };
const onBench = piece(RANGED);
const eq = item(EQUIP), art = item(MAGIC);
const ctx = ctxFor(privWith({ board: [front, behind], hand: [{ idx: 0, piece: eq }, { idx: 1, piece: art }, { idx: 4, piece: onBench }] }));
assert.deepEqual(dropIntent(ctx, eq.uid, { area: 'board', row: 9, col: 3 }).fields, { itemUid: eq.uid, targetUid: front.uid });
assert.deepEqual(dropIntent(ctx, eq.uid, { area: 'board', row: 10, col: 3 }).fields, { itemUid: eq.uid, targetUid: behind.uid });
assert.deepEqual(dropIntent(ctx, eq.uid, { area: 'hand', idx: 4 }).fields, { itemUid: eq.uid, targetUid: onBench.uid }, 'a bench operator');
// the empty tile behind `behind` (where its head is drawn) takes nothing — and the release says why
assert.equal(dropIntent(ctx, eq.uid, { area: 'board', row: 11, col: 3 }), null);
assert.equal(dropFailureReason(ctx, eq.uid, { row: 11, col: 3, area: 'board' }), '请将装备拖拽至干员身上');
// an Art is used on the tile under the pointer itself
assert.deepEqual(dropIntent(ctx, art.uid, { area: 'board', row: 11, col: 3 }), { t: 'g.art', fields: { itemUid: art.uid, row: 11, col: 3 } });
});
});
+3 -2
View File
@@ -256,9 +256,10 @@ describe('client leftovers — real server', { skip: !ENABLED && 'set SP_E2E=1 (
const itemsOf = () => c.page.evaluate((uid) => (globalThis.__SP__.store.get().match.private.board.find((p) => p.uid === uid)?.items || []).map((x) => ({ uid: x.uid, id: x.id })), unit.uid);
const handItem = async (id) => (await c.handPieces('item')).find((p) => p.id === id) || null;
// an item goes to the unit on the tile it is dropped on (user playtest #4 item 1): the operator's own tile
const dropItem = async (it) => {
for (const at of [0.5, 0.35, 0.7]) {
await c.drag(await c.piecePoint(it.uid, 0.5), await c.piecePoint(unit.uid, at));
for (let i = 0; i < 2; i++) {
await c.drag(await c.piecePoint(it.uid, 0.5), await c.tilePoint(tile.row, tile.col));
await sleep(500);
if (await c.exists('.eqr') || !(await handItem(it.id))) return;
}
+4 -1
View File
@@ -102,7 +102,10 @@ describe('user playtest #2 item 1 — loadout chosen in the UI fights in the loc
tag: !!document.querySelector('.dpanel .dskill__name .dtag-loadout'),
module: document.querySelector('.dpanel .dmodule')?.textContent || '',
none: !!document.querySelector('.dpanel .dmodule.is-none'),
stats: Object.fromEntries([...document.querySelectorAll('.dpanel .dstat')].map((el) => [el.querySelector('.dstat__k')?.textContent, el.querySelector('.dstat__v')?.textContent])),
// the record (base) value: with live stats (user playtest #4 item 7: the start-of-battle preview) it is the
// cell's title "基础 N", else the value itself
stats: Object.fromEntries([...document.querySelectorAll('.dpanel .dstat')].map((el) => [el.querySelector('.dstat__k')?.textContent,
(el.getAttribute('title') || '').replace(/^基础 /, '') || el.querySelector('.dstat__v')?.textContent])),
trait: document.querySelector('.dpanel .dtrait')?.textContent || '',
})), () => null);
if (detail) break;
+11 -3
View File
@@ -142,11 +142,16 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
test('co-op briefing (review fix): the overlay shows the INFO_CHECK time left; an edit that misses the lock is told, not lost silently', async () => {
const { ctx, page, problems } = await open();
await page.evaluate(() => globalThis.__SP__.net.request('room.create', { mode: 'coop', difficulty: 'NORMAL' }));
await waitSt(page, (s) => !!s.room, 'room');
const { room } = await waitSt(page, (s) => !!s.room, 'room');
// a second human joins: a single human is untimed like solo (Match.soloUntimed, user playtest #4 item 3)
const mate = await open();
await mate.page.evaluate((code) => globalThis.__SP__.net.request('room.join', { code }), room);
await waitSt(mate.page, (s) => s.room === room, 'the teammate in the room');
await mate.page.evaluate(() => globalThis.__SP__.net.request('room.ready', { ready: true }));
await page.evaluate(() => globalThis.__SP__.net.request('room.addBot', {}));
await page.evaluate(() => globalThis.__SP__.net.request('room.start', {}));
const brief = await waitSt(page, (s) => s.phase === 'INFO_CHECK', 'briefing');
assert.ok(brief.deadline > 0, 'co-op briefing is timed (25 s)');
assert.ok(brief.deadline > 0, 'co-op briefing (two humans) is timed (25 s)');
await clickSel(page, '.brief [data-testid="loadout-open"]');
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
// the briefing (and its countdown) is hidden under the overlay: the overlay carries the time left
@@ -156,8 +161,9 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
await page.click('.lo-card');
await page.waitForSelector('.lo-detail .lo-skill[data-skill="0"]', { visible: true });
// edit, and the briefing ends before the debounced send (every human ready: here the request stands in for the timer)
// edit, and the briefing ends before the debounced send (every human ready: the requests stand in for the timer)
await page.click('.lo-detail .lo-skill[data-skill="0"]');
await mate.page.evaluate(() => globalThis.__SP__.net.request('g.infoReady', {}));
await page.evaluate(() => globalThis.__SP__.net.request('g.infoReady', {}));
await page.waitForFunction(() => !document.querySelector('.lo'), { timeout: 5000 });
await page.waitForFunction(() => [...document.querySelectorAll('.toast__text')].some((e) => /下一局生效/.test(e.textContent)), { timeout: 5000 });
@@ -166,6 +172,8 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.loadout')));
assert.deepEqual(stored.entries[INSIDE], { skill: 0 }, 'kept for the next match');
assert.deepEqual(problems, []);
assert.deepEqual(mate.problems, []);
await mate.ctx.close();
await ctx.close();
});
+31 -4
View File
@@ -245,11 +245,37 @@ describe('in-match UI (mock harness, headless Chrome)', { skip: !ENABLED && 'set
await page.close();
});
test('机变 pick, band pick, briefing ready', async () => {
test('机变 pick (two taps, user playtest #4 item 2), band pick, briefing ready', async () => {
let { page, problems } = await open('phase=SP_DRAFT&variant=bounty');
await page.click('.spcard.is-pickable');
await sleep(200);
// the first tap only selects: the card lifts with its 确认选择 strip, the header offers 确认选择, nothing was sent
assert.ok(await page.$('.spcard.is-armed .spcard__confirm'), 'selected (armed)');
assert.ok(await page.$('.spov__confirm'), 'the header confirm');
assert.ok(!(await page.$('.spcard.is-mine')), 'not picked yet');
assert.ok(!(await page.evaluate(() => globalThis.__MOCK__.S().requests.some(([t]) => t === 'g.choice'))), 'no g.choice after one tap');
// another card moves the selection; Esc drops it
const pickable = await page.$$('.spcard.is-pickable');
await pickable[1].click();
await sleep(150);
assert.equal(await page.$$eval('.spcard.is-armed', (els) => els.length), 1, 'one selection');
await page.keyboard.press('Escape');
await sleep(150);
assert.ok(!(await page.$('.spcard.is-armed')), 'Esc drops the selection');
// select, then tap the same card again: picked
await page.click('.spcard.is-pickable');
await sleep(150);
await page.click('.spcard.is-armed');
await sleep(300);
assert.ok(await page.$('.spcard.is-mine'), 'picked card marked');
// the header's 确认选择 confirms as well
await page.close();
({ page, problems } = await open('phase=SP_DRAFT&variant=supply'));
await page.click('.spcard.is-pickable');
await sleep(150);
await page.click('.spov__confirm');
await sleep(300);
assert.ok(await page.$('.spcard.is-mine'), 'confirmed from the header');
assert.deepEqual(problems, []);
await page.close();
@@ -628,11 +654,12 @@ describe('in-match UI (mock harness, headless Chrome)', { skip: !ENABLED && 'set
await page.close();
});
test('engine: equipment released over an operator\'s upper body equips it', async (t) => {
test('engine: equipment dropped on an operator\'s tile equips it — the tile under the pointer decides (user playtest #4 item 1)', async (t) => {
if (RENDER !== 'engine') { t.skip('SP_RENDER=fallback'); return; }
const { page, problems } = await open('phase=PREP');
await sleep(1500); // spine models
const at = (uid, fy) => page.evaluate((u, f) => { const r = globalThis.__SP_VIEW__?.pieceScreenRect(u); return r && { x: r.left + r.width / 2, y: r.top + r.height * f }; }, uid, fy);
const tileAt = (row, col) => page.evaluate((r, c) => { const x = globalThis.__SP_VIEW__?.raw?.tileScreen?.(r, c); return x && { x: x.x, y: x.y }; }, row, col);
const drag = async (from, to) => {
await page.mouse.move(from.x, from.y);
await page.mouse.down();
@@ -646,9 +673,9 @@ describe('in-match UI (mock harness, headless Chrome)', { skip: !ENABLED && 'set
const items = s.hand.filter((x) => x && x.kind === 'item');
assert.ok(targets.length && items.length, 'mock board has an unequipped operator and a hand item');
const unit = targets[0];
await drag(await at(items[0].uid, 0.5), await at(unit.uid, 0.25));
await drag(await at(items[0].uid, 0.5), await tileAt(unit.row, unit.col));
s = await mockState(page);
assert.ok(s.board.find((x) => x.uid === unit.uid).items.some((i) => i.uid === items[0].uid), 'equipped from the head');
assert.ok(s.board.find((x) => x.uid === unit.uid).items.some((i) => i.uid === items[0].uid), 'equipped: dropped on its tile');
assert.deepEqual(problems, []);
await page.close();
});
+9 -11
View File
@@ -227,24 +227,22 @@ describe('user playtest #2 — UI fixes (mock harness, headless Chrome)', { skip
test('8: a top-row unit under the bond strip is tappable; on a notched phone the underframe stays on its unit', { skip: skipUnless('reach'), timeout: 5 * 60 * 1000 }, async () => {
for (const [name, w, h] of [['16x9', 1920, 1080], ['21x9', 2560, 1080], ['720p', 1280, 720], ['phone', 844, 390]]) {
const { page, problems } = await open('phase=PREP', { w, h });
// the mock's row-12 unit (col 4, behind the row-11 one): its body stands under the bond strip at these sizes
// the mock's row-12 unit (col 4, behind the row-11 one): its body reaches under the bond strip at these sizes; a
// press on its tile selects it (picking is by tile, user playtest #4 item 1), so the tile must be free of the strip
const top = await page.evaluate(() => {
const b = globalThis.__MOCK__.S().priv.board.filter((p) => p.row === 12).sort((a, c) => a.col - c.col)[0];
const r = globalThis.__SP_VIEW__.pieceScreenRect(b.uid);
const t = globalThis.__SP_VIEW__.raw.tileScreen(b.row, b.col);
const strip = document.querySelector('.gm__bonds').getBoundingClientRect();
// points of the sprite's upper half (the lower half is behind the unit in front of it)
const pts = [0.12, 0.25, 0.38].map((f) => ({ x: r.left + r.width / 2, y: r.top + r.height * f }));
const pts = [-0.3, 0, 0.3].map((dx) => ({ x: t.x + dx * t.s, y: t.y }));
const hits = pts.map((p) => { const el = document.elementFromPoint(p.x, p.y); return el.tagName === 'CANVAS' ? 'canvas' : el.closest('.bslot') ? 'disc' : `${el.tagName}.${el.className}`; });
return { uid: b.uid, pts, hits, underStrip: pts.some((p) => p.y >= strip.top && p.y <= strip.bottom) };
return { uid: b.uid, pts, hits, bodyUnderStrip: r.top < strip.bottom };
});
assert.ok(top.underStrip, `${name}: the row-12 unit stands under the bond strip (test premise)`);
// only the bond discs may take the pointer there — never the strip's empty box
for (const hit of top.hits) assert.ok(hit === 'canvas' || hit === 'disc', `${name}: over the row-12 unit: ${hit}`);
const free = top.pts[top.hits.indexOf('canvas')];
assert.ok(free, `${name}: some of the unit's body is free of bond discs (${top.hits})`);
await page.mouse.click(free.x, free.y);
assert.ok(top.bodyUnderStrip, `${name}: the row-12 unit's body reaches under the bond strip (test premise)`);
for (const hit of top.hits) assert.equal(hit, 'canvas', `${name}: the row-12 unit's tile is free of the bond strip`);
await page.mouse.click(top.pts[1].x, top.pts[1].y);
await sleep(350);
assert.ok(await page.$(`.uframe[data-uid="${top.uid}"]`), `${name}: a tap on the row-12 unit's body under the strip selects it`);
assert.ok(await page.$(`.uframe[data-uid="${top.uid}"]`), `${name}: a tap on the row-12 unit's tile selects it`);
await page.keyboard.press('Escape');
await page.mouse.click(5, Math.round(h / 2));
await sleep(150);
+21 -2
View File
@@ -185,6 +185,22 @@ function battleViewState(c) {
});
}
/**
* Wait for a prep matching `pred`; a 机变 draft on the way is picked with its two taps (user playtest #4 item 2) —
* a single human's 机变 is untimed (Match.soloUntimed, item 3): nothing picks it for them any more.
*/
async function untilPrep(c, pred, what, timeout = 90000) {
const t0 = Date.now();
for (;;) {
const left = Math.max(2000, timeout - (Date.now() - t0));
const s = await c.waitFor((x) => (x.phase === 'PREP' && pred(x)) || (x.phase === 'SP_DRAFT' && x.sp?.turn === x.me), what, left);
if (s.phase === 'PREP') return s;
await c.click('.spcard.is-pickable', null, { optional: true, timeout: 4000 });
await c.click('.spcard.is-armed', null, { optional: true, timeout: 4000 });
await c.waitFor((x) => x.phase !== 'SP_DRAFT' || x.sp?.turn !== x.me, '机变 picked', 15000);
}
}
function assertNoPen(v, tag) {
assert.deepEqual(v.orphans, [], `${tag}: no stale unit sprite of another field`);
assert.equal(v.penVisible, 0, `${tag}: no pen figure`);
@@ -216,7 +232,7 @@ describe('user playtest #2 item 6 — 前往查看 → 返回战场 in combat (r
}
let observed = false;
for (let tries = 0; tries < 3 && !observed; tries++) {
const s0 = await c.waitFor((s) => s.phase === 'PREP' && !s.ready, 'prep', 90000);
const s0 = await untilPrep(c, (s) => !s.ready, 'prep');
await sleep(1500);
// the own field fights with everything the deploy cap allows: it clears its wave long before the idle AI's
// enemies have walked their whole path
@@ -268,7 +284,7 @@ describe('user playtest #2 item 6 — 前往查看 → 返回战场 in combat (r
}
assert.ok(observed, `observed a teammate's battle and came back (${c.log.join(' | ')})`);
// and the next prep: the board without the pen, the pen only with 🔍▶▶
const s1 = await c.waitFor((s) => s.phase === 'PREP', 'next prep', 90000);
const s1 = await untilPrep(c, () => true, 'next prep');
await sleep(1500);
assertNoPen(await battleViewState(c), `R${s1.round} prep after observing`);
assert.deepEqual(problemsOf([c]), []);
@@ -344,6 +360,9 @@ describe('user playtest #2 item 10 — boss-round prep on the boss field (real s
await host.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
for (const c of [host, guest]) await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
for (const c of [host, guest]) await c.click('.brief__foot .btn--primary', '准备就绪');
// the draft first (a client still in the briefing would count as done without picking: its turn then runs out —
// Match.BAND_TURN_SECONDS, 30 s each since user playtest #4 item 4)
for (const c of [host, guest]) await c.waitFor((s) => s.phase !== 'INFO_CHECK', 'band draft', 40000);
// band draft: whoever's turn it is picks a free strategy
const picked = new Set();
const t0 = Date.now();
+4 -3
View File
@@ -37,7 +37,7 @@ 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 { ChoiceView, 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');
@@ -366,14 +366,15 @@ describe('9: busy indicators and data loading', () => {
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 });
// (ChoiceView: the overlay's pure view — ChoiceOverlay keeps the two-tap selection, user playtest #4 item 2)
const v = ChoiceView({ pub: { players: [{ playerId: 'me', seat: 0, name: 'Me' }] }, sp, myId: 'me', solo: true, 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 });
const landed = ChoiceView({ pub: { players: [] }, sp: { ...sp, pickOf: new Map([['me', 1]]), cards: [sp.cards[0], { ...sp.cards[1], takenBy: 'me' }] }, myId: 'me', solo: true, busyIdx: 1 });
assert.ok(![...walk(landed)].some((n) => hasClass(n, 'spcard__busy')));
});
+253
View File
@@ -0,0 +1,253 @@
// Regression tests (Node) of user playtest #4, UI-flow items:
// 2 机变 picks take two taps like the shop (ui/choiceOverlay.js): the first tap selects a card (gold frame, 确认选择 ·
// 再次点击 strip, the header's 确认选择), the second — or 确认选择 — sends it; another card moves the selection; a card
// that cannot be picked (not my turn, taken, a pick in flight) is never selected; the selection drops with it
// 3 untimed phases show no timer at all: Countdown renders nothing without a deadline; the 机变 overlay of an untimed
// draft (solo, a single human) shows no clock and says so
// 7 the detail card's live stats (ui/detailPanel.js): battle / prep values coloured against the unit's base (green
// up, red down, a shorter attack interval is up), the difference beside them, a 实时 / 开战时 tag; the battle HP
// bar follows the live HP; the game screen asks g.unitStats for own board units in prep and reads the local sim in
// battle
// 1 (layout) on a notched phone the shop bar keeps its place at the bottom edge instead of rising by the bottom inset
// over the bench pads — a bench piece is pressed / dropped on by its tile (css/devices.css)
// 5 an effect-only item's card (the special 维式重锤, 突变细胞: items.json shopExcluded) says the shop never sells it
// and where it comes from
// (server side: test/match/unitStats.test.js, test/match/draft.test.js, test/ui/bandDraft.test.js; the runner:
// test/match/runner.test.js)
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
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 (the card 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 { ChoiceView, spTap, armedCard, cardPickable } = await import('../../public/js/ui/choiceOverlay.js');
const { ChessDetail, ItemDetail, statTone, liveStat } = await import('../../public/js/ui/detailPanel.js');
const { Button } = await import('../../public/js/ui/components.js');
const { data } = await import('../../public/js/data.js');
/** Every vnode of a preact tree (htm output), depth first; `expand` names components rendered in place (hook-free). */
function* walk(v, expand = new Set()) {
if (Array.isArray(v)) { for (const x of v) yield* walk(x, expand); return; }
if (!v || typeof v !== 'object') return;
yield v;
if (typeof v.type === 'function' && expand.has(v.type.name)) { yield* walk(v.type(v.props), expand); return; }
yield* walk(v.props?.children, expand);
}
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('');
if (typeof v === 'object' && typeof v.type === 'function' && ['Stat', 'LiveTag'].includes(v.type.name)) return textOf(v.type(v.props));
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: two-tap 机变 picks ----------------------------------------------------------------------------------------
describe('2: 机变 cards take two taps (select → confirm), like buying in the shop', () => {
const sp = (o = {}) => ({
family: 'bounty', name: '悬赏决策', desc: '', untimed: false, order: ['me', 'p2'], turnPid: 'me', pickOf: new Map(),
cards: [{ idx: 0, name: 'A', desc: 'a', takenBy: null }, { idx: 1, name: 'B', desc: 'b', takenBy: null }, { idx: 2, name: 'C', desc: 'c', takenBy: 'p2' }],
...o,
});
const me = { myId: 'me', solo: false };
test('spTap: the first tap selects, the second on the same card confirms, another card moves the selection', () => {
assert.deepEqual(spTap(null, 0, true), { armed: 0, pick: null }, 'first tap: selected, nothing sent');
assert.deepEqual(spTap(0, 0, true), { armed: null, pick: 0 }, 'second tap on it: sent');
assert.deepEqual(spTap(0, 1, true), { armed: 1, pick: null }, 'another card: the selection moves');
assert.deepEqual(spTap(1, 0, false), { armed: 1, pick: null }, 'a card that cannot be picked changes nothing');
assert.deepEqual(spTap(null, 'x', true), { armed: null, pick: null });
});
test('cardPickable / armedCard: only my turn, before my pick, a free card, nothing in flight — the selection drops otherwise', () => {
const s = sp();
assert.equal(cardPickable(s, s.cards[0], me), true);
assert.equal(cardPickable(s, s.cards[2], me), false, 'taken');
assert.equal(cardPickable(sp({ turnPid: 'p2' }), s.cards[0], me), false, 'not my turn');
assert.equal(cardPickable(sp({ turnPid: 'p2' }), s.cards[0], { myId: 'me', solo: true }), true, 'solo: always my turn');
assert.equal(cardPickable(sp({ pickOf: new Map([['me', 1]]) }), s.cards[0], me), false, 'after my pick');
assert.equal(cardPickable(s, s.cards[0], { ...me, busyIdx: 1 }), false, 'a pick in flight');
assert.equal(armedCard(0, s, me), 0);
assert.equal(armedCard(2, s, me), null, 'a taken card is never selected');
assert.equal(armedCard(0, sp({ turnPid: 'p2' }), me), null, 'the turn moved on (timeout)');
assert.equal(armedCard(0, s, { ...me, busyIdx: 0 }), null);
assert.equal(armedCard(7, s, me), null);
assert.equal(armedCard(null, s, me), null);
});
test('ChoiceView: the selected card lifts with a 确认选择 · 再次点击 strip, the header offers 确认选择; taps go through onTap', () => {
const s = sp();
const taps = [];
let confirmed = 0;
const v = ChoiceView({ pub: { players: [{ playerId: 'me', seat: 0, name: 'Me' }, { playerId: 'p2', seat: 1, name: 'P2' }], deadline: 0 }, sp: s, ...me,
armed: 1, onTap: (i) => taps.push(i), onConfirm: () => { confirmed++; } });
assert.ok(hasClass(v, 'has-armed'));
const nodes = [...walk(v)];
const cards = nodes.filter((n) => hasClass(n, 'spcard'));
assert.equal(cards.length, 3);
assert.deepEqual(cards.map((c) => hasClass(c, 'is-armed')), [false, true, false]);
assert.equal(cards[1].props['aria-pressed'], 'true');
assert.match(cards[1].props['aria-label'], /已选中,再次点击确认/);
assert.equal(textOf(nodes.find((n) => hasClass(n, 'spcard__confirm'))), '确认选择再次点击');
const btn = nodes.find((n) => n.type === Button);
assert.ok(btn && textOf(btn.props.children).includes('确认选择'), 'the header confirm');
btn.props.onClick();
assert.equal(confirmed, 1);
cards[0].props.onClick();
cards[1].props.onClick();
cards[2].props.onClick(); // taken: disabled, never tapped
assert.deepEqual(taps, [0, 1]);
assert.equal(cards[2].props.disabled, true);
// nothing selected: no strip, no confirm button, the hint says how it works
const idle = [...walk(ChoiceView({ pub: { players: [] }, sp: s, ...me }))];
assert.ok(!idle.some((n) => hasClass(n, 'spcard__confirm')));
assert.ok(!idle.some((n) => n.type === Button));
assert.match(textOf(idle.find((n) => hasClass(n, 'spov__sub'))), /点击卡牌选中,再次点击确认/);
});
test('3: an untimed 机变 (solo, a single human) shows no clock and says so; a timed one keeps its countdown', () => {
const timed = [...walk(ChoiceView({ pub: { players: [], deadline: Date.now() + 9000 }, sp: sp(), ...me }))];
assert.ok(timed.some((n) => n.type?.name === 'Countdown'));
assert.match(textOf(timed.find((n) => hasClass(n, 'spov__sub'))), /倒计时结束后仍未选定将自动分配/);
const lone = [...walk(ChoiceView({ pub: { players: [], deadline: 0 }, sp: sp({ untimed: true }), ...me }))];
assert.ok(!lone.some((n) => n.type?.name === 'Countdown'), 'a single human\'s co-op draft: untimed');
assert.match(textOf(lone.find((n) => hasClass(n, 'spov__sub'))), /无时间限制/);
});
test('the overlay keeps the selection itself and drops it on Esc / a tap elsewhere (source contract)', () => {
const src = read('public/js/ui/choiceOverlay.js');
assert.match(src, /export function ChoiceOverlay\(props\) \{[\s\S]*useState\(null\)[\s\S]*armedCard\(sel, sp/);
assert.match(src, /e\.key === 'Escape'/);
assert.match(src, /closest\('\.spcard, \.spov__confirm'\)/);
const css = read('public/css/screens/game-panels.css');
assert.match(css, /\.spcard\.is-armed/);
assert.match(css, /\.spcard__confirm \{/);
});
});
// ---- 3: no timer without a deadline --------------------------------------------------------------------------------
describe('3: untimed phases show no timer', () => {
test('Countdown renders nothing without a deadline (the "--" placeholder read as a timer)', () => {
const comp = read('public/js/ui/components.js');
const fn = comp.slice(comp.indexOf('export function Countdown'), comp.indexOf('// ---- Modal'));
assert.match(fn, /if \(remain == null\) return null;/);
// after the hooks (a component may toggle between timed and untimed)
assert.ok(fn.indexOf('useEffect(') < fn.indexOf('if (remain == null) return null;'));
});
});
// ---- 7: live stats -------------------------------------------------------------------------------------------------
describe('7: the detail card shows live stats against the base', () => {
test('statTone: higher is up, a shorter attack interval is up, rounding noise is equal', () => {
assert.equal(statTone('atk', 1042, 501), 'up');
assert.equal(statTone('atk', 400, 501), 'down');
assert.equal(statTone('atk', 501.3, 501), null);
assert.equal(statTone('interval', 0.9, 1.05), 'up', 'faster attacks help');
assert.equal(statTone('interval', 1.3, 1.05), 'down');
assert.equal(statTone('interval', 1.052, 1.05), null);
assert.equal(statTone('res', 12.3, 10), 'up');
assert.equal(statTone('res', 10.02, 10), null);
assert.equal(statTone('blockCnt', 2, 3), 'down');
assert.equal(statTone('atk', null, 3), null);
});
test('liveStat: the live value with its difference and the base in the title; the record value without live stats', () => {
const live = { atk: 1042, interval: 0.95, res: 12.5, base: { atk: 501, interval: 1.05, res: 10 } };
assert.deepEqual(liveStat(live, 'atk', 501), { v: '1,042', tone: 'up', sub: '+541', title: '基础 501' });
const iv = liveStat(live, 'interval', 1.05, (v) => `${v.toFixed(2)}s`);
assert.deepEqual(iv, { v: '0.95s', tone: 'up', sub: '−0.10', title: '基础 1.05s' });
assert.equal(liveStat(live, 'res', 10, String).sub, '+2.5');
assert.deepEqual(liveStat(null, 'atk', 501), { v: '501', tone: null, sub: null, title: undefined });
assert.equal(liveStat(null, 'atk', null).v, '—');
assert.deepEqual(liveStat({ atk: 501, base: { atk: 501 } }, 'atk', 501), { v: '501', tone: null, sub: null, title: '基础 501' });
});
test('a rendered card: live values coloured, the 实时 / 开战时 tag, the battle HP bar from the live HP', async () => {
await data.loadAll('chess', 'garrisons', 'assets', 'bonds', 'items');
const c = data.lookup('chess', 'chess_char_1_01_a');
assert.ok(c);
const s = c.stats;
const live = {
src: 'battle', hp: 300, maxHp: Math.round(s.maxHp * 1.2), atk: Math.round(s.atk * 0.8), def: s.def, res: s.res ?? 0,
interval: 0.5, blockCnt: s.blockCnt,
base: { maxHp: s.maxHp, atk: s.atk, def: s.def, res: s.res ?? 0, interval: 1, blockCnt: s.blockCnt },
};
const blocks = ChessDetail({ chess: c, piece: null, editable: false, bonds: [], loadout: null, live });
const stats = blocks.find((b) => b.key === 'stats');
assert.ok(hasClass(stats, 'is-live'));
assert.equal(stats.props['data-live'], 'battle');
const cells = [...walk(stats, new Set(['Stat', 'LiveTag']))].filter((n) => hasClass(n, 'dstat'));
const cell = (k) => cells.find((n) => textOf(n).startsWith(k));
assert.ok(hasClass(cell('生命上限'), 'is-up'));
assert.ok(hasClass(cell('攻击'), 'is-down') && !hasClass(cell('攻击间隔'), 'is-down'));
assert.ok(hasClass(cell('攻击间隔'), 'is-up'), 'a shorter interval is a buff');
assert.ok(!hasClass(cell('防御'), 'is-up') && !hasClass(cell('防御'), 'is-down'), 'unchanged');
assert.ok(textOf(stats).includes('实时'));
const head = blocks.find((b) => b.key === 'head');
const hp = [...walk(head)].find((n) => hasClass(n, 'dhp'));
assert.ok(textOf(hp).includes(`300 / ${live.maxHp.toLocaleString('en-US')}`), 'the live HP');
// the prep preview: 开战时, no HP bar
const prep = ChessDetail({ chess: c, piece: null, editable: false, bonds: [], loadout: null, live: { ...live, src: 'prep' } });
assert.ok(textOf(prep.find((b) => b.key === 'stats')).includes('开战时'));
assert.ok(![...walk(prep.find((b) => b.key === 'head'))].some((n) => hasClass(n, 'dhp')));
// no live stats: the record's numbers, no tag
const plain = ChessDetail({ chess: c, piece: null, editable: false, bonds: [], loadout: null });
const pstats = plain.find((b) => b.key === 'stats');
assert.ok(!hasClass(pstats, 'is-live'));
assert.ok(!textOf(pstats).includes('实时') && !textOf(pstats).includes('开战时'));
});
test('the game screen feeds it: g.unitStats for an own board unit in prep (newest answer only), the local sim in battle', () => {
const src = read('public/js/screens/game.js');
assert.match(src, /net\.on\('m\.unitStats'/);
assert.match(src, /msg\.seq !== statsSeqRef\.current/, 'only the newest request\'s answer');
assert.match(src, /net\.request\('g\.unitStats', \{ seq: statsSeqRef\.current \}\)/);
assert.match(src, /battleRunner\.unitStats\(uid, fid\)/);
assert.match(src, /live=\$\{liveStats\}/);
const panel = read('public/js/ui/detailPanel.js');
assert.match(panel, /useTicker\(detail && getter \? 250 : 0\)/, 'a getter is re-read 4× a second');
// an own board piece's card opened in prep and left open into the battle turns live (its unit found by uid)
assert.match(src, /battleRunner\.unitIdOf\(pieceUid, myId, fid\)/);
});
});
// ---- 1 (layout) / 5 ------------------------------------------------------------------------------------------------
describe('1 / 5: the bench stays reachable on a notched phone; effect-only items say where they come from', () => {
test('devices.css: the shop bar steps back over the HUD\'s bottom inset (it covered ~3/4 of bench pads 2–9)', () => {
const css = read('public/css/devices.css');
assert.match(css, /\.gm__hud > \.shopbar \{ bottom: calc\(\.2rem - var\(--sa-b\)\); \}/);
// the shop's own rule stays the desktop place (.2rem from the bottom): the inset rule only cancels the HUD inset
assert.match(read('public/css/screens/game-shop.css'), /\.shopbar \{\n {2}position: absolute; right: \.26rem; bottom: \.2rem;/);
});
test('ItemDetail: 调度中心不出售 + the source for the special 维式重锤 / 突变细胞, nothing for a shop item', async () => {
await data.loadAll('items', 'assets');
const special = data.lookup('items', 'chess_item_4_09_e_a'); // 灼燃维式重锤
const cell = data.lookup('items', 'chess_item_5_08_e_a'); // 突变细胞
assert.ok(special?.shopExcluded && cell?.shopExcluded, 'items.json marks them');
const plain = data.list('items').find((i) => i.itemType === 'EQUIP' && !i.shopExcluded && !i.isGolden);
const note = (item) => [...walk(ItemDetail({ item, piece: null, editable: false }))].find((n) => hasClass(n, 'dhint--source'));
assert.match(textOf(note(special)), /调度中心不出售 · 获取途径:维多利亚盟约每25层 \/ 洛洛的定制品/);
assert.match(textOf(note(cell)), /昆图斯/);
assert.equal(note(plain), undefined, 'a shop item has no such line');
});
});
+2
View File
@@ -542,7 +542,9 @@ describe('real server + real browsers', { skip: !ENABLED && 'set SP_REAL_E2E=1 (
const s = await c.st();
if (s.phase === 'SP_DRAFT' && s.sp?.turn === s.me && (await c.exists('.spcard.is-pickable'))) {
if (!seen.has(`sp-shot-${s.round}`)) { seen.add(`sp-shot-${s.round}`); await sleep(500); await c.shot(`sp-r${s.round}-${c.label}`); }
// two taps (user playtest #4 item 2): select, then tap the selected card again
await c.click('.spcard.is-pickable');
await c.click('.spcard.is-armed');
await c.waitFor((x) => x.sp?.turn !== x.me || x.phase !== 'SP_DRAFT', 'sp pick', 8000);
spPicked += 1;
c.note(`机变 picked (round ${s.round})`);
+6 -1
View File
@@ -23,7 +23,9 @@ const readCard = (c, timeout = 2500) => c.page.waitForSelector('.dpanel .dskill_
tag: !!document.querySelector('.dpanel .dskill__name .dtag-loadout'),
module: document.querySelector('.dpanel .dmodule')?.textContent || '',
none: !!document.querySelector('.dpanel .dmodule.is-none'),
stats: Object.fromEntries([...document.querySelectorAll('.dpanel .dstat')].map((el) => [el.querySelector('.dstat__k')?.textContent, el.querySelector('.dstat__v')?.textContent])),
// the record (base) value: a live card (user playtest #4 item 7) carries it as the cell's title "基础 N"
stats: Object.fromEntries([...document.querySelectorAll('.dpanel .dstat')].map((el) => [el.querySelector('.dstat__k')?.textContent,
(el.getAttribute('title') || '').replace(/^基础 /, '') || el.querySelector('.dstat__v')?.textContent])),
}))).catch(() => null);
describe('DESIGN §16 — a teammate\'s unit shows its owner\'s loadout (real server)', { skip: !ENABLED && 'set SP_E2E=1 (Chrome + public/assets)' }, () => {
@@ -80,6 +82,9 @@ describe('DESIGN §16 — a teammate\'s unit shows its owner\'s loadout (real se
assert.deepEqual(await loOf(guest), { [BASE]: { skill: 0, module: 'none' } }, 'the guest\'s match loadout');
assert.deepEqual(await loOf(host), {}, 'the host fights with the defaults');
for (const c of [host, guest]) await c.click('.brief__foot .btn--primary', '准备就绪');
// the draft first (a client still in the briefing would count as done without picking: its turn then runs out —
// Match.BAND_TURN_SECONDS, 30 s each since user playtest #4 item 4)
for (const c of [host, guest]) await c.waitFor((s) => s.phase !== 'INFO_CHECK', 'band draft', 40000);
const picked = new Set();
const t0 = Date.now();
while (picked.size < 2 && Date.now() - t0 < 120000) {
+7 -22
View File
@@ -1,6 +1,6 @@
// Regression tests for in-match UI defects (round-1 hunt): terrain 机变 cards in the client placement mirror, the
// result screen (hidden medal, rounds on a Hidden Core clear, remaining LP after the Final Assault), briefing bond
// labels, watch targets, the view-switcher label, equipment dropped on an operator's sprite, illegal-drop reasons,
// labels, watch targets, the view-switcher label, equipment dropped on an operator's tile, illegal-drop reasons,
// full-hand shop cards, the solo exit text and htm's static vnode cache.
import { test, describe } from 'node:test';
@@ -10,7 +10,7 @@ import { buildDeployMap } from '../../server/match/board.js';
import { applyCard } from '../../server/match/choices.js';
import {
placementContext, canPlace, deploySets, stageOverrides, deployMap, effectiveStage, normalizeResult, disabledBondSets,
briefingBondTip, watchTarget, switcherLabel, pickPieceAt, equipRetarget, dropIntent, dropFailureReason, shopBlockReason,
briefingBondTip, watchTarget, switcherLabel, dropIntent, dropFailureReason, shopBlockReason,
completesMerge, handFull, tileKey,
} from '../../public/js/ui/gameLogic.js';
import { PHASE, GEO } from '../../shared/constants.js';
@@ -202,27 +202,12 @@ describe('equipment dropped on an operator', () => {
};
const ctx = placementContext({ priv, stage: DATA.stages.act2autochess_m01, editable: true, getChess, getToken, getItem });
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
];
assert.equal(pickPieceAt(rects, 130, 450), 1, 'overlap: the nearer unit');
assert.equal(pickPieceAt(rects, 130, 360), 2);
assert.equal(pickPieceAt(rects, 10, 10), null);
assert.equal(pickPieceAt(rects, NaN, 10), null);
});
test('an item released over the upper body (tile behind the unit) equips the unit', () => {
const elsewhere = { area: 'board', row: 10, col: 4 };
test('an item dropped on the unit\'s own tile equips it; the tile behind it (where its head is drawn) does not (user playtest #4 item 1: the tile decides)', () => {
const head = { area: 'board', row: 11, col: 3 }; // the empty tile behind unit 2 (10,3), where its head is drawn
assert.equal(canPlace(ctx, 3, head).ok, false, 'the tile itself is empty');
const t = equipRetarget(ctx, 3, head, 2);
assert.deepEqual(t, { area: 'board', row: 10, col: 3 });
assert.deepEqual(dropIntent(ctx, 3, t), { t: 'g.equip', fields: { itemUid: 3, targetUid: 2 }, confirmReplace: false });
assert.deepEqual(equipRetarget(ctx, 3, elsewhere, null), elsewhere, 'no sprite under the pointer: unchanged');
assert.deepEqual(equipRetarget(ctx, 1, head, 2), head, 'units are not retargeted');
assert.deepEqual(equipRetarget(ctx, 3, null, 1), { area: 'board', row: 9, col: 3 }, 'a release off the grid over a unit');
assert.equal(canPlace(ctx, 3, head).ok, false, 'the tile itself is empty: nothing to equip there');
assert.equal(dropIntent(ctx, 3, head), null);
assert.deepEqual(dropIntent(ctx, 3, { area: 'board', row: 10, col: 3 }), { t: 'g.equip', fields: { itemUid: 3, targetUid: 2 }, confirmReplace: false });
assert.deepEqual(dropIntent(ctx, 3, { area: 'board', row: 9, col: 3 }), { t: 'g.equip', fields: { itemUid: 3, targetUid: 1 }, confirmReplace: false });
});
});