Files
ark-stronghold-protocol/server/match/player/economy.js
T
sganggs db1b44bb71 feedback5 WE: the 整备区 rules of PRTS 卫戍协议/帮助 §手牌区 (GitHub #82, second part) — a full regular hand refuses every purchase and reward pick, also one whose copy would complete a merge at once ("当常规手牌区全满无空位时,玩家将无法执行使手牌溢出的操作" — "例如招募/购入等通常情况下会增加手牌的操作"; BWIKI 盟约 says the same with no exception; the reporter checked it in the official game), and a free regular slot pulls the 临时整备区 in at once ("常规手牌区出现空位时自动移入"): PlayerState._fillHandFromTemp runs in every recompute, the temp row right to left (the order it fills, [ASSUMED]) into the hand's free slots right to left — after a sale, a deployment from the hand, a merge that consumed hand copies, an item equipped / destroyed / used, a summon stack removed with its owner. invariants.js: temp holds pieces only while the hand is full. The client's refusal path follows (gameLogic.shopBlockReason: 整备区已满 on every card — a one-line change in a file the R5 split moves). Bots: a full hand never tries a purchase, an affordable third copy of a held pair sheds the weakest hand single first (never one of this prep), resolveTemp decides temp right to left. Tests: test/match/feedback5-hand.test.js; merge / hardening / playtest6-elite-to-board / economy / prep-bench / uiFixes updated to the rule. Docs: META §1 Hand, DESIGN §6, PLAYING §3, research 00-INDEX / 01 (the old "a merge-completing purchase is allowed" corrected). Golden: 3 match scenarios moved (coop2-NORMAL-3, solo-HARD-9-boosted, coop2-ABYSS-10-boosted — bot seats that reached a full hand: the merge-completing purchase refused or a single shed first, temp pieces moving in by themselves); every battle digest unchanged
2026-10-05 18:17:08 +08:00

207 lines
8.8 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, 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, 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();
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() {
const id = this.m.pool.roll(this.m.rngShop, { maxTier: this.shop.level });
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);
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();
}
/** 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);
if (!rec) return fail(ERR.BAD_TARGET);
const base = this.gd.baseIdOf(slot.id);
const need = rec.isGolden ? this.gd.goldenCopies : 1;
if (this.m.pool.has(base) && this.m.pool.left(base) < need) return fail(ERR.SOLD_OUT);
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 (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.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
const room = this.hand.filter((x) => x == null).length + this.temp.filter((x) => x == null).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;
}
}