Files
ark-stronghold-protocol/server/match/effectsMeta.js
T

624 lines
33 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/effectsMeta.js — registry + dispatcher of prep-phase ("SERVER_*") effects (DESIGN §6.4).
// Full API reference with examples: docs/META.md.
//
// Registry keys (one handler object per key; a later register() of the same key replaces the earlier one, so content
// registered by server/sim/content/*.js → registerMeta(registry) overrides the engine's built-in defaults):
// garrison:<effectKey> 特质 prep effects (data/garrisons.json effectKey, e.g. 'garrison:SERVER_ADD_BOND_METHOD')
// band:<bandId> strategy (band) effects
// bond:<bondId> bond meta effects (助力 per-round layers, 远见 milestones, …)
// item:<itemKey> equipment / Arts (itemKey = item id without the _a/_b suffix, e.g. 'item:chess_item_1_03_e')
// choice:<effectId> 机变 card application (overrides the family default in choices.js)
// effect:<id> persistent EffectRefs added with ctx.addEffect({ key: 'effect:<id>' … })
// global:<name> runs for every player on every hook
//
// Hooks (handler methods; all optional, signature (ctx, ev)):
// onRoundStart onIncome onPrepStart onPrepEnd onGain onSold onRefresh onPrice onBuy onSpend onMerge onLevelUp
// onBattleStart onBattleResult onChoicePick onEquip onArt onDestroy onLayers
// Garrisons fire only on the hook of their eventType (SERVER_GAIN→onGain of the gained piece, SERVER_PREP_START→
// onRoundStart, SERVER_PREP_FIN→onPrepEnd, SERVER_CHESS_SOLD→onSold of the sold piece, SERVER_PRICE→onPrice of the
// slot's chess, SERVER_REFRESH_SHOP→onRefresh); the dispatcher calls `handler[hook] ?? handler.run`. A handler may
// widen that per garrison with `garrisonHooks(garrison) → hook[]` (e.g. "<进入休整期时><休整期结束时>"). Owned-piece
// garrisons fire for board pieces, and for hand pieces unless bbStr.conditionkey is 'character_target_inboard'.
// 投资人 (investShip) active ⇒ SERVER_GAIN garrisons run ×2 (×3 at ≥ 100 layers) — owned by the dispatcher.
// ctx.triggerGarrisons(uid, eventType) re-runs another piece's garrisons (铃兰, "触发…的获得时效果", 特质相同).
// Items: onEquip / onArt / onDestroy go to the item's own handler only; every other hook runs for items equipped on
// owned chess (ev.source.holder). That step walks a snapshot — the owned chess and each holder's items as they stand
// when it begins (every [holder, item] pair, taken before the first item runs) — and runs an item only if, when its
// turn comes, it is still equipped on that holder and the holder is still owned: handlers move and destroy pieces
// mid-walk (突变细胞 destroys its holder, returns the equipment to the hand and gains an operator; normal 博士投影
// destroys itself, which splices holder.items), and a live walk skipped the next item or ran it with a holder that was
// gone. Pieces gained or equipped during the walk wait for the next dispatch; an item moved during it runs at most once
// (on the holder it stood on, if its turn came before the move).
// Dispatch order per player: global → band → bonds → garrisons (board reading order,
// then hand) → equipped items → effects (insertion order); onPrice runs the priced chess's own 特质 first (购买价格为N
// sets the price the discounts and caps of bonds / strategies then act on — user playtest #5). Every call is
// try/catch-guarded; nested dispatch depth is capped (MAX_DEPTH) so content can never loop the server.
// An eliminated player gets no dispatch, except the persistent effects whose handler sets `afterElimination: true`
// (onRoundStart, EffectDispatcher.dispatchEliminated: 信标's gift — GitHub #86).
import { itemKey } from './gamedata.js';
import { boardOrder, parseKey, tileKey } from './board.js';
import { pieceBonds as bondsOfPiece } from './bondsMeta.js';
import { registerAllMeta } from '../sim/content/index.js';
import { registerBuiltins } from './builtinMeta.js';
export const HOOKS = Object.freeze([
'onRoundStart', 'onIncome', 'onPrepStart', 'onPrepEnd', 'onGain', 'onSold', 'onRefresh', 'onPrice', 'onBuy',
'onSpend', 'onMerge', 'onLevelUp', 'onBattleStart', 'onBattleResult', 'onChoicePick', 'onEquip', 'onArt',
'onDestroy', 'onLayers',
]);
/** Non-hook members a handler object may carry. */
const HANDLER_EXTRAS = new Set(['run', 'garrisonHooks']);
/** Garrison eventType → dispatcher hook. */
export const GARRISON_HOOK = Object.freeze({
SERVER_GAIN: 'onGain',
SERVER_PREP_START: 'onRoundStart',
SERVER_PREP_FIN: 'onPrepEnd',
SERVER_CHESS_SOLD: 'onSold',
SERVER_PRICE: 'onPrice',
SERVER_REFRESH_SHOP: 'onRefresh',
});
const KEY_RE = /^(garrison|band|bond|item|choice|effect|global):[A-Za-z0-9_\-.:#]+$/;
export const MAX_DEPTH = 6;
export class MetaRegistry {
constructor() {
/** @type {Map<string, object>} */
this._h = new Map();
/** registration problems that do not throw (unknown hook names — usually a typo) */
this.warnings = [];
}
/**
* Register a handler object (or a bare function = { run }) under a key. Replaces an existing registration.
* @param {string} key
* @param {object|Function} handler
*/
register(key, handler) {
if (typeof key !== 'string' || !KEY_RE.test(key)) throw new TypeError(`MetaRegistry.register: bad key ${String(key)}`);
const h = typeof handler === 'function' ? { run: handler } : handler;
if (!h || typeof h !== 'object') throw new TypeError(`MetaRegistry.register(${key}): handler must be an object or function`);
for (const name of Object.keys(h)) {
if (typeof h[name] === 'function' && !HOOKS.includes(name) && !HANDLER_EXTRAS.has(name) && this.warnings.length < 200) {
this.warnings.push(`${key}: '${name}' is not a hook (${HOOKS.join(' ')})`);
}
}
this._h.set(key, h);
return this;
}
unregister(key) { return this._h.delete(key); }
get(key) { return this._h.get(key) ?? null; }
has(key) { return this._h.has(key); }
keys() { return [...this._h.keys()]; }
// sugar
garrison(effectKey, h) { return this.register(`garrison:${effectKey}`, h); }
band(bandId, h) { return this.register(`band:${bandId}`, h); }
bond(bondId, h) { return this.register(`bond:${bondId}`, h); }
item(key, h) { return this.register(`item:${itemKey(key)}`, h); }
choice(effectId, h) { return this.register(`choice:${effectId}`, h); }
effect(id, h) { return this.register(`effect:${id}`, h); }
global(name, h) { return this.register(`global:${name}`, h); }
globals() {
const out = [];
for (const [k, h] of this._h) if (k.startsWith('global:')) out.push([k, h]);
return out;
}
}
let defaultRegistry = null;
/** Build a registry: engine built-ins first, then every content module's registerMeta (content wins). */
export function createRegistry({ builtins = true, content = true, log = console } = {}) {
const reg = new MetaRegistry();
if (builtins) {
try { registerBuiltins(reg); } catch (e) { log.error?.('[meta] registerBuiltins failed', e); }
}
if (content) {
try { registerAllMeta(reg); } catch (e) { log.error?.('[meta] registerAllMeta failed', e); }
}
if (reg.warnings.length) log.warn?.(`[meta] ${reg.warnings.length} handler method(s) are not hooks and will never run, e.g. ${reg.warnings.slice(0, 3).join('; ')}`);
return reg;
}
/** Process-wide registry (server boot): built-ins + content. */
export function getDefaultRegistry() {
if (!defaultRegistry) defaultRegistry = createRegistry();
return defaultRegistry;
}
/** Tests: drop the singleton. */
export function resetDefaultRegistry() { defaultRegistry = null; }
// =====================================================================================================
// dispatcher
export class EffectDispatcher {
/**
* @param {import('./Match.js').Match} m
* @param {MetaRegistry} registry
*/
constructor(m, registry) {
this.m = m;
this.registry = registry;
this.depth = 0;
this.errors = 0;
this._errKeys = new Set();
/** `${key}.${hook}: message` → { count, stack } (diagnostics, tools/matchrun.mjs --errors) */
this.errorsByKey = new Map();
}
_report(key, hook, e) {
this.errors++;
const msg = `${key}.${hook}: ${e && e.message ? e.message : e}`;
const rec = this.errorsByKey.get(msg);
if (rec) rec.count++;
else if (this.errorsByKey.size < 500) this.errorsByKey.set(msg, { count: 1, stack: e && e.stack ? String(e.stack) : null });
if (this._errKeys.has(msg)) return;
this._errKeys.add(msg);
if (this._errKeys.size > 500) this._errKeys.clear();
this.m.log.error?.(`[match ${this.m.roomCode}] meta handler error ${msg}`, e && e.stack ? e.stack.split('\n').slice(0, 4).join(' | ') : '');
}
_call(ps, key, handler, hook, source, ev, repeat = 1) {
const fn = typeof handler[hook] === 'function' ? handler[hook] : source.kind === 'garrison' && typeof handler.run === 'function' ? handler.run : null;
if (!fn) return;
const ctx = makeCtx(this.m, ps, source, hook, ev);
for (let i = 0; i < repeat; i++) {
try { fn.call(handler, ctx, ev); } catch (e) { this._report(key, hook, e); }
}
}
/** 获得时 multiplier from 投资人 (investShip): ×2 while active, ×3 at ≥ 100 layers (research 02 §3.17). */
investRepeat(ps) {
const b = ps.bonds && ps.bonds.investShip;
if (!b || !b.active) return 1;
const bond = this.m.gd.bond('investShip');
const bb = bond && bond.bb ? bond.bb : {};
// data: bb.count 2 ("每次触发2次"), layerMilestones [{ layer: 100 }] ("<达到100层>…每次触发3次")
const base = Number.isInteger(bb.count) && bb.count > 1 ? bb.count : 2;
const ms = bond && Array.isArray(bond.layerMilestones) ? bond.layerMilestones.find((x) => x && Number(x.layer) > 0) : null;
const hi = ms ? Number(ms.layer) : Number.isFinite(bb.layer) && bb.layer > 0 ? bb.layer : 100;
return b.layers >= hi ? base + 1 : base;
}
/**
* Dispatch a hook for one player. `ev` is passed to every handler (mutable: onPrice writes ev.price, onSold
* ev.gain, onBattleStart ev.input, …) and returned.
*/
dispatch(ps, hook, ev = {}, { quiet = false, skipKey = null } = {}) {
if (!ps || this.depth >= MAX_DEPTH) {
if (ps && !quiet) this.m.log.warn?.(`[match ${this.m.roomCode}] meta dispatch depth limit (${hook})`);
return ev;
}
this.depth++;
try {
const reg = this.registry;
// 0. onPrice: the priced chess's own 特质 first — 购买价格为N defines the price every other modifier acts on
if (hook === 'onPrice') this._garrisons(ps, hook, ev);
// 1. globals
for (const [key, h] of reg.globals()) this._call(ps, key, h, hook, { kind: 'global', key }, ev);
// 2. band
if (ps.bandId) {
const key = `band:${ps.bandId}`;
const h = reg.get(key);
if (h) this._call(ps, key, h, hook, { kind: 'band', key, bandId: ps.bandId, band: this.m.gd.band(ps.bandId) }, ev);
}
// 3. bonds
for (const bondId of this.m.gd.bondIds) {
const key = `bond:${bondId}`;
const h = reg.get(key);
if (h) this._call(ps, key, h, hook, { kind: 'bond', key, bondId, bond: ps.bonds[bondId] ?? null }, ev);
}
// 4. garrisons (onPrice: already run as step 0)
if (hook !== 'onPrice') this._garrisons(ps, hook, ev);
// 5. equipped items (not for the item-specific hooks): every [holder, item] pair of the owned chess, taken before
// the first item runs; each runs only while still equipped on its still-owned holder — handlers move / destroy
// pieces (header). Taking the pairs per holder as the walk reached it ran an item equipped meanwhile onto a later
// holder, and twice an item moved from a holder already walked to a later one.
if (hook !== 'onEquip' && hook !== 'onArt' && hook !== 'onDestroy') {
const pairs = [];
for (const holder of ownedChess(ps)) for (const it of holder.items || []) pairs.push([holder, it]);
for (const [holder, it] of pairs) {
const key = `item:${itemKey(it.id)}`;
const h = reg.get(key);
if (h && stillEquipped(ps, holder, it)) this._call(ps, key, h, hook, { kind: 'item', key, piece: it, holder, item: this.m.gd.item(it.id) }, ev);
}
}
// 6. persistent effects
for (const ref of ps.effects.slice()) {
if (!ref || typeof ref.key !== 'string' || ref.key === skipKey) continue;
const h = reg.get(ref.key);
if (h) this._call(ps, ref.key, h, hook, { kind: 'effect', key: ref.key, ref }, ev);
}
} finally {
this.depth--;
}
return ev;
}
/**
* `hook` for an ELIMINATED player (Match.startRound: onRoundStart): only its persistent effects whose handler sets
* `afterElimination: true` run — 信标's gift still reaches the teammate when its sender is out (builtinMeta.js
* builtin_gift, GitHub #86). Nothing else of an eliminated player is dispatched.
*/
dispatchEliminated(ps, hook, ev = {}) {
if (!ps || ps.alive || this.depth >= MAX_DEPTH) return ev;
this.depth++;
try {
for (const ref of ps.effects.slice()) {
if (!ref || typeof ref.key !== 'string') continue;
const h = this.registry.get(ref.key);
if (h && h.afterElimination === true) this._call(ps, ref.key, h, hook, { kind: 'effect', key: ref.key, ref }, ev);
}
} finally {
this.depth--;
}
return ev;
}
/** Hooks a garrison handler runs on: its eventType's hook, or what `handler.garrisonHooks(garrison)` returns. */
_garrisonHooks(h, g) {
if (typeof h.garrisonHooks === 'function') {
try {
const list = h.garrisonHooks(g);
if (Array.isArray(list) && list.length) return list;
} catch (e) { this._report(`garrison:${g.effectKey}`, 'garrisonHooks', e); }
}
return [GARRISON_HOOK[g.eventType]];
}
_garrisons(ps, hook, ev) {
const gd = this.m.gd;
const run = (piece, where) => {
const rec = gd.chess(piece.id);
if (!rec || !Array.isArray(rec.garrisonIds)) return;
for (const gid of rec.garrisonIds) {
const g = gd.garrison(gid);
if (!g) continue;
const key = `garrison:${g.effectKey}`;
const h = this.registry.get(key);
if (!h || !this._garrisonHooks(h, g).includes(hook)) continue;
if (where === 'hand' && g.bbStr && g.bbStr.conditionkey === 'character_target_inboard') continue;
const repeat = hook === 'onGain' ? this.investRepeat(ps) : 1;
this._call(ps, key, h, hook, { kind: 'garrison', key, piece, garrisonId: gid, garrison: g, bb: g.bb || {}, bbStr: g.bbStr || {}, where }, ev, repeat);
}
};
if (hook === 'onGain' || hook === 'onSold') {
if (ev && ev.piece && ev.piece.kind === 'chess') run(ev.piece, hook === 'onGain' ? 'gained' : 'sold');
return;
}
if (hook === 'onPrice') {
if (ev && ev.slot && ev.slot.kind === 'chess') run({ uid: 0, kind: 'chess', id: ev.slot.id, items: [] }, 'shop');
return;
}
if (hook !== 'onRoundStart' && hook !== 'onPrepEnd' && hook !== 'onRefresh') return;
for (const { piece } of boardOrder(ps.board)) if (piece.kind === 'chess') run(piece, 'board');
for (const p of ps.hand) if (p && p.kind === 'chess') run(p, 'hand');
}
/**
* Run the garrisons of `piece` whose eventType is `eventType` as if that event happened now (ctx.triggerGarrisons).
* `asPiece` runs them with another piece as `ctx.source.piece` (特质 "与其相同" copies). 投资人 still multiplies
* SERVER_GAIN. SERVER_PRICE cannot be triggered. Returns the number of garrison handlers run.
*/
triggerGarrisons(ps, piece, eventType, { asPiece = null, triggeredBy = null } = {}) {
const gd = this.m.gd;
const hook = eventType === 'SERVER_PRICE' ? null : GARRISON_HOOK[eventType];
const rec = piece && piece.kind === 'chess' ? gd.chess(piece.id) : null;
if (!hook || !rec || !Array.isArray(rec.garrisonIds) || this.depth >= MAX_DEPTH) return 0;
const self = asPiece || piece;
let n = 0;
this.depth++;
try {
for (const gid of rec.garrisonIds) {
const g = gd.garrison(gid);
if (!g || g.eventType !== eventType) continue;
const key = `garrison:${g.effectKey}`;
const h = this.registry.get(key);
if (!h) continue;
const ev = hook === 'onGain' ? { piece: self, kind: 'chess', source: 'trigger' }
: hook === 'onSold' ? { piece: self, gain: 0 }
: hook === 'onRefresh' ? { slots: ps.shop.slots, free: true, price: 0, trigger: true }
: { round: this.m.round, trigger: true };
const repeat = hook === 'onGain' ? this.investRepeat(ps) : 1;
const loc = ps.find(self.uid);
const where = loc ? (loc.area === 'board' ? 'board' : loc.area) : 'trigger';
this._call(ps, key, h, hook, { kind: 'garrison', key, piece: self, garrisonId: gid, garrison: g, bb: g.bb || {}, bbStr: g.bbStr || {}, where, trigger: true, triggeredBy }, ev, repeat);
n++;
}
} finally {
this.depth--;
}
return n;
}
/** Item-specific hook (onEquip / onArt / onDestroy) for one item piece. */
dispatchItem(ps, item, holder, hook, ev) {
if (!item || this.depth >= MAX_DEPTH) return ev;
const key = `item:${itemKey(item.id)}`;
const h = this.registry.get(key);
if (!h) return ev;
this.depth++;
try {
this._call(ps, key, h, hook, { kind: 'item', key, piece: item, holder, item: this.m.gd.item(item.id) }, ev);
} finally {
this.depth--;
}
return ev;
}
/** Run one registry handler directly (choice cards). Returns true when a handler existed. */
runKey(ps, key, hook, source, ev) {
const h = this.registry.get(key);
if (!h) return false;
const fn = typeof h[hook] === 'function' ? h[hook] : typeof h.run === 'function' ? h.run : null;
if (!fn) return false;
if (this.depth >= MAX_DEPTH) return true;
this.depth++;
try {
const ctx = makeCtx(this.m, ps, { ...source, key }, hook, ev);
try { fn.call(h, ctx, ev); } catch (e) { this._report(key, hook, e); }
} finally {
this.depth--;
}
return true;
}
}
function ownedChess(ps) {
const out = [];
for (const { piece } of boardOrder(ps.board)) if (piece.kind === 'chess') out.push(piece);
for (const p of ps.hand) if (p && p.kind === 'chess') out.push(p);
for (const p of ps.temp) if (p && p.kind === 'chess') out.push(p);
return out;
}
/** Dispatch step 5: is `it` still equipped on `holder`, and `holder` still an owned chess of `ps`? */
function stillEquipped(ps, holder, it) {
if (!Array.isArray(holder.items) || !holder.items.includes(it)) return false;
const loc = ps.find(holder.uid);
return !!loc && loc.piece === holder;
}
// =====================================================================================================
// handler context — the ONLY way content mutates player state (see docs/META.md)
const finiteInt = (n) => (Number.isFinite(n) ? Math.trunc(n) : 0);
/**
* What a pick-one offer made by an effect is called on the shop bar (m.private shop.rewardOffer.label; player report #6
* after 0.1.0 — 凯瑟琳's three items came under the promotion reward's 晋升奖励 header): a strategy's effect name
* (定向投放, 见者有份), an item's name (寻呼模块, 信标), the operator whose 特质 it is (松果), else null.
* @param {import('./gamedata.js').GameData} gd
* @param {object} source the dispatch source (kind band / item / garrison …)
* @returns {string|null}
*/
export function offerLabel(gd, source) {
if (!source || typeof source !== 'object') return null;
if (source.kind === 'band') { const b = source.band || gd.band(source.bandId); return (b && (b.effectName || b.name)) || null; }
if (source.kind === 'item') { const it = source.item || (source.piece ? gd.item(source.piece.id) : null); return (it && it.name) || null; }
if (source.kind === 'garrison' && source.piece) { const c = gd.chess(source.piece.id); return (c && c.name) || null; }
return null;
}
/**
* @param {import('./Match.js').Match} m
* @param {import('./PlayerState.js').PlayerState} ps
* @param {object} source
* @param {string} hook
* @param {object|null} [ev] the event being dispatched (onPrice: ctx.modifyPrice / ctx.setPrice edit ev.price)
*/
export function makeCtx(m, ps, source, hook, ev = null) {
const gd = m.gd;
const view = (p) => (p ? ps.pieceView(p) : null);
const ctx = {
hook,
source,
playerId: ps.playerId,
seat: ps.seat,
name: ps.name,
get round() { return m.round; },
get phase() { return m.phase; },
modeId: m.modeId,
difficulty: m.difficulty,
isSolo: m.isSolo,
isCoop: !m.isSolo,
data: gd.raw,
gd,
rng: m.rngMeta,
log: (msg) => m.log.info?.(`[meta ${m.roomCode}] ${ps.playerId}: ${msg}`),
// ---- reads
funds: () => ps.funds,
lp: () => ps.lp,
alive: () => ps.alive,
bandId: () => ps.bandId,
shopLevel: () => ps.shop.level,
bond: (id) => (ps.bonds[id] ? { ...ps.bonds[id] } : null),
bonds: () => Object.fromEntries(Object.entries(ps.bonds).map(([k, v]) => [k, { ...v }])),
bondActive: (id) => !!(ps.bonds[id] && ps.bonds[id].active),
bondCount: (id) => (ps.bonds[id] ? ps.bonds[id].count : 0),
layers: (id) => ps.layers[id] || 0,
board: () => boardOrder(ps.board).map(({ r, c, piece }) => ps.pieceView(piece, [r, c])),
hand: () => ps.hand.map(view),
temp: () => ps.temp.map(view),
/** View of an owned piece + `area` ('board'|'hand'|'temp'|'equipped'), `holderUid`, and `row`/`col` on the board. */
piece: (uid) => {
const l = ps.find(uid);
if (!l) return null;
const v = { ...(l.area === 'board' ? ps.pieceView(l.piece, parseKey(l.key)) : view(l.piece)), area: l.area, holderUid: l.holder ? l.holder.uid : null };
if (l.area === 'hand' || l.area === 'temp') v.idx = l.idx;
return v;
},
/**
* Board piece standing on (row, col) (row/col included, and its `dir`), or null. "身前一格" of a piece is one step
* along its facing: sim/dir.js frontOf(row, col, piece.dir) (content/support/meta.js frontPiece / behindPiece).
*/
pieceAt: (row, col) => { const p = Number.isInteger(row) && Number.isInteger(col) ? ps.board.get(tileKey(row, col)) : null; return p ? ps.pieceView(p, [row, col]) : null; },
chessRecord: (id) => gd.chess(id),
/** Bonds a chess piece counts for: its own + those granted by 变形同构体 pairings. */
pieceBonds: (uid) => { const l = ps.find(uid); return l && l.piece.kind === 'chess' ? bondsOfPiece(gd, l.piece) : []; },
/** 特质 of a chess piece: [{ garrisonId, effectKey, eventType, bb, bbStr, desc }]. */
garrisonsOf: (uid) => {
const l = ps.find(uid);
const rec = l && l.piece.kind === 'chess' ? gd.chess(l.piece.id) : null;
if (!rec || !Array.isArray(rec.garrisonIds)) return [];
return rec.garrisonIds.map((gid) => gd.garrison(gid)).filter(Boolean)
.map((g) => ({ garrisonId: g.garrisonId, effectKey: g.effectKey, eventType: g.eventType, bb: g.bb || {}, bbStr: g.bbStr || {}, desc: g.desc || '' }));
},
stats: () => ({ ...ps.stats }),
roundStats: () => ({ ...ps.round }),
shopSlots: () => ps.shop.slots.map((s) => (s ? { kind: s.kind, id: s.id, basePrice: s.basePrice, sold: !!s.sold, frozen: !!s.frozen } : null)),
// ---- counters (player scope, persistent)
counter: (k) => (Number.isFinite(ps.counters[k]) ? ps.counters[k] : 0),
setCounter: (k, v) => { if (typeof k === 'string' && Number.isFinite(v)) ps.counters[k] = v; return ps.counters[k] ?? 0; },
incCounter: (k, n = 1) => { if (typeof k !== 'string' || !Number.isFinite(n)) return 0; ps.counters[k] = (Number.isFinite(ps.counters[k]) ? ps.counters[k] : 0) + n; return ps.counters[k]; },
/** Per-piece counter of the current round (0 in a new round / for a new piece; an elite merged this round keeps the
* highest of its copies' — PlayerState.pieceRoundCount). */
pieceCounter: (uid, k) => { const l = ps.find(uid); return l ? ps.pieceRoundCount(l.piece, k) : 0; },
incPieceCounter: (uid, k, n = 1) => { const l = ps.find(uid); return l ? ps.bumpPieceRoundCount(l.piece, k, n) : 0; },
// ---- economy
addFunds: (n, reason = '') => ps.addFunds(finiteInt(n), { reason }),
addPendingFunds: (n) => { const v = finiteInt(n); if (v > 0) { ps.pendingFunds += v; ps.dirty(); } return v > 0 ? v : 0; },
spendFunds: (n) => { const v = finiteInt(n); if (!ps.spend(v)) return false; ps._afterSpend(v, 'effect'); return true; },
grantFreeRefresh: (n = 1) => { const v = finiteInt(n); if (v > 0) { ps.shop.freeRefreshes += v; ps.dirty(); } return ps.shop.freeRefreshes; },
// ---- prices (onPrice only; the same as writing ev.price)
modifyPrice: (delta) => { if (hook === 'onPrice' && ev && Number.isFinite(delta)) ev.price = Math.max(0, (Number(ev.price) || 0) + delta); return ev ? ev.price : null; },
setPrice: (v) => { if (hook === 'onPrice' && ev && Number.isFinite(v)) ev.price = Math.max(0, v); return ev ? ev.price : null; },
// ---- layers
addLayers: (bondId, n, opts = {}) => ps.addLayers(bondId, n, { requireActive: !!opts.requireActive, reason: opts.reason || source.key || '' }),
// ---- pieces
grantChess: (chessId, opts = {}) => {
if (!ps.alive) return null;
let id = chessId;
if (opts.golden) id = gd.goldenIdOf(chessId) || chessId;
if (!gd.chess(id)) return null;
const base = gd.baseIdOf(id);
// "some effects fail when the cap is hit" (research 06 §7): by default a chess of the pool needs a free copy
if (opts.requirePool !== false && m.pool.has(base) && m.pool.left(base) < 1) return null;
const p = ps.acquireChess(id, { source: opts.source || source.key || 'effect', toTemp: !!opts.toTemp, fromPool: opts.fromPool !== false });
return p ? view(p) : null;
},
grantItem: (itemId, opts = {}) => {
if (!ps.alive || !gd.item(itemId)) return null;
const p = ps.acquireItem(itemId, { source: opts.source || source.key || 'effect', toTemp: !!opts.toTemp });
return p ? view(p) : null;
},
/** Random chess id from the shared pool (copy-weighted). opts: { maxTier, tier, bond, filter(id) } */
rollChess: (opts = {}) => {
const f = (id) => {
if (opts.bond) { const c = gd.chess(id); if (!c || !Array.isArray(c.bonds) || !c.bonds.includes(opts.bond)) return false; }
return typeof opts.filter === 'function' ? !!opts.filter(id) : true;
};
return m.pool.roll(m.rngMeta, { maxTier: Number.isInteger(opts.maxTier) ? opts.maxTier : 6, tier: Number.isInteger(opts.tier) ? opts.tier : null, filter: f });
},
rollItem: (opts = {}) => m.rollItemId(opts),
/**
* Roll a choices.json pool: equip pools → { kind: 'item', id }; chess pools (items / weighted / shopEligible with
* tier, minTier, bond, golden) → { kind: 'chess', id, golden } (a pool chess needs a free copy). null when empty.
*/
rollPool: (poolId, opts = {}) => m.rollPool(poolId, { shopLevel: ps.shop.level, ...opts }),
/**
* Run another owned chess's 特质 of `eventType` now (SERVER_GAIN / SERVER_PREP_START / SERVER_PREP_FIN /
* SERVER_CHESS_SOLD / SERVER_REFRESH_SHOP). opts.asUid: run them as if they belonged to that piece. Returns the
* number of handlers run (nesting is depth-capped).
*/
triggerGarrisons: (uid, eventType, opts = {}) => {
const l = ps.find(uid);
if (!l || l.piece.kind !== 'chess' || typeof eventType !== 'string') return 0;
const as = opts.asUid != null ? ps.find(opts.asUid) : null;
if (opts.asUid != null && (!as || as.piece.kind !== 'chess')) return 0;
return m.dispatcher.triggerGarrisons(ps, l.piece, eventType, { asPiece: as ? as.piece : null, triggeredBy: source.key || null });
},
promote: (uid) => { const l = ps.find(uid); return l ? ps.promote(l.piece) : false; },
/** 突变细胞's transformation: destroy a chess piece wherever it stands (its equipment returns to the hand first), then
* gain `chessId` like any gained operator — hand, overflow temp, a completed merge as usual
* (PlayerState.transformChess). Returns the gained piece's view (the elite after a merge) or null. */
transform: (uid, chessId) => { const l = ps.find(uid); if (!l || l.piece.kind !== 'chess') return null; const p = ps.transformChess(l.piece, chessId); return p ? view(p) : null; },
/** Normal item piece → its golden version in place. */
upgradeItem: (uid) => { const l = ps.find(uid); return l && l.piece.kind === 'item' ? ps.upgradeItem(l.piece) : false; },
/** Attach an owned hand/temp item to an owned chess without equip effects (copies, restores). */
equipDirect: (itemUid, chessUid) => {
const il = ps.find(itemUid);
const cl = ps.find(chessUid);
if (!il || il.piece.kind !== 'item' || il.area === 'equipped' || !cl || cl.piece.kind !== 'chess' || cl.area === 'equipped') return false;
ps._detach(il);
ps._attach(cl.piece, il.piece);
ps.checkItemMerges();
ps.recompute();
return true;
},
destroyPiece: (uid) => {
const l = ps.find(uid);
if (!l) return false;
ps._detach(l);
if (l.piece.kind === 'chess') {
ps.removeTokensOf(l.piece.uid);
for (const it of l.piece.items || []) ps.stow(it, { allowTemp: true });
l.piece.items = [];
ps.returnCopies(l.piece);
ps.checkItemMerges(); // the returned equipment auto-merges like any gain
}
ps.recompute();
return true;
},
/** Queue a free pick-one offer of chess (shop.rewardOffer; `label` defaults to offerLabel(source), the bar's header). */
offerChess: (ids, opts = {}) => !!ps.pushRewardOffer(opts.source || source.key || 'effect', { ids, tier: opts.tier ?? null, label: opts.label ?? offerLabel(gd, source) }),
/** Queue a free pick-one offer of items (shop.rewardOffer with slots of kind 'item'). */
offerItems: (ids, opts = {}) => !!ps.pushItemOffer(ids, { source: opts.source || source.key || 'effect', tier: opts.tier ?? null, label: opts.label ?? offerLabel(gd, source) }),
setShopSlot: (i, slot) => {
if (!Number.isInteger(i) || i < 0 || i >= ps.shop.slots.length) return false;
if (slot == null) { ps.shop.slots[i] = null; ps.dirty(); return true; }
const kind = slot.kind === 'item' ? 'item' : 'chess';
if (kind === 'chess' ? !gd.chess(slot.id) : !gd.item(slot.id)) return false;
const basePrice = Number.isFinite(slot.price) ? Math.max(0, Math.trunc(slot.price)) : kind === 'chess' ? gd.chessPrice(slot.id) : gd.itemPrice(slot.id);
ps.shop.slots[i] = { kind, id: slot.id, basePrice, frozen: !!slot.frozen, sold: false };
ps.dirty();
return true;
},
addDeployCap: (n) => { const v = finiteInt(n); ps.deployCapBonus += v; ps.dirty(); return ps.deployCap; },
setDeployCapAtLeast: (n) => { const v = finiteInt(n); if (v > ps.deployCapMin) { ps.deployCapMin = Math.min(v, 12); ps.dirty(); } return ps.deployCap; },
setBondCountBonus: (bondId, n) => { if (gd.bond(bondId) && Number.isInteger(n)) { ps.bondCountBonus[bondId] = n; ps.recompute(); } },
setDeviceActive: (alias, on) => { if (typeof alias === 'string') { ps.deviceOverrides[alias] = !!on; ps.invalidateDeployMap(); ps.dirty(); } },
setTileOverride: (r, c, cls) => { if (Number.isInteger(r) && Number.isInteger(c) && ['melee', 'ranged', 'none'].includes(cls)) { ps.tileOverrides[`${r},${c}`] = cls; ps.invalidateDeployMap(); ps.dirty(); } },
// ---- effects (EffectRef: { id, key?, name, desc, iconKind, iconId, counter?, battle?, params?, data?, hidden? })
addEffect: (ref) => {
if (!ref || typeof ref.id !== 'string') return null;
const existing = ps.effects.find((e) => e.id === ref.id);
if (existing) { Object.assign(existing, ref); ps.dirty(); return existing; }
const e = { iconKind: 'choice', battle: true, ...ref };
ps.effects.push(e);
ps.dirty();
return e;
},
removeEffect: (id) => { const i = ps.effects.findIndex((e) => e.id === id); if (i < 0) return false; ps.effects.splice(i, 1); ps.dirty(); return true; },
effect: (id) => ps.effects.find((e) => e.id === id) ?? null,
setEffectCounter: (id, v) => { const e = ps.effects.find((x) => x.id === id); if (!e) return false; e.counter = v; ps.dirty(); return true; },
// ---- bounties / choices
addBounty: (card) => m.addBounty(ps, card),
// ---- messaging
toast: (text, kind = 'info') => m.toast(ps, kind, String(text)),
ticker: (text) => m.tickerText(String(text)),
/** CHAR_GIFT broadcast to this player: "{0}博士给你赠送了{1}". */
giftTicker: (fromName, chessId) => { const c = gd.chess(chessId); m.tickerFor('CHAR_GIFT', [String(fromName), c ? c.name : String(chessId)], { to: ps.playerId }); },
// ---- team
teammates: () => m.alivePlayers().filter((p) => p !== ps).map((p) => makeCtx(m, p, source, hook, null)),
player: (playerId) => { const p = m.players.get(playerId); return p && p.alive ? makeCtx(m, p, source, hook, null) : null; },
};
return ctx;
}