Files
ark-stronghold-protocol/server/match/gamedata.js
T
sganggs d4f868cd19 Playtest #4 fixes: tile picking, knocked-down state, element gauges, draft timer, live stats, shop items, drones, 赫默, 炎佑, UI flash
- #1 picking by the tile under the pointer (prep, battle, pen, equipment drops); the dragged model stays
  under the pointer (mouse and touch); pixel probe removed; phone ghost-click on the underframe fixed
- #2 two-tap confirm on 机变 cards (like the shop)
- #3 a 同盟 room with one human is untimed like 独立模拟 (no countdown outside battles)
- #4 strategy draft: one synced countdown, 30 s per human turn, timeout takes the highlighted band
- #5 special 维式重锤 (4) and 突变细胞 no longer sold; item card shows where they come from
- #6 operator skill-mode sounds no longer play on normal attacks (纯烬艾雅法拉 and 55 others)
- #7 detail card shows live battle stats and the prep-time battle-start stats (g.unitStats)
- #8 official element gauges on units; burst cooldown (阻回) per PRTS
- #9 knocked-down operators stay on their tile with a redeploy countdown ring
- #10 drone behaviour per PRTS (暴鸰, 先兆者 shells, 黑云, 萨科塔之眼)
- #11 赫默's drone only from her skill; placeable summons limited to talent tokens
- #12 炎佑 stays where it is with no enemies; 祛恶之焰 as a continuous channel
- #13 the occasional giant bond icon (loading texture scaled ×46) fixed
- docs (DESIGN §18), recordings, data and tests updated (2467 unit tests, all browser suites green)
2026-09-30 05:35:45 +08:00

