feedback1b WG: item merges re-checked; returned equipment auto-merges

Player report after 0.1.0: "源石溶剂两个白的合成一个金色的之后又多给一个白的".
Not reproduced: buying next to a white (hand / equipped), buying twice, equip
on another or a full carrier, reward pick, 道具补给, 列装, 整备, 画卷 and the
sale of a carrier all end with one golden and no white; random intents over
all 50 mergeable items conserve the white-equivalents and never leave a pair.

The one gap: equipment a promotion (PlayerState._mergeChess) or an effect
that removes an operator (ctx.destroyPiece) returns to the hand skipped the
auto-merge. Unreachable from a normal state (a pair never exists), closed:
both now run checkItemMerges.
This commit is contained in:
sganggs committed 2026-10-03 02:31:44 +08:00
1 parent b4fa21a0a6
commit 8fa7a85a52
4 files changed
+185 -2

No files matched your search

+3 -1
View File
@@ -331,7 +331,9 @@ Other equipment: 2 slots; a third replaces the equipped item the player picks in
equipped on the target ⇒ `BAD_TARGET`, nothing changes) — and the replaced item is destroyed. Equipped items are
otherwise locked (research 04 §2 / addendum: they leave the operator only on promotion, merge or sale): `g.destroy`
refuses them (`BAD_TARGET 'equipped items are locked'`). A second copy of an equipped normal item merges into the golden
item in the hand.
item in the hand. Every path that hands an item to the player (buy, reward, 机变, grants, equip, the equipment a sale,
a promotion or `ctx.destroyPiece` returns) ends with the auto-merge (`acquireItem` / `checkItemMerges`: "已拥有2件同一初始
装备时…自动合并"), so a player never holds two identical mergeable normal items (`test/match/feedback1b-items.test.js`).
Built-ins (builtinMeta.js, overridable): 盟约之币 / 骑士储蓄罐 (random funds), 随身身份牌 (layers of the target's bonds),
紧急调度券 (take shop chess), 精打细算玩偶 (+funds each round), 简易通讯机 / 拟态物质 (same-bond chess), 见钱眼开玩偶
+4 -1
View File
@@ -477,7 +477,8 @@ export class PlayerState {
* grows. Otherwise the elite goes to the hand, overflow temp — outside PREP too (a SETTLE merge's elite waits in temp
* through the next prep, tempDue). The copies' equipment returns to the hand ("干员晋级后已配发装备会回收至整备区";
* overflow temp; with both full it stays on the elite, up to its equipPerChess (2) slots — any further item is
* destroyed with a log warning, as before the official rule); their summons are removed, and an elite on the board
* destroyed with a log warning, as before the official rule) and an identical normal pair among it merges like any gain
* (checkItemMerges); their summons are removed, and an elite on the board
* gets its own summon stack (grantTokensFor: its loadout, like any deployment). Returns the elite piece (or null if
* the elite could not be stored).
* @param {string} baseId
@@ -515,6 +516,8 @@ export class PlayerState {
if (where && elite.items.length < this.gd.equipPerChess) { elite.items.push(it); continue; }
this.m.log.warn?.(`[match ${this.m.roomCode}] ${this.playerId}: returned item ${it.id} destroyed (no space)`);
}
// the returned equipment follows the auto-merge rule like any other gain ("已拥有2件同一初始装备时…自动合并")
this.checkItemMerges();
// deployed like any operator placed by hand: its manually deployable summons join the hand (after the returned
// equipment, which would be lost in temp — a summon stack removed there comes back at the next round start)
if (where === 'board') this.grantTokensFor(elite);
+1
View File
@@ -506,6 +506,7 @@ export function makeCtx(m, ps, source, hook, ev = null) {
for (const it of l.piece.items || []) ps.stow(it, { allowTemp: true });
l.piece.items = [];
ps.returnCopies(l.piece);
ps.checkItemMerges(); // the returned equipment auto-merges like any gain
}
ps.recompute();
return true;
+177
View File
@@ -0,0 +1,177 @@
// Player report after the public 0.1.0 release (relayed, untested by the user): "源石溶剂两个白的合成一个金色的之后又多给一个白的"
// — after two white 源石溶剂 (chess_item_1_05_e_a) merged into the golden one (_b), an extra white appeared.
// Official rule (PRTS 卫戍协议/帮助 §装备; activity_table act2autochess trapChessDataDict upgradeNum 2 → _b): "已拥有2件同一初始
// 装备时,该装备将自动合并为1件进阶品质的同名装备,发送至手牌区"; equipment is removed "在失去该干员(干员出售、销毁、合并等)或
// 装备合并为进阶品质时". Every real PlayerState path that hands the player a second white is replayed here and must end
// with exactly one golden and no white (none duplicates — the report did not reproduce); random intents conserve the
// white-equivalents W + 2·G. The one gap found: equipment a promotion (or an effect that removes an operator) returns
// to the hand skipped the auto-merge — unreachable from a normal state (a pair never exists), closed anyway.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DATA, makeMatch, give, giveItem, legalTileFor, checkInvariants } from './harness.js';
import { tileKey } from '../../server/match/board.js';
import { applyCard } from '../../server/match/choices.js';
const WHITE = 'chess_item_1_05_e_a';
const GOLD = 'chess_item_1_05_e_b';
const OP = 'chess_char_1_17_a';
function prep(seed = 5) {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', humans: 1, seed, fake: true }).start();
h.toPrep(1);
const m = h.m, 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.offers.length = 0;
ps.funds = 99;
ps.recompute();
return { h, m, ps };
}
function count(ps) {
const c = { white: 0, gold: 0 };
const see = (p) => { if (p && p.kind === 'item') { if (p.id === WHITE) c.white++; if (p.id === GOLD) c.gold++; } };
for (const p of [...ps.hand, ...ps.temp, ...ps.board.values()]) { see(p); for (const it of (p && p.items) || []) see(it); }
return c;
}
/** Put item `id` into the shop's first item slot (after the chess slots); returns its slot index. */
function stockItem(ps, id = WHITE) {
const i = ps.shop.layout ? ps.shop.layout.chess : ps.shop.slots.findIndex((s) => s && s.kind === 'item');
ps.shop.slots[i] = { kind: 'item', id, basePrice: ps.gd.itemPrice(id), frozen: false, sold: false };
return i;
}
/** Put chess `id` into shop slot 0. */
function stockChess(ps, id) {
ps.shop.slots[0] = { kind: 'chess', id, basePrice: ps.gd.chessPrice(id), frozen: false, sold: false };
return 0;
}
function deploy(m, ps, ids) {
const used = new Set();
return ids.map((id) => { const t = legalTileFor(m, ps, id, used); used.add(tileKey(t[0], t[1])); return give(m, ps, id, 'board', t); });
}
test('源石溶剂: the data is the official pair (upgradeNum 2 → the golden _b; 40 % / 60 % ATK, −60 HP/s)', () => {
const w = DATA.items[WHITE], g = DATA.items[GOLD];
assert.equal(w.upgradeNum, 2);
assert.equal(w.upgradeChessId, GOLD);
assert.equal(w.mergeable, true);
assert.equal(g.isGolden, true);
assert.deepEqual([w.params.atk, g.params.atk, w.params.damage, g.params.damage], [0.4, 0.6, 60, 60]);
});
test('源石溶剂: every way to get a second white merges the pair into ONE golden and leaves no white (the player report)', () => {
const cases = {
'buy with a white in the hand': ({ m, ps }) => { giveItem(m, ps, WHITE); return m.handle('p_0', { t: 'g.buy', slot: stockItem(ps) }); },
'buy twice from the shop': ({ m, ps }) => { m.handle('p_0', { t: 'g.buy', slot: stockItem(ps) }); return m.handle('p_0', { t: 'g.buy', slot: stockItem(ps) }); },
'buy with a white equipped': ({ m, ps }) => {
const [a] = deploy(m, ps, [OP]);
const it = giveItem(m, ps, WHITE);
m.handle('p_0', { t: 'g.equip', itemUid: it.uid, targetUid: a.uid });
return m.handle('p_0', { t: 'g.buy', slot: stockItem(ps) });
},
'equip the second on another operator': ({ m, ps }) => {
const [a, b] = deploy(m, ps, [OP, 'chess_char_2_10_a']);
const it = giveItem(m, ps, WHITE);
m.handle('p_0', { t: 'g.equip', itemUid: it.uid, targetUid: a.uid });
const it2 = giveItem(m, ps, WHITE);
return m.handle('p_0', { t: 'g.equip', itemUid: it2.uid, targetUid: b.uid });
},
'equip the second on the same full carrier': ({ m, ps }) => {
const [a] = deploy(m, ps, [OP]);
const it = giveItem(m, ps, WHITE);
const other = giveItem(m, ps, 'chess_item_1_01_e_a');
m.handle('p_0', { t: 'g.equip', itemUid: it.uid, targetUid: a.uid });
m.handle('p_0', { t: 'g.equip', itemUid: other.uid, targetUid: a.uid });
// no twin anywhere else: acquire the second white without the auto-merge (the hand) and equip it on the full carrier
const it2 = ps.newPiece('item', WHITE);
ps.hand[ps.hand.findIndex((x) => x == null)] = it2;
return m.handle('p_0', { t: 'g.equip', itemUid: it2.uid, targetUid: a.uid });
},
'reward offer pick': ({ m, ps }) => { giveItem(m, ps, WHITE); ps.pushItemOffer([WHITE, WHITE, WHITE]); return m.handle('p_0', { t: 'g.reward', idx: 0 }); },
'道具补给 card': ({ m, ps }) => { giveItem(m, ps, WHITE); applyCard(m, ps, { kind: 'item', id: WHITE, family: 'supply', name: '源石溶剂' }); return { ok: true }; },
'列装 rolling two whites': ({ m, ps }) => {
const roll = m.rollPool;
m.rollPool = () => ({ kind: 'item', id: WHITE });
try { applyCard(m, ps, { kind: 'tactic', id: 'allybuff_select_1', family: 'tactic', team: true }); } finally { m.rollPool = roll; }
return { ok: true };
},
'整备 then a white bought next to a white': ({ m, ps }) => {
giveItem(m, ps, WHITE);
applyCard(m, ps, { kind: 'tactic', id: 'allybuff_select_5', family: 'tactic' });
return m.handle('p_0', { t: 'g.buy', slot: stockItem(ps) });
},
'画卷 copy of a white carrier': ({ m, ps }) => {
const [a] = deploy(m, ps, [OP]);
const it = giveItem(m, ps, WHITE);
m.handle('p_0', { t: 'g.equip', itemUid: it.uid, targetUid: a.uid });
const art = giveItem(m, ps, 'chess_item_6_02_m');
const [r, c] = [...ps.board.entries()].find(([, p]) => p === a)[0].split(',').map(Number);
return m.handle('p_0', { t: 'g.art', itemUid: art.uid, row: r, col: c, dir: 'RIGHT' });
},
};
for (const [name, run] of Object.entries(cases)) {
const s = prep();
const res = run(s);
assert.equal(res.ok, true, `${name}: ${JSON.stringify(res)}`);
assert.deepEqual(count(s.ps), { white: 0, gold: 1 }, name);
checkInvariants(s.m);
s.m.dispose();
}
});
test('a promotion that returns equipment merges a returned pair (latent: no normal state holds a pair)', () => {
const { m, ps } = prep();
const op = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier === 1 && m.pool.has(c.chessId) && !m.bannedChess.includes(c.chessId)
&& m.gd.mergeCount(c.chessId) === 3 && !m.gd.placeableTokens(c.chessId).length).chessId;
const [a, b] = deploy(m, ps, [op, op]);
// the copies' equipment built directly: a pair never exists in play — every entry path merges it at once
a.items = [ps.newPiece('item', WHITE)];
b.items = [ps.newPiece('item', WHITE)];
ps.recompute();
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: stockChess(ps, op) }), { ok: true });
assert.ok([...ps.board.values()].some((p) => p.id === DATA.chess[op].goldenId), 'the elite formed');
assert.deepEqual(count(ps), { white: 0, gold: 1 }, '"干员晋级后已配发装备会回收至整备区" + the auto-merge');
checkInvariants(m);
});
test('random intents around 源石溶剂 (buy, equip / replace, sell, move, destroy) conserve W + 2·G and never leave a pair', () => {
let seed = 11;
const rnd = () => { seed = (seed * 1103515245 + 12345) >>> 0; return seed / 4294967296; };
const pick = (arr) => arr[Math.floor(rnd() * arr.length)];
const worth = (ps) => { const c = count(ps); return { ...c, e: c.white + 2 * c.gold }; };
let checked = 0;
for (let run = 0; run < 4; run++) {
const { m, ps } = prep(run + 3);
const ops = Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && c.tier === 1 && m.pool.has(c.chessId) && !m.bannedChess.includes(c.chessId)).slice(0, 2).map((c) => c.chessId);
for (let step = 0; step < 150; step++) {
ps.funds = 50;
const before = worth(ps);
const loose = [...ps.hand, ...ps.temp].filter(Boolean);
const chess = [...loose, ...ps.board.values()].filter((p) => p.kind === 'chess');
const items = loose.filter((p) => p.kind === 'item');
const r = rnd();
let msg = null, expect = 0, rep = null;
if (r < 0.35) { msg = { t: 'g.buy', slot: stockItem(ps) }; expect = 1; }
else if (r < 0.5) msg = { t: 'g.buy', slot: stockChess(ps, pick(ops)) };
else if (r < 0.8 && items.length && chess.length) {
const it = pick(items), tg = pick(chess);
rep = (tg.items || []).length >= 2 ? pick(tg.items) : null;
msg = { t: 'g.equip', itemUid: it.uid, targetUid: tg.uid, ...(rep ? { replaceUid: rep.uid } : {}) };
}
else if (r < 0.9 && chess.length) msg = { t: 'g.sell', uid: pick(chess).uid };
else if (items.length) { const it = pick(items); msg = { t: 'g.destroy', uid: it.uid }; expect = it.id === WHITE ? -1 : it.id === GOLD ? -2 : 0; }
if (!msg) continue;
const res = m.handle('p_0', msg);
if (!res.ok) continue;
// a replacement destroys the replaced item unless the equip merged instead (the replaced one is still owned)
if (rep && !ps.find(rep.uid)) expect = rep.id === WHITE ? -1 : rep.id === GOLD ? -2 : 0;
const after = worth(ps);
assert.equal(after.e - before.e, expect, `run ${run} step ${step} ${JSON.stringify(msg)}: ${JSON.stringify(before)} → ${JSON.stringify(after)}`);
assert.ok(after.white <= 1, `run ${run} step ${step}: an unmerged pair`);
checked++;
}
checkInvariants(m);
m.dispose();
}
assert.ok(checked > 150, `${checked} accepted intents`);
});