diff --git a/docs/META.md b/docs/META.md
index 7f63451c..460ebb5c 100644
--- a/docs/META.md
+++ b/docs/META.md
@@ -314,8 +314,8 @@ Writes (all validated, never throw on bad input, never make funds / pools negati
| `modifyPrice(delta)` / `setPrice(v)` | onPrice only: edit `ev.price` |
| `promote(uid)` / `transform(uid, chessId)` / `upgradeItem(uid)` | elite in place / replace a chess (keeps tile) / item → golden |
| `destroyPiece(uid)` / `equipDirect(itemUid, chessUid)` | remove a piece (chess copies return, items go back) / attach without equip effects |
-| `offerChess(ids, { tier })` | queue a pick-one offer (shown as `shop.rewardOffer`, free) — 寻呼模块 / 信标 style |
-| `offerItems(ids, { tier })` | the same for items (slots of kind `'item'`) — 凯瑟琳 定向投放 style |
+| `offerChess(ids, { tier, label })` | queue a pick-one offer (shown as `shop.rewardOffer`, free) — 寻呼模块 / 信标 style; `label` (default `effectsMeta.offerLabel(source)`: the strategy's effect name, the item's name or the 特质's operator) is the shop bar's header instead of 晋升奖励 (`rewardOffer.source` `'special'`; the promotion reward is `'merge'`) |
+| `offerItems(ids, { tier, label })` | the same for items (slots of kind `'item'`, drawn as item cards) — 凯瑟琳 定向投放 style (player report #6 after 0.1.0) |
| `triggerGarrisons(uid, eventType, { asUid })` | run another owned chess's 特质 of that eventType now (铃兰 "触发…的获得时效果"); 投资人 still multiplies SERVER_GAIN; SERVER_PRICE cannot be triggered; depth-capped |
| `setShopSlot(i, { kind, id, price?, frozen? } \| null)` | rewrite a shop slot (special refreshes) |
| `addDeployCap(n)` / `setDeployCapAtLeast(n)` | deploy cap (+effects; 人事部文档 = 9) |
diff --git a/docs/PLAYING.md b/docs/PLAYING.md
index 062df5a5..37f89ef7 100644
--- a/docs/PLAYING.md
+++ b/docs/PLAYING.md
@@ -52,7 +52,7 @@
## 3. 招募、整备区与晋升
-- **购买点两次**:第一次点商店卡片只选中(卡片抬起并打开详情,底部出现「确认购买 · 再次点击」),再点这张卡片的**任意位置**(包括金色的确认条)才购买;点别处或按 Esc 取消。升级调度中心、晋升奖励同样两次。不能购买时(例如已准备就绪)点卡片只打开详情。
+- **购买点两次**:第一次点商店卡片只选中(卡片抬起并打开详情,底部出现「确认购买 · 再次点击」),再点这张卡片的**任意位置**(包括金色的确认条)才购买;点别处或按 Esc 取消。升级调度中心、晋升奖励同样两次;策略或装备带来的免费挑选(如凯瑟琳【定向投放】升级后刷新的 3 件装备、娜仁图亚【见者有份】的 2 件装备、寻呼模块的 3 名干员)也一样出现在商店卡片的位置,左侧标题写着它的来源,装备显示为装备卡(图标、名称、说明、FREE),选 1 个免费获得。不能购买时(例如已准备就绪)点卡片只打开详情。
- **公共卡池**(同盟):每名干员的副本数有限,I–VI 阶分别 12 / 14 / 18 / 16 / 8 / 5 份,所有玩家共用;持有的干员(棋盘 + 整备区)占用副本,商店里展示的不占用。一名干员被大家拿光后就刷不出来了;淘汰玩家的干员会还回卡池。
- **位置上限**:棋盘最多部署 **8** 名;整备区 **10** 格;整备区满时新获得的单位(合成的精锐、奖励、退回的装备等)进入**临时整备区**(整备区前面的 5 格)。临时整备区有单位时**不能准备就绪**,棋盘上会用红色虚线框标出这一排并说明规则:把它们放进整备区或战场、配发或使用,或者出售 / 销毁。休整期中进入临时整备区的单位,若到**本休整期结束**时仍留在那里就会被自动处理(干员返还卡池、装备销毁);作战、结算期间或准备就绪之后才进入的(例如战斗奖励、结算时合成的精锐),会保留到**下一个休整期**结束,你一定有机会处理它们。
- **晋升精锐**:3 名同名干员(无论是否部署)自动合成**精锐**(等级、技能、模组大幅提升;风丸只需 2 名),精锐不会再合成。如果被合成的干员里有**已经部署在战场上**的,精锐会直接出现在**那名干员的位置**(保持原来的朝向,不多占部署位;有两三名都在场上时,取最先部署的那名——从上到下、从左到右);否则精锐进入整备区(满了进入临时整备区)。原先配发的装备都退回整备区,需要重新配发;被合成干员的召唤物随之移除,场上的精锐会把自己的召唤物送进整备区。点选一张能合成的商店卡片时,精锐将出现的那一格会亮起金色边框,同时打开的详情卡里也会写明去向(「可晋升:精锐干员将出现在作战区原位置 / 将进入整备区」;详情卡会自动停靠在不遮挡这一格的一侧)。在回合开始或结算时因策略、奖励等完成的合成,精锐出现时同样会播放金色的晋升特效。
diff --git a/public/js/screens/game.js b/public/js/screens/game.js
index 55c855a2..f0159f51 100644
--- a/public/js/screens/game.js
+++ b/public/js/screens/game.js
@@ -559,9 +559,10 @@ function MatchScreen() {
const spMine = !!sp && !solo && sp.turnPid === myId && !sp.pickOf.has(myId);
useEffect(() => { if (spMine) audio.sfx('yourTurn'); }, [spMine]);
- // promotion (merge reward offered) and bond activation cues
- const hasOffer = !!priv?.shop?.rewardOffer;
- useEffect(() => { if (hasOffer) audio.sfx('merge'); }, [hasOffer]);
+ // promotion (merge reward offered) and bond activation cues; a special refresh's offer (凯瑟琳 定向投放 …) plays the
+ // shop's refresh sound instead of the promotion cue
+ const offerCue = priv?.shop?.rewardOffer ? (priv.shop.rewardOffer.source === 'special' ? 'refresh' : 'merge') : null;
+ useEffect(() => { if (offerCue) audio.sfx(offerCue); }, [offerCue]);
// a shop / reward card armed for a purchase that completes a merge lights the tile its elite will take (the deployed
// copy that deploys first — PRTS 卫戍协议/帮助 "若消耗已部署至作战区的干员,则发送至作战区对应位置", gameLogic.mergeTarget)
const mergeAt = armedCard?.kind === 'chess' && editable && showPrep ? mergeTarget(priv, armedCard.id, gd.chess) : null;
diff --git a/public/js/ui/gameLogic.js b/public/js/ui/gameLogic.js
index e520febc..92072abf 100644
--- a/public/js/ui/gameLogic.js
+++ b/public/js/ui/gameLogic.js
@@ -538,6 +538,25 @@ export function completesMerge(priv, slot, { getChess = () => null, getItem = ()
return need > 1 && copies + 1 >= need;
}
+/**
+ * How the shop bar names a free pick-one offer (m.private shop.rewardOffer): the promotion reward (`source` 'merge')
+ * 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).
+ * @param {any} offer
+ * @returns {{ title: string, micro: string, sub: string, icon: string, items: boolean, pill: string }}
+ */
+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;
+ if (!items && (!isObj(offer) || offer.source === 'merge' || offer.source == null) && !label) {
+ return { title: '晋升奖励', micro: 'PROMOTION', sub: '免费选择 1 名', icon: 'crown', items: false, pill: '晋升奖励待选择' };
+ }
+ const title = label || (items ? '装备补给' : '特殊招募');
+ return { title, micro: 'SPECIAL', sub: items ? '免费选择 1 件' : '免费选择 1 名', icon: 'refresh', items, pill: `${title}待选择` };
+}
+
/**
* Why a shop action is unavailable (null when available).
* @param {'buy'|'reward'|'refresh'|'freeze'|'levelUp'|'ready'} kind
diff --git a/public/js/ui/rewardOverlay.js b/public/js/ui/rewardOverlay.js
index f0311bfe..2e6a14eb 100644
--- a/public/js/ui/rewardOverlay.js
+++ b/public/js/ui/rewardOverlay.js
@@ -1,9 +1,11 @@
-// Merge reward reminder (research 00 §3): the reward cards themselves live in the shop bar (shopBar.js RewardCards —
-// after a promotion 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 disappears once the server clears `shop.rewardOffer`
-// (picked, or the round ended).
+// 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).
import { html, Icon, TierChip } from './components.js';
+import { offerHeader } from './gameLogic.js';
/**
* @param {{ priv:any, minimized:boolean, onMinimize:(m:boolean)=>void }} props
@@ -11,7 +13,8 @@ import { html, Icon, TierChip } from './components.js';
export function RewardOverlay({ priv, minimized, onMinimize }) {
const offer = priv?.shop?.rewardOffer;
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 4f416ae5..59100f35 100644
--- a/public/js/ui/shopBar.js
+++ b/public/js/ui/shopBar.js
@@ -11,7 +11,9 @@
// detail); a card that cannot be bought right now (the bar is not editable: ready, round start) opens its detail. The
// D / R / F keys stay one-press shortcuts. There is no drag-to-sell zone: selling is the 出售 +N button of a tapped
// unit's underframe (ui/underframe.js). After a promotion the operator cards are replaced by
-// the 晋升奖励 cards (3 free operators, pick 1 — also two taps) until picked or put off (稍后 → rewardOverlay.js pill).
+// the 晋升奖励 cards (3 free operators, pick 1 — also two taps) until picked or put off (稍后 → rewardOverlay.js pill);
+// a special refresh (凯瑟琳 定向投放's 3 items, 娜仁图亚, 寻呼模块, 信标, 松果) takes the same place under its own name, an
+// item slot drawn as an item card (player report #6 after 0.1.0).
// Every operator card shows the skill it will fight with — the player's 干员调配 loadout (m.private.loadout, DESIGN
// §16): the skill icon above the name, mint-framed with 已调配 in its title when it is not the default skill (and the
// module type of an elite card, with its official type icon when the local-client art has it).
@@ -19,7 +21,7 @@
import { useEffect, useState } from '../../vendor/hooks.module.js';
import { html, Icon, HexBadge, TierChip, Tooltip, MicroLabel } from './components.js';
import { Img, BondGlyph, CoinGlyph, GIcon, RichText } from './gameComponents.js';
-import { priceTone, mergeProgress, mergeTarget, shopBlockReason, chessLoadout } from './gameLogic.js';
+import { priceTone, mergeProgress, mergeTarget, shopBlockReason, chessLoadout, offerHeader } from './gameLogic.js';
import { chessPortraitUrl, itemIconUrl, profIconUrl, uiUrl, skillIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from './assetUrls.js';
import { data } from '../data.js';
@@ -120,7 +122,7 @@ function SkillBadge({ chess, lo }) {
* @param {{ slot:any, idx:number, frozen?:boolean, reason?:string|null, armed?:boolean, onTap?:(idx:number)=>void,
* onBuy:Function, onDetail:Function }} props
*/
-export function ItemCard({ slot, idx, frozen = false, reason = null, armed = false, onTap = null, onBuy, onDetail }) {
+export function ItemCard({ slot, idx, frozen = false, reason = null, free = false, armed = false, onTap = null, onBuy, onDetail }) {
const it = data.lookup('items', slot.id);
const m = data.get('assets');
const disabled = !!reason;
@@ -132,7 +134,7 @@ export function ItemCard({ slot, idx, frozen = false, reason = null, armed = fal
<${TierChip} tier=${it?.tier ?? 1} size="md" />
- <${PriceHex} slot=${slot} poor=${reason === '资金不足'} />
+ <${PriceHex} slot=${slot} free=${free} poor=${reason === '资金不足'} />
<${Img} src=${itemIconUrl(m, it)} fallback=${html`<${GIcon} name="bolt" />`} />
@@ -140,7 +142,7 @@ export function ItemCard({ slot, idx, frozen = false, reason = null, armed = fal
<${RichText} text=${it?.descRaw || it?.desc || ''} />
${frozen ? html`<${Icon} name="snow" />` : null}
- ${armed ? html`<${ArmedTag} reason=${reason} />` : null}
+ ${armed ? html`<${ArmedTag} reason=${reason} free=${free} />` : null}
`;
return reason && reason !== '已售出' && !armed ? html`<${Tooltip} text=${reason} block=${true} class="scard-wrap">${card}/>` : card;
}
@@ -210,24 +212,32 @@ export function useTwoTap({ editable, keys }) {
}
/**
- * Promotion reward (research 00 §3): after a merge the operator cards of the bar are replaced by 3 free operators of
- * tier min(level+1, 6); pick 1 (g.reward idx) or put it off (稍后选择 → the normal shop + a reminder pill).
+ * A free pick-one offer in place of the bar's operator cards (pick 1 — g.reward idx — or put it off: 稍后选择 → the
+ * normal shop + a reminder pill): the promotion reward (research 00 §3: 3 free operators of tier min(level+1, 6)) and
+ * 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 …).
*/
-function RewardCards({ offer, priv, editable, onPick, onDetail, onLater, armed, onTap }) {
- return html`
+export function RewardCards({ offer, priv, editable, onPick, onDetail, onLater, armed, onTap }) {
+ const head = offerHeader(offer);
+ return html`
- <${Icon} name="crown" class="rwtag__icon" />
- 晋升奖励
- PROMOTION
- 免费选择 1 名
+ <${Icon} name=${head.icon} class="rwtag__icon" />
+ ${head.title}
+ ${head.micro}
+ ${head.sub}
- ${offer.slots.map((s, i) => (s && !s.sold
- ? html`<${ChessCard} key=${`rw${i}:${s.id}`} slot=${{ ...s, price: 0 }} idx=${i} priv=${priv} free=${true}
- armed=${armed === armKey('r', i, s)} onTap=${(idx) => onTap('r', idx, s, 'chess', shopBlockReason('reward', { priv, editable, slot: s, ...LOOKUPS }), onPick)}
- reason=${shopBlockReason('reward', { priv, editable, slot: s, ...LOOKUPS })} onBuy=${onPick} onDetail=${onDetail} />`
- : html`
已选择
`))}
+ ${offer.slots.map((s, i) => {
+ if (!s || s.sold) return html`
已选择
`;
+ const kind = s.kind === 'item' ? 'item' : 'chess';
+ const reason = shopBlockReason('reward', { priv, editable, slot: s, ...LOOKUPS });
+ const props = { slot: { ...s, price: 0 }, idx: i, free: true, reason, armed: armed === armKey('r', i, s), onBuy: onPick, onDetail,
+ onTap: (idx) => onTap('r', idx, s, kind, reason, onPick) };
+ return kind === 'item' ? html`<${ItemCard} key=${`rw${i}:${s.id}`} ...${props} />` : html`<${ChessCard} key=${`rw${i}:${s.id}`} priv=${priv} ...${props} />`;
+ })}
`;
}
diff --git a/server/match/PlayerState.js b/server/match/PlayerState.js
index 6a6a433e..b42db873 100644
--- a/server/match/PlayerState.js
+++ b/server/match/PlayerState.js
@@ -96,7 +96,7 @@ export class PlayerState {
this.loadout = Object.freeze({});
if (!this.isBot && seat.loadout) this.setLoadout(seat.loadout);
this.shop = { level: 1, upgradePrice: this.gd.upgradeBase(1) ?? 0, slots: [], frozen: false, freeRefreshes: 0 };
- /** reward offers queue (merge rewards, special refreshes): { tier, source, slots: [{ kind, id, price, sold }] } */
+ /** reward offers queue (merge rewards, special refreshes): { tier, source, label, slots: [{ kind, id, price, sold }] } */
this.offers = [];
/** @type {Array
} */
this.hand = new Array(HAND_SIZE).fill(null);
@@ -593,7 +593,7 @@ export class PlayerState {
* 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).
*/
- pushRewardOffer(source = 'merge', { tier = null, ids = null } = {}) {
+ pushRewardOffer(source = 'merge', { tier = null, ids = null, label = null } = {}) {
const ro = this.gd.rewardOffer();
const t = Number.isInteger(tier) ? tier : Math.min(this.shop.level + ro.tierOffset, ro.maxTier);
// an offer never shows one operator twice, whoever built the list (user playtest #6 item 19)
@@ -608,17 +608,20 @@ export class PlayerState {
}
}
if (!list.length) return null;
- const offer = { tier: t, source, slots: list.slice(0, MAX_OFFER_SLOTS).map((id) => ({ kind: 'chess', id, price: ro.price, sold: false })) };
+ const offer = { tier: t, source, label: typeof label === 'string' && label ? label : null, slots: list.slice(0, MAX_OFFER_SLOTS).map((id) => ({ kind: 'chess', id, price: ro.price, sold: false })) };
this.offers.push(offer);
this.dirty();
return offer;
}
- /** Queue a free pick-one offer of items (凯瑟琳 定向投放 and similar); shown as shop.rewardOffer with kind 'item'. */
- pushItemOffer(ids, { source = 'effect', tier = null } = {}) {
- const list = (Array.isArray(ids) ? ids : []).filter((id) => this.gd.item(id)).slice(0, MAX_OFFER_SLOTS);
+ /**
+ * Queue a free pick-one offer of items (凯瑟琳 定向投放, 娜仁图亚 见者有份); shown as shop.rewardOffer with slots of kind
+ * 'item' under its `label` (the effect's name; player report #6 after 0.1.0).
+ */
+ pushItemOffer(ids, { source = 'effect', tier = null, label = null } = {}) {
+ const list = [...new Set(Array.isArray(ids) ? ids : [])].filter((id) => this.gd.item(id)).slice(0, MAX_OFFER_SLOTS);
if (!list.length) return null;
- const offer = { tier: Number.isInteger(tier) ? tier : null, source, slots: list.map((id) => ({ kind: 'item', id, price: 0, sold: false })) };
+ const offer = { tier: Number.isInteger(tier) ? tier : null, source, label: typeof label === 'string' && label ? label : null, slots: list.map((id) => ({ kind: 'item', id, price: 0, sold: false })) };
this.offers.push(offer);
this.dirty();
return offer;
@@ -1481,7 +1484,9 @@ export class PlayerState {
freeRefreshes: this.shop.freeRefreshes,
frozen: this.shop.frozen,
slots,
- rewardOffer: offer ? { tier: offer.tier, slots: offer.slots.map((s) => ({ kind: s.kind === 'item' ? 'item' : 'chess', id: s.id, price: s.price, sold: !!s.sold })) } : null,
+ // `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,
},
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/effectsMeta.js b/server/match/effectsMeta.js
index 14ab7369..1016f746 100644
--- a/server/match/effectsMeta.js
+++ b/server/match/effectsMeta.js
@@ -351,6 +351,22 @@ function ownedChess(ps) {
const finiteInt = (n) => (Number.isFinite(n) ? Math.trunc(n) : 0);
+/**
+ * What a pick-one offer made by an effect is called on the shop bar (m.private shop.rewardOffer.label; player report #6
+ * after 0.1.0 — 凯瑟琳's three items came under the promotion reward's 晋升奖励 header): a strategy's effect name
+ * (定向投放, 见者有份), an item's name (寻呼模块, 信标), the operator whose 特质 it is (松果), else null.
+ * @param {import('./gamedata.js').GameData} gd
+ * @param {object} source the dispatch source (kind band / item / garrison …)
+ * @returns {string|null}
+ */
+export function offerLabel(gd, source) {
+ if (!source || typeof source !== 'object') return null;
+ if (source.kind === 'band') { const b = source.band || gd.band(source.bandId); return (b && (b.effectName || b.name)) || null; }
+ if (source.kind === 'item') { const it = source.item || (source.piece ? gd.item(source.piece.id) : null); return (it && it.name) || null; }
+ if (source.kind === 'garrison' && source.piece) { const c = gd.chess(source.piece.id); return (c && c.name) || null; }
+ return null;
+}
+
/**
* @param {import('./Match.js').Match} m
* @param {import('./PlayerState.js').PlayerState} ps
@@ -510,9 +526,10 @@ export function makeCtx(m, ps, source, hook, ev = null) {
ps.recompute();
return true;
},
- offerChess: (ids, opts = {}) => !!ps.pushRewardOffer(opts.source || source.key || 'effect', { ids, tier: opts.tier ?? null }),
+ /** Queue a free pick-one offer of chess (shop.rewardOffer; `label` defaults to offerLabel(source), the bar's header). */
+ offerChess: (ids, opts = {}) => !!ps.pushRewardOffer(opts.source || source.key || 'effect', { ids, tier: opts.tier ?? null, label: opts.label ?? offerLabel(gd, source) }),
/** Queue a free pick-one offer of items (shop.rewardOffer with slots of kind 'item'). */
- offerItems: (ids, opts = {}) => !!ps.pushItemOffer(ids, { source: opts.source || source.key || 'effect', tier: opts.tier ?? null }),
+ offerItems: (ids, opts = {}) => !!ps.pushItemOffer(ids, { source: opts.source || source.key || 'effect', tier: opts.tier ?? null, label: opts.label ?? offerLabel(gd, source) }),
setShopSlot: (i, slot) => {
if (!Number.isInteger(i) || i < 0 || i >= ps.shop.slots.length) return false;
if (slot == null) { ps.shop.slots[i] = null; ps.dirty(); return true; }
diff --git a/test/ui/feedback1-offers.e2e.test.js b/test/ui/feedback1-offers.e2e.test.js
new file mode 100644
index 00000000..3c4859bc
--- /dev/null
+++ b/test/ui/feedback1-offers.e2e.test.js
@@ -0,0 +1,142 @@
+// Real-server browser E2E of player report #6 after 0.1.0: "凯瑟琳策略升级刷新的三个装备显示异常". A solo 险境 prep with the
+// strategy 凯瑟琳 (【定向投放】"每次升级调度中心时,在调度中心刷新随机3件装备,可以选择并获得其中1件"; test/e2e/fastServer.mjs
+// SP_START_ROUND 2: a starter kit + 20 funds) is driven with real input: the LEVEL card is tapped twice (确认升级) and the
+// 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.
+//
+// SP_E2E=1 node --test test/ui/feedback1-offers.e2e.test.js
+
+import { test, describe } from 'node:test';
+import assert from 'node:assert/strict';
+import { existsSync } from 'node:fs';
+import path from 'node:path';
+import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf } from '../e2e/client.mjs';
+
+const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets'));
+
+async function soloToPrep(c, bandName) {
+ await c.open();
+ await c.enter('煌');
+ await c.click('.mode-card', '独立模拟');
+ await c.click('.diff-card', '险境');
+ await c.click('.create-box button', '开始独立模拟');
+ await c.waitFor((s) => !!s.room, 'solo room');
+ if (!(await c.st()).phase) await c.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
+ await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
+ await c.click('.brief__foot .btn--primary', '准备就绪');
+ await c.waitFor((s) => s.phase === 'BAND_DRAFT', 'band draft', 30000);
+ await c.click('.dband', bandName);
+ await c.click('.draft-detail__btns .btn--primary', '确认选择');
+ await c.waitFor((x) => x.phase === 'PREP' && !x.ready, 'prep', 60000);
+ await sleep(1800);
+}
+
+/** The offer as drawn: header texts, the cards (kind, name, description, art, free tag) and their boxes. */
+function offerDom(c) {
+ return c.page.evaluate(() => {
+ const box = (el) => { if (!el) return null; const r = el.getBoundingClientRect(); return { l: r.left, t: r.top, r: r.right, b: r.bottom, w: r.width, h: r.height }; };
+ const root = document.querySelector('.shopbar__reward');
+ if (!root) return null;
+ const cards = [...root.querySelectorAll('.scard')].map((el) => ({
+ item: el.classList.contains('scard--item'),
+ name: el.querySelector('.scard__name')?.textContent.trim() || '',
+ desc: el.querySelector('.scard__idesc')?.textContent.trim() || '',
+ icon: !!el.querySelector('.scard__itemart img'),
+ free: !!el.querySelector('.scard__free'),
+ tier: el.querySelector('.tierchip, [class*="tier"]')?.textContent.trim() || '',
+ box: box(el),
+ clipped: el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1,
+ }));
+ return {
+ title: root.querySelector('.rwtag__title')?.textContent.trim() || '',
+ micro: root.querySelector('.rwtag__micro')?.textContent.trim() || '',
+ sub: root.querySelector('.rwtag__sub')?.textContent.trim() || '',
+ cards, root: box(root), vw: innerWidth, vh: innerHeight,
+ };
+ });
+}
+
+const privOf = (c) => c.page.evaluate(() => globalThis.__SP__.store.get().match.private);
+
+describe('player report #6: 凯瑟琳\'s three free items after a level-up (real server, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome + assets) to run' }, () => {
+ test('the level-up offer shows three item cards (desktop and phones); a pick puts the item into the hand', { timeout: 240000 }, 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, 'cathy', { prefix: 'fb1-offer' });
+ try {
+ await soloToPrep(c, '凯瑟琳');
+ assert.equal((await privOf(c)).bandId, 'band_cathy', 'strategy 凯瑟琳');
+ // LEVEL: first tap arms 确认升级, the second upgrades
+ await c.click('.lvcard');
+ await sleep(250);
+ await c.click('.lvcard');
+ await c.page.waitForFunction(() => !!globalThis.__SP__.store.get().match.private?.shop?.rewardOffer, { timeout: 8000 });
+ await sleep(600);
+ const priv = await privOf(c);
+ const offer = priv.shop.rewardOffer;
+ assert.equal(offer.slots.length, 3, 'three items');
+ assert.ok(offer.slots.every((s) => s.kind === 'item' && s.price === 0), JSON.stringify(offer));
+ const names = await c.page.evaluate((ids) => ids.map((id) => globalThis.__SP__.data.lookup('items', id)?.name), offer.slots.map((s) => s.id));
+ await c.shot('desktop');
+ const dom = await offerDom(c);
+ assert.ok(dom, 'the offer is in the bar');
+ assert.equal(dom.title, '定向投放', `the header names the strategy (${dom.title} / ${dom.micro})`);
+ assert.match(dom.sub, /免费选择 1 件/);
+ assert.deepEqual(dom.cards.map((x) => x.name), names, 'the item names');
+ for (const card of dom.cards) {
+ assert.ok(card.item, `${card.name}: drawn as an item card`);
+ assert.ok(card.icon, `${card.name}: the item icon`);
+ assert.ok(card.desc.length > 2, `${card.name}: the description`);
+ assert.ok(card.free, `${card.name}: FREE`);
+ }
+ // phones (touch, landscape): the same cards inside the viewport, none overlapping another
+ const browserUa = await c.browser.userAgent();
+ for (const [w, h] of [[844, 390], [756, 366]]) {
+ await c.page.emulate({ viewport: { width: w, height: h, deviceScaleFactor: 2, isMobile: true, hasTouch: true, isLandscape: true }, userAgent: browserUa });
+ await c.page.waitForFunction(() => !!document.querySelector('.shopbar__reward .scard'), { timeout: 30000 });
+ await sleep(900);
+ await c.page.screenshot({ path: path.join(ROOT, 'test/e2e/out', `fb1-offer-${w}x${h}.png`) });
+ const d = await offerDom(c);
+ assert.ok(d && d.cards.length === 3, `${w}×${h}: three cards`);
+ for (const card of d.cards) {
+ assert.ok(card.item && card.icon, `${w}×${h} ${card.name}: an item card with its icon`);
+ assert.ok(card.box.l >= 0 && card.box.r <= w + 0.5 && card.box.t >= 0 && card.box.b <= h + 0.5, `${w}×${h} ${card.name}: inside the viewport ${JSON.stringify(card.box)}`);
+ }
+ for (let i = 1; i < d.cards.length; i++) assert.ok(d.cards[i].box.l >= d.cards[i - 1].box.r - 0.5, `${w}×${h}: cards ${i - 1} / ${i} do not overlap`);
+ }
+ await c.page.emulate({ viewport: { width: 1920, height: 1080, deviceScaleFactor: 1, isMobile: false, hasTouch: false }, userAgent: browserUa });
+ // switching touch emulation reloads the page: the session resumes with the offer still queued (re-hook the intents)
+ await c.page.waitForFunction(() => !!globalThis.__SP__?.store.get().match.private?.shop?.rewardOffer && !!document.querySelector('.shopbar__reward'), { timeout: 30000 });
+ await sleep(900);
+ await c.hookRequests();
+ // first tap: armed + the item's detail; second tap: taken
+ const want = names[1];
+ await c.click('.shopbar__reward .scard', want);
+ await sleep(400);
+ const armed = await c.page.evaluate(() => ({
+ armed: document.querySelector('.shopbar__reward .scard.is-armed .scard__name')?.textContent.trim() || null,
+ strip: document.querySelector('.shopbar__reward .scard.is-armed .scard__confirm')?.textContent.trim() || null,
+ detail: document.querySelector('.dpanel')?.textContent || '',
+ }));
+ assert.equal(armed.armed, want, 'the tapped card is armed');
+ assert.match(armed.strip || '', /确认选择/);
+ assert.ok(armed.detail.includes(want), 'the detail card shows the item');
+ await c.shot('armed');
+ await c.click('.shopbar__reward .scard.is-armed');
+ await c.page.waitForFunction(() => !globalThis.__SP__.store.get().match.private?.shop?.rewardOffer, { timeout: 8000 });
+ const after = await privOf(c);
+ const got = await c.page.evaluate((hand) => hand.filter((p) => p && p.kind === 'item').map((p) => globalThis.__SP__.data.lookup('items', p.id)?.name), after.hand);
+ assert.ok(got.includes(want), `the item is in the hand (${got.join(', ')})`);
+ assert.equal((await c.requests('g.reward')).length, 1, 'one g.reward');
+ await sleep(300);
+ assert.ok(await c.exists('.shopbar__cards'), 'the shop is back');
+ await c.shot('after');
+ 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
new file mode 100644
index 00000000..8c57e4a4
--- /dev/null
+++ b/test/ui/feedback1-offers.test.js
@@ -0,0 +1,191 @@
+// Player feedback after 0.1.0, report #6 (workstream WB): "凯瑟琳策略升级刷新的三个装备显示异常". The strategy 凯瑟琳
+// (【定向投放】"每次升级调度中心时,在调度中心刷新随机3件装备,可以选择并获得其中1件") queues a free pick-one offer of three
+// items; the shop bar drew every offer slot as an operator card (ChessCard on an item id: no art, the raw id as the name,
+// 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.
+// 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';
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
+globalThis.fetch = async (url) => {
+ const name = String(url).split('/').pop();
+ try {
+ const body = readFileSync(path.join(ROOT, 'data', name), 'utf8');
+ return { ok: true, status: 200, json: async () => JSON.parse(body) };
+ } catch {
+ return { ok: false, status: 404, json: async () => ({}) };
+ }
+};
+
+const { offerHeader } = await import('../../public/js/ui/gameLogic.js');
+const { RewardCards, ItemCard, ChessCard } = await import('../../public/js/ui/shopBar.js');
+const { RewardOverlay } = await import('../../public/js/ui/rewardOverlay.js');
+const { data } = await import('../../public/js/data.js');
+const { DATA, makeMatch, give, giveItem, chessOfTier, legalTileFor } = await import('../match/harness.js');
+const { offerLabel } = await import('../../server/match/effectsMeta.js');
+
+await data.loadAll('chess', 'bonds', 'items', 'assets');
+
+const OK = { ok: true };
+const PAGER = 'chess_item_4_01_e_a'; // 寻呼模块
+
+/** A solo prep with an empty board and hand, plenty of funds, the given strategy. */
+function prep(band, seed = 61) {
+ const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed, fake: true }).start();
+ h.toPrep(1);
+ const m = h.m;
+ const ps = h.ps('p_0');
+ for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
+ ps.board.clear();
+ ps.hand.fill(null);
+ ps.offers.length = 0;
+ ps.funds = 60;
+ ps.bandId = band;
+ ps.recompute();
+ return { h, m, ps };
+}
+
+function* walk(v) {
+ if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
+ if (!v || typeof v !== 'object') return;
+ yield v;
+ yield* walk(v.props?.children);
+}
+
+describe('#6 the server names its offers (m.private shop.rewardOffer source / label)', () => {
+ test('凯瑟琳 (the players\' case): a level-up offers three different items, free, labelled 定向投放; a pick takes one into the hand', () => {
+ const { m, ps } = prep('band_cathy');
+ ps.shop.upgradePrice = 0;
+ assert.deepEqual(m.handle('p_0', { t: 'g.levelUp' }), OK);
+ const offer = ps.privateView().shop.rewardOffer;
+ assert.ok(offer, 'an offer after the level-up');
+ assert.equal(offer.source, 'special');
+ assert.equal(offer.label, '定向投放', 'the strategy\'s effect name');
+ assert.equal(offer.tier, null, 'items carry their own tiers');
+ assert.equal(offer.slots.length, 3);
+ assert.equal(new Set(offer.slots.map((s) => s.id)).size, 3, 'three different items');
+ for (const s of offer.slots) {
+ assert.equal(s.kind, 'item');
+ assert.equal(s.price, 0);
+ assert.ok(DATA.items[s.id] && DATA.items[s.id].itemType === 'EQUIP' && DATA.items[s.id].tier <= ps.shop.level, s.id);
+ }
+ const want = offer.slots[1].id;
+ const funds = ps.funds;
+ assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 1 }), OK);
+ assert.equal(ps.funds, funds, 'free');
+ assert.ok([...ps.hand, ...ps.temp].some((p) => p && p.kind === 'item' && (p.id === want || p.id === DATA.items[want].goldenId)), 'the item is owned');
+ assert.equal(ps.privateView().shop.rewardOffer, null, 'the offer is gone');
+ m.dispose();
+ });
+
+ test('two level-ups in one prep queue two offers: the second shows after the first pick, never stale', () => {
+ const { m, ps } = prep('band_cathy', 62);
+ 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.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.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 2 }), OK);
+ assert.equal(ps.privateView().shop.rewardOffer, null);
+ m.dispose();
+ });
+
+ test('娜仁图亚 见者有份 (two items), 寻呼模块 (operators of its bond) and the promotion reward', () => {
+ const { m, ps } = prep('band_narant', 63);
+ ps.shop.level = 3;
+ m.round = 2;
+ m.dispatch(ps, 'onRoundStart', { round: 2 });
+ let offer = ps.privateView().shop.rewardOffer;
+ assert.ok(offer && offer.source === 'special' && offer.label === '见者有份', JSON.stringify(offer));
+ assert.equal(offer.slots.length, 2);
+ assert.ok(offer.slots.every((s) => s.kind === 'item'));
+ ps.offers.length = 0;
+ // 寻呼模块: equipped on an operator — three operators sharing a bond, labelled with the item
+ const id = chessOfTier(1).find((x) => m.pool.has(x) && m.pool.left(x) > 0);
+ const op = give(m, ps, id, 'board', legalTileFor(m, ps, id));
+ const pager = giveItem(m, ps, PAGER);
+ assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: pager.uid, targetUid: op.uid }), OK);
+ offer = ps.privateView().shop.rewardOffer;
+ assert.ok(offer && offer.source === 'special' && offer.label === '寻呼模块', JSON.stringify(offer));
+ assert.ok(offer.slots.length >= 1 && offer.slots.every((s) => s.kind === 'chess'));
+ ps.offers.length = 0;
+ // the promotion reward
+ ps.pushRewardOffer('merge');
+ offer = ps.privateView().shop.rewardOffer;
+ assert.equal(offer.source, 'merge');
+ assert.equal(offer.label, null);
+ assert.ok(Number.isInteger(offer.tier));
+ 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;
+ assert.equal(offerLabel(gd, { kind: 'band', bandId: 'band_cathy' }), '定向投放');
+ assert.equal(offerLabel(gd, { kind: 'band', band: gd.band('band_narant') }), '见者有份');
+ assert.equal(offerLabel(gd, { kind: 'item', piece: { id: 'chess_item_5_04_e_a' } }), '信标');
+ const chess = chessOfTier(2)[0];
+ assert.equal(offerLabel(gd, { kind: 'garrison', piece: { id: chess } }), DATA.chess[chess].name);
+ assert.equal(offerLabel(gd, { kind: 'global', key: 'global:x' }), null);
+ assert.equal(offerLabel(gd, null), null);
+ m.dispose();
+ });
+});
+
+describe('#6 the shop bar draws the offer by its slots and names it', () => {
+ const ITEMS = Object.values(DATA.items).filter((it) => it.itemType === 'EQUIP' && !it.isGolden).slice(0, 3).map((it) => it.id);
+ const cathy = { tier: null, source: 'special', label: '定向投放', slots: ITEMS.map((id) => ({ kind: 'item', id, price: 0, sold: false })) };
+ 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.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');
+ assert.equal(offerHeader({ tier: 2, slots: merge.slots }).title, '晋升奖励', 'an offer view without source (older server) is the promotion reward');
+ });
+
+ test('RewardCards: item slots → ItemCard (FREE), chess slots → ChessCard; the header says 定向投放', () => {
+ const priv = { alive: true, funds: 0, hand: Array(10).fill(null), temp: Array(5).fill(null), board: [], shop: { rewardOffer: cathy } };
+ const v = RewardCards({ offer: cathy, priv, editable: true, onPick() {}, onDetail() {}, onLater() {}, armed: null, onTap() {} });
+ const nodes = [...walk(v)];
+ const cards = nodes.filter((n) => n.type === ItemCard || n.type === ChessCard);
+ assert.equal(cards.length, 3);
+ for (const c of cards) {
+ assert.equal(c.type, ItemCard, 'an item card');
+ assert.equal(c.props.free, true, 'FREE');
+ assert.equal(c.props.slot.price, 0);
+ assert.equal(c.props.reason, null, 'pickable with 0 funds');
+ }
+ const title = nodes.find((n) => typeof n.props?.class === 'string' && n.props.class.includes('rwtag__title'));
+ assert.equal(String(title.props.children), '定向投放');
+ // the item card itself: icon, name, description, FREE (no price hex)
+ const card = ItemCard({ ...cards[0].props });
+ const inner = [...walk(card.type === 'button' || card.props?.class?.includes?.('scard') ? card : card.props.children)];
+ const texts = inner.flatMap((n) => (typeof n.props?.children === 'string' ? [n.props.children] : []));
+ assert.ok(texts.includes(DATA.items[ITEMS[0]].name), `the name (${texts.join(' | ')})`);
+ assert.ok(inner.some((n) => typeof n.props?.class === 'string' && n.props.class.includes('scard__itemart')), 'the item art');
+ // the promotion reward still draws operator cards under 晋升奖励
+ const vm = RewardCards({ offer: merge, priv: { ...priv, shop: { rewardOffer: merge } }, editable: true, onPick() {}, onDetail() {}, onLater() {}, armed: null, onTap() {} });
+ 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), '晋升奖励');
+ });
+
+ test('the reminder pill names the offer (定向投放待选择), with a tier chip only for operator offers', () => {
+ const pill = RewardOverlay({ priv: { shop: { rewardOffer: cathy } }, minimized: true, onMinimize() {} });
+ const texts = [...walk(pill)].flatMap((n) => (typeof n.props?.children === 'string' ? [n.props.children] : []));
+ assert.ok(texts.includes('定向投放待选择'), texts.join(' | '));
+ const mp = RewardOverlay({ priv: { shop: { rewardOffer: merge } }, minimized: true, onMinimize() {} });
+ assert.ok([...walk(mp)].flatMap((n) => (typeof n.props?.children === 'string' ? [n.props.children] : [])).includes('晋升奖励待选择'));
+ });
+});