The match-ended plate, the solo pause plate, the highlight styles and keepEarly now live in public/js/screens/game/*.js. game.js stays the screen entry. MatchScreen stays one component. Imports only those pieces used moved with them. Two source pins follow the lines that moved.
177 lines
12 KiB
JavaScript
177 lines
12 KiB
JavaScript
// User playtest #6 follow-up, client side of "合成精锐时,如果消耗了场上的干员,精锐会出现在场上那个位置" (PRTS 卫戍协议/帮助
|
|
// "若消耗已部署至作战区的干员,则发送至作战区对应位置"; server PlayerState._mergeChess / board.js mergeTile):
|
|
// * gameLogic.mergeTarget mirrors the server: the tile of the deployed copy that deploys first (row desc, col asc)
|
|
// when gaining one more copy completes a merge, null when it completes none or no copy is deployed (the hand)
|
|
// * the shop card's 可晋升 tag says where the elite goes; ShopBar reports the armed card (armedSlotOf → onArm) so the
|
|
// game screen lights that tile (screens/game.js MERGE_HL)
|
|
// * the server and client agree on the target for real merges (the engine's elite lands on mergeTarget's tile)
|
|
// * an elite card never merges (server completesChessMerge): mergeProgress reports 0 copies, so no pips / tag / tile
|
|
|
|
import { test, describe } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
|
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
|
|
|
|
globalThis.fetch = async (url) => {
|
|
const name = String(url).split('/').pop();
|
|
try {
|
|
const body = readFileSync(path.join(ROOT, 'data', name), 'utf8');
|
|
return { ok: true, status: 200, json: async () => JSON.parse(body) };
|
|
} catch {
|
|
return { ok: false, status: 404, json: async () => ({}) };
|
|
}
|
|
};
|
|
|
|
const { mergeTarget, mergeProgress } = await import('../../public/js/ui/gameLogic.js');
|
|
const { ChessCard, armedSlotOf, armKey, mergeHint } = await import('../../public/js/ui/shopBar.js');
|
|
const { ChessDetail, resolveDetail } = await import('../../public/js/ui/detailPanel.js');
|
|
const { data } = await import('../../public/js/data.js');
|
|
const { DATA, makeMatch, give } = await import('../match/harness.js');
|
|
|
|
await data.loadAll('chess', 'bonds', 'assets');
|
|
|
|
const getChess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null);
|
|
const A = 'chess_char_1_01_a';
|
|
const KAZE = 'chess_char_2_11_a';
|
|
const golden = (id) => DATA.chess[id].goldenId;
|
|
const onBoard = (id, row, col, dir = 'RIGHT', uid = row * 100 + col) => ({ uid, kind: 'chess', id, golden: false, row, col, dir });
|
|
const inHand = (id, uid) => ({ uid, kind: 'chess', id, golden: false });
|
|
|
|
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);
|
|
}
|
|
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
|
|
|
|
describe('gameLogic.mergeTarget (mirror of server board.js mergeTile)', () => {
|
|
test('no merge yet / hand copies only → null; a deployed copy → its tile and facing', () => {
|
|
assert.equal(mergeTarget({ board: [], hand: [inHand(A, 1)] }, A, getChess), null, 'one copy: no merge');
|
|
assert.equal(mergeTarget({ board: [], hand: [inHand(A, 1), inHand(A, 2)] }, A, getChess), null, 'hand copies: the elite goes to the hand');
|
|
assert.deepEqual(mergeTarget({ board: [onBoard(A, 10, 4, 'UP')], hand: [inHand(A, 2)] }, A, getChess), { row: 10, col: 4, dir: 'UP' });
|
|
assert.deepEqual(mergeTarget({ board: [onBoard(A, 10, 4)], temp: [inHand(A, 2)] }, A, getChess), { row: 10, col: 4, dir: 'RIGHT' }, 'temp copies count');
|
|
});
|
|
test('two deployed copies → the one that deploys first: col asc, then row desc (by column from the left, Battle.start)', () => {
|
|
const priv = { board: [onBoard(A, 9, 2, 'DOWN'), onBoard(A, 11, 7, 'LEFT')], hand: [] };
|
|
assert.deepEqual(mergeTarget(priv, A, getChess), { row: 9, col: 2, dir: 'DOWN' }, 'the left column, though lower (row-major until 0.1.3)');
|
|
const same = { board: [onBoard(A, 10, 8, 'UP'), onBoard(A, 10, 3, 'DOWN')], hand: [] };
|
|
assert.deepEqual(mergeTarget(same, A, getChess), { row: 10, col: 3, dir: 'DOWN' });
|
|
const column = { board: [onBoard(A, 9, 4, 'UP'), onBoard(A, 12, 4, 'LEFT')], hand: [] };
|
|
assert.deepEqual(mergeTarget(column, A, getChess), { row: 12, col: 4, dir: 'LEFT' }, 'one column: the top first');
|
|
});
|
|
test('elites and other operators are ignored; 风丸 merges with one deployed copy', () => {
|
|
const priv = { board: [{ ...onBoard(golden(A), 12, 2), golden: true }, onBoard('chess_char_1_02_a', 12, 3), onBoard(A, 9, 9)], hand: [inHand(A, 5)] };
|
|
assert.deepEqual(mergeTarget(priv, A, getChess), { row: 9, col: 9, dir: 'RIGHT' });
|
|
assert.equal(mergeProgress({ board: [onBoard(KAZE, 10, 5)] }, KAZE, getChess).need, 2);
|
|
assert.deepEqual(mergeTarget({ board: [onBoard(KAZE, 10, 5, 'UP')] }, KAZE, getChess), { row: 10, col: 5, dir: 'UP' });
|
|
assert.equal(mergeTarget({ board: [onBoard(A, 10, 5)] }, A, getChess), null, 'two of three: no merge yet');
|
|
});
|
|
test('the engine agrees: a real merge puts the elite on mergeTarget\'s tile', () => {
|
|
for (const seed of [61, 62, 63]) {
|
|
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed }).start();
|
|
h.toPrep(1);
|
|
h.setStage('act2autochess_m04');
|
|
const m = h.m;
|
|
const ps = h.ps('p_0');
|
|
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
|
|
ps.board.clear();
|
|
ps.hand.fill(null);
|
|
ps.recompute();
|
|
const id = Object.values(DATA.chess).find((c) => !c.isGolden && c.tier === 1 && c.position === 'MELEE' && m.pool.has(c.chessId) && m.gd.mergeCount(c.chessId) === 3 && !m.gd.placeableTokens(c.chessId).length).chessId;
|
|
const tiles = [];
|
|
const map = ps.deployMap();
|
|
for (const [k, cls] of map) if (cls === 'melee') tiles.push(k.split(',').map(Number));
|
|
// two copies on two seed-dependent melee tiles
|
|
const pick = [tiles[seed % tiles.length], tiles[(seed * 7 + 3) % tiles.length]];
|
|
if (pick[0].join() === pick[1].join()) pick[1] = tiles[(seed + 1) % tiles.length];
|
|
give(m, ps, id, 'board', pick[0]).dir = 'UP';
|
|
give(m, ps, id, 'board', pick[1]).dir = 'DOWN';
|
|
const want = mergeTarget(ps.privateView(), id, getChess);
|
|
ps.funds = 10;
|
|
ps.shop.slots[0] = { kind: 'chess', id, basePrice: m.gd.chessPrice(id), frozen: false, sold: false };
|
|
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
|
|
const view = ps.privateView().board.find((v) => v.golden);
|
|
assert.deepEqual([view.row, view.col, view.dir], [want.row, want.col, want.dir], `seed ${seed}: server and client agree`);
|
|
m.dispose();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('shop bar: the merge tag and the armed card', () => {
|
|
test('the 可晋升 tag says where the elite goes (the board copy\'s place / the hand)', () => {
|
|
const slot = { kind: 'chess', id: A, price: 1, basePrice: 1 };
|
|
const tagOf = (priv) => [...walk(ChessCard({ slot, idx: 0, priv, onBuy() {}, onDetail() {} }))].find((v) => hasClass(v, 'scard__mergetag'));
|
|
assert.equal(tagOf({ board: [onBoard(A, 10, 4)], hand: [inHand(A, 2)] })?.props.title, '精锐干员将出现在作战区原位置');
|
|
assert.equal(tagOf({ board: [], hand: [inHand(A, 1), inHand(A, 2)] })?.props.title, '精锐干员将进入整备区');
|
|
assert.equal(tagOf({ board: [], hand: [inHand(A, 1)] }), undefined, 'no tag before the merge');
|
|
});
|
|
test('touch shows no title: the detail card the first tap opens says where the elite goes (QA 6b)', async () => {
|
|
await data.loadAll('chess', 'garrisons', 'assets', 'bonds', 'items');
|
|
const board = { board: [onBoard(A, 10, 4)], hand: [inHand(A, 2)] };
|
|
const hand = { board: [], hand: [inHand(A, 1), inHand(A, 2)] };
|
|
assert.equal(mergeHint(board, A), '精锐干员将出现在作战区原位置');
|
|
assert.equal(mergeHint(hand, A), '精锐干员将进入整备区');
|
|
assert.equal(mergeHint({ board: [], hand: [inHand(A, 1)] }, A), null, 'no merge yet');
|
|
assert.equal(mergeHint(board, golden(A)), null, 'an elite card never merges');
|
|
// the first tap: ShopBar tapCard → onDetail(id, 'chess', mergeHint) → game.js setDetail({ …, hint }) → resolveDetail
|
|
assert.match(read('public/js/ui/shopBar.js'), /setArmed\(key\); onDetail\(slot\.id, detailKind, detailKind === 'chess' \? mergeHint\(priv, slot\.id\) : null\)/);
|
|
assert.match(read('public/js/screens/game.js'), /onDetail=\$\{\(id, kind, hint\) => setDetail\(\{ kind: kind === 'item' \? 'item' : 'chess', id, hint: hint \|\| null \}\)\}/);
|
|
const r = resolveDetail({ kind: 'chess', id: A, hint: mergeHint(board, A) }, new Map());
|
|
assert.equal(r.hint, '精锐干员将出现在作战区原位置');
|
|
const blocks = ChessDetail({ chess: r.chess, piece: null, editable: false, bonds: [], loadout: null, hint: r.hint });
|
|
assert.equal(blocks[0].key, 'head');
|
|
assert.equal(blocks[1].key, 'merge', 'right under the header');
|
|
assert.ok(hasClass(blocks[1], 'dhint'));
|
|
const text = JSON.stringify(blocks[1].props.children);
|
|
assert.ok(text.includes('可晋升:') && text.includes('精锐干员将出现在作战区原位置'), text);
|
|
assert.ok(!ChessDetail({ chess: r.chess, piece: null, editable: false, bonds: [], loadout: null }).some((b) => b.key === 'merge'), 'no hint, no line');
|
|
});
|
|
test('an elite card never merges (server completesChessMerge refuses isGolden): no pips, no tag, no target tile', () => {
|
|
const E = golden(A);
|
|
const priv = { board: [onBoard(A, 10, 4, 'UP')], hand: [inHand(A, 2)] };
|
|
assert.equal(mergeProgress(priv, A, getChess).copies, 2);
|
|
assert.equal(mergeProgress(priv, E, getChess).copies, 0);
|
|
assert.equal(mergeTarget(priv, E, getChess), null);
|
|
const card = ChessCard({ slot: { kind: 'chess', id: E, price: 3, basePrice: 3 }, idx: 0, priv, onBuy() {}, onDetail() {} });
|
|
const nodes = [...walk(card)];
|
|
assert.equal(nodes.find((v) => hasClass(v, 'scard__mergetag')), undefined, 'no 可晋升 tag');
|
|
assert.equal(nodes.find((v) => hasClass(v, 'scard__pips')), undefined, 'no 已拥有 pips');
|
|
assert.ok(!hasClass(card, 'is-merge'));
|
|
// the normal card in the same state still merges onto the deployed copy's tile
|
|
assert.deepEqual(mergeTarget(priv, A, getChess), { row: 10, col: 4, dir: 'UP' });
|
|
// and the server agrees: gaining the elite completes no merge
|
|
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 64 }).start();
|
|
h.toPrep(1);
|
|
const ps = h.ps('p_0');
|
|
const id = Object.values(DATA.chess).find((c) => !c.isGolden && c.tier === 1 && h.m.pool.has(c.chessId) && h.m.gd.mergeCount(c.chessId) === 3).chessId;
|
|
give(h.m, ps, id);
|
|
give(h.m, ps, id);
|
|
assert.equal(ps.completesChessMerge(id), true);
|
|
assert.equal(ps.completesChessMerge(golden(id)), false);
|
|
h.m.dispose();
|
|
});
|
|
test('armedSlotOf: shop / reward slots by key; level card, stale and sold keys → null', () => {
|
|
const slots = [{ kind: 'chess', id: A }, { kind: 'chess', id: KAZE, sold: true }, { kind: 'item', id: 'chess_item_1_01_e_a' }];
|
|
const reward = [{ kind: 'chess', id: 'chess_char_1_02_a' }];
|
|
assert.equal(armedSlotOf(armKey('c', 0, slots[0]), slots), slots[0]);
|
|
assert.equal(armedSlotOf(armKey('i', 2, slots[2]), slots), slots[2]);
|
|
assert.equal(armedSlotOf(armKey('r', 0, reward[0]), slots, reward), reward[0]);
|
|
assert.equal(armedSlotOf(armKey('c', 1, slots[1]), slots), null, 'sold');
|
|
assert.equal(armedSlotOf('c:0:other', slots), null, 'rerolled');
|
|
assert.equal(armedSlotOf('lv', slots), null);
|
|
assert.equal(armedSlotOf(null, slots), null);
|
|
});
|
|
test('the game screen lights the target tile while a merge-completing card is armed', () => {
|
|
const src = read('public/js/screens/game.js');
|
|
assert.match(src, /onArm=\$\{setArmedCard\}/, 'ShopBar reports the armed card');
|
|
assert.match(src, /mergeTarget\(priv, armedCard\.id, gd\.chess\)/, 'the target comes from gameLogic.mergeTarget');
|
|
assert.match(src, /view\.highlightTiles\(mergeAt \? \[\[mergeAt\.row, mergeAt\.col\]\] : \[\], MERGE_HL\)/, 'its own highlight group (cleared with [])');
|
|
assert.match(read('public/js/screens/game/marks.js'), /const MERGE_HL = Object\.freeze\(\{ group: 'mergeTile'/);
|
|
});
|
|
});
|