feedback1 final: merge the 突变细胞 bench rule (carrier destroyed, tile freed, the new operator gained into the 整备区; pointed out in PR #2)
Conflicts resolved keeping both sides: - effectsMeta.js header: the QA residual's "every [holder, item] pair, taken before the first item runs" and "an item moved during it runs at most once", with the cell branch's wording of what 突变细胞 does mid-walk. - DESIGN §6.6: the band pick that skips a strategy tied to a switched-off bond and the deployment of the operator a transformation gains / the bench shed that spares it. - DESIGN §21.25: PR #2's bench rule credited (the cell branch) plus the QA residual's snapshot-timing sentence. - PLAYING: the new 突变细胞 sentence with the residual's hammer sentence (revives, borrowed hammers, the lend window). - docs-consistency: both new tests (batch 6 QA residuals; 突变细胞 after the WA merge).
This commit is contained in:
commit
77eaeaa15f
23 files changed
+527
-214
No files matched your search
@@ -4,7 +4,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { makeMatch, give, giveItem, DATA } from '../match/harness.js';
|
||||
import { makeMatch, give, giveItem, DATA, legalTileFor } from '../match/harness.js';
|
||||
import { createRegistry } from '../../server/match/effectsMeta.js';
|
||||
import { lendItemEffects, itemGrants, PRIO_REVIVE } from '../../server/sim/content/items/battle.js';
|
||||
import { unitBonds } from '../../server/sim/content/support/index.js';
|
||||
@@ -993,6 +993,24 @@ test('突变细胞: after a battle the carrier becomes a random NORMAL tier+1 op
|
||||
cover(A('5_08'), B('5_08'));
|
||||
});
|
||||
|
||||
test('突变细胞: a DEPLOYED carrier leaves the field — the new operator is gained into the 整备区, the deploy slot comes back (PR #2)', () => {
|
||||
// official footage (bilibili BV1vzyVBuEN9, BV1Qkw1zMEoR): the tile is empty at the next prep and the new operator waits
|
||||
// on the bench; the case added by the closed PR #2, on the destroy-then-gain rule (DESIGN §21.1)
|
||||
const { m, ps, equip } = setup({ seed: 3 });
|
||||
const cid = plain((c) => c.tier === 2)[0];
|
||||
const holder = give(m, ps, cid, 'board', legalTileFor(m, ps, cid));
|
||||
assert.deepEqual(equip(giveItem(m, ps, A('5_08')), holder), OK);
|
||||
assert.equal(ps.deployCount, 1, 'the carrier is deployed');
|
||||
m.dispatch(ps, 'onBattleResult', { result: {}, lpLoss: 0, perfect: true });
|
||||
assert.equal(ps.board.size, 0, 'the carrier left the field');
|
||||
assert.equal(ps.deployCount, 0, 'the deploy slot came back');
|
||||
const chess = ps.hand.filter((p) => p && p.kind === 'chess');
|
||||
assert.equal(chess.length, 1, 'the new operator waits in the 整备区');
|
||||
assert.equal(DATA.chess[chess[0].id].tier, 3, 'the random tier+1 operator');
|
||||
assert.equal(DATA.chess[chess[0].id].isGolden, false);
|
||||
assert.ok(handIds(ps, 'item').includes(A('5_08')), 'the cell back in the hand');
|
||||
});
|
||||
|
||||
test('人事部文档: deploy cap becomes 9 (a second copy adds nothing)', () => {
|
||||
const { m, ps, equip } = setup();
|
||||
const t = give(m, ps, plain((c) => c.tier === 1)[0], 'hand');
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
// playtest6b follow-up (DESIGN §20.10–§20.13): leader HP and 直接乘算, the elite on the consumed copy's tile, the official
|
||||
// 999 layer cap (one implementation) and 限伤 300000, the 假想敌:胄 kit — and the normative lines they rewrote; player
|
||||
// feedback after 0.1.0 (DESIGN §21, v0.1.1) including batch 6 (§21.21–§21.25: 坚固维式重锤 once per deployment, 起飞,
|
||||
// fenced tiles, knocked-out bodies, the dispatcher snapshot and the manifest shrink guard credited to PR #2 / PR #7).
|
||||
// fenced tiles, knocked-out bodies, the dispatcher snapshot and the manifest shrink guard credited to PR #2 / PR #7) and
|
||||
// the 突变细胞 bench rule (§21.1: the carrier destroyed, its new operator gained into the 整备区 — official footage, PR #2).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -765,3 +766,28 @@ test('batch 6 QA residuals (DESIGN §21.21–§21.25): the lock per deployment f
|
||||
assert.match(log, /卢西恩、锏不会再因为身边只有起飞的蒂比就放出打不到人的范围技能/);
|
||||
assert.match(log, /倒在已消失的召唤物(如浊心斯卡蒂的海嗣)初始位置上的干员会回到自己的初始位置躺下/);
|
||||
});
|
||||
|
||||
test('突变细胞 after the WA merge (DESIGN §21.1): the carrier is destroyed, its new operator is gained into the 整备区 — code and every doc agree', async () => {
|
||||
const S21 = DESIGN.slice(DESIGN.indexOf('## 21.'));
|
||||
const s211 = S21.slice(S21.indexOf('### 21.1 '), S21.indexOf('\n### 21.2 '));
|
||||
// the evidence: the two official videos, PRTS, the pull request
|
||||
for (const re of [/BV1vzyVBuEN9 \(上半, ≈ 8:24\)/, /BV1Qkw1zMEoR \(下半, ≈ 7:25\)/, /"原干员销毁,获得一名…"/, /被发送至手牌区的物资优先从右到左填充空位/, /PR #2/]) assert.match(s211, re);
|
||||
// the WA wording (the new operator on the carrier's tile, the tile counting for a merge) is gone everywhere
|
||||
assert.ok(!/one tier higher \(max 6; an elite carrier too\) on its tile when legal/.test(DESIGN), 'DESIGN §21.1: the keep-the-tile rule is gone');
|
||||
assert.ok(!/counts as deployed with its own tile \[ASSUMED\]/.test(DESIGN), 'DESIGN §20.11: the transformed tile no longer counts');
|
||||
assert.ok(!/a transformed carrier's tile and the elite's fresh summon stack/.test(DESIGN), 'DESIGN §20.9: no longer [ASSUMED]');
|
||||
assert.ok(!/whose 突变细胞 rule is superseded by §21\.1/.test(DESIGN), 'DESIGN §21.25: PR #2 credited, not superseded');
|
||||
assert.match(META, /never onto the carrier's tile/);
|
||||
assert.ok(!/one tier higher, max 6, on the carrier's tile/.test(META), 'META: the built-in line follows the rule');
|
||||
assert.ok(!/counts with its own tile/.test(META), 'META: the merge line follows the rule');
|
||||
assert.match(PLAYING, /原来的格子空出来,剩余可放置角色加 1/);
|
||||
assert.match(DATA.items.chess_item_5_08_e_a.note, /进入整备区,需要重新部署/);
|
||||
// the code: a destroy, then a gain through acquireChess; _mergeChess has no carrier-tile option left
|
||||
const PS = doc('server/match/PlayerState.js');
|
||||
const { PlayerState } = await import('../server/match/PlayerState.js');
|
||||
assert.equal(PlayerState.prototype.transformChess.length, 2, 'transformChess(piece, newId)');
|
||||
assert.equal(PlayerState.prototype._mergeChess.length, 2, '_mergeChess(baseId, incoming)');
|
||||
assert.ok(!/fromKey|returnItems/.test(PS), 'no fromKey / returnItems left in PlayerState');
|
||||
assert.match(PS, /const np = this\.acquireChess\(newId, \{ source: 'transform' \}\);/);
|
||||
assert.ok(!/fromKey/.test(doc('server/match/audit.js')), 'audit.js: no exception for a transformed carrier\'s tile');
|
||||
});
|
||||
@@ -134,14 +134,16 @@ test('the dir persists across rounds and reaches the round\'s battle', () => {
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('effect-placed pieces: a transformation / a merge elite taking a consumed copy\'s tile keep that tile\'s dir', () => {
|
||||
test('effect-placed pieces: a transformation leaves the tile (the new operator waits in the hand); a merge elite taking a consumed copy\'s tile keeps that tile\'s dir', () => {
|
||||
const { m, ps } = prepMatch({ seed: 13 });
|
||||
const [id, other] = meleeIds(m, 2);
|
||||
const a = give(m, ps, id, 'board', [9, 3]);
|
||||
a.dir = 'DOWN';
|
||||
const np = ps.transformChess(a, other);
|
||||
assert.ok(np && ps.board.get(tileKey(9, 3)) === np, 'kept the tile');
|
||||
assert.equal(np.dir, 'DOWN');
|
||||
// 突变细胞 (PRTS 备注 "原干员销毁,获得一名…干员"; official footage, PR #2): the tile is freed, nothing inherits its dir
|
||||
assert.ok(np && !ps.board.has(tileKey(9, 3)), 'the tile is freed');
|
||||
assert.ok(ps.hand.includes(np), 'the new operator waits in the hand');
|
||||
assert.equal(ps.deployCount, 0, 'one deployment fewer');
|
||||
// merge of deployed copies (PRTS: the elite goes to the consumed copy's board position), with its dir
|
||||
ps.board.clear();
|
||||
ps.hand.fill(null);
|
||||
|
||||
@@ -5,12 +5,16 @@
|
||||
// (each copy counts the manual refreshes it witnessed this round), not the player's.
|
||||
// #4 昆图斯 突变细胞 "战斗结束后,装备者替换为高一阶的随机干员": the cell is not consumed — the original operator is
|
||||
// destroyed (PRTS 备注 "生效时,原干员销毁,获得一名高一阶的随机初始干员(最高六阶)") and its equipment, the cell
|
||||
// included, returns to the hand, to be equipped again ("之后就是一直打针,扎到核心卡…就换人扎").
|
||||
// included, returns to the hand, to be equipped again ("之后就是一直打针,扎到核心卡…就换人扎"); then the new operator
|
||||
// is gained like any gained operator — into the 整备区, never onto the carrier's tile (official footage: bilibili
|
||||
// BV1vzyVBuEN9 ≈ 8:24, BV1Qkw1zMEoR ≈ 7:25 — the tile is empty at the next prep, one more deployment is left, the
|
||||
// new operator waits on the bench; pointed out in PR #2). Bots / AI 托管 deploy it in their normal placement step.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeMatch, give, giveItem, legalTileFor, checkInvariants, DATA } from './harness.js';
|
||||
import { createRegistry } from '../../server/match/effectsMeta.js';
|
||||
import { botPrep, botPrepEnd, cellTarget } from '../../server/match/bot.js';
|
||||
import { mergeTile } from '../../server/match/board.js';
|
||||
|
||||
const QUIET = { warn() {}, error() {}, info() {} };
|
||||
const REG = createRegistry({ log: QUIET });
|
||||
@@ -164,37 +168,91 @@ const plainOf = (m, tier) => Object.values(DATA.chess)
|
||||
.map((c) => c.chessId).sort();
|
||||
const ownedItems = (ps, id) => [...ps.hand, ...ps.temp].filter((p) => p && p.kind === 'item' && p.id === id);
|
||||
const equip = (m, item, target) => m.handle('p_0', { t: 'g.equip', itemUid: item.uid, targetUid: target.uid });
|
||||
const SWORD = 'chess_item_1_01_e_a'; // 维式重锤
|
||||
const PACK = 'chess_item_5_07_e_a'; // 商业包装方案 (counts sells per item: player counter `pack:<item uid>`)
|
||||
const VIGIL = 'chess_char_3_19_a'; // 伺夜 (Ⅲ) — a placeable 狼群 summon card
|
||||
/** Distinct normal equipment ids (never merge with each other), the cell excluded. */
|
||||
const fillerItems = () => Object.values(DATA.items).filter((it) => it.itemType === 'EQUIP' && !it.isGolden && it.id !== CELL).map((it) => it.id).sort();
|
||||
const unusedFillers = (ps) => fillerItems().filter((id) => ![...ps.hand, ...ps.temp].some((p) => p && p.id === id));
|
||||
/** Fill every free hand slot but `leave` with distinct items. */
|
||||
const fillHand = (m, ps, leave = 1) => {
|
||||
const ids = unusedFillers(ps);
|
||||
while (ps.hand.filter((x) => x == null).length > leave) giveItem(m, ps, ids.shift());
|
||||
};
|
||||
/** Fill every free temp slot with distinct items. */
|
||||
const fillTemp = (m, ps) => {
|
||||
const ids = unusedFillers(ps);
|
||||
while (ps.temp.some((x) => x == null)) giveItem(m, ps, ids.shift(), 'temp');
|
||||
};
|
||||
/** Force the cell's roll (the random draw) of `tier` to `id`; everything else stays the real path. */
|
||||
const forceRoll = (m, tier, id) => { const roll = m.pool.roll.bind(m.pool); m.pool.roll = (rng, o = {}) => (o.tier === tier ? id : roll(rng, o)); };
|
||||
const where = (ps, pred) => (ps.hand.some((p) => p && pred(p)) ? 'hand' : ps.temp.some((p) => p && pred(p)) ? 'temp' : null);
|
||||
const handIdx = (ps, pred) => ps.hand.findIndex((p) => p && pred(p));
|
||||
/** The round loop's SETTLE dispatch, alone. */
|
||||
const battleResult = (m, ps) => m.dispatch(ps, 'onBattleResult', { result: {}, lpLoss: 0, perfect: true });
|
||||
|
||||
test('#4 players\' report: 突变细胞 is not used up — after each battle the carrier is replaced (normal, tier +1) and the cell returns to the hand', () => {
|
||||
test('#4 players\' report and the official flow: after each battle the deployed carrier is destroyed — its tile empties, one more deployment is left —, its equipment (the cell too) comes back first, then a NORMAL tier+1 operator joins the hand', () => {
|
||||
const s = setup({ seed: 5 });
|
||||
const { h, m, ps } = s;
|
||||
const t2 = plainOf(m, 2)[0];
|
||||
const carrier = give(m, ps, t2, 'board', legalTileFor(m, ps, t2));
|
||||
const tile = ps.find(carrier.uid).key;
|
||||
const cell = giveItem(m, ps, CELL);
|
||||
const sword = giveItem(m, ps, 'chess_item_1_01_e_a'); // 维式重锤: other equipment returns too
|
||||
const sword = giveItem(m, ps, SWORD); // other equipment returns too
|
||||
assert.deepEqual(equip(m, cell, carrier), OK);
|
||||
assert.deepEqual(equip(m, sword, carrier), OK);
|
||||
assert.equal(ps.deployCount, 1);
|
||||
h.toPrep(2);
|
||||
assert.ok(!ps.find(carrier.uid), 'the original operator is gone (原干员销毁)');
|
||||
const next = ps.board.get(tile);
|
||||
assert.ok(next && next.kind === 'chess', 'the new operator takes the tile');
|
||||
assert.ok(!ps.board.has(tile), 'its tile is empty (bilibili BV1vzyVBuEN9 ≈ 8:24, BV1Qkw1zMEoR ≈ 7:25)');
|
||||
assert.equal(ps.deployCount, 0, 'one more deployment is left');
|
||||
const next = ps.hand.find((p) => p && p.kind === 'chess');
|
||||
assert.ok(next, 'the new operator waits in the hand (整备区)');
|
||||
assert.equal(m.gd.chess(next.id).tier, 3, 'one tier higher');
|
||||
assert.ok(!m.gd.isGolden(next.id), 'an initial (normal) operator');
|
||||
assert.deepEqual(next.items, [], 'its equipment came off');
|
||||
assert.deepEqual(next.items, [], 'nothing equipped');
|
||||
assert.equal(ownedItems(ps, CELL).length, 1, 'the cell is back in the hand, not consumed');
|
||||
assert.equal(ownedItems(ps, 'chess_item_1_01_e_a').length, 1, 'the other equipment too');
|
||||
// "一直打针": inject again — the same new operator here; it climbs once more
|
||||
assert.equal(ownedItems(ps, SWORD).length, 1, 'the other equipment too');
|
||||
// the hand fills right → left ("被发送至手牌区的物资优先从右到左填充空位"): the equipment came off first, then the gain
|
||||
assert.deepEqual([handIdx(ps, (p) => p.id === CELL), handIdx(ps, (p) => p.id === SWORD), ps.hand.indexOf(next)], [9, 8, 7]);
|
||||
assert.ok(ps.tempEmpty);
|
||||
// the player deploys it by hand, then "一直打针": inject again — it climbs once more and comes back to the bench
|
||||
const [row, col] = legalTileFor(m, ps, next.id);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: next.uid, to: { area: 'board', row, col } }), OK);
|
||||
assert.equal(ps.deployCount, 1);
|
||||
assert.deepEqual(equip(m, ownedItems(ps, CELL)[0], next), OK);
|
||||
h.toPrep(3);
|
||||
const third = ps.board.get(tile);
|
||||
assert.equal(m.gd.chess(third.id).tier, 4, 'tier 4 after the second battle');
|
||||
assert.ok(!ps.find(next.uid) && !ps.board.has(`${row},${col}`), 'the second carrier is gone too, its tile empty');
|
||||
assert.equal(ps.deployCount, 0);
|
||||
const third = ps.hand.find((p) => p && p.kind === 'chess');
|
||||
assert.equal(m.gd.chess(third.id).tier, 4, 'tier 4 after the second battle, on the bench');
|
||||
assert.equal(ownedItems(ps, CELL).length, 1, 'and the cell is back again');
|
||||
checkInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('#4 突变细胞 details: an elite carrier → a NORMAL operator one tier higher; 6阶 → another 6阶; no room → it stays on the new operator', () => {
|
||||
test('#4 bench carriers transform too: a hand or temp carrier leaves its slot; the cell, then the new operator, fill the hand right → left', () => {
|
||||
for (const area of ['hand', 'temp']) {
|
||||
const s = setup({ seed: 6 });
|
||||
const { m, ps } = s;
|
||||
const t1 = plainOf(m, 1)[0];
|
||||
const carrier = give(m, ps, t1, area);
|
||||
assert.deepEqual(equip(m, giveItem(m, ps, CELL), carrier), OK);
|
||||
battleResult(m, ps);
|
||||
assert.ok(!ps.find(carrier.uid), `${area}: the carrier is destroyed`);
|
||||
const got = ps.allChess();
|
||||
assert.equal(got.length, 1, `${area}: one operator gained`);
|
||||
assert.equal(m.gd.chess(got[0].id).tier, 2, `${area}: one tier higher`);
|
||||
assert.ok(!m.gd.isGolden(got[0].id));
|
||||
assert.deepEqual([handIdx(ps, (p) => p.id === CELL), ps.hand.indexOf(got[0])], [9, 8], `${area}: the cell first, then the gain`);
|
||||
assert.ok(ps.tempEmpty, `${area}: nothing waits in temp`);
|
||||
assert.equal(ps.deployCount, 0);
|
||||
checkInvariants(m);
|
||||
m.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('#4 突变细胞 details: an elite carrier → a NORMAL operator one tier higher; 6阶 → another 6阶', () => {
|
||||
{
|
||||
const s = setup({ seed: 9 });
|
||||
const { h, m, ps } = s;
|
||||
@@ -222,23 +280,85 @@ test('#4 突变细胞 details: an elite carrier → a NORMAL operator one tier h
|
||||
assert.equal(ownedItems(ps, CELL).length, 1);
|
||||
m.dispose();
|
||||
}
|
||||
{
|
||||
// hand and temp full: the cell cannot come off and stays on the new operator (nothing is lost)
|
||||
const s = setup({ seed: 11 });
|
||||
const { m, ps } = s;
|
||||
const t2 = plainOf(m, 2)[0];
|
||||
const carrier = give(m, ps, t2, 'board', legalTileFor(m, ps, t2));
|
||||
assert.deepEqual(equip(m, giveItem(m, ps, CELL), carrier), OK);
|
||||
const at = ps.find(carrier.uid).key;
|
||||
for (let i = 0; i < ps.hand.length; i++) if (!ps.hand[i]) giveItem(m, ps, 'chess_item_1_01_e_a', 'hand', i);
|
||||
for (let i = 0; i < ps.temp.length; i++) if (!ps.temp[i]) giveItem(m, ps, 'chess_item_1_01_e_a', 'temp', i);
|
||||
m.dispatch(ps, 'onBattleResult', { result: {}, lpLoss: 0, perfect: true });
|
||||
const next = ps.board.get(at);
|
||||
assert.ok(next && next.uid !== carrier.uid && m.gd.chess(next.id).tier === 3);
|
||||
assert.deepEqual(next.items.map((it) => it.id), [CELL], 'kept on the new operator');
|
||||
checkInvariants(m);
|
||||
m.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('#4 hand full: the returned cell takes the last hand slot, the new operator overflows into temp — with no summon card until it is deployed', () => {
|
||||
const s = setup({ seed: 31 });
|
||||
const { m, ps } = s;
|
||||
forceRoll(m, 3, VIGIL);
|
||||
const t2 = plainOf(m, 2)[0];
|
||||
const carrier = give(m, ps, t2, 'board', legalTileFor(m, ps, t2));
|
||||
const at = ps.find(carrier.uid).key;
|
||||
assert.deepEqual(equip(m, giveItem(m, ps, CELL), carrier), OK);
|
||||
fillHand(m, ps, 1);
|
||||
battleResult(m, ps);
|
||||
assert.ok(!ps.board.has(at), 'the carrier\'s tile is empty');
|
||||
assert.equal(ps.deployCount, 0);
|
||||
assert.equal(where(ps, (p) => p.id === CELL), 'hand', 'the cell came back first, into the last hand slot');
|
||||
const vigil = ps.temp.find((p) => p && p.id === VIGIL);
|
||||
assert.ok(vigil, '伺夜 overflows into temp like any gain with a full hand');
|
||||
assert.equal(where(ps, (p) => p.kind === 'token'), null, 'no 狼群 card yet: a summon card comes with a deployment');
|
||||
// deployed by hand from temp, it brings its card like any operator placed there
|
||||
const [row, col] = legalTileFor(m, ps, VIGIL);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: vigil.uid, to: { area: 'board', row, col } }), OK);
|
||||
assert.ok(where(ps, (p) => p.kind === 'token' && p.ownerUid === vigil.uid), 'its 狼群 card arrived');
|
||||
checkInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('#4 hand and temp full: the gained operator follows the no-room rule of every gain (back to the pool, the same toast); the carrier is destroyed all the same', () => {
|
||||
const s = setup({ seed: 11 });
|
||||
const { h, m, ps } = s;
|
||||
const t2 = plainOf(m, 2)[0];
|
||||
const t3 = plainOf(m, 3)[0];
|
||||
forceRoll(m, 3, t3);
|
||||
const carrier = give(m, ps, t2, 'board', legalTileFor(m, ps, t2));
|
||||
const at = ps.find(carrier.uid).key;
|
||||
assert.deepEqual(equip(m, giveItem(m, ps, CELL), carrier), OK);
|
||||
fillHand(m, ps, 0);
|
||||
fillTemp(m, ps);
|
||||
const left0 = m.pool.left(t3);
|
||||
const left2 = m.pool.left(t2);
|
||||
const toasts = [];
|
||||
const toast = m.toast.bind(m);
|
||||
m.toast = (who, kind, text, ...rest) => { if (who === ps) toasts.push(text); return toast(who, kind, text, ...rest); };
|
||||
battleResult(m, ps);
|
||||
assert.ok(!ps.board.has(at), 'the carrier is destroyed wherever it stands');
|
||||
assert.equal(ps.deployCount, 0);
|
||||
assert.equal(m.pool.left(t2), left2 + 1, 'its copy went back to the pool');
|
||||
assert.ok(!ps.allChess().some((p) => p.id === t3), 'no slot anywhere: the gained operator is not kept');
|
||||
assert.equal(m.pool.left(t3), left0, 'its copy went back to the pool (PlayerState.acquireChess)');
|
||||
assert.ok(toasts.includes('整备区已满,获得的干员已返还'), 'the toast of any gained operator with no room');
|
||||
// the cell found no slot and no gained operator to stay on: destroyed with a log warning, as in a merge
|
||||
assert.equal(ownedItems(ps, CELL).length, 0);
|
||||
assert.ok(h.logs.warn.some((w) => w.includes(`returned item ${CELL} destroyed (no space)`)), 'logged');
|
||||
checkInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('#4 per-piece state rides on the returned equipment: 商业包装方案 keeps its sell count through the transformation', () => {
|
||||
const s = setup({ seed: 36 });
|
||||
const { m, ps } = s;
|
||||
const t2 = plainOf(m, 2)[0];
|
||||
const carrier = give(m, ps, t2, 'board', legalTileFor(m, ps, t2));
|
||||
const pack = giveItem(m, ps, PACK);
|
||||
assert.deepEqual(equip(m, giveItem(m, ps, CELL), carrier), OK);
|
||||
assert.deepEqual(equip(m, pack, carrier), OK);
|
||||
const [fodder] = plainOf(m, 1);
|
||||
const sell = () => assert.deepEqual(m.handle('p_0', { t: 'g.sell', uid: give(m, ps, fodder, 'hand').uid }), OK);
|
||||
for (let i = 0; i < 3; i++) sell();
|
||||
const key = `pack:${pack.uid}`;
|
||||
assert.equal(ps.counters[key], 3, 'three sells counted while equipped');
|
||||
battleResult(m, ps);
|
||||
const back = ownedItems(ps, PACK)[0];
|
||||
assert.ok(back && back.uid === pack.uid, 'the same item came back to the hand');
|
||||
assert.equal(ps.counters[key], 3, 'with its progress');
|
||||
const next = ps.hand.find((p) => p && p.kind === 'chess');
|
||||
assert.deepEqual(equip(m, back, next), OK);
|
||||
sell();
|
||||
assert.equal(ps.counters[key], 4, 'it goes on counting on its next carrier');
|
||||
checkInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('#4 strategy 昆图斯 end to end: the R3 cell survives its first transformation; a bot injects its weakest single normal operator, never an elite', () => {
|
||||
@@ -281,55 +401,158 @@ test('#4 strategy 昆图斯 end to end: the R3 cell survives its first transform
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// #4 follow-up: the returned cell must not be pushed into temp by the new operator's summon card (it would be lost at
|
||||
// the deadline), and a bot never throws it away
|
||||
// #4: a gain that completes a merge merges like any gained copy (DESIGN §20.11) — the elite goes to a consumed DEPLOYED
|
||||
// copy's tile (board.js mergeTile), else to the hand; the destroyed carrier's tile is no copy's. The returned cell is
|
||||
// stowed before the elite's summon card (it would be lost in temp at the deadline).
|
||||
|
||||
const VIGIL = 'chess_char_3_19_a'; // 伺夜 (Ⅲ) — a placeable 狼群 summon card
|
||||
/** Distinct normal equipment ids (never merge with each other), the cell excluded. */
|
||||
const fillerItems = () => Object.values(DATA.items).filter((it) => it.itemType === 'EQUIP' && !it.isGolden && it.id !== CELL).map((it) => it.id).sort();
|
||||
/** Fill every free hand slot but `leave` with distinct items. */
|
||||
const fillHand = (m, ps, leave = 1) => {
|
||||
const ids = fillerItems().filter((id) => ![...ps.hand, ...ps.temp].some((p) => p && p.id === id));
|
||||
while (ps.hand.filter((x) => x == null).length > leave) giveItem(m, ps, ids.shift());
|
||||
};
|
||||
/** Force the cell's roll (the random draw) to 伺夜 for tier 3; everything else stays the real path. */
|
||||
const forceVigil = (m) => { const roll = m.pool.roll.bind(m.pool); m.pool.roll = (rng, o = {}) => (o.tier === 3 ? VIGIL : roll(rng, o)); };
|
||||
const where = (ps, pred) => (ps.hand.some((p) => p && pred(p)) ? 'hand' : ps.temp.some((p) => p && pred(p)) ? 'temp' : null);
|
||||
|
||||
test('#4 one free hand slot, the new operator brings a summon card: the returned cell takes the slot, the card waits in temp', () => {
|
||||
const s = setup({ seed: 31 });
|
||||
test('#4 the gain completes a merge with deployed copies: the elite takes the first deployed copy\'s tile, never the carrier\'s; the cell is returned before the elite\'s summon card', () => {
|
||||
const s = setup({ seed: 32 });
|
||||
const { m, ps } = s;
|
||||
forceVigil(m);
|
||||
forceRoll(m, 3, VIGIL);
|
||||
// the carrier deploys FIRST: its tile leads the deploy order (under the 0.1.1 WA rule the elite took it)
|
||||
const t2 = plainOf(m, 2)[0];
|
||||
const carrier = give(m, ps, t2, 'board', legalTileFor(m, ps, t2));
|
||||
const at = ps.find(carrier.uid).key;
|
||||
const ct = ps.find(carrier.uid).key;
|
||||
const copies = [give(m, ps, VIGIL, 'board', legalTileFor(m, ps, VIGIL))];
|
||||
copies.push(give(m, ps, VIGIL, 'board', legalTileFor(m, ps, VIGIL)));
|
||||
copies[0].dir = 'UP';
|
||||
copies[1].dir = 'DOWN';
|
||||
for (const c of copies) ps.removeTokensOf(c.uid); // keep the hand count exact
|
||||
const copyTiles = copies.map((c) => ps.find(c.uid).key);
|
||||
assert.equal(mergeTile([ct, ...copyTiles].map((key) => ({ key }))).key, ct, 'the carrier\'s tile is the first in deploy order');
|
||||
assert.deepEqual(equip(m, giveItem(m, ps, CELL), carrier), OK);
|
||||
fillHand(m, ps, 1);
|
||||
m.dispatch(ps, 'onBattleResult', { result: {}, lpLoss: 0, perfect: true });
|
||||
const next = ps.board.get(at);
|
||||
assert.equal(next && next.id, VIGIL, '伺夜 takes the carrier\'s tile');
|
||||
assert.equal(where(ps, (p) => p.id === CELL), 'hand', 'the cell came back into the hand (it would be lost in temp at the deadline)');
|
||||
assert.equal(where(ps, (p) => p.kind === 'token' && p.ownerUid === next.uid), 'temp', 'the 狼群 card overflows into temp (it comes back at the next round start)');
|
||||
battleResult(m, ps);
|
||||
const want = mergeTile(copyTiles.map((key) => ({ key })));
|
||||
const elite = ps.board.get(want.key);
|
||||
assert.ok(elite && elite.id === m.gd.goldenIdOf(VIGIL), 'the elite 伺夜 stands on the first deployed copy\'s tile');
|
||||
assert.equal(elite.dir, want.key === copyTiles[0] ? 'UP' : 'DOWN', 'with that copy\'s facing');
|
||||
assert.ok(!ps.board.has(ct), 'the carrier\'s tile is empty');
|
||||
assert.equal(ps.deployCount, 1, 'carrier + two copies deployed → the elite alone');
|
||||
assert.equal(where(ps, (p) => p.id === CELL), 'hand', 'the cell came back first, into the last hand slot');
|
||||
assert.equal(where(ps, (p) => p.kind === 'token' && p.ownerUid === elite.uid), 'temp', 'the elite\'s 狼群 card overflows into temp');
|
||||
assert.equal(ps.stats.merges, 1);
|
||||
checkInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('#4 the transformation completes a merge on the board: the cell is returned before the elite\'s summon card', () => {
|
||||
const s = setup({ seed: 32 });
|
||||
test('#4 the gain completes a merge with no deployed copy: the elite goes to the hand (no summon card); the carrier\'s tile stays empty', () => {
|
||||
const s = setup({ seed: 35 });
|
||||
const { m, ps } = s;
|
||||
forceVigil(m);
|
||||
const copies = [give(m, ps, VIGIL, 'board', legalTileFor(m, ps, VIGIL))];
|
||||
copies.push(give(m, ps, VIGIL, 'board', legalTileFor(m, ps, VIGIL)));
|
||||
for (const c of copies) ps.removeTokensOf(c.uid); // keep the hand count exact
|
||||
forceRoll(m, 3, VIGIL);
|
||||
const t2 = plainOf(m, 2)[0];
|
||||
const carrier = give(m, ps, t2, 'board', legalTileFor(m, ps, t2));
|
||||
const ct = ps.find(carrier.uid).key;
|
||||
const copies = [give(m, ps, VIGIL, 'hand'), give(m, ps, VIGIL, 'hand')];
|
||||
assert.deepEqual(equip(m, giveItem(m, ps, CELL), carrier), OK);
|
||||
fillHand(m, ps, 1);
|
||||
m.dispatch(ps, 'onBattleResult', { result: {}, lpLoss: 0, perfect: true });
|
||||
const elite = [...ps.board.values()].find((p) => p.id === m.gd.goldenIdOf(VIGIL));
|
||||
assert.ok(elite, 'the elite 伺夜 is deployed on a consumed copy\'s tile');
|
||||
assert.equal(where(ps, (p) => p.id === CELL), 'hand', 'the cell is in the hand');
|
||||
assert.equal(where(ps, (p) => p.kind === 'token' && p.ownerUid === elite.uid), 'temp', 'the elite\'s 狼群 card in temp');
|
||||
battleResult(m, ps);
|
||||
const elite = ps.allChess().find((p) => p.id === m.gd.goldenIdOf(VIGIL));
|
||||
assert.ok(elite, 'the gain completed the merge');
|
||||
assert.ok(copies.every((c) => !ps.find(c.uid)), 'both bench copies were consumed');
|
||||
assert.equal(ps.find(elite.uid).area, 'hand', 'no consumed copy was deployed (the carrier\'s tile is no copy\'s): the hand');
|
||||
assert.ok(!ps.board.has(ct), 'the carrier\'s tile is empty');
|
||||
assert.equal(ps.deployCount, 0);
|
||||
assert.equal(where(ps, (p) => p.kind === 'token'), null, 'an elite on the bench brings no summon card');
|
||||
assert.equal(where(ps, (p) => p.id === CELL), 'hand');
|
||||
assert.equal(ps.stats.merges, 1);
|
||||
assert.deepEqual(ps.offers.map((o) => o.source), ['merge'], 'the promotion reward as for any merge');
|
||||
checkInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('#4 bots and AI 托管 deploy the gained operator in their normal placement step (a real co-op round: SETTLE → the next prep)', () => {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 41, registry: REG, fake: true }).start();
|
||||
const m = h.m;
|
||||
h.toPrep(1);
|
||||
const human = h.ps('p_0');
|
||||
const bot = h.ps('ai_0');
|
||||
h.run(() => bot.ready); // the bot's own R1 prep is done
|
||||
// each seat: a small board — a tier-2 carrier of the cell and a tier-1 operator, nothing on the bench
|
||||
const [t1a, t1b] = plainOf(m, 1);
|
||||
const [t2a, t2b] = plainOf(m, 2);
|
||||
const before = new Map();
|
||||
for (const [ps, t1, t2] of [[human, t1a, t2a], [bot, t1b, t2b]]) {
|
||||
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean), ...ps.temp.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
|
||||
ps.board.clear();
|
||||
ps.hand.fill(null);
|
||||
ps.temp.fill(null);
|
||||
ps.offers.length = 0;
|
||||
const carrier = give(m, ps, t2, 'board', legalTileFor(m, ps, t2));
|
||||
give(m, ps, t1, 'board', legalTileFor(m, ps, t1));
|
||||
carrier.items.push(ps.newPiece('item', CELL));
|
||||
ps.recompute();
|
||||
before.set(ps, { uids: new Set(ps.allChess().map((p) => p.uid)), carrier });
|
||||
}
|
||||
// SETTLE: each carrier is destroyed and a tier-3 operator gained into the hand
|
||||
const gained = new Map();
|
||||
const settle = m.settle.bind(m);
|
||||
m.settle = (...a) => {
|
||||
const r = settle(...a);
|
||||
for (const [ps, b] of before) gained.set(ps, ps.allChess().find((p) => !b.uids.has(p.uid)) || null);
|
||||
return r;
|
||||
};
|
||||
// the layout each seat readies with in R2 (its bot prep ends with g.ready)
|
||||
const atReady = new Map();
|
||||
for (const ps of [human, bot]) {
|
||||
const setReady = ps.setReady.bind(ps);
|
||||
ps.setReady = (on) => {
|
||||
const g = gained.get(ps);
|
||||
if (on && m.round === 2 && g && !atReady.has(ps)) atReady.set(ps, { area: ps.find(g.uid)?.area ?? null, deployed: ps.deployCount });
|
||||
return setReady(on);
|
||||
};
|
||||
}
|
||||
h.toPrep(2);
|
||||
for (const [ps, b] of before) {
|
||||
const g = gained.get(ps);
|
||||
assert.ok(g && m.gd.chess(g.id).tier === 3 && !m.gd.isGolden(g.id), `${ps.playerId}: a NORMAL tier-3 operator was gained at SETTLE`);
|
||||
assert.ok(!ps.find(b.carrier.uid), `${ps.playerId}: the carrier is gone`);
|
||||
assert.notEqual(ps.find(g.uid)?.area, 'board', `${ps.playerId}: it starts the prep on the bench`);
|
||||
}
|
||||
assert.ok(!human.ready, 'the human has not readied yet');
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.autoplay', on: true }), OK); // AI 托管 plays the human seat from here
|
||||
h.run(() => atReady.size === 2);
|
||||
for (const ps of [bot, human]) {
|
||||
const r = atReady.get(ps);
|
||||
assert.ok(r, `${ps.playerId}: readied in R2`);
|
||||
assert.equal(r.area, 'board', `${ps.playerId}: the gained operator was deployed by the placement step`);
|
||||
assert.ok(r.deployed >= 2, `${ps.playerId}: the freed deployment is used (${r.deployed} deployed)`);
|
||||
}
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('#4 a bot on 昆图斯 lets the placement step judge each gained operator: the bench shed never sells one in its first prep, and no deployment stays free while one waits on the bench', () => {
|
||||
// a real solo 绝境 bot match on strategy 昆图斯 (the only band on offer, as tools/botbench.mjs --band) through R8: the
|
||||
// buy loop's pre-emptive bench shed sells by piece value while the bench is crowded — before rememberOwned it sold this
|
||||
// seed's R7 operator in the R8 prep (20 such matches: 44 of 222 gained operators, before any placement step saw them)
|
||||
const seats = [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }];
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'HARD', seats, seed: 9, registry: REG });
|
||||
const m = h.m;
|
||||
m.gd.bandIds = () => ['band_quintus'];
|
||||
const ps = h.ps('ai_0');
|
||||
const gained = new Map(); // uid → round of the SETTLE that gained it
|
||||
const shed = [];
|
||||
const waiting = [];
|
||||
const transform = ps.transformChess.bind(ps);
|
||||
ps.transformChess = (...a) => { const np = transform(...a); if (np) gained.set(np.uid, m.round); return np; };
|
||||
const sell = ps.sell.bind(ps);
|
||||
ps.sell = (uid) => {
|
||||
if (gained.get(uid) === m.round - 1 && /buyLoopSteps/.test(new Error().stack)) shed.push(`R${m.round} ${ps.find(uid)?.piece.id}`);
|
||||
return sell(uid);
|
||||
};
|
||||
const setReady = ps.setReady.bind(ps);
|
||||
ps.setReady = (on) => {
|
||||
for (const [uid, r] of gained) {
|
||||
const loc = r === m.round - 1 ? ps.find(uid) : null;
|
||||
if (on && loc && loc.area !== 'board' && ps.deployCount < ps.deployCap) waiting.push(`R${m.round} ${loc.piece.id} ${ps.deployCount}/${ps.deployCap}`);
|
||||
}
|
||||
return setReady(on);
|
||||
};
|
||||
h.start();
|
||||
h.run(() => h.ended != null || (m.phase === 'PREP' && m.round === 9));
|
||||
assert.equal(ps.bandId, 'band_quintus');
|
||||
assert.ok(gained.size >= 5, `the cell transformed after every battle from R3 (${gained.size})`);
|
||||
assert.deepEqual(shed, [], 'no operator sold by the bench shed in the prep after its gain');
|
||||
assert.deepEqual(waiting, [], 'a gained operator off the board only when the board is full');
|
||||
checkInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
@@ -296,7 +296,7 @@ test('item merge with a full hand AND a full temp: the golden item takes the equ
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('a merge completed during SETTLE (突变细胞) keeps its reward offer for the next prep; the elite takes a deployed copy\'s tile (the carrier\'s counts) or goes to the hand', () => {
|
||||
test('a merge completed during SETTLE (突变细胞) keeps its reward offer for the next prep; the elite takes a deployed copy\'s tile (never the carrier\'s) or goes to the hand', () => {
|
||||
const X = 'chess_char_2_04_a';
|
||||
const fillers = Object.values(DATA.items).filter((i) => i.itemType === 'EQUIP' && !i.isGolden && !String(i.kind || '').startsWith('consume')).map((i) => i.itemId ?? i.id).filter(Boolean);
|
||||
// where the carrier of 突变细胞 and the two copies of X are: hand/hand, board/hand, board/board
|
||||
@@ -317,7 +317,9 @@ test('a merge completed during SETTLE (突变细胞) keeps its reward offer for
|
||||
const holder = carrierAt === 'board' ? give(m, ps, T, 'board', legalTileFor(m, ps, T)) : give(m, ps, T);
|
||||
holder.items.push(ps.newPiece('item', 'chess_item_5_08_e_a'));
|
||||
const copies = copiesAt === 'board' ? [0, 1].map(() => give(m, ps, X, 'board', legalTileFor(m, ps, X))) : [give(m, ps, X), give(m, ps, X)];
|
||||
const deployedTiles = [holder, ...copies].map((p) => ps.find(p.uid)).filter((l) => l.area === 'board').map((l) => l.key);
|
||||
// the carrier is destroyed before its gain (PRTS 备注 "原干员销毁,获得一名…"): only the copies' tiles count
|
||||
const holderTile = ps.find(holder.uid).key || null;
|
||||
const deployedTiles = copies.map((p) => ps.find(p.uid)).filter((l) => l.area === 'board').map((l) => l.key);
|
||||
const deployed0 = ps.deployCount;
|
||||
for (let i = 0; ps.hand.some((x) => x == null); i++) giveItem(m, ps, fillers[i]);
|
||||
const fillers0 = ps.hand.filter((p) => p && p.kind === 'item').length;
|
||||
@@ -332,10 +334,10 @@ test('a merge completed during SETTLE (突变细胞) keeps its reward offer for
|
||||
assert.deepEqual(ps.offers.map((o) => o.source), ['merge'], 'the promotion reward waits for this prep');
|
||||
assert.ok(ps.privateView().shop.rewardOffer, 'and is shown');
|
||||
const loc = ps.find(elite.uid);
|
||||
if (holderTile) assert.ok(!ps.board.has(holderTile), `${label}: the carrier's tile is empty`);
|
||||
if (deployedTiles.length) {
|
||||
// PRTS 卫戍协议/帮助: a merge consuming a deployed copy sends the elite to that copy's tile — outside PREP too; the
|
||||
// transformed carrier stood on the board, so its tile counts (when legal for the elite); of several, the first
|
||||
// in deploy order (row desc, col asc)
|
||||
// PRTS 卫戍协议/帮助: a merge consuming a deployed copy sends the elite to that copy's tile — outside PREP too; of
|
||||
// several, the first in deploy order (row desc, col asc)
|
||||
const map = ps.deployMap();
|
||||
const pos = positionClass(m.gd.chess(elite.id));
|
||||
const legal = deployedTiles.map((k) => k.split(',').map(Number)).filter(([r, c]) => canPlace(map, pos, r, c)).sort((a, b) => b[0] - a[0] || a[1] - b[1]);
|
||||
@@ -346,6 +348,8 @@ test('a merge completed during SETTLE (突变细胞) keeps its reward offer for
|
||||
} else {
|
||||
assert.equal(loc.area, 'hand', `${label}: the consumed hand pieces freed the slots`);
|
||||
}
|
||||
// the carrier's deployment is gone; deployed copies were replaced by at most the one elite
|
||||
assert.equal(ps.deployCount, deployed0 - (holderTile ? 1 : 0) - deployedTiles.length + (loc.area === 'board' ? 1 : 0), `${label}: deploy count`);
|
||||
const CELL = 'chess_item_5_08_e_a';
|
||||
assert.equal(ps.hand.filter((p) => p && p.kind === 'item' && p.id !== CELL).length, fillers0, `${label}: the hand's equipment stays`);
|
||||
// the cell is not consumed (player feedback after 0.1.0): it came back to the hand — or temp, the hand being full
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
// official game): "官方就是合成精锐时,如果消耗了场上的干员,精锐会出现在场上那个位置". PRTS 卫戍协议/帮助 §干员的获得与精锐化:
|
||||
// "不获得第3名干员,销毁已有的2名初始干员,发送1名【精锐】状态的该干员至手牌区(若消耗已部署至作战区的干员,则发送至作战区
|
||||
// 对应位置)". Remake (server/match/PlayerState.js _mergeChess, board.js mergeTile): the elite takes the tile and facing of the
|
||||
// consumed copy that deploys first (row desc, col asc — [ASSUMED] when several stood on the board); a transformed deployed
|
||||
// piece's own tile counts; with no deployed copy it goes to the hand (overflow temp). Equipment returns to the hand
|
||||
// ("干员晋级后已配发装备会回收至整备区"), the copies' summons are removed and the elite on the board gets its own stack
|
||||
// (its loadout), the deploy count never grows, the reward offer is unchanged — for buys, rewards, effect grants, SETTLE
|
||||
// merges and the boss-field prep alike. audit.js checks the rule in every audited match.
|
||||
// consumed copy that deploys first (row desc, col asc — [ASSUMED] when several stood on the board); a 突变细胞 carrier is
|
||||
// destroyed before its gain, so its tile never counts; with no deployed copy it goes to the hand (overflow temp).
|
||||
// Equipment returns to the hand ("干员晋级后已配发装备会回收至整备区"), the copies' summons are removed and the elite on
|
||||
// the board gets its own stack (its loadout), the deploy count never grows, the reward offer is unchanged — for buys,
|
||||
// rewards, effect grants, SETTLE merges and the boss-field prep alike. audit.js checks the rule in every audited match.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ERR, PHASE } from '../../shared/constants.js';
|
||||
@@ -112,20 +112,22 @@ test('buy: two deployed copies → the copy that deploys first (row desc, then c
|
||||
}
|
||||
});
|
||||
|
||||
test('three deployed copies (a deployed operator transformed into the third, 突变细胞-style) → the first of the three tiles in deploy order', () => {
|
||||
test('a deployed operator transformed into the third copy (突变细胞) → the first deployed COPY\'s tile, never the carrier\'s (destroyed first)', () => {
|
||||
const { m, ps } = prep({ seed: 43 });
|
||||
const [id, other] = meleeTier1(m);
|
||||
// the carrier deploys first: under the 0.1.1 WA rule its tile won; the official destroy-then-gain frees it instead
|
||||
const carrier = deploy(m, ps, other, 'UP');
|
||||
const c1 = deploy(m, ps, id, 'LEFT');
|
||||
const c2 = deploy(m, ps, id, 'DOWN');
|
||||
const tiles = [carrier, c1, c2].map((p) => keyOf(ps, p));
|
||||
const dirOf = { [tiles[0]]: 'UP', [tiles[1]]: 'LEFT', [tiles[2]]: 'DOWN' };
|
||||
const [ct, t1, t2] = [carrier, c1, c2].map((p) => keyOf(ps, p));
|
||||
assert.equal(mergeTile([ct, t1, t2].map((key) => ({ key }))).key, ct, 'the carrier\'s tile is the first in deploy order');
|
||||
const elite = ps.transformChess(carrier, id);
|
||||
assert.ok(elite && elite.id === chess(id).goldenId, 'the transformation completed the merge');
|
||||
const want = mergeTile(tiles.map((key) => ({ key })));
|
||||
assert.equal(keyOf(ps, elite), want.key, 'the elite takes the first deployed tile');
|
||||
assert.equal(elite.dir, dirOf[want.key]);
|
||||
assert.equal(ps.deployCount, 1, 'three deployed → one');
|
||||
assert.ok(elite && elite.id === chess(id).goldenId, 'the gain completed the merge');
|
||||
const want = mergeTile([t1, t2].map((key) => ({ key })));
|
||||
assert.equal(keyOf(ps, elite), want.key, 'the elite takes the first deployed copy\'s tile');
|
||||
assert.equal(elite.dir, want.key === t1 ? 'LEFT' : 'DOWN', 'with that copy\'s facing');
|
||||
assert.ok(!ps.board.has(ct), 'the carrier\'s tile is empty');
|
||||
assert.equal(ps.deployCount, 1, 'carrier + two copies deployed → the elite alone');
|
||||
assert.equal(ps.stats.merges, 1);
|
||||
checkInvariants(m);
|
||||
m.dispose();
|
||||
@@ -420,7 +422,7 @@ test('audit.js flags an elite that misses the deployed copy\'s tile (and passes
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('audit.js passes transformations that complete a merge (突变细胞 ctx.transform; a deployed carrier is detached first), in PREP and SETTLE', () => {
|
||||
test('audit.js passes transformations that complete a merge (突变细胞 ctx.transform: the carrier is destroyed before the gain, its tile no copy\'s), in PREP and SETTLE', () => {
|
||||
// carrier / the two copies: 'board' or 'hand'
|
||||
const layouts = [['board', 'hand', 'hand'], ['board', 'board', 'hand'], ['board', 'board', 'board'], ['hand', 'board', 'board'], ['hand', 'hand', 'hand']];
|
||||
for (const phase of ['PREP', 'SETTLE']) {
|
||||
@@ -436,23 +438,26 @@ test('audit.js passes transformations that complete a merge (突变细胞 ctx.tr
|
||||
const [id, other] = meleeTier1(m);
|
||||
const place = (cid, at, dir) => (at === 'board' ? deploy(m, ps, cid, dir) : give(m, ps, cid));
|
||||
const carrier = place(other, carrierAt, 'UP');
|
||||
const carrierTile = keyOf(ps, carrier);
|
||||
const copies = copiesAt.map((at, j) => place(id, at, j ? 'DOWN' : 'LEFT'));
|
||||
const dirOf = new Map([carrier, ...copies].filter((p) => keyOf(ps, p)).map((p) => [keyOf(ps, p), p.dir]));
|
||||
// only the copies' tiles count (PRTS: a destroy, then a gain — the carrier is no copy of the merge)
|
||||
const dirOf = new Map(copies.filter((p) => keyOf(ps, p)).map((p) => [keyOf(ps, p), p.dir]));
|
||||
const before = ps.deployCount;
|
||||
if (phase === 'SETTLE') h.drive(() => m.phase === PHASE.SETTLE);
|
||||
assert.equal(m.phase, PHASE[phase]);
|
||||
const ctx = makeCtx(m, ps, { kind: 'item', key: 'item:test' }, phase === 'SETTLE' ? 'onSettle' : 'onBuy');
|
||||
const got = ctx.transform(carrier.uid, id);
|
||||
assert.ok(got && got.id === chess(id).goldenId, `${label}: the transformation completed the merge`);
|
||||
assert.ok(got && got.id === chess(id).goldenId, `${label}: the gain completed the merge`);
|
||||
const want = mergeTile([...dirOf.keys()].map((key) => ({ key })));
|
||||
const loc = ps.find(got.uid);
|
||||
if (carrierTile) assert.ok(!ps.board.has(carrierTile), `${label}: the carrier's tile is empty`);
|
||||
if (want) {
|
||||
assert.equal(loc.key, want.key, `${label}: the elite on the first deployed tile`);
|
||||
assert.equal(loc.key, want.key, `${label}: the elite on the first deployed copy's tile`);
|
||||
assert.equal(loc.piece.dir, dirOf.get(want.key), `${label}: with that copy's facing`);
|
||||
assert.equal(ps.deployCount, 1, `${label}: ${before} deployed → 1`);
|
||||
} else {
|
||||
assert.equal(loc.area, 'hand', `${label}: no deployed copy → the hand`);
|
||||
assert.equal(ps.deployCount, 0);
|
||||
assert.equal(ps.deployCount, 0, `${label}: ${before} deployed → 0`);
|
||||
}
|
||||
assert.deepEqual(audit.violations, [], `${label}: no audit violation`);
|
||||
m.dispose();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Player feedback after 0.1.0, #4 (workstream WA): the 突变细胞 item card says what the official text leaves out — the
|
||||
// cell is not consumed, it returns to the hand with the destroyed operator's other equipment (items.json `note`, built
|
||||
// by tools/build-data.mjs ITEM_RULES; server: builtinMeta char_chess_transformation_equip).
|
||||
// cell is not consumed, it returns to the hand with the destroyed operator's other equipment, and the new operator joins
|
||||
// the 整备区 to be deployed again (official footage; PR #2) (items.json `note`, built by tools/build-data.mjs ITEM_RULES;
|
||||
// server: builtinMeta char_chess_transformation_equip).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -35,7 +36,7 @@ const textOf = (v) => {
|
||||
};
|
||||
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
|
||||
|
||||
test('ItemDetail: 突变细胞 (both qualities) says it returns to the hand; an ordinary item has no rule line', async () => {
|
||||
test('ItemDetail: 突变细胞 (both qualities) says it returns to the hand and the new operator joins the 整备区; an ordinary item has no rule line', async () => {
|
||||
await data.loadAll('items', 'assets');
|
||||
const rule = (item) => [...walk(ItemDetail({ item, piece: null, editable: false }))].find((n) => hasClass(n, 'dhint--rule'));
|
||||
for (const id of ['chess_item_5_08_e_a', 'chess_item_5_08_e_b']) {
|
||||
@@ -44,6 +45,7 @@ test('ItemDetail: 突变细胞 (both qualities) says it returns to the hand; an
|
||||
const t = textOf(rule(cell));
|
||||
assert.match(t, /原干员销毁/);
|
||||
assert.match(t, /突变细胞与其他装备退回整备区,可再次配发/);
|
||||
assert.match(t, /高一阶的随机初始干员(最高6阶),进入整备区,需要重新部署/);
|
||||
}
|
||||
const plain = data.lookup('items', 'chess_item_1_01_e_a'); // 维式重锤
|
||||
assert.equal(plain.note, null);
|
||||
|
||||
Reference in new issue
Block a user