Files
ark-stronghold-protocol/server/match/player/economy.js
T
sganggs 221e6b8dd2 0.2.5-dev: community round 30 and follow-ups
- 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.
2026-10-12 14:07:08 +08:00

240 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}
}