460 lines
23 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/gamedata.js — typed, defaulted view of data/*.json for the match engine.
//
// Every lookup is an own-property lookup (ids come from client intents) that never throws and returns null for
// unknown ids. Tunables come from data/config.json with the documented defaults (research 00-INDEX §2–§8) when a
// key is missing, so a partial data set (tests, data being regenerated) still yields a working match.
//
// No custom balance (DESIGN §14 corrections, research 08 §6): enemy numbers are the official ones — the PRTS
// per-round enemyScale table of data/config.json, the leader pool = bloodPoint. data/tuning.json only overrides result
// titles:
// titles[titleId] { stat?, rule? } merged over config.titles
// (the former enemyHpMul / enemyAtkMul / enemySpeedMul / bossHpMul / flyPlaceholders knobs were removed; a tuning file
// that still carries them is ignored).
import { getConfig, getMode } from '../data.js';
import { isShopItem } from '../sim/simdata.js';
const own = (map, id) => (map && typeof map === 'object' && typeof id === 'string' && Object.hasOwn(map, id) && map[id] && typeof map[id] === 'object' ? map[id] : null);
const numOr = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
const posIntOr = (v, d) => (Number.isInteger(v) && v > 0 ? v : d);
export const DEFAULTS = Object.freeze({
income: [0, 4, 5, 6, 7, 8, 9, 10, 11, 12, 12, 12, 12, 12, 12, 12],
incomeCap: 12,
chessPrice: { 1: 2, 2: 3, 3: 3, 4: 3, 5: 4, 6: 4 },
sellPrice: 1,
refreshPrice: 1,
poolCopies: { 1: 12, 2: 14, 3: 18, 4: 16, 5: 8, 6: 5 },
mergeCount: 3,
goldenCopies: 3,
itemMergeCount: 2,
benchSize: 10,
tempSize: 5,
deployCap: 8,
equipPerChess: 2,
maxArtsPerRound: 2,
rewardOffer: { count: 3, tierOffset: 1, maxTier: 6, price: 0 },
upgradePrices: [5, 8, 11, 12, 13],
maxShopLevel: 6,
shopSlots: { 1: { chess: 3, item: 1 }, 2: { chess: 4, item: 1 }, 3: { chess: 4, item: 1 }, 4: { chess: 5, item: 1 }, 5: { chess: 5, item: 1 }, 6: { chess: 5, item: 1 } },
defaultBandId: 'band_bldsk',
defaultStartLp: 28,
lpCapPerRound: 10,
bossOvertimeAfter: 150,
bossOvertimeDrainPerSec: 1,
hiddenCore: { single: 350, multi: 1200, minTeamLpExclusive: 1, difficulties: ['NORMAL', 'HARD', 'ABYSS'] },
dp: { init: 10, perSec: 1, max: 99 },
unite: { maxHelpers: 2, templates: { 1: 'act1autochess_escaped_single', 2: 'act1autochess_escaped_multi' } },
timers: { infoCheck: 25, bandDraft: 50, bandTurn: 30, battleCheck: 3, spFirst: 30, spTurn: 16 },
bans: { FUNNY: { core: 0, addon: 1 }, NORMAL: { core: 3, addon: 4 }, HARD: { core: 3, addon: 4 }, ABYSS: { core: 3, addon: 4 } },
bandDraft: { skipsPerPlayer: 1, timeoutBandId: 'band_bldsk' },
leftoverFundsKeptByBands: ['band_cannot'],
});
/** Game seconds per real second of a battle (forced 2×): combat limits in data are real seconds (combatTimeLimit). */
export const COMBAT_TIME_SCALE = 2;
/** Strip the _a/_b suffix of an item id (the registry key of an item family). */
export const itemKey = (id) => (typeof id === 'string' ? id.replace(/_[ab]$/, '') : '');
export class GameData {
/**
* @param {Readonly<Record<string, any>>} data server/data.js getData() (may be partial)
* @param {string} modeId e.g. 'mode_multi_hard'
*/
constructor(data, modeId) {
this.raw = data && typeof data === 'object' ? data : {};
this.config = getConfig(this.raw) || {};
this.modeId = modeId;
this.mode = getMode(modeId, this.raw) || {};
this.economy = this.config.economy && typeof this.config.economy === 'object' ? this.config.economy : {};
const chess = this.raw.chess && typeof this.raw.chess === 'object' ? this.raw.chess : {};
this._chess = chess;
this._items = this.raw.items && typeof this.raw.items === 'object' ? this.raw.items : {};
this._bonds = this.raw.bonds && typeof this.raw.bonds === 'object' ? this.raw.bonds : {};
/** visible, shop-eligible base (normal) chess ids */
this.visibleChess = Object.keys(chess).filter((id) => {
const c = chess[id];
return c && c.visible && !c.isGolden && !c.isDiy && !c.isHidden && Number.isInteger(c.tier);
}).sort();
/**
* Shop item ids by tier (sim/simdata.js isShopItem: normal EQUIP, not hidden, not effect-only — the special
* 维式重锤 and 突变细胞 are never sold). Every "shop item" draw uses it: the shop item slot (pool.js), the 道具补给 /
* 机密商店 cards (choices.js) and the shop-eligible item pools (Match.rollItemId).
*/
this.shopItemsByTier = {};
for (const [id, it] of Object.entries(this._items)) {
if (!isShopItem(it)) continue;
(this.shopItemsByTier[it.tier] ||= []).push(id);
}
for (const k of Object.keys(this.shopItemsByTier)) this.shopItemsByTier[k].sort();
this.bondIds = Object.keys(this._bonds).sort((a, b) => (numOr(this._bonds[a].identifier, 99) - numOr(this._bonds[b].identifier, 99)) || (a < b ? -1 : 1));
this.modeInactiveBonds = new Set(Array.isArray(this.mode.inactiveBondIds) ? this.mode.inactiveBondIds : []);
this.inactiveEnemies = new Set(Array.isArray(this.mode.inactiveEnemyKeys) ? this.mode.inactiveEnemyKeys : []);
/** data/tuning.json (titles only, see the header) */
this.tuning = this.raw.tuning && typeof this.raw.tuning === 'object' ? this.raw.tuning : {};
}
/**
* Leader HP pool multiplier — always 1 (no custom balance). Kept for callers written against the old tuning layer
* (finalAssault.bossPoolHp); use `bossPoolHp` for the official pool.
* @deprecated
*/
bossHpMul(bossId) { // eslint-disable-line no-unused-vars
return 1;
}
/**
* Official shared leader HP pool (research 08 §6 #7): co-op = bloodPoint[difficulty] as is — split leaders share one
* pool, "敌方领袖的总生命值不变", no alive-player scaling; solo = bloodPoint × config bossHpScale.solo (0.25,
* [ASSUMED], flagged in data). Leaders are never scaled by enemyScale.
* @param {string} bossId
* @returns {number}
*/
bossPoolHp(bossId) {
const boss = this.boss(bossId);
const diff = this.difficulty;
let base = boss && boss.bloodPoint && Number.isFinite(boss.bloodPoint[diff]) ? boss.bloodPoint[diff] : null;
if (base == null && boss && boss.bloodPoint) base = Object.values(boss.bloodPoint).find((v) => Number.isFinite(v)) ?? null;
if (base == null) base = 500000;
const ms = this.mode.bossHpScale && typeof this.mode.bossHpScale === 'object' ? this.mode.bossHpScale : {};
const cs = this.config.bossHpScale && typeof this.config.bossHpScale === 'object' ? this.config.bossHpScale : {};
const pick = (k, d) => (Number.isFinite(ms[k]) && ms[k] > 0 ? ms[k] : Number.isFinite(cs[k]) && cs[k] > 0 ? cs[k] : d);
const mul = this.isSolo ? pick('solo', 0.25) : pick('coop', 1);
return Math.max(1, Math.round(base * mul));
}
/** config.titles with the tuning overrides (stat / rule per title id) merged in. */
get titles() {
const list = Array.isArray(this.config.titles) ? this.config.titles : [];
const ov = this.tuning.titles && typeof this.tuning.titles === 'object' ? this.tuning.titles : {};
return list.map((t) => {
if (!t || typeof t.id !== 'string' || !Object.hasOwn(ov, t.id) || !ov[t.id] || typeof ov[t.id] !== 'object') return t;
const o = ov[t.id];
const out = { ...t };
if (typeof o.stat === 'string') out.stat = o.stat;
if (o.rule === 'max' || o.rule === 'min') out.rule = o.rule;
return out;
});
}
// ---- ids ------------------------------------------------------------------------------------------
chess(id) { return own(this._chess, id); }
item(id) { return own(this._items, id); }
bond(id) { return own(this._bonds, id); }
band(id) { return own(this.raw.bands, id); }
garrison(id) { return own(this.raw.garrisons, id); }
effect(id) { return own(this.raw.effects, id); }
enemy(key) { return own(this.raw.enemies, key); }
wave(id) { return own(this.raw.waves, id); }
stage(id) { return own(this.raw.stages, id); }
boss(id) { return own(this.raw.bosses, id); }
token(id) { return own(this.raw.tokens, id); }
get choices() { return this.raw.choices && typeof this.raw.choices === 'object' ? this.raw.choices : {}; }
get factions() { return this.raw.factions && typeof this.raw.factions === 'object' ? this.raw.factions : {}; }
/** Normal (base) chess id of a chess id (golden → base). */
baseIdOf(id) {
const c = this.chess(id);
if (!c) return typeof id === 'string' ? id.replace(/_b$/, '_a') : null;
return c.baseId || (c.isGolden ? id.replace(/_b$/, '_a') : id);
}
goldenIdOf(id) {
const c = this.chess(this.baseIdOf(id));
if (c && c.goldenId && this.chess(c.goldenId)) return c.goldenId;
const alt = typeof id === 'string' ? id.replace(/_a$/, '_b') : null;
return alt && this.chess(alt) ? alt : null;
}
isGolden(id) { const c = this.chess(id) || this.item(id); return !!(c && c.isGolden); }
tierOf(id) {
const c = this.chess(id) || this.item(id);
return c && Number.isInteger(c.tier) ? c.tier : 1;
}
// ---- economy --------------------------------------------------------------------------------------
income(round) {
const arr = Array.isArray(this.economy.income) ? this.economy.income : DEFAULTS.income;
const cap = numOr(this.economy.incomeCap, DEFAULTS.incomeCap);
const v = arr[round];
if (typeof v === 'number' && Number.isFinite(v) && v >= 0) return v;
return Math.max(0, Math.min(cap, 3 + round));
}
chessPrice(id) {
const c = this.chess(id);
if (c && Number.isFinite(c.price) && c.price >= 0) return c.price;
const tier = this.tierOf(id);
const row = this.economy.chessPrice && this.economy.chessPrice[tier];
const golden = c && c.isGolden;
if (row && typeof row === 'object') return numOr(golden ? row.golden : row.normal, DEFAULTS.chessPrice[tier] ?? 3);
return DEFAULTS.chessPrice[tier] ?? 3;
}
sellPrice(id) {
const c = this.chess(id);
if (c && Number.isFinite(c.sellPrice) && c.sellPrice >= 0) return c.sellPrice;
const tier = this.tierOf(id);
const row = this.economy.chessSell && this.economy.chessSell[tier];
if (row && typeof row === 'object') return numOr(c && c.isGolden ? row.golden : row.normal, DEFAULTS.sellPrice);
return DEFAULTS.sellPrice;
}
itemPrice(id) {
const it = this.item(id);
return it && Number.isFinite(it.price) && it.price >= 0 ? it.price : 2;
}
get refreshPrice() { return Math.max(0, numOr(this.economy.refreshPrice, DEFAULTS.refreshPrice)); }
get benchSize() { return posIntOr(this.economy.benchSize, DEFAULTS.benchSize); }
get tempSize() { return posIntOr(this.economy.tempSize, DEFAULTS.tempSize); }
get deployCap() { return posIntOr(this.economy.deployCap, DEFAULTS.deployCap); }
get equipPerChess() { return posIntOr(this.economy.equipPerChess, DEFAULTS.equipPerChess); }
get maxArtsPerRound() { return posIntOr(this.economy.maxArtsPerRound, DEFAULTS.maxArtsPerRound); }
get goldenCopies() { return posIntOr(this.economy.goldenCopies, DEFAULTS.goldenCopies); }
get itemMergeCount() { return posIntOr(this.economy.itemMergeCount, DEFAULTS.itemMergeCount); }
get leftoverKeptBands() { return Array.isArray(this.economy.leftoverFundsKeptByBands) ? this.economy.leftoverFundsKeptByBands : DEFAULTS.leftoverFundsKeptByBands; }
get defaultBandId() { return typeof this.economy.defaultBandId === 'string' ? this.economy.defaultBandId : DEFAULTS.defaultBandId; }
get defaultStartLp() { return posIntOr(this.economy.defaultStartLp, DEFAULTS.defaultStartLp); }
rewardOffer() {
const r = this.economy.rewardOffer && typeof this.economy.rewardOffer === 'object' ? this.economy.rewardOffer : {};
return {
count: posIntOr(r.count, DEFAULTS.rewardOffer.count),
tierOffset: Number.isInteger(r.tierOffset) ? r.tierOffset : DEFAULTS.rewardOffer.tierOffset,
maxTier: posIntOr(r.maxTier, DEFAULTS.rewardOffer.maxTier),
price: Math.max(0, numOr(r.price, 0)),
};
}
/** Copies of a base chess in the shared pool. */
poolCopies(baseId) {
const ov = this.economy.poolCopiesOverrides;
if (ov && typeof ov === 'object' && Number.isInteger(ov[baseId]) && ov[baseId] >= 0) return ov[baseId];
const tier = this.tierOf(baseId);
const pc = this.economy.poolCopies;
const v = pc && typeof pc === 'object' ? pc[tier] : undefined;
return Number.isInteger(v) && v >= 0 ? v : (DEFAULTS.poolCopies[tier] ?? 10);
}
/** Copies needed to merge (0 = never merges: golden chess). */
mergeCount(id) {
const c = this.chess(id);
if (!c || c.isGolden) return 0;
if (Number.isInteger(c.upgradeNum) && c.upgradeNum > 0) return c.upgradeNum;
const ov = this.economy.mergeCountOverrides;
if (ov && Number.isInteger(ov[id])) return ov[id];
return posIntOr(this.economy.mergeCount, DEFAULTS.mergeCount);
}
// ---- mode -----------------------------------------------------------------------------------------
get isSolo() { return this.mode.type === 'SINGLE' || /^mode_single_/.test(this.modeId || ''); }
get difficulty() { return this.mode.difficulty || (this.modeId ? String(this.modeId).split('_').pop().toUpperCase() : 'NORMAL'); }
get lastRound() {
if (Number.isInteger(this.mode.lastRound) && this.mode.lastRound > 0) return this.mode.lastRound;
return this.modeId === 'mode_single_funny' ? 9 : 14;
}
get bossRound() { return Number.isInteger(this.mode.bossRound) && this.mode.bossRound > 0 ? this.mode.bossRound : this.lastRound; }
get hiddenRound() { return Number.isInteger(this.mode.hiddenRound) && this.mode.hiddenRound > 0 ? this.mode.hiddenRound : null; }
get maxShopLevel() { return posIntOr(this.mode.maxShopLevel, DEFAULTS.maxShopLevel); }
roundCfg(r) {
const rounds = this.mode.rounds;
return rounds && typeof rounds === 'object' && rounds[String(r)] && typeof rounds[String(r)] === 'object' ? rounds[String(r)] : null;
}
spRounds() { return Array.isArray(this.mode.spRounds) ? this.mode.spRounds.filter((n) => Number.isInteger(n)) : []; }
upgradePrices() {
const arr = Array.isArray(this.mode.upgradePrices) ? this.mode.upgradePrices : DEFAULTS.upgradePrices;
return arr.map((v) => Math.max(0, numOr(v, 99)));
}
/** Base price to go from `level` to level+1 (null at max). */
upgradeBase(level) {
if (level >= this.maxShopLevel) return null;
const arr = this.upgradePrices();
return arr[level - 1] ?? 99;
}
shopSlots(level) {
const s = this.mode.shopSlots && this.mode.shopSlots[String(level)];
const d = DEFAULTS.shopSlots[level] || DEFAULTS.shopSlots[6];
if (!s || typeof s !== 'object') return { ...d };
const chess = Number.isInteger(s.chess) && s.chess >= 0 ? s.chess : d.chess;
const item = Number.isInteger(s.item) && s.item >= 0 ? s.item : d.item;
return { chess: Math.min(chess, 8), item: Math.min(item, 4) };
}
/** Real-second prep timer for round r (null = untimed). */
prepTime(r) {
const rc = this.roundCfg(r);
if (!rc) return this.isSolo ? null : 90;
const v = rc.prepTime;
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null;
}
/** The round level's `maxPlayTime` (config rounds[r].combatTimeLimit) as data gives it — REAL seconds. */
combatTimeLimitReal(r) {
const rc = this.roundCfg(r);
const v = rc ? rc.combatTimeLimit : undefined;
if (typeof v === 'number' && Number.isFinite(v) && v > 0) return v;
const m = this.mode.combatTimeLimit && this.mode.combatTimeLimit[String(r)];
return typeof m === 'number' && Number.isFinite(m) && m > 0 ? m : 60;
}
/**
* Combat time limit of round r in GAME seconds (what the Battle and 联防 use): maxPlayTime × the forced battle speed
* (config.combatTimeScale, default COMBAT_TIME_SCALE 2). `maxPlayTime` counts real seconds of the 2× battle: read
* as game seconds, the rounds' own spawn schedules would not fit (R2 spawns its last flyer at 43 s of a 45 s limit,
* R3 at 62 s of 55 s — enemies that can never be killed, or never spawn), while × 2 every limit is ≈ the last spawn +
* one flyer crossing (R2 43 + 44 ≈ 90, R3 62 + 44 ≈ 110, R5 38 + 67 ≈ 110). docs/BALANCE.md §2.1.
*/
combatTimeLimit(r) {
return this.combatTimeLimitReal(r) * this.combatTimeScale;
}
/** Game seconds per real second of a battle (config.combatTimeScale, default COMBAT_TIME_SCALE 2). */
get combatTimeScale() {
const k = numOr(this.config.combatTimeScale, COMBAT_TIME_SCALE);
return k > 0 ? k : COMBAT_TIME_SCALE;
}
/**
* The boss round level's `maxPlayTime` (config rounds[r].levelMaxPlayTime, 120) in REAL seconds — the countdown of
* the Final Assault / Hidden Core. It is not a hard stop there ("计时结束后战斗仍然会继续", research 01 §10); null
* when the data has none.
*/
bossLevelTime(r) {
const rc = this.roundCfg(r);
const v = rc ? rc.levelMaxPlayTime : undefined;
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null;
}
/** Official enemy multipliers of round r (config enemyScale: the PRTS table + 终极 speed ×1.15 from R3). */
baseEnemyScale(r) {
const e = this.mode.enemyScale && this.mode.enemyScale[String(r)];
if (!e || typeof e !== 'object') return { hpMul: 1, atkMul: 1, speedMul: 1 };
return {
hpMul: Math.max(0.01, numOr(e.hp, 1)),
atkMul: Math.max(0, numOr(e.atk, 1)),
speedMul: Math.max(0.01, numOr(e.speed, 1)),
};
}
/** Enemy multipliers of round r = the official table (baseEnemyScale; no custom multiplier). */
enemyScale(r) {
return this.baseEnemyScale(r);
}
timer(key) {
const t = this.config.timers && this.config.timers[key];
return typeof t === 'number' && Number.isFinite(t) && t > 0 ? t : DEFAULTS.timers[key] ?? 10;
}
get lpCapPerRound() { return posIntOr(this.config.lpCapPerRound, DEFAULTS.lpCapPerRound); }
/**
* Boss overtime (`bossTurnHpReduceTime` 150 / 1 LP per second): a server turn timer of turnInfoDataDict like
* prepPhaseTime, so REAL seconds on the same clock as the boss level's 120 s maxPlayTime (combat limits are real
* seconds, docs/BALANCE.md §2.1) — the level countdown runs out first, the battle continues, and the merged team LP
* drains 1 per real second from the 150 s mark (research 01 §10, 06 §11.7). Read as game seconds the drain would
* start at 75 real s (45 s before the countdown ends) at 2 LP per real second.
*/
get bossOvertimeAfterReal() { return Math.max(0, numOr(this.config.bossOvertimeAfter, DEFAULTS.bossOvertimeAfter)); }
/** Team LP drained per REAL second of overtime. */
get bossOvertimeDrainReal() { return Math.max(0, numOr(this.config.bossOvertimeDrainPerSec, DEFAULTS.bossOvertimeDrainPerSec)); }
/** Overtime start in GAME seconds of a boss field clock (150 real s × the forced 2× = 300). */
get bossOvertimeAfter() { return this.bossOvertimeAfterReal * this.combatTimeScale; }
/** Team LP drained per GAME second of overtime (1 per real second = 0.5 per game second). */
get bossOvertimeDrain() { return this.bossOvertimeDrainReal / this.combatTimeScale; }
/**
* Team LP the overtime drain has taken when a boss field clock reads `gt` game seconds: bossOvertimeDrainReal per
* whole REAL second past bossOvertimeAfterReal (the first point at 151 real s).
*/
bossOvertimeDue(gt) {
const over = (Number(gt) || 0) / this.combatTimeScale - this.bossOvertimeAfterReal;
return over >= 1 ? Math.floor(over) * this.bossOvertimeDrainReal : 0;
}
get dp() {
const d = this.config.dp && typeof this.config.dp === 'object' ? this.config.dp : {};
return { dpInit: numOr(d.init, 10), dpPerSec: numOr(d.perSec, 1), dpMax: numOr(d.max, 99) };
}
get unite() {
const u = this.config.unite && typeof this.config.unite === 'object' ? this.config.unite : {};
return {
maxHelpers: posIntOr(u.maxHelpers, DEFAULTS.unite.maxHelpers),
templates: u.templates && typeof u.templates === 'object' ? u.templates : DEFAULTS.unite.templates,
};
}
get hiddenCore() {
const h = this.config.hiddenCore && typeof this.config.hiddenCore === 'object' ? this.config.hiddenCore : {};
return {
single: numOr(h.single, DEFAULTS.hiddenCore.single),
multi: numOr(h.multi, DEFAULTS.hiddenCore.multi),
minTeamLpExclusive: numOr(h.minTeamLpExclusive, DEFAULTS.hiddenCore.minTeamLpExclusive),
difficulties: Array.isArray(h.difficulties) ? h.difficulties : DEFAULTS.hiddenCore.difficulties,
};
}
bans(difficulty) {
const b = this.config.bans && this.config.bans[difficulty];
const d = DEFAULTS.bans[difficulty] || { core: 0, addon: 0 };
if (!b || typeof b !== 'object') return { ...d };
return { core: Number.isInteger(b.core) && b.core >= 0 ? b.core : d.core, addon: Number.isInteger(b.addon) && b.addon >= 0 ? b.addon : d.addon };
}
get bandDraft() {
const b = this.config.bandDraft && typeof this.config.bandDraft === 'object' ? this.config.bandDraft : {};
return {
skipsPerPlayer: Number.isInteger(b.skipsPerPlayer) && b.skipsPerPlayer >= 0 ? b.skipsPerPlayer : DEFAULTS.bandDraft.skipsPerPlayer,
timeoutBandId: typeof b.timeoutBandId === 'string' && this.band(b.timeoutBandId) ? b.timeoutBandId : this.defaultBandId,
};
}
/** Bosses weights for the boss round / hidden round. */
bossWeights(hidden = false) {
const w = hidden ? this.mode.hiddenBossWeights : this.mode.bossWeights;
return w && typeof w === 'object' ? Object.entries(w).filter(([id, v]) => this.boss(id) && Number(v) > 0) : [];
}
/** Band usable in this mode type. */
bandAllowed(bandId) {
const b = this.band(bandId);
if (!b) return false;
const list = Array.isArray(b.modeTypeList) ? b.modeTypeList : null;
if (!list) return true;
return list.includes(this.isSolo ? 'SINGLE' : 'MULTI');
}
bandIds() {
const bands = this.raw.bands && typeof this.raw.bands === 'object' ? this.raw.bands : {};
return Object.keys(bands).filter((id) => this.bandAllowed(id)).sort((a, b) => numOr(bands[a].sortId, 99) - numOr(bands[b].sortId, 99) || (a < b ? -1 : 1));
}
startLp(bandId) {
const b = this.band(bandId);
return b && Number.isInteger(b.totalHp) && b.totalHp > 0 ? b.totalHp : this.defaultStartLp;
}
/**
* Placeable (hand) tokens a chess sends to the hand when placed on the board: [{ tokenId, count }] — its TALENT
* summons (tokens.json `placeable`: 海嗣 / 狼群 / 流形); a skill's summon (赫默's drone, 巫恋's doll) only appears in
* battle when the skill fires (tools/build-data.mjs buildTokens).
*/
placeableTokens(chessId) {
const c = this.chess(chessId);
if (!c || !Array.isArray(c.tokens)) return [];
const out = [];
for (const tid of c.tokens) {
const t = this.token(tid);
if (!t || t.kind !== 'summon' || t.displayType !== 'DEFAULT' || t.placeable !== true) continue;
const count = posIntOr(t.deployLimit, 1);
out.push({ tokenId: tid, count: Math.min(count, 9) });
}
return out;
}
}