diff --git a/CHANGELOG.md b/CHANGELOG.md index 256510ca..ec8f7c77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -127,6 +127,7 @@ - 本局禁用的盟约在每个难度都一样,只移除全部盟约都被禁用的干员;炎佑也会受到源石溶剂「每秒受到 60 真实伤害」的效果(PRTS 备注);两名变形同构体变成的阿戈尔照常吞噬(社区反馈)。 - 伺夜的狼群最多 3 只狼影,吟游者的特性可以回复战术家召唤物和不屈者的生命,荒芜拉普兰德三技能期间由无人机攻击、本人不普攻,叙拉古的恐惧概率,都和官方一致;突袭的玛恩纳攻击范围内有敌人时不突袭,是盟约原文的规则(社区反馈)。 - 以下没有复现,需要具体的对局(干员、地图、回合或录像):野鬃把敌人顶出地图、锏拉动领袖、昆图斯占地变小、炼金单元没有伤害、归溟幽灵鲨被吞噬时进入再部署、炎佑被空中单位阻挡、选 Pith 策略时联防里阿戈尔的加成被清空、满血时源石溶剂不生效(社区反馈)。 +- 整备区里的拉普兰德照样叠【叙拉古】层数,但叙拉古要已经激活(只数作战区里不同的叙拉古干员,整备区里的她不算在内),而且每回合只看她经历的第一次主动刷新,那次没激活就要等下一回合;临时整备区不算整备区。赫默的特质没有「此干员在整备区时也有效」,和官方数据一样只在作战区生效(社区反馈;文档补充了说明)。 ## 0.1.4 — 2026-10-06 diff --git a/docs/PLAYING.md b/docs/PLAYING.md index ac231232..37db2be6 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -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 层发的维式重锤是这种情况)先留在整备区,已经戴在干员身上的不会为了这场战斗被卸下,合成出的进阶装备在**下一休整期开始**时出现;其他时候拿到的同名装备仍立刻合成。整备区和临时整备区都满时,这件新装备照旧销毁(「整备区已满,获得的装备已销毁」),不会换进装备栏。特定的装备组合可以为干员赋予额外的盟约效果:「变形同构体」与另一件盟约装备(如维式重锤)一同携带时,携带者视为该装备对应盟约的成员。对应关系见变形同构体详情卡的「天赋」一栏(本局禁用的盟约会标出;装在干员身上时,正在生效的那一组会高亮)。 diff --git a/docs/history/0.2.0.md b/docs/history/0.2.0.md index 9297f58c..f0337554 100644 --- a/docs/history/0.2.0.md +++ b/docs/history/0.2.0.md @@ -1980,3 +1980,37 @@ here, each reproduced first; the rest were answered (not a bug, already fixed in the parent), `test/match/playtest6-matchflow.test.js` #235 (2; the 联防 one fails on the parent: losses = the LP settled, no result ticker, gone in the next phase), SP_E2E `test/ui/resultbox.e2e.test.js` (3, mock variants `unite[,through][,helper|,dead]`). Docs: PLAYING.md §1 / §5, design/network.md, design/client.md, design/match.md. + +#### 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 整备区). diff --git a/test/match/feedback5-bench-traits.test.js b/test/match/feedback5-bench-traits.test.js new file mode 100644 index 00000000..80c37cf2 --- /dev/null +++ b/test/match/feedback5-bench-traits.test.js @@ -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(); + } +});