Files
ark-stronghold-protocol/test/match/feedback1-meta.test.js
T
sganggs f8e35d5055 feedback1 WA: Lappland counts her own first refresh; Mutation Cell is not consumed
Player reports after 0.1.0, #1 and #4.

#1 拉普兰德 (garrison_123, SERVER_GAIN_BOND_LAYER_BY_REFRESH_CNT) checked the
player's refresh count of the round, so a copy bought after the round's first
manual refresh never stacked that round. Players report the official
behaviour: the first refresh after acquiring her also stacks. Each copy now
counts the manual refreshes it witnessed this round (board or hand) through a
per-piece round counter (PlayerState.pieceRoundCount, ctx.incPieceCounter); an
elite merged this round keeps the highest count of its copies [ASSUMED]. The
"本回合每刷新过1次" multiplier traits keep the player's count.

#4 突变细胞 destroyed itself after one transformation. Official text never
says 销毁 (every consumable's does), PRTS notes "原干员销毁,获得一名高一阶的随机
初始干员", and players re-inject the cell every round. The built-in now replaces
the carrier with a random normal operator one tier higher (max 6) and returns
the carrier's equipment, the cell included, to the hand
(transformChess returnItems); the content override is gone. The item card
shows the rule (items.json note, build-data ITEM_RULES); bots inject their
least valuable single normal operator below tier 6 (bot.js cellTarget).
2026-10-03 01:47:52 +08:00

282 lines
14 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.
// Player feedback after 0.1.0 (workstream WA, match-side effects), through the real match paths (g.refresh / g.buy /
// g.move / g.equip, the round loop's SETTLE):
// #1 拉普兰德 garrison_123 "<刷新时>若为本回合首次主动刷新,使已激活的【叙拉古】层数+4(+8),此干员在整备区时也有效": the
// players' official behaviour — "获得该干员后该回合的首次刷新" also stacks. The refresh count is the 拉普兰德's own
// (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 ("之后就是一直打针,扎到核心卡…就换人扎").
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, cellTarget } from '../../server/match/bot.js';
const QUIET = { warn() {}, error() {}, info() {} };
const REG = createRegistry({ log: QUIET });
const OK = { ok: true };
const LAP = 'chess_char_2_16_a'; // 拉普兰德 (Ⅱ, 叙拉古) — garrison_123_a
const LAP_B = 'chess_char_2_16_b';
const PROVENCE = 'chess_char_1_07_a'; // 普罗旺斯 (Ⅰ, 叙拉古)
const TEXAS = 'chess_char_1_08_a'; // 德克萨斯 (Ⅰ, 独行 / 叙拉古)
const CELL = 'chess_item_5_08_e_a'; // 突变细胞
const KEY = 'garrison:SERVER_GAIN_BOND_LAYER_BY_REFRESH_CNT';
function setup({ seed = 70, mode = 'solo' } = {}) {
const h = makeMatch({ mode, seed, registry: REG, fake: true }).start();
h.toPrep(1);
const m = h.m;
const ps = h.ps('p_0');
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;
ps.funds = 50;
ps.layers = {};
ps.pendingFunds = 0;
ps.shop.freeRefreshes = 0;
ps.bandId = null; // isolate from the band's own prep effects
ps.recompute();
/** layers added by 拉普兰德's trait (the dispatcher's reason = the registry key) */
const lap = { n: 0 };
const addLayers = ps.addLayers.bind(ps);
ps.addLayers = (b, n, o = {}) => { const a = addLayers(b, n, o); if (o.reason === KEY && b === 'siracusaShip') lap.n += a; return a; };
const refresh = () => assert.deepEqual(m.handle('p_0', { t: 'g.refresh' }), OK);
const L = () => lap.n;
/** put `id` into shop slot `i` (as a roll would) and buy it through g.buy; returns the owned piece */
const buy = (id, i = 0) => {
ps.shop.slots[i] = { kind: 'chess', id, basePrice: m.gd.chessPrice(id), frozen: false, sold: false };
const before = new Set(ps.allChess().map((p) => p.uid));
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: i }), OK, `buy ${id}`);
return ps.allChess().find((p) => !before.has(p.uid));
};
const place = (piece) => {
const [row, col] = legalTileFor(m, ps, piece.id);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: piece.uid, to: { area: 'board', row, col } }), OK);
return [row, col];
};
const activate = () => { ps.bondCountBonus.siracusaShip = 20; ps.recompute(); };
return { h, m, ps, refresh, L, buy, place, activate };
}
// ---------------------------------------------------------------------------------------------------------------------
// #1 拉普兰德
test('#1 players\' scenario: 普罗旺斯 + 德克萨斯 deployed, refresh, buy 拉普兰德, deploy her (3 叙拉古), refresh again → 叙拉古 +4', () => {
const s = setup();
const { m, ps } = s;
give(m, ps, PROVENCE, 'board', legalTileFor(m, ps, PROVENCE));
give(m, ps, TEXAS, 'board', legalTileFor(m, ps, TEXAS));
assert.ok(!ps.bonds.siracusaShip.active, '2 叙拉古: not active yet');
s.refresh(); // the round's first manual refresh — no 拉普兰德 owned yet
const lap = s.buy(LAP);
s.place(lap);
assert.ok(ps.bonds.siracusaShip.active, '3 different 叙拉古 on the board: active');
const before = ps.layers.siracusaShip || 0;
s.refresh(); // the round's 2nd refresh, her 1st
assert.equal((ps.layers.siracusaShip || 0) - before, 4, 'the first refresh after she joined stacks +4 (the official behaviour the players report)');
s.refresh();
assert.equal((ps.layers.siracusaShip || 0) - before, 4, 'only her first refresh of the round');
// the next round: her first refresh of that round stacks again
s.h.toPrep(2);
ps.funds = 50;
const b2 = ps.layers.siracusaShip || 0;
s.refresh();
assert.equal((ps.layers.siracusaShip || 0) - b2, 4, 'R2: first refresh +4');
s.refresh();
assert.equal((ps.layers.siracusaShip || 0) - b2, 4, 'R2: once');
checkInvariants(m);
m.dispose();
});
test('#1 each copy counts its own refreshes: a bench copy (整备区时也有效) bought later fires on its own first refresh; an elite keeps "already fired"', () => {
const s = setup();
const { m, ps } = s;
s.activate();
const a = give(m, ps, LAP, 'board', legalTileFor(m, ps, LAP));
s.refresh();
assert.equal(s.L(), 4, 'copy A (board): its first refresh');
const b = s.buy(LAP); // copy B stays in the hand (整备区)
assert.equal(ps.find(b.uid).area, 'hand');
s.refresh();
assert.equal(s.L(), 8, 'copy B: its first refresh (A already fired this round)');
s.refresh();
assert.equal(s.L(), 8, 'nothing more this round');
// the third copy completes the elite: A and B already fired this round, so the elite does not fire again this round
// [ASSUMED: conservative — the elite keeps the highest refresh count of its copies]
const elite = s.buy(LAP);
assert.equal(elite.id, LAP_B, 'merged into the elite');
assert.ok(!ps.find(a.uid) && !ps.find(b.uid), 'copies consumed');
s.refresh();
assert.equal(s.L(), 8, 'the elite made this round from copies that already fired: no second trigger');
s.h.toPrep(2);
ps.funds = 50;
s.activate();
s.refresh();
assert.equal(s.L(), 8 + 8, 'R2: the elite fires +8 on the round\'s first refresh');
checkInvariants(m);
m.dispose();
});
test('#1 an elite merged from copies that had not fired yet this round fires on the next refresh; a re-bought copy is a new 拉普兰德', () => {
const s = setup();
const { m, ps } = s;
s.activate();
s.refresh();
s.refresh(); // two refreshes before owning any copy
give(m, ps, LAP, 'hand');
give(m, ps, LAP, 'hand');
const elite = s.buy(LAP);
assert.equal(elite.id, LAP_B);
s.refresh();
assert.equal(s.L(), 8, 'the elite\'s first refresh (the round\'s 3rd) stacks +8');
// sell it, buy a new copy: a new operator — it fires on its own first refresh (costs its price and a refresh)
assert.deepEqual(m.handle('p_0', { t: 'g.sell', uid: elite.uid }), OK);
s.buy(LAP);
s.refresh();
assert.equal(s.L(), 8 + 4, 'a newly acquired copy counts from its own first refresh');
s.refresh();
assert.equal(s.L(), 12);
checkInvariants(m);
m.dispose();
});
test('#1 only manual refreshes count: a re-triggered "刷新时" trait (ctx.triggerGarrisons) neither fires nor uses up her first refresh', () => {
const s = setup();
const { m, ps } = s;
s.activate();
const a = give(m, ps, LAP, 'hand');
assert.equal(m.dispatcher.triggerGarrisons(ps, a, 'SERVER_REFRESH_SHOP'), 1, 'the trait ran as a trigger');
assert.equal(s.L(), 0, 'not a manual refresh');
s.refresh();
assert.equal(s.L(), 4, 'her first manual refresh still counts');
checkInvariants(m);
m.dispose();
});
// ---------------------------------------------------------------------------------------------------------------------
// #4 突变细胞
/** Plain visible normal chess (only IN_BATTLE traits) of a tier with a free pool copy. */
const plainOf = (m, tier) => Object.values(DATA.chess)
.filter((c) => c.visible && !c.isGolden && c.tier === tier && (c.garrisonIds || []).every((g) => DATA.garrisons[g].eventType === 'IN_BATTLE') && !m.gd.placeableTokens(c.chessId).length && m.pool.has(c.chessId) && m.pool.left(c.chessId) > 0)
.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 });
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', () => {
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
assert.deepEqual(equip(m, cell, carrier), OK);
assert.deepEqual(equip(m, sword, carrier), OK);
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.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.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.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.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', () => {
{
const s = setup({ seed: 9 });
const { h, m, ps } = s;
const t3 = plainOf(m, 3)[0];
const elite = give(m, ps, m.gd.goldenIdOf(t3), 'hand');
assert.deepEqual(equip(m, giveItem(m, ps, CELL), elite), OK);
h.toPrep(2);
const got = ps.allChess();
assert.equal(got.length, 1);
assert.equal(m.gd.chess(got[0].id).tier, 4);
assert.ok(!m.gd.isGolden(got[0].id), 'PRTS 备注: 获得一名高一阶的随机初始干员');
assert.equal(ownedItems(ps, CELL).length, 1);
m.dispose();
}
{
const s = setup({ seed: 10 });
const { h, m, ps } = s;
const t6 = plainOf(m, 6)[0] || Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier === 6 && m.pool.has(c.chessId) && m.pool.left(c.chessId) > 0).chessId;
const top = give(m, ps, t6, 'hand');
assert.deepEqual(equip(m, giveItem(m, ps, CELL), top), OK);
h.toPrep(2);
const got = ps.allChess();
assert.equal(got.length, 1);
assert.equal(m.gd.chess(got[0].id).tier, 6, '最高6阶');
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 strategy 昆图斯 end to end: the R3 cell survives its first transformation; a bot injects its weakest single normal operator, never an elite', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 21, registry: REG, fake: true }).start();
const m = h.m;
h.toPrep(1, { band: 'band_quintus' });
const ps = h.ps('p_0');
assert.equal(ps.bandId, 'band_quintus');
h.toPrep(3, { band: 'band_quintus' });
assert.equal(ownedItems(ps, CELL).length, 1, 'R3: the strategy grants the cell');
// equip it on a deployed operator (any) and play the R3 battle
const target = ps.allChess()[0] || give(m, ps, plainOf(m, 1)[0], 'board', legalTileFor(m, ps, plainOf(m, 1)[0]));
assert.deepEqual(equip(m, ownedItems(ps, CELL)[0], target), OK);
h.toPrep(4);
const owned = [...ownedItems(ps, CELL), ...ps.allChess().flatMap((p) => (p.items || []).filter((it) => it.id === CELL))];
assert.equal(owned.length, 1, 'R4: the cell is still owned after its transformation');
m.dispose();
// the bot's choice: its least valuable single normal operator below 6阶 — never an elite, a merge pair or a 6阶
const s = setup({ seed: 23 });
const { m: m2, ps: p2 } = s;
const [t1a, t1b] = plainOf(m2, 1);
const t3 = plainOf(m2, 3)[0];
const t6 = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier === 6 && m2.pool.has(c.chessId) && m2.pool.left(c.chessId) > 0).chessId;
const elite = give(m2, p2, m2.gd.goldenIdOf(t3), 'board', legalTileFor(m2, p2, t3));
const pair = [give(m2, p2, t1a, 'board', legalTileFor(m2, p2, t1a)), give(m2, p2, t1a, 'hand')];
const top = give(m2, p2, t6, 'hand');
assert.equal(cellTarget(m2, p2), null, 'no candidate: the cell waits in the hand');
const single = give(m2, p2, t1b, 'board', legalTileFor(m2, p2, t1b));
assert.equal(cellTarget(m2, p2).uid, single.uid, 'the single normal operator');
void elite; void pair; void top;
// through the whole bot prep (it may buy, sell and move first): whoever carries the cell is a normal operator below 6阶
p2.isBot = true;
giveItem(m2, p2, CELL);
botPrep(m2, p2);
const carrierOf = p2.allChess().find((p) => (p.items || []).some((it) => it.id === CELL));
assert.ok(carrierOf, 'the bot equipped the cell');
assert.ok(!m2.gd.isGolden(carrierOf.id) && m2.gd.tierOf(carrierOf.id) < 6, 'never an elite or a 6阶');
m2.dispose();
});