Files
ark-stronghold-protocol/server/match/gamedata.js
T
sganggs 620f88c0d8 Playtest #6 follow-up (v2.5.1): leader HP review — 直接乘算, one debuff instance, 999 layer cap, 限伤; elite to the board; 假想敌:胄 kit
- co-op leaders died within seconds: leader HP is the official data (40 values pinned); bond / strategy / item /
  机变 / 特质 "+X%" bonuses now add up (PRTS 直接乘算) instead of compounding; a same-named enemy debuff keeps one
  (strongest) instance per target (奥术 across a pair field); leader parts pass damage 1:1; fixed co-op pool (the user's call)
- official limits read from the client: each bond stops at 999 layers (MAX_GARRISON_STACK); a leader hit of 300000+ in
  boss / hidden battles is cancelled (MAX_BATTLE_DAMAGE, 限伤) — shared/constants.js switches, research 11
- a merge that consumes a deployed copy puts the elite on that tile and facing (user-confirmed); gold target highlight,
  promotion effect
- 假想敌:胄 per PRTS: one shell, shells within range, any drone death costs 2 % max HP, 剑/锤 sorties and 瘫痪
- DESIGN §20.10–§20.14; 2842 unit tests, all 28 browser suites green; Chrome = Node on boss fields
2026-10-02 04:19:28 +08:00

489 lines
25 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` / `bossPoolShare` for the official pool.
* @deprecated
*/
bossHpMul(bossId) { // eslint-disable-line no-unused-vars
return 1;
}
/**
* Official shared leader HP pool (DESIGN §20.10): ONE pool for every boss field of the match (official tip "最终攻势中,
* 所有人将一起对敌方领袖造成伤害"; the mirrored copies of a pair field share it — notice 5114 "两侧的敌方领袖共享生命值
* (敌方领袖的总生命值不变)", which is about those copies, not about the number of players). Co-op = bloodPoint
* [difficulty]; with config bossHpScale.aliveScaling (default false) × alive / aliveFull (4) — 巴哈姆特 12294 "聯機隊友
* (撤退/死掉)變少,最後boss血條也會變少" is one community note without a proportion, kept off until confirmed (it would
* shorten fights after eliminations, the opposite of the playtest report); `aliveCount` omitted ⇒ a full team. Solo = bloodPoint ×
* bossHpScale.solo (0.25 = one player of four, [ASSUMED]). Leaders are never scaled by enemyScale ("领袖单位于服务器的
* 生命值加成不受上述加成影响").
* @param {string} bossId
* @param {number} [aliveCount] alive players at the Final Assault / Hidden Core start (co-op)
* @returns {number}
*/
bossPoolHp(bossId, aliveCount) {
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;
return Math.max(1, Math.round(base * this.bossPoolShare(aliveCount)));
}
/**
* Multiplier of bloodPoint for the leader pool (see bossPoolHp): solo = bossHpScale.solo (0.25); co-op = coop (1) ×
* min(alive, aliveFull) / aliveFull when bossHpScale.aliveScaling (mode entry first, then the global one).
* @param {number} [aliveCount]
*/
bossPoolShare(aliveCount) {
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);
if (this.isSolo) return pick('solo', 0.25);
const scaling = typeof ms.aliveScaling === 'boolean' ? ms.aliveScaling : cs.aliveScaling === true;
const full = Math.max(1, Math.floor(pick('aliveFull', 4)));
const n = Number(aliveCount);
const alive = scaling && Number.isFinite(n) && n >= 1 ? Math.min(full, Math.floor(n)) : full;
return pick('coop', 1) * (alive / full);
}
/** 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 manually
* deployable summons (tokens.json `placeable`: 医疗探机, 诅咒娃娃, 海嗣, 狼群, 流形, 爬行号·防护单元; user playtest #6)
* that the chess makes under `loadout` ({ skillIndex } from shared/protocol.js resolveLoadout; absent ⇒ its default
* skill): the owner variant's `sources` (`bySkill[skillIndex]` for a non-default skill) name a talent or a skill —
* 赫默 / 巫恋 on S1 make no drone / doll. `count` = the summon's deploy limit (PRTS 卫戍协议/帮助 "根据召唤物部署数量
* 上限(非初始持有量),发送等量召唤物至手牌区": 凯瑟琳 2 of her 3 devices).
*/
placeableTokens(chessId, loadout = null) {
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.placeable !== true) continue;
const vs = t.variants && typeof t.variants === 'object' ? t.variants : {};
const v = vs[chessId] ?? vs[String(chessId).replace(/_b$/, '_a')] ?? null;
if (v) {
const alt = loadout && Number.isInteger(loadout.skillIndex) && v.bySkill ? v.bySkill[loadout.skillIndex] : null;
const src = Array.isArray(alt?.sources) ? alt.sources : Array.isArray(v.sources) ? v.sources : [];
if (!src.includes('talent') && !src.includes('skill')) continue;
}
const count = posIntOr(v?.stats?.deployLimit, posIntOr(t.deployLimit, 1));
out.push({ tokenId: tid, count: Math.min(count, 9) });
}
return out;
}
}