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

517 lines
34 KiB
JavaScript

// 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, dropFailureReason,
} 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);
// the strategy draft: one countdown, the turn's (m.public.draft.turnSeconds; user playtest #4 item 4)
assert.equal(phaseTotalSeconds({ phase: PHASE.BAND_DRAFT, draft: { turnSeconds: 30 } }, config), 30);
assert.equal(phaseTotalSeconds({ phase: PHASE.BAND_DRAFT }, config), config.timers.bandTurn);
});
});
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' } });
});
});
// user playtest #4 item 1: a dragged item drops on the tile under the pointer (render/drag.js) and goes to the unit on
// that tile — the highlighted tile; nothing is retargeted to a sprite drawn over another tile any more
describe('equipment dropped on a tile goes to the unit on it', () => {
test('the operator on the drop tile (board or bench), not the one whose head is drawn there; an empty tile says why', () => {
const front = { ...piece(RANGED), row: 9, col: 3 }, behind = { ...piece(RANGED), row: 10, col: 3 };
const onBench = piece(RANGED);
const eq = item(EQUIP), art = item(MAGIC);
const ctx = ctxFor(privWith({ board: [front, behind], hand: [{ idx: 0, piece: eq }, { idx: 1, piece: art }, { idx: 4, piece: onBench }] }));
assert.deepEqual(dropIntent(ctx, eq.uid, { area: 'board', row: 9, col: 3 }).fields, { itemUid: eq.uid, targetUid: front.uid });
assert.deepEqual(dropIntent(ctx, eq.uid, { area: 'board', row: 10, col: 3 }).fields, { itemUid: eq.uid, targetUid: behind.uid });
assert.deepEqual(dropIntent(ctx, eq.uid, { area: 'hand', idx: 4 }).fields, { itemUid: eq.uid, targetUid: onBench.uid }, 'a bench operator');
// the empty tile behind `behind` (where its head is drawn) takes nothing — and the release says why
assert.equal(dropIntent(ctx, eq.uid, { area: 'board', row: 11, col: 3 }), null);
assert.equal(dropFailureReason(ctx, eq.uid, { row: 11, col: 3, area: 'board' }), '请将装备拖拽至干员身上');
// an Art is used on the tile under the pointer itself
assert.deepEqual(dropIntent(ctx, art.uid, { area: 'board', row: 11, col: 3 }), { t: 'g.art', fields: { itemUid: art.uid, row: 11, col: 3 } });
});
});