卫戍协议:盟约 web remake — initial import
Online co-op browser remake of Arknights' Stronghold Protocol: Alliance (client-simulated combat, official waves/bonds/items/strategies, 3D board, facing wheel, operator loadout, official emotes, deployment scripts).
This commit is contained in:
commit
01a44cd288
408 files changed
+308986
No files matched your search
@@ -0,0 +1,77 @@
|
||||
// Asset URL helpers (public/js/ui/assetUrls.js) against the real manifest: every visible chess resolves its
|
||||
// avatar and portrait (normal + golden), bonds / bands / items / enemies / factions / titles resolve, and
|
||||
// nothing outside the manifest is ever returned.
|
||||
|
||||
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';
|
||||
import {
|
||||
uiUrl, chessAvatarUrl, chessPortraitUrl, skillIconUrl, profIconUrl, subProfIconUrl, bondIconUrl, bandIconUrl, itemIconUrl,
|
||||
enemyIconUrl, tokenAvatarUrl, factionIconUrl, titleIconUrl, effectIconUrl,
|
||||
} from '../../public/js/ui/assetUrls.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const load = (f) => JSON.parse(readFileSync(path.join(ROOT, 'data', f), 'utf8'));
|
||||
const m = load('assets.json');
|
||||
const chess = load('chess.json');
|
||||
|
||||
function manifestUrls(obj, out = new Set()) {
|
||||
if (typeof obj === 'string' && obj.startsWith('/assets/')) out.add(obj);
|
||||
else if (obj && typeof obj === 'object') for (const v of Object.values(obj)) manifestUrls(v, out);
|
||||
return out;
|
||||
}
|
||||
const ALL = manifestUrls(m);
|
||||
const inManifest = (u) => u == null || ALL.has(u);
|
||||
|
||||
describe('assetUrls', () => {
|
||||
test('every visible chess (normal + golden) has avatar, portrait and skill icon', () => {
|
||||
let n = 0;
|
||||
for (const c of Object.values(chess)) {
|
||||
if (!c.charId || c.isDiy) continue;
|
||||
const a = chessAvatarUrl(m, c);
|
||||
const p = chessPortraitUrl(m, c);
|
||||
assert.ok(a && inManifest(a), `avatar ${c.chessId}`);
|
||||
assert.ok(p && inManifest(p), `portrait ${c.chessId}`);
|
||||
assert.ok(inManifest(skillIconUrl(m, c)), `skill ${c.chessId}`);
|
||||
assert.ok(inManifest(profIconUrl(m, c.profession)));
|
||||
assert.ok(inManifest(subProfIconUrl(m, c)));
|
||||
n++;
|
||||
}
|
||||
assert.ok(n >= 200);
|
||||
const golden = Object.values(chess).find((c) => c.isGolden && m.chars[c.charId]?.avatarE2);
|
||||
assert.equal(chessAvatarUrl(m, golden), m.chars[golden.charId].avatarE2, 'golden uses E2 art');
|
||||
});
|
||||
test('bonds, bands, items, enemies, tokens, factions, titles, effects', () => {
|
||||
for (const id of Object.keys(load('bonds.json'))) assert.ok(bondIconUrl(m, id), id);
|
||||
for (const id of Object.keys(load('bands.json'))) assert.ok(bandIconUrl(m, id), id);
|
||||
for (const it of Object.values(load('items.json'))) assert.ok(inManifest(itemIconUrl(m, it)), it.id);
|
||||
assert.ok(itemIconUrl(m, Object.values(load('items.json'))[0]));
|
||||
const enemies = load('enemies.json');
|
||||
let found = 0;
|
||||
for (const k of Object.keys(enemies)) { const u = enemyIconUrl(m, k); assert.ok(inManifest(u), k); if (u) found++; }
|
||||
assert.ok(found > Object.keys(enemies).length * 0.9);
|
||||
for (const t of Object.values(load('factions.json').types)) assert.ok(factionIconUrl(m, t.icon), t.type);
|
||||
for (const t of load('config.json').titles) assert.ok(titleIconUrl(m, t.picId), t.id);
|
||||
assert.ok(tokenAvatarUrl(m, 'token_10028_vigil_wolf'));
|
||||
assert.ok(effectIconUrl(m, { iconKind: 'team' }));
|
||||
assert.ok(effectIconUrl(m, { iconKind: 'choice' }));
|
||||
assert.ok(effectIconUrl(m, { iconKind: 'band', iconId: 'band_bldsk' }));
|
||||
assert.ok(effectIconUrl(m, { iconKind: 'garrison' }));
|
||||
assert.equal(effectIconUrl(m, { iconKind: 'x' }), null);
|
||||
assert.ok(uiUrl(m, 'hudPanel/icon_hp'));
|
||||
});
|
||||
test('missing manifest / ids ⇒ null, never throws', () => {
|
||||
for (const fn of [chessAvatarUrl, chessPortraitUrl, skillIconUrl, subProfIconUrl, itemIconUrl]) {
|
||||
assert.equal(fn(null, null), null);
|
||||
assert.equal(fn(m, { assets: {} }) ?? null, fn === skillIconUrl ? uiUrl(m, 'skillIcon/empty') : null);
|
||||
}
|
||||
assert.equal(bondIconUrl(m, 'nope'), null);
|
||||
assert.equal(bandIconUrl(null, 'band_bldsk'), null);
|
||||
assert.equal(enemyIconUrl(m, 'enemy_nope'), null);
|
||||
assert.equal(profIconUrl(m, 'NOPE'), null);
|
||||
assert.equal(uiUrl(undefined, 'x'), null);
|
||||
assert.equal(tokenAvatarUrl(m, null), null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
// Audio manager (public/js/audio.js): BGM selection, manifest resolution, SFX limiter, and the manager's
|
||||
// never-throw behaviour with a fake Web Audio implementation.
|
||||
|
||||
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';
|
||||
import { bgmKeyFor, resolveBgm, SfxLimiter, AudioManager } from '../../public/js/audio.js';
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const manifest = JSON.parse(readFileSync(path.join(ROOT, 'data', 'assets.json'), 'utf8'));
|
||||
|
||||
describe('bgm selection', () => {
|
||||
test('route and phase → key', () => {
|
||||
assert.equal(bgmKeyFor('title', null), 'lobby');
|
||||
assert.equal(bgmKeyFor('room', null), 'lobby');
|
||||
assert.equal(bgmKeyFor('game', null), 'lobby');
|
||||
assert.equal(bgmKeyFor('game', { phase: PHASE.INFO_CHECK }), 'lobby');
|
||||
assert.equal(bgmKeyFor('game', { phase: PHASE.PREP }), 'prep');
|
||||
assert.equal(bgmKeyFor('game', { phase: PHASE.SP_DRAFT }), 'prep');
|
||||
assert.equal(bgmKeyFor('game', { phase: PHASE.COMBAT }), 'combat');
|
||||
assert.equal(bgmKeyFor('game', { phase: PHASE.UNITE }), 'combat');
|
||||
assert.equal(bgmKeyFor('game', { phase: PHASE.FINAL_ASSAULT, bossId: 'boss_4' }), 'boss:boss_4');
|
||||
assert.equal(bgmKeyFor('game', { phase: PHASE.FINAL_ASSAULT }), 'boss');
|
||||
assert.equal(bgmKeyFor('game', { phase: PHASE.HIDDEN_CORE, bossId: 'boss_1', hiddenBossId: 'boss_9' }), 'boss:boss_9');
|
||||
assert.equal(bgmKeyFor('game', { phase: PHASE.RESULT }), 'lobby');
|
||||
assert.equal(bgmKeyFor('weird', null), null);
|
||||
});
|
||||
test('resolveBgm uses the manifest (boss fallback, intro optional)', () => {
|
||||
const lobby = resolveBgm(manifest, 'lobby');
|
||||
assert.ok(lobby && typeof lobby.loop === 'string');
|
||||
const b4 = resolveBgm(manifest, 'boss:boss_4');
|
||||
assert.equal(b4.loop, manifest.audio.bossBgm.boss_4.loop);
|
||||
assert.equal(resolveBgm(manifest, 'boss:nope').loop, manifest.audio.bgm.boss.loop);
|
||||
assert.equal(resolveBgm(manifest, 'prep').intro, manifest.audio.bgm.prep.intro ?? null);
|
||||
assert.equal(resolveBgm(null, 'lobby'), null);
|
||||
assert.equal(resolveBgm(manifest, null), null);
|
||||
assert.equal(resolveBgm(manifest, 'nope'), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SfxLimiter', () => {
|
||||
test('caps concurrent voices', () => {
|
||||
const l = new SfxLimiter({ maxVoices: 3, unitCooldownMs: 0, urlGapMs: 0 });
|
||||
assert.ok(l.tryAcquire(0, 1, 'a'));
|
||||
assert.ok(l.tryAcquire(0, 2, 'b'));
|
||||
assert.ok(l.tryAcquire(0, 3, 'c'));
|
||||
assert.equal(l.tryAcquire(0, 4, 'd'), false);
|
||||
l.release();
|
||||
assert.ok(l.tryAcquire(0, 4, 'd'));
|
||||
l.release(); l.release(); l.release(); l.release(); l.release();
|
||||
assert.equal(l.active, 0, 'never negative');
|
||||
});
|
||||
test('per-unit cooldown and per-url gap', () => {
|
||||
const l = new SfxLimiter({ maxVoices: 99, unitCooldownMs: 100, urlGapMs: 30 });
|
||||
assert.ok(l.tryAcquire(0, 'u1', 'x'));
|
||||
assert.equal(l.tryAcquire(50, 'u1', 'y'), false, 'same unit too soon');
|
||||
assert.equal(l.tryAcquire(10, 'u2', 'x'), false, 'same url too soon');
|
||||
assert.ok(l.tryAcquire(40, 'u2', 'x'));
|
||||
assert.ok(l.tryAcquire(120, 'u1', 'z'));
|
||||
assert.ok(l.tryAcquire(121, null, 'w'), 'no unit key ⇒ only url gap');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- fake Web Audio -------------------------------------------------------------------------------------
|
||||
|
||||
function fakeWindow() {
|
||||
const listeners = new Map();
|
||||
const made = { sources: 0, started: 0 };
|
||||
class Param { constructor() { this.value = 1; } setValueAtTime(v) { this.value = v; } linearRampToValueAtTime(v) { this.value = v; } setTargetAtTime(v) { this.value = v; } cancelScheduledValues() {} }
|
||||
class Node { connect() {} disconnect() {} }
|
||||
class Gain extends Node { constructor() { super(); this.gain = new Param(); } }
|
||||
class Src extends Node { constructor() { super(); this.playbackRate = new Param(); made.sources++; } start() { made.started++; } stop() {} }
|
||||
class Ctx {
|
||||
constructor() { this.currentTime = 0; this.state = 'running'; this.destination = new Node(); }
|
||||
createGain() { return new Gain(); }
|
||||
createBufferSource() { return new Src(); }
|
||||
decodeAudioData(ab, ok) { ok({ duration: 1.5 }); }
|
||||
resume() { return Promise.resolve(); }
|
||||
suspend() { return Promise.resolve(); }
|
||||
}
|
||||
return {
|
||||
made,
|
||||
win: {
|
||||
AudioContext: Ctx,
|
||||
document: { hidden: false, addEventListener() {} },
|
||||
addEventListener(t, fn) { listeners.set(t, fn); },
|
||||
removeEventListener(t) { listeners.delete(t); },
|
||||
},
|
||||
fire(t) { listeners.get(t)?.(); },
|
||||
};
|
||||
}
|
||||
|
||||
describe('AudioManager', () => {
|
||||
test('no AudioContext / no manifest: every call is a silent no-op', () => {
|
||||
const a = new AudioManager({ win: null, getManifest: () => null });
|
||||
a.install();
|
||||
a.playBgm('prep');
|
||||
a.sfx('buy');
|
||||
a.battle('enemyDie');
|
||||
assert.equal(a.unit('char_x', 'attack', 1), false);
|
||||
a.handleBattleEvents([['atk', 1, 2, 'arrow'], 'junk', null]);
|
||||
a.setVolumes({ bgm: 5, sfx: -1, muted: true });
|
||||
assert.deepEqual(a.volumes, { bgm: 1, sfx: 0, muted: true });
|
||||
assert.equal(a.unlocked, false);
|
||||
});
|
||||
test('unlocks on the first gesture, then plays BGM and SFX from the manifest', async () => {
|
||||
const fw = fakeWindow();
|
||||
const origFetch = globalThis.fetch;
|
||||
const urls = [];
|
||||
globalThis.fetch = async (u) => { urls.push(u); return { ok: true, arrayBuffer: async () => new ArrayBuffer(8) }; };
|
||||
try {
|
||||
const a = new AudioManager({ win: fw.win, getManifest: () => manifest });
|
||||
a.install();
|
||||
a.playBgm('prep'); // remembered while locked
|
||||
assert.equal(urls.length, 0);
|
||||
fw.fire('pointerdown');
|
||||
assert.equal(a.unlocked, true);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.ok(urls.includes(manifest.audio.bgm.prep.loop), 'BGM fetched after unlock');
|
||||
a.sfx('buy');
|
||||
a.sfx('nonexistent');
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.ok(urls.includes(manifest.audio.sfx.ui.buy));
|
||||
// same loop URL ⇒ no restart
|
||||
const before = fw.made.started;
|
||||
a.playBgm('combat');
|
||||
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_'));
|
||||
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));
|
||||
assert.ok(urls.includes(manifest.audio.sfx.units[charId].attack));
|
||||
assert.ok(urls.includes(manifest.audio.sfx.battle.enemyDie), 'fallback death sound');
|
||||
assert.ok(a.limiter.active <= a.limiter.maxVoices);
|
||||
a.setVolumes({ muted: true });
|
||||
const n = urls.length;
|
||||
a.sfx('refresh');
|
||||
assert.equal(urls.length, n, 'muted ⇒ nothing requested');
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
test('fetch failures are swallowed', async () => {
|
||||
const fw = fakeWindow();
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => { throw new Error('offline'); };
|
||||
const origWarn = console.warn;
|
||||
let warns = 0;
|
||||
console.warn = () => { warns++; };
|
||||
try {
|
||||
const a = new AudioManager({ win: fw.win, getManifest: () => manifest });
|
||||
a.install();
|
||||
fw.fire('keydown');
|
||||
a.playBgm('lobby');
|
||||
a.sfx('buy');
|
||||
a.sfx('buy');
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
assert.ok(warns >= 1);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
console.warn = origWarn;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
// 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).
|
||||
|
||||
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';
|
||||
|
||||
function draftOf(o) {
|
||||
const h = makeMatch({ mode: 'coop', ...o }).start();
|
||||
for (const ps of h.m.players.values()) if (!ps.isBot) h.m.handle(ps.playerId, { t: 'g.infoReady' });
|
||||
h.sched.advance(1);
|
||||
assert.equal(h.m.phase, PHASE.BAND_DRAFT);
|
||||
return h;
|
||||
}
|
||||
|
||||
describe('server: no duplicate strategies in the co-op draft', () => {
|
||||
test('a band picked by a teammate is refused with 队友已选; a free one is accepted', () => {
|
||||
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_sarkazb' }), { ok: true });
|
||||
const dup = m.handle(second, { t: 'g.band', bandId: 'band_sarkazb' });
|
||||
assert.equal(dup.error, ERR.BAD_TARGET, 'duplicate refused');
|
||||
assert.equal(m.draftTurn(), second, 'still their turn');
|
||||
assert.equal(m.bandTaken('band_sarkazb', second), true);
|
||||
assert.equal(m.bandTaken('band_sarkazb', first), false, 'your own pick is not "taken by a teammate"');
|
||||
assert.deepEqual(m.handle(second, { t: 'g.band', bandId: 'band_lisa' }), { ok: true });
|
||||
assert.equal(m.handle(third, { t: 'g.band', bandId: 'band_lisa' }).error, ERR.BAD_TARGET);
|
||||
assert.deepEqual(m.handle(third, { t: 'g.band', bandId: 'band_amiya' }), { ok: true });
|
||||
const picks = Object.values(m.publicView().draft?.picks || m.draft.picks);
|
||||
assert.equal(new Set(picks).size, picks.length, 'all different');
|
||||
h.sched.advance(1);
|
||||
assert.equal(m.phase, PHASE.BATTLE_CHECK);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('bots never take a strategy a teammate already holds', () => {
|
||||
for (const seed of [1, 2, 3, 4, 6, 9, 11]) {
|
||||
const h = draftOf({ humans: 1, bots: 3, seed });
|
||||
const m = h.m;
|
||||
// the human picks first when it is their turn; bots pick by themselves
|
||||
h.run(() => m.phase !== PHASE.BAND_DRAFT || m.draftTurn() === 'p_0', { maxTime: 20000 });
|
||||
if (m.phase === PHASE.BAND_DRAFT) assert.deepEqual(m.handle('p_0', { t: 'g.band', bandId: 'band_bldsk' }), { ok: true });
|
||||
h.run(() => m.phase !== PHASE.BAND_DRAFT, { maxTime: 60000 });
|
||||
const ids = [...m.players.values()].map((p) => p.bandId);
|
||||
assert.equal(new Set(ids).size, ids.length, `seed ${seed}: distinct strategies ${ids}`);
|
||||
for (const id of ids) assert.ok(DATA.bands[id], id);
|
||||
m.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('solo: the rule has nothing to compare against', () => {
|
||||
const h = makeMatch({ mode: 'solo', seed: 2 }).start();
|
||||
h.m.handle('p_0', { t: 'g.infoReady' });
|
||||
h.sched.advance(1);
|
||||
assert.equal(h.m.bandTaken('band_orchid', 'p_0'), false);
|
||||
assert.deepEqual(h.m.handle('p_0', { t: 'g.band', bandId: 'band_orchid' }), { ok: true });
|
||||
h.m.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UI: teammateBands', () => {
|
||||
test('maps each band a teammate picked to its pickers; never the viewer', () => {
|
||||
const picks = new Map([['a', 'band_x'], ['b', 'band_y'], ['c', 'band_x'], ['me', 'band_z']]);
|
||||
const t = teammateBands(picks, 'me');
|
||||
assert.deepEqual([...t.keys()].sort(), ['band_x', 'band_y']);
|
||||
assert.deepEqual(t.get('band_x'), ['a', 'c']);
|
||||
assert.equal(t.has('band_z'), false);
|
||||
assert.equal(teammateBands(null, 'me').size, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UI: draftSelection (review regression)', () => {
|
||||
test('on my turn a selection a teammate took meanwhile moves to the first free band; otherwise it is kept', async () => {
|
||||
const { draftSelection } = await import('../../public/js/screens/bandDraft.js');
|
||||
const bands = [{ bandId: 'a' }, { bandId: 'b' }, { bandId: 'c' }];
|
||||
const taken = new Map([['a', ['p1']]]);
|
||||
assert.equal(draftSelection(null, { bands, taken, myPick: null, myTurn: false }), 'b', 'default: first free');
|
||||
assert.equal(draftSelection(null, { bands, taken, myPick: 'c', myTurn: false }), 'c', 'default: my pick');
|
||||
assert.equal(draftSelection('a', { bands, taken, myPick: null, myTurn: false }), 'a', 'browsing a taken band is fine while waiting');
|
||||
assert.equal(draftSelection('a', { bands, taken, myPick: null, myTurn: true }), 'b', 'my turn: off the taken band');
|
||||
assert.equal(draftSelection('c', { bands, taken, myPick: null, myTurn: true }), 'c', 'a free selection is kept');
|
||||
assert.equal(draftSelection('a', { bands, taken, myPick: 'c', myTurn: false }), 'a', 'after my pick nothing moves');
|
||||
assert.equal(draftSelection('x', { bands: [], taken, myPick: null, myTurn: true }), 'x', 'no bands: unchanged');
|
||||
});
|
||||
});
|
||||
|
||||
describe('server: automatic assignments never duplicate a teammate\'s strategy', () => {
|
||||
const distinct = (m) => {
|
||||
const ids = [...m.players.values()].map((p) => p.bandId);
|
||||
assert.equal(new Set(ids).size, ids.length, `distinct strategies ${ids}`);
|
||||
for (const id of ids) assert.ok(DATA.bands[id], id);
|
||||
return ids;
|
||||
};
|
||||
|
||||
test('12 s turn timeout: 华法琳 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);
|
||||
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);
|
||||
assert.equal(m.draft.picks[third], 'band_duyaoy');
|
||||
distinct(m);
|
||||
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);
|
||||
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', () => {
|
||||
const h = draftOf({ humans: 4, seed: 9 });
|
||||
const m = h.m;
|
||||
h.run(() => m.phase !== PHASE.BAND_DRAFT, { maxTime: 120000 });
|
||||
assert.equal(m.phase, PHASE.BATTLE_CHECK);
|
||||
const ids = distinct(m);
|
||||
assert.ok(ids.includes('band_bldsk'), 'the first idle player still gets the official default');
|
||||
for (const ps of m.players.values()) assert.equal(ps.lp, DATA.bands[ps.bandId].totalHp, `${ps.playerId} LP follows its strategy`);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('the step cap (finishBandDraft) assigns the remaining seats one after another without duplicates', () => {
|
||||
const h = draftOf({ humans: 3, seed: 5 });
|
||||
const m = h.m;
|
||||
const [first] = m.draft.order;
|
||||
assert.deepEqual(m.handle(first, { t: 'g.band', bandId: 'band_bldsk' }), { ok: true });
|
||||
m.finishBandDraft(true);
|
||||
distinct(m);
|
||||
assert.equal(m.phase, PHASE.BATTLE_CHECK);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('a departing seat passes with a free strategy; mixed with bots nobody shares one', () => {
|
||||
const h = draftOf({ humans: 3, seed: 5 });
|
||||
const m = h.m;
|
||||
const [first, second] = m.draft.order;
|
||||
assert.deepEqual(m.handle(first, { t: 'g.band', bandId: 'band_bldsk' }), { ok: true });
|
||||
m.onLeave(second);
|
||||
assert.notEqual(m.draft.picks[second], 'band_bldsk', 'the leaver does not duplicate 华法琳');
|
||||
assert.ok(DATA.bands[m.draft.picks[second]]);
|
||||
m.dispose();
|
||||
for (const seed of [1, 2, 3, 4]) {
|
||||
const h2 = draftOf({ humans: 2, bots: 2, seed });
|
||||
h2.run(() => h2.m.phase !== PHASE.BAND_DRAFT, { maxTime: 120000 });
|
||||
distinct(h2.m);
|
||||
h2.m.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('UI: timeoutBand (the tip names what a timeout gives me)', () => {
|
||||
const bands = allowedBands([
|
||||
{ bandId: 'band_amiya', sortId: 2 }, { bandId: 'band_bldsk', sortId: 1 }, { bandId: 'band_duyaoy', sortId: 3 },
|
||||
{ bandId: 'band_solo', sortId: 0, modeTypeList: ['SINGLE'] },
|
||||
], 'MULTI');
|
||||
test('allowedBands keeps the server order (sortId, then id) and the mode filter', () => {
|
||||
assert.deepEqual(bands.map((b) => b.bandId), ['band_bldsk', 'band_amiya', 'band_duyaoy']);
|
||||
});
|
||||
test('华法琳 while free; else the first free one; never a taken one', () => {
|
||||
assert.equal(timeoutBand(bands, new Map()), 'band_bldsk');
|
||||
assert.equal(timeoutBand(bands, new Map([['band_amiya', ['a']]])), 'band_bldsk');
|
||||
assert.equal(timeoutBand(bands, new Map([['band_bldsk', ['a']]])), 'band_amiya');
|
||||
assert.equal(timeoutBand(bands, new Map([['band_bldsk', ['a']], ['band_amiya', ['b']]])), 'band_duyaoy');
|
||||
assert.equal(timeoutBand([], new Map()), 'band_bldsk', 'no data yet: the official default');
|
||||
assert.equal(timeoutBand(bands, null), 'band_bldsk');
|
||||
});
|
||||
test('matches the server for every combination of taken strategies', () => {
|
||||
const h = draftOf({ humans: 4, seed: 3 });
|
||||
const m = h.m;
|
||||
const all = allowedBands(Object.values(DATA.bands), 'MULTI');
|
||||
assert.deepEqual(all.map((b) => b.bandId), m.gd.bandIds(), 'same order as gd.bandIds');
|
||||
const [a, b, c, d] = m.draft.order;
|
||||
for (const picks of [[], ['band_bldsk'], ['band_bldsk', 'band_amiya'], ['band_amiya', 'band_duyaoy'], ['band_bldsk', 'band_amiya', 'band_duyaoy']]) {
|
||||
m.draft.picks = Object.fromEntries(picks.map((id, i) => [[a, b, c][i], id]));
|
||||
const taken = teammateBands(new Map(Object.entries(m.draft.picks)), d);
|
||||
assert.equal(timeoutBand(all, taken), m.defaultBand(d), `picks ${picks}`);
|
||||
}
|
||||
m.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,419 @@
|
||||
// Multi-device E2E (puppeteer-core + system Chrome device emulation). Opt-in: SP_E2E=1 (needs Chrome, ~2 min).
|
||||
//
|
||||
// SP_E2E=1 node --test test/ui/devices.e2e.test.js
|
||||
//
|
||||
// Devices (landscape): iPad (1180×820, touch), iPhone 14 (844×390, touch), Pixel 7 (915×412, touch), a 1366×768 touch
|
||||
// laptop (touch + mouse) and the smallest supported phone (640×360). For each: the real title page (index.html) — on
|
||||
// phones and the iPad also title → lobby → room with taps against the real server — and the in-match mock
|
||||
// (public/dev/game-mock.html) — screenshots → test/e2e/out/device-<device>-<screen>.png — and checks:
|
||||
// * no page overflow, every visible HUD control fully inside the viewport, the rotate hint hidden in landscape;
|
||||
// * <html> feature classes (sp-touch / sp-coarse) from feature detection, touch-action on the field and the page;
|
||||
// * touch: tap targets of the small HUD controls are ≥ 40 px (hit-area probe with elementFromPoint), the 🔍▶▶ pen
|
||||
// button works with taps, two-tap buy works with taps, a long press on a shop card opens its detail (no purchase);
|
||||
// * safe-area insets (a notch on the left) move the HUD, not the field;
|
||||
// plus graceful degradation: WebGL disabled → the DOM view with a notice, local-client art missing → CSS look-alikes,
|
||||
// portrait → the rotate hint.
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const OUT = path.join(ROOT, 'test/e2e/out');
|
||||
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
|
||||
const IOS_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
|
||||
const IPAD_UA = 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
|
||||
const ANDROID_UA = 'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36';
|
||||
|
||||
const DEVICES = {
|
||||
ipad: { viewport: { width: 1180, height: 820, deviceScaleFactor: 2, isMobile: true, hasTouch: true, isLandscape: true }, userAgent: IPAD_UA, coarse: true },
|
||||
iphone14: { viewport: { width: 844, height: 390, deviceScaleFactor: 3, isMobile: true, hasTouch: true, isLandscape: true }, userAgent: IOS_UA, coarse: true },
|
||||
pixel7: { viewport: { width: 915, height: 412, deviceScaleFactor: 2.625, isMobile: true, hasTouch: true, isLandscape: true }, userAgent: ANDROID_UA, coarse: true },
|
||||
'laptop-touch': { viewport: { width: 1366, height: 768, deviceScaleFactor: 1, isMobile: false, hasTouch: true, isLandscape: true }, userAgent: null, coarse: true },
|
||||
'phone-min': { viewport: { width: 640, height: 360, deviceScaleFactor: 2, isMobile: true, hasTouch: true, isLandscape: true }, userAgent: ANDROID_UA, coarse: true },
|
||||
};
|
||||
/** Small HUD controls that must have a ≥ 40 px hit area on touch screens. */
|
||||
const HUD_TARGETS = ['.gtop__exit', '.gtop__iconbtn', '.enemybtn', '.readybtn', '.toolbtn', '.ewheel__btn', '.gm__gear'];
|
||||
|
||||
describe('multi-device (Chrome device emulation)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||
let srv;
|
||||
let browser;
|
||||
let base;
|
||||
|
||||
before(async () => {
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
base = `http://127.0.0.1:${srv.port}`;
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-sandbox'] });
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
await srv?.close();
|
||||
});
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function open(dev, url, { render = 'engine', intercept = null, b = browser, ctx = null } = {}) {
|
||||
const page = await (ctx || b).newPage();
|
||||
const d = DEVICES[dev];
|
||||
await page.emulate({ viewport: d.viewport, userAgent: d.userAgent || await b.userAgent() });
|
||||
const problems = [];
|
||||
const local = (u) => u.startsWith(base);
|
||||
page.on('console', (m) => { if (m.type() === 'error' && !/Failed to load resource/.test(m.text())) problems.push(`console: ${m.text()}`); });
|
||||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
page.on('requestfailed', (r) => { if (local(r.url()) && !intercept) problems.push(`requestfailed: ${r.url()}`); });
|
||||
if (intercept) {
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (r) => (intercept(r.url()) ? r.respond({ status: 404, body: 'gone' }) : r.continue()));
|
||||
}
|
||||
const full = url.startsWith('/') ? `${base}${url}` : `${base}/dev/game-mock.html?shot=1&render=${render}&${url}`;
|
||||
await page.goto(full, { waitUntil: 'networkidle0' });
|
||||
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)'), { timeout: 20000 });
|
||||
await sleep(url.startsWith('/') ? 600 : 1600);
|
||||
return { page, problems };
|
||||
}
|
||||
|
||||
/** No scrollbars, every visible control inside the viewport. */
|
||||
async function layoutProblems(page, scope = 'body') {
|
||||
return page.evaluate((sel) => {
|
||||
const out = [];
|
||||
const se = document.scrollingElement || document.documentElement;
|
||||
if (se.scrollWidth > innerWidth + 1) out.push(`page overflows horizontally (${se.scrollWidth} > ${innerWidth})`);
|
||||
if (se.scrollHeight > innerHeight + 1) out.push(`page overflows vertically (${se.scrollHeight} > ${innerHeight})`);
|
||||
const root = document.querySelector(sel) || document.body;
|
||||
for (const el of root.querySelectorAll('button, input, [role="button"]')) {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width < 2 || r.height < 2) continue;
|
||||
let hidden = false;
|
||||
for (let a = el; a && a !== document.body; a = a.parentElement) {
|
||||
const cs = getComputedStyle(a);
|
||||
if (cs.visibility === 'hidden' || cs.display === 'none' || Number(cs.opacity) < 0.05) { hidden = true; break; }
|
||||
}
|
||||
if (hidden || el.closest('.mockbar')) continue;
|
||||
// content of a scroller (strategy grid, result list …) may scroll; an element parked fully off-screen is hidden
|
||||
let scroller = false;
|
||||
for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) {
|
||||
const cs = getComputedStyle(a);
|
||||
if (/(auto|scroll)/.test(cs.overflowX + cs.overflowY)) { scroller = true; break; }
|
||||
}
|
||||
if (scroller) continue;
|
||||
if (r.right <= 0 || r.bottom <= 0 || r.left >= innerWidth || r.top >= innerHeight) continue;
|
||||
if (r.left < -1 || r.top < -1 || r.right > innerWidth + 1 || r.bottom > innerHeight + 1) {
|
||||
out.push(`clipped: ${el.className || el.tagName} ${(el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 12)} [${Math.round(r.left)},${Math.round(r.top)},${Math.round(r.right)},${Math.round(r.bottom)}]`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, scope);
|
||||
}
|
||||
|
||||
/** Effective hit box of the first visible match of each selector (probe outward from the centre). */
|
||||
async function hitBoxes(page, sels) {
|
||||
return page.evaluate((list) => list.map((sel) => {
|
||||
const el = [...document.querySelectorAll(sel)].find((x) => x.getBoundingClientRect().width > 1);
|
||||
if (!el) return { sel, missing: true };
|
||||
const r = el.getBoundingClientRect();
|
||||
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
|
||||
const inside = (x, y) => { const t = document.elementFromPoint(x, y); return !!t && (t === el || el.contains(t)); };
|
||||
const reach = (dx, dy) => { let d = 0; while (d < 30 && inside(cx + dx * (d + 1), cy + dy * (d + 1))) d++; return d; };
|
||||
return { sel, w: reach(-1, 0) + reach(1, 0) + 1, h: reach(0, -1) + reach(0, 1) + 1 };
|
||||
}), sels);
|
||||
}
|
||||
|
||||
for (const dev of Object.keys(DEVICES)) {
|
||||
test(`${dev}: title page — fits, touch classes, no zoom gestures, no errors`, async () => {
|
||||
const { page, problems } = await open(dev, '/');
|
||||
await page.waitForSelector('.title-screen', { timeout: 10000 });
|
||||
await page.screenshot({ path: path.join(OUT, `device-${dev}-title.png`) });
|
||||
const st = await page.evaluate(() => ({
|
||||
cls: document.documentElement.className,
|
||||
rotate: getComputedStyle(document.querySelector('.rotate-hint')).display,
|
||||
touchAction: getComputedStyle(document.documentElement).touchAction,
|
||||
focus: document.activeElement?.tagName,
|
||||
}));
|
||||
assert.match(st.cls, /\bsp-touch\b/, 'touch detected');
|
||||
assert.match(st.cls, /\bsp-coarse\b/, 'coarse pointer detected');
|
||||
assert.equal(st.rotate, 'none', 'no rotate hint in landscape');
|
||||
assert.equal(st.touchAction, 'pan-x pan-y', 'no pinch / double-tap zoom');
|
||||
assert.notEqual(st.focus, 'INPUT', 'no autofocus on touch screens (it would pop the keyboard)');
|
||||
assert.deepEqual(await layoutProblems(page), []);
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test(`${dev}: match HUD (prep / combat / strategy draft) fits; ≥ 40 px touch targets`, async () => {
|
||||
for (const [name, q] of [['prep', 'phase=PREP'], ['combat', 'phase=COMBAT'], ['draft', 'phase=BAND_DRAFT'], ['briefing', 'phase=INFO_CHECK'], ['sp', 'phase=SP_DRAFT&variant=bounty'], ['result', 'phase=RESULT']]) {
|
||||
const { page, problems } = await open(dev, q);
|
||||
await page.screenshot({ path: path.join(OUT, `device-${dev}-${name}.png`) });
|
||||
assert.deepEqual(await layoutProblems(page), [], `${dev} ${name}`);
|
||||
if (name === 'prep') {
|
||||
assert.equal(await page.$eval('.gm__field canvas:last-of-type', (el) => getComputedStyle(el).touchAction).catch(() => 'none'), 'none', 'the field owns its gestures');
|
||||
for (const b of await hitBoxes(page, HUD_TARGETS)) {
|
||||
if (b.missing) continue;
|
||||
assert.ok(b.w >= 34 && b.h >= 34 && Math.max(b.w, b.h) >= 40, `${dev}: ${b.sel} hit box ${b.w}×${b.h}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(problems, [], `${dev} ${name}`);
|
||||
await page.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const dev of ['iphone14', 'ipad', 'laptop-touch']) {
|
||||
test(`${dev}: touch — tap the pen button and back, two-tap buy, long-press = detail`, async () => {
|
||||
const { page, problems } = await open(dev, 'phase=PREP');
|
||||
await page.waitForFunction(() => !!globalThis.__SP_VIEW__, { timeout: 15000 });
|
||||
const tap = async (sel) => {
|
||||
const r = await page.$eval(sel, (el) => { const b = el.getBoundingClientRect(); return { x: b.left + b.width / 2, y: b.top + b.height / 2 }; });
|
||||
await page.touchscreen.tap(r.x, r.y);
|
||||
await sleep(350);
|
||||
};
|
||||
await tap('.enemybtn');
|
||||
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'pen', { timeout: 3000 });
|
||||
await sleep(400);
|
||||
await page.screenshot({ path: path.join(OUT, `device-${dev}-pen.png`) });
|
||||
await tap('.gtop__iconbtn');
|
||||
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'prep', { timeout: 3000 });
|
||||
assert.equal(await page.$('.tooltip.is-shown'), null, 'a tap never leaves a tooltip behind');
|
||||
// two taps buy
|
||||
const funds0 = await page.evaluate(() => globalThis.__MOCK__.S().priv.funds);
|
||||
await tap('.shopbar__cards .scard:not(.scard--sold):not(.is-disabled)');
|
||||
assert.ok(await page.$('.scard.is-armed'), 'first tap arms 确认购买');
|
||||
await tap('.scard.is-armed');
|
||||
const funds1 = await page.evaluate(() => globalThis.__MOCK__.S().priv.funds);
|
||||
assert.ok(funds1 < funds0, 'second tap buys');
|
||||
// long press on a card: its detail, no purchase / arming
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(200);
|
||||
const card = await page.$eval('.shopbar__cards .scard:not(.scard--sold):not(.is-disabled):not(.is-armed)', (el) => { const b = el.getBoundingClientRect(); return { x: b.left + b.width / 2, y: b.top + b.height / 2 }; });
|
||||
await page.touchscreen.touchStart(card.x, card.y);
|
||||
await sleep(800);
|
||||
await page.touchscreen.touchEnd();
|
||||
await sleep(400);
|
||||
assert.ok(await page.$('.dpanel'), 'long press opens the detail card');
|
||||
assert.equal(await page.evaluate(() => globalThis.__MOCK__.S().priv.funds), funds1, 'long press never buys');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
}
|
||||
|
||||
for (const dev of ['iphone14', 'phone-min', 'pixel7', 'ipad']) {
|
||||
test(`${dev}: 交流 pager — every emote cell, dot and ‹ › is hit at its centre; taps send / change the page`, async () => {
|
||||
const { page, problems } = await open(dev, 'phase=PREP');
|
||||
await page.evaluate(() => localStorage.removeItem('sp.pref.emoteTheme'));
|
||||
const tapAt = async (p) => { await page.touchscreen.tap(p.x, p.y); await sleep(350); };
|
||||
const openWheel = async () => {
|
||||
const b = await page.$eval('.ewheel__btn', (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });
|
||||
await tapAt(b);
|
||||
await page.waitForSelector('.ewheel__panel');
|
||||
await sleep(450); // the panel's pop-in animation
|
||||
};
|
||||
await openWheel();
|
||||
const hits = await page.evaluate(() => {
|
||||
const own = (el, sel) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const t = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||
return t?.closest(sel) === el ? null : `${el.getAttribute('aria-label')} → ${t ? `${t.tagName}.${t.className} ${t.getAttribute('aria-label') || ''}` : 'nothing'}`;
|
||||
};
|
||||
return [...document.querySelectorAll('.ewheel__item')].map((el) => own(el, '.ewheel__item'))
|
||||
.concat([...document.querySelectorAll('.ewheel__dot')].map((el) => own(el, '.ewheel__dot')))
|
||||
.concat([...document.querySelectorAll('.ewheel__nav')].map((el) => own(el, '.ewheel__nav')))
|
||||
.filter(Boolean);
|
||||
});
|
||||
assert.deepEqual(hits, [], `${dev}: controls covered by a neighbour's hit area`);
|
||||
// each dot opens its own page — the last one first: dot 1 used to act as ‹ from the last page
|
||||
const dots = await page.$$eval('.ewheel__dot', (els) => els.map((el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; }));
|
||||
for (const i of [dots.length - 1, ...dots.keys()]) {
|
||||
await tapAt(dots[i]);
|
||||
assert.equal(await page.$$eval('.ewheel__dot', (els) => els.findIndex((d) => d.classList.contains('is-on'))), i, `${dev}: dot ${i + 1}`);
|
||||
}
|
||||
await tapAt(dots[0]);
|
||||
// the bottom corners of page 1 send (they sat under the enlarged ‹ / › hit areas)
|
||||
for (const idx of [3, 5]) {
|
||||
if (!(await page.$('.ewheel__panel'))) await openWheel();
|
||||
const c = await page.$$eval('.ewheel__item', (els, k) => { const r = els[k].getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2, page: document.querySelector('.ewheel__dot.is-on')?.getAttribute('aria-label') }; }, idx);
|
||||
assert.match(c.page, /1\/6/, 'page 1');
|
||||
await tapAt(c);
|
||||
assert.equal(await page.$('.ewheel__panel'), null, `${dev}: a tap on emote #${idx + 1} sends it (the panel closes)`);
|
||||
await sleep(1100); // chatCD
|
||||
}
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
}
|
||||
|
||||
for (const dev of ['phone-min', 'iphone14']) {
|
||||
test(`${dev}: the left detail card leaves the corner buttons (⚙ 📖 ⛶) usable`, async () => {
|
||||
const { page, problems } = await open(dev, 'phase=PREP');
|
||||
const card = await page.$eval('.shopbar__cards .scard:not(.scard--sold):not(.is-disabled)', (el) => { const b = el.getBoundingClientRect(); return { x: b.left + b.width / 2, y: b.top + b.height / 2 }; });
|
||||
await page.touchscreen.tap(card.x, card.y);
|
||||
await page.waitForSelector('.dpanel:not(.dpanel--right)', { timeout: 3000 });
|
||||
await sleep(400);
|
||||
const covered = await page.evaluate(() => [...document.querySelectorAll('.gm__corner .gm__gear')].map((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const t = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||
return t && el.contains(t) ? null : `${el.getAttribute('aria-label') || el.className} under ${t?.className}`;
|
||||
}).filter(Boolean));
|
||||
await page.screenshot({ path: path.join(OUT, `device-${dev}-detail-corner.png`) });
|
||||
assert.deepEqual(covered, []);
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
}
|
||||
|
||||
for (const dev of ['iphone14', 'phone-min', 'ipad']) {
|
||||
test(`${dev}: title → lobby → room with taps (the on-screen flow before a match fits and responds)`, async () => {
|
||||
// a fresh profile: another device's saved session (same origin) would resume straight into its room
|
||||
const ctx = await browser.createBrowserContext();
|
||||
const { page, problems } = await open(dev, '/', { ctx });
|
||||
const tapText = async (sel, text) => {
|
||||
const r = await page.evaluate((s, t) => {
|
||||
const el = [...document.querySelectorAll(s)].find((x) => !t || x.textContent.includes(t));
|
||||
if (!el) return null;
|
||||
const b = el.getBoundingClientRect();
|
||||
return { x: b.left + b.width / 2, y: b.top + b.height / 2 };
|
||||
}, sel, text);
|
||||
assert.ok(r, `${sel} ${text || ''}`);
|
||||
await page.touchscreen.tap(r.x, r.y);
|
||||
await sleep(350);
|
||||
};
|
||||
await tapText('.title-login input');
|
||||
await page.keyboard.type('凯尔希');
|
||||
await tapText('.title-login button', '开始');
|
||||
await page.waitForSelector('.lobby-screen', { timeout: 15000 }).catch(async (err) => {
|
||||
await page.screenshot({ path: path.join(OUT, `device-${dev}-lobby-FAILED.png`) });
|
||||
throw err;
|
||||
});
|
||||
await sleep(400);
|
||||
await page.screenshot({ path: path.join(OUT, `device-${dev}-lobby.png`) });
|
||||
assert.deepEqual(await layoutProblems(page), [], `${dev} lobby`);
|
||||
await tapText('.mode-card', '同盟模拟');
|
||||
await tapText('.create-box button', '创建同盟');
|
||||
await page.waitForSelector('.room-bar__right', { timeout: 15000 });
|
||||
await sleep(500);
|
||||
await page.screenshot({ path: path.join(OUT, `device-${dev}-room.png`) });
|
||||
assert.deepEqual(await layoutProblems(page), [], `${dev} room`);
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
await ctx.close();
|
||||
});
|
||||
}
|
||||
|
||||
test('safe-area insets (notch on the left): the HUD moves in, the field stays full-bleed', async () => {
|
||||
const { page, problems } = await open('iphone14', 'phase=PREP');
|
||||
await page.addStyleTag({ content: ':root { --sa-l: 47px !important; --sa-r: 0px !important; --sa-b: 21px !important; }' });
|
||||
await sleep(300);
|
||||
const r = await page.evaluate(() => {
|
||||
const box = (s) => { const b = document.querySelector(s)?.getBoundingClientRect(); return b ? { left: b.left, bottom: b.bottom, right: b.right } : null; };
|
||||
return { hud: box('.gm__hud'), field: box('.gm__field'), exit: box('.gtop__exit'), corner: box('.gm__corner') };
|
||||
});
|
||||
assert.equal(Math.round(r.hud.left), 47);
|
||||
assert.equal(Math.round(r.field.left), 0, 'the board stays full-bleed');
|
||||
assert.ok(r.exit.left >= 47, `exit button clear of the notch (${r.exit.left})`);
|
||||
assert.ok(r.corner.bottom <= 390 - 21 + 0.5, 'corner buttons clear of the home indicator');
|
||||
await page.screenshot({ path: path.join(OUT, 'device-iphone14-notch.png') });
|
||||
// review regression: a top inset moves the in-match toasts under the top bar once (not twice: margin + top)
|
||||
await page.addStyleTag({ content: ':root { --sa-t: 20px !important; }' });
|
||||
await page.evaluate(() => import('/js/ui/toasts.js').then((m) => m.toast('整备区已满', 'warn')));
|
||||
await page.waitForSelector('.toast');
|
||||
const t = await page.evaluate(() => ({
|
||||
host: document.querySelector('.toast-host').getBoundingClientRect().top,
|
||||
rem: parseFloat(getComputedStyle(document.documentElement).fontSize),
|
||||
}));
|
||||
assert.ok(Math.abs(t.host - (1.34 * t.rem + 20)) <= 1.5, `toast host at ${t.host} (expected ${1.34 * t.rem + 20})`);
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('交流 button: the label never runs into the robot icon (desktop sizes, phones, CJK 12 px minimum font size)', async () => {
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
const { mkdtempSync, mkdirSync: mk, writeFileSync, rmSync } = await import('node:fs');
|
||||
const os = await import('node:os');
|
||||
const prof = mkdtempSync(path.join(os.tmpdir(), 'sp-minfont-'));
|
||||
mk(path.join(prof, 'Default'), { recursive: true });
|
||||
// Chrome / Edge in Chinese locales clamp text to 12 px (webkit.webprefs.minimum_font_size)
|
||||
writeFileSync(path.join(prof, 'Default', 'Preferences'), JSON.stringify({ webkit: { webprefs: { minimum_font_size: 12, minimum_logical_font_size: 12 } } }));
|
||||
const b12 = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-sandbox'], userDataDir: prof });
|
||||
try {
|
||||
for (const b of [browser, b12]) {
|
||||
for (const [w, h, touch] of [[1280, 720], [1366, 657], [1920, 1080], [2560, 1440], [844, 390, true], [640, 360, true]]) {
|
||||
const page = await b.newPage();
|
||||
await page.emulate({ viewport: { width: w, height: h, deviceScaleFactor: 1, isMobile: !!touch, hasTouch: !!touch, isLandscape: true }, userAgent: await b.userAgent() });
|
||||
await page.goto(`${base}/dev/game-mock.html?shot=1&render=fallback&phase=PREP`, { waitUntil: 'networkidle0' });
|
||||
await page.waitForSelector('.ewheel__btn');
|
||||
await sleep(300);
|
||||
const m = await page.$eval('.ewheel__btn', (btn) => {
|
||||
const r = btn.getBoundingClientRect();
|
||||
const lab = btn.querySelector('.ewheel__label');
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(lab);
|
||||
const t = range.getBoundingClientRect();
|
||||
const icon = btn.querySelector('.icon, svg');
|
||||
const ir = icon ? icon.getBoundingClientRect() : null;
|
||||
return { sprite: btn.classList.contains('has-sprite'), l: r.left, w: r.width, right: r.right, tl: t.left, tr: t.right, iconRight: ir ? ir.right : null, fs: parseFloat(getComputedStyle(lab).fontSize) };
|
||||
});
|
||||
const where = `${b === b12 ? 'min-font 12 ' : ''}${w}×${h}`;
|
||||
// sprite: the robot + chevron end at x = 79/151 of the button; CSS fallback: after the icon
|
||||
const iconEnd = m.sprite ? m.l + m.w * (80 / 151) : m.iconRight;
|
||||
assert.ok(m.tl >= iconEnd - 0.5, `${where}: label starts at ${m.tl.toFixed(1)} < icon end ${iconEnd.toFixed(1)} (font ${m.fs}px)`);
|
||||
assert.ok(m.tr <= m.right + 0.5, `${where}: label spills out of the button`);
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await b12.close();
|
||||
try { rmSync(prof, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
test('portrait phone: the rotate hint covers the page', async () => {
|
||||
const page = await browser.newPage();
|
||||
await page.emulate({ viewport: { width: 390, height: 844, deviceScaleFactor: 3, isMobile: true, hasTouch: true }, userAgent: IOS_UA });
|
||||
await page.goto(`${base}/`, { waitUntil: 'networkidle0' });
|
||||
await sleep(400);
|
||||
assert.equal(await page.$eval('.rotate-hint', (el) => getComputedStyle(el).display), 'grid');
|
||||
await page.screenshot({ path: path.join(OUT, 'device-portrait-rotate.png') });
|
||||
await page.close();
|
||||
// review regression: a narrow desktop window (half a 1080p screen, portrait monitor) cannot be "rotated" — no hint
|
||||
const desk = await browser.newPage();
|
||||
await desk.setViewport({ width: 900, height: 1000 });
|
||||
await desk.goto(`${base}/`, { waitUntil: 'networkidle0' });
|
||||
await desk.waitForSelector('.title-screen', { timeout: 10000 });
|
||||
assert.equal(await desk.$eval('.rotate-hint', (el) => getComputedStyle(el).display), 'none', 'desktop keeps the game');
|
||||
await desk.close();
|
||||
});
|
||||
|
||||
test('graceful degradation: no WebGL → the DOM view (with a notice); no local-client art → CSS look-alikes', async () => {
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
const nogl = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-sandbox', '--disable-webgl', '--disable-webgl2', '--disable-3d-apis'] });
|
||||
try {
|
||||
const { page, problems } = await open('ipad', 'phase=PREP', { b: nogl });
|
||||
await page.waitForFunction(() => globalThis.__SP_VIEW__?.kind === 'fallback', { timeout: 20000 });
|
||||
await page.waitForSelector('.toast', { timeout: 5000 });
|
||||
assert.match(await page.$eval('.toast-host', (el) => el.textContent), /简化视图/);
|
||||
await page.click('.enemybtn');
|
||||
await page.waitForSelector('.ff-pen__enemy', { timeout: 3000 });
|
||||
await page.screenshot({ path: path.join(OUT, 'device-ipad-nowebgl-pen.png') });
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
} finally {
|
||||
await nogl.close();
|
||||
}
|
||||
const { page, problems } = await open('iphone14', 'phase=PREP', { intercept: (u) => /\/data\/local-assets\.json|\/assets\/local\//.test(u) });
|
||||
const st = await page.evaluate(() => ({
|
||||
chk: document.querySelector('.enemybtn')?.classList.contains('has-sprite'),
|
||||
emo: document.querySelector('.ewheel__btn')?.classList.contains('has-sprite'),
|
||||
label: document.querySelector('.ewheel__label')?.textContent,
|
||||
}));
|
||||
assert.deepEqual(st, { chk: false, emo: false, label: '交流' }, 'CSS look-alikes');
|
||||
await page.screenshot({ path: path.join(OUT, 'device-iphone14-noart.png') });
|
||||
assert.deepEqual(await layoutProblems(page), []);
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,401 @@
|
||||
// Multi-device support and UI-polish units (no browser): ui/device.js (feature detection, <html> classes, zoom-gesture
|
||||
// blocking, long-press = detail, fullscreen), ui/compat.js polyfills, the HUD 🔍 buttons (hud.js checkButtons), the
|
||||
// enemy preview pen helpers (gameLogic penPlacement / previewEnemyKey), the connection-banner class, the audio unlock on
|
||||
// iOS-like contexts, and the title's lone-surrogate scan (no regex lookbehind: a SyntaxError in Safari < 16.4).
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installCompat } from '../../public/js/ui/compat.js';
|
||||
import { detectFeatures, featureClasses, fullscreen, installDeviceSupport, LONG_PRESS_MS, screenLandscape } from '../../public/js/ui/device.js';
|
||||
import { checkButtons } from '../../public/js/ui/hud.js';
|
||||
import { penPlacement, penZoneTiles, previewEnemyKey, PEN } from '../../public/js/ui/gameLogic.js';
|
||||
import { bannerVisible } from '../../public/js/ui/connBanner.js';
|
||||
import { layoutPen } from '../../public/js/render/pen.js';
|
||||
import { stripLoneSurrogates, sanitizeName } from '../../public/js/screens/title.js';
|
||||
import { AudioManager } from '../../public/js/audio.js';
|
||||
|
||||
// ---- fakes -----------------------------------------------------------------------------------------------------------
|
||||
|
||||
class FakeEvent {
|
||||
constructor(type, init = {}) { Object.assign(this, { cancelable: true, isTrusted: true, ...init }); this.type = type; this.defaultPrevented = false; this.stopped = false; }
|
||||
preventDefault() { if (this.cancelable) this.defaultPrevented = true; }
|
||||
stopPropagation() { this.stopped = true; }
|
||||
}
|
||||
function emitter() {
|
||||
const map = new Map();
|
||||
return {
|
||||
addEventListener(t, fn) { if (!map.has(t)) map.set(t, new Set()); map.get(t).add(fn); },
|
||||
removeEventListener(t, fn) { map.get(t)?.delete(fn); },
|
||||
dispatch(ev) { for (const fn of [...(map.get(ev.type) || [])]) fn(ev); return ev; },
|
||||
count(t) { return map.get(t)?.size || 0; },
|
||||
};
|
||||
}
|
||||
class FakeElement {
|
||||
/** `handles`: a contextmenu handler on the element (or an ancestor) takes the event (preventDefault). */
|
||||
constructor(tag = 'div', { closest = () => null, handles = false } = {}) { this.tagName = tag.toUpperCase(); this.isConnected = true; this._closest = closest; this._handles = handles; this.dispatched = []; }
|
||||
closest(sel) { return this._closest(sel); }
|
||||
dispatchEvent(ev) { this.dispatched.push(ev); if (this._handles && ev.type === 'contextmenu') ev.preventDefault(); return !ev.defaultPrevented; }
|
||||
}
|
||||
function fakeWindow({ media = {}, touchPoints = 0, fs = true } = {}) {
|
||||
const classes = new Set();
|
||||
const props = new Map();
|
||||
const doc = emitter();
|
||||
const el = { classList: { toggle: (k, on) => (on ? classes.add(k) : classes.delete(k)), add: (k) => classes.add(k), remove: (k) => classes.delete(k), contains: (k) => classes.has(k) },
|
||||
style: { setProperty: (k, v) => props.set(k, v) }, requestFullscreen: fs ? async () => { doc.fullscreenElement = el; } : undefined };
|
||||
Object.assign(doc, { documentElement: el, fullscreenEnabled: fs, fullscreenElement: null, exitFullscreen: async () => { doc.fullscreenElement = null; } });
|
||||
const win = emitter();
|
||||
Object.assign(win, {
|
||||
document: doc, navigator: { maxTouchPoints: touchPoints }, innerHeight: 390,
|
||||
matchMedia: (q) => ({ matches: !!media[q], addEventListener() {}, removeEventListener() {} }),
|
||||
Element: FakeElement, MouseEvent: FakeEvent, Event: FakeEvent, screen: {},
|
||||
});
|
||||
return { win, doc, classes, props };
|
||||
}
|
||||
|
||||
// ---- compat ------------------------------------------------------------------------------------------------------------
|
||||
|
||||
describe('ui/compat.js polyfills (Safari 15.0–15.3 / Firefox ESR)', () => {
|
||||
test('installs only what is missing, with standard behaviour', () => {
|
||||
// a global whose builtins lack the newer methods (bare prototypes; the polyfills are generic, called with real values)
|
||||
const g = { Object: { ...Object, hasOwn: undefined }, Array: function FakeArray() {}, String: function FakeString() {} };
|
||||
g.Array.prototype = {};
|
||||
g.String.prototype = {};
|
||||
const added = installCompat(g);
|
||||
for (const k of ['hasOwn', 'at', 'findLast', 'findLastIndex', 'structuredClone']) assert.ok(added.includes(k), `${k} installed`);
|
||||
assert.equal(g.Object.hasOwn({ a: 1 }, 'a'), true);
|
||||
assert.equal(g.Object.hasOwn(Object.create({ a: 1 }), 'a'), false, 'own properties only');
|
||||
assert.throws(() => g.Object.hasOwn(null, 'a'), TypeError);
|
||||
const at = g.Array.prototype.at;
|
||||
assert.equal(at.call([1, 2, 3], -1), 3);
|
||||
assert.equal(at.call([1, 2, 3], 5), undefined);
|
||||
assert.equal(g.String.prototype.at.call('abc', -1), 'c');
|
||||
assert.equal(g.Array.prototype.findLast.call([1, 2, 3, 4], (x) => x % 2), 3);
|
||||
assert.equal(g.Array.prototype.findLastIndex.call([1, 2, 3], (x) => x > 5), -1);
|
||||
const src = { a: [1, { b: 2 }], m: new Map([['k', new Set([1])]]), d: new Date(5) };
|
||||
src.self = src;
|
||||
const c = g.structuredClone(src);
|
||||
assert.notEqual(c, src);
|
||||
assert.deepEqual(c.a, [1, { b: 2 }]);
|
||||
assert.ok(c.m.get('k').has(1) && c.m !== src.m);
|
||||
assert.equal(c.d.getTime(), 5);
|
||||
assert.equal(c.self, c, 'cycles preserved');
|
||||
assert.throws(() => g.structuredClone({ f() {} }), TypeError);
|
||||
});
|
||||
test('a modern runtime keeps its own implementations', () => {
|
||||
assert.deepEqual(installCompat({ Object, Array, String, Int8Array, structuredClone, queueMicrotask }), []);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- device.js ---------------------------------------------------------------------------------------------------------
|
||||
|
||||
describe('ui/device.js feature detection', () => {
|
||||
test('touch / coarse / hover / fullscreen / reduced motion from media queries and APIs (no UA sniffing)', () => {
|
||||
const phone = detectFeatures(fakeWindow({ media: { '(any-pointer: coarse)': true, '(prefers-reduced-motion: reduce)': true }, touchPoints: 5 }).win);
|
||||
assert.equal(phone.touch, true);
|
||||
assert.equal(phone.coarse, true);
|
||||
assert.equal(phone.hover, false);
|
||||
assert.equal(phone.reducedMotion, true);
|
||||
assert.equal(phone.fullscreen, true);
|
||||
const desk = detectFeatures(fakeWindow({ media: { '(any-hover: hover)': true, '(any-pointer: fine)': true }, fs: false }).win);
|
||||
assert.deepEqual({ touch: desk.touch, coarse: desk.coarse, hover: desk.hover, fullscreen: desk.fullscreen }, { touch: false, coarse: false, hover: true, fullscreen: false });
|
||||
assert.deepEqual(featureClasses(phone), {
|
||||
'sp-touch': true, 'sp-coarse': true, 'sp-hover': false, 'sp-no-hover': true, 'sp-fs': true, 'sp-standalone': false, 'sp-reduced-motion': true,
|
||||
'sp-rotatable': true,
|
||||
});
|
||||
});
|
||||
test('review regression: the rotate hint is for devices that can turn — not a narrow desktop window, not split view', () => {
|
||||
const w = (media, screen, orientation) => { const f = fakeWindow({ media, touchPoints: media['(any-pointer: coarse)'] ? 5 : 0 }).win; f.screen = screen; if (orientation !== undefined) f.orientation = orientation; return f; };
|
||||
const coarse = { '(any-pointer: coarse)': true };
|
||||
const fine = { '(any-pointer: fine)': true, '(any-hover: hover)': true };
|
||||
assert.equal(screenLandscape(w(coarse, { orientation: { type: 'landscape-primary' } })), true);
|
||||
assert.equal(screenLandscape(w(coarse, {}, 0)), false, 'old iOS: window.orientation');
|
||||
assert.equal(screenLandscape(w(coarse, {}, -90)), true);
|
||||
assert.equal(screenLandscape(w(coarse, { width: 390, height: 844 })), null, 'no size guess (iOS reports portrait sizes)');
|
||||
const cls = (win) => featureClasses(detectFeatures(win))['sp-rotatable'];
|
||||
assert.equal(cls(w(coarse, { orientation: { type: 'portrait-primary' } })), true, 'a phone held upright');
|
||||
assert.equal(cls(w(coarse, {}, undefined)), true, 'unknown screen orientation on a touch device: the hint may show');
|
||||
assert.equal(cls(w(coarse, { orientation: { type: 'landscape-primary' } })), false, 'split-view iPad: the screen is landscape');
|
||||
assert.equal(cls(w(fine, { orientation: { type: 'portrait-primary' } })), false, 'a desktop window (even on a portrait monitor)');
|
||||
});
|
||||
test('fullscreen: standard API, and unsupported (iPhone Safari) → false', async () => {
|
||||
const f = fakeWindow();
|
||||
assert.equal(fullscreen.active(f.win), false);
|
||||
assert.equal(await fullscreen.enter(f.win), true);
|
||||
assert.equal(fullscreen.active(f.win), true);
|
||||
await fullscreen.toggle(f.win);
|
||||
assert.equal(fullscreen.active(f.win), false);
|
||||
const none = fakeWindow({ fs: false });
|
||||
assert.equal(fullscreen.supported(none.win), false);
|
||||
assert.equal(await fullscreen.enter(none.win), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ui/device.js installDeviceSupport', () => {
|
||||
test('classes, --sp-vh, zoom gestures cancelled, long press → contextmenu (once), click after it swallowed', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const f = fakeWindow({ media: { '(any-pointer: coarse)': true }, touchPoints: 5 });
|
||||
const dispose = installDeviceSupport(f.win);
|
||||
try {
|
||||
assert.ok(f.classes.has('sp-touch') && f.classes.has('sp-coarse'));
|
||||
assert.equal(f.props.get('--sp-vh'), '3.9px');
|
||||
// iOS pinch zoom and ctrl+wheel (trackpad pinch) are cancelled
|
||||
assert.equal(f.doc.dispatch(new FakeEvent('gesturestart')).defaultPrevented, true);
|
||||
assert.equal(f.doc.dispatch(new FakeEvent('wheel', { ctrlKey: true })).defaultPrevented, true);
|
||||
assert.equal(f.doc.dispatch(new FakeEvent('wheel', { ctrlKey: false })).defaultPrevented, false, 'plain scrolling untouched');
|
||||
// long press on a DOM control (iOS never sends contextmenu): a synthetic one after LONG_PRESS_MS
|
||||
const card = new FakeElement('button', { handles: true });
|
||||
f.doc.dispatch(new FakeEvent('pointerdown', { pointerType: 'touch', target: card, clientX: 10, clientY: 10 }));
|
||||
t.mock.timers.tick(LONG_PRESS_MS - 1);
|
||||
assert.equal(card.dispatched.length, 0);
|
||||
t.mock.timers.tick(2);
|
||||
assert.equal(card.dispatched.length, 1);
|
||||
assert.equal(card.dispatched[0].type, 'contextmenu');
|
||||
f.doc.dispatch(new FakeEvent('pointerup', { pointerType: 'touch' }));
|
||||
const click = f.doc.dispatch(new FakeEvent('click'));
|
||||
assert.equal(click.defaultPrevented && click.stopped, true, 'the release after a handled long press is not a tap');
|
||||
assert.equal(f.doc.dispatch(new FakeEvent('click')).stopped, false, 'only that one click');
|
||||
// review regression: a slow tap (held ≥ LONG_PRESS_MS) on a control nobody long-presses (准备就绪, 撤退, 出售 …)
|
||||
// is still a tap — iOS sends its click and it must not be swallowed
|
||||
const ready = new FakeElement('button');
|
||||
f.doc.dispatch(new FakeEvent('pointerdown', { pointerType: 'touch', target: ready, clientX: 5, clientY: 5 }));
|
||||
t.mock.timers.tick(LONG_PRESS_MS + 200);
|
||||
assert.equal(ready.dispatched.length, 1, 'the synthetic contextmenu is still offered');
|
||||
f.doc.dispatch(new FakeEvent('pointerup', { pointerType: 'touch' }));
|
||||
const slow = f.doc.dispatch(new FakeEvent('click'));
|
||||
assert.equal(slow.defaultPrevented || slow.stopped, false, 'an unhandled long press keeps its click');
|
||||
// Android sends its own contextmenu: no synthetic duplicate
|
||||
const c2 = new FakeElement('button');
|
||||
f.doc.dispatch(new FakeEvent('pointerdown', { pointerType: 'touch', target: c2, clientX: 0, clientY: 0 }));
|
||||
f.doc.dispatch(new FakeEvent('contextmenu', { isTrusted: true }));
|
||||
t.mock.timers.tick(LONG_PRESS_MS + 10);
|
||||
assert.equal(c2.dispatched.length, 0);
|
||||
// moving cancels; a mouse press never counts; the field canvas has its own long press
|
||||
const c3 = new FakeElement('button');
|
||||
f.doc.dispatch(new FakeEvent('pointerdown', { pointerType: 'touch', target: c3, clientX: 0, clientY: 0 }));
|
||||
f.doc.dispatch(new FakeEvent('pointermove', { clientX: 30, clientY: 0 }));
|
||||
t.mock.timers.tick(LONG_PRESS_MS + 10);
|
||||
assert.equal(c3.dispatched.length, 0, 'a drag is not a long press');
|
||||
f.doc.dispatch(new FakeEvent('pointerdown', { pointerType: 'mouse', target: c3, clientX: 0, clientY: 0 }));
|
||||
t.mock.timers.tick(LONG_PRESS_MS + 10);
|
||||
assert.equal(c3.dispatched.length, 0);
|
||||
const canvas = new FakeElement('canvas', { closest: (sel) => (sel.includes('canvas') ? {} : null) });
|
||||
f.doc.dispatch(new FakeEvent('pointerdown', { pointerType: 'touch', target: canvas, clientX: 0, clientY: 0 }));
|
||||
t.mock.timers.tick(LONG_PRESS_MS + 10);
|
||||
assert.equal(canvas.dispatched.length, 0);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
assert.equal(f.doc.count('gesturestart'), 0, 'dispose removes the listeners');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- HUD 🔍 buttons (research 09 §2.1 / §6.2 item 3) ------------------------------------------------------------------
|
||||
|
||||
describe('hud.js checkButtons', () => {
|
||||
test('prep: mint 🔍 (本局信息) + amber 🔍▶▶; pen: 🔍◀◀ + grey 🔍; outside prep: grey 🔍', () => {
|
||||
assert.deepEqual(checkButtons({ pen: false, penAvail: true, infoOpen: false }), {
|
||||
left: { sprite: 'btn_check_player_normal', back: false, label: '本局信息', tip: '本局信息(策略 / 禁用盟约 / 干员)' },
|
||||
right: { sprite: 'btn_check_enemy', grey: false, label: '敌方情报', tip: '查看即将迎击的敌方单位' },
|
||||
});
|
||||
const pen = checkButtons({ pen: true, penAvail: true, infoOpen: false });
|
||||
assert.equal(pen.left.sprite, 'btn_check_player_back');
|
||||
assert.equal(pen.left.back, true);
|
||||
assert.equal(pen.right.sprite, 'btn_check_enemy_unfold');
|
||||
assert.equal(pen.right.grey, true);
|
||||
assert.equal(checkButtons({ pen: false, penAvail: false, infoOpen: false }).right.grey, true);
|
||||
assert.equal(checkButtons({ pen: false, penAvail: true, infoOpen: true }).left.sprite, 'btn_check_player_unfold');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- enemy preview pen -------------------------------------------------------------------------------------------------
|
||||
|
||||
describe('gameLogic pen helpers (research 08 §4.2 / 09 §2.2)', () => {
|
||||
test('zones: 13 tiles each, rows 14–15 (lower gate) / 17–18 (upper), never row 16, anchors excluded', () => {
|
||||
const lo = penZoneTiles('lower');
|
||||
const up = penZoneTiles('upper');
|
||||
assert.equal(lo.length, 13);
|
||||
assert.equal(up.length, 13);
|
||||
assert.ok(lo.every(([r]) => r === 14 || r === 15));
|
||||
assert.ok(up.every(([r]) => r === 17 || r === 18));
|
||||
assert.ok(![...lo, ...up].some(([r, c]) => (r === 15 && c === 7) || (r === 18 && c === 7)), 'the pen gates are not stands');
|
||||
assert.deepEqual(lo[0], [14, 7], 'row-major, low row first, from the gate column');
|
||||
});
|
||||
test('one model per enemy in spawn order, by gate; > 50 thinned (elites always kept), ≤ 50 in all', () => {
|
||||
const few = penPlacement([
|
||||
{ enemyKey: 'b', count: 2, gate: 'upper', t: 8 }, { enemyKey: 'a', count: 3, gate: 'lower', t: 2 }, { enemyKey: 'e', count: 1, gate: 'lower', t: 9, elite: true },
|
||||
]);
|
||||
assert.equal(few.length, 6);
|
||||
assert.deepEqual(few.map((m) => m.enemyKey), ['a', 'a', 'a', 'b', 'b', 'e'], 'spawn-time order');
|
||||
assert.ok(few.filter((m) => m.enemyKey === 'b').every((m) => m.row >= 17), 'upper-gate enemies in rows 17–18');
|
||||
assert.ok(few.filter((m) => m.enemyKey !== 'b').every((m) => m.row <= 15), 'lower-gate enemies in rows 14–15');
|
||||
assert.ok(few.every((m) => m.col >= PEN.c0 && m.col <= PEN.c1 && m.row !== PEN.emptyRow));
|
||||
const many = penPlacement([{ enemyKey: 'x', count: 90, gate: 'lower', t: 0 }, { enemyKey: 'y', count: 30, gate: 'upper', t: 1 }, { enemyKey: 'z', count: 3, gate: 'upper', t: 5, elite: true }]);
|
||||
assert.ok(many.length <= 50, `capped (${many.length})`);
|
||||
assert.equal(many.filter((m) => m.enemyKey === 'z').length, 3, 'elites never thinned');
|
||||
assert.ok(many.filter((m) => m.enemyKey === 'x').length > many.filter((m) => m.enemyKey === 'y').length, 'proportional');
|
||||
const slots = new Map();
|
||||
for (const m of many) { const k = `${m.row},${m.col}`; assert.equal(m.slot, slots.get(k) || 0); slots.set(k, (slots.get(k) || 0) + 1); }
|
||||
assert.deepEqual(penPlacement(null), []);
|
||||
assert.deepEqual(penPlacement([{ nope: 1 }]), []);
|
||||
assert.deepEqual(penPlacement(few.map(() => null)), []);
|
||||
assert.deepEqual(penPlacement([{ enemyKey: 'a', count: 3, gate: 'lower', t: 2 }]), penPlacement([{ enemyKey: 'a', count: 3, gate: 'lower', t: 2 }]), 'deterministic');
|
||||
});
|
||||
test('review regression: the DOM fallback lays the pen out exactly like the render engine (render/pen.js)', () => {
|
||||
const list = [
|
||||
{ enemyKey: 'x', count: 40, gate: 'lower', t: 0 }, { enemyKey: 'y', count: 25, gate: 'upper', t: 3 },
|
||||
{ enemyKey: 'e', count: 2, gate: 'upper', t: 9, elite: true }, { enemyKey: 'f', count: 6, gate: 'lower', t: 12, fly: true },
|
||||
];
|
||||
const dom = penPlacement(list);
|
||||
const eng = layoutPen(list).figures;
|
||||
assert.deepEqual(dom.map((m) => [m.enemyKey, m.row, m.col]), eng.map((f) => [f.enemyKey, f.row, f.col]));
|
||||
const load = new Map();
|
||||
for (const m of dom) load.set(`${m.row},${m.col}`, (load.get(`${m.row},${m.col}`) || 0) + 1);
|
||||
assert.ok([...load.values()].every((n) => n <= 3), 'at most 3 per tile while the zone has room');
|
||||
// a small zone stays by its gate: the k-th model of a zone, not of the whole preview
|
||||
const small = penPlacement([{ enemyKey: 'a', count: 20, gate: 'lower', t: 0 }, { enemyKey: 'b', count: 2, gate: 'upper', t: 30 }]);
|
||||
assert.ok(small.filter((m) => m.enemyKey === 'b').every((m) => m.row === 17 && m.col <= 8), JSON.stringify(small.filter((m) => m.enemyKey === 'b')));
|
||||
});
|
||||
test('previewEnemyKey: the engine / fallback pen tap payloads, never a live battle unit', () => {
|
||||
assert.equal(previewEnemyKey({ enemyKey: 'enemy_1007_slime', preview: true }), 'enemy_1007_slime');
|
||||
assert.equal(previewEnemyKey({ preview: true, unit: { side: 'enemy', defId: 'enemy_x' } }), 'enemy_x');
|
||||
assert.equal(previewEnemyKey({ unit: { side: 'enemy', defId: 'enemy_x', preview: true, enemyKey: 'enemy_y' } }), 'enemy_y');
|
||||
assert.equal(previewEnemyKey({ unitId: 3, unit: { side: 'enemy', defId: 'enemy_x' } }), null, 'battle enemies are units');
|
||||
assert.equal(previewEnemyKey({ preview: true, unit: { side: 'ally', defId: 'char_x' } }), null);
|
||||
assert.equal(previewEnemyKey(null), null);
|
||||
assert.equal(previewEnemyKey({ uid: 5 }), null);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- misc --------------------------------------------------------------------------------------------------------------
|
||||
|
||||
describe('connection banner class (replaces CSS :has())', () => {
|
||||
test('bannerVisible mirrors the banner', () => {
|
||||
assert.equal(bannerVisible({ status: 'online' }, true, false), false);
|
||||
assert.equal(bannerVisible({ status: 'online' }, true, true), true, 'syncing after a resume');
|
||||
assert.equal(bannerVisible({ status: 'reconnecting', everOnline: true }, true, false), true);
|
||||
assert.equal(bannerVisible({ status: 'connecting', everOnline: false }, true, false), false, 'first connect is silent');
|
||||
assert.equal(bannerVisible({ status: 'closed' }, false, false), false, 'not before the title');
|
||||
});
|
||||
});
|
||||
|
||||
describe('title: lone surrogates without a lookbehind regex', () => {
|
||||
test('pairs kept, lone halves dropped', () => {
|
||||
assert.equal(stripLoneSurrogates('a\ud800b\udc00c😀d\ud83d'), 'abc😀d');
|
||||
assert.equal(sanitizeName(' 凯\udc00尔希 '), '凯尔希');
|
||||
});
|
||||
});
|
||||
|
||||
describe('audio unlock on iOS-like contexts', () => {
|
||||
test('a context that starts suspended keeps the gesture listeners until it runs; a silent buffer primes the output', async () => {
|
||||
const listeners = new Map();
|
||||
const made = { primed: 0, resumed: 0 };
|
||||
class Node { connect() {} disconnect() {} }
|
||||
class Param { setTargetAtTime() {} setValueAtTime() {} linearRampToValueAtTime() {} cancelScheduledValues() {} }
|
||||
class Ctx {
|
||||
constructor() { this.state = 'suspended'; this.currentTime = 0; this.destination = new Node(); this.sampleRate = 44100; }
|
||||
createGain() { const n = new Node(); n.gain = new Param(); return n; }
|
||||
createBuffer() { return {}; }
|
||||
createBufferSource() { const n = new Node(); n.start = () => { made.primed++; }; return n; }
|
||||
resume() { made.resumed++; if (made.resumed >= 2) this.state = 'running'; return Promise.resolve(); }
|
||||
suspend() { return Promise.resolve(); }
|
||||
}
|
||||
const win = {
|
||||
AudioContext: Ctx, document: { hidden: false, addEventListener() {} },
|
||||
addEventListener(t, fn) { listeners.set(t, fn); }, removeEventListener(t) { listeners.delete(t); },
|
||||
};
|
||||
const a = new AudioManager({ win, getManifest: () => null });
|
||||
a.install();
|
||||
listeners.get('pointerdown')(); // iOS: not an activation — the context stays suspended
|
||||
await Promise.resolve();
|
||||
assert.equal(a.unlocked, true);
|
||||
assert.ok(listeners.has('touchend'), 'still listening while suspended');
|
||||
listeners.get('touchend')(); // the finger lifts: resume() succeeds
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
assert.equal(a.ctx.state, 'running');
|
||||
assert.ok(!listeners.has('touchend') && !listeners.has('pointerdown'), 'listeners dropped once running');
|
||||
assert.ok(made.primed >= 1, 'a silent buffer was started inside a gesture');
|
||||
});
|
||||
|
||||
test('review regression: an interruption (iOS call / Siri) re-arms the gesture unlock; back on the page resumes', async () => {
|
||||
const listeners = new Map();
|
||||
const docListeners = new Map();
|
||||
let refuse = false;
|
||||
class Node { connect() {} disconnect() {} }
|
||||
class Param { setTargetAtTime() {} setValueAtTime() {} linearRampToValueAtTime() {} cancelScheduledValues() {} }
|
||||
class Ctx {
|
||||
constructor() { this.state = 'running'; this.currentTime = 0; this.destination = new Node(); this.sampleRate = 44100; this.ls = new Map(); }
|
||||
addEventListener(t, fn) { this.ls.set(t, fn); }
|
||||
set(state) { this.state = state; this.ls.get('statechange')?.(); }
|
||||
createGain() { const n = new Node(); n.gain = new Param(); return n; }
|
||||
createBuffer() { return {}; }
|
||||
createBufferSource() { const n = new Node(); n.start = () => {}; return n; }
|
||||
resume() { if (!refuse) this.state = 'running'; return Promise.resolve(); }
|
||||
suspend() { this.set('suspended'); return Promise.resolve(); }
|
||||
}
|
||||
const doc = { hidden: false, addEventListener(t, fn) { docListeners.set(t, fn); } };
|
||||
const win = {
|
||||
AudioContext: Ctx, document: doc,
|
||||
addEventListener(t, fn) { listeners.set(t, fn); }, removeEventListener(t) { listeners.delete(t); },
|
||||
};
|
||||
const a = new AudioManager({ win, getManifest: () => null });
|
||||
a.install();
|
||||
listeners.get('touchend')();
|
||||
await Promise.resolve();
|
||||
assert.equal(a.ctx.state, 'running');
|
||||
assert.ok(!listeners.has('touchend'), 'dropped once running');
|
||||
// a phone call: the system interrupts the running context while the page stays visible
|
||||
refuse = true;
|
||||
a.ctx.set('interrupted');
|
||||
assert.ok(listeners.has('touchend') && listeners.has('click'), 'the next gesture may resume it');
|
||||
refuse = false;
|
||||
listeners.get('touchend')();
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
assert.equal(a.ctx.state, 'running');
|
||||
assert.ok(!listeners.has('touchend'), 'dropped again once running');
|
||||
// tab hidden → suspended by us (no re-arm while hidden); visible again → resumed without a gesture where allowed
|
||||
doc.hidden = true;
|
||||
docListeners.get('visibilitychange')();
|
||||
await Promise.resolve();
|
||||
assert.equal(a.ctx.state, 'suspended');
|
||||
assert.ok(!listeners.has('touchend'), 'nothing armed while hidden');
|
||||
doc.hidden = false;
|
||||
refuse = true;
|
||||
docListeners.get('visibilitychange')();
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
assert.equal(a.ctx.state, 'suspended', 'the browser wants a gesture first');
|
||||
assert.ok(listeners.has('touchend'), 'so a gesture is armed');
|
||||
refuse = false;
|
||||
listeners.get('click')();
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
assert.equal(a.ctx.state, 'running');
|
||||
assert.ok(!listeners.has('click'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('room: copy the invite without navigator.clipboard (LAN over http, iOS)', () => {
|
||||
test('review regression: the textarea fallback sets an explicit selection range (iOS ignores select())', async () => {
|
||||
const { copyText } = await import('../../public/js/screens/room.js');
|
||||
const calls = [];
|
||||
const ta = {
|
||||
style: {}, value: '',
|
||||
setAttribute(k) { calls.push(['attr', k]); },
|
||||
select() { calls.push(['select']); },
|
||||
setSelectionRange(a, b) { calls.push(['range', a, b]); },
|
||||
remove() { calls.push(['remove']); },
|
||||
};
|
||||
const saved = { document: globalThis.document, secure: globalThis.isSecureContext, nav: Object.getOwnPropertyDescriptor(globalThis, 'navigator') };
|
||||
globalThis.document = { createElement: () => ta, body: { appendChild: () => calls.push(['append']) }, execCommand: (c) => { calls.push(['exec', c]); return true; } };
|
||||
Object.defineProperty(globalThis, 'navigator', { value: {}, configurable: true, writable: true });
|
||||
globalThis.isSecureContext = false;
|
||||
try {
|
||||
assert.equal(await copyText('http://192.168.1.5:8080/?room=ABCD'), true);
|
||||
assert.deepEqual(calls.find((c) => c[0] === 'range'), ['range', 0, 'http://192.168.1.5:8080/?room=ABCD'.length]);
|
||||
assert.ok(calls.findIndex((c) => c[0] === 'range') < calls.findIndex((c) => c[0] === 'exec'), 'selected before copying');
|
||||
assert.ok(calls.some((c) => c[0] === 'remove'));
|
||||
} finally {
|
||||
if (saved.document === undefined) delete globalThis.document; else globalThis.document = saved.document;
|
||||
if (saved.nav) Object.defineProperty(globalThis, 'navigator', saved.nav);
|
||||
if (saved.secure === undefined) delete globalThis.isSecureContext; else globalThis.isSecureContext = saved.secure;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
// Browser checks of the official emotes (research 09 §4) in headless Chrome (puppeteer-core + system Chrome):
|
||||
// the UI-kit demo (/dev/uikit.html §13) and the in-match mock harness (/dev/game-mock.html). Opt-in like the other
|
||||
// browser suites: SP_E2E=1 node --test test/ui/emotes.e2e.test.js → screenshots in test/e2e/out/emotes-*.png
|
||||
//
|
||||
// Asserted: picture-only wheel pages (3×2 per theme) and bubbles (no text anywhere), swipe / arrow keys / dots change
|
||||
// the theme, sending closes the panel and greys 交流 for the 1 s cooldown, the panel reopens on the last-used theme,
|
||||
// a bubble fades out after 3 s and a newer one replaces it, the neutral glyph fallback when the art is missing, the
|
||||
// bubble sits beside the sender's avatar in the team panel, and zero console errors.
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { EMOTE_THEMES } from '../../shared/constants.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const OUT = path.join(ROOT, 'test/e2e/out');
|
||||
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
|
||||
const HAS_ART = existsSync(path.join(ROOT, 'public/assets/local/emoticon/fooldoctor'));
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
describe('official emotes in the browser', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||
let srv;
|
||||
let browser;
|
||||
let base;
|
||||
|
||||
before(async () => {
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
base = `http://127.0.0.1:${srv.port}`;
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-sandbox', '--force-device-scale-factor=1'] });
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
});
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
await srv?.close();
|
||||
});
|
||||
|
||||
async function open(url, { w = 1600, h = 900, manifest = null, ignore = [] } = {}) {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: w, height: h });
|
||||
const problems = [];
|
||||
const bad = (s) => !ignore.some((re) => re.test(s));
|
||||
page.on('console', (m) => { if (m.type() === 'error' && bad(m.text())) problems.push(`console: ${m.text()}`); });
|
||||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
// a reload cancels the images still in flight (net::ERR_ABORTED): not a failure of the page
|
||||
page.on('requestfailed', (r) => { if (bad(r.url()) && r.failure()?.errorText !== 'net::ERR_ABORTED') problems.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`); });
|
||||
page.on('response', (r) => { if (r.status() >= 400 && bad(r.url())) problems.push(`http ${r.status()}: ${r.url()}`); });
|
||||
if (manifest) {
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (req) => (req.url().endsWith('/data/local-assets.json')
|
||||
? req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify(manifest) })
|
||||
: req.continue()));
|
||||
}
|
||||
await page.goto(`${base}${url}`, { waitUntil: 'networkidle0' });
|
||||
return { page, problems };
|
||||
}
|
||||
|
||||
const wheelState = (page, root = '') => page.evaluate((root) => {
|
||||
const q = (s) => document.querySelector(`${root} ${s}`.trim());
|
||||
const panel = q('.ewheel__panel');
|
||||
return {
|
||||
open: !!panel,
|
||||
theme: q('.ewheel__page')?.dataset.theme || null,
|
||||
items: document.querySelectorAll(`${root} .ewheel__item`.trim()).length,
|
||||
imgs: [...document.querySelectorAll(`${root} .ewheel__item img`.trim())].filter((i) => i.complete && i.naturalWidth > 0).length,
|
||||
glyphs: document.querySelectorAll(`${root} .ewheel__item .eart--glyph`.trim()).length,
|
||||
panelText: panel ? panel.textContent.trim() : '',
|
||||
btnDisabled: q('.ewheel__btn')?.disabled ?? null,
|
||||
dotOn: [...document.querySelectorAll(`${root} .ewheel__dot`.trim())].findIndex((d) => d.classList.contains('is-on')),
|
||||
};
|
||||
}, root);
|
||||
|
||||
test('UI kit: pager per theme, picture-only, swipe / keys / dots, cooldown, remembered theme, 3 s bubble', { skip: !HAS_ART && 'no extracted emote art' }, async () => {
|
||||
const { page, problems } = await open('/dev/uikit.html');
|
||||
await page.evaluate(() => localStorage.removeItem('sp.pref.emoteTheme'));
|
||||
await page.reload({ waitUntil: 'networkidle0' });
|
||||
await page.waitForSelector('#emo-stage .ewheel__panel');
|
||||
await page.$eval('#emo-stage', (el) => el.closest('section').scrollIntoView());
|
||||
await sleep(500);
|
||||
const section = await page.evaluateHandle(() => document.querySelector('#emo-stage').closest('section'));
|
||||
let s = await wheelState(page, '#emo-stage');
|
||||
assert.equal(s.theme, EMOTE_THEMES[0].themeId, 'first page by default');
|
||||
assert.equal(s.items, 6, '3×2 cells');
|
||||
assert.equal(s.imgs, 6, 'official pictures loaded');
|
||||
assert.equal(s.panelText, '', 'no text in the panel');
|
||||
assert.equal(s.dotOn, 0);
|
||||
assert.equal(await page.$$eval('.emo-cat img', (els) => els.filter((i) => i.complete && i.naturalWidth > 0).length), 36, 'all 36 pictures load');
|
||||
await section.screenshot({ path: path.join(OUT, 'emotes-uikit.png') });
|
||||
|
||||
// swipe left → next theme (mouse drag across the pager)
|
||||
const box = await (await page.$('#emo-stage .ewheel__viewport')).boundingBox();
|
||||
const y = box.y + box.height / 2;
|
||||
await page.mouse.move(box.x + box.width * 0.8, y);
|
||||
await page.mouse.down();
|
||||
for (let i = 1; i <= 8; i++) { await page.mouse.move(box.x + box.width * (0.8 - i * 0.08), y); await sleep(16); }
|
||||
await page.mouse.up();
|
||||
await sleep(250);
|
||||
s = await wheelState(page, '#emo-stage');
|
||||
assert.equal(s.theme, EMOTE_THEMES[1].themeId, 'swipe → next page');
|
||||
assert.equal(s.open, true, 'a drag is not a send');
|
||||
// a short drag is ignored
|
||||
await page.mouse.move(box.x + box.width * 0.5, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width * 0.5 - 20, y, { steps: 3 });
|
||||
await page.mouse.up();
|
||||
await sleep(150);
|
||||
assert.equal((await wheelState(page, '#emo-stage')).theme, EMOTE_THEMES[1].themeId);
|
||||
// swipe right at the first page does nothing beyond the edge
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await sleep(200);
|
||||
assert.equal((await wheelState(page, '#emo-stage')).theme, EMOTE_THEMES[0].themeId, 'arrow keys, clamped at the first page');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await sleep(200);
|
||||
assert.equal((await wheelState(page, '#emo-stage')).theme, EMOTE_THEMES[3].themeId);
|
||||
await page.click('#emo-stage .ewheel__dot:nth-child(5)');
|
||||
await sleep(250);
|
||||
s = await wheelState(page, '#emo-stage');
|
||||
assert.equal(s.theme, EMOTE_THEMES[4].themeId, 'dot → page');
|
||||
assert.equal(s.dotOn, 4);
|
||||
assert.equal(s.imgs, 6);
|
||||
await section.screenshot({ path: path.join(OUT, 'emotes-uikit-page5.png') });
|
||||
|
||||
// send: panel closes, bubble beside the avatar (picture only), 交流 greyed for 1 s
|
||||
const sent = EMOTE_THEMES[4].emotes[2].id;
|
||||
await page.click(`#emo-stage .ewheel__item[data-emote="${sent}"]`);
|
||||
await sleep(150);
|
||||
s = await wheelState(page, '#emo-stage');
|
||||
assert.equal(s.open, false);
|
||||
assert.equal(s.btnDisabled, true, 'cooldown');
|
||||
const bubble = await page.$eval('.emo-row .ebubble', (b) => ({ id: b.dataset.emote, text: b.textContent.trim(), img: !!b.querySelector('img') }));
|
||||
assert.deepEqual(bubble, { id: sent, text: '', img: true });
|
||||
await section.screenshot({ path: path.join(OUT, 'emotes-uikit-sent.png') });
|
||||
await sleep(1000);
|
||||
assert.equal((await wheelState(page, '#emo-stage')).btnDisabled, false, 'cooldown over after 1 s');
|
||||
await page.click('#emo-stage .ewheel__btn');
|
||||
await sleep(200);
|
||||
assert.equal((await wheelState(page, '#emo-stage')).theme, EMOTE_THEMES[4].themeId, 'reopens on the last used theme');
|
||||
await page.reload({ waitUntil: 'networkidle0' });
|
||||
await page.waitForSelector('#emo-stage .ewheel__panel');
|
||||
assert.equal((await wheelState(page, '#emo-stage')).theme, EMOTE_THEMES[4].themeId, 'remembered across reloads');
|
||||
|
||||
// bubble lifetime: gone (faded) after 3 s; a newer emote replaces it and pops again
|
||||
await page.click('#emo-stage .ewheel__item:nth-child(1)');
|
||||
await sleep(3250);
|
||||
const op = await page.$eval('.emo-row .ebubble', (b) => Number(getComputedStyle(b).opacity));
|
||||
assert.ok(op < 0.05, `faded after 3 s (opacity ${op})`);
|
||||
const texts = await page.$$eval('.ebubble', (els) => els.map((b) => b.textContent.trim()).filter(Boolean));
|
||||
assert.deepEqual(texts, [], 'no bubble ever shows text');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('wheel: one trackpad swipe (momentum included) turns one page, a mouse-wheel notch turns one page', { skip: !HAS_ART && 'no extracted emote art' }, async () => {
|
||||
const { page, problems } = await open('/dev/uikit.html');
|
||||
await page.evaluate(() => localStorage.removeItem('sp.pref.emoteTheme'));
|
||||
await page.reload({ waitUntil: 'networkidle0' });
|
||||
await page.waitForSelector('#emo-stage .ewheel__panel');
|
||||
await page.$eval('#emo-stage', (el) => el.scrollIntoView({ block: 'center' }));
|
||||
await sleep(300);
|
||||
const idx = async () => EMOTE_THEMES.map((t) => t.themeId).indexOf((await wheelState(page, '#emo-stage')).theme);
|
||||
assert.equal(await idx(), 0);
|
||||
// a macOS trackpad swipe: 60 wheel events 16 ms apart with a decaying deltaX (≈ 750 px) — v1 skipped to the last theme
|
||||
await page.evaluate(async () => {
|
||||
const vp = document.querySelector('#emo-stage .ewheel__viewport');
|
||||
for (let i = 0; i < 60; i++) {
|
||||
vp.dispatchEvent(new WheelEvent('wheel', { deltaX: Math.max(2, 40 * 0.95 ** i), deltaY: 0, bubbles: true, cancelable: true }));
|
||||
await new Promise((r) => setTimeout(r, 16));
|
||||
}
|
||||
});
|
||||
await sleep(350);
|
||||
assert.equal(await idx(), 1, 'one swipe = one theme');
|
||||
// a plain mouse wheel drives the horizontal pager (Unity ScrollRect): down = next, up = previous
|
||||
const box = await (await page.$('#emo-stage .ewheel__viewport')).boundingBox();
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.wheel({ deltaY: 100 });
|
||||
await sleep(350);
|
||||
assert.equal(await idx(), 2, 'wheel down → next theme');
|
||||
await page.mouse.wheel({ deltaY: -100 });
|
||||
await sleep(350);
|
||||
assert.equal(await idx(), 1, 'wheel up → previous theme');
|
||||
assert.equal((await wheelState(page, '#emo-stage')).open, true);
|
||||
// hover over an official cell (emoji_cell_bkg: rounded, transparent corners) brightens it, never fills its square box
|
||||
const cell = await (await page.$('#emo-stage .ewheel__item:nth-child(2)')).boundingBox();
|
||||
await page.mouse.move(cell.x + cell.width / 2, cell.y + cell.height / 2);
|
||||
await sleep(200);
|
||||
const hover = await page.$eval('#emo-stage .ewheel__item:nth-child(2)', (el) => ({
|
||||
sprite: el.closest('.ewheel__panel').classList.contains('has-cell'), bg: getComputedStyle(el).backgroundColor, filter: getComputedStyle(el).filter,
|
||||
}));
|
||||
assert.deepEqual(hover, { sprite: true, bg: 'rgba(0, 0, 0, 0)', filter: 'brightness(1.35)' });
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('the cooldown survives a remount of the wheel; a bubble mounted late resumes its timeline', { skip: !HAS_ART && 'no extracted emote art' }, async () => {
|
||||
const { page, problems } = await open('/dev/uikit.html');
|
||||
await page.waitForSelector('#emo-stage .ewheel__panel');
|
||||
await page.evaluate(async () => {
|
||||
const { render } = await import('preact');
|
||||
const { useState } = await import('preact/hooks');
|
||||
const { html } = await import('/js/ui/components.js');
|
||||
const { EmoteWheel, EmoteBubble } = await import('/js/ui/emotes.js');
|
||||
const host = document.createElement('div');
|
||||
host.id = 'emo-probe';
|
||||
host.style.cssText = 'position:fixed;left:40px;bottom:40px;z-index:99';
|
||||
const bubbles = document.createElement('div');
|
||||
bubbles.id = 'emo-probe-bubbles';
|
||||
bubbles.style.cssText = 'position:fixed;right:40px;top:40px;z-index:99;display:flex;gap:10px';
|
||||
document.body.append(host, bubbles);
|
||||
function Probe() {
|
||||
const [open, setOpen] = useState(true);
|
||||
return html`<${EmoteWheel} open=${open} onToggle=${setOpen} onSend=${() => {}} />`;
|
||||
}
|
||||
window.__probe = {
|
||||
mount: (n) => render(html`<${Probe} key=${n} />`, host),
|
||||
unmount: () => render(null, host),
|
||||
bubbles: (age) => render(html`<div class="late"><${EmoteBubble} id="autochess_battle_happy" at=${Date.now() - age} /></div>
|
||||
<div class="fresh"><${EmoteBubble} id="autochess_battle_happy" at=${Date.now()} /></div>`, bubbles),
|
||||
};
|
||||
window.__probe.mount(1);
|
||||
});
|
||||
await page.waitForSelector('#emo-probe .ewheel__item');
|
||||
await page.click('#emo-probe .ewheel__item:nth-child(2)');
|
||||
await sleep(100);
|
||||
const btn = () => page.$eval('#emo-probe .ewheel__btn', (b) => b.disabled);
|
||||
assert.equal(await btn(), true, 'cooling after the send');
|
||||
await page.evaluate(() => { window.__probe.unmount(); window.__probe.mount(2); });
|
||||
await sleep(50);
|
||||
assert.equal(await btn(), true, 'a remounted wheel is still cooling (v1 re-enabled it at once)');
|
||||
await sleep(1000);
|
||||
assert.equal(await btn(), false, 'cooldown over 1 s after the send');
|
||||
|
||||
await page.evaluate(() => window.__probe.bubbles(2000));
|
||||
await sleep(60);
|
||||
const delay = await page.$eval('#emo-probe-bubbles .late .ebubble', (b) => getComputedStyle(b).animationDelay);
|
||||
assert.match(delay, /^-2(\.0\d*)?s,/, `late bubble resumes 2 s into its timeline (${delay})`);
|
||||
await sleep(1250);
|
||||
const op = await page.$$eval('#emo-probe-bubbles .ebubble', (els) => els.map((b) => Number(getComputedStyle(b).opacity)));
|
||||
assert.ok(op[0] < 0.05, `the late bubble faded at its own 3 s mark (${op[0]})`);
|
||||
assert.ok(op[1] > 0.95, `the fresh bubble is still up (${op[1]})`);
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('touch: a horizontal swipe on the pager turns the page, a tap sends', { skip: !HAS_ART && 'no extracted emote art' }, async () => {
|
||||
const { page, problems } = await open('/dev/uikit.html');
|
||||
await page.setViewport({ width: 1280, height: 720, hasTouch: true, isMobile: false });
|
||||
await page.evaluate(() => localStorage.removeItem('sp.pref.emoteTheme'));
|
||||
await page.reload({ waitUntil: 'networkidle0' });
|
||||
await page.waitForSelector('#emo-stage .ewheel__panel');
|
||||
await page.$eval('#emo-stage', (el) => el.scrollIntoView({ block: 'center' }));
|
||||
await sleep(300);
|
||||
const box = await (await page.$('#emo-stage .ewheel__viewport')).boundingBox();
|
||||
const y = box.y + box.height / 2;
|
||||
await page.touchscreen.touchStart(box.x + box.width * 0.85, y);
|
||||
for (let i = 1; i <= 8; i++) { await page.touchscreen.touchMove(box.x + box.width * (0.85 - i * 0.08), y); await sleep(16); }
|
||||
await page.touchscreen.touchEnd();
|
||||
await sleep(250);
|
||||
let s = await wheelState(page, '#emo-stage');
|
||||
assert.equal(s.theme, EMOTE_THEMES[1].themeId, 'swipe left → next theme');
|
||||
assert.equal(s.open, true);
|
||||
const cell = await (await page.$('#emo-stage .ewheel__item:nth-child(4)')).boundingBox();
|
||||
await page.touchscreen.tap(cell.x + cell.width / 2, cell.y + cell.height / 2);
|
||||
await sleep(200);
|
||||
s = await wheelState(page, '#emo-stage');
|
||||
assert.equal(s.open, false, 'tap sends and closes');
|
||||
assert.equal(await page.$eval('.emo-row .ebubble', (b) => b.dataset.emote), EMOTE_THEMES[1].emotes[3].id);
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('missing art: neutral glyphs, never text', async () => {
|
||||
const { page, problems } = await open('/dev/uikit.html', {
|
||||
manifest: { version: 1, groups: { 'emoticon/basic': { pic_happy_battle: { path: '/assets/local/emoticon/basic/nope_404.png', w: 120, h: 120 } } } },
|
||||
ignore: [/nope_404\.png/, /Failed to load resource/],
|
||||
});
|
||||
await page.evaluate(() => localStorage.removeItem('sp.pref.emoteTheme'));
|
||||
await page.reload({ waitUntil: 'networkidle0' });
|
||||
await page.waitForSelector('#emo-stage .ewheel__panel');
|
||||
await sleep(400);
|
||||
const s = await wheelState(page, '#emo-stage');
|
||||
assert.equal(s.items, 6);
|
||||
assert.equal(s.imgs, 0);
|
||||
assert.equal(s.glyphs, 6, 'unlisted and broken pictures both fall back to the glyph');
|
||||
assert.equal(s.panelText, '');
|
||||
const bubbles = await page.$$eval('.ebubble', (els) => els.map((b) => ({ text: b.textContent.trim(), glyph: !!b.querySelector('.eart--glyph') })));
|
||||
assert.ok(bubbles.length >= 7);
|
||||
for (const b of bubbles) assert.deepEqual(b, { text: '', glyph: true });
|
||||
await (await page.evaluateHandle(() => document.querySelector('#emo-stage').closest('section'))).screenshot({ path: path.join(OUT, 'emotes-uikit-fallback.png') });
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('in-match mock: bubbles beside the senders\' avatars, wheel above 交流', { skip: !HAS_ART && 'no extracted emote art' }, async () => {
|
||||
const { page, problems } = await open('/dev/game-mock.html?shot=1&render=fallback&phase=PREP&variant=emote', { w: 1920, h: 1080 });
|
||||
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)'), { timeout: 15000 });
|
||||
await page.waitForSelector('.team__bubble');
|
||||
await page.waitForSelector('.ewheel__panel');
|
||||
await sleep(700);
|
||||
const s = await wheelState(page);
|
||||
assert.equal(s.items, 6);
|
||||
assert.equal(s.imgs, 6);
|
||||
assert.equal(s.panelText, '');
|
||||
const geo = await page.$$eval('.team__row', (rows) => rows.filter((r) => r.querySelector('.team__bubble')).map((r) => {
|
||||
const row = r.getBoundingClientRect();
|
||||
const a = r.querySelector('.pavatar, .team__btn').getBoundingClientRect();
|
||||
const b = r.querySelector('.team__bubble').getBoundingClientRect();
|
||||
const bubble = r.querySelector('.team__bubble');
|
||||
return {
|
||||
beside: b.left >= a.right && b.left - row.right >= 0 && b.left - row.right <= 24, // just right of the sender's row
|
||||
centred: Math.abs((b.top + b.bottom) / 2 - (a.top + a.bottom) / 2) <= 14, // at the avatar's height
|
||||
absolute: getComputedStyle(bubble).position === 'absolute', // does not push the row
|
||||
text: bubble.textContent.trim(),
|
||||
img: [...r.querySelectorAll('.team__bubble img')].some((i) => i.complete && i.naturalWidth > 0),
|
||||
};
|
||||
}));
|
||||
assert.ok(geo.length >= 1, 'a teammate bubble is shown');
|
||||
for (const g of geo) assert.deepEqual(g, { beside: true, centred: true, absolute: true, text: '', img: true });
|
||||
await page.screenshot({ path: path.join(OUT, 'emotes-mock-1920.png') });
|
||||
// send from the wheel → own bubble
|
||||
await page.click('.ewheel__item:nth-child(3)');
|
||||
await sleep(300);
|
||||
assert.equal((await wheelState(page)).open, false);
|
||||
await page.screenshot({ path: path.join(OUT, 'emotes-mock-sent-1920.png') });
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,363 @@
|
||||
// Official emotes (research 09 §4 / §6.6): the 36 in-match emotes of 盟约 (6 themes × 6), their generated reference
|
||||
// data/emotes.json (tools/build-emotes.mjs), the local-client extraction (tools/local-extract/extract.py), the protocol
|
||||
// whitelist and the pure helpers of public/js/ui/emotes.js. Browser behaviour (picture-only bubbles, pager, cooldown)
|
||||
// is covered by test/ui/emotes.e2e.test.js (SP_E2E=1).
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
EMOTES, EMOTE_THEMES, EMOTE_CATALOG, EMOTE_THEME, EMOTE_LABEL, EMOTE_COOLDOWN_MS, EMOTE_BUBBLE_MS,
|
||||
emoteInfo, emoteArtPath, emoteArtGroup,
|
||||
} from '../../shared/constants.js';
|
||||
import { validateC2S } from '../../shared/protocol.js';
|
||||
import { buildEmotes, formatEmotes, THEME_DIRS } from '../../tools/build-emotes.mjs';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const readJson = (rel) => JSON.parse(readFileSync(path.join(ROOT, rel), 'utf8'));
|
||||
const tryJson = (rel) => { try { return readJson(rel); } catch { return null; } };
|
||||
const EMOTES_JSON = readJson('data/emotes.json');
|
||||
|
||||
const ENABLED = ['emoticon_autochess_basic', 'emoticon_originium_slug', 'emoticon_autochess_basic_2',
|
||||
'emoticon_foolsday_doctor', 'emoticon_foolsday_amiya', 'emoticon_foolsday_wisdel'];
|
||||
|
||||
describe('emote catalog (shared/constants.js)', () => {
|
||||
test('6 themes × 6 battle emotes = 36 unique official ids, in wheel order', () => {
|
||||
assert.deepEqual(EMOTE_THEMES.map((t) => t.themeId), ENABLED, 'pages follow autoChessData.enabledEmoticonThemeIdList');
|
||||
for (const t of EMOTE_THEMES) {
|
||||
assert.equal(t.emotes.length, 6, t.themeId);
|
||||
const sorts = t.emotes.map((e) => e.sortId);
|
||||
assert.deepEqual(sorts, [...sorts].sort((a, b) => a - b), `${t.themeId} by sortId`);
|
||||
}
|
||||
assert.equal(EMOTES.length, 36);
|
||||
assert.equal(new Set(EMOTES).size, 36);
|
||||
assert.deepEqual([...EMOTES], EMOTE_CATALOG.map((e) => e.id));
|
||||
for (const id of EMOTES) assert.match(id, /^(slug_)?autochess_battle_[a-z0-9_]+$/, id);
|
||||
assert.ok(Object.isFrozen(EMOTES) && Object.isFrozen(EMOTE_THEMES) && Object.isFrozen(EMOTE_THEMES[0].emotes));
|
||||
});
|
||||
|
||||
test('picId comes from the table, not from the id (fooldoctor 03–06 → pics 04/05/06/08)', () => {
|
||||
const doc = EMOTE_THEMES.find((t) => t.themeId === 'emoticon_foolsday_doctor');
|
||||
assert.deepEqual(doc.emotes.map((e) => e.picId),
|
||||
['pic_fooldoctor_01_battle', 'pic_fooldoctor_02_battle', 'pic_fooldoctor_04_battle', 'pic_fooldoctor_05_battle', 'pic_fooldoctor_06_battle', 'pic_fooldoctor_08_battle']);
|
||||
assert.equal(emoteArtPath('autochess_battle_fooldoctor_06'), '/assets/local/emoticon/fooldoctor/pic_fooldoctor_08_battle.png');
|
||||
assert.equal(emoteArtPath('autochess_battle_nice_cooperate'), '/assets/local/emoticon/basic/pic_cooperate_battle.png');
|
||||
assert.equal(emoteArtPath('slug_autochess_battle_thanks'), '/assets/local/emoticon/slug/pic_thanks_battle.png');
|
||||
assert.equal(emoteArtGroup('autochess_battle_dying'), 'emoticon/basic_2');
|
||||
for (const e of EMOTE_CATALOG) assert.match(e.picId, /^pic_.+_battle$/, e.id);
|
||||
});
|
||||
|
||||
test('lookups are safe for any input', () => {
|
||||
for (const bad of ['happy', 'autochess_room_hello', '__proto__', 'constructor', 'toString', '', null, undefined, 42, {}]) {
|
||||
assert.equal(emoteInfo(bad), null, String(bad));
|
||||
assert.equal(emoteArtPath(bad), null, String(bad));
|
||||
assert.equal(emoteArtGroup(bad), null, String(bad));
|
||||
}
|
||||
assert.equal(emoteInfo('autochess_battle_call').themeId, 'emoticon_autochess_basic_2');
|
||||
assert.equal(EMOTE_THEME.slug_autochess_battle_bye, 'emoticon_originium_slug');
|
||||
assert.equal(typeof EMOTE_LABEL.autochess_battle_happy, 'string');
|
||||
for (const bad of ['__proto__', 'constructor', 'toString', 'hasOwnProperty']) {
|
||||
assert.equal(EMOTE_THEME[bad], undefined, `EMOTE_THEME[${bad}]`);
|
||||
assert.equal(EMOTE_LABEL[bad], undefined, `EMOTE_LABEL[${bad}]`);
|
||||
}
|
||||
assert.equal(Object.keys(EMOTE_THEME).length, 36);
|
||||
assert.ok(Object.isFrozen(EMOTE_THEME) && Object.isFrozen(EMOTE_LABEL));
|
||||
});
|
||||
|
||||
test('protocol accepts exactly the 36 official battle ids (g.emote { id })', () => {
|
||||
for (const id of EMOTES) assert.equal(validateC2S({ t: 'g.emote', id }), null, id);
|
||||
for (const id of ['happy', 'thanks', 'autochess_room_hello', 'multiv3_battle_thanks', 'duel_battle_happy', '__proto__', '', 7, null]) {
|
||||
assert.notEqual(validateC2S({ t: 'g.emote', id }), null, String(id));
|
||||
}
|
||||
});
|
||||
|
||||
test('cooldown and bubble time are the official chatCD / chatTime', () => {
|
||||
assert.equal(EMOTE_COOLDOWN_MS, EMOTES_JSON.chatCD * 1000);
|
||||
assert.equal(EMOTE_BUBBLE_MS, EMOTES_JSON.chatTime * 1000);
|
||||
assert.equal(EMOTE_COOLDOWN_MS, 1000);
|
||||
assert.equal(EMOTE_BUBBLE_MS, 3000);
|
||||
// the client reads the bubble time from config.timers (game.js) and the server rate-limits with the constant
|
||||
const timers = tryJson('data/config.json')?.timers;
|
||||
if (timers) assert.deepEqual([timers.chatCd * 1000, timers.chatBubble * 1000], [EMOTE_COOLDOWN_MS, EMOTE_BUBBLE_MS]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data/emotes.json (tools/build-emotes.mjs)', () => {
|
||||
test('is identical to the shared/constants.js table', () => {
|
||||
assert.deepEqual(EMOTES_JSON.themes.map(({ themeId, dir, sortId, isBasic, name, emotes }) => ({ themeId, dir, sortId, isBasic, name, emotes })),
|
||||
EMOTE_THEMES.map((t) => ({ themeId: t.themeId, dir: t.dir, sortId: t.sortId, isBasic: t.isBasic, name: t.name, emotes: t.emotes.map((e) => e.id) })));
|
||||
assert.deepEqual(EMOTES_JSON.emotes, EMOTE_CATALOG.map((e) => ({
|
||||
id: e.id, themeId: e.themeId, sortId: e.sortId, picId: e.picId, art: emoteArtPath(e.id), label: e.label,
|
||||
})));
|
||||
});
|
||||
|
||||
test('regenerating from the cached official tables reproduces it byte for byte', {
|
||||
skip: !existsSync(path.join(ROOT, '.cache/gamedata/excel/display_meta_table.json')) && 'no cached display_meta_table.json',
|
||||
}, () => {
|
||||
const { doc, warnings } = buildEmotes({
|
||||
display: readJson('.cache/gamedata/excel/display_meta_table.json'),
|
||||
activity: readJson('.cache/gamedata/excel/activity_table.json'),
|
||||
items: tryJson('.cache/gamedata/excel/item_table.json'),
|
||||
});
|
||||
assert.deepEqual(warnings, []);
|
||||
assert.equal(formatEmotes(doc), readFileSync(path.join(ROOT, 'data/emotes.json'), 'utf8'));
|
||||
});
|
||||
|
||||
test('builder: scene filter, enabled-list page order, sortId order, picId from the table, fallbacks', () => {
|
||||
const e = (id, type, sortId, picId) => ({ id, type, sortId, picId, desc: null });
|
||||
const display = { emoticonData: {
|
||||
emojiDataDict: {
|
||||
a_room: e('a_room', 'AUTOCHESS_ROOM', 1, 'pic_a'),
|
||||
a_2: e('a_2', 'AUTOCHESS_BATTLE', 20, 'pic_a2_battle'),
|
||||
a_1: e('a_1', 'AUTOCHESS_BATTLE', 10, 'pic_a9_battle'),
|
||||
b_1: e('b_1', 'AUTOCHESS_BATTLE', 5, 'pic_b_battle'),
|
||||
b_multi: e('b_multi', 'ACTMULTIV3_BATTLE', 1, 'pic_m_battle'),
|
||||
autochess_battle_happy: e('autochess_battle_happy', 'AUTOCHESS_BATTLE', 1, 'pic_happy_battle'),
|
||||
},
|
||||
emoticonThemeDataDict: { emoticon_x: ['a_room', 'a_2', 'a_1'], emoticon_originium_slug: ['b_multi', 'b_1'], emoticon_empty: ['a_room'] },
|
||||
emoticonThemeTypeDict: { emoticon_x: { sortId: 7, isBasic: true }, emoticon_originium_slug: { sortId: 1001, isBasic: false } },
|
||||
} };
|
||||
const activity = { autoChessData: {
|
||||
enabledEmoticonThemeIdList: ['emoticon_originium_slug', 'emoticon_missing', 'emoticon_x', 'emoticon_empty'],
|
||||
constData: { chatCD: 1, chatTime: 3 },
|
||||
} };
|
||||
const items = { items: { emoticon_originium_slug: { name: '表情套组:虫动' } } };
|
||||
const { doc, warnings } = buildEmotes({ display, activity, items });
|
||||
assert.deepEqual(doc.themes.map((t) => [t.themeId, t.dir, t.emotes]), [
|
||||
['emoticon_originium_slug', 'slug', ['b_1']],
|
||||
['emoticon_x', 'x', ['a_1', 'a_2']],
|
||||
]);
|
||||
assert.equal(doc.emotes.find((x) => x.id === 'a_1').art, '/assets/local/emoticon/x/pic_a9_battle.png');
|
||||
assert.equal(doc.emotes.find((x) => x.id === 'a_1').label, 'emoticon_x 1', 'no official text: generic aria label');
|
||||
assert.equal(doc.emotes.find((x) => x.id === 'b_1').label, '虫动 1');
|
||||
assert.equal(doc.themes[0].name, '表情套组:虫动');
|
||||
assert.deepEqual([doc.chatCD, doc.chatTime], [1, 3]);
|
||||
assert.equal(warnings.length, 2, warnings.join('; '));
|
||||
assert.throws(() => buildEmotes({ display: {}, activity }), /emoticonData/);
|
||||
assert.throws(() => buildEmotes({ display, activity: {} }), /enabledEmoticonThemeIdList/);
|
||||
const text = formatEmotes(doc);
|
||||
assert.deepEqual(JSON.parse(text), doc);
|
||||
assert.equal(formatEmotes(buildEmotes({ display, activity, items }).doc), text, 'deterministic');
|
||||
});
|
||||
|
||||
test('builder: an emoji listed by two themes / a theme listed twice is kept once; art dirs must be unique', () => {
|
||||
const e = (id, sortId, picId = `pic_${id}_battle`) => ({ id, type: 'AUTOCHESS_BATTLE', sortId, picId, desc: null });
|
||||
const display = { emoticonData: {
|
||||
emojiDataDict: { a: e('a', 1), b: e('b', 2), c: e('c', 3), nopic: { id: 'nopic', type: 'AUTOCHESS_BATTLE', sortId: 4, picId: null } },
|
||||
emoticonThemeDataDict: { emoticon_one: ['a', 'b', 'nopic', 'toString'], emoticon_two: ['b', 'c', '__proto__'] },
|
||||
emoticonThemeTypeDict: {},
|
||||
} };
|
||||
const activity = { autoChessData: { enabledEmoticonThemeIdList: ['emoticon_one', 'emoticon_two', 'emoticon_one', 'constructor'] } };
|
||||
const { doc, warnings } = buildEmotes({ display, activity });
|
||||
assert.deepEqual(doc.themes.map((t) => [t.themeId, t.emotes]), [['emoticon_one', ['a', 'b']], ['emoticon_two', ['c']]]);
|
||||
assert.deepEqual(doc.emotes.map((x) => x.id), ['a', 'b', 'c'], 'every protocol id once');
|
||||
assert.equal(new Set(doc.emotes.map((x) => x.art)).size, 3);
|
||||
assert.ok(warnings.some((w) => /^b: already on an earlier page/.test(w)), warnings.join('; '));
|
||||
assert.ok(warnings.some((w) => /emoticon_one: listed twice/.test(w)));
|
||||
assert.ok(warnings.some((w) => /constructor: not in emoticonThemeDataDict/.test(w)), 'prototype keys are not themes');
|
||||
assert.throws(() => buildEmotes({ display: { emoticonData: { ...display.emoticonData, emoticonThemeDataDict: { emoticon_x: ['a'], emticon_x: ['c'] } } },
|
||||
activity: { autoChessData: { enabledEmoticonThemeIdList: ['emoticon_x', 'emticon_x'] } } }), /already used/);
|
||||
});
|
||||
|
||||
test('theme dirs agree between the builder, the constants and data/emotes.json', () => {
|
||||
for (const t of EMOTE_THEMES) assert.equal(THEME_DIRS[t.themeId], t.dir, t.themeId);
|
||||
assert.equal(Object.keys(THEME_DIRS).length, EMOTE_THEMES.length);
|
||||
});
|
||||
});
|
||||
|
||||
const PY = ['python3', 'python'].find((bin) => spawnSync(bin, ['--version']).status === 0);
|
||||
const PY_ENV = { ...process.env, PYTHONDONTWRITEBYTECODE: '1' };
|
||||
|
||||
describe('local-client extraction (tools/local-extract/extract.py)', () => {
|
||||
test('extracts every emote theme (battle sprites only) into emoticon/<dir>', { skip: !PY && 'no python3' }, () => {
|
||||
const r = spawnSync(PY, [path.join(ROOT, 'tools/local-extract/extract.py'), '--print-jobs'], { encoding: 'utf8', env: PY_ENV });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
const { emoteThemes, jobs } = JSON.parse(r.stdout);
|
||||
assert.deepEqual(emoteThemes, EMOTE_THEMES.map((t) => ({ themeId: t.themeId, dir: t.dir })));
|
||||
for (const t of EMOTE_THEMES) {
|
||||
const job = jobs.find((j) => j.sub === `emoticon/${t.dir}`);
|
||||
assert.ok(job, t.themeId);
|
||||
assert.equal(job.bundle, `ui/emoticon/theme/[uc]${t.themeId}.ab`);
|
||||
assert.deepEqual(job.kinds, ['Sprite'], 'the Sprite (true aspect), not the padded Texture2D');
|
||||
const keep = new RegExp(job.keep);
|
||||
for (const e of t.emotes) assert.ok(keep.test(e.picId), e.picId);
|
||||
for (const other of ['pic_hello', 'pic_thanks', 'pic_bg', 'pic_nice_work', 'pic_very_soon']) assert.ok(!keep.test(other), other);
|
||||
}
|
||||
// the other jobs are untouched
|
||||
for (const sub of ['map/autochess', 'ui/battle', 'ui/outer', 'guide']) assert.ok(jobs.some((j) => j.sub === sub && j.keep === null), sub);
|
||||
});
|
||||
|
||||
test('--only re-extracts a subset and merges it into the existing manifest', { skip: !PY && 'no python3' }, () => {
|
||||
const code = `import sys, json; sys.path.insert(0, ${JSON.stringify(path.join(ROOT, 'tools/local-extract'))}); import extract as e
|
||||
print(json.dumps({
|
||||
'emo': [j[1] for j in e.select_jobs(['emoticon'])], 'slug': [j[1] for j in e.select_jobs(['emoticon/slug/'])],
|
||||
'all': len(e.select_jobs([])) == len(e.JOBS), 'none': e.select_jobs(['emo']),
|
||||
'merged': e.merge_manifest({'ui/battle': {'b': 1, 'a': 2}, 'emoticon/slug': {'old': 1}, 'emoticon/basic': {'keep': 1}},
|
||||
{'emoticon/slug': {'pic_z_battle': 1, 'pic_a_battle': 2}}, {'emoticon/slug'}),
|
||||
}))`;
|
||||
const r = spawnSync(PY, ['-c', code], { encoding: 'utf8', env: PY_ENV });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.deepEqual(out.emo, EMOTE_THEMES.map((t) => `emoticon/${t.dir}`));
|
||||
assert.deepEqual(out.slug, ['emoticon/slug']);
|
||||
assert.equal(out.all, true);
|
||||
assert.deepEqual(out.none, [], 'prefixes match whole path segments');
|
||||
assert.deepEqual(out.merged, { 'emoticon/basic': { keep: 1 }, 'emoticon/slug': { pic_a_battle: 2, pic_z_battle: 1 }, 'ui/battle': { a: 2, b: 1 } });
|
||||
assert.deepEqual(Object.keys(out.merged), ['emoticon/basic', 'emoticon/slug', 'ui/battle'], 'sorted, deterministic');
|
||||
});
|
||||
|
||||
const manifest = tryJson('data/local-assets.json');
|
||||
test('the extracted manifest lists all 36 pictures under their picId', { skip: !manifest && 'no data/local-assets.json' }, () => {
|
||||
for (const e of EMOTE_CATALOG) {
|
||||
const entry = manifest.groups[emoteArtGroup(e.id)]?.[e.picId];
|
||||
assert.ok(entry, `${e.id} → ${e.picId}`);
|
||||
assert.equal(entry.path, emoteArtPath(e.id));
|
||||
assert.ok(entry.w >= 64 && entry.h >= 64 && entry.w <= 160 && entry.h <= 160, `${e.picId} ${entry.w}×${entry.h}`);
|
||||
assert.equal(entry.kind, 'Sprite');
|
||||
}
|
||||
for (const t of EMOTE_THEMES) {
|
||||
const names = Object.keys(manifest.groups[`emoticon/${t.dir}`]);
|
||||
assert.deepEqual(names.sort(), t.emotes.map((e) => e.picId).sort(), `${t.dir}: only the battle sprites`);
|
||||
}
|
||||
for (const n of ['emoji_bubble_bkg', 'emoji_bkg', 'emoji_cell_bkg', 'emoji_btn', 'emoji_btn_disable']) assert.ok(manifest.groups['ui/battle']?.[n], n);
|
||||
});
|
||||
|
||||
test('the listed pictures exist on disk', { skip: (!manifest || !existsSync(path.join(ROOT, 'public/assets/local/emoticon'))) && 'no extracted art' }, () => {
|
||||
for (const e of EMOTE_CATALOG) {
|
||||
const file = path.join(ROOT, 'public', emoteArtPath(e.id));
|
||||
assert.ok(existsSync(file), file);
|
||||
const png = readFileSync(file);
|
||||
assert.equal(png.subarray(1, 4).toString(), 'PNG', file);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('public/js/ui/emotes.js helpers', () => {
|
||||
const store = new Map();
|
||||
globalThis.localStorage = {
|
||||
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
||||
setItem: (k, v) => { store.set(k, String(v)); },
|
||||
removeItem: (k) => { store.delete(k); },
|
||||
};
|
||||
const manifest = { version: 1, groups: {
|
||||
'emoticon/fooldoctor': { pic_fooldoctor_08_battle: { path: '/assets/local/emoticon/fooldoctor/pic_fooldoctor_08_battle.png', w: 115, h: 115 } },
|
||||
'ui/battle': { emoji_bubble_bkg: { path: '/assets/local/ui/battle/emoji_bubble_bkg.png', w: 108, h: 97 } },
|
||||
} };
|
||||
const realFetch = globalThis.fetch;
|
||||
|
||||
test('art URLs come from the manifest only (keyed by picId); unknown / unlisted → null', async () => {
|
||||
globalThis.fetch = async (url) => (String(url).endsWith('/local-assets.json')
|
||||
? { ok: true, status: 200, json: async () => manifest } : { ok: false, status: 404, json: async () => ({}) });
|
||||
try {
|
||||
const { data } = await import('../../public/js/data.js');
|
||||
await data.invalidate('local');
|
||||
await data.load('local');
|
||||
const { emoteArtUrl, emoteUiSprite } = await import('../../public/js/ui/emotes.js');
|
||||
assert.equal(emoteArtUrl('autochess_battle_fooldoctor_06'), '/assets/local/emoticon/fooldoctor/pic_fooldoctor_08_battle.png');
|
||||
assert.equal(emoteArtUrl('autochess_battle_fooldoctor_05'), null, 'unlisted');
|
||||
assert.equal(emoteArtUrl('happy'), null, 'v1 id');
|
||||
assert.equal(emoteArtUrl('__proto__'), null);
|
||||
assert.equal(emoteUiSprite('emoji_bubble_bkg'), '/assets/local/ui/battle/emoji_bubble_bkg.png');
|
||||
assert.equal(emoteUiSprite('emoji_bkg'), null);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('pager helpers: clamp, swipe threshold, remembered theme', async () => {
|
||||
const { clampPage, swipeStep, themeIndex, lastThemeIndex, rememberTheme } = await import('../../public/js/ui/emotes.js');
|
||||
assert.deepEqual([clampPage(-3), clampPage(2), clampPage(99), clampPage('x'), clampPage(2.7)], [0, 2, 5, 0, 2]);
|
||||
assert.deepEqual([swipeStep(-60), swipeStep(60), swipeStep(-10), swipeStep(0), swipeStep(-40)], [1, -1, 0, 0, 1]);
|
||||
assert.equal(themeIndex('emoticon_foolsday_amiya'), 4);
|
||||
assert.equal(themeIndex('nope'), 0);
|
||||
store.clear();
|
||||
assert.equal(lastThemeIndex(), 0, 'first page by default');
|
||||
rememberTheme('emoticon_foolsday_wisdel');
|
||||
assert.equal(lastThemeIndex(), 5);
|
||||
rememberTheme('bogus');
|
||||
assert.equal(lastThemeIndex(), 5, 'unknown themes are not stored');
|
||||
store.set('sp.pref.emoteTheme', '"emoticon_gone"');
|
||||
assert.equal(lastThemeIndex(), 0, 'a stale theme falls back to the first page');
|
||||
store.set('sp.pref.emoteTheme', '{bad json');
|
||||
assert.equal(lastThemeIndex(), 0);
|
||||
});
|
||||
|
||||
test('wheel paging: one page per gesture — a trackpad swipe\'s momentum tail never skips themes', async () => {
|
||||
const { wheelStep, wheelDelta, WHEEL_GAP_MS, WHEEL_STEP_PX } = await import('../../public/js/ui/emotes.js');
|
||||
// one macOS trackpad swipe: 60 events 16 ms apart, decaying deltaX (≈ 750 px in total)
|
||||
const acc = { x: 0, t: -Infinity, spent: false };
|
||||
let t = 1000;
|
||||
const swipe = (sign) => {
|
||||
const steps = [];
|
||||
for (let i = 0; i < 60; i++, t += 16) steps.push(wheelStep(acc, sign * Math.max(2, 40 * 0.95 ** i), t));
|
||||
return steps.filter(Boolean);
|
||||
};
|
||||
assert.deepEqual(swipe(1), [1], 'the whole swipe turns exactly one page');
|
||||
t += WHEEL_GAP_MS - 20;
|
||||
assert.deepEqual(swipe(1), [], 'a tail that continues without a pause is still the same gesture');
|
||||
t += WHEEL_GAP_MS + 1;
|
||||
assert.deepEqual(swipe(-1), [-1], 'a new gesture after a pause turns the page again (backwards)');
|
||||
// mouse wheel notches (100 px) with pauses: one page per notch; small deltas accumulate
|
||||
t += 1000;
|
||||
assert.equal(wheelStep(acc, 100, t), 1);
|
||||
t += 400;
|
||||
assert.equal(wheelStep(acc, 100, t), 1);
|
||||
t += 400;
|
||||
assert.equal(wheelStep(acc, WHEEL_STEP_PX / 2, t), 0);
|
||||
assert.equal(wheelStep(acc, WHEEL_STEP_PX / 2, t + 16), 1);
|
||||
assert.equal(wheelStep(acc, 0, t + 1000), 0);
|
||||
assert.equal(wheelStep({ x: 0, t: 5000, spent: true }, 100, 10), 1, 'a clock that went backwards starts a new gesture');
|
||||
// Unity ScrollRect rule: a mostly vertical wheel drives the horizontal pager (down = next); lines / pages → px
|
||||
assert.equal(wheelDelta({ deltaX: 0, deltaY: 100 }), 100);
|
||||
assert.equal(wheelDelta({ deltaX: 0, deltaY: -100 }), -100);
|
||||
assert.equal(wheelDelta({ deltaX: 30, deltaY: 10 }), 30);
|
||||
assert.equal(wheelDelta({ deltaX: 0, deltaY: 3, deltaMode: 1 }), 48);
|
||||
assert.equal(wheelDelta({ deltaX: 1, deltaY: 0, deltaMode: 2 }, 271), 271);
|
||||
assert.equal(wheelDelta({}), 0);
|
||||
assert.equal(wheelDelta(null), 0);
|
||||
});
|
||||
|
||||
test('cooldown and bubble timelines are computed from timestamps (robust to remounts and clock jumps)', async () => {
|
||||
const { cooldownLeft, bubbleAge } = await import('../../public/js/ui/emotes.js');
|
||||
assert.equal(cooldownLeft(-Infinity, 5000), 0, 'never sent');
|
||||
assert.equal(cooldownLeft(5000, 5000), EMOTE_COOLDOWN_MS);
|
||||
assert.equal(cooldownLeft(5000, 5400), 600);
|
||||
assert.equal(cooldownLeft(5000, 6000), 0);
|
||||
assert.equal(cooldownLeft(5000, 1000), 0, 'a clock that went backwards never locks the button');
|
||||
assert.equal(cooldownLeft(5000, 5100, 500), 400);
|
||||
assert.equal(bubbleAge(undefined, 9000), 0);
|
||||
assert.equal(bubbleAge(8000, 9000), 1000);
|
||||
assert.equal(bubbleAge(8000, 7000), 0, 'future timestamps start fresh');
|
||||
assert.equal(bubbleAge(1000, 9000), EMOTE_BUBBLE_MS, 'capped at the display time');
|
||||
assert.equal(bubbleAge(NaN, 9000), 0);
|
||||
});
|
||||
|
||||
test('ensureEmoteCss links public/css/emotes.css once, and is a no-op without a document', async () => {
|
||||
const { ensureEmoteCss, EMOTE_CSS_HREF } = await import('../../public/js/ui/emotes.js');
|
||||
const links = [];
|
||||
const doc = {
|
||||
head: { appendChild: (n) => links.push(n) },
|
||||
createElement: () => ({}),
|
||||
querySelector: () => (links.length ? links[0] : null),
|
||||
};
|
||||
assert.equal(ensureEmoteCss(doc), true);
|
||||
assert.equal(ensureEmoteCss(doc), false);
|
||||
assert.deepEqual(links, [{ rel: 'stylesheet', href: EMOTE_CSS_HREF }]);
|
||||
assert.equal(ensureEmoteCss(undefined), false);
|
||||
assert.ok(existsSync(path.join(ROOT, 'public', EMOTE_CSS_HREF)));
|
||||
assert.match(readFileSync(path.join(ROOT, 'public/index.html'), 'utf8'), /href="\/css\/emotes\.css"/);
|
||||
});
|
||||
|
||||
test('emote UI never renders an emote label as text (aria-label only)', () => {
|
||||
const src = readFileSync(path.join(ROOT, 'public/js/ui/emotes.js'), 'utf8');
|
||||
assert.doesNotMatch(src, /EMOTE_TEXT|EMOTE_LABEL/, 'no text table in the UI');
|
||||
// every use of a label is an attribute value
|
||||
for (const m of src.matchAll(/\.label\b/g)) {
|
||||
const before = src.slice(Math.max(0, m.index - 40), m.index);
|
||||
assert.match(before, /aria-label=\$\{[^}]*$/, `label used outside aria-label: …${before}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,323 @@
|
||||
// Facing / direction step of the prep board (research 09 §1.2, §5, §6.1 5, §6.5; DESIGN §3 "Facing (corrected)"):
|
||||
// the range rotation, the wheel's swipe → direction mapping with its dead-zone, which drops enter the direction step,
|
||||
// the committed intents (g.move / g.art carry `dir`), the underframe actions (撤退 / 出售 +N / 销毁), the 撤退 target
|
||||
// slot, the in-place re-orient drop of render/drag.js, and the g.move / g.art payloads of ui/gameActions.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';
|
||||
import {
|
||||
DIRS, normDir, rotateOffset, rangeTiles, dirFromDelta, dirFromKey, pieceDir, previewGrid, artHasFacing, needsFacing,
|
||||
facingIntent, underframeActions, retreatSlot, DEAD_ZONE_TILES,
|
||||
} from '../../public/js/ui/facing.js';
|
||||
import { placementContext, canPlace, dropIntent } from '../../public/js/ui/gameLogic.js';
|
||||
import { resolveDrop, createDragController, pieceSlot } from '../../public/js/render/drag.js';
|
||||
import { GEO } from '../../shared/constants.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const load = (f) => JSON.parse(readFileSync(path.join(ROOT, 'data', f), 'utf8'));
|
||||
const chess = load('chess.json');
|
||||
const stages = load('stages.json');
|
||||
const items = load('items.json');
|
||||
const tokens = load('tokens.json');
|
||||
const getChess = (id) => chess[id] || null;
|
||||
const getItem = (id) => items[id] || null;
|
||||
const getToken = (id) => tokens[id] || null;
|
||||
const STAGE = stages.act2autochess_m01;
|
||||
const MELEE = 'chess_char_1_02_a';
|
||||
const RANGED = 'chess_char_1_01_a';
|
||||
const EQUIP = 'chess_item_1_01_e_a';
|
||||
const SCROLL = 'chess_item_6_02_m'; // 画卷: range [[0,0],[0,1]]
|
||||
const CUSTOMER = 'chess_item_6_01_m'; // “神秘顾客”: range [[0,0]]
|
||||
|
||||
let uid = 100;
|
||||
const piece = (id, extra = {}) => ({ uid: ++uid, kind: 'chess', id, golden: false, tier: chess[id]?.tier ?? 1, items: [], ...extra });
|
||||
const item = (id) => ({ uid: ++uid, kind: 'item', id, golden: false, tier: 1 });
|
||||
function privWith({ board = [], hand = [] } = {}) {
|
||||
const h = new Array(GEO.HAND_SIZE).fill(null);
|
||||
hand.forEach((p, i) => { if (p) h[p.idx ?? i] = p.piece ?? p; });
|
||||
return { alive: true, ready: false, funds: 10, board, hand: h, temp: new Array(GEO.TEMP_SIZE).fill(null), deployCap: 8,
|
||||
deployCount: board.filter((p) => p.kind === 'chess').length, shop: { level: 3, slots: [] } };
|
||||
}
|
||||
const ctxFor = (priv) => placementContext({ priv, stage: STAGE, editable: true, getChess, getItem, getToken });
|
||||
|
||||
describe('rotation (DESIGN §3: RIGHT (dr,dc) · UP (dc,−dr) · LEFT (−dr,−dc) · DOWN (−dc,dr); row 0 = bottom)', () => {
|
||||
test('rotateOffset per direction', () => {
|
||||
assert.deepEqual(rotateOffset(1, 2, 'RIGHT'), [1, 2]);
|
||||
assert.deepEqual(rotateOffset(1, 2, 'UP'), [2, -1]);
|
||||
assert.deepEqual(rotateOffset(1, 2, 'LEFT'), [-1, -2]);
|
||||
assert.deepEqual(rotateOffset(1, 2, 'DOWN'), [-2, 1]);
|
||||
assert.deepEqual(rotateOffset(0, 1, 'up'), [1, 0], 'the tile in front of an UP unit is the next row up');
|
||||
assert.deepEqual(rotateOffset(0, 1, 'DOWN'), [-1, 0]);
|
||||
assert.deepEqual(rotateOffset(0, 1, null), [0, 1], 'default RIGHT');
|
||||
});
|
||||
|
||||
test('rangeTiles: a sniper grid (3 wide, 4 deep) turns with the facing; clipped to the stage; deduplicated', () => {
|
||||
const grid = chess[RANGED].rangeGrid;
|
||||
assert.ok(grid.length >= 6);
|
||||
const right = rangeTiles(grid, 10, 5, 'RIGHT');
|
||||
const up = rangeTiles(grid, 10, 5, 'UP');
|
||||
const left = rangeTiles(grid, 10, 5, 'LEFT');
|
||||
const down = rangeTiles(grid, 10, 5, 'DOWN');
|
||||
for (const t of [right, up, left, down]) assert.equal(t.length, new Set(grid.map(([a, b]) => `${a},${b}`)).size);
|
||||
assert.ok(right.every(([, c]) => c >= 5), 'RIGHT reaches columns to the right');
|
||||
assert.ok(left.every(([, c]) => c <= 5), 'LEFT mirrors it');
|
||||
assert.ok(up.every(([r]) => r >= 10), 'UP reaches the rows above (higher row index)');
|
||||
assert.ok(down.every(([r]) => r <= 10), 'DOWN reaches the rows below');
|
||||
const clipped = rangeTiles([[0, 0], [0, 1], [0, 30]], 10, 18, 'RIGHT');
|
||||
assert.deepEqual(clipped, [[10, 18], [10, 19]], 'off-stage tiles dropped');
|
||||
assert.deepEqual(rangeTiles([[0, 1], [0, 1]], 10, 5, 'RIGHT'), [[10, 6]]);
|
||||
assert.deepEqual(rangeTiles(null, 10, 5, 'RIGHT'), []);
|
||||
assert.deepEqual(rangeTiles([[0, 0], [0, 1]], 10, 5, 'UP', { extend: 1 }), [[10, 5], [11, 5], [12, 5]], 'rangeExtend grows along +dCol before rotating');
|
||||
});
|
||||
|
||||
test('normDir / pieceDir: case-insensitive, legacy ±1, default RIGHT', () => {
|
||||
assert.deepEqual(DIRS, ['UP', 'RIGHT', 'DOWN', 'LEFT']);
|
||||
assert.equal(normDir('left'), 'LEFT');
|
||||
assert.equal(normDir(-1), 'LEFT');
|
||||
assert.equal(normDir('sideways'), 'RIGHT');
|
||||
assert.equal(pieceDir({ dir: 'UP' }), 'UP');
|
||||
assert.equal(pieceDir({}), 'RIGHT', 'server default when absent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the wheel: swipe → direction with a ~0.5 tile dead-zone', () => {
|
||||
test('dominant axis decides; screen up = UP; inside the dead-zone nothing', () => {
|
||||
const s = 80; const dead = s * DEAD_ZONE_TILES;
|
||||
assert.equal(DEAD_ZONE_TILES, 0.5);
|
||||
assert.equal(dirFromDelta(10, 5, dead), null, 'inside the centre');
|
||||
assert.equal(dirFromDelta(60, 10, dead), 'RIGHT');
|
||||
assert.equal(dirFromDelta(-60, 10, dead), 'LEFT');
|
||||
assert.equal(dirFromDelta(5, -60, dead), 'UP');
|
||||
assert.equal(dirFromDelta(5, 60, dead), 'DOWN');
|
||||
assert.equal(dirFromDelta(50, -50, dead), 'RIGHT', 'ties go horizontal');
|
||||
assert.equal(dirFromDelta(NaN, 0, dead), null);
|
||||
assert.equal(dirFromKey('ArrowUp'), 'UP');
|
||||
assert.equal(dirFromKey('KeyW'), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('which drops enter the direction step', () => {
|
||||
test('units on board tiles (move / swap / own tile) do; bench slots, equipment and single-tile Arts do not; 画卷 does', () => {
|
||||
const m = { ...piece(MELEE), row: 9, col: 3, dir: 'UP' };
|
||||
const r = piece(RANGED);
|
||||
const eq = item(EQUIP);
|
||||
const scroll = item(SCROLL);
|
||||
const customer = item(CUSTOMER);
|
||||
const ctx = ctxFor(privWith({ board: [m], hand: [r, eq, scroll, customer] }));
|
||||
const check = (u, t) => needsFacing(ctx, u, t, canPlace(ctx, u, t));
|
||||
const free = ctx.deploy.ranged.values().next().value.split(',').map(Number);
|
||||
assert.equal(check(r.uid, { area: 'board', row: free[0], col: free[1] }), true, 'hand → board');
|
||||
assert.equal(check(m.uid, { area: 'board', row: 9, col: 3 }), true, 'own tile: re-orient in place');
|
||||
assert.equal(canPlace(ctx, m.uid, { area: 'board', row: 9, col: 3 }).action, 'orient');
|
||||
assert.equal(check(m.uid, { area: 'hand', idx: 5 }), false, 'withdraw to the bench');
|
||||
assert.equal(check(eq.uid, { area: 'board', row: 9, col: 3 }), false, 'equip');
|
||||
assert.equal(check(scroll.uid, { area: 'board', row: 10, col: 5 }), true, '画卷 picks its front tile with the wheel');
|
||||
assert.equal(check(customer.uid, { area: 'board', row: 10, col: 5 }), false, 'a single-tile Art needs no direction');
|
||||
assert.equal(artHasFacing(getItem(SCROLL)), true);
|
||||
assert.equal(artHasFacing(getItem(CUSTOMER)), false);
|
||||
assert.equal(needsFacing(ctx, r.uid, { area: 'board', row: 0, col: 0 }, { ok: false }), false, 'illegal never');
|
||||
});
|
||||
|
||||
test('facingIntent: g.move {uid, to, dir} for units, g.art {itemUid, row, col, dir} for Arts', () => {
|
||||
const r = piece(RANGED);
|
||||
// `to.dir` too: Match.handle forwards only msg.to to PlayerState.move, which reads to.dir when dir is absent
|
||||
assert.deepEqual(facingIntent(r, { row: 10, col: 4 }, 'up'), { t: 'g.move', fields: { uid: r.uid, to: { area: 'board', row: 10, col: 4, dir: 'UP' }, dir: 'UP' } });
|
||||
const s = item(SCROLL);
|
||||
assert.deepEqual(facingIntent(s, { row: 11, col: 6 }, 'LEFT'), { t: 'g.art', fields: { itemUid: s.uid, row: 11, col: 6, dir: 'LEFT' } });
|
||||
});
|
||||
|
||||
test('previewGrid: the unit / summon / Art range grid (null without one)', () => {
|
||||
assert.deepEqual(previewGrid({ getChess, getToken, getItem }, { kind: 'chess', id: RANGED }), chess[RANGED].rangeGrid);
|
||||
assert.deepEqual(previewGrid({ getChess, getToken, getItem }, { kind: 'item', id: SCROLL }), [[0, 0], [0, 1]]);
|
||||
assert.equal(previewGrid({ getChess, getToken, getItem }, { kind: 'item', id: EQUIP }), null);
|
||||
assert.equal(previewGrid(null, { kind: 'chess', id: RANGED }), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('underframe (research 09 §5): 撤退 / 出售 +N / 销毁', () => {
|
||||
test('actions per piece kind and place', () => {
|
||||
const b = { ...piece(MELEE), row: 9, col: 3 };
|
||||
const h = piece(RANGED);
|
||||
const it = item(EQUIP);
|
||||
const tokId = Object.keys(tokens)[0];
|
||||
const bt = { uid: ++uid, kind: 'token', id: tokId, count: 1, ownerUid: b.uid, row: 10, col: 4 };
|
||||
const ht = { uid: ++uid, kind: 'token', id: tokId, count: 1, ownerUid: b.uid };
|
||||
const ctx = ctxFor(privWith({ board: [b, bt], hand: [h, it, ht] }));
|
||||
const sell = chess[MELEE].sellPrice;
|
||||
assert.deepEqual(underframeActions(ctx, b.uid), { retreat: true, sell, destroy: false }, 'board operator');
|
||||
assert.deepEqual(underframeActions(ctx, h.uid), { retreat: false, sell: chess[RANGED].sellPrice, destroy: false }, 'bench operator: 出售 only');
|
||||
assert.deepEqual(underframeActions(ctx, it.uid), { retreat: false, sell: null, destroy: true }, 'items: 销毁 only');
|
||||
assert.deepEqual(underframeActions(ctx, bt.uid), { retreat: true, sell: null, destroy: false }, 'board summon: 撤退 only');
|
||||
assert.equal(underframeActions(ctx, ht.uid), null, 'bench summon: nothing');
|
||||
assert.equal(underframeActions(ctx, 999999), null);
|
||||
});
|
||||
|
||||
test('retreatSlot: the first free bench slot; a full bench → null (summons always go back onto their stack)', () => {
|
||||
const b = { ...piece(MELEE), row: 9, col: 3 };
|
||||
const tok = { uid: ++uid, kind: 'token', id: Object.keys(tokens)[0], count: 1, ownerUid: b.uid, row: 10, col: 4 };
|
||||
const hand = [];
|
||||
for (let i = 0; i < 3; i++) hand.push({ idx: i, piece: piece(RANGED) });
|
||||
assert.deepEqual(retreatSlot(ctxFor(privWith({ board: [b], hand })), b.uid), { area: 'hand', idx: 3 });
|
||||
const full = [];
|
||||
for (let i = 0; i < GEO.HAND_SIZE; i++) full.push({ idx: i, piece: piece(RANGED) });
|
||||
assert.equal(retreatSlot(ctxFor(privWith({ board: [b], hand: full })), b.uid), null);
|
||||
assert.deepEqual(retreatSlot(ctxFor(privWith({ board: [b, tok], hand: full })), tok.uid), { area: 'hand', idx: 0 });
|
||||
const own = full.slice(0, GEO.HAND_SIZE - 1).concat([{ idx: GEO.HAND_SIZE - 1, piece: { uid: ++uid, kind: 'token', id: tok.id, count: 2, ownerUid: b.uid } }]);
|
||||
assert.deepEqual(retreatSlot(ctxFor(privWith({ board: [b], hand: own })), b.uid), { area: 'hand', idx: GEO.HAND_SIZE - 1 }, 'its own summon stack frees a slot');
|
||||
assert.equal(retreatSlot(ctxFor(privWith({ hand: full })), full[0].piece.uid), null, 'not on the board');
|
||||
});
|
||||
});
|
||||
|
||||
describe('render/drag.js: dropping a board piece on its own tile is a drop (re-orient)', () => {
|
||||
test('resolveDrop: own board tile → drop; own bench slot still cancels', () => {
|
||||
const b = { uid: 7, area: 'board', row: 10, col: 4 };
|
||||
const d = resolveDrop({ piece: b, from: pieceSlot(b), tile: { row: 10, col: 4 }, overCanvas: true, clientX: 0, clientY: 0 });
|
||||
assert.deepEqual(d, { kind: 'drop', target: { area: 'board', row: 10, col: 4 } });
|
||||
const refused = resolveDrop({ piece: b, from: pieceSlot(b), tile: { row: 10, col: 4 }, overCanvas: true, canPlace: () => false });
|
||||
assert.equal(refused.kind, 'cancel', 'canPlace still rules');
|
||||
const h = { uid: 8, area: 'hand', idx: 3 };
|
||||
assert.equal(resolveDrop({ piece: h, from: pieceSlot(h), tile: { row: GEO.HAND_ROW, col: 3 }, overCanvas: true }).reason, 'same');
|
||||
});
|
||||
|
||||
test('controller: dragging a board unit away and back onto its tile emits pieceDrop on that tile, lit as legal', () => {
|
||||
const events = [];
|
||||
const pieces = [{ uid: 2, area: 'board', row: 10, col: 4, x: 450, y: 250 }];
|
||||
const c = createDragController({
|
||||
hitPiece: (x, y) => pieces.find((p) => Math.abs(p.x - x) < 40 && Math.abs(p.y - y) < 40) || null,
|
||||
pickTile: (x, y) => ({ row: 12 - Math.floor(y / 100), col: Math.floor(x / 100) }),
|
||||
emit: (n, p) => events.push([n, p]),
|
||||
setTimeout: () => 0, clearTimeout: () => {},
|
||||
});
|
||||
c.setEditable(true);
|
||||
const ev = (t, x, y) => c[t]({ pointerId: 1, pointerType: 'mouse', button: 0, x, y, clientX: x, clientY: y });
|
||||
ev('pointerDown', 450, 250);
|
||||
ev('pointerMove', 470, 250);
|
||||
ev('pointerMove', 560, 250);
|
||||
ev('pointerMove', 452, 252);
|
||||
const mv = events.filter((e) => e[0] === 'pieceDragMove').pop()[1];
|
||||
assert.equal(mv.legal, true, 'its own tile is a legal target while dragging');
|
||||
ev('pointerUp', 452, 252);
|
||||
const drop = events.find((e) => e[0] === 'pieceDrop');
|
||||
assert.ok(drop, 'pieceDrop emitted');
|
||||
assert.deepEqual(drop[1].target, { area: 'board', row: 10, col: 4 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('gameActions: g.move / g.art carry the chosen dir', () => {
|
||||
test('payloads', async () => {
|
||||
const { net } = await import('../../public/js/net.js');
|
||||
const { actions } = await import('../../public/js/ui/gameActions.js');
|
||||
const sent = [];
|
||||
const orig = net.request;
|
||||
net.request = async (t, f) => { sent.push([t, f]); return {}; };
|
||||
try {
|
||||
await actions.move(5, { area: 'board', row: 10, col: 4 }, 'UP');
|
||||
await actions.move(5, { area: 'hand', idx: 2 });
|
||||
await actions.art(9, 11, 6, 'LEFT');
|
||||
await actions.art(9, 11, 6);
|
||||
} finally { net.request = orig; }
|
||||
assert.deepEqual(sent, [
|
||||
['g.move', { uid: 5, to: { area: 'board', row: 10, col: 4 }, dir: 'UP' }],
|
||||
['g.move', { uid: 5, to: { area: 'hand', idx: 2 } }],
|
||||
['g.art', { itemUid: 9, row: 11, col: 6, dir: 'LEFT' }],
|
||||
['g.art', { itemUid: 9, row: 11, col: 6 }],
|
||||
]);
|
||||
});
|
||||
|
||||
test('dropIntent of a same-tile orient is a plain g.move (the wheel adds dir)', () => {
|
||||
const m = { ...piece(MELEE), row: 9, col: 3 };
|
||||
const ctx = ctxFor(privWith({ board: [m] }));
|
||||
assert.equal(dropIntent(ctx, m.uid, { area: 'board', row: 9, col: 3 }).t, 'g.move');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- review regressions ------------------------------------------------------------------------------------------
|
||||
|
||||
describe('the wheel\'s intent reaches the stored facing through the real Match.handle', () => {
|
||||
test('placement, in-place re-orient and the next round keep the chosen dir (no snap back to RIGHT)', async () => {
|
||||
const { makeMatch, give, chessOfTier, legalTileFor } = await import('../match/harness.js');
|
||||
const { PHASE } = await import('../../shared/constants.js');
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 14 }).start();
|
||||
h.toPrep(1);
|
||||
const m = h.m;
|
||||
const ps = h.ps('p_0');
|
||||
const id = chessOfTier(1, (c) => c.position === 'MELEE').find((x) => m.pool.has(x) && !chess[x].tokens?.length);
|
||||
const a = give(m, ps, id, 'hand');
|
||||
const [row, col] = legalTileFor(m, ps, id);
|
||||
const send = (dir, r = row, c = col) => {
|
||||
const it = facingIntent({ uid: a.uid, kind: 'chess', id }, { row: r, col: c }, dir);
|
||||
return m.handle('p_0', { t: it.t, ...it.fields });
|
||||
};
|
||||
const stored = () => ps.privateView().board.find((p) => p.uid === a.uid)?.dir;
|
||||
assert.deepEqual(send('UP'), { ok: true });
|
||||
assert.equal(stored(), 'UP', 'placed facing UP');
|
||||
assert.deepEqual(send('LEFT'), { ok: true });
|
||||
assert.equal(stored(), 'LEFT', 're-oriented in place (own tile)');
|
||||
assert.equal(ps.privateView().board.filter((p) => p.uid === a.uid).length, 1);
|
||||
assert.ok(h.drive(() => m.phase === PHASE.PREP && m.round === 2), 'next prep');
|
||||
assert.equal(stored(), 'LEFT', 'kept across the round');
|
||||
m.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('client range preview = sim range (every rangeGrid of chess / tokens / Arts, 4 dirs, rangeExtend 0..3)', () => {
|
||||
test('rangeTiles matches server/sim/targeting.js absoluteRangeKeys tile for tile', async () => {
|
||||
const { absoluteRangeKeys } = await import('../../server/sim/targeting.js');
|
||||
const grids = new Map();
|
||||
for (const src of [chess, tokens, items]) for (const rec of Object.values(src)) {
|
||||
if (Array.isArray(rec?.rangeGrid) && rec.rangeGrid.length) grids.set(JSON.stringify(rec.rangeGrid), rec.rangeGrid);
|
||||
}
|
||||
assert.ok(grids.size >= 10, `distinct range shapes (${grids.size})`);
|
||||
let n = 0;
|
||||
for (const g of grids.values()) for (const dir of DIRS) for (const ext of [0, 1, 2, 3]) for (const [r, c] of [[9, 0], [10, 5], [12, 9], [11, 2]]) {
|
||||
const a = rangeTiles(g, r, c, dir, { extend: ext }).map(([rr, cc]) => rr * GEO.COLS + cc).sort((x, y) => x - y);
|
||||
const b = absoluteRangeKeys(g, r, c, dir, ext).slice().sort((x, y) => x - y);
|
||||
assert.deepEqual(a, b, `${JSON.stringify(g)} ${dir} +${ext} @${r},${c}`);
|
||||
n++;
|
||||
}
|
||||
assert.ok(n > 500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wheel stripes go under the units on the 3D board (review regression)', () => {
|
||||
test('stripeMount: between the 3D canvas and the Pixi canvas only when the 3D board is on; stripePolys offsets', async () => {
|
||||
const { stripeMount, stripePolys } = await import('../../public/js/ui/facingWheel.js');
|
||||
const host = { insertBefore() {} };
|
||||
const canvas = { parentElement: host, getBoundingClientRect: () => ({ left: 10, top: 20 }) };
|
||||
const cam = { project: (x, y) => ({ x: x * 10, y: 100 - y * 10, s: 10 }) };
|
||||
const view = (board3d) => ({ raw: { debug: { app: { view: canvas }, cam, board3d, tiles: { heightAt: () => 0 } } } });
|
||||
assert.deepEqual(stripeMount(view({ on: true })), { host, before: canvas }, '3D board: under the Pixi canvas');
|
||||
assert.equal(stripeMount(view(null)), null, '2D board (opaque Pixi canvas): overlay');
|
||||
assert.equal(stripeMount({ raw: { tileScreen: () => null } }), null, 'DOM fallback view: overlay');
|
||||
assert.equal(stripeMount(null), null);
|
||||
const polys = stripePolys(view({ on: true }), [[9, 2]], 10, 20);
|
||||
assert.equal(polys.length, 1);
|
||||
// tile (9,2): corners (±0.5) projected, + canvas offset (10,20), − host offset (10,20)
|
||||
assert.equal(polys[0].pts, '15.0,5.0 25.0,5.0 25.0,15.0 15.0,15.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('wheel on the mirrored Final Assault half (research 09 §1.2: RIGHT ↔ LEFT for the right-hand player)', () => {
|
||||
test('boardDir maps screen directions to board directions and back; UP / DOWN unchanged', async () => {
|
||||
const { boardDir, viewMirrored } = await import('../../public/js/ui/facing.js');
|
||||
const { bossPrepField } = await import('../../public/js/render/prepfield.js');
|
||||
for (const d of DIRS) {
|
||||
assert.equal(boardDir(d, false), d);
|
||||
assert.equal(boardDir(boardDir(d, true), true), d, 'its own inverse');
|
||||
// the direction drawn for a board dir on the right half is the screen direction that picks it
|
||||
assert.equal(bossPrepField('R').dirToDisp(boardDir(d, true)), d);
|
||||
}
|
||||
assert.equal(boardDir('RIGHT', true), 'LEFT');
|
||||
assert.equal(boardDir('UP', true), 'UP');
|
||||
assert.equal(boardDir(null, true), null);
|
||||
assert.equal(boardDir('nope', true), null);
|
||||
assert.equal(viewMirrored({ raw: { prepField: () => ({ kind: 'bossPrep', side: 'R', mirror: true }) } }), true);
|
||||
assert.equal(viewMirrored({ raw: { prepField: () => ({ kind: 'board', side: 'L', mirror: false }) } }), false);
|
||||
assert.equal(viewMirrored({ raw: {} }), false, 'views without the hook (DOM fallback)');
|
||||
assert.equal(viewMirrored({ raw: { prepField: () => { throw new Error('x'); } } }), false);
|
||||
assert.equal(viewMirrored(null), false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,494 @@
|
||||
// Unit tests for the in-match UI's pure logic (public/js/ui/gameLogic.js): placement mirror (canPlace),
|
||||
// drop intents, bond sorting / tiers / members, countdown math, draft / 机变 normalisation, fields,
|
||||
// shortcuts, settings, enemies preview, result normalisation.
|
||||
|
||||
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';
|
||||
import {
|
||||
phaseMode, phaseBanner, isCombatPhase, isBossPhase, countdownState, phaseTotalSeconds, sortBonds, bondTier, nextThreshold,
|
||||
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,
|
||||
} from '../../public/js/ui/gameLogic.js';
|
||||
import { pairPlayers } from '../../server/match/finalAssault.js';
|
||||
import { PHASE, GEO } from '../../shared/constants.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const load = (f) => JSON.parse(readFileSync(path.join(ROOT, 'data', f), 'utf8'));
|
||||
const chess = load('chess.json');
|
||||
const stages = load('stages.json');
|
||||
const items = load('items.json');
|
||||
const tokens = load('tokens.json');
|
||||
const bonds = load('bonds.json');
|
||||
const config = load('config.json');
|
||||
const getChess = (id) => chess[id] || null;
|
||||
const getItem = (id) => items[id] || null;
|
||||
const getToken = (id) => tokens[id] || null;
|
||||
|
||||
const STAGE = stages.act2autochess_m01;
|
||||
const MELEE = 'chess_char_1_02_a'; // 角峰
|
||||
const RANGED = 'chess_char_1_01_a'; // 隐现
|
||||
const EQUIP = 'chess_item_1_01_e_a';
|
||||
const MAGIC = 'chess_item_6_01_m';
|
||||
|
||||
let uid = 0;
|
||||
const piece = (id, extra = {}) => ({ uid: ++uid, kind: 'chess', id, golden: false, tier: chess[id]?.tier ?? 1, items: [], ...extra });
|
||||
const item = (id) => ({ uid: ++uid, kind: 'item', id, golden: false, tier: 1 });
|
||||
|
||||
function privWith({ board = [], hand = [], temp = [], deployCap = 8, ready = false } = {}) {
|
||||
const h = new Array(GEO.HAND_SIZE).fill(null);
|
||||
hand.forEach((p, i) => { if (p) h[p.idx ?? i] = p.piece ?? p; });
|
||||
const t = new Array(GEO.TEMP_SIZE).fill(null);
|
||||
temp.forEach((p, i) => { t[i] = p; });
|
||||
return { alive: true, ready, funds: 10, board, hand: h, temp: t, deployCap, deployCount: board.filter((p) => p.kind === 'chess').length, canReady: !t.some(Boolean),
|
||||
shop: { level: 3, maxLevel: 6, upgradePrice: 8, refreshPrice: 1, freeRefreshes: 0, frozen: false, slots: [] } };
|
||||
}
|
||||
const ctxFor = (priv, editable = true) => placementContext({ priv, stage: STAGE, editable, getChess, getItem, getToken });
|
||||
|
||||
describe('phases', () => {
|
||||
test('phaseMode maps every phase', () => {
|
||||
assert.equal(phaseMode(null), 'loading');
|
||||
assert.equal(phaseMode(PHASE.INFO_CHECK), 'briefing');
|
||||
assert.equal(phaseMode(PHASE.BAND_DRAFT), 'draft');
|
||||
assert.equal(phaseMode(PHASE.BATTLE_CHECK), 'boot');
|
||||
for (const p of [PHASE.PREP, PHASE.SP_DRAFT, PHASE.ROUND_START]) assert.equal(phaseMode(p), 'prep');
|
||||
for (const p of [PHASE.COMBAT, PHASE.UNITE, PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE]) assert.equal(phaseMode(p), 'combat');
|
||||
assert.equal(phaseMode(PHASE.SETTLE), 'settle');
|
||||
assert.equal(phaseMode(PHASE.RESULT), 'result');
|
||||
assert.equal(phaseMode('SOMETHING_NEW'), 'prep');
|
||||
});
|
||||
test('combat / boss predicates and banners', () => {
|
||||
assert.ok(isCombatPhase(PHASE.UNITE) && !isCombatPhase(PHASE.PREP));
|
||||
assert.ok(isBossPhase(PHASE.HIDDEN_CORE) && !isBossPhase(PHASE.COMBAT));
|
||||
assert.equal(phaseBanner(PHASE.UNITE, {}).title, '联防阶段');
|
||||
assert.equal(phaseBanner(PHASE.FINAL_ASSAULT, {}).title, '最终攻势');
|
||||
assert.equal(phaseBanner(PHASE.HIDDEN_CORE, {}).title, '隐秘核心');
|
||||
assert.equal(phaseBanner(PHASE.COMBAT, {}).title, '作战开始');
|
||||
assert.match(phaseBanner(PHASE.ROUND_START, { round: 7 }).title, /7/);
|
||||
assert.equal(phaseBanner(PHASE.SETTLE, {}), null);
|
||||
assert.equal(prepCapsuleLabel(PHASE.PREP), '休息一下');
|
||||
});
|
||||
});
|
||||
|
||||
describe('countdown', () => {
|
||||
test('untimed phases show "--"', () => {
|
||||
assert.deepEqual(countdownState(0, Date.now()), { remain: null, warn: false, bars: 0, text: '--', frac: 0 });
|
||||
assert.equal(countdownState(null, 1000).text, '--');
|
||||
assert.equal(countdownState(NaN, 1000).remain, null);
|
||||
});
|
||||
test('ceil seconds, warn at ≤10, gauge bars from total', () => {
|
||||
const now = 1_000_000;
|
||||
const a = countdownState(now + 60_000, now, 60);
|
||||
assert.equal(a.remain, 60); assert.equal(a.bars, 5); assert.equal(a.warn, false); assert.equal(a.text, '60');
|
||||
const b = countdownState(now + 9_001, now, 60);
|
||||
assert.equal(b.remain, 10); assert.equal(b.warn, true); assert.equal(b.bars, 1); assert.equal(b.text, '10');
|
||||
const c = countdownState(now + 4_200, now, 60);
|
||||
assert.equal(c.text, '05');
|
||||
const d = countdownState(now - 5000, now, 60);
|
||||
assert.equal(d.remain, 0); assert.equal(d.bars, 0);
|
||||
assert.equal(countdownState(now + 30_000, now).bars, 5, 'no total ⇒ full gauge');
|
||||
assert.equal(countdownState(now + 2_000_000, now).text, '999', 'clamped to 3 digits');
|
||||
});
|
||||
test('phase totals from config', () => {
|
||||
assert.equal(phaseTotalSeconds({ phase: PHASE.INFO_CHECK }, config), config.timers.infoCheck);
|
||||
assert.equal(phaseTotalSeconds({ phase: PHASE.PREP, modeId: 'mode_multi_hard', round: 4 }, config), config.modes.mode_multi_hard.rounds['4'].prepTime);
|
||||
assert.equal(phaseTotalSeconds({ phase: PHASE.PREP, modeId: 'mode_single_hard', round: 4 }, config), null, 'solo prep untimed');
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bonds', () => {
|
||||
test('sortBonds: active, layers, count, tier, core, id', () => {
|
||||
const list = [
|
||||
{ bondId: 'deputShip', active: false, layers: 50, count: 1, tier: 0 },
|
||||
{ bondId: 'preciShip', active: true, layers: 3, count: 2, tier: 1 },
|
||||
{ bondId: 'yanShip', active: true, layers: 3, count: 2, tier: 1 },
|
||||
{ bondId: 'swiftShip', active: true, layers: 9, count: 2, tier: 1 },
|
||||
null, { nope: 1 },
|
||||
];
|
||||
const sorted = sortBonds(list, (id) => bonds[id]).map((b) => b.bondId);
|
||||
assert.deepEqual(sorted, ['swiftShip', 'yanShip', 'preciShip', 'deputShip']);
|
||||
assert.deepEqual(sortBonds(undefined), []);
|
||||
});
|
||||
test('bondTier / nextThreshold incl. downward bonds', () => {
|
||||
assert.equal(bondTier(5, [3, 6, 9]), 1);
|
||||
assert.equal(bondTier(9, [3, 6, 9]), 3);
|
||||
assert.equal(bondTier(0, [2]), 0);
|
||||
assert.equal(bondTier(1, [1], 1), 1);
|
||||
assert.equal(bondTier(2, [1], 1), 0, '独行 inactive above maxCount');
|
||||
assert.equal(nextThreshold(4, [3, 6, 9]), 6);
|
||||
assert.equal(nextThreshold(9, [3, 6, 9]), null);
|
||||
});
|
||||
test('bondMembers marks on-board / owned / banned', () => {
|
||||
const b = bonds.deputShip;
|
||||
const [m1, m2, m3] = b.visibleMembers;
|
||||
const priv = privWith({ board: [{ ...piece(m1), row: 9, col: 3 }], hand: [piece(chess[m2].goldenId)] });
|
||||
const rows = bondMembers(b, priv, [m3], getChess);
|
||||
const byId = new Map(rows.map((r) => [r.id, r]));
|
||||
assert.equal(byId.get(m1).onBoard, true);
|
||||
assert.equal(byId.get(m2).owned, true, 'golden in hand counts as owned base');
|
||||
assert.equal(byId.get(m2).onBoard, false);
|
||||
assert.equal(byId.get(m3).banned, true);
|
||||
assert.equal(rows[0].id, m1, 'on-board first');
|
||||
assert.equal(rows.length, b.visibleMembers.length);
|
||||
});
|
||||
test('bannedPerBond counts banned visible members', () => {
|
||||
const b = bonds.deputShip;
|
||||
const per = bannedPerBond(Object.values(bonds), [b.visibleMembers[0], b.visibleMembers[1], 'nope']);
|
||||
assert.equal(per.get('deputShip'), 2);
|
||||
assert.equal(per.get('yanShip') >= 0, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shop', () => {
|
||||
test('priceTone: discount / markup / normal / free', () => {
|
||||
assert.equal(priceTone({ price: 2, basePrice: 3 }), 'discount');
|
||||
assert.equal(priceTone({ price: 4, basePrice: 3 }), 'premium');
|
||||
assert.equal(priceTone({ price: 3, basePrice: 3 }), 'gold');
|
||||
assert.equal(priceTone({ price: 0, basePrice: 3 }), 'discount');
|
||||
assert.equal(priceTone({ price: 3 }), 'gold');
|
||||
assert.equal(priceTone(null), 'gold');
|
||||
});
|
||||
test('mergeProgress counts normal copies on board, hand and temp', () => {
|
||||
const priv = privWith({ board: [{ ...piece(MELEE), row: 9, col: 3 }], hand: [piece(MELEE), piece(chess[MELEE].goldenId, { golden: true })], temp: [piece(MELEE)] });
|
||||
assert.deepEqual(mergeProgress(priv, MELEE, getChess), { copies: 3, need: 3 });
|
||||
assert.deepEqual(mergeProgress(privWith(), 'chess_char_2_11_a', getChess).need, chess.chess_char_2_11_a.upgradeNum);
|
||||
});
|
||||
test('shopBlockReason', () => {
|
||||
const priv = privWith();
|
||||
assert.equal(shopBlockReason('buy', { priv, editable: true, slot: { price: 3, sold: false } }), null);
|
||||
assert.equal(shopBlockReason('buy', { priv, editable: true, slot: { price: 30 } }), '资金不足');
|
||||
assert.equal(shopBlockReason('buy', { priv, editable: true, slot: { price: 1, sold: true } }), '已售出');
|
||||
assert.equal(shopBlockReason('buy', { priv: { ...priv, ready: true }, editable: false, slot: {} }), '已准备就绪,取消准备后才能操作');
|
||||
assert.equal(shopBlockReason('levelUp', { priv: { ...priv, shop: { ...priv.shop, level: 6 } }, editable: true }), '调度中心已达最高等级');
|
||||
assert.equal(shopBlockReason('refresh', { priv: { ...priv, funds: 0 }, editable: true }), '资金不足');
|
||||
assert.equal(shopBlockReason('refresh', { priv: { ...priv, funds: 0, shop: { ...priv.shop, refreshPrice: 0 } }, editable: true }), null, 'free refresh');
|
||||
assert.match(shopBlockReason('ready', { priv: { ...priv, canReady: false } }), /临时整备区/);
|
||||
assert.equal(shopBlockReason('ready', { priv }), null);
|
||||
assert.equal(shopBlockReason('buy', { priv: { ...priv, alive: false }, editable: true }), '你已被淘汰');
|
||||
assert.equal(shopBlockReason('buy', {}), '尚未就绪');
|
||||
});
|
||||
});
|
||||
|
||||
describe('placement mirror (canPlace)', () => {
|
||||
test('deploySets uses stage deployTiles, derived legend as fallback', () => {
|
||||
const d = deploySets(STAGE);
|
||||
assert.ok(d.melee.has('9,3') && d.ranged.has('9,3'));
|
||||
assert.ok(!d.melee.has('10,4') && d.ranged.has('10,4'), 'high ground: ranged only');
|
||||
assert.ok(!d.ranged.has('9,2'), 'objective not deployable');
|
||||
const { deployTiles, ...noDt } = STAGE; // eslint-disable-line no-unused-vars
|
||||
const d2 = deploySets(noDt);
|
||||
assert.deepEqual([...d2.melee].sort(), [...d.melee].sort());
|
||||
assert.deepEqual([...d2.ranged].sort(), [...d.ranged].sort());
|
||||
assert.equal(deploySets(null).melee.size, 0);
|
||||
});
|
||||
test('melee vs ranged tiles', () => {
|
||||
const m = piece(MELEE);
|
||||
const r = piece(RANGED);
|
||||
const ctx = ctxFor(privWith({ hand: [m, r] }));
|
||||
assert.equal(canPlace(ctx, m.uid, { area: 'board', row: 9, col: 3 }).ok, true);
|
||||
assert.equal(canPlace(ctx, m.uid, { area: 'board', row: 10, col: 4 }).ok, false, 'melee on high ground');
|
||||
assert.equal(canPlace(ctx, r.uid, { area: 'board', row: 10, col: 4 }).ok, true);
|
||||
assert.equal(canPlace(ctx, r.uid, { area: 'board', row: 9, col: 3 }).ok, true, 'ranged may use melee tiles');
|
||||
assert.equal(canPlace(ctx, m.uid, { area: 'board', row: 9, col: 2 }).ok, false, 'objective');
|
||||
assert.equal(canPlace(ctx, m.uid, { area: 'board', row: 9, col: 10 }).ok, false, 'gate');
|
||||
assert.equal(canPlace(ctx, m.uid, { area: 'board', row: 9, col: 12 }).ok, false, 'outside own field');
|
||||
assert.equal(canPlace(ctx, m.uid, { area: 'board', row: 7, col: 3 }).ok, false, 'hand row is not board');
|
||||
assert.equal(canPlace(ctx, m.uid, { area: 'board', row: 9.5, col: 3 }).ok, false, 'non-integer');
|
||||
assert.equal(canPlace(ctx, 99999, { area: 'board', row: 9, col: 3 }).ok, false, 'unknown uid');
|
||||
assert.equal(canPlace(ctx, m.uid, null).ok, false);
|
||||
assert.equal(canPlace(ctx, m.uid, { area: 'temp', idx: 0 }).ok, false, 'no temp target');
|
||||
});
|
||||
test('not editable ⇒ nothing is legal', () => {
|
||||
const m = piece(MELEE);
|
||||
assert.equal(canPlace(ctxFor(privWith({ hand: [m] }), false), m.uid, { area: 'board', row: 9, col: 3 }).code, 'WRONG_PHASE');
|
||||
assert.equal(canPlace(null, m.uid, { area: 'board', row: 9, col: 3 }).ok, false);
|
||||
});
|
||||
test('deploy cap counts chess on board only; swap keeps count', () => {
|
||||
const board = [];
|
||||
const tiles = [[9, 3], [9, 4], [9, 5], [9, 7], [9, 8], [9, 9], [10, 5], [10, 7]];
|
||||
for (const [row, col] of tiles) board.push({ ...piece(MELEE), row, col });
|
||||
const extra = piece(MELEE);
|
||||
const ctx = ctxFor(privWith({ board, hand: [extra] }));
|
||||
const full = canPlace(ctx, extra.uid, { area: 'board', row: 11, col: 5 });
|
||||
assert.equal(full.ok, false); assert.equal(full.code, 'BOARD_FULL');
|
||||
assert.deepEqual(canPlace(ctx, extra.uid, { area: 'board', row: 9, col: 3 }), { ok: true, action: 'swap' }, 'swap with a deployed unit');
|
||||
const onBoard = board[0];
|
||||
assert.equal(canPlace(ctx, onBoard.uid, { area: 'board', row: 11, col: 5 }).ok, true, 'moving on the board never hits the cap');
|
||||
// tokens don't use deploy slots
|
||||
const tok = { uid: ++uid, kind: 'token', id: 'token_10028_vigil_wolf', count: 1, ownerUid: board[0].uid };
|
||||
const ctx2 = ctxFor(privWith({ board, hand: [tok] }));
|
||||
assert.equal(canPlace(ctx2, tok.uid, { area: 'board', row: 11, col: 5 }).ok, true);
|
||||
assert.equal(canPlace(ctx2, tok.uid, { area: 'board', row: 10, col: 4 }).ok, false, 'MELEE token not on high ground');
|
||||
assert.equal(canPlace(ctx2, tok.uid, { area: 'board', row: 9, col: 3 }).code, 'BAD_TILE', 'a hand token never swaps into an occupied tile');
|
||||
const orphan = { uid: ++uid, kind: 'token', id: 'token_10028_vigil_wolf', count: 1, ownerUid: 777777 };
|
||||
const ctx3 = ctxFor(privWith({ board, hand: [orphan] }));
|
||||
assert.equal(canPlace(ctx3, orphan.uid, { area: 'board', row: 11, col: 5 }).code, 'BAD_TARGET', 'summoner must be deployed');
|
||||
// a hand chess swapping with a board token takes a deploy slot (cap applies)
|
||||
const bTok = { uid: ++uid, kind: 'token', id: 'token_10028_vigil_wolf', count: 1, ownerUid: board[0].uid, row: 11, col: 5 };
|
||||
const ctx4 = ctxFor(privWith({ board: [...board, bTok], hand: [extra] }));
|
||||
assert.equal(canPlace(ctx4, extra.uid, { area: 'board', row: 11, col: 5 }).code, 'BOARD_FULL');
|
||||
});
|
||||
test('board→board swap requires both positions to be legal', () => {
|
||||
const m = { ...piece(MELEE), row: 9, col: 3 };
|
||||
const r = { ...piece(RANGED), row: 10, col: 4 };
|
||||
const ctx = ctxFor(privWith({ board: [m, r] }));
|
||||
assert.equal(canPlace(ctx, r.uid, { area: 'board', row: 9, col: 3 }).ok, false, 'the melee unit cannot go to the high tile');
|
||||
assert.equal(canPlace(ctx, m.uid, { area: 'board', row: 10, col: 4 }).ok, false);
|
||||
// its own tile: legal — the direction wheel re-orients it in place (research 09 §1.2)
|
||||
assert.deepEqual(canPlace(ctx, m.uid, { area: 'board', row: 9, col: 3 }), { ok: true, action: 'orient' });
|
||||
assert.deepEqual(dropIntent(ctx, m.uid, { area: 'board', row: 9, col: 3 }), { t: 'g.move', fields: { uid: m.uid, to: { area: 'board', row: 9, col: 3 } } });
|
||||
});
|
||||
test('hand targets: move, swap, same slot, board unit vs hand item', () => {
|
||||
const b = { ...piece(RANGED), row: 10, col: 4 };
|
||||
const h0 = piece(MELEE);
|
||||
const it = item(EQUIP);
|
||||
const ctx = ctxFor(privWith({ board: [b], hand: [{ idx: 0, piece: h0 }, { idx: 1, piece: it }] }));
|
||||
assert.deepEqual(canPlace(ctx, b.uid, { area: 'hand', idx: 5 }), { ok: true, action: 'move' });
|
||||
assert.equal(canPlace(ctx, b.uid, { area: 'hand', idx: 0 }).ok, false, 'melee hand unit cannot take the high tile');
|
||||
assert.deepEqual(canPlace(ctx, b.uid, { area: 'hand', idx: 1 }), { ok: true, action: 'move' }, 'withdrawn onto an item slot: the server uses another free slot');
|
||||
assert.deepEqual(canPlace(ctx, h0.uid, { area: 'hand', idx: 1 }), { ok: true, action: 'swap' }, 'hand ↔ hand swap with an item');
|
||||
assert.equal(canPlace(ctx, h0.uid, { area: 'hand', idx: 0 }).code, 'ALREADY');
|
||||
assert.equal(canPlace(ctx, h0.uid, { area: 'hand', idx: 10 }).ok, false);
|
||||
assert.equal(canPlace(ctx, h0.uid, { area: 'hand', idx: -1 }).ok, false);
|
||||
});
|
||||
test('items: equip on chess (board or hand), arts on tiles, not on tokens', () => {
|
||||
const b = { ...piece(MELEE), row: 9, col: 3 };
|
||||
const hChess = piece(RANGED);
|
||||
const tok = { uid: ++uid, kind: 'token', id: 'token_10028_vigil_wolf', count: 1, row: 9, col: 4 };
|
||||
const eq = item(EQUIP);
|
||||
const art = item(MAGIC);
|
||||
const ctx = ctxFor(privWith({ board: [b, tok], hand: [{ idx: 0, piece: hChess }, { idx: 1, piece: eq }, { idx: 2, piece: art }] }));
|
||||
assert.deepEqual(canPlace(ctx, eq.uid, { area: 'board', row: 9, col: 3 }), { ok: true, action: 'equip' });
|
||||
assert.deepEqual(canPlace(ctx, eq.uid, { area: 'hand', idx: 0 }), { ok: true, action: 'equip' });
|
||||
assert.equal(canPlace(ctx, eq.uid, { area: 'board', row: 9, col: 4 }).ok, false, 'tokens cannot carry items');
|
||||
assert.equal(canPlace(ctx, eq.uid, { area: 'board', row: 9, col: 5 }).ok, false, 'empty tile');
|
||||
assert.deepEqual(canPlace(ctx, eq.uid, { area: 'hand', idx: 7 }), { ok: true, action: 'move' }, 'rearranging the hand');
|
||||
assert.deepEqual(canPlace(ctx, art.uid, { area: 'board', row: 11, col: 6 }), { ok: true, action: 'art' });
|
||||
assert.equal(canPlace(ctx, art.uid, { area: 'hand', idx: 0 }).ok, false, 'arts are not equipped');
|
||||
assert.deepEqual(canPlace(ctx, art.uid, { area: 'hand', idx: 1 }), { ok: true, action: 'swap' }, 'item ↔ item swap in the hand');
|
||||
});
|
||||
test('boardTargets lists legal tiles for highlighting', () => {
|
||||
const m = piece(MELEE);
|
||||
const r = piece(RANGED);
|
||||
const ctx = ctxFor(privWith({ hand: [m, r] }));
|
||||
const lm = boardTargets(ctx, m.uid).legal.map(([a, b]) => tileKey(a, b));
|
||||
const lr = boardTargets(ctx, r.uid).legal.map(([a, b]) => tileKey(a, b));
|
||||
assert.equal(lm.length, STAGE.deployTiles.normal.melee.length);
|
||||
assert.equal(lr.length, STAGE.deployTiles.normal.melee.length + STAGE.deployTiles.normal.rangedOnly.length);
|
||||
assert.ok(!lm.includes('10,4') && lr.includes('10,4'));
|
||||
assert.deepEqual(boardTargets(ctx, 424242), { legal: [], illegal: [] });
|
||||
});
|
||||
test('dropIntent builds protocol-valid g.* messages', async () => {
|
||||
const { validateC2S } = await import('../../shared/protocol.js');
|
||||
// two OTHER items equipped (an identical copy would merge with the incoming one instead: no replacement — see
|
||||
// test/ui/leftovers.test.js)
|
||||
const b = { ...piece(MELEE), row: 9, col: 3, items: [{ uid: 900, id: 'chess_item_1_02_e_a' }, { uid: 901, id: 'chess_item_1_05_e_a' }] };
|
||||
const h = piece(RANGED);
|
||||
const eq = item(EQUIP);
|
||||
const art = item(MAGIC);
|
||||
const ctx = ctxFor(privWith({ board: [b], hand: [{ idx: 0, piece: h }, { idx: 1, piece: eq }, { idx: 2, piece: art }] }));
|
||||
const mv = dropIntent(ctx, h.uid, { area: 'board', row: 10, col: 4 });
|
||||
assert.deepEqual(mv, { t: 'g.move', fields: { uid: h.uid, to: { area: 'board', row: 10, col: 4 } } });
|
||||
const back = dropIntent(ctx, b.uid, { area: 'hand', idx: 6 });
|
||||
assert.deepEqual(back.fields.to, { area: 'hand', idx: 6 });
|
||||
const eqI = dropIntent(ctx, eq.uid, { area: 'board', row: 9, col: 3 });
|
||||
assert.equal(eqI.t, 'g.equip'); assert.equal(eqI.confirmReplace, true, '2 items equipped ⇒ confirm');
|
||||
const eqH = dropIntent(ctx, eq.uid, { area: 'hand', idx: 0 });
|
||||
assert.equal(eqH.confirmReplace, false);
|
||||
const consumable = Object.values(items).find((x) => x.itemType === 'EQUIP' && String(x.kind).startsWith('consume_on_equip'));
|
||||
const cons = item(consumable.id);
|
||||
const ctxC = ctxFor(privWith({ board: [b], hand: [{ idx: 3, piece: cons }] }));
|
||||
assert.equal(dropIntent(ctxC, cons.uid, { area: 'board', row: 9, col: 3 }).confirmReplace, false, 'consumed on equip: nothing is replaced');
|
||||
const ar = dropIntent(ctx, art.uid, { area: 'board', row: 12, col: 6 });
|
||||
assert.deepEqual(ar, { t: 'g.art', fields: { itemUid: art.uid, row: 12, col: 6 } });
|
||||
for (const i of [mv, back, eqI, eqH, ar]) assert.equal(validateC2S({ t: i.t, ...i.fields }), null, i.t);
|
||||
assert.equal(dropIntent(ctx, h.uid, { area: 'board', row: 9, col: 2 }), null);
|
||||
assert.equal(dropIntent(ctx, h.uid, { area: 'outside', clientX: 1, clientY: 1 }), null);
|
||||
});
|
||||
test('indexPieces tolerates junk', () => {
|
||||
const m = indexPieces({ board: [null, { uid: 'x' }, { uid: 5, row: 9, col: 3, kind: 'chess' }], hand: 'nope', temp: [undefined] });
|
||||
assert.equal(m.size, 1);
|
||||
assert.equal(indexPieces(null).size, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('drafts', () => {
|
||||
const players = [{ playerId: 'a', seat: 0 }, { playerId: 'b', seat: 1 }, { playerId: 'c', seat: 2 }];
|
||||
test('normalizeDraft: index or id turn, object or array picks', () => {
|
||||
const d1 = normalizeDraft({ order: ['c', 'a', 'b'], turn: 1, picks: { c: 'band_x' } }, players);
|
||||
assert.equal(d1.turnPid, 'a'); assert.equal(d1.picks.get('c'), 'band_x'); assert.equal(d1.done, false);
|
||||
const d2 = normalizeDraft({ order: ['c', 'a'], turn: 'c', picks: ['band_y', null] }, players);
|
||||
assert.equal(d2.turnPid, 'c'); assert.equal(d2.picks.get('c'), 'band_y'); assert.equal(d2.picks.has('a'), false);
|
||||
const d3 = normalizeDraft({ picks: [{ playerId: 'a', bandId: 'z' }], skipsLeft: { a: 0 } }, players);
|
||||
assert.deepEqual(d3.order, ['a', 'b', 'c']); assert.equal(d3.picks.get('a'), 'z'); assert.equal(d3.skipsLeft.get('a'), 0);
|
||||
const d4 = normalizeDraft(null, []);
|
||||
assert.equal(d4.turnPid, null); assert.equal(d4.done, false);
|
||||
assert.equal(normalizeDraft({ order: ['a'], turn: 5 }).turnPid, null, 'out-of-range index');
|
||||
assert.equal(normalizeDraft({ order: ['a'], picks: { a: 'q' } }).done, true);
|
||||
});
|
||||
test('normalizeSp: picks map, array, takenBy; turn', () => {
|
||||
const sp = normalizeSp({ family: 'bounty', cards: ['e1', { effectId: 'e2' }, { itemId: 'i3', takenBy: 'c' }, 7], turn: 'a', picks: { b: 0 } }, players);
|
||||
assert.equal(sp.cards.length, 4);
|
||||
assert.equal(sp.cards[0].id, 'e1');
|
||||
assert.equal(sp.cards[0].takenBy, 'b');
|
||||
assert.equal(sp.cards[2].takenBy, 'c');
|
||||
assert.equal(sp.pickOf.get('c'), 2);
|
||||
assert.equal(sp.turnPid, 'a');
|
||||
assert.equal(sp.pickedCount, 2);
|
||||
const sp2 = normalizeSp({ cards: [{}, {}], order: ['b', 'a'], turn: 0, picks: [{ playerId: 'b', idx: 1 }, { playerId: 'x', idx: 9 }] }, players);
|
||||
assert.equal(sp2.turnPid, 'b'); assert.equal(sp2.cards[1].takenBy, 'b'); assert.equal(sp2.pickOf.has('x'), false);
|
||||
assert.equal(normalizeSp(null), null);
|
||||
assert.equal(normalizeSp({ cards: new Array(9).fill({}) }).cards.length, 6, 'at most 6 cards');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fields, players, emotes', () => {
|
||||
const pub = {
|
||||
players: [{ playerId: 'p2', seat: 1, name: 'B' }, { playerId: 'p1', seat: 0, name: 'A', fieldId: 'n:p1' }, null],
|
||||
fields: [{ fieldId: 'n:p1', kind: 'normal', players: ['p1'], live: true }, { fieldId: 'n:p2', kind: 'normal', players: ['p2'], live: true },
|
||||
{ fieldId: 'u', kind: 'unite', players: ['p2'], live: false }],
|
||||
};
|
||||
test('sortedPlayers by seat, junk dropped', () => {
|
||||
assert.deepEqual(sortedPlayers(pub).map((p) => p.playerId), ['p1', 'p2']);
|
||||
assert.deepEqual(sortedPlayers(null), []);
|
||||
});
|
||||
test('cycleField wraps over live fields only', () => {
|
||||
assert.equal(cycleField(pub.fields, 'n:p1', 1), 'n:p2');
|
||||
assert.equal(cycleField(pub.fields, 'n:p2', 1), 'n:p1');
|
||||
assert.equal(cycleField(pub.fields, 'n:p1', -1), 'n:p2');
|
||||
assert.equal(cycleField(pub.fields, 'zzz', 1), 'n:p1');
|
||||
assert.equal(cycleField([], 'n:p1', 1), null);
|
||||
});
|
||||
test('fieldLabel / homeFieldId', () => {
|
||||
assert.equal(fieldLabel(pub.fields[0], pub, 'p1'), '自己');
|
||||
assert.equal(fieldLabel(pub.fields[1], pub, 'p1'), 'B');
|
||||
assert.equal(fieldLabel({ fieldId: 'b1', kind: 'boss', players: ['p1', 'p2'] }, pub, 'p1'), '全景');
|
||||
assert.equal(fieldLabel({ fieldId: 'b2', kind: 'boss', players: ['p2'] }, pub, 'p1'), 'B');
|
||||
assert.equal(fieldLabel({ fieldId: 'u', kind: 'unite', players: ['p2'] }, pub, 'p1'), '联防阵地');
|
||||
assert.equal(homeFieldId(pub, 'p1'), 'n:p1');
|
||||
assert.equal(homeFieldId({ ...pub, fields: [{ fieldId: 'b1', kind: 'boss', players: ['p1'], live: true }] }, 'p1'), 'b1');
|
||||
assert.equal(homeFieldId({}, 'zz'), 'n:zz');
|
||||
});
|
||||
test('activeBubbles keeps the newest emote per player within ttl', () => {
|
||||
const now = 10_000;
|
||||
const m = activeBubbles([{ seq: 1, playerId: 'a', id: 'hello', at: 8_000 }, { seq: 2, playerId: 'a', id: 'thanks', at: 9_000 },
|
||||
{ seq: 3, playerId: 'b', id: 'wow', at: 5_000 }, null], now, 3000);
|
||||
assert.equal(m.get('a').id, 'thanks'); assert.equal(m.has('b'), false);
|
||||
assert.equal(activeBubbles(undefined, now).size, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enemies, HUD, stats', () => {
|
||||
const enemies = { e1: { name: '甲', rank: 'NORMAL', stats: { motion: 'WALK' } }, e2: { name: '乙', rank: 'ELITE', acTypes: ['FLY'], stats: { motion: 'FLY' } }, e3: { name: '丙', rank: 'BOSS' } };
|
||||
test('groupEnemies merges and orders boss → bounty → elite → normal', () => {
|
||||
const rows = groupEnemies([{ enemyKey: 'e1', count: 3 }, { enemyKey: 'e1', count: 2 }, { enemyKey: 'e2', count: 1 }, { enemyKey: 'e3', count: 1, tag: 'boss' },
|
||||
{ enemyKey: 'e1', count: 1, tag: 'bounty' }, { bogus: 1 }, null], (k) => enemies[k]);
|
||||
assert.deepEqual(rows.map((r) => `${r.enemyKey}:${r.tag}:${r.count}`), ['e3:boss:1', 'e1:bounty:1', 'e2:null:1', 'e1:null:5']);
|
||||
assert.equal(rows[2].fly, true); assert.deepEqual(rows[2].acTypes, ['FLY']);
|
||||
assert.equal(groupEnemies([{ enemyKey: 'x', count: -4 }])[0].count, 1);
|
||||
});
|
||||
test('factionTypes accepts strings and objects', () => {
|
||||
assert.deepEqual(factionTypes(['FLY', { type: 'TIMES' }, { id: 'SPECIAL' }, 'FLY', 3, null]), ['FLY', 'TIMES', 'SPECIAL']);
|
||||
});
|
||||
test('snapHud / bossFrac', () => {
|
||||
assert.deepEqual(snapHud({ killed: 3, total: 9, dp: 20 }), { killed: 3, total: 9, dp: 20, boss: null });
|
||||
assert.deepEqual(snapHud({ boss: { hp: 50, max: 200 } }).boss, { hp: 50, max: 200 });
|
||||
assert.equal(snapHud(null), null);
|
||||
assert.equal(bossFrac({ hp: 50, max: 200 }), 0.25);
|
||||
assert.equal(bossFrac({ hp: 500, max: 200 }), 1);
|
||||
assert.equal(bossFrac({ hp: 5, max: 0 }), null);
|
||||
assert.equal(bossFrac(null), null);
|
||||
});
|
||||
test('attackInterval, fmtNum, rangeGridBox', () => {
|
||||
assert.equal(attackInterval(1, 100), 1);
|
||||
assert.equal(attackInterval(1.2, 120), 1);
|
||||
assert.equal(attackInterval(0, 100), null);
|
||||
assert.equal(attackInterval(1, 0), 1, 'bad aspd falls back to 100');
|
||||
assert.equal(fmtNum(12345), '12,345');
|
||||
assert.equal(fmtNum(2_310_000), '231万');
|
||||
assert.equal(fmtNum(150_000), '15.0万');
|
||||
assert.equal(fmtNum('x'), '—');
|
||||
const box = rangeGridBox([[1, 0], [0, 1], [-1, 2]]);
|
||||
assert.equal(box.rows, 3); assert.equal(box.cols, 3); assert.ok(box.cells.has('0,1'));
|
||||
const mir = rangeGridBox([[0, 2]], true);
|
||||
assert.ok(mir.cells.has('0,-2'));
|
||||
assert.equal(rangeGridBox(null).cells.size, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('keyboard & settings', () => {
|
||||
test('shortcutFor', () => {
|
||||
assert.equal(shortcutFor({ key: 'r', code: 'KeyR' }), 'refresh');
|
||||
assert.equal(shortcutFor({ key: 'F', code: 'KeyF' }), 'freeze');
|
||||
assert.equal(shortcutFor({ key: 'd' }), 'levelUp');
|
||||
assert.equal(shortcutFor({ key: ' ', code: 'Space' }), 'ready');
|
||||
assert.equal(shortcutFor({ key: 'Escape' }), 'escape');
|
||||
assert.equal(shortcutFor({ key: 'r', ctrlKey: true }), null);
|
||||
assert.equal(shortcutFor({ key: 'r', repeat: true }), null);
|
||||
assert.equal(shortcutFor({ key: 'Escape', repeat: true }), 'escape');
|
||||
assert.equal(shortcutFor({ key: 'r', target: { tagName: 'input' } }), null);
|
||||
assert.equal(shortcutFor({ key: ' ', code: 'Space', target: { tagName: 'BUTTON' } }), 'ready', 'space readies even with a HUD button focused');
|
||||
assert.equal(shortcutFor({ key: ' ', code: 'Space', target: { tagName: 'TEXTAREA' } }), null);
|
||||
assert.equal(shortcutFor({ key: 'd', target: { tagName: 'DIV', isContentEditable: true } }), null);
|
||||
assert.equal(shortcutFor({ key: 'x' }), null);
|
||||
assert.equal(shortcutFor(null), null);
|
||||
});
|
||||
test('sanitizeSettings', () => {
|
||||
assert.deepEqual(sanitizeSettings(null), { ...DEFAULT_SETTINGS });
|
||||
assert.deepEqual(sanitizeSettings({ bgm: 3, sfx: -1, muted: 'yes', damageNumbers: false, quality: 'ultra' }),
|
||||
{ bgm: 1, sfx: 0, muted: false, damageNumbers: false, quality: 'high' });
|
||||
assert.equal(sanitizeSettings({ bgm: 0.333 }).bgm, 0.33);
|
||||
assert.equal(sanitizeSettings({ quality: 'low' }).quality, 'low');
|
||||
});
|
||||
});
|
||||
|
||||
describe('result', () => {
|
||||
test('normalizeResult fills from m.public and sorts by seat', () => {
|
||||
const pub = { lastRound: 14, difficulty: 'HARD', players: [{ playerId: 'b', seat: 1, name: 'B', lp: 4, bandId: 'band_x' }, { playerId: 'a', seat: 0, name: 'A' }] };
|
||||
const r = normalizeResult({ victory: true, roundsPassed: 14, players: [{ playerId: 'b', title: 'comment_2', lineup: [{ id: 'c1' }], stats: { kills: 3 } }, { playerId: 'a', title: { id: 'comment_1', name: '卫戍之星' } }, null] }, pub);
|
||||
assert.equal(r.victory, true);
|
||||
assert.deepEqual(r.players.map((p) => p.playerId), ['a', 'b']);
|
||||
assert.equal(r.players[1].bandId, 'band_x');
|
||||
assert.equal(r.players[1].lp, 4);
|
||||
assert.deepEqual(r.players[1].title, { id: 'comment_2' });
|
||||
assert.equal(r.players[0].title.name, '卫戍之星');
|
||||
assert.equal(r.difficulty, 'HARD');
|
||||
const empty = normalizeResult(null, null);
|
||||
assert.equal(empty.victory, false); assert.deepEqual(empty.players, []); assert.equal(empty.lastRound, 14);
|
||||
assert.equal(normalizeResult({ players: [{ playerId: 'x', roundsPassed: 9 }] }, null).roundsPassed, 9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepCamera (research 09 §1.2: the Final Assault prep on the own half of the boss field)', () => {
|
||||
const pl = (id, seat, alive = true) => ({ playerId: id, seat, alive });
|
||||
const pub = (round, players) => ({ round, bossRound: 14, hiddenRound: 15, players });
|
||||
test('normal rounds: the own board', () => {
|
||||
const c = prepCamera(pub(6, [pl('a', 0), pl('b', 1)]), 'b');
|
||||
assert.deepEqual(c, { kind: 'prep', opts: { rect: { ...GEO.NORMAL_RECT }, side: 'L' } });
|
||||
assert.equal(prepCamera(null, 'a').kind, 'prep');
|
||||
assert.equal(prepCamera({ round: 14, bossRound: null, hiddenRound: null, players: [] }, 'a').kind, 'prep');
|
||||
});
|
||||
test('boss / hidden round prep: bossPrep on the side of the server pairing (alive players by seat, two by two)', () => {
|
||||
const players = [pl('d', 3), pl('a', 0), pl('c', 2), pl('b', 1)];
|
||||
for (const round of [14, 15]) {
|
||||
const sides = Object.fromEntries(players.map((p) => [p.playerId, prepCamera(pub(round, players), p.playerId)]));
|
||||
assert.deepEqual(Object.fromEntries(Object.entries(sides).map(([k, v]) => [k, `${v.kind}:${v.opts.side}`])),
|
||||
{ a: 'bossPrep:L', b: 'bossPrep:R', c: 'bossPrep:L', d: 'bossPrep:R' });
|
||||
}
|
||||
// somebody left (eliminated): the pairs close up exactly like the server's (Match._quit re-plans the boss waves)
|
||||
const left = [pl('a', 0), pl('b', 1, false), pl('c', 2), pl('d', 3)];
|
||||
const groups = pairPlayers(left.filter((p) => p.alive).map((p) => ({ ...p })));
|
||||
for (const g of groups) g.forEach((p, j) => assert.equal(prepCamera(pub(14, left), p.playerId).opts.side, j === 1 ? 'R' : 'L', p.playerId));
|
||||
assert.equal(prepCamera(pub(14, left), 'c').opts.side, 'R', 'c moves up to the right half');
|
||||
assert.equal(prepCamera(pub(14, left), 'b').kind, 'prep', 'an eliminated player has no half');
|
||||
// solo / a lone last player: the left half
|
||||
assert.deepEqual(prepCamera(pub(14, [pl('a', 0)]), 'a'), { kind: 'bossPrep', opts: { side: 'L' } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,405 @@
|
||||
// Browser E2E of the client leftovers (puppeteer-core + system Chrome; opt-in: SP_E2E=1, ~3 min):
|
||||
//
|
||||
// SP_E2E=1 node --test test/ui/leftovers.e2e.test.js
|
||||
//
|
||||
// Mock harness (public/dev/game-mock.html, in-process server):
|
||||
// 1. a third item dropped on a full operator opens the equip-replace dialog (both equipped items with icon / name /
|
||||
// effect); 取消 and Esc send nothing and change nothing; picking the 2nd item + 确认替换 sends g.equip {replaceUid};
|
||||
// 2. 最终攻势: the countdown gauge counts the level's 120 s; the red DOT warning shows the drain start once the level time
|
||||
// ran out and a live indicator while the team LP drains (m.public.overtimeAt);
|
||||
// 4. solo battle: the pause button sends g.pause {on}, m.public.paused shows the overlay and freezes the countdown,
|
||||
// Space resumes; no pause control in co-op or in prep.
|
||||
// Real server (test/e2e/fastServer.mjs, real clicks and canvas drags):
|
||||
// 1+4. a solo run: equip two items on an operator, drop a third → dialog → 取消 (nothing sent) → pick the OLDER item →
|
||||
// the real engine destroys exactly that one; then ready → the real local battle → pause (the battle clock and the
|
||||
// countdown stand still) → 继续作战;
|
||||
// 3. the server restarts while the briefing is on screen → 「服务器会话已重置,上一局模拟已结束」 and the lobby.
|
||||
// Screenshots: test/e2e/out/leftover-*.png. Every page must end without console / page / request errors (the restart
|
||||
// test tolerates only the socket errors of the server's downtime).
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Client, ROOT, OUT, CHROME, sleep, hasChrome, startRealServer, problemsOf } from '../e2e/client.mjs';
|
||||
|
||||
const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets'));
|
||||
const ITEMS = (() => {
|
||||
const raw = JSON.parse(readFileSync(path.join(ROOT, 'data/items.json'), 'utf8'));
|
||||
const list = Array.isArray(raw) ? raw : Object.values(raw.items || raw);
|
||||
return new Map(list.filter((x) => x && x.id).map((x) => [x.id, x]));
|
||||
})();
|
||||
const attaches = (id) => !String(ITEMS.get(id)?.kind || '').startsWith('consume_on_equip');
|
||||
/** Three distinct plain equipment items (attach, mergeable pairs never complete: all different). */
|
||||
const KIT_ITEMS = [...ITEMS.values()].filter((i) => i.itemType === 'EQUIP' && !i.isGolden && attaches(i.id) && i.tier === 1).map((i) => i.id).sort().slice(0, 3);
|
||||
|
||||
describe('client leftovers — mock harness', { skip: !ENABLED && 'set SP_E2E=1 (Chrome + public/assets)' }, () => {
|
||||
let srv;
|
||||
let browser;
|
||||
let base;
|
||||
before(async () => {
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
base = `http://127.0.0.1:${srv.port}`;
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-sandbox', '--force-device-scale-factor=1'] });
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
});
|
||||
after(async () => { await browser?.close(); await srv?.close(); });
|
||||
|
||||
async function open(query, { w = 1920, h = 1080 } = {}) {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: w, height: h });
|
||||
const problems = [];
|
||||
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
page.on('requestfailed', (r) => problems.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`));
|
||||
page.on('response', (r) => { if (r.status() >= 400) problems.push(`http ${r.status()}: ${r.url()}`); });
|
||||
await page.goto(`${base}/dev/game-mock.html?shot=1&${query}`, { waitUntil: 'networkidle0' });
|
||||
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)'), { timeout: 15000 });
|
||||
await sleep(900);
|
||||
return { page, problems };
|
||||
}
|
||||
const mockPriv = (page) => page.evaluate(() => JSON.parse(JSON.stringify(globalThis.__MOCK__.S().priv)));
|
||||
const mockReqs = (page, t) => page.evaluate((t) => globalThis.__MOCK__.S().requests.filter((r) => r[0] === t), t);
|
||||
const center = (page, sel) => page.$eval(sel, (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });
|
||||
const drag = async (page, from, to) => {
|
||||
await page.mouse.move(from.x, from.y);
|
||||
await page.mouse.down();
|
||||
for (let i = 1; i <= 8; i++) await page.mouse.move(from.x + ((to.x - from.x) * i) / 8, from.y + ((to.y - from.y) * i) / 8);
|
||||
await sleep(80);
|
||||
await page.mouse.up();
|
||||
await sleep(500);
|
||||
};
|
||||
|
||||
test('1. equip-replace dialog: lists both equipped items, cancel / Esc change nothing, the pick is sent as replaceUid', async () => {
|
||||
// DOM drag targets exist in the fallback view (the engine's own drag is covered by the real-server test below)
|
||||
const { page, problems } = await open('phase=PREP&render=fallback');
|
||||
let s = await mockPriv(page);
|
||||
const target = s.board.find((p) => p.kind === 'chess' && (p.items || []).length === 2);
|
||||
const item = s.hand.find((p) => p && p.kind === 'item' && attaches(p.id) && !target.items.some((x) => x.id === p.id));
|
||||
assert.ok(target && item, 'the mock has a full operator and an attachable hand item');
|
||||
const dropOnTarget = async () => {
|
||||
await drag(page, await center(page, `.ff-piece[data-uid="${item.uid}"]`), await center(page, `.ff-piece[data-uid="${target.uid}"]`));
|
||||
await page.waitForSelector('.eqr .eqr__opt', { timeout: 4000 });
|
||||
};
|
||||
|
||||
await dropOnTarget();
|
||||
const dlg = await page.evaluate(() => ({
|
||||
opts: [...document.querySelectorAll('.eqr__opt')].map((el) => ({
|
||||
uid: Number(el.dataset.uid), name: el.querySelector('.eqr__name')?.textContent, desc: (el.querySelector('.eqr__desc')?.textContent || '').length,
|
||||
icon: !!el.querySelector('.uthumb'), checked: el.getAttribute('aria-checked'),
|
||||
})),
|
||||
incoming: document.querySelector('.eqr__card--new .eqr__name')?.textContent,
|
||||
okDisabled: document.querySelector('.eqr__ok')?.disabled,
|
||||
lead: document.querySelector('.eqr__lead')?.textContent || '',
|
||||
}));
|
||||
assert.deepEqual(dlg.opts.map((o) => o.uid), target.items.map((x) => x.uid), 'both equipped items, oldest first');
|
||||
assert.deepEqual(dlg.opts.map((o) => o.name), target.items.map((x) => ITEMS.get(x.id).name));
|
||||
assert.ok(dlg.opts.every((o) => o.icon && o.desc > 0 && o.checked === 'false'), 'icons + effects, nothing preselected');
|
||||
assert.equal(dlg.incoming, ITEMS.get(item.id).name);
|
||||
assert.equal(dlg.okDisabled, true, '确认替换 needs a pick');
|
||||
assert.match(dlg.lead, /被替换的装备将被销毁/);
|
||||
assert.equal(await page.$('.uframe__btn--destroy'), null, 'no 销毁 anywhere for equipped items');
|
||||
await page.screenshot({ path: path.join(OUT, 'leftover-replace-mock.png') });
|
||||
|
||||
// 取消: nothing sent, nothing changed
|
||||
await page.click('.eqr__cancel');
|
||||
await page.waitForFunction(() => !document.querySelector('.eqr'), { timeout: 3000 });
|
||||
// Esc: same
|
||||
await dropOnTarget();
|
||||
await page.click('.eqr__opt:nth-child(1)');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForFunction(() => !document.querySelector('.eqr'), { timeout: 3000 });
|
||||
await sleep(300);
|
||||
assert.deepEqual(await mockReqs(page, 'g.equip'), [], 'cancel / Esc: no g.equip');
|
||||
s = await mockPriv(page);
|
||||
assert.deepEqual(s.board.find((p) => p.uid === target.uid).items.map((x) => x.uid), target.items.map((x) => x.uid));
|
||||
assert.ok(s.hand.some((p) => p && p.uid === item.uid), 'the item stays in the hand');
|
||||
|
||||
// pick the SECOND (newer) item → 确认替换
|
||||
await dropOnTarget();
|
||||
await page.click('.eqr__opt:nth-child(2)');
|
||||
await page.waitForFunction(() => document.querySelector('.eqr__opt:nth-child(2)')?.getAttribute('aria-checked') === 'true' && !document.querySelector('.eqr__ok').disabled, { timeout: 2000 });
|
||||
await sleep(350); // the 将被销毁 flag slides in
|
||||
await page.screenshot({ path: path.join(OUT, 'leftover-replace-mock-picked.png') });
|
||||
await page.click('.eqr__ok');
|
||||
await page.waitForFunction(() => !document.querySelector('.eqr'), { timeout: 3000 });
|
||||
await sleep(400);
|
||||
assert.deepEqual(await mockReqs(page, 'g.equip'), [['g.equip', { itemUid: item.uid, targetUid: target.uid, replaceUid: target.items[1].uid }]]);
|
||||
s = await mockPriv(page);
|
||||
assert.deepEqual(s.board.find((p) => p.uid === target.uid).items.map((x) => x.uid), [target.items[0].uid, item.uid], 'the picked one is destroyed');
|
||||
// a loose item still offers 销毁 (tap → underframe)
|
||||
const loose = s.hand.find((p) => p && p.kind === 'item');
|
||||
if (loose) {
|
||||
await page.click(`.ff-piece[data-uid="${loose.uid}"]`);
|
||||
await page.waitForSelector('.uframe__btn--destroy', { timeout: 3000 });
|
||||
}
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test("2. 最终攻势: the 120 s countdown gauge, then the red DOT overtime warning (drain start + live drain)", async () => {
|
||||
// start of the round: 95 s of 120 left → 4 of 5 bars, no warning yet
|
||||
let { page, problems } = await open('phase=FINAL_ASSAULT');
|
||||
const lit = await page.$$eval('.gtop__right .countdown__gauge i.on', (els) => els.length);
|
||||
assert.equal(lit, 4, 'gauge counts the 120 s level time');
|
||||
assert.equal(await page.$('.otwarn'), null);
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
|
||||
// the level time ran out: the drain starts in ~18 s
|
||||
({ page, problems } = await open('phase=FINAL_ASSAULT&variant=overtime'));
|
||||
await page.waitForSelector('.otwarn[data-state="pending"]', { timeout: 3000 });
|
||||
const pend = await page.$eval('.otwarn', (el) => el.textContent);
|
||||
assert.match(pend, /DOT/);
|
||||
assert.match(pend, /\d+\s*秒后全队生命值开始流失/);
|
||||
const secs = Number(pend.match(/(\d+)\s*秒后/)[1]);
|
||||
assert.ok(secs > 10 && secs <= 18, `drain in ${secs} s`);
|
||||
assert.equal(await page.$eval('.gtop__right .countdown', (el) => el.getAttribute('aria-label')), '剩余0秒');
|
||||
await page.screenshot({ path: path.join(OUT, 'leftover-fa-overtime.png') });
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
|
||||
// draining: live indicator, the team LP turns red, the lost counter moves every second
|
||||
({ page, problems } = await open('phase=FINAL_ASSAULT&variant=drain'));
|
||||
await page.waitForSelector('.otwarn[data-state="drain"]', { timeout: 3000 });
|
||||
const d1 = await page.$eval('.otwarn', (el) => el.textContent);
|
||||
assert.match(d1, /超时 · 生命值 −1\/秒/);
|
||||
assert.ok(await page.$('.gtop__center .lp--danger'), 'the team LP tower turns red');
|
||||
const lost = async () => Number((await page.$eval('.otwarn__lost b', (el) => el.textContent)) || 0);
|
||||
const l1 = await lost();
|
||||
await sleep(2100);
|
||||
const l2 = await lost();
|
||||
assert.ok(l2 >= l1 + 2, `the drain counter is live (${l1} → ${l2})`);
|
||||
await page.screenshot({ path: path.join(OUT, 'leftover-fa-drain.png') });
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
|
||||
({ page, problems } = await open('phase=HIDDEN_CORE&variant=drain'));
|
||||
await page.waitForSelector('.otwarn[data-state="drain"]', { timeout: 3000 });
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('4. solo battle: pause button → g.pause, paused overlay freezes the clock, Space resumes; hidden in co-op / prep', async () => {
|
||||
let { page, problems } = await open('phase=COMBAT&variant=solo');
|
||||
await page.waitForSelector('[data-testid="pause"]', { timeout: 3000 });
|
||||
await page.click('[data-testid="pause"]');
|
||||
await page.waitForSelector('.pauseov', { timeout: 3000 });
|
||||
assert.deepEqual(await mockReqs(page, 'g.pause'), [['g.pause', { on: true }]]);
|
||||
const cd = () => page.$eval('.gtop__right .countdown', (el) => el.getAttribute('aria-label'));
|
||||
const c1 = await cd();
|
||||
await sleep(1600);
|
||||
assert.equal(await cd(), c1, 'the countdown stands still while paused');
|
||||
assert.equal(await page.$eval('[data-testid="pause"]', (el) => el.getAttribute('aria-pressed')), 'true');
|
||||
await page.screenshot({ path: path.join(OUT, 'leftover-pause-mock.png') });
|
||||
await page.keyboard.press('Space');
|
||||
await page.waitForFunction(() => !document.querySelector('.pauseov'), { timeout: 3000 });
|
||||
assert.deepEqual(await mockReqs(page, 'g.pause'), [['g.pause', { on: true }], ['g.pause', { on: false }]]);
|
||||
// the overlay's 继续作战 resumes too
|
||||
await page.keyboard.press('Space');
|
||||
await page.waitForSelector('.pauseov', { timeout: 3000 });
|
||||
await page.click('.pauseov .btn--primary');
|
||||
await page.waitForFunction(() => !document.querySelector('.pauseov'), { timeout: 3000 });
|
||||
assert.equal((await mockReqs(page, 'g.pause')).length, 4);
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
|
||||
for (const q of ['phase=COMBAT', 'phase=FINAL_ASSAULT', 'phase=PREP&variant=solo']) {
|
||||
({ page, problems } = await open(q));
|
||||
assert.equal(await page.$('[data-testid="pause"]'), null, `${q}: no pause control`);
|
||||
if (q !== 'phase=PREP&variant=solo') {
|
||||
await page.keyboard.press('Space');
|
||||
await sleep(300);
|
||||
assert.deepEqual(await mockReqs(page, 'g.pause'), [], `${q}: Space does not pause`);
|
||||
}
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('client leftovers — real server', { skip: !ENABLED && 'set SP_E2E=1 (Chrome + public/assets)' }, () => {
|
||||
test('1+4. solo: equip-replace dialog on the real engine, then pause / resume the real local battle', { timeout: 6 * 60 * 1000 }, async () => {
|
||||
assert.equal(KIT_ITEMS.length, 3);
|
||||
const CHESS = 'chess_char_1_01_a'; // a cheap ranged operator (any legal tile)
|
||||
const srv = await startRealServer({ fast: { timerScale: 0.5, combatSpeed: 2, startRound: 1, chess: [CHESS], items: KIT_ITEMS } });
|
||||
const P = (await import('puppeteer-core')).default;
|
||||
const c = new Client(P, srv.base, 'solo', { prefix: 'leftover' });
|
||||
try {
|
||||
await c.open();
|
||||
await c.enter('替换');
|
||||
await c.click('.mode-card', '独立模拟');
|
||||
await c.click('.diff-card', '标准模拟');
|
||||
await c.click('.create-box button', '开始独立模拟');
|
||||
await c.waitFor((s) => !!s.room, 'solo room');
|
||||
if (!(await c.st()).phase) await c.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
|
||||
await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
|
||||
await c.click('.brief__foot .btn--primary', '准备就绪');
|
||||
await c.waitFor((s) => s.phase === 'BAND_DRAFT', 'band draft', 30000);
|
||||
await c.click('.dband', null, { nth: 1 });
|
||||
await c.click('.draft-detail__btns .btn--primary', '确认选择');
|
||||
await c.waitFor((s) => s.phase === 'PREP' && !s.ready && s.hand >= 4, 'prep with the starter kit', 60000);
|
||||
await sleep(1800);
|
||||
await c.hookRequests();
|
||||
|
||||
// the operator onto the board (drag → direction wheel)
|
||||
const [unit] = (await c.handPieces('chess')).filter((p) => p.id === CHESS);
|
||||
assert.ok(unit, 'the kit operator is in the hand');
|
||||
const tile = await c.freeTileFor(unit.uid);
|
||||
await c.drag(await c.piecePoint(unit.uid), await c.tilePoint(tile.row, tile.col));
|
||||
await c.page.waitForSelector('.fwheel__dia', { timeout: 4000 });
|
||||
await c.swipe('RIGHT');
|
||||
await c.waitFor((s) => s.board > 0, 'placed', 8000);
|
||||
await sleep(700);
|
||||
|
||||
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;
|
||||
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));
|
||||
await sleep(500);
|
||||
if (await c.exists('.eqr') || !(await handItem(it.id))) return;
|
||||
}
|
||||
};
|
||||
// two items equipped normally (no dialog)
|
||||
for (const id of KIT_ITEMS.slice(0, 2)) {
|
||||
const it = await handItem(id);
|
||||
assert.ok(it, `${id} in the hand`);
|
||||
await dropItem(it);
|
||||
assert.equal(await c.exists('.eqr'), false, 'a free slot: no dialog');
|
||||
await c.page.waitForFunction((uid, id) => (globalThis.__SP__.store.get().match.private.board.find((p) => p.uid === uid)?.items || []).some((x) => x.id === id), { timeout: 5000 }, unit.uid, id);
|
||||
}
|
||||
const equipped = await itemsOf();
|
||||
assert.deepEqual(equipped.map((x) => x.id), KIT_ITEMS.slice(0, 2));
|
||||
const third = await handItem(KIT_ITEMS[2]);
|
||||
|
||||
// the third: dialog → 取消 → nothing sent / changed
|
||||
await dropItem(third);
|
||||
await c.page.waitForSelector('.eqr .eqr__opt', { timeout: 4000 });
|
||||
assert.deepEqual(await c.page.$$eval('.eqr__opt', (els) => els.map((el) => Number(el.dataset.uid))), equipped.map((x) => x.uid));
|
||||
assert.deepEqual(await c.page.$$eval('.eqr__opt .eqr__name', (els) => els.map((el) => el.textContent)), equipped.map((x) => ITEMS.get(x.id).name));
|
||||
await c.shot('replace-real');
|
||||
const sentBefore = (await c.requests('g.equip')).length;
|
||||
await c.click('.eqr__cancel');
|
||||
await c.page.waitForFunction(() => !document.querySelector('.eqr'), { timeout: 3000 });
|
||||
await sleep(500);
|
||||
assert.equal((await c.requests('g.equip')).length, sentBefore, '取消: no g.equip');
|
||||
assert.deepEqual(await itemsOf(), equipped, '取消: unchanged');
|
||||
assert.ok(await handItem(KIT_ITEMS[2]), '取消: the item stays in the hand');
|
||||
|
||||
// again → pick the OLDER item (not the server's default either way: explicit replaceUid) → 确认替换
|
||||
await dropItem(third);
|
||||
await c.page.waitForSelector('.eqr .eqr__opt', { timeout: 4000 });
|
||||
await c.click('.eqr__opt', null, { nth: 0 });
|
||||
await c.click('.eqr__ok');
|
||||
await c.page.waitForFunction((uid, id) => (globalThis.__SP__.store.get().match.private.board.find((p) => p.uid === uid)?.items || []).some((x) => x.id === id), { timeout: 5000 }, unit.uid, KIT_ITEMS[2]);
|
||||
const last = (await c.requests('g.equip')).pop();
|
||||
assert.deepEqual(last[1], { itemUid: third.uid, targetUid: unit.uid, replaceUid: equipped[0].uid });
|
||||
const after = await itemsOf();
|
||||
assert.deepEqual(after.map((x) => x.uid).sort(), [equipped[1].uid, third.uid].sort(), 'the engine destroyed exactly the picked item');
|
||||
assert.equal(await handItem(KIT_ITEMS[0]), null, 'the replaced item is not returned to the hand');
|
||||
await c.shot('replace-real-after');
|
||||
|
||||
// 4) ready → the local battle → pause / resume
|
||||
await c.click('.readybtn');
|
||||
await c.waitFor((s) => s.phase === 'COMBAT', 'combat', 60000);
|
||||
await c.page.waitForSelector('[data-testid="pause"]', { visible: true, timeout: 15000 });
|
||||
await sleep(1500);
|
||||
const btime = () => c.page.evaluate(() => {
|
||||
const e = [...(globalThis.__SP_RUNNER__?._entries?.values() || [])].find((x) => x.own && x.battle);
|
||||
return e ? e.battle.time : null;
|
||||
});
|
||||
await c.click('[data-testid="pause"]');
|
||||
await c.page.waitForFunction(() => globalThis.__SP__.store.get().match.public?.paused === true, { timeout: 5000 });
|
||||
await c.page.waitForSelector('.pauseov', { timeout: 3000 });
|
||||
await sleep(400);
|
||||
const t1 = await btime();
|
||||
const cd1 = await c.page.$eval('.gtop__right .countdown', (el) => el.getAttribute('aria-label'));
|
||||
await sleep(2000);
|
||||
assert.equal(await btime(), t1, 'the local battle clock stands still');
|
||||
assert.equal(await c.page.$eval('.gtop__right .countdown', (el) => el.getAttribute('aria-label')), cd1, 'the countdown stands still');
|
||||
assert.deepEqual((await c.requests('g.pause')).map((r) => r[1]), [{ on: true }]);
|
||||
await c.shot('pause-real');
|
||||
await c.click('.pauseov .btn--primary', '继续作战');
|
||||
await c.page.waitForFunction(() => globalThis.__SP__.store.get().match.public?.paused === false, { timeout: 5000 });
|
||||
await c.page.waitForFunction(() => !document.querySelector('.pauseov'), { timeout: 3000 });
|
||||
await sleep(1200);
|
||||
const t2 = await btime();
|
||||
assert.ok(t2 == null || t2 > t1, `the battle runs again (${t1} → ${t2})`);
|
||||
assert.deepEqual((await c.requests('g.pause')).map((r) => r[1]), [{ on: true }, { on: false }]);
|
||||
await c.shot('pause-real-resumed');
|
||||
assert.deepEqual(problemsOf([c]), []);
|
||||
} finally {
|
||||
if (c.problems.length) console.log(c.problems.slice(0, 20).join('\n'));
|
||||
await c.close();
|
||||
await srv.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('3. server restart while a match is on screen: toast + back to the lobby', { timeout: 3 * 60 * 1000 }, async () => {
|
||||
let srv = await startRealServer();
|
||||
const port = srv.port;
|
||||
const P = (await import('puppeteer-core')).default;
|
||||
const c = new Client(P, srv.base, 'restart', { prefix: 'leftover' });
|
||||
try {
|
||||
await c.open();
|
||||
await c.enter('重启');
|
||||
await c.click('.mode-card', '独立模拟');
|
||||
await c.click('.diff-card', '标准模拟');
|
||||
await c.click('.create-box button', '开始独立模拟');
|
||||
await c.waitFor((s) => !!s.room, 'solo room');
|
||||
if (!(await c.st()).phase) await c.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
|
||||
await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
|
||||
const before = await c.st();
|
||||
await sleep(600);
|
||||
// an imperative dialog left open (the app's own module instance) must not survive onto the lobby
|
||||
await c.page.evaluate(() => { import('/js/ui/components.js').then((m) => { globalThis.__e2eDlg = m.confirmDialog({ title: '测试对话框', text: '服务器重启前打开' }); }); });
|
||||
await c.page.waitForSelector('.modal', { timeout: 3000 });
|
||||
|
||||
// a crash / kill: no room.closed reaches the client — only the new session's welcome tells
|
||||
await srv.stop({ hard: true });
|
||||
await sleep(800);
|
||||
srv = await startRealServer({ port });
|
||||
await c.page.waitForFunction(() => [...document.querySelectorAll('.toast')].some((t) => t.textContent.includes('服务器会话已重置,上一局模拟已结束')), { timeout: 30000 });
|
||||
await c.page.waitForSelector('.lobby-screen', { timeout: 10000 });
|
||||
const s = await c.st();
|
||||
assert.notEqual(s.me, before.me, 'a new server session');
|
||||
assert.equal(s.room, null);
|
||||
assert.equal(s.phase, null);
|
||||
assert.equal(await c.exists('.modal'), false, 'no stale dialog');
|
||||
assert.equal(await c.page.evaluate(() => globalThis.__e2eDlg), false, 'the dismissed confirm resolved as cancelled');
|
||||
assert.equal(await c.exists('.awayov'), false);
|
||||
const restoring = await c.page.evaluate(() => globalThis.__SP__.store.get().ui.restoring);
|
||||
assert.equal(restoring, false);
|
||||
await c.shot('restart');
|
||||
// the lobby works on the new server: a new solo run starts
|
||||
await c.click('.mode-card', '独立模拟');
|
||||
await c.click('.diff-card', '标准模拟');
|
||||
await c.click('.create-box button', '开始独立模拟');
|
||||
await c.waitFor((x) => !!x.room, 'a new solo room on the restarted server');
|
||||
if (!(await c.st()).phase) await c.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
|
||||
await c.waitFor((x) => x.phase === 'INFO_CHECK', 'briefing on the restarted server', 30000);
|
||||
// a graceful stop (Ctrl+C / SIGTERM): room.closed 'shutdown' says so first, the same clean way back — and only once
|
||||
const resetToast = () => c.page.evaluate(() => [...document.querySelectorAll('.toast')].some((t) => t.textContent.includes('服务器会话已重置')));
|
||||
await c.page.waitForFunction(() => ![...document.querySelectorAll('.toast')].some((t) => t.textContent.includes('服务器会话已重置')), { timeout: 20000 });
|
||||
await srv.stop();
|
||||
await c.page.waitForFunction(() => [...document.querySelectorAll('.toast')].some((t) => t.textContent.includes('服务器维护中')), { timeout: 15000 });
|
||||
await c.page.waitForSelector('.lobby-screen', { timeout: 10000 });
|
||||
srv = await startRealServer({ port });
|
||||
await c.waitFor((x) => x.room == null && x.phase == null, 'lobby after the graceful restart', 30000);
|
||||
await c.page.waitForFunction(() => globalThis.__SP__.net.status === 'online', { timeout: 30000 });
|
||||
await sleep(800);
|
||||
assert.equal(await resetToast(), false, 'the room was already closed by room.closed: no second notice');
|
||||
await c.shot('restart-graceful');
|
||||
// only the downtime's socket errors are expected
|
||||
const other = c.problems.filter((p) => !/WebSocket|ERR_CONNECTION_REFUSED|\/ws\b|net::ERR_/.test(p));
|
||||
assert.deepEqual(other, []);
|
||||
} finally {
|
||||
if (c.problems.length) console.log(c.problems.slice(0, 20).join('\n'));
|
||||
await c.close();
|
||||
await srv.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
// Client leftovers (unit level; the browser side is test/ui/leftovers.e2e.test.js):
|
||||
// 1. a third item dropped on a full operator → the equip-replace dialog's data (ui/equipReplace.js) and the intent it
|
||||
// sends (g.equip {itemUid, targetUid, replaceUid}) — end to end against the real match engine: the picked item is
|
||||
// destroyed, the other stays; 销毁 is never offered for an equipped item (ui/facing.js itemDestroyable);
|
||||
// 2. boss rounds: the level's 120 s countdown total (gameLogic phaseTotalSeconds) and the overtime DOT warning states
|
||||
// from m.public.overtimeAt (ui/matchStatus.js overtimeState);
|
||||
// 3. a welcome with a new playerId while a room / match was on screen → 「服务器会话已重置…」 (store.js
|
||||
// sessionResetNotice), stale dialogs dismissed (components.js closeAllDialogs);
|
||||
// 4. the solo pause control (pauseAvailable / pauseIntent / frozen clocks).
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DATA, makeMatch, give, giveItem, legalTileFor } from '../match/harness.js';
|
||||
import { placementContext, dropIntent, equipMerges, phaseTotalSeconds, countdownState } from '../../public/js/ui/gameLogic.js';
|
||||
import { underframeActions, itemDestroyable } from '../../public/js/ui/facing.js';
|
||||
import { replaceRequest, replaceIntent } from '../../public/js/ui/equipReplace.js';
|
||||
import {
|
||||
overtimeState, bossLevelSeconds, overtimeDrainPerSec, pauseAvailable, pauseIntent, isPaused, frozenNow, remainAt, OVERTIME_WARN_BEFORE,
|
||||
} from '../../public/js/ui/matchStatus.js';
|
||||
import { sessionResetNotice, emptyMatch } from '../../public/js/store.js';
|
||||
import { confirmDialog, alertDialog, closeAllDialogs } from '../../public/js/ui/components.js';
|
||||
import { validateC2S } from '../../shared/protocol.js';
|
||||
import { PHASE, GEO } from '../../shared/constants.js';
|
||||
|
||||
const getChess = (id) => DATA.chess[id] || null;
|
||||
const getItem = (id) => DATA.items[id] || null;
|
||||
const getToken = (id) => DATA.tokens[id] || null;
|
||||
const STAGE = DATA.stages.act2autochess_m01;
|
||||
const ctxOf = (priv) => placementContext({ priv, stage: STAGE, editable: true, getChess, getToken, getItem });
|
||||
const EQUIP = Object.values(DATA.items).filter((i) => i.itemType === 'EQUIP' && !i.isGolden && !String(i.kind || '').startsWith('consume_on_equip'))
|
||||
.map((i) => i.id).sort();
|
||||
|
||||
describe('1. equip-replace dialog', () => {
|
||||
const RANGED = 'chess_char_1_01_a';
|
||||
const [A, B, C] = EQUIP;
|
||||
const priv = {
|
||||
alive: true, ready: false, funds: 10, deployCap: 8, deployCount: 1,
|
||||
board: [{ uid: 1, kind: 'chess', id: RANGED, row: 9, col: 3, items: [{ uid: 11, id: A }, { uid: 12, id: B }] }],
|
||||
hand: [{ uid: 3, kind: 'item', id: C }, ...new Array(GEO.HAND_SIZE - 1).fill(null)],
|
||||
temp: new Array(GEO.TEMP_SIZE).fill(null),
|
||||
shop: { slots: [] },
|
||||
};
|
||||
const ctx = ctxOf(priv);
|
||||
|
||||
test('a third item on a full operator asks which equipped item to replace (both listed, with names)', () => {
|
||||
const intent = dropIntent(ctx, 3, { area: 'board', row: 9, col: 3 });
|
||||
assert.equal(intent.t, 'g.equip');
|
||||
assert.equal(intent.confirmReplace, true);
|
||||
const req = replaceRequest(ctx, intent, getChess, getItem);
|
||||
assert.equal(req.targetUid, 1);
|
||||
assert.equal(req.targetName, getChess(RANGED).name);
|
||||
assert.deepEqual(req.item, { uid: 3, id: C, name: getItem(C).name });
|
||||
assert.deepEqual(req.options.map((o) => [o.uid, o.id, o.name]), [[11, A, getItem(A).name], [12, B, getItem(B).name]]);
|
||||
});
|
||||
|
||||
test('the picked item travels as replaceUid and the frame passes the protocol validator', () => {
|
||||
const intent = dropIntent(ctx, 3, { area: 'board', row: 9, col: 3 });
|
||||
const r = replaceIntent(intent, 12);
|
||||
assert.deepEqual(r, { t: 'g.equip', fields: { itemUid: 3, targetUid: 1, replaceUid: 12 } });
|
||||
assert.equal(validateC2S({ t: r.t, rid: 1, ...r.fields }), null);
|
||||
assert.deepEqual(replaceIntent(intent, null).fields, { itemUid: 3, targetUid: 1 }, 'no pick ⇒ no replaceUid');
|
||||
});
|
||||
|
||||
test('no dialog when a slot is free, the item is consumed on equip, or the equip completes an item merge', () => {
|
||||
const one = ctxOf({ ...priv, board: [{ ...priv.board[0], items: [{ uid: 11, id: A }] }] });
|
||||
const i1 = dropIntent(one, 3, { area: 'board', row: 9, col: 3 });
|
||||
assert.equal(i1.confirmReplace, false);
|
||||
assert.equal(replaceRequest(one, i1, getChess, getItem), null);
|
||||
// the same item as an equipped one (mergeable pair): the server merges instead of equipping
|
||||
const twin = ctxOf({ ...priv, hand: [{ uid: 3, kind: 'item', id: A }, ...priv.hand.slice(1)] });
|
||||
assert.equal(getItem(A).mergeable, true);
|
||||
assert.equal(equipMerges(twin, 3), true);
|
||||
assert.equal(dropIntent(twin, 3, { area: 'board', row: 9, col: 3 }).confirmReplace, false);
|
||||
assert.equal(equipMerges(ctx, 3), false);
|
||||
const consumable = Object.values(DATA.items).find((x) => x.itemType === 'EQUIP' && String(x.kind).startsWith('consume_on_equip'));
|
||||
const cons = ctxOf({ ...priv, hand: [{ uid: 3, kind: 'item', id: consumable.id }, ...priv.hand.slice(1)] });
|
||||
assert.equal(dropIntent(cons, 3, { area: 'board', row: 9, col: 3 }).confirmReplace, false);
|
||||
});
|
||||
|
||||
test('销毁 only for loose items: an equipped item is locked', () => {
|
||||
assert.deepEqual(underframeActions(ctx, 3), { retreat: false, sell: null, destroy: true });
|
||||
assert.equal(itemDestroyable(ctx, 3), true);
|
||||
assert.equal(itemDestroyable(ctx, 11), false, 'equipped (not an own piece entry)');
|
||||
// an index that lists equipped items (e.g. a future view) still never offers 销毁 for them
|
||||
const pieces = new Map(ctx.pieces);
|
||||
pieces.set(11, { piece: { uid: 11, kind: 'item', id: A }, area: 'equipped', equipped: true });
|
||||
pieces.set(12, { piece: { uid: 12, kind: 'item', id: B }, area: 'hand', idx: 5 }); // inconsistent: also on uid 1
|
||||
assert.equal(underframeActions({ ...ctx, pieces }, 11), null);
|
||||
assert.equal(itemDestroyable({ ...ctx, pieces }, 12), false, 'on an operator ⇒ locked');
|
||||
});
|
||||
|
||||
test('real match engine: the replace intent destroys exactly the picked item (and a bad replaceUid is refused)', () => {
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', humans: 1, seed: 5 });
|
||||
h.start();
|
||||
h.toPrep(1);
|
||||
const m = h.m;
|
||||
const ps = h.ps('p_0');
|
||||
const chessId = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier === 1).chessId;
|
||||
const at = legalTileFor(m, ps, chessId);
|
||||
const unit = give(m, ps, chessId, 'board', at);
|
||||
const [a, b, c] = [giveItem(m, ps, A), giveItem(m, ps, B), giveItem(m, ps, C)];
|
||||
assert.equal(m.handle('p_0', { t: 'g.equip', itemUid: a.uid, targetUid: unit.uid }).error, undefined);
|
||||
assert.equal(m.handle('p_0', { t: 'g.equip', itemUid: b.uid, targetUid: unit.uid }).error, undefined);
|
||||
h.flushAll();
|
||||
const priv = h.lastTo('p_0', 'm.private');
|
||||
const cx = ctxOf(priv);
|
||||
const tile = { area: 'board', row: at[0], col: at[1] };
|
||||
const intent = dropIntent(cx, c.uid, tile);
|
||||
assert.equal(intent.confirmReplace, true, 'the client asks');
|
||||
const req = replaceRequest(cx, intent, getChess, getItem);
|
||||
assert.deepEqual(req.options.map((o) => o.uid), [a.uid, b.uid]);
|
||||
// refused: not one of the target's items (the item itself)
|
||||
const bad = replaceIntent(intent, c.uid);
|
||||
assert.ok(m.handle('p_0', { t: bad.t, ...bad.fields }).error, 'a replaceUid that is not equipped is refused');
|
||||
// the player picks the SECOND (newer) item: the server must not fall back to the oldest
|
||||
const r = replaceIntent(intent, b.uid);
|
||||
assert.equal(validateC2S({ t: r.t, rid: 7, ...r.fields }), null);
|
||||
assert.equal(m.handle('p_0', { t: r.t, ...r.fields }).error, undefined);
|
||||
h.flushAll();
|
||||
const after = h.lastTo('p_0', 'm.private');
|
||||
const u = after.board.find((p) => p.uid === unit.uid);
|
||||
assert.deepEqual(u.items.map((x) => x.uid).sort(), [a.uid, c.uid].sort(), 'b destroyed, a kept, c equipped');
|
||||
const everywhere = JSON.stringify([after.hand, after.temp, after.board]);
|
||||
assert.ok(!everywhere.includes(`"uid":${b.uid},`), 'the replaced item is gone');
|
||||
// g.destroy of an equipped item is refused by the server — the client never offers it
|
||||
assert.ok(m.handle('p_0', { t: 'g.destroy', uid: a.uid }).error);
|
||||
assert.equal(itemDestroyable(ctxOf(after), a.uid), false);
|
||||
h.invariants();
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. boss-round countdown and the overtime (DOT) warning', () => {
|
||||
const T0 = 1_000_000;
|
||||
const fa = { phase: PHASE.FINAL_ASSAULT, round: 14, modeId: 'mode_multi_hard', deadline: T0 + 120_000, overtimeAt: T0 + 150_000 };
|
||||
|
||||
test("the countdown total is the level's maxPlayTime (120 s) in 最终攻势 and 隐秘核心", () => {
|
||||
assert.equal(bossLevelSeconds(fa, DATA.config), 120);
|
||||
assert.equal(phaseTotalSeconds(fa, DATA.config), 120);
|
||||
assert.equal(phaseTotalSeconds({ ...fa, phase: PHASE.HIDDEN_CORE, round: 15 }, DATA.config), 120);
|
||||
assert.equal(phaseTotalSeconds({ ...fa, modeId: 'mode_single_funny', round: 9 }, DATA.config), 120);
|
||||
assert.equal(phaseTotalSeconds(fa, null), 120, 'config not loaded yet: the official 120 s');
|
||||
assert.equal(bossLevelSeconds({ ...fa, phase: PHASE.COMBAT }, DATA.config), null);
|
||||
// the gauge: full at the start, 3/5 at 70 s left, empty at 0
|
||||
assert.equal(countdownState(fa.deadline, T0, 120).bars, 5);
|
||||
assert.equal(countdownState(fa.deadline, T0 + 50_000, 120).bars, 3);
|
||||
assert.equal(countdownState(fa.deadline, T0 + 121_000, 120).bars, 0);
|
||||
});
|
||||
|
||||
test('no warning early on; pending once the level time ran out (or within 30 s); draining from overtimeAt', () => {
|
||||
assert.equal(overtimeState(fa, T0 + 10_000), null);
|
||||
assert.equal(overtimeState(fa, T0 + 150_000 - (OVERTIME_WARN_BEFORE + 1) * 1000), null);
|
||||
assert.deepEqual(overtimeState(fa, T0 + 121_000), { state: 'pending', secs: 29 });
|
||||
assert.deepEqual(overtimeState({ ...fa, deadline: 0 }, T0 + 125_000), { state: 'pending', secs: 25 }, 'no level countdown: 30 s before');
|
||||
assert.deepEqual(overtimeState(fa, T0 + 150_000), { state: 'drain', secs: 0, lost: 0, perSec: 1 });
|
||||
// server gamedata.js bossOvertimeDue: 1 LP per whole real second past overtimeAt
|
||||
assert.deepEqual(overtimeState(fa, T0 + 157_400), { state: 'drain', secs: 7, lost: 7, perSec: 1 });
|
||||
assert.deepEqual(overtimeState(fa, T0 + 157_400, { perSec: 2 }), { state: 'drain', secs: 7, lost: 14, perSec: 2 });
|
||||
assert.equal(overtimeDrainPerSec(DATA.config), DATA.config.bossOvertimeDrainPerSec ?? 1);
|
||||
});
|
||||
|
||||
test('only boss rounds with an overtimeAt show it', () => {
|
||||
assert.equal(overtimeState({ ...fa, phase: PHASE.COMBAT }, T0 + 160_000), null);
|
||||
assert.equal(overtimeState({ ...fa, overtimeAt: undefined }, T0 + 160_000), null);
|
||||
assert.equal(overtimeState({ ...fa, phase: PHASE.RESULT }, T0 + 160_000), null);
|
||||
assert.equal(overtimeState(null, T0), null);
|
||||
assert.equal(overtimeState(fa, NaN), null);
|
||||
assert.ok(overtimeState({ ...fa, phase: PHASE.HIDDEN_CORE }, T0 + 160_000));
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. server session reset', () => {
|
||||
const base = { me: { playerId: 'p_old' }, room: null, match: emptyMatch() };
|
||||
test('a new playerId while a match / room was on screen says why the screen went back to the lobby', () => {
|
||||
assert.equal(sessionResetNotice({ ...base, match: { ...emptyMatch(), public: { phase: 'PREP' } } }, 'p_new'), '服务器会话已重置,上一局模拟已结束');
|
||||
assert.equal(sessionResetNotice({ ...base, room: { code: 'ABCD', inMatch: true } }, 'p_new'), '服务器会话已重置,上一局模拟已结束');
|
||||
assert.equal(sessionResetNotice({ ...base, room: { code: 'ABCD', inMatch: false } }, 'p_new'), '服务器会话已重置,已返回大厅');
|
||||
});
|
||||
test('nothing to say: first welcome, resumed session, lobby / title only', () => {
|
||||
assert.equal(sessionResetNotice({ ...base, me: { playerId: null }, room: { code: 'X' } }, 'p_new'), null);
|
||||
assert.equal(sessionResetNotice({ ...base, room: { code: 'X' } }, 'p_old'), null);
|
||||
assert.equal(sessionResetNotice(base, 'p_new'), null);
|
||||
assert.equal(sessionResetNotice(undefined, 'p_new'), null);
|
||||
});
|
||||
test('stale imperative dialogs are dismissed (confirm → false, alert → true)', async () => {
|
||||
const c = confirmDialog({ title: 'x' });
|
||||
const a = alertDialog({ title: 'y' });
|
||||
closeAllDialogs();
|
||||
assert.equal(await c, false);
|
||||
assert.equal(await a, true);
|
||||
closeAllDialogs(); // nothing queued: no-op
|
||||
});
|
||||
});
|
||||
|
||||
describe('4. solo pause', () => {
|
||||
const pub = (phase, extra = {}) => ({ phase, ...extra });
|
||||
test('offered in solo battles only (hidden in co-op, in prep and for an eliminated player)', () => {
|
||||
for (const ph of [PHASE.COMBAT, PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE]) assert.equal(pauseAvailable(pub(ph), { solo: true }), true, ph);
|
||||
for (const ph of [PHASE.PREP, PHASE.SP_DRAFT, PHASE.SETTLE, PHASE.RESULT, PHASE.INFO_CHECK]) assert.equal(pauseAvailable(pub(ph), { solo: true }), false, ph);
|
||||
assert.equal(pauseAvailable(pub(PHASE.COMBAT), { solo: false }), false, 'co-op');
|
||||
assert.equal(pauseAvailable(pub(PHASE.UNITE), { solo: false }), false);
|
||||
assert.equal(pauseAvailable(pub(PHASE.COMBAT), { solo: true, alive: false }), false);
|
||||
assert.equal(pauseAvailable(null, { solo: true }), false);
|
||||
assert.equal(pauseAvailable(pub(PHASE.COMBAT), { solo: true, done: true }), false, 'the own battle is over: nothing to pause');
|
||||
assert.equal(pauseAvailable(pub(PHASE.COMBAT, { paused: true }), { solo: true, done: true }), true, 'paused: still resumable');
|
||||
});
|
||||
test('g.pause {on} and the paused flag', () => {
|
||||
assert.deepEqual(pauseIntent(1), { t: 'g.pause', fields: { on: true } });
|
||||
assert.deepEqual(pauseIntent(false), { t: 'g.pause', fields: { on: false } });
|
||||
assert.equal(isPaused(pub(PHASE.COMBAT, { paused: true })), true);
|
||||
assert.equal(isPaused(pub(PHASE.COMBAT)), false);
|
||||
});
|
||||
test('while paused the HUD clocks stop at the pause moment', () => {
|
||||
const p = pub(PHASE.COMBAT, { paused: true, deadline: 50_000 });
|
||||
assert.equal(frozenNow(p, 30_000, 20_000), 20_000, 'first seen by this client');
|
||||
assert.equal(frozenNow({ ...p, pausedAt: 18_000 }, 30_000, 20_000), 18_000, 'the server says when');
|
||||
assert.equal(frozenNow(pub(PHASE.COMBAT), 30_000, 20_000), 30_000, 'not paused: live');
|
||||
assert.equal(remainAt(p.deadline, frozenNow(p, 45_000, 20_000)), 30);
|
||||
assert.equal(remainAt(0, 1), null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
// Real-server browser E2E of user playtest #2 item 1 end to end (DESIGN §16): the loadout chosen in the 干员调配
|
||||
// overlay reaches the server (room.loadout → seat → Match → m.private.loadout), the battle spec (units[].skillIndex /
|
||||
// moduleId) and the browser's own local battle (client-side combat, DESIGN §14), which fights with the chosen skill and
|
||||
// module — checked by the skill's signature effect in the local sim, not only by ids.
|
||||
//
|
||||
// SP_E2E=1 node --test test/ui/loadout-battle.e2e.test.js
|
||||
//
|
||||
// The chess: elite 野鬃 (chess_char_1_19_b). Default = S2 夹枪冲锋 (25/40 SP) + module 长枪替补套装 (ATK +40, ASPD +3).
|
||||
// Chosen here: S1 骑枪刺击 (ON_DEPLOY: "部署后攻击速度+100" for 25 s) + 不装备 — so right after the unit deploys the
|
||||
// local battle must show the S1 buff (wildmn:s1, ASPD +100, skill active) and the no-module stats (ATK 524, ASPD 100).
|
||||
// The server is test/e2e/fastServer.mjs with its starter-kit hook (SP_START_CHESS: the elite in the hand at round 1);
|
||||
// everything else is the real match engine and the real UI driven by real mouse input.
|
||||
// Screenshots: test/e2e/out/loadout-battle-*.png.
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf } from '../e2e/client.mjs';
|
||||
|
||||
const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets'));
|
||||
const BASE = 'chess_char_1_19_a';
|
||||
const ELITE = 'chess_char_1_19_b';
|
||||
|
||||
/** Item 11: the phase's server deadline and the countdown widgets actually shown. */
|
||||
const untimed = (c) => c.page.evaluate(() => ({
|
||||
deadline: !!globalThis.__SP__.store.get().match.public?.deadline,
|
||||
shown: [...document.querySelectorAll('.countdown')].filter((el) => { const r = el.getBoundingClientRect(); return r.width > 1 && r.height > 1 && getComputedStyle(el).display !== 'none'; }).length,
|
||||
}));
|
||||
|
||||
describe('user playtest #2 item 1 — loadout chosen in the UI fights in the local battle (real server)', { skip: !ENABLED && 'set SP_E2E=1 (Chrome + public/assets)' }, () => {
|
||||
test('干员调配 S1 + 不装备 for 野鬃 → m.private.loadout → b.start spec → the local sim runs S1 with the no-module stats', { timeout: 6 * 60 * 1000 }, async () => {
|
||||
const chess = JSON.parse(readFileSync(path.join(ROOT, 'data/chess.json'), 'utf8'));
|
||||
const rec = (Array.isArray(chess) ? chess : Object.values(chess.chess || chess)).find((x) => x && x.chessId === ELITE);
|
||||
const s1 = rec.skills.find((s) => s.index === 0);
|
||||
assert.equal(s1.skillId, 'skchr_wildmn_1');
|
||||
assert.equal(s1.spType, 'ON_DEPLOY');
|
||||
assert.notEqual(rec.skills.find((s) => s.isDefault).index, 0, 'S1 is not the default skill');
|
||||
assert.ok(rec.modules.some((m) => m.isDefault && m.uniEquipId !== 'uniequip_001'), 'the elite has a default module');
|
||||
|
||||
const srv = await startRealServer({ fast: { timerScale: 0.5, combatSpeed: 2, startRound: 1, chess: [ELITE] } });
|
||||
const P = (await import('puppeteer-core')).default;
|
||||
const c = new Client(P, srv.base, 'loadout', { prefix: 'loadout-battle' });
|
||||
try {
|
||||
await c.open();
|
||||
await c.enter('调配');
|
||||
// 1) the 干员调配 overlay: search 野鬃 → S1 + 不装备 (real clicks), synced with the server
|
||||
await c.click('.lobby-screen [data-testid="loadout-open"]');
|
||||
await c.page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
||||
await c.click('.lo-search input');
|
||||
await c.page.keyboard.type('野鬃');
|
||||
await c.page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
||||
await c.click('.lo-card');
|
||||
await c.click('.lo-detail .lo-skill[data-skill="0"]');
|
||||
await c.click('.lo-detail .lo-mod[data-module="none"]');
|
||||
await c.page.waitForFunction(() => document.querySelector('.lo-skill.is-on[data-skill="0"]') && document.querySelector('.lo-mod.is-on[data-module="none"]'), { timeout: 3000 });
|
||||
await c.page.waitForFunction(() => /已同步/.test(document.querySelector('.lo-sync')?.textContent || ''), { timeout: 8000 });
|
||||
await c.shot('overlay');
|
||||
await c.page.keyboard.press('Escape');
|
||||
await c.page.waitForFunction(() => !document.querySelector('.lo'), { timeout: 3000 });
|
||||
|
||||
// 2) a solo 标准 match: the briefing's m.private carries the loadout
|
||||
await c.click('.mode-card', '独立模拟');
|
||||
await c.click('.diff-card', '标准模拟');
|
||||
await c.click('.create-box button', '开始独立模拟');
|
||||
await c.waitFor((s) => !!s.room, 'solo room');
|
||||
if (!(await c.st()).phase) await c.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
|
||||
await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
|
||||
const lo = await c.page.evaluate(() => globalThis.__SP__.store.get().match.private?.loadout ?? null);
|
||||
assert.deepEqual(lo, { [BASE]: { skill: 0, module: 'none' } }, 'the match received the loadout');
|
||||
await c.click('.brief__foot .btn--primary', '准备就绪');
|
||||
await c.waitFor((s) => s.phase === 'BAND_DRAFT', 'band draft', 30000);
|
||||
await sleep(500);
|
||||
assert.deepEqual(await untimed(c), { deadline: false, shown: 0 }, 'item 11: the solo strategy draft is untimed');
|
||||
await c.click('.dband', null, { nth: 1 });
|
||||
await c.click('.draft-detail__btns .btn--primary', '确认选择');
|
||||
await c.waitFor((s) => s.phase === 'PREP' && !s.ready && s.hand > 0, 'prep with the starter kit', 60000);
|
||||
await sleep(1800); // camera flight + pieces
|
||||
assert.deepEqual(await untimed(c), { deadline: false, shown: 0 }, 'item 11: solo prep is untimed');
|
||||
|
||||
// 3) place the elite (drag → direction wheel)
|
||||
await c.hookRequests();
|
||||
const [piece] = (await c.handPieces('chess')).filter((p) => p.id === ELITE);
|
||||
assert.ok(piece, 'the elite 野鬃 is in the hand');
|
||||
const tile = await c.freeTileFor(piece.uid);
|
||||
assert.ok(tile, 'a legal tile');
|
||||
const from = await c.piecePoint(piece.uid);
|
||||
const to = await c.tilePoint(tile.row, tile.col);
|
||||
await c.drag(from, to);
|
||||
await c.page.waitForSelector('.fwheel__dia', { timeout: 4000 });
|
||||
await c.swipe('RIGHT');
|
||||
await c.waitFor((s) => s.board > 0, 'placed', 8000);
|
||||
await sleep(600);
|
||||
|
||||
// the detail card of the placed elite shows the chosen skill (已调配) and 未装备模组
|
||||
let detail = null;
|
||||
for (const at of [0.72, 0.4, 0.88]) {
|
||||
const p = await c.piecePoint(piece.uid, at);
|
||||
if (!p) continue;
|
||||
await c.page.mouse.click(p.x, p.y, { button: 'right' }); // right-click: the detail card only
|
||||
detail = await c.page.waitForSelector('.dpanel .dskill__name', { timeout: 2500 }).then(() => c.page.evaluate(() => ({
|
||||
skill: document.querySelector('.dpanel .dskill__name')?.textContent || '',
|
||||
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])),
|
||||
trait: document.querySelector('.dpanel .dtrait')?.textContent || '',
|
||||
})), () => null);
|
||||
if (detail) break;
|
||||
}
|
||||
assert.ok(detail, 'the detail card opens for the placed elite');
|
||||
assert.ok(detail.skill.includes(s1.name) && detail.tag, `detail card: ${s1.name} 已调配 (${JSON.stringify(detail)})`);
|
||||
assert.ok(detail.none && detail.module.includes('未装备模组'), `detail card: 未装备模组 (${JSON.stringify(detail)})`);
|
||||
// …and the stats / 特性 the unit fights with: no module (ATK 524, interval 1.00 s, "获得1点") — not the default module's
|
||||
assert.equal(detail.stats['攻击'], String(rec.statsBase.atk), `detail card ATK without the module (${JSON.stringify(detail.stats)})`);
|
||||
assert.equal(detail.stats['攻击间隔'], '1.00s', 'detail card interval: ASPD 100');
|
||||
assert.match(detail.trait, /获得1点部署费用/, `detail card 特性 without the module (${detail.trait})`);
|
||||
await c.shot('detail');
|
||||
await c.page.keyboard.press('Escape');
|
||||
await c.page.waitForFunction(() => !document.querySelector('.dpanel'), { timeout: 4000 });
|
||||
|
||||
// 4) ready → the local battle
|
||||
await c.click('.readybtn');
|
||||
await c.waitFor((s) => s.phase === 'COMBAT', 'combat', 60000);
|
||||
const got = await c.page.waitForFunction((uid) => {
|
||||
const r = globalThis.__SP_RUNNER__;
|
||||
const e = r && [...r._entries.values()].find((x) => x.own && x.battle);
|
||||
if (!e) return false;
|
||||
const u = e.battle.allyUnits.find((x) => x.uid === uid);
|
||||
if (!u || !u.findBuff?.('wildmn:s1')) return false;
|
||||
const b = u.findBuff('wildmn:s1');
|
||||
const entry = (e.spec.players || []).flatMap((p) => p.units || []).find((x) => x.uid === uid) || null;
|
||||
return {
|
||||
authoritative: e.authoritative, t: e.battle.time,
|
||||
spec: entry && { chessId: entry.chessId, skillIndex: entry.skillIndex, moduleId: entry.moduleId },
|
||||
skill: u.skill?.id ?? null, source: u.kit?.skillSource ?? null, active: !!u.skill?.active,
|
||||
loadout: u.def?.loadout ?? null, atk: u.def?.stats?.atk, aspd: u.def?.stats?.aspd,
|
||||
buff: { aspd: b.mods?.aspd ?? null },
|
||||
};
|
||||
}, { timeout: 30000, polling: 100 }, piece.uid).then((h) => h.jsonValue())
|
||||
.catch(async (err) => {
|
||||
const dbg = await c.page.evaluate((uid) => {
|
||||
const r = globalThis.__SP_RUNNER__;
|
||||
const e = r && [...r._entries.values()].find((x) => x.own && x.battle);
|
||||
const u = e?.battle.allyUnits.find((x) => x.uid === uid);
|
||||
return { entries: r ? r._entries.size : -1, unit: u ? { skill: u.skill?.id, active: u.skill?.active, buffs: u.buffs.map((b) => b.key) } : null };
|
||||
}, piece.uid);
|
||||
throw new Error(`${err.message} — ${JSON.stringify(dbg)}`);
|
||||
});
|
||||
assert.equal(got.authoritative, true, 'the own normal battle is simulated by this browser');
|
||||
assert.deepEqual(got.spec, { chessId: ELITE, skillIndex: 0, moduleId: 'none' }, 'b.start spec carries the loadout');
|
||||
assert.equal(got.skill, 'skchr_wildmn_1', 'the local unit carries S1');
|
||||
assert.equal(got.source, 'skills', 'with its hand-authored spec');
|
||||
assert.deepEqual({ skillIndex: got.loadout?.skillIndex, moduleId: got.loadout?.moduleId }, { skillIndex: 0, moduleId: 'none' });
|
||||
assert.equal(got.atk, rec.statsBase.atk, 'no module: base ATK (the default module adds +40)');
|
||||
assert.equal(got.aspd, rec.statsBase.aspd, 'no module: base ASPD');
|
||||
assert.notEqual(rec.stats.atk, rec.statsBase.atk, 'control: the default module would change ATK');
|
||||
assert.equal(got.buff.aspd, s1.bb.attack_speed, 'S1 signature: ASPD +100 on deploy');
|
||||
assert.ok(got.active, 'S1 is active right after deploying');
|
||||
assert.ok(got.t < 10, `S1 fires on deploy (t = ${got.t}), long before S2 could charge (25/40 SP)`);
|
||||
await sleep(800);
|
||||
await c.shot('combat');
|
||||
// the battle ends and the round settles normally (the result upload is accepted)
|
||||
await c.waitFor((s) => s.phase === 'PREP' && s.round >= 2 || !!s.result, 'next prep', 180000);
|
||||
assert.deepEqual(problemsOf([c]), []);
|
||||
} finally {
|
||||
if (c.problems.length) console.log(c.problems.slice(0, 20).join('\n'));
|
||||
await c.close();
|
||||
await srv.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
// Browser E2E of the 干员调配 overlay (DESIGN §16) against the real server, headless Chrome (puppeteer-core + system
|
||||
// Chrome). Opt-in: SP_E2E=1 node --test test/ui/loadout.e2e.test.js
|
||||
//
|
||||
// Desktop 1920×1080: lobby → 干员调配 → search 隐现 → S1 + 不装备 (persisted in localStorage, synced with room.loadout)
|
||||
// → create a 独立模拟 room → start → the briefing's m.private.loadout carries the choice, the briefing entry reopens the
|
||||
// overlay, the solo briefing shows no countdown value from the server (deadline 0) → 准备就绪 closes the overlay
|
||||
// (auto-close when INFO_CHECK ends). Phone 844×390 (touch): the roster → tap a card → the detail slides over → back.
|
||||
// Module cards: the official module type icons from the local-client art (data/local-assets.json groups.module, optional)
|
||||
// or the lettered tiles without it → test/e2e/out/module-icons.png.
|
||||
// Every page must finish with zero console errors / page errors / failed requests. Screenshots: test/e2e/out/loadout-*.png
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync, 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 OUT = path.join(ROOT, 'test/e2e/out');
|
||||
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const INSIDE = 'chess_char_1_01_a';
|
||||
/** Whether this machine has the extracted module type icons (local-client art is optional, DESIGN §13). */
|
||||
function localModuleIcons() {
|
||||
try { return !!JSON.parse(readFileSync(path.join(ROOT, 'data/local-assets.json'), 'utf8'))?.groups?.module; } catch { return false; }
|
||||
}
|
||||
|
||||
describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||
let srv;
|
||||
let browser;
|
||||
let base;
|
||||
|
||||
before(async () => {
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
base = `http://127.0.0.1:${srv.port}`;
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-sandbox'] });
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
});
|
||||
after(async () => { await browser?.close(); await srv?.close(); });
|
||||
|
||||
async function open({ w = 1920, h = 1080, touch = false } = {}) {
|
||||
const ctx = await browser.createBrowserContext();
|
||||
const page = await ctx.newPage();
|
||||
await page.setViewport({ width: w, height: h, hasTouch: touch, isMobile: touch, deviceScaleFactor: 1 });
|
||||
const problems = [];
|
||||
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
page.on('requestfailed', (r) => { if (!/fonts\.(googleapis|gstatic)/.test(r.url())) problems.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`); });
|
||||
page.on('response', (r) => { if (r.status() >= 400) problems.push(`http ${r.status()}: ${r.url()}`); });
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
localStorage.setItem('sp.name', '调配测试');
|
||||
sessionStorage.setItem('sp.entered', '1');
|
||||
});
|
||||
await page.goto(`${base}/`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => globalThis.__SP__?.store.get().connection.status === 'online' && !!document.querySelector('.lobby-screen'), { timeout: 30000 });
|
||||
return { ctx, page, problems };
|
||||
}
|
||||
const st = (page) => page.evaluate(() => {
|
||||
const s = globalThis.__SP__.store.get();
|
||||
return { phase: s.match.public?.phase ?? null, deadline: s.match.public?.deadline ?? null, loadout: s.match.private?.loadout ?? null, room: s.room?.code ?? null };
|
||||
});
|
||||
async function waitSt(page, pred, what, timeout = 20000) {
|
||||
const t0 = Date.now();
|
||||
let last = null;
|
||||
while (Date.now() - t0 < timeout) { last = await st(page); if (pred(last)) return last; await sleep(150); }
|
||||
throw new Error(`timed out waiting for ${what}: ${JSON.stringify(last)}`);
|
||||
}
|
||||
const clickSel = async (page, sel) => {
|
||||
await page.waitForSelector(sel, { visible: true, timeout: 10000 });
|
||||
await page.click(sel);
|
||||
};
|
||||
|
||||
test('desktop: choose S1 + 不装备 for 隐现, persisted, synced into the solo match, briefing entry, auto-close', async () => {
|
||||
const { ctx, page, problems } = await open();
|
||||
await clickSel(page, '.lobby-screen [data-testid="loadout-open"]');
|
||||
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
||||
assert.equal(await page.$$eval('.lo-card', (els) => els.length), 112, 'every visible chess');
|
||||
await page.screenshot({ path: path.join(OUT, 'loadout-desktop.png') });
|
||||
// search → one card
|
||||
await page.type('.lo-search input', '隐现');
|
||||
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 });
|
||||
await page.click('.lo-detail .lo-skill[data-skill="0"]');
|
||||
await page.click('.lo-detail .lo-mod[data-module="none"]');
|
||||
// elite-level descriptions toggle
|
||||
await page.click('.lo-seg button:nth-child(2)');
|
||||
await page.waitForFunction(() => document.querySelector('.lo-skill.is-on[data-skill="0"]') && document.querySelector('.lo-mod.is-on[data-module="none"]'));
|
||||
const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.loadout')));
|
||||
assert.deepEqual(stored.entries, { [INSIDE]: { skill: 0, module: 'none' } });
|
||||
await page.waitForFunction(() => /已同步/.test(document.querySelector('.lo-sync')?.textContent || ''), { timeout: 5000 });
|
||||
await page.screenshot({ path: path.join(OUT, 'loadout-detail.png') });
|
||||
// close (Esc), create a solo room, start
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForFunction(() => !document.querySelector('.lo'), { timeout: 3000 });
|
||||
await page.evaluate(() => globalThis.__SP__.net.request('room.create', { mode: 'solo', difficulty: 'NORMAL' }));
|
||||
await waitSt(page, (s) => !!s.room, 'room');
|
||||
await page.waitForSelector('.room-screen [data-testid="loadout-open"]', { visible: true });
|
||||
await page.evaluate(() => globalThis.__SP__.net.request('room.start', {}));
|
||||
const brief = await waitSt(page, (s) => s.phase === 'INFO_CHECK' && s.loadout, 'briefing');
|
||||
assert.deepEqual(brief.loadout, { [INSIDE]: { skill: 0, module: 'none' } }, 'the match fights with the loadout');
|
||||
assert.equal(brief.deadline, 0, 'solo briefing: no deadline');
|
||||
assert.equal(await page.$$eval('.brief .countdown', (els) => els.filter((e) => getComputedStyle(e).display !== 'none').length), 0,
|
||||
'no countdown shown in the solo briefing');
|
||||
// the briefing entry reopens it; 准备就绪 (INFO_CHECK ends) closes it
|
||||
await clickSel(page, '.brief [data-testid="loadout-open"]');
|
||||
await page.waitForSelector('.lo', { visible: true });
|
||||
await page.screenshot({ path: path.join(OUT, 'loadout-briefing.png') });
|
||||
await page.evaluate(() => globalThis.__SP__.net.request('g.infoReady', {}));
|
||||
await page.waitForFunction(() => !document.querySelector('.lo'), { timeout: 5000 });
|
||||
assert.deepEqual(problems, []);
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
test('solo briefing: an edit made right before 准备就绪 still applies to this match (closing the overlay sends it)', async () => {
|
||||
const { ctx, page, problems } = await open();
|
||||
await page.evaluate(() => globalThis.__SP__.net.request('room.create', { mode: 'solo', difficulty: 'NORMAL' }));
|
||||
await waitSt(page, (s) => !!s.room, 'room');
|
||||
await page.evaluate(() => globalThis.__SP__.net.request('room.start', {}));
|
||||
await waitSt(page, (s) => s.phase === 'INFO_CHECK', 'briefing');
|
||||
await clickSel(page, '.brief [data-testid="loadout-open"]');
|
||||
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
||||
await page.type('.lo-search input', '隐现');
|
||||
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 });
|
||||
// choose S1, close and confirm at once — well inside the sync's debounce
|
||||
await page.click('.lo-detail .lo-skill[data-skill="0"]');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForFunction(() => !document.querySelector('.lo'), { timeout: 3000 });
|
||||
await page.click('.brief__foot .btn--primary');
|
||||
const s = await waitSt(page, (x) => x.phase && x.phase !== 'INFO_CHECK' && x.loadout && x.loadout[INSIDE], 'band draft with the loadout', 8000)
|
||||
.catch(async (e) => { throw new Error(`${e.message} — last loadout ${JSON.stringify((await st(page)).loadout)}`); });
|
||||
assert.equal(s.loadout[INSIDE].skill, 0, 'the match fights with the edit made just before 准备就绪');
|
||||
assert.deepEqual(problems, []);
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
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');
|
||||
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)');
|
||||
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
|
||||
await page.waitForSelector('.lo-top .lo-deadline', { visible: true, timeout: 3000 });
|
||||
assert.match(await page.$eval('.lo-top .lo-deadline', (el) => el.getAttribute('aria-label')), /剩余\d+秒/);
|
||||
await page.type('.lo-search input', '隐现');
|
||||
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)
|
||||
await page.click('.lo-detail .lo-skill[data-skill="0"]');
|
||||
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 });
|
||||
const s = await st(page);
|
||||
assert.ok(!s.loadout || !s.loadout[INSIDE], 'the running match kept its locked loadout');
|
||||
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, []);
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
test('module cards: official type icons (PRI-X.png / pri-y.png, matched case-insensitively) or lettered tiles', async () => {
|
||||
const { ctx, page, problems } = await open();
|
||||
await clickSel(page, '.lobby-screen [data-testid="loadout-open"]');
|
||||
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
||||
await page.type('.lo-search input', '烛煌');
|
||||
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
||||
await page.click('.lo-card');
|
||||
await page.waitForSelector('.lo-detail .lo-mod[data-module="none"]', { visible: true });
|
||||
// wait for every module tile to settle (image loaded or lettered fallback)
|
||||
await page.waitForFunction(() => [...document.querySelectorAll('.lo-mod:not(.lo-mod--none) .lo-mglyph')]
|
||||
.every((g) => g.querySelector('.lo-mglyph__t') || (g.querySelector('img')?.complete && g.querySelector('img').naturalWidth > 0)), { timeout: 5000 });
|
||||
const tiles = await page.$$eval('.lo-mod:not(.lo-mod--none)', (els) => els.map((el) => ({
|
||||
type: el.querySelector('.lo-mod__type')?.textContent || '',
|
||||
src: el.querySelector('.lo-mglyph img')?.getAttribute('src') || null,
|
||||
letter: el.querySelector('.lo-mglyph__t')?.textContent || null,
|
||||
})));
|
||||
assert.deepEqual(tiles.map((t) => t.type).sort(), ['PRI-X', 'PRI-Y']);
|
||||
if (localModuleIcons()) {
|
||||
for (const t of tiles) assert.match(t.src || '', new RegExp(`^/assets/local/module/${t.type}\\.png$`, 'i'), JSON.stringify(t));
|
||||
assert.ok(await page.$('.lo-minfo__title .lo-minfo__icon'), 'the selected module\'s info shows its icon too');
|
||||
} else {
|
||||
for (const t of tiles) assert.equal(t.letter, t.type.slice(-1), JSON.stringify(t));
|
||||
}
|
||||
await page.hover('.lo-mod[data-module]:not(.is-on):not(.lo-mod--none)');
|
||||
await page.screenshot({ path: path.join(OUT, 'module-icons.png') });
|
||||
assert.deepEqual(problems, []);
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
test('phone 844×390 (touch): roster + detail side by side, tier / class filters, tap to choose', async () => {
|
||||
const { ctx, page, problems } = await open({ w: 844, h: 390, touch: true });
|
||||
await page.tap('.lobby-screen [data-testid="loadout-open"]');
|
||||
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
||||
await page.screenshot({ path: path.join(OUT, 'loadout-phone.png') });
|
||||
// review fix: the shared Button / TextField shrink to 6–7 px text on phones — the overlay's own controls stay readable
|
||||
// and tappable (返回, 全部恢复默认, the detail's 恢复默认, the search input)
|
||||
const ctl = await page.evaluate(() => Object.fromEntries(['.lo-back', '.lo-top__right .btn', '.lo-dhead__reset', '.lo-search .field__input'].map((sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
const b = el.getBoundingClientRect();
|
||||
return [sel, { h: Math.round(b.height), fs: parseFloat(getComputedStyle(el).fontSize) }];
|
||||
})));
|
||||
for (const [sel, v] of Object.entries(ctl)) {
|
||||
assert.ok(v.fs >= 11, `${sel} text ≥ 11 px (${JSON.stringify(v)})`);
|
||||
if (sel !== '.lo-search .field__input') assert.ok(v.h >= 28, `${sel} ≥ 28 px tall (${JSON.stringify(v)})`);
|
||||
}
|
||||
await page.tap('.lo-chip--t6');
|
||||
await page.waitForFunction(() => [...document.querySelectorAll('.lo-card')].every((c) => c.classList.contains('lo-card--t6')));
|
||||
await page.tap('.lo-chip--prof[title="狙击"]');
|
||||
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length >= 1);
|
||||
await page.tap('.lo-card');
|
||||
await page.waitForSelector('.lo-detail .lo-skill', { visible: true });
|
||||
assert.notEqual(await page.$eval('.lo-roster', (el) => getComputedStyle(el).display), 'none', 'side by side');
|
||||
// every skill option is reachable inside the viewport (the detail body scrolls)
|
||||
const r = await page.$eval('.lo-detail .lo-skill:last-child', (el) => { el.scrollIntoView({ block: 'nearest' }); const b = el.getBoundingClientRect(); return { top: b.top, bottom: b.bottom }; });
|
||||
assert.ok(r.top >= 0 && r.bottom <= 390, JSON.stringify(r));
|
||||
await page.tap('.lo-detail .lo-skill:last-child');
|
||||
await page.waitForFunction(() => document.querySelector('.lo-detail .lo-skill:last-child').classList.contains('is-on'));
|
||||
await page.screenshot({ path: path.join(OUT, 'loadout-phone-detail.png') });
|
||||
assert.deepEqual(problems, []);
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
test('small phone 667×375 (touch): the detail slides over the roster and back', async () => {
|
||||
const { ctx, page, problems } = await open({ w: 667, h: 375, touch: true });
|
||||
await page.tap('.lobby-screen [data-testid="loadout-open"]');
|
||||
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
||||
assert.equal(await page.$eval('.lo-detail-wrap', (el) => getComputedStyle(el).display), 'none');
|
||||
await page.tap('.lo-card');
|
||||
await page.waitForSelector('.lo-detail .lo-skill', { visible: true });
|
||||
assert.equal(await page.$eval('.lo-roster', (el) => getComputedStyle(el).display), 'none', 'detail covers the roster');
|
||||
await page.screenshot({ path: path.join(OUT, 'loadout-small-phone.png') });
|
||||
await page.tap('.lo-detail-back');
|
||||
await page.waitForSelector('.lo-roster', { visible: true });
|
||||
assert.deepEqual(problems, []);
|
||||
await ctx.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,417 @@
|
||||
// test/ui/loadout.test.js — 干员调配 client logic (DESIGN §16): the loadout model (storage parsing, options, choices,
|
||||
// sanitising a stale browser loadout so the server never refuses it, roster filters, display helpers), the server
|
||||
// sync (room.loadout after welcome / edits, debounce, lock / rate handling, never on missing data) and the overlay's
|
||||
// auto-close rule.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { checkLoadout, MODULE_NONE } from '../../shared/protocol.js';
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
import {
|
||||
parseStored, toStored, chessOptions, effectiveChoice, setChoice, resetChoice, sanitizeEntries, rosterOf, filterRoster,
|
||||
changedCount, moduleBadge, attrRows, skillTags, skillLabel, selectedSkill, selectedModule, recordsOf,
|
||||
} from '../../public/js/ui/loadoutModel.js';
|
||||
import { installLoadoutSync, SYNC_DEBOUNCE_MS, RETRY_MS } from '../../public/js/ui/loadoutSync.js';
|
||||
import { createStore } from '../../public/js/store.js';
|
||||
import { shouldAutoClose } from '../../public/js/screens/loadout.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const CHESS = JSON.parse(readFileSync(path.join(ROOT, 'data/chess.json'), 'utf8'));
|
||||
const BONDS = JSON.parse(readFileSync(path.join(ROOT, 'data/bonds.json'), 'utf8'));
|
||||
const get = (id) => (Object.hasOwn(CHESS, id) ? CHESS[id] : null);
|
||||
const getBond = (id) => (Object.hasOwn(BONDS, id) ? BONDS[id] : null);
|
||||
const INSIDE = 'chess_char_1_01_a';
|
||||
const SWIRE = 'chess_char_3_04_a';
|
||||
const { base: IB, golden: IG } = recordsOf(INSIDE, get);
|
||||
const { base: SB, golden: SG } = recordsOf(SWIRE, get);
|
||||
const SWIRE_ALT = SG.modules.find((m) => !m.isDefault).uniEquipId;
|
||||
|
||||
test('parseStored: tolerant of junk, keeps structurally valid entries; toStored round trip', () => {
|
||||
assert.deepEqual(parseStored(null), {});
|
||||
assert.deepEqual(parseStored('x'), {});
|
||||
assert.deepEqual(parseStored({ v: 1, entries: { [INSIDE]: { skill: 0, module: 'none', junk: 1 }, 'bad id': { skill: 1 }, [SWIRE]: { skill: -1 } } }),
|
||||
{ [INSIDE]: { skill: 0, module: 'none' } });
|
||||
assert.deepEqual(parseStored({ [INSIDE]: { skill: 1 } }), { [INSIDE]: { skill: 1 } }, 'bare map (older build)');
|
||||
const e = { [INSIDE]: { skill: 0 } };
|
||||
assert.deepEqual(parseStored(JSON.parse(JSON.stringify(toStored(e)))), e);
|
||||
});
|
||||
|
||||
test('options: skill records at Lv4 (normal) and Lv7 (elite); modules + 不装备 with defaults flagged', () => {
|
||||
const o = chessOptions(IB, IG);
|
||||
assert.deepEqual(o.skillOptions.map((s) => s.index), [0, 1]);
|
||||
assert.equal(o.skillOptions.find((s) => s.isDefault).index, 1);
|
||||
for (const s of o.skillOptions) {
|
||||
assert.equal(s.normal.level, IB.status.skillLevel, 'normal record at the normal skill level');
|
||||
assert.equal(s.elite.level, IG.status.skillLevel, 'elite record at the elite skill level');
|
||||
assert.equal(s.normal.skillId, s.elite.skillId);
|
||||
}
|
||||
assert.deepEqual(o.moduleOptions.map((m) => m.id), ['uniequip_002_inside', MODULE_NONE]);
|
||||
assert.ok(o.moduleOptions[0].isDefault && o.moduleOptions[0].rec.typeName === 'MAR-X');
|
||||
assert.equal(o.moduleOptions[1].rec, null);
|
||||
const s = chessOptions(SB, SG);
|
||||
assert.equal(s.moduleOptions.length, SG.modules.length + 1);
|
||||
});
|
||||
|
||||
test('choices: set / reset keep only non-default entries; effectiveChoice falls back to defaults', () => {
|
||||
let e = {};
|
||||
assert.deepEqual(effectiveChoice(e, IB, IG), { skill: 1, module: 'uniequip_002_inside', changed: false });
|
||||
e = setChoice(e, IB, IG, { skill: 0 });
|
||||
assert.deepEqual(e, { [INSIDE]: { skill: 0 } });
|
||||
e = setChoice(e, IB, IG, { module: MODULE_NONE });
|
||||
assert.deepEqual(e, { [INSIDE]: { skill: 0, module: MODULE_NONE } });
|
||||
assert.deepEqual(effectiveChoice(e, IB, IG), { skill: 0, module: MODULE_NONE, changed: true });
|
||||
e = setChoice(e, IB, IG, { skill: 1, module: 'uniequip_002_inside' });
|
||||
assert.deepEqual(e, {}, 'back to the defaults ⇒ no entry');
|
||||
e = setChoice(e, IB, IG, { skill: 5 }); // not selectable: ignored
|
||||
assert.deepEqual(e, {});
|
||||
e = setChoice(setChoice({}, SB, SG, { module: SWIRE_ALT }), IB, IG, { skill: 0 });
|
||||
assert.equal(changedCount(e, get), 2);
|
||||
e = resetChoice(e, SWIRE);
|
||||
assert.deepEqual(e, { [INSIDE]: { skill: 0 } });
|
||||
// stale entries resolve to the defaults
|
||||
assert.deepEqual(effectiveChoice({ [INSIDE]: { skill: 9, module: 'gone' } }, IB, IG), { skill: 1, module: 'uniequip_002_inside', changed: false });
|
||||
});
|
||||
|
||||
test('sanitizeEntries: drops unknown chess / illegal parts one by one, and the result always passes checkLoadout', () => {
|
||||
const stale = {
|
||||
[INSIDE]: { skill: 0, module: SWIRE_ALT }, // the module belongs to another character: the skill part survives
|
||||
[SWIRE]: { module: SWIRE_ALT },
|
||||
chess_char_9_99_a: { skill: 0 }, // removed from the data
|
||||
[IG.chessId]: { skill: 0 }, // an elite id
|
||||
[SB.chessId + 'x']: { skill: 1 },
|
||||
};
|
||||
const out = sanitizeEntries(stale, get);
|
||||
assert.deepEqual(out, { [INSIDE]: { skill: 0 }, [SWIRE]: { module: SWIRE_ALT } });
|
||||
assert.ok(checkLoadout(out, get).ok);
|
||||
// every visible chess × every choice sanitises to an accepted loadout
|
||||
const all = {};
|
||||
for (const c of rosterOf(Object.values(CHESS))) {
|
||||
const o = chessOptions(c, get(c.goldenId));
|
||||
all[c.chessId] = { skill: o.skills.find((i) => i !== o.defaultSkill), module: o.modules[o.modules.length - 1] };
|
||||
}
|
||||
const s = sanitizeEntries(all, get);
|
||||
assert.equal(Object.keys(s).length, 112);
|
||||
assert.ok(checkLoadout(s, get).ok);
|
||||
});
|
||||
|
||||
test('selectedSkill / selectedModule for the in-match UI (shop cards, detail panel)', () => {
|
||||
const lo = checkLoadout({ [INSIDE]: { skill: 0, module: MODULE_NONE } }, get).loadout;
|
||||
assert.equal(selectedSkill(lo, IB, get).index, 0);
|
||||
assert.equal(selectedSkill(lo, IG, get).level, IG.status.skillLevel);
|
||||
assert.equal(selectedModule(lo, IG, get), null);
|
||||
assert.equal(selectedModule({}, IG, get).uniEquipId, 'uniequip_002_inside');
|
||||
assert.equal(selectedModule(lo, IB, get), null, 'normal chess have no module');
|
||||
assert.equal(selectedSkill(null, SB, get).index, SB.skill.index);
|
||||
});
|
||||
|
||||
test('roster and filters: 112 visible chess in shop order; tier / class / bond / search / changed-only', () => {
|
||||
const roster = rosterOf(Object.values(CHESS));
|
||||
assert.equal(roster.length, 112);
|
||||
assert.ok(roster.every((c) => !c.isGolden && c.visible));
|
||||
for (let i = 1; i < roster.length; i++) assert.ok(roster[i - 1].tier <= roster[i].tier);
|
||||
const t3 = filterRoster(roster, { tier: 3 }, {}, get, getBond);
|
||||
assert.ok(t3.length > 0 && t3.every((c) => c.tier === 3));
|
||||
const snipers = filterRoster(roster, { prof: 'SNIPER' }, {}, get, getBond);
|
||||
assert.ok(snipers.length > 0 && snipers.every((c) => c.profession === 'SNIPER'));
|
||||
const bond = IB.bonds[0];
|
||||
assert.ok(filterRoster(roster, { bond }, {}, get, getBond).every((c) => c.bonds.includes(bond)));
|
||||
assert.deepEqual(filterRoster(roster, { query: '隐现' }, {}, get, getBond).map((c) => c.chessId), [INSIDE]);
|
||||
const bondName = getBond(bond).name;
|
||||
assert.ok(filterRoster(roster, { query: bondName }, {}, get, getBond).some((c) => c.chessId === INSIDE), 'search matches bond names');
|
||||
assert.deepEqual(filterRoster(roster, { changedOnly: true }, { [INSIDE]: { skill: 0 }, [SWIRE]: { skill: SB.skill.index } }, get, getBond).map((c) => c.chessId), [INSIDE]);
|
||||
});
|
||||
|
||||
test('display helpers: module badge, stat rows, skill tags', () => {
|
||||
assert.equal(moduleBadge({ typeName: 'MAR-X' }), 'X');
|
||||
assert.equal(moduleBadge({ typeName: 'ISW-α' }), 'α');
|
||||
assert.equal(moduleBadge(null, MODULE_NONE), '—');
|
||||
assert.deepEqual(attrRows({ maxHp: 80, atk: 22, def: 0 }).map((r) => [r.label, r.text, r.positive]), [['生命上限', '+80', true], ['攻击力', '+22', true]]);
|
||||
assert.deepEqual(attrRows({ cost: -1, respawnTime: -4 }).map((r) => [r.label, r.text, r.positive]), [['部署费用', '-1', true], ['再部署时间', '-4秒', true]]);
|
||||
assert.deepEqual(attrRows(null), []);
|
||||
const t = skillTags(IB.skills[1]);
|
||||
assert.equal(t.sp, '自动回复');
|
||||
assert.equal(t.cost, IB.skills[1].spCost);
|
||||
assert.equal(t.duration, '弹药');
|
||||
assert.equal(skillLabel(2), 'S3');
|
||||
const passive = skillTags({ skillType: 'PASSIVE', spType: 8 });
|
||||
assert.equal(passive.passive, true);
|
||||
assert.equal(passive.cost, null);
|
||||
});
|
||||
|
||||
// ---- sync ---------------------------------------------------------------------------------------------------------------
|
||||
|
||||
function fakeNet() {
|
||||
const listeners = new Map();
|
||||
const net = {
|
||||
status: 'online',
|
||||
sent: [],
|
||||
replies: [],
|
||||
on(t, fn) { if (!listeners.has(t)) listeners.set(t, new Set()); listeners.get(t).add(fn); return () => listeners.get(t).delete(fn); },
|
||||
emit(t, msg) { for (const fn of listeners.get(t) || []) fn(msg); },
|
||||
request(t, fields) {
|
||||
net.sent.push({ t, ...fields });
|
||||
const r = net.replies.shift();
|
||||
return r && r.error ? Promise.reject(Object.assign(new Error(r.error), { code: r.error })) : Promise.resolve({ t: 'ok' });
|
||||
},
|
||||
};
|
||||
return net;
|
||||
}
|
||||
function fakeTimers() {
|
||||
let now = 0;
|
||||
let seq = 0;
|
||||
const q = new Map();
|
||||
return {
|
||||
setTimeout: (fn, ms) => { const id = ++seq; q.set(id, { at: now + ms, fn }); return id; },
|
||||
clearTimeout: (id) => q.delete(id),
|
||||
async advance(ms) {
|
||||
const end = now + ms;
|
||||
for (;;) {
|
||||
const next = [...q.entries()].filter(([, t]) => t.at <= end).sort((a, b) => a[1].at - b[1].at)[0];
|
||||
if (!next) break;
|
||||
q.delete(next[0]);
|
||||
now = next[1].at;
|
||||
next[1].fn();
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
}
|
||||
now = end;
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
},
|
||||
};
|
||||
}
|
||||
const syncStore = (entries = {}) => createStore({ entries, open: false, from: null, sel: null, filters: {}, sync: 'idle' });
|
||||
|
||||
test('sync: welcome sends the sanitised loadout; edits are debounced; identical content is not resent', async () => {
|
||||
const net = fakeNet();
|
||||
const T = fakeTimers();
|
||||
const target = syncStore({ [INSIDE]: { skill: 0 }, chess_char_9_99_a: { skill: 1 } });
|
||||
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => CHESS, lookupChess: get });
|
||||
net.emit('welcome', {});
|
||||
await T.advance(100);
|
||||
assert.deepEqual(net.sent, [{ t: 'room.loadout', entries: { [INSIDE]: { skill: 0 } } }], 'stale entry dropped');
|
||||
assert.equal(target.get().sync, 'synced');
|
||||
target.set({ entries: { [INSIDE]: { skill: 0 }, [SWIRE]: { module: SWIRE_ALT } } });
|
||||
target.set({ entries: { [INSIDE]: { skill: 0 }, [SWIRE]: { module: MODULE_NONE } } });
|
||||
await T.advance(SYNC_DEBOUNCE_MS - 10);
|
||||
assert.equal(net.sent.length, 1, 'debounced');
|
||||
await T.advance(20);
|
||||
assert.equal(net.sent.length, 2);
|
||||
assert.deepEqual(net.sent[1].entries, { [INSIDE]: { skill: 0 }, [SWIRE]: { module: MODULE_NONE } });
|
||||
target.set({ entries: { ...target.get().entries } }); // same content, new object
|
||||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||
assert.equal(net.sent.length, 2, 'unchanged content is not resent');
|
||||
net.emit('welcome', {}); // a new / resumed session always gets it again
|
||||
await T.advance(100);
|
||||
assert.equal(net.sent.length, 3);
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('sync: locked match (WRONG_PHASE) is not an error; RATE retries; offline waits for the next welcome; missing data never sends', async () => {
|
||||
const net = fakeNet();
|
||||
const T = fakeTimers();
|
||||
const target = syncStore({ [INSIDE]: { skill: 0 } });
|
||||
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => CHESS, lookupChess: get });
|
||||
net.replies.push({ error: 'WRONG_PHASE' });
|
||||
net.emit('welcome', {});
|
||||
await T.advance(100);
|
||||
assert.equal(target.get().sync, 'locked');
|
||||
// back in the lobby (room.state without a match): sent again
|
||||
net.emit('room.state', { inMatch: false });
|
||||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||
assert.equal(net.sent.length, 2);
|
||||
assert.equal(target.get().sync, 'synced');
|
||||
// RATE → retried after RETRY_MS
|
||||
net.replies.push({ error: 'RATE' });
|
||||
target.set({ entries: {} });
|
||||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||
assert.equal(net.sent.length, 3);
|
||||
assert.equal(target.get().sync, 'pending');
|
||||
await T.advance(RETRY_MS + 10);
|
||||
assert.equal(net.sent.length, 4);
|
||||
assert.deepEqual(net.sent[3].entries, {});
|
||||
assert.equal(target.get().sync, 'synced');
|
||||
// offline: nothing sent until the next welcome
|
||||
net.status = 'reconnecting';
|
||||
target.set({ entries: { [INSIDE]: { skill: 0 } } });
|
||||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||
assert.equal(net.sent.length, 4);
|
||||
net.status = 'online';
|
||||
net.emit('welcome', {});
|
||||
await T.advance(100);
|
||||
assert.equal(net.sent.length, 5);
|
||||
s.dispose();
|
||||
// chess data missing: never sanitise everything away (that would clear the server's copy)
|
||||
const net2 = fakeNet();
|
||||
const t2 = syncStore({ [INSIDE]: { skill: 0 } });
|
||||
const s2 = installLoadoutSync({ net: net2, timers: T, target: t2, getChessReady: async () => null, lookupChess: () => null });
|
||||
net2.emit('welcome', {});
|
||||
await T.advance(100);
|
||||
assert.equal(net2.sent.length, 0);
|
||||
assert.equal(t2.get().sync, 'error');
|
||||
s2.dispose();
|
||||
});
|
||||
|
||||
test('sync: closing the overlay sends a pending edit at once (the next click — 准备就绪 / 开始模拟 — must not overtake it)', async () => {
|
||||
const net = fakeNet();
|
||||
const T = fakeTimers();
|
||||
const target = syncStore({});
|
||||
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => CHESS, lookupChess: get });
|
||||
net.emit('welcome', {});
|
||||
await T.advance(100);
|
||||
assert.equal(net.sent.length, 1);
|
||||
target.set({ open: true, from: 'briefing' });
|
||||
target.set({ entries: { [INSIDE]: { skill: 0 } } });
|
||||
await T.advance(50); // well inside the debounce
|
||||
assert.equal(net.sent.length, 1, 'still debounced while the overlay is open');
|
||||
target.set({ open: false });
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve(); // microtasks only: no timer ran
|
||||
assert.equal(net.sent.length, 2, 'sent on close, before any later task (e.g. the g.infoReady click)');
|
||||
assert.deepEqual(net.sent[1].entries, { [INSIDE]: { skill: 0 } });
|
||||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||
assert.equal(net.sent.length, 2, 'the cancelled debounce does not send it twice');
|
||||
// closing without a pending edit sends nothing
|
||||
target.set({ open: true });
|
||||
target.set({ open: false });
|
||||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||
assert.equal(net.sent.length, 2);
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('overlay auto-close: briefing entry closes when INFO_CHECK ends; lobby / room entry closes when a match starts', () => {
|
||||
const open = (from) => ({ open: true, from });
|
||||
assert.equal(shouldAutoClose(open('briefing'), PHASE.INFO_CHECK, true, true), false);
|
||||
assert.equal(shouldAutoClose(open('briefing'), PHASE.BAND_DRAFT, true, true), true);
|
||||
assert.equal(shouldAutoClose(open('room'), null, false, false), false);
|
||||
assert.equal(shouldAutoClose(open('room'), null, true, false), true);
|
||||
assert.equal(shouldAutoClose(open('lobby'), PHASE.INFO_CHECK, true, true), false, 'reopened during the briefing from elsewhere');
|
||||
assert.equal(shouldAutoClose({ open: false, from: 'briefing' }, PHASE.PREP, true, true), false);
|
||||
});
|
||||
|
||||
test('untimed phases show no countdown: the "无倒计时" placeholder of Countdown is hidden (user playtest #11)', () => {
|
||||
const css = readFileSync(path.join(ROOT, 'public/css/screens/loadout.css'), 'utf8');
|
||||
assert.match(css, /\.countdown\[aria-label="无倒计时"\]\s*\{\s*display:\s*none;/);
|
||||
const html = readFileSync(path.join(ROOT, 'public/index.html'), 'utf8');
|
||||
assert.match(html, /<link rel="stylesheet" href="\/css\/screens\/loadout\.css" \/>/, 'loaded on every page');
|
||||
// contract with ui/components.js: a missing deadline renders the Countdown with exactly that aria-label (or nothing)
|
||||
const comp = readFileSync(path.join(ROOT, 'public/js/ui/components.js'), 'utf8');
|
||||
const fn = comp.slice(comp.indexOf('export function Countdown'), comp.indexOf('export function Countdown') + 2500);
|
||||
assert.ok(/'无倒计时'/.test(fn) || /return null/.test(fn), 'Countdown marks (or skips) the untimed state');
|
||||
});
|
||||
|
||||
// ---- review fixes (adversarial review of the loadout workstream) -------------------------------------------------------
|
||||
|
||||
/** A net whose replies are released by hand (the socket is ordered: replies come back in request order). */
|
||||
function deferredNet() {
|
||||
const listeners = new Map();
|
||||
const net = {
|
||||
status: 'online',
|
||||
sent: [],
|
||||
pending: [],
|
||||
on(t, fn) { if (!listeners.has(t)) listeners.set(t, new Set()); listeners.get(t).add(fn); return () => listeners.get(t).delete(fn); },
|
||||
emit(t, msg) { for (const fn of listeners.get(t) || []) fn(msg); },
|
||||
request(t, fields) {
|
||||
net.sent.push({ t, ...fields });
|
||||
return new Promise((resolve, reject) => net.pending.push({ resolve, reject }));
|
||||
},
|
||||
async reply(error = null) {
|
||||
const p = net.pending.shift();
|
||||
if (error) p.reject(Object.assign(new Error(error), { code: error })); else p.resolve({ t: 'ok' });
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
},
|
||||
};
|
||||
return net;
|
||||
}
|
||||
const ticks = async () => { for (let i = 0; i < 6; i++) await Promise.resolve(); };
|
||||
|
||||
test('sync: an edit closed while an earlier room.loadout is still in flight is sent at once, not after that reply', async () => {
|
||||
const net = deferredNet();
|
||||
const T = fakeTimers();
|
||||
const target = syncStore({ [INSIDE]: { skill: 0 } });
|
||||
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => CHESS, lookupChess: get, notify: () => {} });
|
||||
net.emit('welcome', {});
|
||||
await T.advance(100);
|
||||
assert.equal(net.sent.length, 1, 'welcome send in flight (no reply yet)');
|
||||
target.set({ open: true, from: 'briefing' });
|
||||
target.set({ entries: { [INSIDE]: { skill: 0, module: MODULE_NONE } } });
|
||||
target.set({ open: false });
|
||||
await ticks(); // microtasks only: the player's next click (准备就绪) is a later task
|
||||
assert.equal(net.sent.length, 2, 'the edit went out before the first reply (the same ordered socket)');
|
||||
assert.deepEqual(net.sent[1].entries, { [INSIDE]: { skill: 0, module: MODULE_NONE } });
|
||||
// the older reply must not mark the newer content as synced / the newer reply wins
|
||||
await net.reply();
|
||||
assert.equal(target.get().sync, 'sending', 'the older reply does not settle the state');
|
||||
await net.reply();
|
||||
assert.equal(target.get().sync, 'synced');
|
||||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||
assert.equal(net.sent.length, 2, 'nothing resent');
|
||||
// reverting to the content of a request that is still in flight is not sent twice; reverting to the last accepted
|
||||
// content while a different one is in flight is sent (the server applies the newest frame)
|
||||
target.set({ entries: { [INSIDE]: { skill: 0 } } });
|
||||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||
assert.equal(net.sent.length, 3);
|
||||
target.set({ entries: { [INSIDE]: { skill: 0, module: MODULE_NONE } } });
|
||||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||
assert.equal(net.sent.length, 4, 'back to the accepted content while another is in flight: sent');
|
||||
target.set({ entries: { [INSIDE]: { skill: 0, module: MODULE_NONE } } });
|
||||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||
assert.equal(net.sent.length, 4, 'identical to the one in flight: not duplicated');
|
||||
await net.reply();
|
||||
await net.reply();
|
||||
assert.equal(target.get().sync, 'synced');
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('sync: an edit refused because the match locked its loadout is told to the player once; a resync is not', async () => {
|
||||
const net = deferredNet();
|
||||
const T = fakeTimers();
|
||||
const told = [];
|
||||
const target = syncStore({ [INSIDE]: { skill: 0 } });
|
||||
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => CHESS, lookupChess: get, notify: (t) => told.push(t) });
|
||||
net.emit('welcome', {}); // reconnect during a running match: the resync is refused, nothing to tell
|
||||
await T.advance(100);
|
||||
await net.reply('WRONG_PHASE');
|
||||
assert.equal(target.get().sync, 'locked');
|
||||
assert.deepEqual(told, []);
|
||||
// an edit (the briefing overlay closed by INFO_CHECK ending) arrives too late
|
||||
target.set({ open: true, from: 'briefing' });
|
||||
target.set({ entries: { [INSIDE]: { skill: 1 } } });
|
||||
target.set({ open: false });
|
||||
await ticks();
|
||||
assert.equal(net.sent.length, 2);
|
||||
await net.reply('WRONG_PHASE');
|
||||
assert.equal(target.get().sync, 'locked');
|
||||
assert.equal(told.length, 1);
|
||||
assert.match(told[0], /下一局生效/);
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('sync: an empty loadout is sent without loading chess.json (no 1.6 MB download in the lobby for it)', async () => {
|
||||
const net = deferredNet();
|
||||
const T = fakeTimers();
|
||||
let loads = 0;
|
||||
const target = syncStore({});
|
||||
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => { loads++; return CHESS; }, lookupChess: get, notify: () => {} });
|
||||
net.emit('welcome', {});
|
||||
await T.advance(100);
|
||||
assert.deepEqual(net.sent, [{ t: 'room.loadout', entries: {} }]);
|
||||
assert.equal(loads, 0);
|
||||
await net.reply();
|
||||
target.set({ entries: { [INSIDE]: { skill: 0 } } });
|
||||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||
assert.equal(loads, 1, 'a real loadout is sanitised against the data');
|
||||
assert.deepEqual(net.sent[1].entries, { [INSIDE]: { skill: 0 } });
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('entry badge (review fix): counts like the screen once chess.json is loaded (stale entries of retired chess do not count)', async () => {
|
||||
const { badgeCount } = await import('../../public/js/screens/loadout.js');
|
||||
// unknown id, a retired (hidden) chess on a non-default skill (红豆 S1), an elite id
|
||||
const entries = { [INSIDE]: { skill: 0 }, chess_char_9_99_a: { skill: 1 }, chess_char_1_05_a: { skill: 0 }, [IB.goldenId]: { skill: 0 } };
|
||||
assert.equal(get('chess_char_1_05_a').isHidden, true, 'fixture: 红豆 is retired');
|
||||
assert.equal(badgeCount(entries, null), 4, 'before the data: the stored entries');
|
||||
assert.equal(badgeCount(entries, get), 1, 'with the data: only chess the screen shows as 已调整');
|
||||
assert.equal(badgeCount(entries, get), changedCount(entries, get));
|
||||
assert.equal(badgeCount({}, get), 0);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
// Local-client art is optional (DESIGN §13): the emote art, the 玩法说明 pages and the official tier chips resolve
|
||||
// from data/local-assets.json when present and fall back (text / tips / CSS) when the manifest or an entry is missing.
|
||||
|
||||
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';
|
||||
import { EMOTES, emoteArtPath, emoteArtGroup, emoteInfo } from '../../shared/constants.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
|
||||
const manifest = {
|
||||
version: 1,
|
||||
groups: {
|
||||
'emoticon/basic': { pic_happy_battle: { path: '/assets/local/emoticon/basic/pic_happy_battle.png', w: 120, h: 120 } },
|
||||
guide: Object.fromEntries(['autochess_home_1', 'autochess_home_2', 'autochess_shop_1', 'autochess_handbook_4']
|
||||
.map((k) => [k, { path: `/assets/local/guide/${k}.png`, w: 1024, h: 1024 }])),
|
||||
'ui/battle': { img_chess_level_3: { path: '/assets/local/ui/battle/img_chess_level_3.png', w: 45, h: 45 } },
|
||||
},
|
||||
};
|
||||
|
||||
let mode = 'ok';
|
||||
globalThis.fetch = async (url) => {
|
||||
if (String(url).endsWith('/local-assets.json') && mode === 'ok') return { ok: true, status: 200, json: async () => manifest };
|
||||
return { ok: false, status: 404, json: async () => ({}) };
|
||||
};
|
||||
|
||||
describe('local-client art fallbacks', () => {
|
||||
test('manifest present: listed entries resolve, unlisted ones stay null', async () => {
|
||||
const { data, localAsset } = await import('../../public/js/data.js');
|
||||
await data.load('local');
|
||||
const { emoteArtUrl } = await import('../../public/js/ui/emotes.js');
|
||||
const { guidePages, GUIDE_CHAPTERS } = await import('../../public/js/ui/guide.js');
|
||||
const { tierChipUrl } = await import('../../public/js/ui/components.js');
|
||||
assert.equal(emoteArtUrl('autochess_battle_happy'), emoteArtPath('autochess_battle_happy'));
|
||||
assert.equal(emoteArtUrl('autochess_battle_sad'), null, 'unlisted emote art is never requested');
|
||||
assert.equal(emoteArtUrl('nope'), null);
|
||||
const pages = guidePages();
|
||||
assert.deepEqual(pages.map((p) => p.key), ['autochess_home_1', 'autochess_home_2', 'autochess_shop_1', 'autochess_handbook_4'], 'reading order, missing pages skipped');
|
||||
assert.deepEqual(pages.map((p) => p.chapter), [0, 0, 1, 2]);
|
||||
assert.equal(GUIDE_CHAPTERS.reduce((n, c) => n + c.pages.length, 0), 19, 'the 19 official tutorial pages');
|
||||
assert.equal(tierChipUrl(3), '/assets/local/ui/battle/img_chess_level_3.png');
|
||||
assert.equal(tierChipUrl(4), null, 'CSS fallback');
|
||||
assert.equal(localAsset('guide', 'autochess_home_9'), null);
|
||||
assert.equal(localAsset('nope', 'x'), null);
|
||||
});
|
||||
|
||||
test('manifest missing: everything falls back', async () => {
|
||||
mode = 'missing';
|
||||
const { data } = await import('../../public/js/data.js');
|
||||
const orig = console.warn; console.warn = () => {};
|
||||
try { await data.invalidate('local'); } finally { console.warn = orig; }
|
||||
assert.equal(data.status('local'), 'missing');
|
||||
const { emoteArtUrl } = await import('../../public/js/ui/emotes.js');
|
||||
const { guidePages } = await import('../../public/js/ui/guide.js');
|
||||
const { tierChipUrl } = await import('../../public/js/ui/components.js');
|
||||
for (const id of EMOTES) assert.equal(emoteArtUrl(id), null);
|
||||
assert.deepEqual(guidePages(), []);
|
||||
assert.equal(tierChipUrl(1), null);
|
||||
});
|
||||
|
||||
test('the real manifest (when extracted) lists all 36 emotes and all 19 guide pages', { skip: !safeRead() && 'no data/local-assets.json' }, () => {
|
||||
const real = JSON.parse(safeRead());
|
||||
for (const id of EMOTES) {
|
||||
const entry = real.groups[emoteArtGroup(id)]?.[emoteInfo(id).picId];
|
||||
assert.ok(entry, `emote ${id}`);
|
||||
assert.equal(entry.path, emoteArtPath(id));
|
||||
}
|
||||
assert.equal(Object.keys(real.groups.guide || {}).length, 19);
|
||||
});
|
||||
});
|
||||
|
||||
function safeRead() {
|
||||
try { return readFileSync(path.join(ROOT, 'data/local-assets.json'), 'utf8'); } catch { return null; }
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
// Browser E2E for the in-match UI via the mock harness (public/dev/game-mock.html) in headless Chrome
|
||||
// (puppeteer-core + system Chrome). Opt-in: runs only with SP_E2E=1 (it needs Chrome and takes ~1 min).
|
||||
//
|
||||
// SP_E2E=1 node --test test/ui/mock.e2e.test.js
|
||||
// SP_E2E=1 SP_RENDER=fallback node --test test/ui/mock.e2e.test.js # screenshots with the DOM fallback view
|
||||
//
|
||||
// 1. Screenshots of every phase / variant at 1920×1080 and 1280×720 → test/e2e/out/ui-<name>-<w>.png,
|
||||
// asserting zero console errors / page errors / failed requests.
|
||||
// 2. Interactions against the harness's mock server: two-tap buy, keyboard shortcuts (R/F/D/Space/Esc), drag a hand
|
||||
// unit onto the board + choose a direction on the wheel, tap a unit → underframe 出售 (no drag-to-sell), drag an
|
||||
// item onto a unit (equip), 机变 pick, band pick, briefing ready, emote, exit → 暂离 → 返回.
|
||||
// 3. Regressions: tooltip after a click, toasts / reconnect banner placement, refused watch targets, battle unit
|
||||
// panel closing at the next prep, no Hidden Core medal before R15, equipment dropped on an operator's upper body
|
||||
// (engine), illegal drops toasting their reason.
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const OUT = path.join(ROOT, 'test/e2e/out');
|
||||
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
|
||||
const RENDER = process.env.SP_RENDER === 'fallback' ? 'fallback' : 'engine';
|
||||
|
||||
const SHOTS = [
|
||||
['briefing', 'phase=INFO_CHECK'], ['draft', 'phase=BAND_DRAFT'], ['draft-solo', 'phase=BAND_DRAFT&variant=solo'],
|
||||
['boot', 'phase=BATTLE_CHECK'], ['prep', 'phase=PREP'], ['prep-reward-temp', 'phase=PREP&variant=reward,temp'],
|
||||
['prep-frozen', 'phase=PREP&variant=frozen'], ['prep-dead', 'phase=PREP&variant=dead'], ['prep-collapsed', 'phase=PREP&variant=collapsed'],
|
||||
['prep-detail', 'phase=PREP&variant=detailpiece'], ['prep-shopdetail', 'phase=PREP&variant=detail'], ['prep-bond', 'phase=PREP&variant=bond'],
|
||||
['prep-enemies', 'phase=PREP&variant=drawer'], ['prep-pen', 'phase=PREP&variant=pen'], ['prep-info', 'phase=PREP&variant=info'], ['prep-emote-ticker', 'phase=PREP&variant=emote,ticker'],
|
||||
['prep-settings', 'phase=PREP&variant=settings'], ['sp-bounty', 'phase=SP_DRAFT&variant=bounty'], ['sp-supply', 'phase=SP_DRAFT&variant=supply'],
|
||||
['sp-tactic', 'phase=SP_DRAFT&variant=tactic'], ['sp-solo', 'phase=SP_DRAFT&variant=solo'], ['combat', 'phase=COMBAT'],
|
||||
['combat-done', 'phase=COMBAT&variant=done'], ['unite', 'phase=UNITE'], ['settle', 'phase=SETTLE'], ['final-assault', 'phase=FINAL_ASSAULT'],
|
||||
['hidden-core', 'phase=HIDDEN_CORE'], ['result-win', 'phase=RESULT'], ['result-lose', 'phase=RESULT&variant=defeat'],
|
||||
];
|
||||
|
||||
describe('in-match UI (mock harness, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||
let srv;
|
||||
let browser;
|
||||
let base;
|
||||
|
||||
before(async () => {
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
base = `http://127.0.0.1:${srv.port}`;
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-sandbox'] });
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
await srv?.close();
|
||||
});
|
||||
|
||||
async function open(query, { w = 1920, h = 1080, render = RENDER } = {}) {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: w, height: h });
|
||||
const problems = [];
|
||||
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
page.on('requestfailed', (r) => problems.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`));
|
||||
page.on('response', (r) => { if (r.status() >= 400) problems.push(`http ${r.status()}: ${r.url()}`); });
|
||||
await page.goto(`${base}/dev/game-mock.html?shot=1&render=${render}&${query}`, { waitUntil: 'networkidle0' });
|
||||
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)'), { timeout: 15000 });
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
return { page, problems };
|
||||
}
|
||||
const mockState = (page) => page.evaluate(() => JSON.parse(JSON.stringify(globalThis.__MOCK__.S().priv)));
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
/** Swipe the open direction wheel from its centre towards `dir` and release (research 09 §1.2). */
|
||||
const swipeWheel = async (page, dir = 'RIGHT') => {
|
||||
await page.waitForSelector('.fwheel__dia', { timeout: 4000 });
|
||||
const w = await page.$eval('.fwheel__dia', (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2, h: r.width / 2 }; });
|
||||
const v = { UP: [0, -1], RIGHT: [1, 0], DOWN: [0, 1], LEFT: [-1, 0] }[dir];
|
||||
await page.mouse.move(w.x, w.y);
|
||||
await page.mouse.down();
|
||||
for (let i = 1; i <= 8; i++) { await page.mouse.move(w.x + (v[0] * w.h * 0.7 * i) / 8 + 1, w.y + (v[1] * w.h * 0.7 * i) / 8 + 1); await sleep(16); }
|
||||
await page.mouse.up();
|
||||
await sleep(400);
|
||||
};
|
||||
|
||||
for (const [w, h] of [[1920, 1080], [1280, 720]]) {
|
||||
test(`screenshots of every phase at ${w}×${h} with zero console errors`, async () => {
|
||||
for (const [name, query] of SHOTS) {
|
||||
const { page, problems } = await open(query, { w, h });
|
||||
await sleep(RENDER === 'engine' ? 1500 : 700);
|
||||
await page.screenshot({ path: path.join(OUT, `ui-${name}-${w}.png`) });
|
||||
assert.deepEqual(problems, [], `${name} @${w}`);
|
||||
await page.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('prep: buy, shortcuts, ready, Esc', async () => {
|
||||
const { page, problems } = await open('phase=PREP');
|
||||
let s = await mockState(page);
|
||||
const funds0 = s.funds;
|
||||
const empty0 = s.hand.filter((x) => !x).length;
|
||||
await page.click('.shopbar__cards .scard:not(.scard--sold)');
|
||||
await sleep(300);
|
||||
assert.equal((await mockState(page)).funds, funds0, 'the first tap only selects (确认购买)');
|
||||
assert.ok(await page.$('.scard.is-armed .scard__confirm'));
|
||||
await page.click('.scard.is-armed');
|
||||
await sleep(300);
|
||||
s = await mockState(page);
|
||||
assert.ok(s.funds < funds0, 'bought');
|
||||
assert.equal(s.hand.filter((x) => !x).length, empty0 - 1);
|
||||
const f1 = s.funds;
|
||||
await page.keyboard.press('KeyR');
|
||||
await sleep(300);
|
||||
s = await mockState(page);
|
||||
assert.equal(s.funds, f1 - 1, 'R refreshes');
|
||||
await page.keyboard.press('KeyF');
|
||||
await sleep(300);
|
||||
assert.equal((await mockState(page)).shop.frozen, true, 'F freezes');
|
||||
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => { S.priv.funds = 40; }));
|
||||
await sleep(100);
|
||||
const lv = (await mockState(page)).shop.level;
|
||||
await page.keyboard.press('KeyD');
|
||||
await sleep(300);
|
||||
assert.equal((await mockState(page)).shop.level, lv + 1, 'D levels up');
|
||||
await page.keyboard.press('Space');
|
||||
await sleep(300);
|
||||
assert.equal((await mockState(page)).ready, true, 'Space readies');
|
||||
assert.ok(await page.$('.readybtn.is-on'));
|
||||
await page.keyboard.press('KeyR');
|
||||
await sleep(200);
|
||||
assert.equal((await mockState(page)).shop.frozen, true, 'no actions while ready');
|
||||
await page.keyboard.press('Space');
|
||||
await sleep(300);
|
||||
assert.equal((await mockState(page)).ready, false);
|
||||
// detail + Esc
|
||||
await page.click('.scard .scard__detail');
|
||||
await page.waitForSelector('.dpanel');
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(200);
|
||||
assert.equal(await page.$('.dpanel'), null, 'Esc closes the detail panel');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('prep: drag & drop — tap-sell (no sell zone), hand → board + wheel, item → unit (equip)', async () => {
|
||||
// DOM drag targets only exist in the fallback view (the Pixi engine's own drag is tested by the render module)
|
||||
const { page, problems } = await open('phase=PREP', { render: 'fallback' });
|
||||
const center = async (sel) => page.$eval(sel, (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });
|
||||
const drag = async (from, to) => {
|
||||
await page.mouse.move(from.x, from.y);
|
||||
await page.mouse.down();
|
||||
for (let i = 1; i <= 8; i++) await page.mouse.move(from.x + ((to.x - from.x) * i) / 8, from.y + ((to.y - from.y) * i) / 8);
|
||||
await sleep(80);
|
||||
await page.mouse.up();
|
||||
await sleep(400);
|
||||
};
|
||||
let s = await mockState(page);
|
||||
// sell one board unit first so the deploy cap has room: dropping it on the shop bar does nothing (no drag-to-sell),
|
||||
// tapping it opens its underframe → 出售
|
||||
const boardUnit = s.board.find((p) => p.kind === 'chess' && !p.golden);
|
||||
const cap0 = s.board.length;
|
||||
const funds0 = s.funds;
|
||||
await drag(await center(`.ff-piece[data-uid="${boardUnit.uid}"]`), await center('.shopbar__cards'));
|
||||
assert.equal((await mockState(page)).board.length, cap0, 'the shop bar is not a sell zone');
|
||||
await page.click(`.ff-piece[data-uid="${boardUnit.uid}"]`);
|
||||
await page.waitForSelector('.uframe__btn--sell', { timeout: 3000 });
|
||||
assert.ok(await page.$('.uframe__btn--retreat'), '撤退 too');
|
||||
await page.click('.uframe__btn--sell');
|
||||
await sleep(400);
|
||||
s = await mockState(page);
|
||||
assert.equal(s.board.length, cap0 - 1, '出售 ⇒ sold');
|
||||
assert.equal(s.funds, funds0 + 1);
|
||||
// hand chess → an empty melee tile, then the direction wheel
|
||||
const handChess = s.hand.find((p) => p && p.kind === 'chess');
|
||||
const handLen = s.hand.filter(Boolean).length;
|
||||
const occupied = new Set(s.board.map((p) => `${p.row},${p.col}`));
|
||||
const freeTile = [[9, 9], [9, 8], [12, 6], [11, 7], [10, 7], [9, 7]].find(([r, c]) => !occupied.has(`${r},${c}`));
|
||||
await drag(await center(`.ff-piece[data-uid="${handChess.uid}"]`), await center(`.ff-tile[data-row="${freeTile[0]}"][data-col="${freeTile[1]}"]`));
|
||||
await swipeWheel(page, 'UP');
|
||||
s = await mockState(page);
|
||||
assert.ok(s.board.some((p) => p.uid === handChess.uid && p.row === freeTile[0] && p.col === freeTile[1]), 'moved to the board');
|
||||
assert.equal(s.hand.filter(Boolean).length, handLen - 1);
|
||||
// item → a unit without items
|
||||
const itemPiece = s.hand.find((p) => p && p.kind === 'item');
|
||||
const target = s.board.find((p) => p.kind === 'chess' && (!p.items || p.items.length === 0));
|
||||
await drag(await center(`.ff-piece[data-uid="${itemPiece.uid}"]`), await center(`.ff-piece[data-uid="${target.uid}"]`));
|
||||
s = await mockState(page);
|
||||
assert.ok(s.board.find((p) => p.uid === target.uid).items.some((i) => i.uid === itemPiece.uid), 'equipped');
|
||||
// illegal: melee piece onto high ground does nothing
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('engine drag & drop: tap-sell, hand unit → board unit (swap) + wheel', async (t) => {
|
||||
if (RENDER !== 'engine') { t.skip('SP_RENDER=fallback'); return; }
|
||||
const { page, problems } = await open('phase=PREP');
|
||||
await sleep(1500); // spine models
|
||||
const rect = (uid) => page.evaluate((u) => { const r = globalThis.__SP_VIEW__?.pieceScreenRect(u); return r && { x: r.left + r.width / 2, y: r.top + r.height * 0.65 }; }, uid);
|
||||
const drag = async (from, to) => {
|
||||
await page.mouse.move(from.x, from.y);
|
||||
await page.mouse.down();
|
||||
for (let i = 1; i <= 12; i++) { await page.mouse.move(from.x + ((to.x - from.x) * i) / 12, from.y + ((to.y - from.y) * i) / 12); await sleep(16); }
|
||||
await sleep(100);
|
||||
await page.mouse.up();
|
||||
await sleep(600);
|
||||
};
|
||||
let s = await mockState(page);
|
||||
const unit = s.board.find((x) => x.kind === 'chess' && !x.golden && !(x.items || []).length);
|
||||
const bar = await page.$eval('.shopbar__cards', (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });
|
||||
await drag(await rect(unit.uid), bar);
|
||||
assert.equal((await mockState(page)).board.length, s.board.length, 'no drag-to-sell');
|
||||
const pt = await rect(unit.uid);
|
||||
await page.mouse.click(pt.x, pt.y);
|
||||
await page.waitForSelector('.uframe__btn--sell', { timeout: 3000 });
|
||||
await page.click('.uframe__btn--sell');
|
||||
await sleep(400);
|
||||
const s2 = await mockState(page);
|
||||
assert.equal(s2.board.length, s.board.length - 1, 'sold with the underframe 出售');
|
||||
assert.equal(s2.funds, s.funds + 1);
|
||||
const hc = s2.hand.find((x) => x && x.kind === 'chess' && !x.golden);
|
||||
const target = s2.board.find((x) => x.kind === 'chess' && x.row === 9);
|
||||
await drag(await rect(hc.uid), await rect(target.uid));
|
||||
await swipeWheel(page, 'LEFT');
|
||||
s = await mockState(page);
|
||||
assert.ok(s.board.some((x) => x.uid === hc.uid), 'hand unit deployed');
|
||||
assert.ok(s.hand.some((x) => x && x.uid === target.uid), 'board unit swapped into the hand');
|
||||
assert.equal(s.board.find((x) => x.uid === hc.uid).dir, 'LEFT', 'the mock server stores the wheel direction');
|
||||
assert.equal(s.hand.find((x) => x && x.uid === target.uid).dir, undefined, 'bench pieces carry no direction');
|
||||
// re-orient in place: drop the unit on its own tile → wheel → UP (no duplicate piece)
|
||||
const here = await rect(hc.uid);
|
||||
await page.mouse.move(here.x, here.y);
|
||||
await page.mouse.down();
|
||||
for (let i = 1; i <= 8; i++) { await page.mouse.move(here.x + i * 12, here.y - i * 6); await sleep(16); } // off the tile…
|
||||
for (let i = 7; i >= 0; i--) { await page.mouse.move(here.x + i * 12, here.y - i * 6); await sleep(16); } // …and back onto it
|
||||
await sleep(100);
|
||||
await page.mouse.up();
|
||||
await sleep(600);
|
||||
await swipeWheel(page, 'UP');
|
||||
const s3 = await mockState(page);
|
||||
assert.equal(s3.board.length, s.board.length, 'still one piece');
|
||||
assert.equal(s3.board.find((x) => x.uid === hc.uid).dir, 'UP', 're-oriented in place');
|
||||
assert.equal(await page.evaluate((u) => globalThis.__SP_VIEW__?.raw?.debug?.views?.get('p:' + u)?.dir ?? null, hc.uid), 'UP', 'the model shows it');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('机变 pick, band pick, briefing ready', async () => {
|
||||
let { page, problems } = await open('phase=SP_DRAFT&variant=bounty');
|
||||
await page.click('.spcard.is-pickable');
|
||||
await sleep(300);
|
||||
assert.ok(await page.$('.spcard.is-mine'), 'picked card marked');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
|
||||
({ page, problems } = await open('phase=BAND_DRAFT'));
|
||||
const bands = await page.$$('.dband');
|
||||
await bands[5].click();
|
||||
await page.click('.draft-detail__btns .btn--primary');
|
||||
await sleep(300);
|
||||
assert.ok(await page.$('.dband.is-mine'), 'band picked');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
|
||||
({ page, problems } = await open('phase=INFO_CHECK'));
|
||||
await page.click('.brief__foot .btn--primary');
|
||||
await sleep(300);
|
||||
const txt = await page.$eval('.brief-ready__txt', (el) => el.textContent);
|
||||
assert.match(txt, /3/);
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
// ---- round-1 UI fixes ----------------------------------------------------------------------------------------
|
||||
const rectOf = (page, sel) => page.$eval(sel, (el) => { const r = el.getBoundingClientRect(); return { left: r.left, top: r.top, right: r.right, bottom: r.bottom }; });
|
||||
const overlaps = (a, b) => !!a && !!b && a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
|
||||
|
||||
test('tooltip: after clicking 本局信息 it does not come back with stale text over the dialog', async () => {
|
||||
const { page, problems } = await open('phase=PREP');
|
||||
const btn = await rectOf(page, '.gtop__iconbtn');
|
||||
await page.mouse.move((btn.left + btn.right) / 2, (btn.top + btn.bottom) / 2);
|
||||
await sleep(600);
|
||||
await page.mouse.down();
|
||||
await page.mouse.up();
|
||||
await sleep(800);
|
||||
await page.waitForSelector('.edrawer');
|
||||
const tip = await page.$eval('.tooltip', (el) => ({ shown: el.classList.contains('is-shown'), text: el.textContent })).catch(() => null);
|
||||
assert.ok(!tip || !tip.shown, `no tooltip after the click (got ${JSON.stringify(tip)})`);
|
||||
// leaving and coming back shows the current text
|
||||
await page.mouse.move(5, 500);
|
||||
await sleep(200);
|
||||
await page.mouse.move((btn.left + btn.right) / 2, (btn.top + btn.bottom) / 2);
|
||||
await sleep(600);
|
||||
assert.match(await page.$eval('.tooltip.is-shown', (el) => el.textContent), /本局信息/);
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
// ---- enemy preview pen (research 09 §2 / §6.2 item 3) ------------------------------------------------------------
|
||||
const camera = (page) => page.$eval('.gm', (el) => el.dataset.camera);
|
||||
const sprite = (page, sel) => page.$eval(sel, (el) => ({ sprite: el.dataset.sprite, label: el.getAttribute('aria-label'), disabled: el.getAttribute('aria-disabled') }));
|
||||
|
||||
for (const render of ['engine', 'fallback']) {
|
||||
test(`pen (${render}): 🔍▶▶ pans to the pen, 🔍◀◀ / grey 🔍 / Esc return; the shop folds away and comes back`, async (t) => {
|
||||
if (render === 'engine' && RENDER !== 'engine') { t.skip('SP_RENDER=fallback'); return; }
|
||||
const { page, problems } = await open('phase=PREP', { render });
|
||||
await page.waitForFunction(() => !!globalThis.__SP_VIEW__, { timeout: 15000 });
|
||||
assert.equal(await camera(page), 'prep');
|
||||
assert.deepEqual(await sprite(page, '.enemybtn'), { sprite: 'btn_check_enemy', label: '敌方情报', disabled: 'false' });
|
||||
assert.equal((await sprite(page, '.gtop__iconbtn')).sprite, 'btn_check_player_normal');
|
||||
assert.ok(await page.$('.shopbar:not(.is-collapsed), .shopbar'), 'shop bar shown');
|
||||
const cards0 = await page.$$eval('.shopbar__cards .scard', (els) => els.length);
|
||||
await page.click('.enemybtn');
|
||||
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'pen', { timeout: 3000 });
|
||||
assert.ok(await page.$('.gm.is-pen'));
|
||||
assert.equal((await sprite(page, '.gtop__iconbtn')).sprite, 'btn_check_player_back', 'left button: 🔍◀◀');
|
||||
assert.equal((await sprite(page, '.enemybtn')).sprite, 'btn_check_enemy_unfold', 'right button greys out');
|
||||
assert.equal(await page.$$eval('.shopbar__cards .scard', (els) => els.length), 0, 'the shop bar folded away');
|
||||
assert.equal(await page.$('.edrawer'), null, 'no DOM list: the pen is the preview');
|
||||
await sleep(400);
|
||||
await page.screenshot({ path: path.join(OUT, `ui-pen-${render}.png`) });
|
||||
// the left 🔍◀◀ returns
|
||||
await page.click('.gtop__iconbtn');
|
||||
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'prep', { timeout: 3000 });
|
||||
assert.equal(await page.$$eval('.shopbar__cards .scard', (els) => els.length), cards0, 'the shop bar is back');
|
||||
assert.equal(await page.$('.edrawer'), null, 'returning does not open the info dialog');
|
||||
// the grey right button returns too; Esc returns
|
||||
await page.click('.enemybtn');
|
||||
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'pen');
|
||||
await page.click('.enemybtn');
|
||||
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'prep');
|
||||
await page.click('.enemybtn');
|
||||
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'pen');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'prep');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
}
|
||||
|
||||
test('pen (fallback view): the enemies of m.private.nextEnemies stand in their gate zones; a tap opens the detail card', async () => {
|
||||
const { page, problems } = await open('phase=PREP', { render: 'fallback' });
|
||||
await page.click('.enemybtn');
|
||||
await page.waitForSelector('.ff-pen__enemy', { timeout: 3000 });
|
||||
const s = await mockState(page);
|
||||
const total = s.nextEnemies.reduce((n, e) => n + e.count, 0);
|
||||
const shown = await page.$$eval('.ff-pen__enemy', (els) => els.map((el) => el.dataset.enemy));
|
||||
assert.equal(shown.length, Math.min(50, total), 'one figure per enemy (≤ 50)');
|
||||
assert.deepEqual([...new Set(shown)].sort(), [...new Set(s.nextEnemies.map((e) => e.enemyKey))].sort());
|
||||
await page.click('.ff-pen__enemy');
|
||||
await page.waitForSelector('.dpanel', { timeout: 3000 });
|
||||
assert.equal(await camera(page), 'pen', 'the detail card opens over the pen');
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(200);
|
||||
assert.equal(await page.$('.dpanel'), null, 'Esc closes the card first');
|
||||
assert.equal(await camera(page), 'pen');
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(200);
|
||||
assert.equal(await camera(page), 'prep', 'then leaves the pen');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('pen: only in 休整期 — in combat the right 🔍 is grey and inert; the left 🔍 opens 本局信息 with the enemy list as its 2nd tab', async () => {
|
||||
const { page, problems } = await open('phase=COMBAT');
|
||||
const b = await sprite(page, '.enemybtn');
|
||||
assert.equal(b.sprite, 'btn_check_enemy_unfold');
|
||||
assert.equal(b.disabled, 'true');
|
||||
const cam0 = await camera(page);
|
||||
await page.click('.enemybtn');
|
||||
await sleep(400);
|
||||
assert.equal(await camera(page), cam0, 'no pen outside prep');
|
||||
await page.close();
|
||||
const p2 = await open('phase=PREP');
|
||||
await p2.page.click('.gtop__iconbtn');
|
||||
await p2.page.waitForSelector('.edrawer');
|
||||
const tabs = await p2.page.$$eval('.edrawer .tabs__tab', (els) => els.map((el) => el.textContent.trim()));
|
||||
assert.deepEqual(tabs, ['本局信息', '敌方情报']);
|
||||
assert.equal(await p2.page.$eval('.edrawer .tabs__tab.is-active', (el) => el.textContent.trim()), '本局信息', 'info first');
|
||||
await p2.page.click('.edrawer .tabs__tab:nth-child(2)');
|
||||
await p2.page.waitForSelector('.edrawer .erow');
|
||||
await p2.page.click('.gtop__iconbtn');
|
||||
await sleep(200);
|
||||
assert.equal(await p2.page.$('.edrawer'), null, 'the left 🔍 closes the dialog again');
|
||||
assert.deepEqual([...problems, ...p2.problems], []);
|
||||
await p2.page.close();
|
||||
});
|
||||
|
||||
// ---- review regressions (ui-compat): FA prep on the own half, teammate pen, pen state machine ------------------------
|
||||
for (const side of ['L', 'R']) {
|
||||
test(`Final Assault prep (${side}): the own half of the boss field; a screen swipe → the board direction${side === 'R' ? ' (mirrored)' : ''}`, async (t) => {
|
||||
if (RENDER !== 'engine') { t.skip('SP_RENDER=fallback'); return; }
|
||||
const { page, problems } = await open(`phase=PREP&variant=${side === 'R' ? 'bossR' : 'boss'}`, { w: 1600, h: 900 });
|
||||
await page.waitForFunction(() => !!globalThis.__SP_VIEW__?.raw?.prepField, { timeout: 15000 });
|
||||
await sleep(900);
|
||||
assert.equal(await camera(page), 'bossPrep');
|
||||
assert.deepEqual(await page.evaluate(() => globalThis.__SP_VIEW__.raw.prepField()), { kind: 'bossPrep', side, mirror: side === 'R' });
|
||||
const plan = await page.evaluate(async () => {
|
||||
const v = globalThis.__SP_VIEW__.raw;
|
||||
const S = globalThis.__MOCK__.S();
|
||||
const piece = S.priv.hand.find((x) => x && x.kind === 'chess');
|
||||
const stage = (await import('/js/data.js')).data.lookup('stages', S.pub.stageId);
|
||||
const taken = new Set(S.priv.board.map((b) => `${b.row},${b.col}`));
|
||||
const [row, col] = (stage.deployTiles.normal.melee || []).find(([r, c]) => !taken.has(`${r},${c}`));
|
||||
const r = v.pieceScreenRect(piece.uid);
|
||||
const tt = v.tileScreen(row, col);
|
||||
return { uid: piece.uid, row, col, from: { x: r.left + r.width / 2, y: r.top + r.height * 0.6 }, to: { x: tt.x, y: tt.y } };
|
||||
});
|
||||
const m = page.mouse;
|
||||
await m.move(plan.from.x, plan.from.y); await m.down();
|
||||
for (let i = 1; i <= 14; i++) { await m.move(plan.from.x + ((plan.to.x - plan.from.x) * i) / 14, plan.from.y + ((plan.to.y - plan.from.y) * i) / 14); await sleep(16); }
|
||||
await sleep(120); await m.up();
|
||||
await swipeWheel(page, 'RIGHT');
|
||||
await page.waitForFunction((uid) => globalThis.__MOCK__.S().priv.board.some((b) => b.uid === uid), { timeout: 4000 }, plan.uid);
|
||||
const sent = await page.evaluate(() => globalThis.__MOCK__.S().requests.filter((r) => r[0] === 'g.move').pop()[1]);
|
||||
assert.deepEqual(sent.to, { area: 'board', row: plan.row, col: plan.col, dir: side === 'R' ? 'LEFT' : 'RIGHT' }, 'board coordinates and the board direction');
|
||||
const shown = await page.evaluate((uid) => { const v = globalThis.__SP_VIEW__.raw.debug.views.get('p:' + uid); return { x: v.x, y: v.y, dir: v.dir }; }, plan.uid);
|
||||
assert.deepEqual(shown, { x: side === 'R' ? 20 - plan.col : plan.col, y: plan.row - 7, dir: 'RIGHT' }, 'drawn where it was dropped, facing the swipe');
|
||||
// the pen is a detour of this camera: the way back is the boss-field half again
|
||||
await page.click('.enemybtn');
|
||||
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'pen');
|
||||
await page.click('.gtop__iconbtn');
|
||||
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'bossPrep');
|
||||
assert.equal(await page.evaluate(() => globalThis.__SP_VIEW__.raw.prepField().side), side);
|
||||
await page.screenshot({ path: path.join(OUT, `ui-fa-prep-${side}.png`) });
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
}
|
||||
|
||||
for (const render of ['engine', 'fallback']) {
|
||||
test(`pen (${render}): scouting a teammate in prep shows THEIR pen; the way back is their board, then home shows mine`, async (t) => {
|
||||
if (render === 'engine' && RENDER !== 'engine') { t.skip('SP_RENDER=fallback'); return; }
|
||||
const { page, problems } = await open('phase=PREP', { render });
|
||||
await page.waitForFunction(() => !!globalThis.__SP_VIEW__, { timeout: 15000 });
|
||||
const penKeys = () => page.evaluate(() => {
|
||||
const v = globalThis.__SP_VIEW__;
|
||||
if (v.kind === 'engine') return [...v.raw.debug.penViews.values()].map((x) => [x.info.enemyKey, Math.round(x.info.y)]);
|
||||
return [...document.querySelectorAll('.ff-pen__enemy')].map((el) => [el.dataset.enemy, null]);
|
||||
});
|
||||
const rows = await page.$$('.team__btn');
|
||||
await rows[2].click();
|
||||
await sleep(250);
|
||||
if (await page.$('.team__ob')) await page.click('.team__ob');
|
||||
await page.waitForSelector('.gm__watching', { timeout: 4000 });
|
||||
await page.click('.enemybtn');
|
||||
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'pen', { timeout: 3000 });
|
||||
await sleep(300);
|
||||
const theirs = await page.evaluate(() => globalThis.__MOCK__.store.get().match.field.nextEnemies);
|
||||
const shown = await penKeys();
|
||||
assert.equal(shown.length, theirs.reduce((n, e) => n + e.count, 0), 'one figure per enemy of THEIR round');
|
||||
assert.deepEqual([...new Set(shown.map((x) => x[0]))].sort(), [...new Set(theirs.map((e) => e.enemyKey))].sort());
|
||||
if (render === 'engine') assert.ok(shown.every((x) => x[1] >= 17), 'their upper-gate enemies stand in rows 17–18');
|
||||
await page.click('.gtop__iconbtn');
|
||||
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'normal', { timeout: 3000 });
|
||||
assert.ok(await page.$('.gm__watching'), 'still scouting the teammate');
|
||||
await page.click('.gm__watching button');
|
||||
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'prep', { timeout: 3000 });
|
||||
await page.click('.enemybtn');
|
||||
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'pen', { timeout: 3000 });
|
||||
await sleep(300);
|
||||
const mine = await page.evaluate(() => globalThis.__MOCK__.S().priv.nextEnemies);
|
||||
assert.equal((await penKeys()).length, Math.min(50, mine.reduce((n, e) => n + e.count, 0)), 'home: my own pen again');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
}
|
||||
|
||||
test('pen: leaving prep while the pen is shown returns the camera and unfolds the shop; a folded shop stays folded', async (t) => {
|
||||
if (RENDER !== 'engine') { t.skip('SP_RENDER=fallback'); return; }
|
||||
const { page, problems } = await open('phase=PREP');
|
||||
await page.waitForFunction(() => !!globalThis.__SP_VIEW__, { timeout: 15000 });
|
||||
await page.click('.enemybtn');
|
||||
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'pen');
|
||||
await page.evaluate(() => globalThis.__MOCK__.setPhase('COMBAT'));
|
||||
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'normal', { timeout: 4000 });
|
||||
assert.equal(await page.$('.gm.is-pen'), null);
|
||||
assert.equal(await page.evaluate(() => globalThis.__SP_VIEW__.raw.debug.camKind), 'normal', 'the view left the pen too');
|
||||
assert.equal(await page.evaluate(() => globalThis.__SP_VIEW__.raw.stats().pen), 0, 'a real battle empties the pen');
|
||||
await page.evaluate(() => globalThis.__MOCK__.setPhase('PREP'));
|
||||
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'prep', { timeout: 4000 });
|
||||
assert.equal(await page.$('.gm.is-collapsed'), null, 'the shop is back');
|
||||
await page.click('.funds__collapse');
|
||||
await page.click('.enemybtn');
|
||||
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'pen');
|
||||
for (let i = 0; i < 9; i++) await page.click(i % 2 ? '.enemybtn' : '.gtop__iconbtn'); // fast back-and-forth
|
||||
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'prep', { timeout: 3000 });
|
||||
await sleep(400);
|
||||
assert.equal(await page.evaluate(() => globalThis.__SP_VIEW__.raw.debug.camKind), 'prep', 'view and HUD agree');
|
||||
assert.ok(await page.$('.gm.is-collapsed'), 'the shop folded before the pen stays folded');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
for (const [w, h] of [[1920, 1080], [1366, 768]]) {
|
||||
test(`toasts clear the top bar capsule; the reconnect banner clears the view switcher (${w}×${h})`, async () => {
|
||||
let { page, problems } = await open('phase=PREP', { w, h });
|
||||
await page.evaluate(() => import('/js/ui/toasts.js').then((m) => m.toast('整备区已满', 'error')));
|
||||
await sleep(500);
|
||||
const toastR = await rectOf(page, '.toast');
|
||||
const cap = await rectOf(page, '.gtop__center');
|
||||
assert.ok(!overlaps(toastR, cap), `toast ${JSON.stringify(toastR)} over the capsule ${JSON.stringify(cap)}`);
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
|
||||
({ page, problems } = await open('phase=COMBAT', { w, h }));
|
||||
await page.evaluate(() => globalThis.__MOCK__.store.set({ connection: { status: 'reconnecting', ping: 0, attempt: 2, retryAt: Date.now() + 5000, lastError: null, everOnline: true } }));
|
||||
await page.waitForSelector('.conn-banner');
|
||||
await sleep(900);
|
||||
const banner = await rectOf(page, '.conn-banner');
|
||||
const vs = await rectOf(page, '.vswitch');
|
||||
assert.ok(!overlaps(banner, vs), `banner ${JSON.stringify(banner)} over the switcher ${JSON.stringify(vs)}`);
|
||||
assert.ok(!overlaps(banner, await rectOf(page, '.gtop__center')), 'banner clear of the capsule');
|
||||
const hit = await page.evaluate((x, y) => !!document.elementFromPoint(x, y)?.closest('.vswitch'), (vs.left + vs.right) / 2, (vs.top + vs.bottom) / 2);
|
||||
assert.ok(hit, 'the switcher stays clickable');
|
||||
await page.evaluate(() => import('/js/ui/toasts.js').then((m) => m.toast('连接中断', 'error')));
|
||||
await sleep(500);
|
||||
assert.ok(!overlaps(await rectOf(page, '.toast'), banner), 'toasts stack below the banner');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
}
|
||||
|
||||
test('watch: the other pair of the Final Assault is refused and the eye stays put', async () => {
|
||||
const { page, problems } = await open('phase=FINAL_ASSAULT');
|
||||
const rows = await page.$$('.team__row');
|
||||
await (await rows[2].$('.team__btn')).click(); // P3: the other pair (field b2)
|
||||
await sleep(500);
|
||||
const watched = await page.$$eval('.team__row.is-watched .team__name', (els) => els.map((e) => e.textContent));
|
||||
assert.ok(!watched.includes('Doctor·B') && !watched.includes('灰烬'), `other pair not marked watched (${watched})`);
|
||||
assert.match(await page.$eval('.toast', (el) => el.textContent), /另一组/);
|
||||
assert.equal(await page.$eval('.vswitch__label', (el) => el.textContent), '全景');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('a battle unit panel opened in combat closes when the next prep starts', async () => {
|
||||
const { page, problems } = await open('phase=COMBAT', { render: 'fallback' });
|
||||
await page.waitForSelector('.ff-unit.is-ally');
|
||||
await page.$eval('.ff-unit.is-ally', (el) => el.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 10, clientY: 10 })));
|
||||
await page.waitForSelector('.dpanel');
|
||||
await page.evaluate(() => globalThis.__MOCK__.setPhase('SETTLE'));
|
||||
await sleep(300);
|
||||
assert.ok(await page.$('.dpanel'), 'still open while the battle is being settled');
|
||||
await page.evaluate(() => globalThis.__MOCK__.setPhase('PREP'));
|
||||
await sleep(400);
|
||||
assert.equal(await page.$('.dpanel'), null, 'closed in the next prep');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('R / F / D / Space do nothing behind the 本局信息 / 敌方情报 drawer; Esc closes it', async () => {
|
||||
const { page, problems } = await open('phase=PREP');
|
||||
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => { S.priv.funds = 40; }));
|
||||
await sleep(100);
|
||||
const before = await mockState(page);
|
||||
await page.click('.gtop__iconbtn[aria-label="本局信息"]');
|
||||
await page.waitForSelector('.edrawer');
|
||||
for (const k of ['KeyR', 'KeyF', 'KeyD', 'Space']) { await page.keyboard.press(k); await sleep(150); }
|
||||
const s = await mockState(page);
|
||||
assert.deepEqual([s.funds, s.shop.frozen, s.shop.level, s.ready], [before.funds, before.shop.frozen, before.shop.level, before.ready], 'nothing acted behind the drawer');
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(200);
|
||||
assert.equal(await page.$('.edrawer'), null, 'Esc closes the drawer');
|
||||
await page.keyboard.press('KeyF');
|
||||
await sleep(300);
|
||||
assert.equal((await mockState(page)).shop.frozen, !before.shop.frozen, 'the shortcuts work again');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
for (const render of ['fallback', 'engine']) {
|
||||
test(`a detail card opened by right-click closes when the field is pressed (${render})`, async (t) => {
|
||||
if (render === 'engine' && RENDER !== 'engine') { t.skip('SP_RENDER=fallback'); return; }
|
||||
const { page, problems } = await open('phase=PREP', { render });
|
||||
await page.waitForFunction((k) => globalThis.__SP_VIEW__?.kind === k, { timeout: 15000 }, render);
|
||||
if (render === 'engine') await sleep(1500); // spine models
|
||||
const s = await mockState(page);
|
||||
const unit = s.board.find((p) => p.kind === 'chess');
|
||||
const occupied = new Set(s.board.map((p) => `${p.row},${p.col}`));
|
||||
const [er, ec] = [[9, 9], [9, 8], [11, 7], [10, 7], [9, 7], [12, 6]].find(([r, c]) => !occupied.has(`${r},${c}`));
|
||||
const pts = await page.evaluate((uid, row, col) => {
|
||||
const v = globalThis.__SP_VIEW__;
|
||||
const r = v.pieceScreenRect(uid);
|
||||
const tile = document.querySelector(`.ff-tile[data-row="${row}"][data-col="${col}"]`)?.getBoundingClientRect();
|
||||
const ts = tile ? { x: tile.left + tile.width / 2, y: tile.top + tile.height / 2 } : v.raw.tileScreen(row, col);
|
||||
return { piece: { x: r.left + r.width / 2, y: r.top + r.height * 0.65 }, tile: { x: ts.x, y: ts.y } };
|
||||
}, unit.uid, er, ec);
|
||||
await page.mouse.click(pts.piece.x, pts.piece.y, { button: 'right' });
|
||||
await page.waitForSelector('.dpanel', { timeout: 3000 });
|
||||
await sleep(300);
|
||||
assert.equal(await page.$('.uframe'), null, 'right-click only opens the card (no selection)');
|
||||
await page.mouse.click(pts.tile.x, pts.tile.y);
|
||||
await sleep(400);
|
||||
assert.equal(await page.$('.dpanel'), null, `${render}: a press on an empty tile closes the card`);
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
}
|
||||
|
||||
test('an eliminated player sees one elimination banner during SETTLE', async () => {
|
||||
for (const phase of ['SETTLE', 'PREP']) {
|
||||
const { page, problems } = await open(`phase=${phase}&variant=dead`, { render: 'fallback' });
|
||||
await sleep(300);
|
||||
const banners = await page.$$eval('.gm__dead, .chud__msg--dead', (els) => els.map((el) => el.className));
|
||||
assert.equal(banners.length, 1, `${phase}: ${JSON.stringify(banners)}`);
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('leaving the match screen clears the __SP_VIEW__ dev hook (no detached screen kept alive)', async () => {
|
||||
const { page, problems } = await open('phase=PREP', { render: 'fallback' });
|
||||
await page.waitForFunction(() => !!globalThis.__SP_VIEW__, { timeout: 15000 });
|
||||
await page.evaluate(() => globalThis.__MOCK__.setPhase('RESULT'));
|
||||
await page.waitForFunction(() => !document.querySelector('.gm'), { timeout: 5000 });
|
||||
await sleep(200);
|
||||
assert.equal(await page.evaluate(() => globalThis.__SP_VIEW__), null);
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('result: no Hidden Core medal when R15 was never reached', async () => {
|
||||
const { page, problems } = await open('phase=RESULT&variant=defeat');
|
||||
assert.equal(await page.$('.medal--hidden'), null);
|
||||
assert.ok(await page.$('.medal'), 'the leader medal is still shown');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('engine: equipment released over an operator\'s upper body equips it', 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 drag = async (from, to) => {
|
||||
await page.mouse.move(from.x, from.y);
|
||||
await page.mouse.down();
|
||||
for (let i = 1; i <= 12; i++) { await page.mouse.move(from.x + ((to.x - from.x) * i) / 12, from.y + ((to.y - from.y) * i) / 12); await sleep(16); }
|
||||
await sleep(120);
|
||||
await page.mouse.up();
|
||||
await sleep(600);
|
||||
};
|
||||
let s = await mockState(page);
|
||||
const targets = s.board.filter((x) => x.kind === 'chess' && !(x.items || []).length).sort((a, b) => a.row - b.row);
|
||||
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));
|
||||
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.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('an illegal drop says why (DOM fallback: pieceDrop, engine: cancelled drag)', async () => {
|
||||
for (const render of RENDER === 'engine' ? ['fallback', 'engine'] : ['fallback']) {
|
||||
const { page, problems } = await open('phase=PREP', { render });
|
||||
if (render === 'engine') await sleep(1500);
|
||||
// deploy cap reached: a hand operator dropped on any empty board tile is refused
|
||||
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => { S.priv.deployCap = S.priv.board.filter((x) => x.kind === 'chess').length; S.priv.deployCount = S.priv.deployCap; }));
|
||||
await sleep(200);
|
||||
const s = await mockState(page);
|
||||
const hc = s.hand.find((x) => x && x.kind === 'chess' && !x.golden);
|
||||
const occupied = new Set(s.board.map((x) => `${x.row},${x.col}`));
|
||||
let from;
|
||||
let to = null;
|
||||
if (render === 'fallback') {
|
||||
from = await page.$eval(`.ff-piece[data-uid="${hc.uid}"]`, (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });
|
||||
const free = [[9, 9], [9, 8], [12, 6], [11, 7], [10, 7], [9, 7], [10, 4], [11, 4]].find(([r, c]) => !occupied.has(`${r},${c}`));
|
||||
to = await page.$eval(`.ff-tile[data-row="${free[0]}"][data-col="${free[1]}"]`, (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });
|
||||
} else {
|
||||
from = await page.evaluate((u) => { const r = globalThis.__SP_VIEW__.pieceScreenRect(u); return { x: r.left + r.width / 2, y: r.top + r.height * 0.65 }; }, hc.uid);
|
||||
// find the screen point of an empty board tile through the view's tileHover events
|
||||
await page.evaluate(() => { globalThis.__TH__ = null; globalThis.__SP_VIEW__.on('tileHover', (t) => { globalThis.__TH__ = t; }); });
|
||||
const box = await page.evaluate((uids) => {
|
||||
const rs = uids.map((u) => globalThis.__SP_VIEW__.pieceScreenRect(u)).filter(Boolean);
|
||||
return { l: Math.min(...rs.map((r) => r.left)) - 150, r: Math.max(...rs.map((r) => r.right)) + 150, t: Math.min(...rs.map((r) => r.top)), b: Math.max(...rs.map((r) => r.bottom)) + 40 };
|
||||
}, s.board.map((x) => x.uid));
|
||||
for (let y = box.t; y <= box.b && !to; y += 18) {
|
||||
for (let x = box.l; x <= box.r && !to; x += 18) {
|
||||
await page.mouse.move(x, y);
|
||||
const t = await page.evaluate(() => globalThis.__TH__);
|
||||
if (t && t.area === 'board' && !occupied.has(`${t.row},${t.col}`)) to = { x, y };
|
||||
}
|
||||
}
|
||||
assert.ok(to, 'found an empty board tile on screen');
|
||||
}
|
||||
await page.mouse.move(from.x, from.y);
|
||||
await page.mouse.down();
|
||||
for (let i = 1; i <= 12; i++) { await page.mouse.move(from.x + ((to.x - from.x) * i) / 12, from.y + ((to.y - from.y) * i) / 12); await sleep(16); }
|
||||
await sleep(120);
|
||||
await page.mouse.up();
|
||||
await sleep(500);
|
||||
const toastText = await page.$eval('.toast', (el) => el.textContent).catch(() => null);
|
||||
assert.match(String(toastText), /已达到部署上限|无法部署在该位置|近战单位只能部署在地面/, `${render}: toast explains the refusal`);
|
||||
assert.ok((await mockState(page)).hand.some((x) => x && x.uid === hc.uid), `${render}: the operator stays in the hand`);
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('emote, exit → 暂离 → 返回', async () => {
|
||||
const { page, problems } = await open('phase=PREP');
|
||||
await page.click('.ewheel__btn');
|
||||
await page.click('.ewheel__item');
|
||||
await page.waitForSelector('.team__bubble');
|
||||
await page.click('.gtop__exit');
|
||||
await page.waitForSelector('.modal');
|
||||
const buttons = await page.$$('.modal__actions .btn');
|
||||
await buttons[1].click(); // 暂离
|
||||
await page.waitForSelector('.awayov');
|
||||
await page.click('.awayov .btn');
|
||||
await sleep(300);
|
||||
assert.equal(await page.$('.awayov'), null);
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
// Official module (uniequip) TYPE icons from the local-client art (DESIGN §13 / §16): `data/local-assets.json`
|
||||
// groups.module is keyed by the client's mixed-case file names ('PRI-X', 'mar-x', …) and matched case-insensitively
|
||||
// against a module's typeName; the art is optional — without the manifest or an entry every module UI keeps its
|
||||
// lettered tile / type text.
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { moduleTypeIconUrl } from '../../public/js/ui/assetUrls.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const LOCAL = path.join(ROOT, 'data/local-assets.json');
|
||||
|
||||
const entry = (k) => ({ path: `/assets/local/module/${k}.png`, w: 50, h: 50, kind: 'Sprite' });
|
||||
const manifest = {
|
||||
version: 1,
|
||||
groups: { module: Object.fromEntries(['PRI-X', 'pri-y', 'mar-x', 'isw-a', 'TRP-D'].map((k) => [k, entry(k)])) },
|
||||
};
|
||||
|
||||
let mode = 'ok';
|
||||
globalThis.fetch = async (url) => {
|
||||
if (String(url).endsWith('/local-assets.json') && mode === 'ok') return { ok: true, status: 200, json: async () => manifest };
|
||||
return { ok: false, status: 404, json: async () => ({}) };
|
||||
};
|
||||
|
||||
/** 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);
|
||||
if (v.props?.fallback) yield* walk(v.props.fallback);
|
||||
}
|
||||
const textOf = (v) => [...walk(v)].map((n) => n.props?.children).flat().filter((c) => typeof c === 'string' || typeof c === 'number').join('');
|
||||
|
||||
describe('module type icons (local-client art, optional)', () => {
|
||||
test('lookup is case-insensitive both ways and maps the Greek type letters; exact keys win', () => {
|
||||
assert.equal(moduleTypeIconUrl(manifest, 'MAR-X'), '/assets/local/module/mar-x.png', 'upper-case type → lower-case file');
|
||||
assert.equal(moduleTypeIconUrl(manifest, 'pri-x'), '/assets/local/module/PRI-X.png', 'lower-case type → upper-case file');
|
||||
assert.equal(moduleTypeIconUrl(manifest, 'Pri-Y'), '/assets/local/module/pri-y.png');
|
||||
assert.equal(moduleTypeIconUrl(manifest, 'PRI-X'), '/assets/local/module/PRI-X.png');
|
||||
assert.equal(moduleTypeIconUrl(manifest, ' TRP-D '), '/assets/local/module/TRP-D.png', 'trimmed');
|
||||
assert.equal(moduleTypeIconUrl(manifest, 'ISW-A'), '/assets/local/module/isw-a.png');
|
||||
assert.equal(moduleTypeIconUrl(manifest, 'ISW-α'), '/assets/local/module/isw-a.png', 'α → a');
|
||||
assert.equal(moduleTypeIconUrl(manifest, 'TRP-Δ'), '/assets/local/module/TRP-D.png', 'Δ → d');
|
||||
});
|
||||
|
||||
test('missing manifest / group / entry / junk ⇒ null (the caller draws the lettered tile), never throws', () => {
|
||||
for (const local of [null, undefined, {}, { groups: null }, { groups: {} }, { groups: { module: 'x' } }, 42, 'str']) {
|
||||
assert.equal(moduleTypeIconUrl(local, 'MAR-X'), null, JSON.stringify(local));
|
||||
}
|
||||
assert.equal(moduleTypeIconUrl(manifest, 'MAR-Y'), null, 'entry missing');
|
||||
for (const t of [null, undefined, '', ' ', 42, {}]) assert.equal(moduleTypeIconUrl(manifest, t), null, String(t));
|
||||
const broken = { groups: { module: { 'mar-x': { w: 1 }, 'MAR-Y': { path: '' }, 'mar-y': entry('mar-y') } } };
|
||||
assert.equal(moduleTypeIconUrl(broken, 'MAR-X'), null, 'entry without a path');
|
||||
assert.equal(moduleTypeIconUrl(broken, 'MAR-Y'), '/assets/local/module/mar-y.png', 'an empty exact entry falls through to a valid one');
|
||||
});
|
||||
|
||||
test('the real manifest (when extracted on this machine) covers every module type of chess.json, with on-disk files', { skip: !existsSync(LOCAL) && 'no data/local-assets.json (optional)' }, () => {
|
||||
const local = JSON.parse(readFileSync(LOCAL, 'utf8'));
|
||||
if (!local?.groups?.module) return; // an older extraction without the module group: the UI falls back
|
||||
const chess = JSON.parse(readFileSync(path.join(ROOT, 'data/chess.json'), 'utf8'));
|
||||
const recs = Array.isArray(chess) ? chess : Object.values(chess.chess || chess);
|
||||
const types = new Set(recs.flatMap((c) => (Array.isArray(c?.modules) ? c.modules : []).map((m) => m?.typeName).filter(Boolean)));
|
||||
assert.ok(types.size > 50, `module types in chess.json: ${types.size}`);
|
||||
const missing = [];
|
||||
for (const t of types) {
|
||||
const url = moduleTypeIconUrl(local, t);
|
||||
if (!url) { missing.push(t); continue; }
|
||||
assert.ok(existsSync(path.join(ROOT, 'public', url)), `${t} → ${url} exists`);
|
||||
}
|
||||
assert.deepEqual(missing, [], 'every module type has an official icon');
|
||||
});
|
||||
|
||||
test('干员调配 module tile: official icon when listed, lettered tile when the manifest or the entry is missing', async () => {
|
||||
const { data } = await import('../../public/js/data.js');
|
||||
const { Img } = await import('../../public/js/ui/gameComponents.js');
|
||||
const { ModuleGlyph } = await import('../../public/js/screens/loadout.js');
|
||||
const imgOf = (vnode) => [...walk(vnode)].find((n) => n.type === Img);
|
||||
await data.load('local');
|
||||
let img = imgOf(ModuleGlyph({ m: null, rec: { uniEquipId: 'uniequip_002_x', typeName: 'MAR-X' }, id: 'uniequip_002_x' }));
|
||||
assert.equal(img.props.src, '/assets/local/module/mar-x.png');
|
||||
assert.equal(textOf(img.props.fallback), 'X', 'the letter stays as the image-error fallback');
|
||||
img = imgOf(ModuleGlyph({ m: null, rec: { uniEquipId: 'uniequip_003_x', typeName: 'MAR-Y' }, id: 'uniequip_003_x' }));
|
||||
assert.equal(img.props.src, null, 'unlisted type');
|
||||
assert.equal(textOf(img.props.fallback), 'Y');
|
||||
// manifest missing
|
||||
mode = 'missing';
|
||||
const orig = console.warn; console.warn = () => {};
|
||||
try { await data.invalidate('local'); } finally { console.warn = orig; }
|
||||
assert.equal(data.status('local'), 'missing');
|
||||
img = imgOf(ModuleGlyph({ m: null, rec: { uniEquipId: 'uniequip_002_x', typeName: 'MAR-X' }, id: 'uniequip_002_x' }));
|
||||
assert.equal(img.props.src, null);
|
||||
assert.equal(textOf(img.props.fallback), 'X');
|
||||
assert.equal(moduleTypeIconUrl(data.get('local'), 'MAR-X'), null);
|
||||
});
|
||||
|
||||
test('the in-match detail panel and shop card resolve the icon from the local manifest and keep their text fallback', () => {
|
||||
const detail = readFileSync(path.join(ROOT, 'public/js/ui/detailPanel.js'), 'utf8');
|
||||
assert.match(detail, /<\$\{Img\} src=\$\{moduleTypeIconUrl\(data\.get\('local'\), lo\.module\.typeName\)\} fallback=\$\{html`<b class="num">\$\{moduleBadge\(lo\.module\)\}<\/b>`\}/);
|
||||
assert.match(detail, /<span class="dmodule__type">\$\{lo\.module\.typeName\}<\/span>/, 'the type text stays');
|
||||
const shop = readFileSync(path.join(ROOT, 'public/js/ui/shopBar.js'), 'utf8');
|
||||
assert.match(shop, /<\$\{Img\} src=\$\{moduleTypeIconUrl\(data\.get\('local'\), mod\.typeName\)\} class="scard__modicon" \/>\$\{mod\.typeName\}/,
|
||||
'icon beside the type text (Img renders nothing without it)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,439 @@
|
||||
// Browser regressions of user playtest #2 (UI items 6–9 + the loadout display of item 1), through the mock harness
|
||||
// (public/dev/game-mock.html) in headless Chrome (puppeteer-core + system Chrome). Opt-in: SP_E2E=1.
|
||||
//
|
||||
// SP_E2E=1 node --test test/ui/playtest2.e2e.test.js
|
||||
// SP_E2E=1 SP_P2_ONLY=pen|sell|reach|bonds|loadout node --test test/ui/playtest2.e2e.test.js
|
||||
//
|
||||
// 6 the enemy preview pen is shown only by the pen camera: never with the prep board (after 🔍◀◀ "返回战场" too) nor in
|
||||
// any battle camera (normal / 联防 / 最终攻势 / 隐秘核心): no pen figures, no pen rows drawn (2D), no pen area built (3D)
|
||||
// 7 the underframe's 出售 / 销毁 buttons are coloured plates (amber / red), not a white block — 3D engine and DOM fallback
|
||||
// 8 at 16:9, 16:10, 21:9 (2560×1080 and 3440×1440), 4:3, 1280×720, 1366×768 and iPad landscape every bench / temp /
|
||||
// board unit's underframe buttons are the topmost element under their centre (clickable) and the detail card
|
||||
// never overlaps them; a real click on 出售 sells
|
||||
// 9 the detail card shows the unit's bonds (icon, name, count / threshold, tier pips) in the header's right column
|
||||
// (under name / tier, beside the portrait), visible without scrolling; the bond popup lists its facts and the
|
||||
// current effect before the long description and keeps clear of the selected unit's underframe
|
||||
// §16 the loadout's skill / module: shop card skill badge (mint when not the default; S1–S3 when the manifest has no
|
||||
// icon for the chosen skill), detail card skill + 已调配, an elite with its module unequipped shows 未装备模组
|
||||
// Screenshots: test/e2e/out/fix-*.png.
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const OUT = path.join(ROOT, 'test/e2e/out');
|
||||
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
|
||||
const ONLY = process.env.SP_P2_ONLY || '';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const skipUnless = (k) => (ONLY && ONLY !== k ? `SP_P2_ONLY=${ONLY}` : false);
|
||||
|
||||
/** Viewports of item 8 (name, w, h). */
|
||||
const SIZES = [
|
||||
['16x9', 1920, 1080], ['16x10', 1680, 1050], ['21x9', 2560, 1080], ['uw', 3440, 1440], ['4x3', 1600, 1200],
|
||||
['720p', 1280, 720], ['1366', 1366, 768], ['ipad', 1180, 820],
|
||||
];
|
||||
|
||||
describe('user playtest #2 — UI fixes (mock harness, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||
let srv;
|
||||
let browser;
|
||||
let base;
|
||||
|
||||
before(async () => {
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
base = `http://127.0.0.1:${srv.port}`;
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-sandbox', '--force-device-scale-factor=1'] });
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
await srv?.close();
|
||||
});
|
||||
|
||||
async function open(query, { w = 1920, h = 1080, render = 'engine' } = {}) {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: w, height: h });
|
||||
const problems = [];
|
||||
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
page.on('requestfailed', (r) => problems.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`));
|
||||
page.on('response', (r) => { if (r.status() >= 400) problems.push(`http ${r.status()}: ${r.url()}`); });
|
||||
await page.goto(`${base}/dev/game-mock.html?shot=1&render=${render}&${query}`, { waitUntil: 'networkidle0' });
|
||||
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)') && !!globalThis.__SP_VIEW__, { timeout: 20000 });
|
||||
await sleep(render === 'engine' ? 1600 : 700);
|
||||
return { page, problems };
|
||||
}
|
||||
|
||||
/** Mean RGB of a client rect of the page (screenshot decoded by the page itself). */
|
||||
async function meanColor(page, r) {
|
||||
const clip = { x: Math.max(0, r.x), y: Math.max(0, r.y), width: Math.max(1, r.w), height: Math.max(1, r.h) };
|
||||
const b64 = await page.screenshot({ clip, encoding: 'base64' });
|
||||
return page.evaluate(async (src) => {
|
||||
const img = new Image();
|
||||
img.src = `data:image/png;base64,${src}`;
|
||||
await img.decode();
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = img.width; cv.height = img.height;
|
||||
const g = cv.getContext('2d');
|
||||
g.drawImage(img, 0, 0);
|
||||
const d = g.getImageData(0, 0, cv.width, cv.height).data;
|
||||
let R = 0, G = 0, B = 0, n = 0;
|
||||
for (let i = 0; i < d.length; i += 4) { R += d[i]; G += d[i + 1]; B += d[i + 2]; n++; }
|
||||
return { r: R / n, g: G / n, b: B / n };
|
||||
}, b64);
|
||||
}
|
||||
|
||||
/** Client rect of a prep piece (plain object: the DOM fallback returns a DOMRect). */
|
||||
const pieceRect = (page, uid) => page.evaluate((uid) => {
|
||||
const r = globalThis.__SP_VIEW__.pieceScreenRect(uid);
|
||||
return r ? { left: r.left, top: r.top, width: r.width, height: r.height } : null;
|
||||
}, uid);
|
||||
|
||||
/**
|
||||
* Tap an own prep piece (real mouse) until ITS underframe shows (a unit standing in front can take a low tap: the
|
||||
* next try is higher up the sprite). Pieces without an underframe (bench summons) just get one tap.
|
||||
*/
|
||||
async function tapPiece(page, uid, { underframe = true } = {}) {
|
||||
for (const at of [0.72, 0.4, 0.18, 0.88]) {
|
||||
const r = await pieceRect(page, uid);
|
||||
assert.ok(r && r.width > 0, `piece ${uid} on screen`);
|
||||
await page.mouse.click(r.left + r.width / 2, r.top + r.height * at);
|
||||
await sleep(350);
|
||||
if (!underframe) return true;
|
||||
if (await page.$(`.uframe[data-uid="${uid}"]`)) return true;
|
||||
await page.keyboard.press('Escape');
|
||||
await page.mouse.click(5, Math.round((await page.evaluate(() => innerHeight)) / 2)); // deselect (field press)
|
||||
await sleep(150);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Every underframe button: centre, topmost-ness, overlap with the detail card. */
|
||||
const underframeState = (page) => page.evaluate(() => {
|
||||
const panel = document.querySelector('.dpanel');
|
||||
const pr = panel ? panel.getBoundingClientRect() : null;
|
||||
const out = [];
|
||||
for (const b of document.querySelectorAll('.uframe__btn')) {
|
||||
const r = b.getBoundingClientRect();
|
||||
const x = r.left + r.width / 2; const y = r.top + r.height * 0.35;
|
||||
const top = document.elementFromPoint(x, y);
|
||||
const overlap = !!pr && r.left < pr.right && r.right > pr.left && r.top < pr.bottom && r.bottom > pr.top;
|
||||
out.push({ cls: b.className, x, y, topmost: !!top && b.contains(top), inView: x > 0 && y > 0 && x < innerWidth && y < innerHeight, overlap });
|
||||
}
|
||||
return { buttons: out, side: panel?.dataset.side ?? null };
|
||||
});
|
||||
|
||||
// ---- 7 -------------------------------------------------------------------------------------------------------------
|
||||
|
||||
for (const render of ['engine', 'fallback']) {
|
||||
test(`7: 出售 / 销毁 are amber / red plates, not a white block (${render})`, { skip: skipUnless('sell') }, async () => {
|
||||
const { page, problems } = await open('phase=PREP', { render });
|
||||
const uids = await page.evaluate(() => {
|
||||
const p = globalThis.__MOCK__.S().priv;
|
||||
return { chess: p.hand.find((x) => x && x.kind === 'chess')?.uid, item: p.hand.find((x) => x && x.kind === 'item')?.uid };
|
||||
});
|
||||
await tapPiece(page, uids.chess);
|
||||
await page.waitForSelector('.uframe__btn--sell .uframe__plate', { timeout: 3000 });
|
||||
const plate = await page.$eval('.uframe__btn--sell .uframe__plate', (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width * 0.2, y: r.top + r.height * 0.2, w: r.width * 0.6, h: r.height * 0.6 }; });
|
||||
const c = await meanColor(page, plate);
|
||||
assert.ok(!(c.r > 225 && c.g > 225 && c.b > 225), `sell plate is not white (${JSON.stringify(c)})`);
|
||||
assert.ok(c.r > c.b + 50 && c.g > c.b + 10, `sell plate reads amber (${JSON.stringify(c)})`);
|
||||
await page.screenshot({ path: path.join(OUT, `fix-sell-${render}.png`) });
|
||||
await page.keyboard.press('Escape');
|
||||
await tapPiece(page, uids.item);
|
||||
await page.waitForSelector('.uframe__btn--destroy .uframe__plate', { timeout: 3000 });
|
||||
const dp = await page.$eval('.uframe__btn--destroy .uframe__plate', (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width * 0.2, y: r.top + r.height * 0.2, w: r.width * 0.6, h: r.height * 0.6 }; });
|
||||
const d = await meanColor(page, dp);
|
||||
assert.ok(!(d.r > 225 && d.g > 225 && d.b > 225), `destroy plate is not white (${JSON.stringify(d)})`);
|
||||
assert.ok(d.r > d.g + 60 && d.r > d.b + 60, `destroy plate reads red (${JSON.stringify(d)})`);
|
||||
await page.screenshot({ path: path.join(OUT, `fix-destroy-${render}.png`) });
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 8 -------------------------------------------------------------------------------------------------------------
|
||||
|
||||
for (const render of ['engine', 'fallback']) {
|
||||
test(`8: every bench / temp / board underframe stays clickable at 8 viewports (${render})`, { skip: skipUnless('reach'), timeout: 12 * 60 * 1000 }, async () => {
|
||||
for (const [name, w, h] of SIZES) {
|
||||
const { page, problems } = await open('phase=PREP', { w, h, render });
|
||||
// a full bench and a full temp row (chess + one item each)
|
||||
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => {
|
||||
const pool = S.pool;
|
||||
let k = 0;
|
||||
let uid = 9000;
|
||||
const chess = () => { const c = pool[(k += 3) % pool.length]; return { uid: ++uid, kind: 'chess', id: c.chessId, golden: false, tier: c.tier, items: [] }; };
|
||||
for (let i = 0; i < S.priv.hand.length; i++) if (!S.priv.hand[i] || S.priv.hand[i].kind === 'token') S.priv.hand[i] = chess();
|
||||
for (let i = 0; i < S.priv.temp.length; i++) S.priv.temp[i] = chess();
|
||||
S.priv.temp[4] = { uid: ++uid, kind: 'item', id: S.priv.hand.find((x) => x && x.kind === 'item')?.id, golden: false, tier: 1 };
|
||||
}));
|
||||
await sleep(600);
|
||||
const pieces = await page.evaluate(() => {
|
||||
const p = globalThis.__MOCK__.S().priv;
|
||||
return [
|
||||
...p.hand.map((x, i) => x && { uid: x.uid, where: `hand${i}` }),
|
||||
...p.temp.map((x, i) => x && { uid: x.uid, where: `temp${i}` }),
|
||||
...p.board.map((x) => ({ uid: x.uid, where: `board${x.row},${x.col}` })),
|
||||
].filter(Boolean);
|
||||
});
|
||||
let checked = 0;
|
||||
let rightDocked = 0;
|
||||
const board = await page.evaluate(() => globalThis.__MOCK__.S().priv.board.map((p) => ({ uid: p.uid, row: p.row, col: p.col })));
|
||||
for (const pc of pieces) {
|
||||
const tapped = await tapPiece(page, pc.uid);
|
||||
// a board unit standing right behind others in its column can be hidden by their sprites (as in the game)
|
||||
const b = board.find((x) => x.uid === pc.uid);
|
||||
if (!tapped && b && board.some((o) => o.col === b.col && o.row < b.row && b.row - o.row <= 2)) continue;
|
||||
assert.ok(tapped, `${name}/${render}: ${pc.where} shows its underframe`);
|
||||
const st2 = await underframeState(page);
|
||||
assert.ok(st2.buttons.length, `${name}/${render}: ${pc.where} underframe buttons`);
|
||||
for (const b of st2.buttons) {
|
||||
assert.ok(b.inView, `${name}/${render}: ${pc.where} ${b.cls} inside the viewport`);
|
||||
assert.ok(b.topmost, `${name}/${render}: ${pc.where} ${b.cls} is the topmost element (clickable)`);
|
||||
assert.ok(!b.overlap, `${name}/${render}: ${pc.where} ${b.cls} not under the detail card (${st2.side})`);
|
||||
}
|
||||
if (st2.side === 'right') rightDocked++;
|
||||
checked++;
|
||||
if (pc.where === 'hand0' || pc.where === 'hand1') await page.screenshot({ path: path.join(OUT, `fix-reach-${name}-${render}-${pc.where}.png`) });
|
||||
await page.keyboard.press('Escape'); // closes the card (the next tap selects the next unit)
|
||||
await sleep(120);
|
||||
}
|
||||
assert.ok(checked >= pieces.length - 2, `${name}/${render}: checked ${checked}/${pieces.length}`);
|
||||
// the leftmost bench unit's card docks right whenever the left card would cover its underframe
|
||||
// a real click on 出售 of the leftmost bench unit sells it
|
||||
const first = pieces[0];
|
||||
await tapPiece(page, first.uid);
|
||||
const funds0 = await page.evaluate(() => globalThis.__MOCK__.S().priv.funds);
|
||||
const sell = await page.$eval('.uframe__btn--sell', (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height * 0.35 }; });
|
||||
await page.mouse.click(sell.x, sell.y);
|
||||
await sleep(400);
|
||||
const after = await page.evaluate((uid) => ({ funds: globalThis.__MOCK__.S().priv.funds, gone: !globalThis.__MOCK__.S().priv.hand.some((x) => x && x.uid === uid) }), first.uid);
|
||||
assert.ok(after.gone && after.funds > funds0, `${name}/${render}: 出售 of ${first.where} sold it (${JSON.stringify(after)})`);
|
||||
assert.deepEqual(problems, [], `${name}/${render}`);
|
||||
await page.close();
|
||||
void rightDocked;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// review: the top board row under the bond strip, and a notched phone's safe-area insets
|
||||
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
|
||||
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 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 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) };
|
||||
});
|
||||
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);
|
||||
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`);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.mouse.click(5, Math.round(h / 2));
|
||||
await sleep(150);
|
||||
// notched phone in landscape: the HUD layer sits inside the safe-area insets (css/devices.css)
|
||||
await page.evaluate(() => { const s = document.documentElement.style; s.setProperty('--sa-l', '47px'); s.setProperty('--sa-r', '47px'); s.setProperty('--sa-t', '12px'); s.setProperty('--sa-b', '21px'); });
|
||||
await sleep(300);
|
||||
const hand = await page.evaluate(() => globalThis.__MOCK__.S().priv.hand.map((x, i) => x && x.kind === 'chess' && { uid: x.uid, i }).filter(Boolean).slice(0, 2));
|
||||
for (const pc of hand) {
|
||||
assert.ok(await tapPiece(page, pc.uid), `${name} (safe area): hand${pc.i} selected`);
|
||||
const st = await page.evaluate((pc) => {
|
||||
const uf = document.querySelector(`.uframe[data-uid="${pc.uid}"]`).getBoundingClientRect();
|
||||
const t = globalThis.__SP_VIEW__.raw.tileScreen(7, pc.i);
|
||||
return { dx: uf.left + uf.width / 2 - t.x, dy: uf.top + uf.height / 2 - t.y };
|
||||
}, pc);
|
||||
assert.ok(Math.abs(st.dx) < 2 && Math.abs(st.dy) < 2, `${name} (safe area): hand${pc.i} underframe centred on its tile (${JSON.stringify(st)})`);
|
||||
const s2 = await underframeState(page);
|
||||
for (const b of s2.buttons) {
|
||||
assert.ok(b.topmost && b.inView, `${name} (safe area): hand${pc.i} ${b.cls} clickable`);
|
||||
assert.ok(!b.overlap, `${name} (safe area): hand${pc.i} ${b.cls} not under the detail card (${s2.side})`);
|
||||
}
|
||||
await page.keyboard.press('Escape');
|
||||
await page.mouse.click(5, Math.round(h / 2)); // deselect (a field press)
|
||||
await sleep(150);
|
||||
}
|
||||
if (name === '16x9') await page.screenshot({ path: path.join(OUT, 'fix-reach-safearea.png') });
|
||||
assert.deepEqual(problems, [], name);
|
||||
await page.close();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 9 -------------------------------------------------------------------------------------------------------------
|
||||
|
||||
test('9: the detail card shows the unit\'s bonds under the header, visible without scrolling; the bond popup leads with its facts', { skip: skipUnless('bonds') }, async () => {
|
||||
for (const [w, h] of [[1920, 1080], [1280, 720]]) {
|
||||
const { page, problems } = await open('phase=PREP', { w, h });
|
||||
// a front-row unit (row 9: nothing stands in front of it)
|
||||
const uid = await page.evaluate(() => globalThis.__MOCK__.S().priv.board.find((p) => p.row === 9).uid);
|
||||
assert.ok(await tapPiece(page, uid), 'unit selected');
|
||||
await page.waitForSelector('.dpanel .dbonds--top', { timeout: 3000 });
|
||||
const r = await page.evaluate(() => {
|
||||
const panel = document.querySelector('.dpanel__scroll');
|
||||
const pr = panel.getBoundingClientRect();
|
||||
const art = document.querySelector('.dpanel .dhead__art').getBoundingClientRect();
|
||||
const name = document.querySelector('.dpanel .dhead__name').getBoundingClientRect();
|
||||
const chips = [...document.querySelectorAll('.dpanel .dbonds--top .dbond')];
|
||||
const kids = [...panel.children].map((el) => el.className);
|
||||
return {
|
||||
first: kids[0], inInfo: !!document.querySelector('.dpanel .dhead__info > .dbonds--top'),
|
||||
art: { right: art.right, top: art.top, bottom: art.bottom }, nameBottom: name.bottom,
|
||||
chips: chips.map((el) => { const r = el.getBoundingClientRect(); return { text: el.textContent.trim(), left: r.left, bottom: r.bottom, top: r.top, icon: !!el.querySelector('.bglyph'), count: el.querySelector('.dbond__count')?.textContent.trim() || '' }; }),
|
||||
visibleBottom: pr.bottom, scrollTop: panel.scrollTop,
|
||||
bonds: (globalThis.__SP__?.store || globalThis.__MOCK__.store).get().match.private.bonds.length,
|
||||
};
|
||||
});
|
||||
assert.equal(r.first, 'dhead');
|
||||
assert.ok(r.inInfo, 'bonds in the header\'s right column (under name / tier)');
|
||||
assert.ok(r.chips.length >= 1, 'bond chips');
|
||||
for (const c of r.chips) {
|
||||
assert.ok(c.icon, `chip ${c.text} has its icon`);
|
||||
assert.match(c.count, /^\d+(\/\d+)?$/, `chip ${c.text} shows count/threshold`);
|
||||
assert.ok(c.bottom <= r.visibleBottom && r.scrollTop === 0, `chip ${c.text} visible without scrolling`);
|
||||
assert.ok(c.left >= r.art.right - 1, `chip ${c.text} right of the portrait (top right)`);
|
||||
assert.ok(c.top >= r.nameBottom - 1 && c.top < r.art.bottom, `chip ${c.text} under the name, beside the portrait`);
|
||||
}
|
||||
await page.screenshot({ path: path.join(OUT, `fix-bonds-${w}.png`) });
|
||||
// the chip opens the bond popup; facts + current effect come before the long description
|
||||
await page.click('.dpanel .dbonds--top .dbond');
|
||||
await page.waitForSelector('.bpop', { timeout: 3000 });
|
||||
const pop = await page.evaluate(() => {
|
||||
const secs = [...document.querySelectorAll('.bpop .bpop__sec')].map((s) => s.querySelector('h4')?.textContent || '');
|
||||
const facts = document.querySelector('.bpop__facts').getBoundingClientRect();
|
||||
const box = document.querySelector('.bpop').getBoundingClientRect();
|
||||
const btn = [...document.querySelectorAll('.uframe__btn')].map((b) => { const r = b.getBoundingClientRect(); const t = document.elementFromPoint(r.left + r.width / 2, r.top + r.height * 0.35); return !!t && b.contains(t); });
|
||||
const hit = (a, b) => a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
|
||||
const card = document.querySelector('.dpanel').getBoundingClientRect();
|
||||
const pop = document.querySelector('.bpop');
|
||||
return {
|
||||
secs, factsIn: facts.bottom <= box.bottom, btnTop: btn, place: pop.dataset.place, over: pop.classList.contains('is-over'),
|
||||
coversButtons: [...document.querySelectorAll('.uframe__btn')].some((b) => hit(b.getBoundingClientRect(), box)),
|
||||
coversCard: hit(card, box),
|
||||
};
|
||||
});
|
||||
// the playtest's case (a unit left of the board, card docked right) — the popup keeps clear of the underframe
|
||||
assert.equal(pop.coversButtons, false, `the bond popup (${pop.place}) keeps clear of the underframe buttons`);
|
||||
assert.ok(!pop.coversCard || pop.over, `the popup covers the card only when placed over it (${pop.place})`);
|
||||
const iNow = pop.secs.findIndex((t) => t.includes('当前效果'));
|
||||
const iDesc = pop.secs.findIndex((t) => t.includes('盟约效果'));
|
||||
if (iNow >= 0) assert.ok(iNow < iDesc, `current effect before the description (${pop.secs})`);
|
||||
assert.ok(pop.factsIn);
|
||||
assert.ok(pop.btnTop.every(Boolean), 'the underframe stays above the bond popup');
|
||||
await sleep(450); // the pop-in animation
|
||||
await page.screenshot({ path: path.join(OUT, `fix-bondpop-${w}.png`) });
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- §16 loadout ---------------------------------------------------------------------------------------------------
|
||||
|
||||
test('loadout (DESIGN §16): shop card skill badge, detail card skill + 已调配, elite module 未装备模组', { skip: skipUnless('loadout') }, async () => {
|
||||
const { page, problems } = await open('phase=PREP&variant=loadout');
|
||||
const lo = await page.evaluate(() => globalThis.__MOCK__.S().priv.loadout);
|
||||
assert.ok(lo && Object.keys(lo).length >= 1, 'the mock sends m.private.loadout');
|
||||
const badges = await page.$$eval('.scard__skill', (els) => els.map((el) => ({ custom: el.classList.contains('is-custom'), title: el.getAttribute('title') || '' })));
|
||||
assert.ok(badges.length >= 3, 'every operator card shows its skill');
|
||||
assert.ok(badges.some((b) => b.custom && b.title.includes('已调配')), `a card with a non-default skill (${JSON.stringify(badges)})`);
|
||||
assert.ok(badges.some((b) => !b.custom), 'default skills stay plain');
|
||||
// a chosen skill is never a blank square: its icon, or its slot letter when the manifest has no icon for it
|
||||
const marks = await page.$$eval('.scard__skill.is-custom', (els) => els.map((el) => ({ img: el.querySelector('img')?.getAttribute('src') || null, glyph: el.querySelector('.scard__sglyph')?.textContent || null })));
|
||||
for (const g of marks) assert.ok((g.img && !/empty/i.test(g.img)) || /^S[1-3]$/.test(g.glyph || ''), `custom skill badge shows an icon or S1–S3 (${JSON.stringify(g)})`);
|
||||
await page.screenshot({ path: path.join(OUT, 'fix-loadout-shop.png') });
|
||||
const b0 = await page.evaluate(() => globalThis.__MOCK__.S().priv.board[0].uid);
|
||||
await tapPiece(page, b0);
|
||||
await page.waitForSelector('.dpanel .dskill', { timeout: 3000 });
|
||||
const d = await page.evaluate(() => ({ name: document.querySelector('.dpanel .dskill__name')?.textContent || '', tag: !!document.querySelector('.dpanel .dskill__name .dtag-loadout') }));
|
||||
assert.ok(d.tag, `detail card marks the loadout skill (${d.name})`);
|
||||
const icon = await page.evaluate(() => { const el = document.querySelector('.dpanel .dskill > .dskill__icon'); return { img: el?.tagName === 'IMG' ? el.getAttribute('src') : null, glyph: el?.querySelector('b')?.textContent || null }; });
|
||||
assert.ok((icon.img && !/empty/i.test(icon.img)) || /^S[1-3]$/.test(icon.glyph || ''), `detail skill icon or S1–S3 (${JSON.stringify(icon)})`);
|
||||
await page.evaluate(() => { const el = document.querySelector('.dpanel .dsec--skill'); el?.scrollIntoView({ block: 'center' }); });
|
||||
await sleep(200);
|
||||
await page.screenshot({ path: path.join(OUT, 'fix-loadout-detail.png') });
|
||||
await page.keyboard.press('Escape');
|
||||
const g = await page.evaluate(() => globalThis.__MOCK__.S().priv.board.find((p) => p.golden)?.uid);
|
||||
if (g) {
|
||||
assert.ok(await tapPiece(page, g), 'the elite is selected');
|
||||
await page.waitForSelector('.dpanel .dsec--module', { timeout: 3000 });
|
||||
const mod = await page.$eval('.dpanel .dmodule', (el) => ({ none: el.classList.contains('is-none'), text: el.textContent }));
|
||||
assert.ok(mod.none && mod.text.includes('未装备模组'), `elite module unequipped (${mod.text})`);
|
||||
}
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
// ---- 6 -------------------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Pen / board-area state of the engine view. */
|
||||
const penState = (page) => page.evaluate(() => {
|
||||
const raw = globalThis.__SP_VIEW__.raw;
|
||||
const d = raw.debug;
|
||||
const pens = [...d.penViews.values()];
|
||||
const area = d.board3d ? d.board3d.area : null;
|
||||
return {
|
||||
camera: document.querySelector('.gm')?.dataset.camera, kind: d.camKind,
|
||||
pen: pens.length, penVisible: pens.filter((v) => v.root && v.root.visible !== false).length,
|
||||
band: d.tiles.band, penRowsDrawn: d.tiles.grid.some((row) => row.some((t) => t.r >= 14 && t.drawn)),
|
||||
area3d: area ? Math.max(...area.map((a) => a.r1)) : null, board3d: !!d.board3d,
|
||||
};
|
||||
});
|
||||
|
||||
test('6: the enemy pen shows only in the pen view — never with the prep board (after 返回战场) or any battle', { skip: skipUnless('pen'), timeout: 5 * 60 * 1000 }, async () => {
|
||||
const { page, problems } = await open('phase=PREP', { w: 1920, h: 1080 });
|
||||
let s = await penState(page);
|
||||
assert.equal(s.camera, 'prep');
|
||||
assert.ok(s.pen > 0, 'the pen holds the next round\'s enemies');
|
||||
assert.equal(s.penVisible, 0, 'prep board: no pen figure shown');
|
||||
assert.equal(s.penRowsDrawn, false, 'prep board: pen rows not drawn');
|
||||
if (s.board3d) assert.ok(s.area3d <= 12, `prep board: 3D area without the pen (${s.area3d})`);
|
||||
await page.screenshot({ path: path.join(OUT, 'fix-pen-prep.png') });
|
||||
await page.click('.enemybtn');
|
||||
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'pen', { timeout: 3000 });
|
||||
await sleep(700);
|
||||
s = await penState(page);
|
||||
assert.ok(s.penVisible > 0, 'pen view: figures shown');
|
||||
assert.ok(s.penRowsDrawn, 'pen view: pen rows drawn');
|
||||
if (s.board3d) assert.ok(s.area3d >= 18, 'pen view: 3D pen area built');
|
||||
await page.screenshot({ path: path.join(OUT, 'fix-pen-view.png') });
|
||||
// 🔍◀◀ (tooltip 返回战场) back to the board: the pen is gone again
|
||||
await page.click('.gtop__iconbtn');
|
||||
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'prep', { timeout: 3000 });
|
||||
await sleep(900);
|
||||
s = await penState(page);
|
||||
assert.equal(s.penVisible, 0, 'back on the board (返回战场): no pen figure');
|
||||
assert.equal(s.penRowsDrawn, false, 'back on the board: pen rows not drawn');
|
||||
if (s.board3d) assert.ok(s.area3d <= 12, 'back on the board: no pen area');
|
||||
await page.screenshot({ path: path.join(OUT, 'fix-pen-back.png') });
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
|
||||
// every battle camera
|
||||
for (const phase of ['COMBAT', 'UNITE', 'FINAL_ASSAULT', 'HIDDEN_CORE']) {
|
||||
const o = await open(`phase=${phase}`, { w: 1920, h: 1080 });
|
||||
await sleep(1200);
|
||||
const b = await penState(o.page);
|
||||
assert.equal(b.penVisible, 0, `${phase}: no pen figure`);
|
||||
assert.equal(b.penRowsDrawn, false, `${phase}: pen rows not drawn`);
|
||||
if (b.board3d) assert.ok(b.area3d <= 12, `${phase}: no pen area (${b.area3d})`);
|
||||
await o.page.screenshot({ path: path.join(OUT, `fix-pen-${phase.toLowerCase()}.png`) });
|
||||
assert.deepEqual(o.problems, [], phase);
|
||||
await o.page.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,403 @@
|
||||
// Real-server browser E2E of user playtest #2 item 10 (+ item 6 on the real flow): the boss round's prep shows the
|
||||
// player's own half of the boss field (tiles, gates, objective) and placing works — solo (标准: R9 最终攻势; 险境:
|
||||
// R15 隐秘核心) and co-op (two humans: the host prepares on the LEFT half, the guest on the mirrored RIGHT half) — then
|
||||
// the Final Assault starts with the placed units on the boss field. Along the way (item 6): the pen stays hidden with
|
||||
// the board (🔍▶▶ shows it, 🔍◀◀ "返回战场" hides it again), scouting a teammate (前往查看) and coming back, and in
|
||||
// the boss battle.
|
||||
//
|
||||
// SP_E2E=1 node --test test/ui/playtest2.real.e2e.test.js
|
||||
// SP_E2E=1 SP_P2_REAL=solo|hidden|coop|observe node --test test/ui/playtest2.real.e2e.test.js
|
||||
//
|
||||
// Item 6 on the playtest's own flow ('observe'): co-op with an AI teammate (fastServer SP_IDLE_BOTS: the AI places
|
||||
// nothing, so its battle lasts the enemies' whole walk), the human's battle ends first → 前往查看 the AI's running battle
|
||||
// → 返回战场: the camera frames the own battlefield again, no enemy pen (figures, 2D rows, 3D area).
|
||||
//
|
||||
// The server is test/e2e/fastServer.mjs with its test hook SP_START_ROUND ('boss' / 'hidden': the first round of the
|
||||
// match is the boss round, every human gets a few operators and funds) — everything else is the real match engine
|
||||
// and the real UI driven by real mouse input. Screenshots: test/e2e/out/fix-boss-*.png.
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf } from '../e2e/client.mjs';
|
||||
|
||||
const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets'));
|
||||
const ONLY = process.env.SP_P2_REAL || '';
|
||||
const only = (k) => (ONLY && ONLY !== k ? `SP_P2_REAL=${ONLY}` : false);
|
||||
|
||||
let puppeteer = null;
|
||||
const pptr = async () => { if (!puppeteer) puppeteer = (await import('puppeteer-core')).default; return puppeteer; };
|
||||
|
||||
/** Field view state: camera, prep field, drawn rows, 3D area, pen figures, the boss field's gates / objective on screen. */
|
||||
function viewState(c) {
|
||||
return c.page.evaluate(() => {
|
||||
const v = globalThis.__SP_VIEW__;
|
||||
const raw = v?.raw;
|
||||
const d = raw?.debug;
|
||||
const s = globalThis.__SP__.store.get();
|
||||
const st = globalThis.__SP__.data.lookup('stages', s.match.public?.stageId);
|
||||
const pens = d ? [...d.penViews.values()] : [];
|
||||
const onScreen = (row, col) => {
|
||||
if (!d?.cam) return false;
|
||||
const p = d.cam.project(col, row, 0);
|
||||
const cr = d.app.view.getBoundingClientRect();
|
||||
return p.x > 0 && p.y > 0 && p.x < cr.width && p.y < cr.height;
|
||||
};
|
||||
// the boss field's own-half landmarks (research 05 §2.2: gates 'O', objective 'E' on rows 0–5)
|
||||
const marks = [];
|
||||
(st?.rows || []).forEach((line, r) => { if (r > 5) return; [...line].forEach((ch, cc) => { if (ch === 'O' || ch === 'E') marks.push({ ch, r, c: cc }); }); });
|
||||
return {
|
||||
kind: v?.kind, camera: document.querySelector('.gm')?.dataset.camera, camKind: d?.camKind ?? null,
|
||||
prepField: raw?.prepField?.() ?? null, band: d?.tiles.band ?? null,
|
||||
area3d: d?.board3d ? Math.max(...d.board3d.area.map((a) => a.r1)) : null,
|
||||
pen: pens.length, penVisible: pens.filter((x) => x.root && x.root.visible !== false).length,
|
||||
marks: marks.map((m) => ({ ...m, on: onScreen(m.r, m.c) })),
|
||||
// prep piece views: board uid → world y (FA prep: board row − 7)
|
||||
prepY: Object.fromEntries((s.match.private?.board || []).map((p) => [p.uid, d?.views.get('p:' + p.uid)?.y ?? null])),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function soloToPrep(c, diff) {
|
||||
await c.open();
|
||||
await c.enter('煌');
|
||||
await c.click('.mode-card', '独立模拟');
|
||||
await c.click('.diff-card', diff);
|
||||
await c.click('.create-box button', '开始独立模拟');
|
||||
await c.waitFor((s) => !!s.room, 'solo room');
|
||||
if (!(await c.st()).phase) await c.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
|
||||
await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
|
||||
await c.click('.brief__foot .btn--primary', '准备就绪');
|
||||
await c.waitFor((s) => s.phase === 'BAND_DRAFT', 'band draft', 30000);
|
||||
await c.click('.dband', null, { nth: 1 });
|
||||
await c.click('.draft-detail__btns .btn--primary', '确认选择');
|
||||
const s = await c.waitFor((x) => x.phase === 'PREP' && !x.ready, 'boss-round prep', 60000);
|
||||
await sleep(1800); // camera flight + pieces
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Hand → board through the direction wheel; returns the placed { uid, row, col }. */
|
||||
async function placeOne(c, dir = 'RIGHT') {
|
||||
await c.hookRequests();
|
||||
const hand = (await c.handPieces('chess'));
|
||||
assert.ok(hand.length, `${c.label}: the starter kit is in the hand`);
|
||||
for (const p of hand) {
|
||||
const tile = await c.freeTileFor(p.uid);
|
||||
if (!tile) continue;
|
||||
const from = await c.piecePoint(p.uid);
|
||||
const to = await c.tilePoint(tile.row, tile.col);
|
||||
assert.ok(from && to, `${c.label}: piece and tile on screen`);
|
||||
await c.drag(from, to);
|
||||
await c.page.waitForSelector('.fwheel__dia', { timeout: 4000 });
|
||||
await c.swipe(dir);
|
||||
const placed = await c.waitFor((x) => x.board > 0, 'placed', 8000).then(() => c.boardPiece(p.uid), () => null);
|
||||
if (placed) {
|
||||
assert.deepEqual([placed.row, placed.col], [tile.row, tile.col], `${c.label}: landed on the board tile`);
|
||||
assert.equal(placed.dir, dir, `${c.label}: with the chosen direction`);
|
||||
return { uid: p.uid, ...placed };
|
||||
}
|
||||
}
|
||||
throw new Error(`${c.label}: nothing could be placed`);
|
||||
}
|
||||
|
||||
/** The boss-round prep checks shared by every variant. */
|
||||
async function checkBossPrep(c, side, tag) {
|
||||
const v = await viewState(c);
|
||||
assert.equal(v.kind, 'engine', `${tag}: the render engine is mounted`);
|
||||
assert.equal(v.camera, 'bossPrep', `${tag}: the prep camera is the boss field's`);
|
||||
assert.equal(v.camKind, 'bossPrep');
|
||||
assert.deepEqual(v.prepField, { kind: 'bossPrep', side, mirror: side === 'R' }, `${tag}: own ${side} half`);
|
||||
assert.deepEqual(v.band, [0, 13], `${tag}: the boss field rows are drawn (no pen rows)`);
|
||||
if (v.area3d != null) assert.ok(v.area3d <= 6, `${tag}: the 3D board builds the boss field (${v.area3d})`);
|
||||
const own = v.marks.filter((m) => (side === 'R' ? m.c >= 10 : m.c <= 10));
|
||||
assert.ok(own.length >= 2, `${tag}: boss-field gates / objective exist (${JSON.stringify(v.marks)})`);
|
||||
assert.ok(own.every((m) => m.on), `${tag}: the own half's gates and objective are on screen (${JSON.stringify(own)})`);
|
||||
assert.equal(v.penVisible, 0, `${tag}: no pen figure with the board`);
|
||||
return v;
|
||||
}
|
||||
|
||||
/** 🔍▶▶ → the pen (the boss wave), 🔍◀◀ (返回战场) → back to the boss field, pen hidden again. */
|
||||
async function penRoundTrip(c, tag) {
|
||||
await c.click('.enemybtn');
|
||||
await c.page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'pen', { timeout: 4000 });
|
||||
await sleep(700);
|
||||
const p = await viewState(c);
|
||||
assert.ok(p.pen > 0 && p.penVisible > 0, `${tag}: the pen shows the boss wave`);
|
||||
await c.shot('boss-pen');
|
||||
await c.click('.gtop__iconbtn');
|
||||
await c.page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'bossPrep', { timeout: 4000 });
|
||||
await sleep(900);
|
||||
const b = await viewState(c);
|
||||
assert.equal(b.penVisible, 0, `${tag}: back on the boss field (返回战场): no pen figure`);
|
||||
assert.deepEqual(b.band, [0, 13]);
|
||||
}
|
||||
|
||||
/** The Final Assault / Hidden Core battle: the boss field with the placed unit on its boss row. */
|
||||
async function checkBossBattle(c, placed, side, tag) {
|
||||
await c.waitFor((s) => s.phase === 'FINAL_ASSAULT' || s.phase === 'HIDDEN_CORE', 'boss battle', 60000);
|
||||
await c.page.waitForFunction(() => { const s = globalThis.__SP_VIEW__?.raw?.stats?.(); return !!s && s.mode === 'battle' && s.units > 0; }, { timeout: 30000 });
|
||||
await sleep(1500);
|
||||
// the unit's battle view (units deploy one after another at the start)
|
||||
await c.page.waitForFunction((uid) => [...globalThis.__SP_VIEW__.raw.debug.views.values()].some((x) => x?.info?.uid === uid), { timeout: 10000 }, placed.uid)
|
||||
.catch(async () => console.log(tag, 'views:', JSON.stringify(await c.page.evaluate(() => [...globalThis.__SP_VIEW__.raw.debug.views.values()].map((x) => [x.info?.uid, x.info?.defId, x.info?.side, x.info?.x, x.info?.y])))));
|
||||
const r = await c.page.evaluate((uid) => {
|
||||
const d = globalThis.__SP_VIEW__.raw.debug;
|
||||
const s = globalThis.__SP__.store.get();
|
||||
const view = [...d.views.values()].find((x) => x?.info?.uid === uid) || null;
|
||||
const u = view ? { x: view.info.x, y: view.info.y } : null;
|
||||
const pens = [...d.penViews.values()];
|
||||
return {
|
||||
camKind: d.camKind, field: s.match.field?.kind, band: d.tiles.band,
|
||||
area3d: d.board3d ? Math.max(...d.board3d.area.map((a) => a.r1)) : null,
|
||||
unit: u ? { x: u.x, y: u.y } : null, view: view ? { x: view.x, y: view.y } : null, penVisible: pens.filter((x) => x.root?.visible !== false).length,
|
||||
};
|
||||
}, placed.uid);
|
||||
assert.ok(r.field === 'boss' || r.field === 'hidden', `${tag}: the battle is on the boss field (${r.field})`);
|
||||
assert.equal(r.camKind, 'boss');
|
||||
assert.equal(r.penVisible, 0, `${tag}: no pen in the boss battle`);
|
||||
assert.ok(r.band[1] <= 13, `${tag}: no pen rows drawn (${r.band})`);
|
||||
if (r.area3d != null) assert.ok(r.area3d <= 6, `${tag}: boss area only`);
|
||||
assert.ok(r.unit, `${tag}: the placed unit fights (${JSON.stringify(r)})`);
|
||||
assert.equal(Math.round(r.unit.y), placed.row - 7, `${tag}: on its boss row (board row ${placed.row} − 7)`);
|
||||
assert.equal(Math.round(r.unit.x), side === 'R' ? 20 - placed.col : placed.col, `${tag}: on its ${side} column`);
|
||||
await c.shot('boss-battle');
|
||||
}
|
||||
|
||||
/** Camera / pen state of the battle view (item 6). */
|
||||
function battleViewState(c) {
|
||||
return c.page.evaluate(() => {
|
||||
const d = globalThis.__SP_VIEW__?.raw?.debug;
|
||||
const s = globalThis.__SP__.store.get();
|
||||
const pens = d ? [...d.penViews.values()] : [];
|
||||
const r = globalThis.__SP_RUNNER__?.state?.() || null;
|
||||
return {
|
||||
camera: document.querySelector('.gm')?.dataset.camera ?? null, camKind: d?.camKind ?? null, band: d?.tiles.band ?? null,
|
||||
area3d: d?.board3d ? Math.max(...d.board3d.area.map((a) => a.r1)) : null,
|
||||
penVisible: pens.filter((x) => x.root && x.root.visible !== false).length,
|
||||
penRowsDrawn: !!d && d.tiles.grid.some((row) => row.some((t) => t.r >= 14 && t.drawn)),
|
||||
field: s.match.field?.fieldId ?? null, runner: r ? { fieldId: r.fieldId, own: !!r.own, watch: !!r.watch, done: !!r.done } : null,
|
||||
// unit-layer sprites no view owns (a stale unit of the field shown before would stay drawn)
|
||||
orphans: d ? d.ctx.layers.units.children.filter((ch) => ch.visible !== false && ch.worldVisible !== false)
|
||||
.filter((ch) => ![...d.views.values()].some((v) => v && v.root === ch))
|
||||
.map((ch) => ch.getBounds()).filter((b) => b.width > 4 && b.height > 4).map((b) => `${Math.round(b.x)},${Math.round(b.y)}`) : [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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`);
|
||||
assert.equal(v.penRowsDrawn, false, `${tag}: no pen rows drawn (${v.band})`);
|
||||
assert.ok(v.band && v.band[1] <= 13, `${tag}: drawn rows ${v.band}`);
|
||||
if (v.area3d != null) assert.ok(v.area3d <= 12, `${tag}: 3D area without the pen block (${v.area3d})`);
|
||||
}
|
||||
|
||||
describe('user playtest #2 item 6 — 前往查看 → 返回战场 in combat (real server)', { skip: !ENABLED && 'set SP_E2E=1 (Chrome + public/assets)' }, () => {
|
||||
test('co-op with an AI: observing its battle and 返回战场 frame the battlefield only — never the enemy pen', { skip: only('observe'), timeout: 10 * 60 * 1000 }, async () => {
|
||||
const srv = await startRealServer({ fast: { timerScale: 0.5, combatSpeed: 2, startRound: 2, kit: 6, idleBots: true } });
|
||||
const c = new Client(await pptr(), srv.base, 'observe', { prefix: 'fix' });
|
||||
try {
|
||||
await c.open();
|
||||
await c.enter('煌');
|
||||
await c.click('.mode-card', '同盟模拟');
|
||||
await c.click('.diff-card', '标准模拟');
|
||||
await c.click('.create-box button', '创建同盟');
|
||||
await c.waitFor((s) => !!s.room?.code, 'room created');
|
||||
await c.click('button', '添加 AI 队友');
|
||||
await sleep(500);
|
||||
await c.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
|
||||
await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
|
||||
await c.click('.brief__foot .btn--primary', '准备就绪');
|
||||
await c.waitFor((s) => s.phase === 'BAND_DRAFT' && s.draft?.turn === s.me || s.phase === 'PREP', 'my band pick', 60000);
|
||||
if ((await c.st()).phase === 'BAND_DRAFT') {
|
||||
await c.click('.dband:not(.is-taken)', null, { nth: 1 });
|
||||
await c.click('.draft-detail__btns .btn--primary', '确认选择');
|
||||
}
|
||||
let observed = false;
|
||||
for (let tries = 0; tries < 3 && !observed; tries++) {
|
||||
const s0 = await c.waitFor((s) => s.phase === 'PREP' && !s.ready, 'prep', 90000);
|
||||
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
|
||||
for (let k = 0; k < 8; k++) {
|
||||
const s = await c.st();
|
||||
if (s.deployCount >= s.deployCap || !(await c.handPieces('chess')).length) break;
|
||||
try { await placeOne(c, 'RIGHT'); } catch { break; }
|
||||
await sleep(300);
|
||||
}
|
||||
const v0 = await battleViewState(c);
|
||||
assertNoPen(v0, `R${s0.round} prep`);
|
||||
await c.click('.readybtn');
|
||||
await c.waitFor((s) => s.phase === 'COMBAT', 'combat', 60000);
|
||||
await sleep(1500);
|
||||
assertNoPen(await battleViewState(c), `R${s0.round} own battle`);
|
||||
// own battle over while the AI's field still runs
|
||||
const target = await c.page.waitForFunction(() => {
|
||||
const s = globalThis.__SP__.store.get();
|
||||
const r = globalThis.__SP_RUNNER__?.state?.();
|
||||
const pub = s.match.public;
|
||||
if (pub?.phase !== 'COMBAT') return 'phase';
|
||||
if (!r || !r.own || !r.done) return false;
|
||||
const f = (pub.fields || []).find((x) => x.kind === 'normal' && x.live && !x.players.includes(s.me.playerId));
|
||||
return f ? f.fieldId : 'none';
|
||||
}, { timeout: 120000, polling: 200 }).then((h) => h.jsonValue(), () => 'timeout');
|
||||
if (!target || ['phase', 'none', 'timeout'].includes(target)) { c.note(`R${s0.round}: no running teammate field after the own battle (${target})`); continue; }
|
||||
await c.page.waitForSelector('.chud__wait', { timeout: 5000 });
|
||||
await c.click('.team__row:not(.is-self) .team__btn', null, { nth: 0 });
|
||||
if (!(await c.click('.team__ob', '前往查看', { optional: true, timeout: 3000 }))) { c.note(`R${s0.round}: no 前往查看`); continue; }
|
||||
const ok = await c.page.waitForFunction((fid) => { const r = globalThis.__SP_RUNNER__?.state?.(); return !!r && r.fieldId === fid && r.watch; }, { timeout: 15000 }, target).then(() => true, () => false);
|
||||
if (!ok || (await c.st()).phase !== 'COMBAT') { c.note(`R${s0.round}: the observed battle ended meanwhile`); continue; }
|
||||
await sleep(1300);
|
||||
const w = await battleViewState(c);
|
||||
assert.equal(w.camKind, 'normal', 'observing: the battle camera');
|
||||
assertNoPen(w, 'observing the teammate');
|
||||
await c.shot('observe-teammate');
|
||||
if ((await c.st()).phase !== 'COMBAT') { c.note(`R${s0.round}: combat ended before 返回战场`); continue; }
|
||||
await c.click('.team__back, .chud__back', '返回战场', { timeout: 4000 });
|
||||
await c.page.waitForFunction(() => { const r = globalThis.__SP_RUNNER__?.state?.(); return !r || (r.own && !r.watch); }, { timeout: 10000 });
|
||||
await sleep(1300); // the camera flight
|
||||
const b = await battleViewState(c);
|
||||
if ((await c.st()).phase === 'COMBAT') {
|
||||
assert.equal(b.camKind, 'normal', `返回战场: the own battle camera (${b.camKind})`);
|
||||
assert.ok(b.runner?.own && !b.runner.watch, '返回战场: the own field is shown');
|
||||
assertNoPen(b, '返回战场');
|
||||
await c.shot('observe-back');
|
||||
observed = true;
|
||||
}
|
||||
}
|
||||
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);
|
||||
await sleep(1500);
|
||||
assertNoPen(await battleViewState(c), `R${s1.round} prep after observing`);
|
||||
assert.deepEqual(problemsOf([c]), []);
|
||||
} finally {
|
||||
if (c.problems.length) console.log(c.problems.slice(0, 20).join('\n'));
|
||||
await c.close();
|
||||
await srv.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('user playtest #2 item 10 — boss-round prep on the boss field (real server)', { skip: !ENABLED && 'set SP_E2E=1 (Chrome + public/assets)' }, () => {
|
||||
test('solo 标准 (R9 最终攻势): the own half of the boss field while preparing, placing works, the FA uses it', { skip: only('solo'), timeout: 8 * 60 * 1000 }, async () => {
|
||||
const srv = await startRealServer({ fast: { timerScale: 0.5, combatSpeed: 8, startRound: 'boss' } });
|
||||
const c = new Client(await pptr(), srv.base, 'solo', { prefix: 'fix' });
|
||||
try {
|
||||
const s = await soloToPrep(c, '标准模拟');
|
||||
assert.equal(s.round, s.bossRound, 'the boss round');
|
||||
await checkBossPrep(c, 'L', 'solo');
|
||||
await c.shot('boss-prep-solo');
|
||||
const placed = await placeOne(c, 'RIGHT');
|
||||
await sleep(600);
|
||||
const v = await viewState(c);
|
||||
assert.equal(Math.round(v.prepY[placed.uid]), placed.row - 7, 'the placed piece stands on its boss row');
|
||||
await c.shot('boss-prep-solo-placed');
|
||||
await penRoundTrip(c, 'solo');
|
||||
await c.click('.readybtn');
|
||||
await checkBossBattle(c, placed, 'L', 'solo');
|
||||
assert.deepEqual(problemsOf([c]), []);
|
||||
} finally {
|
||||
if (c.problems.length) console.log(c.problems.slice(0, 20).join('\n'));
|
||||
await c.close();
|
||||
await srv.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('solo 险境 (R15 隐秘核心): the boss field prep of the Hidden Core round', { skip: only('hidden'), timeout: 8 * 60 * 1000 }, async () => {
|
||||
const srv = await startRealServer({ fast: { timerScale: 0.5, combatSpeed: 8, startRound: 'hidden' } });
|
||||
const c = new Client(await pptr(), srv.base, 'hidden', { prefix: 'fix', w: 1366, h: 768 });
|
||||
try {
|
||||
const s = await soloToPrep(c, '险境模拟');
|
||||
assert.equal(s.round, 15, 'the Hidden Core round');
|
||||
await checkBossPrep(c, 'L', 'hidden');
|
||||
const placed = await placeOne(c, 'UP');
|
||||
await c.shot('boss-prep-hidden');
|
||||
await c.click('.readybtn');
|
||||
await checkBossBattle(c, placed, 'L', 'hidden');
|
||||
assert.deepEqual(problemsOf([c]), []);
|
||||
} finally {
|
||||
if (c.problems.length) console.log(c.problems.slice(0, 20).join('\n'));
|
||||
await c.close();
|
||||
await srv.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('co-op 险境 (R14): host on the left half, guest on the mirrored right half; scouting and 返回 keep the pen hidden', { skip: only('coop'), timeout: 10 * 60 * 1000 }, async () => {
|
||||
const srv = await startRealServer({ fast: { timerScale: 0.5, combatSpeed: 8, startRound: 'boss' } });
|
||||
const P = await pptr();
|
||||
const host = new Client(P, srv.base, 'host', { prefix: 'fix' });
|
||||
const guest = new Client(P, srv.base, 'guest', { prefix: 'fix', w: 1280, h: 720 });
|
||||
try {
|
||||
await host.open();
|
||||
await host.enter('凯尔希');
|
||||
await host.click('.mode-card', '同盟模拟');
|
||||
await host.click('.diff-card', '险境模拟');
|
||||
await host.click('.create-box button', '创建同盟');
|
||||
const room = (await host.waitFor((s) => !!s.room?.code, 'room created')).room;
|
||||
await guest.open(`?room=${room.code}`);
|
||||
await guest.enter('阿米娅');
|
||||
await guest.waitFor((s) => s.room?.code === room.code, 'guest joined');
|
||||
await guest.click('.room-bar__right button', '准备就绪');
|
||||
await sleep(400);
|
||||
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', '准备就绪');
|
||||
// band draft: whoever's turn it is picks a free strategy
|
||||
const picked = new Set();
|
||||
const t0 = Date.now();
|
||||
while (picked.size < 2 && Date.now() - t0 < 120000) {
|
||||
for (const c of [host, guest]) {
|
||||
const s = await c.st();
|
||||
if (s.phase !== 'BAND_DRAFT') { picked.add(c.label); continue; }
|
||||
if (picked.has(c.label) || s.draft?.turn !== s.me) continue;
|
||||
await c.click('.dband:not(.is-taken)', null, { nth: c === host ? 2 : 5 });
|
||||
await sleep(200);
|
||||
await c.click('.draft-detail__btns .btn--primary', '确认选择');
|
||||
picked.add(c.label);
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
for (const c of [host, guest]) await c.waitFor((s) => s.phase === 'PREP' && !s.ready, 'boss-round prep', 60000);
|
||||
await sleep(1800);
|
||||
const hs = await host.st();
|
||||
assert.equal(hs.round, hs.bossRound, 'the boss round');
|
||||
await checkBossPrep(host, 'L', 'host');
|
||||
await checkBossPrep(guest, 'R', 'guest');
|
||||
const hp = await placeOne(host, 'RIGHT');
|
||||
const gp = await placeOne(guest, 'RIGHT');
|
||||
await sleep(600);
|
||||
const gv = await viewState(guest);
|
||||
assert.equal(Math.round(gv.prepY[gp.uid]), gp.row - 7, 'guest: on its boss row');
|
||||
await host.shot('boss-prep-coop-L');
|
||||
await guest.shot('boss-prep-coop-R');
|
||||
// scouting the teammate's board (前往查看) and coming back: no pen with the boards, the boss field again
|
||||
await host.click('.team__row:not(.is-self) .team__btn', null, { nth: 0 });
|
||||
assert.ok(await host.click('.team__ob', '前往查看', { optional: true, timeout: 3000 }), 'host: 前往查看 in the boss-round prep');
|
||||
{
|
||||
await host.page.waitForSelector('.gm__watching', { timeout: 6000 });
|
||||
await sleep(900);
|
||||
const w = await viewState(host);
|
||||
assert.equal(w.penVisible, 0, 'scouting: no pen figure with the teammate\'s board');
|
||||
assert.ok(w.band[1] <= 13, `scouting: no pen rows (${w.band})`);
|
||||
await host.click('.gm__watching button, .team__back', null, { timeout: 4000 });
|
||||
await host.page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'bossPrep', { timeout: 6000 });
|
||||
await sleep(900);
|
||||
const back = await viewState(host);
|
||||
assert.equal(back.penVisible, 0, '返回: no pen figure');
|
||||
assert.deepEqual(back.prepField, { kind: 'bossPrep', side: 'L', mirror: false }, '返回: the own boss half again');
|
||||
}
|
||||
await penRoundTrip(guest, 'guest');
|
||||
for (const c of [host, guest]) await c.click('.readybtn');
|
||||
await checkBossBattle(host, hp, 'L', 'host');
|
||||
await checkBossBattle(guest, gp, 'R', 'guest');
|
||||
assert.deepEqual(problemsOf([host, guest]), []);
|
||||
} finally {
|
||||
for (const c of [host, guest]) if (c.problems.length) console.log(c.label, c.problems.slice(0, 20).join('\n'));
|
||||
await host.close();
|
||||
await guest.close();
|
||||
await srv.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,344 @@
|
||||
// Regression tests (Node) of user playtest #2, UI items 6–10 + the loadout display (DESIGN §16):
|
||||
// 6 the enemy preview pen belongs to the pen camera only: bandFor / boardArea / penShown of render/app.js
|
||||
// 8 the detail card docks on the side away from a selected unit's underframe — for every bench / temp / board tile
|
||||
// at the official prep / Final Assault prep cameras of 10 viewports (16:9, 16:10, 21:9, 32:13.4, 4:3, 1280×720,
|
||||
// 1366×768, iPad landscape, 1024×768, 1440×900) the chosen slot never overlaps the underframe
|
||||
// 10 the boss round's prep camera is the own half of the boss field — solo (R9 / R15) and co-op (L / R), and a lone
|
||||
// player's boss battle frames its own half
|
||||
// §16 chessLoadout: the skill / module a chess fights with under m.private.loadout
|
||||
// (browser counterparts: test/ui/playtest2.e2e.test.js, test/ui/playtest2.real.e2e.test.js)
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { presetCamera, OFFICIAL_PARAMS, parseCameraParam } from '../../public/js/render/projection.js';
|
||||
import { bandFor, boardArea, penShown, viewKind } from '../../public/js/render/app.js';
|
||||
import { AREAS } from '../../public/js/render/board3d/layout.js';
|
||||
import { underframeRect } from '../../public/js/ui/underframe.js';
|
||||
import { panelSide, panelSlots, chessLoadout, prepCamera, bondPopupPlace, BPOP } from '../../public/js/ui/gameLogic.js';
|
||||
import { checkButtons } from '../../public/js/ui/hud.js';
|
||||
import { DATA } from '../match/harness.js';
|
||||
import { skillRecordIconUrl } from '../../public/js/ui/assetUrls.js';
|
||||
|
||||
const maxRow = (areas) => Math.max(...areas.map((a) => a.r1));
|
||||
const hit = (a, b) => a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
|
||||
|
||||
describe('6: the enemy pen only with the pen camera', () => {
|
||||
test('prep / battle / 联防 cameras draw no pen rows and build no pen area; the pen camera does', () => {
|
||||
for (const k of ['prep', 'normal', 'unite']) {
|
||||
assert.ok(bandFor(k)[1] <= 13, `${k}: 2D rows ${bandFor(k)}`);
|
||||
assert.ok(maxRow(boardArea(k)) <= 12, `${k}: 3D area rows ≤ 12`);
|
||||
assert.equal(penShown(k), false, `${k}: pen figures hidden`);
|
||||
}
|
||||
for (const k of ['boss', 'hidden', 'bossPrep']) {
|
||||
assert.deepEqual(bandFor(k), [0, 13], k);
|
||||
assert.ok(maxRow(boardArea(viewKind(k))) <= 6, `${k}: boss field only`);
|
||||
assert.equal(penShown(viewKind(k)), false);
|
||||
}
|
||||
assert.deepEqual(bandFor('pen'), [6, 18]);
|
||||
assert.equal(maxRow(boardArea('pen')), 18);
|
||||
assert.equal(boardArea('pen'), AREAS.normal, 'the pen view keeps the field + pen block');
|
||||
assert.equal(penShown('pen'), true);
|
||||
// a camera flight to / from the pen shows it while it lasts
|
||||
assert.equal(penShown('prep', 'pen'), true);
|
||||
assert.equal(penShown('pen', 'prep'), true);
|
||||
});
|
||||
|
||||
test('the field areas are the official ones minus the pen block', () => {
|
||||
assert.deepEqual(boardArea('normal'), AREAS.normal.filter((a) => a.r1 <= 12));
|
||||
assert.deepEqual(boardArea('unite'), AREAS.unite.filter((a) => a.r1 <= 12));
|
||||
assert.ok(boardArea('normal').some((a) => a.r0 <= 6 && a.r1 >= 12 && a.c0 === 0 && a.c1 >= 10), 'own field + bench');
|
||||
});
|
||||
|
||||
test('the 🔍◀◀ back button of the pen view is the one labelled 返回战场', () => {
|
||||
const b = checkButtons({ pen: true, penAvail: true, infoOpen: false });
|
||||
assert.equal(b.left.tip, '返回战场');
|
||||
assert.equal(b.left.back, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('8: the detail card never covers the selected unit\'s underframe', () => {
|
||||
const SIZES = [[1920, 1080], [1680, 1050], [2560, 1080], [3440, 1440], [1600, 1200], [1280, 720], [1366, 768], [1180, 820], [1024, 768], [1440, 900]];
|
||||
const remOf = (W, H) => Math.max(40, Math.min(W / 19.2, H / 10.8, 240)); // css/theme.css html font-size
|
||||
const tiles = () => {
|
||||
const out = [];
|
||||
for (let c = 0; c < 10; c++) out.push([7, c, 'bench']);
|
||||
for (let c = 4; c <= 8; c++) out.push([8, c, 'temp']);
|
||||
for (let r = 9; r <= 12; r++) for (let c = 2; c <= 10; c++) out.push([r, c, 'board']);
|
||||
return out;
|
||||
};
|
||||
|
||||
test('panelSide: left unless the underframe overlaps it and the right slot is clear', () => {
|
||||
const slots = { left: { left: 230, right: 720, top: 210, bottom: 978 }, right: { left: 1340, right: 1830, top: 210, bottom: 702 } };
|
||||
assert.equal(panelSide(null, slots), 'left');
|
||||
assert.equal(panelSide({ left: 800, right: 1000, top: 400, bottom: 600 }, slots), 'left');
|
||||
assert.equal(panelSide({ left: 300, right: 600, top: 500, bottom: 760 }, slots), 'right');
|
||||
assert.equal(panelSide({ left: 300, right: 1500, top: 500, bottom: 690 }, slots), 'left', 'both covered: the default (the underframe is drawn above)');
|
||||
assert.equal(panelSide({ left: 1400, right: 1600, top: 400, bottom: 600 }, slots), 'left');
|
||||
});
|
||||
|
||||
test('panelSlots follow the css (left 2.3rem / 4.9rem wide; right .9rem from the edge, above the open shop)', () => {
|
||||
const s = panelSlots({ width: 1920, height: 1080 }, 100, { shopOpen: true });
|
||||
const round = (a) => a.map((v) => Math.round(v));
|
||||
assert.deepEqual(round([s.left.left, s.left.right, s.left.top]), [230, 720, 210]);
|
||||
assert.deepEqual(round([s.right.left, s.right.right, s.right.bottom]), [1920 - 580, 1830, 1080 - 378]);
|
||||
assert.equal(panelSlots({ width: 1920, height: 1080 }, 100, { shopOpen: false }).right.bottom, 1080 - 102);
|
||||
});
|
||||
|
||||
test('safe-area insets: the slots are laid out in the HUD layer (its client offset and size)', () => {
|
||||
// a notched phone in landscape: the HUD sits 47 px in from both sides and 12 px from the top (css/devices.css)
|
||||
const hud = { left: 47, top: 12, width: 844 - 94, height: 390 - 12 - 21 };
|
||||
const R = 40;
|
||||
const s = panelSlots(hud, R, { shopOpen: true });
|
||||
const z = panelSlots({ width: hud.width, height: hud.height }, R, { shopOpen: true });
|
||||
for (const k of ['left', 'right']) {
|
||||
assert.deepEqual([s[k].left, s[k].right], [z[k].left + 47, z[k].right + 47], `${k}: shifted by the left inset`);
|
||||
assert.deepEqual([s[k].top, s[k].bottom], [z[k].top + 12, z[k].bottom + 12], `${k}: shifted by the top inset`);
|
||||
}
|
||||
assert.ok(Math.abs(s.right.right - (844 - 47 - R * 0.9)) < 1e-9, 'the right slot keeps .9rem from the HUD\'s right edge');
|
||||
// an underframe just right of the left card's client edge: covered only once the inset is counted
|
||||
const uf = { left: 47 + R * 7.1, right: 47 + R * 9, top: 150, bottom: 260 };
|
||||
assert.equal(panelSide(uf, z), 'left', 'viewport frame: looks clear');
|
||||
assert.equal(panelSide(uf, s), 'right', 'HUD frame: the card would cover it');
|
||||
// bond popup candidates shift the same way
|
||||
const ufB = { left: 47 + R * 2.4, right: 47 + R * 3.4, top: 150, bottom: 260 };
|
||||
assert.equal(bondPopupPlace(ufB, { width: hud.width, height: hud.height }, R, null), 'right');
|
||||
assert.equal(bondPopupPlace(ufB, hud, R, null), 'right');
|
||||
assert.equal(bondPopupPlace({ left: R * 2.35, right: R * 2.5, top: 150, bottom: 260 }, hud, R, null), 'left', 'left of the inset-shifted popup');
|
||||
});
|
||||
|
||||
test('css: only the bond discs take the pointer over the board; the underframe undoes the HUD safe-area offset', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const css = readFileSync(new URL('../../public/css/screens/game.css', import.meta.url), 'utf8');
|
||||
assert.match(css, /\.gm__bonds > \*, \.gm__bonds \.bstrip \{ pointer-events: none; \}/, 'the strip box lets taps through');
|
||||
assert.match(css, /\.gm__bonds \.bslot, \.gm__bonds \.bstrip__more \{ pointer-events: auto; \}/, 'the discs stay clickable');
|
||||
assert.doesNotMatch(css, /\.gm__bonds > \* \{ pointer-events: auto; \}/);
|
||||
const dev = readFileSync(new URL('../../public/css/devices.css', import.meta.url), 'utf8');
|
||||
assert.match(dev, /\.gm__hud > \.uframe \{ margin-left: calc\(-1 \* var\(--sa-l\)\); margin-top: calc\(-1 \* var\(--sa-t\)\); \}/);
|
||||
});
|
||||
|
||||
test('underframeRect covers the diamond and its buttons', () => {
|
||||
const r = underframeRect({ x: 500, y: 600, s: 100 }, 100);
|
||||
assert.ok(r.left <= 500 - 105 && r.right >= 500 + 105 && r.bottom === 600 + 105);
|
||||
assert.ok(r.top < 600 - 105, 'buttons above the diamond');
|
||||
assert.equal(underframeRect(null, 100), null);
|
||||
});
|
||||
|
||||
for (const [kind, side] of [['prep', 'L'], ['bossPrep', 'L'], ['bossPrep', 'R']]) {
|
||||
test(`${kind}${kind === 'bossPrep' ? ` ${side}` : ''}: every bench / temp / board tile at 10 viewports`, () => {
|
||||
let docked = 0;
|
||||
for (const [W, H] of SIZES) {
|
||||
const rem = remOf(W, H);
|
||||
const cam = kind === 'prep'
|
||||
? presetCamera('prep', { width: W, height: H }, { rect: { r0: 7, r1: 12, c0: 0, c1: 10 }, side: 'L' })
|
||||
: presetCamera('bossPrep', { width: W, height: H }, { side });
|
||||
for (const shopOpen of [true, false]) {
|
||||
const slots = panelSlots({ width: W, height: H }, rem, { shopOpen });
|
||||
for (const [r, c, area] of tiles()) {
|
||||
// where the view draws the tile (render/prepfield.js: FA prep rows − 7, right half mirrored)
|
||||
const p = kind === 'prep' ? cam.project(c, r, 0) : cam.project(side === 'R' ? 20 - c : c, r - 7, 0);
|
||||
assert.ok(p.x > 0 && p.x < W && p.y > 0 && p.y < H, `${W}×${H} ${area} ${r},${c} on screen`);
|
||||
const uf = underframeRect({ x: p.x, y: p.y, s: p.s }, rem);
|
||||
const chosen = panelSide(uf, slots);
|
||||
if (chosen === 'right') docked++;
|
||||
assert.equal(hit(uf, slots[chosen]), false, `${W}×${H} ${area} ${r},${c}: the ${chosen} card covers the underframe`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(docked > 0, 'some units dock the card on the right');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('8 / 9: the bond popup opened from the card keeps clear of the underframe (and of the card if it can)', () => {
|
||||
const hit = (a, b) => a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
|
||||
const rectOf = (place, W, H, R) => {
|
||||
const left = place === 'left' ? R * BPOP.left : place === 'beside' ? R * BPOP.beside
|
||||
: place === 'besideR' ? W - R * (BPOP.besideR + BPOP.width) : W - R * (BPOP.right + BPOP.width);
|
||||
return { left, right: left + R * BPOP.width, top: R * BPOP.top, bottom: H - R * BPOP.bottomGap };
|
||||
};
|
||||
test('next to the card first, then the free side, then over the card; no card → left', () => {
|
||||
const vp = { width: 1920, height: 1080 };
|
||||
assert.equal(bondPopupPlace(null, vp, 100, null), 'left');
|
||||
assert.equal(bondPopupPlace(null, vp, 100, 'left'), 'beside');
|
||||
assert.equal(bondPopupPlace(null, vp, 100, 'right'), 'besideR');
|
||||
// the playtest screenshot: a bench unit on the left, card docked right, underframe at x 560–840 — both the place
|
||||
// left of the card and the left place cover it: the popup goes over the card
|
||||
assert.equal(bondPopupPlace({ left: 560, right: 840, top: 400, bottom: 700 }, vp, 100, 'right'), 'right');
|
||||
assert.equal(bondPopupPlace({ left: 900, right: 1100, top: 400, bottom: 700 }, vp, 100, 'right'), 'left');
|
||||
assert.equal(bondPopupPlace({ left: 300, right: 500, top: 400, bottom: 700 }, vp, 100, 'right'), 'besideR');
|
||||
assert.equal(bondPopupPlace({ left: 800, right: 1000, top: 400, bottom: 700 }, vp, 100, 'left'), 'right');
|
||||
assert.equal(bondPopupPlace({ left: 0, right: 1920, top: 400, bottom: 700 }, vp, 100, 'left'), 'beside', 'all covered: the first');
|
||||
});
|
||||
test('an underframe anywhere on screen: never covered when some place is free; the card only covered as the last resort', () => {
|
||||
for (const [W, H] of [[1920, 1080], [1280, 720], [2560, 1080], [1600, 1200], [1180, 820]]) {
|
||||
const R = Math.max(40, Math.min(W / 19.2, H / 10.8, 240));
|
||||
const slots = panelSlots({ width: W, height: H }, R, { shopOpen: true });
|
||||
for (const card of ['left', 'right', null]) {
|
||||
const all = card === 'left' ? ['beside', 'right', 'left'] : card === 'right' ? ['besideR', 'left', 'right'] : ['left', 'right'];
|
||||
for (let x = 0; x < W; x += 40) {
|
||||
const uf = { left: x, right: x + R * 2.2, top: H * 0.4, bottom: H * 0.6 };
|
||||
const place = bondPopupPlace(uf, { width: W, height: H }, R, card);
|
||||
const free = all.filter((p) => !hit(uf, rectOf(p, W, H, R)));
|
||||
if (free.length) assert.equal(place, free[0], `${W}×${H} card ${card} uf@${x}`);
|
||||
else assert.equal(place, all[0]);
|
||||
if (card && hit(rectOf(place, W, H, R), slots[card])) assert.equal(place, card, 'over the card only as the last resort');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('10: the boss round prep shows the own half of the boss field', () => {
|
||||
const pl = (playerId, seat, alive = true) => ({ playerId, seat, alive });
|
||||
test('solo 标准 R9 / 险境 R15 and co-op pairs (L / R); eliminated players keep the normal board', () => {
|
||||
assert.deepEqual(prepCamera({ round: 9, bossRound: 9, hiddenRound: null, players: [pl('me', 0)] }, 'me'), { kind: 'bossPrep', opts: { side: 'L' } });
|
||||
assert.deepEqual(prepCamera({ round: 15, bossRound: 14, hiddenRound: 15, players: [pl('me', 0)] }, 'me'), { kind: 'bossPrep', opts: { side: 'L' } });
|
||||
const coop = { round: 14, bossRound: 14, hiddenRound: 15, players: [pl('a', 0), pl('b', 1), pl('c', 2), pl('d', 3, false)] };
|
||||
assert.equal(prepCamera(coop, 'a').opts.side, 'L');
|
||||
assert.equal(prepCamera(coop, 'b').opts.side, 'R');
|
||||
assert.equal(prepCamera(coop, 'c').opts.side, 'L', 'the odd player alone on the second field');
|
||||
assert.equal(prepCamera(coop, 'd').kind, 'prep');
|
||||
assert.equal(prepCamera({ ...coop, round: 13 }, 'a').kind, 'prep');
|
||||
});
|
||||
|
||||
test('the boss prep camera is the official left / right boss prep camera and shows rows 0–5 of its half', () => {
|
||||
for (const side of ['L', 'R']) {
|
||||
const cam = presetCamera('bossPrep', { width: 1920, height: 1080 }, { side });
|
||||
const key = side === 'R' ? 'right_boss_shop_camera_param' : 'left_boss_shop_camera_param';
|
||||
const p = parseCameraParam(OFFICIAL_PARAMS[key]);
|
||||
assert.ok(Math.abs(cam.tx - (p[0] + 10)) < 1e-6, `${side}: official x`);
|
||||
for (const [r, c] of [[2, 2], [2, 10], [5, 10], [0, 0], [1, 4]]) {
|
||||
const q = cam.project(side === 'R' ? 20 - c : c, r, 0);
|
||||
assert.ok(q.x > 0 && q.x < 1920 && q.y > 0 && q.y < 1080, `${side}: boss tile ${r},${c} on screen`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a lone player\'s boss battle can be framed on its half (the ‹ › half cameras)', () => {
|
||||
const half = presetCamera('boss', { width: 1920, height: 1080 }, { rect: { r0: 0, r1: 5, c0: 0, c1: 20 }, side: 'L', half: true });
|
||||
const all = presetCamera('boss', { width: 1920, height: 1080 }, { rect: { r0: 0, r1: 5, c0: 0, c1: 20 }, side: 'L' });
|
||||
assert.ok(half.tx < all.tx, 'the left half camera looks left of 全景');
|
||||
for (const [r, c] of [[2, 2], [2, 10], [5, 10]]) {
|
||||
const q = half.project(c, r, 0);
|
||||
assert.ok(q.x > 0 && q.x < 1920 && q.y > 0 && q.y < 1080, `left half: ${r},${c} on screen (gates / objective)`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('§16: chessLoadout (m.private.loadout → skill / module shown)', () => {
|
||||
const sk = (index, isDefault = false) => ({ index, skillId: `sk_${index}`, iconId: `sk_${index}`, name: `技能${index}`, isDefault });
|
||||
const base = { chessId: 'c_a', baseId: 'c_a', goldenId: 'c_b', isGolden: false, visible: true, skill: sk(2, true), skills: [sk(0), sk(1), sk(2, true)] };
|
||||
const gold = { chessId: 'c_b', baseId: 'c_a', isGolden: true, visible: true, skill: sk(2, true), skills: [sk(0), sk(1), sk(2, true)],
|
||||
module: { id: 'uniequip_x', name: '模组X', type: 'XYZ-X', active: true },
|
||||
modules: [{ uniEquipId: 'uniequip_x', name: '模组X', typeName: 'XYZ-X', isDefault: true }, { uniEquipId: 'uniequip_y', name: '模组Y', typeName: 'XYZ-Y' }] };
|
||||
const get = (id) => ({ c_a: base, c_b: gold }[id] || null);
|
||||
|
||||
test('defaults without a loadout', () => {
|
||||
const r = chessLoadout(base, null, get);
|
||||
assert.equal(r.skill.index, 2);
|
||||
assert.equal(r.defaultSkill, true);
|
||||
assert.equal(r.module, null, 'normal chess have no module');
|
||||
assert.equal(r.changed, false);
|
||||
const g = chessLoadout(gold, {}, get);
|
||||
assert.equal(g.module.id, 'uniequip_x');
|
||||
assert.equal(g.defaultModule, true);
|
||||
});
|
||||
|
||||
test('a chosen skill / module / 不装备 (keyed by the base chess id, for the elite too)', () => {
|
||||
const lo = { c_a: { skill: 0, module: 'none' } };
|
||||
const r = chessLoadout(base, lo, get);
|
||||
assert.equal(r.skill.skillId, 'sk_0');
|
||||
assert.equal(r.defaultSkill, false);
|
||||
assert.equal(r.changed, true);
|
||||
const g = chessLoadout(gold, lo, get);
|
||||
assert.equal(g.skill.skillId, 'sk_0');
|
||||
assert.equal(g.module.none, true);
|
||||
assert.equal(g.module.name, '未装备模组');
|
||||
assert.equal(g.defaultModule, false);
|
||||
const y = chessLoadout(gold, { c_a: { skill: 2, module: 'uniequip_y' } }, get);
|
||||
assert.deepEqual([y.module.id, y.module.name, y.module.typeName, y.defaultSkill], ['uniequip_y', '模组Y', 'XYZ-Y', true]);
|
||||
});
|
||||
|
||||
test('illegal choices fall back to the defaults; data without skills[] keeps the default skill', () => {
|
||||
assert.equal(chessLoadout(base, { c_a: { skill: 7 } }, get).skill.index, 2);
|
||||
assert.equal(chessLoadout(gold, { c_a: { skill: 1, module: 'nope' } }, get).module.id, 'uniequip_x');
|
||||
const old = { chessId: 'o_a', baseId: 'o_a', isGolden: false, visible: true, skill: sk(1, true) };
|
||||
const r = chessLoadout(old, { o_a: { skill: 0 } }, (id) => (id === 'o_a' ? old : null));
|
||||
assert.equal(r.skill.index, 1);
|
||||
assert.equal(chessLoadout(null, null), null);
|
||||
});
|
||||
|
||||
test('a chosen skill without an icon in the manifest: null with { empty: false } (the card draws S1–S3)', () => {
|
||||
const m = { skills: { sk_a: '/assets/skill/sk_a.png' }, skillsById: {}, ui: {} };
|
||||
assert.equal(skillRecordIconUrl(m, { skillId: 'sk_a', iconId: 'sk_a' }, { empty: false }), '/assets/skill/sk_a.png');
|
||||
assert.equal(skillRecordIconUrl(m, { skillId: 'sk_b', iconId: 'sk_b' }, { empty: false }), null);
|
||||
assert.notEqual(skillRecordIconUrl(m, { skillId: 'sk_b' }), '/assets/skill/sk_a.png', 'default: the empty sprite (or null without one)');
|
||||
});
|
||||
|
||||
test('real data: every visible chess resolves a skill (with or without skills[])', () => {
|
||||
const get2 = (id) => DATA.chess[id] || null;
|
||||
for (const c of Object.values(DATA.chess)) {
|
||||
if (!c.visible) continue;
|
||||
const r = chessLoadout(c, null, get2);
|
||||
if (c.skill) assert.ok(r.skill && r.skill.name, `${c.chessId}: default skill`);
|
||||
assert.equal(r.defaultSkill, true, `${c.chessId}: no loadout = default`);
|
||||
}
|
||||
});
|
||||
|
||||
test('integration: the detail card record = what the battle fights with (stats / 特性 / talents of the chosen module or 不装备)', async () => {
|
||||
const { getDefaultSource } = await import('../../server/sim/simdata.js');
|
||||
const ds = getDefaultSource();
|
||||
const get2 = (id) => DATA.chess[id] || null;
|
||||
// 野鬃 elite on 不装备: base ATK / ASPD and the no-module trait (the default module: ATK +40, ASPD +3, "获得2点")
|
||||
const g = DATA.chess.chess_char_1_19_b;
|
||||
const none = chessLoadout(g, { chess_char_1_19_a: { skill: 0, module: 'none' } }, get2);
|
||||
assert.equal(none.record.stats.atk, g.statsBase.atk);
|
||||
assert.equal(none.record.stats.aspd, g.statsBase.aspd);
|
||||
assert.notEqual(none.record.stats.atk, g.stats.atk, 'differs from the default-module card');
|
||||
assert.match(none.record.trait.desc, /获得1点部署费用/);
|
||||
assert.equal(chessLoadout(g, null, get2).record, g, 'default loadout: the data record itself');
|
||||
// every elite × every module choice: the card's record equals the sim's unit def (stats, trait, talents)
|
||||
let n = 0;
|
||||
for (const c of Object.values(DATA.chess)) {
|
||||
if (!c.visible || !c.isGolden || !Array.isArray(c.modules)) continue;
|
||||
for (const moduleId of ['none', ...c.modules.map((x) => x.uniEquipId)]) {
|
||||
const lo = { [c.baseId]: { module: moduleId } };
|
||||
const rec = chessLoadout(c, lo, get2).record;
|
||||
const def = ds.getChess(c.chessId, { moduleId });
|
||||
for (const k of ['maxHp', 'atk', 'def', 'res', 'aspd', 'cost', 'blockCnt', 'respawnTime']) {
|
||||
if (rec.stats[k] != null && def.raw.stats[k] != null) assert.equal(rec.stats[k], def.raw.stats[k], `${c.chessId} ${moduleId} ${k}`);
|
||||
}
|
||||
assert.equal(rec.trait?.desc ?? null, def.raw.trait?.desc ?? null, `${c.chessId} ${moduleId} trait`);
|
||||
assert.deepEqual((rec.talents || []).map((t) => t.name), (def.raw.talents || []).map((t) => t.name), `${c.chessId} ${moduleId} talents`);
|
||||
n++;
|
||||
}
|
||||
}
|
||||
assert.ok(n > 100, `${n} elite × module choices`);
|
||||
});
|
||||
|
||||
test('integration: the card / wheel range = the battle unit\'s range (an equipped "攻击范围扩大" module grid)', async () => {
|
||||
const { makeBattle } = await import('../helpers/battleHarness.js');
|
||||
const { attackRangeGrid } = await import('../../shared/loadoutRecord.js');
|
||||
const { previewGrid } = await import('../../public/js/ui/facing.js');
|
||||
const get2 = (id) => DATA.chess[id] || null;
|
||||
const key = (g) => g.map(([r, c]) => `${r},${c}`).sort().join(' ');
|
||||
let changed = 0;
|
||||
for (const c of Object.values(DATA.chess)) {
|
||||
if (!c.visible || !c.isGolden || !Array.isArray(c.modules)) continue;
|
||||
for (const moduleId of ['none', ...c.modules.map((x) => x.uniEquipId)]) {
|
||||
const lo = { [c.baseId]: { module: moduleId } };
|
||||
const rec = chessLoadout(c, lo, get2).record;
|
||||
const g = attackRangeGrid(rec);
|
||||
if (key(g) === key(c.rangeGrid) && moduleId === 'none') continue;
|
||||
const h = makeBattle({ units: [{ chessId: c.chessId, row: 10, col: 3, moduleId }], enemies: [], autoFinish: false, timeLimit: 5 });
|
||||
h.step();
|
||||
const u = h.unit(c.chessId);
|
||||
assert.equal(key(g), key(u.rangeGrid), `${c.chessId} ${moduleId}: card range = battle range`);
|
||||
const wheel = previewGrid({ getChess: get2, chessRecord: (r) => chessLoadout(r, lo, get2).record }, { kind: 'chess', id: c.chessId });
|
||||
assert.equal(key(wheel), key(u.rangeGrid), `${c.chessId} ${moduleId}: wheel preview = battle range`);
|
||||
if (key(g) !== key(c.rangeGrid)) changed++;
|
||||
}
|
||||
}
|
||||
assert.ok(changed >= 4, `${changed} module choices change the range (莫斯提马 SPC-X, 莱恩哈特 / 白面鸮 / 夕 defaults)`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,671 @@
|
||||
// Real-server browser E2E (DESIGN §11 "Browser E2E"): the real server (server/index.js on a free port) and headless
|
||||
// Chrome clients driving the real UI with real clicks, keys and canvas drags — no net.request shortcuts.
|
||||
//
|
||||
// SP_REAL_E2E=1 node --test test/ui/real.e2e.test.js # co-op (2 humans + AI) to round 4+, then a solo run
|
||||
// SP_REAL_E2E=1 SP_REAL_ROUNDS=6 node --test test/ui/real.e2e.test.js
|
||||
//
|
||||
// Co-op: host creates a 同盟模拟 room (险境), adds an AI teammate; the guest joins through the ?room= invite link;
|
||||
// both ready, the host starts. Briefing ready → band draft (guest skips once, then picks a strategy nobody took —
|
||||
// 队友已选) → every prep round: take a merge reward if offered (two taps), buy from the shop (two taps: 确认购买), drag
|
||||
// hand pieces onto legal board tiles and choose a direction on the deploy wheel (g.move carries `dir`), sell a unit by
|
||||
// tapping it → underframe 出售 +N, level up (two taps), refresh, freeze/unfreeze, equip bought items by dragging them
|
||||
// onto a unit, ready (mouse / keyboard) → 机变 picks → combat rendered by the Pixi engine with real Spine units (client-
|
||||
// side combat: no view switcher); once a human's own battle is over, a teammate row → 前往查看 shows that teammate's
|
||||
// running battle and 返回战场 goes back (research 09 §3.1) → … until combat of round SP_REAL_ROUNDS (default 4).
|
||||
// Solo: 独立模拟 (标准), free band pick, two rounds with shopping and deployment, then 放弃模拟 back to the lobby.
|
||||
// Every page must finish with zero console errors, page errors, failed requests or HTTP errors.
|
||||
// Screenshots: test/e2e/out/real-*.png.
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const OUT = path.join(ROOT, 'test/e2e/out');
|
||||
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const ENABLED = process.env.SP_REAL_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
|
||||
const ROUNDS = Math.max(2, Number(process.env.SP_REAL_ROUNDS) || 4);
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const CHROME_ARGS = ['--no-sandbox', '--no-first-run', '--autoplay-policy=no-user-gesture-required', '--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows'];
|
||||
|
||||
/**
|
||||
* One player: its own Chrome process with a single (always visible) tab. Two tabs of one browser would leave one
|
||||
* hidden — no animation frames, so the Pixi view and puppeteer's visibility checks stall.
|
||||
*/
|
||||
class Client {
|
||||
constructor(puppeteer, base, label, { w = 1920, h = 1080 } = {}) {
|
||||
Object.assign(this, { puppeteer, base, label, w, h, problems: [], log: [], shots: 0 });
|
||||
}
|
||||
|
||||
async open(query = '') {
|
||||
this.browser = await this.puppeteer.launch({ executablePath: CHROME, headless: 'new', args: CHROME_ARGS, protocolTimeout: 60000 });
|
||||
const [first] = await this.browser.pages();
|
||||
const page = first || await this.browser.newPage();
|
||||
this.page = page;
|
||||
await page.setViewport({ width: this.w, height: this.h });
|
||||
page.on('console', (m) => { if (m.type() === 'error') this.problems.push(`console: ${m.text()}`); });
|
||||
page.on('pageerror', (e) => this.problems.push(`pageerror: ${e.message}`));
|
||||
page.on('requestfailed', (r) => this.problems.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`));
|
||||
page.on('response', (r) => { if (r.status() >= 400) this.problems.push(`http ${r.status()}: ${r.url()}`); });
|
||||
page.on('dialog', (d) => d.dismiss().catch(() => {}));
|
||||
await page.goto(`${this.base}/${query}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!globalThis.__SP__ && !!document.querySelector('.screen'), { timeout: 30000 });
|
||||
}
|
||||
|
||||
note(msg) { this.log.push(`[${this.label}] ${msg}`); }
|
||||
|
||||
/** Compact snapshot of the client store. */
|
||||
st() {
|
||||
return this.page.evaluate(() => {
|
||||
const s = globalThis.__SP__.store.get();
|
||||
const pub = s.match.public;
|
||||
const priv = s.match.private;
|
||||
const me = pub?.players?.find((p) => p.playerId === s.me.playerId) || null;
|
||||
return {
|
||||
me: s.me.playerId, room: s.room ? { code: s.room.code, inMatch: !!s.room.inMatch, mode: s.room.mode } : null,
|
||||
phase: pub?.phase ?? null, round: pub?.round ?? 0, status: me?.status ?? null, alive: priv ? priv.alive !== false : true,
|
||||
ready: !!priv?.ready, funds: priv?.funds ?? null, level: priv?.shop?.level ?? null, frozen: !!priv?.shop?.frozen,
|
||||
upgradePrice: priv?.shop?.upgradePrice ?? null, refreshPrice: priv?.shop?.refreshPrice ?? null,
|
||||
deployCap: priv?.deployCap ?? 8, deployCount: priv?.deployCount ?? 0, reward: !!priv?.shop?.rewardOffer,
|
||||
hand: (priv?.hand || []).filter(Boolean).length, temp: (priv?.temp || []).filter(Boolean).length,
|
||||
board: (priv?.board || []).length, result: !!s.match.result, field: s.match.field?.fieldId ?? null,
|
||||
sp: pub?.sp ? { turn: pub.sp.turn, picked: Object.keys(pub.sp.picks || {}).length } : null,
|
||||
draft: pub?.draft ? { turn: pub.draft.turn, picks: Object.keys(pub.draft.picks || {}).length } : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async waitFor(pred, what, timeout = 60000) {
|
||||
const t0 = Date.now();
|
||||
let last = null;
|
||||
while (Date.now() - t0 < timeout) {
|
||||
last = await this.st();
|
||||
if (pred(last)) return last;
|
||||
await sleep(250);
|
||||
}
|
||||
throw new Error(`${this.label}: timed out waiting for ${what} (last: ${JSON.stringify(last)})`);
|
||||
}
|
||||
|
||||
async close() { await this.browser?.close().catch(() => {}); }
|
||||
|
||||
/** Real mouse click on the `nth` visible element matching `sel` whose text includes `text`. */
|
||||
async click(sel, text = null, { timeout = 15000, optional = false, nth = 0 } = {}) {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < timeout) {
|
||||
const pt = await this.page.evaluate((sel, text, nth) => {
|
||||
let n = 0;
|
||||
for (const el of document.querySelectorAll(sel)) {
|
||||
if (text && !(el.textContent || '').includes(text)) continue;
|
||||
if (el.disabled || el.getAttribute('aria-disabled') === 'true') continue;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width < 2 || r.height < 2) continue;
|
||||
const x = r.left + r.width / 2, y = r.top + r.height / 2;
|
||||
const top = document.elementFromPoint(x, y);
|
||||
if (top && !el.contains(top) && !top.contains(el)) continue; // covered
|
||||
if (n++ < nth) continue;
|
||||
return { x, y };
|
||||
}
|
||||
return null;
|
||||
}, sel, text, nth);
|
||||
if (pt) { await this.page.mouse.click(pt.x, pt.y); return true; }
|
||||
await sleep(200);
|
||||
}
|
||||
if (optional) return false;
|
||||
throw new Error(`${this.label}: nothing clickable for ${sel}${text ? ` "${text}"` : ''}`);
|
||||
}
|
||||
|
||||
async exists(sel) { return !!(await this.page.$(sel)); }
|
||||
|
||||
async shot(name) {
|
||||
await this.page.screenshot({ path: path.join(OUT, `real-${name}.png`) });
|
||||
this.shots += 1;
|
||||
}
|
||||
|
||||
/** Title → lobby with a callsign. */
|
||||
async enter(name) {
|
||||
await this.page.waitForSelector('.title-login input', { timeout: 20000 });
|
||||
await this.click('.title-login input'); // fresh profile per client: the field starts empty
|
||||
await this.page.keyboard.type(name);
|
||||
await this.click('.title-login button', '开始');
|
||||
}
|
||||
|
||||
// ---- field view helpers (render engine debug hooks) ------------------------------------------------------------
|
||||
|
||||
viewKind() { return this.page.evaluate(() => globalThis.__SP_VIEW__?.kind ?? null); }
|
||||
viewStats() { return this.page.evaluate(() => { try { return globalThis.__SP_VIEW__?.raw?.stats?.() ?? null; } catch { return null; } }); }
|
||||
|
||||
/** Client point to grab a prep piece. */
|
||||
piecePoint(uid) {
|
||||
return this.page.evaluate((uid) => {
|
||||
const r = globalThis.__SP_VIEW__?.pieceScreenRect(uid);
|
||||
return r && r.width > 0 ? { x: r.left + r.width / 2, y: r.top + r.height * 0.72 } : null;
|
||||
}, uid);
|
||||
}
|
||||
|
||||
/** Client point of a board tile centre (top face). */
|
||||
tilePoint(row, col) {
|
||||
return this.page.evaluate((row, col) => {
|
||||
const raw = globalThis.__SP_VIEW__?.raw;
|
||||
const cam = raw?.debug?.cam;
|
||||
if (!cam) return null;
|
||||
const z = raw.debug.tiles.heightAt(row, col);
|
||||
const p = cam.project(col, row, z);
|
||||
const cr = raw.debug.app.view.getBoundingClientRect();
|
||||
return { x: cr.left + p.x, y: cr.top + p.y };
|
||||
}, row, col);
|
||||
}
|
||||
|
||||
async drag(from, to, steps = 14) {
|
||||
const m = this.page.mouse;
|
||||
await m.move(from.x, from.y);
|
||||
await m.down();
|
||||
for (let i = 1; i <= steps; i++) { await m.move(from.x + ((to.x - from.x) * i) / steps, from.y + ((to.y - from.y) * i) / steps); await sleep(14); }
|
||||
await sleep(120);
|
||||
await m.up();
|
||||
}
|
||||
|
||||
/** An empty legal tile for a hand piece (mirrors the deploy rules: melee on melee tiles, ranged anywhere). */
|
||||
freeTileFor(uid) {
|
||||
return this.page.evaluate((uid) => {
|
||||
const { store, data } = globalThis.__SP__;
|
||||
const s = store.get();
|
||||
const priv = s.match.private;
|
||||
const st = data.lookup('stages', s.match.public.stageId);
|
||||
const piece = [...priv.hand, ...priv.temp].find((p) => p && p.uid === uid);
|
||||
if (!piece || !st?.deployTiles?.normal) return null;
|
||||
const rec = piece.kind === 'token' ? data.lookup('tokens', piece.id) : data.lookup('chess', piece.id);
|
||||
const melee = rec?.position === 'MELEE';
|
||||
const dt = st.deployTiles.normal;
|
||||
const tiles = melee ? dt.melee : [...dt.rangedOnly, ...dt.melee];
|
||||
const used = new Set(priv.board.map((p) => `${p.row},${p.col}`));
|
||||
const free = tiles.filter(([r, c]) => !used.has(`${r},${c}`));
|
||||
if (!free.length) return null;
|
||||
free.sort((a, b) => (b[1] - a[1]) || (a[0] - b[0])); // towards the gates first
|
||||
return { row: free[0][0], col: free[0][1] };
|
||||
}, uid);
|
||||
}
|
||||
|
||||
handPieces(kind = null) {
|
||||
return this.page.evaluate((kind) => {
|
||||
const priv = globalThis.__SP__.store.get().match.private;
|
||||
return (priv?.hand || []).filter((p) => p && (!kind || p.kind === kind)).map((p) => ({ uid: p.uid, id: p.id, kind: p.kind, golden: !!p.golden }));
|
||||
}, kind);
|
||||
}
|
||||
|
||||
boardPieces() {
|
||||
return this.page.evaluate(() => (globalThis.__SP__.store.get().match.private?.board || []).map((p) => ({
|
||||
uid: p.uid, id: p.id, kind: p.kind, golden: !!p.golden, row: p.row, col: p.col, items: (p.items || []).length,
|
||||
})));
|
||||
}
|
||||
|
||||
async isEditable() {
|
||||
const s = await this.st();
|
||||
return s.phase === 'PREP' && !s.ready && s.alive;
|
||||
}
|
||||
|
||||
// ---- prep actions ---------------------------------------------------------------------------------------------------
|
||||
|
||||
async takeReward() {
|
||||
if (!(await this.exists('.shopbar__rwcards .scard:not(.scard--sold)'))) return false;
|
||||
const before = await this.st();
|
||||
await this.click('.shopbar__rwcards .scard:not(.scard--sold)');
|
||||
await this.page.waitForSelector('.shopbar__rwcards .scard.is-armed', { timeout: 3000 });
|
||||
await this.click('.shopbar__rwcards .scard.is-armed');
|
||||
await this.waitFor((s) => !s.reward || s.phase !== 'PREP', 'reward taken', 8000);
|
||||
this.note(`reward taken (hand ${before.hand} → ${(await this.st()).hand})`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Two taps (research 09 §5): the first selects (确认购买), the second buys. */
|
||||
async buyOne(sel = '.shopbar__cards .scard:not(.scard--sold):not(.is-disabled)') {
|
||||
const before = await this.st();
|
||||
const ok = await this.click(sel, null, { timeout: 1500, optional: true });
|
||||
if (!ok) return false;
|
||||
await this.page.waitForSelector('.scard.is-armed', { timeout: 3000 });
|
||||
assert.equal((await this.st()).funds, before.funds, `${this.label}: the first tap only selects`);
|
||||
await this.click('.scard.is-armed');
|
||||
const after = await this.waitFor((s) => s.funds !== before.funds || s.phase !== 'PREP', 'purchase', 8000);
|
||||
this.note(`bought${sel.includes('item') ? ' item' : ''} (funds ${before.funds} → ${after.funds})`);
|
||||
return after.funds < before.funds;
|
||||
}
|
||||
|
||||
buyItem() { return this.buyOne('.shopbar__item .scard:not(.scard--sold):not(.is-disabled)'); }
|
||||
|
||||
/** Right-click a board unit on the canvas → detail panel with its name. */
|
||||
async inspectUnit() {
|
||||
// the front-most unit (nothing is drawn in front of it)
|
||||
const unit = (await this.boardPieces()).filter((b) => b.kind === 'chess').sort((a, b) => a.row - b.row || b.col - a.col)[0];
|
||||
if (!unit) return false;
|
||||
const name = await this.page.evaluate((id) => globalThis.__SP__.data.lookup('chess', id)?.name || '', unit.id);
|
||||
const pt = await this.piecePoint(unit.uid);
|
||||
await this.page.mouse.click(pt.x, pt.y, { button: 'right' });
|
||||
await this.page.waitForSelector('.dpanel', { timeout: 5000 });
|
||||
const shown = await this.page.$eval('.dpanel .dhead__name', (e) => e.textContent);
|
||||
assert.equal(shown, name, `${this.label}: detail panel shows the right-clicked unit`);
|
||||
await sleep(300);
|
||||
await this.shot(`detail-${this.label}`);
|
||||
await this.page.keyboard.press('Escape');
|
||||
await this.page.waitForFunction(() => !document.querySelector('.dpanel'), { timeout: 4000 });
|
||||
this.note(`inspected ${name}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Click the first bond disc → popup with the bond name; close it. */
|
||||
async openBond() {
|
||||
if (!(await this.exists('.bslot .bond'))) return false;
|
||||
await this.click('.bslot .bond');
|
||||
await this.page.waitForSelector('.bpop', { timeout: 5000 });
|
||||
const name = await this.page.$eval('.bpop .bpop__name', (e) => e.textContent);
|
||||
assert.ok(name && name.length > 0, 'bond popup has a name');
|
||||
await sleep(250);
|
||||
await this.shot(`bond-${this.label}`);
|
||||
await this.click('.bpop__close');
|
||||
this.note(`bond popup ${name}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Enemy preview: 🔍▶▶ pans to the pen (🔍◀◀ back); the list is the 敌方情报 tab of the 本局信息 dialog (left 🔍). */
|
||||
async openEnemies() {
|
||||
await this.click('.enemybtn');
|
||||
const pen = await this.page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'pen', { timeout: 5000 }).then(() => true, () => false);
|
||||
if (pen) {
|
||||
await sleep(500);
|
||||
await this.shot(`pen-${this.label}`);
|
||||
await this.click('.gtop__iconbtn');
|
||||
await this.page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera !== 'pen', { timeout: 4000 });
|
||||
}
|
||||
await this.click('.gtop__iconbtn[aria-label="本局信息"]');
|
||||
await this.page.waitForSelector('.edrawer', { timeout: 5000 });
|
||||
await this.click('.edrawer .tabs__tab:nth-child(2)');
|
||||
await sleep(250);
|
||||
const rows = await this.page.$$eval('.edrawer .erow', (els) => els.length);
|
||||
await this.shot(`enemies-${this.label}`);
|
||||
await this.click('.edrawer__close');
|
||||
this.note(`enemy pen ${pen ? 'shown' : 'unavailable'}, list: ${rows} rows`);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async emote() {
|
||||
await this.click('.ewheel__btn');
|
||||
await this.click('.ewheel__item', null, { nth: 2 });
|
||||
}
|
||||
|
||||
async deployFromHand(maxMoves = 3) {
|
||||
let moved = 0;
|
||||
for (let i = 0; i < maxMoves; i++) {
|
||||
const s = await this.st();
|
||||
if (!(await this.isEditable()) || s.deployCount >= s.deployCap) break;
|
||||
const hand = (await this.handPieces()).filter((p) => p.kind !== 'item');
|
||||
if (!hand.length) break;
|
||||
const p = hand[0];
|
||||
const tile = await this.freeTileFor(p.uid);
|
||||
if (!tile) break;
|
||||
const from = await this.piecePoint(p.uid);
|
||||
const to = await this.tilePoint(tile.row, tile.col);
|
||||
if (!from || !to) break;
|
||||
await this.drag(from, to);
|
||||
// the direction wheel (research 09 §1.2): swipe from its centre towards a side, release
|
||||
await this.page.waitForSelector('.fwheel__dia', { timeout: 4000 });
|
||||
const w = await this.page.$eval('.fwheel__dia', (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2, half: r.width / 2 }; });
|
||||
const up = i % 2 === 1;
|
||||
await this.drag({ x: w.x, y: w.y }, { x: w.x + (up ? 1 : w.half * 0.7), y: w.y - (up ? w.half * 0.7 : 0) }, 10);
|
||||
await this.waitFor((x) => x.board !== s.board || x.phase !== 'PREP', 'deploy', 6000).catch(() => null);
|
||||
const onBoard = (await this.boardPieces()).find((b) => b.uid === p.uid);
|
||||
assert.ok(onBoard, `${this.label}: dragged piece ${p.id} onto the board`);
|
||||
assert.deepEqual([onBoard.row, onBoard.col], [tile.row, tile.col], `${this.label}: landed on the target tile`);
|
||||
moved += 1;
|
||||
}
|
||||
if (moved) this.note(`deployed ${moved}`);
|
||||
return moved;
|
||||
}
|
||||
|
||||
/** Tap a normal board unit → its underframe → 出售 +N (research 09 §5: no drag-to-sell). */
|
||||
async sellByTap() {
|
||||
const unit = (await this.boardPieces()).filter((b) => b.kind === 'chess' && !b.golden && !b.items).sort((a, b) => a.row - b.row || b.col - a.col)[0];
|
||||
if (!unit) return false;
|
||||
const before = await this.st();
|
||||
let open = false;
|
||||
for (const at of [0.72, 0.5, 0.88]) {
|
||||
const pt = await this.page.evaluate((uid, at) => { const r = globalThis.__SP_VIEW__?.pieceScreenRect(uid); return r && { x: r.left + r.width / 2, y: r.top + r.height * at }; }, unit.uid, at);
|
||||
if (!pt) break;
|
||||
await this.page.mouse.click(pt.x, pt.y);
|
||||
if (await this.page.waitForSelector('.uframe__btn--sell', { timeout: 1500 }).then(() => true, () => false)) { open = true; break; }
|
||||
}
|
||||
if (!open) return false;
|
||||
await this.click('.uframe__btn--sell');
|
||||
const after = await this.waitFor((s) => s.board < before.board || s.phase !== 'PREP', 'sell', 6000);
|
||||
assert.ok(after.funds > before.funds, `${this.label}: selling paid funds`);
|
||||
this.note(`sold ${unit.id} with 出售 (funds ${before.funds} → ${after.funds})`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Two taps: 确认升级 then the upgrade. */
|
||||
async levelUp() {
|
||||
const s = await this.st();
|
||||
if (s.level >= 6 || s.funds < s.upgradePrice) return false;
|
||||
await this.click('.lvcard');
|
||||
await this.page.waitForSelector('.lvcard.is-armed', { timeout: 3000 });
|
||||
await this.click('.lvcard.is-armed');
|
||||
const after = await this.waitFor((x) => x.level > s.level || x.phase !== 'PREP', 'level up', 6000);
|
||||
this.note(`level ${s.level} → ${after.level}`);
|
||||
return after.level > s.level;
|
||||
}
|
||||
|
||||
/** R shortcut → the shop is rerolled for the refresh price (or a free refresh from a 机变 card is used up). */
|
||||
async refresh() {
|
||||
const s = await this.st();
|
||||
if (s.funds < s.refreshPrice) return false;
|
||||
const shop = () => this.page.evaluate(() => { const sh = globalThis.__SP__.store.get().match.private.shop; return { ids: JSON.stringify(sh.slots.map((x) => x && x.id)), free: sh.freeRefreshes }; });
|
||||
const before = await shop();
|
||||
await this.page.mouse.click(this.w / 2, this.h * 0.3); // focus the page (not a HUD button)
|
||||
await this.page.keyboard.press('KeyR');
|
||||
const t0 = Date.now();
|
||||
let after = null; let now = null;
|
||||
while (Date.now() - t0 < 6000) {
|
||||
after = await this.st(); now = await shop();
|
||||
if (after.phase !== 'PREP' || after.funds !== s.funds || now.free !== before.free || now.ids !== before.ids) break;
|
||||
await sleep(150);
|
||||
}
|
||||
assert.ok(after.funds !== s.funds || now.free !== before.free || now.ids !== before.ids, `${this.label}: R refreshed the shop`);
|
||||
if (s.refreshPrice > 0) assert.equal(after.funds, s.funds - s.refreshPrice, `${this.label}: refresh cost`);
|
||||
this.note(`refresh (${s.refreshPrice ? `funds ${s.funds} → ${after.funds}` : `free ${before.free} → ${now.free}`}, slots ${before.ids === now.ids ? 'same ids' : 'rerolled'})`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 冻结 → 解冻 with the mouse. */
|
||||
async freezeToggle() {
|
||||
const s = await this.st();
|
||||
await this.click('.toolbtn--ice');
|
||||
await this.waitFor((x) => x.frozen !== s.frozen || x.phase !== 'PREP', 'freeze', 6000);
|
||||
assert.ok(await this.exists('.shopbar.is-frozen'), 'the frozen shop is tinted');
|
||||
await this.click('.toolbtn--ice', '解冻');
|
||||
await this.waitFor((x) => x.frozen === s.frozen || x.phase !== 'PREP', 'unfreeze', 6000);
|
||||
this.note('freeze toggled twice');
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Drag a hand item onto a board operator → equipped. */
|
||||
async equipItem() {
|
||||
const item = (await this.handPieces('item'))[0];
|
||||
const target = (await this.boardPieces()).find((b) => b.kind === 'chess' && b.items < 2);
|
||||
if (!item || !target) return false;
|
||||
const from = await this.piecePoint(item.uid);
|
||||
const to = await this.piecePoint(target.uid);
|
||||
if (!from || !to) return false;
|
||||
await this.drag(from, to);
|
||||
await sleep(400);
|
||||
// a full operator: the equip-replace dialog (ui/equipReplace.js) — replace the older item
|
||||
if (await this.exists('.eqr .eqr__opt')) {
|
||||
await this.click('.eqr__opt');
|
||||
await this.click('.eqr__ok');
|
||||
await sleep(400);
|
||||
}
|
||||
const after = (await this.boardPieces()).find((b) => b.uid === target.uid);
|
||||
const itemGone = !(await this.handPieces('item')).some((p) => p.uid === item.uid);
|
||||
this.note(`equip ${item.id} → ${target.id}: ${itemGone && after && after.items > target.items ? 'ok' : 'not applied'}`);
|
||||
return itemGone;
|
||||
}
|
||||
|
||||
async ready() {
|
||||
if (!(await this.isEditable())) return;
|
||||
await this.click('.readybtn');
|
||||
await this.waitFor((s) => s.ready || s.phase !== 'PREP', 'ready', 8000);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertClean(clients) {
|
||||
for (const c of clients) {
|
||||
assert.deepEqual(c.problems, [], `${c.label}: console / page / network errors`);
|
||||
}
|
||||
}
|
||||
|
||||
describe('real server + real browsers', { skip: !ENABLED && 'set SP_REAL_E2E=1 (needs Chrome and downloaded assets)' }, () => {
|
||||
let srv;
|
||||
let puppeteer;
|
||||
let base;
|
||||
|
||||
before(async () => {
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
puppeteer = (await import('puppeteer-core')).default;
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
base = `http://127.0.0.1:${srv.port}`;
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await srv?.close();
|
||||
});
|
||||
|
||||
test(`co-op: 2 humans + AI from the lobby through combat of round ${ROUNDS}`, { timeout: 30 * 60 * 1000 }, async () => {
|
||||
const host = new Client(puppeteer, base, 'host');
|
||||
const guest = new Client(puppeteer, base, 'guest', { w: 1280, h: 720 });
|
||||
const both = [host, guest];
|
||||
try {
|
||||
// ---- lobby & room --------------------------------------------------------------------------------------------
|
||||
await host.open();
|
||||
await host.enter('凯尔希');
|
||||
await host.click('.mode-card', '同盟模拟');
|
||||
await host.click('.diff-card', '险境模拟');
|
||||
await host.click('.create-box button', '创建同盟');
|
||||
const room = (await host.waitFor((s) => !!s.room?.code, 'room created')).room;
|
||||
await host.click('button', '添加 AI 队友');
|
||||
await guest.open(`?room=${room.code}`);
|
||||
await guest.enter('阿米娅');
|
||||
await guest.waitFor((s) => s.room?.code === room.code, 'guest joined via invite link');
|
||||
await guest.click('.room-bar__right button', '准备就绪');
|
||||
await host.shot('room');
|
||||
await host.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
|
||||
|
||||
// ---- briefing ---------------------------------------------------------------------------------------------------
|
||||
for (const c of both) await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
|
||||
await sleep(800);
|
||||
await host.shot('briefing');
|
||||
await guest.shot('briefing-720');
|
||||
for (const c of both) await c.click('.brief__foot .btn--primary', '准备就绪');
|
||||
|
||||
// ---- band draft -------------------------------------------------------------------------------------------------
|
||||
for (const c of both) await c.waitFor((s) => s.phase === 'BAND_DRAFT' || s.phase === 'BATTLE_CHECK' || s.phase === 'PREP', 'band draft', 40000);
|
||||
let guestSkipped = false;
|
||||
let shotDraft = false;
|
||||
const picked = new Set();
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < 150000) {
|
||||
const [hs, gs] = [await host.st(), await guest.st()];
|
||||
if (hs.phase !== 'BAND_DRAFT' && gs.phase !== 'BAND_DRAFT') break;
|
||||
for (const [c, s] of [[host, hs], [guest, gs]]) {
|
||||
if (s.phase !== 'BAND_DRAFT' || picked.has(c.label) || s.draft?.turn !== s.me) continue;
|
||||
if (!shotDraft) { await sleep(600); await c.shot('draft'); shotDraft = true; }
|
||||
if (c === guest && !guestSkipped) {
|
||||
await c.click('.draft-detail__btns .btn', '跳过');
|
||||
guestSkipped = true;
|
||||
c.note('skipped once');
|
||||
continue;
|
||||
}
|
||||
await c.click('.dband:not(.is-taken)', null, { nth: c === host ? 3 : 7 });
|
||||
await c.click('.draft-detail__btns .btn--primary', '确认选择');
|
||||
picked.add(c.label);
|
||||
c.note('band picked');
|
||||
}
|
||||
await sleep(300);
|
||||
}
|
||||
assert.ok(picked.size === 2, `both humans picked a band (${[...picked]})`);
|
||||
|
||||
// ---- rounds -----------------------------------------------------------------------------------------------------
|
||||
const seen = new Set();
|
||||
const did = { watched: false, sold: false, leveled: false, refreshed: false, froze: false, item: false, equipped: false, inspected: false, bond: false, enemies: -1, emote: false };
|
||||
let spPicked = 0;
|
||||
let lastCombatRound = 0;
|
||||
const deadline = Date.now() + 25 * 60 * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
const hs = await host.st();
|
||||
if (hs.result || hs.phase === 'RESULT') break;
|
||||
seen.add(hs.phase);
|
||||
if (hs.phase === 'PREP') {
|
||||
for (const c of both) {
|
||||
if (!(await c.isEditable())) continue;
|
||||
const s = await c.st();
|
||||
if (!seen.has(`prep-shot-${c.label}-${s.round}`)) {
|
||||
seen.add(`prep-shot-${c.label}-${s.round}`);
|
||||
await sleep(1700); // past the 休整期 banner
|
||||
await c.shot(`prep-r${s.round}-${c.label}`);
|
||||
}
|
||||
await c.takeReward();
|
||||
if (c === host && did.enemies < 0) did.enemies = await c.openEnemies();
|
||||
if (c === guest && !did.emote) {
|
||||
await c.emote();
|
||||
await host.page.waitForSelector('.team__bubble', { timeout: 5000 });
|
||||
await host.shot('emote-bubble');
|
||||
did.emote = true;
|
||||
c.note('emote seen by the host');
|
||||
}
|
||||
if (c === guest && s.round >= 2 && !did.leveled) did.leveled = await c.levelUp();
|
||||
if (c === host && s.round >= 2 && !did.item) did.item = await c.buyItem();
|
||||
if (c === host && s.round >= 3 && !did.refreshed) did.refreshed = await c.refresh();
|
||||
for (let k = 0; k < 3; k++) if (!(await c.buyOne())) break;
|
||||
await c.deployFromHand(4);
|
||||
if (c === host && s.round >= 2 && !did.sold) did.sold = await c.sellByTap();
|
||||
if (!did.equipped) did.equipped = await c.equipItem();
|
||||
if (c === host && s.round >= 2 && !did.inspected) did.inspected = await c.inspectUnit();
|
||||
if (c === host && s.round >= 2 && !did.bond) did.bond = await c.openBond();
|
||||
if (c === guest && !did.froze) did.froze = await c.freezeToggle();
|
||||
await c.deployFromHand(2);
|
||||
const s2 = await c.st();
|
||||
if (s2.temp > 0) c.note(`temp not empty (${s2.temp}) — ready blocked`);
|
||||
if (c === guest) { await c.page.keyboard.press('Space'); await c.waitFor((x) => x.ready || x.phase !== 'PREP', 'ready (Space)', 8000); } else await c.ready();
|
||||
}
|
||||
} else if (hs.phase === 'SP_DRAFT') {
|
||||
for (const c of both) {
|
||||
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}`); }
|
||||
await c.click('.spcard.is-pickable');
|
||||
await c.waitFor((x) => x.sp?.turn !== x.me || x.phase !== 'SP_DRAFT', 'sp pick', 8000);
|
||||
spPicked += 1;
|
||||
c.note(`机变 picked (round ${s.round})`);
|
||||
}
|
||||
}
|
||||
} else if (['COMBAT', 'UNITE', 'FINAL_ASSAULT', 'HIDDEN_CORE'].includes(hs.phase) && lastCombatRound !== hs.round * 10 + (hs.phase === 'UNITE' ? 1 : 0)) {
|
||||
lastCombatRound = hs.round * 10 + (hs.phase === 'UNITE' ? 1 : 0);
|
||||
assert.equal(await host.viewKind(), 'engine', 'the Pixi render engine is mounted (not the DOM fallback)');
|
||||
// units rendered by the engine, Spine models loaded
|
||||
const t1 = Date.now();
|
||||
let st = null;
|
||||
while (Date.now() - t1 < 15000) {
|
||||
st = await host.viewStats();
|
||||
if (st && st.mode === 'battle' && st.units > 0 && (st.spine?.ready ?? 0) > 0) break;
|
||||
await sleep(300);
|
||||
}
|
||||
assert.ok(st && st.mode === 'battle' && st.units > 0, `round ${hs.round} ${hs.phase}: battle units on screen (${JSON.stringify(st && { mode: st.mode, units: st.units })})`);
|
||||
assert.ok((st.spine?.ready ?? 0) > 0, `round ${hs.round}: Spine models loaded (${JSON.stringify(st.spine)})`);
|
||||
assert.equal(st.spine?.failed ?? 0, 0, 'no Spine load failures');
|
||||
await sleep(1500);
|
||||
await host.shot(`combat-r${hs.round}${hs.phase === 'UNITE' ? '-unite' : ''}`);
|
||||
await guest.shot(`combat-r${hs.round}${hs.phase === 'UNITE' ? '-unite' : ''}-720`);
|
||||
host.note(`round ${hs.round} ${hs.phase}: ${st.units} views, fps ${st.fps}, spine ${JSON.stringify(st.spine)}`);
|
||||
// research 09 §3.1: no view switcher in normal combat; after the own battle ends, a teammate row → 前往查看
|
||||
if (hs.phase === 'COMBAT') assert.equal(await host.exists('.chud .vswitch__arrow'), false, 'no ‹ › switcher during normal combat');
|
||||
const tObs = Date.now();
|
||||
while (!did.watched && hs.phase === 'COMBAT' && Date.now() - tObs < 90000) {
|
||||
const cs = await host.st();
|
||||
if (cs.phase !== 'COMBAT' || cs.round !== hs.round) break;
|
||||
for (const c of both) {
|
||||
if (did.watched) break;
|
||||
const rs = await c.page.evaluate(() => globalThis.__SP_RUNNER__?.state() ?? null);
|
||||
if (!rs || !rs.own || !rs.done) continue;
|
||||
const other = await c.page.evaluate(() => {
|
||||
const s = globalThis.__SP__.store.get();
|
||||
const pub = s.match.public;
|
||||
const f = (pub?.fields || []).find((x) => x.kind === 'normal' && x.live && !x.players.includes(s.me.playerId));
|
||||
if (!f) return null;
|
||||
const rows = (pub.players || []).slice().sort((a, b) => a.seat - b.seat).filter((p) => p.playerId !== s.me.playerId);
|
||||
return { fieldId: f.fieldId, nth: rows.findIndex((p) => p.playerId === f.players[0]) };
|
||||
});
|
||||
if (!other || other.nth < 0) continue;
|
||||
await c.click('.team__row:not(.is-self) .team__btn', null, { nth: other.nth });
|
||||
if (!(await c.click('.team__ob', '前往查看', { optional: true, timeout: 3000 }))) continue;
|
||||
const ok = await c.page.waitForFunction((fid) => { const s = globalThis.__SP_RUNNER__?.state(); return !!s && s.fieldId === fid && s.watch; }, { timeout: 15000 }, other.fieldId).then(() => true, () => false);
|
||||
if (!ok) continue;
|
||||
await sleep(1200);
|
||||
const other2 = await c.viewStats();
|
||||
assert.ok(other2 && other2.units > 0, 'the observed field renders units');
|
||||
await c.shot(`combat-observe-r${hs.round}`);
|
||||
await c.click('.team__back, .chud__back', '返回战场', { optional: true, timeout: 3000 });
|
||||
did.watched = true;
|
||||
c.note(`前往查看 ${other.fieldId} (${other2.units} views) and 返回战场`);
|
||||
}
|
||||
await sleep(400);
|
||||
}
|
||||
if (hs.round >= ROUNDS && hs.phase !== 'UNITE') break;
|
||||
}
|
||||
await sleep(400);
|
||||
}
|
||||
const end = await host.st();
|
||||
assert.ok(end.round >= ROUNDS || end.result, `reached round ${ROUNDS} (at ${end.round} ${end.phase})`);
|
||||
assert.ok(seen.has('PREP') && seen.has('COMBAT'), 'saw prep and combat');
|
||||
host.note(`did: ${JSON.stringify(did)}, 机变 picks: ${spPicked}, phases: ${[...seen].filter((x) => !x.includes('-')).join(',')}`);
|
||||
assert.ok(did.watched, 'observed a teammate through 前往查看');
|
||||
assert.ok(did.sold, 'sold a unit with the underframe 出售');
|
||||
assert.ok(did.refreshed, 'refreshed the shop (R)');
|
||||
assert.ok(did.froze, 'froze + unfroze the shop');
|
||||
assert.ok(did.leveled, 'levelled the dispatch center up');
|
||||
assert.ok(did.inspected && did.bond, 'detail panel + bond popup');
|
||||
assert.ok(did.emote, 'emote bubble reached the teammate');
|
||||
assert.ok(did.enemies >= 0, 'enemy drawer opened');
|
||||
await assertClean(both);
|
||||
} finally {
|
||||
console.log([...host.log, ...guest.log].join('\n'));
|
||||
for (const c of both) if (c.problems.length) console.log(`${c.label} problems:\n ${c.problems.slice(0, 20).join('\n ')}`);
|
||||
for (const c of both) await c.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('solo: 独立模拟 through two rounds, then 放弃模拟 back to the lobby', { timeout: 10 * 60 * 1000 }, async () => {
|
||||
const solo = new Client(puppeteer, base, 'solo');
|
||||
try {
|
||||
await solo.open();
|
||||
await solo.enter('杜宾');
|
||||
await solo.click('.mode-card', '独立模拟');
|
||||
await solo.click('.diff-card', '标准模拟');
|
||||
await solo.click('.create-box button', '开始独立模拟');
|
||||
await solo.waitFor((s) => !!s.room, 'solo room');
|
||||
if (!(await solo.st()).phase) await solo.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
|
||||
await solo.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
|
||||
await solo.click('.brief__foot .btn--primary', '准备就绪');
|
||||
await solo.waitFor((s) => s.phase === 'BAND_DRAFT', 'band draft', 30000);
|
||||
await sleep(600);
|
||||
await solo.shot('solo-draft');
|
||||
await solo.click('.dband', null, { nth: 1 });
|
||||
await solo.click('.draft-detail__btns .btn--primary', '确认选择');
|
||||
for (let round = 1; round <= 2; round++) {
|
||||
await solo.waitFor((s) => s.phase === 'PREP' && s.round === round && !s.ready, `prep ${round}`, 60000);
|
||||
await sleep(700);
|
||||
await solo.takeReward();
|
||||
for (let k = 0; k < 3; k++) if (!(await solo.buyOne())) break;
|
||||
await solo.deployFromHand(4);
|
||||
await solo.shot(`solo-prep-r${round}`);
|
||||
await solo.ready();
|
||||
await solo.waitFor((s) => s.phase === 'COMBAT', `combat ${round}`, 30000);
|
||||
const t1 = Date.now();
|
||||
let st = null;
|
||||
while (Date.now() - t1 < 15000) { st = await solo.viewStats(); if (st?.mode === 'battle' && st.units > 0) break; await sleep(300); }
|
||||
assert.ok(st?.units > 0, 'solo battle renders units');
|
||||
await sleep(1500);
|
||||
await solo.shot(`solo-combat-r${round}`);
|
||||
}
|
||||
// leave for good: exit → 放弃模拟 → lobby
|
||||
await solo.click('.gtop__exit');
|
||||
await solo.click('.modal__actions .btn', '放弃模拟');
|
||||
await solo.waitFor((s) => !s.room && !s.phase, 'back in the lobby', 20000);
|
||||
await solo.page.waitForSelector('.lobby-screen', { timeout: 10000 });
|
||||
await assertClean([solo]);
|
||||
} finally {
|
||||
console.log(solo.log.join('\n'));
|
||||
if (solo.problems.length) console.log(`solo problems:\n ${solo.problems.slice(0, 20).join('\n ')}`);
|
||||
await solo.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
// Rich-text formatter (public/js/ui/richText.js): official markup parsing, placeholders, bond effects —
|
||||
// checked against every description in the generated data so no real text breaks the parser.
|
||||
|
||||
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';
|
||||
import { parseRichText, richTextPlain, rtClassName, formatPlaceholder, fillPlaceholders, formatBondEffect } from '../../public/js/ui/richText.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const load = (f) => JSON.parse(readFileSync(path.join(ROOT, 'data', f), 'utf8'));
|
||||
|
||||
describe('parseRichText', () => {
|
||||
test('styled spans, terms, literal trigger labels, newlines', () => {
|
||||
const segs = parseRichText('所有干员<@ba.vup>防御力</>提升\n<在场<@autochess.dgreen>3</>名>改为<$ba.stun>晕眩</>');
|
||||
assert.deepEqual(segs, [
|
||||
{ text: '所有干员', cls: [], term: false },
|
||||
{ text: '防御力', cls: ['ba.vup'], term: false },
|
||||
{ text: '提升', cls: [], term: false },
|
||||
{ br: true },
|
||||
{ text: '<在场', cls: [], term: false },
|
||||
{ text: '3', cls: ['autochess.dgreen'], term: false },
|
||||
{ text: '名>改为', cls: [], term: false },
|
||||
{ text: '晕眩', cls: ['ba.stun'], term: true },
|
||||
]);
|
||||
});
|
||||
test('nesting, escaped newlines, stray closers, unterminated tags', () => {
|
||||
const segs = parseRichText('<@ba.vup>a<$ba.cold>b</>c</>d\\ne</>f<@ba.kw>g');
|
||||
assert.deepEqual(segs.filter((s) => !s.br).map((s) => [s.text, s.cls.join('+'), s.term]), [
|
||||
['a', 'ba.vup', false], ['b', 'ba.vup+ba.cold', true], ['c', 'ba.vup', false], ['d', '', false], ['e</>f', '', false], ['g', 'ba.kw', false],
|
||||
]);
|
||||
assert.equal(segs.filter((s) => s.br).length, 1);
|
||||
});
|
||||
test('non-strings and empty', () => {
|
||||
assert.deepEqual(parseRichText(null), []);
|
||||
assert.deepEqual(parseRichText(''), []);
|
||||
assert.deepEqual(parseRichText(42), [{ text: '42', cls: [], term: false }]);
|
||||
assert.equal(richTextPlain('<获得时>获得<@ba.vup>1</>资金'), '<获得时>获得1资金');
|
||||
});
|
||||
test('class names', () => {
|
||||
assert.equal(rtClassName('ba.vup'), 'rt-vup');
|
||||
assert.equal(rtClassName('ba.vdown'), 'rt-vdown');
|
||||
assert.equal(rtClassName('whatever'), 'rt-hl');
|
||||
assert.equal(rtClassName('ba.stun', true), 'rt-term');
|
||||
});
|
||||
test('every official description parses to the same plain text as `desc`', () => {
|
||||
let checked = 0;
|
||||
const files = ['bonds.json', 'bands.json', 'items.json', 'garrisons.json', 'effects.json'];
|
||||
for (const f of files) {
|
||||
for (const rec of Object.values(load(f))) {
|
||||
if (typeof rec?.descRaw !== 'string' || typeof rec?.desc !== 'string') continue;
|
||||
const plain = richTextPlain(rec.descRaw).replace(/\s+/g, '');
|
||||
assert.equal(plain, rec.desc.replace(/\s+/g, ''), `${f}: ${rec.bondId || rec.bandId || rec.id || rec.garrisonId || rec.effectId}`);
|
||||
checked++;
|
||||
}
|
||||
}
|
||||
for (const c of Object.values(load('chess.json'))) {
|
||||
if (!c?.skill?.descRaw) continue;
|
||||
assert.equal(richTextPlain(c.skill.descRaw).replace(/\s+/g, ''), c.skill.desc.replace(/\s+/g, ''), c.chessId);
|
||||
checked++;
|
||||
}
|
||||
assert.ok(checked > 500, `checked ${checked}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('placeholders', () => {
|
||||
test('formatPlaceholder', () => {
|
||||
assert.equal(formatPlaceholder(0.15, '0%'), '15%');
|
||||
assert.equal(formatPlaceholder(0.1234, '0.0%'), '12.3%');
|
||||
assert.equal(formatPlaceholder(3.6, '0'), '4');
|
||||
assert.equal(formatPlaceholder(3.64, '0.0'), '3.6');
|
||||
assert.equal(formatPlaceholder(-0.001, '0'), '0');
|
||||
assert.equal(formatPlaceholder(NaN, '0'), '?');
|
||||
assert.equal(formatPlaceholder(2), '2');
|
||||
});
|
||||
test('fillPlaceholders keeps unknown indexes', () => {
|
||||
assert.equal(fillPlaceholders('攻击+{0:0%},持续{1}秒,{2:0}', [0.2, 5]), '攻击+20%,持续5秒,{2:0}');
|
||||
assert.equal(fillPlaceholders('x{0}', ['文本']), 'x文本');
|
||||
assert.equal(fillPlaceholders(null, []), '');
|
||||
});
|
||||
test('formatBondEffect resolves base + perStack × layers for every bond', () => {
|
||||
const bonds = load('bonds.json');
|
||||
const deput = bonds.deputShip;
|
||||
const txt = formatBondEffect(deput, 10);
|
||||
const expected = Math.round((deput.bb.base_def + deput.bb.def_per_stack * 10) * 100);
|
||||
assert.ok(richTextPlain(txt).includes(`+${expected}%`), txt);
|
||||
for (const b of Object.values(bonds)) {
|
||||
const out = richTextPlain(formatBondEffect(b, 25));
|
||||
assert.ok(!/\{\d+(:[^}]*)?\}/.test(out), `${b.bondId} leaves a placeholder: ${out}`);
|
||||
}
|
||||
assert.equal(formatBondEffect(null, 3), '');
|
||||
assert.equal(formatBondEffect({ effectDesc: 'x{0:0}', effectDescParams: [{ index: 0, format: '0', base: 'a', perStack: 'b' }], bb: { a: 1, b: 2 } }, 'bad'), 'x1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
// Real-server browser E2E (DESIGN §16): scouting a teammate's board in prep (前往查看) shows THAT player's operator
|
||||
// loadout in the detail card — not the viewer's, not the defaults. The guest picks S1 骑枪刺击 + 不装备 for 野鬃 in the
|
||||
// room's 干员调配 and places the elite; the host (no loadout: default S2 + the default module) opens the guest's board
|
||||
// and right-clicks the unit: the card must read S1 (已调配) and 未装备模组, with the no-module ATK.
|
||||
// Unit/integration counterpart: test/match/teammate-loadout.test.js.
|
||||
//
|
||||
// SP_E2E=1 node --test test/ui/teammate-loadout.e2e.test.js
|
||||
//
|
||||
// Screenshots: test/e2e/out/teammate-loadout-*.png.
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf } from '../e2e/client.mjs';
|
||||
|
||||
const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets'));
|
||||
const BASE = 'chess_char_1_19_a';
|
||||
const ELITE = 'chess_char_1_19_b'; // elite 野鬃: default S2 + 长枪替补套装
|
||||
|
||||
/** The open detail card (or null within `timeout`). */
|
||||
const readCard = (c, timeout = 2500) => c.page.waitForSelector('.dpanel .dskill__name', { timeout }).then(() => c.page.evaluate(() => ({
|
||||
skill: document.querySelector('.dpanel .dskill__name')?.textContent || '',
|
||||
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])),
|
||||
}))).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)' }, () => {
|
||||
test('前往查看 in prep: the guest\'s 野鬃 (S1 + 不装备) reads S1 已调配 / 未装备模组 on the host\'s card', { timeout: 6 * 60 * 1000 }, async () => {
|
||||
const chess = JSON.parse(readFileSync(path.join(ROOT, 'data/chess.json'), 'utf8'));
|
||||
const rec = (Array.isArray(chess) ? chess : Object.values(chess.chess || chess)).find((x) => x && x.chessId === ELITE);
|
||||
const s1 = rec.skills.find((s) => s.index === 0);
|
||||
const sDef = rec.skills.find((s) => s.isDefault);
|
||||
assert.notEqual(sDef.index, 0, 'S1 is not the default skill');
|
||||
assert.notEqual(rec.stats.atk, rec.statsBase.atk, 'the default module changes ATK');
|
||||
|
||||
const srv = await startRealServer({ fast: { timerScale: 1, combatSpeed: 2, startRound: 1, chess: [ELITE] } });
|
||||
const P = (await import('puppeteer-core')).default;
|
||||
const host = new Client(P, srv.base, 'host', { prefix: 'teammate-loadout' });
|
||||
const guest = new Client(P, srv.base, 'guest', { prefix: 'teammate-loadout', w: 1280, h: 720 });
|
||||
try {
|
||||
await host.open();
|
||||
await host.enter('凯尔希');
|
||||
await host.click('.mode-card', '同盟模拟');
|
||||
await host.click('.diff-card', '标准模拟');
|
||||
await host.click('.create-box button', '创建同盟');
|
||||
const room = (await host.waitFor((s) => !!s.room?.code, 'room created')).room;
|
||||
await guest.open(`?room=${room.code}`);
|
||||
await guest.enter('阿米娅');
|
||||
await guest.waitFor((s) => s.room?.code === room.code, 'guest joined');
|
||||
|
||||
// the guest's 干员调配 (room screen): 野鬃 → S1 + 不装备, synced
|
||||
await guest.click('[data-testid="loadout-open"]');
|
||||
await guest.page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
||||
await guest.click('.lo-search input');
|
||||
await guest.page.keyboard.type('野鬃');
|
||||
await guest.page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
||||
await guest.click('.lo-card');
|
||||
await guest.click('.lo-detail .lo-skill[data-skill="0"]');
|
||||
await guest.click('.lo-detail .lo-mod[data-module="none"]');
|
||||
await guest.page.waitForFunction(() => /已同步/.test(document.querySelector('.lo-sync')?.textContent || ''), { timeout: 8000 });
|
||||
await guest.page.keyboard.press('Escape');
|
||||
await guest.page.waitForFunction(() => !document.querySelector('.lo'), { timeout: 3000 });
|
||||
|
||||
// ready (the first click after the overlay closed can land while it fades out: click until the seat is ready)
|
||||
const guestReady = () => guest.page.evaluate(() => {
|
||||
const s = globalThis.__SP__.store.get();
|
||||
return !!s.room?.seats?.find((x) => x && x.playerId === s.me.playerId)?.ready;
|
||||
});
|
||||
for (let i = 0; i < 5 && !(await guestReady()); i++) {
|
||||
await sleep(500);
|
||||
await guest.click('.room-bar__right button', '准备就绪', { optional: true, timeout: 3000 });
|
||||
await sleep(500);
|
||||
}
|
||||
assert.ok(await guestReady(), 'guest ready');
|
||||
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);
|
||||
const loOf = (c) => c.page.evaluate(() => globalThis.__SP__.store.get().match.private?.loadout ?? null);
|
||||
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', '准备就绪');
|
||||
const picked = new Set();
|
||||
const t0 = Date.now();
|
||||
while (picked.size < 2 && Date.now() - t0 < 120000) {
|
||||
for (const c of [host, guest]) {
|
||||
const s = await c.st();
|
||||
if (s.phase !== 'BAND_DRAFT') { picked.add(c.label); continue; }
|
||||
if (picked.has(c.label) || s.draft?.turn !== s.me) continue;
|
||||
await c.click('.dband:not(.is-taken)', null, { nth: c === host ? 2 : 5 });
|
||||
await sleep(200);
|
||||
await c.click('.draft-detail__btns .btn--primary', '确认选择');
|
||||
picked.add(c.label);
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
for (const c of [host, guest]) await c.waitFor((s) => s.phase === 'PREP' && !s.ready && s.hand > 0, 'prep with the starter kit', 60000);
|
||||
await sleep(1800);
|
||||
|
||||
// the guest places its elite (drag → direction wheel)
|
||||
const [piece] = (await guest.handPieces('chess')).filter((p) => p.id === ELITE);
|
||||
assert.ok(piece, 'the elite 野鬃 is in the guest\'s hand');
|
||||
const tile = await guest.freeTileFor(piece.uid);
|
||||
assert.ok(tile, 'a legal tile');
|
||||
await guest.drag(await guest.piecePoint(piece.uid), await guest.tilePoint(tile.row, tile.col));
|
||||
await guest.page.waitForSelector('.fwheel__dia', { timeout: 4000 });
|
||||
await guest.swipe('RIGHT');
|
||||
await guest.waitFor((s) => s.board > 0, 'placed', 8000);
|
||||
|
||||
// the host scouts the guest (前往查看) — the scouting m.field carries the guest's loadout
|
||||
await host.click('.team__row:not(.is-self) .team__btn', null, { nth: 0 });
|
||||
assert.ok(await host.click('.team__ob', '前往查看', { optional: true, timeout: 3000 }), 'host: 前往查看 in prep');
|
||||
await host.page.waitForSelector('.gm__watching', { timeout: 6000 });
|
||||
const unit = await host.page.waitForFunction((uid) => {
|
||||
const f = globalThis.__SP__.store.get().match.field;
|
||||
const u = f && f.prep ? (f.units || []).find((x) => x.uid === uid) : null;
|
||||
return u ? { skillIndex: u.skillIndex ?? null, moduleId: u.moduleId ?? null, ownerId: u.ownerId } : false;
|
||||
}, { timeout: 8000, polling: 100 }, piece.uid).then((h) => h.jsonValue());
|
||||
assert.deepEqual([unit.skillIndex, unit.moduleId], [0, 'none'], 'm.field (prep scouting) unit loadout');
|
||||
await sleep(1200); // camera flight + the scouted board
|
||||
// the renderer keeps them on its unit info (the Spine actor's skill clip; a tap hands the info to the card)
|
||||
const info = await host.page.evaluate((uid) => {
|
||||
const views = globalThis.__SP_VIEW__?.raw?.debug?.views;
|
||||
const v = views ? [...views.values()].find((x) => x.info?.uid === uid) : null;
|
||||
return v ? { skillIndex: v.info.skillIndex ?? null, moduleId: v.info.moduleId ?? null } : null;
|
||||
}, piece.uid);
|
||||
assert.deepEqual(info, { skillIndex: 0, moduleId: 'none' }, 'render unit info keeps the loadout');
|
||||
|
||||
let card = null;
|
||||
for (const at of [0.72, 0.4, 0.88]) {
|
||||
const p = await host.piecePoint(piece.uid, at);
|
||||
if (!p) continue;
|
||||
await host.page.mouse.click(p.x, p.y, { button: 'right' });
|
||||
card = await readCard(host);
|
||||
if (card) break;
|
||||
}
|
||||
assert.ok(card, 'the detail card opens for the teammate\'s elite');
|
||||
await host.shot('scout-card');
|
||||
assert.ok(card.skill.includes(s1.name) && card.tag, `teammate card: ${s1.name} 已调配 (${JSON.stringify(card)})`);
|
||||
assert.ok(!card.skill.includes(sDef.name), 'not the default skill');
|
||||
assert.ok(card.none && card.module.includes('未装备模组'), `teammate card: 未装备模组 (${JSON.stringify(card)})`);
|
||||
assert.equal(card.stats['攻击'], String(rec.statsBase.atk), `teammate card ATK without the module (${JSON.stringify(card.stats)})`);
|
||||
assert.deepEqual(problemsOf([host, guest]), []);
|
||||
} finally {
|
||||
for (const c of [host, guest]) if (c.problems.length) console.log(c.label, c.problems.slice(0, 20).join('\n'));
|
||||
await host.close();
|
||||
await guest.close();
|
||||
await srv.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
// 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,
|
||||
// full-hand shop cards, the solo exit text and htm's static vnode cache.
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DATA, makeMatch, give, chessOfTier } from '../match/harness.js';
|
||||
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,
|
||||
completesMerge, handFull, tileKey,
|
||||
} from '../../public/js/ui/gameLogic.js';
|
||||
import { PHASE, GEO } from '../../shared/constants.js';
|
||||
|
||||
const getChess = (id) => DATA.chess[id] || null;
|
||||
const getItem = (id) => DATA.items[id] || null;
|
||||
const getToken = (id) => DATA.tokens[id] || null;
|
||||
const getEffect = (id) => DATA.effects[id] || null;
|
||||
const TERRAIN = DATA.choices.cards.tactic.filter((c) => c.kind === 'terrain');
|
||||
|
||||
/** Client deploy class of every own-board tile ('melee' | 'ranged' | null). */
|
||||
function clientClasses(deploy) {
|
||||
const out = new Map();
|
||||
for (let r = GEO.FIELD.r0; r <= GEO.FIELD.r1; r++) {
|
||||
for (let c = GEO.FIELD.c0; c <= GEO.FIELD.c1; c++) {
|
||||
const k = tileKey(r, c);
|
||||
out.set(k, deploy.melee.has(k) ? 'melee' : deploy.ranged.has(k) ? 'ranged' : null);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pickTerrain(card, seed = 3) {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 1, bots: 1, seed, fake: true });
|
||||
h.start();
|
||||
h.setStage(card.stageId);
|
||||
h.toPrep(1);
|
||||
const ps = h.ps('p_0');
|
||||
applyCard(h.m, ps, { kind: 'tactic', id: card.effectId, team: false, tacticKind: 'terrain', family: 'tactic', idx: 0 });
|
||||
return { h, ps, stage: DATA.stages[card.stageId] };
|
||||
}
|
||||
|
||||
describe('terrain 机变 cards (模拟战场演变) in the placement mirror', () => {
|
||||
test('there are terrain cards that change the own board', () => {
|
||||
assert.ok(TERRAIN.length >= 6);
|
||||
});
|
||||
|
||||
for (const card of TERRAIN) {
|
||||
test(`${card.effectId}: client deploy tiles equal the server's per-player deploy map`, () => {
|
||||
const { ps, stage } = pickTerrain(card);
|
||||
const server = buildDeployMap(stage, { deviceOverrides: ps.deviceOverrides, tileOverrides: ps.tileOverrides });
|
||||
const ctx = placementContext({ priv: ps.privateView(), stage, editable: true, getChess, getToken, getItem, getEffect });
|
||||
const client = clientClasses(ctx.deploy);
|
||||
for (const [k, cls] of client) assert.equal(cls, server.get(k) ?? null, `${card.effectId} tile ${k}`);
|
||||
});
|
||||
}
|
||||
|
||||
test('map_m01_1: a melee operator may now stand on a freed crate tile (client agrees with the server)', () => {
|
||||
const card = TERRAIN.find((c) => c.effectId === 'map_m01_1');
|
||||
const { h, ps, stage } = pickTerrain(card);
|
||||
assert.equal(deploySets(stage).melee.has('10,5'), false, 'a crate blocks (10,5) on the plain stage');
|
||||
const melee = chessOfTier(1, (c) => c.position === 'MELEE')[0];
|
||||
const piece = give(h.m, ps, melee, 'hand');
|
||||
const ctx = placementContext({ priv: ps.privateView(), stage, editable: true, getChess, getToken, getItem, getEffect });
|
||||
assert.deepEqual(canPlace(ctx, piece.uid, { area: 'board', row: 10, col: 5 }), { ok: true, action: 'move' });
|
||||
assert.deepEqual(h.m.handle('p_0', { t: 'g.move', uid: piece.uid, to: { area: 'board', row: 10, col: 5 } }), { ok: true });
|
||||
});
|
||||
|
||||
test('map_m03_2: ranged-only platform tiles become ground (melee allowed)', () => {
|
||||
const card = TERRAIN.find((c) => c.effectId === 'map_m03_2');
|
||||
const { ps, stage } = pickTerrain(card);
|
||||
const ctx = placementContext({ priv: ps.privateView(), stage, editable: true, getChess, getToken, getItem, getEffect });
|
||||
assert.ok(ctx.deploy.melee.has('11,7') && ctx.deploy.melee.has('11,8'));
|
||||
});
|
||||
|
||||
test('m.private deviceOverrides / tileOverrides (when the server sends them) win over the derivation', () => {
|
||||
const stage = DATA.stages.act1autochess_m01;
|
||||
const priv = { deviceOverrides: { 'trap_1105_accrate#001': false }, tileOverrides: { '9,3': 'none' }, effects: [] };
|
||||
const ov = stageOverrides(priv, getEffect);
|
||||
assert.deepEqual(ov, { deviceOverrides: { 'trap_1105_accrate#001': false }, tileOverrides: { '9,3': 'none' } });
|
||||
const map = deployMap(stage, ov);
|
||||
const server = buildDeployMap(stage, ov);
|
||||
assert.deepEqual([...map.entries()].sort(), [...server.entries()].sort());
|
||||
});
|
||||
|
||||
test('deployMap mirrors buildDeployMap for every stage without overrides', () => {
|
||||
for (const [id, stage] of Object.entries(DATA.stages)) {
|
||||
assert.deepEqual([...deployMap(stage).entries()].sort(), [...buildDeployMap(stage).entries()].sort(), id);
|
||||
}
|
||||
});
|
||||
|
||||
test('effectiveStage: removed crates are inactive for the renderer, unchanged stage without overrides', () => {
|
||||
const stage = DATA.stages.act1autochess_m01;
|
||||
assert.equal(effectiveStage(stage, { deviceOverrides: {}, tileOverrides: {} }), stage);
|
||||
const eff = effectiveStage(stage, stageOverrides({ effects: [{ id: 'choice:map_m01_1#77' }] }, getEffect));
|
||||
assert.deepEqual(stageOverrides({ effects: [{ id: 'map_m01_1#77' }] }, getEffect), stageOverrides({ effects: [{ id: 'choice:map_m01_1#77' }] }, getEffect));
|
||||
const crate = eff.devices.find((d) => d.alias === 'trap_1105_accrate#001');
|
||||
assert.equal(crate.active, false);
|
||||
assert.equal(stage.devices.find((d) => d.alias === 'trap_1105_accrate#001').active !== false, true, 'data record untouched');
|
||||
// band entries (no '#<uid>') are not 机变 picks
|
||||
assert.deepEqual(stageOverrides({ effects: [{ id: 'aceffect_band_43' }] }, getEffect).deviceOverrides, {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('result screen', () => {
|
||||
const base = { victory: false, roundsPassed: 4, hiddenBossId: 'boss_8', bossId: 'boss_5', players: [] };
|
||||
test('the Hidden Core medal needs the Hidden Core to have been reached', () => {
|
||||
assert.equal(normalizeResult({ ...base, hiddenReached: false }, null).hiddenReached, false);
|
||||
assert.equal(normalizeResult({ ...base }, null).hiddenReached, false);
|
||||
assert.equal(normalizeResult({ ...base, victory: true, hiddenReached: true }, null).hiddenReached, true);
|
||||
assert.equal(normalizeResult({ ...base, victory: true, hiddenCleared: true }, null).hiddenReached, true);
|
||||
});
|
||||
|
||||
test('remaining LP after the Final Assault is the merged team pool, not the stale own LP', () => {
|
||||
const res = {
|
||||
victory: false, roundsPassed: 13, players: [
|
||||
{ playerId: 'a', seat: 0, alive: true, lp: 7 },
|
||||
{ playerId: 'b', seat: 1, alive: false, lp: 0 },
|
||||
],
|
||||
};
|
||||
const pub = { teamLp: 0, players: [{ playerId: 'a', seat: 0 }, { playerId: 'b', seat: 1 }] };
|
||||
const r = normalizeResult(res, pub);
|
||||
assert.equal(r.players[0].lp, 0, 'defeat by the team LP hitting 0 shows 0 left');
|
||||
assert.equal(r.players[0].lpShared, true);
|
||||
assert.equal(r.players[1].lp, 0);
|
||||
assert.equal(normalizeResult(res, { ...pub, teamLp: 16 }).players[0].lp, 16);
|
||||
// no Final Assault: own LP
|
||||
const plain = normalizeResult(res, { players: pub.players });
|
||||
assert.equal(plain.players[0].lp, 7);
|
||||
assert.equal(plain.players[0].lpShared, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('briefing bond labels', () => {
|
||||
test('drawn bonds read "部分盟约所含干员阵容不完整" (still activatable), static ones "本局禁用"', () => {
|
||||
const pub = { disabledBonds: ['a', 'b', 's'], drawnDisabledBonds: ['a', 'b'] };
|
||||
const sets = disabledBondSets(pub, ['s']);
|
||||
assert.deepEqual([...sets.drawn].sort(), ['a', 'b']);
|
||||
assert.deepEqual([...sets.off], ['s']);
|
||||
const tip = briefingBondTip('坚守', 'drawn', 2);
|
||||
assert.match(tip, /部分盟约所含干员阵容不完整/);
|
||||
assert.doesNotMatch(tip, /本局禁用/);
|
||||
assert.match(briefingBondTip('坚守', 'off', 0), /本局禁用/);
|
||||
assert.equal(briefingBondTip('坚守', null, 0), '坚守');
|
||||
// older payloads: disabledBonds minus the static list
|
||||
assert.deepEqual([...disabledBondSets({ disabledBonds: ['a', 's'] }, ['s']).drawn], ['a']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('watch targets and the view switcher', () => {
|
||||
const pub = {
|
||||
phase: PHASE.FINAL_ASSAULT,
|
||||
players: [
|
||||
{ playerId: 'me', seat: 0, alive: true, fieldId: 'b1', name: '我' },
|
||||
{ playerId: 'mate', seat: 1, alive: true, fieldId: 'b1', name: '搭档' },
|
||||
{ playerId: 'x', seat: 2, alive: true, fieldId: 'b2', name: '另一组' },
|
||||
{ playerId: 'dead', seat: 3, alive: false, fieldId: null, name: '倒下' },
|
||||
],
|
||||
fields: [
|
||||
{ fieldId: 'b1', kind: 'boss', players: ['me', 'mate'], live: true },
|
||||
{ fieldId: 'b2', kind: 'boss', players: ['x'], live: true },
|
||||
],
|
||||
};
|
||||
test('the other pair of a Final Assault and eliminated teammates are refused client-side', () => {
|
||||
assert.match(watchTarget(pub.players[2], pub, 'me').reason, /另一组/);
|
||||
assert.match(watchTarget(pub.players[3], pub, 'me').reason, /淘汰/);
|
||||
assert.deepEqual(watchTarget(pub.players[1], pub, 'me'), { fieldId: 'b1' });
|
||||
// an eliminated spectator may watch any boss field
|
||||
const dead = { ...pub, players: pub.players.map((p) => (p.playerId === 'me' ? { ...p, alive: false, fieldId: null } : p)), fields: [pub.fields[1]] };
|
||||
assert.deepEqual(watchTarget(pub.players[2], dead, 'me'), { fieldId: 'b2' });
|
||||
// prep scouting: the teammate's own board
|
||||
assert.deepEqual(watchTarget(pub.players[1], { ...pub, phase: PHASE.PREP }, 'me'), { fieldId: 'n:mate' });
|
||||
});
|
||||
|
||||
test('switcher label names a watched field whose battle already ended', () => {
|
||||
const cpub = {
|
||||
phase: PHASE.COMBAT,
|
||||
players: [{ playerId: 'me', seat: 0, alive: false, name: '我' }, { playerId: 'ai_1', seat: 1, alive: true, name: 'AI·华法琳' }],
|
||||
fields: [{ fieldId: 'n:ai_1', kind: 'normal', players: ['ai_1'], live: false }, { fieldId: 'n:p3', kind: 'normal', players: ['p3'], live: true }],
|
||||
};
|
||||
assert.equal(switcherLabel(cpub, 'n:ai_1', 'me', true), 'AI·华法琳');
|
||||
assert.equal(switcherLabel(cpub, null, 'me', true), '观战');
|
||||
assert.equal(switcherLabel(cpub, null, 'me', false), '自己');
|
||||
});
|
||||
});
|
||||
|
||||
describe('equipment dropped on an operator', () => {
|
||||
const ITEM = 'chess_item_1_01_e_a';
|
||||
const RANGED = 'chess_char_1_01_a';
|
||||
const priv = {
|
||||
alive: true, ready: false, funds: 10, deployCap: 8, deployCount: 2,
|
||||
board: [
|
||||
{ uid: 1, kind: 'chess', id: RANGED, row: 9, col: 3, items: [] },
|
||||
{ uid: 2, kind: 'chess', id: RANGED, row: 10, col: 3, items: [] },
|
||||
],
|
||||
hand: [{ uid: 3, kind: 'item', id: ITEM }, ...new Array(GEO.HAND_SIZE - 1).fill(null)],
|
||||
temp: new Array(GEO.TEMP_SIZE).fill(null),
|
||||
shop: { slots: [] },
|
||||
};
|
||||
const ctx = placementContext({ priv, stage: DATA.stages.act2autochess_m01, editable: true, getChess, getToken, getItem });
|
||||
|
||||
test('pickPieceAt picks the front-most sprite under the pointer', () => {
|
||||
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 };
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('illegal drops say why', () => {
|
||||
const MELEE = 'chess_char_1_02_a';
|
||||
const priv = {
|
||||
alive: true, ready: false, funds: 10, deployCap: 1, deployCount: 1,
|
||||
board: [{ uid: 1, kind: 'chess', id: 'chess_char_1_01_a', row: 9, col: 3, items: [] }],
|
||||
hand: [{ uid: 2, kind: 'chess', id: MELEE, items: [] }, { uid: 3, kind: 'item', id: 'chess_item_1_01_e_a' }, ...new Array(GEO.HAND_SIZE - 2).fill(null)],
|
||||
temp: new Array(GEO.TEMP_SIZE).fill(null),
|
||||
shop: { slots: [] },
|
||||
};
|
||||
const ctx = placementContext({ priv, stage: DATA.stages.act2autochess_m01, editable: true, getChess, getToken, getItem });
|
||||
test('reasons for board tiles, lanes and the temporary bench; none for the own slot', () => {
|
||||
const melee = [...ctx.deploy.melee].map((k) => k.split(',').map(Number)).find(([r, c]) => !(r === 9 && c === 3));
|
||||
assert.equal(dropFailureReason(ctx, 2, { row: melee[0], col: melee[1], area: 'board' }), '已达到部署上限');
|
||||
assert.equal(dropFailureReason(ctx, 3, { row: melee[0], col: melee[1], area: 'board' }), '请将装备拖拽至干员身上');
|
||||
assert.equal(dropFailureReason(ctx, 2, { row: GEO.TEMP_ROW, col: GEO.TEMP_C0, area: 'temp', idx: 0 }), '临时整备区无法放入单位');
|
||||
assert.equal(dropFailureReason(ctx, 2, { row: GEO.HAND_ROW, col: 0, area: 'hand', idx: 0 }), null, 'own slot');
|
||||
assert.equal(dropFailureReason(ctx, 2, { row: 3, col: 5, area: null }), null, 'far from the board');
|
||||
const lane = [9, 10, 11, 12].flatMap((r) => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((c) => [r, c])).find(([r, c]) => c < GEO.FIELD.c0);
|
||||
assert.equal(dropFailureReason(ctx, 2, { row: lane[0], col: lane[1], area: null }), '无法部署在该位置');
|
||||
});
|
||||
test('a melee operator on a never-deployable tile is not told to use the ground', () => {
|
||||
const blocked = [];
|
||||
for (let r = GEO.FIELD.r0; r <= GEO.FIELD.r1; r++) for (let c = GEO.FIELD.c0; c <= GEO.FIELD.c1; c++) if (!ctx.deploy.ranged.has(tileKey(r, c))) blocked.push([r, c]);
|
||||
assert.ok(blocked.length > 0);
|
||||
const free = { ...ctx, count: 0 };
|
||||
assert.equal(canPlace(free, 2, { area: 'board', row: blocked[0][0], col: blocked[0][1] }).reason, '无法部署在该位置');
|
||||
});
|
||||
});
|
||||
|
||||
describe('full hand: shop and promotion cards', () => {
|
||||
const MELEE = 'chess_char_1_02_a';
|
||||
const other = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier === 1 && c.chessId !== MELEE).chessId;
|
||||
const fullHand = (pieces) => { const h = new Array(GEO.HAND_SIZE).fill(null).map((_, i) => pieces[i] || { uid: 100 + i, kind: 'item', id: 'chess_item_1_01_e_a' }); return h; };
|
||||
const privFull = { alive: true, ready: false, funds: 20, board: [], temp: new Array(GEO.TEMP_SIZE).fill(null), shop: {},
|
||||
hand: fullHand([{ uid: 1, kind: 'chess', id: MELEE, items: [] }, { uid: 2, kind: 'chess', id: MELEE, items: [] }]) };
|
||||
const o = { priv: privFull, editable: true, getChess, getItem };
|
||||
test('a card that needs a slot is blocked with 整备区已满; one completing a merge is not', () => {
|
||||
assert.equal(handFull(privFull), true);
|
||||
assert.equal(shopBlockReason('buy', { ...o, slot: { kind: 'chess', id: other, price: 1 } }), '整备区已满');
|
||||
assert.equal(completesMerge(privFull, { kind: 'chess', id: MELEE }, o), true);
|
||||
assert.equal(shopBlockReason('buy', { ...o, slot: { kind: 'chess', id: MELEE, price: 1 } }), null);
|
||||
assert.equal(shopBlockReason('reward', { ...o, slot: { kind: 'chess', id: other, price: 0 } }), '整备区已满');
|
||||
// the hand is filled with a mergeable equipment: buying another copy merges instead of needing a slot
|
||||
assert.equal(shopBlockReason('buy', { ...o, slot: { kind: 'item', id: 'chess_item_1_01_e_a', price: 1 } }), null);
|
||||
const notFull = { ...privFull, hand: privFull.hand.map((p, i) => (i === 5 ? null : p)) };
|
||||
assert.equal(shopBlockReason('buy', { ...o, priv: notFull, slot: { kind: 'chess', id: other, price: 1 } }), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('solo exit text and htm vnodes', () => {
|
||||
test('the solo quit text does not promise a settlement', async () => {
|
||||
const { EXIT_TEXT } = await import('../../public/js/ui/matchChrome.js');
|
||||
assert.doesNotMatch(EXIT_TEXT.soloQuit, /直接结算/);
|
||||
assert.match(EXIT_TEXT.soloQuit, /返回大厅/);
|
||||
});
|
||||
|
||||
test('static templates give fresh vnodes (no shared, DOM-holding cached vnode)', async () => {
|
||||
const { html } = await import('../../public/js/ui/components.js');
|
||||
const f = () => html`<div class="a"><span>static</span></div>`;
|
||||
const g = (x) => html`<div class="b"><span>static</span><i>${x}</i></div>`;
|
||||
assert.notEqual(f(), f());
|
||||
assert.notEqual(g(1).props.children[0], g(2).props.children[0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
// Regression tests for in-match UI defects (round-3 hunt): the 暂离 (AI 托管) flag leaking into the next match, game
|
||||
// shortcuts acting behind the 本局信息 / 敌方情报 drawer, two elimination banners during SETTLE, a right-click /
|
||||
// long-press detail card that a press on the field did not close, and the touch hit areas of the 交流 pager (static
|
||||
// check here; the in-browser check is in devices.e2e.test.js).
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { shortcutBlocked, showDeadPill, closesOnFieldPress } from '../../public/js/ui/gameLogic.js';
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
|
||||
describe('暂离 (AI 托管) is scoped to one match', () => {
|
||||
test('the flag survives in-match updates and is dropped when the match ends or its state is cleared', async () => {
|
||||
const { awayStore, awayEnds } = await import('../../public/js/ui/matchChrome.js');
|
||||
const { store, emptyMatch } = await import('../../public/js/store.js');
|
||||
const inMatch = (phase) => ({ ...emptyMatch(), public: { phase, players: [] } });
|
||||
store.set({ match: inMatch(PHASE.PREP) });
|
||||
awayStore.set({ away: true });
|
||||
store.patch('match', { public: { phase: PHASE.COMBAT, players: [] } });
|
||||
assert.equal(awayStore.get().away, true, 'still away while the match runs');
|
||||
// result → 返回同盟 (result.js back): the match slice is cleared
|
||||
store.patch('match', { result: { victory: false } });
|
||||
assert.equal(awayStore.get().away, false, 'the settlement ends 暂离');
|
||||
awayStore.set({ away: true });
|
||||
store.set({ match: emptyMatch() });
|
||||
assert.equal(awayStore.get().away, false, 'clearing the match (返回同盟 / a new match / room closed) ends 暂离');
|
||||
// the next match opens without the overlay flag
|
||||
store.set({ match: inMatch(PHASE.INFO_CHECK) });
|
||||
assert.equal(awayStore.get().away, false);
|
||||
assert.equal(awayEnds({ match: inMatch(PHASE.PREP) }, { match: inMatch(PHASE.PREP) }), false);
|
||||
assert.equal(awayEnds({ match: emptyMatch() }, { match: emptyMatch() }), false, 'nothing to end outside a match');
|
||||
store.set({ match: emptyMatch() });
|
||||
});
|
||||
});
|
||||
|
||||
describe('game shortcuts behind overlays', () => {
|
||||
test('the 本局信息 / 敌方情报 drawer swallows R / F / D / Space, Esc still closes it', () => {
|
||||
for (const act of ['refresh', 'freeze', 'levelUp', 'ready']) {
|
||||
assert.equal(shortcutBlocked(act, { drawer: true }), true, act);
|
||||
assert.equal(shortcutBlocked(act, {}), false, act);
|
||||
assert.equal(shortcutBlocked(act, { modal: true }), true, act);
|
||||
}
|
||||
assert.equal(shortcutBlocked('escape', { drawer: true }), false);
|
||||
assert.equal(shortcutBlocked('escape', { modal: true }), true, 'a modal handles its own Esc');
|
||||
assert.equal(shortcutBlocked(null, {}), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('elimination banners', () => {
|
||||
test('the HUD pill shows outside combat only — never during SETTLE, where the combat HUD already says it', () => {
|
||||
assert.equal(showDeadPill(false, PHASE.PREP), true);
|
||||
assert.equal(showDeadPill(false, PHASE.SP_DRAFT), true);
|
||||
assert.equal(showDeadPill(false, PHASE.SETTLE), false);
|
||||
for (const p of [PHASE.COMBAT, PHASE.UNITE, PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE]) assert.equal(showDeadPill(false, p), false, p);
|
||||
assert.equal(showDeadPill(true, PHASE.PREP), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detail card vs a press on the field', () => {
|
||||
test('a card opened from the field (tap, right-click, long press, battle unit) closes; others stay', () => {
|
||||
assert.equal(closesOnFieldPress({ kind: 'piece', uid: 3 }), true);
|
||||
assert.equal(closesOnFieldPress({ kind: 'unit', unitId: 7 }), true);
|
||||
for (const kind of ['chess', 'item', 'enemy']) assert.equal(closesOnFieldPress({ kind, id: 'x' }), false, kind);
|
||||
assert.equal(closesOnFieldPress(null), false);
|
||||
});
|
||||
|
||||
test('the field press handler closes the card whether or not a piece is selected', () => {
|
||||
const src = readFileSync(new URL('../../public/js/screens/game.js', import.meta.url), 'utf8');
|
||||
const h = src.slice(src.indexOf('const onDown = () => {'), src.indexOf("host.addEventListener('pointerdown', onDown, true)"));
|
||||
assert.ok(h.length > 0);
|
||||
assert.match(h, /setDetail\(\(d\) => \(closesOnFieldPress\(d\) \? null : d\)\)/);
|
||||
assert.doesNotMatch(h, /if \(L\.sel\) \{[^}]*setDetail/, 'not only while a piece is selected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('交流 pager touch hit areas (css/devices.css)', () => {
|
||||
const css = readFileSync(new URL('../../public/css/devices.css', import.meta.url), 'utf8');
|
||||
test('‹ / › hit areas start under the emote cells and grow outwards; dots only up to half the gap', () => {
|
||||
assert.match(css, /\.sp-coarse \.ewheel__panel \.ewheel__nav::before \{ top: \.03rem; bottom: -\.15rem; height: auto; transform: none; \}/);
|
||||
assert.match(css, /\.sp-coarse \.ewheel__panel \.ewheel__nav\.is-prev::before \{ left: auto; right: 0; \}/);
|
||||
assert.match(css, /\.sp-coarse \.ewheel__panel \.ewheel__nav\.is-next::before \{ left: 0; right: auto; \}/);
|
||||
assert.match(css, /\.sp-coarse \.ewheel__dot::before \{ inset: -\.02rem -\.05rem -\.2rem;/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dev hook', () => {
|
||||
test('useFieldView clears globalThis.__SP_VIEW__ when it destroys the view', () => {
|
||||
const src = readFileSync(new URL('../../public/js/ui/fieldHost.js', import.meta.url), 'utf8');
|
||||
const cleanup = src.slice(src.indexOf('return () => {', src.indexOf('export function useFieldView')));
|
||||
assert.match(cleanup.slice(0, 600), /globalThis\.__SP_VIEW__ === viewRef\.current\) globalThis\.__SP_VIEW__ = null/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
// render/units.js facing display (research 09 §1.2, DESIGN §3 "Facing (corrected)"): a unit's deploy direction picks
|
||||
// its model (Back for UP when one exists, Front for RIGHT / DOWN, Front mirrored for LEFT), `setDir` re-orients a live
|
||||
// view (swapping Front ⇄ Back without a fallback flash and keeping every Spine acquire paired with one release), and
|
||||
// the orange ground wedge of prep board pieces points along the direction on the ground plane.
|
||||
// Headless: test/render/fakepixi.js stands in for PIXI.
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installFakePixi, fakeViewCtx } from '../render/fakepixi.js';
|
||||
import { presetCamera } from '../../public/js/render/projection.js';
|
||||
|
||||
let fake, UnitView, unitDir;
|
||||
before(async () => {
|
||||
fake = installFakePixi();
|
||||
({ UnitView, unitDir } = await import('../../public/js/render/units.js'));
|
||||
});
|
||||
after(() => fake.restore());
|
||||
|
||||
const tick = () => new Promise((r) => setImmediate(r));
|
||||
const cam = () => presetCamera('prep', { width: 1280, height: 720 });
|
||||
|
||||
/** Asset store stub with Front / Back Spine entries and acquire / release bookkeeping. */
|
||||
function store({ back = true } = {}) {
|
||||
const front = { skel: '/s/front.skel', atlas: '/s/front.atlas', anims: { idle: 'Idle' }, animations: { Idle: 1 } };
|
||||
const backE = { skel: '/s/back.skel', atlas: '/s/back.atlas', anims: { idle: 'Idle' }, animations: { Idle: 1 } };
|
||||
const refs = new Map();
|
||||
const log = [];
|
||||
return {
|
||||
refs, log, front, backE,
|
||||
picture: () => null,
|
||||
image: async () => null,
|
||||
hasBack: () => back,
|
||||
spineEntry: (id, o) => { log.push(!!(o && o.back)); return o && o.back && back ? backE : front; },
|
||||
spine: {
|
||||
acquire: async (e) => { refs.set(e.skel, (refs.get(e.skel) || 0) + 1); return { animations: [{ name: 'Idle' }] }; },
|
||||
release: (e) => { refs.set(e.skel, (refs.get(e.skel) || 0) - 1); },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function view(info, opts = {}, assets = store()) {
|
||||
const ctx = fakeViewCtx(fake.P, { assets, cam });
|
||||
return new UnitView(ctx, { id: 1, side: 'ally', kind: 'chess', defId: 'char_x', spine: 'char_x', tier: 3, x: 5, y: 10, maxHp: 1000, ...info }, opts);
|
||||
}
|
||||
|
||||
describe('deploy direction → model', () => {
|
||||
test('unitDir: `dir` (any case) wins, else the legacy facing sign', () => {
|
||||
assert.equal(unitDir({ dir: 'up' }), 'UP');
|
||||
assert.equal(unitDir({ facing: -1 }), 'LEFT');
|
||||
assert.equal(unitDir({}), 'RIGHT');
|
||||
assert.equal(unitDir({ dir: 'north', facing: -1 }), 'LEFT');
|
||||
});
|
||||
|
||||
test('UP loads the Back model; LEFT mirrors the Front one; enemies ignore dir', async () => {
|
||||
const s = store();
|
||||
const up = view({ dir: 'UP' }, { prep: true }, s);
|
||||
await tick(); await tick();
|
||||
assert.equal(s.log[0], true, 'Back model requested');
|
||||
assert.equal(up.visFacing, 1);
|
||||
const left = view({ dir: 'LEFT' }, { prep: true }, s);
|
||||
await tick();
|
||||
assert.equal(s.log[1], false, 'Front model');
|
||||
assert.equal(left.visFacing, -1, 'mirrored');
|
||||
const noBack = store({ back: false });
|
||||
view({ dir: 'UP' }, {}, noBack);
|
||||
assert.equal(noBack.log[0], false, 'no Back model → Front');
|
||||
const en = view({ side: 'enemy', kind: 'enemy', dir: 'UP' }, {}, s);
|
||||
assert.equal(en.dir, null);
|
||||
en.setDir('RIGHT');
|
||||
assert.equal(en.visFacing, -1, 'enemies keep their own facing rule');
|
||||
});
|
||||
|
||||
test('setDir swaps Front ⇄ Back in place (no fallback flash) and pairs every acquire with a release', async () => {
|
||||
const s = store();
|
||||
const v = view({ dir: 'RIGHT' }, { prep: true }, s);
|
||||
await tick(); await tick();
|
||||
assert.ok(v.spineReady);
|
||||
const first = v.actor;
|
||||
v.setDir('DOWN');
|
||||
await tick();
|
||||
assert.equal(v.actor, first, 'DOWN keeps the Front model');
|
||||
v.setDir('UP');
|
||||
assert.equal(v.actor, first, 'the old model stays up while the Back one loads');
|
||||
await tick(); await tick();
|
||||
assert.notEqual(v.actor, first, 'Back model in place');
|
||||
assert.ok(first.spine === null || first.spine?.destroyed !== false, 'previous actor destroyed');
|
||||
assert.equal(v.swapT, 1, 'no cross-fade from the fallback diamond');
|
||||
assert.equal(s.refs.get('/s/front.skel'), 0, 'Front released');
|
||||
assert.equal(s.refs.get('/s/back.skel'), 1);
|
||||
// a quick UP → LEFT → UP: superseded loads release themselves
|
||||
v.setDir('LEFT');
|
||||
v.setDir('UP');
|
||||
await tick(); await tick();
|
||||
v.destroy();
|
||||
await tick(); await tick();
|
||||
for (const [k, n] of s.refs) assert.equal(n, 0, `${k} balanced after destroy`);
|
||||
});
|
||||
|
||||
test('setFacing(±1) keeps an UP / DOWN operator vertical', () => {
|
||||
const v = view({ dir: 'UP' }, {}, store({ back: false }));
|
||||
v.setFacing(1);
|
||||
assert.equal(v.dir, 'UP');
|
||||
v.setFacing(-1);
|
||||
assert.equal(v.dir, 'LEFT');
|
||||
v.setFacing(1);
|
||||
assert.equal(v.dir, 'RIGHT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ground wedge "›" of prep board pieces', () => {
|
||||
const angle = (dir) => {
|
||||
const v = view({ dir }, { prep: true }, store({ back: false }));
|
||||
v._showFacing = true;
|
||||
v.update(1 / 60, cam(), 0);
|
||||
assert.ok(v.facingArrow && v.facingArrow.visible, 'wedge shown');
|
||||
const a = v.facingArrow.rotation;
|
||||
const pos = { x: v.facingArrow.position.x, y: v.facingArrow.position.y };
|
||||
const c = cam().project(v.x, v.y, 0.01);
|
||||
return { a, dx: pos.x - c.x, dy: pos.y - c.y, sx: v.facingArrow.scale.x, sy: v.facingArrow.scale.y };
|
||||
};
|
||||
test('points along the direction on screen (RIGHT → right, UP → up the board, …) and is foreshortened along it', () => {
|
||||
const r = angle('RIGHT'); const l = angle('LEFT'); const u = angle('UP'); const d = angle('DOWN');
|
||||
assert.ok(Math.abs(r.a) < 0.05 && r.dx > 0, `RIGHT (${r.a})`);
|
||||
assert.ok(Math.abs(Math.abs(l.a) - Math.PI) < 0.05 && l.dx < 0, `LEFT (${l.a})`);
|
||||
assert.ok(Math.abs(u.a + Math.PI / 2) < 0.05 && u.dy < 0, `UP points up the screen (${u.a})`);
|
||||
assert.ok(Math.abs(d.a - Math.PI / 2) < 0.05 && d.dy > 0, `DOWN (${d.a})`);
|
||||
assert.ok(u.sx < r.sx, 'a vertical wedge is shorter along its axis (ground tilt)');
|
||||
assert.ok(r.sx > 0 && l.sx > 0, 'no negative scale (rotation does the mirroring)');
|
||||
});
|
||||
test('setDir re-points the wedge of a live piece', () => {
|
||||
const v = view({ dir: 'RIGHT' }, { prep: true }, store({ back: false }));
|
||||
v._showFacing = true;
|
||||
v.update(1 / 60, cam(), 0);
|
||||
v.setDir('LEFT');
|
||||
v.update(1 / 60, cam(), 1 / 60);
|
||||
assert.ok(Math.abs(Math.abs(v.facingArrow.rotation) - Math.PI) < 0.05);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ground wedge in combat (research 09 §1.2: "prep and combat") — review regression', () => {
|
||||
test('battle allies show it only when the UnitInfo carries dir; enemies and bench pieces never', () => {
|
||||
const withDir = view({ dir: 'UP' }, {}, store({ back: false }));
|
||||
withDir.update(1 / 60, cam(), 0);
|
||||
assert.ok(withDir.facingArrow && withDir.facingArrow.visible, 'battle ally with dir: wedge');
|
||||
assert.ok(Math.abs(withDir.facingArrow.rotation + Math.PI / 2) < 0.05, 'pointing UP');
|
||||
const legacy = view({ facing: 1 }, {}, store({ back: false }));
|
||||
legacy.update(1 / 60, cam(), 0);
|
||||
assert.ok(!legacy.facingArrow || !legacy.facingArrow.visible, 'no dir (legacy ±1 only): no wedge rather than a guessed RIGHT');
|
||||
const enemy = view({ side: 'enemy', kind: 'enemy', dir: 'LEFT' }, {}, store({ back: false }));
|
||||
enemy.update(1 / 60, cam(), 0);
|
||||
assert.ok(!enemy.facingArrow || !enemy.facingArrow.visible, 'enemies: none');
|
||||
const bench = view({ dir: 'RIGHT' }, { prep: true }, store({ back: false }));
|
||||
bench._showFacing = false;
|
||||
bench.update(1 / 60, cam(), 0);
|
||||
assert.ok(!bench.facingArrow || !bench.facingArrow.visible, 'own bench piece: none');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user