feedback3 WH: an item gained as prep ends waits in hand and merges at the next prep start

This commit is contained in:
sganggs committed 2026-10-04 20:34:03 +08:00
1 parent c0cc1d6118
commit 9aaba7708c
7 files changed
+182 -5

No files matched your search

+1 -1
View File
@@ -431,7 +431,7 @@ otherwise locked (research 04 §2 / addendum: they leave the operator only on pr
refuses them (`BAD_TARGET 'equipped items are locked'`). A second copy of an equipped normal item merges into the golden refuses them (`BAD_TARGET 'equipped items are locked'`). A second copy of an equipped normal item merges into the golden
item in the hand. Every path that hands an item to the player (buy, reward, 机变, grants, equip, the equipment a sale, 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件同一初始 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`). 装备时…自动合并"), so a player never holds two identical mergeable normal items (`test/match/feedback1b-items.test.js`), except an item gained while 休整期结束 is dispatching (`onPrepEnd`, including a grant nested under it): it is stowed (hand, else temp) and merges at the next prep's start, and nothing already equipped is taken off for the fight. Hand and temp both full still destroys it with 「整备区已满,获得的装备已销毁」. [ASSUMED] every such grant, not only 维多利亚's hammer (owner's decision 2026-10-04). A buy, an onPrepStart grant and a grant at any other time still merge at once (`test/match/feedback3-prep-end-item.test.js`).
Built-ins (builtinMeta.js, overridable): 盟约之币 / 骑士储蓄罐 (random funds), 随身身份牌 (layers of the target's bonds), Built-ins (builtinMeta.js, overridable): 盟约之币 / 骑士储蓄罐 (random funds), 随身身份牌 (layers of the target's bonds),
紧急调度券 (take shop chess), 精打细算玩偶 (+funds each round), 简易通讯机 / 拟态物质 (same-bond chess), 见钱眼开玩偶 紧急调度券 (take shop chess), 精打细算玩偶 (+funds each round), 简易通讯机 / 拟态物质 (same-bond chess), 见钱眼开玩偶
+1 -1
View File
@@ -102,7 +102,7 @@
- **层数**:盟约通过各种效果叠加层数,层数越高效果越强(例如「炎」攻击力随层数提升、「远见」每 10 层给资金)。**层数整局保留,但只有盟约激活时才生效**,多数情况下也只有激活的盟约能叠层。每个盟约最多 **999 层**(与官方相同,取自官方客户端的常量):到 999 后再获得的层数直接作废,按层数发放的奖励(如「远见」的资金)也随之停止。作战中获得的层数在盟约栏里**实时**显示,作战结束后一直保留到结算(联防期间也不会退回去)。 - **层数**:盟约通过各种效果叠加层数,层数越高效果越强(例如「炎」攻击力随层数提升、「远见」每 10 层给资金)。**层数整局保留,但只有盟约激活时才生效**,多数情况下也只有激活的盟约能叠层。每个盟约最多 **999 层**(与官方相同,取自官方客户端的常量):到 999 后再获得的层数直接作废,按层数发放的奖励(如「远见」的资金)也随之停止。作战中获得的层数在盟约栏里**实时**显示,作战结束后一直保留到结算(联防期间也不会退回去)。
- **加成怎么叠**:盟约、策略、装备、机变卡和特质给的「攻击力 / 防御力 / 生命值 +X%」与技能的「攻击力 +X%」**相加**后再乘到属性上(官方:直接乘算),例如精准 +130%、独行 +60%、一件 +40% 的装备合计 +230%,而不是 ×2.3×1.6×1.4。「伤害提升至 X%」「受到的伤害 +X%」(维多利亚、谢拉格、奥术、脆弱等)这类伤害倍率之间仍然相乘;但同名效果只算一个——同一战场两名玩家都开了「奥术」时,敌人身上只挂一层奥术易伤,取两人中较高的那一个(不再相乘);两名「灵知」的坚冰、两名「莱恩哈特」/「缄默德克萨斯」的法抗削减也一样只算最强的一个。「坚守」的反伤计入被打的那名成员(个人对领袖伤害统计里能看到)。 - **加成怎么叠**:盟约、策略、装备、机变卡和特质给的「攻击力 / 防御力 / 生命值 +X%」与技能的「攻击力 +X%」**相加**后再乘到属性上(官方:直接乘算),例如精准 +130%、独行 +60%、一件 +40% 的装备合计 +230%,而不是 ×2.3×1.6×1.4。「伤害提升至 X%」「受到的伤害 +X%」(维多利亚、谢拉格、奥术、脆弱等)这类伤害倍率之间仍然相乘;但同名效果只算一个——同一战场两名玩家都开了「奥术」时,敌人身上只挂一层奥术易伤,取两人中较高的那一个(不再相乘);两名「灵知」的坚冰、两名「莱恩哈特」/「缄默德克萨斯」的法抗削减也一样只算最强的一个。「坚守」的反伤计入被打的那名成员(个人对领袖伤害统计里能看到)。
- 每局会随机**禁用**若干盟约(标准模拟较少),它们的干员不会出现在商店;开局的「确认本局信息」会列出,选择策略时(「查看禁用盟约与干员」)和对局中(左上角 🔍)也能再查看。 - 每局会随机**禁用**若干盟约(标准模拟较少),它们的干员不会出现在商店;开局的「确认本局信息」会列出,选择策略时(「查看禁用盟约与干员」)和对局中(左上角 🔍)也能再查看。
- 两件同名装备会合成一件更强的装备;特定的装备组合可以为干员赋予额外的盟约效果:「变形同构体」与另一件盟约装备(如维式重锤)一同携带时,携带者视为该装备对应盟约的成员。对应关系见变形同构体详情卡的「天赋」一栏(本局禁用的盟约会标出;装在干员身上时,正在生效的那一组会高亮)。 - 两件同名装备会合成一件更强的装备。休整期结束时才到手的那一件(维多利亚每 25 层发的维式重锤是这种情况)先留在整备区,已经戴在干员身上的不会为了这场战斗被卸下,合成出的进阶装备在**下一休整期开始**时出现;其他时候拿到的同名装备仍立刻合成。整备区和临时整备区都满时,这件新装备照旧销毁(「整备区已满,获得的装备已销毁」),不会换进装备栏。特定的装备组合可以为干员赋予额外的盟约效果:「变形同构体」与另一件盟约装备(如维式重锤)一同携带时,携带者视为该装备对应盟约的成员。对应关系见变形同构体详情卡的「天赋」一栏(本局禁用的盟约会标出;装在干员身上时,正在生效的那一组会高亮)。
## 7. 最终攻势与隐秘核心 ## 7. 最终攻势与隐秘核心
+5
View File
@@ -1658,6 +1658,11 @@ export class Match {
const alive = this.alivePlayers(); const alive = this.alivePlayers();
for (const ps of alive) { for (const ps of alive) {
ps.ready = false; ps.ready = false;
// Items gained as the previous prep ended waited unmerged (acquireItem deferMerge). Merge them now, before
// this prep's onPrepStart grants and before the player acts — not in endPrep, which runs in the same prep
// that granted them and would take an equipped copy off for the fight about to start.
ps.checkItemMerges();
ps.recompute();
this.dispatch(ps, 'onPrepStart', { round: this.round }); this.dispatch(ps, 'onPrepStart', { round: this.round });
ps.recompute(); ps.recompute();
} }
+6 -2
View File
@@ -712,12 +712,16 @@ export class PlayerState {
/** /**
* Acquire an item (buy, supply card, grant). Merges with an identical normal copy (hand/temp/equipped) into the * Acquire an item (buy, supply card, grant). Merges with an identical normal copy (hand/temp/equipped) into the
* golden item (to the hand). Returns the owned piece or null. * golden item (to the hand). Returns the owned piece or null.
* `deferMerge` (effectsMeta, while onPrepEnd is on the stack): stow it — hand, else temp — and do not merge in this
* call, even when an identical normal copy is already owned. The next prep's start runs checkItemMerges. Nothing
* already equipped is taken off for the fight about to start. Hand and temp both full keeps the 「整备区已满,获得的装备已销毁」
* outcome. [ASSUMED] every item granted at 休整期结束, not only 维多利亚's 战栗维式重锤 (owner's decision 2026-10-04).
*/ */
acquireItem(itemId, { source = 'grant', toTemp = false, silent = false } = {}) { acquireItem(itemId, { source = 'grant', toTemp = false, silent = false, deferMerge = false } = {}) {
const rec = this.gd.item(itemId); const rec = this.gd.item(itemId);
if (!rec) return null; if (!rec) return null;
let piece = this.newPiece('item', itemId); let piece = this.newPiece('item', itemId);
if (this.completesItemMerge(itemId)) { if (!deferMerge && this.completesItemMerge(itemId)) {
piece = this._mergeItem(itemId, piece); piece = this._mergeItem(itemId, piece);
if (!piece) return null; if (!piece) return null;
} else if (!this.stow(piece, { allowTemp: true, toTemp })) { } else if (!this.stow(piece, { allowTemp: true, toTemp })) {
+11 -1
View File
@@ -196,6 +196,11 @@ export class EffectDispatcher {
return ev; return ev;
} }
this.depth++; this.depth++;
// An item granted while 休整期结束 is on the stack (this hook, or onGain re-entering it — 维多利亚 pays the next
// milestone from the grant's onGain) is stowed and not merged until the next prep start. [ASSUMED] every such
// item, not only the 战栗维式重锤 (owner's decision 2026-10-04: "an item that arrives at 休整期结束").
const deferItems = hook === 'onPrepEnd';
if (deferItems) ps._deferItemMerge = (ps._deferItemMerge || 0) + 1;
try { try {
const reg = this.registry; const reg = this.registry;
// 0. onPrice: the priced chess's own 特质 first — 购买价格为N defines the price every other modifier acts on // 0. onPrice: the priced chess's own 特质 first — 购买价格为N defines the price every other modifier acts on
@@ -236,6 +241,7 @@ export class EffectDispatcher {
if (h) this._call(ps, ref.key, h, hook, { kind: 'effect', key: ref.key, ref }, ev); if (h) this._call(ps, ref.key, h, hook, { kind: 'effect', key: ref.key, ref }, ev);
} }
} finally { } finally {
if (deferItems) ps._deferItemMerge--;
this.depth--; this.depth--;
} }
return ev; return ev;
@@ -511,7 +517,11 @@ export function makeCtx(m, ps, source, hook, ev = null) {
}, },
grantItem: (itemId, opts = {}) => { grantItem: (itemId, opts = {}) => {
if (!ps.alive || !gd.item(itemId)) return null; if (!ps.alive || !gd.item(itemId)) return null;
const p = ps.acquireItem(itemId, { source: opts.source || source.key || 'effect', toTemp: !!opts.toTemp }); const p = ps.acquireItem(itemId, {
source: opts.source || source.key || 'effect',
toTemp: !!opts.toTemp,
deferMerge: ps._deferItemMerge > 0,
});
return p ? view(p) : null; return p ? view(p) : null;
}, },
/** Random chess id from the shared pool (copy-weighted). opts: { maxTier, tier, bond, filter(id) } */ /** Random chess id from the shared pool (copy-weighted). opts: { maxTier, tier, bond, filter(id) } */
+2
View File
@@ -607,6 +607,8 @@ export function payHammers(ctx) {
ctx.setCounter(VICTORIA_COUNTER, paid + 1); ctx.setCounter(VICTORIA_COUNTER, paid + 1);
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const id = ctx.rollItem({ pool: p.pool }); const id = ctx.rollItem({ pool: p.pool });
// onPrepEnd: grantItem stows this and does not merge until the next prep start, so a second copy does not
// take an equipped hammer off for the fight (effectsMeta _deferItemMerge). [ASSUMED] every item that hook grants.
if (id && ctx.grantItem(id, { source: 'bond:victoriaShip' })) granted++; if (id && ctx.grantItem(id, { source: 'bond:victoriaShip' })) granted++;
} }
} }
+156
View File
@@ -0,0 +1,156 @@
// An item that arrives at 休整期结束 waits in the hand and merges at the next prep start (owner's decision 2026-10-04).
// 维多利亚 pays 战栗维式重锤 on onPrepEnd; merging in that call used to detach the equipped copy before the fight.
// [ASSUMED] every grant while that dispatch is on the stack, not only this hammer.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRegistry } from '../../server/match/effectsMeta.js';
import { registerMeta as coreRegisterMeta } from '../../server/sim/content/bonds/core.js';
import { makeMatch, give, giveItem, legalTileFor } from './harness.js';
import { FakeBattle } from './fakeBattle.js';
import { tileKey } from '../../server/match/board.js';
const HAMMER = 'chess_item_2_03_e_a'; // 战栗维式重锤
const GOLDEN = 'chess_item_2_03_e_b';
const FILLER = 'chess_item_1_01_e_a';
const OPS = ['chess_char_1_06_a', 'chess_char_2_05_a', 'chess_char_3_08_a'];
function copies(ps, id) {
let n = 0;
const see = (p) => { if (p && p.kind === 'item' && p.id === id) n++; };
for (const p of ps.hand) see(p);
for (const p of ps.temp) see(p);
for (const c of ps.board.values()) if (c && c.kind === 'chess') for (const it of c.items || []) see(it);
return n;
}
function fresh() {
const reg = createRegistry({ content: false });
coreRegisterMeta(reg);
const h = makeMatch({ mode: 'solo', seed: 5, registry: reg, fake: true }).start();
h.toPrep(1);
const orig = h.m.rollItemId.bind(h.m);
h.m.rollItemId = (opts = {}) => (opts.pool === 'pool_equip_vict' ? HAMMER : orig(opts));
return h;
}
/** Bond inactive while the layers land, then three 维多利亚 operators. One wears the hammer when `equip`. */
function arm(h, { layers, equip = true, fill = false } = {}) {
const ps = h.ps('p_0');
assert.equal(ps.setReady(false).ok, true);
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean), ...ps.temp.filter(Boolean)]) {
if (p && p.kind === 'chess') ps.returnCopies(p);
}
ps.board.clear();
ps.hand.fill(null);
ps.temp.fill(null);
ps._tempDue.clear();
ps.recompute();
assert.equal(ps.bonds.victoriaShip?.active ?? false, false);
if (layers > 0) assert.equal(ps.addLayers('victoriaShip', layers), layers);
assert.equal(ps.counters['bond:victoria:hammers'] || 0, 0, 'inactive: the milestones are owed, not paid');
const used = new Set();
const ops = OPS.map((id) => {
const t = legalTileFor(h.m, ps, id, used);
used.add(tileKey(t[0], t[1]));
return give(h.m, ps, id, 'board', t);
});
assert.equal(ps.bonds.victoriaShip.active, true);
let equipped = null;
if (equip) {
equipped = giveItem(h.m, ps, HAMMER);
const eq = ps.equip(equipped.uid, ops[0].uid);
assert.equal(eq.ok, true, eq.error || eq.detail);
assert.equal(ops[0].items[0].uid, equipped.uid);
}
if (fill) {
for (let i = 0; i < ps.hand.length; i++) if (!ps.hand[i]) giveItem(h.m, ps, FILLER, 'hand', i);
for (let i = 0; i < ps.temp.length; i++) if (!ps.temp[i]) giveItem(h.m, ps, FILLER, 'temp', i);
assert.ok(ps.hand.every(Boolean) && ps.temp.every(Boolean));
}
return { ps, carrier: ops[0], equipped };
}
test('维多利亚 pays a 战栗维式重锤 at prep end: the equipped copy stays for the fight, and they merge at the next prep', () => {
const h = fresh();
const { ps, carrier, equipped } = arm(h, { layers: 25 });
assert.equal(ps.setReady(true).ok, true);
h.m.endPrep();
assert.equal(h.m.phase, 'COMBAT');
assert.equal(carrier.items.length, 1);
assert.equal(carrier.items[0].uid, equipped.uid, 'the equipped hammer was not taken off');
assert.equal(carrier.items[0].id, HAMMER);
assert.equal(copies(ps, HAMMER), 2, 'the new copy is in hand or temp');
assert.equal(copies(ps, GOLDEN), 0, 'no golden yet');
const input = FakeBattle.instances.at(-1).opts.players[0];
const unit = input.units.find((u) => u.uid === carrier.uid);
assert.ok(unit && unit.items.includes(HAMMER), 'the fight is built with the hammer still equipped');
h.toPrep(2);
assert.equal(h.m.phase, 'PREP');
assert.equal(copies(ps, GOLDEN), 1, 'the pair merged when the next prep started');
assert.equal(copies(ps, HAMMER), 0);
h.invariants();
h.m.dispose();
});
test('two milestones owed at prep end both wait: no golden before the fight; the next prep merges the hand copies and leaves the equipped one', () => {
const h = fresh();
const { ps, carrier, equipped } = arm(h, { layers: 50 });
assert.equal(ps.setReady(true).ok, true);
h.m.endPrep();
assert.equal(carrier.items[0].uid, equipped.uid);
assert.equal(copies(ps, HAMMER), 3, 'equipped + two stowed, the nested onGain grant included');
assert.equal(copies(ps, GOLDEN), 0);
h.toPrep(2);
assert.equal(copies(ps, GOLDEN), 1);
assert.equal(copies(ps, HAMMER), 1);
assert.equal(carrier.items[0] && carrier.items[0].uid, equipped.uid, 'the hand copies merged with each other; the equipped one stayed');
h.m.dispose();
});
test('hand and temp full at prep end: the new hammer is destroyed, the equipped one stays, and the next prep does not put a golden in the slot', () => {
const h = fresh();
const { ps, carrier, equipped } = arm(h, { layers: 50, fill: true });
h.m.endPrep();
assert.equal(carrier.items.length, 1);
assert.equal(carrier.items[0].uid, equipped.uid);
assert.equal(carrier.items[0].id, HAMMER);
assert.equal(copies(ps, GOLDEN), 0);
assert.equal(copies(ps, HAMMER), 1);
const toasts = h.sent.filter(([, msg]) => msg.t === 'm.toast').map(([, msg]) => msg.text);
assert.equal(toasts.filter((t) => t === '整备区已满,获得的装备已销毁').length, 2);
const input = FakeBattle.instances.at(-1).opts.players[0];
assert.ok(input.units.find((u) => u.uid === carrier.uid).items.includes(HAMMER));
h.toPrep(2);
assert.equal(carrier.items[0].uid, equipped.uid, 'nothing was merged into the slot');
assert.equal(copies(ps, GOLDEN), 0);
assert.equal(copies(ps, HAMMER), 1);
h.m.dispose();
});
test('a buy during prep still merges at once', () => {
const h = fresh();
const ps = h.ps('p_0');
assert.equal(ps.setReady(false).ok, true);
giveItem(h.m, ps, HAMMER);
ps.funds = 50;
ps.shop.slots[0] = { kind: 'item', id: HAMMER, price: 1, basePrice: 1, sold: false };
assert.equal(ps.buy(0).ok, true);
assert.equal(h.m.phase, 'PREP');
assert.equal(copies(ps, HAMMER), 0);
assert.equal(copies(ps, GOLDEN), 1);
h.m.dispose();
});
test('a grant after the prep has ended (onLayers during combat) still merges at once', () => {
const h = fresh();
const { ps, carrier, equipped } = arm(h, { layers: 0 });
assert.equal(ps.setReady(true).ok, true);
h.m.endPrep();
assert.equal(h.m.phase, 'COMBAT');
assert.equal(carrier.items[0].uid, equipped.uid);
assert.equal(ps.addLayers('victoriaShip', 25), 25);
assert.equal(copies(ps, HAMMER), 0, 'the equipped copy was merged immediately');
assert.equal(copies(ps, GOLDEN), 1);
assert.equal(h.m.phase, 'COMBAT');
h.m.dispose();
});