Files
ark-stronghold-protocol/test/ui/leftovers.test.js
T
sganggs 8cef750e14 feedback6 R21D: a consume-on-equip item on a full carrier opens the replace dialog and replaces the picked item first (GitHub #263, PR #264); golden: matches 10 of 18 moved (a targeted consumable on a full carrier now destroys the oldest item), the rest unchanged
PRTS 卫戍协议/帮助 §装备: 「达到上限强行佩戴会改为替换装备:指定一件已佩戴装备替换为将要佩戴的装备,并销毁被指定的装备」,
no exception for the 装备时销毁 items. Before: a normal 博士投影 opened no dialog and its kept item pushed the OLDEST
equipped item out (replaceUid ignored); 盟约之币 and the other consumables kept both items.

- server PlayerState.equip (player/items.js): on a full carrier the consume path takes the pick (replaceUid, else the
  oldest) off before onEquip (_takeReplaced), destroys it (onDestroy reason replace) once the effect went through —
  the normal 博士投影 takes the freed slot —, and puts it back at its index when the effect refuses (博士投影 on an elite:
  nothing destroyed)
- client gameLogic equipReplaces: the replace dialog opens for every item on a full carrier, consumables included,
  except an attaching item that completes an item merge and a 博士投影 on an elite; itemAttaches removed
- bots (itemTarget): a consumable whose effect is the same on anyone goes to a carrier with a free slot first (PR #264)
- docs: design/match.md equipment rules, META §2.5, PLAYING 装备 row, research 04

golden (matches only; each traced equip by equip against the parent — the first change of state is a bot's, or the
AI 托管 human seat's, targeted consumable on a full carrier destroying its oldest item): solo-FUNNY-1 (r9 ai_0 随身身份牌),
solo-FUNNY-2 (r9 ai_0 寻呼模块), solo-HARD-2 (r9 ai_0 随身身份牌, r12 简易通讯机), coop2-NORMAL-3 (r9 ai_1 随身身份牌),
coop3-ABYSS-5 (r11 ai_1 随身身份牌), coop4-FUNNY-6 (r4 ai_1 随身身份牌), solo-HARD-9-boosted (r14 ai_0 随身身份牌),
coop2-ABYSS-10-boosted (r13 ai_0 随身身份牌, r14 ai_1 简易通讯机), coop2-NORMAL-14-standins (r9 ai_0 随身身份牌; now won,
14 rounds), coop2-NORMAL-70-diy (r14 h_0 随身身份牌); the bots' free-slot carrier for target-agnostic consumables moves
no scenario by itself; roster / bonds / fields / standins / diy unchanged
2026-10-07 18:03:53 +08:00

278 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 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);
});
test('a consume-on-equip item on a full operator opens the dialog too — all 24, 博士投影 included — except a 博士投影 on an elite (GitHub #263)', () => {
// PRTS 卫戍协议/帮助 "达到上限强行佩戴会改为替换装备": the pick is destroyed, then the item resolves (a free slot is left)
const consumables = Object.values(DATA.items).filter((x) => x.itemType === 'EQUIP' && String(x.kind).startsWith('consume_on_equip'));
assert.equal(consumables.length, 24, '12 consume-on-equip items × normal / golden');
const holo = ['chess_item_5_06_e_a', 'chess_item_5_06_e_b'];
for (const it of consumables) {
const hand = [{ uid: 3, kind: 'item', id: it.id }, ...priv.hand.slice(1)];
const cx = ctxOf({ ...priv, hand });
const intent = dropIntent(cx, 3, { area: 'board', row: 9, col: 3 });
assert.equal(intent.confirmReplace, true, it.id);
assert.deepEqual(replaceRequest(cx, intent, getChess, getItem).options.map((o) => o.uid), [11, 12], it.id);
const one = ctxOf({ ...priv, board: [{ ...priv.board[0], items: [{ uid: 11, id: A }] }], hand });
assert.equal(dropIntent(one, 3, { area: 'board', row: 9, col: 3 }).confirmReplace, false, `${it.id}: a free slot`);
// an elite: the server refuses a 博士投影 (nothing would be replaced), every other consumable still replaces
const elite = ctxOf({ ...priv, board: [{ ...priv.board[0], id: getChess(RANGED).goldenId, golden: true }], hand });
assert.equal(dropIntent(elite, 3, { area: 'board', row: 9, col: 3 }).confirmReplace, !holo.includes(it.id), `${it.id} on an elite`);
}
// an operator in the hand (equippable since 下半) with two items: the same dialog
const benchFull = ctxOf({ ...priv, board: [], hand: [{ uid: 3, kind: 'item', id: holo[0] }, { uid: 1, kind: 'chess', id: RANGED, items: [{ uid: 11, id: A }, { uid: 12, id: B }] }, ...priv.hand.slice(2)] });
assert.equal(dropIntent(benchFull, 3, { area: 'hand', idx: 1 }).confirmReplace, true);
});
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();
});
test('real match engine: 博士投影 and 盟约之币 on a full operator — the dialog asks and the picked item is the one destroyed (GitHub #263)', () => {
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 tile = { area: 'board', row: at[0], col: at[1] };
const [a, b, c] = [giveItem(m, ps, A), giveItem(m, ps, B), giveItem(m, ps, C)];
for (const it of [a, b]) assert.equal(m.handle('p_0', { t: 'g.equip', itemUid: it.uid, targetUid: unit.uid }).error, undefined);
/** Drop `it` on the operator, pick `pick` in the dialog and send the intent; the operator's items after. */
const dropAndPick = (it, pick) => {
h.flushAll();
const cx = ctxOf(h.lastTo('p_0', 'm.private'));
const intent = dropIntent(cx, it.uid, tile);
assert.equal(intent.confirmReplace, true, `${it.id}: the client asks`);
const r = replaceIntent(intent, pick.uid);
assert.equal(m.handle('p_0', { t: r.t, ...r.fields }).error, undefined);
h.flushAll();
return h.lastTo('p_0', 'm.private').board.find((p) => p.uid === unit.uid).items.map((x) => x.uid);
};
// 博士投影 (normal) takes the slot of the NEWER item the player picked — it used to push the oldest out unasked
const holo = giveItem(m, ps, 'chess_item_5_06_e_a');
assert.deepEqual(dropAndPick(holo, b), [a.uid, holo.uid], 'b destroyed, a kept, 博士投影 equipped');
// 盟约之币 on [a, 博士投影]: the pick (a) is destroyed, the coin pays — 博士投影 and a free slot are left
const coin = giveItem(m, ps, 'chess_item_1_03_e_a');
const funds = ps.funds;
assert.deepEqual(dropAndPick(coin, a), [holo.uid]);
assert.equal(ps.funds, funds + 1);
// the free slot takes the next item without a dialog
h.flushAll();
assert.equal(dropIntent(ctxOf(h.lastTo('p_0', 'm.private')), c.uid, tile).confirmReplace, false);
for (const gone of [a, b, coin]) assert.equal(ps.find(gone.uid), null);
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);
});
});