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,
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
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
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;
@@ -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
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 level-up opens the new level's extra slots at once, each with a new card drawn at the new level, and keeps the
cards shown (0.2.0, community report item 19; the official tutorial's 「升级后将出现更多的商品栏位」); the new card
follows the freeze toggle like a manual refresh's cards.
* A level-up opens the new level's extra slots at once — empty, with no card drawn into them (official footage, GitHub
#332: bilibili BV1AXwuzdEys 1:39, a 1→2 upgrade with the new slot visible and empty; the official texts only ever say
「升级后将出现更多的商品栏位」/「增加刷新栏位」) — 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).
* 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,
+1 -1
View File
@@ -49,7 +49,7 @@
| 出售干员 | 任何干员(含精锐)都只返还 **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→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
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--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); }
/* 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 { 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();
if (p.shop.refreshPrice > p.funds) fail('NO_FUNDS');
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()];
refreshPrivate(); return {};
}
@@ -604,7 +604,8 @@ async function mockRequest(t, f = {}) {
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;
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');
refreshPrivate(); return {};
}
+1
View File
@@ -681,6 +681,7 @@
" · 未装备模组": " · No Module equipped",
"已购买": "Purchased",
"已招募": "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} 资金) · {key}": "Upgrade Dispatch Center ({price} Funds) · {key}",
"已满级": "Max Level",
+1
View File
@@ -682,6 +682,7 @@
" · 未装备模组": " · モジュール未装備",
"已购买": "購入済み",
"已招募": "獲得済み",
"空栏位:刷新或下回合开始时补满": "空きスロット:更新するか次のラウンド開始時に補充されます",
"再次点击确认升级({price} 资金)": "もう一度タップでレベルアップ(資金{price})",
"升级调度中心({price} 资金) · {key}": "管理センターをレベルアップ(資金{price}) · {key}",
"已满级": "最大レベル",
+1
View File
@@ -678,6 +678,7 @@
" · 未装备模组": " · 모듈 미장착",
"已购买": "구매 완료",
"已招募": "구매 완료",
"空栏位:刷新或下回合开始时补满": "빈 슬롯: 갱신하거나 다음 라운드가 시작되면 채워집니다",
"再次点击确认升级({price} 资金)": "한 번 더 탭해 승급 확인 (자금 {price})",
"升级调度中心({price} 资金) · {key}": "보급센터 승급 (자금 {price}) · {key}",
"已满级": "최대 레벨",
+1
View File
@@ -680,6 +680,7 @@
" · 未装备模组": " · 未裝備模組",
"已购买": "已購買",
"已招募": "已招募",
"空栏位:刷新或下回合开始时补满": "空欄位:更新或下回合開始時補滿",
"再次点击确认升级({price} 资金)": "再次點擊確認升級({price} 資金)",
"升级调度中心({price} 资金) · {key}": "升級調度中心({price} 資金) · {key}",
"已满级": "已滿級",
+13 -1
View File
@@ -175,6 +175,17 @@ function SoldCard({ item = false }) {
</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 }) {
const lv = shop?.level ?? 1;
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} />`
: html`<div class="shopbar__cards">
${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 });
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} />`;
+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}`);
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}`);
// 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 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}`);
+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
* cards already shown stay where they are (chess slots keep their index, the item slot stays after them). Official: the
* tutorial's 休整期 page 「升级:…升级后将出现更多的商品栏位、可调度干员以及新装备」; the community report of 2026-10-06
* (item 19) 「升级商店获得新的商店位时用新卡补上,而不是空着」. Until 0.2.0 the extra slots waited for the next roll (a
* refresh or the round start). [ASSUMED] the new card follows the freeze toggle, as a manual refresh's cards do.
* The 调度中心's upgrade opens the new level's extra slots at once — EMPTY: no card is drawn into them (a `null` slot).
* The next roll (a manual refresh or the round start) fills them with the other unfrozen slots; the cards already shown
* stay where they are (chess slots keep their index, the item slot stays after them). Official: the footage of a 1→2
* upgrade (bilibili BV1AXwuzdEys 1:39, GitHub #332 / PR #333 by @2321Robin) shows the new slot appear and stay empty, and
* 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() {
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;
const chess = old.slice(0, 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(fresh(this._rollChessSlot()));
while (items.length < nItem) items.push(fresh(this._rollItemSlot()));
while (chess.length < nChess) chess.push(null);
while (items.length < nItem) items.push(null);
this.shop.slots = [...chess, ...items];
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.deepEqual(dg.errors, { engine: 0, logged: 0, sim: 0, dispatcher: 0 });
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) {
const [id, charId] = f.split('→');
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 休整期
// page says 「升级:消耗资金升级调度中心。升级后将出现更多的商品栏位、可调度干员以及新装备」: the upgrade itself opens the
// 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),
// 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
// extra slots at once, each with a new card drawn at the new level, and leaves the cards already shown where they are.
// GitHub #332 / PR #333 by @2321Robin (the footage of an official 1→2 upgrade: bilibili BV1AXwuzdEys 1:39): a 调度中心
// upgrade opens the new level's extra slots at once but EMPTY — the official shop shows the new slot, draws no card into
// it, and fills it with the next roll (a manual refresh or the round start). The official texts only name the slots: the
// tutorial's 休整期 page 「升级后将出现更多的商品栏位」, PRTS 帮助 「增加刷新栏位」. 0.2.0 drew a card into each new slot instead
// (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 assert from 'node:assert/strict';
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 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();
h.toPrep(1);
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();
assert.equal(s1.length, 4);
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.deepEqual(ps.shop.layout, { chess: 4, item: 1 });
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`);
assert.equal(ps.shop.slots[4], s1[3], 'the item card stays after the operator cards');
const added = ps.shop.slots[3];
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);
assert.equal(ps.shop.slots[3], null, 'the new slot is open and empty');
// the player sees it right away (m.private shop)
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();
h.m.handle('p_0', { t: 'g.levelUp' });
assert.equal(ps.shop.level, 3);
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' });
assert.equal(ps.shop.level, 4);
assert.deepEqual(ps.shop.layout, { chess: 5, item: 1 });
for (let i = 0; i < 4; i++) assert.equal(ps.shop.slots[i], s2[i]);
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.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();
h.toPrep(1);
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 });
h.m.handle('p_0', { t: 'g.levelUp' });
assert.equal(ps.shop.slots[0].sold, true, 'the bought slot is not refilled');
const added = ps.shop.slots[3];
assert.ok(added && !added.sold && added.frozen === true, 'the new card follows the freeze toggle');
assert.equal(ps.shop.slots[3], null, 'the new slot is open and empty — nothing to freeze');
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');
// the next round start keeps every frozen card (in place) and rolls only the sold slot
assert.equal(keep.length, 3, 'two operator cards and the item carry the freeze toggle');
// 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);
assert.deepEqual(ps.shop.slots.slice(1).map((s) => s.id), keep);
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.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']]) {
const h = makeMatch({ mode, difficulty, seed: 3, fake: true, humans: 1, bots: mode === 'coop' ? 1 : 0 }).start();
h.toPrep(1);
const ps = h.ps('p_0');
const gd = h.m.gd;
ps.funds = 500;
let empty = 0;
for (let lv = 2; lv <= gd.maxShopLevel; lv++) {
const before = ps.shop.slots.slice();
assert.deepEqual(h.m.handle('p_0', { t: 'g.levelUp' }), { ok: true });
const { chess, item } = gd.shopSlots(lv);
assert.equal(ps.shop.slots.length, chess + item, `${mode} ${difficulty} L${lv}`);
const grew = chess - gd.shopSlots(lv - 1).chess;
const kept = ps.shop.slots.filter((s) => before.includes(s)).length;
assert.equal(kept, before.length, `${mode} ${difficulty} L${lv}: every shown card kept`);
assert.equal(ps.shop.slots.length - before.length, grew + (item - gd.shopSlots(lv - 1).item));
const grew = (chess - gd.shopSlots(lv - 1).chess) + (item - gd.shopSlots(lv - 1).item);
const kept = ps.shop.slots.filter((s) => s && before.includes(s)).length;
assert.equal(kept, before.filter((s) => s).length, `${mode} ${difficulty} L${lv}: every shown card kept`);
// 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.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();
});
});