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`` : 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('晋升奖励待选择')); + }); +});