Files
ark-stronghold-protocol/server/match/gamedata.js
T
sganggs 4e58481706 feedback5 WY: the Final Assault / Hidden Core leader pool is bloodPoint × the players alive when the fight starts (solo × 1), replacing 「保持固定血量」; 胄's drone share passes 限伤 (from PR #209 by @qingjingshenghuo; the owner's decision of 2026-10-06)
Ported by hand from PR #209 by @qingjingshenghuo (its one-line data/config.json was not taken):
- server/match/gamedata.js bossPoolShareOf (used by GameData.bossPoolShare and by finalAssault.bossPoolHp for a plain
  game-data object): solo × `solo` (1); co-op with `perPlayer` true × `coop` (1) × the players alive at the boss
  round's start (bots and AI 托管 seats count, eliminated / departed seats do not; at most aliveFull 4; a count left
  out = a full team). The fixed pool stays restorable through config: bossHpScale perPlayer false + solo 0.25 (and its
  optional aliveScaling).
- tools/build-data.mjs: the global bossHpScale carries the rule (perPlayer true, solo 1); the modes' entries keep only
  bloodPointKey / unaffectedByEnemyScale. data/config.json regenerated offline — JSON compare: only bossHpScale (global
  and the 9 mode entries) changed.
- The problem #209 documented: a 胄 drone death is 2 % of the pool and 限伤 cancels a hit of ceil ≥ 300000, so the
  hidden 胄 终极 pool at 3 / 4 players (21.6M / 28.8M) would cancel every drone (432000 / 576000). The drone link is
  a share of the pool, no hit: Battle.loseHp takes noHitLimit and the link passes the limit [ASSUMED: the official
  client checks every damage modifier and PRTS calls it 真实伤害, but which max HP it reads is undocumented]; a part's
  传递 and every hit stay under the limit.

Golden (regenerated; why: the pool size):
- fields: boss-boss_1..7-pair/-solo and hidden-boss_8..10-pair/-solo (all 20 boss / hidden scenarios) — a pair
  field's pool × 2 (two players), a solo template's × 4 (solo × 1, was × 0.25); boss-boss_1-pair, boss-boss_1-solo,
  boss-boss_3-pair, boss-boss_3-solo, boss-boss_5-solo, boss-boss_7-pair and hidden-boss_9-solo now end at the
  200 s cap instead of clearing; the two 联防 scenarios are unchanged.
- matches: solo-FUNNY-2, solo-NORMAL-1, solo-HARD-1, solo-HARD-2 now lose the Final Assault (solo leaders have 4× the
  HP: team LP 0 with 93088–176395 of the pool left); solo-FUNNY-1, solo-NORMAL-2, solo-HARD-9-boosted (its Hidden Core
  too), coop2-NORMAL-3, coop4-FUNNY-6, coop2-ABYSS-10-boosted, coop2-NORMAL-70-diy still win with more leader damage
  (pool × players alive; solo-FUNNY-1 / solo-HARD-9-boosted end with 6 / 1 team LP less). Unchanged:
  coop2-NORMAL-8-serverrun and coop2-NORMAL-14-standins (one player left at R14: × 1 as before) and the matches that
  end before R14; roster / bonds / standins / diy unchanged.

Tests: test/match/boss-hp-scaling.test.js (from PR #209, adapted — every leader and difficulty × 1–4 players and solo;
R14 / R15 leave out two departed players; AI 托管 and bots count, an eliminated player does not; config restores the
fixed pool, the opposite of the PR's test; the plain-object fallback agrees; only the leader's HP scales);
test/content/enemies_bosses.test.js (hidden 胄 终极 at 3 / 4 players: the drone share lands, a hit of the same size is
cancelled — fails without the change). Updated on purpose: finalAssault, balance, bosshp, connection, docs-consistency
(the §20.9 record points to §25.13.4; the data carries perPlayer true), sim/playtest6_limits (the drone share lands
past the line), match/playtest6-bosspool (the 5 % knob halved to keep the 2-player pool's size: that test is about the
pool's arithmetic). Docs: PLAYING §7, DATA, META, BALANCE, SIM, design engine.md / network.md, the §20.9 / §20.10
records, research 00 / 01 / 08, the code comments, DESIGN §25.13.4 and the §25.10 table.
2026-10-06 21:41:49 +08:00

549 lines
28 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 : {};
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;
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;
}
/**
* 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;
}
}