diff --git a/server/sim/content/items/meta.js b/server/sim/content/items/meta.js index df07541c..ea90ac93 100644 --- a/server/sim/content/items/meta.js +++ b/server/sim/content/items/meta.js @@ -119,17 +119,23 @@ export function registerMeta(registry) { // 画卷 — trap_copy_front_char: copy the operator in range (elite status included) with its equipment. A copied // normal item that completes a pair with an owned one merges at once and the golden stays in the hand (research 04 // addendum "两个同名道具(无论是否被装备)会自动合并…并自动返回整备区"); the built-in equipped that golden on the copy. + // A copy that completes a three-copy merge (GitHub #389, PR #390): the elite wears nothing — the promotion returns the + // copies' equipment to the hand (PRTS 帮助 "在失去该干员(…合并等)…时自动卸除") — and the copied items stay in the hand + // too (PRTS 画卷 备注 "获得的装备为未装备状态"), a copied normal item merging with the returned original. wrap(registry, 'chess_item_6_02_m', () => ({ onArt(ctx, ev) { const target = (ev.targets || []).find((p) => p && p.kind === 'chess'); if (!target) { ev.error = 'BAD_TARGET'; ev.detail = 'no operator in range'; return; } + // snapshot before the gain: a gain that completes a merge consumes the target and empties `target.items`, and an + // item merge detaches the original's copy from `target.items` while we iterate (the built-in's live loop then + // skipped the next item) + const itemIds = (target.items || []).map((it) => it.id); const copy = ctx.grantChess(target.id, { requirePool: false, source: 'item:chess_item_6_02_m' }); if (!copy) { ev.error = 'HAND_FULL'; return; } - // snapshot first: a merge detaches the original's copy of the item from `target.items` while we iterate - // (the built-in's live loop then skipped the next item) - for (const itemId of (target.items || []).map((it) => it.id)) { + const promoted = copy.id !== target.id; + for (const itemId of itemIds) { const got = ctx.grantItem(itemId, { source: 'item:chess_item_6_02_m' }); - const holder = got ? ctx.piece(copy.uid) : null; + const holder = got && !promoted ? ctx.piece(copy.uid) : null; if (got && got.id === itemId && holder && holder.kind === 'chess') ctx.equipDirect(got.uid, holder.uid); } }, diff --git a/test/match/scroll-copy-merge.test.js b/test/match/scroll-copy-merge.test.js new file mode 100644 index 00000000..ca4a597c --- /dev/null +++ b/test/match/scroll-copy-merge.test.js @@ -0,0 +1,113 @@ +// A Picture Scroll (画卷) gain can consume its own target in an immediate three-copy promotion (GitHub #389, PR #390). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeMatch, legalTileFor } from './harness.js'; + +const OP = 'chess_char_1_06_a'; // 刺玫, the operator of the report +const SCROLL = 'chess_item_6_02_m'; +const HAMMER = 'chess_item_1_01_e_a'; +const SHIELD = 'chess_item_1_02_e_a'; +const FILLER = 'chess_item_6_09_e_a'; // non-mergeable +const OK = { ok: true }; + +function prep(t, { copies = 2, golden = false, items = [HAMMER] } = {}) { + const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', seed: 8 }).start(); + t.after(() => h.m.dispose()); + h.toPrep(1, { band: 'band_dusk' }); + const m = h.m, ps = h.ps('p_0'); + assert.equal(m.gd.mergeCount(OP), 3); + const id = golden ? m.gd.goldenIdOf(OP) : OP; + const target = ps.acquireChess(id); + const [row, col] = legalTileFor(m, ps, id); + assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: target.uid, to: { area: 'board', row, col }, dir: 'RIGHT' }), OK); + for (let i = 1; i < copies; i++) ps.acquireChess(id); + for (const itemId of items) { + const item = ps.acquireItem(itemId); + assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: item.uid, targetUid: target.uid }), OK); + } + const art = ps.hand.find((p) => p?.id === SCROLL); + assert.ok(art, 'Dusk grants the scroll in round 1'); + h.invariants(); + const cast = () => m.handle('p_0', { t: 'g.art', itemUid: art.uid, row, col, dir: 'RIGHT' }); + return { h, m, ps, target, art, row, col, cast }; +} + +function itemIds(ps) { + return [...ps.hand, ...ps.temp, ...ps.board.values()].filter(Boolean) + .flatMap((p) => p.kind === 'item' ? [p.id] : (p.items || []).map((it) => it.id)).sort(); +} + +test('Picture Scroll: a third normal copy preserves the target equipment through promotion', (t) => { + const { h, m, ps, target, art, row, col, cast } = prep(t); + const funds = ps.funds; + assert.deepEqual(cast(), OK); + const elite = ps.board.get(`${row},${col}`); + assert.equal(elite.id, m.gd.goldenIdOf(OP)); + assert.equal(elite.dir, 'RIGHT'); + assert.equal(ps.find(target.uid), null, 'the original target was consumed'); + assert.equal(ps.find(art.uid), null, 'the scroll was consumed'); + assert.deepEqual(itemIds(ps), [m.gd.item(HAMMER).goldenId]); + assert.ok(ps.hand.some((p) => p?.id === m.gd.item(HAMMER).goldenId), 'the original and copied hammer merge into an advanced hammer in hand'); + assert.deepEqual(elite.items, []); + assert.equal(ps.stats.merges, 1); + assert.equal(ps.stats.itemMerges, 1); + assert.equal(ps.funds, funds); + h.invariants(); +}); + +for (const advanced of [false, true]) { + test(`Picture Scroll: promotion copies both ${advanced ? 'advanced' : 'normal'} equipped items, none of them worn by the elite`, (t) => { + const items = [HAMMER, SHIELD].map((id) => advanced ? id.replace(/_a$/, '_b') : id); + const { h, m, ps, row, col, cast } = prep(t, { items }); + assert.deepEqual(cast(), OK); + const elite = ps.board.get(`${row},${col}`); + const upgraded = items.map((id) => m.gd.item(id).goldenId || id).sort(); + // advanced items do not merge: the returned originals and the copies all wait in the hand ("获得的装备为未装备状态") + assert.deepEqual(itemIds(ps), advanced ? [...items, ...items].sort() : upgraded); + assert.deepEqual(elite.items, [], 'the promotion unequips, and the copies are not put back on the elite'); + assert.equal([...ps.hand, ...ps.temp].filter((p) => p?.kind === 'item').length, advanced ? 4 : 2); + h.invariants(); + }); +} + +for (const golden of [false, true]) { + test(`Picture Scroll: one ${golden ? 'elite' : 'normal'} target still copies its equipment without promotion`, (t) => { + const { h, m, ps, target, cast } = prep(t, { copies: 1, golden }); + assert.deepEqual(cast(), OK); + assert.equal(ps.find(target.uid)?.piece, target); + assert.equal(ps.allChess().length, 2); + assert.equal(ps.allChess().filter((p) => p.id === target.id).length, 2); + assert.deepEqual(itemIds(ps), [m.gd.item(HAMMER).goldenId]); + assert.equal(ps.stats.merges, 0); + h.invariants(); + }); +} + +test('Picture Scroll: a third copy without equipment still promotes normally', (t) => { + const { h, m, ps, row, col, cast } = prep(t, { items: [] }); + assert.deepEqual(cast(), OK); + assert.equal(ps.board.get(`${row},${col}`).id, m.gd.goldenIdOf(OP)); + assert.deepEqual(itemIds(ps), []); + h.invariants(); +}); + +test('Picture Scroll: a promotion at full hand and temp still copies the hammer', (t) => { + const { h, m, ps, cast } = prep(t); + while (ps.hand.some((p) => p == null) || ps.temp.some((p) => p == null)) assert.ok(ps.acquireItem(FILLER)); + h.invariants(); + assert.deepEqual(cast(), OK); + assert.deepEqual(itemIds(ps).filter((id) => id !== FILLER), [m.gd.item(HAMMER).goldenId]); + assert.equal(itemIds(ps).filter((id) => id === FILLER).length, 13); + h.invariants(); +}); + +test('Picture Scroll: a full hand and temp without promotion keeps the refused scroll and target equipment', (t) => { + const { h, ps, target, art, cast } = prep(t, { copies: 1 }); + while (ps.hand.some((p) => p == null) || ps.temp.some((p) => p == null)) assert.ok(ps.acquireItem(FILLER)); + const before = ps.privateView(); + assert.equal(cast().error, 'HAND_FULL'); + assert.ok(ps.find(art.uid)); + assert.deepEqual(target.items.map((p) => p.id), [HAMMER]); + assert.deepEqual(ps.privateView(), before); + h.invariants(); +});