Files
ark-stronghold-protocol/server/match/gamedata.js
T
sganggs 221e6b8dd2 0.2.5-dev: community round 30 and follow-ups
- Stock: fixed trait gifts (缪尔赛思 / 变形者集群 变形同构体, 耶拉 不融冰, 蜜蜡 / 卡涅利安 迅捷作战粮, 诗怀雅 盟约之币) still occupy stock but are granted past the cap; an operator card already shown in the shop or a reward can be taken after the pool runs out and overdraws (GitHub #498, the owner's decision); item buys and item rewards keep the stock check. 洛洛 / 焰尾 sub-pools use the measured weights (PR #497 by @2321Robin; the occupancy idea of PR #496 by @2321Robin re-implemented differently).
- Boss pairs are drawn at random once per match and fixed to battle start; the Hidden Core reuses them; a leaver does not split a full pair (the owner's decision).
- 阿戈尔 in 联防 no longer re-applies the opening devour HP loss or a false revive; Touch's team medic stays for the match after its holder leaves (owner-approved, official lifetime unverified).
- Auto-skill ranges: 司霆惊蛰 S2, 斩业星熊 S3 against air, 予愿安洁莉娜 S3; 自在's post-rebirth invulnerability is also untargetable (#510); Horn / Clementia / catapult / 忍冬 first hit (PR #502 by @Xinglan233); 不融冰 / tear gas trigger on real damage.
- Bond layer-up sound in round start / draft / prep / battle (#482): one channel, a new cue replaces the old, no extra cooldown; a prep update that adds layers and activates a bond plays one cue.
- Client: multi-swing fix (#506), refresh single-flight (PR #507 by @robll-v1), LP tower keeps the settled value with a −N tick (PR #504 by @robll-v1), export with only 潜能 / 练度 changes (PR #503 by @Xinglan233), spectator rosters list DIY members (PR #505 by @Xinglan233), banned members beside bond tooltips (PR #494 by @YuriRestia), recovery action above the away overlay (PR #511 by @RainoutOvO), the /dev/sim-check.html consistency page (PR #492 by @Cloudnyco), Korean (PR #490 by @D-Roote) and English Victoria wording (PR #499 by @YuriRestia).
- golden: 45 of 287 scenarios move against 0.2.4 (roster 17, bonds 3, fields 6, matches 17, standins 2), each attributed in docs/history/0.2.5.md §30.9 / §30.16.
2026-10-12 14:07:08 +08:00

572 lines
30 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 per player alive at the fight's start
// (bossPoolShareOf). 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';
import { standInRecord } from '../../shared/standIn.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;
/**
* Multiplier of bloodPoint[difficulty] for the shared leader pool (DESIGN §20.10, §25.13.4), from config bossHpScale —
* each key from the mode's entry first, then the global one; the defaults are the current rule:
* solo `solo` (1: the table value — one player's share);
* co-op `perPlayer` true (the owner's decision of 2026-10-06, adopting PR #209 by @qingjingshenghuo): `coop` (1) × the
* players alive when the fight starts — bots and AI 托管 seats count, eliminated and departed seats do not —
* at most `aliveFull` (4); a count left out means a full team;
* `perPlayer` false: the fixed pool of 0.1.x (「保持固定血量」, restorable with `solo` 0.25): `coop` whatever the
* count, × min(alive, aliveFull) / aliveFull with `aliveScaling`.
* @param {object|null|undefined} modeScale config.modes[modeId].bossHpScale
* @param {object|null|undefined} cfgScale config.bossHpScale
* @param {boolean} isSolo
* @param {number} [aliveCount] alive players at the Final Assault / Hidden Core start (co-op)
* @returns {number}
*/
export function bossPoolShareOf(modeScale, cfgScale, isSolo, aliveCount) {
const ms = modeScale && typeof modeScale === 'object' ? modeScale : {};
const cs = cfgScale && typeof cfgScale === 'object' ? cfgScale : {};
const pick = (k, d) => (Number.isFinite(ms[k]) && ms[k] > 0 ? ms[k] : Number.isFinite(cs[k]) && cs[k] > 0 ? cs[k] : d);
const flag = (k, d) => (typeof ms[k] === 'boolean' ? ms[k] : typeof cs[k] === 'boolean' ? cs[k] : d);
if (isSolo) return pick('solo', 1);
const full = Math.max(1, Math.floor(pick('aliveFull', 4)));
const n = Number(aliveCount);
const alive = Number.isFinite(n) && n >= 1 ? Math.min(full, Math.floor(n)) : full;
if (flag('perPlayer', true)) return pick('coop', 1) * alive;
return pick('coop', 1) * (flag('aliveScaling', false) ? alive / full : 1);
}
/** 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 : {};
// Fixed operator gifts are unlimited outputs, but every produced normal/advanced item still occupies 1/2 copies
// (card-pool experiment BV1eLXXBqEgF, maintainer-confirmed 2026-10-11). Random subset gifts are not exempt.
this._fixedGiftItems = new Set(Object.values(this.raw.garrisons || {})
.filter(g => g.effectKey === 'SERVER_GAIN_EQUIP').map(g => g.bbStr?.chess).filter(id => this._items[id]));
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 : []);
/** bandBondIds memo */
this._bandBonds = new Map();
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 : {};
/** standIn memo: chess id → composed 补位 record | null */
this._standIns = new Map();
}
/**
* 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;
}
/**
* Shared leader HP pool (DESIGN §20.10, §25.13.4): 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). Size = bloodPoint[difficulty] × bossPoolShare: the table value
* per player alive when the fight starts (the owner's decision of 2026-10-06, PR #209; it replaces the fixed pool of
* 「保持固定血量」, which config bossHpScale restores — bossPoolShareOf); `aliveCount` omitted ⇒ a full team. 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; the rule and the config keys: bossPoolShareOf).
* @param {number} [aliveCount]
*/
bossPoolShare(aliveCount) {
return bossPoolShareOf(this.mode.bossHpScale, this.config.bossHpScale, this.isSolo, aliveCount);
}
/** 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 : {}; }
/**
* The 补位 record of chess `id` (normal or elite; DATA.md §18): shared/standIn.js standInRecord over data/backups.json
* — the chess's identity (ids, tier, bonds, 特质, price, merge) with its official stand-in's body (stats, range, skills
* with the backup skill as the default, talents, module, art, `standInFor`). Null for a PRESET / 自选 chess, an unknown
* id or data without backups.json. Memoized (frozen records).
* @param {string} id
* @returns {object|null}
*/
standIn(id) {
if (typeof id !== 'string') return null;
if (this._standIns.has(id)) return this._standIns.get(id);
const c = this.chess(id);
const backups = this.raw.backups && typeof this.raw.backups === 'object' ? this.raw.backups : null;
let rec;
try { rec = c && backups ? standInRecord(c, backups) : null; } catch { rec = null; }
if (rec) Object.freeze(rec);
this._standIns.set(id, rec);
return rec;
}
/** 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;
const tier = this.tierOf(baseId);
const pc = this.economy.poolCopies;
const v = ov && Number.isInteger(ov[baseId]) && ov[baseId] >= 0 ? ov[baseId] : pc?.[tier];
const base = Number.isInteger(v) && v >= 0 ? v : (DEFAULTS.poolCopies[tier] ?? 10);
// The measured doubled pool is shared by the whole co-op team, independent of team size. DIY is private stock.
const multiplier = this.isSolo || this.chess(baseId)?.isDiy ? 1 : posIntOr(this.economy.coopPoolMultiplier, 1);
return base * multiplier;
}
/** Normal equipment copies in the shared pool; effect-only items have no shop stock limit. */
itemPoolCopies(id) {
const base = this.baseIdOf(id), rec = this.item(base);
if (!rec || !isShopItem(rec)) return null;
const count = this.economy.itemPoolCopiesOverrides?.[base] ?? this.economy.itemPoolCopies?.[rec.tier];
return Number.isInteger(count) && count >= 0 ? count : null;
}
/** Whether a fixed trait gift may be produced beyond stock (including the advanced version of that item). */
isUnlimitedGiftItem(id) { return this._fixedGiftItems.has(this.baseIdOf(id)); }
/** 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). `supplyHpMul`
* (only when ≠ 1: co-op 终极 R5–R15) = the share of hpMul from 补给线 / 补给线II (config `supplyHp`), effects whose
* `enemy_exclude` leaves out the 14 器物 hit-count keys — they take hpMul / supplyHpMul (archetypes.js `times`).
*/
baseEnemyScale(r) {
const e = this.mode.enemyScale && this.mode.enemyScale[String(r)];
if (!e || typeof e !== 'object') return { hpMul: 1, atkMul: 1, speedMul: 1 };
const supply = numOr(e.supplyHp, 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)),
...(supply > 0 && supply !== 1 ? { supplyHpMul: supply } : {}),
};
}
/** 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, afterReal = this.bossOvertimeAfterReal) {
const over = (Number(gt) || 0) / this.combatTimeScale - afterReal;
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;
}
/**
* The bonds a strategy's mechanic is built around (DESIGN §21.26): bands.json `bondIds`, written at build time by
* shared/bandBonds.js from the band's own text and blackboards (潘格尼尼 → 拉特兰, 克莱门莎 → 阿戈尔, 玛恩纳 → 卡西米尔 …) —
* the field the strategy draft's 本局禁用 mark reads too. Known bond ids in data order; [] for an unknown band, one tied to
* no bond (华法琳, 阿米娅 …) or data without the field. The bot never picks a strategy tied to a bond the mode switches
* off (bot.js botPickBand).
* @param {string} bandId
* @returns {string[]}
*/
bandBondIds(bandId) {
if (this._bandBonds.has(bandId)) return this._bandBonds.get(bandId);
const listed = this.band(bandId)?.bondIds;
const set = new Set(Array.isArray(listed) ? listed : []);
const out = Object.freeze(this.bondIds.filter((id) => set.has(id)));
this._bandBonds.set(bandId, out);
return out;
}
/**
* 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;
}
}