Files
ark-stronghold-protocol/test/ui/leftovers.test.js
T
sganggs 01a44cd288 卫戍协议:盟约 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).
2026-09-29 16:21:31 +08:00

221 lines
14 KiB
JavaScript

// 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);
});
});