Files
ark-stronghold-protocol/server/match/finalAssault.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

151 lines
8.7 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/finalAssault.js — 最终攻势 (R14 / solo FUNNY R9) and 隐秘核心 (R15) helpers (DESIGN §6.1, research
// 00-INDEX §5, 06 §10).
//
// * Merged LP: teamLp = Σ LP of the alive players at the start of the Final Assault (no cap); the Hidden Core
// continues with what is left.
// * Pairing: alive players by seat → (1,2), (3,4); an odd player is alone on its own field with the `_s` template
// (solo modes always use `_s`). Field ids 'b1', 'b2'. In a pair the first player is the LEFT side, the second the
// RIGHT side (the sim mirrors the right side: board col c → field col 20 − c with the piece direction RIGHT ↔
// LEFT, UP / DOWN unchanged (DESIGN §3, research 09 §1.2 ConvertChessPositionInfoToBossMap); board rows 9–12 →
// boss rows 2–5, sim/constants BOSS_ROW_OFFSET). `bossFieldPlacement` gives that mapping for UIs / tools.
// * Shared boss HP pool (DESIGN §20.10, §25.13.4, gamedata.js bossPoolShareOf): one pool shared by every boss field
// (official tip "所有人将一起对敌方领袖造成伤害"; notice 5114's "敌方领袖的总生命值不变" is about the mirrored copies of
// a pair field sharing it) = bloodPoint[difficulty] × the players alive when the fight starts (bots and AI 托管 seats
// count, eliminated and departed seats do not; solo × 1) — the owner's decision of 2026-10-06, adopting PR #209 by
// @qingjingshenghuo, which replaces the fixed pool of 「保持固定血量」 (config bossHpScale perPlayer false / solo 0.25
// restores it); × the tuning bossHpMul when data/tuning.json still has one (docs/BALANCE.md); bosses are never scaled
// by enemyScale and their parts / escorts keep their own HP.
// * Overtime: bossTurnHpReduceTime counts REAL seconds like the level's 120 s maxPlayTime (which runs out first; the
// battle goes on): from 150 real s (300 game s on the 2× field clock) the team loses bossOvertimeDrainPerSec (1) LP
// per real second (gamedata.js bossOvertimeDue); m.public.deadline = the 120 s countdown, m.public.overtimeAt = the
// drain start. Minion / boss leaks cost their `lpr`; team LP 0 ⇒ defeat (all fields force-ended; PRTS 卫戍协议:盟约 下半
// "…使目标生命值扣除至0,则无视倒计时直接失败"); pool 0 ⇒ victory. The first of the two the server registers decides
// (Match._finalEnding): a report that arrives after the team LP ran out credits nothing (user playtest #6 item 5).
// * Pool HP is a float that never keeps float dust: the hit that would leave less than BOSS_POOL_MIN_HP (1) takes the
// rest, so the pool is 0 or ≥ 1 and a leader shown at 0 HP is down (sim/constants.js; the browser's LocalBossPool
// follows the same rule, server/sim/spec.js). Before, the per-player crediting of a report could leave 3.6e-12 that
// the browser could never deal (user playtest #6 item 5).
// * No IN_BATTLE layer gains (flags.layerGainsEnabled = false).
// * BOSS_HIT tickers (BOSS_HIT_STEPS 20 / 50 / 80 %): a player's damage to the round's pool (SharedBossPool.byPlayer)
// over its size, each threshold once per boss round — the Hidden Core never counts the Final Assault's damage
// (player report after 0.1.0: "隐藏boss还没打就出了造成50%伤害播报").
// * Hidden Core eligibility (after an R14 win): difficulty in hiddenCore.difficulties, the mode has a hidden round,
// Σ activated layers of the alive players measured at the end of the boss round's prep > threshold (solo 350 /
// co-op 1200) and team LP > minTeamLpExclusive (1).
import { BOSS_ROW_OFFSET, COLS, BOSS_POOL_MIN_HP } from '../sim/constants.js';
import { mirrorDir, normDir } from '../sim/dir.js';
import { bossPoolShareOf } from './gamedata.js';
/**
* BOSS_HIT ticker thresholds (activity_table autoChessData.broadcastList comment_boss_hit_1..3, paramList 0.2 / 0.5 /
* 0.8: "{0}博士对敌方领袖造成的伤害超过20%!"): a player's damage to the current leader's pool over that pool
* (SharedBossPool.byPlayer — one pool per boss round, so the Final Assault and the Hidden Core count apart).
*/
export const BOSS_HIT_STEPS = [0.2, 0.5, 0.8];
/**
* Where a board piece (board row / col / dir) stands on a boss field for side 'L' | 'R' — the same mapping as the
* sim (Battle.mapTile / mapDir): rows 9–12 → 2–5; the right side mirrored col c → 20 − c, RIGHT ↔ LEFT.
* @returns {{ row: number, col: number, dir: 'UP'|'RIGHT'|'DOWN'|'LEFT' }}
*/
export function bossFieldPlacement(side, row, col, dir = 'RIGHT') {
const d = normDir(dir);
const r = row >= 7 ? row + BOSS_ROW_OFFSET : row;
return side === 'R' ? { row: r, col: COLS - 1 - col, dir: mirrorDir(d) } : { row: r, col, dir: d };
}
/** Pair alive players by seat: [[a, b], [c, d]] / [[a, b], [c]] / [[a]]. */
export function pairPlayers(alive) {
const sorted = alive.slice().sort((a, b) => a.seat - b.seat);
const groups = [];
for (let i = 0; i < sorted.length; i += 2) groups.push(sorted.slice(i, i + 2));
return groups;
}
/** Shared boss HP for a boss id with `aliveCount` alive players (GameData.bossPoolShare / bossPoolShareOf; omitted ⇒ a full team). */
export function bossPoolHp(gd, bossId, aliveCount) {
const boss = gd.boss(bossId);
const diff = gd.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 tune = typeof gd.bossHpMul === 'function' ? gd.bossHpMul(bossId) : 1;
const share = typeof gd.bossPoolShare === 'function' ? gd.bossPoolShare(aliveCount)
: bossPoolShareOf(gd.mode && gd.mode.bossHpScale, gd.config && gd.config.bossHpScale, !!gd.isSolo, aliveCount);
return Math.max(1, Math.round(base * share * tune));
}
/**
* The one HP pool every boss field damages. `damage(playerId, amount)` is called by the sim (server-run fields) and by
* Match._creditBoss (client reports, per player); the hit that would leave less than BOSS_POOL_MIN_HP takes the rest.
*/
export class SharedBossPool {
constructor(hp, { onHit = null } = {}) {
this.maxHp = Math.max(1, hp);
this.hp = this.maxHp;
/** playerId → damage dealt to this pool (this boss round only; the BOSS_HIT tickers' share) */
this.byPlayer = new Map();
this.onHit = onHit;
}
damage(playerId, amount) {
const a = Number(amount);
if (!Number.isFinite(a) || a <= 0 || this.hp <= 0) return 0;
const dealt = this.hp - a < BOSS_POOL_MIN_HP ? this.hp : a;
this.hp -= dealt; // exactly 0 when the rest is taken (x − x): no float dust
if (playerId != null) this.byPlayer.set(playerId, (this.byPlayer.get(playerId) || 0) + dealt);
if (this.onHit) {
try { this.onHit(playerId, dealt); } catch { /* reported by the caller */ }
}
return dealt;
}
}
/**
* Hidden-core eligibility.
* @param {import('./gamedata.js').GameData} gd
* @param {{ layerSum: number, teamLp: number }} s
*/
export function hiddenEligible(gd, { layerSum, teamLp }) {
const hc = gd.hiddenCore;
if (!gd.hiddenRound || !hc.difficulties.includes(gd.difficulty)) return false;
const threshold = gd.isSolo ? hc.single : hc.multi;
return layerSum > threshold && teamLp > hc.minTeamLpExclusive;
}
/**
* A boss field the server takes over mid-fight (its client left, DESIGN §14) re-simulates its spec from t = 0. The
* damage its client already reported (`acked`, cumulative, per player in `ackedBy`) is in the shared pool, so this
* wrapper credits the real pool only with what the re-simulation deals beyond it. Reads (hp, maxHp) go to the pool.
*/
export class CreditPool {
/**
* @param {SharedBossPool} pool
* @param {{ acked?: number, ackedBy?: Record<string, number>, onCredit?: (playerId: string|null, amount: number) => void }} [o]
*/
constructor(pool, { acked = 0, ackedBy = {}, onCredit = null } = {}) {
this.pool = pool;
this.acked = Math.max(0, Number(acked) || 0);
this.ackedBy = { ...ackedBy };
this.cum = 0;
this.onCredit = onCredit;
}
get hp() { return this.pool.hp; }
set hp(v) { /* the sim's fallback path writes hp only when damage() threw; the real pool is never written here */ void v; }
get maxHp() { return this.pool.maxHp; }
damage(playerId, amount) {
const a = Number(amount);
if (!Number.isFinite(a) || a <= 0) return 0;
const before = this.cum;
this.cum += a;
const credit = Math.max(0, this.cum - Math.max(before, this.acked));
if (credit <= 0) return 0;
const dealt = this.pool.damage(playerId, credit);
if (dealt > 0 && this.onCredit) { try { this.onCredit(playerId, dealt); } catch { /* reported by the caller */ } }
return dealt;
}
}