From 9b37a68fcf8a86bbd29704012437c7aedd10b811 Mon Sep 17 00:00:00 2001 From: sganggs Date: Mon, 12 Oct 2026 15:24:07 +0800 Subject: [PATCH] =?UTF-8?q?0.2.5-dev:=20=E8=90=A8=E5=8D=A1=E5=85=B9?= =?UTF-8?q?=E6=9E=AF=E6=9C=BD=E6=88=98=E5=A3=AB=20(and=20every=20enemy=20s?= =?UTF-8?q?haring=20its=20death=20fog)=20leaves=20no=20=E6=B1=A1=E7=A7=BD?= =?UTF-8?q?=E4=BE=B5=E8=9A=80=20when=20it=20dies=20silenced=20=E2=80=94=20?= =?UTF-8?q?the=20owner's=20official-play=20recall,=20against=20the=20PRTS?= =?UTF-8?q?=20/=20in-game=20wording=20(#470);=20the=20=E8=B0=83=E5=BA=A6?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=205=20/=206=20promotion=20reward=20includes?= =?UTF-8?q?=20=E8=87=AA=E9=80=89=20operators=20of=20the=20newly=20reached?= =?UTF-8?q?=20tier=20(#515,=20after=20research;=20the=20owner's=20decision?= =?UTF-8?q?);=20golden:=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 + docs/DATA.md | 7 +-- docs/META.md | 11 +++-- docs/PLAYING.md | 2 +- docs/SIM.md | 2 +- docs/design/match.md | 2 +- docs/history/0.2.5.md | 57 ++++++++++++++++++++++++ server/match/PlayerState.js | 5 ++- server/match/player/acquire.js | 9 ++-- server/match/player/diy.js | 25 +++++++---- server/match/pool.js | 5 ++- server/sim/content/enemies.js | 6 ++- server/sim/content/enemies/archetypes.js | 17 ++++--- test/content/enemies_bosses.test.js | 12 ++--- test/match/diy-shop.test.js | 44 ++++++++++++++---- 15 files changed, 157 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0afedec1..156e1474 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ - 3D 棋盘在提取了官方场景时按战场显示沙地等 11 个场景,没有场景时仍用金属地块(PR #533,感谢 @andrew05060414)。完整包带上提取出的场景;精简包和源码可以在本机下载并提取,提取依赖缺失时继续用金属地块。 - 外勤医疗的预备地图角色在开战时保持比普通初始干员更早的索敌顺序(预备干员-医疗与 Touch 都是;[ASSUMED],维护者 2026-10-12 决定按作者描述实现);准备阶段可以点开该角色的详情和攻击范围(PR #523,感谢 @YinqiMargatroid)。 +- 萨卡兹枯朽战士和组长在已经沉默时被击倒,不留下污染秽蚀。维护者 2026-10-12 的实机回忆是沉默能阻止这团雾,这与 PRTS / 游戏内文字「不可沉默」相反;同一死亡能力只有这两名敌人,其他枯朽敌人的沉默规则不变(#470)。0.2.0 起行为已是如此,这次记下的理由改为上述回忆。 +- 调度中心 4 级的五阶晋升奖励可以出现五阶自选,5 级的六阶晋升奖励可以出现六阶自选;普通商店仍在 5 / 6 级才刷出;不保证出现;禁用和库存照旧(#515,维护者 2026-10-12 决定)。 ## 0.2.4 — 2026-10-11 diff --git a/docs/DATA.md b/docs/DATA.md index da16f255..fdb2f13f 100644 --- a/docs/DATA.md +++ b/docs/DATA.md @@ -112,7 +112,7 @@ Top level: `{ season, seasonName, modes, economy, lpCapPerRound, bossOvertimeAft | `poolCopies[tier]` + `poolCopiesOverrides` | 12/14/18/16/8/5 plus measured low-tier identity exceptions (DESIGN §29.8) | single-player copies; `coopPoolMultiplier: 2` doubles shared operators, not private DIY stock | | `itemPoolCopies[tier]` + `itemPoolCopiesOverrides` | 4/6/7/8/7/3 plus eight measured exceptions (DESIGN §29.8) | shared shop-equipment ownership: normal 1, upgraded 2; non-shop effect-only equipment unlimited | | `goldenCopies`, `mergeCount`, `mergeCountOverrides`, `itemMergeCount` | `3`, `3`, `{"chess_char_2_11_a":2}` (风丸), `2` | | -| `rewardOffer` | `{"count":3,"tierOffset":1,"maxTier":6,"price":0,…}` | merge reward: 3 **different** free chess of tier `min(shopLevel+1,6)` (a short tier tops up from the tier below; `PlayerState.pushRewardOffer`, user playtest #6 item 19) | +| `rewardOffer` | `{"count":3,"tierOffset":1,"maxTier":6,"price":0,…}` | merge reward: 3 **different** free chess of tier `min(shopLevel+1,6)` (a short tier tops up from the tier below; the player's stocked 自选 of that tier joins the roll, DESIGN §30.20; `PlayerState.pushRewardOffer`, user playtest #6 item 19) | | `handFillOrder` | `"rightToLeft"` | | | `shopOdds` | `{"model":"copyWeighted",…}` | [ASSUMED] roll model | | `borrowCount`, `fallbackBondId`, `defaultBandId`, `defaultStartLp` | `20`, `"emptyShip"`, `"band_bldsk"`, `28` | | @@ -642,8 +642,9 @@ prototype. Built by `tools/build-data.mjs buildBackups`; `shared/standIn.js` com `shared/diy.js` adds the 自选 rules on top. The sim fields both (docs/SIM.md §12). Played since 0.2.0: the player fills the four slots on the 自选编队 tab (`room.diy { picks }`, docs/PLAYING.md §3; shared/protocol.js checkDiyPicks keeps the legal picks), the match takes the picks the seat had at its start (`PlayerState.diy`), and each slotted piece is sold in -that player's shop only — its own stock (the tier's pool copies, 8 / 5 [ASSUMED]), from the 调度中心 level `shopLevel`, -none when every bond of it is banned this match — and is the operator for every rule of that player (its data view: +that player's shop only — its own stock (the tier's pool copies, 8 / 5 [ASSUMED]). The ordinary shop draws it from the +调度中心 level `shopLevel`; the promotion reward draws 自选 of the reward tier, one shop level earlier (DESIGN §30.20). +It has no stock when every bond of it is banned this match, and it is the operator for every rule of that player (its data view: server/match/player/diy.js, docs/META.md §3). The rules in the data (activity_table act2autochess `charShopChessDatas`; PRTS 卫戍协议, 卫戍协议:盟约 下半/PRTS盟约记录): diff --git a/docs/META.md b/docs/META.md index 419b14d6..e514ddcd 100644 --- a/docs/META.md +++ b/docs/META.md @@ -560,9 +560,14 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o stock (`ps.diyStock`: `poolCopies` of the slot's tier, 8 / 5 [ASSUMED]; none — `diyBanned` — when every bond of it is off this match; `initDiyStock` once the bans are drawn); `poolOf(baseId)` routes its copy accounting (buy, reward picks, merges, promotions, sells, temp, elimination; `invariants.js` checks `left + held = cap` per player). The - shop's chess slots and the reward offers' temporary refreshes roll it with the shared pool (`pool.roll({ extra })`, - copy-weighted like any chess of its tier) once the 调度中心 reaches the slot's `shopLevel` (5 / 6) [ASSUMED for the - reward offers]; an effect, reward pool or 机变 card that grants the player a random operator draws its stock too + shop's chess slots roll it with the shared pool (`pool.roll({ extra: diyRollEntries() })`, copy-weighted like any + chess of its tier) once the 调度中心 reaches the slot's `shopLevel` (5 / 6). The promotion reward's temporary refresh + (`pushRewardOffer` with no explicit ids) draws the same stock through `diyStockEntries()` under the reward tier + `min(shopLevel + 1, 6)`, so a tier-5 自选 piece can appear when the 调度中心 reaches 4 and a tier-6 piece when it + reaches 5 — one shop level before the ordinary shop, and not guaranteed. 卫戍协议/帮助 干员晋升 names one refresh one + tier above the 调度中心 and no separate pool; the owner decided on 2026-10-12 that the refresh includes 自选 of that + tier (0.2.0 had [ASSUMED] the shop-level gate for the reward too, DESIGN §25; §30.20 reverses it). An effect, reward + pool or 机变 card that grants the player a random operator draws its stock too (effectsMeta `rollChess` / `rollPool`, the 驰援 fallback in choices.js: `ps.diyStockEntries()` — the roll's own tier rules, no 调度中心 gate, bonds through the player's view; 「自选干员放入后模拟中的补给池随机范围也将被相应扩大」, 0.2.0), and 信标 never sends one to a teammate [ASSUMED]. `battleInput` carries `diy` (the pick); `m.private.diy` / `diyBanned`; `prepFieldMeta` and diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 36cc34af..da9b8753 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -67,7 +67,7 @@ - **晋级奖励**:每次晋升后获得一次特殊招募:从 3 名 **高一阶**(最高 VI)、**互不相同**的随机干员中**免费**挑 1 名(该阶剩余干员不足 3 名时由低一阶补足)。它不能刷新、不能冻结、本回合结束失效。 - **干员调配(技能、模组、潜能与练度)**:大厅、房间和「确认本局信息」里的「干员调配」按钮打开的界面有三个标签页:「干员调配」「干员持有」「自选编队」。「干员调配」把干员列成一张表,每名干员一行,各列是**技能**、精锐的**模组**和**潜能 · 练度**:直接点行里的技能图标、模组图标(「不装备」在最前,模组只在精锐时生效)或改两个下拉框就设好了;点头像或名字在右边打开详情,依次是潜能与练度、特质、技能(普通 Lv.4 / 精锐 Lv.7 两档数值可切换)、局内数值和模组(窄屏——宽度 1000 像素以下,例如横屏的手机——详情盖住列表,点「干员列表」返回)。**潜能**(1–6)和**练度**(未精英化 / 精英阶段1 / 精英阶段2 / 精英阶段2-60级)按干员设置,同一名干员的普通、精锐和自选共用,默认潜能 6、精英阶段2-60级(维护者 2026-10-08 的决定:默认满潜、满加成)。官方规则:潜能取你自己持有的这名干员的潜能;练度是调度手册「加成情况」里的**自持有加成**——未精英化 +0%,精英1 攻击、防御 +5%,精英2 攻击 +10%、防御和生命 +5%,精英2 60 级三项 +10%,单独乘算,不和其他「攻击力 +x%」相加;未持有的特许干员在官方按潜能 1、没有加成,想照官方打就把它设成潜能 1、未精英化。补位的替补干员和自选的原型干员没有潜能和练度,设置对它们不生效。商店卡片、详情卡和「局内数值」都按你的设置显示;同盟模拟中队友的干员按队友的设置。和技能、模组一样是**局外设置**:「确认本局信息」结束前修改对本局生效,之后只保存到下一局。「恢复默认」「全部恢复默认」同时重置潜能与练度,「导出 / 导入」会带上它们,「干员调配」按钮上的数字也算上改过潜能或练度的干员。 - **干员持有(补位)**:同一个界面的第二个标签页「**干员持有**」可以把你没有的干员**下掉**(点一下卡片切换「持有 / 未持有」)。和官方一样只有 55 张「普通」棋子能下掉(界面按阶级列出本赛季商店里的 53 名,每张写着由谁替补、用几技能);74 张「预设」棋子(多是 1–2 阶的常见干员)无论是否持有都由本人上场,不在其中。默认全部持有。下掉的干员和官方一样**显示为替补干员**:商店和晋升奖励的卡片、整备区和临时整备区里的单位、作战区、结算界面的阵容,都是替补干员的头像、名字和模型(例如银灰显示为 Sharp),带一个小小的「替补」标记;详情卡显示替补干员的立绘、名字、技能、天赋和模组,名字下面写着「银灰的替补」。**盟约、特质、阶级、价格、合成仍按原干员**(卡片和详情卡上的盟约、特质就是原干员的),作战时的属性、技能、天赋、模组、攻击范围、近战位 / 远程位都是替补干员的(例如银灰 → Sharp 的 2 技能「亮剑」)。同一名替补干员可能替补好几名干员(Sharp 替补忍冬、银灰、隐德来希等),它们按各自的原干员分别合成:看阶级和盟约区分,点开详情卡能看到替补的是谁。同盟模拟中队友查看你的整备区和作战区、你的盟约成员时,看到的也是替补干员。替补的技能和模组由棋子固定,「干员调配」对它不生效;替补干员没有召唤物(下掉缪尔赛思就没有流形)。这是**局外设置**:下一局开始时生效,对局中修改只保存到下一局;同盟模拟中只影响你自己的棋子,队友的棋子按队友的设置。没有官方的好友借用:想用本人上场,改回「持有」即可。「全部持有」一键恢复,「导出 / 导入」可以把设置带到别的浏览器;大厅的「干员调配」按钮上会显示「替补 N」提醒你有几名干员被下掉。 -- **自选编队**:同一个界面的第三个标签页「**自选编队**」有 4 个名额:5 阶两个、6 阶两个。点名额打开选择列表,每个名额可以放一名**你持有的 6★ 干员**(棋子池里没有的那些,标「已持有」:3 个技能任选其一,精锐的模组任选其一或不装备;只在集成战略或生息演算里生效的模组不能选:ISW-A、SO-A / SO-B、RA-A)或一名**原型干员**(标「原型」:Sharp、Mechanist 等 9 名 6★,5 阶另有 6 名 4★ 预备干员;技能和模组与补位时相同,不能更改)。同一名原型干员可以同时放进一个 5 阶和一个 6 阶名额;你持有的干员只能放一个名额;同一阶的两个名额不能放同一人。列表只列出已经做好战斗逻辑的干员(原型干员全部可选,持有的 6★ 随战斗逻辑完成陆续加入)。对局中,自选干员**只出现在你自己的商店里**:调度中心升到 5 级后才会刷出 5 阶名额的干员,6 级后才会刷出 6 阶的;刷新概率和同阶干员一样,每个名额有自己的库存(5 阶 8 张、6 阶 5 张,不与队友共用),价格与同阶干员相同,晋升奖励里也可能出现;效果、奖励或机变卡「获得一名随机干员」时,也可能抽到你自己的自选干员(按那个效果自己的阶级规则,不看调度中心等级)。三张合成精锐,精锐时所选模组生效(5 阶 1 级、6 阶 3 级)。你持有的干员的潜能与练度在名额卡片上设置(默认潜能 6、精英阶段2-60级,规则同「干员调配」);原型干员没有潜能和练度。自选干员**没有特质**,盟约按干员的实际阵营分配(推进之王是维多利亚,煌同时是炎和维多利亚,没有对应阵营的是协防干员),和普通棋子一样计入盟约人数;本局禁用了它全部的盟约时,它不会出现在商店里(协防不会被禁用),开局的「确认本局信息」、选策略时的「本局信息」和对局中的「本局信息」都会在「本局禁用干员」下写明是哪一名。商店卡片、整备区、作战区和详情卡上都标着「自选」,详情卡显示所选的技能和模组。这同样是**局外设置**:下一局开始时生效,对局中修改只保存到下一局;AI 队友不使用自选;你开启 AI 托管时,AI 会优先买你的自选干员,并在快能刷出自选名额时提早一回合升级调度中心;信标不会把自选干员送给队友。「导出 / 导入」「全部清空」可用,大厅的「干员调配」按钮上会显示「自选 N」。 +- **自选编队**:同一个界面的第三个标签页「**自选编队**」有 4 个名额:5 阶两个、6 阶两个。点名额打开选择列表,每个名额可以放一名**你持有的 6★ 干员**(棋子池里没有的那些,标「已持有」:3 个技能任选其一,精锐的模组任选其一或不装备;只在集成战略或生息演算里生效的模组不能选:ISW-A、SO-A / SO-B、RA-A)或一名**原型干员**(标「原型」:Sharp、Mechanist 等 9 名 6★,5 阶另有 6 名 4★ 预备干员;技能和模组与补位时相同,不能更改)。同一名原型干员可以同时放进一个 5 阶和一个 6 阶名额;你持有的干员只能放一个名额;同一阶的两个名额不能放同一人。列表只列出已经做好战斗逻辑的干员(原型干员全部可选,持有的 6★ 随战斗逻辑完成陆续加入)。对局中,自选干员**只出现在你自己的商店里**:调度中心升到 5 级后才会刷出 5 阶名额的干员,6 级后才会刷出 6 阶的;刷新概率和同阶干员一样,每个名额有自己的库存(5 阶 8 张、6 阶 5 张,不与队友共用),价格与同阶干员相同。晋升奖励按奖励自己的阶级抽取,所以该阶自选可以比普通商店早一级出现:调度中心 4 级的五阶奖励可以出现五阶自选,5 级的六阶奖励可以出现六阶自选,不保证刷出;效果、奖励或机变卡「获得一名随机干员」时,也可能抽到你自己的自选干员(按那个效果自己的阶级规则,不看调度中心等级)。三张合成精锐,精锐时所选模组生效(5 阶 1 级、6 阶 3 级)。你持有的干员的潜能与练度在名额卡片上设置(默认潜能 6、精英阶段2-60级,规则同「干员调配」);原型干员没有潜能和练度。自选干员**没有特质**,盟约按干员的实际阵营分配(推进之王是维多利亚,煌同时是炎和维多利亚,没有对应阵营的是协防干员),和普通棋子一样计入盟约人数;本局禁用了它全部的盟约时,它不会出现在商店里(协防不会被禁用),开局的「确认本局信息」、选策略时的「本局信息」和对局中的「本局信息」都会在「本局禁用干员」下写明是哪一名。商店卡片、整备区、作战区和详情卡上都标着「自选」,详情卡显示所选的技能和模组。这同样是**局外设置**:下一局开始时生效,对局中修改只保存到下一局;AI 队友不使用自选;你开启 AI 托管时,AI 会优先买你的自选干员,并在快能刷出自选名额时提早一回合升级调度中心;信标不会把自选干员送给队友。「导出 / 导入」「全部清空」可用,大厅的「干员调配」按钮上会显示「自选 N」。 - 每回合商店会自动刷新未冻结的格子。 - **「本回合首次主动刷新」按每名干员各自计算**(拉普兰德):她在本回合遇到的第一次主动刷新生效,在整备区里也算——先刷新、再买下拉普兰德,下一次刷新她仍会叠层;本回合新合成的精锐算作一名新的拉普兰德——即使合成用的几名本回合已经生效,精锐在本回合的下一次主动刷新仍会 +8。 diff --git a/docs/SIM.md b/docs/SIM.md index 1808a572..31598783 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -382,7 +382,7 @@ enemy: 隐匿 kept out, 起飞 not), 【污染秽蚀】 on every ally inside (fl `alliesInRadius`) — through `dealDamage`, so shields absorb a damage tick, damage-taken modifiers scale it and it counts for 受击回复 SP and TAKE_DAMAGE skills like any hit (§4; element fills excepted). 【污染秽蚀】 (萨卡兹枯朽战车's 秽蚀轰击, -萨卡兹枯朽战士's death — none when it dies silenced, its client template checks 沉默, DESIGN §25.18) is **true** damage, 50 / 25 per second on low / high ground (PRTS "每秒受到50/25点真实普通伤害 … +萨卡兹枯朽战士 / 组长's death — none when either dies while silenced. The owner's official-play recall of 2026-10-12 is that a silenced death creates no fog, which contradicts the PRTS / in-game text 「不可沉默」; only `enemy_1267_nhpbr` and `enemy_1267_nhpbr_2` share this PollutedDie ability, DESIGN §30.20) is **true** damage, 50 / 25 per second on low / high ground (PRTS "每秒受到50/25点真实普通伤害 … 同名效果不叠加", user playtest #6): a unit covered by several zones takes one tick per second (`unit.mem.pollutedAt`), so a crowd of dying 萨卡兹枯朽战士 totals 50 / s, not 50 × n. It is "可对空,无视无法选择": it also burns an airborne 起飞 ally (`ignoreSelect`), at the low-ground rate on a low tile. The other zones are no exception to 对地规避: a ground enemy's diff --git a/docs/design/match.md b/docs/design/match.md index 79bcc183..678f1eaf 100644 --- a/docs/design/match.md +++ b/docs/design/match.md @@ -60,7 +60,7 @@ Summons (§20.1): an operator on the board sends its manually deployable summons Equipment (research 04 §2 + addendum, 09 §1.2): 2 slots per chess; a third item on a full carrier replaces the equipped item `replaceUid` names (the replace dialog's pick, official `UseEquipUp.unloadInstId`; absent ⇒ the oldest; a uid not equipped on the target ⇒ `BAD_TARGET`, nothing changes) and the replaced item is destroyed. A consume-on-equip item (盟约之币, 博士投影 …) on a full carrier replaces the same way before it resolves, so the carrier is left with a free slot (the normal 博士投影 takes it until the next round start); a target its effect refuses (博士投影 on an elite) keeps both items — PRTS 卫戍协议/帮助 "达到上限强行佩戴会改为替换装备" names no exception (GitHub #263; the client opens the replace dialog for it, `equipReplaces`). Equipped items are otherwise **locked**: `destroy` (销毁) accepts hand / temp items only (`BAD_TARGET 'equipped items are locked'`); equipment goes back to the hand only when its carrier merges, is sold, or is destroyed by 突变细胞's transformation (the cell itself returns too; PRTS 帮助 "在失去该干员(干员出售、销毁、合并等)…时自动卸除", §21.1) — a sale is refused with `HAND_FULL` when there is no room for it; returned equipment is stowed before the operator a transformation gains and before a merge elite's summon cards, and every path that hands the player an item — returned equipment included — ends with the auto-merge, so no player holds two identical mergeable normal items (§21.7). -Economy/shop/pool/merge/hand rules: exactly as research 00-INDEX §3–§4 (income `min(3+r,12)`; prices by tier; refresh 1; freeze toggles all; sell +1; items not sellable; upgrade prices per mode with −1/round; single-player pool copies 12/14/18/16/8/5 plus measured exceptions; co-op shared operators use twice those counts, while equipment has separate shared stock (DESIGN §29.8); elite = 3 copies; odds = copy-weighted draw over unbanned visible chess with tier ≤ level; merge 3 (风丸 2) → elite onto the consumed deployed copy's tile, else to the hand (§20.11) + reward offer of 3 **different** free chess of tier `min(level+1, 6)` (copy-weighted; a tier short of different chess tops up from the tier below [ASSUMED], §20.6); full hand blocks every buy / reward pick, a merge-completing one too (PRTS 卫戍协议/帮助 §手牌区, GitHub #82); overflow to temp; a free hand slot pulls the temp pieces back in at once, right → left (`PlayerState._fillHandFromTemp`); temp blocks ready). +Economy/shop/pool/merge/hand rules: exactly as research 00-INDEX §3–§4 (income `min(3+r,12)`; prices by tier; refresh 1; freeze toggles all; sell +1; items not sellable; upgrade prices per mode with −1/round; single-player pool copies 12/14/18/16/8/5 plus measured exceptions; co-op shared operators use twice those counts, while equipment has separate shared stock (DESIGN §29.8); elite = 3 copies; odds = copy-weighted draw over unbanned visible chess with tier ≤ level; merge 3 (风丸 2) → elite onto the consumed deployed copy's tile, else to the hand (§20.11) + reward offer of 3 **different** free chess of tier `min(level+1, 6)` (copy-weighted, including the player's stocked 自选 of that tier — one shop level before the ordinary shop, §30.20; a tier short of different chess tops up from the tier below [ASSUMED], §20.6); full hand blocks every buy / reward pick, a merge-completing one too (PRTS 卫戍协议/帮助 §手牌区, GitHub #82); overflow to temp; a free hand slot pulls the temp pieces back in at once, right → left (`PlayerState._fillHandFromTemp`); temp blocks ready). **Temp overflow (临时整备区, row 8; user playtest #3 item 3).** Passive gains that find the hand full (merge elites, grants — the operator a 突变细胞 transformation gains included, §21.1 —, battle-result rewards, equipment returned by a merged / sold / transformed carrier) overflow into the 5 temp slots, and Ready is refused while any is occupied (official "直到溢出情况排除才可开始进行作战"). A temp piece is resolved — a chess sold back to the pool with its summons removed, items destroyed, a summon stack removed (it comes back at the next round start, PRTS "干员所属召唤物会于下一回合返还", §20.1) — only at the deadline of the **first prep in which its player could act on it** (`PlayerState.tempDue(piece)` vs `prepsEnded`; every write into a temp slot goes through `_putTemp`, which records it): a piece that arrived during a prep before Ready is due at that prep's end; one that arrived after Ready or during the `<休整期结束时>` effects, or outside PREP (COMBAT, 联防, SETTLE — battle-result grants, a SETTLE merge's elite, returned equipment —, the next ROUND_START, 机变) is kept, visible and usable (move to a free hand slot, place, equip, use, destroy, sell) through the **next** prep. Un-readying (`setReady(false)`) makes whatever arrived while ready due at the current prep. ROUND_START no longer wipes temp (it used to destroy what SETTLE had put there before the player ever saw it). A merge's elite whose merge consumed a deployed copy takes that copy's tile and facing (§20.11); otherwise it goes to the hand, overflowing into temp (also outside PREP). `server/match/audit.js` (`tools/matchrun.mjs --check`) tracks the arrivals itself and flags any temp piece that outlives its due prep. The UI frames the row on the board with the rule (§17.5). diff --git a/docs/history/0.2.5.md b/docs/history/0.2.5.md index 0a4a0da0..2c20de9f 100644 --- a/docs/history/0.2.5.md +++ b/docs/history/0.2.5.md @@ -464,3 +464,60 @@ continues the same layer gap (191 → 206). The human settle row first differs a 267 → 282; final bonds move 奥术 27 → 30, 拉特兰 17 → 23 and 精准 53 → 59. This scenario was not among the 45 that already differed from 0.2.4 (§30.9, §30.16), so the release-relative total is now 46. The other 286 digests match the preceding candidate. + +### 30.20 Silenced 枯朽 death fog, and 自选 in the promotion reward + +Two owner decisions of 2026-10-12. Neither is a new pool or a change to any other silence rule. + +**#470.** `enemy_1267_nhpbr` (萨卡兹枯朽战士) and `enemy_1267_nhpbr_2` (萨卡兹枯朽战士组长) share one death ability, +PollutedDie 【污染秽蚀】 (`kitPolluted`). A death while `flags.silence` is set creates no zone. The owner's official-play +recall is that killing one which already shows the silence icon leaves no fog. That recall contradicts the PRTS / +in-game text 「不可沉默」. Both handbook lines are `format: "NORMAL"` ("被击倒时释放污染秽蚀,使周围我方持续损失生命"), +not `SILENCE`. 0.2.0 already withheld the fog (§25.18.2), from the general 死亡爆炸 line "如无特殊说明,此能力默认可沉默" +and a client-template reading of `projectile_on_killed`. That section stays as the 0.2.0 reading. The reason recorded +now is the owner's recall, against 「不可沉默」. The local gamedata cache does not contain the string 「不可沉默」; the +contradiction is the issue title and the owner's statement. + +The same ability is only those two keys. Checked and left unchanged: 萨卡兹枯朽战车 / 尖端 (`enemy_1272_nhtank`, `_2`) +秽蚀轰击 is an attack skill with `_ignoreSilence` and `silenceImmune`; 枯朽之种 (`enemy_1269_nhfly`) self-destructs and +is silence-immune; 枯朽萃聚使徒 (`enemy_1321_wdarft`) summons seeds and is silence-immune; 萨卡兹枯朽前锋 / 辟路前锋 +(`enemy_1422_lrsldr`, `_2`) deal arts while in a 源石污染区; 假想敌:蚀裂 (`enemy_9006_actoxi`) has a different death +cloud that was already silenceable; 高能源石虫's 死亡爆炸 is a different ability. A silence that has expired does not +stop the fog. A leak (`reason !== 'killed'`) still creates none. A killing blow that would apply silence only if the +target is still alive is not this case: the report is the icon already showing. + +**#515.** The promotion reward (three different operators, one tier above the 调度中心, at most 6, price 0) includes +自选 operators of the newly reached tier, the way the ordinary shop includes them once the 调度中心 is at that tier. +`pushRewardOffer` with no explicit `ids` now passes `diyStockEntries()` into `pool.roll`. The tier filter is unchanged, +including the downward walk when a tier has no further distinct chess (playtest #6 item 19). Shop level 4 rolls reward +tier 5, so a stocked tier-5 自选 piece can appear and a tier-6 piece cannot. Shop level 5 rolls reward tier 6, so a +stocked tier-6 piece can appear; a tier-5 piece appears only if that roll walks down. Shop level 6 still can roll tier +6. Shop level 3 rolls tier 4 and has no tier-4 自选 piece. The ordinary shop (`economy.js`, `diyRollEntries`) still +waits for `shopLevel` 5 / 6. Effect offers that pass explicit `ids` do not roll. A 自选 piece is not guaranteed. A slot +whose every bond is banned still has no stock. `left <= 0` stays out. Copy counts 8 / 5 stay [ASSUMED] and unchanged. + +Seen: 卫戍协议/帮助 干员的获得与精锐化 describes a temporary refresh of three operators one tier above the 调度中心 and +does not say that 自选 are excluded. The next section's note "仅在调度中心等级 ≥ 干员所在等阶时,该干员才有可能出现在栏位中" +is the persistent shop: binding it to the refresh would leave a level-4 调度中心 with no tier-5 operator. +`shopLevelDisplayDataDict` lists the tier-5 DIY slots at level 5 and the tier-6 slots at level 6; those rows are visible +shop chess, and no second pool excludes them. No parsed page names 自选 inside the refresh. The inclusion is the +owner's decision of 2026-10-12, not a new [ASSUMED]. 0.2.0 §25 had [ASSUMED] that the reward used the slot's shop +level; that sentence stays there, and this section reverses it. + +Normative lines: `server/match/player/acquire.js` `pushRewardOffer`, `server/match/player/diy.js`, META.md §3, +DATA.md `rewardOffer` and §18, design/match.md economy, PLAYING.md 自选编队, SIM.md enemy damage zones, +`server/sim/content/enemies/archetypes.js` `kitPolluted`. Tests: `test/match/diy-shop.test.js` (the reward tallies; +the level-4 ordinary shop still rolls no tier-5 自选 piece), `test/content/enemies_bosses.test.js` (both keys, silenced +and after the silence runs out). + +`npm run golden:update` recomputed all 287 scenarios. No scenario was added or removed, and no digest moved. +#470 does not change the sim: the fog was already withheld. #515 can change a promotion reward only when the seat +has stocked 自选 of the reward tier and the 调度中心 is still below that slot's shop level. The two DIY matches start +at shop level 5. `coop2-NORMAL-5-coverage-diy` rolls its only reward after the shop is already 6, where both iterators +include the tier-6 stock, so the offer is the same. `coop2-NORMAL-70-diy` rolls four tier-6 rewards while the shop is +still 5. On this seed the card the seat takes is the same operator as before. Rounds 8–10 differ only in an untaken +card (round 8 swaps the other two; rounds 9 and 10 replace the third). The other level-5 offer is unchanged. Both +level-6 offers match the previous roll, including the one that shows the tier-6 自选 piece, which the shop gate already +allowed. The digest records +shop draws, fielded pieces, board hashes and the rng state, not an untaken reward card, so the stored scenario does +not move. The release-relative total against 0.2.4 stays 46. diff --git a/server/match/PlayerState.js b/server/match/PlayerState.js index f80173ac..fd23cdf5 100644 --- a/server/match/PlayerState.js +++ b/server/match/PlayerState.js @@ -26,7 +26,8 @@ // rerolls everything and the new slots stay frozen), level-up price = base − rounds elapsed (floor 0). // * Merge: 3 normal copies (风丸 2) on board/hand/temp → 1 elite; equipment returns to the hand; a // reward offer of 3 different free chess of tier min(level+1, 6) is queued (never one operator twice — user -// playtest #6 item 19; a short tier tops up from the tier below; pick 1, expires at prep end; an offer earned +// playtest #6 item 19; a short tier tops up from the tier below; the player's stocked 自选 of that tier joins, +// §30.20; pick 1, expires at prep end; an offer earned // after the prep — SETTLE / Final Assault effects — is kept for the next prep). Where the elite goes (PRTS // 卫戍协议/帮助 "发送1名【精锐】状态的该干员至手牌区(若消耗已部署至作战区的干员,则发送至作战区对应位置)", user // playtest #6 follow-up): when a consumed copy stood on the board, onto that copy's tile with its facing (of several, @@ -116,7 +117,7 @@ // battleInput // views.js pieceView, effectsView, m.private (privateView) // diy.js 自选编队: the picks (setDiy), the player's data view, the DIY stock (initDiyStock, poolOf), the shop's -// DIY draws (diyRollEntries), the pick of a piece (diyPickOf) +// DIY draws (diyRollEntries) and the reward / grant draws (diyStockEntries), the pick of a piece (diyPickOf) // common.js HAND_SIZE, TEMP_SIZE, MAX_OFFER_SLOTS, OK, fail import { FIELD } from './board.js'; diff --git a/server/match/player/acquire.js b/server/match/player/acquire.js index f2c4bd53..2387aec1 100644 --- a/server/match/player/acquire.js +++ b/server/match/player/acquire.js @@ -215,8 +215,11 @@ export class PlayerAcquire { * copy-weighted roll from the shared pool excluding the ones already drawn; a tier left without another chess tops * up from the tier below (user playtest #6 item 19: the official promotion reward never offers one operator twice — * the user's first-hand report; the normal shop's slots may repeat). The offer reserves no copies (the pick takes one). - * The player's slotted 自选 pieces join the draw like in its shop (player/diy.js diyRollEntries: once the 调度中心 is at - * the slot's level) — the reward is a temporary refresh of the shop ("临时刷新3名…干员") [ASSUMED]. + * The player's slotted 自选 pieces of the reward tier join the draw (player/diy.js diyStockEntries: copies left, then + * the roll's tier — not the ordinary shop's shopLevel gate). 卫戍协议/帮助 干员晋升 names one temporary refresh one tier + * above the 调度中心 and no separate pool; the owner decided on 2026-10-12 that the refresh includes 自选 of the newly + * reached tier. 0.2.0 had [ASSUMED] the shop-level gate on this refresh too (DESIGN §25); §30.20 reverses that. + * An offer built from explicit `ids` does not roll. */ pushRewardOffer(source = 'merge', { tier = null, ids = null, label = null } = {}) { const ro = this.gd.rewardOffer(); @@ -228,7 +231,7 @@ export class PlayerAcquire { const fresh = (id) => !list.includes(id); for (let i = 0; i < ro.count; i++) { let id = null; - for (let tt = t; tt >= 1 && !id; tt--) id = this.m.pool.roll(this.m.rngShop, { tier: tt, filter: fresh, extra: this.diyRollEntries() }); + for (let tt = t; tt >= 1 && !id; tt--) id = this.m.pool.roll(this.m.rngShop, { tier: tt, filter: fresh, extra: this.diyStockEntries() }); if (id) list.push(id); } } diff --git a/server/match/player/diy.js b/server/match/player/diy.js index 3fbc4100..d33a4311 100644 --- a/server/match/player/diy.js +++ b/server/match/player/diy.js @@ -21,11 +21,16 @@ // gets no stock and so leaves the shop, like a preset chess whose bonds are all off (PRTS 卫戍协议:盟约 "禁用盟约有可能 // 影响自选的支援干员"); 协防 (emptyShip) is never banned, so a prototype always stays. poolOf(baseId) routes every copy // taken or returned for a slotted slot to that stock (buy, reward picks, merges, promotions, sells, temp, elimination). -// * the shop's draws (diyRollEntries): the stock of each slotted slot joins this player's copy-weighted rolls — the -// shop's chess slots and the reward offers' temporary refreshes [ASSUMED] — weighted like any chess of its tier, once -// the 调度中心 has reached the slot's shopLevel (activity_table shopLevelDisplayDataDict lists the tier-5 slots at -// level 5 and the tier-6 slots at level 6; PRTS 帮助 "仅在调度中心等级 ≥ 干员所在等阶"). The price is the slot's (any -// chess of its tier: 4). +// * the shop's draws (diyRollEntries): the stock of each slotted slot joins this player's copy-weighted shop rolls, +// weighted like any chess of its tier, once the 调度中心 has reached the slot's shopLevel (activity_table +// shopLevelDisplayDataDict lists the tier-5 slots at level 5 and the tier-6 slots at level 6; PRTS 帮助 "仅在调度中心 +// 等级 ≥ 干员所在等阶" is that persistent shop). The price is the slot's (any chess of its tier: 4). +// * the promotion reward (pushRewardOffer with no explicit ids) draws diyStockEntries() under the reward tier +// min(shopLevel + 1, 6): a tier-5 自选 piece can appear when the 调度中心 reaches 4, a tier-6 piece when it reaches +// 5, one shop level before the ordinary shop. 卫戍协议/帮助 干员晋升 names that refresh and no second pool; the owner +// decided on 2026-10-12 that it includes 自选 of the newly reached tier. 0.2.0 had [ASSUMED] the shop-level gate +// for this refresh too (DESIGN §25); §30.20 reverses it. A piece is not guaranteed. Banned slots and `left <= 0` +// stay out. Explicit-id offers do not use this draw. // * random grants (diyStockEntries, 0.2.0 WE2 #9): an effect, reward or 机变 card that grants this player a random // operator from the pool draws its stock too — 「自选干员放入后模拟中的补给池随机范围也将被相应扩大」 (bilibili), "调度中心 // 随机资源的范围将被扩大" (PRTS 新手教程) — under the roll's own tier rules (the effect's tier / maxTier: no 调度中心 gate, @@ -206,8 +211,9 @@ export class PlayerDiy { } /** - * The stock entries this player's shop draws from now ([baseId, entry] with copies left and the 调度中心 at the slot's - * shopLevel), or null (none — the shared pool's roll alone, exactly as without picks). + * The stock entries this player's ordinary shop draws from now ([baseId, entry] with copies left and the 调度中心 at + * the slot's shopLevel), or null (none — the shared pool's roll alone, exactly as without picks). The promotion + * reward does not use this gate (`diyStockEntries`). * @returns {Array<[string, { cap: number, left: number, tier: number, shopLevel: number }]> | null} */ diyRollEntries() { @@ -218,8 +224,9 @@ export class PlayerDiy { } /** - * The stock entries a random grant of this player draws besides the shared pool (see the header): every slotted piece - * with copies left, whatever the 调度中心 level (the roll's own tier rules apply), or null (none). + * The stock entries a random grant, and the promotion reward's temporary refresh, draw besides the shared pool + * (see the header): every slotted piece with copies left, whatever the 调度中心 level (the roll's own tier rules + * apply), or null (none). * @returns {Array<[string, { cap: number, left: number, tier: number, shopLevel: number }]> | null} */ diyStockEntries() { diff --git a/server/match/pool.js b/server/match/pool.js index 11fb481c..11d345e9 100644 --- a/server/match/pool.js +++ b/server/match/pool.js @@ -15,8 +15,9 @@ // Rolls: each chess slot draws ONE copy uniformly from all remaining copies of eligible chess with tier ≤ shop level // ("copy-weighted"; duplicates within a roll allowed). The item slot picks a tier with the same tier shares, then a // uniform shop-eligible item of that tier (falling back to lower tiers). A roll may add entries outside the pool -// (`extra`, after its own): one player's 自选 stock (0.2.0, player/diy.js diyRollEntries) — weighted by its copies like -// any chess, drawn by that player's shop only. +// (`extra`, after its own): one player's 自选 stock — the ordinary shop passes diyRollEntries (the shopLevel gate); +// the promotion reward and random grants pass diyStockEntries (the roll's own tier, DESIGN §30.20). Weighted by its +// copies like any chess, drawn by that player only. /** * Per-match disabled bond set D and banned chess (research 01 A2): D = uniform sample of `core` core bonds and `addon` diff --git a/server/sim/content/enemies.js b/server/sim/content/enemies.js index 1c7450ee..8f3883f3 100644 --- a/server/sim/content/enemies.js +++ b/server/sim/content/enemies.js @@ -17,8 +17,10 @@ // hidden from later 'kill' handlers) · death(c,…) · status(c,…) (statusApplied on e) · burst(c,…) (elementBurst on e) // · tick(b,e,a,dt) every `iv` s (every tick when iv is 0) · skill: `cd`/`left`(=icd)/`cond`/`fire` (cooldown runs // continuously; fires when ready, the enemy can act and `cond` holds). `sil: true` = the ability's handbook line -// is flagged SILENCE (沉默 disables it: exactly the data's `format: "SILENCE"` lines — and 萨卡兹枯朽战士 / 组长's -// 死亡爆炸, a NORMAL line whose client template checks 沉默: archetypes.js kitPolluted); stun / freeze / sleep block +// is flagged SILENCE (沉默 disables it: exactly the data's `format: "SILENCE"` lines). 萨卡兹枯朽战士 / 组长's +// 【污染秽蚀】 death fog is the one NORMAL line also silenced (archetypes.js kitPolluted, `enemy_1267_nhpbr` and +// `_2` only): the owner's official-play recall of 2026-10-12 is that a death while silenced creates no fog, and +// that recall contradicts the PRTS / in-game text 「不可沉默」 (DESIGN §30.20). stun / freeze / sleep block // skills. Enemy SP skills ("数次攻击后…"): SP +1 per attack, the (spCost+1)-th attack is the skill attack (official // 粉碎攻坚手 text: spCost 2 = "攻击2次后,下一次攻击"). // Shared mechanics: reborn() — "首次被击倒后重生 / 第二形态" (the first KO is hidden from kill credit and bounty; a diff --git a/server/sim/content/enemies/archetypes.js b/server/sim/content/enemies/archetypes.js index 37151907..9e60852a 100644 --- a/server/sim/content/enemies/archetypes.js +++ b/server/sim/content/enemies/archetypes.js @@ -658,13 +658,16 @@ const kitSelfFear = (ab) => [selfFear(ab)]; const kitEmber = (ab) => [husk({ hits: T(ab, 'Revive[Trigger].prop_max_hp'), delay: T(ab, 'Revive[Trigger].interval') })]; /** - * 萨卡兹枯朽战士 / 组长 · death: 【污染秽蚀】 (PRTS 天赋 "{{特殊机制|死亡爆炸}}(释放半径2.0,持续8s的【污染秽蚀】)"). Silenced at its - * death it releases nothing: PRTS 特殊机制 死亡爆炸 "如无特殊说明,此能力默认可沉默…若不处于沉默状态,将会…释放", and the - * client agrees — its PollutedDie ability is a buff of template `projectile_on_killed` (battle prefab - * enemy_1267_nhpbr / _2), whose ON_OWNER_KILLED first checks `CheckAbnormalFlag SILENCED` (unset) before it emits the - * pollution projectile — the template of 高能源石虫's SILENCE-flagged blast too. Its handbook line alone is NORMAL, the one - * exception to the handbook rule of content/enemies.js (community report of 2026-10-06, item 10: until 0.2.0 a - * silenced one still poisoned its blockers). + * 萨卡兹枯朽战士 / 组长 (`enemy_1267_nhpbr`, `enemy_1267_nhpbr_2`) · death: 【污染秽蚀】 (PollutedDie; PRTS 天赋 + * "{{特殊机制|死亡爆炸}}(释放半径2.0,持续8s的【污染秽蚀】)"). These two keys are the only enemies with this ability. + * Not this fog: 萨卡兹枯朽战车 / 尖端's 秽蚀轰击 (an attack skill, `_ignoreSilence`), 枯朽之种's self-destruct, + * 枯朽萃聚使徒's summons, 萨卡兹枯朽前锋 / 辟路前锋's arts while in a 源石污染区, 假想敌:蚀裂's death cloud, and + * 高能源石虫's 死亡爆炸. + * A death while silenced creates no fog. The owner's official-play recall of 2026-10-12 (GitHub #470) is that + * killing one that already shows the silence icon leaves no 污染秽蚀. That recall contradicts the PRTS / in-game + * text 「不可沉默」. Both handbook lines are NORMAL ("被击倒时释放污染秽蚀…"), not SILENCE. 0.2.0 already withheld + * the fog, from the general 死亡爆炸 line "如无特殊说明,此能力默认可沉默" and the client template + * `projectile_on_killed` (DESIGN §25.18.2). The recorded reason is now the owner's recall, against 「不可沉默」. */ const kitPolluted = (ab) => [{ sil: true, diff --git a/test/content/enemies_bosses.test.js b/test/content/enemies_bosses.test.js index 9b13746c..ddbe0770 100644 --- a/test/content/enemies_bosses.test.js +++ b/test/content/enemies_bosses.test.js @@ -70,12 +70,12 @@ test('stats-only enemies attach no ability and fight with their data stats', () } }); -// Abilities the client makes silenceable although their handbook line is NORMAL (the battle prefab's buff template checks -// SILENCED): 萨卡兹枯朽战士 / 组长's PollutedDie = template projectile_on_killed, the 死亡爆炸 template of 高能源石虫's -// SILENCE-flagged blast too (community report of 2026-10-06, item 10; DESIGN §25.18.2) +// 萨卡兹枯朽战士 / 组长's PollutedDie is silenced even though the handbook line is NORMAL and the PRTS / in-game text +// says 「不可沉默」. The owner's official-play recall of 2026-10-12 (GitHub #470) is that a death while silenced +// creates no fog. Only these two keys share that ability (DESIGN §30.20; 0.2.0's client-template reading is §25.18.2). const SILENCE_BY_TEMPLATE = new Set(['enemy_1267_nhpbr', 'enemy_1267_nhpbr_2']); -test('沉默: exactly the abilities whose handbook line is SILENCE-flagged can be silenced (every authored enemy), plus the ones whose client template checks it', () => { +test('沉默: exactly the abilities whose handbook line is SILENCE-flagged can be silenced (every authored enemy), plus 萨卡兹枯朽战士 / 组长\'s death fog', () => { const h = arena(); h.step(); for (const key of [...Object.keys(KITS), ...Object.keys(BOSS_KITS)]) { @@ -768,8 +768,8 @@ for (const key of ['enemy_1267_nhpbr', 'enemy_1267_nhpbr_2']) { approx(h.unit('t_wall').stats.taken, 3 * tb(key, 'PollutedDie.polluted_damage_low')); }); - // PRTS 特殊机制 死亡爆炸 "默认可沉默…若不处于沉默状态,将会…释放"; client template projectile_on_killed: CheckAbnormalFlag - // SILENCED (unset) before EmitProjectile (community report of 2026-10-06, item 10: a silenced one still poisoned) + // Owner's official-play recall of 2026-10-12 (GitHub #470): a death while the silence icon is already showing + // creates no fog. That contradicts the PRTS / in-game text 「不可沉默」. The same PollutedDie ability is on both keys. test(`${nm(key)}: silenced when it dies, it releases no 污染秽蚀; a silence that ran out does not stop it`, () => { const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 6 }] }); h.step(); diff --git a/test/match/diy-shop.test.js b/test/match/diy-shop.test.js index b589c0cb..c487a1c8 100644 --- a/test/match/diy-shop.test.js +++ b/test/match/diy-shop.test.js @@ -2,7 +2,8 @@ // the picks (room.diy → seats[].diy) are checked leniently, fixed for the match and applied to that player only — the // player's data view makes a slotted slot the operator (name, class, bonds from its factions, no 特质, the pick's skill // and module; the slot's tier, price and merge), each slotted piece has its own stock (8 at tier 5, 5 at tier 6), never -// enters the shared pool, joins only its player's shop once the 调度中心 reaches the slot's level, leaves the shop when +// enters the shared pool, joins only its player's shop once the 调度中心 reaches the slot's level, joins that player's +// promotion reward at the reward tier (one shop level earlier: shop 4 → tier 5, shop 5 → tier 6), leaves the shop when // every bond of it is banned, merges 3 → elite, and fields its operator in battle (PlayerBattleInput `diy`), client-run // and server-run identical; prep scouting and the result name the operator. import { test } from 'node:test'; @@ -177,16 +178,35 @@ test('buy → 3 copies merge into the elite (the stock pays the copies); sell / assert.equal(p0.allChess().filter(p => p0.gd.baseIdOf(p.id) === T5A).reduce((n, p) => n + p.poolCopies, 0), 9); assert.equal(p0.diyStock.left(T5A), 0, 'the extra copy does not reopen available stock'); h.invariants(); - // reward offers (the temporary refresh) draw it once the 调度中心 is at the slot's level + // The promotion reward draws 自选 of the reward tier (shop 4 → tier 5, shop 5 → tier 6), one shop level before the + // ordinary shop (owner 2026-10-12, #515). clear() returned the drained T5A copies; the loops roll this stock. clear(p0); p0.offers = []; - let hit = 0; - p0.shop.level = 4; - for (let i = 0; i < 200; i++) { const o = p0.pushRewardOffer('merge'); if (o && o.slots.some((s) => /_diy/.test(s.id))) hit++; p0.offers = []; } - assert.equal(hit, 0, 'a level-4 promotion reward (tier 5) never holds a tier-5 自选 piece'); - p0.shop.level = 6; - for (let i = 0; i < 300; i++) { const o = p0.pushRewardOffer('merge'); if (o && o.slots.some((s) => s.id === T6A)) hit++; p0.offers = []; } - assert.ok(hit > 0, 'a level-6 reward can hold the tier-6 piece'); + assert.deepEqual(p0.diyStock.snapshot(), { [T5A]: 8, [T5B]: 8, [T6A]: 5 }); + const tally = (level, n) => { + p0.shop.level = level; + const c = { t5: 0, t6: 0, any: 0 }; + for (let i = 0; i < n; i++) { + const o = p0.pushRewardOffer('merge'); + if (o) { + const ids = o.slots.map((s) => s.id); + if (ids.some((id) => id === T5A || id === T5B)) c.t5++; + if (ids.some((id) => id === T6A || id === T6B)) c.t6++; + if (ids.some((id) => /_diy/.test(id))) c.any++; + } + p0.offers = []; + } + return c; + }; + assert.equal(tally(3, 80).any, 0, 'a level-3 promotion reward (tier 4) holds no 自选 piece'); + const lv4 = tally(4, 200); + assert.ok(lv4.t5 > 0, 'a level-4 promotion reward (tier 5) can hold a tier-5 自选 piece'); + assert.equal(lv4.t6, 0, 'a level-4 promotion reward never holds a tier-6 自选 piece'); + const lv5 = tally(5, 300); + assert.ok(lv5.t6 > 0, 'a level-5 promotion reward (tier 6) can hold the tier-6 piece'); + assert.equal(lv5.t5, 0, 'while tier 6 still has copies, a level-5 reward does not walk down to a tier-5 自选 piece'); + const lv6 = tally(6, 300); + assert.ok(lv6.t6 > 0, 'a level-6 reward can still hold the tier-6 piece'); // elimination returns everything gain(p0, T6A); p0.eliminate(m.round); @@ -211,6 +231,12 @@ test('bans: a 自选 piece whose every bond is off this match has no stock (out h.toPrep(1); p0.shop.level = 5; for (let i = 0; i < 300; i++) { p0.rollShop(); assert.ok(!p0.shop.slots.some((s) => s && s.id === T5A)); } + p0.shop.level = 4; + for (let i = 0; i < 40; i++) { + const o = p0.pushRewardOffer('merge'); + assert.ok(!o || !o.slots.some((s) => s.id === T5A), 'a banned slot has no stock, so the tier-5 reward never holds it'); + p0.offers = []; + } h.m.flush(true); assert.deepEqual(h.lastTo('p_0', 'm.private').diyBanned, [T5A]); h.invariants();