feedback5: merge R19F — 拉普兰德 / 赫默 layers in the 整备区 are official (拉普兰德: only to an active 叙拉古, once per round per copy; 赫默: deployed only, character_target_inboard) — pin tests, PLAYING §6, CHANGELOG 核实后不是问题; golden unchanged

This commit is contained in:
sganggs committed 2026-10-07 12:40:00 +08:00
commit 31cdd9d072
4 files changed
+246

No files matched your search

+1
View File
@@ -128,6 +128,7 @@
- 本局禁用的盟约在每个难度都一样,只移除全部盟约都被禁用的干员;炎佑也会受到源石溶剂「每秒受到 60 真实伤害」的效果(PRTS 备注);两名变形同构体变成的阿戈尔照常吞噬(社区反馈)。
- 伺夜的狼群最多 3 只狼影,吟游者的特性可以回复战术家召唤物和不屈者的生命,荒芜拉普兰德三技能期间由无人机攻击、本人不普攻,叙拉古的恐惧概率,都和官方一致;突袭的玛恩纳攻击范围内有敌人时不突袭,是盟约原文的规则(社区反馈)。
- 以下没有复现,需要具体的对局(干员、地图、回合或录像):野鬃把敌人顶出地图、锏拉动领袖、昆图斯占地变小、炼金单元没有伤害、归溟幽灵鲨被吞噬时进入再部署、炎佑被空中单位阻挡、选 Pith 策略时联防里阿戈尔的加成被清空、满血时源石溶剂不生效(社区反馈)。
- 整备区里的拉普兰德照样叠【叙拉古】层数,但叙拉古要已经激活(只数作战区里不同的叙拉古干员,整备区里的她不算在内),而且每回合只看她经历的第一次主动刷新,那次没激活就要等下一回合;临时整备区不算整备区。赫默的特质没有「此干员在整备区时也有效」,和官方数据一样只在作战区生效(社区反馈;文档补充了说明)。
## 0.1.4 — 2026-10-06
+1
View File
@@ -112,6 +112,7 @@
- **调和**:激活时,场上已有成员的核心盟约人数各 +1(只加 1 人,不论有几名调和干员)。盟约详情会写「含调和 +1」,成员列表上方列出带来这 1 人的调和干员(如缪尔赛思)。
- **炎佑**:场上 6 名以上「炎」干员时召唤炎佑(9 名时两只)。它追踪全场仇恨最高的敌人并悬停在其上方,技能「祛恶之焰」锁定 1 个目标**持续喷火**(最长 20 秒,每秒对目标周围的敌人造成法术伤害并附带灼燃,喷火时不普攻,目标死亡或被沉默时结束);场上暂时没有敌人时停在原地等待。它持有官方的「孤立」:医疗干员不会治疗它,友方的增益、光环和天赋也不会选中它(敌人照常可以攻击它)。
- **层数**:盟约通过各种效果叠加层数,层数越高效果越强(例如「炎」攻击力随层数提升、「远见」每 10 层给资金)。**层数整局保留,但只有盟约激活时才生效**,多数情况下也只有激活的盟约能叠层。每个盟约最多 **999 层**(与官方相同,取自官方客户端的常量):到 999 后再获得的层数直接作废,按层数发放的奖励(如「远见」的资金)也随之停止。作战中获得的层数在盟约栏里**实时**显示,作战结束后一直保留到结算(联防期间也不会退回去)。「调和」「协防干员」「独行」「绝技」与官方一样**不显示层数**(盟约栏、详情和结算卡片都不显示),但叠层的特质 / 策略 / 装备照样给它们加层(PRTS 盟约记录:「部分盟约不会显示叠加层数,但是叠加层数的特质/策略/装备等效果仍然对其生效」),这些层数也计入激活层数总和;它们的效果本身与层数无关。
- **特质在哪里生效**:「进入休整期时」「休整期结束时」「刷新时」的特质(详情卡的第一项)只在干员部署在作战区时生效;文字里写着「此干员在整备区时也有效」的拉普兰德、调香师、波登可,以及激活【炎】/【投资人】时的琳琅诗怀雅,在整备区里也生效(临时整备区不算),与官方数据一致;「获得时」「售出时」的特质在获得、售出的那一刻生效,不论干员在哪里。「远见」「奇迹」「投资人」的「整备区的…干员也可用于激活盟约」只是把整备区的成员算进人数,不会让赫默等干员的特质在整备区生效。写着「使已激活的…」的特质要求盟约当时已经激活:例如「叙拉古」只数作战区里不同的叙拉古干员,整备区里的拉普兰德不算在这 3 名里;拉普兰德每回合只在她经历的第一次主动刷新时叠层(免费刷新也算),那次刷新时叙拉古没有激活,这一回合就不会再叠。
- **加成怎么叠**:盟约、策略、装备、机变卡和特质给的「攻击力 / 防御力 / 生命值 +X%」与技能的「攻击力 +X%」**相加**后再乘到属性上(官方:直接乘算),例如精准 +130%、独行 +60%、一件 +40% 的装备合计 +230%,而不是 ×2.3×1.6×1.4。「伤害提升至 X%」「受到的伤害 +X%」(维多利亚、谢拉格、奥术、脆弱等)这类伤害倍率之间仍然相乘;但同名效果只算一个——同一战场两名玩家都开了「奥术」时,敌人身上只挂一层奥术易伤,取两人中较高的那一个(不再相乘);两名「灵知」的坚冰、两名「莱恩哈特」/「缄默德克萨斯」的法抗削减也一样只算最强的一个。「坚守」的反伤计入被打的那名成员(个人对领袖伤害统计里能看到)。
- 每局会随机**禁用**若干盟约(标准模拟较少),它们的干员不会出现在商店;开局的「确认本局信息」会列出,选择策略时(「查看禁用盟约与干员」)和对局中(左上角 🔍)也能再查看。
- 两件同名装备会合成一件更强的装备。休整期结束时才到手的那一件(维多利亚每 25 层发的维式重锤是这种情况)先留在整备区,已经戴在干员身上的不会为了这场战斗被卸下,合成出的进阶装备在**下一休整期开始**时出现;其他时候拿到的同名装备仍立刻合成。整备区和临时整备区都满时,这件新装备照旧销毁(「整备区已满,获得的装备已销毁」),不会换进装备栏。特定的装备组合可以为干员赋予额外的盟约效果:「变形同构体」与另一件盟约装备(如维式重锤)一同携带时,携带者视为该装备对应盟约的成员。对应关系见变形同构体详情卡的「天赋」一栏(本局禁用的盟约会标出;装在干员身上时,正在生效的那一组会高亮)。
+33
View File
@@ -2027,3 +2027,36 @@ here, each reproduced first; the rest were answered (not a bug, already fixed in
the skill ended —; the battle that ends while she waits passes on both), `test/render/fxproj.test.js` +1 (a held lock
stays while the shooter's skill bit is on, then LOCK_T after; an unheld one times out — fails on the parent's
renderer); `test/content/kits_t6.test.js`, `kits_alt_t6.test.js`, `test/sim/feedback3-ammo-bar.test.js` pass unchanged.
#### 25.22.7 「小拉和赫默有概率在备战区叠不上叙拉古和远见」 (community report, relayed by the owner on 2026-10-07): not a bug — 拉普兰德 stacks from the 整备区 when 【叙拉古】 is active, 赫默's trait works only deployed (official data) — no code change; `docs/PLAYING.md` §6
- **Seen**: 「有的时候在备战区的干员不能100%叠层…小拉和赫默有概率在备战区叠不上叙拉古和远见」. 备战区 is the players' word
for the 整备区 (community guides: 「远见奇迹干员不需要了就放备战区」). Reproduced through the match paths: a matrix of 26
cases (the 整备区, the board, the 临时整备区; bought before / after the refresh, two copies, an elite merged this round, a
full hand, a 机变 round, a pending reward, the shop frozen, a free refresh, a reconnect, a bot), and 115 whole matches
(60 solo, 40 co-op, 15 co-op with client-run combat; a random human and the bots) checked against an oracle written
from the official text: 11 951 refreshes and 3 640 round starts, no difference. Every case without layers is one of
four rules, nothing random: (1) 【叙拉古】 is inactive — it counts different 叙拉古 deployed, and 拉普兰德 on the bench is
not one of the three (普罗旺斯 + 德克萨斯 deployed, her in the 整备区: 2/3); (2) her trait fires on the first manual
refresh she sees each round, so a refresh made while 叙拉古 is inactive spends it — deploying the third 叙拉古 and
refreshing again adds nothing until the next round (in the random matches 440 of the 675 first refreshes a bench
拉普兰德 saw were of this kind, the other 235 stacked); (3) in the 临时整备区 she neither fires nor counts, and fires on
her first refresh once a free slot pulls her into the 整备区; (4) 赫默 in the 整备区 never fires, 远见 active or not
(204 such round starts).
- **Official**: garrison_123 「<刷新时>若为本回合首次主动刷新,使已激活的【叙拉古】层数+4,此干员在整备区时也有效」 (elite +8, no
condition key); 叙拉古 activeCondition BOARD (「在场」). garrison_80 「<进入休整期时>使自身已激活的盟约层数+2」 (elite +4) has
no such clause, and its blackboard carries conditionkey `character_target_inboard`: deployed only. In the act2autochess
garrison table every 进入休整期时 / 休整期结束时 / 刷新时 trait that works in the 整备区 says so and has no condition key —
拉普兰德 123, 调香师 81, 波登可 102, 琳琅诗怀雅 98 (「若已激活【炎】/【投资人】此特质在整备区也有效」); every other one is
`character_target_inboard`, or a row / tile-in-front condition only a deployed operator meets. 远见's
「整备区的【远见】干员也可用于激活盟约」 counts bench members for the bond; it moves no trait to the bench. The in-game tips
name the overflow 「临时整备区」 apart from the 整备区 (「处于临时整备区的调度资源,在进入下一回合后会自动销毁」).
- **Now**: unchanged. Prep traits run for deployed operators and, unless `character_target_inboard`, for the 10 整备区
slots (`effectsMeta.js _garrisons`), never for the 临时整备区 (§21.1, like the 远见 / 奇迹 / 投资人 count). PLAYING.md §6
now says where a 特质 works, that 「使已激活的…」 needs the bond active, and what 拉普兰德's refresh needs.
- [ASSUMED] (unchanged since §21.1): a refresh made while 叙拉古 is inactive spends her trait for the round (the literal
「若为本回合首次主动刷新」, counted per copy); the 临时整备区 is not the 整备区; a free refresh is a manual refresh. Open for
the owner: fire on her first refresh with 叙拉古 active instead — no source either way.
- Golden: unchanged (no code change). Tests: `test/match/feedback5-bench-traits.test.js` (6 pins: the official data rule;
+4 / +8 in the 整备区 with the hand full, the shop frozen, a free refresh, a reward pending, a reconnect, in a 机变 round,
for a bot; the inactive bond and the spent refresh; the 临时整备区; a copy bought after the first refresh and 普罗旺斯's
free refresh; 赫默 deployed vs in the 整备区).
+211
View File
@@ -0,0 +1,211 @@
// Community report relayed by the owner (2026-10-07): 「有的时候在备战区的干员不能100%叠层…小拉和赫默有概率在备战区叠不上叙拉古
// 和远见」 — 拉普兰德 (小拉, Ⅱ) in the 整备区 sometimes adds no 【叙拉古】 layers, 赫默 (Ⅱ) no 【远见】 layers. Reproduced over
// whole matches (scratchpad notes, R19F): every non-firing case is a rule of the official data, nothing random —
// * 拉普兰德 garrison_123 「<刷新时>若为本回合首次主动刷新,使已激活的【叙拉古】层数+4,此干员在整备区时也有效」: she works in
// the 整备区, but 【叙拉古】 must be active (activeCondition BOARD: 3 different 叙拉古 deployed — her bench copy is not one
// of them), and her trait fires on the first manual refresh she sees this round: a refresh made while 叙拉古 is
// inactive spends it. The 临时整备区 is not the 整备区 (as for every 整备区 rule): she fires once she is in it.
// * 赫默 garrison_80 「<进入休整期时>使自身已激活的盟约层数+2」 has no 「此干员在整备区时也有效」 and carries the official
// blackboard conditionkey character_target_inboard: deployed only. 【远见】's 「整备区的【远见】干员也可用于激活盟约」 counts
// bench members for the bond, it does not move traits to the bench.
// Through the real match paths (g.refresh / g.buy / g.move, the round loop's ROUND_START dispatch). Pins; no change.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeMatch, give, legalTileFor, checkInvariants, chessOfTier, DATA } from './harness.js';
import { createRegistry } from '../../server/match/effectsMeta.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'; // 普罗旺斯 (Ⅰ, 叙拉古; <获得时>获得1次免费刷新)
const TEXAS = 'chess_char_1_08_a'; // 德克萨斯 (Ⅰ, 叙拉古)
const SIYE = 'chess_char_3_18_a'; // 伺夜 (Ⅲ, 叙拉古)
const SILENCE = 'chess_char_2_02_a'; // 赫默 (Ⅱ, 远见) — garrison_80_a
const SILENCE_B = 'chess_char_2_02_b';
const SWIRE = 'chess_char_3_03_a'; // 诗怀雅 (Ⅲ, 远见)
const FILLER = 'chess_char_1_01_a';
/** distinct fillers for a full 整备区: no 叙拉古 / 远见, no prep-side 特质 (one copy each, so nothing merges) */
const FILLERS = [1, 2].flatMap((t) => chessOfTier(t, (c) => !c.bonds.includes('siracusaShip') && !c.bonds.includes('visiShip')
&& (c.garrisonIds || []).every((g) => DATA.garrisons[g].eventType === 'IN_BATTLE')));
const LAP_KEY = 'garrison:SERVER_GAIN_BOND_LAYER_BY_REFRESH_CNT';
const SIL_KEY = 'garrison:SERVER_ADD_BOND_CHESS_ALL';
function setup({ mode = 'solo', bots = 0, round = 1, pid = 'p_0', seed = 70 } = {}) {
const h = makeMatch({ mode, bots, seed, registry: REG, fake: true }).start();
h.toPrep(round);
const m = h.m;
const ps = h.ps(pid);
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.ready = false;
ps.recompute();
/** layers each trait added (the dispatcher's reason = the registry key) */
const got = { lap: 0, sil: 0 };
const addLayers = ps.addLayers.bind(ps);
ps.addLayers = (b, n, o = {}) => {
const a = addLayers(b, n, o);
if (o.reason === LAP_KEY && b === 'siracusaShip') got.lap += a;
if (o.reason === SIL_KEY && b === 'visiShip') got.sil += a;
return a;
};
const refresh = () => assert.deepEqual(m.handle(pid, { t: 'g.refresh' }), OK);
const deploy = (id) => give(m, ps, id, 'board', legalTileFor(m, ps, id));
const place = (piece) => {
const [row, col] = legalTileFor(m, ps, piece.id);
assert.deepEqual(m.handle(pid, { t: 'g.move', uid: piece.uid, to: { area: 'board', row, col }, dir: 'RIGHT' }), OK);
};
const buy = (id) => {
ps.shop.slots[0] = { 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(pid, { t: 'g.buy', slot: 0 }), OK, `buy ${id}`);
return ps.allChess().find((p) => !before.has(p.uid));
};
const fillHand = () => { let i = 0; while (ps.hand.some((x) => x == null)) give(m, ps, FILLERS[i++], 'hand'); };
const nextRound = () => { ps.ready = false; h.toPrep(m.round + 1); ps.funds = 50; };
const siracusa = () => ({ active: !!ps.bonds.siracusaShip?.active, count: ps.bonds.siracusaShip?.count ?? 0 });
return { h, m, ps, got, refresh, deploy, place, buy, fillHand, nextRound, siracusa };
}
test('official data: a 进入休整期时 / 休整期结束时 / 刷新时 特质 works in the 整备区 exactly when its text says so', () => {
const PREP = new Set(['SERVER_PREP_START', 'SERVER_PREP_FIN', 'SERVER_REFRESH_SHOP']);
const bench = [];
for (const g of Object.values(DATA.garrisons)) {
if (!PREP.has(g.eventType)) continue;
const key = g.bbStr && g.bbStr.conditionkey;
if (/整备区时也有效|整备区也有效/.test(g.desc)) {
assert.equal(key, undefined, `${g.garrisonId}: a bench trait carries no condition key`);
bench.push(g.garrisonId);
} else if (!/^身前一格干员若为/.test(g.desc)) {
// the rest: deployed only (character_target_inboard), or a row condition that only a deployed operator can meet;
// the two copy traits (白面鸮 / 塞雷娅 "身前一格…") read the tile in front and are positional by nature
assert.ok(key === 'character_target_inboard' || key === 'character_same_row', `${g.garrisonId}: ${key} — ${g.desc}`);
}
}
// 拉普兰德 123, 调香师 81, 波登可 102, 琳琅诗怀雅 98 (若已激活【炎】/【投资人】) — normal and elite records
assert.deepEqual(bench.sort(), ['garrison_102_a', 'garrison_102_b', 'garrison_123_a', 'garrison_123_b', 'garrison_81_a', 'garrison_81_b', 'garrison_98_a', 'garrison_98_b']);
for (const id of ['garrison_80_a', 'garrison_80_b']) {
const g = DATA.garrisons[id];
assert.equal(g.bbStr.conditionkey, 'character_target_inboard', `${id} (赫默): deployed only`);
assert.doesNotMatch(g.desc, /整备区/);
}
assert.equal(DATA.bonds.siracusaShip.countMode, 'BOARD', '叙拉古 counts deployed operators only');
assert.equal(DATA.bonds.visiShip.countMode, 'BOARD_AND_DECK', '远见 also counts the 整备区');
});
test('拉普兰德 in the 整备区 with 3 different 叙拉古 deployed: +4 on her first manual refresh (+8 elite), whatever else holds', () => {
const cases = {
plain: () => {},
'hand full (10/10)': (s) => s.fillHand(),
'shop frozen': (s) => assert.deepEqual(s.m.handle('p_0', { t: 'g.freeze' }), OK),
'a free refresh': (s) => { s.ps.shop.freeRefreshes = 1; },
'a reward offer pending': (s) => { s.ps.offers.push({ source: 'merge', kind: 'chess', ids: [FILLER], tier: 2 }); },
'a reconnect': (s) => { s.m.onDisconnect('p_0'); s.m.onReconnect('p_0'); },
};
for (const [name, before] of Object.entries(cases)) {
for (const [id, n] of [[LAP, 4], [LAP_B, 8]]) {
const s = setup();
for (const x of [PROVENCE, TEXAS, SIYE]) s.deploy(x);
give(s.m, s.ps, id, 'hand');
before(s);
assert.deepEqual(s.siracusa(), { active: true, count: 3 }, name);
s.refresh();
assert.equal(s.got.lap, n, `${name} ${id}`);
s.refresh();
assert.equal(s.got.lap, n, `${name}: once per round`);
s.m.dispose();
}
}
// a 机变 round (solo R6) and a bot seat (its own ps.refresh, bot.js) work the same
const r6 = setup({ round: 6 });
for (const x of [PROVENCE, TEXAS, SIYE]) r6.deploy(x);
give(r6.m, r6.ps, LAP, 'hand');
r6.refresh();
assert.equal(r6.got.lap, 4, 'a 机变 round');
r6.m.dispose();
const bot = setup({ mode: 'coop', bots: 1, pid: 'ai_0' });
for (const x of [PROVENCE, TEXAS, SIYE]) bot.deploy(x);
give(bot.m, bot.ps, LAP, 'hand');
assert.deepEqual(bot.ps.refresh(), OK);
assert.equal(bot.got.lap, 4, 'a bot');
bot.m.dispose();
});
test('拉普兰德 in the 整备区 is not one of the 3 叙拉古: inactive bond, nothing — and that refresh was her first of the round', () => {
const s = setup();
s.deploy(PROVENCE);
s.deploy(TEXAS);
const lap = give(s.m, s.ps, LAP, 'hand');
assert.deepEqual(s.siracusa(), { active: false, count: 2 }, 'two 叙拉古 deployed: 2/3 (she sits in the 整备区)');
s.refresh();
assert.equal(s.got.lap, 0, '已激活的【叙拉古】: not active, no layers');
s.place(lap);
assert.deepEqual(s.siracusa(), { active: true, count: 3 }, 'deployed she is the third');
s.refresh();
assert.equal(s.got.lap, 0, '若为本回合首次主动刷新: her first refresh of the round was the inactive one');
s.nextRound();
s.refresh();
assert.equal(s.got.lap, 4, 'the next round: her first refresh with 叙拉古 active');
checkInvariants(s.m);
s.m.dispose();
});
test('拉普兰德 in the 临时整备区: no layers there; once she is in the 整备区 her next refresh adds +4', () => {
const s = setup();
for (const x of [PROVENCE, TEXAS, SIYE]) s.deploy(x);
s.fillHand();
const lap = give(s.m, s.ps, LAP, 'temp');
assert.equal(s.ps.find(lap.uid).area, 'temp');
s.refresh();
assert.equal(s.got.lap, 0, 'the 临时整备区 is not the 整备区');
assert.deepEqual(s.m.handle('p_0', { t: 'g.sell', uid: s.ps.hand[0].uid }), OK); // a free slot pulls her in
assert.equal(s.ps.find(lap.uid).area, 'hand');
s.refresh();
assert.equal(s.got.lap, 4, 'her first refresh in the 整备区');
s.refresh();
assert.equal(s.got.lap, 4, 'once');
checkInvariants(s.m);
s.m.dispose();
});
test('拉普兰德 bought after the round\'s first refresh, and a free refresh from 普罗旺斯: both stack (each copy counts its own refreshes)', () => {
const s = setup();
s.deploy(TEXAS);
s.deploy(SIYE);
give(s.m, s.ps, LAP, 'hand');
const prov = s.buy(PROVENCE); // <获得时>获得1次免费刷新
assert.equal(s.ps.shop.freeRefreshes, 1);
s.place(prov);
s.refresh(); // the free one is a manual refresh too
assert.equal(s.ps.shop.freeRefreshes, 0);
assert.equal(s.got.lap, 4);
s.buy(LAP); // a second copy, bought after the round's first refresh, stays in the 整备区
s.refresh();
assert.equal(s.got.lap, 8, 'her own first refresh');
s.m.dispose();
});
test('赫默: +2 / +4 at the round start only when deployed; in the 整备区 nothing even with 远见 active (character_target_inboard)', () => {
for (const [id, where, partner, n] of [
[SILENCE, 'board', 'hand', 2], [SILENCE, 'board', 'board', 2], [SILENCE_B, 'board', 'hand', 4],
[SILENCE, 'hand', 'board', 0], [SILENCE, 'hand', 'hand', 0], [SILENCE_B, 'hand', 'board', 0],
]) {
const s = setup();
if (where === 'board') s.deploy(id); else give(s.m, s.ps, id, 'hand');
if (partner === 'board') s.deploy(SWIRE); else give(s.m, s.ps, SWIRE, 'hand');
assert.ok(s.ps.bonds.visiShip.active, '远见 counts the 整备区: active');
s.nextRound();
assert.equal(s.got.sil, n, `${id} ${where}, partner ${partner}`);
assert.equal(s.ps.layers.visiShip || 0, n);
s.m.dispose();
}
});