- #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)
460 lines
23 KiB
JavaScript
460 lines
23 KiB
JavaScript
// 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;
|
||
}
|
||
}
|