diff --git a/docs/DATA.md b/docs/DATA.md
index 2b490443..be0cf589 100644
--- a/docs/DATA.md
+++ b/docs/DATA.md
@@ -319,7 +319,7 @@ Types: `BAND_INITIAL` 41, `BOND` 23, `EQUIP` 115, `ENEMY_GAIN` 129 (悬赏), `BU
| `events[id]` | `{"id":"enemy_initial_1","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"…","color":"#35d8b4","family":"bounty","solo":false,"training":false}` | 109 official choice events; `_s` = solo copies; `usedBy` on events opened by items (`hunter_band_1` ← 教鞭 / “神秘顾客”) |
| `families` | `{"bounty":{"name":"悬赏决策",…},"supply":{…},"shop":{…},"tactic":{…}}` | 悬赏决策 / 道具补给 / 机密商店 / 战术决策 |
| `format` | `{"multi":{"cards":6,"pickOrder":"random","firstPickSec":30,"otherPickSec":16,"onTimeout":"autoPickRandom","eachPlayerPicks":1},"solo":{"cards":3,"timer":null},"opensAfterIncome":true}` | |
-| `cards.bounty[]` | `{"effectId":"enemyeffect_10_4","name":"悬赏·飞行I","tier":1,"coin":1,"payout":"kill","rounds":2,"multiRound":false,"enemyKey":"enemy_1005_yokai","count":1,"adds":[…],"draft":true,"draftExcluded":null}` | `payout` `kill` (killer gets coins, 联防 helpers too) / `perfect` (chooser's own phase perfect); `rounds` = battles affected (99 = all remaining); `tier` from the I/II/III suffix (else coin value); `draft` = offered by the 悬赏决策 draft — the PRTS 下半 记录 §机变阶段 "敌人轮选" table (105 cards: 1- and 2-battle kill bounties, 源石虫·特训, the 7 multi-round cards); else `draftExcluded` `perfect` (战术特训: "仅由法术教鞭生成" — the 教鞭 Art's cards; 20) / `hidden` (鸭爵 / 高普尼克 / 流泪小子 / 圆仔, commented out of the table; 4) — build-data `bountyDraftExclusion`, `server/match/choices.js draftBounty`, user playtest #6 item 4; `draftHalf` = the half whose drafts offer it: 1 (rounds ≤ 7) for the 42 "接下来两场作战" cards, 2 (R8 on) for the "下场作战" and multi-round cards (63), null when not drafted — build-data `bountyDraftHalf`, player feedback after 0.1.0 report #2 |
+| `cards.bounty[]` | `{"effectId":"enemyeffect_10_4","name":"悬赏·飞行I","tier":1,"coin":1,"payout":"kill","rounds":2,"multiRound":false,"enemyKey":"enemy_1005_yokai","count":1,"adds":[…],"draft":true,"draftExcluded":null}` | `payout` `kill` (killer gets coins, 联防 helpers too) / `perfect` (chooser's own phase perfect); `rounds` = battles affected (99 = all remaining); `tier` from the I/II/III suffix (else coin value); `draft` = offered by the 悬赏决策 draft — the PRTS 下半 记录 §机变阶段 "敌人轮选" table (105 cards: 1- and 2-battle kill bounties, 源石虫·特训, the 7 multi-round cards); else `draftExcluded` `perfect` (战术特训: "仅由法术教鞭生成" — the 教鞭 Art's cards; 20) / `hidden` (鸭爵 / 高普尼克 / 流泪小子 / 圆仔, commented out of the table; 4) — build-data `bountyDraftExclusion`, `server/match/choices.js draftBounty`, user playtest #6 item 4; `draftHalf` = the half whose drafts offer it: 1 (rounds ≤ 7) for the 42 "接下来两场作战" cards, 2 (R8 on) for the "下场作战" and multi-round cards (63), null when not drafted — split by battle count, [ASSUMED] beyond the evidence listed in build-data `bountyDraftHalf`, player feedback after 0.1.0 report #2 |
| `cards.tactic[]` | `{"effectId":"map_m01_1","name":"模拟战场演变·模式一","kind":"terrain","stageId":"act1autochess_m01","team":false}` | `kind` `ally` / `enemyDebuff` / `terrain` (only for the match stage); `team` = also given to teammates |
| `schedule[modeId]` | `{"spRounds":[3,6,9],"rounds":{"3":{"families":[{"family":"bounty","weight":50},…],"cards":6,"supplyTiers":[1,4],"bountyHalf":1,"events":{"bounty":[…],"supply":[…],"shop":[…],"tactic":[…]},"assumed":true}}}` | research 01 A4 defaults: pick a family by weight; `supplyTiers` = item tier window for 道具补给; 机密商店 draws any tier; `bountyHalf` = the `draftHalf` of the round's 悬赏决策 cards (1 up to `factions.generation.firstHalfMaxRound` 7, else 2); `events` = official event ids usable as the phase header (`enemy_initial_*` in the first half, `bounty_hunter_*` / `bossInitial_*` from R8) |
| `pools[poolId]` | `{"kind":"equip","items":[…]}` / `{"kind":"chess","tier":2,"rule":"shopEligible"}` | server-side reward pools referenced by effects (`pool_equip_*`, `pool_chess_shop_N_reward`, `pool_char_later`, …), all [ASSUMED]; `rule:"shopEligible"` = visible/non-hidden entries, `maxTier:"shopLevel"` = ≤ current shop level, `weighted` = `[[id, weight]]`. `pool_equip_vict` (维多利亚, every 25 layers: "获得一件带有随机特殊效果的维式重锤") and `pool_equip_rockr` (洛洛's 定制品; user playtest #4 — the research 04 §8 guess 有限加速器 / 激光发射器 / 护盾无人机 / 双模机械臂 / 蜂鸣器 is superseded) = the 4 special 维式重锤, uniform |
diff --git a/docs/META.md b/docs/META.md
index 460ebb5c..9a41cdc2 100644
--- a/docs/META.md
+++ b/docs/META.md
@@ -115,8 +115,10 @@ or the next two ("接下来两场作战"), 源石虫·特训, and the 7 multi-ro
feedback after 0.1.0, report #2 — late bounty enemies in the early drafts): a draft up to R7 (the official wave
generator's first half: 绝境 / 终极 R3, 险境 co-op R3 / R6) draws the 42 "接下来两场作战" cards, a draft from R8 on (R9)
the 56 "下场作战" cards (boss bounties, 特异 giants, the faction _7 / _8 cards, 源石虫·特训) and the 7 multi-round ones —
-uniformly, with no tier window (the official late-draft screenshot shows boss bounties worth 1 to 6 together; the
-evidence is in `tools/build-data.mjs bountyDraftHalf`). The mode's inactive enemy list does not thin the draft (PRTS
+uniformly, with no tier window (the official late-draft screenshot shows boss bounties worth 1 to 6 together). The
+split by battle count is [ASSUMED] beyond that screenshot and a trend in the faction series (12 of the 18 two-battle
+faction cards bring first-half enemies, no next-battle card does); the evidence and the counter-evidence (the 假想敌
+cards are named `enemyInitial_*`) are in `tools/build-data.mjs bountyDraftHalf`. The mode's inactive enemy list does not thin the draft (PRTS
11/18 note "不影响悬赏决策出场"). Every bounty card carries the effect's official rich text `descRaw` (the battles in blue
"下场作战" / "两场作战"; the overlay and the effects column render it; the effects column also says "还剩 N 场作战").
**Multi-round cards last two battles** (`choices.js MULTI_ROUND_BOUNTY_BATTLES = 2`, `bountyBattles` / `bountyText`): the
@@ -417,7 +419,7 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
consumed copies are removed; a reward offer of 3
**different** free chess of tier min(level+1, 6) (copy-weighted from the shared pool, already-drawn ones excluded; a
short tier tops up from the tier below — user playtest #6 item 19; pick 1, expires at prep end; queued when several
- merges happen). The same rule holds for every way a merge completes — buy, reward pick, effect / band / choice grants
+ merges or special refreshes happen — `shop.rewardOffer.queued` counts the ones behind the shown offer). The same rule holds for every way a merge completes — buy, reward pick, effect / band / choice grants
(`acquireChess`), transformations — and in every phase: a merge completed after the prep (SETTLE / Final Assault
effects such as 突变细胞) keeps its offer for the next prep; its elite takes the deployed copy's tile at once, or goes to
the hand / temp (kept through the next prep, see Hand). In a boss round's prep the tile is read on the player's half of
diff --git a/docs/PLAYING.md b/docs/PLAYING.md
index 37f89ef7..bc1485c5 100644
--- a/docs/PLAYING.md
+++ b/docs/PLAYING.md
@@ -52,7 +52,7 @@
## 3. 招募、整备区与晋升
-- **购买点两次**:第一次点商店卡片只选中(卡片抬起并打开详情,底部出现「确认购买 · 再次点击」),再点这张卡片的**任意位置**(包括金色的确认条)才购买;点别处或按 Esc 取消。升级调度中心、晋升奖励同样两次;策略或装备带来的免费挑选(如凯瑟琳【定向投放】升级后刷新的 3 件装备、娜仁图亚【见者有份】的 2 件装备、寻呼模块的 3 名干员)也一样出现在商店卡片的位置,左侧标题写着它的来源,装备显示为装备卡(图标、名称、说明、FREE),选 1 个免费获得。不能购买时(例如已准备就绪)点卡片只打开详情。
+- **购买点两次**:第一次点商店卡片只选中(卡片抬起并打开详情,底部出现「确认购买 · 再次点击」),再点这张卡片的**任意位置**(包括金色的确认条)才购买;点别处或按 Esc 取消。升级调度中心、晋升奖励同样两次;策略或装备带来的免费挑选(如凯瑟琳【定向投放】升级后刷新的 3 件装备、娜仁图亚【见者有份】的 2 件装备、寻呼模块的 3 名干员)也一样出现在商店卡片的位置,左侧标题写着它的来源,装备显示为装备卡(图标、名称、说明、FREE),选 1 个免费获得;同时有多个免费挑选时依次出现,标题下和「稍后」后的提示按钮会写还有几项(如「+1」)。不能购买时(例如已准备就绪)点卡片只打开详情。
- **公共卡池**(同盟):每名干员的副本数有限,I–VI 阶分别 12 / 14 / 18 / 16 / 8 / 5 份,所有玩家共用;持有的干员(棋盘 + 整备区)占用副本,商店里展示的不占用。一名干员被大家拿光后就刷不出来了;淘汰玩家的干员会还回卡池。
- **位置上限**:棋盘最多部署 **8** 名;整备区 **10** 格;整备区满时新获得的单位(合成的精锐、奖励、退回的装备等)进入**临时整备区**(整备区前面的 5 格)。临时整备区有单位时**不能准备就绪**,棋盘上会用红色虚线框标出这一排并说明规则:把它们放进整备区或战场、配发或使用,或者出售 / 销毁。休整期中进入临时整备区的单位,若到**本休整期结束**时仍留在那里就会被自动处理(干员返还卡池、装备销毁);作战、结算期间或准备就绪之后才进入的(例如战斗奖励、结算时合成的精锐),会保留到**下一个休整期**结束,你一定有机会处理它们。
- **晋升精锐**:3 名同名干员(无论是否部署)自动合成**精锐**(等级、技能、模组大幅提升;风丸只需 2 名),精锐不会再合成。如果被合成的干员里有**已经部署在战场上**的,精锐会直接出现在**那名干员的位置**(保持原来的朝向,不多占部署位;有两三名都在场上时,取最先部署的那名——从上到下、从左到右);否则精锐进入整备区(满了进入临时整备区)。原先配发的装备都退回整备区,需要重新配发;被合成干员的召唤物随之移除,场上的精锐会把自己的召唤物送进整备区。点选一张能合成的商店卡片时,精锐将出现的那一格会亮起金色边框,同时打开的详情卡里也会写明去向(「可晋升:精锐干员将出现在作战区原位置 / 将进入整备区」;详情卡会自动停靠在不遮挡这一格的一侧)。在回合开始或结算时因策略、奖励等完成的合成,精锐出现时同样会播放金色的晋升特效。
diff --git a/public/css/screens/game-shop.css b/public/css/screens/game-shop.css
index 0f7c9454..05600dbb 100644
--- a/public/css/screens/game-shop.css
+++ b/public/css/screens/game-shop.css
@@ -187,6 +187,7 @@
.rwtag__title { writing-mode: vertical-rl; font-size: .22rem; font-weight: 900; letter-spacing: .2em; line-height: 1; }
.rwtag__micro { font-family: var(--font-display); font-size: .09rem; letter-spacing: .14em; opacity: .75; }
.rwtag__sub { font-size: .12rem; color: var(--text-md); line-height: 1.3; }
+.rwtag__more { color: var(--gold); }
.rwtag__later { display: flex; align-items: center; gap: .02rem; margin-top: .04rem; padding: .02rem .08rem; font-size: .13rem; color: var(--text-lo);
background: rgba(0, 0, 0, .35); border: 1px solid var(--line-2); }
.rwtag__later .icon { width: .12rem; height: .12rem; }
@@ -200,6 +201,7 @@
animation: pulse-gold 1.6s ease-in-out infinite;
}
.rewardpill .icon { width: .2rem; height: .2rem; }
+.rewardpill__more { padding: 0 .06rem; border: 1px solid var(--gold); font-size: .13rem; line-height: 1.4; }
@keyframes pulse-gold { 50% { box-shadow: 0 0 .24rem rgba(255, 198, 0, .5); } }
/* ---- two-tap buy / upgrade (research 09 §5): the first tap lifts + enlarges the card and shows 确认购买 ---- */
diff --git a/public/js/ui/gameLogic.js b/public/js/ui/gameLogic.js
index 92072abf..f19f7ddc 100644
--- a/public/js/ui/gameLogic.js
+++ b/public/js/ui/gameLogic.js
@@ -543,18 +543,22 @@ export function completesMerge(priv, slot, { getChess = () => null, getItem = ()
* reads 晋升奖励 / PROMOTION; any other offer — a strategy's special refresh (凯瑟琳 定向投放, 娜仁图亚 见者有份), an item
* (寻呼模块, 信标) or a 特质 (松果) — reads its `label` with the refresh icon (player report #6 after 0.1.0: 凯瑟琳's three
* items showed up as nameless operator cards under 晋升奖励). `items` = the offer holds items (cards drawn as item cards).
+ * `queued` = offers waiting behind this one (shop.rewardOffer.queued, e.g. a 定向投放 behind a promotion reward put
+ * off): `more` (header) and the pill's "+N" say so, null / no suffix when none.
* @param {any} offer
- * @returns {{ title: string, micro: string, sub: string, icon: string, items: boolean, pill: string }}
+ * @returns {{ title: string, micro: string, sub: string, icon: string, items: boolean, pill: string, queued: number, more: string|null }}
*/
export function offerHeader(offer) {
const slots = isObj(offer) && Array.isArray(offer.slots) ? offer.slots : [];
const items = slots.some((s) => isObj(s) && s.kind === 'item');
const label = isObj(offer) && typeof offer.label === 'string' && offer.label ? offer.label : null;
+ const queued = isObj(offer) && Number.isInteger(offer.queued) && offer.queued > 0 ? offer.queued : 0;
+ const tail = { queued, more: queued ? `之后还有 ${queued} 项` : null };
if (!items && (!isObj(offer) || offer.source === 'merge' || offer.source == null) && !label) {
- return { title: '晋升奖励', micro: 'PROMOTION', sub: '免费选择 1 名', icon: 'crown', items: false, pill: '晋升奖励待选择' };
+ return { title: '晋升奖励', micro: 'PROMOTION', sub: '免费选择 1 名', icon: 'crown', items: false, pill: '晋升奖励待选择', ...tail };
}
const title = label || (items ? '装备补给' : '特殊招募');
- return { title, micro: 'SPECIAL', sub: items ? '免费选择 1 件' : '免费选择 1 名', icon: 'refresh', items, pill: `${title}待选择` };
+ return { title, micro: 'SPECIAL', sub: items ? '免费选择 1 件' : '免费选择 1 名', icon: 'refresh', items, pill: `${title}待选择`, ...tail };
}
/**
diff --git a/public/js/ui/rewardOverlay.js b/public/js/ui/rewardOverlay.js
index 2e6a14eb..9713216a 100644
--- a/public/js/ui/rewardOverlay.js
+++ b/public/js/ui/rewardOverlay.js
@@ -1,8 +1,9 @@
// Pick-one offer reminder (research 00 §3): the offer's cards themselves live in the shop bar (shopBar.js RewardCards —
// after a promotion, or a special refresh such as 凯瑟琳's 定向投放, they replace the bar's operator cards, like the
// original). When the player puts the pick off (稍后选择) this pill above the bar brings the cards back; it names the
-// offer like the bar's header (gameLogic.offerHeader: 晋升奖励待选择 / 定向投放待选择 …, player report #6 after 0.1.0) and
-// disappears once the server clears `shop.rewardOffer` (picked, or the round ended).
+// offer like the bar's header (gameLogic.offerHeader: 晋升奖励待选择 / 定向投放待选择 …, player report #6 after 0.1.0), with
+// "+N" while N more offers wait behind it, and disappears once the server clears `shop.rewardOffer` (picked, or the
+// round ended).
import { html, Icon, TierChip } from './components.js';
import { offerHeader } from './gameLogic.js';
@@ -15,6 +16,6 @@ export function RewardOverlay({ priv, minimized, onMinimize }) {
if (!offer || !Array.isArray(offer.slots) || !offer.slots.length || !minimized) return null;
const head = offerHeader(offer);
return html``;
}
diff --git a/public/js/ui/shopBar.js b/public/js/ui/shopBar.js
index 59100f35..335a2050 100644
--- a/public/js/ui/shopBar.js
+++ b/public/js/ui/shopBar.js
@@ -217,7 +217,7 @@ export function useTwoTap({ editable, keys }) {
* the special refreshes of strategies, items and 特质 — e.g. 凯瑟琳 【定向投放】 "在调度中心刷新随机3件装备,可以选择并获得
* 其中1件". Each slot is drawn by its kind: an item slot is an item card (icon, name, tier, description, FREE) — player
* report #6 after 0.1.0, the items used to be drawn as nameless operator cards — and the header names the offer
- * (gameLogic.offerHeader: 晋升奖励 / the strategy's effect name …).
+ * (gameLogic.offerHeader: 晋升奖励 / the strategy's effect name …; "之后还有 N 项" while more offers wait behind it).
*/
export function RewardCards({ offer, priv, editable, onPick, onDetail, onLater, armed, onTap }) {
const head = offerHeader(offer);
@@ -227,6 +227,7 @@ export function RewardCards({ offer, priv, editable, onPick, onDetail, onLater,
${head.title}${head.micro}${head.sub}
+ ${head.more ? html`${head.more}` : null}
diff --git a/server/match/PlayerState.js b/server/match/PlayerState.js
index b42db873..18a7967b 100644
--- a/server/match/PlayerState.js
+++ b/server/match/PlayerState.js
@@ -1485,8 +1485,8 @@ export class PlayerState {
frozen: this.shop.frozen,
slots,
// `source` 'merge' = the promotion reward (晋升奖励); any other offer (a strategy, an item, a 特质) carries the
- // `label` the bar shows instead (player report #6 after 0.1.0)
- rewardOffer: offer ? { tier: offer.tier, source: offer.source === 'merge' ? 'merge' : 'special', label: offer.label || null, slots: offer.slots.map((s) => ({ kind: s.kind === 'item' ? 'item' : 'chess', id: s.id, price: s.price, sold: !!s.sold })) } : null,
+ // `label` the bar shows instead; `queued` = offers waiting behind it (player report #6 after 0.1.0)
+ rewardOffer: offer ? { tier: offer.tier, source: offer.source === 'merge' ? 'merge' : 'special', label: offer.label || null, queued: this.offers.length - 1, slots: offer.slots.map((s) => ({ kind: s.kind === 'item' ? 'item' : 'chess', id: s.id, price: s.price, sold: !!s.sold })) } : null,
},
hand: this.hand.map((p) => (p ? this.pieceView(p) : null)),
temp: this.temp.map((p) => (p ? this.pieceView(p) : null)),
diff --git a/server/match/choices.js b/server/match/choices.js
index 3d0e079a..0cd3e173 100644
--- a/server/match/choices.js
+++ b/server/match/choices.js
@@ -9,9 +9,10 @@
// them) of the draft's half (`bountyDraftHalf`, choices.json `draftHalf`; player feedback after
// 0.1.0, report #2 — late bounty enemies in the early drafts): rounds ≤ 7 (the official wave
// generator's first half: R3, 险境 R6) offer the "接下来两场作战" cards, R8 on (R9) the "下场作战" cards
-// (boss bounties, 特异 giants, the faction _7 / _8 cards …) and the multi-round ones (tools/build-data.mjs
-// bountyDraftHalf has the evidence); drawn uniformly (no tier window: the official late draft shows
-// boss bounties worth 1 to 6 together); the mode's inactive enemy list does not apply (PRTS
+// (boss bounties, 特异 giants, the faction _7 / _8 cards …) and the multi-round ones ([ASSUMED] beyond
+// the evidence in tools/build-data.mjs bountyDraftHalf, which also lists the counter-evidence);
+// drawn uniformly (no tier window: the official late draft shows boss bounties worth 1 to 6
+// together); the mode's inactive enemy list does not apply (PRTS
// 卫戍协议:盟约 11/18 note "以上调整仅针对战术特训敌人,不影响悬赏决策出场"); each card carries its official
// rich text `descRaw` (the battles in blue "下场作战" / "两场作战"); a multi-round card lasts
// MULTI_ROUND_BOUNTY_BATTLES battles and says so (`bountyBattles` / `bountyText`: the user's call
diff --git a/test/content/choices.test.js b/test/content/choices.test.js
index 06770bba..ec430665 100644
--- a/test/content/choices.test.js
+++ b/test/content/choices.test.js
@@ -148,7 +148,7 @@ const KILL2 = 'enemyeffect_12_4'; // 悬赏·损伤I: 1 enemy, killer +1, next 2
const PERF = 'enemyeffect_11_1'; // 战术特训·频次I: 1 enemy, +1 when the own phase is perfect
test('悬赏决策 E2E (co-op, real battles): the picker\'s own battle gets the enemy; the killer earns the kill coins, 战术特训 pays a perfect own phase', () => {
- const data = dataWith('mode_multi_normal', 3, 'bounty', { bounty: [KILL, PERF], extra: { bountyTiers: [1, 2, 3] } });
+ const data = dataWith('mode_multi_normal', 3, 'bounty', { bounty: [KILL, PERF] });
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 31, data, registry: REG });
const m = h.m;
// real battles where every enemy of a player is struck down by that player's operator the tick it appears
diff --git a/test/match/feedback1-bounty.test.js b/test/match/feedback1-bounty.test.js
index 0b0f4492..f17c9f31 100644
--- a/test/match/feedback1-bounty.test.js
+++ b/test/match/feedback1-bounty.test.js
@@ -3,11 +3,14 @@
// 弑君者, 萨卡兹百夫长, 鼠王, 庞贝, 大鲍勃), second-half specials (尖端萨卡兹枯朽战车, 假想敌:蚀裂 / 淤困 …, 法术大师A2,
// 灼藤, 重弩突袭者) and their upgraded variants (新硎, 家族暗影灭迹人, 异光体孽生者 …): the card tier read from the I/II/III
// suffix, the boss bounties' small coin values and the multi-round cards' fixed tier 2 all passed the R3 tier window
-// [1, 2]. Official evidence (tools/build-data.mjs bountyDraftHalf): the one official co-op draft screenshot shows six boss
-// bounties with coins 1–6 together at team LP 6 / 10 / 9 / 15 (a late draft), and in every faction series of the data
-// the "接下来两场作战" cards use first-half enemies, the "下场作战" ones second-half enemies. Now a first-half draft
-// (R ≤ 7, the official wave generator's half) offers the two-battle cards, a draft from R8 on the next-battle and
-// multi-round cards. Real data, real draft code, the real match path for the players' scenario.
+// [1, 2]. Evidence (tools/build-data.mjs bountyDraftHalf): the one official co-op draft screenshot shows six boss
+// bounties with coins 1–6 together at team LP 6 / 10 / 9 / 15 (a late draft); in the faction series 12 of the 18
+// "接下来两场作战" cards bring first-half enemies (5 bring enemies outside the wave tables, enemyeffect_12_4 底海滑动者
+// one attached only to second-half specials) and no "下场作战" card a first-half one — a trend, not a rule. Now a
+// first-half draft (R ≤ 7, the official wave generator's half) offers the two-battle cards, a draft from R8 on the
+// next-battle and multi-round cards ([ASSUMED] beyond that evidence: the 假想敌 cards are named enemyInitial_*, which
+// could point at the early draft — kept late, a question for the user). Real data, real draft code, the real match path
+// for the players' scenario.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
@@ -73,7 +76,9 @@ test('#2 data cross-check (official tables): no first-half card brings a boss or
if (lateEnemy) { assert.equal(c.draftHalf, 2, `${c.effectId} ${c.name}`); late++; }
}
assert.ok(late >= 30, `${late} cards with a boss or a second-half special`);
- // the faction series pair the halves: _4–_6 (two battles) with first-half enemies, _7 / _8 (next battle) with later ones
+ // the rule follows the battle count: the faction series' _4–_6 (two battles) are first-half cards, _7 / _8 (next
+ // battle) second-half ones — whatever wave half their enemy has (enemyeffect_12_4 底海滑动者 is a second-half
+ // attachment, yet a two-battle card)
for (const s of [10, 11, 12, 13, 14, 15]) {
for (const i of [4, 5, 6]) assert.equal(CARD.get(`enemyeffect_${s}_${i}`).draftHalf, 1, `enemyeffect_${s}_${i}`);
for (const i of [7, 8]) assert.equal(CARD.get(`enemyeffect_${s}_${i}`).draftHalf, 2, `enemyeffect_${s}_${i}`);
diff --git a/test/ui/feedback1-offers.e2e.test.js b/test/ui/feedback1-offers.e2e.test.js
index 3c4859bc..4652adff 100644
--- a/test/ui/feedback1-offers.e2e.test.js
+++ b/test/ui/feedback1-offers.e2e.test.js
@@ -4,7 +4,9 @@
// three free items replace the bar's operator cards — each drawn as an item card (icon, name, tier, description, FREE),
// under a 定向投放 header (not 晋升奖励 / PROMOTION), with the item's detail on the first tap; the second tap takes it into
// the hand and the bar shows the shop again. Desktop 1920×1080 and the phones 844×390 / 756×366 (touch). Zero console
-// errors. Screenshots: test/e2e/out/fb1-offer-*.png.
+// errors. Screenshots: test/e2e/out/fb1-offer-*.png. A second case puts the offer off (稍后) and levels up again: the
+// pill reads 定向投放待选择 +1, the header 之后还有 1 项, and the second offer follows the first pick
+// (test/e2e/out/fb1-queue-*.png).
//
// SP_E2E=1 node --test test/ui/feedback1-offers.e2e.test.js
@@ -139,4 +141,62 @@ describe('player report #6: 凯瑟琳\'s three free items after a level-up (real
await srv.stop();
}
});
+
+ test('an offer put off (稍后) and a second level-up: the pill says "+1", the header "之后还有 1 项", then the second offer', { timeout: 180000 }, async () => {
+ const puppeteer = (await import('puppeteer-core')).default;
+ const srv = await startRealServer({ fast: { timerScale: 0.5, combatSpeed: 4, startRound: 2 } });
+ const c = new Client(puppeteer, srv.base, 'cathy2', { prefix: 'fb1-queue' });
+ const levelUp = async () => { await c.click('.lvcard'); await sleep(250); await c.click('.lvcard'); };
+ const offerOf = () => c.page.evaluate(() => globalThis.__SP__.store.get().match.private?.shop?.rewardOffer || null);
+ try {
+ await soloToPrep(c, '凯瑟琳');
+ await levelUp();
+ await c.page.waitForFunction(() => !!document.querySelector('.shopbar__reward .scard'), { timeout: 8000 });
+ await c.click('.rwtag__later');
+ await c.page.waitForFunction(() => !!document.querySelector('.rewardpill') && !!document.querySelector('.shopbar__cards'), { timeout: 8000 });
+ await levelUp();
+ await c.page.waitForFunction(() => globalThis.__SP__.store.get().match.private?.shop?.rewardOffer?.queued === 1, { timeout: 8000 });
+ await sleep(400);
+ const pill = await c.page.evaluate(() => document.querySelector('.rewardpill')?.textContent.replace(/\s+/g, '') || '');
+ assert.match(pill, /定向投放待选择/);
+ assert.match(pill, /\+1/, `the pill counts the offer behind (${pill})`);
+ await c.shot('pill');
+ await c.click('.rewardpill');
+ await c.page.waitForFunction(() => !!document.querySelector('.shopbar__reward .scard'), { timeout: 8000 });
+ await sleep(300);
+ assert.equal(await c.page.evaluate(() => document.querySelector('.rwtag__more')?.textContent.trim() || null), '之后还有 1 项');
+ await c.shot('offer');
+ // the narrowest phone: the header strip still holds every line and the 稍后 button inside the screen
+ const browserUa = await c.browser.userAgent();
+ await c.page.emulate({ viewport: { width: 756, height: 366, deviceScaleFactor: 2, isMobile: true, hasTouch: true, isLandscape: true }, userAgent: browserUa });
+ await c.page.waitForFunction(() => !!document.querySelector('.rwtag__more'), { timeout: 30000 });
+ await sleep(900);
+ await c.page.screenshot({ path: path.join(ROOT, 'test/e2e/out', 'fb1-queue-756x366.png') });
+ const strip = await c.page.evaluate(() => {
+ const box = (el) => { const r = el.getBoundingClientRect(); return { l: r.left, t: r.top, r: r.right, b: r.bottom }; };
+ const tag = document.querySelector('.rwtag');
+ return { tag: box(tag), parts: [...tag.children].map((el) => ({ cls: el.className, ...box(el) })), vw: innerWidth, vh: innerHeight };
+ });
+ for (const part of strip.parts) {
+ assert.ok(part.t >= strip.tag.t - 0.5 && part.b <= strip.tag.b + 0.5 && part.b <= strip.vh + 0.5, `756×366 ${part.cls}: inside the strip and the screen ${JSON.stringify(strip)}`);
+ }
+ await c.page.emulate({ viewport: { width: 1920, height: 1080, deviceScaleFactor: 1, isMobile: false, hasTouch: false }, userAgent: browserUa });
+ await c.page.waitForFunction(() => !!document.querySelector('.shopbar__reward .scard'), { timeout: 30000 });
+ await sleep(900);
+ await c.hookRequests();
+ const first = await offerOf();
+ await c.click('.shopbar__reward .scard');
+ await sleep(300);
+ await c.click('.shopbar__reward .scard.is-armed');
+ await c.page.waitForFunction((ids) => { const o = globalThis.__SP__.store.get().match.private?.shop?.rewardOffer; return !!o && o.queued === 0 && o.slots.map((x) => x.id).join() !== ids; }, { timeout: 8000 }, first.slots.map((x) => x.id).join());
+ await sleep(300);
+ assert.equal(await c.exists('.rwtag__more'), false, 'nothing behind the second offer');
+ assert.equal(await c.page.evaluate(() => document.querySelector('.rwtag__title')?.textContent.trim()), '定向投放');
+ await c.shot('second');
+ assert.deepEqual(problemsOf([c]), []);
+ } finally {
+ await c.close();
+ await srv.stop();
+ }
+ });
});
diff --git a/test/ui/feedback1-offers.test.js b/test/ui/feedback1-offers.test.js
index 8c57e4a4..0f5a3f43 100644
--- a/test/ui/feedback1-offers.test.js
+++ b/test/ui/feedback1-offers.test.js
@@ -4,8 +4,10 @@
// tier I, no description) under the promotion reward's 晋升奖励 / PROMOTION header, and the reminder pill said
// 晋升奖励待选择. Now: m.private shop.rewardOffer says what made the offer (`source` 'merge' | 'special', `label` = the
// effect's name — effectsMeta.offerLabel), an item slot is drawn as an item card (FREE) and the header / pill name the
-// offer (gameLogic.offerHeader). The same path serves 娜仁图亚 见者有份 (two items), 寻呼模块 / 信标 (operators, an item's
-// name) and 松果's 特质. Real match paths for the server side; the bar's offer cards rendered as vnodes.
+// offer (gameLogic.offerHeader; `queued` — "之后还有 N 项" / the pill's "+N" — when more offers wait behind it, e.g. a
+// 定向投放 behind a promotion reward put off). The same path serves 娜仁图亚 见者有份 (two items), 寻呼模块 / 信标
+// (operators, an item's name) and 松果's 特质. Real match paths for the server side; the bar's offer cards rendered as
+// vnodes.
// The browser check (desktop + phones, real input) is test/ui/feedback1-offers.e2e.test.js (SP_E2E=1).
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
@@ -90,9 +92,11 @@ describe('#6 the server names its offers (m.private shop.rewardOffer source / la
for (let i = 0; i < 2; i++) { ps.shop.upgradePrice = 0; assert.deepEqual(m.handle('p_0', { t: 'g.levelUp' }), OK); }
assert.equal(ps.offers.length, 2, 'two offers queued');
assert.equal(ps.privateView().shop.rewardOffer.slots.length, 3, 'the first one is shown');
+ assert.equal(ps.privateView().shop.rewardOffer.queued, 1, 'one more waits behind it');
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 0 }), OK);
const second = ps.privateView().shop.rewardOffer;
assert.ok(second && second.label === '定向投放' && second.slots.length === 3 && second.slots.every((s) => !s.sold), 'the second offer, all free to pick');
+ assert.equal(second.queued, 0);
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 2 }), OK);
assert.equal(ps.privateView().shop.rewardOffer, null);
m.dispose();
@@ -126,6 +130,28 @@ describe('#6 the server names its offers (m.private shop.rewardOffer source / la
m.dispose();
});
+ test('a promotion reward put off, then a 凯瑟琳 level-up: the promotion reward shows with "+1" (queued 1), then 定向投放', () => {
+ const { m, ps } = prep('band_cathy', 65);
+ ps.pushRewardOffer('merge');
+ ps.shop.upgradePrice = 0;
+ assert.deepEqual(m.handle('p_0', { t: 'g.levelUp' }), OK);
+ let offer = ps.privateView().shop.rewardOffer;
+ assert.equal(offer.source, 'merge', 'the promotion reward first');
+ assert.equal(offer.queued, 1, 'the 定向投放 offer waits behind it');
+ const head = offerHeader(offer);
+ assert.equal(head.pill, '晋升奖励待选择');
+ assert.equal(head.queued, 1);
+ assert.equal(head.more, '之后还有 1 项');
+ const pill = RewardOverlay({ priv: { shop: { rewardOffer: offer } }, minimized: true, onMinimize() {} });
+ const texts = [...walk(pill)].flatMap((n) => (Array.isArray(n.props?.children) ? n.props.children.filter((x) => typeof x === 'string' || typeof x === 'number') : typeof n.props?.children === 'string' ? [n.props.children] : []));
+ assert.ok(texts.join('').includes('+1'), `the pill says +1 (${texts.join(' | ')})`);
+ assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 0 }), OK);
+ offer = ps.privateView().shop.rewardOffer;
+ assert.ok(offer.source === 'special' && offer.label === '定向投放' && offer.queued === 0, JSON.stringify(offer));
+ assert.equal(offerHeader(offer).more, null);
+ m.dispose();
+ });
+
test('offerLabel: a strategy → its effect name, an item → its name, a 特质 → its operator, anything else → null', () => {
const { m } = prep(null, 64);
const gd = m.gd;
@@ -146,8 +172,10 @@ describe('#6 the shop bar draws the offer by its slots and names it', () => {
const merge = { tier: 3, source: 'merge', label: null, slots: chessOfTier(3).slice(0, 3).map((id) => ({ kind: 'chess', id, price: 0, sold: false })) };
test('offerHeader: 晋升奖励 for the promotion reward, the label for a special refresh (items: 免费选择 1 件)', () => {
- assert.deepEqual(offerHeader(merge), { title: '晋升奖励', micro: 'PROMOTION', sub: '免费选择 1 名', icon: 'crown', items: false, pill: '晋升奖励待选择' });
- assert.deepEqual(offerHeader(cathy), { title: '定向投放', micro: 'SPECIAL', sub: '免费选择 1 件', icon: 'refresh', items: true, pill: '定向投放待选择' });
+ assert.deepEqual(offerHeader(merge), { title: '晋升奖励', micro: 'PROMOTION', sub: '免费选择 1 名', icon: 'crown', items: false, pill: '晋升奖励待选择', queued: 0, more: null });
+ assert.deepEqual(offerHeader(cathy), { title: '定向投放', micro: 'SPECIAL', sub: '免费选择 1 件', icon: 'refresh', items: true, pill: '定向投放待选择', queued: 0, more: null });
+ assert.equal(offerHeader({ ...cathy, queued: 2 }).more, '之后还有 2 项');
+ assert.equal(offerHeader({ ...cathy, queued: -1 }).queued, 0, 'a bad count is ignored');
assert.equal(offerHeader({ ...merge, source: 'special', label: '寻呼模块' }).title, '寻呼模块');
assert.equal(offerHeader({ ...merge, source: 'special', label: '寻呼模块' }).sub, '免费选择 1 名');
assert.equal(offerHeader({ ...cathy, label: null }).title, '装备补给', 'an unnamed item offer');
@@ -179,6 +207,11 @@ describe('#6 the shop bar draws the offer by its slots and names it', () => {
const mcards = [...walk(vm)].filter((n) => n.type === ItemCard || n.type === ChessCard);
assert.ok(mcards.length === 3 && mcards.every((c) => c.type === ChessCard));
assert.equal(String([...walk(vm)].find((n) => typeof n.props?.class === 'string' && n.props.class.includes('rwtag__title')).props.children), '晋升奖励');
+ assert.ok(!nodes.some((n) => typeof n.props?.class === 'string' && n.props.class.includes('rwtag__more')), 'nothing queued: no "之后还有" line');
+ // an offer with another one behind it says so in the header
+ const vq = RewardCards({ offer: { ...merge, queued: 1 }, priv: { ...priv, shop: { rewardOffer: merge } }, editable: true, onPick() {}, onDetail() {}, onLater() {}, armed: null, onTap() {} });
+ const more = [...walk(vq)].find((n) => typeof n.props?.class === 'string' && n.props.class.includes('rwtag__more'));
+ assert.equal(String(more.props.children), '之后还有 1 项');
});
test('the reminder pill names the offer (定向投放待选择), with a tier chip only for operator offers', () => {
diff --git a/tools/build-data.mjs b/tools/build-data.mjs
index 48d4d8a3..dae1cc21 100644
--- a/tools/build-data.mjs
+++ b/tools/build-data.mjs
@@ -2338,17 +2338,29 @@ function bountyDraftExclusion(e, main) {
* The half of the match whose 悬赏决策 drafts offer a drafted bounty card (player feedback after 0.1.0, report #2: "本来应该
* 后期出的悬赏的怪物在前期的悬赏就出现了"): 1 = the drafts of rounds ≤ firstHalfMaxRound (7: co-op / solo 绝境 · 终极 R3,
* 险境 co-op R3 / R6), 2 = the drafts from R8 on (R9). The official card list of each draft event (enemy_initial_1..10,
- * bounty_hunter_1..15, bossInitial_1..6) is server-side; what the official side shows:
+ * bounty_hunter_1..15, bossInitial_1..6) is server-side; no table (activity_table, PRTS 敌人轮选, BWIKI) gives rounds.
+ * Evidence for the split:
* - the one official co-op 悬赏决策 screenshot (Bahamut 12294, research 06 §15 "Wrkhtxv") holds six boss bounties —
* “复仇者” 6, “庞贝” 2, W 1, 萨卡兹百夫长 2, “邪魔的利刃” 3, 碎骨 1 — with the team at LP 6 / 10 / 9 / 15: a late
* draft, coin values 1–6 together;
- * - the data's six faction series pair the battles with the wave generator's halves (specialEnemyInfoDict
- * isInFirstHalf): "接下来两场作战" (_4–_6) bring first-half enemies or their attached keys (妖怪, 磨砻, 逐腐兽, 萨卡兹
- * 枯朽战车, 山海众头目, 深池伙友卫队 …), "下场作战" (_7 / _8) second-half specials or their upgraded copies (法术大师A2,
- * 假想敌:蚀裂, 尖端萨卡兹枯朽战车, 新硎, 家族暗影灭迹人, 异光体孽生者 …).
- * Rule: a "接下来两场作战" card (data `round` 2) is a first-half card; a "下场作战" card (the boss bounties, the 特异 giants,
- * the faction _7 / _8 cards, 源石虫·特训) and a multi-round card (the 假想敌, a second-half special each) a second-half
- * one [ASSUMED beyond the evidence above]. Not a drafted card ⇒ null.
+ * - a trend, not a rule, in the six faction series against the wave generator's halves (specialEnemyInfoDict
+ * isInFirstHalf and the attached keys): of the 18 "接下来两场作战" cards (_4–_6) 12 bring an enemy of the first half
+ * (妖怪, 磨砻, 逐腐兽, 萨卡兹枯朽战车, 山海众头目, 深池伙友卫队 …), 5 one outside the wave tables (暴鸰, 烹泉, 清明,
+ * 术师快艇, 隐形术师) and 1 (enemyeffect_12_4 底海滑动者) an enemy attached only to second-half specials; of the 12
+ * "下场作战" cards (_7 / _8) 6 bring a second-half special (法术大师A2, 灼藤, 假想敌:蚀裂, 尖端萨卡兹枯朽战车 …) and 6
+ * an enemy outside the wave tables (新硎, 家族暗影灭迹人, 异光体孽生者 …), none a first-half one;
+ * - strength: the half-1 enemies have at most 16000 base HP (median 5000), the half-2 ones up to 100000 (median 22000);
+ * - the user's playtest #6 report "悬赏决策的敌人…正常只会在选择之后出现两回合" (DESIGN §20.6): the bounty met most
+ * often — the R3 draft every 绝境 / 终极 match has — lasts two battles.
+ * Counter-evidence, not reconciled: the multi-round 假想敌 cards are enemyInitial_1..6, a name like the enemy_initial_*
+ * events (used here as the first-half header); a community guide "第三回合懸賞怪机制,数值及对策" (NGA tid 46551276,
+ * linked from Bahamut 12316; NGA needs a login, unread) may describe the R3 pool. The pre-series cards follow the
+ * battle count too, so the next-battle and multi-round cards of the first-half specials “萨科塔之眼” and 山海众头目
+ * (enemyeffect_3_2_e / 3_4 / 4) go to the late drafts, while 山海众头目 is offered early through enemyeffect_14_6.
+ * Rule [ASSUMED beyond the evidence above]: a "接下来两场作战" card (data `round` 2) is a first-half card; a "下场作战"
+ * card (the boss bounties, the 特异 giants, the faction _7 / _8 cards, the e3_* cards, 源石虫·特训) and a multi-round card
+ * (山海众头目·多轮悬赏, the 6 假想敌 — second-half specials, 12000–36000 base HP) a second-half one. Not a drafted card ⇒
+ * null. Flip for the multi-round cards: return 1 for `main.rounds >= 99`.
*/
function bountyDraftHalf(main, draftExcluded) {
if (draftExcluded) return null;