feedback1 WB: special-refresh offers drawn as what they hold (凯瑟琳's three items as item cards)
Player report #6: 凯瑟琳's 定向投放 offer after a level-up showed three nameless operator cards (the raw item ids) under the promotion reward's 晋升奖励 header. The shop bar now draws each offer slot by its kind (item slots as free item cards) and names the offer: m.private shop.rewardOffer carries source ('merge' | 'special') and label (the strategy's effect name, the item's name or the 特质's operator — effectsMeta.offerLabel); the reminder pill says <label>待选择. Same path for 娜仁图亚, 寻呼模块, 信标 and 松果.
This commit is contained in:
1 parent
5e6fe19a70
commit
49c0ebcf4d
10 files changed
+427
-39
No files matched your search
+2
-2
@@ -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) |
|
||||
|
||||
+1
-1
@@ -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 名),精锐不会再合成。如果被合成的干员里有**已经部署在战场上**的,精锐会直接出现在**那名干员的位置**(保持原来的朝向,不多占部署位;有两三名都在场上时,取最先部署的那名——从上到下、从左到右);否则精锐进入整备区(满了进入临时整备区)。原先配发的装备都退回整备区,需要重新配发;被合成干员的召唤物随之移除,场上的精锐会把自己的召唤物送进整备区。点选一张能合成的商店卡片时,精锐将出现的那一格会亮起金色边框,同时打开的详情卡里也会写明去向(「可晋升:精锐干员将出现在作战区原位置 / 将进入整备区」;详情卡会自动停靠在不遮挡这一格的一侧)。在回合开始或结算时因策略、奖励等完成的合成,精锐出现时同样会播放金色的晋升特效。
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`<button type="button" class="rewardpill" onClick=${() => onMinimize(false)}>
|
||||
<${Icon} name="crown" /><span>晋升奖励待选择</span><${TierChip} tier=${offer.tier || 1} size="sm" />
|
||||
<${Icon} name=${head.icon} /><span>${head.pill}</span>${Number.isInteger(offer.tier) ? html`<${TierChip} tier=${offer.tier} size="sm" />` : null}
|
||||
</button>`;
|
||||
}
|
||||
+28
-18
@@ -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
|
||||
<span class="scard__bg" aria-hidden="true"></span>
|
||||
<span class="scard__top">
|
||||
<${TierChip} tier=${it?.tier ?? 1} size="md" />
|
||||
<${PriceHex} slot=${slot} poor=${reason === '资金不足'} />
|
||||
<${PriceHex} slot=${slot} free=${free} poor=${reason === '资金不足'} />
|
||||
</span>
|
||||
<span class="scard__itemart"><${Img} src=${itemIconUrl(m, it)} fallback=${html`<${GIcon} name="bolt" />`} /></span>
|
||||
<span class="scard__body">
|
||||
@@ -140,7 +142,7 @@ export function ItemCard({ slot, idx, frozen = false, reason = null, armed = fal
|
||||
<span class="scard__idesc"><${RichText} text=${it?.descRaw || it?.desc || ''} /></span>
|
||||
</span>
|
||||
${frozen ? html`<span class="scard__ice" aria-hidden="true"><${Icon} name="snow" /></span>` : null}
|
||||
${armed ? html`<${ArmedTag} reason=${reason} />` : null}
|
||||
${armed ? html`<${ArmedTag} reason=${reason} free=${free} />` : null}
|
||||
</button>`;
|
||||
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`<div class="shopbar__reward" role="group" aria-label="晋升奖励">
|
||||
export function RewardCards({ offer, priv, editable, onPick, onDetail, onLater, armed, onTap }) {
|
||||
const head = offerHeader(offer);
|
||||
return html`<div class=${cx('shopbar__reward', head.items && 'is-items')} role="group" aria-label=${head.title}>
|
||||
<div class="rwtag">
|
||||
<${Icon} name="crown" class="rwtag__icon" />
|
||||
<b class="rwtag__title">晋升奖励</b>
|
||||
<span class="rwtag__micro">PROMOTION</span>
|
||||
<span class="rwtag__sub">免费选择 1 名</span>
|
||||
<${Icon} name=${head.icon} class="rwtag__icon" />
|
||||
<b class="rwtag__title">${head.title}</b>
|
||||
<span class="rwtag__micro">${head.micro}</span>
|
||||
<span class="rwtag__sub">${head.sub}</span>
|
||||
<button type="button" class="rwtag__later" onClick=${onLater} title="稍后选择(回合结束后消失)"><${Icon} name="minus" />稍后</button>
|
||||
</div>
|
||||
<div class="shopbar__rwcards">
|
||||
${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`<div key=${`rw${i}`} class="scard scard--sold"><span class="scard__soldtxt"><span>已选择</span></span></div>`))}
|
||||
${offer.slots.map((s, i) => {
|
||||
if (!s || s.sold) return html`<div key=${`rw${i}`} class=${cx('scard', 'scard--sold', s && s.kind === 'item' && 'scard--item')}><span class="scard__soldtxt"><span>已选择</span></span></div>`;
|
||||
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} />`;
|
||||
})}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -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<any>} */
|
||||
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)),
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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('晋升奖励待选择'));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user