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(src, /const MERGE_HL = Object\.freeze\(\{ group: 'mergeTile'/);
|
|
});
|
|
});
|