feedback5 CB5: a 调度中心 upgrade opens the new level's extra slot at once with a new card, the cards shown stay in place (community report of 2026-10-06, item 19; the official tutorial's 休整期 page: 「升级后将出现更多的商品栏位」)
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 1→2 (险境: 3→4 operator slots) or 3→4 (4→5; 标准: 5→6) showed no new card. PlayerState._openLevelSlots (player/economy.js): the extra slots of the new level are appended to the chess slots (the item slot stays after them), each drawn at the new level; [ASSUMED] the new card follows the freeze toggle as a manual refresh's cards do. audit.js checks the slot count and that every card shown before stays in place. Docs: META.md §3 / §7, PLAYING.md. Golden: matches — all 18 scenarios moved (solo-FUNNY-1/2, solo-NORMAL-1/2, solo-HARD-1/2, solo-ABYSS-1/2, coop2-NORMAL-3, coop2-HARD-4, coop3-ABYSS-5, coop4-FUNNY-6, coop4-ABYSS-7, coop2-NORMAL-8-serverrun, solo-HARD-9-boosted, coop2-ABYSS-10-boosted, coop2-NORMAL-14-standins, coop2-NORMAL-70-diy): every bot levels its shop and now sees (and draws the shop RNG for) the new card at once, so purchases and boards part from there. roster / bonds / fields / standins / diy unchanged; no engine error stored. Tests: test/match/feedback5-shop-levelslots.test.js (new).
This commit is contained in:
1 parent
02353e13f8
commit
464a823bc5
6 files changed
+635
-507
No files matched your search
+5
-2
@@ -505,7 +505,8 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
|
|||||||
items by `price`, refresh 1 (free refreshes first), sell +1 (elite too; items cannot be sold, only destroyed).
|
items by `price`, refresh 1 (free refreshes first), sell +1 (elite too; items cannot be sold, only destroyed).
|
||||||
* **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; `MAX_LEVEL` at 6. One freeze
|
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
|
||||||
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;
|
||||||
@@ -823,7 +824,9 @@ 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.
|
||||||
* Level-up does not add slots until the next roll (refresh / 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.
|
||||||
* 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
@@ -48,7 +48,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),升级后按下一级的价格重新计算 |
|
| 升级调度中心(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 格)。
|
||||||
|
|||||||
@@ -221,12 +221,23 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
|
|||||||
const price = ps.shop.upgradePrice;
|
const price = ps.shop.upgradePrice;
|
||||||
const f0 = ps.funds;
|
const f0 = ps.funds;
|
||||||
const fx = hasSpendEffects(m, ps);
|
const fx = hasSpendEffects(m, ps);
|
||||||
|
const slots0 = ps.shop.slots.slice();
|
||||||
|
const layout0 = ps.shop.layout || { chess: slots0.length, item: 0 };
|
||||||
const res = orig();
|
const res = orig();
|
||||||
if (res && res.ok) check('levelUp', () => {
|
if (res && res.ok) check('levelUp', () => {
|
||||||
if (ps.shop.level !== lv + 1) fail(`${ps.playerId}: level ${lv} → ${ps.shop.level}`);
|
if (ps.shop.level !== lv + 1) fail(`${ps.playerId}: level ${lv} → ${ps.shop.level}`);
|
||||||
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
|
||||||
|
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}`);
|
||||||
|
const layout = ps.shop.layout || layout0;
|
||||||
|
slots0.forEach((s, i) => {
|
||||||
|
const at = i < layout0.chess ? i : layout.chess + (i - layout0.chess);
|
||||||
|
if (ps.shop.slots[at] !== s) fail(`${ps.playerId}: shop slot ${i} changed by the level-up`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
return res;
|
return res;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -92,6 +92,27 @@ export class PlayerEconomy {
|
|||||||
this.dirty();
|
this.dirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
_openLevelSlots() {
|
||||||
|
const { chess: nChess, item: nItem } = this.gd.shopSlots(this.shop.level);
|
||||||
|
const old = this.shop.slots;
|
||||||
|
const layout = this.shop.layout || { chess: old.length, item: 0 };
|
||||||
|
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()));
|
||||||
|
this.shop.slots = [...chess, ...items];
|
||||||
|
this.shop.layout = { chess: chess.length, item: items.length };
|
||||||
|
}
|
||||||
|
|
||||||
/** Combat start: unfrozen slots are emptied (research 01 A1). */
|
/** Combat start: unfrozen slots are emptied (research 01 A1). */
|
||||||
clearUnfrozenShop() {
|
clearUnfrozenShop() {
|
||||||
this.shop.slots = this.shop.slots.map((s) => (s && s.frozen && !s.sold ? s : null));
|
this.shop.slots = this.shop.slots.map((s) => (s && s.frozen && !s.sold ? s : null));
|
||||||
@@ -173,6 +194,7 @@ export class PlayerEconomy {
|
|||||||
this.spend(price);
|
this.spend(price);
|
||||||
this.shop.level++;
|
this.shop.level++;
|
||||||
this.shop.upgradePrice = this.gd.upgradeBase(this.shop.level) ?? 0;
|
this.shop.upgradePrice = this.gd.upgradeBase(this.shop.level) ?? 0;
|
||||||
|
this._openLevelSlots();
|
||||||
this.m.tickerFor('SHOP_LEVEL', [this.name, String(this.shop.level)], { playerId: this.playerId, param: String(this.shop.level) });
|
this.m.tickerFor('SHOP_LEVEL', [this.name, String(this.shop.level)], { playerId: this.playerId, param: String(this.shop.level) });
|
||||||
this.m.dispatch(this, 'onLevelUp', { level: this.shop.level, price });
|
this.m.dispatch(this, 'onLevelUp', { level: this.shop.level, price });
|
||||||
this._afterSpend(price, 'levelUp');
|
this._afterSpend(price, 'levelUp');
|
||||||
|
|||||||
+502
-504
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,94 @@
|
|||||||
|
// 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.
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { makeMatch, DATA } from './harness.js';
|
||||||
|
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', () => {
|
||||||
|
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 7, fake: true }).start();
|
||||||
|
h.toPrep(1);
|
||||||
|
const ps = h.ps('p_0');
|
||||||
|
ps.funds = 200;
|
||||||
|
assert.deepEqual(h.m.gd.shopSlots(1), { chess: 3, item: 1 });
|
||||||
|
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
|
||||||
|
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);
|
||||||
|
// 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
|
||||||
|
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
|
||||||
|
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);
|
||||||
|
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', () => {
|
||||||
|
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 11, fake: true }).start();
|
||||||
|
h.toPrep(1);
|
||||||
|
const ps = h.ps('p_0');
|
||||||
|
ps.funds = 200;
|
||||||
|
assert.deepEqual(h.m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
|
||||||
|
assert.equal(ps.shop.slots[0].sold, true);
|
||||||
|
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');
|
||||||
|
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
|
||||||
|
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);
|
||||||
|
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)', () => {
|
||||||
|
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;
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
h.invariants();
|
||||||
|
h.m.dispose();
|
||||||
|
}
|
||||||
|
assert.deepEqual([1, 2, 3, 4, 5, 6].map((l) => new GameData(DATA, 'mode_single_funny').shopSlots(l).chess), [3, 4, 4, 4, 4, 5]);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user