diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 90ecfaf8..69b3cc0b 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -47,7 +47,7 @@ | 刷新 | 1 资金 | | 出售干员 | 任何干员(含精锐)都只返还 **1** 资金 | | 装备 / 法术 | 调度中心出售除特殊装备外的所有装备:4 把特殊维式重锤(战栗 / 坚固 / 加速 / 灼燃)只来自维多利亚盟约(每 25 层)和洛洛的定制品,突变细胞只来自昆图斯的策略。装备不能出售,整备区里的只能「销毁」(不返还)。已配发的装备锁定在干员身上:只能被第三件装备替换(弹出替换窗口,选择要替换的一件,它会被销毁;盟约之币、博士投影等装备即销毁的道具也一样,先销毁选中的那件再生效,干员留下一个空栏位,精锐干员不能使用博士投影,此时什么都不销毁),或在干员出售 / 合成精锐时退回整备区。**突变细胞**不会用完:战斗结束后装备者被销毁(部署在场上的话,原来的格子空出来,剩余可放置角色加 1),突变细胞和它的其他装备先退回整备区,可以再次配发给任意干员;随后获得一名高一阶(最高 VI)的随机普通干员(装备者是精锐也一样),和其他新获得的干员一样进入整备区(满了进入临时整备区),需要自己重新部署;如果它凑齐了合成所需的同名干员,照常合成精锐。**坚固维式重锤**首次受到致命伤害时锁血 8 秒,**每次部署一次**:被击倒再部署(或突袭跳跃再部署、被 M3茧甲 / 埃芒加德复活)后又能锁一次,娜仁图亚策略借来的锤子也一样;借用到期时正在锁血的会锁满 8 秒;站在阿戈尔干员身前被开场吞噬、自带源石溶剂掉血时也会用掉这一次 | -| 冻结 | 免费,保留当前所有未买的格子到下一回合;冻结状态下仍可主动刷新 | +| 冻结 | 免费,保留当前所有未买的格子到下一回合;冻结状态下仍可主动刷新。策略「梓兰」的每次主动刷新会单独冻结送来的那张同名干员:只有这一张卡显示冰霜(上方的冻结按钮不亮),没有再刷新就留到下一回合,再手动刷新会连它一起重抽 | | 升级调度中心(1→2 … 5→6) | 险境及以上 5 / 8 / 11 / 12 / 13;同盟标准 5 / 8 / 10 / 11 / 11;独立标准 1 / 1 / 5 / 8 / 10。**每过一回合价格 −1**(最低 0),升级后按下一级的价格重新计算。升级到干员栏位更多的等级(险境及以上 2 级 4 个、4 级 5 个;标准 2 级 4 个、6 级 5 个)时,**新栏位立刻刷出一名新干员**,已有的卡片不变 | | 其他收入 | 悬赏敌人、盟约「远见」「奇迹」、装备「骑士储蓄罐」、部分策略和特质 | diff --git a/docs/design/network.md b/docs/design/network.md index fb1af4b7..09fb61fd 100644 --- a/docs/design/network.md +++ b/docs/design/network.md @@ -36,7 +36,7 @@ S→C: ```js { playerId, seat, alive, lp, funds, bandId, ready, canReady /* false while temp non-empty */, shop: { level, maxLevel: 6, upgradePrice, refreshPrice /* effective (0 when a free refresh is available) */, freeRefreshes, - frozen, slots: [ { kind: 'chess'|'item', id, price, basePrice, sold } | null ], + frozen, slots: [ { kind: 'chess'|'item', id, price, basePrice, sold, frozen } | null ], // slot.frozen: the 冻结 toggle copies onto every unsold slot, and 梓兰's 猎头顾问 freezes ONE card of an active refresh with the toggle off — every card draws its own flag (GitHub #354) rewardOffer: null | { tier, source: 'merge'|'special', label, queued, slots: [ { kind: 'chess'|'item', id, price: 0, sold } ] } }, // §21.2 hand: [ Piece | null ], // length 10, index = hand slot temp: [ Piece | null ], // length 5 diff --git a/public/js/ui/shopBar.js b/public/js/ui/shopBar.js index e8a275c2..d8f59592 100644 --- a/public/js/ui/shopBar.js +++ b/public/js/ui/shopBar.js @@ -57,6 +57,14 @@ export function mergeHint(priv, chessId) { return mergeTarget(priv, chessId, LOOKUPS.getChess) ? t('精锐干员将出现在作战区原位置') : t('精锐干员将进入整备区'); } +/** + * Whether a shop card shows its frost: its own slot's `frozen` (m.private shop.slots[i].frozen) — the 冻结 toggle copies onto + * every unsold slot, and 梓兰's 猎头顾问 freezes ONE copied card with every active refresh while the toggle stays off + * (GitHub #354: the bar drew only the toggle, so the frozen card showed no frost) — or the toggle itself (a frame without + * per-slot flags). The 冻结 button and the bar's frame keep following the toggle alone. + */ +export const slotFrozen = (slot, toggle = false) => !!slot?.frozen || !!toggle; + /** The armed (first-tapped) card's confirm strip: 确认购买 / 确认选择, or 无法购买 + why. */ function ArmedTag({ reason, free }) { if (reason) return html`${t('无法购买')}${reason}`; @@ -348,7 +356,7 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel, ${chessSlots.map(({ s, i }) => { if (!s || s.sold) return html`<${SoldCard} key=${`s${i}`} />`; const reason = shopBlockReason('buy', { priv, editable, slot: s, ...LOOKUPS }); - return html`<${ChessCard} key=${`c${i}:${s.id}`} slot=${s} idx=${i} priv=${priv} frozen=${frozen} onBuy=${onBuy} onDetail=${onDetail} offBonds=${offBonds} + return html`<${ChessCard} key=${`c${i}:${s.id}`} slot=${s} idx=${i} priv=${priv} frozen=${slotFrozen(s, frozen)} onBuy=${onBuy} onDetail=${onDetail} offBonds=${offBonds} reason=${reason} armed=${armed === armKey('c', i, s)} onTap=${editable ? (idx) => tapCard('c', idx, s, 'chess', reason, onBuy) : null} />`; })} `} @@ -357,7 +365,7 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel, ? itemSlots.map(({ s, i }) => { if (s.sold) return html`<${SoldCard} key=${`is${i}`} item=${true} />`; const reason = shopBlockReason('buy', { priv, editable, slot: s, ...LOOKUPS }); - return html`<${ItemCard} key=${`i${i}:${s.id}`} slot=${s} idx=${i} frozen=${frozen} onBuy=${onBuy} onDetail=${onDetail} reason=${reason} + return html`<${ItemCard} key=${`i${i}:${s.id}`} slot=${s} idx=${i} frozen=${slotFrozen(s, frozen)} onBuy=${onBuy} onDetail=${onDetail} reason=${reason} armed=${armed === armKey('i', i, s)} onTap=${editable ? (idx) => tapCard('i', idx, s, 'item', reason, onBuy) : null} />`; }) : html`<${SoldCard} item=${true} />`} diff --git a/test/ui/feedback7-orchid-freeze.test.js b/test/ui/feedback7-orchid-freeze.test.js new file mode 100644 index 00000000..a3127934 --- /dev/null +++ b/test/ui/feedback7-orchid-freeze.test.js @@ -0,0 +1,109 @@ +// test/ui/feedback7-orchid-freeze.test.js — GitHub #354 (DuolaCCCCat): 「梓兰的主动刷新不会触发主动冻结」. 梓兰's 猎头顾问 (band_orchid) makes +// every active refresh a special one: two operators of the same name, and ONE of them frozen — kept to the next prep. The +// server did freeze it (slot.frozen, sent in m.private shop.slots[i].frozen) while the shop toggle stayed off; the shop bar +// drew the frost from the toggle alone, so the player never saw which card was frozen. Each card now reads its own slot. +// The rule itself (a second manual refresh re-rolls the frozen card too, like the toggle) is unchanged. + +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'; +import { makeMatch, DATA } from '../match/harness.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +// the browser data store reads the real data files from disk +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 { ChessCard, ItemCard, slotFrozen } = await import('../../public/js/ui/shopBar.js'); +const { data } = await import('../../public/js/data.js'); +await data.loadAll('items', 'effects', 'choices', 'assets', 'chess', 'bonds', 'garrisons'); + +/** Every vnode of a preact tree (htm output), depth first. */ +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); +} +const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c); +const iced = (card) => hasClass(card, 'is-frozen') && [...walk(card)].some((n) => hasClass(n, 'scard__ice')); +const bare = (card) => !hasClass(card, 'is-frozen') && ![...walk(card)].some((n) => hasClass(n, 'scard__ice')); + +/** A solo match in its first prep with 梓兰's strategy, level 4 shop, funds for a few refreshes. */ +function orchid(seed = 12) { + const h = makeMatch({ mode: 'solo', seed, fake: true }).start(); + h.toPrep(1); + const ps = h.ps('p_0'); + ps.bandId = 'band_orchid'; + ps.shop.level = 4; + ps.funds = 50; + return { h, ps }; +} + +describe('#354: the card the 猎头顾问 freezes shows its frost', () => { + test('the wire: the active refresh freezes one copied card and leaves the shop toggle off; the cards read their own slot', () => { + const { h, ps } = orchid(); + assert.deepEqual(h.m.handle('p_0', { t: 'g.refresh' }), { ok: true }); + const priv = ps.privateView(); + assert.equal(priv.shop.frozen, false, 'the 冻结 toggle is off'); + const chess = priv.shop.slots.filter((s) => s && s.kind === 'chess'); + const frozen = chess.filter((s) => s.frozen); + assert.equal(frozen.length, 1, 'exactly one card is frozen'); + assert.equal(chess.filter((s) => s.id === frozen[0].id).length, 2, 'one of the two operators of the same name'); + // the bar's frame and the button follow the toggle (off), each card follows its own slot + for (const s of priv.shop.slots.filter(Boolean)) { + const props = { slot: s, idx: 0, priv, frozen: slotFrozen(s, priv.shop.frozen), onBuy() {}, onDetail() {} }; + const card = s.kind === 'item' ? ItemCard(props) : ChessCard(props); + assert.equal(s.frozen ? iced(card) : bare(card), true, `${s.id}: ${s.frozen ? 'frost' : 'no frost'}`); + } + h.m.dispose(); + }); + + test('the toggle still freezes every unsold card, and the helper reads the slot and the toggle', () => { + const { h, ps } = orchid(13); + h.m.handle('p_0', { t: 'g.freeze' }); + const priv = ps.privateView(); + assert.equal(priv.shop.frozen, true); + assert.ok(priv.shop.slots.filter(Boolean).every((s) => s.frozen), 'every slot copies the toggle'); + assert.equal(slotFrozen(priv.shop.slots[0], priv.shop.frozen), true); + assert.equal(slotFrozen({ frozen: false }, true), true, 'a frame without per-slot flags: the toggle'); + assert.equal(slotFrozen({ frozen: true }, false), true, 'a slot of its own: its flag'); + assert.equal(slotFrozen({ frozen: false }, false), false); + assert.equal(slotFrozen(null), false); + h.m.dispose(); + }); + + test('the rule stays as it was: the next manual refresh re-rolls the frozen card too and freezes one of the new pair; one refresh then the next prep keeps that card', () => { + const { h, ps } = orchid(); + h.m.handle('p_0', { t: 'g.refresh' }); + const first = ps.shop.slots.find((s) => s && s.frozen); + h.m.handle('p_0', { t: 'g.refresh' }); + const frozenNow = ps.shop.slots.filter((s) => s && s.frozen); + assert.equal(frozenNow.length, 1, 'again exactly one frozen card'); + assert.equal(ps.shop.frozen, false); + assert.ok(first && frozenNow[0], 'both refreshes froze a card'); + const kept = frozenNow[0].id; + h.toPrep(2); + assert.ok(ps.shop.slots.some((s) => s && s.id === kept), 'the frozen card is still in the shop in the next prep'); + assert.equal(ps.shop.frozen, false); + h.m.dispose(); + }); + + test('ShopBar passes slotFrozen(slot, toggle) to the operator and item cards; the frame and the 冻结 button stay on the toggle', () => { + const src = readFileSync(path.join(ROOT, 'public/js/ui/shopBar.js'), 'utf8'); + assert.match(src, /<\$\{ChessCard\} key=\$\{`c\$\{i\}:\$\{s\.id\}`\} slot=\$\{s\} idx=\$\{i\} priv=\$\{priv\} frozen=\$\{slotFrozen\(s, frozen\)\}/); + assert.match(src, /<\$\{ItemCard\} key=\$\{`i\$\{i\}:\$\{s\.id\}`\} slot=\$\{s\} idx=\$\{i\} frozen=\$\{slotFrozen\(s, frozen\)\}/); + assert.match(src, /const frozen = !!shop\.frozen;/); + assert.match(src, /cx\('shopbar', frozen && 'is-frozen'/, 'the bar\'s frame is the toggle'); + assert.match(src, /cx\('toolbtn', 'toolbtn--ice', frozen && 'is-on'\)/, 'the button is the toggle'); + assert.ok(DATA.bands.band_orchid, 'the band exists in the data'); + }); +});