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;