- Stock: fixed trait gifts (缪尔赛思 / 变形者集群 变形同构体, 耶拉 不融冰, 蜜蜡 / 卡涅利安 迅捷作战粮, 诗怀雅 盟约之币) still occupy stock but are granted past the cap; an operator card already shown in the shop or a reward can be taken after the pool runs out and overdraws (GitHub #498, the owner's decision); item buys and item rewards keep the stock check. 洛洛 / 焰尾 sub-pools use the measured weights (PR #497 by @2321Robin; the occupancy idea of PR #496 by @2321Robin re-implemented differently). - Boss pairs are drawn at random once per match and fixed to battle start; the Hidden Core reuses them; a leaver does not split a full pair (the owner's decision). - 阿戈尔 in 联防 no longer re-applies the opening devour HP loss or a false revive; Touch's team medic stays for the match after its holder leaves (owner-approved, official lifetime unverified). - Auto-skill ranges: 司霆惊蛰 S2, 斩业星熊 S3 against air, 予愿安洁莉娜 S3; 自在's post-rebirth invulnerability is also untargetable (#510); Horn / Clementia / catapult / 忍冬 first hit (PR #502 by @Xinglan233); 不融冰 / tear gas trigger on real damage. - Bond layer-up sound in round start / draft / prep / battle (#482): one channel, a new cue replaces the old, no extra cooldown; a prep update that adds layers and activates a bond plays one cue. - Client: multi-swing fix (#506), refresh single-flight (PR #507 by @robll-v1), LP tower keeps the settled value with a −N tick (PR #504 by @robll-v1), export with only 潜能 / 练度 changes (PR #503 by @Xinglan233), spectator rosters list DIY members (PR #505 by @Xinglan233), banned members beside bond tooltips (PR #494 by @YuriRestia), recovery action above the away overlay (PR #511 by @RainoutOvO), the /dev/sim-check.html consistency page (PR #492 by @Cloudnyco), Korean (PR #490 by @D-Roote) and English Victoria wording (PR #499 by @YuriRestia). - golden: 45 of 287 scenarios move against 0.2.4 (roster 17, bonds 3, fields 6, matches 17, standins 2), each attributed in docs/history/0.2.5.md §30.9 / §30.16.
240 lines
11 KiB
JavaScript
240 lines
11 KiB
JavaScript
// server/match/player/economy.js — PlayerState methods: the economy — funds, spending (onSpend), prep-side bond layer
|
||
// gains (clamped by layerGainRoom), the shop (onPrice prices, copy-weighted rolls from the shared pool plus the player's
|
||
// own 自选 stock — player/diy.js —, freeze) and its intents: g.buy, g.refresh, g.freeze, g.levelUp, g.sell.
|
||
// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is
|
||
// the player state).
|
||
|
||
import { ERR, PHASE, layerGainRoom } from '../../../shared/constants.js';
|
||
import { freeSlot } from '../board.js';
|
||
import { OK, fail } from './common.js';
|
||
|
||
export class PlayerEconomy {
|
||
addFunds(n, { reason = '' } = {}) {
|
||
if (!Number.isFinite(n) || n === 0) return 0;
|
||
const v = Math.trunc(n);
|
||
const before = this.funds;
|
||
this.funds = Math.max(0, this.funds + v);
|
||
if (v > 0) this.stats.fundsGained += v;
|
||
this.dirty();
|
||
return this.funds - before;
|
||
}
|
||
|
||
spend(n) {
|
||
const v = Number.isFinite(n) ? Math.max(0, Math.trunc(n)) : 0;
|
||
if (v > this.funds) return false;
|
||
this.funds -= v;
|
||
this.stats.gold += v;
|
||
this.round.spent += v;
|
||
this.dirty();
|
||
return true;
|
||
}
|
||
|
||
/** onSpend, dispatched once a payment's action is complete (buy / refresh / levelUp / reward / effect). */
|
||
_afterSpend(amount, reason) {
|
||
if (!(amount > 0)) return;
|
||
this.m.dispatch(this, 'onSpend', { amount, reason, total: this.stats.gold });
|
||
}
|
||
|
||
/**
|
||
* Bond layer gain (prep-side). `requireActive` = "使已激活的【X】层数+N". Returns layers added: at most the room left
|
||
* under BOND_LAYER_CAP (999, shared/constants.js) — a gain at the cap adds 0 and dispatches nothing.
|
||
*/
|
||
addLayers(bondId, n, { requireActive = false, reason = '' } = {}) {
|
||
if (!this.gd.bond(bondId) || !Number.isFinite(n) || n <= 0) return 0;
|
||
if (requireActive && !(this.bonds[bondId] && this.bonds[bondId].active)) return 0;
|
||
const before = this.layers[bondId] || 0;
|
||
const add = layerGainRoom(before, Math.floor(n));
|
||
if (add <= 0) return 0;
|
||
this.layers[bondId] = before + add;
|
||
this.recompute();
|
||
// A live prep gain, not a view delta: reconnects and combat-result backfill must not replay the cue.
|
||
if ([PHASE.ROUND_START, PHASE.SP_DRAFT, PHASE.PREP].includes(this.m.phase)) {
|
||
this.m.sendTo(this.playerId, { t: 'm.layer', playerId: this.playerId, bondId, add });
|
||
}
|
||
this.m.dispatch(this, 'onLayers', { bondId, from: before, to: before + add, reason });
|
||
return add;
|
||
}
|
||
|
||
/** Effective price of a shop slot after onPrice modifiers (never negative). */
|
||
priceOf(slot) {
|
||
if (!slot) return 0;
|
||
const ev = { slot, kind: slot.kind, id: slot.id, price: slot.basePrice };
|
||
this.m.dispatch(this, 'onPrice', ev, { quiet: true });
|
||
const p = Number(ev.price);
|
||
return Number.isFinite(p) ? Math.max(0, Math.round(p)) : slot.basePrice;
|
||
}
|
||
|
||
_rollChessSlot() {
|
||
// the slotted 自选 pieces join the draw once the 调度中心 reaches their slot's level (player/diy.js diyRollEntries)
|
||
const id = this.m.pool.roll(this.m.rngShop, { maxTier: this.shop.level, extra: this.diyRollEntries() });
|
||
return id ? { kind: 'chess', id, basePrice: this.gd.chessPrice(id), frozen: false, sold: false } : null;
|
||
}
|
||
|
||
_rollItemSlot() {
|
||
const id = this.m.pool.rollItem(this.m.rngShop, this.shop.level, this.m.itemPool);
|
||
return id ? { kind: 'item', id, basePrice: this.gd.itemPrice(id), frozen: false, sold: false } : null;
|
||
}
|
||
|
||
/**
|
||
* Reroll the shop. keepFrozen → frozen unsold slots survive (round start); otherwise everything is rerolled
|
||
* (manual refresh). Slot counts follow the current level.
|
||
*/
|
||
rollShop({ keepFrozen = false } = {}) {
|
||
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 };
|
||
const oldChess = old.slice(0, layout.chess);
|
||
const oldItems = old.slice(layout.chess);
|
||
const keep = (s, kind) => (keepFrozen && s && s.kind === kind && !s.sold && s.frozen ? { ...s } : null);
|
||
// frozen slots keep their position; sold / empty / unfrozen positions are rerolled
|
||
const slots = [];
|
||
for (let i = 0; i < nChess; i++) slots.push(keep(oldChess[i], 'chess') ?? this._rollChessSlot());
|
||
for (let i = 0; i < nItem; i++) slots.push(keep(oldItems[i], 'item') ?? this._rollItemSlot());
|
||
this.shop.slots = slots;
|
||
this.shop.layout = { chess: nChess, item: nItem };
|
||
for (const s of slots) if (s) s.frozen = this.shop.frozen;
|
||
this.dirty();
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
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);
|
||
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 };
|
||
}
|
||
|
||
/** Combat start: unfrozen slots are emptied (research 01 A1). */
|
||
clearUnfrozenShop() {
|
||
this.shop.slots = this.shop.slots.map((s) => (s && s.frozen && !s.sold ? s : null));
|
||
this.dirty();
|
||
}
|
||
|
||
/**
|
||
* g.buy {slot}. A full regular hand refuses every purchase, also one whose copy would complete a merge at once (PRTS
|
||
* 卫戍协议/帮助 §手牌区 "当常规手牌区全满无空位时,玩家将无法执行使手牌溢出的操作" — "例如招募/购入等通常情况下会增加手牌的
|
||
* 操作"; BWIKI 盟约 "当整备区达到上限后将无法进行涉及到整备区的操作(购买、撤回等)"; GitHub #82: the reporter's first-hand
|
||
* check in the official game, "哪怕是已经有了两个相同干员情况"). Until 0.1.3 such a purchase was let through.
|
||
*/
|
||
buy(slotIdx) {
|
||
const g = this._gate(); if (g) return g;
|
||
if (!Number.isInteger(slotIdx) || slotIdx < 0 || slotIdx >= this.shop.slots.length) return fail(ERR.BAD_TARGET);
|
||
const slot = this.shop.slots[slotIdx];
|
||
if (!slot) return fail(ERR.BAD_TARGET);
|
||
if (slot.sold) return fail(ERR.SOLD_OUT);
|
||
const price = this.priceOf(slot);
|
||
if (this.funds < price) return fail(ERR.NO_FUNDS);
|
||
const handFull = freeSlot(this.hand) < 0;
|
||
let piece;
|
||
if (slot.kind === 'chess') {
|
||
const rec = this.gd.chess(slot.id);
|
||
// a DIY slot sells only as this player's own 自选 piece (a slotted slot's record carries `diyFor`)
|
||
if (!rec || (rec.isDiy && !rec.diyFor)) return fail(ERR.BAD_TARGET);
|
||
// Already displayed operators stay purchasable after stock runs out (#498; 路标月报#2,
|
||
// BV1eLXXBqEgF 2:50–2:57: nine owned + four shop offers => thirteen owned).
|
||
// acquireChess records every copy, including the deficit; future rolls still exclude empty stock.
|
||
if (handFull) return fail(ERR.HAND_FULL);
|
||
this.spend(price);
|
||
slot.sold = true;
|
||
piece = this.acquireChess(slot.id, { source: 'buy' });
|
||
} else {
|
||
if (!this.gd.item(slot.id)) return fail(ERR.BAD_TARGET);
|
||
if (!this.m.itemPool.canGain(slot.id)) return fail(ERR.SOLD_OUT);
|
||
if (handFull) return fail(ERR.HAND_FULL);
|
||
this.spend(price);
|
||
slot.sold = true;
|
||
piece = this.acquireItem(slot.id, { source: 'buy' });
|
||
}
|
||
this.stats.buys++;
|
||
this.round.buys++;
|
||
this.m.dispatch(this, 'onBuy', { piece, slot, price, kind: slot.kind });
|
||
this._afterSpend(price, 'buy');
|
||
this.recompute();
|
||
return OK;
|
||
}
|
||
|
||
refresh() {
|
||
const g = this._gate(); if (g) return g;
|
||
const free = this.shop.freeRefreshes > 0;
|
||
const price = free ? 0 : this.gd.refreshPrice;
|
||
if (!free && this.funds < price) return fail(ERR.NO_FUNDS);
|
||
if (free) this.shop.freeRefreshes--;
|
||
else this.spend(price);
|
||
this.rollShop({ keepFrozen: false });
|
||
this.stats.refreshes++;
|
||
this.round.refreshes++;
|
||
this.m.dispatch(this, 'onRefresh', { slots: this.shop.slots, free, price });
|
||
this._afterSpend(price, 'refresh');
|
||
this.dirty();
|
||
return OK;
|
||
}
|
||
|
||
freeze() {
|
||
const g = this._gate(); if (g) return g;
|
||
this.shop.frozen = !this.shop.frozen;
|
||
for (const s of this.shop.slots) if (s && !s.sold) s.frozen = this.shop.frozen;
|
||
this.dirty();
|
||
return OK;
|
||
}
|
||
|
||
levelUp() {
|
||
const g = this._gate(); if (g) return g;
|
||
if (this.shop.level >= this.gd.maxShopLevel) return fail(ERR.MAX_LEVEL);
|
||
const price = Math.max(0, this.shop.upgradePrice);
|
||
if (this.funds < price) return fail(ERR.NO_FUNDS);
|
||
this.spend(price);
|
||
this.shop.level++;
|
||
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.dispatch(this, 'onLevelUp', { level: this.shop.level, price });
|
||
this._afterSpend(price, 'levelUp');
|
||
this.dirty();
|
||
return OK;
|
||
}
|
||
|
||
sell(uid) {
|
||
const g = this._gate(); if (g) return g;
|
||
const loc = this.find(uid);
|
||
if (!loc) return fail(ERR.BAD_TARGET);
|
||
if (loc.piece.kind !== 'chess') return fail(ERR.BAD_TARGET, loc.piece.kind === 'item' ? 'items cannot be sold' : 'tokens cannot be sold');
|
||
const piece = loc.piece;
|
||
// its equipment returns to the hand (overflow temp): refuse rather than destroy it when there is no room
|
||
// Removing the owner's summon stacks frees one slot per stack, regardless of its summon count.
|
||
const freed = (x) => x == null || (x.kind === 'token' && x.ownerUid === piece.uid);
|
||
const room = this.hand.filter(freed).length + this.temp.filter(freed).length + (loc.area === 'hand' || loc.area === 'temp' ? 1 : 0);
|
||
if ((piece.items || []).length > room) return fail(ERR.HAND_FULL, 'no room for the equipment');
|
||
this._detach(loc);
|
||
this.removeTokensOf(piece.uid);
|
||
for (const it of piece.items || []) {
|
||
if (!this.stow(it, { allowTemp: true })) this.m.log.warn?.(`[match ${this.m.roomCode}] ${this.playerId}: item ${it.id} lost on sell (no space)`);
|
||
}
|
||
piece.items = [];
|
||
this.returnCopies(piece);
|
||
const ev = { piece, gain: this.gd.sellPrice(piece.id) };
|
||
this.m.dispatch(this, 'onSold', ev);
|
||
const gain = Number.isFinite(ev.gain) ? Math.max(0, Math.trunc(ev.gain)) : 1;
|
||
this.addFunds(gain, { reason: 'sell' });
|
||
this.stats.sells++;
|
||
this.round.sells++;
|
||
this.checkItemMerges();
|
||
this.recompute();
|
||
return OK;
|
||
}
|
||
}
|