feedback7 UIBACK: upgrading the 调度中心 opens the new shop slot EMPTY — the next refresh or round start fills it; 0.2.0's fill-at-once (464a823, item 19) reverted (GitHub #332 and PR #333 by @2321Robin, official recording BV1AXwuzdEys 1:39 — authored by them: the economy.js change, the rewritten level-slots test and the docs hunks applied as reviewed, without the PR's UPGRADE_FILLS_NEW_SLOTS flag and LOCAL RULING comment: the empty slot is the rule): the shop bar draws a null operator slot as a bare dashed frame (EmptyCard, inert scard--sold) instead of the SOLD OUT 「已招募」 frame, one new UI string in the four packs; META.md §3 and §7 and PLAYING.md say so; the dev mock opens the slot empty and survives null slots; test/ui/shop-empty-slot.e2e.test.js; golden: only matches moves, all 18 (the bots' shop RNG stream) — end results changed in solo-FUNNY-1 defeat→victory, solo-NORMAL-1 and solo-HARD-1 victory→defeat, solo-HARD-2 and solo-ABYSS-1 defeat→eliminated, coop2-NORMAL-14-standins defeat→victory (2 distinct stand-ins fielded: golden-standins.test.js asks ≥ 2); roster, bonds, fields, standins, diy unchanged

This commit is contained in:
2321Robin authored and sganggs committed 2026-10-08 19:38:48 +08:00
1 parent b4bc89ad4e
commit 4bb0c11e42
15 files changed
+691 -542

No files matched your search

+6 -4
View File
@@ -508,7 +508,7 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
* **Shop**: `shopSlots[level]` chess slots + item slot(s), copy-weighted rolls over remaining pool copies of unbanned, * **Shop**: `shopSlots[level]` chess slots + item slot(s), copy-weighted rolls over remaining pool copies of unbanned,
visible chess with tier ≤ level; item slot: tier by the same shares, uniform item within the tier. Level-up price = visible chess with tier ≤ level; item slot: tier by the same shares, uniform item within the tier. Level-up price =
base per mode, −1 each round start (floor 0), reset to the next base after upgrading; an upgrade opens the new level's base per mode, −1 each round start (floor 0), reset to the next base after upgrading; an upgrade opens the new level's
extra slots at once with new cards (the cards shown stay); `MAX_LEVEL` at 6. One freeze extra slots at once, empty (the cards shown stay; the empty slots fill on the next roll); `MAX_LEVEL` at 6. One freeze
toggle freezes every unsold slot until the next round start; a manual refresh rerolls everything (new slots stay toggle freezes every unsold slot until the next round start; a manual refresh rerolls everything (new slots stay
frozen). Unfrozen slots are cleared at combat start. Slot positions are stable (frozen slots keep their index). frozen). Unfrozen slots are cleared at combat start. Slot positions are stable (frozen slots keep their index).
* **Pool**: copies 12/14/18/16/8/5 (缪尔赛思 4); a normal piece holds 1 copy, an elite 3; displays never reserve copies; * **Pool**: copies 12/14/18/16/8/5 (缪尔赛思 4); a normal piece holds 1 copy, an elite 3; displays never reserve copies;
@@ -826,9 +826,11 @@ round was over. The official 1 s `broadcastBeginDelay` is not modelled.
units > active bond > Σ active layers > standing > seat; the ranks marked 存疑 in PRTS). Unite enemies re-enter with units > active bond > Σ active layers > standing > seat; the ranks marked 存疑 in PRTS). Unite enemies re-enter with
their original stats on the official 联防 spawn timing (waves.js `buildUniteWave`, DATA.md §15 #22). their original stats on the official 联防 spawn timing (waves.js `buildUniteWave`, DATA.md §15 #22).
* A manual refresh while frozen keeps the new slots frozen until the next round start. * A manual refresh while frozen keeps the new slots frozen until the next round start.
* A level-up opens the new level's extra slots at once, each with a new card drawn at the new level, and keeps the * A level-up opens the new level's extra slots at once — empty, with no card drawn into them (official footage, GitHub
cards shown (0.2.0, community report item 19; the official tutorial's 「升级后将出现更多的商品栏位」); the new card #332: bilibili BV1AXwuzdEys 1:39, a 1→2 upgrade with the new slot visible and empty; the official texts only ever say
follows the freeze toggle like a manual refresh's cards. 「升级后将出现更多的商品栏位」/「增加刷新栏位」) — and keeps the cards shown. The empty slots fill on the next roll (a manual
refresh or the round start), like every unfrozen slot; the shop draws an empty slot as a bare frame, never 已招募. 0.2.0
drew a card into each new slot (community report item 19, one uncorroborated remark): reverted in 0.2.2.
* Buying a second copy of an equipped normal item merges into the golden item in the hand (not equipped). * Buying a second copy of an equipped normal item merges into the golden item in the hand (not equipped).
* Promotions by effects (升华, 博士投影) keep the equipment; merges return it; 突变细胞's transformation returns it (the * Promotions by effects (升华, 博士投影) keep the equipment; merges return it; 突变细胞's transformation returns it (the
cell included) before its new operator is gained into the 整备区 — the carrier's tile is left empty (official footage, cell included) before its new operator is gained into the 整备区 — the carrier's tile is left empty (official footage,
+1 -1
View File
@@ -49,7 +49,7 @@
| 出售干员 | 任何干员(含精锐)都只返还 **1** 资金 | | 出售干员 | 任何干员(含精锐)都只返还 **1** 资金 |
| 装备 / 法术 | 调度中心出售除特殊装备外的所有装备:4 把特殊维式重锤(战栗 / 坚固 / 加速 / 灼燃)只来自维多利亚盟约(每 25 层)和洛洛的定制品,突变细胞只来自昆图斯的策略。装备不能出售,整备区里的只能「销毁」(不返还)。已配发的装备锁定在干员身上:只能被第三件装备替换(弹出替换窗口,选择要替换的一件,它会被销毁;盟约之币、博士投影等装备即销毁的道具也一样,先销毁选中的那件再生效,干员留下一个空栏位,精锐干员不能使用博士投影,此时什么都不销毁),或在干员出售 / 合成精锐时退回整备区。**突变细胞**不会用完:战斗结束后装备者被销毁(部署在场上的话,原来的格子空出来,剩余可放置角色加 1),突变细胞和它的其他装备先退回整备区,可以再次配发给任意干员;随后获得一名高一阶(最高 VI)的随机普通干员(装备者是精锐也一样),和其他新获得的干员一样进入整备区(满了进入临时整备区),需要自己重新部署;如果它凑齐了合成所需的同名干员,照常合成精锐。**坚固维式重锤**首次受到致命伤害时锁血 8 秒,**每次部署一次**:被击倒再部署(或突袭跳跃再部署、被 M3茧甲 / 埃芒加德复活)后又能锁一次,娜仁图亚策略借来的锤子也一样;借用到期时正在锁血的会锁满 8 秒;站在阿戈尔干员身前被开场吞噬、自带源石溶剂掉血时也会用掉这一次 | | 装备 / 法术 | 调度中心出售除特殊装备外的所有装备: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→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 个)时,**新栏位立刻出现,但空着不补干员**(官方如此:升级只开位,新位在下一次刷新——手动刷新或回合开始的自动刷新——时才会出现干员;空栏位只画一个空框,不写「已招募」),已有的卡片不变 |
| 其他收入 | 悬赏敌人、盟约「远见」「奇迹」、装备「骑士储蓄罐」、部分策略和特质 | | 其他收入 | 悬赏敌人、盟约「远见」「奇迹」、装备「骑士储蓄罐」、部分策略和特质 |
调度中心等级决定商店能出现的**最高阶级**和格子数(险境及以上:干员 3 / 4 / 4 / 5 / 5 / 5 格 + 装备 1 格)。 调度中心等级决定商店能出现的**最高阶级**和格子数(险境及以上:干员 3 / 4 / 4 / 5 / 5 / 5 格 + 装备 1 格)。
+3
View File
@@ -156,6 +156,9 @@
.scard__idesc { font-size: .12rem; line-height: 1.45; color: var(--text-lo); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } .scard__idesc { font-size: .12rem; line-height: 1.45; color: var(--text-lo); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.scard--sold { background: repeating-linear-gradient(135deg, rgba(255, 255, 255, .02) 0 .08rem, transparent .08rem .16rem), rgba(8, 11, 10, .75); border: 1px dashed var(--line-2); justify-content: center; align-items: center; } .scard--sold { background: repeating-linear-gradient(135deg, rgba(255, 255, 255, .02) 0 .08rem, transparent .08rem .16rem), rgba(8, 11, 10, .75); border: 1px dashed var(--line-2); justify-content: center; align-items: center; }
.scard__soldtxt { display: flex; flex-direction: column; align-items: center; gap: .02rem; font-size: .16rem; color: var(--text-dim); } .scard__soldtxt { display: flex; flex-direction: column; align-items: center; gap: .02rem; font-size: .16rem; color: var(--text-dim); }
/* a slot the 调度中心's upgrade opened and nothing filled yet (ui/shopBar.js EmptyCard, also .scard--sold): the bare frame — no hatching,
no SOLD OUT text */
.scard--sold.scard--empty { background: rgba(8, 11, 10, .4); border-color: var(--line); }
/* ---- funds card ---- */ /* ---- funds card ---- */
.funds { width: 1.18rem; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .08rem; padding: .06rem; border-left: 1px solid var(--line); } .funds { width: 1.18rem; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .08rem; padding: .06rem; border-left: 1px solid var(--line); }
+3 -2
View File
@@ -593,7 +593,7 @@ async function mockRequest(t, f = {}) {
prepOnly(); prepOnly();
if (p.shop.refreshPrice > p.funds) fail('NO_FUNDS'); if (p.shop.refreshPrice > p.funds) fail('NO_FUNDS');
if (p.shop.freeRefreshes > 0) p.shop.freeRefreshes--; else p.funds -= 1; if (p.shop.freeRefreshes > 0) p.shop.freeRefreshes--; else p.funds -= 1;
const n = p.shop.slots.filter((s) => s.kind !== 'item').length; const n = p.shop.slots.filter((s) => !s || s.kind !== 'item').length;
p.shop.slots = [...Array.from({ length: n }, () => makeSlot(p.shop.level)), makeItemSlot()]; p.shop.slots = [...Array.from({ length: n }, () => makeSlot(p.shop.level)), makeItemSlot()];
refreshPrivate(); return {}; refreshPrivate(); return {};
} }
@@ -604,7 +604,8 @@ async function mockRequest(t, f = {}) {
if (p.shop.upgradePrice > p.funds) fail('NO_FUNDS'); if (p.shop.upgradePrice > p.funds) fail('NO_FUNDS');
p.funds -= p.shop.upgradePrice; p.shop.level++; p.shop.upgradePrice = [5, 8, 11, 12, 13][p.shop.level - 1] ?? 0; p.funds -= p.shop.upgradePrice; p.shop.level++; p.shop.upgradePrice = [5, 8, 11, 12, 13][p.shop.level - 1] ?? 0;
const n = p.shop.level >= 4 ? 5 : 4; const n = p.shop.level >= 4 ? 5 : 4;
while (p.shop.slots.filter((s) => s.kind !== 'item').length < n) p.shop.slots.splice(p.shop.slots.length - 1, 0, makeSlot(p.shop.level)); // the upgrade opens the new slot EMPTY (server/match/player/economy.js _openLevelSlots: a null slot; a refresh fills it)
while (p.shop.slots.filter((s) => !s || s.kind !== 'item').length < n) p.shop.slots.splice(p.shop.slots.length - 1, 0, null);
toast(`调度中心等级提升至 ${p.shop.level}`, 'success'); toast(`调度中心等级提升至 ${p.shop.level}`, 'success');
refreshPrivate(); return {}; refreshPrivate(); return {};
} }
+1
View File
@@ -681,6 +681,7 @@
" · 未装备模组": " · No Module equipped", " · 未装备模组": " · No Module equipped",
"已购买": "Purchased", "已购买": "Purchased",
"已招募": "Recruited", "已招募": "Recruited",
"空栏位:刷新或下回合开始时补满": "Empty slot: filled by the next refresh or at the start of the next round",
"再次点击确认升级({price} 资金)": "Tap again to confirm upgrade ({price} Funds)", "再次点击确认升级({price} 资金)": "Tap again to confirm upgrade ({price} Funds)",
"升级调度中心({price} 资金) · {key}": "Upgrade Dispatch Center ({price} Funds) · {key}", "升级调度中心({price} 资金) · {key}": "Upgrade Dispatch Center ({price} Funds) · {key}",
"已满级": "Max Level", "已满级": "Max Level",
+1
View File
@@ -682,6 +682,7 @@
" · 未装备模组": " · モジュール未装備", " · 未装备模组": " · モジュール未装備",
"已购买": "購入済み", "已购买": "購入済み",
"已招募": "獲得済み", "已招募": "獲得済み",
"空栏位:刷新或下回合开始时补满": "空きスロット:更新するか次のラウンド開始時に補充されます",
"再次点击确认升级({price} 资金)": "もう一度タップでレベルアップ(資金{price})", "再次点击确认升级({price} 资金)": "もう一度タップでレベルアップ(資金{price})",
"升级调度中心({price} 资金) · {key}": "管理センターをレベルアップ(資金{price}) · {key}", "升级调度中心({price} 资金) · {key}": "管理センターをレベルアップ(資金{price}) · {key}",
"已满级": "最大レベル", "已满级": "最大レベル",
+1
View File
@@ -678,6 +678,7 @@
" · 未装备模组": " · 모듈 미장착", " · 未装备模组": " · 모듈 미장착",
"已购买": "구매 완료", "已购买": "구매 완료",
"已招募": "구매 완료", "已招募": "구매 완료",
"空栏位:刷新或下回合开始时补满": "빈 슬롯: 갱신하거나 다음 라운드가 시작되면 채워집니다",
"再次点击确认升级({price} 资金)": "한 번 더 탭해 승급 확인 (자금 {price})", "再次点击确认升级({price} 资金)": "한 번 더 탭해 승급 확인 (자금 {price})",
"升级调度中心({price} 资金) · {key}": "보급센터 승급 (자금 {price}) · {key}", "升级调度中心({price} 资金) · {key}": "보급센터 승급 (자금 {price}) · {key}",
"已满级": "최대 레벨", "已满级": "최대 레벨",
+1
View File
@@ -680,6 +680,7 @@
" · 未装备模组": " · 未裝備模組", " · 未装备模组": " · 未裝備模組",
"已购买": "已購買", "已购买": "已購買",
"已招募": "已招募", "已招募": "已招募",
"空栏位:刷新或下回合开始时补满": "空欄位:更新或下回合開始時補滿",
"再次点击确认升级({price} 资金)": "再次點擊確認升級({price} 資金)", "再次点击确认升级({price} 资金)": "再次點擊確認升級({price} 資金)",
"升级调度中心({price} 资金) · {key}": "升級調度中心({price} 資金) · {key}", "升级调度中心({price} 资金) · {key}": "升級調度中心({price} 資金) · {key}",
"已满级": "已滿級", "已满级": "已滿級",
+13 -1
View File
@@ -175,6 +175,17 @@ function SoldCard({ item = false }) {
</div>`; </div>`;
} }
/**
* A slot with no card in it: the one a 调度中心 upgrade has just opened (a `null` slot — server/match/player/economy.js
* _openLevelSlots; GitHub #332 / PR #333: the official shop shows the new slot, empty, until the next refresh or round
* start fills it). Never 已招募 / SOLD OUT: nothing was bought there.
*/
function EmptyCard({ item = false }) {
// `scard--sold` too: the same inert frame (no hover lift, not a buyable card for every `:not(.scard--sold)` rule); `scard--empty` only restyles it
return html`<div class=${cx('scard', 'scard--sold', 'scard--empty', item && 'scard--item')} role="img" aria-label=${t('空栏位:刷新或下回合开始时补满')}
title=${t('空栏位:刷新或下回合开始时补满')}></div>`;
}
function LevelCard({ shop, reason, armed = false, onTap }) { function LevelCard({ shop, reason, armed = false, onTap }) {
const lv = shop?.level ?? 1; const lv = shop?.level ?? 1;
const max = lv >= (shop?.maxLevel ?? 6); const max = lv >= (shop?.maxLevel ?? 6);
@@ -346,7 +357,8 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel,
armed=${armed} onTap=${tapCard} offBonds=${offBonds} />` armed=${armed} onTap=${tapCard} offBonds=${offBonds} />`
: html`<div class="shopbar__cards"> : html`<div class="shopbar__cards">
${chessSlots.map(({ s, i }) => { ${chessSlots.map(({ s, i }) => {
if (!s || s.sold) return html`<${SoldCard} key=${`s${i}`} />`; if (!s) return html`<${EmptyCard} key=${`e${i}`} />`;
if (s.sold) return html`<${SoldCard} key=${`s${i}`} />`;
const reason = shopBlockReason('buy', { priv, editable, slot: s, ...LOOKUPS }); 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=${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} />`; reason=${reason} armed=${armed === armKey('c', i, s)} onTap=${editable ? (idx) => tapCard('c', idx, s, 'chess', reason, onBuy) : null} />`;
+1 -1
View File
@@ -229,7 +229,7 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
if (f0 - ps.funds !== price && !fx && !hasSpendEffects(m, ps)) fail(`${ps.playerId}: level-up paid ${f0 - ps.funds}, price ${price}`); if (f0 - ps.funds !== price && !fx && !hasSpendEffects(m, ps)) fail(`${ps.playerId}: level-up paid ${f0 - ps.funds}, price ${price}`);
const next = gd.upgradeBase(ps.shop.level) ?? 0; const next = gd.upgradeBase(ps.shop.level) ?? 0;
if (ps.shop.upgradePrice !== next) fail(`${ps.playerId}: upgrade price after level-up ${ps.shop.upgradePrice}, expected ${next}`); if (ps.shop.upgradePrice !== next) fail(`${ps.playerId}: upgrade price after level-up ${ps.shop.upgradePrice}, expected ${next}`);
// the new level's extra slots open at once (item 19 of 2026-10-06); the cards shown before stay in place // the new level's extra slots open at once, empty (GitHub #332 / PR #333); the cards shown before stay in place
const { chess, item } = gd.shopSlots(ps.shop.level); const { chess, item } = gd.shopSlots(ps.shop.level);
const want = Math.max(chess, layout0.chess) + Math.max(item, layout0.item); const want = Math.max(chess, layout0.chess) + Math.max(item, layout0.item);
if (ps.shop.slots.length !== want) fail(`${ps.playerId}: ${ps.shop.slots.length} shop slots after the level-up to ${ps.shop.level}, expected ${want}`); if (ps.shop.slots.length !== want) fail(`${ps.playerId}: ${ps.shop.slots.length} shop slots after the level-up to ${ps.shop.level}, expected ${want}`);
+10 -8
View File
@@ -93,11 +93,14 @@ export class PlayerEconomy {
} }
/** /**
* The 调度中心's upgrade opens the new level's extra slots at once, each with a new card drawn at the new level; the * The 调度中心's upgrade opens the new level's extra slots at once — EMPTY: no card is drawn into them (a `null` slot).
* cards already shown stay where they are (chess slots keep their index, the item slot stays after them). Official: the * The next roll (a manual refresh or the round start) fills them with the other unfrozen slots; the cards already shown
* tutorial's 休整期 page 「升级:…升级后将出现更多的商品栏位、可调度干员以及新装备」; the community report of 2026-10-06 * stay where they are (chess slots keep their index, the item slot stays after them). Official: the footage of a 1→2
* (item 19) 「升级商店获得新的商店位时用新卡补上,而不是空着」. Until 0.2.0 the extra slots waited for the next roll (a * upgrade (bilibili BV1AXwuzdEys 1:39, GitHub #332 / PR #333 by @2321Robin) shows the new slot appear and stay empty, and
* refresh or the round start). [ASSUMED] the new card follows the freeze toggle, as a manual refresh's cards do. * the texts only name the slots — the tutorial's 休整期 page 「升级后将出现更多的商品栏位」, PRTS 帮助 「增加刷新栏位」 —,
* never a card that comes with them. 0.2.0 drew a card into each new slot instead (item 19 of the community report of
* 2026-10-06, 「升级商店获得新的商店位时用新卡补上,而不是空着」, one uncorroborated remark; its follow-on, that the new card
* follows the freeze toggle, was [ASSUMED]); until then the extra slots waited for the next roll as well.
*/ */
_openLevelSlots() { _openLevelSlots() {
const { chess: nChess, item: nItem } = this.gd.shopSlots(this.shop.level); const { chess: nChess, item: nItem } = this.gd.shopSlots(this.shop.level);
@@ -106,9 +109,8 @@ export class PlayerEconomy {
if (nChess <= layout.chess && nItem <= layout.item) return; if (nChess <= layout.chess && nItem <= layout.item) return;
const chess = old.slice(0, layout.chess); const chess = old.slice(0, layout.chess);
const items = old.slice(layout.chess); const items = old.slice(layout.chess);
const fresh = (s) => { if (s) s.frozen = this.shop.frozen; return s; }; while (chess.length < nChess) chess.push(null);
while (chess.length < nChess) chess.push(fresh(this._rollChessSlot())); while (items.length < nItem) items.push(null);
while (items.length < nItem) items.push(fresh(this._rollItemSlot()));
this.shop.slots = [...chess, ...items]; this.shop.slots = [...chess, ...items];
this.shop.layout = { chess: chess.length, item: items.length }; this.shop.layout = { chess: chess.length, item: items.length };
} }
+3 -1
View File
@@ -56,7 +56,9 @@ test('matches: the 补位 match\'s human seat fields stand-ins (listed per round
assert.ok(dg, 'scenario present'); assert.ok(dg, 'scenario present');
assert.deepEqual(dg.errors, { engine: 0, logged: 0, sim: 0, dispatcher: 0 }); assert.deepEqual(dg.errors, { engine: 0, logged: 0, sim: 0, dispatcher: 0 });
const fielded = dg.standIns.rounds.filter(([, s]) => s).flatMap(([, s]) => s.split(' ')); const fielded = dg.standIns.rounds.filter(([, s]) => s).flatMap(([, s]) => s.split(' '));
assert.ok(new Set(fielded).size >= 3, `stand-ins fielded: ${[...new Set(fielded)].join(', ')}`); // (≥ 2 since a level-up leaves its new slot empty — GitHub #332, PR #333 by @2321Robin: this match's shop rolls, and with
// them which stand-ins the human seat fields, part from the stream in which the upgrade drew a card into the new slot)
assert.ok(new Set(fielded).size >= 2, `stand-ins fielded: ${[...new Set(fielded)].join(', ')}`);
for (const f of fielded) { for (const f of fielded) {
const [id, charId] = f.split('→'); const [id, charId] = f.split('→');
const c = CHESS[`chess_char_${id}`]; const c = CHESS[`chess_char_${id}`];
+493 -499
View File
File diff suppressed because it is too large. Load diff
+36 -25
View File
@@ -1,8 +1,9 @@
// Community report of 2026-10-06, item 19: 「升级商店获得新的商店位时用新卡补上,而不是空着」. The official tutorial's 休整期 // GitHub #332 / PR #333 by @2321Robin (the footage of an official 1→2 upgrade: bilibili BV1AXwuzdEys 1:39): a 调度中心
// page says 「升级:消耗资金升级调度中心。升级后将出现更多的商品栏位、可调度干员以及新装备」: the upgrade itself opens the // upgrade opens the new level's extra slots at once but EMPTY — the official shop shows the new slot, draws no card into
// new level's extra slot. Until 0.2.0 the shop kept its old slot count until the next roll (a refresh or the round start), // it, and fills it with the next roll (a manual refresh or the round start). The official texts only name the slots: the
// so a level-up from 1 to 2 (险境: 3 → 4 operator slots) or 3 to 4 (4 → 5) showed no new card. Now the upgrade adds the // tutorial's 休整期 page 「升级后将出现更多的商品栏位」, PRTS 帮助 「增加刷新栏位」. 0.2.0 drew a card into each new slot instead
// extra slots at once, each with a new card drawn at the new level, and leaves the cards already shown where they are. // (item 19 of the community report of 2026-10-06, 「升级商店获得新的商店位时用新卡补上,而不是空着」, one uncorroborated remark);
// before that the shop kept its old slot count until the next roll. Either way the upgrade leaves the cards shown where they are.
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { makeMatch, DATA } from './harness.js'; import { makeMatch, DATA } from './harness.js';
@@ -11,7 +12,7 @@ import { GameData } from '../../server/match/gamedata.js';
const ids = (slots) => slots.map((s) => (s ? `${s.kind}:${s.id}${s.sold ? '(sold)' : ''}` : null)); const ids = (slots) => slots.map((s) => (s ? `${s.kind}:${s.id}${s.sold ? '(sold)' : ''}` : null));
const tierOf = (id) => DATA.chess[id]?.tier ?? 0; const tierOf = (id) => DATA.chess[id]?.tier ?? 0;
test('a level-up opens the new level\'s operator slot at once with a new card; the cards shown stay in place', () => { test('a level-up opens the new level\'s operator slot EMPTY; the cards shown stay in place; a refresh fills it', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 7, fake: true }).start(); const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 7, fake: true }).start();
h.toPrep(1); h.toPrep(1);
const ps = h.ps('p_0'); const ps = h.ps('p_0');
@@ -20,35 +21,37 @@ test('a level-up opens the new level\'s operator slot at once with a new card; t
const s1 = ps.shop.slots.slice(); const s1 = ps.shop.slots.slice();
assert.equal(s1.length, 4); assert.equal(s1.length, 4);
assert.deepEqual(h.m.handle('p_0', { t: 'g.levelUp' }), { ok: true }); assert.deepEqual(h.m.handle('p_0', { t: 'g.levelUp' }), { ok: true });
// level 2: 4 operator slots + the item slot — the three cards and the item kept (the same objects), one new card // level 2: 4 operator slots + the item slot — the three cards and the item kept (the same objects), the new slot empty
assert.equal(ps.shop.level, 2); assert.equal(ps.shop.level, 2);
assert.deepEqual(ps.shop.layout, { chess: 4, item: 1 }); assert.deepEqual(ps.shop.layout, { chess: 4, item: 1 });
assert.equal(ps.shop.slots.length, 5); assert.equal(ps.shop.slots.length, 5);
for (let i = 0; i < 3; i++) assert.equal(ps.shop.slots[i], s1[i], `operator card ${i} kept`); for (let i = 0; i < 3; i++) assert.equal(ps.shop.slots[i], s1[i], `operator card ${i} kept`);
assert.equal(ps.shop.slots[4], s1[3], 'the item card stays after the operator cards'); assert.equal(ps.shop.slots[4], s1[3], 'the item card stays after the operator cards');
const added = ps.shop.slots[3]; assert.equal(ps.shop.slots[3], null, 'the new slot is open and empty');
assert.ok(added && added.kind === 'chess' && !added.sold, `a new operator card: ${JSON.stringify(added)}`);
assert.ok(tierOf(added.id) >= 1 && tierOf(added.id) <= 2, `drawn at the new level (tier ${tierOf(added.id)})`);
assert.equal(added.frozen, false);
// the player sees it right away (m.private shop) // the player sees it right away (m.private shop)
assert.deepEqual(ids(ps.privateView().shop.slots), ids(ps.shop.slots)); assert.deepEqual(ids(ps.privateView().shop.slots), ids(ps.shop.slots));
// 2 → 3 keeps 4 operator slots: nothing rerolled // a manual refresh fills every slot at the new level (the empty one included)
assert.deepEqual(h.m.handle('p_0', { t: 'g.refresh' }), { ok: true });
assert.equal(ps.shop.slots.length, 5);
for (const s of ps.shop.slots) assert.ok(s && !s.sold, 'every slot carries a fresh card after the refresh');
for (const s of ps.shop.slots) if (s.kind === 'chess') assert.ok(tierOf(s.id) >= 1 && tierOf(s.id) <= 2, `drawn at level 2 (tier ${tierOf(s.id)})`);
// 2 → 3 keeps 4 operator slots: nothing rerolled, nothing added
const s2 = ps.shop.slots.slice(); const s2 = ps.shop.slots.slice();
h.m.handle('p_0', { t: 'g.levelUp' }); h.m.handle('p_0', { t: 'g.levelUp' });
assert.equal(ps.shop.level, 3); assert.equal(ps.shop.level, 3);
assert.deepEqual(ps.shop.slots, s2, 'no extra slot at level 3, no reroll'); assert.deepEqual(ps.shop.slots, s2, 'no extra slot at level 3, no reroll');
// 3 → 4: the fifth operator slot // 3 → 4: the fifth operator slot, empty again
h.m.handle('p_0', { t: 'g.levelUp' }); h.m.handle('p_0', { t: 'g.levelUp' });
assert.equal(ps.shop.level, 4); assert.equal(ps.shop.level, 4);
assert.deepEqual(ps.shop.layout, { chess: 5, item: 1 }); assert.deepEqual(ps.shop.layout, { chess: 5, item: 1 });
for (let i = 0; i < 4; i++) assert.equal(ps.shop.slots[i], s2[i]); for (let i = 0; i < 4; i++) assert.equal(ps.shop.slots[i], s2[i]);
assert.equal(ps.shop.slots[5], s2[4]); assert.equal(ps.shop.slots[5], s2[4]);
assert.ok(ps.shop.slots[4]?.kind === 'chess' && tierOf(ps.shop.slots[4].id) <= 4); assert.equal(ps.shop.slots[4], null, 'the new slot is open and empty');
h.invariants(); h.invariants();
h.m.dispose(); h.m.dispose();
}); });
test('a bought card stays sold; a frozen shop freezes the new card too [ASSUMED], and the round start keeps it', () => { test('a bought card stays sold; the empty new slot has nothing to freeze; the round start fills the shop to the new level', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 11, fake: true }).start(); const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 11, fake: true }).start();
h.toPrep(1); h.toPrep(1);
const ps = h.ps('p_0'); const ps = h.ps('p_0');
@@ -58,35 +61,43 @@ test('a bought card stays sold; a frozen shop freezes the new card too [ASSUMED]
assert.deepEqual(h.m.handle('p_0', { t: 'g.freeze' }), { ok: true }); assert.deepEqual(h.m.handle('p_0', { t: 'g.freeze' }), { ok: true });
h.m.handle('p_0', { t: 'g.levelUp' }); h.m.handle('p_0', { t: 'g.levelUp' });
assert.equal(ps.shop.slots[0].sold, true, 'the bought slot is not refilled'); assert.equal(ps.shop.slots[0].sold, true, 'the bought slot is not refilled');
const added = ps.shop.slots[3]; assert.equal(ps.shop.slots[3], null, 'the new slot is open and empty — nothing to freeze');
assert.ok(added && !added.sold && added.frozen === true, 'the new card follows the freeze toggle');
const keep = ps.shop.slots.filter((s) => s && !s.sold && s.frozen).map((s) => s.id); const keep = ps.shop.slots.filter((s) => s && !s.sold && s.frozen).map((s) => s.id);
assert.equal(keep.length, 4, 'two operator cards, the new one and the item'); assert.equal(keep.length, 3, 'two operator cards and the item carry the freeze toggle');
// the next round start keeps every frozen card (in place) and rolls only the sold slot // the round start fills every slot of the new level: frozen cards kept in place, the sold and the empty slot rolled
h.drive(() => h.m.phase === 'PREP' && h.m.round === 2); h.drive(() => h.m.phase === 'PREP' && h.m.round === 2);
assert.deepEqual(ps.shop.slots.slice(1).map((s) => s.id), keep);
assert.equal(ps.shop.slots.length, 5); assert.equal(ps.shop.slots.length, 5);
for (const s of ps.shop.slots) assert.ok(s, 'the round start leaves no empty slot');
assert.deepEqual(ps.shop.slots.slice(1).map((s) => s.id).slice(0, 2), keep.slice(0, 2), 'the frozen operator cards kept');
assert.equal(ps.shop.slots[4].id, keep[2], 'the frozen item kept after the operator slots');
assert.notEqual(ps.shop.slots[0].id, undefined, 'the sold slot rerolled');
h.invariants(); h.invariants();
h.m.dispose(); h.m.dispose();
}); });
test('every mode\'s slot table: a level-up adds exactly the slots the new level has more of (标准: 3,4,4,4,4,5)', () => { test('every mode\'s slot table: a level-up opens exactly the new positions, empty (标准: 3,4,4,4,4,5)', () => {
for (const [mode, difficulty] of [['solo', 'FUNNY'], ['solo', 'NORMAL'], ['coop', 'FUNNY'], ['coop', 'ABYSS']]) { for (const [mode, difficulty] of [['solo', 'FUNNY'], ['solo', 'NORMAL'], ['coop', 'FUNNY'], ['coop', 'ABYSS']]) {
const h = makeMatch({ mode, difficulty, seed: 3, fake: true, humans: 1, bots: mode === 'coop' ? 1 : 0 }).start(); const h = makeMatch({ mode, difficulty, seed: 3, fake: true, humans: 1, bots: mode === 'coop' ? 1 : 0 }).start();
h.toPrep(1); h.toPrep(1);
const ps = h.ps('p_0'); const ps = h.ps('p_0');
const gd = h.m.gd; const gd = h.m.gd;
ps.funds = 500; ps.funds = 500;
let empty = 0;
for (let lv = 2; lv <= gd.maxShopLevel; lv++) { for (let lv = 2; lv <= gd.maxShopLevel; lv++) {
const before = ps.shop.slots.slice(); const before = ps.shop.slots.slice();
assert.deepEqual(h.m.handle('p_0', { t: 'g.levelUp' }), { ok: true }); assert.deepEqual(h.m.handle('p_0', { t: 'g.levelUp' }), { ok: true });
const { chess, item } = gd.shopSlots(lv); const { chess, item } = gd.shopSlots(lv);
assert.equal(ps.shop.slots.length, chess + item, `${mode} ${difficulty} L${lv}`); assert.equal(ps.shop.slots.length, chess + item, `${mode} ${difficulty} L${lv}`);
const grew = chess - gd.shopSlots(lv - 1).chess; const grew = (chess - gd.shopSlots(lv - 1).chess) + (item - gd.shopSlots(lv - 1).item);
const kept = ps.shop.slots.filter((s) => before.includes(s)).length; const kept = ps.shop.slots.filter((s) => s && before.includes(s)).length;
assert.equal(kept, before.length, `${mode} ${difficulty} L${lv}: every shown card kept`); assert.equal(kept, before.filter((s) => s).length, `${mode} ${difficulty} L${lv}: every shown card kept`);
assert.equal(ps.shop.slots.length - before.length, grew + (item - gd.shopSlots(lv - 1).item)); // the empty positions accumulate: nothing fills them between the upgrades (no refresh / round start here)
empty += grew;
assert.equal(ps.shop.slots.filter((s) => !s).length, empty, `${mode} ${difficulty} L${lv}: the new positions are open and empty`);
} }
// one refresh fills everything at the final level
assert.deepEqual(h.m.handle('p_0', { t: 'g.refresh' }), { ok: true });
for (const s of ps.shop.slots) assert.ok(s, `${mode} ${difficulty}: the refresh leaves no empty slot`);
h.invariants(); h.invariants();
h.m.dispose(); h.m.dispose();
} }
+118
View File
@@ -0,0 +1,118 @@
// Browser check of the shop bar's empty slot (GitHub #332 / PR #333; opt-in, ~8 s) on the in-match mock harness:
// SP_E2E=1 CHROME_PATH=… node --test test/ui/shop-empty-slot.e2e.test.js
//
// A 调度中心 upgrade opens the new level's extra slot EMPTY (server/match/player/economy.js _openLevelSlots: a null slot
// in m.private shop.slots) and the next refresh or round start fills it. The bar used to draw every null slot like a
// bought one — the SOLD OUT frame with 「已招募」 — which on this slot says that somebody was recruited where nothing was.
// Asserted: the null slot is a bare frame (no text, a tooltip saying it fills at the next refresh), a bought slot still
// says 已招募, a real card is still a card, the empty frame is not a buyable card (`.scard:not(.scard--sold)` skips it)
// and a tap on it opens nothing; the refresh the mock answers fills it.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
describe('the empty slot of the shop bar', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
let srv;
let browser;
let base;
before(async () => {
const { startServer } = await import('../../server/index.js');
const puppeteer = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
base = `http://127.0.0.1:${srv.port}`;
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--force-device-scale-factor=1'] });
});
after(async () => {
await browser?.close();
await srv?.close();
});
const cards = (page) => page.evaluate(() => [...document.querySelectorAll('.shopbar__cards .scard')].map((el) => ({
empty: el.classList.contains('scard--empty'), sold: el.classList.contains('scard--sold'), text: el.textContent.replace(/\s+/g, ' ').trim(),
title: el.getAttribute('title') || '',
})));
test('a null slot is a bare frame, a bought one still says 已招募, and a refresh fills the empty one', async () => {
const page = await browser.newPage();
await page.setViewport({ width: 1600, height: 900 });
const problems = [];
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
await page.goto(`${base}/dev/game-mock.html?phase=PREP&render=fallback`, { waitUntil: 'networkidle0', timeout: 60000 });
await page.waitForSelector('.shopbar__cards .scard', { timeout: 15000 });
await sleep(400);
const before = await cards(page);
const n = before.length;
assert.ok(n >= 4 && !before[0].sold, `the mock's shop has ${n} operator cards, the first one for sale`);
// the upgrade's new slot: a null at the end of the operator slots; one more slot bought
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => {
const slots = S.priv.shop.slots;
const chess = slots.filter((s) => !s || s.kind !== 'item');
const item = slots.filter((s) => s && s.kind === 'item');
chess[0] = { ...chess[0], sold: true };
S.priv.shop.slots = [...chess, null, ...item];
}));
await sleep(300);
const after = await cards(page);
assert.equal(after.length, n + 1);
const empty = after.filter((c) => c.empty);
assert.equal(empty.length, 1, 'one empty frame');
assert.equal(empty[0].text, '', 'no SOLD OUT, no 已招募 on it');
assert.match(empty[0].title, /空栏位/);
assert.ok(empty[0].sold, 'it carries the inert frame class every `:not(.scard--sold)` rule skips (no hover lift, not a buyable card)');
assert.equal(after[0].empty, false);
assert.match(after[0].text, /已招募/, 'a bought slot still says so');
assert.equal(after.filter((c) => !c.sold).length, before.filter((c) => !c.sold).length - 1, 'the rest are real cards');
assert.equal(after[after.length - 1].empty, true, 'the new slot is the last operator slot');
// a tap on the empty frame opens nothing
await page.evaluate(() => document.querySelector('.shopbar__cards .scard--empty').click());
await sleep(250);
assert.equal(await page.$('.dpanel'), null);
// a refresh fills it: the mock rolls every slot
await page.evaluate(() => [...document.querySelectorAll('.toolbtn')].find((b) => /刷新/.test(b.textContent))?.click());
await page.waitForFunction(() => document.querySelectorAll('.shopbar__cards .scard--empty').length === 0, { timeout: 5000 });
assert.equal((await cards(page)).length, n + 1);
assert.deepEqual(problems, []);
await page.close();
});
test('the upgrade itself: tapping the level card twice opens the fifth slot empty, the cards shown stay', async () => {
const page = await browser.newPage();
await page.setViewport({ width: 1600, height: 900 });
const problems = [];
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
await page.goto(`${base}/dev/game-mock.html?phase=PREP&render=fallback`, { waitUntil: 'networkidle0', timeout: 60000 });
await page.waitForSelector('.shopbar__cards .scard', { timeout: 15000 });
// level 3 with its four operator slots, funds for the upgrade (the mock answers like the server: level 4 has five)
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => {
const shop = S.priv.shop;
const chess = shop.slots.filter((s) => s && s.kind === 'chess' && !s.sold).slice(0, 4);
shop.level = 3; shop.upgradePrice = 11; S.priv.funds = 50;
shop.slots = [...chess, shop.slots.find((s) => s && s.kind === 'item')];
}));
await sleep(300);
const before = await cards(page);
assert.equal(before.length, 4);
assert.ok(before.every((c) => !c.sold), 'four real cards');
await page.click('.lvcard'); // two taps: select, confirm
await sleep(200);
await page.click('.lvcard');
await page.waitForFunction(() => document.querySelectorAll('.shopbar__cards .scard--empty').length === 1, { timeout: 5000 });
const after = await cards(page);
assert.equal(after.length, 5);
assert.deepEqual(after.map((c) => c.empty), [false, false, false, false, true]);
assert.deepEqual(after.slice(0, 4).map((c) => c.text), before.map((c) => c.text), 'the cards shown stay as they were');
assert.ok(after.slice(0, 4).every((c) => !/已招募/.test(c.text)));
assert.deepEqual(problems, []);
await page.close();
});
});