feedback7 R22-HUD: the card 梓兰's 猎头顾问 freezes shows its frost — every shop card draws its own slot's freeze, not the toggle's (GitHub #354 by @DuolaCCCCat); golden: unchanged

Seen (reproduced on the parent, notes repro-354.mjs): band_orchid's active refresh copies the top-tier operator into another
slot and freezes the copy (bands/meta.js band_shop_refresh_copy_max_lv_char, slot.frozen = true) while shop.frozen — the
toggle — stays false; m.private sends both (views.js: shop.frozen and slots[i].frozen). The shop bar passed the toggle
(`frozen = !!shop.frozen`) to every ChessCard / ItemCard, so no card showed the frost: the server kept the card to the next
prep, the player could not see which one.

Now: shopBar.js slotFrozen(slot, toggle) = the slot's own flag, or the toggle (a frame without per-slot flags — the dev
mock — and the old behaviour for the 冻结 button, which still copies the toggle onto every unsold slot); both card kinds
use it. The bar's frame (is-frozen) and the 冻结 / 解冻 button stay on the toggle. The rule is not touched (bands.test.js,
and the new test): a second manual refresh re-rolls the frozen card too — like the toggle, "再手动刷新会重抽" — and
freezes one of the new pair; with no further refresh the frozen card stays in the next prep (the flags are cleared
then, round.js startRound).

- Tests: test/ui/feedback7-orchid-freeze.test.js (4: the wire — exactly one frozen card of the pair, toggle off — and the
  cards drawn from it; the toggle still freezes every slot; the rule unchanged across two refreshes and a round; the
  ShopBar source pin); fails on the parent (no slotFrozen, the cards passed `frozen`).
- Docs: PLAYING.md (冻结 row), design/network.md (m.private slot.frozen). Golden: unchanged (client render only).
This commit is contained in:
sganggs committed 2026-10-08 19:22:08 +08:00
1 parent bc11c0d61a
commit 16df0bf466
4 files changed
+121 -4

No files matched your search

+1 -1
View File
@@ -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 个)时,**新栏位立刻刷出一名新干员**,已有的卡片不变 |
| 其他收入 | 悬赏敌人、盟约「远见」「奇迹」、装备「骑士储蓄罐」、部分策略和特质 |
+1 -1
View File
@@ -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
+10 -2
View File
@@ -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`<span class="scard__confirm is-no" role="status"><b>${t('无法购买')}</b><small>${reason}</small></span>`;
@@ -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} />`;
})}
</div>`}
@@ -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} />`}
+109
View File
@@ -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');
});
});